@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 +1 -1
- package/skills/magpie/README.md +16 -1
- package/skills/magpie/bin/magpie.ts +60 -2
- package/skills/magpie/fixtures/example-pr/findings.final.json +137 -0
- package/skills/magpie/fixtures/example-pr/post-status.json +5 -0
- package/skills/magpie/fixtures/example-pr/pr.json +9 -0
- package/skills/magpie/scripts/__tests__/cli.test.ts +119 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +210 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +51 -4
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +43 -0
- package/skills/magpie/scripts/preview-cmd.ts +277 -0
- package/skills/magpie/scripts/refresh.ts +16 -1
- package/skills/magpie/scripts/render-cmd.ts +11 -1
- package/skills/magpie/scripts/render-findings.ts +245 -86
- package/skills/magpie/scripts/render-progress.ts +120 -18
- package/skills/magpie/skill.json +4 -2
- package/skills/magpie/templates/styles.css +894 -352
package/package.json
CHANGED
package/skills/magpie/README.md
CHANGED
|
@@ -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
|
|
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,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
|
+
})
|