/** * E2E tests for the "Create Workspace" flow in Orca. * * Why: the old 'create-worktree' modal was replaced by the composer modal * (`activeModal === 'new-workspace-composer'`) in #710. A prior version of * this spec bypassed the UI entirely — it called `state.createWorktree(...)` * directly on the store — which is why the #1186 regression (a React #31 * crash when `StartFromField` rendered the new `getBaseRefDefault` envelope * as JSX) shipped despite a green suite. * * The spec now drives the real user flow: open the composer, type a * workspace name, click Create, and assert the worktree actually * materialized and became active. See `tests/e2e/AGENTS.md` for the rule * that E2E assertions must target the DOM, not the store. * * Note: the original StartFromField regression guard was removed with #1191 * (Tabbed Create Workspace), which deleted StartFromField/StartFromPicker * entirely. The render-error sweep below still catches any React #31-class * crash in whatever replaces it. */ import type { ConsoleMessage } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForSessionReady, waitForActiveWorktree, getActiveWorktreeId, ensureTerminalVisible, worktreeExists } from './helpers/store' test.describe('Create Workspace', () => { test.beforeEach(async ({ orcaPage }) => { await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) }) test('creates a worktree through the composer UI and activates it', async ({ orcaPage }) => { const worktreeIdBefore = await getActiveWorktreeId(orcaPage) // Capture render errors for the #1186 guard. React logs "Objects are not // valid as a React child" via console.error before throwing the // minified-production error #31; capture both paths so the test fails // loudly whether the build is dev or prod. const pageErrors: Error[] = [] orcaPage.on('pageerror', (err) => { pageErrors.push(err) }) const consoleErrors: string[] = [] const onConsole = (msg: ConsoleMessage): void => { if (msg.type() === 'error') { consoleErrors.push(msg.text()) } } orcaPage.on('console', onConsole) const workspaceName = `e2e-create-${Date.now()}` try { // 1. Open the composer through the visible affordance so the lazy modal // mount path stays covered along with the composer body. await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) await expect(dialog).toBeVisible() // Wait for the composer to settle. The card fires several async effects // on mount (detected-agent probe, name-field autofocus + hydration, // setup-hooks fetch). Clicking before those settle can race Radix's // FocusScope reparenting. await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible() // Force the `getBaseRefDefault` IPC to round-trip so any consumer that // renders the envelope (e.g. SourceControl) has a chance to crash // inside the open modal's React tree — the console/pageerror sweep // below is what catches #1186-class regressions now that the // StartFromField trigger no longer exists (#1191). await orcaPage.evaluate(async () => { const repoId = Object.values(window.__store!.getState().worktreesByRepo).flat()[0]?.repoId if (!repoId) { return } await window.api.repos.getBaseRefDefault({ repoId }) }) await orcaPage.waitForTimeout(100) // 3. Type the workspace name into the unified smart-name input. // The composer's default mode is 'smart'; its placeholder advertises // multiple input shapes ("Type a name, #1234, branch, GitHub or // Linear URL"). Plain free-form text is treated as a workspace name // by submitQuick, which is what we want here. const nameInput = dialog.getByPlaceholder(/Type a name/i) await expect(nameInput).toBeVisible() await nameInput.fill(workspaceName) // 4. Click Create. This fires the full submitQuick path: // createWorktree IPC, applyWorktreeMeta, activateAndRevealWorktree, // and closeModal via onCreated. const createButton = dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }) await expect(createButton).toBeEnabled() await createButton.click() // 5. The modal closes once submitQuick completes successfully. If // something inside the flow threw (IPC failure, hook error), the modal // would stay open with a createError banner — catch that as a fail. await expect(dialog).toBeHidden({ timeout: 15_000 }) // 6. The new worktree must actually exist on disk and in the store. await expect .poll(async () => worktreeExists(orcaPage, workspaceName), { timeout: 10_000, message: `Worktree "${workspaceName}" did not appear in the store` }) .toBe(true) // 7. The new worktree must become active (different from whatever was // active before we opened the composer). await expect .poll( async () => { const id = await getActiveWorktreeId(orcaPage) return id !== null && id !== worktreeIdBefore }, { timeout: 10_000, message: 'New worktree did not become the active worktree' } ) .toBe(true) // 8. A terminal tab must auto-create for the new worktree. This is // the downstream signal that `activateAndRevealWorktree` actually // fired, not just that the store row exists. await ensureTerminalVisible(orcaPage) // Final render-error sweep. Any render crash during the flow (whether // it tore down the modal or bubbled past it) shows up here. expect(pageErrors, `pageerror fired: ${pageErrors.map((e) => e.message).join(', ')}`).toEqual( [] ) const reactChildErrors = consoleErrors.filter((text) => /Objects are not valid as a React child|Minified React error #31/i.test(text) ) expect(reactChildErrors, `React render error: ${reactChildErrors.join(', ')}`).toEqual([]) } finally { orcaPage.off('console', onConsole) // Best-effort close if the test failed mid-flow and left the modal open. await orcaPage .evaluate(() => { window.__store?.getState().closeModal() }) .catch(() => { /* page may already be torn down */ }) } }) test('shows a failed workspace entry when worktree creation fails', async ({ orcaPage }) => { await orcaPage.evaluate(() => { const store = window.__store if (!store) { throw new Error('window.__store is not available') } const originalCreateWorktree = store.getState().createWorktree ;( window as unknown as { __restoreCreateWorktree?: () => void } ).__restoreCreateWorktree = () => { store.setState({ createWorktree: originalCreateWorktree }) } store.setState({ createWorktree: async () => { throw new Error('could not resolve a default base ref for the E2E fixture') } }) }) try { const workspaceName = `e2e-create-failure-${Date.now()}` await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) await expect(dialog).toBeVisible() await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible() const nameInput = dialog.getByPlaceholder(/Type a name/i) await expect(nameInput).toBeVisible() await nameInput.fill(workspaceName) const createButton = dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }) await expect(createButton).toBeEnabled() await createButton.click() await expect(dialog).toBeHidden() const failedWorkspace = orcaPage.getByRole('button', { name: new RegExp(`${workspaceName} No base branch found`) }) await expect(failedWorkspace).toBeVisible() await expect(orcaPage.getByText('Couldn’t create worktree')).toBeVisible() await expect(failedWorkspace).toContainText('No base branch found') await expect(orcaPage.getByRole('button', { name: 'Retry' })).toBeVisible() } finally { await orcaPage .evaluate(() => { ;( window as unknown as { __restoreCreateWorktree?: () => void } ).__restoreCreateWorktree?.() window.__store?.getState().closeModal() }) .catch(() => { /* page may already be torn down */ }) } }) test('reuses a resolved pasted GitHub URL when quick create submits', async ({ electronApp, orcaPage }) => { const title = `E2E smart URL resolution ${Date.now()}` const url = 'https://github.com/stablyai/orca/pull/2049' const linkedWorkspacePattern = new RegExp(title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) try { await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) await expect(dialog).toBeVisible() await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible() await electronApp.evaluate( ({ ipcMain }, { title, url }) => { const counters = globalThis as unknown as { __smartGitHubLookupCount: number __smartResolvePrBaseCount: number } counters.__smartGitHubLookupCount = 0 counters.__smartResolvePrBaseCount = 0 ipcMain.removeHandler('gh:workItemByOwnerRepo') ipcMain.handle( 'gh:workItemByOwnerRepo', ( _event: unknown, args: { number: number repoId?: string } ) => { counters.__smartGitHubLookupCount += 1 return { id: `e2e-pr-${args.number}`, type: 'pr', number: args.number, title, state: 'open', url, labels: [], updatedAt: '2026-05-26T00:00:00.000Z', author: 'e2e', repoId: args.repoId ?? 'e2e-repo' } } ) ipcMain.removeHandler('worktrees:resolvePrBase') // Why: the fixture repo has no remote and its default branch name // depends on the host's git init.defaultBranch (main vs master), so // resolve the PR base to HEAD, which always exists regardless. ipcMain.handle('worktrees:resolvePrBase', () => { counters.__smartResolvePrBaseCount += 1 return { baseBranch: 'HEAD' } }) }, { title, url } ) const nameInput = dialog.getByPlaceholder(/Type a name/i) await expect(nameInput).toBeVisible() await nameInput.fill(url) await expect .poll(() => electronApp.evaluate(() => { const counters = globalThis as unknown as { __smartGitHubLookupCount?: number __smartResolvePrBaseCount?: number } return { githubLookupCount: counters.__smartGitHubLookupCount ?? -1, resolvePrBaseCount: counters.__smartResolvePrBaseCount ?? -1 } }) ) .toEqual({ githubLookupCount: 1, resolvePrBaseCount: 0 }) const createButton = dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }) await expect(createButton).toBeEnabled() await createButton.click() await expect(dialog).toBeHidden({ timeout: 15_000 }) await expect(orcaPage.getByRole('option', { name: linkedWorkspacePattern })).toBeVisible({ timeout: 10_000 }) await expect(orcaPage.getByRole('option', { name: url })).toHaveCount(0) await expect(orcaPage.getByText('Linked PR #2049')).toBeVisible() // Why: quick create reuses the single GitHub lookup from typing (no // redundant re-fetch), and since #5733 ("Create PR worktrees from the PR // head") it resolves the PR start point exactly once at submit time — so // the base resolves once here rather than being skipped. await expect .poll(() => electronApp.evaluate(() => { const counters = globalThis as unknown as { __smartGitHubLookupCount?: number __smartResolvePrBaseCount?: number } return { githubLookupCount: counters.__smartGitHubLookupCount ?? -1, resolvePrBaseCount: counters.__smartResolvePrBaseCount ?? -1 } }) ) .toEqual({ githubLookupCount: 1, resolvePrBaseCount: 1 }) } finally { await orcaPage .evaluate(() => { window.__store?.getState().closeModal() }) .catch(() => { /* page may already be torn down */ }) } }) test('names the workspace after the PR title when the pasted URL suggestion is selected', async ({ electronApp, orcaPage }) => { const title = `E2E selected URL resolution ${Date.now()}` const url = 'https://github.com/stablyai/orca/pull/2050' const linkedWorkspacePattern = new RegExp(title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) try { await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) await expect(dialog).toBeVisible() await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible() await electronApp.evaluate( ({ ipcMain }, { title, url }) => { ipcMain.removeHandler('gh:workItemByOwnerRepo') ipcMain.handle( 'gh:workItemByOwnerRepo', (_event: unknown, args: { number: number; repoId?: string }) => ({ id: `e2e-pr-${args.number}`, type: 'pr', number: args.number, title, state: 'open', url, labels: [], updatedAt: '2026-05-26T00:00:00.000Z', author: 'e2e', repoId: args.repoId ?? 'e2e-repo' }) ) ipcMain.removeHandler('worktrees:resolvePrBase') // Why: the fixture repo has no remote and its default branch name // depends on the host's git init.defaultBranch (main vs master), so // resolve the PR base to HEAD, which always exists regardless. ipcMain.handle('worktrees:resolvePrBase', () => ({ baseBranch: 'HEAD' })) }, { title, url } ) const nameInput = dialog.getByPlaceholder(/Type a name/i) await expect(nameInput).toBeVisible() await nameInput.fill(url) // Why: this is the regression PR #4900 missed — selecting the resolved // suggestion row (instead of submitting the raw URL) must not leave the // pasted URL behind as the workspace name. The suggestion popover is // portaled outside the dialog element, so locate it page-wide. const suggestion = orcaPage.getByRole('option', { name: linkedWorkspacePattern }) await expect(suggestion).toBeVisible() await suggestion.click() const createButton = dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }) await expect(createButton).toBeEnabled() await createButton.click() await expect(dialog).toBeHidden({ timeout: 15_000 }) await expect(orcaPage.getByRole('option', { name: linkedWorkspacePattern })).toBeVisible({ timeout: 10_000 }) await expect(orcaPage.getByRole('option', { name: /https-github/i })).toHaveCount(0) await expect(orcaPage.getByText('Linked PR #2050')).toBeVisible() } finally { await orcaPage .evaluate(() => { window.__store?.getState().closeModal() }) .catch(() => { /* page may already be torn down */ }) } }) })