From 0fd142fcf212c59c8e01b66301365abcd7b4aee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Jul 2026 17:42:30 +0200 Subject: [PATCH 1/3] refactor: isolate Limrun construction dependencies --- src/limrun-runtime-types.ts | 20 ++ src/provider-device-runtimes.ts | 2 +- src/provider-limrun-runtime.ts | 81 +++++++ src/providers/limrun/android.ts | 89 ++++---- src/providers/limrun/device-session.test.ts | 60 +++++- src/providers/limrun/device-session.ts | 65 ++---- src/providers/limrun/ios.ts | 60 +++--- .../limrun/runtime-dependencies.test.ts | 204 ++++++++++++++++++ src/providers/limrun/runtime-dependencies.ts | 113 ++++++++++ src/providers/limrun/runtime.ts | 51 +++-- src/sdk/limrun-runtime-dependencies.ts | 71 ++++++ src/sdk/limrun.ts | 5 +- 12 files changed, 680 insertions(+), 141 deletions(-) create mode 100644 src/limrun-runtime-types.ts create mode 100644 src/provider-limrun-runtime.ts create mode 100644 src/providers/limrun/runtime-dependencies.test.ts create mode 100644 src/providers/limrun/runtime-dependencies.ts create mode 100644 src/sdk/limrun-runtime-dependencies.ts 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..bb5972a771 --- /dev/null +++ b/src/provider-limrun-runtime.ts @@ -0,0 +1,81 @@ +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; + readonly leaseLifecycle: LeaseLifecycleProvider; + readonly recoverExpiredLease: ProviderExpiredLeaseRecovery; + readonly deviceInventoryProvider: DeviceInventoryProvider; + + constructor(options: LimrunRuntimeOptions) { + this.implementation = createLimrunRuntime(options, createLimrunRuntimeDependencies()); + this.leaseLifecycle = this.implementation.leaseLifecycle; + this.recoverExpiredLease = this.implementation.recoverExpiredLease; + this.deviceInventoryProvider = 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..dc2b5fd001 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; + 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 = dependencies.android.createPortReverse(adbProvider); 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( @@ -94,9 +96,7 @@ export async function installLimrunAndroidApp( name: buildAndroidAssetName(packageName, installablePath), }); await session.client.sendAsset(asset.signedDownloadUrl); - const appName = packageName - ? (await import('../../platforms/android/app-lifecycle.ts')).inferAndroidAppName(packageName) - : undefined; + const appName = packageName ? session.dependencies.android.inferAppName(packageName) : undefined; return { ...(packageName ? { packageName, launchTarget: packageName } : {}), ...(appName ? { appName } : {}), @@ -119,10 +119,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 +137,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 +151,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 +181,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 dependencies.android.adbError('Limrun Android ADB command failed', result, { command: ['adb', ...adbArgs].join(' '), }); } @@ -206,7 +213,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..fc22106aea 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_IOS_ADAPTER = { + resolveAppAlias: (app: string) => app, + readBundleAppName: async () => undefined, +}; + +const TEST_ANDROID_DEPENDENCIES = { + android: { + createInteractor: createAndroidInteractor, + createPortReverse: createAndroidPortReverseManager, + inferAppName: inferAndroidAppName, + 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: androidAdbResultError, + }, + host: { + runAdb: async () => adbResult(''), + archiveDirectory: async () => undefined, + }, +} satisfies Pick; + 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'); @@ -32,6 +82,7 @@ test('iOS device session exposes reusable capabilities without its raw client', stopRecording, simctl, }), + TEST_IOS_ADAPTER, ); assert.equal(session.platform, 'ios'); @@ -81,6 +132,7 @@ test('iOS remote install waits for eventually consistent app inventory', async ( listApps, installApp, }), + TEST_IOS_ADAPTER, ); assert.equal(session.platform, 'ios'); @@ -127,6 +179,7 @@ test('Android device session exposes semantic ADB capabilities without its raw c const sendAsset = vi.fn(async () => undefined); const session = createLimrunDeviceSession( androidSession(provider, { pressKey, startRecording, stopRecording, sendAsset }), + TEST_IOS_ADAPTER, ); assert.equal(session.platform, 'android'); @@ -179,6 +232,7 @@ function androidSession( device: device('android'), client, adbProvider, + dependencies: TEST_ANDROID_DEPENDENCIES, } as unknown as LimrunAndroidSession; } diff --git a/src/providers/limrun/device-session.ts b/src/providers/limrun/device-session.ts index 0ee17e846c..0cb3992125 100644 --- a/src/providers/limrun/device-session.ts +++ b/src/providers/limrun/device-session.ts @@ -1,11 +1,12 @@ 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, + LimrunIosRuntimeAdapter, +} from './runtime-dependencies.ts'; import { createLimrunAndroidInteractor, type LimrunAndroidSession } from './android.ts'; import { createLimrunIosInteractor, @@ -69,10 +70,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; }; @@ -92,10 +93,11 @@ export type LimrunDeviceSession = LimrunAndroidDeviceSession | LimrunIosDeviceSe export function createLimrunDeviceSession( session: LimrunAndroidSession | LimrunIosSession, + ios: LimrunIosRuntimeAdapter, ): LimrunDeviceSession { return session.platform === 'android' ? createAndroidDeviceSession(session) - : createIosDeviceSession(session); + : createIosDeviceSession(session, ios); } function createAndroidDeviceSession(session: LimrunAndroidSession): LimrunAndroidDeviceSession { @@ -105,43 +107,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); @@ -149,11 +123,14 @@ function createAndroidDeviceSession(session: LimrunAndroidSession): LimrunAndroi }; } -function createIosDeviceSession(session: LimrunIosSession): LimrunIosDeviceSession { +function createIosDeviceSession( + session: LimrunIosSession, + ios: LimrunIosRuntimeAdapter, +): LimrunIosDeviceSession { return { platform: 'ios', device: session.device, - interactor: createLimrunIosInteractor(session), + interactor: createLimrunIosInteractor(session, ios), viewport: { width: session.client.deviceInfo.screenWidth, height: session.client.deviceInfo.screenHeight, diff --git a/src/providers/limrun/ios.ts b/src/providers/limrun/ios.ts index b827bbb6a1..60dc7b60a4 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 { LimrunIosRuntimeAdapter, LimrunRuntimeDependencies } from './runtime-dependencies.ts'; export type LimrunIosSession = { platform: 'ios'; @@ -70,9 +70,10 @@ export async function installLimrunIosApp( limrun: Limrun, session: LimrunIosSession, installablePath: string, + dependencies: Pick, options?: ProviderDeviceInstallOptions, ): Promise { - const prepared = await prepareLimrunIosAsset(installablePath); + const prepared = await prepareLimrunIosAsset(installablePath, dependencies); try { const asset = await limrun.assets.getOrUpload({ path: prepared.uploadPath, @@ -127,20 +128,25 @@ export async function installLimrunIosRemoteApp( }); } -export function createLimrunIosInteractor(session: LimrunIosSession): Interactor { - return new LimrunIosInteractor(session); +export function createLimrunIosInteractor( + session: LimrunIosSession, + ios: LimrunIosRuntimeAdapter, +): Interactor { + return new LimrunIosInteractor(session, ios); } class LimrunIosInteractor implements Interactor { private readonly session: LimrunIosSession; + private readonly ios: LimrunIosRuntimeAdapter; - constructor(session: LimrunIosSession) { + constructor(session: LimrunIosSession, ios: LimrunIosRuntimeAdapter) { this.session = session; + this.ios = ios; } async open(app: string, options?: { url?: string }): Promise { if (options?.url) { - await this.session.client.launchApp(await resolveIosTarget(app)); + await this.session.client.launchApp(this.ios.resolveAppAlias(app)); await this.session.client.openUrl(options.url); return; } @@ -148,13 +154,13 @@ class LimrunIosInteractor implements Interactor { await this.session.client.openUrl(app); return; } - await this.session.client.launchApp(await resolveIosTarget(app)); + await this.session.client.launchApp(this.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(this.ios.resolveAppAlias(app)).catch(() => {}); } async tap(x: number, y: number): Promise { @@ -265,7 +271,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 +292,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 +357,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..217c62f40d --- /dev/null +++ b/src/providers/limrun/runtime-dependencies.test.ts @@ -0,0 +1,204 @@ +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 { 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: (adb: LimrunAdbProvider) => + createInMemoryPortReverse(adb, activeReverseMappings), + inferAppName: () => '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: (message: string) => new Error(message), + }, + host: { + runAdb: async (args: string[]) => { + adbCalls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + archiveDirectory: async () => undefined, + }, + ios: { + resolveAppAlias: (app: string) => app, + readBundleAppName: async () => undefined, + }, + } satisfies LimrunRuntimeDependencies; + return { adbCalls, createInteractor, dependencies, getKeyboardState, interactor, listApps }; +} + +function createInMemoryPortReverse(adb: LimrunAdbProvider, mappings: LimrunPortReverseMapping[]) { + return { + ensure: async (mapping: LimrunPortReverseMapping) => { + mappings.push(mapping); + await adb.exec(['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.exec(['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.exec(['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..10fb5252ba --- /dev/null +++ b/src/providers/limrun/runtime-dependencies.ts @@ -0,0 +1,113 @@ +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'; + +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 = { + createInteractor(device: DeviceInfo, adb: LimrunAdbProvider): Interactor; + createPortReverse(adb: LimrunAdbProvider): LimrunPortReverse; + inferAppName(packageName: string): string; + 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, + ): Error; +}; + +export type LimrunHostAdapter = { + runAdb(args: string[], options?: LimrunAdbCommandOptions): Promise; + archiveDirectory(options: { + sourceDirectory: string; + entryName: string; + archivePath: string; + }): Promise; +}; + +export type LimrunIosRuntimeAdapter = { + resolveAppAlias(app: string): string; + 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..be0085ee8e 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; } @@ -106,13 +116,13 @@ export class LimrunRuntime implements ProviderDeviceRuntime { const session = this.getSessionForDevice(device); if (!session) return undefined; return session.platform === 'ios' - ? createLimrunIosInteractor(session) + ? createLimrunIosInteractor(session, this.dependencies.ios) : createLimrunAndroidInteractor(session); } getDeviceSession(device: DeviceInfo): LimrunDeviceSession | undefined { const session = this.getSessionForDevice(device); - return session ? createLimrunDeviceSession(session) : undefined; + return session ? createLimrunDeviceSession(session, this.dependencies.ios) : undefined; } async installApp( @@ -136,7 +146,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { const session = this.getSessionForDevice(device); if (!session) return undefined; return session.platform === 'ios' - ? await installLimrunIosApp(this.limrun, session, installablePath, options) + ? await installLimrunIosApp(this.limrun, session, installablePath, this.dependencies, options) : await installLimrunAndroidApp(this.limrun, session, installablePath, options); } @@ -206,14 +216,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.ts b/src/sdk/limrun-runtime-dependencies.ts new file mode 100644 index 0000000000..a98d091a1e --- /dev/null +++ b/src/sdk/limrun-runtime-dependencies.ts @@ -0,0 +1,71 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { createAndroidInteractor } from '../core/interactors/android.ts'; +import { + androidAdbResultError, + createAndroidPortReverseManager, +} 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 { resolveIosAppAlias } from '../platforms/apple/core/app-resolution.ts'; +import { readIosBundleInfo } from '../platforms/apple/core/install-artifact.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: (adb) => createAndroidPortReverseManager(adb), + inferAppName: inferAndroidAppName, + 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: androidAdbResultError, + }, + 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: resolveIosAppAlias, + readBundleAppName: async (appPath) => (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, From 0fa0915d5e7ea00ec00145ddf23a59ef3b5ee5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Jul 2026 18:07:19 +0200 Subject: [PATCH 2/3] fix: preserve lazy Limrun capability loading --- src/providers/limrun/android.ts | 8 +- src/providers/limrun/device-session.test.ts | 8 +- src/providers/limrun/ios.ts | 8 +- .../limrun/runtime-dependencies.test.ts | 8 +- src/providers/limrun/runtime-dependencies.ts | 8 +- src/sdk/limrun-runtime-dependencies.test.ts | 82 +++++++++++++++++++ src/sdk/limrun-runtime-dependencies.ts | 71 +++++++++------- 7 files changed, 146 insertions(+), 47 deletions(-) create mode 100644 src/sdk/limrun-runtime-dependencies.test.ts diff --git a/src/providers/limrun/android.ts b/src/providers/limrun/android.ts index dc2b5fd001..91595f0404 100644 --- a/src/providers/limrun/android.ts +++ b/src/providers/limrun/android.ts @@ -71,7 +71,7 @@ export async function createLimrunAndroidSession( await client.setText(request.target, request.text); }, }; - adbProvider.reverse = dependencies.android.createPortReverse(adbProvider); + adbProvider.reverse = await dependencies.android.createPortReverse(adbProvider); return Object.assign(session, { adbProvider }); } @@ -96,7 +96,9 @@ export async function installLimrunAndroidApp( name: buildAndroidAssetName(packageName, installablePath), }); await session.client.sendAsset(asset.signedDownloadUrl); - const appName = packageName ? session.dependencies.android.inferAppName(packageName) : undefined; + const appName = packageName + ? await session.dependencies.android.inferAppName(packageName) + : undefined; return { ...(packageName ? { packageName, launchTarget: packageName } : {}), ...(appName ? { appName } : {}), @@ -186,7 +188,7 @@ async function requireSuccessfulLimrunAndroidAdb( dependencies: Pick, ): Promise { if (result.exitCode !== 0 && allowFailure !== true) { - throw dependencies.android.adbError('Limrun Android ADB command failed', result, { + throw await dependencies.android.adbError('Limrun Android ADB command failed', result, { command: ['adb', ...adbArgs].join(' '), }); } diff --git a/src/providers/limrun/device-session.test.ts b/src/providers/limrun/device-session.test.ts index fc22106aea..a8489b8514 100644 --- a/src/providers/limrun/device-session.test.ts +++ b/src/providers/limrun/device-session.test.ts @@ -30,15 +30,15 @@ const IOS_APPS = [ ]; const TEST_IOS_ADAPTER = { - resolveAppAlias: (app: string) => app, + resolveAppAlias: async (app: string) => app, readBundleAppName: async () => undefined, }; const TEST_ANDROID_DEPENDENCIES = { android: { createInteractor: createAndroidInteractor, - createPortReverse: createAndroidPortReverseManager, - inferAppName: inferAndroidAppName, + createPortReverse: async (adb) => createAndroidPortReverseManager(adb), + inferAppName: async (packageName) => inferAndroidAppName(packageName), listApps: async (adb, filter) => ( await listAndroidAppsWithAdb(adb, { @@ -57,7 +57,7 @@ const TEST_ANDROID_DEPENDENCIES = { lines: lineLimit, timeoutMs: 5_000, }), - adbError: androidAdbResultError, + adbError: async (message, result, details) => androidAdbResultError(message, result, details), }, host: { runAdb: async () => adbResult(''), diff --git a/src/providers/limrun/ios.ts b/src/providers/limrun/ios.ts index 60dc7b60a4..ab2abd9171 100644 --- a/src/providers/limrun/ios.ts +++ b/src/providers/limrun/ios.ts @@ -146,7 +146,7 @@ class LimrunIosInteractor implements Interactor { async open(app: string, options?: { url?: string }): Promise { if (options?.url) { - await this.session.client.launchApp(this.ios.resolveAppAlias(app)); + await this.session.client.launchApp(await this.ios.resolveAppAlias(app)); await this.session.client.openUrl(options.url); return; } @@ -154,13 +154,15 @@ class LimrunIosInteractor implements Interactor { await this.session.client.openUrl(app); return; } - await this.session.client.launchApp(this.ios.resolveAppAlias(app)); + await this.session.client.launchApp(await this.ios.resolveAppAlias(app)); } async openDevice(): Promise {} async close(app: string): Promise { - if (app) await this.session.client.terminateApp(this.ios.resolveAppAlias(app)).catch(() => {}); + if (app) { + await this.session.client.terminateApp(await this.ios.resolveAppAlias(app)).catch(() => {}); + } } async tap(x: number, y: number): Promise { diff --git a/src/providers/limrun/runtime-dependencies.test.ts b/src/providers/limrun/runtime-dependencies.test.ts index 217c62f40d..43bd531197 100644 --- a/src/providers/limrun/runtime-dependencies.test.ts +++ b/src/providers/limrun/runtime-dependencies.test.ts @@ -123,9 +123,9 @@ function createContractFixture() { clientVersion: 'test-version', android: { createInteractor, - createPortReverse: (adb: LimrunAdbProvider) => + createPortReverse: async (adb: LimrunAdbProvider) => createInMemoryPortReverse(adb, activeReverseMappings), - inferAppName: () => 'Example', + inferAppName: async () => 'Example', listApps, getForegroundApp: async () => ({ appId: 'com.example.app', @@ -140,7 +140,7 @@ function createContractFixture() { dismissed: false, }), readLogs: async () => 'log line\n', - adbError: (message: string) => new Error(message), + adbError: async (message: string) => new Error(message), }, host: { runAdb: async (args: string[]) => { @@ -150,7 +150,7 @@ function createContractFixture() { archiveDirectory: async () => undefined, }, ios: { - resolveAppAlias: (app: string) => app, + resolveAppAlias: async (app: string) => app, readBundleAppName: async () => undefined, }, } satisfies LimrunRuntimeDependencies; diff --git a/src/providers/limrun/runtime-dependencies.ts b/src/providers/limrun/runtime-dependencies.ts index 10fb5252ba..fe1e0678c5 100644 --- a/src/providers/limrun/runtime-dependencies.ts +++ b/src/providers/limrun/runtime-dependencies.ts @@ -72,8 +72,8 @@ export type LimrunAndroidKeyboardDismissResult = LimrunAndroidKeyboardState & { export type LimrunAndroidRuntimeAdapter = { createInteractor(device: DeviceInfo, adb: LimrunAdbProvider): Interactor; - createPortReverse(adb: LimrunAdbProvider): LimrunPortReverse; - inferAppName(packageName: string): string; + createPortReverse(adb: LimrunAdbProvider): Promise; + inferAppName(packageName: string): Promise; listApps( adb: LimrunAdbExecutor, filter: AppsFilter, @@ -88,7 +88,7 @@ export type LimrunAndroidRuntimeAdapter = { message: string, result: LimrunAdbCommandResult, details?: Record, - ): Error; + ): Promise; }; export type LimrunHostAdapter = { @@ -101,7 +101,7 @@ export type LimrunHostAdapter = { }; export type LimrunIosRuntimeAdapter = { - resolveAppAlias(app: string): string; + resolveAppAlias(app: string): Promise; readBundleAppName(appPath: string): Promise; }; 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 index a98d091a1e..ffbb1955f1 100644 --- a/src/sdk/limrun-runtime-dependencies.ts +++ b/src/sdk/limrun-runtime-dependencies.ts @@ -1,21 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; import { createAndroidInteractor } from '../core/interactors/android.ts'; -import { - androidAdbResultError, - createAndroidPortReverseManager, -} 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 { resolveIosAppAlias } from '../platforms/apple/core/app-resolution.ts'; -import { readIosBundleInfo } from '../platforms/apple/core/install-artifact.ts'; import type { LimrunRuntimeDependencies } from '../providers/limrun/runtime-dependencies.ts'; import { execFailureDetails, runCmd } from '../utils/exec.ts'; import { readVersion } from '../utils/version.ts'; @@ -25,27 +9,50 @@ export function createLimrunRuntimeDependencies(): LimrunRuntimeDependencies { clientVersion: readVersion(), android: { createInteractor: (device, adb) => createAndroidInteractor(device, adb), - createPortReverse: (adb) => createAndroidPortReverseManager(adb), - inferAppName: inferAndroidAppName, - listApps: async (adb, filter) => - ( + 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 })), + ).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: getAndroidKeyboardStatusWithAdb, - dismissKeyboard: dismissAndroidKeyboardWithAdb, - readLogs: async (adb, lineLimit) => - await captureAndroidLogcatWithAdb(adb, { + 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: androidAdbResultError, + }); + }, + adbError: async (message, result, details) => { + const { androidAdbResultError } = await import('../platforms/android/adb-executor.ts'); + return androidAdbResultError(message, result, details); + }, }, host: { runAdb: async (args, options) => await runCmd('adb', args, options), @@ -64,8 +71,14 @@ export function createLimrunRuntimeDependencies(): LimrunRuntimeDependencies { }, }, ios: { - resolveAppAlias: resolveIosAppAlias, - readBundleAppName: async (appPath) => (await readIosBundleInfo(appPath)).appName, + 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; + }, }, }; } From 79d2a40f5353dd6ef197cf1b587d24adce4798f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Jul 2026 19:01:33 +0200 Subject: [PATCH 3/3] fix: align Limrun dependency ownership --- src/provider-limrun-runtime.ts | 20 ++++++--- src/providers/limrun/android.ts | 4 +- src/providers/limrun/device-session.test.ts | 20 ++++----- src/providers/limrun/device-session.ts | 11 ++--- src/providers/limrun/ios.ts | 43 ++++++++++--------- .../limrun/runtime-dependencies.test.ts | 13 +++--- src/providers/limrun/runtime-dependencies.ts | 6 ++- src/providers/limrun/runtime.ts | 23 +++++----- src/sdk/limrun-runtime-dependencies.ts | 3 ++ 9 files changed, 77 insertions(+), 66 deletions(-) diff --git a/src/provider-limrun-runtime.ts b/src/provider-limrun-runtime.ts index bb5972a771..c5fcfe2819 100644 --- a/src/provider-limrun-runtime.ts +++ b/src/provider-limrun-runtime.ts @@ -23,15 +23,23 @@ export class LimrunRuntime implements ProviderDeviceRuntime { // Called through ProviderDeviceRuntime composition and the public Limrun SDK. // fallow-ignore-next-line unused-class-member readonly provider = LIMRUN_PROVIDER; - readonly leaseLifecycle: LeaseLifecycleProvider; - readonly recoverExpiredLease: ProviderExpiredLeaseRecovery; - readonly deviceInventoryProvider: DeviceInventoryProvider; constructor(options: LimrunRuntimeOptions) { this.implementation = createLimrunRuntime(options, createLimrunRuntimeDependencies()); - this.leaseLifecycle = this.implementation.leaseLifecycle; - this.recoverExpiredLease = this.implementation.recoverExpiredLease; - this.deviceInventoryProvider = this.implementation.deviceInventoryProvider; + } + + 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. diff --git a/src/providers/limrun/android.ts b/src/providers/limrun/android.ts index 91595f0404..0cc348f442 100644 --- a/src/providers/limrun/android.ts +++ b/src/providers/limrun/android.ts @@ -33,7 +33,7 @@ type LimrunAndroidAdbSession = { adbTunnel?: LimrunAdbTunnel; adbSerial?: string; adbTunnelPromise?: Promise; - dependencies: Pick; + readonly dependencies: Pick; }; export type LimrunAndroidSession = LimrunAndroidAdbSession & { @@ -71,7 +71,7 @@ export async function createLimrunAndroidSession( await client.setText(request.target, request.text); }, }; - adbProvider.reverse = await dependencies.android.createPortReverse(adbProvider); + adbProvider.reverse = await dependencies.android.createPortReverse(adbProvider.exec); return Object.assign(session, { adbProvider }); } diff --git a/src/providers/limrun/device-session.test.ts b/src/providers/limrun/device-session.test.ts index a8489b8514..deecd77736 100644 --- a/src/providers/limrun/device-session.test.ts +++ b/src/providers/limrun/device-session.test.ts @@ -29,12 +29,8 @@ const IOS_APPS = [ { bundleId: 'com.example.ios', name: 'Example', installType: 'User' }, ]; -const TEST_IOS_ADAPTER = { - resolveAppAlias: async (app: string) => app, - readBundleAppName: async () => undefined, -}; - -const TEST_ANDROID_DEPENDENCIES = { +const TEST_DEPENDENCIES = { + clientVersion: 'test-version', android: { createInteractor: createAndroidInteractor, createPortReverse: async (adb) => createAndroidPortReverseManager(adb), @@ -63,7 +59,11 @@ const TEST_ANDROID_DEPENDENCIES = { runAdb: async () => adbResult(''), archiveDirectory: async () => undefined, }, -} satisfies Pick; + 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); @@ -82,7 +82,6 @@ test('iOS device session exposes reusable capabilities without its raw client', stopRecording, simctl, }), - TEST_IOS_ADAPTER, ); assert.equal(session.platform, 'ios'); @@ -132,7 +131,6 @@ test('iOS remote install waits for eventually consistent app inventory', async ( listApps, installApp, }), - TEST_IOS_ADAPTER, ); assert.equal(session.platform, 'ios'); @@ -179,7 +177,6 @@ test('Android device session exposes semantic ADB capabilities without its raw c const sendAsset = vi.fn(async () => undefined); const session = createLimrunDeviceSession( androidSession(provider, { pressKey, startRecording, stopRecording, sendAsset }), - TEST_IOS_ADAPTER, ); assert.equal(session.platform, 'android'); @@ -218,6 +215,7 @@ function iosSession(client: Record): LimrunIosSession { instanceId: 'ios-instance', device: device('ios'), client, + dependencies: TEST_DEPENDENCIES, } as unknown as LimrunIosSession; } @@ -232,7 +230,7 @@ function androidSession( device: device('android'), client, adbProvider, - dependencies: TEST_ANDROID_DEPENDENCIES, + dependencies: TEST_DEPENDENCIES, } as unknown as LimrunAndroidSession; } diff --git a/src/providers/limrun/device-session.ts b/src/providers/limrun/device-session.ts index 0cb3992125..b0725e531d 100644 --- a/src/providers/limrun/device-session.ts +++ b/src/providers/limrun/device-session.ts @@ -5,7 +5,6 @@ import type { LimrunAdbProvider, LimrunAndroidKeyboardDismissResult, LimrunAndroidKeyboardState, - LimrunIosRuntimeAdapter, } from './runtime-dependencies.ts'; import { createLimrunAndroidInteractor, type LimrunAndroidSession } from './android.ts'; import { @@ -93,11 +92,10 @@ export type LimrunDeviceSession = LimrunAndroidDeviceSession | LimrunIosDeviceSe export function createLimrunDeviceSession( session: LimrunAndroidSession | LimrunIosSession, - ios: LimrunIosRuntimeAdapter, ): LimrunDeviceSession { return session.platform === 'android' ? createAndroidDeviceSession(session) - : createIosDeviceSession(session, ios); + : createIosDeviceSession(session); } function createAndroidDeviceSession(session: LimrunAndroidSession): LimrunAndroidDeviceSession { @@ -123,14 +121,11 @@ function createAndroidDeviceSession(session: LimrunAndroidSession): LimrunAndroi }; } -function createIosDeviceSession( - session: LimrunIosSession, - ios: LimrunIosRuntimeAdapter, -): LimrunIosDeviceSession { +function createIosDeviceSession(session: LimrunIosSession): LimrunIosDeviceSession { return { platform: 'ios', device: session.device, - interactor: createLimrunIosInteractor(session, ios), + interactor: createLimrunIosInteractor(session), viewport: { width: session.client.deviceInfo.screenWidth, height: session.client.deviceInfo.screenHeight, diff --git a/src/providers/limrun/ios.ts b/src/providers/limrun/ios.ts index ab2abd9171..29a82aad02 100644 --- a/src/providers/limrun/ios.ts +++ b/src/providers/limrun/ios.ts @@ -23,7 +23,7 @@ import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { flattenIosTree, toIosSelector, writeBase64File, type IosTreeNode } from './snapshot.ts'; import { normalizeOptionalString } from './strings.ts'; -import type { LimrunIosRuntimeAdapter, LimrunRuntimeDependencies } from './runtime-dependencies.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, }; } @@ -70,10 +75,9 @@ export async function installLimrunIosApp( limrun: Limrun, session: LimrunIosSession, installablePath: string, - dependencies: Pick, options?: ProviderDeviceInstallOptions, ): Promise { - const prepared = await prepareLimrunIosAsset(installablePath, dependencies); + const prepared = await prepareLimrunIosAsset(installablePath, session.dependencies); try { const asset = await limrun.assets.getOrUpload({ path: prepared.uploadPath, @@ -128,25 +132,20 @@ export async function installLimrunIosRemoteApp( }); } -export function createLimrunIosInteractor( - session: LimrunIosSession, - ios: LimrunIosRuntimeAdapter, -): Interactor { - return new LimrunIosInteractor(session, ios); +export function createLimrunIosInteractor(session: LimrunIosSession): Interactor { + return new LimrunIosInteractor(session); } class LimrunIosInteractor implements Interactor { private readonly session: LimrunIosSession; - private readonly ios: LimrunIosRuntimeAdapter; - constructor(session: LimrunIosSession, ios: LimrunIosRuntimeAdapter) { + constructor(session: LimrunIosSession) { this.session = session; - this.ios = ios; } async open(app: string, options?: { url?: string }): Promise { if (options?.url) { - await this.session.client.launchApp(await this.ios.resolveAppAlias(app)); + await this.session.client.launchApp(await this.session.dependencies.ios.resolveAppAlias(app)); await this.session.client.openUrl(options.url); return; } @@ -154,14 +153,16 @@ class LimrunIosInteractor implements Interactor { await this.session.client.openUrl(app); return; } - await this.session.client.launchApp(await this.ios.resolveAppAlias(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 this.ios.resolveAppAlias(app)).catch(() => {}); + await this.session.client + .terminateApp(await this.session.dependencies.ios.resolveAppAlias(app)) + .catch(() => {}); } } diff --git a/src/providers/limrun/runtime-dependencies.test.ts b/src/providers/limrun/runtime-dependencies.test.ts index 43bd531197..dd14a5b43c 100644 --- a/src/providers/limrun/runtime-dependencies.test.ts +++ b/src/providers/limrun/runtime-dependencies.test.ts @@ -3,6 +3,7 @@ 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, @@ -123,7 +124,7 @@ function createContractFixture() { clientVersion: 'test-version', android: { createInteractor, - createPortReverse: async (adb: LimrunAdbProvider) => + createPortReverse: async (adb: LimrunAdbExecutor) => createInMemoryPortReverse(adb, activeReverseMappings), inferAppName: async () => 'Example', listApps, @@ -140,7 +141,7 @@ function createContractFixture() { dismissed: false, }), readLogs: async () => 'log line\n', - adbError: async (message: string) => new Error(message), + adbError: async (message: string) => new AppError('COMMAND_FAILED', message), }, host: { runAdb: async (args: string[]) => { @@ -157,22 +158,22 @@ function createContractFixture() { return { adbCalls, createInteractor, dependencies, getKeyboardState, interactor, listApps }; } -function createInMemoryPortReverse(adb: LimrunAdbProvider, mappings: LimrunPortReverseMapping[]) { +function createInMemoryPortReverse(adb: LimrunAdbExecutor, mappings: LimrunPortReverseMapping[]) { return { ensure: async (mapping: LimrunPortReverseMapping) => { mappings.push(mapping); - await adb.exec(['reverse', mapping.local, mapping.remote]); + 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.exec(['reverse', '--remove', local]); + 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.exec(['reverse', '--remove', mapping.local]); + await adb(['reverse', '--remove', mapping.local]); } }, list: async () => [...mappings], diff --git a/src/providers/limrun/runtime-dependencies.ts b/src/providers/limrun/runtime-dependencies.ts index fe1e0678c5..7f84dce62d 100644 --- a/src/providers/limrun/runtime-dependencies.ts +++ b/src/providers/limrun/runtime-dependencies.ts @@ -2,6 +2,7 @@ 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; @@ -71,8 +72,9 @@ export type LimrunAndroidKeyboardDismissResult = LimrunAndroidKeyboardState & { }; export type LimrunAndroidRuntimeAdapter = { + // Interactors need provider-scoped capabilities; command helpers below need only ADB execution. createInteractor(device: DeviceInfo, adb: LimrunAdbProvider): Interactor; - createPortReverse(adb: LimrunAdbProvider): Promise; + createPortReverse(adb: LimrunAdbExecutor): Promise; inferAppName(packageName: string): Promise; listApps( adb: LimrunAdbExecutor, @@ -88,7 +90,7 @@ export type LimrunAndroidRuntimeAdapter = { message: string, result: LimrunAdbCommandResult, details?: Record, - ): Promise; + ): Promise; }; export type LimrunHostAdapter = { diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index be0085ee8e..aed6904104 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -116,13 +116,13 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { const session = this.getSessionForDevice(device); if (!session) return undefined; return session.platform === 'ios' - ? createLimrunIosInteractor(session, this.dependencies.ios) + ? createLimrunIosInteractor(session) : createLimrunAndroidInteractor(session); } getDeviceSession(device: DeviceInfo): LimrunDeviceSession | undefined { const session = this.getSessionForDevice(device); - return session ? createLimrunDeviceSession(session, this.dependencies.ios) : undefined; + return session ? createLimrunDeviceSession(session) : undefined; } async installApp( @@ -146,7 +146,7 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { const session = this.getSessionForDevice(device); if (!session) return undefined; return session.platform === 'ios' - ? await installLimrunIosApp(this.limrun, session, installablePath, this.dependencies, options) + ? await installLimrunIosApp(this.limrun, session, installablePath, options) : await installLimrunAndroidApp(this.limrun, session, installablePath, options); } @@ -190,13 +190,16 @@ class LimrunRuntimeImplementation 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; diff --git a/src/sdk/limrun-runtime-dependencies.ts b/src/sdk/limrun-runtime-dependencies.ts index ffbb1955f1..5c70c31b6e 100644 --- a/src/sdk/limrun-runtime-dependencies.ts +++ b/src/sdk/limrun-runtime-dependencies.ts @@ -1,4 +1,6 @@ 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'; @@ -50,6 +52,7 @@ export function createLimrunRuntimeDependencies(): LimrunRuntimeDependencies { }); }, 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); },