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
| 1 | import type { Args, ResultOf, SessionMessage } from 'claude-code' |
| 2 | import { describe, expect, mock, test, tier } from 'claude-code/testing' |
| 3 | |
| 4 | import Limits from '../hooks/limits' |
| 5 | import Names from '../hooks/names' |
| 6 | import Fixtures from './fixtures' |
| 7 | |
| 8 | tier('builtin') |
| 9 | |
| 10 | describe('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("only the main loop's edit opens the pane", async ($, on) => { |
| 487 | const world = Fixtures.inRepository(on) |
| 488 | |
| 489 | const edit = (agentId?: string) => |
| 490 | $.tool.call({ |
| 491 | tool: 'Write', |
| 492 | file_path: '/work/app.ts', |
| 493 | content: '2', |
| 494 | ...(agentId !== undefined && { agentId }), |
| 495 | }) |
| 496 | |
| 497 | on('tool.call', () => ({ result: 'written' })) |
| 498 | |
| 499 | await $.session.start(Fixtures.SESSION) |
| 500 | await $.ui.render(Fixtures.HINT) |
| 501 | await edit('a-explorer') |
| 502 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 503 | |
| 504 | expect(world.opened, 'the built-in checkpoints no subagent edit').toEqual( |
| 505 | [], |
| 506 | ) |
| 507 | |
| 508 | expect(world.runs, 'so nothing was asked of the repository').toEqual([]) |
| 509 | |
| 510 | await edit() |
| 511 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 512 | |
| 513 | expect( |
| 514 | world.opened.map(pane => pane.id), |
| 515 | "the main loop's", |
| 516 | ).toEqual(['diff']) |
| 517 | }) |
| 518 | |
| 519 | test('checkpointing off in settings: edits open nothing', async ($, on) => { |
| 520 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 521 | settings: { fileCheckpointingEnabled: false }, |
| 522 | }) |
| 523 | |
| 524 | on('tool.call', () => ({ result: 'edited' })) |
| 525 | |
| 526 | await $.session.start(Fixtures.SESSION) |
| 527 | await $.ui.render(Fixtures.HINT) |
| 528 | |
| 529 | await $.tool.call({ |
| 530 | tool: 'Edit', |
| 531 | file_path: '/work/app.ts', |
| 532 | old_string: '1', |
| 533 | new_string: '2', |
| 534 | }) |
| 535 | |
| 536 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 537 | |
| 538 | expect(world.opened, 'the built-in opens on a checkpoint alone').toEqual([]) |
| 539 | |
| 540 | expect(world.runs, 'so nothing was asked of the repository').toEqual([]) |
| 541 | |
| 542 | expect(await $.command.run(Fixtures.DIFF)).toEqual({ |
| 543 | text: 'Diff panel shown', |
| 544 | }) |
| 545 | }) |
| 546 | |
| 547 | test('checkpointing off by variable: edits open nothing', async ($, on) => { |
| 548 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 549 | env: { CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING: 'true' }, |
| 550 | }) |
| 551 | |
| 552 | on('tool.call', () => ({ result: 'edited' })) |
| 553 | |
| 554 | await $.session.start(Fixtures.SESSION) |
| 555 | await $.ui.render(Fixtures.HINT) |
| 556 | |
| 557 | await $.tool.call({ |
| 558 | tool: 'Edit', |
| 559 | file_path: '/work/app.ts', |
| 560 | old_string: '1', |
| 561 | new_string: '2', |
| 562 | }) |
| 563 | |
| 564 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 565 | |
| 566 | expect(world.opened).toEqual([]) |
| 567 | }) |
| 568 | |
| 569 | test('an open left waiting is withdrawn; /diff opens it', async ($, on) => { |
| 570 | let isNarrow = true |
| 571 | |
| 572 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 573 | stored: { [Names.STORE_OPEN_KEY]: true }, |
| 574 | isLeftWaiting: () => isNarrow, |
| 575 | }) |
| 576 | |
| 577 | const edit = () => |
| 578 | $.tool.call({ |
| 579 | tool: 'Edit', |
| 580 | file_path: '/work/app.ts', |
| 581 | old_string: '1', |
| 582 | new_string: '2', |
| 583 | }) |
| 584 | |
| 585 | on('tool.call', () => ({ result: 'edited' })) |
| 586 | |
| 587 | await $.session.start(Fixtures.SESSION) |
| 588 | await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) |
| 589 | await edit() |
| 590 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 591 | |
| 592 | expect( |
| 593 | world.waiting.map(pane => pane.id), |
| 594 | 'the edit opened', |
| 595 | ).toEqual(['diff']) |
| 596 | |
| 597 | expect( |
| 598 | world.closed.map(pane => pane.id), |
| 599 | 'and withdrew the pane the engine left waiting, so no resize seats it', |
| 600 | ).toEqual(['diff']) |
| 601 | |
| 602 | expect( |
| 603 | world.runs.map(run => Fixtures.gitWordOf(run.argv)).slice(0, 2), |
| 604 | 'the repository was found and read once before the open, as the ' + |
| 605 | 'built-in primes; nothing polls for a pane no one sees', |
| 606 | ).toEqual(['rev-parse --show-toplevel', 'status']) |
| 607 | |
| 608 | expect( |
| 609 | world.runs.map(run => Fixtures.gitWordOf(run.argv)), |
| 610 | 'no HEAD poll for a withdrawn pane', |
| 611 | ).not.toContain(Fixtures.POLL_WORD) |
| 612 | |
| 613 | isNarrow = false |
| 614 | |
| 615 | expect( |
| 616 | await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)), |
| 617 | '/diff opens it, where a pane believed open would have been hidden', |
| 618 | ).toEqual({ text: 'Diff panel shown' }) |
| 619 | |
| 620 | await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) |
| 621 | await edit() |
| 622 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 623 | |
| 624 | expect( |
| 625 | world.opened.map(pane => pane.id), |
| 626 | 'closed by hand, no edit opens it again', |
| 627 | ).toEqual(['diff']) |
| 628 | }) |
| 629 | |
| 630 | test('an unplaced open is tried again at the next edit', async ($, on) => { |
| 631 | let isNarrow = true |
| 632 | |
| 633 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 634 | stored: { [Names.STORE_OPEN_KEY]: true }, |
| 635 | isLeftWaiting: () => isNarrow, |
| 636 | }) |
| 637 | |
| 638 | const edit = () => |
| 639 | $.tool.call({ |
| 640 | tool: 'Edit', |
| 641 | file_path: '/work/app.ts', |
| 642 | old_string: '1', |
| 643 | new_string: '2', |
| 644 | }) |
| 645 | |
| 646 | on('tool.call', () => ({ result: 'edited' })) |
| 647 | |
| 648 | await $.session.start(Fixtures.SESSION) |
| 649 | await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) |
| 650 | await edit() |
| 651 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 652 | |
| 653 | expect(world.opened, 'the first try waited and was withdrawn').toEqual([]) |
| 654 | |
| 655 | isNarrow = false |
| 656 | |
| 657 | await edit() |
| 658 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 659 | |
| 660 | expect( |
| 661 | world.opened.map(pane => pane.id), |
| 662 | 'the next edit opened it, as the built-in reads the width again then', |
| 663 | ).toEqual(['diff']) |
| 664 | }) |
| 665 | |
| 666 | test('a docked pane opens once its first fetch settled', async ($, on) => { |
| 667 | const opened: string[] = [] |
| 668 | const clock = Fixtures.startsSession(on) |
| 669 | |
| 670 | on('process.run', async ($, e) => { |
| 671 | if (e.argv.includes('--shortstat')) { |
| 672 | await clock.sleep(Fixtures.SLOW_DIFF_MS) |
| 673 | } |
| 674 | |
| 675 | return { value: Fixtures.gitIn(e.argv) } |
| 676 | }) |
| 677 | |
| 678 | on('ui.open', ($, e) => { |
| 679 | opened.push(e.id) |
| 680 | |
| 681 | return { value: undefined } |
| 682 | }) |
| 683 | |
| 684 | on('ui.close', () => ({ value: undefined })) |
| 685 | on('ui.invalidate', () => ({ value: undefined })) |
| 686 | on('ui.render', { component: 'PromptHint' }, () => Fixtures.HINT_DRAWN) |
| 687 | on('session.messages', () => ({ value: [] })) |
| 688 | on('settings.read', () => ({ value: {} })) |
| 689 | on('tool.call', () => ({ result: 'edited' })) |
| 690 | mock.store(on) |
| 691 | mock.env(on, {}) |
| 692 | |
| 693 | await $.session.start(Fixtures.SESSION) |
| 694 | await $.ui.render(Fixtures.HINT) |
| 695 | |
| 696 | await $.tool.call({ |
| 697 | tool: 'Edit', |
| 698 | file_path: '/work/app.ts', |
| 699 | old_string: '1', |
| 700 | new_string: '2', |
| 701 | }) |
| 702 | |
| 703 | await clock.advance(Fixtures.SLOW_DIFF_MS - 1) |
| 704 | |
| 705 | expect(opened, 'git has not answered: no pane, no Loading frame').toEqual( |
| 706 | [], |
| 707 | ) |
| 708 | |
| 709 | await clock.advance(Fixtures.SETTLE_MS) |
| 710 | |
| 711 | expect(opened, 'the fetch settled: the pane opens filled').toEqual(['diff']) |
| 712 | |
| 713 | const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) |
| 714 | |
| 715 | expect(drawn).toContain('1 file changed') |
| 716 | expect(drawn).not.toContain('Loading diff') |
| 717 | }) |
| 718 | |
| 719 | test('/clear leaves the pane it finds open up and reads the repository afresh', async ($, on) => { |
| 720 | const world = Fixtures.inRepository(on) |
| 721 | |
| 722 | on('command.run', { command: 'clear' }, () => ({})) |
| 723 | |
| 724 | await $.session.start(Fixtures.SESSION) |
| 725 | await $.command.run(Fixtures.DIFF) |
| 726 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 727 | |
| 728 | const read = world.runs.length |
| 729 | |
| 730 | await $.command.run(Fixtures.CLEAR) |
| 731 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 732 | |
| 733 | expect(world.closed, 'as the built-in panel stays across /clear').toEqual( |
| 734 | [], |
| 735 | ) |
| 736 | |
| 737 | expect( |
| 738 | world.runs.slice(read).map(run => Fixtures.gitWordOf(run.argv))[0], |
| 739 | 'the repository found again for the conversation that starts over', |
| 740 | ).toBe('rev-parse --show-toplevel') |
| 741 | |
| 742 | expect( |
| 743 | Fixtures.textOf(await $.ui.render(Fixtures.PANE)), |
| 744 | 'drawn from the fresh read', |
| 745 | ).toContain('1 file changed') |
| 746 | }) |
| 747 | |
| 748 | test('a resumed session whose turns edited opens the pane before any new edit', async ($, on) => { |
| 749 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 750 | messages: () => Fixtures.EDITED_TRANSCRIPT, |
| 751 | }) |
| 752 | |
| 753 | await $.session.start(Fixtures.SESSION) |
| 754 | |
| 755 | expect(world.runs, 'the start itself still runs no git').toEqual([]) |
| 756 | |
| 757 | await $.ui.render(Fixtures.HINT) |
| 758 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 759 | |
| 760 | expect( |
| 761 | world.opened.map(pane => pane.id), |
| 762 | 'open once the width is known, as the built-in opens on the restore', |
| 763 | ).toEqual(['diff']) |
| 764 | }) |
| 765 | |
| 766 | test('a resumed session opens nothing where its first edit would not', async ($, on) => { |
| 767 | const narrow = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 768 | messages: () => Fixtures.EDITED_TRANSCRIPT, |
| 769 | }) |
| 770 | |
| 771 | await $.session.start(Fixtures.SESSION) |
| 772 | await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) |
| 773 | await narrow.clock.advance(Fixtures.SETTLE_MS) |
| 774 | |
| 775 | expect(narrow.opened, 'under the unasked floor, never kept open').toEqual( |
| 776 | [], |
| 777 | ) |
| 778 | |
| 779 | expect(narrow.runs, 'and no git for it').toEqual([]) |
| 780 | }) |
| 781 | |
| 782 | test('a resumed session the person kept the pane open in opens it from the lower floor', async ($, on) => { |
| 783 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 784 | messages: () => Fixtures.EDITED_TRANSCRIPT, |
| 785 | stored: { [Names.STORE_OPEN_KEY]: true }, |
| 786 | }) |
| 787 | |
| 788 | await $.session.start(Fixtures.SESSION) |
| 789 | await $.ui.render(Fixtures.hintAt(Limits.OPEN_MIN_COLUMNS)) |
| 790 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 791 | |
| 792 | expect(world.opened.map(pane => pane.id)).toEqual(['diff']) |
| 793 | }) |
| 794 | |
| 795 | test('a resumed session with no edits opens nothing', async ($, on) => { |
| 796 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 797 | messages: () => [Fixtures.promptOf('just talk')], |
| 798 | }) |
| 799 | |
| 800 | await $.session.start(Fixtures.SESSION) |
| 801 | await $.ui.render(Fixtures.HINT) |
| 802 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 803 | |
| 804 | expect(world.opened).toEqual([]) |
| 805 | expect(world.runs).toEqual([]) |
| 806 | }) |
| 807 | |
| 808 | test('a resumed session whose pane the person closed opens nothing', async ($, on) => { |
| 809 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 810 | messages: () => Fixtures.EDITED_TRANSCRIPT, |
| 811 | stored: { [Names.STORE_OPEN_KEY]: false }, |
| 812 | }) |
| 813 | |
| 814 | await $.session.start(Fixtures.SESSION) |
| 815 | await $.ui.render(Fixtures.HINT) |
| 816 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 817 | |
| 818 | expect(world.opened).toEqual([]) |
| 819 | expect(world.runs).toEqual([]) |
| 820 | }) |
| 821 | |
| 822 | test('/resume closes the pane, then opens it for the turns it brought back', async ($, on) => { |
| 823 | let transcript: readonly SessionMessage[] = [] |
| 824 | |
| 825 | const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { |
| 826 | messages: () => transcript, |
| 827 | }) |
| 828 | |
| 829 | on('command.run', { command: 'resume' }, () => { |
| 830 | transcript = Fixtures.EDITED_TRANSCRIPT |
| 831 | |
| 832 | return {} |
| 833 | }) |
| 834 | |
| 835 | await $.session.start(Fixtures.SESSION) |
| 836 | await $.ui.render(Fixtures.HINT) |
| 837 | await $.command.run(Fixtures.DIFF) |
| 838 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 839 | await $.command.run(Fixtures.RESUME) |
| 840 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 841 | |
| 842 | expect(world.closed.map(pane => pane.id), 'closed at /resume').toEqual([ |
| 843 | 'diff', |
| 844 | ]) |
| 845 | |
| 846 | expect( |
| 847 | world.opened.map(pane => pane.id), |
| 848 | '/diff opened it, /resume opened it again for the restored turns', |
| 849 | ).toEqual(['diff', 'diff']) |
| 850 | }) |
| 851 | |
| 852 | test('ask attaches the hunks on screen and calls no tool', async ($, on) => { |
| 853 | const world = Fixtures.inRepository(on, Fixtures.oneSecret()) |
| 854 | const called: Args<'tool.call'>[] = [] |
| 855 | |
| 856 | on('tool.call', ($, e) => { |
| 857 | called.push(e) |
| 858 | |
| 859 | return { result: 'called' } |
| 860 | }) |
| 861 | |
| 862 | on('prompt.submit', ($, e) => ({ text: e.text, context: e.context })) |
| 863 | |
| 864 | await $.session.start(Fixtures.WORKTREE_SESSION) |
| 865 | await $.command.run(Fixtures.DIFF) |
| 866 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 867 | await $.ui.render(Fixtures.PANE) |
| 868 | await $.ui.press({ plugin: 'diff', key: 'ask:.env' }) |
| 869 | await world.clock.settle() |
| 870 | |
| 871 | const armed = Fixtures.jsonOf(await $.ui.render(Fixtures.PANE)) |
| 872 | |
| 873 | const submitted = await $.prompt.submit(Fixtures.typedPromptOf('why?')) |
| 874 | |
| 875 | expect(armed).toContain('asked ✓') |
| 876 | expect(called).toEqual([]) |
| 877 | |
| 878 | expect(world.statuses.at(-2)).toBe( |
| 879 | '.env rides your next prompt (press asked ✓ to drop it)', |
| 880 | ) |
| 881 | |
| 882 | expect(submitted).toMatchObject({ |
| 883 | context: [ |
| 884 | 'The user attached the diff of .env from the diff pane to this ' + |
| 885 | 'prompt:\n@@ -1 +1 @@\n-KEY=old\n+KEY=new', |
| 886 | ], |
| 887 | }) |
| 888 | }) |
| 889 | |
| 890 | test('an ask with no room in the context is dropped', async ($, on) => { |
| 891 | const world = Fixtures.inRepository(on, Fixtures.oneSecret()) |
| 892 | const full = 'x'.repeat(Limits.PROMPT_CONTEXT_MAX_CHARS) |
| 893 | const reached: (readonly string[] | undefined)[] = [] |
| 894 | |
| 895 | on('prompt.submit', ($, e) => { |
| 896 | reached.push(e.context) |
| 897 | |
| 898 | return { text: e.text } |
| 899 | }) |
| 900 | |
| 901 | await $.session.start(Fixtures.WORKTREE_SESSION) |
| 902 | await $.command.run(Fixtures.DIFF) |
| 903 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 904 | await $.ui.render(Fixtures.PANE) |
| 905 | await $.ui.press({ plugin: 'diff', key: 'ask:.env' }) |
| 906 | await world.clock.settle() |
| 907 | |
| 908 | await $.prompt.submit(Fixtures.typedPromptOf('why?', [full])) |
| 909 | await $.prompt.submit(Fixtures.typedPromptOf('and now?')) |
| 910 | |
| 911 | expect(reached).toEqual([[full], undefined]) |
| 912 | |
| 913 | expect(world.statuses.at(-1)).toBe( |
| 914 | ".env's diff did not fit in the prompt and was dropped", |
| 915 | ) |
| 916 | }) |
| 917 | |
| 918 | test('a worktree opens on the base it kept', async ($, on) => { |
| 919 | const world = Fixtures.inRepository(on, Fixtures.oneSecret(), { |
| 920 | stored: { 'base:/main/wt': 'uncommitted' }, |
| 921 | }) |
| 922 | |
| 923 | await $.session.start(Fixtures.WORKTREE_SESSION) |
| 924 | await $.command.run(Fixtures.DIFF) |
| 925 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 926 | |
| 927 | expect( |
| 928 | Fixtures.stringsOf(await $.ui.render(Fixtures.PANE)), |
| 929 | "the built-in's base line under the header", |
| 930 | ).toContain('uncommitted (vs HEAD)') |
| 931 | }) |
| 932 | |
| 933 | test('no repository is an answer kept until /clear', async ($, on) => { |
| 934 | const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } = |
| 935 | Fixtures.oneSecret() |
| 936 | |
| 937 | const script: Record<string, string> = notYet |
| 938 | const world = Fixtures.inRepository(on, script) |
| 939 | |
| 940 | const probesOf = () => |
| 941 | world.runs.filter(run => run.argv.includes('--show-toplevel')).length |
| 942 | |
| 943 | on('command.run', { command: 'clear' }, () => ({})) |
| 944 | |
| 945 | await $.session.start(Fixtures.WORKTREE_SESSION) |
| 946 | |
| 947 | const before = await $.command.run(Fixtures.DIFF) |
| 948 | |
| 949 | script['rev-parse --path-format=absolute'] = worktree |
| 950 | |
| 951 | const again = await $.command.run(Fixtures.DIFF) |
| 952 | const probesKept = probesOf() |
| 953 | |
| 954 | await $.command.run(Fixtures.CLEAR) |
| 955 | |
| 956 | const after = await $.command.run(Fixtures.DIFF) |
| 957 | |
| 958 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 959 | await $.ui.render(Fixtures.PANE) |
| 960 | |
| 961 | script['rev-parse --path-format=absolute'] = Fixtures.ELSEWHERE_LINES |
| 962 | |
| 963 | const third = $.command.run(Fixtures.DIFF) |
| 964 | |
| 965 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 966 | await third |
| 967 | |
| 968 | expect(before.text).toContain("isn't in a git repository") |
| 969 | |
| 970 | expect(again.text, "kept, as the built-in's answer is").toContain( |
| 971 | "isn't in a git repository", |
| 972 | ) |
| 973 | |
| 974 | expect(probesKept, 'one probe answered both').toBe(1) |
| 975 | expect(after.text, '/clear forgot it').toBe('Diff panel shown') |
| 976 | expect(world.opened[0]?.id).toBe('diff') |
| 977 | expect(probesOf(), 'then one more, pinned from there on').toBe(2) |
| 978 | |
| 979 | expect( |
| 980 | world.runs.some(run => run.argv.includes('--git-dir=/else/.git')), |
| 981 | ).toBe(false) |
| 982 | |
| 983 | expect( |
| 984 | world.runs |
| 985 | .filter(run => run.argv.includes('--numstat')) |
| 986 | .every(run => run.argv[1] === Fixtures.PINNED_LEAD[1]), |
| 987 | ).toBe(true) |
| 988 | }) |
| 989 | |
| 990 | test('a repository that appears later opens on first edit', async ($, on) => { |
| 991 | const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } = |
| 992 | Fixtures.oneSecret() |
| 993 | |
| 994 | const script: Record<string, string> = notYet |
| 995 | const world = Fixtures.inRepository(on, script) |
| 996 | |
| 997 | on('tool.call', () => ({ result: 'edited' })) |
| 998 | |
| 999 | await $.session.start(Fixtures.WORKTREE_SESSION) |
| 1000 | await $.ui.render(Fixtures.HINT) |
| 1001 | |
| 1002 | script['rev-parse --path-format=absolute'] = worktree |
| 1003 | |
| 1004 | await $.tool.call({ |
| 1005 | tool: 'Edit', |
| 1006 | file_path: '/main/wt/.env', |
| 1007 | old_string: 'old', |
| 1008 | new_string: 'new', |
| 1009 | }) |
| 1010 | |
| 1011 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 1012 | |
| 1013 | expect(world.opened.map(pane => pane.id)).toEqual(['diff']) |
| 1014 | }) |
| 1015 | |
| 1016 | test('a git that cannot start is an answer, not a timeout', async ($, on) => { |
| 1017 | const runs: Args<'process.run'>[] = [] |
| 1018 | |
| 1019 | Fixtures.startsSession(on) |
| 1020 | |
| 1021 | on('process.run', ($, e) => { |
| 1022 | runs.push(e) |
| 1023 | |
| 1024 | return e.argv.includes('--show-toplevel') |
| 1025 | ? { deny: Fixtures.FAILED_START } |
| 1026 | : { value: Fixtures.gitIn(e.argv, Fixtures.oneSecret()) } |
| 1027 | }) |
| 1028 | |
| 1029 | await $.session.start(Fixtures.WORKTREE_SESSION) |
| 1030 | |
| 1031 | const { text } = await $.command.run(Fixtures.DIFF) |
| 1032 | const probes = runs.filter(run => run.argv.includes('--show-toplevel')) |
| 1033 | |
| 1034 | expect(text).toContain("isn't in a git repository") |
| 1035 | expect(probes, "this /diff's alone; no retry").toHaveLength(1) |
| 1036 | }) |
| 1037 | |
| 1038 | test('/diff on a narrow terminal: the resize line', async ($, on) => { |
| 1039 | const world = Fixtures.inRepository(on, Fixtures.oneSecret()) |
| 1040 | |
| 1041 | await $.session.start(Fixtures.WORKTREE_SESSION) |
| 1042 | |
| 1043 | const narrow = await $.command.run( |
| 1044 | Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS - 1), |
| 1045 | ) |
| 1046 | |
| 1047 | const openedNarrow = [...world.opened] |
| 1048 | |
| 1049 | await $.command.run(Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS)) |
| 1050 | |
| 1051 | expect(narrow.text).toContain( |
| 1052 | 'Resize your terminal to at least 110 columns to show the diff panel', |
| 1053 | ) |
| 1054 | |
| 1055 | expect(openedNarrow).toEqual([]) |
| 1056 | expect(world.opened.map(pane => pane.id)).toEqual([Names.PANE_ID]) |
| 1057 | }) |
| 1058 | }) |