@marcomigozzi/trailhead 0.13.0 → 0.15.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 (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -0
@@ -0,0 +1,1921 @@
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
+ // The maps read: one GraphQL call that carries each map's sub-issue counts and the whiteboard's.
65
+ const MAPS_QUERY =
66
+ 'query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){issues(first:100,states:OPEN,labels:["trailhead:map"]){nodes{number title url subIssuesSummary{total completed}}}whiteboardOpen:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}whiteboardAll:issues(labels:["trailhead:whiteboard"]){totalCount}}}'
67
+ const MAPS_CALL = ['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MAPS_QUERY}`]
68
+ const MAPS_LIST_CALL = ['gh', 'issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', '100', '--json', 'number,title,url']
69
+ // The pending-UAT read of one scope (a map label, or the whiteboard): the tickets with their bodies.
70
+ const uatCall = (scope: string) => ['gh', 'issue', 'list', '--label', 'trailhead:uat', '--label', scope, '--state', 'open', '--limit', '200', '--json', 'number,body']
71
+ const isGraphql = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql'
72
+ const isMapsList = (argv: readonly string[]) => argv[2] === 'list' && argv.includes('trailhead:map')
73
+ const OTHER_TICKETS = [issue(140, 'Old frontier ticket', ['trailhead:build']), issue(141, 'Old held ticket', ['trailhead:task'], ['carol'])]
74
+ const OTHER_HISTORY = OTHER_TICKETS.map(t => hrow(t.number, t.labels.map(l => l.name), null))
75
+
76
+ // The whiteboard (map-less tickets) and the pinned dashboard issue, as the dashboard view reads them.
77
+ const WB = ['trailhead:ticket', 'trailhead:whiteboard']
78
+ const WHITEBOARD = [
79
+ issue(301, 'Fix the README badge', [...WB, 'trailhead:bug']),
80
+ issue(302, 'Bump the CI node', [...WB, 'trailhead:task'], ['alice']),
81
+ issue(303, 'Waits on upstream', [...WB, 'trailhead:task', 'trailhead:blocked']),
82
+ issue(304, 'Quarantined loose one', [...WB, 'trailhead:task', 'trailhead:unverified']),
83
+ ]
84
+ const DASHBOARD = [{ number: 5, url: `${URL}/5` }]
85
+
86
+ const stateText = (over: Record<string, unknown> = {}): string =>
87
+ JSON.stringify({
88
+ map: 198,
89
+ ticket: 208,
90
+ title: 'Live map pane listing the tickets',
91
+ step: 'execute',
92
+ auto: false,
93
+ autoDone: 0,
94
+ session: SESSION,
95
+ updatedAt: '2026-10-11T00:00:00.000Z',
96
+ ...over,
97
+ })
98
+
99
+ // The plugin's atoms, which the engine resets on /clear.
100
+ const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'isDashboard', 'dashboardPane', 'pendingWork', 'paneAutoOpened', 'ownTicket']
101
+
102
+ const PANE_PROPS = {
103
+ title: 'Map',
104
+ isFocused: false,
105
+ bodyColumns: 60,
106
+ placement: 'dock',
107
+ scroll: { offset: 0, bodyRows: 30 },
108
+ view: {},
109
+ } as const
110
+
111
+ type GhOut = { exitCode: number; stdout: string; stderr: string }
112
+ type Gh = (argv: readonly string[]) => GhOut | Promise<GhOut>
113
+ const ok = (stdout: string): GhOut => ({ exitCode: 0, stdout, stderr: '' })
114
+
115
+ // What the engine would answer beneath the plugin: the marker files, `gh`, the pane
116
+ // bookkeeping, a mocked clock. `calls` records every `gh` run.
117
+ const rig = (on: On, engine?: any) => {
118
+ const r = {
119
+ active: '198\n' as string | Error | null,
120
+ work: null as string | null,
121
+ tickets: TICKETS as unknown[],
122
+ history: HISTORY as unknown[],
123
+ map: MAP as unknown,
124
+ // The open maps list, and the second map's own answers (view, open list, history).
125
+ maps: MAPS as unknown[],
126
+ // Sub-issue totals per map number, as the GraphQL read reports them (a map without one has no summary).
127
+ mapSummary: { 198: { total: 23, completed: 16 }, 14: { total: 5, completed: 2 } } as Record<number, unknown>,
128
+ // The whiteboard's open and total tickets as the same call reports them (null: the fields are absent).
129
+ whiteboardCounts: { open: 3, total: 9 } as { open: number; total: number } | null,
130
+ otherMap: OTHER_MAP as unknown,
131
+ otherTickets: OTHER_TICKETS as unknown[],
132
+ otherHistory: OTHER_HISTORY as unknown[],
133
+ whiteboard: WHITEBOARD as unknown[],
134
+ // The UAT read's answer: one row per UAT ticket, `{ number, body }`; null makes the read fail.
135
+ uat: [] as unknown[] | null,
136
+ dashboard: DASHBOARD as unknown[],
137
+ gh: null as Gh | null,
138
+ // What `gh` answers when no test replaced it; a test can wrap it.
139
+ defaultGh: null as unknown as Gh,
140
+ deny: null as string | null,
141
+ calls: [] as (readonly string[])[],
142
+ panes: [] as { id: string; title: string; isShown: boolean; isFocused: boolean; isPlaced: boolean }[],
143
+ isPlaced: true,
144
+ logs: [] as string[],
145
+ opens: [] as { id: string; title?: string }[],
146
+ // `command.run` beneath the plugin: every run is recorded; `gate` holds a work run, `refuse` rejects it.
147
+ // Every file write beneath the plugin: the pane must never write the active-map marker.
148
+ writes: [] as string[],
149
+ runs: [] as { command: string; args: string }[],
150
+ gate: null as Promise<void> | null,
151
+ refuse: null as string | null,
152
+ // The built-in `clear`: `clearGate` holds it, `refuseClear` rejects it.
153
+ clearGate: null as Promise<void> | null,
154
+ refuseClear: null as string | null,
155
+ // The clear answered by a `{ deny }` hook: it resolves the call and the session stays.
156
+ denyClear: false,
157
+ // Runs between the old session ending and the new one starting.
158
+ duringClear: null as (() => void) | null,
159
+ session: SESSION,
160
+ clock: mock.clock(on, { now: T0 }),
161
+ // The person opening the pane: the engine's own record, the plugin is not told.
162
+ open: () => {
163
+ r.panes = [{ id: PANE, title: 'Map', isShown: true, isFocused: false, isPlaced: r.isPlaced }]
164
+ },
165
+ }
166
+ const defaultGh: Gh = argv => {
167
+ if (argv.includes('trailhead:uat')) return r.uat === null ? { exitCode: 1, stdout: '', stderr: 'boom' } : ok(JSON.stringify(r.uat))
168
+ if (argv.includes('trailhead:whiteboard')) return ok(JSON.stringify(r.whiteboard))
169
+ if (argv.includes('trailhead:dashboard')) return ok(JSON.stringify(r.dashboard))
170
+ if (isGraphql(argv)) {
171
+ const nodes = (r.maps as { number: number }[]).map(m => ({ ...m, subIssuesSummary: r.mapSummary[m.number] }))
172
+ const wb = r.whiteboardCounts
173
+ const counts = wb === null ? {} : { whiteboardOpen: { totalCount: wb.open }, whiteboardAll: { totalCount: wb.total } }
174
+ return ok(JSON.stringify({ data: { repository: { issues: { nodes }, ...counts } } }))
175
+ }
176
+ if (isMapsList(argv)) return ok(JSON.stringify(r.maps))
177
+ const isOther = argv.includes('14') || argv.includes('trailhead:map-14')
178
+ if (argv[2] === 'view') return ok(JSON.stringify(isOther ? r.otherMap : r.map))
179
+ return ok(JSON.stringify(argv.includes('all') ? (isOther ? r.otherHistory : r.history) : isOther ? r.otherTickets : r.tickets))
180
+ }
181
+ on('fs.read', (_$, e) => {
182
+ const file = e.path.endsWith('/.trailhead/active-map') ? r.active : e.path.endsWith('/.trailhead/work-state.json') ? r.work : undefined
183
+ if (file === undefined) return { deny: `unexpected read of ${e.path}` }
184
+ if (file === null) return { deny: `ENOENT: no such file or directory, open '${e.path}'` }
185
+ if (file instanceof Error) return { deny: file.message }
186
+ return { value: file }
187
+ })
188
+ r.defaultGh = defaultGh
189
+ on('fs.write', (_$, e) => {
190
+ r.writes.push(e.path)
191
+ return { value: undefined }
192
+ })
193
+ // The engine's /clear, in the order seen live: the old session ends, the atoms reset and the id
194
+ // changes, the new session starts. The reset is the atoms reading as never written until the
195
+ // plugin writes them again (a test cannot write the plugin's state).
196
+ const emptied = new Set<string>()
197
+ let clears = 0
198
+ on('state.get', { plugin: 'trailhead' }, async (_$, e, next) => {
199
+ const read = await next(e)
200
+ return emptied.has(e.key) ? { value: { value: undefined, version: read.value?.version ?? 0 } } : read
201
+ })
202
+ on('state.set', { plugin: 'trailhead' }, (_$, e, next) => {
203
+ emptied.delete(e.key)
204
+ return next(e)
205
+ })
206
+ const emulateClear = async () => {
207
+ await engine.session.end({ reason: 'clear', sessionId: r.session, resume: {} })
208
+ r.duringClear?.()
209
+ ATOM_KEYS.forEach(key => emptied.add(key))
210
+ r.session = `${NEXT_SESSION}-${(clears += 1)}`
211
+ await engine.classic.SessionStart({ source: 'clear' })
212
+ }
213
+ on('command.run', async (_$, e) => {
214
+ r.runs.push({ command: e.command, args: e.args })
215
+ if (e.command === 'clear') {
216
+ await r.clearGate
217
+ if (r.refuseClear !== null) throw new Error(r.refuseClear)
218
+ if (r.denyClear) return { deny: 'clear refused' }
219
+ await emulateClear()
220
+ return { text: '' }
221
+ }
222
+ await r.gate
223
+ // A hook that throws is skipped, so the caller's `$.command.run` rejects (a `{ deny }` would resolve).
224
+ if (r.refuse !== null) throw new Error(r.refuse)
225
+ return { text: '' }
226
+ })
227
+ on('session.id', () => ({ value: r.session }))
228
+ on('ui.log', (_$, e) => {
229
+ r.logs.push(e.text)
230
+ return { value: undefined }
231
+ })
232
+ on('ui.status', () => ({ value: undefined }))
233
+ on('process.run', async (_$, e) => {
234
+ r.calls.push([...e.argv])
235
+ if (r.deny !== null) return { deny: r.deny }
236
+ const out = await (r.gh ?? defaultGh)(e.argv)
237
+ return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
238
+ })
239
+ on('ui.open', (_$, e) => {
240
+ r.opens.push({ id: e.id, title: e.title })
241
+ r.panes = [
242
+ ...r.panes.filter(p => p.id !== e.id),
243
+ { id: e.id, title: e.title ?? e.id, isShown: true, isFocused: false, isPlaced: r.isPlaced },
244
+ ]
245
+ return { value: r.isPlaced ? { isPlaced: true as const } : { isPlaced: false as const, reason: 'terminal too narrow' } }
246
+ })
247
+ on('ui.close', (_$, e) => {
248
+ r.panes = r.panes.filter(p => p.id !== e.id)
249
+ return { value: undefined }
250
+ })
251
+ on('ui.panes', () => ({ value: r.panes }))
252
+ on('tool.call', { tool: 'Bash' }, () => ({ result: 'ok' }) as never)
253
+ on('session.start', (_$, e) => ({ cwd: e.cwd }))
254
+ on('classic.SessionStart', () => ({}))
255
+ on('turn.start', (_$, e) => ({ turnId: e.turnId }))
256
+ on('turn.complete', () => ({ text: '' }))
257
+ on('session.end', (_$, e) => ({ sessionId: e.sessionId }))
258
+ on('ui.render', () => ({ type: 'Box', props: { key: 'engine' }, children: [] }))
259
+ return r
260
+ }
261
+ type Rig = ReturnType<typeof rig>
262
+
263
+ const BAND_PROPS = {
264
+ hasSurvey: false,
265
+ isWorking: false,
266
+ maxRows: 10,
267
+ bodyColumns: 80,
268
+ scroll: { offset: 0, bodyRows: 10 },
269
+ view: {},
270
+ } as const
271
+ const FULLSCREEN = { columns: 200, rows: 50, isFullscreen: true } as const
272
+
273
+ const START = { cwd: '/repo', surface: 'terminal', isInteractive: true } as const
274
+ let turns = 0
275
+ const completeTurn = ($: any) =>
276
+ $.turn.complete({ turnId: `t${(turns += 1)}`, answer: '', durationMs: 1, isAborted: false, reason: 'answer' })
277
+ // Lets detached work (a refresh started and not awaited) run to its end.
278
+ const settle = async (r: Rig) => {
279
+ for (let i = 0; i < 10; i += 1) await r.clock.settle()
280
+ }
281
+ // The open-list reads; the history read rides along with each and is not counted.
282
+ const fetches = (r: Rig) => r.calls.filter(argv => argv[2] === 'list' && argv.includes('open') && !argv.includes('trailhead:map') && !argv.includes('trailhead:uat')).length
283
+ // A gh that answers as the default does, except for the history read.
284
+ const withHistory = (r: Rig, answer: GhOut): Gh => argv => {
285
+ if (argv.includes('all')) return answer
286
+ if (isGraphql(argv) || isMapsList(argv)) return r.defaultGh(argv)
287
+ return argv[2] === 'view' ? ok(JSON.stringify(r.map)) : ok(JSON.stringify(r.tickets))
288
+ }
289
+ const hasKey = async (ui: any, key: string): Promise<boolean> => (await ui.find({ key })) !== undefined
290
+ const textOf = async (ui: any, key: string): Promise<string | undefined> => (await ui.find({ key }))?.text
291
+ const keysOf = async (ui: any, prefix: string): Promise<string[]> =>
292
+ (await ui.findAll({ type: 'Box' })).map((el: any) => el.key).filter((k: string | undefined) => k?.startsWith(prefix))
293
+
294
+ for (const surface of SURFACES) {
295
+ const mountPane = ($: any, props: Record<string, unknown> = {}) =>
296
+ $.ui.mount({ plugin: 'trailhead', surface, component: 'Pane', requestId: PANE, props: { ...PANE_PROPS, ...props } })
297
+ const openAndMount = async ($: any, r: Rig, props: Record<string, unknown> = {}) => {
298
+ r.open()
299
+ const ui = await mountPane($, props)
300
+ await settle(r)
301
+ return ui
302
+ }
303
+
304
+ test(`the pane lists the tickets by state with holders and the unverified count [${surface}]`, async ($, on) => {
305
+ const r = rig(on, $)
306
+ r.work = stateText()
307
+ await $.session.start(START)
308
+ const ui = await openAndMount($, r)
309
+
310
+ expect(r.calls).toEqual([
311
+ ['gh', 'issue', 'view', '198', '--json', 'title,body,url'],
312
+ ['gh', 'issue', 'list', '--label', 'trailhead:map-198', '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url'],
313
+ ['gh', 'issue', 'list', '--label', 'trailhead:map-198', '--state', 'all', '--limit', '5000', '--json', 'number,labels,createdAt,closedAt'],
314
+ MAPS_CALL,
315
+ uatCall('trailhead:map-198'),
316
+ ])
317
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
318
+ expect((await ui.find({ type: 'Link' }))?.props).toMatchObject({ href: `${URL}/198` })
319
+ expect((await ui.find({ key: 'map' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
320
+ expect(await textOf(ui, 'destination')).toBe('A released trailhead version whose Claude Code mod is done …')
321
+ expect((await ui.find({ key: 'destination' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
322
+
323
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
324
+ expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
325
+ expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
326
+ expect((await ui.find({ key: 'head-frontier' }))?.children[0]).toMatchObject({ props: { bold: true } })
327
+ expect(await keysOf(ui, 'row-')).toEqual(['row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-211', 'row-213'])
328
+
329
+ expect(await textOf(ui, 'ticket-209')).toBe('#209 Burn-down view of the map')
330
+ expect(await textOf(ui, 'type-209')).toBe(' build')
331
+ expect((await ui.find({ key: 'type-209' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
332
+ expect(await textOf(ui, 'type-214')).toBe(' decision')
333
+ const links = (await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)
334
+ expect(links).toContain(`${URL}/209`)
335
+
336
+ // holders on every assigned row; the session's own ticket is marked and in the accent
337
+ expect(await textOf(ui, 'self-208')).toBe('this session')
338
+ expect((await ui.find({ key: 'self-208' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
339
+ expect((await ui.find({ key: 'self-208' }))?.children[0].props).not.toHaveProperty('bold')
340
+ expect(await ui.find({ key: 'holder-208' })).toBeUndefined()
341
+ expect(await textOf(ui, 'mark-208')).toBe('▸ ')
342
+ expect((await ui.find({ key: 'mark-208' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
343
+ expect((await ui.find({ key: 'ticket-208' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
344
+ expect(await textOf(ui, 'holder-212')).toBe('@alice')
345
+ expect((await ui.find({ key: 'holder-212' }))?.children[0].props).not.toHaveProperty('dimColor')
346
+ expect(await textOf(ui, 'mark-212')).toBe(' ')
347
+ expect(await ui.find({ key: 'mark-209' })).toBeUndefined()
348
+
349
+ // blocked rows are dim, a holder too
350
+ expect((await ui.find({ key: 'ticket-211' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
351
+ expect(await textOf(ui, 'holder-213')).toBe('@bob')
352
+ expect((await ui.find({ key: 'holder-213' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
353
+ expect(await ui.find({ key: 'holder-211' })).toBeUndefined()
354
+
355
+ // the quarantined ticket is counted, never listed
356
+ expect(await ui.find({ key: 'row-215' })).toBeUndefined()
357
+ expect(await textOf(ui, 'unverified')).toBe('1 unverified ticket hidden (quarantined)')
358
+ expect(await textOf(ui, 'age')).toBe('updated just now')
359
+ })
360
+
361
+ test(`a UAT ticket sits under UAT between In progress and Paused, with its parent line and its work button [${surface}]`, async ($, on) => {
362
+ const r = rig(on, $)
363
+ r.work = stateText()
364
+ r.tickets = [
365
+ ...TICKETS,
366
+ issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat']),
367
+ issue(241, 'Device UAT seed', ['trailhead:seed', 'trailhead:blocked', 'trailhead:uat']),
368
+ issue(242, 'Held UAT', ['trailhead:task', 'trailhead:uat'], ['alice']),
369
+ issue(243, 'Paused UAT', ['trailhead:task', 'trailhead:uat', 'trailhead:paused']),
370
+ ]
371
+ r.uat = [
372
+ { number: 240, body: `Run it\n\nUAT of: [Burn-down view of the map](${URL}/209)\n` },
373
+ { number: 241, body: `UAT of: #212` },
374
+ { number: 243, body: `UAT of: [Paused parent](${URL}/150)` },
375
+ ]
376
+ await $.session.start(START)
377
+ const ui = await openAndMount($, r)
378
+
379
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 3')
380
+ expect((await ui.find({ key: 'head-uat' }))?.children[0]).toMatchObject({ props: { bold: true } })
381
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-uat', 'group-paused', 'group-blocked'])
382
+ // each ticket once: a UAT row is in no other group, a paused UAT stays under Paused
383
+ expect(await keysOf(ui, 'row-')).toEqual([
384
+ 'row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-240', 'row-241', 'row-242', 'row-243', 'row-211', 'row-213',
385
+ ])
386
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
387
+ expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
388
+ // the parent line, dim, under the row; none without a parent
389
+ expect(await textOf(ui, 'uat-of-240')).toBe('tests Burn-down view of the map #209')
390
+ expect((await ui.find({ key: 'uat-of-240' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
391
+ expect(await textOf(ui, 'uat-of-241')).toBe('tests #212')
392
+ expect(await hasKey(ui, 'uat-of-242')).toBe(false)
393
+ expect(await textOf(ui, 'uat-of-243')).toBe('tests Paused parent #150')
394
+ // a marker, not a state: a frontier UAT task keeps its work button, a held one its holder, a seed stays dim
395
+ expect(await hasKey(ui, 'work-240')).toBe(true)
396
+ expect(await textOf(ui, 'holder-242')).toBe('@alice')
397
+ expect(await hasKey(ui, 'work-242')).toBe(false)
398
+ expect(await hasKey(ui, 'work-241')).toBe(false)
399
+ expect((await ui.find({ key: 'ticket-241' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
400
+ })
401
+
402
+ test(`no UAT ticket draws no UAT group [${surface}]`, async ($, on) => {
403
+ const none = rig(on, $)
404
+ await $.session.start(START)
405
+ const ui0 = await openAndMount($, none)
406
+ expect(await hasKey(ui0, 'group-uat')).toBe(false)
407
+ })
408
+
409
+ test(`a failed UAT read draws the UAT rows without the parent line, no error [${surface}]`, async ($, on) => {
410
+ const r = rig(on, $)
411
+ r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
412
+ r.uat = null
413
+ await $.session.start(START)
414
+ const ui = await openAndMount($, r)
415
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
416
+ expect(await hasKey(ui, 'uat-of-240')).toBe(false)
417
+ expect(await hasKey(ui, 'error')).toBe(false)
418
+ })
419
+
420
+ test(`the dashboard view groups UAT whiteboard tickets with their parent, reading the whiteboard scope [${surface}]`, async ($, on) => {
421
+ const r = rig(on, $)
422
+ r.whiteboard = [...WHITEBOARD, issue(306, 'Loose UAT', [...WB, 'trailhead:task', 'trailhead:uat'])]
423
+ r.uat = [{ number: 306, body: `UAT of: [Loose parent](${URL}/290)` }]
424
+ await $.session.start(START)
425
+ const ui = await openAndMount($, r)
426
+ await pressDashboard(ui, r)
427
+ expect(r.calls.some(argv => JSON.stringify(argv) === JSON.stringify(uatCall('trailhead:whiteboard')))).toBe(true)
428
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
429
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
430
+ expect(await textOf(ui, 'uat-of-306')).toBe('tests Loose parent #290')
431
+ })
432
+
433
+ test(`a paused ticket sits under Paused between In progress and Blocked, with no work button [${surface}]`, async ($, on) => {
434
+ const r = rig(on, $)
435
+ r.work = stateText()
436
+ r.tickets = [
437
+ ...TICKETS,
438
+ issue(230, 'Paused with a holder', ['trailhead:build', 'trailhead:paused'], ['ToRvaLDz']),
439
+ issue(231, 'Paused and released', ['trailhead:build', 'trailhead:paused']),
440
+ issue(232, 'Paused and blocked', ['trailhead:build', 'trailhead:paused', 'trailhead:blocked'], ['bob']),
441
+ ]
442
+ await $.session.start(START)
443
+ const ui = await openAndMount($, r)
444
+
445
+ expect(await textOf(ui, 'head-paused')).toBe('Paused · 3')
446
+ expect((await ui.find({ key: 'head-paused' }))?.children[0]).toMatchObject({ props: { bold: true } })
447
+ expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
448
+ expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
449
+ // a paused ticket appears in one group only: Frontier, In progress, Paused, Blocked in order
450
+ expect(await keysOf(ui, 'row-')).toEqual(['row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-230', 'row-231', 'row-232', 'row-211', 'row-213'])
451
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-paused', 'group-blocked'])
452
+ expect(await textOf(ui, 'holder-230')).toBe('@ToRvaLDz')
453
+ expect((await ui.find({ key: 'holder-230' }))?.children[0].props).not.toHaveProperty('dimColor')
454
+ expect(await textOf(ui, 'holder-232')).toBe('@bob')
455
+ expect(await ui.find({ key: 'holder-231' })).toBeUndefined()
456
+ for (const n of [230, 231, 232]) expect(await hasKey(ui, `work-${n}`)).toBe(false)
457
+ expect((await ui.find({ key: 'ticket-231' }))?.children[0].props).not.toHaveProperty('dimColor')
458
+ })
459
+
460
+ test(`the dashboard view groups paused whiteboard tickets the same way [${surface}]`, async ($, on) => {
461
+ const r = rig(on, $)
462
+ r.whiteboard = [...WHITEBOARD, issue(305, 'Paused loose one', [...WB, 'trailhead:task', 'trailhead:paused'], ['alice'])]
463
+ await $.session.start(START)
464
+ const ui = await openAndMount($, r)
465
+ await pressDashboard(ui, r)
466
+ expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
467
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-305', 'row-303'])
468
+ expect(await hasKey(ui, 'work-305')).toBe(false)
469
+ expect(await textOf(ui, 'holder-305')).toBe('@alice')
470
+ })
471
+
472
+ test(`ticket rows are spaced apart on the desktop only [${surface}]`, async ($, on) => {
473
+ const r = rig(on)
474
+ await $.session.start(START)
475
+ const ui = await openAndMount($, r)
476
+ const groupProps = (await ui.find({ key: 'group-frontier' }))?.props
477
+ if (surface === 'desktop') expect(groupProps).toMatchObject({ rowGap: 0.5 })
478
+ else expect(groupProps).not.toHaveProperty('rowGap')
479
+ })
480
+
481
+ test(`the burn-down section shows the counts, the chart and the axis [${surface}]`, async ($, on) => {
482
+ const r = rig(on, $)
483
+ await $.session.start(START)
484
+ const ui = await openAndMount($, r)
485
+ expect(await textOf(ui, 'burndown-head')).toBe('Burn-down')
486
+ expect((await ui.find({ key: 'burndown-head' }))?.children[0]).toMatchObject({ props: { bold: true } })
487
+ expect(await textOf(ui, 'burndown-counts')).toBe('open 7 · done 2 · removed 1 · peak 10')
488
+ expect((await ui.find({ key: 'burndown-counts' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
489
+ expect(await textOf(ui, 'burndown-axis-start')).toBe('today 00:01')
490
+ expect(await textOf(ui, 'burndown-axis-end')).toBe('now')
491
+ expect((await ui.find({ key: 'burndown-axis-end' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
492
+ expect((await ui.find({ key: 'burndown-axis' }))?.props).toMatchObject({ width: 60, justifyContent: 'space-between' })
493
+ expect(await ui.find({ key: 'burndown-capped' })).toBeUndefined()
494
+ // after the groups, before the footer
495
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
496
+ expect(keys.indexOf('burndown-head')).toBeGreaterThan(keys.indexOf('unverified'))
497
+ expect(keys.indexOf('burndown-head')).toBeLessThan(keys.indexOf('footer'))
498
+ })
499
+
500
+ test(`the open count at now is the open list when the fixture is consistent [${surface}]`, async ($, on) => {
501
+ const r = rig(on, $)
502
+ await $.session.start(START)
503
+ const ui = await openAndMount($, r)
504
+ const listed = (await keysOf(ui, 'row-')).length
505
+ expect(listed).toBe(7)
506
+ expect(await textOf(ui, 'burndown-counts')).toMatch(new RegExp(`^open ${listed} `))
507
+ })
508
+
509
+ test(`the chart is a Raster where the surface has one, a text sparkline elsewhere [${surface}]`, async ($, on) => {
510
+ const r = rig(on, $)
511
+ await $.session.start(START)
512
+ const ui = await openAndMount($, r)
513
+ if (surface === 'terminal') {
514
+ const raster = await ui.find({ key: 'burndown' })
515
+ expect(raster?.type).toBe('Raster')
516
+ expect(raster?.props).toMatchObject({ columns: 60, rows: 3 })
517
+ expect(typeof raster?.props.cells).toBe('string')
518
+ expect(await ui.find({ key: 'burndown-spark' })).toBeUndefined()
519
+ } else {
520
+ expect(await textOf(ui, 'burndown-spark')).toBe(SPARK)
521
+ expect((await ui.find({ key: 'burndown-spark' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
522
+ expect(await ui.find({ key: 'burndown' })).toBeUndefined()
523
+ }
524
+ })
525
+
526
+ test(`the burn-down follows the pane width [${surface}]`, async ($, on) => {
527
+ const r = rig(on, $)
528
+ await $.session.start(START)
529
+ const ui = await openAndMount($, r, { bodyColumns: 30 })
530
+ if (surface === 'terminal') expect((await ui.find({ key: 'burndown' }))?.props).toMatchObject({ columns: 30 })
531
+ else expect((await textOf(ui, 'burndown-spark'))?.length).toBe(30)
532
+ })
533
+
534
+ test(`no burn-down and no gh call without an active map [${surface}]`, async ($, on) => {
535
+ const none = rig(on, $)
536
+ none.active = null
537
+ await $.session.start(START)
538
+ const idle = await openAndMount($, none)
539
+ expect(await hasKey(idle, 'burndown-head')).toBe(false)
540
+ expect(none.calls).toEqual([])
541
+ })
542
+
543
+ test(`an empty or unreadable history draws no section but the list is shown [${surface}]`, async ($, on) => {
544
+ const r = rig(on, $)
545
+ r.history = []
546
+ await $.session.start(START)
547
+ const ui = await openAndMount($, r)
548
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
549
+ expect(await hasKey(ui, 'burndown-head')).toBe(false)
550
+ expect(await textOf(ui, 'age')).toBe('updated just now')
551
+
552
+ r.gh = withHistory(r, ok('not json'))
553
+ r.history = HISTORY
554
+ await r.clock.advance(FLOOR)
555
+ await completeTurn($)
556
+ await settle(r)
557
+ expect(await hasKey(ui, 'burndown-head')).toBe(false)
558
+ expect(await textOf(ui, 'age')).toBe('updated just now')
559
+ })
560
+
561
+ test(`a history-only failure keeps the last chart, takes the fresh list and ages the axis [${surface}]`, async ($, on) => {
562
+ const r = rig(on, $)
563
+ await $.session.start(START)
564
+ const ui = await openAndMount($, r)
565
+ expect(await textOf(ui, 'burndown-axis-end')).toBe('now')
566
+
567
+ r.gh = withHistory(r, { exitCode: 1, stdout: '', stderr: 'boom' })
568
+ r.tickets = [issue(300, 'fresh one', ['trailhead:build'])]
569
+ await r.clock.advance(TIMER)
570
+ await settle(r)
571
+ // the list never froze
572
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
573
+ expect(await textOf(ui, 'ticket-300')).toBe('#300 fresh one')
574
+ expect(await textOf(ui, 'age')).toBe('updated just now')
575
+ // the chart is the last good one, aged
576
+ expect(await textOf(ui, 'burndown-counts')).toBe('open 7 · done 2 · removed 1 · peak 10')
577
+ expect(await textOf(ui, 'burndown-axis-end')).toBe('3m ago')
578
+
579
+ // recovery: a fresh history is back on "now"
580
+ r.gh = null
581
+ await r.clock.advance(TIMER)
582
+ await settle(r)
583
+ expect(await textOf(ui, 'burndown-axis-end')).toBe('now')
584
+ })
585
+
586
+ test(`a failed list read keeps the whole last data, the chart too [${surface}]`, async ($, on) => {
587
+ const r = rig(on, $)
588
+ await $.session.start(START)
589
+ const ui = await openAndMount($, r)
590
+ r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'error connecting to api.github.com' })
591
+ await r.clock.advance(TIMER)
592
+ await settle(r)
593
+ expect(await textOf(ui, 'age')).toBe('⚠ offline: cannot reach GitHub · list from 3m ago')
594
+ expect(await textOf(ui, 'burndown-counts')).toBe('open 7 · done 2 · removed 1 · peak 10')
595
+ // the kept chart is not "now": its axis carries the age, like the footer
596
+ expect(await textOf(ui, 'burndown-axis-end')).toBe('3m ago')
597
+ })
598
+
599
+ test(`after a map switch a failed history read draws no chart [${surface}]`, async ($, on) => {
600
+ const r = rig(on, $)
601
+ await $.session.start(START)
602
+ const ui = await openAndMount($, r)
603
+ expect(await hasKey(ui, 'burndown-head')).toBe(true)
604
+ r.active = '199\n'
605
+ r.map = { ...MAP, title: 'second map', url: `${URL}/199` }
606
+ r.tickets = [issue(300, 'only one', ['trailhead:build'])]
607
+ r.gh = withHistory(r, { exitCode: 1, stdout: '', stderr: 'boom' })
608
+ await r.clock.advance(FLOOR)
609
+ await completeTurn($)
610
+ await settle(r)
611
+ expect(await textOf(ui, 'map')).toBe('#199 second map')
612
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
613
+ expect(await hasKey(ui, 'burndown-head')).toBe(false)
614
+ expect(await hasKey(ui, 'burndown')).toBe(false)
615
+ expect(await hasKey(ui, 'burndown-spark')).toBe(false)
616
+ })
617
+
618
+ test(`a history that reached the gh limit says it starts at the oldest ticket read [${surface}]`, async ($, on) => {
619
+ const r = rig(on, $)
620
+ r.history = Array.from({ length: 5000 }, (_, i) => hrow(i + 1, ['trailhead:build'], i % 2 === 0 ? 120 : null))
621
+ await $.session.start(START)
622
+ const ui = await openAndMount($, r)
623
+ expect(await textOf(ui, 'burndown-capped')).toBe('history capped at 5000: starts at the oldest ticket read')
624
+ expect((await ui.find({ key: 'burndown-capped' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
625
+ r.history = HISTORY
626
+ await r.clock.advance(FLOOR)
627
+ await completeTurn($)
628
+ await settle(r)
629
+ expect(await hasKey(ui, 'burndown-capped')).toBe(false)
630
+ })
631
+
632
+ test(`an orphaned work state marks nothing in play [${surface}]`, async ($, on) => {
633
+ const r = rig(on, $)
634
+ r.work = stateText({ session: 'sess-OLD' })
635
+ await $.session.start(START)
636
+ const ui = await openAndMount($, r)
637
+ expect(await ui.find({ key: 'row-208' })).toBeDefined()
638
+ expect(await ui.find({ key: 'mark-208' })).toBeUndefined()
639
+ expect(await ui.find({ key: 'mark-212' })).toBeUndefined()
640
+ })
641
+
642
+ test(`another session's work state does not move this session's mark [${surface}]`, async ($, on) => {
643
+ const r = rig(on, $)
644
+ r.work = stateText()
645
+ await $.session.start(START)
646
+ const ui = await openAndMount($, r)
647
+ expect(await textOf(ui, 'self-208')).toBe('this session')
648
+
649
+ // a second session on the same map takes over the shared file
650
+ r.work = stateText({ ticket: 212, title: 'Native gh-backed tools', session: NEXT_SESSION })
651
+ await r.clock.advance(FLOOR)
652
+ await completeTurn($)
653
+ await settle(r)
654
+ expect(await textOf(ui, 'self-208')).toBe('this session')
655
+ expect(await textOf(ui, 'mark-208')).toBe('▸ ')
656
+ expect(await ui.find({ key: 'self-212' })).toBeUndefined()
657
+ expect(await textOf(ui, 'holder-212')).toBe('@alice')
658
+
659
+ // and then removes it: the last state read was not ours, so the mark stays
660
+ r.work = null
661
+ await r.clock.advance(FLOOR)
662
+ await completeTurn($)
663
+ await settle(r)
664
+ expect(await textOf(ui, 'self-208')).toBe('this session')
665
+ })
666
+
667
+ test(`this session's own Resolve, which removes the file, clears the mark [${surface}]`, async ($, on) => {
668
+ const r = rig(on, $)
669
+ r.work = stateText()
670
+ await $.session.start(START)
671
+ const ui = await openAndMount($, r)
672
+ expect(await textOf(ui, 'self-208')).toBe('this session')
673
+ r.work = null
674
+ await r.clock.advance(FLOOR)
675
+ await completeTurn($)
676
+ await settle(r)
677
+ expect(await ui.find({ key: 'self-208' })).toBeUndefined()
678
+ expect(await ui.find({ key: 'mark-208' })).toBeUndefined()
679
+ expect(await textOf(ui, 'holder-208')).toBe('@ToRvaLDz')
680
+ })
681
+
682
+ test(`each session marks its own ticket on the same map [${surface}]`, async ($, on) => {
683
+ const r = rig(on, $)
684
+ r.session = NEXT_SESSION
685
+ r.work = stateText({ ticket: 212, title: 'Native gh-backed tools', session: NEXT_SESSION })
686
+ await $.session.start(START)
687
+ const ui = await openAndMount($, r)
688
+ expect(await textOf(ui, 'self-212')).toBe('this session')
689
+ expect(await textOf(ui, 'mark-212')).toBe('▸ ')
690
+ expect(await textOf(ui, 'holder-208')).toBe('@ToRvaLDz')
691
+ expect(await ui.find({ key: 'self-208' })).toBeUndefined()
692
+ })
693
+
694
+ test(`a /clear starts the new session with no ticket of its own [${surface}]`, async ($, on) => {
695
+ const r = rig(on, $)
696
+ r.work = stateText()
697
+ await $.session.start(START)
698
+ const ui = await openAndMount($, r)
699
+ expect(await textOf(ui, 'self-208')).toBe('this session')
700
+ await $.command.run({ command: 'clear', args: '' })
701
+ await settle(r)
702
+ expect(await ui.find({ key: 'self-208' })).toBeUndefined()
703
+ expect(await ui.find({ key: 'mark-208' })).toBeUndefined()
704
+ })
705
+
706
+ test(`unverified tickets are counted in the plural [${surface}]`, async ($, on) => {
707
+ const r = rig(on, $)
708
+ r.tickets = [issue(1, 'a', ['trailhead:unverified']), issue(2, 'b', ['trailhead:unverified']), issue(3, 'c', ['trailhead:build'])]
709
+ await $.session.start(START)
710
+ const ui = await openAndMount($, r)
711
+ expect(await textOf(ui, 'unverified')).toBe('2 unverified tickets hidden (quarantined)')
712
+ r.tickets = [issue(3, 'c', ['trailhead:build'])]
713
+ await r.clock.advance(FLOOR)
714
+ await completeTurn($)
715
+ await settle(r)
716
+ expect(await ui.find({ key: 'unverified' })).toBeUndefined()
717
+ })
718
+
719
+ test(`a title is cut with an ellipsis to the pane width [${surface}]`, async ($, on) => {
720
+ const r = rig(on, $)
721
+ r.tickets = [issue(7, 'x'.repeat(200), ['trailhead:build'])]
722
+ await $.session.start(START)
723
+ const ui = await openAndMount($, r, { bodyColumns: 40 })
724
+ const label = (await textOf(ui, 'ticket-7')) ?? ''
725
+ expect(label.endsWith('…')).toBe(true)
726
+ expect(label.length + ' build'.length + 8 + 1).toBeLessThanOrEqual(40)
727
+ })
728
+
729
+ test(`no active map: the message and the open maps, no map read [${surface}]`, async ($, on) => {
730
+ const r = rig(on, $)
731
+ r.active = null
732
+ await $.session.start(START)
733
+ const ui = await openAndMount($, r)
734
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
735
+ expect(await ui.find({ key: 'map' })).toBeUndefined()
736
+ await completeTurn($)
737
+ await settle(r)
738
+ // the open maps are still read, so one can be picked; no map is
739
+ expect(r.calls).toEqual([MAPS_CALL])
740
+ })
741
+
742
+ test(`first fetch in flight: the map number and Loading tickets [${surface}]`, async ($, on) => {
743
+ const r = rig(on, $)
744
+ let release: () => void = () => undefined
745
+ const held = new Promise<void>(resolve => (release = resolve))
746
+ r.gh = async argv => {
747
+ await held
748
+ return argv[2] === 'view' ? ok(JSON.stringify(r.map)) : ok(JSON.stringify(r.tickets))
749
+ }
750
+ await $.session.start(START)
751
+ r.open()
752
+ const ui = await mountPane($)
753
+ await settle(r)
754
+ expect(await textOf(ui, 'map')).toBe('map #198')
755
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
756
+ expect((await ui.find({ key: 'loading' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
757
+ expect(await ui.find({ key: 'age' })).toBeUndefined()
758
+ release()
759
+ await settle(r)
760
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
761
+ expect(await ui.find({ key: 'loading' })).toBeUndefined()
762
+ })
763
+
764
+ test(`a map with no open tickets says so [${surface}]`, async ($, on) => {
765
+ const r = rig(on, $)
766
+ r.tickets = []
767
+ await $.session.start(START)
768
+ const ui = await openAndMount($, r)
769
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
770
+ expect((await ui.find({ key: 'no-tickets' }))?.text).toBe('No open tickets. Fog may remain: /trailhead:map')
771
+ expect(await textOf(ui, 'age')).toBe('updated just now')
772
+ })
773
+
774
+ test(`a closed pane costs no gh call [${surface}]`, async ($, on) => {
775
+ const r = rig(on, $)
776
+ await $.session.start(START)
777
+ await completeTurn($)
778
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
779
+ await settle(r)
780
+ expect(r.calls).toEqual([])
781
+ })
782
+
783
+ test(`a gh failure keeps the last list and shows one dim reason [${surface}]`, async ($, on) => {
784
+ const r = rig(on, $)
785
+ await $.session.start(START)
786
+ const ui = await openAndMount($, r)
787
+ expect(await textOf(ui, 'age')).toBe('updated just now')
788
+ expect(await ui.find({ key: 'age-row' })).toBeUndefined()
789
+
790
+ r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'error connecting to api.github.com' })
791
+ await r.clock.advance(TIMER)
792
+ await settle(r)
793
+ // too long beside the buttons: whole, on a row of its own under them
794
+ expect(await textOf(ui, 'age')).toBe('⚠ offline: cannot reach GitHub · list from 3m ago')
795
+ expect(await ui.find({ key: 'age-row' })).toBeDefined()
796
+ expect((await ui.find({ key: 'age' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
797
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
798
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
799
+
800
+ // recovery: the reason goes, the age is back
801
+ r.gh = null
802
+ await r.clock.advance(TIMER)
803
+ await settle(r)
804
+ expect(await textOf(ui, 'age')).toBe('updated just now')
805
+ })
806
+
807
+ test(`each failure has its own short reason [${surface}]`, async ($, on) => {
808
+ const r = rig(on, $)
809
+ await $.session.start(START)
810
+ const ui = await openAndMount($, r)
811
+ const reasonFor = async (setup: () => void) => {
812
+ setup()
813
+ await r.clock.advance(FLOOR)
814
+ await completeTurn($)
815
+ await settle(r)
816
+ return (await textOf(ui, 'age')) ?? ''
817
+ }
818
+ expect(await reasonFor(() => (r.deny = 'spawn gh ENOENT'))).toMatch(/^⚠ gh not installed · list from /)
819
+ r.deny = null
820
+ expect(await reasonFor(() => (r.gh = () => ({ exitCode: 4, stdout: '', stderr: 'run: gh auth login' })))).toMatch(
821
+ /^⚠ gh not authenticated \(run gh auth login\) · list from /,
822
+ )
823
+ expect(await reasonFor(() => (r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'boom' })))).toMatch(/^⚠ gh failed \(exit 1\) · list from /)
824
+ })
825
+
826
+ test(`a first fetch that fails shows the reason without a list [${surface}]`, async ($, on) => {
827
+ const r = rig(on, $)
828
+ r.gh = () => ({ exitCode: 1, stdout: '', stderr: 'boom' })
829
+ await $.session.start(START)
830
+ const ui = await openAndMount($, r)
831
+ expect(await textOf(ui, 'map')).toBe('map #198')
832
+ expect(await textOf(ui, 'age')).toBe('⚠ gh failed (exit 1)')
833
+ expect(await ui.find({ key: 'loading' })).toBeUndefined()
834
+ // the tickets are unknown, not absent
835
+ expect(await ui.find({ key: 'no-tickets' })).toBeUndefined()
836
+ expect(await ui.find({ key: 'unverified' })).toBeUndefined()
837
+ })
838
+
839
+ test(`two triggers inside the floor make one fetch and one trailing [${surface}]`, async ($, on) => {
840
+ const r = rig(on, $)
841
+ await $.session.start(START)
842
+ await openAndMount($, r)
843
+ expect(fetches(r)).toBe(1)
844
+
845
+ await r.clock.advance(2_000)
846
+ await completeTurn($)
847
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
848
+ await completeTurn($)
849
+ await settle(r)
850
+ expect(fetches(r)).toBe(1)
851
+
852
+ await r.clock.advance(FLOOR - 2_000 - 1)
853
+ await settle(r)
854
+ expect(fetches(r)).toBe(1)
855
+ await r.clock.advance(1)
856
+ await settle(r)
857
+ expect(fetches(r)).toBe(2)
858
+ // the trailing one is spent
859
+ await r.clock.advance(FLOOR)
860
+ await settle(r)
861
+ expect(fetches(r)).toBe(2)
862
+ })
863
+
864
+ test(`session start, turn complete and the slow timer refresh an open pane [${surface}]`, async ($, on) => {
865
+ const r = rig(on, $)
866
+ await $.session.start(START)
867
+ await openAndMount($, r)
868
+ expect(fetches(r)).toBe(1)
869
+
870
+ await r.clock.advance(FLOOR)
871
+ await completeTurn($)
872
+ await settle(r)
873
+ expect(fetches(r)).toBe(2)
874
+
875
+ await r.clock.advance(FLOOR)
876
+ await $.session.start(START)
877
+ await settle(r)
878
+ expect(fetches(r)).toBe(3)
879
+
880
+ await r.clock.advance(TIMER)
881
+ await settle(r)
882
+ expect(fetches(r)).toBeGreaterThanOrEqual(4)
883
+ })
884
+
885
+ test(`a Bash gh issue write refreshes, gh issue list does not [${surface}]`, async ($, on) => {
886
+ const r = rig(on, $)
887
+ await $.session.start(START)
888
+ await openAndMount($, r)
889
+ const base = fetches(r)
890
+ for (const command of ['gh issue list --state open', 'ls -la', 'gh pr close 4', 'gh issue view 5']) {
891
+ await r.clock.advance(FLOOR)
892
+ await $.tool.call({ tool: 'Bash', command } as never)
893
+ await settle(r)
894
+ }
895
+ expect(fetches(r)).toBe(base)
896
+
897
+ 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']) {
898
+ await r.clock.advance(FLOOR)
899
+ const before = fetches(r)
900
+ await $.tool.call({ tool: 'Bash', command } as never)
901
+ await settle(r)
902
+ expect([command, fetches(r)]).toEqual([command, before + 1])
903
+ }
904
+ })
905
+
906
+ test(`closing the pane stops refreshing [${surface}]`, async ($, on) => {
907
+ const r = rig(on, $)
908
+ await $.session.start(START)
909
+ await openAndMount($, r)
910
+ r.panes = []
911
+ const before = fetches(r)
912
+ await r.clock.advance(TIMER * 3)
913
+ await completeTurn($)
914
+ await settle(r)
915
+ expect(fetches(r)).toBe(before)
916
+ })
917
+
918
+ test(`a fetch overtaken by a map change is dropped and the new map is read [${surface}]`, async ($, on) => {
919
+ const r = rig(on, $)
920
+ let release: () => void = () => undefined
921
+ const held = new Promise<void>(resolve => (release = resolve))
922
+ const out = (n: number, argv: readonly string[]) =>
923
+ argv[2] === 'view'
924
+ ? ok(JSON.stringify({ ...MAP, title: `map ${n}`, url: `${URL}/${n}` }))
925
+ : ok(JSON.stringify([issue(n * 10, `ticket of ${n}`, ['trailhead:build'])]))
926
+ r.gh = async argv => {
927
+ if (argv.includes('198') || argv.includes('trailhead:map-198')) {
928
+ await held
929
+ return out(198, argv)
930
+ }
931
+ return out(199, argv)
932
+ }
933
+ await $.session.start(START)
934
+ r.open()
935
+ const ui = await mountPane($)
936
+ await settle(r)
937
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
938
+
939
+ r.active = '199\n'
940
+ release()
941
+ await settle(r)
942
+ // the 198 answer never reached the screen; the retry does not wait for the floor
943
+ expect(await ui.find({ key: 'ticket-1980' })).toBeUndefined()
944
+ expect(await textOf(ui, 'map')).toBe('#199 map 199')
945
+ expect(await textOf(ui, 'ticket-1990')).toBe('#1990 ticket of 199')
946
+ expect(await ui.find({ key: 'ticket-1980' })).toBeUndefined()
947
+ })
948
+
949
+ test(`a map switched between turns clears the old list at once [${surface}]`, async ($, on) => {
950
+ const r = rig(on, $)
951
+ await $.session.start(START)
952
+ const ui = await openAndMount($, r)
953
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
954
+ r.active = '199\n'
955
+ r.map = { ...MAP, title: 'second map', url: `${URL}/199` }
956
+ r.tickets = [issue(300, 'only one', ['trailhead:build'])]
957
+ await r.clock.advance(FLOOR)
958
+ await completeTurn($)
959
+ await settle(r)
960
+ expect(await textOf(ui, 'map')).toBe('#199 second map')
961
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
962
+ })
963
+
964
+ test(`a work button on each frontier row runs /trailhead:work and shows queued [${surface}]`, async ($, on) => {
965
+ const r = rig(on, $)
966
+ let release: () => void = () => undefined
967
+ r.gate = new Promise<void>(resolve => (release = resolve))
968
+ await $.session.start(START)
969
+ const ui = await openAndMount($, r)
970
+ const buttons = (await ui.findAll({ type: 'Button' })).map((b: any) => b.key)
971
+ expect(buttons).toEqual(['open-whiteboard', 'map-14', 'work-209', 'work-210', 'work-214', 'refresh', 'close'])
972
+ expect((await ui.find({ key: 'work-209' }))?.props).toMatchObject({ label: 'work', variant: 'primary' })
973
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
974
+
975
+ const first = ui.press({ key: 'work-209' })
976
+ await settle(r)
977
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
978
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
979
+ expect((await ui.find({ key: 'queued-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
980
+ expect(await ui.find({ key: 'work-209' })).toBeUndefined()
981
+ expect(await ui.find({ key: 'work-210' })).toBeDefined()
982
+
983
+ // another press while one is pending is ignored
984
+ await ui.press({ key: 'work-210' })
985
+ await settle(r)
986
+ expect(r.runs).toHaveLength(2)
987
+ expect(await ui.find({ key: 'queued-210' })).toBeUndefined()
988
+
989
+ release()
990
+ await first
991
+ await settle(r)
992
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
993
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
994
+
995
+ // free again: the next press goes through
996
+ await ui.press({ key: 'work-210' })
997
+ await settle(r)
998
+ expect(r.runs).toEqual([
999
+ { command: 'clear', args: '' },
1000
+ { command: 'trailhead:work', args: '209' },
1001
+ { command: 'clear', args: '' },
1002
+ { command: 'trailhead:work', args: '210' },
1003
+ ])
1004
+ })
1005
+
1006
+ test(`a work command that is refused clears the queued mark [${surface}]`, async ($, on) => {
1007
+ const r = rig(on, $)
1008
+ r.refuse = 'cannot run now'
1009
+ await $.session.start(START)
1010
+ const ui = await openAndMount($, r)
1011
+ await ui.press({ key: 'work-209' })
1012
+ await settle(r)
1013
+ expect(r.runs).toHaveLength(2)
1014
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1015
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1016
+ })
1017
+
1018
+ test(`a work press clears the conversation first, the row staying queued across the reset [${surface}]`, async ($, on) => {
1019
+ const r = rig(on, $)
1020
+ let releaseClear: () => void = () => undefined
1021
+ let releaseWork: () => void = () => undefined
1022
+ r.clearGate = new Promise<void>(resolve => (releaseClear = resolve))
1023
+ r.gate = new Promise<void>(resolve => (releaseWork = resolve))
1024
+ await $.session.start(START)
1025
+ const ui = await openAndMount($, r)
1026
+
1027
+ const first = ui.press({ key: 'work-209' })
1028
+ await settle(r)
1029
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }])
1030
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1031
+
1032
+ // a second press while the clear is pending is ignored
1033
+ await ui.press({ key: 'work-210' })
1034
+ await settle(r)
1035
+ expect(r.runs).toHaveLength(1)
1036
+
1037
+ // the clear resets the atoms: the work verb still follows, in the new session, with the row queued
1038
+ releaseClear()
1039
+ await settle(r)
1040
+ expect(r.session).toBe(`${NEXT_SESSION}-1`)
1041
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1042
+ await ui.redraw()
1043
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1044
+ await ui.press({ key: 'work-210' })
1045
+ expect(r.runs).toHaveLength(2)
1046
+
1047
+ releaseWork()
1048
+ await first
1049
+ await settle(r)
1050
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1051
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1052
+ })
1053
+
1054
+ test(`a clear that is refused runs no work command and clears the queued mark [${surface}]`, async ($, on) => {
1055
+ const r = rig(on, $)
1056
+ r.refuseClear = 'cannot clear now'
1057
+ await $.session.start(START)
1058
+ const ui = await openAndMount($, r)
1059
+ await ui.press({ key: 'work-209' })
1060
+ await settle(r)
1061
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }])
1062
+ expect(r.session).toBe(SESSION)
1063
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1064
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1065
+ })
1066
+
1067
+ test(`a clear that resolves without switching the session runs no work command [${surface}]`, async ($, on) => {
1068
+ const r = rig(on, $)
1069
+ r.denyClear = true
1070
+ await $.session.start(START)
1071
+ const ui = await openAndMount($, r)
1072
+ await ui.press({ key: 'work-209' })
1073
+ await settle(r)
1074
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }])
1075
+ expect(r.session).toBe(SESSION)
1076
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1077
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1078
+ })
1079
+
1080
+ test(`an active map that moved during /clear drops the carried view [${surface}]`, async ($, on) => {
1081
+ const r = rig(on, $)
1082
+ await $.session.start(START)
1083
+ const ui = await openAndMount($, r)
1084
+ await pressMap(ui, r, 14)
1085
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
1086
+
1087
+ r.duringClear = () => (r.active = '14\n')
1088
+ await $.command.run({ command: 'clear', args: '' } as never)
1089
+ await ui.redraw()
1090
+ await settle(r)
1091
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1092
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1093
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5 · active')
1094
+ })
1095
+
1096
+ test(`a manual /clear keeps the pane filled, with no turn and inside the floor [${surface}]`, async ($, on) => {
1097
+ const r = rig(on, $)
1098
+ await $.session.start(START)
1099
+ const ui = await openAndMount($, r)
1100
+ expect(fetches(r)).toBe(1)
1101
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
1102
+
1103
+ await $.command.run({ command: 'clear', args: '' } as never)
1104
+ await ui.redraw()
1105
+ await settle(r)
1106
+ expect(r.session).toBe(`${NEXT_SESSION}-1`)
1107
+ expect(fetches(r)).toBe(2)
1108
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1109
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
1110
+ })
1111
+
1112
+ test(`a pane the person closed is not opened again by itself after /clear [${surface}]`, async ($, on) => {
1113
+ const r = rig(on, $)
1114
+ await $.session.start(START)
1115
+ await mountBand($, FULLSCREEN)
1116
+ await settle(r)
1117
+ expect(r.opens).toHaveLength(1)
1118
+
1119
+ // closed by the person, then cleared
1120
+ r.panes = []
1121
+ await $.command.run({ command: 'clear', args: '' } as never)
1122
+ await completeTurn($)
1123
+ await mountBand($, FULLSCREEN)
1124
+ await settle(r)
1125
+ expect(r.opens).toHaveLength(1)
1126
+ })
1127
+
1128
+ test(`a viewed map stays viewed through /clear [${surface}]`, async ($, on) => {
1129
+ const r = rig(on, $)
1130
+ await $.session.start(START)
1131
+ const ui = await openAndMount($, r)
1132
+ await pressMap(ui, r, 14)
1133
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
1134
+
1135
+ await $.command.run({ command: 'clear', args: '' } as never)
1136
+ await ui.redraw()
1137
+ await settle(r)
1138
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1139
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
1140
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
1141
+ })
1142
+
1143
+ test(`the refresh button obeys the same floor [${surface}]`, async ($, on) => {
1144
+ const r = rig(on, $)
1145
+ await $.session.start(START)
1146
+ const ui = await openAndMount($, r)
1147
+ expect((await ui.find({ key: 'refresh' }))?.props).toMatchObject({ label: 'refresh' })
1148
+ expect((await ui.find({ key: 'refresh' }))?.props).not.toHaveProperty('variant')
1149
+ expect(fetches(r)).toBe(1)
1150
+
1151
+ // inside the floor: no fetch now, one when it ends
1152
+ await r.clock.advance(5_000)
1153
+ await ui.press({ key: 'refresh' })
1154
+ await ui.press({ key: 'refresh' })
1155
+ await settle(r)
1156
+ expect(fetches(r)).toBe(1)
1157
+ await r.clock.advance(FLOOR - 5_000)
1158
+ await settle(r)
1159
+ expect(fetches(r)).toBe(2)
1160
+
1161
+ // outside the floor: at once
1162
+ await r.clock.advance(FLOOR)
1163
+ await ui.press({ key: 'refresh' })
1164
+ await settle(r)
1165
+ expect(fetches(r)).toBe(3)
1166
+ })
1167
+
1168
+ const panel = ($: any) => $.command.run({ command: 'trailhead:panel' })
1169
+ const mountBand = ($: any, viewport: Record<string, unknown> | undefined) =>
1170
+ $.ui.mount({ plugin: 'trailhead', surface, component: 'AbovePrompt', props: BAND_PROPS, viewport })
1171
+
1172
+ test(`/trailhead:panel opens and closes the pane, answering without running a command [${surface}]`, async ($, on) => {
1173
+ const r = rig(on, $)
1174
+ await $.session.start(START)
1175
+ const opened = await panel($)
1176
+ expect(opened.text).toBe('Map panel opened.')
1177
+ expect(r.opens).toEqual([{ id: PANE, title: 'Map' }])
1178
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
1179
+ await settle(r)
1180
+ expect(fetches(r)).toBe(1)
1181
+
1182
+ const closed = await panel($)
1183
+ expect(closed.text).toBe('Map panel closed.')
1184
+ expect(r.panes).toEqual([])
1185
+ await r.clock.advance(TIMER * 2)
1186
+ await completeTurn($)
1187
+ await settle(r)
1188
+ expect(fetches(r)).toBe(1)
1189
+
1190
+ // the model never sees it; other commands still reach the engine
1191
+ expect(r.runs).toEqual([])
1192
+ await $.command.run({ command: 'trailhead:work', args: '5' } as never)
1193
+ expect(r.runs).toEqual([{ command: 'trailhead:work', args: '5' }])
1194
+ })
1195
+
1196
+ test(`the footer row spreads refresh to the left and close to the right [${surface}]`, async ($, on) => {
1197
+ const r = rig(on, $)
1198
+ await $.session.start(START)
1199
+ const ui = await openAndMount($, r)
1200
+ expect((await ui.find({ key: 'footer-row' }))?.props).toMatchObject({ width: 60, justifyContent: 'space-between' })
1201
+ expect((await ui.findAll({ type: 'Button' })).map((b: any) => b.key).slice(-2)).toEqual(['refresh', 'close'])
1202
+ expect((await ui.find({ key: 'close' }))?.props).toMatchObject({ label: 'close' })
1203
+ })
1204
+
1205
+ test(`a press on close removes the pane for good [${surface}]`, async ($, on) => {
1206
+ const r = rig(on, $)
1207
+ await $.session.start(START)
1208
+ const ui = await openAndMount($, r)
1209
+ expect(fetches(r)).toBe(1)
1210
+ const opened = r.opens.length
1211
+
1212
+ await ui.press({ key: 'close' })
1213
+ await settle(r)
1214
+ expect(r.panes).toEqual([])
1215
+ await r.clock.advance(TIMER * 2)
1216
+ await completeTurn($)
1217
+ await settle(r)
1218
+ expect(fetches(r)).toBe(1)
1219
+
1220
+ // the press counts as the person closing it: fullscreen never opens it again
1221
+ await mountBand($, FULLSCREEN)
1222
+ await settle(r)
1223
+ expect(r.opens).toHaveLength(opened)
1224
+ expect(r.logs).toEqual([])
1225
+ })
1226
+
1227
+ test(`/trailhead:panel says why a pane cannot be placed [${surface}]`, async ($, on) => {
1228
+ const r = rig(on, $)
1229
+ r.isPlaced = false
1230
+ await $.session.start(START)
1231
+ expect((await panel($)).text).toBe('Map panel not shown: terminal too narrow')
1232
+ await settle(r)
1233
+ expect(fetches(r)).toBe(0)
1234
+ // a pane waiting undrawn can still be closed
1235
+ expect((await panel($)).text).toBe('Map panel closed.')
1236
+ })
1237
+
1238
+ for (const withBand of [false, true]) {
1239
+ test(`the pane opens by itself once in fullscreen, the band ${withBand ? 'drawing' : 'yielding'} [${surface}]`, async ($, on) => {
1240
+ const r = rig(on, $)
1241
+ r.work = withBand ? stateText() : null
1242
+ await $.session.start(START)
1243
+ expect(r.opens).toEqual([])
1244
+ const ui = await mountBand($, FULLSCREEN)
1245
+ await settle(r)
1246
+ expect([r.logs, r.opens]).toEqual([[], [{ id: PANE, title: 'Map' }]])
1247
+ expect(fetches(r)).toBe(1)
1248
+ // the band keeps its own drawing: it draws with a state, yields without one
1249
+ expect(await ui.find({ key: withBand ? 'ticket' : 'engine' })).toBeDefined()
1250
+
1251
+ // closed by the person: never again
1252
+ r.panes = []
1253
+ await ui.redraw()
1254
+ await mountBand($, FULLSCREEN)
1255
+ await settle(r)
1256
+ expect(r.opens).toHaveLength(1)
1257
+ })
1258
+ }
1259
+
1260
+ test(`the pane never opens by itself on the main screen, unknown layout or without a map [${surface}]`, async ($, on) => {
1261
+ const r = rig(on, $)
1262
+ await $.session.start(START)
1263
+ await mountBand($, { columns: 200, rows: 50, isFullscreen: false })
1264
+ await mountBand($, { columns: 200, rows: 50 })
1265
+ await mountBand($, undefined)
1266
+ await settle(r)
1267
+ expect(r.opens).toEqual([])
1268
+
1269
+ r.active = null
1270
+ await $.session.start(START)
1271
+ await mountBand($, FULLSCREEN)
1272
+ await settle(r)
1273
+ expect(r.opens).toEqual([])
1274
+ expect(r.calls).toEqual([])
1275
+ })
1276
+
1277
+ test(`a pane the person closed with the command is not opened again by itself [${surface}]`, async ($, on) => {
1278
+ const r = rig(on, $)
1279
+ await $.session.start(START)
1280
+ await panel($)
1281
+ await panel($)
1282
+ expect(r.panes).toEqual([])
1283
+ await mountBand($, FULLSCREEN)
1284
+ await settle(r)
1285
+ expect(r.opens).toHaveLength(1)
1286
+ })
1287
+
1288
+ test(`a list that reached the gh limit says so [${surface}]`, async ($, on) => {
1289
+ const r = rig(on, $)
1290
+ r.tickets = Array.from({ length: 200 }, (_, i) => issue(i + 1, `t${i + 1}`, ['trailhead:build']))
1291
+ await $.session.start(START)
1292
+ const ui = await openAndMount($, r)
1293
+ expect(await textOf(ui, 'capped')).toBe('list capped at 200')
1294
+ expect((await ui.find({ key: 'capped' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
1295
+ r.tickets = TICKETS
1296
+ await r.clock.advance(FLOOR)
1297
+ await completeTurn($)
1298
+ await settle(r)
1299
+ expect(await ui.find({ key: 'capped' })).toBeUndefined()
1300
+ })
1301
+
1302
+ test(`a pane found already up at auto-open ends the auto-open for good [${surface}]`, async ($, on) => {
1303
+ const r = rig(on, $)
1304
+ await $.session.start(START)
1305
+ r.open()
1306
+ const ui = await mountBand($, FULLSCREEN)
1307
+ await settle(r)
1308
+ expect(r.opens).toEqual([])
1309
+ r.panes = []
1310
+ await ui.redraw()
1311
+ await mountBand($, FULLSCREEN)
1312
+ await settle(r)
1313
+ expect(r.opens).toEqual([])
1314
+ })
1315
+
1316
+ test(`a pane opened by hand after a map switch fills within the floor, not at the slow timer [${surface}]`, async ($, on) => {
1317
+ const r = rig(on, $)
1318
+ await $.session.start(START)
1319
+ r.open()
1320
+ const first = await mountPane($)
1321
+ await settle(r)
1322
+ expect(await textOf(first, 'map')).toBe('#198 trailhead mod')
1323
+ r.panes = []
1324
+ await first.unmount()
1325
+
1326
+ // the map moves on while the pane is closed
1327
+ r.active = '199\n'
1328
+ r.map = { ...MAP, title: 'second map', url: `${URL}/199` }
1329
+ r.tickets = [issue(300, 'only one', ['trailhead:build'])]
1330
+ await r.clock.advance(3_000)
1331
+ await completeTurn($)
1332
+ await settle(r)
1333
+ expect(fetches(r)).toBe(1)
1334
+
1335
+ r.open()
1336
+ const ui = await mountPane($)
1337
+ await settle(r)
1338
+ expect(await textOf(ui, 'map')).toBe('map #199')
1339
+ await r.clock.advance(FLOOR - 3_000)
1340
+ await settle(r)
1341
+ expect(await textOf(ui, 'map')).toBe('#199 second map')
1342
+ expect(fetches(r)).toBe(2)
1343
+ })
1344
+
1345
+ // ---- viewing another open map (#229) ----
1346
+ const pressMap = async (ui: any, r: Rig, n: number) => {
1347
+ await ui.press({ key: `map-${n}` })
1348
+ await settle(r)
1349
+ }
1350
+
1351
+ test(`two open maps are listed, the active one marked [${surface}]`, async ($, on) => {
1352
+ const r = rig(on, $)
1353
+ await $.session.start(START)
1354
+ const ui = await openAndMount($, r)
1355
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1356
+ expect(await keysOf(ui, 'map-row-')).toEqual(['map-row-14', 'map-row-198'])
1357
+ expect(await textOf(ui, 'map-mark-198')).toBe('● ')
1358
+ expect((await ui.find({ key: 'map-mark-198' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
1359
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
1360
+ expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
1361
+ expect((await ui.find({ key: 'map-label-198' }))?.children[0]).toMatchObject({ props: { bold: true } })
1362
+ expect(await textOf(ui, 'map-mark-14')).toBe(' ')
1363
+ expect((await ui.find({ key: 'map-14' }))?.props).toMatchObject({ label: '#14 older map' })
1364
+ expect(await ui.find({ key: 'map-198' })).toBeUndefined()
1365
+ expect(await ui.find({ key: 'viewing' })).toBeUndefined()
1366
+ // under the header, before the tickets
1367
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
1368
+ expect(keys.indexOf('group-maps')).toBeGreaterThan(keys.indexOf('header'))
1369
+ expect(keys.indexOf('group-maps')).toBeLessThan(keys.indexOf('group-frontier'))
1370
+ })
1371
+
1372
+ test(`one open map draws no maps group [${surface}]`, async ($, on) => {
1373
+ const r = rig(on, $)
1374
+ r.maps = [MAPS[0]]
1375
+ await $.session.start(START)
1376
+ const ui = await openAndMount($, r)
1377
+ expect(await hasKey(ui, 'head-maps')).toBe(false)
1378
+ expect(await hasKey(ui, 'group-maps')).toBe(false)
1379
+ expect((await ui.findAll({ type: 'Button' })).map((b: any) => b.key)).toEqual(['open-whiteboard', 'work-209', 'work-210', 'work-214', 'refresh', 'close'])
1380
+ })
1381
+
1382
+ test(`a press on another map shows its tickets, the active-map marker is never written [${surface}]`, async ($, on) => {
1383
+ const r = rig(on, $)
1384
+ r.work = stateText()
1385
+ await $.session.start(START)
1386
+ const ui = await openAndMount($, r)
1387
+ await pressMap(ui, r, 14)
1388
+
1389
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1390
+ expect(await textOf(ui, 'destination')).toBe('An older destination.')
1391
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
1392
+ expect(await textOf(ui, 'holder-141')).toBe('@carol')
1393
+ expect(await textOf(ui, 'burndown-counts')).toBe('open 2 · done 0 · removed 0 · peak 2')
1394
+ // the ticket in play belongs to the other map: nothing here is marked
1395
+ expect(await hasKey(ui, 'mark-208')).toBe(false)
1396
+ expect(r.calls.slice(-5).map(argv => argv.slice(0, 4))).toEqual([
1397
+ ['gh', 'issue', 'view', '14'],
1398
+ ['gh', 'issue', 'list', '--label'],
1399
+ ['gh', 'issue', 'list', '--label'],
1400
+ ['gh', 'api', 'graphql', '-F'],
1401
+ ['gh', 'issue', 'list', '--label'],
1402
+ ])
1403
+ expect(r.calls.some(argv => argv.includes('trailhead:map-14'))).toBe(true)
1404
+
1405
+ // the viewed map is bold, the active one a button; one line says where we are
1406
+ expect(await textOf(ui, 'map-label-14')).toBe('#14 older map')
1407
+ expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
1408
+ expect(await hasKey(ui, 'map-14')).toBe(false)
1409
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
1410
+ expect((await ui.find({ key: 'viewing-text' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
1411
+ expect((await ui.find({ key: 'back' }))?.props).toMatchObject({ label: 'back to active' })
1412
+ // the work verb owns the marker: the pane never wrote it
1413
+ expect(r.writes).toEqual([])
1414
+ expect(r.active).toBe('198\n')
1415
+ })
1416
+
1417
+ test(`back to active and a press on the active map return to it [${surface}]`, async ($, on) => {
1418
+ const r = rig(on, $)
1419
+ await $.session.start(START)
1420
+ const ui = await openAndMount($, r)
1421
+ await pressMap(ui, r, 14)
1422
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1423
+
1424
+ await ui.press({ key: 'back' })
1425
+ await settle(r)
1426
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1427
+ expect(await keysOf(ui, 'row-')).toContain('row-208')
1428
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1429
+ expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
1430
+
1431
+ await pressMap(ui, r, 14)
1432
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1433
+ await pressMap(ui, r, 198)
1434
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1435
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1436
+ expect(r.writes).toEqual([])
1437
+ })
1438
+
1439
+ test(`a change of the active map resets the view [${surface}]`, async ($, on) => {
1440
+ const r = rig(on, $)
1441
+ await $.session.start(START)
1442
+ const ui = await openAndMount($, r)
1443
+ await pressMap(ui, r, 14)
1444
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1445
+
1446
+ // the work verb switched the active map to the viewed one at the end of a turn
1447
+ r.active = '14\n'
1448
+ await r.clock.advance(FLOOR)
1449
+ await completeTurn($)
1450
+ await settle(r)
1451
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1452
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1453
+ expect(await textOf(ui, 'map-label-14')).toBe('#14 older map')
1454
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5 · active')
1455
+ expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
1456
+ })
1457
+
1458
+ test(`a work press on a viewed map runs the work verb, then the view follows the new active map [${surface}]`, async ($, on) => {
1459
+ const r = rig(on, $)
1460
+ await $.session.start(START)
1461
+ const ui = await openAndMount($, r)
1462
+ await pressMap(ui, r, 14)
1463
+ await ui.press({ key: 'work-140' })
1464
+ await settle(r)
1465
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '140' }])
1466
+ expect(r.writes).toEqual([])
1467
+ // still viewing until the verb has changed the marker
1468
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
1469
+
1470
+ r.active = '14\n'
1471
+ await r.clock.advance(FLOOR)
1472
+ await completeTurn($)
1473
+ await settle(r)
1474
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1475
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1476
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5 · active')
1477
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23')
1478
+ })
1479
+
1480
+ test(`both maps reads failing keep the last list and set no footer error [${surface}]`, async ($, on) => {
1481
+ const r = rig(on, $)
1482
+ await $.session.start(START)
1483
+ const ui = await openAndMount($, r)
1484
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1485
+
1486
+ const base: Gh = argv => withHistory(r, ok(JSON.stringify(r.history)))(argv)
1487
+ r.gh = argv => (isGraphql(argv) || isMapsList(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1488
+ r.tickets = [issue(300, 'fresh one', ['trailhead:build'])]
1489
+ await r.clock.advance(FLOOR)
1490
+ await completeTurn($)
1491
+ await settle(r)
1492
+ expect(await textOf(ui, 'ticket-300')).toBe('#300 fresh one')
1493
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1494
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1495
+
1496
+ // unreadable output is the same
1497
+ r.gh = argv => (isGraphql(argv) || isMapsList(argv) ? ok('not json') : base(argv))
1498
+ await r.clock.advance(FLOOR)
1499
+ await completeTurn($)
1500
+ await settle(r)
1501
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1502
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1503
+ })
1504
+
1505
+ test(`a viewed map that closed falls back to the active map [${surface}]`, async ($, on) => {
1506
+ const r = rig(on, $)
1507
+ await $.session.start(START)
1508
+ const ui = await openAndMount($, r)
1509
+ await pressMap(ui, r, 14)
1510
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1511
+
1512
+ r.maps = [MAPS[0]]
1513
+ await r.clock.advance(FLOOR)
1514
+ await completeTurn($)
1515
+ await settle(r)
1516
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1517
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1518
+ expect(await hasKey(ui, 'head-maps')).toBe(false)
1519
+ })
1520
+
1521
+ test(`a refresh in flight when the view changes never commits the old map's tickets [${surface}]`, async ($, on) => {
1522
+ const r = rig(on, $)
1523
+ // every value the pane stores, to look at what the old answer wrote
1524
+ const paneWrites: unknown[] = []
1525
+ on('state.set', { plugin: 'trailhead', key: 'mapPane' }, ($$, e, next) => {
1526
+ paneWrites.push(e.value)
1527
+ return next(e)
1528
+ })
1529
+ await $.session.start(START)
1530
+ const ui = await openAndMount($, r)
1531
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1532
+
1533
+ let release: () => void = () => undefined
1534
+ let releaseOther: () => void = () => undefined
1535
+ const held = new Promise<void>(resolve => (release = resolve))
1536
+ const heldOther = new Promise<void>(resolve => (releaseOther = resolve))
1537
+ r.gh = async argv => {
1538
+ // the active map's open-list read is held, and the viewed map's reads too (to look at the screen between)
1539
+ if (argv.includes('trailhead:map-198') && argv.includes('open')) await held
1540
+ if (argv.includes('trailhead:map-14') || argv.includes('14')) await heldOther
1541
+ return r.defaultGh(argv)
1542
+ }
1543
+ r.tickets = [issue(300, 'fresh of 198', ['trailhead:build'])]
1544
+ await r.clock.advance(FLOOR)
1545
+ const refreshing = completeTurn($)
1546
+ await settle(r)
1547
+
1548
+ await ui.press({ key: 'map-14' })
1549
+ await settle(r)
1550
+ const before = paneWrites.length
1551
+ release()
1552
+ await refreshing
1553
+ await settle(r)
1554
+ // the old answer is in, the new one is not: nothing of 198 is on screen, only the wait
1555
+ expect(await textOf(ui, 'map')).toBe('map #14')
1556
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
1557
+ expect(await hasKey(ui, 'ticket-300')).toBe(false)
1558
+ // and the store itself: the stale answer was dropped, not committed behind the render's check
1559
+ const landed = paneWrites.slice(before).map(v => JSON.stringify(v))
1560
+ expect(landed.some(v => v.includes('fresh of 198'))).toBe(false)
1561
+ releaseOther()
1562
+ await settle(r)
1563
+ expect(await hasKey(ui, 'ticket-300')).toBe(false)
1564
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1565
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
1566
+ })
1567
+
1568
+ test(`with no active map the open maps are listed and one can be viewed [${surface}]`, async ($, on) => {
1569
+ const r = rig(on, $)
1570
+ r.active = null
1571
+ await $.session.start(START)
1572
+ const ui = await openAndMount($, r)
1573
+ await completeTurn($)
1574
+ await settle(r)
1575
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
1576
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1577
+ expect(await hasKey(ui, 'map-198')).toBe(true)
1578
+ expect(await hasKey(ui, 'map-14')).toBe(true)
1579
+
1580
+ await pressMap(ui, r, 14)
1581
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1582
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14')
1583
+ expect((await ui.find({ key: 'back' }))?.props).toMatchObject({ label: 'back' })
1584
+ await ui.press({ key: 'back' })
1585
+ await settle(r)
1586
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
1587
+ expect(r.writes).toEqual([])
1588
+ })
1589
+
1590
+ const pressDashboard = async (ui: any, r: Rig) => {
1591
+ await ui.press({ key: 'open-whiteboard' })
1592
+ await settle(r)
1593
+ }
1594
+
1595
+ test(`the Whiteboard row shows the maps index and the whiteboard tickets [${surface}]`, async ($, on) => {
1596
+ const r = rig(on, $)
1597
+ await $.session.start(START)
1598
+ const ui = await openAndMount($, r)
1599
+ expect((await ui.find({ key: 'open-whiteboard' }))?.props).toMatchObject({ label: 'Whiteboard' })
1600
+ const before = r.calls.length
1601
+ await pressDashboard(ui, r)
1602
+
1603
+ expect(r.calls.slice(before)).toEqual([
1604
+ ['gh', 'issue', 'list', '--label', 'trailhead:ticket', '--label', 'trailhead:whiteboard', '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url'],
1605
+ ['gh', 'issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', 'number,url'],
1606
+ MAPS_CALL,
1607
+ uatCall('trailhead:whiteboard'),
1608
+ ])
1609
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1610
+ expect((await ui.find({ key: 'dashboard-title' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
1611
+ expect((await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)).toContain(`${URL}/5`)
1612
+ expect((await ui.find({ key: 'to-map' }))?.props).toMatchObject({ label: 'map' })
1613
+ expect(await hasKey(ui, 'dashboard')).toBe(false)
1614
+ // the view is the whiteboard: its row is not drawn again
1615
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(false)
1616
+
1617
+ // every open map is a button, the active one marked
1618
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1619
+ expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
1620
+ expect(await hasKey(ui, 'map-14')).toBe(true)
1621
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
1622
+
1623
+ // the whiteboard by state, the quarantined one only counted, no map tickets
1624
+ expect(await hasKey(ui, 'whiteboard')).toBe(true)
1625
+ expect(await textOf(ui, 'head-whiteboard')).toBe('Whiteboard · 3 open · 1 assigned')
1626
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
1627
+ expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 1')
1628
+ expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 1')
1629
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
1630
+ expect(await textOf(ui, 'holder-302')).toBe('@alice')
1631
+ expect(await hasKey(ui, 'work-301')).toBe(true)
1632
+ expect(await textOf(ui, 'unverified')).toBe('1 unverified ticket hidden (quarantined)')
1633
+ expect(await hasKey(ui, 'burndown-section')).toBe(false)
1634
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1635
+ expect(r.writes).toEqual([])
1636
+ })
1637
+
1638
+ test(`a Whiteboard row above the maps list carries the counts, the header has no dashboard button [${surface}]`, async ($, on) => {
1639
+ const r = rig(on, $)
1640
+ r.maps = [MAPS[0]]
1641
+ await $.session.start(START)
1642
+ const ui = await openAndMount($, r)
1643
+ // one map open: no maps group, the row is still there, without a per-ticket query
1644
+ expect(await hasKey(ui, 'group-maps')).toBe(false)
1645
+ expect((await ui.find({ key: 'open-whiteboard' }))?.props).toMatchObject({ label: 'Whiteboard' })
1646
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
1647
+ expect((await ui.find({ key: 'whiteboard-aside' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
1648
+ expect(await hasKey(ui, 'dashboard')).toBe(false)
1649
+ expect(r.calls.filter(argv => argv.includes('trailhead:whiteboard'))).toEqual([])
1650
+ expect(r.calls.filter(isGraphql)).toEqual([MAPS_CALL])
1651
+ // above the maps list and the tickets, below the heading
1652
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
1653
+ expect(keys.indexOf('whiteboard-row')).toBeGreaterThan(keys.indexOf('header'))
1654
+ expect(keys.indexOf('whiteboard-row')).toBeLessThan(keys.indexOf('group-frontier'))
1655
+ })
1656
+
1657
+ test(`the Whiteboard row sits above the Maps list [${surface}]`, async ($, on) => {
1658
+ const r = rig(on, $)
1659
+ await $.session.start(START)
1660
+ const ui = await openAndMount($, r)
1661
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
1662
+ expect(keys.indexOf('whiteboard-row')).toBeLessThan(keys.indexOf('group-maps'))
1663
+ expect(keys.indexOf('whiteboard-row')).toBeGreaterThan(keys.indexOf('header'))
1664
+ })
1665
+
1666
+ test(`a press on the Whiteboard row shows the whiteboard, the map button goes back [${surface}]`, async ($, on) => {
1667
+ const r = rig(on, $)
1668
+ r.maps = [MAPS[0]]
1669
+ await $.session.start(START)
1670
+ const ui = await openAndMount($, r)
1671
+ await pressDashboard(ui, r)
1672
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1673
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(false)
1674
+ await ui.press({ key: 'to-map' })
1675
+ await settle(r)
1676
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1677
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
1678
+ })
1679
+
1680
+ test(`the whiteboard counts are drawn as read, zero included, and follow the next read [${surface}]`, async ($, on) => {
1681
+ const r = rig(on, $)
1682
+ r.whiteboardCounts = { open: 0, total: 0 }
1683
+ await $.session.start(START)
1684
+ const ui = await openAndMount($, r)
1685
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 0 open / 0')
1686
+ r.whiteboardCounts = { open: 4, total: 11 }
1687
+ await r.clock.advance(FLOOR)
1688
+ await completeTurn($)
1689
+ await settle(r)
1690
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 4 open / 11')
1691
+ })
1692
+
1693
+ test(`a failed counts read shows the row name-only, never an error [${surface}]`, async ($, on) => {
1694
+ const r = rig(on, $)
1695
+ const base = r.defaultGh
1696
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1697
+ await $.session.start(START)
1698
+ const ui = await openAndMount($, r)
1699
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
1700
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
1701
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1702
+
1703
+ // counts that were read once are not kept through a failed read
1704
+ r.gh = null
1705
+ await r.clock.advance(FLOOR)
1706
+ await completeTurn($)
1707
+ await settle(r)
1708
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
1709
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1710
+ await r.clock.advance(FLOOR)
1711
+ await completeTurn($)
1712
+ await settle(r)
1713
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
1714
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1715
+ })
1716
+
1717
+ test(`both maps reads failing keep the maps list but draw the row name-only [${surface}]`, async ($, on) => {
1718
+ const r = rig(on, $)
1719
+ await $.session.start(START)
1720
+ const ui = await openAndMount($, r)
1721
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
1722
+ const base: Gh = argv => withHistory(r, ok(JSON.stringify(r.history)))(argv)
1723
+ r.gh = argv => (isGraphql(argv) || isMapsList(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1724
+ await r.clock.advance(FLOOR)
1725
+ await completeTurn($)
1726
+ await settle(r)
1727
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1728
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
1729
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
1730
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1731
+ })
1732
+
1733
+ test(`unreadable whiteboard fields leave the maps with counts and the row name-only [${surface}]`, async ($, on) => {
1734
+ const r = rig(on, $)
1735
+ r.whiteboardCounts = null
1736
+ await $.session.start(START)
1737
+ const ui = await openAndMount($, r)
1738
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
1739
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
1740
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
1741
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1742
+ })
1743
+
1744
+ test(`with no active map the Whiteboard row sits above the maps [${surface}]`, async ($, on) => {
1745
+ const none = rig(on, $)
1746
+ none.active = null
1747
+ await $.session.start(START)
1748
+ const ui = await openAndMount($, none)
1749
+ await completeTurn($)
1750
+ await settle(none)
1751
+ expect(await hasKey(ui, 'dashboard')).toBe(false)
1752
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
1753
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
1754
+ expect(keys.indexOf('whiteboard-row')).toBeLessThan(keys.indexOf('group-maps'))
1755
+ })
1756
+
1757
+ test(`the Whiteboard row stays in the loading view of a map [${surface}]`, async ($, on) => {
1758
+ const r = rig(on, $)
1759
+ let release: () => void = () => undefined
1760
+ const held = new Promise<void>(resolve => (release = resolve))
1761
+ const base = r.defaultGh
1762
+ r.gh = async argv => {
1763
+ if (argv[2] === 'view') await held
1764
+ return base(argv)
1765
+ }
1766
+ await $.session.start(START)
1767
+ r.open()
1768
+ const ui = await mountPane($)
1769
+ await settle(r)
1770
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
1771
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
1772
+ release()
1773
+ await settle(r)
1774
+ })
1775
+
1776
+ test(`the maps list shows each map's open and total from one call [${surface}]`, async ($, on) => {
1777
+ const r = rig(on, $)
1778
+ await $.session.start(START)
1779
+ const ui = await openAndMount($, r)
1780
+ expect(r.calls.filter(argv => argv.includes('trailhead:map') || isGraphql(argv))).toEqual([MAPS_CALL])
1781
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
1782
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5')
1783
+ // a map the read gave no summary for is drawn name-only
1784
+ r.mapSummary = { 198: { total: 23, completed: 16 } }
1785
+ await r.clock.advance(FLOOR)
1786
+ await completeTurn($)
1787
+ await settle(r)
1788
+ expect(await hasKey(ui, 'map-aside-14')).toBe(false)
1789
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
1790
+ })
1791
+
1792
+ test(`a failing counts read falls back to the name-only list with no footer error [${surface}]`, async ($, on) => {
1793
+ const r = rig(on, $)
1794
+ const base = r.defaultGh
1795
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1796
+ await $.session.start(START)
1797
+ const ui = await openAndMount($, r)
1798
+ expect(r.calls.filter(argv => argv.includes('trailhead:map') || isGraphql(argv))).toEqual([MAPS_CALL, MAPS_LIST_CALL])
1799
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1800
+ expect(await textOf(ui, 'map-aside-198')).toBe(' active')
1801
+ expect(await hasKey(ui, 'map-aside-14')).toBe(false)
1802
+ expect(await textOf(ui, 'age')).toBe('updated just now')
1803
+
1804
+ // a reply with errors is a failed read too
1805
+ r.gh = argv => (isGraphql(argv) ? ok(JSON.stringify({ data: { repository: { issues: { nodes: [] } } }, errors: [{ message: 'x' }] })) : base(argv))
1806
+ await r.clock.advance(FLOOR)
1807
+ await completeTurn($)
1808
+ await settle(r)
1809
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1810
+ expect(await hasKey(ui, 'map-aside-14')).toBe(false)
1811
+ })
1812
+
1813
+ test(`the dashboard heading counts the whiteboard and every assigned trusted ticket is a row [${surface}]`, async ($, on) => {
1814
+ const r = rig(on, $)
1815
+ r.whiteboard = [
1816
+ ...WHITEBOARD,
1817
+ issue(305, 'Assigned but quarantined', [...WB, 'trailhead:task', 'trailhead:unverified'], ['dave']),
1818
+ issue(306, 'Paused with a holder', [...WB, 'trailhead:task', 'trailhead:paused'], ['erin']),
1819
+ ]
1820
+ await $.session.start(START)
1821
+ const ui = await openAndMount($, r)
1822
+ await pressDashboard(ui, r)
1823
+ expect(await textOf(ui, 'head-whiteboard')).toBe('Whiteboard · 4 open · 2 assigned')
1824
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
1825
+ expect(await textOf(ui, 'holder-302')).toBe('@alice')
1826
+ expect(await textOf(ui, 'holder-306')).toBe('@erin')
1827
+ expect(await hasKey(ui, 'row-305')).toBe(false)
1828
+ expect(await textOf(ui, 'unverified')).toBe('2 unverified tickets hidden (quarantined)')
1829
+ })
1830
+
1831
+ test(`the map button goes back to the map shown before [${surface}]`, async ($, on) => {
1832
+ const r = rig(on, $)
1833
+ await $.session.start(START)
1834
+ const ui = await openAndMount($, r)
1835
+ await pressMap(ui, r, 14)
1836
+ await pressDashboard(ui, r)
1837
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1838
+
1839
+ await ui.press({ key: 'to-map' })
1840
+ await settle(r)
1841
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1842
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
1843
+ expect(await hasKey(ui, 'dashboard-title')).toBe(false)
1844
+ })
1845
+
1846
+ test(`a map pressed on the dashboard shows that map [${surface}]`, async ($, on) => {
1847
+ const r = rig(on, $)
1848
+ await $.session.start(START)
1849
+ const ui = await openAndMount($, r)
1850
+ await pressDashboard(ui, r)
1851
+ await pressMap(ui, r, 14)
1852
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
1853
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
1854
+
1855
+ await pressDashboard(ui, r)
1856
+ await pressMap(ui, r, 198)
1857
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1858
+ expect(await hasKey(ui, 'viewing')).toBe(false)
1859
+ })
1860
+
1861
+ test(`a work press on a whiteboard ticket clears and runs the work verb [${surface}]`, async ($, on) => {
1862
+ const r = rig(on, $)
1863
+ await $.session.start(START)
1864
+ const ui = await openAndMount($, r)
1865
+ await pressDashboard(ui, r)
1866
+ await ui.press({ key: 'work-301' })
1867
+ await settle(r)
1868
+ expect(r.runs).toEqual([
1869
+ { command: 'clear', args: '' },
1870
+ { command: 'trailhead:work', args: '301' },
1871
+ ])
1872
+ // the dashboard stays shown across the reset
1873
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1874
+ expect(await keysOf(ui, 'row-')).toContain('row-301')
1875
+ })
1876
+
1877
+ test(`an empty whiteboard says so, a failed read shows the reason [${surface}]`, async ($, on) => {
1878
+ const r = rig(on, $)
1879
+ r.whiteboard = []
1880
+ r.dashboard = []
1881
+ await $.session.start(START)
1882
+ const ui = await openAndMount($, r)
1883
+ await pressDashboard(ui, r)
1884
+ expect(await textOf(ui, 'no-tickets')).toBe('No open whiteboard tickets.')
1885
+ // no pinned index yet: the title is plain text
1886
+ expect((await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)).not.toContain(`${URL}/5`)
1887
+
1888
+ r.whiteboard = WHITEBOARD
1889
+ r.gh = argv => (argv.includes('trailhead:whiteboard') ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv))
1890
+ await r.clock.advance(FLOOR)
1891
+ await ui.press({ key: 'refresh' })
1892
+ await settle(r)
1893
+ expect(await textOf(ui, 'age')).toBe('⚠ gh failed (exit 1) · list from 15s ago')
1894
+ // a list that may be stale says nothing about being empty
1895
+ expect(await hasKey(ui, 'no-tickets')).toBe(false)
1896
+ })
1897
+
1898
+ test(`with no active map the dashboard is one press away [${surface}]`, async ($, on) => {
1899
+ const r = rig(on, $)
1900
+ r.active = null
1901
+ await $.session.start(START)
1902
+ const ui = await openAndMount($, r)
1903
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
1904
+ await pressDashboard(ui, r)
1905
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
1906
+ await ui.press({ key: 'to-map' })
1907
+ await settle(r)
1908
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
1909
+ })
1910
+
1911
+ test(`the dashboard stays shown through /clear [${surface}]`, async ($, on) => {
1912
+ const r = rig(on, $)
1913
+ await $.session.start(START)
1914
+ const ui = await openAndMount($, r)
1915
+ await pressDashboard(ui, r)
1916
+ await $.command.run({ command: 'clear', args: '' } as never)
1917
+ await settle(r)
1918
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
1919
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
1920
+ })
1921
+ }