@m8i-51/shoal 0.1.0

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 ADDED
@@ -0,0 +1,121 @@
1
+ [日本語版はこちら](README_JA.md)
2
+
3
+ # shoal
4
+
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
6
+ [![Playwright](https://img.shields.io/badge/Playwright-browser-45ba4b?logo=playwright&logoColor=white)](https://playwright.dev/)
7
+ [![Anthropic](https://img.shields.io/badge/Anthropic-Claude-blueviolet?logo=anthropic&logoColor=white)](https://www.anthropic.com/)
8
+
9
+ Point it at any web app. Agents explore it and file GitHub Issues.
10
+
11
+ shoal drops a swarm of agents onto a web app. Each agent has a distinct persona and evaluation lens — accessibility, security, business logic, data integrity, new user experience. They explore independently via API and real browser, then a triage agent deduplicates findings and files GitHub Issues.
12
+
13
+ No test scripts. No test data. No prior knowledge of the app required.
14
+
15
+ ---
16
+
17
+ ## How it works
18
+
19
+ ```
20
+ Target App (any URL)
21
+
22
+ ▼ autonomously learns what the app does
23
+ Product Discovery
24
+
25
+ ▼ generates a user persona team for that app
26
+ Org Design
27
+
28
+ ▼ creates and maintains the agent roster
29
+ HR Agent
30
+
31
+ ├──────────────────────────────────┐
32
+ ▼ ▼
33
+ API Agents ×N Browser Agents ×N
34
+ explore via API browse the real UI
35
+ │ │
36
+ └──────────────┬───────────────────┘
37
+ ▼ deduplicates and files GitHub Issues
38
+ Triage Agent
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Quick Start
44
+
45
+ ```bash
46
+ git clone https://github.com/m8i-51/shoal
47
+ cd shoal
48
+ npm install && npx playwright install chromium
49
+ cp .env.example .env # set ANTHROPIC_API_KEY and BASE_URL
50
+ npm start
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Configuration
56
+
57
+ | Variable | Default | Description |
58
+ |---|---|---|
59
+ | `TARGET` | `none` | Target config name (`example` \| `none` \| your custom name) |
60
+ | `BASE_URL` | `http://localhost:3000` | Target app URL |
61
+ | `MAX_EXPLORERS` | `4` | API explorer agent count (0 to disable) |
62
+ | `MAX_BROWSERS` | `2` | Browser agent count |
63
+ | `ANTHROPIC_API_KEY` | — | Required |
64
+ | `GITHUB_TOKEN` | — | Optional — enables Issue creation |
65
+ | `GITHUB_REPO` | — | `owner/repo` format |
66
+
67
+ ---
68
+
69
+ ## Adding a target
70
+
71
+ shoal loads `shoal.config.ts` from the **current working directory** at startup. Two common setups:
72
+
73
+ **Option A — config inside the shoal repo** (simplest)
74
+
75
+ ```bash
76
+ cp shoal.config.example.ts shoal.config.ts
77
+ # edit shoal.config.ts, then:
78
+ npm start
79
+ ```
80
+
81
+ **Option B — config in your project directory** (keeps shoal untouched)
82
+
83
+ ```bash
84
+ cp /path/to/shoal/shoal.config.example.ts ./shoal.config.ts
85
+ # edit shoal.config.ts, then run shoal from your project root:
86
+ BASE_URL=http://localhost:3000 npm start --prefix /path/to/shoal
87
+ ```
88
+
89
+ `shoal.config.ts` must export a `target` object with two fields:
90
+
91
+ ```typescript
92
+ // shoal.config.ts
93
+ export const target = {
94
+ appTools: [
95
+ { name: "list_items", description: "Get all items.", input_schema: { type: "object", properties: {}, required: [] } },
96
+ ],
97
+ async execute(toolName: string, input: Record<string, unknown>) {
98
+ if (toolName === "list_items") {
99
+ return fetch(`${process.env.BASE_URL}/api/items`).then(r => r.json());
100
+ }
101
+ },
102
+ };
103
+ ```
104
+
105
+ Alternatively, copy `targets/example.ts`, register it in `targets/index.ts`, and set `TARGET=my-app`.
106
+
107
+ ---
108
+
109
+ ## LLM providers
110
+
111
+ shoal defaults to Anthropic Claude. To use a different provider, set these variables in `.env`:
112
+
113
+ | Provider | Variables |
114
+ |---|---|
115
+ | Anthropic (default) | `ANTHROPIC_API_KEY` |
116
+ | OpenAI | `LLM_PROVIDER=openai`, `LLM_API_KEY`, `LLM_MODEL` |
117
+ | Codex (ChatGPT subscription) | run `npm run auth:codex` once, then `LLM_PROVIDER=codex` |
118
+ | Ollama | `LLM_BASE_URL=http://localhost:11434/v1`, `LLM_MODEL` |
119
+ | LM Studio | `LLM_BASE_URL=http://localhost:1234/v1`, `LLM_MODEL` |
120
+
121
+ See `.env.example` for full examples.
package/bin/shoal.js ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * shoal CLI entry point
4
+ *
5
+ * Usage:
6
+ * npx shoal # run exploration
7
+ * npx shoal triage # triage-only mode
8
+ * npx shoal serve # local web dashboard
9
+ */
10
+ import { spawn, spawnSync } from "child_process";
11
+ import { fileURLToPath } from "url";
12
+ import { dirname, join } from "path";
13
+ import { existsSync } from "fs";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const packageRoot = join(__dirname, "..");
17
+
18
+ const subcommand = process.argv[2];
19
+
20
+ // serve の場合、web/dist が存在しなければ自動ビルドする
21
+ if (subcommand === "serve") {
22
+ const distIndex = join(packageRoot, "web", "dist", "index.html");
23
+ const webSrc = join(packageRoot, "web", "src");
24
+ if (!existsSync(distIndex) && existsSync(webSrc)) {
25
+ console.log("[shoal] web/dist not found — building frontend...");
26
+ const viteBin = join(packageRoot, "node_modules", ".bin", "vite");
27
+ const buildBin = existsSync(viteBin) ? viteBin : "vite";
28
+ const result = spawnSync(buildBin, ["build", "web"], {
29
+ stdio: "inherit",
30
+ cwd: packageRoot,
31
+ });
32
+ if (result.status !== 0) {
33
+ console.error("[shoal] Frontend build failed. Run: npm run build:web");
34
+ process.exit(1);
35
+ }
36
+ }
37
+ }
38
+
39
+ const scriptMap = {
40
+ serve: "server/index.ts",
41
+ triage: "triage-only.ts",
42
+ };
43
+ const script = scriptMap[subcommand] ?? "run.ts";
44
+
45
+ // tsx の bin を package 内から解決し、なければ PATH にフォールバック
46
+ const tsxBin = join(packageRoot, "node_modules", ".bin", "tsx");
47
+ const bin = existsSync(tsxBin) ? tsxBin : "tsx";
48
+ const scriptPath = join(packageRoot, script);
49
+
50
+ const child = spawn(bin, [scriptPath, ...process.argv.slice(subcommand ? 3 : 2)], {
51
+ stdio: "inherit",
52
+ env: process.env,
53
+ cwd: process.cwd(),
54
+ });
55
+
56
+ child.on("exit", (code) => process.exit(code ?? 0));
@@ -0,0 +1,232 @@
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 } 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
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ function setupMockCoverage(coverage: Coverage) {
30
+ vi.mocked(fs.existsSync).mockReturnValue(true);
31
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(coverage) as unknown as ReturnType<typeof fs.readFileSync>);
32
+ }
33
+
34
+ describe("loadCoverage", () => {
35
+ it("空ファイルが存在しない場合は空のエントリーを返す", () => {
36
+ vi.mocked(fs.existsSync).mockReturnValue(false);
37
+ const result = loadCoverage();
38
+ expect(result).toEqual({ entries: [] });
39
+ });
40
+
41
+ it("ファイルが存在する場合はパースして返す", () => {
42
+ const coverage: Coverage = { entries: [makeEntry({ runId: "run_1" })] };
43
+ setupMockCoverage(coverage);
44
+ const result = loadCoverage();
45
+ expect(result.entries).toHaveLength(1);
46
+ expect(result.entries[0].runId).toBe("run_1");
47
+ });
48
+ });
49
+
50
+ describe("computeWeightedSummary", () => {
51
+ beforeEach(() => {
52
+ vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
53
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
54
+ });
55
+
56
+ it("エントリーがない場合は空サマリーを返す", () => {
57
+ vi.mocked(fs.existsSync).mockReturnValue(false);
58
+ const result = computeWeightedSummary();
59
+ expect(result.totalWeighted).toBe(0);
60
+ expect(result.formatted).toContain("no coverage data");
61
+ });
62
+
63
+ it("直近のエントリーは重みが高い", () => {
64
+ const now = Date.now();
65
+ const recentEntry = makeEntry({
66
+ runId: "recent",
67
+ timestamp: new Date(now).toISOString(),
68
+ findingsCount: 10,
69
+ byLens: { Accessibility: 10 },
70
+ });
71
+ const oldEntry = makeEntry({
72
+ runId: "old",
73
+ timestamp: new Date(now - halfLifeMs * 2).toISOString(), // 半減期2倍 = weight 0.25
74
+ findingsCount: 10,
75
+ byLens: { Accessibility: 10 },
76
+ });
77
+ setupMockCoverage({ entries: [oldEntry, recentEntry] });
78
+
79
+ const result = computeWeightedSummary();
80
+ // recent(weight≈1.0) + old(weight≈0.25) なので合計は約 12.5
81
+ expect(result.totalWeighted).toBeGreaterThan(10);
82
+ expect(result.totalWeighted).toBeLessThan(15);
83
+ });
84
+
85
+ it("半減期2倍前のエントリーは重みが約0.25になる", () => {
86
+ const now = Date.now();
87
+ const oldTimestamp = new Date(now - halfLifeMs * 2).toISOString();
88
+ setupMockCoverage({
89
+ entries: [
90
+ makeEntry({
91
+ runId: "old",
92
+ timestamp: oldTimestamp,
93
+ findingsCount: 4,
94
+ byLens: { Security: 4 },
95
+ }),
96
+ ],
97
+ });
98
+
99
+ const result = computeWeightedSummary();
100
+ // weight = 0.5^2 = 0.25, findingsCount=4 → totalWeighted=1.0
101
+ expect(result.totalWeighted).toBeCloseTo(1.0, 0);
102
+ expect(result.byLens["Security"]).toBeCloseTo(1.0, 0);
103
+ });
104
+
105
+ it("カテゴリ別の集計が正しい", () => {
106
+ setupMockCoverage({
107
+ entries: [
108
+ makeEntry({
109
+ timestamp: new Date().toISOString(),
110
+ byCategory: { bug: 3, ux: 5 },
111
+ findingsCount: 8,
112
+ }),
113
+ ],
114
+ });
115
+
116
+ const result = computeWeightedSummary();
117
+ expect(result.byCategory["bug"]).toBeGreaterThan(0);
118
+ expect(result.byCategory["ux"]).toBeGreaterThan(result.byCategory["bug"]);
119
+ });
120
+
121
+ it("underrepresented レンズが formatted に含まれる", () => {
122
+ const now = Date.now();
123
+ // Accessibility は 10 件、Security は 1 件(平均 5.5 の半分未満 → underrepresented)
124
+ setupMockCoverage({
125
+ entries: [
126
+ makeEntry({
127
+ timestamp: new Date(now).toISOString(),
128
+ findingsCount: 11,
129
+ byLens: { Accessibility: 10, Security: 1 },
130
+ }),
131
+ ],
132
+ });
133
+
134
+ const result = computeWeightedSummary();
135
+ expect(result.formatted).toContain("Security");
136
+ expect(result.formatted.toLowerCase()).toContain("underrepresented");
137
+ });
138
+
139
+ it("全レンズが均等な場合は underrepresented なしのメッセージを出す", () => {
140
+ setupMockCoverage({
141
+ entries: [
142
+ makeEntry({
143
+ timestamp: new Date().toISOString(),
144
+ findingsCount: 6,
145
+ byLens: { Accessibility: 3, Security: 3 },
146
+ }),
147
+ ],
148
+ });
149
+
150
+ const result = computeWeightedSummary();
151
+ expect(result.formatted).toContain("comparable coverage");
152
+ });
153
+
154
+ it("シナリオ別の集計が結果に含まれる", () => {
155
+ setupMockCoverage({
156
+ entries: [
157
+ makeEntry({
158
+ timestamp: new Date().toISOString(),
159
+ findingsCount: 2,
160
+ byScenario: { "New employee submitting first purchase": 2 },
161
+ }),
162
+ ],
163
+ });
164
+
165
+ const result = computeWeightedSummary();
166
+ expect(result.byScenario["New employee submitting first purchase"]).toBeGreaterThan(0);
167
+ expect(result.formatted).toContain("By scenario");
168
+ });
169
+
170
+ it("MAX_ENTRIES を超えると最新30件に切り捨てる", () => {
171
+ const entries = Array.from({ length: 35 }, (_, i) =>
172
+ makeEntry({
173
+ runId: `run_${i}`,
174
+ timestamp: new Date(Date.now() - i * 1000).toISOString(),
175
+ })
176
+ );
177
+ setupMockCoverage({ entries });
178
+ vi.mocked(fs.writeFileSync).mockImplementation(() => {});
179
+
180
+ // updateCoverage が 31件目を追加してトリムすることを確認
181
+ vi.mocked(fs.existsSync).mockReturnValue(true);
182
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ entries }) as unknown as Buffer);
183
+
184
+ // computeWeightedSummary は entries をそのまま使うだけなので、
185
+ // 35件あってもエラーにならないことを確認
186
+ expect(() => computeWeightedSummary()).not.toThrow();
187
+ });
188
+ });
189
+
190
+ describe("updateCoverage", () => {
191
+ beforeEach(() => {
192
+ vi.mocked(fs.existsSync).mockReturnValue(false);
193
+ vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
194
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
195
+ });
196
+
197
+ it("新しいエントリーを追加して保存する", () => {
198
+ const findings = [
199
+ { id: "f1", runId: "run_1", agentId: "a1", agentName: "Alice", role: "tester", category: "bug", title: "Bug A", body: "", timestamp: new Date().toISOString() },
200
+ { id: "f2", runId: "run_1", agentId: "a2", agentName: "Bob", role: "tester", category: "ux", title: "UX B", body: "", timestamp: new Date().toISOString() },
201
+ ];
202
+ const agentAssignments = new Map([
203
+ ["a1", { lens: "Security: something" }],
204
+ ["a2", { lens: "Accessibility: something" }],
205
+ ]);
206
+
207
+ updateCoverage("run_1", findings, agentAssignments);
208
+
209
+ const calls = vi.mocked(fs.writeFileSync).mock.calls;
210
+ const written = calls[calls.length - 1][1] as string;
211
+ const saved = JSON.parse(written) as Coverage;
212
+ expect(saved.entries).toHaveLength(1);
213
+ expect(saved.entries[0].byCategory).toEqual({ bug: 1, ux: 1 });
214
+ expect(saved.entries[0].byLens["Security"]).toBe(1);
215
+ expect(saved.entries[0].byLens["Accessibility"]).toBe(1);
216
+ });
217
+
218
+ it("シナリオアサインのエントリーは byScenario に記録する", () => {
219
+ const findings = [
220
+ { id: "f1", runId: "run_1", agentId: "a1", agentName: "Alice", role: "tester", category: "ux", title: "UX", body: "", timestamp: new Date().toISOString() },
221
+ ];
222
+ const scenario = { id: "s1", title: "New employee task", context: "", goal: "", constraints: "" };
223
+ const agentAssignments = new Map([["a1", { scenario }]]);
224
+
225
+ updateCoverage("run_1", findings, agentAssignments);
226
+
227
+ const calls = vi.mocked(fs.writeFileSync).mock.calls;
228
+ const written = calls[calls.length - 1][1] as string;
229
+ const saved = JSON.parse(written) as Coverage;
230
+ expect(saved.entries[0].byScenario["New employee task"]).toBe(1);
231
+ });
232
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect, vi, beforeEach } 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 { generateReport } from "../report";
12
+ import type { RunLog, Finding } from "../types";
13
+ import type { TriageResult } from "../triage";
14
+ import type { ProductSpec } from "../product-discovery";
15
+
16
+ function getSavedHtml(): string {
17
+ const calls = vi.mocked(fs.writeFileSync).mock.calls;
18
+ return calls[calls.length - 1][1] as string;
19
+ }
20
+
21
+ function makeRunLog(overrides: Partial<RunLog> = {}): RunLog {
22
+ return {
23
+ runId: "run_test",
24
+ startedAt: "2026-04-27T00:00:00.000Z",
25
+ completedAt: "2026-04-27T00:05:00.000Z",
26
+ repo: "",
27
+ agents: [],
28
+ summary: {
29
+ totalAgents: 0,
30
+ completed: 0,
31
+ errors: 0,
32
+ iterationLimitReached: 0,
33
+ totalActions: 0,
34
+ totalIssuesPosted: 0,
35
+ regressionChecked: 0,
36
+ regressionFailed: 0,
37
+ rateLimitRetries: 0,
38
+ cost: { inputTokens: 0, outputTokens: 0, estimatedUSD: null },
39
+ },
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ function makeProductSpec(overrides: Partial<ProductSpec> = {}): ProductSpec {
45
+ return {
46
+ appName: "Test App",
47
+ appDescription: "A test application",
48
+ targetUsers: "Engineers",
49
+ features: "Login, Dashboard",
50
+ designContext: "",
51
+ uiFeatures: "",
52
+ appGoals: [],
53
+ confidence: "high",
54
+ sources: [],
55
+ ...overrides,
56
+ };
57
+ }
58
+
59
+ function makeFinding(overrides: Partial<Finding> = {}): Finding {
60
+ return {
61
+ id: "f1",
62
+ runId: "run_test",
63
+ agentId: "a1",
64
+ agentName: "Alice",
65
+ role: "tester",
66
+ category: "bug",
67
+ title: "Login button broken",
68
+ body: "Clicking login does nothing",
69
+ timestamp: "2026-04-27T00:01:00.000Z",
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ const emptyTriage: TriageResult = { issued: [], skipped: [], unprocessed: [], issuesCreated: 0 };
75
+
76
+ describe("generateReport", () => {
77
+ beforeEach(() => {
78
+ vi.mocked(fs.existsSync).mockReturnValue(false);
79
+ vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
80
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
81
+ });
82
+
83
+ it("ファイルパスを返す", () => {
84
+ const result = generateReport(makeRunLog(), [], emptyTriage, makeProductSpec(), [], new Map());
85
+ expect(result).toContain("report_run_test.html");
86
+ });
87
+
88
+ it("有効な HTML をファイルに書き出す", () => {
89
+ generateReport(makeRunLog(), [], emptyTriage, makeProductSpec(), [], new Map());
90
+ const html = getSavedHtml();
91
+ expect(html).toContain("<!DOCTYPE html>");
92
+ expect(html).toContain("</html>");
93
+ });
94
+
95
+ it("アプリ名がレポートに含まれる", () => {
96
+ generateReport(makeRunLog(), [], emptyTriage, makeProductSpec({ appName: "MySpecialApp" }), [], new Map());
97
+ expect(getSavedHtml()).toContain("MySpecialApp");
98
+ });
99
+
100
+ it("finding のタイトルが HTML エスケープされる", () => {
101
+ const finding = makeFinding({ title: "XSS <script>alert(1)</script>" });
102
+ generateReport(makeRunLog(), [finding], emptyTriage, makeProductSpec(), [], new Map());
103
+ const html = getSavedHtml();
104
+ expect(html).not.toContain("<script>alert(1)</script>");
105
+ expect(html).toContain("&lt;script&gt;");
106
+ });
107
+
108
+ it("issued finding に → Issue バッジが付く", () => {
109
+ const finding = makeFinding({ id: "f1" });
110
+ const triage: TriageResult = { issued: ["f1"], skipped: [], unprocessed: [], issuesCreated: 1 };
111
+ generateReport(makeRunLog(), [finding], triage, makeProductSpec(), [], new Map());
112
+ expect(getSavedHtml()).toContain("→ Issue");
113
+ });
114
+
115
+ it("skipped finding に skipped バッジが付く", () => {
116
+ const finding = makeFinding({ id: "f1" });
117
+ const triage: TriageResult = { issued: [], skipped: ["f1"], unprocessed: [], issuesCreated: 0 };
118
+ generateReport(makeRunLog(), [finding], triage, makeProductSpec(), [], new Map());
119
+ expect(getSavedHtml()).toContain("skipped");
120
+ });
121
+
122
+ it("シナリオ付きの finding にシナリオタグが付く", () => {
123
+ const finding = makeFinding({ agentId: "a1" });
124
+ const scenario = { id: "s1", title: "New employee task", context: "", goal: "", constraints: "" };
125
+ const agentAssignments = new Map([["a1", { scenario }]]);
126
+ generateReport(makeRunLog(), [finding], emptyTriage, makeProductSpec(), [scenario], agentAssignments);
127
+ const html = getSavedHtml();
128
+ expect(html).toContain("New employee task");
129
+ expect(html).toContain("scenario");
130
+ });
131
+
132
+ it("レンズ付きの finding にレンズタグが付く", () => {
133
+ const finding = makeFinding({ agentId: "a1" });
134
+ const agentAssignments = new Map([["a1", { lens: "Accessibility: keyboard navigation" }]]);
135
+ generateReport(makeRunLog(), [finding], emptyTriage, makeProductSpec(), [], agentAssignments);
136
+ const html = getSavedHtml();
137
+ expect(html).toContain("Accessibility");
138
+ expect(html).toContain("lens");
139
+ });
140
+
141
+ it("finding が issued → unprocessed → skipped の順に並ぶ", () => {
142
+ const f1 = makeFinding({ id: "f1", title: "Issued Finding" });
143
+ const f2 = makeFinding({ id: "f2", title: "Skipped Finding" });
144
+ const f3 = makeFinding({ id: "f3", title: "Unprocessed Finding" });
145
+ const triage: TriageResult = { issued: ["f1"], skipped: ["f2"], unprocessed: ["f3"], issuesCreated: 1 };
146
+ generateReport(makeRunLog(), [f2, f3, f1], triage, makeProductSpec(), [], new Map());
147
+ const html = getSavedHtml();
148
+ const issuedPos = html.indexOf("Issued Finding");
149
+ const unprocessedPos = html.indexOf("Unprocessed Finding");
150
+ const skippedPos = html.indexOf("Skipped Finding");
151
+ expect(issuedPos).toBeLessThan(unprocessedPos);
152
+ expect(unprocessedPos).toBeLessThan(skippedPos);
153
+ });
154
+ });