diff --git a/src/preload/e2e-config.ts b/src/preload/e2e-config.ts index f1cdf668ce3..8414f62d39a 100644 --- a/src/preload/e2e-config.ts +++ b/src/preload/e2e-config.ts @@ -23,5 +23,6 @@ export const preloadE2EConfig = createE2EConfig({ userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null, // Why: Number('') is 0 and Number(undefined) is NaN; both coerce to null so // only a real positive override reaches the renderer parking policy. - terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null + terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null, + terminalRetentionLimit: Number(process.env.ORCA_E2E_TERMINAL_RETENTION_LIMIT) || null }) diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts index 317a3ea4141..102c4503577 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -type MockE2EConfig = { exposeStore: boolean; terminalParkingDelayMs: number | null } +type MockE2EConfig = { + exposeStore: boolean + terminalParkingDelayMs: number | null + terminalRetentionLimit: number | null +} let mockE2EConfig: MockE2EConfig @@ -21,6 +25,8 @@ type TerminalParkingE2EOverridesModule = { coldParkDelayMs?: number hotRetainMs?: number hotRetainLimit?: number + retentionTtlMs?: number + retentionLimit?: number } registerTerminalParkingDebugHandle: () => void } @@ -32,7 +38,11 @@ async function importOverridesModule(): Promise { beforeEach(() => { - mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null } + mockE2EConfig = { + exposeStore: false, + terminalParkingDelayMs: null, + terminalRetentionLimit: null + } delete (globalThis as { window?: unknown }).window }) @@ -41,13 +51,17 @@ describe('getTerminalParkingPolicyOverrides', () => { }) it('ignores the delay override outside e2e (exposeStore off)', async () => { - mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: 500 } + mockE2EConfig = { + exposeStore: false, + terminalParkingDelayMs: 500, + terminalRetentionLimit: null + } const { getTerminalParkingPolicyOverrides } = await importOverridesModule() expect(getTerminalParkingPolicyOverrides()).toEqual({}) }) it('maps the e2e delay to coldParkDelayMs, hotRetainMs, and retentionTtlMs', async () => { - mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 } + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500, terminalRetentionLimit: null } const { getTerminalParkingPolicyOverrides } = await importOverridesModule() expect(getTerminalParkingPolicyOverrides()).toEqual({ coldParkDelayMs: 500, @@ -57,13 +71,48 @@ describe('getTerminalParkingPolicyOverrides', () => { }) it('returns no overrides when no delay is configured', async () => { - mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null } + mockE2EConfig = { + exposeStore: true, + terminalParkingDelayMs: null, + terminalRetentionLimit: null + } const { getTerminalParkingPolicyOverrides } = await importOverridesModule() expect(getTerminalParkingPolicyOverrides()).toEqual({}) }) + it('maps the e2e retention limit independently of the delay', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null, terminalRetentionLimit: 1 } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({ retentionLimit: 1 }) + }) + + it('combines the delay trio with the retention limit when both are set', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500, terminalRetentionLimit: 1 } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({ + coldParkDelayMs: 500, + hotRetainMs: 500, + retentionTtlMs: 500, + retentionLimit: 1 + }) + }) + + it('ignores the retention limit outside e2e (exposeStore off)', async () => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null, terminalRetentionLimit: 1 } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({}) + }) + + it('rejects non-integer or non-positive retention limits', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null, terminalRetentionLimit: 1.5 } + const overridesModule = await importOverridesModule() + expect(overridesModule.getTerminalParkingPolicyOverrides()).toEqual({}) + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null, terminalRetentionLimit: 0 } + expect(overridesModule.getTerminalParkingPolicyOverrides()).toEqual({}) + }) + it('registers window.__terminalParkingDebug on import under exposeStore', async () => { - mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 } + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500, terminalRetentionLimit: null } const testWindow: { __terminalParkingDebug?: { parkDelayMs: number; parkedTabIds: () => string[] } } = {} @@ -74,7 +123,11 @@ describe('getTerminalParkingPolicyOverrides', () => { }) it('does not register the debug handle outside e2e', async () => { - mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null } + mockE2EConfig = { + exposeStore: false, + terminalParkingDelayMs: null, + terminalRetentionLimit: null + } const testWindow: { __terminalParkingDebug?: unknown } = {} ;(globalThis as { window?: unknown }).window = testWindow await importOverridesModule() diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts index 915e03c2ba1..d82e1dfcf50 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts @@ -10,11 +10,21 @@ import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers' // in the hot-retain working set for 5 minutes and never park within a test // run. Gated on exposeStore so packaged builds ignore stray env vars. export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverrides { - const delayMs = e2eConfig.exposeStore ? e2eConfig.terminalParkingDelayMs : null - return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0 - ? // Why retentionTtlMs too: the retention-budget e2e needs its 45min TTL shrunk the same way. - { coldParkDelayMs: delayMs, hotRetainMs: delayMs, retentionTtlMs: delayMs } - : {} + if (!e2eConfig.exposeStore) { + return {} + } + const delayMs = e2eConfig.terminalParkingDelayMs + const retentionLimit = e2eConfig.terminalRetentionLimit + return { + ...(typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0 + ? // Why retentionTtlMs too: the retention-budget e2e needs its 45min TTL shrunk the same way. + { coldParkDelayMs: delayMs, hotRetainMs: delayMs, retentionTtlMs: delayMs } + : {}), + // Why: limit=1 lets a spec force-park with only two hidden un-parkable worktrees (production floor is 12). + ...(typeof retentionLimit === 'number' && Number.isInteger(retentionLimit) && retentionLimit > 0 + ? { retentionLimit } + : {}) + } } export function registerTerminalParkingDebugHandle(): void { diff --git a/src/shared/e2e-config.ts b/src/shared/e2e-config.ts index e8f34d6bf51..e24ab578625 100644 --- a/src/shared/e2e-config.ts +++ b/src/shared/e2e-config.ts @@ -6,6 +6,9 @@ export type E2EConfig = { /** Test-only override (ORCA_E2E_TERMINAL_PARKING_DELAY_MS) shrinking the * terminal hidden-view parking delays. null means use production timing. */ terminalParkingDelayMs: number | null + /** Test-only override (ORCA_E2E_TERMINAL_RETENTION_LIMIT) shrinking the + * hidden un-parkable worktree force-park budget. null means production (12). */ + terminalRetentionLimit: number | null } type E2EConfigInput = { @@ -13,6 +16,7 @@ type E2EConfigInput = { exposeStore?: boolean userDataDir?: string | null terminalParkingDelayMs?: number | null + terminalRetentionLimit?: number | null } export function createE2EConfig(input: E2EConfigInput): E2EConfig { @@ -25,12 +29,20 @@ export function createE2EConfig(input: E2EConfigInput): E2EConfig { input.terminalParkingDelayMs > 0 ? input.terminalParkingDelayMs : null + // Why: a worktree count — only a positive integer is a meaningful budget. + const terminalRetentionLimit = + typeof input.terminalRetentionLimit === 'number' && + Number.isInteger(input.terminalRetentionLimit) && + input.terminalRetentionLimit > 0 + ? input.terminalRetentionLimit + : null return { enabled: headless || exposeStore || userDataDir !== null, headless, exposeStore, userDataDir, - terminalParkingDelayMs + terminalParkingDelayMs, + terminalRetentionLimit } } diff --git a/tests/e2e/helpers/docker-ssh-relay-second-worktree.ts b/tests/e2e/helpers/docker-ssh-relay-second-worktree.ts new file mode 100644 index 00000000000..72f94fbf2f3 --- /dev/null +++ b/tests/e2e/helpers/docker-ssh-relay-second-worktree.ts @@ -0,0 +1,40 @@ +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/terminal-retention-budget.spec.ts b/tests/e2e/terminal-retention-budget.spec.ts new file mode 100644 index 00000000000..440819fb2e5 --- /dev/null +++ b/tests/e2e/terminal-retention-budget.spec.ts @@ -0,0 +1,130 @@ +import type { TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { getActiveTabId, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} 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' + +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({ + orcaAppExtraEnv: { + ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS), + // Why limit=1: two hidden un-parkable worktrees then exceed the budget while + // the last-active exemption still spares exactly one — the smallest live proof. + ORCA_E2E_TERMINAL_RETENTION_LIMIT: '1' + } +}) + +// C1 slice B: hidden worktrees ordinary parking can never evict (here: SSH with +// slice A's terminalSshViewParking off) force-park beyond the retention budget, +// least-recently-hidden first, and reveal restores content from the relay replay. +test.describe('terminal hidden-worktree retention budget', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'Docker SSH parking uses POSIX SSH tooling.') + + test('force-parks the older hidden un-parkable worktree and spares the newest', async ({ + orcaPage + }, testInfo: TestInfo) => { + test.setTimeout(240_000) + let target: DockerSshRelayTarget | null = null + 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 + .poll(() => waitForActiveWorktree(orcaPage), { timeout: 30_000 }) + .toBe(older.worktreeId) + await waitForActiveTerminalManager(orcaPage, 60_000) + const olderPtyId = await waitForActivePanePtyId(orcaPage, 60_000) + const olderTabId = await getActiveTabId(orcaPage) + if (!olderTabId) { + throw new Error('older SSH terminal tab did not become active') + } + // Why the ':' terminator: match the exact echoed line, not the typed command. + const olderMarker = `RETENTION_OLD_${Date.now()}` + await sendToTerminal(orcaPage, olderPtyId, `echo "${olderMarker}:"\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 20_000), { + timeout: 30_000, + message: 'older worktree marker did not render before hiding' + }) + .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( + orcaPage, + older.targetId, + DOCKER_SSH_SECOND_HUB_REMOTE_REPO_PATH + ) + await expect + .poll(() => waitForActiveWorktree(orcaPage), { timeout: 30_000 }) + .toBe(newer.worktreeId) + await waitForActiveTerminalManager(orcaPage, 60_000) + await waitForActivePanePtyId(orcaPage, 60_000) + const newerTabId = await getActiveTabId(orcaPage) + if (!newerTabId) { + 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) + + // The older worktree must force-park (its pane managers unmount)… + await waitForTabParked(orcaPage, olderTabId, { parkDelayMs: PARKING_DELAY_MS }) + // …while the newest hidden worktree keeps its mounted panes (last-active exemption). + const newerStillMounted = await orcaPage.evaluate( + (tabId) => window.__paneManagers?.get(tabId) !== undefined, + newerTabId + ) + expect(newerStillMounted).toBe(true) + + // Reveal the evicted worktree: with SSH parking disabled the model paint is + // off, so the relay replay must restore the marker tail — never a blank pane. + await orcaPage.evaluate( + ({ worktreeId, tabId }) => { + const state = window.__store?.getState() + state?.setActiveWorktree(worktreeId) + state?.setActiveTab(tabId) + state?.setActiveTabType('terminal') + }, + { worktreeId: older.worktreeId, tabId: olderTabId } + ) + await waitForActiveTerminalManager(orcaPage, 60_000) + await expect + .poll(() => getTerminalContent(orcaPage, 20_000), { + timeout: 60_000, + message: 'revealed evicted worktree did not restore the marker via relay replay' + }) + .toContain(`${olderMarker}:`) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) +})