@marcomigozzi/trailhead 0.15.0 → 0.16.1
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 +52 -14
- package/README.md +53 -15
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +13 -6
- package/bin/lib/codex-projection.test.js +25 -2
- package/bin/lib/host-descriptor.js +14 -1
- package/bin/lib/host-descriptor.test.js +18 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +1 -1
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
- package/plugins/trailhead/agents/trailhead-verify.md +2 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +185 -39
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +2 -2
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
- package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +26 -1
|
@@ -11,6 +11,17 @@ const FLOOR = 15_000
|
|
|
11
11
|
const TIMER = 180_000
|
|
12
12
|
const AMBER = '#e0af68'
|
|
13
13
|
const URL = 'https://github.com/o/r/issues'
|
|
14
|
+
const ROOT = '/repo'
|
|
15
|
+
const ROOT_OTHER = '/other'
|
|
16
|
+
// A linked worktree of ROOT: the repo's root is the main tree's, the checkout's own is this.
|
|
17
|
+
const ROOT_TREE = '/repo-t5'
|
|
18
|
+
// The checkout handed to a verb: base64url of the root, computed here by hand so the pane's own encoder is checked.
|
|
19
|
+
// The helper's decodeIn cannot be imported here (the CommonJS template does not load in this suite); the node suite
|
|
20
|
+
// checks decodeIn against Buffer's base64url, and these tests pin the same alphabet.
|
|
21
|
+
const IN = (root: string) => {
|
|
22
|
+
const b64 = btoa(Array.from(new TextEncoder().encode(root), byte => String.fromCharCode(byte)).join(''))
|
|
23
|
+
return `--in=${b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
const MAP = {
|
|
16
27
|
title: 'trailhead mod',
|
|
@@ -63,12 +74,41 @@ const MAPS = [
|
|
|
63
74
|
]
|
|
64
75
|
// The maps read: one GraphQL call that carries each map's sub-issue counts and the whiteboard's.
|
|
65
76
|
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}}}'
|
|
77
|
+
'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}}totalCount}whiteboardOpen:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}whiteboardAll:issues(labels:["trailhead:whiteboard"]){totalCount}}}'
|
|
67
78
|
const MAPS_CALL = ['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MAPS_QUERY}`]
|
|
68
79
|
const MAPS_LIST_CALL = ['gh', 'issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', '100', '--json', 'number,title,url']
|
|
69
|
-
// The pending-UAT read
|
|
70
|
-
|
|
71
|
-
const
|
|
80
|
+
// The pending-UAT read: every open `trailhead:uat` ticket with its body and labels, one GraphQL page per call;
|
|
81
|
+
// the scope (a map label, or the whiteboard) is filtered by the pane.
|
|
82
|
+
const UAT_QUERY =
|
|
83
|
+
'query($owner:String!,$repo:String!,$after:String){repository(owner:$owner,name:$repo){issues(first:100,after:$after,states:OPEN,labels:["trailhead:uat"],orderBy:{field:CREATED_AT,direction:DESC}){nodes{number body labels(first:50){nodes{name}}}pageInfo{hasNextPage endCursor}}}}'
|
|
84
|
+
const uatCall = (after?: string) => [
|
|
85
|
+
'gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${UAT_QUERY}`,
|
|
86
|
+
...(after === undefined ? [] : ['-f', `after=${after}`]),
|
|
87
|
+
]
|
|
88
|
+
const isUatRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.includes(`query=${UAT_QUERY}`)
|
|
89
|
+
// The retired search read of the UAT parents: the pane must never run it.
|
|
90
|
+
const isUatSearch = (argv: readonly string[]) => argv[2] === 'list' && argv.includes('trailhead:uat')
|
|
91
|
+
const UAT_MAX_PAGES = 10
|
|
92
|
+
// One open UAT ticket as the read returns it: its body and the labels it carries (the scope among them).
|
|
93
|
+
const uatRow = (number: number, body: string, scope = 'trailhead:map-198') => ({ number, body, labels: [{ name: 'trailhead:uat' }, { name: scope }] })
|
|
94
|
+
// The open-ticket read: one GraphQL page per call (`after=` carries the cursor of the next one).
|
|
95
|
+
const TICKETS_QUERY =
|
|
96
|
+
'query($owner:String!,$repo:String!,$label:String!,$after:String){repository(owner:$owner,name:$repo){issues(first:100,after:$after,states:OPEN,labels:[$label],orderBy:{field:CREATED_AT,direction:DESC}){nodes{number title url labels(first:50){nodes{name}}assignees(first:10){nodes{login}}}pageInfo{hasNextPage endCursor}}}}'
|
|
97
|
+
const ticketsCall = (label: string, after?: string) => [
|
|
98
|
+
'gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${TICKETS_QUERY}`, '-f', `label=${label}`,
|
|
99
|
+
...(after === undefined ? [] : ['-f', `after=${after}`]),
|
|
100
|
+
]
|
|
101
|
+
const ticketsListCall = (label: string) => ['gh', 'issue', 'list', '--label', label, '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url']
|
|
102
|
+
const isTicketsRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.includes(`query=${TICKETS_QUERY}`)
|
|
103
|
+
// The maps read only: the ticket reads are GraphQL too, but answered and failed on their own.
|
|
104
|
+
const isGraphql = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && !isTicketsRead(argv) && !isUatRead(argv) && !isCountRead(argv)
|
|
105
|
+
// The open-ticket total: one aliased `totalCount` per open map (`m<n>`) plus the whiteboard (`wb`), in one call.
|
|
106
|
+
const countQuery = (maps: number[]) =>
|
|
107
|
+
`query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){${[...maps.map(n => `m${n}:issues(states:OPEN,labels:["trailhead:map-${n}"]){totalCount}`), 'wb:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}'].join('')}}}`
|
|
108
|
+
const countCall = (...maps: number[]) => ['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${countQuery(maps)}`]
|
|
109
|
+
const isCountRead = (argv: readonly string[]) => argv[1] === 'api' && argv[2] === 'graphql' && argv.some(a => a.startsWith('query=') && a.includes('wb:issues('))
|
|
110
|
+
const flatNode = (t: any) => ({ ...t, labels: { nodes: t.labels }, assignees: { nodes: t.assignees } })
|
|
111
|
+
const PAGE = 100
|
|
72
112
|
const isMapsList = (argv: readonly string[]) => argv[2] === 'list' && argv.includes('trailhead:map')
|
|
73
113
|
const OTHER_TICKETS = [issue(140, 'Old frontier ticket', ['trailhead:build']), issue(141, 'Old held ticket', ['trailhead:task'], ['carol'])]
|
|
74
114
|
const OTHER_HISTORY = OTHER_TICKETS.map(t => hrow(t.number, t.labels.map(l => l.name), null))
|
|
@@ -97,7 +137,7 @@ const stateText = (over: Record<string, unknown> = {}): string =>
|
|
|
97
137
|
})
|
|
98
138
|
|
|
99
139
|
// 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']
|
|
140
|
+
const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'isDashboard', 'dashboardPane', 'pendingWork', 'pendingPhase', 'paneAutoOpened', 'ownTicket', 'repoPin']
|
|
101
141
|
|
|
102
142
|
const PANE_PROPS = {
|
|
103
143
|
title: 'Map',
|
|
@@ -127,21 +167,58 @@ const rig = (on: On, engine?: any) => {
|
|
|
127
167
|
mapSummary: { 198: { total: 23, completed: 16 }, 14: { total: 5, completed: 2 } } as Record<number, unknown>,
|
|
128
168
|
// The whiteboard's open and total tickets as the same call reports them (null: the fields are absent).
|
|
129
169
|
whiteboardCounts: { open: 3, total: 9 } as { open: number; total: number } | null,
|
|
170
|
+
// The open tickets per label, as the total's count read reports them (a missing label counts 0); `countMode`
|
|
171
|
+
// makes that read fail (`fail`), answer `errors` (`errors`) or non-JSON (`junk`), `countTotal` the maps read's `totalCount`.
|
|
172
|
+
openCounts: { 'trailhead:map-198': 5, 'trailhead:map-14': 2, 'trailhead:whiteboard': 3 } as Record<string, number>,
|
|
173
|
+
countMode: 'ok' as 'ok' | 'fail' | 'errors' | 'junk',
|
|
174
|
+
countTotal: null as number | null,
|
|
130
175
|
otherMap: OTHER_MAP as unknown,
|
|
131
176
|
otherTickets: OTHER_TICKETS as unknown[],
|
|
132
177
|
otherHistory: OTHER_HISTORY as unknown[],
|
|
133
178
|
whiteboard: WHITEBOARD as unknown[],
|
|
134
|
-
// The UAT read's answer: one row per UAT ticket, `{ number, body }
|
|
179
|
+
// The UAT read's answer: one row per open UAT ticket, `{ number, body, labels }` (the scope label rides
|
|
180
|
+
// in `labels`); null makes the read fail. `uatErrors` answers a reply with `errors`, `uatBroken` non-JSON.
|
|
135
181
|
uat: [] as unknown[] | null,
|
|
182
|
+
uatErrors: false,
|
|
183
|
+
uatBroken: false,
|
|
136
184
|
dashboard: DASHBOARD as unknown[],
|
|
137
185
|
gh: null as Gh | null,
|
|
138
186
|
// What `gh` answers when no test replaced it; a test can wrap it.
|
|
139
187
|
defaultGh: null as unknown as Gh,
|
|
140
188
|
deny: null as string | null,
|
|
189
|
+
// The session's working directory, which a shell `cd` moves, and what `session.repo` finds there
|
|
190
|
+
// (null outside any repo). Relative reads and a `gh` without its own cwd resolve against `cwd`.
|
|
191
|
+
cwd: '/repo',
|
|
192
|
+
repo: { root: '/repo', remote: 'git@github.com:o/r.git', internal: false } as { root: string; remote: string | null; internal: boolean } | null,
|
|
193
|
+
// The active-map marker of the second checkout (ROOT_OTHER); null: it has none.
|
|
194
|
+
otherActive: null as string | null,
|
|
195
|
+
// Runs once before the next read of the active-map marker, to land an event inside a check.
|
|
196
|
+
beforeMarkerRead: null as (() => Promise<void>) | null,
|
|
197
|
+
// The worktree checkout's marker, what `git rev-parse --show-toplevel` answers there (null: it fails), and the
|
|
198
|
+
// session's project root; every path read and every `git` run is recorded.
|
|
199
|
+
treeActive: null as string | null,
|
|
200
|
+
top: null as string | null,
|
|
201
|
+
sessionRoot: '/repo',
|
|
202
|
+
reads: [] as string[],
|
|
203
|
+
// The hosts `gh auth status --hostname` accepts besides github.com, and every host it was asked about.
|
|
204
|
+
authHosts: [] as string[],
|
|
205
|
+
authCalls: [] as string[],
|
|
206
|
+
gitCalls: [] as { argv: readonly string[]; cwd: string | undefined }[],
|
|
207
|
+
// The cwd every `gh` run carried (undefined: none given).
|
|
208
|
+
cwds: [] as (string | undefined)[],
|
|
141
209
|
calls: [] as (readonly string[])[],
|
|
142
210
|
panes: [] as { id: string; title: string; isShown: boolean; isFocused: boolean; isPlaced: boolean }[],
|
|
143
211
|
isPlaced: true,
|
|
144
212
|
logs: [] as string[],
|
|
213
|
+
toasts: [] as string[],
|
|
214
|
+
// What a Bash call prints on stdout (the created issue's URL for a `gh issue create`).
|
|
215
|
+
stdout: 'ok' as string,
|
|
216
|
+
// `session.repo` answers a denial (the checkout cannot be read).
|
|
217
|
+
repoFails: false,
|
|
218
|
+
// What `git remote get-url origin` answers (null: no origin), for a `session.repo` that answers null (#299).
|
|
219
|
+
gitRemote: null as string | null,
|
|
220
|
+
// Checkout roots that no longer exist: git answers 128 there (a removed worktree or clone).
|
|
221
|
+
gone: [] as string[],
|
|
145
222
|
opens: [] as { id: string; title?: string }[],
|
|
146
223
|
// `command.run` beneath the plugin: every run is recorded; `gate` holds a work run, `refuse` rejects it.
|
|
147
224
|
// Every file write beneath the plugin: the pane must never write the active-map marker.
|
|
@@ -155,7 +232,15 @@ const rig = (on: On, engine?: any) => {
|
|
|
155
232
|
// The clear answered by a `{ deny }` hook: it resolves the call and the session stays.
|
|
156
233
|
denyClear: false,
|
|
157
234
|
// Runs between the old session ending and the new one starting.
|
|
158
|
-
duringClear: null as (() => void) | null,
|
|
235
|
+
duringClear: null as (() => void | Promise<void>) | null,
|
|
236
|
+
// Runs once, in the next `session.id` read (a dispatch's own read before its clear).
|
|
237
|
+
beforeSessionId: null as (() => void | Promise<void>) | null,
|
|
238
|
+
// Every value written to the pendingPhase atom, in order.
|
|
239
|
+
phases: [] as unknown[],
|
|
240
|
+
// Atom values the engine reports whatever was written (a state a hot reload left); a write clears the key.
|
|
241
|
+
forced: {} as Record<string, unknown>,
|
|
242
|
+
// Runs once, before the next pendingPhase write lands (the row still reads what it read before).
|
|
243
|
+
beforePhaseWrite: null as (() => void | Promise<void>) | null,
|
|
159
244
|
session: SESSION,
|
|
160
245
|
clock: mock.clock(on, { now: T0 }),
|
|
161
246
|
// The person opening the pane: the engine's own record, the plugin is not told.
|
|
@@ -164,22 +249,62 @@ const rig = (on: On, engine?: any) => {
|
|
|
164
249
|
},
|
|
165
250
|
}
|
|
166
251
|
const defaultGh: Gh = argv => {
|
|
167
|
-
if (argv
|
|
252
|
+
if (isTicketsRead(argv)) {
|
|
253
|
+
const label = (argv.find(a => a.startsWith('label=')) ?? '').slice('label='.length)
|
|
254
|
+
const cursor = argv.find(a => a.startsWith('after='))
|
|
255
|
+
const all = (label === 'trailhead:whiteboard' ? r.whiteboard : label === 'trailhead:map-14' ? r.otherTickets : r.tickets) as unknown[]
|
|
256
|
+
const from = cursor === undefined ? 0 : Number(cursor.slice('after=c'.length))
|
|
257
|
+
const to = from + PAGE
|
|
258
|
+
const pageInfo = { hasNextPage: to < all.length, endCursor: `c${to}` }
|
|
259
|
+
return ok(JSON.stringify({ data: { repository: { issues: { nodes: all.slice(from, to).map(flatNode), pageInfo } } } }))
|
|
260
|
+
}
|
|
261
|
+
if (isUatRead(argv)) {
|
|
262
|
+
if (r.uat === null) return { exitCode: 1, stdout: '', stderr: 'boom' }
|
|
263
|
+
if (r.uatBroken) return ok('not json')
|
|
264
|
+
if (r.uatErrors) return ok(JSON.stringify({ data: { repository: { issues: { nodes: [] } } }, errors: [{ message: 'x' }] }))
|
|
265
|
+
const cursor = argv.find(a => a.startsWith('after='))
|
|
266
|
+
const from = cursor === undefined ? 0 : Number(cursor.slice('after=c'.length))
|
|
267
|
+
const to = from + PAGE
|
|
268
|
+
const pageInfo = { hasNextPage: to < r.uat.length, endCursor: `c${to}` }
|
|
269
|
+
const nodes = (r.uat as any[]).slice(from, to).map(u => ({ ...u, labels: { nodes: u.labels ?? [] } }))
|
|
270
|
+
return ok(JSON.stringify({ data: { repository: { issues: { nodes, pageInfo } } } }))
|
|
271
|
+
}
|
|
272
|
+
if (isCountRead(argv)) {
|
|
273
|
+
if (r.countMode === 'fail') return { exitCode: 1, stdout: '', stderr: 'boom' }
|
|
274
|
+
if (r.countMode === 'junk') return ok('not json')
|
|
275
|
+
const query = (argv.find(a => a.startsWith('query=')) ?? '').slice('query='.length)
|
|
276
|
+
const repo = Object.fromEntries([...query.matchAll(/(\w+):issues\(states:OPEN,labels:\["([^"]+)"\]\)\{totalCount\}/g)].map(m => [m[1], { totalCount: r.openCounts[m[2] as string] ?? 0 }]))
|
|
277
|
+
return ok(JSON.stringify({ data: { repository: repo }, ...(r.countMode === 'errors' ? { errors: [{ message: 'x' }] } : {}) }))
|
|
278
|
+
}
|
|
168
279
|
if (argv.includes('trailhead:whiteboard')) return ok(JSON.stringify(r.whiteboard))
|
|
169
280
|
if (argv.includes('trailhead:dashboard')) return ok(JSON.stringify(r.dashboard))
|
|
170
281
|
if (isGraphql(argv)) {
|
|
171
282
|
const nodes = (r.maps as { number: number }[]).map(m => ({ ...m, subIssuesSummary: r.mapSummary[m.number] }))
|
|
172
283
|
const wb = r.whiteboardCounts
|
|
173
284
|
const counts = wb === null ? {} : { whiteboardOpen: { totalCount: wb.open }, whiteboardAll: { totalCount: wb.total } }
|
|
174
|
-
|
|
285
|
+
const totalCount = r.countTotal ?? nodes.length
|
|
286
|
+
return ok(JSON.stringify({ data: { repository: { issues: { nodes, totalCount }, ...counts } } }))
|
|
175
287
|
}
|
|
176
288
|
if (isMapsList(argv)) return ok(JSON.stringify(r.maps))
|
|
177
289
|
const isOther = argv.includes('14') || argv.includes('trailhead:map-14')
|
|
178
290
|
if (argv[2] === 'view') return ok(JSON.stringify(isOther ? r.otherMap : r.map))
|
|
179
291
|
return ok(JSON.stringify(argv.includes('all') ? (isOther ? r.otherHistory : r.history) : isOther ? r.otherTickets : r.tickets))
|
|
180
292
|
}
|
|
181
|
-
on('fs.read', (_$, e) => {
|
|
182
|
-
|
|
293
|
+
on('fs.read', async (_$, e) => {
|
|
294
|
+
// A relative path reaches the hook already resolved against the host's directory, not the repo's: put
|
|
295
|
+
// it under the session's working directory, which is what the engine resolves it against. A path under
|
|
296
|
+
// one of the repos is absolute (a pinned read) and stays.
|
|
297
|
+
const isAbsolute = [ROOT, ROOT_OTHER, ROOT_TREE].some(root => e.path.startsWith(`${root}/`))
|
|
298
|
+
const path = isAbsolute ? e.path : `${r.cwd}${e.path.slice(e.path.indexOf('/.trailhead/'))}`
|
|
299
|
+
r.reads.push(path)
|
|
300
|
+
if (path.endsWith('/.trailhead/active-map') && r.beforeMarkerRead !== null) {
|
|
301
|
+
const hook = r.beforeMarkerRead
|
|
302
|
+
r.beforeMarkerRead = null
|
|
303
|
+
await hook()
|
|
304
|
+
}
|
|
305
|
+
const root = path.startsWith(`${ROOT_TREE}/`) ? ROOT_TREE : path.startsWith(`${ROOT_OTHER}/`) ? ROOT_OTHER : path.startsWith(`${ROOT}/`) ? ROOT : null
|
|
306
|
+
const marker = root === ROOT_TREE ? r.treeActive : root === ROOT_OTHER ? r.otherActive : r.active
|
|
307
|
+
const file = path.endsWith('/.trailhead/active-map') ? (root === null ? null : marker) : path.endsWith('/.trailhead/work-state.json') ? (root === null ? null : r.work) : undefined
|
|
183
308
|
if (file === undefined) return { deny: `unexpected read of ${e.path}` }
|
|
184
309
|
if (file === null) return { deny: `ENOENT: no such file or directory, open '${e.path}'` }
|
|
185
310
|
if (file instanceof Error) return { deny: file.message }
|
|
@@ -196,16 +321,24 @@ const rig = (on: On, engine?: any) => {
|
|
|
196
321
|
const emptied = new Set<string>()
|
|
197
322
|
let clears = 0
|
|
198
323
|
on('state.get', { plugin: 'trailhead' }, async (_$, e, next) => {
|
|
324
|
+
if (e.key in r.forced) return { value: { value: r.forced[e.key], version: 1 } }
|
|
199
325
|
const read = await next(e)
|
|
200
326
|
return emptied.has(e.key) ? { value: { value: undefined, version: read.value?.version ?? 0 } } : read
|
|
201
327
|
})
|
|
202
|
-
on('state.set', { plugin: 'trailhead' }, (_$, e, next) => {
|
|
328
|
+
on('state.set', { plugin: 'trailhead' }, async (_$, e, next) => {
|
|
329
|
+
if (e.key === 'pendingPhase') {
|
|
330
|
+
const hold = r.beforePhaseWrite
|
|
331
|
+
r.beforePhaseWrite = null
|
|
332
|
+
await hold?.()
|
|
333
|
+
r.phases.push(e.value)
|
|
334
|
+
}
|
|
335
|
+
delete r.forced[e.key]
|
|
203
336
|
emptied.delete(e.key)
|
|
204
337
|
return next(e)
|
|
205
338
|
})
|
|
206
339
|
const emulateClear = async () => {
|
|
207
340
|
await engine.session.end({ reason: 'clear', sessionId: r.session, resume: {} })
|
|
208
|
-
r.duringClear?.()
|
|
341
|
+
await r.duringClear?.()
|
|
209
342
|
ATOM_KEYS.forEach(key => emptied.add(key))
|
|
210
343
|
r.session = `${NEXT_SESSION}-${(clears += 1)}`
|
|
211
344
|
await engine.classic.SessionStart({ source: 'clear' })
|
|
@@ -224,14 +357,52 @@ const rig = (on: On, engine?: any) => {
|
|
|
224
357
|
if (r.refuse !== null) throw new Error(r.refuse)
|
|
225
358
|
return { text: '' }
|
|
226
359
|
})
|
|
227
|
-
on('session.id', () =>
|
|
360
|
+
on('session.id', async () => {
|
|
361
|
+
const hold = r.beforeSessionId
|
|
362
|
+
r.beforeSessionId = null
|
|
363
|
+
await hold?.()
|
|
364
|
+
return { value: r.session }
|
|
365
|
+
})
|
|
228
366
|
on('ui.log', (_$, e) => {
|
|
229
367
|
r.logs.push(e.text)
|
|
230
368
|
return { value: undefined }
|
|
231
369
|
})
|
|
232
370
|
on('ui.status', () => ({ value: undefined }))
|
|
371
|
+
on('ui.toast', (_$, e) => {
|
|
372
|
+
r.toasts.push(e.text)
|
|
373
|
+
return { value: undefined }
|
|
374
|
+
})
|
|
375
|
+
on('session.repo', () => (r.repoFails ? { deny: 'repo read failed' } : { value: r.repo }))
|
|
376
|
+
on('session.root', () => ({ value: r.sessionRoot }))
|
|
377
|
+
on('session.cwd', () => ({ value: r.cwd }))
|
|
233
378
|
on('process.run', async (_$, e) => {
|
|
379
|
+
// The pin's own `git rev-parse --show-toplevel`: answered apart, never counted as a `gh` call.
|
|
380
|
+
if (e.argv[0] === 'git') {
|
|
381
|
+
r.gitCalls.push({ argv: [...e.argv], cwd: e.init?.cwd })
|
|
382
|
+
// Answered per directory, like git: the worktree gives `top`, the other checkouts their own root.
|
|
383
|
+
const where = e.init?.cwd
|
|
384
|
+
if (e.argv[1] === 'remote') {
|
|
385
|
+
const remote = r.gitRemote === null ? { exitCode: 2, stdout: '', stderr: '' } : { exitCode: 0, stdout: `${r.gitRemote}\n`, stderr: '' }
|
|
386
|
+
return { value: { ...remote, isStdoutTruncated: false, isStderrTruncated: false } }
|
|
387
|
+
}
|
|
388
|
+
const top = where !== undefined && r.gone.includes(where) ? null : where === ROOT_TREE ? r.top : where === ROOT || where === ROOT_OTHER ? where : null
|
|
389
|
+
const out = top === null ? { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' } : { exitCode: 0, stdout: `${top}\n`, stderr: '' }
|
|
390
|
+
return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
|
|
391
|
+
}
|
|
392
|
+
// The pin's host check: answered apart, never counted as a read of the pane.
|
|
393
|
+
if (e.argv[1] === 'auth') {
|
|
394
|
+
const host = e.argv[4] ?? ''
|
|
395
|
+
r.authCalls.push(host)
|
|
396
|
+
const out = r.authHosts.includes(host) ? { exitCode: 0, stdout: '', stderr: '' } : { exitCode: 1, stdout: '', stderr: `You are not logged into any accounts on ${host}` }
|
|
397
|
+
return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
|
|
398
|
+
}
|
|
234
399
|
r.calls.push([...e.argv])
|
|
400
|
+
r.cwds.push(e.init?.cwd)
|
|
401
|
+
// `gh` needs a repo around it: without its own cwd it uses the session's, wherever a `cd` left it.
|
|
402
|
+
const where = e.init?.cwd ?? r.cwd
|
|
403
|
+
if (where !== ROOT && where !== ROOT_OTHER && where !== ROOT_TREE) {
|
|
404
|
+
return { value: { exitCode: 1, stdout: '', stderr: 'failed to run git: fatal: not a git repository', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
405
|
+
}
|
|
235
406
|
if (r.deny !== null) return { deny: r.deny }
|
|
236
407
|
const out = await (r.gh ?? defaultGh)(e.argv)
|
|
237
408
|
return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
|
|
@@ -249,7 +420,7 @@ const rig = (on: On, engine?: any) => {
|
|
|
249
420
|
return { value: undefined }
|
|
250
421
|
})
|
|
251
422
|
on('ui.panes', () => ({ value: r.panes }))
|
|
252
|
-
on('tool.call', { tool: 'Bash' }, () => ({ result: '
|
|
423
|
+
on('tool.call', { tool: 'Bash' }, () => ({ result: { stdout: r.stdout, stderr: '' } }) as never)
|
|
253
424
|
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
254
425
|
on('classic.SessionStart', () => ({}))
|
|
255
426
|
on('turn.start', (_$, e) => ({ turnId: e.turnId }))
|
|
@@ -279,11 +450,11 @@ const settle = async (r: Rig) => {
|
|
|
279
450
|
for (let i = 0; i < 10; i += 1) await r.clock.settle()
|
|
280
451
|
}
|
|
281
452
|
// 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
|
|
453
|
+
const fetches = (r: Rig) => r.calls.filter(argv => isTicketsRead(argv) && !argv.some(a => a.startsWith('after='))).length
|
|
283
454
|
// A gh that answers as the default does, except for the history read.
|
|
284
455
|
const withHistory = (r: Rig, answer: GhOut): Gh => argv => {
|
|
285
456
|
if (argv.includes('all')) return answer
|
|
286
|
-
if (isGraphql(argv) || isMapsList(argv)) return r.defaultGh(argv)
|
|
457
|
+
if (isGraphql(argv) || isCountRead(argv) || isMapsList(argv)) return r.defaultGh(argv)
|
|
287
458
|
return argv[2] === 'view' ? ok(JSON.stringify(r.map)) : ok(JSON.stringify(r.tickets))
|
|
288
459
|
}
|
|
289
460
|
const hasKey = async (ui: any, key: string): Promise<boolean> => (await ui.find({ key })) !== undefined
|
|
@@ -309,10 +480,11 @@ for (const surface of SURFACES) {
|
|
|
309
480
|
|
|
310
481
|
expect(r.calls).toEqual([
|
|
311
482
|
['gh', 'issue', 'view', '198', '--json', 'title,body,url'],
|
|
312
|
-
|
|
483
|
+
ticketsCall('trailhead:map-198'),
|
|
313
484
|
['gh', 'issue', 'list', '--label', 'trailhead:map-198', '--state', 'all', '--limit', '5000', '--json', 'number,labels,createdAt,closedAt'],
|
|
314
485
|
MAPS_CALL,
|
|
315
|
-
uatCall(
|
|
486
|
+
uatCall(),
|
|
487
|
+
countCall(14, 198),
|
|
316
488
|
])
|
|
317
489
|
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
318
490
|
expect((await ui.find({ type: 'Link' }))?.props).toMatchObject({ href: `${URL}/198` })
|
|
@@ -369,9 +541,9 @@ for (const surface of SURFACES) {
|
|
|
369
541
|
issue(243, 'Paused UAT', ['trailhead:task', 'trailhead:uat', 'trailhead:paused']),
|
|
370
542
|
]
|
|
371
543
|
r.uat = [
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
544
|
+
uatRow(240, `Run it\n\nUAT of: [Burn-down view of the map](${URL}/209)\n`),
|
|
545
|
+
uatRow(241, `UAT of: #212`),
|
|
546
|
+
uatRow(243, `UAT of: [Paused parent](${URL}/150)`),
|
|
375
547
|
]
|
|
376
548
|
await $.session.start(START)
|
|
377
549
|
const ui = await openAndMount($, r)
|
|
@@ -391,6 +563,9 @@ for (const surface of SURFACES) {
|
|
|
391
563
|
expect(await textOf(ui, 'uat-of-241')).toBe('tests #212')
|
|
392
564
|
expect(await hasKey(ui, 'uat-of-242')).toBe(false)
|
|
393
565
|
expect(await textOf(ui, 'uat-of-243')).toBe('tests Paused parent #150')
|
|
566
|
+
// the parents come through one GraphQL read, never through the search index
|
|
567
|
+
expect(r.calls.filter(isUatRead)).toEqual([uatCall()])
|
|
568
|
+
expect(r.calls.filter(isUatSearch)).toEqual([])
|
|
394
569
|
// a marker, not a state: a frontier UAT task keeps its work button, a held one its holder, a seed stays dim
|
|
395
570
|
expect(await hasKey(ui, 'work-240')).toBe(true)
|
|
396
571
|
expect(await textOf(ui, 'holder-242')).toBe('@alice')
|
|
@@ -417,20 +592,73 @@ for (const surface of SURFACES) {
|
|
|
417
592
|
expect(await hasKey(ui, 'error')).toBe(false)
|
|
418
593
|
})
|
|
419
594
|
|
|
595
|
+
test(`a UAT row of another scope does not set the parent of a row with the same number [${surface}]`, async ($, on) => {
|
|
596
|
+
const r = rig(on, $)
|
|
597
|
+
r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
|
|
598
|
+
r.uat = [uatRow(240, `UAT of: [Elsewhere](${URL}/14)`, 'trailhead:map-14'), uatRow(241, `UAT of: #5`)]
|
|
599
|
+
await $.session.start(START)
|
|
600
|
+
const ui = await openAndMount($, r)
|
|
601
|
+
expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
|
|
602
|
+
expect(await hasKey(ui, 'uat-of-240')).toBe(false)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
test(`more than one page of follow-ups is read, the second call carrying the cursor [${surface}]`, async ($, on) => {
|
|
606
|
+
const r = rig(on, $)
|
|
607
|
+
r.tickets = [...TICKETS, issue(500, 'Deep UAT', ['trailhead:task', 'trailhead:uat'])]
|
|
608
|
+
r.uat = [...Array.from({ length: 149 }, (_, i) => uatRow(1000 + i, 'no parent line')), uatRow(500, `UAT of: [Deep parent](${URL}/77)`)]
|
|
609
|
+
await $.session.start(START)
|
|
610
|
+
const ui = await openAndMount($, r)
|
|
611
|
+
expect(r.calls.filter(isUatRead)).toEqual([uatCall(), uatCall('c100')])
|
|
612
|
+
expect(await textOf(ui, 'uat-of-500')).toBe('tests Deep parent #77')
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
const failClosed: [string, (r: Rig) => void][] = [
|
|
616
|
+
['a failed second page', r => { r.gh = argv => (isUatRead(argv) && argv.some(a => a.startsWith('after=')) ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv)) }],
|
|
617
|
+
['a reply with errors', r => { r.uatErrors = true }],
|
|
618
|
+
['a reply that is not JSON', r => { r.uatBroken = true }],
|
|
619
|
+
]
|
|
620
|
+
for (const [name, arm] of failClosed) {
|
|
621
|
+
test(`${name} draws the UAT rows with no parent line, no fallback, no partial map [${surface}]`, async ($, on) => {
|
|
622
|
+
const r = rig(on, $)
|
|
623
|
+
r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
|
|
624
|
+
// the parent sits on page 1; a second page exists so the failed-page case has something to lose
|
|
625
|
+
r.uat = [uatRow(240, `UAT of: [Burn-down](${URL}/209)`), ...Array.from({ length: 120 }, (_, i) => uatRow(1000 + i, 'x'))]
|
|
626
|
+
arm(r)
|
|
627
|
+
await $.session.start(START)
|
|
628
|
+
const ui = await openAndMount($, r)
|
|
629
|
+
expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
|
|
630
|
+
expect(await hasKey(ui, 'uat-of-240')).toBe(false)
|
|
631
|
+
expect(await hasKey(ui, 'error')).toBe(false)
|
|
632
|
+
expect(r.calls.filter(isUatSearch)).toEqual([])
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
test(`a read still paging at the cap draws no parent line and stops at the cap [${surface}]`, async ($, on) => {
|
|
637
|
+
const r = rig(on, $)
|
|
638
|
+
r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
|
|
639
|
+
r.uat = [uatRow(240, `UAT of: [Burn-down](${URL}/209)`), ...Array.from({ length: UAT_MAX_PAGES * PAGE + 5 }, (_, i) => uatRow(2000 + i, 'x'))]
|
|
640
|
+
await $.session.start(START)
|
|
641
|
+
const ui = await openAndMount($, r)
|
|
642
|
+
expect(r.calls.filter(isUatRead)).toHaveLength(UAT_MAX_PAGES)
|
|
643
|
+
expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
|
|
644
|
+
expect(await hasKey(ui, 'uat-of-240')).toBe(false)
|
|
645
|
+
})
|
|
646
|
+
|
|
420
647
|
test(`the dashboard view groups UAT whiteboard tickets with their parent, reading the whiteboard scope [${surface}]`, async ($, on) => {
|
|
421
648
|
const r = rig(on, $)
|
|
422
649
|
r.whiteboard = [...WHITEBOARD, issue(306, 'Loose UAT', [...WB, 'trailhead:task', 'trailhead:uat'])]
|
|
423
|
-
r.uat = [
|
|
650
|
+
r.uat = [uatRow(306, `UAT of: [Loose parent](${URL}/290)`, 'trailhead:whiteboard'), uatRow(306, `UAT of: #1`, 'trailhead:map-14')]
|
|
424
651
|
await $.session.start(START)
|
|
425
652
|
const ui = await openAndMount($, r)
|
|
426
653
|
await pressDashboard(ui, r)
|
|
427
|
-
expect(r.calls.
|
|
654
|
+
expect(r.calls.filter(isUatRead)).toContainEqual(uatCall())
|
|
655
|
+
expect(r.calls.filter(isUatSearch)).toEqual([])
|
|
428
656
|
expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
|
|
429
657
|
expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
|
|
430
658
|
expect(await textOf(ui, 'uat-of-306')).toBe('tests Loose parent #290')
|
|
431
659
|
})
|
|
432
660
|
|
|
433
|
-
test(`a paused ticket sits under Paused between In progress and Blocked, with
|
|
661
|
+
test(`a paused ticket sits under Paused between In progress and Blocked, with a resume button [${surface}]`, async ($, on) => {
|
|
434
662
|
const r = rig(on, $)
|
|
435
663
|
r.work = stateText()
|
|
436
664
|
r.tickets = [
|
|
@@ -449,11 +677,11 @@ for (const surface of SURFACES) {
|
|
|
449
677
|
// a paused ticket appears in one group only: Frontier, In progress, Paused, Blocked in order
|
|
450
678
|
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
679
|
expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-paused', 'group-blocked'])
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
680
|
+
for (const n of [230, 231, 232]) {
|
|
681
|
+
expect(await hasKey(ui, `resume-${n}`)).toBe(true)
|
|
682
|
+
expect(await hasKey(ui, `work-${n}`)).toBe(false)
|
|
683
|
+
expect(await hasKey(ui, `holder-${n}`)).toBe(false)
|
|
684
|
+
}
|
|
457
685
|
expect((await ui.find({ key: 'ticket-231' }))?.children[0].props).not.toHaveProperty('dimColor')
|
|
458
686
|
})
|
|
459
687
|
|
|
@@ -466,7 +694,63 @@ for (const surface of SURFACES) {
|
|
|
466
694
|
expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
|
|
467
695
|
expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-305', 'row-303'])
|
|
468
696
|
expect(await hasKey(ui, 'work-305')).toBe(false)
|
|
469
|
-
expect(await
|
|
697
|
+
expect(await hasKey(ui, 'resume-305')).toBe(true)
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
test(`needs-you tickets sit in their own group first, once each, with a work button unless held [${surface}]`, async ($, on) => {
|
|
701
|
+
const r = rig(on, $)
|
|
702
|
+
r.tickets = [
|
|
703
|
+
...TICKETS,
|
|
704
|
+
issue(250, 'Needs you plain', ['trailhead:build', 'trailhead:needs-you']),
|
|
705
|
+
issue(251, 'Needs you paused', ['trailhead:build', 'trailhead:needs-you', 'trailhead:paused']),
|
|
706
|
+
issue(252, 'Needs you paused and held', ['trailhead:build', 'trailhead:needs-you', 'trailhead:paused'], ['bob']),
|
|
707
|
+
issue(253, 'Needs you UAT', ['trailhead:task', 'trailhead:needs-you', 'trailhead:uat']),
|
|
708
|
+
issue(254, 'Needs you held', ['trailhead:build', 'trailhead:needs-you'], ['alice']),
|
|
709
|
+
issue(255, 'Needs you blocked', ['trailhead:build', 'trailhead:needs-you', 'trailhead:blocked']),
|
|
710
|
+
]
|
|
711
|
+
await $.session.start(START)
|
|
712
|
+
const ui = await openAndMount($, r)
|
|
713
|
+
|
|
714
|
+
expect(await textOf(ui, 'head-needs-you')).toBe('Needs you · 6')
|
|
715
|
+
expect((await ui.find({ key: 'head-needs-you' }))?.children[0]).toMatchObject({ props: { bold: true } })
|
|
716
|
+
expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-needs-you', 'group-frontier', 'group-in-progress', 'group-blocked'])
|
|
717
|
+
// each ticket once, ahead of the frontier rows; the other counts leave them out
|
|
718
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-250', 'row-251', 'row-252', 'row-253', 'row-254', 'row-255', 'row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-211', 'row-213'])
|
|
719
|
+
expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
|
|
720
|
+
expect(await hasKey(ui, 'head-paused')).toBe(false)
|
|
721
|
+
expect(await hasKey(ui, 'head-uat')).toBe(false)
|
|
722
|
+
for (const n of [250, 251, 253]) {
|
|
723
|
+
expect(await hasKey(ui, `work-${n}`)).toBe(true)
|
|
724
|
+
expect(await hasKey(ui, `resume-${n}`)).toBe(false)
|
|
725
|
+
}
|
|
726
|
+
for (const n of [252, 254]) {
|
|
727
|
+
expect(await textOf(ui, `holder-${n}`)).toBe(n === 252 ? '@bob' : '@alice')
|
|
728
|
+
expect(await hasKey(ui, `work-${n}`)).toBe(false)
|
|
729
|
+
expect(await hasKey(ui, `resume-${n}`)).toBe(false)
|
|
730
|
+
}
|
|
731
|
+
expect(await hasKey(ui, 'work-255')).toBe(false)
|
|
732
|
+
expect(await hasKey(ui, 'resume-255')).toBe(false)
|
|
733
|
+
expect(await hasKey(ui, 'holder-255')).toBe(false)
|
|
734
|
+
expect((await ui.find({ key: 'ticket-255' }))?.children[0].props).toMatchObject({ dimColor: true })
|
|
735
|
+
|
|
736
|
+
await ui.press({ key: 'work-251' })
|
|
737
|
+
await settle(r)
|
|
738
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '251' }])
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
test(`the dashboard view puts a needs-you whiteboard ticket first in its own group [${surface}]`, async ($, on) => {
|
|
742
|
+
const r = rig(on, $)
|
|
743
|
+
r.whiteboard = [...WHITEBOARD, issue(307, 'Loose needs you', [...WB, 'trailhead:task', 'trailhead:needs-you', 'trailhead:paused'])]
|
|
744
|
+
await $.session.start(START)
|
|
745
|
+
const ui = await openAndMount($, r)
|
|
746
|
+
await pressDashboard(ui, r)
|
|
747
|
+
expect(await textOf(ui, 'head-needs-you')).toBe('Needs you · 1')
|
|
748
|
+
// first ticket group, right after the maps group
|
|
749
|
+
expect((await keysOf(ui, 'group-')).slice(0, 2)).toEqual(['group-maps', 'group-needs-you'])
|
|
750
|
+
expect((await keysOf(ui, 'row-'))[0]).toBe('row-307')
|
|
751
|
+
expect(await hasKey(ui, 'head-paused')).toBe(false)
|
|
752
|
+
expect(await hasKey(ui, 'work-307')).toBe(true)
|
|
753
|
+
expect(await hasKey(ui, 'resume-307')).toBe(false)
|
|
470
754
|
})
|
|
471
755
|
|
|
472
756
|
test(`ticket rows are spaced apart on the desktop only [${surface}]`, async ($, on) => {
|
|
@@ -736,7 +1020,7 @@ for (const surface of SURFACES) {
|
|
|
736
1020
|
await completeTurn($)
|
|
737
1021
|
await settle(r)
|
|
738
1022
|
// the open maps are still read, so one can be picked; no map is
|
|
739
|
-
expect(r.calls).toEqual([MAPS_CALL])
|
|
1023
|
+
expect(r.calls).toEqual([MAPS_CALL, countCall(14, 198)])
|
|
740
1024
|
})
|
|
741
1025
|
|
|
742
1026
|
test(`first fetch in flight: the map number and Loading tickets [${surface}]`, async ($, on) => {
|
|
@@ -842,9 +1126,9 @@ for (const surface of SURFACES) {
|
|
|
842
1126
|
await openAndMount($, r)
|
|
843
1127
|
expect(fetches(r)).toBe(1)
|
|
844
1128
|
|
|
1129
|
+
// two turn ends with no write between: no flush, the trailing refresh is the only extra read
|
|
845
1130
|
await r.clock.advance(2_000)
|
|
846
1131
|
await completeTurn($)
|
|
847
|
-
await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
|
|
848
1132
|
await completeTurn($)
|
|
849
1133
|
await settle(r)
|
|
850
1134
|
expect(fetches(r)).toBe(1)
|
|
@@ -861,6 +1145,275 @@ for (const surface of SURFACES) {
|
|
|
861
1145
|
expect(fetches(r)).toBe(2)
|
|
862
1146
|
})
|
|
863
1147
|
|
|
1148
|
+
// A write followed by a turn end flushes: the turn end reads at once, inside the floor. The armed trailing
|
|
1149
|
+
// refresh is kept, so the read after the search index caught up still follows, one floor after the flush.
|
|
1150
|
+
test(`a write then a turn end inside the floor reads at once and keeps the trailing [${surface}]`, async ($, on) => {
|
|
1151
|
+
const r = rig(on, $)
|
|
1152
|
+
await $.session.start(START)
|
|
1153
|
+
await openAndMount($, r)
|
|
1154
|
+
expect(fetches(r)).toBe(1)
|
|
1155
|
+
|
|
1156
|
+
await r.clock.advance(2_000)
|
|
1157
|
+
await completeTurn($)
|
|
1158
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
|
|
1159
|
+
await completeTurn($)
|
|
1160
|
+
await settle(r)
|
|
1161
|
+
expect(fetches(r)).toBe(2)
|
|
1162
|
+
// a second turn end with no write since the flush adds nothing
|
|
1163
|
+
await completeTurn($)
|
|
1164
|
+
await settle(r)
|
|
1165
|
+
expect(fetches(r)).toBe(2)
|
|
1166
|
+
|
|
1167
|
+
// the trailing one fires at the first floor's end, lands inside the flush's floor and waits for its end
|
|
1168
|
+
await r.clock.advance(FLOOR - 2_000)
|
|
1169
|
+
await settle(r)
|
|
1170
|
+
expect(fetches(r)).toBe(2)
|
|
1171
|
+
await r.clock.advance(2_000)
|
|
1172
|
+
await settle(r)
|
|
1173
|
+
expect(fetches(r)).toBe(3)
|
|
1174
|
+
await r.clock.advance(FLOOR * 2)
|
|
1175
|
+
await settle(r)
|
|
1176
|
+
expect(fetches(r)).toBe(3)
|
|
1177
|
+
})
|
|
1178
|
+
|
|
1179
|
+
// The host drops a refused timer silently (a closed ticket stayed on the pane for good): the pane must not
|
|
1180
|
+
// wait on a dead handle.
|
|
1181
|
+
test(`a slow timer the host dropped is started again by the next trigger [${surface}]`, async ($, on) => {
|
|
1182
|
+
// Above the mock clock, which answers the wait without passing it on.
|
|
1183
|
+
let isDropping = false
|
|
1184
|
+
on('clock.every', { ms: TIMER }, async ($$, e, next) => (isDropping ? { deny: 'dropped' } : next(e)))
|
|
1185
|
+
const r = rig(on, $)
|
|
1186
|
+
await $.session.start(START)
|
|
1187
|
+
// the first period is refused: the interval ends unseen
|
|
1188
|
+
isDropping = true
|
|
1189
|
+
await openAndMount($, r)
|
|
1190
|
+
expect(fetches(r)).toBe(1)
|
|
1191
|
+
await r.clock.advance(TIMER)
|
|
1192
|
+
await settle(r)
|
|
1193
|
+
isDropping = false
|
|
1194
|
+
await r.clock.advance(TIMER)
|
|
1195
|
+
await settle(r)
|
|
1196
|
+
expect(fetches(r)).toBe(1)
|
|
1197
|
+
|
|
1198
|
+
// one trigger brings the timer back
|
|
1199
|
+
await completeTurn($)
|
|
1200
|
+
await settle(r)
|
|
1201
|
+
expect(fetches(r)).toBe(2)
|
|
1202
|
+
await r.clock.advance(TIMER)
|
|
1203
|
+
await settle(r)
|
|
1204
|
+
expect(fetches(r)).toBe(3)
|
|
1205
|
+
})
|
|
1206
|
+
|
|
1207
|
+
// The trailing refresh is dropped like the slow timer (a closed ticket stayed on the pane as held): the turn
|
|
1208
|
+
// end after a write reads at once, floor or not, so the final tracker state shows with no timer involved.
|
|
1209
|
+
test(`a turn end after a write shows the close although the trailing refresh was dropped [${surface}]`, async ($, on) => {
|
|
1210
|
+
on('clock.after', { ms: FLOOR }, async () => ({ deny: 'dropped' }))
|
|
1211
|
+
const r = rig(on, $)
|
|
1212
|
+
await $.session.start(START)
|
|
1213
|
+
const ui = await openAndMount($, r)
|
|
1214
|
+
await r.clock.advance(FLOOR)
|
|
1215
|
+
// the resolution comment reads at once, before the close
|
|
1216
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue comment 209 -b done' } as never)
|
|
1217
|
+
await settle(r)
|
|
1218
|
+
r.tickets = TICKETS.filter(t => t.number !== 209)
|
|
1219
|
+
await r.clock.advance(2_000)
|
|
1220
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
|
|
1221
|
+
await completeTurn($)
|
|
1222
|
+
await settle(r)
|
|
1223
|
+
expect(await hasKey(ui, 'row-209')).toBe(false)
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1226
|
+
// Without a turn end, a pane drawn again past the dead trailing's due time and grace reads at once.
|
|
1227
|
+
test(`a pane drawn again after a dropped trailing refresh reads at once [${surface}]`, async ($, on) => {
|
|
1228
|
+
let isDropping = false
|
|
1229
|
+
on('clock.after', { ms: FLOOR }, async ($$, e, next) => (isDropping ? { deny: 'dropped' } : next(e)))
|
|
1230
|
+
const r = rig(on, $)
|
|
1231
|
+
await $.session.start(START)
|
|
1232
|
+
const ui = await openAndMount($, r)
|
|
1233
|
+
await r.clock.advance(FLOOR)
|
|
1234
|
+
isDropping = true
|
|
1235
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue comment 209 -b done' } as never)
|
|
1236
|
+
await settle(r)
|
|
1237
|
+
r.tickets = TICKETS.filter(t => t.number !== 209)
|
|
1238
|
+
await r.clock.advance(2_000)
|
|
1239
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
|
|
1240
|
+
await settle(r)
|
|
1241
|
+
isDropping = false
|
|
1242
|
+
// nothing fired for the close: the pane still shows the closed ticket
|
|
1243
|
+
await r.clock.advance(FLOOR * 4)
|
|
1244
|
+
await settle(r)
|
|
1245
|
+
expect(await hasKey(ui, 'row-209')).toBe(true)
|
|
1246
|
+
await ui.unmount()
|
|
1247
|
+
const again = await mountPane($)
|
|
1248
|
+
await settle(r)
|
|
1249
|
+
expect(await hasKey(again, 'row-209')).toBe(false)
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
// The open list is a GraphQL read, never the search-backed `gh issue list --label`, which lags a write.
|
|
1253
|
+
const listReads = (r: Rig) => r.calls.filter(argv => argv[2] === 'list' && argv.includes('--label') && argv.includes('trailhead:map-198') && argv.includes('open') && !argv.includes('trailhead:uat'))
|
|
1254
|
+
|
|
1255
|
+
test(`a gh issue create shows the new ticket's row without a manual refresh [${surface}]`, async ($, on) => {
|
|
1256
|
+
const r = rig(on, $)
|
|
1257
|
+
await $.session.start(START)
|
|
1258
|
+
const ui = await openAndMount($, r)
|
|
1259
|
+
expect(await hasKey(ui, 'row-240')).toBe(false)
|
|
1260
|
+
|
|
1261
|
+
r.tickets = [...TICKETS, issue(240, 'Just created', ['trailhead:build'])]
|
|
1262
|
+
await r.clock.advance(FLOOR)
|
|
1263
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue create -t t' } as never)
|
|
1264
|
+
await settle(r)
|
|
1265
|
+
expect(await hasKey(ui, 'row-240')).toBe(true)
|
|
1266
|
+
expect(listReads(r)).toEqual([])
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
test(`a gh issue close drops the closed ticket's row without a manual refresh [${surface}]`, async ($, on) => {
|
|
1270
|
+
const r = rig(on, $)
|
|
1271
|
+
await $.session.start(START)
|
|
1272
|
+
const ui = await openAndMount($, r)
|
|
1273
|
+
expect(await hasKey(ui, 'row-209')).toBe(true)
|
|
1274
|
+
|
|
1275
|
+
r.tickets = TICKETS.filter(t => t.number !== 209)
|
|
1276
|
+
await r.clock.advance(FLOOR)
|
|
1277
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
|
|
1278
|
+
await settle(r)
|
|
1279
|
+
expect(await hasKey(ui, 'row-209')).toBe(false)
|
|
1280
|
+
expect(listReads(r)).toEqual([])
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
// The reads asked while the pane was undrawn (the person on another session) are dropped: drawn again with a
|
|
1284
|
+
// list older than one slow-timer period, the pane reads at once instead of showing a closed ticket.
|
|
1285
|
+
test(`a pane drawn again with an overdue list reads at once [${surface}]`, async ($, on) => {
|
|
1286
|
+
const r = rig(on, $)
|
|
1287
|
+
await $.session.start(START)
|
|
1288
|
+
const first = await openAndMount($, r)
|
|
1289
|
+
expect(fetches(r)).toBe(1)
|
|
1290
|
+
|
|
1291
|
+
await first.unmount()
|
|
1292
|
+
r.panes = r.panes.map(p => ({ ...p, isPlaced: false }))
|
|
1293
|
+
r.tickets = TICKETS.filter(t => t.number !== 209)
|
|
1294
|
+
await r.clock.advance(FLOOR)
|
|
1295
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
|
|
1296
|
+
await completeTurn($)
|
|
1297
|
+
await r.clock.advance(TIMER + 30_001)
|
|
1298
|
+
await settle(r)
|
|
1299
|
+
expect(fetches(r)).toBe(1)
|
|
1300
|
+
|
|
1301
|
+
r.panes = r.panes.map(p => ({ ...p, isPlaced: true }))
|
|
1302
|
+
const ui = await mountPane($)
|
|
1303
|
+
await settle(r)
|
|
1304
|
+
expect(fetches(r)).toBe(2)
|
|
1305
|
+
expect(await hasKey(ui, 'row-209')).toBe(false)
|
|
1306
|
+
})
|
|
1307
|
+
|
|
1308
|
+
test(`a create inside the floor shows at the floor's end [${surface}]`, async ($, on) => {
|
|
1309
|
+
const r = rig(on, $)
|
|
1310
|
+
await $.session.start(START)
|
|
1311
|
+
const ui = await openAndMount($, r)
|
|
1312
|
+
await r.clock.advance(2_000)
|
|
1313
|
+
r.tickets = [...TICKETS, issue(240, 'Just created', ['trailhead:build'])]
|
|
1314
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue create -t t' } as never)
|
|
1315
|
+
await settle(r)
|
|
1316
|
+
expect(await hasKey(ui, 'row-240')).toBe(false)
|
|
1317
|
+
await r.clock.advance(FLOOR - 2_000)
|
|
1318
|
+
await settle(r)
|
|
1319
|
+
expect(await hasKey(ui, 'row-240')).toBe(true)
|
|
1320
|
+
})
|
|
1321
|
+
|
|
1322
|
+
test(`a close inside the floor drops the row at the floor's end [${surface}]`, async ($, on) => {
|
|
1323
|
+
const r = rig(on, $)
|
|
1324
|
+
await $.session.start(START)
|
|
1325
|
+
const ui = await openAndMount($, r)
|
|
1326
|
+
await r.clock.advance(2_000)
|
|
1327
|
+
r.tickets = TICKETS.filter(t => t.number !== 209)
|
|
1328
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
|
|
1329
|
+
await settle(r)
|
|
1330
|
+
expect(await hasKey(ui, 'row-209')).toBe(true)
|
|
1331
|
+
await r.clock.advance(FLOOR - 2_000)
|
|
1332
|
+
await settle(r)
|
|
1333
|
+
expect(await hasKey(ui, 'row-209')).toBe(false)
|
|
1334
|
+
})
|
|
1335
|
+
|
|
1336
|
+
test(`a list of 150 tickets is read in two pages [${surface}]`, async ($, on) => {
|
|
1337
|
+
const r = rig(on, $)
|
|
1338
|
+
r.tickets = Array.from({ length: 150 }, (_, i) => issue(i + 1, `t${i + 1}`, ['trailhead:build']))
|
|
1339
|
+
await $.session.start(START)
|
|
1340
|
+
const ui = await openAndMount($, r)
|
|
1341
|
+
expect(r.calls.filter(isTicketsRead)).toEqual([ticketsCall('trailhead:map-198'), ticketsCall('trailhead:map-198', 'c100')])
|
|
1342
|
+
expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 150')
|
|
1343
|
+
expect(await hasKey(ui, 'capped')).toBe(false)
|
|
1344
|
+
expect(listReads(r)).toEqual([])
|
|
1345
|
+
})
|
|
1346
|
+
|
|
1347
|
+
test(`a failure on the second page discards the read and falls back to gh issue list once [${surface}]`, async ($, on) => {
|
|
1348
|
+
const r = rig(on, $)
|
|
1349
|
+
r.tickets = Array.from({ length: 150 }, (_, i) => issue(i + 1, `t${i + 1}`, ['trailhead:build']))
|
|
1350
|
+
r.gh = argv => (isTicketsRead(argv) && argv.some(a => a.startsWith('after=')) ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv))
|
|
1351
|
+
await $.session.start(START)
|
|
1352
|
+
const ui = await openAndMount($, r)
|
|
1353
|
+
expect(listReads(r)).toEqual([ticketsListCall('trailhead:map-198')])
|
|
1354
|
+
// the fallback list is whole (the rig's list answer), never the 100 rows of page one
|
|
1355
|
+
expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 150')
|
|
1356
|
+
expect(r.calls.filter(isTicketsRead)).toHaveLength(2)
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
test(`an unreadable first page falls back the same way [${surface}]`, async ($, on) => {
|
|
1360
|
+
const r = rig(on, $)
|
|
1361
|
+
r.gh = argv => (isTicketsRead(argv) ? ok('not json') : argv[2] === 'list' && argv.includes('trailhead:map-198') && argv.includes('open') ? ok(JSON.stringify([issue(300, 'from the list', ['trailhead:build'])])) : r.defaultGh(argv))
|
|
1362
|
+
await $.session.start(START)
|
|
1363
|
+
const ui = await openAndMount($, r)
|
|
1364
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-300'])
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
test(`the whiteboard read keeps only the rows that are also tickets [${surface}]`, async ($, on) => {
|
|
1368
|
+
const r = rig(on, $)
|
|
1369
|
+
r.whiteboard = [...WHITEBOARD, issue(310, 'Whiteboard label only', ['trailhead:whiteboard', 'trailhead:task'])]
|
|
1370
|
+
await $.session.start(START)
|
|
1371
|
+
const ui = await openAndMount($, r)
|
|
1372
|
+
await pressDashboard(ui, r)
|
|
1373
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
test(`a write that reads at once is followed by one more read at the floor's end [${surface}]`, async ($, on) => {
|
|
1377
|
+
const r = rig(on, $)
|
|
1378
|
+
await $.session.start(START)
|
|
1379
|
+
await openAndMount($, r)
|
|
1380
|
+
await r.clock.advance(FLOOR)
|
|
1381
|
+
const before = fetches(r)
|
|
1382
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
|
|
1383
|
+
await settle(r)
|
|
1384
|
+
expect(fetches(r)).toBe(before + 1)
|
|
1385
|
+
await r.clock.advance(FLOOR - 1)
|
|
1386
|
+
await settle(r)
|
|
1387
|
+
expect(fetches(r)).toBe(before + 1)
|
|
1388
|
+
await r.clock.advance(1)
|
|
1389
|
+
await settle(r)
|
|
1390
|
+
expect(fetches(r)).toBe(before + 2)
|
|
1391
|
+
// the follow-up is spent
|
|
1392
|
+
await r.clock.advance(FLOOR)
|
|
1393
|
+
await settle(r)
|
|
1394
|
+
expect(fetches(r)).toBe(before + 2)
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
test(`a burst of writes costs one read per floor plus one trailing [${surface}]`, async ($, on) => {
|
|
1398
|
+
const r = rig(on, $)
|
|
1399
|
+
await $.session.start(START)
|
|
1400
|
+
await openAndMount($, r)
|
|
1401
|
+
await r.clock.advance(FLOOR)
|
|
1402
|
+
const before = fetches(r)
|
|
1403
|
+
for (const command of ['gh issue close 5', 'gh issue create -t a', 'gh issue comment 5 -b x', 'gh issue edit 5 --add-label y', 'gh issue close 6']) {
|
|
1404
|
+
await $.tool.call({ tool: 'Bash', command } as never)
|
|
1405
|
+
await r.clock.advance(1_000)
|
|
1406
|
+
}
|
|
1407
|
+
await settle(r)
|
|
1408
|
+
expect(fetches(r)).toBe(before + 1)
|
|
1409
|
+
await r.clock.advance(FLOOR)
|
|
1410
|
+
await settle(r)
|
|
1411
|
+
expect(fetches(r)).toBe(before + 2)
|
|
1412
|
+
await r.clock.advance(FLOOR)
|
|
1413
|
+
await settle(r)
|
|
1414
|
+
expect(fetches(r)).toBe(before + 2)
|
|
1415
|
+
})
|
|
1416
|
+
|
|
864
1417
|
test(`session start, turn complete and the slow timer refresh an open pane [${surface}]`, async ($, on) => {
|
|
865
1418
|
const r = rig(on, $)
|
|
866
1419
|
await $.session.start(START)
|
|
@@ -887,15 +1440,21 @@ for (const surface of SURFACES) {
|
|
|
887
1440
|
await $.session.start(START)
|
|
888
1441
|
await openAndMount($, r)
|
|
889
1442
|
const base = fetches(r)
|
|
890
|
-
for (const command of ['gh issue list --state open', 'ls -la', 'gh pr close 4', 'gh issue view 5']) {
|
|
1443
|
+
for (const command of ['gh issue list --state open', 'ls -la', 'gh pr close 4', 'gh issue view 5', 'gh api repos/o/r/issues/5', 'gh api graphql -f query=x']) {
|
|
891
1444
|
await r.clock.advance(FLOOR)
|
|
892
1445
|
await $.tool.call({ tool: 'Bash', command } as never)
|
|
893
1446
|
await settle(r)
|
|
894
1447
|
}
|
|
895
1448
|
expect(fetches(r)).toBe(base)
|
|
896
1449
|
|
|
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
|
-
|
|
1450
|
+
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', 'gh issue delete 5', 'gh api repos/o/r/issues/198/sub_issues -F sub_issue_id=1', 'node /t/trailhead-issue-body.js append --issue 198 --section x --line y']) {
|
|
1451
|
+
// until a whole floor passes with no read (the previous write's follow-up, the slow timer), so this write starts outside one
|
|
1452
|
+
let seen = -1
|
|
1453
|
+
while (seen !== fetches(r)) {
|
|
1454
|
+
seen = fetches(r)
|
|
1455
|
+
await r.clock.advance(FLOOR)
|
|
1456
|
+
await settle(r)
|
|
1457
|
+
}
|
|
899
1458
|
const before = fetches(r)
|
|
900
1459
|
await $.tool.call({ tool: 'Bash', command } as never)
|
|
901
1460
|
await settle(r)
|
|
@@ -975,8 +1534,8 @@ for (const surface of SURFACES) {
|
|
|
975
1534
|
const first = ui.press({ key: 'work-209' })
|
|
976
1535
|
await settle(r)
|
|
977
1536
|
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
978
|
-
expect(await textOf(ui, '
|
|
979
|
-
expect((await ui.find({ key: '
|
|
1537
|
+
expect(await textOf(ui, 'starting-209')).toBe('starting')
|
|
1538
|
+
expect((await ui.find({ key: 'starting-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
|
|
980
1539
|
expect(await ui.find({ key: 'work-209' })).toBeUndefined()
|
|
981
1540
|
expect(await ui.find({ key: 'work-210' })).toBeDefined()
|
|
982
1541
|
|
|
@@ -984,12 +1543,12 @@ for (const surface of SURFACES) {
|
|
|
984
1543
|
await ui.press({ key: 'work-210' })
|
|
985
1544
|
await settle(r)
|
|
986
1545
|
expect(r.runs).toHaveLength(2)
|
|
987
|
-
expect(await ui.find({ key: '
|
|
1546
|
+
expect(await ui.find({ key: 'starting-210' })).toBeUndefined()
|
|
988
1547
|
|
|
989
1548
|
release()
|
|
990
1549
|
await first
|
|
991
1550
|
await settle(r)
|
|
992
|
-
expect(await ui.find({ key: '
|
|
1551
|
+
expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
|
|
993
1552
|
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
994
1553
|
|
|
995
1554
|
// free again: the next press goes through
|
|
@@ -1003,6 +1562,42 @@ for (const surface of SURFACES) {
|
|
|
1003
1562
|
])
|
|
1004
1563
|
})
|
|
1005
1564
|
|
|
1565
|
+
test(`a resume button on a paused row runs /trailhead:resume, shares the one-at-a-time guard and the refusal [${surface}]`, async ($, on) => {
|
|
1566
|
+
const r = rig(on, $)
|
|
1567
|
+
let release: () => void = () => undefined
|
|
1568
|
+
r.gate = new Promise<void>(resolve => (release = resolve))
|
|
1569
|
+
r.tickets = [...TICKETS, issue(230, 'Paused one', ['trailhead:build', 'trailhead:paused'], ['alice'])]
|
|
1570
|
+
await $.session.start(START)
|
|
1571
|
+
const ui = await openAndMount($, r)
|
|
1572
|
+
expect((await ui.find({ key: 'resume-230' }))?.props).toMatchObject({ label: 'resume', variant: 'primary' })
|
|
1573
|
+
|
|
1574
|
+
const first = ui.press({ key: 'resume-230' })
|
|
1575
|
+
await settle(r)
|
|
1576
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:resume', args: '230' }])
|
|
1577
|
+
expect(await textOf(ui, 'starting-230')).toBe('starting')
|
|
1578
|
+
expect(await ui.find({ key: 'resume-230' })).toBeUndefined()
|
|
1579
|
+
|
|
1580
|
+
// a work press meanwhile is ignored: one in flight at a time
|
|
1581
|
+
await ui.press({ key: 'work-210' })
|
|
1582
|
+
await settle(r)
|
|
1583
|
+
expect(r.runs).toHaveLength(2)
|
|
1584
|
+
|
|
1585
|
+
release()
|
|
1586
|
+
await first
|
|
1587
|
+
await settle(r)
|
|
1588
|
+
expect(await ui.find({ key: 'starting-230' })).toBeUndefined()
|
|
1589
|
+
expect(await ui.find({ key: 'resume-230' })).toBeDefined()
|
|
1590
|
+
|
|
1591
|
+
// outside the pinned repo it goes through, handing the pinned checkout to the verb
|
|
1592
|
+
const runs = r.runs.length
|
|
1593
|
+
r.cwd = ROOT_OTHER
|
|
1594
|
+
r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
|
|
1595
|
+
await ui.press({ key: 'resume-230' })
|
|
1596
|
+
await settle(r)
|
|
1597
|
+
expect(r.runs.slice(runs)).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:resume', args: `230 ${IN(ROOT)}` }])
|
|
1598
|
+
expect(r.toasts).toEqual([])
|
|
1599
|
+
})
|
|
1600
|
+
|
|
1006
1601
|
test(`a work command that is refused clears the queued mark [${surface}]`, async ($, on) => {
|
|
1007
1602
|
const r = rig(on, $)
|
|
1008
1603
|
r.refuse = 'cannot run now'
|
|
@@ -1015,19 +1610,121 @@ for (const surface of SURFACES) {
|
|
|
1015
1610
|
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
1016
1611
|
})
|
|
1017
1612
|
|
|
1018
|
-
test(`a work press
|
|
1613
|
+
test(`a work press with the session outside any repo goes through with the pinned checkout handed over [${surface}]`, async ($, on) => {
|
|
1019
1614
|
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
1615
|
await $.session.start(START)
|
|
1025
1616
|
const ui = await openAndMount($, r)
|
|
1026
|
-
|
|
1027
|
-
|
|
1617
|
+
r.cwd = '/tmp'
|
|
1618
|
+
r.repo = null
|
|
1619
|
+
await ui.press({ key: 'work-209' })
|
|
1620
|
+
await settle(r)
|
|
1621
|
+
expect(r.toasts).toEqual([])
|
|
1622
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT)}` }])
|
|
1623
|
+
// The token the verb receives decodes (plain base64url, the helper's alphabet) to the pinned root; the suite has no Buffer.
|
|
1624
|
+
const [number, word] = r.runs[1].args.split(' ')
|
|
1625
|
+
expect(number).toBe('209')
|
|
1626
|
+
expect(word).toMatch(/^--in=[A-Za-z0-9_-]+$/)
|
|
1627
|
+
expect(word).toBe(IN(ROOT))
|
|
1628
|
+
expect(atob(word.slice('--in='.length).replace(/-/g, '+').replace(/_/g, '/'))).toBe(ROOT)
|
|
1629
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
1630
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
1631
|
+
})
|
|
1632
|
+
|
|
1633
|
+
test(`a work press with the session in another repo goes through with the pinned checkout handed over [${surface}]`, async ($, on) => {
|
|
1634
|
+
const r = rig(on, $)
|
|
1635
|
+
await $.session.start(START)
|
|
1636
|
+
const ui = await openAndMount($, r)
|
|
1637
|
+
r.cwd = ROOT_OTHER
|
|
1638
|
+
r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
|
|
1639
|
+
await ui.press({ key: 'work-209' })
|
|
1640
|
+
await settle(r)
|
|
1641
|
+
expect(r.toasts).toEqual([])
|
|
1642
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT)}` }])
|
|
1643
|
+
})
|
|
1644
|
+
|
|
1645
|
+
test(`a pinned worktree checkout is handed over by its own root [${surface}]`, async ($, on) => {
|
|
1646
|
+
const r = rig(on, $)
|
|
1647
|
+
r.top = ROOT_TREE
|
|
1648
|
+
r.cwd = ROOT_TREE
|
|
1649
|
+
r.sessionRoot = ROOT_TREE
|
|
1650
|
+
r.active = '14\n'
|
|
1651
|
+
r.treeActive = '198\n'
|
|
1652
|
+
await $.session.start({ ...START, cwd: ROOT_TREE })
|
|
1653
|
+
const ui = await openAndMount($, r)
|
|
1654
|
+
// From inside it nothing is handed over.
|
|
1655
|
+
await ui.press({ key: 'work-209' })
|
|
1656
|
+
await settle(r)
|
|
1657
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
1658
|
+
// A shell cd out of it: the token names the worktree root, not the main tree's.
|
|
1659
|
+
r.cwd = ROOT_OTHER
|
|
1660
|
+
r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
|
|
1661
|
+
r.runs.length = 0
|
|
1662
|
+
await ui.press({ key: 'work-209' })
|
|
1663
|
+
await settle(r)
|
|
1664
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT_TREE)}` }])
|
|
1665
|
+
})
|
|
1666
|
+
|
|
1667
|
+
test(`a work press whose pinned checkout is gone while the session is out is refused before the clear [${surface}]`, async ($, on) => {
|
|
1668
|
+
const r = rig(on, $)
|
|
1669
|
+
await $.session.start(START)
|
|
1670
|
+
const ui = await openAndMount($, r)
|
|
1671
|
+
r.gone = [ROOT]
|
|
1672
|
+
r.cwd = '/tmp'
|
|
1673
|
+
r.repo = null
|
|
1674
|
+
await ui.press({ key: 'work-209' })
|
|
1675
|
+
await settle(r)
|
|
1676
|
+
expect(r.runs).toEqual([])
|
|
1677
|
+
expect(r.toasts).toHaveLength(1)
|
|
1678
|
+
expect(r.toasts[0]).toContain('#209')
|
|
1679
|
+
expect(r.toasts[0]).toContain('unreadable')
|
|
1680
|
+
expect(r.toasts[0].length).toBeLessThanOrEqual(40)
|
|
1681
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
1682
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
1683
|
+
})
|
|
1684
|
+
|
|
1685
|
+
test(`a work press in the pinned repo goes through although session.repo answers null [${surface}]`, async ($, on) => {
|
|
1686
|
+
const r = rig(on, $)
|
|
1687
|
+
await $.session.start(START)
|
|
1688
|
+
const ui = await openAndMount($, r)
|
|
1689
|
+
r.repo = null
|
|
1690
|
+
r.gitRemote = 'git@github.com:o/r.git'
|
|
1691
|
+
await ui.press({ key: 'work-209' })
|
|
1692
|
+
await settle(r)
|
|
1693
|
+
expect(r.toasts).toEqual([])
|
|
1694
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
1695
|
+
})
|
|
1696
|
+
|
|
1697
|
+
test(`a work press whose checkout cannot be read is refused, and the next one from the repo goes through [${surface}]`, async ($, on) => {
|
|
1698
|
+
const r = rig(on, $)
|
|
1699
|
+
await $.session.start(START)
|
|
1700
|
+
const ui = await openAndMount($, r)
|
|
1701
|
+
r.repoFails = true
|
|
1702
|
+
await ui.press({ key: 'work-209' })
|
|
1703
|
+
await settle(r)
|
|
1704
|
+
expect(r.runs).toEqual([])
|
|
1705
|
+
expect(r.toasts).toHaveLength(1)
|
|
1706
|
+
expect(r.toasts[0]).toContain('unreadable')
|
|
1707
|
+
expect(r.toasts[0]).not.toContain('cd into')
|
|
1708
|
+
expect(r.toasts[0].length).toBeLessThanOrEqual(40)
|
|
1709
|
+
r.repoFails = false
|
|
1710
|
+
await ui.press({ key: 'work-209' })
|
|
1711
|
+
await settle(r)
|
|
1712
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
1713
|
+
})
|
|
1714
|
+
|
|
1715
|
+
test(`a work press clears the conversation first, the row staying starting across the reset [${surface}]`, async ($, on) => {
|
|
1716
|
+
const r = rig(on, $)
|
|
1717
|
+
let releaseClear: () => void = () => undefined
|
|
1718
|
+
let releaseWork: () => void = () => undefined
|
|
1719
|
+
r.clearGate = new Promise<void>(resolve => (releaseClear = resolve))
|
|
1720
|
+
r.gate = new Promise<void>(resolve => (releaseWork = resolve))
|
|
1721
|
+
await $.session.start(START)
|
|
1722
|
+
const ui = await openAndMount($, r)
|
|
1723
|
+
|
|
1724
|
+
const first = ui.press({ key: 'work-209' })
|
|
1028
1725
|
await settle(r)
|
|
1029
1726
|
expect(r.runs).toEqual([{ command: 'clear', args: '' }])
|
|
1030
|
-
expect(await textOf(ui, '
|
|
1727
|
+
expect(await textOf(ui, 'starting-209')).toBe('starting')
|
|
1031
1728
|
|
|
1032
1729
|
// a second press while the clear is pending is ignored
|
|
1033
1730
|
await ui.press({ key: 'work-210' })
|
|
@@ -1040,17 +1737,463 @@ for (const surface of SURFACES) {
|
|
|
1040
1737
|
expect(r.session).toBe(`${NEXT_SESSION}-1`)
|
|
1041
1738
|
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
1042
1739
|
await ui.redraw()
|
|
1043
|
-
expect(await textOf(ui, '
|
|
1740
|
+
expect(await textOf(ui, 'starting-209')).toBe('starting')
|
|
1044
1741
|
await ui.press({ key: 'work-210' })
|
|
1045
1742
|
expect(r.runs).toHaveLength(2)
|
|
1046
1743
|
|
|
1047
1744
|
releaseWork()
|
|
1048
1745
|
await first
|
|
1049
1746
|
await settle(r)
|
|
1747
|
+
expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
|
|
1748
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
1749
|
+
})
|
|
1750
|
+
|
|
1751
|
+
// The main loop's turn, as the host raises it: a press during it is held by the panel until it ends.
|
|
1752
|
+
const startMainTurn = ($: any, id = 'held-turn') => $.turn.start({ text: 'go', turnId: id })
|
|
1753
|
+
const abortTurn = ($: any) =>
|
|
1754
|
+
$.turn.complete({ turnId: `t${(turns += 1)}`, answer: '', durationMs: 1, isAborted: true, reason: 'aborted' })
|
|
1755
|
+
|
|
1756
|
+
test(`a press during a running turn is held with a cancel button and runs when the turn ends [${surface}]`, async ($, on) => {
|
|
1757
|
+
const r = rig(on, $)
|
|
1758
|
+
await $.session.start(START)
|
|
1759
|
+
const ui = await openAndMount($, r)
|
|
1760
|
+
expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
|
|
1761
|
+
|
|
1762
|
+
await startMainTurn($)
|
|
1763
|
+
await ui.press({ key: 'work-209' })
|
|
1764
|
+
await settle(r)
|
|
1765
|
+
// Nothing reaches the host while the turn runs.
|
|
1766
|
+
expect(r.runs).toEqual([])
|
|
1767
|
+
expect(await textOf(ui, 'queued-209')).toBe('queued')
|
|
1768
|
+
expect((await ui.find({ key: 'queued-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
|
|
1769
|
+
expect((await ui.find({ key: 'cancel-209' }))?.props).toMatchObject({ label: 'cancel' })
|
|
1770
|
+
expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
|
|
1771
|
+
expect(await ui.find({ key: 'work-209' })).toBeUndefined()
|
|
1772
|
+
|
|
1773
|
+
// A second press while one is held is ignored.
|
|
1774
|
+
await ui.press({ key: 'work-210' })
|
|
1775
|
+
await settle(r)
|
|
1776
|
+
expect(r.runs).toEqual([])
|
|
1777
|
+
expect(await ui.find({ key: 'queued-210' })).toBeUndefined()
|
|
1778
|
+
|
|
1779
|
+
await completeTurn($)
|
|
1780
|
+
await settle(r)
|
|
1781
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
1782
|
+
expect(r.session).toBe(`${NEXT_SESSION}-1`)
|
|
1050
1783
|
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
1051
1784
|
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
1052
1785
|
})
|
|
1053
1786
|
|
|
1787
|
+
test(`a press held in the repo while the session cd's out is sent with the pinned checkout handed over [${surface}]`, async ($, on) => {
|
|
1788
|
+
const r = rig(on, $)
|
|
1789
|
+
await $.session.start(START)
|
|
1790
|
+
const ui = await openAndMount($, r)
|
|
1791
|
+
await startMainTurn($)
|
|
1792
|
+
await ui.press({ key: 'work-209' })
|
|
1793
|
+
await settle(r)
|
|
1794
|
+
expect(await textOf(ui, 'queued-209')).toBe('queued')
|
|
1795
|
+
// The turn moves the session out of any repo (a GitHub repo would move the pin along with it).
|
|
1796
|
+
r.cwd = '/tmp'
|
|
1797
|
+
r.repo = null
|
|
1798
|
+
await completeTurn($)
|
|
1799
|
+
await settle(r)
|
|
1800
|
+
expect(r.toasts).toEqual([])
|
|
1801
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT)}` }])
|
|
1802
|
+
})
|
|
1803
|
+
|
|
1804
|
+
test(`a held press whose checkout cannot be read at release is refused before the clear [${surface}]`, async ($, on) => {
|
|
1805
|
+
const r = rig(on, $)
|
|
1806
|
+
await $.session.start(START)
|
|
1807
|
+
const ui = await openAndMount($, r)
|
|
1808
|
+
await startMainTurn($)
|
|
1809
|
+
await ui.press({ key: 'work-209' })
|
|
1810
|
+
await settle(r)
|
|
1811
|
+
expect(await textOf(ui, 'queued-209')).toBe('queued')
|
|
1812
|
+
r.repoFails = true
|
|
1813
|
+
await completeTurn($)
|
|
1814
|
+
await settle(r)
|
|
1815
|
+
expect(r.runs).toEqual([])
|
|
1816
|
+
expect(r.toasts).toHaveLength(1)
|
|
1817
|
+
expect(r.toasts[0]).toContain('unreadable')
|
|
1818
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
1819
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
1820
|
+
})
|
|
1821
|
+
|
|
1822
|
+
test(`a cancel before the turn ends sends nothing, keeps the conversation and puts the work button back [${surface}]`, async ($, on) => {
|
|
1823
|
+
const r = rig(on, $)
|
|
1824
|
+
await $.session.start(START)
|
|
1825
|
+
const ui = await openAndMount($, r)
|
|
1826
|
+
await startMainTurn($)
|
|
1827
|
+
await ui.press({ key: 'work-209' })
|
|
1828
|
+
await settle(r)
|
|
1829
|
+
expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
|
|
1830
|
+
|
|
1831
|
+
await ui.press({ key: 'cancel-209' })
|
|
1832
|
+
await settle(r)
|
|
1833
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
1834
|
+
expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
|
|
1835
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
1836
|
+
|
|
1837
|
+
await completeTurn($)
|
|
1838
|
+
await settle(r)
|
|
1839
|
+
expect(r.runs).toEqual([])
|
|
1840
|
+
expect(r.session).toBe(SESSION)
|
|
1841
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
1842
|
+
|
|
1843
|
+
// The slot is free again: another row can be pressed and is held.
|
|
1844
|
+
await startMainTurn($, 'second')
|
|
1845
|
+
await ui.press({ key: 'work-210' })
|
|
1846
|
+
await settle(r)
|
|
1847
|
+
expect(await textOf(ui, 'queued-210')).toBe('queued')
|
|
1848
|
+
expect(r.runs).toEqual([])
|
|
1849
|
+
await completeTurn($)
|
|
1850
|
+
await settle(r)
|
|
1851
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
|
|
1852
|
+
})
|
|
1853
|
+
|
|
1854
|
+
test(`a cancel of a held resume press sends nothing [${surface}]`, async ($, on) => {
|
|
1855
|
+
const r = rig(on, $)
|
|
1856
|
+
r.tickets = [...TICKETS, issue(230, 'Paused one', ['trailhead:build', 'trailhead:paused'], ['alice'])]
|
|
1857
|
+
await $.session.start(START)
|
|
1858
|
+
const ui = await openAndMount($, r)
|
|
1859
|
+
await startMainTurn($)
|
|
1860
|
+
await ui.press({ key: 'resume-230' })
|
|
1861
|
+
await settle(r)
|
|
1862
|
+
expect(await textOf(ui, 'queued-230')).toBe('queued')
|
|
1863
|
+
await ui.press({ key: 'cancel-230' })
|
|
1864
|
+
await completeTurn($)
|
|
1865
|
+
await settle(r)
|
|
1866
|
+
expect(r.runs).toEqual([])
|
|
1867
|
+
expect(await ui.find({ key: 'resume-230' })).toBeDefined()
|
|
1868
|
+
})
|
|
1869
|
+
|
|
1870
|
+
test(`a press with no turn running starts at once and reads starting, with no cancel button [${surface}]`, async ($, on) => {
|
|
1871
|
+
const r = rig(on, $)
|
|
1872
|
+
let release: () => void = () => undefined
|
|
1873
|
+
r.gate = new Promise<void>(resolve => (release = resolve))
|
|
1874
|
+
await $.session.start(START)
|
|
1875
|
+
const ui = await openAndMount($, r)
|
|
1876
|
+
const first = ui.press({ key: 'work-209' })
|
|
1877
|
+
await settle(r)
|
|
1878
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
1879
|
+
expect(await textOf(ui, 'starting-209')).toBe('starting')
|
|
1880
|
+
expect((await ui.find({ key: 'starting-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
|
|
1881
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
1882
|
+
expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
|
|
1883
|
+
release()
|
|
1884
|
+
await first
|
|
1885
|
+
await settle(r)
|
|
1886
|
+
expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
|
|
1887
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
1888
|
+
})
|
|
1889
|
+
|
|
1890
|
+
test(`an interrupted turn releases a held press too [${surface}]`, async ($, on) => {
|
|
1891
|
+
const r = rig(on, $)
|
|
1892
|
+
await $.session.start(START)
|
|
1893
|
+
const ui = await openAndMount($, r)
|
|
1894
|
+
await startMainTurn($)
|
|
1895
|
+
await ui.press({ key: 'work-209' })
|
|
1896
|
+
await settle(r)
|
|
1897
|
+
expect(r.runs).toEqual([])
|
|
1898
|
+
await abortTurn($)
|
|
1899
|
+
await settle(r)
|
|
1900
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
1901
|
+
})
|
|
1902
|
+
|
|
1903
|
+
test(`a subagent's turn does not hold a press, nor does its end release one [${surface}]`, async ($, on) => {
|
|
1904
|
+
const r = rig(on, $)
|
|
1905
|
+
await $.session.start(START)
|
|
1906
|
+
const ui = await openAndMount($, r)
|
|
1907
|
+
await $.turn.start({ text: 'sub', turnId: 'sub-1', agentId: 'agent-1' })
|
|
1908
|
+
await ui.press({ key: 'work-209' })
|
|
1909
|
+
await settle(r)
|
|
1910
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
1911
|
+
|
|
1912
|
+
await startMainTurn($, 'main-1')
|
|
1913
|
+
await ui.press({ key: 'work-210' })
|
|
1914
|
+
await settle(r)
|
|
1915
|
+
expect(r.runs).toHaveLength(2)
|
|
1916
|
+
await $.turn.complete({ turnId: 'sub-1', answer: '', durationMs: 1, isAborted: false, reason: 'answer', agentId: 'agent-1' })
|
|
1917
|
+
await settle(r)
|
|
1918
|
+
expect(r.runs).toHaveLength(2)
|
|
1919
|
+
expect(await textOf(ui, 'queued-210')).toBe('queued')
|
|
1920
|
+
await completeTurn($)
|
|
1921
|
+
await settle(r)
|
|
1922
|
+
expect(r.runs).toHaveLength(4)
|
|
1923
|
+
})
|
|
1924
|
+
|
|
1925
|
+
test(`a turn that starts while the dispatch is still reading re-holds the press, cancel offered again [${surface}]`, async ($, on) => {
|
|
1926
|
+
const r = rig(on, $)
|
|
1927
|
+
await $.session.start(START)
|
|
1928
|
+
const ui = await openAndMount($, r)
|
|
1929
|
+
await startMainTurn($)
|
|
1930
|
+
await ui.press({ key: 'work-209' })
|
|
1931
|
+
await completeTurn($)
|
|
1932
|
+
// The next session id read is the dispatch's own, before its clear.
|
|
1933
|
+
r.beforeSessionId = async () => void (await startMainTurn($, 'late'))
|
|
1934
|
+
await settle(r)
|
|
1935
|
+
expect(r.runs).toEqual([])
|
|
1936
|
+
await ui.redraw()
|
|
1937
|
+
expect(await textOf(ui, 'queued-209')).toBe('queued')
|
|
1938
|
+
expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
|
|
1939
|
+
expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
|
|
1940
|
+
|
|
1941
|
+
await completeTurn($)
|
|
1942
|
+
await settle(r)
|
|
1943
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
1944
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
1945
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
1946
|
+
})
|
|
1947
|
+
|
|
1948
|
+
test(`a turn that starts between the end of one and the release keeps the press held until it ends [${surface}]`, async ($, on) => {
|
|
1949
|
+
const r = rig(on, $)
|
|
1950
|
+
await $.session.start(START)
|
|
1951
|
+
const ui = await openAndMount($, r)
|
|
1952
|
+
await startMainTurn($)
|
|
1953
|
+
await ui.press({ key: 'work-209' })
|
|
1954
|
+
await completeTurn($)
|
|
1955
|
+
// The release timer has not run yet: a queued prompt of the person's starts a turn.
|
|
1956
|
+
await startMainTurn($, 'next')
|
|
1957
|
+
await settle(r)
|
|
1958
|
+
expect(r.runs).toEqual([])
|
|
1959
|
+
expect(await textOf(ui, 'queued-209')).toBe('queued')
|
|
1960
|
+
expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
|
|
1961
|
+
|
|
1962
|
+
await completeTurn($)
|
|
1963
|
+
await settle(r)
|
|
1964
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
1965
|
+
})
|
|
1966
|
+
|
|
1967
|
+
for (const reason of ['prompt_input_exit', 'logout', 'other'] as const) {
|
|
1968
|
+
test(`a session that ends (${reason}) while a press is held drops it and resets the turn flag [${surface}]`, async ($, on) => {
|
|
1969
|
+
const r = rig(on, $)
|
|
1970
|
+
await $.session.start(START)
|
|
1971
|
+
const ui = await openAndMount($, r)
|
|
1972
|
+
await startMainTurn($)
|
|
1973
|
+
await ui.press({ key: 'work-209' })
|
|
1974
|
+
await settle(r)
|
|
1975
|
+
expect(await textOf(ui, 'queued-209')).toBe('queued')
|
|
1976
|
+
|
|
1977
|
+
await $.session.end({ reason, sessionId: r.session, resume: {} })
|
|
1978
|
+
await settle(r)
|
|
1979
|
+
expect(r.runs).toEqual([])
|
|
1980
|
+
await ui.redraw()
|
|
1981
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
1982
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
1983
|
+
|
|
1984
|
+
// No turn is running in the next session: a press goes straight through.
|
|
1985
|
+
await ui.press({ key: 'work-210' })
|
|
1986
|
+
await settle(r)
|
|
1987
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
|
|
1988
|
+
})
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
test(`a cancel pressed between the release and the clear sends nothing and puts the work button back [${surface}]`, async ($, on) => {
|
|
1992
|
+
const r = rig(on, $)
|
|
1993
|
+
await $.session.start(START)
|
|
1994
|
+
const ui = await openAndMount($, r)
|
|
1995
|
+
await startMainTurn($)
|
|
1996
|
+
await ui.press({ key: 'work-209' })
|
|
1997
|
+
await settle(r)
|
|
1998
|
+
await completeTurn($)
|
|
1999
|
+
// The row on screen still reads queued with its cancel button when the press lands, before the clear.
|
|
2000
|
+
let isPressed = false
|
|
2001
|
+
r.beforePhaseWrite = async () => {
|
|
2002
|
+
try {
|
|
2003
|
+
await ui.press({ key: 'cancel-209' })
|
|
2004
|
+
isPressed = true
|
|
2005
|
+
} catch (err) {
|
|
2006
|
+
r.logs.push(`seam press failed: ${String(err)}`)
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
await settle(r)
|
|
2010
|
+
expect(r.logs.filter(l => l.startsWith('seam'))).toEqual([])
|
|
2011
|
+
expect(isPressed).toBe(true)
|
|
2012
|
+
expect(r.runs).toEqual([])
|
|
2013
|
+
expect(r.session).toBe(SESSION)
|
|
2014
|
+
await ui.redraw()
|
|
2015
|
+
expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
|
|
2016
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
2017
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
2018
|
+
// The slot is free again.
|
|
2019
|
+
await ui.press({ key: 'work-210' })
|
|
2020
|
+
await settle(r)
|
|
2021
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
|
|
2022
|
+
})
|
|
2023
|
+
|
|
2024
|
+
// A cancel from a screen a frame behind: the row is drawn queued (the engine reports that phase) for the press.
|
|
2025
|
+
const staleCancel = (ui: any, r: Rig, number: number) => async () => {
|
|
2026
|
+
r.forced = { pendingPhase: 'queued' }
|
|
2027
|
+
await ui.redraw()
|
|
2028
|
+
await ui.press({ key: `cancel-${number}` })
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
test(`a cancel landing while a re-hold writes queued drops the press instead of holding it [${surface}]`, async ($, on) => {
|
|
2032
|
+
const r = rig(on, $)
|
|
2033
|
+
await $.session.start(START)
|
|
2034
|
+
const ui = await openAndMount($, r)
|
|
2035
|
+
await startMainTurn($)
|
|
2036
|
+
await ui.press({ key: 'work-209' })
|
|
2037
|
+
await settle(r)
|
|
2038
|
+
await completeTurn($)
|
|
2039
|
+
// A turn starts during the dispatch's reads: it re-holds, and the cancel lands on that queued write.
|
|
2040
|
+
r.beforeSessionId = async () => {
|
|
2041
|
+
await startMainTurn($, 'late')
|
|
2042
|
+
r.beforePhaseWrite = staleCancel(ui, r, 209)
|
|
2043
|
+
}
|
|
2044
|
+
await settle(r)
|
|
2045
|
+
await completeTurn($)
|
|
2046
|
+
await settle(r)
|
|
2047
|
+
expect(r.runs).toEqual([])
|
|
2048
|
+
expect(r.session).toBe(SESSION)
|
|
2049
|
+
await ui.redraw()
|
|
2050
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
2051
|
+
expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
|
|
2052
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
2053
|
+
})
|
|
2054
|
+
|
|
2055
|
+
test(`a cancel landing between the queued write and the hold drops the press [${surface}]`, async ($, on) => {
|
|
2056
|
+
const r = rig(on, $)
|
|
2057
|
+
await $.session.start(START)
|
|
2058
|
+
const ui = await openAndMount($, r)
|
|
2059
|
+
await startMainTurn($)
|
|
2060
|
+
await ui.press({ key: 'work-209' })
|
|
2061
|
+
await settle(r)
|
|
2062
|
+
await ui.press({ key: 'cancel-209' })
|
|
2063
|
+
await settle(r)
|
|
2064
|
+
|
|
2065
|
+
r.beforePhaseWrite = staleCancel(ui, r, 209)
|
|
2066
|
+
await ui.press({ key: 'work-209' })
|
|
2067
|
+
await settle(r)
|
|
2068
|
+
await completeTurn($)
|
|
2069
|
+
await settle(r)
|
|
2070
|
+
expect(r.runs).toEqual([])
|
|
2071
|
+
await ui.redraw()
|
|
2072
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
2073
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
2074
|
+
})
|
|
2075
|
+
|
|
2076
|
+
test(`a press made right after a cancel keeps its queued mark and claim and runs when the turn ends [${surface}]`, async ($, on) => {
|
|
2077
|
+
const r = rig(on, $)
|
|
2078
|
+
await $.session.start(START)
|
|
2079
|
+
const ui = await openAndMount($, r)
|
|
2080
|
+
await startMainTurn($)
|
|
2081
|
+
await ui.press({ key: 'work-209' })
|
|
2082
|
+
await settle(r)
|
|
2083
|
+
// No settling in between: the cancel's own redraw must not let a stale-claim sweep erase the next press.
|
|
2084
|
+
await ui.press({ key: 'cancel-209' })
|
|
2085
|
+
await ui.press({ key: 'work-210' })
|
|
2086
|
+
await settle(r)
|
|
2087
|
+
await ui.redraw()
|
|
2088
|
+
await settle(r)
|
|
2089
|
+
expect(r.runs).toEqual([])
|
|
2090
|
+
expect(await textOf(ui, 'queued-210')).toBe('queued')
|
|
2091
|
+
expect(await ui.find({ key: 'cancel-210' })).toBeDefined()
|
|
2092
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
2093
|
+
|
|
2094
|
+
await completeTurn($)
|
|
2095
|
+
await settle(r)
|
|
2096
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
|
|
2097
|
+
})
|
|
2098
|
+
|
|
2099
|
+
for (const phase of [null, 'starting'] as const) {
|
|
2100
|
+
test(`a pending claim with phase ${phase} and no press behind it is cleared when the pane draws [${surface}]`, async ($, on) => {
|
|
2101
|
+
const r = rig(on, $)
|
|
2102
|
+
await $.session.start(START)
|
|
2103
|
+
const ui = await openAndMount($, r)
|
|
2104
|
+
// What a hot reload leaves: the atoms say pending, the module state is empty.
|
|
2105
|
+
r.forced = { pendingWork: 209, pendingPhase: phase }
|
|
2106
|
+
await ui.redraw()
|
|
2107
|
+
await settle(r)
|
|
2108
|
+
await ui.redraw()
|
|
2109
|
+
expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
|
|
2110
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
2111
|
+
await ui.press({ key: 'work-210' })
|
|
2112
|
+
await settle(r)
|
|
2113
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
|
|
2114
|
+
})
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
test(`a press with no turn running never shows queued or a cancel button [${surface}]`, async ($, on) => {
|
|
2118
|
+
const r = rig(on, $)
|
|
2119
|
+
let release: () => void = () => undefined
|
|
2120
|
+
r.clearGate = new Promise<void>(resolve => (release = resolve))
|
|
2121
|
+
await $.session.start(START)
|
|
2122
|
+
const ui = await openAndMount($, r)
|
|
2123
|
+
const first = ui.press({ key: 'work-209' })
|
|
2124
|
+
await settle(r)
|
|
2125
|
+
// The phase went straight to starting: queued was never written, so no cancel was ever offered.
|
|
2126
|
+
expect(r.phases[0]).toBe('starting')
|
|
2127
|
+
expect(r.phases).not.toContain('queued')
|
|
2128
|
+
expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
|
|
2129
|
+
release()
|
|
2130
|
+
await first
|
|
2131
|
+
await settle(r)
|
|
2132
|
+
})
|
|
2133
|
+
|
|
2134
|
+
for (const reason of ['clear', 'resume'] as const) {
|
|
2135
|
+
test(`a session that ends (${reason}) while a press is held drops it [${surface}]`, async ($, on) => {
|
|
2136
|
+
const r = rig(on, $)
|
|
2137
|
+
await $.session.start(START)
|
|
2138
|
+
const ui = await openAndMount($, r)
|
|
2139
|
+
await startMainTurn($)
|
|
2140
|
+
await ui.press({ key: 'work-209' })
|
|
2141
|
+
await settle(r)
|
|
2142
|
+
expect(await textOf(ui, 'queued-209')).toBe('queued')
|
|
2143
|
+
|
|
2144
|
+
await $.session.end({ reason, sessionId: r.session, resume: {} })
|
|
2145
|
+
await settle(r)
|
|
2146
|
+
expect(r.runs).toEqual([])
|
|
2147
|
+
await ui.redraw()
|
|
2148
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
2149
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
2150
|
+
// The turn flag was reset too: a press goes straight through.
|
|
2151
|
+
await ui.press({ key: 'work-210' })
|
|
2152
|
+
await settle(r)
|
|
2153
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
|
|
2154
|
+
})
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
test(`a cancel on a stuck queued row (nothing held, nothing sending) clears it [${surface}]`, async ($, on) => {
|
|
2158
|
+
const r = rig(on, $)
|
|
2159
|
+
await $.session.start(START)
|
|
2160
|
+
const ui = await openAndMount($, r)
|
|
2161
|
+
// What a hot reload leaves: the atoms say queued, the module state is empty.
|
|
2162
|
+
r.forced = { pendingWork: 209, pendingPhase: 'queued' }
|
|
2163
|
+
await ui.redraw()
|
|
2164
|
+
expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
|
|
2165
|
+
await ui.press({ key: 'cancel-209' })
|
|
2166
|
+
await settle(r)
|
|
2167
|
+
await ui.redraw()
|
|
2168
|
+
expect(r.runs).toEqual([])
|
|
2169
|
+
expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
|
|
2170
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
2171
|
+
})
|
|
2172
|
+
|
|
2173
|
+
test(`the mod's own clear leaves the row starting across the reset until the verb settles [${surface}]`, async ($, on) => {
|
|
2174
|
+
const r = rig(on, $)
|
|
2175
|
+
let release: () => void = () => undefined
|
|
2176
|
+
r.gate = new Promise<void>(resolve => (release = resolve))
|
|
2177
|
+
await $.session.start(START)
|
|
2178
|
+
const ui = await openAndMount($, r)
|
|
2179
|
+
let during: string | undefined
|
|
2180
|
+
r.duringClear = async () => {
|
|
2181
|
+
await ui.redraw()
|
|
2182
|
+
during = await textOf(ui, 'starting-209')
|
|
2183
|
+
}
|
|
2184
|
+
const first = ui.press({ key: 'work-209' })
|
|
2185
|
+
await settle(r)
|
|
2186
|
+
expect(during).toBe('starting')
|
|
2187
|
+
await ui.redraw()
|
|
2188
|
+
expect(await textOf(ui, 'starting-209')).toBe('starting')
|
|
2189
|
+
expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
|
|
2190
|
+
release()
|
|
2191
|
+
await first
|
|
2192
|
+
await settle(r)
|
|
2193
|
+
expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
|
|
2194
|
+
expect(await ui.find({ key: 'work-209' })).toBeDefined()
|
|
2195
|
+
})
|
|
2196
|
+
|
|
1054
2197
|
test(`a clear that is refused runs no work command and clears the queued mark [${surface}]`, async ($, on) => {
|
|
1055
2198
|
const r = rig(on, $)
|
|
1056
2199
|
r.refuseClear = 'cannot clear now'
|
|
@@ -1235,6 +2378,27 @@ for (const surface of SURFACES) {
|
|
|
1235
2378
|
expect((await panel($)).text).toBe('Map panel closed.')
|
|
1236
2379
|
})
|
|
1237
2380
|
|
|
2381
|
+
test(`/trailhead:panel shows a pane the auto-open left undrawn instead of closing it [${surface}]`, async ($, on) => {
|
|
2382
|
+
const r = rig(on, $)
|
|
2383
|
+
// sessione vuota, terminale sotto la soglia dei pannelli non richiesti: l'auto-open resta non disegnato
|
|
2384
|
+
r.isPlaced = false
|
|
2385
|
+
await $.session.start(START)
|
|
2386
|
+
await mountBand($, FULLSCREEN)
|
|
2387
|
+
await settle(r)
|
|
2388
|
+
expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
|
|
2389
|
+
|
|
2390
|
+
// la richiesta della persona lo disegna a qualsiasi larghezza
|
|
2391
|
+
r.isPlaced = true
|
|
2392
|
+
expect((await panel($)).text).toBe('Map panel opened.')
|
|
2393
|
+
expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, true]])
|
|
2394
|
+
await settle(r)
|
|
2395
|
+
expect(fetches(r)).toBe(1)
|
|
2396
|
+
|
|
2397
|
+
// disegnato, il comando torna a chiuderlo
|
|
2398
|
+
expect((await panel($)).text).toBe('Map panel closed.')
|
|
2399
|
+
expect(r.panes).toEqual([])
|
|
2400
|
+
})
|
|
2401
|
+
|
|
1238
2402
|
for (const withBand of [false, true]) {
|
|
1239
2403
|
test(`the pane opens by itself once in fullscreen, the band ${withBand ? 'drawing' : 'yielding'} [${surface}]`, async ($, on) => {
|
|
1240
2404
|
const r = rig(on, $)
|
|
@@ -1393,12 +2557,13 @@ for (const surface of SURFACES) {
|
|
|
1393
2557
|
expect(await textOf(ui, 'burndown-counts')).toBe('open 2 · done 0 · removed 0 · peak 2')
|
|
1394
2558
|
// the ticket in play belongs to the other map: nothing here is marked
|
|
1395
2559
|
expect(await hasKey(ui, 'mark-208')).toBe(false)
|
|
1396
|
-
expect(r.calls.slice(-
|
|
2560
|
+
expect(r.calls.slice(-6).map(argv => argv.slice(0, 4))).toEqual([
|
|
1397
2561
|
['gh', 'issue', 'view', '14'],
|
|
1398
|
-
['gh', 'issue', 'list', '--label'],
|
|
1399
|
-
['gh', 'issue', 'list', '--label'],
|
|
1400
2562
|
['gh', 'api', 'graphql', '-F'],
|
|
1401
2563
|
['gh', 'issue', 'list', '--label'],
|
|
2564
|
+
['gh', 'api', 'graphql', '-F'],
|
|
2565
|
+
['gh', 'api', 'graphql', '-F'],
|
|
2566
|
+
['gh', 'api', 'graphql', '-F'],
|
|
1402
2567
|
])
|
|
1403
2568
|
expect(r.calls.some(argv => argv.includes('trailhead:map-14'))).toBe(true)
|
|
1404
2569
|
|
|
@@ -1601,10 +2766,11 @@ for (const surface of SURFACES) {
|
|
|
1601
2766
|
await pressDashboard(ui, r)
|
|
1602
2767
|
|
|
1603
2768
|
expect(r.calls.slice(before)).toEqual([
|
|
1604
|
-
|
|
2769
|
+
ticketsCall('trailhead:whiteboard'),
|
|
1605
2770
|
['gh', 'issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', 'number,url'],
|
|
1606
2771
|
MAPS_CALL,
|
|
1607
|
-
uatCall(
|
|
2772
|
+
uatCall(),
|
|
2773
|
+
countCall(14, 198),
|
|
1608
2774
|
])
|
|
1609
2775
|
expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
|
|
1610
2776
|
expect((await ui.find({ key: 'dashboard-title' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
|
|
@@ -1789,25 +2955,185 @@ for (const surface of SURFACES) {
|
|
|
1789
2955
|
expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
|
|
1790
2956
|
})
|
|
1791
2957
|
|
|
1792
|
-
test(`a failing counts read falls back to the name-only list with no footer error [${surface}]`, async ($, on) => {
|
|
2958
|
+
test(`a failing counts read falls back to the name-only list with no footer error [${surface}]`, async ($, on) => {
|
|
2959
|
+
const r = rig(on, $)
|
|
2960
|
+
const base = r.defaultGh
|
|
2961
|
+
r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
|
|
2962
|
+
await $.session.start(START)
|
|
2963
|
+
const ui = await openAndMount($, r)
|
|
2964
|
+
expect(r.calls.filter(argv => argv.includes('trailhead:map') || isGraphql(argv))).toEqual([MAPS_CALL, MAPS_LIST_CALL])
|
|
2965
|
+
expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
|
|
2966
|
+
expect(await textOf(ui, 'map-aside-198')).toBe(' active')
|
|
2967
|
+
expect(await hasKey(ui, 'map-aside-14')).toBe(false)
|
|
2968
|
+
expect(await textOf(ui, 'age')).toBe('updated just now')
|
|
2969
|
+
|
|
2970
|
+
// a reply with errors is a failed read too
|
|
2971
|
+
r.gh = argv => (isGraphql(argv) ? ok(JSON.stringify({ data: { repository: { issues: { nodes: [] } } }, errors: [{ message: 'x' }] })) : base(argv))
|
|
2972
|
+
await r.clock.advance(FLOOR)
|
|
2973
|
+
await completeTurn($)
|
|
2974
|
+
await settle(r)
|
|
2975
|
+
expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
|
|
2976
|
+
expect(await hasKey(ui, 'map-aside-14')).toBe(false)
|
|
2977
|
+
})
|
|
2978
|
+
|
|
2979
|
+
// The open-ticket total across every open map plus the whiteboard (5 + 2 + 3 with the default counts).
|
|
2980
|
+
const TOTAL_TEXT = '10 open across all maps + whiteboard'
|
|
2981
|
+
const totalLines = async (ui: any): Promise<number> => (await ui.findAll({ key: 'open-total' })).length
|
|
2982
|
+
const refreshOnce = async ($: any, r: Rig) => {
|
|
2983
|
+
await r.clock.advance(FLOOR)
|
|
2984
|
+
await completeTurn($)
|
|
2985
|
+
await settle(r)
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
test(`the total line shows exactly once in the map view, under the Whiteboard row [${surface}]`, async ($, on) => {
|
|
2989
|
+
const r = rig(on, $)
|
|
2990
|
+
await $.session.start(START)
|
|
2991
|
+
const ui = await openAndMount($, r)
|
|
2992
|
+
expect(await totalLines(ui)).toBe(1)
|
|
2993
|
+
expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
|
|
2994
|
+
expect((await ui.find({ key: 'open-total' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
|
|
2995
|
+
const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
|
|
2996
|
+
expect(keys.indexOf('open-total')).toBeGreaterThan(keys.indexOf('whiteboard-row'))
|
|
2997
|
+
expect(keys.indexOf('open-total')).toBeLessThan(keys.indexOf('group-frontier'))
|
|
2998
|
+
})
|
|
2999
|
+
|
|
3000
|
+
test(`the total line shows exactly once in the loading state and the no-active-map view [${surface}]`, async ($, on) => {
|
|
3001
|
+
const r = rig(on, $)
|
|
3002
|
+
r.active = null
|
|
3003
|
+
await $.session.start(START)
|
|
3004
|
+
const ui = await openAndMount($, r)
|
|
3005
|
+
expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
|
|
3006
|
+
await completeTurn($)
|
|
3007
|
+
await settle(r)
|
|
3008
|
+
expect(await totalLines(ui)).toBe(1)
|
|
3009
|
+
expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
|
|
3010
|
+
|
|
3011
|
+
// a map pressed while its read is held: Loading tickets, the last good total beside the Whiteboard row
|
|
3012
|
+
const base = r.defaultGh
|
|
3013
|
+
let release: () => void = () => undefined
|
|
3014
|
+
const held = new Promise<void>(resolve => (release = resolve))
|
|
3015
|
+
r.gh = async argv => {
|
|
3016
|
+
if (argv[2] === 'view' || isTicketsRead(argv)) await held
|
|
3017
|
+
return base(argv)
|
|
3018
|
+
}
|
|
3019
|
+
await ui.press({ key: 'map-14' })
|
|
3020
|
+
await settle(r)
|
|
3021
|
+
expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
|
|
3022
|
+
expect(await totalLines(ui)).toBe(1)
|
|
3023
|
+
expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
|
|
3024
|
+
release()
|
|
3025
|
+
await settle(r)
|
|
3026
|
+
})
|
|
3027
|
+
|
|
3028
|
+
test(`the total line shows exactly once under the Dashboard header row [${surface}]`, async ($, on) => {
|
|
3029
|
+
const r = rig(on, $)
|
|
3030
|
+
await $.session.start(START)
|
|
3031
|
+
const ui = await openAndMount($, r)
|
|
3032
|
+
await pressDashboard(ui, r)
|
|
3033
|
+
expect(await hasKey(ui, 'dashboard-title')).toBe(true)
|
|
3034
|
+
expect(await totalLines(ui)).toBe(1)
|
|
3035
|
+
expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
|
|
3036
|
+
const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
|
|
3037
|
+
expect(keys.indexOf('open-total')).toBeGreaterThan(keys.indexOf('header-row'))
|
|
3038
|
+
expect(keys.indexOf('open-total')).toBeLessThan(keys.indexOf('group-maps'))
|
|
3039
|
+
})
|
|
3040
|
+
|
|
3041
|
+
test(`the total is counted over the open maps' numbers, one extra call per read [${surface}]`, async ($, on) => {
|
|
3042
|
+
const r = rig(on, $)
|
|
3043
|
+
r.maps = [MAPS[0]]
|
|
3044
|
+
await $.session.start(START)
|
|
3045
|
+
const ui = await openAndMount($, r)
|
|
3046
|
+
expect(r.calls.filter(isCountRead)).toEqual([countCall(198)])
|
|
3047
|
+
// 5 open on the map + 3 on the whiteboard
|
|
3048
|
+
expect(await textOf(ui, 'open-total')).toBe('8 open across all maps + whiteboard')
|
|
3049
|
+
// a closed map is never counted: only the open maps are enumerated
|
|
3050
|
+
r.maps = MAPS
|
|
3051
|
+
await refreshOnce($, r)
|
|
3052
|
+
expect(r.calls.filter(isCountRead).at(-1)).toEqual(countCall(14, 198))
|
|
3053
|
+
expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
|
|
3054
|
+
})
|
|
3055
|
+
|
|
3056
|
+
for (const mode of ['fail', 'errors', 'junk'] as const) {
|
|
3057
|
+
test(`a count read that fails (${mode}) before any good read draws no total line [${surface}]`, async ($, on) => {
|
|
3058
|
+
const r = rig(on, $)
|
|
3059
|
+
r.countMode = mode
|
|
3060
|
+
await $.session.start(START)
|
|
3061
|
+
const ui = await openAndMount($, r)
|
|
3062
|
+
expect(await totalLines(ui)).toBe(0)
|
|
3063
|
+
// the rest of the pane is unharmed and no footer error is raised for it
|
|
3064
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3065
|
+
expect(await textOf(ui, 'age')).toBe('updated just now')
|
|
3066
|
+
})
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
test(`a later failing count read keeps the last good total while the open maps are the same [${surface}]`, async ($, on) => {
|
|
3070
|
+
const r = rig(on, $)
|
|
3071
|
+
await $.session.start(START)
|
|
3072
|
+
const ui = await openAndMount($, r)
|
|
3073
|
+
expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
|
|
3074
|
+
r.countMode = 'fail'
|
|
3075
|
+
r.openCounts = { ...r.openCounts, 'trailhead:whiteboard': 99 }
|
|
3076
|
+
await refreshOnce($, r)
|
|
3077
|
+
expect(await totalLines(ui)).toBe(1)
|
|
3078
|
+
expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
|
|
3079
|
+
// a good read takes over again
|
|
3080
|
+
r.countMode = 'ok'
|
|
3081
|
+
await refreshOnce($, r)
|
|
3082
|
+
expect(await textOf(ui, 'open-total')).toBe('106 open across all maps + whiteboard')
|
|
3083
|
+
})
|
|
3084
|
+
|
|
3085
|
+
test(`a failing count read after the open maps changed hides the total, never stale [${surface}]`, async ($, on) => {
|
|
3086
|
+
const r = rig(on, $)
|
|
3087
|
+
await $.session.start(START)
|
|
3088
|
+
const ui = await openAndMount($, r)
|
|
3089
|
+
expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
|
|
3090
|
+
r.countMode = 'fail'
|
|
3091
|
+
r.maps = [MAPS[0]]
|
|
3092
|
+
await refreshOnce($, r)
|
|
3093
|
+
expect(await totalLines(ui)).toBe(0)
|
|
3094
|
+
})
|
|
3095
|
+
|
|
3096
|
+
test(`the name-only maps fallback draws no total and makes no count call [${surface}]`, async ($, on) => {
|
|
3097
|
+
const r = rig(on, $)
|
|
3098
|
+
const base = r.defaultGh
|
|
3099
|
+
r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
|
|
3100
|
+
await $.session.start(START)
|
|
3101
|
+
const ui = await openAndMount($, r)
|
|
3102
|
+
expect(r.calls.filter(isCountRead)).toEqual([])
|
|
3103
|
+
expect(await totalLines(ui)).toBe(0)
|
|
3104
|
+
expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
|
|
3105
|
+
})
|
|
3106
|
+
|
|
3107
|
+
test(`a fallback after a good total hides it rather than keep a figure over a list of unknown completeness [${surface}]`, async ($, on) => {
|
|
3108
|
+
const r = rig(on, $)
|
|
3109
|
+
const base = r.defaultGh
|
|
3110
|
+
await $.session.start(START)
|
|
3111
|
+
const ui = await openAndMount($, r)
|
|
3112
|
+
expect(await totalLines(ui)).toBe(1)
|
|
3113
|
+
r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
|
|
3114
|
+
await refreshOnce($, r)
|
|
3115
|
+
expect(await totalLines(ui)).toBe(0)
|
|
3116
|
+
})
|
|
3117
|
+
|
|
3118
|
+
test(`a narrow pane keeps the lower bound in the short form [${surface}]`, async ($, on) => {
|
|
3119
|
+
const r = rig(on, $)
|
|
3120
|
+
r.countTotal = 101
|
|
3121
|
+
await $.session.start(START)
|
|
3122
|
+
const ui = await openAndMount($, r, { bodyColumns: 24 })
|
|
3123
|
+
expect(await textOf(ui, 'open-total')).toBe('≥10 open in total')
|
|
3124
|
+
})
|
|
3125
|
+
|
|
3126
|
+
test(`a truncated maps read words the total as a lower bound only [${surface}]`, async ($, on) => {
|
|
1793
3127
|
const r = rig(on, $)
|
|
1794
|
-
|
|
1795
|
-
r.
|
|
3128
|
+
// more open maps exist (101) than the two the read returned
|
|
3129
|
+
r.countTotal = 101
|
|
1796
3130
|
await $.session.start(START)
|
|
1797
3131
|
const ui = await openAndMount($, r)
|
|
1798
|
-
expect(
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
expect(await textOf(ui, '
|
|
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)
|
|
3132
|
+
expect(await textOf(ui, 'open-total')).toBe('at least 10 open across all maps + whiteboard')
|
|
3133
|
+
// exactly as many maps as were read is not a cut
|
|
3134
|
+
r.countTotal = null
|
|
3135
|
+
await refreshOnce($, r)
|
|
3136
|
+
expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
|
|
1811
3137
|
})
|
|
1812
3138
|
|
|
1813
3139
|
test(`the dashboard heading counts the whiteboard and every assigned trusted ticket is a row [${surface}]`, async ($, on) => {
|
|
@@ -1823,7 +3149,7 @@ for (const surface of SURFACES) {
|
|
|
1823
3149
|
expect(await textOf(ui, 'head-whiteboard')).toBe('Whiteboard · 4 open · 2 assigned')
|
|
1824
3150
|
expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
|
|
1825
3151
|
expect(await textOf(ui, 'holder-302')).toBe('@alice')
|
|
1826
|
-
expect(await
|
|
3152
|
+
expect(await hasKey(ui, 'resume-306')).toBe(true)
|
|
1827
3153
|
expect(await hasKey(ui, 'row-305')).toBe(false)
|
|
1828
3154
|
expect(await textOf(ui, 'unverified')).toBe('2 unverified tickets hidden (quarantined)')
|
|
1829
3155
|
})
|
|
@@ -1886,7 +3212,7 @@ for (const surface of SURFACES) {
|
|
|
1886
3212
|
expect((await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)).not.toContain(`${URL}/5`)
|
|
1887
3213
|
|
|
1888
3214
|
r.whiteboard = WHITEBOARD
|
|
1889
|
-
r.gh = argv => (argv.includes('trailhead:whiteboard') ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv))
|
|
3215
|
+
r.gh = argv => (argv.includes('trailhead:whiteboard') || argv.includes('label=trailhead:whiteboard') ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv))
|
|
1890
3216
|
await r.clock.advance(FLOOR)
|
|
1891
3217
|
await ui.press({ key: 'refresh' })
|
|
1892
3218
|
await settle(r)
|
|
@@ -1918,4 +3244,445 @@ for (const surface of SURFACES) {
|
|
|
1918
3244
|
expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
|
|
1919
3245
|
expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
|
|
1920
3246
|
})
|
|
3247
|
+
test(`the pane keeps its repo when the session cwd moves out of it [${surface}]`, async ($, on) => {
|
|
3248
|
+
const r = rig(on, $)
|
|
3249
|
+
await $.session.start(START)
|
|
3250
|
+
const ui = await openAndMount($, r)
|
|
3251
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3252
|
+
// A shell `cd /tmp`: no repo there, relative reads miss, and a `gh` without its own cwd fails.
|
|
3253
|
+
r.cwd = '/tmp'
|
|
3254
|
+
r.repo = null
|
|
3255
|
+
r.calls.length = 0
|
|
3256
|
+
r.cwds.length = 0
|
|
3257
|
+
await r.clock.advance(FLOOR)
|
|
3258
|
+
await completeTurn($)
|
|
3259
|
+
await settle(r)
|
|
3260
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3261
|
+
expect(await hasKey(ui, 'footer')).toBe(true)
|
|
3262
|
+
expect(await textOf(ui, 'age')).toBe('updated just now')
|
|
3263
|
+
expect(r.calls.length).toBeGreaterThan(0)
|
|
3264
|
+
expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
|
|
3265
|
+
})
|
|
3266
|
+
|
|
3267
|
+
test(`the pane follows a move to another GitHub repo and drops the old one's map [${surface}]`, async ($, on) => {
|
|
3268
|
+
const r = rig(on, $)
|
|
3269
|
+
await $.session.start(START)
|
|
3270
|
+
const ui = await openAndMount($, r)
|
|
3271
|
+
expect(await keysOf(ui, 'row-')).toContain('row-209')
|
|
3272
|
+
r.cwd = ROOT_OTHER
|
|
3273
|
+
r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
|
|
3274
|
+
r.otherActive = '14\n'
|
|
3275
|
+
r.map = OTHER_MAP
|
|
3276
|
+
r.tickets = OTHER_TICKETS
|
|
3277
|
+
r.history = OTHER_HISTORY
|
|
3278
|
+
r.calls.length = 0
|
|
3279
|
+
r.cwds.length = 0
|
|
3280
|
+
await r.clock.advance(FLOOR)
|
|
3281
|
+
await completeTurn($)
|
|
3282
|
+
await settle(r)
|
|
3283
|
+
expect(await textOf(ui, 'map')).toBe('#14 older map')
|
|
3284
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
|
|
3285
|
+
expect(r.calls).toContainEqual(MAPS_CALL)
|
|
3286
|
+
expect(r.cwds.every(cwd => cwd === ROOT_OTHER)).toBe(true)
|
|
3287
|
+
// The toplevel is read where the session now is, not at its project root, which a shell `cd` leaves behind.
|
|
3288
|
+
expect(r.gitCalls.at(-1)).toEqual({ argv: ['git', 'rev-parse', '--show-toplevel'], cwd: ROOT_OTHER })
|
|
3289
|
+
expect(r.reads).toContain(`${ROOT_OTHER}/.trailhead/active-map`)
|
|
3290
|
+
})
|
|
3291
|
+
|
|
3292
|
+
test(`a session started outside any repo says so and runs no gh [${surface}]`, async ($, on) => {
|
|
3293
|
+
const r = rig(on, $)
|
|
3294
|
+
r.cwd = '/tmp'
|
|
3295
|
+
r.repo = null
|
|
3296
|
+
await $.session.start({ ...START, cwd: '/tmp' })
|
|
3297
|
+
const ui = await openAndMount($, r)
|
|
3298
|
+
expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
|
|
3299
|
+
await completeTurn($)
|
|
3300
|
+
await settle(r)
|
|
3301
|
+
expect(r.calls).toEqual([])
|
|
3302
|
+
})
|
|
3303
|
+
|
|
3304
|
+
test(`a remote that names no host and repo is no pin: the same message [${surface}]`, async ($, on) => {
|
|
3305
|
+
const r = rig(on, $)
|
|
3306
|
+
r.repo = { root: ROOT, remote: '/srv/git/r.git', internal: false }
|
|
3307
|
+
await $.session.start(START)
|
|
3308
|
+
const ui = await openAndMount($, r)
|
|
3309
|
+
expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
|
|
3310
|
+
expect(r.calls).toEqual([])
|
|
3311
|
+
})
|
|
3312
|
+
test(`a worktree session reads both files and runs gh under its own toplevel [${surface}]`, async ($, on) => {
|
|
3313
|
+
const r = rig(on, $)
|
|
3314
|
+
// The repo's root is the main tree's; the session works in the linked worktree.
|
|
3315
|
+
r.top = ROOT_TREE
|
|
3316
|
+
r.cwd = ROOT_TREE
|
|
3317
|
+
r.sessionRoot = ROOT_TREE
|
|
3318
|
+
r.active = '14\n'
|
|
3319
|
+
r.treeActive = '198\n'
|
|
3320
|
+
r.work = stateText()
|
|
3321
|
+
await $.session.start({ ...START, cwd: ROOT_TREE })
|
|
3322
|
+
const ui = await openAndMount($, r)
|
|
3323
|
+
expect(r.gitCalls[0]).toEqual({ argv: ['git', 'rev-parse', '--show-toplevel'], cwd: ROOT_TREE })
|
|
3324
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3325
|
+
expect(r.reads).toContain(`${ROOT_TREE}/.trailhead/active-map`)
|
|
3326
|
+
expect(r.reads).toContain(`${ROOT_TREE}/.trailhead/work-state.json`)
|
|
3327
|
+
// Il record del run vive nel main checkout (#296): e l'unica lettura ammessa fuori dal toplevel.
|
|
3328
|
+
expect(r.reads.filter(path => path.startsWith(`${ROOT}/`) && !path.endsWith('/.trailhead/auto-run.json'))).toEqual([])
|
|
3329
|
+
expect(r.cwds.length).toBeGreaterThan(0)
|
|
3330
|
+
expect(r.cwds.every(cwd => cwd === ROOT_TREE)).toBe(true)
|
|
3331
|
+
})
|
|
3332
|
+
|
|
3333
|
+
for (const remote of ['git@ghe.example.com:o/r.git', 'ssh://git@GitHub.com:22/o/r', 'https://user@github.com/o/r.git']) {
|
|
3334
|
+
test(`the remote ${remote} still pins; gh finds the host itself [${surface}]`, async ($, on) => {
|
|
3335
|
+
const r = rig(on, $)
|
|
3336
|
+
r.repo = { root: ROOT, remote, internal: false }
|
|
3337
|
+
r.authHosts = ['ghe.example.com']
|
|
3338
|
+
await $.session.start(START)
|
|
3339
|
+
const ui = await openAndMount($, r)
|
|
3340
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3341
|
+
expect(r.calls).toContainEqual(MAPS_CALL)
|
|
3342
|
+
expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
|
|
3343
|
+
})
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
test(`a refresh in flight when only the band's refresh moves the pin never commits the old repo's tickets [${surface}]`, async ($, on) => {
|
|
3347
|
+
const r = rig(on, $)
|
|
3348
|
+
await $.session.start(START)
|
|
3349
|
+
let release: () => void = () => {}
|
|
3350
|
+
const gate = new Promise<void>(resolve => {
|
|
3351
|
+
release = resolve
|
|
3352
|
+
})
|
|
3353
|
+
// The answer is fixed when the call starts, so the reads begun in /repo keep /repo's tickets.
|
|
3354
|
+
r.gh = async argv => {
|
|
3355
|
+
const answer = r.defaultGh(argv)
|
|
3356
|
+
await gate
|
|
3357
|
+
return answer
|
|
3358
|
+
}
|
|
3359
|
+
r.open()
|
|
3360
|
+
const ui = await mountPane($)
|
|
3361
|
+
await settle(r)
|
|
3362
|
+
// The same map number in the other repo; a turn start refreshes the band, which moves the pin
|
|
3363
|
+
// while the pane has no hook on it.
|
|
3364
|
+
r.cwd = ROOT_OTHER
|
|
3365
|
+
r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
|
|
3366
|
+
r.otherActive = '198\n'
|
|
3367
|
+
r.tickets = OTHER_TICKETS
|
|
3368
|
+
r.history = OTHER_HISTORY
|
|
3369
|
+
await $.turn.start({ text: 'go', turnId: 'race-1' })
|
|
3370
|
+
release()
|
|
3371
|
+
await settle(r)
|
|
3372
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
|
|
3373
|
+
})
|
|
3374
|
+
|
|
3375
|
+
test(`a refresh in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
|
|
3376
|
+
const r = rig(on, $)
|
|
3377
|
+
await $.session.start(START)
|
|
3378
|
+
let release: () => void = () => {}
|
|
3379
|
+
const gate = new Promise<void>(resolve => {
|
|
3380
|
+
release = resolve
|
|
3381
|
+
})
|
|
3382
|
+
// The answer is fixed when the call starts, so the reads begun in /repo keep its tracker state.
|
|
3383
|
+
r.gh = async argv => {
|
|
3384
|
+
const answer = r.defaultGh(argv)
|
|
3385
|
+
await gate
|
|
3386
|
+
return answer
|
|
3387
|
+
}
|
|
3388
|
+
r.open()
|
|
3389
|
+
const ui = await mountPane($)
|
|
3390
|
+
await settle(r)
|
|
3391
|
+
// Another checkout of the SAME repo (same remote), with map 198 active there; the tracker changed after the
|
|
3392
|
+
// read started. A turn start refreshes the band, which moves the pin while the read is still in flight.
|
|
3393
|
+
r.cwd = ROOT_OTHER
|
|
3394
|
+
r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
|
|
3395
|
+
r.otherActive = '198\n'
|
|
3396
|
+
r.tickets = OTHER_TICKETS
|
|
3397
|
+
r.history = OTHER_HISTORY
|
|
3398
|
+
await $.turn.start({ text: 'go', turnId: 'race-same-repo' })
|
|
3399
|
+
release()
|
|
3400
|
+
await settle(r)
|
|
3401
|
+
await r.clock.advance(FLOOR)
|
|
3402
|
+
await settle(r)
|
|
3403
|
+
// The dropped read is repeated: the pane shows the new tracker state, not the old list.
|
|
3404
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
|
|
3405
|
+
})
|
|
3406
|
+
|
|
3407
|
+
test(`a same-repo pin move landing inside the stale check is read again [${surface}]`, async ($, on) => {
|
|
3408
|
+
const r = rig(on, $)
|
|
3409
|
+
await $.session.start(START)
|
|
3410
|
+
let release: () => void = () => {}
|
|
3411
|
+
const gate = new Promise<void>(resolve => {
|
|
3412
|
+
release = resolve
|
|
3413
|
+
})
|
|
3414
|
+
r.gh = async argv => {
|
|
3415
|
+
const answer = r.defaultGh(argv)
|
|
3416
|
+
await gate
|
|
3417
|
+
return answer
|
|
3418
|
+
}
|
|
3419
|
+
r.open()
|
|
3420
|
+
const ui = await mountPane($)
|
|
3421
|
+
await settle(r)
|
|
3422
|
+
// The first marker read once the gate opens is the stale check's last await: the move lands there,
|
|
3423
|
+
// after the check read the old pin and before it decides.
|
|
3424
|
+
r.beforeMarkerRead = async () => {
|
|
3425
|
+
r.cwd = ROOT_OTHER
|
|
3426
|
+
r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
|
|
3427
|
+
r.otherActive = '198\n'
|
|
3428
|
+
r.tickets = OTHER_TICKETS
|
|
3429
|
+
r.history = OTHER_HISTORY
|
|
3430
|
+
await $.turn.start({ text: 'go', turnId: 'race-in-check' })
|
|
3431
|
+
}
|
|
3432
|
+
release()
|
|
3433
|
+
await settle(r)
|
|
3434
|
+
await r.clock.advance(FLOOR)
|
|
3435
|
+
await settle(r)
|
|
3436
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
|
|
3437
|
+
})
|
|
3438
|
+
|
|
3439
|
+
test(`a dashboard read in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
|
|
3440
|
+
const r = rig(on, $)
|
|
3441
|
+
r.maps = [MAPS[0]]
|
|
3442
|
+
await $.session.start(START)
|
|
3443
|
+
const ui = await openAndMount($, r)
|
|
3444
|
+
let release: () => void = () => {}
|
|
3445
|
+
const gate = new Promise<void>(resolve => {
|
|
3446
|
+
release = resolve
|
|
3447
|
+
})
|
|
3448
|
+
r.gh = async argv => {
|
|
3449
|
+
const answer = r.defaultGh(argv)
|
|
3450
|
+
await gate
|
|
3451
|
+
return answer
|
|
3452
|
+
}
|
|
3453
|
+
// The dashboard read starts under /repo and waits on the gate.
|
|
3454
|
+
await ui.press({ key: 'open-whiteboard' })
|
|
3455
|
+
await settle(r)
|
|
3456
|
+
r.cwd = ROOT_OTHER
|
|
3457
|
+
r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
|
|
3458
|
+
r.otherActive = '198\n'
|
|
3459
|
+
r.whiteboard = [issue(320, 'New loose one', [...WB, 'trailhead:bug'])]
|
|
3460
|
+
await $.turn.start({ text: 'go', turnId: 'race-same-repo-dash' })
|
|
3461
|
+
release()
|
|
3462
|
+
await settle(r)
|
|
3463
|
+
await r.clock.advance(FLOOR)
|
|
3464
|
+
await settle(r)
|
|
3465
|
+
expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
|
|
3466
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-320'])
|
|
3467
|
+
})
|
|
3468
|
+
|
|
3469
|
+
test(`the pin survives /clear after the session cwd moved out of the repo [${surface}]`, async ($, on) => {
|
|
3470
|
+
const r = rig(on, $)
|
|
3471
|
+
r.work = stateText()
|
|
3472
|
+
await $.session.start(START)
|
|
3473
|
+
const ui = await openAndMount($, r)
|
|
3474
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3475
|
+
r.cwd = '/tmp'
|
|
3476
|
+
r.repo = null
|
|
3477
|
+
r.calls.length = 0
|
|
3478
|
+
r.cwds.length = 0
|
|
3479
|
+
await $.command.run({ command: 'clear', args: '' } as never)
|
|
3480
|
+
await settle(r)
|
|
3481
|
+
expect(await hasKey(ui, 'no-repo')).toBe(false)
|
|
3482
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3483
|
+
expect(r.calls.length).toBeGreaterThan(0)
|
|
3484
|
+
expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
|
|
3485
|
+
})
|
|
3486
|
+
test(`a session started in a host gh does not serve says so and runs no gh read [${surface}]`, async ($, on) => {
|
|
3487
|
+
const r = rig(on, $)
|
|
3488
|
+
r.repo = { root: ROOT, remote: `git@gitlab-${surface}.example.com:o/r.git`, internal: false }
|
|
3489
|
+
await $.session.start(START)
|
|
3490
|
+
const ui = await openAndMount($, r)
|
|
3491
|
+
await completeTurn($)
|
|
3492
|
+
await settle(r)
|
|
3493
|
+
expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
|
|
3494
|
+
expect(r.calls).toEqual([])
|
|
3495
|
+
expect(r.authCalls).toEqual([`gitlab-${surface}.example.com`])
|
|
3496
|
+
})
|
|
3497
|
+
|
|
3498
|
+
test(`a cd into a GitLab repo keeps the pin and the map [${surface}]`, async ($, on) => {
|
|
3499
|
+
const r = rig(on, $)
|
|
3500
|
+
await $.session.start(START)
|
|
3501
|
+
const ui = await openAndMount($, r)
|
|
3502
|
+
r.cwd = ROOT_OTHER
|
|
3503
|
+
r.repo = { root: ROOT_OTHER, remote: 'https://lab.example.com/o/lab.git', internal: false }
|
|
3504
|
+
r.calls.length = 0
|
|
3505
|
+
r.cwds.length = 0
|
|
3506
|
+
await r.clock.advance(FLOOR)
|
|
3507
|
+
await completeTurn($)
|
|
3508
|
+
await settle(r)
|
|
3509
|
+
expect(r.authCalls).toEqual(['lab.example.com'])
|
|
3510
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3511
|
+
expect(await hasKey(ui, 'no-repo')).toBe(false)
|
|
3512
|
+
expect(r.calls.length).toBeGreaterThan(0)
|
|
3513
|
+
expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
|
|
3514
|
+
})
|
|
3515
|
+
|
|
3516
|
+
test(`the host check runs once per host for the session [${surface}]`, async ($, on) => {
|
|
3517
|
+
const r = rig(on, $)
|
|
3518
|
+
r.repo = { root: ROOT, remote: `git@once-${surface}.example.com:o/r.git`, internal: false }
|
|
3519
|
+
r.authHosts = [`once-${surface}.example.com`]
|
|
3520
|
+
await $.session.start(START)
|
|
3521
|
+
const ui = await openAndMount($, r)
|
|
3522
|
+
for (let i = 0; i < 3; i += 1) {
|
|
3523
|
+
await r.clock.advance(FLOOR)
|
|
3524
|
+
await completeTurn($)
|
|
3525
|
+
await settle(r)
|
|
3526
|
+
}
|
|
3527
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3528
|
+
expect(r.authCalls).toEqual([`once-${surface}.example.com`])
|
|
3529
|
+
})
|
|
3530
|
+
test(`a failed host check is retried after a while, so a later login pins [${surface}]`, async ($, on) => {
|
|
3531
|
+
const r = rig(on, $)
|
|
3532
|
+
const host = `late-${surface}.example.com`
|
|
3533
|
+
r.repo = { root: ROOT, remote: `git@${host}:o/r.git`, internal: false }
|
|
3534
|
+
await $.session.start(START)
|
|
3535
|
+
const ui = await openAndMount($, r)
|
|
3536
|
+
expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
|
|
3537
|
+
// A turn soon after does not ask again; one after the retry window does, and the login now holds.
|
|
3538
|
+
await r.clock.advance(FLOOR)
|
|
3539
|
+
await completeTurn($)
|
|
3540
|
+
await settle(r)
|
|
3541
|
+
expect(r.authCalls).toEqual([host])
|
|
3542
|
+
r.authHosts = [host]
|
|
3543
|
+
await r.clock.advance(60_000)
|
|
3544
|
+
await completeTurn($)
|
|
3545
|
+
await settle(r)
|
|
3546
|
+
expect(r.authCalls).toEqual([host, host])
|
|
3547
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3548
|
+
})
|
|
3549
|
+
test(`a pin moved by the band alone never leaves the old repo's rows drawn [${surface}]`, async ($, on) => {
|
|
3550
|
+
const r = rig(on, $)
|
|
3551
|
+
await $.session.start(START)
|
|
3552
|
+
const ui = await openAndMount($, r)
|
|
3553
|
+
expect(await keysOf(ui, 'row-')).toContain('row-209')
|
|
3554
|
+
r.cwd = ROOT_OTHER
|
|
3555
|
+
r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
|
|
3556
|
+
r.otherActive = '14\n'
|
|
3557
|
+
r.map = OTHER_MAP
|
|
3558
|
+
r.tickets = OTHER_TICKETS
|
|
3559
|
+
r.history = OTHER_HISTORY
|
|
3560
|
+
// Only the band hooks a turn start: it moves the pin, no read of the pane is in flight.
|
|
3561
|
+
await $.turn.start({ text: 'go', turnId: 'band-1' })
|
|
3562
|
+
expect(await keysOf(ui, 'row-')).not.toContain('row-209')
|
|
3563
|
+
await settle(r)
|
|
3564
|
+
expect(await textOf(ui, 'map')).toBe('#14 older map')
|
|
3565
|
+
expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
|
|
3566
|
+
})
|
|
3567
|
+
|
|
3568
|
+
// The panel follows the whiteboard ticket this session takes (#306).
|
|
3569
|
+
const WB_STATE = { map: null, ticket: 301, title: 'Fix the README badge', step: 'discuss' }
|
|
3570
|
+
const BOARD_KEY = 'dashboard-title'
|
|
3571
|
+
const CREATE_WB = 'gh issue create -t t --label trailhead:whiteboard'
|
|
3572
|
+
|
|
3573
|
+
test(`an own whiteboard ticket in play switches the panel to the whiteboard [${surface}]`, async ($, on) => {
|
|
3574
|
+
const r = rig(on, $)
|
|
3575
|
+
r.work = stateText(WB_STATE)
|
|
3576
|
+
await $.session.start(START)
|
|
3577
|
+
const ui = await openAndMount($, r)
|
|
3578
|
+
expect(await hasKey(ui, BOARD_KEY)).toBe(true)
|
|
3579
|
+
expect(await hasKey(ui, 'map')).toBe(false)
|
|
3580
|
+
})
|
|
3581
|
+
|
|
3582
|
+
test(`going back to the map by hand is not undone by the next turn [${surface}]`, async ($, on) => {
|
|
3583
|
+
const r = rig(on, $)
|
|
3584
|
+
r.work = stateText(WB_STATE)
|
|
3585
|
+
await $.session.start(START)
|
|
3586
|
+
const ui = await openAndMount($, r)
|
|
3587
|
+
await ui.press({ key: 'to-map' })
|
|
3588
|
+
await settle(r)
|
|
3589
|
+
expect(await hasKey(ui, BOARD_KEY)).toBe(false)
|
|
3590
|
+
await r.clock.advance(FLOOR)
|
|
3591
|
+
await completeTurn($)
|
|
3592
|
+
await settle(r)
|
|
3593
|
+
expect(await hasKey(ui, BOARD_KEY)).toBe(false)
|
|
3594
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3595
|
+
})
|
|
3596
|
+
|
|
3597
|
+
test(`the followed ticket is not forced again after /clear [${surface}]`, async ($, on) => {
|
|
3598
|
+
const r = rig(on, $)
|
|
3599
|
+
r.work = stateText(WB_STATE)
|
|
3600
|
+
await $.session.start(START)
|
|
3601
|
+
const ui = await openAndMount($, r)
|
|
3602
|
+
await ui.press({ key: 'to-map' })
|
|
3603
|
+
await settle(r)
|
|
3604
|
+
await $.command.run({ command: 'clear', args: '' } as never)
|
|
3605
|
+
r.work = stateText({ ...WB_STATE, session: r.session })
|
|
3606
|
+
await ui.redraw()
|
|
3607
|
+
await settle(r)
|
|
3608
|
+
await r.clock.advance(FLOOR)
|
|
3609
|
+
await completeTurn($)
|
|
3610
|
+
await settle(r)
|
|
3611
|
+
expect(await hasKey(ui, BOARD_KEY)).toBe(false)
|
|
3612
|
+
})
|
|
3613
|
+
|
|
3614
|
+
test(`a state that appears mid-session is followed after the helper call [${surface}]`, async ($, on) => {
|
|
3615
|
+
const r = rig(on, $)
|
|
3616
|
+
await $.session.start(START)
|
|
3617
|
+
const ui = await openAndMount($, r)
|
|
3618
|
+
expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
|
|
3619
|
+
r.work = stateText(WB_STATE)
|
|
3620
|
+
await $.tool.call({ tool: 'Bash', command: 'node /x/trailhead-work-state.js set --ticket 301' } as never)
|
|
3621
|
+
await settle(r)
|
|
3622
|
+
expect(await hasKey(ui, BOARD_KEY)).toBe(true)
|
|
3623
|
+
})
|
|
3624
|
+
|
|
3625
|
+
test(`a state that appears before a turn ends is followed [${surface}]`, async ($, on) => {
|
|
3626
|
+
const r = rig(on, $)
|
|
3627
|
+
await $.session.start(START)
|
|
3628
|
+
const ui = await openAndMount($, r)
|
|
3629
|
+
r.work = stateText(WB_STATE)
|
|
3630
|
+
await r.clock.advance(FLOOR)
|
|
3631
|
+
await completeTurn($)
|
|
3632
|
+
await settle(r)
|
|
3633
|
+
expect(await hasKey(ui, BOARD_KEY)).toBe(true)
|
|
3634
|
+
})
|
|
3635
|
+
|
|
3636
|
+
test(`a map ticket in play does not switch the panel [${surface}]`, async ($, on) => {
|
|
3637
|
+
const r = rig(on, $)
|
|
3638
|
+
r.work = stateText()
|
|
3639
|
+
await $.session.start(START)
|
|
3640
|
+
const ui = await openAndMount($, r)
|
|
3641
|
+
expect(await hasKey(ui, BOARD_KEY)).toBe(false)
|
|
3642
|
+
})
|
|
3643
|
+
|
|
3644
|
+
test(`a Resolve leaves the panel on the whiteboard [${surface}]`, async ($, on) => {
|
|
3645
|
+
const r = rig(on, $)
|
|
3646
|
+
r.work = stateText(WB_STATE)
|
|
3647
|
+
await $.session.start(START)
|
|
3648
|
+
const ui = await openAndMount($, r)
|
|
3649
|
+
r.work = null
|
|
3650
|
+
await r.clock.advance(FLOOR)
|
|
3651
|
+
await completeTurn($)
|
|
3652
|
+
await settle(r)
|
|
3653
|
+
expect(await hasKey(ui, BOARD_KEY)).toBe(true)
|
|
3654
|
+
})
|
|
3655
|
+
|
|
3656
|
+
test(`a whiteboard ticket created while a map is shown raises one toast and no switch [${surface}]`, async ($, on) => {
|
|
3657
|
+
const r = rig(on, $)
|
|
3658
|
+
await $.session.start(START)
|
|
3659
|
+
const ui = await openAndMount($, r)
|
|
3660
|
+
r.stdout = `${URL}/310\n`
|
|
3661
|
+
await $.tool.call({ tool: 'Bash', command: CREATE_WB } as never)
|
|
3662
|
+
await settle(r)
|
|
3663
|
+
expect(r.toasts).toEqual(['new whiteboard ticket #310: open Whiteboard'])
|
|
3664
|
+
expect(await hasKey(ui, BOARD_KEY)).toBe(false)
|
|
3665
|
+
})
|
|
3666
|
+
|
|
3667
|
+
test(`a whiteboard ticket created on the whiteboard view raises no toast [${surface}]`, async ($, on) => {
|
|
3668
|
+
const r = rig(on, $)
|
|
3669
|
+
await $.session.start(START)
|
|
3670
|
+
const ui = await openAndMount($, r)
|
|
3671
|
+
await ui.press({ key: 'open-whiteboard' })
|
|
3672
|
+
await settle(r)
|
|
3673
|
+
r.stdout = `${URL}/310\n`
|
|
3674
|
+
await $.tool.call({ tool: 'Bash', command: CREATE_WB } as never)
|
|
3675
|
+
await settle(r)
|
|
3676
|
+
expect(r.toasts).toEqual([])
|
|
3677
|
+
})
|
|
3678
|
+
|
|
3679
|
+
test(`a ticket created without the whiteboard label raises no toast [${surface}]`, async ($, on) => {
|
|
3680
|
+
const r = rig(on, $)
|
|
3681
|
+
await $.session.start(START)
|
|
3682
|
+
await openAndMount($, r)
|
|
3683
|
+
r.stdout = `${URL}/310\n`
|
|
3684
|
+
await $.tool.call({ tool: 'Bash', command: 'gh issue create -t t --label trailhead:task' } as never)
|
|
3685
|
+
await settle(r)
|
|
3686
|
+
expect(r.toasts).toEqual([])
|
|
3687
|
+
})
|
|
1921
3688
|
}
|