Fix stale and race-prone E2E test failures (#7468)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
0fa8589abf
commit
b58c0a42da
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<typeof waitForSessionReady>[0],
|
||||
repoPath: string,
|
||||
targetWorktreePath: string
|
||||
): Promise<void> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue