From 63ec0d2c1bcff1273ab0f3c69ab023d037f642ca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:17:26 -0700 Subject: [PATCH] Add golden core flow E2E tests (#4615) Co-authored-by: Orca --- .github/workflows/golden-e2e-experiment.yml | 93 ++++ .../src/components/sidebar/SidebarHeader.tsx | 7 +- tests/e2e/golden-core-flows.spec.ts | 463 ++++++++++++++++++ tests/e2e/helpers/orca-app.ts | 34 +- 4 files changed, 593 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/golden-e2e-experiment.yml create mode 100644 tests/e2e/golden-core-flows.spec.ts diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml new file mode 100644 index 000000000..8359c58ff --- /dev/null +++ b/.github/workflows/golden-e2e-experiment.yml @@ -0,0 +1,93 @@ +name: Golden E2E Experiment + +on: + pull_request: + paths: + - '.github/workflows/golden-e2e-experiment.yml' + - 'package.json' + - 'tests/e2e/golden-core-flows.spec.ts' + - 'tests/e2e/helpers/**' + - 'src/renderer/src/components/sidebar/SidebarHeader.tsx' + workflow_dispatch: + inputs: + ref: + description: Ref to check out (defaults to the workflow ref) + required: false + type: string + +jobs: + golden-e2e: + name: golden e2e ${{ matrix.platform }} experiment + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + env: + NODE_OPTIONS: --max-old-space-size=4096 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux + - os: macos-15 + platform: mac + - os: windows-latest + platform: windows + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Install native build tools + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + # Why: Linux golden E2E uses the same native install path as PR/release CI, + # which needs pnpm to bypass its non-executable bundled gyp_main.py. + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build Electron app for E2E + run: npx electron-vite build --mode e2e + + - name: Run golden E2E tests on Linux + if: runner.os == 'Linux' + run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + + - name: Run golden E2E tests on macOS + if: runner.os == 'macOS' + run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + + - name: Run golden E2E tests on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $env:SKIP_BUILD = '1' + $env:ORCA_E2E_FORWARD_APP_LOGS = '1' + pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + + - name: Upload Playwright traces + if: failure() + uses: actions/upload-artifact@v7 + with: + name: golden-${{ matrix.platform }}-playwright-traces + path: test-results/ + retention-days: 7 + if-no-files-found: ignore diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index f6c8e8d3b..9facc2c5e 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -6,13 +6,13 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip import SidebarWorkspaceOptionsMenu from './SidebarWorkspaceOptionsMenu' import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { openWorkspaceCreationComposerWithTourHandoff } from '../contextual-tours/workspace-creation-tour-handoff' const SidebarHeader = React.memo(function SidebarHeader() { const newWorktreeShortcutLabel = useShortcutLabel('workspace.create') const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false) const [workspaceBoardMenuOpen, setWorkspaceBoardMenuOpen] = useState(false) const workspaceBoardOpenRef = useRef(workspaceBoardOpen) - const openModal = useAppStore((s) => s.openModal) const repos = useAppStore((s) => s.repos) const groupBy = useAppStore((s) => s.groupBy) const canCreateWorkspace = repos.length > 0 @@ -132,10 +132,13 @@ const SidebarHeader = React.memo(function SidebarHeader() { if (!canCreateWorkspace) { return } - openModal('new-workspace-composer', { telemetrySource: 'sidebar' }) + // Why: the parallel-work tour must click the real sidebar + // control so it can hand off to the workspace-creation tour. + openWorkspaceCreationComposerWithTourHandoff() }} aria-label="New workspace" disabled={!canCreateWorkspace} + data-contextual-tour-target="workspace-create-control" > diff --git a/tests/e2e/golden-core-flows.spec.ts b/tests/e2e/golden-core-flows.spec.ts new file mode 100644 index 000000000..256676487 --- /dev/null +++ b/tests/e2e/golden-core-flows.spec.ts @@ -0,0 +1,463 @@ +import { execFileSync } from 'child_process' +import { mkdirSync, rmSync, writeFileSync } from 'fs' +import { mkdtemp } from 'fs/promises' +import os from 'os' +import path from 'path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + countVisibleTerminalPanes, + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneCount, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +const tempRoots: string[] = [] +const SORTABLE_TAB = '[data-testid="sortable-tab"]' +const REPO_STEP_HEADING = /Point Orca at some code/i +const TASK_SOURCES_HEADING = /Connect your task sources/i +const ONBOARDING_ADVANCE_LABEL = /^Continue\b|^Add your first project\b/ +test.describe.configure({ mode: 'serial' }) +test.afterAll(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +async function createGitRepo(prefix: string, name: string): Promise { + const rootPath = await mkdtemp(path.join(os.tmpdir(), prefix)) + tempRoots.push(rootPath) + const repoPath = path.join(rootPath, name) + + mkdirSync(repoPath, { recursive: true }) + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'e2e@test.local'], { + cwd: repoPath, + stdio: 'pipe' + }) + execFileSync('git', ['config', 'user.name', 'E2E Test'], { + cwd: repoPath, + stdio: 'pipe' + }) + writeFileSync(path.join(repoPath, 'README.md'), `# ${name}\n`) + execFileSync('git', ['add', 'README.md'], { cwd: repoPath, stdio: 'pipe' }) + execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: repoPath, stdio: 'pipe' }) + execFileSync('git', ['branch', '-M', 'main'], { cwd: repoPath, stdio: 'pipe' }) + return repoPath +} + +async function chooseFolderInNativeDialog( + electronApp: ElectronApplication, + folderPath: string +): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await electronApp.evaluate(({ dialog }, selectedPath) => { + dialog.showOpenDialog = async () => ({ + canceled: false, + filePaths: [selectedPath], + bookmarks: [] + }) + }, folderPath) + return + } catch (error) { + if ( + attempt === 2 || + !(error instanceof Error) || + !error.message.includes('Execution context was destroyed') + ) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + } +} +function onboardingFooter(page: Page) { + return page + .locator('footer') + .filter({ + has: page.getByRole('button', { name: /Back|Continue|Add your first project|Skip/i }) + }) + .first() +} + +async function continueOnboarding(page: Page): Promise { + await onboardingFooter(page).getByRole('button', { name: ONBOARDING_ADVANCE_LABEL }).click() +} + +async function selectCodexAgent(page: Page): Promise { + const codexButton = page.getByRole('button', { name: /^Codex\s/ }) + const codexVisible = await codexButton + .first() + .waitFor({ state: 'visible', timeout: 1_000 }) + .then(() => true) + .catch(() => false) + if (!codexVisible) { + await page.getByText(/Show \d+ more agents/).click() + } + await codexButton.first().click() + await expect(codexButton.first()).toHaveAttribute('aria-pressed', 'true') +} + +async function chooseOppositeTheme(page: Page): Promise { + await page.waitForFunction( + () => + document.documentElement.classList.contains('dark') || + document.documentElement.classList.contains('light') + ) + const startingTheme = await page.evaluate(() => + document.documentElement.classList.contains('dark') ? 'dark' : 'light' + ) + const nextTheme = startingTheme === 'dark' ? 'light' : 'dark' + const tileName = nextTheme === 'light' ? /Bright & crisp/ : /Easy on the eyes/ + await page.getByRole('button', { name: tileName }).click() + await expect + .poll( + () => + page.evaluate(() => + document.documentElement.classList.contains('dark') ? 'dark' : 'light' + ), + { timeout: 5_000 } + ) + .toBe(nextTheme) +} + +async function chooseNotificationSound(page: Page): Promise { + const soundSelect = page.getByRole('combobox').first() + await expect(soundSelect).toContainText(/System Default/i) + await soundSelect.click() + const dingOption = page.getByRole('option', { name: /^Ding$/i }) + await expect(dingOption).toBeVisible() + await dingOption.press('Enter') + await expect(soundSelect).toContainText(/Ding/i) +} + +async function continueFromNotificationsToRepo(page: Page): Promise { + await continueOnboarding(page) + const taskSourcesVisible = await page + .getByRole('heading', { name: TASK_SOURCES_HEADING }) + .waitFor({ state: 'visible', timeout: 1_000 }) + .then(() => true) + .catch(() => false) + if (taskSourcesVisible) { + await continueOnboarding(page) + } + const repoHeading = page.getByRole('heading', { name: REPO_STEP_HEADING }) + const addProjectDialog = page.getByRole('dialog', { name: /Add a project/i }) + await expect + .poll( + async () => { + if (await repoHeading.isVisible().catch(() => false)) { + return 'repo-step' + } + if (await addProjectDialog.isVisible().catch(() => false)) { + return 'add-project-dialog' + } + return 'waiting' + }, + { timeout: 15_000 } + ) + .not.toBe('waiting') +} + +async function waitForRepoLoaded(page: Page, repoPath: string): Promise { + await expect + .poll( + () => + page.evaluate((targetPath) => { + const state = window.__store?.getState() + const repo = state?.repos.find((candidate) => candidate.path === targetPath) + if (!state || !repo) { + return false + } + return (state.worktreesByRepo[repo.id] ?? []).length > 0 + }, repoPath), + { timeout: 30_000, message: `repo did not load: ${repoPath}` } + ) + .toBe(true) +} + +async function expectProjectVisible(page: Page, repoPath: string): Promise { + const repoName = path.basename(repoPath) + await expect(page.getByText(repoName, { exact: true }).first()).toBeVisible({ timeout: 15_000 }) +} + +async function addProjectFromSidebar( + page: Page, + electronApp: ElectronApplication, + repoPath: string +): Promise { + await chooseFolderInNativeDialog(electronApp, repoPath) + await page + .getByRole('button', { name: /Add Project/i }) + .first() + .click() + const addDialog = page.getByRole('dialog', { name: /Add a project/i }) + await expect(addDialog).toBeVisible() + await addDialog.getByRole('button', { name: /Browse folder/i }).click() + + const confirmDialog = page.getByRole('dialog', { name: /^Add Project$/i }) + const needsConfirmation = await confirmDialog + .waitFor({ state: 'visible', timeout: 2_000 }) + .then(() => true) + .catch(() => false) + if (needsConfirmation) { + await confirmDialog.getByRole('button', { name: /^Add Project$/ }).click() + } + + await waitForRepoLoaded(page, repoPath) + await expectProjectVisible(page, repoPath) +} + +async function createWorkspace(page: Page, workspaceName: string): Promise { + await page.getByRole('button', { name: 'New workspace', exact: true }).click() + const dialog = page.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + await expect(dialog).toBeVisible() + const nameInput = dialog.getByPlaceholder(/Type a name/i) + await expect(nameInput).toBeVisible() + await nameInput.fill(workspaceName) + await dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }).click() + await expect(dialog).toBeHidden({ timeout: 20_000 }) + await expectActiveWorkspaceVisible(page, workspaceName) +} + +async function expectActiveWorkspaceBelongsToRepo( + page: Page, + workspaceName: string, + repoPath: string +): Promise { + await expectActiveWorkspaceVisible(page, workspaceName) + await expect + .poll( + () => + page.evaluate( + ({ targetRepoPath, targetWorkspaceName }) => { + const state = window.__store?.getState() + if (!state?.activeWorktreeId) { + return null + } + const repo = state.repos.find((candidate) => candidate.path === targetRepoPath) + if (!repo) { + return null + } + const activeWorktree = (state.worktreesByRepo[repo.id] ?? []).find( + (worktree) => worktree.id === state.activeWorktreeId + ) + return activeWorktree?.displayName === targetWorkspaceName + }, + { targetRepoPath: repoPath, targetWorkspaceName: workspaceName } + ), + { timeout: 20_000, message: 'active workspace did not belong to the newly added project' } + ) + .toBe(true) +} + +async function expectActiveWorkspaceVisible(page: Page, workspaceName: string): Promise { + const activeWorkspace = page + .locator('[role="option"][aria-current="page"]') + .filter({ hasText: new RegExp(escapeRegExp(workspaceName)) }) + .first() + await expect(activeWorkspace).toBeVisible({ timeout: 20_000 }) +} + +async function countRenderedTabs(page: Page): Promise { + return page.locator(SORTABLE_TAB).count() +} + +async function renderedTabIds(page: Page): Promise { + return page.locator(SORTABLE_TAB).evaluateAll((tabs) => + tabs.flatMap((tab) => { + const tabId = tab.getAttribute('data-tab-id') + return tabId ? [tabId] : [] + }) + ) +} + +async function expectTerminalSurface(page: Page): Promise { + await expect + .poll(() => page.locator('[data-terminal-tab-id]').count(), { timeout: 30_000 }) + .toBeGreaterThan(0) + const terminalSurface = page.locator('[data-terminal-tab-id]').first() + await expect(terminalSurface).toHaveAttribute('data-native-file-drop-target', 'terminal') +} + +async function waitForTerminalPaneManager(page: Page): Promise { + await waitForPaneCount(page, 1, 30_000) + await waitForActiveTerminalManager(page, 30_000) +} + +async function createTerminalTabThroughMenu(page: Page): Promise { + const tabIdsBefore = await renderedTabIds(page) + await page.getByRole('button', { name: 'New tab' }).click({ force: true }) + const newTerminalMenuItem = page.getByRole('menuitem', { name: /New Terminal/i }).first() + await newTerminalMenuItem.click({ force: true }) + await expect.poll(() => countRenderedTabs(page), { timeout: 5_000 }).toBe(tabIdsBefore.length + 1) + const createdTabIds = (await renderedTabIds(page)).filter( + (tabId) => !tabIdsBefore.includes(tabId) + ) + expect(createdTabIds, 'new terminal tab should render exactly one new tab').toHaveLength(1) + const createdTab = page.locator(`${SORTABLE_TAB}[data-tab-id="${createdTabIds[0]}"]`).first() + await expect(createdTab).toHaveAttribute('data-tab-title', /.+/) + await expectTerminalSurface(page) +} + +async function splitTerminalPaneAndAssertIdentity(page: Page): Promise { + const paneCountBefore = await countVisibleTerminalPanes(page) + await splitActiveTerminalPane(page, 'vertical') + await waitForPaneCount(page, paneCountBefore + 1) + const snapshot = await waitForPaneIdentitySnapshot(page, paneCountBefore + 1) + expect(snapshot.panes).toHaveLength(paneCountBefore + 1) +} + +async function requestAgentSessionsTour(page: Page): Promise { + await expect + .poll( + () => + page.evaluate(() => { + const state = window.__store?.getState() + const splitTarget = document.querySelector( + '[data-contextual-tour-target="terminal-pane-split-target"], [data-contextual-tour-target="workspace-agent-terminal-tip"]' + ) + const rect = splitTarget?.getBoundingClientRect() + return { + ready: state?.persistedUIReady === true, + onboardingHidden: state?.contextualToursOnboardingVisible === false, + noModal: state?.activeModal === 'none', + splitTargetMeasurable: Boolean(rect && rect.width > 0 && rect.height > 0) + } + }), + { timeout: 30_000 } + ) + .toEqual({ + ready: true, + onboardingHidden: true, + noModal: true, + splitTargetMeasurable: true + }) + + await page.evaluate(() => { + window.__store + ?.getState() + .requestContextualTour('workspace-agent-sessions', 'setup_guide_parallel_work', false, { + force: true + }) + }) + await expect(page.getByRole('dialog', { name: /Split a terminal pane/i })).toBeVisible() +} + +async function completeWorkspaceCreationTour(page: Page, workspaceName: string): Promise { + await expect(page.getByRole('dialog', { name: /Pick a project/i })).toBeVisible() + await page.getByRole('button', { name: /^Next$/ }).click() + const nameStep = page.getByRole('dialog', { name: /Name it, or start from existing work/i }) + await expect(nameStep).toBeVisible() + const autoNameSwitch = nameStep.getByRole('switch', { + name: /Auto-name workspace from first agent message/i + }) + const checkedBefore = await autoNameSwitch.getAttribute('aria-checked') + await autoNameSwitch.click() + await expect(autoNameSwitch).toHaveAttribute( + 'aria-checked', + checkedBefore === 'true' ? 'false' : 'true' + ) + await page.getByRole('button', { name: /^Next$/ }).click() + await expect( + page.getByRole('dialog', { name: /Choose what agent starts the work/i }) + ).toBeVisible() + await page.getByRole('button', { name: /^Done$/ }).click() + + const composer = page.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + await expect(composer).toBeVisible() + await composer.getByPlaceholder(/Type a name/i).fill(workspaceName) + await composer.getByRole('button', { name: /Create (Workspace|Worktree)/i }).click() + await expect(composer).toBeHidden({ timeout: 20_000 }) + await expectActiveWorkspaceVisible(page, workspaceName) +} + +test.describe('Existing-user golden core flow', () => { + test('adds project, creates workspace, opens a terminal tab, and splits a pane', async ({ + electronApp, + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const repoPath = await createGitRepo('orca-e2e-golden-existing-', 'golden-existing-project') + + await addProjectFromSidebar(orcaPage, electronApp, repoPath) + const workspaceName = `golden-existing-${Date.now()}` + await createWorkspace(orcaPage, workspaceName) + await expectActiveWorkspaceBelongsToRepo(orcaPage, workspaceName, repoPath) + await ensureTerminalVisible(orcaPage) + await expectTerminalSurface(orcaPage) + await waitForTerminalPaneManager(orcaPage) + + await createTerminalTabThroughMenu(orcaPage) + await splitTerminalPaneAndAssertIdentity(orcaPage) + }) +}) + +test.describe('New-user golden core flow', () => { + test.use({ dismissOnboarding: false, seedTestRepo: false }) + + test('completes onboarding, adds a project, and follows the workspace tour handoff', async ({ + electronApp, + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ + timeout: 15_000 + }) + + await selectCodexAgent(orcaPage) + await continueOnboarding(orcaPage) + await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible() + await chooseOppositeTheme(orcaPage) + await continueOnboarding(orcaPage) + await expect(orcaPage.getByRole('heading', { name: /Set up notifications/i })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: /Send Test Notification/i })).toBeVisible() + await chooseNotificationSound(orcaPage) + await continueFromNotificationsToRepo(orcaPage) + + const repoPath = await createGitRepo('orca-e2e-golden-new-', 'golden-new-project') + await chooseFolderInNativeDialog(electronApp, repoPath) + await orcaPage + .getByRole('button', { name: /Browse for a folder|Open a folder|Browse folder/i }) + .click() + await expect(orcaPage.getByRole('heading', { name: REPO_STEP_HEADING })).toHaveCount(0, { + timeout: 30_000 + }) + await waitForRepoLoaded(orcaPage, repoPath) + await expectProjectVisible(orcaPage, repoPath) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await expectTerminalSurface(orcaPage) + await waitForTerminalPaneManager(orcaPage) + + await requestAgentSessionsTour(orcaPage) + const paneCountBeforeTourSplit = await countVisibleTerminalPanes(orcaPage) + await orcaPage.getByRole('button', { name: /^Split terminal$/ }).click() + await waitForPaneCount(orcaPage, paneCountBeforeTourSplit + 1) + await waitForPaneIdentitySnapshot(orcaPage, paneCountBeforeTourSplit + 1) + + await expect( + orcaPage.getByRole('dialog', { name: /Start another task in parallel/i }) + ).toBeVisible() + const createControl = orcaPage + .locator('[data-contextual-tour-target="workspace-create-control"]') + .first() + await expect(createControl).toBeVisible() + await expect(createControl).toHaveAttribute('aria-label', 'New workspace') + const createControlBox = await createControl.boundingBox() + expect(createControlBox?.width ?? 0).toBeGreaterThan(0) + expect(createControlBox?.height ?? 0).toBeGreaterThan(0) + await createControl.click() + + const workspaceName = `golden-new-${Date.now()}` + await completeWorkspaceCreationTour(orcaPage, workspaceName) + await expectActiveWorkspaceBelongsToRepo(orcaPage, workspaceName, repoPath) + }) +}) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index 9c8e2f7ea..9de98d8de 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -40,6 +40,9 @@ type OrcaTestFixtures = { // events for every other test. Dismiss it by default; onboarding.spec.ts // opts out via `test.use({ dismissOnboarding: false })`. dismissOnboarding: boolean + // Why: most E2E specs need a ready project before assertions start. Golden + // first-run specs opt out so they can prove the zero-project onboarding path. + seedTestRepo: boolean } type OrcaWorkerFixtures = { @@ -64,6 +67,22 @@ const ORCA_E2E_SLOWMO_MS = ((): number => { return Math.max(parsed, 0) })() +async function removeUserDataDirAfterShutdown(userDataDir: string): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + rmSync(userDataDir, { recursive: true, force: true }) + return + } catch (error) { + if (attempt === 4) { + throw error + } + // Why: Windows can briefly keep Electron profile files locked after the + // process exits; retrying avoids turning a passed flow into teardown noise. + await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1))) + } + } +} + function shouldLaunchHeadful(testInfo: TestInfo): boolean { // Why: ORCA_E2E_FORCE_HEADFUL lets a developer watch any spec in a real // window without retagging it `@headful` or switching projects. @@ -237,15 +256,16 @@ export const test = base.extend({ // descendants are gone in CI; worker teardown then hangs on open handles. await closeElectronAppForE2E(app) await cleanupE2EDaemons(userDataDir) - rmSync(userDataDir, { recursive: true, force: true }) + await removeUserDataDirAfterShutdown(userDataDir) }, // Default: dismiss the onboarding overlay so it doesn't intercept clicks. dismissOnboarding: [true, { option: true }], + seedTestRepo: [true, { option: true }], // Test-scoped: grab the first BrowserWindow, add the test repo, and wait // until the session is fully ready with a worktree active. - sharedPage: async ({ electronApp, testRepoPath }, provideFixture) => { + sharedPage: async ({ electronApp, seedTestRepo, testRepoPath }, provideFixture) => { // Why: the Electron app may take a while to create the first window, // especially on cold start with no prior dev userData. Isolated per-test // profiles make late-suite launches slower, so use the full test budget. @@ -255,6 +275,16 @@ export const test = base.extend({ // Wait for the store to be available await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 }) + if (!seedTestRepo) { + await page.waitForFunction( + () => window.__store?.getState().workspaceSessionReady === true, + null, + { timeout: 30_000 } + ) + await provideFixture(page) + return + } + const repoPath = isValidGitRepo(testRepoPath) ? testRepoPath : createSeededTestRepo() // Add the test repo via the IPC bridge