Add golden core flow E2E tests (#4615)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
e31f65f54f
commit
63ec0d2c1b
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
>
|
||||
<Plus className="size-3.5" strokeWidth={2.25} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await onboardingFooter(page).getByRole('button', { name: ONBOARDING_ADVANCE_LABEL }).click()
|
||||
}
|
||||
|
||||
async function selectCodexAgent(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
return page.locator(SORTABLE_TAB).count()
|
||||
}
|
||||
|
||||
async function renderedTabIds(page: Page): Promise<string[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await waitForPaneCount(page, 1, 30_000)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
}
|
||||
|
||||
async function createTerminalTabThroughMenu(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<void> {
|
||||
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<OrcaTestFixtures, OrcaWorkerFixtures>({
|
|||
// 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<OrcaTestFixtures, OrcaWorkerFixtures>({
|
|||
// 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue