| | @@ -1,13 +1,54 @@ |
| 1 | | -import type { Args, ResultOf, SessionMessage } from 'claude-code' |
| 1 | +import type { Args, FsEntry, On, ResultOf, SessionMessage } from 'claude-code' |
| 2 | 2 | import { describe, expect, mock, test, tier } from 'claude-code/testing' |
| 3 | +import type { Engine } from 'claude-code/testing' |
| 3 | 4 | |
| 4 | 5 | import Limits from '../hooks/limits' |
| 5 | 6 | import Names from '../hooks/names' |
| 7 | +import Record from '../hooks/record' |
| 6 | 8 | import Fixtures from './fixtures' |
| 7 | 9 | |
| 8 | 10 | tier('builtin') |
| 9 | 11 | |
| 10 | 12 | describe('register', () => { |
| 13 | + async function drawnRoundMerge($: Engine, on: On, finishedMs: number) { |
| 14 | + const script = { ...Fixtures.REPOSITORY, 'rev-parse --verify': 'merging' } |
| 15 | + |
| 16 | + let listing = Fixtures.MERGING.map(([name, kind]): FsEntry => ({ |
| 17 | + name, |
| 18 | + kind, |
| 19 | + size: 0, |
| 20 | + isLink: false, |
| 21 | + })) |
| 22 | + |
| 23 | + on('fs.list', (_engine, e) => { |
| 24 | + const isGitDir = e.path === '/work/.git' |
| 25 | + |
| 26 | + return { value: isGitDir ? listing : [] } |
| 27 | + }) |
| 28 | + |
| 29 | + const world = Fixtures.inRepository(on, script) |
| 30 | + |
| 31 | + const drawn = async () => Fixtures.textOf(await $.ui.render(Fixtures.PANE)) |
| 32 | + |
| 33 | + await $.session.start(Fixtures.SESSION) |
| 34 | + await $.command.run(Fixtures.DIFF) |
| 35 | + await world.clock.advance(finishedMs) |
| 36 | + |
| 37 | + const before = await drawn() |
| 38 | + |
| 39 | + listing = [] |
| 40 | + script['rev-parse --verify'] = 'merged' |
| 41 | + |
| 42 | + await world.clock.advance(Limits.HEAD_POLL_MS + Fixtures.SETTLE_MS) |
| 43 | + |
| 44 | + return { before, after: await drawn() } |
| 45 | + } |
| 46 | + |
| 47 | + const NOTICED = { |
| 48 | + before: expect.stringContaining('Diff unavailable'), |
| 49 | + after: expect.stringContaining('1 file changed'), |
| 50 | + } |
| 51 | + |
| 11 | 52 | test('the start asks nothing of git and registers /diff', async ($, on) => { |
| 12 | 53 | const world = Fixtures.inRepository(on) |
| 13 | 54 | |
| | @@ -121,19 +162,19 @@ |
| 121 | 162 | ) |
| 122 | 163 | }) |
| 123 | 164 | |
| 124 | | - test('a shell command the tool held read-only fetches nothing', async ($, on) => { |
| 165 | + test('a shell command held read-only fetches nothing', async ($, on) => { |
| 125 | 166 | const world = Fixtures.inRepository(on) |
| 126 | 167 | |
| 127 | 168 | const fetchesSince = (read: number) => |
| 128 | 169 | world.runs.slice(read).filter(run => run.argv.includes('--numstat')) |
| 129 | 170 | .length |
| 130 | 171 | |
| 131 | | - on('tool.call', ($, e) => |
| 132 | | - e.tool === 'Bash' && e.command === 'ls' |
| 133 | | - ? Fixtures.READ_ONLY_ANSWER |
| 134 | | - : { result: 'done' }, |
| 135 | | - ) |
| 172 | + on('tool.call', ($, e) => { |
| 173 | + const isListing = e.tool === 'Bash' && e.command === 'ls' |
| 136 | 174 | |
| 175 | + return isListing ? Fixtures.READ_ONLY_ANSWER : { result: 'done' } |
| 176 | + }) |
| 177 | + |
| 137 | 178 | await $.session.start(Fixtures.SESSION) |
| 138 | 179 | await $.command.run(Fixtures.DIFF) |
| 139 | 180 | await world.clock.advance(Fixtures.SETTLE_MS) |
| | @@ -383,7 +424,7 @@ |
| 383 | 424 | expect(world.opened.map(pane => pane.id)).toEqual(['diff']) |
| 384 | 425 | }) |
| 385 | 426 | |
| 386 | | - test('on the main screen the first edit opens nothing, and /diff still opens the dialog', async ($, on) => { |
| 427 | + test('on the main screen only /diff opens, as a dialog', async ($, on) => { |
| 387 | 428 | const world = Fixtures.inRepository(on) |
| 388 | 429 | |
| 389 | 430 | on('tool.call', () => ({ result: 'edited' })) |
| | @@ -411,7 +452,7 @@ |
| 411 | 452 | ).toMatchObject({ id: 'diff', focus: true }) |
| 412 | 453 | }) |
| 413 | 454 | |
| 414 | | - test('a surface that does not say whether it docks a pane opens nothing at the first edit', async ($, on) => { |
| 455 | + test('a surface silent on docking opens nothing by itself', async ($, on) => { |
| 415 | 456 | const world = Fixtures.inRepository(on) |
| 416 | 457 | |
| 417 | 458 | on('tool.call', () => ({ result: 'edited' })) |
| | @@ -716,7 +757,7 @@ |
| 716 | 757 | expect(drawn).not.toContain('Loading diff') |
| 717 | 758 | }) |
| 718 | 759 | |
| 719 | | - test('/clear leaves the pane it finds open up and reads the repository afresh', async ($, on) => { |
| 760 | + test('/clear leaves an open pane up and reads afresh', async ($, on) => { |
| 720 | 761 | const world = Fixtures.inRepository(on) |
| 721 | 762 | |
| 722 | 763 | on('command.run', { command: 'clear' }, () => ({})) |
| | @@ -745,7 +786,70 @@ |
| 745 | 786 | ).toContain('1 file changed') |
| 746 | 787 | }) |
| 747 | 788 | |
| 748 | | - test('a resumed session whose turns edited opens the pane before any new edit', async ($, on) => { |
| 789 | + test('/clear under an open pane counts it shown again', async ($, on) => { |
| 790 | + const world = Fixtures.inRepository(on) |
| 791 | + const logged = Fixtures.keeping<Args<'telemetry.log'>>() |
| 792 | + |
| 793 | + let sessionId = 'first' |
| 794 | + |
| 795 | + on('telemetry.log', logged.hook) |
| 796 | + on('session.id', () => ({ value: sessionId })) |
| 797 | + on('tool.call', () => ({ result: 'edited' })) |
| 798 | + |
| 799 | + on('command.run', { command: 'clear' }, () => { |
| 800 | + sessionId = 'second' |
| 801 | + |
| 802 | + return {} |
| 803 | + }) |
| 804 | + |
| 805 | + await $.session.start(Fixtures.SESSION) |
| 806 | + await $.ui.render(Fixtures.HINT) |
| 807 | + |
| 808 | + await $.tool.call({ |
| 809 | + tool: 'Edit', |
| 810 | + file_path: '/work/app.ts', |
| 811 | + old_string: '1', |
| 812 | + new_string: '2', |
| 813 | + }) |
| 814 | + |
| 815 | + await world.clock.advance(Fixtures.SETTLE_MS) |
| 816 | + await $.command.run(Fixtures.CLEAR) |
| 817 | + await world.clock.advance(Fixtures.SETTLE_MS) |
| 818 | + |
| 819 | + expect(world.closed, 'the pane stayed up across /clear').toEqual([]) |
| 820 | + |
| 821 | + expect( |
| 822 | + logged.kept.map(shown => shown.props?.trigger), |
| 823 | + "once a conversation: Claude's edit opened the first, and the second, " + |
| 824 | + "which nobody opened, reads as the person's, as the built-in's does", |
| 825 | + ).toEqual([ |
| 826 | + { value: 'auto_open', of: [...Record.SHOWN_TRIGGERS] }, |
| 827 | + { value: 'manual', of: [...Record.SHOWN_TRIGGERS] }, |
| 828 | + ]) |
| 829 | + }) |
| 830 | + |
| 831 | + test('a merge finished 3 s after the open is noticed', async ($, on) => { |
| 832 | + expect( |
| 833 | + await drawnRoundMerge($, on, 3000), |
| 834 | + 'finished in another terminal: read with no edit or command', |
| 835 | + ).toEqual(NOTICED) |
| 836 | + }) |
| 837 | + |
| 838 | + test('a merge finished 1.2 s after the open is noticed', async ($, on) => { |
| 839 | + expect( |
| 840 | + await drawnRoundMerge($, on, 1200), |
| 841 | + 'before the first look at HEAD on the clock: the open read it', |
| 842 | + ).toEqual(NOTICED) |
| 843 | + }) |
| 844 | + |
| 845 | + test('a merge finished 0.3 s after the open is noticed', async ($, on) => { |
| 846 | + expect( |
| 847 | + await drawnRoundMerge($, on, 300), |
| 848 | + 'before the first look at HEAD on the clock: the open read it', |
| 849 | + ).toEqual(NOTICED) |
| 850 | + }) |
| 851 | + |
| 852 | + test('a resumed session that edited opens before an edit', async ($, on) => { |
| 749 | 853 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 750 | 854 | messages: () => Fixtures.EDITED_TRANSCRIPT, |
| 751 | 855 | }) |
| | @@ -763,7 +867,7 @@ |
| 763 | 867 | ).toEqual(['diff']) |
| 764 | 868 | }) |
| 765 | 869 | |
| 766 | | - test('a resumed session opens nothing where its first edit would not', async ($, on) => { |
| 870 | + test('a resumed session opens only where an edit would', async ($, on) => { |
| 767 | 871 | const narrow = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 768 | 872 | messages: () => Fixtures.EDITED_TRANSCRIPT, |
| 769 | 873 | }) |
| | @@ -779,7 +883,7 @@ |
| 779 | 883 | expect(narrow.runs, 'and no git for it').toEqual([]) |
| 780 | 884 | }) |
| 781 | 885 | |
| 782 | | - test('a resumed session the person kept the pane open in opens it from the lower floor', async ($, on) => { |
| 886 | + test('a resumed session kept open opens at the low floor', async ($, on) => { |
| 783 | 887 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 784 | 888 | messages: () => Fixtures.EDITED_TRANSCRIPT, |
| 785 | 889 | stored: { [Names.STORE_OPEN_KEY]: true }, |
| | @@ -805,7 +909,7 @@ |
| 805 | 909 | expect(world.runs).toEqual([]) |
| 806 | 910 | }) |
| 807 | 911 | |
| 808 | | - test('a resumed session whose pane the person closed opens nothing', async ($, on) => { |
| 912 | + test('a resumed session the person closed opens nothing', async ($, on) => { |
| 809 | 913 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 810 | 914 | messages: () => Fixtures.EDITED_TRANSCRIPT, |
| 811 | 915 | stored: { [Names.STORE_OPEN_KEY]: false }, |
| | @@ -819,7 +923,7 @@ |
| 819 | 923 | expect(world.runs).toEqual([]) |
| 820 | 924 | }) |
| 821 | 925 | |
| 822 | | - test('/resume closes the pane, then opens it for the turns it brought back', async ($, on) => { |
| 926 | + test('/resume closes the pane, reopens it for its turns', async ($, on) => { |
| 823 | 927 | let transcript: readonly SessionMessage[] = [] |
| 824 | 928 | |
| 825 | 929 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| | @@ -839,9 +943,10 @@ |
| 839 | 943 | await $.command.run(Fixtures.RESUME) |
| 840 | 944 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 841 | 945 | |
| 842 | | - expect(world.closed.map(pane => pane.id), 'closed at /resume').toEqual([ |
| 843 | | - 'diff', |
| 844 | | - ]) |
| 946 | + expect( |
| 947 | + world.closed.map(pane => pane.id), |
| 948 | + 'closed at /resume', |
| 949 | + ).toEqual(['diff']) |
| 845 | 950 | |
| 846 | 951 | expect( |
| 847 | 952 | world.opened.map(pane => pane.id), |
| | @@ -934,7 +1039,7 @@ |
| 934 | 1039 | const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } = |
| 935 | 1040 | Fixtures.oneSecret() |
| 936 | 1041 | |
| 937 | | - const script: Record<string, string> = notYet |
| 1042 | + const script = { ...notYet } |
| 938 | 1043 | const world = Fixtures.inRepository(on, script) |
| 939 | 1044 | |
| 940 | 1045 | const probesOf = () => |
| | @@ -991,7 +1096,7 @@ |
| 991 | 1096 | const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } = |
| 992 | 1097 | Fixtures.oneSecret() |
| 993 | 1098 | |
| 994 | | - const script: Record<string, string> = notYet |
| 1099 | + const script = { ...notYet } |
| 995 | 1100 | const world = Fixtures.inRepository(on, script) |
| 996 | 1101 | |
| 997 | 1102 | on('tool.call', () => ({ result: 'edited' })) |