diff --git a/config/scripts/component-fixtures/fixture-page-runner.mjs b/config/scripts/component-fixtures/fixture-page-runner.mjs index 537e209b80d..7ccd629e965 100644 --- a/config/scripts/component-fixtures/fixture-page-runner.mjs +++ b/config/scripts/component-fixtures/fixture-page-runner.mjs @@ -37,6 +37,7 @@ export function createFixturePageRunner(baseUrl, browserPromise) { workers.set(scale, createWorker(scale)) } const worker = await workers.get(scale) + worker.pageErrors = [] if (worker.revision !== sourceRevision) { const revision = sourceRevision await worker.page.goto(`${baseUrl}/fixtures.html?worker=1`) @@ -47,7 +48,6 @@ export function createFixturePageRunner(baseUrl, browserPromise) { ) worker.revision = revision } - worker.pageErrors = [] return worker } diff --git a/config/scripts/component-fixtures/fixtures-mcp-server.mjs b/config/scripts/component-fixtures/fixtures-mcp-server.mjs index 77bd6250163..e001e0f9001 100644 --- a/config/scripts/component-fixtures/fixtures-mcp-server.mjs +++ b/config/scripts/component-fixtures/fixtures-mcp-server.mjs @@ -90,7 +90,10 @@ async function screenshotFixtureTool(args) { content.push({ type: 'image', data, mimeType: 'image/png' }) } } - return { content, isError: payload.results.some((result) => result.error) } + return { + content, + isError: payload.results.some((result) => result.error || result.pageErrors?.length) + } } async function handleRequest(message) { diff --git a/config/scripts/component-fixtures/fixtures-shot.mjs b/config/scripts/component-fixtures/fixtures-shot.mjs index 0515603d8f3..b124ebd899c 100644 --- a/config/scripts/component-fixtures/fixtures-shot.mjs +++ b/config/scripts/component-fixtures/fixtures-shot.mjs @@ -52,6 +52,12 @@ async function main() { scale: Number(values.scale), outDir: values.out ? path.resolve(values.out) : undefined }) + if ( + payload.error || + payload.results?.some((result) => result.error || result.pageErrors?.length) + ) { + process.exitCode = 1 + } if (values.json) { console.log(JSON.stringify({ ...payload, totalMs: Math.round(performance.now() - startedAt) })) return diff --git a/config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs b/config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs index 68ba27667cf..c2046494f11 100644 --- a/config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs +++ b/config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs @@ -99,6 +99,11 @@ function runOracle(label, targetRoot, operation, userDataPath, options = {}) { } }) const output = `${result.stdout ?? ''}${result.stderr ?? ''}` + if (result.error || result.signal || result.status === null) { + throw new Error( + `${label} could not complete (${result.error?.message ?? result.signal ?? 'no exit status'})\n${output}` + ) + } if (options.expectFailure) { if (result.status === 0 || !output.includes(options.expectFailure)) { throw new Error( diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index 4adb6568b04..709ae19cb43 100644 --- a/config/scripts/runtime-serve-terminal-smoke.mjs +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -21,7 +21,7 @@ const ORCAD_ENTRY = join(ORCAD_DIR, ORCAD_SERVER_ENTRY_FILENAME) const READY_TIMEOUT_MS = 120_000 const OUTPUT_TIMEOUT_MS = 30_000 const SHUTDOWN_TIMEOUT_MS = 15_000 -// Why a random high port: a fixed one collides with a developer's own `orca serve`. +// Offset the port when a developer's own `orca serve` is already using it. const PORT = 6800 + Math.floor(Number(process.env.ORCA_SMOKE_PORT_OFFSET ?? '0')) function log(message) { @@ -66,6 +66,11 @@ function orca(pairingCode, args) { if (result.error) { throw new Error(`orca ${args[0]} failed to spawn: ${result.error.message}`) } + if (result.status !== 0) { + throw new Error( + `orca ${args.join(' ')} exited ${result.signal ?? result.status}:\n${result.stderr || result.stdout}` + ) + } const line = (result.stdout ?? '').trim() if (!line.startsWith('{')) { throw new Error(`orca ${args.join(' ')} produced no JSON:\n${result.stdout}\n${result.stderr}`) @@ -311,18 +316,18 @@ async function main() { log(`created ${terminal.handle}`) // Why invoke node rather than `echo`: the shell differs per platform, node does not. - const nonce = `ORCA_SMOKE_${randomBytes(8).toString('hex')}` + const nonce = randomBytes(8).toString('hex') orca(pairingCode, [ 'terminal', 'send', '--terminal', terminal.handle, '--text', - `"${process.execPath}" -e "console.log('${nonce}')"`, + `"${process.execPath}" -e "console.log('ORCA_SMOKE_' + '${nonce}')"`, '--enter' ]) - if (!(await waitForNonce(pairingCode, terminal.handle, nonce))) { + if (!(await waitForNonce(pairingCode, terminal.handle, `ORCA_SMOKE_${nonce}`))) { throw new Error( `terminal produced no output containing ${nonce} within ${OUTPUT_TIMEOUT_MS}ms — ` + `the server started and answered RPC, but its PTY path is dead` diff --git a/config/scripts/windows-daemon-workspace-close-repro.mjs b/config/scripts/windows-daemon-workspace-close-repro.mjs index 1e65b386913..00d57f58154 100644 --- a/config/scripts/windows-daemon-workspace-close-repro.mjs +++ b/config/scripts/windows-daemon-workspace-close-repro.mjs @@ -280,6 +280,9 @@ async function main() { log('SKIP: Windows ConPTY is required') return } + if (!Number.isSafeInteger(iterations) || iterations <= 0) { + throw new Error('ORCA_WINDOWS_DAEMON_CLOSE_ITERATIONS must be a positive safe integer') + } if (!existsSync(entryPath)) { throw new Error(`Missing ${entryPath}; run pnpm build:electron-vite first`) } diff --git a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts index 8ea740bb1e4..abd0ced5279 100644 --- a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts @@ -55,6 +55,11 @@ async function authenticateSession(onLog?: ConnectionLogSink) { fakes.linkOptions!.onAuthenticated() await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce()) const confirmation = sentRequests()[0]! + expect(confirmation).toMatchObject({ + method: 'pairing.getEndpoints', + params: { resumeConfirmReqId: 'confirm-1' }, + deviceToken: 'device-token' + }) fakes.linkOptions!.onText( JSON.stringify({ id: confirmation.id, @@ -76,6 +81,11 @@ async function authenticateSession(onLog?: ConnectionLogSink) { ) await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2)) const capabilities = sentRequests()[1]! + expect(capabilities).toMatchObject({ + method: 'runtime.clientCapabilities.update', + params: { clientCapabilities: expect.arrayContaining(['agent-session.structured.v1']) }, + deviceToken: 'device-token' + }) fakes.linkOptions!.onText( JSON.stringify({ id: capabilities.id, @@ -89,10 +99,21 @@ async function authenticateSession(onLog?: ConnectionLogSink) { return session } -function sentRequests(): Array<{ id: string; method: string }> { - return fakes.sendText.mock.calls.map(([value]): { id: string; method: string } => - JSON.parse(value) - ) +function sentRequests() { + return fakes.sendText.mock.calls.map(([value]) => { + const request: unknown = JSON.parse(value) + if ( + !request || + typeof request !== 'object' || + !('id' in request) || + typeof request.id !== 'string' || + !('method' in request) || + typeof request.method !== 'string' + ) { + throw new Error('Expected a serialized RPC request') + } + return request + }) } describe('mobile relay RPC session liveness', () => { diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts index f8fb558be73..0f58b87b020 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -203,11 +203,15 @@ describe('mobile relay RPC session', () => { }) it('connects when the relay never answers capability negotiation', async () => { + vi.useFakeTimers() const { session } = await confirmResume() + expect(session.getState()).toBe('handshaking') - // An unanswered capability advisory must not trap a slow link before connected. - await vi.waitFor(() => expect(session.getState()).toBe('connected'), { timeout: 5_000 }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(session.getState()).toBe('connected') expect(session.getFailure()).toBeNull() + session.close() }) // Dial stages distinguish a missing upgrade from slow cell assignment while both report connecting. @@ -333,7 +337,10 @@ describe('mobile relay RPC session', () => { .sendRequest('terminal.send', { terminal: 'term', text: 'hi' }) .catch((cause: unknown) => cause) - expect((error as Error).message).toBe('relay outbound overflow') + if (!(error instanceof Error)) { + throw new Error('Expected the relay failure') + } + expect(error.message).toBe('relay outbound overflow') expect(isRpcDeliveryUnknown(error)).toBe(false) }) @@ -352,17 +359,32 @@ describe('mobile relay RPC session', () => { vi.useFakeTimers() try { const pending = session.sendRequest('terminal.send', { terminal: 'term', text: 'hi' }) - const outcome = pending.catch((error: unknown) => ({ - message: (error as Error).message, - unknown: isRpcDeliveryUnknown(error) - })) + let settled = false + const outcome = pending.then( + (response) => { + settled = true + return response + }, + (error: unknown) => { + settled = true + return error + } + ) // Let sendRequest pass its connected-check microtask and register the timer. await vi.advanceTimersByTimeAsync(0) - await vi.advanceTimersByTimeAsync(1_000) - await expect(outcome).resolves.toEqual({ - message: 'relay RPC timed out: terminal.send', - unknown: true - }) + expect(fakes.sendText).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining('"method":"terminal.send"') + ) + await vi.advanceTimersByTimeAsync(999) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + const error = await outcome + if (!(error instanceof Error)) { + throw new Error('Expected the relay timeout') + } + expect(error.message).toBe('relay RPC timed out: terminal.send') + expect(isRpcDeliveryUnknown(error)).toBe(true) + session.close() } finally { vi.useRealTimers() } diff --git a/mobile/src/transport/mobile-web-bundle-compat.test.ts b/mobile/src/transport/mobile-web-bundle-compat.test.ts index 551695e8d8b..41b5951cd04 100644 --- a/mobile/src/transport/mobile-web-bundle-compat.test.ts +++ b/mobile/src/transport/mobile-web-bundle-compat.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from 'vitest' import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability' -import { MOBILE_WEB_BUNDLE_SCHEMA_VERSION } from '../../../src/shared/mobile-web-bundle/manifest-contract' import { evaluateMobileWebBundleCompat, - SUPPORTED_MOBILE_WEB_BUNDLE_SCHEMA_VERSIONS, type MobileWebBundleCompatManifest, type MobileWebBundleCompatVerdict, type MobileWebBundleHostStatus @@ -175,10 +173,6 @@ describe('evaluateMobileWebBundleCompat', () => { }) ).toMatchObject({ reason: 'bundle-incompatible', side: 'mobile' }) }) - - it('supports the schema the desktop writes today, so a current bundle opens', () => { - expect(SUPPORTED_MOBILE_WEB_BUNDLE_SCHEMA_VERSIONS).toContain(MOBILE_WEB_BUNDLE_SCHEMA_VERSION) - }) }) // Pin the page-version floor independently of manifest-schema support. diff --git a/mobile/src/transport/mobile-web-bundle-fetch-pipeline.test.ts b/mobile/src/transport/mobile-web-bundle-fetch-pipeline.test.ts index 1d36b863cf0..237049acedc 100644 --- a/mobile/src/transport/mobile-web-bundle-fetch-pipeline.test.ts +++ b/mobile/src/transport/mobile-web-bundle-fetch-pipeline.test.ts @@ -1,5 +1,5 @@ import { sha256 } from '@noble/hashes/sha256' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeMobileWebBundleId } from '../../../src/shared/mobile-web-bundle/manifest-contract' import { fetchMobileWebBundle } from './mobile-web-bundle-fetch' import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal' @@ -119,7 +119,7 @@ function waveHost(files: Record, options: WaveHostOptions = {}) close: vi.fn() } - const settleMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0)) + const settleMicrotasks = () => vi.advanceTimersByTimeAsync(0) // Drain after settlement too so unstopped sibling reads remain visible. const runWaves = async ( @@ -168,6 +168,9 @@ function syntheticBundle(): Record { } } +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + describe('fetchMobileWebBundle chunk pipeline', () => { it('keeps four chunk reads in flight across the whole bundle, largest asset first', async () => { const files = syntheticBundle() diff --git a/mobile/src/transport/mobile-web-bundle-fetch-range.test.ts b/mobile/src/transport/mobile-web-bundle-fetch-range.test.ts index 11bbb93a13b..e6d9b8831ec 100644 --- a/mobile/src/transport/mobile-web-bundle-fetch-range.test.ts +++ b/mobile/src/transport/mobile-web-bundle-fetch-range.test.ts @@ -174,15 +174,6 @@ function rangeHost( calls, wireBase64Bytes: () => wireBase64Bytes, peakInFlight: () => peakInFlight, - // Drain to quiescence so missing sibling stops become extra requests instead of hidden pending work. - drain: async (): Promise => { - let quiet = 0 - for (let tick = 0; tick < 2000 && quiet < 10; tick += 1) { - const sent = calls.length - await new Promise((resolve) => setTimeout(resolve, 0)) - quiet = inFlight === 0 && calls.length === sent ? quiet + 1 : 0 - } - }, inflations: (): Inflation[] => inflationLog .filter((entry) => sentGzipBodies.has(encodeBase64(entry.body))) @@ -285,26 +276,32 @@ describe('fetchMobileWebBundle from a host whose manifest names a range grid', ( // Bound a gzip bomb at one byte over its slot; enough planned reads expose continued dispatch after refusal. it('refuses a gzip bomb at one byte over the window and sends little after it', async () => { - const bomb = encodeBase64(gzipSync(new Uint8Array(4 * 1024 * 1024), { level: 9 })) - const many = Object.fromEntries( - Array.from({ length: 10 }, (_, index) => [ - `assets/part-${String(index)}.js`, - scriptBytes(RANGE_GRID * 2, index) - ]) - ) - const host = rangeHost(many, { - tamper: (call, body) => - call.params.path === 'assets/part-0.js' && call.params.offset === 0 ? bomb : body - }) + vi.useFakeTimers() + try { + const bomb = encodeBase64(gzipSync(new Uint8Array(4 * 1024 * 1024), { level: 9 })) + const many = Object.fromEntries( + Array.from({ length: 10 }, (_, index) => [ + `assets/part-${String(index)}.js`, + scriptBytes(RANGE_GRID * 2, index) + ]) + ) + const host = rangeHost(many, { + tamper: (call, body) => + call.params.path === 'assets/part-0.js' && call.params.offset === 0 ? bomb : body + }) - expect(await refusalOf(fetchMobileWebBundle({ client: host.client }))).toBe('chunk-oversize') - await host.drain() - const window = RANGE_GRID + 1 - expect(host.inflations().filter((inflation) => inflation.resultLength === window)).toEqual([ - { outLength: window, resultLength: window } - ]) - // Sibling stop permits only in-flight work and immediate follow-ups, never the full planned grid. - expect(readsOf(host.calls, 'mobileWeb.bundle.range').length).toBeLessThanOrEqual(8) + const refusal = refusalOf(fetchMobileWebBundle({ client: host.client })) + await vi.runAllTimersAsync() + expect(await refusal).toBe('chunk-oversize') + const window = RANGE_GRID + 1 + expect(host.inflations().filter((inflation) => inflation.resultLength === window)).toEqual([ + { outLength: window, resultLength: window } + ]) + // Sibling stop permits only in-flight work and immediate follow-ups, never the full planned grid. + expect(readsOf(host.calls, 'mobileWeb.bundle.range').length).toBeLessThanOrEqual(8) + } finally { + vi.useRealTimers() + } }) it('refuses a range that inflates short of its window as short', async () => { diff --git a/mobile/src/transport/mobile-web-bundle-fetch-slow-link.test.ts b/mobile/src/transport/mobile-web-bundle-fetch-slow-link.test.ts index a2d6152ed5b..574a8bfc96d 100644 --- a/mobile/src/transport/mobile-web-bundle-fetch-slow-link.test.ts +++ b/mobile/src/transport/mobile-web-bundle-fetch-slow-link.test.ts @@ -181,6 +181,9 @@ describe('fetchMobileWebBundle over a slow link', () => { expect(isMobileWebBundleTransportFailure(outcome.error)).toBe(true) expect(String(outcome.error)).toContain('stalled') + expect(outcome.settledAt - startedAt).toBe( + 2 * ONE_WAY_LATENCY_MS + MOBILE_WEB_BUNDLE_WINDOW_STALL_MS + ) // Fail against fetch-wide inactivity, not the per-read backstop. expect(outcome.settledAt - startedAt).toBeLessThan(MOBILE_WEB_BUNDLE_WINDOW_STALL_MS + 1_000) expect(host.delivered()).toBe(1) diff --git a/mobile/src/transport/mobile-web-bundle-fetch.test.ts b/mobile/src/transport/mobile-web-bundle-fetch.test.ts index 27c810e9b77..f2f354a106e 100644 --- a/mobile/src/transport/mobile-web-bundle-fetch.test.ts +++ b/mobile/src/transport/mobile-web-bundle-fetch.test.ts @@ -139,13 +139,6 @@ function chunkCallCount(calls: readonly HostCall[]): number { return calls.filter((call) => call.method === 'mobileWeb.bundle.chunk').length } -// Drain enough ticks to expose a worker pool missing its stop check. -async function drainPendingHostWork(): Promise { - for (let tick = 0; tick < 300; tick += 1) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } -} - describe('fetchMobileWebBundle', () => { it('pages every asset to eof and returns the verified bytes', async () => { const host = bundleHost({ 'index.html': '

orca

', 'assets/app.js': 'x=1' }) @@ -438,35 +431,41 @@ describe('fetchMobileWebBundle', () => { }) it('stops the other reads mid-asset once one chunk is refused', async () => { - const host = bundleHost( - { - 'a.js': 'x', - 'b.js': 'b'.repeat(40), - 'c.js': 'c'.repeat(40), - 'd.js': 'd'.repeat(40) - }, - { - chunkBytes: 1, - intercept: (call) => - call.method === 'mobileWeb.bundle.chunk' && - paramField(call.params, 'path') === 'b.js' && - paramField(call.params, 'offset') === 1 - ? new Error('mobile_web_bundle_asset_unknown') - : undefined - } - ) + vi.useFakeTimers() + try { + const host = bundleHost( + { + 'a.js': 'x', + 'b.js': 'b'.repeat(40), + 'c.js': 'c'.repeat(40), + 'd.js': 'd'.repeat(40) + }, + { + chunkBytes: 1, + intercept: (call) => + call.method === 'mobileWeb.bundle.chunk' && + paramField(call.params, 'path') === 'b.js' && + paramField(call.params, 'offset') === 1 + ? new Error('mobile_web_bundle_asset_unknown') + : undefined + } + ) - const error = await fetchMobileWebBundle({ client: host.client }).catch( - (thrown: unknown) => thrown - ) - const atRejection = chunkCallCount(host.calls) - await drainPendingHostWork() + const outcome = fetchMobileWebBundle({ client: host.client }).then( + () => ({ error: undefined, atRejection: chunkCallCount(host.calls) }), + (error: unknown) => ({ error, atRejection: chunkCallCount(host.calls) }) + ) + await vi.runAllTimersAsync() + const { error, atRejection } = await outcome - // The refusal is what the caller sees; the internal stop never surfaces. - expect(readMobileWebBundleErrorCode(error)).toBe('mobile_web_bundle_asset_unknown') - // Stop checks allow at most another concurrency window, never all remaining chunks. - expect(chunkCallCount(host.calls)).toBeLessThanOrEqual(atRejection + 4) - expect(chunkCallCount(host.calls)).toBeLessThan(10) + // The refusal is what the caller sees; the internal stop never surfaces. + expect(readMobileWebBundleErrorCode(error)).toBe('mobile_web_bundle_asset_unknown') + // Stop checks allow at most another concurrency window, never all remaining chunks. + expect(chunkCallCount(host.calls)).toBeLessThanOrEqual(atRejection + 4) + expect(chunkCallCount(host.calls)).toBeLessThan(10) + } finally { + vi.useRealTimers() + } }) it('asks for nothing at all when the caller arrives already aborted', async () => { diff --git a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts index ec1f4f666a5..79807f22e4b 100644 --- a/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts +++ b/mobile/src/transport/mobile-web-bundle-reply-schemas.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - MOBILE_WEB_BUNDLE_CHUNK_BYTES, - MOBILE_WEB_BUNDLE_ERROR_CODES -} from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' +import { MOBILE_WEB_BUNDLE_CHUNK_BYTES } from '../../../src/shared/mobile-web-bundle/bundle-rpc-contract' import { computeMobileWebBundleId, - MobileWebBundleAssetSchema, MobileWebBundleRouteSchema, MOBILE_WEB_BUNDLE_MAX_ASSETS, MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES, @@ -25,6 +21,9 @@ import { MobileWebBundleManifestReplySchema } from './mobile-web-bundle-reply-schemas' import type { RpcReadResult } from './rpc-operation-contract' +import { runRpcOperation } from './rpc-operation' +import { RpcIncompatibleReplyError } from './rpc-incompatible-reply-error' +import type { RpcResponse } from './types' const ASSET_SHA = 'b'.repeat(64) const MAX_DATA_BASE64_LENGTH = Math.ceil(MOBILE_WEB_BUNDLE_CHUNK_BYTES / 3) * 4 + 8 @@ -40,9 +39,8 @@ function asset(overrides: Record = {}) { } // Derive ids after fixture asset edits unless the test explicitly selects its own buildId. -function contentIdOf(assets: unknown): string { - const parsed = MobileWebBundleAssetSchema.array().safeParse(assets) - return parsed.success ? computeMobileWebBundleId(parsed.data) : 'a'.repeat(64) +function contentIdOf(assets: ReturnType[]): string { + return computeMobileWebBundleId(assets) } const BUILD_ID = contentIdOf([asset()]) @@ -331,9 +329,15 @@ describe('mobile web bundle chunk reply reader', () => { }) describe('mobile web bundle error codes', () => { - it('maps every code the host declares, from both positions one can occupy', () => { - expect(MOBILE_WEB_BUNDLE_ERROR_CODES).toHaveLength(6) - for (const code of MOBILE_WEB_BUNDLE_ERROR_CODES) { + it('reads released wire codes bare, wrapped, and followed by diagnostic prose', () => { + for (const code of [ + 'mobile_web_bundle_unavailable', + 'mobile_web_bundle_build_changed', + 'mobile_web_bundle_asset_unknown', + 'mobile_web_bundle_asset_changed', + 'mobile_web_bundle_offset_invalid', + 'mobile_web_bundle_read_limited' + ]) { expect(readMobileWebBundleErrorCode(new Error(`invalid_argument: ${code}`))).toBe(code) expect(readMobileWebBundleErrorCode(new Error(code))).toBe(code) expect(readMobileWebBundleErrorCode(new Error(`invalid_argument: ${code}: no bundle`))).toBe( @@ -378,11 +382,29 @@ describe('mobile web bundle error codes', () => { }) describe('mobile web bundle operation descriptors', () => { - it('accepts no reply but a result', () => { - // Whole-result policy must refuse immediately rather than salvage a corrupt partial bundle. - for (const operation of [mobileWebBundleManifestRead, mobileWebBundleChunkRead]) { - expect(operation.acceptance).toBe('require-result-or-throw') - expect(operation.barrier).toBe('on-settle') + it('rejects host refusals and unreadable successful results for both reads', async () => { + let reply: RpcResponse = { + id: 'read-1', + ok: false, + error: { code: 'invalid_argument', message: 'mobile_web_bundle_unavailable' }, + _meta: { runtimeId: 'host-1' } + } + const client = { sendRequest: async () => reply } + const reads = [ + () => runRpcOperation(client, mobileWebBundleManifestRead, null), + () => + runRpcOperation(client, mobileWebBundleChunkRead, { + buildId: BUILD_ID, + path: 'index.html', + offset: 0 + }) + ] + for (const read of reads) { + await expect(read()).rejects.toThrow('invalid_argument: mobile_web_bundle_unavailable') + } + reply = { id: 'read-1', ok: true, result: null, _meta: { runtimeId: 'host-1' } } + for (const read of reads) { + await expect(read()).rejects.toBeInstanceOf(RpcIncompatibleReplyError) } }) }) @@ -399,7 +421,7 @@ describe('which side a bundle read failed on', () => { }) it.each([ - ['a host refusal', `invalid_argument: ${MOBILE_WEB_BUNDLE_ERROR_CODES[0]}`], + ['a host refusal', 'invalid_argument: mobile_web_bundle_unavailable'], ['bytes that do not hash', 'bundle asset index.html hashed aa, not bb'], ['a build that changed mid-fetch', 'bundle build changed mid-fetch: asked aa, served bb'], ['an unread reply', 'The host sent a reply this app could not read (mobileWeb.bundle.manifest)'] diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index 14fae5063e3..54690de25b6 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -86,8 +86,21 @@ function sentRequest(socket: MockWebSocket, method: string): SentRpcRequest { function sentRequests(socket: MockWebSocket, method: string): SentRpcRequest[] { return socket.sent - .map((payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as SentRpcRequest) - .filter((request) => request.method === method) + .map((payload) => { + const request: unknown = JSON.parse(payload.replace(/^encrypted:/, '')) + if ( + !request || + typeof request !== 'object' || + !('id' in request) || + typeof request.id !== 'string' || + !('method' in request) || + typeof request.method !== 'string' + ) { + return null + } + return request + }) + .filter((request): request is SentRpcRequest => request !== null && request.method === method) } function encodeBrowserFrame(): Uint8Array { @@ -116,6 +129,12 @@ function encodeTerminalOutput(streamId: number, chunk: string): Uint8Array { }) } +function openAndAuthenticate(socket: MockWebSocket) { + socket.open() + socket.receive(JSON.stringify({ type: 'e2ee_ready' })) + socket.receive('encrypted:{"type":"e2ee_authenticated"}') +} + describe('mobile rpc-client connection timeout', () => { beforeEach(() => { vi.useFakeTimers() @@ -174,9 +193,7 @@ describe('mobile rpc-client connection timeout', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const socket = mockSockets[0]! - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) expect(client.getState()).toBe('connected') @@ -192,9 +209,7 @@ describe('mobile rpc-client connection timeout', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const socket = mockSockets[0]! - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) const unsubscribe = client.subscribe( 'session.tabs.subscribe', @@ -228,9 +243,7 @@ describe('mobile rpc-client connection timeout', () => { }) const socket = mockSockets[0]! - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) expect(sentRequests(socket, 'notifications.subscribe')).toHaveLength(1) @@ -242,9 +255,7 @@ describe('mobile rpc-client connection timeout', () => { const socket = mockSockets[0]! const frames: unknown[] = [] - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) const unsubscribe = client.subscribe( 'browser.screencast', @@ -285,9 +296,7 @@ describe('mobile rpc-client connection timeout', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const socket = mockSockets[0]! - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) const unsubscribe = client.subscribe( 'browser.screencast', @@ -324,9 +333,7 @@ describe('mobile rpc-client connection timeout', () => { const events: unknown[] = [] const frames: unknown[] = [] - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) client.subscribe( 'browser.screencast', @@ -372,9 +379,7 @@ describe('mobile rpc-client connection timeout', () => { ) unsubscribe() - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) expect(sentRequests(socket, 'browser.screencast')).toHaveLength(0) @@ -385,9 +390,7 @@ describe('mobile rpc-client connection timeout', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const socket = mockSockets[0]! - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) client.subscribe('browser.screencast', { worktree: 'id:wt-1', page: 'page-1' }, () => {}, { onBinaryFrame: () => {} @@ -425,9 +428,7 @@ describe('mobile rpc-client connection timeout', () => { const firstFrames: unknown[] = [] const secondFrames: unknown[] = [] - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) client.subscribe('browser.screencast', { worktree: 'id:wt-1', page: 'page-1' }, () => {}, { onBinaryFrame: (frame) => firstFrames.push(frame) @@ -476,9 +477,7 @@ describe('mobile rpc-client connection timeout', () => { const socket = mockSockets[0]! const events: unknown[] = [] - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) client.subscribe('terminal.subscribe', { terminal: 'term-1' }, (event) => events.push(event)) const request = sentRequest(socket, 'terminal.subscribe') @@ -504,9 +503,7 @@ describe('mobile rpc-client connection timeout', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const firstSocket = mockSockets[0]! - firstSocket.open() - firstSocket.receive(JSON.stringify({ type: 'e2ee_ready' })) - firstSocket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(firstSocket) client.subscribe( 'terminal.subscribe', @@ -525,9 +522,7 @@ describe('mobile rpc-client connection timeout', () => { firstSocket.close() vi.advanceTimersByTime(500) const secondSocket = mockSockets[1]! - secondSocket.open() - secondSocket.receive(JSON.stringify({ type: 'e2ee_ready' })) - secondSocket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(secondSocket) expect(sentRequest(secondSocket, 'terminal.subscribe').params).toMatchObject({ viewport: { cols: 60, rows: 24 } @@ -540,9 +535,7 @@ describe('mobile rpc-client connection timeout', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const socket = mockSockets[0]! - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) const request = client.sendRequest( 'speech.dictation.finish', @@ -551,15 +544,29 @@ describe('mobile rpc-client connection timeout', () => { timeoutMs: 123 } ) + let settled = false + const outcome = request.then( + (response) => { + settled = true + return response + }, + (error: unknown) => { + settled = true + return error + } + ) await Promise.resolve() + expect(sentRequest(socket, 'speech.dictation.finish').params).toEqual({ dictationId: 'd1' }) await vi.advanceTimersByTimeAsync(122) - await expect( - Promise.race([request.then(() => 'settled'), Promise.resolve('pending')]) - ).resolves.toBe('pending') + expect(settled).toBe(false) await vi.advanceTimersByTimeAsync(1) - await expect(request).rejects.toThrow('Request timed out: speech.dictation.finish') + const error = await outcome + if (!(error instanceof Error)) { + throw new Error('Expected the request timeout') + } + expect(error.message).toBe('Request timed out: speech.dictation.finish') client.close() }) @@ -568,9 +575,7 @@ describe('mobile rpc-client connection timeout', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const socket = mockSockets[0]! - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) socket.close() const request = client.sendRequest( @@ -606,11 +611,6 @@ describe('mobile rpc-client connection timeout', () => { // Foreground recovery must heal Android sessions left half-open after suspension (#5049). describe('foreground recovery', () => { - function openAndAuthenticate(socket: MockWebSocket) { - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') - } function connectAuthenticated() { const client = connect('ws://desktop.invalid', 'token', 'server-key') const socket = mockSockets[0]! @@ -749,7 +749,7 @@ describe('mobile rpc-client connection timeout', () => { const probe = sentRequest(socket, 'status.get') socket.receive(`encrypted:${JSON.stringify({ id: probe.id, ok: true, result: {} })}`) - await vi.advanceTimersByTimeAsync(10_000) + await vi.advanceTimersByTimeAsync(24_000) expect(socket.close).not.toHaveBeenCalled() expect(client.getState()).toBe('connected') @@ -811,7 +811,7 @@ describe('mobile rpc-client connection timeout', () => { error: { code: 'download_failed' } }) - await vi.advanceTimersByTimeAsync(8_000) + await vi.advanceTimersByTimeAsync(24_000) expect(socket.close).not.toHaveBeenCalled() expect(client.getState()).toBe('connected') @@ -839,7 +839,7 @@ describe('mobile rpc-client connection timeout', () => { socket.receive(encodeTerminalOutput(42, 'still alive')) await Promise.resolve() await Promise.resolve() - await vi.advanceTimersByTimeAsync(8_000) + await vi.advanceTimersByTimeAsync(24_000) expect(events).toContainEqual({ type: 'data', streamId: 42, chunk: 'still alive' }) expect(socket.close).not.toHaveBeenCalled() @@ -849,18 +849,23 @@ describe('mobile rpc-client connection timeout', () => { }) it('counts authenticated unknown payloads before semantic decoding', async () => { - const { client, socket } = connectAuthenticated() + for (const payload of [ + 'encrypted:{"unexpected":true}', + 'encrypted:{"id":"rpc-incomplete","ok":true}', + new Uint8Array([0xff, 0x00, 0x01]) + ]) { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets.at(-1)! + openAndAuthenticate(socket) + client.notifyForeground() + socket.receive(payload) - client.notifyForeground() - socket.receive('encrypted:{"unexpected":true}') - socket.receive('encrypted:{"id":"rpc-incomplete","ok":true}') - socket.receive(new Uint8Array([0xff, 0x00, 0x01])) + await vi.advanceTimersByTimeAsync(24_000) + expect(socket.close).not.toHaveBeenCalled() + expect(client.getState()).toBe('connected') - await vi.advanceTimersByTimeAsync(8_000) - expect(socket.close).not.toHaveBeenCalled() - expect(client.getState()).toBe('connected') - - client.close() + client.close() + } }) it('does not count undecryptable payloads as probe activity', async () => { @@ -888,11 +893,6 @@ describe('mobile rpc-client connection timeout', () => { // Transient auth rejection must not force re-pairing while the desktop still has valid credentials (#5200). describe('auth rejection retry (issue #5200)', () => { - function authenticate(socket: MockWebSocket) { - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') - } function unauthorizedResponsePayload(id: string): string { return `encrypted:${JSON.stringify({ id, ok: false, error: { code: 'unauthorized', message: 'Unauthorized' } })}` } @@ -907,7 +907,7 @@ describe('mobile rpc-client connection timeout', () => { expect(client.getState()).toBe('reconnecting') await vi.advanceTimersByTimeAsync(500) - authenticate(mockSockets[mockSockets.length - 1]!) + openAndAuthenticate(mockSockets.at(-1)!) expect(client.getState()).toBe('connected') client.close() @@ -946,7 +946,7 @@ describe('mobile rpc-client connection timeout', () => { socket.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}') } await vi.advanceTimersByTimeAsync(1_000) - authenticate(mockSockets[mockSockets.length - 1]!) + openAndAuthenticate(mockSockets.at(-1)!) expect(client.getState()).toBe('connected') const live = mockSockets[mockSockets.length - 1]! @@ -966,9 +966,7 @@ describe('mobile rpc-client connection timeout', () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') const socket = mockSockets[0]! - socket.open() - socket.receive(JSON.stringify({ type: 'e2ee_ready' })) - socket.receive('encrypted:{"type":"e2ee_authenticated"}') + openAndAuthenticate(socket) socket.close() const waitingRequestError = client.sendRequest('status.get').then( diff --git a/mobile/src/transport/rpc-operation.test.ts b/mobile/src/transport/rpc-operation.test.ts index 87a7c5c680c..d189d5cbde4 100644 --- a/mobile/src/transport/rpc-operation.test.ts +++ b/mobile/src/transport/rpc-operation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { RpcResponse } from './types' import { FakeSession } from './mobile-endpoint-supervisor-test-fakes' import { @@ -165,25 +165,31 @@ describe('transport rejection stays on the promise channel', () => { }) it('fails a Promise.all group immediately instead of waiting for a stalled peer', async () => { - const error = new Error('socket closed') - const failing = new FakeSession('connected') - failing.sendRequest.mockRejectedValue(error) - const stalled = new FakeSession('connected') - stalled.sendRequest.mockReturnValue(new Promise(() => {})) + vi.useFakeTimers() + try { + const error = new Error('socket closed') + const failing = new FakeSession('connected') + failing.sendRequest.mockRejectedValue(error) + const stalled = new FakeSession('connected') + stalled.sendRequest.mockReturnValue(new Promise(() => {})) - const group = Promise.all([ - runRpcOperation(failing, workspaceListOrThrow, {}), - runRpcOperation(stalled, workspaceListOrThrow, {}) - ]) - const raced = await Promise.race([ - group.then( - () => 'resolved' as const, - (caught: unknown) => caught - ), - new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50)) - ]) + const group = Promise.all([ + runRpcOperation(failing, workspaceListOrThrow, {}), + runRpcOperation(stalled, workspaceListOrThrow, {}) + ]) + const raced = Promise.race([ + group.then( + () => 'resolved' as const, + (caught: unknown) => caught + ), + new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50)) + ]) - expect(raced).toBe(error) + await vi.advanceTimersByTimeAsync(50) + expect(await raced).toBe(error) + } finally { + vi.useRealTimers() + } }) it('only captures a rejection when a caller names the all-settled helper', async () => { @@ -294,10 +300,12 @@ describe('an incompatible reply', () => { {} ).catch((thrown: unknown) => thrown) - expect(caught).toBeInstanceOf(RpcIncompatibleReplyError) - expect((caught as RpcIncompatibleReplyError).method).toBe('worktree.ps') - expect((caught as RpcIncompatibleReplyError).operationName).toBe('test.workspaceListOrThrow') - expect((caught as RpcIncompatibleReplyError).issues.length).toBeGreaterThan(0) + if (!(caught instanceof RpcIncompatibleReplyError)) { + throw new Error('expected an incompatible-reply error') + } + expect(caught.method).toBe('worktree.ps') + expect(caught.operationName).toBe('test.workspaceListOrThrow') + expect(caught.issues.length).toBeGreaterThan(0) }) // Unreadable replies prove no mutation failure and must not match errors that authorize retry. @@ -308,6 +316,7 @@ describe('an incompatible reply', () => { {} ).catch((thrown: unknown) => thrown) + expect(caught).toBeInstanceOf(RpcIncompatibleReplyError) expect(isRpcDeliveryUnknown(caught)).toBe(false) expect(isLogicalClientCutoverError(caught)).toBe(false) }) diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index 67b0a339d92..f6bd60ba87f 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -111,6 +111,9 @@ describe('stable logical RPC client', () => { undefined ) expect(oldSession.close).toHaveBeenCalledOnce() + expect(nextSession.subscribe.mock.invocationCallOrder[0]).toBeLessThan( + oldSession.close.mock.invocationCallOrder[0]! + ) expect(client.getActivePath()).toBe('relay') expect(client.getGeneration()).toBe(2) oldSession.emitStream('stale') diff --git a/src/main/runtime/headless-tab-group-selection.test.ts b/src/main/runtime/headless-tab-group-selection.test.ts index 286c3e6fa06..71cc2e6710f 100644 --- a/src/main/runtime/headless-tab-group-selection.test.ts +++ b/src/main/runtime/headless-tab-group-selection.test.ts @@ -16,11 +16,11 @@ describe('withSelectedTabs', () => { it('keeps a selection the move did not touch', () => { const before = [ - { id: 'g1', activeTabId: 'a', tabOrder: ['a', 'b'] }, + { id: 'g1', activeTabId: 'a', tabOrder: ['d', 'a', 'b'] }, { id: 'g2', activeTabId: 'c', tabOrder: ['c'] } ] const after = [ - { id: 'g1', tabOrder: ['a'] }, + { id: 'g1', tabOrder: ['d', 'a'] }, { id: 'g2', tabOrder: ['b', 'c'] } ] expect(withSelectedTabs(before, after, 'b').map((group) => group.activeTabId)).toEqual([ diff --git a/src/main/runtime/held-pane-reopen-layout-owner.test.ts b/src/main/runtime/held-pane-reopen-layout-owner.test.ts index 3e27301449b..b44c10a04e0 100644 --- a/src/main/runtime/held-pane-reopen-layout-owner.test.ts +++ b/src/main/runtime/held-pane-reopen-layout-owner.test.ts @@ -17,6 +17,7 @@ const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') await import('./orca-runtime-test-lifecycle.spec') const { HEADLESS_LEAF_ID, + makeDeferred, TEST_WORKTREE_ID, makeRuntimeStoreWithWorkspaceSession, makeWorkspaceSessionWithHeadlessTerminal @@ -80,7 +81,22 @@ describe("a paired desktop's Reopen of a held pane (layout-owner build)", () => const { runtime, spawn } = await heldPaneRuntime() expect(publishedStatus(runtime)).toBe('held') - await expect(runtime.reopenHeldTerminalPanes([PANE])).resolves.toEqual({ reopened: true }) + const stopStarted = makeDeferred() + const stopFinished = makeDeferred() + daemonHold.reopen.mockImplementationOnce(async () => { + stopStarted.resolve() + await stopFinished.promise + return { reopened: true } + }) + const reopening = runtime.reopenHeldTerminalPanes([PANE]) + await stopStarted.promise + try { + expect(spawn).toHaveBeenCalledOnce() + expect(publishedStatus(runtime)).toBe('held') + } finally { + stopFinished.resolve() + } + await expect(reopening).resolves.toEqual({ reopened: true }) expect(daemonHold.reopen).toHaveBeenCalledOnce() expect(spawn).toHaveBeenCalledTimes(2)