// scripts/snapshot.mjs turns an app directory into the snapshot runtime.mountSnapshot takes. import { test, expect } from "@playwright/test"; import type { Kernel } from "../src/host/index"; // @ts-expect-error untyped build script import { snapshot, encodeSnapshot } from "../scripts/snapshot.mjs"; // @ts-expect-error untyped build script import { pinMtimes } from "../scripts/fixed-mtime.mjs"; declare global { interface Window { __runtime?: Kernel } } interface SnapFile { path: string; bytes: Uint8Array; mtimeMs: number } interface Snap { version: 3; files: SnapFile[] } const APP = "tests/fixtures/snap-app"; const MTIME = 1704067200000; // 2024-01-01T00:00:00.000Z, scripts/fixed-mtime.mjs test.beforeAll(() => pinMtimes(APP)); function toHex(bytes: Uint8Array): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } // A kept file's content, as text when it decodes as UTF-8 and as hex otherwise — // mirrors the old text/base64 split without the v2 shape. const utf8 = new TextDecoder("utf-8", { fatal: true }); function describe(bytes: Uint8Array): string { try { return utf8.decode(bytes); } catch { return toHex(bytes); } } test("keeps app assets and every package file but docs, maps and addons", () => { const snap = snapshot(APP, "/srv") as Snap; expect(snap.files.map((f) => [f.path, describe(f.bytes), f.mtimeMs])).toEqual([ ["/srv/node_modules/pkg/bin/tool", "#!/usr/bin/env node\nrequire(\"../dist/index.cjs\");\n", MTIME], ["/srv/node_modules/pkg/dist/blob.js", "fffe00", MTIME], ["/srv/node_modules/pkg/dist/index.cjs", "module.exports = 1;\n", MTIME], ["/srv/node_modules/pkg/dist/index.d.cts", "export {};\n", MTIME], ["/srv/node_modules/pkg/dist/mod.wasm", "\u0000asm\u0001\u0000\u0000\u0000", MTIME], ["/srv/node_modules/pkg/LICENSE", "MIT\n", MTIME], ["/srv/node_modules/pkg/package.json", '{"name":"pkg"}', MTIME], ["/srv/public/sig.png", "89504e470d0a1a0a", MTIME], ["/srv/public/style.css", "body { color: teal; }\n", MTIME], ["/srv/server.mjs", "export {};\n", MTIME], ]); // encodeSnapshot round-trips: magic, header (version + [path, size, mtimeMs] per // file in walk order), then every file's raw bytes concatenated in that order. const encoded = encodeSnapshot(snap) as Uint8Array; expect(new TextDecoder().decode(encoded.subarray(0, 4))).toBe("NIBS"); const headerLen = new DataView(encoded.buffer, encoded.byteOffset, encoded.byteLength).getUint32(4, true); const header = JSON.parse(new TextDecoder().decode(encoded.subarray(8, 8 + headerLen))) as { version: number; files: Array<[string, number, number]>; }; expect(header).toEqual({ version: 3, files: snap.files.map((f) => [f.path, f.bytes.length, f.mtimeMs]), }); let offset = 8 + headerLen; for (const f of snap.files) { expect(encoded.subarray(offset, offset + f.bytes.length)).toEqual(f.bytes); offset += f.bytes.length; } expect(offset).toBe(encoded.length); }); test("mountSnapshot seeds bytes and recorded mtimes the guest can stat", async ({ page }) => { const snap = snapshot(APP, "/app") as Snap; const encoded = encodeSnapshot(snap) as Uint8Array; const bytes = Array.from(encoded); // A same-magic, version-2 snapshot that mountSnapshot must refuse. const headerLen = new DataView(encoded.buffer, encoded.byteOffset, encoded.byteLength).getUint32(4, true); const headerText = new TextDecoder().decode(encoded.subarray(8, 8 + headerLen)).replace('"version":3', '"version":2'); const headerBytes = new TextEncoder().encode(headerText); const badLen = new Uint8Array(4); new DataView(badLen.buffer).setUint32(0, headerBytes.length, true); const badBytes = Array.from(new Uint8Array([ ...new TextEncoder().encode("NIBS"), ...badLen, ...headerBytes, ...encoded.subarray(8 + headerLen), ])); await page.goto("/demo/fixture.html"); await page.waitForFunction(() => window.__runtime !== undefined, undefined, { timeout: 20_000 }); const out = await page.evaluate(async ({ bytes, badBytes }) => { const runtime = window.__runtime!; runtime.mountSnapshot(new Uint8Array(bytes).buffer); runtime.writeTree({ "/app/probe.cjs": [ 'const fs = require("node:fs");', 'for (const p of ["/app/public/sig.png", "/app/public/style.css"]) {', ' console.log(p, fs.statSync(p).mtimeMs, Buffer.from(fs.readFileSync(p)).toString("hex"));', "}", ].join("\n"), }); const proc = runtime.exec("/app/probe.cjs"); const dec = new TextDecoder(); let stdout = ""; let stderr = ""; await Promise.all([ proc.stdout.pipeTo(new WritableStream({ write(c) { stdout += dec.decode(c); } })), proc.stderr.pipeTo(new WritableStream({ write(c) { stderr += dec.decode(c); } })), ]); let refused = ""; try { runtime.mountSnapshot(new Uint8Array(badBytes).buffer); } catch (e) { refused = (e as Error).message; } return { code: await proc.exited, stdout, stderr, refused }; }, { bytes, badBytes }); expect(out).toEqual({ code: 0, stdout: `/app/public/sig.png ${MTIME} 89504e470d0a1a0a\n/app/public/style.css ${MTIME} 626f6479207b20636f6c6f723a207465616c3b207d0a\n`, stderr: "", refused: "unsupported snapshot version 2 (expected 3)", }); });