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 }