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