152 lines
5.3 KiB
TypeScript
152 lines
5.3 KiB
TypeScript
import { access, mkdir, rm, utimes, writeFile } from "node:fs/promises";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { listSnipFiles, readSnipContext, removeSnipFiles, snipDirectory } from "../lib/snip";
|
|
|
|
/**
|
|
* The snip handoff crosses a command boundary as a serialized launch context, so
|
|
* every field arrives as unknown. This is the boundary where a wrong type would
|
|
* otherwise reach the file system as a path.
|
|
*/
|
|
|
|
describe("readSnipContext", () => {
|
|
it("reads the fields the snip command writes", () => {
|
|
expect(
|
|
readSnipContext({
|
|
snipPath: "/tmp/snip.png",
|
|
snipError: "no tool",
|
|
sessionId: "abc123",
|
|
}),
|
|
).toEqual({ snipPath: "/tmp/snip.png", snipError: "no tool", sessionId: "abc123" });
|
|
});
|
|
|
|
it("returns nothing for a launch with no context at all", () => {
|
|
expect(readSnipContext(undefined)).toEqual({});
|
|
expect(readSnipContext(null)).toEqual({});
|
|
});
|
|
|
|
it("ignores a context that is not an object", () => {
|
|
expect(readSnipContext("snip.png")).toEqual({});
|
|
expect(readSnipContext(42)).toEqual({});
|
|
expect(readSnipContext(["/tmp/snip.png"])).toEqual({});
|
|
});
|
|
|
|
it("drops a value that is not a string rather than passing it on", () => {
|
|
// A number or object here would reach `readAttachment` as a path.
|
|
expect(readSnipContext({ snipPath: 42, sessionId: { id: "x" } })).toEqual({});
|
|
});
|
|
|
|
it("treats an empty string as absent", () => {
|
|
expect(readSnipContext({ snipPath: "", sessionId: "" })).toEqual({});
|
|
});
|
|
|
|
it("ignores unknown keys, so a stray field cannot widen the contract", () => {
|
|
expect(readSnipContext({ snipPath: "/tmp/snip.png", somethingElse: "/etc/passwd" })).toEqual({
|
|
snipPath: "/tmp/snip.png",
|
|
});
|
|
});
|
|
|
|
it("reads a payload that only names a conversation, which is a cancelled snip", () => {
|
|
expect(readSnipContext({ sessionId: "abc123" })).toEqual({ sessionId: "abc123" });
|
|
});
|
|
});
|
|
|
|
describe("snipDirectory", () => {
|
|
it("keeps snips under the extension's support directory, not a temp directory", () => {
|
|
// A stored conversation holds the path, so it has to survive a reboot.
|
|
expect(snipDirectory()).toBe("/tmp/lam-agent-support/snips");
|
|
});
|
|
});
|
|
|
|
describe("listSnipFiles", () => {
|
|
const DIRECTORY = "/tmp/lam-agent-support/snips";
|
|
|
|
beforeEach(async () => {
|
|
await rm(DIRECTORY, { recursive: true, force: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(DIRECTORY, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns nothing before anything has been snipped", async () => {
|
|
await expect(listSnipFiles()).resolves.toEqual([]);
|
|
});
|
|
|
|
it("reports each capture with the time it was written", async () => {
|
|
await mkdir(DIRECTORY, { recursive: true });
|
|
const path = `${DIRECTORY}/one.png`;
|
|
await writeFile(path, "png");
|
|
const when = new Date(Date.now() - 60_000);
|
|
await utimes(path, when, when);
|
|
|
|
const [file] = await listSnipFiles();
|
|
|
|
expect(file.path).toBe(path);
|
|
expect(Date.now() - file.mtimeMs).toBeGreaterThan(30_000);
|
|
});
|
|
|
|
it("ignores a subdirectory, which is not a capture", async () => {
|
|
await mkdir(`${DIRECTORY}/nested`, { recursive: true });
|
|
|
|
await expect(listSnipFiles()).resolves.toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("removeSnipFiles", () => {
|
|
const DIRECTORY = "/tmp/lam-agent-support/snips";
|
|
const OUTSIDE = "/tmp/lam-agent-not-a-snip.png";
|
|
|
|
beforeEach(async () => {
|
|
await rm(DIRECTORY, { recursive: true, force: true });
|
|
await mkdir(DIRECTORY, { recursive: true });
|
|
await writeFile(OUTSIDE, "do not delete me");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(DIRECTORY, { recursive: true, force: true });
|
|
await rm(OUTSIDE, { force: true });
|
|
});
|
|
|
|
it("deletes a capture inside the snip directory and counts it", async () => {
|
|
const path = `${DIRECTORY}/one.png`;
|
|
await writeFile(path, "png");
|
|
|
|
await expect(removeSnipFiles([path])).resolves.toBe(1);
|
|
await expect(access(path)).rejects.toThrow();
|
|
});
|
|
|
|
it("counts a file that was already gone, because the question is whether it is still there", async () => {
|
|
await expect(removeSnipFiles([`${DIRECTORY}/never-existed.png`])).resolves.toBe(1);
|
|
});
|
|
|
|
it("refuses a path outside the snip directory and does not count it", async () => {
|
|
// These paths come out of stored JSON, which is data rather than code, so
|
|
// this must not be a general-purpose delete-any-file helper.
|
|
await expect(removeSnipFiles([OUTSIDE])).resolves.toBe(0);
|
|
|
|
await expect(access(OUTSIDE)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("refuses a path that climbs out of the directory", async () => {
|
|
await expect(removeSnipFiles([`${DIRECTORY}/../../lam-agent-not-a-snip.png`])).resolves.toBe(0);
|
|
|
|
await expect(access(OUTSIDE)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("refuses the directory itself", async () => {
|
|
await expect(removeSnipFiles([DIRECTORY])).resolves.toBe(0);
|
|
|
|
await expect(access(DIRECTORY)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("keeps going when one path in the batch is refused, and counts only the one it acted on", async () => {
|
|
const inside = `${DIRECTORY}/one.png`;
|
|
await writeFile(inside, "png");
|
|
|
|
await expect(removeSnipFiles([OUTSIDE, inside])).resolves.toBe(1);
|
|
|
|
await expect(access(inside)).rejects.toThrow();
|
|
await expect(access(OUTSIDE)).resolves.toBeUndefined();
|
|
});
|
|
});
|