@aaroncql/pim-agent 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/bin/pim.ts +55 -3
- package/package.json +20 -5
- package/src/extensions/_init/index.ts +3 -2
- package/src/extensions/bash/capture.test.ts +0 -126
- package/src/extensions/bash/format.test.ts +0 -240
- package/src/extensions/bash/run.test.ts +0 -262
- package/src/extensions/command-picker/ranker.test.ts +0 -46
- package/src/extensions/edit/edit.test.ts +0 -285
- package/src/extensions/file-picker/catalog.test.ts +0 -263
- package/src/extensions/file-picker/index.test.ts +0 -168
- package/src/extensions/file-picker/ranker.test.ts +0 -94
- package/src/extensions/footer/git.test.ts +0 -76
- package/src/extensions/footer/index.test.ts +0 -161
- package/src/extensions/footer/segments.test.ts +0 -164
- package/src/extensions/glob/glob.test.ts +0 -171
- package/src/extensions/glob/index.test.ts +0 -68
- package/src/extensions/glob/render.test.ts +0 -126
- package/src/extensions/grep/grep.test.ts +0 -387
- package/src/extensions/grep/index.test.ts +0 -68
- package/src/extensions/grep/render.test.ts +0 -269
- package/src/extensions/read/read.test.ts +0 -177
- package/src/extensions/read/render.test.ts +0 -61
- package/src/extensions/subagent/index.test.ts +0 -44
- package/src/extensions/subagent/render.test.ts +0 -292
- package/src/extensions/subagent/subagent.test.ts +0 -315
- package/src/extensions/system-prompt/prompt.test.ts +0 -64
- package/src/extensions/todo/index.test.ts +0 -244
- package/src/extensions/todo/render.test.ts +0 -180
- package/src/extensions/todo/todo.test.ts +0 -222
- package/src/extensions/tps/index.test.ts +0 -254
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
- package/src/extensions/web-fetch/fetch.test.ts +0 -244
- package/src/extensions/web-fetch/render.test.ts +0 -56
- package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
- package/src/extensions/web-search/render.test.ts +0 -21
- package/src/extensions/web-search/search.test.ts +0 -53
- package/src/extensions/working-indicator/index.test.ts +0 -21
- package/src/extensions/write/render.test.ts +0 -64
- package/src/extensions/write/write.test.ts +0 -108
- package/src/shared/DiffLines.test.ts +0 -193
- package/src/shared/DiffRenderer.test.ts +0 -206
- package/src/shared/EditMatcher.test.ts +0 -123
- package/src/shared/FileScanner.test.ts +0 -158
- package/src/shared/FuzzyMatcher.test.ts +0 -114
- package/src/shared/GitignoreFilter.test.ts +0 -64
- package/src/shared/Lines.test.ts +0 -25
- package/src/shared/McpClient.test.ts +0 -235
- package/src/shared/OutputBudget.test.ts +0 -99
- package/src/shared/Paths.test.ts +0 -51
- package/src/shared/PimSettings.test.ts +0 -90
- package/src/shared/Renderer.test.ts +0 -190
- package/src/shared/SpillCache.test.ts +0 -94
- package/src/shared/Tools.test.ts +0 -392
- package/src/telegram/Config.test.ts +0 -275
- package/src/telegram/Markdown.test.ts +0 -143
- package/src/telegram/Renderer.test.ts +0 -216
- package/src/telegram/SessionRegistry.test.ts +0 -89
- package/src/telegram/TaskScheduler.test.ts +0 -278
- package/src/telegram/TaskTool.test.ts +0 -179
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
2
|
-
import { ExaMcpClient } from "./ExaMcpClient";
|
|
3
|
-
|
|
4
|
-
type MockFetch = (
|
|
5
|
-
input: Parameters<typeof fetch>[0],
|
|
6
|
-
init?: Parameters<typeof fetch>[1]
|
|
7
|
-
) => ReturnType<typeof fetch>;
|
|
8
|
-
|
|
9
|
-
const captureMethod = async (
|
|
10
|
-
input: Parameters<typeof fetch>[0],
|
|
11
|
-
init: Parameters<typeof fetch>[1] | undefined
|
|
12
|
-
): Promise<string> => {
|
|
13
|
-
if (input instanceof Request) {
|
|
14
|
-
const body = (await input.clone().json()) as { method?: string };
|
|
15
|
-
return body.method ?? "";
|
|
16
|
-
}
|
|
17
|
-
const body = JSON.parse(String(init?.body)) as { method?: string };
|
|
18
|
-
return body.method ?? "";
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const handshakeOr = (toolCallResponse: () => Response): MockFetch => {
|
|
22
|
-
return async (input, init) => {
|
|
23
|
-
const method = await captureMethod(input, init);
|
|
24
|
-
|
|
25
|
-
if (method === "initialize") {
|
|
26
|
-
return Response.json(
|
|
27
|
-
{ jsonrpc: "2.0", id: 1, result: {} },
|
|
28
|
-
{ headers: { "mcp-session-id": "session" } }
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (method === "notifications/initialized") {
|
|
33
|
-
return new Response(null, { status: 202 });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return toolCallResponse();
|
|
37
|
-
};
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
test("parses Exa JSON results", async () => {
|
|
41
|
-
const client = new ExaMcpClient({
|
|
42
|
-
fetch: handshakeOr(() =>
|
|
43
|
-
Response.json({
|
|
44
|
-
jsonrpc: "2.0",
|
|
45
|
-
id: 2,
|
|
46
|
-
result: {
|
|
47
|
-
content: [
|
|
48
|
-
{
|
|
49
|
-
type: "text",
|
|
50
|
-
text: JSON.stringify({
|
|
51
|
-
results: [
|
|
52
|
-
{
|
|
53
|
-
title: "Pim docs",
|
|
54
|
-
url: "https://example.test/pim",
|
|
55
|
-
snippet: "A concise result.",
|
|
56
|
-
},
|
|
57
|
-
],
|
|
58
|
-
}),
|
|
59
|
-
},
|
|
60
|
-
],
|
|
61
|
-
},
|
|
62
|
-
})
|
|
63
|
-
),
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
await expect(
|
|
67
|
-
client.search({ query: "pim agent", numResults: 3 })
|
|
68
|
-
).resolves.toEqual([
|
|
69
|
-
{
|
|
70
|
-
title: "Pim docs",
|
|
71
|
-
url: "https://example.test/pim",
|
|
72
|
-
snippet: "A concise result.",
|
|
73
|
-
},
|
|
74
|
-
]);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("parses Exa plain-text result blocks", async () => {
|
|
78
|
-
const client = new ExaMcpClient({
|
|
79
|
-
fetch: handshakeOr(() =>
|
|
80
|
-
Response.json({
|
|
81
|
-
jsonrpc: "2.0",
|
|
82
|
-
id: 2,
|
|
83
|
-
result: {
|
|
84
|
-
content: [
|
|
85
|
-
{
|
|
86
|
-
type: "text",
|
|
87
|
-
text: [
|
|
88
|
-
"Title: First text result",
|
|
89
|
-
"URL: https://example.test/first",
|
|
90
|
-
"Published: N/A",
|
|
91
|
-
"Author: N/A",
|
|
92
|
-
"Highlights:",
|
|
93
|
-
"First highlighted sentence.",
|
|
94
|
-
"[...]",
|
|
95
|
-
"Second highlighted sentence.",
|
|
96
|
-
"",
|
|
97
|
-
"---",
|
|
98
|
-
"",
|
|
99
|
-
"Title: Second text result",
|
|
100
|
-
"URL: https://example.test/second",
|
|
101
|
-
"Highlights:",
|
|
102
|
-
"Another result.",
|
|
103
|
-
].join("\n"),
|
|
104
|
-
},
|
|
105
|
-
],
|
|
106
|
-
},
|
|
107
|
-
})
|
|
108
|
-
),
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
await expect(
|
|
112
|
-
client.search({ query: "text result", numResults: 2 })
|
|
113
|
-
).resolves.toEqual([
|
|
114
|
-
{
|
|
115
|
-
title: "First text result",
|
|
116
|
-
url: "https://example.test/first",
|
|
117
|
-
snippet: "First highlighted sentence. Second highlighted sentence.",
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
title: "Second text result",
|
|
121
|
-
url: "https://example.test/second",
|
|
122
|
-
snippet: "Another result.",
|
|
123
|
-
},
|
|
124
|
-
]);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("throws clean errors for malformed tool envelopes", async () => {
|
|
128
|
-
const client = new ExaMcpClient({
|
|
129
|
-
fetch: handshakeOr(() =>
|
|
130
|
-
Response.json({
|
|
131
|
-
jsonrpc: "2.0",
|
|
132
|
-
id: 2,
|
|
133
|
-
result: {
|
|
134
|
-
content: [{ type: "image", url: "https://example.test/image.png" }],
|
|
135
|
-
},
|
|
136
|
-
})
|
|
137
|
-
),
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
await expect(client.search({ query: "pim", numResults: 1 })).rejects.toThrow(
|
|
141
|
-
"Exa returned malformed tool content."
|
|
142
|
-
);
|
|
143
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { DEFAULT_NUM_RESULTS } from "./schema";
|
|
3
|
-
import { formatTitle } from "./render";
|
|
4
|
-
|
|
5
|
-
describe("formatTitle", () => {
|
|
6
|
-
test("always includes the default count in parentheses", () => {
|
|
7
|
-
expect(formatTitle("bun release notes", undefined)).toBe(
|
|
8
|
-
`bun release notes (${DEFAULT_NUM_RESULTS})`
|
|
9
|
-
);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test("includes explicit counts in parentheses", () => {
|
|
13
|
-
expect(formatTitle("pi agent", 3)).toBe("pi agent (3)");
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test("uses a placeholder while keeping the count visible", () => {
|
|
17
|
-
expect(formatTitle(undefined, undefined)).toBe(
|
|
18
|
-
`... (${DEFAULT_NUM_RESULTS})`
|
|
19
|
-
);
|
|
20
|
-
});
|
|
21
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { clampNumResults, formatResults } from "./search";
|
|
3
|
-
|
|
4
|
-
describe("clampNumResults", () => {
|
|
5
|
-
test("defaults when undefined", () => {
|
|
6
|
-
expect(clampNumResults(undefined)).toBe(5);
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
test("clamps above maximum", () => {
|
|
10
|
-
expect(clampNumResults(25)).toBe(10);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
test("clamps below minimum", () => {
|
|
14
|
-
expect(clampNumResults(0)).toBe(1);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test("passes through valid values", () => {
|
|
18
|
-
expect(clampNumResults(3)).toBe(3);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe("formatResults", () => {
|
|
23
|
-
test("renders results as deterministic plain text", () => {
|
|
24
|
-
expect(
|
|
25
|
-
formatResults([
|
|
26
|
-
{
|
|
27
|
-
title: "First",
|
|
28
|
-
url: "https://example.test/first",
|
|
29
|
-
snippet: "First snippet.",
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
title: "Second",
|
|
33
|
-
url: "https://example.test/second",
|
|
34
|
-
snippet: "Second snippet.",
|
|
35
|
-
},
|
|
36
|
-
])
|
|
37
|
-
).toBe(
|
|
38
|
-
[
|
|
39
|
-
"title: First",
|
|
40
|
-
"url: https://example.test/first",
|
|
41
|
-
"snippet: First snippet.",
|
|
42
|
-
"",
|
|
43
|
-
"title: Second",
|
|
44
|
-
"url: https://example.test/second",
|
|
45
|
-
"snippet: Second snippet.",
|
|
46
|
-
].join("\n")
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("returns empty string for empty input", () => {
|
|
51
|
-
expect(formatResults([])).toBe("");
|
|
52
|
-
});
|
|
53
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { formatElapsed } from "./index";
|
|
3
|
-
|
|
4
|
-
describe("working indicator formatting", () => {
|
|
5
|
-
test("formats sub-minute elapsed time", () => {
|
|
6
|
-
expect(formatElapsed(0)).toBe("0s");
|
|
7
|
-
expect(formatElapsed(999)).toBe("0s");
|
|
8
|
-
expect(formatElapsed(32_000)).toBe("32s");
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test("formats minute elapsed time", () => {
|
|
12
|
-
expect(formatElapsed(60_000)).toBe("1m 0s");
|
|
13
|
-
expect(formatElapsed(92_000)).toBe("1m 32s");
|
|
14
|
-
expect(formatElapsed(3_599_000)).toBe("59m 59s");
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test("formats hour elapsed time", () => {
|
|
18
|
-
expect(formatElapsed(3_600_000)).toBe("1h 0m 0s");
|
|
19
|
-
expect(formatElapsed(3_692_000)).toBe("1h 1m 32s");
|
|
20
|
-
});
|
|
21
|
-
});
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { DiffLines } from "../../shared/DiffLines";
|
|
4
|
-
import { DiffView } from "../../shared/DiffView";
|
|
5
|
-
|
|
6
|
-
const stubTheme = {
|
|
7
|
-
fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
|
|
8
|
-
} as unknown as Theme;
|
|
9
|
-
|
|
10
|
-
describe("DiffView.countStats", () => {
|
|
11
|
-
test("returns zeros when diff is undefined", () => {
|
|
12
|
-
expect(DiffView.countStats(undefined)).toEqual({ added: 0, removed: 0 });
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
test("counts added and removed lines across hunks", () => {
|
|
16
|
-
const diff = DiffLines.buildToolDiff(
|
|
17
|
-
"/tmp/x.ts",
|
|
18
|
-
{
|
|
19
|
-
lines: ["alpha", "beta", "gamma", "delta"],
|
|
20
|
-
hasTrailingNewline: true,
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
lines: ["alpha", "BETA", "gamma", "DELTA"],
|
|
24
|
-
hasTrailingNewline: true,
|
|
25
|
-
},
|
|
26
|
-
0
|
|
27
|
-
);
|
|
28
|
-
expect(DiffView.countStats(diff)).toEqual({ added: 2, removed: 2 });
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("counts a brand-new file as all added", () => {
|
|
32
|
-
const diff = DiffLines.buildToolDiff(
|
|
33
|
-
"/tmp/new.ts",
|
|
34
|
-
{ lines: [], hasTrailingNewline: false },
|
|
35
|
-
{ lines: ["one", "two", "three"], hasTrailingNewline: true },
|
|
36
|
-
0
|
|
37
|
-
);
|
|
38
|
-
expect(DiffView.countStats(diff)).toEqual({ added: 3, removed: 0 });
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe("DiffView.formatStats", () => {
|
|
43
|
-
test("emits both segments separated by a slash", () => {
|
|
44
|
-
expect(DiffView.formatStats({ added: 5, removed: 2 }, stubTheme)).toBe(
|
|
45
|
-
"<toolDiffAdded>+5</toolDiffAdded>/<toolDiffRemoved>-2</toolDiffRemoved>"
|
|
46
|
-
);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("omits the removed segment when zero", () => {
|
|
50
|
-
expect(DiffView.formatStats({ added: 5, removed: 0 }, stubTheme)).toBe(
|
|
51
|
-
"<toolDiffAdded>+5</toolDiffAdded>"
|
|
52
|
-
);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("omits the added segment when zero", () => {
|
|
56
|
-
expect(DiffView.formatStats({ added: 0, removed: 2 }, stubTheme)).toBe(
|
|
57
|
-
"<toolDiffRemoved>-2</toolDiffRemoved>"
|
|
58
|
-
);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("returns empty string when both are zero", () => {
|
|
62
|
-
expect(DiffView.formatStats({ added: 0, removed: 0 }, stubTheme)).toBe("");
|
|
63
|
-
});
|
|
64
|
-
});
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterAll, describe, expect, test } from "bun:test";
|
|
5
|
-
import { writeContent } from "./write";
|
|
6
|
-
|
|
7
|
-
const tempRoots: string[] = [];
|
|
8
|
-
|
|
9
|
-
const tempRoot = async (): Promise<string> => {
|
|
10
|
-
const root = await mkdtemp(join(tmpdir(), "pim-write-tool-"));
|
|
11
|
-
tempRoots.push(root);
|
|
12
|
-
return root;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
afterAll(async () => {
|
|
16
|
-
await Promise.all(
|
|
17
|
-
tempRoots.map((root) => rm(root, { force: true, recursive: true }))
|
|
18
|
-
);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
describe("writeContent", () => {
|
|
22
|
-
test("creates a new file and reports an all-added diff", async () => {
|
|
23
|
-
const root = await tempRoot();
|
|
24
|
-
const path = join(root, "fresh.ts");
|
|
25
|
-
|
|
26
|
-
const outcome = await writeContent(path, "alpha\nbeta\n");
|
|
27
|
-
|
|
28
|
-
expect(outcome.created).toBe(true);
|
|
29
|
-
expect(outcome.bytesWritten).toBe(11);
|
|
30
|
-
expect(await readFile(path, "utf8")).toBe("alpha\nbeta\n");
|
|
31
|
-
expect(outcome.diff).toBeDefined();
|
|
32
|
-
const lines = outcome.diff?.hunks[0]?.lines ?? [];
|
|
33
|
-
expect(lines.map((line) => line.kind)).toEqual(["added", "added"]);
|
|
34
|
-
expect(lines.map((line) => line.text)).toEqual(["alpha", "beta"]);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("creates parent directories when missing", async () => {
|
|
38
|
-
const root = await tempRoot();
|
|
39
|
-
const path = join(root, "deep", "nest", "file.txt");
|
|
40
|
-
|
|
41
|
-
const outcome = await writeContent(path, "hello");
|
|
42
|
-
|
|
43
|
-
expect(outcome.created).toBe(true);
|
|
44
|
-
expect(await readFile(path, "utf8")).toBe("hello");
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("emits no diff when content is unchanged", async () => {
|
|
48
|
-
const root = await tempRoot();
|
|
49
|
-
const path = join(root, "same.txt");
|
|
50
|
-
await writeFile(path, "alpha\nbeta\n", "utf8");
|
|
51
|
-
|
|
52
|
-
const outcome = await writeContent(path, "alpha\nbeta\n");
|
|
53
|
-
|
|
54
|
-
expect(outcome.created).toBe(false);
|
|
55
|
-
expect(outcome.diff).toBeUndefined();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("reports a trailing-newline removal even when content matches and produces no diff hunks", async () => {
|
|
59
|
-
const root = await tempRoot();
|
|
60
|
-
const path = join(root, "eof.txt");
|
|
61
|
-
await writeFile(path, "alpha\n", "utf8");
|
|
62
|
-
|
|
63
|
-
const outcome = await writeContent(path, "alpha");
|
|
64
|
-
|
|
65
|
-
expect(outcome.created).toBe(false);
|
|
66
|
-
expect(outcome.diff).toBeUndefined();
|
|
67
|
-
expect(outcome.trailingNewlineChange).toBe("removed");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("reports a trailing-newline addition", async () => {
|
|
71
|
-
const root = await tempRoot();
|
|
72
|
-
const path = join(root, "eof2.txt");
|
|
73
|
-
await writeFile(path, "alpha", "utf8");
|
|
74
|
-
|
|
75
|
-
const outcome = await writeContent(path, "alpha\n");
|
|
76
|
-
|
|
77
|
-
expect(outcome.created).toBe(false);
|
|
78
|
-
expect(outcome.trailingNewlineChange).toBe("added");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("does not report trailing-newline change for newly created files", async () => {
|
|
82
|
-
const root = await tempRoot();
|
|
83
|
-
const path = join(root, "fresh.txt");
|
|
84
|
-
|
|
85
|
-
const outcome = await writeContent(path, "alpha");
|
|
86
|
-
|
|
87
|
-
expect(outcome.created).toBe(true);
|
|
88
|
-
expect(outcome.trailingNewlineChange).toBeUndefined();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("emits a diff capturing modified lines", async () => {
|
|
92
|
-
const root = await tempRoot();
|
|
93
|
-
const path = join(root, "edit.txt");
|
|
94
|
-
await writeFile(path, "alpha\nbeta\ngamma\n", "utf8");
|
|
95
|
-
|
|
96
|
-
const outcome = await writeContent(path, "alpha\nBETA\ngamma\n");
|
|
97
|
-
|
|
98
|
-
expect(outcome.created).toBe(false);
|
|
99
|
-
const lines = outcome.diff?.hunks[0]?.lines ?? [];
|
|
100
|
-
const kinds = lines.map((line) => line.kind);
|
|
101
|
-
expect(kinds).toContain("removed");
|
|
102
|
-
expect(kinds).toContain("added");
|
|
103
|
-
const removed = lines.find((line) => line.kind === "removed");
|
|
104
|
-
const added = lines.find((line) => line.kind === "added");
|
|
105
|
-
expect(removed?.text).toBe("beta");
|
|
106
|
-
expect(added?.text).toBe("BETA");
|
|
107
|
-
});
|
|
108
|
-
});
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { DiffLines, type ToolDiffSide } from "./DiffLines";
|
|
3
|
-
|
|
4
|
-
const side = (
|
|
5
|
-
lines: readonly string[],
|
|
6
|
-
hasTrailingNewline = true
|
|
7
|
-
): ToolDiffSide => ({
|
|
8
|
-
lines,
|
|
9
|
-
hasTrailingNewline,
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
describe("DiffLines.buildToolDiff", () => {
|
|
13
|
-
test("returns undefined when content and EOF state are identical", () => {
|
|
14
|
-
const result = DiffLines.buildToolDiff(
|
|
15
|
-
"/tmp/x.ts",
|
|
16
|
-
side(["alpha", "beta", "gamma"]),
|
|
17
|
-
side(["alpha", "beta", "gamma"]),
|
|
18
|
-
2
|
|
19
|
-
);
|
|
20
|
-
expect(result).toBeUndefined();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("captures a single-line modification with surrounding context", () => {
|
|
24
|
-
const result = DiffLines.buildToolDiff(
|
|
25
|
-
"/tmp/x.ts",
|
|
26
|
-
side(["alpha", "beta", "gamma"]),
|
|
27
|
-
side(["alpha", "BETA", "gamma"]),
|
|
28
|
-
2
|
|
29
|
-
);
|
|
30
|
-
expect(result).toBeDefined();
|
|
31
|
-
const hunk = result?.hunks[0];
|
|
32
|
-
expect(hunk).toBeDefined();
|
|
33
|
-
expect(hunk?.lines.map((line) => line.kind)).toEqual([
|
|
34
|
-
"context",
|
|
35
|
-
"removed",
|
|
36
|
-
"added",
|
|
37
|
-
"context",
|
|
38
|
-
]);
|
|
39
|
-
expect(hunk?.lines.map((line) => line.text)).toEqual([
|
|
40
|
-
"alpha",
|
|
41
|
-
"beta",
|
|
42
|
-
"BETA",
|
|
43
|
-
"gamma",
|
|
44
|
-
]);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("represents an all-added diff for a brand-new file", () => {
|
|
48
|
-
const result = DiffLines.buildToolDiff(
|
|
49
|
-
"/tmp/new.ts",
|
|
50
|
-
side([], false),
|
|
51
|
-
side(["alpha", "beta"]),
|
|
52
|
-
2
|
|
53
|
-
);
|
|
54
|
-
const hunk = result?.hunks[0];
|
|
55
|
-
expect(hunk?.lines.map((line) => line.kind)).toEqual(["added", "added"]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("collapses far-apart changes into separate hunks", () => {
|
|
59
|
-
const oldLines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
|
|
60
|
-
const newLines = oldLines.slice();
|
|
61
|
-
newLines[1] = "EARLY";
|
|
62
|
-
newLines[18] = "LATE";
|
|
63
|
-
|
|
64
|
-
const result = DiffLines.buildToolDiff(
|
|
65
|
-
"/tmp/x.ts",
|
|
66
|
-
side(oldLines),
|
|
67
|
-
side(newLines),
|
|
68
|
-
1
|
|
69
|
-
);
|
|
70
|
-
expect(result?.hunks.length).toBe(2);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("attaches intra-line emphasis to a paired removed/added line", () => {
|
|
74
|
-
const result = DiffLines.buildToolDiff(
|
|
75
|
-
"/tmp/x.ts",
|
|
76
|
-
side(["const x = 1;"]),
|
|
77
|
-
side(["const y = 2;"]),
|
|
78
|
-
1
|
|
79
|
-
);
|
|
80
|
-
const lines = result?.hunks[0]?.lines ?? [];
|
|
81
|
-
const removed = lines.find((line) => line.kind === "removed");
|
|
82
|
-
const added = lines.find((line) => line.kind === "added");
|
|
83
|
-
|
|
84
|
-
expect(removed?.emphasis?.length ?? 0).toBeGreaterThan(0);
|
|
85
|
-
expect(added?.emphasis?.length ?? 0).toBeGreaterThan(0);
|
|
86
|
-
|
|
87
|
-
for (const range of removed?.emphasis ?? []) {
|
|
88
|
-
const slice = removed?.text.slice(range.start, range.end) ?? "";
|
|
89
|
-
expect(slice.includes("x") || slice.includes("1")).toBe(true);
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("skips emphasis when removed and added runs are unequal length", () => {
|
|
94
|
-
const result = DiffLines.buildToolDiff(
|
|
95
|
-
"/tmp/x.ts",
|
|
96
|
-
side(["alpha"]),
|
|
97
|
-
side(["beta", "gamma"]),
|
|
98
|
-
1
|
|
99
|
-
);
|
|
100
|
-
const lines = result?.hunks[0]?.lines ?? [];
|
|
101
|
-
|
|
102
|
-
for (const line of lines) {
|
|
103
|
-
expect(line.emphasis).toBeUndefined();
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("does not emphasize leading whitespace", () => {
|
|
108
|
-
const result = DiffLines.buildToolDiff(
|
|
109
|
-
"/tmp/x.ts",
|
|
110
|
-
side([" foo();"]),
|
|
111
|
-
side([" foo();"]),
|
|
112
|
-
1
|
|
113
|
-
);
|
|
114
|
-
const lines = result?.hunks[0]?.lines ?? [];
|
|
115
|
-
|
|
116
|
-
for (const line of lines) {
|
|
117
|
-
for (const range of line.emphasis ?? []) {
|
|
118
|
-
const slice = line.text.slice(range.start, range.end);
|
|
119
|
-
expect(/^\s/.test(slice)).toBe(false);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test("skips emphasis when lines share no content", () => {
|
|
125
|
-
const result = DiffLines.buildToolDiff(
|
|
126
|
-
"/tmp/x.ts",
|
|
127
|
-
side(["foo"]),
|
|
128
|
-
side(["bar"]),
|
|
129
|
-
1
|
|
130
|
-
);
|
|
131
|
-
const lines = result?.hunks[0]?.lines ?? [];
|
|
132
|
-
|
|
133
|
-
for (const line of lines) {
|
|
134
|
-
expect(line.emphasis).toBeUndefined();
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("returns undefined when only EOF newline state differs (callers surface EOF themselves)", () => {
|
|
139
|
-
const result = DiffLines.buildToolDiff(
|
|
140
|
-
"/tmp/x.ts",
|
|
141
|
-
side(["alpha"], true),
|
|
142
|
-
side(["alpha"], false),
|
|
143
|
-
2
|
|
144
|
-
);
|
|
145
|
-
expect(result).toBeUndefined();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("does not emit a phantom empty added line when appending to a newline-terminated file", () => {
|
|
149
|
-
const result = DiffLines.buildToolDiff(
|
|
150
|
-
"/tmp/x.ts",
|
|
151
|
-
side(["foo", "bar"], true),
|
|
152
|
-
side(["foo", "bar", "baz"], true),
|
|
153
|
-
1
|
|
154
|
-
);
|
|
155
|
-
const lines = result?.hunks[0]?.lines ?? [];
|
|
156
|
-
const added = lines.filter((line) => line.kind === "added");
|
|
157
|
-
expect(added).toHaveLength(1);
|
|
158
|
-
expect(added[0]?.text).toBe("baz");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test("throws on negative contextSize", () => {
|
|
162
|
-
expect(() =>
|
|
163
|
-
DiffLines.buildToolDiff("/tmp/x.ts", side(["a"]), side(["b"]), -1)
|
|
164
|
-
).toThrow();
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
describe("DiffLines.fromText", () => {
|
|
169
|
-
test("treats the empty string as zero lines, no trailing newline", () => {
|
|
170
|
-
expect(DiffLines.fromText("")).toEqual({
|
|
171
|
-
lines: [],
|
|
172
|
-
hasTrailingNewline: false,
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("distinguishes 'a' from 'a\\n'", () => {
|
|
177
|
-
expect(DiffLines.fromText("a")).toEqual({
|
|
178
|
-
lines: ["a"],
|
|
179
|
-
hasTrailingNewline: false,
|
|
180
|
-
});
|
|
181
|
-
expect(DiffLines.fromText("a\n")).toEqual({
|
|
182
|
-
lines: ["a"],
|
|
183
|
-
hasTrailingNewline: true,
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("preserves embedded blank lines without collapsing them", () => {
|
|
188
|
-
expect(DiffLines.fromText("a\n\nb\n")).toEqual({
|
|
189
|
-
lines: ["a", "", "b"],
|
|
190
|
-
hasTrailingNewline: true,
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
});
|