// runtime.persist() keeps directories across page loads: one page saves them under a key, // a fresh page restores them. What a guest sees through node:fs (lstat, readlink, the // bytes) is the same on both pages. import { test, expect, type Page } from "@playwright/test"; import type { KeepReport, Kernel } from "../src/host/index"; declare global { interface Window { __runtime?: Kernel } } const KEPT = ["/app/keep", "/app/also"]; // Every entry under /app/keep and /app/also as the guest's node:fs sees it, links not followed. const LISTER = ` const fs = require("fs"); const out = []; const walk = (p) => { const st = fs.lstatSync(p); const kind = st.isSymbolicLink() ? "symlink" : st.isDirectory() ? "dir" : "file"; const entry = { path: p, kind, mtimeMs: st.mtimeMs }; if (kind === "symlink") entry.target = fs.readlinkSync(p); if (kind === "file") entry.base64 = fs.readFileSync(p).toString("base64"); out.push(entry); if (kind === "dir") for (const name of fs.readdirSync(p).sort()) walk(p + "/" + name); }; for (const root of ${JSON.stringify(KEPT)}) if (fs.existsSync(root)) walk(root); console.log(JSON.stringify(out)); `; type Entry = { path: string; kind: string; mtimeMs: number; target?: string; base64?: string }; /** Boot a runtime on `page`, with `files` written. */ async function boot(page: Page, files: Record): Promise { await page.goto("/demo/fixture.html"); await page.waitForFunction(() => window.__runtime !== undefined, undefined, { timeout: 20_000 }); await page.evaluate((files) => window.__runtime!.writeTree(files), files); } async function listing(page: Page): Promise { const { code, stdout, stderr } = await page.evaluate(async (lister) => { const runtime = window.__runtime!; runtime.writeTree({ "/lister.cjs": lister }); const proc = runtime.exec("/lister.cjs"); void proc.stdin.getWriter().close(); const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); return { code: await proc.exited, stdout, stderr }; }, LISTER); expect(stderr).toBe(""); expect(code).toBe(0); return JSON.parse(stdout) as Entry[]; } const persist = (page: Page, key: string, save: boolean) => page.evaluate(async ({ key, paths, save }) => { const kept = await window.__runtime!.persist({ key, paths }); return { restored: kept.restored, saved: save ? await kept.save() : null }; }, { key, paths: KEPT, save }); const counts = ({ ms, ...rest }: KeepReport) => (expect(ms).toBeGreaterThanOrEqual(0), rest); test("a fresh page restores what another saved under the key: bytes, symlinks and mtimes", async ({ page, context }) => { await boot(page, { "/app/keep/a.txt": "alpha", "/app/keep/sub/b.json": '{"b":1}', "/app/also/c.txt": "gamma", }); const before = await listing(page); await page.evaluate(async () => { const fs = window.__runtime!.fs; await fs.symlink("sub/b.json", "/app/keep/link"); await fs.symlink("nowhere", "/app/keep/dangling"); await fs.writeFile("/app/keep/sub/bytes.bin", new Uint8Array([0, 255, 128, 10])); }); const saved = await listing(page); expect(saved.length).toBe(before.length + 3); const first = await persist(page, "persist-spec", true); expect(counts(first.restored)).toEqual({ paths: [], files: 0, bytes: 0 }); expect(counts(first.saved!)).toEqual({ paths: KEPT, files: 4, bytes: 5 + 7 + 5 + 4 }); const fresh = await context.newPage(); // What the fresh page has at a kept path is replaced, not merged. await boot(fresh, { "/app/keep/stray.txt": "not saved", "/app/keep/a.txt": "changed" }); await fresh.waitForTimeout(20); const second = await persist(fresh, "persist-spec", false); expect(counts(second.restored)).toEqual({ paths: KEPT, files: 4, bytes: 21 }); const restored = await listing(fresh); expect(restored).toEqual(saved); expect(restored.filter((e) => e.kind === "symlink").map((e) => [e.path, e.target])).toEqual([ ["/app/keep/dangling", "nowhere"], ["/app/keep/link", "sub/b.json"], ]); }); test("a key holds only what was saved under it, and a path it did not save is left alone", async ({ page, context }) => { await boot(page, { "/app/keep/a.txt": "alpha" }); expect(counts((await persist(page, "persist-spec-one", true)).saved!)).toEqual({ paths: ["/app/keep"], files: 1, bytes: 5 }); const fresh = await context.newPage(); await boot(fresh, { "/app/also/mine.txt": "mine" }); const untouched = await listing(fresh); expect(counts((await persist(fresh, "persist-spec-other", false)).restored)).toEqual({ paths: [], files: 0, bytes: 0 }); expect(await listing(fresh)).toEqual(untouched); expect(counts((await persist(fresh, "persist-spec-one", false)).restored)).toEqual({ paths: ["/app/keep"], files: 1, bytes: 5 }); expect((await listing(fresh)).map((e) => e.path)).toEqual(["/app/keep", "/app/keep/a.txt", "/app/also", "/app/also/mine.txt"]); });