Files
callstack__agent-device/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts
Karthik Varma 92b22229e6 feat(cloud-webdriver): BrowserStack device-feature capabilities, and fix cloud orientation (#1544)
* feat(cloud-webdriver): support BrowserStack device-feature capabilities

Adds the eight BrowserStack "device feature" session capabilities that had no
representation in agent-device: deviceOrientation, geoLocation, timezone,
language, locale, networkProfile, customNetwork, and resignApp.

These are vendor capabilities, so they are emitted inside `bstack:options`
rather than at the top level. BrowserStack's YAML config lists them unnested
and its SDK relocates them; agent-device talks to the hub directly, so it
nests them itself.

A single spec table drives both the flag reader and the capability builder, so
adding a capability is a table row rather than a branch in each. A structural
test asserts every field owns exactly one row, since a field the table forgets
would parse off the CLI, ride the profile, and then be silently dropped before
the hub ever saw it.

Rejects combinations the provider cannot act on unambiguously: an unknown
orientation is caught at the flag boundary instead of being forwarded to a hub
that accepts and then ignores it, --provider-no-resign-app is refused on
Android, and a named network profile cannot be combined with a custom network
shape.

Also fixes a latent shallow-merge bug in buildBrowserStackCapabilities: a
caller supplying its own `bstack:options` replaced the whole object and
silently dropped the project, build, and session labels. It is now merged
per key.

* fix(cloud-webdriver): rotate via WebDriver orientation endpoints

`setOrientation` on the cloud WebDriver path sent `mobile: rotate`, which is
not a driver command at all. UiAutomator2's own error enumerates its
extensions and `rotate` is absent from the list, so `agent-device orientation`
was a hard failure on every hosted provider.

It also forwarded agent-device's four-way rotation vocabulary verbatim
("landscape-left", "portrait-upside-down"), where the protocol accepts only
uppercase PORTRAIT/LANDSCAPE. Every other platform has a translation layer;
this path was the only one without one.

Now two transports, ordered by backend. `POST /rotation` takes exact four-way
degrees and leads on Android, since it is the only endpoint that can express
upside-down and left-versus-right. `POST /orientation` is two-way and leads on
XCUITest, which rejects `/rotation`. Each falls back to the other, because only
BrowserStack's UiAutomator2 is verified and a provider whose driver disagrees
should degrade rather than hard-fail.

Verified live against BrowserStack App Automate:
  POST /rotation {"x":0,"y":0,"z":0} -> 200 {"value":"ROTATION_0"}

The rotation-to-surface-index mapping moves to contracts/device-rotation.ts and
the existing adb path now reads from it, so the local and hosted mappings
cannot drift apart.

Note this rotates the current display, not persistent device rotation, so an
activity that does not pin its own orientation may still need rotating once it
is in the foreground.

The capability was declared "partial" without the transport existing, and no
test covered setOrientation on the cloud path; only adb and the Apple runner
were covered. Both gaps are now closed.

* fix(cloud-webdriver): narrow orientation fallback and gate provider-owned flags

Addresses review on #1544.

The orientation fallback caught every error, so a timeout, an auth rejection, a
dead session or a provider 5xx on the first transport was swallowed and retried
against the second. When that one also failed the caller got "rejected both
endpoints" with the real cause discarded. Fallback is now keyed on structured
unsupported-endpoint signals only — HTTP 404/405, or a W3C `unknown command` /
`unknown method` code — matching the repo rule of keying on typed details rather
than message text. Everything else rethrows unchanged.

Device-feature capabilities are BrowserStack-owned, but the flags were accepted
by any cloud provider, persisted into the generated profile, and then silently
dropped at session creation. `connect aws-device-farm` now rejects them with a
typed error naming each offending flag, raised before the provider's own
required-argument checks so the caller is told what is unsupported rather than
what else is missing. Ownership is modelled on the capability spec table, so a
new capability inherits the guard without a second list to maintain.

Adds provider-backed orientation scenarios driven through public daemon dispatch
against the fake WebDriver provider: the four-way endpoint on the happy path,
the documented collapse onto the two-way endpoint when the driver does not
implement `/rotation`, and a provider 5xx that must surface without consulting
the second transport. The fake server's route handling became a table in the
process — it had grown to ten branches in one function.

* fix(cloud-webdriver): read W3C error codes before status, enforce ownership at the runtime boundary

Addresses the second review pass on #1544.

The fallback classifier returned on any 404/405 before consulting the W3C error
code, so an HTTP 404 carrying `invalid session id` was masked as a missing route
and retried against the second transport. The structured code now takes
precedence whenever the driver sent one; bare status is consulted only when no
code exists. Two cases pin it: a 404 `invalid session id` and a 405 `timeout`
must both surface rather than fall through.

Provider ownership was enforced only in the CLI profile builder, which the typed
client and hand-authored remote-config profiles bypass entirely — both reach
session preparation without passing through `connect`, so the capabilities were
accepted and then dropped. The check now lives on the capability-ownership
module and runs inside AWS Device Farm's `prepareSession`, with the CLI builder
calling the same helper instead of its own copy. Covered by a scenario that
drives the runtime boundary directly and asserts the rejection happens before
any provider session is created.
2026-08-02 08:17:49 +02:00

535 lines
19 KiB
TypeScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'vitest';
import {
CLOUD_WEBDRIVER_PROVIDERS,
createProviderWebDriver,
} from '@agent-device/provider-webdriver';
import type { CloudArtifact } from '@agent-device/contracts/observability';
import type { DeviceLease } from '@agent-device/contracts/device';
import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts';
import { createExpiredProviderLeaseReleaser } from '../../../src/daemon/provider-lease-expiry.ts';
import type { DaemonRequest } from '../../../src/daemon/types.ts';
import { assertRpcOk } from './assertions.ts';
import {
createProviderScenarioHarness,
withProviderScenarioResource,
withProviderScenarioTempDir,
} from './harness.ts';
import { runProviderScenario, type ProviderScenarioStep } from './scenario.ts';
import {
CloudWebDriverTestServer,
type CloudWebDriverHttpCall,
type CloudWebDriverTestResponse,
cloudWebDriverTestJson,
startCloudWebDriverTestServer,
type StartedCloudWebDriverTestServer,
} from './cloud-webdriver-test-server.ts';
const WEBDRIVER_PROVIDER = CLOUD_WEBDRIVER_PROVIDERS.browserStack;
const CLIENT_VERSION = '0.20.3-test';
test('packaged Cloud WebDriver facade drives provider devices through daemon commands', async () => {
await withProviderScenarioResource(createCloudWebDriverWorld, async (world) => {
const { daemon, server } = world;
await withProviderScenarioTempDir('agent-device-cloud-webdriver-', async (tempDir) => {
const appPath = path.join(tempDir, 'demo.apk');
fs.writeFileSync(appPath, 'fake apk');
const lease = await allocateWebDriverLease(daemon);
const steps = cloudWebDriverScenarioSteps(appPath, lease);
const releaseStep = steps.at(-1);
assert.ok(releaseStep);
await runProviderScenario(daemon, steps.slice(0, -1), {
flags: leaseFlags(lease.leaseId),
meta: leaseMeta(lease.leaseId),
});
const inferredArtifacts = await daemon.callCommand('artifacts');
const inferredData = assertRpcOk<{
provider?: string;
status?: string;
providerSessionId?: string;
}>(inferredArtifacts);
assert.equal(inferredData.provider, WEBDRIVER_PROVIDER);
assert.equal(inferredData.status, 'ready');
assert.equal(inferredData.providerSessionId, 'wd-1');
server.artifactFailuresRemaining = 1;
const unavailableArtifacts = await daemon.callCommand('artifacts');
const unavailableData = assertRpcOk<{
provider?: string;
status?: string;
providerSessionId?: string;
cloudArtifacts?: CloudArtifact[];
}>(unavailableArtifacts);
assert.equal(unavailableData.provider, WEBDRIVER_PROVIDER);
assert.equal(unavailableData.status, 'unavailable');
assert.equal(unavailableData.providerSessionId, 'wd-1');
assert.deepEqual(unavailableData.cloudArtifacts, []);
await runProviderScenario(daemon, [releaseStep], {
flags: leaseFlags(lease.leaseId),
meta: leaseMeta(lease.leaseId),
});
assertWebDriverCalls(server.calls, lease.leaseId);
});
});
}, 15_000);
test('packaged Cloud WebDriver release still returns artifacts when session delete fails', async () => {
await withProviderScenarioResource(createCloudWebDriverWorld, async (world) => {
const { daemon, server } = world;
const lease = await allocateWebDriverLease(daemon);
server.sessionDeleteFailuresRemaining = 2;
const release = await daemon.callCommand('lease_release', [], leaseFlags(lease.leaseId), {
meta: leaseMeta(lease.leaseId),
});
const data = assertRpcOk<{
released?: boolean;
provider?: {
provider?: string;
providerSessionId?: string;
warnings?: Array<{ code?: string; message?: string }>;
cloudArtifacts?: {
status?: string;
cloudArtifacts?: Array<{ kind?: string }>;
};
};
}>(release);
assert.equal(data.released, true);
assert.equal(data.provider?.provider, WEBDRIVER_PROVIDER);
assert.equal(data.provider?.providerSessionId, 'wd-1');
assert.equal(data.provider?.warnings?.[0]?.code, 'WEBDRIVER_SESSION_DELETE_FAILED');
assert.match(data.provider?.warnings?.[0]?.message ?? '', /stale webdriver session/);
assert.equal(data.provider?.cloudArtifacts?.status, 'ready');
assert.equal(data.provider?.cloudArtifacts?.cloudArtifacts?.[0]?.kind, 'video');
});
}, 15_000);
test('packaged Cloud WebDriver orientation rotates through the four-way endpoint', async () => {
await withProviderScenarioResource(createCloudWebDriverWorld, async (world) => {
const { daemon, server } = world;
const lease = await openWebDriverSession(daemon);
const response = await daemon.callCommand(
'orientation',
['landscape-right'],
{
...leaseFlags(lease.leaseId),
},
{ meta: leaseMeta(lease.leaseId) },
);
const data = assertRpcOk<{ action?: string; orientation?: string }>(response);
assert.equal(data.action, 'orientation');
assert.equal(data.orientation, 'landscape-right');
const rotation = rotationCalls(server);
assert.deepEqual(
rotation.map((call) => call.body),
[{ x: 0, y: 0, z: 270 }],
);
// The exact four-way endpoint answered, so the two-way one is never consulted.
assert.equal(orientationCalls(server).length, 0);
});
}, 15_000);
test('packaged Cloud WebDriver orientation falls back when the driver lacks the rotation route', async () => {
await withProviderScenarioResource(createCloudWebDriverWorld, async (world) => {
const { daemon, server } = world;
const lease = await openWebDriverSession(daemon);
server.rotationBehavior = 'unsupported';
const response = await daemon.callCommand(
'orientation',
['portrait'],
{
...leaseFlags(lease.leaseId),
},
{ meta: leaseMeta(lease.leaseId) },
);
assert.equal(assertRpcOk<{ orientation?: string }>(response).orientation, 'portrait');
assert.equal(rotationCalls(server).length, 1);
// Four-way intent collapses onto the two-way endpoint's vocabulary.
assert.deepEqual(
orientationCalls(server).map((call) => call.body),
[{ orientation: 'PORTRAIT' }],
);
});
}, 15_000);
test('packaged Cloud WebDriver orientation surfaces a provider failure instead of falling back', async () => {
await withProviderScenarioResource(createCloudWebDriverWorld, async (world) => {
const { daemon, server } = world;
const lease = await openWebDriverSession(daemon);
server.rotationBehavior = 'server-error';
const response = await daemon.callCommand(
'orientation',
['portrait'],
{
...leaseFlags(lease.leaseId),
},
{ meta: leaseMeta(lease.leaseId) },
);
const error = response.json.error;
assert.ok(error, 'expected orientation to fail');
assert.match(error.message ?? '', /provider rotation backend failed/);
// A driver that implements the route and failed must not be retried against a different one:
// doing so would report a backend outage as an orientation-support problem.
assert.equal(orientationCalls(server).length, 0);
});
}, 15_000);
test('packaged Cloud WebDriver expiry releases the live provider session', async () => {
await withProviderScenarioResource(createCloudWebDriverWorld, async (world) => {
const lease = await allocateWebDriverLease(world.daemon);
const releaser = createExpiredProviderLeaseReleaser({
leaseLifecycleProvider: world.providers.leaseLifecycleProvider,
providerRuntimeIds: world.providers.providerRuntimeIds,
recoverableProviderIds: world.providers.recoverableProviderIds,
});
try {
await releaser.release(lease);
assert.equal(
world.server.calls.some(
(call) => call.method === 'DELETE' && call.path === '/wd/hub/session/wd-1',
),
true,
);
} finally {
releaser.shutdown();
}
});
}, 15_000);
async function createCloudWebDriverWorld() {
const server = await FakeWebDriverServer.start();
const providerWebDriver = createProviderWebDriver({
clientVersion: CLIENT_VERSION,
runHostCommand: async () => {
throw new Error('BrowserStack scenario must not run host commands');
},
});
const runtimes = providerWebDriver.createDefaultRuntimes({
BROWSERSTACK_USERNAME: 'browser-user',
BROWSERSTACK_ACCESS_KEY: 'browser-key',
BROWSERSTACK_WEBDRIVER_ENDPOINT: `${server.url}/wd/hub/`,
BROWSERSTACK_APP_UPLOAD_ENDPOINT: `${server.url}/app-automate/upload`,
BROWSERSTACK_SESSION_DETAILS_ENDPOINT: `${server.url}/app-automate/sessions`,
});
const providers = createProviderDeviceRuntimeRequestProviders(runtimes);
const daemon = await createProviderScenarioHarness({
...providers,
deviceInventoryProvider: providers.deviceInventoryProvider!,
});
return {
daemon,
server,
providers,
close: async () => {
await Promise.allSettled(runtimes.map(async (runtime) => await runtime.shutdown()));
await daemon.close();
await server.close();
},
};
}
async function allocateWebDriverLease(
daemon: Awaited<ReturnType<typeof createProviderScenarioHarness>>,
): Promise<DeviceLease> {
const allocate = await daemon.callCommand('lease_allocate', [], leaseFlags(), {
meta: leaseMeta(),
});
const data = assertRpcOk<{
lease: DeviceLease;
provider?: {
capabilities?: { operations?: { snapshot?: { support?: string } } };
};
}>(allocate);
assert.equal(data.provider?.capabilities?.operations?.snapshot?.support, 'partial');
return data.lease;
}
/** Allocates a lease and opens the app, so interaction commands have a live provider session. */
async function openWebDriverSession(
daemon: Awaited<ReturnType<typeof createProviderScenarioHarness>>,
): Promise<DeviceLease> {
const lease = await allocateWebDriverLease(daemon);
const open = await daemon.callCommand('open', ['com.example.demo'], leaseFlags(lease.leaseId), {
meta: leaseMeta(lease.leaseId),
});
assertRpcOk(open);
return lease;
}
function rotationCalls(server: FakeWebDriverServer): readonly CloudWebDriverHttpCall[] {
return server.calls.filter(
(call) => call.method === 'POST' && call.path === '/wd/hub/session/wd-1/rotation',
);
}
function orientationCalls(server: FakeWebDriverServer): readonly CloudWebDriverHttpCall[] {
return server.calls.filter(
(call) => call.method === 'POST' && call.path === '/wd/hub/session/wd-1/orientation',
);
}
function cloudWebDriverScenarioSteps(appPath: string, lease: DeviceLease): ProviderScenarioStep[] {
return [
{
name: 'heartbeat',
command: 'lease_heartbeat',
expectData: { provider: { provider: WEBDRIVER_PROVIDER } },
},
{
name: 'install',
command: 'install',
positionals: ['com.example.demo', appPath],
expectData: { platform: 'android', packageName: 'com.example.demo' },
},
{
name: 'open',
command: 'open',
positionals: ['com.example.demo'],
expectData: {
platform: 'android',
id: `${WEBDRIVER_PROVIDER}:android:${lease.leaseId}`,
serial: `${WEBDRIVER_PROVIDER}:android:${lease.leaseId}`,
},
},
{ name: 'click', command: 'click', positionals: ['10', '20'], expectData: { x: 10, y: 20 } },
{
name: 'fill',
command: 'fill',
positionals: ['12', '24', 'hello cloud'],
expectData: { x: 12, y: 24, text: 'hello cloud' },
},
{
name: 'snapshot',
command: 'snapshot',
assert: (response) => {
const data = assertRpcOk<{
nodes?: Array<{
label?: string;
identifier?: string;
depth?: number;
parentIndex?: number;
hittable?: boolean;
}>;
}>(response);
assert.equal(data.nodes?.[1]?.label, 'Login');
assert.equal(data.nodes?.[1]?.identifier, 'com.example:id/login');
assert.equal(data.nodes?.[1]?.depth, 1);
assert.equal(data.nodes?.[1]?.parentIndex, 0);
assert.equal(data.nodes?.[1]?.hittable, true);
},
},
{
name: 'scroll',
command: 'scroll',
positionals: ['down'],
flags: { pixels: 200 },
expectData: { direction: 'down', distance: 200 },
},
{
name: 'artifacts',
command: 'artifacts',
expectData: {
provider: WEBDRIVER_PROVIDER,
status: 'ready',
providerSessionId: 'wd-1',
},
},
{
name: 'release',
command: 'lease_release',
assert: (response) => {
const data = assertRpcOk<{
released?: boolean;
provider?: {
provider?: string;
providerSessionId?: string;
cloudArtifacts?: {
status?: string;
cloudArtifacts?: Array<{ kind?: string }>;
};
};
}>(response);
assert.equal(data.released, true);
assert.equal(data.provider?.provider, WEBDRIVER_PROVIDER);
assert.equal(data.provider?.providerSessionId, 'wd-1');
assert.equal(data.provider?.cloudArtifacts?.status, 'ready');
assert.equal(data.provider?.cloudArtifacts?.cloudArtifacts?.[0]?.kind, 'video');
},
},
];
}
function assertWebDriverCalls(calls: readonly CloudWebDriverHttpCall[], leaseId: string): void {
const paths = calls.map((call) => `${call.method} ${call.path}`);
for (const expected of [
'POST /wd/hub/session',
'POST /app-automate/upload',
'POST /wd/hub/session/wd-1/appium/device/install_app',
'POST /wd/hub/session/wd-1/appium/device/activate_app',
'POST /wd/hub/session/wd-1/actions',
'POST /wd/hub/session/wd-1/keys',
'GET /wd/hub/session/wd-1/source',
'DELETE /wd/hub/session/wd-1',
'GET /app-automate/sessions/wd-1.json',
]) {
assert.ok(paths.includes(expected), `missing WebDriver transcript call: ${expected}`);
}
const create = calls.find((call) => call.path === '/wd/hub/session');
assert.deepEqual(create?.body, {
capabilities: {
alwaysMatch: {
platformName: 'Android',
'appium:deviceName': 'Google Pixel 8',
device: 'Google Pixel 8',
os_version: '14.0',
app: 'bs://app-id',
'bstack:options': {
projectName: 'agent-device',
buildName: 'run-a',
sessionName: leaseId,
},
},
},
});
const install = calls.find((call) => call.path.endsWith('/appium/device/install_app'));
assert.deepEqual(install?.body, { appPath: 'bs://uploaded-app' });
for (const call of calls) {
assert.equal(call.headers['x-agent-device-client'], 'agent-device-cli');
assert.equal(call.headers['x-agent-device-version'], CLIENT_VERSION);
}
}
class FakeWebDriverServer extends CloudWebDriverTestServer {
artifactFailuresRemaining = 0;
sessionDeleteFailuresRemaining = 0;
/**
* How the driver answers `POST /rotation`. `unsupported` is a driver that does not implement the
* route; `server-error` is one that implements it and failed. Orientation must treat those
* differently — only the first earns a fallback.
*/
rotationBehavior: 'ok' | 'unsupported' | 'server-error' = 'ok';
static async start(): Promise<StartedCloudWebDriverTestServer<FakeWebDriverServer>> {
return await startCloudWebDriverTestServer(new FakeWebDriverServer());
}
protected respond(call: CloudWebDriverHttpCall) {
// Routes are a table rather than a switch: every added endpoint would otherwise widen one
// function's branch count, and this fake grows an endpoint per provider feature.
const route = this.routes[`${call.method} ${call.path}`];
return route ? route() : cloudWebDriverTestJson({ value: null });
}
private get routes(): Record<string, () => CloudWebDriverTestResponse> {
return {
'POST /wd/hub/session': () =>
cloudWebDriverTestJson({
value: { sessionId: 'wd-1', capabilities: { platformName: 'Android' } },
}),
'POST /app-automate/upload': () => cloudWebDriverTestJson({ app_url: 'bs://uploaded-app' }),
'GET /wd/hub/session/wd-1/source': () =>
cloudWebDriverTestJson({ value: fakeWebDriverSource() }),
'GET /wd/hub/session/wd-1/window/rect': () =>
cloudWebDriverTestJson({ value: { x: 0, y: 0, width: 1080, height: 1920 } }),
'DELETE /wd/hub/session/wd-1/actions': () =>
cloudWebDriverTestJson(
{ value: { message: 'The requested resource could not be found.' } },
500,
),
'POST /wd/hub/session/wd-1/rotation': () => this.rotationResponse(),
'POST /wd/hub/session/wd-1/orientation': () => cloudWebDriverTestJson({ value: null }),
'DELETE /wd/hub/session/wd-1': () => this.deleteSessionResponse(),
'GET /app-automate/sessions/wd-1.json': () => this.artifactResponse(),
};
}
private rotationResponse() {
if (this.rotationBehavior === 'unsupported') {
return cloudWebDriverTestJson(
{ value: { error: 'unknown command', message: 'Unknown command: rotation' } },
404,
);
}
if (this.rotationBehavior === 'server-error') {
return cloudWebDriverTestJson(
{ value: { message: 'provider rotation backend failed' } },
502,
);
}
return cloudWebDriverTestJson({ value: 'ROTATION_0' });
}
private deleteSessionResponse() {
if (this.sessionDeleteFailuresRemaining > 0) {
this.sessionDeleteFailuresRemaining -= 1;
return cloudWebDriverTestJson({ value: { message: 'stale webdriver session' } }, 500);
}
return cloudWebDriverTestJson({ value: null });
}
private artifactResponse() {
if (this.artifactFailuresRemaining > 0) {
this.artifactFailuresRemaining -= 1;
return cloudWebDriverTestJson({ message: 'provider artifact lookup failed' }, 500);
}
return cloudWebDriverTestJson({
automation_session: {
video_url: 'https://provider.example/video.mp4',
appium_logs_url: 'https://provider.example/appium.log',
device_logs_url: 'https://provider.example/device.log',
browser_url: 'https://provider.example/session',
public_url: 'https://provider.example/public',
},
});
}
}
function fakeWebDriverSource(): string {
return (
'<hierarchy><node text="Root" bounds="[0,0][100,40]" displayed="true">' +
'<node text="Login" resource-id="com.example:id/login" bounds="[10,20][110,70]" displayed="true" />' +
'<android.widget.ListView resource-id="com.example:id/results" bounds="[0,279][1080,1496]" displayed="true" />' +
'</node></hierarchy>'
);
}
function leaseFlags(leaseId?: string): DaemonRequest['flags'] {
return {
platform: 'android',
tenant: 'team-a',
runId: 'run-a',
leaseId,
leaseProvider: WEBDRIVER_PROVIDER,
device: 'Google Pixel 8',
providerApp: 'bs://app-id',
providerOsVersion: '14.0',
providerProject: 'agent-device',
providerBuild: 'run-a',
providerSessionName: leaseId,
};
}
function leaseMeta(leaseId?: string): DaemonRequest['meta'] {
return {
tenantId: 'team-a',
runId: 'run-a',
leaseId,
leaseBackend: 'android-instance',
leaseProvider: WEBDRIVER_PROVIDER,
deviceKey: 'webdriver-android-a',
clientId: 'client-a',
};
}