mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
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>
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
/<link\b(?=[^>]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i
|
||||
const object =
|
||||
@@ -34,6 +34,16 @@ function measurePair(source) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [source, expected] of [
|
||||
['<link rel="icon" href="/favicon.ico?version=1">', '/favicon.ico'],
|
||||
["<link href='/shortcut.svg' rel='shortcut icon'>", '/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']) {
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
@@ -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)}"`)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof connect>; 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' } })}`
|
||||
|
||||
@@ -72,6 +72,7 @@ async function nextSocket(): Promise<MockWebSocket> {
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@@ -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<WebSocketServer> {
|
||||
}
|
||||
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<WebSocketServer> {
|
||||
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<WebSocketServer> {
|
||||
}
|
||||
|
||||
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<void> {
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -80,7 +80,7 @@ function lastSocket(): WedgedWebSocket {
|
||||
function openConnectionLogs(spy: MockInstance<typeof console.log>): Record<string, unknown>[] {
|
||||
return spy.mock.calls
|
||||
.filter((call) => call[0] === '[net] openConnection')
|
||||
.map((call) => call[1] as Record<string, unknown>)
|
||||
.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<string, unknown> | 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<string, unknown>
|
||||
| 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<string, unknown> | undefined {
|
||||
return value !== null && typeof value === 'object'
|
||||
? Object.fromEntries(Object.entries(value))
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' }
|
||||
})}`
|
||||
|
||||
@@ -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<typeof setTimeout>
|
||||
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<typeof setTimeout>
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user