@marcomigozzi/trailhead 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +28 -3
- package/README.md +38 -3
- package/bin/lib/codex-projection.js +16 -9
- package/bin/lib/codex-projection.test.js +39 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/trailhead.js +37 -25
- package/bin/trailhead.test.js +162 -5
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
- package/plugins/trailhead/hooks/mod/README.md +187 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
- package/plugins/trailhead/hooks/mod/register.tsx +280 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
- package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +3 -1
- package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
- package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
- package/plugins/trailhead/types/index.d.ts +69 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ageLabel,
|
|
5
|
+
axisStart,
|
|
6
|
+
burndownEnd,
|
|
7
|
+
burndownSeries,
|
|
8
|
+
failureReason,
|
|
9
|
+
footerStatus,
|
|
10
|
+
groupTickets,
|
|
11
|
+
HISTORY_LIMIT,
|
|
12
|
+
mapRowLayout,
|
|
13
|
+
nextViewed,
|
|
14
|
+
parseActiveMap,
|
|
15
|
+
parseHistory,
|
|
16
|
+
parseMap,
|
|
17
|
+
parseMaps,
|
|
18
|
+
parseTickets,
|
|
19
|
+
rasterCells,
|
|
20
|
+
refreshDecision,
|
|
21
|
+
rowLayout,
|
|
22
|
+
sparkline,
|
|
23
|
+
viewTarget,
|
|
24
|
+
} from '../hooks/mod/map-pane-view'
|
|
25
|
+
|
|
26
|
+
const issue = (over: Record<string, unknown> = {}) => ({
|
|
27
|
+
number: 209,
|
|
28
|
+
title: 'Burn-down view',
|
|
29
|
+
labels: [{ name: 'trailhead:build' }, { name: 'trailhead:map-198' }],
|
|
30
|
+
assignees: [],
|
|
31
|
+
url: 'https://github.com/o/r/issues/209',
|
|
32
|
+
...over,
|
|
33
|
+
})
|
|
34
|
+
const list = (...issues: unknown[]) => JSON.stringify(issues)
|
|
35
|
+
const labels = (...names: string[]) => names.map(name => ({ name }))
|
|
36
|
+
|
|
37
|
+
test('parseActiveMap reads a positive integer, with or without the hash', () => {
|
|
38
|
+
expect(parseActiveMap('198\n')).toBe(198)
|
|
39
|
+
expect(parseActiveMap('#198\n')).toBe(198)
|
|
40
|
+
expect(parseActiveMap(' #7 ')).toBe(7)
|
|
41
|
+
expect(parseActiveMap('')).toBeNull()
|
|
42
|
+
expect(parseActiveMap('0')).toBeNull()
|
|
43
|
+
expect(parseActiveMap('-3')).toBeNull()
|
|
44
|
+
expect(parseActiveMap('12 13')).toBeNull()
|
|
45
|
+
expect(parseActiveMap('abc')).toBeNull()
|
|
46
|
+
expect(parseActiveMap('1.5')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('parseTickets maps a gh list to rows with type, state and holders', () => {
|
|
50
|
+
const out = parseTickets(
|
|
51
|
+
list(
|
|
52
|
+
issue(),
|
|
53
|
+
issue({ number: 212, title: 'Held', assignees: [{ login: 'alice' }], labels: labels('trailhead:research') }),
|
|
54
|
+
issue({ number: 211, title: 'Gated', labels: labels('trailhead:blocked', 'trailhead:bug'), assignees: [{ login: 'bob' }] }),
|
|
55
|
+
issue({ number: 214, title: 'No type', labels: labels('other') }),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
expect(out?.unverifiedCount).toBe(0)
|
|
59
|
+
expect(out?.tickets).toEqual([
|
|
60
|
+
{ number: 209, title: 'Burn-down view', type: 'build', state: 'frontier', assignees: [], url: 'https://github.com/o/r/issues/209' },
|
|
61
|
+
{ number: 212, title: 'Held', type: 'research', state: 'in-progress', assignees: ['alice'], url: 'https://github.com/o/r/issues/209' },
|
|
62
|
+
{ number: 211, title: 'Gated', type: 'bug', state: 'blocked', assignees: ['bob'], url: 'https://github.com/o/r/issues/209' },
|
|
63
|
+
{ number: 214, title: 'No type', type: null, state: 'frontier', assignees: [], url: 'https://github.com/o/r/issues/209' },
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('parseTickets takes the first type label among the six', () => {
|
|
68
|
+
const type = (...names: string[]) => parseTickets(list(issue({ labels: labels(...names) })))?.tickets[0]?.type
|
|
69
|
+
expect(type('trailhead:task', 'trailhead:build')).toBe('task')
|
|
70
|
+
for (const t of ['decision', 'research', 'prototype', 'build', 'bug', 'task']) expect(type(`trailhead:${t}`)).toBe(t)
|
|
71
|
+
expect(type('trailhead:map-198', 'trailhead:epic')).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('parseTickets counts unverified tickets and never lists them or the whiteboard ones', () => {
|
|
75
|
+
const out = parseTickets(
|
|
76
|
+
list(
|
|
77
|
+
issue({ number: 1, labels: labels('trailhead:unverified', 'trailhead:build') }),
|
|
78
|
+
issue({ number: 2, labels: labels('trailhead:unverified') }),
|
|
79
|
+
issue({ number: 3, labels: labels('trailhead:whiteboard') }),
|
|
80
|
+
issue({ number: 4 }),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
expect(out?.unverifiedCount).toBe(2)
|
|
84
|
+
expect(out?.tickets.map(t => t.number)).toEqual([4])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('parseTickets flattens an untrusted title', () => {
|
|
88
|
+
const title = parseTickets(list(issue({ title: 'a\n\tbcde\u0007fg h' })))?.tickets[0]?.title
|
|
89
|
+
expect(title).toBe('a bcdefg h')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('parseTickets strips C1 control characters from titles and logins', () => {
|
|
93
|
+
const out = parseTickets(list(issue({ title: 'a\u0085b\u009fc\u0080d', assignees: [{ login: 'al\u0090ice' }], state: 'x' })))
|
|
94
|
+
expect(out?.tickets[0]?.title).toBe('abcd')
|
|
95
|
+
expect(out?.tickets[0]?.assignees).toEqual(['alice'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('parseTickets says when the list reached the gh limit', () => {
|
|
99
|
+
const rows = (n: number) => Array.from({ length: n }, (_, i) => issue({ number: i + 1 }))
|
|
100
|
+
expect(parseTickets(JSON.stringify(rows(199)))?.isCapped).toBe(false)
|
|
101
|
+
expect(parseTickets(JSON.stringify(rows(200)))?.isCapped).toBe(true)
|
|
102
|
+
// quarantined and whiteboard rows count toward the limit gh applied
|
|
103
|
+
const mixed = [...rows(199), issue({ number: 900, labels: labels('trailhead:unverified') })]
|
|
104
|
+
expect(parseTickets(JSON.stringify(mixed))?.isCapped).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('parseTickets is null on invalid JSON and skips malformed rows', () => {
|
|
108
|
+
expect(parseTickets('{nope')).toBeNull()
|
|
109
|
+
expect(parseTickets('{"a":1}')).toBeNull()
|
|
110
|
+
expect(parseTickets('null')).toBeNull()
|
|
111
|
+
expect(parseTickets('[]')).toEqual({ tickets: [], unverifiedCount: 0, isCapped: false })
|
|
112
|
+
const out = parseTickets(list(null, 5, { title: 'x' }, issue({ number: 0 }), issue({ number: 8, labels: undefined, assignees: undefined })))
|
|
113
|
+
expect(out?.tickets.map(t => t.number)).toEqual([8])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('parseTickets keeps only a https issue url', () => {
|
|
117
|
+
const url = (u: unknown) => parseTickets(list(issue({ url: u })))?.tickets[0]?.url
|
|
118
|
+
expect(url('https://github.com/o/r/issues/1')).toBe('https://github.com/o/r/issues/1')
|
|
119
|
+
expect(url('javascript:alert(1)')).toBe('')
|
|
120
|
+
expect(url(5)).toBe('')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('parseMap reads the title and the first destination line, flattened and unmarked', () => {
|
|
124
|
+
const body = ['intro', '', '## Destination', '', '**A released** `trailhead` _version_ whose [mod](https://x.y) works.', 'second line', '', '## Next'].join('\n')
|
|
125
|
+
expect(parseMap(JSON.stringify({ title: 'trailhead mod', body, url: 'https://github.com/o/r/issues/198' }), 198)).toEqual({
|
|
126
|
+
number: 198,
|
|
127
|
+
title: 'trailhead mod',
|
|
128
|
+
destination: 'A released trailhead version whose mod works.',
|
|
129
|
+
url: 'https://github.com/o/r/issues/198',
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('parseMap has a null destination when the section is missing or empty, and null on bad JSON', () => {
|
|
134
|
+
const dest = (body: string) => parseMap(JSON.stringify({ title: 't', body, url: 'https://x/1' }), 1)?.destination
|
|
135
|
+
expect(dest('no heading here')).toBeNull()
|
|
136
|
+
expect(dest('## Destination\n\n## Other\ntext')).toBeNull()
|
|
137
|
+
expect(dest('## Destination')).toBeNull()
|
|
138
|
+
expect(parseMap('{nope', 1)).toBeNull()
|
|
139
|
+
expect(parseMap(JSON.stringify({ body: 'x' }), 1)).toBeNull()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('groupTickets splits by state, ascending by number', () => {
|
|
143
|
+
const t = (number: number, state: 'frontier' | 'in-progress' | 'blocked') => ({
|
|
144
|
+
number, title: '', type: null, state, assignees: [], url: '',
|
|
145
|
+
})
|
|
146
|
+
const g = groupTickets([t(30, 'frontier'), t(5, 'blocked'), t(9, 'in-progress'), t(12, 'frontier'), t(2, 'blocked')])
|
|
147
|
+
expect(g.frontier.map(x => x.number)).toEqual([12, 30])
|
|
148
|
+
expect(g.inProgress.map(x => x.number)).toEqual([9])
|
|
149
|
+
expect(g.blocked.map(x => x.number)).toEqual([2, 5])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('failureReason names one short cause', () => {
|
|
153
|
+
expect(failureReason({ error: Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }) })).toBe('gh not installed')
|
|
154
|
+
expect(failureReason({ error: new Error('spawn gh ENOENT') })).toBe('gh not installed')
|
|
155
|
+
expect(failureReason({ exitCode: 4, stderr: 'To get started with GitHub CLI, please run: gh auth login' })).toBe(
|
|
156
|
+
'gh not authenticated (run gh auth login)',
|
|
157
|
+
)
|
|
158
|
+
expect(failureReason({ exitCode: 1, stderr: 'error connecting to api.github.com' })).toBe('offline: cannot reach GitHub')
|
|
159
|
+
expect(failureReason({ exitCode: 1, stderr: 'dial tcp: lookup api.github.com: no such host' })).toBe('offline: cannot reach GitHub')
|
|
160
|
+
expect(failureReason({ error: new Error('command timed out after 20000ms') })).toBe('offline: cannot reach GitHub')
|
|
161
|
+
expect(failureReason({ exitCode: 1, stderr: 'boom' })).toBe('gh failed (exit 1)')
|
|
162
|
+
expect(failureReason({ exitCode: 2, stderr: '' })).toBe('gh failed (exit 2)')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('refreshDecision runs now outside the floor and waits the remainder inside it', () => {
|
|
166
|
+
expect(refreshDecision(1000, null, 15000)).toEqual({ run: true })
|
|
167
|
+
expect(refreshDecision(20000, 1000, 15000)).toEqual({ run: true })
|
|
168
|
+
expect(refreshDecision(16000, 1000, 15000)).toEqual({ run: true })
|
|
169
|
+
expect(refreshDecision(10000, 1000, 15000)).toEqual({ run: false, waitMs: 6000 })
|
|
170
|
+
expect(refreshDecision(1000, 1000, 15000)).toEqual({ run: false, waitMs: 15000 })
|
|
171
|
+
// a clock that moved back never waits longer than the floor
|
|
172
|
+
expect(refreshDecision(500, 1000, 15000)).toEqual({ run: false, waitMs: 15000 })
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('ageLabel reads just now, seconds, minutes, hours', () => {
|
|
176
|
+
expect(ageLabel(0)).toBe('just now')
|
|
177
|
+
expect(ageLabel(-5)).toBe('just now')
|
|
178
|
+
expect(ageLabel(4_999)).toBe('just now')
|
|
179
|
+
expect(ageLabel(40_000)).toBe('40s ago')
|
|
180
|
+
expect(ageLabel(59_999)).toBe('59s ago')
|
|
181
|
+
expect(ageLabel(60_000)).toBe('1m ago')
|
|
182
|
+
expect(ageLabel(6 * 60_000)).toBe('6m ago')
|
|
183
|
+
expect(ageLabel(2 * 3_600_000)).toBe('2h ago')
|
|
184
|
+
expect(ageLabel(49 * 3_600_000)).toBe('49h ago')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const ticket = (over: Record<string, unknown> = {}) => ({
|
|
188
|
+
number: 209,
|
|
189
|
+
title: 'Burn-down view of the map',
|
|
190
|
+
type: 'build' as const,
|
|
191
|
+
state: 'frontier' as const,
|
|
192
|
+
assignees: [] as string[],
|
|
193
|
+
url: 'https://github.com/o/r/issues/209',
|
|
194
|
+
...over,
|
|
195
|
+
})
|
|
196
|
+
const layout = (t: ReturnType<typeof ticket>, over: Record<string, unknown> = {}) =>
|
|
197
|
+
rowLayout(t, { isInPlay: false, hasMarkColumn: false, pendingWork: null, columns: 60, ...over })
|
|
198
|
+
|
|
199
|
+
test('rowLayout: a frontier row carries the work button, the number, the title and the type', () => {
|
|
200
|
+
expect(layout(ticket())).toEqual({
|
|
201
|
+
mark: null,
|
|
202
|
+
label: '#209 Burn-down view of the map',
|
|
203
|
+
type: ' build',
|
|
204
|
+
aside: { kind: 'work', text: 'work' },
|
|
205
|
+
isDim: false,
|
|
206
|
+
isInPlay: false,
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('rowLayout: the pending ticket shows queued in place of the button', () => {
|
|
211
|
+
expect(layout(ticket(), { pendingWork: 209 }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
212
|
+
expect(layout(ticket(), { pendingWork: 210 }).aside).toEqual({ kind: 'work', text: 'work' })
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('rowLayout: holders are drawn as @login on in-progress and blocked rows', () => {
|
|
216
|
+
expect(layout(ticket({ state: 'in-progress', assignees: ['alice'] })).aside).toEqual({ kind: 'holder', text: '@alice' })
|
|
217
|
+
expect(layout(ticket({ state: 'in-progress', assignees: ['alice', 'bob'] })).aside).toEqual({ kind: 'holder', text: '@alice @bob' })
|
|
218
|
+
const blocked = layout(ticket({ state: 'blocked', assignees: ['bob'] }))
|
|
219
|
+
expect(blocked.aside).toEqual({ kind: 'holder', text: '@bob' })
|
|
220
|
+
expect(blocked.isDim).toBe(true)
|
|
221
|
+
const loose = layout(ticket({ state: 'blocked' }))
|
|
222
|
+
expect(loose.aside).toBeNull()
|
|
223
|
+
expect(loose.isDim).toBe(true)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('rowLayout: the mark column marks the ticket in play and pads the others', () => {
|
|
227
|
+
expect(layout(ticket({ state: 'in-progress', assignees: ['me'] }), { isInPlay: true, hasMarkColumn: true })).toMatchObject({ mark: '▸ ', isInPlay: true })
|
|
228
|
+
expect(layout(ticket(), { hasMarkColumn: true }).mark).toBe(' ')
|
|
229
|
+
expect(layout(ticket(), { isInPlay: true }).mark).toBe('▸ ')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('rowLayout: a long title is cut so the row never passes the width', () => {
|
|
233
|
+
const t = ticket({ title: 'x'.repeat(200) })
|
|
234
|
+
for (const columns of [30, 40, 60]) {
|
|
235
|
+
const r = layout(t, { columns })
|
|
236
|
+
const used = (r.mark?.length ?? 0) + r.label.length + (r.type?.length ?? 0) + (r.aside === null ? 0 : r.aside.text.length + 4) + 1
|
|
237
|
+
expect(r.label.endsWith('…')).toBe(true)
|
|
238
|
+
expect(used).toBeLessThanOrEqual(columns)
|
|
239
|
+
}
|
|
240
|
+
expect(layout(ticket({ type: null })).type).toBeNull()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// Burn-down history (#228)
|
|
244
|
+
const row = (over: Record<string, unknown> = {}) => ({
|
|
245
|
+
number: 1,
|
|
246
|
+
labels: labels('trailhead:build'),
|
|
247
|
+
createdAt: '2026-10-01T00:00:00Z',
|
|
248
|
+
closedAt: '0001-01-01T00:00:00Z',
|
|
249
|
+
...over,
|
|
250
|
+
})
|
|
251
|
+
const at = (iso: string) => Date.parse(iso)
|
|
252
|
+
const hist = (...issues: { openedAt: number; closedAt: number | null; isRemoved?: boolean }[]) => ({
|
|
253
|
+
issues: issues.map(i => ({ isRemoved: false, ...i })),
|
|
254
|
+
isCapped: false,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('parseHistory keeps opened and closed times and flags removed tickets', () => {
|
|
258
|
+
const out = parseHistory(
|
|
259
|
+
list(
|
|
260
|
+
row({ number: 1, closedAt: '2026-10-03T00:00:00Z' }),
|
|
261
|
+
row({ number: 2 }),
|
|
262
|
+
row({ number: 3, labels: labels('trailhead:superseded'), closedAt: '2026-10-04T00:00:00Z' }),
|
|
263
|
+
row({ number: 4, labels: labels('trailhead:out-of-scope'), closedAt: '2026-10-05T00:00:00Z' }),
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
expect(out?.isCapped).toBe(false)
|
|
267
|
+
expect(out?.issues).toEqual([
|
|
268
|
+
{ openedAt: at('2026-10-01T00:00:00Z'), closedAt: at('2026-10-03T00:00:00Z'), isRemoved: false },
|
|
269
|
+
{ openedAt: at('2026-10-01T00:00:00Z'), closedAt: null, isRemoved: false },
|
|
270
|
+
{ openedAt: at('2026-10-01T00:00:00Z'), closedAt: at('2026-10-04T00:00:00Z'), isRemoved: true },
|
|
271
|
+
{ openedAt: at('2026-10-01T00:00:00Z'), closedAt: at('2026-10-05T00:00:00Z'), isRemoved: true },
|
|
272
|
+
])
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('parseHistory skips unverified and whiteboard rows and unreadable dates', () => {
|
|
276
|
+
const out = parseHistory(
|
|
277
|
+
list(
|
|
278
|
+
row({ number: 1, labels: labels('trailhead:unverified') }),
|
|
279
|
+
row({ number: 2, labels: labels('trailhead:whiteboard') }),
|
|
280
|
+
row({ number: 3, createdAt: 'yesterday' }),
|
|
281
|
+
row({ number: 4, createdAt: 12 }),
|
|
282
|
+
row({ number: 5, closedAt: '2026-09-30T00:00:00Z' }),
|
|
283
|
+
row({ number: 6, closedAt: 'soon' }),
|
|
284
|
+
row({ number: 7, closedAt: undefined }),
|
|
285
|
+
row({ number: 8, closedAt: null }),
|
|
286
|
+
row({ number: 9, closedAt: '1970-01-01T00:00:00Z' }),
|
|
287
|
+
row({ number: 10, closedAt: 12 }),
|
|
288
|
+
'junk',
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
// a closedAt that is a string but unreadable skips the row; null, absent and the zero date mean open
|
|
292
|
+
expect(out?.issues).toEqual([
|
|
293
|
+
{ openedAt: at('2026-10-01T00:00:00Z'), closedAt: null, isRemoved: false },
|
|
294
|
+
{ openedAt: at('2026-10-01T00:00:00Z'), closedAt: null, isRemoved: false },
|
|
295
|
+
{ openedAt: at('2026-10-01T00:00:00Z'), closedAt: null, isRemoved: false },
|
|
296
|
+
])
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('parseHistory returns null for output that is not a JSON array', () => {
|
|
300
|
+
expect(parseHistory('not json')).toBeNull()
|
|
301
|
+
expect(parseHistory('{"a":1}')).toBeNull()
|
|
302
|
+
expect(parseHistory('')).toBeNull()
|
|
303
|
+
expect(parseHistory('[]')).toEqual({ issues: [], isCapped: false })
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('parseHistory reports a capped read by the raw row count', () => {
|
|
307
|
+
const rows = Array.from({ length: HISTORY_LIMIT }, (_, i) => row({ number: i + 1 }))
|
|
308
|
+
expect(HISTORY_LIMIT).toBe(5000)
|
|
309
|
+
expect(parseHistory(JSON.stringify(rows))?.isCapped).toBe(true)
|
|
310
|
+
expect(parseHistory(JSON.stringify(rows.slice(1)))?.isCapped).toBe(false)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('burndownSeries counts open tickets at evenly spaced samples ending at now', () => {
|
|
314
|
+
const s = burndownSeries(hist({ openedAt: 0, closedAt: 50 }, { openedAt: 0, closedAt: null }, { openedAt: 30, closedAt: 80 }), 100, 11)
|
|
315
|
+
expect(s?.values).toEqual([2, 2, 2, 3, 3, 2, 2, 2, 1, 1, 1])
|
|
316
|
+
expect(s).toMatchObject({ open: 1, peak: 3, done: 2, removed: 0, startedAt: 0 })
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('burndownSeries counts an event exactly on a sample at that sample', () => {
|
|
320
|
+
const s = burndownSeries(hist({ openedAt: 0, closedAt: 40 }, { openedAt: 20, closedAt: null }), 100, 6)
|
|
321
|
+
// samples at 0 20 40 60 80 100
|
|
322
|
+
expect(s?.values).toEqual([1, 2, 1, 1, 1, 1])
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
test('burndownSeries: the peak is the most open at once, even between two samples', () => {
|
|
326
|
+
// samples at 0 50 100; three open at once only around 20
|
|
327
|
+
const s = burndownSeries(hist({ openedAt: 0, closedAt: 30 }, { openedAt: 10, closedAt: 40 }, { openedAt: 20, closedAt: 45 }), 100, 3)
|
|
328
|
+
expect(s?.values).toEqual([1, 0, 0])
|
|
329
|
+
expect(s?.peak).toBe(3)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('burndownSeries: no samples without history or buckets, one sample is now', () => {
|
|
333
|
+
expect(burndownSeries(hist(), 100, 10)).toBeNull()
|
|
334
|
+
expect(burndownSeries(hist({ openedAt: 0, closedAt: null }), 100, 0)).toBeNull()
|
|
335
|
+
expect(burndownSeries(hist({ openedAt: 0, closedAt: null }), 100, -3)).toBeNull()
|
|
336
|
+
const one = burndownSeries(hist({ openedAt: 0, closedAt: 50 }, { openedAt: 10, closedAt: null }), 100, 1)
|
|
337
|
+
expect(one?.values).toEqual([1])
|
|
338
|
+
expect(one?.open).toBe(1)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('burndownSeries: a start equal to now is a flat series', () => {
|
|
342
|
+
const s = burndownSeries(hist({ openedAt: 100, closedAt: null }), 100, 4)
|
|
343
|
+
expect(s?.values).toEqual([1, 1, 1, 1])
|
|
344
|
+
expect(s?.startedAt).toBe(100)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('burndownSeries clamps future dates to now', () => {
|
|
348
|
+
const s = burndownSeries(hist({ openedAt: 0, closedAt: null }, { openedAt: 500, closedAt: 900 }), 100, 3)
|
|
349
|
+
expect(s?.values).toEqual([1, 1, 1])
|
|
350
|
+
expect(s).toMatchObject({ open: 1, startedAt: 0 })
|
|
351
|
+
expect(burndownSeries(hist({ openedAt: 500, closedAt: null }), 100, 2)).toMatchObject({ values: [1, 1], startedAt: 100 })
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test('burndownSeries agrees with a plain count at every sample on a large history', () => {
|
|
355
|
+
let seed = 7
|
|
356
|
+
const next = () => (seed = (seed * 48271) % 2147483647) / 2147483647
|
|
357
|
+
const issues = Array.from({ length: 400 }, () => {
|
|
358
|
+
const openedAt = Math.floor(next() * 1000)
|
|
359
|
+
return { openedAt, closedAt: next() < 0.5 ? null : openedAt + Math.floor(next() * 600) }
|
|
360
|
+
})
|
|
361
|
+
const s = burndownSeries(hist(...issues), 900, 37)
|
|
362
|
+
const clamp = (v: number) => Math.min(v, 900)
|
|
363
|
+
const startedAt = Math.min(...issues.map(i => clamp(i.openedAt)))
|
|
364
|
+
const expected = Array.from({ length: 37 }, (_, n) => {
|
|
365
|
+
const t = n === 36 ? 900 : startedAt + ((900 - startedAt) * n) / 36
|
|
366
|
+
return issues.filter(i => clamp(i.openedAt) <= t).length - issues.filter(i => i.closedAt !== null && clamp(i.closedAt) <= t).length
|
|
367
|
+
})
|
|
368
|
+
expect(s?.values).toEqual(expected)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('burndownSeries separates done from removed', () => {
|
|
372
|
+
const s = burndownSeries(
|
|
373
|
+
hist(
|
|
374
|
+
{ openedAt: 0, closedAt: 10 },
|
|
375
|
+
{ openedAt: 0, closedAt: 20, isRemoved: true },
|
|
376
|
+
{ openedAt: 0, closedAt: 30, isRemoved: true },
|
|
377
|
+
{ openedAt: 0, closedAt: null },
|
|
378
|
+
),
|
|
379
|
+
100,
|
|
380
|
+
2,
|
|
381
|
+
)
|
|
382
|
+
expect(s).toMatchObject({ open: 1, done: 1, removed: 2, peak: 4 })
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('burndownEnd is now only for a history read by the last good fetch', () => {
|
|
386
|
+
const fresh = { historyAt: 1000, fetchedAt: 1000, error: null }
|
|
387
|
+
expect(burndownEnd(fresh, 5000)).toEqual({ at: 5000, label: 'now' })
|
|
388
|
+
expect(burndownEnd({ ...fresh, historyAt: null }, 5000)).toEqual({ at: 5000, label: 'now' })
|
|
389
|
+
// a history kept from an earlier fetch, or a whole fetch that failed: aged, sampled up to its own time
|
|
390
|
+
expect(burndownEnd({ ...fresh, historyAt: 400 }, 400 + 180_000)).toEqual({ at: 400, label: '3m ago' })
|
|
391
|
+
expect(burndownEnd({ ...fresh, error: 'offline: cannot reach GitHub' }, 1000 + 180_000)).toEqual({ at: 1000, label: '3m ago' })
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test('sparkline draws eighth blocks scaled to the peak, a zero as a space', () => {
|
|
395
|
+
expect(sparkline([0, 1, 2, 4, 8], 8)).toBe(' ▁▂▄█')
|
|
396
|
+
expect(sparkline([1, 8], 800)).toBe('▁▁')
|
|
397
|
+
expect(sparkline([0, 0], 0)).toBe(' ')
|
|
398
|
+
expect(sparkline([], 5)).toBe('')
|
|
399
|
+
expect(sparkline([3, 3, 3], 3)).toBe('███')
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
// The Raster `cells` prop back into [codePoint, fg, bg] words
|
|
403
|
+
// The test runtime has no Buffer, so the decoder is written here, apart from the encoder under test
|
|
404
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
405
|
+
const unbase64 = (text: string): Uint8Array => {
|
|
406
|
+
const sextets = Array.from(text.replace(/=+$/, ''), c => ALPHABET.indexOf(c))
|
|
407
|
+
const out: number[] = []
|
|
408
|
+
for (let i = 0; i + 1 < sextets.length; i += 4) {
|
|
409
|
+
const [a, b, c, d] = [sextets[i] as number, sextets[i + 1] as number, sextets[i + 2], sextets[i + 3]]
|
|
410
|
+
out.push((a << 2) | (b >> 4))
|
|
411
|
+
if (c !== undefined) out.push(((b & 15) << 4) | (c >> 2))
|
|
412
|
+
if (c !== undefined && d !== undefined) out.push(((c & 3) << 6) | d)
|
|
413
|
+
}
|
|
414
|
+
return Uint8Array.from(out)
|
|
415
|
+
}
|
|
416
|
+
const decode = (cells: string) => {
|
|
417
|
+
const bytes = unbase64(cells)
|
|
418
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
419
|
+
return Array.from({ length: bytes.byteLength / 4 }, (_, i) => view.getUint32(i * 4, true))
|
|
420
|
+
}
|
|
421
|
+
const glyphs = (words: number[]) => Array.from({ length: words.length / 3 }, (_, i) => String.fromCodePoint(words[i * 3] as number))
|
|
422
|
+
|
|
423
|
+
test('rasterCells packs columns * rows little-endian triplets, top row first', () => {
|
|
424
|
+
const cells = rasterCells([0, 4, 8], 8, 2, 0x7aa2f7)
|
|
425
|
+
const words = decode(cells)
|
|
426
|
+
expect(words).toHaveLength(3 * 2 * 3)
|
|
427
|
+
// row 0 (top): empty, empty, full; row 1: empty, half, full ... heights over 16 levels: 0, 8, 16
|
|
428
|
+
expect(glyphs(words)).toEqual([' ', ' ', '█', ' ', '█', '█'])
|
|
429
|
+
expect(words[2]).toBe(0x01000000)
|
|
430
|
+
expect(words[5]).toBe(0x01000000)
|
|
431
|
+
expect(words[7]).toBe(0x7aa2f7)
|
|
432
|
+
expect(cells).toMatch(/^[A-Za-z0-9+/]+={0,2}$/)
|
|
433
|
+
expect(cells.length % 4).toBe(0)
|
|
434
|
+
// one full cell in the terminal's own example: 0x2588, 0xff8800, 0x01000000 (little-endian)
|
|
435
|
+
expect(rasterCells([1], 1, 1, 0xff8800)).toBe('iCUAAACI/wAAAAAB')
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
test('rasterCells draws a partial top cell with an eighth block and keeps a non-zero value visible', () => {
|
|
439
|
+
// 2 rows = 16 levels; 12 of 16 -> one full cell and a half block above it
|
|
440
|
+
expect(glyphs(decode(rasterCells([12], 16, 2, 0xffffff)))).toEqual(['▄', '█'])
|
|
441
|
+
expect(glyphs(decode(rasterCells([1], 1000, 3, 0xffffff)))).toEqual([' ', ' ', '▁'])
|
|
442
|
+
expect(glyphs(decode(rasterCells([0, 0], 0, 1, 0xffffff)))).toEqual([' ', ' '])
|
|
443
|
+
expect(decode(rasterCells([], 3, 3, 0xffffff))).toEqual([])
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
test('rasterCells encodes whole base64 for every cell count', () => {
|
|
447
|
+
for (const n of [1, 2, 3, 4, 5]) {
|
|
448
|
+
const cells = rasterCells(Array.from({ length: n }, () => 1), 1, 1, 0x112233)
|
|
449
|
+
// 12 bytes per cell: a multiple of 3, so never padded; a known literal pins the alphabet
|
|
450
|
+
expect(cells.length).toBe(n * 16)
|
|
451
|
+
expect(decode(cells)).toHaveLength(n * 3)
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
test('axisStart words the left label as a date, or a time when the history is under a day', () => {
|
|
456
|
+
const now = at('2026-10-12T15:30:00Z')
|
|
457
|
+
expect(axisStart(at('2026-09-18T08:00:00Z'), now)).toBe('Sep 18')
|
|
458
|
+
expect(axisStart(at('2026-01-05T00:00:00Z'), now)).toBe('Jan 5')
|
|
459
|
+
expect(axisStart(at('2026-10-12T09:05:00Z'), now)).toBe('today 09:05')
|
|
460
|
+
expect(axisStart(now, now)).toBe('today 15:30')
|
|
461
|
+
expect(axisStart(now - 86_400_000, now)).toBe('Oct 11')
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
test('parseMaps keeps the open maps sorted by number, titles flattened', () => {
|
|
465
|
+
const out = parseMaps(
|
|
466
|
+
JSON.stringify([
|
|
467
|
+
{ number: 198, title: 'trailhead\u202e mod\n', url: 'https://github.com/o/r/issues/198' },
|
|
468
|
+
{ number: 14, title: 'older map' },
|
|
469
|
+
{ number: 0, title: 'bad number' },
|
|
470
|
+
{ number: 'x', title: 'worse number' },
|
|
471
|
+
{ number: 20 },
|
|
472
|
+
'junk',
|
|
473
|
+
]),
|
|
474
|
+
)
|
|
475
|
+
expect(out).toEqual([
|
|
476
|
+
{ number: 14, title: 'older map' },
|
|
477
|
+
{ number: 20, title: '' },
|
|
478
|
+
{ number: 198, title: 'trailhead mod' },
|
|
479
|
+
])
|
|
480
|
+
expect(parseMaps('[]')).toEqual([])
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
test('parseMaps rejects what is not a list', () => {
|
|
484
|
+
expect(parseMaps('not json')).toBeNull()
|
|
485
|
+
expect(parseMaps('{"number":1}')).toBeNull()
|
|
486
|
+
expect(parseMaps('null')).toBeNull()
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
test('viewTarget is the viewed map, else the active one', () => {
|
|
490
|
+
expect(viewTarget(198, null)).toBe(198)
|
|
491
|
+
expect(viewTarget(198, 14)).toBe(14)
|
|
492
|
+
expect(viewTarget(null, null)).toBeNull()
|
|
493
|
+
expect(viewTarget(null, 14)).toBe(14)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
test('nextViewed resets when the active map changed or the viewed map left the open list', () => {
|
|
497
|
+
const maps = [{ number: 14, title: 'a' }, { number: 198, title: 'b' }]
|
|
498
|
+
expect(nextViewed(14, { activeChanged: false, maps })).toBe(14)
|
|
499
|
+
expect(nextViewed(14, { activeChanged: true, maps })).toBeNull()
|
|
500
|
+
expect(nextViewed(14, { activeChanged: false, maps: [{ number: 198, title: 'b' }] })).toBeNull()
|
|
501
|
+
expect(nextViewed(14, { activeChanged: false, maps: [] })).toBeNull()
|
|
502
|
+
// no list read yet (or a failed read): nothing to judge by
|
|
503
|
+
expect(nextViewed(14, { activeChanged: false, maps: null })).toBe(14)
|
|
504
|
+
expect(nextViewed(null, { activeChanged: false, maps })).toBeNull()
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
test('mapRowLayout marks the active map, bolds the viewed one and presses the others', () => {
|
|
508
|
+
const m = { number: 14, title: 'older map' }
|
|
509
|
+
expect(mapRowLayout(m, { active: 198, viewed: null, columns: 60 })).toEqual({
|
|
510
|
+
mark: ' ',
|
|
511
|
+
label: '#14 older map',
|
|
512
|
+
aside: null,
|
|
513
|
+
isActive: false,
|
|
514
|
+
isViewed: false,
|
|
515
|
+
isPressable: true,
|
|
516
|
+
})
|
|
517
|
+
const act = mapRowLayout({ number: 198, title: 'trailhead mod' }, { active: 198, viewed: null, columns: 60 })
|
|
518
|
+
expect(act).toMatchObject({ mark: '● ', aside: ' active', isActive: true, isViewed: true, isPressable: false })
|
|
519
|
+
// viewing another map: the active one becomes pressable (back to active), the viewed one is not
|
|
520
|
+
expect(mapRowLayout({ number: 198, title: 't' }, { active: 198, viewed: 14, columns: 60 })).toMatchObject({
|
|
521
|
+
isActive: true,
|
|
522
|
+
isViewed: false,
|
|
523
|
+
isPressable: true,
|
|
524
|
+
})
|
|
525
|
+
expect(mapRowLayout(m, { active: 198, viewed: 14, columns: 60 })).toMatchObject({ isActive: false, isViewed: true, isPressable: false })
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
test('mapRowLayout cuts the title to the room left by the mark and the aside', () => {
|
|
529
|
+
const long = { number: 198, title: 'x'.repeat(200) }
|
|
530
|
+
const act = mapRowLayout(long, { active: 198, viewed: null, columns: 30 })
|
|
531
|
+
expect(act.label.endsWith('…')).toBe(true)
|
|
532
|
+
expect(act.mark.length + act.label.length + (act.aside?.length ?? 0)).toBeLessThanOrEqual(30)
|
|
533
|
+
const other = mapRowLayout({ number: 14, title: 'x'.repeat(200) }, { active: 198, viewed: null, columns: 30 })
|
|
534
|
+
// a Button draws `[ label ]`: four cells of chrome
|
|
535
|
+
expect(other.mark.length + other.label.length + 4).toBeLessThanOrEqual(30)
|
|
536
|
+
expect(mapRowLayout({ number: 14, title: '' }, { active: 198, viewed: null, columns: 30 }).label).toBe('#14')
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
test('footerStatus keeps a status that fits beside the two buttons on their row', () => {
|
|
540
|
+
expect(footerStatus('updated just now', 60)).toEqual({ text: 'updated just now', isInline: true })
|
|
541
|
+
// 60 columns minus `[ refresh ]`, `[ close ]` and the two gaps leaves 38 cells
|
|
542
|
+
expect(footerStatus('⚠ gh failed (exit 1) · list from 3m ago', 60)).toEqual({
|
|
543
|
+
text: '⚠ gh failed (exit 1) · list from 3m ago',
|
|
544
|
+
isInline: false,
|
|
545
|
+
})
|
|
546
|
+
expect(footerStatus('⚠ gh failed (exit 1) · list from 3m', 60)).toEqual({ text: '⚠ gh failed (exit 1) · list from 3m', isInline: true })
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
test('footerStatus moves a long status to a row of its own, whole when the pane is wide enough', () => {
|
|
550
|
+
expect(footerStatus('⚠ gh not authenticated (run gh auth login) · list from 12m ago', 70)).toEqual({
|
|
551
|
+
text: '⚠ gh not authenticated (run gh auth login) · list from 12m ago',
|
|
552
|
+
isInline: false,
|
|
553
|
+
})
|
|
554
|
+
// at the default 60 columns the hint survives, only the tail of the age goes
|
|
555
|
+
expect(footerStatus('⚠ gh not authenticated (run gh auth login) · list from 12m ago', 60)).toEqual({
|
|
556
|
+
text: '⚠ gh not authenticated (run gh auth login) · list from 12m …',
|
|
557
|
+
isInline: false,
|
|
558
|
+
})
|
|
559
|
+
expect(footerStatus('⚠ gh not authenticated (run gh auth login)', 30)).toEqual({ text: '⚠ gh not authenticated (run g…', isInline: false })
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
test('footerStatus on a pane too narrow for the buttons never throws', () => {
|
|
563
|
+
expect(footerStatus('updated just now', 10)).toEqual({ text: 'updated j…', isInline: false })
|
|
564
|
+
expect(footerStatus('updated just now', 0)).toEqual({ text: '…', isInline: false })
|
|
565
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
// Fails when the module does not load; a passthrough leaves session.start as raised.
|
|
4
|
+
test('the module loads and leaves session.start unchanged', async ($, on) => {
|
|
5
|
+
const seen: unknown[] = []
|
|
6
|
+
on('session.start', ($, e) => {
|
|
7
|
+
seen.push(e)
|
|
8
|
+
return { cwd: e.cwd }
|
|
9
|
+
})
|
|
10
|
+
const input = { cwd: '/repo', surface: 'terminal', isInteractive: true } as const
|
|
11
|
+
const result = await $.session.start(input)
|
|
12
|
+
expect(result).toEqual({ cwd: '/repo' })
|
|
13
|
+
expect(seen).toEqual([input])
|
|
14
|
+
})
|