@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.
- package/README.it.md +142 -29
- package/README.md +141 -28
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +17 -10
- package/bin/lib/codex-projection.test.js +80 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +40 -28
- package/bin/trailhead.test.js +503 -6
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -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/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -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 +297 -0
- package/plugins/trailhead/hooks/mod/README.md +219 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
- package/plugins/trailhead/hooks/mod/register.tsx +293 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -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 +187 -42
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
- 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 +59 -13
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
- 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 +7 -3
- package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
- package/plugins/trailhead/skills/_shared/substrate.md +6 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +853 -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 +110 -0
- 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
|
+
}
|