diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index b3c319ce16..0fda6fd0c9 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -17,6 +17,7 @@ import { DEFAULT_TIMEOUT_POLICY, FOLD_TIMEOUT_POLICY, INSTALL_REQUEST_TIMEOUT_MS, + INSTALL_SOURCE_REQUEST_TIMEOUT_MS, LEASE_ALLOCATE_REQUEST_TIMEOUT_MS, PREPARE_REQUEST_TIMEOUT_MS, } from './timeout-policy.ts'; @@ -347,8 +348,6 @@ const PRESERVE_DAEMON_TIMEOUT_POLICY: CommandTimeoutPolicy = { onTimeout: 'preserve-daemon', }; -// Installs run long device subprocesses; their envelope stays above the longest -// platform install subprocess timeout (see INSTALL_REQUEST_TIMEOUT_MS). const INSTALL_TIMEOUT_POLICY: CommandTimeoutPolicy = { ...DEFAULT_TIMEOUT_POLICY, envelopeMs: INSTALL_REQUEST_TIMEOUT_MS, @@ -979,7 +978,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ recordingEffect: 'mutates-app', daemon: { route: 'session', refFrameEffect: 'may-invalidate' }, platformExecution: { kind: 'device-runtime', use: readyMaterializeAndDeployAppUse }, - timeoutPolicy: INSTALL_TIMEOUT_POLICY, + timeoutPolicy: { ...INSTALL_TIMEOUT_POLICY, envelopeMs: INSTALL_SOURCE_REQUEST_TIMEOUT_MS }, batchable: false, }, { @@ -1649,7 +1648,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ recordsSessionAction: true, recordingEffect: 'mutates-app', platformExecution: { kind: 'device-runtime', use: readyMaterializeAndDeployAppUse }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + timeoutPolicy: { ...INSTALL_TIMEOUT_POLICY, envelopeMs: INSTALL_SOURCE_REQUEST_TIMEOUT_MS }, batchable: true, }, diff --git a/packages/command-registry/src/timeout-policy.ts b/packages/command-registry/src/timeout-policy.ts index 2c2d950d22..2052e103dc 100644 --- a/packages/command-registry/src/timeout-policy.ts +++ b/packages/command-registry/src/timeout-policy.ts @@ -6,9 +6,12 @@ import type { CommandTimeoutBudget, CommandTimeoutPolicy } from './types.ts'; const DAEMON_REQUEST_TIMEOUT_MS = 90_000; -// Keep this above the longest platform install subprocess timeout so the client -// envelope does not abort a still-progressing device install first. -export const INSTALL_REQUEST_TIMEOUT_MS = 180_000; +// Covers the install/reinstall/uninstall route's worst-case ledger plus the daemon-result margin. +// Proven by the ledger test: `test/integration/provider-scenarios/install-timeout-ledger.test.ts`. +export const INSTALL_REQUEST_TIMEOUT_MS = 330_000; + +// Adds room for install-from-source URL materialization before the same platform install ledgers. +export const INSTALL_SOURCE_REQUEST_TIMEOUT_MS = 420_000; // Margin over a daemon-side budget so the daemon's own timeout result (with // diagnostics) wins the race against the client envelope. Never shrinks the diff --git a/packages/contracts/src/platform-runtime-host.ts b/packages/contracts/src/platform-runtime-host.ts index 09c05cd422..25965b035c 100644 --- a/packages/contracts/src/platform-runtime-host.ts +++ b/packages/contracts/src/platform-runtime-host.ts @@ -101,7 +101,7 @@ export type AndroidToolHost = Readonly<{ installPackage( device: DeviceInfo, packagePath: string, - options: Readonly<{ replace: boolean }>, + options: Readonly<{ replace: boolean; timeoutMs?: number }>, signal?: AbortSignal, ): Promise; /** Returns false when the selected transport has no bundle installer. */ @@ -109,6 +109,7 @@ export type AndroidToolHost = Readonly<{ device: DeviceInfo, bundlePath: string, mode: string, + options?: Readonly<{ timeoutMs?: number }>, signal?: AbortSignal, ): Promise; }>; diff --git a/packages/platform-android/src/adb-transport.ts b/packages/platform-android/src/adb-transport.ts index 2b170e4522..640ef60a78 100644 --- a/packages/platform-android/src/adb-transport.ts +++ b/packages/platform-android/src/adb-transport.ts @@ -133,7 +133,7 @@ export type AndroidAdbInstaller = ( export type AndroidBundleInstaller = ( bundlePath: string, - options: Readonly<{ mode: string; signal?: AbortSignal }>, + options: Readonly<{ mode: string; signal?: AbortSignal; timeoutMs?: number }>, ) => Promise; export type AndroidTextInputAction = 'type' | 'fill'; diff --git a/packages/platform-android/src/deployment/native.test.ts b/packages/platform-android/src/deployment/native.test.ts index 0efffd5b1d..7c9aa6755e 100644 --- a/packages/platform-android/src/deployment/native.test.ts +++ b/packages/platform-android/src/deployment/native.test.ts @@ -39,6 +39,7 @@ function hostFixture(options: { bundletool?: boolean; bundletoolJar?: string } = executable: 'adb-install', args: [packagePath], replace: options.replace, + timeoutMs: options.timeoutMs, }, signal, ), @@ -73,6 +74,7 @@ test('constructs a signal-bound adb install in the Android package', async () => executable: 'adb-install', args: ['/tmp/app.apk'], replace: true, + timeoutMs: 120_000, }), signal, ); @@ -98,9 +100,14 @@ test('owns bundletool command construction and temporary output cleanup', async await installAndroidArtifact(host, device, '/tmp/app.aab', 'com.example.app', signal); - expect(run.mock.calls.map(([request]) => request.args[0])).toEqual([ - 'build-apks', - 'install-apks', + expect( + run.mock.calls.map(([request]) => ({ + command: request.args[0], + timeoutMs: request.timeoutMs, + })), + ).toEqual([ + { command: 'build-apks', timeoutMs: 120_000 }, + { command: 'install-apks', timeoutMs: 120_000 }, ]); expect(run.mock.calls.every(([, actualSignal]) => actualSignal === signal)).toBe(true); expect(dispose).toHaveBeenCalledOnce(); diff --git a/packages/platform-android/src/deployment/native.ts b/packages/platform-android/src/deployment/native.ts index 39dcffc5c3..68e9ab8e82 100644 --- a/packages/platform-android/src/deployment/native.ts +++ b/packages/platform-android/src/deployment/native.ts @@ -5,6 +5,9 @@ import { deviceShellArgv, type ShellWord } from '@agent-device/kernel/device-she import { AppError } from '@agent-device/kernel/errors'; import path from 'node:path'; +const ANDROID_APP_INSTALL_TIMEOUT_MS = 120_000; +const ANDROID_APP_DEPLOYMENT_ADB_TIMEOUT_MS = 15_000; + export async function installAndroidArtifact( host: PlatformRuntimeHost, device: DeviceInfo, @@ -19,7 +22,7 @@ export async function installAndroidArtifact( const result = await host.androidTools.installPackage( device, installablePath, - { replace: true }, + { replace: true, timeoutMs: ANDROID_APP_INSTALL_TIMEOUT_MS }, signal, ); assertCommandSuccess(result, 'adb install failed'); @@ -36,7 +39,13 @@ export async function uninstallAndroidPackage( packageName: string, signal: AbortSignal, ): Promise { - const result = await runAdb(host, device, ['uninstall', packageName], signal); + const result = await runAdb( + host, + device, + ['uninstall', packageName], + signal, + ANDROID_APP_DEPLOYMENT_ADB_TIMEOUT_MS, + ); if (result.exitCode === 0) return; const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); if (output.includes('unknown package') || output.includes('not installed')) return; @@ -100,7 +109,16 @@ async function installAndroidBundle( bundlePath: string, signal: AbortSignal, ): Promise { - if (await host.androidTools.installBundle(device, bundlePath, 'universal', signal)) return; + if ( + await host.androidTools.installBundle( + device, + bundlePath, + 'universal', + { timeoutMs: ANDROID_APP_INSTALL_TIMEOUT_MS }, + signal, + ) + ) + return; const bundletool = await host.commands.which('bundletool'); const jar = host.androidDeployment.bundletoolJar; if (!bundletool && !jar) { @@ -131,6 +149,7 @@ async function installAndroidBundle( '--mode', 'universal', ], + timeoutMs: ANDROID_APP_INSTALL_TIMEOUT_MS, }, signal, ), @@ -141,6 +160,7 @@ async function installAndroidBundle( { executable, args: [...prefix, 'install-apks', '--apks', apksPath, '--device-id', device.id], + timeoutMs: ANDROID_APP_INSTALL_TIMEOUT_MS, }, signal, ), @@ -189,7 +209,7 @@ async function runAdb( device: DeviceInfo, args: readonly string[], signal: AbortSignal, - timeoutMs = 15_000, + timeoutMs = ANDROID_APP_DEPLOYMENT_ADB_TIMEOUT_MS, ) { return await host.androidTools.runAdb(device, args, { timeoutMs, allowFailure: true }, signal); } diff --git a/packages/platform-apple/src/core/config.ts b/packages/platform-apple/src/core/config.ts index a233326e01..2694084d41 100644 --- a/packages/platform-apple/src/core/config.ts +++ b/packages/platform-apple/src/core/config.ts @@ -9,6 +9,7 @@ export const IOS_DEVICECTL_TIMEOUT_MS = 20_000; export const IOS_DEVICECTL_MIN_TIMEOUT_SECONDS = 5; export const IOS_DEVICE_INSTALL_TIMEOUT_MS = 120_000; +export const IOS_APP_UNINSTALL_TIMEOUT_MS = 20_000; export const IOS_SIMULATOR_SHOW_TIMEOUT_MS = 10_000; diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index a1439c9c0d..5e2daa4d6c 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -18,7 +18,7 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { requireExecSuccess } from '@agent-device/host-kit/command'; import { isMissingAppErrorOutput } from '../core/physical-device-apps.ts'; -import { IOS_DEVICE_INSTALL_TIMEOUT_MS } from '../core/config.ts'; +import { IOS_APP_UNINSTALL_TIMEOUT_MS, IOS_DEVICE_INSTALL_TIMEOUT_MS } from '../core/config.ts'; import { IOS_DEVICECTL_DEFAULT_HINT, resolveIosDevicectlHint } from '../core/devicectl.ts'; import { ensureAppleReady } from '../readiness/runtime.ts'; import { scopeSimctlArgsForDevice } from '../core/simctl.ts'; @@ -178,6 +178,7 @@ async function installAppleApp( ? { tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['install', device.id, installablePath]), + timeoutMs: IOS_DEVICE_INSTALL_TIMEOUT_MS, } : { tool: 'devicectl', @@ -202,10 +203,12 @@ async function uninstallAppleApp( ? { tool: 'simctl', args: scopeSimctlArgsForDevice(device, ['uninstall', device.id, bundleId]), + timeoutMs: IOS_APP_UNINSTALL_TIMEOUT_MS, } : { tool: 'devicectl', args: ['device', 'uninstall', 'app', '--device', device.id, bundleId], + timeoutMs: IOS_APP_UNINSTALL_TIMEOUT_MS, }, signal, `Apple app uninstall failed for ${bundleId}`, diff --git a/packages/provision-kit/src/install-source.ts b/packages/provision-kit/src/install-source.ts index 136e68e94a..e4d058f9d7 100644 --- a/packages/provision-kit/src/install-source.ts +++ b/packages/provision-kit/src/install-source.ts @@ -45,7 +45,7 @@ const INTERNAL_ARCHIVE_EXTENSIONS = ['.zip', '.tar', '.tar.gz', '.tgz'] as const * @public Archive extensions accepted by install-source resolution. */ export const ARCHIVE_EXTENSIONS = Object.freeze([...INTERNAL_ARCHIVE_EXTENSIONS] as const); -const DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000; +export const INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000; export async function materializeInstallablePath( options: MaterializeInstallableOptions, @@ -124,7 +124,7 @@ async function downloadToTempFile( if (requestSignal?.aborted) { throw createRequestCanceledError(); } - const timeoutSignal = AbortSignal.timeout(DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS); + const timeoutSignal = AbortSignal.timeout(INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS); const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal; try { return await downloadInstallSource({ @@ -139,7 +139,7 @@ async function downloadToTempFile( error, requestSignal, timeoutSignal, - DEFAULT_SOURCE_DOWNLOAD_TIMEOUT_MS, + INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS, ); } } diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index 3b1bad5492..a75a2d4f6b 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -145,10 +145,13 @@ test('request envelopes deviating from the default are bounded, reviewed sets', const EXPECTED_ENVELOPES: Record = { // prepare: daemon-side runner budget (PREPARE_STARTUP_BUDGET_MS) plus the daemon-result margin. prepare: 270_000, - install: 180_000, - reinstall: 180_000, - uninstall: 180_000, - install_source: 180_000, + // install family: proven by the worst-case ledger test in + // test/integration/provider-scenarios/install-timeout-ledger.test.ts. + install: 330_000, + reinstall: 330_000, + uninstall: 330_000, + 'install-from-source': 420_000, + install_source: 420_000, longpress: 210_000, // fold: proven by the worst-case ledger test in // test/integration/provider-scenarios/ios-fold.test.ts. @@ -309,15 +312,15 @@ test('snapshot uses the standard daemon request timeout with an explicit overrid ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('install'), { ...base }), - 180_000, + 330_000, ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('reinstall'), { ...base }), - 180_000, + 330_000, ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('install_source'), { ...base }), - 180_000, + 420_000, ); assert.equal( resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('prepare'), { diff --git a/src/mcp/__tests__/command-tools-timeout-note.test.ts b/src/mcp/__tests__/command-tools-timeout-note.test.ts index 228ae3309e..eb71f2e688 100644 --- a/src/mcp/__tests__/command-tools-timeout-note.test.ts +++ b/src/mcp/__tests__/command-tools-timeout-note.test.ts @@ -37,5 +37,6 @@ test('every MCP tool description declares its timeout envelope', () => { test('the declared envelopes carry the known tier values', () => { const descriptions = new Map(listCommandTools().map((tool) => [tool.name, tool.description])); assert.match(descriptions.get('devices') ?? '', / Times out after 90s\.$/); - assert.match(descriptions.get('install') ?? '', / Times out after 180s\.$/); + assert.match(descriptions.get('install') ?? '', / Times out after 330s\.$/); + assert.match(descriptions.get('install-from-source') ?? '', / Times out after 420s\.$/); }); diff --git a/src/platform-runtime-android-tool-host.ts b/src/platform-runtime-android-tool-host.ts index c05b0e74c5..a94be05326 100644 --- a/src/platform-runtime-android-tool-host.ts +++ b/src/platform-runtime-android-tool-host.ts @@ -44,9 +44,14 @@ export function createAndroidToolHost(): AndroidToolHost { const { resolveAndroidAdbProvider } = await loadAndroidMechanics(); const provider = resolveAndroidAdbProvider(device); const result = provider.install - ? await provider.install(packagePath, { replace: options.replace, signal }) + ? await provider.install(packagePath, { + replace: options.replace, + signal, + timeoutMs: options.timeoutMs, + }) : await provider.exec(['install', ...(options.replace ? ['-r'] : []), packagePath], { signal, + timeoutMs: options.timeoutMs, }); return { stdout: result.stdout, @@ -54,11 +59,11 @@ export function createAndroidToolHost(): AndroidToolHost { exitCode: result.exitCode, }; }, - installBundle: async (device, bundlePath, mode, signal) => { + installBundle: async (device, bundlePath, mode, options, signal) => { const { resolveAndroidAdbProvider } = await loadAndroidMechanics(); const installer = resolveAndroidAdbProvider(device).installBundle; if (!installer) return false; - await installer(bundlePath, { mode, signal }); + await installer(bundlePath, { mode, signal, timeoutMs: options?.timeoutMs }); return true; }, }); diff --git a/test/integration/provider-scenarios/install-timeout-ledger.test.ts b/test/integration/provider-scenarios/install-timeout-ledger.test.ts new file mode 100644 index 0000000000..677760b705 --- /dev/null +++ b/test/integration/provider-scenarios/install-timeout-ledger.test.ts @@ -0,0 +1,522 @@ +import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; +import { resolveCommandRequestTimeoutMs } from '@agent-device/command-registry/timeout-policy'; +import { + withCommandExecutorOverride, + type CommandExecutorOverride, +} from '@agent-device/host-kit/command'; +import type { AndroidAdbProvider } from '@agent-device/platform-android/mechanics'; +import type { AppleToolProvider } from '@agent-device/platform-apple/tool-provider'; +import { INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS } from '@agent-device/provision-kit/install-source'; +import * as networkTransport from '@agent-device/provision-kit/install-source-network-transport'; +import assert from 'node:assert/strict'; +import dns from 'node:dns/promises'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { test, vi } from 'vitest'; +import { + PROVIDER_SCENARIO_ANDROID, + PROVIDER_SCENARIO_IOS_DEVICE, + PROVIDER_SCENARIO_IOS_SIMULATOR, + createDemoIosApp, +} from './fixtures.ts'; +import { + createProviderScenarioHarness, + type ClosableProviderScenarioResource, + type ProviderScenarioHarness, +} from './harness.ts'; + +type InstallCommand = 'install' | 'reinstall' | 'install_source'; + +type InstallLedgerCall = Readonly<{ + tool: 'adb' | 'adb-install' | 'bundletool' | 'download' | 'open' | 'simctl' | 'devicectl'; + args: readonly string[]; + timeoutMs: number; +}>; + +type InstallRoute = + | 'iOS physical device' + | 'iOS simulator' + | 'Android APK' + | 'Android AAB fallback'; + +type InstallLedgerClock = Readonly<{ + advanceByLedgerTimeout(timeoutMs: number): void; +}>; + +type InstallLedgerScenario = ClosableProviderScenarioResource & + Readonly<{ + appPath: string; + clock: InstallLedgerClock; + daemon: ProviderScenarioHarness; + ledger: InstallLedgerCall[]; + selection: Readonly< + { platform: 'android'; serial: string } | { platform: 'ios'; udid: string } + >; + }>; + +const REQUIRED_DAEMON_RESULT_MARGIN_MS = 30_000; +// The native deployment unit asserts this is the timeout passed to bundletool; the fake +// executable can only report argv back across the daemon HTTP boundary. +const ANDROID_BUNDLETOOL_TIMEOUT_MS = 120_000; +let nextAppleScenarioId = 0; + +for (const route of [ + 'iOS physical device', + 'iOS simulator', + 'Android APK', + 'Android AAB fallback', +] as const) { + test(`install-family request envelope covers the ${route} worst-case ledger`, async () => { + for (const command of ['install', 'reinstall', 'install_source'] as const) { + await withInstallLedgerScenario(route, async (scenario) => { + await runInstallCommand(scenario, command); + assertInstallLedgerPhaseCoverage(route, command, scenario.ledger); + assertLedgerFitsEnvelope(route, command, scenario.ledger); + }); + } + }, 20_000); +} + +test('install_source request envelope covers URL download before the Android AAB fallback ledger', async () => { + await withInstallLedgerScenario('Android AAB fallback', async (scenario) => { + await runInstallSourceFromUrlCommand(scenario); + assertInstallLedgerPhaseCoverage('Android AAB fallback', 'install_source', scenario.ledger); + assert.ok( + scenario.ledger.some((call) => call.tool === 'download'), + `install_source URL ledger is missing the download phase.\nLedger:\n${JSON.stringify( + scenario.ledger, + null, + 2, + )}`, + ); + assertLedgerFitsEnvelope('Android AAB fallback URL source', 'install_source', scenario.ledger); + }); +}, 20_000); + +async function withInstallLedgerScenario( + route: InstallRoute, + run: (scenario: InstallLedgerScenario) => Promise, +): Promise { + let virtualNowMs = 1_700_000_000_000; + const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => virtualNowMs); + const clock: InstallLedgerClock = { + advanceByLedgerTimeout: (timeoutMs) => { + virtualNowMs += Math.max(0, timeoutMs - 1); + }, + }; + const scenario = isAndroidRoute(route) + ? await createAndroidScenario(route, clock) + : await createIosScenario(route, clock); + try { + return await run(scenario); + } finally { + try { + await scenario.close(); + } finally { + dateNow.mockRestore(); + } + } +} + +async function runInstallCommand( + scenario: InstallLedgerScenario, + command: InstallCommand, +): Promise { + const client = scenario.daemon.client(); + if (command === 'install') { + await client.apps.install({ + app: 'com.example.demo', + appPath: scenario.appPath, + ...scenario.selection, + }); + return; + } + if (command === 'reinstall') { + await client.apps.reinstall({ + app: 'com.example.demo', + appPath: scenario.appPath, + ...scenario.selection, + }); + return; + } + await client.apps.installFromSource({ + source: { kind: 'path', path: scenario.appPath }, + ...scenario.selection, + }); +} + +async function runInstallSourceFromUrlCommand(scenario: InstallLedgerScenario): Promise { + const lookupMock = vi + .spyOn(dns, 'lookup') + .mockImplementation( + async () => + [{ address: '93.184.216.34', family: 4 }] as unknown as Awaited< + ReturnType + >, + ); + const requestMock = vi + .spyOn(networkTransport, 'requestApprovedUrl') + .mockImplementation(async (request): Promise => { + recordLedgerCall( + scenario.ledger, + 'download', + [request.url.toString()], + INSTALL_SOURCE_DOWNLOAD_TIMEOUT_MS, + scenario.clock, + ); + return { + statusCode: 200, + headers: {}, + body: Readable.from([fs.readFileSync(scenario.appPath)]), + close: async () => {}, + }; + }); + try { + await scenario.daemon.client().apps.installFromSource({ + source: { kind: 'url', url: 'https://artifacts.example.com/Demo.aab' }, + ...scenario.selection, + }); + } finally { + requestMock.mockRestore(); + lookupMock.mockRestore(); + } +} + +async function createIosScenario( + route: 'iOS physical device' | 'iOS simulator', + clock: InstallLedgerClock, +): Promise { + const ledger: InstallLedgerCall[] = []; + const scenarioId = ++nextAppleScenarioId; + const device = + route === 'iOS physical device' + ? { + ...PROVIDER_SCENARIO_IOS_DEVICE, + id: `${PROVIDER_SCENARIO_IOS_DEVICE.id}-${scenarioId}`, + iosPhysicalDeviceBackend: 'coredevice' as const, + } + : { + ...PROVIDER_SCENARIO_IOS_SIMULATOR, + id: `sim-install-ledger-${scenarioId}`, + booted: false, + }; + const { tempRoot, appPath } = createDemoIosApp('agent-device-install-ledger-ios-'); + const commandOverride = createInstallLedgerHostCommandOverride(ledger, clock); + const daemon = await withCommandExecutorOverride( + commandOverride, + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [device], + appleToolProvider: () => + createInstallLedgerAppleToolProvider(route, device.id, ledger, clock), + }), + ); + return { + appPath, + clock, + daemon, + ledger, + selection: { platform: 'ios', udid: device.id }, + close: async () => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + await daemon.close(); + }, + }; +} + +function createInstallLedgerAppleToolProvider( + route: 'iOS physical device' | 'iOS simulator', + deviceId: string, + ledger: InstallLedgerCall[], + clock: InstallLedgerClock, +): AppleToolProvider { + const ok = { stdout: '', stderr: '', exitCode: 0 }; + let simulatorBooted = false; + return { + whichCommand: async () => true, + runCommand: async (cmd, args) => { + throw new Error(`Unscripted Apple install ledger host call: ${cmd} ${args.join(' ')}`); + }, + plist: { + readJson: async () => ({ + CFBundleIdentifier: 'com.example.demo', + CFBundleName: 'Demo', + }), + }, + simctl: { + run: async (args, options) => { + recordLedgerCall(ledger, 'simctl', args, options?.timeoutMs, clock); + if (args.includes('list')) { + return { + stdout: JSON.stringify({ + devices: { + 'iOS 18.5': [{ udid: deviceId, state: simulatorBooted ? 'Booted' : 'Shutdown' }], + }, + }), + stderr: '', + exitCode: 0, + }; + } + if (args.includes('boot')) { + simulatorBooted = true; + return ok; + } + assert.ok( + args.includes('bootstatus') || args.includes('install') || args.includes('uninstall'), + args.join(' '), + ); + return ok; + }, + }, + devicectl: { + run: async (args, options) => { + recordLedgerCall(ledger, 'devicectl', args, options?.timeoutMs, clock); + if (args.includes('details')) { + writeCoreDeviceDetailsJson(args); + return ok; + } + assert.equal(route, 'iOS physical device'); + assert.ok(args.includes('install') || args.includes('uninstall'), args.join(' ')); + return ok; + }, + }, + }; +} + +function writeCoreDeviceDetailsJson(args: readonly string[]): void { + const jsonOutputIndex = args.indexOf('--json-output'); + const jsonPath = jsonOutputIndex >= 0 ? args[jsonOutputIndex + 1] : undefined; + assert.ok(jsonPath, `devicectl details call did not include --json-output: ${args.join(' ')}`); + fs.writeFileSync( + jsonPath, + JSON.stringify({ + result: { + connectionProperties: { tunnelState: 'connected', tunnelIPAddress: '127.0.0.1' }, + deviceProperties: { + bootState: 'booted', + developerModeStatus: 'enabled', + ddiServicesAvailable: true, + }, + }, + }), + ); +} + +async function createAndroidScenario( + route: 'Android APK' | 'Android AAB fallback', + clock: InstallLedgerClock, +): Promise { + const ledger: InstallLedgerCall[] = []; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-install-ledger-android-')); + const appPath = path.join(tempRoot, route === 'Android APK' ? 'Demo.apk' : 'Demo.aab'); + const bundletoolPath = path.join(tempRoot, 'bundletool'); + const bundletoolLedgerPath = path.join(tempRoot, 'bundletool-ledger.jsonl'); + fs.writeFileSync(appPath, 'placeholder apk'); + writeBundletoolLedgerScript(bundletoolPath); + fs.chmodSync(bundletoolPath, 0o755); + let installed = false; + let syncedBundletoolCallCount = 0; + const syncBundletoolLedger = () => { + if (!fs.existsSync(bundletoolLedgerPath)) return; + const lines = fs.readFileSync(bundletoolLedgerPath, 'utf8').split('\n').filter(Boolean); + for (; syncedBundletoolCallCount < lines.length; syncedBundletoolCallCount += 1) { + const line = lines[syncedBundletoolCallCount]; + assert.ok(line !== undefined); + const parsedArgs = JSON.parse(line) as unknown; + assert.ok(Array.isArray(parsedArgs) && parsedArgs.every((arg) => typeof arg === 'string')); + const bundletoolArgs = parsedArgs as string[]; + recordLedgerCall(ledger, 'bundletool', bundletoolArgs, ANDROID_BUNDLETOOL_TIMEOUT_MS, clock); + if (bundletoolArgs.includes('install-apks')) installed = true; + } + }; + const provider: AndroidAdbProvider = { + exec: async (args, options) => { + syncBundletoolLedger(); + recordLedgerCall(ledger, 'adb', args, options?.timeoutMs, clock); + const key = args.join(' '); + if (key === 'shell pm list packages') { + return { + stdout: installed ? 'package:com.example.demo\n' : '', + stderr: '', + exitCode: 0, + }; + } + if (key === 'uninstall com.example.demo') { + installed = false; + return { stdout: 'Success\n', stderr: '', exitCode: 0 }; + } + throw new Error(`Unscripted Android install ledger adb call: ${key}`); + }, + install: async (apkPath, options) => { + recordLedgerCall(ledger, 'adb-install', [apkPath], options?.timeoutMs, clock); + installed = true; + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + const commandOverride = createInstallLedgerHostCommandOverride(ledger, clock, { + onBundletoolInstallApks: () => { + installed = true; + }, + }); + const daemon = await withCommandExecutorOverride( + commandOverride, + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + env: { + AGENT_DEVICE_BUNDLETOOL_LEDGER: bundletoolLedgerPath, + PATH: `${tempRoot}${path.delimiter}${process.env.PATH ?? ''}`, + }, + }), + ); + return { + appPath, + clock, + daemon, + ledger, + selection: { platform: 'android', serial: PROVIDER_SCENARIO_ANDROID.id }, + close: async () => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + await daemon.close(); + }, + }; +} + +function writeBundletoolLedgerScript(bundletoolPath: string): void { + fs.writeFileSync( + bundletoolPath, + [ + '#!/usr/bin/env node', + "const fs = require('node:fs');", + 'const ledger = process.env.AGENT_DEVICE_BUNDLETOOL_LEDGER;', + String.raw`if (ledger) fs.appendFileSync(ledger, JSON.stringify(process.argv.slice(2)) + '\n');`, + ].join('\n'), + ); +} + +function isAndroidRoute(route: InstallRoute): route is 'Android APK' | 'Android AAB fallback' { + return route.startsWith('Android'); +} + +function createInstallLedgerHostCommandOverride( + ledger: InstallLedgerCall[], + clock: InstallLedgerClock, + hooks: Readonly<{ onBundletoolInstallApks?: () => void }> = {}, +): CommandExecutorOverride { + return (command, args, options) => { + if (command === 'open') { + recordLedgerCall(ledger, 'open', args, options.timeoutMs, clock); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + if (command === 'bundletool' || command === 'java') { + recordLedgerCall(ledger, 'bundletool', args, options.timeoutMs, clock); + if (args.includes('install-apks')) hooks.onBundletoolInstallApks?.(); + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }); + } + return undefined; + }; +} + +function recordLedgerCall( + ledger: InstallLedgerCall[], + tool: InstallLedgerCall['tool'], + args: readonly string[], + timeoutMs: number | undefined, + clock: InstallLedgerClock, +): void { + assert.ok( + Number.isFinite(timeoutMs) && timeoutMs! > 0, + `install ledger call has no bounded timeoutMs: ${tool} ${args.join(' ')}`, + ); + ledger.push({ tool, args: [...args], timeoutMs: timeoutMs! }); + clock.advanceByLedgerTimeout(timeoutMs!); +} + +function assertLedgerFitsEnvelope( + route: string, + command: InstallCommand, + ledger: readonly InstallLedgerCall[], +): void { + const virtualElapsedMs = ledger.reduce( + (total, call) => total + Math.max(0, call.timeoutMs - 1), + 0, + ); + const envelopeMs = resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy(command), { + positionals: [], + flags: {}, + }); + assert.ok(envelopeMs !== undefined, `${command} must declare a bounded envelope`); + assert.ok( + virtualElapsedMs + REQUIRED_DAEMON_RESULT_MARGIN_MS <= envelopeMs, + `${route} ${command} ledger worst case (${virtualElapsedMs}ms) + ` + + `${REQUIRED_DAEMON_RESULT_MARGIN_MS}ms margin exceeds the ${envelopeMs}ms envelope.\n` + + `Ledger:\n${JSON.stringify(ledger, null, 2)}`, + ); +} + +function assertInstallLedgerPhaseCoverage( + route: InstallRoute, + command: InstallCommand, + ledger: readonly InstallLedgerCall[], +): void { + assert.ok(ledger.length > 0, `${route} ${command} ledger recorded no calls`); + assert.ok( + ledger.some( + (call) => call.args.some((arg) => arg.includes('install')) || call.tool === 'adb-install', + ), + `${route} ${command} ledger is missing the install phase.\nLedger:\n${JSON.stringify( + ledger, + null, + 2, + )}`, + ); + if (command === 'reinstall') { + assert.ok( + ledger.some((call) => call.args.includes('uninstall')), + `${route} reinstall ledger is missing the uninstall phase`, + ); + } + if (route === 'iOS physical device') { + assert.ok( + ledger.some((call) => call.args.includes('details')), + `${route} ${command} ledger is missing the readiness phase`, + ); + } + if (route === 'iOS simulator') { + assert.ok( + ledger.some((call) => call.args.includes('list')), + `${route} ${command} ledger is missing the boot-state probe phase`, + ); + assert.ok( + ledger.some((call) => call.args.includes('boot')), + `${route} ${command} ledger is missing the simulator boot phase`, + ); + assert.ok( + ledger.some((call) => call.args.includes('bootstatus')), + `${route} ${command} ledger is missing the simulator bootstatus phase`, + ); + assert.ok( + ledger.some((call) => call.tool === 'open' && call.args.includes('Simulator')), + `${route} ${command} ledger is missing the Simulator.app reveal phase`, + ); + } + if (isAndroidRoute(route) && (command === 'install' || command === 'install_source')) { + assert.ok( + ledger.some((call) => call.args.join(' ') === 'shell pm list packages'), + `${route} ${command} ledger is missing the package-inventory phase`, + ); + } + if (route === 'Android AAB fallback') { + assert.ok( + ledger.some((call) => call.args.includes('build-apks')), + `${route} ${command} ledger is missing the bundle build phase`, + ); + assert.ok( + ledger.some((call) => call.args.includes('install-apks')), + `${route} ${command} ledger is missing the bundle install phase`, + ); + } +}