Files
callstack__agent-device/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts
Michał Pierzchała a707348b9b feat: add hosted WebDriver provider support (#948)
* feat: add cloud webdriver artifacts

* fix: clean up local session after provider release failure

* fix: tag cloud webdriver provider requests

* feat: connect hosted webdriver providers

* docs: document hosted provider credentials

* refactor: tighten cloud webdriver provider internals

* refactor: consolidate cloud webdriver provider definitions

* refactor: collapse hosted webdriver runtime wrapper

* refactor: reduce cloud webdriver smell surface

* docs: clarify hosted provider interfaces

* fix: avoid regex slash trimming in webdriver urls

* docs: rename hosted providers to device clouds

* fix: align provider profile imports with remote modules

* fix: skip local android recovery for provider devices

* test: classify cloud provider integration flags

* fix: close active cloud connection session

* test: cover provider disconnect cli flow

* fix: make cloud webdriver sessions launchable

* fix: align cloud webdriver input gestures

* fix: avoid keyboard input during cloud scroll

* fix: constrain cloud webdriver scroll gestures

* refactor: isolate cloud webdriver scroll frame

* refactor: deduplicate cloud webdriver helpers

* refactor: tighten cloud webdriver action types

* fix: harden cloud webdriver release

* fix: polish provider disconnect diagnostics

* refactor: group connection profile helpers

* fix: repair rebased internal paths

* fix: satisfy cloud webdriver CI guards
2026-07-01 13:01:47 +02:00

568 lines
19 KiB
TypeScript

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';
import { scrollFrameFromWebDriverSource } from '../../../src/cloud-webdriver/webdriver-scroll-frame.ts';
import { parseWebDriverSource } from '../../../src/cloud-webdriver/webdriver-source.ts';
import { CLOUD_WEBDRIVER_PROVIDERS } from '../../../src/cloud-webdriver/providers.ts';
import type { CloudArtifact } from '../../../src/cloud-artifacts.ts';
import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts';
import type { DeviceLease } from '../../../src/daemon/lease-registry.ts';
import type { DaemonRequest } from '../../../src/daemon/types.ts';
import { assertRpcError, assertRpcOk } from './assertions.ts';
import {
createProviderScenarioHarness,
withProviderScenarioResource,
withProviderScenarioTempDir,
} from './harness.ts';
import { runProviderScenario, type ProviderScenarioStep } from './scenario.ts';
import {
CloudWebDriverTestServer,
type CloudWebDriverHttpCall,
startCloudWebDriverTestServer,
type StartedCloudWebDriverTestServer,
writeCloudWebDriverTestJson,
} from './cloud-webdriver-test-server.ts';
const WEBDRIVER_PROVIDER = 'webdriver-fake';
test('Cloud WebDriver runtime 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');
world.failNextArtifactLookup();
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, appPath);
});
});
}, 15_000);
test('Cloud WebDriver release still returns artifacts when WebDriver 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('Cloud WebDriver allocation preserves create-session failure when cleanup fails', async () => {
let cleanupCalled = false;
await withProviderScenarioResource(
() =>
createCloudWebDriverWorld({
cleanup: async () => {
cleanupCalled = true;
throw new Error('provider cleanup failed');
},
}),
async (world) => {
const { daemon, server } = world;
server.createSessionFailuresRemaining = 2;
const allocate = await daemon.callCommand('lease_allocate', [], leaseFlags(), {
meta: leaseMeta(),
});
const error = assertRpcError(allocate, 'COMMAND_FAILED', /create session failed/) as {
details?: { cleanupError?: unknown };
};
assert.equal(error.details?.cleanupError, 'provider cleanup failed');
assert.equal(cleanupCalled, true);
},
);
}, 15_000);
test('default BrowserStack provider runtime builds sessions from daemon request profile flags', async () => {
const server = await FakeWebDriverServer.start();
const runtimes = createDefaultCloudWebDriverProviderRuntimes({
BROWSERSTACK_USERNAME: 'browser-user',
BROWSERSTACK_ACCESS_KEY: 'browser-key',
BROWSERSTACK_WEBDRIVER_ENDPOINT: `${server.url}/wd/hub/`,
});
const providers = createProviderDeviceRuntimeRequestProviders(runtimes);
const daemon = await createProviderScenarioHarness({
...providers,
deviceInventoryProvider: providers.deviceInventoryProvider!,
});
try {
const allocate = await daemon.callCommand(
'lease_allocate',
[],
{
tenant: 'team-a',
runId: 'run-a',
platform: 'android',
device: 'Google Pixel 8',
providerApp: 'bs://app-id',
providerOsVersion: '14.0',
providerProject: 'agent-device',
providerBuild: 'build-a',
providerSessionName: 'session-a',
},
{
meta: {
tenantId: 'team-a',
runId: 'run-a',
leaseBackend: 'android-instance',
leaseProvider: CLOUD_WEBDRIVER_PROVIDERS.browserStack,
clientId: 'client-a',
},
},
);
const data = assertRpcOk<{
lease?: DeviceLease;
provider?: {
provider?: string;
sessionId?: string;
providerSessionId?: string;
};
}>(allocate);
assert.equal(data.provider?.provider, CLOUD_WEBDRIVER_PROVIDERS.browserStack);
assert.equal(data.provider?.providerSessionId, 'wd-1');
assert.deepEqual(server.calls[0]?.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: 'build-a',
sessionName: 'session-a',
},
},
},
});
assert.equal(
server.calls[0]?.headers.authorization,
`Basic ${Buffer.from('browser-user:browser-key').toString('base64')}`,
);
} finally {
await daemon.close();
await Promise.allSettled(runtimes.map(async (runtime) => await runtime.shutdown()));
await server.close();
}
}, 15_000);
test('WebDriver source parser reuses hardened XML parsing', () => {
const nodes = parseWebDriverSource(
'<hierarchy><node text="A &gt; B" resource-id="login" bounds="[0,0][10,10]" displayed="true" /></hierarchy>',
);
assert.equal(nodes[0]?.label, 'A > B');
assert.equal(nodes[0]?.identifier, 'login');
assert.deepEqual(nodes[0]?.rect, { x: 0, y: 0, width: 10, height: 10 });
assert.throws(
() => parseWebDriverSource('<node __proto__="polluted" text="x" />'),
/Unsupported XML attribute name "__proto__"/,
);
});
test('WebDriver scroll frame prefers visible scrollable containers', () => {
assert.deepEqual(
scrollFrameFromWebDriverSource(
'<hierarchy>' +
'<android.widget.FrameLayout bounds="[0,0][1080,2400]" displayed="true" />' +
'<android.widget.ListView bounds="[0,393][1080,1496]" displayed="true" />' +
'<android.support.v7.widget.RecyclerView bounds="[18,597][1062,1196]" displayed="false" />' +
'</hierarchy>',
),
{ x: 0, y: 393, width: 1080, height: 1103 },
);
});
async function createCloudWebDriverWorld(
options: { cleanup?: () => Promise<Record<string, unknown> | undefined> } = {},
) {
const server = await FakeWebDriverServer.start();
let artifactFailuresRemaining = 0;
const runtime = createCloudWebDriverRuntime({
provider: WEBDRIVER_PROVIDER,
endpoint: `${server.url}/wd/hub/`,
platform: 'android',
deviceName: 'BrowserStack Google Pixel 8',
webdriverCapabilities: (lease) => ({
'appium:automationName': 'UiAutomator2',
'bstack:options': {
buildName: lease.runId,
sessionName: lease.leaseId,
},
}),
prepareSession: options.cleanup
? async ({ base }) => ({ ...base, cleanup: options.cleanup })
: undefined,
listArtifacts: async ({ provider, providerSessionId }) => {
if (artifactFailuresRemaining > 0) {
artifactFailuresRemaining -= 1;
throw new Error('provider artifact lookup failed');
}
return {
provider,
providerSessionId,
status: 'ready',
cloudArtifacts: [
{
provider,
providerSessionId,
kind: 'video',
name: 'Session video',
url: 'https://provider.example/video.mp4',
availability: 'ready',
},
],
};
},
});
const providers = createProviderDeviceRuntimeRequestProviders([runtime]);
const daemon = await createProviderScenarioHarness({
...providers,
deviceInventoryProvider: providers.deviceInventoryProvider!,
});
return {
daemon,
server,
failNextArtifactLookup: () => {
artifactFailuresRemaining += 1;
},
close: async () => {
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;
}
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-fake:android:${lease.leaseId}`,
serial: `webdriver-fake: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,
appPath: string,
): void {
assert.deepEqual(
calls.map((call) => `${call.method} ${call.path}`),
[
'POST /wd/hub/session',
'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',
'DELETE /wd/hub/session/wd-1/actions',
'POST /wd/hub/session/wd-1/actions',
'DELETE /wd/hub/session/wd-1/actions',
'POST /wd/hub/session/wd-1/keys',
'GET /wd/hub/session/wd-1/source',
'POST /wd/hub/session/wd-1/appium/device/hide_keyboard',
'GET /wd/hub/session/wd-1/source',
'POST /wd/hub/session/wd-1/actions',
'DELETE /wd/hub/session/wd-1/actions',
'DELETE /wd/hub/session/wd-1',
],
);
assert.deepEqual(calls[0]?.body, {
capabilities: {
alwaysMatch: {
platformName: 'Android',
'appium:deviceName': 'BrowserStack Google Pixel 8',
'appium:automationName': 'UiAutomator2',
'bstack:options': {
buildName: 'run-a',
sessionName: leaseId,
},
},
},
});
assert.deepEqual(calls[1]?.body, { appPath });
assert.deepEqual(calls[2]?.body, { appId: 'com.example.demo' });
assert.deepEqual(calls[7]?.body, { value: Array.from('hello cloud') });
assert.equal(calls[9]?.body, undefined);
assert.deepEqual(calls[11]?.body, {
actions: [
{
type: 'pointer',
id: 'swipe',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x: 540, y: 988 },
{ type: 'pointerDown', button: 0 },
{ type: 'pointerMove', duration: 350, x: 540, y: 788 },
{ type: 'pointerUp', button: 0 },
],
},
],
});
for (const call of calls) {
assert.equal(call.headers['x-agent-device-client'], 'agent-device-cli');
assert.equal(typeof call.headers['x-agent-device-version'], 'string');
assert.notEqual(call.headers['x-agent-device-version'], '');
}
}
class FakeWebDriverServer extends CloudWebDriverTestServer {
createSessionFailuresRemaining = 0;
sessionDeleteFailuresRemaining = 0;
static async start(): Promise<StartedCloudWebDriverTestServer<FakeWebDriverServer>> {
return await startCloudWebDriverTestServer(new FakeWebDriverServer());
}
protected respond(call: CloudWebDriverHttpCall, res: ServerResponse): void {
respondToFakeWebDriverCall(this, call, res);
}
}
function respondToFakeWebDriverCall(
server: FakeWebDriverServer,
call: CloudWebDriverHttpCall,
res: ServerResponse,
): void {
switch (`${call.method} ${call.path}`) {
case 'POST /wd/hub/session':
writeFakeCreateSessionResponse(server, res);
return;
case 'GET /wd/hub/session/wd-1/source':
writeCloudWebDriverTestJson(res, { value: fakeWebDriverSource() });
return;
case 'GET /wd/hub/session/wd-1/window/rect':
writeCloudWebDriverTestJson(res, { value: { x: 0, y: 0, width: 1080, height: 1920 } });
return;
case 'DELETE /wd/hub/session/wd-1/actions':
writeCloudWebDriverTestJson(
res,
{ value: { message: 'The requested resource could not be found.' } },
500,
);
return;
case 'DELETE /wd/hub/session/wd-1':
writeFakeDeleteSessionResponse(server, res);
return;
default:
writeCloudWebDriverTestJson(res, { value: null });
}
}
function writeFakeCreateSessionResponse(server: FakeWebDriverServer, res: ServerResponse): void {
if (server.createSessionFailuresRemaining > 0) {
server.createSessionFailuresRemaining -= 1;
writeCloudWebDriverTestJson(res, { value: { message: 'create session failed' } }, 500);
return;
}
writeCloudWebDriverTestJson(res, {
value: {
sessionId: 'wd-1',
capabilities: { platformName: 'Android' },
},
});
}
function writeFakeDeleteSessionResponse(server: FakeWebDriverServer, res: ServerResponse): void {
if (server.sessionDeleteFailuresRemaining > 0) {
server.sessionDeleteFailuresRemaining -= 1;
writeCloudWebDriverTestJson(res, { value: { message: 'stale webdriver session' } }, 500);
return;
}
writeCloudWebDriverTestJson(res, { value: null });
}
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,
};
}
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',
};
}