@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
|
@@ -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
|
+
});
|
|
@@ -83,15 +83,17 @@ describe("scheduler — 時刻判定ロジック", () => {
|
|
|
83
83
|
it("スケジュール時刻に一致したとき spawnRun を呼ぶ", async () => {
|
|
84
84
|
vi.useFakeTimers();
|
|
85
85
|
|
|
86
|
-
// 月曜 09:00
|
|
86
|
+
// 月曜 09:00 に固定。scheduler は new Date().getDay() などローカル時刻を使うため、
|
|
87
|
+
// テスト設定も同じメソッドで一致させる(環境のタイムゾーンに依存するが両者が整合する)
|
|
87
88
|
const monday9am = new Date("2026-05-11T09:00:00.000Z");
|
|
88
89
|
vi.setSystemTime(monday9am);
|
|
90
|
+
const now = new Date();
|
|
89
91
|
|
|
90
92
|
const config: ScheduleConfig = {
|
|
91
93
|
enabled: true,
|
|
92
|
-
dayOfWeek:
|
|
93
|
-
hour:
|
|
94
|
-
minute:
|
|
94
|
+
dayOfWeek: now.getDay(),
|
|
95
|
+
hour: now.getHours(),
|
|
96
|
+
minute: now.getMinutes(),
|
|
95
97
|
lastRunDate: null,
|
|
96
98
|
};
|
|
97
99
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
@@ -111,13 +113,14 @@ describe("scheduler — 時刻判定ロジック", () => {
|
|
|
111
113
|
|
|
112
114
|
const monday9am = new Date("2026-05-11T09:00:00.000Z");
|
|
113
115
|
vi.setSystemTime(monday9am);
|
|
114
|
-
const
|
|
116
|
+
const now = new Date();
|
|
117
|
+
const today = now.toISOString().slice(0, 10); // scheduler と同じ UTC 日付を使う
|
|
115
118
|
|
|
116
119
|
const config: ScheduleConfig = {
|
|
117
120
|
enabled: true,
|
|
118
|
-
dayOfWeek:
|
|
119
|
-
hour:
|
|
120
|
-
minute:
|
|
121
|
+
dayOfWeek: now.getDay(),
|
|
122
|
+
hour: now.getHours(),
|
|
123
|
+
minute: now.getMinutes(),
|
|
121
124
|
lastRunDate: today,
|
|
122
125
|
};
|
|
123
126
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
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,
|
|
@@ -174,7 +180,8 @@ app.post("/api/findings/proxy-url", async (req, res) => {
|
|
|
174
180
|
parsed = new URL(url);
|
|
175
181
|
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("invalid protocol");
|
|
176
182
|
const h = parsed.hostname;
|
|
177
|
-
|
|
183
|
+
const bare = h.replace(/^\[|\]$/g, ""); // IPv6 brackets: [::1] → ::1
|
|
184
|
+
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1" || bare.startsWith("192.168.") || bare.startsWith("10.") || bare.endsWith(".local")) {
|
|
178
185
|
res.status(400).json({ error: "private urls not allowed" });
|
|
179
186
|
return;
|
|
180
187
|
}
|
|
@@ -320,6 +327,27 @@ app.get("/api/runs/:runId/log", (req, res) => {
|
|
|
320
327
|
res.status(404).json({ error: "no log found" });
|
|
321
328
|
});
|
|
322
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
|
+
|
|
323
351
|
// ----------------------------------------------------------------
|
|
324
352
|
// Static: serve built React app
|
|
325
353
|
// ----------------------------------------------------------------
|
|
@@ -351,28 +379,11 @@ process.on("unhandledRejection", (reason) => {
|
|
|
351
379
|
console.error("[server] unhandledRejection:", reason);
|
|
352
380
|
});
|
|
353
381
|
|
|
354
|
-
|
|
355
|
-
// API: schedule config
|
|
356
|
-
// ----------------------------------------------------------------
|
|
357
|
-
app.get("/api/schedule", (_req, res) => {
|
|
358
|
-
res.json(loadSchedule());
|
|
359
|
-
});
|
|
382
|
+
export { app };
|
|
360
383
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
...(dayOfWeek != null && Number.isInteger(dayOfWeek) && dayOfWeek >= 0 && dayOfWeek <= 6 ? { dayOfWeek } : {}),
|
|
368
|
-
...(hour != null && Number.isInteger(hour) && hour >= 0 && hour <= 23 ? { hour } : {}),
|
|
369
|
-
...(minute != null && Number.isInteger(minute) && minute >= 0 && minute <= 59 ? { minute } : {}),
|
|
370
|
-
};
|
|
371
|
-
saveSchedule(updated);
|
|
372
|
-
res.json(updated);
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
app.listen(PORT, () => {
|
|
376
|
-
console.log(`\nshoal dashboard → http://localhost:${PORT}\n`);
|
|
377
|
-
startScheduler();
|
|
378
|
-
});
|
|
384
|
+
if (process.env.NODE_ENV !== "test") {
|
|
385
|
+
app.listen(PORT, () => {
|
|
386
|
+
console.log(`\nshoal dashboard → http://localhost:${PORT}\n`);
|
|
387
|
+
startScheduler();
|
|
388
|
+
});
|
|
389
|
+
}
|
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 {
|