409 lines
16 KiB
TypeScript
409 lines
16 KiB
TypeScript
/**
|
||
* 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 */
|
||
})
|
||
}
|
||
})
|
||
})
|