Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,15 @@
"file": "packages/provider-limrun/src/index.ts",
"exports": ["LimrunIosCommandExecution"]
},
{
"comment": "TestMu session preparation reads these off the lazy loadTestMuDeviceFeatures() import in packages/provider-webdriver/src/provider-definitions.ts, which keeps the package entry's eager closure unchanged; Fallow cannot connect the dynamic member reads.",
"file": "packages/provider-webdriver/src/testmu-device-features.ts",
"exports": [
"buildTestMuDeviceFeatureCapabilities",
"readTestMuDeviceFeatureFields",
"readTestMuDeviceType"
]
},
{
"comment": "Converting the contracts façades from `export *` to explicit named re-exports (the pin-table retirement) made these individually visible to --production analysis for the first time; a bare star previously hid them from this exact check. isRecord/IOS_SAFARI_BUNDLE_ID/REPLAY_DIVERGENCE_* have no production consumer. Kept rather than narrowed here so the façade's re-export surface stays byte-identical to the symbol set the retired pin table asserted — narrowing the surface is a follow-up with its own review, not a side effect of this mechanical conversion.",
"file": "packages/contracts/src/facades/{client,command,divergence,recording}.ts",
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,15 @@ The same session and evidence model works at every step: the agent explores the
| --- | --- | --- |
| Local | Trying commands and debugging apps on simulators, emulators, physical devices, macOS, and Linux. | Follow the Quick Start. |
| CI/CD | Automated pull request and merge validation with replay scripts and captured artifacts. | Try the [EAS workflow template](https://github.com/callstackincubator/eas-agent-device/blob/main/.eas/workflows/agent-qa-mobile.yml). |
| Cloud / remote | Linux runners, managed devices, and remote jobs. | Set up a [remote proxy](https://oss.callstack.com/agent-device/docs/remote-proxy), connect a [device cloud](https://oss.callstack.com/agent-device/docs/device-clouds) (BrowserStack, AWS Device Farm, Limrun), or [contact Callstack](mailto:hello@callstack.com) for team QA. |
| Cloud / remote | Linux runners, managed devices, and remote jobs. | Set up a [remote proxy](https://oss.callstack.com/agent-device/docs/remote-proxy), connect a [device cloud](https://oss.callstack.com/agent-device/docs/device-clouds) (BrowserStack, AWS Device Farm, TestMu AI, Limrun), or [contact Callstack](mailto:hello@callstack.com) for team QA. |

## How it works

`agent-device` keeps device state in sessions. It uses a local accessibility bridge for iOS Simulator snapshots and XCTest for iOS interactions, physical iOS, and tvOS; ADB and the snapshot helper on Android; HDC and ArkUI `uitest` on HarmonyOS; Vega CLI/VDA on the Vega Virtual Device; a local helper on macOS; and AT-SPI on Linux.

Support depth varies by target. Newer backends such as HarmonyOS and Vega OS cover a subset of commands; run `agent-device capabilities --platform <platform>` to see what a target supports.

Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. Inspect ownership without a daemon via `agent-device device status`, and settle provably dead owners with `agent-device device release --stale`. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, and Limrun](https://oss.callstack.com/agent-device/docs/device-clouds).
Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. Inspect ownership without a daemon via `agent-device device status`, and settle provably dead owners with `agent-device device release --stale`. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, TestMu AI, and Limrun](https://oss.callstack.com/agent-device/docs/device-clouds).

`agent-device` uses the inspect-act-verify process from Vercel's [agent-browser](https://github.com/vercel-labs/agent-browser) for mobile, TV, and desktop apps. Basic `--platform web` support runs `agent-browser` in the same session and replay system.

Expand Down
18 changes: 16 additions & 2 deletions packages/command-registry/src/flag-definitions-connection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { PROVIDER_DEVICE_ORIENTATIONS } from '@agent-device/contracts/remote';
import {
PROVIDER_DEVICE_ORIENTATIONS,
PROVIDER_DEVICE_TYPES,
} from '@agent-device/contracts/remote';
import type { FlagDefinition } from './flag-types.ts';

export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
Expand Down Expand Up @@ -174,6 +177,17 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
projectConfig: false,
recorded: false,
},
{
key: 'providerDeviceType',
names: ['--provider-device-type'],
type: 'enum',
enumValues: PROVIDER_DEVICE_TYPES,
usageLabel: '--provider-device-type real|virtual',
usageDescription:
'TestMu AI device pool: real devices or virtual devices (emulators and simulators). Defaults to virtual',
projectConfig: false,
recorded: false,
},
{
key: 'providerProject',
names: ['--provider-project'],
Expand Down Expand Up @@ -236,7 +250,7 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
type: 'string',
usageLabel: '--provider-appium-version <version>',
usageDescription:
'Hosted cloud provider Appium server version, for example 3.2.0. Without it BrowserStack falls back to its default (Appium 1.x)',
'Hosted cloud provider Appium server version, for example 3.2.0. Without it each provider starts its own default (Appium 1.x on BrowserStack)',
projectConfig: false,
recorded: false,
},
Expand Down
1 change: 1 addition & 0 deletions packages/command-registry/src/flag-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const COMMON_COMMAND_SUPPORTED_FLAG_KEYS = flagKeys(
'device',
'providerApp',
'providerOsVersion',
'providerDeviceType',
'providerProject',
'providerBuild',
'providerSessionName',
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/__tests__/lease-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ test('readLeaseAllocateProviderFlags carries the provider-allocation flags and d
device: 'iPhone 15',
providerApp: 'bs://abc',
providerOsVersion: '17',
providerDeviceType: 'real',
providerProject: 'MyProject',
providerBuild: 'Build-1',
providerSessionName: 'smoke',
Expand All @@ -198,6 +199,7 @@ test('readLeaseAllocateProviderFlags carries the provider-allocation flags and d
device: 'iPhone 15',
providerApp: 'bs://abc',
providerOsVersion: '17',
providerDeviceType: 'real',
providerProject: 'MyProject',
providerBuild: 'Build-1',
providerSessionName: 'smoke',
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/client-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export type AgentDeviceRequestOverrides = Pick<
| 'clientId'
| 'providerApp'
| 'providerOsVersion'
| 'providerDeviceType'
| 'providerProject'
| 'providerBuild'
| 'providerSessionName'
Expand Down
3 changes: 2 additions & 1 deletion packages/contracts/src/facades/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ export type {
ProviderConnectionResource,
ProviderConnectionVerification,
} from '../provider-connection.ts';
export { PROVIDER_DEVICE_ORIENTATIONS } from '../remote-config-fields.ts';
export { PROVIDER_DEVICE_ORIENTATIONS, PROVIDER_DEVICE_TYPES } from '../remote-config-fields.ts';
export type {
CloudProviderProfileFields,
ProviderDeviceOrientation,
ProviderDeviceType,
RemoteConfigMetroOptions,
RemoteConnectionProfileFields,
} from '../remote-config-fields.ts';
1 change: 1 addition & 0 deletions packages/contracts/src/lease-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ const LEASE_ALLOCATE_PROVIDER_FLAG_KEYS = [
// The Cloud provider profile fields; pinned exhaustive against that vocabulary below.
'providerApp',
'providerOsVersion',
'providerDeviceType',
'providerProject',
'providerBuild',
'providerSessionName',
Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/remote-config-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ import type { MetroPrepareKind } from './metro.ts';
export const PROVIDER_DEVICE_ORIENTATIONS = ['portrait', 'landscape'] as const;
export type ProviderDeviceOrientation = (typeof PROVIDER_DEVICE_ORIENTATIONS)[number];

/** Device pool a hosted provider session is created in: physical devices or emulators/simulators. */
export const PROVIDER_DEVICE_TYPES = ['real', 'virtual'] as const;
export type ProviderDeviceType = (typeof PROVIDER_DEVICE_TYPES)[number];

export type CloudProviderProfileFields = {
providerApp?: string;
providerOsVersion?: string;
providerDeviceType?: ProviderDeviceType;
providerProject?: string;
providerBuild?: string;
providerSessionName?: string;
Expand Down
4 changes: 4 additions & 0 deletions packages/provider-webdriver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"./providers": {
"types": "./src/providers.ts",
"default": "./src/providers.ts"
},
"./testmu-device-features": {
"types": "./src/testmu-device-features.ts",
"default": "./src/testmu-device-features.ts"
}
}
}
14 changes: 14 additions & 0 deletions packages/provider-webdriver/src/artifact-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,17 @@ export function unavailableCloudArtifactsResult(options: {
message: options.error instanceof Error ? options.error.message : String(options.error),
};
}

/** A ready URL artifact read off a provider's session-details record, or nothing when the field is absent. */
export function urlArtifactFromDetails(
provider: string,
providerSessionId: string,
details: Record<string, unknown>,
field: string,
kind: CloudArtifact['kind'],
name: string,
): CloudArtifact | undefined {
const url = details[field];
if (typeof url !== 'string' || url.length === 0) return undefined;
return { provider, providerSessionId, kind, name, url, availability: 'ready' };
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import { agentDeviceRequestHeaders } from './request-headers.ts';
import { basicAuthHeader } from './webdriver-utils.ts';
import { asRecord, fetchProviderVerificationJson, sameOsVersion } from './webdriver-utils.ts';
import type {
CloudWebDriverConnectionVerification,
CloudWebDriverConnectionVerificationOptions,
Expand Down Expand Up @@ -105,42 +104,15 @@ async function fetchBrowserStackJson(
auth: { username: string; accessKey: string },
clientVersion: string,
): Promise<unknown> {
try {
const response = await fetch(endpoint, {
headers: {
...agentDeviceRequestHeaders(clientVersion),
Authorization: basicAuthHeader(auth),
},
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
const unauthorized = response.status === 401 || response.status === 403;
throw new AppError(
unauthorized ? 'UNAUTHORIZED' : 'COMMAND_FAILED',
'BrowserStack rejected connection verification.',
{
status: response.status,
hint: unauthorized
? 'Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.'
: 'Retry connect or check the BrowserStack service status.',
},
);
}
return (await response.json()) as unknown;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
'COMMAND_FAILED',
'BrowserStack connection verification failed.',
{ hint: 'Check network access to api-cloud.browserstack.com and retry connect.' },
error,
);
}
}

function sameOsVersion(left: string, right: string): boolean {
const normalize = (value: string) => value.replace(/(?:\.0)+$/, '');
return normalize(left) === normalize(right);
return await fetchProviderVerificationJson(endpoint, {
clientVersion,
auth,
hints: {
service: 'BrowserStack',
unauthorizedHint: 'Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.',
networkHint: 'Check network access to api-cloud.browserstack.com and retry connect.',
},
});
}

function readBrowserStackDevices(
Expand Down Expand Up @@ -184,9 +156,3 @@ function readBrowserStackApps(
];
});
}

function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
16 changes: 2 additions & 14 deletions packages/provider-webdriver/src/browserstack-device-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type CloudProviderProfileFields,
} from '@agent-device/contracts/remote';
import { AppError } from '@agent-device/kernel/errors';
import { requireProviderDeviceOrientation } from './webdriver-utils.ts';
import type { CloudWebDriverPlatform } from './runtime.ts';

/**
Expand Down Expand Up @@ -199,26 +200,13 @@ function assignStringField(
value: string,
): void {
if (spec.field === 'providerDeviceOrientation') {
fields.providerDeviceOrientation = requireDeviceOrientation(spec, value);
fields.providerDeviceOrientation = requireProviderDeviceOrientation(spec, value);
return;
}
if (spec.field === 'providerNoResignApp') return;
fields[spec.field] = value;
}

function requireDeviceOrientation(
spec: BrowserStackDeviceFeatureSpec,
value: string,
): (typeof PROVIDER_DEVICE_ORIENTATIONS)[number] {
const match = PROVIDER_DEVICE_ORIENTATIONS.find((orientation) => orientation === value);
if (match) return match;
throw new AppError('INVALID_ARGS', `Invalid ${spec.flag} value: ${value}.`, {
hint: `Use ${PROVIDER_DEVICE_ORIENTATIONS.join('|')}.`,
flag: spec.flag,
capability: spec.capability,
});
}

function requireSupportedPlatform(
spec: BrowserStackDeviceFeatureSpec,
platform: CloudWebDriverPlatform,
Expand Down
91 changes: 90 additions & 1 deletion packages/provider-webdriver/src/browserstack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { promises as fs } from 'node:fs';

import path from 'node:path';
import { afterEach, test } from 'vitest';
import { uploadBrowserStackApp } from './browserstack.ts';
import { AppError } from '@agent-device/kernel/errors';
import {
listBrowserStackCloudArtifacts,
resolveBrowserStackAppReference,
uploadBrowserStackApp,
} from './browserstack.ts';
import { mkdtempForTest } from './tmp-dir.fixtures.ts';

const realFetch = globalThis.fetch;
Expand Down Expand Up @@ -46,3 +51,87 @@ test('BrowserStack upload aborts while the provider request is in flight', async
await fs.rm(tempDir, { recursive: true, force: true });
}
});

const upload = { clientVersion: '0.0.0-test', username: 'user', accessKey: 'key' };

test('BrowserStack upload sends the file field and fails typed on a gateway error page', async () => {
const tempDir = await mkdtempForTest('agent-device-browserstack-upload-error-');
const appPath = path.join(tempDir, 'App.apk');
try {
await fs.writeFile(appPath, 'placeholder');
globalThis.fetch = async (_input, init) => {
assert.ok(init?.body instanceof FormData);
assert.ok(init.body.get('file') instanceof Blob);
return new Response('<html>502 Bad Gateway</html>', { status: 502 });
};
await assert.rejects(uploadBrowserStackApp(appPath, upload), (error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.equal(error.message, 'BrowserStack app upload failed.');
assert.equal(error.details?.status, 502);
return true;
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});

test('BrowserStack passes bs:// ids and URLs to the hub and uploads only local paths', async () => {
const tempDir = await mkdtempForTest('agent-device-browserstack-resolve-');
try {
await fs.writeFile(path.join(tempDir, 'App.apk'), 'placeholder');
const fetched: string[] = [];
globalThis.fetch = async (input) => {
fetched.push(String(input));
return new Response(JSON.stringify({ app_url: 'bs://uploaded' }), { status: 200 });
};
const resolve = async (app: string) =>
await resolveBrowserStackAppReference(app, { ...upload, cwd: tempDir });

assert.equal(await resolve('bs://preuploaded'), 'bs://preuploaded');
assert.equal(await resolve('https://builds.example/App.apk'), 'https://builds.example/App.apk');
assert.equal(fetched.length, 0);
assert.equal(await resolve('App.apk'), 'bs://uploaded');
assert.deepEqual(fetched, ['https://api-cloud.browserstack.com/app-automate/upload']);
await assert.rejects(resolve('missing.apk'), (error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(
error.message,
'BrowserStack --provider-app must be a bs:// app id, URL, or existing local app path.',
);
return true;
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});

test('BrowserStack session details lookup has a deadline and fails typed', async () => {
const lookup = async () =>
await listBrowserStackCloudArtifacts('browserstack', 'SESSION1', upload);
const timeout = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
const transportFailures: unknown[] = [timeout, new TypeError('fetch failed')];
for (const failure of transportFailures) {
globalThis.fetch = async (_input, init) => {
assert.ok(init?.signal instanceof AbortSignal);
throw failure;
};
await assert.rejects(lookup(), (error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.equal(error.message, 'BrowserStack session details lookup failed.');
assert.equal(error.cause, failure);
return true;
});
}

for (const body of ['<html>gateway</html>', '[]']) {
globalThis.fetch = async () => new Response(body, { status: 200 });
await assert.rejects(lookup(), (error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.equal(error.details?.status, 200);
return true;
});
}
});
Loading