import { InMemoryFs } from "../fs/in-memory-fs"; import { describe, expect, it } from "vitest"; import { createMemoryStateBackend } from "../memory"; import { StateBatchOperationError } from "../index"; describe("MemoryStateBackend", () => { it("reads, writes, and appends text content", async () => { const backend = createMemoryStateBackend(); await backend.writeFile("/notes/todo.txt", "hello"); await backend.appendFile("/notes/todo.txt", "\nworld"); await expect(backend.readFile("/notes/todo.txt")).resolves.toBe( "hello\nworld" ); }); it("reads and writes JSON content", async () => { const backend = createMemoryStateBackend(); await backend.writeJson("/config.json", { name: "demo", flags: ["a", "b"] }); await expect(backend.readJson("/config.json")).resolves.toEqual({ name: "demo", flags: ["a", "b"] }); await expect(backend.readFile("/config.json")).resolves.toBe( '{\n "name": "demo",\n "flags": [\n "a",\n "b"\n ]\n}\n' ); }); it("throws a helpful error for invalid JSON", async () => { const backend = createMemoryStateBackend({ files: { "/broken.json": "{ nope" } }); await expect(backend.readJson("/broken.json")).rejects.toThrow( "Invalid JSON in /broken.json" ); }); it("supports host-side globbing and unified diffs", async () => { const backend = createMemoryStateBackend({ files: { "/src/a.ts": "export const a = 1;\n", "/src/b.ts": "export const b = 2;\n" } }); await expect(backend.glob("/src/*.ts")).resolves.toEqual([ "/src/a.ts", "/src/b.ts" ]); const diff = await backend.diffContent( "/src/a.ts", "export const a = 3;\n" ); expect(diff).toContain("--- /src/a.ts"); expect(diff).toContain("+++ /src/a.ts"); expect(diff).toContain("-export const a = 1;"); expect(diff).toContain("+export const a = 3;"); }); it("supports text search with plain text, regex, and word boundaries", async () => { const backend = createMemoryStateBackend({ files: { "/notes.txt": "foo food\nFoo foo42\nfoo\n" } }); await expect(backend.searchText("/notes.txt", "foo")).resolves.toEqual([ { line: 1, column: 1, match: "foo", lineText: "foo food" }, { line: 1, column: 5, match: "foo", lineText: "foo food" }, { line: 2, column: 5, match: "foo", lineText: "Foo foo42" }, { line: 3, column: 1, match: "foo", lineText: "foo" } ]); await expect( backend.searchText("/notes.txt", "foo", { caseSensitive: false }) ).resolves.toEqual([ { line: 1, column: 1, match: "foo", lineText: "foo food" }, { line: 1, column: 5, match: "foo", lineText: "foo food" }, { line: 2, column: 1, match: "Foo", lineText: "Foo foo42" }, { line: 2, column: 5, match: "foo", lineText: "Foo foo42" }, { line: 3, column: 1, match: "foo", lineText: "foo" } ]); await expect( backend.searchText("/notes.txt", "foo", { wholeWord: true }) ).resolves.toEqual([ { line: 1, column: 1, match: "foo", lineText: "foo food" }, { line: 3, column: 1, match: "foo", lineText: "foo" } ]); await expect( backend.searchText("/notes.txt", "foo\\d+", { regex: true }) ).resolves.toEqual([ { line: 2, column: 5, match: "foo42", lineText: "Foo foo42" } ]); }); it("supports richer search context and max match limits", async () => { const backend = createMemoryStateBackend({ files: { "/search.txt": "zero\none foo\ntwo\nthree foo\nfour\n" } }); await expect( backend.searchText("/search.txt", "foo", { contextBefore: 1, contextAfter: 1, maxMatches: 1 }) ).resolves.toEqual([ { line: 2, column: 5, match: "foo", lineText: "one foo", beforeLines: ["zero"], afterLines: ["two"] } ]); }); it("supports structured file discovery via find", async () => { const backend = createMemoryStateBackend({ files: { "/src/a.ts": "a", "/src/nested/b.ts": "bb", "/src/nested/c.txt": "ccc", "/empty.txt": "" } }); await expect( backend.find("/src", { type: "file", pathPattern: "/src/**/*.ts" }) ).resolves.toEqual([ { path: "/src/a.ts", name: "a.ts", type: "file", depth: 1, size: 1, mtime: expect.any(Date) }, { path: "/src/nested/b.ts", name: "b.ts", type: "file", depth: 2, size: 2, mtime: expect.any(Date) } ]); await expect( backend.find("/", { empty: true, type: "file" }) ).resolves.toEqual([ { path: "/empty.txt", name: "empty.txt", type: "file", depth: 1, size: 0, mtime: expect.any(Date) } ]); }); it("supports JSON query and update operations", async () => { const backend = createMemoryStateBackend({ files: { "/config.json": '{ "app": { "name": "demo", "flags": ["a", "b"] } }\n' } }); await expect( backend.queryJson("/config.json", ".app.flags[1]") ).resolves.toBe("b"); const updated = await backend.updateJson("/config.json", [ { op: "set", path: ".app.name", value: "demo-2" }, { op: "set", path: ".app.flags[2]", value: "c" }, { op: "delete", path: ".app.flags[0]" } ]); expect(updated.operationsApplied).toBe(3); expect(updated.value).toEqual({ app: { name: "demo-2", flags: ["b", "c"] } }); await expect(backend.readJson("/config.json")).resolves.toEqual({ app: { name: "demo-2", flags: ["b", "c"] } }); }); it("supports in-file replacement without touching unmatched content", async () => { const backend = createMemoryStateBackend({ files: { "/replace.txt": "foo food foo\n" } }); await expect( backend.replaceInFile("/replace.txt", "foo", "bar", { wholeWord: true }) ).resolves.toEqual({ replaced: 2, content: "bar food bar\n" }); await expect(backend.readFile("/replace.txt")).resolves.toBe( "bar food bar\n" ); }); it("supports multi-file search across glob matches", async () => { const backend = createMemoryStateBackend({ files: { "/src/a.ts": 'export const alpha = "foo";\n', "/src/b.ts": 'export const beta = "bar";\n', "/src/c.ts": 'export const gamma = "foo";\n' } }); await expect(backend.searchFiles("/src/*.ts", "foo")).resolves.toEqual([ { path: "/src/a.ts", matches: [ { line: 1, column: 23, match: "foo", lineText: 'export const alpha = "foo";' } ] }, { path: "/src/c.ts", matches: [ { line: 1, column: 23, match: "foo", lineText: 'export const gamma = "foo";' } ] } ]); }); it("supports multi-file replacement with dry-run previews", async () => { const backend = createMemoryStateBackend({ files: { "/src/a.ts": 'export const alpha = "foo";\n', "/src/b.ts": 'export const beta = "foo";\n' } }); const preview = await backend.replaceInFiles("/src/*.ts", "foo", "bar", { dryRun: true }); expect(preview).toEqual({ dryRun: true, files: [ { path: "/src/a.ts", replaced: 1, content: 'export const alpha = "bar";\n', diff: '--- /src/a.ts\n+++ /src/a.ts\n@@ -1,2 +1,2 @@\n-export const alpha = "foo";\n+export const alpha = "bar";\n ' }, { path: "/src/b.ts", replaced: 1, content: 'export const beta = "bar";\n', diff: '--- /src/b.ts\n+++ /src/b.ts\n@@ -1,2 +1,2 @@\n-export const beta = "foo";\n+export const beta = "bar";\n ' } ], totalFiles: 2, totalReplacements: 2 }); await expect(backend.readFile("/src/a.ts")).resolves.toBe( 'export const alpha = "foo";\n' ); }); it("applies multi-file replacements when not in dry-run mode", async () => { const backend = createMemoryStateBackend({ files: { "/src/a.ts": 'export const alpha = "foo";\n', "/src/b.ts": 'export const beta = "foo";\n', "/src/c.ts": 'export const gamma = "nope";\n' } }); const result = await backend.replaceInFiles("/src/*.ts", "foo", "bar"); expect(result.totalFiles).toBe(2); expect(result.totalReplacements).toBe(2); await expect(backend.readFile("/src/a.ts")).resolves.toBe( 'export const alpha = "bar";\n' ); await expect(backend.readFile("/src/b.ts")).resolves.toBe( 'export const beta = "bar";\n' ); await expect(backend.readFile("/src/c.ts")).resolves.toBe( 'export const gamma = "nope";\n' ); }); it("applies batches of edits with dry-run and no-op tracking", async () => { const backend = createMemoryStateBackend({ files: { "/src/a.ts": "a\n", "/src/b.ts": "b\n" } }); const preview = await backend.applyEdits( [ { path: "/src/a.ts", content: "aa\n" }, { path: "/src/b.ts", content: "b\n" }, { path: "/src/c.ts", content: "c\n" } ], { dryRun: true } ); expect(preview.totalChanged).toBe(2); expect(preview.edits[0].changed).toBe(true); expect(preview.edits[1].changed).toBe(false); expect(preview.edits[2].changed).toBe(true); await expect(backend.exists("/src/c.ts")).resolves.toBe(false); const applied = await backend.applyEdits([ { path: "/src/a.ts", content: "aa\n" }, { path: "/src/b.ts", content: "b\n" }, { path: "/src/c.ts", content: "c\n" } ]); expect(applied.totalChanged).toBe(2); await expect(backend.readFile("/src/a.ts")).resolves.toBe("aa\n"); await expect(backend.readFile("/src/b.ts")).resolves.toBe("b\n"); await expect(backend.readFile("/src/c.ts")).resolves.toBe("c\n"); }); it("plans structured edits by intent and applies the resulting plan", async () => { const backend = createMemoryStateBackend({ files: { "/src/a.ts": 'export const a = "foo";\n', "/src/data.json": '{ "count": 1 }\n' } }); const plan = await backend.planEdits([ { kind: "replace", path: "/src/a.ts", search: "foo", replacement: "bar" }, { kind: "writeJson", path: "/src/data.json", value: { count: 2 } }, { kind: "write", path: "/src/new.ts", content: "export const created = true;\n" } ]); expect(plan.totalInstructions).toBe(3); expect(plan.totalChanged).toBe(3); expect(plan.edits[0]).toEqual({ instruction: { kind: "replace", path: "/src/a.ts", search: "foo", replacement: "bar" }, path: "/src/a.ts", changed: true, content: 'export const a = "bar";\n', diff: expect.stringContaining("--- /src/a.ts") }); expect(plan.edits[1]).toEqual({ instruction: { kind: "writeJson", path: "/src/data.json", value: { count: 2 } }, path: "/src/data.json", changed: true, content: '{\n "count": 2\n}\n', diff: expect.stringContaining("--- /src/data.json") }); expect(plan.edits[2]).toEqual({ instruction: { kind: "write", path: "/src/new.ts", content: "export const created = true;\n" }, path: "/src/new.ts", changed: true, content: "export const created = true;\n", diff: expect.stringContaining("--- /src/new.ts") }); await expect( backend.applyEditPlan(plan, { dryRun: true }) ).resolves.toMatchObject({ dryRun: true, totalChanged: 3 }); await expect(backend.exists("/src/new.ts")).resolves.toBe(false); const applied = await backend.applyEditPlan(plan); expect(applied.totalChanged).toBe(3); await expect(backend.readFile("/src/a.ts")).resolves.toBe( 'export const a = "bar";\n' ); await expect(backend.readFile("/src/data.json")).resolves.toBe( '{\n "count": 2\n}\n' ); await expect(backend.readFile("/src/new.ts")).resolves.toBe( "export const created = true;\n" ); }); it("fails planning when a replace instruction targets a missing file", async () => { const backend = createMemoryStateBackend(); await expect( backend.planEdits([ { kind: "replace", path: "/src/missing.ts", search: "foo", replacement: "bar" } ]) ).rejects.toThrow("ENOENT: no such file: /src/missing.ts"); }); it("rolls back multi-file replacements when a later write fails", async () => { const backend = createMemoryStateBackend({ fs: new FailingWriteFs( new InMemoryFs({ "/src/a.ts": 'export const alpha = "foo";\n', "/src/b.ts": 'export const beta = "foo";\n' }), "/src/b.ts" ) }); await expect( backend.replaceInFiles("/src/*.ts", "foo", "bar") ).rejects.toMatchObject({ name: "StateBatchOperationError", operation: "replaceInFiles", rolledBack: true } satisfies Partial); await expect(backend.readFile("/src/a.ts")).resolves.toBe( 'export const alpha = "foo";\n' ); await expect(backend.readFile("/src/b.ts")).resolves.toBe( 'export const beta = "foo";\n' ); }); it("can leave partial writes in place when rollback is disabled", async () => { const backend = createMemoryStateBackend({ fs: new FailingWriteFs( new InMemoryFs({ "/src/a.ts": 'export const alpha = "foo";\n', "/src/b.ts": 'export const beta = "foo";\n' }), "/src/b.ts" ) }); await expect( backend.replaceInFiles("/src/*.ts", "foo", "bar", { rollbackOnError: false }) ).rejects.toMatchObject({ name: "StateBatchOperationError", operation: "replaceInFiles", rolledBack: false } satisfies Partial); await expect(backend.readFile("/src/a.ts")).resolves.toBe( 'export const alpha = "bar";\n' ); await expect(backend.readFile("/src/b.ts")).resolves.toBe( 'export const beta = "foo";\n' ); }); it("rolls back applied edits when a later edit write fails", async () => { const backend = createMemoryStateBackend({ fs: new FailingWriteFs( new InMemoryFs({ "/src/a.ts": "a\n", "/src/b.ts": "b\n" }), "/src/b.ts" ) }); await expect( backend.applyEdits([ { path: "/src/a.ts", content: "aa\n" }, { path: "/src/b.ts", content: "bb\n" }, { path: "/src/c.ts", content: "cc\n" } ]) ).rejects.toMatchObject({ name: "StateBatchOperationError", operation: "applyEdits", rolledBack: true } satisfies Partial); await expect(backend.readFile("/src/a.ts")).resolves.toBe("a\n"); await expect(backend.readFile("/src/b.ts")).resolves.toBe("b\n"); await expect(backend.exists("/src/c.ts")).resolves.toBe(false); }); it("supports recursive copy and move helpers", async () => { const backend = createMemoryStateBackend({ files: { "/project/src/index.ts": "export const value = 1;\n" } }); await backend.copyTree("/project", "/project-copy"); await backend.moveTree("/project-copy", "/archive/project-copy"); await expect( backend.readFile("/archive/project-copy/src/index.ts") ).resolves.toBe("export const value = 1;\n"); await expect(backend.exists("/project-copy")).resolves.toBe(false); }); it("supports tree walking and directory summaries", async () => { const backend = createMemoryStateBackend({ files: { "/tree/a.txt": "a", "/tree/nested/b.txt": "bb", "/tree/nested/deeper/c.txt": "ccc" } }); const tree = await backend.walkTree("/tree", { maxDepth: 2 }); expect(tree).toEqual({ path: "/tree", name: "tree", type: "directory", size: 0, children: [ { path: "/tree/a.txt", name: "a.txt", type: "file", size: 1 }, { path: "/tree/nested", name: "nested", type: "directory", size: 0, children: [ { path: "/tree/nested/b.txt", name: "b.txt", type: "file", size: 2 }, { path: "/tree/nested/deeper", name: "deeper", type: "directory", size: 0 } ] } ] }); await expect(backend.summarizeTree("/tree")).resolves.toEqual({ files: 3, directories: 3, symlinks: 0, totalBytes: 6, maxDepth: 3 }); }); it("supports archive creation, listing, extraction, compression, hashing, and file detection", async () => { const backend = createMemoryStateBackend({ files: { "/archive-src/a.txt": "hello", "/archive-src/nested/b.txt": "world" } }); const archive = await backend.createArchive("/bundle.tar", [ "/archive-src" ]); expect(archive.path).toBe("/bundle.tar"); expect(archive.entries).toEqual([ { path: "archive-src", type: "directory", size: 0 }, { path: "archive-src/a.txt", type: "file", size: 5 }, { path: "archive-src/nested", type: "directory", size: 0 }, { path: "archive-src/nested/b.txt", type: "file", size: 5 } ]); await expect(backend.listArchive("/bundle.tar")).resolves.toEqual( archive.entries ); const extracted = await backend.extractArchive("/bundle.tar", "/restored"); expect(extracted.destination).toBe("/restored"); await expect(backend.readFile("/restored/archive-src/a.txt")).resolves.toBe( "hello" ); await expect( backend.readFile("/restored/archive-src/nested/b.txt") ).resolves.toBe("world"); const compressed = await backend.compressFile("/archive-src/a.txt"); expect(compressed.destination).toBe("/archive-src/a.txt.gz"); const decompressed = await backend.decompressFile( "/archive-src/a.txt.gz", "/archive-src/a-restored.txt" ); expect(decompressed.destination).toBe("/archive-src/a-restored.txt"); await expect(backend.readFile("/archive-src/a-restored.txt")).resolves.toBe( "hello" ); await expect( backend.hashFile("/archive-src/a.txt", { algorithm: "sha256" }) ).resolves.toBe( "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" ); await expect(backend.detectFile("/archive-src/a.txt")).resolves.toEqual({ mime: "text/plain", extension: "txt", binary: false, description: "text/plain (txt)" }); }); }); describe("InMemoryFs — symlinks", () => { it("stat follows symlinks, lstat does not", async () => { const fs = new InMemoryFs({ "/target.txt": "hello" }); await fs.symlink("/target.txt", "/link.txt"); const stat = await fs.stat("/link.txt"); expect(stat.type).toBe("file"); expect(stat.size).toBe(5); const lstat = await fs.lstat("/link.txt"); expect(lstat.type).toBe("symlink"); }); it("readlink returns the symlink target", async () => { const fs = new InMemoryFs(); await fs.writeFile("/a.txt", "a"); await fs.symlink("/a.txt", "/link"); await expect(fs.readlink("/link")).resolves.toBe("/a.txt"); }); it("readlink throws on non-symlink", async () => { const fs = new InMemoryFs({ "/plain.txt": "x" }); await expect(fs.readlink("/plain.txt")).rejects.toThrow("EINVAL"); }); it("realpath resolves through symlinks", async () => { const fs = new InMemoryFs({ "/dir/file.txt": "content" }); await fs.symlink("/dir", "/link"); await expect(fs.realpath("/link/file.txt")).resolves.toBe("/dir/file.txt"); }); it("resolves intermediate symlinks when reading files", async () => { const fs = new InMemoryFs({ "/actual/data.txt": "payload" }); await fs.symlink("/actual", "/shortcut"); await expect(fs.readFile("/shortcut/data.txt")).resolves.toBe("payload"); }); it("resolves chained symlinks", async () => { const fs = new InMemoryFs({ "/root.txt": "end" }); await fs.symlink("/root.txt", "/hop1"); await fs.symlink("/hop1", "/hop2"); await fs.symlink("/hop2", "/hop3"); await expect(fs.readFile("/hop3")).resolves.toBe("end"); await expect(fs.realpath("/hop3")).resolves.toBe("/root.txt"); }); it("resolves relative symlinks", async () => { const fs = new InMemoryFs({ "/dir/target.txt": "found" }); await fs.symlink("target.txt", "/dir/link"); await expect(fs.readFile("/dir/link")).resolves.toBe("found"); }); it("throws ELOOP on circular symlinks", async () => { const fs = new InMemoryFs(); await fs.symlink("/b", "/a"); await fs.symlink("/a", "/b"); await expect(fs.readFile("/a")).rejects.toThrow("ELOOP"); }); it("cp preserves symlinks instead of following them", async () => { const fs = new InMemoryFs({ "/target.txt": "data" }); await fs.symlink("/target.txt", "/link"); await fs.cp("/link", "/copied"); const lstat = await fs.lstat("/copied"); expect(lstat.type).toBe("symlink"); await expect(fs.readlink("/copied")).resolves.toBe("/target.txt"); }); it("mv preserves symlinks", async () => { const fs = new InMemoryFs({ "/target.txt": "data" }); await fs.symlink("/target.txt", "/link"); await fs.mv("/link", "/moved"); await expect(fs.readlink("/moved")).resolves.toBe("/target.txt"); await expect(fs.exists("/link")).resolves.toBe(false); }); it("readdirWithFileTypes reports symlinks", async () => { const fs = new InMemoryFs({ "/dir/file.txt": "x" }); await fs.symlink("/dir/file.txt", "/dir/link"); const entries = await fs.readdirWithFileTypes("/dir"); const linkEntry = entries.find((e) => e.name === "link"); expect(linkEntry?.type).toBe("symlink"); }); }); describe("InMemoryFs — hard links", () => { it("link creates a file sharing the source content", async () => { const fs = new InMemoryFs({ "/original.txt": "shared" }); await fs.link("/original.txt", "/hardlink.txt"); await expect(fs.readFile("/hardlink.txt")).resolves.toBe("shared"); }); it("link throws EEXIST when destination exists", async () => { const fs = new InMemoryFs({ "/a.txt": "a", "/b.txt": "b" }); await expect(fs.link("/a.txt", "/b.txt")).rejects.toThrow("EEXIST"); }); it("link throws EPERM on directories", async () => { const fs = new InMemoryFs({ "/dir/child.txt": "c" }); await expect(fs.link("/dir", "/link")).rejects.toThrow("EPERM"); }); }); describe("InMemoryFs — chmod and utimes", () => { it("chmod changes file mode", async () => { const fs = new InMemoryFs({ "/f.txt": "x" }); await fs.chmod("/f.txt", 0o755); const stat = await fs.stat("/f.txt"); expect(stat.mode).toBe(0o755); }); it("utimes changes modification time", async () => { const fs = new InMemoryFs({ "/f.txt": "x" }); const past = new Date("2020-01-01T00:00:00Z"); await fs.utimes("/f.txt", past, past); const stat = await fs.stat("/f.txt"); expect(stat.mtime).toEqual(past); }); it("chmod throws ENOENT on missing path", async () => { const fs = new InMemoryFs(); await expect(fs.chmod("/nope", 0o644)).rejects.toThrow("ENOENT"); }); }); describe("InMemoryFs — root path guards", () => { it("symlink to root path throws", async () => { const fs = new InMemoryFs(); await expect(fs.symlink("/target", "/")).rejects.toThrow("EEXIST"); }); it("link to root path throws", async () => { const fs = new InMemoryFs({ "/f.txt": "x" }); await expect(fs.link("/f.txt", "/")).rejects.toThrow("EEXIST"); }); it("cp to root path throws", async () => { const fs = new InMemoryFs({ "/f.txt": "x" }); await expect(fs.cp("/f.txt", "/")).rejects.toThrow("EISDIR"); }); }); class FailingWriteFs { constructor( private readonly inner: InMemoryFs, private readonly failPath: string ) {} async readFile(path: string) { return this.inner.readFile(path); } async readFileBytes(path: string) { return this.inner.readFileBytes(path); } async writeFile(path: string, content: string) { if (path === this.failPath) { throw new Error(`simulated write failure: ${path}`); } return this.inner.writeFile(path, content); } async writeFileBytes(path: string, content: Uint8Array) { if (path === this.failPath) { throw new Error(`simulated write failure: ${path}`); } return this.inner.writeFileBytes(path, content); } async appendFile(path: string, content: string | Uint8Array) { return this.inner.appendFile(path, content); } async exists(path: string) { return this.inner.exists(path); } async stat(path: string) { return this.inner.stat(path); } async lstat(path: string) { return this.inner.lstat(path); } async mkdir(path: string, options?: { recursive?: boolean }) { return this.inner.mkdir(path, options); } async readdir(path: string) { return this.inner.readdir(path); } async readdirWithFileTypes(path: string) { return this.inner.readdirWithFileTypes(path); } async rm(path: string, options?: { recursive?: boolean; force?: boolean }) { return this.inner.rm(path, options); } async cp(src: string, dest: string, options?: { recursive?: boolean }) { return this.inner.cp(src, dest, options); } async mv(src: string, dest: string) { return this.inner.mv(src, dest); } resolvePath(base: string, path: string) { return this.inner.resolvePath(base, path); } async glob(pattern: string) { return this.inner.glob(pattern); } async chmod(path: string, mode: number) { return this.inner.chmod(path, mode); } async symlink(target: string, linkPath: string) { return this.inner.symlink(target, linkPath); } async link(existingPath: string, newPath: string) { return this.inner.link(existingPath, newPath); } async readlink(path: string) { return this.inner.readlink(path); } async realpath(path: string) { return this.inner.realpath(path); } async utimes(path: string, atime: Date, mtime: Date) { return this.inner.utimes(path, atime, mtime); } }