import assert from "node:assert/strict"; import test from "node:test"; import { ProtocolError, SourceFile } from "../src/protocol"; import { parseSourceUriPath, SourceChange, SourceClient, SourceFsError, SourceStore, sourceUriPath, } from "../src/sources"; class FakeImage implements SourceClient { files = new Map(); writes: [string, string | null, string][] = []; private next = 1; put(path: string, source: string): SourceFile { const file = { path, source, source_id: `id${this.next++}` }; this.files.set(path, file); return file; } async listSources(): Promise { return [...this.files.values()]; } async readSource(path: string): Promise { const file = this.files.get(path); if (!file) throw new ProtocolError("not_found", "unknown source"); return file; } async writeSource(path: string, expected: string | null, source: string): Promise { this.writes.push([path, expected, source]); const current = this.files.get(path)?.source_id ?? null; if (current !== expected) throw new ProtocolError("conflict", "source identity conflict"); return this.put(path, source); } } function setup(now = () => 1000) { const image = new FakeImage(); const events: SourceChange[][] = []; const store = new SourceStore((changes) => events.push(changes), now); return { image, events, store }; } const bytes = (text: string) => new TextEncoder().encode(text); function code(expected: string) { return (error: unknown) => error instanceof SourceFsError && error.code === expected; } test("source URI paths map to canonical image paths under the source root", () => { assert.deepEqual(parseSourceUriPath("/"), { kind: "root" }); assert.deepEqual(parseSourceUriPath("/source"), { kind: "directory", path: "" }); assert.deepEqual(parseSourceUriPath("/source/actors"), { kind: "directory", path: "actors" }); assert.deepEqual(parseSourceUriPath("/source/actors/counter.rn"), { kind: "file", path: "actors/counter", }); assert.equal(sourceUriPath("actors/counter"), "/source/actors/counter.rn"); assert.throws(() => parseSourceUriPath("/other/x.rn"), code("FileNotFound")); for (const invalid of [ "/source/actors/my.counter.rn", "/source/actors/counter.txt", "/source/actors/../x.rn", "/source/source/x.rn", "/source/a b.rn", ]) assert.throws(() => parseSourceUriPath(invalid), code("NoPermissions"), invalid); assert.throws(() => sourceUriPath("actors/counter.rn")); }); test("lookups of names the image cannot hold report not found; writes report invalid names", async () => { const { image, store } = setup(); store.attach("127.0.0.1:1", 1, image); await store.refresh(); assert.throws(() => store.stat("/source/.vscode/settings.json"), code("FileNotFound")); assert.throws(() => store.readDirectory("/source/.vscode"), code("FileNotFound")); await assert.rejects(store.read("/source/.vscode/settings.json"), code("FileNotFound")); await assert.rejects( store.write("/source/.vscode/settings.json", bytes("{}"), { create: true, overwrite: true }), code("NoPermissions"), ); assert.throws(() => store.createDirectory("/source/.vscode"), code("NoPermissions")); }); test("disconnected stores show an empty root and reject file access", async () => { const { store } = setup(); assert.deepEqual(store.readDirectory("/source"), []); assert.deepEqual(store.readDirectory("/"), [["source", "directory"]]); assert.equal(store.stat("/source").type, "directory"); assert.throws(() => store.stat("/source/actors/counter.rn"), code("Unavailable")); await assert.rejects(store.read("/source/actors/counter.rn"), code("Unavailable")); }); test("listing derives directories and reports created, changed, and deleted files", async () => { let clock = 1000; const { image, events, store } = setup(() => clock); image.put("actors/counter", "a"); store.attach("127.0.0.1:1", 1, image); await store.refresh(); assert.deepEqual(events.pop(), [ { type: "created", uriPath: "/source/actors" }, { type: "created", uriPath: "/source/actors/counter.rn" }, { type: "changed", uriPath: "/source" }, ]); assert.deepEqual(store.readDirectory("/source"), [["actors", "directory"]]); assert.deepEqual(store.readDirectory("/source/actors"), [["counter.rn", "file"]]); assert.deepEqual(store.stat("/source/actors/counter.rn"), { type: "file", ctime: 0, mtime: 1000, size: 1, }); assert.throws(() => store.readDirectory("/source/missing"), code("FileNotFound")); assert.throws(() => store.readDirectory("/source/actors/counter.rn"), code("FileNotADirectory")); await store.refresh(); assert.equal(events.length, 0, "unchanged listings emit nothing"); image.put("actors/counter", "abc"); image.put("lib/util", "u"); await store.refresh(); assert.deepEqual(events.pop(), [ { type: "changed", uriPath: "/source/actors/counter.rn" }, { type: "created", uriPath: "/source/lib" }, { type: "created", uriPath: "/source/lib/util.rn" }, ]); // Changed identities always advance mtime, even if the clock does not. assert.equal(store.stat("/source/actors/counter.rn").mtime, 1001); assert.equal(store.stat("/source/actors/counter.rn").size, 3); image.files.delete("lib/util"); clock = 5000; await store.refresh(); assert.deepEqual(events.pop(), [{ type: "deleted", uriPath: "/source/lib/util.rn" }]); assert.deepEqual(store.paths(), ["actors/counter"]); }); test("saves compare-and-swap against the last observed source identity", async () => { const { image, events, store } = setup(); const original = image.put("actors/counter", "a"); store.attach("127.0.0.1:1", 1, image); await store.refresh(); events.length = 0; assert.equal(await store.read("/source/actors/counter.rn"), "a"); await store.write("/source/actors/counter.rn", bytes("b 雪"), { create: true, overwrite: true }); assert.deepEqual(image.writes.pop(), ["actors/counter", original.source_id, "b 雪"]); assert.deepEqual(events.pop(), [{ type: "changed", uriPath: "/source/actors/counter.rn" }]); const saved = store.sourceId("actors/counter"); // Another client saves; this store has not observed it yet. image.put("actors/counter", "other"); await assert.rejects( store.write("/source/actors/counter.rn", bytes("c"), { create: true, overwrite: true }), (error: unknown) => error instanceof SourceFsError && error.code === "Unavailable" && /changed in the image/.test(error.message), ); assert.deepEqual(image.writes.pop(), ["actors/counter", saved, "c"]); assert.equal(image.files.get("actors/counter")?.source, "other"); assert.equal(await store.read("/source/actors/counter.rn"), "other"); await store.write("/source/actors/counter.rn", bytes("d"), { create: false, overwrite: true }); assert.equal(image.files.get("actors/counter")?.source, "d"); }); test("new files are created with a null expected identity; invalid writes are rejected", async () => { const { image, events, store } = setup(); store.attach("127.0.0.1:1", 1, image); await store.refresh(); events.length = 0; store.createDirectory("/source/actors"); assert.deepEqual(events.pop(), [{ type: "created", uriPath: "/source/actors" }]); assert.deepEqual(store.readDirectory("/source/actors"), []); assert.throws(() => store.createDirectory("/source/actors"), code("FileExists")); await assert.rejects( store.write("/source/actors/new.rn", bytes("x"), { create: false, overwrite: true }), code("FileNotFound"), ); await store.write("/source/actors/new.rn", bytes("x"), { create: true, overwrite: false }); assert.deepEqual(image.writes.pop(), ["actors/new", null, "x"]); await assert.rejects( store.write("/source/actors/new.rn", bytes("y"), { create: true, overwrite: false }), code("FileExists"), ); await assert.rejects( store.write("/source/actors/bad.rn", new Uint8Array([0xff]), { create: true, overwrite: true }), code("NoPermissions"), ); await assert.rejects( store.write("/source/notes.txt", bytes("x"), { create: true, overwrite: true }), code("NoPermissions"), ); await assert.rejects(store.read("/source/actors/missing.rn"), code("FileNotFound")); assert.equal(image.writes.length, 0); }); test("results from a previous connection are not applied to a new session", async () => { const { image, store } = setup(); image.put("actors/counter", "a"); let release = () => {}; const slow: SourceClient = { listSources: () => image.listSources(), readSource: (path) => new Promise((resolve) => { release = () => resolve(image.readSource(path)); }), writeSource: (...args) => image.writeSource(...args), }; store.attach("127.0.0.1:1", 1, slow); await store.refresh(); const read = store.read("/source/actors/counter.rn"); store.attach("127.0.0.1:1", 2, image); release(); await assert.rejects(read, code("Unavailable")); assert.equal(store.sourceId("actors/counter"), undefined); store.detach(); assert.throws(() => store.stat("/source/actors/counter.rn"), code("Unavailable")); }); test("a directory and Rune file can share a stem without sharing a filesystem path", async () => { const { image, store } = setup(); image.put("actors/counter", "root"); store.attach("127.0.0.1:1", 1, image); await store.refresh(); store.createDirectory("/source/actors/counter"); await store.write("/source/actors/counter/child.rn", bytes("child"), { create: true, overwrite: false, }); assert.deepEqual(store.readDirectory("/source/actors"), [ ["counter", "directory"], ["counter.rn", "file"], ]); store.createDirectory("/source/actors/other"); await store.write("/source/actors/other.rn", bytes("other"), { create: true, overwrite: false }); assert.equal(await store.read("/source/actors/other.rn"), "other"); }); test("apply records rollback results and preserves mtimes across reconnects to one image", async () => { let clock = 10; const { image, events, store } = setup(() => clock); image.put("actors/counter", "a"); store.attach("127.0.0.1:1", 1, image); await store.refresh(); const mtime = store.stat("/source/actors/counter.rn").mtime; clock = 20; store.attach("127.0.0.1:1", 2, image); await store.refresh(); assert.equal(store.stat("/source/actors/counter.rn").mtime, mtime); events.length = 0; store.apply({ path: "actors/counter", source_id: "rolled", source: "old" }); assert.deepEqual(events.pop(), [{ type: "changed", uriPath: "/source/actors/counter.rn" }]); assert.equal(store.sourceId("actors/counter"), "rolled"); assert.equal(store.stat("/source/actors/counter.rn").mtime, 20); });