import { describe, it, expect, beforeEach, afterEach } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { CoauthorStore } from "../src/store"; import { emptyArtifact, newId, serializeArtifact, type Artifact } from "../src/model"; let root: string; beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), "coauthor-store-")); }); afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); function withThread(): Artifact { const a = emptyArtifact("docs/spec.md"); a.anchors["a1"] = { fingerprint: { text: "hello", before: "", after: "", lineHint: 0 } }; a.threads.push({ id: "t1", anchorId: "a1", status: "open", messages: [{ id: newId("m"), author: { kind: "human", id: "ben" }, body: "note", createdAt: "2026-06-10T00:00:00.000Z" }], }); return a; } describe("CoauthorStore", () => { it("returns null for a document with no sidecar", () => { const store = new CoauthorStore(root); expect(store.load("docs/spec.md")).toBeNull(); }); it("computes the sidecar path as .threads/.json", () => { const store = new CoauthorStore(root); expect(store.sidecarPath("docs/spec.md")).toBe(path.join(root, ".threads", "docs", "spec.md.json")); }); it("save then load round-trips the artifact", () => { const store = new CoauthorStore(root); const a = withThread(); store.save("docs/spec.md", a); expect(store.load("docs/spec.md")).toEqual(a); }); it("writes byte-stable, pretty JSON (re-save is identical)", () => { const store = new CoauthorStore(root); const a = withThread(); store.save("docs/spec.md", a); const first = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8"); store.save("docs/spec.md", store.load("docs/spec.md")!); const second = fs.readFileSync(store.sidecarPath("docs/spec.md"), "utf8"); expect(second).toBe(first); expect(first).toBe(serializeArtifact(a)); }); it("update(): two writers merge sections instead of clobbering (F3 ยง6.2)", () => { const store = new CoauthorStore(root); store.update("d.md", (a) => { a.anchors["a_t"] = { fingerprint: { text: "t", before: "", after: "", lineHint: 0 } }; a.threads.push({ id: "t1", anchorId: "a_t", status: "open", messages: [] }); }); store.update("d.md", (a) => { a.anchors["a_at"] = { fingerprint: { text: "x", before: "", after: "", lineHint: 0 } }; a.attributions.push({ id: "at1", anchorId: "a_at", author: { kind: "human", id: "ben" }, createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z", }); }); const loaded = store.load("d.md")!; expect(loaded.threads).toHaveLength(1); expect(loaded.attributions).toHaveLength(1); expect(Object.keys(loaded.anchors).sort()).toEqual(["a_at", "a_t"]); }); it("update() prunes anchors no longer referenced by threads or attributions", () => { const store = new CoauthorStore(root); store.update("d.md", (a) => { a.anchors["a_old"] = { fingerprint: { text: "o", before: "", after: "", lineHint: 0 } }; a.attributions.push({ id: "at1", anchorId: "a_old", author: { kind: "human", id: "ben" }, createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z", }); }); store.update("d.md", (a) => { a.attributions = []; }); expect(Object.keys(store.load("d.md")!.anchors)).toEqual([]); }); it("consumeSelfWrite counts multiple writes to the same sidecar (double-save suppression)", () => { const store = new CoauthorStore(root); store.update("d.md", (a) => { a.anchors["a_t"] = { fingerprint: { text: "t", before: "", after: "", lineHint: 0 } }; a.threads.push({ id: "t1", anchorId: "a_t", status: "open", messages: [] }); }); store.update("d.md", (a) => { a.attributions.push({ id: "at1", anchorId: "a_t", author: { kind: "human", id: "ben" }, createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z", }); }); const p = store.sidecarPath("d.md"); expect(store.consumeSelfWrite(p)).toBe(true); expect(store.consumeSelfWrite(p)).toBe(true); expect(store.consumeSelfWrite(p)).toBe(false); }); }); describe("newer-major write refusal (F5 SLICE-2, INV-16 backstop)", () => { it("update REFUSES to write a sidecar from a newer major, leaving bytes untouched", () => { const store = new CoauthorStore(root); const p = store.sidecarPath("docs/v2.md"); fs.mkdirSync(path.dirname(p), { recursive: true }); const v2 = { ...emptyArtifact("docs/v2.md"), schemaVersion: 2 }; fs.writeFileSync(p, serializeArtifact(v2), "utf8"); const before = fs.readFileSync(p, "utf8"); expect(() => store.update("docs/v2.md", (a) => { a.threads = []; }), ).toThrow(/INV-16/); expect(fs.readFileSync(p, "utf8")).toBe(before); }); });