@marcomigozzi/trailhead 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
@@ -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',
@@ -61,9 +72,57 @@ const MAPS = [
61
72
  { number: 198, title: 'trailhead mod', url: `${URL}/198` },
62
73
  { number: 14, title: 'older map', url: `${URL}/14` },
63
74
  ]
75
+ // The maps read: one GraphQL call that carries each map's sub-issue counts and the whiteboard's.
76
+ const MAPS_QUERY =
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}}}'
78
+ const MAPS_CALL = ['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-f', `query=${MAPS_QUERY}`]
79
+ const MAPS_LIST_CALL = ['gh', 'issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', '100', '--json', 'number,title,url']
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
112
+ const isMapsList = (argv: readonly string[]) => argv[2] === 'list' && argv.includes('trailhead:map')
64
113
  const OTHER_TICKETS = [issue(140, 'Old frontier ticket', ['trailhead:build']), issue(141, 'Old held ticket', ['trailhead:task'], ['carol'])]
65
114
  const OTHER_HISTORY = OTHER_TICKETS.map(t => hrow(t.number, t.labels.map(l => l.name), null))
66
115
 
116
+ // The whiteboard (map-less tickets) and the pinned dashboard issue, as the dashboard view reads them.
117
+ const WB = ['trailhead:ticket', 'trailhead:whiteboard']
118
+ const WHITEBOARD = [
119
+ issue(301, 'Fix the README badge', [...WB, 'trailhead:bug']),
120
+ issue(302, 'Bump the CI node', [...WB, 'trailhead:task'], ['alice']),
121
+ issue(303, 'Waits on upstream', [...WB, 'trailhead:task', 'trailhead:blocked']),
122
+ issue(304, 'Quarantined loose one', [...WB, 'trailhead:task', 'trailhead:unverified']),
123
+ ]
124
+ const DASHBOARD = [{ number: 5, url: `${URL}/5` }]
125
+
67
126
  const stateText = (over: Record<string, unknown> = {}): string =>
68
127
  JSON.stringify({
69
128
  map: 198,
@@ -78,7 +137,7 @@ const stateText = (over: Record<string, unknown> = {}): string =>
78
137
  })
79
138
 
80
139
  // The plugin's atoms, which the engine resets on /clear.
81
- const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'pendingWork', 'paneAutoOpened']
140
+ const ATOM_KEYS = ['workState', 'isOrphaned', 'promptSnapshot', 'mapPane', 'activeMap', 'viewedMap', 'isDashboard', 'dashboardPane', 'pendingWork', 'pendingPhase', 'paneAutoOpened', 'ownTicket', 'repoPin']
82
141
 
83
142
  const PANE_PROPS = {
84
143
  title: 'Map',
@@ -104,17 +163,60 @@ const rig = (on: On, engine?: any) => {
104
163
  map: MAP as unknown,
105
164
  // The open maps list, and the second map's own answers (view, open list, history).
106
165
  maps: MAPS as unknown[],
166
+ // Sub-issue totals per map number, as the GraphQL read reports them (a map without one has no summary).
167
+ mapSummary: { 198: { total: 23, completed: 16 }, 14: { total: 5, completed: 2 } } as Record<number, unknown>,
168
+ // The whiteboard's open and total tickets as the same call reports them (null: the fields are absent).
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,
107
175
  otherMap: OTHER_MAP as unknown,
108
176
  otherTickets: OTHER_TICKETS as unknown[],
109
177
  otherHistory: OTHER_HISTORY as unknown[],
178
+ whiteboard: WHITEBOARD as unknown[],
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.
181
+ uat: [] as unknown[] | null,
182
+ uatErrors: false,
183
+ uatBroken: false,
184
+ dashboard: DASHBOARD as unknown[],
110
185
  gh: null as Gh | null,
111
186
  // What `gh` answers when no test replaced it; a test can wrap it.
112
187
  defaultGh: null as unknown as Gh,
113
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
+ // The worktree checkout's marker, what `git rev-parse --show-toplevel` answers there (null: it fails), and the
196
+ // session's project root; every path read and every `git` run is recorded.
197
+ treeActive: null as string | null,
198
+ top: null as string | null,
199
+ sessionRoot: '/repo',
200
+ reads: [] as string[],
201
+ // The hosts `gh auth status --hostname` accepts besides github.com, and every host it was asked about.
202
+ authHosts: [] as string[],
203
+ authCalls: [] as string[],
204
+ gitCalls: [] as { argv: readonly string[]; cwd: string | undefined }[],
205
+ // The cwd every `gh` run carried (undefined: none given).
206
+ cwds: [] as (string | undefined)[],
114
207
  calls: [] as (readonly string[])[],
115
208
  panes: [] as { id: string; title: string; isShown: boolean; isFocused: boolean; isPlaced: boolean }[],
116
209
  isPlaced: true,
117
210
  logs: [] as string[],
211
+ toasts: [] as string[],
212
+ // What a Bash call prints on stdout (the created issue's URL for a `gh issue create`).
213
+ stdout: 'ok' as string,
214
+ // `session.repo` answers a denial (the checkout cannot be read).
215
+ repoFails: false,
216
+ // What `git remote get-url origin` answers (null: no origin), for a `session.repo` that answers null (#299).
217
+ gitRemote: null as string | null,
218
+ // Checkout roots that no longer exist: git answers 128 there (a removed worktree or clone).
219
+ gone: [] as string[],
118
220
  opens: [] as { id: string; title?: string }[],
119
221
  // `command.run` beneath the plugin: every run is recorded; `gate` holds a work run, `refuse` rejects it.
120
222
  // Every file write beneath the plugin: the pane must never write the active-map marker.
@@ -128,7 +230,15 @@ const rig = (on: On, engine?: any) => {
128
230
  // The clear answered by a `{ deny }` hook: it resolves the call and the session stays.
129
231
  denyClear: false,
130
232
  // Runs between the old session ending and the new one starting.
131
- duringClear: null as (() => void) | null,
233
+ duringClear: null as (() => void | Promise<void>) | null,
234
+ // Runs once, in the next `session.id` read (a dispatch's own read before its clear).
235
+ beforeSessionId: null as (() => void | Promise<void>) | null,
236
+ // Every value written to the pendingPhase atom, in order.
237
+ phases: [] as unknown[],
238
+ // Atom values the engine reports whatever was written (a state a hot reload left); a write clears the key.
239
+ forced: {} as Record<string, unknown>,
240
+ // Runs once, before the next pendingPhase write lands (the row still reads what it read before).
241
+ beforePhaseWrite: null as (() => void | Promise<void>) | null,
132
242
  session: SESSION,
133
243
  clock: mock.clock(on, { now: T0 }),
134
244
  // The person opening the pane: the engine's own record, the plugin is not told.
@@ -137,13 +247,57 @@ const rig = (on: On, engine?: any) => {
137
247
  },
138
248
  }
139
249
  const defaultGh: Gh = argv => {
140
- if (argv[2] === 'list' && argv.includes('trailhead:map')) return ok(JSON.stringify(r.maps))
250
+ if (isTicketsRead(argv)) {
251
+ const label = (argv.find(a => a.startsWith('label=')) ?? '').slice('label='.length)
252
+ const cursor = argv.find(a => a.startsWith('after='))
253
+ const all = (label === 'trailhead:whiteboard' ? r.whiteboard : label === 'trailhead:map-14' ? r.otherTickets : r.tickets) as unknown[]
254
+ const from = cursor === undefined ? 0 : Number(cursor.slice('after=c'.length))
255
+ const to = from + PAGE
256
+ const pageInfo = { hasNextPage: to < all.length, endCursor: `c${to}` }
257
+ return ok(JSON.stringify({ data: { repository: { issues: { nodes: all.slice(from, to).map(flatNode), pageInfo } } } }))
258
+ }
259
+ if (isUatRead(argv)) {
260
+ if (r.uat === null) return { exitCode: 1, stdout: '', stderr: 'boom' }
261
+ if (r.uatBroken) return ok('not json')
262
+ if (r.uatErrors) return ok(JSON.stringify({ data: { repository: { issues: { nodes: [] } } }, errors: [{ message: 'x' }] }))
263
+ const cursor = argv.find(a => a.startsWith('after='))
264
+ const from = cursor === undefined ? 0 : Number(cursor.slice('after=c'.length))
265
+ const to = from + PAGE
266
+ const pageInfo = { hasNextPage: to < r.uat.length, endCursor: `c${to}` }
267
+ const nodes = (r.uat as any[]).slice(from, to).map(u => ({ ...u, labels: { nodes: u.labels ?? [] } }))
268
+ return ok(JSON.stringify({ data: { repository: { issues: { nodes, pageInfo } } } }))
269
+ }
270
+ if (isCountRead(argv)) {
271
+ if (r.countMode === 'fail') return { exitCode: 1, stdout: '', stderr: 'boom' }
272
+ if (r.countMode === 'junk') return ok('not json')
273
+ const query = (argv.find(a => a.startsWith('query=')) ?? '').slice('query='.length)
274
+ 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 }]))
275
+ return ok(JSON.stringify({ data: { repository: repo }, ...(r.countMode === 'errors' ? { errors: [{ message: 'x' }] } : {}) }))
276
+ }
277
+ if (argv.includes('trailhead:whiteboard')) return ok(JSON.stringify(r.whiteboard))
278
+ if (argv.includes('trailhead:dashboard')) return ok(JSON.stringify(r.dashboard))
279
+ if (isGraphql(argv)) {
280
+ const nodes = (r.maps as { number: number }[]).map(m => ({ ...m, subIssuesSummary: r.mapSummary[m.number] }))
281
+ const wb = r.whiteboardCounts
282
+ const counts = wb === null ? {} : { whiteboardOpen: { totalCount: wb.open }, whiteboardAll: { totalCount: wb.total } }
283
+ const totalCount = r.countTotal ?? nodes.length
284
+ return ok(JSON.stringify({ data: { repository: { issues: { nodes, totalCount }, ...counts } } }))
285
+ }
286
+ if (isMapsList(argv)) return ok(JSON.stringify(r.maps))
141
287
  const isOther = argv.includes('14') || argv.includes('trailhead:map-14')
142
288
  if (argv[2] === 'view') return ok(JSON.stringify(isOther ? r.otherMap : r.map))
143
289
  return ok(JSON.stringify(argv.includes('all') ? (isOther ? r.otherHistory : r.history) : isOther ? r.otherTickets : r.tickets))
144
290
  }
