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
605 lines · 16.8 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('/diff whose probe never answers probes once more', async ($, on) => {
125 const probes: (readonly string[])[] = []
126 const clock = Fixtures.startsSession(on)
127
128 on('process.run', async ($, e) => {
129 probes.push(e.argv)
130
131 if (probes.length === 1) {
132 await clock.sleep(Limits.GIT_TIMEOUT_MS)
133
134 return { deny: Fixtures.GIT_HUNG }
135 }
136
137 return { value: Fixtures.NOT_A_REPOSITORY }
138 })
139
140 await $.session.start(Fixtures.SESSION)
141
142 const ran = $.command.run(Fixtures.DIFF)
143
144 await clock.settle()
145
146 expect(probes, "this /diff's probe, unanswered yet").toHaveLength(1)
147
148 await clock.advance(Limits.GIT_TIMEOUT_MS)
149
150 expect(await ran).toEqual({
151 text: expect.stringContaining("isn't in a git repository"),
152 })
153
154 expect(probes, 'then one more, which answered').toHaveLength(2)
155 })
156
157 test('two /diff typed together probe the repository once', async ($, on) => {
158 const world = Fixtures.inRepository(on)
159
160 await $.session.start(Fixtures.SESSION)
161
162 const [first, second] = await Promise.all([
163 $.command.run(Fixtures.DIFF),
164 $.command.run(Fixtures.DIFF),
165 ])
166
167 const probes = world.runs.filter(run =>
168 run.argv.includes('--show-toplevel'),
169 )
170
171 expect(first.text).toMatch(/^Diff panel (shown|hidden)$/)
172 expect(second.text).toMatch(/^Diff panel (shown|hidden)$/)
173 expect(probes, 'the second joined the probe in flight').toHaveLength(1)
174 })
175
176 test('outside a repository /diff says so, opens nothing', async ($, on) => {
177 const opened: string[] = []
178
179 Fixtures.startsSession(on)
180 on('process.run', () => ({ value: Fixtures.NOT_A_REPOSITORY }))
181
182 on('ui.open', ($, e, next) => {
183 opened.push(e.id)
184
185 return next(e)
186 })
187
188 await $.session.start(Fixtures.SESSION)
189
190 const { text } = await $.command.run(Fixtures.DIFF)
191
192 expect(text).toContain("isn't in a git repository")
193 expect(opened).toEqual([])
194 })
195
196 test('a git that never answers is not "no repository"', async ($, on) => {
197 Fixtures.startsSession(on)
198 on('process.run', () => ({ deny: Fixtures.GIT_HUNG }))
199
200 await $.session.start(Fixtures.SESSION)
201
202 const { text } = await $.command.run(Fixtures.DIFF)
203
204 expect(text).toContain("git didn't answer")
205 })
206
207 test('when the built-in holds /diff, the mod stands down', async ($, on) => {
208 const logged: string[] = []
209 const runs: Args<'process.run'>[] = []
210
211 mock.clock(on)
212 on('session.start', ($, e) => ({ cwd: e.cwd }))
213 on('command.register', () => ({ deny: Fixtures.BUILTIN_HOLDS }))
214 on('command.run', () => ({ text: 'the built-in /diff ran' }))
215 on('tool.call', () => ({ result: 'edited' }))
216
217 on('process.run', ($, e) => {
218 runs.push(e)
219
220 return { value: Fixtures.gitIn(e.argv) }
221 })
222
223 on('ui.log', ($, e) => {
224 logged.push(e.text)
225
226 return { value: undefined }
227 })
228
229 await $.session.start(Fixtures.SESSION)
230
231 expect(await $.command.run(Fixtures.DIFF)).toEqual({
232 text: 'the built-in /diff ran',
233 })
234
235 await $.tool.call({
236 tool: 'Edit',
237 file_path: '/work/app.ts',
238 old_string: '1',
239 new_string: '2',
240 })
241
242 expect(logged).toEqual([])
243 expect(runs, 'idle: no git for /diff or the edit').toEqual([])
244 })
245
246 test('a refusal the built-in did not cause is said aloud', async ($, on) => {
247 const logged: string[] = []
248
249 mock.clock(on)
250 on('session.start', ($, e) => ({ cwd: e.cwd }))
251
252 on('command.register', () => ({
253 deny: '32 commands are registered already',
254 }))
255
256 on('ui.log', ($, e) => {
257 logged.push(e.text)
258
259 return { value: undefined }
260 })
261
262 await $.session.start(Fixtures.SESSION)
263
264 expect(logged).toEqual([
265 'could not register /diff: diff: $.command.register: 32 commands are ' +
266 'registered already; the diff panel is unavailable this session',
267 ])
268 })
269
270 test('/diff opens the pane, unfocused, and says so', async ($, on) => {
271 const world = Fixtures.inRepository(on)
272
273 await $.session.start(Fixtures.SESSION)
274
275 expect(world.runs, 'the start ran nothing').toEqual([])
276
277 expect(await $.command.run(Fixtures.DIFF)).toEqual({
278 text: 'Diff panel shown',
279 })
280
281 expect(world.runs[0]?.argv, '/diff found the repository first').toContain(
282 '--show-toplevel',
283 )
284
285 expect(world.opened).toEqual([
286 { id: 'diff', title: 'Diff', holdToasts: true },
287 ])
288
289 await world.clock.advance(Fixtures.SETTLE_MS)
290
291 const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
292
293 expect(drawn).toContain('1 file changed')
294 expect(drawn).toContain('app.ts')
295 })
296
297 test('off fullscreen, /diff opens the dialog, focused', async ($, on) => {
298 const world = Fixtures.inRepository(on)
299
300 await $.session.start(Fixtures.SESSION)
301
302 expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({})
303
304 expect(world.opened[0]).toEqual({
305 id: 'diff',
306 title: 'Diff',
307 holdToasts: true,
308 closeOnEscape: true,
309 rows: expect.any(Number),
310 focus: true,
311 })
312
313 expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({
314 text: 'Diff dialog dismissed',
315 })
316 })
317
318 test('/diff again closes the pane and says so', async ($, on) => {
319 const world = Fixtures.inRepository(on)
320
321 await $.session.start(Fixtures.SESSION)
322 await $.command.run(Fixtures.DIFF)
323
324 expect(await $.command.run(Fixtures.DIFF)).toEqual({
325 text: 'Diff panel hidden',
326 })
327
328 expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
329 })
330
331 test('a wide terminal opens the pane at the first edit', async ($, on) => {
332 const world = Fixtures.inRepository(on)
333
334 on('tool.call', () => ({ result: 'edited' }))
335
336 await $.session.start(Fixtures.SESSION)
337 await $.ui.render(Fixtures.HINT)
338
339 await $.tool.call({
340 tool: 'Edit',
341 file_path: '/work/app.ts',
342 old_string: '1',
343 new_string: '2',
344 })
345
346 await world.clock.advance(Fixtures.SETTLE_MS)
347
348 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
349 })
350
351 test('an edit that failed or was refused opens nothing', async ($, on) => {
352 const world = Fixtures.inRepository(on)
353
354 const answers: ResultOf['tool.call'][] = [
355 { isError: true, result: 'no such text' },
356 { deny: 'not allowed' },
357 ]
358
359 const edit = () =>
360 $.tool.call({
361 tool: 'Edit',
362 file_path: '/work/app.ts',
363 old_string: '1',
364 new_string: '2',
365 })
366
367 on('tool.call', () => answers.shift() ?? { result: 'edited' })
368
369 await $.session.start(Fixtures.SESSION)
370 await $.ui.render(Fixtures.HINT)
371 await edit()
372 await edit()
373 await world.clock.advance(Fixtures.SETTLE_MS)
374
375 expect(world.opened, 'neither edit landed').toEqual([])
376 expect(world.runs, 'so nothing asked after the repository').toEqual([])
377
378 await edit()
379 await world.clock.advance(Fixtures.SETTLE_MS)
380
381 expect(
382 world.opened.map(pane => pane.id),
383 'the third did',
384 ).toEqual(['diff'])
385 })
386
387 test('/clear closes the pane it finds open', async ($, on) => {
388 const world = Fixtures.inRepository(on)
389
390 on('command.run', { command: 'clear' }, () => ({}))
391
392 await $.session.start(Fixtures.SESSION)
393 await $.command.run(Fixtures.DIFF)
394 await $.command.run(Fixtures.CLEAR)
395
396 expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
397 })
398
399 test('ask attaches the hunks on screen and calls no tool', async ($, on) => {
400 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
401 const called: Args<'tool.call'>[] = []
402
403 on('tool.call', ($, e) => {
404 called.push(e)
405
406 return { result: 'called' }
407 })
408
409 on('prompt.submit', ($, e) => ({ text: e.text, context: e.context }))
410
411 await $.session.start(Fixtures.WORKTREE_SESSION)
412 await $.command.run(Fixtures.DIFF)
413 await world.clock.advance(Fixtures.SETTLE_MS)
414 await $.ui.render(Fixtures.PANE)
415 await $.ui.press({ plugin: 'diff', key: 'ask:.env' })
416 await world.clock.settle()
417
418 const armed = Fixtures.jsonOf(await $.ui.render(Fixtures.PANE))
419
420 const submitted = await $.prompt.submit(Fixtures.typedPromptOf('why?'))
421
422 expect(armed).toContain('asked ✓')
423 expect(called).toEqual([])
424
425 expect(world.statuses.at(-2)).toBe(
426 '.env rides your next prompt (press asked ✓ to drop it)',
427 )
428
429 expect(submitted).toMatchObject({
430 context: [
431 'The user attached the diff of .env from the diff pane to this ' +
432 'prompt:\n@@ -1 +1 @@\n-KEY=old\n+KEY=new',
433 ],
434 })
435 })
436
437 test('an ask with no room in the context is dropped', async ($, on) => {
438 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
439 const full = 'x'.repeat(Limits.PROMPT_CONTEXT_MAX_CHARS)
440 const reached: (readonly string[] | undefined)[] = []
441
442 on('prompt.submit', ($, e) => {
443 reached.push(e.context)
444
445 return { text: e.text }
446 })
447
448 await $.session.start(Fixtures.WORKTREE_SESSION)
449 await $.command.run(Fixtures.DIFF)
450 await world.clock.advance(Fixtures.SETTLE_MS)
451 await $.ui.render(Fixtures.PANE)
452 await $.ui.press({ plugin: 'diff', key: 'ask:.env' })
453 await world.clock.settle()
454
455 await $.prompt.submit(Fixtures.typedPromptOf('why?', [full]))
456 await $.prompt.submit(Fixtures.typedPromptOf('and now?'))
457
458 expect(reached).toEqual([[full], undefined])
459
460 expect(world.statuses.at(-1)).toBe(
461 ".env's diff did not fit in the prompt and was dropped",
462 )
463 })
464
465 test('a worktree opens on the base it kept', async ($, on) => {
466 const world = Fixtures.inRepository(on, Fixtures.oneSecret(), {
467 'base:/main/wt': 'uncommitted',
468 })
469
470 await $.session.start(Fixtures.WORKTREE_SESSION)
471 await $.command.run(Fixtures.DIFF)
472 await world.clock.advance(Fixtures.SETTLE_MS)
473
474 expect(
475 Fixtures.stringsOf(await $.ui.render(Fixtures.PANE)),
476 "the built-in's base line under the header",
477 ).toContain('uncommitted (vs HEAD)')
478 })
479
480 test('no repository is an answer kept until /clear', async ($, on) => {
481 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
482 Fixtures.oneSecret()
483
484 const script: Record<string, string> = notYet
485 const world = Fixtures.inRepository(on, script)
486
487 const probesOf = () =>
488 world.runs.filter(run => run.argv.includes('--show-toplevel')).length
489
490 on('command.run', { command: 'clear' }, () => ({}))
491
492 await $.session.start(Fixtures.WORKTREE_SESSION)
493
494 const before = await $.command.run(Fixtures.DIFF)
495
496 script['rev-parse --path-format=absolute'] = worktree
497
498 const again = await $.command.run(Fixtures.DIFF)
499 const probesKept = probesOf()
500
501 await $.command.run(Fixtures.CLEAR)
502
503 const after = await $.command.run(Fixtures.DIFF)
504
505 await world.clock.advance(Fixtures.SETTLE_MS)
506 await $.ui.render(Fixtures.PANE)
507
508 script['rev-parse --path-format=absolute'] = Fixtures.ELSEWHERE_LINES
509
510 const third = $.command.run(Fixtures.DIFF)
511
512 await world.clock.advance(Fixtures.SETTLE_MS)
513 await third
514
515 expect(before.text).toContain("isn't in a git repository")
516
517 expect(again.text, "kept, as the built-in's answer is").toContain(
518 "isn't in a git repository",
519 )
520
521 expect(probesKept, 'one probe answered both').toBe(1)
522 expect(after.text, '/clear forgot it').toBe('Diff panel shown')
523 expect(world.opened[0]?.id).toBe('diff')
524 expect(probesOf(), 'then one more, pinned from there on').toBe(2)
525
526 expect(
527 world.runs.some(run => run.argv.includes('--git-dir=/else/.git')),
528 ).toBe(false)
529
530 expect(
531 world.runs
532 .filter(run => run.argv.includes('--numstat'))
533 .every(run => run.argv[1] === Fixtures.PINNED_LEAD[1]),
534 ).toBe(true)
535 })
536
537 test('a repository that appears later opens on first edit', async ($, on) => {
538 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
539 Fixtures.oneSecret()
540
541 const script: Record<string, string> = notYet
542 const world = Fixtures.inRepository(on, script)
543
544 on('tool.call', () => ({ result: 'edited' }))
545
546 await $.session.start(Fixtures.WORKTREE_SESSION)
547 await $.ui.render(Fixtures.HINT)
548
549 script['rev-parse --path-format=absolute'] = worktree
550
551 await $.tool.call({
552 tool: 'Edit',
553 file_path: '/main/wt/.env',
554 old_string: 'old',
555 new_string: 'new',
556 })
557
558 await world.clock.advance(Fixtures.SETTLE_MS)
559
560 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
561 })
562
563 test('a git that cannot start is an answer, not a timeout', async ($, on) => {
564 const runs: Args<'process.run'>[] = []
565
566 Fixtures.startsSession(on)
567
568 on('process.run', ($, e) => {
569 runs.push(e)
570
571 return e.argv.includes('--show-toplevel')
572 ? { deny: Fixtures.FAILED_START }
573 : { value: Fixtures.gitIn(e.argv, Fixtures.oneSecret()) }
574 })
575
576 await $.session.start(Fixtures.WORKTREE_SESSION)
577
578 const { text } = await $.command.run(Fixtures.DIFF)
579 const probes = runs.filter(run => run.argv.includes('--show-toplevel'))
580
581 expect(text).toContain("isn't in a git repository")
582 expect(probes, "this /diff's alone; no retry").toHaveLength(1)
583 })
584
585 test('/diff on a narrow terminal: the resize line', async ($, on) => {
586 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
587
588 await $.session.start(Fixtures.WORKTREE_SESSION)
589
590 const narrow = await $.command.run(
591 Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS - 1),
592 )
593
594 const openedNarrow = [...world.opened]
595
596 await $.command.run(Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS))
597
598 expect(narrow.text).toContain(
599 'Resize your terminal to at least 110 columns to show the diff panel',
600 )
601
602 expect(openedNarrow).toEqual([])
603 expect(world.opened.map(pane => pane.id)).toEqual([Names.PANE_ID])
604 })
605})