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)
- })
})