@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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/skills/magpie/README.md +16 -1
  4. package/skills/magpie/bin/magpie.ts +60 -2
  5. package/skills/magpie/fixtures/example-pr/diff.patch +86 -0
  6. package/skills/magpie/fixtures/example-pr/findings.final.json +137 -0
  7. package/skills/magpie/fixtures/example-pr/post-status.json +5 -0
  8. package/skills/magpie/fixtures/example-pr/pr.json +21 -0
  9. package/skills/magpie/fixtures/fake-gh.sh +10 -1
  10. package/skills/magpie/package.json +1 -1
  11. package/skills/magpie/scripts/__tests__/cli.test.ts +119 -0
  12. package/skills/magpie/scripts/__tests__/diff-utils.test.ts +106 -0
  13. package/skills/magpie/scripts/__tests__/file-tree.test.ts +97 -0
  14. package/skills/magpie/scripts/__tests__/gh.test.ts +8 -0
  15. package/skills/magpie/scripts/__tests__/helper.test.ts +63 -0
  16. package/skills/magpie/scripts/__tests__/pipeline-e2e.test.ts +10 -0
  17. package/skills/magpie/scripts/__tests__/post-cmd.test.ts +90 -1
  18. package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +210 -0
  19. package/skills/magpie/scripts/__tests__/refresh.test.ts +2 -2
  20. package/skills/magpie/scripts/__tests__/render-action-bar.test.ts +66 -0
  21. package/skills/magpie/scripts/__tests__/render-annotation.test.ts +67 -0
  22. package/skills/magpie/scripts/__tests__/render-cmd.test.ts +46 -0
  23. package/skills/magpie/scripts/__tests__/render-diff.test.ts +84 -0
  24. package/skills/magpie/scripts/__tests__/render-file-tree.test.ts +54 -0
  25. package/skills/magpie/scripts/__tests__/render-findings.test.ts +104 -182
  26. package/skills/magpie/scripts/__tests__/render-issues-list.test.ts +67 -0
  27. package/skills/magpie/scripts/__tests__/render-progress.test.ts +86 -0
  28. package/skills/magpie/scripts/__tests__/server.test.ts +54 -0
  29. package/skills/magpie/scripts/__tests__/types.test.ts +48 -3
  30. package/skills/magpie/scripts/diff-utils.ts +129 -0
  31. package/skills/magpie/scripts/file-tree.ts +74 -0
  32. package/skills/magpie/scripts/gh.ts +1 -0
  33. package/skills/magpie/scripts/helper.js +402 -192
  34. package/skills/magpie/scripts/post-cmd.ts +202 -0
  35. package/skills/magpie/scripts/preview-cmd.ts +300 -0
  36. package/skills/magpie/scripts/refresh.ts +16 -1
  37. package/skills/magpie/scripts/render-action-bar.ts +51 -0
  38. package/skills/magpie/scripts/render-annotation.ts +96 -0
  39. package/skills/magpie/scripts/render-cmd.ts +24 -1
  40. package/skills/magpie/scripts/render-diff.ts +125 -0
  41. package/skills/magpie/scripts/render-file-tree.ts +86 -0
  42. package/skills/magpie/scripts/render-findings.ts +165 -196
  43. package/skills/magpie/scripts/render-issues-list.ts +78 -0
  44. package/skills/magpie/scripts/render-progress.ts +74 -32
  45. package/skills/magpie/scripts/server.ts +43 -0
  46. package/skills/magpie/scripts/types.ts +13 -0
  47. package/skills/magpie/skill.json +4 -2
  48. package/skills/magpie/templates/styles.css +1288 -629
@@ -69,3 +69,49 @@ test('findings render with no findings json shows empty state', async () => {
69
69
  const html = await Bun.file(join(runDir, 'screen', 'findings.html')).text()
70
70
  expect(html).toContain('No findings')
71
71
  })
