diff --git a/.fallowrc.json b/.fallowrc.json index 635559ff05..a3b17a524c 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -2,6 +2,10 @@ "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", "entry": [ "tsdown.config.ts", + "scripts/check-provider-plugin.mjs", + "packages/provider-testmu/test/package-smoke.mjs", + "packages/provider-testmu/tsdown.config.ts", + "packages/provider-testmu/src/plugin.ts", "vitest.mutation.config.ts", "src/sdk/index.ts", "src/sdk/io.ts", @@ -10,6 +14,7 @@ "src/sdk/remote-config.ts", "src/sdk/install-source.ts", "src/sdk/plugins.ts", + "src/sdk/plugin-webdriver.ts", "src/sdk/android-adb.ts", "src/sdk/contracts.ts", "src/sdk/selectors.ts", @@ -56,6 +61,10 @@ // @agent-device/provider-limrun owns the source import, while the published // root build externalizes @limrun/api and retains runtime imports in packed chunks. "@limrun/api", + // @agent-device/testmu imports agent-device type-only, and its tsconfig maps those specifiers + // through this workspace link to core source so typecheck needs no build; Fallow then credits + // the source files rather than the package. + "agent-device", // Oxlint resolves the shared config's JS plugins dynamically from their package names. "@nkzw/eslint-plugin", "eslint-plugin-no-only-tests", @@ -66,6 +75,11 @@ "@arethetypeswrong/cli" ], "ignoreExports": [ + { + "comment": "The standalone packed-install harness loads this fixture from the selected package path and reads its request scenarios dynamically.", + "file": "packages/provider-*/test/package-smoke.mjs", + "exports": ["*"] + }, { "comment": "Android perf mechanics are selected through the lazy platform host so importing agent-device does not eagerly load adb mechanics. Fallow cannot follow the dynamic property read in src/platform-runtime-perf-host.ts.", "file": "packages/platform-android/src/perf.ts", @@ -287,7 +301,7 @@ }, { "comment": "Tool config default exports, loaded by the tool rather than imported.", - "file": "{oxlint.config.ts,tsdown.config.ts,vitest.mutation.config.ts,website/rspress.config.ts}", + "file": "{oxlint.config.ts,tsdown.config.ts,packages/provider-*/tsdown.config.ts,vitest.mutation.config.ts,website/rspress.config.ts}", "exports": ["default"] }, { diff --git a/README.md b/README.md index a5f9cb069c..9c056a81cb 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ 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 @@ -145,7 +145,7 @@ The same session and evidence model works at every step: the agent explores the Support depth varies by target. Newer backends such as HarmonyOS and Vega OS cover a subset of commands; run `agent-device capabilities --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. diff --git a/package.json b/package.json index 7a32681976..f87480200b 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,10 @@ "./plugins": { "types": "./dist/src/plugins.d.ts", "import": "./dist/src/plugins.js" + }, + "./plugins/webdriver": { + "types": "./dist/src/plugins/webdriver.d.ts", + "import": "./dist/src/plugins/webdriver.js" } }, "engines": { @@ -175,7 +179,7 @@ "check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "npm_config_dry_run=false pnpm release:prepare", - "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/device-selection packages/host-kit packages/capture-kit packages/managed-allocation packages/provision-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/command-registry packages/session-journal packages/ad-replay packages/maestro packages/replay-port packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", + "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/device-selection packages/host-kit packages/capture-kit packages/managed-allocation packages/provision-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/command-registry packages/session-journal packages/ad-replay packages/maestro packages/replay-port packages/replay-test packages/provider-webdriver packages/provider-limrun packages/provider-testmu && 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", @@ -341,6 +345,7 @@ "vite": "^8.2.1", "vitest": "^4.1.11", "yaml": "^2.9.0", - "yauzl": "^3.4.0" + "yauzl": "^3.4.0", + "@agent-device/testmu": "workspace:*" } } diff --git a/packages/command-registry/src/flag-definitions-connection.ts b/packages/command-registry/src/flag-definitions-connection.ts index 2b6fc8fbda..4d3aada9c4 100644 --- a/packages/command-registry/src/flag-definitions-connection.ts +++ b/packages/command-registry/src/flag-definitions-connection.ts @@ -1,5 +1,8 @@ import { LEASE_BACKENDS } from '@agent-device/kernel/contracts'; -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[] = [ @@ -175,6 +178,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'], @@ -237,7 +251,7 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ type: 'string', usageLabel: '--provider-appium-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, }, diff --git a/packages/command-registry/src/flag-groups.ts b/packages/command-registry/src/flag-groups.ts index c8dc16507d..f3f4ae1804 100644 --- a/packages/command-registry/src/flag-groups.ts +++ b/packages/command-registry/src/flag-groups.ts @@ -78,6 +78,7 @@ export const COMMON_COMMAND_SUPPORTED_FLAG_KEYS = flagKeys( 'device', 'providerApp', 'providerOsVersion', + 'providerDeviceType', 'providerProject', 'providerBuild', 'providerSessionName', diff --git a/packages/contracts/src/__tests__/lease-scope.test.ts b/packages/contracts/src/__tests__/lease-scope.test.ts index 364fbee930..87ba632d22 100644 --- a/packages/contracts/src/__tests__/lease-scope.test.ts +++ b/packages/contracts/src/__tests__/lease-scope.test.ts @@ -204,6 +204,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', @@ -220,6 +221,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', diff --git a/packages/contracts/src/client-connection.ts b/packages/contracts/src/client-connection.ts index 52d6bc78ce..07cbf69ad4 100644 --- a/packages/contracts/src/client-connection.ts +++ b/packages/contracts/src/client-connection.ts @@ -70,6 +70,7 @@ export type AgentDeviceRequestOverrides = Pick< | 'clientId' | 'providerApp' | 'providerOsVersion' + | 'providerDeviceType' | 'providerProject' | 'providerBuild' | 'providerSessionName' diff --git a/packages/contracts/src/facades/remote.ts b/packages/contracts/src/facades/remote.ts index 277da93e58..41314baee8 100644 --- a/packages/contracts/src/facades/remote.ts +++ b/packages/contracts/src/facades/remote.ts @@ -11,17 +11,20 @@ export type { ResolvedMetroKind, } from '../metro.ts'; export type { + ConnectionProviderCapabilities, ProviderConnectionResource, ProviderConnectionVerification, } from '../provider-connection.ts'; export { PROVIDER_DEVICE_ORIENTATIONS, + PROVIDER_DEVICE_TYPES, PROVIDER_PROFILE_FIELD_FLAG_ALIASES, PROVIDER_PROFILE_FIELD_FLAGS, } from '../remote-config-fields.ts'; export type { CloudProviderProfileFields, ProviderDeviceOrientation, + ProviderDeviceType, RemoteConfigMetroOptions, RemoteConnectionProfileFields, } from '../remote-config-fields.ts'; diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index e365a472e5..3c471ff8ef 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -221,6 +221,7 @@ const LEASE_ALLOCATE_PROVIDER_FLAG_KEYS = [ // The Cloud provider profile fields; pinned exhaustive against that vocabulary below. 'providerApp', 'providerOsVersion', + 'providerDeviceType', 'providerProject', 'providerBuild', 'providerSessionName', diff --git a/packages/contracts/src/provider-connection.ts b/packages/contracts/src/provider-connection.ts index 3a3fa1125d..a0ff737e9e 100644 --- a/packages/contracts/src/provider-connection.ts +++ b/packages/contracts/src/provider-connection.ts @@ -17,3 +17,13 @@ export type ProviderConnectionVerification = { device: ProviderConnectionResource; app: ProviderConnectionResource; }; + +export type ConnectionProviderCapabilities = { + leaseKind: 'proxy' | 'direct-device-provider' | 'remote-provider'; + requiresAppAttachment: boolean; + requiresRemoteDaemon: boolean; + supportsArtifacts: boolean; + supportsDeferredAppSelection: boolean; + supportsDirectPortReverse: boolean; + usesCloudWebDriverLease: boolean; +}; diff --git a/packages/contracts/src/provider-profile-fields.test.ts b/packages/contracts/src/provider-profile-fields.test.ts index 4d79c40a3e..064a217927 100644 --- a/packages/contracts/src/provider-profile-fields.test.ts +++ b/packages/contracts/src/provider-profile-fields.test.ts @@ -12,6 +12,7 @@ const DECLARATION: ProviderProfileFieldDeclaration = { fields: { providerApp: 'consumed', providerOsVersion: 'refused', + providerDeviceType: 'consumed', providerProject: 'consumed', providerBuild: 'consumed', providerSessionName: 'consumed', diff --git a/packages/contracts/src/remote-config-fields.ts b/packages/contracts/src/remote-config-fields.ts index 728927d688..06ea1ad1f7 100644 --- a/packages/contracts/src/remote-config-fields.ts +++ b/packages/contracts/src/remote-config-fields.ts @@ -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; @@ -48,6 +53,7 @@ export const PROVIDER_PROFILE_FIELD_FLAGS: Readonly< > = { providerApp: '--provider-app', providerOsVersion: '--provider-os-version', + providerDeviceType: '--provider-device-type', providerProject: '--provider-project', providerBuild: '--provider-build', providerSessionName: '--provider-session-name', diff --git a/packages/kernel/src/errors.test.ts b/packages/kernel/src/errors.test.ts index fbefc6b8c2..0d47220b90 100644 --- a/packages/kernel/src/errors.test.ts +++ b/packages/kernel/src/errors.test.ts @@ -178,3 +178,20 @@ test('discloseDispatchAfterSteps keeps no only while no step of the series was d const plain = new Error('socket closed'); assert.equal(discloseDispatchAfterSteps(plain, 4), plain); }); + +test('bundled plugin errors preserve codes and details without changing subclass checks', async () => { + const copyPath = './errors.ts?plugin-copy'; + const { AppError: PluginError } = await import(copyPath); + assert.notEqual(PluginError, AppError); + const foreign = new PluginError('INVALID_ARGS', 'bad plugin profile', { provider: 'example' }); + assert.ok(foreign instanceof AppError); + const normalized = normalizeError(foreign); + assert.equal(normalized.code, 'INVALID_ARGS'); + assert.equal(normalized.message, 'bad plugin profile'); + assert.deepEqual(normalized.details, { provider: 'example' }); + const ordinary = Object.assign(new Error('bad plugin profile'), { code: 'INVALID_ARGS' }); + assert.equal(ordinary instanceof AppError, false); + class SpecificError extends AppError {} + assert.ok(new SpecificError('COMMAND_FAILED', 'specific') instanceof SpecificError); + assert.equal(foreign instanceof SpecificError, false); +}); diff --git a/packages/kernel/src/errors.ts b/packages/kernel/src/errors.ts index 7a15f00fdf..56ed18b6bd 100644 --- a/packages/kernel/src/errors.ts +++ b/packages/kernel/src/errors.ts @@ -36,6 +36,31 @@ export type KnownAppErrorCode = (typeof KNOWN_APP_ERROR_CODES)[number]; // include a default branch. export type AppErrorCode = KnownAppErrorCode | (string & {}); +const APP_ERROR_BRAND = Symbol.for('agent-device.AppError'); + +export class AppError extends Error { + static [Symbol.hasInstance](value: unknown): boolean { + if (this !== AppError) return Function.prototype[Symbol.hasInstance].call(this, value); + return ( + typeof value === 'object' && + value !== null && + (value as Record)[APP_ERROR_BRAND] === true + ); + } + + code: AppErrorCode; + details?: AppErrorDetails; + cause?: unknown; + + constructor(code: AppErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) { + super(message); + Object.defineProperty(this, APP_ERROR_BRAND, { value: true }); + this.code = code; + this.details = details; + this.cause = cause; + } +} + export function toAppErrorCode( code: string | undefined, fallback: AppErrorCode = 'COMMAND_FAILED', @@ -219,19 +244,6 @@ export type DaemonError = { supportedOn?: string; }; -export class AppError extends Error { - code: AppErrorCode; - details?: AppErrorDetails; - cause?: unknown; - - constructor(code: AppErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) { - super(message); - this.code = code; - this.details = details; - this.cause = cause; - } -} - /** Rehydrate a daemon transport error into the error type used by local callers. */ export function throwDaemonError(error: DaemonError): never { throw new AppError( diff --git a/packages/provider-limrun/src/device.ts b/packages/provider-limrun/src/device.ts index 78f563da4e..8468e5ae36 100644 --- a/packages/provider-limrun/src/device.ts +++ b/packages/provider-limrun/src/device.ts @@ -14,6 +14,7 @@ export const LIMRUN_PROFILE_FIELDS: ProviderProfileFieldDeclaration = { fields: { providerApp: 'consumed', providerOsVersion: 'refused', + providerDeviceType: 'refused', providerProject: 'refused', providerBuild: 'refused', providerSessionName: 'refused', diff --git a/packages/provider-testmu/README.md b/packages/provider-testmu/README.md new file mode 100644 index 0000000000..2cf34a621c --- /dev/null +++ b/packages/provider-testmu/README.md @@ -0,0 +1,17 @@ +# @agent-device/testmu + +Use TestMu AI Android and iOS virtual and real devices with [agent-device](https://agent-device.dev). +You need a TestMu AI account and its API credentials. + +```sh +npm install -g agent-device +agent-device plugins add @agent-device/testmu +export LT_USERNAME=your-username +export LT_ACCESS_KEY=your-access-key +agent-device connect testmu --platform ios --device "iPhone 16" --provider-os-version 18.0 --provider-app ./MyApp.zip +``` + +See the [TestMu AI guide](https://agent-device.dev/docs/testmu) for setup and supported operations. +To update the plugin, run `agent-device plugins update @agent-device/testmu`. + +After adding or updating a plugin, close your sessions and run `agent-device daemon stop` before reconnecting. diff --git a/packages/provider-testmu/package.json b/packages/provider-testmu/package.json new file mode 100644 index 0000000000..2dd95897f9 --- /dev/null +++ b/packages/provider-testmu/package.json @@ -0,0 +1,63 @@ +{ + "name": "@agent-device/testmu", + "version": "0.21.22", + "type": "module", + "description": "TestMu AI real and virtual mobile device plugin for agent-device.", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown --config tsdown.config.ts", + "prepack": "pnpm build" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./connection-verification": { + "types": "./src/testmu-connection-verification.ts", + "default": "./src/testmu-connection-verification.ts" + }, + "./package.json": "./package.json" + }, + "devDependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*", + "@agent-device/provider-webdriver": "workspace:*", + "agent-device": "workspace:*" + }, + "agentDevicePlugin": { + "apiVersion": 1, + "provider": "testmu", + "entry": "./dist/plugin.mjs", + "connection": { + "leaseKind": "direct-device-provider", + "requiresAppAttachment": false, + "requiresRemoteDaemon": false, + "supportsArtifacts": true, + "supportsDeferredAppSelection": false, + "supportsDirectPortReverse": false, + "usesCloudWebDriverLease": true + }, + "credentialVariables": [ + "LT_USERNAME", + "LT_ACCESS_KEY" + ] + }, + "publishConfig": { + "exports": { + ".": { + "import": "./dist/plugin.mjs" + } + }, + "access": "public" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/callstack/agent-device.git", + "directory": "packages/provider-testmu" + }, + "homepage": "https://agent-device.dev/docs/testmu" +} diff --git a/packages/provider-testmu/src/connection.ts b/packages/provider-testmu/src/connection.ts new file mode 100644 index 0000000000..d5e0caf904 --- /dev/null +++ b/packages/provider-testmu/src/connection.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { ProviderPluginHost } from 'agent-device/plugins'; +import type { CliFlags } from '@agent-device/contracts/command'; +import { + rejectRefusedProviderProfileFields, + type ProviderProfileFieldDeclaration, +} from '@agent-device/contracts/provider-profile-fields'; +import { canonicalTestMuAppReference, isTestMuAppReference } from './providers.ts'; +import { verifyTestMuConnection } from './testmu-connection-verification.ts'; +import { readTestMuDeviceFeatureFields, readTestMuDeviceType } from './testmu-device-features.ts'; + +export function createTestMuConnection( + host: ProviderPluginHost, + fields: ProviderProfileFieldDeclaration, +) { + const required = (value: string | undefined, name: string) => { + if (value?.trim()) return value; + throw host.createError('INVALID_ARGS', `connect testmu requires ${name}.`); + }; + return { + resolve: ({ flags, cwd }: { flags: CliFlags; cwd: string }) => { + rejectRefusedProviderProfileFields(flags, fields); + required(host.env.LT_USERNAME, 'LT_USERNAME'); + required(host.env.LT_ACCESS_KEY, 'LT_ACCESS_KEY'); + if (flags.platform !== 'android' && flags.platform !== 'ios') + throw host.createError('INVALID_ARGS', 'connect testmu requires --platform ios|android.'); + let app = canonicalTestMuAppReference( + required(flags.providerApp, '--provider-app '), + ); + if (app.startsWith('lt://')) { + if (!isTestMuAppReference(app)) + throw host.createError( + 'INVALID_ARGS', + 'connect testmu requires a valid lt:// app reference.', + ); + } else if (!/^https?:\/\//i.test(app)) { + app = path.resolve(cwd, app); + if (!fs.statSync(app, { throwIfNoEntry: false })?.isFile()) + throw host.createError('INVALID_ARGS', `TestMu AI app file not found: ${app}`); + } + return { + profile: { + leaseProvider: 'testmu', + leaseBackend: + flags.leaseBackend ?? (flags.platform === 'ios' ? 'ios-instance' : 'android-instance'), + platform: flags.platform, + device: required(flags.device, '--device '), + providerOsVersion: required(flags.providerOsVersion, '--provider-os-version '), + providerApp: app, + providerDeviceType: readTestMuDeviceType(flags), + providerProject: flags.providerProject, + providerBuild: flags.providerBuild, + providerSessionName: flags.providerSessionName, + ...readTestMuDeviceFeatureFields(flags), + } as const, + extraFlags: { providerApp: app }, + }; + }, + verify: async ({ flags }: { flags: CliFlags }) => { + if (flags.platform !== 'android' && flags.platform !== 'ios') + throw host.createError('INVALID_ARGS', 'TestMu profile missed platform.'); + return await verifyTestMuConnection( + { + provider: 'testmu', + username: required(host.env.LT_USERNAME, 'LT_USERNAME'), + accessKey: required(host.env.LT_ACCESS_KEY, 'LT_ACCESS_KEY'), + platform: flags.platform, + deviceName: required(flags.device, '--device'), + osVersion: required(flags.providerOsVersion, '--provider-os-version'), + app: canonicalTestMuAppReference(required(flags.providerApp, '--provider-app')), + deviceType: readTestMuDeviceType(flags), + apiEndpoint: host.env.TESTMU_API_ENDPOINT, + }, + host.clientVersion, + ); + }, + }; +} diff --git a/packages/provider-testmu/src/index.ts b/packages/provider-testmu/src/index.ts new file mode 100644 index 0000000000..f00db85a53 --- /dev/null +++ b/packages/provider-testmu/src/index.ts @@ -0,0 +1 @@ +export { default } from './plugin.ts'; diff --git a/packages/provider-testmu/src/plugin.test.ts b/packages/provider-testmu/src/plugin.test.ts new file mode 100644 index 0000000000..29621ae951 --- /dev/null +++ b/packages/provider-testmu/src/plugin.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { afterEach, test, vi } from 'vitest'; +import type { ProviderPluginHost } from 'agent-device/plugins'; +import { AppError } from '@agent-device/kernel/errors'; +import testMuPlugin from './plugin.ts'; +import { verifyTestMuConnection } from './testmu-connection-verification.ts'; + +vi.mock('./testmu-connection-verification.ts', () => ({ verifyTestMuConnection: vi.fn() })); + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; + vi.clearAllMocks(); +}); + +function host(env: Record): ProviderPluginHost { + return { + env, + options: {}, + clientVersion: '0.0.0-test', + createError: (code, message, details) => new AppError(code, message, details), + }; +} + +test('whitespace-only credentials are missing, not a provider authentication failure', async () => { + globalThis.fetch = async () => { + throw new Error('a missing credential must not reach TestMu AI'); + }; + for (const env of [ + { LT_USERNAME: ' ', LT_ACCESS_KEY: 'key' }, + { LT_USERNAME: 'user', LT_ACCESS_KEY: '\t' }, + ]) { + await assert.rejects( + testMuPlugin(host(env)).webDriver.listArtifacts!({ + provider: 'testmu', + providerSessionId: 'SESSION1', + }), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /requires LT_(USERNAME|ACCESS_KEY) in the environment/.test(error.message), + ); + } +}); + +test('verification of a hand-authored profile canonicalizes an upper-case app scheme', async () => { + vi.mocked(verifyTestMuConnection).mockResolvedValue({ + provider: 'testmu', + service: 'TestMu AI', + verificationMessage: 'verified', + device: { status: 'verified', name: 'iPhone 16', platform: 'ios', osVersion: '18.0' }, + app: { status: 'verified', reference: 'lt://APP1' }, + }); + const env = { LT_USERNAME: 'user', LT_ACCESS_KEY: 'key' }; + await testMuPlugin(host(env)).connection.verify({ + flags: { + json: false, + help: false, + version: false, + platform: 'ios', + device: 'iPhone 16', + providerOsVersion: '18.0', + providerApp: 'LT://APP1', + }, + }); + assert.equal(vi.mocked(verifyTestMuConnection).mock.calls[0]?.[0].app, 'lt://APP1'); +}); diff --git a/packages/provider-testmu/src/plugin.ts b/packages/provider-testmu/src/plugin.ts new file mode 100644 index 0000000000..f844178ad6 --- /dev/null +++ b/packages/provider-testmu/src/plugin.ts @@ -0,0 +1,160 @@ +import type { ProviderPluginHost } from 'agent-device/plugins'; +import type { WebDriverPluginOptions } from 'agent-device/plugins/webdriver'; +import type { ProviderProfileFieldDeclaration } from '@agent-device/contracts/provider-profile-fields'; +import type { ProviderDeviceType } from '@agent-device/contracts/remote'; +import { + buildCloudWebDriverBaseCapabilities, + readFlag, + requireEnv, + requireFlag, + requireRequest, + requireRequestPlatform, +} from '@agent-device/provider-webdriver/plugin'; +import { createTestMuConnection } from './connection.ts'; +import { + buildTestMuCapabilities, + createTestMuUploadApp, + listTestMuCloudArtifacts, + resolveTestMuAppReference, +} from './testmu.ts'; +import { + buildTestMuDeviceFeatureCapabilities, + readTestMuDeviceFeatureFields, + readTestMuDeviceType, +} from './testmu-device-features.ts'; +const TESTMU_WEBDRIVER_ENDPOINT = 'https://mobile-hub.lambdatest.com/wd/hub/'; +const TESTMU_CAPABILITY_OVERRIDES = { + install: { + support: 'partial', + note: 'Local app artifacts are uploaded to TestMu AI as real- or virtual-device apps (lt://), then installed with Appium.', + }, + portReverse: { + support: 'unsupported', + note: 'Use the TestMu AI tunnel for network access to local hosts; agent-device port reverse is not available.', + }, + artifacts: { + support: 'supported', + note: 'TestMu AI session details expose provider-hosted video, Appium logs, device logs, network logs, and dashboard links.', + }, +} as const; + +const TESTMU_PROFILE_FIELDS: ProviderProfileFieldDeclaration = { + provider: 'testmu', + label: 'TestMu AI', + fields: { + providerApp: 'consumed', + providerOsVersion: 'consumed', + providerDeviceType: 'consumed', + providerProject: 'consumed', + providerBuild: 'consumed', + providerSessionName: 'consumed', + providerDeviceOrientation: 'consumed', + providerGeoLocation: 'consumed', + providerTimezone: 'consumed', + providerAppiumVersion: 'consumed', + providerLanguage: 'consumed', + providerLocale: 'consumed', + providerNetworkProfile: 'refused', + providerCustomNetwork: 'refused', + providerNoResignApp: 'refused', + awsProjectArn: 'refused', + awsDeviceArn: 'refused', + awsAppArn: 'refused', + awsRegion: 'refused', + awsInteractionMode: 'refused', + }, +}; + +export default function testMuPlugin(host: ProviderPluginHost) { + const env = host.env; + async function listTestMuArtifactsFromEnv( + provider: string, + providerSessionId: string | undefined, + env: ProviderPluginHost['env'], + ) { + return await listTestMuCloudArtifacts(provider, providerSessionId, { + clientVersion: host.clientVersion, + ...requireTestMuCredentials(env, 'TestMu AI artifact lookup'), + endpoint: env.TESTMU_API_ENDPOINT, + }); + } + const webDriver: WebDriverPluginOptions = { + provider: 'testmu', + profileFields: TESTMU_PROFILE_FIELDS, + platform: 'android', + deviceName: 'TestMu AI device', + endpoint: env.TESTMU_WEBDRIVER_ENDPOINT ?? TESTMU_WEBDRIVER_ENDPOINT, + capabilityOverrides: TESTMU_CAPABILITY_OVERRIDES, + listArtifacts: async ({ provider, providerSessionId }) => + await listTestMuArtifactsFromEnv(provider, providerSessionId, env), + prepareSession: async ({ req, lease, base }) => { + const request = requireRequest(req, 'TestMu AI'); + const deviceType = readTestMuDeviceType(request.flags); + const uploadEndpoint = testMuAppUploadEndpoint(env, deviceType); + const credentials = requireTestMuCredentials(env, 'TestMu AI'); + const platform = requireRequestPlatform(request, 'TestMu AI'); + const deviceName = requireFlag(request, 'device', 'TestMu AI requires --device .'); + const osVersion = requireFlag( + request, + 'providerOsVersion', + 'TestMu AI requires --provider-os-version .', + ); + const upload = { + clientVersion: host.clientVersion, + + ...credentials, + deviceType, + endpoint: uploadEndpoint, + }; + const app = await resolveTestMuAppReference( + requireFlag( + request, + 'providerApp', + 'TestMu AI requires --provider-app .', + ), + { ...upload, cwd: request.cwd, signal: request.signal }, + ); + return { + ...base, + platform, + deviceName, + auth: credentials, + uploadApp: createTestMuUploadApp(upload), + webdriverCapabilities: buildTestMuCapabilities({ + platform, + deviceType, + deviceName, + osVersion, + app, + projectName: readFlag(request, 'providerProject'), + buildName: readFlag(request, 'providerBuild') ?? lease.runId, + sessionName: readFlag(request, 'providerSessionName') ?? lease.leaseId, + deviceFeatures: buildTestMuDeviceFeatureCapabilities( + readTestMuDeviceFeatureFields(request.flags), + ), + configured: buildCloudWebDriverBaseCapabilities(platform, deviceName), + }), + }; + }, + }; + return { webDriver, connection: createTestMuConnection(host, TESTMU_PROFILE_FIELDS) }; +} +function requireTestMuCredentials( + env: ProviderPluginHost['env'], + providerLabel: string, +): { username: string; accessKey: string } { + return { + username: requireEnv(env, 'LT_USERNAME', providerLabel), + accessKey: requireEnv(env, 'LT_ACCESS_KEY', providerLabel), + }; +} + +/** Each pool has its own upload API, so each has its own override. */ +function testMuAppUploadEndpoint( + env: ProviderPluginHost['env'], + deviceType: ProviderDeviceType, +): string | undefined { + return deviceType === 'real' + ? env.TESTMU_REAL_DEVICE_APP_UPLOAD_ENDPOINT + : env.TESTMU_APP_UPLOAD_ENDPOINT; +} diff --git a/packages/provider-testmu/src/providers.ts b/packages/provider-testmu/src/providers.ts new file mode 100644 index 0000000000..11260f933f --- /dev/null +++ b/packages/provider-testmu/src/providers.ts @@ -0,0 +1,9 @@ +import { canonicalSchemeReference } from '@agent-device/provider-webdriver/providers'; + +export function isTestMuAppReference(value: string): boolean { + return /^lt:\/\/[\w.-]+$/.test(value); +} + +export function canonicalTestMuAppReference(value: string): string { + return canonicalSchemeReference(value, 'lt://') ?? value; +} diff --git a/packages/provider-testmu/src/testmu-connection-verification.test.ts b/packages/provider-testmu/src/testmu-connection-verification.test.ts new file mode 100644 index 0000000000..e82ac69cbd --- /dev/null +++ b/packages/provider-testmu/src/testmu-connection-verification.test.ts @@ -0,0 +1,408 @@ +import assert from 'node:assert/strict'; +import { afterEach, test, vi } from 'vitest'; +import { verifyTestMuConnection } from './testmu-connection-verification.ts'; +import type { TestMuOptions } from './testmu-connection-verification.ts'; +afterEach(() => vi.unstubAllGlobals()); +function createProvider() { + return { + verifyConnection: async (options: TestMuOptions) => + await verifyTestMuConnection(options, '1.2.3'), + }; +} +function jsonResponse(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { status }); +} +const testMuOptions = { + provider: 'testmu' as const, + username: 'lt-user', + accessKey: 'lt-key', + platform: 'android' as const, + deviceName: 'Pixel 8', + osVersion: '14', + app: 'lt://APP1', + devicesEndpoint: 'https://testmu.test/capability/generator?isVirtualDevice=true', + appsEndpoint: 'https://testmu.test/app/data', +}; + +const testMuCatalog = { + app: { + devices: { + android: { + brands: { + Google: [ + { name: 'Pixel 8', osVersion: ['14', '15'] }, + { name: 'Pixel 4a', osVersion: ['13'] }, + ], + }, + }, + ios: { brands: { Apple: [{ name: 'iPhone 16', osVersion: ['18.0'] }] } }, + }, + }, +}; + +test('TestMu verifies the virtual device and uploaded app without creating a session', async () => { + const fetchMock = vi.fn(async (input, init) => { + const headers = (init?.headers ?? {}) as Record; + if (String(input).includes('capability/generator')) { + assert.equal(headers.Authorization, undefined); + return jsonResponse(testMuCatalog); + } + assert.match(String(headers.Authorization), /^Basic /); + return jsonResponse({ + data: [{ app_id: 'APP1', name: 'sample.apk', version: '1.2.3', type: 'android' }], + metaData: { total: 1 }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await createProvider().verifyConnection(testMuOptions); + + assert.equal(result.provider, 'testmu'); + assert.equal(result.service, 'TestMu AI'); + assert.deepEqual(result.device, { + status: 'verified', + name: 'Pixel 8', + platform: 'android', + osVersion: '14', + }); + assert.deepEqual(result.app, { + status: 'verified', + name: 'sample.apk', + reference: 'lt://APP1', + version: '1.2.3', + }); + assert.deepEqual( + fetchMock.mock.calls.map(([input]) => String(input)), + [ + 'https://testmu.test/capability/generator?isVirtualDevice=true', + 'https://testmu.test/app/data?type=emulator&level=user', + ], + ); +}); + +test('TestMu checks the catalog of the configured API endpoint', async () => { + const fetchMock = vi.fn(async (input) => + String(input).includes('capability/generator') + ? jsonResponse(testMuCatalog) + : jsonResponse({ data: [{ app_id: 'APP1' }] }), + ); + vi.stubGlobal('fetch', fetchMock); + const { devicesEndpoint: _devicesEndpoint, ...options } = testMuOptions; + + await createProvider().verifyConnection({ + ...options, + apiEndpoint: 'https://staging.testmu.test/mobile-automation/api/v1/', + }); + + assert.equal( + String(fetchMock.mock.calls[0]?.[0]), + 'https://staging.testmu.test/mobile-automation/api/v1/capability/generator?isVirtualDevice=true', + ); +}); + +test('TestMu keeps the query of an overridden endpoint and adds its own filters', async () => { + const fetchMock = vi.fn(async (input) => + String(input).includes('capability/generator') + ? jsonResponse(testMuCatalog) + : jsonResponse({ data: [{ app_id: 'APP1' }] }), + ); + vi.stubGlobal('fetch', fetchMock); + const { devicesEndpoint: _devicesEndpoint, ...options } = testMuOptions; + + await createProvider().verifyConnection({ + ...options, + apiEndpoint: 'https://staging.testmu.test/api/v1/?region=eu', + appsEndpoint: 'https://staging.testmu.test/app/data?org=42', + }); + await createProvider().verifyConnection({ + ...testMuOptions, + devicesEndpoint: 'https://testmu.test/capability/generator?region=eu', + }); + + assert.deepEqual( + fetchMock.mock.calls.map(([input]) => String(input)), + [ + 'https://staging.testmu.test/api/v1/capability/generator?region=eu&isVirtualDevice=true', + 'https://staging.testmu.test/app/data?org=42&type=emulator&level=user', + 'https://testmu.test/capability/generator?region=eu&isVirtualDevice=true', + 'https://testmu.test/app/data?type=emulator&level=user', + ], + ); +}); + +test('TestMu rejects a device or OS version missing from the virtual-device catalog', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(testMuCatalog)), + ); + await assert.rejects( + createProvider().verifyConnection({ ...testMuOptions, osVersion: '12' }), + (error: unknown) => + error instanceof Error && /"Pixel 8" with android 12 is not available/.test(error.message), + ); + await assert.rejects( + createProvider().verifyConnection({ ...testMuOptions, platform: 'ios', deviceName: 'Pixel 8' }), + /is not available/, + ); +}); + +// The hub rejects `platformVersion: '18'` for a catalog entry spelled `18.0`, so connect must too. +test('TestMu matches the catalog OS version spelling exactly and lists the offered versions', async () => { + const catalog = { + app: { + devices: { + ios: { + brands: { + Apple: [{ name: 'iPhone 16', osVersion: ['18.1', '26.0', '18.0', '18.5', '26.2'] }], + }, + }, + }, + }, + }; + vi.stubGlobal( + 'fetch', + vi.fn(async (input) => + String(input).includes('capability/generator') + ? jsonResponse(catalog) + : jsonResponse({ data: [], metaData: { total: 0 } }), + ), + ); + const iosOptions = { ...testMuOptions, platform: 'ios' as const, deviceName: 'iPhone 16' }; + + await assert.rejects( + createProvider().verifyConnection({ ...iosOptions, osVersion: '18' }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as { code?: string }).code, 'INVALID_ARGS'); + assert.match(error.message, /iPhone 16 offers 18\.0, 18\.1, 18\.5, 26\.0, 26\.2/); + return true; + }, + ); + + const result = await createProvider().verifyConnection({ ...iosOptions, osVersion: '18.0' }); + assert.deepEqual(result.device, { + status: 'verified', + name: 'iPhone 16', + platform: 'ios', + osVersion: '18.0', + }); +}); + +test('TestMu classifies rejected credentials without exposing them', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input) => + String(input).includes('capability/generator') + ? jsonResponse(testMuCatalog) + : jsonResponse({ message: 'Unauthorized' }, 401), + ), + ); + await assert.rejects(createProvider().verifyConnection(testMuOptions), (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as { code?: string }).code, 'UNAUTHORIZED'); + assert.doesNotMatch(error.message, /lt-key/); + return true; + }); +}); + +test('TestMu defers an lt:// reference it cannot find and a local path it will upload', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input) => + String(input).includes('capability/generator') + ? jsonResponse(testMuCatalog) + : jsonResponse({ data: [], metaData: { total: 0 } }), + ), + ); + const unknownApp = await createProvider().verifyConnection(testMuOptions); + assert.equal(unknownApp.app.status, 'configured'); + assert.equal(unknownApp.app.reference, 'lt://APP1'); + + const localApp = await createProvider().verifyConnection({ + ...testMuOptions, + app: '/tmp/builds/App.apk', + }); + assert.deepEqual(localApp.app, { + status: 'configured', + name: 'App.apk', + reference: '/tmp/builds/App.apk', + message: 'Local app artifact is ready and will be uploaded when creating the session.', + }); +}); + +// The real-device catalog is keyed by platform at the top level, not under `app.devices`. +const testMuRealCatalog = { + android: { + brands: { + Google: [ + { name: 'Pixel 6', osVersion: ['12', '13', '14', '15', '16'] }, + { name: 'Pixel 8', osVersion: ['14'] }, + ], + }, + }, + ios: { + brands: { + Apple: [ + { name: 'iPhone 16', osVersion: ['18'] }, + { name: 'iPhone 15', osVersion: ['17', '18', '26'] }, + ], + }, + }, + roku: { brands: {} }, + tvos: { brands: {} }, +}; + +test('TestMu verifies a real device against the real-device catalog shape', async () => { + const fetchMock = vi.fn(async (input) => + String(input).includes('capability/generator') + ? jsonResponse(testMuRealCatalog) + : jsonResponse({ data: [{ app_id: 'APP1', name: 'MyApp.ipa' }], metaData: { total: 1 } }), + ); + vi.stubGlobal('fetch', fetchMock); + const { devicesEndpoint: _devicesEndpoint, ...defaultCatalog } = testMuOptions; + + const result = await createProvider().verifyConnection({ + ...defaultCatalog, + deviceType: 'real', + platform: 'ios', + deviceName: 'iPhone 16', + osVersion: '18', + }); + + assert.equal(result.verificationMessage, 'Credentials, real device, and uploaded app verified.'); + assert.deepEqual(result.device, { + status: 'verified', + name: 'iPhone 16', + platform: 'ios', + osVersion: '18', + }); + assert.equal( + String(fetchMock.mock.calls[0]?.[0]), + 'https://mobile-api.lambdatest.com/mobile-automation/api/v1/capability/generator?isVirtualDevice=false', + ); + + const android = await createProvider().verifyConnection({ + ...testMuOptions, + deviceType: 'real', + deviceName: 'Pixel 6', + osVersion: '14', + }); + assert.equal(android.device.name, 'Pixel 6'); +}); + +// Real iOS devices are listed by major version, so `18.0` is the wrong spelling for the real pool. +test('TestMu matches real-device OS versions exactly and lists what the device offers', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(testMuRealCatalog)), + ); + const realIos = { + ...testMuOptions, + deviceType: 'real' as const, + platform: 'ios' as const, + deviceName: 'iPhone 15', + }; + await assert.rejects( + createProvider().verifyConnection({ ...realIos, deviceName: 'iPhone 16', osVersion: '18.0' }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal((error as { code?: string }).code, 'INVALID_ARGS'); + assert.match( + error.message, + /TestMu AI real device "iPhone 16" with ios 18\.0 is not available/, + ); + assert.match(error.message, /iPhone 16 offers 18\.$/); + return true; + }, + ); + await assert.rejects( + createProvider().verifyConnection({ ...realIos, osVersion: '16' }), + /iPhone 15 offers 17, 18, 26/, + ); +}); + +test('TestMu fails typed when a catalog does not have the selected pool shape', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(testMuCatalog)), + ); + await assert.rejects( + createProvider().verifyConnection({ ...testMuOptions, deviceType: 'real' }), + (error: unknown) => + error instanceof Error && + (error as { code?: string }).code === 'COMMAND_FAILED' && + /real-device catalog response did not list devices/.test(error.message), + ); + vi.stubGlobal( + 'fetch', + vi.fn(async () => jsonResponse(testMuRealCatalog)), + ); + await assert.rejects( + createProvider().verifyConnection(testMuOptions), + /virtual-device catalog response did not list devices/, + ); +}); + +// The listing is keyed by pool: `emulator`/`simulator` hold virtual uploads, `android`/`ios` real +// ones, so an id must be looked up in the list of the pool the session will run on. +test('TestMu checks an lt:// id against the app list of the selected pool and platform', async () => { + const cases = [ + { deviceType: 'virtual', platform: 'android', deviceName: 'Pixel 8', listType: 'emulator' }, + { deviceType: 'virtual', platform: 'ios', deviceName: 'iPhone 16', listType: 'simulator' }, + { deviceType: 'real', platform: 'android', deviceName: 'Pixel 6', listType: 'android' }, + { deviceType: 'real', platform: 'ios', deviceName: 'iPhone 16', listType: 'ios' }, + ] as const; + for (const { deviceType, platform, deviceName, listType } of cases) { + const fetchMock = vi.fn(async (input) => { + const url = String(input); + if (url.includes('capability/generator')) { + return jsonResponse(deviceType === 'real' ? testMuRealCatalog : testMuCatalog); + } + return jsonResponse({ + data: new URL(url).searchParams.get('type') === listType ? [{ app_id: 'APP1' }] : [], + }); + }); + vi.stubGlobal('fetch', fetchMock); + const result = await createProvider().verifyConnection({ + ...testMuOptions, + deviceType, + platform, + deviceName, + osVersion: + deviceType === 'real' + ? platform === 'ios' + ? '18' + : '14' + : platform === 'ios' + ? '18.0' + : '14', + }); + assert.equal(result.app.status, 'verified', `${deviceType} ${platform}`); + assert.equal( + String(fetchMock.mock.calls[1]?.[0]), + `https://testmu.test/app/data?type=${listType}&level=user`, + ); + } +}); + +test('TestMu defers a real-device lt:// id missing from the real-device app list', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input) => + String(input).includes('capability/generator') + ? jsonResponse(testMuRealCatalog) + : jsonResponse({ data: [], metaData: { total: 0 } }), + ), + ); + const result = await createProvider().verifyConnection({ + ...testMuOptions, + deviceType: 'real', + deviceName: 'Pixel 6', + }); + assert.equal(result.app.status, 'configured'); + assert.match(String(result.app.message), /not found among your real-device uploads/); + assert.equal( + result.verificationMessage, + 'Credentials and real device verified; app availability is checked when the session is created.', + ); +}); diff --git a/packages/provider-testmu/src/testmu-connection-verification.ts b/packages/provider-testmu/src/testmu-connection-verification.ts new file mode 100644 index 0000000000..5f7bf3fe94 --- /dev/null +++ b/packages/provider-testmu/src/testmu-connection-verification.ts @@ -0,0 +1,214 @@ +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { asOptionalRecord } from '@agent-device/kernel/record'; +import { + appendUrlPath, + fetchProviderVerificationJson, +} from '@agent-device/provider-webdriver/plugin'; +import { + TESTMU_API_ENDPOINT, + TESTMU_APPS_ENDPOINT, + isTestMuAppReference, + testMuAppReferenceFromId, +} from './testmu.ts'; +import type { + ProviderConnectionVerification, + ProviderConnectionResource, + ProviderDeviceType, +} from '@agent-device/contracts/remote'; + +export type TestMuOptions = { + provider: 'testmu'; + username: string; + accessKey: string; + platform: 'android' | 'ios'; + deviceName: string; + osVersion: string; + app: string; + deviceType?: ProviderDeviceType; + apiEndpoint?: string | URL; + devicesEndpoint?: string | URL; + appsEndpoint?: string | URL; +}; + +type TestMuAuth = { username: string; accessKey: string }; + +/** `/app/data?type=` keys uploads by pool: real-device apps by platform, virtual ones by runtime. */ +const TESTMU_APP_LIST_TYPES: Record> = { + real: { android: 'android', ios: 'ios' }, + virtual: { android: 'emulator', ios: 'simulator' }, +}; + +/** + * Verifies a TestMu device selection without creating a session: the public capability catalog + * of the selected pool (real or virtual) confirms the device/OS pair exists, and the + * authenticated app listing confirms the credentials and, for an `lt://` reference, the upload. + */ +export async function verifyTestMuConnection( + options: TestMuOptions, + clientVersion: string, +): Promise { + const auth = { username: options.username, accessKey: options.accessKey }; + const deviceType = options.deviceType ?? 'virtual'; + const catalogUrl = options.devicesEndpoint + ? new URL(options.devicesEndpoint) + : appendUrlPath(options.apiEndpoint ?? TESTMU_API_ENDPOINT, 'capability/generator'); + catalogUrl.searchParams.set('isVirtualDevice', String(deviceType === 'virtual')); + const catalog = await fetchTestMuJson(catalogUrl, undefined, clientVersion); + const namedDevices = readTestMuCatalogDevices(catalog, options.platform, deviceType).filter( + (device) => device.name === options.deviceName, + ); + // Exact match on purpose: the hub rejects `18` for a virtual device the catalog lists as `18.0`, + // and real iOS devices are listed by major version only. + const matchedDevice = namedDevices.find((device) => + device.osVersions.includes(options.osVersion), + ); + if (!matchedDevice) { + const offered = [...new Set(namedDevices.flatMap((device) => device.osVersions))].sort( + (left, right) => left.localeCompare(right, undefined, { numeric: true }), + ); + throw new AppError( + 'INVALID_ARGS', + `TestMu AI ${deviceType} device "${options.deviceName}" with ${options.platform} ${options.osVersion} is not available${ + offered.length > 0 ? `; ${options.deviceName} offers ${offered.join(', ')}` : '' + }.`, + { + hint: `Choose an exact device name and OS version from the TestMu AI ${deviceType}-device capability generator.`, + deviceType, + ...(offered.length > 0 ? { availableOsVersions: offered } : {}), + }, + ); + } + + const app = await verifyTestMuApp(options, deviceType, auth, clientVersion); + return { + provider: 'testmu', + service: 'TestMu AI', + verificationMessage: + app.status === 'verified' + ? `Credentials, ${deviceType} device, and uploaded app verified.` + : `Credentials and ${deviceType} device verified; app availability is checked when the session is created.`, + device: { + status: 'verified', + name: matchedDevice.name, + platform: options.platform, + osVersion: options.osVersion, + }, + app, + }; +} + +async function verifyTestMuApp( + options: TestMuOptions, + deviceType: ProviderDeviceType, + auth: TestMuAuth, + clientVersion: string, +): Promise { + const { app } = options; + // The listing is authenticated, so it doubles as the credential check for every app kind. + const appsUrl = new URL(options.appsEndpoint ?? TESTMU_APPS_ENDPOINT); + appsUrl.searchParams.set('type', TESTMU_APP_LIST_TYPES[deviceType][options.platform]); + appsUrl.searchParams.set('level', 'user'); + const apps = await fetchTestMuJson(appsUrl, auth, clientVersion); + if (isTestMuAppReference(app)) { + const matched = readTestMuApps(apps).find((entry) => entry.reference === app); + if (!matched) { + return { + status: 'configured', + reference: app, + message: `App reference was not found among your ${deviceType}-device uploads; TestMu AI validates it when creating the session.`, + }; + } + return { status: 'verified', ...matched }; + } + if (/^https?:\/\//i.test(app)) { + return { + status: 'configured', + reference: app, + message: 'Public app URL configured; TestMu AI fetches it when creating the session.', + }; + } + return { + status: 'configured', + name: path.basename(app), + reference: app, + message: 'Local app artifact is ready and will be uploaded when creating the session.', + }; +} + +async function fetchTestMuJson( + endpoint: string | URL, + auth: TestMuAuth | undefined, + clientVersion: string, +): Promise { + return await fetchProviderVerificationJson(endpoint, { + clientVersion, + auth, + hints: { + service: 'TestMu AI', + unauthorizedHint: 'Check LT_USERNAME and LT_ACCESS_KEY.', + networkHint: + 'Check network access to mobile-api.lambdatest.com and manual-api.lambdatest.com, then retry connect.', + }, + }); +} + +/** + * The capability generator lists devices per platform as `brands.[]` of + * `{ name, osVersion: string[] }`: under `app.devices.` for the virtual pool + * (`isVirtualDevice=true`), and directly under `` for the real pool. + */ +function readTestMuCatalogDevices( + value: unknown, + platform: 'android' | 'ios', + deviceType: ProviderDeviceType, +): Array<{ name: string; osVersions: string[] }> { + const platformCatalog = + deviceType === 'real' + ? asOptionalRecord(asOptionalRecord(value)?.[platform]) + : asOptionalRecord( + asOptionalRecord(asOptionalRecord(asOptionalRecord(value)?.app)?.devices)?.[platform], + ); + const brandRecord = asOptionalRecord(platformCatalog?.brands); + if (!brandRecord) { + throw new AppError( + 'COMMAND_FAILED', + `TestMu AI ${deviceType}-device catalog response did not list devices for the platform.`, + { platform, deviceType }, + ); + } + return Object.values(brandRecord).flatMap((devices) => { + if (!Array.isArray(devices)) return []; + return devices.flatMap((entry) => { + const record = asOptionalRecord(entry); + if (!record || typeof record.name !== 'string' || !Array.isArray(record.osVersion)) return []; + const osVersions = record.osVersion.flatMap((osVersion) => + typeof osVersion === 'string' || typeof osVersion === 'number' ? [String(osVersion)] : [], + ); + return [{ name: record.name, osVersions }]; + }); + }); +} + +/** `/app/data` answers `{ data: [{ app_id, name, version, ... }], metaData }`. */ +function readTestMuApps( + value: unknown, +): Array<{ name?: string; reference: string; version?: string }> { + const record = asOptionalRecord(value); + const data = record?.data; + if (!Array.isArray(data)) { + throw new AppError('COMMAND_FAILED', 'TestMu AI app listing response was not a list.'); + } + return data.flatMap((entry) => { + const app = asOptionalRecord(entry); + if (!app || typeof app.app_id !== 'string') return []; + const reference = testMuAppReferenceFromId(app.app_id); + return [ + { + reference, + ...(typeof app.name === 'string' ? { name: app.name } : {}), + ...(typeof app.version === 'string' ? { version: app.version } : {}), + }, + ]; + }); +} diff --git a/packages/provider-testmu/src/testmu-device-features.test.ts b/packages/provider-testmu/src/testmu-device-features.test.ts new file mode 100644 index 0000000000..2faba2e427 --- /dev/null +++ b/packages/provider-testmu/src/testmu-device-features.test.ts @@ -0,0 +1,72 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; + +import { AppError } from '@agent-device/kernel/errors'; +import { + TESTMU_DEVICE_FEATURE_SPECS, + buildTestMuDeviceFeatureCapabilities, + readTestMuDeviceFeatureFields, + readTestMuDeviceType, +} from './testmu-device-features.ts'; + +test('every device-feature spec maps to exactly one lt:options key', () => { + const capabilities = TESTMU_DEVICE_FEATURE_SPECS.map((spec) => spec.capability); + assert.equal(new Set(capabilities).size, capabilities.length); +}); + +test('configured device features project onto TestMu capability keys', () => { + const capabilities = buildTestMuDeviceFeatureCapabilities({ + providerDeviceOrientation: 'landscape', + providerGeoLocation: 'US', + providerTimezone: 'UTC+05:30', + providerAppiumVersion: '2.16.2', + providerLanguage: 'fr', + providerLocale: 'fr_FR', + }); + assert.deepEqual(capabilities, { + deviceOrientation: 'LANDSCAPE', + geoLocation: 'US', + timezone: 'UTC+05:30', + appiumVersion: '2.16.2', + language: 'fr', + locale: 'fr_FR', + }); +}); + +test('unset and empty device features emit nothing', () => { + assert.deepEqual(buildTestMuDeviceFeatureCapabilities({}), {}); + assert.deepEqual(buildTestMuDeviceFeatureCapabilities({ providerGeoLocation: '' }), {}); +}); + +test('daemon flag bags are read through the same table with orientation validated', () => { + assert.deepEqual( + readTestMuDeviceFeatureFields({ + providerDeviceOrientation: 'portrait', + providerLocale: 'de_DE', + providerNetworkProfile: 'ignored-here', + providerGeoLocation: 7, + }), + { providerDeviceOrientation: 'portrait', providerLocale: 'de_DE' }, + ); + assert.throws( + () => readTestMuDeviceFeatureFields({ providerDeviceOrientation: 'sideways' }), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.details?.flag === '--provider-device-orientation', + ); +}); + +test('the device type defaults to the virtual pool and rejects unknown values', () => { + assert.equal(readTestMuDeviceType(undefined), 'virtual'); + assert.equal(readTestMuDeviceType({ providerDeviceType: '' }), 'virtual'); + assert.equal(readTestMuDeviceType({ providerDeviceType: 'virtual' }), 'virtual'); + assert.equal(readTestMuDeviceType({ providerDeviceType: 'real' }), 'real'); + assert.throws( + () => readTestMuDeviceType({ providerDeviceType: 'physical' }), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.details?.flag === '--provider-device-type', + ); +}); diff --git a/packages/provider-testmu/src/testmu-device-features.ts b/packages/provider-testmu/src/testmu-device-features.ts new file mode 100644 index 0000000000..da67971388 --- /dev/null +++ b/packages/provider-testmu/src/testmu-device-features.ts @@ -0,0 +1,98 @@ +import { + PROVIDER_DEVICE_TYPES, + type CloudProviderProfileFields, + type ProviderDeviceType, +} from '@agent-device/contracts/remote'; +import { AppError } from '@agent-device/kernel/errors'; +import { requireProviderDeviceOrientation } from '@agent-device/provider-webdriver/plugin'; + +/** + * TestMu "device feature" session capabilities: the hosted-provider flags TestMu can act on, + * projected onto their `lt:options` keys. The table is the contract; adding a capability means + * adding a row, not a branch. + */ +export type TestMuDeviceFeatureFields = Pick< + CloudProviderProfileFields, + | 'providerDeviceOrientation' + | 'providerGeoLocation' + | 'providerTimezone' + | 'providerAppiumVersion' + | 'providerLanguage' + | 'providerLocale' +>; + +type TestMuDeviceFeatureSpec = { + field: keyof TestMuDeviceFeatureFields; + /** Key emitted inside `lt:options`. */ + capability: string; + /** Canonical CLI flag, so an error can name a recovery action. */ + flag: string; + /** Projects the validated flag value onto what the hub expects. */ + project?: (value: string) => unknown; +}; + +export const TESTMU_DEVICE_FEATURE_SPECS: readonly TestMuDeviceFeatureSpec[] = [ + { + field: 'providerDeviceOrientation', + capability: 'deviceOrientation', + flag: '--provider-device-orientation', + // The hub matches the orientation enum case-sensitively in upper case. + project: (value) => value.toUpperCase(), + }, + { field: 'providerGeoLocation', capability: 'geoLocation', flag: '--provider-geo-location' }, + { field: 'providerTimezone', capability: 'timezone', flag: '--provider-timezone' }, + { + field: 'providerAppiumVersion', + capability: 'appiumVersion', + flag: '--provider-appium-version', + }, + { field: 'providerLanguage', capability: 'language', flag: '--provider-language' }, + { field: 'providerLocale', capability: 'locale', flag: '--provider-locale' }, +]; + +/** Builds the `lt:options` fragment for the configured device features. */ +export function buildTestMuDeviceFeatureCapabilities( + fields: TestMuDeviceFeatureFields, +): Record { + const capabilities: Record = {}; + for (const spec of TESTMU_DEVICE_FEATURE_SPECS) { + const value = fields[spec.field]; + if (value === undefined || value === '') continue; + capabilities[spec.capability] = spec.project ? spec.project(value) : value; + } + return capabilities; +} + +/** + * Reads device-feature fields off an untyped flag bag (a daemon request). Enum values are + * validated here rather than forwarded to the hub, where an unrecognized value is ignored. + */ +export function readTestMuDeviceFeatureFields( + flags: Record | undefined, +): TestMuDeviceFeatureFields { + const fields: TestMuDeviceFeatureFields = {}; + for (const spec of TESTMU_DEVICE_FEATURE_SPECS) { + const value = flags?.[spec.field]; + if (typeof value !== 'string' || value.length === 0) continue; + if (spec.field === 'providerDeviceOrientation') { + fields.providerDeviceOrientation = requireProviderDeviceOrientation(spec, value); + continue; + } + fields[spec.field] = value; + } + return fields; +} + +/** Reads the TestMu device pool off an untyped flag bag; an unset value keeps the virtual pool. */ +export function readTestMuDeviceType( + flags: Record | undefined, +): ProviderDeviceType { + const value = flags?.providerDeviceType; + if (value === undefined || value === '') return 'virtual'; + const match = PROVIDER_DEVICE_TYPES.find((deviceType) => deviceType === value); + if (match) return match; + throw new AppError('INVALID_ARGS', `Invalid --provider-device-type value: ${String(value)}.`, { + hint: `Use ${PROVIDER_DEVICE_TYPES.join('|')}.`, + flag: '--provider-device-type', + }); +} diff --git a/packages/provider-testmu/src/testmu.test.ts b/packages/provider-testmu/src/testmu.test.ts new file mode 100644 index 0000000000..5825c4ffe8 --- /dev/null +++ b/packages/provider-testmu/src/testmu.test.ts @@ -0,0 +1,521 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { + buildTestMuCapabilities, + createTestMuUploadApp, + listTestMuCloudArtifacts, + resolveTestMuAppReference, + uploadTestMuApp, + uploadTestMuAppFromUrl, +} from './testmu.ts'; +import { buildCloudWebDriverBaseCapabilities } from '@agent-device/provider-webdriver/plugin'; +import { mkdtempForTest } from './tmp-dir.fixtures.ts'; + +const realFetch = globalThis.fetch; +const auth = { clientVersion: '0.0.0-test', username: 'user', accessKey: 'key' }; + +afterEach(() => { + globalThis.fetch = realFetch; + vi.unstubAllGlobals(); +}); + +// `isRealMobile: false` is the one capability that routes to the emulator/simulator pool; a +// session without it lands on a real device and bills differently. +test('TestMu capabilities select the virtual-device pool and keep vendor keys in lt:options', () => { + const capabilities = buildTestMuCapabilities({ + platform: 'android', + deviceName: 'Pixel 8', + osVersion: '14', + app: 'lt://APP1', + projectName: 'agent-device', + buildName: 'run-1', + sessionName: 'lease-1', + deviceFeatures: { geoLocation: 'US' }, + configured: buildCloudWebDriverBaseCapabilities('android', 'Pixel 8'), + }); + + assert.deepEqual(capabilities, { + platformName: 'Android', + 'appium:deviceName': 'Pixel 8', + 'appium:platformVersion': '14', + 'appium:app': 'lt://APP1', + 'lt:options': { + isRealMobile: false, + w3c: true, + platformName: 'Android', + deviceName: 'Pixel 8', + platformVersion: '14', + app: 'lt://APP1', + project: 'agent-device', + build: 'run-1', + name: 'lease-1', + video: true, + devicelog: true, + geoLocation: 'US', + }, + }); + for (const key of Object.keys(capabilities)) { + assert.ok( + key === 'platformName' || key.startsWith('appium:') || key === 'lt:options', + `legacy top-level key ${key} would make the hub ignore lt:options`, + ); + } +}); + +// Unpinned, TestMu AI starts its own default Appium server for the device, as BrowserStack does. +test('a configured lt:options merges per key and only a pinned Appium version is sent', () => { + const capabilities = buildTestMuCapabilities({ + platform: 'ios', + deviceName: 'iPhone 16', + osVersion: '18.0', + buildName: 'run-1', + sessionName: 'lease-1', + deviceFeatures: { appiumVersion: '2.16.2' }, + configured: { 'lt:options': { tunnel: true } }, + }); + const ltOptions = capabilities['lt:options'] as Record; + assert.equal(ltOptions.appiumVersion, '2.16.2'); + assert.equal(ltOptions.tunnel, true); + assert.equal(ltOptions.build, 'run-1'); + assert.equal(ltOptions.platformName, 'iOS'); + assert.equal('appium:app' in capabilities, false); + + const unpinned = buildTestMuCapabilities({ + platform: 'ios', + deviceName: 'iPhone 16', + osVersion: '18.0', + buildName: 'run-1', + sessionName: 'lease-1', + }); + assert.equal('appiumVersion' in (unpinned['lt:options'] as Record), false); +}); + +test('a configured lt:options cannot turn off the W3C dialect', () => { + const capabilities = buildTestMuCapabilities({ + platform: 'android', + deviceName: 'Pixel 8', + osVersion: '14', + buildName: 'run-1', + sessionName: 'lease-1', + configured: { 'lt:options': { w3c: false, tunnel: true } }, + }); + const ltOptions = capabilities['lt:options'] as Record; + assert.equal(ltOptions.w3c, true); + assert.equal(ltOptions.tunnel, true); +}); + +// A configured `isRealMobile` would silently move the session to the other pool, which bills +// differently. +test('the device type selects the TestMu pool and a configured lt:options cannot override it', () => { + const base = { + platform: 'ios' as const, + deviceName: 'iPhone 16', + osVersion: '18', + buildName: 'run-1', + sessionName: 'lease-1', + }; + const real = buildTestMuCapabilities({ + ...base, + deviceType: 'real', + configured: { 'lt:options': { isRealMobile: false, tunnel: true } }, + }); + const realOptions = real['lt:options'] as Record; + assert.equal(realOptions.isRealMobile, true); + assert.equal(realOptions.tunnel, true); + assert.equal(realOptions.platformVersion, '18'); + + const virtual = buildTestMuCapabilities({ + ...base, + deviceType: 'virtual', + configured: { 'lt:options': { isRealMobile: true } }, + }); + assert.equal((virtual['lt:options'] as Record).isRealMobile, false); + + const unset = buildTestMuCapabilities(base); + assert.equal((unset['lt:options'] as Record).isRealMobile, false); +}); + +test('TestMu uploads go to the upload API of the selected device pool', async () => { + const tempDir = await mkdtempForTest('agent-device-testmu-upload-pool-'); + const appPath = path.join(tempDir, 'MyApp.ipa'); + const endpoints: string[] = []; + try { + await fs.writeFile(appPath, 'placeholder'); + globalThis.fetch = async (input) => { + endpoints.push(String(input)); + return jsonResponse({ app_url: 'lt://APP1' }); + }; + await uploadTestMuApp(appPath, { ...auth, deviceType: 'real' }); + await uploadTestMuAppFromUrl('https://example.test/App.apk', { ...auth, deviceType: 'real' }); + await uploadTestMuApp(appPath, auth); + await uploadTestMuApp(appPath, { ...auth, deviceType: 'virtual' }); + await uploadTestMuApp(appPath, { + ...auth, + deviceType: 'real', + endpoint: 'https://upload.test/real', + }); + assert.deepEqual(endpoints, [ + 'https://manual-api.lambdatest.com/app/upload/realDevice', + 'https://manual-api.lambdatest.com/app/upload/realDevice', + 'https://manual-api.lambdatest.com/app/upload/virtualDevice', + 'https://manual-api.lambdatest.com/app/upload/virtualDevice', + 'https://upload.test/real', + ]); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('a real-device upload of an .app directory asks for a signed .ipa', async () => { + const tempDir = await mkdtempForTest('agent-device-testmu-real-app-dir-'); + const appPath = path.join(tempDir, 'Demo.app'); + try { + await fs.mkdir(appPath); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + await assert.rejects( + uploadTestMuApp(appPath, { ...auth, deviceType: 'real' }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.match(String(error.details?.hint), /signed \.ipa/); + return true; + }, + ); + assert.equal(fetchMock.mock.calls.length, 0); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('TestMu upload reads the lt:// reference and aborts while the request is in flight', async () => { + const tempDir = await mkdtempForTest('agent-device-testmu-upload-'); + const appPath = path.join(tempDir, 'App.apk'); + const controller = new AbortController(); + const abortReason = new Error('request cancelled during TestMu AI upload'); + try { + await fs.writeFile(appPath, 'placeholder'); + let started: () => void = () => {}; + const fetchStarted = new Promise((resolve) => { + started = resolve; + }); + globalThis.fetch = async (_input, init) => + await new Promise((_resolve, reject) => { + assert.equal(init?.signal, controller.signal); + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + started(); + }); + + const pending = uploadTestMuApp(appPath, auth, controller.signal); + await fetchStarted; + controller.abort(abortReason); + await assert.rejects(pending, (error: unknown) => error === abortReason); + + globalThis.fetch = async (_input, init) => { + const body = init?.body as FormData; + assert.ok(body.get('appFile') instanceof Blob); + assert.equal(body.get('name'), 'App'); + return jsonResponse({ app_id: 'APP123', name: 'App' }); + }; + assert.equal(await uploadTestMuApp(appPath, auth), 'lt://APP123'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +// iOS simulator builds are `.app` directories; the upload API only takes a file. +test('TestMu upload rejects an unzipped .app bundle before calling the upload API', async () => { + const tempDir = await mkdtempForTest('agent-device-testmu-app-dir-'); + const appPath = path.join(tempDir, 'Demo.app'); + try { + await fs.mkdir(appPath); + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + await assert.rejects(uploadTestMuApp(appPath, auth), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.match(String(error.details?.hint), /[Zz]ip the \.app bundle/); + return true; + }); + assert.equal(fetchMock.mock.calls.length, 0); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +// The install adapter reaches the upload without the resolver's existence check in front of it. +test('TestMu upload of a missing path fails typed, not with a bare ENOENT', async () => { + const fetchMock = vi.fn(); + globalThis.fetch = fetchMock; + await assert.rejects(uploadTestMuApp('/nonexistent/App.apk', auth), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.message, 'TestMu AI can only upload an app file: /nonexistent/App.apk'); + assert.ok(error.details?.hint); + return true; + }); + assert.equal(fetchMock.mock.calls.length, 0); +}); + +test('the install adapter uploads the local build and launches the hinted app id', async () => { + const tempDir = await mkdtempForTest('agent-device-testmu-install-'); + const appPath = path.join(tempDir, 'Demo.apk'); + try { + await fs.writeFile(appPath, 'placeholder'); + globalThis.fetch = async () => jsonResponse({ app_url: 'lt://APP77' }); + const uploadApp = createTestMuUploadApp(auth); + const result = await uploadApp({ + provider: 'testmu', + lease: {} as never, + device: {} as never, + app: 'com.example.demo', + appPath, + options: { packageNameHint: 'com.example.demo' }, + }); + assert.deepEqual(result, { + appReference: 'lt://APP77', + bundleId: undefined, + packageName: 'com.example.demo', + launchTarget: 'com.example.demo', + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('TestMu passes lt:// ids through and has the upload API fetch a public URL', async () => { + const forms: FormData[] = []; + globalThis.fetch = async (_input, init) => { + forms.push(init?.body as FormData); + return jsonResponse({ app_id: 'APP9' }); + }; + assert.equal(await resolveTestMuAppReference('lt://APP1', auth), 'lt://APP1'); + assert.equal(await resolveTestMuAppReference('LT://APP1', auth), 'lt://APP1'); + await assert.rejects( + resolveTestMuAppReference('lt://', auth), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /--provider-app lt:\/\/ is not an lt:\/\/ app id/.test(error.message), + ); + assert.equal(forms.length, 0); + assert.equal( + await resolveTestMuAppReference('https://builds.example/App.apk', auth), + 'lt://APP9', + ); + assert.equal(forms[0]?.get('url'), 'https://builds.example/App.apk'); + await assert.rejects( + resolveTestMuAppReference('missing.apk', { ...auth, cwd: '/nonexistent' }), + /must be an lt:\/\/ app id, URL, or existing local app path/, + ); +}); + +test('TestMu upload accepts only an lt:// reference or a valid app id from the response', async () => { + const cases: Array<[unknown, string | undefined]> = [ + [{ app_url: 'lt://APP6' }, 'lt://APP6'], + [{ app_url: 'https://cdn.example/app.apk', app_id: 'APP5' }, 'lt://APP5'], + [{ app_id: 'lt://APP7' }, 'lt://APP7'], + [{ app_id: 'LT://APP7' }, 'lt://APP7'], + [{ app_url: 'Lt://APP6' }, 'lt://APP6'], + [{ app_url: 'https://cdn.example/app.apk' }, undefined], + [{ app_url: 'lt://' }, undefined], + [{ app_id: 'bs://APP8' }, undefined], + ]; + for (const [body, expected] of cases) { + globalThis.fetch = async () => jsonResponse(body); + const pending = uploadTestMuAppFromUrl('https://builds.example/App.apk', auth); + if (expected) { + assert.equal(await pending, expected); + continue; + } + await assert.rejects(pending, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.deepEqual(error.details?.response, body); + return true; + }); + } +}); + +test('TestMu URL upload hands the URL to the upload API and surfaces a failed upload', async () => { + globalThis.fetch = async (_input, init) => { + const body = init?.body as FormData; + assert.equal(body.get('url'), 'https://example.test/builds/App.apk'); + assert.equal(body.get('storage'), 'url'); + assert.equal(body.get('name'), 'App.apk'); + assert.equal(body.get('appFile'), null); + return jsonResponse({ app_url: 'lt://APP9' }); + }; + assert.equal( + await uploadTestMuAppFromUrl('https://example.test/builds/App.apk', auth), + 'lt://APP9', + ); + + globalThis.fetch = async () => jsonResponse({ message: 'invalid app' }, 400); + await assert.rejects( + uploadTestMuAppFromUrl('https://example.test/builds/App.apk', auth), + (error: unknown) => + error instanceof AppError && error.code === 'COMMAND_FAILED' && error.details?.status === 400, + ); +}); + +test('TestMu artifacts come from the jsend session payload and stay pending until a URL exists', async () => { + const calls: string[] = []; + globalThis.fetch = async (input, init) => { + calls.push(String(input)); + const headers = (init?.headers ?? {}) as Record; + assert.match(String(headers.Authorization), /^Basic /); + return jsonResponse({ + status: 'success', + data: { + test_id: 'SESSION1', + video_url: ' https://cdn.test/video.mp4\n', + appium_logs_url: 'https://api.test/sessions/SESSION1/log/appium', + device_logs_url: '', + network_logs_url: ' ', + }, + }); + }; + const result = await listTestMuCloudArtifacts('testmu', 'SESSION1', { + ...auth, + endpoint: 'https://api.test/mobile-automation/api/v1/', + }); + await listTestMuCloudArtifacts('testmu', 'SESSION 2', { + ...auth, + endpoint: 'https://api.test/mobile-automation/api/v1?region=eu', + }); + assert.deepEqual(calls, [ + 'https://api.test/mobile-automation/api/v1/sessions/SESSION1', + 'https://api.test/mobile-automation/api/v1/sessions/SESSION%202?region=eu', + ]); + assert.equal(result?.status, 'ready'); + assert.deepEqual( + result?.cloudArtifacts.map((artifact) => [artifact.kind, artifact.url]), + [ + ['video', 'https://cdn.test/video.mp4'], + ['appium-log', 'https://api.test/sessions/SESSION1/log/appium'], + ['provider-session', 'https://appautomation.lambdatest.com/test?testID=SESSION1'], + ], + ); + + globalThis.fetch = async () => jsonResponse({ status: 'success', data: { test_id: 'SESSION1' } }); + const pending = await listTestMuCloudArtifacts('testmu', 'SESSION1', auth); + assert.equal(pending?.status, 'pending'); + assert.deepEqual(pending?.cloudArtifacts, []); +}); + +// Virtual-device session details carry the device log as `console_logs_url`. +test('TestMu reads the console log as the device log and falls back to device_logs_url', async () => { + globalThis.fetch = async () => + jsonResponse({ + status: 'success', + data: { + console_logs_url: 'https://api.test/sessions/SESSION1/log/console', + device_logs_url: 'https://api.test/sessions/SESSION1/log/device', + }, + }); + const consoleLog = await listTestMuCloudArtifacts('testmu', 'SESSION1', auth); + assert.deepEqual( + consoleLog?.cloudArtifacts + .filter((artifact) => artifact.kind === 'device-log') + .map((artifact) => artifact.url), + ['https://api.test/sessions/SESSION1/log/console'], + ); + + for (const consoleLogsUrl of [undefined, ' ']) { + globalThis.fetch = async () => + jsonResponse({ + status: 'success', + data: { + console_logs_url: consoleLogsUrl, + device_logs_url: 'https://api.test/sessions/SESSION1/log/device', + }, + }); + const deviceLog = await listTestMuCloudArtifacts('testmu', 'SESSION1', auth); + assert.deepEqual( + deviceLog?.cloudArtifacts + .filter((artifact) => artifact.kind === 'device-log') + .map((artifact) => artifact.url), + ['https://api.test/sessions/SESSION1/log/device'], + ); + } +}); + +test('TestMu session details read as pending on 404 and fail typed on a body that is not JSON', async () => { + let signal: AbortSignal | undefined; + globalThis.fetch = async (_input, init) => { + signal = init?.signal ?? undefined; + return jsonResponse({ status: 'fail', message: 'session not found' }, 404); + }; + const notFound = await listTestMuCloudArtifacts('testmu', 'SESSION1', auth); + assert.equal(notFound?.status, 'pending'); + assert.deepEqual(notFound?.cloudArtifacts, []); + assert.ok(signal instanceof AbortSignal, 'session details lookup should carry a timeout'); + + globalThis.fetch = async () => new Response('Bad Gateway', { status: 502 }); + await assert.rejects( + listTestMuCloudArtifacts('testmu', 'SESSION1', auth), + (error: unknown) => + error instanceof AppError && error.code === 'COMMAND_FAILED' && error.details?.status === 502, + ); + + globalThis.fetch = async () => new Response('', { status: 200 }); + await assert.rejects( + listTestMuCloudArtifacts('testmu', 'SESSION1', auth), + (error: unknown) => + error instanceof AppError && error.code === 'COMMAND_FAILED' && error.details?.status === 200, + ); +}); + +test('TestMu session details require the jsend data envelope', async () => { + globalThis.fetch = async () => jsonResponse({ video_url: 'https://cdn.test/video.mp4' }); + await assert.rejects( + listTestMuCloudArtifacts('testmu', 'SESSION1', auth), + (error: unknown) => error instanceof AppError && error.code === 'COMMAND_FAILED', + ); +}); + +test('TestMu upload reports the HTTP status when the response is not JSON', async () => { + globalThis.fetch = async () => new Response('Bad Gateway', { status: 502 }); + await assert.rejects( + uploadTestMuAppFromUrl('https://example.test/builds/App.apk', auth), + (error: unknown) => + error instanceof AppError && error.code === 'COMMAND_FAILED' && error.details?.status === 502, + ); + + globalThis.fetch = async () => new Response('', { status: 200 }); + await assert.rejects( + uploadTestMuAppFromUrl('https://example.test/builds/App.apk', auth), + (error: unknown) => + error instanceof AppError && error.code === 'COMMAND_FAILED' && error.details?.status === 200, + ); +}); + +test('TestMu session details lookup types a timeout and a network failure', async () => { + const timeout = new DOMException('The operation was aborted due to timeout', 'TimeoutError'); + globalThis.fetch = async () => { + throw timeout; + }; + await assert.rejects(listTestMuCloudArtifacts('testmu', 'SESSION1', auth), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.match(error.message, /TestMu AI session details lookup failed/); + assert.match(String(error.details?.hint), /retry/); + assert.equal(error.cause, timeout); + return true; + }); + + globalThis.fetch = async () => { + throw new TypeError('fetch failed'); + }; + await assert.rejects( + listTestMuCloudArtifacts('testmu', 'SESSION1', auth), + (error: unknown) => error instanceof AppError && error.code === 'COMMAND_FAILED', + ); +}); + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { status }); +} diff --git a/packages/provider-testmu/src/testmu.ts b/packages/provider-testmu/src/testmu.ts new file mode 100644 index 0000000000..8ea6b12e87 --- /dev/null +++ b/packages/provider-testmu/src/testmu.ts @@ -0,0 +1,299 @@ +import { + type CloudWebDriverPlatform, + type CloudWebDriverUploadApp, + cloudArtifactsReadyOrPending, + urlArtifactFromDetails, + appendUrlPath, + appFileUploadForm, + createHubUploadApp, + fetchProviderSessionDetails, + postHubAppUpload, + resolveHubAppReference, +} from '@agent-device/provider-webdriver/plugin'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { CloudArtifact, CloudArtifactsResult } from '@agent-device/contracts/observability'; +import type { ProviderDeviceType } from '@agent-device/contracts/remote'; +import { AppError } from '@agent-device/kernel/errors'; +import { asOptionalRecord } from '@agent-device/kernel/record'; +import { canonicalTestMuAppReference, isTestMuAppReference } from './providers.ts'; + +/** + * TestMu session, upload, and artifact mechanics. `isRealMobile` in `lt:options` is what routes a + * session to the real or virtual device pool, and the hostnames still carry the lambdatest.com + * brand. + */ +const TESTMU_APP_UPLOAD_ENDPOINTS: Record = { + real: 'https://manual-api.lambdatest.com/app/upload/realDevice', + virtual: 'https://manual-api.lambdatest.com/app/upload/virtualDevice', +}; +export const TESTMU_APPS_ENDPOINT = 'https://manual-api.lambdatest.com/app/data'; +export const TESTMU_API_ENDPOINT = 'https://mobile-api.lambdatest.com/mobile-automation/api/v1'; +export { isTestMuAppReference }; + +const TESTMU_DASHBOARD_TEST_URL = 'https://appautomation.lambdatest.com/test?testID='; + +export type TestMuCapabilitiesOptions = { + platform: CloudWebDriverPlatform; + /** Defaults to `virtual`. */ + deviceType?: ProviderDeviceType; + deviceName: string; + osVersion: string; + app?: string; + projectName?: string; + buildName: string; + sessionName: string; + /** Vendor device-feature capabilities, already projected onto their `lt:options` keys. */ + deviceFeatures?: Record; + configured?: Record; +}; + +export type TestMuAuth = { + username: string; + accessKey: string; +}; + +export type TestMuSessionDetailsOptions = TestMuAuth & { + clientVersion: string; + endpoint?: string | URL; +}; + +export async function listTestMuCloudArtifacts( + provider: string, + providerSessionId: string | undefined, + options: TestMuSessionDetailsOptions, +): Promise { + if (!providerSessionId) return undefined; + const details = await fetchTestMuSessionDetails(providerSessionId, options); + const artifacts = mapTestMuArtifacts(provider, providerSessionId, details); + return cloudArtifactsReadyOrPending({ + provider, + providerSessionId, + artifacts, + pendingMessage: 'TestMu AI artifacts are not ready yet.', + }); +} + +export type TestMuUploadOptions = TestMuAuth & { + clientVersion: string; + /** Selects the pool's upload API when no endpoint override is given; defaults to `virtual`. */ + deviceType?: ProviderDeviceType; + endpoint?: string | URL; +}; + +/** Uploads a local `.apk`, `.aab`, `.ipa`, or zipped simulator `.app` and returns its `lt://` reference. */ +export async function uploadTestMuApp( + appPath: string, + options: TestMuUploadOptions, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + // A missing path is the same caller mistake as a directory, so both get the typed refusal. + const stat = await fs.stat(appPath).catch(() => undefined); + if (!stat?.isFile()) { + throw new AppError('INVALID_ARGS', `TestMu AI can only upload an app file: ${appPath}`, { + appPath, + hint: + options.deviceType === 'real' + ? 'Real iOS devices install a signed .ipa; pass the .ipa file.' + : 'Zip the .app bundle of an iOS simulator build and pass the .zip.', + }); + } + const form = await appFileUploadForm(appPath, 'appFile', { + provider: 'testmu', + service: 'TestMu AI', + }); + form.set('name', path.parse(appPath).name); + return await postTestMuUpload(form, options, signal); +} + +/** Has TestMu fetch a public app URL itself, returning its `lt://` reference. */ +export async function uploadTestMuAppFromUrl( + url: string, + options: TestMuUploadOptions, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const form = new FormData(); + form.set('url', url); + form.set('storage', 'url'); + form.set('name', path.basename(new URL(url).pathname) || 'app'); + return await postTestMuUpload(form, options, signal); +} + +async function postTestMuUpload( + form: FormData, + options: TestMuUploadOptions, + signal?: AbortSignal, +): Promise { + return await postHubAppUpload( + form, + { + service: 'TestMu AI', + endpoint: options.endpoint ?? TESTMU_APP_UPLOAD_ENDPOINTS[options.deviceType ?? 'virtual'], + clientVersion: options.clientVersion, + auth: options, + readAppReference: readTestMuAppReference, + }, + signal, + ); +} + +export function createTestMuUploadApp(options: TestMuUploadOptions): CloudWebDriverUploadApp { + return createHubUploadApp( + async (appPath, signal) => await uploadTestMuApp(appPath, options, signal), + ); +} + +/** The hub only accepts `lt://` references, so a public URL is handed to the upload API to fetch. */ +export async function resolveTestMuAppReference( + app: string, + options: TestMuUploadOptions & { cwd?: string; signal?: AbortSignal }, +): Promise { + const reference = parseTestMuAppReference(app); + if (reference !== undefined) return reference; + if (/^https?:\/\//i.test(app)) { + return await uploadTestMuAppFromUrl(app, options, options.signal); + } + return await resolveHubAppReference({ + service: 'TestMu AI', + app, + cwd: options.cwd, + referenceLabel: 'an lt:// app id', + parseReference: parseTestMuAppReference, + uploadFile: async (appPath, signal) => await uploadTestMuApp(appPath, options, signal), + signal: options.signal, + }); +} + +const TESTMU_APP_SCHEME = 'lt://'; + +/** The canonical `lt://` reference for `app`, or undefined when `app` does not use the scheme. */ +function parseTestMuAppReference(app: string): string | undefined { + const reference = canonicalTestMuAppReference(app); + if (!reference.startsWith(TESTMU_APP_SCHEME)) return undefined; + if (isTestMuAppReference(reference)) return reference; + throw new AppError('INVALID_ARGS', `TestMu AI --provider-app ${app} is not an lt:// app id.`, { + providerApp: app, + }); +} + +/** + * Builds the W3C `alwaysMatch` capabilities for a TestMu session. + * + * Standard Appium keys stay `appium:`-prefixed at the top level; everything TestMu-specific lives + * in `lt:options`. `isRealMobile` selects a real device or an emulator/simulator, and `w3c: true` + * keeps the hub on the W3C dialect agent-device speaks. `appiumVersion` is sent only when the caller + * pins one; otherwise TestMu AI starts its default server for the device. + */ +export function buildTestMuCapabilities( + options: TestMuCapabilitiesOptions, +): Record { + const { 'lt:options': configuredLtOptions, ...configured } = options.configured ?? {}; + const deviceFeatures = options.deviceFeatures ?? {}; + return { + 'appium:deviceName': options.deviceName, + 'appium:platformVersion': options.osVersion, + ...(options.app ? { 'appium:app': options.app } : {}), + ...configured, + // Merged per key, never assigned: a configured `lt:options` must not drop the labels below. + 'lt:options': { + platformName: options.platform === 'ios' ? 'iOS' : 'Android', + deviceName: options.deviceName, + platformVersion: options.osVersion, + ...(options.app ? { app: options.app } : {}), + ...(options.projectName ? { project: options.projectName } : {}), + build: options.buildName, + name: options.sessionName, + video: true, + devicelog: true, + ...deviceFeatures, + ...(asOptionalRecord(configuredLtOptions) ?? {}), + // A configured value cannot switch the device pool or drop the W3C dialect agent-device speaks. + isRealMobile: options.deviceType === 'real', + w3c: true, + }, + }; +} + +/** The upload and app-list APIs answer with a bare app id or an `lt://` reference. */ +export function testMuAppReferenceFromId(id: string): string { + const reference = canonicalTestMuAppReference(id); + return reference.startsWith(TESTMU_APP_SCHEME) ? reference : `${TESTMU_APP_SCHEME}${id}`; +} + +async function fetchTestMuSessionDetails( + sessionId: string, + options: TestMuSessionDetailsOptions, +): Promise> { + const endpoint = appendUrlPath( + options.endpoint ?? TESTMU_API_ENDPOINT, + `sessions/${encodeURIComponent(sessionId)}`, + ); + let json: Record; + try { + json = await fetchProviderSessionDetails(endpoint, { + clientVersion: options.clientVersion, + auth: options, + service: 'TestMu AI', + }); + } catch (error) { + // Details are published a little after the session ends; until then the API answers 404. + if (error instanceof AppError && error.details?.status === 404) return {}; + throw error; + } + // The API wraps the session in a jsend envelope: `{ status, data: {...}, message }`. + const details = asOptionalRecord(json.data); + if (!details) { + throw new AppError('COMMAND_FAILED', 'TestMu AI session details response had no data.', { + response: json, + }); + } + return details; +} + +function mapTestMuArtifacts( + provider: string, + providerSessionId: string, + details: Record, +): CloudArtifact[] { + // Virtual-device sessions report the device log as `console_logs_url`. + const deviceLogField = + typeof details.console_logs_url === 'string' && details.console_logs_url.trim().length > 0 + ? 'console_logs_url' + : 'device_logs_url'; + const fromDetails = ( + [ + ['video_url', 'video', 'Session video'], + ['appium_logs_url', 'appium-log', 'Appium logs'], + [deviceLogField, 'device-log', 'Device logs'], + ['network_logs_url', 'raw', 'Network logs'], + ['command_logs_url', 'automation-log', 'Command logs'], + ['screenshot_url', 'raw', 'Screenshots'], + ] as const + ).map(([field, kind, name]) => + urlArtifactFromDetails(provider, providerSessionId, details, field, kind, name), + ); + const dashboard: CloudArtifact = { + provider, + providerSessionId, + kind: 'provider-session', + name: 'TestMu AI dashboard', + url: `${TESTMU_DASHBOARD_TEST_URL}${encodeURIComponent(providerSessionId)}`, + availability: 'ready', + }; + const ready = fromDetails.filter((artifact): artifact is CloudArtifact => artifact !== undefined); + // The dashboard link alone does not mean the session finished uploading; keep "pending" until + // the API reports at least one artifact URL. + return ready.length > 0 ? [...ready, dashboard] : []; +} + +/** The upload answers with `app_url` (`lt://…`) and/or a bare `app_id`; anything else is a failed upload. */ +function readTestMuAppReference(value: unknown): string | undefined { + const { app_url: appUrl, app_id: appId } = asOptionalRecord(value) ?? {}; + const url = typeof appUrl === 'string' ? canonicalTestMuAppReference(appUrl) : undefined; + if (url && isTestMuAppReference(url)) return url; + if (typeof appId !== 'string') return undefined; + const reference = testMuAppReferenceFromId(appId); + return isTestMuAppReference(reference) ? reference : undefined; +} diff --git a/packages/provider-testmu/src/tmp-dir.fixtures.ts b/packages/provider-testmu/src/tmp-dir.fixtures.ts new file mode 100644 index 0000000000..dbc5c2724c --- /dev/null +++ b/packages/provider-testmu/src/tmp-dir.fixtures.ts @@ -0,0 +1,21 @@ +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * Creates a fresh scratch directory for one test. Cleanup is automatic: the + * unit suite redirects TMPDIR to a per-run directory (scripts/vitest-tmpdir-global-setup.ts) + * that gets removed in one recursive rm after every worker finishes, so + * individual tests never need their own afterEach/afterAll for this. + */ +// fallow-ignore-next-line code-duplication +export async function mkdtempForTest(prefix: string): Promise { + return fsPromises.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +/** Sync counterpart of {@link mkdtempForTest}, for setup code that can't await. */ +// fallow-ignore-next-line code-duplication +export function mkdtempForTestSync(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} diff --git a/packages/provider-testmu/test/package-smoke.mjs b/packages/provider-testmu/test/package-smoke.mjs new file mode 100644 index 0000000000..fb29daa306 --- /dev/null +++ b/packages/provider-testmu/test/package-smoke.mjs @@ -0,0 +1,33 @@ +export const args = [ + '--platform', + 'android', + '--device', + 'Pixel 8', + '--provider-os-version', + '14', + '--provider-app', + 'lt://APP1', +]; +export function environment(endpoint) { + return { LT_USERNAME: 'user', LT_ACCESS_KEY: 'key', TESTMU_API_ENDPOINT: endpoint }; +} +export function respond(url, rejectCredentials) { + if (url.includes('capability/generator')) + return { + body: { + app: { + devices: { android: { brands: { Google: [{ name: 'Pixel 8', osVersion: ['14'] }] } } }, + }, + }, + }; + return rejectCredentials + ? { status: 401, body: { error: 'unauthorized' } } + : { + body: { + data: [{ app_id: 'APP1', name: 'app.apk', type: 'android' }], + metaData: { total: 1 }, + }, + }; +} + +export const fetchRedirects = ['https://manual-api.lambdatest.com']; diff --git a/packages/provider-testmu/tsconfig.json b/packages/provider-testmu/tsconfig.json new file mode 100644 index 0000000000..aa0881ab1c --- /dev/null +++ b/packages/provider-testmu/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src", + "paths": { + "agent-device/plugins": ["./node_modules/agent-device/src/sdk/plugins.ts"], + "agent-device/plugins/webdriver": ["./node_modules/agent-device/src/sdk/plugin-webdriver.ts"] + } + }, + "include": ["src"] +} diff --git a/packages/provider-testmu/tsdown.config.ts b/packages/provider-testmu/tsdown.config.ts new file mode 100644 index 0000000000..5aeb12099a --- /dev/null +++ b/packages/provider-testmu/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { plugin: 'src/plugin.ts' }, + outDir: 'dist', + format: 'esm', + platform: 'node', + target: 'es2022', + minify: true, + dts: false, + hash: false, + deps: { alwaysBundle: [/^@agent-device\//] }, + inputOptions: { + onLog(level, log, handler) { + if (log.code === 'UNRESOLVED_IMPORT') throw new Error(log.message); + handler(level, log); + }, + }, +}); diff --git a/packages/provider-webdriver/package.json b/packages/provider-webdriver/package.json index d4d56b8d0e..1d445ccdf6 100644 --- a/packages/provider-webdriver/package.json +++ b/packages/provider-webdriver/package.json @@ -19,6 +19,10 @@ "./providers": { "types": "./src/providers.ts", "default": "./src/providers.ts" + }, + "./plugin": { + "types": "./src/plugin.ts", + "default": "./src/plugin.ts" } } } diff --git a/packages/provider-webdriver/src/capabilities.ts b/packages/provider-webdriver/src/capabilities.ts index 04293fd628..f0e5d456d1 100644 --- a/packages/provider-webdriver/src/capabilities.ts +++ b/packages/provider-webdriver/src/capabilities.ts @@ -171,3 +171,15 @@ function applyCapabilityOverrides( } return next; } + +export function buildCloudWebDriverBaseCapabilities( + platform: CloudWebDriverPlatform, + deviceName: string, + configured: Record = {}, +): Record { + return { + platformName: platform === 'ios' ? 'iOS' : 'Android', + 'appium:deviceName': deviceName, + ...configured, + }; +} diff --git a/packages/provider-webdriver/src/connection-verification.ts b/packages/provider-webdriver/src/connection-verification.ts index 0ef8f5ce97..9cd048b951 100644 --- a/packages/provider-webdriver/src/connection-verification.ts +++ b/packages/provider-webdriver/src/connection-verification.ts @@ -17,18 +17,20 @@ export type CloudWebDriverConnectionVerification = project: { name?: string; reference: string }; }); +/** Credentials plus the exact device, OS, and app a hosted Appium hub session is created with. */ +type HubSelectionVerificationOptions = { + username: string; + accessKey: string; + platform: 'android' | 'ios'; + deviceName: string; + osVersion: string; + app: string; + devicesEndpoint?: string | URL; + appsEndpoint?: string | URL; +}; + export type CloudWebDriverConnectionVerificationOptions = - | { - provider: 'browserstack'; - username: string; - accessKey: string; - platform: 'android' | 'ios'; - deviceName: string; - osVersion: string; - app: string; - devicesEndpoint?: string | URL; - appsEndpoint?: string | URL; - } + | (HubSelectionVerificationOptions & { provider: 'browserstack' }) | { provider: 'aws-device-farm'; platform: 'android' | 'ios'; @@ -42,7 +44,10 @@ export async function verifyCloudWebDriverConnection( options: CloudWebDriverConnectionVerificationOptions, dependencies: ProviderWebDriverDependencies, ): Promise { - return options.provider === 'browserstack' - ? await verifyBrowserStackConnection(options, dependencies.clientVersion) - : await verifyAwsDeviceFarmConnection(options, dependencies.runHostCommand); + switch (options.provider) { + case 'browserstack': + return await verifyBrowserStackConnection(options, dependencies.clientVersion); + case 'aws-device-farm': + return await verifyAwsDeviceFarmConnection(options, dependencies.runHostCommand); + } } diff --git a/packages/provider-webdriver/src/plugin.ts b/packages/provider-webdriver/src/plugin.ts new file mode 100644 index 0000000000..8c6dac887b --- /dev/null +++ b/packages/provider-webdriver/src/plugin.ts @@ -0,0 +1,30 @@ +import type { CloudWebDriverRuntimeOptions, CloudWebDriverRuntime } from './runtime.ts'; + +export async function createCloudWebDriverRuntime( + options: CloudWebDriverRuntimeOptions, +): Promise { + const runtime = await import('./runtime.ts'); + return runtime.createCloudWebDriverRuntime(options); +} +export type { + CloudWebDriverRuntimeOptions, + CloudWebDriverPlatform, + CloudWebDriverUploadApp, +} from './runtime.ts'; +export { + appFileUploadForm, + appendUrlPath, + createHubUploadApp, + fetchProviderSessionDetails, + fetchProviderVerificationJson, + postHubAppUpload, + readFlag, + requireEnv, + requireFlag, + requireProviderDeviceOrientation, + requireRequest, + requireRequestPlatform, + resolveHubAppReference, +} from './webdriver-utils.ts'; +export { cloudArtifactsReadyOrPending, urlArtifactFromDetails } from './artifact-results.ts'; +export { buildCloudWebDriverBaseCapabilities } from './capabilities.ts'; diff --git a/packages/provider-webdriver/src/profile-fields.fixtures.ts b/packages/provider-webdriver/src/profile-fields.fixtures.ts index 941db5235a..f52e710f25 100644 --- a/packages/provider-webdriver/src/profile-fields.fixtures.ts +++ b/packages/provider-webdriver/src/profile-fields.fixtures.ts @@ -8,6 +8,7 @@ export function consumeAllProfileFields(provider: string): ProviderProfileFieldD fields: { providerApp: 'consumed', providerOsVersion: 'consumed', + providerDeviceType: 'consumed', providerProject: 'consumed', providerBuild: 'consumed', providerSessionName: 'consumed', diff --git a/packages/provider-webdriver/src/provider-definitions.ts b/packages/provider-webdriver/src/provider-definitions.ts index e0bc328033..8571568975 100644 --- a/packages/provider-webdriver/src/provider-definitions.ts +++ b/packages/provider-webdriver/src/provider-definitions.ts @@ -29,10 +29,15 @@ import { type CloudWebDriverKnownProviderName, } from './providers.ts'; import { readAwsDeviceFarmRegionFromArn } from './connection-verification.ts'; +import { + readFlag, + requireFlag, + requireRequest, + requireRequestPlatform, +} from './webdriver-utils.ts'; import { buildCloudWebDriverBaseCapabilities, createCloudWebDriverRuntime, - type CloudWebDriverPlatform, type CloudWebDriverRuntime, } from './runtime.ts'; @@ -61,6 +66,7 @@ const BROWSERSTACK_PROFILE_FIELDS: ProviderProfileFieldDeclaration = { fields: { providerApp: 'consumed', providerOsVersion: 'consumed', + providerDeviceType: 'refused', providerProject: 'consumed', providerBuild: 'consumed', providerSessionName: 'consumed', @@ -87,6 +93,7 @@ const AWS_DEVICE_FARM_PROFILE_FIELDS: ProviderProfileFieldDeclaration = { fields: { providerApp: 'refused', providerOsVersion: 'refused', + providerDeviceType: 'refused', providerProject: 'refused', providerBuild: 'refused', providerSessionName: 'consumed', @@ -308,37 +315,6 @@ export function createCloudWebDriverProviderDefinitions( ]; } -function requireRequest( - req: LeaseLifecycleContext | undefined, - providerLabel: string, -): LeaseLifecycleContext { - if (req) return req; - throw new AppError( - 'INVALID_ARGS', - `${providerLabel} lease allocation requires provider profile flags on the request.`, - ); -} - -function requireRequestPlatform( - req: LeaseLifecycleContext, - providerLabel: string, -): CloudWebDriverPlatform { - const platform = req.flags?.platform; - if (platform === 'android' || platform === 'ios') return platform; - throw new AppError('INVALID_ARGS', `${providerLabel} requires --platform ios|android.`); -} - -function requireFlag(req: LeaseLifecycleContext, key: string, message: string): string { - const value = readFlag(req, key); - if (value) return value; - throw new AppError('INVALID_ARGS', message); -} - -function readFlag(req: LeaseLifecycleContext, key: string): string | undefined { - const value = req.flags?.[key]; - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - function requireAwsValue( req: LeaseLifecycleContext, env: DefaultCloudWebDriverProviderRuntimeEnv, diff --git a/packages/provider-webdriver/src/providers.ts b/packages/provider-webdriver/src/providers.ts index 04ae5e4d5a..ea66ef7ad5 100644 --- a/packages/provider-webdriver/src/providers.ts +++ b/packages/provider-webdriver/src/providers.ts @@ -33,14 +33,16 @@ export function readBrowserStackCredentials( const BROWSERSTACK_APP_SCHEME = 'bs://'; /** - * URI schemes are case-insensitive, but BrowserStack only matches the lower-case spelling, so - * `BS://id` is returned as `bs://id`. Anything without the scheme returns undefined. + * URI schemes are case-insensitive, but hosted hubs only match the lower-case spelling, so + * `BS://id` is returned as `bs://id`. Anything without the lower-case `scheme` returns undefined. */ +export function canonicalSchemeReference(app: string, scheme: string): string | undefined { + if (app.slice(0, scheme.length).toLowerCase() !== scheme) return undefined; + return `${scheme}${app.slice(scheme.length)}`; +} + export function canonicalBrowserStackAppReference(app: string): string | undefined { - if (app.slice(0, BROWSERSTACK_APP_SCHEME.length).toLowerCase() !== BROWSERSTACK_APP_SCHEME) { - return undefined; - } - return `${BROWSERSTACK_APP_SCHEME}${app.slice(BROWSERSTACK_APP_SCHEME.length)}`; + return canonicalSchemeReference(app, BROWSERSTACK_APP_SCHEME); } /** An id outside this grammar would pass every local check and fail only at session creation. */ diff --git a/packages/provider-webdriver/src/runtime-session.ts b/packages/provider-webdriver/src/runtime-session.ts index 7274789f6c..4425f761b6 100644 --- a/packages/provider-webdriver/src/runtime-session.ts +++ b/packages/provider-webdriver/src/runtime-session.ts @@ -8,6 +8,7 @@ import { AppError, errorMessage } from '@agent-device/kernel/errors'; import { unavailableCloudArtifactsResult } from './artifact-results.ts'; import { createCloudWebDriverCapabilities, + buildCloudWebDriverBaseCapabilities, type CloudWebDriverProviderCapabilities, } from './capabilities.ts'; import { WebDriverClient, type WebDriverSession } from './webdriver-client.ts'; @@ -17,7 +18,6 @@ import { snapshotBackendForPlatform } from './runtime-helpers.ts'; import { releaseOnFailure } from './webdriver-utils.ts'; import type { CloudWebDriverBaseSession, - CloudWebDriverPlatform, CloudWebDriverPreparedSession, CloudWebDriverRuntimeOptions, } from './runtime.ts'; @@ -293,18 +293,6 @@ export class WebDriverSessionManager { } } -export function buildCloudWebDriverBaseCapabilities( - platform: CloudWebDriverPlatform, - deviceName: string, - configured: Record = {}, -): Record { - return { - platformName: platform === 'ios' ? 'iOS' : 'Android', - 'appium:deviceName': deviceName, - ...configured, - }; -} - /** * The transport gave up on `POST /session`; the provider may still finish it, * and nothing here can learn that session's id — so the error names the lease diff --git a/packages/provider-webdriver/src/runtime.ts b/packages/provider-webdriver/src/runtime.ts index 4b94ac2d72..4f400bf28c 100644 --- a/packages/provider-webdriver/src/runtime.ts +++ b/packages/provider-webdriver/src/runtime.ts @@ -111,7 +111,7 @@ export function createCloudWebDriverRuntime( return new CloudWebDriverRuntimeImplementation(options); } -export { buildCloudWebDriverBaseCapabilities } from './runtime-session.ts'; +export { buildCloudWebDriverBaseCapabilities } from './capabilities.ts'; /** Public façade: provider wiring stays small while session/deployment mechanics stay focused. */ class CloudWebDriverRuntimeImplementation implements CloudWebDriverRuntime { diff --git a/packages/provider-webdriver/src/webdriver-utils.test.ts b/packages/provider-webdriver/src/webdriver-utils.test.ts index 9f874cd8e4..e61415b48a 100644 --- a/packages/provider-webdriver/src/webdriver-utils.test.ts +++ b/packages/provider-webdriver/src/webdriver-utils.test.ts @@ -10,6 +10,8 @@ import { appFileUploadForm, createHubUploadApp, postHubAppUpload, + readFlag, + requireEnv, resolveHubAppReference, trimLeadingSlash, trimTrailingSlash, @@ -206,6 +208,26 @@ test('the hub app resolver surfaces the grammar rejection of a malformed referen } }); +test('a whitespace-only credential is missing', () => { + assert.equal(requireEnv({ USER: 'u' }, 'USER', 'Hub'), 'u'); + for (const env of [{}, { USER: '' }, { USER: ' \t' }]) { + assert.throws( + () => requireEnv(env, 'USER', 'Hub'), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === 'Hub requires USER in the environment.', + ); + } +}); + +test('only non-empty string flags are read', () => { + const req = { flags: { device: 'Pixel 8', empty: '', count: 3 } }; + assert.equal(readFlag(req, 'device'), 'Pixel 8'); + assert.equal(readFlag(req, 'empty'), undefined); + assert.equal(readFlag(req, 'count'), undefined); +}); + test.skipIf(process.platform === 'win32')( 'appFileUploadForm refuses a named pipe without reading it', async () => { diff --git a/packages/provider-webdriver/src/webdriver-utils.ts b/packages/provider-webdriver/src/webdriver-utils.ts index 6d5cb139aa..c4dae20c1f 100644 --- a/packages/provider-webdriver/src/webdriver-utils.ts +++ b/packages/provider-webdriver/src/webdriver-utils.ts @@ -3,6 +3,7 @@ import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import type { DeviceLease, + LeaseLifecycleContext, ProviderDeviceInstallOptions, ProviderDeviceInstallResult, } from '@agent-device/contracts/device'; @@ -203,7 +204,8 @@ export async function fetchProviderVerificationJson( endpoint: string | URL, options: { clientVersion: string; - auth: { username: string; accessKey: string }; + /** Omitted for a public catalog endpoint. */ + auth?: { username: string; accessKey: string }; hints: ProviderJsonFailureHints; }, ): Promise { @@ -213,7 +215,7 @@ export async function fetchProviderVerificationJson( const response = await fetch(endpoint, { headers: { ...agentDeviceRequestHeaders(options.clientVersion), - Authorization: basicAuthHeader(options.auth), + ...(options.auth ? { Authorization: basicAuthHeader(options.auth) } : {}), }, signal: AbortSignal.timeout(PROVIDER_API_TIMEOUT_MS), }); @@ -322,3 +324,46 @@ export function requireProviderDeviceOrientation( capability: spec.capability, }); } + +/** Lease-flag and credential readers every hosted-WebDriver provider shares. */ +export function requireRequest( + req: LeaseLifecycleContext | undefined, + providerLabel: string, +): LeaseLifecycleContext { + if (req) return req; + throw new AppError( + 'INVALID_ARGS', + `${providerLabel} lease allocation requires provider profile flags on the request.`, + ); +} + +export function requireRequestPlatform( + req: LeaseLifecycleContext, + providerLabel: string, +): 'android' | 'ios' { + const platform = req.flags?.platform; + if (platform === 'android' || platform === 'ios') return platform; + throw new AppError('INVALID_ARGS', `${providerLabel} requires --platform ios|android.`); +} + +export function requireFlag(req: LeaseLifecycleContext, key: string, message: string): string { + const value = readFlag(req, key); + if (value) return value; + throw new AppError('INVALID_ARGS', message); +} + +export function readFlag(req: LeaseLifecycleContext, key: string): string | undefined { + const value = req.flags?.[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** A whitespace-only credential is missing, not one the provider should reject later. */ +export function requireEnv( + env: Readonly>>, + key: Key, + providerLabel: string, +): string { + const value = env[key]; + if (value?.trim()) return value; + throw new AppError('INVALID_ARGS', `${providerLabel} requires ${key} in the environment.`); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf1452f083..0ae0a8b811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: '@agent-device/session-journal': specifier: workspace:* version: link:packages/session-journal + '@agent-device/testmu': + specifier: workspace:* + version: link:packages/provider-testmu '@agent-device/xml': specifier: workspace:* version: link:packages/xml @@ -470,6 +473,21 @@ importers: specifier: ^0.49.3 version: 0.49.3(supports-color@7.2.0) + packages/provider-testmu: + devDependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + '@agent-device/provider-webdriver': + specifier: workspace:* + version: link:../provider-webdriver + agent-device: + specifier: workspace:* + version: link:../.. + packages/provider-webdriver: dependencies: '@agent-device/capture-kit': diff --git a/scripts/check-provider-plugin.mjs b/scripts/check-provider-plugin.mjs new file mode 100644 index 0000000000..13d30d255a --- /dev/null +++ b/scripts/check-provider-plugin.mjs @@ -0,0 +1,143 @@ +// Manual pre-push check for provider plugin packages; no CI lane runs it (it needs pnpm and a +// built core). Usage: `pnpm build && node scripts/check-provider-plugin.mjs packages/provider-testmu`. +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { pathToFileURL } from 'node:url'; + +const exec = promisify(execFile); +const root = path.resolve(import.meta.dirname, '..'); +const plugin = path.resolve(root, process.argv[2]); +const fixture = await import(pathToFileURL(path.join(plugin, 'test/package-smoke.mjs')).href); +const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-plugin-package-')); +const consumer = path.join(scratch, 'consumer'); +const home = path.join(scratch, 'home'); +const installation = crypto.randomUUID(); +const project = path.join(home, 'plugins', installation); +let rejectCredentials = true; +const requests = []; +const server = http.createServer((request, response) => { + requests.push(request.url); + const result = fixture.respond(request.url, rejectCredentials); + response.writeHead(result.status ?? 200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(result.body)); +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const endpoint = `http://127.0.0.1:${server.address().port}`; +async function run(command, args, cwd) { + return await exec(command, args, { cwd, maxBuffer: 16 * 1024 * 1024, timeout: 120_000 }); +} +async function pack(directory) { + if (directory === plugin) { + const tarball = path.join(scratch, 'plugin.tgz'); + await run('pnpm', ['pack', '--out', tarball], directory); + return tarball; + } + const { stdout } = await run( + 'npm', + ['pack', '--ignore-scripts', '--json', '--pack-destination', scratch], + directory, + ); + return path.join(scratch, JSON.parse(stdout)[0].filename); +} +try { + await run('pnpm', ['build'], plugin); + const coreTarball = await pack(root); + const pluginTarball = await pack(plugin); + for (const directory of [consumer, project]) { + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(directory, 'package.json'), '{"private":true,"type":"module"}'); + } + await run( + 'npm', + ['install', '--ignore-scripts', '--no-audit', '--no-fund', coreTarball], + consumer, + ); + await run( + 'npm', + ['install', '--ignore-scripts', '--no-audit', '--no-fund', pluginTarball], + project, + ); + const manifest = JSON.parse(await fs.readFile(path.join(plugin, 'package.json'), 'utf8')); + const installed = JSON.parse( + await fs.readFile(path.join(project, 'node_modules', manifest.name, 'package.json'), 'utf8'), + ); + assert.deepEqual(installed.exports, { '.': { import: './dist/plugin.mjs' } }); + assert.equal(installed.dependencies, undefined); + assert.equal(installed.peerDependencies, undefined); + await assert.rejects(fs.stat(path.join(project, 'node_modules', 'agent-device')), { + code: 'ENOENT', + }); + await fs.writeFile( + path.join(home, 'config.json'), + JSON.stringify({ plugins: { [manifest.name]: { installation } } }), + ); + const core = path.join(consumer, 'node_modules', 'agent-device'); + const coreManifest = JSON.parse(await fs.readFile(path.join(core, 'package.json'), 'utf8')); + const bin = path.resolve(core, coreManifest.bin['agent-device']); + const env = { + ...process.env, + ...fixture.environment(endpoint), + AGENT_DEVICE_HOME: home, + AGENT_DEVICE_NO_UPDATE_NOTIFIER: '1', + }; + const preload = path.join(scratch, 'fetch-fixture.mjs'); + await fs.writeFile( + preload, + `const originalFetch = globalThis.fetch; +const redirects = ${JSON.stringify(fixture.fetchRedirects ?? [])}; +globalThis.fetch = (input, init) => { + let url = String(input); + for (const prefix of redirects) if (url.startsWith(prefix)) url = ${JSON.stringify(endpoint)} + url.slice(prefix.length); + if (new URL(url).hostname !== '127.0.0.1') throw new Error('Unexpected external request: ' + url); + return originalFetch(url, init); +};`, + ); + const command = async (args) => { + try { + return await exec(process.execPath, ['--import', preload, bin, ...args, '--json'], { + cwd: consumer, + env, + timeout: 30_000, + }); + } catch (error) { + if (typeof error.stdout !== 'string') throw error; + return error; + } + }; + const listed = await command(['plugins', 'list']); + assert.equal(listed.code, undefined, listed.stderr); + const rejected = await command([ + 'connect', + manifest.agentDevicePlugin.provider, + ...fixture.args, + '--state-dir', + path.join(scratch, 'state'), + ]); + assert.equal( + JSON.parse(rejected.stdout).error.code, + 'UNAUTHORIZED', + rejected.stdout + rejected.stderr, + ); + rejectCredentials = false; + const connected = await command([ + 'connect', + manifest.agentDevicePlugin.provider, + ...fixture.args, + '--state-dir', + path.join(scratch, 'state'), + ]); + assert.equal(connected.code, undefined, connected.stdout + connected.stderr); + assert.ok(requests.length > 0); + console.log( + `Packed ${manifest.name}: isolated install, host-recognized errors, and CLI connect passed.`, + ); +} finally { + await new Promise((resolve) => server.close(resolve)); + await fs.rm(scratch, { recursive: true, force: true }); +} diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 3a1be8a0b8..2d9eb9a809 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -254,6 +254,7 @@ function summarizeProviderScenarioFlagExclusions() { 'providerSessionId', 'providerApp', 'providerOsVersion', + 'providerDeviceType', 'providerProject', 'providerBuild', 'providerSessionName', diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 17f5590ad1..cc6f0cdaa8 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -108,6 +108,7 @@ export const UNRANKED_ZONES: ReadonlySet = new Set([ ...PLATFORMS.map((family) => `platform-${family}`), 'provider-webdriver', 'provider-limrun', + 'provider-testmu', 'xml', ]); diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 7c105c79d3..341a7f9409 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -16,6 +16,7 @@ import { checkPackageInternalSites, checkRootSites, readWorkspacePackages, + workspacePackagesFromManifests, rootExternalDependencyRanges, rootWorkspaceDependencyNames, specifierSites, @@ -148,6 +149,38 @@ test('readWorkspacePackages reads tracked manifests only', () => { ); }); +test('published ESM plugins declare bundled workspace build dependencies without runtime dependencies', () => { + const [plugin] = workspacePackagesFromManifests( + new Map([ + [ + 'packages/provider-example/package.json', + JSON.stringify({ + name: '@agent-device/example', + exports: { '.': { import: './dist/plugin.mjs' } }, + devDependencies: { '@agent-device/kernel': 'workspace:*', tsdown: '^0.21.0' }, + }), + ], + ]), + ); + assert.ok(plugin); + assert.equal( + plugin.exportTargets.get('@agent-device/example'), + 'packages/provider-example/dist/plugin.mjs', + ); + assert.deepEqual([...plugin.workspaceDependencies], ['@agent-device/kernel']); + assert.equal(plugin.externalDependencies.size, 0); + const sites = specifierSites( + 'packages/provider-example/src/plugin.ts', + "import { AppError } from '@agent-device/kernel/errors';", + ); + assert.deepEqual(checkPackageInternalSites(plugin, sites, [plugin, kernel]), []); + const undeclared = { ...plugin, workspaceDependencies: new Set() }; + const violations = checkPackageInternalSites(undeclared, sites, [undeclared, kernel]); + assert.equal(violations.length, 1); + assert.equal(violations[0]?.rule, 'R11 package-boundaries'); + assert.match(violations[0]?.message ?? '', /without declaring/); +}); + test('every workspace package façade names its exports explicitly (no bare `export *`)', () => { // #1574 built a hand-maintained pin table (`facade-symbols.ts`, 816 symbols across every // workspace-package façade) plus a ~200-line star-chain resolver (`readFacadeExports`) whose @@ -757,6 +790,7 @@ test('the real tree parses, declares, and passes R11', () => { assert.ok(providerWebDriverPackage, 'provider-webdriver package must exist'); assert.deepEqual([...providerWebDriverPackage.exportTargets.keys()].sort(), [ '@agent-device/provider-webdriver', + '@agent-device/provider-webdriver/plugin', '@agent-device/provider-webdriver/providers', ]); assert.deepEqual([...providerWebDriverPackage.workspaceDependencies].sort(), [ diff --git a/scripts/layering/package-boundaries.ts b/scripts/layering/package-boundaries.ts index cb8c462c4d..5b9d288401 100644 --- a/scripts/layering/package-boundaries.ts +++ b/scripts/layering/package-boundaries.ts @@ -100,13 +100,14 @@ export function workspacePackagesFromManifests( const manifest = JSON.parse(manifests.get(manifestFile)!) as { name?: string; private?: boolean; - exports?: Record; + exports?: Record; + devDependencies?: Record; dependencies?: Record; }; if (!manifest.name) continue; const exportTargets = new Map(); for (const [subpath, target] of Object.entries(manifest.exports ?? {})) { - const targetFile = typeof target === 'string' ? target : target.default; + const targetFile = typeof target === 'string' ? target : (target.default ?? target.import); if (!targetFile) continue; exportTargets.set( path.posix.join(manifest.name, subpath), @@ -114,7 +115,7 @@ export function workspacePackagesFromManifests( ); } const workspaceDependencies = new Set( - Object.entries(manifest.dependencies ?? {}) + Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }) .filter(([, range]) => range.startsWith('workspace:')) .map(([name]) => name), ); diff --git a/src/__tests__/client-leases.test.ts b/src/__tests__/client-leases.test.ts new file mode 100644 index 0000000000..02ea6d0d58 --- /dev/null +++ b/src/__tests__/client-leases.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { createTransport } from './client-transport-fixture.ts'; + +test('lease allocation carries the TestMu device type to the provider flags', async () => { + const setup = createTransport(async (req) => ({ + ok: true, + data: { + lease: { + leaseId: 'lease-new', + tenantId: req.meta?.tenantId, + runId: req.meta?.runId, + backend: req.meta?.leaseBackend, + }, + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await client.leases.allocate({ + tenant: 'testmu', + runId: 'remote-run', + leaseBackend: 'ios-instance', + leaseProvider: 'testmu', + platform: 'ios', + device: 'iPhone 16', + providerOsVersion: '18', + providerDeviceType: 'real', + providerApp: 'lt://APP1', + }); + + assert.equal(setup.calls[0]?.command, 'lease_allocate'); + assert.equal(setup.calls[0]?.flags?.providerDeviceType, 'real'); + assert.equal(setup.calls[0]?.flags?.providerOsVersion, '18'); +}); diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index b33c1130b3..6c31c1d59e 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -17,6 +17,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { verifyLimrunConnection } from '@agent-device/provider-limrun'; import { providerWebDriver } from '../provider-webdriver.ts'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; +import { connectWithGeneratedProviderProfile } from './test-utils/connect-command.ts'; vi.mock('../cli/auth-session.ts', async (importOriginal) => ({ ...(await importOriginal()), @@ -919,29 +920,6 @@ async function captureConnectStdout(task: () => Promise): Promise { } } -async function connectWithGeneratedProviderProfile(options: { - stateDir: string; - positionals: string[]; - flags: Partial[0]['flags']>; -}): Promise { - const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - try { - await connectCommand({ - positionals: options.positionals, - flags: { - json: true, - help: false, - version: false, - stateDir: options.stateDir, - ...options.flags, - }, - client: {} as AgentDeviceClient, - }); - } finally { - stdoutWrite.mockRestore(); - } -} - function readGeneratedConfig(configPath: string): { tenant?: string; leaseProvider?: string; diff --git a/src/__tests__/cloud-connect-testmu.test.ts b/src/__tests__/cloud-connect-testmu.test.ts new file mode 100644 index 0000000000..2a6d157460 --- /dev/null +++ b/src/__tests__/cloud-connect-testmu.test.ts @@ -0,0 +1,370 @@ +import { afterEach, beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runCliCapture } from './cli-capture.ts'; +import { + readActiveConnectionState, + type RemoteConnectionState, +} from '../remote/remote-connection-state.ts'; +import { resolveCloudWebDriverConnectProfile as resolveBuiltinProfile } from '../cli/connection/cloud-webdriver-profile.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import { verifyTestMuConnection } from '@agent-device/testmu/connection-verification'; +import testMuPlugin from '@agent-device/testmu'; +import { createPluginHost } from '../plugins/host.ts'; +import { persistAndResolveGeneratedProfile } from '../cli/connection/generated-config.ts'; +import { selectPlugin, pluginHome } from '../plugins/plugin.fixtures.ts'; +import { installedPlugins } from '../plugins/store.ts'; +import manifest from '@agent-device/testmu/package.json' with { type: 'json' }; +import type { CliFlags } from '@agent-device/contracts/command'; +import type { PluginConnection } from '../plugins/connection.ts'; +import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; +import { connectWithGeneratedProviderProfile } from './test-utils/connect-command.ts'; + +vi.mock('@agent-device/testmu/connection-verification', () => ({ + verifyTestMuConnection: vi.fn(), +})); +vi.mock('../plugins/load.ts', () => ({ + withPluginConnection: async ( + _provider: string, + env: NodeJS.ProcessEnv, + runConnection: (connection: PluginConnection) => Promise, + ) => { + const registration = testMuPlugin(createPluginHost(env, undefined)); + return await runConnection(registration.connection); + }, +})); +function resolveCloudWebDriverConnectProfile(options: { + provider: 'testmu' | 'browserstack' | 'aws-device-farm'; + flags: CliFlags; + stateDir: string; + cwd: string; + env?: NodeJS.ProcessEnv; +}) { + if (options.provider !== 'testmu') + return resolveBuiltinProfile({ ...options, provider: options.provider }); + const resolved = testMuPlugin(createPluginHost(options.env ?? {}, undefined)).connection.resolve( + options, + ); + return persistAndResolveGeneratedProfile({ ...options, ...resolved }); +} + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +const mockedVerifyWebDriverConnection = vi.mocked(verifyTestMuConnection); + +beforeEach(() => { + const { home, env } = pluginHome(); + selectPlugin(home, manifest.name, 'testmu', 'export default () => {};'); + const [plugin] = installedPlugins(env); + const manifestPath = path.join(plugin!.directory, 'package.json'); + const declared = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + declared.agentDevicePlugin.connection = manifest.agentDevicePlugin.connection; + fs.writeFileSync(manifestPath, JSON.stringify(declared)); + vi.stubEnv('AGENT_DEVICE_HOME', home); + mockedVerifyWebDriverConnection.mockImplementation(async (options) => { + assert.equal(options.provider, 'testmu'); + return { + provider: 'testmu', + service: 'TestMu AI', + verificationMessage: 'Credentials, device, and uploaded app verified.', + device: { + status: 'verified', + name: options.deviceName, + platform: options.platform, + osVersion: options.osVersion, + }, + app: { status: 'verified', reference: options.app }, + }; + }); +}); + +test('connect testmu generates a local provider profile and verifies the virtual device', async () => { + const tempRoot = mkdtempForTestSync('agent-device-connect-testmu-'); + const stateDir = path.join(tempRoot, '.state'); + vi.stubEnv('LT_USERNAME', 'lt-user'); + vi.stubEnv('LT_ACCESS_KEY', 'lt-key'); + + try { + await connectWithGeneratedProviderProfile({ + stateDir, + positionals: ['testmu'], + flags: { + platform: 'ios', + device: 'iPhone 16', + providerOsVersion: '18.0', + providerApp: 'lt://APP1', + providerBuild: 'build-a', + }, + }); + + assert.deepEqual(mockedVerifyWebDriverConnection.mock.calls[0]?.[0], { + provider: 'testmu', + username: 'lt-user', + accessKey: 'lt-key', + platform: 'ios', + deviceName: 'iPhone 16', + osVersion: '18.0', + app: 'lt://APP1', + deviceType: 'virtual', + apiEndpoint: undefined, + }); + const state = readRequiredActiveState(stateDir); + assert.equal(state.tenant, 'testmu'); + assert.equal(state.leaseProvider, 'testmu'); + assert.match(state.remoteConfigPath, /generated\/testmu-[a-f0-9]{16}\.json$/); + const generated = readGeneratedConfig(state.remoteConfigPath); + assert.equal(generated.providerApp, 'lt://APP1'); + assert.equal(generated.providerOsVersion, '18.0'); + assert.equal(generated.providerBuild, 'build-a'); + assert.equal(JSON.stringify(generated).includes('lt-key'), false); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect canonicalizes an upper-case app scheme and refuses an empty app id', () => { + const tempRoot = mkdtempForTestSync('agent-device-connect-app-scheme-'); + const base = { json: false, help: false, version: false, platform: 'ios' as const }; + const connect = (provider: 'testmu' | 'browserstack', providerApp: string) => + resolveCloudWebDriverConnectProfile({ + provider, + stateDir: path.join(tempRoot, `.state-${provider}`), + cwd: tempRoot, + env: { + LT_USERNAME: 'u', + LT_ACCESS_KEY: 'k', + BROWSERSTACK_USERNAME: 'u', + BROWSERSTACK_ACCESS_KEY: 'k', + }, + flags: { ...base, device: 'iPhone 16', providerOsVersion: '18.0', providerApp }, + }); + + try { + const upperCase = connect('testmu', 'LT://APP1'); + assert.equal(readGeneratedConfig(upperCase.remoteConfigPath).providerApp, 'lt://APP1'); + // Connect verification reads these flags, so they must carry the canonical reference too. + assert.equal(upperCase.flags.providerApp, 'lt://APP1'); + assert.equal( + readGeneratedConfig(connect('browserstack', 'Bs://abc').remoteConfigPath).providerApp, + 'bs://abc', + ); + for (const [provider, app] of [ + ['testmu', 'lt://'], + ['testmu', 'lt://a b'], + ['testmu', 'LT://a/b'], + ['browserstack', 'bs://'], + ] as const) { + assert.throws( + () => connect(provider, app), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /valid lt:\/\/ app reference|is not a bs:\/\/ app id/.test(error.message), + ); + } + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect testmu verifies against TESTMU_API_ENDPOINT', async () => { + const tempRoot = mkdtempForTestSync('agent-device-connect-testmu-endpoint-'); + vi.stubEnv('LT_USERNAME', 'lt-user'); + vi.stubEnv('LT_ACCESS_KEY', 'lt-key'); + vi.stubEnv('TESTMU_API_ENDPOINT', 'https://staging.testmu.test/mobile-automation/api/v1'); + + try { + await connectWithGeneratedProviderProfile({ + stateDir: path.join(tempRoot, '.state'), + positionals: ['testmu'], + flags: { + platform: 'android', + device: 'Pixel 8', + providerOsVersion: '14', + providerApp: 'lt://APP1', + }, + }); + + const options = mockedVerifyWebDriverConnection.mock.calls[0]?.[0]; + assert.equal(options?.provider, 'testmu'); + assert.equal(options.apiEndpoint, 'https://staging.testmu.test/mobile-automation/api/v1'); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect testmu rejects BrowserStack network and re-sign flags before saving a profile', () => { + const tempRoot = mkdtempForTestSync('agent-device-connect-testmu-reject-'); + + try { + assert.throws( + () => + resolveCloudWebDriverConnectProfile({ + provider: 'testmu', + stateDir: path.join(tempRoot, '.state'), + cwd: tempRoot, + env: { LT_USERNAME: 'lt-user', LT_ACCESS_KEY: 'lt-key' }, + flags: { + json: false, + help: false, + version: false, + platform: 'ios', + device: 'iPhone 16', + providerOsVersion: '18.0', + providerApp: 'lt://APP1', + providerNetworkProfile: '3g-lossy', + providerNoResignApp: true, + }, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.match(error.message, /not supported by TestMu AI/); + assert.deepEqual(error.details?.flags, [ + '--provider-network-profile', + '--provider-no-resign-app', + ]); + return true; + }, + ); + assert.equal(fs.existsSync(path.join(tempRoot, '.state')), false); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect testmu stores and verifies the real-device pool', async () => { + const tempRoot = mkdtempForTestSync('agent-device-connect-testmu-real-'); + const stateDir = path.join(tempRoot, '.state'); + vi.stubEnv('LT_USERNAME', 'lt-user'); + vi.stubEnv('LT_ACCESS_KEY', 'lt-key'); + + try { + await connectWithGeneratedProviderProfile({ + stateDir, + positionals: ['testmu'], + flags: { + platform: 'ios', + device: 'iPhone 16', + providerOsVersion: '18', + providerDeviceType: 'real', + providerApp: 'lt://APP1', + }, + }); + + assert.deepEqual(mockedVerifyWebDriverConnection.mock.calls[0]?.[0], { + provider: 'testmu', + username: 'lt-user', + accessKey: 'lt-key', + platform: 'ios', + deviceName: 'iPhone 16', + osVersion: '18', + app: 'lt://APP1', + deviceType: 'real', + apiEndpoint: undefined, + }); + const state = readRequiredActiveState(stateDir); + const generated = readGeneratedConfig(state.remoteConfigPath); + assert.equal(generated.providerDeviceType, 'real'); + assert.equal(generated.providerOsVersion, '18'); + + // The saved profile reproduces the same verification when it is loaded again. + mockedVerifyWebDriverConnection.mockClear(); + await connectWithGeneratedProviderProfile({ + stateDir, + positionals: [], + flags: { remoteConfig: state.remoteConfigPath, force: true }, + }); + const reloaded = mockedVerifyWebDriverConnection.mock.calls[0]?.[0]; + assert.equal(reloaded?.provider, 'testmu'); + assert.equal(reloaded.deviceType, 'real'); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('providers other than TestMu refuse --provider-device-type before saving a profile', () => { + const tempRoot = mkdtempForTestSync('agent-device-connect-device-type-reject-'); + const base = { json: false, help: false, version: false, platform: 'android' as const }; + + try { + for (const [provider, flags, env] of [ + [ + 'browserstack', + { + ...base, + device: 'Google Pixel 8', + providerOsVersion: '14.0', + providerApp: 'bs://app-id', + }, + { BROWSERSTACK_USERNAME: 'u', BROWSERSTACK_ACCESS_KEY: 'k' }, + ], + [ + 'aws-device-farm', + { + ...base, + awsProjectArn: 'arn:aws:devicefarm:us-west-2:123:project/p', + awsDeviceArn: 'arn:aws:devicefarm:us-west-2::device/d', + }, + {}, + ], + ] as const) { + assert.throws( + () => + resolveCloudWebDriverConnectProfile({ + provider, + stateDir: path.join(tempRoot, '.state'), + cwd: tempRoot, + env, + flags: { ...flags, providerDeviceType: 'real' }, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.match(error.message, /^--provider-device-type is not supported by /); + assert.equal(error.details?.provider, provider); + return true; + }, + ); + } + assert.equal(fs.existsSync(path.join(tempRoot, '.state')), false); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect limrun refuses the device type', async () => { + const result = await runCliCapture( + ['connect', 'limrun', '--platform', 'ios', '--provider-device-type', 'real', '--json'], + { + env: { LIMRUN_API_KEY: 'lim_test_key' }, + stateDirPrefix: 'agent-device-connect-limrun-device-type-', + }, + ); + assert.equal(result.code, 1); + assert.match(result.stdout, /--provider-device-type is not supported by Limrun/); +}); + +function readGeneratedConfig(configPath: string): { + providerApp?: string; + providerOsVersion?: string; + providerDeviceType?: string; + providerBuild?: string; +} { + return JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + providerApp?: string; + providerOsVersion?: string; + providerDeviceType?: string; + providerBuild?: string; + }; +} + +function readRequiredActiveState(stateDir: string): RemoteConnectionState { + const state = readActiveConnectionState({ stateDir }); + assert.ok(state); + return state; +} diff --git a/src/__tests__/test-utils/connect-command.ts b/src/__tests__/test-utils/connect-command.ts new file mode 100644 index 0000000000..c16a40ca43 --- /dev/null +++ b/src/__tests__/test-utils/connect-command.ts @@ -0,0 +1,27 @@ +import { vi } from 'vitest'; +import { connectCommand } from '../../cli/commands/connection.ts'; +import type { AgentDeviceClient } from '../../agent-device-client.ts'; + +/** Runs `connect` the way the CLI does, writing its generated profile under `stateDir`. */ +export async function connectWithGeneratedProviderProfile(options: { + stateDir: string; + positionals: string[]; + flags: Partial[0]['flags']>; +}): Promise { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await connectCommand({ + positionals: options.positionals, + flags: { + json: true, + help: false, + version: false, + stateDir: options.stateDir, + ...options.flags, + }, + client: {} as AgentDeviceClient, + }); + } finally { + stdoutWrite.mockRestore(); + } +} diff --git a/src/cli.ts b/src/cli.ts index 0bdbb3de5c..e5c7ce568a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,10 +25,8 @@ import { type AgentDeviceClientConfig, type AgentDeviceDaemonTransport, } from './agent-device-client.ts'; -import { materializeRemoteConnectionForCommand } from './cli/commands/connection-runtime.ts'; import { tryRunClientBackedCommand } from './cli/commands/router.ts'; import { runAgentCdpCommand } from './cli/commands/agent-cdp.ts'; -import { runReactDevtoolsCommand } from './cli/commands/react-devtools.ts'; import { readCliBatchStepsJson } from './commands/batch/batch-steps.ts'; import { createRequestId, @@ -353,6 +351,7 @@ async function resolveRunContextOrExit( } async function runReactDevtoolsCli(ctx: CliRunContext, deps: CliDeps): Promise { + const { runReactDevtoolsCommand } = await import('./cli/commands/react-devtools.ts'); const { daemonAuthToken, ...directRequestFlags } = ctx.effectiveFlags; return await runReactDevtoolsCommand(ctx.positionals, { flags: { @@ -408,6 +407,8 @@ async function resolveRemoteContext(ctx: CliRunContext, deps: CliDeps): Promise< const materializationClient = createAgentDeviceClient(buildClientConfig(ctx), { transport: createClientDaemonTransport(deps.sendToDaemon), }); + const { materializeRemoteConnectionForCommand } = + await import('./cli/commands/connection-runtime.ts'); const materialized = await materializeRemoteConnectionForCommand({ command: ctx.command, flags: ctx.effectiveFlags, @@ -457,6 +458,7 @@ function buildClientConfig(ctx: CliRunContext): AgentDeviceClientConfig { deviceKey: connection?.deviceKey, providerApp: currentFlags.providerApp, providerOsVersion: currentFlags.providerOsVersion, + providerDeviceType: currentFlags.providerDeviceType, providerProject: currentFlags.providerProject, providerBuild: currentFlags.providerBuild, providerSessionName: currentFlags.providerSessionName, diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index a959b7a173..f0e610b4a6 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -914,6 +914,7 @@ async function allocateOrReuseLease( serial: flags.serial, providerApp: initialApp ?? flags.providerApp, providerOsVersion: flags.providerOsVersion, + providerDeviceType: flags.providerDeviceType, providerProject: flags.providerProject, providerBuild: flags.providerBuild, providerSessionName: flags.providerSessionName, diff --git a/src/cli/commands/plugins.ts b/src/cli/commands/plugins.ts index 5987978e8c..8fbf202bb9 100644 --- a/src/cli/commands/plugins.ts +++ b/src/cli/commands/plugins.ts @@ -16,11 +16,7 @@ export const pluginsCommand: ClientCommandHandler = async ({ positionals, flags ); } if (action !== 'list' && name) { - const reserved = - action === 'remove' - ? [] - : (await import('../../provider-device-runtimes.ts')).DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS; - await changePlugin(action as 'add' | 'update' | 'remove', name, process.env, reserved); + await changePlugin(action as 'add' | 'update' | 'remove', name, process.env); } const plugins = listPlugins(process.env); await writeCommandOutput(flags, { plugins, restartRequired: action !== 'list' }, () => diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 4a69f206f3..d1adb6e8bd 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -169,7 +169,7 @@ function shouldConfigureDirectReverse( const { flags } = options; if (!flags) return false; return ( - connectionProviderCapabilities(flags.leaseProvider).supportsDirectPortReverse && + connectionProviderCapabilities(flags.leaseProvider, options.env).supportsDirectPortReverse && flags.leaseBackend === 'android-instance' && flags.metroProxyBaseUrl === undefined && options.configureDirectPortReverse !== undefined diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index 28ca37743e..79f9ca159e 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -14,7 +14,15 @@ import { resolveLimrunConnectProfile } from './limrun-profile.ts'; import { readLimrunCredentials } from '../../provider-limrun-credentials.ts'; import { resolveProxyConnectProfile } from './proxy-profile.ts'; import { profileToCliFlags } from '../remote-config-flags.ts'; -import { isConnectProviderName, type ConnectProvider } from './provider-policy.ts'; +import { + isConnectProviderName, + type ConnectProvider, + type BuiltinConnectProvider, +} from './provider-policy.ts'; +import { withPluginConnection } from '../../plugins/load.ts'; +import { readMetroProfileFields } from './profile-fields.ts'; +import { buildConnectClientId } from './client-id.ts'; +import { persistAndResolveGeneratedProfile } from './generated-config.ts'; type ConnectProfile = { flags: CliFlags; remoteConfigPath: string }; type ResolvedConnectProfile = ConnectProfile & { provider?: ConnectProvider }; @@ -75,7 +83,7 @@ const CONNECT_PROVIDER_ADAPTERS = { resolve: resolveLimrunConnectProfile, verify: verifyLimrun, }, -} satisfies Record; +} satisfies Record; export async function resolveConnectProviderProfile(options: { provider?: ConnectProvider; @@ -100,17 +108,52 @@ export async function resolveConnectProviderProfile(options: { remoteConfig: resolved.resolvedPath, }, remoteConfigPath: resolved.resolvedPath, - ...(isConnectProviderName(leaseProvider) ? { provider: leaseProvider } : {}), + ...(isConnectProviderName(leaseProvider, env) ? { provider: leaseProvider } : {}), }; } const provider = options.provider ?? (shouldUseProxyConnectShortcut(options.flags) ? 'proxy' : 'cloud'); - const profile = await CONNECT_PROVIDER_ADAPTERS[provider].resolve({ + const context = { flags: options.flags, stateDir: options.stateDir, cwd, env, - }); + }; + const adapter = (CONNECT_PROVIDER_ADAPTERS as Partial>)[ + provider + ]; + const profile = adapter + ? await adapter.resolve(context) + : await withPluginConnection(provider, env, async (connection) => { + const resolved = await connection.resolve(context); + if (resolved.profile.leaseProvider !== provider) + throw new AppError( + 'INVALID_ARGS', + 'Plugin connection profile must select its declared provider', + ); + const clientId = buildConnectClientId( + provider, + context.stateDir, + context.flags.session, + resolved.profile.device, + ); + return persistAndResolveGeneratedProfile({ + ...context, + ...resolved, + provider, + profile: { + tenant: context.flags.tenant ?? provider, + sessionIsolation: context.flags.sessionIsolation ?? 'tenant', + runId: context.flags.runId ?? `${provider}-${clientId}`, + clientId, + target: context.flags.target ?? 'mobile', + session: context.flags.session, + stateDir: context.stateDir, + ...readMetroProfileFields(context.flags), + ...resolved.profile, + }, + }); + }); return { ...profile, provider }; } @@ -125,10 +168,20 @@ export async function verifyResolvedConnectProvider( 'Remote connection profile loaded. Access is checked by the first remote command.', }; } - return await CONNECT_PROVIDER_ADAPTERS[resolved.provider].verify({ + const context = { flags: resolved.flags, env: process.env, - }); + }; + const adapter = (CONNECT_PROVIDER_ADAPTERS as Partial>)[ + resolved.provider + ]; + return adapter + ? await adapter.verify(context) + : await withPluginConnection( + resolved.provider, + context.env, + async (connection) => await connection.verify(context), + ); } async function verifyBrowserStack( diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index d537e91c6a..b6ced9d546 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import { test } from 'vitest'; -import { connectionProviderCapabilities } from './provider-policy.ts'; +import { connectionProviderCapabilities, isConnectProviderName } from './provider-policy.ts'; +import { pluginHome, selectPlugin } from '../../plugins/plugin.fixtures.ts'; +import { installedPlugins } from '../../plugins/store.ts'; test('provider policy projects provider identity into semantic capabilities', () => { assert.deepEqual(connectionProviderCapabilities('limrun'), { @@ -18,3 +22,24 @@ test('provider policy projects provider identity into semantic capabilities', () assert.equal(connectionProviderCapabilities('aws-device-farm').requiresAppAttachment, true); assert.equal(connectionProviderCapabilities('proxy').leaseKind, 'proxy'); }); + +test('plugin capabilities come from the same environment as the provider name check', () => { + const { home, env } = pluginHome(); + selectPlugin(home, 'example', 'example', 'export default () => {};'); + const manifestPath = path.join(installedPlugins(env)[0]!.directory, 'package.json'); + const declared = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const connection = { + leaseKind: 'direct-device-provider', + requiresAppAttachment: true, + requiresRemoteDaemon: false, + supportsArtifacts: true, + supportsDeferredAppSelection: false, + supportsDirectPortReverse: false, + usesCloudWebDriverLease: true, + } as const; + declared.agentDevicePlugin.connection = connection; + fs.writeFileSync(manifestPath, JSON.stringify(declared)); + + assert.equal(isConnectProviderName('example', env), true); + assert.deepEqual(connectionProviderCapabilities('example', env), connection); +}); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index ebb86bc6d4..d0f4d8ba3d 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -1,24 +1,26 @@ +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; import { CLOUD_WEBDRIVER_PROVIDERS, isCloudWebDriverProviderName, type CloudWebDriverKnownProviderName, } from '@agent-device/provider-webdriver/providers'; +import { pluginConnectionCapabilities, pluginConnectionNames } from '../../plugins/connection.ts'; export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; -export type ConnectProvider = 'cloud' | 'proxy' | DirectDeviceConnectProvider; +import { RESERVED_PLUGIN_PROVIDERS as BUILTIN_CONNECT_PROVIDERS } from '../../plugins/manifest.ts'; +export type BuiltinConnectProvider = (typeof BUILTIN_CONNECT_PROVIDERS)[number]; +export type ConnectProvider = BuiltinConnectProvider | (string & {}); -export type ConnectionProviderCapabilities = { - leaseKind: 'proxy' | 'direct-device-provider' | 'remote-provider'; - requiresAppAttachment: boolean; - requiresRemoteDaemon: boolean; - supportsArtifacts: boolean; - supportsDeferredAppSelection: boolean; - supportsDirectPortReverse: boolean; - usesCloudWebDriverLease: boolean; -}; - -export function isConnectProviderName(value: string | undefined): value is ConnectProvider { - return value === 'cloud' || value === 'proxy' || isDirectDeviceConnectProvider(value); +export function isConnectProviderName( + value: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): value is ConnectProvider { + return ( + value === 'cloud' || + value === 'proxy' || + isDirectDeviceConnectProvider(value) || + pluginConnectionCapabilities(value, env) !== undefined + ); } function isDirectDeviceConnectProvider( @@ -28,20 +30,19 @@ function isDirectDeviceConnectProvider( } export function connectProviderNamesForError(): string { - return [ - 'cloud', - 'proxy', - CLOUD_WEBDRIVER_PROVIDERS.browserStack, - CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, - 'limrun', - ].join(', '); + return [...BUILTIN_CONNECT_PROVIDERS, ...pluginConnectionNames()].join(', '); } export function connectionProviderCapabilities( provider: string | undefined, + env: NodeJS.ProcessEnv = process.env, ): ConnectionProviderCapabilities { const directDeviceProvider = isDirectDeviceConnectProvider(provider); const cloudWebDriver = isCloudWebDriverProviderName(provider); + if (!directDeviceProvider && provider !== 'cloud' && provider !== 'proxy') { + const plugin = pluginConnectionCapabilities(provider, env); + if (plugin) return plugin; + } return { leaseKind: provider === 'proxy' diff --git a/src/cli/parser/__tests__/args-parse-provider-device-type.test.ts b/src/cli/parser/__tests__/args-parse-provider-device-type.test.ts new file mode 100644 index 0000000000..534d2d6a3d --- /dev/null +++ b/src/cli/parser/__tests__/args-parse-provider-device-type.test.ts @@ -0,0 +1,17 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { parseArgs } from '../args.ts'; + +test('parseArgs reads the TestMu device type and rejects an unknown pool', () => { + const parsed = parseArgs(['connect', 'testmu', '--provider-device-type', 'real'], { + strictFlags: true, + }); + assert.equal(parsed.flags.providerDeviceType, 'real'); + assert.throws( + () => + parseArgs(['connect', 'testmu', '--provider-device-type', 'physical'], { + strictFlags: true, + }), + /Invalid provider-device-type: physical/, + ); +}); diff --git a/src/commands/command-flags.ts b/src/commands/command-flags.ts index f7b44a4618..fa04c2d2ef 100644 --- a/src/commands/command-flags.ts +++ b/src/commands/command-flags.ts @@ -28,6 +28,7 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { providerSessionId: options.providerSessionId, providerApp: options.providerApp, providerOsVersion: options.providerOsVersion, + providerDeviceType: options.providerDeviceType, providerProject: options.providerProject, providerBuild: options.providerBuild, providerSessionName: options.providerSessionName, diff --git a/src/commands/management/artifacts.ts b/src/commands/management/artifacts.ts index 11cea82c1b..1fbb683529 100644 --- a/src/commands/management/artifacts.ts +++ b/src/commands/management/artifacts.ts @@ -11,7 +11,9 @@ const artifactsCommandMetadata = defineFieldCommandMetadata( 'artifacts', 'List daemon or cloud provider artifacts for an active or completed session.', { - provider: stringField('Cloud provider name, for example browserstack or aws-device-farm.'), + provider: stringField( + 'Cloud provider name, for example browserstack, aws-device-farm, or testmu.', + ), providerSessionId: stringField('Cloud provider session id or ARN.'), }, ); diff --git a/src/commands/schema/cli-help-topics.test.ts b/src/commands/schema/cli-help-topics.test.ts index 2364272b00..7d983f2fcc 100644 --- a/src/commands/schema/cli-help-topics.test.ts +++ b/src/commands/schema/cli-help-topics.test.ts @@ -436,7 +436,23 @@ test('usageForCommand resolves remote help topic', async () => { assert.match(help, /AGENT_DEVICE_HTTP_AUTH_HOOK configured treats HTTP requests as remote/); assert.match(help, /host-path install sources are rejected/); assert.match(help, /uploaded artifacts remain supported/); - assert.match(help, /Limrun, BrowserStack, and AWS Device Farm through local provider profiles/); + assert.match( + help, + /Limrun, BrowserStack, AWS Device Farm, and TestMu AI through local provider profiles/, + ); + assert.match(help, /plugins add @agent-device\/testmu/); + assert.match(help, /TestMu AI uses LT_USERNAME and LT_ACCESS_KEY/); + const testMuFlow = help.slice( + help.indexOf('TestMu AI virtual-device flow'), + help.indexOf('BrowserStack hosted-device flow'), + ); + assert.match(testMuFlow, /--device "iPhone 16" --provider-os-version 18\.0/); + assert.match( + testMuFlow, + /connect testmu --provider-device-type real .*--provider-os-version 18 --provider-app \.\/MyApp\.ipa/, + ); + assert.match(testMuFlow, /major OS version \(18, not 18\.0\)/); + assert.match(testMuFlow, /agent-device disconnect/); assert.match(help, /Limrun uses LIMRUN_API_KEY/); assert.match(help, /BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY/); assert.match(help, /Generated connection profiles store app\/device selectors and ARNs/); diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 0dc5d845df..934a84fae9 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -581,17 +581,18 @@ Providers: Direct proxy: agent-device connect proxy --daemon-base-url stores the shared proxy profile and client identity. BrowserStack: agent-device connect browserstack verifies credentials, the exact device, and a bs:// app reference, then stores a local provider profile. It does not create an App Automate session. AWS Device Farm: agent-device connect aws-device-farm verifies credentials and the exact project, device, and optional app upload, then stores a local provider profile. It does not create a remote access session. + TestMu AI: agent-device connect testmu verifies credentials and the exact virtual device (emulator or simulator) or, with --provider-device-type real, real device and OS version, then stores a local provider profile. --provider-app takes an lt:// app reference, an https URL, or a local path: connect looks an lt:// id up among your uploads for that device pool, while URL and local sources are uploaded and validated when open creates the session. It does not create a hub session. Limrun: agent-device connect limrun verifies access to the selected iOS or Android instance service, then stores a local provider profile. It does not create an instance. After direct-provider connect: Read the printed Device, App, Next, and workflow-note lines. They are also available as verification/device/app/liveSession/nextSteps/notes in --json output. - BrowserStack and AWS Device Farm create the hosted session on open. open needs the installed package or bundle identifier, not the app artifact name or ARN. + BrowserStack, AWS Device Farm, and TestMu AI create the hosted session on open. open needs the installed package or bundle identifier, not the app artifact name, ARN, or lt:// id. Before provider allocation, apps lists compatible uploaded app assets without creating an instance when the selected provider exposes a catalog. open creates the instance with that asset, resolves its installed app id, and launches it. install remains available when the app comes from a fresh local path or URL. AWS Device Farm cannot install after allocation. If connect reports no attached app, run its printed reconnect command, which includes --session --force, before open. Do not run devices as a pre-open catalog probe for direct providers; it can allocate the deferred provider session. Limrun is the exception for apps: before allocation it lists uploaded assets for the selected platform. Device cloud interfaces: - CLI is the canonical bootstrap path: connect limrun/browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. + CLI is the canonical bootstrap path: connect limrun/browserstack/aws-device-farm/testmu, then use normal open/snapshot/click/close/artifacts/disconnect commands. JavaScript can skip persisted connect state by passing leaseProvider plus provider fields to createAgentDeviceClient or per-command options. MCP exposes operational tools such as open, snapshot, click, close, and artifacts. It does not expose connect/disconnect; run CLI connect first in the same state dir before relying on MCP tools. @@ -621,6 +622,20 @@ Cloud profile flow: agent-device snapshot agent-device disconnect +TestMu AI virtual-device flow (emulators and simulators): + agent-device plugins add @agent-device/testmu + export LT_USERNAME=... LT_ACCESS_KEY=... + agent-device connect testmu --platform ios --device "iPhone 16" --provider-os-version 18.0 --provider-app lt://APP-id + agent-device open com.example.app + agent-device snapshot -i + agent-device close + agent-device artifacts --json + agent-device disconnect + +TestMu AI real-device flow: + agent-device connect testmu --provider-device-type real --platform ios --device "iPhone 16" --provider-os-version 18 --provider-app ./MyApp.ipa + Real iOS devices are listed by major OS version (18, not 18.0) and install a signed .ipa. Real and virtual devices have separate upload APIs, so pass an lt:// id uploaded for the pool you connect to. + BrowserStack hosted-device flow: BROWSERSTACK_USERNAME=... BROWSERSTACK_ACCESS_KEY=... agent-device connect browserstack --platform android --device "Google Pixel 8" --provider-os-version 14.0 --provider-app bs://app-id @@ -669,13 +684,13 @@ Rules: Use connect without --remote-config when the cloud control plane owns the connection profile. Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. Use agent-device proxy for direct tunnel access to a Mac you control. Expose the printed proxy URL through cloudflared/ngrok, then run agent-device connect proxy with the tunnel URL and printed token before normal commands. - Use Limrun, BrowserStack, and AWS Device Farm through local provider profiles; they do not accept a remote agent-device daemon URL. - Device cloud credentials must be available before the command starts. Limrun uses LIMRUN_API_KEY, or the LIM_*_INSTANCE_* variables for an existing instance. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. - A local daemon keeps the Limrun and BrowserStack credentials it started with. When the shell holds different ones, the first command that allocates a lease refuses with reason provider-credentials-changed; run agent-device daemon stop with the same --state-dir, then rerun the command. A shell that sets none of them uses the daemon's. A daemon with an HTTP auth hook serves remote callers and does not compare. + Use Limrun, BrowserStack, AWS Device Farm, and TestMu AI through local provider profiles; they do not accept a remote agent-device daemon URL. + Device cloud credentials must be available before the command starts. Limrun uses LIMRUN_API_KEY, or the LIM_*_INSTANCE_* variables for an existing instance. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. TestMu AI uses LT_USERNAME and LT_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. + A local daemon keeps the Limrun, BrowserStack, and TestMu AI credentials it started with. When the shell holds different ones, the first command that allocates a lease refuses with reason provider-credentials-changed; run agent-device daemon stop with the same --state-dir, then rerun the command. A shell that sets none of them uses the daemon's. A daemon with an HTTP auth hook serves remote callers and does not compare. Direct-provider connect performs read-only provider calls and saves active connection state only after verification succeeds. It never creates a device, instance, App Automate session, or AWS remote access session. connect without --session always creates a fresh remote session and prints that session in its next-step commands. Concurrent callers must pass the returned --session on every command; the ambient active connection is only a single-workflow convenience. To replace an existing connection, pass its returned session explicitly with --session --force. --force without --session creates another fresh session and does not release or overwrite an unrelated active connection. - Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors and ARNs, not Limrun API keys or instance tokens, BrowserStack access keys, or AWS credentials. + Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors and ARNs, not Limrun API keys or instance tokens, BrowserStack or TestMu AI access keys, or AWS credentials. Limrun Android supports direct ADB port reverse for local Metro. Limrun iOS requires a public Metro/React DevTools URL because it cannot reach local host ports directly. After closing a device cloud session, run agent-device artifacts --json to retrieve provider video/log/dashboard URLs when the provider has made them available. connect proxy stores the connection profile and client identity. Proxy device leases are acquired on open and expire after five minutes without commands; devices may inspect proxy inventory without allocating. diff --git a/src/commands/schema/command-overrides.ts b/src/commands/schema/command-overrides.ts index 25e68ffbd1..5c50607014 100644 --- a/src/commands/schema/command-overrides.ts +++ b/src/commands/schema/command-overrides.ts @@ -75,7 +75,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 'Configure remote access without allocating a device. Direct providers validate credentials/resources before saving state and print the exact device/app preparation needed before open. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.', }, usageOverride: - 'connect [cloud|proxy|limrun|browserstack|aws-device-farm] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', + 'connect [provider] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', usageFlags: [], listUsageOverride: 'connect', positionalArgs: ['provider?'], @@ -88,6 +88,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { 'leaseBackend', 'providerApp', 'providerOsVersion', + 'providerDeviceType', 'providerProject', 'providerBuild', 'providerSessionName', diff --git a/src/daemon-client/daemon-client-rpc.test.ts b/src/daemon-client/daemon-client-rpc.test.ts index faa8c6e835..a8656d63f2 100644 --- a/src/daemon-client/daemon-client-rpc.test.ts +++ b/src/daemon-client/daemon-client-rpc.test.ts @@ -45,6 +45,7 @@ test('lease allocation transports the provider configuration the session needs ( device: 'iPhone 15', providerApp: 'bs://app-id', providerOsVersion: '17', + providerDeviceType: 'real', providerProject: 'MyProject', providerBuild: 'Build-2026-09-11', providerSessionName: 'smoke — iOS', @@ -72,6 +73,7 @@ test('lease allocation transports the provider configuration the session needs ( device: 'iPhone 15', providerApp: 'bs://app-id', providerOsVersion: '17', + providerDeviceType: 'real', providerProject: 'MyProject', providerBuild: 'Build-2026-09-11', providerSessionName: 'smoke — iOS', diff --git a/src/daemon/handlers/__tests__/lease.test.ts b/src/daemon/handlers/__tests__/lease.test.ts index 15f4a96325..b7f4e511f7 100644 --- a/src/daemon/handlers/__tests__/lease.test.ts +++ b/src/daemon/handlers/__tests__/lease.test.ts @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import { test } from 'vitest'; import { handleLeaseCommands } from '../lease.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; @@ -15,6 +17,7 @@ import { providerCredentialFingerprint, readDaemonProviderCredentials, } from '../../../provider-credential-fingerprint.ts'; +import { pluginHome, selectPluginManifest } from '../../../plugins/plugin.fixtures.ts'; import { HUMAN_CONTROL_LEASE_REQUEST, HUMAN_CONTROL_SCOPE, @@ -525,6 +528,63 @@ test('a daemon started without BrowserStack credentials refuses a shell that has assert.match(String(outcome.error?.message), /started without the browserstack credentials/); }); +function testMuPluginEnv(): Record { + const manifest = path.resolve( + import.meta.dirname, + '../../../../packages/provider-testmu/package.json', + ); + const { home, env } = pluginHome(); + selectPluginManifest(home, JSON.parse(fs.readFileSync(manifest, 'utf8'))); + return env; +} + +test('a daemon holding other TestMu AI credentials refuses before allocation', async () => { + const home = testMuPluginEnv(); + const daemonEnv = { ...home, LT_USERNAME: 'user', LT_ACCESS_KEY: 'key-1' }; + const outcome = await allocateWithDaemonEnv( + providerAllocateRequest( + 'testmu', + providerCredentialFingerprint('testmu', { ...daemonEnv, LT_ACCESS_KEY: 'key-2' }), + ), + daemonEnv, + ); + + assert.equal(outcome.allocations, 0); + assert.equal(outcome.error?.code, 'INVALID_ARGS'); + assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); + assert.equal(outcome.error?.details?.provider, 'testmu'); +}); + +test('a daemon holding the TestMu AI credentials of the shell allocates', async () => { + const env = { ...testMuPluginEnv(), LT_USERNAME: 'user', LT_ACCESS_KEY: 'key-1' }; + const outcome = await allocateWithDaemonEnv( + providerAllocateRequest('testmu', providerCredentialFingerprint('testmu', env)), + env, + ); + + assert.equal(outcome.error, undefined); + assert.equal(outcome.allocations, 1); +}); + +test('a daemon started without TestMu AI credentials refuses a shell that has them', async () => { + const home = testMuPluginEnv(); + const outcome = await allocateWithDaemonEnv( + providerAllocateRequest( + 'testmu', + providerCredentialFingerprint('testmu', { + ...home, + LT_USERNAME: 'user', + LT_ACCESS_KEY: 'key-1', + }), + ), + home, + ); + + assert.equal(outcome.allocations, 0); + assert.equal(outcome.error?.details?.reason, 'provider-credentials-changed'); + assert.match(String(outcome.error?.message), /started without the testmu credentials/); +}); + test('a tenant cannot allocate a macos-app lease', async () => { const registry = new LeaseRegistry(); const request: DaemonRequest = { diff --git a/src/daemon/handlers/session-doctor-options.ts b/src/daemon/handlers/session-doctor-options.ts index 4c9f6eb1de..7481ac986d 100644 --- a/src/daemon/handlers/session-doctor-options.ts +++ b/src/daemon/handlers/session-doctor-options.ts @@ -20,6 +20,7 @@ const REMOTE_PROVIDER_FLAG_KEYS = [ 'providerSessionId', 'providerApp', 'providerOsVersion', + 'providerDeviceType', 'providerProject', 'providerBuild', 'providerSessionName', diff --git a/src/plugins/connection.ts b/src/plugins/connection.ts new file mode 100644 index 0000000000..295f8f8cbe --- /dev/null +++ b/src/plugins/connection.ts @@ -0,0 +1,34 @@ +import type { CliFlags } from '@agent-device/contracts/command'; +import type { + ConnectionProviderCapabilities, + ProviderConnectionVerification, +} from '@agent-device/contracts/remote'; +import type { EnvMap } from '@agent-device/kernel/source-value'; +import type { RemoteConfigProfile } from '../remote/remote-config-schema.ts'; +import { installedPlugins } from './store.ts'; + +export type PluginConnection = Readonly<{ + resolve(context: { flags: CliFlags; stateDir: string; cwd: string; env: EnvMap }): + | Promise<{ + profile: RemoteConfigProfile; + extraFlags?: Partial; + }> + | { profile: RemoteConfigProfile; extraFlags?: Partial }; + verify(context: { flags: CliFlags; env: EnvMap }): Promise; +}>; + +export function pluginConnectionCapabilities( + provider: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): ConnectionProviderCapabilities | undefined { + return provider === undefined + ? undefined + : installedPlugins(env).find((plugin) => plugin.agentDevicePlugin.provider === provider) + ?.agentDevicePlugin.connection; +} + +export function pluginConnectionNames(env: NodeJS.ProcessEnv = process.env): string[] { + return installedPlugins(env) + .filter((plugin) => plugin.agentDevicePlugin.connection) + .map((plugin) => plugin.agentDevicePlugin.provider); +} diff --git a/src/plugins/host.ts b/src/plugins/host.ts new file mode 100644 index 0000000000..3021c710ae --- /dev/null +++ b/src/plugins/host.ts @@ -0,0 +1,15 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { readVersion } from '@agent-device/host-kit/version'; +import type { ProviderPluginHost } from '../sdk/plugins.ts'; + +export function createPluginHost( + env: NodeJS.ProcessEnv, + options: Record | undefined, +): ProviderPluginHost { + return Object.freeze({ + env: Object.freeze({ ...env }), + options: Object.freeze({ ...options }), + clientVersion: readVersion(), + createError: (code, message, details) => new AppError(code, message, details), + } satisfies ProviderPluginHost); +} diff --git a/src/plugins/load.test.ts b/src/plugins/load.test.ts index 64afbc9f44..3bf512dee2 100644 --- a/src/plugins/load.test.ts +++ b/src/plugins/load.test.ts @@ -2,11 +2,30 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import { loadProviderPlugins } from './load.ts'; -import { pluginHome, selectPlugin, registrationSource } from './plugin.fixtures.ts'; +import { loadProviderPlugins, withPluginConnection } from './load.ts'; +import { + pluginHome, + selectPlugin, + registrationSource, + webDriverPluginSource, +} from './plugin.fixtures.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { ProviderPluginHost } from '../sdk/plugins.ts'; -import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import type { DeviceLease, ProviderDeviceRuntime } from '@agent-device/contracts/device'; + +const realFetch = globalThis.fetch; +const lease: DeviceLease = { + leaseId: 'lease-1', + tenantId: 'team-a', + runId: 'run-a', + clientId: 'client-a', + leaseProvider: 'example', + backend: 'android-instance', + deviceKey: 'example:device-a', + createdAt: 1, + expiresAt: 2, + heartbeatAt: 1, +}; test('startup loads the factory with options and the host error constructor', async () => { const { home, env } = pluginHome(); @@ -62,3 +81,83 @@ test('cleanup throwing synchronously preserves the factory failure', async () => await assert.rejects(loadProviderPlugins(env, []), /factory failed/); assert.ok(fs.existsSync(marker)); }); + +test('connection callbacks run from the installed plugin and always release runtimes', async () => { + const { home, env } = pluginHome(); + const marker = path.join(home, 'shutdown'); + const source = registrationSource('example', marker).replace( + 'platformModule:', + "connection: { resolve: () => ({ profile: { leaseProvider: 'example', platform: 'android' } }), verify: async () => { throw host.createError('COMMAND_FAILED', 'verification failed'); } }, platformModule:", + ); + selectPlugin(home, 'example', 'example', source); + selectPlugin(home, 'unrelated', 'unrelated', 'throw new Error("unrelated plugin evaluated");'); + const profile = await withPluginConnection( + 'example', + env, + async (connection) => + await connection.resolve({ + flags: { json: false, help: false, version: false }, + stateDir: home, + cwd: home, + env, + }), + ); + assert.equal(profile.profile.platform, 'android'); + assert.ok(fs.existsSync(marker)); + fs.unlinkSync(marker); + await assert.rejects( + withPluginConnection( + 'example', + env, + async (connection) => + await connection.verify({ flags: { json: false, help: false, version: false }, env }), + ), + { code: 'COMMAND_FAILED' }, + ); + assert.ok(fs.existsSync(marker)); +}); + +test('WebDriver plugins allocate through the shared engine and refuse mismatched providers', async () => { + const { home, env } = pluginHome(); + const source = webDriverPluginSource('example', ['awsProjectArn']); + selectPlugin(home, 'example', 'example', source); + const [registration] = await loadProviderPlugins(env, []); + const runtime = registration!.runtime; + assert.equal(runtime.provider, 'example'); + assert.equal(registration!.platformModule.owner.provider, 'example'); + const requests: string[] = []; + globalThis.fetch = async (input, init) => { + requests.push(`${init?.method ?? 'GET'} ${String(input)}`); + return new Response(JSON.stringify({ value: { sessionId: 'SESSION1', capabilities: {} } })); + }; + try { + await assert.rejects( + runtime.leaseLifecycle.allocate!(lease, { flags: { awsProjectArn: 'arn:project' } }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.details?.provider, 'example'); + assert.deepEqual(error.details?.flags, ['--aws-project-arn']); + return true; + }, + ); + assert.deepEqual(requests, []); + const allocated = await runtime.leaseLifecycle.allocate!(lease, { flags: {} }); + assert.equal(allocated?.sessionId, 'SESSION1'); + assert.deepEqual(requests, ['POST https://webdriver.test/wd/hub/session']); + } finally { + await runtime.shutdown(); + globalThis.fetch = realFetch; + } + const other = pluginHome(); + selectPlugin(other.home, 'example', 'wrong', source); + await assert.rejects(loadProviderPlugins(other.env, []), { code: 'INVALID_ARGS' }); +}); + +test('a factory returning a primitive is refused as an invalid plugin', async () => { + for (const value of ['42', '"runtime"', 'true']) { + const { home, env } = pluginHome(); + selectPlugin(home, 'example', 'example', `export default () => ${value};`); + await assert.rejects(loadProviderPlugins(env, []), { code: 'INVALID_ARGS' }); + } +}); diff --git a/src/plugins/load.ts b/src/plugins/load.ts index 024b7a9573..f9b2d6472b 100644 --- a/src/plugins/load.ts +++ b/src/plugins/load.ts @@ -4,60 +4,36 @@ import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { PlatformRuntimeProviderModule } from '@agent-device/contracts/platform-runtime-operations'; import type { ProviderPluginHost } from '../sdk/plugins.ts'; import { installedPlugins } from './store.ts'; -import { resolvePluginEntry, assertUniquePluginProviders } from './manifest.ts'; +import { + resolvePluginEntry, + assertUniquePluginProviders, + RESERVED_PLUGIN_PROVIDERS, +} from './manifest.ts'; +import { createPluginHost } from './host.ts'; +import type { PluginConnection } from './connection.ts'; +import type { WebDriverPluginOptions } from '../sdk/plugin-webdriver.ts'; type ProviderPluginRegistration = Readonly<{ runtime: ProviderDeviceRuntime; platformModule: PlatformRuntimeProviderModule; + connection?: PluginConnection; }>; export async function loadProviderPlugins( env: NodeJS.ProcessEnv, reservedProviders: readonly string[], + onlyProvider?: string, ): Promise { const plugins = installedPlugins(env); assertUniquePluginProviders(plugins, reservedProviders); const registrations: ProviderPluginRegistration[] = []; try { - for (const plugin of plugins) { - const module = await import( - pathToFileURL(resolvePluginEntry(plugin.directory, plugin.agentDevicePlugin.entry)).href - ); - if (typeof module.default !== 'function') - throw new AppError('INVALID_ARGS', `Plugin must export a default factory: ${plugin.name}`); - const registration = await ( - module.default as ( - host: ProviderPluginHost, - ) => ProviderPluginRegistration | Promise - )( - Object.freeze({ - env: Object.freeze({ ...env }), - options: Object.freeze({ ...plugin.selection.options }), - createError: (code, message, details) => new AppError(code, message, details), - }), - ); - if (!registration?.runtime || typeof registration.runtime.shutdown !== 'function') { - throw new AppError('INVALID_ARGS', `Plugin must return a provider runtime: ${plugin.name}`); - } + for (const plugin of plugins.filter( + (plugin) => onlyProvider === undefined || plugin.agentDevicePlugin.provider === onlyProvider, + )) { + const registration = await instantiateProviderPlugin(plugin, env); registrations.push(registration); - if ( - registration.runtime.provider !== plugin.agentDevicePlugin.provider || - typeof registration.runtime.ownsDevice !== 'function' || - typeof registration.runtime.getInteractor !== 'function' || - typeof registration.runtime.deviceInventoryProvider !== 'function' || - !registration.runtime.leaseLifecycle || - typeof registration.runtime.leaseLifecycle !== 'object' || - registration.platformModule?.owner?.kind !== 'provider-runtime' || - registration.platformModule.owner.provider !== registration.runtime.provider || - typeof registration.platformModule.owner.instance !== 'string' || - registration.platformModule.owner.instance.trim().length === 0 || - typeof registration.platformModule.loadRuntime !== 'function' - ) { - throw new AppError( - 'INVALID_ARGS', - `Plugin runtime owner does not match its declaration: ${plugin.name}`, - ); - } + validateProviderPlugin(registration, plugin); } return registrations; } catch (error) { @@ -65,3 +41,113 @@ export async function loadProviderPlugins( throw error; } } + +export async function withPluginConnection( + provider: string, + env: NodeJS.ProcessEnv, + runConnection: (connection: PluginConnection) => Promise, +): Promise { + const registrations = await loadProviderPlugins(env, RESERVED_PLUGIN_PROVIDERS, provider); + try { + const connection = registrations.find( + (entry) => entry.runtime.provider === provider, + )?.connection; + if (!connection) + throw new AppError('INVALID_ARGS', `Plugin does not register connect: ${provider}`); + return await runConnection(connection); + } finally { + await Promise.allSettled(registrations.map(async ({ runtime }) => await runtime.shutdown())); + } +} + +async function instantiateProviderPlugin( + plugin: ReturnType[number], + env: NodeJS.ProcessEnv, +): Promise { + const module = await import( + pathToFileURL(resolvePluginEntry(plugin.directory, plugin.agentDevicePlugin.entry)).href + ); + if (typeof module.default !== 'function') + throw new AppError('INVALID_ARGS', `Plugin must export a default factory: ${plugin.name}`); + const host = createPluginHost(env, plugin.selection.options); + const result = await ( + module.default as ( + host: ProviderPluginHost, + ) => + | ProviderPluginRegistration + | { webDriver: WebDriverPluginOptions; connection?: PluginConnection } + | Promise< + | ProviderPluginRegistration + | { webDriver: WebDriverPluginOptions; connection?: PluginConnection } + > + )(host); + let registration: ProviderPluginRegistration; + if (isWebDriverPluginResult(result)) { + if (result.webDriver?.provider !== plugin.agentDevicePlugin.provider) { + throw new AppError( + 'INVALID_ARGS', + `WebDriver plugin provider does not match its declaration: ${plugin.name}`, + ); + } + const { createCloudWebDriverRuntime } = await import('@agent-device/provider-webdriver/plugin'); + const runtime = await createCloudWebDriverRuntime({ + ...result.webDriver, + clientVersion: host.clientVersion, + }); + registration = { + runtime, + platformModule: runtime.platformRuntimeModule, + connection: result.connection, + }; + } else registration = result; + if (!registration?.runtime || typeof registration.runtime.shutdown !== 'function') { + throw new AppError('INVALID_ARGS', `Plugin must return a provider runtime: ${plugin.name}`); + } + return registration; +} + +function isWebDriverPluginResult( + result: unknown, +): result is { webDriver: WebDriverPluginOptions; connection?: PluginConnection } { + return typeof result === 'object' && result !== null && 'webDriver' in result; +} + +function validateProviderPlugin( + registration: ProviderPluginRegistration, + plugin: ReturnType[number], +): void { + if ( + registration.runtime.provider !== plugin.agentDevicePlugin.provider || + !hasProviderFacets(registration.runtime) || + !matchesRuntimeOwner(registration) || + (plugin.agentDevicePlugin.connection && + (typeof registration.connection?.resolve !== 'function' || + typeof registration.connection?.verify !== 'function')) + ) { + throw new AppError( + 'INVALID_ARGS', + `Plugin runtime owner does not match its declaration: ${plugin.name}`, + ); + } +} + +function matchesRuntimeOwner(registration: ProviderPluginRegistration): boolean { + const owner = registration.platformModule?.owner; + return ( + owner?.kind === 'provider-runtime' && + owner.provider === registration.runtime.provider && + typeof owner.instance === 'string' && + owner.instance.trim().length > 0 && + typeof registration.platformModule.loadRuntime === 'function' + ); +} + +function hasProviderFacets(runtime: ProviderDeviceRuntime): boolean { + return ( + runtime.leaseLifecycle !== null && + typeof runtime.leaseLifecycle === 'object' && + (['ownsDevice', 'getInteractor', 'deviceInventoryProvider'] as const).every( + (method) => typeof runtime[method] === 'function', + ) + ); +} diff --git a/src/plugins/manifest.test.ts b/src/plugins/manifest.test.ts index a7763c828a..58767eb244 100644 --- a/src/plugins/manifest.test.ts +++ b/src/plugins/manifest.test.ts @@ -2,7 +2,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import { readPluginManifest } from './manifest.ts'; +import { readPluginManifest, RESERVED_PLUGIN_PROVIDERS } from './manifest.ts'; +import { DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS } from '../provider-device-runtimes.ts'; import { pluginHome, writePlugin } from './plugin.fixtures.ts'; test('manifest compatibility is checked without evaluating plugin code', () => { @@ -33,3 +34,59 @@ test('manifest refuses traversal and symlink entries outside the installed packa fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify(manifest)); assert.throws(() => readPluginManifest(directory), { code: 'INVALID_ARGS' }); }); + +test('connection metadata admits local providers and rejects malformed or remote policies', () => { + const { home } = pluginHome(); + const directory = writePlugin(home); + const file = path.join(directory, 'package.json'); + const manifest = JSON.parse(fs.readFileSync(file, 'utf8')); + const connection = { + leaseKind: 'direct-device-provider', + requiresAppAttachment: true, + requiresRemoteDaemon: false, + supportsArtifacts: true, + supportsDeferredAppSelection: false, + supportsDirectPortReverse: false, + usesCloudWebDriverLease: true, + }; + const read = (policy: unknown) => { + manifest.agentDevicePlugin.connection = policy; + fs.writeFileSync(file, JSON.stringify(manifest)); + return readPluginManifest(directory); + }; + assert.deepEqual(read(connection).agentDevicePlugin.connection, connection); + for (const invalid of [ + null, + {}, + { ...connection, leaseKind: 'remote-daemon' }, + { ...connection, requiresRemoteDaemon: true }, + { ...connection, supportsArtifacts: 'true' }, + { ...connection, supportsDeferredAppSelection: undefined }, + ]) { + assert.throws(() => read(invalid), { code: 'INVALID_ARGS' }); + } +}); + +test('credential variables are optional environment variable names', () => { + const { home } = pluginHome(); + const directory = writePlugin(home); + const file = path.join(directory, 'package.json'); + const manifest = JSON.parse(fs.readFileSync(file, 'utf8')); + const read = (credentialVariables: unknown) => { + manifest.agentDevicePlugin.credentialVariables = credentialVariables; + fs.writeFileSync(file, JSON.stringify(manifest)); + return readPluginManifest(directory); + }; + assert.equal(read(undefined).agentDevicePlugin.credentialVariables, undefined); + assert.deepEqual(read(['EXAMPLE_USER', '_KEY_2']).agentDevicePlugin.credentialVariables, [ + 'EXAMPLE_USER', + '_KEY_2', + ]); + for (const invalid of [null, 'EXAMPLE_USER', {}, [''], ['example_user'], ['2KEY'], ['A-B'], [1]]) + assert.throws(() => read(invalid), { code: 'INVALID_ARGS' }, JSON.stringify(invalid)); +}); + +test('every bundled provider runtime is reserved from plugins', () => { + for (const provider of DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS) + assert.ok((RESERVED_PLUGIN_PROVIDERS as readonly string[]).includes(provider), provider); +}); diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 44b6d2a1c9..87fd0962ec 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -1,19 +1,39 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; +import type { ConnectionProviderCapabilities } from '@agent-device/contracts/remote'; + +import { + CLOUD_WEBDRIVER_PROVIDERS, + type CloudWebDriverKnownProviderName, +} from '@agent-device/provider-webdriver/providers'; + +/** The one list of provider ids plugins cannot claim: connect routes and bundled runtimes. */ +export const RESERVED_PLUGIN_PROVIDERS: readonly ( + | 'cloud' + | 'proxy' + | 'limrun' + | CloudWebDriverKnownProviderName +)[] = ['cloud', 'proxy', ...Object.values(CLOUD_WEBDRIVER_PROVIDERS), 'limrun']; const PROVIDER_PLUGIN_API_VERSION = 1; type PluginManifest = { name: string; version: string; - agentDevicePlugin: { apiVersion: number; provider: string; entry: string }; + agentDevicePlugin: { + apiVersion: number; + provider: string; + entry: string; + connection?: ConnectionProviderCapabilities; + credentialVariables?: string[]; + }; }; export function assertUniquePluginProviders( plugins: readonly PluginManifest[], reserved: readonly string[], ): void { - const providers = new Set(reserved); + const providers = new Set(reserved); for (const plugin of plugins) { const provider = plugin.agentDevicePlugin.provider; if (providers.has(provider)) @@ -53,9 +73,47 @@ export function readPluginManifest(directory: string): PluginManifest { }); } resolvePluginEntry(directory, declaration.entry); + assertLocalConnectionPolicy(declaration.connection); + assertCredentialVariables(declaration.credentialVariables); return manifest as PluginManifest; } +function assertLocalConnectionPolicy(policy: ConnectionProviderCapabilities | undefined): void { + if (policy === undefined) return; + if ( + !policy || + typeof policy !== 'object' || + policy.leaseKind !== 'direct-device-provider' || + [ + 'requiresAppAttachment', + 'requiresRemoteDaemon', + 'supportsArtifacts', + 'supportsDeferredAppSelection', + 'supportsDirectPortReverse', + 'usesCloudWebDriverLease', + ].some((key) => typeof policy[key as keyof ConnectionProviderCapabilities] !== 'boolean') || + policy.requiresRemoteDaemon + ) { + throw new AppError( + 'INVALID_ARGS', + 'Plugin connection must declare local provider capabilities', + ); + } +} + +function assertCredentialVariables(variables: unknown): void { + if ( + variables !== undefined && + (!Array.isArray(variables) || + !variables.every((name) => typeof name === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(name))) + ) { + throw new AppError( + 'INVALID_ARGS', + 'Plugin credentialVariables must list environment variable names', + ); + } +} + export function resolvePluginEntry(directory: string, entry: string): string { try { const root = fs.realpathSync(directory); diff --git a/src/plugins/plugin.fixtures.ts b/src/plugins/plugin.fixtures.ts index 38b80443f9..00e6ef75e1 100644 --- a/src/plugins/plugin.fixtures.ts +++ b/src/plugins/plugin.fixtures.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import type { ProviderProfileField } from '@agent-device/contracts/provider-profile-fields'; import { mkdtempForTestSync } from '../__tests__/test-utils/tmp-dir.ts'; export function pluginHome() { @@ -39,6 +40,28 @@ export function selectPlugin( ) { const installation = crypto.randomUUID(); writePlugin(path.join(home, 'plugins', installation), name, apiVersion, provider, source); + recordSelection(home, name, installation); +} + +/** Installs `manifest` as-is, with an entry file that throws if evaluated. */ +export function selectPluginManifest( + home: string, + manifest: { + name: string; + version: string; + agentDevicePlugin: Record & { entry: string }; + }, +) { + const installation = crypto.randomUUID(); + const directory = path.join(home, 'plugins', installation, 'node_modules', manifest.name); + const entry = path.join(directory, manifest.agentDevicePlugin.entry); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(path.join(directory, 'package.json'), JSON.stringify(manifest)); + fs.writeFileSync(entry, 'throw new Error("evaluated");'); + recordSelection(home, manifest.name, installation); +} + +function recordSelection(home: string, name: string, installation: string) { const configPath = path.join(home, 'config.json'); const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf8')) : {}; config.plugins ??= {}; @@ -58,3 +81,49 @@ export function registrationSource(provider: string, shutdownFile?: string) { loadRuntime: async () => { throw new Error('lazy'); } } });`; } + +const CONSUMED_PROFILE_FIELDS: Record = { + providerApp: 'consumed', + providerOsVersion: 'consumed', + providerDeviceType: 'consumed', + providerProject: 'consumed', + providerBuild: 'consumed', + providerSessionName: 'consumed', + providerDeviceOrientation: 'consumed', + providerGeoLocation: 'consumed', + providerTimezone: 'consumed', + providerAppiumVersion: 'consumed', + providerLanguage: 'consumed', + providerLocale: 'consumed', + providerNetworkProfile: 'consumed', + providerCustomNetwork: 'consumed', + providerNoResignApp: 'consumed', + awsProjectArn: 'consumed', + awsDeviceArn: 'consumed', + awsAppArn: 'consumed', + awsRegion: 'consumed', + awsInteractionMode: 'consumed', +}; + +/** A `{ webDriver }` factory with a total field declaration; `connection` is a JS expression. */ +export function webDriverPluginSource( + provider: string, + refused: readonly ProviderProfileField[] = [], + connection?: string, +) { + const fields = { + ...CONSUMED_PROFILE_FIELDS, + ...Object.fromEntries(refused.map((field) => [field, 'refused'])), + }; + const webDriver = { + provider, + endpoint: 'https://webdriver.test/wd/hub/', + platform: 'android', + deviceName: provider, + profileFields: { provider, label: provider, fields }, + requestPolicy: { retryAttempts: 0 }, + }; + return `export default (host) => ({ webDriver: ${JSON.stringify(webDriver)}${ + connection ? `, connection: ${connection}` : '' + } });`; +} diff --git a/src/plugins/store.ts b/src/plugins/store.ts index 4435a624cb..ec50f038a1 100644 --- a/src/plugins/store.ts +++ b/src/plugins/store.ts @@ -7,7 +7,11 @@ import { runCmd } from '@agent-device/host-kit/command'; import { acquireProcessLock, publishFileSync } from '@agent-device/host-kit/file'; import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; import { resolveUserConfigPath } from '../commands/schema/cli-config.ts'; -import { readPluginManifest, assertUniquePluginProviders } from './manifest.ts'; +import { + readPluginManifest, + assertUniquePluginProviders, + RESERVED_PLUGIN_PROVIDERS, +} from './manifest.ts'; type PluginSelection = { installation: string; @@ -136,7 +140,7 @@ export async function changePlugin( action: PluginAction, input: string, env: NodeJS.ProcessEnv = process.env, - reservedProviders: readonly string[] = [], + reservedProviders: readonly string[] = RESERVED_PLUGIN_PROVIDERS, ): Promise { const { name, version } = parsePluginRequest(action, input); const configPath = resolveUserConfigPath(env); diff --git a/src/provider-credential-fingerprint.test.ts b/src/provider-credential-fingerprint.test.ts index a73beacb14..6ce107c060 100644 --- a/src/provider-credential-fingerprint.test.ts +++ b/src/provider-credential-fingerprint.test.ts @@ -3,6 +3,7 @@ import { providerCredentialFingerprint, readDaemonProviderCredentials, } from './provider-credential-fingerprint.ts'; +import { pluginHome, selectPluginManifest } from './plugins/plugin.fixtures.ts'; const BROWSERSTACK_ENV = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KEY: 'key-1' }; @@ -58,7 +59,8 @@ test('a fingerprint hashes the exact values each provider reads', () => { }); test('a provider name that only matches an inherited object key has no fingerprint', () => { - expect(providerCredentialFingerprint('constructor', BROWSERSTACK_ENV)).toBe(undefined); + const env = { ...pluginHome().env, ...BROWSERSTACK_ENV }; + expect(providerCredentialFingerprint('constructor', env)).toBe(undefined); }); test('AWS Device Farm has no environment fingerprint', () => { @@ -109,3 +111,44 @@ test('a Limrun lease fingerprint covers only the leased platform and the account readDaemonProviderCredentials(before, '/state').fingerprint('limrun', 'ios-instance'), ).toBe(ios(rotatedAndroid)); }); + +function pluginWithCredentialVariables(credentialVariables?: string[], provider = 'example') { + const { home, env } = pluginHome(); + selectPluginManifest(home, { + name: '@example/provider', + version: '1.2.3', + agentDevicePlugin: { apiVersion: 1, provider, entry: './plugin.js', credentialVariables }, + }); + return env; +} + +test('a plugin provider fingerprint hashes the variables its manifest declares', () => { + const home = pluginWithCredentialVariables(['EXAMPLE_USER', 'EXAMPLE_KEY']); + const env = { ...home, EXAMPLE_USER: 'user', EXAMPLE_KEY: 'key-1' }; + const fingerprint = providerCredentialFingerprint('example', env); + + expect(fingerprint).toMatch(/^v1:[0-9a-f]{16}$/); + expect(providerCredentialFingerprint('example', { ...env, UNRELATED: 'x' })).toBe(fingerprint); + expect(providerCredentialFingerprint('example', { ...env, EXAMPLE_KEY: 'key-2' })).not.toBe( + fingerprint, + ); + expect(providerCredentialFingerprint('example', { ...env, EXAMPLE_KEY: 'key-1 ' })).not.toBe( + fingerprint, + ); + expect(providerCredentialFingerprint('example', { ...home, EXAMPLE_USER: ' ' })).toBe(undefined); + expect(readDaemonProviderCredentials(env, '/state').fingerprint('example')).toBe(fingerprint); +}); + +test('a plugin provider without declared credential variables has no fingerprint', () => { + const env = { ...pluginWithCredentialVariables(), EXAMPLE_USER: 'user' }; + expect(providerCredentialFingerprint('example', env)).toBe(undefined); + expect(providerCredentialFingerprint('other', env)).toBe(undefined); +}); + +test('a plugin declaration never adds a fingerprint to a bundled provider', () => { + const env = { + ...pluginWithCredentialVariables(['AWS_ACCESS_KEY_ID'], 'aws-device-farm'), + AWS_ACCESS_KEY_ID: 'id', + }; + expect(providerCredentialFingerprint('aws-device-farm', env)).toBe(undefined); +}); diff --git a/src/provider-credential-fingerprint.ts b/src/provider-credential-fingerprint.ts index d6d12ee404..df2cf4ce69 100644 --- a/src/provider-credential-fingerprint.ts +++ b/src/provider-credential-fingerprint.ts @@ -7,6 +7,8 @@ import { import type { LIMRUN_PROVIDER } from '@agent-device/provider-limrun'; import type { EnvMap } from '@agent-device/kernel/source-value'; import { readLimrunCredentialValues } from './provider-limrun-credentials.ts'; +import { RESERVED_PLUGIN_PROVIDERS } from './plugins/manifest.ts'; +import { installedPlugins } from './plugins/store.ts'; type CredentialValues = Readonly>; @@ -40,7 +42,23 @@ export function providerCredentialFingerprint( leaseBackend?: string, ): string | undefined { const read = PROVIDER_CREDENTIAL_READERS.get(provider); - return read ? digest(read(env, leaseBackend)) : undefined; + if (read) return digest(read(env, leaseBackend)); + // Whitespace-only counts as unset and other values are kept as is, as the plugin's requireEnv does. + const variables = pluginCredentialVariables(provider, env); + return variables + ? digest( + Object.fromEntries( + variables.map((name) => [name, env[name]?.trim() ? env[name] : undefined]), + ), + ) + : undefined; +} + +// Read from the manifest, so neither the client nor the daemon evaluates plugin code. +function pluginCredentialVariables(provider: string, env: EnvMap): readonly string[] | undefined { + if ((RESERVED_PLUGIN_PROVIDERS as readonly string[]).includes(provider)) return undefined; + return installedPlugins(env).find((plugin) => plugin.agentDevicePlugin.provider === provider) + ?.agentDevicePlugin.credentialVariables; } /** The provider credentials a daemon started with, and the state dir that names that daemon. */ diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 3d2e0f8a3b..df925fb3d3 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -49,8 +49,11 @@ export async function createDaemonProviderRuntimeComposition( ): Promise { const bundled = await createDefaultProviderRuntimeComposition(env); try { - const { loadProviderPlugins } = await import('./plugins/load.ts'); - const plugins = await loadProviderPlugins(env, DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS); + const [{ loadProviderPlugins }, { RESERVED_PLUGIN_PROVIDERS }] = await Promise.all([ + import('./plugins/load.ts'), + import('./plugins/manifest.ts'), + ]); + const plugins = await loadProviderPlugins(env, RESERVED_PLUGIN_PROVIDERS); return Object.freeze({ ...bundled, runtimes: Object.freeze([...bundled.runtimes, ...plugins.map(({ runtime }) => runtime)]), diff --git a/src/remote/remote-config-schema.ts b/src/remote/remote-config-schema.ts index b71d8595e7..49f06dcfbc 100644 --- a/src/remote/remote-config-schema.ts +++ b/src/remote/remote-config-schema.ts @@ -1,5 +1,6 @@ import { PROVIDER_DEVICE_ORIENTATIONS, + PROVIDER_DEVICE_TYPES, type CloudProviderProfileFields, type RemoteConfigMetroOptions, type RemoteConnectionProfileFields, @@ -75,6 +76,7 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ { key: 'session', type: 'string' }, { key: 'providerApp', type: 'string' }, { key: 'providerOsVersion', type: 'string' }, + { key: 'providerDeviceType', type: 'enum', enumValues: PROVIDER_DEVICE_TYPES }, { key: 'providerProject', type: 'string' }, { key: 'providerBuild', type: 'string' }, { key: 'providerSessionName', type: 'string' }, diff --git a/src/sdk/plugin-webdriver.ts b/src/sdk/plugin-webdriver.ts new file mode 100644 index 0000000000..1f43c9f016 --- /dev/null +++ b/src/sdk/plugin-webdriver.ts @@ -0,0 +1,3 @@ +import type { CloudWebDriverRuntimeOptions } from '@agent-device/provider-webdriver/plugin'; + +export type WebDriverPluginOptions = Omit; diff --git a/src/sdk/plugins.ts b/src/sdk/plugins.ts index 188481315e..8cbc13d56c 100644 --- a/src/sdk/plugins.ts +++ b/src/sdk/plugins.ts @@ -3,5 +3,6 @@ import type { AppError, AppErrorCode, AppErrorDetails } from '@agent-device/kern export type ProviderPluginHost = Readonly<{ env: Readonly>; options: Readonly>; + clientVersion: string; createError(code: AppErrorCode, message: string, details?: AppErrorDetails): AppError; }>; diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index 0253ca03e6..c76c99197f 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -228,6 +228,10 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi const pluginTypes = fs.readFileSync(path.join(installedPackageRoot, 'dist/src/plugins.d.ts')); assert.ok(pluginTypes.length < 1024); + const webDriverPluginTypes = fs.readFileSync( + path.join(installedPackageRoot, 'dist/src/plugins/webdriver.d.ts'), + ); + assert.ok(webDriverPluginTypes.length < 5120); assert.match(pluginTypes.toString(), /export \{ ProviderPluginHost \}/); metroPort = await listenOnLoopback(metroServer); t.after(async () => { @@ -327,6 +331,7 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi }, './metro': (mod) => mod.buildBundleUrl('https://public.example.test', 'ios'), './plugins': (mod) => Object.keys(mod).length === 0, + './plugins/webdriver': (mod) => Object.keys(mod).length === 0, './remote-config': (mod) => typeof mod, './selectors': (mod) => mod.isSelectorToken('||') && @@ -404,6 +409,7 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi './io': 'function', './limrun': 'limrun', './plugins': true, + './plugins/webdriver': true, // Type-only subpath: resolving the module from the packed exports map is // the entire runtime check. './remote-config': 'object', diff --git a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts index 31e5d18072..58b7debefd 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -14,6 +14,7 @@ import type { LeaseLifecycleContext, ProviderDeviceRuntime, } from '@agent-device/contracts/device'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { CloudArtifactsResult } from '@agent-device/contracts/observability'; import { withProviderScenarioResource, withProviderScenarioTempDir } from './harness.ts'; import { @@ -24,6 +25,10 @@ import { type StartedCloudWebDriverTestServer, } from './cloud-webdriver-test-server.ts'; +import testMuPlugin from '@agent-device/testmu'; +import { createPluginHost } from '../../../src/plugins/host.ts'; +import { createCloudWebDriverRuntime } from '@agent-device/provider-webdriver/plugin'; + const CLIENT_VERSION = '0.20.3-test'; test('BrowserStack facade prepares capabilities, uploads apps, and returns artifacts', async () => { @@ -188,6 +193,135 @@ test('AWS Device Farm facade rejects device features it does not read at session }); }, 15_000); +test('TestMu facade routes a real-device session to the real pool and its upload API', async () => { + await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { + const registration = testMuPlugin( + createPluginHost( + { + LT_USERNAME: 'user', + LT_ACCESS_KEY: 'key', + TESTMU_WEBDRIVER_ENDPOINT: `${server.url}/wd/hub/`, + TESTMU_APP_UPLOAD_ENDPOINT: `${server.url}/lt/upload/virtualDevice`, + TESTMU_REAL_DEVICE_APP_UPLOAD_ENDPOINT: `${server.url}/lt/upload/realDevice`, + }, + undefined, + ), + ); + const runtime = await createCloudWebDriverRuntime({ + ...registration.webDriver, + clientVersion: CLIENT_VERSION, + }); + const lease = makeLease('testmu'); + try { + await runtime.leaseLifecycle.allocate?.(lease, { + flags: { + platform: 'android', + device: 'Pixel 6', + providerOsVersion: '14', + providerDeviceType: 'real', + providerApp: 'https://builds.example/app.apk', + }, + }); + } finally { + await runtime.shutdown(); + } + + assert.deepEqual( + server.calls.filter((call) => call.path.startsWith('/lt/upload/')).map((call) => call.path), + ['/lt/upload/realDevice'], + ); + const session = server.calls.find((call) => call.path === '/wd/hub/session'); + const alwaysMatch = ( + session?.body as { capabilities?: { alwaysMatch?: Record } } | undefined + )?.capabilities?.alwaysMatch; + const ltOptions = alwaysMatch?.['lt:options'] as Record | undefined; + assert.equal(ltOptions?.isRealMobile, true); + assert.equal(ltOptions?.app, 'lt://REAL1'); + assert.equal(ltOptions?.platformVersion, '14'); + }); +}, 15_000); + +test('TestMu uploads the materializer-selected simulator archive through the plugin runtime', async () => { + await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { + await withProviderScenarioTempDir('agent-device-testmu-materialized-', async (tempDir) => { + const archivePath = path.join(tempDir, 'App.app.zip'); + const installablePath = path.join(tempDir, 'extracted', 'App.app'); + fs.writeFileSync(archivePath, 'zip bytes'); + fs.mkdirSync(installablePath, { recursive: true }); + const registration = testMuPlugin( + createPluginHost( + { + LT_USERNAME: 'user', + LT_ACCESS_KEY: 'key', + TESTMU_WEBDRIVER_ENDPOINT: `${server.url}/wd/hub/`, + TESTMU_APP_UPLOAD_ENDPOINT: `${server.url}/lt/upload/virtualDevice`, + }, + undefined, + ), + ); + const runtime = await createCloudWebDriverRuntime({ + ...registration.webDriver, + clientVersion: CLIENT_VERSION, + }); + const lease = makeLease('testmu'); + try { + await runtime.leaseLifecycle.allocate?.(lease, { + flags: { + platform: 'ios', + device: 'iPhone 16', + providerOsVersion: '18.0', + providerApp: 'lt://APP1', + }, + }); + const [device] = + (await runtime.deviceInventoryProvider({ + leaseProvider: 'testmu', + leaseId: lease.leaseId, + platform: 'ios', + })) ?? []; + assert.ok(device); + const owner = await runtime.platformRuntimeModule.loadRuntime({ + snapshot: { + presentIosAcquisition: async () => { + throw new Error('Unexpected snapshot'); + }, + }, + } as unknown as PlatformRuntimeHost); + const binding = await owner.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + try { + assert.ok(binding.operations.deployMaterializedApp); + await binding.operations.deployMaterializedApp({ + artifact: { + archivePath, + installablePath, + uploadPath: archivePath, + cleanup: async () => {}, + }, + }); + } finally { + await binding[Symbol.asyncDispose](); + } + const upload = server.calls.find((call) => call.path === '/lt/upload/virtualDevice'); + assert.deepEqual((upload?.body as { filenames?: string[] })?.filenames, ['App.app.zip']); + const install = server.calls.find((call) => + call.path.endsWith('/appium/device/install_app'), + ); + assert.deepEqual(install?.body, { appPath: 'lt://VIRTUAL1' }); + } finally { + await runtime.shutdown(); + } + }); + }); +}, 15_000); + test('BrowserStack refuses a refused field on a repeat allocation of its live lease', async () => { await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { const provider = createProviderWebDriver({ @@ -563,6 +697,10 @@ class FakeCloudProviderServer extends CloudWebDriverTestServer { }); case 'POST /app-automate/upload': return cloudWebDriverTestJson({ app_url: 'bs://uploaded-app' }); + case 'POST /lt/upload/realDevice': + return cloudWebDriverTestJson({ app_url: 'lt://REAL1' }); + case 'POST /lt/upload/virtualDevice': + return cloudWebDriverTestJson({ app_url: 'lt://VIRTUAL1' }); case 'GET /app-automate/sessions/wd-1.json': return cloudWebDriverTestJson({ automation_session: { diff --git a/test/integration/provider-scenarios/cloud-webdriver-test-server.ts b/test/integration/provider-scenarios/cloud-webdriver-test-server.ts index e2b166100f..a1265a6c75 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-test-server.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-test-server.ts @@ -97,9 +97,19 @@ async function neverAnsweredResponse(signal: AbortSignal | undefined): Promise { if (!request.body) return {}; + const multipart = request.headers.get('content-type')?.startsWith('multipart/form-data') + ? await request.clone().formData() + : undefined; const buffer = Buffer.from(await request.arrayBuffer()); if (request.headers.get('content-type')?.startsWith('multipart/form-data')) { - return { body: { multipartBytes: buffer.length } }; + return { + body: { + multipartBytes: buffer.length, + filenames: [...multipart!.values()] + .filter((value): value is File => value instanceof File) + .map((value) => value.name), + }, + }; } const text = buffer.toString('utf8'); return text ? { body: JSON.parse(text) as unknown } : {}; diff --git a/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts b/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts index fd615dd7fc..275c09b659 100644 --- a/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts +++ b/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts @@ -47,6 +47,7 @@ test('Provider-backed integration daemon HTTP lease allocate forwards provider a device: 'iPhone 15', providerApp: 'bs://app-id', providerOsVersion: '17', + providerDeviceType: 'real', providerProject: 'MyProject', providerBuild: 'Build-1', providerSessionName: 'smoke', @@ -64,6 +65,7 @@ test('Provider-backed integration daemon HTTP lease allocate forwards provider a device: 'iPhone 15', providerApp: 'bs://app-id', providerOsVersion: '17', + providerDeviceType: 'real', providerProject: 'MyProject', providerBuild: 'Build-1', providerSessionName: 'smoke', diff --git a/tsconfig.json b/tsconfig.json index b77afe95cb..d99f51c1a3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,11 @@ "erasableSyntaxOnly": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true, - "types": ["node"] + "types": ["node"], + "paths": { + "agent-device/plugins": ["./src/sdk/plugins.ts"], + "agent-device/plugins/webdriver": ["./src/sdk/plugin-webdriver.ts"] + } }, "include": [ "src", diff --git a/tsdown.config.ts b/tsdown.config.ts index 7131e46e79..163bb4c75d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -102,6 +102,7 @@ export default defineConfig({ 'android-adb': 'src/sdk/android-adb.ts', limrun: 'src/sdk/limrun.ts', plugins: 'src/sdk/plugins.ts', + 'plugins/webdriver': 'src/sdk/plugin-webdriver.ts', contracts: 'src/sdk/contracts.ts', selectors: 'src/sdk/selectors.ts', finders: 'src/sdk/finders.ts', diff --git a/website/docs/docs/_meta.json b/website/docs/docs/_meta.json index a951d4384a..44f54a2f95 100644 --- a/website/docs/docs/_meta.json +++ b/website/docs/docs/_meta.json @@ -118,6 +118,11 @@ "label": "AWS Device Farm", "link": "/docs/aws-device-farm" }, + { + "type": "custom-link", + "label": "TestMu AI", + "link": "/docs/testmu" + }, { "type": "custom-link", "label": "Limrun", diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 6261a4949b..dde7455752 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -98,6 +98,8 @@ Supported public entry points for Node consumers: - `runtime.getDeviceSession(device)` - types: `LimrunRuntimeOptions`, `LimrunDeviceSession`, `LimrunAndroidDeviceSession`, `LimrunIosDeviceSession`, `LimrunIosCommandExecution` +- `agent-device/plugins/webdriver` + - experimental `WebDriverPluginOptions` for providers using the shared engine. - `agent-device/plugins` - experimental factory context: `ProviderPluginHost`; see [provider plugins](./plugins.md). - `agent-device/ai-sdk` @@ -120,7 +122,7 @@ stdout/stderr. The option mirrors `open --launch-console` and is not valid for U or contacts the daemon. Pass `{ stateDir }` to resolve an explicit override the same way the CLI resolves `--state-dir`. `client.sessions.artifacts({ provider, providerSessionId })` mirrors `artifacts --provider ... --provider-session ...` and returns provider-hosted `cloudArtifacts`. -Use it for BrowserStack or AWS Device Farm session videos/logs after a cloud session has stopped, or omit `providerSessionId` when an embedding host has registered a provider runtime that can infer the active lease. Limrun does not currently expose provider artifacts through this command. +Use it for BrowserStack, AWS Device Farm, or TestMu AI session videos/logs after a cloud session has stopped, or omit `providerSessionId` when an embedding host has registered a provider runtime that can infer the active lease. Limrun does not currently expose provider artifacts through this command. ```ts const result = await client.sessions.artifacts({ @@ -137,7 +139,7 @@ if ('cloudArtifacts' in result) { ## Device cloud sessions -Limrun, BrowserStack, and AWS Device Farm can be driven through the normal typed client methods. Use the corresponding CLI `connect` flow when you want persisted local connection state. Use direct client config when a Node integration already owns credentials and provider selectors. +Limrun, BrowserStack, AWS Device Farm, and TestMu AI can be driven through the normal typed client methods. Use the corresponding CLI `connect` flow when you want persisted local connection state. Use direct client config when a Node integration already owns credentials and provider selectors. ```ts import { createAgentDeviceClient } from 'agent-device'; @@ -161,7 +163,7 @@ from an explicit selector, an existing session, one local booted/bootable candid simulator with the app installed, or one provider-owned candidate. Ambiguous requests fail with structured retry selectors instead of silently retargeting. -Use `client.sessions.artifacts({ provider, providerSessionId })` with `closed.provider?.providerSessionId` to fetch provider-hosted video and log URLs after close. See the [BrowserStack](/docs/browserstack), [AWS Device Farm](/docs/aws-device-farm), and [Limrun](/docs/limrun) guides for provider-specific setup. +Use `client.sessions.artifacts({ provider, providerSessionId })` with `closed.provider?.providerSessionId` to fetch provider-hosted video and log URLs after close. See the [BrowserStack](/docs/browserstack), [AWS Device Farm](/docs/aws-device-farm), [TestMu AI](/docs/testmu), and [Limrun](/docs/limrun) guides for provider-specific setup. ## Web sessions diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index e3c7530176..1fb05f6043 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -117,7 +117,7 @@ agent-device fold open - Remote daemon clients can pass `--daemon-base-url http(s)://host:port[/base-path]` to skip local daemon discovery/startup and call a remote HTTP daemon directly. - Use `--daemon-auth-token ` (or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`) for explicit service/API-token automation against non-loopback remote daemon URLs; the client sends it in both the JSON-RPC request token and HTTP auth headers. - Use [Remote Proxy](/docs/remote-proxy) when you need to run `agent-device proxy` on a Mac with simulator/device access and drive it from another machine through cloudflared, ngrok, or another HTTP tunnel. -- Use [BrowserStack](/docs/browserstack) or [AWS Device Farm](/docs/aws-device-farm) when a CI agent needs a hosted device session without interactive login. +- Use [BrowserStack](/docs/browserstack), [AWS Device Farm](/docs/aws-device-farm), or [TestMu AI](/docs/testmu) when a CI agent needs a hosted device session without interactive login. - For human cloud access, `connect` can discover a cloud connection profile, while `connect --remote-config ...` uses a local profile. Both refresh a stored CLI session into a short-lived `adc_agent_...` token when needed. If no CLI session exists, interactive shells start login automatically; CI and non-interactive shells fail with API-token setup instructions. Use `--no-login` to disable implicit login. `AGENT_DEVICE_CLOUD_BASE_URL` is the bridge/control-plane API origin; its `/api-keys` route may redirect to the dashboard for token creation. - For remote `connect` and `connect --remote-config` flows, see [Remote Metro workflow](#remote-metro-workflow). - Android React Native relaunch flows require an installed package name for `open --relaunch`; install/reinstall the APK first, then relaunch by package. `open --relaunch` is rejected because runtime hints are written through the installed app sandbox. @@ -1297,7 +1297,7 @@ agent-device artifacts --provider aws-device-farm --provider-session ` and `--provider `. BrowserStack uses `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY`. AWS Device Farm uses the AWS CLI credential chain and infers the region from the session ARN when possible. See [BrowserStack](/docs/browserstack) and [AWS Device Farm](/docs/aws-device-farm) for CI credential setup. +- Historical lookup requires `--provider-session ` and `--provider `. BrowserStack uses `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY`. TestMu AI uses `LT_USERNAME` and `LT_ACCESS_KEY`. AWS Device Farm uses the AWS CLI credential chain and infers the region from the session ARN when possible. See [BrowserStack](/docs/browserstack), [AWS Device Farm](/docs/aws-device-farm), and [TestMu AI](/docs/testmu) for CI credential setup. - When a cloud runtime is registered in-process by an embedding host, `artifacts` can infer the active provider session from the current lease before disconnect. - `disconnect --json` and `close --json` include provider release data when the runtime returns final cloud artifacts after session teardown. Some providers only finalize video/log URLs after the remote session is stopped, so retry `agent-device artifacts --provider --json` if the first response is `pending`. diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index d81d679bc0..fc1923adba 100644 --- a/website/docs/docs/device-clouds.md +++ b/website/docs/docs/device-clouds.md @@ -9,9 +9,10 @@ Use a device cloud or farm when an agent needs to automate a hosted mobile devic - [BrowserStack](/docs/browserstack): Android and iOS App Automate sessions over WebDriver. - [AWS Device Farm](/docs/aws-device-farm): Android and iOS remote-access sessions through AWS. +- [TestMu AI](/docs/testmu): Android emulator, iOS simulator, and real-device sessions over WebDriver. - [Limrun](/docs/limrun): direct iOS simulator and Android emulator instances. -All three integrations run through the local `agent-device` daemon. `connect` checks the credentials and configuration, then saves non-secret connection state. It does not allocate a device. BrowserStack and AWS Device Farm allocate a hosted session on `open`. Limrun allocates an instance on the first device command, such as `install` or `open`. +All four integrations run through the local `agent-device` daemon. `connect` checks the credentials and configuration, then saves non-secret connection state. It does not allocate a device. BrowserStack, AWS Device Farm, and TestMu AI allocate a hosted session on `open`. Limrun allocates an instance on the first device command, such as `install` or `open`. For each provider, the standard lifecycle is: @@ -22,4 +23,4 @@ For each provider, the standard lifecycle is: Each provider reads only its own provider flags (`--provider-*` and `--aws-*`). A flag the provider does not use fails with `INVALID_ARGS` naming the flag, whether it arrives through `connect`, `client.leases.allocate()`, or a remote-config profile, so a setting is never silently dropped. -Each provider guide covers its connection selectors, client configuration, MCP setup, artifacts, and troubleshooting. Generated remote profiles are safe to store as non-secret configuration. They may include app IDs, ARNs, device names, OS versions, and labels, but never provider API keys or AWS secret keys. +Each provider guide covers its connection selectors, client configuration, MCP setup, artifacts, and troubleshooting. Generated remote profiles are safe to store as non-secret configuration. They may include app IDs, ARNs, device names, OS versions, and labels, but never provider API keys, access keys, or AWS secret keys. diff --git a/website/docs/docs/plugins.md b/website/docs/docs/plugins.md index a668163fa2..1a1ff789af 100644 --- a/website/docs/docs/plugins.md +++ b/website/docs/docs/plugins.md @@ -27,8 +27,30 @@ The interface is experimental. Publish this declaration in your package manifest {"agentDevicePlugin": {"apiVersion": 1, "provider": "example", "entry": "./dist/plugin.mjs"}} ``` -Use `agent-device` as a development dependency. The default factory accepts `ProviderPluginHost` from `agent-device/plugins`: `env`, package-specific `options`, and `createError` for host-recognized errors. Return `{ runtime, platformModule }` implementing the [provider runtime](https://github.com/callstack/agent-device/blob/main/packages/contracts/src/provider-device-runtime.ts) and [platform module](https://github.com/callstack/agent-device/blob/main/packages/contracts/src/platform-runtime-operations.ts) contracts. Both provider IDs must match the manifest; the module owner must declare `kind: 'provider-runtime'` and a nonempty `instance`. Core checks required runtime methods and owner metadata at startup; operation contracts are checked when used. Provider packages own implementation types; the SDK exposes only factory context. Set options through `plugins[""].options` in user config; leave `installation` unchanged. +Use `agent-device` as a development dependency. The default factory accepts `ProviderPluginHost` from `agent-device/plugins`: `env`, package-specific `options`, `clientVersion`, and `createError` for host-recognized errors. Return `{ runtime, platformModule }` implementing the [provider runtime](https://github.com/callstack/agent-device/blob/main/packages/contracts/src/provider-device-runtime.ts) and [platform module](https://github.com/callstack/agent-device/blob/main/packages/contracts/src/platform-runtime-operations.ts) contracts. Both provider IDs must match the manifest; the module owner must declare `kind: 'provider-runtime'` and a nonempty `instance`. Core checks required runtime methods and owner metadata at startup; operation contracts are checked when used. Provider packages own implementation types; the SDK exposes only factory context. Set options through `plugins[""].options` in user config; leave `installation` unchanged. Keep initialization prompt and free of network I/O or device allocation; a stalled factory blocks startup. Load platform mechanics through `platformModule.loadRuntime` and perform remote work in request-bound operations. A failing factory cleans up its own resources; core shuts down previously returned runtimes if another plugin fails. -Incompatible contract changes require a new API version. Plugins cannot replace bundled providers or register arbitrary commands. Installing a package does not add `connect `; provider-specific connect adapters need separate support. Limrun, BrowserStack, and AWS Device Farm remain bundled. +Incompatible contract changes require a new API version. Plugins cannot replace bundled providers or register arbitrary commands. Plugins can add `connect ` through the connection callbacks described below. Limrun, BrowserStack, and AWS Device Farm remain bundled. + +For an Appium or WebDriver service, return `{ webDriver: options }` instead of building an engine. `WebDriverPluginOptions` is available through the type-only `agent-device/plugins/webdriver` import. Core supplies the client version and creates the shared runtime. Provider callbacks prepare sessions, upload apps, and retrieve artifacts. + +To support `agent-device connect example`, declare `agentDevicePlugin.connection` in the package manifest: + +```json +{ + "leaseKind": "direct-device-provider", + "requiresAppAttachment": false, + "requiresRemoteDaemon": false, + "supportsArtifacts": false, + "supportsDeferredAppSelection": true, + "supportsDirectPortReverse": false, + "usesCloudWebDriverLease": false +} +``` + +If the provider reads credentials from the environment, list the variables in `agentDevicePlugin.credentialVariables`, for example `["EXAMPLE_USERNAME", "EXAMPLE_ACCESS_KEY"]`. A local daemon keeps the values it started with; when the shell holds different ones, the first command that allocates a lease refuses with reason `provider-credentials-changed` until the daemon is stopped. Core reads this list from the manifest without loading the plugin, and treats a whitespace-only value as unset. + +Return `connection` alongside the runtime or WebDriver options. Its `resolve({ flags, env, cwd, stateDir })` callback validates provider flags and returns `{ profile, extraFlags? }`; `profile.leaseProvider` must match the manifest. Core supplies connection identity, session defaults, Metro settings, and persists the profile. Its async `verify({ flags, env })` callback returns the provider verification result. These callbacks run without allocating a device and their temporary runtimes are shut down afterwards. + +Bundle the plugin implementation and ship ready-to-run ESM: installation disables lifecycle scripts. Bundle shared implementation helpers with the plugin; `AppError` carries a shared brand so core preserves its code and details across package copies. Import types with `import type` to keep them out of the runtime dependency graph. diff --git a/website/docs/docs/testmu.md b/website/docs/docs/testmu.md new file mode 100644 index 0000000000..883530483e --- /dev/null +++ b/website/docs/docs/testmu.md @@ -0,0 +1,219 @@ +--- +title: TestMu AI +description: Drive TestMu AI (formerly LambdaTest) virtual devices, Android emulators and iOS simulators, and real devices with agent-device. +--- + +# TestMu AI + +TestMu AI (formerly LambdaTest) hosts virtual devices for Android emulator and iOS simulator +WebDriver sessions, and real devices you select with `--provider-device-type real`. One Appium hub +fronts both pools; agent-device selects the pool with `isRealMobile` and defaults to the +virtual-device pool. + +## Install the plugin + +```bash +agent-device plugins add @agent-device/testmu +``` + +The provider is an optional npm package installed under `AGENT_DEVICE_HOME`. +After adding or updating it, close sessions and run `agent-device daemon stop` +with the state directory you use; the next device command loads the selected plugin. + +## Credentials and connection + +Set TestMu AI credentials in a non-interactive environment. These are the same variables every +TestMu AI SDK reads: + +```bash +export LT_USERNAME=... +export LT_ACCESS_KEY=... +``` + +Connect with the platform, exact device name and OS version, and the app to test: + +```bash +agent-device connect testmu \ + --platform android \ + --device "Galaxy S22 Ultra 5G" \ + --provider-os-version 14 \ + --provider-app lt://APP-id +``` + +`--device` and `--provider-os-version` must match the virtual-device catalog spelling exactly. The +hub rejects `--provider-os-version 18` for a device listed with `18.0`, so `connect` does too and +lists the versions the device offers. + +`--provider-app` accepts a TestMu AI app reference such as `lt://APP...`, an HTTP(S) app URL, or +an existing local app path (`.apk`, or a zipped simulator `.app` for iOS). When `open` creates the +hosted session, agent-device uploads a local path, and TestMu AI fetches a URL, through the +virtual-device upload API. + +During `connect`, agent-device checks the device/OS pair against TestMu AI's virtual-device +catalog (`/capability/generator?isVirtualDevice=true`), verifies the credentials against your +uploaded-app listing, looks an `lt://` reference up in that listing, and confirms that a local +artifact exists before saving its absolute path. `connect` does not prove the app usable: an `lt://` +id missing from the listing is still accepted, and TestMu AI validates it, like a URL or local +upload, only when the session is created. `open` still needs the app's installed package or bundle +identifier, not the upload name or `lt://` id. + +Optional labels: + +```bash +--provider-project agent-device +--provider-build "$GITHUB_RUN_ID" +--provider-session-name "$GITHUB_JOB" +``` + +Optional device features: + +```bash +--provider-device-orientation portrait # or landscape (alias --device-orientation) +--provider-geo-location US # (alias --geo-location) +--provider-timezone UTC+05:30 # (alias --timezone) +--provider-appium-version 2.16.2 # (alias --appium-version) +--provider-language fr # (alias --language) +--provider-locale fr_FR # (alias --locale) +``` + +TestMu AI receives these values in `lt:options` when it creates the hosted session. + +- Without `--provider-appium-version`, agent-device sends no Appium version and TestMu AI starts + its default server for the device. Pin a version when a suite depends on one. +- `--provider-network-profile`, `--provider-custom-network`, and `--provider-no-resign-app` are + BrowserStack capabilities; `connect testmu` and TestMu AI session creation refuse them by flag + name rather than ignoring them. +- Session video and device logs are requested on every session so `artifacts` has something to + return. + +## Real devices + +Pass `--provider-device-type real` to run on a physical device. Everything else works as for +virtual devices: `connect` checks the device/OS pair against the real-device catalog +(`/capability/generator?isVirtualDevice=false`), and a local path or URL is uploaded through the +real-device upload API. + +```bash +agent-device connect testmu \ + --provider-device-type real \ + --platform ios \ + --device "iPhone 16" \ + --provider-os-version 18 \ + --provider-app ./MyApp.ipa + +agent-device connect testmu \ + --provider-device-type real \ + --platform android \ + --device "Pixel 6" \ + --provider-os-version 14 \ + --provider-app https://example.com/builds/app.apk +``` + +- Real iOS devices are listed by major OS version only: use `--provider-os-version 18`, not `18.0`. + The exact-spelling check still applies, so `connect` rejects `18.0` for a real iPhone 16 and lists + the versions it offers. +- Real iOS devices install a signed `.ipa`; a zipped simulator `.app` only runs on simulators. + Android takes an `.apk` or `.aab`. +- Real and virtual devices have separate upload APIs. Pass an `lt://` id that was uploaded for the + pool you connect to; when in doubt, pass the local path or URL and let agent-device upload it. +- `TESTMU_REAL_DEVICE_APP_UPLOAD_ENDPOINT` redirects real-device uploads, as + `TESTMU_APP_UPLOAD_ENDPOINT` does for virtual-device uploads. +- `--provider-device-type` applies only to TestMu AI; BrowserStack, AWS Device Farm, and Limrun + refuse it on every route, including `client.leases.allocate()`. + +## CLI workflow + +```bash +export LT_USERNAME=... +export LT_ACCESS_KEY=... + +agent-device connect testmu \ + --platform ios \ + --device "iPhone 16" \ + --provider-os-version 18.0 \ + --provider-app ./MyApp.app.zip \ + --provider-build "$GITHUB_RUN_ID" + +agent-device open com.example.app +agent-device snapshot -i +agent-device click 'label="Continue"' +agent-device close +agent-device artifacts --json +agent-device disconnect +``` + +For MCP-only use, run `connect` in the same effective state directory before starting +`agent-device mcp`. MCP exposes `open`, `snapshot`, `click`, `close`, and `artifacts`, but not +provider `connect` commands. + +## Node.js client + +The typed client reaches TestMu AI through a lease. Allocate one with the provider selectors, then +scope a client to it for normal commands. `sessions.close()` ends the hosted session and releases +the lease; `leases.release()` in `finally` is then a no-op, and still releases the lease when a +command fails first. The daemon reads `LT_USERNAME` and `LT_ACCESS_KEY` from its environment and +keeps the values it started with. If your shell holds different ones, the first command that +allocates a lease, such as `open`, refuses before it creates a session; run +`agent-device daemon stop` (with the same `--state-dir`) and rerun the command. A shell that sets +neither variable uses the daemon's. Add `providerDeviceType: 'real'` to `leases.allocate` to run on a real device. + +```ts +import { createAgentDeviceClient } from 'agent-device'; + +const scope = { + tenant: 'testmu', + runId: process.env.GITHUB_RUN_ID ?? 'local-run', + leaseBackend: 'ios-instance', + leaseProvider: 'testmu', +} as const; + +const lease = await createAgentDeviceClient().leases.allocate({ + ...scope, + platform: 'ios', + device: 'iPhone 16', + providerOsVersion: '18.0', + providerApp: 'lt://APP-id', + providerProject: 'agent-device', + providerBuild: process.env.GITHUB_RUN_ID, +}); +const client = createAgentDeviceClient({ ...scope, leaseId: lease.leaseId }); + +let providerSessionId: string | undefined; +try { + await client.apps.open({ app: 'com.example.app' }); + await client.capture.snapshot({ interactiveOnly: true }); + await client.interactions.click({ selector: 'label="Continue"' }); + const closed = await client.sessions.close(); + providerSessionId = closed.provider?.providerSessionId; +} finally { + await client.leases.release({ ...scope, leaseId: lease.leaseId }); +} + +if (providerSessionId) { + const artifacts = await client.sessions.artifacts({ provider: 'testmu', providerSessionId }); + if ('cloudArtifacts' in artifacts) console.log(artifacts.cloudArtifacts); +} +``` + +## Artifacts and troubleshooting + +After `close`, TestMu AI can return session video, Appium logs, device logs, network and command +logs, a screenshot archive, and the App Automation dashboard link. Run `agent-device artifacts +--json`, or look up a previous session explicitly: + +```bash +agent-device artifacts --provider testmu --json +``` + +The TestMu AI session id is the WebDriver session id. If artifact lookup is pending immediately +after `close`, retry it; TestMu AI finalizes video and log URLs after the session ends. + +WebDriver, upload, and catalog/session-detail endpoints can be redirected for a staging or private +TestMu AI deployment with `TESTMU_WEBDRIVER_ENDPOINT`, `TESTMU_APP_UPLOAD_ENDPOINT` (virtual +devices), `TESTMU_REAL_DEVICE_APP_UPLOAD_ENDPOINT` (real devices), and `TESTMU_API_ENDPOINT`. The +app listing `connect` uses to check credentials stays fixed at +`https://manual-api.lambdatest.com/app/data`. + +On hosted WebDriver sessions, `fill` checks that the field received focus before it sends keys. If +it cannot confirm focus, it fails without typing. Use `snapshot -i` to confirm the target, or +`press ` followed by `type `.