From 856955ccb7091e7b55f236caccd2096dc7d79724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= Date: Mon, 26 Oct 2026 10:02:26 +0100 Subject: [PATCH] A new Tests report is shown, not appended out of view (P20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a Tests run with failures, «Tests» looked dead once the code was edited: the button was enabled and the second run tested the edited code, but since the console became one transcript (P18) the new report is appended below the previous one - and a learner who scrolled up to read the first report doesn't see it arrive. ▶ Ausführen "fixed" it by clearing the console. The console still follows streamed output only while it is at its end, but an entry a click asked for - a Tests report, a REPL input, the stop note - scrolls the console to that entry's start (the report's summary first), setting the console's scrollTop only. test:console (scripts/check-console-browser.mjs, in CI): Tests → fail → edit → Tests and Tests → pass → edit → Tests, with the console scrolled up in between, run the edited code and show its report. Drives the built embed in Chrome through its protocol, with Pyodide; fails without the fix. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ++- CLAUDE.md | 3 +- package-lock.json | 14 +++++ package.json | 2 + scripts/check-console-browser.mjs | 85 +++++++++++++++++++++++++++++++ src/GlPythonWorkspace.tsx | 31 ++++++++--- 6 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 scripts/check-console-browser.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e75530a..a42ed42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,10 @@ jobs: # condition comments, readable types and argument checks, the doctest # runner, and which errors get a hint. - run: npm run test:runtime + # The console in Chrome (on the runner) with Pyodide from its CDN, + # driving the embed built above: Tests → edit → Tests shows the new + # report (P20). + - run: npm run test:console - name: Not checked here run: | - echo "::notice title=Not run in CI::Needs Pyodide in a browser, so it isn't checked here: the worker (run, REPL, Tests in the browser, input() bridge, gturtle) and the UI. Check these in the embed preview (npm run build:embed && npm run preview:embed)." + echo "::notice title=Not run in CI::Needs Pyodide in a browser, so apart from test:console it isn't checked here: the worker (run, REPL, input() bridge, gturtle) and the UI. Check these in the embed preview (npm run build:embed && npm run preview:embed)." diff --git a/CLAUDE.md b/CLAUDE.md index 51a4090..d941fe1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,13 +15,14 @@ npm run lint # oxlint npm run test:protocol # builds the library, then runs scripts/check-embed-protocol.mjs against dist/ npm run test:runtime # the runtime under CPython: scripts/check-runtime-{columns,values,doctests,calls,hints,fidelity,replay}.py npm run test:hints # the error hints' wording (de/en) against the built dist/ (run a build first) +npm run test:console # the console in Chrome with Pyodide, against dist-embed/ (run build:embed first) npm run build:embed # builds the iframe app + demo page into dist-embed/ npm run preview:embed # serves dist-embed/ with COOP/COEP headers; open /demo.html ``` `npm run dev:embed` currently 404s: `embed/index.html` loads `../src/embed/main.tsx`, which lies outside the Vite root, so only the build works. Use `build:embed` + `preview:embed`. -There is no unit test framework. The automated checks are `test:protocol` (plain `node:assert` against the built `dist/index.js`; add protocol validation cases there) and `test:runtime` (plain Python scripts that exec `src/runtime/` under CPython 3.12, Pyodide's version; add runtime regression cases there). `.github/workflows/ci.yml` runs lint, both builds and the checks on every pull request and on pushes to `main`. Nothing that needs Pyodide in a browser (the worker, the UI) is checked automatically. `npm run lint` has one known warning in `GlPythonWorkspace.tsx` (`react(immutability)` on the `cursor` variable in `Frame`). +There is no unit test framework. The automated checks are `test:protocol` (plain `node:assert` against the built `dist/index.js`; add protocol validation cases there) and `test:runtime` (plain Python scripts that exec `src/runtime/` under CPython 3.12, Pyodide's version; add runtime regression cases there). `.github/workflows/ci.yml` runs lint, both builds and the checks on every pull request and on pushes to `main`. The one browser check is `test:console` (`scripts/check-console-browser.mjs`): `playwright-core` drives the installed Chrome (GitHub's runner has one) against `vite preview` of `dist-embed/`, through the embed protocol, with Pyodide from its CDN; add browser regression cases there. Everything else that needs Pyodide in a browser (the worker, most of the UI) is not checked automatically. `npm run lint` has one known warning in `GlPythonWorkspace.tsx` (`react(immutability)` on the `cursor` variable in `Frame`). **Releases.** - The package is consumed from git: the glpython IDE (`../glpython`) pins `glpython-editor` by tag in its `package.json`. The `prepare` script builds `dist/` on install. diff --git a/package-lock.json b/package-lock.json index 4c78f15..e6bbe87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@vitejs/plugin-react": "^6.1.1", "lucide-react": "^1.47.0", "oxlint": "^1.81.0", + "playwright-core": "^1.48.2", "react": "^19.2.8", "react-dom": "^19.2.8", "typescript": "~6.0.2", @@ -2041,6 +2042,19 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright-core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz", + "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/package.json b/package.json index a91ef07..cde83fd 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "preview:embed": "vite preview --config vite.embed.config.ts", "test:protocol": "npm run build && node scripts/check-embed-protocol.mjs", "test:hints": "node scripts/check-error-hints.mjs", + "test:console": "node scripts/check-console-browser.mjs", "test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py", "prepare": "npm run build" }, @@ -43,6 +44,7 @@ "@vitejs/plugin-react": "^6.1.1", "lucide-react": "^1.47.0", "oxlint": "^1.81.0", + "playwright-core": "^1.48.2", "react": "^19.2.8", "react-dom": "^19.2.8", "typescript": "~6.0.2", diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs new file mode 100644 index 0000000..901dac9 --- /dev/null +++ b/scripts/check-console-browser.mjs @@ -0,0 +1,85 @@ +// 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. +// +// 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 +// CDN. Needs Google Chrome (GitHub's ubuntu-latest has it). +// +// npm run build:embed && npm run test:console +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { chromium } from 'playwright-core' + +const port = 4185 +const origin = `http://localhost:${port}` +const server = spawn('npx', ['vite', 'preview', '--config', 'vite.embed.config.ts', '--port', String(port), '--strictPort'], { stdio: 'ignore' }) +const stopServer = () => server.kill() +process.on('exit', stopServer) + +// The parent page, served on the embed's own origin: it loads the embed and +// passes messages on, version 4. +const harness = ` + +` + +const program = (examples) => `def schnitt(a, b):\n """\n${examples.map(([call, want]) => ` >>> ${call}\n ${want}\n`).join('')} """\n return (a + b) / 2\n` +// Enough failing examples that the report is taller than the console. +const failing = program([['schnitt(4, 6)', '5'], ['schnitt(6, 4)', '4.5'], ['schnitt(2, 2)', '3'], ['schnitt(1, 1)', '7'], ['schnitt(0, 4)', '1']]) +const edited = program([['schnitt(4, 6)', '5.0'], ['schnitt(6, 4)', '4.5'], ['schnitt(2, 2)', '3'], ['schnitt(1, 1)', '7'], ['schnitt(0, 4)', '1']]) +const passing = program([['schnitt(4, 6)', '5.0']]) +const passingEdited = program([['schnitt(4, 6)', '5.0'], ['schnitt(1, 2)', '1.5']]) + +let failed = 0 +const browser = await chromium.launch({ channel: 'chrome', headless: true }) +try { + for (let attempt = 0; ; attempt++) { + try { await fetch(`${origin}/index.html`); break } catch { if (attempt > 50) throw new Error('vite preview did not start') } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + for (const [label, first, second, firstSummary, secondSummary] of [ + ['Tests → fail → edit → Tests', failing, edited, '0 von 5 Beispielen bestanden', '1 von 5 Beispielen bestanden'], + ['Tests → pass → edit → Tests', passing, passingEdited, '1 von 1 Beispielen bestanden', '2 von 2 Beispielen bestanden'], + ]) { + 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 summaries = frame.locator('.test-summary') + await page.evaluate((source) => window.send('test', source), first) + await summaries.first().waitFor({ timeout: 60000 }) + // 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 }) + await page.waitForTimeout(300) + try { + assert.deepEqual(await summaries.allInnerTexts(), [firstSummary, secondSummary], `${label}: the second run tests the edited code`) + const inView = await frame.locator('.console').evaluate((element) => { + const view = element.getBoundingClientRect() + const summary = [...element.querySelectorAll('.test-summary')].at(-1).getBoundingClientRect() + return summary.top >= view.top && summary.bottom <= view.bottom + }) + assert.ok(inView, `${label}: the new report's summary is in the console's view`) + console.log(`${label}: ok`) + } catch (error) { + failed += 1 + console.error(error.message) + } + await page.close() + } +} finally { + await browser.close() + stopServer() +} +if (failed > 0) process.exit(1) +console.log('Console browser checks passed.') diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index eade513..d206641 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -960,6 +960,11 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // Set at a stop, so the next worker's "ready" doesn't clear the console; // and whether the running program has printed anything yet (its first // output replaces the «… wird ausgeführt» line). + // A console entry a click asked for (a Tests report, a REPL input, the + // stop note): shown as soon as it is there, see the console's scroll effect. + const revealEntryRef = useRef(null) + // Whether the console is scrolled to its end (set by its onScroll). + const consoleAtBottomRef = useRef(true) const keepConsoleOnReadyRef = useRef(false) const runStreamStartedRef = useRef(false) // Streamed output waiting to be shown: applied together every 40 ms rather @@ -1029,7 +1034,9 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { 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') { - setConsoleEntries((entries) => [...entries, { kind: 'tests', id: ++replIdRef.current, report: event.data as TestReport }]) + const id = ++replIdRef.current + revealEntryRef.current = id + setConsoleEntries((entries) => [...entries, { kind: 'tests', id, report: event.data as TestReport }]) setIsTesting(false) } if (event.data.type === 'repl-result') { @@ -1101,6 +1108,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { if (!workerRef.current) return if (workspaceCapabilities.graphics && projectImportsGturtle(project)) onGraphicsOpenChange(true) runStreamStartedRef.current = false + consoleAtBottomRef.current = true setIsRunning(true); setOutput(t.runningOutput(project.mainFile)); setOutputErrorHint(null); setConsoleEntries([]) workerRef.current.postMessage({ type: 'run', @@ -1164,7 +1172,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { setOutput((current) => current === t.runningOutput(project.mainFile) ? '' : current) setConsoleEntries((entries) => [ ...entries.map((entry) => entry.kind === 'repl' ? { ...entry, stale: true, result: entry.result ?? { output: entry.live ?? '' }, live: undefined } : entry), - { kind: 'note' as const, id: ++replIdRef.current, text: t.stoppedNote ?? 'Stopped. Python was restarted.' }, + { kind: 'note' as const, id: (revealEntryRef.current = ++replIdRef.current), text: t.stoppedNote ?? 'Stopped. Python was restarted.' }, ]) } // Reset: Python restarts too, and the console, the visualization and the @@ -1194,6 +1202,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { if (!workerRef.current || !source.trim() || isRunning || isReplBusy) return if (visualTrace) exitVisualizer() const id = rememberReplInput(source) + revealEntryRef.current = id setConsoleEntries((entries) => [...entries, { kind: 'repl', id, source, result: null }]) setIsReplBusy(true) workerRef.current.postMessage({ type: 'repl', id, source }) @@ -1222,11 +1231,21 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { } } // The console follows new output, unless the learner scrolled up to read - // something earlier. - const consoleAtBottomRef = useRef(true) + // something earlier - except for an entry a click asked for: it scrolls to + // that entry's start, so e.g. a second Tests report doesn't land out of + // view below the first one (P20). Only the console scrolls: scrollIntoView + // would move the course page around the embed too. useEffect(() => { const element = outputRef.current - if (element && consoleAtBottomRef.current) element.scrollTop = element.scrollHeight + if (!element) return + const entry = revealEntryRef.current === null ? null : element.querySelector(`[data-console-entry="${revealEntryRef.current}"]`) + if (entry) { + revealEntryRef.current = null + element.scrollTop += entry.getBoundingClientRect().top - element.getBoundingClientRect().top - 8 + consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 + } else if (consoleAtBottomRef.current) { + element.scrollTop = element.scrollHeight + } }, [consoleEntries, output]) useEffect(() => { if (!command) return @@ -2006,7 +2025,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const visualizerWarningNode = <>{isVisualizing && visualTrace?.error && !errorTraceStep &&
{t.visualizerError(visualTrace.error)}
} const editorTabsNode =
{openFiles.map((path) => { const file = project.files[path]; return })}
const editorWrapNode =
{isVisualizing ?
: null} />{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => )}{animationsEnabled && flights.map((flight) => )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => {flight.value})}
}
: { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} options={{ minimap: { enabled: false }, bracketPairColorization: { enabled: false }, fontSize: 15, lineHeight: 24, padding: { top: 22 }, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollBeyondLastLine: false, smoothScrolling: true, automaticLayout: true, tabSize: 4, insertSpaces: true, detectIndentation: false, placeholder: t.editorPlaceholder ?? 'Type code here' }} />}
- const consoleNode = <>{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{ const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ?
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error} : visualStdout || t.noOutput}
: <>
{output}
{consoleEntries.map((entry) => entry.kind === 'tests' ? : entry.kind === 'note' ?

{entry.text}

:
 visualizeReplEntry(entry.id) : undefined} busy={isBusy} />
)}}
{workspaceCapabilities.repl &&
{ event.preventDefault(); submitRepl() }}> { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" />
}
} + const consoleNode = <>{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{ const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ?
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error} : visualStdout || t.noOutput}
: <>
{output}
{consoleEntries.map((entry) => entry.kind === 'tests' ?
: entry.kind === 'note' ?

{entry.text}

:
 visualizeReplEntry(entry.id) : undefined} busy={isBusy} />
)}}
{workspaceCapabilities.repl &&
{ event.preventDefault(); submitRepl() }}> { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" />
}
} // In the stacked layout the graphics show only while the program draws - // for a visualization: if it draws at all, so the panel doesn't appear in // the middle of stepping. Its ✕ hides it until the next drawing.