llm-to-agent/packages/local-files/src/local-workspace-resolver.test.ts
2026-08-14 15:48:49 +08:00

151 lines
5.5 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { LocalWorkspaceFiles, LocalWorkspaceResolver } from ".";
describe("LocalWorkspaceResolver", () => {
test("只接受实际存在且可读写的绝对目录", async () => {
const root = await mkdtemp(join(tmpdir(), "great-agent-workspace-"));
try {
const resolver = new LocalWorkspaceResolver();
expect(await resolver.resolveForCreation(root)).toContain(
"great-agent-workspace-",
);
expect(await resolver.isAvailable(root)).toBe(true);
expect(
resolver.resolveForCreation("relative/path"),
).rejects.toMatchObject({ code: "WORKSPACE_PATH_NOT_ABSOLUTE" });
} finally {
await rm(root, { recursive: true });
}
});
});
describe("LocalWorkspaceFiles", () => {
test("列出、搜索并读取工作区文本", async () => {
const root = await mkdtemp(join(tmpdir(), "great-agent-files-"));
try {
await mkdir(join(root, "docs"));
await writeFile(
join(root, "docs", "说明.md"),
"第一行\n需要搜索的内容\n",
);
const files = new LocalWorkspaceFiles();
expect(await files.list(root)).toEqual([
{ path: "docs", name: "docs", kind: "directory" },
]);
expect((await files.search(root, "搜索"))[0]).toMatchObject({
path: "docs/说明.md",
line: 2,
matchedBy: "content",
});
expect((await files.readText(root, "docs/说明.md")).content).toContain(
"第一行",
);
} finally {
await rm(root, { recursive: true });
}
});
test("拒绝绝对路径、父目录跳转和符号链接逃逸", async () => {
const root = await mkdtemp(join(tmpdir(), "great-agent-safe-"));
const outside = await mkdtemp(join(tmpdir(), "great-agent-outside-"));
try {
await writeFile(join(outside, "secret.txt"), "secret");
await symlink(join(outside, "secret.txt"), join(root, "shortcut.txt"));
await symlink(outside, join(root, "outside-directory"));
const files = new LocalWorkspaceFiles();
await expect(files.readText(root, "../secret.txt")).rejects.toMatchObject(
{
code: "WORKSPACE_PATH_FORBIDDEN",
},
);
await expect(
files.readText(root, join(outside, "secret.txt")),
).rejects.toMatchObject({
code: "WORKSPACE_PATH_FORBIDDEN",
});
await expect(files.readText(root, "shortcut.txt")).rejects.toMatchObject({
code: "WORKSPACE_PATH_FORBIDDEN",
});
await expect(
files.createText(root, "outside-directory/new.txt", "escape"),
).rejects.toMatchObject({ code: "WORKSPACE_PATH_FORBIDDEN" });
} finally {
await rm(root, { recursive: true });
await rm(outside, { recursive: true });
}
});
test("二进制文件返回明确的不支持状态", async () => {
const root = await mkdtemp(join(tmpdir(), "great-agent-binary-"));
try {
await writeFile(join(root, "image.bin"), new Uint8Array([1, 0, 2]));
const files = new LocalWorkspaceFiles();
expect((await files.inspect(root, "image.bin")).readableAsText).toBe(
false,
);
await expect(files.readText(root, "image.bin")).rejects.toMatchObject({
code: "FILE_TYPE_UNSUPPORTED",
});
} finally {
await rm(root, { recursive: true });
}
});
test("排他创建并使用内容哈希安全修改文本", async () => {
const root = await mkdtemp(join(tmpdir(), "great-agent-write-"));
try {
const files = new LocalWorkspaceFiles();
const created = await files.createText(root, "notes.txt", "旧内容\n");
expect(created.path).toBe("notes.txt");
expect(created.hash).toHaveLength(64);
await expect(
files.createText(root, "notes.txt", "不能覆盖"),
).rejects.toMatchObject({ code: "FILE_ALREADY_EXISTS" });
const changed = await files.applyTextPatch(
root,
"notes.txt",
created.hash,
[{ oldText: "旧内容", newText: "新内容" }],
);
expect(changed.hash).not.toBe(created.hash);
expect((await files.readText(root, "notes.txt")).content).toBe(
"新内容\n",
);
await expect(
files.applyTextPatch(root, "notes.txt", created.hash, [
{ oldText: "新内容", newText: "危险覆盖" },
]),
).rejects.toMatchObject({ code: "FILE_CHANGED" });
} finally {
await rm(root, { recursive: true });
}
});
test("修改目标缺失或不唯一时不写文件", async () => {
const root = await mkdtemp(join(tmpdir(), "great-agent-patch-"));
try {
await writeFile(join(root, "repeat.txt"), "相同\n相同\n");
const files = new LocalWorkspaceFiles();
const current = await files.readText(root, "repeat.txt");
await expect(
files.applyTextPatch(root, "repeat.txt", current.hash, [
{ oldText: "不存在", newText: "内容" },
]),
).rejects.toMatchObject({ code: "PATCH_TARGET_NOT_FOUND" });
await expect(
files.applyTextPatch(root, "repeat.txt", current.hash, [
{ oldText: "相同", newText: "内容" },
]),
).rejects.toMatchObject({ code: "PATCH_TARGET_AMBIGUOUS" });
expect((await files.readText(root, "repeat.txt")).content).toBe(
"相同\n相同\n",
);
} finally {
await rm(root, { recursive: true });
}
});
});