151 lines
5.5 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|