diff --git a/tests/e2e/helpers/docker-ssh-relay-second-worktree.ts b/tests/e2e/helpers/docker-ssh-relay-second-worktree.ts deleted file mode 100644 index 72f94fbf2f3..00000000000 --- a/tests/e2e/helpers/docker-ssh-relay-second-worktree.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Page } from '@stablyai/playwright-test' - -// Why: the retention-budget spec needs TWO remote worktrees on one connected -// target; connectDockerSshRelayTarget always mints a fresh target, so this -// replays only its addRemote+activate tail against an existing connection. -export async function addDockerSshRelayRemoteWorktree( - page: Page, - targetId: string, - remotePath: string -): Promise<{ repoId: string; worktreeId: string }> { - return page.evaluate( - async ({ targetId, remotePath }) => { - const store = window.__store - if (!store) { - throw new Error('Store unavailable') - } - const result = await window.api.repos.addRemote({ - connectionId: targetId, - remotePath, - displayName: `Docker SSH Relay E2E ${remotePath}` - }) - if ('error' in result) { - throw new Error(result.error) - } - await store.getState().fetchRepos() - await store.getState().fetchWorktrees(result.repo.id) - const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? [])[0] - if (!worktree) { - throw new Error(`No remote worktree found for ${result.repo.path}`) - } - store.getState().setActiveWorktree(worktree.id) - if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { - store.getState().createTab(worktree.id) - } - store.getState().setActiveTabType('terminal') - return { repoId: result.repo.id, worktreeId: worktree.id } - }, - { targetId, remotePath } - ) -} diff --git a/tests/e2e/helpers/docker-ssh-relay-worktree-activation.ts b/tests/e2e/helpers/docker-ssh-relay-worktree-activation.ts new file mode 100644 index 00000000000..afe54b45347 --- /dev/null +++ b/tests/e2e/helpers/docker-ssh-relay-worktree-activation.ts @@ -0,0 +1,67 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' + +// Why: the retention-budget spec needs several worktrees of ONE connected remote +// repo (adding a second repo mid-session misroutes its pty spawn to the local +// daemon — pre-existing multi-repo issue). Creation goes through the product's +// own createWorktree path so the result lands in worktreesByRepo (an external +// `git worktree add` only shows up as a detected worktree needing adoption). +// The create is polled: the relay channel can drop and reconnect shortly after +// connect, and an RPC inside that window fails with "Multiplexer disposed". +export async function createAndActivateDockerSshRelayWorktree( + page: Page, + repoId: string, + worktreeName: string +): Promise<{ worktreeId: string }> { + let worktreeId: string | null = null + await expect + .poll( + async () => { + worktreeId = await page.evaluate( + async ({ repoId, worktreeName }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + // Why: a retried create must reuse a prior attempt's worktree + // instead of failing forever on "already exists". + const existing = (store.getState().worktreesByRepo[repoId] ?? []).find((candidate) => + candidate.path.endsWith(`/${worktreeName}`) + ) + if (existing) { + return existing.id + } + try { + const result = await store.getState().createWorktree(repoId, worktreeName) + await store.getState().fetchWorktrees(repoId) + return result.worktree.id + } catch { + return null + } + }, + { repoId, worktreeName } + ) + return worktreeId + }, + { + timeout: 90_000, + message: `remote worktree ${worktreeName} was not created in repo ${repoId}` + } + ) + .not.toBeNull() + if (!worktreeId) { + throw new Error(`remote worktree ${worktreeName} did not resolve an id`) + } + await page.evaluate((id) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setActiveWorktree(id) + if ((store.getState().tabsByWorktree[id] ?? []).length === 0) { + store.getState().createTab(id) + } + store.getState().setActiveTabType('terminal') + }, worktreeId) + return { worktreeId } +} diff --git a/tests/e2e/terminal-retention-budget.spec.ts b/tests/e2e/terminal-retention-budget.spec.ts index 440819fb2e5..d877a29cee2 100644 --- a/tests/e2e/terminal-retention-budget.spec.ts +++ b/tests/e2e/terminal-retention-budget.spec.ts @@ -9,18 +9,20 @@ import { } from './helpers/terminal' import { waitForTabParked } from './helpers/terminal-hidden-parking' import { - DOCKER_SSH_SECOND_HUB_REMOTE_REPO_PATH, cleanupDockerSshRelayTarget, startDockerSshRelayTarget, type DockerSshRelayTarget } from './helpers/docker-ssh-relay-target' import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' -import { addDockerSshRelayRemoteWorktree } from './helpers/docker-ssh-relay-second-worktree' +import { createAndActivateDockerSshRelayWorktree } from './helpers/docker-ssh-relay-worktree-activation' const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 test.use({ + // Why no seeded local repo: matching every green Docker SSH spec — the same + // mid-session repo-add misroute hits a remote repo added beside a local one. + seedTestRepo: false, orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS), // Why limit=1: two hidden un-parkable worktrees then exceed the budget while @@ -44,14 +46,6 @@ test.describe('terminal hidden-worktree retention budget', () => { try { target = startDockerSshRelayTarget(testInfo) await waitForSessionReady(orcaPage) - // The seeded local repo is the third context both remote worktrees hide behind. - const localWorktreeId = await waitForActiveWorktree(orcaPage) - - // Why: with SSH view parking off, SSH ptys are not park-restorable, so both - // remote worktrees join the un-parkable class the retention budget governs. - await orcaPage.evaluate(async () => { - await window.__store?.getState().updateSettings({ terminalSshViewParking: false }) - }) const older = await connectDockerSshRelayTarget(orcaPage, target) await expect @@ -73,12 +67,23 @@ test.describe('terminal hidden-worktree retention budget', () => { }) .toContain(`${olderMarker}:`) - // Second remote worktree on the same connection — activating it hides the - // older one, making the older the least-recently-hidden candidate. - const newer = await addDockerSshRelayRemoteWorktree( + // Why: with SSH view parking off, SSH ptys are not park-restorable, so the + // hidden remote worktrees join the un-parkable class the budget governs. + // Written after the first terminal is live so it cannot race target setup; + // parking eligibility reads it at verdict time, not spawn time. + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ terminalSshViewParking: false }) + }) + + // Why worktrees of ONE remote repo: the retention budget ranks worktrees, + // and a repo added mid-session misroutes its pty spawn to the local daemon + // (pre-existing multi-repo issue, independent of retention). + // Activating the second worktree hides the older one, making the older the + // least-recently-hidden candidate. + const newer = await createAndActivateDockerSshRelayWorktree( orcaPage, - older.targetId, - DOCKER_SSH_SECOND_HUB_REMOTE_REPO_PATH + older.repoId, + 'retention-newer' ) await expect .poll(() => waitForActiveWorktree(orcaPage), { timeout: 30_000 }) @@ -90,11 +95,18 @@ test.describe('terminal hidden-worktree retention budget', () => { throw new Error('newer SSH terminal tab did not become active') } - // Hide both remote worktrees behind the local context: two hidden - // un-parkable worktrees against a budget of one. - await orcaPage.evaluate((worktreeId) => { - window.__store?.getState().setActiveWorktree(worktreeId) - }, localWorktreeId) + // Third context: activating it hides BOTH earlier worktrees — two hidden + // un-parkable worktrees against a budget of one. It stays visible, so it + // is never a retention candidate itself. + const third = await createAndActivateDockerSshRelayWorktree( + orcaPage, + older.repoId, + 'retention-third' + ) + await expect + .poll(() => waitForActiveWorktree(orcaPage), { timeout: 30_000 }) + .toBe(third.worktreeId) + await waitForActiveTerminalManager(orcaPage, 60_000) // The older worktree must force-park (its pane managers unmount)… await waitForTabParked(orcaPage, olderTabId, { parkDelayMs: PARKING_DELAY_MS })