@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.
@@ -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 に固定(UTC = ローカルとして扱う)
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: monday9am.getDay(),
93
- hour: monday9am.getHours(),
94
- minute: monday9am.getMinutes(),
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 today = monday9am.toISOString().slice(0, 10);
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: monday9am.getDay(),
119
- hour: monday9am.getHours(),
120
- minute: monday9am.getMinutes(),
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
- if (h === "localhost" || h === "127.0.0.1" || h === "::1" || h.startsWith("192.168.") || h.startsWith("10.") || h.endsWith(".local")) {
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
- app.patch("/api/schedule", (req, res) => {
362
- const current = loadSchedule();
363
- const { enabled, dayOfWeek, hour, minute } = req.body as Partial<ScheduleConfig>;
364
- const updated: ScheduleConfig = {
365
- ...current,
366
- ...(enabled != null ? { enabled: Boolean(enabled) } : {}),
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 {