@iceinvein/agent-skills 0.1.24 → 0.1.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iceinvein/agent-skills",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Install agent skills into AI coding tools",
5
5
  "author": "iceinvein",
6
6
  "license": "MIT",
@@ -33,11 +33,26 @@ Inside Claude Code, ask: "Review PR 1234" (or paste a PR URL). The agent reads S
33
33
 
34
34
  ```
35
35
  bun install # Installs @types/bun
36
- bun test # Run all tests (145 tests, 24 files)
36
+ bun test # Run all tests
37
37
  bun run lint # Biome check
38
38
  bun run typecheck # tsc --noEmit
39
39
  ```
40
40
 
41
+ ### Previewing the UI
42
+
43
+ Use the bundled example PR fixture to render either page without a real review. Useful for design iteration.
44
+
45
+ ```
46
+ magpie preview # render both pages, open findings.html
47
+ magpie preview --page progress --stage fresh # progress page at "everything pending"
48
+ magpie preview --page progress --stage specialists-running
49
+ magpie preview --page progress --stage peer-review-error
50
+ magpie preview --no-open --out /tmp/magpie-ui # write files only
51
+ magpie preview --list-stages # see all stage presets
52
+ ```
53
+
54
+ The fixture lives at `fixtures/example-pr/` (pr.json + findings.final.json + post-status.json) and covers all four severities, all five focus domains, every section combination (with/without suggestion code block, with/without verification, raw-prose fallback), and a mix of posted / failed / fresh badges.
55
+
41
56
  ## Layout
42
57
 
43
58
  - `SKILL.md` is the agent-facing prompt; installed by the agent-skills CLI.
@@ -15,11 +15,32 @@ Subcommands:
15
15
  status <run-dir> Print highest completed stage
16
16
  open [id] Open findings.html in your browser (defaults to latest run)
17
17
  post <run-dir> --ids a,b Post the given finding ids via gh (rich body + optional summary)
18
+ preview [opts] Render the UI from a bundled fixture (no PR needed). See --help-preview.
18
19
  --list-runs List archived runs
19
20
  --cleanup-run <id> Delete an archived run
20
21
  --version Print the magpie version
