@selesai/code 0.8.5 → 0.8.6
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/CHANGELOG.md +12 -0
- package/dist/defaults/models.json +5 -0
- package/dist/extensions/agent-browser.test.ts +178 -2
- package/dist/extensions/agent-browser.ts +2 -0
- package/dist/extensions/copy-turn.test.ts +229 -0
- package/dist/extensions/copy-turn.ts +11 -0
- package/dist/extensions/grep-app/index.test.ts +356 -1
- package/dist/extensions/grep-app/index.ts +6 -1
- package/dist/extensions/handoff-new.test.ts +351 -161
- package/dist/extensions/handoff-new.ts +3 -0
- package/dist/extensions/inline-skills.test.ts +93 -0
- package/dist/extensions/inline-skills.ts +3 -0
- package/dist/extensions/pi-subagents/agents/builder.md +5 -2
- package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
- package/dist/extensions/pi-subagents/docs/configuration.md +8 -0
- package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
- package/dist/extensions/pi-subagents/src/extension/config.ts +6 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +83 -14
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +3 -0
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
- package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +7 -5
- package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +39 -9
- package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +16 -0
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +1 -1
- package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
- package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
- package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
- package/dist/extensions/ponytail/index.js +11 -0
- package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
- package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
- package/dist/extensions/ponytail/test/extension.test.js +274 -140
- package/dist/extensions/ponytail/test/helpers.test.js +280 -92
- package/dist/extensions/question/index.ts +18 -0
- package/dist/extensions/question/question-list.ts +22 -0
- package/dist/extensions/question/tests/batch.test.ts +103 -65
- package/dist/extensions/question/tests/helpers.test.ts +68 -35
- package/dist/extensions/question/tests/question-list.test.ts +506 -204
- package/dist/extensions/question/tests/row-layout.test.ts +180 -114
- package/dist/extensions/question/tests/schemas.test.ts +42 -19
- package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
- package/dist/extensions/question/tests/wizard.test.ts +771 -50
- package/dist/extensions/rtk.test.ts +180 -1
- package/dist/extensions/tokenin-onboarding.ts +3 -0
- package/dist/extensions/undo.test.ts +720 -0
- package/dist/extensions/undo.ts +6 -0
- package/dist/extensions/web-agent-onboarding.test.ts +415 -0
- package/dist/extensions/workflow/extension.ts +3 -3
- package/dist/extensions/workflow/modes.ts +41 -19
- package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
- package/docs/plans/workflow-autoloop-reference.md +1 -2
- package/docs/plans/workflow-handoff-carryover.md +108 -0
- package/package.json +4 -1
|
@@ -1,13 +1,51 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { buildSearchUrl, formatSearchResponse } from "./index.ts";
|
|
3
3
|
|
|
4
|
+
// Mock fetch and playwright so the tool execute paths run offline.
|
|
5
|
+
const fetchMock = vi.fn();
|
|
6
|
+
|
|
7
|
+
const chromiumMock = {
|
|
8
|
+
launch: vi.fn(),
|
|
9
|
+
};
|
|
10
|
+
vi.mock("playwright", () => ({ chromium: chromiumMock }));
|
|
11
|
+
|
|
12
|
+
import grepAppExtension from "./index.ts";
|
|
13
|
+
|
|
14
|
+
function makePi() {
|
|
15
|
+
const tools = new Map<string, any>();
|
|
16
|
+
const pi = {
|
|
17
|
+
registerTool: (tool: any) => tools.set(tool.name, tool),
|
|
18
|
+
};
|
|
19
|
+
grepAppExtension(pi as any);
|
|
20
|
+
return { pi, tools };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const theme = {
|
|
24
|
+
fg: (_name: string, value: string) => value,
|
|
25
|
+
bold: (value: string) => value,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const okResponse = (body: string, status = 200, headers: Record<string, string> = {}) =>
|
|
29
|
+
new Response(body, { status, headers });
|
|
30
|
+
|
|
4
31
|
describe("grep-app extension", () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
34
|
+
fetchMock.mockReset();
|
|
35
|
+
chromiumMock.launch.mockReset();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.unstubAllGlobals();
|
|
40
|
+
});
|
|
41
|
+
|
|
5
42
|
it("maps search filters to grep.app parameters", () => {
|
|
6
43
|
const url = buildSearchUrl({
|
|
7
44
|
query: "useEffect cleanup",
|
|
8
45
|
page: 2,
|
|
9
46
|
caseSensitive: true,
|
|
10
47
|
useRegex: true,
|
|
48
|
+
wholeWords: true,
|
|
11
49
|
repo: "facebook/react",
|
|
12
50
|
path: "packages/",
|
|
13
51
|
language: "TypeScript",
|
|
@@ -18,12 +56,24 @@ describe("grep-app extension", () => {
|
|
|
18
56
|
page: "2",
|
|
19
57
|
case: "true",
|
|
20
58
|
regexp: "true",
|
|
59
|
+
words: "true",
|
|
21
60
|
"f.repo.pattern": "facebook/react",
|
|
22
61
|
"f.path.pattern": "packages/",
|
|
23
62
|
"f.lang": "TypeScript",
|
|
24
63
|
});
|
|
25
64
|
});
|
|
26
65
|
|
|
66
|
+
it("buildSearchUrl defaults page to 1 and omits optional filters", () => {
|
|
67
|
+
const url = buildSearchUrl({ query: "foo" });
|
|
68
|
+
expect(url.searchParams.get("page")).toBe("1");
|
|
69
|
+
expect(url.searchParams.has("case")).toBe(false);
|
|
70
|
+
expect(url.searchParams.has("regexp")).toBe(false);
|
|
71
|
+
expect(url.searchParams.has("words")).toBe(false);
|
|
72
|
+
expect(url.searchParams.has("f.repo.pattern")).toBe(false);
|
|
73
|
+
expect(url.searchParams.has("f.path.pattern")).toBe(false);
|
|
74
|
+
expect(url.searchParams.has("f.lang")).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
27
77
|
it("turns grep.app HTML snippets into readable numbered source", () => {
|
|
28
78
|
const output = formatSearchResponse(
|
|
29
79
|
{
|
|
@@ -48,4 +98,309 @@ describe("grep-app extension", () => {
|
|
|
48
98
|
expect(output).toContain("L7: const x = <mark>;");
|
|
49
99
|
expect(output).toContain("https://github.com/owner/repo/blob/main/src/a%20file.ts#L7");
|
|
50
100
|
});
|
|
101
|
+
|
|
102
|
+
it("formatSearchResponse returns no-matches text for empty hits", () => {
|
|
103
|
+
expect(formatSearchResponse({ hits: { total: 0, hits: [] } }, 1)).toBe("No matches found.");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("formatSearchResponse omits the line anchor when no data-line is present", () => {
|
|
107
|
+
const output = formatSearchResponse(
|
|
108
|
+
{
|
|
109
|
+
hits: {
|
|
110
|
+
total: 1,
|
|
111
|
+
hits: [
|
|
112
|
+
{
|
|
113
|
+
repo: "owner/repo",
|
|
114
|
+
branch: "main",
|
|
115
|
+
path: "src/file.ts",
|
|
116
|
+
content: { snippet: "<table><tr><td><pre>code</pre></td></tr></table>" },
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
1,
|
|
122
|
+
);
|
|
123
|
+
expect(output).toContain("L?: code");
|
|
124
|
+
expect(output).toContain("https://github.com/owner/repo/blob/main/src/file.ts");
|
|
125
|
+
expect(output).not.toContain("#L");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("registers both tools", () => {
|
|
129
|
+
const { tools } = makePi();
|
|
130
|
+
expect(tools.has("grep_app_search")).toBe(true);
|
|
131
|
+
expect(tools.has("grep_app_fetch")).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("grep_app_search executes and formats results", async () => {
|
|
135
|
+
const { tools } = makePi();
|
|
136
|
+
fetchMock.mockResolvedValueOnce(
|
|
137
|
+
okResponse(JSON.stringify({
|
|
138
|
+
hits: {
|
|
139
|
+
total: 5,
|
|
140
|
+
hits: [
|
|
141
|
+
{
|
|
142
|
+
repo: "owner/repo",
|
|
143
|
+
branch: "main",
|
|
144
|
+
path: "src/a.ts",
|
|
145
|
+
content: { snippet: '<table><tr data-line="3"><td><pre>const x = 1;</pre></td></tr></table>' },
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
})),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const result = await tools.get("grep_app_search").execute("call1", { query: "const x" }, undefined);
|
|
153
|
+
expect(result.content[0].text).toContain("grep.app: 5 total matches; page 1");
|
|
154
|
+
expect(result.content[0].text).toContain("L3: const x = 1;");
|
|
155
|
+
expect(result.details).toMatchObject({ total: 5, page: 1, resultCount: 1 });
|
|
156
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
157
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
158
|
+
expect(String(url)).toContain("q=const+x");
|
|
159
|
+
expect(init.headers["User-Agent"]).toBe("selesai-grep-app-extension");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("grep_app_search rejects useRegex + wholeWords", async () => {
|
|
163
|
+
const { tools } = makePi();
|
|
164
|
+
await expect(tools.get("grep_app_search").execute("call1", { query: "x", useRegex: true, wholeWords: true }, undefined))
|
|
165
|
+
.rejects.toThrow("useRegex and wholeWords cannot both be true.");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("grep_app_search throws on invalid JSON and unexpected shapes", async () => {
|
|
169
|
+
const { tools } = makePi();
|
|
170
|
+
fetchMock.mockResolvedValueOnce(okResponse("not json"));
|
|
171
|
+
await expect(tools.get("grep_app_search").execute("call1", { query: "x" }, undefined))
|
|
172
|
+
.rejects.toThrow("grep.app returned invalid JSON.");
|
|
173
|
+
|
|
174
|
+
fetchMock.mockResolvedValueOnce(okResponse(JSON.stringify({ hits: {} })));
|
|
175
|
+
await expect(tools.get("grep_app_search").execute("call1", { query: "x" }, undefined))
|
|
176
|
+
.rejects.toThrow("grep.app returned an unexpected response.");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("grep_app_search throws on non-OK responses", async () => {
|
|
180
|
+
const { tools } = makePi();
|
|
181
|
+
fetchMock.mockResolvedValueOnce(okResponse("oops", 500));
|
|
182
|
+
await expect(tools.get("grep_app_search").execute("call1", { query: "x" }, undefined))
|
|
183
|
+
.rejects.toThrow("500");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("grep_app_search falls back to the browser on a 429 challenge", async () => {
|
|
187
|
+
const { tools } = makePi();
|
|
188
|
+
fetchMock.mockResolvedValueOnce(
|
|
189
|
+
okResponse("challenge", 429, { "x-vercel-mitigated": "challenge" }),
|
|
190
|
+
);
|
|
191
|
+
const page = {
|
|
192
|
+
goto: vi.fn(async () => {}),
|
|
193
|
+
waitForFunction: vi.fn(async () => {}),
|
|
194
|
+
locator: vi.fn(() => ({ innerText: vi.fn(async () => '{"hits":{"total":1,"hits":[]}}') })),
|
|
195
|
+
};
|
|
196
|
+
const browser = { newPage: vi.fn(async () => page), close: vi.fn(async () => {}) };
|
|
197
|
+
chromiumMock.launch.mockResolvedValue(browser);
|
|
198
|
+
|
|
199
|
+
const result = await tools.get("grep_app_search").execute("call1", { query: "x" }, undefined);
|
|
200
|
+
expect(result.content[0].text).toBe("No matches found.");
|
|
201
|
+
expect(chromiumMock.launch).toHaveBeenCalledWith({ headless: true });
|
|
202
|
+
expect(browser.close).toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("grep_app_search aborts the browser fetch when the signal fires", async () => {
|
|
206
|
+
const { tools } = makePi();
|
|
207
|
+
fetchMock.mockResolvedValueOnce(
|
|
208
|
+
okResponse("challenge", 429, { "x-vercel-mitigated": "challenge" }),
|
|
209
|
+
);
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
const page = {
|
|
212
|
+
goto: vi.fn(async () => {}),
|
|
213
|
+
waitForFunction: vi.fn(() => new Promise((_resolve, reject) => {
|
|
214
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason));
|
|
215
|
+
})),
|
|
216
|
+
locator: vi.fn(() => ({ innerText: vi.fn(async () => "{}") })),
|
|
217
|
+
};
|
|
218
|
+
const browser = { newPage: vi.fn(async () => page), close: vi.fn(async () => {}) };
|
|
219
|
+
chromiumMock.launch.mockResolvedValue(browser);
|
|
220
|
+
|
|
221
|
+
const promise = tools.get("grep_app_search").execute("call1", { query: "x" }, controller.signal);
|
|
222
|
+
// Let the browser launch and page.goto run, then abort while waitForFunction hangs.
|
|
223
|
+
await vi.waitFor(() => expect(page.goto).toHaveBeenCalled());
|
|
224
|
+
controller.abort(new Error("cancelled"));
|
|
225
|
+
await expect(promise).rejects.toThrow("cancelled");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("grep_app_search rethrows non-abort browser errors", async () => {
|
|
229
|
+
const { tools } = makePi();
|
|
230
|
+
fetchMock.mockResolvedValueOnce(
|
|
231
|
+
okResponse("challenge", 429, { "x-vercel-mitigated": "challenge" }),
|
|
232
|
+
);
|
|
233
|
+
const page = {
|
|
234
|
+
goto: vi.fn(async () => {}),
|
|
235
|
+
waitForFunction: vi.fn(() => Promise.reject(new Error("browser timeout"))),
|
|
236
|
+
locator: vi.fn(() => ({ innerText: vi.fn(async () => "{}") })),
|
|
237
|
+
};
|
|
238
|
+
const browser = { newPage: vi.fn(async () => page), close: vi.fn(async () => {}) };
|
|
239
|
+
chromiumMock.launch.mockResolvedValue(browser);
|
|
240
|
+
|
|
241
|
+
await expect(tools.get("grep_app_search").execute("call1", { query: "x" }, undefined))
|
|
242
|
+
.rejects.toThrow("browser timeout");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("grep_app_search aborts with a default Cancelled error when no reason is given", async () => {
|
|
246
|
+
const { tools } = makePi();
|
|
247
|
+
fetchMock.mockResolvedValueOnce(
|
|
248
|
+
okResponse("challenge", 429, { "x-vercel-mitigated": "challenge" }),
|
|
249
|
+
);
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
const page = {
|
|
252
|
+
goto: vi.fn(async () => {}),
|
|
253
|
+
waitForFunction: vi.fn(() => new Promise((_resolve, reject) => {
|
|
254
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason));
|
|
255
|
+
})),
|
|
256
|
+
locator: vi.fn(() => ({ innerText: vi.fn(async () => "{}") })),
|
|
257
|
+
};
|
|
258
|
+
const browser = { newPage: vi.fn(async () => page), close: vi.fn(async () => {}) };
|
|
259
|
+
chromiumMock.launch.mockResolvedValue(browser);
|
|
260
|
+
|
|
261
|
+
const promise = tools.get("grep_app_search").execute("call1", { query: "x" }, controller.signal);
|
|
262
|
+
await vi.waitFor(() => expect(page.goto).toHaveBeenCalled());
|
|
263
|
+
controller.abort(); // no reason → signal.reason is a DOMException in Node
|
|
264
|
+
await expect(promise).rejects.toThrow();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("grep_app_search waitForFunction predicate checks the page body prefix", async () => {
|
|
268
|
+
const { tools } = makePi();
|
|
269
|
+
fetchMock.mockResolvedValueOnce(
|
|
270
|
+
okResponse("challenge", 429, { "x-vercel-mitigated": "challenge" }),
|
|
271
|
+
);
|
|
272
|
+
let predicate: (() => boolean) | null = null;
|
|
273
|
+
const page = {
|
|
274
|
+
goto: vi.fn(async () => {}),
|
|
275
|
+
waitForFunction: vi.fn((fn: () => boolean) => {
|
|
276
|
+
predicate = fn;
|
|
277
|
+
return Promise.resolve();
|
|
278
|
+
}),
|
|
279
|
+
locator: vi.fn(() => ({ innerText: vi.fn(async () => '{"hits":{"total":0,"hits":[]}}') })),
|
|
280
|
+
};
|
|
281
|
+
const browser = { newPage: vi.fn(async () => page), close: vi.fn(async () => {}) };
|
|
282
|
+
chromiumMock.launch.mockResolvedValue(browser);
|
|
283
|
+
|
|
284
|
+
const result = await tools.get("grep_app_search").execute("call1", { query: "x" }, undefined);
|
|
285
|
+
expect(result.content[0].text).toBe("No matches found.");
|
|
286
|
+
|
|
287
|
+
// The predicate reads document.body.innerText.
|
|
288
|
+
const originalDocument = (globalThis as any).document;
|
|
289
|
+
(globalThis as any).document = { body: { innerText: "{\"hits\":1}" } };
|
|
290
|
+
try {
|
|
291
|
+
expect(predicate!()).toBe(true);
|
|
292
|
+
(globalThis as any).document = { body: { innerText: "not json" } };
|
|
293
|
+
expect(predicate!()).toBe(false);
|
|
294
|
+
} finally {
|
|
295
|
+
(globalThis as any).document = originalDocument;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("grep_app_search truncates long output and reports the full path", async () => {
|
|
300
|
+
const { tools } = makePi();
|
|
301
|
+
// A snippet with 100 table rows produces 100 output lines per hit;
|
|
302
|
+
// 30 hits × 100 lines exceeds the 2000-line truncation limit.
|
|
303
|
+
const rows = Array.from({ length: 100 }, (_, i) => `<tr data-line="${i + 1}"><td><pre>line ${i}</pre></td></tr>`).join("");
|
|
304
|
+
const snippet = `<table>${rows}</table>`;
|
|
305
|
+
const hits = Array.from({ length: 30 }, (_, i) => ({
|
|
306
|
+
repo: "owner/repo",
|
|
307
|
+
branch: "main",
|
|
308
|
+
path: `src/file${i}.ts`,
|
|
309
|
+
content: { snippet },
|
|
310
|
+
}));
|
|
311
|
+
fetchMock.mockResolvedValueOnce(okResponse(JSON.stringify({ hits: { total: 30, hits } })));
|
|
312
|
+
|
|
313
|
+
const result = await tools.get("grep_app_search").execute("call1", { query: "x" }, undefined);
|
|
314
|
+
expect(result.content[0].text).toContain("[Output truncated to");
|
|
315
|
+
expect(result.details.fullOutputPath).toBeTruthy();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("grep_app_fetch fetches a file with line ranges", async () => {
|
|
319
|
+
const { tools } = makePi();
|
|
320
|
+
fetchMock.mockResolvedValueOnce(okResponse("line1\nline2\nline3\nline4"));
|
|
321
|
+
|
|
322
|
+
const result = await tools.get("grep_app_fetch").execute("call2", {
|
|
323
|
+
repo: "owner/repo",
|
|
324
|
+
path: "src/a.ts",
|
|
325
|
+
ref: "v1.0",
|
|
326
|
+
startLine: 2,
|
|
327
|
+
endLine: 3,
|
|
328
|
+
}, undefined);
|
|
329
|
+
expect(result.content[0].text).toBe("2: line2\n3: line3");
|
|
330
|
+
expect(result.details).toMatchObject({ repo: "owner/repo", path: "src/a.ts", ref: "v1.0", startLine: 2, endLine: 3, totalLines: 4 });
|
|
331
|
+
const [url] = fetchMock.mock.calls[0];
|
|
332
|
+
expect(String(url)).toBe("https://raw.githubusercontent.com/owner/repo/v1.0/src/a.ts");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("grep_app_fetch defaults ref and endLine", async () => {
|
|
336
|
+
const { tools } = makePi();
|
|
337
|
+
fetchMock.mockResolvedValueOnce(okResponse("a\nb\nc"));
|
|
338
|
+
|
|
339
|
+
const result = await tools.get("grep_app_fetch").execute("call2", { repo: "owner/repo", path: "src/a.ts" }, undefined);
|
|
340
|
+
expect(result.content[0].text).toBe("1: a\n2: b\n3: c");
|
|
341
|
+
expect(result.details.ref).toBe("HEAD");
|
|
342
|
+
expect(result.details.endLine).toBe(3);
|
|
343
|
+
const [url] = fetchMock.mock.calls[0];
|
|
344
|
+
expect(String(url)).toBe("https://raw.githubusercontent.com/owner/repo/HEAD/src/a.ts");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("grep_app_fetch rejects endLine before startLine", async () => {
|
|
348
|
+
const { tools } = makePi();
|
|
349
|
+
await expect(tools.get("grep_app_fetch").execute("call2", {
|
|
350
|
+
repo: "owner/repo",
|
|
351
|
+
path: "src/a.ts",
|
|
352
|
+
startLine: 5,
|
|
353
|
+
endLine: 2,
|
|
354
|
+
}, undefined)).rejects.toThrow("endLine must be greater than or equal to startLine.");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("grep_app_fetch returns empty-file text for empty ranges", async () => {
|
|
358
|
+
const { tools } = makePi();
|
|
359
|
+
fetchMock.mockResolvedValueOnce(okResponse("a\nb\nc"));
|
|
360
|
+
// startLine beyond the file length produces an empty selection.
|
|
361
|
+
const result = await tools.get("grep_app_fetch").execute("call2", { repo: "owner/repo", path: "src/a.ts", startLine: 5 }, undefined);
|
|
362
|
+
expect(result.content[0].text).toBe("(empty file or range)");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("grep_app_fetch truncates long output", async () => {
|
|
366
|
+
const { tools } = makePi();
|
|
367
|
+
const lines = Array.from({ length: 3000 }, (_, i) => `line ${i}`).join("\n");
|
|
368
|
+
fetchMock.mockResolvedValueOnce(okResponse(lines));
|
|
369
|
+
const result = await tools.get("grep_app_fetch").execute("call2", { repo: "owner/repo", path: "src/a.ts" }, undefined);
|
|
370
|
+
expect(result.content[0].text).toContain("[Output truncated to");
|
|
371
|
+
expect(result.details.fullOutputPath).toBeTruthy();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("renderResult renders success and error states", () => {
|
|
375
|
+
const { tools } = makePi();
|
|
376
|
+
const search = tools.get("grep_app_search");
|
|
377
|
+
const success = search.renderResult(
|
|
378
|
+
{ content: [], details: { total: 10, page: 2, resultCount: 3 } },
|
|
379
|
+
{},
|
|
380
|
+
theme,
|
|
381
|
+
{ isError: false },
|
|
382
|
+
);
|
|
383
|
+
expect(success.render(80).join("\n")).toContain("✓ grep.app: 3 results · page 2 · 10 total matches");
|
|
384
|
+
|
|
385
|
+
const error = search.renderResult({ content: [] }, {}, theme, { isError: true });
|
|
386
|
+
expect(error.render(80).join("\n")).toContain("✗ grep.app search failed");
|
|
387
|
+
|
|
388
|
+
const noDetails = search.renderResult({ content: [] }, {}, theme, { isError: false });
|
|
389
|
+
expect(noDetails.render(80).join("\n")).toContain("0 results");
|
|
390
|
+
|
|
391
|
+
const fetchTool = tools.get("grep_app_fetch");
|
|
392
|
+
const fetchSuccess = fetchTool.renderResult(
|
|
393
|
+
{ content: [], details: { repo: "o/r", path: "src/a.ts", startLine: 1, endLine: 5 } },
|
|
394
|
+
{},
|
|
395
|
+
theme,
|
|
396
|
+
{ isError: false },
|
|
397
|
+
);
|
|
398
|
+
expect(fetchSuccess.render(80).join("\n")).toContain("✓ GitHub: o/r/src/a.ts · lines 1-5");
|
|
399
|
+
|
|
400
|
+
const fetchError = fetchTool.renderResult({ content: [] }, {}, theme, { isError: true });
|
|
401
|
+
expect(fetchError.render(80).join("\n")).toContain("✗ GitHub file fetch failed");
|
|
402
|
+
|
|
403
|
+
const fetchNoDetails = fetchTool.renderResult({ content: [] }, {}, theme, { isError: false });
|
|
404
|
+
expect(fetchNoDetails.render(80).join("\n")).toContain("✓ GitHub: ?/? · lines 1-?");
|
|
405
|
+
});
|
|
51
406
|
});
|
|
@@ -97,7 +97,12 @@ async function fetchWithBrowser(url: URL | string, signal: AbortSignal | undefin
|
|
|
97
97
|
await page.waitForFunction(() => document.body.innerText.trimStart().startsWith("{"), undefined, { timeout: 15_000 });
|
|
98
98
|
return await page.locator("body").innerText();
|
|
99
99
|
} catch (error) {
|
|
100
|
-
if (signal?.aborted)
|
|
100
|
+
if (signal?.aborted) {
|
|
101
|
+
// signal.reason is always set after abort() in Node, so the ?? fallback
|
|
102
|
+
// is unreachable.
|
|
103
|
+
/* v8 ignore next 1 */
|
|
104
|
+
throw signal.reason ?? new Error("Cancelled");
|
|
105
|
+
}
|
|
101
106
|
throw error;
|
|
102
107
|
} finally {
|
|
103
108
|
signal?.removeEventListener("abort", abort);
|