@m8i-51/shoal 0.1.18 → 0.1.20

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/README.md CHANGED
@@ -114,12 +114,26 @@ npm run serve # from cloned repo
114
114
  Opens at `http://localhost:4000`. From there you can:
115
115
 
116
116
  - **Start a run** — configure agent count, target URL, and custom instructions
117
- - **Monitor live progress** — watch agents explore and file findings in real time
117
+ - **Watch agents swim live** — the Swarm tab shows an animated real-time view of agents as they explore. When a finding is discovered, the agent's chip flashes with the finding title.
118
118
  - **Review past runs** — findings by category, agent count, duration, and estimated cost
119
+ - **Generate an Agent Diary** — after a run completes, one LLM call turns the raw log into a story-style narrative of the exploration, readable by anyone on the team
120
+ - **Hall of Issues** — browse all findings across every run with full-text search and category filter. Export as JSON to share, or paste a community findings URL to import findings from other projects.
119
121
  - **Edit app goals** — guide the goal-gap detector by defining what the app should achieve
120
122
 
121
123
  ---
122
124
 
125
+ ## Cross-run intelligence
126
+
127
+ shoal gets smarter with each run.
128
+
129
+ **Diff exploration** — after every browser navigation, shoal hashes the page content (SHA-256 of `innerText`). On the next run, agents that land on an unchanged page are nudged to move on: *"page content unchanged since last run — consider exploring a different area."* The hashes accumulate in `cache/page-hashes/` and steer future agents toward parts of the app that have actually changed.
130
+
131
+ **Finding hotspots** — the persona designer has access to a `get_finding_hotspots` tool that aggregates findings by URL area across all past runs. It uses this to recruit agents toward under-investigated parts of the app, or to send specialists into zones where problems keep clustering.
132
+
133
+ Both signals work passively — no configuration needed. They improve automatically as runs accumulate.
134
+
135
+ ---
136
+
123
137
  ## Configuration
124
138
 
125
139
  | Variable | Default | Description |
