From 583745e6ca65c4c5e2c17e46838ee0ec14f0fa65 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Mon, 18 Dec 2023 20:22:26 +0100 Subject: [PATCH] feat: handle python input synchronously (#52526) Co-authored-by: Shaun Hamilton --- client/package.json | 4 +- client/serve/serve.json | 2 +- .../Challenges/classic/desktop-layout.tsx | 6 +- .../Challenges/classic/mobile-layout.tsx | 6 +- .../src/templates/Challenges/classic/show.tsx | 25 +- .../templates/Challenges/classic/xterm.tsx | 102 ++++++ .../Challenges/components/preview-portal.tsx | 5 + .../rechallenge/transform-python.js | 120 ------- .../rechallenge/transform-python.test.ts | 41 --- .../Challenges/rechallenge/transformers.js | 16 +- .../redux/execute-challenge-saga.js | 33 +- .../src/templates/Challenges/utils/build.ts | 61 ++-- .../src/templates/Challenges/utils/frame.ts | 6 +- .../Challenges/utils/python-worker-handler.ts | 65 ++++ client/static/python-input-sw.js | 23 ++ .../64b163c20e59cbd4a64940b0.md | 36 ++- curriculum/test/test-challenges.js | 90 +++--- pnpm-lock.yaml | 217 ++++++++----- .../client-plugins/browser-scripts/index.d.ts | 1 - .../browser-scripts/python-runner.ts | 299 ------------------ .../browser-scripts/python-test-evaluator.ts | 166 ++++++++++ .../browser-scripts/python-worker.ts | 81 +++++ .../browser-scripts/tsconfig.json | 1 + .../browser-scripts/webpack.config.js | 14 +- 24 files changed, 760 insertions(+), 660 deletions(-) create mode 100644 client/src/templates/Challenges/classic/xterm.tsx delete mode 100644 client/src/templates/Challenges/rechallenge/transform-python.js delete mode 100644 client/src/templates/Challenges/rechallenge/transform-python.test.ts create mode 100644 client/src/templates/Challenges/utils/python-worker-handler.ts create mode 100644 client/static/python-input-sw.js delete mode 100644 tools/client-plugins/browser-scripts/python-runner.ts create mode 100644 tools/client-plugins/browser-scripts/python-test-evaluator.ts create mode 100644 tools/client-plugins/browser-scripts/python-worker.ts diff --git a/client/package.json b/client/package.json index 2bb60e985f0..520c4d6f102 100644 --- a/client/package.json +++ b/client/package.json @@ -136,7 +136,9 @@ "typescript": "5.2.2", "util": "0.12.5", "uuid": "8.3.2", - "validator": "13.11.0" + "validator": "13.11.0", + "xterm": "^5.2.1", + "xterm-addon-fit": "^0.8.0" }, "devDependencies": { "@babel/plugin-syntax-dynamic-import": "7.8.3", diff --git a/client/serve/serve.json b/client/serve/serve.json index 66c25ffdb32..51935a93b9d 100644 --- a/client/serve/serve.json +++ b/client/serve/serve.json @@ -20,7 +20,7 @@ ] }, { - "source": "{misc/*.js,sw.js}", + "source": "{misc/*.js,sw.js,python-input-sw.js}", "headers": [ { "key": "Cache-Control", diff --git a/client/src/templates/Challenges/classic/desktop-layout.tsx b/client/src/templates/Challenges/classic/desktop-layout.tsx index ad38207c31b..82ddedc9485 100644 --- a/client/src/templates/Challenges/classic/desktop-layout.tsx +++ b/client/src/templates/Challenges/classic/desktop-layout.tsx @@ -45,6 +45,7 @@ interface DesktopLayoutProps { testsPane: Pane; }; notes: ReactElement; + onPreviewResize: () => void; preview: ReactElement; resizeProps: ResizeProps; testOutput: ReactElement; @@ -149,6 +150,7 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => { isFirstStep, layoutState, notes, + onPreviewResize, preview, hasEditableBoundaries, windowTitle @@ -285,7 +287,9 @@ const DesktopLayout = (props: DesktopLayoutProps): JSX.Element => { )} {displayPreviewPortal && ( - {preview} + + {preview} + )} ); diff --git a/client/src/templates/Challenges/classic/mobile-layout.tsx b/client/src/templates/Challenges/classic/mobile-layout.tsx index ec8a679ede7..b2a4af0655a 100644 --- a/client/src/templates/Challenges/classic/mobile-layout.tsx +++ b/client/src/templates/Challenges/classic/mobile-layout.tsx @@ -30,6 +30,7 @@ interface MobileLayoutProps { instructions: JSX.Element; notes: ReactElement; preview: JSX.Element; + onPreviewResize: () => void; windowTitle: string; showPreviewPortal: boolean; showPreviewPane: boolean; @@ -157,6 +158,7 @@ class MobileLayout extends Component { hasPreview, notes, preview, + onPreviewResize, showPreviewPane, showPreviewPortal, removePortalWindow, @@ -328,7 +330,9 @@ class MobileLayout extends Component { )} {displayPreviewPortal && ( - {preview} + + {preview} + )} ); diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx index e831ff40bd8..1c9776867d2 100644 --- a/client/src/templates/Challenges/classic/show.tsx +++ b/client/src/templates/Challenges/classic/show.tsx @@ -9,6 +9,8 @@ import { bindActionCreators, Dispatch } from 'redux'; import { createStructuredSelector } from 'reselect'; import store from 'store'; import { editor } from 'monaco-editor'; +import type { FitAddon } from 'xterm-addon-fit'; + import { challengeTypes } from '../../../../../shared/config/challenge-types'; import LearnLayout from '../../../components/layouts/learn'; import { MAX_MOBILE_WIDTH } from '../../../../config/misc'; @@ -56,6 +58,7 @@ import { } from '../redux/selectors'; import { savedChallengesSelector } from '../../../redux/selectors'; import { getGuideUrl } from '../utils'; +import { XtermTerminal } from './xterm'; import MultifileEditor from './multifile-editor'; import DesktopLayout from './desktop-layout'; import MobileLayout from './mobile-layout'; @@ -148,9 +151,16 @@ const handleContentWidgetEvents = (e: MouseEvent | TouchEvent): void => { const StepPreview = ({ disableIframe, - previewMounted -}: Pick) => { - return ( + previewMounted, + challengeType, + xtermFitRef +}: Pick & { + challengeType: number; + xtermFitRef: React.MutableRefObject; +}) => { + return challengeType === challengeTypes.python ? ( + + ) : ( (null); const editorRef = useRef(); const instructionsPanelRef = useRef(null); + const xtermFitRef = useRef(null); const isMobile = useMediaQuery({ query: `(max-width: ${MAX_MOBILE_WIDTH}px)` }); @@ -248,6 +259,8 @@ function ShowClassic({ return isValidLayout ? reflexLayout : BASE_LAYOUT; }; + const onPreviewResize = () => xtermFitRef.current?.fit(); + // layout: Holds the information of the panes sizes for desktop view const [layout, setLayout] = useState(getLayoutState()); @@ -438,10 +451,13 @@ function ShowClassic({ showToolPanel: false })} notes={} + onPreviewResize={onPreviewResize} preview={ } windowTitle={windowTitle} @@ -470,10 +486,13 @@ function ShowClassic({ isFirstStep={isFirstStep} layoutState={layout} notes={} + onPreviewResize={onPreviewResize} preview={ } resizeProps={resizeProps} diff --git a/client/src/templates/Challenges/classic/xterm.tsx b/client/src/templates/Challenges/classic/xterm.tsx new file mode 100644 index 00000000000..b33ec8fa53c --- /dev/null +++ b/client/src/templates/Challenges/classic/xterm.tsx @@ -0,0 +1,102 @@ +import React, { MutableRefObject, useEffect, useRef } from 'react'; +import type { IDisposable, Terminal } from 'xterm'; +import type { FitAddon } from 'xterm-addon-fit'; + +import { registerTerminal } from '../utils/python-worker-handler'; + +const registerServiceWorker = async () => { + if ('serviceWorker' in navigator) { + try { + await navigator.serviceWorker.register('/python-input-sw.js'); + } catch (error) { + console.error(`Registration failed`); + console.error(error); + } + } +}; + +export const XtermTerminal = ({ + xtermFitRef +}: { + xtermFitRef: MutableRefObject; +}) => { + const termContainerRef = useRef(null); + + useEffect(() => { + void registerServiceWorker(); + + let term: Terminal | null; + + async function createTerminal() { + const disposables: IDisposable[] = []; + const { Terminal } = await import('xterm'); + const { FitAddon } = await import('xterm-addon-fit'); + // Setting convertEol so that \n is converted to \r\n. Otherwise the terminal + // will interpret \n as line feed and just move the cursor to the next line. + // convertEol makes every \n a \r\n. + term = new Terminal({ convertEol: true }); + const fitAddon = new FitAddon(); + xtermFitRef.current = fitAddon; + term.loadAddon(fitAddon); + if (termContainerRef.current) term.open(termContainerRef.current); + fitAddon.fit(); + + const print = (text: string) => term?.writeln(`>>> ${text}`); + + // TODO: prevent user from moving cursor outside the current input line and + // handle insertion and deletion properly. While backspace and delete don't + // seem to work, we can use "\x1b[0K" to clear from the cursor to the end. + // Also, we should not add special characters to the userinput string. + const input = (text: string) => { + print(text); + let userinput = ''; + // Eslint is correct that this only gets assigned once, but we can't use + // const because the declaration (before keyListener is defined) and + // assignment (after keyListener is defined) must be separate. + // eslint-disable-next-line prefer-const + let disposable: IDisposable | undefined; + + const done = () => { + disposable?.dispose(); + navigator.serviceWorker.controller?.postMessage(userinput); + }; + + const keyListener = (key: string) => { + if (key === '\u007F' || key === '\b') { + // Backspace or delete key + term?.write('\b \b'); // Move cursor back, replace character with space, then move cursor back again + userinput = userinput.slice(0, -1); // Remove the last character from userinput + } + if (key == '\r') { + term?.write('\r\n'); + done(); + } else { + userinput += key; + term?.write(key); + } + }; + + disposable = term?.onData(keyListener); // Listen for key events and store the disposable + if (disposable) disposables.push(disposable); + }; + const reset = () => { + term?.reset(); + disposables.forEach(disposable => disposable.dispose()); + disposables.length = 0; + }; + registerTerminal({ print, input }, reset); + } + + void createTerminal(); + + return () => { + term?.dispose(); + }; + }, [xtermFitRef]); + + return ( +
+ +
+ ); +}; diff --git a/client/src/templates/Challenges/components/preview-portal.tsx b/client/src/templates/Challenges/components/preview-portal.tsx index 208fd6a0b41..32a7efa7b18 100644 --- a/client/src/templates/Challenges/components/preview-portal.tsx +++ b/client/src/templates/Challenges/components/preview-portal.tsx @@ -34,6 +34,7 @@ interface PreviewPortalProps { isAdvancing: boolean; setChapterSlug: (arg: string) => void; chapterSlug: string; + onResize: () => void; } const mapDispatchToProps = { @@ -136,6 +137,10 @@ class PreviewPortal extends Component { this.props.removePortalWindow(); }); + this.externalWindow?.addEventListener('resize', () => { + this.props.onResize(); + }); + this.props.storePortalWindow(this.externalWindow); // close the portal if the main window closes diff --git a/client/src/templates/Challenges/rechallenge/transform-python.js b/client/src/templates/Challenges/rechallenge/transform-python.js deleted file mode 100644 index abbcd162411..00000000000 --- a/client/src/templates/Challenges/rechallenge/transform-python.js +++ /dev/null @@ -1,120 +0,0 @@ -export const indent = (code, spaces) => { - const lines = code.split('\n'); - return lines.map(line => `${' '.repeat(spaces)}${line}`).join('\n'); -}; - -// Requirements: -// - run in a single instance of pyodide (because loadPyodide is slow) -// - be able to stop execution of learner code -// -// This wrapper lets us meet the second requirement, since tasks are -// cancellable. This creates a second issue: the learner code no longer modifies -// the global scope, so we need to copy the locals to globals. -// -// Finally, we have to await the task, or there's no way for the JavaScript -// context to know when the task is complete. -export const makeCancellable = code => `import asyncio -async def cancellable_coroutine(): - try: -${indent(code, 8)} - globals()['__locals'] = locals() - except asyncio.CancelledError: - pass - -__task = asyncio.create_task(cancellable_coroutine()) - -def __cancel(): - __task.cancel() -await __task`; - -export function modifyInputStatements(line) { - // Use a regular expression to match input statements with chained methods - const inputRegex = /(.*=\s*)input\((["'].*?["']\))(\.\w+\([^)]*\))*/; - const match = line.match(inputRegex); - if (match) { - const inputStatement = match[0]; - const varAssignment = match[1]; - const inputCall = - 'input' + - inputStatement - .slice(varAssignment.length) - .split('input')[1] - .split('.')[0]; - const methods = inputStatement - .slice(varAssignment.length + inputCall.length) - .split('.') - .slice(1); - const tempVar = '_temp_input_var'; - const newStatements = [ - `${tempVar} = ${inputCall}`, - ...methods.map(method => `${tempVar} = ${tempVar}.${method}`), - `${varAssignment.trim()} ${tempVar}` - ]; - // Get the indentation of the original line - const indentation = line.match(/^\s*/)[0]; - // Apply the same indentation to each new statement - const indentedStatements = newStatements.map(stmt => indentation + stmt); - // Replace the original input statement in the line with the temporary variable - const updatedLine = line.replace( - inputStatement, - indentedStatements.join('\n') - ); - return updatedLine.split('\n'); - } - return [line]; -} - -export function makeInputAwaitable(code) { - const lines = code.split('\n'); - const asyncFunctions = new Set(); - const modifiedLines = []; - - for (let i = 0; i < lines.length; i++) { - let line = lines[i]; - - // Modify input statements with chained methods - const updatedLines = modifyInputStatements(line); - - // If the line contains an input statement, update it to use "await" - if (updatedLines.some(updatedLine => updatedLine.includes('input('))) { - updatedLines.forEach((updatedLine, index) => { - if (updatedLine.includes('input(')) { - updatedLines[index] = updatedLine.replace('input(', 'await input('); - } - }); - - // Find the outer function definition and make it async - for (let j = i - 1; j >= 0; j--) { - if (lines[j].includes('def ')) { - if (!modifiedLines[j].includes('async def ')) { - const functionName = lines[j].match( - /def\s+([a-zA-Z_][a-zA-Z_0-9]*)/ - )[1]; - asyncFunctions.add(functionName); - modifiedLines[j] = modifiedLines[j].replace('def ', 'async def '); - } - break; - } - } - } - - // Update function calls to include 'await' for async functions - asyncFunctions.forEach(funcName => { - updatedLines.forEach((updatedLine, index) => { - if ( - updatedLine.includes(` ${funcName}(`) && - !updatedLine.includes(`await ${funcName}(`) - ) { - updatedLines[index] = updatedLine.replace( - `${funcName}(`, - `await ${funcName}(` - ); - } - }); - }); - - modifiedLines.push(...updatedLines); - } - - return modifiedLines.join('\n'); -} diff --git a/client/src/templates/Challenges/rechallenge/transform-python.test.ts b/client/src/templates/Challenges/rechallenge/transform-python.test.ts deleted file mode 100644 index 515874c9bd2..00000000000 --- a/client/src/templates/Challenges/rechallenge/transform-python.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -import { indent, makeCancellable } from './transform-python'; - -describe('transform-python', () => { - describe('indent', () => { - it('should indent n spaces', () => { - const inputCode = `def foo(): - print('bar')`; - const fourSpaces = ` def foo(): - print('bar')`; - const eightSpaces = ` def foo(): - print('bar')`; - - expect(indent(inputCode, 4)).toEqual(fourSpaces); - expect(indent(inputCode, 8)).toEqual(eightSpaces); - }); - }); - - describe('makeCancellable', () => { - it('should wrap a code string in a cancellable coroutine', () => { - const inputCode = `def foo(): - print('bar')`; - const wrappedCode = `import asyncio -async def cancellable_coroutine(): - try: - def foo(): - print('bar') - globals()['__locals'] = locals() - except asyncio.CancelledError: - pass - -__task = asyncio.create_task(cancellable_coroutine()) - -def __cancel(): - __task.cancel() -await __task`; - - expect(makeCancellable(inputCode)).toEqual(wrappedCode); - }); - }); -}); diff --git a/client/src/templates/Challenges/rechallenge/transformers.js b/client/src/templates/Challenges/rechallenge/transformers.js index f368311b187..2beebf9161a 100644 --- a/client/src/templates/Challenges/rechallenge/transformers.js +++ b/client/src/templates/Challenges/rechallenge/transformers.js @@ -19,7 +19,6 @@ import { compileHeadTail } from '../../../../../shared/utils/polyvinyl'; import createWorker from '../utils/worker-executor'; -import { makeCancellable, makeInputAwaitable } from './transform-python'; const { filename: sassCompile } = sassData; @@ -100,7 +99,6 @@ const NBSPReg = new RegExp(String.fromCharCode(160), 'g'); const testJS = matchesProperty('ext', 'js'); const testJSX = matchesProperty('ext', 'jsx'); const testHTML = matchesProperty('ext', 'html'); -const testPython = matchesProperty('ext', 'py'); const testHTML$JS$JSX = overSome(testHTML, testJS, testJSX); const replaceNBSP = cond([ @@ -306,17 +304,6 @@ const htmlTransformer = cond([ [stubTrue, identity] ]); -const transformPython = async function (file) { - const awaitableCode = makeInputAwaitable(file.contents); - const cancellableCode = makeCancellable(awaitableCode); - return transformContents(() => cancellableCode, file); -}; - -const pythonTransformer = cond([ - [testPython, transformPython], - [stubTrue, identity] -]); - export const getTransformers = loopProtectOptions => [ replaceNBSP, babelTransformer(loopProtectOptions), @@ -326,6 +313,5 @@ export const getTransformers = loopProtectOptions => [ export const getPythonTransformers = () => [ replaceNBSP, - partial(compileHeadTail, ''), - pythonTransformer + partial(compileHeadTail, '') ]; diff --git a/client/src/templates/Challenges/redux/execute-challenge-saga.js b/client/src/templates/Challenges/redux/execute-challenge-saga.js index 608f791df21..81532ad1085 100644 --- a/client/src/templates/Challenges/redux/execute-challenge-saga.js +++ b/client/src/templates/Challenges/redux/execute-challenge-saga.js @@ -33,7 +33,10 @@ import { updatePreview, updateProjectPreview } from '../utils/build'; -import { runPythonInFrame, mainPreviewId } from '../utils/frame'; +import { + getPythonWorker, + resetPythonWorker +} from '../utils/python-worker-handler'; import { executeGA } from '../../../redux/actions'; import { fireConfetti } from '../../../utils/fire-confetti'; import { actionTypes } from './action-types'; @@ -264,15 +267,13 @@ function* previewChallengeSaga({ flushLogs = true } = {}) { const portalDocument = yield select(portalDocumentSelector); const finalDocument = portalDocument || document; - yield call(updatePreview, buildData, finalDocument, proxyLogger); - - // Python challenges need to be created in two steps: - // 1) build the frame - // 2) evaluate the code in the frame. This is necessary to avoid - // recreating the frame (which is slow since loadPyodide takes a long - // time)on every change. + // Python challenges do not use the preview frame, they use a web worker + // to run the code. The UI is handled by the xterm component, so there + // is no need to update the preview frame. if (challengeData.challengeType === challengeTypes.python) { yield updatePython(challengeData); + } else { + yield call(updatePreview, buildData, finalDocument, proxyLogger); } } else if (isJavaScriptChallenge(challengeData)) { const runUserCode = getTestRunner(buildData, { @@ -306,19 +307,19 @@ function* updatePreviewSaga() { } function* updatePython(challengeData) { - const document = yield getContext('document'); // TODO: refactor the build pipeline so that we have discrete, composable // functions to handle transforming code, embedding it and building the // final html. Then we can just use the transformation function here. const buildData = yield buildChallengeData(challengeData); - const code = buildData.transformedPython; + resetPythonWorker(); + const worker = getPythonWorker(); + const code = { + contents: buildData.sources.index, + editableContents: buildData.sources.editableContents, + original: buildData.sources.original + }; + worker.postMessage({ code }); // TODO: proxy errors to the console - try { - yield call(runPythonInFrame, document, code, mainPreviewId); - } catch (err) { - console.log('Error evaluating python code', code); - console.log('Message:', err.message); - } } function* previewProjectSolutionSaga({ payload }) { diff --git a/client/src/templates/Challenges/utils/build.ts b/client/src/templates/Challenges/utils/build.ts index 97d4c276f63..7db9d5aa89b 100644 --- a/client/src/templates/Challenges/utils/build.ts +++ b/client/src/templates/Challenges/utils/build.ts @@ -1,13 +1,13 @@ import { challengeTypes } from '../../../../../shared/config/challenge-types'; import frameRunnerData from '../../../../../client/config/browser-scripts/frame-runner.json'; -import testEvaluatorData from '../../../../../client/config/browser-scripts/test-evaluator.json'; -import pythonRunnerData from '../../../../../client/config/browser-scripts/python-runner.json'; +import jsTestEvaluatorData from '../../../../../client/config/browser-scripts/test-evaluator.json'; +import pyTestEvaluatorData from '../../../../../client/config/browser-scripts/python-test-evaluator.json'; import { ChallengeFile as PropTypesChallengeFile, ChallengeMeta } from '../../../redux/prop-types'; -import { concatHtml, createPythonTerminal } from '../rechallenge/builders'; +import { concatHtml } from '../rechallenge/builders'; import { getTransformers, embedFilesInHtml, @@ -48,12 +48,16 @@ interface BuildOptions { usesTestRunner?: boolean; } -const { filename: testEvaluator } = testEvaluatorData; +interface WorkerConfig { + terminateWorker: boolean; + testEvaluator: string; +} + +const { filename: jsTestEvaluator } = jsTestEvaluatorData; +const { filename: pyTestEvaluator } = pyTestEvaluatorData; const frameRunnerSrc = `/js/${frameRunnerData.filename}.js`; -const pythonRunnerSrc = `/js/${pythonRunnerData.filename}.js`; - type ApplyFunctionProps = (file: ChallengeFile) => Promise; const applyFunction = @@ -143,6 +147,7 @@ const testRunners = { [challengeTypes.html]: getDOMTestRunner, [challengeTypes.backend]: getDOMTestRunner, [challengeTypes.pythonProject]: getDOMTestRunner, + [challengeTypes.python]: getPyTestRunner, [challengeTypes.multifileCertProject]: getDOMTestRunner }; // TODO: Figure out and (hopefully) simplify the return type. @@ -163,13 +168,37 @@ export function getTestRunner( function getJSTestRunner( { build, sources }: BuildChallengeData, { proxyLogger, removeComments }: TestRunnerConfig +) { + return getWorkerTestRunner( + { build, sources }, + { proxyLogger, removeComments }, + { testEvaluator: jsTestEvaluator, terminateWorker: true } + ); +} + +function getPyTestRunner( + { build, sources }: BuildChallengeData, + { proxyLogger, removeComments }: TestRunnerConfig +) { + return getWorkerTestRunner( + { build, sources }, + { proxyLogger, removeComments }, + { testEvaluator: pyTestEvaluator, terminateWorker: false } + ); +} + +function getWorkerTestRunner( + { build, sources }: Pick, + { proxyLogger, removeComments }: TestRunnerConfig, + { testEvaluator, terminateWorker }: WorkerConfig ) { const code = { contents: sources.index, - editableContents: sources.editableContents + editableContents: sources.editableContents, + original: sources.original }; - const testWorker = createWorker(testEvaluator, { terminateWorker: true }); + const testWorker = createWorker(testEvaluator, { terminateWorker }); type CreateWorker = ReturnType; @@ -203,7 +232,7 @@ async function getDOMTestRunner( type BuildResult = { challengeType: number; - build: string; + build?: string; sources: Source | undefined; }; @@ -282,10 +311,6 @@ function buildBackendChallenge({ url }: BuildChallengeData) { }; } -function getTransformedPython(challengeFiles: ChallengeFiles) { - return challengeFiles[0].contents; -} - export function buildPythonChallenge({ challengeFiles }: BuildChallengeData): Promise | undefined { @@ -298,14 +323,8 @@ export function buildPythonChallenge({ .then(checkFilesErrors) // Unlike the DOM challenges, there's no need to embed the files in HTML .then(challengeFiles => ({ - // TODO: Stop overwriting challengeType with 'html'. Figure out why it's - // necessary at the moment. - challengeType: challengeTypes.html, - // Both the terminal and pyodide are loaded into the browser, so we - // still need to build the HTML. - build: createPythonTerminal(pythonRunnerSrc), - sources: buildSourceMap(challengeFiles), - transformedPython: getTransformedPython(challengeFiles) + challengeType: challengeTypes.python, + sources: buildSourceMap(challengeFiles) })) ); } diff --git a/client/src/templates/Challenges/utils/frame.ts b/client/src/templates/Challenges/utils/frame.ts index decfdc3bc7c..8c033266fc0 100644 --- a/client/src/templates/Challenges/utils/frame.ts +++ b/client/src/templates/Challenges/utils/frame.ts @@ -24,7 +24,6 @@ export interface Context { build: string; sources: Source; loadEnzyme?: () => void; - transformedPython?: string; } export interface TestRunnerConfig { @@ -268,15 +267,14 @@ const updateWindowI18next = () => (frameContext: Context) => { const initTestFrame = (frameReady?: () => void) => (frameContext: Context) => { waitForFrame(frameContext) .then(async () => { - const { sources, loadEnzyme, transformedPython } = frameContext; + const { sources, loadEnzyme } = frameContext; // provide the file name and get the original source const getUserInput = (fileName: string) => toString(sources[fileName as keyof typeof sources]); await frameContext.document?.__initTestFrame({ code: sources, getUserInput, - loadEnzyme, - transformedPython + loadEnzyme }); if (frameReady) frameReady(); diff --git a/client/src/templates/Challenges/utils/python-worker-handler.ts b/client/src/templates/Challenges/utils/python-worker-handler.ts new file mode 100644 index 00000000000..8c3ec31f0a6 --- /dev/null +++ b/client/src/templates/Challenges/utils/python-worker-handler.ts @@ -0,0 +1,65 @@ +import pythonWorkerData from '../../../../config/browser-scripts/python-worker.json'; + +const pythonWorkerSrc = `/js/${pythonWorkerData.filename}.js`; + +let worker: Worker | null = null; +let testWorker: Worker | null = null; +let listener: ((event: MessageEvent) => void) | null = null; +let resetTerminal: (() => void) | null = null; + +export function getPythonWorker(): Worker { + if (!worker) { + worker = new Worker(pythonWorkerSrc); + } + return worker; +} + +export function getPythonTestWorker(): Worker { + if (!testWorker) { + testWorker = new Worker(pythonWorkerSrc); + } + return testWorker; +} + +type PythonWorkerEvent = { + data: { + type: 'print' | 'input' | 'contentLoaded'; + text: string; + }; +}; + +/** + * Registers a terminal to receive print and input messages from the python worker. + * @param handlers + * @param handlers.print - A function that handles print messages from the python worker + * @param handlers.input - A function that handles input messages from the python worker + * @param reset - A function that resets the terminal + */ +export function registerTerminal( + handlers: { + print: (text: string) => void; + input: (text: string) => void; + }, + reset: () => void +): void { + const pythonWorker = getPythonWorker(); + if (listener) pythonWorker.removeEventListener('message', listener); + listener = (event: PythonWorkerEvent) => { + const { type, text } = event.data; + // Ignore contentLoaded messages for now. + if (type === 'contentLoaded') return; + handlers[type](text); + }; + pythonWorker.addEventListener('message', listener); + resetTerminal = reset; +} + +/** + * Terminates the existing python worker and creates a new one. + */ +export function resetPythonWorker(): void { + if (resetTerminal) resetTerminal(); + worker?.terminate(); + worker = new Worker(pythonWorkerSrc); + if (listener) worker.addEventListener('message', listener); +} diff --git a/client/static/python-input-sw.js b/client/static/python-input-sw.js new file mode 100644 index 00000000000..cdd4fcdf196 --- /dev/null +++ b/client/static/python-input-sw.js @@ -0,0 +1,23 @@ +self.addEventListener('install', function() { + self.skipWaiting(); +}); + +self.addEventListener('activate', function() { + self.clients.claim(); +}) + +let resolver; + +self.onmessage = function(event) { + resolver(event.data); +} + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (url.pathname === '/python/intercept-input/') { + const response = new Promise((resolve) => { + resolver = (data) => resolve(new Response(data)); + }); + event.respondWith(response); + } +}); diff --git a/curriculum/challenges/english/20-upcoming-python/learn-python-by-building-a-blackjack-game/64b163c20e59cbd4a64940b0.md b/curriculum/challenges/english/20-upcoming-python/learn-python-by-building-a-blackjack-game/64b163c20e59cbd4a64940b0.md index 36ad3da4198..020d23938c1 100644 --- a/curriculum/challenges/english/20-upcoming-python/learn-python-by-building-a-blackjack-game/64b163c20e59cbd4a64940b0.md +++ b/curriculum/challenges/english/20-upcoming-python/learn-python-by-building-a-blackjack-game/64b163c20e59cbd4a64940b0.md @@ -13,11 +13,42 @@ Create a function to add two numbers together. Adding 3 and 5 should return 8. +```js +({ + test: () => assert.equal(__userGlobals.get("add")(3,5), 8) +}) +``` + +Test value of `__name__` + +```js +({ + test: () => assert(__pyodide.runPython(`__name__ == '__main__'`)) +}) +``` + +Test __locals and __pyodide + +```js +({ + test: () => assert(__pyodide.runPython(`__locals.get('add')(4,5) == 9`)) +}) +``` + +Test cleaner syntax + +```js +({ + test: () => assert(runPython(`add(4,5) == 9`)) +}) +``` + +Testing getDef + ```js ({ test: () => { - assert.equal(__userGlobals.get("add")(3,5), 8); - const add = __helpers.python.getDef(e.code.original["main.py"].replaceAll(/\r/g, ""), "add"); + const add = __helpers.python.getDef(code, "add"); assert.deepEqual(add, { def: "def add(a, b):\n return a + b\n", function_parameters: "a, b", @@ -28,7 +59,6 @@ Adding 3 and 5 should return 8. }) ``` - # --seed-- ## --seed-contents-- diff --git a/curriculum/test/test-challenges.js b/curriculum/test/test-challenges.js index b49dab5b838..5592b558951 100644 --- a/curriculum/test/test-challenges.js +++ b/curriculum/test/test-challenges.js @@ -33,8 +33,10 @@ const { } = require('../../client/src/templates/Challenges/utils/worker-executor'); const { challengeTypes } = require('../../shared/config/challenge-types'); // the config files are created during the build, but not before linting -const testEvaluator = +const javaScriptTestEvaluator = require('../../client/config/browser-scripts/test-evaluator.json').filename; +const pythonTestEvaluator = + require('../../client/config/browser-scripts/python-test-evaluator.json').filename; const { getLines } = require('../../shared/utils/get-lines'); @@ -531,28 +533,39 @@ async function createTestRunner( solutionFiles ); - const { build, sources, loadEnzyme, transformedPython } = - await buildChallenge( - { - challengeFiles, - required, - template - }, - { usesTestRunner: true } - ); + const { build, sources, loadEnzyme } = await buildChallenge( + { + challengeFiles, + required, + template + }, + { usesTestRunner: true } + ); const code = { contents: sources.index, - editableContents: sources.editableContents + editableContents: sources.editableContents, + original: sources.original }; - const runsInBrowser = - buildChallenge === buildDOMChallenge || - buildChallenge === buildPythonChallenge; + const runsInBrowser = buildChallenge === buildDOMChallenge; + const runsInPythonWorker = buildChallenge === buildPythonChallenge; + + const testEvaluator = runsInPythonWorker + ? pythonTestEvaluator + : javaScriptTestEvaluator; + + // The python worker clears the globals between tests, so it should be fine + // to use the same evaluator for all tests. TODO: check if this is true for + // sys, since sys.modules is not being reset. + const workerConfig = { + testEvaluator, + options: { terminateWorker: !runsInPythonWorker } + }; const evaluator = await (runsInBrowser - ? getContextEvaluator(build, sources, code, loadEnzyme, transformedPython) - : getWorkerEvaluator(build, sources, code, removeComments)); + ? getContextEvaluator(build, sources, code, loadEnzyme) + : getWorkerEvaluator(build, sources, code, removeComments, workerConfig)); return async ({ text, testString }) => { try { @@ -596,20 +609,8 @@ function replaceChallengeFilesContentsWithSolutions( }); } -async function getContextEvaluator( - build, - sources, - code, - loadEnzyme, - transformedPython -) { - await initializeTestRunner( - build, - sources, - code, - loadEnzyme, - transformedPython - ); +async function getContextEvaluator(build, sources, code, loadEnzyme) { + await initializeTestRunner(build, sources, code, loadEnzyme); return { evaluate: async (testString, timeout) => @@ -624,8 +625,15 @@ async function getContextEvaluator( }; } -async function getWorkerEvaluator(build, sources, code, removeComments) { - const testWorker = createWorker(testEvaluator, { terminateWorker: true }); +async function getWorkerEvaluator( + build, + sources, + code, + removeComments, + workerConfig +) { + const { testEvaluator, options } = workerConfig; + const testWorker = createWorker(testEvaluator, options); return { evaluate: async (testString, timeout) => await testWorker.execute( @@ -635,30 +643,22 @@ async function getWorkerEvaluator(build, sources, code, removeComments) { }; } -async function initializeTestRunner( - build, - sources, - code, - loadEnzyme, - transformedPython -) { +async function initializeTestRunner(build, sources, code, loadEnzyme) { await page.reload(); await page.setContent(build); await page.evaluate( - async (code, sources, loadEnzyme, transformedPython) => { + async (code, sources, loadEnzyme) => { const getUserInput = fileName => sources[fileName]; // TODO: use frame's functions directly, so it behaves more like the - // client. Also, keep an eye on performance - loading pyodide is slow. + // client. await document.__initTestFrame({ code: sources, getUserInput, - loadEnzyme, - transformedPython + loadEnzyme }); }, code, sources, - loadEnzyme, - transformedPython + loadEnzyme ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b69bcdab1f..b9d22e0aa4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -595,7 +595,7 @@ importers: version: 4.20.10 gatsby: specifier: 3.15.0 - version: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + version: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) gatsby-cli: specifier: 3.15.0 version: 3.15.0 @@ -782,6 +782,12 @@ importers: validator: specifier: 13.11.0 version: 13.11.0 + xterm: + specifier: ^5.2.1 + version: 5.2.1 + xterm-addon-fit: + specifier: ^0.8.0 + version: 0.8.0(xterm@5.2.1) devDependencies: '@babel/plugin-syntax-dynamic-import': specifier: 7.8.3 @@ -947,7 +953,7 @@ importers: version: 13.0.4 ts-node: specifier: 10.9.1 - version: 10.9.1(@types/node@20.8.2)(typescript@5.2.2) + version: 10.9.1(@types/node@18.18.9)(typescript@5.2.2) webpack: specifier: 5.89.0 version: 5.89.0(webpack-cli@4.10.0) @@ -2650,7 +2656,7 @@ packages: '@babel/traverse': 7.23.2 '@babel/types': 7.23.0 convert-source-map: 1.9.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 gensync: 1.0.0-beta.2 json5: 2.2.3 lodash: 4.17.21 @@ -2722,7 +2728,7 @@ packages: '@babel/traverse': 7.23.2 '@babel/types': 7.23.0 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -2744,7 +2750,7 @@ packages: '@babel/traverse': 7.23.3 '@babel/types': 7.23.3 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -2946,7 +2952,7 @@ packages: '@babel/core': 7.23.3 '@babel/helper-compilation-targets': 7.22.15 '@babel/helper-plugin-utils': 7.22.5 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 lodash.debounce: 4.0.8 resolve: 1.22.6 transitivePeerDependencies: @@ -2961,7 +2967,7 @@ packages: '@babel/core': 7.23.0 '@babel/helper-compilation-targets': 7.22.15 '@babel/helper-plugin-utils': 7.22.5 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 lodash.debounce: 4.0.8 resolve: 1.22.6 transitivePeerDependencies: @@ -2975,7 +2981,7 @@ packages: '@babel/core': 7.23.3 '@babel/helper-compilation-targets': 7.22.15 '@babel/helper-plugin-utils': 7.22.5 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 lodash.debounce: 4.0.8 resolve: 1.22.6 transitivePeerDependencies: @@ -6408,7 +6414,7 @@ packages: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.23.0 '@babel/types': 7.23.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -6425,7 +6431,7 @@ packages: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.23.0 '@babel/types': 7.23.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -6442,7 +6448,7 @@ packages: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.23.3 '@babel/types': 7.23.3 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -6812,7 +6818,7 @@ packages: engines: {node: ^10.12.0 || >=12.0.0} dependencies: ajv: 6.12.6 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 espree: 7.3.1 globals: 13.22.0 ignore: 4.0.6 @@ -6828,7 +6834,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 espree: 9.6.1 globals: 13.22.0 ignore: 5.2.4 @@ -7197,7 +7203,7 @@ packages: tslib: 2.2.0 value-or-promise: 1.0.6 - /@graphql-tools/url-loader@6.10.1(@types/node@20.8.2)(graphql@15.8.0): + /@graphql-tools/url-loader@6.10.1(@types/node@18.18.9)(graphql@15.8.0): resolution: {integrity: sha512-DSDrbhQIv7fheQ60pfDpGD256ixUQIR6Hhf9Z5bRjVkXOCvO5XrkwoWLiU7iHL81GB1r0Ba31bf+sl+D4nyyfw==} peerDependencies: graphql: ^14.0.0 || ^15.0.0 @@ -7216,7 +7222,7 @@ packages: is-promise: 4.0.0 isomorphic-ws: 4.0.1(ws@7.4.5) lodash: 4.17.21 - meros: 1.1.4(@types/node@20.8.2) + meros: 1.1.4(@types/node@18.18.9) subscriptions-transport-ws: 0.9.19(graphql@15.8.0) sync-fetch: 0.3.0 tslib: 2.2.0 @@ -7335,7 +7341,7 @@ packages: engines: {node: '>=10.10.0'} dependencies: '@humanwhocodes/object-schema': 2.0.1 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -7345,7 +7351,7 @@ packages: engines: {node: '>=10.10.0'} dependencies: '@humanwhocodes/object-schema': 1.2.1 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -11251,7 +11257,7 @@ packages: '@typescript-eslint/experimental-utils': 4.33.0(eslint@7.32.0)(typescript@5.2.2) '@typescript-eslint/parser': 4.33.0(eslint@7.32.0)(typescript@5.2.2) '@typescript-eslint/scope-manager': 4.33.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 eslint: 7.32.0 functional-red-black-tree: 1.0.1 ignore: 5.2.4 @@ -11279,7 +11285,7 @@ packages: '@typescript-eslint/type-utils': 6.10.0(eslint@8.53.0)(typescript@5.2.2) '@typescript-eslint/utils': 6.10.0(eslint@8.53.0)(typescript@5.2.2) '@typescript-eslint/visitor-keys': 6.10.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 eslint: 8.53.0 graphemer: 1.4.0 ignore: 5.2.4 @@ -11337,7 +11343,7 @@ packages: '@typescript-eslint/scope-manager': 4.33.0 '@typescript-eslint/types': 4.33.0 '@typescript-eslint/typescript-estree': 4.33.0(typescript@5.2.2) - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 eslint: 7.32.0 typescript: 5.2.2 transitivePeerDependencies: @@ -11357,7 +11363,7 @@ packages: '@typescript-eslint/types': 6.10.0 '@typescript-eslint/typescript-estree': 6.10.0(typescript@5.2.2) '@typescript-eslint/visitor-keys': 6.10.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 eslint: 8.53.0 typescript: 5.2.2 transitivePeerDependencies: @@ -11397,7 +11403,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 6.10.0(typescript@5.2.2) '@typescript-eslint/utils': 6.10.0(eslint@8.53.0)(typescript@5.2.2) - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 eslint: 8.53.0 ts-api-utils: 1.0.3(typescript@5.2.2) typescript: 5.2.2 @@ -11433,7 +11439,7 @@ packages: dependencies: '@typescript-eslint/types': 3.10.1 '@typescript-eslint/visitor-keys': 3.10.1 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 glob: 7.2.3 is-glob: 4.0.3 lodash: 4.17.21 @@ -11454,7 +11460,7 @@ packages: dependencies: '@typescript-eslint/types': 4.33.0 '@typescript-eslint/visitor-keys': 4.33.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -11474,7 +11480,7 @@ packages: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -11495,7 +11501,7 @@ packages: dependencies: '@typescript-eslint/types': 6.10.0 '@typescript-eslint/visitor-keys': 6.10.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 @@ -11978,7 +11984,7 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} dependencies: - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -13201,7 +13207,7 @@ packages: '@babel/core': 7.23.0 '@babel/runtime': 7.23.1 '@babel/types': 7.23.0 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) gatsby-core-utils: 2.15.0 /babel-plugin-remove-graphql-queries@3.15.0(@babel/core@7.23.3)(gatsby@3.15.0): @@ -13214,7 +13220,7 @@ packages: '@babel/core': 7.23.3 '@babel/runtime': 7.23.1 '@babel/types': 7.23.0 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) gatsby-core-utils: 2.15.0 /babel-plugin-syntax-async-functions@6.13.0: @@ -15977,6 +15983,16 @@ packages: ms: 2.0.0 dev: false + /debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + /debug@3.2.7(supports-color@5.5.0): resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -16011,6 +16027,17 @@ packages: ms: 2.1.2 dev: true + /debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + /debug@4.3.4(supports-color@8.1.1): resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -16308,7 +16335,7 @@ packages: hasBin: true dependencies: address: 1.1.2 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -16336,7 +16363,7 @@ packages: '@types/tmp': 0.0.33 application-config-path: 0.1.1 command-exists: 1.2.9 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7 eol: 0.9.1 get-port: 3.2.0 glob: 7.2.3 @@ -16464,7 +16491,7 @@ packages: /docsify-server-renderer@4.13.1: resolution: {integrity: sha512-XNJeCK3zp+mVO7JZFn0bH4hNBAMMC1MbuCU7CBsjLHYn4NHrjIgCBGmylzEan3/4Qm6kbSzQx8XzUK5T7GQxHw==} dependencies: - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 docsify: 4.13.1 node-fetch: 2.7.0 resolve-pathname: 3.0.0 @@ -16765,7 +16792,7 @@ packages: dependencies: base64-arraybuffer: 0.1.4 component-emitter: 1.3.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 engine.io-parser: 4.0.3 has-cors: 1.1.0 parseqs: 0.0.6 @@ -16792,7 +16819,7 @@ packages: base64id: 2.0.0 cookie: 0.4.2 cors: 2.8.5 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 engine.io-parser: 4.0.3 ws: 7.4.6 transitivePeerDependencies: @@ -17198,7 +17225,7 @@ packages: /eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7 is-core-module: 2.13.1 resolve: 1.22.6 transitivePeerDependencies: @@ -17211,10 +17238,10 @@ packages: eslint: '*' eslint-plugin-import: '*' dependencies: - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 enhanced-resolve: 5.15.0 eslint: 8.53.0 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.10.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.5.5)(eslint@8.53.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.10.0)(eslint-import-resolver-typescript@3.5.5)(eslint@8.53.0) eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.10.0)(eslint-import-resolver-typescript@3.5.5)(eslint@8.53.0) get-tsconfig: 4.7.2 globby: 13.2.2 @@ -17249,7 +17276,7 @@ packages: optional: true dependencies: '@typescript-eslint/parser': 4.33.0(eslint@7.32.0)(typescript@5.2.2) - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7 eslint: 7.32.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@6.10.0)(eslint-plugin-import@2.29.0)(eslint@8.53.0) @@ -17278,13 +17305,41 @@ packages: optional: true dependencies: '@typescript-eslint/parser': 6.10.0(eslint@8.53.0)(typescript@5.2.2) - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7 eslint: 8.53.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@6.10.0)(eslint-plugin-import@2.29.0)(eslint@8.53.0) transitivePeerDependencies: - supports-color + /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.10.0)(eslint-import-resolver-typescript@3.5.5)(eslint@8.53.0): + resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + dependencies: + '@typescript-eslint/parser': 6.10.0(eslint@8.53.0)(typescript@5.2.2) + debug: 3.2.7 + eslint: 8.53.0 + eslint-import-resolver-typescript: 3.5.5(@typescript-eslint/parser@6.10.0)(eslint-plugin-import@2.29.0)(eslint@8.53.0) + transitivePeerDependencies: + - supports-color + /eslint-plugin-filenames-simple@0.8.0(eslint@8.53.0): resolution: {integrity: sha512-8+uBzNBE5gSUMQv7bmMBiOD26eKzD4/5flPtD5Vl3dzZLXotSwXK3W7ZZqKQfU0Qyoborh+LqbN76EfmbBcU8A==} engines: {node: ^14.17.0 || ^16.0.0 || ^18.0.0} @@ -17305,7 +17360,7 @@ packages: lodash: 4.17.21 string-natural-compare: 3.0.1 - /eslint-plugin-graphql@4.0.0(@types/node@20.8.2)(graphql@15.8.0)(typescript@5.2.2): + /eslint-plugin-graphql@4.0.0(@types/node@18.18.9)(graphql@15.8.0)(typescript@5.2.2): resolution: {integrity: sha512-d5tQm24YkVvCEk29ZR5ScsgXqAGCjKlMS8lx3mS7FS/EKsWbkvXQImpvic03EpMIvNTBW5e+2xnHzXB/VHNZJw==} engines: {node: '>=10.0'} peerDependencies: @@ -17313,7 +17368,7 @@ packages: dependencies: '@babel/runtime': 7.23.1 graphql: 15.8.0 - graphql-config: 3.4.1(@types/node@20.8.2)(graphql@15.8.0)(typescript@5.2.2) + graphql-config: 3.4.1(@types/node@18.18.9)(graphql@15.8.0)(typescript@5.2.2) lodash.flatten: 4.4.0 lodash.without: 4.4.0 transitivePeerDependencies: @@ -17338,7 +17393,7 @@ packages: array.prototype.findlastindex: 1.2.3 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7 doctrine: 2.1.0 eslint: 7.32.0 eslint-import-resolver-node: 0.3.9 @@ -17372,7 +17427,7 @@ packages: array.prototype.findlastindex: 1.2.3 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7 doctrine: 2.1.0 eslint: 8.53.0 eslint-import-resolver-node: 0.3.9 @@ -17412,7 +17467,7 @@ packages: '@es-joy/jsdoccomment': 0.39.4 are-docs-informative: 0.0.2 comment-parser: 1.3.1 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 escape-string-regexp: 4.0.0 eslint: 8.53.0 esquery: 1.5.0 @@ -17643,7 +17698,7 @@ packages: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 doctrine: 3.0.0 enquirer: 2.4.1 escape-string-regexp: 4.0.0 @@ -17696,7 +17751,7 @@ packages: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -18616,7 +18671,7 @@ packages: debug: optional: true dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7 /follow-redirects@1.15.3(debug@4.3.4): resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==} @@ -18627,7 +18682,7 @@ packages: debug: optional: true dependencies: - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -19091,7 +19146,7 @@ packages: dependencies: '@babel/runtime': 7.14.0 fs-extra: 10.0.1 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) lodash: 4.17.21 moment: 2.29.1 pify: 5.0.0 @@ -19105,7 +19160,7 @@ packages: gatsby: ^3.0.0-next.0 dependencies: '@babel/runtime': 7.20.13 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) dev: false /gatsby-plugin-manifest@3.15.0(gatsby@3.15.0)(graphql@15.8.0): @@ -19115,7 +19170,7 @@ packages: gatsby: ^3.0.0-next.0 dependencies: '@babel/runtime': 7.20.13 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) gatsby-core-utils: 2.15.0 gatsby-plugin-utils: 1.15.0(gatsby@3.15.0)(graphql@15.8.0) semver: 7.5.4 @@ -19136,7 +19191,7 @@ packages: chokidar: 3.5.3 fs-exists-cached: 1.0.0 fs-extra: 10.1.0 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) gatsby-core-utils: 2.15.0 gatsby-page-utils: 1.15.0 gatsby-plugin-utils: 1.15.0(gatsby@3.15.0)(graphql@15.8.0) @@ -19153,7 +19208,7 @@ packages: peerDependencies: gatsby: ~2.x.x || ~3.x.x || ~4.x.x dependencies: - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) lodash.get: 4.4.2 lodash.uniq: 4.5.0 dev: false @@ -19166,7 +19221,7 @@ packages: postcss: ^8.0.5 dependencies: '@babel/runtime': 7.20.13 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) postcss: 8.4.31 postcss-loader: 4.3.0(postcss@8.4.31)(webpack@5.89.0) transitivePeerDependencies: @@ -19181,7 +19236,7 @@ packages: react-helmet: ^5.1.3 || ^6.0.0 dependencies: '@babel/runtime': 7.20.13 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) react-helmet: 6.1.0(react@16.14.0) dev: false @@ -19202,7 +19257,7 @@ packages: '@babel/preset-typescript': 7.23.3(@babel/core@7.23.3) '@babel/runtime': 7.23.1 babel-plugin-remove-graphql-queries: 3.15.0(@babel/core@7.23.3)(gatsby@3.15.0) - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) transitivePeerDependencies: - supports-color @@ -19215,7 +19270,7 @@ packages: dependencies: '@babel/runtime': 7.23.1 fastq: 1.15.0 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) graphql: 15.8.0 joi: 17.11.0 @@ -19225,7 +19280,7 @@ packages: gatsby: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 dependencies: '@babel/runtime': 7.23.1 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) webpack-bundle-analyzer: 4.9.1 transitivePeerDependencies: - bufferutil @@ -19266,7 +19321,7 @@ packages: chokidar: 3.5.3 contentful-management: 7.54.2(debug@4.3.4) cors: 2.8.5 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 detect-port: 1.5.1 dotenv: 8.6.0 execa: 5.1.1 @@ -19322,7 +19377,7 @@ packages: prismjs: ^1.15.0 dependencies: '@babel/runtime': 7.20.13 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) parse-numeric-range: 1.3.0 prismjs: 1.29.0 unist-util-visit: 2.0.3 @@ -19339,7 +19394,7 @@ packages: fastq: 1.15.0 file-type: 16.5.4 fs-extra: 10.1.0 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) gatsby-core-utils: 2.15.0 got: 9.6.0 md5-file: 5.0.0 @@ -19379,7 +19434,7 @@ packages: gatsby: ^4.0.0-next dependencies: '@babel/runtime': 7.23.1 - gatsby: 3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) + gatsby: 3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0) gatsby-core-utils: 3.25.0 gray-matter: 4.0.3 hast-util-raw: 6.1.0 @@ -19414,7 +19469,7 @@ packages: transitivePeerDependencies: - supports-color - /gatsby@3.15.0(@types/node@20.8.2)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0): + /gatsby@3.15.0(@types/node@18.18.9)(babel-eslint@10.1.0)(eslint-import-resolver-typescript@3.5.5)(eslint-plugin-testing-library@3.9.0)(react-dom@16.14.0)(react@16.14.0)(typescript@5.2.2)(webpack-cli@4.10.0): resolution: {integrity: sha512-zZrHYZtBksrWkOvIJIsaOdfT6rTd5g+HclsWO25H3kTecaPGm5wiKrTtEDPePHWNqEM1V0rLJ/I97/N5tS+7Lw==} engines: {node: '>=12.13.0'} hasBin: true @@ -19465,7 +19520,7 @@ packages: css-minimizer-webpack-plugin: 2.0.0(webpack@5.89.0) css.escape: 1.5.1 date-fns: 2.30.0 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7 deepmerge: 4.3.1 del: 5.1.0 detect-port: 1.5.1 @@ -19474,7 +19529,7 @@ packages: eslint: 7.32.0 eslint-config-react-app: 6.0.0(@typescript-eslint/eslint-plugin@4.33.0)(@typescript-eslint/parser@4.33.0)(babel-eslint@10.1.0)(eslint-plugin-flowtype@5.10.0)(eslint-plugin-import@2.28.1)(eslint-plugin-jsx-a11y@6.7.1)(eslint-plugin-react-hooks@4.6.0)(eslint-plugin-react@7.33.2)(eslint-plugin-testing-library@3.9.0)(eslint@7.32.0)(typescript@5.2.2) eslint-plugin-flowtype: 5.10.0(eslint@7.32.0) - eslint-plugin-graphql: 4.0.0(@types/node@20.8.2)(graphql@15.8.0)(typescript@5.2.2) + eslint-plugin-graphql: 4.0.0(@types/node@18.18.9)(graphql@15.8.0)(typescript@5.2.2) eslint-plugin-import: 2.28.1(@typescript-eslint/parser@4.33.0)(eslint-import-resolver-typescript@3.5.5)(eslint@7.32.0) eslint-plugin-jsx-a11y: 6.7.1(eslint@7.32.0) eslint-plugin-react: 7.33.2(eslint@7.32.0) @@ -19992,7 +20047,7 @@ packages: graphql-type-json: 0.3.2(graphql@15.8.0) object-path: 0.11.5 - /graphql-config@3.4.1(@types/node@20.8.2)(graphql@15.8.0)(typescript@5.2.2): + /graphql-config@3.4.1(@types/node@18.18.9)(graphql@15.8.0)(typescript@5.2.2): resolution: {integrity: sha512-g9WyK4JZl1Ko++FSyE5Ir2g66njfxGzrDDhBOwnkoWf/t3TnnZG6BBkWP+pkqVJ5pqMJGPKHNrbew8jRxStjhw==} engines: {node: '>= 10.0.0'} peerDependencies: @@ -20003,7 +20058,7 @@ packages: '@graphql-tools/json-file-loader': 6.2.6(graphql@15.8.0) '@graphql-tools/load': 6.2.8(graphql@15.8.0) '@graphql-tools/merge': 6.2.14(graphql@15.8.0) - '@graphql-tools/url-loader': 6.10.1(@types/node@20.8.2)(graphql@15.8.0) + '@graphql-tools/url-loader': 6.10.1(@types/node@18.18.9)(graphql@15.8.0) '@graphql-tools/utils': 7.10.0(graphql@15.8.0) cosmiconfig: 7.0.0 cosmiconfig-toml-loader: 1.0.0 @@ -20648,7 +20703,7 @@ packages: dependencies: '@tootallnate/once': 2.0.0 agent-base: 6.0.2 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 transitivePeerDependencies: - supports-color dev: true @@ -20725,7 +20780,7 @@ packages: engines: {node: '>= 6'} dependencies: agent-base: 6.0.2 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -21657,7 +21712,7 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} dependencies: - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 istanbul-lib-coverage: 3.2.0 source-map: 0.6.1 transitivePeerDependencies: @@ -22816,7 +22871,7 @@ packages: cli-truncate: 3.1.0 colorette: 2.0.20 commander: 9.5.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 execa: 6.1.0 lilconfig: 2.0.6 listr2: 5.0.8 @@ -23908,7 +23963,7 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - /meros@1.1.4(@types/node@20.8.2): + /meros@1.1.4(@types/node@18.18.9): resolution: {integrity: sha512-E9ZXfK9iQfG9s73ars9qvvvbSIkJZF5yOo9j4tcwM5tN8mUKfj/EKN5PzOr3ZH0y5wL7dLAHw3RVEfpQV9Q7VQ==} engines: {node: '>=12'} peerDependencies: @@ -23917,7 +23972,7 @@ packages: '@types/node': optional: true dependencies: - '@types/node': 20.8.2 + '@types/node': 18.18.9 /method-override@3.0.0: resolution: {integrity: sha512-IJ2NNN/mSl9w3kzWB92rcdHpz+HjkxhDJWNDBqSlas+zQdP8wBiJzITPg08M/k2uVvMow7Sk41atndNtt/PHSA==} @@ -24366,7 +24421,7 @@ packages: /micromark@2.11.4: resolution: {integrity: sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==} dependencies: - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 parse-entities: 2.0.0 transitivePeerDependencies: - supports-color @@ -24376,7 +24431,7 @@ packages: resolution: {integrity: sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==} dependencies: '@types/debug': 4.1.9 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 decode-named-character-reference: 1.0.2 micromark-core-commonmark: 1.1.0 micromark-factory-space: 1.1.0 @@ -24399,7 +24454,7 @@ packages: resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} dependencies: '@types/debug': 4.1.9 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.0 @@ -29383,7 +29438,7 @@ packages: '@types/component-emitter': 1.2.12 backo2: 1.0.2 component-emitter: 1.3.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 engine.io-client: 4.1.4 parseuri: 0.0.6 socket.io-parser: 4.0.5 @@ -29398,7 +29453,7 @@ packages: dependencies: '@types/component-emitter': 1.2.12 component-emitter: 1.3.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -29411,7 +29466,7 @@ packages: '@types/node': 14.18.63 accepts: 1.3.8 base64id: 2.0.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 engine.io: 4.1.2 socket.io-adapter: 2.1.0 socket.io-parser: 4.0.5 @@ -29649,7 +29704,7 @@ packages: arg: 5.0.2 bluebird: 3.7.2 check-more-types: 2.24.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 execa: 5.1.1 lazy-ass: 1.6.0 ps-tree: 1.2.0 @@ -32189,7 +32244,7 @@ packages: /webpack-virtual-modules@0.3.2: resolution: {integrity: sha512-RXQXioY6MhzM4CNQwmBwKXYgBs6ulaiQ8bkNQEl2J6Z+V+s7lgl/wGvaI/I0dLnYKB8cKsxQc17QOAVIphPLDw==} dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7 transitivePeerDependencies: - supports-color diff --git a/tools/client-plugins/browser-scripts/index.d.ts b/tools/client-plugins/browser-scripts/index.d.ts index dc041d22eee..10969a45a82 100644 --- a/tools/client-plugins/browser-scripts/index.d.ts +++ b/tools/client-plugins/browser-scripts/index.d.ts @@ -22,7 +22,6 @@ export interface InitTestFrameArg { }; getUserInput?: (fileName: string) => string; loadEnzyme?: () => void; - transformedPython?: string; } export type FrameWindow = Window & diff --git a/tools/client-plugins/browser-scripts/python-runner.ts b/tools/client-plugins/browser-scripts/python-runner.ts deleted file mode 100644 index 04e8fc04c2c..00000000000 --- a/tools/client-plugins/browser-scripts/python-runner.ts +++ /dev/null @@ -1,299 +0,0 @@ -// We have to specify pyodide.js because we need to import that file (not .mjs) -// and 'import' defaults to .mjs -import { loadPyodide, type PyodideInterface } from 'pyodide/pyodide.js'; -import pkg from 'pyodide/package.json'; -import { IDisposable, Terminal } from 'xterm'; -import { FitAddon } from 'xterm-addon-fit'; -import jQuery from 'jquery'; // TODO: is jQuery needed for the python runner? -import * as helpers from '@freecodecamp/curriculum-helpers'; - -import type { PythonDocument, FrameWindow, InitTestFrameArg } from '.'; - -import 'xterm/css/xterm.css'; - -(window as FrameWindow).$ = jQuery; - -// This will be running in an iframe, so document will be -// element.contentDocument. This declaration is just to add properties we know -// exist on this document (but not on the parent) -const contentDocument = document as PythonDocument; - -function createTerminal(disposables: IDisposable[]) { - const terminalContainer = document.getElementById('terminal'); - if (!terminalContainer) throw Error('Could not find terminal container'); - - // Setting convertEol so that \n is converted to \r\n. Otherwise the terminal - // will interpret \n as line feed and just move the cursor to the next line. - // convertEol makes every \n a \r\n. - const term = new Terminal({ convertEol: true }); - const fitAddon = new FitAddon(); - term.loadAddon(fitAddon); - term.open(terminalContainer); - fitAddon.fit(); - - const resetTerminal = () => { - term.reset(); - disposables.forEach(disposable => disposable.dispose()); - disposables.length = 0; - }; - - return { term, resetTerminal }; -} - -async function setupPyodide() { - // I tried setting jsglobals here, to provide 'input' and 'print' to python, - // without having to modify the global window object. However, it didn't work - // because pyodide needs access to that object. Instead, I used - // registerJsModule when setting up runPython. - return await loadPyodide({ - indexURL: `https://cdn.jsdelivr.net/pyodide/v${pkg.version}/full/` - }); -} - -type Input = (text: string) => Promise; -type Print = (...args: unknown[]) => void; -type ResetTerminal = () => void; -type EvaluatedTeststring = { - input: string[]; - test: () => Promise; -}; - -function createJSFunctionsForPython( - term: Terminal, - disposables: IDisposable[], - pyodide: PyodideInterface -) { - const writeLine = (text: string) => term.writeln(`>>> ${text}`); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const str = pyodide.globals.get('str') as (x: unknown) => string; - function print(...args: unknown[]) { - const text = args.map(x => str(x)).join(' '); - writeLine(text); - } - // TODO: prevent user from moving cursor outside the current input line and - // handle insertion and deletion properly. While backspace and delete don't - // seem to work, we can use "\x1b[0K" to clear from the cursor to the end. - // Also, we should not add special characters to the userinput string. - const waitForInput = (): Promise => - new Promise(resolve => { - let userinput = ''; - // Eslint is correct that this only gets assigned once, but we can't use - // const because the declaration (before keyListener is defined) and - // assignment (after keyListener is defined) must be separate. - // eslint-disable-next-line prefer-const - let disposable: IDisposable | undefined; - - const done = () => { - disposable?.dispose(); - resolve(userinput); - }; - - const keyListener = (key: string) => { - if (key === '\u007F' || key === '\b') { - // Backspace or delete key - term.write('\b \b'); // Move cursor back, replace character with space, then move cursor back again - userinput = userinput.slice(0, -1); // Remove the last character from userinput - } - if (key == '\r') { - term.write('\r\n'); - done(); - } else { - userinput += key; - term.write(key); - } - }; - - disposable = term.onData(keyListener); // Listen for key events and store the disposable - disposables.push(disposable); - }); - - const input = async (text: string) => { - writeLine(text); - return await waitForInput(); - }; - - return { print, input }; -} - -function setupRunPython( - pyodide: PyodideInterface, - { - input, - print, - resetTerminal - }: { input: Input; print: Print; resetTerminal: ResetTerminal } -) { - // Make print and input available to python - pyodide.registerJsModule('jscustom', { - input, - print - }); - pyodide.runPython(` - import jscustom - from jscustom import print - from jscustom import input - `); - - async function runPython(code: string) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - pyodide.globals.get('__cancel')?.(); - resetTerminal(); - - // There's no need to clear out globals between runs, because the user's - // code is always run in a coroutine and shouldn't pollute them. If we - // subsequently want to run code that does interact with globals, we can - // revisit this. - await pyodide.runPythonAsync(code); - return pyodide; - } - - contentDocument.__runPython = runPython; -} - -async function initPythonFrame() { - const disposables: IDisposable[] = []; - const { term, resetTerminal } = createTerminal(disposables); - const pyodide = await setupPyodide(); - const { print, input } = createJSFunctionsForPython( - term, - disposables, - pyodide - ); - setupRunPython(pyodide, { input, print, resetTerminal }); -} - -contentDocument.__initPythonFrame = initPythonFrame; -contentDocument.__initTestFrame = initTestFrame; - -// TODO: DRY this and frame-runner.ts's initTestFrame -async function initTestFrame(e: InitTestFrameArg) { - const pyodide = await setupPyodide(); - - // transformedPython is used here not because it's necessary (it's not since - // the transformation converts `input` into `await input` and the tests - // provide a synchronous `input` function), but because we want to run the - // tests against exactly the same code that runs in the preview. - const code = (e.transformedPython || '').slice(); - const __file = (id?: string) => { - if (id && e.code.original) { - return e.code.original[id]; - } else { - return code; - } - }; - - if (!e.getUserInput) { - e.getUserInput = () => code; - } - - /* eslint-disable @typescript-eslint/no-unused-vars */ - // Fake Deep Equal dependency - const DeepEqual = (a: Record, b: Record) => - JSON.stringify(a) === JSON.stringify(b); - - // Hardcode Deep Freeze dependency - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const DeepFreeze = (o: Record) => { - Object.freeze(o); - Object.getOwnPropertyNames(o).forEach(function (prop) { - if ( - Object.prototype.hasOwnProperty.call(o, prop) && - o[prop] !== null && - (typeof o[prop] === 'object' || typeof o[prop] === 'function') && - !Object.isFrozen(o[prop]) - ) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - DeepFreeze(o[prop]); - } - }); - return o; - }; - - const { default: chai } = await import(/* webpackChunkName: "chai" */ 'chai'); - const assert = chai.assert; - const __helpers = helpers; - /* eslint-enable @typescript-eslint/no-unused-vars */ - - contentDocument.__runTest = async function runTests(testString: string) { - // uncomment the following line to inspect - // the frame-runner as it runs tests - // make sure the dev tools console is open - // debugger; - try { - // eval test string to get the dummy input and actual test - const evaluatedTestString = await new Promise( - (resolve, reject) => - // To avoid race conditions, we have to run the test in a final - // frameDocument ready: - $(() => { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const test: { input: string[]; test: () => Promise } = - eval(testString); - resolve(test); - } catch (err) { - reject(err); - } - }) - ); - - // If the test string does not evaluate to an object, then we assume that - // it's a standard JS test and any assertions have already passed. - if (typeof evaluatedTestString !== 'object') { - return { pass: true }; - } - - if (!evaluatedTestString || !('test' in evaluatedTestString)) { - throw new Error( - 'Test string did not evaluate to an object with the test property' - ); - } - - const { input, test } = evaluatedTestString as EvaluatedTeststring; - - // TODO: throw helpful error if we run out of input values, since it's likely - // that the user added too many input statements. - const inputIterator = input ? input.values() : null; - setupRunPython(pyodide, { - input: () => { - return Promise.resolve( - inputIterator ? inputIterator.next().value : '' - ); - }, - // We don't, currently, care what print is called with, but it does need - // to exist - print: () => void 0, - // resetTerminal is only necessary when calling __runPython more than - // once, which we don't do in the test frame - resetTerminal: () => void 0 - }); - - // We have to declare these variables in the scope of 'eval', so that they - // exist when the `testString` is evaluated. Otherwise, they will be - // undefined when `test` is called and the tests will not be able to use - // __pyodide or __userGlobals. - const __pyodide = await this.__runPython(code); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const __userGlobals = __pyodide.globals.get('__locals') as unknown; - await test(); - - return { pass: true }; - } catch (err) { - if (!(err instanceof chai.AssertionError)) { - console.error(err); - } - // to provide useful debugging information when debugging the tests, we - // have to extract the message, stack and, if they exist, expected and - // actual before returning - return { - err: { - message: (err as Error).message, - stack: (err as Error).stack, - expected: (err as { expected?: string }).expected, - actual: (err as { actual?: string }).actual - } - }; - } - }; -} diff --git a/tools/client-plugins/browser-scripts/python-test-evaluator.ts b/tools/client-plugins/browser-scripts/python-test-evaluator.ts new file mode 100644 index 00000000000..839e7f62a8c --- /dev/null +++ b/tools/client-plugins/browser-scripts/python-test-evaluator.ts @@ -0,0 +1,166 @@ +// We have to specify pyodide.js because we need to import that file (not .mjs) +// and 'import' defaults to .mjs +import { loadPyodide, type PyodideInterface } from 'pyodide/pyodide.js'; +import type { PyProxy } from 'pyodide/ffi'; +import pkg from 'pyodide/package.json'; +import * as helpers from '@freecodecamp/curriculum-helpers'; +import chai from 'chai'; + +const ctx: Worker & typeof globalThis = self as unknown as Worker & + typeof globalThis; + +let pyodide: PyodideInterface; + +interface PythonRunEvent extends MessageEvent { + data: { + code: { + contents: string; + editableContents: string; + original: { [id: string]: string }; + }; + removeComments: boolean; + firstTest: unknown; + testString: string; + build: string; + sources: { + [fileName: string]: unknown; + }; + }; +} + +type EvaluatedTeststring = { + input: string[]; + test: () => Promise; +}; + +async function setupPyodide() { + if (pyodide) return pyodide; + + pyodide = await loadPyodide({ + // TODO: host this ourselves + indexURL: `https://cdn.jsdelivr.net/pyodide/v${pkg.version}/full/` + }); + + // We freeze this to prevent learners from getting the worker into a + // weird state. NOTE: this has to come after pyodide is loaded, because + // pyodide modifies self while loading. + Object.freeze(self); + + ctx.postMessage({ type: 'contentLoaded' }); + + return pyodide; +} + +void setupPyodide(); + +ctx.onmessage = async (e: PythonRunEvent) => { + const pyodide = await setupPyodide(); + // TODO: Use removeComments when we have it + /* eslint-disable @typescript-eslint/no-unused-vars */ + const code = (e.data.code.contents || '').slice(); + const editableContents = (e.data.code.editableContents || '').slice(); + const testString = e.data.testString; + + const assert = chai.assert; + const __helpers = helpers; + /* eslint-enable @typescript-eslint/no-unused-vars */ + // uncomment the following line to inspect + // the frame-runner as it runs tests + // make sure the dev tools console is open + // debugger; + try { + // eval test string to get the dummy input and actual test + const evaluatedTestString = await new Promise( + (resolve, reject) => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const test: { input: string[]; test: () => Promise } = + eval(testString); + resolve(test); + } catch (err) { + reject(err); + } + } + ); + + // If the test string does not evaluate to an object, then we assume that + // it's a standard JS test and any assertions have already passed. + if (typeof evaluatedTestString !== 'object') { + ctx.postMessage({ pass: true }); + return; + } + + if (!evaluatedTestString || !('test' in evaluatedTestString)) { + throw new Error( + 'Test string did not evaluate to an object with the test property' + ); + } + + const { input, test } = evaluatedTestString as EvaluatedTeststring; + + const inputIterator = (input ?? []).values(); + const testInput = () => { + const next = inputIterator.next(); + if (next.done) { + // TODO: handle this error in the UI + throw new Error('Too many input calls'); + } else { + return next.value; + } + }; + + // Make input available to python (print is not used yet) + pyodide.registerJsModule('jscustom', { + input: testInput + // print: () => {} + }); + // Create fresh globals for each test + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const __userGlobals = pyodide.globals.get('dict')() as PyProxy; + // Some tests rely on __name__ being set to __main__ and we new dicts do not + // have this set by default. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + __userGlobals.set('__name__', '__main__'); + + // The runPython helper is a shortcut for running python code with our + // custom globals. + const runPython = (pyCode: string) => + pyodide.runPython(pyCode, { globals: __userGlobals }) as unknown; + // TODO: remove __pyodide once all the test use runPython. + const __pyodide = { + runPython + }; + + runPython( + ` + import jscustom + from jscustom import input + ` + ); + + // Evaluates the learner's code so that any variables they define are + // available to the test. + runPython(code); + // TODO: remove the next line, creating __locals, once all the tests access + // variables directly. + runPython('__locals = globals()'); + await test(); + + ctx.postMessage({ pass: true }); + } catch (err) { + if (!(err instanceof chai.AssertionError)) { + console.error(err); + } + // to provide useful debugging information when debugging the tests, we + // have to extract the message, stack and, if they exist, expected and + // actual before returning + ctx.postMessage({ + err: { + message: (err as Error).message, + stack: (err as Error).stack, + expected: (err as { expected?: string }).expected, + actual: (err as { actual?: string }).actual + } + }); + } +}; diff --git a/tools/client-plugins/browser-scripts/python-worker.ts b/tools/client-plugins/browser-scripts/python-worker.ts new file mode 100644 index 00000000000..5ea2c26ef70 --- /dev/null +++ b/tools/client-plugins/browser-scripts/python-worker.ts @@ -0,0 +1,81 @@ +// We have to specify pyodide.js because we need to import that file (not .mjs) +// and 'import' defaults to .mjs +import { loadPyodide, type PyodideInterface } from 'pyodide/pyodide.js'; +import pkg from 'pyodide/package.json'; + +const ctx: Worker & typeof globalThis = self as unknown as Worker & + typeof globalThis; + +let pyodide: PyodideInterface; + +interface PythonRunEvent extends MessageEvent { + data: { + code: { + contents: string; + editableContents: string; + original: { [id: string]: string }; + }; + }; +} + +async function setupPyodide() { + if (pyodide) return pyodide; + + pyodide = await loadPyodide({ + // TODO: host this ourselves + indexURL: `https://cdn.jsdelivr.net/pyodide/v${pkg.version}/full/` + }); + + // We freeze this to prevent learners from getting the worker into a + // weird state. NOTE: this has to come after pyodide is loaded, because + // pyodide modifies self while loading. + Object.freeze(self); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const str = pyodide.globals.get('str') as (x: unknown) => string; + + function print(...args: unknown[]) { + const text = args.map(x => str(x)).join(' '); + postMessage({ type: 'print', text }); + } + + function input(text: string) { + // TODO: send unique ids to the main thread and the service worker, so we + // can have multiple concurrent input requests. + postMessage({ type: 'input', text }); + const request = new XMLHttpRequest(); + request.open('POST', '/python/intercept-input/', false); + request.send(null); + + return request.responseText; + } + + // I tried setting jsglobals here, to provide 'input' and 'print' to python, + // without having to modify the global window object. However, it didn't work + // because pyodide needs access to that object. Instead, I used + // registerJsModule when setting up runPython. + + // Make print available to python + pyodide.registerJsModule('jscustom', { + print, + input + }); + // TODO: use a fresh global object for each runPython call if we stop terminating + // the worker when the user input changes. (See python-test-evaluator.ts) + pyodide.runPython(` + import jscustom + from jscustom import print + from jscustom import input +`); + + return pyodide; +} + +void setupPyodide(); + +ctx.onmessage = async (e: PythonRunEvent) => { + const code = (e.data.code.contents || '').slice(); + const pyodide = await setupPyodide(); + // use pyodide.runPythonAsync if we want top-level await + pyodide.runPython(code); +}; diff --git a/tools/client-plugins/browser-scripts/tsconfig.json b/tools/client-plugins/browser-scripts/tsconfig.json index 7a6d59c7f6f..af99915a71e 100644 --- a/tools/client-plugins/browser-scripts/tsconfig.json +++ b/tools/client-plugins/browser-scripts/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "es2022", "module": "CommonJS", + "lib": ["WebWorker", "DOM"], "allowJs": true, "strict": true, "forceConsistentCasingInFileNames": true, diff --git a/tools/client-plugins/browser-scripts/webpack.config.js b/tools/client-plugins/browser-scripts/webpack.config.js index bd509976c09..447ba57259b 100644 --- a/tools/client-plugins/browser-scripts/webpack.config.js +++ b/tools/client-plugins/browser-scripts/webpack.config.js @@ -17,7 +17,8 @@ module.exports = (env = {}) => { 'frame-runner': './frame-runner.ts', 'sass-compile': './sass-compile.ts', 'test-evaluator': './test-evaluator.ts', - 'python-runner': './python-runner.ts' + 'python-worker': './python-worker.ts', + 'python-test-evaluator': './python-test-evaluator.ts' }, devtool: __DEV__ ? 'inline-source-map' : 'source-map', output: { @@ -61,17 +62,16 @@ module.exports = (env = {}) => { ] } } - }, - // xterm doesn't bundle its css, so we need to load it ourselves - { - test: /\.css$/, - use: ['style-loader', 'css-loader'] } ] }, plugins: [ new CopyWebpackPlugin({ - patterns: ['./node_modules/sass.js/dist/sass.sync.js'] + patterns: [ + './node_modules/sass.js/dist/sass.sync.js', + // TODO: copy this into the css folder, not the js folder + './node_modules/xterm/css/xterm.css' + ] }), new webpack.ProvidePlugin({ process: 'process/browser'