// Run a guest program to exit on the fs fixture page and collect its stdio. import { test, expect, type Page } from "@playwright/test"; import type { ExecOptions, Kernel } from "../src/host/index"; import { listGoldens, maskTestDurations, readGolden, type GuestGolden } from "../scripts/golden-files.ts"; declare global { interface Window { __runtime?: Kernel } } export interface GuestResult { code: number; signal: string | null; stdout: string; stderr: string; } /** Like spawnSync, the guest's stdin gets `stdin` (or nothing) and is then closed. */ export async function runGuest( page: Page, files: Record, entry: string, stdin = "", options: ExecOptions = {}, ): Promise { await page.goto("/demo/fixture.html"); await page.waitForFunction(() => window.__runtime !== undefined, undefined, { timeout: 20_000 }); return await page.evaluate(async ({ files, entry, stdin, options }) => { const runtime = window.__runtime!; runtime.writeTree(files); const proc = runtime.exec(entry, options); const writer = proc.stdin.getWriter(); if (stdin) void writer.write(new TextEncoder().encode(stdin)); void writer.close(); const dec = new TextDecoder(); let stdout = ""; let stderr = ""; const piped = Promise.all([ proc.stdout.pipeTo(new WritableStream({ write(c) { stdout += dec.decode(c); } })), proc.stderr.pipeTo(new WritableStream({ write(c) { stderr += dec.decode(c); } })), ]); const code = await proc.exited; const signal = await proc.signal; await piped; return { code, signal, stdout, stderr }; }, { files, entry, stdin, options }); } /** Run a guest kept under tests/fixtures/guests/ (also runnable by real Node, which is * where each spec's expected lines come from) and return its stdout lines. */ export async function guestFileLines(page: Page, name: string): Promise { const res = await page.request.get(`/tests/fixtures/guests/${name}`); expect(res.ok()).toBe(true); return await guestLines(page, { [`/app/${name}`]: await res.text() }, `/app/${name}`); } /** Run to a clean exit and return stdout lines. */ export async function guestLines(page: Page, files: Record, entry: string): Promise { const res = await runGuest(page, files, entry); expect(res.stderr).toBe(""); expect(res.code).toBe(0); return res.stdout.trimEnd().split("\n"); } /** Every golden under tests/goldens/guests whose guest starts with one of `prefixes`, as one test * each: the kernel must print exactly what the pinned Node did. Record or re-check them * with `node scripts/golden.mjs`. */ export function goldenSuite(prefixes: string[]): void { const guests = prefixes.flatMap((p) => listGoldens("guests", p)); test("the suite has goldens", () => expect(guests.length).toBeGreaterThan(0)); for (const guest of guests) goldenTest(readGolden("guests", guest)); } /** Run a golden's guest with its deps, the way it was recorded. */ export async function runGoldenGuest(page: Page, g: GuestGolden): Promise { const files: Record = {}; for (const name of [g.guest, ...(g.deps ?? [])]) { const res = await page.request.get(`/tests/fixtures/guests/${name}`); expect(res.ok()).toBe(true); files[`/app/${name}`] = await res.text(); } return await runGuest(page, files, `/app/${g.guest}`, g.stdin); } /** One golden as a test. With `fails`, the kernel is known not to match yet and the * test is expected to fail, so a fix shows up as a failure to delete the reason. */ export function goldenTest(g: GuestGolden, fails?: string): void { test(`golden ${g.guest} matches Node`, async ({ page }) => { test.fail(fails !== undefined, fails); const out = await runGoldenGuest(page, g); // Narrow, opt-in masking (see GuestGolden#mask): only guests that declare it get // their durations/internal stack frames normalized, and both sides (the golden // recorded under real Node and the kernel's live output) go through the exact // same function so neither side is favored. const mask = (text: string): string => (g.mask ? maskTestDurations(text) : text); expect({ code: out.signal === null ? out.code : null, signal: out.signal, stdout: mask(out.stdout), stderr: mask(out.stderr.replace(/\(node:\d+\)/g, "(node:PID)")), }).toEqual({ code: g.code, signal: g.signal ?? null, stdout: mask(g.stdout), stderr: mask(g.stderr) }); }); } /** One step of a terminal session: wait until the master has shown `waitFor` (since the * previous wait), then type `write` or resize the terminal. */ export type PtyStep = { waitFor?: string; write?: string; resize?: [cols: number, rows: number] }; export interface PtyResult { code: number; signal: string | null; /** Everything the master read: output and echo, after output processing. */ output: string; stderr: string; } /** Run a guest on a pty of `size`, driving it with `steps`, until it exits. */ export async function runPtyGuest( page: Page, source: string, steps: PtyStep[], options: { size?: [number, number]; env?: Record } = {}, ): Promise { await page.goto("/demo/fixture.html"); await page.waitForFunction(() => window.__runtime !== undefined, undefined, { timeout: 20_000 }); return await page.evaluate(async ({ source, steps, size, env }) => { const runtime = window.__runtime!; runtime.writeTree({ "/app/main.cjs": source }); const proc = runtime.exec("/app/main.cjs", { pty: { cols: size[0], rows: size[1] }, env }); const dec = new TextDecoder(); let output = ""; let stderr = ""; let seen = 0; const piped = Promise.all([ proc.stdout.pipeTo(new WritableStream({ write(c) { output += dec.decode(c); } })), proc.stderr.pipeTo(new WritableStream({ write(c) { stderr += dec.decode(c); } })), ]); let exited = false; void proc.exited.then(() => { exited = true; }); for (const step of steps) { if (step.waitFor !== undefined) { const text = step.waitFor; await new Promise((resolve, reject) => { const started = Date.now(); const check = (): void => { const at = output.indexOf(text, seen); if (at !== -1) { seen = at + text.length; resolve(); } else if (exited || Date.now() - started > 5000) { reject(new Error(`never saw ${JSON.stringify(text)}; output so far ${JSON.stringify(output)}`)); } else setTimeout(check, 5); }; check(); }); } if (step.write !== undefined) proc.pty.write(step.write); if (step.resize) proc.pty.resize(...step.resize); } const code = await proc.exited; const signal = await proc.signal; await piped; return { code, signal, output, stderr }; }, { source, steps, size: options.size ?? [80, 24], env: options.env ?? {} }); }