255 lines
11 KiB
TypeScript
255 lines
11 KiB
TypeScript
|
|
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.throws(() => store.rejectMutation("Deleting"), code("NoPermissions"));
|
||
|
|
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);
|
||
|
|
});
|