@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
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": {
|
|
@@ -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,582 @@
|
|
|
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, spawnRun, cancelSession } from "../runner.js";
|
|
19
|
+
import { listRuns, getReportPath } from "../runs.js";
|
|
20
|
+
import { loadSchedule } from "../scheduler.js";
|
|
21
|
+
|
|
22
|
+
// NODE_ENV=test なので app.listen は呼ばれない
|
|
23
|
+
const { app } = await import("../index.js");
|
|
24
|
+
|
|
25
|
+
// ----------------------------------------------------------------
|
|
26
|
+
// テスト用ヘルパー
|
|
27
|
+
// ----------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
function mockFinding(overrides = {}) {
|
|
30
|
+
return {
|
|
31
|
+
id: "f1",
|
|
32
|
+
runId: "run_1",
|
|
33
|
+
agentId: "a1",
|
|
34
|
+
agentName: "Alice",
|
|
35
|
+
role: "tester",
|
|
36
|
+
title: "Test finding",
|
|
37
|
+
body: "Something broke",
|
|
38
|
+
category: "bug",
|
|
39
|
+
timestamp: new Date().toISOString(),
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function setupFindingsDir(runDirs: Record<string, object[]>) {
|
|
45
|
+
vi.mocked(fs.existsSync).mockImplementation((p: unknown) => {
|
|
46
|
+
const path = String(p);
|
|
47
|
+
return path.includes("findings") || Object.keys(runDirs).some((r) => path.includes(r));
|
|
48
|
+
});
|
|
49
|
+
vi.mocked(fs.readdirSync).mockImplementation((p: unknown) => {
|
|
50
|
+
const path = String(p);
|
|
51
|
+
const runId = Object.keys(runDirs).find((r) => path.endsWith(r));
|
|
52
|
+
if (runId) {
|
|
53
|
+
return runDirs[runId].map((_, i) => `f${i}.json`) as unknown as ReturnType<typeof fs.readdirSync>;
|
|
54
|
+
}
|
|
55
|
+
// findings ベースディレクトリ
|
|
56
|
+
return Object.keys(runDirs) as unknown as ReturnType<typeof fs.readdirSync>;
|
|
57
|
+
});
|
|
58
|
+
vi.mocked(fs.readFileSync).mockImplementation((p: unknown) => {
|
|
59
|
+
const path = String(p);
|
|
60
|
+
for (const [runId, items] of Object.entries(runDirs)) {
|
|
61
|
+
const idx = items.findIndex((_, i) => path.endsWith(`f${i}.json`));
|
|
62
|
+
if (idx >= 0 && path.includes(runId)) {
|
|
63
|
+
return JSON.stringify(items[idx]) as unknown as ReturnType<typeof fs.readFileSync>;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return "{}" as unknown as ReturnType<typeof fs.readFileSync>;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
process.env.BASE_URL = "http://localhost:3000";
|
|
72
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
73
|
+
vi.mocked(fs.readdirSync).mockReturnValue([] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
74
|
+
vi.mocked(fs.readFileSync).mockReturnValue("{}" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
75
|
+
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
|
76
|
+
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
|
77
|
+
vi.mocked(getDiaryPath).mockReturnValue(null);
|
|
78
|
+
vi.mocked(generateDiary).mockResolvedValue("# 探索日誌");
|
|
79
|
+
(activeSessions as Map<string, unknown>).clear();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ================================================================
|
|
83
|
+
// GET /api/runs/:runId/diary
|
|
84
|
+
// ================================================================
|
|
85
|
+
describe("GET /api/runs/:runId/diary", () => {
|
|
86
|
+
it("不正な runId → 400", async () => {
|
|
87
|
+
const res = await request(app).get("/api/runs/run_abc/diary");
|
|
88
|
+
expect(res.status).toBe(400);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("diary ファイルが存在しない → 404", async () => {
|
|
92
|
+
vi.mocked(getDiaryPath).mockReturnValue(null);
|
|
93
|
+
const res = await request(app).get("/api/runs/run_123/diary");
|
|
94
|
+
expect(res.status).toBe(404);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("diary ファイルが存在する → 200 + content", async () => {
|
|
98
|
+
vi.mocked(getDiaryPath).mockReturnValue("/some/path/diary_run_123.md");
|
|
99
|
+
vi.mocked(fs.readFileSync).mockReturnValue("# 日誌" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
100
|
+
const res = await request(app).get("/api/runs/run_123/diary");
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
expect(res.body.content).toBe("# 日誌");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ================================================================
|
|
107
|
+
// POST /api/runs/:runId/diary
|
|
108
|
+
// ================================================================
|
|
109
|
+
describe("POST /api/runs/:runId/diary", () => {
|
|
110
|
+
it("不正な runId → 400", async () => {
|
|
111
|
+
const res = await request(app).post("/api/runs/invalid/diary");
|
|
112
|
+
expect(res.status).toBe(400);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("アクティブセッションがない + ログファイルなし → 404", async () => {
|
|
116
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
117
|
+
const res = await request(app).post("/api/runs/run_123/diary");
|
|
118
|
+
expect(res.status).toBe(404);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("ログファイルがある → generateDiary を呼んで content を返す", async () => {
|
|
122
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
123
|
+
vi.mocked(fs.readFileSync).mockReturnValue("line1\nline2\n" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
124
|
+
vi.mocked(generateDiary).mockResolvedValue("# 探索日誌");
|
|
125
|
+
const res = await request(app).post("/api/runs/run_123/diary");
|
|
126
|
+
expect(res.status).toBe(200);
|
|
127
|
+
expect(res.body.content).toBe("# 探索日誌");
|
|
128
|
+
expect(generateDiary).toHaveBeenCalledWith("run_123", ["line1", "line2"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("アクティブセッションがある → session.lines を使う", async () => {
|
|
132
|
+
(activeSessions as Map<string, unknown>).set("run_123", { lines: ["live line"], done: false });
|
|
133
|
+
vi.mocked(generateDiary).mockResolvedValue("# ライブ日誌");
|
|
134
|
+
const res = await request(app).post("/api/runs/run_123/diary");
|
|
135
|
+
expect(res.status).toBe(200);
|
|
136
|
+
expect(generateDiary).toHaveBeenCalledWith("run_123", ["live line"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("generateDiary が失敗 → 500", async () => {
|
|
140
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
141
|
+
vi.mocked(fs.readFileSync).mockReturnValue("line\n" as unknown as ReturnType<typeof fs.readFileSync>);
|
|
142
|
+
vi.mocked(generateDiary).mockRejectedValue(new Error("LLM error"));
|
|
143
|
+
const res = await request(app).post("/api/runs/run_123/diary");
|
|
144
|
+
expect(res.status).toBe(500);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ================================================================
|
|
149
|
+
// GET /api/findings
|
|
150
|
+
// ================================================================
|
|
151
|
+
describe("GET /api/findings", () => {
|
|
152
|
+
it("findings ディレクトリがない → 空配列", async () => {
|
|
153
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
154
|
+
const res = await request(app).get("/api/findings");
|
|
155
|
+
expect(res.status).toBe(200);
|
|
156
|
+
expect(res.body).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("findings を timestamp 降順で返す", async () => {
|
|
160
|
+
const older = mockFinding({ id: "f1", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" });
|
|
161
|
+
const newer = mockFinding({ id: "f2", runId: "run_2", timestamp: "2026-06-01T00:00:00.000Z" });
|
|
162
|
+
setupFindingsDir({ run_1: [older], run_2: [newer] });
|
|
163
|
+
const res = await request(app).get("/api/findings");
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
expect(res.body[0].timestamp).toBe("2026-06-01T00:00:00.000Z");
|
|
166
|
+
expect(res.body[1].timestamp).toBe("2026-01-01T00:00:00.000Z");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("run_\\d+ 以外のディレクトリは無視する", async () => {
|
|
170
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
171
|
+
vi.mocked(fs.readdirSync).mockReturnValue([".DS_Store", "tmp"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
172
|
+
const res = await request(app).get("/api/findings");
|
|
173
|
+
expect(res.body).toEqual([]);
|
|
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
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ================================================================
|
|
238
|
+
// GET /api/findings/export
|
|
239
|
+
// ================================================================
|
|
240
|
+
describe("GET /api/findings/export", () => {
|
|
241
|
+
it("正しい Content-Disposition ヘッダーを返す", async () => {
|
|
242
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
243
|
+
const res = await request(app).get("/api/findings/export");
|
|
244
|
+
expect(res.status).toBe(200);
|
|
245
|
+
expect(res.headers["content-disposition"]).toContain("attachment");
|
|
246
|
+
expect(res.headers["content-disposition"]).toContain(".json");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("レスポンスに version / exportedAt / source / findings が含まれる", async () => {
|
|
250
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
251
|
+
const res = await request(app).get("/api/findings/export");
|
|
252
|
+
expect(res.body.version).toBe("1");
|
|
253
|
+
expect(res.body.source).toBe("shoal");
|
|
254
|
+
expect(typeof res.body.exportedAt).toBe("string");
|
|
255
|
+
expect(Array.isArray(res.body.findings)).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("findings の screenshotPath は除外される", async () => {
|
|
259
|
+
const f = mockFinding({ screenshotPath: "/secret/path.png" });
|
|
260
|
+
setupFindingsDir({ run_1: [f] });
|
|
261
|
+
const res = await request(app).get("/api/findings/export");
|
|
262
|
+
expect(res.body.findings[0]).not.toHaveProperty("screenshotPath");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ================================================================
|
|
267
|
+
// POST /api/findings/proxy-url — SSRF 防御テスト
|
|
268
|
+
// ================================================================
|
|
269
|
+
describe("POST /api/findings/proxy-url", () => {
|
|
270
|
+
it("url パラメータなし → 400", async () => {
|
|
271
|
+
const res = await request(app).post("/api/findings/proxy-url").send({});
|
|
272
|
+
expect(res.status).toBe(400);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("http / https 以外のプロトコル → 400", async () => {
|
|
276
|
+
const cases = ["file:///etc/passwd", "javascript:alert(1)", "ftp://example.com"];
|
|
277
|
+
for (const url of cases) {
|
|
278
|
+
const res = await request(app).post("/api/findings/proxy-url").send({ url });
|
|
279
|
+
expect(res.status).toBe(400);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("localhost → 400(SSRF 防御)", async () => {
|
|
284
|
+
const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://localhost/data.json" });
|
|
285
|
+
expect(res.status).toBe(400);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("127.0.0.1 → 400(SSRF 防御)", async () => {
|
|
289
|
+
const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://127.0.0.1/data.json" });
|
|
290
|
+
expect(res.status).toBe(400);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("::1(IPv6 localhost)→ 400(SSRF 防御)", async () => {
|
|
294
|
+
const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://[::1]/data.json" });
|
|
295
|
+
expect(res.status).toBe(400);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("192.168.x.x → 400(SSRF 防御)", async () => {
|
|
299
|
+
const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://192.168.1.1/data.json" });
|
|
300
|
+
expect(res.status).toBe(400);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("10.x.x.x → 400(SSRF 防御)", async () => {
|
|
304
|
+
const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://10.0.0.1/data.json" });
|
|
305
|
+
expect(res.status).toBe(400);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it(".local ドメイン → 400(SSRF 防御)", async () => {
|
|
309
|
+
const res = await request(app).post("/api/findings/proxy-url").send({ url: "http://myserver.local/data.json" });
|
|
310
|
+
expect(res.status).toBe(400);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("正常な外部 URL → upstream レスポンスを返す", async () => {
|
|
314
|
+
const bundle = { version: "1", source: "shoal", findings: [] };
|
|
315
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
316
|
+
ok: true,
|
|
317
|
+
json: async () => bundle,
|
|
318
|
+
}));
|
|
319
|
+
const res = await request(app).post("/api/findings/proxy-url").send({ url: "https://raw.githubusercontent.com/example/data.json" });
|
|
320
|
+
expect(res.status).toBe(200);
|
|
321
|
+
expect(res.body.version).toBe("1");
|
|
322
|
+
vi.unstubAllGlobals();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("upstream が失敗 → 502", async () => {
|
|
326
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
327
|
+
ok: false,
|
|
328
|
+
status: 503,
|
|
329
|
+
}));
|
|
330
|
+
const res = await request(app).post("/api/findings/proxy-url").send({ url: "https://example.com/data.json" });
|
|
331
|
+
expect(res.status).toBe(502);
|
|
332
|
+
vi.unstubAllGlobals();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("fetch 例外(タイムアウト等)→ 502", async () => {
|
|
336
|
+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("AbortError")));
|
|
337
|
+
const res = await request(app).post("/api/findings/proxy-url").send({ url: "https://example.com/data.json" });
|
|
338
|
+
expect(res.status).toBe(502);
|
|
339
|
+
vi.unstubAllGlobals();
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ================================================================
|
|
344
|
+
// PATCH /api/schedule — 既存エンドポイントのバリデーション
|
|
345
|
+
// ================================================================
|
|
346
|
+
describe("PATCH /api/schedule", () => {
|
|
347
|
+
it("範囲外の dayOfWeek(-1, 7)は無視してデフォルト値を維持する", async () => {
|
|
348
|
+
const { loadSchedule } = await import("../scheduler.js");
|
|
349
|
+
vi.mocked(loadSchedule).mockReturnValue({ enabled: false, dayOfWeek: 1, hour: 9, minute: 0, lastRunDate: null });
|
|
350
|
+
const res = await request(app).patch("/api/schedule").send({ dayOfWeek: -1 });
|
|
351
|
+
expect(res.status).toBe(200);
|
|
352
|
+
expect(res.body.dayOfWeek).toBe(1); // デフォルト値を維持
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("範囲外の hour(24)は無視する", async () => {
|
|
356
|
+
const { loadSchedule } = await import("../scheduler.js");
|
|
357
|
+
vi.mocked(loadSchedule).mockReturnValue({ enabled: false, dayOfWeek: 1, hour: 9, minute: 0, lastRunDate: null });
|
|
358
|
+
const res = await request(app).patch("/api/schedule").send({ hour: 24 });
|
|
359
|
+
expect(res.status).toBe(200);
|
|
360
|
+
expect(res.body.hour).toBe(9);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("enabled に数値を渡すと Boolean 変換される", async () => {
|
|
364
|
+
const { loadSchedule } = await import("../scheduler.js");
|
|
365
|
+
vi.mocked(loadSchedule).mockReturnValue({ enabled: false, dayOfWeek: 1, hour: 9, minute: 0, lastRunDate: null });
|
|
366
|
+
const res = await request(app).patch("/api/schedule").send({ enabled: 1 });
|
|
367
|
+
expect(res.status).toBe(200);
|
|
368
|
+
expect(res.body.enabled).toBe(true);
|
|
369
|
+
});
|
|
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
|
+
});
|