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
11 changes: 6 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,11 +270,12 @@ The perfect-shape refactor is complete and merged. Its end-state:
it ranks an explicit target spine — as rank groups, lowest (kernel sink) to highest, where `A ◄ B`
means B may not be outranked by A (the back-edge order the gate rejects), NOT that every displayed
import exists:
`{ contracts, request, selectors, platforms, utils, replay, recording, snapshot, screenshot-diff, cloud-webdriver } ◄ { core, providers } ◄ { commands, cli-schema, mcp } ◄ { client, daemon-server, compat, remote, metro, sdk } ◄ daemon-client ◄ cli` (the former rank-0 kernel zone lives in `packages/kernel` since #1490 W0; R11 package-boundaries owns that seam, and R1 kernel-sink is retired as physically subsumed) —
and rejects every back-edge within it. Only `(root)` is unranked (`UNRANKED_ZONES` in
`scripts/layering/model.ts`): it holds the entrypoints and the composition roots that wire the
command surface into the daemon, and R2 forbids `daemon/` from importing `commands/`, so those
files sit outside the spine by construction. The satellite zones used to be unranked too, on the
`{ contracts, request, selectors, platforms, utils, replay, recording, snapshot, screenshot-diff } ◄ { core, providers } ◄ { commands, cli-schema, mcp } ◄ { client, daemon-server, compat, remote, metro, sdk } ◄ daemon-client ◄ cli` (the former rank-0 kernel zone lives in `packages/kernel` since #1490 W0, the former `cloud-webdriver` leaf lives behind the single `@agent-device/provider-webdriver` facade since W1b, and the dependency-free XML codec lives behind the single `@agent-device/xml` facade; R11 package-boundaries owns these physical seams) —
and rejects every back-edge within it. Only `(root)` is unranked among `src/` zones
(`UNRANKED_ZONES` in `scripts/layering/model.ts`): it holds the entrypoints and the composition
roots that wire the command surface into the daemon, and R2 forbids `daemon/` from importing
`commands/`, so those files sit outside the spine by construction. Extracted workspace packages
are classified separately and enforced by R11. The satellite zones used to be unranked too, on the
grounds that ranking them would invent an order the architecture had not committed to; once
`utils` joined the spine and `(root)` was emptied of shared contracts, every one of them turned
out to have a consistent rank already. `model.test.ts` guards that no new zone escapes this
Expand Down
2 changes: 1 addition & 1 deletion fallow-baselines/health.json
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@
"src/selectors/build.ts:high impact",
"src/kernel/redaction.ts:high impact",
"src/utils/rect-visibility.ts:high impact",
"src/cloud-webdriver/webdriver-utils.ts:high impact",
"packages/provider-webdriver/src/webdriver-utils.ts:high impact",
"src/utils/keyed-lock.ts:high impact",
"src/daemon/request-progress-protocol.ts:high impact",
"src/replay/test/reporters/format.ts:high impact",
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
"check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
"prepack": "pnpm check:mcp-metadata && pnpm package:npm",
"typecheck": "tsc -b packages/kernel packages/contracts packages/maestro && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/maestro packages/provider-webdriver && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"test-app:install": "pnpm install --dir examples/test-app",
"test-app:start": "pnpm --dir examples/test-app start",
"test-app:ios": "pnpm --dir examples/test-app ios",
Expand Down Expand Up @@ -248,6 +248,8 @@
"@agent-device/contracts": "workspace:*",
"@agent-device/kernel": "workspace:*",
"@agent-device/maestro": "workspace:*",
"@agent-device/provider-webdriver": "workspace:*",
"@agent-device/xml": "workspace:*",
"@chenglou/freerange": "^0.0.1",
"@stryker-mutator/core": "9.6.1",
"@stryker-mutator/vitest-runner": "9.6.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/device-provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// The device-provider port.
//
// A provider adapter (`providers/`, `cloud-webdriver/`) implements these; the daemon calls
// A provider adapter (`providers/`, `@agent-device/provider-webdriver`) implements these; the daemon calls
// them. Both sides therefore name the same shapes, and since the adapters sit below the
// daemon in the spine, the shapes have to be declared below both — here — rather than
// inside the daemon module that happens to consume them first.
Expand Down
18 changes: 18 additions & 0 deletions packages/provider-webdriver/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@agent-device/provider-webdriver",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "BrowserStack and AWS Device Farm WebDriver providers for agent-device. Internal workspace package bundled into the published artifact.",
"dependencies": {
"@agent-device/contracts": "workspace:*",
"@agent-device/kernel": "workspace:*",
"@agent-device/xml": "workspace:*"
},
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import type {
CloudWebDriverPrepareSession,
} from './runtime.ts';
import type { DeviceLease, ProviderDeviceRuntime } from '@agent-device/contracts/device';
import { runCmd } from '../utils/exec.ts';
import { sleep } from '../utils/timeouts.ts';
import { setTimeout as sleep } from 'node:timers/promises';
import { AppError } from '@agent-device/kernel/errors';
import type { RunHostCommand } from './dependencies.ts';
import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts';
import { resolveLeaseValue, type LeaseValue } from './webdriver-utils.ts';

Expand Down Expand Up @@ -79,6 +79,7 @@ export type AwsCreateRemoteAccessSessionInput = {
};

export type AwsDeviceFarmWebDriverRuntimeOptions = {
clientVersion: string;
projectArn: string;
deviceArn: string;
region?: string;
Expand Down Expand Up @@ -118,10 +119,17 @@ export function getAwsDeviceFarmWebDriverCapabilities(
export function createAwsDeviceFarmWebDriverRuntime(
options: AwsDeviceFarmWebDriverRuntimeOptions,
): ProviderDeviceRuntime {
const client = options.client ?? createAwsCliDeviceFarmClient({ region: options.region });
if (!options.client) {
throw new AppError(
'INVALID_ARGS',
'AWS Device Farm runtime construction requires a client from the package facade.',
);
}
const client = options.client;
const platform = options.platform ?? 'android';
const deviceName = options.deviceName ?? 'AWS Device Farm device';
return createCloudWebDriverRuntime({
clientVersion: options.clientVersion,
provider: AWS_DEVICE_FARM_PROVIDER,
endpoint: 'http://127.0.0.1/',
platform,
Expand All @@ -144,12 +152,13 @@ export function createAwsDeviceFarmWebDriverRuntime(
}

export type AwsCliDeviceFarmClientOptions = {
runHostCommand: RunHostCommand;
region?: string;
awsCommand?: string;
};

export function createAwsCliDeviceFarmClient(
options: AwsCliDeviceFarmClientOptions = {},
options: AwsCliDeviceFarmClientOptions,
): AwsDeviceFarmClient {
const runDeviceFarmJson = createAwsDeviceFarmCommandRunner(options);
return {
Expand Down Expand Up @@ -189,7 +198,10 @@ export function createAwsDeviceFarmPrepareSession(
'client' | 'platform' | 'deviceName' | 'projectArn' | 'deviceArn'
>
> &
Omit<AwsDeviceFarmWebDriverRuntimeOptions, 'client' | 'platform' | 'deviceName'>,
Omit<
AwsDeviceFarmWebDriverRuntimeOptions,
'client' | 'platform' | 'deviceName' | 'clientVersion'
>,
): CloudWebDriverPrepareSession {
return async ({ lease, base }) => {
const remoteAccess = await options.client.createRemoteAccessSession({
Expand Down Expand Up @@ -289,8 +301,12 @@ async function waitForRunningRemoteAccessSession(
});
}

async function runAwsJson(command: string, args: string[]): Promise<unknown> {
const result = await runCmd(command, args, { maxBuffer: 10 * 1024 * 1024 });
async function runAwsJson(
runHostCommand: RunHostCommand,
command: string,
args: string[],
): Promise<unknown> {
const result = await runHostCommand(command, args);
return JSON.parse(result.stdout) as unknown;
}

Expand All @@ -300,7 +316,7 @@ function createAwsDeviceFarmCommandRunner(
const regionArgs = options.region ? ['--region', options.region] : [];
const awsCommand = options.awsCommand ?? 'aws';
return async (subcommand, args) =>
await runAwsJson(awsCommand, [
await runAwsJson(options.runHostCommand, awsCommand, [
'devicefarm',
subcommand,
...regionArgs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const BROWSERSTACK_CAPABILITY_OVERRIDES = {
} as const satisfies CloudWebDriverCapabilityOverrides;

export type BrowserStackWebDriverRuntimeOptions = {
clientVersion: string;
username: string;
accessKey: string;
platform: CloudWebDriverPlatform;
Expand Down Expand Up @@ -97,11 +98,13 @@ export function createBrowserStackWebDriverRuntime(
): ProviderDeviceRuntime {
const uploadEndpoint = options.uploadEndpoint ?? BROWSERSTACK_APP_UPLOAD_ENDPOINT;
const artifactOptions = {
clientVersion: options.clientVersion,
username: options.username,
accessKey: options.accessKey,
endpoint: options.sessionDetailsEndpoint ?? BROWSERSTACK_SESSION_DETAILS_ENDPOINT,
};
return createCloudWebDriverRuntime({
clientVersion: options.clientVersion,
provider: BROWSERSTACK_PROVIDER,
endpoint: options.endpoint ?? BROWSERSTACK_APP_AUTOMATE_ENDPOINT,
platform: options.platform,
Expand All @@ -121,6 +124,7 @@ export function createBrowserStackWebDriverRuntime(
configured: resolveConfiguredBrowserStackCapabilities(options, lease),
}),
uploadApp: createBrowserStackUploadApp({
clientVersion: options.clientVersion,
username: options.username,
accessKey: options.accessKey,
endpoint: uploadEndpoint,
Expand All @@ -135,6 +139,7 @@ export function createBrowserStackWebDriverRuntime(
}

export type BrowserStackSessionDetailsOptions = {
clientVersion: string;
username: string;
accessKey: string;
endpoint?: string | URL;
Expand All @@ -157,6 +162,7 @@ export async function listBrowserStackCloudArtifacts(
}

export type BrowserStackUploadOptions = {
clientVersion: string;
username: string;
accessKey: string;
endpoint?: string | URL;
Expand All @@ -172,7 +178,7 @@ export async function uploadBrowserStackApp(
const response = await fetch(options.endpoint ?? BROWSERSTACK_APP_UPLOAD_ENDPOINT, {
method: 'POST',
headers: {
...agentDeviceRequestHeaders(),
...agentDeviceRequestHeaders(options.clientVersion),
Authorization: basicAuthHeader(options),
},
body: form,
Expand Down Expand Up @@ -236,7 +242,7 @@ async function fetchBrowserStackSessionDetails(
);
const response = await fetch(endpoint, {
headers: {
...agentDeviceRequestHeaders(),
...agentDeviceRequestHeaders(options.clientVersion),
Authorization: basicAuthHeader(options),
},
});
Expand Down
13 changes: 13 additions & 0 deletions packages/provider-webdriver/src/dependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type HostCommandResult = {
stdout: string;
};

export type RunHostCommand = (
command: string,
args: readonly string[],
) => Promise<HostCommandResult>;

export type ProviderWebDriverDependencies = {
clientVersion: string;
runHostCommand: RunHostCommand;
};
46 changes: 46 additions & 0 deletions packages/provider-webdriver/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { ProviderDeviceRuntime } from '@agent-device/contracts/device';
import type {
CloudArtifactsQuery,
CloudArtifactsResult,
} from '@agent-device/contracts/observability';
import type { ProviderWebDriverDependencies } from './dependencies.ts';
import {
createCloudWebDriverProviderDefinitions,
type DefaultCloudWebDriverArtifactEnv,
type DefaultCloudWebDriverProviderRuntimeEnv,
} from './provider-definitions.ts';
import { CLOUD_WEBDRIVER_PROVIDERS, isCloudWebDriverProviderName } from './providers.ts';

export { CLOUD_WEBDRIVER_PROVIDERS, isCloudWebDriverProviderName };
export type { CloudWebDriverKnownProviderName } from './providers.ts';
export type { ProviderWebDriverDependencies, RunHostCommand } from './dependencies.ts';
export type {
DefaultCloudWebDriverArtifactEnv,
DefaultCloudWebDriverProviderRuntimeEnv,
} from './provider-definitions.ts';

export type ProviderWebDriver = {
readonly providerIds: readonly string[];
createDefaultRuntimes(env?: DefaultCloudWebDriverProviderRuntimeEnv): ProviderDeviceRuntime[];
listArtifactsFromEnv(
query: CloudArtifactsQuery,
env: DefaultCloudWebDriverArtifactEnv,
): Promise<CloudArtifactsResult | undefined>;
};

export function createProviderWebDriver(
dependencies: ProviderWebDriverDependencies,
): ProviderWebDriver {
const definitions = createCloudWebDriverProviderDefinitions(dependencies);
return {
providerIds: definitions.map((definition) => definition.provider),
createDefaultRuntimes: (env = process.env) =>
definitions.map((definition) => definition.createRuntime(env)),
listArtifactsFromEnv: async (query, env) => {
if (!query.providerSessionId) return undefined;
return await definitions
.find((definition) => definition.provider === query.provider)
?.listArtifactsFromEnv(query.providerSessionId, env);
},
};
}
Loading
Loading