diff --git a/src/__tests__/hermetic-env-setup.test.ts b/src/__tests__/hermetic-env-setup.test.ts index 22ad6f8b45..ab413468aa 100644 --- a/src/__tests__/hermetic-env-setup.test.ts +++ b/src/__tests__/hermetic-env-setup.test.ts @@ -1,3 +1,5 @@ +import os from 'node:os'; +import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import vitestConfig from '../../vitest.config.ts'; @@ -7,6 +9,7 @@ const AMBIENT_DAEMON_VARS = [ 'AGENT_DEVICE_DAEMON_BASE_URL', 'AGENT_DEVICE_DAEMON_AUTH_TOKEN', ] as const; +const VITEST_CLAIMS_DIR = path.join(os.tmpdir(), `agent-device-vitest-claims-${process.pid}`); type ProjectShape = { test?: { name?: string; setupFiles?: readonly string[] } }; @@ -39,3 +42,10 @@ test('importing hermetic-env-setup scrubs the ambient daemon connection vars', a assert.equal(process.env[name], undefined, `${name} must be scrubbed when the setup loads`); } }); + +test('importing hermetic-env-setup isolates advisory claims from the host and other workers', async () => { + process.env.AGENT_DEVICE_CLAIMS_DIR = '/host/device-claims'; + vi.resetModules(); + await import('./hermetic-env-setup.ts'); + assert.equal(process.env.AGENT_DEVICE_CLAIMS_DIR, VITEST_CLAIMS_DIR); +}); diff --git a/src/__tests__/hermetic-env-setup.ts b/src/__tests__/hermetic-env-setup.ts index 6546038499..2549c74892 100644 --- a/src/__tests__/hermetic-env-setup.ts +++ b/src/__tests__/hermetic-env-setup.ts @@ -1,3 +1,6 @@ +import os from 'node:os'; +import path from 'node:path'; + // Unit tests must be hermetic with respect to the host's daemon-connection // environment. A machine actually running agent-device — including this repo's // own remote dev containers — exports AGENT_DEVICE_DAEMON_BASE_URL and @@ -21,3 +24,15 @@ const AMBIENT_DAEMON_ENV_VARS = [ for (const name of AMBIENT_DAEMON_ENV_VARS) { delete process.env[name]; } + +// Provider-backed scenarios intentionally use local device identities so their +// request path covers advisory-claim ownership. Each Vitest fork, however, +// mocks the same identities (for example `sim-1`). Keeping claims under the +// host-global default makes unrelated workers poll one process lock and can +// push otherwise instant scenarios past Vitest's timeout. Scope claims to the +// worker process: the production claim mechanism still runs, while workers no +// longer contend for mocked devices or inherit a host's real claims. +process.env.AGENT_DEVICE_CLAIMS_DIR = path.join( + os.tmpdir(), + `agent-device-vitest-claims-${process.pid}`, +); diff --git a/test/integration/interaction-contract/direct-ios-selector.contract.test.ts b/test/integration/interaction-contract/direct-ios-selector.contract.test.ts index bf971a063d..1d427a3300 100644 --- a/test/integration/interaction-contract/direct-ios-selector.contract.test.ts +++ b/test/integration/interaction-contract/direct-ios-selector.contract.test.ts @@ -3,6 +3,7 @@ import { test } from 'vitest'; import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts'; import { AppError } from '@agent-device/kernel/errors'; import { assertRpcError, assertRpcOk } from '../provider-scenarios/assertions.ts'; +import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from '../provider-scenarios/test-timeouts.ts'; import { scenarioName } from './coverage-manifest.ts'; import { DIRECT_IOS_SELECTOR_COVERAGE } from './direct-ios-selector.coverage.ts'; import { @@ -54,25 +55,32 @@ const RECORDING_TARGET_NODES = [ }, ] as const; -test(scenario('responseConstruction'), async () => { - await withIosContractDaemon([runnerTapEntry({ x: 150, y: 200 })], async (daemon, transcript) => { - const click = await daemon.callCommand('click', ['label=Continue']); - const data = assertRpcOk(click); +test( + scenario('responseConstruction'), + async () => { + await withIosContractDaemon( + [runnerTapEntry({ x: 150, y: 200 })], + async (daemon, transcript) => { + const click = await daemon.callCommand('click', ['label=Continue']); + const data = assertRpcOk(click); - // The direct path really ran: the single runner call is a selector-keyed - // tap, with no snapshot capture before it. - const tapRequest = transcript.calls[0]?.request as Record | undefined; - assert.equal(transcript.calls[0]?.command, 'ios.runner.tap'); - assert.equal(tapRequest?.selectorKey, 'label'); - assert.equal(tapRequest?.selectorValue, 'Continue'); + // The direct path really ran: the single runner call is a selector-keyed + // tap, with no snapshot capture before it. + const tapRequest = transcript.calls[0]?.request as Record | undefined; + assert.equal(transcript.calls[0]?.command, 'ios.runner.tap'); + assert.equal(tapRequest?.selectorKey, 'label'); + assert.equal(tapRequest?.selectorValue, 'Continue'); - // Canonical runner-payload response set from the shared construction site. - assert.equal(data.x, 150); - assert.equal(data.y, 200); - assert.equal(data.selector, 'label=Continue'); - assert.match(String(data.message), /Tapped label=Continue/); - }); -}); + // Canonical runner-payload response set from the shared construction site. + assert.equal(data.x, 150); + assert.equal(data.y, 200); + assert.equal(data.selector, 'label=Continue'); + assert.match(String(data.message), /Tapped label=Continue/); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); test(scenario('resolutionDisclosure'), async () => { await withIosContractDaemon([runnerTapEntry({ x: 150, y: 200 })], async (daemon) => { diff --git a/test/integration/interaction-contract/maestro-fallback.contract.test.ts b/test/integration/interaction-contract/maestro-fallback.contract.test.ts index 31e79a8962..844fa9f0cb 100644 --- a/test/integration/interaction-contract/maestro-fallback.contract.test.ts +++ b/test/integration/interaction-contract/maestro-fallback.contract.test.ts @@ -3,6 +3,7 @@ import { test } from 'vitest'; import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts'; import { AppError } from '@agent-device/kernel/errors'; import { assertRpcError, assertRpcOk } from '../provider-scenarios/assertions.ts'; +import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from '../provider-scenarios/test-timeouts.ts'; import { scenarioName } from './coverage-manifest.ts'; import { MAESTRO_FALLBACK_COVERAGE } from './maestro-fallback.coverage.ts'; import { @@ -22,37 +23,41 @@ const scenario = (guarantee: InteractionGuarantee): string => const MAESTRO_FLAGS = { maestro: { allowNonHittableCoordinateFallback: true } }; -test(scenario('responseConstruction'), async () => { - await withIosContractDaemon( - [ - runnerTapEntry({ - x: 50, - y: 60, - message: 'tapped via non-hittable coordinate fallback', - maestroNonHittableCoordinateFallbackUsed: true, - }), - ], - async (daemon, transcript) => { - const click = await daemon.callCommand('click', ['label=Pin'], MAESTRO_FLAGS); - const data = assertRpcOk(click); - - // The runner received the fallback permission on the selector tap. - const tapRequest = transcript.calls[0]?.request as Record | undefined; - assert.equal(tapRequest?.selectorValue, 'Pin'); - assert.equal(tapRequest?.allowNonHittableCoordinateFallback, true); - - // Canonical field set plus the fallback markers the replay layer keys on. - assert.equal(data.x, 50); - assert.equal(data.y, 60); - assert.equal(data.selector, 'label=Pin'); - assert.equal(data.maestroNonHittableCoordinateFallbackAllowed, true); - assert.equal(data.maestroNonHittableCoordinateFallbackUsed, true); - assert.equal(data.maestroFallbackReason, 'non-hittable-coordinate'); - // Fallback actually TAKEN: the inapplicable maestro cell, no resolution field. - assert.equal(data.resolution, undefined); - }, - ); -}); +test( + scenario('responseConstruction'), + async () => { + await withIosContractDaemon( + [ + runnerTapEntry({ + x: 50, + y: 60, + message: 'tapped via non-hittable coordinate fallback', + maestroNonHittableCoordinateFallbackUsed: true, + }), + ], + async (daemon, transcript) => { + const click = await daemon.callCommand('click', ['label=Pin'], MAESTRO_FLAGS); + const data = assertRpcOk(click); + + // The runner received the fallback permission on the selector tap. + const tapRequest = transcript.calls[0]?.request as Record | undefined; + assert.equal(tapRequest?.selectorValue, 'Pin'); + assert.equal(tapRequest?.allowNonHittableCoordinateFallback, true); + + // Canonical field set plus the fallback markers the replay layer keys on. + assert.equal(data.x, 50); + assert.equal(data.y, 60); + assert.equal(data.selector, 'label=Pin'); + assert.equal(data.maestroNonHittableCoordinateFallbackAllowed, true); + assert.equal(data.maestroNonHittableCoordinateFallbackUsed, true); + assert.equal(data.maestroFallbackReason, 'non-hittable-coordinate'); + // Fallback actually TAKEN: the inapplicable maestro cell, no resolution field. + assert.equal(data.resolution, undefined); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); // Permission is not usage: with the fallback allowed but the runner hitting // the element normally ("tapped"), the dispatch is the direct-ios path and @@ -125,16 +130,20 @@ test('maestro-non-hittable-fallback fill resolutionDisclosure: allowed-but-not-t ); }); -test(scenario('offscreen'), async () => { - await withIosContractDaemon( - [ - // The runner refuses empty/out-of-app frames. Maestro replay preserves - // this typed result so the compat runtime can own fresh-geometry fallback. - runnerTapErrorEntry(new AppError('ELEMENT_OFFSCREEN', 'Element has no tappable frame')), - ], - async (daemon) => { - const click = await daemon.callCommand('click', ['label=Explore'], MAESTRO_FLAGS); - assertRpcError(click, 'ELEMENT_OFFSCREEN', /no tappable frame/); - }, - ); -}); +test( + scenario('offscreen'), + async () => { + await withIosContractDaemon( + [ + // The runner refuses empty/out-of-app frames. Maestro replay preserves + // this typed result so the compat runtime can own fresh-geometry fallback. + runnerTapErrorEntry(new AppError('ELEMENT_OFFSCREEN', 'Element has no tappable frame')), + ], + async (daemon) => { + const click = await daemon.callCommand('click', ['label=Explore'], MAESTRO_FLAGS); + assertRpcError(click, 'ELEMENT_OFFSCREEN', /no tappable frame/); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); diff --git a/test/integration/interaction-contract/runtime-ref.contract.test.ts b/test/integration/interaction-contract/runtime-ref.contract.test.ts index 4d3118ac53..6f654f84bb 100644 --- a/test/integration/interaction-contract/runtime-ref.contract.test.ts +++ b/test/integration/interaction-contract/runtime-ref.contract.test.ts @@ -4,6 +4,7 @@ import type { InteractionGuarantee } from '../../../src/contracts/interaction-gu import type { Point } from '@agent-device/kernel/snapshot'; import { ref } from '../../../src/commands/interaction/runtime/selector-read.ts'; import { assertRpcOk } from '../provider-scenarios/assertions.ts'; +import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from '../provider-scenarios/test-timeouts.ts'; import { scenarioName, scenarioNames } from './coverage-manifest.ts'; import { RUNTIME_REF_COVERAGE } from './runtime-ref.coverage.ts'; import { @@ -146,23 +147,29 @@ test(scenario('responseIdentity'), async () => { assert.ok(Array.isArray(result.selectorChain) && result.selectorChain.length > 0); }); -test(scenario('responseConstruction'), async () => { - await withIosContractDaemon( - [runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry({ x: 200, y: 322 })], - async (daemon) => { - const snapshot = await daemon.callCommand('snapshot', [], { snapshotInteractiveOnly: true }); - assertRpcOk(snapshot); - - const press = await daemon.callCommand('press', ['@e2']); - const data = assertRpcOk(press); - // Canonical ref response set from the shared construction site. - assert.equal(data.ref, 'e2'); - assert.equal(data.x, 200); - assert.equal(data.y, 322); - assert.match(String(data.message), /Tapped @e2/); - }, - ); -}); +test( + scenario('responseConstruction'), + async () => { + await withIosContractDaemon( + [runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry({ x: 200, y: 322 })], + async (daemon) => { + const snapshot = await daemon.callCommand('snapshot', [], { + snapshotInteractiveOnly: true, + }); + assertRpcOk(snapshot); + + const press = await daemon.callCommand('press', ['@e2']); + const data = assertRpcOk(press); + // Canonical ref response set from the shared construction site. + assert.equal(data.ref, 'e2'); + assert.equal(data.x, 200); + assert.equal(data.y, 322); + assert.match(String(data.message), /Tapped @e2/); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); test(scenarioNames(RUNTIME_REF_COVERAGE, 'resolutionDisclosure')[0]!, async () => { const device = createContractDevice(continueButtonSnapshot(), { diff --git a/test/integration/interaction-contract/runtime-selector.contract.test.ts b/test/integration/interaction-contract/runtime-selector.contract.test.ts index 6fade22f9b..1e362f6d3d 100644 --- a/test/integration/interaction-contract/runtime-selector.contract.test.ts +++ b/test/integration/interaction-contract/runtime-selector.contract.test.ts @@ -4,6 +4,7 @@ import type { InteractionGuarantee } from '../../../src/contracts/interaction-gu import type { Point } from '@agent-device/kernel/snapshot'; import { selector } from '../../../src/commands/interaction/runtime/selector-read.ts'; import { assertRpcOk } from '../provider-scenarios/assertions.ts'; +import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from '../provider-scenarios/test-timeouts.ts'; import { scenarioName, scenarioNames } from './coverage-manifest.ts'; import { RUNTIME_SELECTOR_COVERAGE } from './runtime-selector.coverage.ts'; import { @@ -197,20 +198,24 @@ test(scenario('responseIdentity'), async () => { assert.ok(Array.isArray(result.selectorChain) && result.selectorChain.length > 0); }); -test(scenario('responseConstruction'), async () => { - await withIosContractDaemon( - [runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry({ x: 200, y: 322 })], - async (daemon) => { - const press = await daemon.callCommand('press', ['label=Continue']); - const data = assertRpcOk(press); - // Canonical selector response set from the shared construction site. - assert.equal(data.x, 200); - assert.equal(data.y, 322); - assert.equal(data.selector, 'label=Continue'); - assert.ok(Array.isArray(data.selectorChain)); - }, - ); -}); +test( + scenario('responseConstruction'), + async () => { + await withIosContractDaemon( + [runnerSnapshotEntry(RUNNER_CONTINUE_NODES), runnerTapEntry({ x: 200, y: 322 })], + async (daemon) => { + const press = await daemon.callCommand('press', ['label=Continue']); + const data = assertRpcOk(press); + // Canonical selector response set from the shared construction site. + assert.equal(data.x, 200); + assert.equal(data.y, 322); + assert.equal(data.selector, 'label=Continue'); + assert.ok(Array.isArray(data.selectorChain)); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); test(scenarioNames(RUNTIME_SELECTOR_COVERAGE, 'resolutionDisclosure')[0]!, async () => { const device = createContractDevice(continueButtonSnapshot(), { diff --git a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts index 835e370be6..516203bae3 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import type { IncomingHttpHeaders, ServerResponse } from 'node:http'; +import type { IncomingHttpHeaders } from 'node:http'; import path from 'node:path'; import { test } from 'vitest'; import { @@ -24,9 +24,9 @@ import { withProviderScenarioResource, withProviderScenarioTempDir } from './har import { CloudWebDriverTestServer, type CloudWebDriverHttpCall, + cloudWebDriverTestJson, startCloudWebDriverTestServer, type StartedCloudWebDriverTestServer, - writeCloudWebDriverTestJson, } from './cloud-webdriver-test-server.ts'; test('BrowserStack adapter prepares App Automate capabilities and uploads install artifacts', async () => { @@ -520,22 +520,19 @@ class FakeCloudProviderServer extends CloudWebDriverTestServer { return await startCloudWebDriverTestServer(new FakeCloudProviderServer()); } - protected respond(call: CloudWebDriverHttpCall, res: ServerResponse): void { + protected respond(call: CloudWebDriverHttpCall) { if (call.method === 'POST' && call.path === '/wd/hub/session') { if (this.sessionFailuresRemaining > 0) { this.sessionFailuresRemaining -= 1; - writeCloudWebDriverTestJson(res, { value: { message: 'transient provider failure' } }, 503); - return; + return cloudWebDriverTestJson({ value: { message: 'transient provider failure' } }, 503); } - writeCloudWebDriverTestJson(res, { value: { sessionId: 'wd-1', capabilities: {} } }); - return; + return cloudWebDriverTestJson({ value: { sessionId: 'wd-1', capabilities: {} } }); } if (call.method === 'POST' && call.path === '/app-automate/upload') { - writeCloudWebDriverTestJson(res, { app_url: 'bs://uploaded-app' }); - return; + return cloudWebDriverTestJson({ app_url: 'bs://uploaded-app' }); } if (call.method === 'GET' && call.path === '/app-automate/sessions/wd-1.json') { - writeCloudWebDriverTestJson(res, { + return cloudWebDriverTestJson({ automation_session: { video_url: 'https://browserstack.example/video.mp4', appium_logs_url: 'https://browserstack.example/appium.log', @@ -544,9 +541,8 @@ class FakeCloudProviderServer extends CloudWebDriverTestServer { public_url: 'https://browserstack.example/public', }, }); - return; } - writeCloudWebDriverTestJson(res, { value: null }); + return cloudWebDriverTestJson({ value: null }); } } diff --git a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts index 6480fd40bb..5115b17c58 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import type { ServerResponse } from 'node:http'; import { test } from 'vitest'; import { createCloudWebDriverRuntime } from '../../../src/cloud-webdriver/runtime.ts'; import { createDefaultCloudWebDriverProviderRuntimes } from '../../../src/cloud-webdriver/provider-runtimes.ts'; @@ -23,9 +22,9 @@ import { runProviderScenario, type ProviderScenarioStep } from './scenario.ts'; import { CloudWebDriverTestServer, type CloudWebDriverHttpCall, + cloudWebDriverTestJson, startCloudWebDriverTestServer, type StartedCloudWebDriverTestServer, - writeCloudWebDriverTestJson, } from './cloud-webdriver-test-server.ts'; const WEBDRIVER_PROVIDER = 'webdriver-fake'; @@ -502,48 +501,42 @@ class FakeWebDriverServer extends CloudWebDriverTestServer { return await startCloudWebDriverTestServer(new FakeWebDriverServer()); } - protected respond(call: CloudWebDriverHttpCall, res: ServerResponse): void { - respondToFakeWebDriverCall(this, call, res); + protected respond(call: CloudWebDriverHttpCall) { + return respondToFakeWebDriverCall(this, call); } } function respondToFakeWebDriverCall( server: FakeWebDriverServer, call: CloudWebDriverHttpCall, - res: ServerResponse, -): void { +): ReturnType { switch (`${call.method} ${call.path}`) { case 'POST /wd/hub/session': - writeFakeCreateSessionResponse(server, res); - return; + return fakeCreateSessionResponse(server); case 'GET /wd/hub/session/wd-1/source': - writeCloudWebDriverTestJson(res, { value: fakeWebDriverSource() }); - return; + return cloudWebDriverTestJson({ value: fakeWebDriverSource() }); case 'GET /wd/hub/session/wd-1/window/rect': - writeCloudWebDriverTestJson(res, { value: { x: 0, y: 0, width: 1080, height: 1920 } }); - return; + return cloudWebDriverTestJson({ value: { x: 0, y: 0, width: 1080, height: 1920 } }); case 'DELETE /wd/hub/session/wd-1/actions': - writeCloudWebDriverTestJson( - res, + return cloudWebDriverTestJson( { value: { message: 'The requested resource could not be found.' } }, 500, ); - return; case 'DELETE /wd/hub/session/wd-1': - writeFakeDeleteSessionResponse(server, res); - return; + return fakeDeleteSessionResponse(server); default: - writeCloudWebDriverTestJson(res, { value: null }); + return cloudWebDriverTestJson({ value: null }); } } -function writeFakeCreateSessionResponse(server: FakeWebDriverServer, res: ServerResponse): void { +function fakeCreateSessionResponse( + server: FakeWebDriverServer, +): ReturnType { if (server.createSessionFailuresRemaining > 0) { server.createSessionFailuresRemaining -= 1; - writeCloudWebDriverTestJson(res, { value: { message: 'create session failed' } }, 500); - return; + return cloudWebDriverTestJson({ value: { message: 'create session failed' } }, 500); } - writeCloudWebDriverTestJson(res, { + return cloudWebDriverTestJson({ value: { sessionId: 'wd-1', capabilities: { platformName: 'Android' }, @@ -551,13 +544,14 @@ function writeFakeCreateSessionResponse(server: FakeWebDriverServer, res: Server }); } -function writeFakeDeleteSessionResponse(server: FakeWebDriverServer, res: ServerResponse): void { +function fakeDeleteSessionResponse( + server: FakeWebDriverServer, +): ReturnType { if (server.sessionDeleteFailuresRemaining > 0) { server.sessionDeleteFailuresRemaining -= 1; - writeCloudWebDriverTestJson(res, { value: { message: 'stale webdriver session' } }, 500); - return; + return cloudWebDriverTestJson({ value: { message: 'stale webdriver session' } }, 500); } - writeCloudWebDriverTestJson(res, { value: null }); + return cloudWebDriverTestJson({ value: null }); } function fakeWebDriverSource(): string { diff --git a/test/integration/provider-scenarios/cloud-webdriver-test-server.ts b/test/integration/provider-scenarios/cloud-webdriver-test-server.ts index f9df7c74d5..7f72b38c54 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-test-server.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-test-server.ts @@ -1,9 +1,4 @@ -import assert from 'node:assert/strict'; -import http, { - type IncomingHttpHeaders, - type IncomingMessage, - type ServerResponse, -} from 'node:http'; +import type { IncomingHttpHeaders } from 'node:http'; export type CloudWebDriverHttpCall = { method: string; @@ -12,33 +7,44 @@ export type CloudWebDriverHttpCall = { body?: unknown; }; +export type CloudWebDriverTestResponse = { + body: unknown; + status?: number; +}; + +/** + * In-memory Fetch transport for Cloud WebDriver integration scenarios. + * + * The production client still uses Fetch; this fixture observes the exact + * request URL, headers and body, then supplies a real Response. Avoiding a + * loopback listener keeps the suite hermetic in sandboxes that prohibit + * binding ports without reducing request/response transport coverage. + */ export abstract class CloudWebDriverTestServer { readonly calls: CloudWebDriverHttpCall[] = []; - url = ''; - - constructor() { - const server = http.createServer(); - server.on('request', async (req, res) => await this.handle(req, res)); - cloudWebDriverHttpServers.set(this, server); - } + // fallow-ignore-next-line unused-class-member + readonly url = 'http://cloud-webdriver.test'; - protected abstract respond(call: CloudWebDriverHttpCall, res: ServerResponse): void; + protected abstract respond(call: CloudWebDriverHttpCall): CloudWebDriverTestResponse; - private async handle(req: IncomingMessage, res: ServerResponse): Promise { - const body = await readRequestBody(req); + // fallow-ignore-next-line unused-class-member + readonly fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init); const call: CloudWebDriverHttpCall = { - method: req.method ?? 'GET', - path: req.url ?? '/', - headers: req.headers, - ...(body === undefined ? {} : { body }), + method: request.method, + path: new URL(request.url).pathname, + headers: Object.fromEntries(request.headers.entries()), + ...(await requestBody(request)), }; this.calls.push(call); - this.respond(call, res); - } + const response = this.respond(call); + return new Response(JSON.stringify(response.body), { + status: response.status ?? 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; } -const cloudWebDriverHttpServers = new WeakMap(); - export type StartedCloudWebDriverTestServer = T & { close(): Promise; }; @@ -46,50 +52,25 @@ export type StartedCloudWebDriverTestServer export async function startCloudWebDriverTestServer( testServer: T, ): Promise> { - const server = getCloudWebDriverHttpServer(testServer); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); - const address = server.address(); - assert.ok(address && typeof address === 'object'); - testServer.url = `http://127.0.0.1:${address.port}`; + const previousFetch = globalThis.fetch; + globalThis.fetch = testServer.fetch; return Object.assign(testServer, { - close: async () => await closeCloudWebDriverTestServer(testServer), + close: async () => { + if (globalThis.fetch === testServer.fetch) globalThis.fetch = previousFetch; + }, }); } -export function writeCloudWebDriverTestJson( - res: ServerResponse, - body: unknown, - status = 200, -): void { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(body)); +export function cloudWebDriverTestJson(body: unknown, status = 200): CloudWebDriverTestResponse { + return { body, status }; } -async function readRequestBody(req: IncomingMessage): Promise { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); - } - const buffer = Buffer.concat(chunks); - if (req.headers['content-type']?.startsWith('multipart/form-data') === true) { - return { multipartBytes: buffer.length }; +async function requestBody(request: Request): Promise<{ body?: unknown }> { + if (!request.body) return {}; + const buffer = Buffer.from(await request.arrayBuffer()); + if (request.headers.get('content-type')?.startsWith('multipart/form-data')) { + return { body: { multipartBytes: buffer.length } }; } const text = buffer.toString('utf8'); - return text ? (JSON.parse(text) as unknown) : undefined; -} - -function getCloudWebDriverHttpServer(testServer: CloudWebDriverTestServer): http.Server { - const server = cloudWebDriverHttpServers.get(testServer); - assert.ok(server); - return server; -} - -async function closeCloudWebDriverTestServer(testServer: CloudWebDriverTestServer): Promise { - const server = getCloudWebDriverHttpServer(testServer); - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); + return text ? { body: JSON.parse(text) as unknown } : {}; } diff --git a/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts b/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts index 76e13a508d..961736a5c8 100644 --- a/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts +++ b/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { assertRpcError, assertRpcOk } from './assertions.ts'; +import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from './test-timeouts.ts'; import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; import { createProviderScenarioHarness, @@ -272,27 +273,31 @@ test('Provider-backed integration maestro replay dispatch keeps runner AMBIGUOUS }); }); -test('Provider-backed integration maestro replay dispatch keeps runner ELEMENT_OFFSCREEN without fallback', async () => { - const transcript = createProviderTranscript([ - { - command: 'ios.runner.tap', - deviceId: DEVICE_ID, - platform: 'apple', - request: { - command: 'tap', - selectorKey: 'label', - selectorValue: 'Continue', - allowNonHittableCoordinateFallback: true, - appBundleId: APP, +test( + 'Provider-backed integration maestro replay dispatch keeps runner ELEMENT_OFFSCREEN without fallback', + async () => { + const transcript = createProviderTranscript([ + { + command: 'ios.runner.tap', + deviceId: DEVICE_ID, + platform: 'apple', + request: { + command: 'tap', + selectorKey: 'label', + selectorValue: 'Continue', + allowNonHittableCoordinateFallback: true, + appBundleId: APP, + }, + error: new AppError('ELEMENT_OFFSCREEN', 'element resolved off-screen at (-161, 265)'), }, - error: new AppError('ELEMENT_OFFSCREEN', 'element resolved off-screen at (-161, 265)'), - }, - ]); + ]); - await withDirectSelectorScenario(transcript, async (daemon) => { - const click = await daemon.callCommand('click', ['label="Continue"'], { - maestro: { allowNonHittableCoordinateFallback: true }, + await withDirectSelectorScenario(transcript, async (daemon) => { + const click = await daemon.callCommand('click', ['label="Continue"'], { + maestro: { allowNonHittableCoordinateFallback: true }, + }); + assertRpcError(click, 'ELEMENT_OFFSCREEN', /resolved off-screen/); }); - assertRpcError(click, 'ELEMENT_OFFSCREEN', /resolved off-screen/); - }); -}); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); diff --git a/test/integration/provider-scenarios/ios-lifecycle.test.ts b/test/integration/provider-scenarios/ios-lifecycle.test.ts index 71d90a4178..2becd3f0c5 100644 --- a/test/integration/provider-scenarios/ios-lifecycle.test.ts +++ b/test/integration/provider-scenarios/ios-lifecycle.test.ts @@ -13,6 +13,7 @@ import { PROVIDER_SCENARIO_IOS_SIMULATOR, } from './fixtures.ts'; import { withProviderScenarioResource } from './harness.ts'; +import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from './test-timeouts.ts'; test('Provider-backed integration iOS Settings flow uses scripted simctl and runner providers', async () => { await withProviderScenarioResource( @@ -298,39 +299,43 @@ test('Provider-backed integration iOS Settings flow uses scripted simctl and run ); }); -test('Provider-backed integration iOS regular snapshot preserves fixed bottom tabs after scroll content', async () => { - await withProviderScenarioResource( - createIosBottomTabsSnapshotWorld, - async ({ daemon, runnerTranscript }) => { - await daemon.callCommand('open', ['org.reactnavigation.playground'], { - platform: 'ios', - udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - }); +test( + 'Provider-backed integration iOS regular snapshot preserves fixed bottom tabs after scroll content', + async () => { + await withProviderScenarioResource( + createIosBottomTabsSnapshotWorld, + async ({ daemon, runnerTranscript }) => { + await daemon.callCommand('open', ['org.reactnavigation.playground'], { + platform: 'ios', + udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + }); - const snapshot = await daemon.callCommand('snapshot'); - const data = snapshot.json?.result?.data; - const nodes = data?.nodes ?? []; - assert.equal(data?.truncated, false); - assert.ok( - nodes.some((node: { identifier?: string }) => node.identifier === 'article'), - JSON.stringify(nodes), - ); - assert.ok( - nodes.some((node: { identifier?: string }) => node.identifier === 'contacts'), - JSON.stringify(nodes), - ); - assert.ok( - nodes.some((node: { identifier?: string }) => node.identifier === 'albums'), - JSON.stringify(nodes), - ); - assert.equal( - nodes.find((node: { label?: string }) => node.label === 'Contacts')?.hiddenContentBelow, - true, - ); - runnerTranscript.assertComplete(); - }, - ); -}); + const snapshot = await daemon.callCommand('snapshot'); + const data = snapshot.json?.result?.data; + const nodes = data?.nodes ?? []; + assert.equal(data?.truncated, false); + assert.ok( + nodes.some((node: { identifier?: string }) => node.identifier === 'article'), + JSON.stringify(nodes), + ); + assert.ok( + nodes.some((node: { identifier?: string }) => node.identifier === 'contacts'), + JSON.stringify(nodes), + ); + assert.ok( + nodes.some((node: { identifier?: string }) => node.identifier === 'albums'), + JSON.stringify(nodes), + ); + assert.equal( + nodes.find((node: { label?: string }) => node.label === 'Contacts')?.hiddenContentBelow, + true, + ); + runnerTranscript.assertComplete(); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); test('Provider-backed integration iOS physical reinstall uses scripted devicectl provider', async () => { await withProviderScenarioResource( diff --git a/test/integration/provider-scenarios/test-timeouts.ts b/test/integration/provider-scenarios/test-timeouts.ts new file mode 100644 index 0000000000..856141fbf1 --- /dev/null +++ b/test/integration/provider-scenarios/test-timeouts.ts @@ -0,0 +1,6 @@ +// These scenarios construct complete in-process daemon/provider fixtures. In +// Vitest's default parallel workload, module transforms and sibling workers +// can delay their valid setup beyond the general 5s unit-test budget. Keep +// the exception at the affected scenarios rather than relaxing a whole test +// project. Measured on 2026-07-30: the parallel tail reached 4.8s. +export const PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS = 10_000;