mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminal): heal stale PTY grids on window wake (#9626)
* fix(terminal): heal stale PTY grids on window wake * fix(terminal): bound window-wake size recovery
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-19",
|
||||
"updatedAt": "2026-07-20",
|
||||
"policy": {
|
||||
"maturityLevels": [
|
||||
"experimental",
|
||||
@@ -1224,7 +1224,8 @@
|
||||
"PTY sizing",
|
||||
"split layout",
|
||||
"restore",
|
||||
"hidden-to-visible transitions"
|
||||
"hidden-to-visible transitions",
|
||||
"window wake"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
@@ -1241,7 +1242,7 @@
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Shell-visible size, SSH/remote geometry, Windows ConPTY readback, and resume-time reassertion coverage arrive with the pending stack and #7006.",
|
||||
"coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Deterministic provider-contract coverage now includes settled window-wake reassertion and SSH relay applied-size readback. Live shell-visible SSH/remote geometry and Windows ConPTY readback remain non-blocking gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6644",
|
||||
"https://github.com/stablyai/orca/pull/6649",
|
||||
@@ -1252,15 +1253,19 @@
|
||||
"https://github.com/stablyai/orca/pull/7192"
|
||||
],
|
||||
"invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes.",
|
||||
"oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, and hidden-to-visible activation, then asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, and visibility resume reasserts real drift without hot listSessions. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.",
|
||||
"oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, and window wake. It asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, visibility resume reasserts real drift without hot listSessions, one settled wake produces one geometry-only readback, and SSH relay readback is authoritative with bounded fallback. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts"
|
||||
"src/main/providers/ssh-pty-provider.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/relay/pty-handler.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -1271,6 +1276,13 @@
|
||||
"applied-size readback divergence is re-forwarded before handoff"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts",
|
||||
"assertions": [
|
||||
"only one applied-size read stays in flight while the grid changes",
|
||||
"stale in-flight targets never resize the PTY back to an older grid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts",
|
||||
"assertions": [
|
||||
@@ -1279,11 +1291,26 @@
|
||||
"hidden 0x0 background spawn is not forced to phantom desktop size"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"assertions": [
|
||||
"window focus reasserts PTY size after fitting",
|
||||
"the immediate and settled wake fits produce exactly one geometry-only reassertion"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty.test.ts",
|
||||
"assertions": [
|
||||
"accepted desktop resizes fan out to the runtime after provider resize",
|
||||
"rejected desktop resizes do not fan out to the runtime"
|
||||
"rejected desktop resizes do not fan out to the runtime",
|
||||
"provider-owned null remains unverified instead of falling back to requested size"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/ssh-pty-provider.test.ts",
|
||||
"assertions": [
|
||||
"SSH applied-size readback uses the relay with a one-second timeout",
|
||||
"old-relay method-not-found is cached while transient failures remain retryable"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1292,17 +1319,24 @@
|
||||
"the headless mirror resizes after an accepted desktop PTY resize (#7192)",
|
||||
"headless mirror resizes are ordered behind queued PTY writes (#7192)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/relay/pty-handler.test.ts",
|
||||
"assertions": [
|
||||
"relay readback reports the grid actually applied by node-pty",
|
||||
"missing relay PTYs return an unverified null size"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-03",
|
||||
"date": "2026-07-20",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.4,
|
||||
"summary": "4 test file(s) passed, 777 tests passed on main@1282f5c2d in a clean checkout."
|
||||
"durationSeconds": 18.54,
|
||||
"summary": "8 test files passed, 1,272 tests passed on the PR branch."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -1319,16 +1353,15 @@
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Must include resize/throughput budget so convergence loops do not add frame or SIGWINCH churn."
|
||||
"evidence": "The wake hook count test permits two visual fits but exactly one applied-size read per visible pane and no broad visibility/agent lifecycle call. SSH readback has a one-second timeout, caches stable method-not-found, and retries only transient failures. No polling, listSessions scan, subprocess, or unbounded retry was added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Build deterministic fault hooks before adding broad E2E coverage.",
|
||||
"Keep stress variants non-blocking until runtime and flake history are known."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live Electron geometry command yet beyond the local live PTY resize slice.",
|
||||
"The runtime mirror geometry slice is unregistered: #7192's red tests (orca-runtime.test.ts headless-mirror resize and ordering, pty.test.ts desktop resize fan-out) exist on fresh main but not at this branch's merge-base, so they can only be registered during the rebase.",
|
||||
"Does not yet prove shell-visible size, provider-applied size, and xterm size converge across SSH/remote paths.",
|
||||
"Local and Docker-backed SSH Electron wake specs exist but remain manual/non-blocking pending runtime and flake history.",
|
||||
"The deterministic gate proves the SSH provider/relay readback contract, not live shell-visible convergence across every remote path.",
|
||||
"Does not yet prove Windows ConPTY geometry/readback.",
|
||||
"Current command uses deterministic fake providers for the main/renderer contracts, not a real remote PTY."
|
||||
],
|
||||
|
||||
@@ -4703,6 +4703,18 @@ describe('registerPtyHandlers', () => {
|
||||
expect(reported).toEqual({ cols: 80, rows: 24 })
|
||||
})
|
||||
|
||||
it('preserves provider-owned null so the renderer re-forwards an unverified size', async () => {
|
||||
setupProviderWithAppliedSize({ applied: null, getAppliedSize: async () => null })
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
const spawn = await handlers.get('pty:spawn')!(null, { cols: 100, rows: 30, env: {} })
|
||||
const id = (spawn as { id: string }).id
|
||||
resizeListener()(mainWindowIpcEvent, { id, cols: 80, rows: 24 })
|
||||
|
||||
const reported = await handlers.get('pty:getSize')!(null, { id })
|
||||
expect(reported).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the requested size when getAppliedSize throws', async () => {
|
||||
// A dead daemon/relay must never throw across the IPC boundary or block.
|
||||
setupProviderWithAppliedSize({
|
||||
|
||||
+6
-3
@@ -4814,10 +4814,13 @@ export function registerPtyHandlers(
|
||||
ipcMain.handle(
|
||||
'pty:getSize',
|
||||
async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => {
|
||||
const provider = tryGetProviderForPty(args.id)
|
||||
try {
|
||||
const applied = await tryGetProviderForPty(args.id)?.getAppliedSize?.(args.id)
|
||||
if (applied) {
|
||||
return applied
|
||||
if (provider?.getAppliedSize) {
|
||||
// Why: a provider-owned null means it could not verify the applied
|
||||
// grid; preserve null so the renderer re-forwards instead of trusting
|
||||
// the requested-size cache that may describe a dropped resize.
|
||||
return await provider.getAppliedSize(args.id)
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
|
||||
import { toRelaySshPtyId } from './ssh-pty-id'
|
||||
|
||||
type AppliedPtySize = { cols: number; rows: number }
|
||||
// Why: wake repair can safely re-forward on failure, so never inherit the generic 30-second RPC timeout.
|
||||
const SSH_PTY_APPLIED_SIZE_TIMEOUT_MS = 1_000
|
||||
|
||||
export function createSshPtyAppliedSizeReader(
|
||||
mux: SshChannelMultiplexer,
|
||||
connectionId: string
|
||||
): (id: string) => Promise<AppliedPtySize | null> {
|
||||
let supported: boolean | null = null
|
||||
return async (id) => {
|
||||
if (supported === false) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const result = (await mux.request(
|
||||
'pty.getSize',
|
||||
{
|
||||
id: toRelaySshPtyId(connectionId, id)
|
||||
},
|
||||
{ timeoutMs: SSH_PTY_APPLIED_SIZE_TIMEOUT_MS }
|
||||
)) as {
|
||||
cols?: unknown
|
||||
rows?: unknown
|
||||
} | null
|
||||
supported = true
|
||||
if (
|
||||
!result ||
|
||||
!Number.isInteger(result.cols) ||
|
||||
!Number.isInteger(result.rows) ||
|
||||
Number(result.cols) <= 0 ||
|
||||
Number(result.rows) <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { cols: Number(result.cols), rows: Number(result.rows) }
|
||||
} catch (error) {
|
||||
if ((error as { code?: unknown })?.code === JsonRpcErrorCode.MethodNotFound) {
|
||||
// Why: old relays lack pty.getSize; remember that per SSH provider so
|
||||
// each wake re-forwards once without repeatedly probing the same host.
|
||||
supported = false
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,33 @@ describe('SshPtyProvider', () => {
|
||||
expect(mux.notify).toHaveBeenCalledWith('pty.resize', { id: 'pty-1', cols: 120, rows: 40 })
|
||||
})
|
||||
|
||||
it('reads the applied PTY size from the relay', async () => {
|
||||
mux.request.mockResolvedValue({ cols: 120, rows: 40 })
|
||||
|
||||
await expect(provider.getAppliedSize(scopedPty1)).resolves.toEqual({ cols: 120, rows: 40 })
|
||||
expect(mux.request).toHaveBeenCalledWith('pty.getSize', { id: 'pty-1' }, { timeoutMs: 1_000 })
|
||||
})
|
||||
|
||||
it('caches only an old relay method-not-found response', async () => {
|
||||
mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 }))
|
||||
|
||||
await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull()
|
||||
await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull()
|
||||
expect(mux.request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries an applied-size read after a transient relay failure', async () => {
|
||||
mux.request
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('connection lost'), { code: 'CONNECTION_LOST' })
|
||||
)
|
||||
.mockResolvedValueOnce({ cols: 100, rows: 30 })
|
||||
|
||||
await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull()
|
||||
await expect(provider.getAppliedSize(scopedPty1)).resolves.toEqual({ cols: 100, rows: 30 })
|
||||
expect(mux.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shutdown sends pty.shutdown request', async () => {
|
||||
await provider.shutdown(scopedPty1, { immediate: true })
|
||||
expect(mux.request).toHaveBeenCalledWith(
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } fr
|
||||
import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id'
|
||||
import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env'
|
||||
import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress'
|
||||
import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size'
|
||||
|
||||
type DataCallback = (payload: {
|
||||
id: string
|
||||
@@ -56,6 +57,7 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
// multiplexer. Without this, notification callbacks keep firing after
|
||||
// the provider is torn down on disconnect, routing events to stale state.
|
||||
private unsubscribeNotifications: (() => void) | null = null
|
||||
readonly getAppliedSize: NonNullable<IPtyProvider['getAppliedSize']>
|
||||
|
||||
constructor(
|
||||
connectionId: string,
|
||||
@@ -64,6 +66,7 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
) {
|
||||
this.connectionId = connectionId
|
||||
this.mux = mux
|
||||
this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId)
|
||||
|
||||
// Subscribe to relay notifications for PTY events
|
||||
this.unsubscribeNotifications = mux.onNotification((method, params) => {
|
||||
|
||||
@@ -1274,6 +1274,24 @@ describe('PtyHandler', () => {
|
||||
expect(mockResize).toHaveBeenCalledWith(120, 40)
|
||||
})
|
||||
|
||||
it('reports the PTY grid actually applied by node-pty', async () => {
|
||||
mockPtySpawn.mockReturnValue({
|
||||
...mockPtyInstance,
|
||||
cols: 132,
|
||||
rows: 43,
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn()
|
||||
})
|
||||
|
||||
const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string }
|
||||
|
||||
await expect(dispatcher.callRequest('pty.getSize', { id: spawned.id })).resolves.toEqual({
|
||||
cols: 132,
|
||||
rows: 43
|
||||
})
|
||||
await expect(dispatcher.callRequest('pty.getSize', { id: 'missing' })).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('kills PTY on shutdown with SIGTERM by default', async () => {
|
||||
const mockKill = vi.fn()
|
||||
mockPtySpawn.mockReturnValue({
|
||||
|
||||
@@ -575,6 +575,7 @@ export class PtyHandler {
|
||||
this.dispatcher.onRequest('pty.sendSignal', (p) => this.sendSignal(p))
|
||||
this.dispatcher.onRequest('pty.getCwd', (p) => this.getCwd(p))
|
||||
this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p))
|
||||
this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p))
|
||||
this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p))
|
||||
this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p))
|
||||
this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p))
|
||||
@@ -1021,6 +1022,16 @@ export class PtyHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async getSize(
|
||||
params: Record<string, unknown>
|
||||
): Promise<{ cols: number; rows: number } | null> {
|
||||
const managed = this.ptys.get(params.id as string)
|
||||
if (!managed || managed.disposed) {
|
||||
return null
|
||||
}
|
||||
return { cols: managed.pty.cols, rows: managed.pty.rows }
|
||||
}
|
||||
|
||||
private async shutdown(params: Record<string, unknown>): Promise<void> {
|
||||
const id = params.id as string
|
||||
const immediate = params.immediate as boolean
|
||||
|
||||
@@ -1720,6 +1720,7 @@ export default function TerminalPane({
|
||||
managerRef,
|
||||
containerRef,
|
||||
paneTransportsRef,
|
||||
panePtyBindingsRef,
|
||||
isActiveRef,
|
||||
isVisibleRef,
|
||||
toggleExpandPane
|
||||
|
||||
@@ -625,6 +625,7 @@ let inactiveForegroundImmediateBudgetWindowStart = 0
|
||||
type PanePtyBinding = IDisposable & {
|
||||
syncProcessTracking: () => void
|
||||
noteVisibilityResume: () => void
|
||||
reassertPtySizeAfterWindowWake: () => void
|
||||
/** Navigation-free hibernation wake: fires the armed cold-restore --resume
|
||||
* without the size-reassert/foreground-sample side effects of a real reveal.
|
||||
* Used by the mobile wake fanout so a hidden hibernated pane resumes with no
|
||||
@@ -8037,6 +8038,9 @@ export function connectPanePty(
|
||||
requestKnownDroidReconfirmation()
|
||||
sampleVisiblePaneForegroundAgent()
|
||||
},
|
||||
reassertPtySizeAfterWindowWake() {
|
||||
ptySizeReassertion.request({ fit: false })
|
||||
},
|
||||
// Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads.
|
||||
wakeHibernatedAgentIfArmed(claimedProviderSessions) {
|
||||
if (hibernatedWakeInFlightClaimKey) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/constants/terminal'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import { handleTerminalFileDrop } from './terminal-drop-handler'
|
||||
import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event'
|
||||
import { surfaceStaleAgentRow } from './stale-agent-row'
|
||||
@@ -38,6 +39,7 @@ type UseTerminalPaneGlobalEffectsArgs = {
|
||||
managerRef: React.RefObject<PaneManager | null>
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
|
||||
panePtyBindingsRef?: React.RefObject<Map<number, IDisposable>>
|
||||
isActiveRef: React.RefObject<boolean>
|
||||
isVisibleRef: React.RefObject<boolean>
|
||||
toggleExpandPane: (paneId: number) => void
|
||||
@@ -70,6 +72,7 @@ export function useTerminalPaneGlobalEffects({
|
||||
managerRef,
|
||||
containerRef,
|
||||
paneTransportsRef,
|
||||
panePtyBindingsRef,
|
||||
isActiveRef,
|
||||
isVisibleRef,
|
||||
toggleExpandPane
|
||||
@@ -120,7 +123,8 @@ export function useTerminalPaneGlobalEffects({
|
||||
isVisible: rendererVisible,
|
||||
managerRef,
|
||||
isActiveRef,
|
||||
isVisibleRef
|
||||
isVisibleRef,
|
||||
panePtyBindingsRef
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -97,6 +97,59 @@ describe('useTerminalWindowWakeRecovery', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('reasserts pane PTY sizes after the window-focus fit', () => {
|
||||
const reassertPtySizeAfterWindowWake = vi.fn()
|
||||
renderHook(() =>
|
||||
useTerminalWindowWakeRecovery({
|
||||
isVisible: true,
|
||||
managerRef: { current: manager },
|
||||
isActiveRef: { current: true },
|
||||
isVisibleRef: { current: true },
|
||||
panePtyBindingsRef: {
|
||||
current: new Map([[1, { dispose: vi.fn(), reassertPtySizeAfterWindowWake }]]) as never
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
|
||||
expect(reassertPtySizeAfterWindowWake).toHaveBeenCalledTimes(1)
|
||||
expect(recoverVisibleTerminalWindowWakeMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
reassertPtySizeAfterWindowWake.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
|
||||
)
|
||||
})
|
||||
|
||||
it('reasserts once after the settled fit when animation frames are available', () => {
|
||||
const scheduled: { settle: FrameRequestCallback | null } = { settle: null }
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
scheduled.settle = callback
|
||||
return 1
|
||||
})
|
||||
const reassertPtySizeAfterWindowWake = vi.fn()
|
||||
renderHook(() =>
|
||||
useTerminalWindowWakeRecovery({
|
||||
isVisible: true,
|
||||
managerRef: { current: manager },
|
||||
isActiveRef: { current: true },
|
||||
isVisibleRef: { current: true },
|
||||
panePtyBindingsRef: {
|
||||
current: new Map([[1, { dispose: vi.fn(), reassertPtySizeAfterWindowWake }]]) as never
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
expect(reassertPtySizeAfterWindowWake).not.toHaveBeenCalled()
|
||||
expect(scheduled.settle).not.toBeNull()
|
||||
|
||||
scheduled.settle?.(performance.now())
|
||||
|
||||
expect(reassertPtySizeAfterWindowWake).toHaveBeenCalledTimes(1)
|
||||
expect(recoverVisibleTerminalWindowWakeMock.mock.invocationCallOrder.at(-1)).toBeLessThan(
|
||||
reassertPtySizeAfterWindowWake.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
|
||||
)
|
||||
})
|
||||
|
||||
it('unsubscribes from the system resume event on cleanup', () => {
|
||||
const { unmount } = renderWakeRecoveryHook()
|
||||
expect(onSystemResumed).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -2,19 +2,26 @@ import { useEffect } from 'react'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume'
|
||||
import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
|
||||
type UseTerminalWindowWakeRecoveryArgs = {
|
||||
isVisible: boolean
|
||||
managerRef: React.RefObject<PaneManager | null>
|
||||
isActiveRef: React.RefObject<boolean>
|
||||
isVisibleRef: React.RefObject<boolean>
|
||||
panePtyBindingsRef?: React.RefObject<Map<number, IDisposable>>
|
||||
}
|
||||
|
||||
type WindowWakePtyBinding = IDisposable & {
|
||||
reassertPtySizeAfterWindowWake?: () => void
|
||||
}
|
||||
|
||||
export function useTerminalWindowWakeRecovery({
|
||||
isVisible,
|
||||
managerRef,
|
||||
isActiveRef,
|
||||
isVisibleRef
|
||||
isVisibleRef,
|
||||
panePtyBindingsRef
|
||||
}: UseTerminalWindowWakeRecoveryArgs): void {
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
@@ -30,6 +37,13 @@ export function useTerminalWindowWakeRecovery({
|
||||
cancelAnimationFrame(wakeRecoveryFrameId)
|
||||
wakeRecoveryFrameId = null
|
||||
}
|
||||
const reassertPanePtySizes = (): void => {
|
||||
for (const binding of panePtyBindingsRef?.current.values() ?? []) {
|
||||
// Why: one settled read avoids duplicate SSH RPCs while still detecting a dropped resize.
|
||||
const windowWakeBinding = binding as WindowWakePtyBinding
|
||||
windowWakeBinding.reassertPtySizeAfterWindowWake?.()
|
||||
}
|
||||
}
|
||||
const recoverVisibleWake = (
|
||||
clearGlyphAtlases: boolean,
|
||||
source: 'focus' | 'visibilitychange' | 'system-resumed'
|
||||
@@ -60,6 +74,7 @@ export function useTerminalWindowWakeRecovery({
|
||||
clearGlyphAtlases
|
||||
})
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
reassertPanePtySizes()
|
||||
return
|
||||
}
|
||||
settledClearGlyphAtlases = clearGlyphAtlases
|
||||
@@ -76,6 +91,7 @@ export function useTerminalWindowWakeRecovery({
|
||||
isActive: isActiveRef.current,
|
||||
clearGlyphAtlases: clearGlyphAtlasesOnSettle
|
||||
})
|
||||
reassertPanePtySizes()
|
||||
})
|
||||
}
|
||||
// Why: plain refocus (alt-tab, devtools) is frequent and often lands while
|
||||
@@ -115,5 +131,5 @@ export function useTerminalWindowWakeRecovery({
|
||||
}
|
||||
unsubscribeSystemResumed?.()
|
||||
}
|
||||
}, [isActiveRef, isVisible, isVisibleRef, managerRef])
|
||||
}, [isActiveRef, isVisible, isVisibleRef, managerRef, panePtyBindingsRef])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import {
|
||||
execDockerSshRelayTargetCommand,
|
||||
type DockerSshRelayTarget,
|
||||
writeDockerSshRelayTargetFile
|
||||
} from './helpers/docker-ssh-relay-target'
|
||||
|
||||
export const REMOTE_MONITOR_PATH = '/tmp/orca-idle-grid-monitor.mjs'
|
||||
export const REMOTE_STATE_PATH = '/tmp/orca-idle-grid-state.json'
|
||||
|
||||
export type Grid = { cols: number; rows: number }
|
||||
|
||||
export type RemoteGridState = Grid & {
|
||||
draws: number
|
||||
pid: number
|
||||
updatedAt: number
|
||||
winches: number
|
||||
}
|
||||
|
||||
export type RendererGridState = {
|
||||
applied: Grid | null
|
||||
xterm: Grid | null
|
||||
}
|
||||
|
||||
export type ReproSample = {
|
||||
cycle: number
|
||||
elapsedMs: number
|
||||
remote: RemoteGridState
|
||||
renderer: RendererGridState
|
||||
}
|
||||
|
||||
function idleGridMonitorScript(): string {
|
||||
return `
|
||||
import fs from 'node:fs'
|
||||
|
||||
const statePath = process.argv[2]
|
||||
let draws = 0
|
||||
let winches = 0
|
||||
let previousRow = null
|
||||
|
||||
function readGrid() {
|
||||
const [cols, rows] = process.stdout.getWindowSize()
|
||||
return { cols, rows }
|
||||
}
|
||||
|
||||
function persist() {
|
||||
const grid = readGrid()
|
||||
const state = { ...grid, draws, pid: process.pid, updatedAt: Date.now(), winches }
|
||||
const temporaryPath = statePath + '.' + process.pid + '.tmp'
|
||||
fs.writeFileSync(temporaryPath, JSON.stringify(state))
|
||||
fs.renameSync(temporaryPath, statePath)
|
||||
return grid
|
||||
}
|
||||
|
||||
function draw() {
|
||||
draws += 1
|
||||
const { cols, rows } = persist()
|
||||
const text = ('REMOTE_BOTTOM_BAR rows=' + rows + ' cols=' + cols + ' ' + '='.repeat(240)).slice(0, Math.max(1, cols - 1))
|
||||
const clearPrevious = previousRow && previousRow !== rows ? '\\x1b[' + previousRow + ';1H\\x1b[2K' : ''
|
||||
process.stdout.write('\\x1b7' + clearPrevious + '\\x1b[' + rows + ';1H\\x1b[2K' + text + '\\x1b8')
|
||||
previousRow = rows
|
||||
}
|
||||
|
||||
process.on('SIGWINCH', () => {
|
||||
winches += 1
|
||||
draw()
|
||||
})
|
||||
|
||||
draw()
|
||||
setInterval(persist, 25)
|
||||
setTimeout(() => process.exit(0), 600000)
|
||||
`
|
||||
}
|
||||
|
||||
export function installIdleGridMonitor(target: DockerSshRelayTarget): void {
|
||||
writeDockerSshRelayTargetFile(target, REMOTE_MONITOR_PATH, idleGridMonitorScript())
|
||||
}
|
||||
|
||||
export function readRemoteGrid(target: DockerSshRelayTarget): RemoteGridState {
|
||||
const json = execDockerSshRelayTargetCommand(target, `cat ${REMOTE_STATE_PATH}`)
|
||||
return JSON.parse(json) as RemoteGridState
|
||||
}
|
||||
|
||||
export async function readRendererGrid(page: Page, ptyId: string): Promise<RendererGridState> {
|
||||
return page.evaluate(async (id) => {
|
||||
let xterm: Grid | null = null
|
||||
for (const manager of window.__paneManagers?.values() ?? []) {
|
||||
for (const pane of manager.getPanes?.() ?? []) {
|
||||
if (pane.container?.dataset?.ptyId === id) {
|
||||
xterm = { cols: pane.terminal.cols, rows: pane.terminal.rows }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
applied: (await window.api.pty.getSize(id)) ?? null,
|
||||
xterm
|
||||
}
|
||||
}, ptyId)
|
||||
}
|
||||
|
||||
export function actualGridMatchesXterm(
|
||||
remote: RemoteGridState,
|
||||
renderer: RendererGridState
|
||||
): boolean {
|
||||
return (
|
||||
renderer.xterm !== null &&
|
||||
renderer.xterm.cols > 0 &&
|
||||
renderer.xterm.rows > 0 &&
|
||||
remote.cols === renderer.xterm.cols &&
|
||||
remote.rows === renderer.xterm.rows
|
||||
)
|
||||
}
|
||||
|
||||
export async function sampleRemoteConvergence(args: {
|
||||
cycle: number
|
||||
page: Page
|
||||
ptyId: string
|
||||
target: DockerSshRelayTarget
|
||||
timeoutMs?: number
|
||||
}): Promise<{ last: ReproSample; stale: ReproSample[] }> {
|
||||
const startedAt = Date.now()
|
||||
const stale: ReproSample[] = []
|
||||
let last: ReproSample | null = null
|
||||
while (Date.now() - startedAt < (args.timeoutMs ?? 6_000)) {
|
||||
const remote = readRemoteGrid(args.target)
|
||||
const renderer = await readRendererGrid(args.page, args.ptyId)
|
||||
last = {
|
||||
cycle: args.cycle,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
remote,
|
||||
renderer
|
||||
}
|
||||
if (actualGridMatchesXterm(remote, renderer)) {
|
||||
return { last, stale }
|
||||
}
|
||||
stale.push(last)
|
||||
await args.page.waitForTimeout(100)
|
||||
}
|
||||
if (!last) {
|
||||
throw new Error('Remote grid convergence sampling produced no samples')
|
||||
}
|
||||
return { last, stale }
|
||||
}
|
||||
|
||||
export async function attachStaleGridEvidence(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
label: string,
|
||||
samples: ReproSample[]
|
||||
): Promise<void> {
|
||||
await testInfo.attach(`${label}.json`, {
|
||||
body: Buffer.from(JSON.stringify(samples, null, 2)),
|
||||
contentType: 'application/json'
|
||||
})
|
||||
await testInfo.attach(`${label}.png`, {
|
||||
body: await page.screenshot({ fullPage: true }),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
|
||||
import {
|
||||
cleanupDockerSshRelayTarget,
|
||||
DOCKER_SSH_RELAY_REMOTE_REPO_PATH,
|
||||
execDockerSshRelayTargetCommand,
|
||||
startDockerSshRelayTarget,
|
||||
type DockerSshRelayTarget
|
||||
} from './helpers/docker-ssh-relay-target'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
execInTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import {
|
||||
actualGridMatchesXterm,
|
||||
attachStaleGridEvidence,
|
||||
installIdleGridMonitor,
|
||||
readRemoteGrid,
|
||||
readRendererGrid,
|
||||
REMOTE_MONITOR_PATH,
|
||||
REMOTE_STATE_PATH,
|
||||
sampleRemoteConvergence,
|
||||
type Grid
|
||||
} from './ssh-terminal-stale-grid-probe'
|
||||
|
||||
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
|
||||
const BASE_VIEWPORT = { width: 1160, height: 760 }
|
||||
|
||||
async function startRemoteMonitor(page: Page, ptyId: string): Promise<void> {
|
||||
const marker = `ORCA_SSH_WAKE_READY_${Date.now()}`
|
||||
await execInTerminal(page, ptyId, `printf '${marker}\\n'`)
|
||||
await waitForTerminalOutput(page, marker, 20_000, 60_000)
|
||||
await execInTerminal(page, ptyId, `node ${REMOTE_MONITOR_PATH} ${REMOTE_STATE_PATH}`)
|
||||
}
|
||||
|
||||
function chooseStaleGrid(current: Grid): Grid {
|
||||
return {
|
||||
cols: Math.max(40, current.cols - 19),
|
||||
rows: Math.max(12, current.rows - 7)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('SSH terminal window-wake stale PTY grid repro', () => {
|
||||
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH repro.')
|
||||
test.skip(process.platform === 'win32', 'Docker SSH repro uses POSIX SSH tooling.')
|
||||
|
||||
test('window focus heals a remote PTY whose applied grid drifted from xterm', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.setTimeout(240_000)
|
||||
let target: DockerSshRelayTarget | null = null
|
||||
try {
|
||||
target = startDockerSshRelayTarget(testInfo)
|
||||
const pageErrors: string[] = []
|
||||
orcaPage.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
installIdleGridMonitor(target)
|
||||
await orcaPage.setViewportSize(BASE_VIEWPORT)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const identity = await orcaPage.evaluate(() => window.api.app.getIdentity())
|
||||
expect(identity.isDev).toBe(true)
|
||||
expect(identity.devWorktreeName).toBe(path.basename(process.cwd()))
|
||||
|
||||
await connectDockerSshRelayTarget(orcaPage, target, { relayGracePeriodSeconds: 300 })
|
||||
await ensureTerminalVisible(orcaPage, 60_000)
|
||||
await waitForActiveTerminalManager(orcaPage, 60_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
|
||||
await startRemoteMonitor(orcaPage, ptyId)
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
try {
|
||||
return readRemoteGrid(target!).pid > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000, message: 'Idle remote grid monitor did not start' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
actualGridMatchesXterm(
|
||||
readRemoteGrid(target!),
|
||||
await readRendererGrid(orcaPage, ptyId)
|
||||
),
|
||||
{ timeout: 15_000, message: 'Remote PTY and xterm did not establish a matching baseline' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const baseline = await readRendererGrid(orcaPage, ptyId)
|
||||
if (!baseline.xterm) {
|
||||
throw new Error('Active xterm grid unavailable')
|
||||
}
|
||||
const staleGrid = chooseStaleGrid(baseline.xterm)
|
||||
await orcaPage.evaluate(({ id, grid }) => window.api.pty.resize(id, grid.cols, grid.rows), {
|
||||
id: ptyId,
|
||||
grid: staleGrid
|
||||
})
|
||||
await expect.poll(() => readRemoteGrid(target!).cols, { timeout: 5_000 }).toBe(staleGrid.cols)
|
||||
await expect.poll(() => readRemoteGrid(target!).rows, { timeout: 5_000 }).toBe(staleGrid.rows)
|
||||
|
||||
const drifted = await readRendererGrid(orcaPage, ptyId)
|
||||
expect(drifted.xterm).toEqual(baseline.xterm)
|
||||
expect(drifted.applied).toEqual(staleGrid)
|
||||
|
||||
await orcaPage.evaluate(() => window.dispatchEvent(new Event('focus')))
|
||||
const wakeResult = await sampleRemoteConvergence({
|
||||
cycle: 0,
|
||||
page: orcaPage,
|
||||
ptyId,
|
||||
target,
|
||||
timeoutMs: 3_000
|
||||
})
|
||||
|
||||
if (!actualGridMatchesXterm(wakeResult.last.remote, wakeResult.last.renderer)) {
|
||||
await attachStaleGridEvidence(
|
||||
orcaPage,
|
||||
testInfo,
|
||||
'ssh-window-focus-stale-grid',
|
||||
wakeResult.stale
|
||||
)
|
||||
// Manual resize is the field workaround and proves the remote channel
|
||||
// can still deliver the corrective SIGWINCH.
|
||||
await orcaPage.setViewportSize({
|
||||
width: BASE_VIEWPORT.width + 24,
|
||||
height: BASE_VIEWPORT.height + 24
|
||||
})
|
||||
const manualResize = await sampleRemoteConvergence({
|
||||
cycle: 1,
|
||||
page: orcaPage,
|
||||
ptyId,
|
||||
target,
|
||||
timeoutMs: 6_000
|
||||
})
|
||||
expect(actualGridMatchesXterm(manualResize.last.remote, manualResize.last.renderer)).toBe(
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
expect(
|
||||
actualGridMatchesXterm(wakeResult.last.remote, wakeResult.last.renderer),
|
||||
`Window-focus recovery left the Linux PTY stale: ${JSON.stringify(wakeResult.last)}`
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
|
||||
const remoteGitStatus = execDockerSshRelayTargetCommand(
|
||||
target,
|
||||
`git -C ${DOCKER_SSH_RELAY_REMOTE_REPO_PATH} status --short --branch`
|
||||
)
|
||||
const remoteWorktrees = execDockerSshRelayTargetCommand(
|
||||
target,
|
||||
`git -C ${DOCKER_SSH_RELAY_REMOTE_REPO_PATH} worktree list --porcelain`
|
||||
)
|
||||
expect(remoteGitStatus).toContain('## master')
|
||||
expect(remoteWorktrees).toContain(DOCKER_SSH_RELAY_REMOTE_REPO_PATH)
|
||||
expect(
|
||||
execDockerSshRelayTargetCommand(
|
||||
target,
|
||||
`kill -0 ${wakeResult.last.remote.pid} && printf alive`
|
||||
)
|
||||
).toBe('alive')
|
||||
|
||||
const evidence = {
|
||||
identity,
|
||||
target: { containerName: target.containerName, port: target.port },
|
||||
baseline,
|
||||
injectedRemoteGrid: staleGrid,
|
||||
recovered: wakeResult.last,
|
||||
remoteGitStatus,
|
||||
remoteWorktrees
|
||||
}
|
||||
console.log(`[ssh-window-wake-stale-grid] ${JSON.stringify(evidence)}`)
|
||||
testInfo.annotations.push({
|
||||
type: 'ssh-window-wake-stale-grid',
|
||||
description: JSON.stringify(evidence)
|
||||
})
|
||||
const healedScreenshot = testInfo.outputPath('ssh-window-focus-healed.png')
|
||||
await orcaPage.screenshot({ path: healedScreenshot, fullPage: true })
|
||||
await testInfo.attach('ssh-window-focus-healed.png', {
|
||||
path: healedScreenshot,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
} finally {
|
||||
cleanupDockerSshRelayTarget(target)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal'
|
||||
import { waitForPtyShellEcho } from './terminal-pty-readiness'
|
||||
|
||||
type Grid = { cols: number; rows: number }
|
||||
type GridSnapshot = { applied: Grid | null; xterm: Grid | null }
|
||||
|
||||
async function readGridSnapshot(page: Page, ptyId: string): Promise<GridSnapshot> {
|
||||
return page.evaluate(async (id) => {
|
||||
let xterm: Grid | null = null
|
||||
for (const manager of window.__paneManagers?.values() ?? []) {
|
||||
for (const pane of manager.getPanes?.() ?? []) {
|
||||
if (pane.container?.dataset?.ptyId === id) {
|
||||
xterm = { cols: pane.terminal.cols, rows: pane.terminal.rows }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { applied: (await window.api.pty.getSize(id)) ?? null, xterm }
|
||||
}, ptyId)
|
||||
}
|
||||
|
||||
function chooseStaleGrid(current: Grid): Grid {
|
||||
return {
|
||||
cols: Math.max(40, current.cols - 19),
|
||||
rows: Math.max(12, current.rows - 7)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('terminal window-wake stale grid repro', () => {
|
||||
test('window focus heals a local PTY whose applied grid drifted from xterm', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await waitForPtyShellEcho(orcaPage, ptyId, 15_000)
|
||||
|
||||
const baseline = await readGridSnapshot(orcaPage, ptyId)
|
||||
expect(baseline.xterm).not.toBeNull()
|
||||
expect(baseline.applied).toEqual(baseline.xterm)
|
||||
const staleGrid = chooseStaleGrid(baseline.xterm!)
|
||||
|
||||
// Why: model the field state directly—xterm is fitted, but the idle PTY
|
||||
// still has an older grid and produces no output that could self-heal it.
|
||||
await orcaPage.evaluate(({ id, grid }) => window.api.pty.resize(id, grid.cols, grid.rows), {
|
||||
id: ptyId,
|
||||
grid: staleGrid
|
||||
})
|
||||
await expect
|
||||
.poll(async () => (await readGridSnapshot(orcaPage, ptyId)).applied, { timeout: 10_000 })
|
||||
.toEqual(staleGrid)
|
||||
expect((await readGridSnapshot(orcaPage, ptyId)).xterm).toEqual(baseline.xterm)
|
||||
|
||||
await orcaPage.evaluate(() => window.dispatchEvent(new Event('focus')))
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await readGridSnapshot(orcaPage, ptyId)
|
||||
return snapshot.applied && snapshot.xterm ? snapshot : null
|
||||
},
|
||||
{ timeout: 10_000, message: 'Window focus should converge the local PTY to xterm' }
|
||||
)
|
||||
.toEqual({ applied: baseline.xterm, xterm: baseline.xterm })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user