@m8i-51/shoal 0.1.20 → 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__/coverage.test.ts +19 -0
- package/framework/__tests__/cross-run-dedup.test.ts +83 -0
- package/framework/__tests__/diary.test.ts +16 -0
- package/framework/coverage.ts +2 -1
- package/framework/cross-run-dedup.ts +43 -0
- package/framework/diary.ts +4 -2
- package/package.json +2 -2
- package/server/__tests__/api.test.ts +276 -1
- package/server/__tests__/runs.test.ts +186 -0
- package/server/index.ts +28 -22
- package/server/runs.ts +2 -1
|
@@ -426,4 +426,23 @@ describe("getFindingHotspots", () => {
|
|
|
426
426
|
const hotspots = getFindingHotspots();
|
|
427
427
|
expect(hotspots.some((h) => h.pathPrefix === "/")).toBe(true);
|
|
428
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
|
+
});
|
|
429
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
|
+
});
|
|
@@ -90,6 +90,22 @@ describe("generateDiary", () => {
|
|
|
90
90
|
expect(params.messages[0].content).toContain("Login is broken");
|
|
91
91
|
});
|
|
92
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
|
+
|
|
93
109
|
it("ログが空の場合はプロンプトに「イベントログなし」を含める", async () => {
|
|
94
110
|
await generateDiary("run_123", []);
|
|
95
111
|
const [params] = mockCreateMessage.mock.calls[0];
|
package/framework/coverage.ts
CHANGED
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m8i-51/shoal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Multi-agent web exploration framework — finds bugs, UX issues, and missing features by running AI agents against your app",
|
|
6
6
|
"repository": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"express-rate-limit": "^8.5.0",
|
|
43
43
|
"openai": "^6.33.0",
|
|
44
44
|
"playwright": "^1.59.1",
|
|
45
|
-
"tsx": "^4.
|
|
45
|
+
"tsx": "^4.22.4",
|
|
46
46
|
"yaml": "^2.9.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
@@ -15,7 +15,9 @@ vi.mock("express-rate-limit", () => ({ rateLimit: () => (_req: unknown, _res: un
|
|
|
15
15
|
|
|
16
16
|
import * as fs from "fs";
|
|
17
17
|
import { generateDiary, getDiaryPath } from "../../framework/diary.js";
|
|
18
|
-
import { activeSessions } from "../runner.js";
|
|
18
|
+
import { activeSessions, spawnRun, cancelSession } from "../runner.js";
|
|
19
|
+
import { listRuns, getReportPath } from "../runs.js";
|
|
20
|
+
import { loadSchedule } from "../scheduler.js";
|
|
19
21
|
|
|
20
22
|
// NODE_ENV=test なので app.listen は呼ばれない
|
|
21
23
|
const { app } = await import("../index.js");
|
|
@@ -66,6 +68,7 @@ function setupFindingsDir(runDirs: Record<string, object[]>) {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
beforeEach(() => {
|
|
71
|
+
process.env.BASE_URL = "http://localhost:3000";
|
|
69
72
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
70
73
|
vi.mocked(fs.readdirSync).mockReturnValue([] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
71
74
|
vi.mocked(fs.readFileSync).mockReturnValue("{}" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
@@ -169,6 +172,66 @@ describe("GET /api/findings", () => {
|
|
|
169
172
|
const res = await request(app).get("/api/findings");
|
|
170
173
|
expect(res.body).toEqual([]);
|
|
171
174
|
});
|
|
175
|
+
|
|
176
|
+
it("triage_result.json(timestamp を持たない集計ファイル)は無視する", async () => {
|
|
177
|
+
const finding = mockFinding({ id: "f1", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" });
|
|
178
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
179
|
+
vi.mocked(fs.readdirSync).mockImplementation((p: unknown) => {
|
|
180
|
+
const path = String(p);
|
|
181
|
+
if (path.endsWith("run_1")) return ["f0.json", "triage_result.json"] as unknown as ReturnType<typeof fs.readdirSync>;
|
|
182
|
+
return ["run_1"] as unknown as ReturnType<typeof fs.readdirSync>;
|
|
183
|
+
});
|
|
184
|
+
vi.mocked(fs.readFileSync).mockImplementation((p: unknown) => {
|
|
185
|
+
const path = String(p);
|
|
186
|
+
if (path.endsWith("f0.json")) return JSON.stringify(finding) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
187
|
+
if (path.endsWith("triage_result.json")) {
|
|
188
|
+
return JSON.stringify({ runId: "run_1", completedAt: "x", issued: [], skipped: [], unprocessed: [] }) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
189
|
+
}
|
|
190
|
+
return "{}" as unknown as ReturnType<typeof fs.readFileSync>;
|
|
191
|
+
});
|
|
192
|
+
const res = await request(app).get("/api/findings");
|
|
193
|
+
expect(res.status).toBe(200);
|
|
194
|
+
expect(res.body).toHaveLength(1);
|
|
195
|
+
expect(res.body[0].id).toBe("f1");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ================================================================
|
|
200
|
+
// GET /api/findings/cross-run-duplicates
|
|
201
|
+
// ================================================================
|
|
202
|
+
describe("GET /api/findings/cross-run-duplicates", () => {
|
|
203
|
+
it("findings がない → 空配列", async () => {
|
|
204
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
205
|
+
const res = await request(app).get("/api/findings/cross-run-duplicates");
|
|
206
|
+
expect(res.status).toBe(200);
|
|
207
|
+
expect(res.body).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("run をまたいで似た finding をクラスタとして返す", async () => {
|
|
211
|
+
const a = mockFinding({
|
|
212
|
+
id: "f0", runId: "run_1",
|
|
213
|
+
title: "Dashboard metric not accessible via API",
|
|
214
|
+
body: "no API endpoint for the metric card",
|
|
215
|
+
});
|
|
216
|
+
const b = mockFinding({
|
|
217
|
+
id: "f0", runId: "run_2",
|
|
218
|
+
title: "Dashboard metrics not accessible via API",
|
|
219
|
+
body: "no API endpoint for the metric card",
|
|
220
|
+
});
|
|
221
|
+
setupFindingsDir({ run_1: [a], run_2: [b] });
|
|
222
|
+
const res = await request(app).get("/api/findings/cross-run-duplicates");
|
|
223
|
+
expect(res.status).toBe(200);
|
|
224
|
+
expect(res.body).toHaveLength(1);
|
|
225
|
+
expect(res.body[0]).toHaveLength(2);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("似ていない finding しかない → 空配列", async () => {
|
|
229
|
+
const a = mockFinding({ id: "f0", runId: "run_1", title: "Login button broken", body: "click does nothing" });
|
|
230
|
+
const b = mockFinding({ id: "f0", runId: "run_2", title: "Dark mode missing", body: "no theme switch" });
|
|
231
|
+
setupFindingsDir({ run_1: [a], run_2: [b] });
|
|
232
|
+
const res = await request(app).get("/api/findings/cross-run-duplicates");
|
|
233
|
+
expect(res.body).toEqual([]);
|
|
234
|
+
});
|
|
172
235
|
});
|
|
173
236
|
|
|
174
237
|
// ================================================================
|
|
@@ -305,3 +368,215 @@ describe("PATCH /api/schedule", () => {
|
|
|
305
368
|
expect(res.body.enabled).toBe(true);
|
|
306
369
|
});
|
|
307
370
|
});
|
|
371
|
+
|
|
372
|
+
// ================================================================
|
|
373
|
+
// GET /api/schedule
|
|
374
|
+
// ================================================================
|
|
375
|
+
describe("GET /api/schedule", () => {
|
|
376
|
+
it("loadSchedule の結果を返す", async () => {
|
|
377
|
+
vi.mocked(loadSchedule).mockReturnValue({ enabled: true, dayOfWeek: 3, hour: 10, minute: 30, lastRunDate: null });
|
|
378
|
+
const res = await request(app).get("/api/schedule");
|
|
379
|
+
expect(res.status).toBe(200);
|
|
380
|
+
expect(res.body).toEqual({ enabled: true, dayOfWeek: 3, hour: 10, minute: 30, lastRunDate: null });
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// ================================================================
|
|
385
|
+
// GET /api/spec
|
|
386
|
+
// ================================================================
|
|
387
|
+
describe("GET /api/spec", () => {
|
|
388
|
+
it("spec ファイルが存在しない → 404", async () => {
|
|
389
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
390
|
+
const res = await request(app).get("/api/spec");
|
|
391
|
+
expect(res.status).toBe(404);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("spec ファイルが存在する → 200 + JSON", async () => {
|
|
395
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
396
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ appGoals: ["goal1"] }) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
397
|
+
const res = await request(app).get("/api/spec");
|
|
398
|
+
expect(res.status).toBe(200);
|
|
399
|
+
expect(res.body.appGoals).toEqual(["goal1"]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("spec ファイルの JSON が壊れている → 500", async () => {
|
|
403
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
404
|
+
vi.mocked(fs.readFileSync).mockReturnValue("not json" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
405
|
+
const res = await request(app).get("/api/spec");
|
|
406
|
+
expect(res.status).toBe(500);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ================================================================
|
|
411
|
+
// PATCH /api/spec/goals
|
|
412
|
+
// ================================================================
|
|
413
|
+
describe("PATCH /api/spec/goals", () => {
|
|
414
|
+
it("spec ファイルが存在しない → 404", async () => {
|
|
415
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
416
|
+
const res = await request(app).patch("/api/spec/goals").send({ goals: ["a"] });
|
|
417
|
+
expect(res.status).toBe(404);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("goals が配列でない → 400", async () => {
|
|
421
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
422
|
+
const res = await request(app).patch("/api/spec/goals").send({ goals: "not-an-array" });
|
|
423
|
+
expect(res.status).toBe(400);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("goals に文字列以外が含まれる → 400", async () => {
|
|
427
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
428
|
+
const res = await request(app).patch("/api/spec/goals").send({ goals: ["a", 1] });
|
|
429
|
+
expect(res.status).toBe(400);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("正常な goals → 200 + ok:true、ファイルに書き込む", async () => {
|
|
433
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
434
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ appGoals: [] }) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
435
|
+
const res = await request(app).patch("/api/spec/goals").send({ goals: ["new goal"] });
|
|
436
|
+
expect(res.status).toBe(200);
|
|
437
|
+
expect(res.body.ok).toBe(true);
|
|
438
|
+
expect(fs.writeFileSync).toHaveBeenCalled();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("spec ファイルの読み込みに失敗 → 500", async () => {
|
|
442
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
443
|
+
vi.mocked(fs.readFileSync).mockReturnValue("not json" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
444
|
+
const res = await request(app).patch("/api/spec/goals").send({ goals: ["a"] });
|
|
445
|
+
expect(res.status).toBe(500);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// ================================================================
|
|
450
|
+
// GET /api/runs
|
|
451
|
+
// ================================================================
|
|
452
|
+
describe("GET /api/runs", () => {
|
|
453
|
+
it("listRuns の結果をそのまま返す(アクティブセッションなし)", async () => {
|
|
454
|
+
vi.mocked(listRuns).mockReturnValue([{ runId: "run_1", isLive: false } as never]);
|
|
455
|
+
const res = await request(app).get("/api/runs");
|
|
456
|
+
expect(res.status).toBe(200);
|
|
457
|
+
expect(res.body).toEqual([{ runId: "run_1", isLive: false }]);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("アクティブセッションがある run は isLive で補完される", async () => {
|
|
461
|
+
vi.mocked(listRuns).mockReturnValue([{ runId: "run_2", isLive: false } as never]);
|
|
462
|
+
(activeSessions as Map<string, unknown>).set("run_2", { done: false });
|
|
463
|
+
const res = await request(app).get("/api/runs");
|
|
464
|
+
expect(res.status).toBe(200);
|
|
465
|
+
expect(res.body[0].isLive).toBe(true);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// ================================================================
|
|
470
|
+
// GET /api/runs/:runId/report
|
|
471
|
+
// ================================================================
|
|
472
|
+
describe("GET /api/runs/:runId/report", () => {
|
|
473
|
+
it("不正な runId → 400", async () => {
|
|
474
|
+
const res = await request(app).get("/api/runs/bad-id/report");
|
|
475
|
+
expect(res.status).toBe(400);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("report が見つからない → 404", async () => {
|
|
479
|
+
vi.mocked(getReportPath).mockReturnValue(null);
|
|
480
|
+
const res = await request(app).get("/api/runs/run_123/report");
|
|
481
|
+
expect(res.status).toBe(404);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ================================================================
|
|
486
|
+
// POST /api/runs/start
|
|
487
|
+
// ================================================================
|
|
488
|
+
describe("POST /api/runs/start", () => {
|
|
489
|
+
it("spawnRun を呼んで sessionId を返す", async () => {
|
|
490
|
+
vi.mocked(spawnRun).mockReturnValue("run_999");
|
|
491
|
+
const res = await request(app).post("/api/runs/start").send({ baseUrl: "https://example.com" });
|
|
492
|
+
expect(res.status).toBe(200);
|
|
493
|
+
expect(res.body.sessionId).toBe("run_999");
|
|
494
|
+
expect(spawnRun).toHaveBeenCalledWith(expect.objectContaining({ baseUrl: "https://example.com" }));
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ================================================================
|
|
499
|
+
// POST /api/runs/:runId/cancel
|
|
500
|
+
// ================================================================
|
|
501
|
+
describe("POST /api/runs/:runId/cancel", () => {
|
|
502
|
+
it("cancelSession の結果を ok として返す(成功)", async () => {
|
|
503
|
+
vi.mocked(cancelSession).mockReturnValue(true);
|
|
504
|
+
const res = await request(app).post("/api/runs/run_123/cancel");
|
|
505
|
+
expect(res.status).toBe(200);
|
|
506
|
+
expect(res.body.ok).toBe(true);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("cancelSession の結果を ok として返す(失敗)", async () => {
|
|
510
|
+
vi.mocked(cancelSession).mockReturnValue(false);
|
|
511
|
+
const res = await request(app).post("/api/runs/run_123/cancel");
|
|
512
|
+
expect(res.status).toBe(200);
|
|
513
|
+
expect(res.body.ok).toBe(false);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ================================================================
|
|
518
|
+
// GET /api/runs/:runId/log
|
|
519
|
+
// ================================================================
|
|
520
|
+
describe("GET /api/runs/:runId/log", () => {
|
|
521
|
+
it("不正な runId → 400", async () => {
|
|
522
|
+
const res = await request(app).get("/api/runs/bad-id/log");
|
|
523
|
+
expect(res.status).toBe(400);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("アクティブセッションがある → session 情報を返す", async () => {
|
|
527
|
+
(activeSessions as Map<string, unknown>).set("run_123", { lines: ["a", "b"], done: false, exitCode: null });
|
|
528
|
+
const res = await request(app).get("/api/runs/run_123/log");
|
|
529
|
+
expect(res.status).toBe(200);
|
|
530
|
+
expect(res.body.lines).toEqual(["a", "b"]);
|
|
531
|
+
expect(res.body.done).toBe(false);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("アクティブセッションなし + ログファイルあり → ファイルから読む", async () => {
|
|
535
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
536
|
+
vi.mocked(fs.readFileSync).mockReturnValue("line1\nline2\n" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
537
|
+
const res = await request(app).get("/api/runs/run_123/log");
|
|
538
|
+
expect(res.status).toBe(200);
|
|
539
|
+
expect(res.body.lines).toEqual(["line1", "line2"]);
|
|
540
|
+
expect(res.body.done).toBe(true);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("アクティブセッションもログファイルもない → 404", async () => {
|
|
544
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
545
|
+
const res = await request(app).get("/api/runs/run_123/log");
|
|
546
|
+
expect(res.status).toBe(404);
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// ================================================================
|
|
551
|
+
// SSE: /api/sessions/:sessionId/events, /api/runs/:runId/events
|
|
552
|
+
// ================================================================
|
|
553
|
+
describe("SSE events", () => {
|
|
554
|
+
it("/api/sessions/:sessionId/events 不正な sessionId → 400", async () => {
|
|
555
|
+
const res = await request(app).get("/api/sessions/bad-id/events");
|
|
556
|
+
expect(res.status).toBe(400);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("/api/runs/:runId/events 不正な runId → 400", async () => {
|
|
560
|
+
const res = await request(app).get("/api/runs/bad-id/events");
|
|
561
|
+
expect(res.status).toBe(400);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("/api/sessions/:sessionId/events セッションが存在しない → 404", async () => {
|
|
565
|
+
const res = await request(app).get("/api/sessions/run_404/events");
|
|
566
|
+
expect(res.status).toBe(404);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("/api/runs/:runId/events 完了済みセッション → イベントを送信して終了", async () => {
|
|
570
|
+
(activeSessions as Map<string, unknown>).set("run_555", {
|
|
571
|
+
lines: ["log line"],
|
|
572
|
+
done: true,
|
|
573
|
+
exitCode: 0,
|
|
574
|
+
listeners: [],
|
|
575
|
+
doneListeners: [],
|
|
576
|
+
});
|
|
577
|
+
const res = await request(app).get("/api/runs/run_555/events");
|
|
578
|
+
expect(res.status).toBe(200);
|
|
579
|
+
expect(res.text).toContain("log line");
|
|
580
|
+
expect(res.text).toContain("event: done");
|
|
581
|
+
});
|
|
582
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("fs");
|
|
4
|
+
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import { listRuns, getReportPath } from "../runs";
|
|
7
|
+
import type { RunLog, Finding } from "../../framework/types";
|
|
8
|
+
|
|
9
|
+
function mockRunLog(overrides: Partial<RunLog> = {}): RunLog {
|
|
10
|
+
return {
|
|
11
|
+
runId: "run_1",
|
|
12
|
+
startedAt: "2026-01-01T00:00:00.000Z",
|
|
13
|
+
completedAt: "2026-01-01T00:05:00.000Z",
|
|
14
|
+
repo: "test",
|
|
15
|
+
agents: [],
|
|
16
|
+
summary: {
|
|
17
|
+
totalAgents: 0,
|
|
18
|
+
completed: 0,
|
|
19
|
+
errors: 0,
|
|
20
|
+
iterationLimitReached: 0,
|
|
21
|
+
totalActions: 0,
|
|
22
|
+
totalIssuesPosted: 0,
|
|
23
|
+
regressionChecked: 0,
|
|
24
|
+
regressionFailed: 0,
|
|
25
|
+
rateLimitRetries: 0,
|
|
26
|
+
cost: { inputTokens: 0, outputTokens: 0, estimatedUSD: null },
|
|
27
|
+
},
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mockFinding(overrides: Partial<Finding> = {}): Finding {
|
|
33
|
+
return {
|
|
34
|
+
id: "f1",
|
|
35
|
+
runId: "run_1",
|
|
36
|
+
agentId: "a1",
|
|
37
|
+
agentName: "Alice",
|
|
38
|
+
role: "tester",
|
|
39
|
+
title: "title",
|
|
40
|
+
body: "body",
|
|
41
|
+
category: "bug",
|
|
42
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
49
|
+
vi.mocked(fs.readdirSync).mockReturnValue([] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
50
|
+
vi.mocked(fs.readFileSync).mockReturnValue("{}" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("listRuns", () => {
|
|
54
|
+
it("logs ディレクトリがない → 空配列", () => {
|
|
55
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
56
|
+
expect(listRuns()).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("running_*.json から実行中の run を isLive:true で返す", () => {
|
|
60
|
+
vi.mocked(fs.existsSync).mockImplementation((p: unknown) => String(p).endsWith("logs"));
|
|
61
|
+
vi.mocked(fs.readdirSync).mockReturnValue(["running_run_live.json"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
62
|
+
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
63
|
+
JSON.stringify({ runId: "run_live", startedAt: "2026-06-01T00:00:00.000Z" }) as unknown as ReturnType<typeof fs.readFileSync>
|
|
64
|
+
);
|
|
65
|
+
const result = listRuns();
|
|
66
|
+
expect(result).toHaveLength(1);
|
|
67
|
+
expect(result[0]).toMatchObject({ runId: "run_live", status: "running", isLive: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("report_ プレフィックスのファイルは run ログとして扱わない", () => {
|
|
71
|
+
vi.mocked(fs.existsSync).mockImplementation((p: unknown) => String(p).endsWith("logs"));
|
|
72
|
+
vi.mocked(fs.readdirSync).mockReturnValue(["report_run_1.html"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
73
|
+
expect(listRuns()).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("通常の run ログを完了状態のサマリーに変換する", () => {
|
|
77
|
+
const log = mockRunLog({
|
|
78
|
+
runId: "run_done",
|
|
79
|
+
agents: [
|
|
80
|
+
{ agentType: "explorer", agentId: "a1", agentName: "A", role: "r", startedAt: "", completedAt: null, status: "completed", iterations: 1, actions: [], visitedPaths: [], issuesPosted: [], regressionChecks: [], error: null },
|
|
81
|
+
{ agentType: "explorer", agentId: "a2", agentName: "B", role: "r", startedAt: "", completedAt: null, status: "error", iterations: 1, actions: [], visitedPaths: [], issuesPosted: [], regressionChecks: [], error: "x" },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
vi.mocked(fs.existsSync).mockImplementation((p: unknown) => String(p).endsWith("logs"));
|
|
85
|
+
vi.mocked(fs.readdirSync).mockReturnValue(["2026-01-01T00-00-00_run_done.json"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
86
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(log) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
87
|
+
|
|
88
|
+
const result = listRuns();
|
|
89
|
+
expect(result).toHaveLength(1);
|
|
90
|
+
expect(result[0]).toMatchObject({
|
|
91
|
+
runId: "run_done",
|
|
92
|
+
status: "completed",
|
|
93
|
+
agentCount: 2,
|
|
94
|
+
completedAgents: 1,
|
|
95
|
+
errorAgents: 1,
|
|
96
|
+
hasReport: false,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("findings/run_id/ 内の finding を category 別に集計する(triage_result.json は除外)", () => {
|
|
101
|
+
const log = mockRunLog({ runId: "run_with_findings" });
|
|
102
|
+
vi.mocked(fs.existsSync).mockImplementation((p: unknown) => {
|
|
103
|
+
const s = String(p);
|
|
104
|
+
return s.endsWith("logs") || s.endsWith("findings/run_with_findings") || s.includes("findings") && s.endsWith("run_with_findings");
|
|
105
|
+
});
|
|
106
|
+
vi.mocked(fs.readdirSync).mockImplementation((p: unknown) => {
|
|
107
|
+
const s = String(p);
|
|
108
|
+
if (s.endsWith("logs")) return ["2026-01-01T00-00-00_run_with_findings.json"] as unknown as ReturnType<typeof fs.readdirSync>;
|
|
109
|
+
return ["f0.json", "f1.json", "triage_result.json"] as unknown as ReturnType<typeof fs.readdirSync>;
|
|
110
|
+
});
|
|
111
|
+
vi.mocked(fs.readFileSync).mockImplementation((p: unknown) => {
|
|
112
|
+
const s = String(p);
|
|
113
|
+
if (s.endsWith("f0.json")) return JSON.stringify(mockFinding({ id: "f0", category: "bug" })) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
114
|
+
if (s.endsWith("f1.json")) return JSON.stringify(mockFinding({ id: "f1", category: "ux" })) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
115
|
+
if (s.endsWith("triage_result.json")) {
|
|
116
|
+
return JSON.stringify({ runId: "run_with_findings", completedAt: "x", issued: [], skipped: [], unprocessed: [] }) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
117
|
+
}
|
|
118
|
+
return JSON.stringify(log) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const result = listRuns();
|
|
122
|
+
expect(result).toHaveLength(1);
|
|
123
|
+
expect(result[0].findingCount).toBe(2);
|
|
124
|
+
expect(result[0].findingsByCategory).toEqual({ bug: 1, ux: 1 });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("同じ runId が複数ファイルに登場しても重複しない", () => {
|
|
128
|
+
vi.mocked(fs.existsSync).mockImplementation((p: unknown) => String(p).endsWith("logs"));
|
|
129
|
+
vi.mocked(fs.readdirSync).mockReturnValue([
|
|
130
|
+
"running_run_dup.json",
|
|
131
|
+
"2026-01-01T00-00-00_run_dup.json",
|
|
132
|
+
] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
133
|
+
vi.mocked(fs.readFileSync).mockImplementation((p: unknown) => {
|
|
134
|
+
const s = String(p);
|
|
135
|
+
if (s.includes("running_run_dup")) {
|
|
136
|
+
return JSON.stringify({ runId: "run_dup", startedAt: "2026-01-01T00:00:00.000Z" }) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
137
|
+
}
|
|
138
|
+
return JSON.stringify(mockRunLog({ runId: "run_dup" })) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const result = listRuns();
|
|
142
|
+
expect(result).toHaveLength(1);
|
|
143
|
+
expect(result[0].isLive).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("startedAt の降順でソートされる", () => {
|
|
147
|
+
const older = mockRunLog({ runId: "run_old", startedAt: "2026-01-01T00:00:00.000Z" });
|
|
148
|
+
const newer = mockRunLog({ runId: "run_new", startedAt: "2026-06-01T00:00:00.000Z" });
|
|
149
|
+
vi.mocked(fs.existsSync).mockImplementation((p: unknown) => String(p).endsWith("logs"));
|
|
150
|
+
vi.mocked(fs.readdirSync).mockReturnValue([
|
|
151
|
+
"2026-01-01T00-00-00_run_old.json",
|
|
152
|
+
"2026-06-01T00-00-00_run_new.json",
|
|
153
|
+
] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
154
|
+
vi.mocked(fs.readFileSync).mockImplementation((p: unknown) => {
|
|
155
|
+
const s = String(p);
|
|
156
|
+
if (s.includes("run_old")) return JSON.stringify(older) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
157
|
+
return JSON.stringify(newer) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = listRuns();
|
|
161
|
+
expect(result.map((r) => r.runId)).toEqual(["run_new", "run_old"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("壊れた JSON の run ログはスキップする", () => {
|
|
165
|
+
vi.mocked(fs.existsSync).mockImplementation((p: unknown) => String(p).endsWith("logs"));
|
|
166
|
+
vi.mocked(fs.readdirSync).mockReturnValue(["2026-01-01T00-00-00_run_broken.json"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
167
|
+
vi.mocked(fs.readFileSync).mockReturnValue("not json" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
168
|
+
expect(listRuns()).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("getReportPath", () => {
|
|
173
|
+
it("不正な runId → null", () => {
|
|
174
|
+
expect(getReportPath("bad-id")).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("report ファイルが存在しない → null", () => {
|
|
178
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
179
|
+
expect(getReportPath("run_123")).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("report ファイルが存在する → パスを返す", () => {
|
|
183
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
184
|
+
expect(getReportPath("run_123")).toMatch(/report_run_123\.html$/);
|
|
185
|
+
});
|
|
186
|
+
});
|
package/server/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { listRuns, getReportPath } from "./runs.js";
|
|
|
8
8
|
import { activeSessions, spawnRun, cancelSession } from "./runner.js";
|
|
9
9
|
import { loadSchedule, saveSchedule, startScheduler, type ScheduleConfig } from "./scheduler.js";
|
|
10
10
|
import { generateDiary, getDiaryPath } from "../framework/diary.js";
|
|
11
|
+
import { findCrossRunDuplicates } from "../framework/cross-run-dedup.js";
|
|
11
12
|
import type { Finding } from "../framework/types.js";
|
|
12
13
|
|
|
13
14
|
function specFilePath(baseUrl: string): string {
|
|
@@ -141,9 +142,10 @@ function loadAllFindings(): (Finding & { runId: string })[] {
|
|
|
141
142
|
const dir = join(base, runDir);
|
|
142
143
|
try {
|
|
143
144
|
for (const file of readdirSync(dir)) {
|
|
144
|
-
if (!file.endsWith(".json")) continue;
|
|
145
|
+
if (!file.endsWith(".json") || file === "triage_result.json") continue;
|
|
145
146
|
try {
|
|
146
147
|
const f: Finding = JSON.parse(readFileSync(join(dir, file), "utf-8"));
|
|
148
|
+
if (typeof f.timestamp !== "string") continue;
|
|
147
149
|
all.push({ ...f, runId: runDir });
|
|
148
150
|
} catch { /* skip */ }
|
|
149
151
|
}
|
|
@@ -156,6 +158,10 @@ app.get("/api/findings", (_req, res) => {
|
|
|
156
158
|
res.json(loadAllFindings());
|
|
157
159
|
});
|
|
158
160
|
|
|
161
|
+
app.get("/api/findings/cross-run-duplicates", (_req, res) => {
|
|
162
|
+
res.json(findCrossRunDuplicates(loadAllFindings()));
|
|
163
|
+
});
|
|
164
|
+
|
|
159
165
|
app.get("/api/findings/export", (_req, res) => {
|
|
160
166
|
const findings = loadAllFindings().map(({ id, title, body, category, agentName, role, timestamp, runId }) => ({
|
|
161
167
|
id, title, body, category, agentName, role, timestamp, runId,
|
|
@@ -321,6 +327,27 @@ app.get("/api/runs/:runId/log", (req, res) => {
|
|
|
321
327
|
res.status(404).json({ error: "no log found" });
|
|
322
328
|
});
|
|
323
329
|
|
|
330
|
+
// ----------------------------------------------------------------
|
|
331
|
+
// API: schedule config
|
|
332
|
+
// ----------------------------------------------------------------
|
|
333
|
+
app.get("/api/schedule", (_req, res) => {
|
|
334
|
+
res.json(loadSchedule());
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
app.patch("/api/schedule", (req, res) => {
|
|
338
|
+
const current = loadSchedule();
|
|
339
|
+
const { enabled, dayOfWeek, hour, minute } = req.body as Partial<ScheduleConfig>;
|
|
340
|
+
const updated: ScheduleConfig = {
|
|
341
|
+
...current,
|
|
342
|
+
...(enabled != null ? { enabled: Boolean(enabled) } : {}),
|
|
343
|
+
...(dayOfWeek != null && Number.isInteger(dayOfWeek) && dayOfWeek >= 0 && dayOfWeek <= 6 ? { dayOfWeek } : {}),
|
|
344
|
+
...(hour != null && Number.isInteger(hour) && hour >= 0 && hour <= 23 ? { hour } : {}),
|
|
345
|
+
...(minute != null && Number.isInteger(minute) && minute >= 0 && minute <= 59 ? { minute } : {}),
|
|
346
|
+
};
|
|
347
|
+
saveSchedule(updated);
|
|
348
|
+
res.json(updated);
|
|
349
|
+
});
|
|
350
|
+
|
|
324
351
|
// ----------------------------------------------------------------
|
|
325
352
|
// Static: serve built React app
|
|
326
353
|
// ----------------------------------------------------------------
|
|
@@ -352,27 +379,6 @@ process.on("unhandledRejection", (reason) => {
|
|
|
352
379
|
console.error("[server] unhandledRejection:", reason);
|
|
353
380
|
});
|
|
354
381
|
|
|
355
|
-
// ----------------------------------------------------------------
|
|
356
|
-
// API: schedule config
|
|
357
|
-
// ----------------------------------------------------------------
|
|
358
|
-
app.get("/api/schedule", (_req, res) => {
|
|
359
|
-
res.json(loadSchedule());
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
app.patch("/api/schedule", (req, res) => {
|
|
363
|
-
const current = loadSchedule();
|
|
364
|
-
const { enabled, dayOfWeek, hour, minute } = req.body as Partial<ScheduleConfig>;
|
|
365
|
-
const updated: ScheduleConfig = {
|
|
366
|
-
...current,
|
|
367
|
-
...(enabled != null ? { enabled: Boolean(enabled) } : {}),
|
|
368
|
-
...(dayOfWeek != null && Number.isInteger(dayOfWeek) && dayOfWeek >= 0 && dayOfWeek <= 6 ? { dayOfWeek } : {}),
|
|
369
|
-
...(hour != null && Number.isInteger(hour) && hour >= 0 && hour <= 23 ? { hour } : {}),
|
|
370
|
-
...(minute != null && Number.isInteger(minute) && minute >= 0 && minute <= 59 ? { minute } : {}),
|
|
371
|
-
};
|
|
372
|
-
saveSchedule(updated);
|
|
373
|
-
res.json(updated);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
382
|
export { app };
|
|
377
383
|
|
|
378
384
|
if (process.env.NODE_ENV !== "test") {
|
package/server/runs.ts
CHANGED
|
@@ -25,9 +25,10 @@ function countFindings(runId: string): { total: number; byCategory: Record<strin
|
|
|
25
25
|
const byCategory: Record<string, number> = {};
|
|
26
26
|
let total = 0;
|
|
27
27
|
for (const file of fs.readdirSync(dir)) {
|
|
28
|
-
if (!file.endsWith(".json")) continue;
|
|
28
|
+
if (!file.endsWith(".json") || file === "triage_result.json") continue;
|
|
29
29
|
try {
|
|
30
30
|
const f: Finding = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
|
|
31
|
+
if (typeof f.category !== "string") continue;
|
|
31
32
|
byCategory[f.category] = (byCategory[f.category] ?? 0) + 1;
|
|
32
33
|
total++;
|
|
33
34
|
} catch {
|