mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
test(terminal): retention-budget force-park e2e with a retentionLimit override (C1 6b)
ORCA_E2E_TERMINAL_RETENTION_LIMIT flows preload → e2e-config → getTerminalParkingPolicyOverrides (exposeStore-gated, positive-integer only) so a spec can shrink the force-park budget to 1. The Docker-gated spec opens two remote worktrees on one relay target (second pre-seeded remote repo), disables terminalSshViewParking to make both un-parkable, hides both behind the local context, and proves the older one force-parks while the last-active exemption spares the newest; re-activating the evicted worktree restores the marker tail via relay replay. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
})
|
||||
|
||||
@@ -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<TerminalParkingE2EOverridesModul
|
||||
|
||||
describe('getTerminalParkingPolicyOverrides', () => {
|
||||
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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user