From e13c47bea966d2f189a7a537cada03ee154db8db Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 25 Oct 2026 07:22:28 -0700 Subject: [PATCH] test: strengthen transport recovery and benchmark assertions (#33547) * test: strengthen transport recovery and benchmark assertions * test: use preferred array type in reconnect fixture --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../scripts/relay-json-payload-benchmark.ts | 17 +++++++- .../renderer-quadratic-scan-benchmark.mjs | 22 ++++++---- .../repo-icon-source-href-benchmark.mjs | 12 +++++- .../rich-markdown-doc-link-scan-benchmark.mjs | 13 +++--- .../scripts/session-search-scope-benchmark.ts | 6 +-- .../session-write-hot-path-benchmark.mjs | 9 ++-- .../terminal-pr-link-carry-benchmark.mjs | 16 ++++---- ...replacement-session-authentication.test.ts | 27 ++++++++++-- .../rpc-client-connect-wait-replay.test.ts | 18 ++++++++ .../rpc-client-delivery-ambiguity.test.ts | 29 ++++++++----- .../rpc-client-direct-e2ee-v2.test.ts | 13 +++++- .../rpc-client-live-recovery.test.ts | 41 +++++++++++++++---- .../rpc-client-log-redaction.test.ts | 2 + .../rpc-client-runtime-events.test.ts | 19 ++++++++- .../rpc-client-stream-registry.test.ts | 19 +++++++-- ...ient-synthesized-close-diagnostics.test.ts | 21 ++++++---- .../rpc-client-terminal-reconnect.test.ts | 40 ++++++++++-------- .../rpc-client-unauthorized-close.test.ts | 7 ++++ .../rpc-session-liveness-integration.test.ts | 25 +++++++++-- .../rpc-session-liveness-watchdog.test.ts | 35 ++++++++++++---- .../headless-terminal-split-layout.test.ts | 25 +++++------ 21 files changed, 310 insertions(+), 106 deletions(-) diff --git a/config/scripts/relay-json-payload-benchmark.ts b/config/scripts/relay-json-payload-benchmark.ts index 79ea8065e78..3197efff841 100644 --- a/config/scripts/relay-json-payload-benchmark.ts +++ b/config/scripts/relay-json-payload-benchmark.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict' import { performance } from 'node:perf_hooks' import { platform, release } from 'node:os' import { @@ -72,6 +73,20 @@ function median(values: readonly number[]): number { } function benchmarkCase(entry: BenchmarkCase): BenchmarkResult { + const payload = prepareJsonRpcPayload(entry.message) + assert.equal( + HEADER_LENGTH + payload.byteLength, + encodeLegacyJsonRpcFrame(entry.message, 0, 0).length + ) + for (let target = 0; target < entry.targets; target++) { + const id = target + 37 + const ack = 19 + assert.deepEqual( + encodePreparedJsonRpcFrame(payload, id, ack), + encodeLegacyJsonRpcFrame(entry.message, id, ack), + `${entry.name}: prepared frame differs for target ${target}` + ) + } const legacy = (iteration: number): void => runLegacy(entry.message, entry.targets, iteration) const prepared = (iteration: number): void => runPrepared(entry.message, entry.targets, iteration) for (let warmup = 0; warmup < 3; warmup++) { @@ -160,7 +175,7 @@ const lines = [ ), `checksum=${checksum}`, 'Limitations: in-process framing only; excludes dispatcher scheduling, sink/network I/O, and tail-GC.', - 'Legacy is the exact pre-change framing composition retained in this script, not a separate checkout.', + 'Legacy models a separate size estimate and JSON serialization per target using the current frame encoder.', 'The two-target rows model dispatcher fan-out; watcher batches and file streams are commonly single-target.' ] process.stdout.write(`${lines.join('\n')}\n`) diff --git a/config/scripts/renderer-quadratic-scan-benchmark.mjs b/config/scripts/renderer-quadratic-scan-benchmark.mjs index 33853ad8dd2..01b015425e4 100644 --- a/config/scripts/renderer-quadratic-scan-benchmark.mjs +++ b/config/scripts/renderer-quadratic-scan-benchmark.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Benchmark four per-keystroke/store-write projections. Scenarios 1/3/4 compare production with pre-change baseline after checking equivalence. +// Benchmark four projection workloads. Scenarios 1/3/4 compare current production with local baselines after checking equivalence. // Scenario 2 models both loops because its React hook cannot load without a renderer; it does not time production. import { spawnSync } from 'node:child_process' import { transformSync } from 'esbuild' @@ -133,7 +133,7 @@ compare({ label: 'workspace board filter (per keystroke burst)', scale: `${WORKTREES} worktrees x ${KEYSTROKES} keystrokes`, drives: 'production', - // Omitting `documents` is the pre-change shape: the index is rebuilt inside every match. + // Omitting `documents` rebuilds the index inside every match. before: () => matchAll(undefined), // The hook memoizes the index on [worktrees, repoMap]; only the match reruns per keystroke. after: () => matchAll(buildWorkspaceBoardPaletteDocuments({ worktrees: boardWorktrees, repoMap })) @@ -156,9 +156,17 @@ const groupTabById = new Map(groupTabs.map((item) => [item.id, item])) function tabProjections(findOpenFile, findGroupTab) { const editorItems = groupTabs .filter((item) => item.contentType === 'editor') - .map((item) => findOpenFile(item.entityId)) - .filter((file) => file !== undefined) - const order = tabOrder.map((itemId) => findGroupTab(itemId)?.entityId ?? itemId) + .map((item) => { + const file = findOpenFile(item.entityId) + return file ? { ...file, tabId: item.id } : null + }) + .filter((file) => file !== null) + const order = tabOrder.map((itemId) => { + const item = findGroupTab(itemId) + return item && (item.contentType === 'terminal' || item.contentType === 'browser') + ? item.entityId + : itemId + }) return [editorItems, order] } @@ -193,7 +201,7 @@ const changedEntries = Array.from({ length: CHANGED_FILES }, (_, index) => ({ path: `src/area-${index % 20}/module-${index % 60}/nested/deep/part-${index % 7}/file-${index}.ts` })) -// Pre-change `buildSourceControlTree`: identical except each ancestor path is re-joined. +// Local tree comparator re-joins ancestor paths; shares current normalization and sorting. function buildSourceControlTreeBefore(area, entries) { const makeDirectory = (dirPath, name, depth) => ({ type: 'directory', @@ -291,7 +299,7 @@ const boundaryArgs = { repoHeaderBucketByRepoId: new Map(headerRepoIds.map((id) => [id, 'ungrouped'])) } -// Pre-change `getRepoHeaderSectionEndByRepoId`: a findIndex and an indexOf per header row. +// Local boundary comparator uses findIndex and indexOf per header row. function getRepoHeaderSectionEndByRepoIdBefore(args) { const rowStarts = [] let offset = 0 diff --git a/config/scripts/repo-icon-source-href-benchmark.mjs b/config/scripts/repo-icon-source-href-benchmark.mjs index da560724a23..2eae42dc445 100644 --- a/config/scripts/repo-icon-source-href-benchmark.mjs +++ b/config/scripts/repo-icon-source-href-benchmark.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { performance } from 'node:perf_hooks' import { extractIconHref } from '../../src/main/repo-icon-source-href.ts' -// Original production expressions, preserved for the before/after measurement. +// Regex comparator retained for the before/after measurement. const html = /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i const object = @@ -34,6 +34,16 @@ function measurePair(source) { } } +for (const [source, expected] of [ + ['', '/favicon.ico'], + ["", '/shortcut.svg'], + ['{ rel: "icon", href: "/object.png?version=1" }', '/object.png'], + ["{ href: '/shortcut-object.svg', rel: 'shortcut icon' }", '/shortcut-object.svg'] +]) { + assert.equal(original(source), expected) + assert.equal(extractIconHref(source), expected) +} + const results = [] for (const size of [8192, 16384, 32768]) { for (const variant of ['no icon', 'rel without href', 'unterminated link starts']) { diff --git a/config/scripts/rich-markdown-doc-link-scan-benchmark.mjs b/config/scripts/rich-markdown-doc-link-scan-benchmark.mjs index e0f88bd4189..a73aa0b13a0 100644 --- a/config/scripts/rich-markdown-doc-link-scan-benchmark.mjs +++ b/config/scripts/rich-markdown-doc-link-scan-benchmark.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Measures the complete ProseMirror doc traversal used by the two doc-link plugins. +// Measures doc-link scans over plain paragraph fixtures built from markdown source lines. import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { join } from 'node:path' @@ -99,7 +99,10 @@ function createFixture(doc, nonce) { function median(samples) { const sorted = [...samples].sort((a, b) => a - b) - return sorted[Math.floor(sorted.length / 2)] + const middle = sorted.length / 2 + return Number.isInteger(middle) + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[Math.floor(middle)] } function measureCorpus(docs) { @@ -174,7 +177,7 @@ const large = docs.filter((doc) => doc.size > 3000) const biggest = docs.reduce((a, b) => (b.size > a.size ? b : a)) const pad = (value, width) => String(value).padStart(width) -console.log('Doc-link ProseMirror traversal, per editor transaction. Lower is better.') +console.log('Doc-link ProseMirror scan over plain paragraph fixtures. Lower is better.') console.log( `docs=${docs.length} (>3KB: ${large.length}) iterations=${ITERATIONS} (interleaved median)` ) @@ -183,7 +186,7 @@ console.log( ) for (const [label, set] of [ - ['all repo markdown', docs], + ['repo markdown sample', docs], ['docs over 3 KB', large], [`biggest (${biggest.file.split('/').pop()})`, [biggest]] ]) { @@ -198,5 +201,5 @@ for (const [label, set] of [ ) } console.log( - '\nAuto-conversion pays this once per keystroke; preview decorations pay it once\nper keystroke and once per caret move.' + '\nEach round scans one document from the selected corpus. Fixtures omit Markdown parsing,\ncode marks/blocks, link conversion, resolution and decorations; this is not full plugin timing.' ) diff --git a/config/scripts/session-search-scope-benchmark.ts b/config/scripts/session-search-scope-benchmark.ts index 19cc8750563..cb2d42f5be6 100644 --- a/config/scripts/session-search-scope-benchmark.ts +++ b/config/scripts/session-search-scope-benchmark.ts @@ -90,11 +90,11 @@ function queries(): string[] { const run = (index: number, length: number): string => Array.from({ length }, (_unused, step) => QUERIES[(index + step) % QUERIES.length]).join(' ') return [ - // Two terms, unquoted: not literal, so straight to OR. + // Unquoted pairs use the current phrase/AND/OR retrieval ladder. ...QUERIES, - // Quoted pair reaches phrase: fourteen-word corpus contains every pair. + // Quoted pairs sample phrase matching in the fourteen-word corpus. ...QUERIES.map((query) => `"${query}"`), - // Quoted eight-term run is absent from corpus, forcing phrase miss then AND. + // Longer quoted runs sample fallback routes; reports group by the observed route. ...QUERIES.map((_query, index) => `"${run(index, 4)}"`) ] } diff --git a/config/scripts/session-write-hot-path-benchmark.mjs b/config/scripts/session-write-hot-path-benchmark.mjs index 8820d83521c..9943ed440dc 100644 --- a/config/scripts/session-write-hot-path-benchmark.mjs +++ b/config/scripts/session-write-hot-path-benchmark.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Benchmark production capTerminalScrollbackSessionBuffer/remapPaneKeys against equivalent pre-change baselines on every-write CPU paths. +// Benchmark production scrollback capping and pane-key remapping against scan/rebuild baselines. // Snapshot disk rewrite is measured separately (#18764). import { spawnSync } from 'node:child_process' import { performance } from 'node:perf_hooks' @@ -65,7 +65,8 @@ const { isTerminalLeafId, makePaneKey, parsePaneKey } = await import( function median(samples) { const sorted = [...samples].sort((left, right) => left - right) - return sorted[Math.floor(sorted.length / 2)] + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2 } function timeRounds(run) { @@ -87,7 +88,7 @@ function report(label, baselineMs, currentMs, extra = '') { return speedup } -// Verbatim pre-change capTerminalScrollbackSessionBuffer; measureUtf8ByteLength itself is unchanged. +// Scan UTF-8 bytes before clamping; compare with the production budget probe. function baselineCapScrollbackBuffer(buffer) { if ( buffer.length <= TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT && @@ -149,7 +150,7 @@ for (let index = 0; index < PANE_KEYS; index += 1) { leaves.set(leafId, leafId) } -// Verbatim pre-change remapPaneKeys: parses every key, then rebuilds the object regardless. +// Parse every key and rebuild the object; compare steady-state reuse. function baselineRemapPaneKeys(values, remap) { if (!values || Object.keys(values).length === 0) { return { values, changed: false } diff --git a/config/scripts/terminal-pr-link-carry-benchmark.mjs b/config/scripts/terminal-pr-link-carry-benchmark.mjs index 28af9285834..716733dc5c8 100644 --- a/config/scripts/terminal-pr-link-carry-benchmark.mjs +++ b/config/scripts/terminal-pr-link-carry-benchmark.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -// Measure full-chunk scheme scans against bounded 512-byte carry suffix on each PTY chunk, including early-out without /pull/. -// Arms mirror production; re-read source constants at startup so changed cap/schemes fail rather than benchmarking stale assumptions. +// Model full-chunk and bounded carry scans, including an includes() early-out without /pull/. +// Source constants are checked; the mirrored algorithms still require manual review for drift. import { readFileSync } from 'node:fs' import { performance } from 'node:perf_hooks' import { fileURLToPath } from 'node:url' @@ -61,7 +61,7 @@ function endsWithHttpSchemePrefixFragment(value) { return '' } -// Pre-fix implementation, kept verbatim for comparison. +// Full-chunk comparator for the bounded-suffix model. function carryBefore(value) { const schemeIndex = Math.max(...HTTP_SCHEME_PREFIXES.map((prefix) => value.lastIndexOf(prefix))) if (schemeIndex !== -1) { @@ -181,10 +181,10 @@ for (const bytes of SIZES) { } const pad = (value, width) => String(value).padStart(width) -console.log('PR-link carry scan, per PTY chunk. Speedup = before / after (>1 is faster).') +console.log('Modeled PR-link carry scan, per chunk. Ratio = full-chunk / bounded (>1 is faster).') console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`) console.log( - `${pad('chunk', 9)} ${pad('carry only', 12)} ${pad('detector path', 15)} ${pad('fragment tail', 15)}` + `${pad('chunk', 9)} ${pad('carry only', 12)} ${pad('modeled path', 15)} ${pad('fragment tail', 15)}` ) for (const row of rows) { console.log( @@ -193,7 +193,7 @@ for (const row of rows) { } console.log( '\ncarry only = the scan this change bounds, in isolation.\n' + - 'detector path = includes() + carry, i.e. what the PTY hot path actually saves.\n' + - 'fragment tail = chunk ending mid-scheme, where the new code pays an extra\n' + - ' bounded probe. ~1x means the fallback costs nothing material.' + 'modeled path = includes() + carry; excludes normalization, parsing and callbacks.\n' + + 'fragment tail = chunk ending mid-scheme, including the fallback probe.\n' + + 'Ratios describe these local models and fixtures; the full detector is not executed.' ) diff --git a/mobile/src/transport/replacement-session-authentication.test.ts b/mobile/src/transport/replacement-session-authentication.test.ts index 099fe64fb4d..a235b78fc88 100644 --- a/mobile/src/transport/replacement-session-authentication.test.ts +++ b/mobile/src/transport/replacement-session-authentication.test.ts @@ -22,6 +22,9 @@ class FakeSession implements RpcClient { this.listeners.add(listener) return () => this.listeners.delete(listener) } + listenerCount(): number { + return this.listeners.size + } setState(state: ConnectionState): void { this.state = state for (const listener of this.listeners) { @@ -64,6 +67,8 @@ describe('waitForAuthenticated', () => { const outcome = await settle(waiting) expect(outcome.error).toBeInstanceOf(ReplacementAuthenticationTimeoutError) expect(outcome.error?.message).toBe('replacement session authentication timed out') + expect(session.listenerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(0) }) // Allow lock-contended cell assignment past the opening deadline through per-stage budgets. @@ -83,6 +88,8 @@ describe('waitForAuthenticated', () => { await vi.advanceTimersByTimeAsync(20_000) session.setState('connected') await expect(waiting).resolves.toBeUndefined() + expect(session.listenerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(0) }) it('bounds a cell that took the dial and never answers, naming the stage', async () => { @@ -96,22 +103,35 @@ describe('waitForAuthenticated', () => { await vi.advanceTimersByTimeAsync(1) const outcome = await settle(waiting) expect(outcome.error).toBeInstanceOf(ReplacementAuthenticationTimeoutError) - expect((outcome.error as ReplacementAuthenticationTimeoutError).stage).toBe('awaiting-hello') + if (!(outcome.error instanceof ReplacementAuthenticationTimeoutError)) { + throw new Error('Expected a stage timeout') + } + expect(outcome.error.stage).toBe('awaiting-hello') expect(outcome.error?.message).toBe( 'replacement session authentication timed out (awaiting-hello, 30s)' ) + expect(session.listenerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(0) }) it('keeps the caller bound while the socket never opens', async () => { const session = new FakeRelaySession() const waiting = waitForAuthenticated(session, 12_000) waiting.catch(() => {}) - await vi.advanceTimersByTimeAsync(12_000) + await vi.advanceTimersByTimeAsync(11_999) + expect((await settle(waiting)).status).toBe('pending') + await vi.advanceTimersByTimeAsync(1) const outcome = await settle(waiting) - expect((outcome.error as ReplacementAuthenticationTimeoutError).stage).toBe('opening') + expect(outcome.error).toBeInstanceOf(ReplacementAuthenticationTimeoutError) + if (!(outcome.error instanceof ReplacementAuthenticationTimeoutError)) { + throw new Error('Expected an opening timeout') + } + expect(outcome.error.stage).toBe('opening') expect(outcome.error?.message).toBe( 'replacement session authentication timed out (opening, 12s)' ) + expect(session.listenerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(0) }) it('ignores stage advances after the wait has settled', async () => { @@ -119,6 +139,7 @@ describe('waitForAuthenticated', () => { const waiting = waitForAuthenticated(session, 12_000) session.setState('disconnected') await expect(waiting).rejects.toThrow('replacement session disconnected') + expect(session.listenerCount()).toBe(0) session.dialStage.advance('awaiting-hello') expect(vi.getTimerCount()).toBe(0) }) diff --git a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts index 82653db4265..a41d4ce06c5 100644 --- a/mobile/src/transport/rpc-client-connect-wait-replay.test.ts +++ b/mobile/src/transport/rpc-client-connect-wait-replay.test.ts @@ -86,6 +86,7 @@ describe('mobile rpc-client connect-wait replay', () => { beforeEach(() => { vi.useFakeTimers() mockSockets.length = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client. globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket }) @@ -115,6 +116,7 @@ describe('mobile rpc-client connect-wait replay', () => { await vi.advanceTimersByTimeAsync(500) mockSockets[1]!.open() await vi.advanceTimersByTimeAsync(0) + expect(client.getState()).toBe('connected') expect(terminalSendFrames(mockSockets[1]!)).toEqual([]) } finally { client.close() @@ -141,6 +143,22 @@ describe('mobile rpc-client connect-wait replay', () => { mockSockets[1]!.open() await vi.advanceTimersByTimeAsync(0) expect(terminalSendFrames(mockSockets[1]!)).toHaveLength(1) + const frame: unknown = JSON.parse( + terminalSendFrames(mockSockets[1]!)[0]!.replace(/^encrypted:/, '') + ) + expect(frame).toEqual({ + id: expect.any(String), + deviceToken: 'token', + method: 'terminal.send', + params: { terminal: 't1', text: 'YZZY' } + }) + if (!frame || typeof frame !== 'object' || !('id' in frame) || typeof frame.id !== 'string') { + throw new Error('Expected the written request ID') + } + mockSockets[1]!.receive( + `encrypted:${JSON.stringify({ id: frame.id, ok: true, result: { sent: true } })}` + ) + await expect(request).resolves.toMatchObject({ ok: true, result: { sent: true } }) } finally { client.close() await request.catch(() => undefined) diff --git a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts index 1985e0ffffb..e719b80096d 100644 --- a/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts +++ b/mobile/src/transport/rpc-client-delivery-ambiguity.test.ts @@ -70,11 +70,20 @@ class MockWebSocket { const mockSockets: MockWebSocket[] = [] const originalWebSocket = globalThis.WebSocket +function readRequest(payload: string): { id: string; method: string } | null { + const value: unknown = JSON.parse(payload.replace(/^encrypted:/, '')) + return value && + typeof value === 'object' && + 'id' in value && + typeof value.id === 'string' && + 'method' in value && + typeof value.method === 'string' + ? { id: value.id, method: value.method } + : null +} + function hasSentRequest(socket: MockWebSocket, method: string): boolean { - return socket.sent.some( - (payload) => - (JSON.parse(payload.replace(/^encrypted:/, '')) as { method: string }).method === method - ) + return socket.sent.some((payload) => readRequest(payload)?.method === method) } function connectAuthenticated(): { client: ReturnType; socket: MockWebSocket } { @@ -91,6 +100,7 @@ describe('mobile rpc-client delivery ambiguity marking', () => { beforeEach(() => { vi.useFakeTimers() mockSockets.length = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client. globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket }) @@ -206,12 +216,11 @@ describe('mobile rpc-client delivery ambiguity marking', () => { ) const authProbe = client.sendRequest('status.get') await Promise.resolve() - const probe = socket.sent - .map( - (payload) => - JSON.parse(payload.replace(/^encrypted:/, '')) as { id: string; method: string } - ) - .find((request) => request.method === 'status.get')! + expect(hasSentRequest(socket, 'terminal.send')).toBe(true) + const probe = socket.sent.map(readRequest).find((request) => request?.method === 'status.get') + if (!probe) { + throw new Error('Expected the written status probe') + } socket.receive( `encrypted:${JSON.stringify({ id: probe.id, ok: false, error: { code: 'unauthorized', message: 'Unauthorized' } })}` diff --git a/mobile/src/transport/rpc-client-direct-e2ee-v2.test.ts b/mobile/src/transport/rpc-client-direct-e2ee-v2.test.ts index c46af3ffc2e..7ac63e8191a 100644 --- a/mobile/src/transport/rpc-client-direct-e2ee-v2.test.ts +++ b/mobile/src/transport/rpc-client-direct-e2ee-v2.test.ts @@ -72,6 +72,7 @@ async function nextSocket(): Promise { for (let elapsed = 0; sockets.length === count && elapsed < 60_000; elapsed += 100) { await vi.advanceTimersByTimeAsync(100) } + expect(sockets).toHaveLength(count + 1) return sockets.at(-1)! } @@ -81,7 +82,16 @@ function firstFrame(socket: MockWebSocket): unknown { function requestIdOf(plaintext: string | null): string { const value: unknown = JSON.parse(plaintext ?? 'null') - return value && typeof value === 'object' && 'id' in value ? String(value.id) : '' + expect(value).toEqual({ + id: expect.any(String), + deviceToken: 'device-token', + method: 'status.get', + params: {} + }) + if (!value || typeof value !== 'object' || !('id' in value) || typeof value.id !== 'string') { + throw new Error('Expected the sealed request ID') + } + return value.id } // Plays the current host's direct v2 handshake with real crypto. @@ -119,6 +129,7 @@ describe('direct connections use E2EE v2 framing', () => { sockets.length = 0 storage.clear() resetE2EEV2HostLatchForTests() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client. globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket }) diff --git a/mobile/src/transport/rpc-client-live-recovery.test.ts b/mobile/src/transport/rpc-client-live-recovery.test.ts index 7a57972ad77..66a207cc0bb 100644 --- a/mobile/src/transport/rpc-client-live-recovery.test.ts +++ b/mobile/src/transport/rpc-client-live-recovery.test.ts @@ -1,8 +1,7 @@ -// Exercise real sockets, timers and NaCl against half-open mobile/Tailscale paths (#5049). +// Exercise legacy framing with real sockets, timers and NaCl against half-open mobile/Tailscale paths (#5049). // ORCA_MOBILE_LIVE_REPRO=1 enables the short case; ORCA_MOBILE_LIVE_REPRO_FULL=1 adds the roughly eight-minute recovery loop. import { afterEach, describe, expect, it, vi } from 'vitest' import { randomBytes } from 'node:crypto' -import type { AddressInfo } from 'node:net' import nacl from 'tweetnacl' import { WebSocketServer, type WebSocket as ServerSocket } from 'ws' import { connect, type RpcClient } from './rpc-client' @@ -12,6 +11,8 @@ vi.mock('expo-crypto', () => ({ getRandomBytes: (n: number) => new Uint8Array(randomBytes(n)) })) +vi.mock('./direct-e2ee-framing-policy', () => import('./legacy-direct-framing-test-policy')) + const RUN_LIVE = process.env.ORCA_MOBILE_LIVE_REPRO === '1' || !!process.env.ORCA_MOBILE_LIVE_REPRO_FULL const RUN_FULL = process.env.ORCA_MOBILE_LIVE_REPRO_FULL === '1' @@ -58,7 +59,16 @@ function startServer(port = 0): Promise { } const msg = typeof data === 'string' ? data : data.toString('utf-8') if (!sharedKey) { - const hello = JSON.parse(msg) as { publicKeyB64: string } + const hello: unknown = JSON.parse(msg) + if ( + !hello || + typeof hello !== 'object' || + !('publicKeyB64' in hello) || + typeof hello.publicKeyB64 !== 'string' + ) { + ws.terminate() + return + } const clientKey = Uint8Array.from(Buffer.from(hello.publicKeyB64, 'base64')) sharedKey = nacl.box.before(clientKey, serverKeyPair.secretKey) ws.send(JSON.stringify({ type: 'e2ee_ready' })) @@ -68,14 +78,25 @@ function startServer(port = 0): Promise { if (!plaintext) { return } - const request = JSON.parse(plaintext) as { id?: string; type?: string; deviceToken?: string } + const request: unknown = JSON.parse(plaintext) + if (!request || typeof request !== 'object') { + return + } if (!authenticated) { - if (request.type === 'e2ee_auth' && request.deviceToken === AUTH_TOKEN) { + if ( + 'type' in request && + request.type === 'e2ee_auth' && + 'deviceToken' in request && + request.deviceToken === AUTH_TOKEN + ) { authenticated = true ws.send(e2eeEncrypt(JSON.stringify({ type: 'e2ee_authenticated' }), sharedKey)) } return } + if (!('id' in request) || typeof request.id !== 'string') { + return + } ws.send( e2eeEncrypt(JSON.stringify({ id: request.id, ok: true, result: { up: true } }), sharedKey) ) @@ -85,7 +106,11 @@ function startServer(port = 0): Promise { } function serverPort(wss: WebSocketServer): number { - return (wss.address() as AddressInfo).port + const address = wss.address() + if (!address || typeof address === 'string') { + throw new Error('Expected an assigned loopback port') + } + return address.port } function stopServer(wss: WebSocketServer): Promise { @@ -155,7 +180,7 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => { ) it.runIf(RUN_FULL)( - 'repro: parked retry loop stays stuck until the foreground nudge', + 'foreground nudge accelerates recovery while the trickle retry is pending', { timeout: 600_000 }, async () => { wss = await startServer() @@ -172,7 +197,7 @@ describe.runIf(RUN_LIVE)('live foreground recovery (issue #5049)', () => { expect(c.getState()).toBe('reconnecting') wss = await startServer(port) - // Keep the server healthy long enough to distinguish automatic recovery from a loop waiting for foreground. + // The next 90-second trickle retry is still pending during this observation. await sleep(70_000) expect(c.getState()).not.toBe('connected') diff --git a/mobile/src/transport/rpc-client-log-redaction.test.ts b/mobile/src/transport/rpc-client-log-redaction.test.ts index ff893cdde1a..7c66406ec61 100644 --- a/mobile/src/transport/rpc-client-log-redaction.test.ts +++ b/mobile/src/transport/rpc-client-log-redaction.test.ts @@ -43,6 +43,7 @@ afterEach(() => { describe('mobile rpc-client connection logs', () => { it('never exposes endpoint query credentials', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client. globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket const logs: ConnectionLogEntry[] = [] const endpoint = 'wss://desktop.example:7443/runtime?token=super-secret&route=private' @@ -60,6 +61,7 @@ describe('mobile rpc-client connection logs', () => { }) it('does not expose URL credentials in malformed legacy endpoints', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client. globalThis.WebSocket = NeverOpeningWebSocket as unknown as typeof WebSocket const logs: ConnectionLogEntry[] = [] diff --git a/mobile/src/transport/rpc-client-runtime-events.test.ts b/mobile/src/transport/rpc-client-runtime-events.test.ts index cd6cd7e3708..17fb74c5356 100644 --- a/mobile/src/transport/rpc-client-runtime-events.test.ts +++ b/mobile/src/transport/rpc-client-runtime-events.test.ts @@ -68,7 +68,23 @@ const originalWebSocket = globalThis.WebSocket function sentRequests(socket: RuntimeEventTestSocket, method: string): SentRequest[] { return socket.sent - .map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRequest) + .flatMap((payload) => { + const value: unknown = JSON.parse(payload.replace(/^encrypted:/, '')) + return value && + typeof value === 'object' && + 'id' in value && + typeof value.id === 'string' && + 'method' in value && + typeof value.method === 'string' + ? [ + { + id: value.id, + method: value.method, + params: 'params' in value ? value.params : undefined + } + ] + : [] + }) .filter((request) => request.method === method) } @@ -101,6 +117,7 @@ describe('runtime client-event stream disposal', () => { beforeEach(() => { vi.useFakeTimers() sockets.length = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the socket constructor, states, handlers, send and close consumed by the client. globalThis.WebSocket = RuntimeEventTestSocket as unknown as typeof WebSocket }) diff --git a/mobile/src/transport/rpc-client-stream-registry.test.ts b/mobile/src/transport/rpc-client-stream-registry.test.ts index 67164b887af..a9c05d45f5c 100644 --- a/mobile/src/transport/rpc-client-stream-registry.test.ts +++ b/mobile/src/transport/rpc-client-stream-registry.test.ts @@ -23,7 +23,7 @@ function createRegistry(initialState: ConnectionState = 'connected') { if (state !== 'connected') { return false } - sent.push(request as SentRequest) + sent.push(request) return true } }) @@ -72,6 +72,9 @@ describe('RpcClientStreamRegistry', () => { registry.markForReplay() registry.replayAfterAuthentication() registry.handleBinary(terminalOutput(7, 'stale')) + registry.handleResponse(streamingResponse(first.id, { type: 'subscribed', streamId: 8 })) + registry.handleBinary(terminalOutput(8, 'after')) + registry.handleBinary(terminalOutput(7, 'still-stale')) expect(sent[1]).toMatchObject({ id: first.id, @@ -80,7 +83,9 @@ describe('RpcClientStreamRegistry', () => { }) expect(events).toEqual([ { type: 'subscribed', streamId: 7 }, - { type: 'data', streamId: 7, chunk: 'before' } + { type: 'data', streamId: 7, chunk: 'before' }, + { type: 'subscribed', streamId: 8 }, + { type: 'data', streamId: 8, chunk: 'after' } ]) }) @@ -383,8 +388,11 @@ describe('RpcClientStreamRegistry', () => { it('ends one transcript stream on dispose and leaves a sibling on the same socket (U-03)', () => { const { registry, sent } = createRegistry() const disposeFirst = registry.subscribe('agentSession.subscribe', { sessionId: 's1' }, () => {}) - registry.subscribe('agentSession.subscribe', { sessionId: 's2' }, () => {}) - const [first] = sent + const siblingEvents: unknown[] = [] + registry.subscribe('agentSession.subscribe', { sessionId: 's2' }, (event) => + siblingEvents.push(event) + ) + const [first, sibling] = sent disposeFirst() expect(sent.at(-1)).toMatchObject({ @@ -392,6 +400,9 @@ describe('RpcClientStreamRegistry', () => { params: { sessionId: 's1', subscriptionId: first!.id } }) expect(sent.filter((request) => request.method === 'agentSession.unsubscribe')).toHaveLength(1) + registry.handleResponse(streamingResponse(sibling!.id, { type: 'chunk', text: 'still active' })) + expect(siblingEvents).toEqual([{ type: 'chunk', text: 'still active' }]) + expect(registry.size()).toBe(1) }) it('names the terminal request it sent when unsubscribing, and keeps the slot for older hosts', () => { diff --git a/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts b/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts index 7215ffa1f00..ec1a8bfab7c 100644 --- a/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts +++ b/mobile/src/transport/rpc-client-synthesized-close-diagnostics.test.ts @@ -80,7 +80,7 @@ function lastSocket(): WedgedWebSocket { function openConnectionLogs(spy: MockInstance): Record[] { return spy.mock.calls .filter((call) => call[0] === '[net] openConnection') - .map((call) => call[1] as Record) + .map((call) => logFields(call[1]) ?? {}) } describe('synthesized close diagnostics', () => { @@ -90,6 +90,7 @@ describe('synthesized close diagnostics', () => { vi.useFakeTimers() recordSocketDeadlineAppState('active') sockets.length = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client. globalThis.WebSocket = WedgedWebSocket as unknown as typeof WebSocket logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) }) @@ -245,18 +246,18 @@ describe('synthesized close diagnostics', () => { // Deliver the old close during the replacement socket’s lifetime. await vi.advanceTimersByTimeAsync(200) wedged.deliverClose(1006) - const staleCloseLog = logSpy.mock.calls.findLast( - (call) => call[0] === '[net] ws.onclose' - )?.[1] as Record | undefined + const staleCloseLog = logFields( + logSpy.mock.calls.findLast((call) => call[0] === '[net] ws.onclose')?.[1] + ) expect(staleCloseLog?.constructToCloseMs).toBe(5_700) expect(staleCloseLog?.aliveMs).toBeNull() expect(staleCloseLog?.inboundIdleMs).toBe(5_600) await vi.advanceTimersByTimeAsync(300) lastSocket().deliverClose(1006) - const closeLog = logSpy.mock.calls.findLast((call) => call[0] === '[net] ws.onclose')?.[1] as - | Record - | undefined + const closeLog = logFields( + logSpy.mock.calls.findLast((call) => call[0] === '[net] ws.onclose')?.[1] + ) expect(closeLog?.constructToCloseMs).toBe(500) expect(closeLog?.aliveMs).toBe(500) expect(closeLog?.inboundIdleMs).toBe(500) @@ -334,3 +335,9 @@ describe('synthesized close diagnostics', () => { client.close() }) }) + +function logFields(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' + ? Object.fromEntries(Object.entries(value)) + : undefined +} diff --git a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts index 58b93cf6835..d736a76db73 100644 --- a/mobile/src/transport/rpc-client-terminal-reconnect.test.ts +++ b/mobile/src/transport/rpc-client-terminal-reconnect.test.ts @@ -70,25 +70,26 @@ function authenticate(socket: MockWebSocket): void { socket.receive('encrypted:{"type":"e2ee_authenticated"}') } -function sentRequest(socket: MockWebSocket, method: string): { id: string } { - for (const payload of socket.sent) { - const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as { - id: string - method: string - } - if (decoded.method === method) { - return { id: decoded.id } - } - } - throw new Error(`Request not sent: ${method}`) +function sentRequests(socket: MockWebSocket, method: string): { id: string }[] { + return socket.sent.flatMap((payload) => { + const value: unknown = JSON.parse(payload.replace(/^encrypted:/, '')) + return value && + typeof value === 'object' && + 'method' in value && + value.method === method && + 'id' in value && + typeof value.id === 'string' + ? [{ id: value.id }] + : [] + }) } -function sentRequests(socket: MockWebSocket, method: string): Array<{ id: string }> { - return socket.sent - .map( - (payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as { id: string; method: string } - ) - .filter((request) => request.method === method) +function sentRequest(socket: MockWebSocket, method: string): { id: string } { + const request = sentRequests(socket, method)[0] + if (!request) { + throw new Error(`Request not sent: ${method}`) + } + return request } function encryptedStreamingReady(id: string, streamId: number): string { @@ -121,6 +122,7 @@ describe('rpc-client terminal reconnect streams', () => { beforeEach(() => { vi.useFakeTimers() mockSockets.length = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the socket constructor, states, handlers, send and close consumed by the client. globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket }) @@ -148,9 +150,13 @@ describe('rpc-client terminal reconnect streams', () => { await vi.advanceTimersByTimeAsync(500) const second = mockSockets.at(-1)! + expect(mockSockets).toHaveLength(2) + expect(second).not.toBe(first) authenticate(second) + expect(client.getState()).toBe('connected') expect(sentRequests(second, 'terminal.subscribe')).toHaveLength(1) const resumedSubscribe = sentRequest(second, 'terminal.subscribe') + expect(resumedSubscribe.id).toBe(initialSubscribe.id) second.receive(encryptedStreamingReady(resumedSubscribe.id, 77)) second.receive(encodeTerminalOutput(77, 'after-reconnect')) second.receive(encodeTerminalOutput(76, 'stale-before-reconnect')) diff --git a/mobile/src/transport/rpc-client-unauthorized-close.test.ts b/mobile/src/transport/rpc-client-unauthorized-close.test.ts index 1f18528746c..fdd9943b0c5 100644 --- a/mobile/src/transport/rpc-client-unauthorized-close.test.ts +++ b/mobile/src/transport/rpc-client-unauthorized-close.test.ts @@ -80,6 +80,7 @@ describe('unauthorized close-code mapping (silent 4001)', () => { beforeEach(() => { vi.useFakeTimers() mockSockets.length = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This socket fixture supplies the constructor, states, handlers, send and close consumed by the real client. globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket }) @@ -96,6 +97,7 @@ describe('unauthorized close-code mapping (silent 4001)', () => { if (i > 0) { await vi.advanceTimersByTimeAsync(1_000) } + expect(mockSockets).toHaveLength(i + 1) const socket = lastSocket() socket.open() socket.receive(JSON.stringify({ type: 'e2ee_ready' })) @@ -122,6 +124,8 @@ describe('unauthorized close-code mapping (silent 4001)', () => { await vi.advanceTimersByTimeAsync(500) const next = lastSocket() + expect(next).not.toBe(first) + expect(mockSockets).toHaveLength(2) next.open() next.receive(JSON.stringify({ type: 'e2ee_ready' })) next.receive('encrypted:{"type":"e2ee_authenticated"}') @@ -138,6 +142,7 @@ describe('unauthorized close-code mapping (silent 4001)', () => { if (i > 0) { await vi.advanceTimersByTimeAsync(1_000) } + expect(mockSockets).toHaveLength(i + 1) const socket = lastSocket() socket.open() socket.receive(JSON.stringify({ type: 'e2ee_ready' })) @@ -157,8 +162,10 @@ describe('unauthorized close-code mapping (silent 4001)', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') for (let i = 0; i < 5; i++) { + const previousCount = mockSockets.length if (i > 0) { await vi.advanceTimersByTimeAsync(60_000) + expect(mockSockets.length).toBeGreaterThan(previousCount) } const socket = lastSocket() socket.open() diff --git a/mobile/src/transport/rpc-session-liveness-integration.test.ts b/mobile/src/transport/rpc-session-liveness-integration.test.ts index aaccc8b71fe..042978611c8 100644 --- a/mobile/src/transport/rpc-session-liveness-integration.test.ts +++ b/mobile/src/transport/rpc-session-liveness-integration.test.ts @@ -73,6 +73,7 @@ const originalWebSocket = globalThis.WebSocket beforeEach(() => { vi.useFakeTimers() sockets.length = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the socket constructor, states, handlers, send and close consumed by the client. globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket }) @@ -126,7 +127,7 @@ describe('physical session liveness', () => { }) }) await Promise.resolve() - await vi.advanceTimersByTimeAsync(8_000) + await vi.advanceTimersByTimeAsync(24_000) expect(socket.close).not.toHaveBeenCalled() expect(client.getState()).toBe('connected') @@ -179,11 +180,27 @@ describe('physical session liveness', () => { client.notifyForeground() const request = socket.sent .map((payload) => payload.replace(/^encrypted:/, '')) - .map((payload) => JSON.parse(payload) as { id?: string; method?: string }) - .findLast((payload) => payload.method === 'status.get') + .flatMap((payload) => { + const value: unknown = JSON.parse(payload) + return value && + typeof value === 'object' && + 'method' in value && + value.method === 'status.get' && + 'id' in value && + typeof value.id === 'string' + ? [{ id: value.id }] + : [] + }) + .at(-1) + expect(socket.sent.filter((payload) => payload.includes('status.get'))).toHaveLength( + index + 1 + ) + if (!request) { + throw new Error('Expected a written liveness probe') + } socket.onmessage?.({ data: `encrypted:${JSON.stringify({ - id: request?.id, + id: request.id, ok: false, error: { code: 'unauthorized', message: 'Unauthorized' } })}` diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.test.ts b/mobile/src/transport/rpc-session-liveness-watchdog.test.ts index f55418d1fc5..cddc394ae43 100644 --- a/mobile/src/transport/rpc-session-liveness-watchdog.test.ts +++ b/mobile/src/transport/rpc-session-liveness-watchdog.test.ts @@ -114,41 +114,54 @@ describe('RpcSessionLivenessWatchdog', () => { let now = 0 let callback: (() => void) | undefined const terminate = vi.fn() + const sendProbe = vi.fn(() => true) const watchdog = new RpcSessionLivenessWatchdog({ transport: 'relay', idleProbeMs: null, probeTimeoutMs: 4_000, missedProbeLimit: 2, - sendProbe: () => true, + sendProbe, terminate, now: () => now, setTimer: (next) => { callback = next - return 1 as unknown as ReturnType + return setTimeout(() => {}, 0) }, clearTimer: () => {} }) const identity = {} watchdog.start(identity) watchdog.probeNow(identity) + expect(sendProbe).toHaveBeenCalledOnce() + if (!callback) { + throw new Error('Expected a scheduled probe deadline') + } now = 8_000 - callback?.() + callback() + expect(sendProbe).toHaveBeenCalledTimes(2) now += 4_000 - callback?.() + callback() + expect(sendProbe).toHaveBeenCalledTimes(3) expect(terminate).not.toHaveBeenCalled() + now += 4_000 + callback() + expect(terminate).toHaveBeenCalledOnce() + expect(terminate).toHaveBeenCalledWith(identity) }) it('invalidates late callbacks after identity replacement', () => { const callbacks: (() => void)[] = [] const terminate = vi.fn() + const sendProbe = vi.fn(() => true) + let now = 0 const watchdog = new RpcSessionLivenessWatchdog({ transport: 'direct', - sendProbe: () => true, + sendProbe, terminate, - now: () => 0, + now: () => now, setTimer: (callback) => { callbacks.push(callback) - return callbacks.length as unknown as ReturnType + return setTimeout(() => {}, 0) }, clearTimer: () => {} }) @@ -156,8 +169,14 @@ describe('RpcSessionLivenessWatchdog', () => { const replacement = {} watchdog.start(first) watchdog.probeNow(first) + expect(callbacks).toHaveLength(2) + const staleCallbacks = [...callbacks] watchdog.start(replacement) - callbacks.forEach((callback) => callback()) + expect(callbacks).toHaveLength(3) + now = 20_000 + staleCallbacks.forEach((callback) => callback()) + expect(sendProbe).toHaveBeenCalledOnce() + expect(callbacks).toHaveLength(3) expect(terminate).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/headless-terminal-split-layout.test.ts b/src/main/runtime/headless-terminal-split-layout.test.ts index 362d7bf078f..de45a9ce372 100644 --- a/src/main/runtime/headless-terminal-split-layout.test.ts +++ b/src/main/runtime/headless-terminal-split-layout.test.ts @@ -151,6 +151,17 @@ describe('buildHeadlessTerminalSplitLayout (headless split persistence)', () => { leafId: 'leaf-c', ptyId: 'pty-c', splitFromLeafId: 'leaf-a', direction: 'vertical' } ) + expect(next.root).toEqual({ + type: 'split', + direction: 'horizontal', + first: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'leaf-a' }, + second: { type: 'leaf', leafId: 'leaf-c' } + }, + second: { type: 'leaf', leafId: 'leaf-b' } + }) expect(countTerminalLayoutLeaves(next.root)).toBe(3) expect(next.ptyIdsByLeafId).toEqual({ 'leaf-a': 'pty-a', @@ -158,18 +169,4 @@ describe('buildHeadlessTerminalSplitLayout (headless split persistence)', () => 'leaf-c': 'pty-c' }) }) - - it('does not collapse — the persisted layout keeps both leaves (regression guard)', () => { - // Rebuild from the persisted split must retain multiple leaves. - const afterSplit = buildHeadlessTerminalSplitLayout( - { - root: { type: 'leaf', leafId: 'leaf-a' }, - activeLeafId: 'leaf-a', - expandedLeafId: null, - ptyIdsByLeafId: { 'leaf-a': 'pty-a' } - }, - { leafId: 'leaf-b', ptyId: 'pty-b', splitFromLeafId: 'leaf-a', direction: 'vertical' } - ) - expect(countTerminalLayoutLeaves(afterSplit.root)).toBeGreaterThan(1) - }) })