@iceinvein/agent-skills 0.1.24 → 0.1.26
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 +1 -0
- 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/diff.patch +86 -0
- 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 +21 -0
- package/skills/magpie/fixtures/fake-gh.sh +10 -1
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/scripts/__tests__/cli.test.ts +119 -0
- package/skills/magpie/scripts/__tests__/diff-utils.test.ts +106 -0
- package/skills/magpie/scripts/__tests__/file-tree.test.ts +97 -0
- package/skills/magpie/scripts/__tests__/gh.test.ts +8 -0
- package/skills/magpie/scripts/__tests__/helper.test.ts +63 -0
- package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +10 -0
- package/skills/magpie/scripts/__tests__/post-cmd.test.ts +90 -1
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +210 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +2 -2
- package/skills/magpie/scripts/__tests__/render-action-bar.test.ts +66 -0
- package/skills/magpie/scripts/__tests__/render-annotation.test.ts +67 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +46 -0
- package/skills/magpie/scripts/__tests__/render-diff.test.ts +84 -0
- package/skills/magpie/scripts/__tests__/render-file-tree.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +104 -182
- package/skills/magpie/scripts/__tests__/render-issues-list.test.ts +67 -0
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +86 -0
- package/skills/magpie/scripts/__tests__/server.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/types.test.ts +48 -3
- package/skills/magpie/scripts/diff-utils.ts +129 -0
- package/skills/magpie/scripts/file-tree.ts +74 -0
- package/skills/magpie/scripts/gh.ts +1 -0
- package/skills/magpie/scripts/helper.js +402 -192
- package/skills/magpie/scripts/post-cmd.ts +202 -0
- package/skills/magpie/scripts/preview-cmd.ts +300 -0
- package/skills/magpie/scripts/refresh.ts +16 -1
- package/skills/magpie/scripts/render-action-bar.ts +51 -0
- package/skills/magpie/scripts/render-annotation.ts +96 -0
- package/skills/magpie/scripts/render-cmd.ts +24 -1
- package/skills/magpie/scripts/render-diff.ts +125 -0
- package/skills/magpie/scripts/render-file-tree.ts +86 -0
- package/skills/magpie/scripts/render-findings.ts +165 -196
- package/skills/magpie/scripts/render-issues-list.ts +78 -0
- package/skills/magpie/scripts/render-progress.ts +74 -32
- package/skills/magpie/scripts/server.ts +43 -0
- package/skills/magpie/scripts/types.ts +13 -0
- package/skills/magpie/skill.json +4 -2
- package/skills/magpie/templates/styles.css +1288 -629
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
buildTree,
|
|
4
|
+
collapseTree,
|
|
5
|
+
countDirFindings,
|
|
6
|
+
type DirNode,
|
|
7
|
+
findingCountsBySeverity,
|
|
8
|
+
} from '../file-tree.ts'
|
|
9
|
+
import type { PrFileEntry, ReviewFinding } from '../types.ts'
|
|
10
|
+
|
|
11
|
+
const files: PrFileEntry[] = [
|
|
12
|
+
{ path: 'src/a.ts', additions: 5, deletions: 1 },
|
|
13
|
+
{ path: 'src/b.ts', additions: 2, deletions: 0 },
|
|
14
|
+
{ path: 'src/deep/only.ts', additions: 1, deletions: 0 },
|
|
15
|
+
{ path: 'README.md', additions: 3, deletions: 0 },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const findings: ReviewFinding[] = [
|
|
19
|
+
{
|
|
20
|
+
id: '1',
|
|
21
|
+
file: 'src/a.ts',
|
|
22
|
+
line: 1,
|
|
23
|
+
severity: 'blocker',
|
|
24
|
+
title: 't',
|
|
25
|
+
description: 'd',
|
|
26
|
+
risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
|
|
27
|
+
domain: 'security',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: '2',
|
|
31
|
+
file: 'src/a.ts',
|
|
32
|
+
line: 2,
|
|
33
|
+
severity: 'high',
|
|
34
|
+
title: 't',
|
|
35
|
+
description: 'd',
|
|
36
|
+
risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'should-fix' },
|
|
37
|
+
domain: 'bugs',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: '3',
|
|
41
|
+
file: 'src/deep/only.ts',
|
|
42
|
+
line: 1,
|
|
43
|
+
severity: 'medium',
|
|
44
|
+
title: 't',
|
|
45
|
+
description: 'd',
|
|
46
|
+
risk: { impact: 'medium', likelihood: 'possible', confidence: 'medium', action: 'consider' },
|
|
47
|
+
domain: 'bugs',
|
|
48
|
+
},
|
|
49
|
+
] as ReviewFinding[]
|
|
50
|
+
|
|
51
|
+
describe('buildTree', () => {
|
|
52
|
+
test('groups files by directory', () => {
|
|
53
|
+
const tree = buildTree(files)
|
|
54
|
+
expect(tree.dirs.has('src')).toBe(true)
|
|
55
|
+
expect(tree.files.find((f) => f.path === 'README.md')).toBeDefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('nests deep directories', () => {
|
|
59
|
+
const tree = buildTree(files)
|
|
60
|
+
const src = tree.dirs.get('src')
|
|
61
|
+
if (!src) throw new Error('expected src dir')
|
|
62
|
+
expect(src.dirs.has('deep')).toBe(true)
|
|
63
|
+
expect(src.dirs.get('deep')?.files.map((f) => f.path)).toEqual(['src/deep/only.ts'])
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('collapseTree', () => {
|
|
68
|
+
test('collapses nested single-child dirs like apps/server/lib', () => {
|
|
69
|
+
const f: PrFileEntry[] = [{ path: 'apps/server/lib/x.ts', additions: 1, deletions: 0 }]
|
|
70
|
+
const tree = collapseTree(buildTree(f))
|
|
71
|
+
const first = [...tree.dirs.values()][0]
|
|
72
|
+
if (!first) throw new Error('expected one dir')
|
|
73
|
+
expect(first.name).toBe('apps/server/lib')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('findingCountsBySeverity', () => {
|
|
78
|
+
test('returns severity counts for a file in canonical order', () => {
|
|
79
|
+
const counts = findingCountsBySeverity(findings, 'src/a.ts')
|
|
80
|
+
expect(counts).toEqual([
|
|
81
|
+
{ severity: 'blocker', count: 1 },
|
|
82
|
+
{ severity: 'high', count: 1 },
|
|
83
|
+
])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('returns empty array when the file has no findings', () => {
|
|
87
|
+
expect(findingCountsBySeverity(findings, 'README.md')).toEqual([])
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('countDirFindings', () => {
|
|
92
|
+
test('sums findings across all files in a subtree', () => {
|
|
93
|
+
const tree = collapseTree(buildTree(files))
|
|
94
|
+
const src = tree.dirs.get('src') as DirNode | undefined
|
|
95
|
+
if (src) expect(countDirFindings(findings, src)).toBe(3)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -26,6 +26,14 @@ test('fetchPr writes pr.json and diff.patch', async () => {
|
|
|
26
26
|
expect(diff).toContain('export const x = 2')
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
+
test('fetchPr includes files in pr.json', async () => {
|
|
30
|
+
const result = await fetchPr({ ghBin: FAKE_GH, prNumber: 1234, runDir })
|
|
31
|
+
expect(result.ok).toBe(true)
|
|
32
|
+
const pr = JSON.parse(await readFile(join(runDir, 'pr.json'), 'utf8'))
|
|
33
|
+
expect(Array.isArray(pr.files)).toBe(true)
|
|
34
|
+
expect(pr.files[0]).toMatchObject({ path: 'src/a.ts', additions: 1, deletions: 0 })
|
|
35
|
+
})
|
|
36
|
+
|
|
29
37
|
test('fetchPr returns ok=false when gh exits non-zero', async () => {
|
|
30
38
|
const result = await fetchPr({
|
|
31
39
|
ghBin: '/usr/bin/false',
|
|
@@ -57,3 +57,66 @@ test('helper.js sends periodic heartbeats', async () => {
|
|
|
57
57
|
expect(src).toContain("fetch('/heartbeat'")
|
|
58
58
|
expect(src).toMatch(/setInterval/)
|
|
59
59
|
})
|
|
60
|
+
|
|
61
|
+
test('helper.js dispatches set-view and toggles body.dataset.view', async () => {
|
|
62
|
+
const src = await readFile(HELPER, 'utf8')
|
|
63
|
+
expect(src).toContain('handleSetView')
|
|
64
|
+
expect(src).toMatch(/document\.body\.dataset\.view\s*=\s*view/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('helper.js handles select-file by toggling [data-file-pane] hidden', async () => {
|
|
68
|
+
const src = await readFile(HELPER, 'utf8')
|
|
69
|
+
expect(src).toContain('handleSelectFile')
|
|
70
|
+
expect(src).toContain('data-file-pane')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('helper.js handles set-diff-mode by toggling .diff-unified / .diff-split', async () => {
|
|
74
|
+
const src = await readFile(HELPER, 'utf8')
|
|
75
|
+
expect(src).toContain('handleSetDiffMode')
|
|
76
|
+
expect(src).toContain('diff-unified')
|
|
77
|
+
expect(src).toContain('diff-split')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('helper.js implements per-file finding navigation', async () => {
|
|
81
|
+
const src = await readFile(HELPER, 'utf8')
|
|
82
|
+
expect(src).toContain('navigateFinding')
|
|
83
|
+
expect(src).toContain('fileFindingIdx')
|
|
84
|
+
expect(src).toContain('is-focused')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('helper.js toggles suggestions visibility via body.dataset.showSuggestions', async () => {
|
|
88
|
+
const src = await readFile(HELPER, 'utf8')
|
|
89
|
+
expect(src).toContain('handleToggleSuggestions')
|
|
90
|
+
expect(src).toContain('showSuggestions')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('helper.js toggles severity filter via body.hide-sev-* classes', async () => {
|
|
94
|
+
const src = await readFile(HELPER, 'utf8')
|
|
95
|
+
expect(src).toContain('handleFilterSev')
|
|
96
|
+
expect(src).toContain('hide-sev-')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('helper.js wires select-sev and select-recommended', async () => {
|
|
100
|
+
const src = await readFile(HELPER, 'utf8')
|
|
101
|
+
expect(src).toContain('handleSelectSev')
|
|
102
|
+
expect(src).toContain('handleSelectRecommended')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('helper.js updates selected-count via [data-role="selected-count"]', async () => {
|
|
106
|
+
const src = await readFile(HELPER, 'utf8')
|
|
107
|
+
expect(src).toContain('updateSelectedCount')
|
|
108
|
+
expect(src).toContain('selected-count')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('helper.js POSTs to /api/post-review when Post Selected is wired', async () => {
|
|
112
|
+
const src = await readFile(HELPER, 'utf8')
|
|
113
|
+
expect(src).toContain('/api/post-review')
|
|
114
|
+
expect(src).toContain('handlePostSelected')
|
|
115
|
+
expect(src).toContain('handlePostRecommended')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('helper.js applies review result to data-posted on matching annotations', async () => {
|
|
119
|
+
const src = await readFile(HELPER, 'utf8')
|
|
120
|
+
expect(src).toContain('applyReviewResult')
|
|
121
|
+
expect(src).toContain("setAttribute('data-posted'")
|
|
122
|
+
})
|
|
@@ -77,6 +77,16 @@ test('setup -> dedupe -> render(progress) -> render(findings) -> cleanup compose
|
|
|
77
77
|
expect(screen).toContain('progress.html')
|
|
78
78
|
expect(screen).toContain('findings.html')
|
|
79
79
|
|
|
80
|
+
// Verify Pylon-style DOM markers in findings.html
|
|
81
|
+
const findingsHtml = await Bun.file(join(runDir, 'screen', 'findings.html')).text()
|
|
82
|
+
expect(findingsHtml.includes('class="pr-header"')).toBe(true)
|
|
83
|
+
expect(findingsHtml.includes('data-finding-id')).toBe(true)
|
|
84
|
+
expect(findingsHtml.includes('data-file-pane')).toBe(true)
|
|
85
|
+
expect(findingsHtml.includes('data-role="action-bar"')).toBe(true)
|
|
86
|
+
expect(findingsHtml.includes('data-action="post-recommended"')).toBe(true)
|
|
87
|
+
expect(findingsHtml.includes('class="annot-body')).toBe(true)
|
|
88
|
+
expect(findingsHtml.includes('class="issue-card')).toBe(true)
|
|
89
|
+
|
|
80
90
|
expect(await runCleanup({ runDir, repoPath: repo, gitBin: 'git' })).toBe(0)
|
|
81
91
|
const parent = dirname(runDir)
|
|
82
92
|
const entries = await readdir(parent)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
formatPostBody,
|
|
9
9
|
formatReviewSummaryBody,
|
|
10
10
|
parseRepoFromUrl,
|
|
11
|
+
postFindingsAsReview,
|
|
11
12
|
runPost,
|
|
12
13
|
} from '../post-cmd.ts'
|
|
13
14
|
|
|
@@ -331,3 +332,91 @@ test('runPost appends post stage events to log.jsonl', async () => {
|
|
|
331
332
|
expect(log).toContain('"status":"start"')
|
|
332
333
|
expect(log).toContain('"status":"dry-run"')
|
|
333
334
|
})
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// postFindingsAsReview
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
async function scaffoldRunDir(findings: unknown[]): Promise<string> {
|
|
341
|
+
const dir = await mkdtemp(join(tmpdir(), 'magpie-post-review-'))
|
|
342
|
+
await writeFile(join(dir, 'findings.json'), JSON.stringify(findings))
|
|
343
|
+
return dir
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const inlineFinding = (id: string, line: number) => ({
|
|
347
|
+
id,
|
|
348
|
+
file: 'src/a.ts',
|
|
349
|
+
line,
|
|
350
|
+
severity: 'high',
|
|
351
|
+
title: `Finding ${id}`,
|
|
352
|
+
description: 'd',
|
|
353
|
+
risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
|
|
354
|
+
domain: 'security',
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
describe('postFindingsAsReview', () => {
|
|
358
|
+
test('builds a payload with N inline comments under dryRun', async () => {
|
|
359
|
+
const dir = await scaffoldRunDir([
|
|
360
|
+
inlineFinding('1', 10),
|
|
361
|
+
inlineFinding('2', 20),
|
|
362
|
+
inlineFinding('3', 30),
|
|
363
|
+
])
|
|
364
|
+
const r = await postFindingsAsReview({
|
|
365
|
+
runDir: dir,
|
|
366
|
+
findingIds: ['1', '2', '3'],
|
|
367
|
+
prNumber: 42,
|
|
368
|
+
headSha: 'abc123',
|
|
369
|
+
dryRun: true,
|
|
370
|
+
})
|
|
371
|
+
expect(r.command?.[0]).toBe('gh')
|
|
372
|
+
expect(r.command?.[2]).toBe('repos/{owner}/{repo}/pulls/42/reviews')
|
|
373
|
+
expect(r.payload).toBeDefined()
|
|
374
|
+
const parsed = JSON.parse(r.payload as string) as {
|
|
375
|
+
comments: Array<{ path: string; line: number; side: string }>
|
|
376
|
+
}
|
|
377
|
+
expect(parsed.comments).toHaveLength(3)
|
|
378
|
+
expect(parsed.comments[0]).toMatchObject({ path: 'src/a.ts', line: 10, side: 'RIGHT' })
|
|
379
|
+
expect(parsed.comments[1]).toMatchObject({ path: 'src/a.ts', line: 20, side: 'RIGHT' })
|
|
380
|
+
expect(parsed.comments[2]).toMatchObject({ path: 'src/a.ts', line: 30, side: 'RIGHT' })
|
|
381
|
+
await rm(dir, { recursive: true, force: true })
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test('routes line-less findings into the review body', async () => {
|
|
385
|
+
const dir = await scaffoldRunDir([
|
|
386
|
+
{
|
|
387
|
+
id: '1',
|
|
388
|
+
file: null,
|
|
389
|
+
line: null,
|
|
390
|
+
severity: 'high',
|
|
391
|
+
title: 'general note',
|
|
392
|
+
description: 'd',
|
|
393
|
+
risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
|
|
394
|
+
domain: 'architecture',
|
|
395
|
+
},
|
|
396
|
+
])
|
|
397
|
+
const r = await postFindingsAsReview({
|
|
398
|
+
runDir: dir,
|
|
399
|
+
findingIds: ['1'],
|
|
400
|
+
prNumber: 42,
|
|
401
|
+
headSha: 'abc123',
|
|
402
|
+
dryRun: true,
|
|
403
|
+
})
|
|
404
|
+
const parsed = JSON.parse(r.payload as string) as { body: string; comments: unknown[] }
|
|
405
|
+
expect(parsed.comments).toHaveLength(0)
|
|
406
|
+
expect(parsed.body).toContain('general note')
|
|
407
|
+
await rm(dir, { recursive: true, force: true })
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test('returns comments for unknown ids under dryRun', async () => {
|
|
411
|
+
const dir = await scaffoldRunDir([])
|
|
412
|
+
const r = await postFindingsAsReview({
|
|
413
|
+
runDir: dir,
|
|
414
|
+
findingIds: ['missing'],
|
|
415
|
+
prNumber: 42,
|
|
416
|
+
headSha: 'abc123',
|
|
417
|
+
dryRun: true,
|
|
418
|
+
})
|
|
419
|
+
expect(r.comments.find((c) => c.id === 'missing')).toBeDefined()
|
|
420
|
+
await rm(dir, { recursive: true, force: true })
|
|
421
|
+
})
|
|
422
|
+
})
|
|
@@ -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="status-chip posted"')
|
|
194
|
+
expect(html).toContain('class="status-chip 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-domain="${domain}"`)
|
|
209
|
+
}
|
|
210
|
+
})
|
|
@@ -60,8 +60,8 @@ test('refreshFindings writes findings.html and prunes any older findings*.html',
|
|
|
60
60
|
const fresh = await readFile(join(runDir, 'screen', 'findings.html'), 'utf8')
|
|
61
61
|
// The new file is rendered, not the stale OLD content.
|
|
62
62
|
expect(fresh).not.toContain('OLD')
|
|
63
|
-
// Contains the new interactivity surface (
|
|
64
|
-
expect(fresh).toContain('
|
|
63
|
+
// Contains the new interactivity surface (segmented tabs, data-run-id).
|
|
64
|
+
expect(fresh).toContain('data-action="set-view"')
|
|
65
65
|
expect(fresh).toMatch(/data-run-id="[^"]+"/)
|
|
66
66
|
// And the rendered finding itself.
|
|
67
67
|
expect(fresh).toContain('tsst')
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { renderActionBar } from '../render-action-bar.ts'
|
|
3
|
+
import type { ReviewFinding } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
const findings: ReviewFinding[] = [
|
|
6
|
+
{
|
|
7
|
+
id: '1',
|
|
8
|
+
file: 'a.ts',
|
|
9
|
+
line: 1,
|
|
10
|
+
severity: 'blocker',
|
|
11
|
+
title: 'A',
|
|
12
|
+
description: 'd',
|
|
13
|
+
risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
|
|
14
|
+
domain: 'security',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: '2',
|
|
18
|
+
file: 'b.ts',
|
|
19
|
+
line: 1,
|
|
20
|
+
severity: 'high',
|
|
21
|
+
title: 'B',
|
|
22
|
+
description: 'd',
|
|
23
|
+
risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'should-fix' },
|
|
24
|
+
domain: 'bugs',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: '3',
|
|
28
|
+
file: 'c.ts',
|
|
29
|
+
line: 1,
|
|
30
|
+
severity: 'low',
|
|
31
|
+
title: 'C',
|
|
32
|
+
description: 'd',
|
|
33
|
+
risk: { impact: 'low', likelihood: 'unknown', confidence: 'medium', action: 'optional' },
|
|
34
|
+
domain: 'code-smells',
|
|
35
|
+
},
|
|
36
|
+
] as ReviewFinding[]
|
|
37
|
+
|
|
38
|
+
describe('renderActionBar', () => {
|
|
39
|
+
test('Post Recommended count excludes suggestions', () => {
|
|
40
|
+
const html = renderActionBar({ findings })
|
|
41
|
+
expect(html).toContain('Post Recommended (2)')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('initial Post Selected reads 0', () => {
|
|
45
|
+
const html = renderActionBar({ findings })
|
|
46
|
+
expect(html).toContain('Post Selected (')
|
|
47
|
+
expect(html).toMatch(/data-role="selected-count">0</)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('renders excluded suggestions hint when suggestions present', () => {
|
|
51
|
+
const html = renderActionBar({ findings })
|
|
52
|
+
expect(html).toContain('1 suggestion excluded')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('renders severity selection pills with counts', () => {
|
|
56
|
+
const html = renderActionBar({ findings })
|
|
57
|
+
expect(html).toContain('data-action="select-sev"')
|
|
58
|
+
expect(html).toContain('data-sev="blocker"')
|
|
59
|
+
expect(html).toContain('data-sev="high"')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('renders Select recommended link', () => {
|
|
63
|
+
const html = renderActionBar({ findings })
|
|
64
|
+
expect(html).toContain('data-action="select-recommended"')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { renderAnnotation } from '../render-annotation.ts'
|
|
3
|
+
import type { ReviewFinding } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
const finding: ReviewFinding = {
|
|
6
|
+
id: 'f-1',
|
|
7
|
+
file: 'src/a.ts',
|
|
8
|
+
line: 42,
|
|
9
|
+
severity: 'high',
|
|
10
|
+
title: 'Race in cache.refresh()',
|
|
11
|
+
description: 'Observation: two refreshes overlap.\n\nWhy it matters: stale data.',
|
|
12
|
+
risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'should-fix' },
|
|
13
|
+
domain: 'bugs',
|
|
14
|
+
suggestion: { body: 'await lock.acquire()', startLine: 42, endLine: 42 },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('renderAnnotation', () => {
|
|
18
|
+
test('emits a div with data-finding-id and severity class', () => {
|
|
19
|
+
const html = renderAnnotation(finding, { checked: false, posted: false, asCard: false })
|
|
20
|
+
expect(html).toContain(`data-finding-id="f-1"`)
|
|
21
|
+
expect(html).toContain('annot')
|
|
22
|
+
expect(html).toContain('data-severity="high"')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('renders severity label, domain chip, and title', () => {
|
|
26
|
+
const html = renderAnnotation(finding, { checked: false, posted: false, asCard: false })
|
|
27
|
+
expect(html).toContain('HIGH')
|
|
28
|
+
expect(html).toContain('Bugs')
|
|
29
|
+
expect(html).toContain('Race in cache.refresh()')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('renders parsed description sections', () => {
|
|
33
|
+
const html = renderAnnotation(finding, { checked: false, posted: false, asCard: false })
|
|
34
|
+
expect(html).toContain('Observation')
|
|
35
|
+
expect(html).toContain('two refreshes overlap')
|
|
36
|
+
expect(html).toContain('Why it matters')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('renders the suggestion code block when present', () => {
|
|
40
|
+
const html = renderAnnotation(finding, { checked: false, posted: false, asCard: false })
|
|
41
|
+
expect(html).toContain('<pre')
|
|
42
|
+
expect(html).toContain('await lock.acquire()')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('renders risk dimensions footer', () => {
|
|
46
|
+
const html = renderAnnotation(finding, { checked: false, posted: false, asCard: false })
|
|
47
|
+
expect(html).toContain('Impact: <span class="tag">high</span>')
|
|
48
|
+
expect(html).toContain('Likelihood: <span class="tag">likely</span>')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('marks posted findings and disables checkbox', () => {
|
|
52
|
+
const html = renderAnnotation(finding, { checked: true, posted: true, asCard: false })
|
|
53
|
+
expect(html).toContain('data-posted="true"')
|
|
54
|
+
expect(html).toContain('checked disabled')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('marks suggestion-only findings with data-suggestion="true"', () => {
|
|
58
|
+
const suggestion: ReviewFinding = { ...finding, risk: { ...finding.risk, action: 'optional' } }
|
|
59
|
+
const html = renderAnnotation(suggestion, { checked: false, posted: false, asCard: false })
|
|
60
|
+
expect(html).toContain('data-suggestion="true"')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('asCard=true wraps in .issue-card', () => {
|
|
64
|
+
const html = renderAnnotation(finding, { checked: false, posted: false, asCard: true })
|
|
65
|
+
expect(html).toContain('class="issue-card')
|
|
66
|
+
})
|
|
67
|
+
})
|