21
22
  --help Show this message`
22
23
 
24
+ const USAGE_PREVIEW = `Usage: magpie preview [options]
25
+
26
+ Render the findings and/or progress pages from a bundled example PR fixture so
27
+ you can iterate on the UI without running a real review.
28
+
29
+ Options:
30
+ --page <findings|progress|both> Which page to render (default: both)
31
+ --stage <preset> Pipeline state preset for the progress page
32
+ (default: report-done)
33
+ --fixture <dir> Override the fixture directory
34
+ --out <dir> Where to write the HTML (default: ~/.magpie/preview-<ts>)
35
+ --no-open Don't open the page in your browser
36
+ --dry-run Print the paths that would be written, write nothing
37
+ --list-stages List the known --stage presets and exit
38
+ --help Show this message
39
+
40
+ Stage presets: fresh, setup-running, setup-done, context-skipped,
41
+ specialists-running, specialists-done, dedupe-done, critic-done,
42
+ peer-review-error, report-done, post-done.`
43
+
23
44
  type Handler = (args: string[]) => Promise<number> | number
24
45
 
25
46
  const HANDLERS: Record<string, Handler> = {
@@ -178,6 +199,45 @@ const HANDLERS: Record<string, Handler> = {
178
199
  process.stdout.write(`${JSON.stringify(outcome)}\n`)
179
200
  return outcome.ok ? 0 : 1
180
201
  },
202
+ preview: async (args) => {
203
+ if (args.includes('--help') || args.includes('-h')) {
204
+ process.stdout.write(`${USAGE_PREVIEW}\n`)
205
+ return 0
206
+ }
207
+ const { KNOWN_STAGE_PRESETS, DEFAULT_STAGE, runPreview } = await import(
208
+ '../scripts/preview-cmd.ts'
209
+ )
210
+ if (args.includes('--list-stages')) {
211
+ for (const s of KNOWN_STAGE_PRESETS) process.stdout.write(`${s}\n`)
212
+ return 0
213
+ }
214
+ const pageFlag = args.indexOf('--page')
215
+ const pageRaw = pageFlag !== -1 ? args[pageFlag + 1] : 'both'
216
+ if (pageRaw !== 'findings' && pageRaw !== 'progress' && pageRaw !== 'both') {
217
+ process.stderr.write(`preview: invalid --page ${pageRaw} (want findings|progress|both)\n`)
218
+ return 2
219
+ }
220
+ const stageFlag = args.indexOf('--stage')
221
+ const stageRaw = stageFlag !== -1 ? args[stageFlag + 1] : DEFAULT_STAGE
222
+ if (!stageRaw || !KNOWN_STAGE_PRESETS.includes(stageRaw as never)) {
223
+ process.stderr.write(
224
+ `preview: invalid --stage ${stageRaw}. Use --list-stages to see available presets.\n`,
225
+ )
226
+ return 2
227
+ }
228
+ const fixtureFlag = args.indexOf('--fixture')
229
+ const outFlag = args.indexOf('--out')
230
+ const result = await runPreview({
231
+ page: pageRaw,
232
+ stage: stageRaw as never,
233
+ ...(fixtureFlag !== -1 && args[fixtureFlag + 1] ? { fixtureDir: args[fixtureFlag + 1] } : {}),
234
+ ...(outFlag !== -1 && args[outFlag + 1] ? { outDir: args[outFlag + 1] } : {}),
235
+ openInBrowser: !args.includes('--no-open') && !args.includes('--dry-run'),
236
+ dryRun: args.includes('--dry-run'),
237
+ })
238
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
239
+ return 0
240
+ },
181
241
  '--list-runs': async () => {
182
242
  const { listRuns } = await import('../scripts/housekeeping-cmd.ts')
183
243
  const runs = await listRuns()
@@ -221,5 +281,3 @@ async function main(argv: string[]): Promise<number> {
221
281
 
222
282
  const code = await main(process.argv.slice(2))
223
283
  process.exit(code)
224
-
225
- export {}
@@ -0,0 +1,137 @@
1
+ [
2
+ {
3
+ "id": "security-1",
4
+ "file": "src/api/session.ts",
5
+ "line": 87,
6
+ "severity": "blocker",
7
+ "risk": {
8
+ "impact": "critical",
9
+ "likelihood": "likely",
10
+ "confidence": "high",
11
+ "action": "must-fix"
12
+ },
13
+ "title": "Session token written to Redis with no TTL and no namespace, every tenant shares the same keyspace",
14
+ "description": "Observation: createSession() (src/api/session.ts:87) writes `session:${userId}` straight into the shared Redis instance with redis.set(key, token) and no SET EX argument. The same connection pool is shared with the marketing job runner introduced in #1102.\n\nWhy it matters: sessions never expire so a stolen token is valid forever, and the marketing runner can read every active session via SCAN. Two tenants on the same cluster will overwrite each other's sessions because user ids are not globally unique across tenants in this codebase (verified in `getUserById`).\n\nSuggested direction: namespace the key with tenant id (`t:${tenantId}:session:${userId}`), use SET ... EX <ttl>, and move sessions to a dedicated logical DB or a separate Redis with auth. Add an integration test that two tenants with the same numeric user id do not collide.\n\nNeeds verification: confirm whether the marketing job runner has SCAN access; if so, this is exploitable today.",
15
+ "suggestion": {
16
+ "body": "// src/api/session.ts\nconst SESSION_TTL_SECONDS = 60 * 60 * 8 // 8h\nconst sessionKey = (tenantId: string, userId: string) => `t:${tenantId}:session:${userId}`\n\nexport async function createSession(tenantId: string, userId: string, token: string) {\n await redis.set(sessionKey(tenantId, userId), token, 'EX', SESSION_TTL_SECONDS)\n}",
17
+ "startLine": 80,
18
+ "endLine": 95
19
+ },
20
+ "domain": "security"
21
+ },
22
+ {
23
+ "id": "bugs-1",
24
+ "file": "src/cache/invalidation.ts",
25
+ "line": 142,
26
+ "severity": "high",
27
+ "risk": {
28
+ "impact": "high",
29
+ "likelihood": "likely",
30
+ "confidence": "high",
31
+ "action": "must-fix"
32
+ },
33
+ "title": "invalidate() reads the cache version then writes it back without atomicity, two concurrent invalidations drop the second one",
34
+ "description": "Observation: invalidate() reads cache.version (src/cache/invalidation.ts:142), increments it locally, then writes it back with cache.setVersion(v + 1). Two concurrent invalidations triggered by webhooks both read v=4, both write v=5, and the second invalidation is silently lost.\n\nWhy it matters: stale entries continue serving for the next read window (up to 30s), which the team has previously flagged as the root cause of two billing incidents this quarter. The pattern is easy to miss because it only fails under burst load.\n\nSuggested direction: replace the read-then-write with redis.incr on the version key (atomic at the Redis level), or wrap the read/write in WATCH/MULTI. Add a load test that fires 50 parallel invalidations and asserts the final version is exactly initial + 50.",
35
+ "suggestion": {
36
+ "body": "// src/cache/invalidation.ts\nconst newVersion = await redis.incr(versionKey)\nreturn newVersion",
37
+ "startLine": 140,
38
+ "endLine": 150
39
+ },
40
+ "domain": "bugs"
41
+ },
42
+ {
43
+ "id": "perf-1",
44
+ "file": "src/dashboard/recent-activity.tsx",
45
+ "line": 58,
46
+ "severity": "high",
47
+ "risk": {
48
+ "impact": "high",
49
+ "likelihood": "possible",
50
+ "confidence": "medium",
51
+ "action": "should-fix"
52
+ },
53
+ "title": "RecentActivity rebuilds the entire timeline on every keystroke in the unrelated search box",
54
+ "description": "Observation: RecentActivity (src/dashboard/recent-activity.tsx:58) recomputes `timeline = events.map(toTimelineEntry).sort(byRecency)` inline in the render, with `events` coming from the parent Dashboard component whose state includes the search box value.\n\nWhy it matters: with the production median of ~3200 events per workspace, every keystroke in the search box rebuilds the whole timeline and re-renders 200+ TimelineRow components. Lighthouse traces show 380ms of scripting per keystroke on a mid-tier laptop.\n\nSuggested direction: move the search input into its own component, memoize `timeline` with useMemo keyed on events, or hoist the timeline into a selector. The fix is small but the impact is felt on every dashboard interaction.",
55
+ "domain": "performance"
56
+ },
57
+ {
58
+ "id": "code-smells-1",
59
+ "file": "src/lib/parser.ts",
60
+ "line": 156,
61
+ "severity": "medium",
62
+ "risk": {
63
+ "impact": "medium",
64
+ "likelihood": "possible",
65
+ "confidence": "high",
66
+ "action": "should-fix"
67
+ },
68
+ "title": "Three near-identical parseX functions diverge in their error handling for the same edge case",
69
+ "description": "Observation: parseHeader (src/lib/parser.ts:156), parseFooter (line 198), and parseBody (line 240) all handle the empty-line case differently: parseHeader returns null, parseFooter throws, parseBody returns an empty object. All three are called from the same dispatch loop in normalize().\n\nWhy it matters: the dispatch loop swallows the parseFooter throw inside a try/catch added in this PR (line 88), so currently parseFooter silently becomes \"no result\". Future authors adding a fourth parser will have no obvious convention to follow and the loop's catch will hide whatever they pick.\n\nSuggested direction: extract a shared parseSection(label, lines) helper that returns Result<Section, ParseError>, replace the three functions with thin wrappers, and remove the swallowing catch from normalize(). This is one of those refactors that gets harder every release.",
70
+ "domain": "code-smells"
71
+ },
72
+ {
73
+ "id": "arch-1",
74
+ "file": "src/services/auth/index.ts",
75
+ "line": 24,
76
+ "severity": "medium",
77
+ "risk": {
78
+ "impact": "medium",
79
+ "likelihood": "likely",
80
+ "confidence": "high",
81
+ "action": "should-fix"
82
+ },
83
+ "title": "AuthService now imports from src/db/users directly, breaking the layered architecture set up last quarter",
84
+ "description": "Observation: AuthService.signIn (src/services/auth/index.ts:24) now imports `userRepo` from `src/db/users` directly to read the last-login column. Every other service goes through the `UserRepository` interface in `src/domain/user.ts` so that the storage layer can be swapped during tests.\n\nWhy it matters: this is the first service to skip the interface. Once one does it the others will follow within a few sprints, and the layering set up to make in-memory tests cheap erodes. Tests that depend on real Postgres are 20x slower than the in-memory ones.\n\nSuggested direction: route the last-login read through UserRepository.findById and add the column to the User domain type, or add a UserRepository.findLastLoginAt method to keep the projection narrow. Keep the storage import inside `src/db/`.",
85
+ "suggestion": {
86
+ "body": "// src/domain/user.ts\nexport interface UserRepository {\n findById(id: UserId): Promise<User | null>\n findLastLoginAt(id: UserId): Promise<Date | null>\n}\n\n// src/services/auth/index.ts\nconst lastLoginAt = await this.userRepo.findLastLoginAt(userId)",
87
+ "startLine": 20,
88
+ "endLine": 40
89
+ },
90
+ "domain": "architecture"
91
+ },
92
+ {
93
+ "id": "bugs-2",
94
+ "file": "src/utils/format-currency.ts",
95
+ "line": 8,
96
+ "severity": "medium",
97
+ "risk": {
98
+ "impact": "medium",
99
+ "likelihood": "edge-case",
100
+ "confidence": "medium",
101
+ "action": "should-fix"
102
+ },
103
+ "title": "formatCurrency uses toFixed(2) which loses precision on JPY, KRW, and other zero-decimal currencies",
104
+ "description": "formatCurrency in src/utils/format-currency.ts:8 hardcodes value.toFixed(2) and tacks on a currency symbol from a map. For JPY a payment of 1500 yen renders as ¥1500.00, which is technically wrong but harmless visually. For KRW the localization team flagged this as a translation bug in their style guide and asked us to use Intl.NumberFormat instead. The change is small and worth doing while the formatter is being touched anyway.",
105
+ "domain": "bugs"
106
+ },
107
+ {
108
+ "id": "code-smells-2",
109
+ "file": "src/components/Button.tsx",
110
+ "line": 12,
111
+ "severity": "low",
112
+ "risk": {
113
+ "impact": "low",
114
+ "likelihood": "possible",
115
+ "confidence": "high",
116
+ "action": "consider"
117
+ },
118
+ "title": "Button accepts a boolean `primary` and a boolean `danger` and a boolean `ghost`, three flags that should be one variant prop",
119
+ "description": "Observation: Button (src/components/Button.tsx:12) now accepts `primary`, `danger`, `ghost`, and the new `secondary` flag added in this PR. The render branches on them with a chain of ternaries and the four-flag combinatorics are not all valid (primary && danger renders as danger but consumers don't know).\n\nWhy it matters: the next variant added will make the ternary unreadable and the type system isn't preventing nonsense combinations. The fix is small now and gets worse with each addition.\n\nSuggested direction: collapse to `variant: 'primary' | 'danger' | 'ghost' | 'secondary'` (default 'primary'). Migrate callers in one sweep; the four flags only have ~40 call sites across the repo.",
120
+ "domain": "code-smells"
121
+ },
122
+ {
123
+ "id": "perf-2",
124
+ "file": "vendor/legacy/heavy-parser.js",
125
+ "line": 412,
126
+ "severity": "low",
127
+ "risk": {
128
+ "impact": "low",
129
+ "likelihood": "edge-case",
130
+ "confidence": "low",
131
+ "action": "optional"
132
+ },
133
+ "title": "Synchronous regex compilation in a hot loop, but only matters if the input crosses ~50k chars",
134
+ "description": "Observation: heavy-parser.js:412 compiles a regex inside the tokenize loop instead of hoisting it to module scope.\n\nWhy it matters: at the input sizes this repo actually parses today (<5k chars) the overhead is negligible (<1ms). It would matter if the parser were ever pointed at the new bulk-import payloads the team has been discussing.\n\nNeeds verification: confirm whether the bulk-import payloads actually flow through this parser; if not, this can be left alone.",
135
+ "domain": "performance"
136
+ }
137
+ ]
@@ -0,0 +1,5 @@
1
+ {
2
+ "security-1": "posted",
3
+ "arch-1": "posted",
4
+ "perf-1": { "status": "failed", "message": "HTTP 422: pull_request_review_thread: line out of diff" }
5
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "number": 1337,
3
+ "title": "Migrate session store to Redis and introduce TextOnlyPrompt abstraction",
4
+ "author": { "login": "asha-platform" },
5
+ "headRefName": "feat/session-redis-and-prompts",
6
+ "baseRefName": "main",
7
+ "headRefOid": "9f3a8c0211dbb5fe7a82a2c1b08e0a45c2d1ee01",
8
+ "url": "https://github.com/example/repo/pull/1337"
9
+ }
@@ -155,3 +155,122 @@ test('post rejects missing --ids', async () => {
155
155
  expect(exit).toBe(2)
156
156
  expect(stderr).toContain('post: missing --ids')
157
157
  })
158
+
159
+ test('preview surfaces its own --help block with the stage preset list', async () => {
160
+ const proc = Bun.spawn(['bun', CLI, 'preview', '--help'], { stdout: 'pipe' })
161
+ const stdout = await new Response(proc.stdout).text()
162
+ const exit = await proc.exited
163
+ expect(exit).toBe(0)
164
+ expect(stdout).toContain('Usage: magpie preview')
165
+ expect(stdout).toContain('--stage')
166
+ // Sanity-check that the preset list mentions both endpoints of the pipeline.
167
+ expect(stdout).toContain('fresh')
168
+ expect(stdout).toContain('post-done')
169
+ })
170
+
171
+ test('preview --list-stages prints every known preset, one per line', async () => {
172
+ const proc = Bun.spawn(['bun', CLI, 'preview', '--list-stages'], { stdout: 'pipe' })
173
+ const stdout = await new Response(proc.stdout).text()
174
+ const exit = await proc.exited
175
+ expect(exit).toBe(0)
176
+ const presets = stdout.trim().split('\n')
177
+ expect(presets).toContain('fresh')
178
+ expect(presets).toContain('specialists-running')
179
+ expect(presets).toContain('peer-review-error')
180
+ expect(presets).toContain('report-done')
181
+ expect(presets).toContain('post-done')
182
+ })
183
+
184
+ test('preview --dry-run --no-open writes nothing and prints the planned paths', async () => {
185
+ const out = await mkdtemp(join(tmpdir(), 'magpie-cli-preview-'))
186
+ try {
187
+ // mkdtemp creates the directory; remove it so we can confirm the dry-run
188
+ // truly writes nothing on its own. The handler should still report it
189
+ // would have written here.
190
+ await rm(out, { recursive: true, force: true })
191
+ const proc = Bun.spawn(
192
+ ['bun', CLI, 'preview', '--dry-run', '--no-open', '--out', out, '--stage', 'critic-done'],
193
+ { stdout: 'pipe', stderr: 'pipe' },
194
+ )
195
+ const stdout = await new Response(proc.stdout).text()
196
+ const exit = await proc.exited
197
+ expect(exit).toBe(0)
198
+ const result = JSON.parse(stdout.trim())
199
+ expect(result.dryRun).toBe(true)
200
+ expect(result.outDir).toBe(out)
201
+ expect(result.findingsHtml).toBe(join(out, 'findings.html'))
202
+ expect(result.progressHtml).toBe(join(out, 'progress.html'))
203
+ // Dry run must not create the directory or any files inside it.
204
+ const findingsFile = Bun.file(join(out, 'findings.html'))
205
+ expect(await findingsFile.exists()).toBe(false)
206
+ } finally {
207
+ await rm(out, { recursive: true, force: true })
208
+ }
209
+ })
210
+
211
+ test('preview --no-open renders the requested page to disk and returns its path', async () => {
212
+ const out = await mkdtemp(join(tmpdir(), 'magpie-cli-preview-render-'))
213
+ try {
214
+ const proc = Bun.spawn(
215
+ [
216
+ 'bun',
217
+ CLI,
218
+ 'preview',
219
+ '--no-open',
220
+ '--page',
221
+ 'progress',
222
+ '--stage',
223
+ 'specialists-running',
224
+ '--out',
225
+ out,
226
+ ],
227
+ { stdout: 'pipe', stderr: 'pipe' },
228
+ )
229
+ const stdout = await new Response(proc.stdout).text()
230
+ const exit = await proc.exited
231
+ expect(exit).toBe(0)
232
+ const result = JSON.parse(stdout.trim())
233
+ expect(result.progressHtml).toBe(join(out, 'progress.html'))
234
+ expect(result.findingsHtml).toBeUndefined()
235
+ const html = await Bun.file(join(out, 'progress.html')).text()
236
+ // The preset has setup + context done (2/7 segments filled) and
237
+ // specialists running.
238
+ expect(html).toContain('--done-count: 2')
239
+ expect(html).toContain('class="step running"')
240
+ expect(html).toContain('data-stage="specialists"')
241
+ } finally {
242
+ await rm(out, { recursive: true, force: true })
243
+ }
244
+ })
245
+
246
+ test('preview rejects an unknown --stage with exit 2 and a hint', async () => {
247
+ const proc = Bun.spawn(['bun', CLI, 'preview', '--no-open', '--stage', 'bogus'], {
248
+ stdout: 'pipe',
249
+ stderr: 'pipe',
250
+ })
251
+ const stderr = await new Response(proc.stderr).text()
252
+ const exit = await proc.exited
253
+ expect(exit).toBe(2)
254
+ expect(stderr).toContain('preview: invalid --stage bogus')
255
+ expect(stderr).toContain('--list-stages')
256
+ })
257
+
258
+ test('preview rejects an unknown --page with exit 2', async () => {
259
+ const proc = Bun.spawn(['bun', CLI, 'preview', '--no-open', '--page', 'sidebar'], {
260
+ stdout: 'pipe',
261
+ stderr: 'pipe',
262
+ })
263
+ const stderr = await new Response(proc.stderr).text()
264
+ const exit = await proc.exited
265
+ expect(exit).toBe(2)
266
+ expect(stderr).toContain('preview: invalid --page sidebar')
267
+ })
268
+
269
+ test('top-level --help surfaces the preview subcommand', async () => {
270
+ const proc = Bun.spawn(['bun', CLI, '--help'], { stdout: 'pipe' })
271
+ const stdout = await new Response(proc.stdout).text()
272
+ const exit = await proc.exited
273
+ expect(exit).toBe(0)
274
+ expect(stdout).toContain('preview')
275
+ expect(stdout).toContain('--help-preview')
276
+ })
@@ -0,0 +1,210 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ DEFAULT_STAGE,
7
+ KNOWN_STAGE_PRESETS,
8
+ type PreviewResult,
9
+ runPreview,
10
+ type STAGE_PRESETS,
11
+ } from '../preview-cmd.ts'
12
+
13
+ let workDir: string
14
+
15
+ beforeEach(async () => {
16
+ workDir = await mkdtemp(join(tmpdir(), 'magpie-preview-test-'))
17
+ })
18
+
19
+ afterEach(async () => {
20
+ await rm(workDir, { recursive: true, force: true })
21
+ })
22
+
23
+ // Captures any opener invocations so we can assert without spawning a real OS
24
+ // open command in tests.
25
+ function withCapturedOpener(): { calls: string[]; factory: () => (path: string) => Promise<void> } {
26
+ const calls: string[] = []
27
+ return {
28
+ calls,
29
+ factory: () => async (path: string) => {
30
+ calls.push(path)
31
+ },
32
+ }
33
+ }
34
+
35
+ test('renders both pages by default from the bundled fixture', async () => {
36
+ const opener = withCapturedOpener()
37
+ const result = await runPreview({
38
+ page: 'both',
39
+ stage: DEFAULT_STAGE,
40
+ outDir: workDir,
41
+ openerFactory: opener.factory,
42
+ })
43
+ expect(result.findingsHtml).toBe(join(workDir, 'findings.html'))
44
+ expect(result.progressHtml).toBe(join(workDir, 'progress.html'))
45
+ const findings = await readFile(result.findingsHtml as string, 'utf8')
46
+ const progress = await readFile(result.progressHtml as string, 'utf8')
47
+ expect(findings).toContain('PR #1337')
48
+ expect(findings).toContain('feat/session-redis-and-prompts')
49
+ // The fixture covers all four severities so the breakdown line should
50
+ // include each one.
51
+ expect(findings).toContain('blocker')
52
+ expect(findings).toContain('high')
53
+ expect(findings).toContain('medium')
54
+ expect(findings).toContain('low')
55
+ expect(progress).toContain('PR #1337')
56
+ // Default stage is report-done so the connector should fill 7/7 segments
57
+ // before the final post stage.
58
+ expect(progress).toContain('--done-count: 7')
59
+ })
60
+
61
+ test('honors --page findings: progress is not rendered', async () => {
62
+ const opener = withCapturedOpener()
63
+ const result = await runPreview({
64
+ page: 'findings',
65
+ stage: DEFAULT_STAGE,
66
+ outDir: workDir,
67
+ openerFactory: opener.factory,
68
+ })
69
+ expect(result.findingsHtml).toBeDefined()
70
+ expect(result.progressHtml).toBeUndefined()
71
+ })
72
+
73
+ test('honors --page progress: findings is not rendered', async () => {
74
+ const opener = withCapturedOpener()
75
+ const result = await runPreview({
76
+ page: 'progress',
77
+ stage: 'specialists-running',
78
+ outDir: workDir,
79
+ openerFactory: opener.factory,
80
+ })
81
+ expect(result.progressHtml).toBeDefined()
82
+ expect(result.findingsHtml).toBeUndefined()
83
+ const html = await readFile(result.progressHtml as string, 'utf8')
84
+ expect(html).toContain('class="step running"')
85
+ })
86
+
87
+ test('--no-open suppresses the browser opener', async () => {
88
+ const opener = withCapturedOpener()
89
+ await runPreview({
90
+ page: 'both',
91
+ stage: DEFAULT_STAGE,
92
+ outDir: workDir,
93
+ openInBrowser: false,
94
+ openerFactory: opener.factory,
95
+ })
96
+ expect(opener.calls).toEqual([])
97
+ })
98
+
99
+ test('opener is called with the findings path when both pages are rendered', async () => {
100
+ const opener = withCapturedOpener()
101
+ const result = await runPreview({
102
+ page: 'both',
103
+ stage: DEFAULT_STAGE,
104
+ outDir: workDir,
105
+ openInBrowser: true,
106
+ openerFactory: opener.factory,
107
+ })
108
+ expect(opener.calls).toEqual([result.findingsHtml as string])
109
+ expect(result.opened).toBe(result.findingsHtml)
110
+ })
111
+
112
+ test('dry-run returns the planned paths without writing files', async () => {
113
+ const opener = withCapturedOpener()
114
+ const result = await runPreview({
115
+ page: 'both',
116
+ stage: DEFAULT_STAGE,
117
+ outDir: workDir,
118
+ dryRun: true,
119
+ openerFactory: opener.factory,
120
+ })
121
+ expect(result.dryRun).toBe(true)
122
+ expect(result.findingsHtml).toBe(join(workDir, 'findings.html'))
123
+ expect(opener.calls).toEqual([])
124
+ // Nothing should have been created in workDir.
125
+ let createdAnyway = false
126
+ try {
127
+ await readFile(join(workDir, 'findings.html'), 'utf8')
128
+ createdAnyway = true
129
+ } catch {
130
+ // expected
131
+ }
132
+ expect(createdAnyway).toBe(false)
133
+ })
134
+
135
+ test('every stage preset is renderable end-to-end', async () => {
136
+ for (const stage of KNOWN_STAGE_PRESETS) {
137
+ const stageDir = join(workDir, stage)
138
+ const opener = withCapturedOpener()
139
+ const result: PreviewResult = await runPreview({
140
+ page: 'progress',
141
+ stage,
142
+ outDir: stageDir,
143
+ openInBrowser: false,
144
+ openerFactory: opener.factory,
145
+ })
146
+ const html = await readFile(result.progressHtml as string, 'utf8')
147
+ expect(html).toContain('class="pipeline"')
148
+ }
149
+ })
150
+
151
+ test('--done-count in the progress page reflects the chosen preset', async () => {
152
+ const cases: Array<{ stage: keyof typeof STAGE_PRESETS; expectedDone: number }> = [
153
+ { stage: 'fresh', expectedDone: 0 },
154
+ { stage: 'setup-done', expectedDone: 1 },
155
+ { stage: 'specialists-done', expectedDone: 3 },
156
+ { stage: 'critic-done', expectedDone: 5 },
157
+ { stage: 'report-done', expectedDone: 7 },
158
+ { stage: 'post-done', expectedDone: 8 },
159
+ ]
160
+ for (const { stage, expectedDone } of cases) {
161
+ const stageDir = join(workDir, stage)
162
+ await runPreview({
163
+ page: 'progress',
164
+ stage,
165
+ outDir: stageDir,
166
+ openInBrowser: false,
167
+ })
168
+ const html = await readFile(join(stageDir, 'progress.html'), 'utf8')
169
+ expect(html).toContain(`--done-count: ${expectedDone}`)
170
+ }
171
+ })
172
+
173
+ test('peer-review-error preset surfaces the error step in the rendered HTML', async () => {
174
+ await runPreview({
175
+ page: 'progress',
176
+ stage: 'peer-review-error',
177
+ outDir: workDir,
178
+ openInBrowser: false,
179
+ })
180
+ const html = await readFile(join(workDir, 'progress.html'), 'utf8')
181
+ expect(html).toContain('class="step error"')
182
+ expect(html).toContain('data-stage="peer-review"')
183
+ })
184
+
185
+ test('mixed post-status in the fixture surfaces both posted and failed badges', async () => {
186
+ await runPreview({
187
+ page: 'findings',
188
+ stage: DEFAULT_STAGE,
189
+ outDir: workDir,
190
+ openInBrowser: false,
191
+ })
192
+ const html = await readFile(join(workDir, 'findings.html'), 'utf8')
193
+ expect(html).toContain('class="badge posted"')
194
+ expect(html).toContain('class="badge failed"')
195
+ // The fixture's failed entry includes a 422 message; make sure it survives.
196
+ expect(html).toContain('422')
197
+ })
198
+
199
+ test('bundled fixture covers all five focus domains', async () => {
200
+ await runPreview({
201
+ page: 'findings',
202
+ stage: DEFAULT_STAGE,
203
+ outDir: workDir,
204
+ openInBrowser: false,
205
+ })
206
+ const html = await readFile(join(workDir, 'findings.html'), 'utf8')
207
+ for (const domain of ['security', 'bugs', 'performance', 'code-smells', 'architecture']) {
208
+ expect(html).toContain(`data-filter-value="${domain}"`)
209
+ }
210
+ })