import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CAPTURE_BACKENDS, NoCaptureToolError, captureRegion, selectBackend, snipFileName, type CaptureBackend, } from "../lib/capture"; /** A `PATH` containing exactly the named tools. */ function pathWith(...tools: string[]) { const present = new Set(tools); return async (tool: string) => present.has(tool); } function backendNamed(name: string): CaptureBackend { const backend = CAPTURE_BACKENDS.find((candidate) => candidate.name === name); if (!backend) throw new Error(`no backend named ${name}`); return backend; } const tempDirs: string[] = []; async function tempDir(): Promise { const dir = await mkdtemp(join(tmpdir(), "lam-snip-")); tempDirs.push(dir); return dir; } afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); describe("snipFileName", () => { it("is sortable and filesystem-safe", () => { expect(snipFileName(new Date(2026, 9, 4, 14, 59, 26))).toBe("snip-20261004-145926.png"); }); it("zero-pads every field so names sort as text", () => { expect(snipFileName(new Date(2026, 0, 1, 0, 0, 0))).toBe("snip-20260101-000000.png"); }); }); describe("selectBackend", () => { it("returns nothing when no tool is installed", async () => { expect(await selectBackend(pathWith())).toBeUndefined(); }); it("prefers grimblast on a Hyprland machine", async () => { const backend = await selectBackend(pathWith("grimblast", "grim", "slurp", "maim")); expect(backend?.name).toBe("grimblast"); }); it("falls back to grim + slurp when grimblast is absent", async () => { const backend = await selectBackend(pathWith("grim", "slurp", "maim")); expect(backend?.name).toBe("grim + slurp"); }); it("needs both halves of a pair, not just one", async () => { // grim without slurp cannot select a region, so it must not be chosen. const backend = await selectBackend(pathWith("grim", "maim")); expect(backend?.name).toBe("maim"); }); it("falls back through the X11 tools", async () => { expect((await selectBackend(pathWith("maim")))?.name).toBe("maim"); expect((await selectBackend(pathWith("scrot")))?.name).toBe("scrot"); expect((await selectBackend(pathWith("flameshot")))?.name).toBe("flameshot"); }); it("recognises the macOS built-in tool", async () => { expect((await selectBackend(pathWith("screencapture")))?.name).toBe("screencapture"); }); it("can be given a shorter candidate list, which is how macOS is restricted", async () => { const onlyScreencapture = CAPTURE_BACKENDS.filter((b) => b.name === "screencapture"); const backend = await selectBackend(pathWith("screencapture", "maim"), onlyScreencapture); expect(backend?.name).toBe("screencapture"); }); }); describe("backend commands", () => { it("runs slurp before grim and passes the geometry through", async () => { const calls: Array<{ tool: string; args: string[] }> = []; const run = vi.fn(async (tool: string, args: string[]) => { calls.push({ tool, args }); return tool === "slurp" ? Buffer.from("120,340 800x600\n") : Buffer.alloc(0); }); await backendNamed("grim + slurp").capture({ output: "/tmp/snip.png", run }); expect(calls).toEqual([ { tool: "slurp", args: [] }, { tool: "grim", args: ["-g", "120,340 800x600", "/tmp/snip.png"] }, ]); }); it("does not shell out to grim when the selection was cancelled", async () => { const run = vi.fn(async () => Buffer.from("")); await backendNamed("grim + slurp").capture({ output: "/tmp/snip.png", run }); expect(run).toHaveBeenCalledTimes(1); expect(run).toHaveBeenCalledWith("slurp", [], expect.anything()); }); it("keeps a shell pipeline out of grim by using argv", () => { // `grim -g "$(slurp)"` is the documented one-liner, but a shell here would // be a needless injection surface and would not survive a path with spaces. expect(backendNamed("grim + slurp").requires).toEqual(["grim", "slurp"]); }); it("writes flameshot's stdout, because it refuses an output path", async () => { const dir = await tempDir(); const output = join(dir, "snip.png"); const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]); await backendNamed("flameshot").capture({ output, run: async () => png }); expect(await readFile(output)).toEqual(png); }); it("writes nothing when flameshot returned no image", async () => { const dir = await tempDir(); const output = join(dir, "snip.png"); await backendNamed("flameshot").capture({ output, run: async () => Buffer.alloc(0) }); await expect(readFile(output)).rejects.toThrow(); }); it("asks screencapture for an interactive region and no sound", async () => { const run = vi.fn(async () => Buffer.alloc(0)); await backendNamed("screencapture").capture({ output: "/tmp/snip.png", run }); expect(run).toHaveBeenCalledWith("screencapture", ["-i", "-x", "/tmp/snip.png"], expect.anything()); }); }); describe("captureRegion", () => { it("writes into the directory it was given and reports the path", async () => { const dir = await tempDir(); const output = join(dir, "snip-20261004-145926.png"); const result = await captureRegion({ outputDir: dir, platform: "darwin", now: new Date(2026, 9, 4, 14, 59, 26), hasCommand: pathWith("screencapture"), run: async (_tool, args) => { await writeFile(args[args.length - 1], Buffer.from([0x89, 0x50])); return Buffer.alloc(0); }, }); expect(result).toEqual({ path: output, bytes: 2, backend: "screencapture" }); }); it("returns null when the user backed out and no image was written", async () => { const dir = await tempDir(); const result = await captureRegion({ outputDir: dir, platform: "darwin", hasCommand: pathWith("screencapture"), run: async () => Buffer.alloc(0), }); expect(result).toBeNull(); }); it("treats an empty file as a cancel rather than an attachment", async () => { // At least one tool exits 0 on cancel, so exit codes cannot be trusted. A // zero-byte PNG would be sent to the model as a broken image. const dir = await tempDir(); const result = await captureRegion({ outputDir: dir, platform: "darwin", hasCommand: pathWith("screencapture"), run: async (_tool, args) => { await writeFile(args[args.length - 1], Buffer.alloc(0)); return Buffer.alloc(0); }, }); expect(result).toBeNull(); }); it("explains what to install when nothing is available", async () => { const dir = await tempDir(); await expect( captureRegion({ outputDir: dir, platform: "linux", hasCommand: pathWith() }), ).rejects.toThrow(NoCaptureToolError); }); it("creates the output directory on first use", async () => { const dir = await tempDir(); const nested = join(dir, "snips"); const now = new Date(2026, 9, 4, 14, 59, 26); await captureRegion({ outputDir: nested, platform: "darwin", now, hasCommand: pathWith("screencapture"), run: async (_tool, args) => { await writeFile(args[args.length - 1], Buffer.from([0x89])); return Buffer.alloc(0); }, }); expect((await readFile(join(nested, snipFileName(now)))).length).toBeGreaterThan(0); }); it("does not invent a backend on macOS", async () => { const dir = await tempDir(); // maim is available in this fake PATH but is meaningless on darwin. await expect( captureRegion({ outputDir: dir, platform: "darwin", hasCommand: pathWith("maim") }), ).rejects.toThrow(NoCaptureToolError); }); });