diff --git a/src/main/providers/agent-foreground-process-git-bash.win32.test.ts b/src/main/providers/agent-foreground-process-git-bash.win32.test.ts index 568127bb5af..60616dee919 100644 --- a/src/main/providers/agent-foreground-process-git-bash.win32.test.ts +++ b/src/main/providers/agent-foreground-process-git-bash.win32.test.ts @@ -1,125 +1,23 @@ -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { removeTreeSync } from '../../shared/windows-transient-lock-removal' -import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { confirmPtyShellForeground } from '../daemon/pty-subprocess/pty-shell-foreground-confirmation' -import { createPtyShellLaunchPlan } from '../daemon/pty-subprocess/shell-launch-plan' -import { spawnNativeDaemonPty } from '../daemon/pty-subprocess/native-pty-spawn' -import { canUseBunPty, spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' -import { createWindowsBunPtyLaunch } from '../daemon/pty-subprocess/windows-bun-pty-launch' -import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment' -import type { PtySubprocessOptions } from '../daemon/pty-subprocess' -import { isGitForWindowsBashLauncherPath } from '../git-bash' -import { readWindowsPtyJobProcessIds } from './windows-pty-job-membership' +import { describe, it } from 'vitest' +import { runBundledBunFixture } from '../bundled-bun-test-execution' const describeOnWindows = process.platform === 'win32' ? describe : describe.skip describeOnWindows("Git Bash launcher shell proof with Orca's real launch", () => { - let userData: string - const previousUserData = process.env.ORCA_USER_DATA_PATH - - beforeAll(() => { - // The launch plan writes shell-ready wrappers under the user-data root. - userData = mkdtempSync(join(tmpdir(), 'orca-git-bash-proof-')) - process.env.ORCA_USER_DATA_PATH = userData - }) - - afterAll(() => { - if (previousUserData === undefined) { - delete process.env.ORCA_USER_DATA_PATH - } else { - process.env.ORCA_USER_DATA_PATH = previousUserData - } - removeTreeSync(userData) - }) - it.each([ ['login shell', {}], - // Why: with the Codex preflight set, the exec'd shell runs Orca's --rcfile wrapper. ['rcfile wrapper', { ORCA_CODEX_LAUNCH_PREFLIGHT: 'C:\\orca-missing-preflight.exe' }] ])( 'confirms an idle %s prompt, refutes a running command, and confirms again', async (_label, extraEnv) => { - const opts: PtySubprocessOptions = { - sessionId: 'git-bash-shell-proof', - cols: 120, - rows: 30, - cwd: tmpdir(), - shellOverride: WINDOWS_GIT_BASH_SHELL, - env: extraEnv - } - const env = createDaemonPtyEnvironment(opts) - const plan = await createPtyShellLaunchPlan(opts, env) - expect(isGitForWindowsBashLauncherPath(plan.shellPath)).toBe(true) - expect(plan.shellArgs.join(' ')).toContain('exec "$BASH"') - const spawned = await spawnNativeDaemonPty( - { ...plan, env, cols: opts.cols, rows: opts.rows }, - { - canUseBunPty, - spawnBunPty: (args) => - spawnBunPty(args, { - // Source tests use the TS worker; packaged hosts resolve their adjacent JS worker. - createWindowsLaunch: (launch) => - createWindowsBunPtyLaunch(launch, { - workerPath: join( - __dirname, - '../daemon/pty-subprocess/windows-bun-pty-gate-entry.ts' - ) - }) - }) - } + await runBundledBunFixture( + join(__dirname, 'git-bash-foreground-bun-fixture.ts'), + 'verifyGitBashForeground', + extraEnv, + 60_000 ) - const proc = spawned.process - let output = '' - let dead = false - proc.onData((chunk) => { - output += chunk - }) - proc.onExit(() => { - dead = true - }) - const confirm = (): Promise => - confirmPtyShellForeground({ - process: proc, - shellPath: spawned.shellPath, - isDead: () => dead - }) - try { - await vi.waitFor(() => expect(output).toContain('$'), { timeout: 20_000 }) - // Launcher, exec stub, interactive bash: the shape that a size-1 or size-2 rule never matches. - await vi.waitFor(() => expect(readWindowsPtyJobProcessIds(proc)?.size).toBe(3), { - timeout: 5_000 - }) - await vi.waitFor(async () => expect(await confirm(), 'initial prompt').toBe(true), { - timeout: 5_000 - }) - - // Interrupt only after the child is ready, not during a transient shell fork. - proc.write( - "node -e \"console.log(['ORCA','FOREGROUND_READY'].join('_')); setInterval(() => {}, 1000)\"\r" - ) - await vi.waitFor(() => expect(output).toContain('ORCA_FOREGROUND_READY'), { - timeout: 10_000 - }) - await vi.waitFor(async () => expect(await confirm()).toBe(false), { timeout: 10_000 }) - - proc.write('\x03') - await vi.waitFor( - async () => { - expect(dead, 'terminal survived foreground interrupt').toBe(false) - expect(await confirm(), 'prompt after interrupt').toBe(true) - }, - { timeout: 10_000 } - ) - - proc.write('sleep 60 &\r') - await vi.waitFor(async () => expect(await confirm()).toBe(false), { timeout: 10_000 }) - } finally { - proc.kill() - } }, - 60_000 + 65_000 ) }) diff --git a/src/main/providers/git-bash-foreground-bun-fixture.ts b/src/main/providers/git-bash-foreground-bun-fixture.ts new file mode 100644 index 00000000000..17b20e0cf23 --- /dev/null +++ b/src/main/providers/git-bash-foreground-bun-fixture.ts @@ -0,0 +1,134 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import assert from 'node:assert/strict' +import { setTimeout as delay } from 'node:timers/promises' +import { removeTreeSync } from '../../shared/windows-transient-lock-removal' +import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' +import { confirmPtyShellForeground } from '../daemon/pty-subprocess/pty-shell-foreground-confirmation' +import { createPtyShellLaunchPlan } from '../daemon/pty-subprocess/shell-launch-plan' +import { spawnNativeDaemonPty } from '../daemon/pty-subprocess/native-pty-spawn' +import { canUseBunPty, spawnBunPty } from '../daemon/pty-subprocess/bun-pty-process' +import { createWindowsBunPtyLaunch } from '../daemon/pty-subprocess/windows-bun-pty-launch' +import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment' +import type { PtySubprocessOptions } from '../daemon/pty-subprocess' +import { isGitForWindowsBashLauncherPath } from '../git-bash' +import { readWindowsPtyJobProcessIds } from './windows-pty-job-membership' + +async function waitForAssertion(check: () => unknown, options: { timeout: number }): Promise { + const deadline = Date.now() + options.timeout + for (;;) { + try { + await check() + return + } catch (error) { + if (Date.now() >= deadline) { + throw error + } + await delay(50) + } + } +} + +export async function verifyGitBashForeground(extraEnv: Record): Promise { + assert.equal(process.platform, 'win32') + assert(process.versions.bun) + const previousUserData = process.env.ORCA_USER_DATA_PATH + const userData = mkdtempSync(join(tmpdir(), 'orca-git-bash-proof-')) + process.env.ORCA_USER_DATA_PATH = userData + try { + const opts: PtySubprocessOptions = { + sessionId: 'git-bash-shell-proof', + cols: 120, + rows: 30, + cwd: tmpdir(), + shellOverride: WINDOWS_GIT_BASH_SHELL, + env: extraEnv + } + const env = createDaemonPtyEnvironment(opts) + const plan = await createPtyShellLaunchPlan(opts, env) + assert(isGitForWindowsBashLauncherPath(plan.shellPath)) + assert(plan.shellArgs.join(' ').includes('exec "$BASH"')) + const spawned = await spawnNativeDaemonPty( + { ...plan, env, cols: opts.cols, rows: opts.rows }, + { + canUseBunPty, + spawnBunPty: (args) => + spawnBunPty(args, { + // Source tests use the TS worker; packaged hosts resolve their adjacent JS worker. + createWindowsLaunch: (launch) => + createWindowsBunPtyLaunch(launch, { + workerPath: join( + __dirname, + '../daemon/pty-subprocess/windows-bun-pty-gate-entry.ts' + ) + }) + }) + } + ) + const proc = spawned.process + let output = '' + let dead = false + proc.onData((chunk) => { + output += chunk + }) + const exited = new Promise((resolve) => { + proc.onExit(() => { + dead = true + resolve() + }) + }) + const confirm = (): Promise => + confirmPtyShellForeground({ + process: proc, + shellPath: spawned.shellPath, + isDead: () => dead + }) + try { + await waitForAssertion(() => assert(output.includes('$')), { timeout: 20_000 }) + // Launcher, exec stub, interactive bash: the shape that a size-1 or size-2 rule never matches. + await waitForAssertion(() => assert.equal(readWindowsPtyJobProcessIds(proc)?.size, 3), { + timeout: 5_000 + }) + await waitForAssertion(async () => assert.equal(await confirm(), true, 'initial prompt'), { + timeout: 5_000 + }) + + // Interrupt only after the child is ready, not during a transient shell fork. + proc.write( + "node -e \"console.log(['ORCA','FOREGROUND_READY'].join('_')); setInterval(() => {}, 1000)\"\r" + ) + await waitForAssertion(() => assert(output.includes('ORCA_FOREGROUND_READY')), { + timeout: 10_000 + }) + await waitForAssertion(async () => assert.equal(await confirm(), false), { timeout: 10_000 }) + + proc.write('\x03') + await waitForAssertion( + async () => { + assert.equal(dead, false, 'terminal survived foreground interrupt') + assert.equal(await confirm(), true, 'prompt after interrupt') + }, + { timeout: 10_000 } + ) + + proc.write('sleep 60 &\r') + await waitForAssertion(async () => assert.equal(await confirm(), false), { timeout: 10_000 }) + } finally { + proc.kill() + await Promise.race([ + exited, + delay(5_000, undefined, { ref: false }).then(() => { + throw new Error('Git Bash fixture did not exit') + }) + ]) + } + } finally { + if (previousUserData === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserData + } + removeTreeSync(userData) + } +}