spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / mods / diff / tests / register.test.ts
704 lines · 19.6 KBRaw
1import type { Args, ResultOf } from 'claude-code'
2import { describe, expect, mock, test, tier } from 'claude-code/testing'
3
4import Limits from '../hooks/limits'
5import Names from '../hooks/names'
6import Fixtures from './fixtures'
7
8tier('builtin')
9
10describe('register', () => {
11 test('the start asks nothing of git and registers /diff', async ($, on) => {
12 const world = Fixtures.inRepository(on)
13
14 await $.session.start(Fixtures.SESSION)
15
16 expect(world.runs, "no git, as the built-in's start runs none").toEqual([])
17
18 expect(await $.command.run(Fixtures.DIFF), '/diff was registered').toEqual({
19 text: 'Diff panel shown',
20 })
21 })
22
23 test("git runs at the built-in's moments and no others", async ($, on) => {
24 const world = Fixtures.inRepository(on)
25
26 let read = 0
27
28 function spawnedSince(): string[] {
29 const words = world.runs
30 .slice(read)
31 .map(run => Fixtures.gitWordOf(run.argv))
32 .filter(word => word !== Fixtures.POLL_WORD)
33
34 read = world.runs.length
35
36 return words
37 }
38
39 const edit = () =>
40 $.tool.call({
41 tool: 'Edit',
42 file_path: '/work/app.ts',
43 old_string: '1',
44 new_string: '2',
45 })
46
47 on('tool.call', () => ({ result: 'done' }))
48 on('turn.complete', ($, e) => ({ text: e.answer }))
49 on('command.run', { command: 'clear' }, () => ({}))
50
51 await $.session.start(Fixtures.SESSION)
52 await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
53 await world.clock.advance(Fixtures.SETTLE_MS)
54
55 expect(spawnedSince(), 'the start and the footer: nothing').toEqual([])
56
57 await edit()
58 await world.clock.advance(Fixtures.SETTLE_MS)
59
60 expect(
61 spawnedSince(),
62 'an edit with no room for a pane: nothing, the width is read first',
63 ).toEqual([])
64
65 await $.command.run(Fixtures.DIFF)
66 await world.clock.advance(Fixtures.SETTLE_MS)
67
68 expect(spawnedSince(), '/diff: found, then one fetch for the pane').toEqual(
69 [
70 'rev-parse --show-toplevel',
71 'status',
72 'diff --shortstat',
73 'diff --numstat',
74 'ls-files',
75 'diff -- app.ts',
76 ],
77 )
78
79 await edit()
80 await world.clock.advance(Fixtures.SETTLE_MS)
81
82 expect(spawnedSince(), 'an edit, the pane open: one fetch').toEqual([
83 'diff --shortstat',
84 'diff --numstat',
85 'ls-files',
86 'diff -- app.ts',
87 ])
88
89 await $.tool.call({ tool: 'Bash', command: 'make' })
90 await world.clock.advance(Fixtures.SETTLE_MS)
91
92 expect(spawnedSince(), 'a shell command, the pane open: one fetch').toEqual(
93 ['diff --shortstat', 'diff --numstat', 'ls-files', 'diff -- app.ts'],
94 )
95
96 await $.turn.complete({
97 answer: 'done',
98 durationMs: 1,
99 isAborted: false,
100 turnId: 't1',
101 reason: 'answer',
102 })
103
104 await world.clock.advance(Fixtures.SETTLE_MS)
105
106 expect(spawnedSince(), "a turn's end: nothing").toEqual([])
107
108 await $.command.run(Fixtures.DIFF)
109 await edit()
110 await $.tool.call({ tool: 'Bash', command: 'make' })
111 await world.clock.advance(Fixtures.SETTLE_MS)
112
113 expect(spawnedSince(), 'the pane closed by hand: nothing').toEqual([])
114
115 await $.command.run(Fixtures.CLEAR)
116 await $.command.run(Fixtures.DIFF)
117 await world.clock.advance(Fixtures.SETTLE_MS)
118
119 expect(spawnedSince()[0], '/clear forgot the repository').toBe(
120 'rev-parse --show-toplevel',
121 )
122 })
123
124 test('a shell command the tool held read-only fetches nothing', async ($, on) => {
125 const world = Fixtures.inRepository(on)
126
127 const fetchesSince = (read: number) =>
128 world.runs.slice(read).filter(run => run.argv.includes('--numstat'))
129 .length
130
131 on('tool.call', ($, e) =>
132 e.tool === 'Bash' && e.command === 'ls'
133 ? Fixtures.READ_ONLY_ANSWER
134 : { result: 'done' },
135 )
136
137 await $.session.start(Fixtures.SESSION)
138 await $.command.run(Fixtures.DIFF)
139 await world.clock.advance(Fixtures.SETTLE_MS)
140
141 const opened = world.runs.length
142
143 await $.tool.call({ tool: 'Bash', command: 'ls' })
144 await world.clock.advance(Fixtures.SETTLE_MS)
145
146 expect(
147 fetchesSince(opened),
148 "read-only, the pane open: nothing, as the built-in's touch is gated",
149 ).toBe(0)
150
151 const listed = world.runs.length
152
153 await $.tool.call({ tool: 'Bash', command: 'make' })
154 await world.clock.advance(Fixtures.SETTLE_MS)
155
156 expect(fetchesSince(listed), 'a command that may write: one fetch').toBe(1)
157 })
158
159 test('/diff whose probe never answers probes once more', async ($, on) => {
160 const probes: (readonly string[])[] = []
161 const clock = Fixtures.startsSession(on)
162
163 on('process.run', async ($, e) => {
164 probes.push(e.argv)
165
166 if (probes.length === 1) {
167 await clock.sleep(Limits.GIT_TIMEOUT_MS)
168
169 return { deny: Fixtures.GIT_HUNG }
170 }
171
172 return { value: Fixtures.NOT_A_REPOSITORY }
173 })
174
175 await $.session.start(Fixtures.SESSION)
176
177 const ran = $.command.run(Fixtures.DIFF)
178
179 await clock.settle()
180
181 expect(probes, "this /diff's probe, unanswered yet").toHaveLength(1)
182
183 await clock.advance(Limits.GIT_TIMEOUT_MS)
184
185 expect(await ran).toEqual({
186 text: expect.stringContaining("isn't in a git repository"),
187 })
188
189 expect(probes, 'then one more, which answered').toHaveLength(2)
190 })
191
192 test('two /diff typed together probe the repository once', async ($, on) => {
193 const world = Fixtures.inRepository(on)
194
195 await $.session.start(Fixtures.SESSION)
196
197 const [first, second] = await Promise.all([
198 $.command.run(Fixtures.DIFF),
199 $.command.run(Fixtures.DIFF),
200 ])
201
202 const probes = world.runs.filter(run =>
203 run.argv.includes('--show-toplevel'),
204 )
205
206 expect(first.text).toMatch(/^Diff panel (shown|hidden)$/)
207 expect(second.text).toMatch(/^Diff panel (shown|hidden)$/)
208 expect(probes, 'the second joined the probe in flight').toHaveLength(1)
209 })
210
211 test('outside a repository /diff says so, opens nothing', async ($, on) => {
212 const opened: string[] = []
213
214 Fixtures.startsSession(on)
215 on('process.run', () => ({ value: Fixtures.NOT_A_REPOSITORY }))
216
217 on('ui.open', ($, e, next) => {
218 opened.push(e.id)
219
220 return next(e)
221 })
222
223 await $.session.start(Fixtures.SESSION)
224
225 const { text } = await $.command.run(Fixtures.DIFF)
226
227 expect(text).toContain("isn't in a git repository")
228 expect(opened).toEqual([])
229 })
230
231 test('a git that never answers is not "no repository"', async ($, on) => {
232 Fixtures.startsSession(on)
233 on('process.run', () => ({ deny: Fixtures.GIT_HUNG }))
234
235 await $.session.start(Fixtures.SESSION)
236
237 const { text } = await $.command.run(Fixtures.DIFF)
238
239 expect(text).toContain("git didn't answer")
240 })
241
242 test('when the built-in holds /diff, the mod stands down', async ($, on) => {
243 const logged: string[] = []
244 const runs: Args<'process.run'>[] = []
245
246 mock.clock(on)
247 on('session.start', ($, e) => ({ cwd: e.cwd }))
248 on('command.register', () => ({ deny: Fixtures.BUILTIN_HOLDS }))
249 on('command.run', () => ({ text: 'the built-in /diff ran' }))
250 on('tool.call', () => ({ result: 'edited' }))
251
252 on('process.run', ($, e) => {
253 runs.push(e)
254
255 return { value: Fixtures.gitIn(e.argv) }
256 })
257
258 on('ui.log', ($, e) => {
259 logged.push(e.text)
260
261 return { value: undefined }
262 })
263
264 await $.session.start(Fixtures.SESSION)
265
266 expect(await $.command.run(Fixtures.DIFF)).toEqual({
267 text: 'the built-in /diff ran',
268 })
269
270 await $.tool.call({
271 tool: 'Edit',
272 file_path: '/work/app.ts',
273 old_string: '1',
274 new_string: '2',
275 })
276
277 expect(logged).toEqual([])
278 expect(runs, 'idle: no git for /diff or the edit').toEqual([])
279 })
280
281 test('a refusal the built-in did not cause is said aloud', async ($, on) => {
282 const logged: string[] = []
283
284 mock.clock(on)
285 on('session.start', ($, e) => ({ cwd: e.cwd }))
286
287 on('command.register', () => ({
288 deny: '32 commands are registered already',
289 }))
290
291 on('ui.log', ($, e) => {
292 logged.push(e.text)
293
294 return { value: undefined }
295 })
296
297 await $.session.start(Fixtures.SESSION)
298
299 expect(logged).toEqual([
300 'could not register /diff: diff: $.command.register: 32 commands are ' +
301 'registered already; the diff panel is unavailable this session',
302 ])
303 })
304
305 test('/diff opens the pane, unfocused, and says so', async ($, on) => {
306 const world = Fixtures.inRepository(on)
307
308 await $.session.start(Fixtures.SESSION)
309
310 expect(world.runs, 'the start ran nothing').toEqual([])
311
312 expect(await $.command.run(Fixtures.DIFF)).toEqual({
313 text: 'Diff panel shown',
314 })
315
316 expect(world.runs[0]?.argv, '/diff found the repository first').toContain(
317 '--show-toplevel',
318 )
319
320 expect(world.opened).toEqual([
321 { id: 'diff', title: 'Diff', holdToasts: true },
322 ])
323
324 await world.clock.advance(Fixtures.SETTLE_MS)
325
326 const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
327
328 expect(drawn).toContain('1 file changed')
329 expect(drawn).toContain('app.ts')
330 })
331
332 test('off fullscreen, /diff opens the dialog, focused', async ($, on) => {
333 const world = Fixtures.inRepository(on)
334
335 await $.session.start(Fixtures.SESSION)
336
337 expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({})
338
339 expect(world.opened[0]).toEqual({
340 id: 'diff',
341 title: 'Diff',
342 holdToasts: true,
343 closeOnEscape: true,
344 rows: expect.any(Number),
345 focus: true,
346 })
347
348 expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({
349 text: 'Diff dialog dismissed',
350 })
351 })
352
353 test('/diff again closes the pane and says so', async ($, on) => {
354 const world = Fixtures.inRepository(on)
355
356 await $.session.start(Fixtures.SESSION)
357 await $.command.run(Fixtures.DIFF)
358
359 expect(await $.command.run(Fixtures.DIFF)).toEqual({
360 text: 'Diff panel hidden',
361 })
362
363 expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
364 })
365
366 test('a wide terminal opens the pane at the first edit', async ($, on) => {
367 const world = Fixtures.inRepository(on)
368
369 on('tool.call', () => ({ result: 'edited' }))
370
371 await $.session.start(Fixtures.SESSION)
372 await $.ui.render(Fixtures.HINT)
373
374 await $.tool.call({
375 tool: 'Edit',
376 file_path: '/work/app.ts',
377 old_string: '1',
378 new_string: '2',
379 })
380
381 await world.clock.advance(Fixtures.SETTLE_MS)
382
383 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
384 })
385
386 test('on the main screen the first edit opens nothing, and /diff still opens the dialog', async ($, on) => {
387 const world = Fixtures.inRepository(on)
388
389 on('tool.call', () => ({ result: 'edited' }))
390
391 await $.session.start(Fixtures.SESSION)
392 await $.ui.render(Fixtures.MAIN_SCREEN_HINT)
393
394 await $.tool.call({
395 tool: 'Edit',
396 file_path: '/work/app.ts',
397 old_string: '1',
398 new_string: '2',
399 })
400
401 await world.clock.advance(Fixtures.SETTLE_MS)
402
403 expect(world.opened, 'a pane there would be an unasked dialog').toEqual([])
404 expect(world.runs, 'so nothing was asked of the repository').toEqual([])
405
406 expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({})
407
408 expect(
409 world.opened[0],
410 "the person's /diff opens the dialog, not dismisses one",
411 ).toMatchObject({ id: 'diff', focus: true })
412 })
413
414 test('a surface that does not say whether it docks a pane opens nothing at the first edit', async ($, on) => {
415 const world = Fixtures.inRepository(on)
416
417 on('tool.call', () => ({ result: 'edited' }))
418
419 await $.session.start(Fixtures.SESSION)
420 await $.ui.render(Fixtures.UNSAID_HINT)
421
422 await $.tool.call({
423 tool: 'Edit',
424 file_path: '/work/app.ts',
425 old_string: '1',
426 new_string: '2',
427 })
428
429 await world.clock.advance(Fixtures.SETTLE_MS)
430
431 expect(world.opened, 'unknown is not a dock').toEqual([])
432
433 await $.ui.render(Fixtures.HINT)
434
435 await $.tool.call({
436 tool: 'Edit',
437 file_path: '/work/app.ts',
438 old_string: '2',
439 new_string: '3',
440 })
441
442 await world.clock.advance(Fixtures.SETTLE_MS)
443
444 expect(
445 world.opened.map(pane => pane.id),
446 'once a drawing says the layout docks, the next edit opens it',
447 ).toEqual(['diff'])
448 })
449
450 test('an edit that failed or was refused opens nothing', async ($, on) => {
451 const world = Fixtures.inRepository(on)
452
453 const answers: ResultOf['tool.call'][] = [
454 { isError: true, result: 'no such text' },
455 { deny: 'not allowed' },
456 ]
457
458 const edit = () =>
459 $.tool.call({
460 tool: 'Edit',
461 file_path: '/work/app.ts',
462 old_string: '1',
463 new_string: '2',
464 })
465
466 on('tool.call', () => answers.shift() ?? { result: 'edited' })
467
468 await $.session.start(Fixtures.SESSION)
469 await $.ui.render(Fixtures.HINT)
470 await edit()
471 await edit()
472 await world.clock.advance(Fixtures.SETTLE_MS)
473
474 expect(world.opened, 'neither edit landed').toEqual([])
475 expect(world.runs, 'so nothing asked after the repository').toEqual([])
476
477 await edit()
478 await world.clock.advance(Fixtures.SETTLE_MS)
479
480 expect(
481 world.opened.map(pane => pane.id),
482 'the third did',
483 ).toEqual(['diff'])
484 })
485
486 test('/clear closes the pane it finds open', async ($, on) => {
487 const world = Fixtures.inRepository(on)
488
489 on('command.run', { command: 'clear' }, () => ({}))
490
491 await $.session.start(Fixtures.SESSION)
492 await $.command.run(Fixtures.DIFF)
493 await $.command.run(Fixtures.CLEAR)
494
495 expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
496 })
497
498 test('ask attaches the hunks on screen and calls no tool', async ($, on) => {
499 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
500 const called: Args<'tool.call'>[] = []
501
502 on('tool.call', ($, e) => {
503 called.push(e)
504
505 return { result: 'called' }
506 })
507
508 on('prompt.submit', ($, e) => ({ text: e.text, context: e.context }))
509
510 await $.session.start(Fixtures.WORKTREE_SESSION)
511 await $.command.run(Fixtures.DIFF)
512 await world.clock.advance(Fixtures.SETTLE_MS)
513 await $.ui.render(Fixtures.PANE)
514 await $.ui.press({ plugin: 'diff', key: 'ask:.env' })
515 await world.clock.settle()
516
517 const armed = Fixtures.jsonOf(await $.ui.render(Fixtures.PANE))
518
519 const submitted = await $.prompt.submit(Fixtures.typedPromptOf('why?'))
520
521 expect(armed).toContain('asked ✓')
522 expect(called).toEqual([])
523
524 expect(world.statuses.at(-2)).toBe(
525 '.env rides your next prompt (press asked ✓ to drop it)',
526 )
527
528 expect(submitted).toMatchObject({
529 context: [
530 'The user attached the diff of .env from the diff pane to this ' +
531 'prompt:\n@@ -1 +1 @@\n-KEY=old\n+KEY=new',
532 ],
533 })
534 })
535
536 test('an ask with no room in the context is dropped', async ($, on) => {
537 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
538 const full = 'x'.repeat(Limits.PROMPT_CONTEXT_MAX_CHARS)
539 const reached: (readonly string[] | undefined)[] = []
540
541 on('prompt.submit', ($, e) => {
542 reached.push(e.context)
543
544 return { text: e.text }
545 })
546
547 await $.session.start(Fixtures.WORKTREE_SESSION)
548 await $.command.run(Fixtures.DIFF)
549 await world.clock.advance(Fixtures.SETTLE_MS)
550 await $.ui.render(Fixtures.PANE)
551 await $.ui.press({ plugin: 'diff', key: 'ask:.env' })
552 await world.clock.settle()
553
554 await $.prompt.submit(Fixtures.typedPromptOf('why?', [full]))
555 await $.prompt.submit(Fixtures.typedPromptOf('and now?'))
556
557 expect(reached).toEqual([[full], undefined])
558
559 expect(world.statuses.at(-1)).toBe(
560 ".env's diff did not fit in the prompt and was dropped",
561 )
562 })
563
564 test('a worktree opens on the base it kept', async ($, on) => {
565 const world = Fixtures.inRepository(on, Fixtures.oneSecret(), {
566 'base:/main/wt': 'uncommitted',
567 })
568
569 await $.session.start(Fixtures.WORKTREE_SESSION)
570 await $.command.run(Fixtures.DIFF)
571 await world.clock.advance(Fixtures.SETTLE_MS)
572
573 expect(
574 Fixtures.stringsOf(await $.ui.render(Fixtures.PANE)),
575 "the built-in's base line under the header",
576 ).toContain('uncommitted (vs HEAD)')
577 })
578
579 test('no repository is an answer kept until /clear', async ($, on) => {
580 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
581 Fixtures.oneSecret()
582
583 const script: Record<string, string> = notYet
584 const world = Fixtures.inRepository(on, script)
585
586 const probesOf = () =>
587 world.runs.filter(run => run.argv.includes('--show-toplevel')).length
588
589 on('command.run', { command: 'clear' }, () => ({}))
590
591 await $.session.start(Fixtures.WORKTREE_SESSION)
592
593 const before = await $.command.run(Fixtures.DIFF)
594
595 script['rev-parse --path-format=absolute'] = worktree
596
597 const again = await $.command.run(Fixtures.DIFF)
598 const probesKept = probesOf()
599
600 await $.command.run(Fixtures.CLEAR)
601
602 const after = await $.command.run(Fixtures.DIFF)
603
604 await world.clock.advance(Fixtures.SETTLE_MS)
605 await $.ui.render(Fixtures.PANE)
606
607 script['rev-parse --path-format=absolute'] = Fixtures.ELSEWHERE_LINES
608
609 const third = $.command.run(Fixtures.DIFF)
610
611 await world.clock.advance(Fixtures.SETTLE_MS)
612 await third
613
614 expect(before.text).toContain("isn't in a git repository")
615
616 expect(again.text, "kept, as the built-in's answer is").toContain(
617 "isn't in a git repository",
618 )
619
620 expect(probesKept, 'one probe answered both').toBe(1)
621 expect(after.text, '/clear forgot it').toBe('Diff panel shown')
622 expect(world.opened[0]?.id).toBe('diff')
623 expect(probesOf(), 'then one more, pinned from there on').toBe(2)
624
625 expect(
626 world.runs.some(run => run.argv.includes('--git-dir=/else/.git')),
627 ).toBe(false)
628
629 expect(
630 world.runs
631 .filter(run => run.argv.includes('--numstat'))
632 .every(run => run.argv[1] === Fixtures.PINNED_LEAD[1]),
633 ).toBe(true)
634 })
635
636 test('a repository that appears later opens on first edit', async ($, on) => {
637 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
638 Fixtures.oneSecret()
639
640 const script: Record<string, string> = notYet
641 const world = Fixtures.inRepository(on, script)
642
643 on('tool.call', () => ({ result: 'edited' }))
644
645 await $.session.start(Fixtures.WORKTREE_SESSION)
646 await $.ui.render(Fixtures.HINT)
647
648 script['rev-parse --path-format=absolute'] = worktree
649
650 await $.tool.call({
651 tool: 'Edit',
652 file_path: '/main/wt/.env',
653 old_string: 'old',
654 new_string: 'new',
655 })
656
657 await world.clock.advance(Fixtures.SETTLE_MS)
658
659 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
660 })
661
662 test('a git that cannot start is an answer, not a timeout', async ($, on) => {
663 const runs: Args<'process.run'>[] = []
664
665 Fixtures.startsSession(on)
666
667 on('process.run', ($, e) => {
668 runs.push(e)
669
670 return e.argv.includes('--show-toplevel')
671 ? { deny: Fixtures.FAILED_START }
672 : { value: Fixtures.gitIn(e.argv, Fixtures.oneSecret()) }
673 })
674
675 await $.session.start(Fixtures.WORKTREE_SESSION)
676
677 const { text } = await $.command.run(Fixtures.DIFF)
678 const probes = runs.filter(run => run.argv.includes('--show-toplevel'))
679
680 expect(text).toContain("isn't in a git repository")
681 expect(probes, "this /diff's alone; no retry").toHaveLength(1)
682 })
683
684 test('/diff on a narrow terminal: the resize line', async ($, on) => {
685 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
686
687 await $.session.start(Fixtures.WORKTREE_SESSION)
688
689 const narrow = await $.command.run(
690 Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS - 1),
691 )
692
693 const openedNarrow = [...world.opened]
694
695 await $.command.run(Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS))
696
697 expect(narrow.text).toContain(
698 'Resize your terminal to at least 110 columns to show the diff panel',
699 )
700
701 expect(openedNarrow).toEqual([])
702 expect(world.opened.map(pane => pane.id)).toEqual([Names.PANE_ID])
703 })
704})