72
+
73
+ test('findings render includes files and diff when provided', async () => {
74
+ await writeFile(
75
+ join(runDir, 'findings.final.json'),
76
+ JSON.stringify([
77
+ {
78
+ id: 'a',
79
+ file: 'src/app.ts',
80
+ line: 5,
81
+ severity: 'high',
82
+ risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
83
+ title: 'test finding',
84
+ description: 'd',
85
+ domain: 'bugs',
86
+ },
87
+ ]),
88
+ )
89
+ await writeFile(
90
+ join(runDir, 'pr.json'),
91
+ JSON.stringify({
92
+ number: 1234,
93
+ headRefName: 'feature-x',
94
+ headRefOid: 'deadbeef',
95
+ files: [
96
+ { path: 'src/app.ts', additions: 10, deletions: 2 },
97
+ { path: 'src/util.ts', additions: 5, deletions: 0 },
98
+ ],
99
+ }),
100
+ )
101
+ await writeFile(
102
+ join(runDir, 'diff.patch'),
103
+ `--- a/src/app.ts
104
+ +++ b/src/app.ts
105
+ @@ -1,3 +1,6 @@
106
+ function hello() {
107
+ + console.log('added');
108
+ + console.log('more');
109
+ }
110
+ `,
111
+ )
112
+ const exit = await runRender(runDir, 'findings')
113
+ expect(exit).toBe(0)
114
+ const html = await Bun.file(join(runDir, 'screen', 'findings.html')).text()
115
+ expect(html).toContain('data-file-pane="src/app.ts"')
116
+ expect(html).toContain('data-file-pane="src/util.ts"')
117
+ })
@@ -0,0 +1,84 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { parseUnifiedDiffToHunks } from '../diff-utils.ts'
3
+ import { renderSplitDiff, renderUnifiedDiff } from '../render-diff.ts'
4
+ import type { ReviewFinding } from '../types.ts'
5
+
6
+ const DIFF = `--- a/x.ts
7
+ +++ b/x.ts
8
+ @@ -1,3 +1,4 @@
9
+ line1
10
+ -old
11
+ +new
12
+ +added
13
+ line3
14
+ `
15
+
16
+ const finding: ReviewFinding = {
17
+ id: 'f-1',
18
+ file: 'x.ts',
19
+ line: 2,
20
+ severity: 'high',
21
+ title: 't',
22
+ description: 'd',
23
+ risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'should-fix' },
24
+ domain: 'bugs',
25
+ }
26
+
27
+ describe('renderUnifiedDiff', () => {
28
+ test('emits one row per diff line', () => {
29
+ const html = renderUnifiedDiff({
30
+ hunks: parseUnifiedDiffToHunks(DIFF),
31
+ findings: [],
32
+ postStatus: {},
33
+ selectedIds: new Set(),
34
+ })
35
+ expect((html.match(/class="diff-row/g) ?? []).length).toBe(5)
36
+ })
37
+
38
+ test('places annotations after the matching new-line', () => {
39
+ const hunks = parseUnifiedDiffToHunks(DIFF)
40
+ const html = renderUnifiedDiff({
41
+ hunks,
42
+ findings: [finding],
43
+ postStatus: {},
44
+ selectedIds: new Set(),
45
+ })
46
+ const idx = html.indexOf('data-finding-id="f-1"')
47
+ const before = html.lastIndexOf('class="diff-row', idx)
48
+ const newRowSnippet = html.slice(before, idx)
49
+ expect(newRowSnippet).toContain('class="diff-row added"')
50
+ })
51
+
52
+ test('emits a "no diff" placeholder when hunks is empty', () => {
53
+ const html = renderUnifiedDiff({
54
+ hunks: [],
55
+ findings: [],
56
+ postStatus: {},
57
+ selectedIds: new Set(),
58
+ })
59
+ expect(html).toContain('No changes')
60
+ })
61
+
62
+ test('renders posted findings with data-posted="true"', () => {
63
+ const html = renderUnifiedDiff({
64
+ hunks: parseUnifiedDiffToHunks(DIFF),
65
+ findings: [finding],
66
+ postStatus: { 'f-1': 'posted' },
67
+ selectedIds: new Set(),
68
+ })
69
+ expect(html).toContain('data-posted="true"')
70
+ })
71
+ })
72
+
73
+ describe('renderSplitDiff', () => {
74
+ test('emits left/right cells per row', () => {
75
+ const html = renderSplitDiff({
76
+ hunks: parseUnifiedDiffToHunks(DIFF),
77
+ findings: [],
78
+ postStatus: {},
79
+ selectedIds: new Set(),
80
+ })
81
+ expect((html.match(/diff-row split/g) ?? []).length).toBeGreaterThan(0)
82
+ expect(html).toContain('diff-cell')
83
+ })
84
+ })
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { renderFileTree } from '../render-file-tree.ts'
3
+ import type { PrFileEntry, ReviewFinding } from '../types.ts'
4
+
5
+ const files: PrFileEntry[] = [
6
+ { path: 'src/a.ts', additions: 5, deletions: 1 },
7
+ { path: 'src/b.ts', additions: 2, deletions: 0 },
8
+ { path: 'README.md', additions: 3, deletions: 0 },
9
+ ]
10
+
11
+ const findings: ReviewFinding[] = [
12
+ {
13
+ id: '1',
14
+ file: 'src/a.ts',
15
+ line: 1,
16
+ severity: 'blocker',
17
+ title: 't',
18
+ description: 'd',
19
+ risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'must-fix' },
20
+ domain: 'security',
21
+ },
22
+ ] as ReviewFinding[]
23
+
24
+ describe('renderFileTree', () => {
25
+ test('emits a button per file with data-file attribute', () => {
26
+ const html = renderFileTree({ files, findings })
27
+ expect(html).toContain('data-file="src/a.ts"')
28
+ expect(html).toContain('data-file="src/b.ts"')
29
+ expect(html).toContain('data-file="README.md"')
30
+ })
31
+
32
+ test('emits +adds -dels deltas per file', () => {
33
+ const html = renderFileTree({ files, findings })
34
+ expect(html).toContain('+5')
35
+ expect(html).toContain('-1')
36
+ })
37
+
38
+ test('emits severity pip counts on files that have findings', () => {
39
+ const html = renderFileTree({ files, findings })
40
+ expect(html).toMatch(/data-file="src\/a\.ts"[\s\S]*?sev-pip blocker[\s\S]*?>1</)
41
+ })
42
+
43
+ test('renders an Overview entry as data-file=""', () => {
44
+ const html = renderFileTree({ files, findings })
45
+ expect(html).toContain('data-file=""')
46
+ expect(html).toContain('Overview')
47
+ })
48
+
49
+ test('collapses single-child directory chains', () => {
50
+ const f: PrFileEntry[] = [{ path: 'apps/server/lib/x.ts', additions: 1, deletions: 0 }]
51
+ const html = renderFileTree({ files: f, findings: [] })
52
+ expect(html).toContain('apps/server/lib')
53
+ })
54
+ })
@@ -22,243 +22,165 @@ function f(p: Partial<ReviewFinding> & { id: string; title: string }): ReviewFin
22
22
  }
23
23
  }
