diff --git a/src/limrun-runtime-types.ts b/src/limrun-runtime-types.ts new file mode 100644 index 0000000000..cccfd7c018 --- /dev/null +++ b/src/limrun-runtime-types.ts @@ -0,0 +1,20 @@ +import type { AndroidAdbProvider } from './platforms/android/adb-executor.ts'; +import type { + AndroidKeyboardDismissResult, + AndroidKeyboardState, +} from './platforms/android/device-input-state.ts'; +import type { + LimrunAndroidDeviceSession as InternalLimrunAndroidDeviceSession, + LimrunIosDeviceSession, +} from './providers/limrun/device-session.ts'; + +export type LimrunAndroidDeviceSession = Omit< + InternalLimrunAndroidDeviceSession, + 'adb' | 'getKeyboardState' | 'dismissKeyboard' +> & { + readonly adb: AndroidAdbProvider; + getKeyboardState(): Promise; + dismissKeyboard(): Promise; +}; + +export type LimrunDeviceSession = LimrunAndroidDeviceSession | LimrunIosDeviceSession; diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 42c30c9325..e214b6f2e1 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -19,7 +19,7 @@ export async function createDefaultProviderDeviceRuntimes( const apiKey = env.LIMRUN_API_KEY?.trim(); if (!apiKey) return runtimes; - const { LimrunRuntime } = await import('./providers/limrun/runtime.ts'); + const { LimrunRuntime } = await import('./provider-limrun-runtime.ts'); return [ ...runtimes, new LimrunRuntime({ diff --git a/src/provider-limrun-runtime.ts b/src/provider-limrun-runtime.ts new file mode 100644 index 0000000000..c5fcfe2819 --- /dev/null +++ b/src/provider-limrun-runtime.ts @@ -0,0 +1,89 @@ +import type { + DeviceInventoryProvider, + LeaseLifecycleProvider, + ProviderDeviceInstallOptions, + ProviderDeviceInstallResult, + ProviderDeviceRuntime, + ProviderExpiredLeaseRecovery, + ProviderPortReverseOptions, +} from '@agent-device/contracts/device'; +import type { Interactor } from '@agent-device/contracts/interaction'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { LimrunDeviceSession } from './limrun-runtime-types.ts'; +import { + createLimrunRuntime, + type LimrunRuntime as LimrunRuntimeImplementation, + type LimrunRuntimeOptions, +} from './providers/limrun/runtime.ts'; +import { LIMRUN_PROVIDER } from './providers/limrun/device.ts'; +import { createLimrunRuntimeDependencies } from './sdk/limrun-runtime-dependencies.ts'; + +export class LimrunRuntime implements ProviderDeviceRuntime { + private readonly implementation: LimrunRuntimeImplementation; + // Called through ProviderDeviceRuntime composition and the public Limrun SDK. + // fallow-ignore-next-line unused-class-member + readonly provider = LIMRUN_PROVIDER; + + constructor(options: LimrunRuntimeOptions) { + this.implementation = createLimrunRuntime(options, createLimrunRuntimeDependencies()); + } + + get leaseLifecycle(): LeaseLifecycleProvider { + return this.implementation.leaseLifecycle; + } + + get recoverExpiredLease(): ProviderExpiredLeaseRecovery { + return this.implementation.recoverExpiredLease; + } + + // Called through ProviderDeviceRuntime composition. + // fallow-ignore-next-line unused-class-member + get deviceInventoryProvider(): DeviceInventoryProvider { + return this.implementation.deviceInventoryProvider; + } + + // Called through ProviderDeviceRuntime composition and the public Limrun SDK. + // fallow-ignore-next-line unused-class-member + ownsDevice(device: DeviceInfo): boolean { + return this.implementation.ownsDevice(device); + } + + getInteractor(device: DeviceInfo): Interactor | undefined { + return this.implementation.getInteractor(device); + } + + getDeviceSession(device: DeviceInfo): LimrunDeviceSession | undefined { + return this.implementation.getDeviceSession(device) as LimrunDeviceSession | undefined; + } + + async installApp( + device: DeviceInfo, + app: string, + appPath: string, + options?: ProviderDeviceInstallOptions, + ): Promise { + return await this.implementation.installApp?.(device, app, appPath, options); + } + + // Called through ProviderDeviceRuntime composition and the public Limrun SDK. + // fallow-ignore-next-line unused-class-member + async installInstallablePath( + device: DeviceInfo, + installablePath: string, + options?: ProviderDeviceInstallOptions, + ): Promise { + return await this.implementation.installInstallablePath?.(device, installablePath, options); + } + + async configurePortReverse( + options: ProviderPortReverseOptions, + ): Promise | undefined> { + return await this.implementation.configurePortReverse?.(options); + } + + async shutdown(): Promise { + await this.implementation.shutdown(); + } +} + +export type { LimrunRuntimeOptions }; diff --git a/src/providers/limrun/android.ts b/src/providers/limrun/android.ts index bd0d7f444b..0cc348f442 100644 --- a/src/providers/limrun/android.ts +++ b/src/providers/limrun/android.ts @@ -4,7 +4,6 @@ import { createInstanceClient as createAndroidInstanceClient, type InstanceClient as LimrunAndroidClient, } from '@limrun/api/instance-client'; -import { createAndroidInteractor } from '../../core/interactors/android.ts'; import type { Interactor } from '@agent-device/contracts/interaction'; import type { DeviceLease, @@ -14,13 +13,13 @@ import type { } from '@agent-device/contracts/device'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - type AndroidAdbExecutorOptions, - type AndroidAdbExecutorResult, - type AndroidAdbProvider, - type AndroidPortReverseEndpoint, -} from '../../platforms/android/adb-executor.ts'; -import { runCmd } from '../../utils/exec.ts'; +import type { + LimrunAdbCommandOptions, + LimrunAdbCommandResult, + LimrunAdbProvider, + LimrunPortReverseEndpoint, + LimrunRuntimeDependencies, +} from './runtime-dependencies.ts'; import { normalizeOptionalString } from './strings.ts'; type LimrunAdbTunnel = Awaited>; @@ -34,20 +33,24 @@ type LimrunAndroidAdbSession = { adbTunnel?: LimrunAdbTunnel; adbSerial?: string; adbTunnelPromise?: Promise; + readonly dependencies: Pick; }; export type LimrunAndroidSession = LimrunAndroidAdbSession & { - adbProvider: AndroidAdbProvider; + adbProvider: LimrunAdbProvider; }; -export async function createLimrunAndroidSession(options: { - lease: DeviceLease; - instanceId: string; - device: DeviceInfo; - apiUrl: string; - adbUrl: string; - token: string; -}): Promise { +export async function createLimrunAndroidSession( + options: { + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + apiUrl: string; + adbUrl: string; + token: string; + }, + dependencies: Pick, +): Promise { const client = await createAndroidInstanceClient({ apiUrl: options.apiUrl, adbUrl: options.adbUrl, @@ -60,21 +63,20 @@ export async function createLimrunAndroidSession(options: { instanceId: options.instanceId, device: options.device, client, + dependencies, }; - const adbProvider: AndroidAdbProvider = { + const adbProvider: LimrunAdbProvider = { exec: async (args, execOptions) => await runLimrunAndroidAdb(session, args, execOptions), text: async (request) => { await client.setText(request.target, request.text); }, }; - const { createAndroidPortReverseManager } = - await import('../../platforms/android/adb-executor.ts'); - adbProvider.reverse = createAndroidPortReverseManager(adbProvider); + adbProvider.reverse = await dependencies.android.createPortReverse(adbProvider.exec); return Object.assign(session, { adbProvider }); } export function createLimrunAndroidInteractor(session: LimrunAndroidSession): Interactor { - return createAndroidInteractor(session.device, session.adbProvider); + return session.dependencies.android.createInteractor(session.device, session.adbProvider); } export async function installLimrunAndroidApp( @@ -95,7 +97,7 @@ export async function installLimrunAndroidApp( }); await session.client.sendAsset(asset.signedDownloadUrl); const appName = packageName - ? (await import('../../platforms/android/app-lifecycle.ts')).inferAndroidAppName(packageName) + ? await session.dependencies.android.inferAppName(packageName) : undefined; return { ...(packageName ? { packageName, launchTarget: packageName } : {}), @@ -119,10 +121,12 @@ export async function cleanupLimrunAndroidAdbTunnel(session: LimrunAndroidSessio const serial = session.adbSerial; if (serial) { await cleanupAndroidPortReverse(session); - await runCmd('adb', ['disconnect', serial], { - allowFailure: true, - timeoutMs: 10_000, - }).catch(() => {}); + await session.dependencies.host + .runAdb(['disconnect', serial], { + allowFailure: true, + timeoutMs: 10_000, + }) + .catch(() => {}); } session.adbTunnel?.close(); session.adbTunnel = undefined; @@ -135,7 +139,7 @@ async function cleanupAndroidPortReverse(session: LimrunAndroidSession): Promise if (!reverse?.list) return; const mappings = await reverse.list().catch(() => []); const owners = new Set(); - const unownedLocals: AndroidPortReverseEndpoint[] = []; + const unownedLocals: LimrunPortReverseEndpoint[] = []; for (const mapping of mappings) { if (mapping.ownerId) owners.add(mapping.ownerId); else unownedLocals.push(mapping.local); @@ -149,20 +153,25 @@ async function cleanupAndroidPortReverse(session: LimrunAndroidSession): Promise async function runLimrunAndroidAdb( session: LimrunAndroidAdbSession, args: string[], - options?: AndroidAdbExecutorOptions, -): Promise { + options?: LimrunAdbCommandOptions, +): Promise { const { adbArgs, result } = await executeLimrunAndroidAdb(session, args, options); - return await requireSuccessfulLimrunAndroidAdb(adbArgs, result, options?.allowFailure); + return await requireSuccessfulLimrunAndroidAdb( + adbArgs, + result, + options?.allowFailure, + session.dependencies, + ); } async function executeLimrunAndroidAdb( session: LimrunAndroidAdbSession, args: string[], - options?: AndroidAdbExecutorOptions, -): Promise<{ adbArgs: string[]; result: AndroidAdbExecutorResult }> { + options?: LimrunAdbCommandOptions, +): Promise<{ adbArgs: string[]; result: LimrunAdbCommandResult }> { const serial = await ensurePersistentAndroidAdbSerial(session); const adbArgs = ['-s', serial, ...args]; - const result = await runCmd('adb', adbArgs, { + const result = await session.dependencies.host.runAdb(adbArgs, { allowFailure: options?.allowFailure, binaryStdout: options?.binaryStdout, stdin: options?.stdin, @@ -174,12 +183,12 @@ async function executeLimrunAndroidAdb( async function requireSuccessfulLimrunAndroidAdb( adbArgs: string[], - result: AndroidAdbExecutorResult, + result: LimrunAdbCommandResult, allowFailure: boolean | undefined, -): Promise { + dependencies: Pick, +): Promise { if (result.exitCode !== 0 && allowFailure !== true) { - const { androidAdbResultError } = await import('../../platforms/android/adb-executor.ts'); - throw androidAdbResultError('Limrun Android ADB command failed', result, { + throw await dependencies.android.adbError('Limrun Android ADB command failed', result, { command: ['adb', ...adbArgs].join(' '), }); } @@ -206,7 +215,7 @@ async function startAndroidAdbTunnel(session: LimrunAndroidAdbSession): Promise< return serial; } -function tcpEndpoint(port: number): AndroidPortReverseEndpoint { +function tcpEndpoint(port: number): LimrunPortReverseEndpoint { if (!Number.isInteger(port) || port < 1 || port > 65_535) { throw new AppError('INVALID_ARGS', `Invalid Android tcp reverse port: ${port}`); } diff --git a/src/providers/limrun/device-session.test.ts b/src/providers/limrun/device-session.test.ts index 61fb7af89d..deecd77736 100644 --- a/src/providers/limrun/device-session.test.ts +++ b/src/providers/limrun/device-session.test.ts @@ -2,19 +2,69 @@ import assert from 'node:assert/strict'; import { test, vi } from 'vitest'; import type { DeviceLease } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { - AndroidAdbExecutor, - AndroidAdbProvider, +import { createAndroidInteractor } from '../../core/interactors/android.ts'; +import { + androidAdbResultError, + createAndroidPortReverseManager, + type AndroidAdbExecutor, + type AndroidAdbProvider, } from '../../platforms/android/adb-executor.ts'; +import { + getAndroidAppStateWithAdb, + listAndroidAppsWithAdb, +} from '../../platforms/android/app-helpers.ts'; +import { inferAndroidAppName } from '../../platforms/android/app-lifecycle.ts'; +import { + dismissAndroidKeyboardWithAdb, + getAndroidKeyboardStatusWithAdb, +} from '../../platforms/android/device-input-state.ts'; +import { captureAndroidLogcatWithAdb } from '../../platforms/android/logcat.ts'; import type { LimrunAndroidSession } from './android.ts'; import { createLimrunDeviceSession, type LimrunIosCommandExecution } from './device-session.ts'; import type { LimrunIosSession } from './ios.ts'; +import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; const IOS_APPS = [ { bundleId: 'com.apple.Preferences', name: 'Settings', installType: 'System' }, { bundleId: 'com.example.ios', name: 'Example', installType: 'User' }, ]; +const TEST_DEPENDENCIES = { + clientVersion: 'test-version', + android: { + createInteractor: createAndroidInteractor, + createPortReverse: async (adb) => createAndroidPortReverseManager(adb), + inferAppName: async (packageName) => inferAndroidAppName(packageName), + listApps: async (adb, filter) => + ( + await listAndroidAppsWithAdb(adb, { + filter, + target: 'mobile', + }) + ).map((app) => ({ id: app.package, name: app.name })), + getForegroundApp: async (adb) => { + const app = await getAndroidAppStateWithAdb(adb); + return app.package ? { appId: app.package, activity: app.activity } : undefined; + }, + getKeyboardState: getAndroidKeyboardStatusWithAdb, + dismissKeyboard: dismissAndroidKeyboardWithAdb, + readLogs: async (adb, lineLimit) => + await captureAndroidLogcatWithAdb(adb, { + lines: lineLimit, + timeoutMs: 5_000, + }), + adbError: async (message, result, details) => androidAdbResultError(message, result, details), + }, + host: { + runAdb: async () => adbResult(''), + archiveDirectory: async () => undefined, + }, + ios: { + resolveAppAlias: async (app: string) => app, + readBundleAppName: async () => undefined, + }, +} satisfies LimrunRuntimeDependencies; + test('iOS device session exposes reusable capabilities without its raw client', async () => { const pressKey = vi.fn(async () => undefined); const readLogs = vi.fn(async () => 'line one\nline two\n'); @@ -165,6 +215,7 @@ function iosSession(client: Record): LimrunIosSession { instanceId: 'ios-instance', device: device('ios'), client, + dependencies: TEST_DEPENDENCIES, } as unknown as LimrunIosSession; } @@ -179,6 +230,7 @@ function androidSession( device: device('android'), client, adbProvider, + dependencies: TEST_DEPENDENCIES, } as unknown as LimrunAndroidSession; } diff --git a/src/providers/limrun/device-session.ts b/src/providers/limrun/device-session.ts index 0ee17e846c..b0725e531d 100644 --- a/src/providers/limrun/device-session.ts +++ b/src/providers/limrun/device-session.ts @@ -1,11 +1,11 @@ import type { Interactor } from '@agent-device/contracts/interaction'; import { resolveAppsFilter, type AppsFilter } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { AndroidAdbProvider } from '../../platforms/android/adb-executor.ts'; import type { - AndroidKeyboardDismissResult, - AndroidKeyboardState, -} from '../../platforms/android/device-input-state.ts'; + LimrunAdbProvider, + LimrunAndroidKeyboardDismissResult, + LimrunAndroidKeyboardState, +} from './runtime-dependencies.ts'; import { createLimrunAndroidInteractor, type LimrunAndroidSession } from './android.ts'; import { createLimrunIosInteractor, @@ -69,10 +69,10 @@ type LimrunRecordingClient = { export type LimrunAndroidDeviceSession = LimrunDeviceSessionBase & { readonly platform: 'android'; - readonly adb: AndroidAdbProvider; + readonly adb: LimrunAdbProvider; getForegroundApp(): Promise; - getKeyboardState(): Promise; - dismissKeyboard(): Promise; + getKeyboardState(): Promise; + dismissKeyboard(): Promise; readLogs(lineLimit: number): Promise; installRemoteApp(url: string): Promise; }; @@ -105,43 +105,15 @@ function createAndroidDeviceSession(session: LimrunAndroidSession): LimrunAndroi device: session.device, interactor: createLimrunAndroidInteractor(session), adb: session.adbProvider, - listApps: async (filter) => { - const { listAndroidAppsWithAdb } = await import('../../platforms/android/app-helpers.ts'); - return ( - await listAndroidAppsWithAdb(adb, { - filter: resolveAppsFilter(filter), - target: 'mobile', - }) - ).map((app) => ({ - id: app.package, - name: app.name, - })); - }, - getForegroundApp: async () => { - const { getAndroidAppStateWithAdb } = await import('../../platforms/android/app-helpers.ts'); - const app = await getAndroidAppStateWithAdb(adb); - return app.package ? { appId: app.package, activity: app.activity } : undefined; - }, + listApps: async (filter) => + await session.dependencies.android.listApps(adb, resolveAppsFilter(filter)), + getForegroundApp: async () => await session.dependencies.android.getForegroundApp(adb), pressKey: async (key, modifiers) => { await session.client.pressKey(key, modifiers); }, - getKeyboardState: async () => { - const { getAndroidKeyboardStatusWithAdb } = - await import('../../platforms/android/device-input-state.ts'); - return await getAndroidKeyboardStatusWithAdb(adb); - }, - dismissKeyboard: async () => { - const { dismissAndroidKeyboardWithAdb } = - await import('../../platforms/android/device-input-state.ts'); - return await dismissAndroidKeyboardWithAdb(adb); - }, - readLogs: async (lineLimit) => { - const { captureAndroidLogcatWithAdb } = await import('../../platforms/android/logcat.ts'); - return await captureAndroidLogcatWithAdb(adb, { - lines: lineLimit, - timeoutMs: 5_000, - }); - }, + getKeyboardState: async () => await session.dependencies.android.getKeyboardState(adb), + dismissKeyboard: async () => await session.dependencies.android.dismissKeyboard(adb), + readLogs: async (lineLimit) => await session.dependencies.android.readLogs(adb, lineLimit), ...createRecordingOperations(session.client), installRemoteApp: async (url) => { await session.client.sendAsset(url); diff --git a/src/providers/limrun/ios.ts b/src/providers/limrun/ios.ts index b827bbb6a1..29a82aad02 100644 --- a/src/providers/limrun/ios.ts +++ b/src/providers/limrun/ios.ts @@ -20,10 +20,10 @@ import { import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { execFailureDetails, runCmd } from '../../utils/exec.ts'; -import { sleep } from '../../utils/timeouts.ts'; +import { setTimeout as sleep } from 'node:timers/promises'; import { flattenIosTree, toIosSelector, writeBase64File, type IosTreeNode } from './snapshot.ts'; import { normalizeOptionalString } from './strings.ts'; +import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; export type LimrunIosSession = { platform: 'ios'; @@ -31,6 +31,7 @@ export type LimrunIosSession = { instanceId: string; device: DeviceInfo; client: LimrunIosClient; + readonly dependencies: Pick; }; export type LimrunIosRemoteInstallOptions = { @@ -45,13 +46,16 @@ export type LimrunIosRemoteInstallResult = { type LimrunIosApp = Awaited>[number]; -export async function createLimrunIosSession(options: { - lease: DeviceLease; - instanceId: string; - device: DeviceInfo; - apiUrl: string; - token: string; -}): Promise { +export async function createLimrunIosSession( + options: { + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + apiUrl: string; + token: string; + }, + dependencies: Pick, +): Promise { const client = await createIosInstanceClient({ apiUrl: options.apiUrl, token: options.token, @@ -63,6 +67,7 @@ export async function createLimrunIosSession(options: { instanceId: options.instanceId, device: options.device, client, + dependencies, }; } @@ -72,7 +77,7 @@ export async function installLimrunIosApp( installablePath: string, options?: ProviderDeviceInstallOptions, ): Promise { - const prepared = await prepareLimrunIosAsset(installablePath); + const prepared = await prepareLimrunIosAsset(installablePath, session.dependencies); try { const asset = await limrun.assets.getOrUpload({ path: prepared.uploadPath, @@ -140,7 +145,7 @@ class LimrunIosInteractor implements Interactor { async open(app: string, options?: { url?: string }): Promise { if (options?.url) { - await this.session.client.launchApp(await resolveIosTarget(app)); + await this.session.client.launchApp(await this.session.dependencies.ios.resolveAppAlias(app)); await this.session.client.openUrl(options.url); return; } @@ -148,13 +153,17 @@ class LimrunIosInteractor implements Interactor { await this.session.client.openUrl(app); return; } - await this.session.client.launchApp(await resolveIosTarget(app)); + await this.session.client.launchApp(await this.session.dependencies.ios.resolveAppAlias(app)); } async openDevice(): Promise {} async close(app: string): Promise { - if (app) await this.session.client.terminateApp(await resolveIosTarget(app)).catch(() => {}); + if (app) { + await this.session.client + .terminateApp(await this.session.dependencies.ios.resolveAppAlias(app)) + .catch(() => {}); + } } async tap(x: number, y: number): Promise { @@ -265,7 +274,10 @@ class LimrunIosInteractor implements Interactor { } } -async function prepareLimrunIosAsset(artifactPath: string): Promise<{ +async function prepareLimrunIosAsset( + artifactPath: string, + dependencies: Pick, +): Promise<{ uploadPath: string; assetName: string; appName?: string; @@ -283,32 +295,28 @@ async function prepareLimrunIosAsset(artifactPath: string): Promise<{ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-limrun-ios-app-')); const zipPath = path.join(tempDir, `${path.basename(artifactPath)}.zip`); - const result = await runCmd('zip', ['-qr', zipPath, path.basename(artifactPath)], { - cwd: path.dirname(artifactPath), - timeoutMs: 120_000, - }); - if (result.exitCode !== 0) { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - throw new AppError('COMMAND_FAILED', 'Failed to package iOS .app for Limrun install', { - command: ['zip', '-qr', zipPath, path.basename(artifactPath)].join(' '), - ...execFailureDetails(result), + try { + await dependencies.host.archiveDirectory({ + sourceDirectory: path.dirname(artifactPath), + entryName: path.basename(artifactPath), + archivePath: zipPath, }); + } catch (error) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + throw error; } return { uploadPath: zipPath, assetName: path.basename(zipPath), - appName: await readIosBundleAppName(artifactPath), + appName: + (await dependencies.ios.readBundleAppName(artifactPath)) ?? + inferAppNameFromPath(artifactPath), cleanup: async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }, }; } -async function resolveIosTarget(app: string): Promise { - const { resolveIosAppAlias } = await import('../../platforms/apple/core/app-resolution.ts'); - return resolveIosAppAlias(app); -} - function inferAppNameFromPath(appPath: string): string | undefined { const base = path.basename(appPath).replace(/\.(?:app|ipa|apk|aab|zip)$/i, ''); return base || undefined; @@ -352,11 +360,6 @@ export function isUserInstalledIosApp(app: LimrunIosApp): boolean { ); } -async function readIosBundleAppName(appPath: string): Promise { - const { readIosBundleInfo } = await import('../../platforms/apple/core/install-artifact.ts'); - return (await readIosBundleInfo(appPath)).appName ?? inferAppNameFromPath(appPath); -} - function unsupported(command: string, message: string): never { throw new AppError('UNSUPPORTED_OPERATION', message, { command }); } diff --git a/src/providers/limrun/runtime-dependencies.test.ts b/src/providers/limrun/runtime-dependencies.test.ts new file mode 100644 index 0000000000..dd14a5b43c --- /dev/null +++ b/src/providers/limrun/runtime-dependencies.test.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; +import type { AppsFilter, DeviceLease } from '@agent-device/contracts/device'; +import type { Interactor } from '@agent-device/contracts/interaction'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { createLimrunRuntime } from './runtime.ts'; +import type { + LimrunAdbProvider, + LimrunAdbExecutor, + LimrunPortReverseMapping, + LimrunRuntimeDependencies, +} from './runtime-dependencies.ts'; + +const state = vi.hoisted(() => ({ + constructorOptions: [] as Array<{ defaultHeaders?: Record }>, + tunnelClose: vi.fn(), + disconnect: vi.fn(), +})); + +vi.mock('@limrun/api', () => ({ + default: class MockLimrun { + readonly iosInstances = { + create: vi.fn(), + list: vi.fn(), + delete: vi.fn(), + }; + + readonly androidInstances = { + create: vi.fn(async () => ({ + metadata: { id: 'android-instance-1' }, + status: { + token: 'instance-token', + apiUrl: 'https://android.example', + adbWebSocketUrl: 'wss://adb.example', + }, + })), + list: vi.fn(), + delete: vi.fn(async () => undefined), + }; + + readonly assets = { + getOrUpload: vi.fn(), + }; + + constructor(options: { defaultHeaders?: Record }) { + state.constructorOptions.push(options); + } + }, +})); + +vi.mock('@limrun/api/instance-client', () => ({ + createInstanceClient: vi.fn(async () => ({ + disconnect: state.disconnect, + setText: vi.fn(async () => undefined), + startAdbTunnel: vi.fn(async () => ({ + address: { address: '127.0.0.1', port: 62_001 }, + close: state.tunnelClose, + })), + })), +})); + +test('factory uses the injected Android and host adapters as its construction seam', async () => { + const fixture = createContractFixture(); + const runtime = createLimrunRuntime({ apiKey: 'lim_test_key' }, fixture.dependencies); + + try { + const device = await allocateAndroidDevice(runtime); + + assert.equal(runtime.getInteractor(device), fixture.interactor); + const deviceSession = runtime.getDeviceSession(device); + assert.equal(deviceSession?.platform, 'android'); + if (deviceSession?.platform !== 'android') throw new Error('Expected Android device session'); + + assert.deepEqual(await deviceSession.listApps('all'), [ + { id: 'com.example.app', name: 'Example' }, + ]); + assert.equal((await deviceSession.getKeyboardState()).visible, false); + await runtime.configurePortReverse?.({ + leaseId: 'lease-android', + devicePort: 8081, + hostPort: 8081, + name: 'metro', + }); + + assert.deepEqual(state.constructorOptions[0]?.defaultHeaders, { + 'x-agent-device-client': 'agent-device-cli', + 'x-agent-device-version': 'test-version', + }); + assert.equal(fixture.createInteractor.mock.calls[0]?.[0], device); + assert.equal(fixture.listApps.mock.calls[0]?.[1], 'all'); + assert.equal(fixture.getKeyboardState.mock.calls.length, 1); + assert.deepEqual(fixture.adbCalls[0], [ + '-s', + '127.0.0.1:62001', + 'reverse', + 'tcp:8081', + 'tcp:8081', + ]); + } finally { + await runtime.shutdown(); + } + + assert.deepEqual(fixture.adbCalls.slice(-2), [ + ['-s', '127.0.0.1:62001', 'reverse', '--remove', 'tcp:8081'], + ['disconnect', '127.0.0.1:62001'], + ]); + assert.equal(state.tunnelClose.mock.calls.length, 1); +}); + +function createContractFixture() { + const adbCalls: string[][] = []; + const activeReverseMappings: LimrunPortReverseMapping[] = []; + const interactor = { open: vi.fn() } as unknown as Interactor; + const createInteractor = vi.fn((_device: DeviceInfo, _adb: LimrunAdbProvider) => interactor); + const listApps = vi.fn(async (_adb: LimrunAdbExecutor, _filter: AppsFilter) => [ + { id: 'com.example.app', name: 'Example' }, + ]); + const getKeyboardState = vi.fn(async () => ({ + visible: false, + inputOwner: 'unknown' as const, + })); + const dependencies = { + clientVersion: 'test-version', + android: { + createInteractor, + createPortReverse: async (adb: LimrunAdbExecutor) => + createInMemoryPortReverse(adb, activeReverseMappings), + inferAppName: async () => 'Example', + listApps, + getForegroundApp: async () => ({ + appId: 'com.example.app', + activity: '.MainActivity', + }), + getKeyboardState, + dismissKeyboard: async () => ({ + visible: false, + inputOwner: 'unknown' as const, + attempts: 0, + wasVisible: false, + dismissed: false, + }), + readLogs: async () => 'log line\n', + adbError: async (message: string) => new AppError('COMMAND_FAILED', message), + }, + host: { + runAdb: async (args: string[]) => { + adbCalls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + archiveDirectory: async () => undefined, + }, + ios: { + resolveAppAlias: async (app: string) => app, + readBundleAppName: async () => undefined, + }, + } satisfies LimrunRuntimeDependencies; + return { adbCalls, createInteractor, dependencies, getKeyboardState, interactor, listApps }; +} + +function createInMemoryPortReverse(adb: LimrunAdbExecutor, mappings: LimrunPortReverseMapping[]) { + return { + ensure: async (mapping: LimrunPortReverseMapping) => { + mappings.push(mapping); + await adb(['reverse', mapping.local, mapping.remote]); + }, + remove: async (local: LimrunPortReverseMapping['local']) => { + const index = mappings.findIndex((mapping) => mapping.local === local); + if (index >= 0) mappings.splice(index, 1); + await adb(['reverse', '--remove', local]); + }, + removeAllOwned: async (ownerId: string) => { + const owned = mappings.filter((mapping) => mapping.ownerId === ownerId); + for (const mapping of owned) { + mappings.splice(mappings.indexOf(mapping), 1); + await adb(['reverse', '--remove', mapping.local]); + } + }, + list: async () => [...mappings], + }; +} + +async function allocateAndroidDevice( + runtime: ReturnType, +): Promise { + const allocation = await runtime.leaseLifecycle.allocate?.(androidLease()); + const allocatedDevice = allocation?.device; + if (!allocatedDevice || typeof allocatedDevice !== 'object') { + throw new Error('Expected allocated device'); + } + return allocatedDevice as DeviceInfo; +} + +function androidLease(): DeviceLease { + return { + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; +} diff --git a/src/providers/limrun/runtime-dependencies.ts b/src/providers/limrun/runtime-dependencies.ts new file mode 100644 index 0000000000..7f84dce62d --- /dev/null +++ b/src/providers/limrun/runtime-dependencies.ts @@ -0,0 +1,115 @@ +import type { AppsFilter } from '@agent-device/contracts/device'; +import type { Interactor } from '@agent-device/contracts/interaction'; +import type { AndroidInputOwner } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AppError } from '@agent-device/kernel/errors'; + +export type LimrunAdbCommandOptions = { + allowFailure?: boolean; + binaryStdout?: boolean; + stdin?: string | Buffer; + timeoutMs?: number; + signal?: AbortSignal; +}; + +export type LimrunAdbCommandResult = { + exitCode: number; + stdout: string; + stderr: string; + stdoutBuffer?: Buffer; +}; + +export type LimrunAdbExecutor = ( + args: string[], + options?: LimrunAdbCommandOptions, +) => Promise; + +export type LimrunPortReverseEndpoint = `tcp:${number}` | `localabstract:${string}`; + +export type LimrunPortReverseMapping = { + local: LimrunPortReverseEndpoint; + remote: LimrunPortReverseEndpoint; + ownerId?: string; +}; + +export type LimrunPortReverseOptions = { + signal?: AbortSignal; + timeoutMs?: number; +}; + +export type LimrunPortReverse = { + ensure(mapping: LimrunPortReverseMapping, options?: LimrunPortReverseOptions): Promise; + remove(local: LimrunPortReverseEndpoint, options?: LimrunPortReverseOptions): Promise; + removeAllOwned(ownerId: string, options?: LimrunPortReverseOptions): Promise; + list?(options?: LimrunPortReverseOptions): Promise; +}; + +export type LimrunAdbProvider = { + exec: LimrunAdbExecutor; + reverse?: LimrunPortReverse; + text?: (request: { + action: 'type' | 'fill'; + text: string; + delayMs?: number; + target?: { x: number; y: number }; + }) => Promise; +}; + +export type LimrunAndroidKeyboardState = { + visible: boolean; + inputType?: string; + type?: 'text' | 'number' | 'email' | 'phone' | 'password' | 'datetime' | 'unknown'; + inputMethodPackage?: string; + focusedPackage?: string; + focusedResourceId?: string; + inputOwner: AndroidInputOwner; +}; + +export type LimrunAndroidKeyboardDismissResult = LimrunAndroidKeyboardState & { + attempts: number; + wasVisible: boolean; + dismissed: boolean; +}; + +export type LimrunAndroidRuntimeAdapter = { + // Interactors need provider-scoped capabilities; command helpers below need only ADB execution. + createInteractor(device: DeviceInfo, adb: LimrunAdbProvider): Interactor; + createPortReverse(adb: LimrunAdbExecutor): Promise; + inferAppName(packageName: string): Promise; + listApps( + adb: LimrunAdbExecutor, + filter: AppsFilter, + ): Promise>; + getForegroundApp( + adb: LimrunAdbExecutor, + ): Promise<{ appId?: string; activity?: string } | undefined>; + getKeyboardState(adb: LimrunAdbExecutor): Promise; + dismissKeyboard(adb: LimrunAdbExecutor): Promise; + readLogs(adb: LimrunAdbExecutor, lineLimit: number): Promise; + adbError( + message: string, + result: LimrunAdbCommandResult, + details?: Record, + ): Promise; +}; + +export type LimrunHostAdapter = { + runAdb(args: string[], options?: LimrunAdbCommandOptions): Promise; + archiveDirectory(options: { + sourceDirectory: string; + entryName: string; + archivePath: string; + }): Promise; +}; + +export type LimrunIosRuntimeAdapter = { + resolveAppAlias(app: string): Promise; + readBundleAppName(appPath: string): Promise; +}; + +export type LimrunRuntimeDependencies = { + clientVersion: string; + android: LimrunAndroidRuntimeAdapter; + host: LimrunHostAdapter; + ios: LimrunIosRuntimeAdapter; +}; diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 643a64eaf7..aed6904104 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -12,7 +12,6 @@ import type { } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { readVersion } from '../../utils/version.ts'; import { cleanupLimrunAndroidAdbTunnel, configureLimrunAndroidPortReverse, @@ -34,6 +33,7 @@ import { type LimrunIosSession, } from './ios.ts'; import { createLimrunDeviceSession, type LimrunDeviceSession } from './device-session.ts'; +import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; type LimrunInstance = { metadata: { id: string }; @@ -53,10 +53,23 @@ export type LimrunRuntimeOptions = { const LIMRUN_CLIENT_HEADER = 'agent-device-cli'; -export class LimrunRuntime implements ProviderDeviceRuntime { +export type LimrunRuntime = ProviderDeviceRuntime & { + recoverExpiredLease: ProviderExpiredLeaseRecovery; + getDeviceSession(device: DeviceInfo): LimrunDeviceSession | undefined; +}; + +export function createLimrunRuntime( + options: LimrunRuntimeOptions, + dependencies: LimrunRuntimeDependencies, +): LimrunRuntime { + return new LimrunRuntimeImplementation(options, dependencies); +} + +class LimrunRuntimeImplementation implements ProviderDeviceRuntime { private readonly limrun: Limrun; private readonly sessions = new Map(); private readonly options: LimrunRuntimeOptions; + private readonly dependencies: LimrunRuntimeDependencies; readonly provider = LIMRUN_PROVIDER; readonly leaseLifecycle: LeaseLifecycleProvider = { @@ -75,8 +88,6 @@ export class LimrunRuntime implements ProviderDeviceRuntime { await this.release(lease); }; - // Called through ProviderDeviceRuntime composition, not by this concrete class. - // fallow-ignore-next-line unused-class-member readonly deviceInventoryProvider: DeviceInventoryProvider = async (request) => { if (request.leaseProvider !== this.provider || !request.leaseId) return null; const session = this.sessions.get(request.leaseId); @@ -85,19 +96,18 @@ export class LimrunRuntime implements ProviderDeviceRuntime { return [session.device]; }; - constructor(options: LimrunRuntimeOptions) { + constructor(options: LimrunRuntimeOptions, dependencies: LimrunRuntimeDependencies) { this.options = options; + this.dependencies = dependencies; this.limrun = new Limrun({ apiKey: options.apiKey, defaultHeaders: { 'x-agent-device-client': LIMRUN_CLIENT_HEADER, - 'x-agent-device-version': readVersion(), + 'x-agent-device-version': dependencies.clientVersion, }, }); } - // Called through ProviderDeviceRuntime composition, not by this concrete class. - // fallow-ignore-next-line unused-class-member ownsDevice(device: DeviceInfo): boolean { return parseLimrunDeviceId(device.id) !== undefined; } @@ -180,13 +190,16 @@ export class LimrunRuntime implements ProviderDeviceRuntime { if (!instance.status.apiUrl) { throw new AppError('COMMAND_FAILED', 'Limrun iOS instance did not expose apiUrl'); } - return await createLimrunIosSession({ - lease, - instanceId: instance.metadata.id, - device: buildLimrunDevice('ios', lease, instance.metadata.id), - apiUrl: instance.status.apiUrl, - token: instance.status.token, - }); + return await createLimrunIosSession( + { + lease, + instanceId: instance.metadata.id, + device: buildLimrunDevice('ios', lease, instance.metadata.id), + apiUrl: instance.status.apiUrl, + token: instance.status.token, + }, + this.dependencies, + ); } catch (error) { await this.limrun.iosInstances.delete(instance.metadata.id).catch(() => {}); throw error; @@ -206,14 +219,17 @@ export class LimrunRuntime implements ProviderDeviceRuntime { 'Limrun Android instance did not expose API and ADB websocket endpoints', ); } - return await createLimrunAndroidSession({ - lease, - instanceId: instance.metadata.id, - device: buildLimrunDevice('android', lease, instance.metadata.id), - apiUrl: instance.status.apiUrl, - adbUrl: instance.status.adbWebSocketUrl, - token: instance.status.token, - }); + return await createLimrunAndroidSession( + { + lease, + instanceId: instance.metadata.id, + device: buildLimrunDevice('android', lease, instance.metadata.id), + apiUrl: instance.status.apiUrl, + adbUrl: instance.status.adbWebSocketUrl, + token: instance.status.token, + }, + this.dependencies, + ); } catch (error) { await this.limrun.androidInstances.delete(instance.metadata.id).catch(() => {}); throw error; diff --git a/src/sdk/limrun-runtime-dependencies.test.ts b/src/sdk/limrun-runtime-dependencies.test.ts new file mode 100644 index 0000000000..4e94811ec4 --- /dev/null +++ b/src/sdk/limrun-runtime-dependencies.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; + +const moduleLoads = vi.hoisted(() => ({ + androidAppHelpers: 0, + androidLogcat: 0, + appleAppResolution: 0, + appleInstallArtifact: 0, +})); + +vi.mock('@limrun/api', () => ({ + default: class MockLimrun {}, +})); + +vi.mock('../platforms/android/app-helpers.ts', () => { + moduleLoads.androidAppHelpers += 1; + return { + getAndroidAppStateWithAdb: vi.fn(async () => ({ + package: 'com.example.app', + activity: '.MainActivity', + })), + listAndroidAppsWithAdb: vi.fn(async () => [{ package: 'com.example.app', name: 'Example' }]), + }; +}); + +vi.mock('../platforms/android/logcat.ts', () => { + moduleLoads.androidLogcat += 1; + return { + captureAndroidLogcatWithAdb: vi.fn(async () => 'log line\n'), + }; +}); + +vi.mock('../platforms/apple/core/app-resolution.ts', () => { + moduleLoads.appleAppResolution += 1; + return { + resolveIosAppAlias: vi.fn((app: string) => `resolved:${app}`), + }; +}); + +vi.mock('../platforms/apple/core/install-artifact.ts', () => { + moduleLoads.appleInstallArtifact += 1; + return { + readIosBundleInfo: vi.fn(async () => ({ appName: 'Example' })), + }; +}); + +test('Limrun construction defers operation and opposite-platform helper modules', async () => { + const { LimrunRuntime } = await import('../provider-limrun-runtime.ts'); + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); + + assert.deepEqual(moduleLoads, { + androidAppHelpers: 0, + androidLogcat: 0, + appleAppResolution: 0, + appleInstallArtifact: 0, + }); + await runtime.shutdown(); + + const { createLimrunRuntimeDependencies } = await import('./limrun-runtime-dependencies.ts'); + const dependencies = createLimrunRuntimeDependencies(); + const adb = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); + + assert.deepEqual(await dependencies.android.listApps(adb, 'all'), [ + { id: 'com.example.app', name: 'Example' }, + ]); + assert.equal(moduleLoads.androidAppHelpers, 1); + assert.equal(moduleLoads.androidLogcat, 0); + assert.equal(moduleLoads.appleAppResolution, 0); + assert.equal(moduleLoads.appleInstallArtifact, 0); + + assert.equal(await dependencies.android.readLogs(adb, 10), 'log line\n'); + assert.equal(moduleLoads.androidLogcat, 1); + assert.equal(moduleLoads.appleAppResolution, 0); + assert.equal(moduleLoads.appleInstallArtifact, 0); + + assert.equal(await dependencies.ios.resolveAppAlias('settings'), 'resolved:settings'); + assert.equal(moduleLoads.appleAppResolution, 1); + assert.equal(moduleLoads.appleInstallArtifact, 0); + + assert.equal(await dependencies.ios.readBundleAppName('/tmp/Example.app'), 'Example'); + assert.equal(moduleLoads.appleInstallArtifact, 1); +}); diff --git a/src/sdk/limrun-runtime-dependencies.ts b/src/sdk/limrun-runtime-dependencies.ts new file mode 100644 index 0000000000..5c70c31b6e --- /dev/null +++ b/src/sdk/limrun-runtime-dependencies.ts @@ -0,0 +1,87 @@ +import { AppError } from '@agent-device/kernel/errors'; +// ProviderDeviceRuntime.getInteractor is synchronous, so this previously eager factory remains the +// deliberate static edge; making it lazy would require a proxy interactor rather than this seam. +import { createAndroidInteractor } from '../core/interactors/android.ts'; +import type { LimrunRuntimeDependencies } from '../providers/limrun/runtime-dependencies.ts'; +import { execFailureDetails, runCmd } from '../utils/exec.ts'; +import { readVersion } from '../utils/version.ts'; + +export function createLimrunRuntimeDependencies(): LimrunRuntimeDependencies { + return { + clientVersion: readVersion(), + android: { + createInteractor: (device, adb) => createAndroidInteractor(device, adb), + createPortReverse: async (adb) => { + const { createAndroidPortReverseManager } = + await import('../platforms/android/adb-executor.ts'); + return createAndroidPortReverseManager(adb); + }, + inferAppName: async (packageName) => { + const { inferAndroidAppName } = await import('../platforms/android/app-lifecycle.ts'); + return inferAndroidAppName(packageName); + }, + listApps: async (adb, filter) => { + const { listAndroidAppsWithAdb } = await import('../platforms/android/app-helpers.ts'); + return ( + await listAndroidAppsWithAdb(adb, { + filter, + target: 'mobile', + }) + ).map((app) => ({ id: app.package, name: app.name })); + }, + getForegroundApp: async (adb) => { + const { getAndroidAppStateWithAdb } = await import('../platforms/android/app-helpers.ts'); + const app = await getAndroidAppStateWithAdb(adb); + return app.package ? { appId: app.package, activity: app.activity } : undefined; + }, + getKeyboardState: async (adb) => { + const { getAndroidKeyboardStatusWithAdb } = + await import('../platforms/android/device-input-state.ts'); + return await getAndroidKeyboardStatusWithAdb(adb); + }, + dismissKeyboard: async (adb) => { + const { dismissAndroidKeyboardWithAdb } = + await import('../platforms/android/device-input-state.ts'); + return await dismissAndroidKeyboardWithAdb(adb); + }, + readLogs: async (adb, lineLimit) => { + const { captureAndroidLogcatWithAdb } = await import('../platforms/android/logcat.ts'); + return await captureAndroidLogcatWithAdb(adb, { + lines: lineLimit, + timeoutMs: 5_000, + }); + }, + adbError: async (message, result, details) => { + // Error construction is async so the platform helper remains lazy until an ADB failure. + const { androidAdbResultError } = await import('../platforms/android/adb-executor.ts'); + return androidAdbResultError(message, result, details); + }, + }, + host: { + runAdb: async (args, options) => await runCmd('adb', args, options), + archiveDirectory: async ({ sourceDirectory, entryName, archivePath }) => { + const args = ['-qr', archivePath, entryName]; + const result = await runCmd('zip', args, { + cwd: sourceDirectory, + timeoutMs: 120_000, + }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Failed to package iOS .app for Limrun install', { + command: ['zip', ...args].join(' '), + ...execFailureDetails(result), + }); + } + }, + }, + ios: { + resolveAppAlias: async (app) => { + const { resolveIosAppAlias } = await import('../platforms/apple/core/app-resolution.ts'); + return resolveIosAppAlias(app); + }, + readBundleAppName: async (appPath) => { + const { readIosBundleInfo } = await import('../platforms/apple/core/install-artifact.ts'); + return (await readIosBundleInfo(appPath)).appName; + }, + }, + }; +} diff --git a/src/sdk/limrun.ts b/src/sdk/limrun.ts index cb216eff31..f0c2d82c5d 100644 --- a/src/sdk/limrun.ts +++ b/src/sdk/limrun.ts @@ -1,12 +1,11 @@ -export { LimrunRuntime, type LimrunRuntimeOptions } from '../providers/limrun/runtime.ts'; +export { LimrunRuntime, type LimrunRuntimeOptions } from '../provider-limrun-runtime.ts'; +export type { LimrunAndroidDeviceSession, LimrunDeviceSession } from '../limrun-runtime-types.ts'; export type { AndroidAdbProvider } from '../platforms/android/adb-executor.ts'; export type { AndroidKeyboardDismissResult, AndroidKeyboardState, } from '../platforms/android/device-input-state.ts'; export type { - LimrunAndroidDeviceSession, - LimrunDeviceSession, LimrunForegroundApp, LimrunInstalledApp, LimrunIosCommandExecution,