diff --git a/.fallowrc.json b/.fallowrc.json index 61871a5de6..76de910d65 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -99,6 +99,11 @@ "file": "packages/provider-*/src/index.ts", "exports": ["default"] }, + { + // The version 1 entry a provider package's `agentDevicePlugin.entry` names; hosts without the version 2 contract import the built file and read `default`. + "file": "packages/provider-*/src/plugin.ts", + "exports": ["default"] + }, { // packages/platform-apple/src/lifecycle.ts loads this module on demand and reads the member off the memoized import() promise, which Fallow cannot follow. "file": "packages/platform-apple/src/expo-dev-client-launch.ts", diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a58b82df..3a9c8b4468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Fix `ensureDaemon()`, the CLI and MCP killing a daemon they had just asked to leave. After the daemon answers the quiesce request with `draining`, the client now waits up to 60 s for it to exit by itself and clean up its registration, so plugin disposal and lease finalization finish (on Windows a signal ends the process outright, and on POSIX a drain longer than 3 s was killed). A daemon still running at the deadline that fails its liveness probes is force-retired; one that still answers is left running and the call fails with `details.reason: 'daemon-drain-timeout'`, with a hint naming `agent-device daemon stop --state-dir `. +- `@agent-device/testmu` is now a version 2 provider plugin. `import testmu from '@agent-device/testmu'` gives a factory, `testmu({ credentials: { username, accessKey } })`, that reads those credentials instead of `LT_USERNAME` and `LT_ACCESS_KEY`; without options it reads the variables as before. The package still declares the version 1 plugin entry, so `agent-device plugins add @agent-device/testmu` keeps working on hosts that predate the version 2 contract. - Provider plugins have a new contract. A package exports a factory at `./agent-device` that returns `{ apiVersion: 2, provider, credentials, connection?, setup(host) }`, typed by `AgentDevicePluginFactory` from `agent-device/plugins`. The store calls the factory with the package's `config.json` options, and a JavaScript host can call it directly. Version 1 plugins (`agentDevicePlugin.apiVersion: 1`) keep loading. - Bundled providers and installed plugins now register through one registry, in that order, with a third slot for embedder plugins (#3757). The first plugin to name a provider id claims it; a later duplicate is recorded as failed, and `cloud` and `proxy` stay reserved. - A plugin that fails to load, validate, or set up no longer stops the daemon. It is listed as `failed` with a typed `error.reason`, and the other providers still register. The new `agent-device daemon status [--json]` shows the inventory, which the daemon also publishes in `daemon.json`; `plugins list --json` works without a daemon and reports a running daemon's inventory under `daemon`. diff --git a/packages/provider-testmu/README.md b/packages/provider-testmu/README.md index 31d6a82757..7bd75623de 100644 --- a/packages/provider-testmu/README.md +++ b/packages/provider-testmu/README.md @@ -15,3 +15,15 @@ See the [TestMu AI guide](https://oss.callstack.com/agent-device/docs/testmu) fo To update the plugin, run `agent-device plugins update @agent-device/testmu`. After you add or update the plugin, close your sessions and run `agent-device daemon stop` before reconnecting. + +## Use it from JavaScript + +The package default-exports a plugin factory. Pass credentials as options, or leave them out to read `LT_USERNAME` and `LT_ACCESS_KEY` from the environment: + +```js +import testmu from '@agent-device/testmu'; + +const plugin = testmu({ credentials: { username: 'your-username', accessKey: 'your-access-key' } }); +``` + +Credentials passed as options are used instead of the environment variables, and the daemon does not compare them with your shell. diff --git a/packages/provider-testmu/package.json b/packages/provider-testmu/package.json index 9191e2bbca..856ec1c08c 100644 --- a/packages/provider-testmu/package.json +++ b/packages/provider-testmu/package.json @@ -15,6 +15,10 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, + "./agent-device": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, "./connection-verification": { "types": "./src/testmu-connection-verification.ts", "default": "./src/testmu-connection-verification.ts" @@ -48,7 +52,10 @@ "publishConfig": { "exports": { ".": { - "import": "./dist/plugin.mjs" + "import": "./dist/agent-device.mjs" + }, + "./agent-device": { + "import": "./dist/agent-device.mjs" } }, "access": "public" diff --git a/packages/provider-testmu/src/connection.ts b/packages/provider-testmu/src/connection.ts index d730bb42bd..a10a0cb17e 100644 --- a/packages/provider-testmu/src/connection.ts +++ b/packages/provider-testmu/src/connection.ts @@ -4,7 +4,6 @@ import { rejectRefusedProviderProfileFields, requireResolvedProfilePlatform, requireResolvedProfileValue, - type ProviderProfileFieldDeclaration, } from '@agent-device/contracts/provider-profile-fields'; import { requireConnectFlag, @@ -16,19 +15,17 @@ import { isTestMuAppReference, requireTestMuCredentials, } from './providers.ts'; +import { TESTMU_PROFILE_FIELDS } from './profile-fields.ts'; import { verifyTestMuConnection } from './testmu-connection-verification.ts'; import { readTestMuDeviceFeatureFields, readTestMuDeviceType } from './testmu-device-features.ts'; const PROVIDER = 'testmu'; const SERVICE = 'TestMu AI'; -export function createTestMuConnection( - host: ProviderPluginHost, - fields: ProviderProfileFieldDeclaration, -) { +export function createTestMuConnection(host: ProviderPluginHost) { return { resolve: ({ flags, cwd }: { flags: CliFlags; cwd: string }) => { - rejectRefusedProviderProfileFields(flags, fields); + rejectRefusedProviderProfileFields(flags, TESTMU_PROFILE_FIELDS); requireTestMuCredentials(host.env, `connect ${PROVIDER}`); const platform = requireConnectPlatform(flags, PROVIDER); const app = connectAppReference( diff --git a/packages/provider-testmu/src/index.test.ts b/packages/provider-testmu/src/index.test.ts new file mode 100644 index 0000000000..924457b2fd --- /dev/null +++ b/packages/provider-testmu/src/index.test.ts @@ -0,0 +1,137 @@ +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 testmu from './index.ts'; +import { verifyTestMuConnection } from './testmu-connection-verification.ts'; +import manifest from '../package.json' with { type: 'json' }; + +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), + }; +} + +function capturedAuthorization(): { read(): string | undefined } { + let authorization: string | undefined; + globalThis.fetch = async (_input, init) => { + authorization = new Headers(init?.headers).get('authorization') ?? undefined; + return new Response(JSON.stringify({ data: {} })); + }; + return { read: () => authorization }; +} + +async function listArtifacts(plugin: ReturnType, env: Record) { + const setup = await plugin.setup(host(env)); + if (!('webDriver' in setup)) throw new Error('TestMu AI sets up a WebDriver descriptor'); + await setup.webDriver.listArtifacts!({ provider: 'testmu', providerSessionId: 'SESSION1' }); +} + +const basic = (username: string, accessKey: string) => + `Basic ${Buffer.from(`${username}:${accessKey}`).toString('base64')}`; + +test('the plugin declares the LT variables only when it reads credentials from the environment', () => { + assert.deepEqual(testmu().credentials, { variables: ['LT_USERNAME', 'LT_ACCESS_KEY'] }); + assert.deepEqual(testmu({ credentials: { username: 'u', accessKey: 'k' } }).credentials, { + variables: [], + }); +}); + +test('a credentials object missing one key still declares the variable it reads from the environment', () => { + assert.deepEqual(testmu({ credentials: { username: 'u' } as never }).credentials, { + variables: ['LT_ACCESS_KEY'], + }); + assert.deepEqual(testmu({ credentials: { accessKey: 'k' } as never }).credentials, { + variables: ['LT_USERNAME'], + }); +}); + +test('credentials passed as options replace the environment, which stays the fallback', async () => { + const captured = capturedAuthorization(); + const env = { LT_USERNAME: 'env-user', LT_ACCESS_KEY: 'env-key' }; + + await listArtifacts(testmu({ credentials: { username: 'opt-user', accessKey: 'opt-key' } }), env); + assert.equal(captured.read(), basic('opt-user', 'opt-key')); + await listArtifacts(testmu({ credentials: { username: 'opt-user', accessKey: 'opt-key' } }), {}); + assert.equal(captured.read(), basic('opt-user', 'opt-key')); + await listArtifacts(testmu(), env); + assert.equal(captured.read(), basic('env-user', 'env-key')); + await assert.rejects(listArtifacts(testmu(), {}), { code: 'INVALID_ARGS' }); +}); + +test('connect uses the option credentials without an environment and verifies with them', 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 { connection } = testmu({ credentials: { username: 'opt-user', accessKey: 'opt-key' } }); + const flags = { + json: false, + help: false, + version: false, + platform: 'ios', + device: 'iPhone 16', + providerOsVersion: '18.0', + providerApp: 'lt://APP1', + } as const; + + const resolved = await connection!.resolve({ + flags, + stateDir: '/state', + cwd: '/work', + env: {}, + host: host({}), + }); + assert.equal(resolved.profile.leaseProvider, 'testmu'); + await connection!.verify({ flags, env: {}, host: host({}) }); + assert.deepEqual( + [ + vi.mocked(verifyTestMuConnection).mock.calls[0]?.[0].username, + vi.mocked(verifyTestMuConnection).mock.calls[0]?.[0].accessKey, + ], + ['opt-user', 'opt-key'], + ); + await assert.rejects( + async () => + await testmu().connection!.resolve({ + flags, + stateDir: '/state', + cwd: '/work', + env: {}, + host: host({}), + }), + { code: 'INVALID_ARGS' }, + ); +}); + +test('the v2 declaration matches the v1 manifest that hosts without the v2 contract read', () => { + const v1 = manifest.agentDevicePlugin; + assert.equal(testmu().provider, v1.provider); + assert.deepEqual(testmu().connection?.capabilities, v1.connection); + assert.deepEqual(testmu().credentials.variables, v1.credentialVariables); + assert.equal(v1.apiVersion, 1); +}); + +test('a credentials object missing one key falls back to the environment for that key', async () => { + const captured = capturedAuthorization(); + await listArtifacts(testmu({ credentials: { username: 'opt-user' } as never }), { + LT_USERNAME: 'env-user', + LT_ACCESS_KEY: 'env-key', + }); + assert.equal(captured.read(), basic('opt-user', 'env-key')); +}); diff --git a/packages/provider-testmu/src/index.ts b/packages/provider-testmu/src/index.ts index f00db85a53..26a6b75674 100644 --- a/packages/provider-testmu/src/index.ts +++ b/packages/provider-testmu/src/index.ts @@ -1 +1,64 @@ -export { default } from './plugin.ts'; +import type { AgentDevicePlugin, ProviderPluginHost } from 'agent-device/plugins'; +import manifest from '../package.json' with { type: 'json' }; + +type ConnectionCapabilities = NonNullable['capabilities']; + +export type TestmuOptions = { + /** Used instead of `LT_USERNAME` and `LT_ACCESS_KEY` when set. */ + credentials?: { username: string; accessKey: string }; +}; + +// The connection and WebDriver code load when they are first used, so importing the package for its +// declaration stays small. +async function loadConnection(host: ProviderPluginHost) { + return (await import('./connection.ts')).createTestMuConnection(host); +} + +/** + * TestMu AI as a plugin. The credentials come from `options.credentials`, with the + * `LT_USERNAME` and `LT_ACCESS_KEY` environment variables as the fallback for each key the options + * leave out. The plugin declares only the variables it reads from the environment; credentials passed + * as options are not part of the environment the daemon fingerprints. + */ +function testmu(options: TestmuOptions = {}): AgentDevicePlugin { + const { credentials } = options; + const withCredentials = (host: ProviderPluginHost): ProviderPluginHost => + credentials + ? { + ...host, + // A key the caller left out falls back to the environment instead of shadowing it. + env: { + ...host.env, + ...(credentials.username ? { LT_USERNAME: credentials.username } : {}), + ...(credentials.accessKey ? { LT_ACCESS_KEY: credentials.accessKey } : {}), + }, + } + : host; + // Only the variables the options do not supply come from the environment, so only those are + // part of the credential fingerprint. + const environmentVariables = [ + ...(credentials?.username ? [] : ['LT_USERNAME']), + ...(credentials?.accessKey ? [] : ['LT_ACCESS_KEY']), + ]; + return { + apiVersion: 2, + provider: 'testmu', + credentials: { variables: environmentVariables }, + connection: { + // The declaration hosts without the version 2 contract read from package.json; the registry + // validates it when the plugin loads. + capabilities: manifest.agentDevicePlugin.connection as ConnectionCapabilities, + resolve: async (context) => + (await loadConnection(withCredentials(context.host))).resolve(context), + verify: async (context) => + await (await loadConnection(withCredentials(context.host))).verify(context), + }, + setup: async (host) => ({ + webDriver: (await import('./testmu-webdriver.ts')).createTestMuWebDriver( + withCredentials(host), + ), + }), + }; +} + +export { testmu as default }; diff --git a/packages/provider-testmu/src/plugin.ts b/packages/provider-testmu/src/plugin.ts index f716f61ce9..6303af5632 100644 --- a/packages/provider-testmu/src/plugin.ts +++ b/packages/provider-testmu/src/plugin.ts @@ -1,150 +1,8 @@ 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, - requireFlag, - requireRequest, - requireRequestPlatform, -} from '@agent-device/provider-webdriver/plugin'; import { createTestMuConnection } from './connection.ts'; -import { requireTestMuCredentials } from './providers.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', - }, -}; +import { createTestMuWebDriver } from './testmu-webdriver.ts'; +/** The version 1 plugin entry, kept so hosts without the version 2 contract still load this package. */ 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) }; -} -/** 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; + return { webDriver: createTestMuWebDriver(host), connection: createTestMuConnection(host) }; } diff --git a/packages/provider-testmu/src/profile-fields.ts b/packages/provider-testmu/src/profile-fields.ts new file mode 100644 index 0000000000..07a6eb3511 --- /dev/null +++ b/packages/provider-testmu/src/profile-fields.ts @@ -0,0 +1,28 @@ +import type { ProviderProfileFieldDeclaration } from '@agent-device/contracts/provider-profile-fields'; + +export 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', + }, +}; diff --git a/packages/provider-testmu/src/testmu-webdriver.ts b/packages/provider-testmu/src/testmu-webdriver.ts new file mode 100644 index 0000000000..abc6e00c54 --- /dev/null +++ b/packages/provider-testmu/src/testmu-webdriver.ts @@ -0,0 +1,124 @@ +import type { ProviderPluginHost } from 'agent-device/plugins'; +import type { WebDriverPluginOptions } from 'agent-device/plugins/webdriver'; +import type { ProviderDeviceType } from '@agent-device/contracts/remote'; +import { + buildCloudWebDriverBaseCapabilities, + readFlag, + requireFlag, + requireRequest, + requireRequestPlatform, +} from '@agent-device/provider-webdriver/plugin'; +import { TESTMU_PROFILE_FIELDS } from './profile-fields.ts'; +import { requireTestMuCredentials } from './providers.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; + +/** The TestMu AI WebDriver descriptor the host drives through its shared engine. */ +export function createTestMuWebDriver(host: ProviderPluginHost): WebDriverPluginOptions { + 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; +} +/** 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/tsconfig.json b/packages/provider-testmu/tsconfig.json index 9541eb178c..81ccf03333 100644 --- a/packages/provider-testmu/tsconfig.json +++ b/packages/provider-testmu/tsconfig.json @@ -7,7 +7,7 @@ "declaration": true, "declarationDir": "./dist-types", "tsBuildInfoFile": "./dist-types/index.tsbuildinfo", - "rootDir": "./src", + "rootDir": ".", "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"] @@ -15,8 +15,14 @@ }, "include": ["src"], "references": [ - { "path": "../contracts" }, - { "path": "../kernel" }, - { "path": "../provider-webdriver" } + { + "path": "../contracts" + }, + { + "path": "../kernel" + }, + { + "path": "../provider-webdriver" + } ] } diff --git a/packages/provider-testmu/tsdown.config.ts b/packages/provider-testmu/tsdown.config.ts index 5aeb12099a..6173fbf90a 100644 --- a/packages/provider-testmu/tsdown.config.ts +++ b/packages/provider-testmu/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: { plugin: 'src/plugin.ts' }, + entry: { plugin: 'src/plugin.ts', 'agent-device': 'src/index.ts' }, outDir: 'dist', format: 'esm', platform: 'node', diff --git a/scripts/check-provider-plugin.mjs b/scripts/check-provider-plugin.mjs index 13d30d255a..535b85f706 100644 --- a/scripts/check-provider-plugin.mjs +++ b/scripts/check-provider-plugin.mjs @@ -47,17 +47,15 @@ async function pack(directory) { } try { await run('pnpm', ['build'], plugin); - const coreTarball = await pack(root); + // AGENT_DEVICE_CORE_SPEC installs a published agent-device (such as agent-device@0.21.28) instead + // of packing this checkout, to prove a plugin still loads on a host without the newest contract. + const coreSpec = process.env.AGENT_DEVICE_CORE_SPEC ?? (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', coreSpec], consumer); await run( 'npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', pluginTarball], @@ -67,7 +65,10 @@ try { 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.deepEqual(installed.exports, { + '.': { import: './dist/agent-device.mjs' }, + './agent-device': { import: './dist/agent-device.mjs' }, + }); assert.equal(installed.dependencies, undefined); assert.equal(installed.peerDependencies, undefined); await assert.rejects(fs.stat(path.join(project, 'node_modules', 'agent-device')), { diff --git a/src/__tests__/cloud-connect-testmu.test.ts b/src/__tests__/cloud-connect-testmu.test.ts index 892e39c851..8d08192c07 100644 --- a/src/__tests__/cloud-connect-testmu.test.ts +++ b/src/__tests__/cloud-connect-testmu.test.ts @@ -9,8 +9,8 @@ import { } from '@agent-device/remote/remote-connection-state'; import { AppError } from '@agent-device/kernel/errors'; import { verifyTestMuConnection } from '@agent-device/testmu/connection-verification'; -import testMuPlugin from '@agent-device/testmu'; -import { createPluginHost } from '@agent-device/plugins/host'; +import testmu from '@agent-device/testmu'; +import { resolveProviderPlugins } from '@agent-device/plugins/registry'; import { resolveConnectProviderProfile } from '../cli/connection/connect-provider-adapters.ts'; import { mkdtempForTestSync, pluginHome, selectPlugin } from '@agent-device/test-kit'; import manifest from '@agent-device/testmu/package.json' with { type: 'json' }; @@ -24,11 +24,13 @@ vi.mock('@agent-device/plugins/load', () => ({ withPluginConnection: async ( _provider: string, _builtinProviders: readonly string[], - env: NodeJS.ProcessEnv, + _env: NodeJS.ProcessEnv, runConnection: (connection: PluginConnectionCallbacks) => Promise, ) => { - const registration = testMuPlugin(createPluginHost(env, undefined)); - return await runConnection(registration.connection); + const { connections } = await resolveProviderPlugins([ + { source: 'store', package: manifest.name, load: () => testmu() }, + ]); + return await runConnection(connections.get('testmu')!); }, })); 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 c21b78ca5b..bf9d7633e5 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -25,7 +25,7 @@ import { type StartedCloudWebDriverTestServer, } from './cloud-webdriver-test-server.ts'; -import testMuPlugin from '@agent-device/testmu'; +import testmu from '@agent-device/testmu'; import { createPluginHost } from '@agent-device/plugins/host'; import { createCloudWebDriverRuntime } from '@agent-device/provider-webdriver/plugin'; import { CLOUD_WEBDRIVER_PROVIDERS } from '@agent-device/provider-webdriver/providers'; @@ -194,22 +194,23 @@ test('AWS Device Farm facade rejects device features it does not read at session }); }, 15_000); +async function testMuWebDriver(env: Record) { + const setup = await testmu().setup(createPluginHost(env, undefined)); + if (!('webDriver' in setup)) throw new Error('TestMu AI sets up a WebDriver descriptor'); + return setup.webDriver; +} + 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 webDriver = await testMuWebDriver({ + 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`, + }); const runtime = await createCloudWebDriverRuntime({ - ...registration.webDriver, + ...webDriver, clientVersion: CLIENT_VERSION, }); const lease = makeLease('testmu'); @@ -249,19 +250,14 @@ test('TestMu uploads the materializer-selected simulator archive through the plu 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 webDriver = await testMuWebDriver({ + LT_USERNAME: 'user', + LT_ACCESS_KEY: 'key', + TESTMU_WEBDRIVER_ENDPOINT: `${server.url}/wd/hub/`, + TESTMU_APP_UPLOAD_ENDPOINT: `${server.url}/lt/upload/virtualDevice`, + }); const runtime = await createCloudWebDriverRuntime({ - ...registration.webDriver, + ...webDriver, clientVersion: CLIENT_VERSION, }); const lease = makeLease('testmu'); diff --git a/website/docs/docs/testmu.md b/website/docs/docs/testmu.md index c28ba994fd..e5833f17ab 100644 --- a/website/docs/docs/testmu.md +++ b/website/docs/docs/testmu.md @@ -151,7 +151,7 @@ releases the lease. Keep `leases.release()` in `finally`: it does nothing after and releases the lease when a command fails first. Add `providerDeviceType: 'real'` to `leases.allocate` to run on a real device. -The daemon reads `LT_USERNAME` and `LT_ACCESS_KEY` from its environment. If you change credentials while the daemon runs, see [Change credentials while the daemon runs](/docs/device-clouds#change-credentials-while-the-daemon-runs). +The daemon reads `LT_USERNAME` and `LT_ACCESS_KEY` from its environment, unless a JavaScript host passes the plugin `testmu({ credentials })` instead. If you change credentials while the daemon runs, see [Change credentials while the daemon runs](/docs/device-clouds#change-credentials-while-the-daemon-runs). ```ts import { createAgentDeviceClient } from 'agent-device';