mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(terminal): serialize desktop viewport ownership
This commit is contained in:
+102
-12
@@ -278,7 +278,7 @@
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 20.6,
|
||||
"durationSeconds": 21.3,
|
||||
"summary": "After a fresh build exposed an unrelated mirrored terminal entering the broad backing-state diagnostic, the exact built candidate passed with that oracle scoped to the four seeded browser/simulator tabs; rendered active-tab and backing execution-host assertions remained green."
|
||||
}
|
||||
],
|
||||
@@ -5526,8 +5526,8 @@
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": [],
|
||||
"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.",
|
||||
"coveredProviders": ["remote-runtime"],
|
||||
"coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Deterministic provider-contract coverage includes settled window-wake reassertion and SSH relay applied-size readback. A headed host plus two paired desktop clients now covers remote-runtime viewport ownership and a capability-less legacy client. Live shell-visible SSH geometry and Windows ConPTY readback remain non-blocking gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6644",
|
||||
"https://github.com/stablyai/orca/pull/6649",
|
||||
@@ -5537,20 +5537,31 @@
|
||||
"https://github.com/stablyai/orca/pull/6939",
|
||||
"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, 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.",
|
||||
"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. Across desktops, the most recently active visible owner controls geometry: passive attach, unfocused layout, and automatic terminal protocol traffic cannot transfer ownership; input claims before bytes; detach and failed reclaim preserve a deterministic restoration target.",
|
||||
"oracle": "The executable slices force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, window wake, delayed fit publication, adjacent focus reports, two competing desktop viewers, failed reclaim, stream detach, and capability-less legacy traffic. They assert usable renderer geometry, applied provider size, ordered runtime mirrors, atomic automatic-input classification, passive non-ownership, input-before-bytes transfer, host reclaim, detach fallback, and intentional legacy resize-as-activity. 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/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-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler-output-streaming.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-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler-output-streaming.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/runtime/remote-desktop-driver.test.ts src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts",
|
||||
"SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'current clients'",
|
||||
"SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'host input reclaims'",
|
||||
"SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'legacy clients'"
|
||||
],
|
||||
"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/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts",
|
||||
"src/main/ipc/pty-applied-size-reporting.test.ts",
|
||||
"src/main/providers/ssh-pty-provider.test.ts",
|
||||
"src/main/runtime/remote-desktop-driver.test.ts",
|
||||
"src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts",
|
||||
"src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/relay/pty-handler-output-streaming.test.ts"
|
||||
"src/relay/pty-handler-output-streaming.test.ts",
|
||||
"tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -5588,9 +5599,38 @@
|
||||
"assertions": [
|
||||
"accepted desktop resizes fan out to the runtime after provider resize",
|
||||
"rejected desktop resizes do not fan out to the runtime",
|
||||
"automatic terminal query replies do not reclaim host viewport ownership",
|
||||
"single and transport-batched xterm focus reports do not reclaim host viewport ownership",
|
||||
"provider-owned null remains unverified instead of falling back to requested size"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts",
|
||||
"assertions": [
|
||||
"an acknowledged reclaim rejection does not arm parser probes or remount a live bound pane"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/remote-desktop-driver.test.ts",
|
||||
"assertions": [
|
||||
"failed host restoration retains remote geometry suppression until input retry succeeds",
|
||||
"host input restores cached host geometry before bytes",
|
||||
"current-owner input applies geometry recorded by an earlier passive relayout"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts",
|
||||
"assertions": [
|
||||
"multiplex and single desktop input revalidate runtime ownership before bytes",
|
||||
"terminal query replies and transport-batched xterm focus reports do not transfer desktop viewport ownership"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts",
|
||||
"assertions": [
|
||||
"slot and connection cleanup drop queued current and legacy viewport work before restoration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/ssh-pty-provider.test.ts",
|
||||
"assertions": [
|
||||
@@ -5611,6 +5651,18 @@
|
||||
"relay readback reports the grid actually applied by node-pty",
|
||||
"missing relay PTYs return an unverified null size"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts",
|
||||
"assertions": [
|
||||
"passive attach and unfocused layout publish no desktop viewport claim",
|
||||
"passive terminal preview subscription does not resize or publish a claim",
|
||||
"focused input claims before bytes while fit state is delayed",
|
||||
"host input reclaims cached host geometry before bytes while renderer fit state is delayed",
|
||||
"host input and later desktop input transfer ownership in order",
|
||||
"desktop detach falls back to the remaining active viewer, then the host",
|
||||
"capability-less legacy clients retain resize-as-activity behavior"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -5620,8 +5672,44 @@
|
||||
"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/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-applied-size-reporting.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler-output-streaming.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 18.54,
|
||||
"summary": "8 test files passed, 1,272 tests passed on the PR branch."
|
||||
"durationSeconds": 11.49,
|
||||
"summary": "8 test files passed, 1,282 tests passed and 1 skipped on the PR branch. One prior combined run timed out in an unrelated legacy-worker orchestration fixture; the exact test passed alone in 84 ms and this rerun passed."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-connection-terminal-input-gating.test.ts src/renderer/src/components/terminal-pane/remote-desktop-viewport-claim.test.ts src/main/ipc/pty-applied-size-reporting.test.ts src/main/runtime/remote-desktop-driver.test.ts src/main/runtime/rpc/terminal-desktop-input-claim-serialization.test.ts src/main/runtime/rpc/terminal-desktop-stream-detach-serialization.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4.6,
|
||||
"summary": "7 focused ownership-boundary files passed, 109 tests passed."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'current clients'",
|
||||
"result": "passed",
|
||||
"durationSeconds": 22.7,
|
||||
"summary": "Current-client ownership passed twice consecutively with a headed host and two real paired Electron clients after the payload-aware wire oracle excluded pure focus reports."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'host input reclaims'",
|
||||
"result": "passed",
|
||||
"durationSeconds": 20.2,
|
||||
"summary": "Host input reclaimed cached host geometry before bytes while host fit events and renderer viewport claims were suppressed."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-terminal-viewport-ownership-audit.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 -g 'legacy clients'",
|
||||
"result": "passed",
|
||||
"durationSeconds": 20.2,
|
||||
"summary": "The capability-less legacy arm passed in a separate fresh Playwright process."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -5630,11 +5718,11 @@
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Deterministic Vitest slices passed locally; no CI soak history yet."
|
||||
"evidence": "Deterministic Vitest slices and all three paired Electron arms passed locally. A wire assertion initially counted a pure focus-report Input as activity; recording raw Input payloads made the oracle semantic, after which current/current passed twice consecutively. Repeated Playwright processes also exposed an unrelated seeded-worktree loader failure before the test body; no CI soak history yet."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "#7192 proved the mirror slice red before its fix (snapshot stayed 80x24, queued write parsed at the wrong width). The renderer 0x0/settle slices do not have recorded red runs."
|
||||
"evidence": "#7192 proved the mirror slice red before its fix (snapshot stayed 80x24, queued write parsed at the wrong width). STA-5050's paired oracle failed with [14,8] for unfocused generic resize when that guard was disabled, failed with input [7] at the prior owner's 57x41 grid instead of [14,8,7] at 20x26 when pre-fit claiming was disabled, and put HOST_PRE_FIT_INPUT bytes at the remote 20x26 grid instead of cached host 107x52 when host reclaim was disabled. Disabling runtime input revalidation failed both multiplex and single-stream tests; disabling query-reply classification or the focus-report predicate invoked ownership reclaim; exact-only focus classification failed on a transport-batched ESC[O ESC[I payload; disabling retained-target suppression exposed the failed restoration to layout resize; disabling stream-liveness gates failed all four detach cases. The renderer 0x0/settle slices do not have recorded red runs."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -5648,7 +5736,9 @@
|
||||
"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."
|
||||
"A passive current-owner relayout can temporarily leave xterm and PTY geometry divergent until the next genuine input revalidates and applies the recorded geometry; no bytes run at the stale geometry.",
|
||||
"If provider resize persistently fails, genuine input is rejected until ownership geometry can be established; delivering or indefinitely queueing those bytes would violate the ordering invariant or risk delayed interactive input.",
|
||||
"The Vitest command uses deterministic fake providers; the paired macOS Electron arm covers a real remote-runtime PTY."
|
||||
],
|
||||
"demotionRule": "Cannot promote without deterministic oracle and runtime history."
|
||||
},
|
||||
|
||||
@@ -278,6 +278,95 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
it('retries failed implicit reclaim before later host input', async () => {
|
||||
const write = setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 } })
|
||||
const retry = Promise.withResolvers<boolean>()
|
||||
const claimRemoteDesktopHostForInput = vi
|
||||
.fn<() => Promise<boolean> | null>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockReturnValueOnce(retry.promise)
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'idle' })),
|
||||
claimRemoteDesktopHostForInput,
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
|
||||
const id = (spawn as { id: string }).id
|
||||
const writeEvent = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:write')
|
||||
|
||||
writeEvent?.[1](mainWindowIpcEvent, { id, data: 'first' })
|
||||
expect(claimRemoteDesktopHostForInput).toHaveBeenCalledWith(id)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
writeEvent?.[1](mainWindowIpcEvent, { id, data: 'second' })
|
||||
expect(claimRemoteDesktopHostForInput).toHaveBeenCalledTimes(2)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
|
||||
retry.resolve(true)
|
||||
await vi.waitFor(() => expect(write).toHaveBeenCalledWith(id, 'second'))
|
||||
expect(write).not.toHaveBeenCalledWith(id, 'first')
|
||||
})
|
||||
it('writes terminal query replies without reclaiming host viewport ownership', async () => {
|
||||
const write = setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 } })
|
||||
const claimRemoteDesktopHostForInput = vi.fn().mockResolvedValue(true)
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'idle' })),
|
||||
claimRemoteDesktopHostForInput,
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
|
||||
const id = (spawn as { id: string }).id
|
||||
const writeEvent = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:write')
|
||||
|
||||
writeEvent?.[1](mainWindowIpcEvent, { id, data: '\u001b[3;1R', inputKind: 'query-reply' })
|
||||
|
||||
expect(claimRemoteDesktopHostForInput).not.toHaveBeenCalled()
|
||||
expect(write).toHaveBeenCalledWith(id, '\u001b[3;1R')
|
||||
})
|
||||
it('writes terminal focus reports without reclaiming host viewport ownership', async () => {
|
||||
const write = setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 } })
|
||||
const claimRemoteDesktopHostForInput = vi.fn().mockResolvedValue(true)
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'idle' })),
|
||||
claimRemoteDesktopHostForInput,
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const spawn = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} })
|
||||
const id = (spawn as { id: string }).id
|
||||
const writeEvent = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:write')
|
||||
|
||||
writeEvent?.[1](mainWindowIpcEvent, { id, data: '\u001b[I' })
|
||||
writeEvent?.[1](mainWindowIpcEvent, { id, data: '\u001b[O' })
|
||||
writeEvent?.[1](mainWindowIpcEvent, { id, data: '\u001b[O\u001b[I' })
|
||||
|
||||
expect(claimRemoteDesktopHostForInput).not.toHaveBeenCalled()
|
||||
expect(write).toHaveBeenNthCalledWith(1, id, '\u001b[I')
|
||||
expect(write).toHaveBeenNthCalledWith(2, id, '\u001b[O')
|
||||
expect(write).toHaveBeenNthCalledWith(3, id, '\u001b[O\u001b[I')
|
||||
})
|
||||
it('does not populate the remote reclaim cache when only a phone drives', async () => {
|
||||
const resizeSpy = vi.fn()
|
||||
setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 }, resize: resizeSpy })
|
||||
|
||||
@@ -271,6 +271,7 @@ describe('registerPtyHandlers', () => {
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
claimRemoteDesktopHostForInput: vi.fn(() => null),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -385,6 +386,7 @@ describe('registerPtyHandlers', () => {
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
claimRemoteDesktopHostForInput: vi.fn(() => null),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
@@ -481,6 +483,7 @@ describe('registerPtyHandlers', () => {
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
claimRemoteDesktopHostForInput: vi.fn(() => null),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
|
||||
@@ -121,6 +121,7 @@ describe('registerPtyHandlers', () => {
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
getDriver: vi.fn(() => ({ kind: 'host' })),
|
||||
claimRemoteDesktopHostForInput: vi.fn(() => null),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
|
||||
+41
-12
@@ -26,6 +26,7 @@ import type {
|
||||
PtyRendererDeliveryStateReport
|
||||
} from '../../shared/pty-renderer-delivery-health'
|
||||
import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction'
|
||||
import { isTerminalFocusReport } from '../../shared/terminal-focus-report'
|
||||
import {
|
||||
INITIAL_MODE_2031_REPLY_SCAN_STATE,
|
||||
scanMode2031ReplyDecision,
|
||||
@@ -7305,7 +7306,7 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
type PtyWritePayload = { id: string; data: string }
|
||||
type PtyWritePayload = { id: string; data: string; inputKind?: 'query-reply' }
|
||||
type PtyViewportClaimPayload = { id: string; cols: number; rows: number }
|
||||
|
||||
const isPtyWritePayload = (value: unknown): value is PtyWritePayload =>
|
||||
@@ -7313,7 +7314,9 @@ export function registerPtyHandlers(
|
||||
value !== null &&
|
||||
typeof (value as { id?: unknown }).id === 'string' &&
|
||||
(value as { id: string }).id.length > 0 &&
|
||||
typeof (value as { data?: unknown }).data === 'string'
|
||||
typeof (value as { data?: unknown }).data === 'string' &&
|
||||
((value as { inputKind?: unknown }).inputKind === undefined ||
|
||||
(value as { inputKind?: unknown }).inputKind === 'query-reply')
|
||||
|
||||
const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload =>
|
||||
typeof value === 'object' &&
|
||||
@@ -7388,6 +7391,37 @@ export function registerPtyHandlers(
|
||||
|
||||
const hostViewportClaimTails = new Map<string, Promise<boolean>>()
|
||||
|
||||
const trackHostViewportClaim = (id: string, claim: Promise<boolean>): Promise<boolean> => {
|
||||
const tracked = claim.catch(() => false)
|
||||
hostViewportClaimTails.set(id, tracked)
|
||||
void tracked.then(() => {
|
||||
if (hostViewportClaimTails.get(id) === tracked) {
|
||||
hostViewportClaimTails.delete(id)
|
||||
}
|
||||
})
|
||||
return tracked
|
||||
}
|
||||
|
||||
const getHostInputViewportClaim = (id: string): Promise<boolean> | null => {
|
||||
const pending = hostViewportClaimTails.get(id)
|
||||
if (pending || runtime?.getDriver(id).kind === 'mobile') {
|
||||
return pending ?? null
|
||||
}
|
||||
// Runtime owns both active viewers and retained failed-reclaim targets.
|
||||
const claim = runtime?.claimRemoteDesktopHostForInput(id) ?? null
|
||||
return claim ? trackHostViewportClaim(id, claim) : null
|
||||
}
|
||||
|
||||
const getHostWriteViewportClaim = (args: PtyWritePayload): Promise<boolean> | null => {
|
||||
if (args.inputKind === 'query-reply') {
|
||||
return null
|
||||
}
|
||||
if (isTerminalFocusReport(args.data)) {
|
||||
return hostViewportClaimTails.get(args.id) ?? null
|
||||
}
|
||||
return getHostInputViewportClaim(args.id)
|
||||
}
|
||||
|
||||
ipc.on('pty:write', (event, args: unknown) => {
|
||||
if (
|
||||
!isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) ||
|
||||
@@ -7395,7 +7429,7 @@ export function registerPtyHandlers(
|
||||
) {
|
||||
return
|
||||
}
|
||||
const claimTail = hostViewportClaimTails.get(args.id)
|
||||
const claimTail = getHostWriteViewportClaim(args)
|
||||
if (claimTail) {
|
||||
void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false))
|
||||
return
|
||||
@@ -7409,7 +7443,7 @@ export function registerPtyHandlers(
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const claimTail = hostViewportClaimTails.get(args.id)
|
||||
const claimTail = getHostWriteViewportClaim(args)
|
||||
return claimTail
|
||||
? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false))
|
||||
: writePtyInputAccepted(args)
|
||||
@@ -7426,20 +7460,15 @@ export function registerPtyHandlers(
|
||||
}
|
||||
const prior = hostViewportClaimTails.get(args.id)
|
||||
// Why: two panes can mirror one PTY — never let a later no-op claim replace the in-flight resize that the following host input must await.
|
||||
const claim = (
|
||||
trackHostViewportClaim(
|
||||
args.id,
|
||||
prior
|
||||
? prior.then(
|
||||
() => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows),
|
||||
() => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows)
|
||||
)
|
||||
: runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows)
|
||||
).catch(() => false)
|
||||
hostViewportClaimTails.set(args.id, claim)
|
||||
void claim.then(() => {
|
||||
if (hostViewportClaimTails.get(args.id) === claim) {
|
||||
hostViewportClaimTails.delete(args.id)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Why: resize is fire-and-forget — ipc.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply.
|
||||
|
||||
@@ -15450,7 +15450,7 @@ export class OrcaRuntimeService {
|
||||
// Why: the host's own fit cascade (window resize, split drag, tab reveal,
|
||||
// "+"-new-tab re-render) must not resize a PTY whose width a remote client
|
||||
// owns — that is the remote "porridge" bug. True while a phone (mobile driver)
|
||||
// OR an active remote desktop viewer owns the PTY. Input is deliberately NOT gated
|
||||
// OR remote desktop ownership/reclaim still reserves the PTY. Input is deliberately NOT gated
|
||||
// here (see the `writePtyInput` mobile-only checks): shared-control desktop
|
||||
// viewers may still type alongside the host.
|
||||
// Note: this is intentionally NOT a driver kind. An active remote viewer needs
|
||||
@@ -15467,7 +15467,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
isRemoteDesktopResizeDriven(ptyId: string): boolean {
|
||||
return this.remoteDesktopOwners.has(ptyId)
|
||||
return this.hasRemoteDesktopLayoutState(ptyId)
|
||||
}
|
||||
|
||||
isRemoteDesktopViewerOwner(ptyId: string, subscriptionKey: string): boolean {
|
||||
@@ -15652,6 +15652,14 @@ export class OrcaRuntimeService {
|
||||
return this.applyRemoteDesktopLayout(ptyId)
|
||||
}
|
||||
|
||||
claimRemoteDesktopHostForInput(ptyId: string): Promise<boolean> | null {
|
||||
if (!this.hasRemoteDesktopLayoutState(ptyId)) {
|
||||
return null
|
||||
}
|
||||
const target = this.resolveRemoteDesktopHostReclaimTarget(ptyId)
|
||||
return this.claimRemoteDesktopHost(ptyId, target.cols, target.rows)
|
||||
}
|
||||
|
||||
unregisterRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise<boolean> {
|
||||
return this.unregisterRemoteDesktopViewers(ptyId, [subscriptionKey])
|
||||
}
|
||||
|
||||
@@ -241,6 +241,32 @@ describe('remote desktop viewer width driver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reclaims from the cached host grid when input precedes renderer fit state', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
|
||||
|
||||
await runtime.claimRemoteDesktopHostForInput('pty-1')
|
||||
|
||||
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(runtime.claimRemoteDesktopHostForInput('pty-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('retries a retained host target after input reclaim fails', async () => {
|
||||
const { runtime, setResizeSucceeds } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
|
||||
setResizeSucceeds(false)
|
||||
|
||||
await expect(runtime.claimRemoteDesktopHostForInput('pty-1')).resolves.toBe(false)
|
||||
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(true)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 })
|
||||
|
||||
setResizeSucceeds(true)
|
||||
await expect(runtime.claimRemoteDesktopHostForInput('pty-1')).resolves.toBe(true)
|
||||
expect(runtime.isPtyResizeDrivenRemotely('pty-1')).toBe(false)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
})
|
||||
|
||||
it('does not emit resize churn for repeated activity from the current owner', async () => {
|
||||
const { runtime, resizeCalls, fitOverrideEvents } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
|
||||
@@ -253,6 +279,17 @@ describe('remote desktop viewer width driver', () => {
|
||||
expect(fitOverrideEvents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('applies passively recorded owner geometry before later input', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
|
||||
|
||||
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30, false)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(runtime.claimRemoteDesktopViewer('pty-1', 'sub-A')).resolves.toBe(true)
|
||||
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 100, rows: 30 })
|
||||
})
|
||||
|
||||
it('reclaims the host width when the last viewer detaches', async () => {
|
||||
const { runtime } = createRuntime()
|
||||
// The viewer drives the source PTY to its own 80-wide viewport.
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '../terminal-stream-byte-length'
|
||||
import { isTuiAgent } from '../../../../shared/tui-agent-config'
|
||||
import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply'
|
||||
import { isTerminalFocusReport } from '../../../../shared/terminal-focus-report'
|
||||
import {
|
||||
EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE,
|
||||
scanTerminalReplyQuerySequences,
|
||||
@@ -864,6 +865,25 @@ async function updateViewportForClient(
|
||||
return { updated, applied: updated }
|
||||
}
|
||||
|
||||
function enqueueRemoteDesktopInputClaim(
|
||||
runtime: OrcaRuntimeService,
|
||||
ptyId: string,
|
||||
subscriptionKey: string,
|
||||
priorClaimTail: Promise<boolean>,
|
||||
registered: boolean,
|
||||
isLive: () => boolean
|
||||
): Promise<boolean> {
|
||||
// Why: host reclaim can outrun its renderer notification; runtime ownership must be current before bytes.
|
||||
return priorClaimTail
|
||||
.then((priorClaimed) => {
|
||||
if (!isLive()) {
|
||||
return false
|
||||
}
|
||||
return registered ? runtime.claimRemoteDesktopViewer(ptyId, subscriptionKey) : priorClaimed
|
||||
})
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
const TerminalHandle = z.object({
|
||||
terminal: requiredString('Missing terminal handle')
|
||||
})
|
||||
@@ -2229,10 +2249,30 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) {
|
||||
return
|
||||
}
|
||||
// Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input.
|
||||
const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail
|
||||
const transfersDesktopOwnership =
|
||||
!stream.isMobile && !isTerminalFocusReport(text) && !isTerminalQueryReply(text)
|
||||
const inputClaimTail = stream.isMobile
|
||||
? Promise.resolve(true)
|
||||
: transfersDesktopOwnership
|
||||
? enqueueRemoteDesktopInputClaim(
|
||||
runtime,
|
||||
stream.ptyId,
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
stream.desktopClaimTail,
|
||||
stream.registeredRemoteDesktopDriver,
|
||||
() => !closed && streams.get(stream.streamId) === stream
|
||||
)
|
||||
: stream.desktopClaimTail
|
||||
if (transfersDesktopOwnership) {
|
||||
stream.desktopClaimTail = inputClaimTail
|
||||
}
|
||||
void inputClaimTail.then(async (claimed) => {
|
||||
if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) {
|
||||
if (
|
||||
closed ||
|
||||
streams.get(stream.streamId) !== stream ||
|
||||
!claimed ||
|
||||
isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const outcome = await sendTerminalStreamInput(runtime, {
|
||||
@@ -2278,6 +2318,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
stream.desktopClaimTail = stream.desktopClaimTail
|
||||
.then(async (priorClaimed) => {
|
||||
if (closed || streams.get(stream.streamId) !== stream) {
|
||||
return false
|
||||
}
|
||||
const result = await updateViewportForClient(
|
||||
runtime,
|
||||
stream.ptyId,
|
||||
@@ -2311,17 +2354,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
stream.registeredRemoteDesktopDriver = true
|
||||
stream.desktopClaimTail = stream.desktopClaimTail
|
||||
.then(
|
||||
() =>
|
||||
runtime.updateRemoteDesktopViewer(
|
||||
stream.ptyId,
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
stream.client!.id,
|
||||
cols,
|
||||
rows,
|
||||
true
|
||||
),
|
||||
() =>
|
||||
runtime.updateRemoteDesktopViewer(
|
||||
() => {
|
||||
if (closed || streams.get(stream.streamId) !== stream) {
|
||||
return false
|
||||
}
|
||||
return runtime.updateRemoteDesktopViewer(
|
||||
stream.ptyId,
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
stream.client!.id,
|
||||
@@ -2329,6 +2366,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
rows,
|
||||
true
|
||||
)
|
||||
},
|
||||
() => {
|
||||
if (closed || streams.get(stream.streamId) !== stream) {
|
||||
return false
|
||||
}
|
||||
return runtime.updateRemoteDesktopViewer(
|
||||
stream.ptyId,
|
||||
stream.remoteDesktopSubscriptionKey,
|
||||
stream.client!.id,
|
||||
cols,
|
||||
rows,
|
||||
true
|
||||
)
|
||||
}
|
||||
)
|
||||
.catch(() => false)
|
||||
return
|
||||
@@ -3256,8 +3307,27 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) {
|
||||
return
|
||||
}
|
||||
void desktopClaimTail.then(async (claimed) => {
|
||||
if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) {
|
||||
const transfersDesktopOwnership =
|
||||
!isMobile && !isTerminalFocusReport(text) && !isTerminalQueryReply(text)
|
||||
const inputClaimTail = transfersDesktopOwnership
|
||||
? enqueueRemoteDesktopInputClaim(
|
||||
runtime,
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
desktopClaimTail,
|
||||
registeredRemoteDesktopDriver,
|
||||
() => !closed
|
||||
)
|
||||
: desktopClaimTail
|
||||
if (transfersDesktopOwnership) {
|
||||
desktopClaimTail = inputClaimTail
|
||||
}
|
||||
void inputClaimTail.then(async (claimed) => {
|
||||
if (
|
||||
closed ||
|
||||
!claimed ||
|
||||
isTerminalInputLockedForClient(runtime, ptyId, params.client)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const outcome = await sendTerminalStreamInput(runtime, {
|
||||
@@ -3294,6 +3364,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
}
|
||||
desktopClaimTail = desktopClaimTail
|
||||
.then(async (priorClaimed) => {
|
||||
if (closed) {
|
||||
return false
|
||||
}
|
||||
const result = await updateViewportForClient(
|
||||
runtime,
|
||||
ptyId,
|
||||
@@ -3332,17 +3405,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
registeredRemoteDesktopDriver = true
|
||||
desktopClaimTail = desktopClaimTail
|
||||
.then(
|
||||
() =>
|
||||
runtime.updateRemoteDesktopViewer(
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
clientId,
|
||||
cols,
|
||||
rows,
|
||||
true
|
||||
),
|
||||
() =>
|
||||
runtime.updateRemoteDesktopViewer(
|
||||
() => {
|
||||
if (closed) {
|
||||
return false
|
||||
}
|
||||
return runtime.updateRemoteDesktopViewer(
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
clientId,
|
||||
@@ -3350,6 +3417,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
||||
rows,
|
||||
true
|
||||
)
|
||||
},
|
||||
() => {
|
||||
if (closed) {
|
||||
return false
|
||||
}
|
||||
return runtime.updateRemoteDesktopViewer(
|
||||
ptyId,
|
||||
remoteDesktopSubscriptionKey,
|
||||
clientId,
|
||||
cols,
|
||||
rows,
|
||||
true
|
||||
)
|
||||
}
|
||||
)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
encodeTerminalStreamFrame,
|
||||
encodeTerminalStreamText
|
||||
} from '../../../shared/terminal-stream-protocol'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { TERMINAL_METHODS } from './methods/terminal'
|
||||
import {
|
||||
makeRequest,
|
||||
sendDesktopMultiplexSubscribe,
|
||||
startDesktopMultiplexSubscribe,
|
||||
stubRuntime
|
||||
} from './terminal-multiplex-test-harness'
|
||||
import { createSubscriptionRegistryDouble } from './subscription-registry-test-double'
|
||||
|
||||
function inputFrame(streamId: number, seq = 2, text = 'input') {
|
||||
return decodeTerminalStreamFrame(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
streamId,
|
||||
seq,
|
||||
payload: encodeTerminalStreamText(text)
|
||||
})
|
||||
)!
|
||||
}
|
||||
|
||||
describe('desktop input claim serialization', () => {
|
||||
it('multiplex revalidates runtime ownership before remote input bytes', async () => {
|
||||
let owner = 'host'
|
||||
let releaseClaim = (): void => {}
|
||||
const order: string[] = []
|
||||
const claimRemoteDesktopViewer = vi.fn(
|
||||
(_ptyId: string, subscriptionKey: string) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
releaseClaim = () => {
|
||||
owner = subscriptionKey
|
||||
order.push(`claim:${subscriptionKey}`)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
)
|
||||
const sendTerminal = vi.fn().mockImplementation(async () => {
|
||||
order.push(`input:${owner}`)
|
||||
return { accepted: true }
|
||||
})
|
||||
const harness = startDesktopMultiplexSubscribe({ claimRemoteDesktopViewer, sendTerminal })
|
||||
|
||||
sendDesktopMultiplexSubscribe(harness.handlers)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
harness.messages.some((message) => JSON.parse(message).result?.type === 'subscribed')
|
||||
).toBe(true)
|
||||
)
|
||||
harness.handlers.get(7)!(inputFrame(7))
|
||||
|
||||
await vi.waitFor(() => expect(claimRemoteDesktopViewer).toHaveBeenCalledTimes(1))
|
||||
expect(sendTerminal).not.toHaveBeenCalled()
|
||||
expect(owner).toBe('host')
|
||||
|
||||
releaseClaim()
|
||||
await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1))
|
||||
expect(order).toEqual([
|
||||
'claim:multiplex:conn-desktop-first-paint:7',
|
||||
'input:multiplex:conn-desktop-first-paint:7'
|
||||
])
|
||||
|
||||
claimRemoteDesktopViewer.mockClear()
|
||||
sendTerminal.mockClear()
|
||||
harness.handlers.get(7)!(inputFrame(7, 3, '\u001b[O\u001b[I'))
|
||||
await Promise.resolve()
|
||||
expect(claimRemoteDesktopViewer).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1))
|
||||
sendTerminal.mockClear()
|
||||
harness.handlers.get(7)!(inputFrame(7, 4, '\u001b[3;1R'))
|
||||
await Promise.resolve()
|
||||
expect(claimRemoteDesktopViewer).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1))
|
||||
|
||||
harness.runtime.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint')
|
||||
await harness.dispatchPromise
|
||||
})
|
||||
|
||||
it('single stream revalidates runtime ownership before remote input bytes', async () => {
|
||||
let owner = 'host'
|
||||
let releaseClaim = (): void => {}
|
||||
const order: string[] = []
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const registry = createSubscriptionRegistryDouble()
|
||||
const claimRemoteDesktopViewer = vi.fn(
|
||||
(_ptyId: string, subscriptionKey: string) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
releaseClaim = () => {
|
||||
owner = subscriptionKey
|
||||
order.push(`claim:${subscriptionKey}`)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
)
|
||||
const sendTerminal = vi.fn().mockImplementation(async () => {
|
||||
order.push(`input:${owner}`)
|
||||
return { accepted: true }
|
||||
})
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snapshot', cols: 120, rows: 40 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup),
|
||||
cleanupSubscription: vi.fn(registry.cleanupSubscription),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
claimRemoteDesktopViewer,
|
||||
sendTerminal
|
||||
})
|
||||
const messages: string[] = []
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
capabilities: { terminalBinaryStream: 1, ackOutput: 1, desktopViewportClaims: 1 }
|
||||
}),
|
||||
(message) => messages.push(message),
|
||||
{
|
||||
connectionId: 'conn-single-input',
|
||||
sendBinary: vi.fn(),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe(
|
||||
true
|
||||
)
|
||||
)
|
||||
const [streamId, handler] = Array.from(handlers.entries())[0]!
|
||||
handler(inputFrame(streamId))
|
||||
|
||||
await vi.waitFor(() => expect(claimRemoteDesktopViewer).toHaveBeenCalledTimes(1))
|
||||
expect(sendTerminal).not.toHaveBeenCalled()
|
||||
expect(owner).toBe('host')
|
||||
|
||||
releaseClaim()
|
||||
await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1))
|
||||
expect(order).toEqual(['claim:stream:1', 'input:stream:1'])
|
||||
|
||||
claimRemoteDesktopViewer.mockClear()
|
||||
sendTerminal.mockClear()
|
||||
handler(inputFrame(streamId, 3, '\u001b[O\u001b[I'))
|
||||
await Promise.resolve()
|
||||
expect(claimRemoteDesktopViewer).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1))
|
||||
sendTerminal.mockClear()
|
||||
handler(inputFrame(streamId, 4, '\u001b[3;1R'))
|
||||
await Promise.resolve()
|
||||
expect(claimRemoteDesktopViewer).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(sendTerminal).toHaveBeenCalledTimes(1))
|
||||
|
||||
runtime.cleanupSubscription('terminal-1:desktop-1')
|
||||
await dispatchPromise
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
encodeTerminalStreamFrame,
|
||||
encodeTerminalStreamJson,
|
||||
encodeTerminalStreamText
|
||||
} from '../../../shared/terminal-stream-protocol'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { TERMINAL_METHODS } from './methods/terminal'
|
||||
import {
|
||||
makeRequest,
|
||||
sendDesktopMultiplexSubscribe,
|
||||
startDesktopMultiplexSubscribe,
|
||||
stubRuntime
|
||||
} from './terminal-multiplex-test-harness'
|
||||
import { createSubscriptionRegistryDouble } from './subscription-registry-test-double'
|
||||
|
||||
const CLIENT_CASES: {
|
||||
name: string
|
||||
capabilities: Record<string, 1>
|
||||
opcode: TerminalStreamOpcode
|
||||
}[] = [
|
||||
{
|
||||
name: 'current client claims',
|
||||
capabilities: { ackOutput: 1 as const, desktopViewportClaims: 1 as const },
|
||||
opcode: TerminalStreamOpcode.ClaimViewport
|
||||
},
|
||||
{
|
||||
name: 'legacy client resize claims',
|
||||
capabilities: { ackOutput: 1 as const },
|
||||
opcode: TerminalStreamOpcode.Resize
|
||||
}
|
||||
]
|
||||
|
||||
const MULTIPLEX_CASES = CLIENT_CASES.flatMap((client) =>
|
||||
(['slot unsubscribe', 'connection cleanup'] as const).map((detach) => ({ ...client, detach }))
|
||||
)
|
||||
|
||||
function encodedFrame(
|
||||
opcode: TerminalStreamOpcode,
|
||||
streamId: number,
|
||||
seq: number,
|
||||
payload: Uint8Array<ArrayBufferLike>
|
||||
) {
|
||||
return decodeTerminalStreamFrame(encodeTerminalStreamFrame({ opcode, streamId, seq, payload }))!
|
||||
}
|
||||
|
||||
async function flushPromiseChain(): Promise<void> {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('desktop viewport stream detach serialization', () => {
|
||||
it.each(MULTIPLEX_CASES)(
|
||||
'multiplex $detach drops queued $name and input before detach restoration',
|
||||
async ({ capabilities, detach, opcode }) => {
|
||||
let owner = 'host'
|
||||
let releaseClaim = (): void => {}
|
||||
const updateRemoteDesktopViewer = vi.fn(
|
||||
async (_ptyId: string, subscriptionKey: string): Promise<boolean> => {
|
||||
owner = subscriptionKey
|
||||
return true
|
||||
}
|
||||
)
|
||||
const unregisterRemoteDesktopViewer = vi.fn(
|
||||
async (_ptyId: string, _subscriptionKey: string): Promise<boolean> => {
|
||||
owner = 'host'
|
||||
return true
|
||||
}
|
||||
)
|
||||
const unregisterRemoteDesktopViewers = vi.fn(
|
||||
async (_ptyId: string, _subscriptionKeys: Iterable<string>): Promise<boolean> => {
|
||||
owner = 'host'
|
||||
return true
|
||||
}
|
||||
)
|
||||
const harness = startDesktopMultiplexSubscribe({
|
||||
updateRemoteDesktopViewer,
|
||||
unregisterRemoteDesktopViewer,
|
||||
unregisterRemoteDesktopViewers,
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true })
|
||||
})
|
||||
|
||||
sendDesktopMultiplexSubscribe(harness.handlers, capabilities)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
harness.messages.some((message) => JSON.parse(message).result?.type === 'subscribed')
|
||||
).toBe(true)
|
||||
)
|
||||
owner = 'host'
|
||||
updateRemoteDesktopViewer.mockImplementationOnce(
|
||||
(_ptyId: string, subscriptionKey: string) => {
|
||||
owner = subscriptionKey
|
||||
return new Promise<boolean>((resolve) => {
|
||||
releaseClaim = () => resolve(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const handler = harness.handlers.get(7)!
|
||||
handler(encodedFrame(opcode, 7, 2, encodeTerminalStreamJson({ cols: 96, rows: 32 })))
|
||||
await vi.waitFor(() => expect(updateRemoteDesktopViewer).toHaveBeenCalledTimes(2))
|
||||
handler(encodedFrame(opcode, 7, 3, encodeTerminalStreamJson({ cols: 88, rows: 28 })))
|
||||
handler(encodedFrame(TerminalStreamOpcode.Input, 7, 4, encodeTerminalStreamText('late')))
|
||||
if (detach === 'slot unsubscribe') {
|
||||
handler(encodedFrame(TerminalStreamOpcode.Unsubscribe, 7, 5, new Uint8Array()))
|
||||
} else {
|
||||
harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint')
|
||||
}
|
||||
|
||||
if (detach === 'slot unsubscribe') {
|
||||
expect(unregisterRemoteDesktopViewer).toHaveBeenCalledWith(
|
||||
'pty-1',
|
||||
'multiplex:conn-desktop-first-paint:7'
|
||||
)
|
||||
} else {
|
||||
expect(unregisterRemoteDesktopViewers).toHaveBeenCalledWith('pty-1', [
|
||||
'multiplex:conn-desktop-first-paint:7'
|
||||
])
|
||||
}
|
||||
expect(owner).toBe('host')
|
||||
releaseClaim()
|
||||
await flushPromiseChain()
|
||||
|
||||
expect(updateRemoteDesktopViewer).toHaveBeenCalledTimes(2)
|
||||
expect(harness.runtime.sendTerminal).not.toHaveBeenCalled()
|
||||
expect(owner).toBe('host')
|
||||
|
||||
if (detach === 'slot unsubscribe') {
|
||||
harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint')
|
||||
}
|
||||
await harness.dispatchPromise
|
||||
}
|
||||
)
|
||||
|
||||
it.each(CLIENT_CASES)(
|
||||
'single stream drops queued $name and input before detach restoration',
|
||||
async ({ capabilities, opcode }) => {
|
||||
let owner = 'host'
|
||||
let releaseClaim = (): void => {}
|
||||
const handlers = new Map<
|
||||
number,
|
||||
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
|
||||
>()
|
||||
const registry = createSubscriptionRegistryDouble()
|
||||
const updateRemoteDesktopViewer = vi.fn(
|
||||
async (_ptyId: string, subscriptionKey: string): Promise<boolean> => {
|
||||
owner = subscriptionKey
|
||||
return true
|
||||
}
|
||||
)
|
||||
const unregisterRemoteDesktopViewer = vi.fn(
|
||||
async (_ptyId: string, _subscriptionKey: string): Promise<boolean> => {
|
||||
owner = 'host'
|
||||
return true
|
||||
}
|
||||
)
|
||||
const runtime = stubRuntime({
|
||||
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
|
||||
serializeTerminalBuffer: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ data: 'snapshot', cols: 120, rows: 40 }),
|
||||
getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }),
|
||||
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
|
||||
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
|
||||
subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
|
||||
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
|
||||
getTerminalFitOverride: vi.fn().mockReturnValue(null),
|
||||
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
|
||||
registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup),
|
||||
cleanupSubscription: vi.fn(registry.cleanupSubscription),
|
||||
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {})),
|
||||
updateRemoteDesktopViewer,
|
||||
unregisterRemoteDesktopViewer,
|
||||
sendTerminal: vi.fn().mockResolvedValue({ accepted: true })
|
||||
})
|
||||
const messages: string[] = []
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
const dispatchPromise = dispatcher.dispatchStreaming(
|
||||
makeRequest('terminal.subscribe', {
|
||||
terminal: 'terminal-1',
|
||||
client: { id: 'desktop-1', type: 'desktop' },
|
||||
viewport: { cols: 120, rows: 40 },
|
||||
capabilities: { terminalBinaryStream: 1, ...capabilities }
|
||||
}),
|
||||
(message) => messages.push(message),
|
||||
{
|
||||
connectionId: 'conn-single',
|
||||
sendBinary: vi.fn(),
|
||||
registerBinaryStreamHandler: (streamId, handler) => {
|
||||
handlers.set(streamId, handler)
|
||||
return () => handlers.delete(streamId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe(
|
||||
true
|
||||
)
|
||||
)
|
||||
const [streamId, handler] = Array.from(handlers.entries())[0]!
|
||||
owner = 'host'
|
||||
updateRemoteDesktopViewer.mockImplementationOnce(
|
||||
(_ptyId: string, subscriptionKey: string) => {
|
||||
owner = subscriptionKey
|
||||
return new Promise<boolean>((resolve) => {
|
||||
releaseClaim = () => resolve(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
handler(encodedFrame(opcode, streamId, 2, encodeTerminalStreamJson({ cols: 96, rows: 32 })))
|
||||
await vi.waitFor(() => expect(updateRemoteDesktopViewer).toHaveBeenCalledTimes(2))
|
||||
handler(encodedFrame(opcode, streamId, 3, encodeTerminalStreamJson({ cols: 88, rows: 28 })))
|
||||
handler(
|
||||
encodedFrame(TerminalStreamOpcode.Input, streamId, 4, encodeTerminalStreamText('late'))
|
||||
)
|
||||
runtime.cleanupSubscription('terminal-1:desktop-1')
|
||||
|
||||
expect(unregisterRemoteDesktopViewer).toHaveBeenCalledWith('pty-1', expect.any(String))
|
||||
expect(owner).toBe('host')
|
||||
releaseClaim()
|
||||
await flushPromiseChain()
|
||||
|
||||
expect(updateRemoteDesktopViewer).toHaveBeenCalledTimes(2)
|
||||
expect(runtime.sendTerminal).not.toHaveBeenCalled()
|
||||
expect(owner).toBe('host')
|
||||
await dispatchPromise
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -213,7 +213,7 @@ describe('terminal multiplex RPC', () => {
|
||||
})
|
||||
)
|
||||
const sentAfterSuccessfulClaim = vi.mocked(runtime.sendTerminal).mock.calls.length
|
||||
vi.mocked(runtime.updateRemoteDesktopViewer).mockResolvedValueOnce(false)
|
||||
vi.mocked(runtime.claimRemoteDesktopViewer).mockResolvedValueOnce(false)
|
||||
for (const [opcode, seq, payload] of [
|
||||
[TerminalStreamOpcode.ClaimViewport, 3, encodeTerminalStreamJson({ cols: 88, rows: 28 })],
|
||||
[TerminalStreamOpcode.Resize, 4, encodeTerminalStreamJson({ cols: 88, rows: 28 })],
|
||||
|
||||
@@ -33,6 +33,7 @@ export function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): OrcaRu
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
requestRendererTerminalTabMount: vi.fn().mockReturnValue(true),
|
||||
updateRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
|
||||
claimRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
|
||||
unregisterRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
|
||||
unregisterRemoteDesktopViewers: vi.fn().mockResolvedValue(true),
|
||||
isPtyResizeDrivenRemotely: vi.fn().mockReturnValue(false),
|
||||
|
||||
@@ -66,7 +66,7 @@ export type PtyApi = {
|
||||
startupCwdFallback?: { kind: 'worktree'; cwd: string }
|
||||
agentResumeUnavailable?: true
|
||||
}>
|
||||
write: (id: string, data: string) => void
|
||||
write: (id: string, data: string, inputKind?: 'query-reply') => void
|
||||
writeAccepted: (id: string, data: string) => Promise<boolean>
|
||||
onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void
|
||||
resize: (id: string, cols: number, rows: number) => void
|
||||
|
||||
@@ -1049,8 +1049,8 @@ const api = {
|
||||
agentResumeUnavailable?: true
|
||||
}> => ipcRenderer.invoke('pty:spawn', opts),
|
||||
|
||||
write: (id: string, data: string): void => {
|
||||
ipcRenderer.send('pty:write', { id, data })
|
||||
write: (id: string, data: string, inputKind?: 'query-reply'): void => {
|
||||
ipcRenderer.send('pty:write', { id, data, inputKind })
|
||||
},
|
||||
writeAccepted: (id: string, data: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('pty:writeAccepted', { id, data }),
|
||||
|
||||
@@ -23,6 +23,7 @@ import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
installFocusedTerminalTestDocument,
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
@@ -147,6 +148,7 @@ describe('connectPanePty', () => {
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
installTerminalTestGlobals()
|
||||
installFocusedTerminalTestDocument()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
+2
@@ -12,6 +12,7 @@ import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
installFocusedTerminalTestDocument,
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
@@ -136,6 +137,7 @@ describe('connectPanePty', () => {
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
installTerminalTestGlobals()
|
||||
installFocusedTerminalTestDocument()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
installFocusedTerminalTestDocument,
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
@@ -139,6 +140,7 @@ describe('connectPanePty', () => {
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
installTerminalTestGlobals()
|
||||
installFocusedTerminalTestDocument()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -874,7 +874,10 @@ describe('connectPanePty', () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { WRITE_PIPELINE_STALL_CHECK_MS } =
|
||||
await import('@/lib/pane-manager/terminal-write-pipeline-health')
|
||||
const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true)
|
||||
mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState
|
||||
const transport = createMockTransport('pty-rejected')
|
||||
transport.isConnected = vi.fn(() => true)
|
||||
transport.sendInputAccepted = vi.fn().mockResolvedValue(false)
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
@@ -890,6 +893,7 @@ describe('connectPanePty', () => {
|
||||
|
||||
expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03')
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith('', expect.any(Function))
|
||||
expect(remountTerminalTabForRecovery).not.toHaveBeenCalled()
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -94,6 +94,15 @@ export function installTerminalTestGlobals(): void {
|
||||
globalThis.cancelAnimationFrame = vi.fn()
|
||||
}
|
||||
|
||||
export function installFocusedTerminalTestDocument(): void {
|
||||
const target = new EventTarget()
|
||||
Object.defineProperties(target, {
|
||||
visibilityState: { value: 'visible' },
|
||||
hasFocus: { value: vi.fn(() => true) }
|
||||
})
|
||||
;(globalThis as { document?: Document }).document = target as Document
|
||||
}
|
||||
|
||||
export async function restoreTerminalTestGlobals(): Promise<void> {
|
||||
// Drain deferred confirmation work before the next test replaces its store mock.
|
||||
await drainFakeTimerWork()
|
||||
|
||||
@@ -37,6 +37,7 @@ export type MockTransport = {
|
||||
claimViewport: ReturnType<typeof vi.fn>
|
||||
setOutputPaused?: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
isConnected?: ReturnType<typeof vi.fn>
|
||||
getPtyId: ReturnType<typeof vi.fn>
|
||||
getConnectionId: ReturnType<typeof vi.fn>
|
||||
serializeBuffer?: ReturnType<typeof vi.fn>
|
||||
|
||||
+2
@@ -13,6 +13,7 @@ import { buildPaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
installFocusedTerminalTestDocument,
|
||||
installTerminalTestGlobals,
|
||||
restoreTerminalTestGlobals
|
||||
} from './pty-connection-test-environment'
|
||||
@@ -137,6 +138,7 @@ describe('connectPanePty', () => {
|
||||
storeSubscribers = []
|
||||
mockStoreState = createInitialStoreState(() => mockStoreState)
|
||||
installTerminalTestGlobals()
|
||||
installFocusedTerminalTestDocument()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
consumeForegroundImmediateBudget,
|
||||
createForegroundImmediateBudget
|
||||
} from './foreground-output-budgets'
|
||||
import {
|
||||
TERMINAL_FOCUS_IN_SEQUENCE,
|
||||
TERMINAL_FOCUS_OUT_SEQUENCE
|
||||
} from '../../../../../shared/terminal-focus-report'
|
||||
|
||||
export { TERMINAL_FOCUS_IN_SEQUENCE, TERMINAL_FOCUS_OUT_SEQUENCE }
|
||||
|
||||
export const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256
|
||||
export const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h'
|
||||
@@ -11,8 +17,6 @@ export const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l'
|
||||
export const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1
|
||||
export const CURSOR_SHOW_SEQUENCE = '\x1b[?25h'
|
||||
export const CURSOR_HIDE_SEQUENCE = '\x1b[?25l'
|
||||
export const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I'
|
||||
export const TERMINAL_FOCUS_OUT_SEQUENCE = '\x1b[O'
|
||||
export const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l'
|
||||
export const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250
|
||||
export const SHIFT_ENTER_RECONFIRM_IDLE_MS = 350
|
||||
|
||||
@@ -20,7 +20,10 @@ import { FOREGROUND_GRID_DRIFT_CHECK_MIN_MS } from './foreground-output-budgets'
|
||||
import { TERMINAL_FOCUS_IN_SEQUENCE, TERMINAL_FOCUS_OUT_SEQUENCE } from './foreground-output-scan'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import { isCodexPaneStale } from './codex-pane-stale'
|
||||
import { isRemoteDesktopViewportClaimEligible } from '../remote-desktop-viewport-claim'
|
||||
import {
|
||||
getRemoteDesktopViewportClaimDocumentState,
|
||||
isRemoteDesktopViewportClaimEligible
|
||||
} from '../remote-desktop-viewport-claim'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
@@ -213,14 +216,10 @@ export function installPtyInputForward(session: ConnectPanePtySession): void {
|
||||
if (queuePanePtyResizeIfHeld(session.pane.container, cols, rows)) {
|
||||
return
|
||||
}
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
const claim =
|
||||
!isRemoteRuntimePtyId(currentPtyId) ||
|
||||
isRemoteDesktopViewportClaimEligible({
|
||||
paneVisible: session.deps.isVisibleRef.current,
|
||||
documentVisible: document.visibilityState !== 'hidden',
|
||||
documentFocused: document.hasFocus()
|
||||
})
|
||||
const claim = isRemoteDesktopViewportClaimEligible({
|
||||
paneVisible: session.deps.isVisibleRef.current,
|
||||
...getRemoteDesktopViewportClaimDocumentState()
|
||||
})
|
||||
session.transport.resize(cols, rows, { claim })
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import { TRANSPORT_CONNECT_SETTLE_GRACE_MS } from './pty-connect-limits'
|
||||
import { shouldClaimDesktopViewportForUserActivity } from '../remote-desktop-viewport-claim'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
@@ -192,7 +193,14 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void {
|
||||
|
||||
session.claimViewportForUserActivity = (): void => {
|
||||
const currentPtyId = session.transport.getPtyId()
|
||||
if (!currentPtyId || getFitOverrideForPty(currentPtyId)?.mode !== 'remote-desktop-fit') {
|
||||
if (
|
||||
!currentPtyId ||
|
||||
!shouldClaimDesktopViewportForUserActivity({
|
||||
initialRemoteFitPending:
|
||||
isRemoteRuntimePtyId(currentPtyId) && session.pendingVisibleRemoteViewportClaim,
|
||||
holdMode: getFitOverrideForPty(currentPtyId)?.mode ?? null
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
let proposed: { cols: number; rows: number } | undefined
|
||||
|
||||
@@ -4,7 +4,10 @@ import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observe
|
||||
import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
|
||||
import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state'
|
||||
import { reconcilePtySizeAcrossFrames } from '../pty-size-reconcile'
|
||||
import { shouldClaimRemoteDesktopViewport } from '../remote-desktop-viewport-claim'
|
||||
import {
|
||||
getRemoteDesktopViewportClaimDocumentState,
|
||||
shouldClaimRemoteDesktopViewport
|
||||
} from '../remote-desktop-viewport-claim'
|
||||
import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild'
|
||||
import { waitForStableStartupGrid } from '../terminal-startup-grid-settle'
|
||||
|
||||
@@ -78,8 +81,7 @@ export function installPtyResizeGeometry(session: ConnectPanePtySession): void {
|
||||
current: proposed,
|
||||
paneGeometryChanged,
|
||||
paneVisible: session.deps.isVisibleRef.current,
|
||||
documentVisible: document.visibilityState !== 'hidden',
|
||||
documentFocused: document.hasFocus()
|
||||
...getRemoteDesktopViewportClaimDocumentState()
|
||||
})
|
||||
) {
|
||||
// Why: a focused, visible layout change is genuine activity; release
|
||||
|
||||
@@ -204,6 +204,23 @@ describe('pty input write queue', () => {
|
||||
expect(writes.map((write) => write.data)).toEqual(replies)
|
||||
})
|
||||
|
||||
it('classifies query replies for host ownership without classifying user input', async () => {
|
||||
const writes: { data: string; inputKind?: 'query-reply' }[] = []
|
||||
const queue = createPtyInputWriteQueue({
|
||||
isWritable: () => true,
|
||||
write: (_id, data, inputKind) => writes.push({ data, inputKind })
|
||||
})
|
||||
|
||||
queue.enqueueQueryReply('pty-1', '\u001b[1;1R')
|
||||
queue.enqueue('pty-1', 'x')
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(writes).toEqual([
|
||||
{ data: '\u001b[1;1R', inputKind: 'query-reply' },
|
||||
{ data: 'x', inputKind: undefined }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not coalesce a color-scheme reply with a following keystroke', async () => {
|
||||
const { writes, queue } = createRecordingQueue()
|
||||
const reply = mode2031SequenceFor('dark')
|
||||
|
||||
@@ -34,7 +34,7 @@ export type PtyInputWriteQueue = {
|
||||
|
||||
export type PtyInputWriteQueueDeps = {
|
||||
isWritable: (id: string) => boolean
|
||||
write: (id: string, data: string) => void
|
||||
write: (id: string, data: string, inputKind?: 'query-reply') => void
|
||||
yieldBetweenWrites?: () => Promise<void>
|
||||
onDrainFailure?: (id: string) => void
|
||||
}
|
||||
@@ -215,7 +215,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
removePending(next)
|
||||
continue
|
||||
}
|
||||
deps.write(next.id, chunk.value)
|
||||
deps.write(next.id, chunk.value, next.replyOnly ? 'query-reply' : undefined)
|
||||
const following = next.chunks.next()
|
||||
if (following.done) {
|
||||
removePending(next)
|
||||
|
||||
@@ -84,12 +84,12 @@ describe('createIpcPtyTransport', () => {
|
||||
|
||||
expect(transport.sendInput(`${chunk}tail`)).toBe(true)
|
||||
expect(window.api.pty.write).toHaveBeenCalledTimes(1)
|
||||
expect(window.api.pty.write).toHaveBeenNthCalledWith(1, 'pty-1', chunk)
|
||||
expect(window.api.pty.write).toHaveBeenNthCalledWith(1, 'pty-1', chunk, undefined)
|
||||
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
expect(window.api.pty.write).toHaveBeenCalledTimes(2)
|
||||
expect(window.api.pty.write).toHaveBeenNthCalledWith(2, 'pty-1', 'tail')
|
||||
expect(window.api.pty.write).toHaveBeenNthCalledWith(2, 'pty-1', 'tail', undefined)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -114,11 +114,11 @@ describe('createIpcPtyTransport', () => {
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([
|
||||
['pty-1', first],
|
||||
['pty-1', ordinary],
|
||||
['pty-1', first, 'query-reply'],
|
||||
['pty-1', ordinary, undefined],
|
||||
...replies
|
||||
.slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES)
|
||||
.map((reply) => ['pty-1', reply])
|
||||
.map((reply) => ['pty-1', reply, 'query-reply'])
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
|
||||
@@ -591,7 +591,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
let storedCallbacks: Parameters<PtyTransport['connect']>[0]['callbacks'] = {}
|
||||
const inputWriteQueue = createPtyInputWriteQueue({
|
||||
isWritable: (id) => connected && ptyId === id,
|
||||
write: (id, data) => window.api.pty.write(id, data),
|
||||
write: (id, data, inputKind) => window.api.pty.write(id, data, inputKind),
|
||||
// Guard like the registered writeUnavailable handler: a rebind during the async drain
|
||||
// must not tell the new pane that its write failed.
|
||||
onDrainFailure: (id) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isRemoteDesktopViewportClaimEligible,
|
||||
shouldClaimRemoteDesktopViewport
|
||||
shouldClaimRemoteDesktopViewport,
|
||||
shouldClaimDesktopViewportForUserActivity
|
||||
} from './remote-desktop-viewport-claim'
|
||||
|
||||
describe('isRemoteDesktopViewportClaimEligible', () => {
|
||||
@@ -81,3 +82,23 @@ describe('shouldClaimRemoteDesktopViewport', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldClaimDesktopViewportForUserActivity', () => {
|
||||
it.each([
|
||||
{ initialRemoteFitPending: true, holdMode: null, expected: true },
|
||||
{ initialRemoteFitPending: false, holdMode: null, expected: false },
|
||||
{ initialRemoteFitPending: false, holdMode: 'remote-desktop-fit' as const, expected: true },
|
||||
{ initialRemoteFitPending: true, holdMode: 'mobile-fit' as const, expected: false }
|
||||
])('handles connection-local and authoritative fit state: %o', (input) => {
|
||||
expect(shouldClaimDesktopViewportForUserActivity(input)).toBe(input.expected)
|
||||
})
|
||||
|
||||
it('preserves known host reclaim', () => {
|
||||
expect(
|
||||
shouldClaimDesktopViewportForUserActivity({
|
||||
initialRemoteFitPending: false,
|
||||
holdMode: 'remote-desktop-fit'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,27 @@ export function isRemoteDesktopViewportClaimEligible(args: {
|
||||
return args.paneVisible && args.documentVisible && args.documentFocused
|
||||
}
|
||||
|
||||
export function getRemoteDesktopViewportClaimDocumentState(): {
|
||||
documentVisible: boolean
|
||||
documentFocused: boolean
|
||||
} {
|
||||
const available = typeof document !== 'undefined'
|
||||
return {
|
||||
documentVisible: available && document.visibilityState !== 'hidden',
|
||||
documentFocused: available && typeof document.hasFocus === 'function' && document.hasFocus()
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldClaimDesktopViewportForUserActivity(args: {
|
||||
initialRemoteFitPending: boolean
|
||||
holdMode: FitHoldMode | null
|
||||
}): boolean {
|
||||
return (
|
||||
args.holdMode === 'remote-desktop-fit' ||
|
||||
(args.initialRemoteFitPending && args.holdMode === null)
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldClaimRemoteDesktopViewport(args: {
|
||||
holdMode: FitHoldMode
|
||||
prior: TerminalGrid | null
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const TERMINAL_FOCUS_IN_SEQUENCE = '\u001b[I'
|
||||
export const TERMINAL_FOCUS_OUT_SEQUENCE = '\u001b[O'
|
||||
|
||||
export function isTerminalFocusReport(data: string): boolean {
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
for (let offset = 0; offset < data.length; offset += TERMINAL_FOCUS_IN_SEQUENCE.length) {
|
||||
const report = data.slice(offset, offset + TERMINAL_FOCUS_IN_SEQUENCE.length)
|
||||
if (report !== TERMINAL_FOCUS_IN_SEQUENCE && report !== TERMINAL_FOCUS_OUT_SEQUENCE) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -101,6 +101,7 @@ export function createHostTerminalRuntimeStub(
|
||||
registerRemoteTerminalViewSubscriber: () => () => {},
|
||||
requestRendererTerminalTabMount: () => true,
|
||||
updateRemoteDesktopViewer: async () => true,
|
||||
claimRemoteDesktopViewer: async () => true,
|
||||
unregisterRemoteDesktopViewer: async () => true,
|
||||
unregisterRemoteDesktopViewers: async () => true,
|
||||
isPtyResizeDrivenRemotely: () => false,
|
||||
|
||||
@@ -15,20 +15,6 @@ export type TerminalViewportTarget = {
|
||||
webTabId: string
|
||||
}
|
||||
|
||||
export type TerminalWireFrame = {
|
||||
direction: 'in' | 'out'
|
||||
opcode?: number
|
||||
payload?: unknown
|
||||
held?: boolean
|
||||
}
|
||||
|
||||
type WireProbeState = {
|
||||
cleanup: () => void
|
||||
held: { channel: string; args: unknown[] }[]
|
||||
release: () => void
|
||||
trace: TerminalWireFrame[]
|
||||
}
|
||||
|
||||
export function createViewportFixture(): {
|
||||
command: string
|
||||
dispose: () => void
|
||||
@@ -169,6 +155,48 @@ export async function openTerminalTab(
|
||||
await page.waitForFunction((id) => window.__paneManagers?.has(id) ?? false, tabId, {
|
||||
timeout: 60_000
|
||||
})
|
||||
await page.waitForFunction(
|
||||
(id) => {
|
||||
const state = window.__store?.getState()
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0]
|
||||
const screen = pane?.container.querySelector<HTMLElement>('.xterm-screen')
|
||||
if (state?.activeTabId !== id || !pane?.container.isConnected || !screen) {
|
||||
return false
|
||||
}
|
||||
const style = getComputedStyle(pane.container)
|
||||
const rect = screen.getBoundingClientRect()
|
||||
return (
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
)
|
||||
},
|
||||
tabId,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function waitForTerminalText(page: Page, tabId: string, text: string): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
({ tabId, text }) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0]
|
||||
if (!pane) {
|
||||
return false
|
||||
}
|
||||
const buffer = pane.terminal.buffer.active
|
||||
for (let index = 0; index < buffer.length; index += 1) {
|
||||
if (buffer.getLine(index)?.translateToString(true).includes(text)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
{ tabId, text },
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function readPaneGrid(
|
||||
@@ -219,95 +247,6 @@ export async function configureElectronWindow(
|
||||
)
|
||||
}
|
||||
|
||||
export async function installTerminalWireProbe(
|
||||
app: ElectronApplication,
|
||||
options: { holdFitEvents?: boolean; legacyViewportClient?: boolean } = {}
|
||||
): Promise<void> {
|
||||
await app.evaluate(({ BrowserWindow, ipcMain }, probeOptions) => {
|
||||
const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState }
|
||||
target.__sta5050WireProbe?.cleanup()
|
||||
const trace: TerminalWireFrame[] = []
|
||||
const listener = (_event: unknown, message: { bytes?: Uint8Array }) => {
|
||||
const bytes = message.bytes
|
||||
if (!bytes || bytes.byteLength < 16) {
|
||||
return
|
||||
}
|
||||
const opcode = bytes[2]
|
||||
if (probeOptions.legacyViewportClient && opcode === 9) {
|
||||
const text = new TextDecoder().decode(bytes.slice(16))
|
||||
const key = 'desktopViewportClaims'
|
||||
const offset = text.indexOf(key)
|
||||
if (offset !== -1) {
|
||||
bytes[16 + offset + key.length - 1] = 'x'.charCodeAt(0)
|
||||
}
|
||||
}
|
||||
if (probeOptions.legacyViewportClient && opcode === 14) {
|
||||
bytes[2] = 8
|
||||
}
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(new TextDecoder().decode(bytes.slice(16)))
|
||||
} catch {
|
||||
payload = undefined
|
||||
}
|
||||
trace.push({ direction: 'out', opcode: bytes[2], payload })
|
||||
}
|
||||
ipcMain.prependListener('runtimeEnvironments:subscriptionBinary', listener)
|
||||
const window = BrowserWindow.getAllWindows()[0]
|
||||
if (!window) {
|
||||
throw new Error('Electron window unavailable')
|
||||
}
|
||||
const originalSend = window.webContents.send.bind(window.webContents)
|
||||
const held: { channel: string; args: unknown[] }[] = []
|
||||
window.webContents.send = ((channel: string, ...args: unknown[]) => {
|
||||
const isFit =
|
||||
channel === 'runtimeEnvironments:subscriptionEvent' &&
|
||||
JSON.stringify(args).includes('fit-override-changed')
|
||||
trace.push({ direction: 'in', held: isFit && probeOptions.holdFitEvents, payload: args })
|
||||
if (isFit && probeOptions.holdFitEvents) {
|
||||
held.push({ channel, args })
|
||||
return
|
||||
}
|
||||
originalSend(channel, ...args)
|
||||
}) as typeof window.webContents.send
|
||||
const release = () => {
|
||||
for (const event of held.splice(0)) {
|
||||
originalSend(event.channel, ...event.args)
|
||||
}
|
||||
}
|
||||
const cleanup = () => {
|
||||
release()
|
||||
ipcMain.removeListener('runtimeEnvironments:subscriptionBinary', listener)
|
||||
window.webContents.send = originalSend
|
||||
delete target.__sta5050WireProbe
|
||||
}
|
||||
target.__sta5050WireProbe = { cleanup, held, release, trace }
|
||||
}, options)
|
||||
}
|
||||
|
||||
export async function readTerminalWireProbe(
|
||||
app: ElectronApplication
|
||||
): Promise<TerminalWireFrame[]> {
|
||||
return app.evaluate(() => {
|
||||
const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState }
|
||||
return target.__sta5050WireProbe?.trace ?? []
|
||||
})
|
||||
}
|
||||
|
||||
export async function releaseTerminalFitEvents(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(() => {
|
||||
const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState }
|
||||
target.__sta5050WireProbe?.release()
|
||||
})
|
||||
}
|
||||
|
||||
export async function disposeTerminalWireProbe(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(() => {
|
||||
const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState }
|
||||
target.__sta5050WireProbe?.cleanup()
|
||||
})
|
||||
}
|
||||
|
||||
export function lastFixtureGrid(text: string): { cols: number; rows: number } | null {
|
||||
const matches = [...text.matchAll(/(?:READY|SIZE|LINE:[^:]+):(\d+)x(\d+)/g)]
|
||||
const match = matches.at(-1)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { ElectronApplication } from '@stablyai/playwright-test'
|
||||
|
||||
export type TerminalWireFrame = {
|
||||
direction: 'in' | 'out'
|
||||
opcode?: number
|
||||
originalOpcode?: number
|
||||
payload?: unknown
|
||||
held?: boolean
|
||||
streamId?: number
|
||||
}
|
||||
|
||||
type WireProbeState = {
|
||||
cleanup: () => void
|
||||
held: {
|
||||
args: unknown[]
|
||||
channel: string
|
||||
send: (channel: string, ...args: unknown[]) => void
|
||||
}[]
|
||||
release: () => void
|
||||
trace: TerminalWireFrame[]
|
||||
}
|
||||
|
||||
export async function installTerminalWireProbe(
|
||||
app: ElectronApplication,
|
||||
options: {
|
||||
holdFitEvents?: boolean
|
||||
holdHostFitEvents?: boolean
|
||||
dropHostViewportClaims?: boolean
|
||||
legacyViewportClient?: boolean
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
await app.evaluate(({ BrowserWindow, ipcMain }, probeOptions) => {
|
||||
const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState }
|
||||
target.__sta5050WireProbe?.cleanup()
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
if (windows.length === 0) {
|
||||
throw new Error('Electron window unavailable')
|
||||
}
|
||||
const trace: TerminalWireFrame[] = []
|
||||
const listener = (_event: unknown, message: { bytes?: Uint8Array }) => {
|
||||
const bytes = message.bytes
|
||||
if (!bytes || bytes.byteLength < 16) {
|
||||
return
|
||||
}
|
||||
const streamId = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(
|
||||
4,
|
||||
true
|
||||
)
|
||||
const opcode = bytes[2]
|
||||
if (probeOptions.legacyViewportClient && opcode === 9) {
|
||||
const text = new TextDecoder().decode(bytes.slice(16))
|
||||
const key = 'desktopViewportClaims'
|
||||
const offset = text.indexOf(key)
|
||||
if (offset !== -1) {
|
||||
bytes[16 + offset + key.length - 1] = 'x'.charCodeAt(0)
|
||||
}
|
||||
}
|
||||
if (probeOptions.legacyViewportClient && opcode === 14) {
|
||||
// Why: reconstruct the wire shape of an intentionally old client without running an obsolete desktop build.
|
||||
bytes[2] = 8
|
||||
}
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(new TextDecoder().decode(bytes.slice(16)))
|
||||
} catch {
|
||||
payload = bytes[2] === 7 ? new TextDecoder().decode(bytes.slice(16)) : undefined
|
||||
}
|
||||
trace.push({ direction: 'out', opcode: bytes[2], originalOpcode: opcode, payload, streamId })
|
||||
}
|
||||
ipcMain.prependListener('runtimeEnvironments:subscriptionBinary', listener)
|
||||
const originalEmit = ipcMain.emit
|
||||
ipcMain.emit = ((channel: string, ...args: unknown[]) => {
|
||||
if (probeOptions.dropHostViewportClaims && channel === 'pty:claimViewport') {
|
||||
trace.push({ direction: 'out', opcode: 14, payload: args.at(-1) })
|
||||
return true
|
||||
}
|
||||
return originalEmit.call(ipcMain, channel, ...args)
|
||||
}) as typeof ipcMain.emit
|
||||
const held: WireProbeState['held'] = []
|
||||
const sendPrototype = Object.getPrototypeOf(windows[0].webContents) as {
|
||||
send: (channel: string, ...args: unknown[]) => void
|
||||
}
|
||||
const originalSend = sendPrototype.send
|
||||
sendPrototype.send = function (this: unknown, channel: string, ...args: unknown[]) {
|
||||
const send = (nextChannel: string, ...nextArgs: unknown[]) =>
|
||||
originalSend.call(this, nextChannel, ...nextArgs)
|
||||
const isRemoteFit =
|
||||
channel === 'runtimeEnvironments:subscriptionEvent' &&
|
||||
JSON.stringify(args).includes('fit-override-changed')
|
||||
const isHostFit = channel === 'runtime:terminalFitOverrideChanged'
|
||||
const shouldHold =
|
||||
(isRemoteFit && probeOptions.holdFitEvents) || (isHostFit && probeOptions.holdHostFitEvents)
|
||||
if (isRemoteFit || isHostFit) {
|
||||
trace.push({ direction: 'in', held: Boolean(shouldHold), payload: args })
|
||||
}
|
||||
if (shouldHold) {
|
||||
held.push({ channel, args, send })
|
||||
return
|
||||
}
|
||||
send(channel, ...args)
|
||||
}
|
||||
const release = () => {
|
||||
for (const event of held.splice(0)) {
|
||||
event.send(event.channel, ...event.args)
|
||||
}
|
||||
}
|
||||
const cleanup = () => {
|
||||
release()
|
||||
ipcMain.removeListener('runtimeEnvironments:subscriptionBinary', listener)
|
||||
ipcMain.emit = originalEmit
|
||||
sendPrototype.send = originalSend
|
||||
delete target.__sta5050WireProbe
|
||||
}
|
||||
target.__sta5050WireProbe = { cleanup, held, release, trace }
|
||||
}, options)
|
||||
}
|
||||
|
||||
export async function readTerminalWireProbe(
|
||||
app: ElectronApplication
|
||||
): Promise<TerminalWireFrame[]> {
|
||||
return app.evaluate(() => {
|
||||
const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState }
|
||||
return target.__sta5050WireProbe?.trace ?? []
|
||||
})
|
||||
}
|
||||
|
||||
export async function releaseTerminalFitEvents(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(() => {
|
||||
const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState }
|
||||
target.__sta5050WireProbe?.release()
|
||||
})
|
||||
}
|
||||
|
||||
export async function disposeTerminalWireProbe(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(() => {
|
||||
const target = globalThis as typeof globalThis & { __sta5050WireProbe?: WireProbeState }
|
||||
target.__sta5050WireProbe?.cleanup()
|
||||
})
|
||||
}
|
||||
@@ -74,6 +74,7 @@ function startHost(): {
|
||||
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
|
||||
requestRendererTerminalTabMount: vi.fn().mockReturnValue(true),
|
||||
updateRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
|
||||
claimRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
|
||||
unregisterRemoteDesktopViewer: vi.fn().mockResolvedValue(true),
|
||||
unregisterRemoteDesktopViewers: vi.fn().mockResolvedValue(true),
|
||||
isPtyResizeDrivenRemotely: vi.fn().mockReturnValue(false),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { isTerminalFocusReport } from '../../src/shared/terminal-focus-report'
|
||||
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import {
|
||||
@@ -12,16 +13,19 @@ import {
|
||||
configureElectronWindow,
|
||||
createViewportFixture,
|
||||
createViewportTerminal,
|
||||
disposeTerminalWireProbe,
|
||||
fitTerminalPane,
|
||||
installTerminalWireProbe,
|
||||
lastFixtureGrid,
|
||||
openTerminalTab,
|
||||
readPaneGrid,
|
||||
readTerminalWireProbe,
|
||||
releaseTerminalFitEvents,
|
||||
type TerminalViewportTarget
|
||||
type TerminalViewportTarget,
|
||||
waitForTerminalText
|
||||
} from './helpers/terminal-viewport-ownership-fixture'
|
||||
import {
|
||||
disposeTerminalWireProbe,
|
||||
installTerminalWireProbe,
|
||||
readTerminalWireProbe,
|
||||
releaseTerminalFitEvents
|
||||
} from './helpers/terminal-viewport-wire-probe'
|
||||
|
||||
const fixture = createViewportFixture()
|
||||
|
||||
@@ -50,6 +54,20 @@ async function focusApp(client: PairedElectronClient): Promise<void> {
|
||||
await expect.poll(() => client.page.evaluate(() => document.hasFocus())).toBe(true)
|
||||
}
|
||||
|
||||
async function focusAppWithoutResize(client: PairedElectronClient): Promise<void> {
|
||||
await client.app.evaluate(({ app, BrowserWindow }) => {
|
||||
const window = BrowserWindow.getAllWindows()[0]
|
||||
if (!window) {
|
||||
throw new Error('Electron window unavailable')
|
||||
}
|
||||
window.show()
|
||||
app.focus({ steal: true })
|
||||
window.focus()
|
||||
})
|
||||
await client.page.bringToFront()
|
||||
await expect.poll(() => client.page.evaluate(() => document.hasFocus())).toBe(true)
|
||||
}
|
||||
|
||||
async function forceDocumentUnfocused(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'hasFocus', {
|
||||
@@ -60,6 +78,12 @@ async function forceDocumentUnfocused(page: Page): Promise<void> {
|
||||
await expect.poll(() => page.evaluate(() => document.hasFocus())).toBe(false)
|
||||
}
|
||||
|
||||
async function restoreDocumentFocus(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
Reflect.deleteProperty(document, 'hasFocus')
|
||||
})
|
||||
}
|
||||
|
||||
async function typeMarker(page: Page, marker: string): Promise<void> {
|
||||
await focusActiveTerminalInput(page)
|
||||
await page.evaluate((value) => {
|
||||
@@ -103,6 +127,48 @@ function clampGrid(grid: { cols: number; rows: number }): { cols: number; rows:
|
||||
}
|
||||
}
|
||||
|
||||
function fixtureLineGrid(text: string, marker: string): { cols: number; rows: number } | null {
|
||||
const match = text.match(new RegExp(`LINE:${marker}:(\\d+)x(\\d+)`))
|
||||
return match ? { cols: Number(match[1]), rows: Number(match[2]) } : null
|
||||
}
|
||||
|
||||
function activityOpcodes(
|
||||
frames: Awaited<ReturnType<typeof readTerminalWireProbe>>,
|
||||
streamId?: number
|
||||
): number[] {
|
||||
return frames.flatMap((frame) =>
|
||||
frame.direction === 'out' &&
|
||||
(streamId === undefined || frame.streamId === streamId) &&
|
||||
frame.opcode &&
|
||||
[7, 8, 14].includes(frame.opcode) &&
|
||||
!(
|
||||
frame.opcode === 7 &&
|
||||
typeof frame.payload === 'string' &&
|
||||
isTerminalFocusReport(frame.payload)
|
||||
)
|
||||
? [frame.opcode]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
function terminalStreamId(
|
||||
frames: Awaited<ReturnType<typeof readTerminalWireProbe>>,
|
||||
terminal: string
|
||||
): number {
|
||||
for (const frame of frames) {
|
||||
const payload = frame.payload as { streamId?: unknown; terminal?: unknown } | undefined
|
||||
if (
|
||||
frame.direction === 'out' &&
|
||||
frame.opcode === 9 &&
|
||||
payload?.terminal === terminal &&
|
||||
typeof payload.streamId === 'number'
|
||||
) {
|
||||
return payload.streamId
|
||||
}
|
||||
}
|
||||
throw new Error(`terminal stream unavailable for ${terminal}`)
|
||||
}
|
||||
|
||||
async function runtimeTail(page: Page, terminal: string): Promise<string> {
|
||||
const result = await callLocal<{ terminal: { tail: string[] } }>(page, 'terminal.read', {
|
||||
terminal
|
||||
@@ -158,12 +224,38 @@ test('current clients keep viewport ownership with real activity @headful', asyn
|
||||
await waitForSink(target, 'READY:')
|
||||
const hostGrid = await readPaneGrid(orcaPage, target.hostTabId)
|
||||
await configureElectronWindow(clientA.app, 1120, 720, true)
|
||||
await installTerminalWireProbe(clientA.app)
|
||||
await openTerminalTab(clientA.page, worktreeId, target.webTabId)
|
||||
await expect
|
||||
.poll(() => readTerminalWireProbe(clientA!.app), { timeout: 30_000 })
|
||||
.toEqual(expect.arrayContaining([expect.objectContaining({ direction: 'out', opcode: 9 })]))
|
||||
const clientAStreamId = terminalStreamId(
|
||||
await readTerminalWireProbe(clientA.app),
|
||||
target.terminal
|
||||
)
|
||||
await focusApp(clientA)
|
||||
await typeMarker(clientA.page, 'A_OWNER')
|
||||
await waitForSink(target, 'LINE:A_OWNER:')
|
||||
const clientAGrid = await readPaneGrid(clientA.page, target.webTabId)
|
||||
await waitForGrid(target, clientAGrid)
|
||||
const previewWireStart = (await readTerminalWireProbe(clientA.app)).length
|
||||
const passivePreview = await orcaPage.evaluate(async (hostTabId) => {
|
||||
const ptyId = window.__store?.getState().ptyIdsByTabId[hostTabId]?.[0]
|
||||
if (!ptyId) {
|
||||
throw new Error(`host PTY unavailable for ${hostTabId}`)
|
||||
}
|
||||
const connection = await window.api.terminalPreview.connect(ptyId, { scrollbackRows: 24 })
|
||||
await window.api.terminalPreview.unsubscribe(ptyId)
|
||||
return {
|
||||
ptyId,
|
||||
snapshotCols: connection.snapshot?.cols,
|
||||
snapshotRows: connection.snapshot?.rows
|
||||
}
|
||||
}, target.hostTabId)
|
||||
const passivePreviewFrames = (await readTerminalWireProbe(clientA.app)).slice(previewWireStart)
|
||||
const passivePreviewGrid = lastFixtureGrid(fixture.readSink(target.sinkPath))
|
||||
expect(activityOpcodes(passivePreviewFrames, clientAStreamId)).toEqual([])
|
||||
expect(passivePreviewGrid).toEqual(clientAGrid)
|
||||
|
||||
clientB = await launchPairedElectronClient(
|
||||
await createRuntimeDesktopPairingOffer(orcaPage),
|
||||
@@ -176,6 +268,7 @@ test('current clients keep viewport ownership with real activity @headful', asyn
|
||||
await installTerminalWireProbe(clientB.app, { holdFitEvents: true })
|
||||
await focusApp(clientA)
|
||||
await openTerminalTab(clientB.page, worktreeId, target.webTabId)
|
||||
await waitForTerminalText(clientB.page, target.webTabId, 'READY:')
|
||||
await expect
|
||||
.poll(() => readTerminalWireProbe(clientB!.app))
|
||||
.toEqual(
|
||||
@@ -189,6 +282,10 @@ test('current clients keep viewport ownership with real activity @headful', asyn
|
||||
})
|
||||
])
|
||||
)
|
||||
const clientBStreamId = terminalStreamId(
|
||||
await readTerminalWireProbe(clientB.app),
|
||||
target.terminal
|
||||
)
|
||||
await configureElectronWindow(clientB.app, 760, 520, false)
|
||||
await focusApp(clientA)
|
||||
await forceDocumentUnfocused(clientB.page)
|
||||
@@ -199,76 +296,175 @@ test('current clients keep viewport ownership with real activity @headful', asyn
|
||||
}))
|
||||
expect(unfocusedVisible).toEqual({ focused: false, visibility: 'visible' })
|
||||
|
||||
const passiveResizeStart = (await readTerminalWireProbe(clientB.app)).length
|
||||
await configureElectronWindow(clientB.app, 700, 480, false)
|
||||
await focusApp(clientA)
|
||||
await expect.poll(() => clientB!.page.evaluate(() => document.hasFocus())).toBe(false)
|
||||
await fitTerminalPane(clientB.page, target.webTabId)
|
||||
await expect
|
||||
.poll(() => readTerminalWireProbe(clientB!.app), { timeout: 30_000 })
|
||||
.poll(
|
||||
() =>
|
||||
readTerminalWireProbe(clientB!.app).then((frames) => frames.slice(passiveResizeStart)),
|
||||
{
|
||||
timeout: 30_000
|
||||
}
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.objectContaining({ direction: 'out', opcode: 8 })]))
|
||||
const clientBGrid = clampGrid(await readPaneGrid(clientB.page, target.webTabId))
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
await typeMarker(clientA.page, 'A_PASSIVE_BARRIER')
|
||||
await waitForSink(target, 'LINE:A_PASSIVE_BARRIER:')
|
||||
const raceGrid = lastFixtureGrid(fixture.readSink(target.sinkPath))
|
||||
const runtimeRaceTail = await runtimeTail(orcaPage, target.terminal)
|
||||
const passiveResizeFrames = (await readTerminalWireProbe(clientB.app)).slice(passiveResizeStart)
|
||||
expect(activityOpcodes(passiveResizeFrames, clientBStreamId)).not.toContain(14)
|
||||
expect(raceGrid).toEqual(clientAGrid)
|
||||
|
||||
await restoreDocumentFocus(clientB.page)
|
||||
await focusAppWithoutResize(clientB)
|
||||
const clientBInputGrid = clampGrid(await readPaneGrid(clientB.page, target.webTabId))
|
||||
expect(clientBInputGrid).not.toEqual(clientAGrid)
|
||||
expect(clientBInputGrid).not.toEqual(hostGrid)
|
||||
expect(clientAGrid).not.toEqual(hostGrid)
|
||||
const clientBInputStart = (await readTerminalWireProbe(clientB.app)).length
|
||||
expect(lastFixtureGrid(fixture.readSink(target.sinkPath))).toEqual(clientAGrid)
|
||||
await typeMarker(clientB.page, 'B_PRE_FIT_INPUT')
|
||||
await waitForSink(target, 'LINE:B_PRE_FIT_INPUT:')
|
||||
const clientBInputFrames = (await readTerminalWireProbe(clientB.app)).slice(clientBInputStart)
|
||||
expect({
|
||||
lineGrid: fixtureLineGrid(fixture.readSink(target.sinkPath), 'B_PRE_FIT_INPUT'),
|
||||
opcodes: activityOpcodes(clientBInputFrames, clientBStreamId).slice(-3)
|
||||
}).toEqual({ lineGrid: clientBInputGrid, opcodes: [14, 8, 7] })
|
||||
await waitForGrid(target, clientBInputGrid)
|
||||
|
||||
await releaseTerminalFitEvents(clientB.app)
|
||||
await focusApp(clientA)
|
||||
const clientAInputStart = (await readTerminalWireProbe(clientA.app)).length
|
||||
expect(lastFixtureGrid(fixture.readSink(target.sinkPath))).toEqual(clientBInputGrid)
|
||||
await typeMarker(clientA.page, 'A_RECLAIM')
|
||||
await waitForSink(target, 'LINE:A_RECLAIM:')
|
||||
await waitForGrid(target, clientAGrid)
|
||||
const clientAInputFrames = (await readTerminalWireProbe(clientA.app)).slice(clientAInputStart)
|
||||
expect(activityOpcodes(clientAInputFrames, clientAStreamId).slice(-3)).toEqual([14, 8, 7])
|
||||
expect(fixtureLineGrid(fixture.readSink(target.sinkPath), 'A_RECLAIM')).toEqual(clientAGrid)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
readTerminalWireProbe(clientA!.app).then((frames) =>
|
||||
frames
|
||||
.slice(clientAInputStart)
|
||||
.some(
|
||||
(frame) =>
|
||||
frame.direction === 'in' &&
|
||||
JSON.stringify(frame.payload).includes('"mode":"desktop-fit"')
|
||||
)
|
||||
),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
await clientA.page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
)
|
||||
)
|
||||
const clientASteadyInputStart = (await readTerminalWireProbe(clientA.app)).length
|
||||
await typeMarker(clientA.page, 'A_STEADY_INPUT')
|
||||
await waitForSink(target, 'LINE:A_STEADY_INPUT:')
|
||||
const clientASteadyInputFrames = (await readTerminalWireProbe(clientA.app)).slice(
|
||||
clientASteadyInputStart
|
||||
)
|
||||
expect(activityOpcodes(clientASteadyInputFrames, clientAStreamId)).toEqual([7])
|
||||
expect(fixtureLineGrid(fixture.readSink(target.sinkPath), 'A_STEADY_INPUT')).toEqual(
|
||||
clientAGrid
|
||||
)
|
||||
|
||||
const claimsBeforeBackgroundResize = (await readTerminalWireProbe(clientB.app)).filter(
|
||||
(frame) => frame.opcode === 14
|
||||
(frame) => frame.streamId === clientBStreamId && frame.opcode === 14
|
||||
).length
|
||||
await forceDocumentUnfocused(clientB.page)
|
||||
const backgroundResizeStart = (await readTerminalWireProbe(clientB.app)).length
|
||||
await configureElectronWindow(clientB.app, 660, 460, false)
|
||||
await focusApp(clientA)
|
||||
await expect.poll(() => clientB!.page.evaluate(() => document.hasFocus())).toBe(false)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
await fitTerminalPane(clientB.page, target.webTabId)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
readTerminalWireProbe(clientB!.app).then((frames) => frames.slice(backgroundResizeStart)),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.objectContaining({ direction: 'out', opcode: 8 })]))
|
||||
const backgroundClientBGrid = clampGrid(await readPaneGrid(clientB.page, target.webTabId))
|
||||
await typeMarker(clientA.page, 'A_BACKGROUND_BARRIER')
|
||||
await waitForSink(target, 'LINE:A_BACKGROUND_BARRIER:')
|
||||
const finalWireTrace = await readTerminalWireProbe(clientB.app)
|
||||
const claimsAfterBackgroundResize = finalWireTrace.filter((frame) => frame.opcode === 14).length
|
||||
const claimsAfterBackgroundResize = finalWireTrace.filter(
|
||||
(frame) => frame.streamId === clientBStreamId && frame.opcode === 14
|
||||
).length
|
||||
const backgroundGrid = lastFixtureGrid(fixture.readSink(target.sinkPath))
|
||||
|
||||
await configureElectronWindow(electronApp, 1500, 900, true)
|
||||
await orcaPage.bringToFront()
|
||||
expect(lastFixtureGrid(fixture.readSink(target.sinkPath))).toEqual(clientAGrid)
|
||||
await typeMarker(orcaPage, 'HOST_RECLAIM')
|
||||
await waitForSink(target, 'LINE:HOST_RECLAIM:')
|
||||
expect(fixtureLineGrid(fixture.readSink(target.sinkPath), 'HOST_RECLAIM')).toEqual(hostGrid)
|
||||
await waitForGrid(target, hostGrid)
|
||||
|
||||
await focusApp(clientA)
|
||||
expect(lastFixtureGrid(fixture.readSink(target.sinkPath))).toEqual(hostGrid)
|
||||
await typeMarker(clientA.page, 'A_DETACH_OWNER')
|
||||
await waitForSink(target, 'LINE:A_DETACH_OWNER:')
|
||||
await waitForGrid(target, clientAGrid)
|
||||
await screenshotTopology(testInfo, orcaPage, [clientA, clientB])
|
||||
const versions = await captureVersions(orcaPage, [clientA, clientB])
|
||||
await disposeTerminalWireProbe(clientB.app)
|
||||
await clientA.dispose()
|
||||
createdClients.splice(createdClients.indexOf(clientA), 1)
|
||||
clientA = null
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
await waitForGrid(target, backgroundClientBGrid)
|
||||
const fallbackGrid = lastFixtureGrid(fixture.readSink(target.sinkPath))
|
||||
await clientB.dispose()
|
||||
createdClients.splice(createdClients.indexOf(clientB), 1)
|
||||
clientB = null
|
||||
await waitForGrid(target, hostGrid)
|
||||
const detachGrid = lastFixtureGrid(fixture.readSink(target.sinkPath))
|
||||
|
||||
const evidence = {
|
||||
backgroundClientBGrid,
|
||||
backgroundGrid,
|
||||
claimsAfterBackgroundResize,
|
||||
claimsBeforeBackgroundResize,
|
||||
clientAInputFrames,
|
||||
clientASteadyInputFrames,
|
||||
clientAGrid,
|
||||
clientBGrid,
|
||||
clientBInputFrames,
|
||||
clientBInputGrid,
|
||||
detachGrid,
|
||||
fallbackGrid,
|
||||
hostGrid,
|
||||
passiveAttachGrid,
|
||||
passivePreview,
|
||||
passivePreviewGrid,
|
||||
raceGrid,
|
||||
runtimeRaceTailHasGrid: runtimeRaceTail.includes(
|
||||
`SIZE:${clientBGrid.cols}x${clientBGrid.rows}`
|
||||
),
|
||||
unfocusedVisible,
|
||||
versions: await captureVersions(orcaPage, [clientB]),
|
||||
wireFrames: finalWireTrace.filter((frame) => frame.opcode === 8 || frame.opcode === 14)
|
||||
versions,
|
||||
wireFrames: finalWireTrace.filter(
|
||||
(frame) => frame.streamId === clientBStreamId && (frame.opcode === 8 || frame.opcode === 14)
|
||||
)
|
||||
}
|
||||
console.log(`[sta5050-current] ${JSON.stringify(evidence)}`)
|
||||
expect(evidence).toMatchObject({
|
||||
backgroundGrid: clientAGrid,
|
||||
claimsAfterBackgroundResize: claimsBeforeBackgroundResize,
|
||||
detachGrid: hostGrid,
|
||||
fallbackGrid: backgroundClientBGrid,
|
||||
passiveAttachGrid: clientAGrid,
|
||||
passivePreviewGrid: clientAGrid,
|
||||
raceGrid: clientAGrid,
|
||||
runtimeRaceTailHasGrid: false,
|
||||
unfocusedVisible: { focused: false, visibility: 'visible' }
|
||||
@@ -277,6 +473,9 @@ test('current clients keep viewport ownership with real activity @headful', asyn
|
||||
if (clientB) {
|
||||
await disposeTerminalWireProbe(clientB.app).catch(() => undefined)
|
||||
}
|
||||
if (clientA) {
|
||||
await disposeTerminalWireProbe(clientA.app).catch(() => undefined)
|
||||
}
|
||||
for (const client of createdClients.toReversed()) {
|
||||
await client.dispose().catch(() => undefined)
|
||||
}
|
||||
@@ -288,6 +487,81 @@ test('current clients keep viewport ownership with real activity @headful', asyn
|
||||
}
|
||||
})
|
||||
|
||||
test('host input reclaims before delayed renderer fit state @headful', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000)
|
||||
const worktreeId = await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId)
|
||||
if (!worktreeId) {
|
||||
throw new Error('headed host has no active worktree')
|
||||
}
|
||||
let target: TerminalViewportTarget | null = null
|
||||
let client: PairedElectronClient | null = null
|
||||
try {
|
||||
client = await launchPairedElectronClient(
|
||||
await createRuntimeDesktopPairingOffer(orcaPage),
|
||||
testInfo,
|
||||
'STA-5050 remote owner'
|
||||
)
|
||||
await waitForWorktree(client, worktreeId)
|
||||
target = await createViewportTerminal(client.page, client.environmentId, worktreeId, fixture)
|
||||
await configureElectronWindow(electronApp, 1500, 900, true)
|
||||
await openTerminalTab(orcaPage, worktreeId, target.hostTabId)
|
||||
await waitForSink(target, 'READY:')
|
||||
const hostGrid = await readPaneGrid(orcaPage, target.hostTabId)
|
||||
await configureElectronWindow(client.app, 700, 480, false)
|
||||
await installTerminalWireProbe(electronApp, {
|
||||
dropHostViewportClaims: true,
|
||||
holdHostFitEvents: true
|
||||
})
|
||||
await configureElectronWindow(electronApp, 1500, 900, true)
|
||||
await orcaPage.bringToFront()
|
||||
await openTerminalTab(client.page, worktreeId, target.webTabId)
|
||||
await waitForTerminalText(client.page, target.webTabId, 'READY:')
|
||||
await focusAppWithoutResize(client)
|
||||
await fitTerminalPane(client.page, target.webTabId)
|
||||
const clientGrid = clampGrid(await readPaneGrid(client.page, target.webTabId))
|
||||
await waitForGrid(target, clientGrid)
|
||||
await typeMarker(client.page, 'REMOTE_OWNER')
|
||||
await waitForSink(target, 'LINE:REMOTE_OWNER:')
|
||||
expect(fixtureLineGrid(fixture.readSink(target.sinkPath), 'REMOTE_OWNER')).toEqual(clientGrid)
|
||||
expect(clientGrid).not.toEqual(hostGrid)
|
||||
await expect
|
||||
.poll(() => readTerminalWireProbe(electronApp), { timeout: 30_000 })
|
||||
.toEqual(expect.arrayContaining([expect.objectContaining({ direction: 'in', held: true })]))
|
||||
|
||||
await forceDocumentUnfocused(orcaPage)
|
||||
const hostLayoutStart = (await readTerminalWireProbe(electronApp)).length
|
||||
await fitTerminalPane(orcaPage, target.hostTabId)
|
||||
await typeMarker(client.page, 'REMOTE_HOST_LAYOUT_BARRIER')
|
||||
await waitForSink(target, 'LINE:REMOTE_HOST_LAYOUT_BARRIER:')
|
||||
expect(
|
||||
fixtureLineGrid(fixture.readSink(target.sinkPath), 'REMOTE_HOST_LAYOUT_BARRIER')
|
||||
).toEqual(clientGrid)
|
||||
const hostLayoutFrames = (await readTerminalWireProbe(electronApp)).slice(hostLayoutStart)
|
||||
expect(activityOpcodes(hostLayoutFrames)).not.toContain(14)
|
||||
|
||||
await restoreDocumentFocus(orcaPage)
|
||||
await configureElectronWindow(electronApp, 1500, 900, true)
|
||||
await orcaPage.bringToFront()
|
||||
await typeMarker(orcaPage, 'HOST_PRE_FIT_INPUT')
|
||||
await waitForSink(target, 'LINE:HOST_PRE_FIT_INPUT:')
|
||||
expect(fixtureLineGrid(fixture.readSink(target.sinkPath), 'HOST_PRE_FIT_INPUT')).toEqual(
|
||||
hostGrid
|
||||
)
|
||||
await waitForGrid(target, hostGrid)
|
||||
} finally {
|
||||
await disposeTerminalWireProbe(electronApp).catch(() => undefined)
|
||||
await client?.dispose().catch(() => undefined)
|
||||
if (target) {
|
||||
await callLocal(orcaPage, 'terminal.closeTab', { terminal: target.terminal }).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('legacy clients retain resize-as-activity compatibility @headful', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
@@ -323,15 +597,41 @@ test('legacy clients retain resize-as-activity compatibility @headful', async ({
|
||||
await installTerminalWireProbe(legacy.app, { legacyViewportClient: true })
|
||||
await focusApp(active)
|
||||
await openTerminalTab(legacy.page, worktreeId, target.webTabId)
|
||||
await waitForGrid(target, clampGrid(await readPaneGrid(legacy.page, target.webTabId)))
|
||||
const legacyStreamId = terminalStreamId(
|
||||
await readTerminalWireProbe(legacy.app),
|
||||
target.terminal
|
||||
)
|
||||
const legacyResizeStart = (await readTerminalWireProbe(legacy.app)).length
|
||||
await configureElectronWindow(legacy.app, 660, 460, false)
|
||||
await fitTerminalPane(legacy.page, target.webTabId)
|
||||
const legacyGrid = clampGrid(await readPaneGrid(legacy.page, target.webTabId))
|
||||
await expect
|
||||
.poll(
|
||||
() => readTerminalWireProbe(legacy!.app).then((frames) => frames.slice(legacyResizeStart)),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.objectContaining({ direction: 'out', opcode: 8 })]))
|
||||
await waitForGrid(target, legacyGrid)
|
||||
const trace = await readTerminalWireProbe(legacy.app)
|
||||
const outgoingFrames = trace.filter((frame) => frame.direction === 'out')
|
||||
const outgoingFrames = trace.filter((frame) => {
|
||||
if (frame.direction !== 'out') {
|
||||
return false
|
||||
}
|
||||
const payload = frame.payload as { terminal?: unknown } | undefined
|
||||
return (
|
||||
frame.streamId === legacyStreamId ||
|
||||
(frame.opcode === 9 && payload?.terminal === target!.terminal)
|
||||
)
|
||||
})
|
||||
console.log(
|
||||
`[sta5050-legacy] ${JSON.stringify({ activeGrid, legacyGrid, trace: outgoingFrames })}`
|
||||
)
|
||||
expect(lastFixtureGrid(fixture.readSink(target.sinkPath))).toEqual(legacyGrid)
|
||||
expect(outgoingFrames.some((frame) => frame.opcode === 14)).toBe(false)
|
||||
expect(outgoingFrames.some((frame) => frame.originalOpcode === 14 && frame.opcode === 8)).toBe(
|
||||
true
|
||||
)
|
||||
expect(JSON.stringify(outgoingFrames)).not.toContain('desktopViewportClaims')
|
||||
} finally {
|
||||
if (legacy) {
|
||||
|
||||
Reference in New Issue
Block a user