Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 43 additions & 7 deletions scripts/check-console-browser.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
16 changes: 12 additions & 4 deletions src/GlPythonWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@

// 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).
Expand Down Expand Up @@ -708,7 +711,7 @@
{parens && '('}
{lines.map((line, index) => {
const lineStart = cursor
cursor += line.length + 1

Check warning on line 714 in src/GlPythonWorkspace.tsx

View workflow job for this annotation

GitHub Actions / check

react(immutability)

src/GlPythonWorkspace.tsx:714:11: Cannot reassign variable after render completes
return <Fragment key={index}>{index > 0 && '\n'}{renderSegments(lineSegments(line, lineStart))}</Fragment>
})}
{parens && ')'}
Expand Down Expand Up @@ -1033,7 +1036,8 @@
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 }])
Expand Down Expand Up @@ -1130,11 +1134,15 @@
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: {
Expand Down
83 changes: 50 additions & 33 deletions src/pyodide-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
Loading