From b58c0a42da9da82576b534fc3c8bd6f85b6f41d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:46:49 -0700 Subject: [PATCH] Fix stale and race-prone E2E test failures (#7468) Co-authored-by: Orca --- tests/e2e/github-cli-stall-repro.spec.ts | 28 ++-- tests/e2e/helpers/worktree-registration.ts | 151 ++++++++++++++++++ tests/e2e/onboarding.spec.ts | 67 +++++++- tests/e2e/pr-comments-sidebar-cards.spec.ts | 1 - tests/e2e/settings-display-name-ime.spec.ts | 7 + ...e-control-commit-draft-persistence.spec.ts | 62 +------ .../source-control-commit-message-ai.spec.ts | 72 ++------- tests/e2e/terminal-shortcuts.spec.ts | 62 +++---- tests/e2e/workspace-space-git-status.spec.ts | 25 ++- tests/e2e/worktree.spec.ts | 18 ++- 10 files changed, 315 insertions(+), 178 deletions(-) create mode 100644 tests/e2e/helpers/worktree-registration.ts diff --git a/tests/e2e/github-cli-stall-repro.spec.ts b/tests/e2e/github-cli-stall-repro.spec.ts index 18e13778e..31a58f63b 100644 --- a/tests/e2e/github-cli-stall-repro.spec.ts +++ b/tests/e2e/github-cli-stall-repro.spec.ts @@ -21,6 +21,10 @@ if (args[0] === 'api' && args.includes('rate_limit')) { console.log(JSON.stringify({ resources: { core: { limit: 5000, remaining: 5000, reset: 0 }, graphql: { limit: 5000, remaining: 5000, reset: 0 }, search: { limit: 30, remaining: 30, reset: 0 } } })) process.exit(0) } +if (args[0] === 'api' && joined.includes('search/issues')) { + console.log(JSON.stringify({ total_count: 0, incomplete_results: false, items: [] })) + process.exit(0) +} if (args[0] === 'issue' && args[1] === 'list') { console.log('[]') process.exit(0) @@ -113,20 +117,18 @@ test('GitHub Tasks drawer recovers when gh stalls on issue details', async ({ return { repoId: repo.id } }, testRepoPath) - const drawer = orcaPage - .getByRole('dialog') - .filter({ hasText: 'Issue detail fetch that hangs in gh' }) - .last() - await expect(drawer).toBeVisible() + // The item detail opens as an inline task-detail page (no longer a modal + // dialog): the item title heading proves it mounted. + const detailHeading = orcaPage.getByRole('heading', { + name: /Issue detail fetch that hangs in gh/ + }) + await expect(detailHeading).toBeVisible({ timeout: 10_000 }) - // Why: this is the user-visible regression signal. Before ghExecFileAsync had - // a default timeout, the drawer's pending details promise never settled and - // the conversation pane stayed stuck in its loading shell. The main GitHub - // details service degrades failed detail fetches to an empty shell, so the - // stable visible proof is that the drawer becomes usable and stops spinning. - await expect(drawer.getByText('No description provided.')).toBeVisible({ timeout: 5_000 }) - await expect(drawer.getByText('No comments yet.')).toBeVisible() - await expect(drawer.locator('.animate-spin')).toHaveCount(0) + // Why: the bounded gh timeout rejects instead of hanging, so this terminal + // error text (replacing the body, not a spinner) proves the stall recovered. + await expect(orcaPage.getByText('Unable to load details for this GitHub item.')).toBeVisible({ + timeout: 5_000 + }) expect(repoId).toBeTruthy() }) diff --git a/tests/e2e/helpers/worktree-registration.ts b/tests/e2e/helpers/worktree-registration.ts new file mode 100644 index 000000000..96a4bce5e --- /dev/null +++ b/tests/e2e/helpers/worktree-registration.ts @@ -0,0 +1,151 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' +import type { CommitMessageAiSettings } from '../../../src/shared/types' + +// Why: these specs create worktrees via raw `git worktree add`, which bypasses +// Orca's own add/remove path — the one thing that invalidates the main-process +// worktrees.list scan cache (DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000). So a +// read inside the TTL window can serve a stale miss. No renderer-reachable +// cache-invalidation seam exists without adding product surface, so poll past +// the deterministic 5s boundary. Budget 10s (not 6s, which sits dangerously +// close to a 5s TTL if one scan is slow); the informative message keeps a +// genuinely never-loading worktree failing loudly. +const WORKTREE_CACHE_TTL_POLL_MS = 10_000 + +/** + * Loads the repo's worktrees into the renderer store and resolves the id of the + * worktree at `targetWorktreePath`, polling past the 5s scan-cache TTL so a + * raw `git worktree add` that Orca never observed still becomes visible. + */ +async function resolveE2eWorktreeId( + page: Page, + repoPath: string, + targetWorktreePath: string +): Promise { + let worktreeId: string | null = null + await expect + .poll( + async () => { + worktreeId = await page.evaluate( + async ({ repoPath, targetWorktreePath }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + await store.getState().fetchRepos() + const repo = store.getState().repos.find((entry) => entry.path === repoPath) + if (!repo) { + throw new Error(`Seeded E2E repo was not registered: ${repoPath}`) + } + // Why: use the store's own fetch (like loadWorktreesUntilPathsPresent) + // so both TTL workarounds stay behaviorally identical. + await store.getState().fetchWorktrees(repo.id) + const listedWorktrees = store.getState().worktreesByRepo[repo.id] ?? [] + const normalize = (value: string): string => + value.startsWith('/private/var/') ? value.slice('/private'.length) : value + const worktree = listedWorktrees.find( + (entry) => normalize(entry.path) === normalize(targetWorktreePath) + ) + return worktree?.id ?? null + }, + { repoPath, targetWorktreePath } + ) + return worktreeId + }, + { + timeout: WORKTREE_CACHE_TTL_POLL_MS, + message: `E2E worktree was not loaded within the worktree-cache TTL window: ${targetWorktreePath}` + } + ) + .not.toBeNull() + + if (!worktreeId) { + throw new Error(`E2E worktree was not loaded: ${targetWorktreePath}`) + } + return worktreeId +} + +/** + * Shared setup for the Source Control specs: resolves the target worktree + * (surviving the scan-cache TTL race), activates it, optionally applies AI + * commit-message settings, loads its git status, and opens the Source Control + * sidebar tab. + */ +export async function openSourceControlForWorktree( + page: Page, + repoPath: string, + targetWorktreePath: string, + options: { commitMessageAi?: CommitMessageAiSettings } = {} +): Promise { + const worktreeId = await resolveE2eWorktreeId(page, repoPath, targetWorktreePath) + + await page.evaluate( + async ({ worktreeId, commitMessageAi }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const worktree = Object.values(store.getState().worktreesByRepo) + .flat() + .find((entry) => entry.id === worktreeId) + if (!worktree) { + throw new Error(`E2E worktree disappeared from the store: ${worktreeId}`) + } + store.getState().setActiveWorktree(worktree.id) + if (commitMessageAi) { + await store.getState().updateSettings({ commitMessageAi }) + } + const status = await window.api.git.status({ worktreePath: worktree.path }) + store.getState().setGitStatus(worktree.id, status) + store.getState().setRightSidebarTab('source-control') + store.getState().setRightSidebarOpen(true) + }, + { worktreeId, commitMessageAi: options.commitMessageAi ?? null } + ) + + await expect + .poll( + async () => + page.evaluate(() => { + const state = window.__store?.getState() + return Boolean(state?.rightSidebarOpen && state?.rightSidebarTab === 'source-control') + }), + { timeout: 5_000 } + ) + .toBe(true) +} + +/** + * Polls `fetchWorktrees` past the scan-cache TTL until every expected path is + * registered in the store. Used by the Workspace Space git-status spec, which + * adds 60 worktrees via raw git and would otherwise read a stale partial scan. + */ +export async function loadWorktreesUntilPathsPresent( + page: Page, + repoId: string, + expectedPaths: string[] +): Promise { + await expect + .poll( + async () => + page.evaluate( + async ({ repoId, expectedPaths }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + await store.getState().fetchWorktrees(repoId) + const registered = new Set( + (store.getState().worktreesByRepo[repoId] ?? []).map((entry) => entry.path) + ) + return expectedPaths.every((entry) => registered.has(entry)) + }, + { repoId, expectedPaths } + ), + { + timeout: WORKTREE_CACHE_TTL_POLL_MS, + message: `Not all worktrees registered within the worktree-cache TTL window (${expectedPaths.length} expected)` + } + ) + .toBe(true) +} diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index cdfd3b452..1fd2e1cc8 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -241,12 +241,17 @@ test.describe('Onboarding flow', () => { .toBe(oppositeTheme) await continueOnboarding(orcaPage) + // Why: the theme Continue persists step 2, then persists *through* any + // skipped optional steps (integrations is skipped when gh is installed, + // windows_terminal off macOS), so lastCompletedStep can land at 2, 3, or 4. + // Key off the settled "theme step committed" lower bound rather than a fixed + // window that assumed integrations always renders. await expect - .poll(async () => [2, 3].includes((await getOnboardingState(orcaPage)).lastCompletedStep), { + .poll(async () => (await getOnboardingState(orcaPage)).lastCompletedStep, { timeout: 5_000, - message: 'lastCompletedStep did not advance after second Continue' + message: 'lastCompletedStep did not advance past the theme step after second Continue' }) - .toBe(true) + .toBeGreaterThanOrEqual(2) await expect .poll(async () => (await getSettings(orcaPage)).theme, { timeout: 5_000 }) .toBe(oppositeTheme) @@ -414,7 +419,53 @@ test.describe('Onboarding flow', () => { timeout: 15_000 }) await orcaPage.evaluate(async () => { - await window.__store?.getState().updateSettings({ activeRuntimeEnvironmentId: 'env-e2e' }) + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + // Why: after #5071 the server-path add step gates on the registered + // runtime-environment list (store.runtimeEnvironments), not just the + // activeRuntimeEnvironmentId setting. Seed a redacted environment so the + // host option exists and the "on host" add UI renders. + const now = Date.now() + store.getState().setRuntimeEnvironments([ + { + id: 'env-e2e', + name: 'E2E Server', + createdAt: now, + updatedAt: now, + lastUsedAt: null, + runtimeId: null, + source: 'manual', + endpoints: [ + { + id: 'ws-env-e2e', + kind: 'websocket', + label: 'WebSocket', + endpoint: 'wss://e2e.invalid/ws' + } + ], + preferredEndpointId: 'ws-env-e2e' + } + ]) + // Why: a runtime host is only auto-selectable (health 'available') when it + // has a live, protocol-compatible status; without one it reads + // 'disconnected' and the Add Project dialog falls back to Local Mac. + // runtimeProtocolVersion 3 clears MIN_COMPATIBLE_RUNTIME_SERVER_VERSION. + store.getState().setRuntimeEnvironmentStatus('env-e2e', { + status: { + runtimeId: 'env-e2e-runtime', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 1 + }, + checkedAt: now + }) + await store.getState().updateSettings({ activeRuntimeEnvironmentId: 'env-e2e' }) }) await expect .poll(async () => (await getSettings(orcaPage)).activeRuntimeEnvironmentId, { @@ -425,10 +476,12 @@ test.describe('Onboarding flow', () => { await onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON).click() await expectAddProjectDialog(orcaPage) - await expect(orcaPage.getByRole('button', { name: /Browse server/i })).toBeVisible() + // The runtime env is selected as the Add Project host and the browse action + // is host-scoped, proving the server project-setup UI is preserved on skip. + await expect(orcaPage.getByText('Existing Git repository or folder on this host')).toBeVisible() + await expect(orcaPage.getByRole('button', { name: /Browse folder/i })).toBeVisible() await expect(orcaPage.getByRole('button', { name: /Clone from URL/i })).toBeVisible() - await expect(orcaPage.getByRole('button', { name: /Create on server/i })).toBeVisible() - await expect(orcaPage.getByText(/Or enter a server path manually/i)).toBeVisible() + await expect(orcaPage.getByRole('button', { name: /Create new project/i })).toBeVisible() await expect(onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON)).toHaveCount(0) expect((await getOnboardingState(orcaPage)).closedAt).not.toBeNull() }) diff --git a/tests/e2e/pr-comments-sidebar-cards.spec.ts b/tests/e2e/pr-comments-sidebar-cards.spec.ts index a04530226..9cccce81b 100644 --- a/tests/e2e/pr-comments-sidebar-cards.spec.ts +++ b/tests/e2e/pr-comments-sidebar-cards.spec.ts @@ -58,7 +58,6 @@ test.describe('PR comments sidebar cards view', () => { await expect(orcaPage.getByText('Needs review · 1')).toBeVisible() await expect(orcaPage.getByText('Please update this handler before merge.')).toBeVisible() await expect(orcaPage.getByText('alice')).toBeVisible() - await expect(orcaPage.getByText('Open', { exact: true })).toBeVisible() await expect(orcaPage.getByText('LGTM on the overall approach.')).toBeVisible() const openThreadCard = orcaPage.getByTestId('pr-comment-group').filter({ diff --git a/tests/e2e/settings-display-name-ime.spec.ts b/tests/e2e/settings-display-name-ime.spec.ts index 5de78264c..8001c6d33 100644 --- a/tests/e2e/settings-display-name-ime.spec.ts +++ b/tests/e2e/settings-display-name-ime.spec.ts @@ -112,6 +112,13 @@ async function typeHangulGanadaSlowly( // Slow typing: let the async store echo land before the next key. await page.waitForTimeout(200) } + + // Why: a real IME commits the pending syllable (space/enter) at the end of a + // word, firing compositionend. Without this final commit the controlled + // input stays in composing state, so the component's defer-until-compositionend + // persist never runs. insertText replaces the composing region in place, so it + // finalizes to the same text rather than double-committing. + await session.send('Input.insertText', { text: committed + pending }) } test.describe('Repository Display Name IME composition', () => { diff --git a/tests/e2e/source-control-commit-draft-persistence.spec.ts b/tests/e2e/source-control-commit-draft-persistence.spec.ts index e4efd1fda..4607e027b 100644 --- a/tests/e2e/source-control-commit-draft-persistence.spec.ts +++ b/tests/e2e/source-control-commit-draft-persistence.spec.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' +import { openSourceControlForWorktree } from './helpers/worktree-registration' type E2eWorktree = { branchName: string @@ -46,67 +47,6 @@ function cleanupWorktree(repoPath: string, worktreePath: string, branchName: str } } -async function openSourceControlForWorktree( - page: Parameters[0], - repoPath: string, - targetWorktreePath: string -): Promise { - await page.evaluate( - async ({ repoPath, targetWorktreePath }) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - - const state = store.getState() - await state.fetchRepos() - const repo = store.getState().repos.find((entry) => entry.path === repoPath) - if (!repo) { - throw new Error(`Seeded E2E repo was not registered: ${repoPath}`) - } - - const listedWorktrees = await window.api.worktrees.list({ repoId: repo.id }) - store.setState((current) => ({ - worktreesByRepo: { - ...current.worktreesByRepo, - [repo.id]: listedWorktrees - } - })) - - const normalizeMacTmpPath = (value: string): string => - value.startsWith('/private/var/') ? value.slice('/private'.length) : value - const worktree = listedWorktrees.find( - (entry) => normalizeMacTmpPath(entry.path) === normalizeMacTmpPath(targetWorktreePath) - ) - if (!worktree) { - throw new Error( - `E2E worktree was not loaded: ${targetWorktreePath}; listed=${listedWorktrees - .map((entry) => entry.path) - .join(', ')}` - ) - } - - store.getState().setActiveWorktree(worktree.id) - const status = await window.api.git.status({ worktreePath: worktree.path }) - store.getState().setGitStatus(worktree.id, status) - store.getState().setRightSidebarOpen(true) - store.getState().setRightSidebarTab('source-control') - }, - { repoPath, targetWorktreePath } - ) - - await expect - .poll( - async () => - page.evaluate(() => { - const state = window.__store?.getState() - return Boolean(state?.rightSidebarOpen && state?.rightSidebarTab === 'source-control') - }), - { timeout: 5_000 } - ) - .toBe(true) -} - test.describe('Source Control commit draft persistence', () => { test('preserves a typed draft when the sidebar tab remounts', async ({ orcaPage, diff --git a/tests/e2e/source-control-commit-message-ai.spec.ts b/tests/e2e/source-control-commit-message-ai.spec.ts index 89c11f5f2..60fd8c49d 100644 --- a/tests/e2e/source-control-commit-message-ai.spec.ts +++ b/tests/e2e/source-control-commit-message-ai.spec.ts @@ -4,6 +4,7 @@ import os from 'node:os' import path from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' +import { openSourceControlForWorktree } from './helpers/worktree-registration' function createWorktreeWithStagedChange(repoPath: string): { branchName: string @@ -50,67 +51,16 @@ test.describe('Source Control AI commit messages', () => { try { await waitForSessionReady(orcaPage) - await orcaPage.evaluate( - async ({ repoPath, worktreePath: targetWorktreePath, agentCommand: command }) => { - const store = window.__store - if (!store) { - throw new Error('window.__store is not available') - } - const state = store.getState() - await state.fetchRepos() - const repo = store.getState().repos.find((entry) => entry.path === repoPath) - if (!repo) { - throw new Error(`Seeded E2E repo was not registered: ${repoPath}`) - } - const listedWorktrees = await window.api.worktrees.list({ repoId: repo.id }) - store.setState((current) => ({ - worktreesByRepo: { - ...current.worktreesByRepo, - [repo.id]: listedWorktrees - } - })) - const normalizeMacTmpPath = (value: string): string => - value.startsWith('/private/var/') ? value.slice('/private'.length) : value - const worktree = listedWorktrees.find( - (entry) => normalizeMacTmpPath(entry.path) === normalizeMacTmpPath(targetWorktreePath) - ) - if (!worktree) { - throw new Error( - `E2E worktree was not loaded: ${targetWorktreePath}; listed=${listedWorktrees - .map((entry) => entry.path) - .join(', ')}` - ) - } - - store.getState().setActiveWorktree(worktree.id) - await store.getState().updateSettings({ - commitMessageAi: { - enabled: true, - agentId: 'custom', - selectedModelByAgent: {}, - selectedThinkingByModel: {}, - customPrompt: '', - customAgentCommand: command - } - }) - const status = await window.api.git.status({ worktreePath: worktree.path }) - store.getState().setGitStatus(worktree.id, status) - store.getState().setRightSidebarTab('source-control') - store.getState().setRightSidebarOpen(true) - }, - { repoPath: testRepoPath, worktreePath, agentCommand } - ) - - await expect - .poll( - async () => - orcaPage.evaluate(() => { - const state = window.__store?.getState() - return Boolean(state?.rightSidebarOpen && state?.rightSidebarTab === 'source-control') - }), - { timeout: 5_000 } - ) - .toBe(true) + await openSourceControlForWorktree(orcaPage, testRepoPath, worktreePath, { + commitMessageAi: { + enabled: true, + agentId: 'custom', + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: agentCommand + } + }) const textarea = orcaPage.getByRole('textbox', { name: 'Commit message' }) await expect(textarea).toBeVisible({ timeout: 10_000 }) diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index 9d13159f0..fbea6ebf1 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -413,21 +413,6 @@ async function pressShiftedRussianLayoutKey(page: Page): Promise<{ }) } -// Why: handleRequestClosePane pops a "Close Terminal?" dialog when the pane -// reports a running child process. Under E2E, a freshly split pane's -// proc.process is briefly unset so the check returns true spuriously. Click -// Close when the dialog appears so the test's chord-routing assertion stays -// deterministic; no-op when it doesn't. -async function confirmCloseDialogIfShown(page: Page): Promise { - const confirmButton = page.getByRole('button', { name: 'Close', exact: true }) - try { - await confirmButton.waitFor({ state: 'visible', timeout: 500 }) - await confirmButton.click() - } catch { - // Dialog did not appear — pane closed directly. - } -} - async function pressAndExpectWrite( page: Page, app: ElectronApplication, @@ -460,6 +445,34 @@ const mod = isMac ? 'Meta' : 'Control' const splitVerticalChord = isMac ? `${mod}+d` : `${mod}+Shift+d` const splitHorizontalChord = isMac ? `${mod}+Shift+d` : 'Alt+Shift+d' +// Why: a freshly split pane can transiently still report a running child, so +// poll for the confirm dialog and pane-count settling instead of a fixed wait. +async function closeActivePaneAndSettle(page: Page, expectedCount: number): Promise { + await focusActiveTerminal(page) + await page.keyboard.press(`${mod}+w`) + // The "Stop running command?" confirm surfaces a "Stop and Close" action when + // the pane still reports a running child. + const confirmButton = page.getByRole('button', { name: /Stop and Close/i }) + await expect + .poll( + async () => { + if (await confirmButton.isVisible().catch(() => false)) { + // Why: surface a click failure so a real actionability/strict-mode + // error isn't hidden behind the generic pane-count timeout. + await confirmButton.click().catch((err) => { + console.warn('closeActivePaneAndSettle: confirm click failed', err) + }) + } + return countVisibleTerminalPanes(page) + }, + { + timeout: 10_000, + message: `Expected ${expectedCount} visible terminal panes after close` + } + ) + .toBe(expectedCount) +} + // Why: serial mode is load-bearing. Tests mutate shared Electron app state // (pane layout, terminal buffer, expand toggle) and the pty:write spy log is // a single main-process singleton. Parallel execution would interleave chord @@ -749,6 +762,9 @@ test.describe('Terminal Shortcuts', () => { await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(splitVerticalChord) await waitForPaneCount(orcaPage, panesBeforeSplit + 1) + // Why: ensure the new split pane's PTY is actually bound before we later + // close it, so the close cycle can't race an in-progress split. + await waitForActivePanePtyId(orcaPage) // Cmd/Ctrl+] and Cmd/Ctrl+[ cycle focus (no pane-count change). await focusActiveTerminal(orcaPage) @@ -781,25 +797,15 @@ test.describe('Terminal Shortcuts', () => { .toBe(false) // Cmd/Ctrl+W closes the active split pane (not the whole tab: >1 pane). - // Why: the close handler checks hasChildProcesses async; a freshly - // spawned pane can transiently report a running child (node-pty's - // proc.process lags the spawn), which surfaces a confirmation dialog - // instead of closing immediately. Confirm it if it appears — the test - // only needs to prove the chord routed to the close handler. - await focusActiveTerminal(orcaPage) - await orcaPage.keyboard.press(`${mod}+w`) - await confirmCloseDialogIfShown(orcaPage) - await waitForPaneCount(orcaPage, panesBeforeSplit) + await closeActivePaneAndSettle(orcaPage, panesBeforeSplit) // Split horizontally (chord varies by platform — see splitHorizontalChord). const panesBeforeHSplit = await countVisibleTerminalPanes(orcaPage) await focusActiveTerminal(orcaPage) await orcaPage.keyboard.press(splitHorizontalChord) await waitForPaneCount(orcaPage, panesBeforeHSplit + 1) - await focusActiveTerminal(orcaPage) - await orcaPage.keyboard.press(`${mod}+w`) - await confirmCloseDialogIfShown(orcaPage) - await waitForPaneCount(orcaPage, panesBeforeHSplit) + await waitForActivePanePtyId(orcaPage) + await closeActivePaneAndSettle(orcaPage, panesBeforeHSplit) // Cmd/Ctrl+F toggles the search overlay. await focusActiveTerminal(orcaPage) diff --git a/tests/e2e/workspace-space-git-status.spec.ts b/tests/e2e/workspace-space-git-status.spec.ts index 44102bd80..45b756a66 100644 --- a/tests/e2e/workspace-space-git-status.spec.ts +++ b/tests/e2e/workspace-space-git-status.spec.ts @@ -3,13 +3,19 @@ import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { test, expect } from './helpers/orca-app' +import { loadWorktreesUntilPathsPresent } from './helpers/worktree-registration' test.describe('Workspace Space git status checks', () => { test('checks every scanned deletable row, including rows after the first 50', async ({ orcaPage, testRepoPath }) => { - const worktreeParent = mkdtempSync(path.join(os.tmpdir(), 'orca-space-git-status-')) + // Why: on symlinked tmpdirs (/var→/private/var on macOS, /tmp→… on CI) Orca + // registers worktrees under their realpath, so the parent must be canonical + // before `git worktree add` or the recorded paths won't match and rows drop. + const worktreeParent = realpathSync( + mkdtempSync(path.join(os.tmpdir(), 'orca-space-git-status-')) + ) const worktreePaths = Array.from({ length: 60 }, (_, index) => path.join(worktreeParent, `worktree-${index}`) ) @@ -25,6 +31,22 @@ test.describe('Workspace Space git status checks', () => { realpathSync(worktreePath) ) + const repoId = await orcaPage.evaluate((testRepoPath) => { + const store = window.__store + if (!store) { + throw new Error('Expected e2e store to be exposed') + } + const repo = store.getState().repos.find((item) => item.path === testRepoPath) + if (!repo) { + throw new Error('Expected test repo to be loaded') + } + return repo.id + }, testRepoPath) + + // Why: the 60 worktrees were added via raw git, so poll past the 5s scan + // cache TTL until every path registers before deriving the space rows. + await loadWorktreesUntilPathsPresent(orcaPage, repoId, registeredWorktreePaths) + await orcaPage.evaluate( async ({ testRepoPath, worktreePaths }) => { const store = window.__store @@ -37,7 +59,6 @@ test.describe('Workspace Space git status checks', () => { if (!repo) { throw new Error('Expected test repo to be loaded') } - await initialState.fetchWorktrees(repo.id) await window.api.git.status({ worktreePath: worktreePaths[0] }) const state = store.getState() diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index 352f57ab5..3c980756e 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -263,9 +263,12 @@ test.describe('Create Workspace', () => { } ) 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: 'origin/main' } + return { baseBranch: 'HEAD' } }) }, { title, url } @@ -299,6 +302,10 @@ test.describe('Create Workspace', () => { }) 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(() => { @@ -312,7 +319,7 @@ test.describe('Create Workspace', () => { } }) ) - .toEqual({ githubLookupCount: 1, resolvePrBaseCount: 0 }) + .toEqual({ githubLookupCount: 1, resolvePrBaseCount: 1 }) } finally { await orcaPage .evaluate(() => { @@ -358,9 +365,10 @@ test.describe('Create Workspace', () => { }) ) ipcMain.removeHandler('worktrees:resolvePrBase') - // Why: the fixture repo's default branch is master and has no - // remote; resolve the PR base to a ref that actually exists. - ipcMain.handle('worktrees:resolvePrBase', () => ({ baseBranch: 'master' })) + // 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 } )