| import type { Args, ResultOf } from 'claude-code' |
| import { describe, expect, mock, test, tier } from 'claude-code/testing' |
|
|
| import Limits from '../hooks/limits' |
| import Names from '../hooks/names' |
| import Fixtures from './fixtures' |
|
|
| tier('builtin') |
|
|
| describe('register', () => { |
| test('the start asks nothing of git and registers /diff', async ($, on) => { |
| const world = Fixtures.inRepository(on) |
|
|
| await $.session.start(Fixtures.SESSION) |
|
|
| expect(world.runs, "no git, as the built-in's start runs none").toEqual([]) |
|
|
| expect(await $.command.run(Fixtures.DIFF), '/diff was registered').toEqual({ |
| text: 'Diff panel shown', |
| }) |
| }) |
|
|
| test("git runs at the built-in's moments and no others", async ($, on) => { |
| const world = Fixtures.inRepository(on) |
|
|
| let read = 0 |
|
|
| function spawnedSince(): string[] { |
| const words = world.runs |
| .slice(read) |
| .map(run => Fixtures.gitWordOf(run.argv)) |
| .filter(word => word !== Fixtures.POLL_WORD) |
|
|
| read = world.runs.length |
|
|
| return words |
| } |
|
|
| const edit = () => |
| $.tool.call({ |
| tool: 'Edit', |
| file_path: '/work/app.ts', |
| old_string: '1', |
| new_string: '2', |
| }) |
|
|
| on('tool.call', () => ({ result: 'done' })) |
| on('turn.complete', ($, e) => ({ text: e.answer })) |
| on('command.run', { command: 'clear' }, () => ({})) |
|
|
| await $.session.start(Fixtures.SESSION) |
| await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) |
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(spawnedSince(), 'the start and the footer: nothing').toEqual([]) |
|
|
| await edit() |
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect( |
| spawnedSince(), |
| 'an edit with no room for a pane: nothing, the width is read first', |
| ).toEqual([]) |
|
|
| await $.command.run(Fixtures.DIFF) |
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(spawnedSince(), '/diff: found, then one fetch for the pane').toEqual( |
| [ |
| 'rev-parse --show-toplevel', |
| 'status', |
| 'diff --shortstat', |
| 'diff --numstat', |
| 'ls-files', |
| 'diff -- app.ts', |
| ], |
| ) |
|
|
| await edit() |
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(spawnedSince(), 'an edit, the pane open: one fetch').toEqual([ |
| 'diff --shortstat', |
| 'diff --numstat', |
| 'ls-files', |
| 'diff -- app.ts', |
| ]) |
|
|
| await $.tool.call({ tool: 'Bash', command: 'make' }) |
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(spawnedSince(), 'a shell command, the pane open: one fetch').toEqual( |
| ['diff --shortstat', 'diff --numstat', 'ls-files', 'diff -- app.ts'], |
| ) |
|
|
| await $.turn.complete({ |
| answer: 'done', |
| durationMs: 1, |
| isAborted: false, |
| turnId: 't1', |
| reason: 'answer', |
| }) |
|
|
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(spawnedSince(), "a turn's end: nothing").toEqual([]) |
|
|
| await $.command.run(Fixtures.DIFF) |
| await edit() |
| await $.tool.call({ tool: 'Bash', command: 'make' }) |
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(spawnedSince(), 'the pane closed by hand: nothing').toEqual([]) |
|
|
| await $.command.run(Fixtures.CLEAR) |
| await $.command.run(Fixtures.DIFF) |
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(spawnedSince()[0], '/clear forgot the repository').toBe( |
| 'rev-parse --show-toplevel', |
| ) |
| }) |
|
|
| test('/diff whose probe never answers probes once more', async ($, on) => { |
| const probes: (readonly string[])[] = [] |
| const clock = Fixtures.startsSession(on) |
|
|
| on('process.run', async ($, e) => { |
| probes.push(e.argv) |
|
|
| if (probes.length === 1) { |
| await clock.sleep(Limits.GIT_TIMEOUT_MS) |
|
|
| return { deny: Fixtures.GIT_HUNG } |
| } |
|
|
| return { value: Fixtures.NOT_A_REPOSITORY } |
| }) |
|
|
| await $.session.start(Fixtures.SESSION) |
|
|
| const ran = $.command.run(Fixtures.DIFF) |
|
|
| await clock.settle() |
|
|
| expect(probes, "this /diff's probe, unanswered yet").toHaveLength(1) |
|
|
| await clock.advance(Limits.GIT_TIMEOUT_MS) |
|
|
| expect(await ran).toEqual({ |
| text: expect.stringContaining("isn't in a git repository"), |
| }) |
|
|
| expect(probes, 'then one more, which answered').toHaveLength(2) |
| }) |
|
|
| test('two /diff typed together probe the repository once', async ($, on) => { |
| const world = Fixtures.inRepository(on) |
|
|
| await $.session.start(Fixtures.SESSION) |
|
|
| const [first, second] = await Promise.all([ |
| $.command.run(Fixtures.DIFF), |
| $.command.run(Fixtures.DIFF), |
| ]) |
|
|
| const probes = world.runs.filter(run => |
| run.argv.includes('--show-toplevel'), |
| ) |
|
|
| expect(first.text).toMatch(/^Diff panel (shown|hidden)$/) |
| expect(second.text).toMatch(/^Diff panel (shown|hidden)$/) |
| expect(probes, 'the second joined the probe in flight').toHaveLength(1) |
| }) |
|
|
| test('outside a repository /diff says so, opens nothing', async ($, on) => { |
| const opened: string[] = [] |
|
|
| Fixtures.startsSession(on) |
| on('process.run', () => ({ value: Fixtures.NOT_A_REPOSITORY })) |
|
|
| on('ui.open', ($, e, next) => { |
| opened.push(e.id) |
|
|
| return next(e) |
| }) |
|
|
| await $.session.start(Fixtures.SESSION) |
|
|
| const { text } = await $.command.run(Fixtures.DIFF) |
|
|
| expect(text).toContain("isn't in a git repository") |
| expect(opened).toEqual([]) |
| }) |
|
|
| test('a git that never answers is not "no repository"', async ($, on) => { |
| Fixtures.startsSession(on) |
| on('process.run', () => ({ deny: Fixtures.GIT_HUNG })) |
|
|
| await $.session.start(Fixtures.SESSION) |
|
|
| const { text } = await $.command.run(Fixtures.DIFF) |
|
|
| expect(text).toContain("git didn't answer") |
| }) |
|
|
| test('when the built-in holds /diff, the mod stands down', async ($, on) => { |
| const logged: string[] = [] |
| const runs: Args<'process.run'>[] = [] |
|
|
| mock.clock(on) |
| on('session.start', ($, e) => ({ cwd: e.cwd })) |
| on('command.register', () => ({ deny: Fixtures.BUILTIN_HOLDS })) |
| on('command.run', () => ({ text: 'the built-in /diff ran' })) |
| on('tool.call', () => ({ result: 'edited' })) |
|
|
| on('process.run', ($, e) => { |
| runs.push(e) |
|
|
| return { value: Fixtures.gitIn(e.argv) } |
| }) |
|
|
| on('ui.log', ($, e) => { |
| logged.push(e.text) |
|
|
| return { value: undefined } |
| }) |
|
|
| await $.session.start(Fixtures.SESSION) |
|
|
| expect(await $.command.run(Fixtures.DIFF)).toEqual({ |
| text: 'the built-in /diff ran', |
| }) |
|
|
| await $.tool.call({ |
| tool: 'Edit', |
| file_path: '/work/app.ts', |
| old_string: '1', |
| new_string: '2', |
| }) |
|
|
| expect(logged).toEqual([]) |
| expect(runs, 'idle: no git for /diff or the edit').toEqual([]) |
| }) |
|
|
| test('a refusal the built-in did not cause is said aloud', async ($, on) => { |
| const logged: string[] = [] |
|
|
| mock.clock(on) |
| on('session.start', ($, e) => ({ cwd: e.cwd })) |
|
|
| on('command.register', () => ({ |
| deny: '32 commands are registered already', |
| })) |
|
|
| on('ui.log', ($, e) => { |
| logged.push(e.text) |
|
|
| return { value: undefined } |
| }) |
|
|
| await $.session.start(Fixtures.SESSION) |
|
|
| expect(logged).toEqual([ |
| 'could not register /diff: diff: $.command.register: 32 commands are ' + |
| 'registered already; the diff panel is unavailable this session', |
| ]) |
| }) |
|
|
| test('/diff opens the pane, unfocused, and says so', async ($, on) => { |
| const world = Fixtures.inRepository(on) |
|
|
| await $.session.start(Fixtures.SESSION) |
|
|
| expect(world.runs, 'the start ran nothing').toEqual([]) |
|
|
| expect(await $.command.run(Fixtures.DIFF)).toEqual({ |
| text: 'Diff panel shown', |
| }) |
|
|
| expect(world.runs[0]?.argv, '/diff found the repository first').toContain( |
| '--show-toplevel', |
| ) |
|
|
| expect(world.opened).toEqual([ |
| { id: 'diff', title: 'Diff', holdToasts: true }, |
| ]) |
|
|
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) |
|
|
| expect(drawn).toContain('1 file changed') |
| expect(drawn).toContain('app.ts') |
| }) |
|
|
| test('off fullscreen, /diff opens the dialog, focused', async ($, on) => { |
| const world = Fixtures.inRepository(on) |
|
|
| await $.session.start(Fixtures.SESSION) |
|
|
| expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({}) |
|
|
| expect(world.opened[0]).toEqual({ |
| id: 'diff', |
| title: 'Diff', |
| holdToasts: true, |
| closeOnEscape: true, |
| rows: expect.any(Number), |
| focus: true, |
| }) |
|
|
| expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({ |
| text: 'Diff dialog dismissed', |
| }) |
| }) |
|
|
| test('/diff again closes the pane and says so', async ($, on) => { |
| const world = Fixtures.inRepository(on) |
|
|
| await $.session.start(Fixtures.SESSION) |
| await $.command.run(Fixtures.DIFF) |
|
|
| expect(await $.command.run(Fixtures.DIFF)).toEqual({ |
| text: 'Diff panel hidden', |
| }) |
|
|
| expect(world.closed.map(pane => pane.id)).toEqual(['diff']) |
| }) |
|
|
| test('a wide terminal opens the pane at the first edit', async ($, on) => { |
| const world = Fixtures.inRepository(on) |
|
|
| on('tool.call', () => ({ result: 'edited' })) |
|
|
| await $.session.start(Fixtures.SESSION) |
| await $.ui.render(Fixtures.HINT) |
|
|
| await $.tool.call({ |
| tool: 'Edit', |
| file_path: '/work/app.ts', |
| old_string: '1', |
| new_string: '2', |
| }) |
|
|
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(world.opened.map(pane => pane.id)).toEqual(['diff']) |
| }) |
|
|
| test('on the main screen the first edit opens nothing, and /diff still opens the dialog', async ($, on) => { |
| const world = Fixtures.inRepository(on) |
|
|
| on('tool.call', () => ({ result: 'edited' })) |
|
|
| await $.session.start(Fixtures.SESSION) |
| await $.ui.render(Fixtures.MAIN_SCREEN_HINT) |
|
|
| await $.tool.call({ |
| tool: 'Edit', |
| file_path: '/work/app.ts', |
| old_string: '1', |
| new_string: '2', |
| }) |
|
|
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(world.opened, 'a pane there would be an unasked dialog').toEqual([]) |
| expect(world.runs, 'so nothing was asked of the repository').toEqual([]) |
|
|
| expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({}) |
|
|
| expect( |
| world.opened[0], |
| "the person's /diff opens the dialog, not dismisses one", |
| ).toMatchObject({ id: 'diff', focus: true }) |
| }) |
|
|
| test('a surface that does not say whether it docks a pane opens nothing at the first edit', async ($, on) => { |
| const world = Fixtures.inRepository(on) |
|
|
| on('tool.call', () => ({ result: 'edited' })) |
|
|
| await $.session.start(Fixtures.SESSION) |
| await $.ui.render(Fixtures.UNSAID_HINT) |
|
|
| await $.tool.call({ |
| tool: 'Edit', |
| file_path: '/work/app.ts', |
| old_string: '1', |
| new_string: '2', |
| }) |
|
|
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(world.opened, 'unknown is not a dock').toEqual([]) |
|
|
| await $.ui.render(Fixtures.HINT) |
|
|
| await $.tool.call({ |
| tool: 'Edit', |
| file_path: '/work/app.ts', |
| old_string: '2', |
| new_string: '3', |
| }) |
|
|
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect( |
| world.opened.map(pane => pane.id), |
| 'once a drawing says the layout docks, the next edit opens it', |
| ).toEqual(['diff']) |
| }) |
|
|
| test('an edit that failed or was refused opens nothing', async ($, on) => { |
| const world = Fixtures.inRepository(on) |
|
|
| const answers: ResultOf['tool.call'][] = [ |
| { isError: true, result: 'no such text' }, |
| { deny: 'not allowed' }, |
| ] |
|
|
| const edit = () => |
| $.tool.call({ |
| tool: 'Edit', |
| file_path: '/work/app.ts', |
| old_string: '1', |
| new_string: '2', |
| }) |
|
|
| on('tool.call', () => answers.shift() ?? { result: 'edited' }) |
|
|
| await $.session.start(Fixtures.SESSION) |
| await $.ui.render(Fixtures.HINT) |
| await edit() |
| await edit() |
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(world.opened, 'neither edit landed').toEqual([]) |
| expect(world.runs, 'so nothing asked after the repository').toEqual([]) |
|
|
| await edit() |
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect( |
| world.opened.map(pane => pane.id), |
| 'the third did', |
| ).toEqual(['diff']) |
| }) |
|
|
| test('/clear closes the pane it finds open', async ($, on) => { |
| const world = Fixtures.inRepository(on) |
|
|
| on('command.run', { command: 'clear' }, () => ({})) |
|
|
| await $.session.start(Fixtures.SESSION) |
| await $.command.run(Fixtures.DIFF) |
| await $.command.run(Fixtures.CLEAR) |
|
|
| expect(world.closed.map(pane => pane.id)).toEqual(['diff']) |
| }) |
|
|
| test('ask attaches the hunks on screen and calls no tool', async ($, on) => { |
| const world = Fixtures.inRepository(on, Fixtures.oneSecret()) |
| const called: Args<'tool.call'>[] = [] |
|
|
| on('tool.call', ($, e) => { |
| called.push(e) |
|
|
| return { result: 'called' } |
| }) |
|
|
| on('prompt.submit', ($, e) => ({ text: e.text, context: e.context })) |
|
|
| await $.session.start(Fixtures.WORKTREE_SESSION) |
| await $.command.run(Fixtures.DIFF) |
| await world.clock.advance(Fixtures.SETTLE_MS) |
| await $.ui.render(Fixtures.PANE) |
| await $.ui.press({ plugin: 'diff', key: 'ask:.env' }) |
| await world.clock.settle() |
|
|
| const armed = Fixtures.jsonOf(await $.ui.render(Fixtures.PANE)) |
|
|
| const submitted = await $.prompt.submit(Fixtures.typedPromptOf('why?')) |
|
|
| expect(armed).toContain('asked ✓') |
| expect(called).toEqual([]) |
|
|
| expect(world.statuses.at(-2)).toBe( |
| '.env rides your next prompt (press asked ✓ to drop it)', |
| ) |
|
|
| expect(submitted).toMatchObject({ |
| context: [ |
| 'The user attached the diff of .env from the diff pane to this ' + |
| 'prompt:\n@@ -1 +1 @@\n-KEY=old\n+KEY=new', |
| ], |
| }) |
| }) |
|
|
| test('an ask with no room in the context is dropped', async ($, on) => { |
| const world = Fixtures.inRepository(on, Fixtures.oneSecret()) |
| const full = 'x'.repeat(Limits.PROMPT_CONTEXT_MAX_CHARS) |
| const reached: (readonly string[] | undefined)[] = [] |
|
|
| on('prompt.submit', ($, e) => { |
| reached.push(e.context) |
|
|
| return { text: e.text } |
| }) |
|
|
| await $.session.start(Fixtures.WORKTREE_SESSION) |
| await $.command.run(Fixtures.DIFF) |
| await world.clock.advance(Fixtures.SETTLE_MS) |
| await $.ui.render(Fixtures.PANE) |
| await $.ui.press({ plugin: 'diff', key: 'ask:.env' }) |
| await world.clock.settle() |
|
|
| await $.prompt.submit(Fixtures.typedPromptOf('why?', [full])) |
| await $.prompt.submit(Fixtures.typedPromptOf('and now?')) |
|
|
| expect(reached).toEqual([[full], undefined]) |
|
|
| expect(world.statuses.at(-1)).toBe( |
| ".env's diff did not fit in the prompt and was dropped", |
| ) |
| }) |
|
|
| test('a worktree opens on the base it kept', async ($, on) => { |
| const world = Fixtures.inRepository(on, Fixtures.oneSecret(), { |
| 'base:/main/wt': 'uncommitted', |
| }) |
|
|
| await $.session.start(Fixtures.WORKTREE_SESSION) |
| await $.command.run(Fixtures.DIFF) |
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect( |
| Fixtures.stringsOf(await $.ui.render(Fixtures.PANE)), |
| "the built-in's base line under the header", |
| ).toContain('uncommitted (vs HEAD)') |
| }) |
|
|
| test('no repository is an answer kept until /clear', async ($, on) => { |
| const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } = |
| Fixtures.oneSecret() |
|
|
| const script: Record<string, string> = notYet |
| const world = Fixtures.inRepository(on, script) |
|
|
| const probesOf = () => |
| world.runs.filter(run => run.argv.includes('--show-toplevel')).length |
|
|
| on('command.run', { command: 'clear' }, () => ({})) |
|
|
| await $.session.start(Fixtures.WORKTREE_SESSION) |
|
|
| const before = await $.command.run(Fixtures.DIFF) |
|
|
| script['rev-parse --path-format=absolute'] = worktree |
|
|
| const again = await $.command.run(Fixtures.DIFF) |
| const probesKept = probesOf() |
|
|
| await $.command.run(Fixtures.CLEAR) |
|
|
| const after = await $.command.run(Fixtures.DIFF) |
|
|
| await world.clock.advance(Fixtures.SETTLE_MS) |
| await $.ui.render(Fixtures.PANE) |
|
|
| script['rev-parse --path-format=absolute'] = Fixtures.ELSEWHERE_LINES |
|
|
| const third = $.command.run(Fixtures.DIFF) |
|
|
| await world.clock.advance(Fixtures.SETTLE_MS) |
| await third |
|
|
| expect(before.text).toContain("isn't in a git repository") |
|
|
| expect(again.text, "kept, as the built-in's answer is").toContain( |
| "isn't in a git repository", |
| ) |
|
|
| expect(probesKept, 'one probe answered both').toBe(1) |
| expect(after.text, '/clear forgot it').toBe('Diff panel shown') |
| expect(world.opened[0]?.id).toBe('diff') |
| expect(probesOf(), 'then one more, pinned from there on').toBe(2) |
|
|
| expect( |
| world.runs.some(run => run.argv.includes('--git-dir=/else/.git')), |
| ).toBe(false) |
|
|
| expect( |
| world.runs |
| .filter(run => run.argv.includes('--numstat')) |
| .every(run => run.argv[1] === Fixtures.PINNED_LEAD[1]), |
| ).toBe(true) |
| }) |
|
|
| test('a repository that appears later opens on first edit', async ($, on) => { |
| const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } = |
| Fixtures.oneSecret() |
|
|
| const script: Record<string, string> = notYet |
| const world = Fixtures.inRepository(on, script) |
|
|
| on('tool.call', () => ({ result: 'edited' })) |
|
|
| await $.session.start(Fixtures.WORKTREE_SESSION) |
| await $.ui.render(Fixtures.HINT) |
|
|
| script['rev-parse --path-format=absolute'] = worktree |
|
|
| await $.tool.call({ |
| tool: 'Edit', |
| file_path: '/main/wt/.env', |
| old_string: 'old', |
| new_string: 'new', |
| }) |
|
|
| await world.clock.advance(Fixtures.SETTLE_MS) |
|
|
| expect(world.opened.map(pane => pane.id)).toEqual(['diff']) |
| }) |
|
|
| test('a git that cannot start is an answer, not a timeout', async ($, on) => { |
| const runs: Args<'process.run'>[] = [] |
|
|
| Fixtures.startsSession(on) |
|
|
| on('process.run', ($, e) => { |
| runs.push(e) |
|
|
| return e.argv.includes('--show-toplevel') |
| ? { deny: Fixtures.FAILED_START } |
| : { value: Fixtures.gitIn(e.argv, Fixtures.oneSecret()) } |
| }) |
|
|
| await $.session.start(Fixtures.WORKTREE_SESSION) |
|
|
| const { text } = await $.command.run(Fixtures.DIFF) |
| const probes = runs.filter(run => run.argv.includes('--show-toplevel')) |
|
|
| expect(text).toContain("isn't in a git repository") |
| expect(probes, "this /diff's alone; no retry").toHaveLength(1) |
| }) |
|
|
| test('/diff on a narrow terminal: the resize line', async ($, on) => { |
| const world = Fixtures.inRepository(on, Fixtures.oneSecret()) |
|
|
| await $.session.start(Fixtures.WORKTREE_SESSION) |
|
|
| const narrow = await $.command.run( |
| Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS - 1), |
| ) |
|
|
| const openedNarrow = [...world.opened] |
|
|
| await $.command.run(Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS)) |
|
|
| expect(narrow.text).toContain( |
| 'Resize your terminal to at least 110 columns to show the diff panel', |
| ) |
|
|
| expect(openedNarrow).toEqual([]) |
| expect(world.opened.map(pane => pane.id)).toEqual([Names.PANE_ID]) |
| }) |
| }) |
|
|