diff --git a/package.json b/package.json index 1d85e43354..3d1fc84a9d 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,10 @@ "import": "./dist/src/android-adb.js", "types": "./dist/src/android-adb.d.ts" }, + "./limrun": { + "import": "./dist/src/limrun.js", + "types": "./dist/src/limrun.d.ts" + }, "./contracts": { "import": "./dist/src/contracts.js", "types": "./dist/src/contracts.d.ts" diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index a5116e2db0..6a55bc14d5 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -3,11 +3,12 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; -import { LimrunRuntime } from '../providers/limrun/runtime.ts'; +import { LimrunRuntime } from '../sdk/limrun.ts'; import { createExpiredProviderLeaseReleaser } from '../daemon/provider-lease-expiry.ts'; import type { SimulatorLease } from '../daemon/lease-registry.ts'; import type { DeviceInfo } from '../kernel/device.ts'; import { runCmd } from '../utils/exec.ts'; +import { readVersion } from '../utils/version.ts'; type LimrunInstancePage = { getPaginatedItems: () => Array<{ metadata: { id: string } }>; @@ -118,7 +119,6 @@ afterEach(() => { test('Limrun runtime identifies direct CLI usage to the Limrun API', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', - version: '9.9.9-test', }); const lease: SimulatorLease = { @@ -139,7 +139,7 @@ test('Limrun runtime identifies direct CLI usage to the Limrun API', async () => assert.deepEqual(limrunMockState.constructorOptions[0]?.defaultHeaders, { 'x-agent-device-client': 'agent-device-cli', - 'x-agent-device-version': '9.9.9-test', + 'x-agent-device-version': readVersion(), }); const iosCreateCalls = limrunMockState.iosCreate.mock.calls as unknown as Array< [{ metadata?: { labels?: Record } }] @@ -157,7 +157,7 @@ test('Limrun runtime identifies direct CLI usage to the Limrun API', async () => }); test('Limrun iOS uses shared deep-link classification', async () => { - const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); try { const device = await allocateLimrunDevice(runtime, { @@ -179,7 +179,7 @@ test('Limrun iOS uses shared deep-link classification', async () => { }); test('Limrun reclaims a labeled iOS instance without an in-memory session', async () => { - const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); const lease: SimulatorLease = { leaseId: 'lease-recovered-ios', tenantId: 'team-a', @@ -220,7 +220,7 @@ test('Limrun recovers a failed expired lease release after a daemon restart', as heartbeatAt: 1, expiresAt: 60_001, }; - const firstRuntime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + const firstRuntime = new LimrunRuntime({ apiKey: 'lim_test_key' }); const firstReleaser = createExpiredProviderLeaseReleaser({ recoverExpiredLease: firstRuntime.recoverExpiredLease, recoverableProviderIds: ['limrun'], @@ -239,7 +239,7 @@ test('Limrun recovers a failed expired lease release after a daemon restart', as assert.deepEqual(limrunMockState.iosDelete.mock.calls[0], ['ios-instance-1']); firstReleaser.shutdown(); - recoveredRuntime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + recoveredRuntime = new LimrunRuntime({ apiKey: 'lim_test_key' }); recoveredReleaser = createExpiredProviderLeaseReleaser({ recoverExpiredLease: recoveredRuntime.recoverExpiredLease, recoverableProviderIds: ['limrun'], @@ -266,7 +266,6 @@ test('Limrun recovers a failed expired lease release after a daemon restart', as test('Limrun Android reverses localhost URL ports through the persistent ADB tunnel', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', - version: '9.9.9-test', }); try { @@ -358,7 +357,6 @@ function assertAndroidTunnelLifecycle(openUrl: string): void { test('Limrun Android installs direct local artifacts through Limrun assets', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', - version: '9.9.9-test', }); const lease: SimulatorLease = { leaseId: 'lease-android', @@ -410,7 +408,7 @@ test('Limrun Android installs direct local artifacts through Limrun assets', asy }); test('Limrun Android shares an in-flight ADB tunnel across concurrent port reverse requests', async () => { - const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); try { await allocateLimrunDevice(runtime, androidLease()); @@ -441,7 +439,6 @@ test('Limrun iOS installs direct local artifacts through Limrun assets', async ( fs.writeFileSync(ipaPath, 'demo'); const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', - version: '9.9.9-test', }); const lease: SimulatorLease = { leaseId: 'lease-ios', @@ -505,7 +502,7 @@ test('Limrun iOS reads the bundle display name before uploading an app bundle', '', ].join(''), ); - const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); try { const device = await allocateLimrunDevice(runtime, { @@ -529,7 +526,6 @@ test('Limrun iOS reads the bundle display name before uploading an app bundle', test('Limrun iOS maps supported orientation and rejects unsupported upside-down orientation', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', - version: '9.9.9-test', }); try { @@ -556,7 +552,6 @@ test('Limrun iOS maps supported orientation and rejects unsupported upside-down test('Limrun Android configures an explicit port reverse', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', - version: '9.9.9-test', }); const lease: SimulatorLease = { leaseId: 'lease-android', @@ -601,7 +596,7 @@ test('Limrun Android configures an explicit port reverse', async () => { }); test('Limrun Android preserves canonical ADB failure classification', async () => { - const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); try { await allocateLimrunDevice(runtime, androidLease()); @@ -638,7 +633,6 @@ test('Limrun deletes iOS instance when post-create validation fails', async () = } as never); const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', - version: '9.9.9-test', }); const lease: SimulatorLease = { leaseId: 'lease-ios', @@ -671,7 +665,6 @@ test('Limrun deletes Android instance when post-create validation fails', async } as never); const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', - version: '9.9.9-test', }); const lease: SimulatorLease = { leaseId: 'lease-android', @@ -697,7 +690,6 @@ test('Limrun deletes Android instance when post-create validation fails', async test('Limrun keeps session tracked when release fails so release can be retried', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', - version: '9.9.9-test', }); const lease: SimulatorLease = { leaseId: 'lease-android', diff --git a/src/__tests__/package-exports.test.ts b/src/__tests__/package-exports.test.ts index eec37eaab0..529728af44 100644 --- a/src/__tests__/package-exports.test.ts +++ b/src/__tests__/package-exports.test.ts @@ -29,6 +29,7 @@ const supportedSubpaths = [ './remote-config', './install-source', './android-adb', + './limrun', './contracts', './selectors', './finders', diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 2f102e12fe..4835e7c41f 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -16,9 +16,15 @@ export async function createDefaultProviderDeviceRuntimes( env: DefaultProviderDeviceRuntimeEnv = process.env, ): Promise { const runtimes = createDefaultCloudWebDriverProviderRuntimes(env); - if (!env.LIMRUN_API_KEY?.trim()) return runtimes; + const apiKey = env.LIMRUN_API_KEY?.trim(); + if (!apiKey) return runtimes; - const { createLimrunRuntimeFromEnv } = await import('./providers/limrun/runtime.ts'); - const limrunRuntime = createLimrunRuntimeFromEnv(env); - return limrunRuntime ? [...runtimes, limrunRuntime] : runtimes; + const { LimrunRuntime } = await import('./providers/limrun/runtime.ts'); + return [ + ...runtimes, + new LimrunRuntime({ + apiKey, + region: env.LIMRUN_REGION?.trim() || undefined, + }), + ]; } diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 57f2e6b814..634007e160 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -47,24 +47,13 @@ type LimrunInstance = { type LimrunRuntimeSession = LimrunIosSession | LimrunAndroidSession; -type LimrunRuntimeOptions = { +export type LimrunRuntimeOptions = { apiKey: string; region?: string; - version?: string; }; const LIMRUN_CLIENT_HEADER = 'agent-device-cli'; -export function createLimrunRuntimeFromEnv(env: NodeJS.ProcessEnv): LimrunRuntime | undefined { - const apiKey = env.LIMRUN_API_KEY?.trim(); - if (!apiKey) return undefined; - return new LimrunRuntime({ - apiKey, - region: env.LIMRUN_REGION?.trim() || undefined, - version: readVersion(), - }); -} - export class LimrunRuntime implements ProviderDeviceRuntime { private readonly limrun: Limrun; private readonly sessions = new Map(); @@ -101,7 +90,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { apiKey: options.apiKey, defaultHeaders: { 'x-agent-device-client': LIMRUN_CLIENT_HEADER, - 'x-agent-device-version': options.version ?? readVersion(), + 'x-agent-device-version': readVersion(), }, }); } diff --git a/src/sdk/limrun.ts b/src/sdk/limrun.ts new file mode 100644 index 0000000000..9162a45410 --- /dev/null +++ b/src/sdk/limrun.ts @@ -0,0 +1 @@ +export { LimrunRuntime, type LimrunRuntimeOptions } from '../providers/limrun/runtime.ts'; diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index 423e9524d1..20e905600f 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -316,6 +316,11 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi './finders': (mod) => mod.findBestMatchesByLocator([], 'text', 'anything').matches.length, './install-source': (mod) => typeof mod.isTrustedInstallSourceUrl('https://example.test/app.apk'), './io': (mod) => typeof mod.createLocalArtifactAdapter({ cwd: process.cwd() }).reserveOutput, + './limrun': async (mod) => { + const runtime = new mod.LimrunRuntime({ apiKey: 'lim_test_key' }); + await runtime.shutdown(); + return runtime.provider; + }, './metro': (mod) => mod.buildBundleUrl('https://public.example.test', 'ios'), './remote-config': (mod) => typeof mod, './selectors': (mod) => mod.isSelectorToken('||') && typeof mod.parseSelectorChain === 'function', @@ -388,6 +393,7 @@ test('installed package exposes Node APIs and packaged companion tunnel entrypoi './finders': 0, './install-source': 'boolean', './io': 'function', + './limrun': 'limrun', // Type-only subpath: resolving the module from the packed exports map is // the entire runtime check. './remote-config': 'object', diff --git a/tsdown.config.ts b/tsdown.config.ts index faee43c0e2..c3ddd9b899 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -51,6 +51,7 @@ export default defineConfig({ 'remote-config': 'src/sdk/remote-config.ts', 'install-source': 'src/sdk/install-source.ts', 'android-adb': 'src/sdk/android-adb.ts', + limrun: 'src/sdk/limrun.ts', contracts: 'src/sdk/contracts.ts', selectors: 'src/sdk/selectors.ts', finders: 'src/sdk/finders.ts', diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index dadb61a08d..cc857ed49d 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -61,8 +61,11 @@ Public subpath API exposed for Node consumers: - `listAndroidAppsWithAdb(executor)` - `getAndroidAppStateWithAdb(executor)` - types: `AndroidAdbExecutor`, `AndroidAdbExecutorOptions`, `AndroidPortReverseEndpoint` +- `agent-device/limrun` + - `new LimrunRuntime(options)` + - types: `LimrunRuntimeOptions` -The `contracts`, `selectors`, `finders`, `install-source`, `android-adb`, `artifacts`, `batch`, `metro`, `remote-config`, and `io` subpaths are the supported Node entry points. The former compatibility subpaths `agent-device/android-apps` and `agent-device/daemon`, plus hosted-runtime subpaths `agent-device/cloud-webdriver`, `agent-device/commands`, `agent-device/backend`, `agent-device/testing/conformance`, and `agent-device/observability`, are not published. +The `contracts`, `selectors`, `finders`, `install-source`, `android-adb`, `limrun`, `artifacts`, `batch`, `metro`, `remote-config`, and `io` subpaths are the supported Node entry points. The former compatibility subpaths `agent-device/android-apps` and `agent-device/daemon`, plus hosted-runtime subpaths `agent-device/cloud-webdriver`, `agent-device/commands`, `agent-device/backend`, `agent-device/testing/conformance`, and `agent-device/observability`, are not published. ## Basic usage diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index db324dcff5..958e327452 100644 --- a/website/docs/docs/device-clouds.md +++ b/website/docs/docs/device-clouds.md @@ -251,7 +251,24 @@ const client = createAgentDeviceClient({ }); ``` -The JavaScript client does not publish provider SDK subpaths. Use the normal typed client methods; provider implementation details stay internal. Limrun uses the same client shape with `leaseProvider: 'limrun'`, `platform: 'android'` or `platform: 'ios'`, and `LIMRUN_API_KEY` in the daemon environment. +Use the normal typed client methods when agent-device owns the daemon. Limrun uses the same client +shape with `leaseProvider: 'limrun'`, `platform: 'android'` or `platform: 'ios'`, and +`LIMRUN_API_KEY` in the daemon environment. + +The first-party agent-device-cloud bridge hosts the provider runtime itself and can reuse +agent-device's Limrun implementation: + +```ts +import { LimrunRuntime } from 'agent-device/limrun'; + +const apiKey = process.env.LIMRUN_API_KEY; +if (!apiKey) throw new Error('LIMRUN_API_KEY is required'); + +const runtime = new LimrunRuntime({ + apiKey, + region: process.env.LIMRUN_REGION, +}); +``` ## MCP Experience