@marcomigozzi/trailhead 0.15.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.
- package/README.it.md +45 -9
- package/README.md +46 -10
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +5 -5
- package/bin/lib/codex-projection.test.js +2 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/mod/README.md +180 -39
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +526 -92
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +21 -10
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1732 -142
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +26 -1
|
@@ -5,31 +5,40 @@ import {
|
|
|
5
5
|
axisStart,
|
|
6
6
|
burndownEnd,
|
|
7
7
|
burndownSeries,
|
|
8
|
+
CANCEL_LABEL,
|
|
8
9
|
countsLabel,
|
|
9
10
|
failureReason,
|
|
10
11
|
footerStatus,
|
|
11
12
|
groupTickets,
|
|
12
13
|
headingRoom,
|
|
13
14
|
HISTORY_LIMIT,
|
|
15
|
+
isTimerLate,
|
|
16
|
+
keepOpenTotal,
|
|
14
17
|
mapRowLayout,
|
|
15
18
|
nextViewed,
|
|
19
|
+
openTotalLabel,
|
|
20
|
+
openTotalQuery,
|
|
16
21
|
parseActiveMap,
|
|
17
22
|
parseDashboard,
|
|
18
23
|
parseHistory,
|
|
24
|
+
parseIssuesPage,
|
|
19
25
|
parseMap,
|
|
20
26
|
parseMaps,
|
|
21
27
|
parseMapsRead,
|
|
28
|
+
parseOpenTotal,
|
|
22
29
|
parseTickets,
|
|
23
30
|
parseUatParents,
|
|
24
31
|
rasterCells,
|
|
25
32
|
refreshDecision,
|
|
26
33
|
rowLayout,
|
|
27
34
|
sparkline,
|
|
35
|
+
ticketsFromRows,
|
|
28
36
|
uatParentLine,
|
|
29
37
|
viewTarget,
|
|
30
38
|
withUatParents,
|
|
31
39
|
whiteboardHeading,
|
|
32
40
|
whiteboardRowLayout,
|
|
41
|
+
withLabel,
|
|
33
42
|
} from '../hooks/mod/map-pane-view'
|
|
34
43
|
|
|
35
44
|
const issue = (over: Record<string, unknown> = {}) => ({
|
|
@@ -66,10 +75,10 @@ test('parseTickets maps a gh list to rows with type, state and holders', () => {
|
|
|
66
75
|
)
|
|
67
76
|
expect(out?.unverifiedCount).toBe(0)
|
|
68
77
|
expect(out?.tickets).toEqual([
|
|
69
|
-
{ number: 209, title: 'Burn-down view', type: 'build', state: 'frontier', assignees: [], url: 'https://github.com/o/r/issues/209', isUat: false, uatOf: null },
|
|
70
|
-
{ number: 212, title: 'Held', type: 'research', state: 'in-progress', assignees: ['alice'], url: 'https://github.com/o/r/issues/209', isUat: false, uatOf: null },
|
|
71
|
-
{ number: 211, title: 'Gated', type: 'bug', state: 'blocked', assignees: ['bob'], url: 'https://github.com/o/r/issues/209', isUat: false, uatOf: null },
|
|
72
|
-
{ number: 214, title: 'No type', type: null, state: 'frontier', assignees: [], url: 'https://github.com/o/r/issues/209', isUat: false, uatOf: null },
|
|
78
|
+
{ number: 209, title: 'Burn-down view', type: 'build', state: 'frontier', assignees: [], url: 'https://github.com/o/r/issues/209', isUat: false, isNeedsYou: false, uatOf: null },
|
|
79
|
+
{ number: 212, title: 'Held', type: 'research', state: 'in-progress', assignees: ['alice'], url: 'https://github.com/o/r/issues/209', isUat: false, isNeedsYou: false, uatOf: null },
|
|
80
|
+
{ number: 211, title: 'Gated', type: 'bug', state: 'blocked', assignees: ['bob'], url: 'https://github.com/o/r/issues/209', isUat: false, isNeedsYou: false, uatOf: null },
|
|
81
|
+
{ number: 214, title: 'No type', type: null, state: 'frontier', assignees: [], url: 'https://github.com/o/r/issues/209', isUat: false, isNeedsYou: false, uatOf: null },
|
|
73
82
|
])
|
|
74
83
|
})
|
|
75
84
|
|
|
@@ -185,6 +194,15 @@ test('parseTickets sets isUat from the trailhead:uat label, with no parent yet',
|
|
|
185
194
|
expect(t('trailhead:seed', 'trailhead:blocked', 'trailhead:uat')).toMatchObject({ isUat: true, state: 'blocked' })
|
|
186
195
|
})
|
|
187
196
|
|
|
197
|
+
test('parseTickets sets isNeedsYou from the trailhead:needs-you label and keeps the state', () => {
|
|
198
|
+
const t = (names: string[], assignees: { login: string }[] = []) => parseTickets(list(issue({ labels: labels(...names), assignees })))?.tickets[0]
|
|
199
|
+
expect(t(['trailhead:task', 'trailhead:needs-you'])).toMatchObject({ isNeedsYou: true, state: 'frontier' })
|
|
200
|
+
expect(t(['trailhead:task', 'trailhead:needs-you', 'trailhead:paused'])).toMatchObject({ isNeedsYou: true, state: 'paused' })
|
|
201
|
+
expect(t(['trailhead:task', 'trailhead:needs-you', 'trailhead:blocked'])).toMatchObject({ isNeedsYou: true, state: 'blocked' })
|
|
202
|
+
expect(t(['trailhead:task', 'trailhead:needs-you'], [{ login: 'alice' }])).toMatchObject({ isNeedsYou: true, state: 'in-progress' })
|
|
203
|
+
expect(t(['trailhead:task'])).toMatchObject({ isNeedsYou: false })
|
|
204
|
+
})
|
|
205
|
+
|
|
188
206
|
const body = (text: string, number = 5) => ({ number, body: text })
|
|
189
207
|
const uatList = (...rows: unknown[]) => JSON.stringify(rows)
|
|
190
208
|
|
|
@@ -219,7 +237,7 @@ test('parseUatParents is null on non-JSON or a non-list', () => {
|
|
|
219
237
|
})
|
|
220
238
|
|
|
221
239
|
test('withUatParents sets the parent on a UAT ticket only, and leaves the tickets alone without a read', () => {
|
|
222
|
-
const t = (number: number, isUat: boolean) => ({ number, title: '', type: null, state: 'frontier' as const, assignees: [], url: '', isUat, uatOf: null })
|
|
240
|
+
const t = (number: number, isUat: boolean) => ({ number, title: '', type: null, state: 'frontier' as const, assignees: [], url: '', isUat, isNeedsYou: false, uatOf: null })
|
|
223
241
|
const tickets = [t(1, true), t(2, false), t(3, true)]
|
|
224
242
|
const out = withUatParents(tickets, new Map([[1, { number: 9, title: 'P' }], [2, { number: 8, title: 'Q' }]]))
|
|
225
243
|
expect(out.map(x => x.uatOf)).toEqual([{ number: 9, title: 'P' }, null, null])
|
|
@@ -228,7 +246,7 @@ test('withUatParents sets the parent on a UAT ticket only, and leaves the ticket
|
|
|
228
246
|
})
|
|
229
247
|
|
|
230
248
|
test('uatParentLine draws tests <title> #<n>, fitted to the width, and nothing without a parent', () => {
|
|
231
|
-
const t = (uatOf: { number: number; title: string } | null) => ({ number: 1, title: '', type: null, state: 'frontier' as const, assignees: [], url: '', isUat: true, uatOf })
|
|
249
|
+
const t = (uatOf: { number: number; title: string } | null) => ({ number: 1, title: '', type: null, state: 'frontier' as const, assignees: [], url: '', isUat: true, isNeedsYou: false, uatOf })
|
|
232
250
|
expect(uatParentLine(t(null), 60)).toBeNull()
|
|
233
251
|
expect(uatParentLine(t({ number: 209, title: 'Burn-down view' }), 60)).toBe('tests Burn-down view #209')
|
|
234
252
|
expect(uatParentLine(t({ number: 210, title: '' }), 60)).toBe('tests #210')
|
|
@@ -239,7 +257,7 @@ test('uatParentLine draws tests <title> #<n>, fitted to the width, and nothing w
|
|
|
239
257
|
|
|
240
258
|
test('groupTickets lists a UAT ticket once, under UAT, ascending; a paused one stays under Paused', () => {
|
|
241
259
|
const t = (number: number, state: 'frontier' | 'in-progress' | 'blocked' | 'paused', isUat: boolean) => ({
|
|
242
|
-
number, title: '', type: null, state, assignees: [], url: '', isUat, uatOf: null,
|
|
260
|
+
number, title: '', type: null, state, assignees: [], url: '', isUat, isNeedsYou: false, uatOf: null,
|
|
243
261
|
})
|
|
244
262
|
const g = groupTickets([t(9, 'frontier', true), t(4, 'blocked', true), t(6, 'in-progress', true), t(7, 'paused', true), t(2, 'frontier', false), t(3, 'blocked', false)])
|
|
245
263
|
expect(g.uat.map(x => x.number)).toEqual([4, 6, 9])
|
|
@@ -247,6 +265,19 @@ test('groupTickets lists a UAT ticket once, under UAT, ascending; a paused one s
|
|
|
247
265
|
expect([g.frontier, g.inProgress, g.blocked].map(x => x.map(y => y.number))).toEqual([[2], [], [3]])
|
|
248
266
|
})
|
|
249
267
|
|
|
268
|
+
test('groupTickets lists a needs-you ticket once, under Needs you, ascending, whatever its state', () => {
|
|
269
|
+
const t = (number: number, state: 'frontier' | 'in-progress' | 'blocked' | 'paused', over: Record<string, unknown> = {}) => ({
|
|
270
|
+
number, title: '', type: null, state, assignees: [], url: '', isUat: false, isNeedsYou: true, uatOf: null, ...over,
|
|
271
|
+
})
|
|
272
|
+
const g = groupTickets([
|
|
273
|
+
t(9, 'frontier'), t(4, 'blocked'), t(6, 'in-progress', { assignees: ['alice'] }), t(7, 'paused'), t(8, 'frontier', { isUat: true }),
|
|
274
|
+
t(2, 'frontier', { isNeedsYou: false }), t(3, 'blocked', { isNeedsYou: false }),
|
|
275
|
+
])
|
|
276
|
+
expect(g.needsYou.map(x => x.number)).toEqual([4, 6, 7, 8, 9])
|
|
277
|
+
expect([g.frontier, g.inProgress, g.uat, g.paused, g.blocked].map(x => x.map(y => y.number))).toEqual([[2], [], [], [], [3]])
|
|
278
|
+
expect(groupTickets([t(1, 'frontier', { isNeedsYou: false })]).needsYou).toEqual([])
|
|
279
|
+
})
|
|
280
|
+
|
|
250
281
|
test('failureReason names one short cause', () => {
|
|
251
282
|
expect(failureReason({ error: Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }) })).toBe('gh not installed')
|
|
252
283
|
expect(failureReason({ error: new Error('spawn gh ENOENT') })).toBe('gh not installed')
|
|
@@ -270,6 +301,16 @@ test('refreshDecision runs now outside the floor and waits the remainder inside
|
|
|
270
301
|
expect(refreshDecision(500, 1000, 15000)).toEqual({ run: false, waitMs: 15000 })
|
|
271
302
|
})
|
|
272
303
|
|
|
304
|
+
test('isTimerLate holds a timer dead only once its due time is past by more than the grace', () => {
|
|
305
|
+
// in time, or late within the grace: still alive
|
|
306
|
+
expect(isTimerLate(1000, 5000, 2000)).toBe(false)
|
|
307
|
+
expect(isTimerLate(7000, 5000, 2000)).toBe(false)
|
|
308
|
+
// past the grace: the host dropped it (a refused dispatch never fires)
|
|
309
|
+
expect(isTimerLate(7001, 5000, 2000)).toBe(true)
|
|
310
|
+
// a clock that moved back never makes a timer late
|
|
311
|
+
expect(isTimerLate(0, 5000, 2000)).toBe(false)
|
|
312
|
+
})
|
|
313
|
+
|
|
273
314
|
test('ageLabel reads just now, seconds, minutes, hours', () => {
|
|
274
315
|
expect(ageLabel(0)).toBe('just now')
|
|
275
316
|
expect(ageLabel(-5)).toBe('just now')
|
|
@@ -289,10 +330,11 @@ const ticket = (over: Record<string, unknown> = {}) => ({
|
|
|
289
330
|
state: 'frontier' as const,
|
|
290
331
|
assignees: [] as string[],
|
|
291
332
|
url: 'https://github.com/o/r/issues/209',
|
|
333
|
+
isNeedsYou: false,
|
|
292
334
|
...over,
|
|
293
335
|
})
|
|
294
336
|
const layout = (t: ReturnType<typeof ticket>, over: Record<string, unknown> = {}) =>
|
|
295
|
-
rowLayout(t, { isInPlay: false, hasMarkColumn: false, pendingWork: null, columns: 60, ...over })
|
|
337
|
+
rowLayout(t, { isInPlay: false, hasMarkColumn: false, pendingWork: null, pendingPhase: null, columns: 60, ...over })
|
|
296
338
|
|
|
297
339
|
test('rowLayout: a frontier row carries the work button, the number, the title and the type', () => {
|
|
298
340
|
expect(layout(ticket())).toEqual({
|
|
@@ -306,10 +348,38 @@ test('rowLayout: a frontier row carries the work button, the number, the title a
|
|
|
306
348
|
})
|
|
307
349
|
|
|
308
350
|
test('rowLayout: the pending ticket shows queued in place of the button', () => {
|
|
309
|
-
expect(layout(ticket(), { pendingWork: 209 }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
351
|
+
expect(layout(ticket(), { pendingWork: 209, pendingPhase: 'queued' }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
310
352
|
expect(layout(ticket(), { pendingWork: 210 }).aside).toEqual({ kind: 'work', text: 'work' })
|
|
311
353
|
})
|
|
312
354
|
|
|
355
|
+
test('rowLayout: the queued aside counts the cancel button in the row width', () => {
|
|
356
|
+
const t = ticket({ title: 'x'.repeat(200) })
|
|
357
|
+
for (const columns of [30, 40, 60]) {
|
|
358
|
+
const r = layout(t, { columns, pendingWork: 209, pendingPhase: 'queued' })
|
|
359
|
+
const used = (r.mark?.length ?? 0) + r.label.length + (r.type?.length ?? 0) + (r.aside?.text.length ?? 0) + 1 + CANCEL_LABEL.length + 4 + 1
|
|
360
|
+
expect(used).toBeLessThanOrEqual(columns)
|
|
361
|
+
// Less title room than the work button leaves.
|
|
362
|
+
expect(r.label.length).toBeLessThan(layout(t, { columns }).label.length)
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('rowLayout: a starting row reads starting with no button room', () => {
|
|
367
|
+
expect(layout(ticket(), { pendingWork: 209, pendingPhase: 'starting' }).aside).toEqual({ kind: 'starting', text: 'starting' })
|
|
368
|
+
expect(layout(ticket(), { pendingWork: 209, pendingPhase: 'queued' }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
369
|
+
// a pending press with no phase yet is never offered a cancel
|
|
370
|
+
expect(layout(ticket(), { pendingWork: 209, pendingPhase: null }).aside).toEqual({ kind: 'starting', text: 'starting' })
|
|
371
|
+
// another ticket's phase never touches this row
|
|
372
|
+
expect(layout(ticket(), { pendingWork: 210, pendingPhase: 'starting' }).aside).toEqual({ kind: 'work', text: 'work' })
|
|
373
|
+
const t = ticket({ title: 'x'.repeat(200) })
|
|
374
|
+
for (const columns of [30, 40, 60]) {
|
|
375
|
+
const r = layout(t, { columns, pendingWork: 209, pendingPhase: 'starting' })
|
|
376
|
+
const used = (r.mark?.length ?? 0) + r.label.length + (r.type?.length ?? 0) + (r.aside?.text.length ?? 0) + 1
|
|
377
|
+
expect(used).toBeLessThanOrEqual(columns)
|
|
378
|
+
// No cancel button to make room for: more title than the queued row keeps.
|
|
379
|
+
expect(r.label.length).toBeGreaterThan(layout(t, { columns, pendingWork: 209, pendingPhase: 'queued' }).label.length)
|
|
380
|
+
}
|
|
381
|
+
})
|
|
382
|
+
|
|
313
383
|
test('rowLayout: holders are drawn as @login on in-progress and blocked rows', () => {
|
|
314
384
|
expect(layout(ticket({ state: 'in-progress', assignees: ['alice'] })).aside).toEqual({ kind: 'holder', text: '@alice' })
|
|
315
385
|
expect(layout(ticket({ state: 'in-progress', assignees: ['alice', 'bob'] })).aside).toEqual({ kind: 'holder', text: '@alice @bob' })
|
|
@@ -321,13 +391,58 @@ test('rowLayout: holders are drawn as @login on in-progress and blocked rows', (
|
|
|
321
391
|
expect(loose.isDim).toBe(true)
|
|
322
392
|
})
|
|
323
393
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
expect(layout(
|
|
394
|
+
const needsYou = (over: Record<string, unknown> = {}) => ticket({ isNeedsYou: true, ...over })
|
|
395
|
+
|
|
396
|
+
test('rowLayout: a needs-you row without a holder has a work button, a paused one too, never resume', () => {
|
|
397
|
+
expect(layout(needsYou()).aside).toEqual({ kind: 'work', text: 'work' })
|
|
398
|
+
expect(layout(needsYou({ state: 'paused' })).aside).toEqual({ kind: 'work', text: 'work' })
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('rowLayout: a needs-you row with any holder shows the holder, never a button', () => {
|
|
402
|
+
const holder = { kind: 'holder', text: '@alice' }
|
|
403
|
+
expect(layout(needsYou({ state: 'paused', assignees: ['alice'] })).aside).toEqual(holder)
|
|
404
|
+
expect(layout(needsYou({ state: 'blocked', assignees: ['alice'] })).aside).toEqual(holder)
|
|
405
|
+
expect(layout(needsYou({ state: 'in-progress', assignees: ['alice'] })).aside).toEqual(holder)
|
|
406
|
+
// a press pending does not turn a held row into a button either
|
|
407
|
+
expect(layout(needsYou({ state: 'paused', assignees: ['alice'] }), { pendingWork: 209 }).aside).toEqual(holder)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test('rowLayout: a needs-you row blocked with no holder shows nothing and is dim', () => {
|
|
411
|
+
const row = layout(needsYou({ state: 'blocked' }))
|
|
412
|
+
expect(row.aside).toBeNull()
|
|
413
|
+
expect(row.isDim).toBe(true)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('rowLayout: a needs-you row shows queued while its press is pending and this session in play', () => {
|
|
417
|
+
expect(layout(needsYou(), { pendingWork: 209, pendingPhase: 'queued' }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
418
|
+
expect(layout(needsYou({ state: 'paused' }), { pendingWork: 209, pendingPhase: 'queued' }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
419
|
+
expect(layout(needsYou({ state: 'in-progress', assignees: ['alice'] }), { isInPlay: true }).aside).toEqual({ kind: 'self', text: 'this session' })
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('rowLayout: a paused row has a resume button, whoever holds it', () => {
|
|
423
|
+
expect(layout(ticket({ state: 'paused', assignees: ['alice'] })).aside).toEqual({ kind: 'resume', text: 'resume' })
|
|
424
|
+
expect(layout(ticket({ state: 'paused' })).aside).toEqual({ kind: 'resume', text: 'resume' })
|
|
328
425
|
expect(layout(ticket({ state: 'paused' })).isDim).toBe(false)
|
|
329
426
|
})
|
|
330
427
|
|
|
428
|
+
test('rowLayout: a paused row reads queued while its press is pending', () => {
|
|
429
|
+
expect(layout(ticket({ number: 209, state: 'paused' }), { pendingWork: 209, pendingPhase: 'queued' }).aside).toEqual({ kind: 'queued', text: 'queued' })
|
|
430
|
+
expect(layout(ticket({ number: 209, state: 'paused' }), { pendingWork: 7 }).aside).toEqual({ kind: 'resume', text: 'resume' })
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test('rowLayout: a paused ticket in play keeps this session', () => {
|
|
434
|
+
expect(layout(ticket({ state: 'paused', assignees: ['me'] }), { isInPlay: true, hasMarkColumn: true }).aside).toEqual({ kind: 'self', text: 'this session' })
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
test('rowLayout: the resume button chrome is counted in the row width', () => {
|
|
438
|
+
const t = ticket({ title: 'x'.repeat(200), state: 'paused' })
|
|
439
|
+
for (const columns of [30, 40, 60]) {
|
|
440
|
+
const r = layout(t, { columns })
|
|
441
|
+
const used = (r.mark?.length ?? 0) + r.label.length + (r.type?.length ?? 0) + (r.aside?.text.length ?? 0) + 4 + 1
|
|
442
|
+
expect(used).toBeLessThanOrEqual(columns)
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
|
|
331
446
|
test('rowLayout: the ticket in play says this session instead of its holder', () => {
|
|
332
447
|
const r = layout(ticket({ state: 'in-progress', assignees: ['me'] }), { isInPlay: true, hasMarkColumn: true })
|
|
333
448
|
expect(r.aside).toEqual({ kind: 'self', text: 'this session' })
|
|
@@ -665,7 +780,11 @@ test('parseMapsRead gives a null whiteboard, maps intact, when its fields are mi
|
|
|
665
780
|
{ whiteboardOpen: { totalCount: 10 }, whiteboardAll: { totalCount: 9 } },
|
|
666
781
|
]
|
|
667
782
|
for (const repo of bad) {
|
|
668
|
-
expect(parseMapsRead(gql([node], {}, repo))).toEqual({
|
|
783
|
+
expect(parseMapsRead(gql([node], {}, repo))).toEqual({
|
|
784
|
+
maps: [{ number: 1, title: 'a', counts: { open: 3, total: 4 } }],
|
|
785
|
+
whiteboard: null,
|
|
786
|
+
isMapsCapped: false,
|
|
787
|
+
})
|
|
669
788
|
}
|
|
670
789
|
})
|
|
671
790
|
|
|
@@ -682,6 +801,101 @@ test('parseMapsRead is null on anything but a clean reply', () => {
|
|
|
682
801
|
expect(parseMapsRead(gql([node], { errors: [{ message: 'partial' }] }))).toBeNull()
|
|
683
802
|
})
|
|
684
803
|
|
|
804
|
+
// A maps connection with a `totalCount` beside its nodes, as the maps read asks for.
|
|
805
|
+
const gqlTotal = (nodes: unknown[], totalCount: unknown) =>
|
|
806
|
+
JSON.stringify({ data: { repository: { issues: { nodes, totalCount }, ...WB_FIELDS } } })
|
|
807
|
+
const mapNodes = (n: number) => Array.from({ length: n }, (_, i) => ({ number: i + 1, title: `m${i + 1}`, subIssuesSummary: { total: 1, completed: 0 } }))
|
|
808
|
+
|
|
809
|
+
test('parseMapsRead marks the maps read capped only when more open maps exist than were read', () => {
|
|
810
|
+
expect(parseMapsRead(gqlTotal(mapNodes(3), 3))?.isMapsCapped).toBe(false)
|
|
811
|
+
expect(parseMapsRead(gqlTotal(mapNodes(3), 4))?.isMapsCapped).toBe(true)
|
|
812
|
+
// exactly the limit, with no more behind it, is not capped
|
|
813
|
+
expect(parseMapsRead(gqlTotal(mapNodes(100), 100))?.isMapsCapped).toBe(false)
|
|
814
|
+
expect(parseMapsRead(gqlTotal(mapNodes(100), 101))?.isMapsCapped).toBe(true)
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
test('parseMapsRead without a usable totalCount falls back to the read limit', () => {
|
|
818
|
+
expect(parseMapsRead(gqlTotal(mapNodes(3), undefined))?.isMapsCapped).toBe(false)
|
|
819
|
+
expect(parseMapsRead(gqlTotal(mapNodes(100), undefined))?.isMapsCapped).toBe(true)
|
|
820
|
+
expect(parseMapsRead(gqlTotal(mapNodes(100), 'x'))?.isMapsCapped).toBe(true)
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
test('openTotalQuery counts the open tickets of each map and the whiteboard in one query', () => {
|
|
824
|
+
const q = openTotalQuery([3, 198])
|
|
825
|
+
expect(q).toContain('query($owner:String!,$repo:String!)')
|
|
826
|
+
expect(q).toContain('repository(owner:$owner,name:$repo)')
|
|
827
|
+
expect(q).toContain('m3:issues(states:OPEN,labels:["trailhead:map-3"]){totalCount}')
|
|
828
|
+
expect(q).toContain('m198:issues(states:OPEN,labels:["trailhead:map-198"]){totalCount}')
|
|
829
|
+
expect(q).toContain('wb:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}')
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
test('openTotalQuery with no open map still counts the whiteboard', () => {
|
|
833
|
+
const q = openTotalQuery([])
|
|
834
|
+
expect(q).toContain('wb:issues(states:OPEN,labels:["trailhead:whiteboard"]){totalCount}')
|
|
835
|
+
expect(q).not.toContain('trailhead:map-')
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
const totals = (counts: Record<string, unknown>) =>
|
|
839
|
+
JSON.stringify({ data: { repository: Object.fromEntries(Object.entries(counts).map(([k, v]) => [k, { totalCount: v }])) } })
|
|
840
|
+
|
|
841
|
+
test('parseOpenTotal sums every map alias and the whiteboard', () => {
|
|
842
|
+
expect(parseOpenTotal(totals({ m3: 4, m198: 7, wb: 2 }), [3, 198])).toBe(13)
|
|
843
|
+
expect(parseOpenTotal(totals({ wb: 0 }), [])).toBe(0)
|
|
844
|
+
expect(parseOpenTotal(totals({ m3: 0, wb: 5 }), [3])).toBe(5)
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
test('parseOpenTotal is null on errors, a missing alias or a bad count (never a partial sum)', () => {
|
|
848
|
+
const ok = { m3: 4, wb: 2 }
|
|
849
|
+
expect(parseOpenTotal(totals(ok), [3])).toBe(6)
|
|
850
|
+
expect(parseOpenTotal('not json', [3])).toBeNull()
|
|
851
|
+
expect(parseOpenTotal('[]', [3])).toBeNull()
|
|
852
|
+
expect(parseOpenTotal('{"data":null}', [3])).toBeNull()
|
|
853
|
+
expect(parseOpenTotal('{"data":{"repository":null}}', [3])).toBeNull()
|
|
854
|
+
expect(parseOpenTotal(JSON.stringify({ errors: [{ message: 'boom' }] }), [3])).toBeNull()
|
|
855
|
+
expect(parseOpenTotal(JSON.stringify({ ...JSON.parse(totals(ok)), errors: [{ message: 'partial' }] }), [3])).toBeNull()
|
|
856
|
+
// a map alias or the whiteboard missing
|
|
857
|
+
expect(parseOpenTotal(totals({ wb: 2 }), [3])).toBeNull()
|
|
858
|
+
expect(parseOpenTotal(totals({ m3: 4 }), [3])).toBeNull()
|
|
859
|
+
expect(parseOpenTotal(totals({ m3: 4, wb: 2 }), [3, 198])).toBeNull()
|
|
860
|
+
// negative, fractional, textual, null counts
|
|
861
|
+
for (const bad of [-1, 1.5, '4', null]) expect(parseOpenTotal(totals({ m3: bad, wb: 2 }), [3])).toBeNull()
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
test('keepOpenTotal keeps the last total only over the same maps and the same cut', () => {
|
|
865
|
+
const kept = { total: 10, maps: [14, 198], isMapsCapped: false }
|
|
866
|
+
expect(keepOpenTotal(kept, [14, 198], false)).toBe(kept)
|
|
867
|
+
expect(keepOpenTotal(kept, [14], false)).toBeNull()
|
|
868
|
+
expect(keepOpenTotal(kept, [14, 198, 300], false)).toBeNull()
|
|
869
|
+
expect(keepOpenTotal(kept, [14, 199], false)).toBeNull()
|
|
870
|
+
expect(keepOpenTotal(kept, [14, 198], true)).toBeNull()
|
|
871
|
+
expect(keepOpenTotal(null, [14, 198], false)).toBeNull()
|
|
872
|
+
expect(keepOpenTotal({ total: 3, maps: [], isMapsCapped: false }, [], false)).toEqual({ total: 3, maps: [], isMapsCapped: false })
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
test('openTotalLabel words the total across all maps and the whiteboard', () => {
|
|
876
|
+
expect(openTotalLabel(13, false, 80)).toBe('13 open across all maps + whiteboard')
|
|
877
|
+
expect(openTotalLabel(0, false, 80)).toBe('0 open across all maps + whiteboard')
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
test('openTotalLabel words a truncated maps read as a lower bound', () => {
|
|
881
|
+
expect(openTotalLabel(13, true, 80)).toBe('at least 13 open across all maps + whiteboard')
|
|
882
|
+
expect(openTotalLabel(13, true, 20)).toBe('≥13 open in total')
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
test('openTotalLabel shortens when the long form does not fit, then gives up', () => {
|
|
886
|
+
const long = '13 open across all maps + whiteboard'
|
|
887
|
+
expect(openTotalLabel(13, false, long.length)).toBe(long)
|
|
888
|
+
expect(openTotalLabel(13, false, long.length - 1)).toBe('13 open in total')
|
|
889
|
+
expect(openTotalLabel(13, false, '13 open in total'.length)).toBe('13 open in total')
|
|
890
|
+
expect(openTotalLabel(13, false, '13 open in total'.length - 1)).toBeNull()
|
|
891
|
+
expect(openTotalLabel(13, true, '≥13 open in total'.length - 1)).toBeNull()
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
test('openTotalLabel is null when the total is unknown', () => {
|
|
895
|
+
expect(openTotalLabel(null, false, 80)).toBeNull()
|
|
896
|
+
expect(openTotalLabel(null, true, 80)).toBeNull()
|
|
897
|
+
})
|
|
898
|
+
|
|
685
899
|
test('countsLabel words open over total', () => {
|
|
686
900
|
expect(countsLabel({ open: 7, total: 23 })).toBe('7 open / 23')
|
|
687
901
|
expect(countsLabel({ open: 0, total: 0 })).toBe('0 open / 0')
|
|
@@ -851,3 +1065,83 @@ test('headingRoom leaves the switch button its cells, never below zero', () => {
|
|
|
851
1065
|
expect(headingRoom(60, 'map')).toBe(52)
|
|
852
1066
|
expect(headingRoom(5, 'dashboard')).toBe(0)
|
|
853
1067
|
})
|
|
1068
|
+
|
|
1069
|
+
// One page of the GraphQL open-ticket read, as `gh api graphql` prints it.
|
|
1070
|
+
const node = (number: number, names: string[], logins: string[] = []) => ({
|
|
1071
|
+
number,
|
|
1072
|
+
title: `t${number}`,
|
|
1073
|
+
url: `https://github.com/o/r/issues/${number}`,
|
|
1074
|
+
labels: { nodes: labels(...names) },
|
|
1075
|
+
assignees: { nodes: logins.map(login => ({ login })) },
|
|
1076
|
+
})
|
|
1077
|
+
const page = (nodes: unknown[], pageInfo: unknown = { hasNextPage: false, endCursor: null }) =>
|
|
1078
|
+
JSON.stringify({ data: { repository: { issues: { nodes, pageInfo } } } })
|
|
1079
|
+
|
|
1080
|
+
test('parseIssuesPage flattens the nodes to the rows gh issue list prints, with the page info', () => {
|
|
1081
|
+
const out = parseIssuesPage(page([node(7, ['trailhead:build', 'trailhead:map-1'], ['alice'])], { hasNextPage: true, endCursor: 'abc' }))
|
|
1082
|
+
expect(out).toEqual({
|
|
1083
|
+
rows: [
|
|
1084
|
+
{
|
|
1085
|
+
number: 7,
|
|
1086
|
+
title: 't7',
|
|
1087
|
+
url: 'https://github.com/o/r/issues/7',
|
|
1088
|
+
labels: [{ name: 'trailhead:build' }, { name: 'trailhead:map-1' }],
|
|
1089
|
+
assignees: [{ login: 'alice' }],
|
|
1090
|
+
},
|
|
1091
|
+
],
|
|
1092
|
+
hasNextPage: true,
|
|
1093
|
+
endCursor: 'abc',
|
|
1094
|
+
})
|
|
1095
|
+
// the flattened rows go through the same parser as a gh list
|
|
1096
|
+
expect(parseTickets(JSON.stringify(out?.rows))?.tickets.map(t => [t.number, t.state])).toEqual([[7, 'in-progress']])
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
test('parseIssuesPage carries a string body and leaves any other out', () => {
|
|
1100
|
+
const withBody = { ...node(7, ['trailhead:uat']), body: 'UAT of: #3' }
|
|
1101
|
+
const out = parseIssuesPage(page([withBody, node(8, ['trailhead:uat']), { ...node(9, []), body: null }, { ...node(10, []), body: 5 }]))
|
|
1102
|
+
expect(out?.rows[0]).toEqual({
|
|
1103
|
+
number: 7,
|
|
1104
|
+
title: 't7',
|
|
1105
|
+
url: 'https://github.com/o/r/issues/7',
|
|
1106
|
+
labels: [{ name: 'trailhead:uat' }],
|
|
1107
|
+
assignees: [],
|
|
1108
|
+
body: 'UAT of: #3',
|
|
1109
|
+
})
|
|
1110
|
+
expect(out?.rows.slice(1).map(r => 'body' in r)).toEqual([false, false, false])
|
|
1111
|
+
})
|
|
1112
|
+
|
|
1113
|
+
test('parseIssuesPage is null on anything but a clean page', () => {
|
|
1114
|
+
expect(parseIssuesPage('nope')).toBeNull()
|
|
1115
|
+
expect(parseIssuesPage('[]')).toBeNull()
|
|
1116
|
+
expect(parseIssuesPage(JSON.stringify({ data: { repository: null } }))).toBeNull()
|
|
1117
|
+
expect(parseIssuesPage(JSON.stringify({ data: { repository: { issues: { nodes: {} } } } }))).toBeNull()
|
|
1118
|
+
// errors beside partial data, a missing or malformed pageInfo, a next page with no cursor
|
|
1119
|
+
expect(parseIssuesPage(JSON.stringify({ data: { repository: { issues: { nodes: [], pageInfo: { hasNextPage: false } } } }, errors: [{ message: 'x' }] }))).toBeNull()
|
|
1120
|
+
expect(parseIssuesPage(JSON.stringify({ data: { repository: { issues: { nodes: [] } } } }))).toBeNull()
|
|
1121
|
+
expect(parseIssuesPage(page([], { hasNextPage: 'yes', endCursor: null }))).toBeNull()
|
|
1122
|
+
expect(parseIssuesPage(page([], { hasNextPage: true, endCursor: null }))).toBeNull()
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
test('parseIssuesPage skips a malformed node and keeps an empty page', () => {
|
|
1126
|
+
expect(parseIssuesPage(page([node(1, []), null, 'x']))?.rows).toHaveLength(1)
|
|
1127
|
+
expect(parseIssuesPage(page([]))).toEqual({ rows: [], hasNextPage: false, endCursor: null })
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
test('the whiteboard read keeps the rows that carry both labels (GraphQL labels is any-of)', () => {
|
|
1131
|
+
const rows = parseIssuesPage(
|
|
1132
|
+
page([
|
|
1133
|
+
node(1, ['trailhead:whiteboard', 'trailhead:ticket', 'trailhead:bug']),
|
|
1134
|
+
node(2, ['trailhead:whiteboard']),
|
|
1135
|
+
node(3, ['trailhead:whiteboard', 'trailhead:ticket', 'trailhead:unverified']),
|
|
1136
|
+
]),
|
|
1137
|
+
)?.rows as unknown[]
|
|
1138
|
+
const parsed = ticketsFromRows(withLabel(rows, 'trailhead:ticket'), { isWhiteboard: true })
|
|
1139
|
+
expect(parsed?.tickets.map(t => t.number)).toEqual([1])
|
|
1140
|
+
expect(parsed?.unverifiedCount).toBe(1)
|
|
1141
|
+
})
|
|
1142
|
+
|
|
1143
|
+
test('ticketsFromRows says capped at the limit, as parseTickets does', () => {
|
|
1144
|
+
const rows = Array.from({ length: 200 }, (_, i) => issue({ number: i + 1 }))
|
|
1145
|
+
expect(ticketsFromRows(rows)?.isCapped).toBe(true)
|
|
1146
|
+
expect(ticketsFromRows(rows.slice(1))?.isCapped).toBe(false)
|
|
1147
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
|
|
3
|
+
import { GH_ISSUE_WRITE } from '../hooks/mod/map-pane'
|
|
4
|
+
|
|
5
|
+
// Bash commands the pane refreshes on (a write to the tracker) and ones it must ignore.
|
|
6
|
+
const WRITES = [
|
|
7
|
+
'gh issue close 5',
|
|
8
|
+
'gh --repo o/r issue close 5',
|
|
9
|
+
'gh issue comment 5 -b hi',
|
|
10
|
+
'gh issue edit 5 --add-label x',
|
|
11
|
+
'gh issue create -t t',
|
|
12
|
+
'gh issue reopen 5',
|
|
13
|
+
'gh issue delete 5',
|
|
14
|
+
'gh issue transfer 5 o/other',
|
|
15
|
+
'env GH_HOST=x gh issue close 5',
|
|
16
|
+
'command gh issue close 5',
|
|
17
|
+
'cd x && gh issue close 5',
|
|
18
|
+
'gh api -X POST repos/o/r/issues/198/sub_issues -F sub_issue_id=1',
|
|
19
|
+
'gh api repos/o/r/issues/198/sub_issues -F sub_issue_id=1',
|
|
20
|
+
'gh api repos/{owner}/{repo}/issues/5 -f state=closed',
|
|
21
|
+
'gh api --method=PATCH repos/o/r/issues/5',
|
|
22
|
+
'gh api --method PATCH repos/o/r/issues/5 -f title=x',
|
|
23
|
+
'gh api -XDELETE repos/o/r/issues/5/labels/x',
|
|
24
|
+
'gh api "repos/o/r/issues/5/comments" -f body=hi',
|
|
25
|
+
"gh api -X PUT 'repos/o/r/issues/5/lock'",
|
|
26
|
+
'gh api repos/o/r/issues/5 --input body.json',
|
|
27
|
+
'node /x/templates/trailhead-issue-body.js append --issue 198 --section "Decisions so far" --line x',
|
|
28
|
+
]
|
|
29
|
+
const READS = [
|
|
30
|
+
'gh issue list --state open',
|
|
31
|
+
'gh issue view 5',
|
|
32
|
+
'ls -la',
|
|
33
|
+
'gh pr close 4',
|
|
34
|
+
'gh api repos/o/r/issues/5',
|
|
35
|
+
'gh api repos/o/r/issues --jq length',
|
|
36
|
+
'gh api -X GET repos/o/r/issues -f labels=x',
|
|
37
|
+
'gh api --method=GET repos/o/r/issues -F per_page=1',
|
|
38
|
+
'gh api graphql -f query=query{viewer{login}}',
|
|
39
|
+
'gh api graphql -F owner={owner} -F repo={repo} -f query=x',
|
|
40
|
+
'gh api repos/o/r/pulls -f state=open',
|
|
41
|
+
'git log && echo gh issue',
|
|
42
|
+
'gh api repos/o/r/issues/5 | jq . ; echo -f x',
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
test('the write matcher takes the tracker writes of a Bash call', () => {
|
|
46
|
+
for (const command of WRITES) expect([command, GH_ISSUE_WRITE.test(command)]).toEqual([command, true])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('the write matcher leaves reads alone', () => {
|
|
50
|
+
for (const command of READS) expect([command, GH_ISSUE_WRITE.test(command)]).toEqual([command, false])
|
|
51
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { On } from 'claude-code'
|
|
2
|
+
import { expect, test } from 'claude-code/testing'
|
|
3
|
+
|
|
4
|
+
// #299: a session started outside any repo, the repo and `origin` created mid-session. The host's
|
|
5
|
+
// `session.repo()` keeps answering null (observed live on Claude Code 2.1.306), while git in the cwd
|
|
6
|
+
// already answers; the pin the pane reads must follow git.
|
|
7
|
+
const CWD = '/work/new-project'
|
|
8
|
+
const REMOTE = 'https://github.com/o/new-project.git'
|
|
9
|
+
const out = (stdout: string, exitCode = 0) => ({ value: { exitCode, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } })
|
|
10
|
+
|
|
11
|
+
test('the pin follows a repo created mid-session although session.repo() still answers null', async ($, on) => {
|
|
12
|
+
const pins: unknown[] = []
|
|
13
|
+
let isCreated = false
|
|
14
|
+
on('state.set', { plugin: 'trailhead', key: 'repoPin' }, (_$, e, next) => {
|
|
15
|
+
pins.push(e.value)
|
|
16
|
+
return next(e)
|
|
17
|
+
})
|
|
18
|
+
on('fs.read', () => ({ deny: 'ENOENT: none' }))
|
|
19
|
+
on('fs.write', () => ({ value: undefined }))
|
|
20
|
+
on('ui.status', () => ({ value: undefined }))
|
|
21
|
+
on('ui.log', () => ({ value: undefined }))
|
|
22
|
+
on('ui.panes', () => ({ value: [] }))
|
|
23
|
+
on('session.id', () => ({ value: 's1' }))
|
|
24
|
+
on('session.cwd', () => ({ value: CWD }))
|
|
25
|
+
on('session.root', () => ({ value: CWD }))
|
|
26
|
+
// The host's memoized answer: null from the start, even once the repo exists.
|
|
27
|
+
on('session.repo', () => ({ value: null }))
|
|
28
|
+
on('process.run', (_$, e) => {
|
|
29
|
+
const cmd = e.argv.join(' ')
|
|
30
|
+
if (cmd === 'git rev-parse --show-toplevel') return isCreated ? out(`${CWD}\n`) : out('', 128)
|
|
31
|
+
if (cmd === 'git remote get-url origin') return isCreated ? out(`${REMOTE}\n`) : out('', 2)
|
|
32
|
+
return out('', 1)
|
|
33
|
+
})
|
|
34
|
+
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
35
|
+
on('turn.start', (_$, e) => ({ turnId: e.turnId }))
|
|
36
|
+
on('turn.complete', () => ({ text: '' }))
|
|
37
|
+
|
|
38
|
+
await $.session.start({ cwd: CWD, surface: 'terminal', isInteractive: true })
|
|
39
|
+
isCreated = true
|
|
40
|
+
await $.turn.complete({ turnId: 't1', answer: '', durationMs: 1, isAborted: false, reason: 'answer' })
|
|
41
|
+
expect(pins.at(-1)).toEqual({ root: CWD, host: 'github.com', slug: 'o/new-project' })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('the git fallback spawns no more than it needs: nothing past a failed rev-parse, one rev-parse per read', async ($, on) => {
|
|
45
|
+
const spawned: string[] = []
|
|
46
|
+
let isCreated = false
|
|
47
|
+
on('state.set', (_$, e, next) => next(e))
|
|
48
|
+
on('fs.read', () => ({ deny: 'ENOENT: none' }))
|
|
49
|
+
on('fs.write', () => ({ value: undefined }))
|
|
50
|
+
on('ui.status', () => ({ value: undefined }))
|
|
51
|
+
on('ui.log', () => ({ value: undefined }))
|
|
52
|
+
on('ui.panes', () => ({ value: [] }))
|
|
53
|
+
on('session.id', () => ({ value: 's1' }))
|
|
54
|
+
on('session.cwd', () => ({ value: CWD }))
|
|
55
|
+
on('session.root', () => ({ value: CWD }))
|
|
56
|
+
on('session.repo', () => ({ value: null }))
|
|
57
|
+
on('process.run', (_$, e) => {
|
|
58
|
+
const cmd = e.argv.join(' ')
|
|
59
|
+
spawned.push(cmd)
|
|
60
|
+
if (cmd === 'git rev-parse --show-toplevel') return isCreated ? out(`${CWD}\n`) : out('', 128)
|
|
61
|
+
if (cmd === 'git remote get-url origin') return out(`${REMOTE}\n`)
|
|
62
|
+
return out('', 1)
|
|
63
|
+
})
|
|
64
|
+
on('session.start', (_$, e) => ({ cwd: e.cwd }))
|
|
65
|
+
on('turn.start', (_$, e) => ({ turnId: e.turnId }))
|
|
66
|
+
on('turn.complete', () => ({ text: '' }))
|
|
67
|
+
|
|
68
|
+
await $.session.start({ cwd: CWD, surface: 'terminal', isInteractive: true })
|
|
69
|
+
expect(spawned).not.toContain('git remote get-url origin')
|
|
70
|
+
spawned.length = 0
|
|
71
|
+
isCreated = true
|
|
72
|
+
await $.turn.complete({ turnId: 't1', answer: '', durationMs: 1, isAborted: false, reason: 'answer' })
|
|
73
|
+
// The fallback hands its toplevel back: each read is one rev-parse and one remote lookup, no repeat.
|
|
74
|
+
const tops = spawned.filter(c => c === 'git rev-parse --show-toplevel').length
|
|
75
|
+
expect(tops).toBeGreaterThan(0)
|
|
76
|
+
expect(spawned.filter(c => c === 'git remote get-url origin')).toHaveLength(tops)
|
|
77
|
+
})
|