Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/limrun-runtime-types.ts
Original file line number Diff line number Diff line change
@@ -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<AndroidKeyboardState>;
dismissKeyboard(): Promise<AndroidKeyboardDismissResult>;
};

export type LimrunDeviceSession = LimrunAndroidDeviceSession | LimrunIosDeviceSession;
2 changes: 1 addition & 1 deletion src/provider-device-runtimes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
89 changes: 89 additions & 0 deletions src/provider-limrun-runtime.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderDeviceInstallResult | undefined> {
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<ProviderDeviceInstallResult | undefined> {
return await this.implementation.installInstallablePath?.(device, installablePath, options);
}

async configurePortReverse(
options: ProviderPortReverseOptions,
): Promise<Record<string, unknown> | undefined> {
return await this.implementation.configurePortReverse?.(options);
}

async shutdown(): Promise<void> {
await this.implementation.shutdown();
}
}

export type { LimrunRuntimeOptions };
87 changes: 48 additions & 39 deletions src/providers/limrun/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<ReturnType<LimrunAndroidClient['startAdbTunnel']>>;
Expand All @@ -34,20 +33,24 @@ type LimrunAndroidAdbSession = {
adbTunnel?: LimrunAdbTunnel;
adbSerial?: string;
adbTunnelPromise?: Promise<string>;
readonly dependencies: Pick<LimrunRuntimeDependencies, 'android' | 'host'>;
};

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<LimrunAndroidSession> {
export async function createLimrunAndroidSession(
options: {
lease: DeviceLease;
instanceId: string;
device: DeviceInfo;
apiUrl: string;
adbUrl: string;
token: string;
},
dependencies: Pick<LimrunRuntimeDependencies, 'android' | 'host'>,
): Promise<LimrunAndroidSession> {
const client = await createAndroidInstanceClient({
apiUrl: options.apiUrl,
adbUrl: options.adbUrl,
Expand All @@ -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(
Expand All @@ -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 } : {}),
Expand All @@ -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;
Expand All @@ -135,7 +139,7 @@ async function cleanupAndroidPortReverse(session: LimrunAndroidSession): Promise
if (!reverse?.list) return;
const mappings = await reverse.list().catch(() => []);
const owners = new Set<string>();
const unownedLocals: AndroidPortReverseEndpoint[] = [];
const unownedLocals: LimrunPortReverseEndpoint[] = [];
for (const mapping of mappings) {
if (mapping.ownerId) owners.add(mapping.ownerId);
else unownedLocals.push(mapping.local);
Expand All @@ -149,20 +153,25 @@ async function cleanupAndroidPortReverse(session: LimrunAndroidSession): Promise
async function runLimrunAndroidAdb(
session: LimrunAndroidAdbSession,
args: string[],
options?: AndroidAdbExecutorOptions,
): Promise<AndroidAdbExecutorResult> {
options?: LimrunAdbCommandOptions,
): Promise<LimrunAdbCommandResult> {
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,
Expand All @@ -174,12 +183,12 @@ async function executeLimrunAndroidAdb(

async function requireSuccessfulLimrunAndroidAdb(
adbArgs: string[],
result: AndroidAdbExecutorResult,
result: LimrunAdbCommandResult,
allowFailure: boolean | undefined,
): Promise<AndroidAdbExecutorResult> {
dependencies: Pick<LimrunRuntimeDependencies, 'android'>,
): Promise<LimrunAdbCommandResult> {
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(' '),
});
}
Expand All @@ -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}`);
}
Expand Down
58 changes: 55 additions & 3 deletions src/providers/limrun/device-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -165,6 +215,7 @@ function iosSession(client: Record<string, unknown>): LimrunIosSession {
instanceId: 'ios-instance',
device: device('ios'),
client,
dependencies: TEST_DEPENDENCIES,
} as unknown as LimrunIosSession;
}

Expand All @@ -179,6 +230,7 @@ function androidSession(
device: device('android'),
client,
adbProvider,
dependencies: TEST_DEPENDENCIES,
} as unknown as LimrunAndroidSession;
}

Expand Down
Loading
Loading