import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { FilesystemConnector } from "../src/connectors/filesystem.js"; import type { JazzThoughtStore } from "../src/jazz/store.js"; import { temporaryProject, testStore } from "./helpers.js"; const stores: JazzThoughtStore[] = []; const roots: string[] = []; afterEach(async () => { await Promise.all(stores.splice(0).map((store) => store.close())); await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); }); describe("FilesystemConnector", () => { test("records add, change, rename, and delete with stable lineage inputs", async () => { const project = await temporaryProject(); roots.push(project); const vault = path.join(project, "vault"); await fs.mkdir(vault); await fs.mkdir(path.join(vault, ".obsidian")); await fs.writeFile(path.join(vault, ".obsidian", "workspace.json"), "{\"private\":true}\n"); await fs.writeFile(path.join(vault, "alpha.md"), "---\nid: alpha\n---\n# Alpha\n\n[[Beta]]\n"); await fs.writeFile(path.join(vault, "beta.md"), "# Beta\n"); const store = await testStore(project); stores.push(store); const connector = new FilesystemConnector({ id: "filesystem:fixture", root: vault }); const initial = await connector.scan(store); expect(initial.added).toBe(2); expect((await store.listCurrentDocuments("filesystem:fixture")).map((document) => document.path)) .not.toContain(".obsidian/workspace.json"); expect(initial.events.map((event) => event.type)).toEqual([ "stream.thought.source.file.added", "stream.thought.source.file.added", ]); const unchanged = await connector.scan(store); expect(unchanged.unchanged).toBe(2); expect(unchanged.events).toHaveLength(0); await fs.writeFile(path.join(vault, "alpha.md"), "---\nid: alpha\n---\n# Alpha changed\n\n[[Beta]]\n"); const changed = await connector.scan(store); expect(changed.changed).toBe(1); expect(changed.events[0]?.payload.diff).toContain("# Alpha changed"); const beforeRename = (await store.listCurrentDocuments("filesystem:fixture")).find((document) => document.path === "alpha.md"); await fs.rename(path.join(vault, "alpha.md"), path.join(vault, "renamed-alpha.md")); const renamed = await connector.scan(store); expect(renamed.renamed).toBe(1); expect(renamed.events[0]?.payload.previousPath).toBe("alpha.md"); expect(renamed.events[0]?.payload.documentId).toBe(beforeRename?.documentId); await fs.rm(path.join(vault, "beta.md")); const deleted = await connector.scan(store); expect(deleted.deleted).toBe(1); expect(deleted.events[0]?.type).toBe("stream.thought.source.file.deleted"); const all = await store.listEvents({ source: "filesystem:fixture" }); expect(all.map((event) => event.type)).toEqual([ "stream.thought.source.file.added", "stream.thought.source.file.added", "stream.thought.source.file.changed", "stream.thought.source.file.renamed", "stream.thought.source.file.deleted", ]); }); test("refuses symlinks that leave the configured root", async () => { const project = await temporaryProject(); roots.push(project); const vault = path.join(project, "vault"); await fs.mkdir(vault); await fs.writeFile(path.join(project, "outside.md"), "secret"); await fs.symlink(path.join(project, "outside.md"), path.join(vault, "escape.md")); const store = await testStore(project); stores.push(store); const connector = new FilesystemConnector({ id: "filesystem:fixture", root: vault }); const result = await connector.scan(store); expect(result.scanned).toBe(0); expect(await store.listEvents()).toHaveLength(0); }); });