Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/provider-webdriver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
},
Expand Down
84 changes: 45 additions & 39 deletions packages/provider-webdriver/src/webdriver-client.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<void> {
const sessionId = this.requireSessionId();
await this.requestValue('DELETE', `/session/${sessionId}`);
Expand All @@ -114,18 +112,36 @@ export class WebDriverClient {
}

async activateApp(appId: string): Promise<void> {
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<void> {
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<void> {
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(

@cubic-dev-ai cubic-dev-ai Bot Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The webdriver_route_fallback event is emitted only when the sibling request succeeds; a failing sibling (timeout, 5xx, reset) leaves only the webdriver_route_unsupported event, so the debug log cannot show the fallback was attempted or how it ended. Emit the fallback event with an outcome/error in all cases, or at least on failure as well.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/provider-webdriver/src/webdriver-client.ts, line 135:

<comment>The `webdriver_route_fallback` event is emitted only when the sibling request succeeds; a failing sibling (timeout, 5xx, reset) leaves only the `webdriver_route_unsupported` event, so the debug log cannot show the fallback was attempted or how it ended. Emit the fallback event with an outcome/error in all cases, or at least on failure as well.</comment>

<file context>
@@ -111,20 +112,36 @@ export class WebDriverClient {
     } catch (error) {
       if (!isWebDriverRouteUnsupported(error)) throw error;
-      await this.executeScript('mobile: terminateApp', [{ appId, bundleId: appId }]);
+      const { status } = await this.transport.request(
+        'POST',
+        `/session/${this.requireSessionId()}/execute/sync`,
</file context>
Fix with cubic

'POST',
`/session/${this.requireSessionId()}/execute/sync`,
{ script: siblingScript, args: [{ appId, bundleId: appId }] },
);
emitWebDriverDiagnostic('webdriver_route_fallback', {
from: route,
to: siblingScript,
status,
});
}
}

Expand All @@ -134,17 +150,15 @@ export class WebDriverClient {
}

async releaseActions(): Promise<void> {
await this.sessionRequest('DELETE', '/actions', undefined, { retryAttempts: 0 });
await this.sessionRequest('DELETE', '/actions');
}

async sendKeys(text: string): Promise<void> {
await this.sessionRequest('POST', '/keys', { value: Array.from(text) });
}

async hideKeyboard(): Promise<void> {
await this.sessionRequest('POST', '/appium/device/hide_keyboard', undefined, {
retryAttempts: 0,
});
await this.sessionRequest('POST', '/appium/device/hide_keyboard');
}

/**
Expand All @@ -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';
Expand Down Expand Up @@ -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;
}
Expand All @@ -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.
Expand Down Expand Up @@ -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<unknown> {
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<unknown> {
return await this.sessionRequest(
'POST',
'/execute/sync',
{ script, args },
{ idempotent: true },
);
}

private async sessionRequest(
method: string,
pathSuffix: string,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
2 changes: 1 addition & 1 deletion packages/provider-webdriver/src/webdriver-interactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ class WebDriverInteractor implements Interactor {

async readClipboard(): Promise<string> {
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 : '';
}

Expand Down
Loading
Loading