|
| 1 | +import { mkdtempSync, writeFileSync } from 'node:fs' |
| 2 | +import { tmpdir } from 'node:os' |
| 3 | +import { join } from 'node:path' |
1 | 4 | import { describe, expect, it, vi } from 'vitest' |
2 | 5 | import { TerminalService } from '@/main/terminal' |
3 | 6 |
|
| 7 | +/** |
| 8 | + * A tmux attachment the service sees only when a test turns it on: run windows get real status |
| 9 | + * files, and Ctrl-C in a run's window ends its command the way tmux would. |
| 10 | + */ |
| 11 | +const tmuxFake = vi.hoisted(() => ({ |
| 12 | + on: false, |
| 13 | + keys: [] as Array<{ target: string; key: string }>, |
| 14 | + closed: [] as string[], |
| 15 | + statusPaths: new Map<string, string>(), |
| 16 | +})) |
| 17 | + |
| 18 | +vi.mock('@/main/terminal/tmux', async () => { |
| 19 | + const actual = |
| 20 | + await vi.importActual<typeof import('@/main/terminal/tmux')>('@/main/terminal/tmux') |
| 21 | + let nextWindow = 1 |
| 22 | + return { |
| 23 | + ...actual, |
| 24 | + isTmuxUnavailable: () => (tmuxFake.on ? false : actual.isTmuxUnavailable()), |
| 25 | + resolveAttachment: async (pid: number, env: NodeJS.ProcessEnv) => |
| 26 | + tmuxFake.on |
| 27 | + ? { session: 'agent', clientTty: '/dev/ttys001' } |
| 28 | + : actual.resolveAttachment(pid, env), |
| 29 | + startRun: async (...args: Parameters<typeof actual.startRun>) => { |
| 30 | + if (!tmuxFake.on) return actual.startRun(...args) |
| 31 | + const dir = mkdtempSync(join(tmpdir(), 'sim-tmux-fake-')) |
| 32 | + const window = `@${nextWindow++}` |
| 33 | + const statusPath = join(dir, 'status') |
| 34 | + writeFileSync(join(dir, 'out'), '') |
| 35 | + tmuxFake.statusPaths.set(window, statusPath) |
| 36 | + return { window, outPath: join(dir, 'out'), statusPath, dispose: () => {} } |
| 37 | + }, |
| 38 | + sendKey: async (target: string, key: string, env: NodeJS.ProcessEnv) => { |
| 39 | + if (!tmuxFake.on) return actual.sendKey(target, key, env) |
| 40 | + tmuxFake.keys.push({ target, key }) |
| 41 | + const statusPath = tmuxFake.statusPaths.get(target) |
| 42 | + if (key === 'C-c' && statusPath) writeFileSync(statusPath, '130') |
| 43 | + return { ok: true, stdout: '', stderr: '' } |
| 44 | + }, |
| 45 | + closeRunWindow: async (...args: Parameters<typeof actual.closeRunWindow>) => { |
| 46 | + const [handle] = args |
| 47 | + if (!tmuxFake.on) return actual.closeRunWindow(...args) |
| 48 | + tmuxFake.closed.push(handle.window) |
| 49 | + }, |
| 50 | + } |
| 51 | +}) |
| 52 | + |
4 | 53 | /** Stub sessions by terminal id, populated by the mock below. */ |
5 | 54 | const { stubSessions } = vi.hoisted(() => ({ |
6 | 55 | stubSessions: new Map< |
@@ -500,3 +549,28 @@ describe('stopping a tool call', () => { |
500 | 549 | await running |
501 | 550 | }) |
502 | 551 | }) |
| 552 | + |
| 553 | +describe('agent commands in tmux', () => { |
| 554 | + it('stops a tmux run at sign-out while its call still waits on it', async () => { |
| 555 | + tmuxFake.on = true |
| 556 | + try { |
| 557 | + const terminal = new TerminalService({ loadCwd: () => '/tmp' }) |
| 558 | + terminal.start({ cols: 80, rows: 24 }) |
| 559 | + const running = terminal.executeTool('call-tmux', 'run', { |
| 560 | + command: 'sleep 600', |
| 561 | + waitSeconds: 60, |
| 562 | + }) |
| 563 | + await vi.waitFor(() => expect(tmuxFake.statusPaths.size).toBe(1)) |
| 564 | + |
| 565 | + await terminal.stopAgentCommands() |
| 566 | + |
| 567 | + expect(tmuxFake.keys).toEqual([{ target: '@1', key: 'C-c' }]) |
| 568 | + await expect(running).resolves.toMatchObject({ |
| 569 | + ok: true, |
| 570 | + result: { status: 'completed', exitCode: 130 }, |
| 571 | + }) |
| 572 | + } finally { |
| 573 | + tmuxFake.on = false |
| 574 | + } |
| 575 | + }) |
| 576 | +}) |
0 commit comments