Skip to content

Commit 586aa86

Browse files
committed
refactor(provider-webdriver): give BrowserStack and AWS Device Farm the plugin factory shape
Each bundled hosted-WebDriver provider is now a factory returning { webDriver, connection } plus a manifest-shaped declaration, the exact shape an installed WebDriver plugin returns. The CLI composes bundled and installed providers through one connect route and one runtime factory; the bundled definition table, the ProviderWebDriver facade with its dead listArtifactsFromEnv, the core-side profile builders, and the per-provider verify adapters are gone. Connect-time flag readers and resolved-profile checks are shared with TestMu.
1 parent d994d1f commit 586aa86

46 files changed

Lines changed: 1751 additions & 1320 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/contracts/src/provider-profile-fields.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
33
import { AppError } from '@agent-device/kernel/errors';
44
import {
55
rejectRefusedProviderProfileFields,
6+
requireResolvedProfilePlatform,
7+
requireResolvedProfileValue,
68
type ProviderProfileFieldDeclaration,
79
} from './provider-profile-fields.ts';
810

@@ -76,3 +78,26 @@ test('every refused field is reported at once', () => {
7678
'["--provider-os-version","--provider-geo-location","--provider-no-resign-app"]',
7779
);
7880
});
81+
82+
test('a saved profile missing a required value asks for a reconnect, not a flag', () => {
83+
assert.equal(
84+
requireResolvedProfileValue('Pixel 8', 'Fake Cloud profile missed device.'),
85+
'Pixel 8',
86+
);
87+
assert.equal(requireResolvedProfilePlatform('ios', 'Fake Cloud'), 'ios');
88+
for (const run of [
89+
() => requireResolvedProfileValue(undefined, 'Fake Cloud profile missed device.'),
90+
() => requireResolvedProfilePlatform('web', 'Fake Cloud'),
91+
() => requireResolvedProfilePlatform(undefined, 'Fake Cloud'),
92+
]) {
93+
assert.throws(
94+
run,
95+
(error: unknown) =>
96+
error instanceof AppError &&
97+
error.code === 'COMMAND_FAILED' &&
98+
/^Fake Cloud profile missed/.test(error.message) &&
99+
typeof error.details?.hint === 'string' &&
100+
error.details.hint.startsWith('Reconnect'),
101+
);
102+
}
103+
});

‎packages/contracts/src/provider-profile-fields.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,27 @@ export function rejectRefusedProviderProfileFields(
4242
);
4343
}
4444

