From 136a34a331a261d4c591a92538fb2e839c4cc48f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:46:23 -0700 Subject: [PATCH] test(terminal): SSH park+reveal e2e round-trip + as-built design notes (C1) Docker-gated (ORCA_E2E_SSH_DOCKER=1) spec: SSH tab parks behind a decoy and reveal restores marker content at multi-viewport scrollback depth. DESIGN.md records the as-built deltas (inline paint, force-park shape, last-active floor) and the residuals so follow-ups aren't lost. Co-authored-by: Orca --- DESIGN.md | 56 +++++++++++++++ tests/e2e/ssh-terminal-parking.spec.ts | 98 ++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/e2e/ssh-terminal-parking.spec.ts diff --git a/DESIGN.md b/DESIGN.md index 67681d9bc46..f2194c84b5c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -333,3 +333,59 @@ targeted vitest suites; no max-lines disables. - Revert story: each option is a separable commit; B's eviction is additive to the parking effect and reverts cleanly; A's paint change falls back to the relay-replay branch which remains intact. + +## 8. Implementation notes (as built — deltas and residuals) + +Landed as four commits on this branch (A, B, C, plus an A paint-path fix): + +1. **A paint is inline, not via `applyMainBufferSnapshot`.** That function + runs its own `structuralReplayCoordinator.run`; calling it from + `applyReattachPayload` (already inside the coordinator when a relay replay + exists) deadlocks on the coordinator's tail chain. The paint mirrors the + daemon-snapshot branch (folded `scrollbackAnsi` + rehydrate + screen, + dimension-matched, escape tail last) and arms + `setRestoredSnapshotBaseline` so deferred/live chunks the snapshot covers + dedupe instead of double-painting. +2. **B is force-park, not unmount-from-`mountedWorktreeIdsRef`.** Force-parked + ids join the existing parked set after the coverage veto, so every + downstream mechanism (render null, watcher sync, reveal) is the ordinary + parking machinery; the only new state is the verdict itself. Blast radius + shrank accordingly. +3. **Last-active exemption applies to eviction too** (ranking reuses + `selectIdsBeyondHotRetain`): a SINGLE hidden un-parkable worktree is never + force-parked — one warm slot is the deliberate floor, matching parking + semantics. The field profile this targets is many-worktree. +4. **Residual: empty relay replay on parked-SSH reveal.** If relay `buffered` + is empty (relay restart), `hasStructuralReplay` is false and nothing + paints (pre-existing app-restart behavior). When the hidden-delivery gate + dropped bytes during the park, main's `pty:modelRestoreNeeded` marker + fires on reveal and repaints from the model with seq dedupe — so the + blank case is confined to relay-restart with no dropped-byte marker. +5. **Residual (pre-existing, unchanged):** parked/evicted fact-mode watchers + omit `onCommandFinished`/`onCommandCode*`; OSC 133;D command-lifecycle + facts drop while parked. + +Test coverage vs gate condition 5: +- SSH park+reveal round-trip with scrollback-depth assertion: + `tests/e2e/ssh-terminal-parking.spec.ts` (Docker-gated, + `ORCA_E2E_SSH_DOCKER=1`, same lane as the other SSH relay specs) + unit + coverage of eligibility/coverage/paint-source decisions. +- Folder workspaces: unit parity case (worktree-shaped id equality) in + `terminal-hidden-view-parking.test.ts`; the local parking e2e already runs + on folder-workspace-shaped ids. +- Remote-runtime eviction under B: covered at the selector level + (`terminal-hidden-worktree-retention.test.ts`); a live e2e needs either two + remote worktrees in the rig or a retention-limit e2e override (the + last-active exemption shields a single candidate) — follow-up, noted here + so it isn't lost. +- Fail-open exemption: unit-covered (`isEvictionExemptTerminalTab` + selector + exclusion case). +- C demotion+restore: unit-covered (selector TTL/monotone cases, registry + notify damping, row clamp); demotion applies through the same + `applyTerminalScrollbackRowsToMountedPanes` path the existing + settings-change e2e exercises. +- Flip-loop regression (condition 4): policy-level idempotence + + time-monotone membership tests for both new selectors, plus notify damping + on the demotion registry; the component-level damping conventions (verdict + out of own deps, set-equality bail, positive-delay timers) are preserved + unchanged. diff --git a/tests/e2e/ssh-terminal-parking.spec.ts b/tests/e2e/ssh-terminal-parking.spec.ts new file mode 100644 index 00000000000..ba6540b7368 --- /dev/null +++ b/tests/e2e/ssh-terminal-parking.spec.ts @@ -0,0 +1,98 @@ +import type { TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady, getActiveTabId } from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' +import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking' +import { + cleanupDockerSshRelayTarget, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' + +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({ seedTestRepo: false }) + +// C1 slice A: SSH tabs park like local ones and reveal restores content from +// main's headless model (relay replay is the fallback). This is the SSH +// park+reveal round-trip fidelity check the design gate required. +test.describe('SSH terminal hidden view parking', () => { + 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('parks a hidden SSH tab and restores its scrollback on reveal', async ({ + orcaPage + }, testInfo: TestInfo) => { + test.setTimeout(240_000) + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + const remote = await connectDockerSshRelayTarget(orcaPage, target) + await expect + .poll(() => waitForActiveWorktree(orcaPage), { timeout: 30_000 }) + .toBe(remote.worktreeId) + await waitForActiveTerminalManager(orcaPage, 60_000) + const sshPtyId = await waitForActivePanePtyId(orcaPage, 60_000) + const sshTabId = await getActiveTabId(orcaPage) + if (!sshTabId) { + throw new Error('SSH terminal tab did not become active') + } + const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + expect(snapshot.panes[0]?.ptyId).toBe(sshPtyId) + + // Why numbered lines: the reveal assertion checks BOTH the final marker + // (screen) and an early line (scrollback depth beyond one viewport). + const marker = `SSH_PARK_MARKER_${Date.now()}` + await sendToTerminal( + orcaPage, + sshPtyId, + `for i in $(seq 1 200); do echo "${marker}_$i"; done\r` + ) + await expect + .poll(() => getTerminalContent(orcaPage, 20_000), { + timeout: 30_000, + message: 'SSH marker output did not render before parking' + }) + .toContain(`${marker}_200`) + + await parkHiddenTabBehindDecoy(orcaPage, remote.worktreeId, sshTabId, { + parkDelayMs: PARKING_DELAY_MS + }) + + // Reveal: reattach must paint from main's headless model (or relay + // replay when the model is unavailable) — never a blank pane. + await orcaPage.evaluate((tabId) => { + const state = window.__store?.getState() + state?.setActiveTab(tabId) + state?.setActiveTabType('terminal') + }, sshTabId) + await waitForActiveTerminalManager(orcaPage, 60_000) + await expect + .poll(() => getTerminalContent(orcaPage, 20_000), { + timeout: 60_000, + message: 'revealed SSH tab did not restore the final marker line' + }) + .toContain(`${marker}_200`) + // Why _150 not _1: the relay-replay fallback is a 100KiB tail, so the + // earliest lines are only guaranteed under the main-model paint; a mid + // marker asserts multi-viewport depth without coupling to either source. + await expect + .poll(() => getTerminalContent(orcaPage, 40_000), { + timeout: 15_000, + message: 'revealed SSH tab lost scrollback beyond the visible screen' + }) + .toContain(`${marker}_150`) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) +})