@@ -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.5 = 1.5
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
- const entries = Array.from({ length: 35 }, (_, i) =>
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
- // computeWeightedSummary entries をそのまま使うだけなので、
266
- // 35件あってもエラーにならないことを確認
267
- expect(() => computeWeightedSummary()).not.toThrow();
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,112 @@ 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
+ });
@@ -0,0 +1,139 @@
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("ログが空の場合はプロンプトに「イベントログなし」を含める", async () => {
94
+ await generateDiary("run_123", []);
95
+ const [params] = mockCreateMessage.mock.calls[0];
96
+ expect(params.messages[0].content).toContain("イベントログなし");
97
+ });
98
+
99
+ it("navigate 行は最大 25 件まで抽出する", async () => {
100
+ const lines = Array.from({ length: 30 }, (_, i) => ` → navigate({"path":"/page${i}"})`);
101
+ await generateDiary("run_123", lines);
102
+ const [params] = mockCreateMessage.mock.calls[0];
103
+ const content: string = params.messages[0].content;
104
+ const navCount = (content.match(/navigate/g) ?? []).length;
105
+ expect(navCount).toBeLessThanOrEqual(25);
106
+ });
107
+
108
+ it("agent start/done 行はすべて抽出する", async () => {
109
+ const lines = [
110
+ "[explorer] Alice start",
111
+ "[browser] Bob start",
112
+ "[explorer] Alice done",
113
+ ];
114
+ await generateDiary("run_123", lines);
115
+ const [params] = mockCreateMessage.mock.calls[0];
116
+ const content: string = params.messages[0].content;
117
+ expect(content).toContain("[explorer] Alice start");
118
+ expect(content).toContain("[browser] Bob start");
119
+ expect(content).toContain("[explorer] Alice done");
120
+ });
121
+
122
+ it("finding 発見ログは抽出される", async () => {
123
+ const lines = [' → [findings] saved: "Login page crashes" (bug)'];
124
+ await generateDiary("run_123", lines);
125
+ const [params] = mockCreateMessage.mock.calls[0];
126
+ expect(params.messages[0].content).toContain("[findings] saved");
127
+ });
128
+
129
+ it("navigate 行が 100 文字超えると切り詰められる", async () => {
130
+ const longPath = "/very/long/path/" + "a".repeat(200);
131
+ const lines = [` → navigate({"path":"${longPath}"})`];
132
+ await generateDiary("run_123", lines);
133
+ const [params] = mockCreateMessage.mock.calls[0];
134
+ const content: string = params.messages[0].content;
135
+ const lines2 = content.split("\n");
136
+ const navLine = lines2.find((l) => l.includes("navigate"));
137
+ expect(navLine!.length).toBeLessThanOrEqual(103); // 100 + "…"
138
+ });
139
+ });
@@ -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("&lt;script&gt;");
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("&lt;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 };
@@ -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(/\b(\/[a-zA-Z0-9_/-]{2,})/);
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]}` : "/";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m8i-51/shoal",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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": {
@@ -50,6 +50,7 @@
50
50
  "@types/node": "^20",
51
51
  "@types/react": "^19.2.14",
52
52
  "@types/react-dom": "^19.2.3",
53
+ "@types/supertest": "^7.2.0",
53
54
  "@vitejs/plugin-react": "^4.7.0",
54
55
  "@vitest/coverage-v8": "^4.1.6",
55
56
  "concurrently": "^10.0.3",
@@ -58,6 +59,7 @@
58
59
  "react-dom": "^19.2.5",
59
60
  "react-i18next": "^17.0.4",
60
61
  "react-router-dom": "^7.14.1",
62
+ "supertest": "^7.2.2",
61
63
  "typescript": "^5",
62
64
  "vite": "^6.4.2",
63
65
  "vitest": "^4.1.5"
@@ -0,0 +1,307 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import request from "supertest";
3
+
4
+ // ---- モック ----
5
+ vi.mock("fs");
6
+ vi.mock("path", async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import("path")>();
8
+ return { ...actual, join: (...args: string[]) => args.join("/"), resolve: (...args: string[]) => args.join("/"), dirname: (p: string) => p };
9
+ });
10
+ vi.mock("../runner.js", () => ({ activeSessions: new Map(), spawnRun: vi.fn(), cancelSession: vi.fn() }));
11
+ vi.mock("../runs.js", () => ({ listRuns: vi.fn(() => []), getReportPath: vi.fn(() => null) }));
12
+ vi.mock("../scheduler.js", () => ({ loadSchedule: vi.fn(() => ({ enabled: false, dayOfWeek: 1, hour: 9, minute: 0, lastRunDate: null })), saveSchedule: vi.fn(), startScheduler: vi.fn() }));
13
+ vi.mock("../../framework/diary.js", () => ({ generateDiary: vi.fn(), getDiaryPath: vi.fn(() => null) }));
14
+ vi.mock("express-rate-limit", () => ({ rateLimit: () => (_req: unknown, _res: unknown, next: () => void) => next() }));
15
+
16
+ import * as fs from "fs";
17
+ import { generateDiary, getDiaryPath } from "../../framework/diary.js";
18
+ import { activeSessions } from "../runner.js";
19
+
20
+ // NODE_ENV=test なので app.listen は呼ばれない
21
+ const { app } = await import("../index.js");
22
+
23
+ // ----------------------------------------------------------------
24
+ // テスト用ヘルパー
25
+ // ----------------------------------------------------------------
26
+
27
+ function mockFinding(overrides = {}) {
28
+ return {
29
+ id: "f1",
30
+ runId: "run_1",
31
+ agentId: "a1",
32
+ agentName: "Alice",
33
+ role: "tester",
34
+ title: "Test finding",
35
+ body: "Something broke",
36
+ category: "bug",
37
+ timestamp: new Date().toISOString(),
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ function setupFindingsDir(runDirs: Record<string, object[]>) {
43
+ vi.mocked(fs.existsSync).mockImplementation((p: unknown) => {
44
+ const path = String(p);
45
+ return path.includes("findings") || Object.keys(runDirs).some((r) => path.includes(r));
46
+ });
47
+ vi.mocked(fs.readdirSync).mockImplementation((p: unknown) => {
48
+ const path = String(p);
49
+ const runId = Object.keys(runDirs).find((r) => path.endsWith(r));
50
+ if (runId) {
51
+ return runDirs[runId].map((_, i) => `f${i}.json`) as unknown as ReturnType<typeof fs.readdirSync>;
52
+ }
53
+ // findings ベースディレクトリ
54
+ return Object.keys(runDirs) as unknown as ReturnType<typeof fs.readdirSync>;
55
+ });
56
+ vi.mocked(fs.readFileSync).mockImplementation((p: unknown) => {
57
+ const path = String(p);
58
+ for (const [runId, items] of Object.entries(runDirs)) {
59
+ const idx = items.findIndex((_, i) => path.endsWith(`f${i}.json`));
60
+ if (idx >= 0 && path.includes(runId)) {
61
+ return JSON.stringify(items[idx]) as unknown as ReturnType<typeof fs.readFileSync>;
62
+ }
63
+ }
64
+ return "{}" as unknown as ReturnType<typeof fs.readFileSync>;
65
+ });
66
+ }
67
+
68
+ beforeEach(() => {
69
+ vi.mocked(fs.existsSync).mockReturnValue(false);
70
+ vi.mocked(fs.readdirSync).mockReturnValue([] as unknown as ReturnType<typeof fs.readdirSync>);
71
+ vi.mocked(fs.readFileSync).mockReturnValue("{}" as unknown as ReturnType<typeof fs.readFileSync>);
72
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
73
+ vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
74
+ vi.mocked(getDiaryPath).mockReturnValue(null);
75
+ vi.mocked(generateDiary).mockResolvedValue("# 探索日誌");
76
+ (activeSessions as Map<string, unknown>).clear();
77
+ });
78
+
79
+ // ================================================================
80
+ // GET /api/runs/:runId/diary
81
+ // ================================================================
82
+ describe("GET /api/runs/:runId/diary", () => {
83
+ it("不正な runId → 400", async () => {
84
+ const res = await request(app).get("/api/runs/run_abc/diary");
85
+ expect(res.status).toBe(400);
86
+ });
87
+
88
+ it("diary ファイルが存在しない → 404", async () => {
89
+ vi.mocked(getDiaryPath).mockReturnValue(null);
90
+ const res = await request(app).get("/api/runs/run_123/diary");
91
+ expect(res.status).toBe(404);
92
+ });
93
+
94
+ it("diary ファイルが存在する → 200 + content", async () => {
95
+ vi.mocked(getDiaryPath).mockReturnValue("/some/path/diary_run_123.md");
96
+ vi.mocked(fs.readFileSync).mockReturnValue("# 日誌" as unknown as ReturnType<typeof fs.readFileSync>);
97
+ const res = await request(app).get("/api/runs/run_123/diary");
98
+ expect(res.status).toBe(200);
99
+ expect(res.body.content).toBe("# 日誌");
100
+ });
101
+ });
102
+
103
+ // ================================================================
104
+ // POST /api/runs/:runId/diary
105
+ // ================================================================
106
+ describe("POST /api/runs/:runId/diary", () => {
107
+ it("不正な runId → 400", async () => {
108
+ const res = await request(app).post("/api/runs/invalid/diary");
109
+ expect(res.status).toBe(400);
110
+ });
111
+
112
+ it("アクティブセッションがない + ログファイルなし → 404", async () => {
113
+ vi.mocked(fs.existsSync).mockReturnValue(false);
114
+ const res = await request(app).post("/api/runs/run_123/diary");
115
+ expect(res.status).toBe(404);
116
+ });
117
+
118
+ it("ログファイルがある → generateDiary を呼んで content を返す", async () => {
119
+ vi.mocked(fs.existsSync).mockReturnValue(true);
120
+ vi.mocked(fs.readFileSync).mockReturnValue("line1\nline2\n" as unknown as ReturnType<typeof fs.readFileSync>);
121
+ vi.mocked(generateDiary).mockResolvedValue("# 探索日誌");
122
+ const res = await request(app).post("/api/runs/run_123/diary");
123
+ expect(res.status).toBe(200);
124
+ expect(res.body.content).toBe("# 探索日誌");
125
+ expect(generateDiary).toHaveBeenCalledWith("run_123", ["line1", "line2"]);
126
+ });
127
+
128
+ it("アクティブセッションがある → session.lines を使う", async () => {
129
+ (activeSessions as Map<string, unknown>).set("run_123", { lines: ["live line"], done: false });
130
+ vi.mocked(generateDiary).mockResolvedValue("# ライブ日誌");
131
+ const res = await request(app).post("/api/runs/run_123/diary");
132
+ expect(res.status).toBe(200);
133
+ expect(generateDiary).toHaveBeenCalledWith("run_123", ["live line"]);
134
+ });
135
+
136
+ it("generateDiary が失敗 → 500", async () => {
137
+ vi.mocked(fs.existsSync).mockReturnValue(true);
138
+ vi.mocked(fs.readFileSync).mockReturnValue("line\n" as unknown as ReturnType<typeof fs.readFileSync>);
139
+ vi.mocked(generateDiary).mockRejectedValue(new Error("LLM error"));
140
+ const res = await request(app).post("/api/runs/run_123/diary");
141
+ expect(res.status).toBe(500);
142
+ });
143
+ });
144
+
145
+ // ================================================================
146
+ // GET /api/findings
147
+ // ================================================================
148
+ describe("GET /api/findings", () => {
149
+ it("findings ディレクトリがない → 空配列", async () => {
150
+ vi.mocked(fs.existsSync).mockReturnValue(false);
151
+ const res = await request(app).get("/api/findings");
152
+ expect(res.status).toBe(200);
153
+ expect(res.body).toEqual([]);
154
+ });
155
+
156
+ it("findings を timestamp 降順で返す", async () => {
157
+ const older = mockFinding({ id: "f1", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" });
158
+ const newer = mockFinding({ id: "f2", runId: "run_2", timestamp: "2026-06-01T00:00:00.000Z" });
159
+ setupFindingsDir({ run_1: [older], run_2: [newer] });
160
+ const res = await request(app).get("/api/findings");
161
+ expect(res.status).toBe(200);
162
+ expect(res.body[0].timestamp).toBe("2026-06-01T00:00:00.000Z");
163
+ expect(res.body[1].timestamp).toBe("2026-01-01T00:00:00.000Z");
164
+ });
165
+
166
+ it("run_\\d+ 以外のディレクトリは無視する", async () => {
167
+ vi.mocked(fs.existsSync).mockReturnValue(true);
168
+ vi.mocked(fs.readdirSync).mockReturnValue([".DS_Store", "tmp"] as unknown as ReturnType<typeof fs.readdirSync>);
169
+ const res = await request(app).get("/api/findings");
170
+ expect(res.body).toEqual([]);
171
+ });
172
+ });
173
+
174
+ // ================================================================
175
+ // GET /api/findings/export
176
+ // ================================================================
177
+ describe("GET /api/findings/export", () => {
178
+ it("正しい Content-Disposition ヘッダーを返す", async () => {
179
+ vi.mocked(fs.existsSync).mockReturnValue(false);
180
+ const res = await request(app).get("/api/findings/export");
181
+ expect(res.status).toBe(200);
182
+ expect(res.headers["content-disposition"]).toContain("attachment");
183
+ expect(res.headers["content-disposition"]).toContain(".json");
184
+ });
185
+
186
+ it("レスポンスに version / exportedAt / source / findings が含まれる", async () => {
187
+ vi.mocked(fs.existsSync).mockReturnValue(false);
188
+ const res = await request(app).get("/api/findings/export");
189
+ expect(res.body.version).toBe("1");
190
+ expect(res.body.source).toBe("shoal");
191
+ expect(typeof res.body.exportedAt).toBe("string");
192
+ expect(Array.isArray(res.body.findings)).toBe(true);
193
+ });
194
+
195
+ it("findings の screenshotPath は除外される", async () => {
196
+ const f = mockFinding({ screenshotPath: "/secret/path.png" });
197
+ setupFindingsDir({ run_1: [f] });
198
+ const res = await request(app).get("/api/findings/export");
199
+ expect(res.body.findings[0]).not.toHaveProperty("screenshotPath");
200
+ });
201
+ });
202
+
203
+ // ================================================================
204
+ // POST /api/findings/proxy-url — SSRF 防御テスト
205
+ // ================================================================
206
+ describe("POST /api/findings/proxy-url", () => {
207
+ it("url パラメータなし → 400", async () => {
208
+ const res = await request(app).post("/api/findings/proxy-url").send({});
209
+ expect(res.status).toBe(400);
210
+ });
211
+
212
+ it("http / https 以外のプロトコル → 400", async () => {
213
+ const cases = ["file:///etc/passwd", "javascript:alert(1)", "ftp://example.com"];
214
+ for (const url of cases) {
215
+ const res = await request(app).post("/api/findings/proxy-url").send({ url });
216
+ expect(res.status).toBe(400);
217
+ }
218
+ });
219
+
220
+ it("localhost → 400(SSRF 防御)", async () => {
221
+ const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://localhost/data.json" });
222
+ expect(res.status).toBe(400);
223
+ });
224
+
225
+ it("127.0.0.1 → 400(SSRF 防御)", async () => {
226
+ const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://127.0.0.1/data.json" });
227
+ expect(res.status).toBe(400);
228
+ });
229
+
230
+ it("::1(IPv6 localhost)→ 400(SSRF 防御)", async () => {
231
+ const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://[::1]/data.json" });
232
+ expect(res.status).toBe(400);
233
+ });
234
+
235
+ it("192.168.x.x → 400(SSRF 防御)", async () => {
236
+ const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://192.168.1.1/data.json" });
237
+ expect(res.status).toBe(400);
238
+ });
239
+
240
+ it("10.x.x.x → 400(SSRF 防御)", async () => {
241
+ const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://10.0.0.1/data.json" });
242
+ expect(res.status).toBe(400);
243
+ });
244
+
245
+ it(".local ドメイン → 400(SSRF 防御)", async () => {
246
+ const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://myserver.local/data.json" });
247
+ expect(res.status).toBe(400);
248
+ });
249
+
250
+ it("正常な外部 URL → upstream レスポンスを返す", async () => {
251
+ const bundle = { version: "1", source: "shoal", findings: [] };
252
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
253
+ ok: true,
254
+ json: async () => bundle,
255
+ }));
256
+ const res = await request(app).post("/api/findings/proxy-url").send({ url: "https://raw.githubusercontent.com/example/data.json" });
257
+ expect(res.status).toBe(200);
258
+ expect(res.body.version).toBe("1");
259
+ vi.unstubAllGlobals();
260
+ });
261
+
262
+ it("upstream が失敗 → 502", async () => {
263
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
264
+ ok: false,
265
+ status: 503,
266
+ }));
267
+ const res = await request(app).post("/api/findings/proxy-url").send({ url: "https://example.com/data.json" });
268
+ expect(res.status).toBe(502);
269
+ vi.unstubAllGlobals();
270
+ });
271
+
272
+ it("fetch 例外(タイムアウト等)→ 502", async () => {
273
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("AbortError")));
274
+ const res = await request(app).post("/api/findings/proxy-url").send({ url: "https://example.com/data.json" });
275
+ expect(res.status).toBe(502);
276
+ vi.unstubAllGlobals();
277
+ });
278
+ });
279
+
280
+ // ================================================================
281
+ // PATCH /api/schedule — 既存エンドポイントのバリデーション
282
+ // ================================================================
283
+ describe("PATCH /api/schedule", () => {
284
+ it("範囲外の dayOfWeek(-1, 7)は無視してデフォルト値を維持する", async () => {
285
+ const { loadSchedule } = await import("../scheduler.js");
286
+ vi.mocked(loadSchedule).mockReturnValue({ enabled: false, dayOfWeek: 1, hour: 9, minute: 0, lastRunDate: null });
287
+ const res = await request(app).patch("/api/schedule").send({ dayOfWeek: -1 });
288
+ expect(res.status).toBe(200);
289
+ expect(res.body.dayOfWeek).toBe(1); // デフォルト値を維持
290
+ });
291
+
292
+ it("範囲外の hour(24)は無視する", async () => {
293
+ const { loadSchedule } = await import("../scheduler.js");
294
+ vi.mocked(loadSchedule).mockReturnValue({ enabled: false, dayOfWeek: 1, hour: 9, minute: 0, lastRunDate: null });
295
+ const res = await request(app).patch("/api/schedule").send({ hour: 24 });
296
+ expect(res.status).toBe(200);
297
+ expect(res.body.hour).toBe(9);
298
+ });
299
+
300
+ it("enabled に数値を渡すと Boolean 変換される", async () => {
301
+ const { loadSchedule } = await import("../scheduler.js");
302
+ vi.mocked(loadSchedule).mockReturnValue({ enabled: false, dayOfWeek: 1, hour: 9, minute: 0, lastRunDate: null });
303
+ const res = await request(app).patch("/api/schedule").send({ enabled: 1 });
304
+ expect(res.status).toBe(200);
305
+ expect(res.body.enabled).toBe(true);
306
+ });
307
+ });
@@ -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
@@ -174,7 +174,8 @@ app.post("/api/findings/proxy-url", async (req, res) => {
174
174
  parsed = new URL(url);
175
175
  if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("invalid protocol");
176
176
  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")) {
177
+ const bare = h.replace(/^\[|\]$/g, ""); // IPv6 brackets: [::1] ::1
178
+ if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1" || bare.startsWith("192.168.") || bare.startsWith("10.") || bare.endsWith(".local")) {
178
179
  res.status(400).json({ error: "private urls not allowed" });
179
180
  return;
180
181
  }
@@ -372,7 +373,11 @@ app.patch("/api/schedule", (req, res) => {
372
373
  res.json(updated);
373
374
  });
374
375
 
375
- app.listen(PORT, () => {
376
- console.log(`\nshoal dashboard → http://localhost:${PORT}\n`);
377
- startScheduler();
378
- });
376
+ export { app };
377
+
378
+ if (process.env.NODE_ENV !== "test") {
379
+ app.listen(PORT, () => {
380
+ console.log(`\nshoal dashboard → http://localhost:${PORT}\n`);
381
+ startScheduler();
382
+ });
383
+ }