45+
/**
46+
* A value a saved provider profile must carry before verification can run. Connect writes every
47+
* field its provider requires, so a missing one means a hand-authored profile needs regenerating.
48+
*/
49+
export function requireResolvedProfileValue<T>(value: T | undefined, message: string): T {
50+
if (value !== undefined) return value;
51+
throw new AppError('COMMAND_FAILED', message, {
52+
hint: 'Reconnect to regenerate and validate the provider profile.',
53+
});
54+
}
55+
56+
export function requireResolvedProfilePlatform(
57+
platform: string | undefined,
58+
service: string,
59+
): 'android' | 'ios' {
60+
if (platform === 'android' || platform === 'ios') return platform;
61+
throw new AppError('COMMAND_FAILED', `${service} profile missed a mobile platform.`, {
62+
hint: 'Reconnect with --platform ios|android.',
63+
});
64+
}
65+
4566
// The flags bag keeps the field, not the spelling, so name every spelling the user could have typed.
4667
function describeFlag(field: ProviderProfileField): string {
4768
const aliases = PROVIDER_PROFILE_FIELD_FLAG_ALIASES[field];
Lines changed: 59 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,55 @@
1-
import fs from 'node:fs';
2-
import path from 'node:path';
31
import type { ProviderPluginHost } from 'agent-device/plugins';
42
import type { CliFlags } from '@agent-device/contracts/command';
53
import {
64
rejectRefusedProviderProfileFields,
5+
requireResolvedProfilePlatform,
6+
requireResolvedProfileValue,
77
type ProviderProfileFieldDeclaration,
88
} from '@agent-device/contracts/provider-profile-fields';
9-
import { canonicalTestMuAppReference, isTestMuAppReference } from './providers.ts';
9+
import {
10+
requireConnectFlag,
11+
requireConnectPlatform,
12+
resolveLocalAppArtifact,
13+
} from '@agent-device/provider-webdriver/plugin';
14+
import {
15+
canonicalTestMuAppReference,
16+
isTestMuAppReference,
17+
requireTestMuCredentials,
18+
} from './providers.ts';
1019
import { verifyTestMuConnection } from './testmu-connection-verification.ts';
1120
import { readTestMuDeviceFeatureFields, readTestMuDeviceType } from './testmu-device-features.ts';
1221

22+
const PROVIDER = 'testmu';
23+
const SERVICE = 'TestMu AI';
24+
1325
export function createTestMuConnection(
1426
host: ProviderPluginHost,
1527
fields: ProviderProfileFieldDeclaration,
1628
) {
17-
const required = (value: string | undefined, name: string) => {
18-
if (value?.trim()) return value;
19-
throw host.createError('INVALID_ARGS', `connect testmu requires ${name}.`);
20-
};
2129
return {
2230
resolve: ({ flags, cwd }: { flags: CliFlags; cwd: string }) => {
2331
rejectRefusedProviderProfileFields(flags, fields);
24-
required(host.env.LT_USERNAME, 'LT_USERNAME');
25-
required(host.env.LT_ACCESS_KEY, 'LT_ACCESS_KEY');
26-
if (flags.platform !== 'android' && flags.platform !== 'ios')
27-
throw host.createError('INVALID_ARGS', 'connect testmu requires --platform ios|android.');
28-
let app = canonicalTestMuAppReference(
29-
required(flags.providerApp, '--provider-app <lt://app-id, URL, or local path>'),
32+
requireTestMuCredentials(host.env, `connect ${PROVIDER}`);
33+
const platform = requireConnectPlatform(flags, PROVIDER);
34+
const app = connectAppReference(
35+
requireConnectFlag(
36+
flags.providerApp,
37+
PROVIDER,
38+
'--provider-app <lt://app-id, URL, or local path>',
39+
),
40+
cwd,
41+
host,
3042
);
31-
if (app.startsWith('lt://')) {
32-
if (!isTestMuAppReference(app))
33-
throw host.createError(
34-
'INVALID_ARGS',
35-
'connect testmu requires a valid lt:// app reference.',
36-
);
37-
} else if (!/^https?:\/\//i.test(app)) {
38-
app = path.resolve(cwd, app);
39-
if (!fs.statSync(app, { throwIfNoEntry: false })?.isFile())
40-
throw host.createError('INVALID_ARGS', `TestMu AI app file not found: ${app}`);
41-
}
4243
return {
4344
profile: {
44-
leaseProvider: 'testmu',
45-
leaseBackend:
46-
flags.leaseBackend ?? (flags.platform === 'ios' ? 'ios-instance' : 'android-instance'),
47-
platform: flags.platform,
48-
device: required(flags.device, '--device <name>'),
49-
providerOsVersion: required(flags.providerOsVersion, '--provider-os-version <version>'),
45+
leaseProvider: PROVIDER,
46+
platform,
47+
device: requireConnectFlag(flags.device, PROVIDER, '--device <name>'),
48+
providerOsVersion: requireConnectFlag(
49+
flags.providerOsVersion,
50+
PROVIDER,
51+
'--provider-os-version <version>',
52+
),
5053
providerApp: app,
5154
providerDeviceType: readTestMuDeviceType(flags),
5255
providerProject: flags.providerProject,
@@ -57,23 +60,36 @@ export function createTestMuConnection(
5760
extraFlags: { providerApp: app },
5861
};
5962
},
60-
verify: async ({ flags }: { flags: CliFlags }) => {
61-
if (flags.platform !== 'android' && flags.platform !== 'ios')
62-
throw host.createError('INVALID_ARGS', 'TestMu profile missed platform.');
63-
return await verifyTestMuConnection(
63+
verify: async ({ flags }: { flags: CliFlags }) =>
64+
await verifyTestMuConnection(
6465
{
65-
provider: 'testmu',
66-
username: required(host.env.LT_USERNAME, 'LT_USERNAME'),
67-
accessKey: required(host.env.LT_ACCESS_KEY, 'LT_ACCESS_KEY'),
68-
platform: flags.platform,
69-
deviceName: required(flags.device, '--device'),
70-
osVersion: required(flags.providerOsVersion, '--provider-os-version'),
71-
app: canonicalTestMuAppReference(required(flags.providerApp, '--provider-app')),
66+
provider: PROVIDER,
67+
...requireTestMuCredentials(host.env, `connect ${PROVIDER}`),
68+
platform: requireResolvedProfilePlatform(flags.platform, SERVICE),
69+
deviceName: requireResolvedProfileValue(flags.device, 'TestMu AI profile missed device.'),
70+
osVersion: requireResolvedProfileValue(
71+
flags.providerOsVersion,
72+
'TestMu AI profile missed OS version.',
73+
),
74+
app: canonicalTestMuAppReference(
75+
requireResolvedProfileValue(flags.providerApp, 'TestMu AI profile missed app.'),
76+
),
7277
deviceType: readTestMuDeviceType(flags),
7378
apiEndpoint: host.env.TESTMU_API_ENDPOINT,
7479
},
7580
host.clientVersion,
76-
);
77-
},
81+
),
7882
};
7983
}
84+
85+
/** An `lt://` id is canonicalized, a public URL passes through, and anything else must be a local file. */
86+
function connectAppReference(app: string, cwd: string, host: ProviderPluginHost): string {
87+
const reference = canonicalTestMuAppReference(app);
88+
if (reference.startsWith('lt://')) {
89+
if (isTestMuAppReference(reference)) return reference;
90+
throw host.createError('INVALID_ARGS', 'connect testmu requires a valid lt:// app reference.');
91+
}
92+
return /^https?:\/\//i.test(reference)
93+
? reference
94+
: resolveLocalAppArtifact(reference, cwd, SERVICE);
95+
}

‎packages/provider-testmu/src/plugin.ts‎

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import type { ProviderDeviceType } from '@agent-device/contracts/remote';
55
import {
66
buildCloudWebDriverBaseCapabilities,
77
readFlag,
8-
requireEnv,
98
requireFlag,
109
requireRequest,
1110
requireRequestPlatform,
1211
} from '@agent-device/provider-webdriver/plugin';
1312
import { createTestMuConnection } from './connection.ts';
13+
import { requireTestMuCredentials } from './providers.ts';
1414
import {
1515
buildTestMuCapabilities,
1616
createTestMuUploadApp,
@@ -139,16 +139,6 @@ export default function testMuPlugin(host: ProviderPluginHost) {
139139
};
140140
return { webDriver, connection: createTestMuConnection(host, TESTMU_PROFILE_FIELDS) };
141141
}
142-
function requireTestMuCredentials(
143-
env: ProviderPluginHost['env'],
144-
providerLabel: string,
145-
): { username: string; accessKey: string } {
146-
return {
147-
username: requireEnv(env, 'LT_USERNAME', providerLabel),
148-
accessKey: requireEnv(env, 'LT_ACCESS_KEY', providerLabel),
149-
};
150-
}
151-
152142
/** Each pool has its own upload API, so each has its own override. */
153143
function testMuAppUploadEndpoint(
154144
env: ProviderPluginHost['env'],

‎packages/provider-testmu/src/providers.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1+
import { requireEnv } from '@agent-device/provider-webdriver/plugin';
12
import { canonicalSchemeReference } from '@agent-device/provider-webdriver/providers';
23

4+
export function requireTestMuCredentials(
5+
env: Readonly<Record<string, string | undefined>>,
6+
providerLabel: string,
7+
): { username: string; accessKey: string } {
8+
return {
9+
username: requireEnv(env, 'LT_USERNAME', providerLabel),
10+
accessKey: requireEnv(env, 'LT_ACCESS_KEY', providerLabel),
11+
};
12+
}
13+
314
export function isTestMuAppReference(value: string): boolean {
415
return /^lt:\/\/[\w.-]+$/.test(value);
516
}

‎packages/provider-webdriver/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
"./plugin": {
2424
"types": "./src/plugin.ts",
2525
"default": "./src/plugin.ts"
26+
},
27+
"./connection-verification": {
28+
"types": "./src/connection-verification.ts",
29+
"default": "./src/connection-verification.ts"
2630
}
2731
}
2832
}

‎packages/provider-webdriver/src/aws-device-farm-connection-verification.ts‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,28 @@ import { AppError } from '@agent-device/kernel/errors';
22
import { createAwsDeviceFarmCommandRunner } from './aws-device-farm.ts';
33
import type { RunHostCommand } from './dependencies.ts';
44
import type {
5-
CloudWebDriverConnectionVerification,
6-
CloudWebDriverConnectionVerificationOptions,
7-
} from './connection-verification.ts';
8-
import type { ProviderConnectionResource } from '@agent-device/contracts/remote';
5+
ProviderConnectionResource,
6+
ProviderConnectionVerification,
7+
} from '@agent-device/contracts/remote';
98

10-
type AwsOptions = Extract<
11-
CloudWebDriverConnectionVerificationOptions,
12-
{ provider: 'aws-device-farm' }
13-
>;
9+
export type AwsDeviceFarmConnectionVerificationOptions = {
10+
platform: 'android' | 'ios';
11+
projectArn: string;
12+
deviceArn: string;
13+
appArn?: string;
14+
region?: string;
15+
};
1416

15-
export function readAwsDeviceFarmRegionFromArn(arn: string): string | undefined {
16-
return /^arn:[^:]+:devicefarm:([^:]+):/.exec(arn)?.[1];
17-
}
17+
export type AwsDeviceFarmConnectionVerification = ProviderConnectionVerification & {
18+
provider: 'aws-device-farm';
19+
service: 'AWS Device Farm';
20+
project: { name?: string; reference: string };
21+
};
1822

1923
export async function verifyAwsDeviceFarmConnection(
20-
options: AwsOptions,
24+
options: AwsDeviceFarmConnectionVerificationOptions,
2125
runHostCommand: RunHostCommand,
22-
): Promise<CloudWebDriverConnectionVerification> {
26+
): Promise<AwsDeviceFarmConnectionVerification> {
2327
const runAwsJson = createAwsDeviceFarmCommandRunner({
2428
runHostCommand,
2529
region: options.region,
@@ -67,7 +71,7 @@ export async function verifyAwsDeviceFarmConnection(
6771

6872
function verifyAwsUpload(
6973
upload: Record<string, unknown>,
70-
options: AwsOptions,
74+
options: AwsDeviceFarmConnectionVerificationOptions,
7175
): ProviderConnectionResource {
7276
const status = readString(upload.status);
7377
if (status !== 'SUCCEEDED') {

0 commit comments

Comments
 (0)