@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.
Files changed (61) hide show
  1. package/README.it.md +28 -3
  2. package/README.md +38 -3
  3. package/bin/lib/codex-projection.js +16 -9
  4. package/bin/lib/codex-projection.test.js +39 -11
  5. package/bin/lib/install-verify.js +10 -5
  6. package/bin/lib/install-verify.test.js +51 -0
  7. package/bin/trailhead.js +37 -25
  8. package/bin/trailhead.test.js +162 -5
  9. package/package.json +4 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  11. package/plugins/trailhead/commands/panel.md +10 -0
  12. package/plugins/trailhead/hooks/hooks.json +3 -18
  13. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  14. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  15. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  16. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  17. package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
  18. package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
  19. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  20. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  21. package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
  22. package/plugins/trailhead/hooks/mod/README.md +187 -0
  23. package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
  24. package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
  25. package/plugins/trailhead/hooks/mod/register.tsx +280 -0
  26. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  27. package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
  28. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  29. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  30. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  31. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  32. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  33. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  34. package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
  35. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
  36. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  38. package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
  39. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
  40. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  42. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  44. package/plugins/trailhead/skills/_shared/choices.md +3 -1
  45. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  46. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  47. package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
  48. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  49. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  50. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  51. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  52. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
  53. package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
  54. package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
  55. package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
  56. package/plugins/trailhead/tests/mod.test.ts +14 -0
  57. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  58. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  59. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  60. package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
  61. package/plugins/trailhead/types/index.d.ts +69 -0