24
24
 
25
- test('empty findings renders an explicit empty state', () => {
25
+ const SAMPLE_FINDINGS: ReviewFinding[] = [
26
+ f({ id: 'a', title: 'null deref', severity: 'high', file: 'src/a.ts', line: 2 }),
27
+ f({
28
+ id: 'b',
29
+ title: 'magic number',
30
+ severity: 'low',
31
+ file: 'src/a.ts',
32
+ line: 4,
33
+ risk: { impact: 'low', likelihood: 'unknown', confidence: 'medium', action: 'optional' },
34
+ }),
35
+ ]
36
+
37
+ const SAMPLE_DIFF = `--- a/src/a.ts
38
+ +++ b/src/a.ts
39
+ @@ -1,3 +1,4 @@
40
+ line1
41
+ -old
42
+ +new
43
+ +added
44
+ line3
45
+ `
46
+
47
+ test('empty findings renders the page shell with an empty-state message', () => {
26
48
  const html = renderFindingsHtml({ findings: [], postStatus: {} })
49
+ expect(html).toContain('class="pr-header"')
27
50
  expect(html).toContain('No findings')
28
51
  })
29
52
 
30
- test('renders one finding with severity chip and post button', () => {
31
- const html = renderFindingsHtml({
32
- findings: [f({ id: 'a', title: 'null deref', severity: 'high' })],
33
- postStatus: {},
34
- })
35
- expect(html).toContain('null deref')
36
- expect(html).toContain('sev-high')
37
- expect(html).toContain('data-finding-id="a"')
38
- expect(html).toContain('data-action="post"')
53
+ test('emits the two-tab segmented control with Files and All Issues buttons', () => {
54
+ const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {} })
55
+ expect(html).toContain('data-action="set-view"')
56
+ expect(html).toContain('data-view="files"')
57
+ expect(html).toContain('data-view="all-issues"')
39
58
  })
40
59
 
41
- test('shows posted badge when present in postStatus', () => {
42
- const html = renderFindingsHtml({
43
- findings: [f({ id: 'a', title: 'x' })],
44
- postStatus: { a: 'posted' },
45
- })
46
- expect(html).toContain('class="badge posted"')
47
- })
48
-
49
- test('shows failed badge with message', () => {
50
- const html = renderFindingsHtml({
51
- findings: [f({ id: 'a', title: 'x' })],
52
- postStatus: { a: { status: 'failed', message: 'rate limit' } },
53
- })
54
- expect(html).toContain('class="badge failed"')
55
- expect(html).toContain('rate limit')
60
+ test('sets body data attributes for default view, diff-mode, and suggestions visibility', () => {
61
+ const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {} })
62
+ expect(html).toContain('data-page="findings"')
63
+ expect(html).toContain('data-view="files"')
64
+ expect(html).toContain('data-diff-mode="unified"')
65
+ expect(html).toContain('data-show-suggestions="false"')
56
66
  })
57
67
 
58
- test('renders suggestion block when present, with a "Suggested change" caption', () => {
68
+ test('renders one file pane per PrFileEntry, hidden by default', () => {
59
69
  const html = renderFindingsHtml({
60
- findings: [
61
- f({
62
- id: 'a',
63
- title: 'x',
64
- suggestion: { body: 'if (!y) return', startLine: 10, endLine: 11 },
65
- }),
66
- ],
70
+ findings: SAMPLE_FINDINGS,
67
71
  postStatus: {},
68
- })
69
- expect(html).toContain('if (!y) return')
70
- expect(html).toContain('Suggested change')
71
- expect(html).toContain('class="finding-suggestion-wrap"')
72
- })
73
-
74
- test('parses labelled paragraphs into section headers', () => {
75
- const html = renderFindingsHtml({
76
- findings: [
77
- f({
78
- id: 'a',
79
- title: 'unsafe userId',
80
- description:
81
- 'Observation: trusts client-supplied userId.\n\nWhy it matters: cross-tenant exposure.\n\nSuggested direction: read from auth context.',
82
- }),
72
+ files: [
73
+ { path: 'src/a.ts', additions: 3, deletions: 1 },
74
+ { path: 'src/b.ts', additions: 1, deletions: 0 },
83
75
  ],
84
- postStatus: {},
76
+ diff: SAMPLE_DIFF,
85
77
  })
86
- expect(html).toContain('class="finding-section finding-section-observation"')
87
- expect(html).toContain('class="finding-section finding-section-impact"')
88
- expect(html).toContain('class="finding-section finding-section-suggestion"')
89
- expect(html).toContain('>Observation</h4>')
90
- expect(html).toContain('>Why it matters</h4>')
91
- expect(html).toContain('>Suggested direction</h4>')
92
- expect(html).toContain('>trusts client-supplied userId.</p>')
78
+ expect(html).toContain('data-file-pane="src/a.ts"')
79
+ expect(html).toContain('data-file-pane="src/b.ts"')
80
+ expect(html).toMatch(/data-file-pane="src\/a\.ts"[^>]*hidden/)
93
81
  })
94
82
 
95
- test('renders the risk footer with Impact/Likelihood/Confidence/Action', () => {
96
- const html = renderFindingsHtml({
97
- findings: [
98
- f({
99
- id: 'a',
100
- title: 'x',
101
- risk: { impact: 'high', likelihood: 'likely', confidence: 'high', action: 'should-fix' },
102
- }),
103
- ],
104
- postStatus: {},
105
- })
106
- expect(html).toContain('class="finding-risk"')
107
- expect(html).toContain('Impact <strong>high</strong>')
108
- expect(html).toContain('Likelihood <strong>likely</strong>')
109
- expect(html).toContain('Confidence <strong>high</strong>')
110
- expect(html).toContain('Action <strong>should-fix</strong>')
83
+ test('renders an Overview pane mapped to data-file-pane=""', () => {
84
+ const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {} })
85
+ expect(html).toContain('data-file-pane=""')
111
86
  })
112
87
 
113
- test('falls back to a single observation when description has no labels', () => {
88
+ test('renders a per-file toolbar with breadcrumb, deltas, finding-nav and diff-mode toggle', () => {
114
89
  const html = renderFindingsHtml({
115
- findings: [f({ id: 'a', title: 'x', description: 'Just one sentence.' })],
90
+ findings: SAMPLE_FINDINGS,
116
91
  postStatus: {},
92
+ files: [{ path: 'src/a.ts', additions: 3, deletions: 1 }],
93
+ diff: SAMPLE_DIFF,
117
94
  })
118
- expect(html).toContain('finding-section-observation')
119
- expect(html).toContain('>Just one sentence.</p>')
95
+ expect(html).toContain('+3')
96
+ expect(html).toContain('-1')
97
+ expect(html).toContain('data-action="prev-finding"')
98
+ expect(html).toContain('data-action="next-finding"')
99
+ expect(html).toContain('data-action="set-diff-mode"')
100
+ expect(html).toContain('data-mode="unified"')
101
+ expect(html).toContain('data-mode="split"')
120
102
  })
121
103
 
122
- test('includes the archived-mode banner element so file:// view can self-degrade', () => {
104
+ test('renders both unified and split diff containers per file', () => {
123
105
  const html = renderFindingsHtml({
124
- findings: [f({ id: 'a', title: 'x' })],
106
+ findings: SAMPLE_FINDINGS,
125
107
  postStatus: {},
108
+ files: [{ path: 'src/a.ts', additions: 3, deletions: 1 }],
109
+ diff: SAMPLE_DIFF,
126
110
  })
127
- expect(html).toContain('archived-banner')
128
- expect(html).toContain('Archived view')
111
+ expect(html).toContain('data-diff-mode="unified"')
112
+ expect(html).toContain('data-diff-mode="split"')
129
113
  })
130
114
 
131
- test('renders a severity-breakdown summary line', () => {
115
+ test('places an inline annotation at the affected diff line', () => {
132
116
  const html = renderFindingsHtml({
133
- findings: [
134
- f({ id: 'a', title: 't1', severity: 'high' }),
135
- f({ id: 'b', title: 't2', severity: 'high' }),
136
- f({ id: 'c', title: 't3', severity: 'low' }),
137
- ],
117
+ findings: SAMPLE_FINDINGS,
138
118
  postStatus: {},
119
+ files: [{ path: 'src/a.ts', additions: 3, deletions: 1 }],
120
+ diff: SAMPLE_DIFF,
139
121
  })
140
- expect(html).toContain('findings-summary')
141
- expect(html).toContain('2</span>&nbsp;high')
142
- expect(html).toContain('1</span>&nbsp;low')
122
+ expect(html).toContain('data-finding-id="a"')
123
+ expect(html).toContain('class="annot')
143
124
  })
144
125
 
145
- test('post button starts disabled (enabled by helper.js after first select)', () => {
146
- const html = renderFindingsHtml({
147
- findings: [f({ id: 'a', title: 'x' })],
148
- postStatus: {},
149
- })
150
- expect(html).toMatch(/<button[^>]*data-action="post"[^>]*disabled/)
126
+ test('mounts the All Issues pane', () => {
127
+ const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {} })
128
+ expect(html).toContain('data-role="issues-list"')
151
129
  })
152
130
 
153
- test('renders a filter bar with severity and domain chips when findings exist', () => {
154
- const html = renderFindingsHtml({
155
- findings: [
156
- f({ id: 'a', title: 't1', severity: 'high', domain: 'bugs' }),
157
- f({ id: 'b', title: 't2', severity: 'high', domain: 'security' }),
158
- f({ id: 'c', title: 't3', severity: 'low', domain: 'bugs' }),
159
- ],
160
- postStatus: {},
161
- })
162
- // The bar
163
- expect(html).toContain('class="filter-bar"')
164
- // Search input
165
- expect(html).toMatch(/<input[^>]+type="search"[^>]+data-role="search"/)
166
- // Severity chips with counts
167
- expect(html).toMatch(/data-filter-group="sev"[^>]+data-filter-value="high"/)
168
- expect(html).toMatch(/data-filter-group="sev"[^>]+data-filter-value="low"/)
169
- // Domain chips
170
- expect(html).toMatch(/data-filter-group="domain"[^>]+data-filter-value="bugs"/)
171
- expect(html).toMatch(/data-filter-group="domain"[^>]+data-filter-value="security"/)
172
- // Each chip carries its current count
173
- expect(html).toMatch(/<span class="filter-chip-count">2<\/span>/) // high count
174
- expect(html).toMatch(/<span class="filter-chip-count">1<\/span>/) // low count
131
+ test('mounts the action bar at the page bottom', () => {
132
+ const html = renderFindingsHtml({ findings: SAMPLE_FINDINGS, postStatus: {} })
133
+ expect(html).toContain('data-role="action-bar"')
134
+ expect(html).toContain('Post Recommended')
175
135
  })
176
136
 
177
- test('exposes bulk select actions and a keyboard-shortcut hint', () => {
137
+ test('marks posted findings with data-posted="true"', () => {
178
138
  const html = renderFindingsHtml({
179
- findings: [f({ id: 'a', title: 'x' })],
180
- postStatus: {},
139
+ findings: SAMPLE_FINDINGS,
140
+ postStatus: { a: 'posted' },
181
141
  })
182
- expect(html).toMatch(/data-action="select-visible"/)
183
- expect(html).toMatch(/data-action="select-priority"/)
184
- expect(html).toMatch(/data-action="select-none"/)
185
- expect(html).toContain('class="kbd-hint"')
186
- expect(html).toContain('<kbd>j</kbd>')
187
- expect(html).toContain('<kbd>/</kbd>')
142
+ expect(html).toContain('data-posted="true"')
188
143
  })
189
144
 
190
- test('threads runId into a data-run-id attribute on body', () => {
145
+ test('renders the suggestion code block in the annotation', () => {
191
146
  const html = renderFindingsHtml({
192
- findings: [f({ id: 'a', title: 'x' })],
147
+ findings: [
148
+ f({
149
+ id: 'x',
150
+ title: 'with patch',
151
+ suggestion: { body: 'if (!y) return', startLine: 10, endLine: 11 },
152
+ }),
153
+ ],
193
154
  postStatus: {},
194
- runId: 'pr-42-1700000000',
195
155
  })
196
- expect(html).toMatch(/<body[^>]+data-run-id="pr-42-1700000000"/)
156
+ expect(html).toContain('if (!y) return')
157
+ expect(html).toContain('Suggested change')
197
158
  })
198
159
 
199
- test('each finding card carries severity/domain/file/search-text data attrs for client-side filtering', () => {
160
+ test('parses labelled paragraphs into section headers', () => {
200
161
  const html = renderFindingsHtml({
201
162
  findings: [
202
163
  f({
203
164
  id: 'a',
204
- title: 'race in handler',
205
- severity: 'high',
206
- domain: 'bugs',
207
- file: 'src/main.ts',
208
- description: 'fire-and-forget promise',
165
+ title: 'unsafe userId',
166
+ description:
167
+ 'Observation: client picks userId.\n\nWhy it matters: leak risk.\n\nSuggested direction: drop arg.',
209
168
  }),
210
169
  ],
211
170
  postStatus: {},
212
171
  })
213
- expect(html).toMatch(/data-severity="high"/)
214
- expect(html).toMatch(/data-domain="bugs"/)
215
- expect(html).toMatch(/data-file="src\/main\.ts"/)
216
- expect(html).toMatch(/data-search-text="[^"]*race in handler[^"]*fire-and-forget promise/)
217
- })
218
-
219
- test('post button label is "Post to PR" and triggers the confirm flow', () => {
220
- const html = renderFindingsHtml({
221
- findings: [f({ id: 'a', title: 'x' })],
222
- postStatus: {},
223
- })
224
- expect(html).toMatch(/<button[^>]*data-action="post"[^>]*>Post to PR<\/button>/)
225
- })
226
-
227
- test('renders the inline confirm popup (hidden until user clicks Post to PR)', () => {
228
- const html = renderFindingsHtml({
229
- findings: [f({ id: 'a', title: 'x' })],
230
- postStatus: {},
231
- })
232
- // It is anchored to the Post to PR button, not a separate row.
233
- expect(html).toContain('class="post-button-group"')
234
- expect(html).toMatch(/<div\s+class="confirm-popup"\s+data-role="confirm-bar"[^>]*hidden[^>]*>/)
235
- expect(html).toContain('data-action="cancel-post"')
236
- expect(html).toContain('data-action="confirm-post"')
237
- })
238
-
239
- test('lede explains the server-side post flow (not the terminal reply)', () => {
240
- const html = renderFindingsHtml({
241
- findings: [f({ id: 'a', title: 'x' })],
242
- postStatus: {},
243
- })
244
- expect(html).toMatch(/Post to PR/)
245
- expect(html).toMatch(/<code>gh<\/code>/)
246
- })
247
-
248
- test('renders a polite submit-status region for transient queue/error feedback', () => {
249
- const html = renderFindingsHtml({
250
- findings: [f({ id: 'a', title: 'x' })],
251
- postStatus: {},
252
- })
253
- expect(html).toMatch(
254
- /<p\s+class="submit-status"\s+data-role="submit-status"\s+role="status"\s+aria-live="polite"\s+hidden>/,
255
- )
172
+ expect(html).toContain('Observation')
173
+ expect(html).toContain('Why it matters')
174
+ expect(html).toContain('Suggested direction')
256
175
  })
257
176
 
258
- test('renders a no-matches placeholder for the client-side filter to reveal', () => {
177
+ test('renders pr meta when supplied', () => {
259
178
  const html = renderFindingsHtml({
260
- findings: [f({ id: 'a', title: 'x' })],
179
+ findings: SAMPLE_FINDINGS,
261
180
  postStatus: {},
181
+ pr: { number: 42, branch: 'feat/x', headSha: 'deadbeefdeadbeef0000111122223333' },
262
182
  })
263
- expect(html).toMatch(/data-role="no-matches"[^>]+hidden/)
183
+ expect(html).toContain('PR #42')
184
+ expect(html).toContain('feat/x')
185
+ expect(html).toContain('deadbeefdead')
264
186
  })
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { renderIssuesList } from '../render-issues-list.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('renderIssuesList', () => {
39
+ test('renders an issue-card per finding', () => {
40
+ const html = renderIssuesList({ findings, postStatus: {}, selectedIds: new Set() })
41
+ expect((html.match(/class="issue-card/g) ?? []).length).toBe(3)
42
+ })
43
+
44
+ test('renders severity filter pills with counts', () => {
45
+ const html = renderIssuesList({ findings, postStatus: {}, selectedIds: new Set() })
46
+ expect(html).toContain('Blocker (1)')
47
+ expect(html).toContain('High (1)')
48
+ expect(html).toContain('Low (1)')
49
+ })
50
+
51
+ test('emits "N should review" with actionable count only', () => {
52
+ const html = renderIssuesList({ findings, postStatus: {}, selectedIds: new Set() })
53
+ expect(html).toContain('2 should review')
54
+ })
55
+
56
+ test('emits "Show N suggestions" toggle when suggestions present', () => {
57
+ const html = renderIssuesList({ findings, postStatus: {}, selectedIds: new Set() })
58
+ expect(html).toContain('Show 1 suggestion')
59
+ })
60
+
61
+ test('suggestion finding has data-suggestion="true"', () => {
62
+ const html = renderIssuesList({ findings, postStatus: {}, selectedIds: new Set() })
63
+ expect(html).toMatch(
64
+ /data-finding-id="3"[^>]*data-suggestion="true"|data-suggestion="true"[^>]*data-finding-id="3"/,
65
+ )
66
+ })
67
+ })