From 0e4b7a7385d1d207be758a48ff974ae07df18e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 27 Jun 2026 14:15:23 +0200 Subject: [PATCH 01/23] feat: add direct Limrun cloud runtime --- package.json | 1 + pnpm-lock.yaml | 82 ++ src/__tests__/cli-react-devtools.test.ts | 50 ++ src/__tests__/cloud-connect-profile.test.ts | 142 ++- src/__tests__/limrun-runtime.test.ts | 480 ++++++++++ src/cli/commands/connection.ts | 13 +- src/cli/commands/react-devtools.ts | 18 + src/cli/connection/limrun-profile.ts | 58 ++ src/cli/connection/profile-fields.ts | 1 + src/cli/connection/provider-policy.ts | 14 +- src/daemon/lease-registry.ts | 11 +- src/daemon/request-router.ts | 2 +- src/daemon/server/daemon-runtime.ts | 7 +- src/daemon/types.ts | 1 + src/providers/limrun/device.ts | 50 ++ src/providers/limrun/runtime.ts | 948 ++++++++++++++++++++ src/providers/limrun/snapshot.ts | 147 +++ src/remote/remote-config-schema.ts | 2 + 18 files changed, 2016 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/limrun-runtime.test.ts create mode 100644 src/cli/connection/limrun-profile.ts create mode 100644 src/providers/limrun/device.ts create mode 100644 src/providers/limrun/runtime.ts create mode 100644 src/providers/limrun/snapshot.ts diff --git a/package.json b/package.json index 850328657..35d95d0fc 100644 --- a/package.json +++ b/package.json @@ -218,6 +218,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/src/__tests__/cli-react-devtools.test.ts b/src/__tests__/cli-react-devtools.test.ts index d3ac0549b..c11476d7d 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/src/__tests__/cli-react-devtools.test.ts @@ -235,6 +235,56 @@ 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', + 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..e5de84590 --- /dev/null +++ b/src/__tests__/limrun-runtime.test.ts @@ -0,0 +1,480 @@ +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, readLocalhostUrlPort } 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', + })), + 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, + })), +})); + +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', () => ({ + 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 { + 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); + const androidOpenUrlCalls = limrunMockState.androidOpenUrl.mock.calls as unknown as Array< + [string] + >; + assert.equal(androidOpenUrlCalls[0]?.[0], openUrl); + 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', + 'reverse', + '--remove', + 'tcp:8081', + ]); + assert.deepEqual(vi.mocked(runCmd).mock.calls[2]?.[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: 'android', + }); + } 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 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(); + } +}); + +test('readLocalhostUrlPort recognizes loopback URLs only', () => { + assert.equal(readLocalhostUrlPort('exp://127.0.0.1:8081'), 8081); + assert.equal(readLocalhostUrlPort('http://localhost:19000/status'), 19000); + assert.equal(readLocalhostUrlPort('http://[::1]:8097'), 8097); + assert.equal(readLocalhostUrlPort('https://metro.agent-device.dev/status'), undefined); + assert.equal(readLocalhostUrlPort('not a url'), undefined); +}); 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..9cee03516 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -27,6 +27,7 @@ type ReactDevtoolsCommandOptions = { session?: string; cwd?: string; env?: NodeJS.ProcessEnv; + configureDirectPortReverse?: () => Promise; }; type RemoteBridgeConfig = { @@ -155,6 +156,20 @@ 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 ( + isRemoteBridgeBackend(flags.leaseBackend) && + flags.metroProxyBaseUrl === undefined && + options.configureDirectPortReverse !== undefined + ); +} + export async function runReactDevtoolsCommand( args: string[], options: ReactDevtoolsCommandOptions = {}, @@ -162,6 +177,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..29a4586c3 --- /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 '../parser/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/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index b41afd603..cda11fb7c 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 { @@ -345,7 +348,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 +362,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/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 7b69f38ee..62259f1c4 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -7,9 +7,11 @@ 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 { createLimrunRuntimeFromEnv } from '../../providers/limrun/runtime.ts'; import { composeCloudArtifactProviders, createProviderDeviceRuntimeRequestProviders, + type ProviderDeviceRuntime, } from '../../provider-device-runtime.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { createRequestHandler } from '../request-router.ts'; @@ -98,7 +100,10 @@ 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 = [ + ...createDefaultCloudWebDriverProviderRuntimes(env), + createLimrunRuntimeFromEnv(env), + ].filter((runtime): runtime is ProviderDeviceRuntime => Boolean(runtime)); 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>; + +type LimrunInstance = { + metadata: { id: string }; + status: { + token: string; + apiUrl?: string; + adbWebSocketUrl?: string; + state?: string; + }; +}; + +type LimrunRuntimeSession = + | { + platform: 'ios'; + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + client: LimrunIosClient; + } + | { + platform: 'android'; + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + client: LimrunAndroidClient; + adbTunnel?: LimrunAdbTunnel; + adbSerial?: string; + reversedPorts: Map; + }; + +type LimrunRuntimeOptions = { + apiKey: string; + region?: string; + version?: string; +}; + +const LIMRUN_CLIENT_HEADER = 'agent-device-cli'; +const SETTINGS_INTENT = 'android.settings.SETTINGS'; + +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) return null; + const leaseId = readLimrunLeaseIdFromInventoryRequest(request); + if (!leaseId) return null; + const session = this.sessions.get(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): Interactor | undefined { + const session = this.getSessionForDevice(device); + if (!session) return undefined; + return session.platform === 'ios' + ? new LimrunIosInteractor(session) + : new LimrunAndroidInteractor(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: { + leaseId: string; + devicePort: number; + hostPort: number; + name: string; + }): Promise | undefined> { + const session = this.getAndroidPortReverseSession(options.leaseId, { throwOnIos: true }); + if (!session) return undefined; + await ensureAndroidPortReverse(session, options); + return portReverseResult(options); + } + + async removePortReverse(options: { + leaseId: string; + devicePort: number; + hostPort: number; + name: string; + }): Promise | undefined> { + const session = this.getAndroidPortReverseSession(options.leaseId); + if (!session) return undefined; + await removeAndroidPortReverse(session, options.devicePort); + return portReverseResult(options); + } + + async shutdown(): Promise { + const sessions = Array.from(this.sessions.values()); + await Promise.allSettled(sessions.map((session) => 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'); + } + const client = await createIosInstanceClient({ + apiUrl: instance.status.apiUrl, + token: instance.status.token, + logLevel: 'warn', + }); + return { + platform: 'ios', + lease, + instanceId: instance.metadata.id, + device: buildLimrunDevice('ios', lease, instance.metadata.id), + client, + }; + } 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', + ); + } + const client = await createAndroidInstanceClient({ + apiUrl: instance.status.apiUrl, + adbUrl: instance.status.adbWebSocketUrl, + token: instance.status.token, + logLevel: 'warn', + }); + return { + platform: 'android', + lease, + instanceId: instance.metadata.id, + device: buildLimrunDevice('android', lease, instance.metadata.id), + client, + reversedPorts: new Map(), + }; + } 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 cleanupAndroidAdbTunnel(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); + if (!session || session.platform !== parsed.platform) return undefined; + return session; + } + + private getAndroidPortReverseSession( + leaseId: string, + options: { throwOnIos?: boolean } = {}, + ): Extract | undefined { + const session = this.sessions.get(leaseId); + if (!session) return undefined; + if (session.platform === 'android') return session; + if (options.throwOnIos) { + throw unsupported( + 'port reverse', + 'Direct Limrun iOS sessions cannot reach local host ports; use a bridge public URL.', + ); + } + return undefined; + } +} + +function portReverseResult(options: { + leaseId: string; + devicePort: number; + hostPort: number; + name: string; +}): Record { + return { + leaseId: options.leaseId, + devicePort: options.devicePort, + hostPort: options.hostPort, + name: options.name, + }; +} + +async function installLimrunIosApp( + limrun: Limrun, + session: Extract, + 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(); + } +} + +async function installLimrunAndroidApp( + limrun: Limrun, + session: Extract, + 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); + return { + ...(packageName ? { packageName, launchTarget: packageName } : {}), + ...(packageName ? { appName: inferAndroidAppName(packageName) } : {}), + }; +} + +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(' '), + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }); + } + return { + uploadPath: zipPath, + assetName: path.basename(zipPath), + cleanup: async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +async function runLimrunAndroidAdb( + session: Extract, + args: string[], + options?: AndroidAdbExecutorOptions, +): Promise { + const serial = await ensurePersistentAndroidAdbSerial(session); + const result = await runCmd('adb', ['-s', serial, ...args], { + allowFailure: options?.allowFailure, + timeoutMs: options?.timeoutMs ?? 30_000, + signal: options?.signal, + }); + if (result.exitCode !== 0 && options?.allowFailure !== true) { + throw new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { + command: ['adb', '-s', serial, ...args].join(' '), + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }); + } +} + +class LimrunIosInteractor implements Interactor { + private readonly session: Extract; + + constructor(session: Extract) { + 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 swipe(): Promise { + throw unsupported('swipe', 'Limrun iOS direct sessions do not expose swipe yet.'); + } + + async pan(): Promise { + throw unsupported('pan', 'Limrun iOS direct sessions do not expose pan yet.'); + } + + async fling(): Promise { + throw unsupported('fling', 'Limrun iOS direct sessions do not expose fling yet.'); + } + + 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 pinch(): Promise { + throw unsupported('pinch', 'Limrun iOS direct sessions do not expose multi-touch pinch yet.'); + } + + 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 rotate(orientation: 'portrait' | 'landscape-left' | 'landscape-right'): Promise { + await this.session.client.setOrientation(orientation === 'portrait' ? 'Portrait' : 'Landscape'); + } + + async rotateGesture(): Promise { + throw unsupported('rotate', 'Limrun iOS direct sessions do not expose rotate gestures yet.'); + } + + async transformGesture(): Promise { + throw unsupported( + 'transform', + 'Limrun iOS direct sessions do not expose transform gestures yet.', + ); + } + + async appSwitcher(): Promise { + throw unsupported('app-switcher', 'Limrun iOS direct sessions do not expose app switcher yet.'); + } + + 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.'); + } +} + +class LimrunAndroidInteractor implements Interactor { + private readonly session: Extract; + + constructor(session: Extract) { + this.session = session; + } + + async open(app: string, options?: { url?: string }): Promise { + if (options?.url) { + await this.ensureReverseForUrl(options.url, 'launch-url'); + await this.runAdb([ + 'shell', + 'monkey', + '-p', + app, + '-c', + 'android.intent.category.LAUNCHER', + '1', + ]); + await this.session.client.openUrl(options.url); + return; + } + if (looksLikeUrl(app)) { + await this.ensureReverseForUrl(app, 'open-url'); + await this.session.client.openUrl(app); + return; + } + if (isAndroidSettingsTarget(app)) { + await this.runAdb(['shell', 'am', 'start', '-W', '-a', SETTINGS_INTENT]); + return; + } + await this.runAdb([ + 'shell', + 'monkey', + '-p', + app, + '-c', + 'android.intent.category.LAUNCHER', + '1', + ]); + } + + async openDevice(): Promise {} + + async close(app: string): Promise { + if (app) await this.runAdb(['shell', 'am', 'force-stop', app], { allowFailure: true }); + } + + 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 { + await this.session.client.tap({ selector: toAndroidSelector(selector) }); + } + + async doubleTap(x: number, y: number): Promise { + await this.tap(x, y); + await this.tap(x, y); + } + + async swipe(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { + await this.runAdb([ + 'shell', + 'input', + 'swipe', + String(Math.round(x1)), + String(Math.round(y1)), + String(Math.round(x2)), + String(Math.round(y2)), + String(durationMs ?? 300), + ]); + } + + async pan(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { + await this.swipe(x1, y1, x2, y2, durationMs); + } + + async fling(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { + await this.swipe(x1, y1, x2, y2, durationMs); + } + + async longPress(x: number, y: number, durationMs?: number) { + await this.swipe(x, y, x, y, durationMs ?? 800); + } + + async focus(x: number, y: number): Promise { + await this.tap(x, y); + } + + async type(text: string): Promise { + await this.session.client.setText(undefined, text); + } + + async fill(x: number, y: number, text: string): Promise { + await this.session.client.setText({ x, y }, text); + } + + async fillElementSelector( + selector: { key: 'id' | 'label' | 'text' | 'value'; value: string }, + text: string, + ): Promise { + await this.session.client.setText({ selector: toAndroidSelector(selector) }, text); + } + + async scroll(direction: 'up' | 'down' | 'left' | 'right', options?: { pixels?: number }) { + await this.session.client.scrollScreen(direction, options?.pixels); + } + + async pinch(): Promise { + throw unsupported('pinch', 'Limrun Android direct sessions do not expose pinch yet.'); + } + + async screenshot(outPath: string): Promise { + const screenshot = await this.session.client.screenshot(); + await writeDataUriFile(outPath, screenshot.dataUri); + } + + async snapshot(): Promise { + const tree = await this.session.client.getElementTree(); + return { + nodes: tree.nodes.map(mapAndroidNode), + ...readOptionalTruncation(tree), + backend: 'android', + }; + } + + async back(): Promise { + await this.session.client.pressKey('BACK'); + } + + async home(): Promise { + await this.session.client.pressKey('HOME'); + } + + async rotate(): Promise { + throw unsupported('rotate', 'Limrun Android direct sessions do not expose rotate yet.'); + } + + async rotateGesture(): Promise { + throw unsupported( + 'rotate', + 'Limrun Android direct sessions do not expose rotate gestures yet.', + ); + } + + async transformGesture(): Promise { + throw unsupported( + 'transform', + 'Limrun Android direct sessions do not expose transform gestures yet.', + ); + } + + async appSwitcher(): Promise { + await this.session.client.pressKey('APP_SWITCH'); + } + + async readClipboard(): Promise { + throw unsupported( + 'clipboard', + 'Limrun Android direct sessions do not expose clipboard read yet.', + ); + } + + async writeClipboard(): Promise { + throw unsupported( + 'clipboard', + 'Limrun Android direct sessions do not expose clipboard write yet.', + ); + } + + async setSetting(): Promise { + throw unsupported( + 'settings', + 'Limrun Android direct sessions do not expose settings changes yet.', + ); + } + + private async runAdb(args: string[], options?: AndroidAdbExecutorOptions): Promise { + await runLimrunAndroidAdb(this.session, args, options); + } + + private async ensureReverseForUrl(url: string, name: string): Promise { + const port = readLocalhostUrlPort(url); + if (!port) return; + await ensureAndroidPortReverse(this.session, { + devicePort: port, + hostPort: port, + name, + }); + } +} + +async function ensureAndroidPortReverse( + session: Extract, + options: { + devicePort: number; + hostPort: number; + name: string; + }, +): Promise { + const existing = session.reversedPorts.get(options.devicePort); + if (existing === options.name) return; + if (existing && existing !== options.name) { + throw new AppError('INVALID_ARGS', `Limrun Android port reverse already exists`, { + devicePort: options.devicePort, + existingName: existing, + requestedName: options.name, + }); + } + const serial = await ensurePersistentAndroidAdbSerial(session); + const result = await runCmd( + 'adb', + ['-s', serial, 'reverse', `tcp:${options.devicePort}`, `tcp:${options.hostPort}`], + { + timeoutMs: 30_000, + }, + ); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Limrun Android ADB reverse failed', { + command: [ + 'adb', + '-s', + serial, + 'reverse', + `tcp:${options.devicePort}`, + `tcp:${options.hostPort}`, + ].join(' '), + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }); + } + session.reversedPorts.set(options.devicePort, options.name); +} + +async function removeAndroidPortReverse( + session: Extract, + devicePort: number, +): Promise { + if (!session.reversedPorts.has(devicePort)) return; + const serial = await ensurePersistentAndroidAdbSerial(session); + await runCmd('adb', ['-s', serial, 'reverse', '--remove', `tcp:${devicePort}`], { + allowFailure: true, + timeoutMs: 10_000, + }).catch(() => {}); + session.reversedPorts.delete(devicePort); +} + +function readOptionalTruncation(value: unknown): Pick { + const truncated = + value && typeof value === 'object' && 'truncated' in value + ? (value as { truncated?: unknown }).truncated + : undefined; + return typeof truncated === 'boolean' ? { truncated } : {}; +} + +async function ensurePersistentAndroidAdbSerial( + session: Extract, +): Promise { + if (session.adbTunnel && session.adbSerial) return session.adbSerial; + const tunnel = await session.client.startAdbTunnel(); + const serial = `${tunnel.address.address}:${tunnel.address.port}`; + session.adbTunnel = tunnel; + session.adbSerial = serial; + return serial; +} + +async function cleanupAndroidAdbTunnel( + session: Extract, +): Promise { + const serial = session.adbSerial; + if (serial) { + await Promise.allSettled( + Array.from(session.reversedPorts.keys()).map((port) => + runCmd('adb', ['-s', serial, 'reverse', '--remove', `tcp:${port}`], { + allowFailure: true, + timeoutMs: 10_000, + }), + ), + ); + await runCmd('adb', ['disconnect', serial], { + allowFailure: true, + timeoutMs: 10_000, + }).catch(() => {}); + } + session.reversedPorts.clear(); + session.adbTunnel?.close(); + session.adbTunnel = undefined; + session.adbSerial = undefined; +} + +function resolveIosTarget(app: string): string { + const normalized = app.trim().toLowerCase(); + if (normalized === 'settings') return 'com.apple.Preferences'; + return app; +} + +function isAndroidSettingsTarget(app: string): boolean { + const normalized = app.trim().toLowerCase(); + return normalized === 'settings' || normalized === 'com.android.settings'; +} + +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 inferAndroidAppName(packageName: string): string | undefined { + const segments = packageName.split('.').filter(Boolean); + const last = segments.at(-1); + return last ? last.replace(/[_-]+/g, ' ') : 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}`; +} + +function looksLikeUrl(value: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(value.trim()); +} + +export function readLocalhostUrlPort(value: string): number | undefined { + let url: URL; + try { + url = new URL(value); + } catch { + return undefined; + } + const hostname = url.hostname.toLowerCase(); + if ( + hostname !== 'localhost' && + hostname !== '127.0.0.1' && + hostname !== '::1' && + hostname !== '[::1]' + ) { + return undefined; + } + const port = Number(url.port); + if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined; + return port; +} + +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..42baa44ae --- /dev/null +++ b/src/providers/limrun/snapshot.ts @@ -0,0 +1,147 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { AndroidElementNode } from '@limrun/api/instance-client'; +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 mapAndroidNode(node: AndroidElementNode, index: number): RawSnapshotNode { + const bounds = node.parsedBounds; + return { + index, + type: node.className, + role: node.className, + label: node.text || node.contentDesc, + value: node.text, + identifier: node.resourceId, + rect: bounds + ? { + x: bounds.left, + y: bounds.top, + width: Math.max(0, bounds.right - bounds.left), + height: Math.max(0, bounds.bottom - bounds.top), + } + : undefined, + enabled: node.enabled, + selected: node.selected, + hittable: node.clickable, + }; +} + +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 function toAndroidSelector(selector: LimrunSelector) { + if (selector.key === 'id') return { resourceId: selector.value }; + return { text: selector.value }; +} + +export async function writeDataUriFile(filePath: string, dataUri: string): Promise { + const match = /^data:[^;]+;base64,(?.+)$/u.exec(dataUri); + await writeBase64File(filePath, match?.groups?.data ?? dataUri); +} + +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 = [ From 0e877666d208f636ba2b4e6cb86f103a5954b91c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 13:54:51 +0200 Subject: [PATCH 02/23] refactor: reuse Android provider runtime for Limrun --- src/__tests__/limrun-runtime.test.ts | 39 ++- src/cli/connection/limrun-profile.ts | 2 +- src/daemon/server/daemon-runtime.ts | 9 +- .../__tests__/app-lifecycle-open.test.ts | 55 +++ src/platforms/android/app-lifecycle.ts | 1 + src/provider-device-runtimes.ts | 16 + src/providers/limrun/runtime.ts | 327 +++++------------- src/providers/limrun/snapshot.ts | 34 -- 8 files changed, 181 insertions(+), 302 deletions(-) create mode 100644 src/provider-device-runtimes.ts diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index e5de84590..19aa6781f 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; -import { LimrunRuntime, readLocalhostUrlPort } from '../providers/limrun/runtime.ts'; +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'; @@ -83,9 +83,13 @@ vi.mock('@limrun/api/instance-client', () => ({ })), })); -vi.mock('../utils/exec.ts', () => ({ - runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), -})); +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; @@ -182,10 +186,7 @@ async function allocateLimrunDevice( function assertAndroidTunnelLifecycle(openUrl: string): void { assert.equal(limrunMockState.androidStartAdbTunnel.mock.calls.length, 1); - const androidOpenUrlCalls = limrunMockState.androidOpenUrl.mock.calls as unknown as Array< - [string] - >; - assert.equal(androidOpenUrlCalls[0]?.[0], openUrl); + assert.equal(limrunMockState.androidOpenUrl.mock.calls.length, 0); assert.deepEqual(vi.mocked(runCmd).mock.calls[0]?.[1], [ '-s', '127.0.0.1:62001', @@ -194,13 +195,25 @@ function assertAndroidTunnelLifecycle(openUrl: string): void { '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', '--remove', 'tcp:8081', ]); - assert.deepEqual(vi.mocked(runCmd).mock.calls[2]?.[1], ['disconnect', '127.0.0.1:62001']); + assert.deepEqual(vi.mocked(runCmd).mock.calls[3]?.[1], ['disconnect', '127.0.0.1:62001']); assert.equal(limrunMockState.androidTunnelClose.mock.calls.length, 1); } @@ -470,11 +483,3 @@ test('Limrun keeps session tracked when release fails so release can be retried' await runtime.shutdown(); } }); - -test('readLocalhostUrlPort recognizes loopback URLs only', () => { - assert.equal(readLocalhostUrlPort('exp://127.0.0.1:8081'), 8081); - assert.equal(readLocalhostUrlPort('http://localhost:19000/status'), 19000); - assert.equal(readLocalhostUrlPort('http://[::1]:8097'), 8097); - assert.equal(readLocalhostUrlPort('https://metro.agent-device.dev/status'), undefined); - assert.equal(readLocalhostUrlPort('not a url'), undefined); -}); diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index 29a4586c3..abf058c4c 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -1,7 +1,7 @@ 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 '../parser/cli-flags.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'; diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 62259f1c4..8538aebf7 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -6,13 +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 { createLimrunRuntimeFromEnv } from '../../providers/limrun/runtime.ts'; import { composeCloudArtifactProviders, createProviderDeviceRuntimeRequestProviders, - type ProviderDeviceRuntime, } 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'; @@ -100,10 +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), - createLimrunRuntimeFromEnv(env), - ].filter((runtime): runtime is ProviderDeviceRuntime => Boolean(runtime)); + const providerDeviceRuntimes = createDefaultProviderDeviceRuntimes(env); const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders(providerDeviceRuntimes); const cloudArtifactProvider = composeCloudArtifactProviders( 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/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-runtimes.ts b/src/provider-device-runtimes.ts new file mode 100644 index 000000000..704ca73ef --- /dev/null +++ b/src/provider-device-runtimes.ts @@ -0,0 +1,16 @@ +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'; +import { createLimrunRuntimeFromEnv } from './providers/limrun/runtime.ts'; + +export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRuntimeEnv & + NodeJS.ProcessEnv; + +export function createDefaultProviderDeviceRuntimes( + env: DefaultProviderDeviceRuntimeEnv = process.env, +): ProviderDeviceRuntime[] { + return [ + ...createDefaultCloudWebDriverProviderRuntimes(env), + createLimrunRuntimeFromEnv(env), + ].filter((runtime): runtime is ProviderDeviceRuntime => Boolean(runtime)); +} diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 5eb577b2c..898840b26 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -15,8 +15,16 @@ import { runCmd } from '../../utils/exec.ts'; import { readVersion } from '../../utils/version.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; import type { Interactor, SnapshotOptions, SnapshotResult } from '../../core/interactor-types.ts'; +import { createAndroidInteractor } from '../../core/interactors/android.ts'; import type { DeviceInventoryProvider } from '../../core/dispatch-resolve.ts'; -import type { AndroidAdbExecutorOptions } from '../../platforms/android/adb-executor.ts'; +import { + withAndroidAdbProvider, + type AndroidAdbExecutorOptions, + type AndroidAdbExecutorResult, + type AndroidAdbProvider, + type AndroidPortReverseEndpoint, + type AndroidPortReverseProvider, +} from '../../platforms/android/adb-executor.ts'; import type { LeaseLifecycleProvider } from '../../daemon/handlers/lease.ts'; import type { DeviceLease } from '../../daemon/lease-registry.ts'; import type { @@ -31,15 +39,7 @@ import { platformForLimrunLeaseBackend, readLimrunLeaseIdFromInventoryRequest, } from './device.ts'; -import { - flattenIosTree, - mapAndroidNode, - toAndroidSelector, - toIosSelector, - writeBase64File, - writeDataUriFile, - type IosTreeNode, -} from './snapshot.ts'; +import { flattenIosTree, toIosSelector, writeBase64File, type IosTreeNode } from './snapshot.ts'; type LimrunAdbTunnel = Awaited>; @@ -79,7 +79,6 @@ type LimrunRuntimeOptions = { }; const LIMRUN_CLIENT_HEADER = 'agent-device-cli'; -const SETTINGS_INTENT = 'android.settings.SETTINGS'; export function createLimrunRuntimeFromEnv(env: NodeJS.ProcessEnv): LimrunRuntime | undefined { const apiKey = env.LIMRUN_API_KEY?.trim() || env.LIM_API_KEY?.trim(); @@ -132,7 +131,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { if (!session) return undefined; return session.platform === 'ios' ? new LimrunIosInteractor(session) - : new LimrunAndroidInteractor(session); + : createLimrunAndroidInteractor(session); } async installApp( @@ -429,10 +428,12 @@ async function runLimrunAndroidAdb( session: Extract, args: string[], options?: AndroidAdbExecutorOptions, -): Promise { +): Promise { const serial = await ensurePersistentAndroidAdbSerial(session); const result = await runCmd('adb', ['-s', serial, ...args], { allowFailure: options?.allowFailure, + binaryStdout: options?.binaryStdout, + stdin: options?.stdin, timeoutMs: options?.timeoutMs ?? 30_000, signal: options?.signal, }); @@ -444,6 +445,72 @@ async function runLimrunAndroidAdb( stderr: result.stderr, }); } + return result; +} + +function createLimrunAndroidInteractor( + session: Extract, +): Interactor { + const base = createAndroidInteractor(session.device); + const provider = createLimrunAndroidAdbProvider(session); + return new Proxy(base, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => + withAndroidAdbProvider(provider, { serial: session.device.id }, async () => + value.apply(target, args), + ); + }, + }); +} + +function createLimrunAndroidAdbProvider( + session: Extract, +): AndroidAdbProvider { + return { + exec: async (args, options) => await runLimrunAndroidAdb(session, args, options), + reverse: createLimrunAndroidPortReverseProvider(session), + text: async (request) => { + await session.client.setText(request.target, request.text); + }, + }; +} + +function createLimrunAndroidPortReverseProvider( + session: Extract, +): AndroidPortReverseProvider { + return { + async ensure(mapping) { + await ensureAndroidPortReverse(session, { + devicePort: tcpEndpointPort(mapping.local), + hostPort: tcpEndpointPort(mapping.remote), + name: mapping.ownerId ?? 'android-adb-provider', + }); + }, + async remove(local) { + await removeAndroidPortReverse(session, tcpEndpointPort(local)); + }, + async removeAllOwned(ownerId) { + const ownedPorts = Array.from(session.reversedPorts.entries()) + .filter(([, name]) => name === ownerId) + .map(([port]) => port); + for (const port of ownedPorts) { + await removeAndroidPortReverse(session, port); + } + }, + }; +} + +function tcpEndpointPort(endpoint: AndroidPortReverseEndpoint): number { + if (!endpoint.startsWith('tcp:')) { + throw unsupported('port reverse', `Limrun Android only supports tcp reverse endpoints.`); + } + const port = Number(endpoint.slice('tcp:'.length)); + if (!Number.isInteger(port) || port <= 0) { + throw new AppError('INVALID_ARGS', `Invalid Android tcp reverse endpoint: ${endpoint}`); + } + return port; } class LimrunIosInteractor implements Interactor { @@ -577,6 +644,10 @@ class LimrunIosInteractor implements Interactor { 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.'); } @@ -590,202 +661,6 @@ class LimrunIosInteractor implements Interactor { } } -class LimrunAndroidInteractor implements Interactor { - private readonly session: Extract; - - constructor(session: Extract) { - this.session = session; - } - - async open(app: string, options?: { url?: string }): Promise { - if (options?.url) { - await this.ensureReverseForUrl(options.url, 'launch-url'); - await this.runAdb([ - 'shell', - 'monkey', - '-p', - app, - '-c', - 'android.intent.category.LAUNCHER', - '1', - ]); - await this.session.client.openUrl(options.url); - return; - } - if (looksLikeUrl(app)) { - await this.ensureReverseForUrl(app, 'open-url'); - await this.session.client.openUrl(app); - return; - } - if (isAndroidSettingsTarget(app)) { - await this.runAdb(['shell', 'am', 'start', '-W', '-a', SETTINGS_INTENT]); - return; - } - await this.runAdb([ - 'shell', - 'monkey', - '-p', - app, - '-c', - 'android.intent.category.LAUNCHER', - '1', - ]); - } - - async openDevice(): Promise {} - - async close(app: string): Promise { - if (app) await this.runAdb(['shell', 'am', 'force-stop', app], { allowFailure: true }); - } - - 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 { - await this.session.client.tap({ selector: toAndroidSelector(selector) }); - } - - async doubleTap(x: number, y: number): Promise { - await this.tap(x, y); - await this.tap(x, y); - } - - async swipe(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { - await this.runAdb([ - 'shell', - 'input', - 'swipe', - String(Math.round(x1)), - String(Math.round(y1)), - String(Math.round(x2)), - String(Math.round(y2)), - String(durationMs ?? 300), - ]); - } - - async pan(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { - await this.swipe(x1, y1, x2, y2, durationMs); - } - - async fling(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { - await this.swipe(x1, y1, x2, y2, durationMs); - } - - async longPress(x: number, y: number, durationMs?: number) { - await this.swipe(x, y, x, y, durationMs ?? 800); - } - - async focus(x: number, y: number): Promise { - await this.tap(x, y); - } - - async type(text: string): Promise { - await this.session.client.setText(undefined, text); - } - - async fill(x: number, y: number, text: string): Promise { - await this.session.client.setText({ x, y }, text); - } - - async fillElementSelector( - selector: { key: 'id' | 'label' | 'text' | 'value'; value: string }, - text: string, - ): Promise { - await this.session.client.setText({ selector: toAndroidSelector(selector) }, text); - } - - async scroll(direction: 'up' | 'down' | 'left' | 'right', options?: { pixels?: number }) { - await this.session.client.scrollScreen(direction, options?.pixels); - } - - async pinch(): Promise { - throw unsupported('pinch', 'Limrun Android direct sessions do not expose pinch yet.'); - } - - async screenshot(outPath: string): Promise { - const screenshot = await this.session.client.screenshot(); - await writeDataUriFile(outPath, screenshot.dataUri); - } - - async snapshot(): Promise { - const tree = await this.session.client.getElementTree(); - return { - nodes: tree.nodes.map(mapAndroidNode), - ...readOptionalTruncation(tree), - backend: 'android', - }; - } - - async back(): Promise { - await this.session.client.pressKey('BACK'); - } - - async home(): Promise { - await this.session.client.pressKey('HOME'); - } - - async rotate(): Promise { - throw unsupported('rotate', 'Limrun Android direct sessions do not expose rotate yet.'); - } - - async rotateGesture(): Promise { - throw unsupported( - 'rotate', - 'Limrun Android direct sessions do not expose rotate gestures yet.', - ); - } - - async transformGesture(): Promise { - throw unsupported( - 'transform', - 'Limrun Android direct sessions do not expose transform gestures yet.', - ); - } - - async appSwitcher(): Promise { - await this.session.client.pressKey('APP_SWITCH'); - } - - async readClipboard(): Promise { - throw unsupported( - 'clipboard', - 'Limrun Android direct sessions do not expose clipboard read yet.', - ); - } - - async writeClipboard(): Promise { - throw unsupported( - 'clipboard', - 'Limrun Android direct sessions do not expose clipboard write yet.', - ); - } - - async setSetting(): Promise { - throw unsupported( - 'settings', - 'Limrun Android direct sessions do not expose settings changes yet.', - ); - } - - private async runAdb(args: string[], options?: AndroidAdbExecutorOptions): Promise { - await runLimrunAndroidAdb(this.session, args, options); - } - - private async ensureReverseForUrl(url: string, name: string): Promise { - const port = readLocalhostUrlPort(url); - if (!port) return; - await ensureAndroidPortReverse(this.session, { - devicePort: port, - hostPort: port, - name, - }); - } -} - async function ensureAndroidPortReverse( session: Extract, options: { @@ -842,14 +717,6 @@ async function removeAndroidPortReverse( session.reversedPorts.delete(devicePort); } -function readOptionalTruncation(value: unknown): Pick { - const truncated = - value && typeof value === 'object' && 'truncated' in value - ? (value as { truncated?: unknown }).truncated - : undefined; - return typeof truncated === 'boolean' ? { truncated } : {}; -} - async function ensurePersistentAndroidAdbSerial( session: Extract, ): Promise { @@ -891,11 +758,6 @@ function resolveIosTarget(app: string): string { return app; } -function isAndroidSettingsTarget(app: string): boolean { - const normalized = app.trim().toLowerCase(); - return normalized === 'settings' || normalized === 'com.android.settings'; -} - function normalizeOptionalString(value: string | undefined): string | undefined { const normalized = value?.trim(); return normalized ? normalized : undefined; @@ -922,27 +784,6 @@ function looksLikeUrl(value: string): boolean { return /^[a-z][a-z0-9+.-]*:/i.test(value.trim()); } -export function readLocalhostUrlPort(value: string): number | undefined { - let url: URL; - try { - url = new URL(value); - } catch { - return undefined; - } - const hostname = url.hostname.toLowerCase(); - if ( - hostname !== 'localhost' && - hostname !== '127.0.0.1' && - hostname !== '::1' && - hostname !== '[::1]' - ) { - return undefined; - } - const port = Number(url.port); - if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined; - return port; -} - 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 index 42baa44ae..765cbe55c 100644 --- a/src/providers/limrun/snapshot.ts +++ b/src/providers/limrun/snapshot.ts @@ -1,6 +1,5 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { AndroidElementNode } from '@limrun/api/instance-client'; import type { RawSnapshotNode } from '../../kernel/snapshot.ts'; type LimrunSelector = { key: 'id' | 'label' | 'text' | 'value'; value: string }; @@ -102,45 +101,12 @@ function readIosNodeChildren(node: IosTreeNode): IosTreeNode[] { return node.children ?? node.nodes ?? node.elements ?? []; } -export function mapAndroidNode(node: AndroidElementNode, index: number): RawSnapshotNode { - const bounds = node.parsedBounds; - return { - index, - type: node.className, - role: node.className, - label: node.text || node.contentDesc, - value: node.text, - identifier: node.resourceId, - rect: bounds - ? { - x: bounds.left, - y: bounds.top, - width: Math.max(0, bounds.right - bounds.left), - height: Math.max(0, bounds.bottom - bounds.top), - } - : undefined, - enabled: node.enabled, - selected: node.selected, - hittable: node.clickable, - }; -} - 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 function toAndroidSelector(selector: LimrunSelector) { - if (selector.key === 'id') return { resourceId: selector.value }; - return { text: selector.value }; -} - -export async function writeDataUriFile(filePath: string, dataUri: string): Promise { - const match = /^data:[^;]+;base64,(?.+)$/u.exec(dataUri); - await writeBase64File(filePath, match?.groups?.data ?? dataUri); -} - 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')); From a03a3af41feb5bf4af9ca869b6799f48230dc5d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 14:33:05 +0200 Subject: [PATCH 03/23] refactor: pass runner context to provider runtimes --- src/__tests__/limrun-runtime.test.ts | 2 +- src/__tests__/provider-device-runtime.test.ts | 14 +++++-- src/cloud-webdriver/runtime.ts | 4 +- src/core/interactors.ts | 2 +- src/provider-device-runtime.ts | 11 +++-- src/providers/limrun/runtime.ts | 35 +++++----------- src/utils/__tests__/interactors.test.ts | 42 ++++++++++++++++++- 7 files changed, 73 insertions(+), 37 deletions(-) diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 19aa6781f..20778e875 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -145,7 +145,7 @@ test('Limrun Android reverses localhost URL ports through the persistent ADB tun try { const device = await allocateLimrunDevice(runtime, androidLease()); - const interactor = runtime.getInteractor(device); + 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'); 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/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/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/provider-device-runtime.ts b/src/provider-device-runtime.ts index bf6c06f7c..045db6cc0 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, @@ -83,10 +83,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/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 898840b26..fe03dc810 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -14,7 +14,12 @@ import { AppError } from '../../kernel/errors.ts'; import { runCmd } from '../../utils/exec.ts'; import { readVersion } from '../../utils/version.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; -import type { Interactor, SnapshotOptions, SnapshotResult } from '../../core/interactor-types.ts'; +import type { + Interactor, + RunnerContext, + SnapshotOptions, + SnapshotResult, +} from '../../core/interactor-types.ts'; import { createAndroidInteractor } from '../../core/interactors/android.ts'; import type { DeviceInventoryProvider } from '../../core/dispatch-resolve.ts'; import { @@ -126,7 +131,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { return parseLimrunDeviceId(device.id) !== undefined; } - getInteractor(device: DeviceInfo): Interactor | undefined { + getInteractor(device: DeviceInfo, _runnerContext: RunnerContext): Interactor | undefined { const session = this.getSessionForDevice(device); if (!session) return undefined; return session.platform === 'ios' @@ -555,18 +560,6 @@ class LimrunIosInteractor implements Interactor { await this.tap(x, y); } - async swipe(): Promise { - throw unsupported('swipe', 'Limrun iOS direct sessions do not expose swipe yet.'); - } - - async pan(): Promise { - throw unsupported('pan', 'Limrun iOS direct sessions do not expose pan yet.'); - } - - async fling(): Promise { - throw unsupported('fling', 'Limrun iOS direct sessions do not expose fling yet.'); - } - async longPress(): Promise { throw unsupported('longpress', 'Limrun iOS direct sessions do not expose long press yet.'); } @@ -602,10 +595,6 @@ class LimrunIosInteractor implements Interactor { await this.session.client.scroll(direction, options?.pixels ?? 300); } - async pinch(): Promise { - throw unsupported('pinch', 'Limrun iOS direct sessions do not expose multi-touch pinch yet.'); - } - async screenshot(outPath: string): Promise { const screenshot = await this.session.client.screenshot(); await writeBase64File(outPath, screenshot.base64); @@ -629,14 +618,10 @@ class LimrunIosInteractor implements Interactor { await this.session.client.setOrientation(orientation === 'portrait' ? 'Portrait' : 'Landscape'); } - async rotateGesture(): Promise { - throw unsupported('rotate', 'Limrun iOS direct sessions do not expose rotate gestures yet.'); - } - - async transformGesture(): Promise { + async performGesture(): Promise { throw unsupported( - 'transform', - 'Limrun iOS direct sessions do not expose transform gestures yet.', + 'gesture', + 'Limrun iOS direct sessions do not expose portable gesture execution yet.', ); } 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) => { From 772cf6b7ff859afd7627f490d343644ff6647a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 14:53:11 +0200 Subject: [PATCH 04/23] fix: remove Android gesture swipe fallback --- src/providers/limrun/runtime.ts | 32 +++++++++++++------ .../provider-scenarios/android-world.ts | 19 +++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index fe03dc810..1266d400a 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -435,22 +435,34 @@ async function runLimrunAndroidAdb( options?: AndroidAdbExecutorOptions, ): Promise { const serial = await ensurePersistentAndroidAdbSerial(session); - const result = await runCmd('adb', ['-s', serial, ...args], { + const adbArgs = limrunAdbArgs(serial, args); + const result = await runCmd('adb', adbArgs, limrunAdbRunOptions(options)); + if (result.exitCode !== 0 && options?.allowFailure !== true) + throw limrunAndroidAdbError(adbArgs, result); + return result; +} + +function limrunAdbArgs(serial: string, args: string[]): string[] { + return ['-s', serial, ...args]; +} + +function limrunAdbRunOptions(options: AndroidAdbExecutorOptions | undefined) { + return { allowFailure: options?.allowFailure, binaryStdout: options?.binaryStdout, stdin: options?.stdin, timeoutMs: options?.timeoutMs ?? 30_000, signal: options?.signal, + }; +} + +function limrunAndroidAdbError(adbArgs: string[], result: AndroidAdbExecutorResult): AppError { + return new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { + command: ['adb', ...adbArgs].join(' '), + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, }); - if (result.exitCode !== 0 && options?.allowFailure !== true) { - throw new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { - command: ['adb', '-s', serial, ...args].join(' '), - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, - }); - } - return result; } function createLimrunAndroidInteractor( 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 } = {}, From 1c5c62f8dbda554f22319953482be8d85f5892c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 15 Jul 2026 21:17:33 +0200 Subject: [PATCH 05/23] fix: reconcile Limrun direct runtime with main --- .../test-app/replays/gesture-lab-android.ad | 14 ++--- src/__tests__/limrun-runtime.test.ts | 29 +++++++++++ .../__tests__/session-relaunch-close.test.ts | 52 +++++++++++++++++++ src/daemon/handlers/session-open.ts | 28 +++++++++- src/providers/limrun/runtime.ts | 9 +++- 5 files changed, 123 insertions(+), 9 deletions(-) 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/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 20778e875..d5e80278b 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -20,6 +20,7 @@ const limrunMockState = vi.hoisted(() => { 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), @@ -71,6 +72,7 @@ vi.mock('@limrun/api/ios-client', () => ({ createInstanceClient: vi.fn(async () => ({ disconnect: vi.fn(), installApp: limrunMockState.iosInstallApp, + setOrientation: limrunMockState.iosSetOrientation, })), })); @@ -328,6 +330,33 @@ test('Limrun iOS installs direct local artifacts through Limrun assets', async ( } }); +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', 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/session-open.ts b/src/daemon/handlers/session-open.ts index b5d87d5fd..92279bc22 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; @@ -255,7 +272,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/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 1266d400a..845a50878 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -13,6 +13,7 @@ import { import { AppError } from '../../kernel/errors.ts'; import { runCmd } from '../../utils/exec.ts'; import { readVersion } from '../../utils/version.ts'; +import type { DeviceRotation } from '../../contracts/device-rotation.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; import type { Interactor, @@ -626,7 +627,13 @@ class LimrunIosInteractor implements Interactor { throw unsupported('home', 'Limrun iOS direct sessions do not expose home yet.'); } - async rotate(orientation: 'portrait' | 'landscape-left' | 'landscape-right'): Promise { + 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'); } From 5c826cf2fa62289646e1499f22220c2df68b0e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 15 Jul 2026 21:19:22 +0200 Subject: [PATCH 06/23] refactor: compose Android provider interactors in core --- scripts/layering/model.ts | 1 + src/core/interactors/android.ts | 24 ++++++++++++++++++++++-- src/providers/limrun/runtime.ts | 14 +------------- 3 files changed, 24 insertions(+), 15 deletions(-) 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/core/interactors/android.ts b/src/core/interactors/android.ts index 89fcf82d3..5d2af6c69 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, @@ -90,4 +97,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/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 845a50878..185ec1a08 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -24,7 +24,6 @@ import type { import { createAndroidInteractor } from '../../core/interactors/android.ts'; import type { DeviceInventoryProvider } from '../../core/dispatch-resolve.ts'; import { - withAndroidAdbProvider, type AndroidAdbExecutorOptions, type AndroidAdbExecutorResult, type AndroidAdbProvider, @@ -469,18 +468,7 @@ function limrunAndroidAdbError(adbArgs: string[], result: AndroidAdbExecutorResu function createLimrunAndroidInteractor( session: Extract, ): Interactor { - const base = createAndroidInteractor(session.device); - const provider = createLimrunAndroidAdbProvider(session); - return new Proxy(base, { - get(target, property, receiver) { - const value = Reflect.get(target, property, receiver); - if (typeof value !== 'function') return value; - return (...args: unknown[]) => - withAndroidAdbProvider(provider, { serial: session.device.id }, async () => - value.apply(target, args), - ); - }, - }); + return createAndroidInteractor(session.device, createLimrunAndroidAdbProvider(session)); } function createLimrunAndroidAdbProvider( From 5af9134bf421780a991c06e86c12afc9f4c817a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 15 Jul 2026 21:29:47 +0200 Subject: [PATCH 07/23] fix: satisfy packaged Limrun runtime checks --- src/providers/limrun/runtime.ts | 14 ++++---------- test/integration/installed-package-metro.test.ts | 1 + 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 185ec1a08..61621bc1a 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -11,7 +11,7 @@ import { type InstanceClient as LimrunIosClient, } from '@limrun/api/ios-client'; import { AppError } from '../../kernel/errors.ts'; -import { runCmd } from '../../utils/exec.ts'; +import { execFailureDetails, runCmd } from '../../utils/exec.ts'; import { readVersion } from '../../utils/version.ts'; import type { DeviceRotation } from '../../contracts/device-rotation.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; @@ -415,9 +415,7 @@ async function prepareLimrunIosAsset(artifactPath: string): Promise<{ 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(' '), - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, + ...execFailureDetails(result), }); } return { @@ -459,9 +457,7 @@ function limrunAdbRunOptions(options: AndroidAdbExecutorOptions | undefined) { function limrunAndroidAdbError(adbArgs: string[], result: AndroidAdbExecutorResult): AppError { return new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { command: ['adb', ...adbArgs].join(' '), - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, + ...execFailureDetails(result), }); } @@ -688,9 +684,7 @@ async function ensureAndroidPortReverse( `tcp:${options.devicePort}`, `tcp:${options.hostPort}`, ].join(' '), - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, + ...execFailureDetails(result), }); } session.reversedPorts.set(options.devicePort, options.name); 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'); } } From 58e2e48bc3eb7fd50f25153d3d62abed5bdbfd8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 15 Jul 2026 21:39:34 +0200 Subject: [PATCH 08/23] perf: load Limrun provider runtime on demand --- .../provider-device-runtimes.test.ts | 23 +++++++++++++++++++ src/daemon/server/daemon-runtime.ts | 2 +- src/provider-device-runtimes.ts | 15 ++++++------ 3 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/provider-device-runtimes.test.ts 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/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 8538aebf7..a0b2d89ca 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.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 = createDefaultProviderDeviceRuntimes(env); + const providerDeviceRuntimes = await createDefaultProviderDeviceRuntimes(env); const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders(providerDeviceRuntimes); const cloudArtifactProvider = composeCloudArtifactProviders( diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 704ca73ef..be894760e 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -1,16 +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'; -import { createLimrunRuntimeFromEnv } from './providers/limrun/runtime.ts'; export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRuntimeEnv & NodeJS.ProcessEnv; -export function createDefaultProviderDeviceRuntimes( +export async function createDefaultProviderDeviceRuntimes( env: DefaultProviderDeviceRuntimeEnv = process.env, -): ProviderDeviceRuntime[] { - return [ - ...createDefaultCloudWebDriverProviderRuntimes(env), - createLimrunRuntimeFromEnv(env), - ].filter((runtime): runtime is ProviderDeviceRuntime => Boolean(runtime)); +): 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; } From 62f0582d2f34e88ad261d79357e0fc2fc617cae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 08:15:27 +0200 Subject: [PATCH 09/23] docs: document Limrun device cloud flow --- src/cli-schema/command-overrides.ts | 2 +- .../parser/__tests__/cli-help-topics.test.ts | 6 ++- src/cli/parser/cli-help.ts | 18 +++++-- website/docs/docs/client-api.md | 6 +-- website/docs/docs/device-clouds.md | 53 +++++++++++++++---- 5 files changed, 67 insertions(+), 18 deletions(-) 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/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 7be77bd1d..90cd6f35d 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/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. From f14308c26b8c693062e20acdf0656ec144793e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 08:32:17 +0200 Subject: [PATCH 10/23] refactor: reuse Android reverse provider for Limrun --- .../cli-react-devtools-session.test.ts | 61 +- src/__tests__/cli-react-devtools.test.ts | 1 + src/__tests__/limrun-runtime.test.ts | 42 +- src/cli.ts | 23 +- src/cli/commands/react-devtools.ts | 29 +- .../android/__tests__/adb-executor.test.ts | 11 + src/platforms/android/adb-executor.ts | 9 +- src/providers/limrun/android.ts | 228 +++++++ src/providers/limrun/device.ts | 14 +- src/providers/limrun/ios.ts | 270 ++++++++ src/providers/limrun/runtime.ts | 596 ++---------------- 11 files changed, 714 insertions(+), 570 deletions(-) create mode 100644 src/providers/limrun/android.ts create mode 100644 src/providers/limrun/ios.ts 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 c11476d7d..f9896250f 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/src/__tests__/cli-react-devtools.test.ts @@ -250,6 +250,7 @@ test('react-devtools start configures direct remote port reverse', async () => { ...remoteBridgeScope, metroProxyBaseUrl: undefined, leaseBackend: 'android-instance', + leaseProvider: 'limrun', remoteConfig: '/tmp/remote.json', session: 'default', }, diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index d5e80278b..1f3923cc7 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -146,6 +146,12 @@ test('Limrun Android reverses localhost URL ports through the persistent ADB tun }); 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'); @@ -209,13 +215,19 @@ function assertAndroidTunnelLifecycle(openUrl: string): void { 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[3]?.[1], ['disconnect', '127.0.0.1:62001']); + assert.deepEqual(vi.mocked(runCmd).mock.calls[4]?.[1], ['disconnect', '127.0.0.1:62001']); assert.equal(limrunMockState.androidTunnelClose.mock.calls.length, 1); } @@ -266,13 +278,39 @@ test('Limrun Android installs direct local artifacts through Limrun assets', asy assert.deepEqual(result, { packageName: 'com.example.android', launchTarget: 'com.example.android', - appName: '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'); 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/react-devtools.ts b/src/cli/commands/react-devtools.ts index 9cee03516..ecf2ba9b1 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -11,18 +11,22 @@ 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; @@ -164,7 +168,8 @@ function shouldConfigureDirectReverse( const { flags } = options; if (!flags) return false; return ( - isRemoteBridgeBackend(flags.leaseBackend) && + flags.leaseProvider === 'limrun' && + flags.leaseBackend === 'android-instance' && flags.metroProxyBaseUrl === undefined && options.configureDirectPortReverse !== undefined ); diff --git a/src/platforms/android/__tests__/adb-executor.test.ts b/src/platforms/android/__tests__/adb-executor.test.ts index 098aa0f00..2fc4f1637 100644 --- a/src/platforms/android/__tests__/adb-executor.test.ts +++ b/src/platforms/android/__tests__/adb-executor.test.ts @@ -29,6 +29,7 @@ import { resolveAndroidAdbExecutor, resolveAndroidAdbProvider, withAndroidAdbProvider, + type AndroidAdbProvider, } from '../adb-executor.ts'; import { runCmd, runCmdBackground } from '../../../utils/exec.ts'; import { AppError, normalizeError } from '../../../kernel/errors.ts'; @@ -221,6 +222,16 @@ test('createAndroidPortReverseManager rejects mappings owned by another session' ); }); +test('createAndroidPortReverseManager reuses a provider-held canonical manager', () => { + 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/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/providers/limrun/android.ts b/src/providers/limrun/android.ts new file mode 100644 index 000000000..9276a2b12 --- /dev/null +++ b/src/providers/limrun/android.ts @@ -0,0 +1,228 @@ +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 { inferAndroidAppName } from '../../platforms/android/app-lifecycle.ts'; +import { + createAndroidPortReverseManager, + 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); + }, + }; + 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); + return { + ...(packageName ? { packageName, launchTarget: packageName } : {}), + ...(packageName ? { appName: inferAndroidAppName(packageName) } : {}), + }; +} + +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 index 193eb0f07..85ab35bee 100644 --- a/src/providers/limrun/device.ts +++ b/src/providers/limrun/device.ts @@ -31,20 +31,14 @@ export function buildLimrunDevice( export function parseLimrunDeviceId( value: string, -): { platform: LimrunPlatform; leaseId: string } | null { +): { platform: LimrunPlatform; leaseId: string } | undefined { const [prefix, platform, leaseId] = value.split(':'); - if (prefix !== LIMRUN_DEVICE_ID_PREFIX) return null; - if (platform !== 'ios' && platform !== 'android') return null; - if (!leaseId) return null; + if (prefix !== LIMRUN_DEVICE_ID_PREFIX) return undefined; + if (platform !== 'ios' && platform !== 'android') return undefined; + if (!leaseId) return undefined; return { platform, leaseId }; } -export function readLimrunLeaseIdFromInventoryRequest(request: { - leaseId?: string; -}): string | undefined { - return request.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 index 61621bc1a..c79263cac 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -1,52 +1,38 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import Limrun from '@limrun/api'; -import { - createInstanceClient as createAndroidInstanceClient, - type InstanceClient as LimrunAndroidClient, -} from '@limrun/api/instance-client'; -import { - createInstanceClient as createIosInstanceClient, - type InstanceClient as LimrunIosClient, -} from '@limrun/api/ios-client'; -import { AppError } from '../../kernel/errors.ts'; -import { execFailureDetails, runCmd } from '../../utils/exec.ts'; -import { readVersion } from '../../utils/version.ts'; -import type { DeviceRotation } from '../../contracts/device-rotation.ts'; -import type { DeviceInfo } from '../../kernel/device.ts'; -import type { - Interactor, - RunnerContext, - SnapshotOptions, - SnapshotResult, -} from '../../core/interactor-types.ts'; -import { createAndroidInteractor } from '../../core/interactors/android.ts'; +import type { Interactor, RunnerContext } from '../../core/interactor-types.ts'; import type { DeviceInventoryProvider } from '../../core/dispatch-resolve.ts'; -import { - type AndroidAdbExecutorOptions, - type AndroidAdbExecutorResult, - type AndroidAdbProvider, - type AndroidPortReverseEndpoint, - type AndroidPortReverseProvider, -} from '../../platforms/android/adb-executor.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, - readLimrunLeaseIdFromInventoryRequest, } from './device.ts'; -import { flattenIosTree, toIosSelector, writeBase64File, type IosTreeNode } from './snapshot.ts'; - -type LimrunAdbTunnel = Awaited>; +import { + createLimrunIosInteractor, + createLimrunIosSession, + installLimrunIosApp, + type LimrunIosSession, +} from './ios.ts'; type LimrunInstance = { metadata: { id: string }; @@ -54,28 +40,10 @@ type LimrunInstance = { token: string; apiUrl?: string; adbWebSocketUrl?: string; - state?: string; }; }; -type LimrunRuntimeSession = - | { - platform: 'ios'; - lease: DeviceLease; - instanceId: string; - device: DeviceInfo; - client: LimrunIosClient; - } - | { - platform: 'android'; - lease: DeviceLease; - instanceId: string; - device: DeviceInfo; - client: LimrunAndroidClient; - adbTunnel?: LimrunAdbTunnel; - adbSerial?: string; - reversedPorts: Map; - }; +type LimrunRuntimeSession = LimrunIosSession | LimrunAndroidSession; type LimrunRuntimeOptions = { apiKey: string; @@ -107,10 +75,8 @@ export class LimrunRuntime implements ProviderDeviceRuntime { }; readonly deviceInventoryProvider: DeviceInventoryProvider = async (request) => { - if (request.leaseProvider !== this.provider) return null; - const leaseId = readLimrunLeaseIdFromInventoryRequest(request); - if (!leaseId) return null; - const session = this.sessions.get(leaseId); + 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]; @@ -135,7 +101,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { const session = this.getSessionForDevice(device); if (!session) return undefined; return session.platform === 'ios' - ? new LimrunIosInteractor(session) + ? createLimrunIosInteractor(session) : createLimrunAndroidInteractor(session); } @@ -164,33 +130,27 @@ export class LimrunRuntime implements ProviderDeviceRuntime { : await installLimrunAndroidApp(this.limrun, session, installablePath, options); } - async configurePortReverse(options: { - leaseId: string; - devicePort: number; - hostPort: number; - name: string; - }): Promise | undefined> { - const session = this.getAndroidPortReverseSession(options.leaseId, { throwOnIos: true }); + async configurePortReverse( + options: ProviderPortReverseOptions, + ): Promise | undefined> { + const session = this.getAndroidPortReverseSession(options.leaseId, true); if (!session) return undefined; - await ensureAndroidPortReverse(session, options); + await configureLimrunAndroidPortReverse(session, options); return portReverseResult(options); } - async removePortReverse(options: { - leaseId: string; - devicePort: number; - hostPort: number; - name: string; - }): Promise | undefined> { - const session = this.getAndroidPortReverseSession(options.leaseId); + async removePortReverse( + options: ProviderPortReverseOptions, + ): Promise | undefined> { + const session = this.getAndroidPortReverseSession(options.leaseId, false); if (!session) return undefined; - await removeAndroidPortReverse(session, options.devicePort); + await removeLimrunAndroidPortReverse(session, options); return portReverseResult(options); } async shutdown(): Promise { - const sessions = Array.from(this.sessions.values()); - await Promise.allSettled(sessions.map((session) => this.terminateSession(session))); + const sessions = [...this.sessions.values()]; + await Promise.allSettled(sessions.map(async (session) => await this.terminateSession(session))); this.sessions.clear(); } @@ -199,9 +159,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { 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 }; - } + if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device }; const session = platform === 'ios' @@ -211,7 +169,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { return { limrunInstanceId: session.instanceId, device: session.device }; } - private async createIosSession(lease: DeviceLease): Promise { + private async createIosSession(lease: DeviceLease): Promise { const instance = (await this.limrun.iosInstances.create({ wait: true, metadata: this.buildInstanceMetadata(lease), @@ -221,25 +179,20 @@ export class LimrunRuntime implements ProviderDeviceRuntime { if (!instance.status.apiUrl) { throw new AppError('COMMAND_FAILED', 'Limrun iOS instance did not expose apiUrl'); } - const client = await createIosInstanceClient({ - apiUrl: instance.status.apiUrl, - token: instance.status.token, - logLevel: 'warn', - }); - return { - platform: 'ios', + return await createLimrunIosSession({ lease, instanceId: instance.metadata.id, device: buildLimrunDevice('ios', lease, instance.metadata.id), - client, - }; + 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 { + private async createAndroidSession(lease: DeviceLease): Promise { const instance = (await this.limrun.androidInstances.create({ wait: true, metadata: this.buildInstanceMetadata(lease), @@ -252,20 +205,14 @@ export class LimrunRuntime implements ProviderDeviceRuntime { 'Limrun Android instance did not expose API and ADB websocket endpoints', ); } - const client = await createAndroidInstanceClient({ + 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, - logLevel: 'warn', }); - return { - platform: 'android', - lease, - instanceId: instance.metadata.id, - device: buildLimrunDevice('android', lease, instance.metadata.id), - client, - reversedPorts: new Map(), - }; } catch (error) { await this.limrun.androidInstances.delete(instance.metadata.id).catch(() => {}); throw error; @@ -299,7 +246,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { await this.limrun.iosInstances.delete(session.instanceId); return; } - await cleanupAndroidAdbTunnel(session); + await cleanupLimrunAndroidAdbTunnel(session); await this.limrun.androidInstances.delete(session.instanceId); } @@ -307,18 +254,16 @@ export class LimrunRuntime implements ProviderDeviceRuntime { const parsed = parseLimrunDeviceId(device.id); if (!parsed) return undefined; const session = this.sessions.get(parsed.leaseId); - if (!session || session.platform !== parsed.platform) return undefined; - return session; + return session?.platform === parsed.platform ? session : undefined; } private getAndroidPortReverseSession( leaseId: string, - options: { throwOnIos?: boolean } = {}, - ): Extract | undefined { + throwOnIos: boolean, + ): LimrunAndroidSession | undefined { const session = this.sessions.get(leaseId); - if (!session) return undefined; - if (session.platform === 'android') return session; - if (options.throwOnIos) { + 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.', @@ -328,12 +273,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { } } -function portReverseResult(options: { - leaseId: string; - devicePort: number; - hostPort: number; - name: string; -}): Record { +function portReverseResult(options: ProviderPortReverseOptions): Record { return { leaseId: options.leaseId, devicePort: options.devicePort, @@ -342,434 +282,6 @@ function portReverseResult(options: { }; } -async function installLimrunIosApp( - limrun: Limrun, - session: Extract, - 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(); - } -} - -async function installLimrunAndroidApp( - limrun: Limrun, - session: Extract, - 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); - return { - ...(packageName ? { packageName, launchTarget: packageName } : {}), - ...(packageName ? { appName: inferAndroidAppName(packageName) } : {}), - }; -} - -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 }); - }, - }; -} - -async function runLimrunAndroidAdb( - session: Extract, - args: string[], - options?: AndroidAdbExecutorOptions, -): Promise { - const serial = await ensurePersistentAndroidAdbSerial(session); - const adbArgs = limrunAdbArgs(serial, args); - const result = await runCmd('adb', adbArgs, limrunAdbRunOptions(options)); - if (result.exitCode !== 0 && options?.allowFailure !== true) - throw limrunAndroidAdbError(adbArgs, result); - return result; -} - -function limrunAdbArgs(serial: string, args: string[]): string[] { - return ['-s', serial, ...args]; -} - -function limrunAdbRunOptions(options: AndroidAdbExecutorOptions | undefined) { - return { - allowFailure: options?.allowFailure, - binaryStdout: options?.binaryStdout, - stdin: options?.stdin, - timeoutMs: options?.timeoutMs ?? 30_000, - signal: options?.signal, - }; -} - -function limrunAndroidAdbError(adbArgs: string[], result: AndroidAdbExecutorResult): AppError { - return new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { - command: ['adb', ...adbArgs].join(' '), - ...execFailureDetails(result), - }); -} - -function createLimrunAndroidInteractor( - session: Extract, -): Interactor { - return createAndroidInteractor(session.device, createLimrunAndroidAdbProvider(session)); -} - -function createLimrunAndroidAdbProvider( - session: Extract, -): AndroidAdbProvider { - return { - exec: async (args, options) => await runLimrunAndroidAdb(session, args, options), - reverse: createLimrunAndroidPortReverseProvider(session), - text: async (request) => { - await session.client.setText(request.target, request.text); - }, - }; -} - -function createLimrunAndroidPortReverseProvider( - session: Extract, -): AndroidPortReverseProvider { - return { - async ensure(mapping) { - await ensureAndroidPortReverse(session, { - devicePort: tcpEndpointPort(mapping.local), - hostPort: tcpEndpointPort(mapping.remote), - name: mapping.ownerId ?? 'android-adb-provider', - }); - }, - async remove(local) { - await removeAndroidPortReverse(session, tcpEndpointPort(local)); - }, - async removeAllOwned(ownerId) { - const ownedPorts = Array.from(session.reversedPorts.entries()) - .filter(([, name]) => name === ownerId) - .map(([port]) => port); - for (const port of ownedPorts) { - await removeAndroidPortReverse(session, port); - } - }, - }; -} - -function tcpEndpointPort(endpoint: AndroidPortReverseEndpoint): number { - if (!endpoint.startsWith('tcp:')) { - throw unsupported('port reverse', `Limrun Android only supports tcp reverse endpoints.`); - } - const port = Number(endpoint.slice('tcp:'.length)); - if (!Number.isInteger(port) || port <= 0) { - throw new AppError('INVALID_ARGS', `Invalid Android tcp reverse endpoint: ${endpoint}`); - } - return port; -} - -class LimrunIosInteractor implements Interactor { - private readonly session: Extract; - - constructor(session: Extract) { - 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 ensureAndroidPortReverse( - session: Extract, - options: { - devicePort: number; - hostPort: number; - name: string; - }, -): Promise { - const existing = session.reversedPorts.get(options.devicePort); - if (existing === options.name) return; - if (existing && existing !== options.name) { - throw new AppError('INVALID_ARGS', `Limrun Android port reverse already exists`, { - devicePort: options.devicePort, - existingName: existing, - requestedName: options.name, - }); - } - const serial = await ensurePersistentAndroidAdbSerial(session); - const result = await runCmd( - 'adb', - ['-s', serial, 'reverse', `tcp:${options.devicePort}`, `tcp:${options.hostPort}`], - { - timeoutMs: 30_000, - }, - ); - if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Limrun Android ADB reverse failed', { - command: [ - 'adb', - '-s', - serial, - 'reverse', - `tcp:${options.devicePort}`, - `tcp:${options.hostPort}`, - ].join(' '), - ...execFailureDetails(result), - }); - } - session.reversedPorts.set(options.devicePort, options.name); -} - -async function removeAndroidPortReverse( - session: Extract, - devicePort: number, -): Promise { - if (!session.reversedPorts.has(devicePort)) return; - const serial = await ensurePersistentAndroidAdbSerial(session); - await runCmd('adb', ['-s', serial, 'reverse', '--remove', `tcp:${devicePort}`], { - allowFailure: true, - timeoutMs: 10_000, - }).catch(() => {}); - session.reversedPorts.delete(devicePort); -} - -async function ensurePersistentAndroidAdbSerial( - session: Extract, -): Promise { - if (session.adbTunnel && session.adbSerial) return session.adbSerial; - const tunnel = await session.client.startAdbTunnel(); - const serial = `${tunnel.address.address}:${tunnel.address.port}`; - session.adbTunnel = tunnel; - session.adbSerial = serial; - return serial; -} - -async function cleanupAndroidAdbTunnel( - session: Extract, -): Promise { - const serial = session.adbSerial; - if (serial) { - await Promise.allSettled( - Array.from(session.reversedPorts.keys()).map((port) => - runCmd('adb', ['-s', serial, 'reverse', '--remove', `tcp:${port}`], { - allowFailure: true, - timeoutMs: 10_000, - }), - ), - ); - await runCmd('adb', ['disconnect', serial], { - allowFailure: true, - timeoutMs: 10_000, - }).catch(() => {}); - } - session.reversedPorts.clear(); - session.adbTunnel?.close(); - session.adbTunnel = undefined; - session.adbSerial = undefined; -} - -function resolveIosTarget(app: string): string { - const normalized = app.trim().toLowerCase(); - if (normalized === 'settings') return 'com.apple.Preferences'; - return 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 inferAndroidAppName(packageName: string): string | undefined { - const segments = packageName.split('.').filter(Boolean); - const last = segments.at(-1); - return last ? last.replace(/[_-]+/g, ' ') : 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}`; -} - -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 }); } From 5b9b819fabc81582d1cc9207ea963e9bb80177df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 08:47:47 +0200 Subject: [PATCH 11/23] fix: isolate provider-owned iOS sessions --- src/core/dispatch-resolve.ts | 8 +- src/daemon/__tests__/lease-lifecycle.test.ts | 37 +++ src/daemon/apple-runner-options.ts | 8 +- src/daemon/direct-ios-selector.ts | 5 + .../__tests__/session-close-shutdown.test.ts | 61 +++-- src/daemon/handlers/lease.ts | 8 +- src/daemon/handlers/session-close.ts | 52 +++-- src/daemon/handlers/session-open.ts | 1 + src/daemon/lease-lifecycle.ts | 27 ++- src/daemon/lease-registry.ts | 23 +- src/daemon/selector-runtime-backend.ts | 2 + .../provider-ios-selector-runtime.test.ts | 218 ++++++++++++++++++ 12 files changed, 382 insertions(+), 68 deletions(-) create mode 100644 test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 1302e54a6..e4097c161 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: false, }), ); } 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/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 | undefined; // 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; + const 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, + }); 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); - } + providerData = await releaseSessionLease({ session, leaseRegistry, leaseLifecycleProvider }); + } catch (error) { + const normalized = normalizeError(error); + return { + ok: false, + error: { + ...normalized, + hint: 'The provider device could not be released. Retry close after the provider is reachable.', + details: { ...normalized.details, session: session.name }, + retriable: true, + }, + }; } + sessionStore.delete(sessionName); const cleanupAggregate = reportSessionCleanupFailures({ sessionName, phase: 'session_close_cleanup_failed', diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 92279bc22..7ec1cd5d8 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -220,6 +220,7 @@ async function completeOpenCommand(params: { const shouldPrewarmIosRunner = isIosFamily(device) && + !isActiveProviderDevice(device) && surface === 'app' && openPositionals.length > 0 && Boolean(sessionAppBundleId); 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 cda11fb7c..37283e597 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -297,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 { diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index 3d66b4166..2a59a9404 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; @@ -199,6 +200,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/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', + }; +} From f6d04801e00c7f6dc16efdc055482b2f3cb8189e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 10:09:55 +0200 Subject: [PATCH 12/23] fix: preserve provider runtime boundaries --- src/core/dispatch-resolve.ts | 2 +- src/providers/limrun/android.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index e4097c161..42ba547fa 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -154,7 +154,7 @@ export async function resolveTargetDevice( cacheKey, await resolveAppleDevice(injectedDevices, selector as AppleDeviceSelector, { simulatorSetPath: iosSimulatorSetPath, - allowLocalSimulatorFallback: false, + allowLocalSimulatorFallback: inventoryRequest.leaseProvider === undefined, }), ); } diff --git a/src/providers/limrun/android.ts b/src/providers/limrun/android.ts index 9276a2b12..ed4fa4ff3 100644 --- a/src/providers/limrun/android.ts +++ b/src/providers/limrun/android.ts @@ -9,9 +9,7 @@ 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 { inferAndroidAppName } from '../../platforms/android/app-lifecycle.ts'; import { - createAndroidPortReverseManager, type AndroidAdbExecutorOptions, type AndroidAdbExecutorResult, type AndroidAdbProvider, @@ -68,6 +66,8 @@ export async function createLimrunAndroidSession(options: { 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 }); } @@ -93,9 +93,12 @@ export async function installLimrunAndroidApp( name: buildAndroidAssetName(packageName, installablePath), }); await session.client.sendAsset(asset.signedDownloadUrl); + const appName = packageName + ? (await import('../../platforms/android/app-lifecycle.ts')).inferAndroidAppName(packageName) + : undefined; return { ...(packageName ? { packageName, launchTarget: packageName } : {}), - ...(packageName ? { appName: inferAndroidAppName(packageName) } : {}), + ...(appName ? { appName } : {}), }; } From 809673c999e35006b5f16a65a53eef2df0fb6884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 10:16:40 +0200 Subject: [PATCH 13/23] refactor: split close repair lifecycle --- src/daemon/handlers/session-close.ts | 127 ++++++++++++++++----------- 1 file changed, 77 insertions(+), 50 deletions(-) diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 750cc5b6e..24ea9edb4 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -266,6 +266,70 @@ function repairPlatformCloseIdentity(req: DaemonRequest): string { return JSON.stringify(req.positionals ?? []); } +type RepairClosePreparation = + | { repairArmed: boolean; healedScriptPath?: string } + | { response: DaemonResponse }; + +async function prepareRepairClose(params: { + req: DaemonRequest; + session: SessionState; + logPath: string; + sessionStore: SessionStore; +}): 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,35 +435,8 @@ 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. The provider lease is committed only after that teardown, @@ -414,25 +449,17 @@ export async function handleCloseCommand(params: { logPath, sessionStore, cleanupFailures, - repairArmed, + 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: repairArmed, + skipPlatformClose: repair.repairArmed, }); - try { - providerData = await releaseSessionLease({ session, leaseRegistry, leaseLifecycleProvider }); - } catch (error) { - const normalized = normalizeError(error); - return { - ok: false, - error: { - ...normalized, - hint: 'The provider device could not be released. Retry close after the provider is reachable.', - details: { ...normalized.details, session: session.name }, - retriable: true, - }, - }; - } + const leaseRelease = await releaseProviderLeaseForClose({ + session, + leaseRegistry, + leaseLifecycleProvider, + }); + if (leaseRelease.response) return leaseRelease.response; sessionStore.delete(sessionName); const cleanupAggregate = reportSessionCleanupFailures({ sessionName, @@ -451,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, @@ -460,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}`, ), @@ -472,7 +499,7 @@ export async function handleCloseCommand(params: { session: session.name, ...successText(`Closed: ${session.name}`), ...savedScript, - ...(providerData ? { provider: providerData } : {}), + ...(leaseRelease.providerData ? { provider: leaseRelease.providerData } : {}), }, }; } From f2112dd9d16660e2e0fc322dea11fe04b0bfc997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 13:41:52 +0200 Subject: [PATCH 14/23] fix: reject unavailable provider leases --- src/daemon/handlers/lease.ts | 20 ++++++++++ src/daemon/request-handler-chain.ts | 2 + src/daemon/request-router.ts | 3 ++ src/daemon/server/daemon-runtime.ts | 1 + src/provider-device-runtime.ts | 2 + .../provider-device-runtime.test.ts | 37 ++++++++++++++++++- 6 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 974b2b42b..522b3447d 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -40,6 +40,7 @@ type LeaseHandlerArgs = { sessionName: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; + providerRuntimeIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; }; @@ -50,6 +51,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise | undefined; try { @@ -114,6 +117,23 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise, diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index dc0bba286..024792209 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -16,6 +16,7 @@ type RequestHandlerChainParams = { logPath: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; + providerRuntimeIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; invoke: DaemonInvokeFn; @@ -90,6 +91,7 @@ async function runLeaseHandler( sessionName: params.sessionName, sessionStore: params.sessionStore, leaseRegistry: params.leaseRegistry, + providerRuntimeIds: params.providerRuntimeIds, leaseLifecycleProvider: params.leaseLifecycleProvider, cloudArtifactProvider: params.cloudArtifactProvider, }), diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index b8bee203d..a25f20b9d 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -64,6 +64,7 @@ export type RequestRouterDeps = { appLogProvider?: AppLogProviderResolver; recordingProvider?: RecordingProviderResolver; deviceInventoryProvider?: DeviceInventoryProvider; + providerRuntimeIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; @@ -88,6 +89,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { appLogProvider, recordingProvider, deviceInventoryProvider, + providerRuntimeIds, leaseLifecycleProvider, cloudArtifactProvider, providerDeviceRuntimeScope, @@ -213,6 +215,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { sessionStore, leaseRegistry, leaseLifecycleProvider, + providerRuntimeIds, cloudArtifactProvider, invoke: handleRequest, invokeReplayAction: allowReplayActions diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index a0b2d89ca..8c103d8d3 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -115,6 +115,7 @@ export async function startDaemonRuntime( leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, cloudArtifactProvider, deviceInventoryProvider: providerRuntimeProviders.deviceInventoryProvider, + providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, trackDownloadableArtifact, }); diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 045db6cc0..61c1b68f1 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -60,6 +60,7 @@ export type ProviderPortReverseOptions = { }; export type ProviderDeviceRuntimeRequestProviders = { + providerRuntimeIds: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; deviceInventoryProvider?: DeviceInventoryProvider; @@ -164,6 +165,7 @@ export function createProviderDeviceRuntimeRequestProviders( runtimes: ProviderDeviceRuntime[], ): ProviderDeviceRuntimeRequestProviders { return { + providerRuntimeIds: runtimes.map((runtime) => runtime.provider), leaseLifecycleProvider: composeLeaseProvider(runtimes), cloudArtifactProvider: composeCloudArtifactProvider(runtimes), deviceInventoryProvider: composeDeviceInventoryProvider(runtimes), diff --git a/test/integration/provider-scenarios/provider-device-runtime.test.ts b/test/integration/provider-scenarios/provider-device-runtime.test.ts index 1c75f663f..47cdea681 100644 --- a/test/integration/provider-scenarios/provider-device-runtime.test.ts +++ b/test/integration/provider-scenarios/provider-device-runtime.test.ts @@ -13,7 +13,7 @@ import type { LeaseLifecycleProvider } from '../../../src/daemon/handlers/lease. 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 { assertRpcError, assertRpcOk } from './assertions.ts'; import { createProviderScenarioHarness, withProviderScenarioResource, @@ -56,6 +56,41 @@ test('Provider-backed scenario composes lease, inventory, dispatch, and port rev }); }, 15_000); +test('provider lease allocation fails when the daemon lacks the requested runtime', async () => { + const daemon = await createProviderScenarioHarness({ + providerRuntimeIds: [FAKE_PROVIDER], + deviceInventoryProvider: async () => null, + }); + try { + const response = await daemon.callCommand( + 'lease_allocate', + [], + { + platform: 'ios', + tenant: 'team-a', + runId: 'run-a', + leaseProvider: 'limrun', + }, + { + meta: { + tenantId: 'team-a', + runId: 'run-a', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + }, + }, + ); + const error = assertRpcError( + response, + 'UNSUPPORTED_OPERATION', + /Provider "limrun" is not available in this daemon runtime/, + ); + assert.match(String(error.hint), /Restart the daemon/); + } finally { + await daemon.close(); + } +}); + async function runFakeProviderScenario( daemon: Awaited>, runtime: FakeProviderDeviceRuntime, From 2bd656e21163108f0e20158a599408aa4f00be9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 15:11:23 +0200 Subject: [PATCH 15/23] fix: reconcile provider runtime review feedback --- .../test-app/replays/gesture-lab-android.ad | 2 +- src/__tests__/limrun-runtime.test.ts | 95 ++++++++++++++++++- src/__tests__/provider-device-runtime.test.ts | 14 +-- src/cloud-webdriver/runtime.ts | 4 +- src/core/interactors.ts | 2 +- src/daemon/__tests__/lease-lifecycle.test.ts | 14 +++ src/daemon/handlers/lease.ts | 17 +++- src/daemon/lease-lifecycle.ts | 32 ++++++- src/daemon/request-handler-chain.ts | 2 + src/daemon/request-router.ts | 3 + src/daemon/server/daemon-runtime.ts | 26 +++-- src/platforms/apple/core/app-resolution.ts | 9 +- src/platforms/apple/core/install-artifact.ts | 2 +- src/provider-device-runtime.ts | 24 +++-- src/provider-device-runtimes.ts | 7 ++ src/providers/limrun/android.ts | 18 ++-- src/providers/limrun/ios.ts | 33 ++++--- src/providers/limrun/runtime.ts | 29 +++--- src/providers/limrun/snapshot.ts | 2 + src/providers/limrun/strings.ts | 4 + src/utils/__tests__/interactors.test.ts | 7 +- .../provider-device-runtime.test.ts | 26 +++++ 22 files changed, 286 insertions(+), 86 deletions(-) create mode 100644 src/providers/limrun/strings.ts diff --git a/examples/test-app/replays/gesture-lab-android.ad b/examples/test-app/replays/gesture-lab-android.ad index c4684c80d..cc626f1c0 100644 --- a/examples/test-app/replays/gesture-lab-android.ad +++ b/examples/test-app/replays/gesture-lab-android.ad @@ -15,7 +15,7 @@ wait "two-pointer pan activations 0" 5000 gesture pan 220 700 180 0 500 --pointer-count 2 wait "two-pointer pan activations 1" 5000 -gesture fling left 500 700 300 +gesture fling right 500 700 300 wait "fling 1" 5000 gesture fling left 500 700 300 diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 1f3923cc7..58a8c1824 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -20,6 +20,8 @@ const limrunMockState = vi.hoisted(() => { bundleId: 'com.example.ios', url: 'https://assets.example/app', })), + iosLaunchApp: vi.fn(async () => undefined), + iosOpenUrl: vi.fn(async () => undefined), iosSetOrientation: vi.fn(async () => undefined), androidOpenUrl: vi.fn(async () => undefined), androidDisconnect: vi.fn(), @@ -72,6 +74,8 @@ vi.mock('@limrun/api/ios-client', () => ({ createInstanceClient: vi.fn(async () => ({ disconnect: vi.fn(), installApp: limrunMockState.iosInstallApp, + launchApp: limrunMockState.iosLaunchApp, + openUrl: limrunMockState.iosOpenUrl, setOrientation: limrunMockState.iosSetOrientation, })), })); @@ -139,6 +143,28 @@ test('Limrun runtime identifies direct CLI usage to the Limrun API', async () => } }); +test('Limrun iOS uses shared deep-link classification', async () => { + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + + try { + const device = await allocateLimrunDevice(runtime, { + ...androidLease(), + leaseId: 'lease-ios-deep-link', + backend: 'ios-instance', + }); + const interactor = runtime.getInteractor(device); + if (!interactor) throw new Error('Limrun runtime must return an interactor'); + + await interactor.open('http:malformed'); + await interactor.open('example://screen'); + + assert.deepEqual(limrunMockState.iosLaunchApp.mock.calls, [['http:malformed']]); + assert.deepEqual(limrunMockState.iosOpenUrl.mock.calls, [['example://screen']]); + } 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', @@ -153,7 +179,7 @@ test('Limrun Android reverses localhost URL ports through the persistent ADB tun exitCode: 0, })); const device = await allocateLimrunDevice(runtime, androidLease()); - const interactor = runtime.getInteractor(device, {}); + 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'); @@ -368,6 +394,40 @@ test('Limrun iOS installs direct local artifacts through Limrun assets', async ( } }); +test('Limrun iOS reads the bundle display name before uploading an app bundle', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-limrun-app-name-')); + const appPath = path.join(tempRoot, 'Renamed.app'); + fs.mkdirSync(appPath); + fs.writeFileSync( + path.join(appPath, 'Info.plist'), + [ + '', + '', + 'CFBundleDisplayNameActual App Name', + '', + ].join(''), + ); + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + + try { + const device = await allocateLimrunDevice(runtime, { + ...androidLease(), + leaseId: 'lease-ios-app-name', + backend: 'ios-instance', + }); + const result = await runtime.installApp( + device as Parameters[0], + 'com.example.ios', + appPath, + ); + + assert.equal(result?.appName, 'Actual App Name'); + } 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', @@ -380,7 +440,7 @@ test('Limrun iOS maps supported orientation and rejects unsupported upside-down leaseId: 'lease-ios-orientation', backend: 'ios-instance', }); - const interactor = runtime.getInteractor(device, {}); + const interactor = runtime.getInteractor(device); if (!interactor) throw new Error('Limrun runtime must return an interactor'); await interactor.setOrientation('landscape-left'); @@ -456,6 +516,37 @@ test('Limrun Android configures and removes an explicit port reverse', async () } }); +test('Limrun Android preserves canonical ADB failure classification', async () => { + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + + try { + await allocateLimrunDevice(runtime, androidLease()); + vi.mocked(runCmd).mockResolvedValueOnce({ + stdout: '', + stderr: 'error: device offline', + exitCode: 1, + }); + + await assert.rejects( + () => + runtime.configurePortReverse({ + leaseId: 'lease-android', + devicePort: 8097, + hostPort: 8097, + name: 'react-devtools', + }), + (error) => + typeof error === 'object' && + error !== null && + (error as { details?: { adbFailure?: unknown; retriable?: unknown } }).details + ?.adbFailure === 'device_offline' && + (error as { details?: { retriable?: unknown } }).details?.retriable === true, + ); + } finally { + await runtime.shutdown(); + } +}); + test('Limrun deletes iOS instance when post-create validation fails', async () => { limrunMockState.iosCreate.mockResolvedValueOnce({ metadata: { id: 'ios-instance-missing-api' }, diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 8e232ad4e..439ee06e6 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -58,18 +58,16 @@ 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, runnerContexts, missRuntime, hitRuntime }; + return { lease, device, interactor, missRuntime, hitRuntime }; } function makeMissingRuntime(): ProviderDeviceRuntime { @@ -84,9 +82,7 @@ function makeMissingRuntime(): ProviderDeviceRuntime { } async function assertProviderRuntimeDelegates(world: ReturnType) { - const runnerContext = { appBundleId: 'com.example.app', requestId: 'request-a' }; - assert.equal(getProviderDeviceInteractor(world.device, runnerContext), world.interactor); - assert.deepEqual(world.runnerContexts, [runnerContext]); + assert.equal(getProviderDeviceInteractor(world.device), world.interactor); assert.deepEqual( await installProviderDeviceApp(world.device, 'com.example.app', '/tmp/app.ipa'), { @@ -145,7 +141,6 @@ function makeRuntime(options: { leaseResult: Record | undefined; devices: DeviceInfo[] | null; interactor: Interactor | undefined; - runnerContexts?: unknown[]; installResult: { bundleId: string } | undefined; portReverseResult: Record | undefined; installHook?: boolean; @@ -159,10 +154,7 @@ function makeRuntime(options: { }, deviceInventoryProvider: async () => options.devices, ownsDevice: (device) => options.devices?.some((entry) => entry.id === device.id) ?? false, - getInteractor: (_device, runnerContext) => { - options.runnerContexts?.push(runnerContext); - return options.interactor; - }, + getInteractor: () => options.interactor, ...(options.installHook === false ? {} : { installApp: async () => options.installResult }), configurePortReverse: async () => options.portReverseResult, removePortReverse: async () => options.portReverseResult, diff --git a/src/cloud-webdriver/runtime.ts b/src/cloud-webdriver/runtime.ts index 8cfde958d..9aa5420ce 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, RunnerContext } from '../core/interactor-types.ts'; +import type { Interactor } 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, _runnerContext: RunnerContext): Interactor | undefined { + getInteractor(device: DeviceInfo): Interactor | undefined { return [...this.sessionsByLeaseId.values()].find((session) => session.device.id === device.id) ?.interactor; } diff --git a/src/core/interactors.ts b/src/core/interactors.ts index 3328001f6..6cf470452 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, runnerContext); + const providerInteractor = getProviderDeviceInteractor(device); if (providerInteractor) return providerInteractor; throw new AppError( 'UNSUPPORTED_OPERATION', diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index 1e7eb09e3..9ae302ddf 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -5,6 +5,7 @@ import { LeaseRegistry } from '../lease-registry.ts'; import { admitRequestLeaseForLockedScope, cleanupExpiredLeasedSession, + releaseExpiredProviderLease, releaseSessionLease, resolveSessionLeaseForRequest, } from '../lease-lifecycle.ts'; @@ -119,6 +120,19 @@ test('releaseSessionLease releases with the stored session owner scope', async ( expect(provider).toEqual({ provider: 'proxy' }); }); +test('releaseExpiredProviderLease releases a provider-owned lease without a session', async () => { + const lease = new LeaseRegistry().allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'limrun', + }); + const release = vi.fn(async () => ({ limrunInstanceId: 'instance-1' })); + + await releaseExpiredProviderLease({ release }, lease); + + expect(release).toHaveBeenCalledWith(lease); +}); + test('releaseSessionLease keeps the local lease when the provider release fails', async () => { const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 522b3447d..4c40681d4 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -41,6 +41,7 @@ type LeaseHandlerArgs = { sessionStore: SessionStore; leaseRegistry: LeaseRegistry; providerRuntimeIds?: readonly string[]; + providerRuntimeRequiredIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; }; @@ -52,6 +53,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise | undefined; try { @@ -120,8 +126,15 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise Promise; +export async function releaseExpiredProviderLease( + leaseLifecycleProvider: LeaseLifecycleProvider | undefined, + lease: DeviceLease, +): Promise { + if (!lease.leaseProvider || !leaseLifecycleProvider?.release) return; + + try { + const provider = await leaseLifecycleProvider.release(lease); + emitDiagnostic({ + level: 'info', + phase: 'provider_lease_expired_released', + data: { + leaseId: lease.leaseId, + provider: lease.leaseProvider, + ...(provider ? { providerData: provider } : {}), + }, + }); + } catch (error) { + emitDiagnostic({ + level: 'error', + phase: 'provider_lease_expiry_release_failed', + data: { + leaseId: lease.leaseId, + provider: lease.leaseProvider, + error: error instanceof Error ? error.message : String(error), + }, + }); + } +} + export function assertLockedLeaseAdmissionPreflight(req: DaemonRequest): void { assertRequestLeaseAdmissionPreflight(req); } diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 024792209..e7b27b4de 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -17,6 +17,7 @@ type RequestHandlerChainParams = { sessionStore: SessionStore; leaseRegistry: LeaseRegistry; providerRuntimeIds?: readonly string[]; + providerRuntimeRequiredIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; invoke: DaemonInvokeFn; @@ -92,6 +93,7 @@ async function runLeaseHandler( sessionStore: params.sessionStore, leaseRegistry: params.leaseRegistry, providerRuntimeIds: params.providerRuntimeIds, + providerRuntimeRequiredIds: params.providerRuntimeRequiredIds, leaseLifecycleProvider: params.leaseLifecycleProvider, cloudArtifactProvider: params.cloudArtifactProvider, }), diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index a25f20b9d..111891771 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -65,6 +65,7 @@ export type RequestRouterDeps = { recordingProvider?: RecordingProviderResolver; deviceInventoryProvider?: DeviceInventoryProvider; providerRuntimeIds?: readonly string[]; + providerRuntimeRequiredIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; @@ -90,6 +91,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { recordingProvider, deviceInventoryProvider, providerRuntimeIds, + providerRuntimeRequiredIds, leaseLifecycleProvider, cloudArtifactProvider, providerDeviceRuntimeScope, @@ -216,6 +218,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseRegistry, leaseLifecycleProvider, providerRuntimeIds, + providerRuntimeRequiredIds, cloudArtifactProvider, invoke: handleRequest, invokeReplayAction: allowReplayActions diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 8c103d8d3..0ecea5e71 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -10,8 +10,12 @@ import { composeCloudArtifactProviders, createProviderDeviceRuntimeRequestProviders, } from '../../provider-device-runtime.ts'; -import { createDefaultProviderDeviceRuntimes } from '../../provider-device-runtimes.ts'; +import { + createDefaultProviderDeviceRuntimes, + DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS, +} from '../../provider-device-runtimes.ts'; import { LeaseRegistry } from '../lease-registry.ts'; +import { releaseExpiredProviderLease } from '../lease-lifecycle.ts'; import { createRequestHandler } from '../request-router.ts'; import { teardownSessionResources } from '../session-teardown.ts'; import { closeDaemonServers } from './server-shutdown.ts'; @@ -88,19 +92,24 @@ export async function startDaemonRuntime( cleanupStaleAppLogProcesses(sessionsDir); const sessionStore = new SessionStore(sessionsDir); + const version = readVersion(); + const token = crypto.randomBytes(24).toString('hex'); + const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined; + const daemonCodeSignature = resolveDaemonCodeSignature(); + const providerDeviceRuntimes = await createDefaultProviderDeviceRuntimes(env); + const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders( + providerDeviceRuntimes, + { providerRuntimeRequiredIds: DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS }, + ); const leaseRegistry = new LeaseRegistry({ maxActiveSimulatorLeases: parseIntegerEnv(env.AGENT_DEVICE_MAX_SIMULATOR_LEASES), defaultLeaseTtlMs: parseIntegerEnv(env.AGENT_DEVICE_LEASE_TTL_MS), minLeaseTtlMs: parseIntegerEnv(env.AGENT_DEVICE_LEASE_MIN_TTL_MS), maxLeaseTtlMs: parseIntegerEnv(env.AGENT_DEVICE_LEASE_MAX_TTL_MS), + onLeaseExpired: (lease) => { + void releaseExpiredProviderLease(providerRuntimeProviders.leaseLifecycleProvider, lease); + }, }); - const version = readVersion(); - const token = crypto.randomBytes(24).toString('hex'); - const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined; - const daemonCodeSignature = resolveDaemonCodeSignature(); - const providerDeviceRuntimes = await createDefaultProviderDeviceRuntimes(env); - const providerRuntimeProviders = - createProviderDeviceRuntimeRequestProviders(providerDeviceRuntimes); const cloudArtifactProvider = composeCloudArtifactProviders( providerRuntimeProviders.cloudArtifactProvider, createDefaultCloudArtifactProvider(env), @@ -116,6 +125,7 @@ export async function startDaemonRuntime( cloudArtifactProvider, deviceInventoryProvider: providerRuntimeProviders.deviceInventoryProvider, providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, + providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope, trackDownloadableArtifact, }); diff --git a/src/platforms/apple/core/app-resolution.ts b/src/platforms/apple/core/app-resolution.ts index 0cd6de575..bea7bec4b 100644 --- a/src/platforms/apple/core/app-resolution.ts +++ b/src/platforms/apple/core/app-resolution.ts @@ -39,8 +39,8 @@ export async function resolveIosApp(device: DeviceInfo, app: string): Promise { const infoPlistPath = path.join(appBundlePath, 'Info.plist'); diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 61c1b68f1..84199cff7 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, RunnerContext } from './core/interactor-types.ts'; +import type { Interactor } 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, runnerContext: RunnerContext): Interactor | undefined; + getInteractor(device: DeviceInfo): Interactor | undefined; installApp?( device: DeviceInfo, app: string, @@ -61,6 +61,7 @@ export type ProviderPortReverseOptions = { export type ProviderDeviceRuntimeRequestProviders = { providerRuntimeIds: readonly string[]; + providerRuntimeRequiredIds: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; deviceInventoryProvider?: DeviceInventoryProvider; @@ -84,13 +85,10 @@ async function withProviderDeviceRuntimeScope( return await providerDeviceRuntimeScope.run([...runtimes], task); } -export function getProviderDeviceInteractor( - device: DeviceInfo, - runnerContext: RunnerContext, -): Interactor | undefined { +export function getProviderDeviceInteractor(device: DeviceInfo): Interactor | undefined { for (const runtime of getActiveProviderDeviceRuntimes()) { if (!runtime.ownsDevice(device)) continue; - const interactor = runtime.getInteractor(device, runnerContext); + const interactor = runtime.getInteractor(device); if (interactor) return interactor; } return undefined; @@ -163,9 +161,15 @@ function getActiveProviderDeviceRuntimes(): ProviderDeviceRuntime[] { export function createProviderDeviceRuntimeRequestProviders( runtimes: ProviderDeviceRuntime[], + options: { providerRuntimeRequiredIds?: readonly string[] } = {}, ): ProviderDeviceRuntimeRequestProviders { + const providerRuntimeIds = runtimes.map((runtime) => runtime.provider); return { - providerRuntimeIds: runtimes.map((runtime) => runtime.provider), + providerRuntimeIds, + providerRuntimeRequiredIds: uniqueProviderIds([ + ...providerRuntimeIds, + ...(options.providerRuntimeRequiredIds ?? []), + ]), leaseLifecycleProvider: composeLeaseProvider(runtimes), cloudArtifactProvider: composeCloudArtifactProvider(runtimes), deviceInventoryProvider: composeDeviceInventoryProvider(runtimes), @@ -174,6 +178,10 @@ export function createProviderDeviceRuntimeRequestProviders( }; } +function uniqueProviderIds(providerIds: readonly string[]): string[] { + return [...new Set(providerIds)]; +} + export function composeCloudArtifactProviders( ...providers: Array ): CloudArtifactProvider | undefined { diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index be894760e..9cd06b2b0 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -1,10 +1,17 @@ import { createDefaultCloudWebDriverProviderRuntimes } from './cloud-webdriver/provider-runtimes.ts'; +import { CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS } from './cloud-webdriver/provider-definitions.ts'; import type { DefaultCloudWebDriverProviderRuntimeEnv } from './cloud-webdriver/provider-runtimes.ts'; import type { ProviderDeviceRuntime } from './provider-device-runtime.ts'; +import { LIMRUN_PROVIDER } from './providers/limrun/device.ts'; export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRuntimeEnv & NodeJS.ProcessEnv; +export const DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS = [ + ...CLOUD_WEBDRIVER_PROVIDER_DEFINITIONS.map((definition) => definition.provider), + LIMRUN_PROVIDER, +] as const; + export async function createDefaultProviderDeviceRuntimes( env: DefaultProviderDeviceRuntimeEnv = process.env, ): Promise { diff --git a/src/providers/limrun/android.ts b/src/providers/limrun/android.ts index ed4fa4ff3..82fdb1892 100644 --- a/src/providers/limrun/android.ts +++ b/src/providers/limrun/android.ts @@ -20,7 +20,8 @@ import type { ProviderDeviceInstallResult, ProviderPortReverseOptions, } from '../../provider-device-runtime.ts'; -import { execFailureDetails, runCmd } from '../../utils/exec.ts'; +import { runCmd } from '../../utils/exec.ts'; +import { normalizeOptionalString } from './strings.ts'; type LimrunAdbTunnel = Awaited>; @@ -158,7 +159,7 @@ async function runLimrunAndroidAdb( options?: AndroidAdbExecutorOptions, ): Promise { const { adbArgs, result } = await executeLimrunAndroidAdb(session, args, options); - return requireSuccessfulLimrunAndroidAdb(adbArgs, result, options?.allowFailure); + return await requireSuccessfulLimrunAndroidAdb(adbArgs, result, options?.allowFailure); } async function executeLimrunAndroidAdb( @@ -178,15 +179,15 @@ async function executeLimrunAndroidAdb( return { adbArgs, result }; } -function requireSuccessfulLimrunAndroidAdb( +async function requireSuccessfulLimrunAndroidAdb( adbArgs: string[], result: AndroidAdbExecutorResult, allowFailure: boolean | undefined, -): AndroidAdbExecutorResult { +): Promise { if (result.exitCode !== 0 && allowFailure !== true) { - throw new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { + const { androidAdbResultError } = await import('../../platforms/android/adb-executor.ts'); + throw androidAdbResultError('Limrun Android ADB command failed', result, { command: ['adb', ...adbArgs].join(' '), - ...execFailureDetails(result), }); } return result; @@ -219,11 +220,6 @@ function tcpEndpoint(port: number): AndroidPortReverseEndpoint { 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'; diff --git a/src/providers/limrun/ios.ts b/src/providers/limrun/ios.ts index ca27bd04e..0f47847fe 100644 --- a/src/providers/limrun/ios.ts +++ b/src/providers/limrun/ios.ts @@ -7,6 +7,7 @@ import { type InstanceClient as LimrunIosClient, } from '@limrun/api/ios-client'; import type { DeviceRotation } from '../../contracts/device-rotation.ts'; +import { isDeepLinkTarget } from '../../contracts/open-target.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'; @@ -16,7 +17,9 @@ import type { ProviderDeviceInstallResult, } from '../../provider-device-runtime.ts'; import { execFailureDetails, runCmd } from '../../utils/exec.ts'; +import { sleep } from '../../utils/timeouts.ts'; import { flattenIosTree, toIosSelector, writeBase64File, type IosTreeNode } from './snapshot.ts'; +import { normalizeOptionalString } from './strings.ts'; export type LimrunIosSession = { platform: 'ios'; @@ -66,7 +69,7 @@ export async function installLimrunIosApp( const bundleId = normalizeOptionalString(result.bundleId) ?? options?.appIdentifierHint; return { ...(bundleId ? { bundleId, launchTarget: bundleId } : {}), - appName: inferAppNameFromPath(installablePath), + ...(prepared.appName ? { appName: prepared.appName } : {}), }; } finally { await prepared.cleanup(); @@ -86,21 +89,21 @@ class LimrunIosInteractor implements Interactor { async open(app: string, options?: { url?: string }): Promise { if (options?.url) { - await this.session.client.launchApp(app); + await this.session.client.launchApp(await resolveIosTarget(app)); await this.session.client.openUrl(options.url); return; } - if (looksLikeUrl(app)) { + if (isDeepLinkTarget(app)) { await this.session.client.openUrl(app); return; } - await this.session.client.launchApp(resolveIosTarget(app)); + await this.session.client.launchApp(await resolveIosTarget(app)); } async openDevice(): Promise {} async close(app: string): Promise { - if (app) await this.session.client.terminateApp(resolveIosTarget(app)).catch(() => {}); + if (app) await this.session.client.terminateApp(await resolveIosTarget(app)).catch(() => {}); } async tap(x: number, y: number): Promise { @@ -131,7 +134,7 @@ class LimrunIosInteractor implements Interactor { if (delayMs && delayMs > 0) { for (const char of Array.from(text)) { await this.session.client.typeText(char); - await new Promise((resolve) => setTimeout(resolve, delayMs)); + await sleep(delayMs); } return; } @@ -214,6 +217,7 @@ class LimrunIosInteractor implements Interactor { async function prepareLimrunIosAsset(artifactPath: string): Promise<{ uploadPath: string; assetName: string; + appName?: string; cleanup: () => Promise; }> { const stat = await fs.promises.stat(artifactPath); @@ -221,6 +225,7 @@ async function prepareLimrunIosAsset(artifactPath: string): Promise<{ return { uploadPath: artifactPath, assetName: path.basename(artifactPath), + appName: inferAppNameFromPath(artifactPath), cleanup: async () => {}, }; } @@ -241,19 +246,16 @@ async function prepareLimrunIosAsset(artifactPath: string): Promise<{ return { uploadPath: zipPath, assetName: path.basename(zipPath), + appName: await readIosBundleAppName(artifactPath), 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; +async function resolveIosTarget(app: string): Promise { + const { resolveIosAppAlias } = await import('../../platforms/apple/core/app-resolution.ts'); + return resolveIosAppAlias(app); } function inferAppNameFromPath(appPath: string): string | undefined { @@ -261,8 +263,9 @@ function inferAppNameFromPath(appPath: string): string | undefined { return base || undefined; } -function looksLikeUrl(value: string): boolean { - return /^[a-z][a-z0-9+.-]*:/i.test(value.trim()); +async function readIosBundleAppName(appPath: string): Promise { + const { readIosBundleInfo } = await import('../../platforms/apple/core/install-artifact.ts'); + return (await readIosBundleInfo(appPath)).appName ?? inferAppNameFromPath(appPath); } function unsupported(command: string, message: string): never { diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index c79263cac..cafe1dc1f 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -1,5 +1,5 @@ import Limrun from '@limrun/api'; -import type { Interactor, RunnerContext } from '../../core/interactor-types.ts'; +import type { Interactor } 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'; @@ -97,7 +97,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { return parseLimrunDeviceId(device.id) !== undefined; } - getInteractor(device: DeviceInfo, _runnerContext: RunnerContext): Interactor | undefined { + getInteractor(device: DeviceInfo): Interactor | undefined { const session = this.getSessionForDevice(device); if (!session) return undefined; return session.platform === 'ios' @@ -133,7 +133,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { async configurePortReverse( options: ProviderPortReverseOptions, ): Promise | undefined> { - const session = this.getAndroidPortReverseSession(options.leaseId, true); + const session = this.requireAndroidPortReverseSession(options.leaseId); if (!session) return undefined; await configureLimrunAndroidPortReverse(session, options); return portReverseResult(options); @@ -142,7 +142,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { async removePortReverse( options: ProviderPortReverseOptions, ): Promise | undefined> { - const session = this.getAndroidPortReverseSession(options.leaseId, false); + const session = this.findAndroidPortReverseSession(options.leaseId); if (!session) return undefined; await removeLimrunAndroidPortReverse(session, options); return portReverseResult(options); @@ -257,19 +257,18 @@ export class LimrunRuntime implements ProviderDeviceRuntime { return session?.platform === parsed.platform ? session : undefined; } - private getAndroidPortReverseSession( - leaseId: string, - throwOnIos: boolean, - ): LimrunAndroidSession | undefined { + private requireAndroidPortReverseSession(leaseId: string): 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; + throw unsupported( + 'port reverse', + 'Direct Limrun iOS sessions cannot reach local host ports; use a bridge public URL.', + ); + } + + private findAndroidPortReverseSession(leaseId: string): LimrunAndroidSession | undefined { + const session = this.sessions.get(leaseId); + return session?.platform === 'android' ? session : undefined; } } diff --git a/src/providers/limrun/snapshot.ts b/src/providers/limrun/snapshot.ts index 765cbe55c..fe65740f1 100644 --- a/src/providers/limrun/snapshot.ts +++ b/src/providers/limrun/snapshot.ts @@ -104,6 +104,8 @@ function readIosNodeChildren(node: IosTreeNode): IosTreeNode[] { export function toIosSelector(selector: LimrunSelector) { if (selector.key === 'id') return { accessibilityId: selector.value }; if (selector.key === 'value') return { value: selector.value }; + // The Limrun iOS tree exposes visible text through AXLabel, so both + // agent-device label and text selectors target the provider's label field. return { label: selector.value }; } diff --git a/src/providers/limrun/strings.ts b/src/providers/limrun/strings.ts new file mode 100644 index 000000000..8b6689d72 --- /dev/null +++ b/src/providers/limrun/strings.ts @@ -0,0 +1,4 @@ +export function normalizeOptionalString(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} diff --git a/src/utils/__tests__/interactors.test.ts b/src/utils/__tests__/interactors.test.ts index bc4cb32b5..370257bd8 100644 --- a/src/utils/__tests__/interactors.test.ts +++ b/src/utils/__tests__/interactors.test.ts @@ -69,22 +69,17 @@ test('provider device interactor receives runner context from core resolution', 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; - }, + getInteractor: () => 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 () => { diff --git a/test/integration/provider-scenarios/provider-device-runtime.test.ts b/test/integration/provider-scenarios/provider-device-runtime.test.ts index 47cdea681..a238eec85 100644 --- a/test/integration/provider-scenarios/provider-device-runtime.test.ts +++ b/test/integration/provider-scenarios/provider-device-runtime.test.ts @@ -59,6 +59,7 @@ test('Provider-backed scenario composes lease, inventory, dispatch, and port rev test('provider lease allocation fails when the daemon lacks the requested runtime', async () => { const daemon = await createProviderScenarioHarness({ providerRuntimeIds: [FAKE_PROVIDER], + providerRuntimeRequiredIds: ['limrun'], deviceInventoryProvider: async () => null, }); try { @@ -91,6 +92,31 @@ test('provider lease allocation fails when the daemon lacks the requested runtim } }); +test('proxy lease allocation remains daemon-local when direct runtimes are configured', async () => { + const daemon = await createProviderScenarioHarness({ + providerRuntimeIds: [FAKE_PROVIDER], + providerRuntimeRequiredIds: ['limrun'], + deviceInventoryProvider: async () => null, + }); + try { + const response = await daemon.callCommand( + 'lease_allocate', + [], + { tenant: 'team-a', runId: 'run-a', leaseProvider: 'proxy' }, + { + meta: { + tenantId: 'team-a', + runId: 'run-a', + leaseProvider: 'proxy', + }, + }, + ); + assert.equal(assertRpcOk<{ lease: DeviceLease }>(response).lease.leaseProvider, 'proxy'); + } finally { + await daemon.close(); + } +}); + async function runFakeProviderScenario( daemon: Awaited>, runtime: FakeProviderDeviceRuntime, From 21ad1cb5d69c087c3dd3a5428222ec509afcf667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 15:29:23 +0200 Subject: [PATCH 16/23] test: stabilize alert deadline smoke assertion --- .../RunnerTests+CommandExecution.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 1aa3da2c7..a67b5a600 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -599,12 +599,12 @@ extension RunnerTests { var error: Error? var probeDeadline: Date? var observedDeadline: Date? + var commandStartedAt: Date? } let box = ResultBox() let releaseResolution = DispatchSemaphore(value: 0) let resolutionExited = expectation(description: "bounded alert resolution exited") let commandFinished = expectation(description: "alert command respected its deadline") - let startedAt = Date() let command = try runnerCommandFixture( #"{"command":"alert","commandId":"alert-deadline","appBundleId":"com.apple.springboard","action":"get","timeoutMs":500}"# ) @@ -629,6 +629,7 @@ extension RunnerTests { } DispatchQueue(label: "agent-device.runner.tests.alert-deadline").async { + box.commandStartedAt = Date() do { _ = try self.executeDispatched(command: command) } catch { @@ -643,10 +644,12 @@ extension RunnerTests { XCTAssertEqual(error?.code, RunnerErrorCode.mainThreadExecutionTimedOut) XCTAssertNotNil(box.probeDeadline) XCTAssertNotNil(box.observedDeadline) - if let probeDeadline = box.probeDeadline, let observedDeadline = box.observedDeadline { + if let probeDeadline = box.probeDeadline, + let observedDeadline = box.observedDeadline, + let commandStartedAt = box.commandStartedAt + { XCTAssertEqual(probeDeadline.timeIntervalSince(observedDeadline), 0, accuracy: 0.01) - XCTAssertGreaterThan(observedDeadline.timeIntervalSince(startedAt), 0) - XCTAssertLessThan(observedDeadline.timeIntervalSince(startedAt), 0.75) + XCTAssertEqual(observedDeadline.timeIntervalSince(commandStartedAt), 0.5, accuracy: 0.05) } releaseResolution.signal() From d9a08833d18982fb88f2ee2bf52daa01d7fe5240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 16:11:40 +0200 Subject: [PATCH 17/23] fix: recover expired provider leases --- .../__tests__/provider-lease-expiry.test.ts | 42 +++++++++++ .../__tests__/session-device-utils.test.ts | 21 ++++++ .../__tests__/session-open-target.test.ts | 30 +++++++- src/daemon/handlers/session-device-utils.ts | 6 +- src/daemon/handlers/session-open-target.ts | 6 +- src/daemon/handlers/session-open.ts | 6 +- src/daemon/lease-lifecycle.ts | 6 +- src/daemon/provider-lease-expiry.ts | 71 +++++++++++++++++++ src/daemon/server/daemon-runtime.ts | 8 ++- .../provider-device-runtime.test.ts | 67 +++++++++++++++-- 10 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 src/daemon/__tests__/provider-lease-expiry.test.ts create mode 100644 src/daemon/provider-lease-expiry.ts diff --git a/src/daemon/__tests__/provider-lease-expiry.test.ts b/src/daemon/__tests__/provider-lease-expiry.test.ts new file mode 100644 index 000000000..6f4939ca0 --- /dev/null +++ b/src/daemon/__tests__/provider-lease-expiry.test.ts @@ -0,0 +1,42 @@ +import { expect, test, vi } from 'vitest'; +import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; + +test('retries an expired provider lease release after a transient failure', async () => { + vi.useFakeTimers(); + let now = 1_000; + const leaseRegistry = new LeaseRegistry({ + defaultLeaseTtlMs: 10, + minLeaseTtlMs: 1, + now: () => now, + }); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'limrun', + }); + const release = vi + .fn<() => Promise>>() + .mockRejectedValueOnce(new Error('temporary provider outage')) + .mockResolvedValueOnce({ limrunInstanceId: 'instance-1' }); + const releaser = createExpiredProviderLeaseReleaser({ + leaseLifecycleProvider: { release }, + retryDelayMs: 10, + }); + + try { + now = 1_011; + const expiredLease = leaseRegistry.consumeExpiredLease(lease.leaseId); + expect(expiredLease).toEqual(lease); + await releaser.release(expiredLease!); + expect(release).toHaveBeenCalledTimes(1); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(10); + expect(release).toHaveBeenCalledTimes(2); + expect(release).toHaveBeenLastCalledWith(lease); + } finally { + releaser.shutdown(); + vi.useRealTimers(); + } +}); diff --git a/src/daemon/handlers/__tests__/session-device-utils.test.ts b/src/daemon/handlers/__tests__/session-device-utils.test.ts index d439bc739..d202f6490 100644 --- a/src/daemon/handlers/__tests__/session-device-utils.test.ts +++ b/src/daemon/handlers/__tests__/session-device-utils.test.ts @@ -7,6 +7,7 @@ import { } from '../session-device-utils.ts'; import { getRunnerSessionSnapshot } from '../../../platforms/apple/core/runner/runner-client.ts'; import { resolveTargetDevice } from '../../../core/dispatch.ts'; +import { isActiveProviderDevice } from '../../../provider-device-runtime.ts'; vi.mock('../../../platforms/apple/core/runner/runner-client.ts', () => ({ getRunnerSessionSnapshot: vi.fn(() => null), @@ -14,14 +15,20 @@ vi.mock('../../../platforms/apple/core/runner/runner-client.ts', () => ({ vi.mock('../../../core/dispatch.ts', () => ({ resolveTargetDevice: vi.fn(), })); +vi.mock('../../../provider-device-runtime.ts', () => ({ + isActiveProviderDevice: vi.fn(() => false), +})); const mockGetRunnerSessionSnapshot = vi.mocked(getRunnerSessionSnapshot); const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); +const mockIsActiveProviderDevice = vi.mocked(isActiveProviderDevice); beforeEach(() => { mockGetRunnerSessionSnapshot.mockReset(); mockGetRunnerSessionSnapshot.mockReturnValue(null); mockResolveTargetDevice.mockReset(); + mockIsActiveProviderDevice.mockReset(); + mockIsActiveProviderDevice.mockReturnValue(false); }); const iosSimulatorSession: SessionState = { @@ -55,6 +62,20 @@ test('refreshSessionDeviceIfNeeded keeps iOS simulator session device on non-mac expect(device).toBe(iosSimulatorSession.device); }); +test('refreshSessionDeviceIfNeeded keeps provider-owned iOS simulators out of local refresh', async () => { + mockIsActiveProviderDevice.mockReturnValue(true); + + const device = await withMockedPlatform('darwin', async () => + refreshSessionDeviceIfNeeded({ + ...iosSimulatorSession.device, + id: 'limrun:ios:lease-1', + }), + ); + + expect(device.id).toBe('limrun:ios:lease-1'); + expect(mockResolveTargetDevice).not.toHaveBeenCalled(); +}); + test('refreshSessionDeviceIfNeeded skips re-resolve while the iOS runner session is alive', async () => { mockGetRunnerSessionSnapshot.mockReturnValue({ sessionId: 'sim-1:1234:1', alive: true }); diff --git a/src/daemon/handlers/__tests__/session-open-target.test.ts b/src/daemon/handlers/__tests__/session-open-target.test.ts index f747037b7..9b2137d8d 100644 --- a/src/daemon/handlers/__tests__/session-open-target.test.ts +++ b/src/daemon/handlers/__tests__/session-open-target.test.ts @@ -1,13 +1,21 @@ import { beforeEach, expect, test, vi } from 'vitest'; import type { DeviceInfo } from '../../../kernel/device.ts'; +import { isActiveProviderDevice } from '../../../provider-device-runtime.ts'; +import { IOS_SIMULATOR } from '../../../__tests__/test-utils/device-fixtures.ts'; +import { getAndroidAppState } from '../../../platforms/android/app-lifecycle.ts'; +import { + inferAndroidPackageAfterOpen, + resolveSessionAppBundleIdForTarget, +} from '../session-open-target.ts'; +vi.mock('../../../provider-device-runtime.ts', () => ({ + isActiveProviderDevice: vi.fn(() => false), +})); vi.mock('../../../platforms/android/app-lifecycle.ts', () => ({ getAndroidAppState: vi.fn(), })); -import { getAndroidAppState } from '../../../platforms/android/app-lifecycle.ts'; -import { inferAndroidPackageAfterOpen } from '../session-open-target.ts'; - +const mockIsActiveProviderDevice = vi.mocked(isActiveProviderDevice); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const androidDevice: DeviceInfo = { platform: 'android', @@ -19,6 +27,7 @@ const androidDevice: DeviceInfo = { beforeEach(() => { vi.clearAllMocks(); + mockIsActiveProviderDevice.mockReturnValue(false); }); test('inferAndroidPackageAfterOpen reads foreground package for Android URL opens', async () => { @@ -31,3 +40,18 @@ test('inferAndroidPackageAfterOpen reads foreground package for Android URL open inferAndroidPackageAfterOpen(androidDevice, 'exp://127.0.0.1:8082', undefined), ).resolves.toBe('host.exp.exponent'); }); + +test('provider iOS keeps the known bundle id without local app resolution', async () => { + mockIsActiveProviderDevice.mockReturnValue(true); + const resolveAndroidPackageForOpen = vi.fn(async () => 'com.example.android'); + + const bundleId = await resolveSessionAppBundleIdForTarget( + IOS_SIMULATOR, + 'com.example.demo', + 'com.example.installed', + resolveAndroidPackageForOpen, + ); + + expect(bundleId).toBe('com.example.installed'); + expect(resolveAndroidPackageForOpen).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/handlers/session-device-utils.ts b/src/daemon/handlers/session-device-utils.ts index e87fb39d9..60e061c3b 100644 --- a/src/daemon/handlers/session-device-utils.ts +++ b/src/daemon/handlers/session-device-utils.ts @@ -1,5 +1,6 @@ import { isIosFamily, type DeviceInfo } from '../../kernel/device.ts'; import { AppError } from '../../kernel/errors.ts'; +import { isActiveProviderDevice } from '../../provider-device-runtime.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { getRunnerSessionSnapshot } from '../../platforms/apple/core/runner/runner-client.ts'; import { resolveTargetDevice } from '../../core/dispatch.ts'; @@ -34,7 +35,7 @@ export function hasExplicitSessionFlag(flags: DaemonRequest['flags'] | undefined } export async function settleIosSimulator(device: DeviceInfo, delayMs: number): Promise { - if (!isIosSimulator(device) || delayMs <= 0) return; + if (isActiveProviderDevice(device) || !isIosSimulator(device) || delayMs <= 0) return; await new Promise((resolve) => setTimeout(resolve, delayMs)); } @@ -58,6 +59,9 @@ export async function resolveCommandDevice(params: { } export async function refreshSessionDeviceIfNeeded(device: DeviceInfo): Promise { + if (isActiveProviderDevice(device)) { + return device; + } if (!isIosFamily(device) || device.kind !== 'simulator') { return device; } diff --git a/src/daemon/handlers/session-open-target.ts b/src/daemon/handlers/session-open-target.ts index 0cb74f606..59b19eae9 100644 --- a/src/daemon/handlers/session-open-target.ts +++ b/src/daemon/handlers/session-open-target.ts @@ -3,7 +3,8 @@ import { isWebUrl, resolveIosDeviceDeepLinkBundleId, } from '../../contracts/open-target.ts'; -import { isMacOs, isApplePlatform, type DeviceInfo } from '../../kernel/device.ts'; +import { isIosFamily, isMacOs, isApplePlatform, type DeviceInfo } from '../../kernel/device.ts'; +import { isActiveProviderDevice } from '../../provider-device-runtime.ts'; async function resolveIosBundleIdForOpen( device: DeviceInfo, @@ -100,6 +101,9 @@ export async function resolveSessionAppBundleIdForTarget( openTarget: string | undefined, ) => Promise, ): Promise { + if (isIosFamily(device) && isActiveProviderDevice(device)) { + return currentAppBundleId; + } return ( (await resolveIosBundleIdForOpen(device, openTarget, currentAppBundleId)) ?? (await resolveAndroidPackageForOpenFn(device, openTarget)) ?? diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 7ec1cd5d8..285eabd78 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -362,7 +362,11 @@ async function completeOpenCommand(params: { } else if (runnerPrewarm && !runnerPrewarmAwaited) { timing.runnerPrewarmWaited = false; } - if (isIosSimulator(device) && (shouldRelaunch || runnerTargetPredatesOpen)) { + if ( + !isActiveProviderDevice(device) && + isIosSimulator(device) && + (shouldRelaunch || runnerTargetPredatesOpen) + ) { await notifyIosRunnerAppRelaunched(device, runnerPrewarmOptions); } sessionAppBundleId = await inferAndroidPackageAfterOpen(device, openTarget, sessionAppBundleId); diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index a7aaedb94..c22140a4b 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -15,8 +15,8 @@ export type SessionTeardown = (session: SessionState, sessionName: string) => Pr export async function releaseExpiredProviderLease( leaseLifecycleProvider: LeaseLifecycleProvider | undefined, lease: DeviceLease, -): Promise { - if (!lease.leaseProvider || !leaseLifecycleProvider?.release) return; +): Promise { + if (!lease.leaseProvider || !leaseLifecycleProvider?.release) return true; try { const provider = await leaseLifecycleProvider.release(lease); @@ -29,6 +29,7 @@ export async function releaseExpiredProviderLease( ...(provider ? { providerData: provider } : {}), }, }); + return true; } catch (error) { emitDiagnostic({ level: 'error', @@ -39,6 +40,7 @@ export async function releaseExpiredProviderLease( error: error instanceof Error ? error.message : String(error), }, }); + return false; } } diff --git a/src/daemon/provider-lease-expiry.ts b/src/daemon/provider-lease-expiry.ts new file mode 100644 index 000000000..260fb8950 --- /dev/null +++ b/src/daemon/provider-lease-expiry.ts @@ -0,0 +1,71 @@ +import type { LeaseLifecycleProvider } from './handlers/lease.ts'; +import { releaseExpiredProviderLease } from './lease-lifecycle.ts'; +import type { DeviceLease } from './lease-registry.ts'; +import { emitDiagnostic } from '../utils/diagnostics.ts'; + +const DEFAULT_RETRY_DELAY_MS = 1_000; + +type Timer = ReturnType; + +export type ExpiredProviderLeaseReleaser = { + release: (lease: DeviceLease) => Promise; + retryPending: () => Promise; + shutdown: () => void; +}; + +export function createExpiredProviderLeaseReleaser(options: { + leaseLifecycleProvider?: LeaseLifecycleProvider; + retryDelayMs?: number; +}): ExpiredProviderLeaseReleaser { + const pendingLeases = new Map(); + const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + let retryTimer: Timer | undefined; + let retrying = false; + + const scheduleRetry = (): void => { + if (retryTimer || pendingLeases.size === 0) return; + retryTimer = setTimeout(() => { + retryTimer = undefined; + void retryPending(); + }, retryDelayMs); + retryTimer.unref?.(); + emitDiagnostic({ + level: 'warn', + phase: 'provider_lease_expiry_retry_scheduled', + data: { pendingLeaseCount: pendingLeases.size, retryDelayMs }, + }); + }; + + const retryPending = async (): Promise => { + if (retrying) return; + retrying = true; + try { + for (const lease of pendingLeases.values()) { + if (await releaseExpiredProviderLease(options.leaseLifecycleProvider, lease)) { + pendingLeases.delete(lease.leaseId); + } + } + } finally { + retrying = false; + if (pendingLeases.size === 0 && retryTimer) { + clearTimeout(retryTimer); + retryTimer = undefined; + } else { + scheduleRetry(); + } + } + }; + + return { + release: async (lease) => { + if (!lease.leaseProvider || !options.leaseLifecycleProvider?.release) return; + pendingLeases.set(lease.leaseId, lease); + await retryPending(); + }, + retryPending, + shutdown: () => { + if (retryTimer) clearTimeout(retryTimer); + retryTimer = undefined; + }, + }; +} diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 0ecea5e71..7554d2331 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -15,7 +15,7 @@ import { DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS, } from '../../provider-device-runtimes.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { releaseExpiredProviderLease } from '../lease-lifecycle.ts'; +import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; import { createRequestHandler } from '../request-router.ts'; import { teardownSessionResources } from '../session-teardown.ts'; import { closeDaemonServers } from './server-shutdown.ts'; @@ -101,13 +101,16 @@ export async function startDaemonRuntime( providerDeviceRuntimes, { providerRuntimeRequiredIds: DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS }, ); + const expiredProviderLeaseReleaser = createExpiredProviderLeaseReleaser({ + leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, + }); const leaseRegistry = new LeaseRegistry({ maxActiveSimulatorLeases: parseIntegerEnv(env.AGENT_DEVICE_MAX_SIMULATOR_LEASES), defaultLeaseTtlMs: parseIntegerEnv(env.AGENT_DEVICE_LEASE_TTL_MS), minLeaseTtlMs: parseIntegerEnv(env.AGENT_DEVICE_LEASE_MIN_TTL_MS), maxLeaseTtlMs: parseIntegerEnv(env.AGENT_DEVICE_LEASE_MAX_TTL_MS), onLeaseExpired: (lease) => { - void releaseExpiredProviderLease(providerRuntimeProviders.leaseLifecycleProvider, lease); + void expiredProviderLeaseReleaser.release(lease); }, }); const cloudArtifactProvider = composeCloudArtifactProviders( @@ -299,6 +302,7 @@ export async function startDaemonRuntime( idleReap.cancel(); if (shuttingDown) return; shuttingDown = true; + expiredProviderLeaseReleaser.shutdown(); if (shutdownOptions.cause) { await emitFatalDiagnostic(shutdownOptions.cause); } diff --git a/test/integration/provider-scenarios/provider-device-runtime.test.ts b/test/integration/provider-scenarios/provider-device-runtime.test.ts index a238eec85..6f58a9498 100644 --- a/test/integration/provider-scenarios/provider-device-runtime.test.ts +++ b/test/integration/provider-scenarios/provider-device-runtime.test.ts @@ -56,6 +56,32 @@ test('Provider-backed scenario composes lease, inventory, dispatch, and port rev }); }, 15_000); +test('provider-owned iOS simulators relaunch without local CoreSimulator refresh', async () => { + await withProviderScenarioResource( + async () => await createFakeProviderWorld('ios'), + async ({ daemon, runtime }) => { + const lease = await allocateIosFakeProviderLease(daemon); + const flags = iosLeaseFlags(lease.leaseId); + const options = { meta: iosLeaseMeta(lease.leaseId) }; + + assertRpcOk(await daemon.callCommand('open', ['com.example.demo'], flags, options)); + assertRpcOk( + await daemon.callCommand( + 'open', + ['com.example.demo'], + { ...flags, relaunch: true }, + options, + ), + ); + + assert.deepEqual( + runtime.calls.filter((call) => call.type === 'open').map((call) => call.deviceId), + [runtime.deviceIdForLease(lease.leaseId), runtime.deviceIdForLease(lease.leaseId)], + ); + }, + ); +}); + test('provider lease allocation fails when the daemon lacks the requested runtime', async () => { const daemon = await createProviderScenarioHarness({ providerRuntimeIds: [FAKE_PROVIDER], @@ -142,6 +168,15 @@ async function allocateFakeProviderLease( return assertRpcOk<{ lease: DeviceLease }>(allocate).lease; } +async function allocateIosFakeProviderLease( + daemon: Awaited>, +): Promise { + const allocate = await daemon.callCommand('lease_allocate', [], iosLeaseFlags(), { + meta: iosLeaseMeta(), + }); + return assertRpcOk<{ lease: DeviceLease }>(allocate).lease; +} + function providerScenarioSteps( appPath: string, lease: DeviceLease, @@ -263,8 +298,8 @@ function assertFakeProviderCallOrder(calls: FakeProviderCall[]): void { ); } -async function createFakeProviderWorld() { - const runtime = new FakeProviderDeviceRuntime(); +async function createFakeProviderWorld(platform: 'android' | 'ios' = 'android') { + const runtime = new FakeProviderDeviceRuntime(platform); const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders([runtime]); const daemon = await createProviderScenarioHarness({ ...providerRuntimeProviders, @@ -284,6 +319,11 @@ class FakeProviderDeviceRuntime implements ProviderDeviceRuntime { readonly provider = FAKE_PROVIDER; readonly calls: FakeProviderCall[] = []; private readonly sessionsByLeaseId = new Map(); + private readonly platform: 'android' | 'ios'; + + constructor(platform: 'android' | 'ios' = 'android') { + this.platform = platform; + } readonly leaseLifecycle: LeaseLifecycleProvider = { allocate: async (lease) => { @@ -335,7 +375,7 @@ class FakeProviderDeviceRuntime implements ProviderDeviceRuntime { }; ownsDevice(device: DeviceInfo): boolean { - return device.id.startsWith('fake-provider:android:'); + return device.id.startsWith(`fake-provider:${this.platform}:`); } getInteractor(device: DeviceInfo): Interactor | undefined { @@ -378,10 +418,21 @@ class FakeProviderDeviceRuntime implements ProviderDeviceRuntime { } deviceIdForLease(leaseId: string): string { - return `fake-provider:android:${leaseId}`; + return `fake-provider:${this.platform}:${leaseId}`; } private createDevice(lease: DeviceLease): DeviceInfo { + if (this.platform === 'ios') { + return { + platform: 'apple', + appleOs: 'ios', + id: this.deviceIdForLease(lease.leaseId), + name: 'Fake Provider iOS Simulator', + kind: 'simulator', + target: 'mobile', + booted: true, + }; + } return { platform: 'android', id: this.deviceIdForLease(lease.leaseId), @@ -464,6 +515,14 @@ function leaseMeta(leaseId?: string): DaemonRequest['meta'] { }; } +function iosLeaseFlags(leaseId?: string): DaemonRequest['flags'] { + return { ...leaseFlags(leaseId), platform: 'ios' }; +} + +function iosLeaseMeta(leaseId?: string): DaemonRequest['meta'] { + return { ...leaseMeta(leaseId), leaseBackend: 'ios-instance', deviceKey: 'ios-a' }; +} + function throwUnexpectedProviderInteraction(method: string): never { throw new Error(`Unexpected fake provider interactor call: ${method}`); } From 1484a3e14faecc920ababf100433d6a1ef51f61f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 16:16:51 +0200 Subject: [PATCH 18/23] fix: limit Limrun to remote simulators --- src/__tests__/cloud-connect-profile.test.ts | 70 +++++++++++++++++++ src/cli/connection/limrun-profile.ts | 45 ++++++++++-- src/cli/parser/cli-help.ts | 2 + .../provider-ios-selector-runtime.test.ts | 8 ++- website/docs/docs/device-clouds.md | 2 + 5 files changed, 118 insertions(+), 9 deletions(-) diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 3e347fb68..2a7c236d9 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -187,6 +187,76 @@ test('connect limrun requires LIMRUN_API_KEY', async () => { } }); +test('connect limrun accepts only remote mobile simulator and emulator sessions', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-limrun-scope-')); + const stateDir = path.join(tempRoot, '.state'); + vi.stubEnv('LIMRUN_API_KEY', 'lim_test_key'); + + try { + await assert.rejects( + connectCommand({ + positionals: ['limrun'], + flags: { + json: true, + help: false, + version: false, + stateDir, + platform: 'ios', + udid: '00008110-001234567890801E', + }, + client: {} as AgentDeviceClient, + }), + /does not accept --udid/, + ); + await assert.rejects( + connectCommand({ + positionals: ['limrun'], + flags: { + json: true, + help: false, + version: false, + stateDir, + platform: 'macos', + }, + client: {} as AgentDeviceClient, + }), + /requires --platform ios or android/, + ); + await assert.rejects( + connectCommand({ + positionals: ['limrun'], + flags: { + json: true, + help: false, + version: false, + stateDir, + platform: 'ios', + target: 'desktop', + }, + client: {} as AgentDeviceClient, + }), + /supports only mobile simulators and emulators/, + ); + await assert.rejects( + connectCommand({ + positionals: ['limrun'], + flags: { + json: true, + help: false, + version: false, + stateDir, + platform: 'ios', + leaseBackend: 'android-instance', + }, + client: {} as AgentDeviceClient, + }), + /requires --lease-backend ios-instance/, + ); + } 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'); diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index abf058c4c..277809154 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -8,6 +8,13 @@ import { persistAndResolveGeneratedProfile } from './generated-config.ts'; import { resolveRequestedLeaseBackend } from '../commands/connection-runtime.ts'; const DEFAULT_LIMRUN_TENANT = 'limrun'; +const LIMRUN_LOCAL_DEVICE_SELECTORS = [ + ['--device', (flags: CliFlags) => flags.device], + ['--udid', (flags: CliFlags) => flags.udid], + ['--serial', (flags: CliFlags) => flags.serial], + ['--ios-simulator-device-set', (flags: CliFlags) => flags.iosSimulatorDeviceSet], + ['--android-device-allowlist', (flags: CliFlags) => flags.androidDeviceAllowlist], +] as const; export function resolveLimrunConnectProfile(options: { flags: CliFlags; @@ -36,6 +43,7 @@ export function resolveLimrunConnectProfile(options: { function buildLimrunRemoteProfile(options: { flags: CliFlags }): RemoteConfigProfile { const flags = options.flags; + const leaseBackend = validateLimrunConnectFlags(flags); const daemonPaths = resolveDaemonPaths(flags.stateDir); return { stateDir: daemonPaths.baseDir, @@ -43,16 +51,41 @@ function buildLimrunRemoteProfile(options: { flags: CliFlags }): RemoteConfigPro tenant: flags.tenant ?? DEFAULT_LIMRUN_TENANT, runId: flags.runId ?? `cli-${Date.now().toString(36)}`, sessionIsolation: 'tenant', - leaseBackend: resolveRequestedLeaseBackend(flags), + leaseBackend, 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), }; } + +function validateLimrunConnectFlags(flags: CliFlags): 'android-instance' | 'ios-instance' { + if (flags.platform !== 'android' && flags.platform !== 'ios') { + throw new AppError('INVALID_ARGS', 'connect limrun requires --platform ios or android.'); + } + if (flags.target !== undefined && flags.target !== 'mobile') { + throw new AppError( + 'INVALID_ARGS', + 'connect limrun supports only mobile simulators and emulators.', + ); + } + const selector = LIMRUN_LOCAL_DEVICE_SELECTORS.find( + ([, value]) => value(flags) !== undefined, + )?.[0]; + if (selector) { + throw new AppError( + 'INVALID_ARGS', + `connect limrun does not accept ${selector}; Limrun selects a remote simulator or emulator.`, + ); + } + const leaseBackend = resolveRequestedLeaseBackend(flags); + const expectedLeaseBackend = flags.platform === 'ios' ? 'ios-instance' : 'android-instance'; + if (leaseBackend !== expectedLeaseBackend) { + throw new AppError( + 'INVALID_ARGS', + `connect limrun --platform ${flags.platform} requires --lease-backend ${expectedLeaseBackend}.`, + ); + } + return expectedLeaseBackend; +} diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 90cd6f35d..34db016d2 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -849,6 +849,8 @@ AWS Device Farm hosted-device flow: Limrun direct-device flow: LIMRUN_API_KEY=... agent-device connect limrun --platform android + + Limrun creates remote iOS simulators and Android emulators only. Do not pass local device selectors such as --udid, --serial, or --device. agent-device open com.example.app agent-device snapshot -i agent-device close diff --git a/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts b/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts index b8085ac06..35640a33b 100644 --- a/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts +++ b/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts @@ -16,9 +16,11 @@ import { createProviderScenarioHarness, withProviderScenarioResource } from './h const PROVIDER = 'fake-ios-provider'; const DEVICE: DeviceInfo = { platform: 'apple', - id: 'fake-ios-provider:device-1', - name: 'Fake Provider iPhone', - kind: 'device', + appleOs: 'ios', + id: 'fake-ios-provider:simulator-1', + name: 'Fake Provider iOS Simulator', + kind: 'simulator', + target: 'mobile', booted: true, }; diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index b06c1c5f2..6d3115a00 100644 --- a/website/docs/docs/device-clouds.md +++ b/website/docs/docs/device-clouds.md @@ -63,6 +63,8 @@ Choose the platform to create a matching instance: agent-device connect limrun --platform android ``` +Limrun creates remote iOS simulators and Android emulators only. It does not use local or physical-device selectors such as `--udid`, `--serial`, or `--device`. + Full Android flow: ```bash From fe63b8ccb4c217615ea5e662457089df574d1eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 16:29:44 +0200 Subject: [PATCH 19/23] fix: make Limrun provider cleanup durable --- src/__tests__/limrun-runtime.test.ts | 42 ++++++ .../__tests__/provider-lease-expiry.test.ts | 46 +++++++ src/daemon/handlers/session-open.ts | 8 +- src/daemon/provider-lease-expiry.ts | 128 +++++++++++++++++- src/daemon/server/daemon-runtime.ts | 4 + src/providers/limrun/runtime.ts | 32 ++++- .../provider-device-runtime.test.ts | 14 +- 7 files changed, 261 insertions(+), 13 deletions(-) diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 58a8c1824..f39e34be8 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -8,6 +8,10 @@ import type { SimulatorLease } from '../daemon/lease-registry.ts'; import type { DeviceInfo } from '../kernel/device.ts'; import { runCmd } from '../utils/exec.ts'; +type LimrunInstancePage = { + getPaginatedItems: () => Array<{ metadata: { id: string } }>; +}; + const limrunMockState = vi.hoisted(() => { const androidTunnelClose = vi.fn(); return { @@ -35,6 +39,9 @@ const limrunMockState = vi.hoisted(() => { metadata: { id: 'ios-instance-1' }, status: { token: 'instance-token', apiUrl: 'https://ios.example' }, })), + iosList: vi.fn<() => Promise>(async () => ({ + getPaginatedItems: () => [], + })), iosDelete: vi.fn(async () => undefined), androidCreate: vi.fn(async () => ({ metadata: { id: 'android-instance-1' }, @@ -44,6 +51,9 @@ const limrunMockState = vi.hoisted(() => { adbWebSocketUrl: 'wss://adb.example', }, })), + androidList: vi.fn<() => Promise>(async () => ({ + getPaginatedItems: () => [], + })), androidDelete: vi.fn(async () => undefined), }; }); @@ -52,11 +62,13 @@ vi.mock('@limrun/api', () => ({ default: class MockLimrun { readonly iosInstances = { create: limrunMockState.iosCreate, + list: limrunMockState.iosList, delete: limrunMockState.iosDelete, }; readonly androidInstances = { create: limrunMockState.androidCreate, + list: limrunMockState.androidList, delete: limrunMockState.androidDelete, }; @@ -165,6 +177,36 @@ test('Limrun iOS uses shared deep-link classification', async () => { } }); +test('Limrun reclaims a labeled iOS instance without an in-memory session', async () => { + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + const lease: SimulatorLease = { + leaseId: 'lease-recovered-ios', + tenantId: 'team-a', + runId: 'run-a', + backend: 'ios-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + limrunMockState.iosList.mockResolvedValueOnce({ + getPaginatedItems: () => [{ metadata: { id: 'ios-instance-recovered' } }], + }); + + try { + const releaseLease = runtime.leaseLifecycle.release; + if (!releaseLease) throw new Error('Limrun runtime must provide lease release'); + await releaseLease(lease); + + assert.deepEqual(limrunMockState.iosList.mock.calls, [ + [{ labelSelector: 'provider=limrun,leaseId=lease-recovered-ios' }], + ]); + assert.deepEqual(limrunMockState.iosDelete.mock.calls, [['ios-instance-recovered']]); + } 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', diff --git a/src/daemon/__tests__/provider-lease-expiry.test.ts b/src/daemon/__tests__/provider-lease-expiry.test.ts index 6f4939ca0..420ca1eaa 100644 --- a/src/daemon/__tests__/provider-lease-expiry.test.ts +++ b/src/daemon/__tests__/provider-lease-expiry.test.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { expect, test, vi } from 'vitest'; import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; import { LeaseRegistry } from '../lease-registry.ts'; @@ -40,3 +43,46 @@ test('retries an expired provider lease release after a transient failure', asyn vi.useRealTimers(); } }); + +test('retries a persisted expired provider lease after daemon restart', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-expired-provider-lease-')); + const lease = new LeaseRegistry().allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'limrun', + }); + const release = vi + .fn<() => Promise>>() + .mockRejectedValueOnce(new Error('temporary provider outage')) + .mockResolvedValueOnce({ limrunInstanceId: 'instance-1' }); + const options = { + leaseLifecycleProvider: { release }, + stateDir, + providerRuntimeIds: ['limrun'], + providerRuntimeRequiredIds: ['limrun'], + }; + const firstDaemon = createExpiredProviderLeaseReleaser(options); + + try { + await firstDaemon.release(lease); + expect(release).toHaveBeenCalledTimes(1); + firstDaemon.shutdown(); + + const daemonWithoutLimrun = createExpiredProviderLeaseReleaser({ + ...options, + providerRuntimeIds: [], + }); + await daemonWithoutLimrun.retryPending(); + expect(release).toHaveBeenCalledTimes(1); + daemonWithoutLimrun.shutdown(); + + const restartedDaemon = createExpiredProviderLeaseReleaser(options); + await restartedDaemon.retryPending(); + expect(release).toHaveBeenCalledTimes(2); + expect(release).toHaveBeenLastCalledWith(lease); + restartedDaemon.shutdown(); + } finally { + firstDaemon.shutdown(); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 285eabd78..1e8f9fecb 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -260,9 +260,10 @@ async function completeOpenCommand(params: { schedulePrewarm(); } - // iOS simulators relaunch with one `simctl launch --terminate-running-process` - // instead of terminate + settle + launch (~1s per relaunch). Runtime hints - // written below are user-defaults reads at that launch, so ordering holds. + // Local iOS simulators relaunch with one `simctl launch --terminate-running-process` + // instead of terminate + settle + launch (~1s per relaunch). Provider simulators + // must dispatch an explicit close because their interactors own app termination. + // Runtime hints written below are user-defaults reads at that launch, so ordering holds. // Only the single app-launch form collapses: `open ` dispatches // through the URL path, where a deep-link open never launches the app and // so cannot carry the terminate; those keep the close-first ordering, as @@ -272,6 +273,7 @@ async function completeOpenCommand(params: { Boolean(openTarget) && openPositionals.length === 1 && isIosSimulator(device) && + !isActiveProviderDevice(device) && req.flags?.clearAppState !== true; if ( shouldRunRelaunchPreClose({ diff --git a/src/daemon/provider-lease-expiry.ts b/src/daemon/provider-lease-expiry.ts index 260fb8950..da049ca93 100644 --- a/src/daemon/provider-lease-expiry.ts +++ b/src/daemon/provider-lease-expiry.ts @@ -1,12 +1,29 @@ +import fs from 'node:fs'; +import path from 'node:path'; import type { LeaseLifecycleProvider } from './handlers/lease.ts'; import { releaseExpiredProviderLease } from './lease-lifecycle.ts'; import type { DeviceLease } from './lease-registry.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; const DEFAULT_RETRY_DELAY_MS = 1_000; +const PENDING_RELEASES_FILE = 'expired-provider-leases.json'; +const PENDING_RELEASES_VERSION = 1; +const PERSISTED_LEASE_STRING_FIELDS = [ + 'leaseId', + 'tenantId', + 'runId', + 'backend', + 'leaseProvider', +] as const; +const PERSISTED_LEASE_NUMBER_FIELDS = ['createdAt', 'heartbeatAt', 'expiresAt'] as const; type Timer = ReturnType; +type PersistedExpiredProviderLeases = { + version: number; + leases: DeviceLease[]; +}; + export type ExpiredProviderLeaseReleaser = { release: (lease: DeviceLease) => Promise; retryPending: () => Promise; @@ -15,15 +32,54 @@ export type ExpiredProviderLeaseReleaser = { export function createExpiredProviderLeaseReleaser(options: { leaseLifecycleProvider?: LeaseLifecycleProvider; + stateDir?: string; + providerRuntimeIds?: readonly string[]; + providerRuntimeRequiredIds?: readonly string[]; retryDelayMs?: number; }): ExpiredProviderLeaseReleaser { - const pendingLeases = new Map(); + const pendingLeases = loadPendingLeases(options.stateDir); const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; let retryTimer: Timer | undefined; let retrying = false; + const persistPendingLeases = (): void => { + if (!options.stateDir) return; + const filePath = pendingReleasesPath(options.stateDir); + try { + if (pendingLeases.size === 0) { + fs.rmSync(filePath, { force: true }); + return; + } + fs.mkdirSync(options.stateDir, { recursive: true, mode: 0o700 }); + const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + const record: PersistedExpiredProviderLeases = { + version: PENDING_RELEASES_VERSION, + leases: [...pendingLeases.values()], + }; + fs.writeFileSync(temporaryPath, `${JSON.stringify(record)}\n`, { mode: 0o600 }); + fs.renameSync(temporaryPath, filePath); + fs.chmodSync(filePath, 0o600); + } catch (error) { + emitDiagnostic({ + level: 'error', + phase: 'provider_lease_expiry_record_write_failed', + data: { + pendingLeaseCount: pendingLeases.size, + error: error instanceof Error ? error.message : String(error), + }, + }); + } + }; + const scheduleRetry = (): void => { - if (retryTimer || pendingLeases.size === 0) return; + if ( + retryTimer || + ![...pendingLeases.values()].some((lease) => + isProviderRuntimeAvailable(lease, options.providerRuntimeIds), + ) + ) { + return; + } retryTimer = setTimeout(() => { retryTimer = undefined; void retryPending(); @@ -41,8 +97,10 @@ export function createExpiredProviderLeaseReleaser(options: { retrying = true; try { for (const lease of pendingLeases.values()) { + if (!isProviderRuntimeAvailable(lease, options.providerRuntimeIds)) continue; if (await releaseExpiredProviderLease(options.leaseLifecycleProvider, lease)) { pendingLeases.delete(lease.leaseId); + persistPendingLeases(); } } } finally { @@ -58,8 +116,15 @@ export function createExpiredProviderLeaseReleaser(options: { return { release: async (lease) => { - if (!lease.leaseProvider || !options.leaseLifecycleProvider?.release) return; + if ( + !lease.leaseProvider || + !options.leaseLifecycleProvider?.release || + !isManagedProviderLease(lease, options.providerRuntimeRequiredIds) + ) { + return; + } pendingLeases.set(lease.leaseId, lease); + persistPendingLeases(); await retryPending(); }, retryPending, @@ -69,3 +134,60 @@ export function createExpiredProviderLeaseReleaser(options: { }, }; } + +function isManagedProviderLease( + lease: DeviceLease, + providerRuntimeRequiredIds: readonly string[] | undefined, +): boolean { + return ( + lease.leaseProvider !== undefined && + (providerRuntimeRequiredIds === undefined || + providerRuntimeRequiredIds.includes(lease.leaseProvider)) + ); +} + +function isProviderRuntimeAvailable( + lease: DeviceLease, + providerRuntimeIds: readonly string[] | undefined, +): boolean { + return ( + lease.leaseProvider === undefined || + providerRuntimeIds === undefined || + providerRuntimeIds.includes(lease.leaseProvider) + ); +} + +function loadPendingLeases(stateDir: string | undefined): Map { + if (!stateDir) return new Map(); + const filePath = pendingReleasesPath(stateDir); + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as PersistedExpiredProviderLeases; + if (parsed.version !== PENDING_RELEASES_VERSION || !Array.isArray(parsed.leases)) { + throw new Error('unsupported expiry release record'); + } + return new Map( + parsed.leases.filter(isPersistedDeviceLease).map((lease) => [lease.leaseId, lease]), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map(); + emitDiagnostic({ + level: 'warn', + phase: 'provider_lease_expiry_record_read_failed', + data: { error: error instanceof Error ? error.message : String(error) }, + }); + return new Map(); + } +} + +function isPersistedDeviceLease(value: unknown): value is DeviceLease { + if (!value || typeof value !== 'object') return false; + const lease = value as Partial; + return ( + PERSISTED_LEASE_STRING_FIELDS.every((field) => typeof lease[field] === 'string') && + PERSISTED_LEASE_NUMBER_FIELDS.every((field) => typeof lease[field] === 'number') + ); +} + +function pendingReleasesPath(stateDir: string): string { + return path.join(stateDir, PENDING_RELEASES_FILE); +} diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 7554d2331..b2063e249 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -103,7 +103,11 @@ export async function startDaemonRuntime( ); const expiredProviderLeaseReleaser = createExpiredProviderLeaseReleaser({ leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, + stateDir: baseDir, + providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, + providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, }); + void expiredProviderLeaseReleaser.retryPending(); const leaseRegistry = new LeaseRegistry({ maxActiveSimulatorLeases: parseIntegerEnv(env.AGENT_DEVICE_MAX_SIMULATOR_LEASES), defaultLeaseTtlMs: parseIntegerEnv(env.AGENT_DEVICE_LEASE_TTL_MS), diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index cafe1dc1f..1c675ab7c 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -71,7 +71,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { readonly leaseLifecycle: LeaseLifecycleProvider = { allocate: async (lease) => await this.allocate(lease), - release: async (lease) => await this.release(lease.leaseId), + release: async (lease) => await this.release(lease), }; readonly deviceInventoryProvider: DeviceInventoryProvider = async (request) => { @@ -232,14 +232,36 @@ export class LimrunRuntime implements ProviderDeviceRuntime { }; } - private async release(leaseId: string): Promise | undefined> { - const session = this.sessions.get(leaseId); - if (!session) return undefined; + private async release(lease: DeviceLease): Promise | undefined> { + const session = this.sessions.get(lease.leaseId); + if (!session) return await this.releaseRecoveredSession(lease); await this.terminateSession(session); - this.sessions.delete(leaseId); + this.sessions.delete(lease.leaseId); return { limrunInstanceId: session.instanceId }; } + private async releaseRecoveredSession( + lease: DeviceLease, + ): Promise | undefined> { + const platform = platformForLimrunLeaseBackend(lease.backend); + if (!platform) return undefined; + const labelSelector = `provider=${LIMRUN_PROVIDER},leaseId=${lease.leaseId}`; + const instances = + platform === 'ios' + ? await this.limrun.iosInstances.list({ labelSelector }) + : await this.limrun.androidInstances.list({ labelSelector }); + const instanceIds = instances.getPaginatedItems().map((instance) => instance.metadata.id); + for (const instanceId of instanceIds) { + if (platform === 'ios') { + await this.limrun.iosInstances.delete(instanceId); + } else { + await this.limrun.androidInstances.delete(instanceId); + } + } + if (instanceIds.length === 0) return undefined; + return { limrunInstanceId: instanceIds[0], limrunInstanceCount: instanceIds.length }; + } + private async terminateSession(session: LimrunRuntimeSession): Promise { session.client.disconnect(); if (session.platform === 'ios') { diff --git a/test/integration/provider-scenarios/provider-device-runtime.test.ts b/test/integration/provider-scenarios/provider-device-runtime.test.ts index 6f58a9498..a54762dcc 100644 --- a/test/integration/provider-scenarios/provider-device-runtime.test.ts +++ b/test/integration/provider-scenarios/provider-device-runtime.test.ts @@ -38,6 +38,7 @@ type FakeProviderCall = { | 'inventory' | 'install' | 'open' + | 'close' | 'tap' | 'snapshot' | 'portReverse.ensure' @@ -75,8 +76,14 @@ test('provider-owned iOS simulators relaunch without local CoreSimulator refresh ); assert.deepEqual( - runtime.calls.filter((call) => call.type === 'open').map((call) => call.deviceId), - [runtime.deviceIdForLease(lease.leaseId), runtime.deviceIdForLease(lease.leaseId)], + runtime.calls + .filter((call) => call.type === 'open' || call.type === 'close') + .map((call) => [call.type, call.deviceId]), + [ + ['open', runtime.deviceIdForLease(lease.leaseId)], + ['close', runtime.deviceIdForLease(lease.leaseId)], + ['open', runtime.deviceIdForLease(lease.leaseId)], + ], ); }, ); @@ -450,6 +457,9 @@ function createFakeProviderInteractor(device: DeviceInfo, calls: FakeProviderCal open: async (app, options) => { calls.push({ type: 'open', deviceId: device.id, app, url: options?.url }); }, + close: async (app) => { + calls.push({ type: 'close', deviceId: device.id, app }); + }, tap: async (x, y) => { calls.push({ type: 'tap', deviceId: device.id, x, y }); return { backend: 'fake-provider', x, y }; From fc0b828b9cb7840e17346c4145544a6947cb5fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 16:32:10 +0200 Subject: [PATCH 20/23] test: cover Limrun connect through CLI --- src/__tests__/cloud-connect-profile.test.ts | 92 ++++++--------------- src/cli/connection/limrun-profile.ts | 23 +----- 2 files changed, 27 insertions(+), 88 deletions(-) diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 2a7c236d9..4e858c872 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { connectCommand } from '../cli/commands/connection.ts'; import { resolveCloudAccessForConnect } from '../cli/auth-session.ts'; +import { runCliCapture } from './cli-capture.ts'; import { hashRemoteConfigFile, readActiveConnectionState, @@ -12,7 +13,8 @@ import { } from '../remote/remote-connection-state.ts'; import type { AgentDeviceClient } from '../agent-device-client.ts'; -vi.mock('../cli/auth-session.ts', () => ({ +vi.mock('../cli/auth-session.ts', async (importOriginal) => ({ + ...(await importOriginal()), resolveCloudAccessForConnect: vi.fn(), })); @@ -187,74 +189,28 @@ test('connect limrun requires LIMRUN_API_KEY', async () => { } }); -test('connect limrun accepts only remote mobile simulator and emulator sessions', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-limrun-scope-')); - const stateDir = path.join(tempRoot, '.state'); - vi.stubEnv('LIMRUN_API_KEY', 'lim_test_key'); +test('connect limrun rejects unsupported public device and backend selections', async () => { + const environment = { LIMRUN_API_KEY: 'lim_test_key' }; + const device = await runCliCapture( + ['connect', 'limrun', '--platform', 'ios', '--device', 'local-ios', '--json'], + { env: environment, stateDirPrefix: 'agent-device-connect-limrun-scope-' }, + ); + assert.equal(device.code, 1); + assert.match(device.stdout, /does not accept --device/); - try { - await assert.rejects( - connectCommand({ - positionals: ['limrun'], - flags: { - json: true, - help: false, - version: false, - stateDir, - platform: 'ios', - udid: '00008110-001234567890801E', - }, - client: {} as AgentDeviceClient, - }), - /does not accept --udid/, - ); - await assert.rejects( - connectCommand({ - positionals: ['limrun'], - flags: { - json: true, - help: false, - version: false, - stateDir, - platform: 'macos', - }, - client: {} as AgentDeviceClient, - }), - /requires --platform ios or android/, - ); - await assert.rejects( - connectCommand({ - positionals: ['limrun'], - flags: { - json: true, - help: false, - version: false, - stateDir, - platform: 'ios', - target: 'desktop', - }, - client: {} as AgentDeviceClient, - }), - /supports only mobile simulators and emulators/, - ); - await assert.rejects( - connectCommand({ - positionals: ['limrun'], - flags: { - json: true, - help: false, - version: false, - stateDir, - platform: 'ios', - leaseBackend: 'android-instance', - }, - client: {} as AgentDeviceClient, - }), - /requires --lease-backend ios-instance/, - ); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } + const platform = await runCliCapture(['connect', 'limrun', '--platform', 'macos', '--json'], { + env: environment, + stateDirPrefix: 'agent-device-connect-limrun-scope-', + }); + assert.equal(platform.code, 1); + assert.match(platform.stdout, /requires --platform ios or android/); + + const backend = await runCliCapture( + ['connect', 'limrun', '--platform', 'ios', '--lease-backend', 'android-instance', '--json'], + { env: environment, stateDirPrefix: 'agent-device-connect-limrun-scope-' }, + ); + assert.equal(backend.code, 1); + assert.match(backend.stdout, /requires --lease-backend ios-instance/); }); test('connect without remote config rejects legacy remoteConfig string profile response', async () => { diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index 277809154..44eb7b3aa 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -8,14 +8,6 @@ import { persistAndResolveGeneratedProfile } from './generated-config.ts'; import { resolveRequestedLeaseBackend } from '../commands/connection-runtime.ts'; const DEFAULT_LIMRUN_TENANT = 'limrun'; -const LIMRUN_LOCAL_DEVICE_SELECTORS = [ - ['--device', (flags: CliFlags) => flags.device], - ['--udid', (flags: CliFlags) => flags.udid], - ['--serial', (flags: CliFlags) => flags.serial], - ['--ios-simulator-device-set', (flags: CliFlags) => flags.iosSimulatorDeviceSet], - ['--android-device-allowlist', (flags: CliFlags) => flags.androidDeviceAllowlist], -] as const; - export function resolveLimrunConnectProfile(options: { flags: CliFlags; stateDir: string; @@ -54,7 +46,7 @@ function buildLimrunRemoteProfile(options: { flags: CliFlags }): RemoteConfigPro leaseBackend, leaseProvider: 'limrun', platform: flags.platform, - target: flags.target ?? 'mobile', + target: 'mobile', session: flags.session, ...readMetroProfileFields(flags), }; @@ -64,19 +56,10 @@ function validateLimrunConnectFlags(flags: CliFlags): 'android-instance' | 'ios- if (flags.platform !== 'android' && flags.platform !== 'ios') { throw new AppError('INVALID_ARGS', 'connect limrun requires --platform ios or android.'); } - if (flags.target !== undefined && flags.target !== 'mobile') { - throw new AppError( - 'INVALID_ARGS', - 'connect limrun supports only mobile simulators and emulators.', - ); - } - const selector = LIMRUN_LOCAL_DEVICE_SELECTORS.find( - ([, value]) => value(flags) !== undefined, - )?.[0]; - if (selector) { + if (flags.device !== undefined) { throw new AppError( 'INVALID_ARGS', - `connect limrun does not accept ${selector}; Limrun selects a remote simulator or emulator.`, + 'connect limrun does not accept --device; Limrun selects a remote simulator or emulator.', ); } const leaseBackend = resolveRequestedLeaseBackend(flags); From 3dd93541945ce4ca215082bff067eb3fff4d5eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 18:02:18 +0200 Subject: [PATCH 21/23] fix: make provider expiry recovery durable --- src/__tests__/limrun-runtime.test.ts | 56 +++++++++++ src/__tests__/provider-device-runtime.test.ts | 9 +- src/daemon/__tests__/lease-lifecycle.test.ts | 6 +- .../__tests__/provider-lease-expiry.test.ts | 55 +++++++++-- .../__tests__/session-close-shutdown.test.ts | 43 +++++++++ src/daemon/handlers/session-close.ts | 2 + src/daemon/lease-lifecycle.ts | 9 +- src/daemon/provider-lease-expiry.ts | 92 ++++++++++--------- src/daemon/server/daemon-runtime.ts | 5 +- src/provider-device-runtime.ts | 28 ++++++ src/providers/limrun/runtime.ts | 12 +++ 11 files changed, 255 insertions(+), 62 deletions(-) diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index f39e34be8..21d0828cc 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { LimrunRuntime } from '../providers/limrun/runtime.ts'; +import { createExpiredProviderLeaseReleaser } from '../daemon/provider-lease-expiry.ts'; import type { SimulatorLease } from '../daemon/lease-registry.ts'; import type { DeviceInfo } from '../kernel/device.ts'; import { runCmd } from '../utils/exec.ts'; @@ -207,6 +208,61 @@ test('Limrun reclaims a labeled iOS instance without an in-memory session', asyn } }); +test('Limrun recovers a failed expired lease release after a daemon restart', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-limrun-expiry-')); + const lease: SimulatorLease = { + leaseId: 'lease-recovered-after-restart', + tenantId: 'team-a', + runId: 'run-a', + backend: 'ios-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + const firstRuntime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + const firstReleaser = createExpiredProviderLeaseReleaser({ + recoverExpiredLease: firstRuntime.recoverExpiredLease, + recoverableProviderIds: ['limrun'], + stateDir, + }); + let recoveredRuntime: LimrunRuntime | undefined; + let recoveredReleaser: ReturnType | undefined; + + try { + const allocateLease = firstRuntime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + await allocateLease(lease); + limrunMockState.iosDelete.mockRejectedValueOnce(new Error('temporary provider outage')); + + await firstReleaser.release(lease); + assert.deepEqual(limrunMockState.iosDelete.mock.calls[0], ['ios-instance-1']); + firstReleaser.shutdown(); + + recoveredRuntime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + recoveredReleaser = createExpiredProviderLeaseReleaser({ + recoverExpiredLease: recoveredRuntime.recoverExpiredLease, + recoverableProviderIds: ['limrun'], + stateDir, + }); + limrunMockState.iosList.mockResolvedValueOnce({ + getPaginatedItems: () => [{ metadata: { id: 'ios-instance-recovered' } }], + }); + await recoveredReleaser.retryPending(); + + assert.deepEqual(limrunMockState.iosList.mock.calls[0], [ + { labelSelector: 'provider=limrun,leaseId=lease-recovered-after-restart' }, + ]); + assert.deepEqual(limrunMockState.iosDelete.mock.calls.at(-1), ['ios-instance-recovered']); + } finally { + // A crashed daemon cannot run the original runtime's graceful shutdown. + firstReleaser.shutdown(); + recoveredReleaser?.shutdown(); + await recoveredRuntime?.shutdown(); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + test('Limrun Android reverses localhost URL ports through the persistent ADB tunnel', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 439ee06e6..133fb0820 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -28,6 +28,9 @@ test('provider device runtime registry delegates lifecycle, inventory, interacto assert.deepEqual(await requestProviders.leaseLifecycleProvider?.allocate?.(world.lease), { provider: 'hit', }); + await requestProviders.recoverExpiredLease?.(world.lease); + assert.deepEqual(requestProviders.recoverableProviderIds, ['hit']); + assert.deepEqual(world.recoveredLeases, [world.lease]); assert.deepEqual( await requestProviders.deviceInventoryProvider?.({ platform: 'ios', @@ -58,6 +61,7 @@ function makeProviderRuntimeWorld() { booted: true, }; const interactor = { open: async () => undefined } as unknown as Interactor; + const recoveredLeases: SimulatorLease[] = []; const missRuntime = makeMissingRuntime(); const hitRuntime = makeRuntime({ provider: 'hit', @@ -67,7 +71,10 @@ function makeProviderRuntimeWorld() { installResult: { bundleId: 'com.example.app' }, portReverseResult: { provider: 'hit' }, }); - return { lease, device, interactor, missRuntime, hitRuntime }; + hitRuntime.recoverExpiredLease = async (expiredLease) => { + recoveredLeases.push(expiredLease); + }; + return { lease, device, interactor, missRuntime, hitRuntime, recoveredLeases }; } function makeMissingRuntime(): ProviderDeviceRuntime { diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index 9ae302ddf..7c17dac51 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -126,11 +126,11 @@ test('releaseExpiredProviderLease releases a provider-owned lease without a sess runId: 'run-1', leaseProvider: 'limrun', }); - const release = vi.fn(async () => ({ limrunInstanceId: 'instance-1' })); + const recover = vi.fn(async () => undefined); - await releaseExpiredProviderLease({ release }, lease); + await releaseExpiredProviderLease(recover, lease); - expect(release).toHaveBeenCalledWith(lease); + expect(recover).toHaveBeenCalledWith(lease); }); test('releaseSessionLease keeps the local lease when the provider release fails', async () => { diff --git a/src/daemon/__tests__/provider-lease-expiry.test.ts b/src/daemon/__tests__/provider-lease-expiry.test.ts index 420ca1eaa..f91f3512d 100644 --- a/src/daemon/__tests__/provider-lease-expiry.test.ts +++ b/src/daemon/__tests__/provider-lease-expiry.test.ts @@ -3,10 +3,11 @@ import os from 'node:os'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; -import { LeaseRegistry } from '../lease-registry.ts'; +import { LeaseRegistry, type DeviceLease } from '../lease-registry.ts'; test('retries an expired provider lease release after a transient failure', async () => { vi.useFakeTimers(); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-expired-provider-lease-')); let now = 1_000; const leaseRegistry = new LeaseRegistry({ defaultLeaseTtlMs: 10, @@ -19,11 +20,15 @@ test('retries an expired provider lease release after a transient failure', asyn leaseProvider: 'limrun', }); const release = vi - .fn<() => Promise>>() + .fn<(lease: DeviceLease) => Promise>>() .mockRejectedValueOnce(new Error('temporary provider outage')) .mockResolvedValueOnce({ limrunInstanceId: 'instance-1' }); const releaser = createExpiredProviderLeaseReleaser({ - leaseLifecycleProvider: { release }, + recoverExpiredLease: async (expiredLease) => { + await release(expiredLease); + }, + stateDir, + recoverableProviderIds: ['limrun'], retryDelayMs: 10, }); @@ -41,6 +46,7 @@ test('retries an expired provider lease release after a transient failure', asyn } finally { releaser.shutdown(); vi.useRealTimers(); + fs.rmSync(stateDir, { recursive: true, force: true }); } }); @@ -52,14 +58,15 @@ test('retries a persisted expired provider lease after daemon restart', async () leaseProvider: 'limrun', }); const release = vi - .fn<() => Promise>>() + .fn<(lease: DeviceLease) => Promise>>() .mockRejectedValueOnce(new Error('temporary provider outage')) .mockResolvedValueOnce({ limrunInstanceId: 'instance-1' }); const options = { - leaseLifecycleProvider: { release }, + recoverExpiredLease: async (expiredLease: typeof lease) => { + await release(expiredLease); + }, stateDir, - providerRuntimeIds: ['limrun'], - providerRuntimeRequiredIds: ['limrun'], + recoverableProviderIds: ['limrun'], }; const firstDaemon = createExpiredProviderLeaseReleaser(options); @@ -70,7 +77,7 @@ test('retries a persisted expired provider lease after daemon restart', async () const daemonWithoutLimrun = createExpiredProviderLeaseReleaser({ ...options, - providerRuntimeIds: [], + recoverableProviderIds: [], }); await daemonWithoutLimrun.retryPending(); expect(release).toHaveBeenCalledTimes(1); @@ -86,3 +93,35 @@ test('retries a persisted expired provider lease after daemon restart', async () fs.rmSync(stateDir, { recursive: true, force: true }); } }); + +test('does not release until the expired lease record is durable', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-expired-provider-lease-')); + const lease = new LeaseRegistry().allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'limrun', + }); + const recoverExpiredLease = vi.fn(async () => undefined); + const releaser = createExpiredProviderLeaseReleaser({ + recoverExpiredLease, + stateDir, + recoverableProviderIds: ['limrun'], + retryDelayMs: 60_000, + }); + const writeFile = vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => { + throw new Error('disk full'); + }); + + try { + await releaser.release(lease); + expect(recoverExpiredLease).not.toHaveBeenCalled(); + + writeFile.mockRestore(); + await releaser.retryPending(); + expect(recoverExpiredLease).toHaveBeenCalledWith(lease); + } finally { + writeFile.mockRestore(); + releaser.shutdown(); + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index a0102f900..aba701c80 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -63,6 +63,7 @@ import { cleanupAndroidNativePerfSession } from '../../../platforms/android/perf import { stopAndroidSnapshotHelperSessionForDevice } from '../../../platforms/android/snapshot-helper.ts'; import { stopIosRunnerSession } from '../../../platforms/apple/core/runner/runner-client.ts'; import { WEB_DESKTOP_DEVICE } from '../../../__tests__/test-utils/index.ts'; +import { setActiveProviderDeviceRuntimes } from '../../../provider-device-runtime.ts'; const mockShutdownSimulator = vi.mocked(shutdownSimulator); const mockRunCmd = vi.mocked(runCmd); @@ -92,6 +93,7 @@ function makeSession(name: string, device: SessionState['device']): SessionState beforeEach(() => { vi.clearAllMocks(); + setActiveProviderDeviceRuntimes([]); }); test('close --shutdown calls shutdownSimulator for iOS simulator and includes result in response', async () => { @@ -143,6 +145,47 @@ test('close --shutdown calls shutdownSimulator for iOS simulator and includes re } }); +test('close --shutdown leaves provider-owned iOS simulators to their provider', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'provider-ios-shutdown-session'; + const device = { + platform: 'apple' as const, + id: 'limrun:ios:lease-a', + name: 'Limrun iOS', + kind: 'simulator' as const, + booted: true, + }; + sessionStore.set(sessionName, makeSession(sessionName, device)); + setActiveProviderDeviceRuntimes([ + { + provider: 'limrun', + leaseLifecycle: {}, + deviceInventoryProvider: async () => [], + ownsDevice: (candidate) => candidate.id === device.id, + getInteractor: () => undefined, + shutdown: async () => undefined, + }, + ]); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: { shutdown: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + expect(mockShutdownSimulator).not.toHaveBeenCalled(); + if (response?.ok) expect(response.data?.shutdown).toBeUndefined(); +}); + test('close --shutdown calls shutdownAndroidEmulator for Android emulator and includes result in response', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-shutdown-session'; diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 24ea9edb4..c5b2ae24a 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -2,6 +2,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { AppError, normalizeError } from '../../kernel/errors.ts'; import { scheduleIosRunnerIdleStop } from '../../platforms/apple/core/runner/runner-client.ts'; import { isApplePlatform, type DeviceInfo } from '../../kernel/device.ts'; +import { isActiveProviderDevice } from '../../provider-device-runtime.ts'; import { dispatchCommand } from '../../core/dispatch.ts'; import { contextFromFlags } from '../context.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; @@ -44,6 +45,7 @@ async function maybeShutdownSessionTarget(params: { }): Promise { const { device, shutdownRequested } = params; if (!shutdownRequested) return undefined; + if (isActiveProviderDevice(device)) return undefined; if (!canShutdownDeviceTarget(device)) return undefined; return await shutdownDeviceTarget(device); } diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index c22140a4b..d7655dab2 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -10,23 +10,24 @@ import type { SessionStore } from './session-store.ts'; import type { DaemonRequest, SessionState } from './types.ts'; import type { LeaseLifecycleProvider } from './handlers/lease.ts'; +export type ExpiredProviderLeaseRecovery = (lease: DeviceLease) => Promise; + export type SessionTeardown = (session: SessionState, sessionName: string) => Promise; export async function releaseExpiredProviderLease( - leaseLifecycleProvider: LeaseLifecycleProvider | undefined, + recoverExpiredLease: ExpiredProviderLeaseRecovery | undefined, lease: DeviceLease, ): Promise { - if (!lease.leaseProvider || !leaseLifecycleProvider?.release) return true; + if (!lease.leaseProvider || !recoverExpiredLease) return false; try { - const provider = await leaseLifecycleProvider.release(lease); + await recoverExpiredLease(lease); emitDiagnostic({ level: 'info', phase: 'provider_lease_expired_released', data: { leaseId: lease.leaseId, provider: lease.leaseProvider, - ...(provider ? { providerData: provider } : {}), }, }); return true; diff --git a/src/daemon/provider-lease-expiry.ts b/src/daemon/provider-lease-expiry.ts index da049ca93..17b14d69f 100644 --- a/src/daemon/provider-lease-expiry.ts +++ b/src/daemon/provider-lease-expiry.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { LeaseLifecycleProvider } from './handlers/lease.ts'; +import type { ProviderExpiredLeaseRecovery } from '../provider-device-runtime.ts'; import { releaseExpiredProviderLease } from './lease-lifecycle.ts'; import type { DeviceLease } from './lease-registry.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; @@ -31,43 +31,46 @@ export type ExpiredProviderLeaseReleaser = { }; export function createExpiredProviderLeaseReleaser(options: { - leaseLifecycleProvider?: LeaseLifecycleProvider; + recoverExpiredLease?: ProviderExpiredLeaseRecovery; stateDir?: string; - providerRuntimeIds?: readonly string[]; - providerRuntimeRequiredIds?: readonly string[]; + recoverableProviderIds?: readonly string[]; retryDelayMs?: number; }): ExpiredProviderLeaseReleaser { const pendingLeases = loadPendingLeases(options.stateDir); + const persistedLeaseIds = new Set(pendingLeases.keys()); const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; let retryTimer: Timer | undefined; let retrying = false; - const persistPendingLeases = (): void => { - if (!options.stateDir) return; + const persistPendingLeases = (): boolean => { + if (!options.stateDir) { + emitPersistenceFailure( + pendingLeases.size, + new Error('daemon state directory is unavailable'), + ); + return false; + } const filePath = pendingReleasesPath(options.stateDir); try { if (pendingLeases.size === 0) { fs.rmSync(filePath, { force: true }); - return; + } else { + fs.mkdirSync(options.stateDir, { recursive: true, mode: 0o700 }); + const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + const record: PersistedExpiredProviderLeases = { + version: PENDING_RELEASES_VERSION, + leases: [...pendingLeases.values()], + }; + fs.writeFileSync(temporaryPath, `${JSON.stringify(record)}\n`, { mode: 0o600 }); + fs.renameSync(temporaryPath, filePath); + fs.chmodSync(filePath, 0o600); } - fs.mkdirSync(options.stateDir, { recursive: true, mode: 0o700 }); - const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - const record: PersistedExpiredProviderLeases = { - version: PENDING_RELEASES_VERSION, - leases: [...pendingLeases.values()], - }; - fs.writeFileSync(temporaryPath, `${JSON.stringify(record)}\n`, { mode: 0o600 }); - fs.renameSync(temporaryPath, filePath); - fs.chmodSync(filePath, 0o600); + persistedLeaseIds.clear(); + for (const leaseId of pendingLeases.keys()) persistedLeaseIds.add(leaseId); + return true; } catch (error) { - emitDiagnostic({ - level: 'error', - phase: 'provider_lease_expiry_record_write_failed', - data: { - pendingLeaseCount: pendingLeases.size, - error: error instanceof Error ? error.message : String(error), - }, - }); + emitPersistenceFailure(pendingLeases.size, error); + return false; } }; @@ -75,7 +78,7 @@ export function createExpiredProviderLeaseReleaser(options: { if ( retryTimer || ![...pendingLeases.values()].some((lease) => - isProviderRuntimeAvailable(lease, options.providerRuntimeIds), + isRecoverableProviderAvailable(lease, options.recoverableProviderIds), ) ) { return; @@ -97,8 +100,9 @@ export function createExpiredProviderLeaseReleaser(options: { retrying = true; try { for (const lease of pendingLeases.values()) { - if (!isProviderRuntimeAvailable(lease, options.providerRuntimeIds)) continue; - if (await releaseExpiredProviderLease(options.leaseLifecycleProvider, lease)) { + if (!isRecoverableProviderAvailable(lease, options.recoverableProviderIds)) continue; + if (!persistedLeaseIds.has(lease.leaseId) && !persistPendingLeases()) continue; + if (await releaseExpiredProviderLease(options.recoverExpiredLease, lease)) { pendingLeases.delete(lease.leaseId); persistPendingLeases(); } @@ -118,13 +122,16 @@ export function createExpiredProviderLeaseReleaser(options: { release: async (lease) => { if ( !lease.leaseProvider || - !options.leaseLifecycleProvider?.release || - !isManagedProviderLease(lease, options.providerRuntimeRequiredIds) + !options.recoverExpiredLease || + !isRecoverableProviderAvailable(lease, options.recoverableProviderIds) ) { return; } pendingLeases.set(lease.leaseId, lease); - persistPendingLeases(); + if (!persistPendingLeases()) { + scheduleRetry(); + return; + } await retryPending(); }, retryPending, @@ -135,26 +142,25 @@ export function createExpiredProviderLeaseReleaser(options: { }; } -function isManagedProviderLease( +function isRecoverableProviderAvailable( lease: DeviceLease, - providerRuntimeRequiredIds: readonly string[] | undefined, + recoverableProviderIds: readonly string[] | undefined, ): boolean { return ( lease.leaseProvider !== undefined && - (providerRuntimeRequiredIds === undefined || - providerRuntimeRequiredIds.includes(lease.leaseProvider)) + recoverableProviderIds?.includes(lease.leaseProvider) === true ); } -function isProviderRuntimeAvailable( - lease: DeviceLease, - providerRuntimeIds: readonly string[] | undefined, -): boolean { - return ( - lease.leaseProvider === undefined || - providerRuntimeIds === undefined || - providerRuntimeIds.includes(lease.leaseProvider) - ); +function emitPersistenceFailure(pendingLeaseCount: number, error: unknown): void { + emitDiagnostic({ + level: 'error', + phase: 'provider_lease_expiry_record_write_failed', + data: { + pendingLeaseCount, + error: error instanceof Error ? error.message : String(error), + }, + }); } function loadPendingLeases(stateDir: string | undefined): Map { diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index b2063e249..d9a703883 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -102,10 +102,9 @@ export async function startDaemonRuntime( { providerRuntimeRequiredIds: DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS }, ); const expiredProviderLeaseReleaser = createExpiredProviderLeaseReleaser({ - leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, + recoverExpiredLease: providerRuntimeProviders.recoverExpiredLease, stateDir: baseDir, - providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, - providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, + recoverableProviderIds: providerRuntimeProviders.recoverableProviderIds, }); void expiredProviderLeaseReleaser.retryPending(); const leaseRegistry = new LeaseRegistry({ diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 84199cff7..5ef5b3025 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -27,6 +27,7 @@ export type ProviderDeviceInstallOptions = { export type ProviderDeviceRuntime = { provider: string; leaseLifecycle: LeaseLifecycleProvider; + recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifacts?: CloudArtifactProvider; deviceInventoryProvider: DeviceInventoryProvider; ownsDevice(device: DeviceInfo): boolean; @@ -62,12 +63,16 @@ export type ProviderPortReverseOptions = { export type ProviderDeviceRuntimeRequestProviders = { providerRuntimeIds: readonly string[]; providerRuntimeRequiredIds: readonly string[]; + recoverableProviderIds: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; + recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifactProvider?: CloudArtifactProvider; deviceInventoryProvider?: DeviceInventoryProvider; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; }; +export type ProviderExpiredLeaseRecovery = (lease: DeviceLease) => Promise; + let activeProviderDeviceRuntimes: ProviderDeviceRuntime[] = []; const providerDeviceRuntimeScope = new AsyncLocalStorage(); @@ -171,6 +176,10 @@ export function createProviderDeviceRuntimeRequestProviders( ...(options.providerRuntimeRequiredIds ?? []), ]), leaseLifecycleProvider: composeLeaseProvider(runtimes), + recoverableProviderIds: runtimes + .filter((runtime) => runtime.recoverExpiredLease !== undefined) + .map((runtime) => runtime.provider), + recoverExpiredLease: composeExpiredLeaseRecovery(runtimes), cloudArtifactProvider: composeCloudArtifactProvider(runtimes), deviceInventoryProvider: composeDeviceInventoryProvider(runtimes), providerDeviceRuntimeScope: async (task) => @@ -178,6 +187,25 @@ export function createProviderDeviceRuntimeRequestProviders( }; } +function composeExpiredLeaseRecovery( + runtimes: ProviderDeviceRuntime[], +): ProviderExpiredLeaseRecovery | undefined { + if (!runtimes.some((runtime) => runtime.recoverExpiredLease !== undefined)) return undefined; + return async (lease) => { + const runtime = runtimes.find((candidate) => + runtimeMatchesProvider(candidate, lease.leaseProvider), + ); + if (!runtime?.recoverExpiredLease) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `Provider ${lease.leaseProvider ?? 'unknown'} cannot recover an expired lease.`, + { provider: lease.leaseProvider, leaseId: lease.leaseId }, + ); + } + await runtime.recoverExpiredLease(lease); + }; +} + function uniqueProviderIds(providerIds: readonly string[]): string[] { return [...new Set(providerIds)]; } diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 1c675ab7c..5b30db282 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -9,6 +9,7 @@ import type { ProviderDeviceInstallOptions, ProviderDeviceInstallResult, ProviderDeviceRuntime, + ProviderExpiredLeaseRecovery, ProviderPortReverseOptions, } from '../../provider-device-runtime.ts'; import { readVersion } from '../../utils/version.ts'; @@ -74,6 +75,17 @@ export class LimrunRuntime implements ProviderDeviceRuntime { release: async (lease) => await this.release(lease), }; + readonly recoverExpiredLease: ProviderExpiredLeaseRecovery = async (lease) => { + if (lease.leaseProvider !== this.provider || !platformForLimrunLeaseBackend(lease.backend)) { + throw new AppError('UNSUPPORTED_OPERATION', 'Limrun cannot recover this expired lease.', { + leaseId: lease.leaseId, + leaseProvider: lease.leaseProvider, + leaseBackend: lease.backend, + }); + } + await this.release(lease); + }; + readonly deviceInventoryProvider: DeviceInventoryProvider = async (request) => { if (request.leaseProvider !== this.provider || !request.leaseId) return null; const session = this.sessions.get(request.leaseId); From 61b8821b5f7ef6f6cc371889cb996bc32ef25c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 18:53:00 +0200 Subject: [PATCH 22/23] refactor: remove Limrun compatibility cleanup --- src/__tests__/cloud-connect-profile.test.ts | 2 +- src/__tests__/limrun-runtime.test.ts | 16 +-------- src/__tests__/provider-device-runtime.test.ts | 12 ------- .../provider-device-runtimes.test.ts | 4 +-- src/cli/connection/limrun-profile.ts | 2 +- src/cli/parser/cli-help.ts | 2 +- .../handlers/session-runtime-command.ts | 34 ++++--------------- src/provider-device-runtime.ts | 14 -------- src/provider-device-runtimes.ts | 2 +- src/providers/limrun/android.ts | 7 ---- src/providers/limrun/runtime.ts | 19 ++--------- .../provider-device-runtime.test.ts | 19 +---------- website/docs/docs/device-clouds.md | 2 +- 13 files changed, 17 insertions(+), 118 deletions(-) diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 4e858c872..cadd21616 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -168,7 +168,7 @@ 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', ''); + vi.stubEnv('LIM_API_KEY', 'lim_test_key'); try { await assert.rejects( diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 21d0828cc..a5116e2db 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -553,7 +553,7 @@ test('Limrun iOS maps supported orientation and rejects unsupported upside-down } }); -test('Limrun Android configures and removes an explicit port reverse', async () => { +test('Limrun Android configures an explicit port reverse', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test', @@ -588,13 +588,6 @@ test('Limrun Android configures and removes an explicit port reverse', async () 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', @@ -602,13 +595,6 @@ test('Limrun Android configures and removes an explicit port reverse', async () '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(); } diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 133fb0820..443421d7a 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -5,7 +5,6 @@ import { configureProviderPortReverse, getProviderDeviceInteractor, installProviderDeviceApp, - removeProviderPortReverse, setActiveProviderDeviceRuntimes, type ProviderDeviceRuntime, } from '../provider-device-runtime.ts'; @@ -106,16 +105,6 @@ async function assertProviderRuntimeDelegates(world: ReturnType { @@ -164,7 +153,6 @@ function makeRuntime(options: { getInteractor: () => options.interactor, ...(options.installHook === false ? {} : { installApp: async () => options.installResult }), configurePortReverse: async () => options.portReverseResult, - removePortReverse: async () => options.portReverseResult, shutdown: async () => undefined, }; } diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts index 3807104d0..77e909b97 100644 --- a/src/__tests__/provider-device-runtimes.test.ts +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -2,8 +2,8 @@ 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({}); +test('default provider runtimes skip Limrun when only the removed API key alias is configured', async () => { + const runtimes = await createDefaultProviderDeviceRuntimes({ LIM_API_KEY: 'lim_test_key' }); assert.equal( runtimes.some((runtime) => runtime.provider === 'limrun'), diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index 44eb7b3aa..fca502e7f 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -15,7 +15,7 @@ export function resolveLimrunConnectProfile(options: { env?: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { const env = options.env ?? process.env; - const apiKey = env.LIMRUN_API_KEY?.trim() || env.LIM_API_KEY?.trim(); + const apiKey = env.LIMRUN_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.', diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 34db016d2..eb3892a75 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -873,7 +873,7 @@ Rules: 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 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. + Device cloud credentials must be available before the command starts. Limrun uses LIMRUN_API_KEY. 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. diff --git a/src/daemon/handlers/session-runtime-command.ts b/src/daemon/handlers/session-runtime-command.ts index c5e11d300..c838aa034 100644 --- a/src/daemon/handlers/session-runtime-command.ts +++ b/src/daemon/handlers/session-runtime-command.ts @@ -12,11 +12,9 @@ import { import { configureProviderPortReverse, type ProviderPortReverseOptions, - removeProviderPortReverse, } from '../../provider-device-runtime.ts'; type RuntimeAction = 'set' | 'show' | 'clear'; -type PortReverseAction = 'port-reverse' | 'port-reverse-remove'; type PortReverseParseResult = | { ok: true; options: ProviderPortReverseOptions } | { ok: false; response: DaemonResponse }; @@ -34,14 +32,11 @@ export async function handleRuntimeCommand(params: { }): Promise { const { req, sessionName, sessionStore } = params; const action = (req.positionals?.[0] ?? 'show').toLowerCase(); - if (isPortReverseAction(action)) { - return await handlePortReverseCommand(req, action); + if (action === 'port-reverse') { + return await handlePortReverseCommand(req); } if (!isRuntimeAction(action)) { - return errorResponse( - 'INVALID_ARGS', - 'runtime requires set, show, clear, port-reverse, or port-reverse-remove', - ); + return errorResponse('INVALID_ARGS', 'runtime requires set, show, clear, or port-reverse'); } const session = sessionStore.get(sessionName); const current = sessionStore.getRuntimeHints(sessionName); @@ -59,10 +54,6 @@ function isRuntimeAction(action: string): action is RuntimeAction { return action === 'set' || action === 'show' || action === 'clear'; } -function isPortReverseAction(action: string): action is PortReverseAction { - return action === 'port-reverse' || action === 'port-reverse-remove'; -} - async function clearRuntimeCommand( sessionName: string, sessionStore: SessionStore, @@ -141,13 +132,10 @@ function setRuntimeCommand(params: { }; } -async function handlePortReverseCommand( - req: DaemonRequest, - action: PortReverseAction, -): Promise { +async function handlePortReverseCommand(req: DaemonRequest): Promise { const parsed = readPortReverseOptions(req); if (!parsed.ok) return parsed.response; - const result = await executePortReverseAction(action, parsed.options); + const result = await configureProviderPortReverse(parsed.options); if (!result) { return errorResponse( 'UNSUPPORTED_OPERATION', @@ -157,7 +145,7 @@ async function handlePortReverseCommand( return { ok: true, data: { - action, + action: 'port-reverse', ...result, }, }; @@ -217,16 +205,6 @@ function readPortReversePorts(req: DaemonRequest): PortReversePorts { return { ok: true, devicePort, hostPort }; } -async function executePortReverseAction( - action: PortReverseAction, - options: ProviderPortReverseOptions, -): Promise | undefined> { - if (action === 'port-reverse') { - return await configureProviderPortReverse(options); - } - return await removeProviderPortReverse(options); -} - function readTcpPort(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65_535) { return undefined; diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 5ef5b3025..605daa81a 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -46,9 +46,6 @@ export type ProviderDeviceRuntime = { configurePortReverse?( options: ProviderPortReverseOptions, ): Promise | undefined>; - removePortReverse?( - options: ProviderPortReverseOptions, - ): Promise | undefined>; shutdown(): Promise; }; @@ -149,17 +146,6 @@ export async function configureProviderPortReverse( return undefined; } -export async function removeProviderPortReverse( - options: ProviderPortReverseOptions, -): Promise | undefined> { - for (const runtime of getActiveProviderDeviceRuntimes()) { - if (!runtimeMatchesProvider(runtime, options.provider)) continue; - const result = await runtime.removePortReverse?.(options); - if (result) return result; - } - return undefined; -} - function getActiveProviderDeviceRuntimes(): ProviderDeviceRuntime[] { return providerDeviceRuntimeScope.getStore() ?? activeProviderDeviceRuntimes; } diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 9cd06b2b0..2f102e12f 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -16,7 +16,7 @@ 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; + if (!env.LIMRUN_API_KEY?.trim()) return runtimes; const { createLimrunRuntimeFromEnv } = await import('./providers/limrun/runtime.ts'); const limrunRuntime = createLimrunRuntimeFromEnv(env); diff --git a/src/providers/limrun/android.ts b/src/providers/limrun/android.ts index 82fdb1892..8c1148573 100644 --- a/src/providers/limrun/android.ts +++ b/src/providers/limrun/android.ts @@ -114,13 +114,6 @@ export async function configureLimrunAndroidPortReverse( }); } -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; diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 5b30db282..082b11976 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -19,7 +19,6 @@ import { createLimrunAndroidInteractor, createLimrunAndroidSession, installLimrunAndroidApp, - removeLimrunAndroidPortReverse, type LimrunAndroidSession, } from './android.ts'; import { @@ -55,11 +54,11 @@ type LimrunRuntimeOptions = { 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(); + const apiKey = env.LIMRUN_API_KEY?.trim(); if (!apiKey) return undefined; return new LimrunRuntime({ apiKey, - region: env.LIMRUN_REGION?.trim() || env.LIM_REGION?.trim() || undefined, + region: env.LIMRUN_REGION?.trim() || undefined, version: readVersion(), }); } @@ -151,15 +150,6 @@ export class LimrunRuntime implements ProviderDeviceRuntime { return portReverseResult(options); } - async removePortReverse( - options: ProviderPortReverseOptions, - ): Promise | undefined> { - const session = this.findAndroidPortReverseSession(options.leaseId); - 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))); @@ -299,11 +289,6 @@ export class LimrunRuntime implements ProviderDeviceRuntime { 'Direct Limrun iOS sessions cannot reach local host ports; use a bridge public URL.', ); } - - private findAndroidPortReverseSession(leaseId: string): LimrunAndroidSession | undefined { - const session = this.sessions.get(leaseId); - return session?.platform === 'android' ? session : undefined; - } } function portReverseResult(options: ProviderPortReverseOptions): Record { diff --git a/test/integration/provider-scenarios/provider-device-runtime.test.ts b/test/integration/provider-scenarios/provider-device-runtime.test.ts index a54762dcc..ef58bdfce 100644 --- a/test/integration/provider-scenarios/provider-device-runtime.test.ts +++ b/test/integration/provider-scenarios/provider-device-runtime.test.ts @@ -41,8 +41,7 @@ type FakeProviderCall = { | 'close' | 'tap' | 'snapshot' - | 'portReverse.ensure' - | 'portReverse.remove'; + | 'portReverse.ensure'; [key: string]: unknown; }; @@ -221,13 +220,6 @@ function providerScenarioSteps( flags: DEVTOOLS_PORT_REVERSE, expectData: { action: 'port-reverse', ...portReverse }, }, - { - name: 'port-reverse-remove', - command: 'runtime', - positionals: ['port-reverse-remove'], - flags: DEVTOOLS_PORT_REVERSE, - expectData: { action: 'port-reverse-remove', ...portReverse }, - }, { name: 'release', command: 'lease_release', @@ -299,7 +291,6 @@ function assertFakeProviderCallOrder(calls: FakeProviderCall[]): void { 'tap', 'snapshot', 'portReverse.ensure', - 'portReverse.remove', 'lease.release', ], ); @@ -412,14 +403,6 @@ class FakeProviderDeviceRuntime implements ProviderDeviceRuntime { return { provider: this.provider, ...options }; } - async removePortReverse( - options: ProviderPortReverseOptions, - ): Promise | undefined> { - if (options.provider !== this.provider) return undefined; - this.calls.push({ type: 'portReverse.remove', options }); - return { provider: this.provider, ...options }; - } - async shutdown(): Promise { this.sessionsByLeaseId.clear(); } diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index 6d3115a00..a2cccb726 100644 --- a/website/docs/docs/device-clouds.md +++ b/website/docs/docs/device-clouds.md @@ -55,7 +55,7 @@ Required environment: export LIMRUN_API_KEY=... ``` -`LIM_API_KEY` is accepted as a compatibility alias. `LIMRUN_REGION` (or `LIM_REGION`) optionally selects a Limrun region. +`LIMRUN_REGION` optionally selects a Limrun region. Choose the platform to create a matching instance: From 03e3967f21eaf69be51c96f0c090902206b77f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 19:15:17 +0200 Subject: [PATCH 23/23] fix: release live provider leases on expiry --- .../__tests__/provider-lease-expiry.test.ts | 13 +- src/daemon/provider-lease-expiry.ts | 128 ++++++++++++++---- src/daemon/server/daemon-runtime.ts | 2 + .../cloud-webdriver-runtime.test.ts | 25 ++++ 4 files changed, 129 insertions(+), 39 deletions(-) diff --git a/src/daemon/__tests__/provider-lease-expiry.test.ts b/src/daemon/__tests__/provider-lease-expiry.test.ts index f91f3512d..87b823594 100644 --- a/src/daemon/__tests__/provider-lease-expiry.test.ts +++ b/src/daemon/__tests__/provider-lease-expiry.test.ts @@ -5,9 +5,8 @@ import { expect, test, vi } from 'vitest'; import { createExpiredProviderLeaseReleaser } from '../provider-lease-expiry.ts'; import { LeaseRegistry, type DeviceLease } from '../lease-registry.ts'; -test('retries an expired provider lease release after a transient failure', async () => { +test('retries an expired live-only provider lease release after a transient failure', async () => { vi.useFakeTimers(); - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-expired-provider-lease-')); let now = 1_000; const leaseRegistry = new LeaseRegistry({ defaultLeaseTtlMs: 10, @@ -17,18 +16,15 @@ test('retries an expired provider lease release after a transient failure', asyn const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1', - leaseProvider: 'limrun', + leaseProvider: 'browserstack', }); const release = vi .fn<(lease: DeviceLease) => Promise>>() .mockRejectedValueOnce(new Error('temporary provider outage')) .mockResolvedValueOnce({ limrunInstanceId: 'instance-1' }); const releaser = createExpiredProviderLeaseReleaser({ - recoverExpiredLease: async (expiredLease) => { - await release(expiredLease); - }, - stateDir, - recoverableProviderIds: ['limrun'], + leaseLifecycleProvider: { release }, + providerRuntimeIds: ['browserstack'], retryDelayMs: 10, }); @@ -46,7 +42,6 @@ test('retries an expired provider lease release after a transient failure', asyn } finally { releaser.shutdown(); vi.useRealTimers(); - fs.rmSync(stateDir, { recursive: true, force: true }); } }); diff --git a/src/daemon/provider-lease-expiry.ts b/src/daemon/provider-lease-expiry.ts index 17b14d69f..e271ac950 100644 --- a/src/daemon/provider-lease-expiry.ts +++ b/src/daemon/provider-lease-expiry.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { ProviderExpiredLeaseRecovery } from '../provider-device-runtime.ts'; +import type { LeaseLifecycleProvider } from './handlers/lease.ts'; import { releaseExpiredProviderLease } from './lease-lifecycle.ts'; import type { DeviceLease } from './lease-registry.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; @@ -31,13 +32,16 @@ export type ExpiredProviderLeaseReleaser = { }; export function createExpiredProviderLeaseReleaser(options: { + leaseLifecycleProvider?: LeaseLifecycleProvider; + providerRuntimeIds?: readonly string[]; recoverExpiredLease?: ProviderExpiredLeaseRecovery; stateDir?: string; recoverableProviderIds?: readonly string[]; retryDelayMs?: number; }): ExpiredProviderLeaseReleaser { - const pendingLeases = loadPendingLeases(options.stateDir); - const persistedLeaseIds = new Set(pendingLeases.keys()); + const pendingLiveLeases = new Map(); + const pendingRecoveryLeases = loadPendingLeases(options.stateDir); + const persistedRecoveryLeaseIds = new Set(pendingRecoveryLeases.keys()); const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; let retryTimer: Timer | undefined; let retrying = false; @@ -45,31 +49,33 @@ export function createExpiredProviderLeaseReleaser(options: { const persistPendingLeases = (): boolean => { if (!options.stateDir) { emitPersistenceFailure( - pendingLeases.size, + pendingRecoveryLeases.size, new Error('daemon state directory is unavailable'), ); return false; } const filePath = pendingReleasesPath(options.stateDir); try { - if (pendingLeases.size === 0) { + if (pendingRecoveryLeases.size === 0) { fs.rmSync(filePath, { force: true }); } else { fs.mkdirSync(options.stateDir, { recursive: true, mode: 0o700 }); const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; const record: PersistedExpiredProviderLeases = { version: PENDING_RELEASES_VERSION, - leases: [...pendingLeases.values()], + leases: [...pendingRecoveryLeases.values()], }; fs.writeFileSync(temporaryPath, `${JSON.stringify(record)}\n`, { mode: 0o600 }); fs.renameSync(temporaryPath, filePath); fs.chmodSync(filePath, 0o600); } - persistedLeaseIds.clear(); - for (const leaseId of pendingLeases.keys()) persistedLeaseIds.add(leaseId); + persistedRecoveryLeaseIds.clear(); + for (const leaseId of pendingRecoveryLeases.keys()) { + persistedRecoveryLeaseIds.add(leaseId); + } return true; } catch (error) { - emitPersistenceFailure(pendingLeases.size, error); + emitPersistenceFailure(pendingRecoveryLeases.size, error); return false; } }; @@ -77,9 +83,8 @@ export function createExpiredProviderLeaseReleaser(options: { const scheduleRetry = (): void => { if ( retryTimer || - ![...pendingLeases.values()].some((lease) => - isRecoverableProviderAvailable(lease, options.recoverableProviderIds), - ) + (!hasRetryableLiveLease(pendingLiveLeases, options.providerRuntimeIds) && + !hasRetryableRecoveryLease(pendingRecoveryLeases, options.recoverableProviderIds)) ) { return; } @@ -91,25 +96,43 @@ export function createExpiredProviderLeaseReleaser(options: { emitDiagnostic({ level: 'warn', phase: 'provider_lease_expiry_retry_scheduled', - data: { pendingLeaseCount: pendingLeases.size, retryDelayMs }, + data: { + pendingLiveLeaseCount: pendingLiveLeases.size, + pendingRecoveryLeaseCount: pendingRecoveryLeases.size, + retryDelayMs, + }, }); }; + const retryPendingLiveLeases = async (): Promise => { + for (const lease of pendingLiveLeases.values()) { + if (!isProviderRuntimeAvailable(lease, options.providerRuntimeIds)) continue; + if (await releaseLiveProviderLease(options.leaseLifecycleProvider, lease)) { + pendingLiveLeases.delete(lease.leaseId); + } + } + }; + + const retryPendingRecoveryLeases = async (): Promise => { + for (const lease of pendingRecoveryLeases.values()) { + if (!isRecoverableProviderAvailable(lease, options.recoverableProviderIds)) continue; + if (!persistedRecoveryLeaseIds.has(lease.leaseId) && !persistPendingLeases()) continue; + if (await releaseExpiredProviderLease(options.recoverExpiredLease, lease)) { + pendingRecoveryLeases.delete(lease.leaseId); + persistPendingLeases(); + } + } + }; + const retryPending = async (): Promise => { if (retrying) return; retrying = true; try { - for (const lease of pendingLeases.values()) { - if (!isRecoverableProviderAvailable(lease, options.recoverableProviderIds)) continue; - if (!persistedLeaseIds.has(lease.leaseId) && !persistPendingLeases()) continue; - if (await releaseExpiredProviderLease(options.recoverExpiredLease, lease)) { - pendingLeases.delete(lease.leaseId); - persistPendingLeases(); - } - } + await retryPendingLiveLeases(); + await retryPendingRecoveryLeases(); } finally { retrying = false; - if (pendingLeases.size === 0 && retryTimer) { + if (pendingLiveLeases.size === 0 && pendingRecoveryLeases.size === 0 && retryTimer) { clearTimeout(retryTimer); retryTimer = undefined; } else { @@ -120,19 +143,23 @@ export function createExpiredProviderLeaseReleaser(options: { return { release: async (lease) => { - if ( - !lease.leaseProvider || - !options.recoverExpiredLease || - !isRecoverableProviderAvailable(lease, options.recoverableProviderIds) - ) { + if (!lease.leaseProvider) return; + if (isRecoverableProviderAvailable(lease, options.recoverableProviderIds)) { + pendingRecoveryLeases.set(lease.leaseId, lease); + if (persistPendingLeases()) { + await retryPending(); + } else { + scheduleRetry(); + } return; } - pendingLeases.set(lease.leaseId, lease); - if (!persistPendingLeases()) { + if (!isProviderRuntimeAvailable(lease, options.providerRuntimeIds)) return; + if (!options.leaseLifecycleProvider?.release) return; + pendingLiveLeases.set(lease.leaseId, lease); + await retryPending(); + if (pendingLiveLeases.has(lease.leaseId)) { scheduleRetry(); - return; } - await retryPending(); }, retryPending, shutdown: () => { @@ -142,6 +169,47 @@ export function createExpiredProviderLeaseReleaser(options: { }; } +async function releaseLiveProviderLease( + leaseLifecycleProvider: LeaseLifecycleProvider | undefined, + lease: DeviceLease, +): Promise { + return await releaseExpiredProviderLease( + leaseLifecycleProvider?.release + ? async (expiredLease) => { + await leaseLifecycleProvider.release?.(expiredLease); + } + : undefined, + lease, + ); +} + +function hasRetryableLiveLease( + pendingLeases: ReadonlyMap, + providerRuntimeIds: readonly string[] | undefined, +): boolean { + return [...pendingLeases.values()].some((lease) => + isProviderRuntimeAvailable(lease, providerRuntimeIds), + ); +} + +function hasRetryableRecoveryLease( + pendingLeases: ReadonlyMap, + recoverableProviderIds: readonly string[] | undefined, +): boolean { + return [...pendingLeases.values()].some((lease) => + isRecoverableProviderAvailable(lease, recoverableProviderIds), + ); +} + +function isProviderRuntimeAvailable( + lease: DeviceLease, + providerRuntimeIds: readonly string[] | undefined, +): boolean { + return ( + lease.leaseProvider !== undefined && providerRuntimeIds?.includes(lease.leaseProvider) === true + ); +} + function isRecoverableProviderAvailable( lease: DeviceLease, recoverableProviderIds: readonly string[] | undefined, diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index d9a703883..01df28da0 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -102,6 +102,8 @@ export async function startDaemonRuntime( { providerRuntimeRequiredIds: DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS }, ); const expiredProviderLeaseReleaser = createExpiredProviderLeaseReleaser({ + leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, + providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, recoverExpiredLease: providerRuntimeProviders.recoverExpiredLease, stateDir: baseDir, recoverableProviderIds: providerRuntimeProviders.recoverableProviderIds, diff --git a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts index 0bc9014be..1eb5b56f6 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -10,6 +10,7 @@ import { parseWebDriverSource } from '../../../src/cloud-webdriver/webdriver-sou import { CLOUD_WEBDRIVER_PROVIDERS } from '../../../src/cloud-webdriver/providers.ts'; import type { CloudArtifact } from '../../../src/cloud-artifacts.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; +import { createExpiredProviderLeaseReleaser } from '../../../src/daemon/provider-lease-expiry.ts'; import type { DeviceLease } from '../../../src/daemon/lease-registry.ts'; import type { DaemonRequest } from '../../../src/daemon/types.ts'; import { assertRpcError, assertRpcOk } from './assertions.ts'; @@ -107,6 +108,29 @@ test('Cloud WebDriver release still returns artifacts when WebDriver session del }); }, 15_000); +test('Cloud WebDriver expiry releases the live provider session', async () => { + await withProviderScenarioResource(createCloudWebDriverWorld, async (world) => { + const lease = await allocateWebDriverLease(world.daemon); + const releaser = createExpiredProviderLeaseReleaser({ + leaseLifecycleProvider: world.providers.leaseLifecycleProvider, + providerRuntimeIds: world.providers.providerRuntimeIds, + recoverableProviderIds: world.providers.recoverableProviderIds, + }); + + try { + await releaser.release(lease); + assert.equal( + world.server.calls.some( + (call) => call.method === 'DELETE' && call.path === '/wd/hub/session/wd-1', + ), + true, + ); + } finally { + releaser.shutdown(); + } + }); +}, 15_000); + test('Cloud WebDriver allocation preserves create-session failure when cleanup fails', async () => { let cleanupCalled = false; await withProviderScenarioResource( @@ -285,6 +309,7 @@ async function createCloudWebDriverWorld( return { daemon, server, + providers, failNextArtifactLookup: () => { artifactFailuresRemaining += 1; },