From 659d19a7d7056324db7824957a5d6549fda7059d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= Date: Mon, 26 Oct 2026 10:29:51 +0100 Subject: [PATCH] =?UTF-8?q?=C2=ABTests=C2=BB=20starts=20as=20=E2=96=B6=20A?= =?UTF-8?q?usf=C3=BChren=20does:=20empty=20console,=20fresh=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests used to load the file into a namespace of its own and append its report, so after an edit and Tests the REPL still held the definitions of the last Run. Now Tests first does what Run does - an empty console, the current files run as __main__ into a fresh REPL session (output streams, the REPL's replay record starts anew) - and then runs the doctests and shows their report. The REPL then has exactly the definitions just tested. If the file itself fails, its traceback and hint are the answer and no report repeats the load error. The report no longer shows what the file printed while loading; the Run part already did. The worker's run is one function now (runProgram), used by run and test. #34's reveal of REPL answers, reports and the stop note stays. test:console: the second Tests run leaves only its own report, and a new scenario checks Tests → REPL → edit → Tests → REPL uses the new definition. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + README.md | 9 ++-- scripts/check-console-browser.mjs | 50 ++++++++++++++++--- src/GlPythonWorkspace.tsx | 16 ++++-- src/pyodide-worker.js | 83 +++++++++++++++++++------------ 5 files changed, 112 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d941fe1..6ce8807 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ There is no unit test framework. The automated checks are `test:protocol` (plain Worker messages: - `run` → `result`/`error`; while it runs, `output` messages (`{id, text}`, `id` undefined for a program, the input's id for a REPL input) stream what was printed, at most 50 per 50 ms. The final message brings the whole output again. +- `test` → first as `run` (`result`/`error`, a fresh REPL session from these files), then `test-result` (the doctests' report; `{skipped: true}` if the file failed) - `run-visual` → `trace-result` (`{trace, ast, output, graphics, error, errorMessage, truncated}`) - `repl` → `repl-result` - `run-visual-repl` (a doctest example) → `trace-result` with `rootPath`/`sources`, or `repl-result` if the input doesn't parse diff --git a/README.md b/README.md index 582bbfe..411c4db 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,12 @@ browser. Texts: `copyChain`, `copyChainDone`, `copyChainFailed`, ### Tests (doctests) The **Tests** button (capability `tests`, a `test` command for editor-free -hosts) runs the doctests in the docstrings of the file that would run, as -`python -m doctest` does: the file is loaded as a module named after it, so an -`if __name__ == "__main__":` block doesn't run. The output panel shows how many +hosts) starts as **Run** does - an empty console and a fresh session from the +current files, whose output shows first and whose definitions the REPL has +afterwards - and then runs the doctests in the docstrings of the file that would +run, as `python -m doctest` does: the file is loaded once more as a module named +after it (its output there isn't shown again). If the file itself fails, its +traceback is the answer and no report follows. The console then shows how many examples passed, then each failing example in doctest's own words (`Failed example:`, `Expected:`, `Got:` stay untranslated) with a button that visualizes that example step by step against the same definitions. If the example didn't diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index 901dac9..b743e13 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -1,8 +1,9 @@ -// Browser check of the console (P20): Tests, then an edit, then Tests again -// - without ▶ Run in between - runs the edited code, and its report is in -// view, also when the learner scrolled up to read the first report. Before -// the fix the second report was appended out of view below the first one, -// so «Tests» looked as if it didn't react. +// Browser check of the console: Tests, then an edit, then Tests again - +// without ▶ Run in between - runs the edited code, and its report is in +// view, also when the learner scrolled up to read the first report (P20: +// the second report used to land out of view below the first one). Tests +// starts as Run does, so the console then holds only the new report, and +// the REPL has the definitions just tested (not the ones of the last Run). // // Drives the built embed (dist-embed/, from npm run build:embed) in Chrome // through its protocol, as the LiaScript macro does; Pyodide comes from its @@ -57,13 +58,16 @@ try { const summaries = frame.locator('.test-summary') await page.evaluate((source) => window.send('test', source), first) await summaries.first().waitFor({ timeout: 60000 }) + const firstSeen = await summaries.allInnerTexts() // The learner reads the report from its top. await frame.locator('.console').evaluate((element) => { element.scrollTop = 0; element.dispatchEvent(new Event('scroll')) }) await page.evaluate((source) => window.send('test', source), second) - await summaries.nth(1).waitFor({ timeout: 60000 }) + // The new report: its summary appears (before the fix: as a second one). + await page.frames()[1].waitForFunction((want) => [...document.querySelectorAll('.test-summary')].some((summary) => summary.textContent === want), secondSummary, { timeout: 60000 }) await page.waitForTimeout(300) try { - assert.deepEqual(await summaries.allInnerTexts(), [firstSummary, secondSummary], `${label}: the second run tests the edited code`) + assert.deepEqual(firstSeen, [firstSummary], `${label}: the first run's report`) + assert.deepEqual(await summaries.allInnerTexts(), [secondSummary], `${label}: the second run tests the edited code, in an emptied console`) const inView = await frame.locator('.console').evaluate((element) => { const view = element.getBoundingClientRect() const summary = [...element.querySelectorAll('.test-summary')].at(-1).getBoundingClientRect() @@ -77,6 +81,38 @@ try { } await page.close() } + // Edit → Tests → the REPL uses the new definition, without ▶ Run. + const page = await browser.newPage({ viewport: { width: 800, height: 950 } }) + await page.route(`${origin}/check-console.html`, (route) => route.fulfill({ contentType: 'text/html', body: harness })) + await page.goto(`${origin}/check-console.html`) + await page.waitForFunction(() => window.ready, null, { timeout: 120000 }) + const frame = page.frameLocator('#embed') + const repl = frame.getByRole('textbox', { name: 'Python-Eingabe' }) + const ask = async (input) => { + const count = await frame.locator('.repl-value, .repl-entry .output-error').count() + await repl.fill(input) + await repl.press('Enter') + await frame.locator('.repl-value, .repl-entry .output-error').nth(count).waitFor({ timeout: 30000 }) + return frame.locator('.repl-value, .repl-entry .output-error').nth(count).innerText() + } + const version = (value) => `def f():\n """\n >>> f()\n ${value}\n """\n return ${value}\n` + await page.evaluate((source) => window.send('test', source), version(1)) + await frame.locator('.test-summary').waitFor({ timeout: 60000 }) + const before = await ask('f()') + await page.evaluate((source) => window.send('test', source), version(2)) + // Tests empties the console (before: the REPL entry stayed). + await frame.locator('.repl-entry').waitFor({ state: 'detached', timeout: 15000 }).catch(() => {}) + await frame.locator('.test-summary').last().waitFor({ timeout: 60000 }) + await page.waitForTimeout(500) + const after = await ask('f()') + try { + assert.deepEqual([before, after], ['1', '2'], 'edit → Tests → the REPL uses the new definition') + console.log('Tests → REPL → edit → Tests → REPL: ok') + } catch (error) { + failed += 1 + console.error(error.message) + } + await page.close() } finally { await browser.close() stopServer() diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index d206641..dd275b5 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -177,6 +177,9 @@ type WorkspaceCommand = { // What the worker reports for a Tests run (see doctests.py). type TestReport = { + // The file failed while running (its traceback is the program's output), + // so there is no report. + skipped?: boolean attempted?: number failed?: number // `want`/`got` only for an example whose output didn't match (not one that raised). @@ -1033,7 +1036,8 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { if (event.data.type === 'error') setRuntimeState((state) => state === 'loading' ? 'failed' : state) if (event.data.type === 'result' || event.data.type === 'error') { setOutput(event.data.output || currentTranslation.noOutput); setOutputErrorHint(event.data.type === 'error' ? event.data.errorHint ?? null : null); setGraphics(event.data.graphics ?? []); setGraphicsDismissed(false); setIsRunning(false) } if (event.data.type === 'input_request') { setInputValue(''); setInputRequest(event.data.prompt ?? '') } - if (event.data.type === 'test-result') { + if (event.data.type === 'test-result' && event.data.skipped) setIsTesting(false) + else if (event.data.type === 'test-result') { const id = ++replIdRef.current revealEntryRef.current = id setConsoleEntries((entries) => [...entries, { kind: 'tests', id, report: event.data as TestReport }]) @@ -1130,11 +1134,15 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { project: { mainFile: project.mainFile, files: sources }, }) } - // Runs the doctests of the file that would run (see doctests.py); the - // report replaces the output until the next Run. + // Tests start as Run does: an empty console and a fresh session from the + // current files - so the REPL afterwards has exactly the definitions just + // tested - then the doctests' report (see the worker's "test"). const runTests = () => { if (!workerRef.current || isTesting) return - setIsTesting(true) + if (workspaceCapabilities.graphics && projectImportsGturtle(project)) onGraphicsOpenChange(true) + runStreamStartedRef.current = false + consoleAtBottomRef.current = true + setIsTesting(true); setOutput(t.runningOutput(project.mainFile)); setOutputErrorHint(null); setConsoleEntries([]) workerRef.current.postMessage({ type: 'test', project: { diff --git a/src/pyodide-worker.js b/src/pyodide-worker.js index d61ab3a..702e472 100644 --- a/src/pyodide-worker.js +++ b/src/pyodide-worker.js @@ -195,6 +195,42 @@ function outputStream(id) { } } +// Run: mounts the project's files and runs its main file in a fresh REPL +// namespace (see _glpython_run_project), streaming what it prints, and +// answers "result" or "error". A REPL input's replay starts from here (see +// replayReplEntry). Says whether the program ran through. +async function runProgram(project) { + let runOutput = '' + try { + const pyodide = await getPyodide() + await mountProjectFiles(pyodide, project.files) + lastRunFiles = project.files + lastRunMainFile = project.mainFile + const stream = outputStream(undefined) + pyodide.setStdout({ batched: (text) => { runOutput += `${text}\n`; stream(`${text}\n`) } }) + pyodide.setStderr({ batched: (text) => { runOutput += `${text}\n`; stream(`${text}\n`) } }) + hasRun = true + lastRunOutcome = null + replLog = [] + callPython(pyodide, '_glpython_run_project', `/${project.mainFile}`) + lastRunOutcome = runOutput + const graphicsJson = callPython(pyodide, '_glpython_graphics_json') + self.postMessage({ type: 'result', output: runOutput.trim(), graphics: JSON.parse(String(graphicsJson)) }) + return true + } catch (error) { + // What the program printed, then Python's traceback as CPython shows + // it, plus the hint for its error (see _glpython_traceback_text). + // Pyodide's own error text only if the program never ran. + let report = null + try { report = JSON.parse(String(callPython(await getPyodide(), '_glpython_take_error_report'))) } catch {} + if (report) lastRunOutcome = `${runOutput}${report.traceback}` + self.postMessage(report + ? { type: 'error', output: `${runOutput}${report.traceback}`.trimEnd(), errorHint: report.errorHint } + : { type: 'error', output: String(error), errorHint: null }) + return false + } +} + self.onmessage = async (event) => { if (event.data.type === 'init-input-channel') { inputControl = new Int32Array(event.data.buffer, 0, 2) @@ -203,49 +239,30 @@ self.onmessage = async (event) => { } if (event.data.type === 'run') { - let runOutput = '' - try { - const pyodide = await getPyodide() - await mountProjectFiles(pyodide, event.data.project.files) - lastRunFiles = event.data.project.files - lastRunMainFile = event.data.project.mainFile - const stream = outputStream(undefined) - pyodide.setStdout({ batched: (text) => { runOutput += `${text}\n`; stream(`${text}\n`) } }) - pyodide.setStderr({ batched: (text) => { runOutput += `${text}\n`; stream(`${text}\n`) } }) - hasRun = true - lastRunOutcome = null - replLog = [] - callPython(pyodide, '_glpython_run_project', `/${event.data.project.mainFile}`) - lastRunOutcome = runOutput - const graphicsJson = callPython(pyodide, '_glpython_graphics_json') - self.postMessage({ type: 'result', output: runOutput.trim(), graphics: JSON.parse(String(graphicsJson)) }) - } catch (error) { - // What the program printed, then Python's traceback as CPython shows - // it, plus the hint for its error (see _glpython_traceback_text). - // Pyodide's own error text only if the program never ran. - let report = null - try { report = JSON.parse(String(callPython(await getPyodide(), '_glpython_take_error_report'))) } catch {} - if (report) lastRunOutcome = `${runOutput}${report.traceback}` - self.postMessage(report - ? { type: 'error', output: `${runOutput}${report.traceback}`.trimEnd(), errorHint: report.errorHint } - : { type: 'error', output: String(error), errorHint: null }) - } + await runProgram(event.data.project) return } + // Tests start as Run does - a fresh session from the current files, whose + // definitions the REPL then has - and then run the doctests. If the file + // itself fails, its traceback (from the Run part) is the answer, and no + // report repeats it ("skipped"). What the file printed shows once, from the + // Run part; the report leaves it out. if (event.data.type === 'test') { - let output = '' + if (!await runProgram(event.data.project)) { + self.postMessage({ type: 'test-result', skipped: true }) + return + } try { const pyodide = await getPyodide() - await mountProjectFiles(pyodide, event.data.project.files) lastTestFiles = event.data.project.files lastTestMainFile = event.data.project.mainFile - pyodide.setStdout({ batched: (text) => { output += `${text}\n` } }) - pyodide.setStderr({ batched: (text) => { output += `${text}\n` } }) + pyodide.setStdout({ batched: () => {} }) + pyodide.setStderr({ batched: () => {} }) const result = JSON.parse(String(callPython(pyodide, '_glpython_run_doctests', `/${event.data.project.mainFile}`))) - self.postMessage({ type: 'test-result', output, ...result }) + self.postMessage({ type: 'test-result', ...result }) } catch (error) { - self.postMessage({ type: 'test-result', output, error: String(error) }) + self.postMessage({ type: 'test-result', error: String(error) }) } return }