145
291
  on('fs.read', (_$, e) => {
146
- const file = e.path.endsWith('/.trailhead/active-map') ? r.active : e.path.endsWith('/.trailhead/work-state.json') ? r.work : undefined
292
+ // A relative path reaches the hook already resolved against the host's directory, not the repo's: put
293
+ // it under the session's working directory, which is what the engine resolves it against. A path under
294
+ // one of the repos is absolute (a pinned read) and stays.
295
+ const isAbsolute = [ROOT, ROOT_OTHER, ROOT_TREE].some(root => e.path.startsWith(`${root}/`))
296
+ const path = isAbsolute ? e.path : `${r.cwd}${e.path.slice(e.path.indexOf('/.trailhead/'))}`
297
+ r.reads.push(path)
298
+ const root = path.startsWith(`${ROOT_TREE}/`) ? ROOT_TREE : path.startsWith(`${ROOT_OTHER}/`) ? ROOT_OTHER : path.startsWith(`${ROOT}/`) ? ROOT : null
299
+ const marker = root === ROOT_TREE ? r.treeActive : root === ROOT_OTHER ? r.otherActive : r.active
300
+ const file = path.endsWith('/.trailhead/active-map') ? (root === null ? null : marker) : path.endsWith('/.trailhead/work-state.json') ? (root === null ? null : r.work) : undefined
147
301
  if (file === undefined) return { deny: `unexpected read of ${e.path}` }
148
302
  if (file === null) return { deny: `ENOENT: no such file or directory, open '${e.path}'` }
149
303
  if (file instanceof Error) return { deny: file.message }
@@ -160,16 +314,24 @@ const rig = (on: On, engine?: any) => {
160
314
  const emptied = new Set<string>()
161
315
  let clears = 0
162
316
  on('state.get', { plugin: 'trailhead' }, async (_$, e, next) => {
317
+ if (e.key in r.forced) return { value: { value: r.forced[e.key], version: 1 } }
163
318
  const read = await next(e)
164
319
  return emptied.has(e.key) ? { value: { value: undefined, version: read.value?.version ?? 0 } } : read
165
320
  })
166
- on('state.set', { plugin: 'trailhead' }, (_$, e, next) => {
321
+ on('state.set', { plugin: 'trailhead' }, async (_$, e, next) => {
322
+ if (e.key === 'pendingPhase') {
323
+ const hold = r.beforePhaseWrite
324
+ r.beforePhaseWrite = null
325
+ await hold?.()
326
+ r.phases.push(e.value)
327
+ }
328
+ delete r.forced[e.key]
167
329
  emptied.delete(e.key)
168
330
  return next(e)
169
331
  })
170
332
  const emulateClear = async () => {
171
333
  await engine.session.end({ reason: 'clear', sessionId: r.session, resume: {} })
172
- r.duringClear?.()
334
+ await r.duringClear?.()
173
335
  ATOM_KEYS.forEach(key => emptied.add(key))
174
336
  r.session = `${NEXT_SESSION}-${(clears += 1)}`
175
337
  await engine.classic.SessionStart({ source: 'clear' })
@@ -188,14 +350,52 @@ const rig = (on: On, engine?: any) => {
188
350
  if (r.refuse !== null) throw new Error(r.refuse)
189
351
  return { text: '' }
190
352
  })
191
- on('session.id', () => ({ value: r.session }))
353
+ on('session.id', async () => {
354
+ const hold = r.beforeSessionId
355
+ r.beforeSessionId = null
356
+ await hold?.()
357
+ return { value: r.session }
358
+ })
192
359
  on('ui.log', (_$, e) => {
193
360
  r.logs.push(e.text)
194
361
  return { value: undefined }
195
362
  })
196
363
  on('ui.status', () => ({ value: undefined }))
364
+ on('ui.toast', (_$, e) => {
365
+ r.toasts.push(e.text)
366
+ return { value: undefined }
367
+ })
368
+ on('session.repo', () => (r.repoFails ? { deny: 'repo read failed' } : { value: r.repo }))
369
+ on('session.root', () => ({ value: r.sessionRoot }))
370
+ on('session.cwd', () => ({ value: r.cwd }))
197
371
  on('process.run', async (_$, e) => {
372
+ // The pin's own `git rev-parse --show-toplevel`: answered apart, never counted as a `gh` call.
373
+ if (e.argv[0] === 'git') {
374
+ r.gitCalls.push({ argv: [...e.argv], cwd: e.init?.cwd })
375
+ // Answered per directory, like git: the worktree gives `top`, the other checkouts their own root.
376
+ const where = e.init?.cwd
377
+ if (e.argv[1] === 'remote') {
378
+ const remote = r.gitRemote === null ? { exitCode: 2, stdout: '', stderr: '' } : { exitCode: 0, stdout: `${r.gitRemote}\n`, stderr: '' }
379
+ return { value: { ...remote, isStdoutTruncated: false, isStderrTruncated: false } }
380
+ }
381
+ const top = where !== undefined && r.gone.includes(where) ? null : where === ROOT_TREE ? r.top : where === ROOT || where === ROOT_OTHER ? where : null
382
+ const out = top === null ? { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' } : { exitCode: 0, stdout: `${top}\n`, stderr: '' }
383
+ return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
384
+ }
385
+ // The pin's host check: answered apart, never counted as a read of the pane.
386
+ if (e.argv[1] === 'auth') {
387
+ const host = e.argv[4] ?? ''
388
+ r.authCalls.push(host)
389
+ const out = r.authHosts.includes(host) ? { exitCode: 0, stdout: '', stderr: '' } : { exitCode: 1, stdout: '', stderr: `You are not logged into any accounts on ${host}` }
390
+ return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
391
+ }
198
392
  r.calls.push([...e.argv])
393
+ r.cwds.push(e.init?.cwd)
394
+ // `gh` needs a repo around it: without its own cwd it uses the session's, wherever a `cd` left it.
395
+ const where = e.init?.cwd ?? r.cwd
396
+ if (where !== ROOT && where !== ROOT_OTHER && where !== ROOT_TREE) {
397
+ return { value: { exitCode: 1, stdout: '', stderr: 'failed to run git: fatal: not a git repository', isStdoutTruncated: false, isStderrTruncated: false } }
398
+ }
199
399
  if (r.deny !== null) return { deny: r.deny }
200
400
  const out = await (r.gh ?? defaultGh)(e.argv)
201
401
  return { value: { ...out, isStdoutTruncated: false, isStderrTruncated: false } }
@@ -213,7 +413,7 @@ const rig = (on: On, engine?: any) => {
213
413
  return { value: undefined }
214
414
  })
215
415
  on('ui.panes', () => ({ value: r.panes }))
216
- on('tool.call', { tool: 'Bash' }, () => ({ result: 'ok' }) as never)
416
+ on('tool.call', { tool: 'Bash' }, () => ({ result: { stdout: r.stdout, stderr: '' } }) as never)
217
417
  on('session.start', (_$, e) => ({ cwd: e.cwd }))
218
418
  on('classic.SessionStart', () => ({}))
219
419
  on('turn.start', (_$, e) => ({ turnId: e.turnId }))
@@ -243,11 +443,11 @@ const settle = async (r: Rig) => {
243
443
  for (let i = 0; i < 10; i += 1) await r.clock.settle()
244
444
  }
245
445
  // The open-list reads; the history read rides along with each and is not counted.
246
- const fetches = (r: Rig) => r.calls.filter(argv => argv[2] === 'list' && argv.includes('open') && !argv.includes('trailhead:map')).length
446
+ const fetches = (r: Rig) => r.calls.filter(argv => isTicketsRead(argv) && !argv.some(a => a.startsWith('after='))).length
247
447
  // A gh that answers as the default does, except for the history read.
248
448
  const withHistory = (r: Rig, answer: GhOut): Gh => argv => {
249
449
  if (argv.includes('all')) return answer
250
- if (argv.includes('trailhead:map')) return ok(JSON.stringify(r.maps))
450
+ if (isGraphql(argv) || isCountRead(argv) || isMapsList(argv)) return r.defaultGh(argv)
251
451
  return argv[2] === 'view' ? ok(JSON.stringify(r.map)) : ok(JSON.stringify(r.tickets))
252
452
  }
253
453
  const hasKey = async (ui: any, key: string): Promise<boolean> => (await ui.find({ key })) !== undefined
@@ -273,9 +473,11 @@ for (const surface of SURFACES) {
273
473
 
274
474
  expect(r.calls).toEqual([
275
475
  ['gh', 'issue', 'view', '198', '--json', 'title,body,url'],
276
- ['gh', 'issue', 'list', '--label', 'trailhead:map-198', '--state', 'open', '--limit', '200', '--json', 'number,title,labels,assignees,url'],
476
+ ticketsCall('trailhead:map-198'),
277
477
  ['gh', 'issue', 'list', '--label', 'trailhead:map-198', '--state', 'all', '--limit', '5000', '--json', 'number,labels,createdAt,closedAt'],
278
- ['gh', 'issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', '100', '--json', 'number,title,url'],
478
+ MAPS_CALL,
479
+ uatCall(),
480
+ countCall(14, 198),
279
481
  ])
280
482
  expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
281
483
  expect((await ui.find({ type: 'Link' }))?.props).toMatchObject({ href: `${URL}/198` })
@@ -297,8 +499,10 @@ for (const surface of SURFACES) {
297
499
  expect(links).toContain(`${URL}/209`)
298
500
 
299
501
  // holders on every assigned row; the session's own ticket is marked and in the accent
300
- expect(await textOf(ui, 'holder-208')).toBe('@ToRvaLDz')
301
- expect((await ui.find({ key: 'holder-208' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
502
+ expect(await textOf(ui, 'self-208')).toBe('this session')
503
+ expect((await ui.find({ key: 'self-208' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
504
+ expect((await ui.find({ key: 'self-208' }))?.children[0].props).not.toHaveProperty('bold')
505
+ expect(await ui.find({ key: 'holder-208' })).toBeUndefined()
302
506
  expect(await textOf(ui, 'mark-208')).toBe('▸ ')
303
507
  expect((await ui.find({ key: 'mark-208' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
304
508
  expect((await ui.find({ key: 'ticket-208' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
@@ -319,6 +523,229 @@ for (const surface of SURFACES) {
319
523
  expect(await textOf(ui, 'age')).toBe('updated just now')
320
524
  })
321
525
 
526
+ test(`a UAT ticket sits under UAT between In progress and Paused, with its parent line and its work button [${surface}]`, async ($, on) => {
527
+ const r = rig(on, $)
528
+ r.work = stateText()
529
+ r.tickets = [
530
+ ...TICKETS,
531
+ issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat']),
532
+ issue(241, 'Device UAT seed', ['trailhead:seed', 'trailhead:blocked', 'trailhead:uat']),
533
+ issue(242, 'Held UAT', ['trailhead:task', 'trailhead:uat'], ['alice']),
534
+ issue(243, 'Paused UAT', ['trailhead:task', 'trailhead:uat', 'trailhead:paused']),
535
+ ]
536
+ r.uat = [
537
+ uatRow(240, `Run it\n\nUAT of: [Burn-down view of the map](${URL}/209)\n`),
538
+ uatRow(241, `UAT of: #212`),
539
+ uatRow(243, `UAT of: [Paused parent](${URL}/150)`),
540
+ ]
541
+ await $.session.start(START)
542
+ const ui = await openAndMount($, r)
543
+
544
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 3')
545
+ expect((await ui.find({ key: 'head-uat' }))?.children[0]).toMatchObject({ props: { bold: true } })
546
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-uat', 'group-paused', 'group-blocked'])
547
+ // each ticket once: a UAT row is in no other group, a paused UAT stays under Paused
548
+ expect(await keysOf(ui, 'row-')).toEqual([
549
+ 'row-209', 'row-210', 'row-214', 'row-208', 'row-212', 'row-240', 'row-241', 'row-242', 'row-243', 'row-211', 'row-213',
550
+ ])
551
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
552
+ expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
553
+ // the parent line, dim, under the row; none without a parent
554
+ expect(await textOf(ui, 'uat-of-240')).toBe('tests Burn-down view of the map #209')
555
+ expect((await ui.find({ key: 'uat-of-240' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
556
+ expect(await textOf(ui, 'uat-of-241')).toBe('tests #212')
557
+ expect(await hasKey(ui, 'uat-of-242')).toBe(false)
558
+ expect(await textOf(ui, 'uat-of-243')).toBe('tests Paused parent #150')
559
+ // the parents come through one GraphQL read, never through the search index
560
+ expect(r.calls.filter(isUatRead)).toEqual([uatCall()])
561
+ expect(r.calls.filter(isUatSearch)).toEqual([])
562
+ // a marker, not a state: a frontier UAT task keeps its work button, a held one its holder, a seed stays dim
563
+ expect(await hasKey(ui, 'work-240')).toBe(true)
564
+ expect(await textOf(ui, 'holder-242')).toBe('@alice')
565
+ expect(await hasKey(ui, 'work-242')).toBe(false)
566
+ expect(await hasKey(ui, 'work-241')).toBe(false)
567
+ expect((await ui.find({ key: 'ticket-241' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
568
+ })
569
+
570
+ test(`no UAT ticket draws no UAT group [${surface}]`, async ($, on) => {
571
+ const none = rig(on, $)
572
+ await $.session.start(START)
573
+ const ui0 = await openAndMount($, none)
574
+ expect(await hasKey(ui0, 'group-uat')).toBe(false)
575
+ })
576
+
577
+ test(`a failed UAT read draws the UAT rows without the parent line, no error [${surface}]`, async ($, on) => {
578
+ const r = rig(on, $)
579
+ r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
580
+ r.uat = null
581
+ await $.session.start(START)
582
+ const ui = await openAndMount($, r)
583
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
584
+ expect(await hasKey(ui, 'uat-of-240')).toBe(false)
585
+ expect(await hasKey(ui, 'error')).toBe(false)
586
+ })
587
+
588
+ test(`a UAT row of another scope does not set the parent of a row with the same number [${surface}]`, async ($, on) => {
589
+ const r = rig(on, $)
590
+ r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
591
+ r.uat = [uatRow(240, `UAT of: [Elsewhere](${URL}/14)`, 'trailhead:map-14'), uatRow(241, `UAT of: #5`)]
592
+ await $.session.start(START)
593
+ const ui = await openAndMount($, r)
594
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
595
+ expect(await hasKey(ui, 'uat-of-240')).toBe(false)
596
+ })
597
+
598
+ test(`more than one page of follow-ups is read, the second call carrying the cursor [${surface}]`, async ($, on) => {
599
+ const r = rig(on, $)
600
+ r.tickets = [...TICKETS, issue(500, 'Deep UAT', ['trailhead:task', 'trailhead:uat'])]
601
+ r.uat = [...Array.from({ length: 149 }, (_, i) => uatRow(1000 + i, 'no parent line')), uatRow(500, `UAT of: [Deep parent](${URL}/77)`)]
602
+ await $.session.start(START)
603
+ const ui = await openAndMount($, r)
604
+ expect(r.calls.filter(isUatRead)).toEqual([uatCall(), uatCall('c100')])
605
+ expect(await textOf(ui, 'uat-of-500')).toBe('tests Deep parent #77')
606
+ })
607
+
608
+ const failClosed: [string, (r: Rig) => void][] = [
609
+ ['a failed second page', r => { r.gh = argv => (isUatRead(argv) && argv.some(a => a.startsWith('after=')) ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv)) }],
610
+ ['a reply with errors', r => { r.uatErrors = true }],
611
+ ['a reply that is not JSON', r => { r.uatBroken = true }],
612
+ ]
613
+ for (const [name, arm] of failClosed) {
614
+ test(`${name} draws the UAT rows with no parent line, no fallback, no partial map [${surface}]`, async ($, on) => {
615
+ const r = rig(on, $)
616
+ r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
617
+ // the parent sits on page 1; a second page exists so the failed-page case has something to lose
618
+ r.uat = [uatRow(240, `UAT of: [Burn-down](${URL}/209)`), ...Array.from({ length: 120 }, (_, i) => uatRow(1000 + i, 'x'))]
619
+ arm(r)
620
+ await $.session.start(START)
621
+ const ui = await openAndMount($, r)
622
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
623
+ expect(await hasKey(ui, 'uat-of-240')).toBe(false)
624
+ expect(await hasKey(ui, 'error')).toBe(false)
625
+ expect(r.calls.filter(isUatSearch)).toEqual([])
626
+ })
627
+ }
628
+
629
+ test(`a read still paging at the cap draws no parent line and stops at the cap [${surface}]`, async ($, on) => {
630
+ const r = rig(on, $)
631
+ r.tickets = [...TICKETS, issue(240, 'UAT of the burn-down', ['trailhead:task', 'trailhead:uat'])]
632
+ r.uat = [uatRow(240, `UAT of: [Burn-down](${URL}/209)`), ...Array.from({ length: UAT_MAX_PAGES * PAGE + 5 }, (_, i) => uatRow(2000 + i, 'x'))]
633
+ await $.session.start(START)
634
+ const ui = await openAndMount($, r)
635
+ expect(r.calls.filter(isUatRead)).toHaveLength(UAT_MAX_PAGES)
636
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
637
+ expect(await hasKey(ui, 'uat-of-240')).toBe(false)
638
+ })
639
+
640
+ test(`the dashboard view groups UAT whiteboard tickets with their parent, reading the whiteboard scope [${surface}]`, async ($, on) => {
641
+ const r = rig(on, $)
642
+ r.whiteboard = [...WHITEBOARD, issue(306, 'Loose UAT', [...WB, 'trailhead:task', 'trailhead:uat'])]
643
+ r.uat = [uatRow(306, `UAT of: [Loose parent](${URL}/290)`, 'trailhead:whiteboard'), uatRow(306, `UAT of: #1`, 'trailhead:map-14')]
644
+ await $.session.start(START)
645
+ const ui = await openAndMount($, r)
646
+ await pressDashboard(ui, r)
647
+ expect(r.calls.filter(isUatRead)).toContainEqual(uatCall())
648
+ expect(r.calls.filter(isUatSearch)).toEqual([])
649
+ expect(await textOf(ui, 'head-uat')).toBe('UAT · 1')
650
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
651
+ expect(await textOf(ui, 'uat-of-306')).toBe('tests Loose parent #290')
652
+ })
653
+
654
+ test(`a paused ticket sits under Paused between In progress and Blocked, with a resume button [${surface}]`, async ($, on) => {
655
+ const r = rig(on, $)
656
+ r.work = stateText()
657
+ r.tickets = [
658
+ ...TICKETS,
659
+ issue(230, 'Paused with a holder', ['trailhead:build', 'trailhead:paused'], ['ToRvaLDz']),
660
+ issue(231, 'Paused and released', ['trailhead:build', 'trailhead:paused']),
661
+ issue(232, 'Paused and blocked', ['trailhead:build', 'trailhead:paused', 'trailhead:blocked'], ['bob']),
662
+ ]
663
+ await $.session.start(START)
664
+ const ui = await openAndMount($, r)
665
+
666
+ expect(await textOf(ui, 'head-paused')).toBe('Paused · 3')
667
+ expect((await ui.find({ key: 'head-paused' }))?.children[0]).toMatchObject({ props: { bold: true } })
668
+ expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 2')
669
+ expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 2')
670
+ // a paused ticket appears in one group only: Frontier, In progress, Paused, Blocked in order
671
+ 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'])
672
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-frontier', 'group-in-progress', 'group-paused', 'group-blocked'])
673
+ for (const n of [230, 231, 232]) {
674
+ expect(await hasKey(ui, `resume-${n}`)).toBe(true)
675
+ expect(await hasKey(ui, `work-${n}`)).toBe(false)
676
+ expect(await hasKey(ui, `holder-${n}`)).toBe(false)
677
+ }
678
+ expect((await ui.find({ key: 'ticket-231' }))?.children[0].props).not.toHaveProperty('dimColor')
679
+ })
680
+
681
+ test(`the dashboard view groups paused whiteboard tickets the same way [${surface}]`, async ($, on) => {
682
+ const r = rig(on, $)
683
+ r.whiteboard = [...WHITEBOARD, issue(305, 'Paused loose one', [...WB, 'trailhead:task', 'trailhead:paused'], ['alice'])]
684
+ await $.session.start(START)
685
+ const ui = await openAndMount($, r)
686
+ await pressDashboard(ui, r)
687
+ expect(await textOf(ui, 'head-paused')).toBe('Paused · 1')
688
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-305', 'row-303'])
689
+ expect(await hasKey(ui, 'work-305')).toBe(false)
690
+ expect(await hasKey(ui, 'resume-305')).toBe(true)
691
+ })
692
+
693
+ test(`needs-you tickets sit in their own group first, once each, with a work button unless held [${surface}]`, async ($, on) => {
694
+ const r = rig(on, $)
695
+ r.tickets = [
696
+ ...TICKETS,
697
+ issue(250, 'Needs you plain', ['trailhead:build', 'trailhead:needs-you']),
698
+ issue(251, 'Needs you paused', ['trailhead:build', 'trailhead:needs-you', 'trailhead:paused']),
699
+ issue(252, 'Needs you paused and held', ['trailhead:build', 'trailhead:needs-you', 'trailhead:paused'], ['bob']),
700
+ issue(253, 'Needs you UAT', ['trailhead:task', 'trailhead:needs-you', 'trailhead:uat']),
701
+ issue(254, 'Needs you held', ['trailhead:build', 'trailhead:needs-you'], ['alice']),
702
+ issue(255, 'Needs you blocked', ['trailhead:build', 'trailhead:needs-you', 'trailhead:blocked']),
703
+ ]
704
+ await $.session.start(START)
705
+ const ui = await openAndMount($, r)
706
+
707
+ expect(await textOf(ui, 'head-needs-you')).toBe('Needs you · 6')
708
+ expect((await ui.find({ key: 'head-needs-you' }))?.children[0]).toMatchObject({ props: { bold: true } })
709
+ expect(await keysOf(ui, 'group-')).toEqual(['group-maps', 'group-needs-you', 'group-frontier', 'group-in-progress', 'group-blocked'])
710
+ // each ticket once, ahead of the frontier rows; the other counts leave them out
711
+ 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'])
712
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
713
+ expect(await hasKey(ui, 'head-paused')).toBe(false)
714
+ expect(await hasKey(ui, 'head-uat')).toBe(false)
715
+ for (const n of [250, 251, 253]) {
716
+ expect(await hasKey(ui, `work-${n}`)).toBe(true)
717
+ expect(await hasKey(ui, `resume-${n}`)).toBe(false)
718
+ }
719
+ for (const n of [252, 254]) {
720
+ expect(await textOf(ui, `holder-${n}`)).toBe(n === 252 ? '@bob' : '@alice')
721
+ expect(await hasKey(ui, `work-${n}`)).toBe(false)
722
+ expect(await hasKey(ui, `resume-${n}`)).toBe(false)
723
+ }
724
+ expect(await hasKey(ui, 'work-255')).toBe(false)
725
+ expect(await hasKey(ui, 'resume-255')).toBe(false)
726
+ expect(await hasKey(ui, 'holder-255')).toBe(false)
727
+ expect((await ui.find({ key: 'ticket-255' }))?.children[0].props).toMatchObject({ dimColor: true })
728
+
729
+ await ui.press({ key: 'work-251' })
730
+ await settle(r)
731
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '251' }])
732
+ })
733
+
734
+ test(`the dashboard view puts a needs-you whiteboard ticket first in its own group [${surface}]`, async ($, on) => {
735
+ const r = rig(on, $)
736
+ r.whiteboard = [...WHITEBOARD, issue(307, 'Loose needs you', [...WB, 'trailhead:task', 'trailhead:needs-you', 'trailhead:paused'])]
737
+ await $.session.start(START)
738
+ const ui = await openAndMount($, r)
739
+ await pressDashboard(ui, r)
740
+ expect(await textOf(ui, 'head-needs-you')).toBe('Needs you · 1')
741
+ // first ticket group, right after the maps group
742
+ expect((await keysOf(ui, 'group-')).slice(0, 2)).toEqual(['group-maps', 'group-needs-you'])
743
+ expect((await keysOf(ui, 'row-'))[0]).toBe('row-307')
744
+ expect(await hasKey(ui, 'head-paused')).toBe(false)
745
+ expect(await hasKey(ui, 'work-307')).toBe(true)
746
+ expect(await hasKey(ui, 'resume-307')).toBe(false)
747
+ })
748
+
322
749
  test(`ticket rows are spaced apart on the desktop only [${surface}]`, async ($, on) => {
323
750
  const r = rig(on)
324
751
  await $.session.start(START)
@@ -489,6 +916,70 @@ for (const surface of SURFACES) {
489
916
  expect(await ui.find({ key: 'mark-212' })).toBeUndefined()
490
917
  })
491
918
 
919
+ test(`another session's work state does not move this session's mark [${surface}]`, async ($, on) => {
920
+ const r = rig(on, $)
921
+ r.work = stateText()
922
+ await $.session.start(START)
923
+ const ui = await openAndMount($, r)
924
+ expect(await textOf(ui, 'self-208')).toBe('this session')
925
+
926
+ // a second session on the same map takes over the shared file
927
+ r.work = stateText({ ticket: 212, title: 'Native gh-backed tools', session: NEXT_SESSION })
928
+ await r.clock.advance(FLOOR)
929
+ await completeTurn($)
930
+ await settle(r)
931
+ expect(await textOf(ui, 'self-208')).toBe('this session')
932
+ expect(await textOf(ui, 'mark-208')).toBe('▸ ')
933
+ expect(await ui.find({ key: 'self-212' })).toBeUndefined()
934
+ expect(await textOf(ui, 'holder-212')).toBe('@alice')
935
+
936
+ // and then removes it: the last state read was not ours, so the mark stays
937
+ r.work = null
938
+ await r.clock.advance(FLOOR)
939
+ await completeTurn($)
940
+ await settle(r)
941
+ expect(await textOf(ui, 'self-208')).toBe('this session')
942
+ })
943
+
944
+ test(`this session's own Resolve, which removes the file, clears the mark [${surface}]`, async ($, on) => {
945
+ const r = rig(on, $)
946
+ r.work = stateText()
947
+ await $.session.start(START)
948
+ const ui = await openAndMount($, r)
949
+ expect(await textOf(ui, 'self-208')).toBe('this session')
950
+ r.work = null
951
+ await r.clock.advance(FLOOR)
952
+ await completeTurn($)
953
+ await settle(r)
954
+ expect(await ui.find({ key: 'self-208' })).toBeUndefined()
955
+ expect(await ui.find({ key: 'mark-208' })).toBeUndefined()
956
+ expect(await textOf(ui, 'holder-208')).toBe('@ToRvaLDz')
957
+ })
958
+
959
+ test(`each session marks its own ticket on the same map [${surface}]`, async ($, on) => {
960
+ const r = rig(on, $)
961
+ r.session = NEXT_SESSION
962
+ r.work = stateText({ ticket: 212, title: 'Native gh-backed tools', session: NEXT_SESSION })
963
+ await $.session.start(START)
964
+ const ui = await openAndMount($, r)
965
+ expect(await textOf(ui, 'self-212')).toBe('this session')
966
+ expect(await textOf(ui, 'mark-212')).toBe('▸ ')
967
+ expect(await textOf(ui, 'holder-208')).toBe('@ToRvaLDz')
968
+ expect(await ui.find({ key: 'self-208' })).toBeUndefined()
969
+ })
970
+
971
+ test(`a /clear starts the new session with no ticket of its own [${surface}]`, async ($, on) => {
972
+ const r = rig(on, $)
973
+ r.work = stateText()
974
+ await $.session.start(START)
975
+ const ui = await openAndMount($, r)
976
+ expect(await textOf(ui, 'self-208')).toBe('this session')
977
+ await $.command.run({ command: 'clear', args: '' })
978
+ await settle(r)
979
+ expect(await ui.find({ key: 'self-208' })).toBeUndefined()
980
+ expect(await ui.find({ key: 'mark-208' })).toBeUndefined()
981
+ })
982
+
492
983
  test(`unverified tickets are counted in the plural [${surface}]`, async ($, on) => {
493
984
  const r = rig(on, $)
494
985
  r.tickets = [issue(1, 'a', ['trailhead:unverified']), issue(2, 'b', ['trailhead:unverified']), issue(3, 'c', ['trailhead:build'])]
@@ -522,7 +1013,7 @@ for (const surface of SURFACES) {
522
1013
  await completeTurn($)
523
1014
  await settle(r)
524
1015
  // the open maps are still read, so one can be picked; no map is
525
- expect(r.calls).toEqual([['gh', 'issue', 'list', '--label', 'trailhead:map', '--state', 'open', '--limit', '100', '--json', 'number,title,url']])
1016
+ expect(r.calls).toEqual([MAPS_CALL, countCall(14, 198)])
526
1017
  })
527
1018
 
528
1019
  test(`first fetch in flight: the map number and Loading tickets [${surface}]`, async ($, on) => {
@@ -647,6 +1138,199 @@ for (const surface of SURFACES) {
647
1138
  expect(fetches(r)).toBe(2)
648
1139
  })
649
1140
 
1141
+ // The host drops a refused timer silently (a closed ticket stayed on the pane for good): the pane must not
1142
+ // wait on a dead handle.
1143
+ test(`a slow timer the host dropped is started again by the next trigger [${surface}]`, async ($, on) => {
1144
+ // Above the mock clock, which answers the wait without passing it on.
1145
+ let isDropping = false
1146
+ on('clock.every', { ms: TIMER }, async ($$, e, next) => (isDropping ? { deny: 'dropped' } : next(e)))
1147
+ const r = rig(on, $)
1148
+ await $.session.start(START)
1149
+ // the first period is refused: the interval ends unseen
1150
+ isDropping = true
1151
+ await openAndMount($, r)
1152
+ expect(fetches(r)).toBe(1)
1153
+ await r.clock.advance(TIMER)
1154
+ await settle(r)
1155
+ isDropping = false
1156
+ await r.clock.advance(TIMER)
1157
+ await settle(r)
1158
+ expect(fetches(r)).toBe(1)
1159
+
1160
+ // one trigger brings the timer back
1161
+ await completeTurn($)
1162
+ await settle(r)
1163
+ expect(fetches(r)).toBe(2)
1164
+ await r.clock.advance(TIMER)
1165
+ await settle(r)
1166
+ expect(fetches(r)).toBe(3)
1167
+ })
1168
+
1169
+ // The open list is a GraphQL read, never the search-backed `gh issue list --label`, which lags a write.
1170
+ 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'))
1171
+
1172
+ test(`a gh issue create shows the new ticket's row without a manual refresh [${surface}]`, async ($, on) => {
1173
+ const r = rig(on, $)
1174
+ await $.session.start(START)
1175
+ const ui = await openAndMount($, r)
1176
+ expect(await hasKey(ui, 'row-240')).toBe(false)
1177
+
1178
+ r.tickets = [...TICKETS, issue(240, 'Just created', ['trailhead:build'])]
1179
+ await r.clock.advance(FLOOR)
1180
+ await $.tool.call({ tool: 'Bash', command: 'gh issue create -t t' } as never)
1181
+ await settle(r)
1182
+ expect(await hasKey(ui, 'row-240')).toBe(true)
1183
+ expect(listReads(r)).toEqual([])
1184
+ })
1185
+
1186
+ test(`a gh issue close drops the closed ticket's row without a manual refresh [${surface}]`, async ($, on) => {
1187
+ const r = rig(on, $)
1188
+ await $.session.start(START)
1189
+ const ui = await openAndMount($, r)
1190
+ expect(await hasKey(ui, 'row-209')).toBe(true)
1191
+
1192
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1193
+ await r.clock.advance(FLOOR)
1194
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1195
+ await settle(r)
1196
+ expect(await hasKey(ui, 'row-209')).toBe(false)
1197
+ expect(listReads(r)).toEqual([])
1198
+ })
1199
+
1200
+ // The reads asked while the pane was undrawn (the person on another session) are dropped: drawn again with a
1201
+ // list older than one slow-timer period, the pane reads at once instead of showing a closed ticket.
1202
+ test(`a pane drawn again with an overdue list reads at once [${surface}]`, async ($, on) => {
1203
+ const r = rig(on, $)
1204
+ await $.session.start(START)
1205
+ const first = await openAndMount($, r)
1206
+ expect(fetches(r)).toBe(1)
1207
+
1208
+ await first.unmount()
1209
+ r.panes = r.panes.map(p => ({ ...p, isPlaced: false }))
1210
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1211
+ await r.clock.advance(FLOOR)
1212
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1213
+ await completeTurn($)
1214
+ await r.clock.advance(TIMER + 30_001)
1215
+ await settle(r)
1216
+ expect(fetches(r)).toBe(1)
1217
+
1218
+ r.panes = r.panes.map(p => ({ ...p, isPlaced: true }))
1219
+ const ui = await mountPane($)
1220
+ await settle(r)
1221
+ expect(fetches(r)).toBe(2)
1222
+ expect(await hasKey(ui, 'row-209')).toBe(false)
1223
+ })
1224
+
1225
+ test(`a create inside the floor shows at the floor's end [${surface}]`, async ($, on) => {
1226
+ const r = rig(on, $)
1227
+ await $.session.start(START)
1228
+ const ui = await openAndMount($, r)
1229
+ await r.clock.advance(2_000)
1230
+ r.tickets = [...TICKETS, issue(240, 'Just created', ['trailhead:build'])]
1231
+ await $.tool.call({ tool: 'Bash', command: 'gh issue create -t t' } as never)
1232
+ await settle(r)
1233
+ expect(await hasKey(ui, 'row-240')).toBe(false)
1234
+ await r.clock.advance(FLOOR - 2_000)
1235
+ await settle(r)
1236
+ expect(await hasKey(ui, 'row-240')).toBe(true)
1237
+ })
1238
+
1239
+ test(`a close inside the floor drops the row at the floor's end [${surface}]`, async ($, on) => {
1240
+ const r = rig(on, $)
1241
+ await $.session.start(START)
1242
+ const ui = await openAndMount($, r)
1243
+ await r.clock.advance(2_000)
1244
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1245
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1246
+ await settle(r)
1247
+ expect(await hasKey(ui, 'row-209')).toBe(true)
1248
+ await r.clock.advance(FLOOR - 2_000)
1249
+ await settle(r)
1250
+ expect(await hasKey(ui, 'row-209')).toBe(false)
1251
+ })
1252
+
1253
+ test(`a list of 150 tickets is read in two pages [${surface}]`, async ($, on) => {
1254
+ const r = rig(on, $)
1255
+ r.tickets = Array.from({ length: 150 }, (_, i) => issue(i + 1, `t${i + 1}`, ['trailhead:build']))
1256
+ await $.session.start(START)
1257
+ const ui = await openAndMount($, r)
1258
+ expect(r.calls.filter(isTicketsRead)).toEqual([ticketsCall('trailhead:map-198'), ticketsCall('trailhead:map-198', 'c100')])
1259
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 150')
1260
+ expect(await hasKey(ui, 'capped')).toBe(false)
1261
+ expect(listReads(r)).toEqual([])
1262
+ })
1263
+
1264
+ test(`a failure on the second page discards the read and falls back to gh issue list once [${surface}]`, async ($, on) => {
1265
+ const r = rig(on, $)
1266
+ r.tickets = Array.from({ length: 150 }, (_, i) => issue(i + 1, `t${i + 1}`, ['trailhead:build']))
1267
+ r.gh = argv => (isTicketsRead(argv) && argv.some(a => a.startsWith('after=')) ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv))
1268
+ await $.session.start(START)
1269
+ const ui = await openAndMount($, r)
1270
+ expect(listReads(r)).toEqual([ticketsListCall('trailhead:map-198')])
1271
+ // the fallback list is whole (the rig's list answer), never the 100 rows of page one
1272
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 150')
1273
+ expect(r.calls.filter(isTicketsRead)).toHaveLength(2)
1274
+ })
1275
+
1276
+ test(`an unreadable first page falls back the same way [${surface}]`, async ($, on) => {
1277
+ const r = rig(on, $)
1278
+ 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))
1279
+ await $.session.start(START)
1280
+ const ui = await openAndMount($, r)
1281
+ expect(await keysOf(ui, 'row-')).toEqual(['row-300'])
1282
+ })
1283
+
1284
+ test(`the whiteboard read keeps only the rows that are also tickets [${surface}]`, async ($, on) => {
1285
+ const r = rig(on, $)
1286
+ r.whiteboard = [...WHITEBOARD, issue(310, 'Whiteboard label only', ['trailhead:whiteboard', 'trailhead:task'])]
1287
+ await $.session.start(START)
1288
+ const ui = await openAndMount($, r)
1289
+ await pressDashboard(ui, r)
1290
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
1291
+ })
1292
+
1293
+ test(`a write that reads at once is followed by one more read at the floor's end [${surface}]`, async ($, on) => {
1294
+ const r = rig(on, $)
1295
+ await $.session.start(START)
1296
+ await openAndMount($, r)
1297
+ await r.clock.advance(FLOOR)
1298
+ const before = fetches(r)
1299
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
1300
+ await settle(r)
1301
+ expect(fetches(r)).toBe(before + 1)
1302
+ await r.clock.advance(FLOOR - 1)
1303
+ await settle(r)
1304
+ expect(fetches(r)).toBe(before + 1)
1305
+ await r.clock.advance(1)
1306
+ await settle(r)
1307
+ expect(fetches(r)).toBe(before + 2)
1308
+ // the follow-up is spent
1309
+ await r.clock.advance(FLOOR)
1310
+ await settle(r)
1311
+ expect(fetches(r)).toBe(before + 2)
1312
+ })
1313
+
1314
+ test(`a burst of writes costs one read per floor plus one trailing [${surface}]`, async ($, on) => {
1315
+ const r = rig(on, $)
1316
+ await $.session.start(START)
1317
+ await openAndMount($, r)
1318
+ await r.clock.advance(FLOOR)
1319
+ const before = fetches(r)
1320
+ 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']) {
1321
+ await $.tool.call({ tool: 'Bash', command } as never)
1322
+ await r.clock.advance(1_000)
1323
+ }
1324
+ await settle(r)
1325
+ expect(fetches(r)).toBe(before + 1)
1326
+ await r.clock.advance(FLOOR)
1327
+ await settle(r)
1328
+ expect(fetches(r)).toBe(before + 2)
1329
+ await r.clock.advance(FLOOR)
1330
+ await settle(r)
1331
+ expect(fetches(r)).toBe(before + 2)
1332
+ })
1333
+
650
1334
  test(`session start, turn complete and the slow timer refresh an open pane [${surface}]`, async ($, on) => {
651
1335
  const r = rig(on, $)
652
1336
  await $.session.start(START)
@@ -673,15 +1357,21 @@ for (const surface of SURFACES) {
673
1357
  await $.session.start(START)
674
1358
  await openAndMount($, r)
675
1359
  const base = fetches(r)
676
- for (const command of ['gh issue list --state open', 'ls -la', 'gh pr close 4', 'gh issue view 5']) {
1360
+ 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']) {
677
1361
  await r.clock.advance(FLOOR)
678
1362
  await $.tool.call({ tool: 'Bash', command } as never)
679
1363
  await settle(r)
680
1364
  }
681
1365
  expect(fetches(r)).toBe(base)
682
1366
 
683
- for (const command of ['gh issue close 5', 'gh --repo o/r issue close 5', 'gh issue comment 5 -b hi', 'gh issue edit 5 --add-label x', 'gh issue create -t t', 'gh issue reopen 5']) {
684
- await r.clock.advance(FLOOR)
1367
+ 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']) {
1368
+ // until a whole floor passes with no read (the previous write's follow-up, the slow timer), so this write starts outside one
1369
+ let seen = -1
1370
+ while (seen !== fetches(r)) {
1371
+ seen = fetches(r)
1372
+ await r.clock.advance(FLOOR)
1373
+ await settle(r)
1374
+ }
685
1375
  const before = fetches(r)
686
1376
  await $.tool.call({ tool: 'Bash', command } as never)
687
1377
  await settle(r)
@@ -754,15 +1444,15 @@ for (const surface of SURFACES) {
754
1444
  await $.session.start(START)
755
1445
  const ui = await openAndMount($, r)
756
1446
  const buttons = (await ui.findAll({ type: 'Button' })).map((b: any) => b.key)
757
- expect(buttons).toEqual(['map-14', 'work-209', 'work-210', 'work-214', 'refresh', 'close'])
1447
+ expect(buttons).toEqual(['open-whiteboard', 'map-14', 'work-209', 'work-210', 'work-214', 'refresh', 'close'])
758
1448
  expect((await ui.find({ key: 'work-209' }))?.props).toMatchObject({ label: 'work', variant: 'primary' })
759
1449
  expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
760
1450
 
761
1451
  const first = ui.press({ key: 'work-209' })
762
1452
  await settle(r)
763
1453
  expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
764
- expect(await textOf(ui, 'queued-209')).toBe('queued')
765
- expect((await ui.find({ key: 'queued-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
1454
+ expect(await textOf(ui, 'starting-209')).toBe('starting')
1455
+ expect((await ui.find({ key: 'starting-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
766
1456
  expect(await ui.find({ key: 'work-209' })).toBeUndefined()
767
1457
  expect(await ui.find({ key: 'work-210' })).toBeDefined()
768
1458
 
@@ -770,12 +1460,12 @@ for (const surface of SURFACES) {
770
1460
  await ui.press({ key: 'work-210' })
771
1461
  await settle(r)
772
1462
  expect(r.runs).toHaveLength(2)
773
- expect(await ui.find({ key: 'queued-210' })).toBeUndefined()
1463
+ expect(await ui.find({ key: 'starting-210' })).toBeUndefined()
774
1464
 
775
1465
  release()
776
1466
  await first
777
1467
  await settle(r)
778
- expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1468
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
779
1469
  expect(await ui.find({ key: 'work-209' })).toBeDefined()
780
1470
 
781
1471
  // free again: the next press goes through
@@ -789,7 +1479,43 @@ for (const surface of SURFACES) {
789
1479
  ])
790
1480
  })
791
1481
 
792
- test(`a work command that is refused clears the queued mark [${surface}]`, async ($, on) => {
1482
+ test(`a resume button on a paused row runs /trailhead:resume, shares the one-at-a-time guard and the refusal [${surface}]`, async ($, on) => {
1483
+ const r = rig(on, $)
1484
+ let release: () => void = () => undefined
1485
+ r.gate = new Promise<void>(resolve => (release = resolve))
1486
+ r.tickets = [...TICKETS, issue(230, 'Paused one', ['trailhead:build', 'trailhead:paused'], ['alice'])]
1487
+ await $.session.start(START)
1488
+ const ui = await openAndMount($, r)
1489
+ expect((await ui.find({ key: 'resume-230' }))?.props).toMatchObject({ label: 'resume', variant: 'primary' })
1490
+
1491
+ const first = ui.press({ key: 'resume-230' })
1492
+ await settle(r)
1493
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:resume', args: '230' }])
1494
+ expect(await textOf(ui, 'starting-230')).toBe('starting')
1495
+ expect(await ui.find({ key: 'resume-230' })).toBeUndefined()
1496
+
1497
+ // a work press meanwhile is ignored: one in flight at a time
1498
+ await ui.press({ key: 'work-210' })
1499
+ await settle(r)
1500
+ expect(r.runs).toHaveLength(2)
1501
+
1502
+ release()
1503
+ await first
1504
+ await settle(r)
1505
+ expect(await ui.find({ key: 'starting-230' })).toBeUndefined()
1506
+ expect(await ui.find({ key: 'resume-230' })).toBeDefined()
1507
+
1508
+ // outside the pinned repo it goes through, handing the pinned checkout to the verb
1509
+ const runs = r.runs.length
1510
+ r.cwd = ROOT_OTHER
1511
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
1512
+ await ui.press({ key: 'resume-230' })
1513
+ await settle(r)
1514
+ expect(r.runs.slice(runs)).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:resume', args: `230 ${IN(ROOT)}` }])
1515
+ expect(r.toasts).toEqual([])
1516
+ })
1517
+
1518
+ test(`a work command that is refused clears the queued mark [${surface}]`, async ($, on) => {
793
1519
  const r = rig(on, $)
794
1520
  r.refuse = 'cannot run now'
795
1521
  await $.session.start(START)
@@ -801,7 +1527,109 @@ for (const surface of SURFACES) {
801
1527
  expect(await ui.find({ key: 'work-209' })).toBeDefined()
802
1528
  })
803
1529
 
804
- test(`a work press clears the conversation first, the row staying queued across the reset [${surface}]`, async ($, on) => {
1530
+ test(`a work press with the session outside any repo goes through with the pinned checkout handed over [${surface}]`, async ($, on) => {
1531
+ const r = rig(on, $)
1532
+ await $.session.start(START)
1533
+ const ui = await openAndMount($, r)
1534
+ r.cwd = '/tmp'
1535
+ r.repo = null
1536
+ await ui.press({ key: 'work-209' })
1537
+ await settle(r)
1538
+ expect(r.toasts).toEqual([])
1539
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT)}` }])
1540
+ // The token the verb receives decodes (plain base64url, the helper's alphabet) to the pinned root; the suite has no Buffer.
1541
+ const [number, word] = r.runs[1].args.split(' ')
1542
+ expect(number).toBe('209')
1543
+ expect(word).toMatch(/^--in=[A-Za-z0-9_-]+$/)
1544
+ expect(word).toBe(IN(ROOT))
1545
+ expect(atob(word.slice('--in='.length).replace(/-/g, '+').replace(/_/g, '/'))).toBe(ROOT)
1546
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1547
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1548
+ })
1549
+
1550
+ test(`a work press with the session in another repo goes through with the pinned checkout handed over [${surface}]`, async ($, on) => {
1551
+ const r = rig(on, $)
1552
+ await $.session.start(START)
1553
+ const ui = await openAndMount($, r)
1554
+ r.cwd = ROOT_OTHER
1555
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
1556
+ await ui.press({ key: 'work-209' })
1557
+ await settle(r)
1558
+ expect(r.toasts).toEqual([])
1559
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT)}` }])
1560
+ })
1561
+
1562
+ test(`a pinned worktree checkout is handed over by its own root [${surface}]`, async ($, on) => {
1563
+ const r = rig(on, $)
1564
+ r.top = ROOT_TREE
1565
+ r.cwd = ROOT_TREE
1566
+ r.sessionRoot = ROOT_TREE
1567
+ r.active = '14\n'
1568
+ r.treeActive = '198\n'
1569
+ await $.session.start({ ...START, cwd: ROOT_TREE })
1570
+ const ui = await openAndMount($, r)
1571
+ // From inside it nothing is handed over.
1572
+ await ui.press({ key: 'work-209' })
1573
+ await settle(r)
1574
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1575
+ // A shell cd out of it: the token names the worktree root, not the main tree's.
1576
+ r.cwd = ROOT_OTHER
1577
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
1578
+ r.runs.length = 0
1579
+ await ui.press({ key: 'work-209' })
1580
+ await settle(r)
1581
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT_TREE)}` }])
1582
+ })
1583
+
1584
+ test(`a work press whose pinned checkout is gone while the session is out is refused before the clear [${surface}]`, async ($, on) => {
1585
+ const r = rig(on, $)
1586
+ await $.session.start(START)
1587
+ const ui = await openAndMount($, r)
1588
+ r.gone = [ROOT]
1589
+ r.cwd = '/tmp'
1590
+ r.repo = null
1591
+ await ui.press({ key: 'work-209' })
1592
+ await settle(r)
1593
+ expect(r.runs).toEqual([])
1594
+ expect(r.toasts).toHaveLength(1)
1595
+ expect(r.toasts[0]).toContain('#209')
1596
+ expect(r.toasts[0]).toContain('unreadable')
1597
+ expect(r.toasts[0].length).toBeLessThanOrEqual(40)
1598
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1599
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1600
+ })
1601
+
1602
+ test(`a work press in the pinned repo goes through although session.repo answers null [${surface}]`, async ($, on) => {
1603
+ const r = rig(on, $)
1604
+ await $.session.start(START)
1605
+ const ui = await openAndMount($, r)
1606
+ r.repo = null
1607
+ r.gitRemote = 'git@github.com:o/r.git'
1608
+ await ui.press({ key: 'work-209' })
1609
+ await settle(r)
1610
+ expect(r.toasts).toEqual([])
1611
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1612
+ })
1613
+
1614
+ test(`a work press whose checkout cannot be read is refused, and the next one from the repo goes through [${surface}]`, async ($, on) => {
1615
+ const r = rig(on, $)
1616
+ await $.session.start(START)
1617
+ const ui = await openAndMount($, r)
1618
+ r.repoFails = true
1619
+ await ui.press({ key: 'work-209' })
1620
+ await settle(r)
1621
+ expect(r.runs).toEqual([])
1622
+ expect(r.toasts).toHaveLength(1)
1623
+ expect(r.toasts[0]).toContain('unreadable')
1624
+ expect(r.toasts[0]).not.toContain('cd into')
1625
+ expect(r.toasts[0].length).toBeLessThanOrEqual(40)
1626
+ r.repoFails = false
1627
+ await ui.press({ key: 'work-209' })
1628
+ await settle(r)
1629
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1630
+ })
1631
+
1632
+ test(`a work press clears the conversation first, the row staying starting across the reset [${surface}]`, async ($, on) => {
805
1633
  const r = rig(on, $)
806
1634
  let releaseClear: () => void = () => undefined
807
1635
  let releaseWork: () => void = () => undefined
@@ -813,7 +1641,7 @@ for (const surface of SURFACES) {
813
1641
  const first = ui.press({ key: 'work-209' })
814
1642
  await settle(r)
815
1643
  expect(r.runs).toEqual([{ command: 'clear', args: '' }])
816
- expect(await textOf(ui, 'queued-209')).toBe('queued')
1644
+ expect(await textOf(ui, 'starting-209')).toBe('starting')
817
1645
 
818
1646
  // a second press while the clear is pending is ignored
819
1647
  await ui.press({ key: 'work-210' })
@@ -826,549 +1654,1858 @@ for (const surface of SURFACES) {
826
1654
  expect(r.session).toBe(`${NEXT_SESSION}-1`)
827
1655
  expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
828
1656
  await ui.redraw()
829
- expect(await textOf(ui, 'queued-209')).toBe('queued')
1657
+ expect(await textOf(ui, 'starting-209')).toBe('starting')
830
1658
  await ui.press({ key: 'work-210' })
831
1659
  expect(r.runs).toHaveLength(2)
832
1660
 
833
1661
  releaseWork()
834
1662
  await first
835
1663
  await settle(r)
836
- expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1664
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
837
1665
  expect(await ui.find({ key: 'work-209' })).toBeDefined()
838
1666
  })
839
1667
 
840
- test(`a clear that is refused runs no work command and clears the queued mark [${surface}]`, async ($, on) => {
1668
+ // The main loop's turn, as the host raises it: a press during it is held by the panel until it ends.
1669
+ const startMainTurn = ($: any, id = 'held-turn') => $.turn.start({ text: 'go', turnId: id })
1670
+ const abortTurn = ($: any) =>
1671
+ $.turn.complete({ turnId: `t${(turns += 1)}`, answer: '', durationMs: 1, isAborted: true, reason: 'aborted' })
1672
+
1673
+ test(`a press during a running turn is held with a cancel button and runs when the turn ends [${surface}]`, async ($, on) => {
841
1674
  const r = rig(on, $)
842
- r.refuseClear = 'cannot clear now'
843
1675
  await $.session.start(START)
844
1676
  const ui = await openAndMount($, r)
1677
+ expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
1678
+
1679
+ await startMainTurn($)
845
1680
  await ui.press({ key: 'work-209' })
846
1681
  await settle(r)
847
- expect(r.runs).toEqual([{ command: 'clear', args: '' }])
848
- expect(r.session).toBe(SESSION)
1682
+ // Nothing reaches the host while the turn runs.
1683
+ expect(r.runs).toEqual([])
1684
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1685
+ expect((await ui.find({ key: 'queued-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
1686
+ expect((await ui.find({ key: 'cancel-209' }))?.props).toMatchObject({ label: 'cancel' })
1687
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
1688
+ expect(await ui.find({ key: 'work-209' })).toBeUndefined()
1689
+
1690
+ // A second press while one is held is ignored.
1691
+ await ui.press({ key: 'work-210' })
1692
+ await settle(r)
1693
+ expect(r.runs).toEqual([])
1694
+ expect(await ui.find({ key: 'queued-210' })).toBeUndefined()
1695
+
1696
+ await completeTurn($)
1697
+ await settle(r)
1698
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1699
+ expect(r.session).toBe(`${NEXT_SESSION}-1`)
849
1700
  expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
850
1701
  expect(await ui.find({ key: 'work-209' })).toBeDefined()
851
1702
  })
852
1703
 
853
- test(`a clear that resolves without switching the session runs no work command [${surface}]`, async ($, on) => {
1704
+ test(`a press held in the repo while the session cd's out is sent with the pinned checkout handed over [${surface}]`, async ($, on) => {
854
1705
  const r = rig(on, $)
855
- r.denyClear = true
856
1706
  await $.session.start(START)
857
1707
  const ui = await openAndMount($, r)
1708
+ await startMainTurn($)
858
1709
  await ui.press({ key: 'work-209' })
859
1710
  await settle(r)
860
- expect(r.runs).toEqual([{ command: 'clear', args: '' }])
861
- expect(r.session).toBe(SESSION)
862
- expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
863
- expect(await ui.find({ key: 'work-209' })).toBeDefined()
1711
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1712
+ // The turn moves the session out of any repo (a GitHub repo would move the pin along with it).
1713
+ r.cwd = '/tmp'
1714
+ r.repo = null
1715
+ await completeTurn($)
1716
+ await settle(r)
1717
+ expect(r.toasts).toEqual([])
1718
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: `209 ${IN(ROOT)}` }])
864
1719
  })
865
1720
 
866
- test(`an active map that moved during /clear drops the carried view [${surface}]`, async ($, on) => {
1721
+ test(`a held press whose checkout cannot be read at release is refused before the clear [${surface}]`, async ($, on) => {
867
1722
  const r = rig(on, $)
868
1723
  await $.session.start(START)
869
1724
  const ui = await openAndMount($, r)
870
- await pressMap(ui, r, 14)
871
- expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
872
-
873
- r.duringClear = () => (r.active = '14\n')
874
- await $.command.run({ command: 'clear', args: '' } as never)
875
- await ui.redraw()
1725
+ await startMainTurn($)
1726
+ await ui.press({ key: 'work-209' })
876
1727
  await settle(r)
877
- expect(await textOf(ui, 'map')).toBe('#14 older map')
878
- expect(await hasKey(ui, 'viewing')).toBe(false)
879
- expect(await textOf(ui, 'map-aside-14')).toBe(' active')
1728
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1729
+ r.repoFails = true
1730
+ await completeTurn($)
1731
+ await settle(r)
1732
+ expect(r.runs).toEqual([])
1733
+ expect(r.toasts).toHaveLength(1)
1734
+ expect(r.toasts[0]).toContain('unreadable')
1735
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1736
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
880
1737
  })
881
1738
 
882
- test(`a manual /clear keeps the pane filled, with no turn and inside the floor [${surface}]`, async ($, on) => {
1739
+ test(`a cancel before the turn ends sends nothing, keeps the conversation and puts the work button back [${surface}]`, async ($, on) => {
883
1740
  const r = rig(on, $)
884
1741
  await $.session.start(START)
885
1742
  const ui = await openAndMount($, r)
886
- expect(fetches(r)).toBe(1)
887
- expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
1743
+ await startMainTurn($)
1744
+ await ui.press({ key: 'work-209' })
1745
+ await settle(r)
1746
+ expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
888
1747
 
889
- await $.command.run({ command: 'clear', args: '' } as never)
890
- await ui.redraw()
1748
+ await ui.press({ key: 'cancel-209' })
891
1749
  await settle(r)
892
- expect(r.session).toBe(`${NEXT_SESSION}-1`)
893
- expect(fetches(r)).toBe(2)
894
- expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
895
- expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
896
- })
1750
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1751
+ expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
1752
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
897
1753
 
898
- test(`a pane the person closed is not opened again by itself after /clear [${surface}]`, async ($, on) => {
899
- const r = rig(on, $)
900
- await $.session.start(START)
901
- await mountBand($, FULLSCREEN)
1754
+ await completeTurn($)
902
1755
  await settle(r)
903
- expect(r.opens).toHaveLength(1)
1756
+ expect(r.runs).toEqual([])
1757
+ expect(r.session).toBe(SESSION)
1758
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
904
1759
 
905
- // closed by the person, then cleared
906
- r.panes = []
907
- await $.command.run({ command: 'clear', args: '' } as never)
1760
+ // The slot is free again: another row can be pressed and is held.
1761
+ await startMainTurn($, 'second')
1762
+ await ui.press({ key: 'work-210' })
1763
+ await settle(r)
1764
+ expect(await textOf(ui, 'queued-210')).toBe('queued')
1765
+ expect(r.runs).toEqual([])
908
1766
  await completeTurn($)
909
- await mountBand($, FULLSCREEN)
910
1767
  await settle(r)
911
- expect(r.opens).toHaveLength(1)
1768
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
912
1769
  })
913
1770
 
914
- test(`a viewed map stays viewed through /clear [${surface}]`, async ($, on) => {
1771
+ test(`a cancel of a held resume press sends nothing [${surface}]`, async ($, on) => {
915
1772
  const r = rig(on, $)
1773
+ r.tickets = [...TICKETS, issue(230, 'Paused one', ['trailhead:build', 'trailhead:paused'], ['alice'])]
916
1774
  await $.session.start(START)
917
1775
  const ui = await openAndMount($, r)
918
- await pressMap(ui, r, 14)
919
- expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
920
-
921
- await $.command.run({ command: 'clear', args: '' } as never)
922
- await ui.redraw()
1776
+ await startMainTurn($)
1777
+ await ui.press({ key: 'resume-230' })
923
1778
  await settle(r)
924
- expect(await textOf(ui, 'map')).toBe('#14 older map')
925
- expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
926
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
1779
+ expect(await textOf(ui, 'queued-230')).toBe('queued')
1780
+ await ui.press({ key: 'cancel-230' })
1781
+ await completeTurn($)
1782
+ await settle(r)
1783
+ expect(r.runs).toEqual([])
1784
+ expect(await ui.find({ key: 'resume-230' })).toBeDefined()
927
1785
  })
928
1786
 
929
- test(`the refresh button obeys the same floor [${surface}]`, async ($, on) => {
1787
+ test(`a press with no turn running starts at once and reads starting, with no cancel button [${surface}]`, async ($, on) => {
930
1788
  const r = rig(on, $)
1789
+ let release: () => void = () => undefined
1790
+ r.gate = new Promise<void>(resolve => (release = resolve))
931
1791
  await $.session.start(START)
932
1792
  const ui = await openAndMount($, r)
933
- expect((await ui.find({ key: 'refresh' }))?.props).toMatchObject({ label: 'refresh' })
934
- expect((await ui.find({ key: 'refresh' }))?.props).not.toHaveProperty('variant')
935
- expect(fetches(r)).toBe(1)
936
-
937
- // inside the floor: no fetch now, one when it ends
938
- await r.clock.advance(5_000)
939
- await ui.press({ key: 'refresh' })
940
- await ui.press({ key: 'refresh' })
941
- await settle(r)
942
- expect(fetches(r)).toBe(1)
943
- await r.clock.advance(FLOOR - 5_000)
1793
+ const first = ui.press({ key: 'work-209' })
944
1794
  await settle(r)
945
- expect(fetches(r)).toBe(2)
946
-
947
- // outside the floor: at once
948
- await r.clock.advance(FLOOR)
949
- await ui.press({ key: 'refresh' })
1795
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1796
+ expect(await textOf(ui, 'starting-209')).toBe('starting')
1797
+ expect((await ui.find({ key: 'starting-209' }))?.children[0]).toMatchObject({ props: { color: AMBER } })
1798
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1799
+ expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
1800
+ release()
1801
+ await first
950
1802
  await settle(r)
951
- expect(fetches(r)).toBe(3)
1803
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
1804
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
952
1805
  })
953
1806
 
954
- const panel = ($: any) => $.command.run({ command: 'trailhead:panel' })
955
- const mountBand = ($: any, viewport: Record<string, unknown> | undefined) =>
956
- $.ui.mount({ plugin: 'trailhead', surface, component: 'AbovePrompt', props: BAND_PROPS, viewport })
957
-
958
- test(`/trailhead:panel opens and closes the pane, answering without running a command [${surface}]`, async ($, on) => {
1807
+ test(`an interrupted turn releases a held press too [${surface}]`, async ($, on) => {
959
1808
  const r = rig(on, $)
960
1809
  await $.session.start(START)
961
- const opened = await panel($)
962
- expect(opened.text).toBe('Map panel opened.')
963
- expect(r.opens).toEqual([{ id: PANE, title: 'Map' }])
964
- expect(r.panes.map(p => p.id)).toEqual([PANE])
965
- await settle(r)
966
- expect(fetches(r)).toBe(1)
967
-
968
- const closed = await panel($)
969
- expect(closed.text).toBe('Map panel closed.')
970
- expect(r.panes).toEqual([])
971
- await r.clock.advance(TIMER * 2)
972
- await completeTurn($)
1810
+ const ui = await openAndMount($, r)
1811
+ await startMainTurn($)
1812
+ await ui.press({ key: 'work-209' })
973
1813
  await settle(r)
974
- expect(fetches(r)).toBe(1)
975
-
976
- // the model never sees it; other commands still reach the engine
977
1814
  expect(r.runs).toEqual([])
978
- await $.command.run({ command: 'trailhead:work', args: '5' } as never)
979
- expect(r.runs).toEqual([{ command: 'trailhead:work', args: '5' }])
1815
+ await abortTurn($)
1816
+ await settle(r)
1817
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
980
1818
  })
981
1819
 
982
- test(`the footer row spreads refresh to the left and close to the right [${surface}]`, async ($, on) => {
1820
+ test(`a subagent's turn does not hold a press, nor does its end release one [${surface}]`, async ($, on) => {
983
1821
  const r = rig(on, $)
984
1822
  await $.session.start(START)
985
1823
  const ui = await openAndMount($, r)
986
- expect((await ui.find({ key: 'footer-row' }))?.props).toMatchObject({ width: 60, justifyContent: 'space-between' })
987
- expect((await ui.findAll({ type: 'Button' })).map((b: any) => b.key).slice(-2)).toEqual(['refresh', 'close'])
988
- expect((await ui.find({ key: 'close' }))?.props).toMatchObject({ label: 'close' })
1824
+ await $.turn.start({ text: 'sub', turnId: 'sub-1', agentId: 'agent-1' })
1825
+ await ui.press({ key: 'work-209' })
1826
+ await settle(r)
1827
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1828
+
1829
+ await startMainTurn($, 'main-1')
1830
+ await ui.press({ key: 'work-210' })
1831
+ await settle(r)
1832
+ expect(r.runs).toHaveLength(2)
1833
+ await $.turn.complete({ turnId: 'sub-1', answer: '', durationMs: 1, isAborted: false, reason: 'answer', agentId: 'agent-1' })
1834
+ await settle(r)
1835
+ expect(r.runs).toHaveLength(2)
1836
+ expect(await textOf(ui, 'queued-210')).toBe('queued')
1837
+ await completeTurn($)
1838
+ await settle(r)
1839
+ expect(r.runs).toHaveLength(4)
989
1840
  })
990
1841
 
991
- test(`a press on close removes the pane for good [${surface}]`, async ($, on) => {
1842
+ test(`a turn that starts while the dispatch is still reading re-holds the press, cancel offered again [${surface}]`, async ($, on) => {
992
1843
  const r = rig(on, $)
993
1844
  await $.session.start(START)
994
1845
  const ui = await openAndMount($, r)
995
- expect(fetches(r)).toBe(1)
996
- const opened = r.opens.length
997
-
998
- await ui.press({ key: 'close' })
999
- await settle(r)
1000
- expect(r.panes).toEqual([])
1001
- await r.clock.advance(TIMER * 2)
1846
+ await startMainTurn($)
1847
+ await ui.press({ key: 'work-209' })
1002
1848
  await completeTurn($)
1849
+ // The next session id read is the dispatch's own, before its clear.
1850
+ r.beforeSessionId = async () => void (await startMainTurn($, 'late'))
1003
1851
  await settle(r)
1004
- expect(fetches(r)).toBe(1)
1852
+ expect(r.runs).toEqual([])
1853
+ await ui.redraw()
1854
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1855
+ expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
1856
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
1005
1857
 
1006
- // the press counts as the person closing it: fullscreen never opens it again
1007
- await mountBand($, FULLSCREEN)
1858
+ await completeTurn($)
1008
1859
  await settle(r)
1009
- expect(r.opens).toHaveLength(opened)
1010
- expect(r.logs).toEqual([])
1860
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1861
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1862
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1011
1863
  })
1012
1864
 
1013
- test(`/trailhead:panel says why a pane cannot be placed [${surface}]`, async ($, on) => {
1865
+ test(`a turn that starts between the end of one and the release keeps the press held until it ends [${surface}]`, async ($, on) => {
1014
1866
  const r = rig(on, $)
1015
- r.isPlaced = false
1016
1867
  await $.session.start(START)
1017
- expect((await panel($)).text).toBe('Map panel not shown: terminal too narrow')
1868
+ const ui = await openAndMount($, r)
1869
+ await startMainTurn($)
1870
+ await ui.press({ key: 'work-209' })
1871
+ await completeTurn($)
1872
+ // The release timer has not run yet: a queued prompt of the person's starts a turn.
1873
+ await startMainTurn($, 'next')
1018
1874
  await settle(r)
1019
- expect(fetches(r)).toBe(0)
1020
- // a pane waiting undrawn can still be closed
1021
- expect((await panel($)).text).toBe('Map panel closed.')
1875
+ expect(r.runs).toEqual([])
1876
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1877
+ expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
1878
+
1879
+ await completeTurn($)
1880
+ await settle(r)
1881
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
1022
1882
  })
1023
1883
 
1024
- for (const withBand of [false, true]) {
1025
- test(`the pane opens by itself once in fullscreen, the band ${withBand ? 'drawing' : 'yielding'} [${surface}]`, async ($, on) => {
1884
+ for (const reason of ['prompt_input_exit', 'logout', 'other'] as const) {
1885
+ test(`a session that ends (${reason}) while a press is held drops it and resets the turn flag [${surface}]`, async ($, on) => {
1026
1886
  const r = rig(on, $)
1027
- r.work = withBand ? stateText() : null
1028
1887
  await $.session.start(START)
1029
- expect(r.opens).toEqual([])
1030
- const ui = await mountBand($, FULLSCREEN)
1888
+ const ui = await openAndMount($, r)
1889
+ await startMainTurn($)
1890
+ await ui.press({ key: 'work-209' })
1031
1891
  await settle(r)
1032
- expect([r.logs, r.opens]).toEqual([[], [{ id: PANE, title: 'Map' }]])
1033
- expect(fetches(r)).toBe(1)
1034
- // the band keeps its own drawing: it draws with a state, yields without one
1035
- expect(await ui.find({ key: withBand ? 'ticket' : 'engine' })).toBeDefined()
1892
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
1036
1893
 
1037
- // closed by the person: never again
1038
- r.panes = []
1894
+ await $.session.end({ reason, sessionId: r.session, resume: {} })
1895
+ await settle(r)
1896
+ expect(r.runs).toEqual([])
1039
1897
  await ui.redraw()
1040
- await mountBand($, FULLSCREEN)
1898
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1899
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1900
+
1901
+ // No turn is running in the next session: a press goes straight through.
1902
+ await ui.press({ key: 'work-210' })
1041
1903
  await settle(r)
1042
- expect(r.opens).toHaveLength(1)
1904
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
1043
1905
  })
1044
1906
  }
1045
1907
 
1046
- test(`the pane never opens by itself on the main screen, unknown layout or without a map [${surface}]`, async ($, on) => {
1908
+ test(`a cancel pressed between the release and the clear sends nothing and puts the work button back [${surface}]`, async ($, on) => {
1047
1909
  const r = rig(on, $)
1048
1910
  await $.session.start(START)
1049
- await mountBand($, { columns: 200, rows: 50, isFullscreen: false })
1050
- await mountBand($, { columns: 200, rows: 50 })
1051
- await mountBand($, undefined)
1911
+ const ui = await openAndMount($, r)
1912
+ await startMainTurn($)
1913
+ await ui.press({ key: 'work-209' })
1052
1914
  await settle(r)
1053
- expect(r.opens).toEqual([])
1915
+ await completeTurn($)
1916
+ // The row on screen still reads queued with its cancel button when the press lands, before the clear.
1917
+ let isPressed = false
1918
+ r.beforePhaseWrite = async () => {
1919
+ try {
1920
+ await ui.press({ key: 'cancel-209' })
1921
+ isPressed = true
1922
+ } catch (err) {
1923
+ r.logs.push(`seam press failed: ${String(err)}`)
1924
+ }
1925
+ }
1926
+ await settle(r)
1927
+ expect(r.logs.filter(l => l.startsWith('seam'))).toEqual([])
1928
+ expect(isPressed).toBe(true)
1929
+ expect(r.runs).toEqual([])
1930
+ expect(r.session).toBe(SESSION)
1931
+ await ui.redraw()
1932
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
1933
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1934
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1935
+ // The slot is free again.
1936
+ await ui.press({ key: 'work-210' })
1937
+ await settle(r)
1938
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
1939
+ })
1054
1940
 
1055
- r.active = null
1941
+ // A cancel from a screen a frame behind: the row is drawn queued (the engine reports that phase) for the press.
1942
+ const staleCancel = (ui: any, r: Rig, number: number) => async () => {
1943
+ r.forced = { pendingPhase: 'queued' }
1944
+ await ui.redraw()
1945
+ await ui.press({ key: `cancel-${number}` })
1946
+ }
1947
+
1948
+ test(`a cancel landing while a re-hold writes queued drops the press instead of holding it [${surface}]`, async ($, on) => {
1949
+ const r = rig(on, $)
1056
1950
  await $.session.start(START)
1057
- await mountBand($, FULLSCREEN)
1951
+ const ui = await openAndMount($, r)
1952
+ await startMainTurn($)
1953
+ await ui.press({ key: 'work-209' })
1058
1954
  await settle(r)
1059
- expect(r.opens).toEqual([])
1060
- expect(r.calls).toEqual([])
1955
+ await completeTurn($)
1956
+ // A turn starts during the dispatch's reads: it re-holds, and the cancel lands on that queued write.
1957
+ r.beforeSessionId = async () => {
1958
+ await startMainTurn($, 'late')
1959
+ r.beforePhaseWrite = staleCancel(ui, r, 209)
1960
+ }
1961
+ await settle(r)
1962
+ await completeTurn($)
1963
+ await settle(r)
1964
+ expect(r.runs).toEqual([])
1965
+ expect(r.session).toBe(SESSION)
1966
+ await ui.redraw()
1967
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1968
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
1969
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1061
1970
  })
1062
1971
 
1063
- test(`a pane the person closed with the command is not opened again by itself [${surface}]`, async ($, on) => {
1972
+ test(`a cancel landing between the queued write and the hold drops the press [${surface}]`, async ($, on) => {
1064
1973
  const r = rig(on, $)
1065
1974
  await $.session.start(START)
1066
- await panel($)
1067
- await panel($)
1068
- expect(r.panes).toEqual([])
1975
+ const ui = await openAndMount($, r)
1976
+ await startMainTurn($)
1977
+ await ui.press({ key: 'work-209' })
1978
+ await settle(r)
1979
+ await ui.press({ key: 'cancel-209' })
1980
+ await settle(r)
1981
+
1982
+ r.beforePhaseWrite = staleCancel(ui, r, 209)
1983
+ await ui.press({ key: 'work-209' })
1984
+ await settle(r)
1985
+ await completeTurn($)
1986
+ await settle(r)
1987
+ expect(r.runs).toEqual([])
1988
+ await ui.redraw()
1989
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
1990
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
1991
+ })
1992
+
1993
+ test(`a press made right after a cancel keeps its queued mark and claim and runs when the turn ends [${surface}]`, async ($, on) => {
1994
+ const r = rig(on, $)
1995
+ await $.session.start(START)
1996
+ const ui = await openAndMount($, r)
1997
+ await startMainTurn($)
1998
+ await ui.press({ key: 'work-209' })
1999
+ await settle(r)
2000
+ // No settling in between: the cancel's own redraw must not let a stale-claim sweep erase the next press.
2001
+ await ui.press({ key: 'cancel-209' })
2002
+ await ui.press({ key: 'work-210' })
2003
+ await settle(r)
2004
+ await ui.redraw()
2005
+ await settle(r)
2006
+ expect(r.runs).toEqual([])
2007
+ expect(await textOf(ui, 'queued-210')).toBe('queued')
2008
+ expect(await ui.find({ key: 'cancel-210' })).toBeDefined()
2009
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2010
+
2011
+ await completeTurn($)
2012
+ await settle(r)
2013
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
2014
+ })
2015
+
2016
+ for (const phase of [null, 'starting'] as const) {
2017
+ test(`a pending claim with phase ${phase} and no press behind it is cleared when the pane draws [${surface}]`, async ($, on) => {
2018
+ const r = rig(on, $)
2019
+ await $.session.start(START)
2020
+ const ui = await openAndMount($, r)
2021
+ // What a hot reload leaves: the atoms say pending, the module state is empty.
2022
+ r.forced = { pendingWork: 209, pendingPhase: phase }
2023
+ await ui.redraw()
2024
+ await settle(r)
2025
+ await ui.redraw()
2026
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
2027
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2028
+ await ui.press({ key: 'work-210' })
2029
+ await settle(r)
2030
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
2031
+ })
2032
+ }
2033
+
2034
+ test(`a press with no turn running never shows queued or a cancel button [${surface}]`, async ($, on) => {
2035
+ const r = rig(on, $)
2036
+ let release: () => void = () => undefined
2037
+ r.clearGate = new Promise<void>(resolve => (release = resolve))
2038
+ await $.session.start(START)
2039
+ const ui = await openAndMount($, r)
2040
+ const first = ui.press({ key: 'work-209' })
2041
+ await settle(r)
2042
+ // The phase went straight to starting: queued was never written, so no cancel was ever offered.
2043
+ expect(r.phases[0]).toBe('starting')
2044
+ expect(r.phases).not.toContain('queued')
2045
+ expect(await ui.find({ key: 'cancel-209' })).toBeUndefined()
2046
+ release()
2047
+ await first
2048
+ await settle(r)
2049
+ })
2050
+
2051
+ for (const reason of ['clear', 'resume'] as const) {
2052
+ test(`a session that ends (${reason}) while a press is held drops it [${surface}]`, async ($, on) => {
2053
+ const r = rig(on, $)
2054
+ await $.session.start(START)
2055
+ const ui = await openAndMount($, r)
2056
+ await startMainTurn($)
2057
+ await ui.press({ key: 'work-209' })
2058
+ await settle(r)
2059
+ expect(await textOf(ui, 'queued-209')).toBe('queued')
2060
+
2061
+ await $.session.end({ reason, sessionId: r.session, resume: {} })
2062
+ await settle(r)
2063
+ expect(r.runs).toEqual([])
2064
+ await ui.redraw()
2065
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
2066
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2067
+ // The turn flag was reset too: a press goes straight through.
2068
+ await ui.press({ key: 'work-210' })
2069
+ await settle(r)
2070
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '210' }])
2071
+ })
2072
+ }
2073
+
2074
+ test(`a cancel on a stuck queued row (nothing held, nothing sending) clears it [${surface}]`, async ($, on) => {
2075
+ const r = rig(on, $)
2076
+ await $.session.start(START)
2077
+ const ui = await openAndMount($, r)
2078
+ // What a hot reload leaves: the atoms say queued, the module state is empty.
2079
+ r.forced = { pendingWork: 209, pendingPhase: 'queued' }
2080
+ await ui.redraw()
2081
+ expect(await ui.find({ key: 'cancel-209' })).toBeDefined()
2082
+ await ui.press({ key: 'cancel-209' })
2083
+ await settle(r)
2084
+ await ui.redraw()
2085
+ expect(r.runs).toEqual([])
2086
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
2087
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2088
+ })
2089
+
2090
+ test(`the mod's own clear leaves the row starting across the reset until the verb settles [${surface}]`, async ($, on) => {
2091
+ const r = rig(on, $)
2092
+ let release: () => void = () => undefined
2093
+ r.gate = new Promise<void>(resolve => (release = resolve))
2094
+ await $.session.start(START)
2095
+ const ui = await openAndMount($, r)
2096
+ let during: string | undefined
2097
+ r.duringClear = async () => {
2098
+ await ui.redraw()
2099
+ during = await textOf(ui, 'starting-209')
2100
+ }
2101
+ const first = ui.press({ key: 'work-209' })
2102
+ await settle(r)
2103
+ expect(during).toBe('starting')
2104
+ await ui.redraw()
2105
+ expect(await textOf(ui, 'starting-209')).toBe('starting')
2106
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '209' }])
2107
+ release()
2108
+ await first
2109
+ await settle(r)
2110
+ expect(await ui.find({ key: 'starting-209' })).toBeUndefined()
2111
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2112
+ })
2113
+
2114
+ test(`a clear that is refused runs no work command and clears the queued mark [${surface}]`, async ($, on) => {
2115
+ const r = rig(on, $)
2116
+ r.refuseClear = 'cannot clear now'
2117
+ await $.session.start(START)
2118
+ const ui = await openAndMount($, r)
2119
+ await ui.press({ key: 'work-209' })
2120
+ await settle(r)
2121
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }])
2122
+ expect(r.session).toBe(SESSION)
2123
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
2124
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2125
+ })
2126
+
2127
+ test(`a clear that resolves without switching the session runs no work command [${surface}]`, async ($, on) => {
2128
+ const r = rig(on, $)
2129
+ r.denyClear = true
2130
+ await $.session.start(START)
2131
+ const ui = await openAndMount($, r)
2132
+ await ui.press({ key: 'work-209' })
2133
+ await settle(r)
2134
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }])
2135
+ expect(r.session).toBe(SESSION)
2136
+ expect(await ui.find({ key: 'queued-209' })).toBeUndefined()
2137
+ expect(await ui.find({ key: 'work-209' })).toBeDefined()
2138
+ })
2139
+
2140
+ test(`an active map that moved during /clear drops the carried view [${surface}]`, async ($, on) => {
2141
+ const r = rig(on, $)
2142
+ await $.session.start(START)
2143
+ const ui = await openAndMount($, r)
2144
+ await pressMap(ui, r, 14)
2145
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
2146
+
2147
+ r.duringClear = () => (r.active = '14\n')
2148
+ await $.command.run({ command: 'clear', args: '' } as never)
2149
+ await ui.redraw()
2150
+ await settle(r)
2151
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
2152
+ expect(await hasKey(ui, 'viewing')).toBe(false)
2153
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5 · active')
2154
+ })
2155
+
2156
+ test(`a manual /clear keeps the pane filled, with no turn and inside the floor [${surface}]`, async ($, on) => {
2157
+ const r = rig(on, $)
2158
+ await $.session.start(START)
2159
+ const ui = await openAndMount($, r)
2160
+ expect(fetches(r)).toBe(1)
2161
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
2162
+
2163
+ await $.command.run({ command: 'clear', args: '' } as never)
2164
+ await ui.redraw()
2165
+ await settle(r)
2166
+ expect(r.session).toBe(`${NEXT_SESSION}-1`)
2167
+ expect(fetches(r)).toBe(2)
2168
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
2169
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 3')
2170
+ })
2171
+
2172
+ test(`a pane the person closed is not opened again by itself after /clear [${surface}]`, async ($, on) => {
2173
+ const r = rig(on, $)
2174
+ await $.session.start(START)
2175
+ await mountBand($, FULLSCREEN)
2176
+ await settle(r)
2177
+ expect(r.opens).toHaveLength(1)
2178
+
2179
+ // closed by the person, then cleared
2180
+ r.panes = []
2181
+ await $.command.run({ command: 'clear', args: '' } as never)
2182
+ await completeTurn($)
1069
2183
  await mountBand($, FULLSCREEN)
1070
2184
  await settle(r)
1071
- expect(r.opens).toHaveLength(1)
2185
+ expect(r.opens).toHaveLength(1)
2186
+ })
2187
+
2188
+ test(`a viewed map stays viewed through /clear [${surface}]`, async ($, on) => {
2189
+ const r = rig(on, $)
2190
+ await $.session.start(START)
2191
+ const ui = await openAndMount($, r)
2192
+ await pressMap(ui, r, 14)
2193
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
2194
+
2195
+ await $.command.run({ command: 'clear', args: '' } as never)
2196
+ await ui.redraw()
2197
+ await settle(r)
2198
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
2199
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
2200
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
2201
+ })
2202
+
2203
+ test(`the refresh button obeys the same floor [${surface}]`, async ($, on) => {
2204
+ const r = rig(on, $)
2205
+ await $.session.start(START)
2206
+ const ui = await openAndMount($, r)
2207
+ expect((await ui.find({ key: 'refresh' }))?.props).toMatchObject({ label: 'refresh' })
2208
+ expect((await ui.find({ key: 'refresh' }))?.props).not.toHaveProperty('variant')
2209
+ expect(fetches(r)).toBe(1)
2210
+
2211
+ // inside the floor: no fetch now, one when it ends
2212
+ await r.clock.advance(5_000)
2213
+ await ui.press({ key: 'refresh' })
2214
+ await ui.press({ key: 'refresh' })
2215
+ await settle(r)
2216
+ expect(fetches(r)).toBe(1)
2217
+ await r.clock.advance(FLOOR - 5_000)
2218
+ await settle(r)
2219
+ expect(fetches(r)).toBe(2)
2220
+
2221
+ // outside the floor: at once
2222
+ await r.clock.advance(FLOOR)
2223
+ await ui.press({ key: 'refresh' })
2224
+ await settle(r)
2225
+ expect(fetches(r)).toBe(3)
2226
+ })
2227
+
2228
+ const panel = ($: any) => $.command.run({ command: 'trailhead:panel' })
2229
+ const mountBand = ($: any, viewport: Record<string, unknown> | undefined) =>
2230
+ $.ui.mount({ plugin: 'trailhead', surface, component: 'AbovePrompt', props: BAND_PROPS, viewport })
2231
+
2232
+ test(`/trailhead:panel opens and closes the pane, answering without running a command [${surface}]`, async ($, on) => {
2233
+ const r = rig(on, $)
2234
+ await $.session.start(START)
2235
+ const opened = await panel($)
2236
+ expect(opened.text).toBe('Map panel opened.')
2237
+ expect(r.opens).toEqual([{ id: PANE, title: 'Map' }])
2238
+ expect(r.panes.map(p => p.id)).toEqual([PANE])
2239
+ await settle(r)
2240
+ expect(fetches(r)).toBe(1)
2241
+
2242
+ const closed = await panel($)
2243
+ expect(closed.text).toBe('Map panel closed.')
2244
+ expect(r.panes).toEqual([])
2245
+ await r.clock.advance(TIMER * 2)
2246
+ await completeTurn($)
2247
+ await settle(r)
2248
+ expect(fetches(r)).toBe(1)
2249
+
2250
+ // the model never sees it; other commands still reach the engine
2251
+ expect(r.runs).toEqual([])
2252
+ await $.command.run({ command: 'trailhead:work', args: '5' } as never)
2253
+ expect(r.runs).toEqual([{ command: 'trailhead:work', args: '5' }])
2254
+ })
2255
+
2256
+ test(`the footer row spreads refresh to the left and close to the right [${surface}]`, async ($, on) => {
2257
+ const r = rig(on, $)
2258
+ await $.session.start(START)
2259
+ const ui = await openAndMount($, r)
2260
+ expect((await ui.find({ key: 'footer-row' }))?.props).toMatchObject({ width: 60, justifyContent: 'space-between' })
2261
+ expect((await ui.findAll({ type: 'Button' })).map((b: any) => b.key).slice(-2)).toEqual(['refresh', 'close'])
2262
+ expect((await ui.find({ key: 'close' }))?.props).toMatchObject({ label: 'close' })
2263
+ })
2264
+
2265
+ test(`a press on close removes the pane for good [${surface}]`, async ($, on) => {
2266
+ const r = rig(on, $)
2267
+ await $.session.start(START)
2268
+ const ui = await openAndMount($, r)
2269
+ expect(fetches(r)).toBe(1)
2270
+ const opened = r.opens.length
2271
+
2272
+ await ui.press({ key: 'close' })
2273
+ await settle(r)
2274
+ expect(r.panes).toEqual([])
2275
+ await r.clock.advance(TIMER * 2)
2276
+ await completeTurn($)
2277
+ await settle(r)
2278
+ expect(fetches(r)).toBe(1)
2279
+
2280
+ // the press counts as the person closing it: fullscreen never opens it again
2281
+ await mountBand($, FULLSCREEN)
2282
+ await settle(r)
2283
+ expect(r.opens).toHaveLength(opened)
2284
+ expect(r.logs).toEqual([])
2285
+ })
2286
+
2287
+ test(`/trailhead:panel says why a pane cannot be placed [${surface}]`, async ($, on) => {
2288
+ const r = rig(on, $)
2289
+ r.isPlaced = false
2290
+ await $.session.start(START)
2291
+ expect((await panel($)).text).toBe('Map panel not shown: terminal too narrow')
2292
+ await settle(r)
2293
+ expect(fetches(r)).toBe(0)
2294
+ // a pane waiting undrawn can still be closed
2295
+ expect((await panel($)).text).toBe('Map panel closed.')
2296
+ })
2297
+
2298
+ test(`/trailhead:panel shows a pane the auto-open left undrawn instead of closing it [${surface}]`, async ($, on) => {
2299
+ const r = rig(on, $)
2300
+ // sessione vuota, terminale sotto la soglia dei pannelli non richiesti: l'auto-open resta non disegnato
2301
+ r.isPlaced = false
2302
+ await $.session.start(START)
2303
+ await mountBand($, FULLSCREEN)
2304
+ await settle(r)
2305
+ expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, false]])
2306
+
2307
+ // la richiesta della persona lo disegna a qualsiasi larghezza
2308
+ r.isPlaced = true
2309
+ expect((await panel($)).text).toBe('Map panel opened.')
2310
+ expect(r.panes.map(p => [p.id, p.isPlaced])).toEqual([[PANE, true]])
2311
+ await settle(r)
2312
+ expect(fetches(r)).toBe(1)
2313
+
2314
+ // disegnato, il comando torna a chiuderlo
2315
+ expect((await panel($)).text).toBe('Map panel closed.')
2316
+ expect(r.panes).toEqual([])
2317
+ })
2318
+
2319
+ for (const withBand of [false, true]) {
2320
+ test(`the pane opens by itself once in fullscreen, the band ${withBand ? 'drawing' : 'yielding'} [${surface}]`, async ($, on) => {
2321
+ const r = rig(on, $)
2322
+ r.work = withBand ? stateText() : null
2323
+ await $.session.start(START)
2324
+ expect(r.opens).toEqual([])
2325
+ const ui = await mountBand($, FULLSCREEN)
2326
+ await settle(r)
2327
+ expect([r.logs, r.opens]).toEqual([[], [{ id: PANE, title: 'Map' }]])
2328
+ expect(fetches(r)).toBe(1)
2329
+ // the band keeps its own drawing: it draws with a state, yields without one
2330
+ expect(await ui.find({ key: withBand ? 'ticket' : 'engine' })).toBeDefined()
2331
+
2332
+ // closed by the person: never again
2333
+ r.panes = []
2334
+ await ui.redraw()
2335
+ await mountBand($, FULLSCREEN)
2336
+ await settle(r)
2337
+ expect(r.opens).toHaveLength(1)
2338
+ })
2339
+ }
2340
+
2341
+ test(`the pane never opens by itself on the main screen, unknown layout or without a map [${surface}]`, async ($, on) => {
2342
+ const r = rig(on, $)
2343
+ await $.session.start(START)
2344
+ await mountBand($, { columns: 200, rows: 50, isFullscreen: false })
2345
+ await mountBand($, { columns: 200, rows: 50 })
2346
+ await mountBand($, undefined)
2347
+ await settle(r)
2348
+ expect(r.opens).toEqual([])
2349
+
2350
+ r.active = null
2351
+ await $.session.start(START)
2352
+ await mountBand($, FULLSCREEN)
2353
+ await settle(r)
2354
+ expect(r.opens).toEqual([])
2355
+ expect(r.calls).toEqual([])
2356
+ })
2357
+
2358
+ test(`a pane the person closed with the command is not opened again by itself [${surface}]`, async ($, on) => {
2359
+ const r = rig(on, $)
2360
+ await $.session.start(START)
2361
+ await panel($)
2362
+ await panel($)
2363
+ expect(r.panes).toEqual([])
2364
+ await mountBand($, FULLSCREEN)
2365
+ await settle(r)
2366
+ expect(r.opens).toHaveLength(1)
2367
+ })
2368
+
2369
+ test(`a list that reached the gh limit says so [${surface}]`, async ($, on) => {
2370
+ const r = rig(on, $)
2371
+ r.tickets = Array.from({ length: 200 }, (_, i) => issue(i + 1, `t${i + 1}`, ['trailhead:build']))
2372
+ await $.session.start(START)
2373
+ const ui = await openAndMount($, r)
2374
+ expect(await textOf(ui, 'capped')).toBe('list capped at 200')
2375
+ expect((await ui.find({ key: 'capped' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
2376
+ r.tickets = TICKETS
2377
+ await r.clock.advance(FLOOR)
2378
+ await completeTurn($)
2379
+ await settle(r)
2380
+ expect(await ui.find({ key: 'capped' })).toBeUndefined()
2381
+ })
2382
+
2383
+ test(`a pane found already up at auto-open ends the auto-open for good [${surface}]`, async ($, on) => {
2384
+ const r = rig(on, $)
2385
+ await $.session.start(START)
2386
+ r.open()
2387
+ const ui = await mountBand($, FULLSCREEN)
2388
+ await settle(r)
2389
+ expect(r.opens).toEqual([])
2390
+ r.panes = []
2391
+ await ui.redraw()
2392
+ await mountBand($, FULLSCREEN)
2393
+ await settle(r)
2394
+ expect(r.opens).toEqual([])
2395
+ })
2396
+
2397
+ test(`a pane opened by hand after a map switch fills within the floor, not at the slow timer [${surface}]`, async ($, on) => {
2398
+ const r = rig(on, $)
2399
+ await $.session.start(START)
2400
+ r.open()
2401
+ const first = await mountPane($)
2402
+ await settle(r)
2403
+ expect(await textOf(first, 'map')).toBe('#198 trailhead mod')
2404
+ r.panes = []
2405
+ await first.unmount()
2406
+
2407
+ // the map moves on while the pane is closed
2408
+ r.active = '199\n'
2409
+ r.map = { ...MAP, title: 'second map', url: `${URL}/199` }
2410
+ r.tickets = [issue(300, 'only one', ['trailhead:build'])]
2411
+ await r.clock.advance(3_000)
2412
+ await completeTurn($)
2413
+ await settle(r)
2414
+ expect(fetches(r)).toBe(1)
2415
+
2416
+ r.open()
2417
+ const ui = await mountPane($)
2418
+ await settle(r)
2419
+ expect(await textOf(ui, 'map')).toBe('map #199')
2420
+ await r.clock.advance(FLOOR - 3_000)
2421
+ await settle(r)
2422
+ expect(await textOf(ui, 'map')).toBe('#199 second map')
2423
+ expect(fetches(r)).toBe(2)
2424
+ })
2425
+
2426
+ // ---- viewing another open map (#229) ----
2427
+ const pressMap = async (ui: any, r: Rig, n: number) => {
2428
+ await ui.press({ key: `map-${n}` })
2429
+ await settle(r)
2430
+ }
2431
+
2432
+ test(`two open maps are listed, the active one marked [${surface}]`, async ($, on) => {
2433
+ const r = rig(on, $)
2434
+ await $.session.start(START)
2435
+ const ui = await openAndMount($, r)
2436
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2437
+ expect(await keysOf(ui, 'map-row-')).toEqual(['map-row-14', 'map-row-198'])
2438
+ expect(await textOf(ui, 'map-mark-198')).toBe('● ')
2439
+ expect((await ui.find({ key: 'map-mark-198' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
2440
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
2441
+ expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
2442
+ expect((await ui.find({ key: 'map-label-198' }))?.children[0]).toMatchObject({ props: { bold: true } })
2443
+ expect(await textOf(ui, 'map-mark-14')).toBe(' ')
2444
+ expect((await ui.find({ key: 'map-14' }))?.props).toMatchObject({ label: '#14 older map' })
2445
+ expect(await ui.find({ key: 'map-198' })).toBeUndefined()
2446
+ expect(await ui.find({ key: 'viewing' })).toBeUndefined()
2447
+ // under the header, before the tickets
2448
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
2449
+ expect(keys.indexOf('group-maps')).toBeGreaterThan(keys.indexOf('header'))
2450
+ expect(keys.indexOf('group-maps')).toBeLessThan(keys.indexOf('group-frontier'))
2451
+ })
2452
+
2453
+ test(`one open map draws no maps group [${surface}]`, async ($, on) => {
2454
+ const r = rig(on, $)
2455
+ r.maps = [MAPS[0]]
2456
+ await $.session.start(START)
2457
+ const ui = await openAndMount($, r)
2458
+ expect(await hasKey(ui, 'head-maps')).toBe(false)
2459
+ expect(await hasKey(ui, 'group-maps')).toBe(false)
2460
+ expect((await ui.findAll({ type: 'Button' })).map((b: any) => b.key)).toEqual(['open-whiteboard', 'work-209', 'work-210', 'work-214', 'refresh', 'close'])
2461
+ })
2462
+
2463
+ test(`a press on another map shows its tickets, the active-map marker is never written [${surface}]`, async ($, on) => {
2464
+ const r = rig(on, $)
2465
+ r.work = stateText()
2466
+ await $.session.start(START)
2467
+ const ui = await openAndMount($, r)
2468
+ await pressMap(ui, r, 14)
2469
+
2470
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
2471
+ expect(await textOf(ui, 'destination')).toBe('An older destination.')
2472
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
2473
+ expect(await textOf(ui, 'holder-141')).toBe('@carol')
2474
+ expect(await textOf(ui, 'burndown-counts')).toBe('open 2 · done 0 · removed 0 · peak 2')
2475
+ // the ticket in play belongs to the other map: nothing here is marked
2476
+ expect(await hasKey(ui, 'mark-208')).toBe(false)
2477
+ expect(r.calls.slice(-6).map(argv => argv.slice(0, 4))).toEqual([
2478
+ ['gh', 'issue', 'view', '14'],
2479
+ ['gh', 'api', 'graphql', '-F'],
2480
+ ['gh', 'issue', 'list', '--label'],
2481
+ ['gh', 'api', 'graphql', '-F'],
2482
+ ['gh', 'api', 'graphql', '-F'],
2483
+ ['gh', 'api', 'graphql', '-F'],
2484
+ ])
2485
+ expect(r.calls.some(argv => argv.includes('trailhead:map-14'))).toBe(true)
2486
+
2487
+ // the viewed map is bold, the active one a button; one line says where we are
2488
+ expect(await textOf(ui, 'map-label-14')).toBe('#14 older map')
2489
+ expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
2490
+ expect(await hasKey(ui, 'map-14')).toBe(false)
2491
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
2492
+ expect((await ui.find({ key: 'viewing-text' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
2493
+ expect((await ui.find({ key: 'back' }))?.props).toMatchObject({ label: 'back to active' })
2494
+ // the work verb owns the marker: the pane never wrote it
2495
+ expect(r.writes).toEqual([])
2496
+ expect(r.active).toBe('198\n')
2497
+ })
2498
+
2499
+ test(`back to active and a press on the active map return to it [${surface}]`, async ($, on) => {
2500
+ const r = rig(on, $)
2501
+ await $.session.start(START)
2502
+ const ui = await openAndMount($, r)
2503
+ await pressMap(ui, r, 14)
2504
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
2505
+
2506
+ await ui.press({ key: 'back' })
2507
+ await settle(r)
2508
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
2509
+ expect(await keysOf(ui, 'row-')).toContain('row-208')
2510
+ expect(await hasKey(ui, 'viewing')).toBe(false)
2511
+ expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
2512
+
2513
+ await pressMap(ui, r, 14)
2514
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
2515
+ await pressMap(ui, r, 198)
2516
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
2517
+ expect(await hasKey(ui, 'viewing')).toBe(false)
2518
+ expect(r.writes).toEqual([])
2519
+ })
2520
+
2521
+ test(`a change of the active map resets the view [${surface}]`, async ($, on) => {
2522
+ const r = rig(on, $)
2523
+ await $.session.start(START)
2524
+ const ui = await openAndMount($, r)
2525
+ await pressMap(ui, r, 14)
2526
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
2527
+
2528
+ // the work verb switched the active map to the viewed one at the end of a turn
2529
+ r.active = '14\n'
2530
+ await r.clock.advance(FLOOR)
2531
+ await completeTurn($)
2532
+ await settle(r)
2533
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
2534
+ expect(await hasKey(ui, 'viewing')).toBe(false)
2535
+ expect(await textOf(ui, 'map-label-14')).toBe('#14 older map')
2536
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5 · active')
2537
+ expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
2538
+ })
2539
+
2540
+ test(`a work press on a viewed map runs the work verb, then the view follows the new active map [${surface}]`, async ($, on) => {
2541
+ const r = rig(on, $)
2542
+ await $.session.start(START)
2543
+ const ui = await openAndMount($, r)
2544
+ await pressMap(ui, r, 14)
2545
+ await ui.press({ key: 'work-140' })
2546
+ await settle(r)
2547
+ expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '140' }])
2548
+ expect(r.writes).toEqual([])
2549
+ // still viewing until the verb has changed the marker
2550
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
2551
+
2552
+ r.active = '14\n'
2553
+ await r.clock.advance(FLOOR)
2554
+ await completeTurn($)
2555
+ await settle(r)
2556
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
2557
+ expect(await hasKey(ui, 'viewing')).toBe(false)
2558
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5 · active')
2559
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23')
2560
+ })
2561
+
2562
+ test(`both maps reads failing keep the last list and set no footer error [${surface}]`, async ($, on) => {
2563
+ const r = rig(on, $)
2564
+ await $.session.start(START)
2565
+ const ui = await openAndMount($, r)
2566
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2567
+
2568
+ const base: Gh = argv => withHistory(r, ok(JSON.stringify(r.history)))(argv)
2569
+ r.gh = argv => (isGraphql(argv) || isMapsList(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
2570
+ r.tickets = [issue(300, 'fresh one', ['trailhead:build'])]
2571
+ await r.clock.advance(FLOOR)
2572
+ await completeTurn($)
2573
+ await settle(r)
2574
+ expect(await textOf(ui, 'ticket-300')).toBe('#300 fresh one')
2575
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2576
+ expect(await textOf(ui, 'age')).toBe('updated just now')
2577
+
2578
+ // unreadable output is the same
2579
+ r.gh = argv => (isGraphql(argv) || isMapsList(argv) ? ok('not json') : base(argv))
2580
+ await r.clock.advance(FLOOR)
2581
+ await completeTurn($)
2582
+ await settle(r)
2583
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2584
+ expect(await textOf(ui, 'age')).toBe('updated just now')
2585
+ })
2586
+
2587
+ test(`a viewed map that closed falls back to the active map [${surface}]`, async ($, on) => {
2588
+ const r = rig(on, $)
2589
+ await $.session.start(START)
2590
+ const ui = await openAndMount($, r)
2591
+ await pressMap(ui, r, 14)
2592
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
2593
+
2594
+ r.maps = [MAPS[0]]
2595
+ await r.clock.advance(FLOOR)
2596
+ await completeTurn($)
2597
+ await settle(r)
2598
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
2599
+ expect(await hasKey(ui, 'viewing')).toBe(false)
2600
+ expect(await hasKey(ui, 'head-maps')).toBe(false)
2601
+ })
2602
+
2603
+ test(`a refresh in flight when the view changes never commits the old map's tickets [${surface}]`, async ($, on) => {
2604
+ const r = rig(on, $)
2605
+ // every value the pane stores, to look at what the old answer wrote
2606
+ const paneWrites: unknown[] = []
2607
+ on('state.set', { plugin: 'trailhead', key: 'mapPane' }, ($$, e, next) => {
2608
+ paneWrites.push(e.value)
2609
+ return next(e)
2610
+ })
2611
+ await $.session.start(START)
2612
+ const ui = await openAndMount($, r)
2613
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
2614
+
2615
+ let release: () => void = () => undefined
2616
+ let releaseOther: () => void = () => undefined
2617
+ const held = new Promise<void>(resolve => (release = resolve))
2618
+ const heldOther = new Promise<void>(resolve => (releaseOther = resolve))
2619
+ r.gh = async argv => {
2620
+ // the active map's open-list read is held, and the viewed map's reads too (to look at the screen between)
2621
+ if (argv.includes('trailhead:map-198') && argv.includes('open')) await held
2622
+ if (argv.includes('trailhead:map-14') || argv.includes('14')) await heldOther
2623
+ return r.defaultGh(argv)
2624
+ }
2625
+ r.tickets = [issue(300, 'fresh of 198', ['trailhead:build'])]
2626
+ await r.clock.advance(FLOOR)
2627
+ const refreshing = completeTurn($)
2628
+ await settle(r)
2629
+
2630
+ await ui.press({ key: 'map-14' })
2631
+ await settle(r)
2632
+ const before = paneWrites.length
2633
+ release()
2634
+ await refreshing
2635
+ await settle(r)
2636
+ // the old answer is in, the new one is not: nothing of 198 is on screen, only the wait
2637
+ expect(await textOf(ui, 'map')).toBe('map #14')
2638
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
2639
+ expect(await hasKey(ui, 'ticket-300')).toBe(false)
2640
+ // and the store itself: the stale answer was dropped, not committed behind the render's check
2641
+ const landed = paneWrites.slice(before).map(v => JSON.stringify(v))
2642
+ expect(landed.some(v => v.includes('fresh of 198'))).toBe(false)
2643
+ releaseOther()
2644
+ await settle(r)
2645
+ expect(await hasKey(ui, 'ticket-300')).toBe(false)
2646
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
2647
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
2648
+ })
2649
+
2650
+ test(`with no active map the open maps are listed and one can be viewed [${surface}]`, async ($, on) => {
2651
+ const r = rig(on, $)
2652
+ r.active = null
2653
+ await $.session.start(START)
2654
+ const ui = await openAndMount($, r)
2655
+ await completeTurn($)
2656
+ await settle(r)
2657
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
2658
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2659
+ expect(await hasKey(ui, 'map-198')).toBe(true)
2660
+ expect(await hasKey(ui, 'map-14')).toBe(true)
2661
+
2662
+ await pressMap(ui, r, 14)
2663
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
2664
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14')
2665
+ expect((await ui.find({ key: 'back' }))?.props).toMatchObject({ label: 'back' })
2666
+ await ui.press({ key: 'back' })
2667
+ await settle(r)
2668
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
2669
+ expect(r.writes).toEqual([])
2670
+ })
2671
+
2672
+ const pressDashboard = async (ui: any, r: Rig) => {
2673
+ await ui.press({ key: 'open-whiteboard' })
2674
+ await settle(r)
2675
+ }
2676
+
2677
+ test(`the Whiteboard row shows the maps index and the whiteboard tickets [${surface}]`, async ($, on) => {
2678
+ const r = rig(on, $)
2679
+ await $.session.start(START)
2680
+ const ui = await openAndMount($, r)
2681
+ expect((await ui.find({ key: 'open-whiteboard' }))?.props).toMatchObject({ label: 'Whiteboard' })
2682
+ const before = r.calls.length
2683
+ await pressDashboard(ui, r)
2684
+
2685
+ expect(r.calls.slice(before)).toEqual([
2686
+ ticketsCall('trailhead:whiteboard'),
2687
+ ['gh', 'issue', 'list', '--label', 'trailhead:dashboard', '--state', 'open', '--limit', '10', '--json', 'number,url'],
2688
+ MAPS_CALL,
2689
+ uatCall(),
2690
+ countCall(14, 198),
2691
+ ])
2692
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
2693
+ expect((await ui.find({ key: 'dashboard-title' }))?.children[0]).toMatchObject({ props: { bold: true, color: ACCENT } })
2694
+ expect((await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)).toContain(`${URL}/5`)
2695
+ expect((await ui.find({ key: 'to-map' }))?.props).toMatchObject({ label: 'map' })
2696
+ expect(await hasKey(ui, 'dashboard')).toBe(false)
2697
+ // the view is the whiteboard: its row is not drawn again
2698
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(false)
2699
+
2700
+ // every open map is a button, the active one marked
2701
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2702
+ expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
2703
+ expect(await hasKey(ui, 'map-14')).toBe(true)
2704
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
2705
+
2706
+ // the whiteboard by state, the quarantined one only counted, no map tickets
2707
+ expect(await hasKey(ui, 'whiteboard')).toBe(true)
2708
+ expect(await textOf(ui, 'head-whiteboard')).toBe('Whiteboard · 3 open · 1 assigned')
2709
+ expect(await textOf(ui, 'head-frontier')).toBe('Frontier · 1')
2710
+ expect(await textOf(ui, 'head-in-progress')).toBe('In progress · 1')
2711
+ expect(await textOf(ui, 'head-blocked')).toBe('Blocked · 1')
2712
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
2713
+ expect(await textOf(ui, 'holder-302')).toBe('@alice')
2714
+ expect(await hasKey(ui, 'work-301')).toBe(true)
2715
+ expect(await textOf(ui, 'unverified')).toBe('1 unverified ticket hidden (quarantined)')
2716
+ expect(await hasKey(ui, 'burndown-section')).toBe(false)
2717
+ expect(await textOf(ui, 'age')).toBe('updated just now')
2718
+ expect(r.writes).toEqual([])
2719
+ })
2720
+
2721
+ test(`a Whiteboard row above the maps list carries the counts, the header has no dashboard button [${surface}]`, async ($, on) => {
2722
+ const r = rig(on, $)
2723
+ r.maps = [MAPS[0]]
2724
+ await $.session.start(START)
2725
+ const ui = await openAndMount($, r)
2726
+ // one map open: no maps group, the row is still there, without a per-ticket query
2727
+ expect(await hasKey(ui, 'group-maps')).toBe(false)
2728
+ expect((await ui.find({ key: 'open-whiteboard' }))?.props).toMatchObject({ label: 'Whiteboard' })
2729
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
2730
+ expect((await ui.find({ key: 'whiteboard-aside' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
2731
+ expect(await hasKey(ui, 'dashboard')).toBe(false)
2732
+ expect(r.calls.filter(argv => argv.includes('trailhead:whiteboard'))).toEqual([])
2733
+ expect(r.calls.filter(isGraphql)).toEqual([MAPS_CALL])
2734
+ // above the maps list and the tickets, below the heading
2735
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
2736
+ expect(keys.indexOf('whiteboard-row')).toBeGreaterThan(keys.indexOf('header'))
2737
+ expect(keys.indexOf('whiteboard-row')).toBeLessThan(keys.indexOf('group-frontier'))
2738
+ })
2739
+
2740
+ test(`the Whiteboard row sits above the Maps list [${surface}]`, async ($, on) => {
2741
+ const r = rig(on, $)
2742
+ await $.session.start(START)
2743
+ const ui = await openAndMount($, r)
2744
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
2745
+ expect(keys.indexOf('whiteboard-row')).toBeLessThan(keys.indexOf('group-maps'))
2746
+ expect(keys.indexOf('whiteboard-row')).toBeGreaterThan(keys.indexOf('header'))
2747
+ })
2748
+
2749
+ test(`a press on the Whiteboard row shows the whiteboard, the map button goes back [${surface}]`, async ($, on) => {
2750
+ const r = rig(on, $)
2751
+ r.maps = [MAPS[0]]
2752
+ await $.session.start(START)
2753
+ const ui = await openAndMount($, r)
2754
+ await pressDashboard(ui, r)
2755
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
2756
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(false)
2757
+ await ui.press({ key: 'to-map' })
2758
+ await settle(r)
2759
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
2760
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
2761
+ })
2762
+
2763
+ test(`the whiteboard counts are drawn as read, zero included, and follow the next read [${surface}]`, async ($, on) => {
2764
+ const r = rig(on, $)
2765
+ r.whiteboardCounts = { open: 0, total: 0 }
2766
+ await $.session.start(START)
2767
+ const ui = await openAndMount($, r)
2768
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 0 open / 0')
2769
+ r.whiteboardCounts = { open: 4, total: 11 }
2770
+ await r.clock.advance(FLOOR)
2771
+ await completeTurn($)
2772
+ await settle(r)
2773
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 4 open / 11')
2774
+ })
2775
+
2776
+ test(`a failed counts read shows the row name-only, never an error [${surface}]`, async ($, on) => {
2777
+ const r = rig(on, $)
2778
+ const base = r.defaultGh
2779
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
2780
+ await $.session.start(START)
2781
+ const ui = await openAndMount($, r)
2782
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
2783
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
2784
+ expect(await textOf(ui, 'age')).toBe('updated just now')
2785
+
2786
+ // counts that were read once are not kept through a failed read
2787
+ r.gh = null
2788
+ await r.clock.advance(FLOOR)
2789
+ await completeTurn($)
2790
+ await settle(r)
2791
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
2792
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
2793
+ await r.clock.advance(FLOOR)
2794
+ await completeTurn($)
2795
+ await settle(r)
2796
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
2797
+ expect(await textOf(ui, 'age')).toBe('updated just now')
2798
+ })
2799
+
2800
+ test(`both maps reads failing keep the maps list but draw the row name-only [${surface}]`, async ($, on) => {
2801
+ const r = rig(on, $)
2802
+ await $.session.start(START)
2803
+ const ui = await openAndMount($, r)
2804
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
2805
+ const base: Gh = argv => withHistory(r, ok(JSON.stringify(r.history)))(argv)
2806
+ r.gh = argv => (isGraphql(argv) || isMapsList(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
2807
+ await r.clock.advance(FLOOR)
2808
+ await completeTurn($)
2809
+ await settle(r)
2810
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2811
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
2812
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
2813
+ expect(await textOf(ui, 'age')).toBe('updated just now')
2814
+ })
2815
+
2816
+ test(`unreadable whiteboard fields leave the maps with counts and the row name-only [${surface}]`, async ($, on) => {
2817
+ const r = rig(on, $)
2818
+ r.whiteboardCounts = null
2819
+ await $.session.start(START)
2820
+ const ui = await openAndMount($, r)
2821
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
2822
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
2823
+ expect(await hasKey(ui, 'whiteboard-aside')).toBe(false)
2824
+ expect(await textOf(ui, 'age')).toBe('updated just now')
2825
+ })
2826
+
2827
+ test(`with no active map the Whiteboard row sits above the maps [${surface}]`, async ($, on) => {
2828
+ const none = rig(on, $)
2829
+ none.active = null
2830
+ await $.session.start(START)
2831
+ const ui = await openAndMount($, none)
2832
+ await completeTurn($)
2833
+ await settle(none)
2834
+ expect(await hasKey(ui, 'dashboard')).toBe(false)
2835
+ expect(await textOf(ui, 'whiteboard-aside')).toBe(' 3 open / 9')
2836
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
2837
+ expect(keys.indexOf('whiteboard-row')).toBeLessThan(keys.indexOf('group-maps'))
2838
+ })
2839
+
2840
+ test(`the Whiteboard row stays in the loading view of a map [${surface}]`, async ($, on) => {
2841
+ const r = rig(on, $)
2842
+ let release: () => void = () => undefined
2843
+ const held = new Promise<void>(resolve => (release = resolve))
2844
+ const base = r.defaultGh
2845
+ r.gh = async argv => {
2846
+ if (argv[2] === 'view') await held
2847
+ return base(argv)
2848
+ }
2849
+ await $.session.start(START)
2850
+ r.open()
2851
+ const ui = await mountPane($)
2852
+ await settle(r)
2853
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
2854
+ expect(await hasKey(ui, 'open-whiteboard')).toBe(true)
2855
+ release()
2856
+ await settle(r)
2857
+ })
2858
+
2859
+ test(`the maps list shows each map's open and total from one call [${surface}]`, async ($, on) => {
2860
+ const r = rig(on, $)
2861
+ await $.session.start(START)
2862
+ const ui = await openAndMount($, r)
2863
+ expect(r.calls.filter(argv => argv.includes('trailhead:map') || isGraphql(argv))).toEqual([MAPS_CALL])
2864
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
2865
+ expect(await textOf(ui, 'map-aside-14')).toBe(' 3 open / 5')
2866
+ // a map the read gave no summary for is drawn name-only
2867
+ r.mapSummary = { 198: { total: 23, completed: 16 } }
2868
+ await r.clock.advance(FLOOR)
2869
+ await completeTurn($)
2870
+ await settle(r)
2871
+ expect(await hasKey(ui, 'map-aside-14')).toBe(false)
2872
+ expect(await textOf(ui, 'map-aside-198')).toBe(' 7 open / 23 · active')
2873
+ })
2874
+
2875
+ test(`a failing counts read falls back to the name-only list with no footer error [${surface}]`, async ($, on) => {
2876
+ const r = rig(on, $)
2877
+ const base = r.defaultGh
2878
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
2879
+ await $.session.start(START)
2880
+ const ui = await openAndMount($, r)
2881
+ expect(r.calls.filter(argv => argv.includes('trailhead:map') || isGraphql(argv))).toEqual([MAPS_CALL, MAPS_LIST_CALL])
2882
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2883
+ expect(await textOf(ui, 'map-aside-198')).toBe(' active')
2884
+ expect(await hasKey(ui, 'map-aside-14')).toBe(false)
2885
+ expect(await textOf(ui, 'age')).toBe('updated just now')
2886
+
2887
+ // a reply with errors is a failed read too
2888
+ r.gh = argv => (isGraphql(argv) ? ok(JSON.stringify({ data: { repository: { issues: { nodes: [] } } }, errors: [{ message: 'x' }] })) : base(argv))
2889
+ await r.clock.advance(FLOOR)
2890
+ await completeTurn($)
2891
+ await settle(r)
2892
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
2893
+ expect(await hasKey(ui, 'map-aside-14')).toBe(false)
2894
+ })
2895
+
2896
+ // The open-ticket total across every open map plus the whiteboard (5 + 2 + 3 with the default counts).
2897
+ const TOTAL_TEXT = '10 open across all maps + whiteboard'
2898
+ const totalLines = async (ui: any): Promise<number> => (await ui.findAll({ key: 'open-total' })).length
2899
+ const refreshOnce = async ($: any, r: Rig) => {
2900
+ await r.clock.advance(FLOOR)
2901
+ await completeTurn($)
2902
+ await settle(r)
2903
+ }
2904
+
2905
+ test(`the total line shows exactly once in the map view, under the Whiteboard row [${surface}]`, async ($, on) => {
2906
+ const r = rig(on, $)
2907
+ await $.session.start(START)
2908
+ const ui = await openAndMount($, r)
2909
+ expect(await totalLines(ui)).toBe(1)
2910
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
2911
+ expect((await ui.find({ key: 'open-total' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
2912
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
2913
+ expect(keys.indexOf('open-total')).toBeGreaterThan(keys.indexOf('whiteboard-row'))
2914
+ expect(keys.indexOf('open-total')).toBeLessThan(keys.indexOf('group-frontier'))
2915
+ })
2916
+
2917
+ test(`the total line shows exactly once in the loading state and the no-active-map view [${surface}]`, async ($, on) => {
2918
+ const r = rig(on, $)
2919
+ r.active = null
2920
+ await $.session.start(START)
2921
+ const ui = await openAndMount($, r)
2922
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
2923
+ await completeTurn($)
2924
+ await settle(r)
2925
+ expect(await totalLines(ui)).toBe(1)
2926
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
2927
+
2928
+ // a map pressed while its read is held: Loading tickets, the last good total beside the Whiteboard row
2929
+ const base = r.defaultGh
2930
+ let release: () => void = () => undefined
2931
+ const held = new Promise<void>(resolve => (release = resolve))
2932
+ r.gh = async argv => {
2933
+ if (argv[2] === 'view' || isTicketsRead(argv)) await held
2934
+ return base(argv)
2935
+ }
2936
+ await ui.press({ key: 'map-14' })
2937
+ await settle(r)
2938
+ expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
2939
+ expect(await totalLines(ui)).toBe(1)
2940
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
2941
+ release()
2942
+ await settle(r)
2943
+ })
2944
+
2945
+ test(`the total line shows exactly once under the Dashboard header row [${surface}]`, async ($, on) => {
2946
+ const r = rig(on, $)
2947
+ await $.session.start(START)
2948
+ const ui = await openAndMount($, r)
2949
+ await pressDashboard(ui, r)
2950
+ expect(await hasKey(ui, 'dashboard-title')).toBe(true)
2951
+ expect(await totalLines(ui)).toBe(1)
2952
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
2953
+ const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
2954
+ expect(keys.indexOf('open-total')).toBeGreaterThan(keys.indexOf('header-row'))
2955
+ expect(keys.indexOf('open-total')).toBeLessThan(keys.indexOf('group-maps'))
2956
+ })
2957
+
2958
+ test(`the total is counted over the open maps' numbers, one extra call per read [${surface}]`, async ($, on) => {
2959
+ const r = rig(on, $)
2960
+ r.maps = [MAPS[0]]
2961
+ await $.session.start(START)
2962
+ const ui = await openAndMount($, r)
2963
+ expect(r.calls.filter(isCountRead)).toEqual([countCall(198)])
2964
+ // 5 open on the map + 3 on the whiteboard
2965
+ expect(await textOf(ui, 'open-total')).toBe('8 open across all maps + whiteboard')
2966
+ // a closed map is never counted: only the open maps are enumerated
2967
+ r.maps = MAPS
2968
+ await refreshOnce($, r)
2969
+ expect(r.calls.filter(isCountRead).at(-1)).toEqual(countCall(14, 198))
2970
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
2971
+ })
2972
+
2973
+ for (const mode of ['fail', 'errors', 'junk'] as const) {
2974
+ test(`a count read that fails (${mode}) before any good read draws no total line [${surface}]`, async ($, on) => {
2975
+ const r = rig(on, $)
2976
+ r.countMode = mode
2977
+ await $.session.start(START)
2978
+ const ui = await openAndMount($, r)
2979
+ expect(await totalLines(ui)).toBe(0)
2980
+ // the rest of the pane is unharmed and no footer error is raised for it
2981
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
2982
+ expect(await textOf(ui, 'age')).toBe('updated just now')
2983
+ })
2984
+ }
2985
+
2986
+ test(`a later failing count read keeps the last good total while the open maps are the same [${surface}]`, async ($, on) => {
2987
+ const r = rig(on, $)
2988
+ await $.session.start(START)
2989
+ const ui = await openAndMount($, r)
2990
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
2991
+ r.countMode = 'fail'
2992
+ r.openCounts = { ...r.openCounts, 'trailhead:whiteboard': 99 }
2993
+ await refreshOnce($, r)
2994
+ expect(await totalLines(ui)).toBe(1)
2995
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
2996
+ // a good read takes over again
2997
+ r.countMode = 'ok'
2998
+ await refreshOnce($, r)
2999
+ expect(await textOf(ui, 'open-total')).toBe('106 open across all maps + whiteboard')
3000
+ })
3001
+
3002
+ test(`a failing count read after the open maps changed hides the total, never stale [${surface}]`, async ($, on) => {
3003
+ const r = rig(on, $)
3004
+ await $.session.start(START)
3005
+ const ui = await openAndMount($, r)
3006
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
3007
+ r.countMode = 'fail'
3008
+ r.maps = [MAPS[0]]
3009
+ await refreshOnce($, r)
3010
+ expect(await totalLines(ui)).toBe(0)
3011
+ })
3012
+
3013
+ test(`the name-only maps fallback draws no total and makes no count call [${surface}]`, async ($, on) => {
3014
+ const r = rig(on, $)
3015
+ const base = r.defaultGh
3016
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
3017
+ await $.session.start(START)
3018
+ const ui = await openAndMount($, r)
3019
+ expect(r.calls.filter(isCountRead)).toEqual([])
3020
+ expect(await totalLines(ui)).toBe(0)
3021
+ expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
3022
+ })
3023
+
3024
+ test(`a fallback after a good total hides it rather than keep a figure over a list of unknown completeness [${surface}]`, async ($, on) => {
3025
+ const r = rig(on, $)
3026
+ const base = r.defaultGh
3027
+ await $.session.start(START)
3028
+ const ui = await openAndMount($, r)
3029
+ expect(await totalLines(ui)).toBe(1)
3030
+ r.gh = argv => (isGraphql(argv) ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
3031
+ await refreshOnce($, r)
3032
+ expect(await totalLines(ui)).toBe(0)
3033
+ })
3034
+
3035
+ test(`a narrow pane keeps the lower bound in the short form [${surface}]`, async ($, on) => {
3036
+ const r = rig(on, $)
3037
+ r.countTotal = 101
3038
+ await $.session.start(START)
3039
+ const ui = await openAndMount($, r, { bodyColumns: 24 })
3040
+ expect(await textOf(ui, 'open-total')).toBe('≥10 open in total')
3041
+ })
3042
+
3043
+ test(`a truncated maps read words the total as a lower bound only [${surface}]`, async ($, on) => {
3044
+ const r = rig(on, $)
3045
+ // more open maps exist (101) than the two the read returned
3046
+ r.countTotal = 101
3047
+ await $.session.start(START)
3048
+ const ui = await openAndMount($, r)
3049
+ expect(await textOf(ui, 'open-total')).toBe('at least 10 open across all maps + whiteboard')
3050
+ // exactly as many maps as were read is not a cut
3051
+ r.countTotal = null
3052
+ await refreshOnce($, r)
3053
+ expect(await textOf(ui, 'open-total')).toBe(TOTAL_TEXT)
3054
+ })
3055
+
3056
+ test(`the dashboard heading counts the whiteboard and every assigned trusted ticket is a row [${surface}]`, async ($, on) => {
3057
+ const r = rig(on, $)
3058
+ r.whiteboard = [
3059
+ ...WHITEBOARD,
3060
+ issue(305, 'Assigned but quarantined', [...WB, 'trailhead:task', 'trailhead:unverified'], ['dave']),
3061
+ issue(306, 'Paused with a holder', [...WB, 'trailhead:task', 'trailhead:paused'], ['erin']),
3062
+ ]
3063
+ await $.session.start(START)
3064
+ const ui = await openAndMount($, r)
3065
+ await pressDashboard(ui, r)
3066
+ expect(await textOf(ui, 'head-whiteboard')).toBe('Whiteboard · 4 open · 2 assigned')
3067
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-306', 'row-303'])
3068
+ expect(await textOf(ui, 'holder-302')).toBe('@alice')
3069
+ expect(await hasKey(ui, 'resume-306')).toBe(true)
3070
+ expect(await hasKey(ui, 'row-305')).toBe(false)
3071
+ expect(await textOf(ui, 'unverified')).toBe('2 unverified tickets hidden (quarantined)')
3072
+ })
3073
+
3074
+ test(`the map button goes back to the map shown before [${surface}]`, async ($, on) => {
3075
+ const r = rig(on, $)
3076
+ await $.session.start(START)
3077
+ const ui = await openAndMount($, r)
3078
+ await pressMap(ui, r, 14)
3079
+ await pressDashboard(ui, r)
3080
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
3081
+
3082
+ await ui.press({ key: 'to-map' })
3083
+ await settle(r)
3084
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
3085
+ expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
3086
+ expect(await hasKey(ui, 'dashboard-title')).toBe(false)
3087
+ })
3088
+
3089
+ test(`a map pressed on the dashboard shows that map [${surface}]`, async ($, on) => {
3090
+ const r = rig(on, $)
3091
+ await $.session.start(START)
3092
+ const ui = await openAndMount($, r)
3093
+ await pressDashboard(ui, r)
3094
+ await pressMap(ui, r, 14)
3095
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
3096
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3097
+
3098
+ await pressDashboard(ui, r)
3099
+ await pressMap(ui, r, 198)
3100
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3101
+ expect(await hasKey(ui, 'viewing')).toBe(false)
3102
+ })
3103
+
3104
+ test(`a work press on a whiteboard ticket clears and runs the work verb [${surface}]`, async ($, on) => {
3105
+ const r = rig(on, $)
3106
+ await $.session.start(START)
3107
+ const ui = await openAndMount($, r)
3108
+ await pressDashboard(ui, r)
3109
+ await ui.press({ key: 'work-301' })
3110
+ await settle(r)
3111
+ expect(r.runs).toEqual([
3112
+ { command: 'clear', args: '' },
3113
+ { command: 'trailhead:work', args: '301' },
3114
+ ])
3115
+ // the dashboard stays shown across the reset
3116
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
3117
+ expect(await keysOf(ui, 'row-')).toContain('row-301')
3118
+ })
3119
+
3120
+ test(`an empty whiteboard says so, a failed read shows the reason [${surface}]`, async ($, on) => {
3121
+ const r = rig(on, $)
3122
+ r.whiteboard = []
3123
+ r.dashboard = []
3124
+ await $.session.start(START)
3125
+ const ui = await openAndMount($, r)
3126
+ await pressDashboard(ui, r)
3127
+ expect(await textOf(ui, 'no-tickets')).toBe('No open whiteboard tickets.')
3128
+ // no pinned index yet: the title is plain text
3129
+ expect((await ui.findAll({ type: 'Link' })).map((l: any) => l.props.href)).not.toContain(`${URL}/5`)
3130
+
3131
+ r.whiteboard = WHITEBOARD
3132
+ r.gh = argv => (argv.includes('trailhead:whiteboard') || argv.includes('label=trailhead:whiteboard') ? { exitCode: 1, stdout: '', stderr: 'boom' } : r.defaultGh(argv))
3133
+ await r.clock.advance(FLOOR)
3134
+ await ui.press({ key: 'refresh' })
3135
+ await settle(r)
3136
+ expect(await textOf(ui, 'age')).toBe('⚠ gh failed (exit 1) · list from 15s ago')
3137
+ // a list that may be stale says nothing about being empty
3138
+ expect(await hasKey(ui, 'no-tickets')).toBe(false)
3139
+ })
3140
+
3141
+ test(`with no active map the dashboard is one press away [${surface}]`, async ($, on) => {
3142
+ const r = rig(on, $)
3143
+ r.active = null
3144
+ await $.session.start(START)
3145
+ const ui = await openAndMount($, r)
3146
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
3147
+ await pressDashboard(ui, r)
3148
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
3149
+ await ui.press({ key: 'to-map' })
3150
+ await settle(r)
3151
+ expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
3152
+ })
3153
+
3154
+ test(`the dashboard stays shown through /clear [${surface}]`, async ($, on) => {
3155
+ const r = rig(on, $)
3156
+ await $.session.start(START)
3157
+ const ui = await openAndMount($, r)
3158
+ await pressDashboard(ui, r)
3159
+ await $.command.run({ command: 'clear', args: '' } as never)
3160
+ await settle(r)
3161
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
3162
+ expect(await keysOf(ui, 'row-')).toEqual(['row-301', 'row-302', 'row-303'])
3163
+ })
3164
+ test(`the pane keeps its repo when the session cwd moves out of it [${surface}]`, async ($, on) => {
3165
+ const r = rig(on, $)
3166
+ await $.session.start(START)
3167
+ const ui = await openAndMount($, r)
3168
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3169
+ // A shell `cd /tmp`: no repo there, relative reads miss, and a `gh` without its own cwd fails.
3170
+ r.cwd = '/tmp'
3171
+ r.repo = null
3172
+ r.calls.length = 0
3173
+ r.cwds.length = 0
3174
+ await r.clock.advance(FLOOR)
3175
+ await completeTurn($)
3176
+ await settle(r)
3177
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3178
+ expect(await hasKey(ui, 'footer')).toBe(true)
3179
+ expect(await textOf(ui, 'age')).toBe('updated just now')
3180
+ expect(r.calls.length).toBeGreaterThan(0)
3181
+ expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
3182
+ })
3183
+
3184
+ test(`the pane follows a move to another GitHub repo and drops the old one's map [${surface}]`, async ($, on) => {
3185
+ const r = rig(on, $)
3186
+ await $.session.start(START)
3187
+ const ui = await openAndMount($, r)
3188
+ expect(await keysOf(ui, 'row-')).toContain('row-209')
3189
+ r.cwd = ROOT_OTHER
3190
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
3191
+ r.otherActive = '14\n'
3192
+ r.map = OTHER_MAP
3193
+ r.tickets = OTHER_TICKETS
3194
+ r.history = OTHER_HISTORY
3195
+ r.calls.length = 0
3196
+ r.cwds.length = 0
3197
+ await r.clock.advance(FLOOR)
3198
+ await completeTurn($)
3199
+ await settle(r)
3200
+ expect(await textOf(ui, 'map')).toBe('#14 older map')
3201
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3202
+ expect(r.calls).toContainEqual(MAPS_CALL)
3203
+ expect(r.cwds.every(cwd => cwd === ROOT_OTHER)).toBe(true)
3204
+ // The toplevel is read where the session now is, not at its project root, which a shell `cd` leaves behind.
3205
+ expect(r.gitCalls.at(-1)).toEqual({ argv: ['git', 'rev-parse', '--show-toplevel'], cwd: ROOT_OTHER })
3206
+ expect(r.reads).toContain(`${ROOT_OTHER}/.trailhead/active-map`)
3207
+ })
3208
+
3209
+ test(`a session started outside any repo says so and runs no gh [${surface}]`, async ($, on) => {
3210
+ const r = rig(on, $)
3211
+ r.cwd = '/tmp'
3212
+ r.repo = null
3213
+ await $.session.start({ ...START, cwd: '/tmp' })
3214
+ const ui = await openAndMount($, r)
3215
+ expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
3216
+ await completeTurn($)
3217
+ await settle(r)
3218
+ expect(r.calls).toEqual([])
3219
+ })
3220
+
3221
+ test(`a remote that names no host and repo is no pin: the same message [${surface}]`, async ($, on) => {
3222
+ const r = rig(on, $)
3223
+ r.repo = { root: ROOT, remote: '/srv/git/r.git', internal: false }
3224
+ await $.session.start(START)
3225
+ const ui = await openAndMount($, r)
3226
+ expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
3227
+ expect(r.calls).toEqual([])
3228
+ })
3229
+ test(`a worktree session reads both files and runs gh under its own toplevel [${surface}]`, async ($, on) => {
3230
+ const r = rig(on, $)
3231
+ // The repo's root is the main tree's; the session works in the linked worktree.
3232
+ r.top = ROOT_TREE
3233
+ r.cwd = ROOT_TREE
3234
+ r.sessionRoot = ROOT_TREE
3235
+ r.active = '14\n'
3236
+ r.treeActive = '198\n'
3237
+ r.work = stateText()
3238
+ await $.session.start({ ...START, cwd: ROOT_TREE })
3239
+ const ui = await openAndMount($, r)
3240
+ expect(r.gitCalls[0]).toEqual({ argv: ['git', 'rev-parse', '--show-toplevel'], cwd: ROOT_TREE })
3241
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3242
+ expect(r.reads).toContain(`${ROOT_TREE}/.trailhead/active-map`)
3243
+ expect(r.reads).toContain(`${ROOT_TREE}/.trailhead/work-state.json`)
3244
+ // Il record del run vive nel main checkout (#296): e l'unica lettura ammessa fuori dal toplevel.
3245
+ expect(r.reads.filter(path => path.startsWith(`${ROOT}/`) && !path.endsWith('/.trailhead/auto-run.json'))).toEqual([])
3246
+ expect(r.cwds.length).toBeGreaterThan(0)
3247
+ expect(r.cwds.every(cwd => cwd === ROOT_TREE)).toBe(true)
3248
+ })
3249
+
3250
+ 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']) {
3251
+ test(`the remote ${remote} still pins; gh finds the host itself [${surface}]`, async ($, on) => {
3252
+ const r = rig(on, $)
3253
+ r.repo = { root: ROOT, remote, internal: false }
3254
+ r.authHosts = ['ghe.example.com']
3255
+ await $.session.start(START)
3256
+ const ui = await openAndMount($, r)
3257
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3258
+ expect(r.calls).toContainEqual(MAPS_CALL)
3259
+ expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
3260
+ })
3261
+ }
3262
+
3263
+ test(`a refresh in flight when only the band's refresh moves the pin never commits the old repo's tickets [${surface}]`, async ($, on) => {
3264
+ const r = rig(on, $)
3265
+ await $.session.start(START)
3266
+ let release: () => void = () => {}
3267
+ const gate = new Promise<void>(resolve => {
3268
+ release = resolve
3269
+ })
3270
+ // The answer is fixed when the call starts, so the reads begun in /repo keep /repo's tickets.
3271
+ r.gh = async argv => {
3272
+ const answer = r.defaultGh(argv)
3273
+ await gate
3274
+ return answer
3275
+ }
3276
+ r.open()
3277
+ const ui = await mountPane($)
3278
+ await settle(r)
3279
+ // The same map number in the other repo; a turn start refreshes the band, which moves the pin
3280
+ // while the pane has no hook on it.
3281
+ r.cwd = ROOT_OTHER
3282
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
3283
+ r.otherActive = '198\n'
3284
+ r.tickets = OTHER_TICKETS
3285
+ r.history = OTHER_HISTORY
3286
+ await $.turn.start({ text: 'go', turnId: 'race-1' })
3287
+ release()
3288
+ await settle(r)
3289
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
1072
3290
  })
1073
3291
 
1074
- test(`a list that reached the gh limit says so [${surface}]`, async ($, on) => {
3292
+ test(`the pin survives /clear after the session cwd moved out of the repo [${surface}]`, async ($, on) => {
1075
3293
  const r = rig(on, $)
1076
- r.tickets = Array.from({ length: 200 }, (_, i) => issue(i + 1, `t${i + 1}`, ['trailhead:build']))
3294
+ r.work = stateText()
1077
3295
  await $.session.start(START)
1078
3296
  const ui = await openAndMount($, r)
1079
- expect(await textOf(ui, 'capped')).toBe('list capped at 200')
1080
- expect((await ui.find({ key: 'capped' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
1081
- r.tickets = TICKETS
1082
- await r.clock.advance(FLOOR)
1083
- await completeTurn($)
3297
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3298
+ r.cwd = '/tmp'
3299
+ r.repo = null
3300
+ r.calls.length = 0
3301
+ r.cwds.length = 0
3302
+ await $.command.run({ command: 'clear', args: '' } as never)
1084
3303
  await settle(r)
1085
- expect(await ui.find({ key: 'capped' })).toBeUndefined()
3304
+ expect(await hasKey(ui, 'no-repo')).toBe(false)
3305
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3306
+ expect(r.calls.length).toBeGreaterThan(0)
3307
+ expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
1086
3308
  })
1087
-
1088
- test(`a pane found already up at auto-open ends the auto-open for good [${surface}]`, async ($, on) => {
3309
+ test(`a session started in a host gh does not serve says so and runs no gh read [${surface}]`, async ($, on) => {
1089
3310
  const r = rig(on, $)
3311
+ r.repo = { root: ROOT, remote: `git@gitlab-${surface}.example.com:o/r.git`, internal: false }
1090
3312
  await $.session.start(START)
1091
- r.open()
1092
- const ui = await mountBand($, FULLSCREEN)
1093
- await settle(r)
1094
- expect(r.opens).toEqual([])
1095
- r.panes = []
1096
- await ui.redraw()
1097
- await mountBand($, FULLSCREEN)
3313
+ const ui = await openAndMount($, r)
3314
+ await completeTurn($)
1098
3315
  await settle(r)
1099
- expect(r.opens).toEqual([])
3316
+ expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
3317
+ expect(r.calls).toEqual([])
3318
+ expect(r.authCalls).toEqual([`gitlab-${surface}.example.com`])
1100
3319
  })
1101
3320
 
1102
- test(`a pane opened by hand after a map switch fills within the floor, not at the slow timer [${surface}]`, async ($, on) => {
3321
+ test(`a cd into a GitLab repo keeps the pin and the map [${surface}]`, async ($, on) => {
1103
3322
  const r = rig(on, $)
1104
3323
  await $.session.start(START)
1105
- r.open()
1106
- const first = await mountPane($)
1107
- await settle(r)
1108
- expect(await textOf(first, 'map')).toBe('#198 trailhead mod')
1109
- r.panes = []
1110
- await first.unmount()
1111
-
1112
- // the map moves on while the pane is closed
1113
- r.active = '199\n'
1114
- r.map = { ...MAP, title: 'second map', url: `${URL}/199` }
1115
- r.tickets = [issue(300, 'only one', ['trailhead:build'])]
1116
- await r.clock.advance(3_000)
3324
+ const ui = await openAndMount($, r)
3325
+ r.cwd = ROOT_OTHER
3326
+ r.repo = { root: ROOT_OTHER, remote: 'https://lab.example.com/o/lab.git', internal: false }
3327
+ r.calls.length = 0
3328
+ r.cwds.length = 0
3329
+ await r.clock.advance(FLOOR)
1117
3330
  await completeTurn($)
1118
3331
  await settle(r)
1119
- expect(fetches(r)).toBe(1)
1120
-
1121
- r.open()
1122
- const ui = await mountPane($)
1123
- await settle(r)
1124
- expect(await textOf(ui, 'map')).toBe('map #199')
1125
- await r.clock.advance(FLOOR - 3_000)
1126
- await settle(r)
1127
- expect(await textOf(ui, 'map')).toBe('#199 second map')
1128
- expect(fetches(r)).toBe(2)
3332
+ expect(r.authCalls).toEqual(['lab.example.com'])
3333
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3334
+ expect(await hasKey(ui, 'no-repo')).toBe(false)
3335
+ expect(r.calls.length).toBeGreaterThan(0)
3336
+ expect(r.cwds.every(cwd => cwd === ROOT)).toBe(true)
1129
3337
  })
1130
3338
 
1131
- // ---- viewing another open map (#229) ----
1132
- const pressMap = async (ui: any, r: Rig, n: number) => {
1133
- await ui.press({ key: `map-${n}` })
1134
- await settle(r)
1135
- }
1136
-
1137
- test(`two open maps are listed, the active one marked [${surface}]`, async ($, on) => {
3339
+ test(`the host check runs once per host for the session [${surface}]`, async ($, on) => {
1138
3340
  const r = rig(on, $)
3341
+ r.repo = { root: ROOT, remote: `git@once-${surface}.example.com:o/r.git`, internal: false }
3342
+ r.authHosts = [`once-${surface}.example.com`]
1139
3343
  await $.session.start(START)
1140
3344
  const ui = await openAndMount($, r)
1141
- expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1142
- expect(await keysOf(ui, 'map-row-')).toEqual(['map-row-14', 'map-row-198'])
1143
- expect(await textOf(ui, 'map-mark-198')).toBe('● ')
1144
- expect((await ui.find({ key: 'map-mark-198' }))?.children[0]).toMatchObject({ props: { color: ACCENT } })
1145
- expect(await textOf(ui, 'map-aside-198')).toBe(' active')
1146
- expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
1147
- expect((await ui.find({ key: 'map-label-198' }))?.children[0]).toMatchObject({ props: { bold: true } })
1148
- expect(await textOf(ui, 'map-mark-14')).toBe(' ')
1149
- expect((await ui.find({ key: 'map-14' }))?.props).toMatchObject({ label: '#14 older map' })
1150
- expect(await ui.find({ key: 'map-198' })).toBeUndefined()
1151
- expect(await ui.find({ key: 'viewing' })).toBeUndefined()
1152
- // under the header, before the tickets
1153
- const keys = (await ui.findAll({ type: 'Box' })).map((el: any) => el.key)
1154
- expect(keys.indexOf('group-maps')).toBeGreaterThan(keys.indexOf('header'))
1155
- expect(keys.indexOf('group-maps')).toBeLessThan(keys.indexOf('group-frontier'))
3345
+ for (let i = 0; i < 3; i += 1) {
3346
+ await r.clock.advance(FLOOR)
3347
+ await completeTurn($)
3348
+ await settle(r)
3349
+ }
3350
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3351
+ expect(r.authCalls).toEqual([`once-${surface}.example.com`])
1156
3352
  })
1157
-
1158
- test(`one open map draws no maps group [${surface}]`, async ($, on) => {
3353
+ test(`a failed host check is retried after a while, so a later login pins [${surface}]`, async ($, on) => {
1159
3354
  const r = rig(on, $)
1160
- r.maps = [MAPS[0]]
3355
+ const host = `late-${surface}.example.com`
3356
+ r.repo = { root: ROOT, remote: `git@${host}:o/r.git`, internal: false }
1161
3357
  await $.session.start(START)
1162
3358
  const ui = await openAndMount($, r)
1163
- expect(await hasKey(ui, 'head-maps')).toBe(false)
1164
- expect(await hasKey(ui, 'group-maps')).toBe(false)
1165
- expect((await ui.findAll({ type: 'Button' })).map((b: any) => b.key)).toEqual(['work-209', 'work-210', 'work-214', 'refresh', 'close'])
3359
+ expect(await textOf(ui, 'no-repo')).toBe('Not in a GitHub repository: open the session in a trailhead repo')
3360
+ // A turn soon after does not ask again; one after the retry window does, and the login now holds.
3361
+ await r.clock.advance(FLOOR)
3362
+ await completeTurn($)
3363
+ await settle(r)
3364
+ expect(r.authCalls).toEqual([host])
3365
+ r.authHosts = [host]
3366
+ await r.clock.advance(60_000)
3367
+ await completeTurn($)
3368
+ await settle(r)
3369
+ expect(r.authCalls).toEqual([host, host])
3370
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1166
3371
  })
1167
-
1168
- test(`a press on another map shows its tickets, the active-map marker is never written [${surface}]`, async ($, on) => {
3372
+ test(`a pin moved by the band alone never leaves the old repo's rows drawn [${surface}]`, async ($, on) => {
1169
3373
  const r = rig(on, $)
1170
- r.work = stateText()
1171
3374
  await $.session.start(START)
1172
3375
  const ui = await openAndMount($, r)
1173
- await pressMap(ui, r, 14)
1174
-
3376
+ expect(await keysOf(ui, 'row-')).toContain('row-209')
3377
+ r.cwd = ROOT_OTHER
3378
+ r.repo = { root: ROOT_OTHER, remote: 'https://github.com/o/other.git', internal: false }
3379
+ r.otherActive = '14\n'
3380
+ r.map = OTHER_MAP
3381
+ r.tickets = OTHER_TICKETS
3382
+ r.history = OTHER_HISTORY
3383
+ // Only the band hooks a turn start: it moves the pin, no read of the pane is in flight.
3384
+ await $.turn.start({ text: 'go', turnId: 'band-1' })
3385
+ expect(await keysOf(ui, 'row-')).not.toContain('row-209')
3386
+ await settle(r)
1175
3387
  expect(await textOf(ui, 'map')).toBe('#14 older map')
1176
- expect(await textOf(ui, 'destination')).toBe('An older destination.')
1177
3388
  expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
1178
- expect(await textOf(ui, 'holder-141')).toBe('@carol')
1179
- expect(await textOf(ui, 'burndown-counts')).toBe('open 2 · done 0 · removed 0 · peak 2')
1180
- // the ticket in play belongs to the other map: nothing here is marked
1181
- expect(await hasKey(ui, 'mark-208')).toBe(false)
1182
- expect(r.calls.slice(-4).map(argv => argv.slice(0, 4))).toEqual([
1183
- ['gh', 'issue', 'view', '14'],
1184
- ['gh', 'issue', 'list', '--label'],
1185
- ['gh', 'issue', 'list', '--label'],
1186
- ['gh', 'issue', 'list', '--label'],
1187
- ])
1188
- expect(r.calls.some(argv => argv.includes('trailhead:map-14'))).toBe(true)
1189
-
1190
- // the viewed map is bold, the active one a button; one line says where we are
1191
- expect(await textOf(ui, 'map-label-14')).toBe('#14 older map')
1192
- expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
1193
- expect(await hasKey(ui, 'map-14')).toBe(false)
1194
- expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
1195
- expect((await ui.find({ key: 'viewing-text' }))?.children[0]).toMatchObject({ props: { dimColor: true } })
1196
- expect((await ui.find({ key: 'back' }))?.props).toMatchObject({ label: 'back to active' })
1197
- // the work verb owns the marker: the pane never wrote it
1198
- expect(r.writes).toEqual([])
1199
- expect(r.active).toBe('198\n')
1200
3389
  })
1201
3390
 
1202
- test(`back to active and a press on the active map return to it [${surface}]`, async ($, on) => {
3391
+ // The panel follows the whiteboard ticket this session takes (#306).
3392
+ const WB_STATE = { map: null, ticket: 301, title: 'Fix the README badge', step: 'discuss' }
3393
+ const BOARD_KEY = 'dashboard-title'
3394
+ const CREATE_WB = 'gh issue create -t t --label trailhead:whiteboard'
3395
+
3396
+ test(`an own whiteboard ticket in play switches the panel to the whiteboard [${surface}]`, async ($, on) => {
1203
3397
  const r = rig(on, $)
3398
+ r.work = stateText(WB_STATE)
1204
3399
  await $.session.start(START)
1205
3400
  const ui = await openAndMount($, r)
1206
- await pressMap(ui, r, 14)
1207
- expect(await textOf(ui, 'map')).toBe('#14 older map')
1208
-
1209
- await ui.press({ key: 'back' })
1210
- await settle(r)
1211
- expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1212
- expect(await keysOf(ui, 'row-')).toContain('row-208')
1213
- expect(await hasKey(ui, 'viewing')).toBe(false)
1214
- expect(await textOf(ui, 'map-label-198')).toBe('#198 trailhead mod')
1215
-
1216
- await pressMap(ui, r, 14)
1217
- expect(await textOf(ui, 'map')).toBe('#14 older map')
1218
- await pressMap(ui, r, 198)
1219
- expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1220
- expect(await hasKey(ui, 'viewing')).toBe(false)
1221
- expect(r.writes).toEqual([])
3401
+ expect(await hasKey(ui, BOARD_KEY)).toBe(true)
3402
+ expect(await hasKey(ui, 'map')).toBe(false)
1222
3403
  })
1223
3404
 
1224
- test(`a change of the active map resets the view [${surface}]`, async ($, on) => {
3405
+ test(`going back to the map by hand is not undone by the next turn [${surface}]`, async ($, on) => {
1225
3406
  const r = rig(on, $)
3407
+ r.work = stateText(WB_STATE)
1226
3408
  await $.session.start(START)
1227
3409
  const ui = await openAndMount($, r)
1228
- await pressMap(ui, r, 14)
1229
- expect(await textOf(ui, 'map')).toBe('#14 older map')
1230
-
1231
- // the work verb switched the active map to the viewed one at the end of a turn
1232
- r.active = '14\n'
3410
+ await ui.press({ key: 'to-map' })
3411
+ await settle(r)
3412
+ expect(await hasKey(ui, BOARD_KEY)).toBe(false)
1233
3413
  await r.clock.advance(FLOOR)
1234
3414
  await completeTurn($)
1235
3415
  await settle(r)
1236
- expect(await textOf(ui, 'map')).toBe('#14 older map')
1237
- expect(await hasKey(ui, 'viewing')).toBe(false)
1238
- expect(await textOf(ui, 'map-label-14')).toBe('#14 older map')
1239
- expect(await textOf(ui, 'map-aside-14')).toBe(' active')
1240
- expect((await ui.find({ key: 'map-198' }))?.props).toMatchObject({ label: '#198 trailhead mod' })
3416
+ expect(await hasKey(ui, BOARD_KEY)).toBe(false)
3417
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1241
3418
  })
1242
3419
 
1243
- test(`a work press on a viewed map runs the work verb, then the view follows the new active map [${surface}]`, async ($, on) => {
3420
+ test(`the followed ticket is not forced again after /clear [${surface}]`, async ($, on) => {
1244
3421
  const r = rig(on, $)
3422
+ r.work = stateText(WB_STATE)
1245
3423
  await $.session.start(START)
1246
3424
  const ui = await openAndMount($, r)
1247
- await pressMap(ui, r, 14)
1248
- await ui.press({ key: 'work-140' })
3425
+ await ui.press({ key: 'to-map' })
3426
+ await settle(r)
3427
+ await $.command.run({ command: 'clear', args: '' } as never)
3428
+ r.work = stateText({ ...WB_STATE, session: r.session })
3429
+ await ui.redraw()
1249
3430
  await settle(r)
1250
- expect(r.runs).toEqual([{ command: 'clear', args: '' }, { command: 'trailhead:work', args: '140' }])
1251
- expect(r.writes).toEqual([])
1252
- // still viewing until the verb has changed the marker
1253
- expect(await textOf(ui, 'viewing-text')).toBe('viewing #14 · active #198')
1254
-
1255
- r.active = '14\n'
1256
3431
  await r.clock.advance(FLOOR)
1257
3432
  await completeTurn($)
1258
3433
  await settle(r)
1259
- expect(await textOf(ui, 'map')).toBe('#14 older map')
1260
- expect(await hasKey(ui, 'viewing')).toBe(false)
1261
- expect(await textOf(ui, 'map-aside-14')).toBe(' active')
1262
- expect(await hasKey(ui, 'map-aside-198')).toBe(false)
3434
+ expect(await hasKey(ui, BOARD_KEY)).toBe(false)
1263
3435
  })
1264
3436
 
1265
- test(`a failed maps read keeps the last list and sets no footer error [${surface}]`, async ($, on) => {
3437
+ test(`a state that appears mid-session is followed after the helper call [${surface}]`, async ($, on) => {
1266
3438
  const r = rig(on, $)
1267
3439
  await $.session.start(START)
1268
3440
  const ui = await openAndMount($, r)
1269
- expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1270
-
1271
- const base: Gh = argv => withHistory(r, ok(JSON.stringify(r.history)))(argv)
1272
- r.gh = argv => (argv.includes('trailhead:map') ? { exitCode: 1, stdout: '', stderr: 'boom' } : base(argv))
1273
- r.tickets = [issue(300, 'fresh one', ['trailhead:build'])]
1274
- await r.clock.advance(FLOOR)
1275
- await completeTurn($)
3441
+ expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
3442
+ r.work = stateText(WB_STATE)
3443
+ await $.tool.call({ tool: 'Bash', command: 'node /x/trailhead-work-state.js set --ticket 301' } as never)
1276
3444
  await settle(r)
1277
- expect(await textOf(ui, 'ticket-300')).toBe('#300 fresh one')
1278
- expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1279
- expect(await textOf(ui, 'age')).toBe('updated just now')
3445
+ expect(await hasKey(ui, BOARD_KEY)).toBe(true)
3446
+ })
1280
3447
 
1281
- // unreadable output is the same
1282
- r.gh = argv => (argv.includes('trailhead:map') ? ok('not json') : base(argv))
3448
+ test(`a state that appears before a turn ends is followed [${surface}]`, async ($, on) => {
3449
+ const r = rig(on, $)
3450
+ await $.session.start(START)
3451
+ const ui = await openAndMount($, r)
3452
+ r.work = stateText(WB_STATE)
1283
3453
  await r.clock.advance(FLOOR)
1284
3454
  await completeTurn($)
1285
3455
  await settle(r)
1286
- expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1287
- expect(await textOf(ui, 'age')).toBe('updated just now')
3456
+ expect(await hasKey(ui, BOARD_KEY)).toBe(true)
1288
3457
  })
1289
3458
 
1290
- test(`a viewed map that closed falls back to the active map [${surface}]`, async ($, on) => {
3459
+ test(`a map ticket in play does not switch the panel [${surface}]`, async ($, on) => {
1291
3460
  const r = rig(on, $)
3461
+ r.work = stateText()
1292
3462
  await $.session.start(START)
1293
3463
  const ui = await openAndMount($, r)
1294
- await pressMap(ui, r, 14)
1295
- expect(await textOf(ui, 'map')).toBe('#14 older map')
3464
+ expect(await hasKey(ui, BOARD_KEY)).toBe(false)
3465
+ })
1296
3466
 
1297
- r.maps = [MAPS[0]]
3467
+ test(`a Resolve leaves the panel on the whiteboard [${surface}]`, async ($, on) => {
3468
+ const r = rig(on, $)
3469
+ r.work = stateText(WB_STATE)
3470
+ await $.session.start(START)
3471
+ const ui = await openAndMount($, r)
3472
+ r.work = null
1298
3473
  await r.clock.advance(FLOOR)
1299
3474
  await completeTurn($)
1300
3475
  await settle(r)
1301
- expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1302
- expect(await hasKey(ui, 'viewing')).toBe(false)
1303
- expect(await hasKey(ui, 'head-maps')).toBe(false)
3476
+ expect(await hasKey(ui, BOARD_KEY)).toBe(true)
1304
3477
  })
1305
3478
 
1306
- test(`a refresh in flight when the view changes never commits the old map's tickets [${surface}]`, async ($, on) => {
3479
+ test(`a whiteboard ticket created while a map is shown raises one toast and no switch [${surface}]`, async ($, on) => {
1307
3480
  const r = rig(on, $)
1308
- // every value the pane stores, to look at what the old answer wrote
1309
- const paneWrites: unknown[] = []
1310
- on('state.set', { plugin: 'trailhead', key: 'mapPane' }, ($$, e, next) => {
1311
- paneWrites.push(e.value)
1312
- return next(e)
1313
- })
1314
3481
  await $.session.start(START)
1315
3482
  const ui = await openAndMount($, r)
1316
- expect(await textOf(ui, 'map')).toBe('#198 trailhead mod')
1317
-
1318
- let release: () => void = () => undefined
1319
- let releaseOther: () => void = () => undefined
1320
- const held = new Promise<void>(resolve => (release = resolve))
1321
- const heldOther = new Promise<void>(resolve => (releaseOther = resolve))
1322
- r.gh = async argv => {
1323
- // the active map's open-list read is held, and the viewed map's reads too (to look at the screen between)
1324
- if (argv.includes('trailhead:map-198') && argv.includes('open')) await held
1325
- if (argv.includes('trailhead:map-14') || argv.includes('14')) await heldOther
1326
- return r.defaultGh(argv)
1327
- }
1328
- r.tickets = [issue(300, 'fresh of 198', ['trailhead:build'])]
1329
- await r.clock.advance(FLOOR)
1330
- const refreshing = completeTurn($)
1331
- await settle(r)
1332
-
1333
- await ui.press({ key: 'map-14' })
1334
- await settle(r)
1335
- const before = paneWrites.length
1336
- release()
1337
- await refreshing
1338
- await settle(r)
1339
- // the old answer is in, the new one is not: nothing of 198 is on screen, only the wait
1340
- expect(await textOf(ui, 'map')).toBe('map #14')
1341
- expect(await textOf(ui, 'loading')).toBe('Loading tickets…')
1342
- expect(await hasKey(ui, 'ticket-300')).toBe(false)
1343
- // and the store itself: the stale answer was dropped, not committed behind the render's check
1344
- const landed = paneWrites.slice(before).map(v => JSON.stringify(v))
1345
- expect(landed.some(v => v.includes('fresh of 198'))).toBe(false)
1346
- releaseOther()
3483
+ r.stdout = `${URL}/310\n`
3484
+ await $.tool.call({ tool: 'Bash', command: CREATE_WB } as never)
1347
3485
  await settle(r)
1348
- expect(await hasKey(ui, 'ticket-300')).toBe(false)
1349
- expect(await textOf(ui, 'map')).toBe('#14 older map')
1350
- expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3486
+ expect(r.toasts).toEqual(['new whiteboard ticket #310: open Whiteboard'])
3487
+ expect(await hasKey(ui, BOARD_KEY)).toBe(false)
1351
3488
  })
1352
3489
 
1353
- test(`with no active map the open maps are listed and one can be viewed [${surface}]`, async ($, on) => {
3490
+ test(`a whiteboard ticket created on the whiteboard view raises no toast [${surface}]`, async ($, on) => {
1354
3491
  const r = rig(on, $)
1355
- r.active = null
1356
3492
  await $.session.start(START)
1357
3493
  const ui = await openAndMount($, r)
1358
- await completeTurn($)
3494
+ await ui.press({ key: 'open-whiteboard' })
1359
3495
  await settle(r)
1360
- expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
1361
- expect(await textOf(ui, 'head-maps')).toBe('Maps · 2')
1362
- expect(await hasKey(ui, 'map-198')).toBe(true)
1363
- expect(await hasKey(ui, 'map-14')).toBe(true)
3496
+ r.stdout = `${URL}/310\n`
3497
+ await $.tool.call({ tool: 'Bash', command: CREATE_WB } as never)
3498
+ await settle(r)
3499
+ expect(r.toasts).toEqual([])
3500
+ })
1364
3501
 
1365
- await pressMap(ui, r, 14)
1366
- expect(await textOf(ui, 'map')).toBe('#14 older map')
1367
- expect(await textOf(ui, 'viewing-text')).toBe('viewing #14')
1368
- expect((await ui.find({ key: 'back' }))?.props).toMatchObject({ label: 'back' })
1369
- await ui.press({ key: 'back' })
3502
+ test(`a ticket created without the whiteboard label raises no toast [${surface}]`, async ($, on) => {
3503
+ const r = rig(on, $)
3504
+ await $.session.start(START)
3505
+ await openAndMount($, r)
3506
+ r.stdout = `${URL}/310\n`
3507
+ await $.tool.call({ tool: 'Bash', command: 'gh issue create -t t --label trailhead:task' } as never)
1370
3508
  await settle(r)
1371
- expect(await textOf(ui, 'empty')).toBe('No active map. Run /trailhead:map to pick one.')
1372
- expect(r.writes).toEqual([])
3509
+ expect(r.toasts).toEqual([])
1373
3510
  })
1374
3511
  }