@m8i-51/shoal 0.1.21 → 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 +3 -3
- package/framework/diary.ts +3 -3
- package/framework/llm-client.ts +4 -1
- package/framework/types.ts +11 -0
- package/package.json +5 -3
- package/server/index.ts +3 -3
- package/server/runs.ts +3 -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 -448
- package/framework/__tests__/cross-run-dedup.test.ts +0 -83
- package/framework/__tests__/diary.test.ts +0 -155
- package/framework/__tests__/page-cache.test.ts +0 -106
- package/framework/__tests__/report.test.ts +0 -253
- package/server/__tests__/api.test.ts +0 -582
- package/server/__tests__/runs.test.ts +0 -186
- package/server/__tests__/scheduler.test.ts +0 -137
- package/web/dist/assets/index-PhasbbTM.js +0 -85
package/web/dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>shoal</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-Cx9-vLgF.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-DPLuVm2n.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { estimateCost, formatCostUSD } from "../cost";
|
|
3
|
-
|
|
4
|
-
// OpenRouter のフェッチをモック(ネットワーク不要)
|
|
5
|
-
vi.stubGlobal("fetch", vi.fn());
|
|
6
|
-
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
vi.mocked(fetch).mockResolvedValue({
|
|
9
|
-
ok: false,
|
|
10
|
-
status: 503,
|
|
11
|
-
json: async () => ({}),
|
|
12
|
-
text: async () => "",
|
|
13
|
-
} as Response);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
describe("formatCostUSD", () => {
|
|
17
|
-
it("null / undefined は —", () => {
|
|
18
|
-
expect(formatCostUSD(null)).toBe("—");
|
|
19
|
-
expect(formatCostUSD(undefined)).toBe("—");
|
|
20
|
-
});
|
|
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
|
-
|
|
34
|
-
it("0.00005 未満は < $0.0001", () => {
|
|
35
|
-
expect(formatCostUSD(0.000005)).toBe("< $0.0001");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("0.0001 以上 0.01 未満は 4 桁小数", () => {
|
|
39
|
-
expect(formatCostUSD(0.0023)).toBe("$0.0023");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("0.01 以上 1 未満は 3 桁小数", () => {
|
|
43
|
-
expect(formatCostUSD(0.123)).toBe("$0.123");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("1 以上は 2 桁小数", () => {
|
|
47
|
-
expect(formatCostUSD(2.5)).toBe("$2.50");
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
describe("estimateCost — free providers", () => {
|
|
52
|
-
it.each(["ollama", "lm-studio", "codex", "local"])("%s は null を返す", async (provider) => {
|
|
53
|
-
expect(await estimateCost("any-model", provider, 1000, 500)).toBeNull();
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe("estimateCost — Anthropic", () => {
|
|
58
|
-
it("claude-haiku-4-5-20251001 の料金を計算する", async () => {
|
|
59
|
-
const cost = await estimateCost("claude-haiku-4-5-20251001", "anthropic", 1_000_000, 500_000);
|
|
60
|
-
// input: 0.8/1M × 1M = 0.8, output: 4/1M × 500k = 2.0 → 2.8
|
|
61
|
-
expect(cost).toBeCloseTo(2.8, 5);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("prefix match — claude-haiku-4-5-xxx はキーに一致する", async () => {
|
|
65
|
-
const cost = await estimateCost("claude-haiku-4-5-some-suffix", "anthropic", 1_000_000, 0);
|
|
66
|
-
expect(cost).toBeCloseTo(0.8, 5);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("不明モデルは null", async () => {
|
|
70
|
-
expect(await estimateCost("claude-unknown-9999", "anthropic", 1000, 500)).toBeNull();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("claude-sonnet-4-6 の料金を計算する", async () => {
|
|
74
|
-
const cost = await estimateCost("claude-sonnet-4-6", "anthropic", 1_000_000, 1_000_000);
|
|
75
|
-
// input: 3/1M + output: 15/1M = 18
|
|
76
|
-
expect(cost).toBeCloseTo(18, 5);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe("estimateCost — Bedrock", () => {
|
|
81
|
-
it("anthropic.claude-3-5-haiku-20241022-v1:0 の料金を計算する", async () => {
|
|
82
|
-
const cost = await estimateCost("anthropic.claude-3-5-haiku-20241022-v1:0", "bedrock", 1_000_000, 1_000_000);
|
|
83
|
-
// input: 0.8/1M + output: 4/1M = 4.8
|
|
84
|
-
expect(cost).toBeCloseTo(4.8, 5);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("クロスリージョンプレフィックス us. を除去してマッチする", async () => {
|
|
88
|
-
const direct = await estimateCost("anthropic.claude-3-5-sonnet-20241022-v2:0", "bedrock", 1_000_000, 0);
|
|
89
|
-
const crossRegion = await estimateCost("us.anthropic.claude-3-5-sonnet-20241022-v2:0", "bedrock", 1_000_000, 0);
|
|
90
|
-
expect(crossRegion).toBeCloseTo(direct!, 8);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("不明モデルは null", async () => {
|
|
94
|
-
expect(await estimateCost("anthropic.claude-unknown-v99:0", "bedrock", 1000, 500)).toBeNull();
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe("estimateCost — OpenAI", () => {
|
|
99
|
-
it("gpt-4o の料金を計算する", async () => {
|
|
100
|
-
const cost = await estimateCost("gpt-4o", "openai", 1_000_000, 1_000_000);
|
|
101
|
-
// input: 5/1M + output: 15/1M = 20
|
|
102
|
-
expect(cost).toBeCloseTo(20, 5);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("gpt-4o-mini の料金を計算する", async () => {
|
|
106
|
-
const cost = await estimateCost("gpt-4o-mini", "openai", 1_000_000, 1_000_000);
|
|
107
|
-
expect(cost).toBeCloseTo(0.75, 5);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("不明モデルは null", async () => {
|
|
111
|
-
expect(await estimateCost("gpt-unknown", "openai", 1000, 500)).toBeNull();
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
describe("estimateCost — OpenRouter", () => {
|
|
116
|
-
it("fetch 失敗時は null を返す", async () => {
|
|
117
|
-
expect(await estimateCost("some/model", "openrouter", 1000, 500)).toBeNull();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("fetch 成功時はレスポンスの料金を使う", async () => {
|
|
121
|
-
vi.mocked(fetch).mockResolvedValueOnce({
|
|
122
|
-
ok: true,
|
|
123
|
-
json: async () => ({
|
|
124
|
-
data: [{ id: "some/model", pricing: { prompt: "0.000003", completion: "0.000015" } }],
|
|
125
|
-
}),
|
|
126
|
-
} as Response);
|
|
127
|
-
const cost = await estimateCost("some/model", "openrouter", 1_000_000, 1_000_000);
|
|
128
|
-
expect(cost).toBeCloseTo(18, 5);
|
|
129
|
-
});
|
|
130
|
-
});
|
|
@@ -1,448 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
-
import * as fs from "fs";
|
|
3
|
-
import * as path from "path";
|
|
4
|
-
|
|
5
|
-
vi.mock("fs");
|
|
6
|
-
vi.mock("path", async (importOriginal) => {
|
|
7
|
-
const actual = await importOriginal<typeof path>();
|
|
8
|
-
return { ...actual, join: (...args: string[]) => args.join("/") };
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
import { computeWeightedSummary, updateCoverage, loadCoverage, getLastRunPaths, getFindingHotspots } from "../coverage";
|
|
12
|
-
import type { Coverage, RunCoverage } from "../coverage";
|
|
13
|
-
|
|
14
|
-
const HALF_LIFE_DAYS = 7;
|
|
15
|
-
const halfLifeMs = HALF_LIFE_DAYS * 24 * 60 * 60 * 1000;
|
|
16
|
-
|
|
17
|
-
function makeEntry(overrides: Partial<RunCoverage> = {}): RunCoverage {
|
|
18
|
-
return {
|
|
19
|
-
runId: "run_1",
|
|
20
|
-
timestamp: new Date().toISOString(),
|
|
21
|
-
findingsCount: 0,
|
|
22
|
-
byCategory: {},
|
|
23
|
-
byLens: {},
|
|
24
|
-
byScenario: {},
|
|
25
|
-
visitedPaths: [],
|
|
26
|
-
...overrides,
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function setupMockCoverage(coverage: Coverage) {
|
|
31
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
32
|
-
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(coverage) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
describe("loadCoverage", () => {
|
|
36
|
-
it("空ファイルが存在しない場合は空のエントリーを返す", () => {
|
|
37
|
-
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
38
|
-
const result = loadCoverage();
|
|
39
|
-
expect(result).toEqual({ entries: [] });
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("ファイルが存在する場合はパースして返す", () => {
|
|
43
|
-
const coverage: Coverage = { entries: [makeEntry({ runId: "run_1" })] };
|
|
44
|
-
setupMockCoverage(coverage);
|
|
45
|
-
const result = loadCoverage();
|
|
46
|
-
expect(result.entries).toHaveLength(1);
|
|
47
|
-
expect(result.entries[0].runId).toBe("run_1");
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
describe("computeWeightedSummary", () => {
|
|
52
|
-
beforeEach(() => {
|
|
53
|
-
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
|
54
|
-
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("エントリーがない場合は空サマリーを返す", () => {
|
|
58
|
-
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
59
|
-
const result = computeWeightedSummary();
|
|
60
|
-
expect(result.totalWeighted).toBe(0);
|
|
61
|
-
expect(result.formatted).toContain("no coverage data");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("直近のエントリーは重みが高い", () => {
|
|
65
|
-
const now = Date.now();
|
|
66
|
-
const recentEntry = makeEntry({
|
|
67
|
-
runId: "recent",
|
|
68
|
-
timestamp: new Date(now).toISOString(),
|
|
69
|
-
findingsCount: 10,
|
|
70
|
-
byLens: { Accessibility: 10 },
|
|
71
|
-
});
|
|
72
|
-
const oldEntry = makeEntry({
|
|
73
|
-
runId: "old",
|
|
74
|
-
timestamp: new Date(now - halfLifeMs * 2).toISOString(), // 半減期2倍 = weight 0.25
|
|
75
|
-
findingsCount: 10,
|
|
76
|
-
byLens: { Accessibility: 10 },
|
|
77
|
-
});
|
|
78
|
-
setupMockCoverage({ entries: [oldEntry, recentEntry] });
|
|
79
|
-
|
|
80
|
-
const result = computeWeightedSummary();
|
|
81
|
-
// recent(weight≈1.0) + old(weight≈0.25) なので合計は約 12.5
|
|
82
|
-
expect(result.totalWeighted).toBeGreaterThan(10);
|
|
83
|
-
expect(result.totalWeighted).toBeLessThan(15);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("半減期2倍前のエントリーは重みが約0.25になる", () => {
|
|
87
|
-
const now = Date.now();
|
|
88
|
-
const oldTimestamp = new Date(now - halfLifeMs * 2).toISOString();
|
|
89
|
-
setupMockCoverage({
|
|
90
|
-
entries: [
|
|
91
|
-
makeEntry({
|
|
92
|
-
runId: "old",
|
|
93
|
-
timestamp: oldTimestamp,
|
|
94
|
-
findingsCount: 4,
|
|
95
|
-
byLens: { Security: 4 },
|
|
96
|
-
}),
|
|
97
|
-
],
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const result = computeWeightedSummary();
|
|
101
|
-
// weight = 0.5^2 = 0.25, findingsCount=4 → totalWeighted=1.0
|
|
102
|
-
expect(result.totalWeighted).toBeCloseTo(1.0, 0);
|
|
103
|
-
expect(result.byLens["Security"]).toBeCloseTo(1.0, 0);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("カテゴリ別の集計が正しい", () => {
|
|
107
|
-
setupMockCoverage({
|
|
108
|
-
entries: [
|
|
109
|
-
makeEntry({
|
|
110
|
-
timestamp: new Date().toISOString(),
|
|
111
|
-
byCategory: { bug: 3, ux: 5 },
|
|
112
|
-
findingsCount: 8,
|
|
113
|
-
}),
|
|
114
|
-
],
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
const result = computeWeightedSummary();
|
|
118
|
-
expect(result.byCategory["bug"]).toBeGreaterThan(0);
|
|
119
|
-
expect(result.byCategory["ux"]).toBeGreaterThan(result.byCategory["bug"]);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("underrepresented レンズが formatted に含まれる", () => {
|
|
123
|
-
const now = Date.now();
|
|
124
|
-
// Accessibility は 10 件、Security は 1 件(平均 5.5 の半分未満 → underrepresented)
|
|
125
|
-
setupMockCoverage({
|
|
126
|
-
entries: [
|
|
127
|
-
makeEntry({
|
|
128
|
-
timestamp: new Date(now).toISOString(),
|
|
129
|
-
findingsCount: 11,
|
|
130
|
-
byLens: { Accessibility: 10, Security: 1 },
|
|
131
|
-
}),
|
|
132
|
-
],
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
const result = computeWeightedSummary();
|
|
136
|
-
expect(result.formatted).toContain("Security");
|
|
137
|
-
expect(result.formatted.toLowerCase()).toContain("underrepresented");
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("全レンズが均等な場合は underrepresented なしのメッセージを出す", () => {
|
|
141
|
-
setupMockCoverage({
|
|
142
|
-
entries: [
|
|
143
|
-
makeEntry({
|
|
144
|
-
timestamp: new Date().toISOString(),
|
|
145
|
-
findingsCount: 6,
|
|
146
|
-
byLens: { Accessibility: 3, Security: 3 },
|
|
147
|
-
}),
|
|
148
|
-
],
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
const result = computeWeightedSummary();
|
|
152
|
-
expect(result.formatted).toContain("comparable coverage");
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("シナリオ別の集計が結果に含まれる", () => {
|
|
156
|
-
setupMockCoverage({
|
|
157
|
-
entries: [
|
|
158
|
-
makeEntry({
|
|
159
|
-
timestamp: new Date().toISOString(),
|
|
160
|
-
findingsCount: 2,
|
|
161
|
-
byScenario: { "New employee submitting first purchase": 2 },
|
|
162
|
-
}),
|
|
163
|
-
],
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
const result = computeWeightedSummary();
|
|
167
|
-
expect(result.byScenario["New employee submitting first purchase"]).toBeGreaterThan(0);
|
|
168
|
-
expect(result.formatted).toContain("By scenario");
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("14日以内に同じレンズが複数 run に登場するとボーナスが乗る", () => {
|
|
172
|
-
const now = Date.now();
|
|
173
|
-
// 同じ Accessibility レンズが2回登場 → bonus = 1 + (2-1)^3 * 0.005 = 1.005
|
|
174
|
-
setupMockCoverage({
|
|
175
|
-
entries: [
|
|
176
|
-
makeEntry({
|
|
177
|
-
runId: "run_1",
|
|
178
|
-
timestamp: new Date(now - 1000).toISOString(),
|
|
179
|
-
findingsCount: 2,
|
|
180
|
-
byLens: { Accessibility: 2 },
|
|
181
|
-
}),
|
|
182
|
-
makeEntry({
|
|
183
|
-
runId: "run_2",
|
|
184
|
-
timestamp: new Date(now).toISOString(),
|
|
185
|
-
findingsCount: 2,
|
|
186
|
-
byLens: { Accessibility: 2 },
|
|
187
|
-
}),
|
|
188
|
-
],
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
const resultWithRepeat = computeWeightedSummary();
|
|
192
|
-
|
|
193
|
-
// 1回しか登場しない場合と比較
|
|
194
|
-
setupMockCoverage({
|
|
195
|
-
entries: [
|
|
196
|
-
makeEntry({
|
|
197
|
-
runId: "run_1",
|
|
198
|
-
timestamp: new Date(now).toISOString(),
|
|
199
|
-
findingsCount: 2,
|
|
200
|
-
byLens: { Accessibility: 2 },
|
|
201
|
-
}),
|
|
202
|
-
],
|
|
203
|
-
});
|
|
204
|
-
const resultSingle = computeWeightedSummary();
|
|
205
|
-
|
|
206
|
-
// 繰り返しありのほうが lens の重みが高いはず
|
|
207
|
-
expect(resultWithRepeat.byLens["Accessibility"]).toBeGreaterThan(resultSingle.byLens["Accessibility"]);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("14日より古いエントリーは繰り返しカウントに含まれない", () => {
|
|
211
|
-
const now = Date.now();
|
|
212
|
-
const oldMs = 15 * 24 * 60 * 60 * 1000; // 15日前
|
|
213
|
-
setupMockCoverage({
|
|
214
|
-
entries: [
|
|
215
|
-
makeEntry({
|
|
216
|
-
runId: "run_old",
|
|
217
|
-
timestamp: new Date(now - oldMs).toISOString(),
|
|
218
|
-
findingsCount: 2,
|
|
219
|
-
byLens: { Security: 2 },
|
|
220
|
-
}),
|
|
221
|
-
makeEntry({
|
|
222
|
-
runId: "run_new",
|
|
223
|
-
timestamp: new Date(now).toISOString(),
|
|
224
|
-
findingsCount: 2,
|
|
225
|
-
byLens: { Security: 2 },
|
|
226
|
-
}),
|
|
227
|
-
],
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
const result = computeWeightedSummary();
|
|
231
|
-
// 古いエントリーはウィンドウ外なのでボーナスなし(繰り返し回数=1 → bonus=1.0)
|
|
232
|
-
// ボーナスなしの場合: weight≈1.0*2 + 15日前のdecay*2 ≈ 2.06
|
|
233
|
-
expect(result.formatted).not.toContain("Repeated lenses");
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it("繰り返しレンズが formatted に含まれる", () => {
|
|
237
|
-
const now = Date.now();
|
|
238
|
-
setupMockCoverage({
|
|
239
|
-
entries: [
|
|
240
|
-
makeEntry({ runId: "r1", timestamp: new Date(now - 1000).toISOString(), findingsCount: 1, byLens: { "UI design": 1 } }),
|
|
241
|
-
makeEntry({ runId: "r2", timestamp: new Date(now).toISOString(), findingsCount: 1, byLens: { "UI design": 1 } }),
|
|
242
|
-
],
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
const result = computeWeightedSummary();
|
|
246
|
-
expect(result.formatted).toContain("Repeated lenses");
|
|
247
|
-
expect(result.formatted).toContain("UI design");
|
|
248
|
-
expect(result.formatted).toContain("×2");
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it("MAX_ENTRIES を超えると最新30件に切り捨てる", () => {
|
|
252
|
-
// 既に30件ある状態で updateCoverage を呼ぶと31件→30件にトリムされることを確認
|
|
253
|
-
const entries = Array.from({ length: 30 }, (_, i) =>
|
|
254
|
-
makeEntry({
|
|
255
|
-
runId: `run_${i}`,
|
|
256
|
-
timestamp: new Date(Date.now() - (30 - i) * 1000).toISOString(),
|
|
257
|
-
})
|
|
258
|
-
);
|
|
259
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
260
|
-
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ entries }));
|
|
261
|
-
vi.mocked(fs.writeFileSync).mockImplementation(() => {});
|
|
262
|
-
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
|
263
|
-
|
|
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);
|
|
275
|
-
});
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
describe("updateCoverage", () => {
|
|
279
|
-
beforeEach(() => {
|
|
280
|
-
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
281
|
-
vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
|
282
|
-
vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it("新しいエントリーを追加して保存する", () => {
|
|
286
|
-
const findings = [
|
|
287
|
-
{ id: "f1", runId: "run_1", agentId: "a1", agentName: "Alice", role: "tester", category: "bug", title: "Bug A", body: "", timestamp: new Date().toISOString() },
|
|
288
|
-
{ id: "f2", runId: "run_1", agentId: "a2", agentName: "Bob", role: "tester", category: "ux", title: "UX B", body: "", timestamp: new Date().toISOString() },
|
|
289
|
-
];
|
|
290
|
-
const agentAssignments = new Map([
|
|
291
|
-
["a1", { lens: "Security: something" }],
|
|
292
|
-
["a2", { lens: "Accessibility: something" }],
|
|
293
|
-
]);
|
|
294
|
-
|
|
295
|
-
updateCoverage("run_1", findings, agentAssignments);
|
|
296
|
-
|
|
297
|
-
const calls = vi.mocked(fs.writeFileSync).mock.calls;
|
|
298
|
-
const written = calls[calls.length - 1][1] as string;
|
|
299
|
-
const saved = JSON.parse(written) as Coverage;
|
|
300
|
-
expect(saved.entries).toHaveLength(1);
|
|
301
|
-
expect(saved.entries[0].byCategory).toEqual({ bug: 1, ux: 1 });
|
|
302
|
-
expect(saved.entries[0].byLens["Security"]).toBe(1);
|
|
303
|
-
expect(saved.entries[0].byLens["Accessibility"]).toBe(1);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("シナリオアサインのエントリーは byScenario に記録する", () => {
|
|
307
|
-
const findings = [
|
|
308
|
-
{ id: "f1", runId: "run_1", agentId: "a1", agentName: "Alice", role: "tester", category: "ux", title: "UX", body: "", timestamp: new Date().toISOString() },
|
|
309
|
-
];
|
|
310
|
-
const scenario = { id: "s1", title: "New employee task", context: "", goal: "", constraints: "" };
|
|
311
|
-
const agentAssignments = new Map([["a1", { scenario }]]);
|
|
312
|
-
|
|
313
|
-
updateCoverage("run_1", findings, agentAssignments);
|
|
314
|
-
|
|
315
|
-
const calls = vi.mocked(fs.writeFileSync).mock.calls;
|
|
316
|
-
const written = calls[calls.length - 1][1] as string;
|
|
317
|
-
const saved = JSON.parse(written) as Coverage;
|
|
318
|
-
expect(saved.entries[0].byScenario["New employee task"]).toBe(1);
|
|
319
|
-
});
|
|
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
|
-
|
|
430
|
-
it("triage_result.json(timestamp/category を持たない集計ファイル)は無視する", () => {
|
|
431
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
432
|
-
vi.mocked(fs.readdirSync)
|
|
433
|
-
.mockReturnValueOnce(["run_1"] as unknown as ReturnType<typeof fs.readdirSync>)
|
|
434
|
-
.mockReturnValueOnce(["f1.json", "triage_result.json"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
435
|
-
|
|
436
|
-
const finding = { id: "f1", runId: "run_1", agentId: "a", agentName: "A", role: "r", title: "Bug on /settings", body: "broken at /settings", category: "bug", timestamp: new Date().toISOString() };
|
|
437
|
-
const triageResult = { runId: "run_1", completedAt: "x", issued: [], skipped: [], unprocessed: [] };
|
|
438
|
-
vi.mocked(fs.readFileSync)
|
|
439
|
-
.mockReturnValueOnce(JSON.stringify(finding) as unknown as ReturnType<typeof fs.readFileSync>)
|
|
440
|
-
.mockReturnValueOnce(JSON.stringify(triageResult) as unknown as ReturnType<typeof fs.readFileSync>);
|
|
441
|
-
|
|
442
|
-
const hotspots = getFindingHotspots();
|
|
443
|
-
const settings = hotspots.find((h) => h.pathPrefix === "/settings");
|
|
444
|
-
expect(settings?.totalFindings).toBe(1);
|
|
445
|
-
expect(settings?.categories).toEqual({ bug: 1 });
|
|
446
|
-
expect(Object.keys(settings?.categories ?? {})).not.toContain("undefined");
|
|
447
|
-
});
|
|
448
|
-
});
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { groupSimilarFindings, findCrossRunDuplicates } from "../cross-run-dedup";
|
|
3
|
-
import type { Finding } from "../types";
|
|
4
|
-
|
|
5
|
-
function mockFinding(overrides: Partial<Finding> = {}): Finding {
|
|
6
|
-
return {
|
|
7
|
-
id: "f1",
|
|
8
|
-
runId: "run_1",
|
|
9
|
-
agentId: "a1",
|
|
10
|
-
agentName: "Alice",
|
|
11
|
-
role: "tester",
|
|
12
|
-
title: "Untitled",
|
|
13
|
-
body: "",
|
|
14
|
-
category: "bug",
|
|
15
|
-
timestamp: new Date().toISOString(),
|
|
16
|
-
...overrides,
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe("groupSimilarFindings", () => {
|
|
21
|
-
it("空配列 → 空配列", () => {
|
|
22
|
-
expect(groupSimilarFindings([])).toEqual([]);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("似ていない finding は別クラスタになる", () => {
|
|
26
|
-
const a = mockFinding({ id: "a", title: "Login button broken", body: "clicking login does nothing" });
|
|
27
|
-
const b = mockFinding({ id: "b", title: "Dark mode toggle missing", body: "no way to switch themes" });
|
|
28
|
-
const clusters = groupSimilarFindings([a, b]);
|
|
29
|
-
expect(clusters).toHaveLength(2);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("似ている finding は同じクラスタになる(run をまたいでも)", () => {
|
|
33
|
-
const a = mockFinding({
|
|
34
|
-
id: "a", runId: "run_1",
|
|
35
|
-
title: "Dashboard metric not accessible via API",
|
|
36
|
-
body: "The dashboard shows a metric card but there is no API endpoint to retrieve it.",
|
|
37
|
-
});
|
|
38
|
-
const b = mockFinding({
|
|
39
|
-
id: "b", runId: "run_2",
|
|
40
|
-
title: "Dashboard metrics not accessible via API",
|
|
41
|
-
body: "The dashboard metric card has no corresponding API endpoint to retrieve it.",
|
|
42
|
-
});
|
|
43
|
-
const clusters = groupSimilarFindings([a, b]);
|
|
44
|
-
expect(clusters).toHaveLength(1);
|
|
45
|
-
expect(clusters[0]).toHaveLength(2);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("3件中2件が似ている → 2クラスタ(似ているペア + 単独1件)", () => {
|
|
49
|
-
const a = mockFinding({ id: "a", title: "Profile update API missing", body: "no endpoint to update profile" });
|
|
50
|
-
const b = mockFinding({ id: "b", title: "Profile update API is missing", body: "no endpoint exists to update the profile" });
|
|
51
|
-
const c = mockFinding({ id: "c", title: "Dark mode toggle missing", body: "no way to switch themes at all" });
|
|
52
|
-
const clusters = groupSimilarFindings([a, b, c]);
|
|
53
|
-
expect(clusters).toHaveLength(2);
|
|
54
|
-
const sizes = clusters.map((cl) => cl.length).sort();
|
|
55
|
-
expect(sizes).toEqual([1, 2]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("threshold を下げるとより緩く同一クラスタになる", () => {
|
|
59
|
-
const a = mockFinding({ id: "a", title: "Cannot export dashboard data", body: "no CSV export button" });
|
|
60
|
-
const b = mockFinding({ id: "b", title: "Missing budgeting dashboard", body: "no way to adjust project budget" });
|
|
61
|
-
const clustersStrict = groupSimilarFindings([a, b], 0.9);
|
|
62
|
-
const clustersLoose = groupSimilarFindings([a, b], 0.01);
|
|
63
|
-
expect(clustersStrict).toHaveLength(2);
|
|
64
|
-
expect(clustersLoose).toHaveLength(1);
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe("findCrossRunDuplicates", () => {
|
|
69
|
-
it("単独 finding しかない場合は空配列", () => {
|
|
70
|
-
const a = mockFinding({ id: "a", title: "Login button broken", body: "clicking login does nothing" });
|
|
71
|
-
const b = mockFinding({ id: "b", title: "Dark mode toggle missing", body: "no way to switch themes" });
|
|
72
|
-
expect(findCrossRunDuplicates([a, b])).toEqual([]);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("2件以上のクラスタのみ返す", () => {
|
|
76
|
-
const a = mockFinding({ id: "a", runId: "run_1", title: "Dashboard metric not accessible via API", body: "no endpoint for the metric card" });
|
|
77
|
-
const b = mockFinding({ id: "b", runId: "run_2", title: "Dashboard metrics not accessible via API", body: "no endpoint for the metric card" });
|
|
78
|
-
const c = mockFinding({ id: "c", runId: "run_3", title: "Dark mode toggle missing", body: "no way to switch themes" });
|
|
79
|
-
const result = findCrossRunDuplicates([a, b, c]);
|
|
80
|
-
expect(result).toHaveLength(1);
|
|
81
|
-
expect(result[0].map((f) => f.runId).sort()).toEqual(["run_1", "run_2"]);
|
|
82
|
-
});
|
|
83
|
-
});
|