diff --git a/packages/provider-webdriver/package.json b/packages/provider-webdriver/package.json index 596d8f1c3d..d4d56b8d0e 100644 --- a/packages/provider-webdriver/package.json +++ b/packages/provider-webdriver/package.json @@ -7,6 +7,7 @@ "dependencies": { "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", + "@agent-device/host-kit": "workspace:*", "@agent-device/kernel": "workspace:*", "@agent-device/xml": "workspace:*" }, diff --git a/packages/provider-webdriver/src/webdriver-client.ts b/packages/provider-webdriver/src/webdriver-client.ts index b9cc59d87a..06276c4cf9 100644 --- a/packages/provider-webdriver/src/webdriver-client.ts +++ b/packages/provider-webdriver/src/webdriver-client.ts @@ -1,6 +1,8 @@ import fs from 'node:fs/promises'; import { AppError } from '@agent-device/kernel/errors'; import { + emitWebDriverDiagnostic, + isWebDriverRouteUnsupported, WebDriverTransport, type WebDriverAuth, type WebDriverRequestOverrides, @@ -92,17 +94,13 @@ export class WebDriverClient { 'POST', '/session', { capabilities: normalizeCapabilities(capabilities) }, - { - retryAttempts: 0, - timeoutMs: budgetWithin(this.sessionCreateTimeoutMs, options?.deadline), - }, + { timeoutMs: budgetWithin(this.sessionCreateTimeoutMs, options?.deadline) }, ); const session = readSession(value); this.sessionId = session.sessionId; return session; } - // fallow-ignore-next-line unused-class-member async deleteSession(): Promise { const sessionId = this.requireSessionId(); await this.requestValue('DELETE', `/session/${sessionId}`); @@ -114,18 +112,36 @@ export class WebDriverClient { } async activateApp(appId: string): Promise { - try { - await this.sessionRequest('POST', '/appium/device/activate_app', { appId }); - } catch { - await this.executeScript('mobile: activateApp', [{ appId, bundleId: appId }]); - } + await this.appRouteWithSibling('/appium/device/activate_app', 'mobile: activateApp', appId); } async terminateApp(appId: string): Promise { + await this.appRouteWithSibling('/appium/device/terminate_app', 'mobile: terminateApp', appId); + } + + /** + * Sends the Appium app route, and the `mobile:` sibling script only when the driver answered + * that it does not implement the first route. + */ + private async appRouteWithSibling( + route: string, + siblingScript: string, + appId: string, + ): Promise { try { - await this.sessionRequest('POST', '/appium/device/terminate_app', { appId }); - } catch { - await this.executeScript('mobile: terminateApp', [{ appId, bundleId: appId }]); + await this.sessionRequest('POST', route, { appId }); + } catch (error) { + if (!isWebDriverRouteUnsupported(error)) throw error; + const { status } = await this.transport.request( + 'POST', + `/session/${this.requireSessionId()}/execute/sync`, + { script: siblingScript, args: [{ appId, bundleId: appId }] }, + ); + emitWebDriverDiagnostic('webdriver_route_fallback', { + from: route, + to: siblingScript, + status, + }); } } @@ -134,7 +150,7 @@ export class WebDriverClient { } async releaseActions(): Promise { - await this.sessionRequest('DELETE', '/actions', undefined, { retryAttempts: 0 }); + await this.sessionRequest('DELETE', '/actions'); } async sendKeys(text: string): Promise { @@ -142,9 +158,7 @@ export class WebDriverClient { } async hideKeyboard(): Promise { - await this.sessionRequest('POST', '/appium/device/hide_keyboard', undefined, { - retryAttempts: 0, - }); + await this.sessionRequest('POST', '/appium/device/hide_keyboard'); } /** @@ -167,7 +181,7 @@ export class WebDriverClient { ...(timeoutMs === undefined ? {} : { timeoutMs }), }); } catch (error) { - if (isUnimplementedWebDriverRoute(error)) return 'unsupported'; + if (isWebDriverRouteUnsupported(error)) return 'unsupported'; throw error; } return typeof value === 'boolean' ? value : 'unsupported'; @@ -207,7 +221,7 @@ export class WebDriverClient { await this.sessionRequest('GET', '/element/active', undefined, requestBudget(deadline)), ); } catch (error) { - if (isUnimplementedWebDriverRoute(error)) return 'unsupported'; + if (isWebDriverRouteUnsupported(error)) return 'unsupported'; if (isNoSuchElementError(error)) return 'none'; throw error; } @@ -221,7 +235,7 @@ export class WebDriverClient { ); return { id: elementId, rect: readWindowRect(value) }; } catch (error) { - if (isUnimplementedWebDriverRoute(error)) return 'unsupported'; + if (isWebDriverRouteUnsupported(error)) return 'unsupported'; // The focused element went away between the two calls — a stale answer, // not a broken driver. Report it as "nothing focused" so the caller polls // again rather than failing on a race it can simply retry out of. @@ -274,10 +288,21 @@ export class WebDriverClient { return readWindowRect(await this.sessionRequest('GET', '/window/rect')); } + /** A script that may change device state; it is sent once. */ async executeScript(script: string, args: unknown[] = []): Promise { return await this.sessionRequest('POST', '/execute/sync', { script, args }); } + /** A script that only reads, so it may be resent after an ambiguous failure. */ + async executeReadScript(script: string, args: unknown[] = []): Promise { + return await this.sessionRequest( + 'POST', + '/execute/sync', + { script, args }, + { idempotent: true }, + ); + } + private async sessionRequest( method: string, pathSuffix: string, @@ -362,18 +387,6 @@ function readSession(value: unknown): WebDriverSession { return { sessionId, capabilities }; } -/** - * A route this driver does not implement, as opposed to one that failed. - * - * Classified from the W3C error code, NOT from the HTTP status: `unknown - * command` and `invalid session id` are both 404, so a status test would read a - * dead session as a missing feature — exactly the confusion that turns a broken - * session into a blind text entry. Only 405/501 are unambiguous enough to stand - * on their own. A 5xx, an auth rejection, or a timeout is a real failure. - */ -const UNIMPLEMENTED_WEBDRIVER_STATUSES = new Set([405, 501]); -const UNIMPLEMENTED_WEBDRIVER_ERRORS = new Set(['unknown command', 'unknown method']); - /** * The W3C element identifier, whose key is the spec's fixed UUID rather than a * readable name. Appium also echoes the legacy `ELEMENT` key; accept either so @@ -414,13 +427,6 @@ function isNoSuchElementError(error: unknown): boolean { return readWebDriverErrorCode(error).toLowerCase() === 'no such element'; } -function isUnimplementedWebDriverRoute(error: unknown): boolean { - if (!(error instanceof AppError)) return false; - const status = error.details?.status; - if (typeof status === 'number' && UNIMPLEMENTED_WEBDRIVER_STATUSES.has(status)) return true; - return UNIMPLEMENTED_WEBDRIVER_ERRORS.has(readWebDriverErrorCode(error).toLowerCase()); -} - function readWebDriverErrorCode(error: AppError): string { const response = error.details?.response; const value = diff --git a/packages/provider-webdriver/src/webdriver-interactor.ts b/packages/provider-webdriver/src/webdriver-interactor.ts index 6b677904c9..2d748e582e 100644 --- a/packages/provider-webdriver/src/webdriver-interactor.ts +++ b/packages/provider-webdriver/src/webdriver-interactor.ts @@ -363,7 +363,7 @@ class WebDriverInteractor implements Interactor { async readClipboard(): Promise { this.requireSupport('clipboard.read'); - const value = await this.client.executeScript('mobile: getClipboard', [{}]); + const value = await this.client.executeReadScript('mobile: getClipboard', [{}]); return typeof value === 'string' ? value : ''; } diff --git a/packages/provider-webdriver/src/webdriver-mutation-resend.test.ts b/packages/provider-webdriver/src/webdriver-mutation-resend.test.ts new file mode 100644 index 0000000000..3c10e06246 --- /dev/null +++ b/packages/provider-webdriver/src/webdriver-mutation-resend.test.ts @@ -0,0 +1,333 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { withDiagnosticsScope } from '@agent-device/host-kit/diagnostics'; +import { AppError } from '@agent-device/kernel/errors'; +import { createCloudWebDriverCapabilities } from './capabilities.ts'; +import { mkdtempForTest } from './tmp-dir.fixtures.ts'; +import { WebDriverClient } from './webdriver-client.ts'; +import { createWebDriverInteractor } from './webdriver-interactor.ts'; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** + * The route a request addresses: the `mobile:` script name for `POST .../execute/sync`, otherwise + * the method and path with the session id replaced by `:id`. + */ +function routeOf(input: Parameters[0], init?: RequestInit): string { + const url = new URL(input instanceof Request ? input.url : String(input)); + const method = init?.method ?? 'GET'; + const pathname = url.pathname + .replace(/^\/wd\/hub/, '') + .replace(/^\/session\/wd-1/, '/session/:id'); + if (pathname.endsWith('/execute/sync') && typeof init?.body === 'string') { + return (JSON.parse(init.body) as { script: string }).script; + } + return `${method} ${pathname}`; +} + +/** + * A grid that never answers `hangRoute` until the transport's own timeout aborts it, answers + * `unsupportedRoute` with a W3C 404, and answers every other route immediately. A hung request is + * the shape a slow driver takes on a real provider: it received the request and is still working + * on it. + */ +function hangingWebDriverFetch( + hangRoute: string, + unsupportedRoute?: string, + unsupportedErrorCode = 'unknown command', +): { fetch: typeof globalThis.fetch; sendsTo: (route: string) => number } { + const routes: string[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const route = routeOf(input, init); + routes.push(route); + if (route === hangRoute) { + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason as Error)); + }); + } + if (route === 'POST /session') { + return Response.json({ value: { sessionId: 'wd-1', capabilities: {} } }); + } + if (route === unsupportedRoute) { + return Response.json( + { value: { error: unsupportedErrorCode, message: 'refused' } }, + { status: 404 }, + ); + } + return Response.json({ value: null }); + }; + return { fetch, sendsTo: (route) => routes.filter((sent) => sent === route).length }; +} + +/** + * A real `WebDriverClient` and `createWebDriverInteractor` over the hanging grid. The default + * transport policy retries once, so a request that is resent shows two sends. + */ +async function connectedWebDriverInteractor(options: { + hangRoute: string; + unsupportedRoute?: string; + unsupportedErrorCode?: string; + createSession?: boolean; +}) { + const { fetch, sendsTo } = hangingWebDriverFetch( + options.hangRoute, + options.unsupportedRoute, + options.unsupportedErrorCode, + ); + globalThis.fetch = fetch; + const client = new WebDriverClient({ + clientVersion: '0.0.0-test', + endpoint: 'http://cloud-webdriver.test/wd/hub/', + requestPolicy: { timeoutMs: 20, retryDelayMs: 5, sessionCreateTimeoutMs: 20 }, + }); + if (options.createSession !== false) await client.createSession({ platformName: 'Android' }); + const interactor = createWebDriverInteractor({ + client, + backend: 'android', + capabilities: createCloudWebDriverCapabilities({ + provider: 'test', + platform: 'android', + overrides: { + home: 'supported', + 'clipboard.read': 'supported', + 'clipboard.write': 'supported', + }, + }), + }); + return { client, interactor, sendsTo }; +} + +type Connected = Awaited>; + +type MutatingRouteRow = { + act: (connected: Connected) => Promise; + /** A route the grid answers as unsupported so `act` reaches the hung sibling route. */ + unsupportedRoute?: string; + createSession?: false; +}; + +const MUTATING_ROUTES: Record = { + 'POST /session': { + act: ({ client }) => client.createSession({ platformName: 'Android' }), + createSession: false, + }, + 'DELETE /session/:id': { act: ({ client }) => client.deleteSession() }, + 'POST /session/:id/appium/device/install_app': { + act: ({ client }) => client.installApp('/tmp/app.apk'), + }, + 'POST /session/:id/appium/device/activate_app': { + act: ({ client }) => client.activateApp('com.example.app'), + }, + 'POST /session/:id/appium/device/terminate_app': { + act: ({ client }) => client.terminateApp('com.example.app'), + }, + 'POST /session/:id/appium/device/hide_keyboard': { act: ({ client }) => client.hideKeyboard() }, + 'POST /session/:id/actions': { act: ({ interactor }) => interactor.tap(10, 20) }, + 'DELETE /session/:id/actions': { act: ({ client }) => client.releaseActions() }, + 'POST /session/:id/keys': { act: ({ client }) => client.sendKeys('hello') }, + 'POST /session/:id/back': { act: ({ interactor }) => interactor.back() }, + 'POST /session/:id/rotation': { act: ({ client }) => client.setRotation(90) }, + 'POST /session/:id/orientation': { act: ({ client }) => client.setOrientation('LANDSCAPE') }, + 'mobile: deepLink': { + act: ({ interactor }) => interactor.open('com.example.app', { url: 'example://home' }), + }, + 'mobile: activateApp': { act: ({ interactor }) => interactor.openDevice() }, + 'mobile: terminateApp': { + act: ({ client }) => client.terminateApp('com.example.app'), + unsupportedRoute: 'POST /session/:id/appium/device/terminate_app', + }, + 'mobile: pressButton': { + act: async ({ interactor }) => { + assert.ok(interactor.home); + await interactor.home(); + }, + }, + 'mobile: setClipboard': { + act: async ({ interactor }) => { + assert.ok(interactor.writeClipboard); + await interactor.writeClipboard('copied'); + }, + }, +}; + +/** + * Every mutating route the provider sends, read from its source: each non-GET client request, each + * Appium app route and its `mobile:` sibling, and each `mobile:` script passed to `executeScript`. `POST .../execute/sync` itself is the carrier + * of the `mobile:` scripts, which are enumerated by name. + */ +function mutatingRoutesInSource(): string[] { + const sourceDir = path.dirname(new URL(import.meta.url).pathname); + const sources = fs + .readdirSync(sourceDir) + .filter((file) => file.endsWith('.ts') && !file.endsWith('.test.ts')) + .map((file) => fs.readFileSync(path.join(sourceDir, file), 'utf8')); + const routes = sources.flatMap((source) => [ + ...requestRoutesIn(source), + ...appRoutesWithSiblingIn(source), + ...[...source.matchAll(/\.executeScript\(\s*'(mobile: \w+)'/g)].map((match) => match[1]!), + ]); + return [...new Set(routes)].filter((route) => route !== 'POST /session/:id/execute/sync').sort(); +} + +function requestRoutesIn(source: string): string[] { + return [ + ...source.matchAll( + /(sessionRequest|requestValue)\(\s*'(POST|PUT|PATCH|DELETE)',\s*[`']([^`']+)[`']/g, + ), + ].map(([, helper, method, routePath]) => { + const absolute = helper === 'sessionRequest' ? `/session/:id${routePath}` : routePath!; + return `${method} ${absolute.replace('${sessionId}', ':id')}`; + }); +} + +function appRoutesWithSiblingIn(source: string): string[] { + return [...source.matchAll(/appRouteWithSibling\(\s*'([^']+)',\s*'(mobile: \w+)'/g)].flatMap( + ([, route, script]) => [`POST /session/:id${route}`, script!], + ); +} + +test('the mutating-route table covers every mutating route the provider sends', () => { + assert.deepEqual(Object.keys(MUTATING_ROUTES).sort(), mutatingRoutesInSource()); +}); + +// A resend after a timeout cannot tell whether the first attempt's side effect already landed, so +// every mutating route gets one send and discloses that its outcome is unresolved. +for (const [route, row] of Object.entries(MUTATING_ROUTES)) { + test(`a timed-out ${route} is sent once`, async () => { + const connected = await connectedWebDriverInteractor({ + hangRoute: route, + unsupportedRoute: row.unsupportedRoute, + createSession: row.createSession, + }); + + await assert.rejects(row.act(connected), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }); + + assert.equal(connected.sendsTo(route), 1); + }); +} + +test('a timed-out GET read is resent once', async () => { + const { interactor, sendsTo } = await connectedWebDriverInteractor({ + hangRoute: 'GET /session/:id/source', + }); + + await assert.rejects(interactor.snapshot(), () => true); + + assert.equal(sendsTo('GET /session/:id/source'), 2); +}); + +test('a timed-out mobile: getClipboard read is resent once', async () => { + const { interactor, sendsTo } = await connectedWebDriverInteractor({ + hangRoute: 'mobile: getClipboard', + }); + + assert.ok(interactor.readClipboard); + await assert.rejects(interactor.readClipboard(), () => true); + + assert.equal(sendsTo('mobile: getClipboard'), 2); +}); + +// App activation has a sibling route (`mobile: activateApp`) for drivers without the Appium one. +// A timeout on the first route is not "unsupported": the driver may already be activating the app, +// so switching routes would send the mutation twice. +test('a timed-out app activation does not fall back to the sibling route', async () => { + const { client, sendsTo } = await connectedWebDriverInteractor({ + hangRoute: 'POST /session/:id/appium/device/activate_app', + }); + + await assert.rejects(client.activateApp('com.example.app'), () => true); + + assert.equal(sendsTo('mobile: activateApp'), 0); +}); + +/** The `--debug` diagnostics log a request writes, one parsed event per line. */ +async function debugEvents( + run: () => Promise, +): Promise<{ phase: string; level: string; data?: Record }[]> { + const logPath = path.join(await mkdtempForTest('webdriver-fallback-'), 'request.ndjson'); + const read = () => + fs.existsSync(logPath) + ? fs + .readFileSync(logPath, 'utf8') + .trim() + .split('\n') + .map( + (line) => + JSON.parse(line) as { phase: string; level: string; data?: Record }, + ) + : []; + await withDiagnosticsScope({ command: 'open', debug: true, logPath }, async () => { + await run(); + await vi.waitFor(() => assert.equal(read().length, 2)); + }); + return read(); +} + +// A driver that answers the first route with "unknown command" never ran it, so the sibling route +// is the one attempt that reaches the device. The debug log shows the refused route with its W3C +// code, then the sibling route that ran. +for (const { action, route, script } of [ + { action: 'activateApp', route: '/appium/device/activate_app', script: 'mobile: activateApp' }, + { action: 'terminateApp', route: '/appium/device/terminate_app', script: 'mobile: terminateApp' }, +] as const) { + test(`an unsupported ${action} route falls back to the sibling route once`, async () => { + const { client, sendsTo } = await connectedWebDriverInteractor({ + hangRoute: 'none', + unsupportedRoute: `POST /session/:id${route}`, + }); + + const events = await debugEvents(() => client[action]('com.example.app')); + + assert.deepEqual( + events.map(({ phase, level, data }) => ({ phase, level, data })), + [ + { + phase: 'webdriver_route_unsupported', + level: 'debug', + data: { + method: 'POST', + path: `/session/wd-1${route}`, + status: 404, + code: 'unknown command', + }, + }, + { + phase: 'webdriver_route_fallback', + level: 'debug', + data: { from: route, to: script, status: 200 }, + }, + ], + ); + assert.equal(sendsTo(`POST /session/:id${route}`), 1); + assert.equal(sendsTo(script), 1); + }); +} + +// W3C also answers 404 for a session that no longer exists. That is not an unsupported route, so +// the sibling route is not tried and the failure is not classified as never dispatched here. +test('a 404 naming another W3C error does not fall back to the sibling route', async () => { + const { client, sendsTo } = await connectedWebDriverInteractor({ + hangRoute: 'none', + unsupportedRoute: 'POST /session/:id/appium/device/terminate_app', + unsupportedErrorCode: 'invalid session id', + }); + + await assert.rejects(client.terminateApp('com.example.app'), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, undefined); + return true; + }); + + assert.equal(sendsTo('POST /session/:id/appium/device/terminate_app'), 1); + assert.equal(sendsTo('mobile: terminateApp'), 0); +}); diff --git a/packages/provider-webdriver/src/webdriver-orientation.test.ts b/packages/provider-webdriver/src/webdriver-orientation.test.ts index 91ea8e41bb..4195857282 100644 --- a/packages/provider-webdriver/src/webdriver-orientation.test.ts +++ b/packages/provider-webdriver/src/webdriver-orientation.test.ts @@ -1,21 +1,49 @@ -import { test } from 'vitest'; +import { afterEach, test } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; import type { WebDriverClient } from './webdriver-client.ts'; import { setWebDriverOrientation } from './webdriver-orientation.ts'; +import { WebDriverTransport } from './webdriver-transport.ts'; type Call = { method: string; args: unknown[] }; -/** A driver answering "I do not implement this route" — the only case that earns a fallback. */ -function unsupportedEndpointError(): AppError { - return new AppError('COMMAND_FAILED', 'Unknown command', { - status: 404, - response: { value: { error: 'unknown command' } }, +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** The error the real transport raises for this driver answer. */ +async function driverAnswerError(driver: typeof globalThis.fetch): Promise { + const transport = new WebDriverTransport({ + clientVersion: '0.0.0-test', + endpoint: 'http://cloud-webdriver.test/wd/hub/', + requestPolicy: { timeoutMs: 20 }, }); + globalThis.fetch = driver; + try { + await transport.requestValue('POST', '/session/wd-1/rotation', {}); + } catch (error) { + return error; + } finally { + globalThis.fetch = realFetch; + } + throw new Error('the driver answer did not fail'); +} + +function answer(status: number, body: unknown): () => Promise { + return () => driverAnswerError(async () => Response.json(body, { status })); } -function makeClient(options: { reject?: readonly string[]; rejectWith?: () => unknown } = {}): { +/** A driver answering "I do not implement this route" — the only case that earns a fallback. */ +const unsupportedEndpointError = answer(404, { + value: { error: 'unknown command', message: 'Unknown command' }, +}); + +function makeClient( + options: { reject?: readonly string[]; rejectWith?: () => Promise } = {}, +): { client: WebDriverClient; calls: Call[]; } { @@ -25,7 +53,7 @@ function makeClient(options: { reject?: readonly string[]; rejectWith?: () => un const record = (method: string) => { return async (...args: unknown[]): Promise => { calls.push({ method, args }); - if (reject.has(method)) throw rejectWith(); + if (reject.has(method)) throw await rejectWith(); }; }; return { @@ -84,40 +112,44 @@ test('a driver rejecting /rotation degrades to the two-way endpoint', async () = // A transport that fails for any reason other than "not implemented" must surface as itself. The // earlier implementation caught everything, so a timeout or an expired session was reported as an // orientation-support problem with the real cause discarded. -const NON_FALLBACK_FAILURES: readonly { name: string; error: () => unknown }[] = [ +const NON_FALLBACK_FAILURES: readonly { name: string; error: () => Promise }[] = [ { name: 'a request timeout', - error: () => Object.assign(new Error('The operation timed out'), { name: 'TimeoutError' }), + error: () => + driverAnswerError( + async (_input, init) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason as Error)); + }), + ), }, { name: 'an auth rejection', - error: () => new AppError('COMMAND_FAILED', 'Forbidden', { status: 403 }), + error: answer(403, { value: { error: 'unable to set cookie', message: 'Forbidden' } }), }, { name: 'a provider 5xx', - error: () => new AppError('COMMAND_FAILED', 'Bad gateway', { status: 502 }), + error: answer(502, { value: { error: 'unknown error', message: 'Bad gateway' } }), }, { name: 'a dead session', - error: () => new AppError('SESSION_NOT_FOUND', 'WebDriver session has not been created yet.'), + error: async () => + new AppError('SESSION_NOT_FOUND', 'WebDriver session has not been created yet.'), }, { // The status alone says "not found", but the W3C code says the session died. Reading the code // first is what keeps this from being misread as a missing route. name: 'a 404 carrying invalid session id', - error: () => - new AppError('COMMAND_FAILED', 'A session is either terminated or not started', { - status: 404, - response: { value: { error: 'invalid session id' } }, - }), + error: answer(404, { + value: { + error: 'invalid session id', + message: 'A session is either terminated or not started', + }, + }), }, { name: 'a 405 carrying a non-routing error code', - error: () => - new AppError('COMMAND_FAILED', 'Session timed out', { - status: 405, - response: { value: { error: 'timeout' } }, - }), + error: answer(405, { value: { error: 'timeout', message: 'Session timed out' } }), }, ]; diff --git a/packages/provider-webdriver/src/webdriver-orientation.ts b/packages/provider-webdriver/src/webdriver-orientation.ts index ff11196be3..29e29f57db 100644 --- a/packages/provider-webdriver/src/webdriver-orientation.ts +++ b/packages/provider-webdriver/src/webdriver-orientation.ts @@ -5,6 +5,7 @@ import { } from '@agent-device/contracts/device'; import { AppError } from '@agent-device/kernel/errors'; import type { WebDriverClient } from './webdriver-client.ts'; +import { isWebDriverRouteUnsupported } from './webdriver-transport.ts'; export type WebDriverOrientationBackend = 'android' | 'xctest'; @@ -48,7 +49,7 @@ export async function setWebDriverOrientation( // Only a driver saying "I do not implement this endpoint" justifies trying the next one. // A timeout, an auth rejection, a dead session or a 5xx is a real failure, and swallowing it // here would report it as an orientation-support problem and hide the actual cause. - if (!isUnsupportedEndpointError(error)) throw error; + if (!isWebDriverRouteUnsupported(error)) throw error; attempts.push({ transport, error: error instanceof Error ? error.message : String(error) }); } } @@ -64,39 +65,6 @@ export async function setWebDriverOrientation( ); } -/** - * W3C error codes a driver returns for an endpoint it does not implement. Keyed structurally rather - * than by message text, so a driver rewording its prose cannot turn a real failure into a fallback. - */ -const UNSUPPORTED_ENDPOINT_W3C_ERRORS: ReadonlySet = new Set([ - 'unknown command', - 'unknown method', -]); - -/** HTTP statuses that mean "this route does not exist here", as opposed to "it failed". */ -const UNSUPPORTED_ENDPOINT_STATUSES: ReadonlySet = new Set([404, 405]); - -function isUnsupportedEndpointError(error: unknown): boolean { - if (!(error instanceof AppError)) return false; - // A session that never opened, or died, is not an unsupported endpoint. - if (error.code === 'SESSION_NOT_FOUND') return false; - // The W3C code wins whenever the driver sent one: several real failures share the statuses - // below, most importantly a 404 carrying `invalid session id`, which is a dead session rather - // than a missing route. Bare status is only consulted when no code was returned at all. - const code = readW3CErrorCode(error.details?.response); - if (code) return UNSUPPORTED_ENDPOINT_W3C_ERRORS.has(code); - const status = error.details?.status; - return typeof status === 'number' && UNSUPPORTED_ENDPOINT_STATUSES.has(status); -} - -function readW3CErrorCode(response: unknown): string { - if (!response || typeof response !== 'object') return ''; - const value = (response as { value?: unknown }).value; - if (!value || typeof value !== 'object') return ''; - const code = (value as { error?: unknown }).error; - return typeof code === 'string' ? code : ''; -} - async function applyTransport( client: WebDriverClient, transport: OrientationTransport, diff --git a/packages/provider-webdriver/src/webdriver-transport.test.ts b/packages/provider-webdriver/src/webdriver-transport.test.ts index 7d85b57c37..f003ebff64 100644 --- a/packages/provider-webdriver/src/webdriver-transport.test.ts +++ b/packages/provider-webdriver/src/webdriver-transport.test.ts @@ -1,7 +1,18 @@ import assert from 'node:assert/strict'; -import { afterEach, test } from 'vitest'; +import http from 'node:http'; +import net, { type AddressInfo } from 'node:net'; +import { afterEach, test, vi } from 'vitest'; +import { + countDiagnosticEventsByPhase, + withDiagnosticsScope, +} from '@agent-device/host-kit/diagnostics'; import { AppError } from '@agent-device/kernel/errors'; -import { WebDriverTransport, isWebDriverRequestTimeout } from './webdriver-transport.ts'; +import { + WebDriverTransport, + isWebDriverConnectRefused, + isWebDriverRequestTimeout, + isWebDriverRouteUnsupported, +} from './webdriver-transport.ts'; const realFetch = globalThis.fetch; @@ -94,3 +105,254 @@ test('cancels a retry delay when the request binding aborts', async () => { await assert.rejects(pending, /abort|cancel/i); assert.equal(calls, 1); }); + +/** A 127.0.0.1 port nothing listens on: bound, then released. */ +async function refusedPort(): Promise { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +/** A local driver whose every request runs `handle`; `requests` counts what reached it. */ +async function localDriver( + handle: (request: http.IncomingMessage, response: http.ServerResponse) => void, +): Promise<{ endpoint: string; requests: () => number; close: () => Promise }> { + let requests = 0; + const server = http.createServer((request, response) => { + requests += 1; + handle(request, response); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return { + endpoint: `http://127.0.0.1:${port}/wd/hub/`, + requests: () => requests, + close: async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +/** Counts fetch calls while the real fetch still produces the real network failure. */ +function countFetchCalls(): () => number { + let calls = 0; + globalThis.fetch = async (input, init) => { + calls += 1; + return await realFetch(input, init); + }; + return () => calls; +} + +// A refused connection was never established, so no byte of the request reached the driver. A +// GET read keeps its retry. +test('a refused connection discloses the request never reached the driver', async () => { + const transport = new WebDriverTransport({ + clientVersion: '0.0.0-test', + endpoint: `http://127.0.0.1:${await refusedPort()}/wd/hub/`, + requestPolicy: { timeoutMs: 5_000, retryDelayMs: 1 }, + }); + const calls = countFetchCalls(); + + await assert.rejects(transport.requestValue('GET', '/session/wd-1/source'), (error: unknown) => { + assert.ok(isWebDriverConnectRefused(error)); + assert.equal(error.details?.dispatched, 'no'); + return true; + }); + assert.equal(calls(), 2); +}); + +// A POST may change device state, so it gets one attempt whichever failure it hit. +test('a refused POST is not resent', async () => { + const transport = new WebDriverTransport({ + clientVersion: '0.0.0-test', + endpoint: `http://127.0.0.1:${await refusedPort()}/wd/hub/`, + requestPolicy: { timeoutMs: 5_000, retryDelayMs: 1 }, + }); + const calls = countFetchCalls(); + + await assert.rejects(transport.requestValue('POST', '/session/wd-1/actions', {})); + assert.equal(calls(), 1); +}); + +// The driver read the whole body before the socket reset, so it may have acted on it. +test('a socket reset after the driver read the body discloses an unresolved outcome', async () => { + const driver = await localDriver((request) => { + request.resume(); + request.on('end', () => request.socket.destroy()); + }); + const transport = new WebDriverTransport({ + clientVersion: '0.0.0-test', + endpoint: driver.endpoint, + requestPolicy: { timeoutMs: 5_000, retryDelayMs: 1 }, + }); + + try { + await assert.rejects( + transport.requestValue('POST', '/session/wd-1/actions', { actions: [] }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(isWebDriverConnectRefused(error), false); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }, + ); + assert.equal(driver.requests(), 1); + } finally { + await driver.close(); + } +}); + +// The status line arrived, so the driver received the request before the body was cut off. +test('a response body cut off after the headers discloses an unresolved outcome', async () => { + const driver = await localDriver((request, response) => { + request.resume(); + response.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' }); + response.write('{"value":', () => request.socket.destroy()); + }); + const transport = new WebDriverTransport({ + clientVersion: '0.0.0-test', + endpoint: driver.endpoint, + requestPolicy: { timeoutMs: 5_000, retryDelayMs: 1 }, + }); + + try { + await assert.rejects( + transport.requestValue('POST', '/session/wd-1/keys', { value: ['a'] }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(isWebDriverConnectRefused(error), false); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }, + ); + assert.equal(driver.requests(), 1); + } finally { + await driver.close(); + } +}); + +// A 5xx means the driver answered — it received and processed the request — +// but not whether the mutation it described completed before it failed. +test('a 5xx response to a POST discloses an unresolved outcome and is not resent', async () => { + const transport = new WebDriverTransport({ + clientVersion: '0.0.0-test', + endpoint: 'http://cloud-webdriver.test/wd/hub/', + requestPolicy: { timeoutMs: 30_000, retryAttempts: 1, retryDelayMs: 1 }, + }); + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response(JSON.stringify({ value: { message: 'grid unavailable' } }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + await assert.rejects( + transport.requestValue('POST', '/session/wd-1/actions', {}), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }, + ); + assert.equal(calls, 1); +}); + +const ROUTE_ANSWERS: readonly { status: number; body: unknown; unsupported: boolean }[] = [ + { status: 404, body: { value: { error: 'unknown command', message: 'x' } }, unsupported: true }, + { status: 405, body: { value: { error: 'unknown method', message: 'x' } }, unsupported: true }, + { status: 404, body: {}, unsupported: true }, + { status: 405, body: {}, unsupported: true }, + { status: 501, body: {}, unsupported: true }, + { + status: 404, + body: { value: { error: 'invalid session id', message: 'x' } }, + unsupported: false, + }, + { status: 405, body: { value: { error: 'timeout', message: 'x' } }, unsupported: false }, + { status: 501, body: { value: { error: 'unknown error', message: 'x' } }, unsupported: false }, + { status: 500, body: {}, unsupported: false }, + { status: 403, body: {}, unsupported: false }, +]; + +// A W3C error code decides when present; without one, a bare 404, 405, or 501 is the driver +// saying it does not implement the route. +for (const { status, body, unsupported } of ROUTE_ANSWERS) { + test(`HTTP ${status} ${JSON.stringify(body)} is ${unsupported ? '' : 'not '}an unsupported route`, async () => { + const transport = new WebDriverTransport({ + clientVersion: '0.0.0-test', + endpoint: 'http://cloud-webdriver.test/wd/hub/', + }); + globalThis.fetch = async () => Response.json(body, { status }); + + await withDiagnosticsScope({ command: 'back' }, async () => { + await assert.rejects( + transport.requestValue('POST', '/session/wd-1/back'), + (error: unknown) => { + assert.equal(isWebDriverRouteUnsupported(error), unsupported); + if (unsupported) assert.equal((error as AppError).details?.dispatched, 'no'); + return true; + }, + ); + await vi.waitFor(() => + assert.equal( + countDiagnosticEventsByPhase(['webdriver_route_unsupported']), + unsupported ? 1 : 0, + ), + ); + }); + }); +} + +/** The shape undici rejects with: `TypeError('fetch failed')` whose `cause` holds the socket error. */ +function fetchFailedWith(cause: unknown): TypeError { + return new TypeError('fetch failed', { cause }); +} + +function socketError(code: string): Error { + return Object.assign(new Error(code), { code }); +} + +/** `socketError(code)` wrapped in `layers` code-less errors, so the code sits `layers + 1` causes down. */ +function nestedCause(code: string, layers: number): Error { + let error = socketError(code); + for (let layer = 0; layer < layers; layer += 1) error = new Error('wrapped', { cause: error }); + return error; +} + +async function dispatchedAfter(failure: TypeError): Promise { + const transport = new WebDriverTransport({ + clientVersion: '0.0.0-test', + endpoint: 'http://cloud-webdriver.test/wd/hub/', + }); + globalThis.fetch = async () => { + throw failure; + }; + try { + await transport.requestValue('POST', '/session/wd-1/actions', {}); + } catch (error) { + return (error as AppError).details?.dispatched; + } + throw new Error('the request did not fail'); +} + +// Happy-eyeballs connects surface as an AggregateError over every address tried. One address that +// connected and then reset means the request may have reached the driver. +test('an AggregateError discloses no only when every member failed before connecting', async () => { + const allRefused = new AggregateError([socketError('ECONNREFUSED'), socketError('ECONNREFUSED')]); + const oneReset = new AggregateError([socketError('ECONNREFUSED'), socketError('ECONNRESET')]); + + assert.equal(await dispatchedAfter(fetchFailedWith(allRefused)), 'no'); + assert.equal(await dispatchedAfter(fetchFailedWith(oneReset)), 'unknown'); +}); + +// The cause chain is read to depth 4 below the thrown error. A code further down is not read, and +// a failure with no readable code never proves the request stayed off the wire. +test('a pre-connect code is read to the depth cap and not beyond', async () => { + assert.equal(await dispatchedAfter(fetchFailedWith(nestedCause('ECONNREFUSED', 3))), 'no'); + assert.equal(await dispatchedAfter(fetchFailedWith(nestedCause('ECONNREFUSED', 4))), 'unknown'); +}); diff --git a/packages/provider-webdriver/src/webdriver-transport.ts b/packages/provider-webdriver/src/webdriver-transport.ts index 735592b34e..aa1e28993c 100644 --- a/packages/provider-webdriver/src/webdriver-transport.ts +++ b/packages/provider-webdriver/src/webdriver-transport.ts @@ -29,8 +29,57 @@ export function isWebDriverRequestTimeout(error: unknown): error is AppError { return error instanceof AppError && error.details?.reason === WEBDRIVER_REQUEST_TIMEOUT_REASON; } +/** + * What a failed request discloses about whether the driver acted on it. WebDriver never proves + * that a request executed, so a failure is either `no` (the request never reached the driver) or + * `unknown`. + */ +type WebDriverDispatchDisclosure = 'no' | 'unknown'; + +function dispatchDisclosure(dispatched: WebDriverDispatchDisclosure): { + dispatched: WebDriverDispatchDisclosure; +} { + return { dispatched }; +} + +/** Machine-readable `details.reason` of a request that never reached the driver. */ +const WEBDRIVER_CONNECT_REFUSED_REASON = 'webdriver_connect_refused'; + +/** + * Machine-readable `details.reason` of a request whose connection failed after it may have + * reached the driver: a socket reset, or a response body cut off after the status line. + */ +const WEBDRIVER_REQUEST_INTERRUPTED_REASON = 'webdriver_request_interrupted'; + +/** + * Socket error codes that prove the connection was never established, so no byte of the request + * reached the driver. + */ +const PRE_CONNECT_ERROR_CODES: ReadonlySet = new Set([ + 'ECONNREFUSED', + 'ENOTFOUND', + 'EAI_AGAIN', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'UND_ERR_CONNECT_TIMEOUT', +]); + +/** How deep the `cause` and `AggregateError` chain is read for socket error codes. */ +const ERROR_CAUSE_DEPTH_LIMIT = 4; + +/** A request whose connection failed before it was established (refused or unreachable host). */ +export function isWebDriverConnectRefused(error: unknown): error is AppError { + return error instanceof AppError && error.details?.reason === WEBDRIVER_CONNECT_REFUSED_REASON; +} + export type WebDriverRequestOverrides = { retryAttempts?: number; + /** + * The request reads and changes nothing, so a resend after an ambiguous failure is safe. GET + * requests are idempotent by method; this marks a read the protocol sends as a POST, such as a + * `mobile:` read script. + */ + idempotent?: boolean; /** * Per-request transport bound, for callers whose own budget is far shorter * than the client's default. Without it a caller waiting 2s on a poll can be @@ -65,6 +114,16 @@ type ResolvedWebDriverRequestPolicy = Required< NonNullable >; +/** + * Only a request that changes nothing gets the policy's retry budget. A resend after an ambiguous + * outcome (a timeout, or a 5xx after the driver received the request) cannot tell whether the first + * attempt's side effect already landed, so every other request gets exactly one attempt. + */ +const IDEMPOTENT_METHODS: ReadonlySet = new Set(['GET', 'HEAD']); + +/** A successful WebDriver answer: its HTTP status and the unwrapped W3C `value`. */ +export type WebDriverAnswer = { status: number; value: unknown }; + /** Focused HTTP/retry policy for one WebDriver endpoint; session semantics stay in WebDriverClient. */ export class WebDriverTransport { private readonly endpoint: URL; @@ -91,29 +150,35 @@ export class WebDriverTransport { body?: unknown, overrides?: WebDriverRequestOverrides, ): Promise { - return await this.requestValueWithRetries(method, path, body, { - retryAttempts: overrides?.retryAttempts ?? this.requestPolicy.retryAttempts, + return (await this.request(method, path, body, overrides)).value; + } + + /** The request's answer with the HTTP status it arrived with. */ + async request( + method: string, + path: string, + body?: unknown, + overrides?: WebDriverRequestOverrides, + ): Promise { + const idempotent = overrides?.idempotent ?? IDEMPOTENT_METHODS.has(method); + return await this.requestWithRetries(method, path, body, { + retryAttempts: + overrides?.retryAttempts ?? (idempotent ? this.requestPolicy.retryAttempts : 0), timeoutMs: overrides?.timeoutMs ?? this.requestPolicy.timeoutMs, signal: overrides?.signal, }); } - private async requestValueWithRetries( + private async requestWithRetries( method: string, path: string, body: unknown, overrides: ResolvedWebDriverRequestOverrides, - ): Promise { + ): Promise { let lastError: unknown; for (let attempt = 0; attempt <= overrides.retryAttempts; attempt += 1) { try { - return await this.requestValueOnce( - method, - path, - body, - overrides.timeoutMs, - overrides.signal, - ); + return await this.requestOnce(method, path, body, overrides.timeoutMs, overrides.signal); } catch (error) { lastError = error; if ( @@ -127,13 +192,13 @@ export class WebDriverTransport { throw lastError; } - private async requestValueOnce( + private async requestOnce( method: string, path: string, body: unknown, timeoutMs: number, requestSignal?: AbortSignal, - ): Promise { + ): Promise { const { ok, status, text } = await this.fetchWebDriver( method, path, @@ -142,8 +207,17 @@ export class WebDriverTransport { requestSignal, ); const payload = text ? parseJsonResponse(text) : {}; - if (!ok) throw webdriverError(status, payload); - return readWebDriverValue(payload); + if (ok) return { status, value: readWebDriverValue(payload) }; + const error = webdriverError(status, payload); + if (isWebDriverRouteUnsupported(error)) { + emitWebDriverDiagnostic(WEBDRIVER_ROUTE_UNSUPPORTED_REASON, { + method, + path, + status, + code: w3cErrorCode(payload) ?? null, + }); + } + throw error; } private async fetchWebDriver( @@ -153,10 +227,13 @@ export class WebDriverTransport { timeoutMs: number, requestSignal?: AbortSignal, ): Promise & { text: string }> { + const url = new URL(trimLeadingSlash(path), this.endpoint); const timeoutSignal = AbortSignal.timeout(timeoutMs); const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal; + const failure = { method, path, timeoutMs, timeoutSignal, requestSignal }; + let response: Response; try { - const response = await fetch(new URL(trimLeadingSlash(path), this.endpoint), { + response = await fetch(url, { method, headers: { Accept: 'application/json', @@ -166,19 +243,64 @@ export class WebDriverTransport { body: body === undefined ? undefined : JSON.stringify(body), signal, }); + } catch (error) { + throw classifyWebDriverFetchFailure(error, failure, 'request'); + } + try { return { ok: response.ok, status: response.status, text: await response.text() }; } catch (error) { - // The caller's own cancellation keeps its reason; only the transport's - // deadline becomes a typed timeout, so callers key on `details.reason` - // instead of sniffing fetch's DOMException name. - if (timeoutSignal.aborted && !requestSignal?.aborted) { - throw webdriverTimeoutError(method, path, timeoutMs, error); - } - throw error; + throw classifyWebDriverFetchFailure(error, failure, 'response'); } } } +/** + * A caller's own cancellation keeps its reason as-is. Only a failure the transport itself can + * explain — its deadline, or a network failure fetch reports as a TypeError — becomes a typed + * error, so callers key on `details.reason` instead of sniffing fetch's error names. A failure + * discloses `no` only when a socket error code proves the connection was never established; + * any other network failure, and every failure after the response headers arrived, is `unknown`. + */ +function classifyWebDriverFetchFailure( + error: unknown, + context: { + method: string; + path: string; + timeoutMs: number; + timeoutSignal: AbortSignal; + requestSignal: AbortSignal | undefined; + }, + stage: 'request' | 'response', +): unknown { + const { method, path, timeoutMs, timeoutSignal, requestSignal } = context; + if (requestSignal?.aborted) return error; + if (timeoutSignal.aborted) return webdriverTimeoutError(method, path, timeoutMs, error); + if (stage === 'request' && failedBeforeConnect(error)) { + return webdriverConnectRefusedError(method, path, error); + } + if (stage === 'response' || error instanceof TypeError) { + return webdriverRequestInterruptedError(method, path, error); + } + return error; +} + +function failedBeforeConnect(error: unknown): boolean { + const codes = socketErrorCodes(error, 0); + return codes.length > 0 && codes.every((code) => PRE_CONNECT_ERROR_CODES.has(code)); +} + +/** Every `code` on the error, its `cause` chain, and each `AggregateError` member. */ +function socketErrorCodes(error: unknown, depth: number): string[] { + if (depth > ERROR_CAUSE_DEPTH_LIMIT || !error || typeof error !== 'object') return []; + const { code, cause } = error as { code?: unknown; cause?: unknown }; + const members: unknown[] = error instanceof AggregateError ? error.errors : []; + return [ + ...(typeof code === 'string' ? [code] : []), + ...socketErrorCodes(cause, depth + 1), + ...members.flatMap((member) => socketErrorCodes(member, depth + 1)), + ]; +} + function shouldRetryWebDriverRequest( error: unknown, attempt: number, @@ -214,7 +336,75 @@ function webdriverError(status: number, payload: unknown): AppError { typeof (value as { message?: unknown }).message === 'string' ? (value as { message: string }).message : `WebDriver request failed with HTTP ${status}.`; - return new AppError('COMMAND_FAILED', message, { status, response: payload }); + if (isUnsupportedRouteAnswer(status, payload)) { + return new AppError('COMMAND_FAILED', message, { + reason: WEBDRIVER_ROUTE_UNSUPPORTED_REASON, + status, + response: payload, + ...dispatchDisclosure('no'), + }); + } + return new AppError('COMMAND_FAILED', message, { + status, + response: payload, + // A 5xx means the driver received and processed the request, but not + // whether the mutation it described completed before it failed. + ...(status >= 500 ? dispatchDisclosure('unknown') : {}), + }); +} + +/** Machine-readable `details.reason` of a driver answer that it does not implement the route. */ +const WEBDRIVER_ROUTE_UNSUPPORTED_REASON = 'webdriver_route_unsupported'; + +/** W3C `error` codes a driver answers with when it does not implement the route. */ +const UNSUPPORTED_ROUTE_ERROR_CODES: ReadonlySet = new Set([ + 'unknown command', + 'unknown method', +]); + +/** HTTP statuses that, without a W3C error code, mean the route is not implemented here. */ +const UNSUPPORTED_ROUTE_STATUSES: ReadonlySet = new Set([404, 405, 501]); + +/** + * A W3C error code decides when the driver sent one, because other failures share these statuses + * (a 404 `invalid session id` is a dead session); with no code, a bare 404 (no such route), 405 + * (the route exists but not for this method), or 501 (the driver declares it not implemented) + * means the route is not implemented by this driver. + */ +function isUnsupportedRouteAnswer(status: number, payload: unknown): boolean { + const code = w3cErrorCode(payload); + if (code !== undefined) return UNSUPPORTED_ROUTE_ERROR_CODES.has(code.toLowerCase()); + return UNSUPPORTED_ROUTE_STATUSES.has(status); +} + +/** The W3C `value.error` string of a response body, if it carries one. */ +function w3cErrorCode(payload: unknown): string | undefined { + const value = + payload && typeof payload === 'object' && 'value' in payload + ? (payload as { value?: unknown }).value + : undefined; + const code = + value && typeof value === 'object' ? (value as { error?: unknown }).error : undefined; + return typeof code === 'string' ? code : undefined; +} + +/** + * Records a debug event in the request diagnostics. The emitter loads on demand because the + * events are rare and the transport sits in the provider's eager import closure. + */ +export function emitWebDriverDiagnostic(phase: string, data: Record): void { + void import('@agent-device/host-kit/diagnostics') + .then(({ emitDiagnostic }) => emitDiagnostic({ level: 'debug', phase, data })) + .catch(() => undefined); +} + +/** + * The driver answered that it does not implement the route, so nothing was dispatched and a + * sibling route for the same action may be tried. A timeout or 5xx is not this answer: the first + * route may already have acted. + */ +export function isWebDriverRouteUnsupported(error: unknown): error is AppError { + return error instanceof AppError && error.details?.reason === WEBDRIVER_ROUTE_UNSUPPORTED_REASON; } function webdriverTimeoutError( @@ -226,16 +416,55 @@ function webdriverTimeoutError( return new AppError( 'COMMAND_FAILED', `WebDriver ${method} ${path} timed out after ${timeoutMs}ms.`, - { reason: WEBDRIVER_REQUEST_TIMEOUT_REASON, method, path, timeoutMs }, + { + reason: WEBDRIVER_REQUEST_TIMEOUT_REASON, + method, + path, + timeoutMs, + // The transport stopped waiting; the driver may still be mid-request. + ...dispatchDisclosure('unknown'), + }, + cause instanceof Error ? cause : undefined, + ); +} + +function webdriverConnectRefusedError(method: string, path: string, cause: unknown): AppError { + return new AppError( + 'COMMAND_FAILED', + `WebDriver ${method} ${path} could not reach the driver.`, + { + reason: WEBDRIVER_CONNECT_REFUSED_REASON, + method, + path, + ...dispatchDisclosure('no'), + }, + cause instanceof Error ? cause : undefined, + ); +} + +function webdriverRequestInterruptedError(method: string, path: string, cause: unknown): AppError { + return new AppError( + 'COMMAND_FAILED', + `WebDriver ${method} ${path} lost its connection to the driver.`, + { + reason: WEBDRIVER_REQUEST_INTERRUPTED_REASON, + method, + path, + ...dispatchDisclosure('unknown'), + }, cause instanceof Error ? cause : undefined, ); } function isRetriableWebDriverError(error: unknown): boolean { if (isWebDriverRequestTimeout(error)) return true; + if (isWebDriverConnectRefused(error)) return true; + if (error instanceof AppError && error.details?.reason === WEBDRIVER_REQUEST_INTERRUPTED_REASON) { + return true; + } if (error instanceof AppError) { const status = error.details?.status; return typeof status === 'number' && status >= 500; } - return error instanceof TypeError; + return false; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d3122356f..bf1452f083 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,6 +478,9 @@ importers: '@agent-device/contracts': specifier: workspace:* version: link:../contracts + '@agent-device/host-kit': + specifier: workspace:* + version: link:../host-kit '@agent-device/kernel': specifier: workspace:* version: link:../kernel diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 222b622719..ea6b4f56ef 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -758,6 +758,7 @@ test('the real tree parses, declares, and passes R11', () => { assert.deepEqual([...providerWebDriverPackage.workspaceDependencies].sort(), [ '@agent-device/capture-kit', '@agent-device/contracts', + '@agent-device/host-kit', '@agent-device/kernel', '@agent-device/xml', ]);