@@ -0,0 +1,1374 @@
1
+ import type { On } from 'claude-code'
2
+ import { expect, mock, test } from 'claude-code/testing'
3
+
4
+ const SURFACES = ['terminal', 'desktop'] as const
5
+ const SESSION = 'sess-A'
6
+ const NEXT_SESSION = 'sess-B'
7
+ const PANE = 'trailhead-map'
8
+ const ACCENT = '#7aa2f7'
9
+ const T0 = 1_000_000
10
+ const FLOOR = 15_000
11
+ const TIMER = 180_000
12
+ const AMBER = '#e0af68'
13
+ const URL = 'https://github.com/o/r/issues'
14
+
15
+ const MAP = {
16
+ title: 'trailhead mod',
17
+ body: '## Destination\n\nA released trailhead version whose Claude Code mod is done and shipped to every user.\n\n## Decisions\n',
18
+ url: `${URL}/198`,
19
+ }
20
+ const issue = (number: number, title: string, labels: string[], assignees: string[] = []) => ({
21
+ number,
22
+ title,
23
+ labels: labels.map(name => ({ name })),
24
+ assignees: assignees.map(login => ({ login })),
25
+ url: `${URL}/${number}`,
26
+ })
27
+ const TICKETS = [
28
+ issue(214, 'Porting order of the guards', ['trailhead:decision']),
29
+ issue(209, 'Burn-down view of the map', ['trailhead:build']),
30
+ issue(210, 'Ticket-closed toast', ['trailhead:build']),
31
+ issue(208, 'Live map pane listing the tickets', ['trailhead:build'], ['ToRvaLDz']),
32
+ issue(212, 'Native gh-backed tools', ['trailhead:build'], ['alice']),
33
+ issue(211, 'Model enforcement at spawn', ['trailhead:build', 'trailhead:blocked']),
34
+ issue(213, 'auto loop on turn events', ['trailhead:build', 'trailhead:blocked'], ['bob']),
35
+ issue(215, 'Quarantined', ['trailhead:build', 'trailhead:unverified']),
36
+ ]
37
+
38
+ // The map's history as `gh issue list --state all` prints it: the 7 listed open tickets and the
39
+ // unverified one (never counted), plus 2 done and 1 superseded, all opened a minute after the epoch
40
+ // (the mock clock starts at T0 = 1_000_000 ms) and closed at 2, 3 and 4 minutes.
41
+ const at = (seconds: number) => new Date(seconds * 1000).toISOString()
42
+ const OPEN_ZERO = '0001-01-01T00:00:00Z'
43
+ const hrow = (number: number, labels: string[], closedSeconds: number | null) => ({
44
+ number,
45
+ labels: labels.map(name => ({ name })),
46
+ createdAt: at(60),
47
+ closedAt: closedSeconds === null ? OPEN_ZERO : at(closedSeconds),
48
+ })
49
+ const HISTORY = [
50
+ ...TICKETS.map(t => hrow(t.number, t.labels.map(l => l.name), null)),
51
+ hrow(201, ['trailhead:build'], 120),
52
+ hrow(202, ['trailhead:build'], 180),
53
+ hrow(203, ['trailhead:build', 'trailhead:superseded'], 240),
54
+ ]
55
+ // 60 columns over 60..1000 s: the three closings land on samples 4, 8 and 12 (peak 10)
56
+ const SPARK = '█'.repeat(4) + '▇'.repeat(4) + '▆'.repeat(52)
57
+
58
+ // The repo's open maps as `gh issue list --label trailhead:map` prints them, and the tickets of the second.
59
+ const OTHER_MAP = { title: 'older map', body: '## Destination\n\nAn older destination.\n', url: `${URL}/14` }
60
+ const MAPS = [
61
+ { number: 198, title: 'trailhead mod', url: `${URL}/198` },
62
+ { number: 14, title: 'older map', url: `${URL}/14` },
63
+ ]
64
+ const OTHER_TICKETS = [issue(140, 'Old frontier ticket', ['trailhead:build']), issue(141, 'Old held ticket', ['trailhead:task'], ['carol'])]
65
+ const OTHER_HISTORY = OTHER_TICKETS.map(t => hrow(t.number, t.labels.map(l => l.name), null))
66
+
67
+ const stateText = (over: Record<string, unknown> = {}): string =>
68
+ JSON.stringify({
69
+ map: 198,
70
+ ticket: 208,
71
+ title: 'Live map pane listing the tickets',
72
+ step: 'execute',
73
+ auto: false,
74
+ autoDone: 0,
75
+ session: SESSION,
76
+ updatedAt: '2026-10-11T00:00:00.000Z',
77
+ ...over,
78
+ })
79
+
80
+ // The plugin's atoms, which the engine resets on /clear.
81
+ const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'pendingWork', 'paneAutoOpened']
82
+
83
+ const PANE_PROPS = {
84
+ title: 'Map',
85
+ isFocused: false,
86
+ bodyColumns: 60,
87
+ placement: 'dock',
88
+ scroll: { offset: 0, bodyRows: 30 },
89
+ view: {},
90
+ } as const
91
+
92
+ type GhOut = { exitCode: number; stdout: string; stderr: string }
93
+ type Gh = (argv: readonly string[]) => GhOut | Promise<GhOut>
94
+ const ok = (stdout: string): GhOut => ({ exitCode: 0, stdout, stderr: '' })
95
+
96
+ // What the engine would answer beneath the plugin: the marker files, `gh`, the pane
97
+ // bookkeeping, a mocked clock. `calls` records every `gh` run.
98
+ const rig = (on: On, engine?: any) => {
99
+ const r = {
100
+ active: '198\n' as string | Error | null,
101
+ work: null as string | null,
102
+ tickets: TICKETS as unknown[],
103
+ history: HISTORY as unknown[],
104
+ map: MAP as unknown,
105
+ // The open maps list, and the second map's own answers (view, open list, history).
106
+ maps: MAPS as unknown[],
107
+ otherMap: OTHER_MAP as unknown,
108
+ otherTickets: OTHER_TICKETS as unknown[],
109
+ otherHistory: OTHER_HISTORY as unknown[],
110
+ gh: null as Gh | null,
111
+ // What `gh` answers when no test replaced it; a test can wrap it.
112
+ defaultGh: null as unknown as Gh,
113
+ deny: null as string | null,
114
+ calls: [] as (readonly string[])[],
115
+ panes: [] as { id: string; title: string; isShown: boolean; isFocused: boolean; isPlaced: boolean }[],
116
+ isPlaced: true,
117
+ logs: [] as string[],
118
+ opens: [] as { id: string; title?: string }[],
119
+ // `command.run` beneath the plugin: every run is recorded; `gate` holds a work run, `refuse` rejects it.
120
+ // Every file write beneath the plugin: the pane must never write the active-map marker.
121
+ writes: [] as string[],
122
+ runs: [] as { command: string; args: string }[],
123
+ gate: null as Promise<void> | null,
124
+ refuse: null as string | null,
125
+ // The built-in `clear`: `clearGate` holds it, `refuseClear` rejects it.
126
+ clearGate: null as Promise<void> | null,
127
+ refuseClear: null as string | null,
128
+ // The clear answered by a `{ deny }` hook: it resolves the call and the session stays.
129
+ denyClear: false,
130
+ // Runs between the old session ending and the new one starting.
131
+ duringClear: null as (() => void) | null,
132
+ session: SESSION,
133
+ clock: mock.clock(on, { now: T0 }),
134
+ // The person opening the pane: the engine's own record, the plugin is not told.
135
+ open: () => {
136
+ r.panes = [{ id: PANE, title: 'Map', isShown: true, isFocused: false, isPlaced: r.isPlaced }]
137
+ },
138
+ }
139
+ const defaultGh: Gh = argv => {
140
+ if (argv[2] === 'list' && argv.includes('trailhead:map')) return ok(JSON.stringify(r.maps))
141
+ const isOther = argv.includes('14') || argv.includes('trailhead:map-14')
142
+ if (argv[2] === 'view') return ok(JSON.stringify(isOther ? r.otherMap : r.map))
143
+ return ok(JSON.stringify(argv.includes('all') ? (isOther ? r.otherHistory : r.history) : isOther ? r.otherTickets : r.tickets))
144
+ }
145
+ on('fs.read', (_$, e) => {
146
+ const file = e.path.endsWith('/.trailhead/active-map') ? r.active : e.path.endsWith('/.trailhead/work-state.json') ? r.work : undefined
147
+ if (file === undefined) return { deny: `unexpected read of ${e.path}` }
148
+ if (file === null) return { deny: `ENOENT: no such file or directory, open '${e.path}'` }
149
+ if (file instanceof Error) return { deny: file.message }
150
+ return { value: file }
151
+ })
152
+ r.defaultGh = defaultGh
153
+ on('fs.write', (_$, e) => {
154
+ r.writes.push(e.path)
155
+ return { value: undefined }
156
+ })
157
+ // The engine's /clear, in the order seen live: the old session ends, the atoms reset and the id
158
+ // changes, the new session starts. The reset is the atoms reading as never written until the
159
+ // plugin writes them again (a test cannot write the plugin's state).
160
+ const emptied = new Set<string>()
161
+ let clears = 0
162
+ on('state.get', { plugin: 'trailhead' }, async (_$, e, next) => {
163
+ const read = await next(e)
164
+ return emptied.has(e.key) ? { value: { value: undefined, version: read.value?.version ?? 0 } } : read
165
+ })
166
+ on('state.set', { plugin: 'trailhead' }, (_$, e, next) => {
167
+ emptied.delete(e.key)
168
+ return next(e)
169
+ })
170
+ const emulateClear = async () => {
171
+ await engine.session.end({ reason: 'clear', sessionId: r.session, resume: {} })
172
+ r.duringClear?.()
173
+ ATOM_KEYS.forEach(key => emptied.add(key))
174
+ r.session = `${NEXT_SESSION}-${(clears += 1)}`
175
+ await engine.classic.SessionStart({ source: 'clear' })
176
+ }
177
+ on('command.run', async (_$, e) => {
178
+ r.runs.push({ command: e.command, args: e.args })
179
+ if (e.command === 'clear') {
180
+ await r.clearGate
181
+ if (r.refuseClear !== null) throw new Error(r.refuseClear)
182
+ if (r.denyClear) return { deny: 'clear refused' }
183
+ await emulateClear()
184
+ return { text: '' }
185
+ }
186
+ await r.gate
187
+ // A hook that throws is skipped, so the caller's `$.command.run` rejects (a `{ deny }` would resolve).
188
+ if (r.refuse !== null) throw new Error(r.refuse)
189
+ return { text: '' }
190
+ })
191
+ on('session.id', () => ({ value: r.session }))
192
+ on('ui.log', (_$, e) => {
193
+ r.logs.push(e.text)
194
+ return { value: undefined }
195
+ })
196
+ on('ui.status', () => ({ value: undefined }))
197
+ on('process.run', async (_$, e) => {
198
+ r.calls.push([...e.argv])
199
+ if (r.deny !== null) return { deny: r.deny }
200
+ const out = await (r.gh ?? defaultGh)(e.argv)
201
+ return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
202
+ })
203
+ on('ui.open', (_$, e) => {
204
+ r.opens.push({ id: e.id, title: e.title })
205
+ r.panes = [
206
+ ...r.panes.filter(p => p.id !== e.id),
207
+ { id: e.id, title: e.title ?? e.id, isShown: true, isFocused: false, isPlaced: r.isPlaced },
208
+ ]
209
+ return { value: r.isPlaced ? { isPlaced: true as const } : { isPlaced: false as const, reason: 'terminal too narrow' } }
210
+ })
211
+ on('ui.close', (_$, e) => {
212
+ r.panes = r.panes.filter(p => p.id !== e.id)
213
+ return { value: undefined }
214
+ })
215
+ on('ui.panes', () => ({ value: r.panes }))
216
+ on('tool.call', { tool: 'Bash' }, () => ({ result: 'ok' }) as never)
217
+ on('session.start', (_$, e) => ({ cwd: e.cwd }))
218
+ on('classic.SessionStart', () => ({}))
219
+ on('turn.start', (_$, e) => ({ turnId: e.turnId }))
220
+ on('turn.complete', () => ({ text: '' }))
221
+ on('session.end', (_$, e) => ({ sessionId: e.sessionId }))
222
+ on('ui.render', () => ({ type: 'Box', props: { key: 'engine' }, children: [] }))
223
+ return r
224
+ }
225
+ type Rig = ReturnType<typeof rig>
226
+
227
+ const BAND_PROPS = {
228
+ hasSurvey: false,
229
+ isWorking: false,
230
+ maxRows: 10,
231
+ bodyColumns: 80,
232
+ scroll: { offset: 0, bodyRows: 10 },
233
+ view: {},
234
+ } as const
235
+ const FULLSCREEN = { columns: 200, rows: 50, isFullscreen: true } as const
236
+
237
+ const START = { cwd: '/repo', surface: 'terminal', isInteractive: true } as const
238
+ let turns = 0
239
+ const completeTurn = ($: any) =>
240
+ $.turn.complete({ turnId: `t${(turns += 1)}`, answer: '', durationMs: 1, isAborted: false, reason: 'answer' })
241
+ // Lets detached work (a refresh started and not awaited) run to its end.
242
+ const settle = async (r: Rig) => {
243
+ for (let i = 0; i < 10; i += 1) await r.clock.settle()
244
+ }
245
+ // The open-list reads; the history read rides along with each and is not counted.
246
+ const fetches = (r: Rig) => r.calls.filter(argv => argv[2] === 'list' && argv.includes('open') && !argv.includes('trailhead:map')).length
247
+ // A gh that answers as the default does, except for the history read.
248
+ const withHistory = (r: Rig, answer: GhOut): Gh => argv => {
249
+ if (argv.includes('all')) return answer
250
+ if (argv.includes('trailhead:map')) return ok(JSON.stringify(r.maps))
251
+ return argv[2] === 'view' ? ok(JSON.stringify(r.map)) : ok(JSON.stringify(r.tickets))
252
+ }
253
+ const hasKey = async (ui: any, key: string): Promise<boolean> => (await ui.find({ key })) !== undefined
254
+ const textOf = async (ui: any, key: string): Promise<string | undefined> => (await ui.find({ key }))?.text
255
+ const keysOf = async (ui: any, prefix: string): Promise<string[]> =>
256
+ (await ui.findAll({ type: 'Box' })).map((el: any) => el.key).filter((k: string | undefined) => k?.startsWith(prefix))
257
+
258
+ for (const surface of SURFACES) {
259
+ const mountPane = ($: any, props: Record<string, unknown> = {}) =>
260
+ $.ui.mount({ plugin: 'trailhead', surface, component: 'Pane', requestId: PANE, props: { ...PANE_PROPS, ...props } })
261
+ const openAndMount = async ($: any, r: Rig, props: Record<string, unknown> = {}) => {
262
+ r.open()
263
+ const ui = await mountPane($, props)
264
+ await settle(r)
265
+ return ui
266
+ }
267
+
268
+ test(`the pane lists the tickets by state with holders and the unverified count [${surface}]`, async ($, on) => {
269
+ const r = rig(on, $)
270
+ r.work = stateText()
271
+ await $.session.start(START)
272
+ const ui = await openAndMount($, r)
273
+
274
+ expect(r.calls).toEqual([
275
+ ['gh', 'issue', 'view', '198', '--json', 'title,body,url'],
276
+ ['gh', 'issue', 'list', '--label', 'trailhead:map-198', '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url'],
277
+ ['gh', 'issue', 'list', '--label', 'trailhead:map-198', '--state', 'all', '--limit', '5000', '--json', 'number,labels,createdAt,closedAt'],
278
+ ['gh', 'issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', '100', '--json', 'number,title,url'],
279
+ ])
280
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
281
+ expect((await ui.find({ type: 'Link' }))?.props).toMatchObject({ href: `${URL}/198` })
282
+ expect((await ui.find({ key: 'map' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
283
+ expect(await textOf(ui, 'destination')).toBe('A released trailhead version whose Claude Code mod is done …')
284
+ expect((await ui.find({ key: 'destination' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
285
+
286
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
287
+ expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
288
+ expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
289
+ expect((await ui.find({ key: 'head-frontier' }))?.children[0]).toMatchObject({ props: { bold: true } })
290
+ expect(await keysOf(ui, 'row-')).toEqual(['row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-211', 'row-213'])
291
+
292
+ expect(await textOf(ui, 'ticket-209')).toBe('#209 Burn-down view of the map')
293
+ expect(await textOf(ui, 'type-209')).toBe(' build')
294
+ expect((await ui.find({ key: 'type-209' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
295
+ expect(await textOf(ui, 'type-214')).toBe(' decision')
296
+ const links = (await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)
297
+ expect(links).toContain(`${URL}/209`)
298
+
299
+ // holders on every assigned row; the session's own ticket is marked and in the accent
300
+ expect(await textOf(ui, 'holder-208')).toBe('@ToRvaLDz')
301
+ expect((await ui.find({ key: 'holder-208' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
302
+ expect(await textOf(ui, 'mark-208')).toBe('▸ ')
303
+ expect((await ui.find({ key: 'mark-208' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
304
+ expect((await ui.find({ key: 'ticket-208' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
305
+ expect(await textOf(ui, 'holder-212')).toBe('@alice')
306
+ expect((await ui.find({ key: 'holder-212' }))?.children[0].props).not.toHaveProperty('dimColor')
307
+ expect(await textOf(ui, 'mark-212')).toBe(' ')
308
+ expect(await ui.find({ key: 'mark-209' })).toBeUndefined()
309
+
310
+ // blocked rows are dim, a holder too
311
+ expect((await ui.find({ key: 'ticket-211' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
312
+ expect(await textOf(ui, 'holder-213')).toBe('@bob')
313
+ expect((await ui.find({ key: 'holder-213' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
314
+ expect(await ui.find({ key: 'holder-211' })).toBeUndefined()
315
+
316
+ // the quarantined ticket is counted, never listed
317
+ expect(await ui.find({ key: 'row-215' })).toBeUndefined()
318
+ expect(await textOf(ui, 'unverified')).toBe('1 unverified ticket hidden (quarantined)')
319
+ expect(await textOf(ui, 'age')).toBe('updated just now')
320
+ })
321
+
322
+ test(`ticket rows are spaced apart on the desktop only [${surface}]`, async ($, on) => {
323
+ const r = rig(on)
324
+ await $.session.start(START)
325
+ const ui = await openAndMount($, r)
326
+ const groupProps = (await ui.find({ key: 'group-frontier' }))?.props
327
+ if (surface === 'desktop') expect(groupProps).toMatchObject({ rowGap: 0.5 })
328
+ else expect(groupProps).not.toHaveProperty('rowGap')
329
+ })
330
+
331
+ test(`the burn-down section shows the counts, the chart and the axis [${surface}]`, async ($, on) => {
332
+ const r = rig(on, $)
333
+ await $.session.start(START)
334
+ const ui = await openAndMount($, r)
335
+ expect(await textOf(ui, 'burndown-head')).toBe('Burn-down')
336
+ expect((await ui.find({ key: 'burndown-head' }))?.children[0]).toMatchObject({ props: { bold: true } })
337
+ expect(await textOf(ui, 'burndown-counts')).toBe('open 7 · done 2 · removed 1 · peak 10')
338
+ expect((await ui.find({ key: 'burndown-counts' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
339
+ expect(await textOf(ui, 'burndown-axis-start')).toBe('today 00:01')
340
+ expect(await textOf(ui, 'burndown-axis-end')).toBe('now')
341
+ expect((await ui.find({ key: 'burndown-axis-end' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
342
+ expect((await ui.find({ key: 'burndown-axis' }))?.props).toMatchObject({ width: 60, justifyContent: 'space-between' })
343
+ expect(await ui.find({ key: 'burndown-capped' })).toBeUndefined()
344
+ // after the groups, before the footer
345
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
346
+ expect(keys.indexOf('burndown-head')).toBeGreaterThan(keys.indexOf('unverified'))
347
+ expect(keys.indexOf('burndown-head')).toBeLessThan(keys.indexOf('footer'))
348
+ })
349
+
350
+ test(`the open count at now is the open list when the fixture is consistent [${surface}]`, async ($, on) => {
351
+ const r = rig(on, $)
352
+ await $.session.start(START)
353
+ const ui = await openAndMount($, r)
354
+ const listed = (await keysOf(ui, 'row-')).length
355
+ expect(listed).toBe(7)
356
+ expect(await textOf(ui, 'burndown-counts')).toMatch(new RegExp(`^open ${listed} `))
357
+ })
358
+
359
+ test(`the chart is a Raster where the surface has one, a text sparkline elsewhere [${surface}]`, async ($, on) => {
360
+ const r = rig(on, $)
361
+ await $.session.start(START)
362
+ const ui = await openAndMount($, r)
363
+ if (surface === 'terminal') {
364
+ const raster = await ui.find({ key: 'burndown' })
365
+ expect(raster?.type).toBe('Raster')
366
+ expect(raster?.props).toMatchObject({ columns: 60, rows: 3 })
367
+ expect(typeof raster?.props.cells).toBe('string')
368
+ expect(await ui.find({ key: 'burndown-spark' })).toBeUndefined()
369
+ } else {
370
+ expect(await textOf(ui, 'burndown-spark')).toBe(SPARK)
371
+ expect((await ui.find({ key: 'burndown-spark' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
372
+ expect(await ui.find({ key: 'burndown' })).toBeUndefined()
373
+ }
374
+ })
375
+
376
+ test(`the burn-down follows the pane width [${surface}]`, async ($, on) => {
377
+ const r = rig(on, $)
378
+ await $.session.start(START)
379
+ const ui = await openAndMount($, r, { bodyColumns: 30 })
380
+ if (surface === 'terminal') expect((await ui.find({ key: 'burndown' }))?.props).toMatchObject({ columns: 30 })
381
+ else expect((await textOf(ui, 'burndown-spark'))?.length).toBe(30)
382
+ })
383
+
384
+ test(`no burn-down and no gh call without an active map [${surface}]`, async ($, on) => {
385
+ const none = rig(on, $)
386
+ none.active = null
387
+ await $.session.start(START)
388
+ const idle = await openAndMount($, none)
389
+ expect(await hasKey(idle, 'burndown-head')).toBe(false)
390
+ expect(none.calls).toEqual([])
391
+ })
392
+
393
+ test(`an empty or unreadable history draws no section but the list is shown [${surface}]`, async ($, on) => {
394
+ const r = rig(on, $)
395
+ r.history = []
396
+ await $.session.start(START)
397
+ const ui = await openAndMount($, r)
398
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
399
+ expect(await hasKey(ui, 'burndown-head')).toBe(false)
400
+ expect(await textOf(ui, 'age')).toBe('updated just now')
401
+
402
+ r.gh = withHistory(r, ok('not json'))
403
+ r.history = HISTORY
404
+ await r.clock.advance(FLOOR)
405
+ await completeTurn($)
406
+ await settle(r)
407
+ expect(await hasKey(ui, 'burndown-head')).toBe(false)
408
+ expect(await textOf(ui, 'age')).toBe('updated just now')
409
+ })
410
+
411
+ test(`a history-only failure keeps the last chart, takes the fresh list and ages the axis [${surface}]`, async ($, on) => {
412
+ const r = rig(on, $)
413
+ await $.session.start(START)
414
+ const ui = await openAndMount($, r)
415
+ expect(await textOf(ui, 'burndown-axis-end')).toBe('now')
416
+
417
+ r.gh = withHistory(r, { exitCode: 1, stdout: '', stderr: 'boom' })
418
+ r.tickets = [issue(300, 'fresh one', ['trailhead:build'])]
419
+ await r.clock.advance(TIMER)
420
+ await settle(r)
421
+ // the list never froze
422
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
423
+ expect(await textOf(ui, 'ticket-300')).toBe('#300 fresh one')
424
+ expect(await textOf(ui, 'age')).toBe('updated just now')
425
+ // the chart is the last good one, aged
426
+ expect(await textOf(ui, 'burndown-counts')).toBe('open 7 · done 2 · removed 1 · peak 10')
427
+ expect(await textOf(ui, 'burndown-axis-end')).toBe('3m ago')
428
+
429
+ // recovery: a fresh history is back on "now"
430
+ r.gh = null
431
+ await r.clock.advance(TIMER)
432
+ await settle(r)
433
+ expect(await textOf(ui, 'burndown-axis-end')).toBe('now')
434
+ })
435
+
436
+ test(`a failed list read keeps the whole last data, the chart too [${surface}]`, async ($, on) => {
437
+ const r = rig(on, $)
438
+ await $.session.start(START)
439
+ const ui = await openAndMount($, r)
440
+ r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'error connecting to api.github.com' })
441
+ await r.clock.advance(TIMER)
442
+ await settle(r)
443
+ expect(await textOf(ui, 'age')).toBe('⚠ offline: cannot reach GitHub · list from 3m ago')
444
+ expect(await textOf(ui, 'burndown-counts')).toBe('open 7 · done 2 · removed 1 · peak 10')
445
+ // the kept chart is not "now": its axis carries the age, like the footer
446
+ expect(await textOf(ui, 'burndown-axis-end')).toBe('3m ago')
447
+ })
448
+
449
+ test(`after a map switch a failed history read draws no chart [${surface}]`, async ($, on) => {
450
+ const r = rig(on, $)
451
+ await $.session.start(START)
452
+ const ui = await openAndMount($, r)
453
+ expect(await hasKey(ui, 'burndown-head')).toBe(true)
454
+ r.active = '199\n'
455
+ r.map = { ...MAP, title: 'second map', url: `${URL}/199` }
456
+ r.tickets = [issue(300, 'only one', ['trailhead:build'])]
457
+ r.gh = withHistory(r, { exitCode: 1, stdout: '', stderr: 'boom' })
458
+ await r.clock.advance(FLOOR)
459
+ await completeTurn($)
460
+ await settle(r)
461
+ expect(await textOf(ui, 'map')).toBe('#199 second map')
462
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
463
+ expect(await hasKey(ui, 'burndown-head')).toBe(false)
464
+ expect(await hasKey(ui, 'burndown')).toBe(false)
465
+ expect(await hasKey(ui, 'burndown-spark')).toBe(false)
466
+ })
467
+
468
+ test(`a history that reached the gh limit says it starts at the oldest ticket read [${surface}]`, async ($, on) => {
469
+ const r = rig(on, $)
470
+ r.history = Array.from({ length: 5000 }, (_, i) => hrow(i + 1, ['trailhead:build'], i % 2 === 0 ? 120 : null))
471
+ await $.session.start(START)
472
+ const ui = await openAndMount($, r)
473
+ expect(await textOf(ui, 'burndown-capped')).toBe('history capped at 5000: starts at the oldest ticket read')
474
+ expect((await ui.find({ key: 'burndown-capped' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
475
+ r.history = HISTORY
476
+ await r.clock.advance(FLOOR)
477
+ await completeTurn($)
478
+ await settle(r)
479
+ expect(await hasKey(ui, 'burndown-capped')).toBe(false)
480
+ })
481
+
482
+ test(`an orphaned work state marks nothing in play [${surface}]`, async ($, on) => {
483
+ const r = rig(on, $)
484
+ r.work = stateText({ session: 'sess-OLD' })
485
+ await $.session.start(START)
486
+ const ui = await openAndMount($, r)
487
+ expect(await ui.find({ key: 'row-208' })).toBeDefined()
488
+ expect(await ui.find({ key: 'mark-208' })).toBeUndefined()
489
+ expect(await ui.find({ key: 'mark-212' })).toBeUndefined()
490
+ })
491
+
492
+ test(`unverified tickets are counted in the plural [${surface}]`, async ($, on) => {
493
+ const r = rig(on, $)
494
+ r.tickets = [issue(1, 'a', ['trailhead:unverified']), issue(2, 'b', ['trailhead:unverified']), issue(3, 'c', ['trailhead:build'])]
495
+ await $.session.start(START)
496
+ const ui = await openAndMount($, r)
497
+ expect(await textOf(ui, 'unverified')).toBe('2 unverified tickets hidden (quarantined)')
498
+ r.tickets = [issue(3, 'c', ['trailhead:build'])]
499
+ await r.clock.advance(FLOOR)
500
+ await completeTurn($)
501
+ await settle(r)
502
+ expect(await ui.find({ key: 'unverified' })).toBeUndefined()
503
+ })
504
+
505
+ test(`a title is cut with an ellipsis to the pane width [${surface}]`, async ($, on) => {
506
+ const r = rig(on, $)
507
+ r.tickets = [issue(7, 'x'.repeat(200), ['trailhead:build'])]
508
+ await $.session.start(START)
509
+ const ui = await openAndMount($, r, { bodyColumns: 40 })
510
+ const label = (await textOf(ui, 'ticket-7')) ?? ''
511
+ expect(label.endsWith('…')).toBe(true)
512
+ expect(label.length + ' build'.length + 8 + 1).toBeLessThanOrEqual(40)
513
+ })
514
+
515
+ test(`no active map: the message and the open maps, no map read [${surface}]`, async ($, on) => {
516
+ const r = rig(on, $)
517
+ r.active = null
518
+ await $.session.start(START)
519
+ const ui = await openAndMount($, r)
520
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
521
+ expect(await ui.find({ key: 'map' })).toBeUndefined()
522
+ await completeTurn($)
523
+ await settle(r)
524
+ // the open maps are still read, so one can be picked; no map is
525
+ expect(r.calls).toEqual([['gh', 'issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', '100', '--json', 'number,title,url']])
526
+ })
527
+
528
+ test(`first fetch in flight: the map number and Loading tickets [${surface}]`, async ($, on) => {
529
+ const r = rig(on, $)
530
+ let release: () => void = () => undefined
531
+ const held = new Promise<void>(resolve => (release = resolve))
532
+ r.gh = async argv => {
533
+ await held
534
+ return argv[2] === 'view' ? ok(JSON.stringify(r.map)) : ok(JSON.stringify(r.tickets))
535
+ }
536
+ await $.session.start(START)
537
+ r.open()
538
+ const ui = await mountPane($)
539
+ await settle(r)
540
+ expect(await textOf(ui, 'map')).toBe('map #198')
541
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
542
+ expect((await ui.find({ key: 'loading' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
543
+ expect(await ui.find({ key: 'age' })).toBeUndefined()
544
+ release()
545
+ await settle(r)
546
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
547
+ expect(await ui.find({ key: 'loading' })).toBeUndefined()
548
+ })
549
+
550
+ test(`a map with no open tickets says so [${surface}]`, async ($, on) => {
551
+ const r = rig(on, $)
552
+ r.tickets = []
553
+ await $.session.start(START)
554
+ const ui = await openAndMount($, r)
555
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
556
+ expect((await ui.find({ key: 'no-tickets' }))?.text).toBe('No open tickets. Fog may remain: /trailhead:map')
557
+ expect(await textOf(ui, 'age')).toBe('updated just now')
558
+ })
559
+
560
+ test(`a closed pane costs no gh call [${surface}]`, async ($, on) => {
561
+ const r = rig(on, $)
562
+ await $.session.start(START)
563
+ await completeTurn($)
564
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
565
+ await settle(r)
566
+ expect(r.calls).toEqual([])
567
+ })
568
+
569
+ test(`a gh failure keeps the last list and shows one dim reason [${surface}]`, async ($, on) => {
570
+ const r = rig(on, $)
571
+ await $.session.start(START)
572
+ const ui = await openAndMount($, r)
573
+ expect(await textOf(ui, 'age')).toBe('updated just now')
574
+ expect(await ui.find({ key: 'age-row' })).toBeUndefined()
575
+
576
+ r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'error connecting to api.github.com' })
577
+ await r.clock.advance(TIMER)
578
+ await settle(r)
579
+ // too long beside the buttons: whole, on a row of its own under them
580
+ expect(await textOf(ui, 'age')).toBe('⚠ offline: cannot reach GitHub · list from 3m ago')
581
+ expect(await ui.find({ key: 'age-row' })).toBeDefined()
582
+ expect((await ui.find({ key: 'age' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
583
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
584
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
585
+
586
+ // recovery: the reason goes, the age is back
587
+ r.gh = null
588
+ await r.clock.advance(TIMER)
589
+ await settle(r)
590
+ expect(await textOf(ui, 'age')).toBe('updated just now')
591
+ })
592
+
593
+ test(`each failure has its own short reason [${surface}]`, async ($, on) => {
594
+ const r = rig(on, $)
595
+ await $.session.start(START)
596
+ const ui = await openAndMount($, r)
597
+ const reasonFor = async (setup: () => void) => {
598
+ setup()
599
+ await r.clock.advance(FLOOR)
600
+ await completeTurn($)
601
+ await settle(r)
602
+ return (await textOf(ui, 'age')) ?? ''
603
+ }
604
+ expect(await reasonFor(() => (r.deny = 'spawn gh ENOENT'))).toMatch(/^⚠ gh not installed · list from /)
605
+ r.deny = null
606
+ expect(await reasonFor(() => (r.gh = () => ({ exitCode: 4, stdout: '', stderr: 'run: gh auth login' })))).toMatch(
607
+ /^⚠ gh not authenticated \(run gh auth login\) · list from /,
608
+ )
609
+ expect(await reasonFor(() => (r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'boom' })))).toMatch(/^⚠ gh failed \(exit 1\) · list from /)
610
+ })
611
+
612
+ test(`a first fetch that fails shows the reason without a list [${surface}]`, async ($, on) => {
613
+ const r = rig(on, $)
614
+ r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'boom' })
615
+ await $.session.start(START)
616
+ const ui = await openAndMount($, r)
617
+ expect(await textOf(ui, 'map')).toBe('map #198')
618
+ expect(await textOf(ui, 'age')).toBe('⚠ gh failed (exit 1)')
619
+ expect(await ui.find({ key: 'loading' })).toBeUndefined()
620
+ // the tickets are unknown, not absent
621
+ expect(await ui.find({ key: 'no-tickets' })).toBeUndefined()
622
+ expect(await ui.find({ key: 'unverified' })).toBeUndefined()
623
+ })
624
+
625
+ test(`two triggers inside the floor make one fetch and one trailing [${surface}]`, async ($, on) => {
626
+ const r = rig(on, $)
627
+ await $.session.start(START)
628
+ await openAndMount($, r)
629
+ expect(fetches(r)).toBe(1)
630
+
631
+ await r.clock.advance(2_000)
632
+ await completeTurn($)
633
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
634
+ await completeTurn($)
635
+ await settle(r)
636
+ expect(fetches(r)).toBe(1)
637
+
638
+ await r.clock.advance(FLOOR - 2_000 - 1)
639
+ await settle(r)
640
+ expect(fetches(r)).toBe(1)
641
+ await r.clock.advance(1)
642
+ await settle(r)
643
+ expect(fetches(r)).toBe(2)
644
+ // the trailing one is spent
645
+ await r.clock.advance(FLOOR)
646
+ await settle(r)
647
+ expect(fetches(r)).toBe(2)
648
+ })
649
+
650
+ test(`session start, turn complete and the slow timer refresh an open pane [${surface}]`, async ($, on) => {
651
+ const r = rig(on, $)
652
+ await $.session.start(START)
653
+ await openAndMount($, r)
654
+ expect(fetches(r)).toBe(1)
655
+
656
+ await r.clock.advance(FLOOR)
657
+ await completeTurn($)
658
+ await settle(r)
659
+ expect(fetches(r)).toBe(2)
660
+
661
+ await r.clock.advance(FLOOR)
662
+ await $.session.start(START)
663
+ await settle(r)
664
+ expect(fetches(r)).toBe(3)
665
+
666
+ await r.clock.advance(TIMER)
667
+ await settle(r)
668
+ expect(fetches(r)).toBeGreaterThanOrEqual(4)
669
+ })
670
+
671
+ test(`a Bash gh issue write refreshes, gh issue list does not [${surface}]`, async ($, on) => {
672
+ const r = rig(on, $)
673
+ await $.session.start(START)
674
+ await openAndMount($, r)
675
+ const base = fetches(r)
676
+ for (const command of ['gh issue list --state open', 'ls -la', 'gh pr close 4', 'gh issue view 5']) {
677
+ await r.clock.advance(FLOOR)
678
+ await $.tool.call({ tool: 'Bash', command } as never)
679
+ await settle(r)
680
+ }
681
+ expect(fetches(r)).toBe(base)
682
+
683
+ for (const command of ['gh issue close 5', 'gh --repo o/r issue close 5', 'gh issue comment 5 -b hi', 'gh issue edit 5 --add-label x', 'gh issue create -t t', 'gh issue reopen 5']) {
684
+ await r.clock.advance(FLOOR)
685
+ const before = fetches(r)
686
+ await $.tool.call({ tool: 'Bash', command } as never)
687
+ await settle(r)
688
+ expect([command, fetches(r)]).toEqual([command, before + 1])
689
+ }
690
+ })
691
+
692
+ test(`closing the pane stops refreshing [${surface}]`, async ($, on) => {
693
+ const r = rig(on, $)
694
+ await $.session.start(START)
695
+ await openAndMount($, r)
696
+ r.panes = []
697
+ const before = fetches(r)
698
+ await r.clock.advance(TIMER * 3)
699
+ await completeTurn($)
700
+ await settle(r)
701
+ expect(fetches(r)).toBe(before)
702
+ })
703
+
704
+ test(`a fetch overtaken by a map change is dropped and the new map is read [${surface}]`, async ($, on) => {
705
+ const r = rig(on, $)
706
+ let release: () => void = () => undefined
707
+ const held = new Promise<void>(resolve => (release = resolve))
708
+ const out = (n: number, argv: readonly string[]) =>
709
+ argv[2] === 'view'
710
+ ? ok(JSON.stringify({ ...MAP, title: `map ${n}`, url: `${URL}/${n}` }))
711
+ : ok(JSON.stringify([issue(n * 10, `ticket of ${n}`, ['trailhead:build'])]))
712
+ r.gh = async argv => {
713
+ if (argv.includes('198') || argv.includes('trailhead:map-198')) {
714
+ await held
715
+ return out(198, argv)
716
+ }
717
+ return out(199, argv)
718
+ }
719
+ await $.session.start(START)
720
+ r.open()
721
+ const ui = await mountPane($)
722
+ await settle(r)
723
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
724
+
725
+ r.active = '199\n'
726
+ release()
727
+ await settle(r)
728
+ // the 198 answer never reached the screen; the retry does not wait for the floor
729
+ expect(await ui.find({ key: 'ticket-1980' })).toBeUndefined()
730
+ expect(await textOf(ui, 'map')).toBe('#199 map 199')
731
+ expect(await textOf(ui, 'ticket-1990')).toBe('#1990 ticket of 199')
732
+ expect(await ui.find({ key: 'ticket-1980' })).toBeUndefined()
733
+ })
734
+
735
+ test(`a map switched between turns clears the old list at once [${surface}]`, async ($, on) => {
736
+ const r = rig(on, $)
737
+ await $.session.start(START)
738
+ const ui = await openAndMount($, r)
739
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
740
+ r.active = '199\n'
741
+ r.map = { ...MAP, title: 'second map', url: `${URL}/199` }
742
+ r.tickets = [issue(300, 'only one', ['trailhead:build'])]
743
+ await r.clock.advance(FLOOR)
744
+ await completeTurn($)
745
+ await settle(r)
746
+ expect(await textOf(ui, 'map')).toBe('#199 second map')
747
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
748
+ })
749
+
750
+ test(`a work button on each frontier row runs /trailhead:work and shows queued [${surface}]`, async ($, on) => {
751
+ const r = rig(on, $)
752
+ let release: () => void = () => undefined
753
+ r.gate = new Promise<void>(resolve => (release = resolve))
754
+ await $.session.start(START)
755
+ const ui = await openAndMount($, r)
756
+ const buttons = (await ui.findAll({ type: 'Button' })).map((b: any) => b.key)
757
+ expect(buttons).toEqual(['map-14', 'work-209', 'work-210', 'work-214', 'refresh', 'close'])
758
+ expect((await ui.find({ key: 'work-209' }))?.props).toMatchObject({ label: 'work', variant: 'primary' })
759
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
760
+
761
+ const first = ui.press({ key: 'work-209' })
762
+ await settle(r)
763
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
764
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
765
+ expect((await ui.find({ key: 'queued-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
766
+ expect(await ui.find({ key: 'work-209' })).toBeUndefined()
767
+ expect(await ui.find({ key: 'work-210' })).toBeDefined()
768
+
769
+ // another press while one is pending is ignored
770
+ await ui.press({ key: 'work-210' })
771
+ await settle(r)
772
+ expect(r.runs).toHaveLength(2)
773
+ expect(await ui.find({ key: 'queued-210' })).toBeUndefined()
774
+
775
+ release()
776
+ await first
777
+ await settle(r)
778
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
779
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
780
+
781
+ // free again: the next press goes through
782
+ await ui.press({ key: 'work-210' })
783
+ await settle(r)
784
+ expect(r.runs).toEqual([
785
+ { command: 'clear', args: '' },
786
+ { command: 'trailhead:work', args: '209' },
787
+ { command: 'clear', args: '' },
788
+ { command: 'trailhead:work', args: '210' },
789
+ ])
790
+ })
791
+
792
+ test(`a work command that is refused clears the queued mark [${surface}]`, async ($, on) => {
793
+ const r = rig(on, $)
794
+ r.refuse = 'cannot run now'
795
+ await $.session.start(START)
796
+ const ui = await openAndMount($, r)
797
+ await ui.press({ key: 'work-209' })
798
+ await settle(r)
799
+ expect(r.runs).toHaveLength(2)
800
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
801
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
802
+ })
803
+
804
+ test(`a work press clears the conversation first, the row staying queued across the reset [${surface}]`, async ($, on) => {
805
+ const r = rig(on, $)
806
+ let releaseClear: () => void = () => undefined
807
+ let releaseWork: () => void = () => undefined
808
+ r.clearGate = new Promise<void>(resolve => (releaseClear = resolve))
809
+ r.gate = new Promise<void>(resolve => (releaseWork = resolve))
810
+ await $.session.start(START)
811
+ const ui = await openAndMount($, r)
812
+
813
+ const first = ui.press({ key: 'work-209' })
814
+ await settle(r)
815
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }])
816
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
817
+
818
+ // a second press while the clear is pending is ignored
819
+ await ui.press({ key: 'work-210' })
820
+ await settle(r)
821
+ expect(r.runs).toHaveLength(1)
822
+
823
+ // the clear resets the atoms: the work verb still follows, in the new session, with the row queued
824
+ releaseClear()
825
+ await settle(r)
826
+ expect(r.session).toBe(`${NEXT_SESSION}-1`)
827
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
828
+ await ui.redraw()
829
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
830
+ await ui.press({ key: 'work-210' })
831
+ expect(r.runs).toHaveLength(2)
832
+
833
+ releaseWork()
834
+ await first
835
+ await settle(r)
836
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
837
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
838
+ })
839
+
840
+ test(`a clear that is refused runs no work command and clears the queued mark [${surface}]`, async ($, on) => {
841
+ const r = rig(on, $)
842
+ r.refuseClear = 'cannot clear now'
843
+ await $.session.start(START)
844
+ const ui = await openAndMount($, r)
845
+ await ui.press({ key: 'work-209' })
846
+ await settle(r)
847
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }])
848
+ expect(r.session).toBe(SESSION)
849
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
850
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
851
+ })
852
+
853
+ test(`a clear that resolves without switching the session runs no work command [${surface}]`, async ($, on) => {
854
+ const r = rig(on, $)
855
+ r.denyClear = true
856
+ await $.session.start(START)
857
+ const ui = await openAndMount($, r)
858
+ await ui.press({ key: 'work-209' })
859
+ await settle(r)
860
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }])
861
+ expect(r.session).toBe(SESSION)
862
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
863
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
864
+ })
865
+
866
+ test(`an active map that moved during /clear drops the carried view [${surface}]`, async ($, on) => {
867
+ const r = rig(on, $)
868
+ await $.session.start(START)
869
+ const ui = await openAndMount($, r)
870
+ await pressMap(ui, r, 14)
871
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
872
+
873
+ r.duringClear = () => (r.active = '14\n')
874
+ await $.command.run({ command: 'clear', args: '' } as never)
875
+ await ui.redraw()
876
+ await settle(r)
877
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
878
+ expect(await hasKey(ui, 'viewing')).toBe(false)
879
+ expect(await textOf(ui, 'map-aside-14')).toBe(' active')
880
+ })
881
+
882
+ test(`a manual /clear keeps the pane filled, with no turn and inside the floor [${surface}]`, async ($, on) => {
883
+ const r = rig(on, $)
884
+ await $.session.start(START)
885
+ const ui = await openAndMount($, r)
886
+ expect(fetches(r)).toBe(1)
887
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
888
+
889
+ await $.command.run({ command: 'clear', args: '' } as never)
890
+ await ui.redraw()
891
+ await settle(r)
892
+ expect(r.session).toBe(`${NEXT_SESSION}-1`)
893
+ expect(fetches(r)).toBe(2)
894
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
895
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
896
+ })
897
+
898
+ test(`a pane the person closed is not opened again by itself after /clear [${surface}]`, async ($, on) => {
899
+ const r = rig(on, $)
900
+ await $.session.start(START)
901
+ await mountBand($, FULLSCREEN)
902
+ await settle(r)
903
+ expect(r.opens).toHaveLength(1)
904
+
905
+ // closed by the person, then cleared
906
+ r.panes = []
907
+ await $.command.run({ command: 'clear', args: '' } as never)
908
+ await completeTurn($)
909
+ await mountBand($, FULLSCREEN)
910
+ await settle(r)
911
+ expect(r.opens).toHaveLength(1)
912
+ })
913
+
914
+ test(`a viewed map stays viewed through /clear [${surface}]`, async ($, on) => {
915
+ const r = rig(on, $)
916
+ await $.session.start(START)
917
+ const ui = await openAndMount($, r)
918
+ await pressMap(ui, r, 14)
919
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
920
+
921
+ await $.command.run({ command: 'clear', args: '' } as never)
922
+ await ui.redraw()
923
+ await settle(r)
924
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
925
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
926
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
927
+ })
928
+
929
+ test(`the refresh button obeys the same floor [${surface}]`, async ($, on) => {
930
+ const r = rig(on, $)
931
+ await $.session.start(START)
932
+ const ui = await openAndMount($, r)
933
+ expect((await ui.find({ key: 'refresh' }))?.props).toMatchObject({ label: 'refresh' })
934
+ expect((await ui.find({ key: 'refresh' }))?.props).not.toHaveProperty('variant')
935
+ expect(fetches(r)).toBe(1)
936
+
937
+ // inside the floor: no fetch now, one when it ends
938
+ await r.clock.advance(5_000)
939
+ await ui.press({ key: 'refresh' })
940
+ await ui.press({ key: 'refresh' })
941
+ await settle(r)
942
+ expect(fetches(r)).toBe(1)
943
+ await r.clock.advance(FLOOR - 5_000)
944
+ await settle(r)
945
+ expect(fetches(r)).toBe(2)
946
+
947
+ // outside the floor: at once
948
+ await r.clock.advance(FLOOR)
949
+ await ui.press({ key: 'refresh' })
950
+ await settle(r)
951
+ expect(fetches(r)).toBe(3)
952
+ })
953
+
954
+ const panel = ($: any) => $.command.run({ command: 'trailhead:panel' })
955
+ const mountBand = ($: any, viewport: Record<string, unknown> | undefined) =>
956
+ $.ui.mount({ plugin: 'trailhead', surface, component: 'AbovePrompt', props: BAND_PROPS, viewport })
957
+
958
+ test(`/trailhead:panel opens and closes the pane, answering without running a command [${surface}]`, async ($, on) => {
959
+ const r = rig(on, $)
960
+ await $.session.start(START)
961
+ const opened = await panel($)
962
+ expect(opened.text).toBe('Map panel opened.')
963
+ expect(r.opens).toEqual([{ id: PANE, title: 'Map' }])
964
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
965
+ await settle(r)
966
+ expect(fetches(r)).toBe(1)
967
+
968
+ const closed = await panel($)
969
+ expect(closed.text).toBe('Map panel closed.')
970
+ expect(r.panes).toEqual([])
971
+ await r.clock.advance(TIMER * 2)
972
+ await completeTurn($)
973
+ await settle(r)
974
+ expect(fetches(r)).toBe(1)
975
+
976
+ // the model never sees it; other commands still reach the engine
977
+ expect(r.runs).toEqual([])
978
+ await $.command.run({ command: 'trailhead:work', args: '5' } as never)
979
+ expect(r.runs).toEqual([{ command: 'trailhead:work', args: '5' }])
980
+ })
981
+
982
+ test(`the footer row spreads refresh to the left and close to the right [${surface}]`, async ($, on) => {
983
+ const r = rig(on, $)
984
+ await $.session.start(START)
985
+ const ui = await openAndMount($, r)
986
+ expect((await ui.find({ key: 'footer-row' }))?.props).toMatchObject({ width: 60, justifyContent: 'space-between' })
987
+ expect((await ui.findAll({ type: 'Button' })).map((b: any) => b.key).slice(-2)).toEqual(['refresh', 'close'])
988
+ expect((await ui.find({ key: 'close' }))?.props).toMatchObject({ label: 'close' })
989
+ })
990
+
991
+ test(`a press on close removes the pane for good [${surface}]`, async ($, on) => {
992
+ const r = rig(on, $)
993
+ await $.session.start(START)
994
+ const ui = await openAndMount($, r)
995
+ expect(fetches(r)).toBe(1)
996
+ const opened = r.opens.length
997
+
998
+ await ui.press({ key: 'close' })
999
+ await settle(r)
1000
+ expect(r.panes).toEqual([])
1001
+ await r.clock.advance(TIMER * 2)
1002
+ await completeTurn($)
1003
+ await settle(r)
1004
+ expect(fetches(r)).toBe(1)
1005
+
1006
+ // the press counts as the person closing it: fullscreen never opens it again
1007
+ await mountBand($, FULLSCREEN)
1008
+ await settle(r)
1009
+ expect(r.opens).toHaveLength(opened)
1010
+ expect(r.logs).toEqual([])
1011
+ })
1012
+
1013
+ test(`/trailhead:panel says why a pane cannot be placed [${surface}]`, async ($, on) => {
1014
+ const r = rig(on, $)
1015
+ r.isPlaced = false
1016
+ await $.session.start(START)
1017
+ expect((await panel($)).text).toBe('Map panel not shown: terminal too narrow')
1018
+ await settle(r)
1019
+ expect(fetches(r)).toBe(0)
1020
+ // a pane waiting undrawn can still be closed
1021
+ expect((await panel($)).text).toBe('Map panel closed.')
1022
+ })
1023
+
1024
+ for (const withBand of [false, true]) {
1025
+ test(`the pane opens by itself once in fullscreen, the band ${withBand ? 'drawing' : 'yielding'} [${surface}]`, async ($, on) => {
1026
+ const r = rig(on, $)
1027
+ r.work = withBand ? stateText() : null
1028
+ await $.session.start(START)
1029
+ expect(r.opens).toEqual([])
1030
+ const ui = await mountBand($, FULLSCREEN)
1031
+ await settle(r)
1032
+ expect([r.logs, r.opens]).toEqual([[], [{ id: PANE, title: 'Map' }]])
1033
+ expect(fetches(r)).toBe(1)
1034
+ // the band keeps its own drawing: it draws with a state, yields without one
1035
+ expect(await ui.find({ key: withBand ? 'ticket' : 'engine' })).toBeDefined()
1036
+
1037
+ // closed by the person: never again
1038
+ r.panes = []
1039
+ await ui.redraw()
1040
+ await mountBand($, FULLSCREEN)
1041
+ await settle(r)
1042
+ expect(r.opens).toHaveLength(1)
1043
+ })
1044
+ }
1045
+
1046
+ test(`the pane never opens by itself on the main screen, unknown layout or without a map [${surface}]`, async ($, on) => {
1047
+ const r = rig(on, $)
1048
+ await $.session.start(START)
1049
+ await mountBand($, { columns: 200, rows: 50, isFullscreen: false })
1050
+ await mountBand($, { columns: 200, rows: 50 })
1051
+ await mountBand($, undefined)
1052
+ await settle(r)
1053
+ expect(r.opens).toEqual([])
1054
+
1055
+ r.active = null
1056
+ await $.session.start(START)
1057
+ await mountBand($, FULLSCREEN)
1058
+ await settle(r)
1059
+ expect(r.opens).toEqual([])
1060
+ expect(r.calls).toEqual([])
1061
+ })
1062
+
1063
+ test(`a pane the person closed with the command is not opened again by itself [${surface}]`, async ($, on) => {
1064
+ const r = rig(on, $)
1065
+ await $.session.start(START)
1066
+ await panel($)
1067
+ await panel($)
1068
+ expect(r.panes).toEqual([])
1069
+ await mountBand($, FULLSCREEN)
1070
+ await settle(r)
1071
+ expect(r.opens).toHaveLength(1)
1072
+ })
1073
+
1074
+ test(`a list that reached the gh limit says so [${surface}]`, async ($, on) => {
1075
+ const r = rig(on, $)
1076
+ r.tickets = Array.from({ length: 200 }, (_, i) => issue(i + 1, `t${i + 1}`, ['trailhead:build']))
1077
+ await $.session.start(START)
1078
+ const ui = await openAndMount($, r)
1079
+ expect(await textOf(ui, 'capped')).toBe('list capped at 200')
1080
+ expect((await ui.find({ key: 'capped' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
1081
+ r.tickets = TICKETS
1082
+ await r.clock.advance(FLOOR)
1083
+ await completeTurn($)
1084
+ await settle(r)
1085
+ expect(await ui.find({ key: 'capped' })).toBeUndefined()
1086
+ })
1087
+
1088
+ test(`a pane found already up at auto-open ends the auto-open for good [${surface}]`, async ($, on) => {
1089
+ const r = rig(on, $)
1090
+ await $.session.start(START)
1091
+ r.open()
1092
+ const ui = await mountBand($, FULLSCREEN)
1093
+ await settle(r)
1094
+ expect(r.opens).toEqual([])
1095
+ r.panes = []
1096
+ await ui.redraw()
1097
+ await mountBand($, FULLSCREEN)
1098
+ await settle(r)
1099
+ expect(r.opens).toEqual([])
1100
+ })
1101
+
1102
+ test(`a pane opened by hand after a map switch fills within the floor, not at the slow timer [${surface}]`, async ($, on) => {
1103
+ const r = rig(on, $)
1104
+ await $.session.start(START)
1105
+ r.open()
1106
+ const first = await mountPane($)
1107
+ await settle(r)
1108
+ expect(await textOf(first, 'map')).toBe('#198 trailhead mod')
1109
+ r.panes = []
1110
+ await first.unmount()
1111
+
1112
+ // the map moves on while the pane is closed
1113
+ r.active = '199\n'
1114
+ r.map = { ...MAP, title: 'second map', url: `${URL}/199` }
1115
+ r.tickets = [issue(300, 'only one', ['trailhead:build'])]
1116
+ await r.clock.advance(3_000)
1117
+ await completeTurn($)
1118
+ await settle(r)
1119
+ expect(fetches(r)).toBe(1)
1120
+
1121
+ r.open()
1122
+ const ui = await mountPane($)
1123
+ await settle(r)
1124
+ expect(await textOf(ui, 'map')).toBe('map #199')
1125
+ await r.clock.advance(FLOOR - 3_000)
1126
+ await settle(r)
1127
+ expect(await textOf(ui, 'map')).toBe('#199 second map')
1128
+ expect(fetches(r)).toBe(2)
1129
+ })
1130
+
1131
+ // ---- viewing another open map (#229) ----
1132
+ const pressMap = async (ui: any, r: Rig, n: number) => {
1133
+ await ui.press({ key: `map-${n}` })
1134
+ await settle(r)
1135
+ }
1136
+
1137
+ test(`two open maps are listed, the active one marked [${surface}]`, async ($, on) => {
1138
+ const r = rig(on, $)
1139
+ await $.session.start(START)
1140
+ const ui = await openAndMount($, r)
1141
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1142
+ expect(await keysOf(ui, 'map-row-')).toEqual(['map-row-14', 'map-row-198'])
1143
+ expect(await textOf(ui, 'map-mark-198')).toBe('● ')
1144
+ expect((await ui.find({ key: 'map-mark-198' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
1145
+ expect(await textOf(ui, 'map-aside-198')).toBe(' active')
1146
+ expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
1147
+ expect((await ui.find({ key: 'map-label-198' }))?.children[0]).toMatchObject({ props: { bold: true } })
1148
+ expect(await textOf(ui, 'map-mark-14')).toBe(' ')
1149
+ expect((await ui.find({ key: 'map-14' }))?.props).toMatchObject({ label: '#14 older map' })
1150
+ expect(await ui.find({ key: 'map-198' })).toBeUndefined()
1151
+ expect(await ui.find({ key: 'viewing' })).toBeUndefined()
1152
+ // under the header, before the tickets
1153
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
1154
+ expect(keys.indexOf('group-maps')).toBeGreaterThan(keys.indexOf('header'))
1155
+ expect(keys.indexOf('group-maps')).toBeLessThan(keys.indexOf('group-frontier'))
1156
+ })
1157
+
1158
+ test(`one open map draws no maps group [${surface}]`, async ($, on) => {
1159
+ const r = rig(on, $)
1160
+ r.maps = [MAPS[0]]
1161
+ await $.session.start(START)
1162
+ const ui = await openAndMount($, r)
1163
+ expect(await hasKey(ui, 'head-maps')).toBe(false)
1164
+ expect(await hasKey(ui, 'group-maps')).toBe(false)
1165
+ expect((await ui.findAll({ type: 'Button' })).map((b: any) => b.key)).toEqual(['work-209', 'work-210', 'work-214', 'refresh', 'close'])
1166
+ })
1167
+
1168
+ test(`a press on another map shows its tickets, the active-map marker is never written [${surface}]`, async ($, on) => {
1169
+ const r = rig(on, $)
1170
+ r.work = stateText()
1171
+ await $.session.start(START)
1172
+ const ui = await openAndMount($, r)
1173
+ await pressMap(ui, r, 14)
1174
+
1175
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1176
+ expect(await textOf(ui, 'destination')).toBe('An older destination.')
1177
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
1178
+ expect(await textOf(ui, 'holder-141')).toBe('@carol')
1179
+ expect(await textOf(ui, 'burndown-counts')).toBe('open 2 · done 0 · removed 0 · peak 2')
1180
+ // the ticket in play belongs to the other map: nothing here is marked
1181
+ expect(await hasKey(ui, 'mark-208')).toBe(false)
1182
+ expect(r.calls.slice(-4).map(argv => argv.slice(0, 4))).toEqual([
1183
+ ['gh', 'issue', 'view', '14'],
1184
+ ['gh', 'issue', 'list', '--label'],
1185
+ ['gh', 'issue', 'list', '--label'],
1186
+ ['gh', 'issue', 'list', '--label'],
1187
+ ])
1188
+ expect(r.calls.some(argv => argv.includes('trailhead:map-14'))).toBe(true)
1189
+
1190
+ // the viewed map is bold, the active one a button; one line says where we are
1191
+ expect(await textOf(ui, 'map-label-14')).toBe('#14 older map')
1192
+ expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
1193
+ expect(await hasKey(ui, 'map-14')).toBe(false)
1194
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
1195
+ expect((await ui.find({ key: 'viewing-text' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
1196
+ expect((await ui.find({ key: 'back' }))?.props).toMatchObject({ label: 'back to active' })
1197
+ // the work verb owns the marker: the pane never wrote it
1198
+ expect(r.writes).toEqual([])
1199
+ expect(r.active).toBe('198\n')
1200
+ })
1201
+
1202
+ test(`back to active and a press on the active map return to it [${surface}]`, async ($, on) => {
1203
+ const r = rig(on, $)
1204
+ await $.session.start(START)
1205
+ const ui = await openAndMount($, r)
1206
+ await pressMap(ui, r, 14)
1207
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1208
+
1209
+ await ui.press({ key: 'back' })
1210
+ await settle(r)
1211
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1212
+ expect(await keysOf(ui, 'row-')).toContain('row-208')
1213
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1214
+ expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
1215
+
1216
+ await pressMap(ui, r, 14)
1217
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1218
+ await pressMap(ui, r, 198)
1219
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1220
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1221
+ expect(r.writes).toEqual([])
1222
+ })
1223
+
1224
+ test(`a change of the active map resets the view [${surface}]`, async ($, on) => {
1225
+ const r = rig(on, $)
1226
+ await $.session.start(START)
1227
+ const ui = await openAndMount($, r)
1228
+ await pressMap(ui, r, 14)
1229
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1230
+
1231
+ // the work verb switched the active map to the viewed one at the end of a turn
1232
+ r.active = '14\n'
1233
+ await r.clock.advance(FLOOR)
1234
+ await completeTurn($)
1235
+ await settle(r)
1236
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1237
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1238
+ expect(await textOf(ui, 'map-label-14')).toBe('#14 older map')
1239
+ expect(await textOf(ui, 'map-aside-14')).toBe(' active')
1240
+ expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
1241
+ })
1242
+
1243
+ test(`a work press on a viewed map runs the work verb, then the view follows the new active map [${surface}]`, async ($, on) => {
1244
+ const r = rig(on, $)
1245
+ await $.session.start(START)
1246
+ const ui = await openAndMount($, r)
1247
+ await pressMap(ui, r, 14)
1248
+ await ui.press({ key: 'work-140' })
1249
+ await settle(r)
1250
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '140' }])
1251
+ expect(r.writes).toEqual([])
1252
+ // still viewing until the verb has changed the marker
1253
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
1254
+
1255
+ r.active = '14\n'
1256
+ await r.clock.advance(FLOOR)
1257
+ await completeTurn($)
1258
+ await settle(r)
1259
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1260
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1261
+ expect(await textOf(ui, 'map-aside-14')).toBe(' active')
1262
+ expect(await hasKey(ui, 'map-aside-198')).toBe(false)
1263
+ })
1264
+
1265
+ test(`a failed maps read keeps the last list and sets no footer error [${surface}]`, async ($, on) => {
1266
+ const r = rig(on, $)
1267
+ await $.session.start(START)
1268
+ const ui = await openAndMount($, r)
1269
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1270
+
1271
+ const base: Gh = argv => withHistory(r, ok(JSON.stringify(r.history)))(argv)
1272
+ r.gh = argv => (argv.includes('trailhead:map') ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1273
+ r.tickets = [issue(300, 'fresh one', ['trailhead:build'])]
1274
+ await r.clock.advance(FLOOR)
1275
+ await completeTurn($)
1276
+ await settle(r)
1277
+ expect(await textOf(ui, 'ticket-300')).toBe('#300 fresh one')
1278
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1279
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1280
+
1281
+ // unreadable output is the same
1282
+ r.gh = argv => (argv.includes('trailhead:map') ? ok('not json') : base(argv))
1283
+ await r.clock.advance(FLOOR)
1284
+ await completeTurn($)
1285
+ await settle(r)
1286
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1287
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1288
+ })
1289
+
1290
+ test(`a viewed map that closed falls back to the active map [${surface}]`, async ($, on) => {
1291
+ const r = rig(on, $)
1292
+ await $.session.start(START)
1293
+ const ui = await openAndMount($, r)
1294
+ await pressMap(ui, r, 14)
1295
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1296
+
1297
+ r.maps = [MAPS[0]]
1298
+ await r.clock.advance(FLOOR)
1299
+ await completeTurn($)
1300
+ await settle(r)
1301
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1302
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1303
+ expect(await hasKey(ui, 'head-maps')).toBe(false)
1304
+ })
1305
+
1306
+ test(`a refresh in flight when the view changes never commits the old map's tickets [${surface}]`, async ($, on) => {
1307
+ const r = rig(on, $)
1308
+ // every value the pane stores, to look at what the old answer wrote
1309
+ const paneWrites: unknown[] = []
1310
+ on('state.set', { plugin: 'trailhead', key: 'mapPane' }, ($$, e, next) => {
1311
+ paneWrites.push(e.value)
1312
+ return next(e)
1313
+ })
1314
+ await $.session.start(START)
1315
+ const ui = await openAndMount($, r)
1316
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1317
+
1318
+ let release: () => void = () => undefined
1319
+ let releaseOther: () => void = () => undefined
1320
+ const held = new Promise<void>(resolve => (release = resolve))
1321
+ const heldOther = new Promise<void>(resolve => (releaseOther = resolve))
1322
+ r.gh = async argv => {
1323
+ // the active map's open-list read is held, and the viewed map's reads too (to look at the screen between)
1324
+ if (argv.includes('trailhead:map-198') && argv.includes('open')) await held
1325
+ if (argv.includes('trailhead:map-14') || argv.includes('14')) await heldOther
1326
+ return r.defaultGh(argv)
1327
+ }
1328
+ r.tickets = [issue(300, 'fresh of 198', ['trailhead:build'])]
1329
+ await r.clock.advance(FLOOR)
1330
+ const refreshing = completeTurn($)
1331
+ await settle(r)
1332
+
1333
+ await ui.press({ key: 'map-14' })
1334
+ await settle(r)
1335
+ const before = paneWrites.length
1336
+ release()
1337
+ await refreshing
1338
+ await settle(r)
1339
+ // the old answer is in, the new one is not: nothing of 198 is on screen, only the wait
1340
+ expect(await textOf(ui, 'map')).toBe('map #14')
1341
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
1342
+ expect(await hasKey(ui, 'ticket-300')).toBe(false)
1343
+ // and the store itself: the stale answer was dropped, not committed behind the render's check
1344
+ const landed = paneWrites.slice(before).map(v => JSON.stringify(v))
1345
+ expect(landed.some(v => v.includes('fresh of 198'))).toBe(false)
1346
+ releaseOther()
1347
+ await settle(r)
1348
+ expect(await hasKey(ui, 'ticket-300')).toBe(false)
1349
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1350
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
1351
+ })
1352
+
1353
+ test(`with no active map the open maps are listed and one can be viewed [${surface}]`, async ($, on) => {
1354
+ const r = rig(on, $)
1355
+ r.active = null
1356
+ await $.session.start(START)
1357
+ const ui = await openAndMount($, r)
1358
+ await completeTurn($)
1359
+ await settle(r)
1360
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
1361
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1362
+ expect(await hasKey(ui, 'map-198')).toBe(true)
1363
+ expect(await hasKey(ui, 'map-14')).toBe(true)
1364
+
1365
+ await pressMap(ui, r, 14)
1366
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1367
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14')
1368
+ expect((await ui.find({ key: 'back' }))?.props).toMatchObject({ label: 'back' })
1369
+ await ui.press({ key: 'back' })
1370
+ await settle(r)
1371
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
1372
+ expect(r.writes).toEqual([])
1373
+ })
1374
+ }