diff --git a/examples/test-app/replays/gesture-lab-android.ad b/examples/test-app/replays/gesture-lab-android.ad index c3000c853..c4684c80d 100644 --- a/examples/test-app/replays/gesture-lab-android.ad +++ b/examples/test-app/replays/gesture-lab-android.ad @@ -9,16 +9,16 @@ wait "Gesture lab" 30000 wait "gesture canary ready" 5000 wait "two-pointer pan activations 0" 5000 -gesture pan 350 1100 180 0 500 +gesture pan 220 700 180 0 500 wait "two-pointer pan activations 0" 5000 -gesture pan 350 1100 180 0 500 --pointer-count 2 +gesture pan 220 700 180 0 500 --pointer-count 2 wait "two-pointer pan activations 1" 5000 -gesture fling left 850 1100 300 +gesture fling left 500 700 300 wait "fling 1" 5000 -gesture fling right 750 1100 300 +gesture fling left 500 700 300 wait "fling 2" 5000 open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}" @@ -26,7 +26,7 @@ react-native dismiss-overlay wait "gesture canary ready" 5000 wait "pan changed no, pinch changed no, rotate changed no" 30000 -gesture pinch 1.5 750 1100 +gesture pinch 1.5 360 700 wait "pan changed no, pinch changed yes, rotate changed no" 5000 open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}" @@ -34,7 +34,7 @@ react-native dismiss-overlay wait "gesture canary ready" 5000 wait "pan changed no, pinch changed no, rotate changed no" 30000 -gesture rotate 35 750 1100 +gesture rotate 35 360 700 wait "pan changed no, pinch changed no, rotate changed yes" 5000 open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}" @@ -42,7 +42,7 @@ react-native dismiss-overlay wait "gesture canary ready" 5000 wait "pan changed no, pinch changed no, rotate changed no" 30000 -gesture transform 750 1100 60 0 1.75 30 600 +gesture transform 360 700 60 0 1.75 30 600 wait "pan changed yes, pinch changed yes, rotate changed yes" 5000 close diff --git a/package.json b/package.json index 422fa7cc0..b8ff62c18 100644 --- a/package.json +++ b/package.json @@ -225,6 +225,7 @@ "detox" ], "dependencies": { + "@limrun/api": "^0.24.5", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f378c51ac..d16213c19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@limrun/api': + specifier: ^0.24.5 + version: 0.24.5 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -158,6 +161,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@limrun/api@0.24.5': + resolution: {integrity: sha512-bgIs+KcP9xITj1v2gMe8wJrvqPakl1PlXtvxlO6Akci8ijU7V27RWQAq7JBaA89AzwUb5EvLc+nmAMMiKl/g1A==} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -1278,6 +1284,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -1440,6 +1450,14 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventsource-client@1.2.0: + resolution: {integrity: sha512-kDI75RSzO3TwyG/K9w1ap8XwqSPcwi6jaMkNulfVeZmSeUM49U8kUzk1s+vKNt0tGrXgK47i+620Yasn1ccFiw==} + engines: {node: '>=18.0.0'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1529,6 +1547,14 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + import-without-cache@0.4.0: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} @@ -1935,6 +1961,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -2246,6 +2276,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.24.7: + resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} + engines: {node: '>=20.18.1'} + unhead@2.1.15: resolution: {integrity: sha512-MCt5T90mCWyr3Z6pUCdM9lVRXoMoVBlL7z7U4CYVIiaDiuzad/UCfLuMqz5MeNmpZUgoBCQnrucJimU7EZR+XA==} @@ -2377,6 +2411,18 @@ packages: engines: {node: '>=8'} hasBin: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -2505,6 +2551,19 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@limrun/api@0.24.5': + dependencies: + eventsource-client: 1.2.0 + https-proxy-agent: 7.0.6 + ignore: 7.0.6 + proxy-from-env: 2.1.0 + undici: 7.24.7 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.8 @@ -3291,6 +3350,8 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + ansi-regex@6.2.2: {} ansis@4.3.1: {} @@ -3422,6 +3483,12 @@ snapshots: dependencies: '@types/estree': 1.0.8 + eventsource-client@1.2.0: + dependencies: + eventsource-parser: 3.1.0 + + eventsource-parser@3.1.0: {} + expect-type@1.3.0: {} extend@3.0.2: {} @@ -3584,6 +3651,15 @@ snapshots: html-void-elements@3.0.0: {} + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ignore@7.0.6: {} + import-without-cache@0.4.0: {} inline-style-parser@0.2.7: {} @@ -4275,6 +4351,8 @@ snapshots: property-information@7.1.0: {} + proxy-from-env@2.1.0: {} + quansync@1.0.0: {} react-dom@19.2.7(react@19.2.7): @@ -4654,6 +4732,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.24.7: {} + unhead@2.1.15: dependencies: hookable: 6.1.0 @@ -4767,6 +4847,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.21.0: {} + yaml@2.9.0: {} yuku-ast@0.1.7: diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 63f6becae..50edbe388 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -55,6 +55,7 @@ export const UNRANKED_ZONES: ReadonlySet = new Set([ 'compat', 'mcp', 'metro', + 'providers', 'recording', 'remote', 'replay', diff --git a/src/__tests__/cli-react-devtools-session.test.ts b/src/__tests__/cli-react-devtools-session.test.ts index f772c376d..38ca9b748 100644 --- a/src/__tests__/cli-react-devtools-session.test.ts +++ b/src/__tests__/cli-react-devtools-session.test.ts @@ -15,7 +15,7 @@ import { hashRemoteConfigFile, writeRemoteConnectionState, } from '../remote/remote-connection-state.ts'; -import type { DaemonResponse } from '../daemon/client/daemon-client.ts'; +import type { DaemonRequest, DaemonResponse } from '../daemon/client/daemon-client.ts'; afterEach(() => { vi.clearAllMocks(); @@ -73,3 +73,62 @@ test('react-devtools uses active remote connection session after defaults are me assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls.length, 1); assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[1]?.session, 'adc-android'); }); + +test('react-devtools starts Limrun port reverse through the daemon', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-react-devtools-limrun-')); + const stateDir = path.join(tempRoot, 'state'); + const originalExit = process.exit; + let exitCode: number | undefined; + const restoreEnv = installIsolatedCliTestEnv(); + (process as any).exit = ((code?: number) => { + exitCode = code ?? 0; + }) as typeof process.exit; + const remoteConfigPath = path.join(tempRoot, 'limrun.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ platform: 'android' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'limrun-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: {}, + tenant: 'limrun', + runId: 'run-1', + leaseId: 'lease-1', + leaseBackend: 'android-instance', + leaseProvider: 'limrun', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + const requests: Array> = []; + const sendToDaemon = async (request: Omit): Promise => { + requests.push(request); + return { ok: true, data: {} }; + }; + vi.mocked(runReactDevtoolsCommand).mockImplementationOnce(async (_args, options) => { + await options?.configureDirectPortReverse?.(); + return 0; + }); + + try { + await runCli(['react-devtools', 'start', '--state-dir', stateDir], { sendToDaemon }); + } finally { + restoreEnv(); + process.exit = originalExit; + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + + assert.equal(exitCode, 0); + assert.equal(requests.length, 1); + const request = requests[0]; + assert.equal(request?.command, 'runtime'); + assert.deepEqual(request?.positionals, ['port-reverse']); + assert.equal(request?.session, 'limrun-android'); + assert.equal(request?.flags?.leaseProvider, 'limrun'); + assert.equal(request?.flags?.devicePort, 8097); + assert.equal(request?.flags?.hostPort, 8097); + assert.equal(request?.flags?.portReverseName, 'react-devtools'); +}); diff --git a/src/__tests__/cli-react-devtools.test.ts b/src/__tests__/cli-react-devtools.test.ts index d3ac0549b..f9896250f 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/src/__tests__/cli-react-devtools.test.ts @@ -235,6 +235,57 @@ test('react-devtools stop cleans up remote companion', async () => { }); }); +test('react-devtools start configures direct remote port reverse', async () => { + const configureDirectPortReverse = vi.fn(async () => undefined); + vi.mocked(runCmdStreaming).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + const exitCode = await runReactDevtoolsCommand(['start'], { + stateDir: '/tmp/agent-device-state', + session: 'default', + flags: { + ...remoteBridgeScope, + metroProxyBaseUrl: undefined, + leaseBackend: 'android-instance', + leaseProvider: 'limrun', + remoteConfig: '/tmp/remote.json', + session: 'default', + }, + configureDirectPortReverse, + }); + + assert.equal(exitCode, 0); + assert.equal(configureDirectPortReverse.mock.calls.length, 1); + assertNoRemoteCompanion(); +}); + +test('react-devtools start keeps bridge-backed Android on companion tunnel', async () => { + const env = { ...process.env }; + const configureDirectPortReverse = vi.fn(async () => undefined); + mockRemoteCompanionSuccess(); + + const exitCode = await runReactDevtoolsCommand(['start'], { + stateDir: '/tmp/agent-device-state', + session: 'default', + cwd: '/tmp/project', + env, + flags: { + ...remoteBridgeScope, + leaseBackend: 'android-instance', + remoteConfig: '/tmp/remote.json', + session: 'default', + }, + configureDirectPortReverse, + }); + + assert.equal(exitCode, 0); + assert.equal(configureDirectPortReverse.mock.calls.length, 0); + assertRemoteCompanionStarted(env); +}); + test('react-devtools skips companion for non-bridge remote sessions', async () => { await runStatusWithoutCompanion({ ...remoteBridgeScope, diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index d7ca31d17..3e347fb68 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -55,6 +55,138 @@ test('connect without remote config generates one from cloud connection profile' } }); +test('connect limrun generates a local daemon remote profile', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-limrun-')); + const stateDir = path.join(tempRoot, '.state'); + vi.stubEnv('LIMRUN_API_KEY', 'lim_test_key'); + + try { + await captureConnectStdout(async () => { + await connectCommand({ + positionals: ['limrun'], + flags: { + json: true, + help: false, + version: false, + stateDir, + platform: 'android', + tenant: 'team-a', + runId: 'run-a', + session: 'limrun-android', + }, + client: {} as AgentDeviceClient, + }); + }); + + const state = readRequiredActiveState(stateDir); + assert.equal(state.session, 'limrun-android'); + assert.equal(state.tenant, 'team-a'); + assert.equal(state.runId, 'run-a'); + assert.equal(state.leaseBackend, 'android-instance'); + assert.equal(state.leaseProvider, 'limrun'); + assert.equal(state.platform, 'android'); + assert.equal(state.daemon?.baseUrl, undefined); + assert.match( + state.remoteConfigPath, + /remote-connections\/generated\/limrun-[a-f0-9]{16}\.json$/, + ); + assert.deepEqual(readGeneratedConfigKeys(state.remoteConfigPath), [ + 'daemonTransport', + 'leaseBackend', + 'leaseProvider', + 'platform', + 'runId', + 'session', + 'sessionIsolation', + 'stateDir', + 'target', + 'tenant', + ]); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect limrun persists deferred Metro bridge settings', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-limrun-metro-')); + const stateDir = path.join(tempRoot, '.state'); + vi.stubEnv('LIMRUN_API_KEY', 'lim_test_key'); + + try { + await captureConnectStdout(async () => { + await connectCommand({ + positionals: ['limrun'], + flags: { + json: true, + help: false, + version: false, + stateDir, + platform: 'ios', + tenant: 'team-a', + runId: 'run-a', + session: 'limrun-ios', + metroProjectRoot: '/tmp/app', + metroKind: 'expo', + metroProxyBaseUrl: 'https://metro.agent-device.dev', + metroBearerToken: 'adc_live_test', + metroPreparePort: 8082, + launchUrl: 'exp://127.0.0.1:8082', + }, + client: {} as AgentDeviceClient, + }); + }); + + const state = readRequiredActiveState(stateDir); + assert.equal(state.leaseBackend, 'ios-instance'); + assert.equal(state.leaseProvider, 'limrun'); + assert.equal(state.platform, 'ios'); + assert.deepEqual(readGeneratedConfig(state.remoteConfigPath), { + daemonTransport: 'auto', + launchUrl: 'exp://127.0.0.1:8082', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + metroKind: 'expo', + metroPreparePort: 8082, + metroProjectRoot: '/tmp/app', + metroProxyBaseUrl: 'https://metro.agent-device.dev', + platform: 'ios', + runId: 'run-a', + session: 'limrun-ios', + sessionIsolation: 'tenant', + stateDir, + target: 'mobile', + tenant: 'team-a', + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect limrun requires LIMRUN_API_KEY', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-limrun-env-')); + const stateDir = path.join(tempRoot, '.state'); + vi.stubEnv('LIMRUN_API_KEY', ''); + vi.stubEnv('LIM_API_KEY', ''); + + try { + await assert.rejects( + connectCommand({ + positionals: ['limrun'], + flags: { + json: true, + help: false, + version: false, + stateDir, + }, + client: {} as AgentDeviceClient, + }), + /connect limrun requires LIMRUN_API_KEY/, + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test('connect without remote config rejects legacy remoteConfig string profile response', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-legacy-')); const stateDir = path.join(tempRoot, '.state'); @@ -251,8 +383,7 @@ function fetchProfileUrl(fetchMock: ReturnType): string | undefine } async function connectWithGeneratedCloudProfile(stateDir: string): Promise { - const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - try { + await captureConnectStdout(async () => { await connectCommand({ positionals: [], flags: { @@ -263,6 +394,13 @@ async function connectWithGeneratedCloudProfile(stateDir: string): Promise }, client: {} as AgentDeviceClient, }); + }); +} + +async function captureConnectStdout(task: () => Promise): Promise { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await task(); } finally { stdoutWrite.mockRestore(); } diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts new file mode 100644 index 000000000..1f3923cc7 --- /dev/null +++ b/src/__tests__/limrun-runtime.test.ts @@ -0,0 +1,552 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { LimrunRuntime } from '../providers/limrun/runtime.ts'; +import type { SimulatorLease } from '../daemon/lease-registry.ts'; +import type { DeviceInfo } from '../kernel/device.ts'; +import { runCmd } from '../utils/exec.ts'; + +const limrunMockState = vi.hoisted(() => { + const androidTunnelClose = vi.fn(); + return { + constructorOptions: [] as Array<{ defaultHeaders?: Record }>, + assetsGetOrUpload: vi.fn(async () => ({ + signedDownloadUrl: 'https://assets.example/app', + md5: 'asset-md5', + })), + iosInstallApp: vi.fn(async () => ({ + bundleId: 'com.example.ios', + url: 'https://assets.example/app', + })), + iosSetOrientation: vi.fn(async () => undefined), + androidOpenUrl: vi.fn(async () => undefined), + androidDisconnect: vi.fn(), + androidSendAsset: vi.fn(async () => undefined), + androidTunnelClose, + androidStartAdbTunnel: vi.fn(async () => ({ + address: { address: '127.0.0.1', port: 62_001 }, + close: androidTunnelClose, + })), + iosCreate: vi.fn(async () => ({ + metadata: { id: 'ios-instance-1' }, + status: { token: 'instance-token', apiUrl: 'https://ios.example' }, + })), + iosDelete: vi.fn(async () => undefined), + androidCreate: vi.fn(async () => ({ + metadata: { id: 'android-instance-1' }, + status: { + token: 'instance-token', + apiUrl: 'https://android.example', + adbWebSocketUrl: 'wss://adb.example', + }, + })), + androidDelete: vi.fn(async () => undefined), + }; +}); + +vi.mock('@limrun/api', () => ({ + default: class MockLimrun { + readonly iosInstances = { + create: limrunMockState.iosCreate, + delete: limrunMockState.iosDelete, + }; + + readonly androidInstances = { + create: limrunMockState.androidCreate, + delete: limrunMockState.androidDelete, + }; + + readonly assets = { + getOrUpload: limrunMockState.assetsGetOrUpload, + }; + + constructor(options: { defaultHeaders?: Record }) { + limrunMockState.constructorOptions.push(options); + } + }, +})); + +vi.mock('@limrun/api/ios-client', () => ({ + createInstanceClient: vi.fn(async () => ({ + disconnect: vi.fn(), + installApp: limrunMockState.iosInstallApp, + setOrientation: limrunMockState.iosSetOrientation, + })), +})); + +vi.mock('@limrun/api/instance-client', () => ({ + createInstanceClient: vi.fn(async () => ({ + disconnect: limrunMockState.androidDisconnect, + openUrl: limrunMockState.androidOpenUrl, + sendAsset: limrunMockState.androidSendAsset, + startAdbTunnel: limrunMockState.androidStartAdbTunnel, + })), +})); + +vi.mock('../utils/exec.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + }; +}); + +afterEach(() => { + limrunMockState.constructorOptions.length = 0; + vi.clearAllMocks(); +}); + +test('Limrun runtime identifies direct CLI usage to the Limrun API', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + + const lease: SimulatorLease = { + leaseId: 'lease-a', + tenantId: 'team-a', + runId: 'run-a', + backend: 'ios-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + await allocateLease(lease); + + assert.deepEqual(limrunMockState.constructorOptions[0]?.defaultHeaders, { + 'x-agent-device-client': 'agent-device-cli', + 'x-agent-device-version': '9.9.9-test', + }); + const iosCreateCalls = limrunMockState.iosCreate.mock.calls as unknown as Array< + [{ metadata?: { labels?: Record } }] + >; + assert.deepEqual(iosCreateCalls[0]?.[0].metadata?.labels, { + tenantId: 'team-a', + runId: 'run-a', + leaseId: 'lease-a', + provider: 'limrun', + source: 'agent-device-cli', + }); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun Android reverses localhost URL ports through the persistent ADB tunnel', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + + try { + vi.mocked(runCmd).mockImplementation(async (_command, args) => ({ + stdout: + args[2] === 'reverse' && args[3] === '--list' ? '127.0.0.1:62001 tcp:8081 tcp:8081\n' : '', + stderr: '', + exitCode: 0, + })); + const device = await allocateLimrunDevice(runtime, androidLease()); + const interactor = runtime.getInteractor(device, {}); + if (!interactor) throw new Error('Limrun runtime must return an interactor'); + + await interactor.open('exp://127.0.0.1:8081'); + await runtime.shutdown(); + + assertAndroidTunnelLifecycle('exp://127.0.0.1:8081'); + } finally { + await runtime.shutdown(); + } +}); + +function androidLease(): SimulatorLease { + return { + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; +} + +async function allocateLimrunDevice( + runtime: LimrunRuntime, + lease: SimulatorLease, +): Promise { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + const allocated = await allocateLease(lease); + const device = allocated?.device; + if (!device || typeof device !== 'object') { + throw new Error('Limrun runtime must return allocated device metadata'); + } + return device as DeviceInfo; +} + +function assertAndroidTunnelLifecycle(openUrl: string): void { + assert.equal(limrunMockState.androidStartAdbTunnel.mock.calls.length, 1); + assert.equal(limrunMockState.androidOpenUrl.mock.calls.length, 0); + assert.deepEqual(vi.mocked(runCmd).mock.calls[0]?.[1], [ + '-s', + '127.0.0.1:62001', + 'reverse', + 'tcp:8081', + 'tcp:8081', + ]); + assert.deepEqual(vi.mocked(runCmd).mock.calls[1]?.[1], [ + '-s', + '127.0.0.1:62001', + 'shell', + 'am', + 'start', + '-W', + '-a', + 'android.intent.action.VIEW', + '-d', + openUrl, + ]); + assert.deepEqual(vi.mocked(runCmd).mock.calls[2]?.[1], [ + '-s', + '127.0.0.1:62001', + 'reverse', + '--list', + ]); + assert.deepEqual(vi.mocked(runCmd).mock.calls[3]?.[1], [ + '-s', + '127.0.0.1:62001', + 'reverse', + '--remove', + 'tcp:8081', + ]); + assert.deepEqual(vi.mocked(runCmd).mock.calls[4]?.[1], ['disconnect', '127.0.0.1:62001']); + assert.equal(limrunMockState.androidTunnelClose.mock.calls.length, 1); +} + +test('Limrun Android installs direct local artifacts through Limrun assets', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + const allocated = await allocateLease(lease); + const device = allocated?.device; + if (!device || typeof device !== 'object') { + throw new Error('Limrun runtime must return allocated device metadata'); + } + const allocatedDevice = device as Parameters[0]; + assert.equal(allocatedDevice.platform, 'android'); + assert.equal(allocatedDevice.id, 'limrun:android:lease-android'); + + const result = await runtime.installApp( + allocatedDevice, + 'com.example.android', + '/tmp/app-debug.apk', + ); + + const assetCalls = limrunMockState.assetsGetOrUpload.mock.calls as unknown as Array< + [Record] + >; + assert.deepEqual(assetCalls[0]?.[0], { + path: '/tmp/app-debug.apk', + name: 'com.example.android.apk', + }); + assert.deepEqual(limrunMockState.androidSendAsset.mock.calls[0], [ + 'https://assets.example/app', + ]); + assert.deepEqual(result, { + packageName: 'com.example.android', + launchTarget: 'com.example.android', + appName: 'Example', + }); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun Android shares an in-flight ADB tunnel across concurrent port reverse requests', async () => { + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + + try { + await allocateLimrunDevice(runtime, androidLease()); + await Promise.all([ + runtime.configurePortReverse({ + leaseId: 'lease-android', + devicePort: 8081, + hostPort: 8081, + name: 'metro', + }), + runtime.configurePortReverse({ + leaseId: 'lease-android', + devicePort: 8097, + hostPort: 8097, + name: 'react-devtools', + }), + ]); + + assert.equal(limrunMockState.androidStartAdbTunnel.mock.calls.length, 1); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun iOS installs direct local artifacts through Limrun assets', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-limrun-install-')); + const ipaPath = path.join(tempRoot, 'Demo.ipa'); + fs.writeFileSync(ipaPath, 'demo'); + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-ios', + tenantId: 'team-a', + runId: 'run-a', + backend: 'ios-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + const allocated = await allocateLease(lease); + const device = allocated?.device; + if (!device || typeof device !== 'object') { + throw new Error('Limrun runtime must return allocated device metadata'); + } + + const result = await runtime.installApp( + device as Parameters[0], + 'com.example.ios', + ipaPath, + { relaunch: true }, + ); + + const assetCalls = limrunMockState.assetsGetOrUpload.mock.calls as unknown as Array< + [Record] + >; + assert.deepEqual(assetCalls[0]?.[0], { + path: ipaPath, + name: 'Demo.ipa', + }); + assert.deepEqual(limrunMockState.iosInstallApp.mock.calls[0], [ + 'https://assets.example/app', + { md5: 'asset-md5', launchMode: 'RelaunchIfRunning' }, + ]); + assert.deepEqual(result, { + bundleId: 'com.example.ios', + launchTarget: 'com.example.ios', + appName: 'Demo', + }); + } finally { + await runtime.shutdown(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('Limrun iOS maps supported orientation and rejects unsupported upside-down orientation', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + + try { + const device = await allocateLimrunDevice(runtime, { + ...androidLease(), + leaseId: 'lease-ios-orientation', + backend: 'ios-instance', + }); + const interactor = runtime.getInteractor(device, {}); + if (!interactor) throw new Error('Limrun runtime must return an interactor'); + + await interactor.setOrientation('landscape-left'); + await assert.rejects( + () => interactor.setOrientation('portrait-upside-down'), + /not portrait upside-down/, + ); + + assert.deepEqual(limrunMockState.iosSetOrientation.mock.calls, [['Landscape']]); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun Android configures and removes an explicit port reverse', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + await allocateLease(lease); + + assert.deepEqual( + await runtime.configurePortReverse({ + leaseId: lease.leaseId, + devicePort: 8097, + hostPort: 8097, + name: 'react-devtools', + }), + { + leaseId: lease.leaseId, + devicePort: 8097, + hostPort: 8097, + name: 'react-devtools', + }, + ); + await runtime.removePortReverse({ + leaseId: lease.leaseId, + devicePort: 8097, + hostPort: 8097, + name: 'react-devtools', + }); + + assert.deepEqual(vi.mocked(runCmd).mock.calls[0]?.[1], [ + '-s', + '127.0.0.1:62001', + 'reverse', + 'tcp:8097', + 'tcp:8097', + ]); + assert.deepEqual(vi.mocked(runCmd).mock.calls[1]?.[1], [ + '-s', + '127.0.0.1:62001', + 'reverse', + '--remove', + 'tcp:8097', + ]); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun deletes iOS instance when post-create validation fails', async () => { + limrunMockState.iosCreate.mockResolvedValueOnce({ + metadata: { id: 'ios-instance-missing-api' }, + status: { token: 'instance-token' }, + } as never); + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-ios', + tenantId: 'team-a', + runId: 'run-a', + backend: 'ios-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + await assert.rejects(() => allocateLease(lease), /did not expose apiUrl/); + assert.deepEqual(limrunMockState.iosDelete.mock.calls[0], ['ios-instance-missing-api']); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun deletes Android instance when post-create validation fails', async () => { + limrunMockState.androidCreate.mockResolvedValueOnce({ + metadata: { id: 'android-instance-missing-adb' }, + status: { + token: 'instance-token', + apiUrl: 'https://android.example', + }, + } as never); + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + await assert.rejects(() => allocateLease(lease), /did not expose API and ADB/); + assert.deepEqual(limrunMockState.androidDelete.mock.calls[0], ['android-instance-missing-adb']); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun keeps session tracked when release fails so release can be retried', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + const releaseLease = runtime.leaseLifecycle.release; + if (!allocateLease || !releaseLease) { + throw new Error('Limrun runtime must provide lease lifecycle hooks'); + } + await allocateLease(lease); + limrunMockState.androidDelete.mockRejectedValueOnce(new Error('temporary delete failure')); + + await assert.rejects(() => releaseLease(lease), /temporary delete failure/); + assert.deepEqual(await releaseLease(lease), { limrunInstanceId: 'android-instance-1' }); + } finally { + await runtime.shutdown(); + } +}); diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 439ee06e6..8e232ad4e 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -58,16 +58,18 @@ function makeProviderRuntimeWorld() { booted: true, }; const interactor = { open: async () => undefined } as unknown as Interactor; + const runnerContexts: unknown[] = []; const missRuntime = makeMissingRuntime(); const hitRuntime = makeRuntime({ provider: 'hit', leaseResult: { provider: 'hit' }, devices: [device], interactor, + runnerContexts, installResult: { bundleId: 'com.example.app' }, portReverseResult: { provider: 'hit' }, }); - return { lease, device, interactor, missRuntime, hitRuntime }; + return { lease, device, interactor, runnerContexts, missRuntime, hitRuntime }; } function makeMissingRuntime(): ProviderDeviceRuntime { @@ -82,7 +84,9 @@ function makeMissingRuntime(): ProviderDeviceRuntime { } async function assertProviderRuntimeDelegates(world: ReturnType) { - assert.equal(getProviderDeviceInteractor(world.device), world.interactor); + const runnerContext = { appBundleId: 'com.example.app', requestId: 'request-a' }; + assert.equal(getProviderDeviceInteractor(world.device, runnerContext), world.interactor); + assert.deepEqual(world.runnerContexts, [runnerContext]); assert.deepEqual( await installProviderDeviceApp(world.device, 'com.example.app', '/tmp/app.ipa'), { @@ -141,6 +145,7 @@ function makeRuntime(options: { leaseResult: Record | undefined; devices: DeviceInfo[] | null; interactor: Interactor | undefined; + runnerContexts?: unknown[]; installResult: { bundleId: string } | undefined; portReverseResult: Record | undefined; installHook?: boolean; @@ -154,7 +159,10 @@ function makeRuntime(options: { }, deviceInventoryProvider: async () => options.devices, ownsDevice: (device) => options.devices?.some((entry) => entry.id === device.id) ?? false, - getInteractor: () => options.interactor, + getInteractor: (_device, runnerContext) => { + options.runnerContexts?.push(runnerContext); + return options.interactor; + }, ...(options.installHook === false ? {} : { installApp: async () => options.installResult }), configurePortReverse: async () => options.portReverseResult, removePortReverse: async () => options.portReverseResult, diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts new file mode 100644 index 000000000..3807104d0 --- /dev/null +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { createDefaultProviderDeviceRuntimes } from '../provider-device-runtimes.ts'; + +test('default provider runtimes skip Limrun when no Limrun API key is configured', async () => { + const runtimes = await createDefaultProviderDeviceRuntimes({}); + + assert.equal( + runtimes.some((runtime) => runtime.provider === 'limrun'), + false, + ); + await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); +}); + +test('default provider runtimes load Limrun when a Limrun API key is configured', async () => { + const runtimes = await createDefaultProviderDeviceRuntimes({ LIMRUN_API_KEY: 'lim_test_key' }); + + assert.equal( + runtimes.some((runtime) => runtime.provider === 'limrun'), + true, + ); + await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); +}); diff --git a/src/cli-schema/command-overrides.ts b/src/cli-schema/command-overrides.ts index 8757726c1..8b4dc6b47 100644 --- a/src/cli-schema/command-overrides.ts +++ b/src/cli-schema/command-overrides.ts @@ -31,7 +31,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { }, connect: { usageOverride: - 'connect [cloud|proxy|browserstack|aws-device-farm] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', + 'connect [cloud|proxy|limrun|browserstack|aws-device-farm] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', helpDescription: 'Connect to a remote daemon, authenticate when needed, and save remote session state. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.', listUsageOverride: 'connect', diff --git a/src/cli.ts b/src/cli.ts index 4b8f7412d..c9acd341b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ import { parseRawArgs, usage, usageForCommand } from './cli/parser/args.ts'; import { suggestCommandFor } from './cli/parser/command-suggestions.ts'; import { asAppError, AppError, normalizeError } from './kernel/errors.ts'; +import { throwDaemonError } from './daemon-error.ts'; import { printHumanError, printJson } from './utils/output.ts'; import { readVersion } from './utils/version.ts'; import { pathToFileURL } from 'node:url'; @@ -37,7 +38,7 @@ import { import { resolveRemoteAuthForCli } from './cli/auth-session.ts'; import type { CliFlags, FlagKey } from './commands/cli-grammar/flag-types.ts'; import type { SessionRuntimeHints } from './kernel/contracts.ts'; -import { isKnownCliCommandName } from './command-catalog.ts'; +import { INTERNAL_COMMANDS, isKnownCliCommandName } from './command-catalog.ts'; type CliDeps = { sendToDaemon: typeof sendToDaemon; @@ -210,11 +211,29 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): try { if (command === 'react-devtools') { const exitCode = await runReactDevtoolsCommand(positionals, { - flags: effectiveFlags, + flags: { + ...effectiveFlags, + leaseProvider: connectionDefaults?.connection?.leaseProvider, + }, stateDir: daemonPaths.baseDir, session: effectiveFlags.session ?? sessionName, cwd: process.cwd(), env: process.env, + configureDirectPortReverse: async () => { + const response = await deps.sendToDaemon({ + command: INTERNAL_COMMANDS.runtime, + positionals: ['port-reverse'], + flags: { + ...effectiveFlags, + leaseProvider: connectionDefaults?.connection?.leaseProvider, + devicePort: 8097, + hostPort: 8097, + portReverseName: 'react-devtools', + }, + session: effectiveFlags.session ?? sessionName, + }); + if (!response.ok) throwDaemonError(response.error); + }, }); process.exit(exitCode); return; diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 9de6cc035..54f97caa7 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -19,11 +19,12 @@ import { connectProviderNamesForError, connectionProviderLeaseKind, connectionProviderRequiresRemoteDaemon, + isCloudWebDriverConnectProvider, isConnectProviderName, - isDirectDeviceConnectProvider, type ConnectProvider, } from '../connection/provider-policy.ts'; import { resolveCloudWebDriverConnectProfile } from '../connection/cloud-webdriver-profile.ts'; +import { resolveLimrunConnectProfile } from '../connection/limrun-profile.ts'; import { resolveProxyConnectProfile } from '../connection/proxy-profile.ts'; import { hasDeferredMetroConfig, @@ -93,7 +94,7 @@ async function resolveConnectProfile(options: { }): Promise<{ flags: CliFlags; remoteConfigPath: string }> { const { provider, flags, stateDir } = options; if (flags.remoteConfig) return resolveRemoteConnectFlags(flags); - if (isDirectDeviceConnectProvider(provider)) { + if (isCloudWebDriverConnectProvider(provider)) { return resolveCloudWebDriverConnectProfile({ provider, flags, @@ -110,6 +111,14 @@ async function resolveConnectProfile(options: { env: process.env, }); } + if (provider === 'limrun') { + return resolveLimrunConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); + } return await resolveCloudConnectProfile({ flags, stateDir, diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 2a95f89a4..ecf2ba9b1 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -11,22 +11,27 @@ const AGENT_REACT_DEVTOOLS_VERSION = '0.4.0'; export const AGENT_REACT_DEVTOOLS_PACKAGE = `agent-react-devtools@${AGENT_REACT_DEVTOOLS_VERSION}`; const AGENT_REACT_DEVTOOLS_BIN = 'agent-react-devtools'; +type ReactDevtoolsFlags = Pick< + CliFlags, + | 'leaseBackend' + | 'metroProxyBaseUrl' + | 'metroBearerToken' + | 'tenant' + | 'runId' + | 'leaseId' + | 'remoteConfig' + | 'session' +> & { + leaseProvider?: string; +}; + type ReactDevtoolsCommandOptions = { - flags?: Pick< - CliFlags, - | 'leaseBackend' - | 'metroProxyBaseUrl' - | 'metroBearerToken' - | 'tenant' - | 'runId' - | 'leaseId' - | 'remoteConfig' - | 'session' - >; + flags?: ReactDevtoolsFlags; stateDir?: string; session?: string; cwd?: string; env?: NodeJS.ProcessEnv; + configureDirectPortReverse?: () => Promise; }; type RemoteBridgeConfig = { @@ -155,6 +160,21 @@ async function withRemoteDevtoolsCompanion( return await action(); } +function shouldConfigureDirectReverse( + args: string[], + options: ReactDevtoolsCommandOptions, +): boolean { + if (args[0] !== 'start') return false; + const { flags } = options; + if (!flags) return false; + return ( + flags.leaseProvider === 'limrun' && + flags.leaseBackend === 'android-instance' && + flags.metroProxyBaseUrl === undefined && + options.configureDirectPortReverse !== undefined + ); +} + export async function runReactDevtoolsCommand( args: string[], options: ReactDevtoolsCommandOptions = {}, @@ -162,6 +182,9 @@ export async function runReactDevtoolsCommand( const cwd = options.cwd ?? process.cwd(); const env = options.env ?? process.env; const exitCode = await withRemoteDevtoolsCompanion(args, options, async () => { + if (shouldConfigureDirectReverse(args, options)) { + await options.configureDirectPortReverse?.(); + } const result = await runCmdStreaming('npm', buildReactDevtoolsNpmExecArgs(args), { cwd, env, diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts new file mode 100644 index 000000000..abf058c4c --- /dev/null +++ b/src/cli/connection/limrun-profile.ts @@ -0,0 +1,58 @@ +import { resolveDaemonPaths } from '../../daemon/config.ts'; +import type { RemoteConfigProfile } from '../../remote/remote-config-schema.ts'; +import { AppError } from '../../kernel/errors.ts'; +import type { CliFlags } from '../../contracts/cli-flags.ts'; +import type { EnvMap } from '../../utils/env-map.ts'; +import { readMetroProfileFields } from './profile-fields.ts'; +import { persistAndResolveGeneratedProfile } from './generated-config.ts'; +import { resolveRequestedLeaseBackend } from '../commands/connection-runtime.ts'; + +const DEFAULT_LIMRUN_TENANT = 'limrun'; + +export function resolveLimrunConnectProfile(options: { + flags: CliFlags; + stateDir: string; + cwd: string; + env?: EnvMap; +}): { flags: CliFlags; remoteConfigPath: string } { + const env = options.env ?? process.env; + const apiKey = env.LIMRUN_API_KEY?.trim() || env.LIM_API_KEY?.trim(); + if (!apiKey) { + throw new AppError('INVALID_ARGS', 'connect limrun requires LIMRUN_API_KEY.', { + hint: 'Set LIMRUN_API_KEY in the environment before running agent-device connect limrun.', + }); + } + + const profile = buildLimrunRemoteProfile({ flags: options.flags }); + return persistAndResolveGeneratedProfile({ + stateDir: options.stateDir, + provider: 'limrun', + profile, + cwd: options.cwd, + env, + flags: options.flags, + }); +} + +function buildLimrunRemoteProfile(options: { flags: CliFlags }): RemoteConfigProfile { + const flags = options.flags; + const daemonPaths = resolveDaemonPaths(flags.stateDir); + return { + stateDir: daemonPaths.baseDir, + daemonTransport: 'auto', + tenant: flags.tenant ?? DEFAULT_LIMRUN_TENANT, + runId: flags.runId ?? `cli-${Date.now().toString(36)}`, + sessionIsolation: 'tenant', + leaseBackend: resolveRequestedLeaseBackend(flags), + leaseProvider: 'limrun', + platform: flags.platform, + target: flags.target ?? 'mobile', + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, + session: flags.session, + ...readMetroProfileFields(flags), + }; +} diff --git a/src/cli/connection/profile-fields.ts b/src/cli/connection/profile-fields.ts index b323a322c..adf3ab89a 100644 --- a/src/cli/connection/profile-fields.ts +++ b/src/cli/connection/profile-fields.ts @@ -15,5 +15,6 @@ export function readMetroProfileFields(flags: CliFlags): RemoteConfigMetroOption metroRuntimeFile: flags.metroRuntimeFile, metroNoReuseExisting: flags.metroNoReuseExisting, metroNoInstallDeps: flags.metroNoInstallDeps, + launchUrl: flags.launchUrl, }; } diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index 6941f5e6e..9c14812b3 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -4,13 +4,20 @@ import { type CloudWebDriverKnownProviderName, } from '../../cloud-webdriver/providers.ts'; -export type ConnectProvider = 'cloud' | 'proxy' | CloudWebDriverKnownProviderName; +export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; +export type ConnectProvider = 'cloud' | 'proxy' | DirectDeviceConnectProvider; export function isConnectProviderName(value: string | undefined): value is ConnectProvider { - return value === 'cloud' || value === 'proxy' || isCloudWebDriverProviderName(value); + return value === 'cloud' || value === 'proxy' || isDirectDeviceConnectProvider(value); } -export function isDirectDeviceConnectProvider( +function isDirectDeviceConnectProvider( + provider: string | undefined, +): provider is DirectDeviceConnectProvider { + return provider === 'limrun' || isCloudWebDriverProviderName(provider); +} + +export function isCloudWebDriverConnectProvider( provider: string | undefined, ): provider is CloudWebDriverKnownProviderName { return isCloudWebDriverProviderName(provider); @@ -22,6 +29,7 @@ export function connectProviderNamesForError(): string { 'proxy', CLOUD_WEBDRIVER_PROVIDERS.browserStack, CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + 'limrun', ].join(', '); } diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index de4a7011f..c7dc349d4 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -407,10 +407,12 @@ test('usageForCommand resolves remote help topic', async () => { assert.match(help, /stores the shared proxy profile and client identity/); assert.match(help, /BrowserStack: agent-device connect browserstack/); assert.match(help, /AWS Device Farm: agent-device connect aws-device-farm/); + assert.match(help, /Limrun: agent-device connect limrun/); assert.match(help, /agent-device open com\.example\.app --remote-config \.\/remote-config\.json/); assert.match(help, /disconnect --remote-config \.\/remote-config\.json/); assert.match(help, /connect browserstack --platform android/); assert.match(help, /connect aws-device-farm --platform android/); + assert.match(help, /connect limrun --platform android/); assert.match(help, /AWS_REGION=us-west-2 AWS_ACCESS_KEY_ID/); assert.match(help, /AWS Device Farm uses the AWS CLI credential chain/); assert.match(help, /Prefer short-lived AWS role credentials in CI/); @@ -430,9 +432,11 @@ test('usageForCommand resolves remote help topic', async () => { assert.match(help, /Multiple agents can share one proxy/); assert.match(help, /disconnect releases local connection state/); assert.match(help, /A busy direct-proxy device error means another agent owns the device/); - assert.match(help, /BrowserStack and AWS Device Farm through local provider profiles/); + assert.match(help, /Limrun, BrowserStack, and AWS Device Farm through local provider profiles/); + assert.match(help, /Limrun uses LIMRUN_API_KEY/); assert.match(help, /BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY/); assert.match(help, /Generated connection profiles store app\/device selectors and ARNs/); + assert.match(help, /Limrun Android supports direct ADB port reverse/); assert.match(help, /local\/proxy iOS reports that the runner is already owned/); assert.match(help, /same --remote-config to every operational command/); assert.match(help, /Do not use --config as a remote profile flag/); diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index b076283a2..5d9f15847 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -802,9 +802,10 @@ Providers: Direct proxy: agent-device connect proxy --daemon-base-url stores the shared proxy profile and client identity. BrowserStack: agent-device connect browserstack stores a local provider profile and creates the App Automate session on first open. AWS Device Farm: agent-device connect aws-device-farm stores a local provider profile and creates the remote access session on first open. + Limrun: agent-device connect limrun stores a local provider profile and creates a direct iOS or Android instance on first open. Device cloud interfaces: - CLI is the canonical bootstrap path: connect browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. + CLI is the canonical bootstrap path: connect limrun/browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. JavaScript can skip persisted connect state by passing leaseProvider plus provider fields to createAgentDeviceClient or per-command options. MCP exposes operational tools such as open, snapshot, click, close, and artifacts. It does not expose connect/disconnect; run CLI connect first in the same state dir before relying on MCP tools. @@ -845,6 +846,14 @@ AWS Device Farm hosted-device flow: agent-device artifacts --json agent-device disconnect +Limrun direct-device flow: + LIMRUN_API_KEY=... + agent-device connect limrun --platform android + agent-device open com.example.app + agent-device snapshot -i + agent-device close + agent-device disconnect + Local profile flow: agent-device connect --remote-config ./remote-config.json agent-device open com.example.app @@ -861,9 +870,10 @@ Rules: Use connect without --remote-config when the cloud control plane owns the connection profile. Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. Use agent-device proxy for direct tunnel access to a Mac you control. Expose the printed proxy URL through cloudflared/ngrok, then run agent-device connect proxy with the tunnel URL and printed token before normal commands. - Use BrowserStack and AWS Device Farm through local provider profiles; they do not accept a remote agent-device daemon URL. - Device cloud credentials must be available before the command starts. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. - Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors and ARNs, not BrowserStack access keys or AWS credentials. + Use Limrun, BrowserStack, and AWS Device Farm through local provider profiles; they do not accept a remote agent-device daemon URL. + Device cloud credentials must be available before the command starts. Limrun uses LIMRUN_API_KEY (LIM_API_KEY is accepted as an alias). BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. + Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors and ARNs, not Limrun API keys, BrowserStack access keys, or AWS credentials. + Limrun Android supports direct ADB port reverse for local Metro. Limrun iOS requires a public Metro/React DevTools URL because it cannot reach local host ports directly. After closing a device cloud session, run agent-device artifacts --json to retrieve provider video/log/dashboard URLs when the provider has made them available. connect proxy stores the connection profile and client identity. Device leases are acquired on open and expire after five minutes without commands. Multiple agents can share one proxy when each uses connect proxy, open, commands, close, and disconnect. diff --git a/src/cloud-webdriver/runtime.ts b/src/cloud-webdriver/runtime.ts index 9aa5420ce..8cfde958d 100644 --- a/src/cloud-webdriver/runtime.ts +++ b/src/cloud-webdriver/runtime.ts @@ -4,7 +4,7 @@ import type { CloudArtifactsResult, } from '../cloud-artifacts.ts'; import type { DeviceInventoryProvider } from '../core/dispatch-resolve.ts'; -import type { Interactor } from '../core/interactor-types.ts'; +import type { Interactor, RunnerContext } from '../core/interactor-types.ts'; import type { LeaseLifecycleProvider } from '../daemon/handlers/lease.ts'; import type { DeviceLease } from '../daemon/lease-registry.ts'; import type { DaemonRequest } from '../daemon/types.ts'; @@ -179,7 +179,7 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { return [...this.sessionsByLeaseId.values()].some((session) => session.device.id === device.id); } - getInteractor(device: DeviceInfo): Interactor | undefined { + getInteractor(device: DeviceInfo, _runnerContext: RunnerContext): Interactor | undefined { return [...this.sessionsByLeaseId.values()].find((session) => session.device.id === device.id) ?.interactor; } diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 1302e54a6..42ba547fa 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -63,11 +63,14 @@ type ResolveTargetDeviceOptions = { async function resolveAppleDevice( devices: DeviceInfo[], selector: AppleDeviceSelector, - context: { simulatorSetPath?: string }, + context: { simulatorSetPath?: string; allowLocalSimulatorFallback?: boolean }, ): Promise { const selected = await resolveAppleDeviceCandidate(devices, selector, context); - if (shouldUseAppleSimulatorFallback(selector, selected)) { + if ( + context.allowLocalSimulatorFallback !== false && + shouldUseAppleSimulatorFallback(selector, selected) + ) { const { findBootableIosSimulator } = await import('../platforms/apple/core/devices.ts'); const simulator = await findBootableIosSimulator({ simulatorSetPath: context.simulatorSetPath, @@ -151,6 +154,7 @@ export async function resolveTargetDevice( cacheKey, await resolveAppleDevice(injectedDevices, selector as AppleDeviceSelector, { simulatorSetPath: iosSimulatorSetPath, + allowLocalSimulatorFallback: inventoryRequest.leaseProvider === undefined, }), ); } diff --git a/src/core/interactors.ts b/src/core/interactors.ts index 6cf470452..3328001f6 100644 --- a/src/core/interactors.ts +++ b/src/core/interactors.ts @@ -14,7 +14,7 @@ export async function getInteractor( runnerContext: RunnerContext, ): Promise { if (isActiveProviderDevice(device)) { - const providerInteractor = getProviderDeviceInteractor(device); + const providerInteractor = getProviderDeviceInteractor(device, runnerContext); if (providerInteractor) return providerInteractor; throw new AppError( 'UNSUPPORTED_OPERATION', diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 543514885..af9544c1f 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -20,6 +20,10 @@ import { executeAndroidTouchPlan, readAndroidGestureViewport, } from '../../platforms/android/touch-executor.ts'; +import { + withAndroidAdbProvider, + type AndroidAdbProvider, +} from '../../platforms/android/adb-executor.ts'; import { readAndroidClipboardText, writeAndroidClipboardText, @@ -32,8 +36,11 @@ import type { DeviceInfo } from '../../kernel/device.ts'; import type { Interactor } from '../interactor-types.ts'; import { snapshotCaptureAnnotationsFrom } from '../../snapshot-capture-annotations.ts'; -export function createAndroidInteractor(device: DeviceInfo): Interactor { - return { +export function createAndroidInteractor( + device: DeviceInfo, + provider?: AndroidAdbProvider, +): Interactor { + const interactor: Interactor = { open: (app, options) => openAndroidApp(device, app, { activity: options?.activity, @@ -89,4 +96,17 @@ export function createAndroidInteractor(device: DeviceInfo): Interactor { setSetting: (setting, state, appId, options) => setAndroidSetting(device, setting, state, appId, options), }; + if (!provider) return interactor; + return new Proxy(interactor, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => + withAndroidAdbProvider( + provider, + { serial: device.id }, + async () => await value.apply(target, args), + ); + }, + }); } diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index ede428747..1e7eb09e3 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -119,6 +119,43 @@ test('releaseSessionLease releases with the stored session owner scope', async ( expect(provider).toEqual({ provider: 'proxy' }); }); +test('releaseSessionLease keeps the local lease when the provider release fails', async () => { + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + const session = makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }, + }); + + await expect( + releaseSessionLease({ + session, + leaseRegistry, + leaseLifecycleProvider: { + release: async () => { + throw new Error('provider unavailable'); + }, + }, + }), + ).rejects.toThrow('provider unavailable'); + + expect(leaseRegistry.listActiveLeases()).toEqual([lease]); +}); + test('resolveSessionLeaseForRequest prefers admitted lease and falls back to existing lease', () => { const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ diff --git a/src/daemon/apple-runner-options.ts b/src/daemon/apple-runner-options.ts index 95b5c3269..005be947a 100644 --- a/src/daemon/apple-runner-options.ts +++ b/src/daemon/apple-runner-options.ts @@ -3,6 +3,7 @@ import type { SessionSurface } from '../contracts/session-surface.ts'; import type { AppleRunnerLifecycleOptions } from '../platforms/apple/core/runner/runner-provider.ts'; import { prewarmAppleRunnerCache } from '../platforms/apple/core/runner/runner-client.ts'; import { isIosFamily, type DeviceInfo } from '../kernel/device.ts'; +import { isActiveProviderDevice } from '../provider-device-runtime.ts'; import { contextFromFlags } from './context.ts'; import type { DaemonRequest } from './types.ts'; @@ -63,7 +64,12 @@ export function createAppleRunnerCachePrewarmOnColdBoot(params: { enabled: boolean; }): ((device: DeviceInfo) => void) | undefined { const { req, logPath, device, traceLogPath, enabled } = params; - if (!enabled || !isIosFamily(device) || device.kind !== 'simulator') { + if ( + !enabled || + !isIosFamily(device) || + isActiveProviderDevice(device) || + device.kind !== 'simulator' + ) { return undefined; } return (bootingDevice) => diff --git a/src/daemon/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index 3054446dd..45225eae5 100644 --- a/src/daemon/direct-ios-selector.ts +++ b/src/daemon/direct-ios-selector.ts @@ -1,4 +1,5 @@ import { isIosFamily } from '../kernel/device.ts'; +import { isActiveProviderDevice } from '../provider-device-runtime.ts'; import type { SessionState } from './types.ts'; import { tryParseSelectorChain } from '../selectors/index.ts'; import { asAppError } from '../kernel/errors.ts'; @@ -13,6 +14,10 @@ export function readSimpleIosSelectorTarget(params: { const { session, selectorExpression } = params; if (!session) return null; if (!isIosFamily(session.device)) return null; + // This fast path talks directly to the local XCTest runner. Provider-owned + // iOS devices must resolve through their interactor-backed snapshot runtime + // instead, which keeps selectors and interaction guarantees on one backend. + if (isActiveProviderDevice(session.device)) return null; if (session.postGestureStabilization) return null; const chain = tryParseSelectorChain(selectorExpression); if (!chain) return null; diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index 8d74bb4e4..a0102f900 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -481,7 +481,7 @@ test('close dispatches web session cleanup without a positional target', async ( expect(sessionStore.get(sessionName)).toBeUndefined(); }); -test('close deletes local session when provider lease release fails', async () => { +test('close preserves the session and lease when provider release fails so it can be retried', async () => { const sessionStore = makeSessionStore(); const leaseRegistry = new LeaseRegistry(); const sessionName = 'provider-release-failure-session'; @@ -506,28 +506,49 @@ test('close deletes local session when provider lease release fails', async () = }, }); - await expect( - handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - leaseRegistry, - leaseLifecycleProvider: { - release: async () => { + let releaseAttempts = 0; + const request = { + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + leaseRegistry, + leaseLifecycleProvider: { + release: async () => { + releaseAttempts += 1; + if (releaseAttempts === 1) { throw new AppError('COMMAND_FAILED', 'provider cleanup failed'); - }, + } + return { releasedBy: 'provider' }; }, - invoke: noopInvoke, - }), - ).rejects.toThrow('provider cleanup failed'); + }, + invoke: noopInvoke, + }; + const failed = await handleSessionCommands(request); + expect(failed).toMatchObject({ + ok: false, + error: { + code: 'COMMAND_FAILED', + retriable: true, + details: { session: sessionName }, + }, + }); + expect(sessionStore.get(sessionName)?.lease?.leaseId).toBe(lease.leaseId); + expect(leaseRegistry.listActiveLeases()).toHaveLength(1); + + const retried = await handleSessionCommands(request); + expect(retried).toMatchObject({ + ok: true, + data: { provider: { releasedBy: 'provider' } }, + }); + expect(releaseAttempts).toBe(2); expect(sessionStore.get(sessionName)).toBeUndefined(); expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 30dbf83b9..6399d3891 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -2,6 +2,7 @@ import { test, expect, vi } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import { LeaseRegistry } from '../../lease-registry.ts'; +import { setActiveProviderDeviceRuntimes } from '../../../provider-device-runtime.ts'; import { mockDispatch, mockResolveTargetDevice, @@ -103,6 +104,57 @@ test('open --relaunch leaves the old frame expired when the close dispatch fails expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); }); +test('open --relaunch skips provider pre-close before first provider session exists', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'provider-android-session'; + const device = { + platform: 'android' as const, + id: 'provider-android-1', + name: 'Provider Android', + kind: 'emulator' as const, + booted: true, + }; + setActiveProviderDeviceRuntimes([ + { + provider: 'fake-provider', + leaseLifecycle: {}, + deviceInventoryProvider: async () => [device], + ownsDevice: (candidate) => candidate.id === device.id, + getInteractor: () => undefined, + shutdown: async () => {}, + }, + ]); + mockResolveTargetDevice.mockResolvedValue(device); + + const calls: Array<{ command: string; positionals: string[] }> = []; + mockDispatch.mockImplementation(async (_device, command, positionals) => { + calls.push({ command, positionals }); + return {}; + }); + + try { + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['com.example.app'], + flags: { relaunch: true, platform: 'android' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual([{ command: 'open', positionals: ['com.example.app'] }]); + } finally { + setActiveProviderDeviceRuntimes([]); + } +}); + test('open --relaunch on iOS stops runner before close/open', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-session'; diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 70560ff75..974b2b42b 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -98,10 +98,12 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { + const { req, session, logPath, sessionStore } = params; + const repairArmed = session.saveScriptBoundary !== undefined; + const closeIdentity = repairPlatformCloseIdentity(req); + if ( + repairArmed && + !(session.repairPlatformCloseSucceeded && session.repairPlatformCloseIdentity === closeIdentity) + ) { + const platformCloseError = await dispatchTargetedPlatformClose({ req, session, logPath }); + if (platformCloseError) { + return { + response: buildRepairCloseFailureResponse( + session, + toRepairPlatformCloseFailure(platformCloseError), + ), + }; + } + session.repairPlatformCloseSucceeded = true; + session.repairPlatformCloseIdentity = closeIdentity; + } + const repairCommit = commitRepairBeforeClose(sessionStore, session, req); + if (repairCommit.kind === 'failed') { + return { response: buildRepairCloseFailureResponse(session, repairCommit.error) }; + } + session.repairPlatformCloseSucceeded = false; + session.repairPlatformCloseIdentity = undefined; + return { + repairArmed, + ...(repairCommit.kind === 'committed' ? { healedScriptPath: repairCommit.path } : {}), + }; +} + +async function releaseProviderLeaseForClose(params: { + session: SessionState; + leaseRegistry: LeaseRegistry; + leaseLifecycleProvider: LeaseLifecycleProvider | undefined; +}): Promise<{ providerData?: Record; response?: DaemonResponse }> { + try { + return { providerData: await releaseSessionLease(params) }; + } catch (error) { + const normalized = normalizeError(error); + return { + response: { + ok: false, + error: { + ...normalized, + hint: 'The provider device could not be released. Retry close after the provider is reachable.', + details: { ...normalized.details, session: params.session.name }, + retriable: true, + }, + }, + }; + } +} + async function dispatchTargetedPlatformClose(params: { req: DaemonRequest; session: SessionState; @@ -353,8 +417,6 @@ export async function handleCloseCommand(params: { if (req.internal?.closeAppOnly === true) { return await closeAppWithoutEndingSession({ req, session, logPath }); } - const repairArmed = session.saveScriptBoundary !== undefined; - const closeIdentity = repairPlatformCloseIdentity(req); // ADR 0012 decision 6 (BLOCKER 2): for a repair-armed session, the platform // close must run and SUCCEED before anything is committed or torn down — // otherwise a committed healed `.ad` could claim a successful `close` that @@ -373,63 +435,32 @@ export async function handleCloseCommand(params: { // identity DIFFERS (third follow-up: e.g. untargeted -> targeted, or a // changed target) never matches — the marker only ever attests to the // identity it was recorded under, so the platform close runs (again). - if ( - repairArmed && - !(session.repairPlatformCloseSucceeded && session.repairPlatformCloseIdentity === closeIdentity) - ) { - const platformCloseError = await dispatchTargetedPlatformClose({ req, session, logPath }); - if (platformCloseError) { - return buildRepairCloseFailureResponse( - session, - toRepairPlatformCloseFailure(platformCloseError), - ); - } - session.repairPlatformCloseSucceeded = true; - session.repairPlatformCloseIdentity = closeIdentity; - } - // Commit the repair transaction BEFORE any destructive teardown. On failure, - // return without tearing the session down — it stays addressable so the - // agent can fix the cause and retry. - const repairCommit = commitRepairBeforeClose(sessionStore, session, req); - if (repairCommit.kind === 'failed') { - return buildRepairCloseFailureResponse(session, repairCommit.error); - } - // The transaction has now either committed for real or intentionally - // aborted (an incomplete transaction never reaches 'failed') — either way - // this close attempt's outcome is settled, so the platform-close-succeeded - // marker (BLOCKER 3) has served its purpose and must not linger. - session.repairPlatformCloseSucceeded = false; - session.repairPlatformCloseIdentity = undefined; - const healedScriptPath = repairCommit.kind === 'committed' ? repairCommit.path : undefined; - let providerData: Record | undefined; + const repair = await prepareRepairClose({ req, session, logPath, sessionStore }); + if ('response' in repair) return repair.response; // Resource teardown is failure-isolated: a rejected step is collected instead of // short-circuiting the rest, so every subsequent resource (and the runner stop) - // is still attempted. Lease release and session deletion below run regardless, - // and any collected failures are surfaced as an aggregate after cleanup. + // is still attempted. The provider lease is committed only after that teardown, + // and a failed provider release keeps the session retryable. const cleanupFailures: SessionCleanupFailure[] = []; - let platformCloseError: unknown; - try { - platformCloseError = await runSessionCloseTeardown({ - req, - session, - sessionName, - logPath, - sessionStore, - cleanupFailures, - repairArmed, - // The platform close for a repair-armed session already ran (and was - // confirmed to succeed) above, before the commit — never dispatch it twice. - skipPlatformClose: repairArmed, - }); - } finally { - // Always drop the local session, even if provider-side release fails: - // a failed close must not strand device ownership until inactivity expiry. - try { - providerData = await releaseSessionLease({ session, leaseRegistry, leaseLifecycleProvider }); - } finally { - sessionStore.delete(sessionName); - } - } + const platformCloseError = await runSessionCloseTeardown({ + req, + session, + sessionName, + logPath, + sessionStore, + cleanupFailures, + repairArmed: repair.repairArmed, + // The platform close for a repair-armed session already ran (and was + // confirmed to succeed) above, before the commit — never dispatch it twice. + skipPlatformClose: repair.repairArmed, + }); + const leaseRelease = await releaseProviderLeaseForClose({ + session, + leaseRegistry, + leaseLifecycleProvider, + }); + if (leaseRelease.response) return leaseRelease.response; + sessionStore.delete(sessionName); const cleanupAggregate = reportSessionCleanupFailures({ sessionName, phase: 'session_close_cleanup_failed', @@ -447,7 +478,7 @@ export async function handleCloseCommand(params: { // ADR 0012 decision 6 (BLOCKER 2a): positively report the committed healed // artifact path so the agent learns the repair published (and where) without // an extra round-trip. - const savedScript = healedScriptPath ? { savedScript: healedScriptPath } : {}; + const savedScript = repair.healedScriptPath ? { savedScript: repair.healedScriptPath } : {}; if (shutdownResult) { return { ok: true, @@ -456,7 +487,7 @@ export async function handleCloseCommand(params: { session: session.name, shutdown: shutdownResult, ...savedScript, - ...(providerData ? { provider: providerData } : {}), + ...(leaseRelease.providerData ? { provider: leaseRelease.providerData } : {}), }, `Closed: ${session.name}`, ), @@ -468,7 +499,7 @@ export async function handleCloseCommand(params: { session: session.name, ...successText(`Closed: ${session.name}`), ...savedScript, - ...(providerData ? { provider: providerData } : {}), + ...(leaseRelease.providerData ? { provider: leaseRelease.providerData } : {}), }, }; } diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index b5d87d5fd..7ec1cd5d8 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -35,6 +35,7 @@ import { resetAndroidFramePerfStats } from '../../platforms/android/perf.ts'; import { activateAndroidTestIme } from '../../platforms/android/ime-lifecycle.ts'; import { withKeyedLock } from '../../utils/keyed-lock.ts'; import { emitDiagnostic, getDiagnosticsMeta } from '../../utils/diagnostics.ts'; +import { isActiveProviderDevice } from '../../provider-device-runtime.ts'; import { inferAndroidPackageAfterOpen } from './session-open-target.ts'; import { invalidOpenArgs, @@ -166,6 +167,22 @@ function buildStartupPerfSample( }; } +function shouldRunRelaunchPreClose(params: { + shouldRelaunch: boolean; + openTarget: string | undefined; + collapseSimulatorRelaunch: boolean; + device: DeviceInfo; + existingSession: SessionState | undefined; +}): boolean { + if (!params.shouldRelaunch || !params.openTarget || params.collapseSimulatorRelaunch) { + return false; + } + if (!params.existingSession && isActiveProviderDevice(params.device)) { + return false; + } + return true; +} + // fallow-ignore-next-line complexity async function completeOpenCommand(params: { req: DaemonRequest; @@ -203,6 +220,7 @@ async function completeOpenCommand(params: { const shouldPrewarmIosRunner = isIosFamily(device) && + !isActiveProviderDevice(device) && surface === 'app' && openPositionals.length > 0 && Boolean(sessionAppBundleId); @@ -255,7 +273,16 @@ async function completeOpenCommand(params: { openPositionals.length === 1 && isIosSimulator(device) && req.flags?.clearAppState !== true; - if (shouldRelaunch && openTarget && !collapseSimulatorRelaunch) { + if ( + shouldRunRelaunchPreClose({ + shouldRelaunch, + openTarget, + collapseSimulatorRelaunch, + device, + existingSession, + }) && + openTarget + ) { // ADR 0014 side-effect seam: the relaunch close is the FIRST device dispatch // against the existing session. Expire its frame before awaiting the close, // so a close timeout/failure that may already have torn the app down still diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index d61a511e4..edb398f8c 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -101,17 +101,20 @@ export async function releaseSessionLease(params: { }): Promise | undefined> { const lease = params.session.lease; if (!lease) return undefined; - const result = params.leaseRegistry.releaseLease( - leaseScopeToReleaseRequest({ - leaseId: lease.leaseId, - tenantId: lease.tenantId, - runId: lease.runId, - leaseBackend: lease.leaseBackend, - leaseProvider: lease.leaseProvider, - deviceKey: lease.deviceKey, - clientId: lease.clientId, - }), - ); + const releaseRequest = leaseScopeToReleaseRequest({ + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.leaseBackend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }); + const activeLease = params.leaseRegistry.getLease(releaseRequest); + const providerData = activeLease + ? await params.leaseLifecycleProvider?.release?.(activeLease) + : undefined; + const result = params.leaseRegistry.releaseLease(releaseRequest); emitDiagnostic({ level: 'info', phase: 'session_lease_released', @@ -121,5 +124,5 @@ export async function releaseSessionLease(params: { released: result.released, }, }); - return result.lease ? await params.leaseLifecycleProvider?.release?.(result.lease) : undefined; + return providerData; } diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index b41afd603..37283e597 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -24,6 +24,7 @@ export type LeaseRegistryOptions = { minLeaseTtlMs?: number; maxLeaseTtlMs?: number; now?: () => number; + onLeaseExpired?: (lease: DeviceLease) => void; }; export type AllocateLeaseRequest = { @@ -214,6 +215,7 @@ export class LeaseRegistry { private readonly minLeaseTtlMs: number; private readonly maxLeaseTtlMs: number; private readonly now: () => number; + private readonly onLeaseExpired?: (lease: DeviceLease) => void; constructor(options: LeaseRegistryOptions = {}) { this.maxActiveSimulatorLeases = Number.isInteger(options.maxActiveSimulatorLeases) @@ -229,6 +231,7 @@ export class LeaseRegistry { ? Math.max(this.minLeaseTtlMs, Number(options.maxLeaseTtlMs)) : MAX_LEASE_TTL_MS; this.now = options.now ?? (() => Date.now()); + this.onLeaseExpired = options.onLeaseExpired; } allocateLease(request: AllocateLeaseRequest): DeviceLease { @@ -294,17 +297,28 @@ export class LeaseRegistry { } releaseLease(request: ReleaseLeaseRequest): { released: boolean; lease?: DeviceLease } { - const leaseId = this.normalizeRequiredLeaseId(request.leaseId); - this.cleanupExpiredLeases(); - const lease = this.leases.get(leaseId); + const lease = this.getLease(request); if (!lease) { return { released: false }; } + this.leases.delete(lease.leaseId); + this.unbindLease(lease); + return { released: true, lease }; + } + + /** + * Reads a releasable lease without committing its removal. Callers with an + * external provider use this to release the remote resource first, so a + * transient provider failure leaves the local lease available for retry. + */ + getLease(request: ReleaseLeaseRequest): DeviceLease | undefined { + const leaseId = this.normalizeRequiredLeaseId(request.leaseId); + this.cleanupExpiredLeases(); + const lease = this.leases.get(leaseId); + if (!lease) return undefined; this.assertRequiredScopeForDeviceAwareLease(lease, request); this.assertOptionalScopeMatch(lease, request); - this.leases.delete(leaseId); - this.unbindLease(lease); - return { released: true, lease: { ...lease } }; + return { ...lease }; } assertLeaseAdmission(request: AdmissionRequest): void { @@ -345,7 +359,9 @@ export class LeaseRegistry { if (lease.expiresAt > now) continue; this.leases.delete(lease.leaseId); this.unbindLease(lease); - expired.push({ ...lease }); + const expiredLease = { ...lease }; + expired.push(expiredLease); + this.onLeaseExpired?.(expiredLease); } return expired; } @@ -357,7 +373,9 @@ export class LeaseRegistry { if (!lease || lease.expiresAt > this.now()) return undefined; this.leases.delete(lease.leaseId); this.unbindLease(lease); - return { ...lease }; + const expiredLease = { ...lease }; + this.onLeaseExpired?.(expiredLease); + return expiredLease; } private cleanupExpiredLeases(): void { diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index ceaa165fb..b8bee203d 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -34,6 +34,7 @@ import { loadGenericRequestHandlerModule, runRequestHandlerChain, } from './request-handler-chain.ts'; +import type { LeaseLifecycleProvider } from './handlers/lease.ts'; import { createRequestExecutionScope, type LockedRequestScope, @@ -43,7 +44,6 @@ import { import { buildRequestFinishedEvent, shouldRecordEventForRequest } from './session-event-log.ts'; import { canRunReplayScopedAction } from './daemon-command-registry.ts'; import { createAgentBrowserWebProvider } from '../platforms/web/agent-browser-provider.ts'; -import type { LeaseLifecycleProvider } from './handlers/lease.ts'; import { openWebSessionNames } from './web-session-names.ts'; // --------------------------------------------------------------------------- diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index 4a048391f..49f343ecb 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -21,6 +21,7 @@ import type { ContextFromFlags } from './handlers/interaction-common.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { createSelectorCaptureRuntime } from './selector-capture-runtime.ts'; +import { isActiveProviderDevice } from '../provider-device-runtime.ts'; export type SelectorRuntimeParams = { req: DaemonRequest; @@ -189,6 +190,7 @@ function readAppleRunnerFindTextTarget( params: SelectorRuntimeDeviceParams, ): AppleRunnerFindTextTarget | null { if (!isApplePlatform(params.device.platform)) return null; + if (isActiveProviderDevice(params.device)) return null; if (!params.session?.appBundleId) return null; return { device: params.device, diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 7b69f38ee..a0b2d89ca 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -6,11 +6,11 @@ import { resolveDaemonPaths, resolveDaemonServerMode } from '../config.ts'; import { createDaemonHttpServer } from './http-server.ts'; import { trackDownloadableArtifact } from '../artifact-tracking.ts'; import { createDefaultCloudArtifactProvider } from '../../default-cloud-artifact-provider.ts'; -import { createDefaultCloudWebDriverProviderRuntimes } from '../../cloud-webdriver/provider-runtimes.ts'; import { composeCloudArtifactProviders, createProviderDeviceRuntimeRequestProviders, } from '../../provider-device-runtime.ts'; +import { createDefaultProviderDeviceRuntimes } from '../../provider-device-runtimes.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { createRequestHandler } from '../request-router.ts'; import { teardownSessionResources } from '../session-teardown.ts'; @@ -98,7 +98,7 @@ export async function startDaemonRuntime( const token = crypto.randomBytes(24).toString('hex'); const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined; const daemonCodeSignature = resolveDaemonCodeSignature(); - const providerDeviceRuntimes = createDefaultCloudWebDriverProviderRuntimes(env); + const providerDeviceRuntimes = await createDefaultProviderDeviceRuntimes(env); const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders(providerDeviceRuntimes); const cloudArtifactProvider = composeCloudArtifactProviders( diff --git a/src/daemon/types.ts b/src/daemon/types.ts index d832e81c1..ef3cbcb08 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -51,6 +51,7 @@ type DaemonRequestMeta = Omit { + const provider: AndroidAdbProvider = { + exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }; + const manager = createAndroidPortReverseManager(provider); + provider.reverse = manager; + + assert.strictEqual(createAndroidPortReverseManager(provider), manager); +}); + test('createAndroidPortReverseManager lists parsed reverse mappings with owners', async () => { const manager = createAndroidPortReverseManager(async (args) => { if (args.join(' ') === 'reverse --list') { diff --git a/src/platforms/android/__tests__/app-lifecycle-open.test.ts b/src/platforms/android/__tests__/app-lifecycle-open.test.ts index 49c356877..4d71829af 100644 --- a/src/platforms/android/__tests__/app-lifecycle-open.test.ts +++ b/src/platforms/android/__tests__/app-lifecycle-open.test.ts @@ -213,6 +213,61 @@ test('openAndroidApp ensures Android reverse before localhost deep link launch', ]); }); +test('openAndroidApp ensures Android reverse before localhost app-bound deep link launch', async () => { + const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }; + const calls: Array< + { kind: 'exec'; args: string[] } | { kind: 'reverse'; local: string; remote: string } + > = []; + + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push({ kind: 'exec', args }); + return { + stdout: args.join(' ') === 'shell pm list packages' ? 'package:com.example.app\n' : '', + stderr: '', + exitCode: 0, + }; + }, + reverse: { + ensure: async (mapping) => { + calls.push({ kind: 'reverse', local: mapping.local, remote: mapping.remote }); + }, + remove: async () => {}, + removeAllOwned: async () => {}, + }, + }, + { serial: 'emulator-5554' }, + async () => + await openAndroidApp(device, 'com.example.app', { url: 'http://localhost:8081/status' }), + ); + + assert.deepEqual(calls, [ + { kind: 'reverse', local: 'tcp:8081', remote: 'tcp:8081' }, + { + kind: 'exec', + args: [ + 'shell', + 'am', + 'start', + '-W', + '-a', + 'android.intent.action.VIEW', + '-d', + 'http://localhost:8081/status', + '-p', + 'com.example.app', + ], + }, + ]); +}); + test('openAndroidApp ensures Android reverse before IPv6 localhost deep link launch', async () => { const device: DeviceInfo = { platform: 'android', diff --git a/src/platforms/android/adb-executor.ts b/src/platforms/android/adb-executor.ts index 3ae326501..ba55aa54c 100644 --- a/src/platforms/android/adb-executor.ts +++ b/src/platforms/android/adb-executor.ts @@ -498,9 +498,12 @@ export function createAndroidPortReverseManager( provider: AndroidAdbProvider | AndroidAdbExecutor, ): AndroidPortReverseProvider { const normalized = normalizeAndroidAdbProvider(provider); + if (normalized.reverse && managedAndroidPortReverseProviders.has(normalized.reverse)) { + return normalized.reverse; + } const reverse = normalized.reverse ?? createExecAndroidPortReverseProvider(normalized.exec); const active = new Map(); - return { + const manager: AndroidPortReverseProvider = { async ensure(mapping, options) { const current = active.get(mapping.local); if (current && current.ownerId !== mapping.ownerId) { @@ -541,6 +544,8 @@ export function createAndroidPortReverseManager( return reverse.list ? await reverse.list(options) : [...active.values()]; }, }; + managedAndroidPortReverseProviders.add(manager); + return manager; } function normalizeAndroidAdbProvider( @@ -648,6 +653,8 @@ function stripAdbSerialArgs(args: string[], expectedSerial: string): string[] | return args.slice(2); } +const managedAndroidPortReverseProviders = new WeakSet(); + function createExecAndroidPortReverseProvider(adb: AndroidAdbExecutor): AndroidPortReverseProvider { const owned = new Map>(); return { diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index e8dd4ff75..d64169b7d 100644 --- a/src/platforms/android/app-lifecycle.ts +++ b/src/platforms/android/app-lifecycle.ts @@ -394,6 +394,7 @@ async function openAndroidAppBoundDeepLink( if (!isDeepLinkTarget(deepLinkUrl)) { throw new AppError('INVALID_ARGS', 'Android app-bound open requires a valid URL target'); } + await ensureAndroidLocalhostReverse(device, deepLinkUrl); const resolved = await resolveAndroidPackageForOpen(device, app, 'app-bound open'); await runAndroidAdb(device, [ 'shell', diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 52de51dd0..60307aab1 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -4,7 +4,7 @@ import type { CloudArtifactsQuery, CloudArtifactsResult, } from './cloud-artifacts.ts'; -import type { Interactor } from './core/interactor-types.ts'; +import type { Interactor, RunnerContext } from './core/interactor-types.ts'; import type { DeviceInventoryProvider } from './core/dispatch-resolve.ts'; import type { LeaseLifecycleContext, LeaseLifecycleProvider } from './daemon/handlers/lease.ts'; import type { DeviceLease } from './daemon/lease-registry.ts'; @@ -30,7 +30,7 @@ export type ProviderDeviceRuntime = { cloudArtifacts?: CloudArtifactProvider; deviceInventoryProvider: DeviceInventoryProvider; ownsDevice(device: DeviceInfo): boolean; - getInteractor(device: DeviceInfo): Interactor | undefined; + getInteractor(device: DeviceInfo, runnerContext: RunnerContext): Interactor | undefined; installApp?( device: DeviceInfo, app: string, @@ -80,10 +80,13 @@ async function withProviderDeviceRuntimeScope( return await providerDeviceRuntimeScope.run([...runtimes], task); } -export function getProviderDeviceInteractor(device: DeviceInfo): Interactor | undefined { +export function getProviderDeviceInteractor( + device: DeviceInfo, + runnerContext: RunnerContext, +): Interactor | undefined { for (const runtime of getActiveProviderDeviceRuntimes()) { if (!runtime.ownsDevice(device)) continue; - const interactor = runtime.getInteractor(device); + const interactor = runtime.getInteractor(device, runnerContext); if (interactor) return interactor; } return undefined; diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts new file mode 100644 index 000000000..be894760e --- /dev/null +++ b/src/provider-device-runtimes.ts @@ -0,0 +1,17 @@ +import { createDefaultCloudWebDriverProviderRuntimes } from './cloud-webdriver/provider-runtimes.ts'; +import type { DefaultCloudWebDriverProviderRuntimeEnv } from './cloud-webdriver/provider-runtimes.ts'; +import type { ProviderDeviceRuntime } from './provider-device-runtime.ts'; + +export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRuntimeEnv & + NodeJS.ProcessEnv; + +export async function createDefaultProviderDeviceRuntimes( + env: DefaultProviderDeviceRuntimeEnv = process.env, +): Promise { + const runtimes = createDefaultCloudWebDriverProviderRuntimes(env); + if (!env.LIMRUN_API_KEY?.trim() && !env.LIM_API_KEY?.trim()) return runtimes; + + const { createLimrunRuntimeFromEnv } = await import('./providers/limrun/runtime.ts'); + const limrunRuntime = createLimrunRuntimeFromEnv(env); + return limrunRuntime ? [...runtimes, limrunRuntime] : runtimes; +} diff --git a/src/providers/limrun/android.ts b/src/providers/limrun/android.ts new file mode 100644 index 000000000..ed4fa4ff3 --- /dev/null +++ b/src/providers/limrun/android.ts @@ -0,0 +1,231 @@ +import path from 'node:path'; +import type Limrun from '@limrun/api'; +import { + createInstanceClient as createAndroidInstanceClient, + type InstanceClient as LimrunAndroidClient, +} from '@limrun/api/instance-client'; +import { createAndroidInteractor } from '../../core/interactors/android.ts'; +import type { Interactor } from '../../core/interactor-types.ts'; +import type { DeviceLease } from '../../daemon/lease-registry.ts'; +import { AppError } from '../../kernel/errors.ts'; +import type { DeviceInfo } from '../../kernel/device.ts'; +import { + type AndroidAdbExecutorOptions, + type AndroidAdbExecutorResult, + type AndroidAdbProvider, + type AndroidPortReverseEndpoint, +} from '../../platforms/android/adb-executor.ts'; +import type { + ProviderDeviceInstallOptions, + ProviderDeviceInstallResult, + ProviderPortReverseOptions, +} from '../../provider-device-runtime.ts'; +import { execFailureDetails, runCmd } from '../../utils/exec.ts'; + +type LimrunAdbTunnel = Awaited>; + +type LimrunAndroidAdbSession = { + platform: 'android'; + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + client: LimrunAndroidClient; + adbTunnel?: LimrunAdbTunnel; + adbSerial?: string; + adbTunnelPromise?: Promise; +}; + +export type LimrunAndroidSession = LimrunAndroidAdbSession & { + adbProvider: AndroidAdbProvider; +}; + +export async function createLimrunAndroidSession(options: { + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + apiUrl: string; + adbUrl: string; + token: string; +}): Promise { + const client = await createAndroidInstanceClient({ + apiUrl: options.apiUrl, + adbUrl: options.adbUrl, + token: options.token, + logLevel: 'warn', + }); + const session: LimrunAndroidAdbSession = { + platform: 'android', + lease: options.lease, + instanceId: options.instanceId, + device: options.device, + client, + }; + const adbProvider: AndroidAdbProvider = { + 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); + return Object.assign(session, { adbProvider }); +} + +export function createLimrunAndroidInteractor(session: LimrunAndroidSession): Interactor { + return createAndroidInteractor(session.device, session.adbProvider); +} + +export async function installLimrunAndroidApp( + limrun: Limrun, + session: LimrunAndroidSession, + installablePath: string, + options?: ProviderDeviceInstallOptions, +): Promise { + const packageName = normalizeOptionalString(options?.packageNameHint); + if (options?.relaunch && packageName) { + await runLimrunAndroidAdb(session, ['shell', 'am', 'force-stop', packageName], { + allowFailure: true, + }); + } + const asset = await limrun.assets.getOrUpload({ + path: installablePath, + name: buildAndroidAssetName(packageName, installablePath), + }); + await session.client.sendAsset(asset.signedDownloadUrl); + const appName = packageName + ? (await import('../../platforms/android/app-lifecycle.ts')).inferAndroidAppName(packageName) + : undefined; + return { + ...(packageName ? { packageName, launchTarget: packageName } : {}), + ...(appName ? { appName } : {}), + }; +} + +export async function configureLimrunAndroidPortReverse( + session: LimrunAndroidSession, + options: ProviderPortReverseOptions, +): Promise { + await session.adbProvider.reverse?.ensure({ + local: tcpEndpoint(options.devicePort), + remote: tcpEndpoint(options.hostPort), + ownerId: options.name, + }); +} + +export async function removeLimrunAndroidPortReverse( + session: LimrunAndroidSession, + options: ProviderPortReverseOptions, +): Promise { + await session.adbProvider.reverse?.remove(tcpEndpoint(options.devicePort)); +} + +export async function cleanupLimrunAndroidAdbTunnel(session: LimrunAndroidSession): Promise { + await session.adbTunnelPromise?.catch(() => {}); + const serial = session.adbSerial; + if (serial) { + await cleanupAndroidPortReverse(session); + await runCmd('adb', ['disconnect', serial], { + allowFailure: true, + timeoutMs: 10_000, + }).catch(() => {}); + } + session.adbTunnel?.close(); + session.adbTunnel = undefined; + session.adbSerial = undefined; + session.adbTunnelPromise = undefined; +} + +async function cleanupAndroidPortReverse(session: LimrunAndroidSession): Promise { + const reverse = session.adbProvider.reverse; + if (!reverse?.list) return; + const mappings = await reverse.list().catch(() => []); + const owners = new Set(); + const unownedLocals: AndroidPortReverseEndpoint[] = []; + for (const mapping of mappings) { + if (mapping.ownerId) owners.add(mapping.ownerId); + else unownedLocals.push(mapping.local); + } + await Promise.allSettled([ + ...[...owners].map(async (ownerId) => await reverse.removeAllOwned(ownerId)), + ...unownedLocals.map(async (local) => await reverse.remove(local)), + ]); +} + +async function runLimrunAndroidAdb( + session: LimrunAndroidAdbSession, + args: string[], + options?: AndroidAdbExecutorOptions, +): Promise { + const { adbArgs, result } = await executeLimrunAndroidAdb(session, args, options); + return requireSuccessfulLimrunAndroidAdb(adbArgs, result, options?.allowFailure); +} + +async function executeLimrunAndroidAdb( + session: LimrunAndroidAdbSession, + args: string[], + options?: AndroidAdbExecutorOptions, +): Promise<{ adbArgs: string[]; result: AndroidAdbExecutorResult }> { + const serial = await ensurePersistentAndroidAdbSerial(session); + const adbArgs = ['-s', serial, ...args]; + const result = await runCmd('adb', adbArgs, { + allowFailure: options?.allowFailure, + binaryStdout: options?.binaryStdout, + stdin: options?.stdin, + timeoutMs: options?.timeoutMs ?? 30_000, + signal: options?.signal, + }); + return { adbArgs, result }; +} + +function requireSuccessfulLimrunAndroidAdb( + adbArgs: string[], + result: AndroidAdbExecutorResult, + allowFailure: boolean | undefined, +): AndroidAdbExecutorResult { + if (result.exitCode !== 0 && allowFailure !== true) { + throw new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { + command: ['adb', ...adbArgs].join(' '), + ...execFailureDetails(result), + }); + } + return result; +} + +async function ensurePersistentAndroidAdbSerial(session: LimrunAndroidAdbSession): Promise { + if (session.adbSerial) return session.adbSerial; + const pending = session.adbTunnelPromise ?? startAndroidAdbTunnel(session); + session.adbTunnelPromise = pending; + try { + return await pending; + } catch (error) { + if (session.adbTunnelPromise === pending) session.adbTunnelPromise = undefined; + throw error; + } +} + +async function startAndroidAdbTunnel(session: LimrunAndroidAdbSession): Promise { + const tunnel = await session.client.startAdbTunnel(); + const serial = `${tunnel.address.address}:${tunnel.address.port}`; + session.adbTunnel = tunnel; + session.adbSerial = serial; + return serial; +} + +function tcpEndpoint(port: number): AndroidPortReverseEndpoint { + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new AppError('INVALID_ARGS', `Invalid Android tcp reverse port: ${port}`); + } + return `tcp:${port}`; +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function buildAndroidAssetName(packageName: string | undefined, artifactPath: string): string { + const extension = path.extname(artifactPath) || '.apk'; + const prefix = packageName?.replace(/[^a-zA-Z0-9_.-]+/g, '-') || 'android-app'; + return `${prefix}${extension}`; +} diff --git a/src/providers/limrun/device.ts b/src/providers/limrun/device.ts new file mode 100644 index 000000000..85ab35bee --- /dev/null +++ b/src/providers/limrun/device.ts @@ -0,0 +1,44 @@ +import type { DeviceLease } from '../../daemon/lease-registry.ts'; +import type { DeviceInfo } from '../../kernel/device.ts'; + +export type LimrunPlatform = 'ios' | 'android'; + +export const LIMRUN_PROVIDER = 'limrun'; + +const LIMRUN_DEVICE_ID_PREFIX = LIMRUN_PROVIDER; + +export function platformForLimrunLeaseBackend(backend: string): LimrunPlatform | undefined { + if (backend === 'ios-instance') return 'ios'; + if (backend === 'android-instance') return 'android'; + return undefined; +} + +export function buildLimrunDevice( + platform: LimrunPlatform, + lease: DeviceLease, + instanceId: string, +): DeviceInfo { + return { + platform: platform === 'ios' ? 'apple' : 'android', + ...(platform === 'ios' ? { appleOs: 'ios' as const } : {}), + id: limrunDeviceId(platform, lease.leaseId), + name: `Limrun ${platform} ${instanceId.slice(0, 8)}`, + kind: platform === 'ios' ? 'simulator' : 'emulator', + target: 'mobile', + booted: true, + }; +} + +export function parseLimrunDeviceId( + value: string, +): { platform: LimrunPlatform; leaseId: string } | undefined { + const [prefix, platform, leaseId] = value.split(':'); + if (prefix !== LIMRUN_DEVICE_ID_PREFIX) return undefined; + if (platform !== 'ios' && platform !== 'android') return undefined; + if (!leaseId) return undefined; + return { platform, leaseId }; +} + +function limrunDeviceId(platform: LimrunPlatform, leaseId: string): string { + return `${LIMRUN_DEVICE_ID_PREFIX}:${platform}:${leaseId}`; +} diff --git a/src/providers/limrun/ios.ts b/src/providers/limrun/ios.ts new file mode 100644 index 000000000..ca27bd04e --- /dev/null +++ b/src/providers/limrun/ios.ts @@ -0,0 +1,270 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type Limrun from '@limrun/api'; +import { + createInstanceClient as createIosInstanceClient, + type InstanceClient as LimrunIosClient, +} from '@limrun/api/ios-client'; +import type { DeviceRotation } from '../../contracts/device-rotation.ts'; +import type { Interactor, SnapshotOptions, SnapshotResult } from '../../core/interactor-types.ts'; +import type { DeviceLease } from '../../daemon/lease-registry.ts'; +import type { DeviceInfo } from '../../kernel/device.ts'; +import { AppError } from '../../kernel/errors.ts'; +import type { + ProviderDeviceInstallOptions, + ProviderDeviceInstallResult, +} from '../../provider-device-runtime.ts'; +import { execFailureDetails, runCmd } from '../../utils/exec.ts'; +import { flattenIosTree, toIosSelector, writeBase64File, type IosTreeNode } from './snapshot.ts'; + +export type LimrunIosSession = { + platform: 'ios'; + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + client: LimrunIosClient; +}; + +export async function createLimrunIosSession(options: { + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + apiUrl: string; + token: string; +}): Promise { + const client = await createIosInstanceClient({ + apiUrl: options.apiUrl, + token: options.token, + logLevel: 'warn', + }); + return { + platform: 'ios', + lease: options.lease, + instanceId: options.instanceId, + device: options.device, + client, + }; +} + +export async function installLimrunIosApp( + limrun: Limrun, + session: LimrunIosSession, + installablePath: string, + options?: ProviderDeviceInstallOptions, +): Promise { + const prepared = await prepareLimrunIosAsset(installablePath); + try { + const asset = await limrun.assets.getOrUpload({ + path: prepared.uploadPath, + name: prepared.assetName, + }); + const result = await session.client.installApp(asset.signedDownloadUrl, { + md5: asset.md5, + launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'ForegroundIfRunning', + }); + const bundleId = normalizeOptionalString(result.bundleId) ?? options?.appIdentifierHint; + return { + ...(bundleId ? { bundleId, launchTarget: bundleId } : {}), + appName: inferAppNameFromPath(installablePath), + }; + } finally { + await prepared.cleanup(); + } +} + +export function createLimrunIosInteractor(session: LimrunIosSession): Interactor { + return new LimrunIosInteractor(session); +} + +class LimrunIosInteractor implements Interactor { + private readonly session: LimrunIosSession; + + constructor(session: LimrunIosSession) { + this.session = session; + } + + async open(app: string, options?: { url?: string }): Promise { + if (options?.url) { + await this.session.client.launchApp(app); + await this.session.client.openUrl(options.url); + return; + } + if (looksLikeUrl(app)) { + await this.session.client.openUrl(app); + return; + } + await this.session.client.launchApp(resolveIosTarget(app)); + } + + async openDevice(): Promise {} + + async close(app: string): Promise { + if (app) await this.session.client.terminateApp(resolveIosTarget(app)).catch(() => {}); + } + + async tap(x: number, y: number): Promise { + await this.session.client.tap(x, y); + } + + async tapElementSelector(selector: { + key: 'id' | 'label' | 'text' | 'value'; + value: string; + }): Promise | void> { + await this.session.client.tapElement(toIosSelector(selector)); + } + + async doubleTap(x: number, y: number): Promise { + await this.tap(x, y); + await this.tap(x, y); + } + + async longPress(): Promise { + throw unsupported('longpress', 'Limrun iOS direct sessions do not expose long press yet.'); + } + + async focus(x: number, y: number): Promise { + await this.tap(x, y); + } + + async type(text: string, delayMs?: number): Promise { + if (delayMs && delayMs > 0) { + for (const char of Array.from(text)) { + await this.session.client.typeText(char); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return; + } + await this.session.client.typeText(text); + } + + async fill(x: number, y: number, text: string): Promise { + await this.tap(x, y); + await this.session.client.typeText(text); + } + + async fillElementSelector( + selector: { key: 'id' | 'label' | 'text' | 'value'; value: string }, + text: string, + ): Promise { + await this.session.client.setElementValue(text, toIosSelector(selector)); + } + + async scroll(direction: 'up' | 'down' | 'left' | 'right', options?: { pixels?: number }) { + await this.session.client.scroll(direction, options?.pixels ?? 300); + } + + async screenshot(outPath: string): Promise { + const screenshot = await this.session.client.screenshot(); + await writeBase64File(outPath, screenshot.base64); + } + + async snapshot(_options?: SnapshotOptions): Promise { + const treeJson = await this.session.client.elementTree(); + const parsed = JSON.parse(treeJson) as IosTreeNode | IosTreeNode[]; + return { nodes: flattenIosTree(parsed), backend: 'xctest' }; + } + + async back(): Promise { + await this.session.client.pressKey('escape'); + } + + async home(): Promise { + throw unsupported('home', 'Limrun iOS direct sessions do not expose home yet.'); + } + + async setOrientation(orientation: DeviceRotation): Promise { + if (orientation === 'portrait-upside-down') { + throw unsupported( + 'orientation', + 'Limrun iOS direct sessions support portrait and landscape orientation, not portrait upside-down.', + ); + } + await this.session.client.setOrientation(orientation === 'portrait' ? 'Portrait' : 'Landscape'); + } + + async performGesture(): Promise { + throw unsupported( + 'gesture', + 'Limrun iOS direct sessions do not expose portable gesture execution yet.', + ); + } + + async appSwitcher(): Promise { + throw unsupported('app-switcher', 'Limrun iOS direct sessions do not expose app switcher yet.'); + } + + async tvRemote(): Promise { + throw unsupported('tv-remote', 'Limrun iOS direct sessions do not expose tv remote control.'); + } + + async readClipboard(): Promise { + throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard read yet.'); + } + + async writeClipboard(): Promise { + throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard write yet.'); + } + + async setSetting(): Promise { + throw unsupported('settings', 'Limrun iOS direct sessions do not expose settings changes yet.'); + } +} + +async function prepareLimrunIosAsset(artifactPath: string): Promise<{ + uploadPath: string; + assetName: string; + cleanup: () => Promise; +}> { + const stat = await fs.promises.stat(artifactPath); + if (!stat.isDirectory()) { + return { + uploadPath: artifactPath, + assetName: path.basename(artifactPath), + cleanup: async () => {}, + }; + } + + 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), + }); + } + return { + uploadPath: zipPath, + assetName: path.basename(zipPath), + cleanup: async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +function resolveIosTarget(app: string): string { + return app.trim().toLowerCase() === 'settings' ? 'com.apple.Preferences' : app; +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function inferAppNameFromPath(appPath: string): string | undefined { + const base = path.basename(appPath).replace(/\.(?:app|ipa|apk|aab|zip)$/i, ''); + return base || undefined; +} + +function looksLikeUrl(value: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(value.trim()); +} + +function unsupported(command: string, message: string): never { + throw new AppError('UNSUPPORTED_OPERATION', message, { command }); +} diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts new file mode 100644 index 000000000..c79263cac --- /dev/null +++ b/src/providers/limrun/runtime.ts @@ -0,0 +1,287 @@ +import Limrun from '@limrun/api'; +import type { Interactor, RunnerContext } from '../../core/interactor-types.ts'; +import type { DeviceInventoryProvider } from '../../core/dispatch-resolve.ts'; +import type { LeaseLifecycleProvider } from '../../daemon/handlers/lease.ts'; +import type { DeviceLease } from '../../daemon/lease-registry.ts'; +import type { DeviceInfo } from '../../kernel/device.ts'; +import { AppError } from '../../kernel/errors.ts'; +import type { + ProviderDeviceInstallOptions, + ProviderDeviceInstallResult, + ProviderDeviceRuntime, + ProviderPortReverseOptions, +} from '../../provider-device-runtime.ts'; +import { readVersion } from '../../utils/version.ts'; +import { + cleanupLimrunAndroidAdbTunnel, + configureLimrunAndroidPortReverse, + createLimrunAndroidInteractor, + createLimrunAndroidSession, + installLimrunAndroidApp, + removeLimrunAndroidPortReverse, + type LimrunAndroidSession, +} from './android.ts'; +import { + buildLimrunDevice, + LIMRUN_PROVIDER, + parseLimrunDeviceId, + platformForLimrunLeaseBackend, +} from './device.ts'; +import { + createLimrunIosInteractor, + createLimrunIosSession, + installLimrunIosApp, + type LimrunIosSession, +} from './ios.ts'; + +type LimrunInstance = { + metadata: { id: string }; + status: { + token: string; + apiUrl?: string; + adbWebSocketUrl?: string; + }; +}; + +type LimrunRuntimeSession = LimrunIosSession | LimrunAndroidSession; + +type LimrunRuntimeOptions = { + apiKey: string; + region?: string; + version?: string; +}; + +const LIMRUN_CLIENT_HEADER = 'agent-device-cli'; + +export function createLimrunRuntimeFromEnv(env: NodeJS.ProcessEnv): LimrunRuntime | undefined { + const apiKey = env.LIMRUN_API_KEY?.trim() || env.LIM_API_KEY?.trim(); + if (!apiKey) return undefined; + return new LimrunRuntime({ + apiKey, + region: env.LIMRUN_REGION?.trim() || env.LIM_REGION?.trim() || undefined, + version: readVersion(), + }); +} + +export class LimrunRuntime implements ProviderDeviceRuntime { + private readonly limrun: Limrun; + private readonly sessions = new Map(); + private readonly options: LimrunRuntimeOptions; + readonly provider = LIMRUN_PROVIDER; + + readonly leaseLifecycle: LeaseLifecycleProvider = { + allocate: async (lease) => await this.allocate(lease), + release: async (lease) => await this.release(lease.leaseId), + }; + + readonly deviceInventoryProvider: DeviceInventoryProvider = async (request) => { + if (request.leaseProvider !== this.provider || !request.leaseId) return null; + const session = this.sessions.get(request.leaseId); + if (!session) return null; + if (request.platform && request.platform !== session.platform) return []; + return [session.device]; + }; + + constructor(options: LimrunRuntimeOptions) { + this.options = options; + this.limrun = new Limrun({ + apiKey: options.apiKey, + defaultHeaders: { + 'x-agent-device-client': LIMRUN_CLIENT_HEADER, + 'x-agent-device-version': options.version ?? readVersion(), + }, + }); + } + + ownsDevice(device: DeviceInfo): boolean { + return parseLimrunDeviceId(device.id) !== undefined; + } + + getInteractor(device: DeviceInfo, _runnerContext: RunnerContext): Interactor | undefined { + const session = this.getSessionForDevice(device); + if (!session) return undefined; + return session.platform === 'ios' + ? createLimrunIosInteractor(session) + : createLimrunAndroidInteractor(session); + } + + async installApp( + device: DeviceInfo, + app: string, + appPath: string, + options?: ProviderDeviceInstallOptions, + ): Promise { + return await this.installInstallablePath(device, appPath, { + ...options, + appIdentifierHint: options?.appIdentifierHint ?? app, + packageNameHint: options?.packageNameHint ?? app, + }); + } + + async installInstallablePath( + device: DeviceInfo, + installablePath: string, + options?: ProviderDeviceInstallOptions, + ): Promise { + const session = this.getSessionForDevice(device); + if (!session) return undefined; + return session.platform === 'ios' + ? await installLimrunIosApp(this.limrun, session, installablePath, options) + : await installLimrunAndroidApp(this.limrun, session, installablePath, options); + } + + async configurePortReverse( + options: ProviderPortReverseOptions, + ): Promise | undefined> { + const session = this.getAndroidPortReverseSession(options.leaseId, true); + if (!session) return undefined; + await configureLimrunAndroidPortReverse(session, options); + return portReverseResult(options); + } + + async removePortReverse( + options: ProviderPortReverseOptions, + ): Promise | undefined> { + const session = this.getAndroidPortReverseSession(options.leaseId, false); + if (!session) return undefined; + await removeLimrunAndroidPortReverse(session, options); + return portReverseResult(options); + } + + async shutdown(): Promise { + const sessions = [...this.sessions.values()]; + await Promise.allSettled(sessions.map(async (session) => await this.terminateSession(session))); + this.sessions.clear(); + } + + private async allocate(lease: DeviceLease): Promise | undefined> { + if (lease.leaseProvider !== this.provider) return undefined; + const platform = platformForLimrunLeaseBackend(lease.backend); + if (!platform) return undefined; + const existing = this.sessions.get(lease.leaseId); + if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device }; + + const session = + platform === 'ios' + ? await this.createIosSession(lease) + : await this.createAndroidSession(lease); + this.sessions.set(lease.leaseId, session); + return { limrunInstanceId: session.instanceId, device: session.device }; + } + + private async createIosSession(lease: DeviceLease): Promise { + const instance = (await this.limrun.iosInstances.create({ + wait: true, + metadata: this.buildInstanceMetadata(lease), + spec: this.options.region ? { region: this.options.region } : {}, + })) as LimrunInstance; + try { + 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, + }); + } catch (error) { + await this.limrun.iosInstances.delete(instance.metadata.id).catch(() => {}); + throw error; + } + } + + private async createAndroidSession(lease: DeviceLease): Promise { + const instance = (await this.limrun.androidInstances.create({ + wait: true, + metadata: this.buildInstanceMetadata(lease), + spec: this.options.region ? { region: this.options.region } : {}, + })) as LimrunInstance; + try { + if (!instance.status.apiUrl || !instance.status.adbWebSocketUrl) { + throw new AppError( + 'COMMAND_FAILED', + '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, + }); + } catch (error) { + await this.limrun.androidInstances.delete(instance.metadata.id).catch(() => {}); + throw error; + } + } + + private buildInstanceMetadata(lease: DeviceLease) { + return { + displayName: `agent-device-${lease.tenantId}-${lease.runId}`, + labels: { + tenantId: lease.tenantId, + runId: lease.runId, + leaseId: lease.leaseId, + provider: lease.leaseProvider ?? LIMRUN_PROVIDER, + source: LIMRUN_CLIENT_HEADER, + }, + }; + } + + private async release(leaseId: string): Promise | undefined> { + const session = this.sessions.get(leaseId); + if (!session) return undefined; + await this.terminateSession(session); + this.sessions.delete(leaseId); + return { limrunInstanceId: session.instanceId }; + } + + private async terminateSession(session: LimrunRuntimeSession): Promise { + session.client.disconnect(); + if (session.platform === 'ios') { + await this.limrun.iosInstances.delete(session.instanceId); + return; + } + await cleanupLimrunAndroidAdbTunnel(session); + await this.limrun.androidInstances.delete(session.instanceId); + } + + private getSessionForDevice(device: DeviceInfo): LimrunRuntimeSession | undefined { + const parsed = parseLimrunDeviceId(device.id); + if (!parsed) return undefined; + const session = this.sessions.get(parsed.leaseId); + return session?.platform === parsed.platform ? session : undefined; + } + + private getAndroidPortReverseSession( + leaseId: string, + throwOnIos: boolean, + ): LimrunAndroidSession | undefined { + const session = this.sessions.get(leaseId); + if (!session || session.platform === 'android') return session; + if (throwOnIos) { + throw unsupported( + 'port reverse', + 'Direct Limrun iOS sessions cannot reach local host ports; use a bridge public URL.', + ); + } + return undefined; + } +} + +function portReverseResult(options: ProviderPortReverseOptions): Record { + return { + leaseId: options.leaseId, + devicePort: options.devicePort, + hostPort: options.hostPort, + name: options.name, + }; +} + +function unsupported(command: string, message: string): never { + throw new AppError('UNSUPPORTED_OPERATION', message, { command }); +} diff --git a/src/providers/limrun/snapshot.ts b/src/providers/limrun/snapshot.ts new file mode 100644 index 000000000..765cbe55c --- /dev/null +++ b/src/providers/limrun/snapshot.ts @@ -0,0 +1,113 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { RawSnapshotNode } from '../../kernel/snapshot.ts'; + +type LimrunSelector = { key: 'id' | 'label' | 'text' | 'value'; value: string }; + +export type IosTreeNode = { + elementType?: string; + type?: string; + label?: string; + AXLabel?: string | null; + identifier?: string; + AXUniqueId?: string | null; + value?: string; + AXValue?: string | null; + frame?: { x?: number; y?: number; width?: number; height?: number }; + rect?: { x?: number; y?: number; width?: number; height?: number }; + enabled?: boolean; + role?: string; + selected?: boolean; + hittable?: boolean; + children?: IosTreeNode[]; + nodes?: IosTreeNode[]; + elements?: IosTreeNode[]; +}; + +export function flattenIosTree(input: IosTreeNode | IosTreeNode[]): RawSnapshotNode[] { + const roots = Array.isArray(input) ? input : [input]; + const nodes: RawSnapshotNode[] = []; + const visit = (node: IosTreeNode, depth: number, parentIndex?: number) => { + const index = nodes.length; + nodes.push(mapIosNode(node, { index, depth, parentIndex })); + for (const child of readIosNodeChildren(node)) { + visit(child, depth + 1, index); + } + }; + for (const root of roots) visit(root, 0); + return nodes; +} + +function mapIosNode( + node: IosTreeNode, + options: { index: number; depth: number; parentIndex?: number }, +): RawSnapshotNode { + return { + index: options.index, + type: readIosNodeType(node), + role: readIosNodeRole(node), + label: readIosNodeLabel(node), + value: readIosNodeValue(node), + identifier: readIosNodeIdentifier(node), + rect: readIosNodeRect(node), + enabled: node.enabled, + selected: node.selected, + hittable: node.hittable, + depth: options.depth, + parentIndex: options.parentIndex, + }; +} + +function readIosNodeType(node: IosTreeNode): string | undefined { + return node.elementType ?? node.type; +} + +function readIosNodeRole(node: IosTreeNode): string | undefined { + return node.role ?? readIosNodeType(node); +} + +function readIosNodeLabel(node: IosTreeNode): string | undefined { + return node.label ?? node.AXLabel ?? undefined; +} + +function readIosNodeValue(node: IosTreeNode): string | undefined { + return node.value ?? node.AXValue ?? undefined; +} + +function readIosNodeIdentifier(node: IosTreeNode): string | undefined { + return node.identifier ?? node.AXUniqueId ?? undefined; +} + +function readIosNodeRect(node: IosTreeNode): RawSnapshotNode['rect'] { + const rect = node.rect ?? node.frame; + if ( + !rect || + typeof rect.x !== 'number' || + typeof rect.y !== 'number' || + typeof rect.width !== 'number' || + typeof rect.height !== 'number' + ) { + return undefined; + } + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }; +} + +function readIosNodeChildren(node: IosTreeNode): IosTreeNode[] { + return node.children ?? node.nodes ?? node.elements ?? []; +} + +export function toIosSelector(selector: LimrunSelector) { + if (selector.key === 'id') return { accessibilityId: selector.value }; + if (selector.key === 'value') return { value: selector.value }; + return { label: selector.value }; +} + +export async function writeBase64File(filePath: string, base64: string): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, Buffer.from(base64, 'base64')); +} diff --git a/src/remote/remote-config-schema.ts b/src/remote/remote-config-schema.ts index b7c1a7170..f8713de8a 100644 --- a/src/remote/remote-config-schema.ts +++ b/src/remote/remote-config-schema.ts @@ -22,6 +22,7 @@ export type RemoteConfigMetroOptions = { metroRuntimeFile?: string; metroNoReuseExisting?: boolean; metroNoInstallDeps?: boolean; + launchUrl?: string; }; export type RemoteConnectionProfileFields = { @@ -142,6 +143,7 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ { key: 'metroRuntimeFile', type: 'string', path: true }, { key: 'metroNoReuseExisting', type: 'boolean' }, { key: 'metroNoInstallDeps', type: 'boolean' }, + { key: 'launchUrl', type: 'string' }, ] as const satisfies readonly RemoteConfigFieldSpec[]; const REMOTE_CONFIG_LEASE_FIELD_SPECS = [ diff --git a/src/utils/__tests__/interactors.test.ts b/src/utils/__tests__/interactors.test.ts index a3f341a48..bc4cb32b5 100644 --- a/src/utils/__tests__/interactors.test.ts +++ b/src/utils/__tests__/interactors.test.ts @@ -1,8 +1,13 @@ -import { beforeEach, test, vi } from 'vitest'; +import { afterEach, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import type { RunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; import { AppError } from '../../kernel/errors.ts'; +import type { Interactor, RunnerContext } from '../../core/interactor-types.ts'; +import { + setActiveProviderDeviceRuntimes, + type ProviderDeviceRuntime, +} from '../../provider-device-runtime.ts'; vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -38,6 +43,10 @@ beforeEach(() => { mockRunAppleRunnerCommand.mockReset(); }); +afterEach(() => { + setActiveProviderDeviceRuntimes([]); +}); + test('resolveAppleBackRunnerCommand defaults plain back to in-app navigation', () => { assert.equal(resolveAppleBackRunnerCommand(), 'backInApp'); }); @@ -47,6 +56,37 @@ test('resolveAppleBackRunnerCommand maps explicit back modes to runner commands' assert.equal(resolveAppleBackRunnerCommand('system'), 'backSystem'); }); +test('provider device interactor receives runner context from core resolution', async () => { + const device: DeviceInfo = { + platform: 'apple', + id: 'provider:ios:lease-a', + name: 'Provider iOS', + kind: 'simulator', + booted: true, + }; + const interactor = { open: async () => undefined } as unknown as Interactor; + const runnerContext: RunnerContext = { + appBundleId: 'com.example.app', + requestId: 'request-a', + }; + const runnerContexts: RunnerContext[] = []; + const runtime: ProviderDeviceRuntime = { + provider: 'provider', + leaseLifecycle: {}, + deviceInventoryProvider: async () => [device], + ownsDevice: (candidate) => candidate.id === device.id, + getInteractor: (_device, context) => { + runnerContexts.push(context); + return interactor; + }, + shutdown: async () => {}, + }; + setActiveProviderDeviceRuntimes([runtime]); + + assert.equal(await getInteractor(device, runnerContext), interactor); + assert.deepEqual(runnerContexts, [runnerContext]); +}); + test('ios scroll sends a single fused scroll command and reports planned pixels', async () => { const commands: RunnerCommand[] = []; mockRunAppleRunnerCommand.mockImplementation(async (_device, command) => { diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index c01f3acc8..74725fffa 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -78,6 +78,7 @@ function linkRuntimeDependencies(installedPackageRoot: string, consumerRoot: str const sourcePath = path.join(repoRoot, 'node_modules', dependencyName); const targetPath = path.join(consumerNodeModules, dependencyName); if (!fs.existsSync(sourcePath) || fs.existsSync(targetPath)) continue; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.symlinkSync(sourcePath, targetPath, 'junction'); } } diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index b556d5cc3..810328209 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -440,6 +440,13 @@ function androidCaptureAdbResult( searchText: string, snapshotXml?: () => string, ): AndroidAdbResult | undefined { + if (key.includes('com.callstack.agentdevice.multitouchhelper/.MultiTouchInstrumentation')) { + return { + stdout: androidMultiTouchHelperOutput(), + stderr: '', + exitCode: 0, + }; + } if (key.startsWith('shell am instrument ')) { return { stdout: androidSnapshotHelperOutput(snapshotXml?.() ?? androidSettingsXml(searchText)), @@ -469,6 +476,18 @@ export function androidSnapshotHelperOutput(xml: string): string { ].join('\n'); } +function androidMultiTouchHelperOutput(): string { + return [ + 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-multitouch-helper-v1', + 'INSTRUMENTATION_RESULT: helperApiVersion=1', + 'INSTRUMENTATION_RESULT: kind=swipe', + 'INSTRUMENTATION_RESULT: ok=true', + 'INSTRUMENTATION_RESULT: injectedEvents=2', + 'INSTRUMENTATION_RESULT: elapsedMs=100', + 'INSTRUMENTATION_CODE: 0', + ].join('\n'); +} + export function androidSettingsXml( searchText: string, options: { duplicateAppsRow?: boolean } = {}, diff --git a/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts b/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts new file mode 100644 index 000000000..b8085ac06 --- /dev/null +++ b/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + createProviderDeviceRuntimeRequestProviders, + type ProviderDeviceRuntime, +} from '../../../src/provider-device-runtime.ts'; +import type { DeviceInventoryProvider } from '../../../src/core/dispatch-resolve.ts'; +import type { Interactor, SnapshotResult } from '../../../src/core/interactor-types.ts'; +import type { LeaseLifecycleProvider } from '../../../src/daemon/handlers/lease.ts'; +import type { DeviceLease } from '../../../src/daemon/lease-registry.ts'; +import type { DaemonRequest } from '../../../src/daemon/types.ts'; +import type { DeviceInfo } from '../../../src/kernel/device.ts'; +import { assertRpcOk } from './assertions.ts'; +import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; + +const PROVIDER = 'fake-ios-provider'; +const DEVICE: DeviceInfo = { + platform: 'apple', + id: 'fake-ios-provider:device-1', + name: 'Fake Provider iPhone', + kind: 'device', + booted: true, +}; + +test('provider-owned iOS selectors use the shared interactor runtime instead of local XCTest', async () => { + await withProviderScenarioResource(createProviderIosSelectorWorld, async ({ daemon, calls }) => { + const lease = await allocateLease(daemon); + const request = { flags: leaseFlags(lease.leaseId), meta: leaseMeta(lease.leaseId) }; + + assertRpcOk(await daemon.callCommand('open', ['com.example.app'], request.flags, request)); + assert.match( + String( + assertRpcOk( + await daemon.callCommand( + 'get', + ['text', 'label="Provider Ready"'], + request.flags, + request, + ), + ).text, + ), + /^Provider Ready$/, + ); + assert.equal( + assertRpcOk( + await daemon.callCommand( + 'is', + ['exists', 'label="Provider Ready"'], + request.flags, + request, + ), + ).pass, + true, + ); + assertRpcOk( + await daemon.callCommand('wait', ['label="Provider Ready"'], request.flags, request), + ); + assertRpcOk(await daemon.callCommand('wait', ['Provider Ready'], request.flags, request)); + + const click = assertRpcOk( + await daemon.callCommand('click', ['label="Provider Ready"'], request.flags, request), + ); + assert.equal(click.x, 60); + assert.equal(click.y, 30); + const fill = assertRpcOk( + await daemon.callCommand('fill', ['label="Provider Input"', 'hello'], request.flags, request), + ); + assert.equal(fill.x, 60); + assert.equal(fill.y, 90); + + assert.deepEqual(calls.taps, [{ x: 60, y: 30 }]); + assert.deepEqual(calls.fills, [{ x: 60, y: 90, text: 'hello' }]); + assert.ok( + calls.snapshots >= 5, + `expected shared snapshot runtime calls, got ${calls.snapshots}`, + ); + }); +}); + +async function allocateLease( + daemon: Awaited>, +): Promise { + const response = await daemon.callCommand('lease_allocate', [], leaseFlags(), { + meta: leaseMeta(), + }); + return assertRpcOk<{ lease: DeviceLease }>(response).lease; +} + +async function createProviderIosSelectorWorld() { + const calls = { + snapshots: 0, + taps: [] as Array<{ x: number; y: number }>, + fills: [] as Array<{ x: number; y: number; text: string }>, + }; + const runtime = createProviderRuntime(calls); + const providers = createProviderDeviceRuntimeRequestProviders([runtime]); + const daemon = await createProviderScenarioHarness({ + ...providers, + deviceInventoryProvider: providers.deviceInventoryProvider!, + }); + return { + daemon, + calls, + close: async () => { + await runtime.shutdown(); + await daemon.close(); + }, + }; +} + +function createProviderRuntime(calls: { + snapshots: number; + taps: Array<{ x: number; y: number }>; + fills: Array<{ x: number; y: number; text: string }>; +}): ProviderDeviceRuntime { + const interactor = createProviderInteractor(calls); + const leaseLifecycle: LeaseLifecycleProvider = { + allocate: async (lease) => + lease.leaseProvider === PROVIDER ? { provider: PROVIDER, deviceId: DEVICE.id } : undefined, + }; + const deviceInventoryProvider: DeviceInventoryProvider = async (request) => + request.leaseProvider === PROVIDER && request.leaseId ? [DEVICE] : null; + return { + provider: PROVIDER, + leaseLifecycle, + deviceInventoryProvider, + ownsDevice: (device) => device.id === DEVICE.id, + getInteractor: (device) => (device.id === DEVICE.id ? interactor : undefined), + shutdown: async () => undefined, + }; +} + +function createProviderInteractor(calls: { + snapshots: number; + taps: Array<{ x: number; y: number }>; + fills: Array<{ x: number; y: number; text: string }>; +}): Interactor { + return new Proxy>( + { + open: async () => undefined, + tap: async (x, y) => { + calls.taps.push({ x, y }); + return { x, y }; + }, + fill: async (x, y, text) => { + calls.fills.push({ x, y, text }); + return { x, y, text }; + }, + snapshot: async (): Promise => { + calls.snapshots += 1; + return { + backend: 'xctest', + nodes: [ + { + index: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 400, height: 800 }, + }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Provider Ready', + hittable: true, + rect: { x: 20, y: 10, width: 80, height: 40 }, + }, + { + index: 2, + parentIndex: 0, + type: 'TextField', + label: 'Provider Input', + hittable: true, + rect: { x: 20, y: 70, width: 80, height: 40 }, + }, + ], + }; + }, + }, + { + get(target, property, receiver) { + if ( + property === 'then' || + property === 'tapElementSelector' || + property === 'fillElementSelector' + ) { + return undefined; + } + if (property in target) return Reflect.get(target, property, receiver); + return () => { + throw new Error(`Unexpected provider iOS interactor call: ${String(property)}`); + }; + }, + }, + ) as Interactor; +} + +function leaseFlags(leaseId?: string): DaemonRequest['flags'] { + return { + platform: 'ios', + tenant: 'team-a', + runId: 'run-a', + leaseId, + leaseProvider: PROVIDER, + }; +} + +function leaseMeta(leaseId?: string): DaemonRequest['meta'] { + return { + tenantId: 'team-a', + runId: 'run-a', + leaseId, + leaseBackend: 'ios-instance', + leaseProvider: PROVIDER, + deviceKey: DEVICE.id, + clientId: 'client-a', + }; +} diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 3816700a9..90da296ee 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -113,7 +113,7 @@ standalone Simulator app. or contacts the daemon. Pass `{ stateDir }` to resolve an explicit override the same way the CLI resolves `--state-dir`. `client.sessions.artifacts({ provider, providerSessionId })` mirrors `artifacts --provider ... --provider-session ...` and returns provider-hosted `cloudArtifacts`. -Use it for BrowserStack or AWS Device Farm session videos/logs after a cloud session has stopped, or omit `providerSessionId` when an embedding host has registered a cloud runtime that can infer the active lease. +Use it for BrowserStack or AWS Device Farm session videos/logs after a cloud session has stopped, or omit `providerSessionId` when an embedding host has registered a provider runtime that can infer the active lease. Limrun does not currently expose provider artifacts through this command. ```ts const result = await client.sessions.artifacts({ @@ -128,7 +128,7 @@ for (const artifact of result.cloudArtifacts) { ## Device cloud sessions -BrowserStack and AWS Device Farm can be driven through the normal typed client methods. Use the CLI `connect browserstack` / `connect aws-device-farm` flow when you want persisted local connection state. Use direct client config when a Node integration already owns credentials and provider selectors. +Limrun, BrowserStack, and AWS Device Farm can be driven through the normal typed client methods. Use the corresponding CLI `connect` flow when you want persisted local connection state. Use direct client config when a Node integration already owns credentials and provider selectors. ```ts import { createAgentDeviceClient } from 'agent-device'; @@ -146,7 +146,7 @@ await client.capture.snapshot({ interactiveOnly: true }); const closed = await client.sessions.close(); ``` -Use `client.sessions.artifacts({ provider, providerSessionId })` with `closed.provider?.providerSessionId` to fetch hosted video/log URLs after close. See [Device Clouds & Farms](/docs/device-clouds) for BrowserStack, AWS Device Farm, CLI, JavaScript, and MCP flows. +Use `client.sessions.artifacts({ provider, providerSessionId })` with `closed.provider?.providerSessionId` to fetch hosted video/log URLs after close when the provider supports them. See [Device Clouds & Farms](/docs/device-clouds) for Limrun, BrowserStack, AWS Device Farm, CLI, JavaScript, and MCP flows. ## Web sessions diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index 1f631d712..b06c1c5f2 100644 --- a/website/docs/docs/device-clouds.md +++ b/website/docs/docs/device-clouds.md @@ -1,18 +1,19 @@ --- title: Device Clouds & Farms -description: Connect agents to BrowserStack device clouds and AWS Device Farm without interactive login. +description: Connect agents to Limrun, BrowserStack, and AWS Device Farm without interactive login. --- # Device Clouds & Farms -Use device cloud and device farm connections when the agent should drive BrowserStack App Automate or AWS Device Farm remote access through the local `agent-device` daemon: +Use device cloud and device farm connections when the agent should drive Limrun direct instances, BrowserStack App Automate, or AWS Device Farm remote access through the local `agent-device` daemon: ```bash agent-device connect browserstack ... agent-device connect aws-device-farm ... +agent-device connect limrun ... ``` -These providers are not remote `agent-device` daemons. `connect browserstack` and `connect aws-device-farm` write a local generated profile, then the first lease-allocating command such as `open` creates the hosted WebDriver session. +These providers are not remote `agent-device` daemons. `connect` writes a local generated profile, then the first lease-allocating command such as `open` creates the provider session. BrowserStack and AWS Device Farm use hosted WebDriver sessions; Limrun uses its direct iOS/Android provider runtime. ## Interface Summary @@ -20,11 +21,11 @@ Device cloud providers have one setup model and three ways to drive the resultin | Interface | What it does well | How provider setup works | | ----------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| CLI | Best default for agents and CI. It creates the local provider profile once, then normal commands inherit it. | Run `agent-device connect browserstack` or `agent-device connect aws-device-farm`, then `open`, `snapshot`, `click`, `close`, `artifacts`, and `disconnect`. | +| CLI | Best default for agents and CI. It creates the local provider profile once, then normal commands inherit it. | Run `agent-device connect limrun`, `agent-device connect browserstack`, or `agent-device connect aws-device-farm`, then normal device commands. | | JavaScript client | Best for Node integrations that already own process configuration. | Pass provider fields in `createAgentDeviceClient(...)` config or per-command options, then call normal client methods such as `client.apps.open(...)` and `client.capture.snapshot(...)`. | | MCP | Best for tool-only agents after bootstrap. MCP exposes operational tools backed by the same command contracts. | Run CLI `connect ...` before starting or using the MCP server in the same effective state directory. MCP intentionally does not expose `connect` or `disconnect` as provider setup tools. | -The CLI is the canonical bootstrap path because it persists a non-secret generated profile and keeps BrowserStack/AWS credentials in the environment. After bootstrap, CLI, JavaScript, and MCP all use the same daemon/session behavior. +The CLI is the canonical bootstrap path because it persists a non-secret generated profile and keeps provider credentials in the environment. After bootstrap, CLI, JavaScript, and MCP all use the same daemon/session behavior. ## Autonomous Agent Requirements @@ -32,7 +33,7 @@ Agents can connect autonomously when all required credentials and selectors are - Do not rely on browser-based login inside the agent workflow. - Put provider credentials in CI secrets, a local ignored env file, or the CI platform's secret store. -- Keep generated remote profiles non-secret. They may contain provider app ids, Device Farm ARNs, device names, OS versions, and labels; they must not contain BrowserStack access keys or AWS secret keys. +- Keep generated remote profiles non-secret. They may contain provider app ids, Device Farm ARNs, device names, OS versions, and labels; they must not contain Limrun API keys, BrowserStack access keys, or AWS secret keys. - Run `agent-device artifacts --json` after `close` when the provider has video/log URLs to fetch. ## CLI Experience @@ -46,6 +47,39 @@ The CLI experience is: 5. Run `agent-device artifacts --json` to retrieve provider-hosted video/log/dashboard URLs. 6. Run `agent-device disconnect` to clear local connection state. +### Limrun + +Required environment: + +```bash +export LIMRUN_API_KEY=... +``` + +`LIM_API_KEY` is accepted as a compatibility alias. `LIMRUN_REGION` (or `LIM_REGION`) optionally selects a Limrun region. + +Choose the platform to create a matching instance: + +```bash +agent-device connect limrun --platform android +``` + +Full Android flow: + +```bash +export LIMRUN_API_KEY=... + +agent-device connect limrun --platform android +agent-device open com.example.app +agent-device snapshot -i +agent-device click 'label="Continue"' +agent-device close +agent-device disconnect +``` + +Limrun Android uses the direct ADB tunnel, so the normal Android helper-backed snapshots, installs, and port reverse flow are available. This makes a local Metro server reachable through the normal Android reverse setup. + +Limrun iOS uses the direct Limrun iOS client. It supports normal app lifecycle, snapshots, screenshots, taps, text input, scrolling, and app install, but it cannot reverse a remote device port to a local host port. For iOS Metro or React DevTools, use a publicly reachable HTTPS endpoint or a bridge URL rather than a local-only address. Limrun does not currently expose provider artifacts through `agent-device artifacts`. + ### BrowserStack Required environment: @@ -213,11 +247,11 @@ const client = createAgentDeviceClient({ }); ``` -The JavaScript client does not publish a hosted WebDriver SDK subpath. Use the normal typed client methods; provider implementation details stay internal. +The JavaScript client does not publish provider SDK subpaths. Use the normal typed client methods; provider implementation details stay internal. Limrun uses the same client shape with `leaseProvider: 'limrun'`, `platform: 'android'` or `platform: 'ios'`, and `LIMRUN_API_KEY` in the daemon environment. ## MCP Experience -The MCP server exposes operational command tools such as `open`, `snapshot`, `click`, `close`, and `artifacts`. It does not expose `connect browserstack` or `connect aws-device-farm`. +The MCP server exposes operational command tools such as `open`, `snapshot`, `click`, `close`, and `artifacts`. It does not expose provider `connect` commands. For MCP-only operation, bootstrap with the CLI first in the same effective state directory: @@ -238,7 +272,7 @@ close {} artifacts {} ``` -Use the same pattern for AWS Device Farm: provide AWS credentials in the MCP server environment, run CLI `connect aws-device-farm ...` once, then let MCP tools operate on the active connection. If an integration cannot run CLI bootstrap, use the JavaScript client path instead of MCP for provider setup. +Use the same pattern for Limrun or AWS Device Farm: provide the provider credentials in the MCP server environment, run the corresponding CLI `connect` command once, then let MCP tools operate on the active connection. If an integration cannot run CLI bootstrap, use the JavaScript client path instead of MCP for provider setup. ## Artifact Lookup @@ -256,4 +290,5 @@ BrowserStack can return session video, Appium logs, device logs, dashboard URL, - If BrowserStack connect fails before opening a session, check `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `--provider-app`, `--provider-os-version`, and `--device`. - If AWS allocation fails, first run `aws sts get-caller-identity` in the same CI step to confirm the AWS CLI credential chain is active, then verify the Device Farm ARNs and region. +- If Limrun allocation fails, check `LIMRUN_API_KEY`, `--platform ios|android`, and the optional `LIMRUN_REGION`. Keep the key available in the environment used to start the local daemon. - If artifact lookup is pending immediately after `close`, retry `agent-device artifacts --json`. Some providers finalize video/log URLs asynchronously after the hosted session stops.