// FS slice acceptance proofs (FS-SLICE-BRIEF §Tests): the host fs facade over the // kernel's pid-0 host process, driven through a real page. The fixture page // (demo/fixture.html) exists only so these tests don't depend on the not-yet-built // demo harness; every assertion runs against the public Kernel.fs contract. // // Error-shape ground truth was captured from real node (rmSync/unlinkSync/ // renameSync errno matrix). Two deliberate brief-over-Node choices are pinned // here: unlink of a dir → EISDIR (Node: EPERM) and rm of a non-empty dir without // recursive → ENOTEMPTY (Node: ERR_FS_EISDIR). import { test, expect, type Page } from "@playwright/test"; import type { Kernel } from "../src/host/index"; import { runGuest } from "./guest"; declare global { interface Window { __runtime?: Kernel } } async function bootFixture(page: Page): Promise { await page.goto("/demo/fixture.html"); await page.waitForFunction(() => window.__runtime !== undefined, undefined, { timeout: 20_000 }); } test("readdir of a seeded dir returns names+kinds; stat sizes match content length", async ({ page }) => { await bootFixture(page); const res = await page.evaluate(async () => { const runtime = window.__runtime!; return { entries: await runtime.fs.readdir("/app"), one: await runtime.fs.stat("/app/one.txt"), dir: await runtime.fs.stat("/app/dir"), nested: await runtime.fs.stat("/app/dir/nested.txt"), }; }); const byName = new Map(res.entries.map((e) => [e.name, e.kind])); expect(byName.get("one.txt")).toBe("file"); expect(byName.get("dir")).toBe("dir"); expect(res.one).toMatchObject({ kind: "file", size: 3 }); // "one" expect(res.nested).toMatchObject({ kind: "file", size: 6 }); // "nested" expect(res.dir).toMatchObject({ kind: "dir", size: 0 }); expect(typeof res.one.mode).toBe("number"); expect(typeof res.one.mtimeMs).toBe("number"); }); test("writeFile then readFile round-trips string and Uint8Array payloads", async ({ page }) => { await bootFixture(page); const res = await page.evaluate(async () => { const runtime = window.__runtime!; await runtime.fs.writeFile("/app/round.txt", "hello page"); const text = new TextDecoder().decode(await runtime.fs.readFile("/app/round.txt")); const payload = new Uint8Array([0, 1, 2, 254, 255]); await runtime.fs.writeFile("/app/bin.txt", payload); const bytes = Array.from(await runtime.fs.readFile("/app/bin.txt")); // Overwriting truncates: a shorter write must not leave the old tail. await runtime.fs.writeFile("/app/round.txt", "hi"); const truncated = new TextDecoder().decode(await runtime.fs.readFile("/app/round.txt")); return { text, bytes, truncated, stat: await runtime.fs.stat("/app/round.txt") }; }); expect(res.text).toBe("hello page"); expect(res.bytes).toEqual([0, 1, 2, 254, 255]); expect(res.truncated).toBe("hi"); expect(res.stat).toMatchObject({ kind: "file", size: 2 }); }); test("mkdir recursive + nested writeFile + readdir nesting", async ({ page }) => { await bootFixture(page); await page.evaluate(async () => { const runtime = window.__runtime!; await runtime.fs.mkdir("/app/a/b/c", { recursive: true }); await runtime.fs.writeFile("/app/a/b/c/leaf.txt", "leaf"); }); const tree = await page.evaluate(async () => { const runtime = window.__runtime!; return { a: await runtime.fs.readdir("/app/a"), b: await runtime.fs.readdir("/app/a/b"), c: await runtime.fs.readdir("/app/a/b/c"), }; }); expect(tree.a).toContainEqual({ name: "b", kind: "dir" }); expect(tree.b).toContainEqual({ name: "c", kind: "dir" }); expect(tree.c).toContainEqual({ name: "leaf.txt", kind: "file" }); }); test("rename moves a file (and a dir with its children); the old path is gone", async ({ page }) => { await bootFixture(page); await page.evaluate(async () => { const runtime = window.__runtime!; await runtime.fs.rename("/app/one.txt", "/app/two.txt"); await runtime.fs.rename("/app/dir", "/app/dir2"); }); const after = await page.evaluate(async () => { const runtime = window.__runtime!; let oldPath: string | null = null; try { await runtime.fs.stat("/app/one.txt"); } catch (e) { oldPath = (e as { code?: string }).code ?? "THREW"; } return { oldPath, moved: new TextDecoder().decode(await runtime.fs.readFile("/app/two.txt")), children: await runtime.fs.readdir("/app/dir2"), }; }); expect(after.oldPath).toBe("ENOENT"); expect(after.moved).toBe("one"); // The Dentry moves — children travel with it and still resolve. expect(after.children).toContainEqual({ name: "nested.txt", kind: "file" }); }); test("unlink file; rmdir empty dir; rm recursive removes a subtree; rmdir non-empty without recursive rejects ENOTEMPTY", async ({ page }) => { await bootFixture(page); const res = await page.evaluate(async () => { const runtime = window.__runtime!; const code = async (fn: () => Promise): Promise => { try { await fn(); return null; } catch (e) { return (e as { code?: string }).code ?? "THREW"; } }; await runtime.fs.writeFile("/app/tmp.txt", "x"); await runtime.fs.unlink("/app/tmp.txt"); const unlinked = await code(() => runtime.fs.stat("/app/tmp.txt")); await runtime.fs.mkdir("/app/empty"); await runtime.fs.rm("/app/empty"); // non-recursive rm on an empty dir removes it const rmdirEmpty = await code(() => runtime.fs.stat("/app/empty")); await runtime.fs.mkdir("/app/sub/inner", { recursive: true }); await runtime.fs.writeFile("/app/sub/inner/deep.txt", "deep"); await runtime.fs.rm("/app/sub", { recursive: true }); const subtreeGone = await code(() => runtime.fs.stat("/app/sub/inner/deep.txt")); await runtime.fs.mkdir("/app/stay"); await runtime.fs.writeFile("/app/stay/keep.txt", "k"); const nonEmpty = await code(() => runtime.fs.rm("/app/stay")); const survivor = await runtime.fs.stat("/app/stay/keep.txt"); // untouched by the refusal return { unlinked, rmdirEmpty, subtreeGone, nonEmpty, survivor }; }); expect(res.unlinked).toBe("ENOENT"); expect(res.rmdirEmpty).toBe("ENOENT"); expect(res.subtreeGone).toBe("ENOENT"); expect(res.nonEmpty).toBe("ENOTEMPTY"); expect(res.survivor).toMatchObject({ kind: "file", size: 1 }); }); test("error shapes: readFile missing → ENOENT; unlink a dir → EISDIR; readdir a file → ENOTDIR", async ({ page }) => { await bootFixture(page); const errs = await page.evaluate(async () => { const runtime = window.__runtime!; const shape = async (fn: () => Promise) => { try { await fn(); return null; } catch (e) { const err = e as { code?: string; errno?: number; syscall?: string; path?: string }; return { code: err.code, errno: err.errno, syscall: err.syscall, path: err.path }; } }; return { missing: await shape(() => runtime.fs.readFile("/app/nope.txt")), unlinkDir: await shape(() => runtime.fs.unlink("/app/dir")), readdirFile: await shape(() => runtime.fs.readdir("/app/one.txt")), }; }); expect(errs.missing).toMatchObject({ code: "ENOENT", errno: 2, syscall: "open", path: "/app/nope.txt" }); expect(errs.unlinkDir).toMatchObject({ code: "EISDIR", syscall: "unlink", path: "/app/dir" }); expect(errs.readdirFile).toMatchObject({ code: "ENOTDIR", syscall: "readdir" }); }); test("cross-writer visibility: guest writes → page readdir; page writes → guest readFileSync", async ({ page }) => { await bootFixture(page); const res = await page.evaluate(async () => { const runtime = window.__runtime!; // Seed the two guest scripts through the host fs (awaited, so no seed race). await runtime.fs.writeFile("/app/guest-write.cjs", `const fs = require("node:fs");\nfs.writeFileSync("/app/guest.txt", "written-by-guest");\nconsole.log("guest wrote");\n`); await runtime.fs.writeFile("/app/guest-read.cjs", `const fs = require("node:fs");\nconst s = fs.readFileSync("/app/reply.txt", "utf8");\nconsole.log("guest read:", s);\n`); // Listen BEFORE the guest runs — its write must surface via onFsChanged // (single-writer notification), with a timeout fallback so a missed event // degrades to a poll instead of a hang. let resolveChanged: (v: string) => void = () => {}; const changed = new Promise((resolve) => { resolveChanged = resolve; }); const off = runtime.onFsChanged(() => resolveChanged("changed")); setTimeout(() => resolveChanged("timeout"), 5000); const code = await runtime.exec("/app/guest-write.cjs").exited; const notice = await changed; off(); const names = (await runtime.fs.readdir("/app")).map((e) => e.name); // Direction 2: the page writes, a second guest reads the bytes back. await runtime.fs.writeFile("/app/reply.txt", "hello-from-page"); const chunks: string[] = []; const proc = runtime.exec("/app/guest-read.cjs"); proc.stdout.pipeTo(new WritableStream({ write(c) { chunks.push(new TextDecoder().decode(c)); } })); const readCode = await proc.exited; return { code, notice, names, readCode, stdout: chunks.join("") }; }); expect(res.code).toBe(0); expect(res.notice).toBe("changed"); expect(res.names).toContain("guest.txt"); expect(res.readCode).toBe(0); expect(res.stdout).toContain("guest read: hello-from-page"); }); test("guest sync builtins: mkdir/readdir/stat/rename/unlink/rmdir round-trip inside the guest", async ({ page }) => { await bootFixture(page); const res = await page.evaluate(async () => { const runtime = window.__runtime!; await runtime.fs.writeFile("/app/builtins.cjs", [ `const fs = require("node:fs");`, `fs.mkdirSync("/app/gb/nested", { recursive: true });`, `fs.writeFileSync("/app/gb/nested/f.txt", "payload");`, `const names = fs.readdirSync("/app/gb").sort().join(",");`, `const st = fs.statSync("/app/gb/nested/f.txt");`, `fs.renameSync("/app/gb/nested/f.txt", "/app/gb/moved.txt");`, `const moved = fs.readFileSync("/app/gb/moved.txt", "utf8");`, `fs.unlinkSync("/app/gb/moved.txt");`, `fs.rmdirSync("/app/gb", { recursive: true });`, `const gone = (() => { try { fs.statSync("/app/gb"); return "exists"; } catch (e) { return e.code; } })();`, `console.log("names=" + names, "size=" + st.size, "dir=" + st.isDirectory(),`, ` "moved=" + moved, "gone=" + gone);`, ].join("\n")); const chunks: string[] = []; const proc = runtime.exec("/app/builtins.cjs"); proc.stdout.pipeTo(new WritableStream({ write(c) { chunks.push(new TextDecoder().decode(c)); } })); const stderr: string[] = []; proc.stderr.pipeTo(new WritableStream({ write(c) { stderr.push(new TextDecoder().decode(c)); } })); const code = await proc.exited; return { code, stdout: chunks.join(""), stderr: stderr.join("") }; }); expect(res.stderr).toBe(""); expect(res.code).toBe(0); expect(res.stdout).toContain("names=nested"); expect(res.stdout).toContain("size=7"); // "payload" expect(res.stdout).toContain("dir=false"); expect(res.stdout).toContain("moved=payload"); // renameSync moved it, readFileSync found it expect(res.stdout).toContain("gone=ENOENT"); // rmdirSync recursive removed the subtree }); test("rename edge cases + seed notifies onFsChanged", async ({ page }) => { await bootFixture(page); const res = await page.evaluate(async () => { const runtime = window.__runtime!; const code = async (fn: () => Promise): Promise => { try { await fn(); return null; } catch (e) { return (e as { code?: string }).code ?? "THREW"; } }; await runtime.fs.mkdir("/app/edir"); await runtime.fs.writeFile("/app/efile.txt", "e"); return { fileOntoDir: await code(() => runtime.fs.rename("/app/efile.txt", "/app/edir")), // restore for the next case: the failed rename left both paths intact dirOntoFile: await code(() => runtime.fs.rename("/app/edir", "/app/efile.txt")), intoOwnSubtree: await code(() => runtime.fs.rename("/app/edir", "/app/edir/sub")), missing: await code(() => runtime.fs.rename("/app/nope.txt", "/app/elsewhere.txt")), missingParent: await code(() => runtime.fs.rename("/app/efile.txt", "/app/nodir/f.txt")), // The seed path (writeTree) is a writer too and must notify. seedNotice: await new Promise((resolve) => { const off = runtime.onFsChanged(() => { off(); resolve("changed"); }); setTimeout(() => { off(); resolve("timeout"); }, 5000); runtime.writeTree({ "/app/seeded-by-tree.txt": "seed" }); }), seeded: await runtime.fs.readFile("/app/seeded-by-tree.txt").then( (b) => new TextDecoder().decode(b), (e) => e.code, ), }; }); expect(res.fileOntoDir).toBe("EISDIR"); // brief contract (Node agrees for file→dir) expect(res.dirOntoFile).toBe("ENOTDIR"); // Node semantics expect(res.intoOwnSubtree).toBe("EINVAL"); // POSIX/Node semantics expect(res.missing).toBe("ENOENT"); expect(res.missingParent).toBe("ENOENT"); // target parent must exist expect(res.seedNotice).toBe("changed"); // writeTree goes through the notification path expect(res.seeded).toBe("seed"); }); // Real Node 24.16's lines for the same script. test("{ bigint: true } answers BigIntStats from every stat form, as Node does", async ({ page }) => { const res = await runGuest(page, { "/app/main.cjs": [ "const fs = require(\"node:fs\");", "fs.writeFileSync(\"/app/f.txt\", \"hello\");", "const s = fs.statSync(\"/app/f.txt\", { bigint: true });", "console.log(Object.keys(s).join(\",\"));", "console.log([typeof s.size, typeof s.mtimeNs, s.size, s.isFile(), s.isDirectory(), s.mtimeNs / 1000000n === s.mtimeMs, s.mtime.getTime() === Number(s.mtimeMs)].join(\" \"));", "const fd = fs.openSync(\"/app/f.txt\", \"r\");", "console.log(typeof fs.fstatSync(fd, { bigint: true }).ino, typeof fs.lstatSync(\"/app/f.txt\", { bigint: true }).mode, typeof fs.statSync(\"/app/f.txt\").size);", "fs.stat(\"/app/f.txt\", { bigint: true }, (e, st) => {", " console.log(\"cb\", typeof st.blocks);", " fs.promises.stat(\"/app/f.txt\", { bigint: true }).then(async (p) => {", " const h = await fs.promises.open(\"/app/f.txt\");", " console.log(\"promise\", typeof p.dev, \"handle\", typeof (await h.stat({ bigint: true })).nlink);", " await h.close();", " });", "});", ].join("\n"), }, "/app/main.cjs"); expect(res.stdout.split("\n")).toEqual([ "dev,mode,nlink,uid,gid,rdev,blksize,ino,size,blocks,atimeMs,mtimeMs,ctimeMs,birthtimeMs,atimeNs,mtimeNs,ctimeNs,birthtimeNs", "bigint bigint 5 true false true true", "bigint bigint number", "cb bigint", "promise bigint handle bigint", "", ]); });