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