@m8i-51/shoal 0.1.19 → 0.1.21
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/framework/__tests__/cost.test.ts +12 -0
- package/framework/__tests__/coverage.test.ts +146 -11
- package/framework/__tests__/cross-run-dedup.test.ts +83 -0
- package/framework/__tests__/diary.test.ts +155 -0
- package/framework/__tests__/page-cache.test.ts +106 -0
- package/framework/__tests__/report.test.ts +8 -0
- package/framework/coverage.ts +3 -2
- package/framework/cross-run-dedup.ts +43 -0
- package/framework/diary.ts +4 -2
- package/package.json +4 -2
- package/server/__tests__/api.test.ts +582 -0
- package/server/__tests__/runs.test.ts +186 -0
- package/server/__tests__/scheduler.test.ts +11 -8
- package/server/index.ts +37 -26
- package/server/runs.ts +2 -1
|
@@ -19,6 +19,18 @@ describe("formatCostUSD", () => {
|
|
|
19
19
|
expect(formatCostUSD(undefined)).toBe("—");
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
it("0 は < $0.0001", () => {
|
|
23
|
+
expect(formatCostUSD(0)).toBe("< $0.0001");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("負値は < $0.0001", () => {
|
|
27
|
+
expect(formatCostUSD(-1)).toBe("< $0.0001");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("0.0001 ちょうどは 4 桁小数", () => {
|
|
31
|
+
expect(formatCostUSD(0.0001)).toBe("$0.0001");
|
|
32
|
+
});
|
|
33
|
+
|
|
22
34
|
it("0.00005 未満は < $0.0001", () => {
|
|
23
35
|
expect(formatCostUSD(0.000005)).toBe("< $0.0001");
|
|
24
36
|
});
|
|
@@ -8,7 +8,7 @@ vi.mock("path", async (importOriginal) => {
|
|
|
8
8
|
return { ...actual, join: (...args: string[]) => args.join("/") };
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
-
import { computeWeightedSummary, updateCoverage, loadCoverage } from "../coverage";
|
|
11
|
+
import { computeWeightedSummary, updateCoverage, loadCoverage, getLastRunPaths, getFindingHotspots } from "../coverage";
|
|
12
12
|
import type { Coverage, RunCoverage } from "../coverage";
|
|
13
13
|
|
|
14
14
|
const HALF_LIFE_DAYS = 7;
|
|
@@ -170,7 +170,7 @@ describe("computeWeightedSummary", () => {
|
|
|
170
170
|
|
|
171
171
|
it("14日以内に同じレンズが複数 run に登場するとボーナスが乗る", () => {
|
|
172
172
|
const now = Date.now();
|
|
173
|
-
// 同じ Accessibility レンズが2回登場 → bonus = 1 + (2-1)*0.
|
|
173
|
+
// 同じ Accessibility レンズが2回登場 → bonus = 1 + (2-1)^3 * 0.005 = 1.005
|
|
174
174
|
setupMockCoverage({
|
|
175
175
|
entries: [
|
|
176
176
|
makeEntry({
|
|
@@ -249,22 +249,29 @@ describe("computeWeightedSummary", () => {
|
|
|
249
249
|
});
|
|
250
250
|
|
|
251
251
|
it("MAX_ENTRIES を超えると最新30件に切り捨てる", () => {
|
|
252
|
-
|
|
252
|
+
// 既に30件ある状態で updateCoverage を呼ぶと31件→30件にトリムされることを確認
|
|
253
|
+
const entries = Array.from({ length: 30 }, (_, i) =>
|
|
253
254
|
makeEntry({
|
|
254
255
|
runId: `run_${i}`,
|
|
255
|
-
timestamp: new Date(Date.now() - i * 1000).toISOString(),
|
|
256
|
+
timestamp: new Date(Date.now() - (30 - i) * 1000).toISOString(),
|
|
256
257
|
})
|
|
257
258
|
);
|
|
258
|
-
setupMockCoverage({ entries });
|
|
259
|
-
vi.mocked(fs.writeFileSync).mockImplementation(() => {});
|
|
260
|
-
|
|
261
|
-
// updateCoverage が 31件目を追加してトリムすることを確認
|
|
262
259
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
263
260
|
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ entries }));
|
|
261
|
+
vi.mocked(fs.writeFileSync).mockImplementation(() => {});
|
|
262
|
+
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
|
264
263
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
264
|
+
updateCoverage("run_new", [], new Map());
|
|
265
|
+
|
|
266
|
+
const calls = vi.mocked(fs.writeFileSync).mock.calls;
|
|
267
|
+
const written = calls[calls.length - 1][1] as string;
|
|
268
|
+
const saved = JSON.parse(written) as Coverage;
|
|
269
|
+
// 30件 + 1件 → MAX_ENTRIES(30) に切り詰め
|
|
270
|
+
expect(saved.entries).toHaveLength(30);
|
|
271
|
+
// 最新のエントリーが含まれる
|
|
272
|
+
expect(saved.entries.some((e) => e.runId === "run_new")).toBe(true);
|
|
273
|
+
// 最も古いエントリーが除外される
|
|
274
|
+
expect(saved.entries.some((e) => e.runId === "run_0")).toBe(false);
|
|
268
275
|
});
|
|
269
276
|
});
|
|
270
277
|
|
|
@@ -311,3 +318,131 @@ describe("updateCoverage", () => {
|
|
|
311
318
|
expect(saved.entries[0].byScenario["New employee task"]).toBe(1);
|
|
312
319
|
});
|
|
313
320
|
});
|
|
321
|
+
|
|
322
|
+
describe("getLastRunPaths", () => {
|
|
323
|
+
it("エントリーがない場合は null を返す", () => {
|
|
324
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
325
|
+
expect(getLastRunPaths()).toBeNull();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("最後のエントリーの visitedPaths と runId を返す", () => {
|
|
329
|
+
setupMockCoverage({
|
|
330
|
+
entries: [
|
|
331
|
+
makeEntry({ runId: "run_1", visitedPaths: ["/old"] }),
|
|
332
|
+
makeEntry({ runId: "run_2", visitedPaths: ["/a", "/b"] }),
|
|
333
|
+
],
|
|
334
|
+
});
|
|
335
|
+
const result = getLastRunPaths();
|
|
336
|
+
expect(result).not.toBeNull();
|
|
337
|
+
expect(result!.runId).toBe("run_2");
|
|
338
|
+
expect(result!.visitedPaths).toEqual(["/a", "/b"]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("visitedPaths が undefined のエントリーは空配列を返す", () => {
|
|
342
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
343
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
344
|
+
JSON.stringify({ entries: [{ ...makeEntry({ runId: "run_1" }), visitedPaths: undefined }] })
|
|
345
|
+
);
|
|
346
|
+
const result = getLastRunPaths();
|
|
347
|
+
expect(result!.visitedPaths).toEqual([]);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe("getFindingHotspots", () => {
|
|
352
|
+
beforeEach(() => {
|
|
353
|
+
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
|
354
|
+
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("findings ディレクトリが存在しない場合は空配列を返す", () => {
|
|
358
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
359
|
+
expect(getFindingHotspots()).toEqual([]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("run_\\d+ パターン以外のディレクトリは無視する", () => {
|
|
363
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
364
|
+
vi.mocked(fs.readdirSync).mockReturnValue(
|
|
365
|
+
[".DS_Store", "run_abc", "tmp"] as unknown as ReturnType<typeof fs.readdirSync>
|
|
366
|
+
);
|
|
367
|
+
expect(getFindingHotspots()).toEqual([]);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("複数 run の findings を同一パスで合算する", () => {
|
|
371
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
372
|
+
vi.mocked(fs.readdirSync)
|
|
373
|
+
.mockReturnValueOnce(["run_1", "run_2"] as unknown as ReturnType<typeof fs.readdirSync>)
|
|
374
|
+
.mockReturnValueOnce(["f1.json"] as unknown as ReturnType<typeof fs.readdirSync>)
|
|
375
|
+
.mockReturnValueOnce(["f2.json"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
376
|
+
|
|
377
|
+
const finding1 = { id: "f1", runId: "run_1", agentId: "a1", agentName: "Alice", role: "r", title: "Bug on /settings page", body: "Found at /settings/profile", category: "bug", timestamp: new Date().toISOString() };
|
|
378
|
+
const finding2 = { id: "f2", runId: "run_2", agentId: "a2", agentName: "Bob", role: "r", title: "UX issue on /settings", body: "The /settings layout is confusing", category: "ux", timestamp: new Date().toISOString() };
|
|
379
|
+
|
|
380
|
+
vi.mocked(fs.readFileSync)
|
|
381
|
+
.mockReturnValueOnce(JSON.stringify(finding1) as unknown as ReturnType<typeof fs.readFileSync>)
|
|
382
|
+
.mockReturnValueOnce(JSON.stringify(finding2) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
383
|
+
|
|
384
|
+
const hotspots = getFindingHotspots();
|
|
385
|
+
const settings = hotspots.find((h) => h.pathPrefix === "/settings");
|
|
386
|
+
expect(settings).toBeDefined();
|
|
387
|
+
expect(settings!.totalFindings).toBe(2);
|
|
388
|
+
expect(settings!.categories["bug"]).toBe(1);
|
|
389
|
+
expect(settings!.categories["ux"]).toBe(1);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("topN パラメータで件数を絞る", () => {
|
|
393
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
394
|
+
vi.mocked(fs.readdirSync)
|
|
395
|
+
.mockReturnValueOnce(["run_1"] as unknown as ReturnType<typeof fs.readdirSync>)
|
|
396
|
+
.mockReturnValueOnce(["f1.json", "f2.json", "f3.json"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
397
|
+
|
|
398
|
+
const makeFinding = (id: string, path: string) => ({ id, runId: "run_1", agentId: "a", agentName: "A", role: "r", title: `Issue on ${path}`, body: `Problem at ${path}`, category: "bug", timestamp: new Date().toISOString() });
|
|
399
|
+
vi.mocked(fs.readFileSync)
|
|
400
|
+
.mockReturnValueOnce(JSON.stringify(makeFinding("f1", "/alpha")) as unknown as ReturnType<typeof fs.readFileSync>)
|
|
401
|
+
.mockReturnValueOnce(JSON.stringify(makeFinding("f2", "/beta")) as unknown as ReturnType<typeof fs.readFileSync>)
|
|
402
|
+
.mockReturnValueOnce(JSON.stringify(makeFinding("f3", "/gamma")) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
403
|
+
|
|
404
|
+
expect(getFindingHotspots(2)).toHaveLength(2);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("壊れた JSON ファイルはスキップする", () => {
|
|
408
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
409
|
+
vi.mocked(fs.readdirSync)
|
|
410
|
+
.mockReturnValueOnce(["run_1"] as unknown as ReturnType<typeof fs.readdirSync>)
|
|
411
|
+
.mockReturnValueOnce(["bad.json"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
412
|
+
vi.mocked(fs.readFileSync).mockReturnValueOnce("invalid{{{" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
413
|
+
|
|
414
|
+
expect(() => getFindingHotspots()).not.toThrow();
|
|
415
|
+
expect(getFindingHotspots()).toEqual([]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("パスが見つからない場合は / にフォールバックする", () => {
|
|
419
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
420
|
+
vi.mocked(fs.readdirSync)
|
|
421
|
+
.mockReturnValueOnce(["run_1"] as unknown as ReturnType<typeof fs.readdirSync>)
|
|
422
|
+
.mockReturnValueOnce(["f1.json"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
423
|
+
const finding = { id: "f1", runId: "run_1", agentId: "a", agentName: "A", role: "r", title: "Generic error", body: "Something went wrong", category: "bug", timestamp: new Date().toISOString() };
|
|
424
|
+
vi.mocked(fs.readFileSync).mockReturnValueOnce(JSON.stringify(finding) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
425
|
+
|
|
426
|
+
const hotspots = getFindingHotspots();
|
|
427
|
+
expect(hotspots.some((h) => h.pathPrefix === "/")).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("triage_result.json(timestamp/category を持たない集計ファイル)は無視する", () => {
|
|
431
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
432
|
+
vi.mocked(fs.readdirSync)
|
|
433
|
+
.mockReturnValueOnce(["run_1"] as unknown as ReturnType<typeof fs.readdirSync>)
|
|
434
|
+
.mockReturnValueOnce(["f1.json", "triage_result.json"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
435
|
+
|
|
436
|
+
const finding = { id: "f1", runId: "run_1", agentId: "a", agentName: "A", role: "r", title: "Bug on /settings", body: "broken at /settings", category: "bug", timestamp: new Date().toISOString() };
|
|
437
|
+
const triageResult = { runId: "run_1", completedAt: "x", issued: [], skipped: [], unprocessed: [] };
|
|
438
|
+
vi.mocked(fs.readFileSync)
|
|
439
|
+
.mockReturnValueOnce(JSON.stringify(finding) as unknown as ReturnType<typeof fs.readFileSync>)
|
|
440
|
+
.mockReturnValueOnce(JSON.stringify(triageResult) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
441
|
+
|
|
442
|
+
const hotspots = getFindingHotspots();
|
|
443
|
+
const settings = hotspots.find((h) => h.pathPrefix === "/settings");
|
|
444
|
+
expect(settings?.totalFindings).toBe(1);
|
|
445
|
+
expect(settings?.categories).toEqual({ bug: 1 });
|
|
446
|
+
expect(Object.keys(settings?.categories ?? {})).not.toContain("undefined");
|
|
447
|
+
});
|
|
448
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { groupSimilarFindings, findCrossRunDuplicates } from "../cross-run-dedup";
|
|
3
|
+
import type { Finding } from "../types";
|
|
4
|
+
|
|
5
|
+
function mockFinding(overrides: Partial<Finding> = {}): Finding {
|
|
6
|
+
return {
|
|
7
|
+
id: "f1",
|
|
8
|
+
runId: "run_1",
|
|
9
|
+
agentId: "a1",
|
|
10
|
+
agentName: "Alice",
|
|
11
|
+
role: "tester",
|
|
12
|
+
title: "Untitled",
|
|
13
|
+
body: "",
|
|
14
|
+
category: "bug",
|
|
15
|
+
timestamp: new Date().toISOString(),
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("groupSimilarFindings", () => {
|
|
21
|
+
it("空配列 → 空配列", () => {
|
|
22
|
+
expect(groupSimilarFindings([])).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("似ていない finding は別クラスタになる", () => {
|
|
26
|
+
const a = mockFinding({ id: "a", title: "Login button broken", body: "clicking login does nothing" });
|
|
27
|
+
const b = mockFinding({ id: "b", title: "Dark mode toggle missing", body: "no way to switch themes" });
|
|
28
|
+
const clusters = groupSimilarFindings([a, b]);
|
|
29
|
+
expect(clusters).toHaveLength(2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("似ている finding は同じクラスタになる(run をまたいでも)", () => {
|
|
33
|
+
const a = mockFinding({
|
|
34
|
+
id: "a", runId: "run_1",
|
|
35
|
+
title: "Dashboard metric not accessible via API",
|
|
36
|
+
body: "The dashboard shows a metric card but there is no API endpoint to retrieve it.",
|
|
37
|
+
});
|
|
38
|
+
const b = mockFinding({
|
|
39
|
+
id: "b", runId: "run_2",
|
|
40
|
+
title: "Dashboard metrics not accessible via API",
|
|
41
|
+
body: "The dashboard metric card has no corresponding API endpoint to retrieve it.",
|
|
42
|
+
});
|
|
43
|
+
const clusters = groupSimilarFindings([a, b]);
|
|
44
|
+
expect(clusters).toHaveLength(1);
|
|
45
|
+
expect(clusters[0]).toHaveLength(2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("3件中2件が似ている → 2クラスタ(似ているペア + 単独1件)", () => {
|
|
49
|
+
const a = mockFinding({ id: "a", title: "Profile update API missing", body: "no endpoint to update profile" });
|
|
50
|
+
const b = mockFinding({ id: "b", title: "Profile update API is missing", body: "no endpoint exists to update the profile" });
|
|
51
|
+
const c = mockFinding({ id: "c", title: "Dark mode toggle missing", body: "no way to switch themes at all" });
|
|
52
|
+
const clusters = groupSimilarFindings([a, b, c]);
|
|
53
|
+
expect(clusters).toHaveLength(2);
|
|
54
|
+
const sizes = clusters.map((cl) => cl.length).sort();
|
|
55
|
+
expect(sizes).toEqual([1, 2]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("threshold を下げるとより緩く同一クラスタになる", () => {
|
|
59
|
+
const a = mockFinding({ id: "a", title: "Cannot export dashboard data", body: "no CSV export button" });
|
|
60
|
+
const b = mockFinding({ id: "b", title: "Missing budgeting dashboard", body: "no way to adjust project budget" });
|
|
61
|
+
const clustersStrict = groupSimilarFindings([a, b], 0.9);
|
|
62
|
+
const clustersLoose = groupSimilarFindings([a, b], 0.01);
|
|
63
|
+
expect(clustersStrict).toHaveLength(2);
|
|
64
|
+
expect(clustersLoose).toHaveLength(1);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("findCrossRunDuplicates", () => {
|
|
69
|
+
it("単独 finding しかない場合は空配列", () => {
|
|
70
|
+
const a = mockFinding({ id: "a", title: "Login button broken", body: "clicking login does nothing" });
|
|
71
|
+
const b = mockFinding({ id: "b", title: "Dark mode toggle missing", body: "no way to switch themes" });
|
|
72
|
+
expect(findCrossRunDuplicates([a, b])).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("2件以上のクラスタのみ返す", () => {
|
|
76
|
+
const a = mockFinding({ id: "a", runId: "run_1", title: "Dashboard metric not accessible via API", body: "no endpoint for the metric card" });
|
|
77
|
+
const b = mockFinding({ id: "b", runId: "run_2", title: "Dashboard metrics not accessible via API", body: "no endpoint for the metric card" });
|
|
78
|
+
const c = mockFinding({ id: "c", runId: "run_3", title: "Dark mode toggle missing", body: "no way to switch themes" });
|
|
79
|
+
const result = findCrossRunDuplicates([a, b, c]);
|
|
80
|
+
expect(result).toHaveLength(1);
|
|
81
|
+
expect(result[0].map((f) => f.runId).sort()).toEqual(["run_1", "run_2"]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
|
|
4
|
+
vi.mock("fs");
|
|
5
|
+
vi.mock("path", async (importOriginal) => {
|
|
6
|
+
const actual = await importOriginal<typeof import("path")>();
|
|
7
|
+
return { ...actual, join: (...args: string[]) => args.join("/"), dirname: (p: string) => p.split("/").slice(0, -1).join("/") };
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const mockCreateMessage = vi.fn();
|
|
11
|
+
vi.mock("../llm-client.js", () => ({
|
|
12
|
+
createLLMClient: vi.fn(() => ({
|
|
13
|
+
client: { createMessage: mockCreateMessage },
|
|
14
|
+
defaultModel: "mock-model",
|
|
15
|
+
})),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { generateDiary, getDiaryPath } from "../diary";
|
|
19
|
+
|
|
20
|
+
const DIARY_TEXT = "# 探索日誌 — run_123\n冒険が始まった。";
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
24
|
+
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
|
25
|
+
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
|
26
|
+
vi.mocked(fs.readdirSync).mockReturnValue([] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
27
|
+
mockCreateMessage.mockClear();
|
|
28
|
+
mockCreateMessage.mockResolvedValue({
|
|
29
|
+
content: [{ type: "text", text: DIARY_TEXT }],
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("getDiaryPath", () => {
|
|
34
|
+
it("run_\\d+ 形式 + ファイル存在 → パスを返す", () => {
|
|
35
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
36
|
+
const result = getDiaryPath("run_123");
|
|
37
|
+
expect(result).not.toBeNull();
|
|
38
|
+
expect(result).toContain("run_123");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("run_\\d+ 形式だがファイルなし → null", () => {
|
|
42
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
43
|
+
expect(getDiaryPath("run_123")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("不正な runId(英字)→ null", () => {
|
|
47
|
+
expect(getDiaryPath("run_abc")).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("パストラバーサル試みは null", () => {
|
|
51
|
+
expect(getDiaryPath("../etc/passwd")).toBeNull();
|
|
52
|
+
expect(getDiaryPath("run_123/../../../etc")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("generateDiary", () => {
|
|
57
|
+
it("生成されたテキストを返す", async () => {
|
|
58
|
+
const result = await generateDiary("run_123", []);
|
|
59
|
+
expect(result).toBe(DIARY_TEXT);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("生成テキストをファイルに書き込む", async () => {
|
|
63
|
+
await generateDiary("run_123", []);
|
|
64
|
+
expect(fs.writeFileSync).toHaveBeenCalled();
|
|
65
|
+
const [, content] = vi.mocked(fs.writeFileSync).mock.calls[0];
|
|
66
|
+
expect(content).toBe(DIARY_TEXT);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ファイル書き込み前にディレクトリを作成する", async () => {
|
|
70
|
+
await generateDiary("run_123", []);
|
|
71
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("findings がない場合はプロンプトに「発見なし」を含める", async () => {
|
|
75
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
76
|
+
await generateDiary("run_123", []);
|
|
77
|
+
const [params] = mockCreateMessage.mock.calls[0];
|
|
78
|
+
const userContent = (params.messages[0].content as string);
|
|
79
|
+
expect(userContent).toContain("発見なし");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("findings がある場合はタイトルをプロンプトに含める", async () => {
|
|
83
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
84
|
+
vi.mocked(fs.readdirSync).mockReturnValue(["f1.json"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
85
|
+
const finding = { id: "f1", runId: "run_123", agentId: "a", agentName: "A", role: "r", title: "Login is broken", body: "", category: "bug", timestamp: new Date().toISOString() };
|
|
86
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(finding) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
87
|
+
|
|
88
|
+
await generateDiary("run_123", []);
|
|
89
|
+
const [params] = mockCreateMessage.mock.calls[0];
|
|
90
|
+
expect(params.messages[0].content).toContain("Login is broken");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("triage_result.json(timestamp を持たない集計ファイル)はプロンプトに混ぜない", async () => {
|
|
94
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
95
|
+
vi.mocked(fs.readdirSync).mockReturnValue(["f1.json", "triage_result.json"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
96
|
+
const finding = { id: "f1", runId: "run_123", agentId: "a", agentName: "A", role: "r", title: "Login is broken", body: "", category: "bug", timestamp: new Date().toISOString() };
|
|
97
|
+
const triageResult = { runId: "run_123", completedAt: "x", issued: [], skipped: [], unprocessed: [] };
|
|
98
|
+
vi.mocked(fs.readFileSync).mockImplementation((p: unknown) => {
|
|
99
|
+
const path = String(p);
|
|
100
|
+
if (path.endsWith("triage_result.json")) return JSON.stringify(triageResult) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
101
|
+
return JSON.stringify(finding) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await generateDiary("run_123", []);
|
|
105
|
+
const [params] = mockCreateMessage.mock.calls[0];
|
|
106
|
+
expect(params.messages[0].content).not.toContain("undefined");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("ログが空の場合はプロンプトに「イベントログなし」を含める", async () => {
|
|
110
|
+
await generateDiary("run_123", []);
|
|
111
|
+
const [params] = mockCreateMessage.mock.calls[0];
|
|
112
|
+
expect(params.messages[0].content).toContain("イベントログなし");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("navigate 行は最大 25 件まで抽出する", async () => {
|
|
116
|
+
const lines = Array.from({ length: 30 }, (_, i) => ` → navigate({"path":"/page${i}"})`);
|
|
117
|
+
await generateDiary("run_123", lines);
|
|
118
|
+
const [params] = mockCreateMessage.mock.calls[0];
|
|
119
|
+
const content: string = params.messages[0].content;
|
|
120
|
+
const navCount = (content.match(/navigate/g) ?? []).length;
|
|
121
|
+
expect(navCount).toBeLessThanOrEqual(25);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("agent start/done 行はすべて抽出する", async () => {
|
|
125
|
+
const lines = [
|
|
126
|
+
"[explorer] Alice start",
|
|
127
|
+
"[browser] Bob start",
|
|
128
|
+
"[explorer] Alice done",
|
|
129
|
+
];
|
|
130
|
+
await generateDiary("run_123", lines);
|
|
131
|
+
const [params] = mockCreateMessage.mock.calls[0];
|
|
132
|
+
const content: string = params.messages[0].content;
|
|
133
|
+
expect(content).toContain("[explorer] Alice start");
|
|
134
|
+
expect(content).toContain("[browser] Bob start");
|
|
135
|
+
expect(content).toContain("[explorer] Alice done");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("finding 発見ログは抽出される", async () => {
|
|
139
|
+
const lines = [' → [findings] saved: "Login page crashes" (bug)'];
|
|
140
|
+
await generateDiary("run_123", lines);
|
|
141
|
+
const [params] = mockCreateMessage.mock.calls[0];
|
|
142
|
+
expect(params.messages[0].content).toContain("[findings] saved");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("navigate 行が 100 文字超えると切り詰められる", async () => {
|
|
146
|
+
const longPath = "/very/long/path/" + "a".repeat(200);
|
|
147
|
+
const lines = [` → navigate({"path":"${longPath}"})`];
|
|
148
|
+
await generateDiary("run_123", lines);
|
|
149
|
+
const [params] = mockCreateMessage.mock.calls[0];
|
|
150
|
+
const content: string = params.messages[0].content;
|
|
151
|
+
const lines2 = content.split("\n");
|
|
152
|
+
const navLine = lines2.find((l) => l.includes("navigate"));
|
|
153
|
+
expect(navLine!.length).toBeLessThanOrEqual(103); // 100 + "…"
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
|
|
4
|
+
vi.mock("fs");
|
|
5
|
+
vi.mock("path", async (importOriginal) => {
|
|
6
|
+
const actual = await importOriginal<typeof import("path")>();
|
|
7
|
+
return { ...actual, join: (...args: string[]) => args.join("/") };
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
import { loadPageHashes, updatePageHashes, hashContent } from "../page-cache";
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
14
|
+
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
|
15
|
+
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("hashContent", () => {
|
|
19
|
+
it("同じ文字列は常に同じハッシュを返す", () => {
|
|
20
|
+
expect(hashContent("hello")).toBe(hashContent("hello"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("異なる文字列は異なるハッシュを返す", () => {
|
|
24
|
+
expect(hashContent("hello")).not.toBe(hashContent("world"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("空文字列も一意のハッシュを返す", () => {
|
|
28
|
+
const h = hashContent("");
|
|
29
|
+
expect(typeof h).toBe("string");
|
|
30
|
+
expect(h.length).toBeGreaterThan(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("16文字に切り詰められる", () => {
|
|
34
|
+
expect(hashContent("any content").length).toBe(16);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("loadPageHashes", () => {
|
|
39
|
+
it("キャッシュファイルがない場合は空オブジェクトを返す", () => {
|
|
40
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
41
|
+
expect(loadPageHashes("localhost:3000")).toEqual({});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("キャッシュファイルがある場合はパースして返す", () => {
|
|
45
|
+
const data = { "/home": "abc123", "/about": "def456" };
|
|
46
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
47
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(data) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
48
|
+
expect(loadPageHashes("localhost:3000")).toEqual(data);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("壊れた JSON は空オブジェクトを返す(例外を投げない)", () => {
|
|
52
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
53
|
+
vi.mocked(fs.readFileSync).mockReturnValue("not valid json{{{" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
54
|
+
expect(() => loadPageHashes("localhost:3000")).not.toThrow();
|
|
55
|
+
expect(loadPageHashes("localhost:3000")).toEqual({});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ホスト名の特殊文字(: や /)はファイルパスで - に変換される", () => {
|
|
59
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
60
|
+
vi.mocked(fs.readFileSync).mockReturnValue("{}" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
61
|
+
loadPageHashes("localhost:3000");
|
|
62
|
+
const checkedPath = vi.mocked(fs.existsSync).mock.calls[0][0] as string;
|
|
63
|
+
expect(checkedPath).not.toContain(":");
|
|
64
|
+
expect(checkedPath).toContain("localhost-3000");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("updatePageHashes", () => {
|
|
69
|
+
it("空の updates を渡すと書き込みをしない", () => {
|
|
70
|
+
updatePageHashes("localhost:3000", {});
|
|
71
|
+
expect(fs.mkdirSync).not.toHaveBeenCalled();
|
|
72
|
+
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("既存データと新データをマージして書き込む", () => {
|
|
76
|
+
const existing = { "/home": "old_hash", "/about": "about_hash" };
|
|
77
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
78
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existing) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
79
|
+
|
|
80
|
+
updatePageHashes("localhost:3000", { "/home": "new_hash", "/contact": "contact_hash" });
|
|
81
|
+
|
|
82
|
+
const written = JSON.parse(vi.mocked(fs.writeFileSync).mock.calls[0][1] as string);
|
|
83
|
+
expect(written["/home"]).toBe("new_hash"); // 上書き
|
|
84
|
+
expect(written["/about"]).toBe("about_hash"); // 既存を保持
|
|
85
|
+
expect(written["/contact"]).toBe("contact_hash"); // 新規追加
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("複数回呼ぶと蓄積される", () => {
|
|
89
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
90
|
+
|
|
91
|
+
updatePageHashes("localhost:3000", { "/a": "hash_a" });
|
|
92
|
+
// 2回目: 1回目の書き込み内容を既存として読み込む
|
|
93
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
94
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ "/a": "hash_a" }) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
95
|
+
updatePageHashes("localhost:3000", { "/b": "hash_b" });
|
|
96
|
+
|
|
97
|
+
const lastWrite = JSON.parse(vi.mocked(fs.writeFileSync).mock.calls.at(-1)![1] as string);
|
|
98
|
+
expect(lastWrite["/a"]).toBe("hash_a");
|
|
99
|
+
expect(lastWrite["/b"]).toBe("hash_b");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("書き込み前にディレクトリを作成する", () => {
|
|
103
|
+
updatePageHashes("localhost:3000", { "/x": "hash_x" });
|
|
104
|
+
expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -126,6 +126,14 @@ describe("generateReport", () => {
|
|
|
126
126
|
expect(html).toContain("<script>");
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
it("finding の body が HTML エスケープされる", () => {
|
|
130
|
+
const finding = makeFinding({ body: 'Click <a href="javascript:void(0)" onclick="steal()">here</a>' });
|
|
131
|
+
generateReport(makeRunLog(), [finding], emptyTriage, makeProductSpec(), [], new Map());
|
|
132
|
+
const html = getSavedHtml();
|
|
133
|
+
expect(html).not.toContain("<a href=");
|
|
134
|
+
expect(html).toContain("<a href=");
|
|
135
|
+
});
|
|
136
|
+
|
|
129
137
|
it("issued finding に → Issue バッジが付く", () => {
|
|
130
138
|
const finding = makeFinding({ id: "f1" });
|
|
131
139
|
const triage: TriageResult = { issued: ["f1"], skipped: [], unprocessed: [], issuesCreated: 1 };
|
package/framework/coverage.ts
CHANGED
|
@@ -214,7 +214,7 @@ export interface FindingHotspot {
|
|
|
214
214
|
|
|
215
215
|
function extractPath(finding: Finding): string {
|
|
216
216
|
const text = `${finding.title} ${finding.body}`;
|
|
217
|
-
const m = text.match(
|
|
217
|
+
const m = text.match(/(\/[a-zA-Z0-9_][a-zA-Z0-9_/-]*)/);
|
|
218
218
|
if (!m) return "/";
|
|
219
219
|
const segments = m[1].split("/").filter(Boolean);
|
|
220
220
|
return segments.length > 0 ? `/${segments[0]}` : "/";
|
|
@@ -231,9 +231,10 @@ export function getFindingHotspots(topN = 12): FindingHotspot[] {
|
|
|
231
231
|
const dir = path.join(base, runDir);
|
|
232
232
|
try {
|
|
233
233
|
for (const file of fs.readdirSync(dir)) {
|
|
234
|
-
if (!file.endsWith(".json")) continue;
|
|
234
|
+
if (!file.endsWith(".json") || file === "triage_result.json") continue;
|
|
235
235
|
try {
|
|
236
236
|
const f: Finding = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
237
|
+
if (typeof f.category !== "string") continue;
|
|
237
238
|
const p = extractPath(f);
|
|
238
239
|
const entry = counts.get(p) ?? { total: 0, categories: {} };
|
|
239
240
|
entry.total++;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Finding } from "./types";
|
|
2
|
+
|
|
3
|
+
function tokenize(text: string): Set<string> {
|
|
4
|
+
return new Set(text.toLowerCase().match(/[a-z0-9]+/g) ?? []);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function jaccard(a: Set<string>, b: Set<string>): number {
|
|
8
|
+
const intersection = [...a].filter((x) => b.has(x)).length;
|
|
9
|
+
const union = new Set([...a, ...b]).size;
|
|
10
|
+
return union === 0 ? 0 : intersection / union;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* タイトル+本文の単語集合の Jaccard 係数で finding をクラスタリングする。
|
|
15
|
+
* triage は同一 run 内の重複しか除去しないため、run をまたいで
|
|
16
|
+
* 繰り返し報告されている問題を見つけるための補助。
|
|
17
|
+
*/
|
|
18
|
+
export function groupSimilarFindings(findings: Finding[], threshold = 0.3): Finding[][] {
|
|
19
|
+
const tokensByIndex = findings.map((f) => tokenize(`${f.title} ${f.body}`));
|
|
20
|
+
const seen = new Set<number>();
|
|
21
|
+
const clusters: Finding[][] = [];
|
|
22
|
+
|
|
23
|
+
for (let i = 0; i < findings.length; i++) {
|
|
24
|
+
if (seen.has(i)) continue;
|
|
25
|
+
const clusterIndices = [i];
|
|
26
|
+
for (let j = i + 1; j < findings.length; j++) {
|
|
27
|
+
if (seen.has(j)) continue;
|
|
28
|
+
if (jaccard(tokensByIndex[i], tokensByIndex[j]) >= threshold) {
|
|
29
|
+
clusterIndices.push(j);
|
|
30
|
+
seen.add(j);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
seen.add(i);
|
|
34
|
+
clusters.push(clusterIndices.map((idx) => findings[idx]));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return clusters;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 2件以上で構成されるクラスタ(= run をまたいだ重複候補)のみ返す */
|
|
41
|
+
export function findCrossRunDuplicates(findings: Finding[], threshold = 0.3): Finding[][] {
|
|
42
|
+
return groupSimilarFindings(findings, threshold).filter((cluster) => cluster.length > 1);
|
|
43
|
+
}
|
package/framework/diary.ts
CHANGED
|
@@ -8,9 +8,11 @@ function loadFindings(runId: string): Finding[] {
|
|
|
8
8
|
if (!fs.existsSync(dir)) return [];
|
|
9
9
|
const out: Finding[] = [];
|
|
10
10
|
for (const file of fs.readdirSync(dir)) {
|
|
11
|
-
if (!file.endsWith(".json")) continue;
|
|
11
|
+
if (!file.endsWith(".json") || file === "triage_result.json") continue;
|
|
12
12
|
try {
|
|
13
|
-
|
|
13
|
+
const f: Finding = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
14
|
+
if (typeof f.timestamp !== "string") continue;
|
|
15
|
+
out.push(f);
|
|
14
16
|
} catch { /* skip */ }
|
|
15
17
|
}
|
|
16
18
|
return out;
|