runa/vscode/test/sources.test.ts

254 lines
10 KiB
TypeScript
Raw Permalink Normal View History

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<string, SourceFile>();
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<SourceFile[]> {
return [...this.files.values()];
}
async readSource(path: string): Promise<SourceFile> {
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<SourceFile> {
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);
});