Files

540 lines
28 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* End-to-end check for Python in the runtime.
*
* The claim under test is not "Python runs" but the one the tool description
* makes: that `python3` and `pip` are on this machine, that the machine is the
* *same* machine the terminal and the agent already share, and that a package
* installed once stays installed — across commands, and across a reload.
*
* So it drives three surfaces rather than one. `dsh.shell` is the agent's path,
* the terminal is the user's, and a reload is what a returning visitor does.
* A Python that passed the first and failed the second would be exactly the
* split machine this build exists to avoid.
*
* Usage: `npx tsx scripts/python-e2e.ts [--url <url>] [--case <name>] [--headed]`
*/
import { chromium, type Page } from 'playwright'
const args = process.argv.slice(2)
const url = valueOf('--url') ?? 'http://127.0.0.1:4173/'
const only = valueOf('--case')
const headed = args.includes('--headed')
/** Read a `--flag value` pair from argv. */
function valueOf(flag: string): string | undefined {
const index = args.indexOf(flag)
return index === -1 ? undefined : args[index + 1]
}
/** Assert a condition, failing the check with a readable message. */
function expect(condition: boolean, message: string): void {
if (!condition) throw new Error(`assertion failed: ${message}`)
}
/** Wait until the app's own boot screen is gone and the shell rendered. */
async function waitForShell(page: Page): Promise<void> {
await page.waitForFunction(() => {
const root = document.getElementById('root')
return root !== null && root.childElementCount > 0 && document.getElementById('dshw-boot') === null
}, undefined, { timeout: 180_000 })
}
/** What one command produced, as the agent would see it. */
interface Outcome {
status: number
out: string
}
/**
* Run one command through the harness's own path.
*
* Both streams are returned joined, because that is how the runtime reports a
* command and how the agent reads one — and because the interpreter writes its
* progress to standard error while the program writes its answer to standard
* output, so a check that looked at only one of them would miss half of what
* this suite is about.
* @param page - the loaded app.
* @param script - the shell source to run.
* @returns the exit status and everything the command wrote.
*/
async function shell(page: Page, script: string): Promise<Outcome> {
const result = await page.evaluate(async (source: string) => globalThis.dsh.shell(source), script)
// Colour stripped before anything matches on it: `python3 -m json.tool` and
// `pip` both write SGR codes into their output, and a pattern anchored on a
// quote or a word boundary sees the escape's letters as neighbouring text.
return { status: result.status, out: `${result.stdout}${result.stderr}`.replace(/\u001b\[[0-9;]*m/g, '') }
}
/** Run a command and require it to have succeeded. */
async function ok(page: Page, script: string): Promise<string> {
const result = await shell(page, script)
expect(result.status === 0, `\`${script}\` exited ${String(result.status)}:\n ${result.out.replace(/\n/g, '\n ')}`)
return result.out
}
/** One check. */
interface Check {
name: string
run(page: Page): Promise<void>
}
/** Whether this page already has a terminal open. */
let terminalOpen = false
/**
* Open the terminal through the plugin's own sidebar action.
*
* The surface shows its notice over everything until it is acknowledged, and a
* click that lands on that mask never reaches the button — so the notice is
* dismissed first and the action is invoked on the element itself.
*
* Called at most once per load, because the action is a *toggle*: a second
* check that opened it again would close it, and every `send` after that would
* go nowhere while the buffer still showed a prompt.
* @param page - the page to drive.
*/
async function openTerminal(page: Page): Promise<void> {
if (terminalOpen) return
const acknowledge = page.getByRole('button', { name: /Continue/ })
await acknowledge.first().waitFor({ state: 'visible', timeout: 30_000 }).catch(() => undefined)
if (await acknowledge.count() > 0) {
await acknowledge.first().click().catch(() => undefined)
await acknowledge.first().waitFor({ state: 'detached', timeout: 20_000 }).catch(() => undefined)
}
const action = page.getByRole('button', { name: /Terminal/ })
await action.first().waitFor({ state: 'visible', timeout: 30_000 })
await action.first().evaluate((node: HTMLElement) => { node.click() })
await page.waitForFunction(
() => /[❯$]\s*$/m.test((globalThis as { __DSH_TERMINAL__?: { text(): string } }).__DSH_TERMINAL__?.text() ?? ''),
undefined,
{ timeout: 300_000 },
)
terminalOpen = true
}
/**
* Wait for something to appear on the terminal, and say what was there instead.
*
* A bare `waitForFunction` that times out reports only that it timed out, which
* for a screen is the least useful half of the answer.
* @param page - the page driving the terminal.
* @param pattern - what to wait for.
* @param what - how to describe it in a failure.
* @param timeoutMs - how long to wait.
*/
async function waitForScreen(page: Page, pattern: RegExp, what: string, timeoutMs = 60_000): Promise<void> {
try {
// Source *and* flags: a pattern rebuilt from `source` alone loses `m`, and
// then `^` and `$` anchor to the whole screen rather than to a line — which
// is a wait that silently never matches.
await page.waitForFunction(
([source, flags]: [string, string]) => new RegExp(source, flags).test(
(globalThis as { __DSH_TERMINAL__?: { text(): string } }).__DSH_TERMINAL__?.text() ?? ''),
[pattern.source, pattern.flags] as [string, string],
{ timeout: timeoutMs },
)
} catch {
const screen = await page.evaluate(() =>
(globalThis as { __DSH_TERMINAL__?: { text(): string } }).__DSH_TERMINAL__?.text() ?? '(no terminal)')
throw new Error(`${what} never appeared; the terminal showed:\n${screen.split('\n').slice(-30).join('\n')}`)
}
}
/**
* Type a line into whatever is reading the terminal, until it answers.
*
* The prompt appearing and the program being ready to read are not the same
* moment: bytes sent in the gap between them are dropped, and the reader waits
* forever for a line that was already typed. A person retypes; this does too,
* which is also the closest thing to what a person would see.
* @param page - the page driving the terminal.
* @param line - what to type, without its newline.
* @param answer - what the reader should print in reply.
* @param what - how to describe the exchange in a failure.
*/
async function typeInto(page: Page, line: string, answer: RegExp, what: string): Promise<void> {
for (let attempt = 0; attempt < 4; attempt++) {
await page.evaluate((text: string) => {
(globalThis as { __DSH_TERMINAL__?: { send(text: string): void } }).__DSH_TERMINAL__?.send(`${text}\n`)
}, line)
const answered = await page.waitForFunction(
([source, flags]: [string, string]) => new RegExp(source, flags).test(
(globalThis as { __DSH_TERMINAL__?: { text(): string } }).__DSH_TERMINAL__?.text() ?? ''),
[answer.source, answer.flags] as [string, string],
{ timeout: 15_000 },
).then(() => true, () => false)
if (answered) return
}
const screen = await page.evaluate(() =>
(globalThis as { __DSH_TERMINAL__?: { text(): string } }).__DSH_TERMINAL__?.text() ?? '(no terminal)')
throw new Error(`${what} never answered; the terminal showed:\n${screen.split('\n').filter(l => l.trim() !== '').slice(-20).join('\n')}`)
}
let counter = 0
/**
* Type one command into the terminal and wait for it to finish.
*
* A prompt is not a reliable "finished" signal — it appears in the middle of
* output as often as at the end — so a unique marker is echoed after the
* command, and it carries the exit status back with it.
* @param page - the page driving the terminal.
* @param script - what to type.
* @param timeoutMs - how long the command may take.
* @returns the terminal text around the marker.
*/
async function terminal(page: Page, script: string, timeoutMs = 180_000): Promise<string> {
const marker = `__done_${String(counter++)}`
await page.evaluate(
([source, sentinel]) => {
const view = (globalThis as { __DSH_TERMINAL__?: { send(text: string): void } }).__DSH_TERMINAL__
view?.send(`${source}; echo ${sentinel}:$?\n`)
},
[script, marker] as const,
)
await page.waitForFunction(
(sentinel: string) => {
const text = (globalThis as { __DSH_TERMINAL__?: { text(): string } }).__DSH_TERMINAL__?.text() ?? ''
return new RegExp(`${sentinel}:\\d`).test(text)
},
marker,
{ timeout: timeoutMs },
)
const screen = await page.evaluate(() =>
(globalThis as { __DSH_TERMINAL__?: { text(): string } }).__DSH_TERMINAL__?.text() ?? '')
const lines = screen.split('\n')
const end = lines.findLastIndex(line => new RegExp(`${marker}:\\d`).test(line))
return end === -1 ? lines.slice(-40).join('\n') : lines.slice(Math.max(0, end - 40), end + 1).join('\n')
}
const checks: Check[] = [
{
// The whole feature rests on one fact about `jsh`: it resolves a command
// against the `PATH` it was spawned with. If the harness's directory is not
// in front, `python3` is the container's RustPython and every check below
// would pass against the wrong interpreter.
name: 'python3 is the harness\'s, not the container\'s',
async run(page) {
const which = await ok(page, 'which python3')
expect(/\.dsh\/bin\/python3/.test(which), `python3 resolves elsewhere: ${which.trim()}`)
const version = await ok(page, 'python3 -V')
expect(/Python 3\.1[4-9]/.test(version), `unexpected version: ${version.trim()}`)
expect(!/RustPython/.test(version), `this is still RustPython: ${version.trim()}`)
const pip = await ok(page, 'which pip')
expect(/\.dsh\/bin\/pip/.test(pip), `pip resolves elsewhere: ${pip.trim()}`)
},
},
{
// Every one of these is something RustPython could not do, which is why
// they are the ones asserted.
name: 'the standard library is the real one',
async run(page) {
const out = await ok(page, 'python3 -c "import pathlib, sqlite3, decimal, dataclasses, typing, csv, zipfile; '
+ 'print(pathlib.Path(\'.\').resolve(), sqlite3.sqlite_version)"')
expect(/\/home\/dsh\/workspace/.test(out), `pathlib did not resolve the working directory:\n${out}`)
const math = await ok(page, 'python3 -c "import statistics, fractions; '
+ 'print(statistics.median([3,1,2]), fractions.Fraction(1,3) + fractions.Fraction(1,6))"')
expect(/2 1\/2/.test(math), `the standard library computed the wrong answer:\n${math}`)
},
},
{
name: 'the shell\'s files are Python\'s files',
async run(page) {
await ok(page, 'echo hello-from-the-shell > shared.txt')
const read = await ok(page, 'python3 -c "print(open(\'shared.txt\').read().strip().upper())"')
expect(/HELLO-FROM-THE-SHELL/.test(read), `Python could not read the shell's file:\n${read}`)
await ok(page, 'python3 -c "open(\'from-python.txt\', \'w\').write(\'hello-from-python\')"')
const back = await ok(page, 'cat from-python.txt')
expect(/hello-from-python/.test(back), `the shell could not read Python's file:\n${back}`)
},
},
{
name: 'scripts, modules, stdin and arguments',
async run(page) {
await ok(page, 'node -e \'const nl=String.fromCharCode(10); require("fs").writeFileSync("script.py", '
+ '["import sys", "print(\\"argv\\", sys.argv[1:])", "sys.exit(3)"].join(nl))\'')
const script = await shell(page, 'python3 script.py alpha beta')
expect(/argv \['alpha', 'beta'\]/.test(script.out), `sys.argv is wrong:\n${script.out}`)
expect(script.status === 3, `sys.exit(3) reported ${String(script.status)}`)
const module = await ok(page, 'echo \'{"b":2,"a":1}\' > j.json && python3 -m json.tool --sort-keys j.json')
expect(/"a": 1[\s\S]*"b": 2/.test(module), `\`python3 -m json.tool\` did not sort:\n${module}`)
const piped = await ok(page, 'echo "print(6*7)" | python3 -')
expect(/\b42\b/.test(piped), `a program on standard input did not run:\n${piped}`)
const stdin = await ok(page, 'echo tea | python3 -c "import sys; print(sys.stdin.read().strip().upper())"')
expect(/TEA/.test(stdin), `Python could not read piped input:\n${stdin}`)
// `-uc` is one option group, not an unknown option: CPython accepts it and
// scripts written by habit pass `-u`.
const bundled = await ok(page, 'python3 -uc "print(\'bundled flags\')"')
expect(/bundled flags/.test(bundled), `a combined option group did not run:\n${bundled}`)
// `-X` and `-W` carry their value attached as often as separated.
const valued = await ok(page, 'python3 -Xdev -Wignore -c "print(\'valued flags\')"')
expect(/valued flags/.test(valued), `an attached option value was not accepted:\n${valued}`)
const unknown = await shell(page, 'python3 -Z -c "print(1)"')
expect(unknown.status === 2 && /Unknown option/.test(unknown.out), `an unknown option was accepted:\n${unknown.out}`)
},
},
{
name: 'exit codes and tracebacks are CPython\'s',
async run(page) {
const clean = await shell(page, 'python3 -c "print(1)"')
expect(clean.status === 0, `a working command exited ${String(clean.status)}`)
const raised = await shell(page, 'python3 -c "1/0"')
expect(raised.status === 1, `an uncaught exception exited ${String(raised.status)}`)
expect(/Traceback \(most recent call last\)[\s\S]*ZeroDivisionError: division by zero/.test(raised.out),
`the traceback is not CPython's:\n${raised.out}`)
// The wrapper that runs the program must not appear in the user's
// traceback; if it does, every error the agent reads names a file it
// cannot open.
expect(!/__dsh_run__|_pyodide|runpy/.test(raised.out), `the traceback exposes the harness:\n${raised.out}`)
const missing = await shell(page, 'python3 no-such-file.py')
expect(missing.status === 2, `a missing script exited ${String(missing.status)}`)
expect(/can't open file/.test(missing.out), `unexpected message for a missing script:\n${missing.out}`)
const exit = await shell(page, 'python3 -c "raise SystemExit(7)"')
expect(exit.status === 7, `SystemExit(7) reported ${String(exit.status)}`)
// `bool` is an `int` in Python, and CPython exits 1 here.
const truthy = await shell(page, 'python3 -c "import sys; sys.exit(True)"')
expect(truthy.status === 1, `sys.exit(True) reported ${String(truthy.status)}`)
// A script's traceback goes through a different path than `-c`, and it is
// the path that used to carry runpy's frames into the user's output.
await ok(page, 'node -e \'const nl=String.fromCharCode(10); require("fs").writeFileSync("boom.py", '
+ '["def inner():", " raise ValueError(\\"from a script\\")", "inner()"].join(nl))\'')
const script = await shell(page, 'python3 boom.py')
expect(script.status === 1, `a raising script exited ${String(script.status)}`)
expect(/File "\/home\/dsh\/workspace\/boom\.py", line 2, in inner[\s\S]*ValueError: from a script/.test(script.out),
`the script traceback does not name the user's file:\n${script.out}`)
expect(!/runpy|__dsh_run__|_pyodide/.test(script.out), `the script traceback exposes the harness:\n${script.out}`)
},
},
{
name: 'text is bytes: unicode survives the trip',
async run(page) {
const out = await ok(page, 'python3 -c "print(\'héllo — 世界\')"')
expect(/héllo — 世界/.test(out), `unicode did not survive:\n${out}`)
},
},
{
name: 'pip installs a wheel from PyPI, and it stays installed',
async run(page) {
const install = await shell(page, 'pip install six')
expect(install.status === 0, `pip install failed:\n${install.out}`)
expect(/Successfully installed six-|already satisfied/.test(install.out), `pip said nothing useful:\n${install.out}`)
// A *separate* invocation: the interpreter is a fresh process each time,
// so this is what proves the packages live in the container rather than
// in one process's memory.
const imported = await ok(page, 'python3 -c "import six; print(\'six\', six.__version__)"')
expect(/six 1\./.test(imported), `the installed package did not import:\n${imported}`)
const listed = await ok(page, 'pip list')
expect(/six/.test(listed), `pip list does not show it:\n${listed}`)
const frozen = await ok(page, 'pip freeze')
expect(/^six==\d/m.test(frozen), `pip freeze is not requirements-shaped:\n${frozen}`)
const shown = await ok(page, 'pip show six')
expect(/Name: six[\s\S]*Version: 1\./.test(shown), `pip show is not pip-shaped:\n${shown}`)
// Progress belongs on standard error: a caller that redirects standard
// output must get the answer and nothing else.
const redirected = await ok(page, 'pip install six > pip-stdout.txt && cat pip-stdout.txt')
expect(!/Loading |attempting to load|caching the wheel/.test(redirected),
`interpreter progress reached standard output:\n${redirected}`)
},
},
{
name: 'pip installs a package Pyodide built for WebAssembly',
async run(page) {
const install = await shell(page, 'pip install numpy')
expect(install.status === 0, `installing numpy failed:\n${install.out}`)
const used = await ok(page, 'python3 -c "import numpy; print(\'numpy\', numpy.__version__, numpy.arange(5).sum())"')
expect(/numpy \d+\.\d+.*\b10\b/.test(used), `numpy did not compute:\n${used}`)
},
},
{
name: 'pip fails clearly, and `python3 -m pip` is the same pip',
async run(page) {
const missing = await shell(page, 'pip install dsh-web-no-such-package-12345')
expect(missing.status !== 0, 'installing a package that does not exist reported success')
expect(/ERROR:/.test(missing.out), `the failure is not pip-shaped:\n${missing.out}`)
expect(!/Traceback/.test(missing.out), `the failure is a stack trace rather than a message:\n${missing.out}`)
const viaModule = await ok(page, 'python3 -m pip --version')
expect(/micropip/.test(viaModule), `\`python3 -m pip\` is not the same pip:\n${viaModule}`)
// A flag that would change where or what gets installed must be refused
// rather than ignored, and refused before anything is downloaded.
const refused = await shell(page, 'pip install --target ./vendor six')
expect(refused.status === 2 && /not available here/.test(refused.out),
`--target was not refused:\n${refused.out}`)
// A requirements file is how a project's dependencies actually arrive.
await ok(page, 'echo "six # the compatibility one" > requirements.txt')
const fromFile = await shell(page, 'pip install -r requirements.txt')
expect(fromFile.status === 0 && /installed six-|already satisfied/.test(fromFile.out),
`installing from a requirements file failed:\n${fromFile.out}`)
const removed = await shell(page, 'pip uninstall six')
expect(removed.status === 0, `pip uninstall failed:\n${removed.out}`)
const gone = await shell(page, 'python3 -c "import six"')
expect(gone.status === 1 && /ModuleNotFoundError/.test(gone.out), `the package survived uninstall:\n${gone.out}`)
},
},
{
// The boundary the tool description draws, asserted from both sides: what
// cannot work has to fail with the message the model was promised, and the
// one way out has to actually work.
name: 'what Python can and cannot reach',
async run(page) {
const spawned = await shell(page, 'python3 -c "import subprocess; subprocess.run([\'echo\', \'x\'])"')
expect(spawned.status === 1, `subprocess exited ${String(spawned.status)}`)
expect(/does not support processes/.test(spawned.out), `unexpected subprocess failure:\n${spawned.out}`)
const opened = await shell(page, 'python3 -c "import os; os.popen(\'echo x\')"')
expect(opened.status === 1 && /does not support processes/.test(opened.out),
`unexpected os.popen failure:\n${opened.out}`)
const threaded = await shell(page, 'python3 -c "import threading; threading.Thread(target=print).start()"')
expect(threaded.status === 1 && /can't start new thread/.test(threaded.out),
`unexpected threading failure:\n${threaded.out}`)
// `os.system` is the exception: Emscripten runs it through the container's
// own shell, so it works — and a description that said otherwise would
// send the model looking for a way around something that is not in its way.
const system = await shell(page, 'python3 -c "import os; print(\'rc\', os.system(\'echo through-the-shell\'))"')
expect(system.status === 0 && /through-the-shell[\s\S]*rc 0/.test(system.out),
`os.system did not reach the shell:\n${system.out}`)
const failed = await ok(page, 'python3 -c "import os; print(\'rc\', os.system(\'no-such-command-here\'))"')
expect(/rc 32512/.test(failed), `os.system did not report the shell's status:\n${failed}`)
// `urllib.parse` has no socket in it and must keep working; it is the half
// of `urllib` the description does not say raises.
const parsed = await ok(page, 'python3 -c "from urllib.parse import urlparse; print(urlparse(\'https://a/b\').path)"')
expect(/\/b/.test(parsed), `urllib.parse does not work:\n${parsed}`)
await ok(page, 'node -e \'const nl=String.fromCharCode(10); require("fs").writeFileSync("net.py", '
+ '["import asyncio", "from pyodide.http import pyfetch", "async def go():", '
+ '" r = await pyfetch(\\"https://pypi.org/pypi/six/json\\")", " return r.status", '
+ '"print(\\"status\\", asyncio.run(go()))"].join(nl))\'')
const fetched = await shell(page, 'python3 net.py')
expect(fetched.status === 0 && /status 200/.test(fetched.out), `pyfetch did not reach the network:\n${fetched.out}`)
},
},
{
name: 'the terminal and the agent are the same Python',
async run(page) {
await openTerminal(page)
const version = await terminal(page, 'python3 -V')
expect(/Python 3\.1[4-9]/.test(version), `the terminal has a different Python:\n${version}`)
// numpy was installed through the agent's path; if the terminal cannot
// import it, they are two machines that merely look alike. The assertion
// is on a *computed* value, because the emulator echoes the command line
// — a marker that appears in what was typed would pass without running.
const shared = await terminal(page, 'python3 -c "import numpy; print(\'numpy-here\', numpy.arange(4).sum())"')
expect(/numpy-here 6\b/.test(shared), `the terminal cannot see the agent's packages:\n${shared}`)
await terminal(page, 'python3 -c "open(\'from-terminal.txt\',\'w\').write(\'typed\')"')
const seen = await ok(page, 'cat from-terminal.txt')
expect(/typed/.test(seen), `the agent cannot see what the terminal's Python wrote:\n${seen}`)
},
},
{
name: 'the interactive interpreter answers',
async run(page) {
await openTerminal(page)
await page.evaluate(() => {
(globalThis as { __DSH_TERMINAL__?: { send(text: string): void } }).__DSH_TERMINAL__?.send('python3\n')
})
await waitForScreen(page, />>>\s*$/m, 'the interpreter prompt', 120_000)
// A product no other line in the scrollback contains, matched loosely
// because the emulator's buffer is a fixed grid and pads every line with
// the spaces to its right.
await typeInto(page, '123 * 456', /\b56088\b/, 'the interpreter')
// A block: the console, not this suite, is what decides that `def` is
// unfinished and that a blank line ends it. The lines in the middle are
// sent without retrying, because by now the channel has carried a line
// and because retyping half a block would be a syntax error rather than
// a second chance.
await typeInto(page, 'def twice(n):', /^\.\.\.\s*$/m, 'the continuation prompt')
await page.evaluate(() => {
const view = (globalThis as { __DSH_TERMINAL__?: { send(text: string): void } }).__DSH_TERMINAL__
view?.send(' return n * 2\n')
view?.send('\n')
})
await waitForScreen(page, /^>>>\s*$/m, 'the prompt after the block')
await typeInto(page, 'twice(21)', /\b42\b/, 'a function defined across lines')
// Left at a prompt, the next check would type into Python rather than the
// shell, so the session is ended the way a person ends one — and proved
// ended by running a shell command, because a prompt on screen may be one
// that scrolled past rather than the one waiting for input.
// Retried as a pair: if the `exit()` is the keystroke that gets dropped,
// the `echo` lands in Python instead and raises, and the next attempt
// sends both again. Matching a whole line is what tells the echo of the
// command apart from its output.
let left = false
for (let attempt = 0; attempt < 4 && !left; attempt++) {
await page.evaluate(() => {
const view = (globalThis as { __DSH_TERMINAL__?: { send(text: string): void } }).__DSH_TERMINAL__
view?.send('exit()\n')
})
await page.waitForTimeout(1500)
await page.evaluate(() => {
const view = (globalThis as { __DSH_TERMINAL__?: { send(text: string): void } }).__DSH_TERMINAL__
view?.send('echo out-of-the-repl\n')
})
left = await page.waitForFunction(
() => /^out-of-the-repl$/m.test(
(globalThis as { __DSH_TERMINAL__?: { text(): string } }).__DSH_TERMINAL__?.text() ?? ''),
undefined,
{ timeout: 15_000 },
).then(() => true, () => false)
}
expect(left, 'the shell did not come back after exit()')
},
},
{
// The promise the README makes to a returning visitor: the interpreter is
// fetched once, and what was installed into it is still there.
name: 'a reload keeps the interpreter and the packages',
async run(page) {
await ok(page, 'pip install six')
await page.evaluate(async () => { await globalThis.dsh.flush() })
await page.reload({ waitUntil: 'domcontentloaded', timeout: 120_000 })
terminalOpen = false
await waitForShell(page)
const after = await shell(page, 'python3 -c "import six, numpy; print(\'both survived\', six.__version__)"')
expect(after.status === 0, `Python did not survive the reload:\n${after.out}`)
expect(/both survived/.test(after.out), `the installed packages did not survive:\n${after.out}`)
expect(!/Installing Python/.test(after.out), `the interpreter was downloaded again:\n${after.out}`)
},
},
]
/** Run the selected checks. */
async function main(): Promise<void> {
const browser = await chromium.launch({ headless: !headed })
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await context.newPage()
const errors: string[] = []
page.on('pageerror', (error: Error) => { errors.push(error.message) })
let failures = 0
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 120_000 })
await waitForShell(page)
const isolated = await page.evaluate(() => globalThis.crossOriginIsolated)
expect(isolated, 'the page is not cross-origin isolated, so the runtime cannot start')
for (const check of checks) {
if (only !== undefined && check.name !== only) continue
process.stdout.write(`▶ ${check.name}\n`)
const started = Date.now()
try {
await check.run(page)
process.stdout.write(` ✓ ${((Date.now() - started) / 1000).toFixed(1)}s\n`)
} catch (error) {
failures++
process.stdout.write(` ✗ ${error instanceof Error ? error.message : String(error)}\n`)
await page.screenshot({ path: `/tmp/dsh-python-${check.name.replace(/\W+/g, '-')}.png` }).catch(() => undefined)
}
}
} finally {
await browser.close()
}
if (errors.length > 0) process.stdout.write(`\npage errors:\n ${errors.slice(0, 5).join('\n ')}\n`)
process.stdout.write(failures === 0 ? '\n✓ Python works\n' : `\n✗ ${String(failures)} check(s) failed\n`)
process.exit(failures === 0 ? 0 : 1)
}
void main()