From b23c2ba07defbd728b3442c3c1a00dbb4aa7e587 Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Wed, 4 Nov 2026 04:21:23 +0300 Subject: [PATCH 1/5] feat(limrun): sync local app installs with SDK 0.69.1 --- package.json | 2 +- packages/provider-limrun/package.json | 2 +- packages/provider-limrun/src/android.ts | 41 +-- .../provider-limrun/src/deployment-runtime.ts | 8 +- packages/provider-limrun/src/ios.ts | 145 ++++------ .../src/request-cancellation.ts | 4 +- .../src/runtime-dependencies.fixtures.ts | 2 +- .../src/runtime-dependencies.test.ts | 2 +- .../src/runtime-dependencies.ts | 5 - packages/provider-limrun/src/runtime.ts | 12 +- pnpm-lock.yaml | 14 +- pnpm-workspace.yaml | 2 +- src/__tests__/limrun-runtime.test.ts | 44 ++- src/platform-runtime-gateway.fixtures.ts | 1 - src/sdk/limrun-host-scope.test.ts | 37 +-- src/sdk/limrun-runtime-dependencies.ts | 16 +- ...limrun-deployment-cancellation.fixtures.ts | 74 +++-- .../limrun-deployment-cancellation.test.ts | 256 +++++++----------- .../stale-provider-runtime-admission.test.ts | 1 - website/docs/docs/limrun.md | 4 +- 20 files changed, 274 insertions(+), 398 deletions(-) diff --git a/package.json b/package.json index b24b4f0b18..14cc65f55f 100644 --- a/package.json +++ b/package.json @@ -340,7 +340,7 @@ "@agent-device/xml": "workspace:*", "@arethetypeswrong/cli": "^0.18.5", "@chenglou/freerange": "^0.0.4", - "@limrun/api": "^0.68.1", + "@limrun/api": "^0.69.1", "@nkzw/eslint-plugin": "^2.0.0", "@nkzw/oxlint-config": "^1.3.0", "@statelyai/graph": "2.4.0", diff --git a/packages/provider-limrun/package.json b/packages/provider-limrun/package.json index 4dfeb6fc97..ec6c5a4492 100644 --- a/packages/provider-limrun/package.json +++ b/packages/provider-limrun/package.json @@ -18,6 +18,6 @@ }, "devDependencies": { "@agent-device/platform-apple": "workspace:*", - "@limrun/api": "^0.68.1" + "@limrun/api": "^0.69.1" } } diff --git a/packages/provider-limrun/src/android.ts b/packages/provider-limrun/src/android.ts index e532f5e512..995aaa3e31 100644 --- a/packages/provider-limrun/src/android.ts +++ b/packages/provider-limrun/src/android.ts @@ -1,6 +1,4 @@ -import path from 'node:path'; import { AsyncLocalStorage } from 'node:async_hooks'; -import type Limrun from '@limrun/api'; import { createInstanceClient as createAndroidInstanceClient, type InstanceClient as LimrunAndroidClient, @@ -114,7 +112,6 @@ export function createLimrunAndroidInteractor(session: LimrunAndroidSession): In } export async function installLimrunAndroidApp( - limrun: Limrun, session: LimrunAndroidSession, installablePath: string, options?: ProviderDeviceInstallOptions, @@ -122,31 +119,19 @@ export async function installLimrunAndroidApp( operationDrain?: LimrunRequestOperationDrain, ): Promise { signal?.throwIfAborted(); - const packageName = normalizeOptionalString(options?.packageNameHint); - if (options?.relaunch && packageName) { - await session.dependencies.android.forceStopApp( - async (adbArgs, adbOptions) => await runLimrunAndroidAdb(session, adbArgs, adbOptions), - packageName, - signal, - ); - } - const asset = await awaitLimrunDeploymentOperation( - operationDrain, - limrun.assets.getOrUpload( - { - path: installablePath, - name: buildAndroidAssetName(packageName, installablePath), - }, - { signal }, - ), - signal, - ); - await awaitLimrunDeploymentOperation( + // The shared deployment owner must retain the artifact until sync finishes reading it. + const result = await awaitLimrunDeploymentOperation( operationDrain, - session.client.sendAsset(asset.signedDownloadUrl), - signal, + session.client.syncApp(installablePath, { + install: true, + watch: false, + launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'None', + }), ); signal?.throwIfAborted(); + const packageName = + normalizeOptionalString(result.installedBundleId) ?? + normalizeOptionalString(options?.packageNameHint); const appName = packageName ? await session.dependencies.android.inferAppName(packageName) : undefined; @@ -364,9 +349,3 @@ function tcpEndpoint(port: number): LimrunPortReverseEndpoint { } return `tcp:${port}`; } - -function buildAndroidAssetName(packageName: string | undefined, artifactPath: string): string { - const extension = path.extname(artifactPath) || '.apk'; - const prefix = packageName?.replaceAll(/[^a-zA-Z0-9_.-]+/g, '-') || 'android-app'; - return `${prefix}${extension}`; -} diff --git a/packages/provider-limrun/src/deployment-runtime.ts b/packages/provider-limrun/src/deployment-runtime.ts index 6445754a2c..720dbf6c94 100644 --- a/packages/provider-limrun/src/deployment-runtime.ts +++ b/packages/provider-limrun/src/deployment-runtime.ts @@ -5,7 +5,10 @@ import type { import { unavailableFact } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import type { LimrunRequestOperationDrain } from './request-cancellation.ts'; +import { + awaitLimrunDeploymentOperation, + type LimrunRequestOperationDrain, +} from './request-cancellation.ts'; import { isSupportedLimrunRuntimeDevice } from './runtime-device.ts'; const available = Object.freeze({ available: true } as const); @@ -95,7 +98,8 @@ export function createLimrunAppDeploymentOperations( }, } : {}), - deployApp: async (input) => (await load()).deployApp(input), + deployApp: async (input) => + awaitLimrunDeploymentOperation(operationDrain, (await load()).deployApp(input), signal), materializeAppSource: async (input) => (await load()).materializeAppSource(input), deployMaterializedApp: async (input) => (await load()).deployMaterializedApp(input), uninstallApp: async (input) => (await load()).uninstallApp(input), diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index a384c62fc9..64dc81a15b 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -7,13 +7,11 @@ import type { import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import type Limrun from '@limrun/api'; import { createInstanceClient as createIosInstanceClient, type InstanceClient as LimrunIosClient, } from '@limrun/api/ios-client'; import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { normalizeOptionalString } from './strings.ts'; @@ -77,7 +75,6 @@ export async function createLimrunIosSession( } export async function installLimrunIosApp( - limrun: Limrun, session: LimrunIosSession, installablePath: string, options?: ProviderDeviceInstallOptions, @@ -85,39 +82,36 @@ export async function installLimrunIosApp( operationDrain?: LimrunRequestOperationDrain, ): Promise { signal?.throwIfAborted(); - const prepared = await prepareLimrunIosAsset(installablePath, session.dependencies); - try { - signal?.throwIfAborted(); - const asset = await awaitLimrunDeploymentOperation( - operationDrain, - limrun.assets.getOrUpload( - { - path: prepared.uploadPath, - name: prepared.assetName, - }, - { signal }, - ), - signal, - ); - const result = await installLimrunIosRemoteApp( - session, - asset.signedDownloadUrl, - { - md5: asset.md5, - relaunch: options?.relaunch, - appIdentifierHint: options?.appIdentifierHint, - }, - signal, - operationDrain, - ); - const bundleId = result.appId; - return { - ...(bundleId ? { bundleId, launchTarget: bundleId } : {}), - ...(prepared.appName ? { appName: prepared.appName } : {}), - }; - } finally { - await prepared.cleanup(); - } + const stat = await fs.promises.stat(installablePath); + const appName = + (stat.isDirectory() + ? await session.dependencies.ios.readBundleAppName(installablePath) + : undefined) ?? inferAppNameFromPath(installablePath); + const result = await verifyLimrunIosInstall( + session, + async () => { + signal?.throwIfAborted(); + // The shared deployment owner must retain the artifact until sync finishes reading it. + const synced = await awaitLimrunDeploymentOperation( + operationDrain, + session.client.syncApp(installablePath, { + install: true, + watch: false, + launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'None', + }), + ); + signal?.throwIfAborted(); + return synced.installedBundleId; + }, + options, + signal, + operationDrain, + ); + const bundleId = result.appId; + return { + ...(bundleId ? { bundleId, launchTarget: bundleId } : {}), + ...(appName ? { appName } : {}), + }; } export async function installLimrunIosRemoteApp( @@ -126,6 +120,32 @@ export async function installLimrunIosRemoteApp( options?: LimrunIosRemoteInstallOptions, signal?: AbortSignal, operationDrain?: LimrunRequestOperationDrain, +): Promise { + return await verifyLimrunIosInstall( + session, + async () => { + const result = await awaitLimrunDeploymentOperation( + operationDrain, + session.client.installApp(url, { + // The server's digest cache survives uninstall, so every requested install must run. + launchMode: options?.relaunch ? 'RelaunchIfRunning' : undefined, + }), + signal, + ); + return result.bundleId; + }, + options, + signal, + operationDrain, + ); +} + +async function verifyLimrunIosInstall( + session: LimrunIosSession, + install: () => Promise, + options?: LimrunIosRemoteInstallOptions, + signal?: AbortSignal, + operationDrain?: LimrunRequestOperationDrain, ): Promise { signal?.throwIfAborted(); const beforeInstallApps = await awaitLimrunDeploymentOperation( @@ -136,15 +156,7 @@ export async function installLimrunIosRemoteApp( if (signal?.aborted) throw error; return undefined; }); - const result = await awaitLimrunDeploymentOperation( - operationDrain, - session.client.installApp(url, { - // The server's digest cache survives uninstall, so every requested install must run. - launchMode: options?.relaunch ? 'RelaunchIfRunning' : undefined, - }), - signal, - ); - const resultBundleId = normalizeOptionalString(result.bundleId); + const resultBundleId = normalizeOptionalString(await install()); const requestedBundleId = normalizeOptionalString(options?.appIdentifierHint); let afterInstallApps: LimrunIosApp[] = []; for (const delayMs of IOS_APP_INVENTORY_RETRY_DELAYS_MS) { @@ -251,49 +263,6 @@ class LimrunIosHostInteractor { } } -async function prepareLimrunIosAsset( - artifactPath: string, - dependencies: Pick, -): Promise<{ - uploadPath: string; - assetName: string; - appName?: string; - cleanup: () => Promise; -}> { - const stat = await fs.promises.stat(artifactPath); - if (!stat.isDirectory()) { - return { - uploadPath: artifactPath, - assetName: path.basename(artifactPath), - appName: inferAppNameFromPath(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`); - try { - await dependencies.host.archiveDirectory({ - sourceDirectory: path.dirname(artifactPath), - entryName: path.basename(artifactPath), - archivePath: zipPath, - }); - } catch (error) { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - throw error; - } - return { - uploadPath: zipPath, - assetName: path.basename(zipPath), - appName: - (await dependencies.ios.readBundleAppName(artifactPath)) ?? - inferAppNameFromPath(artifactPath), - cleanup: async () => { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - }, - }; -} - function inferAppNameFromPath(appPath: string): string | undefined { const base = path.basename(appPath).replace(/\.(?:app|ipa|apk|aab|zip)$/i, ''); return base || undefined; diff --git a/packages/provider-limrun/src/request-cancellation.ts b/packages/provider-limrun/src/request-cancellation.ts index 5aeeaaac7d..ebf869a65d 100644 --- a/packages/provider-limrun/src/request-cancellation.ts +++ b/packages/provider-limrun/src/request-cancellation.ts @@ -1,6 +1,6 @@ import { awaitWithSignal } from '@agent-device/kernel/await-with-signal'; /** - * The Limrun WebSocket client does not expose an AbortSignal parameter. Keep the request-bound + * The Limrun SDK does not expose an AbortSignal parameter. Keep the request-bound * caller cancellable while retaining a handled continuation for the provider operation it started. */ export async function awaitLimrunOperation( @@ -12,7 +12,7 @@ export async function awaitLimrunOperation( } /** - * Request-local ownership for an opaque Limrun WebSocket operation. A caller can observe its own + * Request-local ownership for an opaque Limrun SDK operation. A caller can observe its own * abort immediately, but a bound runtime does not release the provider session until every * accepted operation has settled. This is deliberately not a durable resource or daemon policy. */ diff --git a/packages/provider-limrun/src/runtime-dependencies.fixtures.ts b/packages/provider-limrun/src/runtime-dependencies.fixtures.ts index 350b439e98..f35ce3d02d 100644 --- a/packages/provider-limrun/src/runtime-dependencies.fixtures.ts +++ b/packages/provider-limrun/src/runtime-dependencies.fixtures.ts @@ -55,7 +55,7 @@ export function stubLimrunRuntimeDependencies(): LimrunRuntimeDependencies { }, host: { runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - archiveDirectory: async () => undefined, + downloadFile: async () => undefined, }, ios: { diff --git a/packages/provider-limrun/src/runtime-dependencies.test.ts b/packages/provider-limrun/src/runtime-dependencies.test.ts index 6492f0568e..1add61ec61 100644 --- a/packages/provider-limrun/src/runtime-dependencies.test.ts +++ b/packages/provider-limrun/src/runtime-dependencies.test.ts @@ -355,7 +355,7 @@ function createContractFixture() { adbCalls.push([...serializeAndroidAdbInvocation(invocation)]); return { stdout: '', stderr: '', exitCode: 0 }; }, - archiveDirectory: async () => undefined, + downloadFile: async () => undefined, }, ios: { diff --git a/packages/provider-limrun/src/runtime-dependencies.ts b/packages/provider-limrun/src/runtime-dependencies.ts index da25d51927..739ed6e594 100644 --- a/packages/provider-limrun/src/runtime-dependencies.ts +++ b/packages/provider-limrun/src/runtime-dependencies.ts @@ -160,11 +160,6 @@ export type LimrunHostAdapter = { invocation: AndroidAdbInvocation, options?: LimrunAdbCommandOptions, ): Promise; - archiveDirectory(options: { - sourceDirectory: string; - entryName: string; - archivePath: string; - }): Promise; downloadFile(options: LimrunFileDownload): Promise; }; diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 4bef8417cd..a93bedcfdc 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -299,17 +299,9 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { ): Promise { const session = this.getSessionForDevice(device); if (!session) return undefined; - const limrun = this.orgClient('Uploading an app'); return session.platform === 'ios' - ? await installLimrunIosApp(limrun, session, installablePath, options, signal, operationDrain) - : await installLimrunAndroidApp( - limrun, - session, - installablePath, - options, - signal, - operationDrain, - ); + ? await installLimrunIosApp(session, installablePath, options, signal, operationDrain) + : await installLimrunAndroidApp(session, installablePath, options, signal, operationDrain); } async configurePortReverse( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0a51b8fa3..89a03acc55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,8 +149,8 @@ importers: specifier: ^0.0.4 version: 0.0.4 '@limrun/api': - specifier: ^0.68.1 - version: 0.68.1(supports-color@7.2.0) + specifier: ^0.69.1 + version: 0.69.1(supports-color@7.2.0) '@nkzw/eslint-plugin': specifier: ^2.0.0 version: 2.0.0(eslint@10.9.1(supports-color@7.2.0)) @@ -877,8 +877,8 @@ importers: specifier: workspace:* version: link:../platform-apple '@limrun/api': - specifier: ^0.68.1 - version: 0.68.1(supports-color@7.2.0) + specifier: ^0.69.1 + version: 0.69.1(supports-color@7.2.0) packages/provider-revyl: devDependencies: @@ -2098,8 +2098,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@limrun/api@0.68.1': - resolution: {integrity: sha512-BgE74RtV1BSbIJJlctYdWF10gxr90dU3AniO3ERhjb0Q5HN8Eth4DXvmmHDKCfjZRCIeft4m9WvZY98PW7LKoA==} + '@limrun/api@0.69.1': + resolution: {integrity: sha512-aPxWiE+ZTclJO4kM/qhHRkn00E7CtsNP3+QrKO0XowNWgoA8pATxD/hoQURiruQTW1nmWQ9L21mwxrZ44ZBKlg==} '@limrun/xdelta3-wasm@0.2.0': resolution: {integrity: sha512-mEohO7CYj4Gdc0v1Gr/AjlBhfbXQnIz6+2wmMhRfwz8hXZrsIC/yqYQhfoDhu1ysqQwBnU5lDDLPUGdUu+8hPQ==} @@ -6704,7 +6704,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@limrun/api@0.68.1(supports-color@7.2.0)': + '@limrun/api@0.69.1(supports-color@7.2.0)': dependencies: '@limrun/xdelta3-wasm': 0.2.0 '@types/har-format': 1.2.16 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 934a90a366..334f41a022 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,7 +8,7 @@ allowBuilds: fallow: true minimumReleaseAgeExclude: - '@chenglou/freerange@0.0.4' - - '@limrun/api@0.64.1' + - '@limrun/api@0.64.1 || 0.69.1' overrides: brace-expansion@5: '^5.0.12' fast-uri: 3.1.9 diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 1816d11a5d..8674b4c62c 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -29,6 +29,8 @@ const limrunMockState = vi.hoisted(() => { bundleId: 'com.example.ios', url: 'https://assets.example/app', })), + iosSyncApp: vi.fn(async () => ({ installedBundleId: 'com.example.ios' })), + androidSyncApp: vi.fn(async () => ({ installedBundleId: 'com.example.android' })), iosSimctl: vi.fn(), iosLaunchApp: vi.fn(async () => undefined), iosOpenUrl: vi.fn(async () => undefined), @@ -112,6 +114,7 @@ vi.mock('@limrun/api/ios-client', async (importOriginal) => { createInstanceClient: vi.fn(async () => ({ disconnect: vi.fn(), installApp: limrunMockState.iosInstallApp, + syncApp: limrunMockState.iosSyncApp, launchApp: limrunMockState.iosLaunchApp, openUrl: limrunMockState.iosOpenUrl, listApps: limrunMockState.iosListApps, @@ -132,6 +135,7 @@ vi.mock('@limrun/api/instance-client', () => ({ keepAlive: limrunMockState.androidKeepAlive, openUrl: limrunMockState.androidOpenUrl, sendAsset: limrunMockState.androidSendAsset, + syncApp: limrunMockState.androidSyncApp, startAdbTunnel: limrunMockState.androidStartAdbTunnel, })), })); @@ -440,7 +444,7 @@ function assertAndroidTunnelLifecycle(openUrl: string): void { assert.equal(limrunMockState.androidTunnelClose.mock.calls.length, 1); } -test('Limrun Android installs direct local artifacts through Limrun assets', async () => { +test('Limrun Android syncs direct local artifacts', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', }); @@ -473,15 +477,11 @@ test('Limrun Android installs direct local artifacts through Limrun assets', asy '/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.equal(limrunMockState.assetsGetOrUpload.mock.calls.length, 0); + assert.equal(limrunMockState.androidSendAsset.mock.calls.length, 0); + assert.deepEqual(limrunMockState.androidSyncApp.mock.calls[0], [ + '/tmp/app-debug.apk', + { install: true, watch: false, launchMode: 'None' }, ]); assert.deepEqual(result, { packageName: 'com.example.android', @@ -519,7 +519,7 @@ test('Limrun Android shares an in-flight ADB tunnel across concurrent port rever } }); -test('Limrun iOS installs direct local artifacts through Limrun assets', async () => { +test('Limrun iOS syncs direct local artifacts', async () => { const tempRoot = mkdtempForTestSync('agent-device-limrun-install-'); const ipaPath = path.join(tempRoot, 'Demo.ipa'); fs.writeFileSync(ipaPath, 'demo'); @@ -553,16 +553,11 @@ test('Limrun iOS installs direct local artifacts through Limrun assets', async ( { 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', - { launchMode: 'RelaunchIfRunning' }, + assert.equal(limrunMockState.assetsGetOrUpload.mock.calls.length, 0); + assert.equal(limrunMockState.iosInstallApp.mock.calls.length, 0); + assert.deepEqual(limrunMockState.iosSyncApp.mock.calls[0], [ + ipaPath, + { install: true, watch: false, launchMode: 'RelaunchIfRunning' }, ]); assert.deepEqual(result, { bundleId: 'com.example.ios', @@ -575,7 +570,7 @@ 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 () => { +test('Limrun iOS reads the bundle display name before syncing an app bundle', async () => { const tempRoot = mkdtempForTestSync('agent-device-limrun-app-name-'); const appPath = path.join(tempRoot, 'Renamed.app'); fs.mkdirSync(appPath); @@ -936,11 +931,6 @@ test('Limrun without an API key refuses operations that need one', async () => { const runtime = new LimrunRuntime({ instances: { ios: ATTACHED_IOS } }); try { - const device = await allocateLimrunDevice(runtime, iosLease('lease-attached-ios')); - await assert.rejects(runtime.installInstallablePath(device, '/tmp/Example.app'), { - code: 'UNSUPPORTED_OPERATION', - message: 'Uploading an app requires a Limrun API key.', - }); await assert.rejects(allocateLimrunDevice(runtime, androidLease()), { code: 'UNSUPPORTED_OPERATION', message: 'Creating an instance requires a Limrun API key.', diff --git a/src/platform-runtime-gateway.fixtures.ts b/src/platform-runtime-gateway.fixtures.ts index 92ebe225db..7d296850b0 100644 --- a/src/platform-runtime-gateway.fixtures.ts +++ b/src/platform-runtime-gateway.fixtures.ts @@ -249,7 +249,6 @@ export const limrunTestDependencies: LimrunRuntimeDependencies = { host: { runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }), downloadFile: async () => undefined, - archiveDirectory: async () => {}, }, ios: { createAppLogReader: ({ client }) => ({ diff --git a/src/sdk/limrun-host-scope.test.ts b/src/sdk/limrun-host-scope.test.ts index 604664513d..355c51dc81 100644 --- a/src/sdk/limrun-host-scope.test.ts +++ b/src/sdk/limrun-host-scope.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; import { mkdtempForTestSync } from '@agent-device/test-kit'; @@ -7,18 +7,18 @@ import { withCommandHost } from '@agent-device/host-kit/command'; import { createLimrunRuntimeDependencies } from './limrun-runtime-dependencies.ts'; test.skipIf(process.platform === 'win32')( - 'Limrun archive callbacks retain their owning host outside and across concurrent foreign scopes', + 'Limrun ADB callbacks retain their owning host outside and across concurrent foreign scopes', async () => { const root = mkdtempForTestSync('agent-device-limrun-host-'); const bin = path.join(root, 'bin'); mkdirSync(bin); - const zip = path.join(bin, 'zip'); + const adb = path.join(bin, 'adb'); writeFileSync( - zip, + adb, `#!${process.execPath}\n` + - `require('node:fs').writeFileSync(process.argv[3], JSON.stringify({args:process.argv.slice(2),host:process.env.LIMRUN_OWNER}));\n`, + `process.stdout.write(JSON.stringify({args:process.argv.slice(2),host:process.env.LIMRUN_OWNER}));\n`, ); - chmodSync(zip, 0o755); + chmodSync(adb, 0o755); const owner = (name: string) => createLimrunRuntimeDependencies({ env: { ...process.env, PATH: bin, LIMRUN_OWNER: name }, @@ -26,35 +26,28 @@ test.skipIf(process.platform === 'win32')( }); const first = owner('first'); const second = owner('second'); - const archive = (name: string) => ({ - sourceDirectory: root, - entryName: 'Example.app', - archivePath: path.join(root, `${name}.zip`), - }); + const invocation = { + target: { selector: { kind: 'unspecified' as const }, server: { kind: 'ambient' as const } }, + command: ['devices'], + }; const previous = { PATH: process.env.PATH, LIMRUN_OWNER: process.env.LIMRUN_OWNER }; process.env.PATH = bin; process.env.LIMRUN_OWNER = 'ambient'; try { - const standalone = archive('standalone'); - await first.host.archiveDirectory(standalone); - const left = archive('left'), - right = archive('right'); - await withCommandHost( + const standalone = await first.host.runAdb(invocation); + const [left, right] = await withCommandHost( { host: { env: { ...process.env, PATH: bin, LIMRUN_OWNER: 'foreign' }, cwd: root }, requestEnv: { LIMRUN_OWNER: 'foreign-request' }, }, - () => Promise.all([first.host.archiveDirectory(left), second.host.archiveDirectory(right)]), + () => Promise.all([first.host.runAdb(invocation), second.host.runAdb(invocation)]), ); - for (const [request, host] of [ + for (const [result, host] of [ [standalone, 'first'], [left, 'first'], [right, 'second'], ] as const) { - assert.deepEqual(JSON.parse(readFileSync(request.archivePath, 'utf8')), { - args: ['-qr', request.archivePath, 'Example.app'], - host, - }); + assert.deepEqual(JSON.parse(result.stdout), { args: ['devices'], host }); } } finally { for (const [key, value] of Object.entries(previous)) { diff --git a/src/sdk/limrun-runtime-dependencies.ts b/src/sdk/limrun-runtime-dependencies.ts index a8920dece1..5f469f7b83 100644 --- a/src/sdk/limrun-runtime-dependencies.ts +++ b/src/sdk/limrun-runtime-dependencies.ts @@ -1,7 +1,6 @@ import { createLimrunIosAppLogReader } from '../core/interactors/limrun-ios-app-logs.ts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import { withMethodScope } from '@agent-device/kernel/scoped-provider'; -import { AppError } from '@agent-device/kernel/errors'; import type { LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; import '../platform-runtime-android-adb-host.ts'; // ProviderDeviceRuntime.getInteractor is synchronous, so this factory is the deliberate static edge; @@ -13,7 +12,7 @@ import { androidAdbSerialTarget, runAndroidHostAdb, } from '@agent-device/platform-android/mechanics'; -import { execFailureDetails, runCmd, withCommandHost } from '@agent-device/host-kit/command'; +import { withCommandHost } from '@agent-device/host-kit/command'; import { readVersion } from '@agent-device/host-kit/version'; export function createLimrunRuntimeDependencies(host: HostEnvironment): LimrunRuntimeDependencies { @@ -107,19 +106,6 @@ export function createLimrunRuntimeDependencies(host: HostEnvironment): LimrunRu const { downloadLimrunFile } = await import('../core/limrun-download-file.ts'); await downloadLimrunFile(options); }, - archiveDirectory: async ({ sourceDirectory, entryName, archivePath }) => { - const args = ['-qr', archivePath, entryName]; - const result = await runCmd('zip', args, { - cwd: sourceDirectory, - timeoutMs: 120_000, - }); - if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Failed to package iOS .app for Limrun install', { - command: ['zip', ...args].join(' '), - ...execFailureDetails(result), - }); - } - }, }, runOnHost, ), diff --git a/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts b/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts index bc1783e961..7298f8a705 100644 --- a/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts +++ b/test/integration/provider-scenarios/limrun-deployment-cancellation.fixtures.ts @@ -7,13 +7,6 @@ import { limrunTestDependencies } from '../../../src/platform-runtime-gateway.fi import type { DeviceInfo } from '@agent-device/kernel/device'; import { mkdtempForTestSync } from '@agent-device/test-kit'; -export type LimrunDeploymentAsset = { signedDownloadUrl: string; md5: string }; - -export const LIMRUN_DEPLOYMENT_ASSET: LimrunDeploymentAsset = { - signedDownloadUrl: 'https://assets.example.test/app', - md5: 'asset-md5', -}; - export const LIMRUN_IOS_APPS = [ { bundleId: 'com.apple.Preferences', name: 'Settings', installType: 'System' }, { bundleId: 'com.example.ios', name: 'Example', installType: 'User' }, @@ -24,23 +17,60 @@ export async function bindLimrunDeployment( signal: AbortSignal, ios: LimrunRuntimeDependencies['ios'] = limrunTestDependencies.ios, ) { - const registration = createLimrunRuntime({ apiKey: 'limrun-test-key' }, limrunDependencies(ios), { - includePlatformModule: true, - }); + const registration = createLimrunRuntime( + { + instances: { + [platform]: { + apiUrl: 'https://limrun.example.test', + token: 'instance-token', + adbUrl: 'wss://limrun.example.test/adb', + }, + }, + }, + limrunDependencies(ios), + { + includePlatformModule: true, + }, + ); const device = await allocatedDevice(registration.runtime, lease(platform)); + const events: string[] = []; + let artifactPath: string | undefined; const deployment = { - prepareArtifact: async (input: { source: { path: string } }) => ({ - installablePath: input.source.path, - bundleId: 'com.example.ios', - packageName: 'com.example.android', - cleanup: async () => {}, - }), + resolveAppBundleId: async () => 'com.example.ios', + resolveAppPackage: async () => 'com.example.android', + prepareArtifact: async (input: { source: { path: string } }) => { + events.push('prepare'); + artifactPath = input.source.path + '.prepared'; + fs.copyFileSync(input.source.path, artifactPath); + const preparedPath = artifactPath; + return { + installablePath: preparedPath, + bundleId: 'com.example.ios', + packageName: 'com.example.android', + cleanup: async () => { + events.push('cleanup'); + fs.rmSync(preparedPath); + }, + }; + }, withInvalidatedAppResolutionCache: async ( _device: unknown, operation: () => Promise, ) => operation(), }; const owner = await registration.platformModule.loadRuntime({ + appleTools: { + run: async () => { + events.push('uninstall'); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }, + androidTools: { + runAdb: async () => { + events.push('uninstall'); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }, appleDeployment: deployment, androidDeployment: deployment, deviceReadiness: { @@ -84,11 +114,18 @@ export async function bindLimrunDeployment( }; return { binding, - deploy: async () => + events, + artifactExists: () => artifactPath !== undefined && fs.existsSync(artifactPath), + materialize: async () => { + const materialize = binding.operations.materializeAppSource; + if (!materialize) throw new Error('Expected materialization operation'); + return await materialize({ source: { kind: 'path', path: appPath } }); + }, + deploy: async (replaceExisting = false) => await deployApp({ app: platform === 'ios' ? 'com.example.ios' : 'com.example.android', appPath, - replaceExisting: false, + replaceExisting, }), shutdown, dispose: async () => { @@ -165,7 +202,6 @@ function limrunDependencies(ios: LimrunRuntimeDependencies['ios']): LimrunRuntim host: { runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }), downloadFile: async () => undefined, - archiveDirectory: async () => {}, }, ios, }; diff --git a/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts b/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts index f64f56fcfa..944a45922a 100644 --- a/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts +++ b/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts @@ -1,9 +1,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import { bindLimrunDeployment, - LIMRUN_DEPLOYMENT_ASSET, LIMRUN_IOS_APPS, - type LimrunDeploymentAsset, } from './limrun-deployment-cancellation.fixtures.ts'; const state = vi.hoisted(() => ({ @@ -21,14 +19,14 @@ const state = vi.hoisted(() => ({ })), androidDelete: vi.fn(async () => undefined), androidDisconnect: vi.fn(async () => undefined), - androidSendAsset: vi.fn(async (_url: string) => undefined), + androidSyncApp: vi.fn(async () => ({ installedBundleId: 'com.example.android' })), iosCreate: vi.fn(async () => ({ metadata: { id: 'limrun-ios-instance' }, status: { token: 'instance-token', apiUrl: 'https://limrun.example.test' }, })), iosDelete: vi.fn(async () => undefined), iosDisconnect: vi.fn(async () => undefined), - iosInstallApp: vi.fn(async () => ({ bundleId: 'com.example.ios' })), + iosSyncApp: vi.fn(async () => ({ installedBundleId: 'com.example.ios' })), iosListApps: vi.fn(async () => [ { bundleId: 'com.apple.Preferences', name: 'Settings', installType: 'System' }, { bundleId: 'com.example.ios', name: 'Example', installType: 'User' }, @@ -54,7 +52,7 @@ vi.mock('@limrun/api', () => ({ vi.mock('@limrun/api/instance-client', () => ({ createInstanceClient: vi.fn(async () => ({ disconnect: state.androidDisconnect, - sendAsset: state.androidSendAsset, + syncApp: state.androidSyncApp, setText: vi.fn(async () => undefined), startAdbTunnel: vi.fn(async () => ({ address: { address: '127.0.0.1', port: 62_001 }, @@ -66,7 +64,7 @@ vi.mock('@limrun/api/instance-client', () => ({ vi.mock('@limrun/api/ios-client', () => ({ createInstanceClient: vi.fn(async () => ({ disconnect: state.iosDisconnect, - installApp: state.iosInstallApp, + syncApp: state.iosSyncApp, listApps: state.iosListApps, deviceInfo: { udid: 'ios-device', screenWidth: 402, screenHeight: 874, model: 'iPhone' }, })), @@ -76,160 +74,104 @@ afterEach(() => { vi.clearAllMocks(); }); -test('bound Limrun Android deployment cancels an in-flight getOrUpload call', async () => { - const controller = new AbortController(); - const abortReason = new Error('cancel Android upload'); - const uploadStarted = deferred(); - const upload = deferred(); - state.assetsGetOrUpload.mockImplementationOnce(() => { - uploadStarted.resolve(); - return upload.promise; - }); - const deployment = await bindLimrunDeployment('android', controller.signal); - - try { - const pending = deployment.deploy(); - await uploadStarted.promise; - controller.abort(abortReason); - - await expect(pending).rejects.toBe(abortReason); - expect(uploadSignal()).toBe(controller.signal); - expect(state.androidSendAsset).not.toHaveBeenCalled(); - } finally { - upload.resolve(LIMRUN_DEPLOYMENT_ASSET); - await deployment.dispose(); - } -}); - -test('bound Limrun Android deployment cancels an in-flight sendAsset call', async () => { - const controller = new AbortController(); - const abortReason = new Error('cancel Android send asset'); - const sendStarted = deferred(); - const send = deferred(); - state.androidSendAsset.mockImplementationOnce(() => { - sendStarted.resolve(); - return send.promise; - }); - const deployment = await bindLimrunDeployment('android', controller.signal); - let bindingDisposed = false; - - try { - const pending = deployment.deploy(); - await sendStarted.promise; - controller.abort(abortReason); - - await expect(pending).rejects.toBe(abortReason); - const disposing = deployment.binding[Symbol.asyncDispose]().then(() => { - bindingDisposed = true; +for (const platform of ['android', 'ios'] as const) { + const sync = platform === 'ios' ? state.iosSyncApp : state.androidSyncApp; + test(`Limrun ${platform} source install keeps caller-owned files until sync settles after cancellation`, async () => { + const controller = new AbortController(); + const abortReason = new Error('cancel source install'); + const started = deferred(); + const pendingSync = deferred<{ installedBundleId: string }>(); + sync.mockImplementationOnce(() => { + started.resolve(); + return pendingSync.promise; }); - await waitForAsyncTurn(); - - expect(bindingDisposed).toBe(false); - send.resolve(undefined); - await disposing; - expect(uploadSignal()).toBe(controller.signal); - expect(state.androidSendAsset).toHaveBeenCalledWith(LIMRUN_DEPLOYMENT_ASSET.signedDownloadUrl); - } finally { - send.resolve(undefined); - await deployment.dispose(); - } -}); - -test('bound Limrun Android deployment handles a late sendAsset rejection after caller abort', async () => { - const controller = new AbortController(); - const abortReason = new Error('cancel Android send asset'); - const lateProviderFailure = new Error('sendAsset rejected after caller cancellation'); - const sendStarted = deferred(); - const send = deferred(); - const unhandledRejections: unknown[] = []; - const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason); - process.on('unhandledRejection', onUnhandledRejection); - state.androidSendAsset.mockImplementationOnce(() => { - sendStarted.resolve(); - return send.promise; + const deployment = await bindLimrunDeployment(platform, controller.signal); + const artifact = await deployment.materialize(); + const install = deployment.binding.operations.deployMaterializedApp; + if (!install) throw new Error('Expected materialized deployment operation'); + try { + const pending = install({ artifact }).finally(() => artifact.cleanup()); + await started.promise; + controller.abort(abortReason); + await waitForAsyncTurn(); + expect(deployment.artifactExists()).toBe(true); + pendingSync.resolve({ installedBundleId: `com.example.${platform}` }); + await expect(pending).rejects.toBe(abortReason); + expect(deployment.artifactExists()).toBe(false); + } finally { + pendingSync.resolve({ installedBundleId: `com.example.${platform}` }); + await deployment.dispose(); + } }); - const deployment = await bindLimrunDeployment('android', controller.signal); - let bindingDisposed = false; - - try { - const pending = deployment.deploy(); - await sendStarted.promise; - controller.abort(abortReason); - await expect(pending).rejects.toBe(abortReason); - const disposing = deployment.binding[Symbol.asyncDispose]().then(() => { - bindingDisposed = true; + for (const replaceExisting of [false, true]) { + test(`Limrun ${platform} ${replaceExisting ? 'reinstall' : 'install'} syncs with instance credentials and leaves launch to open`, async () => { + const deployment = await bindLimrunDeployment(platform, new AbortController().signal); + sync.mockImplementationOnce(async () => { + expect(deployment.artifactExists()).toBe(true); + deployment.events.push('sync'); + return { installedBundleId: `com.example.${platform}` }; + }); + try { + const result = await deployment.deploy(replaceExisting); + expect(result.launchTarget).toBe(`com.example.${platform}`); + expect(sync).toHaveBeenCalledWith(expect.any(String), { + install: true, + watch: false, + launchMode: 'None', + }); + expect(deployment.events).toEqual([ + ...(replaceExisting ? ['uninstall'] : []), + 'prepare', + 'sync', + 'cleanup', + ]); + expect(deployment.artifactExists()).toBe(false); + expect(state.assetsGetOrUpload).not.toHaveBeenCalled(); + expect(state.iosCreate).not.toHaveBeenCalled(); + expect(state.androidCreate).not.toHaveBeenCalled(); + } finally { + await deployment.dispose(); + } }); - await waitForAsyncTurn(); - - // The opaque WebSocket request has already been accepted by Limrun. The - // caller may observe cancellation promptly, but request binding disposal - // must retain ownership until that mutation settles. - expect(bindingDisposed).toBe(false); - send.reject(lateProviderFailure); - await waitForAsyncTurn(); - expect(unhandledRejections).toEqual([]); - await disposing; - } finally { - process.off('unhandledRejection', onUnhandledRejection); - send.resolve(undefined); - if (bindingDisposed) await deployment.dispose(); - else await deployment.shutdown(); - } -}); - -test('bound Limrun iOS deployment cancels an in-flight getOrUpload call', async () => { - const controller = new AbortController(); - const abortReason = new Error('cancel iOS upload'); - const uploadStarted = deferred(); - const upload = deferred(); - state.assetsGetOrUpload.mockImplementationOnce(() => { - uploadStarted.resolve(); - return upload.promise; - }); - const deployment = await bindLimrunDeployment('ios', controller.signal); - - try { - const pending = deployment.deploy(); - await uploadStarted.promise; - controller.abort(abortReason); - - await expect(pending).rejects.toBe(abortReason); - expect(uploadSignal()).toBe(controller.signal); - expect(state.iosInstallApp).not.toHaveBeenCalled(); - } finally { - upload.resolve(LIMRUN_DEPLOYMENT_ASSET); - await deployment.dispose(); } -}); - -test('bound Limrun iOS deployment cancels an in-flight installApp call', async () => { - const controller = new AbortController(); - const abortReason = new Error('cancel iOS install'); - const installStarted = deferred(); - const install = deferred<{ bundleId: string }>(); - state.iosListApps.mockResolvedValueOnce(LIMRUN_IOS_APPS); - state.iosInstallApp.mockImplementationOnce(() => { - installStarted.resolve(); - return install.promise; - }); - const deployment = await bindLimrunDeployment('ios', controller.signal); - - try { - const pending = deployment.deploy(); - await installStarted.promise; - controller.abort(abortReason); - await expect(pending).rejects.toBe(abortReason); - expect(uploadSignal()).toBe(controller.signal); - expect(state.iosInstallApp).toHaveBeenCalledWith(LIMRUN_DEPLOYMENT_ASSET.signedDownloadUrl, { - launchMode: undefined, + for (const fail of [false, true]) { + test(`Limrun ${platform} cancellation retains the materialized app until sync ${fail ? 'fails' : 'finishes'}`, async () => { + const controller = new AbortController(); + const abortReason = new Error('cancel sync'); + const started = deferred(); + const pendingSync = deferred<{ installedBundleId: string }>(); + sync.mockImplementationOnce(() => { + started.resolve(); + return pendingSync.promise; + }); + const deployment = await bindLimrunDeployment(platform, controller.signal); + let disposed = false; + try { + const pending = deployment.deploy(); + await started.promise; + controller.abort(abortReason); + await expect(pending).rejects.toBe(abortReason); + const disposing = deployment.binding[Symbol.asyncDispose]().then(() => { + disposed = true; + }); + await waitForAsyncTurn(); + expect(disposed).toBe(false); + expect(deployment.artifactExists()).toBe(true); + expect(deployment.events).toEqual(['prepare']); + if (fail) pendingSync.reject(new Error('late sync failure')); + else pendingSync.resolve({ installedBundleId: `com.example.${platform}` }); + await disposing; + expect(deployment.artifactExists()).toBe(false); + expect(deployment.events).toEqual(['prepare', 'cleanup']); + } finally { + pendingSync.resolve({ installedBundleId: `com.example.${platform}` }); + await deployment.dispose(); + } }); - } finally { - install.resolve({ bundleId: 'com.example.ios' }); - await deployment.dispose(); } -}); +} test('bound Limrun iOS deployment cancels an in-flight post-install listApps call', async () => { const controller = new AbortController(); @@ -250,8 +192,7 @@ test('bound Limrun iOS deployment cancels an in-flight post-install listApps cal controller.abort(abortReason); await expect(pending).rejects.toBe(abortReason); - expect(uploadSignal()).toBe(controller.signal); - expect(state.iosInstallApp).toHaveBeenCalledOnce(); + expect(state.iosSyncApp).toHaveBeenCalledOnce(); expect(state.iosListApps).toHaveBeenCalledTimes(2); } finally { inventory.resolve(LIMRUN_IOS_APPS); @@ -259,13 +200,6 @@ test('bound Limrun iOS deployment cancels an in-flight post-install listApps cal } }); -function uploadSignal(): AbortSignal | undefined { - const options = state.assetsGetOrUpload.mock.calls[0]?.[1] as - | { signal?: AbortSignal } - | undefined; - return options?.signal; -} - function deferred() { let resolve!: (value: Value) => void; let reject!: (error: unknown) => void; diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts index 14b3de8448..4c20742f11 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts @@ -338,7 +338,6 @@ function limrunDependencies(): LimrunRuntimeDependencies { host: { runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }), downloadFile: async () => undefined, - archiveDirectory: async () => {}, }, ios: limrunTestDependencies.ios, }; diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 02c6fb290a..4ff5d6b2f7 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -47,7 +47,7 @@ On an attached Android instance, agent-device does not replace an existing port If your shell's account variables, or its instance variables for the platform you lease, differ from the ones the daemon started with, the first `install` or `open` refuses before it creates or attaches to an instance. See [Change credentials while the daemon runs](/docs/device-clouds#change-credentials-while-the-daemon-runs). -`install`, and `apps` before the first `open`, need `LIMRUN_API_KEY` because they use Limrun asset storage. After `open`, `apps` lists the apps installed on the instance without the key. Install your app before you hand over the instance. From the Node.js runtime, `getDeviceSession(device).installRemoteApp(url)` installs from a signed asset URL without the API key. +`install` and `reinstall` sync local app files directly to the instance. Attached instances need only their URL and token for these commands. `apps` before the first `open` needs `LIMRUN_API_KEY` to list stored assets. After `open`, `apps` lists the apps installed on the instance without the key. From the Node.js runtime, `getDeviceSession(device).installRemoteApp(url)` installs from a signed asset URL without the API key. ## Keep idle sessions alive @@ -97,7 +97,7 @@ agent-device disconnect Use a selector from your app's snapshot in place of `Continue`. -On Android, agent-device connects to Limrun over an ADB tunnel. Snapshots use the Android snapshot helper, installs upload the app through Limrun asset storage, and port reverse runs over ADB, including the usual reverse setup for a local Metro server. +On Android, agent-device connects to Limrun over an ADB tunnel. Snapshots use the Android snapshot helper, local installs use HTTP delta sync, and port reverse runs over ADB, including the usual reverse setup for a local Metro server. ## Use an iOS simulator From fce348b8b065633bea6b5d37fde87697f69d285f Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Thu, 5 Nov 2026 12:53:18 +0300 Subject: [PATCH 2/5] fix(limrun): bundle delta encoder and own sync cache lifetime --- LICENSES/xdelta3-wasm.txt | 23 ++++ THIRD_PARTY_NOTICES.md | 6 + packages/provider-limrun/package.json | 1 + packages/provider-limrun/src/android.ts | 21 +++- packages/provider-limrun/src/ios.ts | 21 +++- packages/provider-limrun/src/lease-release.ts | 9 +- .../provider-limrun/src/sync-cache.test.ts | 118 ++++++++++++++++++ packages/provider-limrun/src/sync-cache.ts | 83 ++++++++++++ pnpm-lock.yaml | 3 + .../__tests__/bundled-license-notices.test.ts | 31 +++++ .../fixtures/bundled-license-build.mts | 1 + scripts/lib/bundled-license-notices.ts | 16 ++- src/__tests__/limrun-runtime.test.ts | 41 +++--- src/commands/schema/cli-help.ts | 2 +- src/vendor/limrun-delta-sync-omitted.ts | 3 - .../limrun-deployment-cancellation.test.ts | 23 +++- tsdown.config.ts | 11 +- website/docs/docs/limrun.md | 2 +- 18 files changed, 356 insertions(+), 59 deletions(-) create mode 100644 LICENSES/xdelta3-wasm.txt create mode 100644 packages/provider-limrun/src/sync-cache.test.ts create mode 100644 packages/provider-limrun/src/sync-cache.ts delete mode 100644 src/vendor/limrun-delta-sync-omitted.ts diff --git a/LICENSES/xdelta3-wasm.txt b/LICENSES/xdelta3-wasm.txt new file mode 100644 index 0000000000..07c2ac68d3 --- /dev/null +++ b/LICENSES/xdelta3-wasm.txt @@ -0,0 +1,23 @@ +@limrun/xdelta3-wasm 0.2.0 source-file notices + +The published package declares Apache-2.0 but omits the license text. +Package source: https://github.com/limrun-inc/typescript-sdk/tree/main/packages/xdelta3-wasm +Native source: https://github.com/jmacd/xdelta/tree/7508fd2a823443b1f0173ca361620f21d62a7d37/xdelta3 + +xdelta3 - delta compression tools and library + Copyright 2016 Joshua MacDonald + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +Full Apache-2.0 license follows in THIRD_PARTY_NOTICES.txt. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7d8868279c..be02cf24e8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -15,6 +15,12 @@ the package-level BSD license. These are retained in [`LICENSES/jpeg-js.txt`](LICENSES/jpeg-js.txt) and included with the Apache License 2.0 in the generated notice file whenever `jpeg-js` is bundled. +`@limrun/xdelta3-wasm` 0.2.0 omits license files from its npm archive. Its Apache-2.0 +license text and xdelta3 source attribution are retained through +[`LICENSES/xdelta3-wasm.txt`](LICENSES/xdelta3-wasm.txt) and +[`LICENSES/Apache-2.0.txt`](LICENSES/Apache-2.0.txt). The build verifies the package +version before including these notices. + ## Maestro conformance fixtures The 42 upstream flows in `scripts/maestro-conformance/corpus/upstream/` are copied diff --git a/packages/provider-limrun/package.json b/packages/provider-limrun/package.json index ec6c5a4492..6ce474cabb 100644 --- a/packages/provider-limrun/package.json +++ b/packages/provider-limrun/package.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@agent-device/platform-apple": "workspace:*", + "@agent-device/test-kit": "workspace:*", "@limrun/api": "^0.69.1" } } diff --git a/packages/provider-limrun/src/android.ts b/packages/provider-limrun/src/android.ts index 995aaa3e31..f97d81f53a 100644 --- a/packages/provider-limrun/src/android.ts +++ b/packages/provider-limrun/src/android.ts @@ -1,3 +1,4 @@ +import type { LimrunSyncCache } from './sync-cache.ts'; import { AsyncLocalStorage } from 'node:async_hooks'; import { createInstanceClient as createAndroidInstanceClient, @@ -36,6 +37,7 @@ type LimrunAndroidAdbSession = { instanceId: string; readonly ownership: LimrunInstanceOwnership; device: DeviceInfo; + syncCache?: LimrunSyncCache; client: LimrunAndroidClient; /** Instance bearer token; the recording download the SDK would run inline is done by the host instead. */ readonly token: string; @@ -119,14 +121,23 @@ export async function installLimrunAndroidApp( operationDrain?: LimrunRequestOperationDrain, ): Promise { signal?.throwIfAborted(); + const { LimrunSyncCache } = await import('./sync-cache.ts'); + const syncCache = (session.syncCache ??= new LimrunSyncCache()); // The shared deployment owner must retain the artifact until sync finishes reading it. const result = await awaitLimrunDeploymentOperation( operationDrain, - session.client.syncApp(installablePath, { - install: true, - watch: false, - launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'None', - }), + syncCache.run( + installablePath, + 'android', + (basisCacheDir) => + session.client.syncApp(installablePath, { + basisCacheDir, + install: true, + watch: false, + launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'None', + }), + signal, + ), ); signal?.throwIfAborted(); const packageName = diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index 64dc81a15b..ffbc838bfe 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -1,3 +1,4 @@ +import type { LimrunSyncCache } from './sync-cache.ts'; import type { SettingOptions } from '@agent-device/contracts/settings'; import type { DeviceLease, @@ -31,6 +32,7 @@ export type LimrunIosSession = { appBundleId?: string; readonly ownership: LimrunInstanceOwnership; device: DeviceInfo; + syncCache?: LimrunSyncCache; client: LimrunIosClient; /** Instance bearer token; the recording download the SDK would run inline is done by the host instead. */ readonly token: string; @@ -82,6 +84,8 @@ export async function installLimrunIosApp( operationDrain?: LimrunRequestOperationDrain, ): Promise { signal?.throwIfAborted(); + const { LimrunSyncCache } = await import('./sync-cache.ts'); + const syncCache = (session.syncCache ??= new LimrunSyncCache()); const stat = await fs.promises.stat(installablePath); const appName = (stat.isDirectory() @@ -94,11 +98,18 @@ export async function installLimrunIosApp( // The shared deployment owner must retain the artifact until sync finishes reading it. const synced = await awaitLimrunDeploymentOperation( operationDrain, - session.client.syncApp(installablePath, { - install: true, - watch: false, - launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'None', - }), + syncCache.run( + installablePath, + 'ios', + (basisCacheDir) => + session.client.syncApp(installablePath, { + basisCacheDir, + install: true, + watch: false, + launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'None', + }), + signal, + ), ); signal?.throwIfAborted(); return synced.installedBundleId; diff --git a/packages/provider-limrun/src/lease-release.ts b/packages/provider-limrun/src/lease-release.ts index f8802d5679..0f8652fade 100644 --- a/packages/provider-limrun/src/lease-release.ts +++ b/packages/provider-limrun/src/lease-release.ts @@ -306,10 +306,15 @@ async function releaseLocalResources(owner: LimrunLeaseOwner): Promise { const session = owner.session ?? (await sessionTheCreateBuilt(owner)); if (session === undefined) return; const { awaitLimrunWithinBudget } = await import('./lifecycle-budget.ts'); - const platformResources = + const platformResources = Promise.allSettled([ + session.syncCache?.dispose(), session.platform === 'android' ? cleanupLimrunAndroidAdbTunnel(session) - : session.runner.dispose(); + : session.runner.dispose(), + ]).then((outcomes) => { + const refused = outcomes.find((outcome) => outcome.status === 'rejected'); + if (refused?.status === 'rejected') throw refused.reason; + }); // Runner and reverse cleanup use the connection, but cannot postpone disconnect beyond their budget. const disconnect = awaitLimrunWithinBudget(platformResources, LOCAL_TEARDOWN_BUDGET_MS).then( () => { diff --git a/packages/provider-limrun/src/sync-cache.test.ts b/packages/provider-limrun/src/sync-cache.test.ts new file mode 100644 index 0000000000..f9555c5189 --- /dev/null +++ b/packages/provider-limrun/src/sync-cache.test.ts @@ -0,0 +1,118 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { mkdtempForTest } from '@agent-device/test-kit'; +import { AppError } from '@agent-device/kernel/errors'; +import { LimrunSyncCache } from './sync-cache.ts'; + +test('materializations reuse one basis, prune removed resources, and release it with the lease', async () => { + const root = await mkdtempForTest('limrun-sync-test-'); + const cache = new LimrunSyncCache(); + let basis = ''; + for (const version of ['first', 'second']) { + const app = path.join(root, version, 'Example.app'); + await fs.mkdir(app, { recursive: true }); + await fs.writeFile(path.join(app, 'Executable'), version); + await cache.run(app, 'ios', async (directory) => { + if (basis) { + expect(directory).toBe(basis); + expect(await fs.readFile(path.join(directory, 'Executable'), 'utf8')).toBe('first'); + } + basis = directory; + await fs.writeFile(path.join(directory, 'Executable'), version); + await fs.writeFile(path.join(directory, 'removed-resource'), 'old'); + }); + expect(await fs.readdir(basis)).toEqual(['Executable']); + } + await cache.dispose(); + await expect(fs.stat(basis)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(cache.run(root, 'ios', async () => {})).rejects.toMatchObject({ + details: { reason: 'limrun_app_sync_retired' }, + }); +}); + +test('cache teardown waits for serialized APK syncs to finish reading their basis', async () => { + const root = await mkdtempForTest('limrun-sync-test-'); + const apk = path.join(root, 'Example.apk'); + await fs.writeFile(apk, 'apk'); + const cache = new LimrunSyncCache(); + let release!: () => void; + let started!: () => void; + const entered = new Promise((resolve) => { + started = resolve; + }); + const hold = new Promise((resolve) => { + release = resolve; + }); + let basis = ''; + const first = cache.run(apk, 'android', async (directory) => { + basis = directory; + await fs.copyFile(apk, path.join(directory, 'Example.apk')); + started(); + await hold; + expect(await fs.readFile(path.join(directory, 'Example.apk'), 'utf8')).toBe('apk'); + }); + await entered; + let secondRan = false; + const second = cache.run(apk, 'android', async () => { + secondRan = true; + }); + const disposal = cache.dispose(); + expect(secondRan).toBe(false); + release(); + await Promise.all([first, second, disposal]); + expect(secondRan).toBe(true); + await expect(fs.stat(basis)).rejects.toMatchObject({ code: 'ENOENT' }); +}); + +test.each([new Error('SDK refused sync'), new AppError('INVALID_ARGS', 'invalid input')])( + 'sync failures preserve typed errors or wrap the SDK cause and clear partial basis', + async (cause) => { + const cache = new LimrunSyncCache(); + let basis = ''; + const pending = cache.run('/unneeded.apk', 'android', async (directory) => { + basis = directory; + await fs.writeFile(path.join(directory, 'partial'), 'partial'); + throw cause; + }); + if (cause instanceof AppError) await expect(pending).rejects.toBe(cause); + else + await expect(pending).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'limrun_app_sync_failed', platform: 'android' }, + cause, + }); + await expect(fs.stat(basis)).rejects.toMatchObject({ code: 'ENOENT' }); + await cache.dispose(); + }, +); + +test('a canceled queued install never reaches the SDK after the previous install settles', async () => { + const root = await mkdtempForTest('limrun-sync-test-'); + const apk = path.join(root, 'Example.apk'); + await fs.writeFile(apk, 'apk'); + const cache = new LimrunSyncCache(); + let release!: () => void; + const hold = new Promise((resolve) => { + release = resolve; + }); + const first = cache.run(apk, 'android', async () => await hold); + const controller = new AbortController(); + let secondRan = false; + const second = cache.run( + apk, + 'android', + async () => { + secondRan = true; + }, + controller.signal, + ); + const reason = new Error('queued install canceled'); + const rejected = expect(second).rejects.toBe(reason); + controller.abort(reason); + release(); + await first; + await rejected; + expect(secondRan).toBe(false); + await cache.dispose(); +}); diff --git a/packages/provider-limrun/src/sync-cache.ts b/packages/provider-limrun/src/sync-cache.ts new file mode 100644 index 0000000000..bf1981660e --- /dev/null +++ b/packages/provider-limrun/src/sync-cache.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; + +/** One lease retains only its latest artifact's basis, even when materialization paths change. */ +export class LimrunSyncCache { + private directory: string | undefined; + private pending: Promise = Promise.resolve(); + private retired = false; + + async run( + installablePath: string, + platform: 'ios' | 'android', + sync: (basisCacheDir: string) => Promise, + signal?: AbortSignal, + ): Promise { + if (this.retired) { + throw new AppError('COMMAND_FAILED', 'Limrun app sync session has ended.', { + reason: 'limrun_app_sync_retired', + }); + } + const operation = this.pending.then(async () => { + signal?.throwIfAborted(); + try { + this.directory ??= await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-limrun-sync-')); + signal?.throwIfAborted(); + const result = await sync(this.directory); + // The SDK overwrites current files but retains files removed from the source artifact. + const source = await fs.stat(installablePath); + await pruneBasis( + this.directory, + source.isDirectory() ? installablePath : undefined, + source.isDirectory() ? undefined : path.basename(installablePath), + ); + return result; + } catch (error) { + if (this.directory) + await fs.rm(this.directory, { recursive: true, force: true }).catch(() => {}); + this.directory = undefined; + if (error instanceof AppError || (signal?.aborted && error === signal.reason)) throw error; + throw new AppError( + 'COMMAND_FAILED', + 'Limrun app sync failed.', + { + reason: 'limrun_app_sync_failed', + platform, + installablePath, + }, + error, + ); + } + }); + this.pending = operation.catch(() => {}); + return await operation; + } + + async dispose(): Promise { + this.retired = true; + await this.pending; + if (this.directory) await fs.rm(this.directory, { recursive: true, force: true }); + this.directory = undefined; + } +} + +async function pruneBasis(cache: string, source?: string, fileName?: string): Promise { + for (const entry of await fs.readdir(cache, { withFileTypes: true })) { + const cached = path.join(cache, entry.name); + const original = source ? path.join(source, entry.name) : undefined; + const stat = original + ? await fs.lstat(original).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }) + : undefined; + if (!stat && entry.name !== fileName) { + await fs.rm(cached, { recursive: true, force: true }); + } else if (entry.isDirectory()) { + if (stat?.isDirectory()) await pruneBasis(cached, original); + else await fs.rm(cached, { recursive: true, force: true }); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89a03acc55..87df0c58e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,6 +876,9 @@ importers: '@agent-device/platform-apple': specifier: workspace:* version: link:../platform-apple + '@agent-device/test-kit': + specifier: workspace:* + version: link:../test-kit '@limrun/api': specifier: ^0.69.1 version: 0.69.1(supports-color@7.2.0) diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts index 60a08c7d7c..01cd4afb99 100644 --- a/scripts/__tests__/bundled-license-notices.test.ts +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -357,3 +357,34 @@ test('output overrides write notices beside the bundle without creating the defa ); await assert.rejects(fs.stat(path.join(root, 'dist')), { code: 'ENOENT' }); }); + +test.each(['0.2.0', '0.2.1'])( + 'bundled xdelta WASM %s requires the matching retained license', + async (version) => { + const [sourceNotices, apacheLicense] = await Promise.all([ + fs.readFile(new URL('../../LICENSES/xdelta3-wasm.txt', import.meta.url), 'utf8'), + fs.readFile(new URL('../../LICENSES/Apache-2.0.txt', import.meta.url), 'utf8'), + ]); + const root = await fixture({ + 'input.ts': "export { value } from '@limrun/xdelta3-wasm';", + 'node_modules/@limrun/xdelta3-wasm/package.json': JSON.stringify({ + name: '@limrun/xdelta3-wasm', + version, + license: 'Apache-2.0', + main: 'index.js', + }), + 'node_modules/@limrun/xdelta3-wasm/index.js': + 'export const value = process.env.FIXTURE_NUMBER;', + 'LICENSES/xdelta3-wasm.txt': sourceNotices, + 'LICENSES/Apache-2.0.txt': apacheLicense, + }); + if (version !== '0.2.0') { + await assert.rejects(bundle(root), /Refresh LICENSES/); + return; + } + await bundle(root); + const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); + assert.ok(notices.includes('Copyright 2016 Joshua MacDonald')); + assert.ok(notices.includes(apacheLicense.trim())); + }, +); diff --git a/scripts/__tests__/fixtures/bundled-license-build.mts b/scripts/__tests__/fixtures/bundled-license-build.mts index 69604fabd2..1c24529d6b 100644 --- a/scripts/__tests__/fixtures/bundled-license-build.mts +++ b/scripts/__tests__/fixtures/bundled-license-build.mts @@ -22,6 +22,7 @@ await build({ 'distinct', 'unused', 'jpeg-js', + '@limrun/xdelta3-wasm', 'included-extra', 'included.extra', 'included_extra', diff --git a/scripts/lib/bundled-license-notices.ts b/scripts/lib/bundled-license-notices.ts index e390625fcb..e8a18ca8d9 100644 --- a/scripts/lib/bundled-license-notices.ts +++ b/scripts/lib/bundled-license-notices.ts @@ -106,7 +106,9 @@ function attributableDependency( const version = typeof manifest.version === 'string' ? manifest.version.trim() : ''; if (!version) throw new Error(`Bundled dependency ${name} is missing its version.`); const license = typeof manifest.license === 'string' ? manifest.license.trim() : ''; - const licenseText = readNamedFile(directory, /^licen[cs]e/i); + const licenseText = + readNamedFile(directory, /^licen[cs]e/i) ?? + (name === '@limrun/xdelta3-wasm' ? xdeltaLicenseText(root, version, license) : undefined); if (!license || !licenseText) { throw new Error( `Bundled dependency ${name}@${version} is missing its license or license text.`, @@ -139,3 +141,15 @@ function deduplicate(dependencies: readonly NoticeDependency[]): NoticeDependenc ).values(), ]; } + +function xdeltaLicenseText(root: string, version: string, license: string): string { + const notices = fs.readFileSync(path.join(root, 'LICENSES/xdelta3-wasm.txt'), 'utf8'); + if ( + notices.split(/\r?\n/, 1)[0] !== `@limrun/xdelta3-wasm ${version} source-file notices` || + license !== 'Apache-2.0' + ) + throw new Error( + `Refresh LICENSES/xdelta3-wasm.txt for bundled @limrun/xdelta3-wasm@${version}.`, + ); + return notices + '\n' + fs.readFileSync(path.join(root, 'LICENSES/Apache-2.0.txt'), 'utf8'); +} diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 8674b4c62c..2f3aeacc08 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { afterEach, test, vi } from 'vitest'; +import { afterEach, expect, test, vi } from 'vitest'; import type { ClientOptions } from '@limrun/api'; import { createInstanceClient as createAndroidInstanceClient } from '@limrun/api/instance-client'; import { createInstanceClient as createIosInstanceClient } from '@limrun/api/ios-client'; @@ -21,10 +21,6 @@ const limrunMockState = vi.hoisted(() => { const androidTunnelClose = vi.fn(); return { constructorOptions: [] as ClientOptions[], - 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', @@ -86,10 +82,6 @@ vi.mock('@limrun/api', () => ({ delete: limrunMockState.androidDelete, }; - readonly assets = { - getOrUpload: limrunMockState.assetsGetOrUpload, - }; - constructor(options: ClientOptions) { limrunMockState.constructorOptions.push(options); } @@ -445,6 +437,8 @@ function assertAndroidTunnelLifecycle(openUrl: string): void { } test('Limrun Android syncs direct local artifacts', async () => { + const apkPath = path.join(mkdtempForTestSync('limrun-install-'), 'app-debug.apk'); + fs.writeFileSync(apkPath, 'apk fixture'); const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', }); @@ -471,18 +465,15 @@ test('Limrun Android syncs direct local artifacts', async () => { 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 result = await runtime.installApp(allocatedDevice, 'com.example.android', apkPath); - assert.equal(limrunMockState.assetsGetOrUpload.mock.calls.length, 0); assert.equal(limrunMockState.androidSendAsset.mock.calls.length, 0); - assert.deepEqual(limrunMockState.androidSyncApp.mock.calls[0], [ - '/tmp/app-debug.apk', - { install: true, watch: false, launchMode: 'None' }, - ]); + expect(limrunMockState.androidSyncApp).toHaveBeenCalledWith(apkPath, { + install: true, + watch: false, + launchMode: 'None', + basisCacheDir: expect.any(String), + }); assert.deepEqual(result, { packageName: 'com.example.android', launchTarget: 'com.example.android', @@ -553,12 +544,13 @@ test('Limrun iOS syncs direct local artifacts', async () => { { relaunch: true }, ); - assert.equal(limrunMockState.assetsGetOrUpload.mock.calls.length, 0); assert.equal(limrunMockState.iosInstallApp.mock.calls.length, 0); - assert.deepEqual(limrunMockState.iosSyncApp.mock.calls[0], [ - ipaPath, - { install: true, watch: false, launchMode: 'RelaunchIfRunning' }, - ]); + expect(limrunMockState.iosSyncApp).toHaveBeenCalledWith(ipaPath, { + install: true, + watch: false, + launchMode: 'RelaunchIfRunning', + basisCacheDir: expect.any(String), + }); assert.deepEqual(result, { bundleId: 'com.example.ios', launchTarget: 'com.example.ios', @@ -935,7 +927,6 @@ test('Limrun without an API key refuses operations that need one', async () => { code: 'UNSUPPORTED_OPERATION', message: 'Creating an instance requires a Limrun API key.', }); - assert.equal(limrunMockState.assetsGetOrUpload.mock.calls.length, 0); } finally { await runtime.shutdown(); } diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 2fd5b21220..167ee745f8 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -628,7 +628,7 @@ Limrun direct-device flow: 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. - To drive an existing instance without the API key, set LIM_IOS_INSTANCE_URL and LIM_IOS_INSTANCE_TOKEN, or LIM_ANDROID_INSTANCE_URL, LIM_ANDROID_INSTANCE_TOKEN, and LIM_ANDROID_INSTANCE_ADB_URL, from the instance status before connect. agent-device then never creates or deletes that instance; install, and apps before the first open, still need LIMRUN_API_KEY. + To drive an existing instance without the API key, set LIM_IOS_INSTANCE_URL and LIM_IOS_INSTANCE_TOKEN, or LIM_ANDROID_INSTANCE_URL, LIM_ANDROID_INSTANCE_TOKEN, and LIM_ANDROID_INSTANCE_ADB_URL, from the instance status before connect. agent-device then never creates or deletes that instance; install and reinstall sync local app files without LIMRUN_API_KEY. Android still needs its ADB URL to connect. apps before the first open needs LIMRUN_API_KEY to list stored assets. Set LIMRUN_KEEP_ALIVE=1 to ping the instance every 30 seconds while a session is open, so an idle session does not hit the Limrun inactivity timeout. It is off by default. To capture network traffic, connect with --provider-network-logs --provider-network-hosts . artifacts and close write the HAR and print its path and any capture failures; the HAR holds raw headers and bodies, and captured traffic exits through the daemon host. agent-device apps diff --git a/src/vendor/limrun-delta-sync-omitted.ts b/src/vendor/limrun-delta-sync-omitted.ts deleted file mode 100644 index f85d577301..0000000000 --- a/src/vendor/limrun-delta-sync-omitted.ts +++ /dev/null @@ -1,3 +0,0 @@ -throw new Error( - 'agent-device omits @limrun/xdelta3-wasm, which Limrun loads only from client.syncApp. Restore it in tsdown.config.ts to use folder delta sync.', -); diff --git a/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts b/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts index 944a45922a..68b969ada3 100644 --- a/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts +++ b/test/integration/provider-scenarios/limrun-deployment-cancellation.test.ts @@ -5,10 +5,6 @@ import { } from './limrun-deployment-cancellation.fixtures.ts'; const state = vi.hoisted(() => ({ - assetsGetOrUpload: vi.fn(async (_input: unknown, _options: { signal?: AbortSignal }) => ({ - signedDownloadUrl: 'https://assets.example.test/app', - md5: 'asset-md5', - })), androidCreate: vi.fn(async () => ({ metadata: { id: 'limrun-android-instance' }, status: { @@ -45,7 +41,6 @@ vi.mock('@limrun/api', () => ({ list: vi.fn(async () => ({ getPaginatedItems: () => [] })), delete: state.iosDelete, }; - readonly assets = { getOrUpload: state.assetsGetOrUpload }; }, })); @@ -119,6 +114,7 @@ for (const platform of ['android', 'ios'] as const) { install: true, watch: false, launchMode: 'None', + basisCacheDir: expect.any(String), }); expect(deployment.events).toEqual([ ...(replaceExisting ? ['uninstall'] : []), @@ -127,7 +123,6 @@ for (const platform of ['android', 'ios'] as const) { 'cleanup', ]); expect(deployment.artifactExists()).toBe(false); - expect(state.assetsGetOrUpload).not.toHaveBeenCalled(); expect(state.iosCreate).not.toHaveBeenCalled(); expect(state.androidCreate).not.toHaveBeenCalled(); } finally { @@ -136,6 +131,22 @@ for (const platform of ['android', 'ios'] as const) { }); } + test(`Limrun ${platform} reports plain SDK sync errors with a typed reason`, async () => { + const cause = new Error('SDK sync refused'); + sync.mockRejectedValueOnce(cause); + const deployment = await bindLimrunDeployment(platform, new AbortController().signal); + try { + await expect(deployment.deploy()).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'limrun_app_sync_failed', platform }, + cause, + }); + expect(deployment.artifactExists()).toBe(false); + } finally { + await deployment.dispose(); + } + }); + for (const fail of [false, true]) { test(`Limrun ${platform} cancellation retains the materialized app until sync ${fail ? 'fails' : 'finishes'}`, async () => { const controller = new AbortController(); diff --git a/tsdown.config.ts b/tsdown.config.ts index 8b12613f80..c6456ede0b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,6 +1,5 @@ import { buildEntries } from './scripts/build-entries.ts'; import fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; import { defineConfig, type TsdownPluginOption } from 'tsdown'; import { bundledLicenseNotices } from './scripts/lib/bundled-license-notices.ts'; @@ -99,6 +98,7 @@ export default defineConfig({ alwaysBundle: [/^@agent-device\//], onlyBundle: [ '@limrun/api', + '@limrun/xdelta3-wasm', '@xmldom/xmldom', 'agent-base', 'b4a', @@ -148,15 +148,6 @@ export default defineConfig({ handler(level, log); }, }, - // Limrun loads `@limrun/xdelta3-wasm` only inside `client.syncApp`, which agent-device never - // calls, so 52 kB of base64 wasm would ship for a path nothing reaches. The alias resolves the - // specifier to a chunk that names the omission. Add the package to `deps.onlyBundle` and drop - // this alias to restore that path. - alias: { - '@limrun/xdelta3-wasm': fileURLToPath( - new URL('./src/vendor/limrun-delta-sync-omitted.ts', import.meta.url), - ), - }, format: 'esm', platform: 'node', target: 'es2022', diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 4ff5d6b2f7..26d5923dcf 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -47,7 +47,7 @@ On an attached Android instance, agent-device does not replace an existing port If your shell's account variables, or its instance variables for the platform you lease, differ from the ones the daemon started with, the first `install` or `open` refuses before it creates or attaches to an instance. See [Change credentials while the daemon runs](/docs/device-clouds#change-credentials-while-the-daemon-runs). -`install` and `reinstall` sync local app files directly to the instance. Attached instances need only their URL and token for these commands. `apps` before the first `open` needs `LIMRUN_API_KEY` to list stored assets. After `open`, `apps` lists the apps installed on the instance without the key. From the Node.js runtime, `getDeviceSession(device).installRemoteApp(url)` installs from a signed asset URL without the API key. +`install` and `reinstall` sync local app files directly to the instance. An attached iOS instance needs its URL and token. An attached Android instance also needs `LIM_ANDROID_INSTANCE_ADB_URL` to connect the provider; the app upload itself uses HTTP. `apps` before the first `open` needs `LIMRUN_API_KEY` to list stored assets. After `open`, `apps` lists the apps installed on the instance without the key. From the Node.js runtime, `getDeviceSession(device).installRemoteApp(url)` installs from a signed asset URL without the API key. ## Keep idle sessions alive From 2cf342868c76b5f7760ed12fc6333b64ba1adae3 Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Thu, 5 Nov 2026 13:08:55 +0300 Subject: [PATCH 3/5] chore(gates): align sync fixtures and remove the deleted vendor zone --- .../src/lease-release-observation.test.ts | 2 +- .../src/runtime-dependencies.fixtures.ts | 1 - .../src/runtime-dependencies.test.ts | 1 - packages/provider-limrun/src/runtime.test.ts | 1 - packages/provider-limrun/tsconfig.json | 23 +++++++++++++++---- scripts/layering/model.ts | 3 --- .../limrun-ios-appstate.test.ts | 2 +- .../limrun-ios-snapshot-owner.test.ts | 2 +- .../stale-provider-runtime-admission.test.ts | 1 - 9 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/provider-limrun/src/lease-release-observation.test.ts b/packages/provider-limrun/src/lease-release-observation.test.ts index f1beb07e1c..21176f9c70 100644 --- a/packages/provider-limrun/src/lease-release-observation.test.ts +++ b/packages/provider-limrun/src/lease-release-observation.test.ts @@ -59,7 +59,7 @@ vi.mock('@limrun/api', () => ({ await answered; }), }; - readonly assets = { getOrUpload: vi.fn(), list: vi.fn(async () => []) }; + readonly assets = { list: vi.fn(async () => []) }; }, })); diff --git a/packages/provider-limrun/src/runtime-dependencies.fixtures.ts b/packages/provider-limrun/src/runtime-dependencies.fixtures.ts index f35ce3d02d..427fc88aec 100644 --- a/packages/provider-limrun/src/runtime-dependencies.fixtures.ts +++ b/packages/provider-limrun/src/runtime-dependencies.fixtures.ts @@ -55,7 +55,6 @@ export function stubLimrunRuntimeDependencies(): LimrunRuntimeDependencies { }, host: { runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - downloadFile: async () => undefined, }, ios: { diff --git a/packages/provider-limrun/src/runtime-dependencies.test.ts b/packages/provider-limrun/src/runtime-dependencies.test.ts index 1add61ec61..6d32cebee0 100644 --- a/packages/provider-limrun/src/runtime-dependencies.test.ts +++ b/packages/provider-limrun/src/runtime-dependencies.test.ts @@ -61,7 +61,6 @@ vi.mock('@limrun/api', () => ({ }; readonly assets = { - getOrUpload: vi.fn(), list: state.assetList, }; diff --git a/packages/provider-limrun/src/runtime.test.ts b/packages/provider-limrun/src/runtime.test.ts index 021c06f341..f8c4369188 100644 --- a/packages/provider-limrun/src/runtime.test.ts +++ b/packages/provider-limrun/src/runtime.test.ts @@ -83,7 +83,6 @@ vi.mock('@limrun/api', () => ({ delete: state.androidDelete, }; readonly assets = { - getOrUpload: vi.fn(), list: vi.fn(async () => state.uploadedApps), }; }, diff --git a/packages/provider-limrun/tsconfig.json b/packages/provider-limrun/tsconfig.json index b9fa33399b..25a1e8040c 100644 --- a/packages/provider-limrun/tsconfig.json +++ b/packages/provider-limrun/tsconfig.json @@ -11,10 +11,23 @@ }, "include": ["src"], "references": [ - { "path": "../capture-kit" }, - { "path": "../contracts" }, - { "path": "../kernel" }, - { "path": "../platform-android" }, - { "path": "../platform-apple" } + { + "path": "../capture-kit" + }, + { + "path": "../contracts" + }, + { + "path": "../kernel" + }, + { + "path": "../platform-android" + }, + { + "path": "../platform-apple" + }, + { + "path": "../test-kit" + } ] } diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 4dd816b966..13ab2f2f8b 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -110,9 +110,6 @@ export function zoneRank(zone: string): number | null { // workspace-aware graphs. The platform packages additionally carry R13's // exact-family/composition/laziness policy. export const UNRANKED_ZONES: ReadonlySet = new Set([ - // Stand-ins the bundler resolves in place of a dependency it deliberately omits. Nothing in - // the production graph imports them, so ranking them would claim an edge the alias replaces. - 'vendor', 'kernel', 'host-kit', 'capture-kit', diff --git a/test/integration/provider-scenarios/limrun-ios-appstate.test.ts b/test/integration/provider-scenarios/limrun-ios-appstate.test.ts index 6d9edc0321..bb92521dc0 100644 --- a/test/integration/provider-scenarios/limrun-ios-appstate.test.ts +++ b/test/integration/provider-scenarios/limrun-ios-appstate.test.ts @@ -33,7 +33,7 @@ vi.mock('@limrun/api', () => ({ readonly androidInstances = { create: vi.fn(), list: vi.fn(), delete: vi.fn() }; - readonly assets = { getOrUpload: vi.fn(), list: vi.fn() }; + readonly assets = { list: vi.fn() }; }, })); diff --git a/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts b/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts index 0e2d47e30b..2d7b8e6691 100644 --- a/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts +++ b/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts @@ -43,7 +43,7 @@ vi.mock('@limrun/api', () => ({ delete: vi.fn(), }; - readonly assets = { getOrUpload: vi.fn(), list: vi.fn() }; + readonly assets = { list: vi.fn() }; }, })); diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts index 4c20742f11..6d3412f55a 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.test.ts @@ -48,7 +48,6 @@ vi.mock('@limrun/api', () => ({ delete: limrunState.androidDelete, }; readonly iosInstances = { create: vi.fn(), list: vi.fn(), delete: vi.fn() }; - readonly assets = { getOrUpload: vi.fn() }; }, NotFoundError: class NotFoundError extends Error {}, })); From e7bdceb4421dbe1ee387febcd079b2269dc0cb80 Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Thu, 5 Nov 2026 13:33:45 +0300 Subject: [PATCH 4/5] test(limrun): mock the public simctl execution interface --- .../limrun-ios-appstate.test.ts | 18 ++++++++---------- .../limrun-ios-snapshot-owner.test.ts | 15 ++++++++------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/test/integration/provider-scenarios/limrun-ios-appstate.test.ts b/test/integration/provider-scenarios/limrun-ios-appstate.test.ts index bb92521dc0..f02aebca1f 100644 --- a/test/integration/provider-scenarios/limrun-ios-appstate.test.ts +++ b/test/integration/provider-scenarios/limrun-ios-appstate.test.ts @@ -41,16 +41,14 @@ vi.mock('@limrun/api/ios-client', async (importOriginal) => { const actual = await importOriginal(); limrunApiState.simctl.mockImplementation((args: string[]) => { const execution = new actual.SimctlExecution(() => {}); - if (args[0] === 'list') { - execution._handleStdout( - Buffer.from( - JSON.stringify({ - devices: { ios: [{ udid: 'remote-udid', state: 'Booted' }] }, - }), - ), - ); - } - execution._handleExit(0); + vi.spyOn(execution, 'wait').mockResolvedValue({ + code: 0, + stdout: + args[0] === 'list' + ? JSON.stringify({ devices: { ios: [{ udid: 'remote-udid', state: 'Booted' }] } }) + : '', + stderr: '', + }); return execution; }); return { diff --git a/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts b/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts index 2d7b8e6691..001fbf058e 100644 --- a/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts +++ b/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts @@ -57,13 +57,14 @@ vi.mock('@limrun/api/ios-client', async (importOriginal) => { const actual = await importOriginal(); limrunApiState.simctl.mockImplementation((args: string[]) => { const execution = new actual.SimctlExecution(() => {}); - if (args[0] === 'list') - execution._handleStdout( - Buffer.from( - JSON.stringify({ devices: { ios: [{ udid: 'remote-udid', state: 'Booted' }] } }), - ), - ); - execution._handleExit(0); + vi.spyOn(execution, 'wait').mockResolvedValue({ + code: 0, + stdout: + args[0] === 'list' + ? JSON.stringify({ devices: { ios: [{ udid: 'remote-udid', state: 'Booted' }] } }) + : '', + stderr: '', + }); return execution; }); return { From d2c9b9fc16e221ecd68e0bc7d3358a0a2c57a7dc Mon Sep 17 00:00:00 2001 From: Muvaffak Onus Date: Thu, 5 Nov 2026 13:43:02 +0300 Subject: [PATCH 5/5] test(limrun): harden sync coverage and deadline fixtures --- .../src/launch-confirmation.test.ts | 40 ++++++++++++++----- .../src/request-cancellation.ts | 2 +- .../provider-limrun/src/sync-cache.test.ts | 3 +- .../__tests__/bundled-license-notices.test.ts | 18 +++++---- src/commands/schema/cli-help.ts | 2 +- 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/packages/platform-apple/src/launch-confirmation.test.ts b/packages/platform-apple/src/launch-confirmation.test.ts index 1c8df0f03e..219acac848 100644 --- a/packages/platform-apple/src/launch-confirmation.test.ts +++ b/packages/platform-apple/src/launch-confirmation.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest'; +import { setTimeout as realDelay } from 'node:timers/promises'; import { DEFAULT_ALERT_TIMEOUT_MS } from '@agent-device/contracts/alert-contract'; import { awaitAppleAlert, readAppleAlert } from './alert.ts'; import { runAppleRunnerCommand } from './core/runner-client.ts'; @@ -218,17 +219,20 @@ test.each([ */ function wedgedCoreSimulator( answers: Readonly> = {}, + onSpawn?: (count: number) => void, ): AppleToolProvider & { spawns: () => number } { let spawns = 0; const answered = (key: string): ExecResult | undefined => { const stdout = answers[key]; if (stdout === undefined) return undefined; spawns += 1; + onSpawn?.(spawns); return { stdout, stderr: '', exitCode: 0 } as ExecResult; }; const hang = async (options?: ExecOptions): Promise => await new Promise((_resolve, reject) => { spawns += 1; + onSpawn?.(spawns); if (options?.signal?.aborted) { reject(options.signal.reason); return; @@ -257,14 +261,6 @@ function wedgedCoreSimulator( }; } -/** Real I/O turns until the provider saw `count` spawns; bounded so a lookup that stops early fails here, not at the test timeout. */ -async function untilSpawned(coreSimulator: { spawns: () => number }, count: number): Promise { - for (let turn = 0; turn < 1000 && coreSimulator.spawns() < count; turn += 1) { - await new Promise((resolve) => setImmediate(resolve)); - } - expect(coreSimulator.spawns()).toBe(count); -} - const LISTED_APP = JSON.stringify({ 'com.example.app': { ApplicationType: 'User', @@ -286,21 +282,42 @@ test.each([ // spawns plutil, and that I/O must complete before the budget is advanced past. vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); vi.mocked(emitDiagnostic).mockClear(); + const lookupAbort = new AbortController(); try { const { interactor, acceptAlert } = confirmationRunner(); - const coreSimulator = wedgedCoreSimulator(answers); + let signalLookupStarted: () => void; + const lookupStarted = new Promise((resolve) => { + signalLookupStarted = resolve; + }); + const coreSimulator = wedgedCoreSimulator(answers, (count) => { + if (count === 2) signalLookupStarted(); + }); let answered: unknown = 'pending'; const answer = withAppleToolProvider(coreSimulator, async () => answerSimulatorLaunchConfirmation( simulator, { url: 'example://automation', appBundleId: 'com.example.app' }, Promise.resolve(interactor), - new AbortController().signal, + lookupAbort.signal, undefined, ).then((value) => (answered = value)), ); - await untilSpawned(coreSimulator, 2); + const ioWait = new AbortController(); + try { + await Promise.race([ + lookupStarted, + answer.then(() => { + throw new Error('Owner lookup completed before the second command started'); + }), + realDelay(1_000, undefined, { signal: ioWait.signal }).then(() => { + throw new Error('Owner lookup did not start its second command'); + }), + ]); + } finally { + ioWait.abort(); + } + expect(coreSimulator.spawns()).toBe(2); await vi.advanceTimersByTimeAsync(URL_OWNER_LOOKUP_TIMEOUT_MS + 1); expect(answered).toEqual({ outcome: 'unreadable', step: 'url-owner' }); await answer; @@ -311,6 +328,7 @@ test.each([ }), ); } finally { + lookupAbort.abort(); vi.useRealTimers(); } }, diff --git a/packages/provider-limrun/src/request-cancellation.ts b/packages/provider-limrun/src/request-cancellation.ts index ebf869a65d..c21aa29753 100644 --- a/packages/provider-limrun/src/request-cancellation.ts +++ b/packages/provider-limrun/src/request-cancellation.ts @@ -1,6 +1,6 @@ import { awaitWithSignal } from '@agent-device/kernel/await-with-signal'; /** - * The Limrun SDK does not expose an AbortSignal parameter. Keep the request-bound + * The instance-client operations wrapped here do not accept an AbortSignal. Keep the request-bound * caller cancellable while retaining a handled continuation for the provider operation it started. */ export async function awaitLimrunOperation( diff --git a/packages/provider-limrun/src/sync-cache.test.ts b/packages/provider-limrun/src/sync-cache.test.ts index f9555c5189..8e16ed5fe0 100644 --- a/packages/provider-limrun/src/sync-cache.test.ts +++ b/packages/provider-limrun/src/sync-cache.test.ts @@ -54,7 +54,8 @@ test('cache teardown waits for serialized APK syncs to finish reading their basi }); await entered; let secondRan = false; - const second = cache.run(apk, 'android', async () => { + const second = cache.run(apk, 'android', async (directory) => { + expect(await fs.readFile(path.join(directory, 'Example.apk'), 'utf8')).toBe('apk'); secondRan = true; }); const disposal = cache.dispose(); diff --git a/scripts/__tests__/bundled-license-notices.test.ts b/scripts/__tests__/bundled-license-notices.test.ts index 01cd4afb99..c7c3ca7e16 100644 --- a/scripts/__tests__/bundled-license-notices.test.ts +++ b/scripts/__tests__/bundled-license-notices.test.ts @@ -358,9 +358,13 @@ test('output overrides write notices beside the bundle without creating the defa await assert.rejects(fs.stat(path.join(root, 'dist')), { code: 'ENOENT' }); }); -test.each(['0.2.0', '0.2.1'])( - 'bundled xdelta WASM %s requires the matching retained license', - async (version) => { +test.each([ + { version: '0.2.0', newline: '\n', lineEnding: 'LF' }, + { version: '0.2.0', newline: '\r\n', lineEnding: 'CRLF' }, + { version: '0.2.1', newline: '\n', lineEnding: 'LF' }, +])( + 'bundled xdelta WASM $version requires matching complete notices with $lineEnding line endings', + async ({ version, newline }) => { const [sourceNotices, apacheLicense] = await Promise.all([ fs.readFile(new URL('../../LICENSES/xdelta3-wasm.txt', import.meta.url), 'utf8'), fs.readFile(new URL('../../LICENSES/Apache-2.0.txt', import.meta.url), 'utf8'), @@ -375,8 +379,8 @@ test.each(['0.2.0', '0.2.1'])( }), 'node_modules/@limrun/xdelta3-wasm/index.js': 'export const value = process.env.FIXTURE_NUMBER;', - 'LICENSES/xdelta3-wasm.txt': sourceNotices, - 'LICENSES/Apache-2.0.txt': apacheLicense, + 'LICENSES/xdelta3-wasm.txt': sourceNotices.replaceAll('\r\n', '\n').replaceAll('\n', newline), + 'LICENSES/Apache-2.0.txt': apacheLicense.replaceAll('\r\n', '\n').replaceAll('\n', newline), }); if (version !== '0.2.0') { await assert.rejects(bundle(root), /Refresh LICENSES/); @@ -384,7 +388,7 @@ test.each(['0.2.0', '0.2.1'])( } await bundle(root); const notices = await fs.readFile(path.join(root, 'dist/THIRD_PARTY_NOTICES.txt'), 'utf8'); - assert.ok(notices.includes('Copyright 2016 Joshua MacDonald')); - assert.ok(notices.includes(apacheLicense.trim())); + assert.ok(notices.includes(sourceNotices.replaceAll('\r\n', '\n').trim())); + assert.ok(notices.includes(apacheLicense.replaceAll('\r\n', '\n').trim())); }, ); diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 167ee745f8..d45619b352 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -628,7 +628,7 @@ Limrun direct-device flow: 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. - To drive an existing instance without the API key, set LIM_IOS_INSTANCE_URL and LIM_IOS_INSTANCE_TOKEN, or LIM_ANDROID_INSTANCE_URL, LIM_ANDROID_INSTANCE_TOKEN, and LIM_ANDROID_INSTANCE_ADB_URL, from the instance status before connect. agent-device then never creates or deletes that instance; install and reinstall sync local app files without LIMRUN_API_KEY. Android still needs its ADB URL to connect. apps before the first open needs LIMRUN_API_KEY to list stored assets. + To drive an existing instance without the API key, set LIM_IOS_INSTANCE_URL and LIM_IOS_INSTANCE_TOKEN, or LIM_ANDROID_INSTANCE_URL, LIM_ANDROID_INSTANCE_TOKEN, and LIM_ANDROID_INSTANCE_ADB_URL, from the instance status before connect. agent-device then never creates or deletes that instance; install and reinstall sync local app files without LIMRUN_API_KEY. Android still needs its ADB URL to connect. The apps command before the first open requires LIMRUN_API_KEY to list stored assets. Set LIMRUN_KEEP_ALIVE=1 to ping the instance every 30 seconds while a session is open, so an idle session does not hit the Limrun inactivity timeout. It is off by default. To capture network traffic, connect with --provider-network-logs --provider-network-hosts . artifacts and close write the HAR and print its path and any capture failures; the HAR holds raw headers and bodies, and captured traffic exits through the daemon host. agent-device apps