@m8i-51/shoal 0.1.20 → 0.1.22
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/.env.example +11 -0
- package/README.md +3 -0
- package/framework/coverage.ts +4 -3
- package/framework/cross-run-dedup.ts +43 -0
- package/framework/diary.ts +5 -3
- package/framework/llm-client.ts +4 -1
- package/framework/types.ts +11 -0
- package/package.json +6 -4
- package/server/index.ts +30 -24
- package/server/runs.ts +4 -3
- package/web/dist/assets/index-Cx9-vLgF.js +85 -0
- package/web/dist/index.html +1 -1
- package/framework/__tests__/cost.test.ts +0 -130
- package/framework/__tests__/coverage.test.ts +0 -429
- package/framework/__tests__/diary.test.ts +0 -139
- package/framework/__tests__/page-cache.test.ts +0 -106
- package/framework/__tests__/report.test.ts +0 -253
- package/server/__tests__/api.test.ts +0 -307
- package/server/__tests__/scheduler.test.ts +0 -137
- package/web/dist/assets/index-PhasbbTM.js +0 -85
|
@@ -1,307 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } 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
|
-
vi.mock("../runner.js", () => ({ spawnRun: vi.fn() }));
|
|
10
|
-
|
|
11
|
-
import { loadSchedule, saveSchedule, type ScheduleConfig } from "../scheduler";
|
|
12
|
-
import { spawnRun } from "../runner.js";
|
|
13
|
-
|
|
14
|
-
const DEFAULT: ScheduleConfig = {
|
|
15
|
-
enabled: false,
|
|
16
|
-
dayOfWeek: 1,
|
|
17
|
-
hour: 9,
|
|
18
|
-
minute: 0,
|
|
19
|
-
lastRunDate: null,
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
beforeEach(() => {
|
|
23
|
-
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
24
|
-
vi.mocked(fs.readFileSync).mockReturnValue("{}");
|
|
25
|
-
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
afterEach(() => {
|
|
29
|
-
vi.clearAllMocks();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe("loadSchedule", () => {
|
|
33
|
-
it("ファイルがない場合はデフォルト設定を返す", () => {
|
|
34
|
-
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
35
|
-
expect(loadSchedule()).toEqual(DEFAULT);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("ファイルがある場合は設定を読み込む", () => {
|
|
39
|
-
const saved: ScheduleConfig = { enabled: true, dayOfWeek: 3, hour: 14, minute: 30, lastRunDate: "2026-05-12" };
|
|
40
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
41
|
-
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(saved));
|
|
42
|
-
expect(loadSchedule()).toEqual(saved);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("ファイルが壊れている場合はデフォルトを返す", () => {
|
|
46
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
47
|
-
vi.mocked(fs.readFileSync).mockReturnValue("invalid json{{{");
|
|
48
|
-
expect(loadSchedule()).toEqual(DEFAULT);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("部分的な設定はデフォルトとマージされる", () => {
|
|
52
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
53
|
-
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ enabled: true }));
|
|
54
|
-
expect(loadSchedule()).toEqual({ ...DEFAULT, enabled: true });
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe("saveSchedule", () => {
|
|
59
|
-
it("設定を JSON ファイルに書き出す", () => {
|
|
60
|
-
const config: ScheduleConfig = { enabled: true, dayOfWeek: 1, hour: 9, minute: 0, lastRunDate: null };
|
|
61
|
-
saveSchedule(config);
|
|
62
|
-
const [, content] = vi.mocked(fs.writeFileSync).mock.calls[0];
|
|
63
|
-
expect(JSON.parse(content as string)).toEqual(config);
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe("scheduler — 時刻判定ロジック", () => {
|
|
68
|
-
it("enabled=false のときは spawnRun を呼ばない", async () => {
|
|
69
|
-
vi.useFakeTimers();
|
|
70
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
71
|
-
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ ...DEFAULT, enabled: false }));
|
|
72
|
-
|
|
73
|
-
vi.resetModules();
|
|
74
|
-
const { startScheduler } = await import("../scheduler");
|
|
75
|
-
startScheduler();
|
|
76
|
-
|
|
77
|
-
// 最初の setTimeout(次の分の頭)+ check が走る分だけ進める
|
|
78
|
-
await vi.advanceTimersByTimeAsync(61_000);
|
|
79
|
-
expect(spawnRun).not.toHaveBeenCalled();
|
|
80
|
-
vi.useRealTimers();
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("スケジュール時刻に一致したとき spawnRun を呼ぶ", async () => {
|
|
84
|
-
vi.useFakeTimers();
|
|
85
|
-
|
|
86
|
-
// 月曜 09:00 に固定。scheduler は new Date().getDay() などローカル時刻を使うため、
|
|
87
|
-
// テスト設定も同じメソッドで一致させる(環境のタイムゾーンに依存するが両者が整合する)
|
|
88
|
-
const monday9am = new Date("2026-05-11T09:00:00.000Z");
|
|
89
|
-
vi.setSystemTime(monday9am);
|
|
90
|
-
const now = new Date();
|
|
91
|
-
|
|
92
|
-
const config: ScheduleConfig = {
|
|
93
|
-
enabled: true,
|
|
94
|
-
dayOfWeek: now.getDay(),
|
|
95
|
-
hour: now.getHours(),
|
|
96
|
-
minute: now.getMinutes(),
|
|
97
|
-
lastRunDate: null,
|
|
98
|
-
};
|
|
99
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
100
|
-
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(config));
|
|
101
|
-
|
|
102
|
-
vi.resetModules();
|
|
103
|
-
const { startScheduler: start } = await import("../scheduler");
|
|
104
|
-
start();
|
|
105
|
-
|
|
106
|
-
await vi.advanceTimersByTimeAsync(61_000);
|
|
107
|
-
expect(spawnRun).toHaveBeenCalledOnce();
|
|
108
|
-
vi.useRealTimers();
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("同日に既に実行済みなら spawnRun を呼ばない", async () => {
|
|
112
|
-
vi.useFakeTimers();
|
|
113
|
-
|
|
114
|
-
const monday9am = new Date("2026-05-11T09:00:00.000Z");
|
|
115
|
-
vi.setSystemTime(monday9am);
|
|
116
|
-
const now = new Date();
|
|
117
|
-
const today = now.toISOString().slice(0, 10); // scheduler と同じ UTC 日付を使う
|
|
118
|
-
|
|
119
|
-
const config: ScheduleConfig = {
|
|
120
|
-
enabled: true,
|
|
121
|
-
dayOfWeek: now.getDay(),
|
|
122
|
-
hour: now.getHours(),
|
|
123
|
-
minute: now.getMinutes(),
|
|
124
|
-
lastRunDate: today,
|
|
125
|
-
};
|
|
126
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
127
|
-
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(config));
|
|
128
|
-
|
|
129
|
-
vi.resetModules();
|
|
130
|
-
const { startScheduler: start } = await import("../scheduler");
|
|
131
|
-
start();
|
|
132
|
-
|
|
133
|
-
await vi.advanceTimersByTimeAsync(61_000);
|
|
134
|
-
expect(spawnRun).not.toHaveBeenCalled();
|
|
135
|
-
vi.useRealTimers();
|
|
136
|
-
});
|
|
137
|
-
});
|