diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 1f0a2b42755..040996489d1 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -74,6 +74,7 @@ jobs: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden - name: Run golden E2E tests on macOS @@ -87,6 +88,7 @@ jobs: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden - name: Run golden E2E tests on Windows @@ -97,6 +99,7 @@ jobs: $env:ORCA_E2E_FORWARD_APP_LOGS = '1' pnpm run --if-present test:e2e:workspace-session-golden pnpm run --if-present test:e2e:windows-fresh-startup-golden + pnpm run --if-present test:e2e:tab-bar-agent-launch-golden if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) { pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts } diff --git a/package.json b/package.json index a026bdb7824..66d0d0bf53e 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:agent-tui-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-agent-tui-launch.spec.ts tests/e2e/golden-shell-after-agent-exit.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:tab-bar-agent-launch-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-tab-bar-agent-launch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", "test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs", diff --git a/tests/e2e/fixtures/golden-stub-agent/claude b/tests/e2e/fixtures/golden-stub-agent/claude new file mode 100755 index 00000000000..d16a99de266 --- /dev/null +++ b/tests/e2e/fixtures/golden-stub-agent/claude @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/golden-stub-agent" "$@" diff --git a/tests/e2e/fixtures/golden-stub-agent/claude.cmd b/tests/e2e/fixtures/golden-stub-agent/claude.cmd new file mode 100644 index 00000000000..894a9216aa2 --- /dev/null +++ b/tests/e2e/fixtures/golden-stub-agent/claude.cmd @@ -0,0 +1,2 @@ +@echo off +call "%~dp0golden-stub-agent.cmd" %* diff --git a/tests/e2e/golden-tab-bar-agent-launch.spec.ts b/tests/e2e/golden-tab-bar-agent-launch.spec.ts new file mode 100644 index 00000000000..d8501c0a668 --- /dev/null +++ b/tests/e2e/golden-tab-bar-agent-launch.spec.ts @@ -0,0 +1,90 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + configureGoldenStubAgent, + getGoldenStubAgentLaunchEnv, + GOLDEN_STUB_AGENTS, + GOLDEN_STUB_READY_MARKER, + launchGoldenStubAgentFromNewTab +} from './helpers/golden-stub-agent' +import { + getFirstWslDistro, + removeWslGoldenStubAgent, + stageWslGoldenStubAgent, + useWslRuntimeForActiveProject +} from './helpers/wsl-golden-stub-agent' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { getTerminalContent } from './helpers/terminal' +import type { BuiltInWindowsTerminalShell } from '../../src/shared/windows-terminal-shell' + +// Covers the Windows-only tab-bar launch path that the default-shell test misses. + +test.use({ launchEnv: getGoldenStubAgentLaunchEnv() }) + +const WINDOWS_SHELLS: readonly BuiltInWindowsTerminalShell[] = [ + 'powershell.exe', + 'cmd.exe', + 'git-bash' +] + +async function openWorkspaceTerminal(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) +} + +for (const { id, menuItemName } of GOLDEN_STUB_AGENTS) { + test(`tab-bar + menu launches ${id} into a live TUI @tab-bar-agent-launch-golden`, async ({ + orcaPage + }) => { + await openWorkspaceTerminal(orcaPage) + await configureGoldenStubAgent(orcaPage, { agent: id }) + await launchGoldenStubAgentFromNewTab(orcaPage, menuItemName) + + const activeTab = orcaPage.locator('[data-testid="sortable-tab"][data-active="true"]') + await expect(activeTab).toHaveAttribute('data-tab-title', /Golden Stub Agent|Codex|Claude/i) + // The marker distinguishes an agent launch from an identical bare-shell tab. + expect(await getTerminalContent(orcaPage)).toContain(GOLDEN_STUB_READY_MARKER) + }) +} + +// Suite-level skipping avoids launching Electron for each unsupported case. +test.describe('Windows runtimes', () => { + test.skip(process.platform !== 'win32', 'Windows agent launch matrix is Windows-only') + + for (const shell of WINDOWS_SHELLS) { + test(`tab-bar + menu launches an agent under ${shell} @tab-bar-agent-launch-golden`, async ({ + orcaPage + }) => { + await openWorkspaceTerminal(orcaPage) + // Each shell family requires different launch-command quoting. + await configureGoldenStubAgent(orcaPage, { agent: 'codex', windowsShell: shell }) + await launchGoldenStubAgentFromNewTab(orcaPage) + + expect(await getTerminalContent(orcaPage)).toContain(GOLDEN_STUB_READY_MARKER) + }) + } + + test('tab-bar + menu launches an agent inside WSL @tab-bar-agent-launch-golden', async ({ + orcaPage + }) => { + await openWorkspaceTerminal(orcaPage) + + const distro = await getFirstWslDistro(orcaPage) + test.skip(!distro, 'No WSL distro is available on this Windows host') + const stage = stageWslGoldenStubAgent(distro!) + test.skip(!stage, 'WSL distro would not accept the staged stub agent') + + try { + // WSL must retarget both agent detection and the PTY. + await useWslRuntimeForActiveProject(orcaPage, distro!) + await configureGoldenStubAgent(orcaPage, { agent: 'codex' }) + await launchGoldenStubAgentFromNewTab(orcaPage) + + // The distro-only marker proves the agent ran in WSL. + expect(await getTerminalContent(orcaPage)).toContain(GOLDEN_STUB_READY_MARKER) + } finally { + removeWslGoldenStubAgent(distro!, stage!) + } + }) +}) diff --git a/tests/e2e/helpers/golden-stub-agent.ts b/tests/e2e/helpers/golden-stub-agent.ts index 62e44c9b091..5f01e691c53 100644 --- a/tests/e2e/helpers/golden-stub-agent.ts +++ b/tests/e2e/helpers/golden-stub-agent.ts @@ -2,10 +2,17 @@ import path from 'node:path' import type { Page } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' import { focusActiveTerminalInput, waitForTerminalOutput } from './terminal' +import type { BuiltInWindowsTerminalShell } from '../../../src/shared/windows-terminal-shell' export const GOLDEN_STUB_READY_MARKER = 'GOLDEN_STUB_AGENT_READY' export const GOLDEN_STUB_EXIT_MARKER = 'GOLDEN_STUB_AGENT_EXITED' +/** Agents exposed by the fixture directory for tab-bar detection. */ +export const GOLDEN_STUB_AGENTS = [ + { id: 'codex', menuItemName: /^Codex(?:\s|$)/i }, + { id: 'claude', menuItemName: /^Claude(?:\s|$)/i } +] as const + const fixtureDir = path.join(process.cwd(), 'tests', 'e2e', 'fixtures', 'golden-stub-agent') export function getGoldenStubAgentLaunchEnv(): NodeJS.ProcessEnv { @@ -15,23 +22,38 @@ export function getGoldenStubAgentLaunchEnv(): NodeJS.ProcessEnv { } } -export async function configureGoldenStubAgent(page: Page): Promise { - await page.evaluate(async () => { - const store = window.__store - if (!store) { - throw new Error('Orca store is unavailable') - } - await store.getState().updateSettings({ - defaultTuiAgent: 'codex', - agentCmdOverrides: { codex: 'golden-stub-agent' }, - agentDefaultArgs: { codex: '' } - }) - }) +export async function configureGoldenStubAgent( + page: Page, + options: { + agent?: (typeof GOLDEN_STUB_AGENTS)[number]['id'] + /** Windows default shell the launch command must survive; ignored elsewhere. */ + windowsShell?: BuiltInWindowsTerminalShell + } = {} +): Promise { + const agent = options.agent ?? 'codex' + await page.evaluate( + async ({ agent, windowsShell }) => { + const store = window.__store + if (!store) { + throw new Error('Orca store is unavailable') + } + await store.getState().updateSettings({ + defaultTuiAgent: agent, + agentCmdOverrides: { [agent]: 'golden-stub-agent' }, + agentDefaultArgs: { [agent]: '' }, + ...(windowsShell ? { terminalWindowsShell: windowsShell } : {}) + }) + }, + { agent, windowsShell: options.windowsShell ?? null } + ) } -export async function launchGoldenStubAgentFromNewTab(page: Page): Promise { +export async function launchGoldenStubAgentFromNewTab( + page: Page, + menuItemName: RegExp = /^Codex(?:\s|$)/i +): Promise { await page.getByRole('button', { name: 'New tab' }).click({ force: true }) - const launchOption = page.getByRole('menuitem', { name: /^Codex(?:\s|$)/i }).first() + const launchOption = page.getByRole('menuitem', { name: menuItemName }).first() await expect(launchOption).toBeVisible({ timeout: 15_000 }) await launchOption.click({ force: true }) await focusActiveTerminalInput(page) diff --git a/tests/e2e/helpers/wsl-golden-stub-agent.ts b/tests/e2e/helpers/wsl-golden-stub-agent.ts new file mode 100644 index 00000000000..0b09644c14b --- /dev/null +++ b/tests/e2e/helpers/wsl-golden-stub-agent.ts @@ -0,0 +1,159 @@ +import { execFileSync } from 'node:child_process' +import type { Page } from '@stablyai/playwright-test' +import { buildWslExecArgs } from '../../../src/shared/wsl-login-shell-command' + +/** A WSL-only path makes the stub marker proof that the pane ran in the distro. */ +const WSL_STUB_PATH = '/usr/local/bin/golden-stub-agent' +const WSL_STUB_AGENT_LINK = '/usr/local/bin/codex' +const WSL_STUB_BACKUP_PATH = '/usr/local/bin/golden-stub-agent.orca-e2e-backup' +/** mkdir is atomic in the distro, so the lock dir serializes overlapping invocations. */ +const WSL_STUB_LOCK_PATH = '/usr/local/bin/golden-stub-agent.orca-e2e-lock' +const WSL_STUB_LINK_MARKER = `${WSL_STUB_LOCK_PATH}/created-codex-link` +const WSL_STUB_STAGED_MARKER = `${WSL_STUB_LOCK_PATH}/staged-stub` +const WSL_STUB_LOCK_STALE_MINUTES = 10 +const WSL_STUB_LOCK_WAIT_SECONDS = 60 + +// Undoes a lock holder that died mid-run, so its leftovers cannot poison later invocations. +const RECLAIM_STALE_LOCK_SCRIPT = + `if [ -e ${WSL_STUB_LINK_MARKER} ]; then rm -f ${WSL_STUB_AGENT_LINK}; fi; ` + + `if [ -e ${WSL_STUB_STAGED_MARKER} ]; then rm -f ${WSL_STUB_PATH}; fi; ` + + `if [ -e ${WSL_STUB_BACKUP_PATH} ] || [ -L ${WSL_STUB_BACKUP_PATH} ]; then ` + + `mv ${WSL_STUB_BACKUP_PATH} ${WSL_STUB_PATH}; fi; ` + + `rm -rf ${WSL_STUB_LOCK_PATH}` + +const ACQUIRE_LOCK_SCRIPT = + `mkdir -p /usr/local/bin || exit 1; i=0; ` + + `while [ $i -lt ${WSL_STUB_LOCK_WAIT_SECONDS} ]; do ` + + `if mkdir ${WSL_STUB_LOCK_PATH} 2>/dev/null; then printf acquired; exit 0; fi; ` + + `if [ -n "$(find ${WSL_STUB_LOCK_PATH} -maxdepth 0 -mmin +${WSL_STUB_LOCK_STALE_MINUTES} ` + + `2>/dev/null)" ]; then ${RECLAIM_STALE_LOCK_SCRIPT}; else sleep 1; fi; i=$((i+1)); ` + + `done; printf timeout` + +// Keep the cross-boundary script newline-free to avoid Windows argv-encoding surprises. +// Moving the entry avoids following and overwriting a pre-existing symlink. +const BACKUP_EXISTING_STUB_SCRIPT = + `mkdir -p /usr/local/bin && ` + + `if [ -e ${WSL_STUB_BACKUP_PATH} ] || [ -L ${WSL_STUB_BACKUP_PATH} ]; then exit 1; fi && ` + + `if [ -e ${WSL_STUB_PATH} ] || [ -L ${WSL_STUB_PATH} ]; then ` + + `mv ${WSL_STUB_PATH} ${WSL_STUB_BACKUP_PATH} && ` + + `printf backed-up; else printf none; fi` + +// The marker is written first so stale-lock recovery only removes a stub this helper wrote. +const STAGE_SCRIPT = + `mkdir -p /usr/local/bin && : > ${WSL_STUB_STAGED_MARKER} && ` + + `printf '#!/bin/sh\\necho GOLDEN_STUB_AGENT_READY\\nexec sleep 3600\\n' > ${WSL_STUB_PATH} && ` + + `chmod 0755 ${WSL_STUB_PATH}` + +// The marker is written before the link so a crashed run over-reports rather than leaks a link. +const STAGE_CODEX_LINK_IF_MISSING_SCRIPT = + `if [ -e ${WSL_STUB_AGENT_LINK} ] || [ -L ${WSL_STUB_AGENT_LINK} ]; then ` + + `printf existing; else : > ${WSL_STUB_LINK_MARKER} && ` + + `ln -s ${WSL_STUB_PATH} ${WSL_STUB_AGENT_LINK} && printf created; fi` + +// `;` between steps so the lock is released even when a restore step fails. +function buildRestoreScript(stage: WslGoldenStubAgentStage): string { + const steps: string[] = [] + if (stage.ownsStubPath) { + const removed = stage.createdCodexLink + ? `${WSL_STUB_AGENT_LINK} ${WSL_STUB_PATH}` + : WSL_STUB_PATH + steps.push(`rm -f ${removed}`) + if (stage.backedUpStub) { + steps.push(`mv ${WSL_STUB_BACKUP_PATH} ${WSL_STUB_PATH}`) + } + } + if (stage.heldLock) { + steps.push(`rm -rf ${WSL_STUB_LOCK_PATH}`) + } + return steps.join(' ; ') +} + +// --exec prevents wsl.exe from expanding shell variables in argv. +function runInWslAsRoot(distro: string, script: string): string { + return execFileSync( + 'wsl.exe', + ['-u', 'root', ...buildWslExecArgs(distro, ['sh', '-c', script])], + { encoding: 'utf8', stdio: 'pipe', windowsHide: true } + ) +} + +export async function getFirstWslDistro(page: Page): Promise { + const wsl = await page.evaluate(async () => ({ + available: await window.api.wsl.isAvailable(), + distros: await window.api.wsl.listDistros() + })) + return wsl.available ? (wsl.distros[0] ?? null) : null +} + +export type WslGoldenStubAgentStage = { + createdCodexLink: boolean + backedUpStub: boolean + ownsStubPath: boolean + heldLock: boolean +} + +/** Returns null when the distro cannot stage the stub. Holds a distro lock until cleanup. */ +export function stageWslGoldenStubAgent(distro: string): WslGoldenStubAgentStage | null { + const stage: WslGoldenStubAgentStage = { + createdCodexLink: false, + backedUpStub: false, + ownsStubPath: false, + heldLock: false + } + try { + if (runInWslAsRoot(distro, ACQUIRE_LOCK_SCRIPT).trim() !== 'acquired') { + return null + } + stage.heldLock = true + stage.backedUpStub = runInWslAsRoot(distro, BACKUP_EXISTING_STUB_SCRIPT).trim() === 'backed-up' + stage.ownsStubPath = true + runInWslAsRoot(distro, STAGE_SCRIPT) + stage.createdCodexLink = + runInWslAsRoot(distro, STAGE_CODEX_LINK_IF_MISSING_SCRIPT).trim() === 'created' + return stage + } catch { + removeWslGoldenStubAgent(distro, stage) + return null + } +} + +export function removeWslGoldenStubAgent(distro: string, stage: WslGoldenStubAgentStage): void { + const script = buildRestoreScript(stage) + if (!script) { + return + } + try { + runInWslAsRoot(distro, script) + stage.heldLock = false + stage.ownsStubPath = false + } catch { + // Best-effort cleanup; a leftover stub only affects this fixture's own name. + } +} + +/** Retargets project agent detection and terminal spawning to WSL. */ +export async function useWslRuntimeForActiveProject(page: Page, distro: string): Promise { + await page.evaluate(async (wslDistro) => { + const store = window.__store + if (!store) { + throw new Error('Orca store is unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('No active worktree') + } + const activeWorktree = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === worktreeId) + const activeProject = state.projects.find((project) => + activeWorktree ? project.sourceRepoIds.includes(activeWorktree.repoId) : false + ) + if (!activeProject) { + throw new Error('No active project') + } + await state.updateProject(activeProject.id, { + localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } + }) + }, distro) +}