Files
2026-07-13 13:05:33 +08:00

409 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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('Couldnt 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 */
})
}
})
})