diff --git a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts index bec3a976694..3567cb1d8a2 100644 --- a/tests/e2e/terminal-send-agent-prompt-submit.spec.ts +++ b/tests/e2e/terminal-send-agent-prompt-submit.spec.ts @@ -1,46 +1,88 @@ import { execFile } from 'node:child_process' -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { promisify } from 'node:util' import { expect, test } from './helpers/orca-app' +import { buildFakeAgentCommandOverride } from './helpers/fake-agent-command-override' import { waitForSessionReady } from './helpers/store' +import { RuntimeClient } from '../../src/cli/runtime-client' +import { recognizeAgentProcess } from '../../src/shared/agent-process-recognition' import { SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS } from '../../src/shared/orchestration-timing-budgets' const execFileAsync = promisify(execFile) const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-send-agent-prompt-')) -const fixtureBin = path.join(fixtureRoot, 'bin') const fixtureReport = path.join(fixtureRoot, 'report.json') const fixtureMarker = `ORCA_TERMINAL_SEND_E2E_${process.pid}` const fixtureScript = path.join(process.cwd(), 'tests', 'tools', 'repro-terminal-send-submit.mjs') -const fakeCodex = path.join(fixtureBin, process.platform === 'win32' ? 'codex.cmd' : 'codex') +const fakeCodex = path.join(fixtureRoot, process.platform === 'win32' ? 'codex.cmd' : 'codex') +const fakeCodexCommand = buildFakeAgentCommandOverride(fakeCodex) const swallowedEnterFixtureTimeoutMs = SWALLOWED_ENTER_FIXTURE_TIMEOUT_MS -mkdirSync(fixtureBin) writeFileSync( fakeCodex, process.platform === 'win32' ? `@echo off\r\n"${process.execPath}" "${fixtureScript}" --fake-agent --report "%ORCA_FAKE_AGENT_REPORT%" --marker "%ORCA_FAKE_AGENT_MARKER%" --allow-unframed-paste %*\r\n` - : `#!/usr/bin/env sh\nexec "${process.execPath}" "${fixtureScript}" --fake-agent --report "$ORCA_FAKE_AGENT_REPORT" --marker "$ORCA_FAKE_AGENT_MARKER" "$@"\n`, + : `#!/usr/bin/env sh\n"${process.execPath}" "${fixtureScript}" --fake-agent --report "$ORCA_FAKE_AGENT_REPORT" --marker "$ORCA_FAKE_AGENT_MARKER" "$@"\n`, 'utf8' ) if (process.platform !== 'win32') { chmodSync(fakeCodex, 0o755) } -test.use({ - seedTestRepo: false, - orcaAppExtraEnv: { - PATH: `${fixtureBin}${path.delimiter}${process.env.PATH ?? ''}`, - ORCA_FAKE_AGENT_REPORT: fixtureReport, - ORCA_FAKE_AGENT_MARKER: fixtureMarker - } -}) - test.afterAll(() => { rmSync(fixtureRoot, { recursive: true, force: true }) }) +async function createFakeCodexTerminal( + userDataDir: string, + testRepoPath: string, + args: string[] = [] +): Promise { + const client = new RuntimeClient(userDataDir, 30_000, null, null) + const expectedPath = path.resolve(testRepoPath) + const findWorktree = async (): Promise<{ id: string } | undefined> => { + const listed = await client.call<{ worktrees: { id: string; path: string }[] }>( + 'worktree.list', + {} + ) + return listed.result.worktrees.find((worktree) => { + const candidatePath = path.resolve(worktree.path) + return process.platform === 'win32' + ? candidatePath.toLowerCase() === expectedPath.toLowerCase() + : candidatePath === expectedPath + }) + } + await expect.poll(async () => Boolean(await findWorktree()), { timeout: 60_000 }).toBe(true) + const worktree = await findWorktree() + if (!worktree) { + throw new Error(`runtime did not register ${testRepoPath}`) + } + const created = await client.call<{ terminal: { handle: string } }>('terminal.create', { + worktree: `id:${worktree.id}`, + command: [fakeCodexCommand, ...args].join(' '), + launchAgent: 'codex', + env: { + ORCA_FAKE_AGENT_REPORT: fixtureReport, + ORCA_FAKE_AGENT_MARKER: fixtureMarker + }, + title: 'terminal send submit repro' + }) + const handle = created.result.terminal.handle + await expect + .poll( + async () => { + const inspected = await client.call<{ + process: { foregroundProcess: string | null } + }>('terminal.inspectProcess', { terminal: handle }) + return recognizeAgentProcess(inspected.result.process.foregroundProcess)?.agent ?? null + }, + { timeout: 30_000 } + ) + .toBe('codex') + return handle +} + test('CLI text plus Enter waits for a slow agent composer before submitting', async ({ electronApp, orcaPage, @@ -49,6 +91,7 @@ test('CLI text plus Enter waits for a slow agent composer before submitting', as test.setTimeout(110_000) await waitForSessionReady(orcaPage) const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const terminal = await createFakeCodexTerminal(userDataDir, testRepoPath) const repoRoot = process.cwd() let stdout = '' try { @@ -60,8 +103,8 @@ test('CLI text plus Enter waits for a slow agent composer before submitting', as path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'), '--worktree', testRepoPath, - '--agent-command', - 'codex', + '--terminal', + terminal, '--report', fixtureReport, '--marker', @@ -99,6 +142,11 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ test.setTimeout(110_000) await waitForSessionReady(orcaPage) const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const terminal = await createFakeCodexTerminal(userDataDir, testRepoPath, [ + '--swallow-first-enter', + '--timeout-ms', + String(swallowedEnterFixtureTimeoutMs) + ]) const repoRoot = process.cwd() let stdout = '' try { @@ -110,8 +158,10 @@ test('CLI reports a swallowed Enter without submitting a second Enter', async ({ path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'), '--worktree', testRepoPath, - '--agent-command', - `codex --swallow-first-enter --timeout-ms ${swallowedEnterFixtureTimeoutMs}`, + '--terminal', + terminal, + '--timeout-ms', + String(swallowedEnterFixtureTimeoutMs), '--expect-stalled', '--report', fixtureReport, @@ -152,6 +202,9 @@ test('CLI does not write prompt bytes into an active permission dialog', async ( test.setTimeout(110_000) await waitForSessionReady(orcaPage) const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const terminal = await createFakeCodexTerminal(userDataDir, testRepoPath, [ + '--permission-before-send' + ]) const repoRoot = process.cwd() let stdout = '' try { @@ -163,6 +216,8 @@ test('CLI does not write prompt bytes into an active permission dialog', async ( path.join(repoRoot, 'config', 'scripts', 'orca-dev.mjs'), '--worktree', testRepoPath, + '--terminal', + terminal, '--expect-blocked', '--report', fixtureReport, diff --git a/tests/tools/repro-terminal-send-submit.mjs b/tests/tools/repro-terminal-send-submit.mjs index 6e44b1ce665..7e1c0152012 100644 --- a/tests/tools/repro-terminal-send-submit.mjs +++ b/tests/tools/repro-terminal-send-submit.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node import { spawn } from 'node:child_process' -import { mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' +import { pathToFileURL } from 'node:url' const SCRIPT_PATH = import.meta.filename const BEGIN = '\x1b[200~' @@ -118,6 +119,20 @@ async function closeTerminal(cli, handle, cwd) { } } +async function waitForPermissionPrompt(cli, handle, cwd) { + const deadline = Date.now() + 10_000 + let lastTerminal = null + while (Date.now() < deadline) { + const shown = await callOrca(cli, ['terminal', 'show', '--terminal', handle], cwd) + lastTerminal = shown.terminal ?? null + if (lastTerminal?.agentWait) { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`terminal permission prompt was not observed: ${JSON.stringify(lastTerminal)}`) +} + async function waitForWorktreeSelector(cli, repoId, cwd) { const deadline = Date.now() + 10_000 const expectedPath = path.resolve(cwd) @@ -138,18 +153,25 @@ async function waitForWorktreeSelector(cli, repoId, cwd) { } async function createFakeCodexCommand(tempDir, args) { + const launcherPath = path.join(tempDir, 'codex') + const runnerPath = path.join(tempDir, 'fake-agent.mjs') + await writeFile(runnerPath, `import ${JSON.stringify(pathToFileURL(SCRIPT_PATH).href)}\n`, 'utf8') if (process.platform === 'win32') { - const launcherPath = path.join(tempDir, 'codex.cmd') + const commandPath = path.join(tempDir, 'codex.cmd') await writeFile( - launcherPath, - `@echo off\r\n"${process.execPath}" "${SCRIPT_PATH}" %*\r\n`, + commandPath, + `@echo off\r\n"${process.execPath}" "%~dp0fake-agent.mjs" %*\r\n`, 'utf8' ) - return [shellQuote(launcherPath), ...args].join(' ') + return [shellQuote(commandPath), ...args].join(' ') } - const launcherPath = path.join(tempDir, 'codex') - await symlink(process.execPath, launcherPath) - return [shellQuote(launcherPath), shellQuote(SCRIPT_PATH), ...args].join(' ') + await writeFile( + launcherPath, + `#!/usr/bin/env sh\n"${process.execPath}" "${runnerPath}" "$@"\n`, + 'utf8' + ) + await chmod(launcherPath, 0o755) + return [shellQuote(launcherPath), ...args].join(' ') } async function parentMain() { @@ -162,43 +184,48 @@ async function parentMain() { const prompt = `${marker} ${'slow composer payload '.repeat(24)}` const expectStalled = hasFlag('expect-stalled') const expectBlocked = hasFlag('expect-blocked') + const providedHandle = argValue('terminal') await mkdir(tempDir, { recursive: true }) await rm(reportPath, { force: true }) - const command = - argValue('agent-command') ?? - (await createFakeCodexCommand(tempDir, [ - '--fake-agent', - '--report', - shellQuote(reportPath), - '--marker', - shellQuote(marker), - '--timeout-ms', - String(timeoutMs), - ...(expectBlocked ? ['--permission-before-send'] : []), - ...(process.platform === 'win32' ? ['--allow-unframed-paste'] : []) - ])) - const added = await callOrca(cli, ['repo', 'add', '--path', cwd], cwd) - const repoId = added.repo?.id - if (!repoId) { - throw new Error('repo add returned no id') + let handle = providedHandle + if (!handle) { + const command = + argValue('agent-command') ?? + (await createFakeCodexCommand(tempDir, [ + '--fake-agent', + '--report', + shellQuote(reportPath), + '--marker', + shellQuote(marker), + '--timeout-ms', + String(timeoutMs), + ...(expectStalled ? ['--swallow-first-enter'] : []), + ...(expectBlocked ? ['--permission-before-send'] : []), + ...(process.platform === 'win32' ? ['--allow-unframed-paste'] : []) + ])) + const added = await callOrca(cli, ['repo', 'add', '--path', cwd], cwd) + const repoId = added.repo?.id + if (!repoId) { + throw new Error('repo add returned no id') + } + const worktreeSelector = await waitForWorktreeSelector(cli, repoId, cwd) + const created = await callOrca( + cli, + [ + 'terminal', + 'create', + '--worktree', + worktreeSelector, + '--title', + 'terminal send submit repro', + '--command', + command + ], + cwd + ) + handle = created.terminal?.handle } - const worktreeSelector = await waitForWorktreeSelector(cli, repoId, cwd) - const created = await callOrca( - cli, - [ - 'terminal', - 'create', - '--worktree', - worktreeSelector, - '--title', - 'terminal send submit repro', - '--command', - command - ], - cwd - ) - const handle = created.terminal?.handle if (!handle) { throw new Error('terminal create returned no handle') } @@ -209,6 +236,7 @@ async function parentMain() { if (!setupReport) { throw new Error('terminal permission prompt did not materialize') } + await waitForPermissionPrompt(cli, handle, cwd) } else { await callOrca( cli, @@ -278,6 +306,7 @@ async function parentMain() { } async function fakeAgentMain() { + process.title = 'codex' const reportPath = argValue('report') const marker = argValue('marker') const timeoutMs = parsePositiveInteger('timeout-ms', DEFAULT_TIMEOUT_MS) @@ -291,10 +320,14 @@ async function fakeAgentMain() { process.stdin.setRawMode(true) } process.stdin.resume() - process.stdout.write('OpenAI Codex\nmodel: fake\ndirectory: fixture\n> ') - if (permissionBeforeSend) { - process.stdout.write('\nPermission required\nAllow once\nAllow always\nReject\n') - } + process.stdout.write('\x1b]0;Codex working\x07') + setTimeout(() => { + const title = permissionBeforeSend ? 'Codex permission' : 'Codex Ready' + process.stdout.write(`\x1b]0;${title}\x07OpenAI Codex\nmodel: fake\ndirectory: fixture\n> `) + if (permissionBeforeSend) { + process.stdout.write('\nPermission required\nAllow once\nAllow always\nReject\n') + } + }, 100) let input = '' let countedCarriages = 0