diff --git a/CONTEXT.md b/CONTEXT.md index def5bde310..d1ac350e7a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -411,6 +411,13 @@ axis and stages a deeper platform-runtime seam, one abandonment-safe command cut that earned ADR 0019's staged platform-runtime migration. Its substrate plus complete `devices`/`logs`/`network` checkpoint must validate the seam before any further command migration. These are ratchets, not permission to scaffold façades before a real seam has two adapters. +- Platform package boundary (R13). ADR 0019 has exactly six private + `@agent-device/platform-*` package façades and one root composition file, + `src/platform-runtime.ts`. R13 pins that total registration, forbids contracts-to-platform, + sibling-platform, root/daemon, and raw-process edges in every import form (including tests), and + keeps package façades metadata-eager but inventory/runtime mechanics lazy. Composition cannot probe tools, prepare + assets, or construct helpers. Each rule has a planted-red structural case; R11 still owns the + general workspace exports/dependency boundary. - Zero-dep CI jobs (R8). Some jobs run scripts straight from a checkout with `install-deps: false`, so they have no `node_modules`. Nothing local can feel that constraint — every dev machine has `node_modules` sitting right there — so a script grows a package import, passes locally, and fails diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index cbc555b5ab..690df344ea 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -9,6 +9,11 @@ app-log disposal contract without changing `close`'s legacy platform-execution o command's platform adapter must change, this Status must name its complete unit before substrate work begins. Broader migration requires this Status to record **continue**. +During the `devices` unit, doctor discovery, replay-test sharding, Apple simulator hints, and Android +emulator lifecycle keep their existing command execution owners while consuming the same injected, +neutral inventory capability. That coexistence moves discovery mechanics once; it does not migrate +those descriptors or authorize a second local/provider chooser. + ## Rules at a glance - Daemon device-execution code depends on platform-neutral contracts. Concrete device mechanics diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 12fbcf8d26..f224a99b39 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -140,9 +140,10 @@ hand-maintained path map: left to CI by `--run` so a root checkout never installs Expo dependencies implicitly. - **Always-on gates** (`lint`, `typecheck`, `layering`, `fallow`, `format`) fire - for their input categories and are never silently skipped. Platform source - also selects the provider-integration and coverage gates required by the - Testing Matrix. + for their input categories and are never silently skipped. Legacy + `src/platforms/` source also selects provider-integration and coverage. + `packages/platform-*` source selects the shared runtime-contract unit lane, + provider-integration, and coverage so a package move cannot narrow its evidence. - **Commands** are resolved from real `package.json` scripts, so a renamed script fails loudly instead of dropping a gate. - A **small explicit build-ownership layer** covers the paths whose owning build diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index a933268e0b..bd81e6ddcb 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -374,11 +374,6 @@ "count": 1 } }, - "src/platforms/apple/core/devices.ts": { - "crap_moderate": { - "count": 1 - } - }, "src/platforms/apple/core/perf.ts": { "complexity_high": { "count": 1 diff --git a/package.json b/package.json index 0df2ae35bf..67899eb7e1 100644 --- a/package.json +++ b/package.json @@ -127,10 +127,10 @@ "fallow:baseline": "fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary && fallow health --report-only --save-baseline fallow-baselines/health.json --summary", "check:fallow": "fallow audit", "check:affected": "node --experimental-strip-types scripts/check-affected/run.ts", - "check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts", + "check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/platform-packages.test.ts scripts/check-affected/run.test.ts", "check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts", "check:coverage-changed:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts", - "check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts", + "check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/platform-package-policy.test.ts scripts/layering/platform-package-repository.test.ts scripts/layering/platform-package-source-policy.test.ts scripts/layering/device-inventory-cutover-policy.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts", "depgraph": "node --experimental-strip-types scripts/depgraph/build.ts", "depgraph:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues", @@ -149,7 +149,7 @@ "check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "pnpm check:mcp-metadata && pnpm package:npm", - "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/ad-script packages/selectors packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", + "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", "test-app:install": "pnpm install --dir examples/test-app", "test-app:start": "pnpm --dir examples/test-app start", "test-app:ios": "pnpm --dir examples/test-app ios", @@ -173,7 +173,7 @@ "test:output-economy": "vitest run --project output-economy", "test:smoke:web": "pnpm build && node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-web-platform.test.ts", "test:smoke": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-*.test.ts", - "test:integration:node": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/*.test.ts", + "test:integration:node": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test --test-concurrency=1 test/integration/*.test.ts", "test:integration": "pnpm test:integration:node && pnpm test:integration:provider", "test:concurrency-torture": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/nightly/concurrency-torture.test.ts", "test:replay:ios": "node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator", @@ -257,6 +257,12 @@ "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*", "@agent-device/maestro": "workspace:*", + "@agent-device/platform-android": "workspace:*", + "@agent-device/platform-apple": "workspace:*", + "@agent-device/platform-harmonyos": "workspace:*", + "@agent-device/platform-linux": "workspace:*", + "@agent-device/platform-vega": "workspace:*", + "@agent-device/platform-web": "workspace:*", "@agent-device/provider-limrun": "workspace:*", "@agent-device/provider-webdriver": "workspace:*", "@agent-device/replay-test": "workspace:*", diff --git a/packages/contracts/src/android-adb-failure.test.ts b/packages/contracts/src/android-adb-failure.test.ts new file mode 100644 index 0000000000..85e059f7e1 --- /dev/null +++ b/packages/contracts/src/android-adb-failure.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { classifyAndroidAdbFailure } from './android-adb-failure.ts'; + +test('Android ADB failures keep transport on stderr and install verdicts on stdout', () => { + assert.equal( + classifyAndroidAdbFailure("adb server version (40) doesn't match this client (41); killing...") + ?.reason, + 'server_version_mismatch', + ); + assert.equal(classifyAndroidAdbFailure('', 'log line: device offline detected'), undefined); + assert.equal( + classifyAndroidAdbFailure('', 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]')?.reason, + 'install_update_incompatible', + ); +}); diff --git a/packages/contracts/src/android-adb-failure.ts b/packages/contracts/src/android-adb-failure.ts new file mode 100644 index 0000000000..3c10b1b0bf --- /dev/null +++ b/packages/contracts/src/android-adb-failure.ts @@ -0,0 +1,117 @@ +type AndroidAdbFailureReason = + | 'timeout' + | 'device_offline' + | 'device_unauthorized' + | 'device_not_found' + | 'multiple_devices' + | 'no_devices' + | 'connection_dropped' + | 'server_version_mismatch' + | 'install_insufficient_storage' + | 'install_update_incompatible' + | 'install_version_downgrade' + | 'install_failed'; + +export type AndroidAdbFailureClassification = Readonly<{ + /** Machine-readable failure family attached to error details as `adbFailure`. */ + reason: AndroidAdbFailureReason; + hint: string; + /** Present only when an unchanged retry can succeed. */ + retriable?: boolean; +}>; + +type AndroidAdbFailureMatcher = AndroidAdbFailureClassification & + Readonly<{ + pattern: RegExp; + /** Android package-manager install verdicts may be emitted on stdout. */ + matchStdout?: boolean; + }>; + +const ANDROID_ADB_FAILURE_MATCHERS: readonly AndroidAdbFailureMatcher[] = [ + { + reason: 'device_unauthorized', + pattern: /device unauthorized|device still authorizing/, + hint: 'USB debugging is not authorized — accept the authorization prompt on the device screen (re-plug the cable if none appears), then retry.', + }, + { + reason: 'device_offline', + pattern: /device offline/, + hint: 'The device is connected but offline — wait for it to finish booting or run adb reconnect, then retry.', + retriable: true, + }, + { + reason: 'multiple_devices', + pattern: /more than one (?:device\/emulator|device and emulator)/, + hint: 'Multiple Android devices are connected — pass --serial (see adb devices) to select one.', + }, + { + reason: 'no_devices', + pattern: /no devices\/emulators found|no devices found/, + hint: 'No Android devices detected — boot an emulator or connect a device and verify it appears in adb devices.', + }, + { + reason: 'device_not_found', + pattern: /device (?:'[^']*' )?not found/, + hint: 'The device disconnected or is restarting — verify it is listed in adb devices, then retry.', + retriable: true, + }, + { + reason: 'server_version_mismatch', + pattern: /adb server version \(\d+\) doesn't match this client/, + hint: 'Multiple adb installs conflict — adb restarts its server automatically, so retry; align PATH to a single adb to stop recurrences.', + retriable: true, + }, + { + reason: 'connection_dropped', + pattern: /transport error|connection reset|broken pipe|protocol fault/, + hint: 'The adb connection dropped — retry; if it persists, run adb kill-server and reconnect the device.', + retriable: true, + }, + { + reason: 'install_insufficient_storage', + pattern: /install_failed_insufficient_storage/, + hint: 'The device is out of storage — free up space or uninstall unused apps, then retry the install.', + matchStdout: true, + }, + { + reason: 'install_update_incompatible', + pattern: /install_failed_update_incompatible/, + hint: 'The installed app has an incompatible signature — uninstall the existing app first, then retry the install.', + matchStdout: true, + }, + { + reason: 'install_version_downgrade', + pattern: /install_failed_version_downgrade/, + hint: 'The APK is older than the installed app — uninstall the app first (or install with downgrade allowed), then retry.', + matchStdout: true, + }, + { + reason: 'install_failed', + pattern: /install_failed_\w+|install_parse_failed_\w+/, + hint: 'The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.', + matchStdout: true, + }, +]; + +export const ANDROID_ADB_TIMEOUT_FAILURE: AndroidAdbFailureClassification = Object.freeze({ + reason: 'timeout', + hint: 'adb timed out — the adb server may be wedged. Run adb kill-server && adb start-server, check adb devices, then retry.', +}); + +/** + * Classifies transport failures from stderr. Package-manager install verdicts + * additionally inspect stdout because adb emits those semantic results there. + */ +export function classifyAndroidAdbFailure( + stderr: string, + stdout = '', +): AndroidAdbFailureClassification | undefined { + const stderrText = stderr.toLowerCase(); + const stdoutText = stdout.toLowerCase(); + for (const { pattern, matchStdout, ...classification } of ANDROID_ADB_FAILURE_MATCHERS) { + if (pattern.test(stderrText) || (matchStdout && pattern.test(stdoutText))) { + return classification; + } + } + return undefined; +} diff --git a/packages/contracts/src/android-device.test.ts b/packages/contracts/src/android-device.test.ts new file mode 100644 index 0000000000..aedd93b64c --- /dev/null +++ b/packages/contracts/src/android-device.test.ts @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { isAndroidEmulatorSerial, normalizeAndroidDeviceName } from './android-device.ts'; + +test('Android device identity normalizes AVD names and recognizes running emulator serials', () => { + assert.equal(normalizeAndroidDeviceName(' Pixel_9 Pro '), 'pixel 9 pro'); + assert.equal(isAndroidEmulatorSerial('emulator-5554'), true); + assert.equal(isAndroidEmulatorSerial('R58M123ABC'), false); +}); diff --git a/packages/contracts/src/android-device.ts b/packages/contracts/src/android-device.ts new file mode 100644 index 0000000000..9c89522e80 --- /dev/null +++ b/packages/contracts/src/android-device.ts @@ -0,0 +1,9 @@ +const ANDROID_EMULATOR_SERIAL_PREFIX = 'emulator-'; + +export function isAndroidEmulatorSerial(serial: string): boolean { + return serial.startsWith(ANDROID_EMULATOR_SERIAL_PREFIX); +} + +export function normalizeAndroidDeviceName(value: string): string { + return value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); +} diff --git a/packages/contracts/src/command-platform-execution.test.ts b/packages/contracts/src/command-platform-execution.test.ts new file mode 100644 index 0000000000..a0cf329923 --- /dev/null +++ b/packages/contracts/src/command-platform-execution.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'vitest'; +import { inventoryUse } from './platform-module.ts'; +import { assertCommandPlatformExecution } from './command-platform-execution.ts'; + +describe('command platform execution declaration', () => { + test.each([ + { kind: 'legacy' }, + { kind: 'inventory', use: inventoryUse }, + { kind: 'device-runtime', use: { required: ['capture'], preferred: ['inspect'] } }, + ])('accepts one closed execution shape: %j', (value) => { + expect(() => assertCommandPlatformExecution(value)).not.toThrow(); + }); + + test.each([ + {}, + { kind: 'legacy', use: inventoryUse }, + { kind: 'inventory' }, + { kind: 'inventory', use: inventoryUse, legacy: true }, + { kind: 'device-runtime', use: { required: [], preferred: [] }, inventory: true }, + { kind: 'device-runtime', use: { required: ['capture', 'capture'], preferred: [] } }, + { kind: 'device-runtime', use: { required: ['capture'], preferred: ['capture'] } }, + ])('rejects neither, mixed, widened, duplicate, or overlapping declarations: %j', (value) => { + expect(() => assertCommandPlatformExecution(value)).toThrow(/exactly one/); + }); +}); diff --git a/packages/contracts/src/command-platform-execution.ts b/packages/contracts/src/command-platform-execution.ts new file mode 100644 index 0000000000..e28699eac1 --- /dev/null +++ b/packages/contracts/src/command-platform-execution.ts @@ -0,0 +1,79 @@ +import type { InventoryUse } from './platform-module.ts'; + +/** Serializable operation requirements owned by a runtime-backed command descriptor. */ +export type RuntimeUseDeclaration = Readonly<{ + required: readonly string[]; + preferred: readonly string[]; +}>; + +export type CommandPlatformExecution = + | Readonly<{ kind: 'legacy' }> + | Readonly<{ kind: 'inventory'; use: InventoryUse }> + | Readonly<{ kind: 'device-runtime'; use: RuntimeUseDeclaration }>; + +// The discriminated union cannot prove uniqueness or required/preferred disjointness inside +// readonly arrays. Validate those declaration invariants where descriptors enter the registry. +export function assertCommandPlatformExecution( + value: unknown, +): asserts value is CommandPlatformExecution { + if (value === null || typeof value !== 'object') throw invalidPlatformExecution(); + const declaration = value as Record; + const keys = Object.keys(declaration).sort(); + if (declaration['kind'] === 'legacy' && sameKeys(keys, ['kind'])) return; + if ( + declaration['kind'] === 'inventory' && + sameKeys(keys, ['kind', 'use']) && + hasExactInventoryUse(declaration['use']) + ) { + return; + } + if ( + declaration['kind'] === 'device-runtime' && + sameKeys(keys, ['kind', 'use']) && + hasRuntimeUseDeclaration(declaration['use']) + ) { + return; + } + throw invalidPlatformExecution(); +} + +function hasExactInventoryUse(value: unknown): boolean { + if (value === null || typeof value !== 'object') return false; + const use = value as Record; + return use['kind'] === 'device-inventory' && sameKeys(Object.keys(use).sort(), ['kind']); +} + +function hasRuntimeUseDeclaration(value: unknown): boolean { + if (value === null || typeof value !== 'object') return false; + const use = value as Record; + const required = stringArray(use['required']); + const preferred = stringArray(use['preferred']); + if (!required || !preferred) return false; + if (!hasUniqueValues(required) || !hasUniqueValues(preferred)) return false; + if (!areDisjoint(required, preferred)) return false; + return sameKeys(Object.keys(use).sort(), ['preferred', 'required']); +} + +function stringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + return value.every((key): key is string => typeof key === 'string') ? value : null; +} + +function hasUniqueValues(values: readonly string[]): boolean { + return new Set(values).size === values.length; +} + +function areDisjoint(left: readonly string[], right: readonly string[]): boolean { + const leftValues = new Set(left); + return right.every((value) => !leftValues.has(value)); +} + +function sameKeys(actual: readonly string[], expected: readonly string[]): boolean { + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function invalidPlatformExecution(): TypeError { + return new TypeError( + 'Command platform execution must declare exactly one of legacy, inventory, or device-runtime', + ); +} diff --git a/packages/contracts/src/device-inventory.test.ts b/packages/contracts/src/device-inventory.test.ts index 7b12071cfb..a6bdd9ab8a 100644 --- a/packages/contracts/src/device-inventory.test.ts +++ b/packages/contracts/src/device-inventory.test.ts @@ -3,6 +3,7 @@ import { test } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { countDeviceInventoryByGroup, + filterDeviceInventoryProjection, LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS, } from './device-inventory.ts'; @@ -33,3 +34,53 @@ test('inventory counts Vega as its own platform family', () => { assert.deepEqual(counts.vega, { available: 1, booted: 1 }); assert.deepEqual(counts.android, { available: 0, booted: 0 }); }); + +test('local inventory projection filters device kind and fresh boot state', () => { + const devices: DeviceInfo[] = [ + { + platform: 'apple', + appleOs: 'ios', + target: 'mobile', + id: 'booted', + name: 'Booted iPhone', + kind: 'simulator', + booted: true, + }, + { + platform: 'apple', + appleOs: 'ios', + target: 'mobile', + id: 'shutdown', + name: 'Shutdown iPhone', + kind: 'simulator', + booted: false, + }, + { + platform: 'apple', + appleOs: 'ios', + target: 'mobile', + id: 'physical', + name: 'Physical iPhone', + kind: 'device', + booted: true, + }, + { + platform: 'apple', + appleOs: 'tvos', + target: 'tv', + id: 'booted-tv', + name: 'Booted Apple TV', + kind: 'simulator', + booted: true, + }, + ]; + + assert.deepEqual( + filterDeviceInventoryProjection(devices, { + target: 'mobile', + kind: 'simulator', + booted: true, + }).map((device) => device.id), + ['booted'], + ); +}); diff --git a/packages/contracts/src/device-inventory.ts b/packages/contracts/src/device-inventory.ts index 45cd52263a..1d3c3bbde5 100644 --- a/packages/contracts/src/device-inventory.ts +++ b/packages/contracts/src/device-inventory.ts @@ -1,7 +1,9 @@ import { isIosFamily, isMacOs, + matchesPlatformSelector, type DeviceInfo, + type DeviceKind, type DeviceTarget, type PlatformSelector, } from '@agent-device/kernel/device'; @@ -26,8 +28,20 @@ export type DeviceInventoryRequest = { clientId?: string; iosSimulatorSetPath?: string; androidSerialAllowlist?: string[]; + /** Internal local-inventory projection filters; not public command grammar. */ + kind?: DeviceKind; + booted?: boolean; }; +export type ProviderDeviceInventoryRequest = Omit; + +export function projectProviderDeviceInventoryRequest( + request: Readonly, +): ProviderDeviceInventoryRequest { + const { booted: _booted, kind: _kind, ...providerRequest } = request; + return providerRequest; +} + export type DeviceInventoryGroup = 'android' | 'harmonyos' | 'apple' | 'vega' | 'linux' | 'web'; export type DeviceInventoryGroupCounts = Record< DeviceInventoryGroup, @@ -53,13 +67,16 @@ export function countDeviceInventoryByGroup(devices: DeviceInfo[]): DeviceInvent return counts; } -export function shouldUseHostMacFastPath(selector: { - platform?: PlatformSelector; - target?: DeviceTarget; -}): boolean { - return ( - selector.platform === 'macos' || - (selector.platform === 'apple' && selector.target === 'desktop') +export function filterDeviceInventoryProjection( + devices: readonly DeviceInfo[], + request: Pick, +): DeviceInfo[] { + return devices.filter( + (device) => + matchesPlatformSelector(device, request.platform) && + (request.target === undefined || (device.target ?? 'mobile') === request.target) && + (request.kind === undefined || device.kind === request.kind) && + (request.booted === undefined || device.booted === request.booted), ); } diff --git a/packages/contracts/src/device-provider.ts b/packages/contracts/src/device-provider.ts index 4570031880..6558536fa4 100644 --- a/packages/contracts/src/device-provider.ts +++ b/packages/contracts/src/device-provider.ts @@ -7,7 +7,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { LeaseBackend } from '@agent-device/kernel/contracts'; -import type { DeviceInventoryRequest } from './device-inventory.ts'; +import type { ProviderDeviceInventoryRequest } from './device-inventory.ts'; export type DeviceLease = { leaseId: string; @@ -43,5 +43,18 @@ export type LeaseLifecycleProvider = { }; export type DeviceInventoryProvider = ( - request: DeviceInventoryRequest, + request: ProviderDeviceInventoryRequest, + signal?: AbortSignal, ) => Promise; + +export type ProviderDeviceInventoryOutcome = + | Readonly<{ kind: 'declined' }> + | Readonly<{ kind: 'inventory'; devices: readonly DeviceInfo[] }>; + +/** Closed provider-owned inventory source; an empty inventory is authoritative. */ +export type ProviderDeviceInventorySource = Readonly<{ + discover( + request: Readonly, + signal: AbortSignal, + ): Promise; +}>; diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index 44143e18c7..f0ae86763a 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -1,4 +1,7 @@ export type { TriggerAppEventCommandResult } from '../app-events.ts'; +export { ANDROID_ADB_TIMEOUT_FAILURE, classifyAndroidAdbFailure } from '../android-adb-failure.ts'; +export type { AndroidAdbFailureClassification } from '../android-adb-failure.ts'; +export { isAndroidEmulatorSerial, normalizeAndroidDeviceName } from '../android-device.ts'; export { DEFAULT_APPS_FILTER, assertResolvedAppsFilter, @@ -10,18 +13,22 @@ export { LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS, WEB_DESKTOP_DEVICE, countDeviceInventoryByGroup, - shouldUseHostMacFastPath, + filterDeviceInventoryProjection, + projectProviderDeviceInventoryRequest, } from '../device-inventory.ts'; export type { DeviceInventoryGroup, DeviceInventoryGroupCounts, DeviceInventoryRequest, + ProviderDeviceInventoryRequest, } from '../device-inventory.ts'; export type { DeviceInventoryProvider, DeviceLease, LeaseLifecycleContext, LeaseLifecycleProvider, + ProviderDeviceInventoryOutcome, + ProviderDeviceInventorySource, } from '../device-provider.ts'; export { DEVICE_ROTATIONS, diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index 0a109a9741..42bf50eac0 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -33,5 +33,45 @@ export { isHostSystemAudioProbeDevice, } from '../audio-probe-support.ts'; export type { PlatformPlugin } from '../platform-plugin.ts'; +export { assertCommandPlatformExecution } from '../command-platform-execution.ts'; +export type { + CommandPlatformExecution, + RuntimeUseDeclaration, +} from '../command-platform-execution.ts'; export type { PlatformGatedProviderResolverKey } from '../platform-providers.ts'; export type { RunnerLogicalLeaseContext } from '../runner-lease-context.ts'; +export type { + AppleToolHost, + AppleToolRequest, + AppleXcrunTool, + HostCommandRequest, + HostCommandResult, + HostCommandRunner, + HostToolchainPreparer, + HostOperatingSystem, + HostTemporaryTextFile, + DeviceInventoryFileHost, + DeviceInventoryHost, + DeviceInventoryHostByFamily, + DeviceInventoryHostFor, + DeviceObservationSink, + NativeAssetResolver, + PlatformDiagnosticEvent, + PlatformDiagnosticSink, + PlatformProgressSink, + PlatformProgressUpdate, + ResolvedNativeAsset, + PlatformRequestScope, +} from '../platform-runtime-host.ts'; +export { createPlatformModuleRegistry, inventoryUse } from '../platform-module.ts'; +export type { + ComposedDeviceInventoryGateways, + DeviceInventoryDiscovery, + DeviceInventoryGateway, + DeviceInventorySource, + InventoryPlatformModule, + InventoryUse, + PlatformModuleMetadata, + PlatformModuleRegistry, + ProviderAwareDeviceInventoryGateway, +} from '../platform-module.ts'; diff --git a/packages/contracts/src/platform-module.test.ts b/packages/contracts/src/platform-module.test.ts new file mode 100644 index 0000000000..c97373f58e --- /dev/null +++ b/packages/contracts/src/platform-module.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { PLATFORMS, type Platform } from '@agent-device/kernel/device'; +import { createPlatformModuleRegistry, type PlatformModuleMetadata } from './platform-module.ts'; + +test('platform metadata registry is canonical, ordered, and immutable', () => { + const registrations = [...PLATFORMS] + .reverse() + .map((family) => ({ family, marker: family.toUpperCase() })); + const registry = createPlatformModuleRegistry(registrations); + + assert.deepEqual(registry.families, PLATFORMS); + assert.deepEqual( + registry.modules.map((module) => module.family), + PLATFORMS, + ); + assert.equal(registry.get('harmonyos').marker, 'HARMONYOS'); + assert.ok(Object.isFrozen(registry)); + assert.ok(Object.isFrozen(registry.families)); + assert.ok(Object.isFrozen(registry.modules)); + assert.ok(Object.isFrozen(registry.get('apple'))); +}); + +test('platform metadata registry rejects missing and duplicate family ownership', () => { + const missing = PLATFORMS.filter((family) => family !== 'web').map((family) => ({ family })); + assert.throws(() => createPlatformModuleRegistry(missing), /Missing.*web/); + + const duplicate: PlatformModuleMetadata[] = [ + ...PLATFORMS.map((family) => ({ family })), + { family: 'apple' }, + ]; + assert.throws(() => createPlatformModuleRegistry(duplicate), /Duplicate.*apple/); +}); + +test('platform metadata registry rejects an unknown runtime family value', () => { + const registrations = [ + ...PLATFORMS.map((family) => ({ family })), + { family: 'unknown' as Platform }, + ]; + assert.throws(() => createPlatformModuleRegistry(registrations), /Unknown.*unknown/); +}); diff --git a/packages/contracts/src/platform-module.ts b/packages/contracts/src/platform-module.ts new file mode 100644 index 0000000000..2f4866b516 --- /dev/null +++ b/packages/contracts/src/platform-module.ts @@ -0,0 +1,121 @@ +import { PLATFORMS, isPlatform, type DeviceInfo, type Platform } from '@agent-device/kernel/device'; +import type { DeviceInventoryRequest } from './device-inventory.ts'; +import type { DeviceInventoryHostFor, PlatformRequestScope } from './platform-runtime-host.ts'; + +/** Cheap family identity which may be evaluated during root composition. */ +export type PlatformModuleMetadata = Readonly<{ + family: Platform; +}>; + +/** + * An immutable, total view of the six canonical family registrations. + * + * The registry deliberately exposes no backing Map: `ReadonlyMap` would only + * hide mutation at compile time while a retained mutable alias could still + * change composition after validation. + */ +export type PlatformModuleRegistry = Readonly<{ + families: readonly Platform[]; + modules: readonly Module[]; + get(family: Platform): Module; +}>; + +export function createPlatformModuleRegistry( + registrations: readonly Module[], +): PlatformModuleRegistry { + const byFamily = new Map(); + for (const registration of registrations) { + if (!isPlatform(registration.family)) { + throw new TypeError(`Unknown platform family registration: ${String(registration.family)}`); + } + if (byFamily.has(registration.family)) { + throw new TypeError(`Duplicate platform family registration: ${registration.family}`); + } + byFamily.set(registration.family, freezeRegistration(registration)); + } + + const missing = PLATFORMS.filter((family) => !byFamily.has(family)); + if (missing.length > 0) { + throw new TypeError(`Missing platform family registrations: ${missing.join(', ')}`); + } + + const families = Object.freeze([...PLATFORMS]); + const modules = Object.freeze(families.map((family) => requiredRegistration(byFamily, family))); + return Object.freeze({ + families, + modules, + get: (family: Platform) => requiredRegistration(byFamily, family), + }); +} + +function freezeRegistration(registration: Module): Module { + return Object.freeze({ ...registration }) as Module; +} + +function requiredRegistration( + byFamily: ReadonlyMap, + family: Platform, +): Module { + const registration = byFamily.get(family); + if (!registration) { + throw new TypeError(`Missing platform family registration: ${family}`); + } + return registration; +} + +/** The descriptor-owned declaration for a device inventory operation. */ +export type InventoryUse = Readonly<{ + kind: 'device-inventory'; +}>; + +export const inventoryUse: InventoryUse = Object.freeze({ kind: 'device-inventory' }); + +export type DeviceInventorySource = Readonly<{ + discover( + request: Readonly, + scope: PlatformRequestScope, + ): Promise; +}>; + +export type DeviceInventoryGateway = Readonly<{ + discover( + request: Readonly, + scope: PlatformRequestScope, + ): Promise; +}>; + +export type DeviceInventoryDiscovery = Readonly<{ + devices: readonly DeviceInfo[]; + source: 'provider' | 'local'; +}>; + +/** Provider-first inventory plus the selected source needed by device admission. */ +export type ProviderAwareDeviceInventoryGateway = DeviceInventoryGateway & + Readonly<{ + discoverWithSource( + request: Readonly, + scope: PlatformRequestScope, + ): Promise; + }>; + +export type ComposedDeviceInventoryGateways = Readonly<{ + providerFirst: ProviderAwareDeviceInventoryGateway; + localOnly: DeviceInventoryGateway; +}>; + +/** A family module gains this interface only with an honest package-owned source. */ +/** A family module receives only the host authority its inventory mechanics need. */ +export type InventoryPlatformModule = Family extends Platform + ? PlatformModuleMetadata & + Readonly< + Family extends 'web' + ? { + family: Family; + loadInventory(): Promise; + } + : { + family: Family; + loadInventory(host: DeviceInventoryHostFor): Promise; + } + > + : never; diff --git a/packages/contracts/src/platform-plugin.ts b/packages/contracts/src/platform-plugin.ts index 20d3a816cb..fadc072eff 100644 --- a/packages/contracts/src/platform-plugin.ts +++ b/packages/contracts/src/platform-plugin.ts @@ -4,7 +4,6 @@ import type { RecordingBackendTag } from './recording.ts'; import type { PerfMetricsSamplerTag } from './perf.ts'; import type { PlatformGatedProviderResolverKey } from './platform-providers.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { DeviceInventoryRequest } from './device-inventory.ts'; type CapabilityBucket = 'apple' | 'android' | 'harmonyos' | 'vega' | 'linux' | 'web'; @@ -19,7 +18,7 @@ type CapabilityBucket = 'apple' | 'android' | 'harmonyos' | 'vega' | 'linux' | ' * synthesis, adb/idb), which stays exactly where it is. * * Imports are TYPE-ONLY; the concrete leaf code is reached through LAZY dynamic - * `import()` inside `createInteractor` / `discoverDevices`, preserving the + * `import()` inside `createInteractor`, preserving the * CLI cold-start laziness that today's `getInteractor` switch relies on. * * Daemon-owned columns (step b.3, issue #974): each is declared ONLY once it is @@ -52,8 +51,6 @@ export type PlatformPlugin = { readonly familySelector?: PlatformSelector; /** Lazily builds the {@link Interactor} for `device` — wraps today's `getInteractor` switch arm. */ createInteractor(device: DeviceInfo, runner: RunnerContext): Promise; - /** Lazily discovers devices for this family — wraps today's inventory if-chain branch. */ - discoverDevices(request: DeviceInventoryRequest): Promise; /** * The capability facet. `bucket` is the {@link CapabilityBucket} this family * reads from a `CommandCapability`. diff --git a/packages/contracts/src/platform-runtime-host.ts b/packages/contracts/src/platform-runtime-host.ts new file mode 100644 index 0000000000..aedddb9817 --- /dev/null +++ b/packages/contracts/src/platform-runtime-host.ts @@ -0,0 +1,138 @@ +import type { DeviceInfo, Platform } from '@agent-device/kernel/device'; +import type { JsonObject, JsonValue } from './json.ts'; + +export type HostCommandRequest = Readonly<{ + executable: string; + args: readonly string[]; + cwd?: string; + env?: Readonly>; + timeoutMs?: number; + allowFailure?: boolean; +}>; + +export type HostCommandResult = Readonly<{ + stdout: string; + stderr: string; + exitCode: number | null; + signal?: string; +}>; + +/** Generic process-execution port; focused Apple foreground tools use AppleToolHost. */ +export type HostCommandRunner = Readonly<{ + which(executable: string): Promise; + run(request: HostCommandRequest, signal?: AbortSignal): Promise; +}>; + +export type AppleXcrunTool = 'simctl' | 'devicectl' | 'xctrace'; + +export type AppleToolRequest = Readonly<{ + tool: AppleXcrunTool; + args: readonly string[]; + timeoutMs?: number; + allowFailure?: boolean; +}>; + +/** Request-bound foreground Apple tooling backed by the selected scoped provider. */ +export type AppleToolHost = Readonly<{ + isXcrunAvailable(signal?: AbortSignal): Promise; + run(request: AppleToolRequest, signal?: AbortSignal): Promise; +}>; + +export type HostOperatingSystem = 'darwin' | 'linux' | 'win32' | 'other'; + +export type HostTemporaryTextFile = AsyncDisposable & + Readonly<{ + path: string; + readText(): Promise; + }>; + +export type DeviceInventoryFileHost = Readonly<{ + isExecutable(path: string): Promise; + createTemporaryTextFile( + options: Readonly<{ + prefix: string; + suffix: string; + }>, + ): Promise; +}>; + +export type DeviceObservationSink = Readonly<{ + /** Record only a fresh native-tool observation, never cached or persisted inventory. */ + deviceBooted(device: DeviceInfo): Promise; +}>; + +/** + * Family-lazy bridge for process-lifetime toolchain preparation still shared + * with legacy mechanics. Platform packages select a family but never receive + * ambient environment or filesystem authority. + */ +export type HostToolchainPreparer = Readonly<{ + prepare(family: Platform): Promise; +}>; + +/** + * Focused host mechanics needed by discovery implementations. Family-specific + * configured paths remain package factory inputs, not a shared environment bag. + */ +export type DeviceInventoryHost = Readonly<{ + commands: HostCommandRunner; + appleTools: AppleToolHost; + toolchains: HostToolchainPreparer; + files: DeviceInventoryFileHost; + hostOs: HostOperatingSystem; + hostName: string; + homeDirectory: string; + observations: DeviceObservationSink; +}>; + +/** The exact host authority made visible to each family inventory implementation. */ +export type DeviceInventoryHostByFamily = Readonly<{ + apple: Pick; + android: Pick; + harmonyos: Pick; + vega: Pick; + linux: Pick; + web: never; +}>; + +export type DeviceInventoryHostFor = DeviceInventoryHostByFamily[Family]; + +export type PlatformDiagnosticEvent = Readonly<{ + level: 'debug' | 'info' | 'warn' | 'error'; + phase: string; + message?: string; + data?: JsonObject; +}>; + +export type PlatformDiagnosticSink = Readonly<{ + emit(event: PlatformDiagnosticEvent): void; +}>; + +export type PlatformProgressUpdate = Readonly<{ + phase: string; + message: string; + completed?: number; + total?: number; + data?: JsonValue; +}>; + +export type PlatformProgressSink = Readonly<{ + report(update: PlatformProgressUpdate): void; +}>; + +/** Request-lifetime attachments; durable handles must never retain this scope. */ +export type PlatformRequestScope = Readonly<{ + signal: AbortSignal; + diagnostics: PlatformDiagnosticSink; + progress: PlatformProgressSink; +}>; + +export type ResolvedNativeAsset = Readonly<{ + path: string; + version?: string; +}>; + +/** Asset names stay package-owned literal unions rather than a shared universal catalog. */ +export type NativeAssetResolver = Readonly<{ + resolve(name: AssetName, signal?: AbortSignal): Promise; +}>; diff --git a/packages/platform-android/package.json b/packages/platform-android/package.json new file mode 100644 index 0000000000..ac59d0d4a2 --- /dev/null +++ b/packages/platform-android/package.json @@ -0,0 +1,17 @@ +{ + "name": "@agent-device/platform-android", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Android-family platform runtime metadata and implementations for agent-device.", + "dependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/platform-android/src/adb-failure.test.ts b/packages/platform-android/src/adb-failure.test.ts new file mode 100644 index 0000000000..c1162c835f --- /dev/null +++ b/packages/platform-android/src/adb-failure.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { androidDiscoveryCommandError } from './adb-failure.ts'; + +test('ADB discovery classifies transport failures from stderr without trusting stdout', () => { + const stdoutOnly = androidDiscoveryCommandError( + 'adb devices failed', + { stdout: 'error: device offline', stderr: '', exitCode: 1 }, + 'fallback', + ); + const versionMismatch = androidDiscoveryCommandError( + 'adb devices failed', + { + stdout: '', + stderr: "adb server version (40) doesn't match this client (41); killing...", + exitCode: 1, + }, + 'fallback', + ); + + assert.equal(stdoutOnly.details?.adbFailure, undefined); + assert.equal(stdoutOnly.details?.retriable, undefined); + assert.equal(stdoutOnly.details?.hint, 'fallback'); + assert.equal(versionMismatch.details?.adbFailure, 'server_version_mismatch'); + assert.equal(versionMismatch.details?.retriable, true); + assert.match(String(versionMismatch.details?.hint), /Multiple adb installs conflict/); +}); diff --git a/packages/platform-android/src/adb-failure.ts b/packages/platform-android/src/adb-failure.ts new file mode 100644 index 0000000000..74fcb6a4b4 --- /dev/null +++ b/packages/platform-android/src/adb-failure.ts @@ -0,0 +1,43 @@ +import { + ANDROID_ADB_TIMEOUT_FAILURE, + classifyAndroidAdbFailure, +} from '@agent-device/contracts/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { HostCommandResult } from '@agent-device/contracts/platform'; + +export function androidDiscoveryCommandError( + message: string, + result: HostCommandResult, + fallbackHint: string, +): AppError { + const failure = classifyAndroidAdbFailure(result.stderr, result.stdout); + return new AppError('COMMAND_FAILED', message, { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + processExitError: true, + hint: failure?.hint ?? fallbackHint, + ...(failure ? { adbFailure: failure.reason } : {}), + ...(failure?.retriable === undefined ? {} : { retriable: failure.retriable }), + }); +} + +export function attachAndroidDiscoveryTimeout(error: T): T { + if ( + !(error instanceof AppError) || + error.code !== 'COMMAND_FAILED' || + typeof error.details?.timeoutMs !== 'number' + ) { + return error; + } + error.details = { + ...error.details, + adbFailure: ANDROID_ADB_TIMEOUT_FAILURE.reason, + ...(typeof error.details.hint === 'string' + ? {} + : { + hint: ANDROID_ADB_TIMEOUT_FAILURE.hint, + }), + }; + return error; +} diff --git a/packages/platform-android/src/index.ts b/packages/platform-android/src/index.ts new file mode 100644 index 0000000000..2864e19815 --- /dev/null +++ b/packages/platform-android/src/index.ts @@ -0,0 +1,28 @@ +import type { + InventoryPlatformModule, + PlatformModuleMetadata, +} from '@agent-device/contracts/platform'; +import type { AndroidInventoryConfig } from './inventory-config.ts'; + +const metadata = Object.freeze({ + family: 'android', +} satisfies PlatformModuleMetadata); + +export type { AndroidInventoryConfig } from './inventory-config.ts'; + +export function createAndroidInventoryModule( + config: AndroidInventoryConfig, +): InventoryPlatformModule<'android'> { + const capturedConfig = Object.freeze({ + sdkRoots: Object.freeze([ + ...new Set(config.sdkRoots.map((root) => root.trim()).filter(Boolean)), + ]), + }); + return Object.freeze({ + ...metadata, + loadInventory: async (host) => { + const { createAndroidInventory } = await import('./inventory.ts'); + return createAndroidInventory(host, capturedConfig); + }, + }); +} diff --git a/packages/platform-android/src/inventory-config.ts b/packages/platform-android/src/inventory-config.ts new file mode 100644 index 0000000000..9658d36ea5 --- /dev/null +++ b/packages/platform-android/src/inventory-config.ts @@ -0,0 +1,4 @@ +export type AndroidInventoryConfig = Readonly<{ + /** Ordered Android SDK roots captured inertly by the composition root. */ + sdkRoots: readonly string[]; +}>; diff --git a/packages/platform-android/src/inventory-parsers.test.ts b/packages/platform-android/src/inventory-parsers.test.ts new file mode 100644 index 0000000000..ca3086fcb4 --- /dev/null +++ b/packages/platform-android/src/inventory-parsers.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + parseAndroidAvdList, + parseAndroidDeviceEntries, + parseAndroidEmulatorAvdNameOutput, + parseAndroidFeatureListForTv, + parseAndroidTargetFromCharacteristics, +} from './inventory-parsers.ts'; + +test('Android inventory parsers preserve device, AVD-name, and TV detection semantics', () => { + assert.deepEqual( + parseAndroidDeviceEntries( + 'List of devices attached\nemulator-5554 device product:sdk model:Pixel_9_Pro_XL\noffline offline\n', + ), + [{ serial: 'emulator-5554', rawModel: 'Pixel_9_Pro_XL' }], + ); + assert.deepEqual(parseAndroidAvdList('\nPixel_9_Pro_XL\n\nWear_OS\n'), [ + 'Pixel_9_Pro_XL', + 'Wear_OS', + ]); + assert.equal(parseAndroidEmulatorAvdNameOutput('Pixel_9_Pro_XL\r\nOK\r\n'), 'Pixel_9_Pro_XL'); + assert.equal(parseAndroidEmulatorAvdNameOutput('\r\nOK\r\n'), undefined); + assert.equal(parseAndroidTargetFromCharacteristics('tv,nosdcard'), 'tv'); + assert.equal(parseAndroidTargetFromCharacteristics('phone,tablet'), null); + assert.equal(parseAndroidFeatureListForTv('feature:android.hardware.type.television'), true); +}); diff --git a/packages/platform-android/src/inventory-parsers.ts b/packages/platform-android/src/inventory-parsers.ts new file mode 100644 index 0000000000..9816a840cd --- /dev/null +++ b/packages/platform-android/src/inventory-parsers.ts @@ -0,0 +1,51 @@ +import { normalizeAndroidDeviceName } from '@agent-device/contracts/device'; + +export type AndroidDeviceEntry = Readonly<{ + serial: string; + rawModel: string; +}>; + +export function parseAndroidDeviceEntries(rawOutput: string): AndroidDeviceEntry[] { + const devices: AndroidDeviceEntry[] = []; + for (const rawLine of rawOutput.split('\n')) { + const parts = rawLine.trim().split(/\s+/); + const serial = parts[0]; + if (!serial || serial === 'List' || parts[1] !== 'device') continue; + devices.push({ + serial, + rawModel: (parts.find((entry) => entry.startsWith('model:')) ?? '').replace('model:', ''), + }); + } + return devices; +} + +export function parseAndroidAvdList(rawOutput: string): string[] { + return rawOutput + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +export function parseAndroidEmulatorAvdNameOutput(rawOutput: string): string | undefined { + const lines = rawOutput + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (lines.at(-1) === 'OK') lines.pop(); + return lines.join('\n').trim() || undefined; +} + +export function parseAndroidTargetFromCharacteristics(rawOutput: string): 'tv' | null { + const normalized = rawOutput.toLowerCase(); + return normalized.includes('tv') || normalized.includes('leanback') ? 'tv' : null; +} + +export function parseAndroidFeatureListForTv(rawOutput: string): boolean { + return /feature:android\.(software\.leanback(_only)?|hardware\.type\.television)\b/i.test( + rawOutput, + ); +} + +export function inferAndroidAvdTarget(avdName: string): 'mobile' | 'tv' { + return /\b(tv|television)\b/i.test(normalizeAndroidDeviceName(avdName)) ? 'tv' : 'mobile'; +} diff --git a/packages/platform-android/src/inventory.test.ts b/packages/platform-android/src/inventory.test.ts new file mode 100644 index 0000000000..cf1065e99b --- /dev/null +++ b/packages/platform-android/src/inventory.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + DeviceInventoryHostFor, + HostCommandRequest, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { createAndroidInventory } from './inventory.ts'; + +const scope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => undefined }, + progress: { report: () => undefined }, +}; + +test('Android inventory invokes bounded host commands and filters serials', async () => { + const calls: HostCommandRequest[] = []; + const host = createHost(async (request) => { + calls.push(request); + const key = request.args.join('\0'); + if (request.executable === 'emulator') return result('Pixel_9_Pro_XL\nLiving_Room_TV\n'); + if (key === 'devices\0-l') { + return result( + 'List of devices attached\nemulator-5554 device model:Pixel_9_Pro_XL\nphysical-1 device model:Phone\n', + ); + } + if (key.includes('ro.boot.qemu.avd_name')) return result('Pixel_9_Pro_XL\n'); + if (key.includes('sys.boot_completed')) return result('1\n'); + if (key.includes('ro.build.characteristics')) return result('phone\n'); + if (key.includes('has-feature')) return result('false\n'); + if (key.includes('pm\0list\0features')) return result(''); + throw new Error(`Unexpected command: ${request.executable} ${request.args.join(' ')}`); + }); + + const devices = await createAndroidInventory(host).discover( + { androidSerialAllowlist: ['emulator-5554'] }, + scope, + ); + + assert.deepEqual( + devices.map((device) => [device.id, device.name, device.booted]), + [ + ['emulator-5554', 'Pixel 9 Pro XL', true], + ['Living_Room_TV', 'Living_Room_TV', false], + ], + ); + assert.ok(calls.every((call) => call.timeoutMs === 10_000)); + assert.ok(calls.every((call) => call.allowFailure === true)); +}); + +test('Android inventory fails closed when adb is unavailable', async () => { + const host = createHost(async () => result(''), { adb: undefined }); + await assert.rejects( + createAndroidInventory(host).discover({}, scope), + (error: unknown) => error instanceof AppError && error.code === 'TOOL_MISSING', + ); +}); + +test('Android inventory honors captured SDK roots and classifies adb transport failures', async () => { + const calls: HostCommandRequest[] = []; + const host = createHost( + async (request) => { + calls.push(request); + return result('', 'error: device offline', 1); + }, + { adb: undefined, emulator: undefined }, + async (candidate) => candidate === '/custom-sdk/platform-tools/adb', + ); + + await assert.rejects( + createAndroidInventory(host, { sdkRoots: ['/custom-sdk'] }).discover({}, scope), + (error: unknown) => + error instanceof AppError && + error.code === 'COMMAND_FAILED' && + error.details?.adbFailure === 'device_offline' && + error.details?.retriable === true, + ); + assert.equal(calls[0]?.executable, '/custom-sdk/platform-tools/adb'); +}); + +test('Android inventory prepares the legacy toolchain before an ANDROID_HOME-only discovery', async () => { + const preparedFamilies: string[] = []; + const host = { + ...createHost( + async (request) => { + if (request.args.join('\0') === 'devices\0-l') return result(''); + if (request.executable.endsWith('/emulator')) return result(''); + throw new Error(`Unexpected command: ${request.executable} ${request.args.join(' ')}`); + }, + { adb: undefined, emulator: undefined }, + async (candidate) => + candidate === '/android-home/platform-tools/adb' || + candidate === '/android-home/emulator/emulator', + ), + toolchains: { + prepare: async (family: string) => { + preparedFamilies.push(family); + }, + }, + }; + + await createAndroidInventory(host, { sdkRoots: ['/android-home'] }).discover({}, scope); + + assert.deepEqual(preparedFamilies, ['android']); +}); + +function createHost( + run: DeviceInventoryHostFor<'android'>['commands']['run'], + tools: { adb?: string; emulator?: string } = { adb: 'adb', emulator: 'emulator' }, + isExecutable: DeviceInventoryHostFor<'android'>['files']['isExecutable'] = async () => false, +): DeviceInventoryHostFor<'android'> { + return { + commands: { + which: async (executable) => tools[executable as keyof typeof tools], + run, + }, + toolchains: { prepare: async () => undefined }, + files: { + isExecutable, + createTemporaryTextFile: async () => { + throw new Error('unused'); + }, + }, + homeDirectory: '/Users/test', + }; +} + +function result(stdout: string, stderr = '', exitCode = 0) { + return { stdout, stderr, exitCode }; +} diff --git a/packages/platform-android/src/inventory.ts b/packages/platform-android/src/inventory.ts new file mode 100644 index 0000000000..f5fbbc2b3a --- /dev/null +++ b/packages/platform-android/src/inventory.ts @@ -0,0 +1,286 @@ +import path from 'node:path'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError, asAppError } from '@agent-device/kernel/errors'; +import type { + DeviceInventoryHostFor, + DeviceInventorySource, + HostCommandResult, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { + isAndroidEmulatorSerial, + normalizeAndroidDeviceName, + type DeviceInventoryRequest, +} from '@agent-device/contracts/device'; +import { androidDiscoveryCommandError, attachAndroidDiscoveryTimeout } from './adb-failure.ts'; +import type { AndroidInventoryConfig } from './inventory-config.ts'; +import { + inferAndroidAvdTarget, + parseAndroidAvdList, + parseAndroidDeviceEntries, + parseAndroidEmulatorAvdNameOutput, + parseAndroidFeatureListForTv, + parseAndroidTargetFromCharacteristics, + type AndroidDeviceEntry, +} from './inventory-parsers.ts'; + +const PROBE_TIMEOUT_MS = 10_000; +const TV_FEATURES = [ + 'android.software.leanback', + 'android.software.leanback_only', + 'android.hardware.type.television', +] as const; + +type AndroidInventoryContext = Readonly<{ + host: DeviceInventoryHostFor<'android'>; + adb: string; + emulator?: string; + scope: PlatformRequestScope; +}>; + +export function createAndroidInventory( + host: DeviceInventoryHostFor<'android'>, + config: AndroidInventoryConfig = { sdkRoots: [] }, +): DeviceInventorySource { + return { + discover: async (request, scope) => await discoverAndroidDevices(host, config, request, scope), + }; +} + +async function discoverAndroidDevices( + host: DeviceInventoryHostFor<'android'>, + config: AndroidInventoryConfig, + request: Readonly, + scope: PlatformRequestScope, +): Promise { + await host.toolchains.prepare('android'); + const sdkRoots = [...config.sdkRoots, path.join(host.homeDirectory, 'Android', 'Sdk')]; + const adb = await resolveTool(host, 'adb', sdkRoots, ['platform-tools', 'adb']); + if (!adb) throw new AppError('TOOL_MISSING', 'adb not found in PATH'); + const emulator = await resolveTool(host, 'emulator', sdkRoots, ['emulator', 'emulator']); + const context: AndroidInventoryContext = { + host, + adb, + emulator, + scope, + }; + const allowlist = resolveSerialAllowlist(request); + const entries = (await listDeviceEntries(context)).filter( + (entry) => !allowlist || allowlist.has(entry.serial), + ); + const running = await mapWithConcurrency( + entries, + 3, + async (entry) => await probeRunningDevice(context, entry), + ); + return [...running, ...(await listStoppedAvds(context, running))]; +} + +function resolveSerialAllowlist( + request: Readonly, +): ReadonlySet | undefined { + const selected = request.serial?.trim(); + const policy = request.androidSerialAllowlist; + if (!selected) return policy ? new Set(policy) : undefined; + if (!policy) return new Set([selected]); + return new Set(policy.includes(selected) ? [selected] : []); +} + +async function resolveTool( + host: DeviceInventoryHostFor<'android'>, + executable: string, + sdkRoots: readonly string[], + relativeSegments: readonly string[], +): Promise { + for (const sdkRoot of sdkRoots) { + const candidate = path.join(sdkRoot, ...relativeSegments); + if (await host.files.isExecutable(candidate)) return candidate; + } + return await host.commands.which(executable); +} + +async function listDeviceEntries(context: AndroidInventoryContext): Promise { + const result = await run(context, context.adb, ['devices', '-l']); + if (result.exitCode !== 0) { + throw androidDiscoveryCommandError( + 'Failed to list Android devices', + result, + 'Verify adb is running and the device appears in `adb devices`.', + ); + } + return parseAndroidDeviceEntries(result.stdout); +} + +async function probeRunningDevice( + context: AndroidInventoryContext, + entry: AndroidDeviceEntry, +): Promise { + const [name, booted, target] = await Promise.all([ + resolveDeviceName(context, entry), + isBooted(context, entry.serial), + resolveTarget(context, entry.serial), + ]); + return { + platform: 'android', + id: entry.serial, + name, + kind: isAndroidEmulatorSerial(entry.serial) ? 'emulator' : 'device', + target, + booted, + }; +} + +async function resolveDeviceName( + context: AndroidInventoryContext, + entry: AndroidDeviceEntry, +): Promise { + const model = entry.rawModel.replace(/_/g, ' ').trim(); + if (!isAndroidEmulatorSerial(entry.serial)) return model || entry.serial; + const avdName = await resolveEmulatorAvdName(context, entry.serial); + return avdName?.replace(/_/g, ' ') || model || entry.serial; +} + +async function resolveEmulatorAvdName( + context: AndroidInventoryContext, + serial: string, +): Promise { + for (const prop of ['ro.boot.qemu.avd_name', 'persist.sys.avd_name']) { + const result = await runBestEffortNameProbe(context, serial, ['shell', 'getprop', prop]); + const value = result?.stdout.trim(); + if (result?.exitCode === 0 && value) return value; + } + const result = await runBestEffortNameProbe(context, serial, ['emu', 'avd', 'name']); + const value = result && parseAndroidEmulatorAvdNameOutput(result.stdout); + return result?.exitCode === 0 ? value : undefined; +} + +async function runBestEffortNameProbe( + context: AndroidInventoryContext, + serial: string, + args: string[], +): Promise { + try { + return await runAdb(context, serial, args); + } catch (error) { + const appError = asAppError(error); + if (appError.code === 'COMMAND_FAILED' && typeof appError.details?.timeoutMs === 'number') { + return undefined; + } + throw error; + } +} + +async function isBooted(context: AndroidInventoryContext, serial: string): Promise { + try { + return ( + (await runAdb(context, serial, ['shell', 'getprop', 'sys.boot_completed'])).stdout.trim() === + '1' + ); + } catch (error) { + if (context.scope.signal.aborted) throw error; + return false; + } +} + +async function resolveTarget( + context: AndroidInventoryContext, + serial: string, +): Promise<'mobile' | 'tv'> { + const characteristics = await runAdb(context, serial, [ + 'shell', + 'getprop', + 'ro.build.characteristics', + ]); + if (parseAndroidTargetFromCharacteristics(commandOutput(characteristics)) === 'tv') return 'tv'; + const featureResults = await mapWithConcurrency( + TV_FEATURES, + 2, + async (feature) => + await runAdb(context, serial, ['shell', 'cmd', 'package', 'has-feature', feature]), + ); + if (featureResults.some((result) => commandOutput(result).toLowerCase().includes('true'))) { + return 'tv'; + } + const featureList = await runAdb(context, serial, ['shell', 'pm', 'list', 'features']); + return parseAndroidFeatureListForTv(commandOutput(featureList)) ? 'tv' : 'mobile'; +} + +async function listStoppedAvds( + context: AndroidInventoryContext, + running: readonly DeviceInfo[], +): Promise { + if (!context.emulator) return []; + let result: HostCommandResult; + try { + result = await run(context, context.emulator, ['-list-avds']); + } catch (error) { + if (context.scope.signal.aborted) throw error; + return []; + } + if (result.exitCode !== 0) return []; + const runningNames = new Set( + running + .filter((device) => device.kind === 'emulator') + .map((device) => normalizeAndroidDeviceName(device.name)), + ); + return parseAndroidAvdList(result.stdout) + .filter((name) => !runningNames.has(normalizeAndroidDeviceName(name))) + .map((name) => ({ + platform: 'android', + id: name, + name, + kind: 'emulator', + target: inferAndroidAvdTarget(name), + booted: false, + })); +} + +async function runAdb( + context: AndroidInventoryContext, + serial: string, + args: string[], +): Promise { + return await run(context, context.adb, ['-s', serial, ...args]); +} + +async function run( + context: AndroidInventoryContext, + executable: string, + args: readonly string[], +): Promise { + try { + return await context.host.commands.run( + { + executable, + args, + allowFailure: true, + timeoutMs: PROBE_TIMEOUT_MS, + }, + context.scope.signal, + ); + } catch (error) { + throw attachAndroidDiscoveryTimeout(error); + } +} + +function commandOutput(result: HostCommandResult): string { + return `${result.stdout}\n${result.stderr}`; +} + +async function mapWithConcurrency( + values: readonly T[], + concurrency: number, + mapper: (value: T) => Promise, +): Promise { + const results = new Array(values.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(concurrency, values.length) }, async () => { + while (next < values.length) { + const index = next++; + results[index] = await mapper(values[index]!); + } + }), + ); + return results; +} diff --git a/packages/platform-android/tsconfig.json b/packages/platform-android/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/platform-android/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/platform-apple/package.json b/packages/platform-apple/package.json new file mode 100644 index 0000000000..a7dfb671be --- /dev/null +++ b/packages/platform-apple/package.json @@ -0,0 +1,17 @@ +{ + "name": "@agent-device/platform-apple", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Apple-family platform runtime metadata and implementations for agent-device.", + "dependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/platform-apple/src/index.ts b/packages/platform-apple/src/index.ts new file mode 100644 index 0000000000..a452b6734f --- /dev/null +++ b/packages/platform-apple/src/index.ts @@ -0,0 +1,16 @@ +import type { + InventoryPlatformModule, + PlatformModuleMetadata, +} from '@agent-device/contracts/platform'; + +const metadata = Object.freeze({ + family: 'apple', +} satisfies PlatformModuleMetadata); + +export const inventoryModule = Object.freeze({ + ...metadata, + loadInventory: async (host) => { + const { createAppleInventorySource } = await import('./inventory.ts'); + return createAppleInventorySource(host); + }, +} satisfies InventoryPlatformModule<'apple'>); diff --git a/packages/platform-apple/src/inventory-classification.test.ts b/packages/platform-apple/src/inventory-classification.test.ts new file mode 100644 index 0000000000..f43bebedbb --- /dev/null +++ b/packages/platform-apple/src/inventory-classification.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + isAppleProductType, + isSupportedAppleDevicectlDevice, + mapDevicectlAppleDevice, + resolveAppleOs, + resolveAppleTargetFromDevicectlDevice, +} from './inventory-classification.ts'; + +test('devicectl classification recognizes Apple families without relying on a device name', () => { + assert.equal(isAppleProductType('iPhone16,2'), true); + assert.equal(isAppleProductType('AppleTV11,1'), true); + assert.equal(isAppleProductType('RealityDevice14,1'), true); + assert.equal(isAppleProductType('Pixel 9'), false); + assert.equal( + isSupportedAppleDevicectlDevice({ + hardwareProperties: { productType: 'AppleTV11,1' }, + deviceProperties: { name: 'Living Room' }, + }), + true, + ); +}); + +test('devicectl classification resolves tvOS, iPadOS, and visionOS vocabulary', () => { + assert.equal( + resolveAppleTargetFromDevicectlDevice({ + hardwareProperties: { platform: 'tvOS' }, + }), + 'tv', + ); + assert.equal(resolveAppleOs('mobile', ['iPad16,3']), 'ipados'); + assert.equal(resolveAppleOs('mobile', ['visionOS 2.0']), 'visionos'); +}); + +test('devicectl records map to normalized physical Apple devices', () => { + assert.deepEqual( + mapDevicectlAppleDevice({ + name: 'Living Room', + hardwareProperties: { + platform: 'tvOS', + productType: 'AppleTV11,1', + udid: 'tv-1', + }, + }), + { + platform: 'apple', + id: 'tv-1', + name: 'Living Room', + kind: 'device', + target: 'tv', + appleOs: 'tvos', + iosPhysicalDeviceBackend: 'coredevice', + booted: true, + }, + ); + assert.equal( + mapDevicectlAppleDevice({ + identifier: 'android-1', + hardwareProperties: { platform: 'Android' }, + }), + null, + ); +}); diff --git a/packages/platform-apple/src/inventory-classification.ts b/packages/platform-apple/src/inventory-classification.ts new file mode 100644 index 0000000000..3e74b49add --- /dev/null +++ b/packages/platform-apple/src/inventory-classification.ts @@ -0,0 +1,119 @@ +import type { AppleOS, DeviceInfo, DeviceTarget } from '@agent-device/kernel/device'; + +const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|realitydevice)/i; +const APPLE_IPAD_PATTERN = /ipad/i; +const APPLE_VISION_PATTERN = /\b(apple vision|vision pro|xros|visionos|realitydevice)\b/i; +const APPLE_MOBILE_LABEL_PATTERN = /\b(iphone|ipad|ipod)\b/i; +const APPLE_TV_PRODUCT_TYPE_PATTERN = /^appletv/i; +const APPLE_TV_LABEL_HINTS = ['apple tv', 'appletv', 'tvos'] as const; + +export type DevicectlAppleDevice = { + identifier?: string; + name?: string; + hardwareProperties?: { platform?: string; udid?: string; productType?: string }; + deviceProperties?: { name?: string; productType?: string; deviceType?: string }; + connectionProperties?: { tunnelState?: string }; +}; + +function normalizeAppleDescriptor(value: string | undefined): string { + return (value ?? '').trim().toLowerCase(); +} + +function isAppleTvPlatform(platform: string): boolean { + return platform.includes('tvos'); +} + +function isAppleVisionPlatform(platform: string): boolean { + return platform.includes('xros') || platform.includes('visionos'); +} + +export function resolveAppleTargetFromRuntime(runtime: string): DeviceTarget { + return isAppleTvPlatform(normalizeAppleDescriptor(runtime)) ? 'tv' : 'mobile'; +} + +export function isSupportedAppleRuntime(runtime: string): boolean { + const normalized = normalizeAppleDescriptor(runtime); + return ( + normalized.includes('ios') || + normalized.includes('tvos') || + normalized.includes('xros') || + normalized.includes('visionos') + ); +} + +function isAppleTvLabel(value: string): boolean { + const normalized = normalizeAppleDescriptor(value); + return APPLE_TV_LABEL_HINTS.some((hint) => normalized.includes(hint)); +} + +function isAppleMobileLabel(value: string): boolean { + return APPLE_MOBILE_LABEL_PATTERN.test(value.trim()); +} + +export function resolveAppleTargetFromLabel(value: string): DeviceTarget | null { + if (isAppleTvLabel(value)) return 'tv'; + if (isAppleMobileLabel(value) || APPLE_VISION_PATTERN.test(value)) return 'mobile'; + return null; +} + +export function resolveAppleOs(target: DeviceTarget, descriptors: string[]): AppleOS { + if (target === 'tv') return 'tvos'; + if (descriptors.some((descriptor) => APPLE_VISION_PATTERN.test(descriptor))) return 'visionos'; + if (descriptors.some((descriptor) => APPLE_IPAD_PATTERN.test(descriptor))) return 'ipados'; + return 'ios'; +} + +export function isAppleProductType(productType: string): boolean { + return APPLE_PRODUCT_TYPE_PATTERN.test(productType.trim()); +} + +function isAppleTvProductType(productType: string): boolean { + return APPLE_TV_PRODUCT_TYPE_PATTERN.test(productType.trim()); +} + +function devicectlLabels(device: DevicectlAppleDevice): string[] { + return [ + device.name ?? '', + device.deviceProperties?.name ?? '', + device.deviceProperties?.deviceType ?? '', + ]; +} + +function devicectlProductType(device: DevicectlAppleDevice): string { + return device.hardwareProperties?.productType ?? device.deviceProperties?.productType ?? ''; +} + +export function resolveAppleTargetFromDevicectlDevice(device: DevicectlAppleDevice): DeviceTarget { + const platform = normalizeAppleDescriptor(device.hardwareProperties?.platform); + if (isAppleTvPlatform(platform)) return 'tv'; + const productType = devicectlProductType(device); + if (isAppleTvProductType(productType)) return 'tv'; + return devicectlLabels(device).some(isAppleTvLabel) ? 'tv' : 'mobile'; +} + +export function isSupportedAppleDevicectlDevice(device: DevicectlAppleDevice): boolean { + const platform = normalizeAppleDescriptor(device.hardwareProperties?.platform); + if (platform.includes('ios') || isAppleTvPlatform(platform) || isAppleVisionPlatform(platform)) { + return true; + } + if (isAppleProductType(devicectlProductType(device))) return true; + return devicectlLabels(device).some(isAppleTvLabel); +} + +export function mapDevicectlAppleDevice(device: DevicectlAppleDevice): DeviceInfo | null { + if (!isSupportedAppleDevicectlDevice(device)) return null; + const id = device.hardwareProperties?.udid ?? device.identifier ?? ''; + const name = device.name ?? device.deviceProperties?.name ?? id; + if (!id) return null; + const target = resolveAppleTargetFromDevicectlDevice(device); + return { + platform: 'apple', + id, + name, + kind: 'device', + target, + appleOs: resolveAppleOs(target, [devicectlProductType(device), ...devicectlLabels(device)]), + iosPhysicalDeviceBackend: 'coredevice', + booted: true, + }; +} diff --git a/packages/platform-apple/src/inventory.fixtures.ts b/packages/platform-apple/src/inventory.fixtures.ts new file mode 100644 index 0000000000..07bb8c51e9 --- /dev/null +++ b/packages/platform-apple/src/inventory.fixtures.ts @@ -0,0 +1,57 @@ +import type { + AppleToolHost, + DeviceInventoryHostFor, + HostCommandResult, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; + +export const inventoryScope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => undefined }, + progress: { report: () => undefined }, +}; + +export function commandResult( + stdout = '', + stderr = '', + exitCode: number | null = 0, +): HostCommandResult { + return { stdout, stderr, exitCode }; +} + +export function createInventoryHost( + options: { + run?: AppleToolHost['run']; + which?: (executable: string) => Promise; + createTemporaryTextFile?: DeviceInventoryHostFor<'apple'>['files']['createTemporaryTextFile']; + hostOs?: DeviceInventoryHostFor<'apple'>['hostOs']; + observed?: string[]; + } = {}, +): DeviceInventoryHostFor<'apple'> { + return { + appleTools: { + isXcrunAvailable: async () => + Boolean(await (options.which ?? (async (executable) => `/usr/bin/${executable}`))('xcrun')), + run: + options.run ?? + (async (request) => { + throw new Error(`Unexpected Apple tool: ${request.tool} ${request.args.join(' ')}`); + }), + }, + files: { + isExecutable: async () => false, + createTemporaryTextFile: + options.createTemporaryTextFile ?? + (async () => { + throw new Error('Unexpected temporary file request'); + }), + }, + hostOs: options.hostOs ?? 'darwin', + hostName: 'Test Mac', + observations: { + deviceBooted: async (device) => { + options.observed?.push(device.id); + }, + }, + }; +} diff --git a/packages/platform-apple/src/inventory.test.ts b/packages/platform-apple/src/inventory.test.ts new file mode 100644 index 0000000000..d60fb9d7b1 --- /dev/null +++ b/packages/platform-apple/src/inventory.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { createAppleInventorySource } from './inventory.ts'; +import { commandResult, createInventoryHost, inventoryScope } from './inventory.fixtures.ts'; +import { inventoryModule } from './index.ts'; + +test('Apple inventory projects the host Mac without loading native tools', async () => { + let commands = 0; + const host = createInventoryHost({ + which: async () => { + commands += 1; + return undefined; + }, + }); + + assert.deepEqual( + await createAppleInventorySource(host).discover( + { platform: 'apple', target: 'desktop' }, + inventoryScope, + ), + [ + { + platform: 'apple', + id: 'host-macos-local', + name: 'Test Mac', + kind: 'device', + target: 'desktop', + appleOs: 'macos', + booted: true, + }, + ], + ); + assert.equal(commands, 0); +}); + +test('Apple inventory rejects unsupported hosts and missing tools with typed errors', async () => { + await assert.rejects( + createAppleInventorySource(createInventoryHost({ hostOs: 'linux' })).discover( + {}, + inventoryScope, + ), + (error: unknown) => error instanceof AppError && error.code === 'UNSUPPORTED_PLATFORM', + ); + await assert.rejects( + createAppleInventorySource(createInventoryHost({ which: async () => undefined })).discover( + {}, + inventoryScope, + ), + (error: unknown) => error instanceof AppError && error.code === 'TOOL_MISSING', + ); +}); + +test('simulator-only inventory avoids physical discovery and observes fresh booted devices', async () => { + const calls: string[][] = []; + const observed: string[] = []; + const host = createInventoryHost({ + observed, + run: async (request) => { + calls.push([request.tool, ...request.args]); + return commandResult( + JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + name: 'iPhone 16', + udid: 'sim-1', + state: 'Booted', + isAvailable: true, + }, + ], + }, + }), + ); + }, + }); + + const source = await inventoryModule.loadInventory(host); + const devices = await source.discover({ kind: 'simulator', booted: true }, inventoryScope); + + assert.equal(inventoryModule.family, 'apple'); + assert.ok(Object.isFrozen(inventoryModule)); + assert.deepEqual( + devices.map((device) => device.id), + ['sim-1'], + ); + assert.deepEqual(observed, ['sim-1']); + assert.deepEqual(calls, [['simctl', 'list', 'devices', '-j']]); +}); + +test('simulator-set inventory remains scoped while retaining the host Mac', async () => { + const calls: string[][] = []; + const host = createInventoryHost({ + run: async (request) => { + calls.push([request.tool, ...request.args]); + return commandResult( + JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [ + { + name: 'Apple TV 4K', + udid: 'tv-1', + state: 'Shutdown', + isAvailable: true, + }, + ], + }, + }), + ); + }, + }); + + const devices = await createAppleInventorySource(host).discover( + { iosSimulatorSetPath: '/tmp/custom-set' }, + inventoryScope, + ); + + assert.deepEqual( + devices.map((device) => device.id), + ['tv-1', 'host-macos-local'], + ); + assert.deepEqual(calls, [['simctl', '--set', '/tmp/custom-set', 'list', 'devices', '-j']]); +}); diff --git a/packages/platform-apple/src/inventory.ts b/packages/platform-apple/src/inventory.ts new file mode 100644 index 0000000000..71800e131b --- /dev/null +++ b/packages/platform-apple/src/inventory.ts @@ -0,0 +1,78 @@ +import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; +import type { + DeviceInventoryHostFor, + DeviceInventorySource, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { sortAppleDevicesForSelection, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { listPhysicalAppleDevices } from './physical-inventory.ts'; +import { listAppleSimulators } from './simulator-inventory.ts'; + +export function createAppleInventorySource( + host: DeviceInventoryHostFor<'apple'>, +): DeviceInventorySource { + return Object.freeze({ + discover: async (request, scope) => await discoverAppleDevices(host, request, scope), + }); +} + +async function discoverAppleDevices( + host: DeviceInventoryHostFor<'apple'>, + request: Readonly, + scope: PlatformRequestScope, +): Promise { + if (isMacOnlyRequest(request)) { + return [hostMacDevice(host)]; + } + if (host.hostOs !== 'darwin') { + throw new AppError('UNSUPPORTED_PLATFORM', 'Apple tools are only available on macOS'); + } + if (!(await host.appleTools.isXcrunAvailable(scope.signal))) { + throw new AppError('TOOL_MISSING', 'xcrun not found in PATH'); + } + + const simulators = await listAppleSimulators(host, request, scope); + if (request.kind === 'simulator') return simulators; + + const withHost = [...simulators, hostMacDevice(host)]; + if ( + request.iosSimulatorSetPath || + (request.udid && simulators.some((device) => device.id === request.udid)) + ) { + return sortAppleDevicesForSelection(withHost); + } + + const physical = await listPhysicalAppleDevices(host, scope); + return sortAppleDevicesForSelection(mergeAppleDevices(withHost, physical)); +} + +function isMacOnlyRequest(request: Readonly): boolean { + return ( + request.platform === 'macos' || (request.platform === 'apple' && request.target === 'desktop') + ); +} + +function hostMacDevice(host: DeviceInventoryHostFor<'apple'>): DeviceInfo { + return { + platform: 'apple', + id: 'host-macos-local', + name: host.hostName, + kind: 'device', + target: 'desktop', + appleOs: 'macos', + booted: true, + }; +} + +function mergeAppleDevices(primary: DeviceInfo[], supplemental: DeviceInfo[]): DeviceInfo[] { + const ids = new Set(primary.map((device) => device.id)); + return [ + ...primary, + ...supplemental.filter((device) => { + if (ids.has(device.id)) return false; + ids.add(device.id); + return true; + }), + ]; +} diff --git a/packages/platform-apple/src/physical-inventory.test.ts b/packages/platform-apple/src/physical-inventory.test.ts new file mode 100644 index 0000000000..be945e6ada --- /dev/null +++ b/packages/platform-apple/src/physical-inventory.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + listPhysicalAppleDevices, + parseXctracePhysicalAppleDevices, +} from './physical-inventory.ts'; +import { commandResult, createInventoryHost, inventoryScope } from './inventory.fixtures.ts'; + +test('xctrace parser accepts Apple devices only from the online Devices section', () => { + assert.deepEqual( + parseXctracePhysicalAppleDevices( + [ + '== Devices ==', + 'My iPhone [phone-1]', + 'Living Room Apple TV (18.0) (tv-1)', + 'Studio Mac [mac-1]', + '== Devices Offline ==', + 'Offline iPhone [offline-1]', + '== Simulators ==', + 'iPhone 16 (18.0) (sim-1)', + ].join('\n'), + ).map((device) => [device.id, device.target, device.appleOs]), + [ + ['phone-1', 'mobile', 'ios'], + ['tv-1', 'tv', 'tvos'], + ], + ); +}); + +test('physical inventory prefers CoreDevice, supplements xctrace, and disposes its file', async () => { + const calls: string[][] = []; + let disposeCount = 0; + const host = createInventoryHost({ + run: async (request) => { + calls.push([request.tool, ...request.args]); + if (request.tool === 'devicectl') return commandResult(); + return commandResult(['== Devices ==', 'Phone [core-1]', 'Apple TV [tv-1]'].join('\n')); + }, + createTemporaryTextFile: async () => ({ + path: '/tmp/devices.json', + readText: async () => + JSON.stringify({ + result: { + devices: [ + { + name: 'My iPhone', + hardwareProperties: { + platform: 'iOS', + productType: 'iPhone16,2', + udid: 'core-1', + }, + }, + ], + }, + }), + [Symbol.asyncDispose]: async () => { + disposeCount += 1; + }, + }), + }); + + const devices = await listPhysicalAppleDevices(host, inventoryScope); + + assert.deepEqual( + devices.map((device) => [device.id, device.iosPhysicalDeviceBackend]), + [ + ['core-1', 'coredevice'], + ['tv-1', 'xctest'], + ], + ); + assert.equal(disposeCount, 1); + assert.ok(calls.some((args) => args[0] === 'devicectl')); + assert.ok(calls.some((args) => args[0] === 'xctrace')); +}); + +test('physical inventory fails soft when either native source is unavailable', async () => { + const host = createInventoryHost({ + run: async (request) => { + if (request.tool === 'devicectl') throw new Error('CoreDevice unavailable'); + return commandResult('== Devices ==\nMy iPhone [phone-1]'); + }, + createTemporaryTextFile: async () => { + throw new Error('temporary storage unavailable'); + }, + }); + + assert.deepEqual( + (await listPhysicalAppleDevices(host, inventoryScope)).map((device) => device.id), + ['phone-1'], + ); +}); + +test('physical inventory preserves request cancellation instead of failing soft', async () => { + const controller = new AbortController(); + const reason = new Error('inventory cancelled'); + const requestScope = { ...inventoryScope, signal: controller.signal }; + const host = createInventoryHost({ + run: async () => { + controller.abort(reason); + throw reason; + }, + createTemporaryTextFile: async () => ({ + path: '/tmp/devices.json', + readText: async () => '{}', + [Symbol.asyncDispose]: async () => undefined, + }), + }); + + await assert.rejects(listPhysicalAppleDevices(host, requestScope), reason); +}); diff --git a/packages/platform-apple/src/physical-inventory.ts b/packages/platform-apple/src/physical-inventory.ts new file mode 100644 index 0000000000..426f334055 --- /dev/null +++ b/packages/platform-apple/src/physical-inventory.ts @@ -0,0 +1,134 @@ +import type { + DeviceInventoryHostFor, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { + mapDevicectlAppleDevice, + resolveAppleOs, + resolveAppleTargetFromLabel, + type DevicectlAppleDevice, +} from './inventory-classification.ts'; + +const IOS_DEVICECTL_LIST_TIMEOUT_MS = 8_000; +const XCTRACE_SECTION_HEADER_PATTERN = /^==\s*(.+?)\s*==$/; +const XCTRACE_DEVICE_LINE_PATTERN = + /^(?.+?)\s+(?:\[(?[^[\]]+)\]|\((?[^)]+)\)\s+\((?[^)]+)\))\s*$/; + +type DevicectlListDevicesPayload = { + result?: { devices?: DevicectlAppleDevice[] }; +}; + +export function parseXctracePhysicalAppleDevices(output: string): DeviceInfo[] { + const devices: DeviceInfo[] = []; + let section: string | null = null; + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const sectionMatch = XCTRACE_SECTION_HEADER_PATTERN.exec(line); + if (sectionMatch) { + section = sectionMatch[1]?.trim() ?? null; + continue; + } + if (section !== 'Devices') continue; + const device = parseXctracePhysicalAppleDeviceLine(line); + if (device) devices.push(device); + } + return devices; +} + +function parseXctracePhysicalAppleDeviceLine(line: string): DeviceInfo | undefined { + const groups = XCTRACE_DEVICE_LINE_PATTERN.exec(line)?.groups; + if (!groups) return undefined; + const name = groups.name?.trim(); + const id = groups.idBracket?.trim() || groups.idParen?.trim(); + if (!name || !id) return undefined; + const target = resolveAppleTargetFromLabel(name); + if (!target) return undefined; + const osVersion = groups.osVersion?.trim(); + return { + platform: 'apple', + id, + name, + kind: 'device', + target, + appleOs: resolveAppleOs(target, osVersion ? [name, osVersion] : [name]), + iosPhysicalDeviceBackend: 'xctest', + booted: true, + }; +} + +export async function listPhysicalAppleDevices( + host: DeviceInventoryHostFor<'apple'>, + scope: PlatformRequestScope, +): Promise { + const [devicectl, xctrace] = await Promise.all([ + listPhysicalDevicesFromDevicectl(host, scope), + listPhysicalDevicesFromXctrace(host, scope), + ]); + return mergeAppleDevices(devicectl, xctrace); +} + +async function listPhysicalDevicesFromDevicectl( + host: DeviceInventoryHostFor<'apple'>, + scope: PlatformRequestScope, +): Promise { + let temporaryFile; + try { + temporaryFile = await host.files.createTemporaryTextFile({ + prefix: 'agent-device-devicectl-', + suffix: '.json', + }); + const result = await host.appleTools.run( + { + tool: 'devicectl', + args: ['list', 'devices', '--json-output', temporaryFile.path], + allowFailure: true, + timeoutMs: IOS_DEVICECTL_LIST_TIMEOUT_MS, + }, + scope.signal, + ); + if (result.exitCode !== 0) return []; + const payload = JSON.parse(await temporaryFile.readText()) as DevicectlListDevicesPayload; + return (payload.result?.devices ?? []).flatMap((device) => { + const mapped = mapDevicectlAppleDevice(device); + return mapped ? [mapped] : []; + }); + } catch { + scope.signal.throwIfAborted(); + return []; + } finally { + try { + await temporaryFile?.[Symbol.asyncDispose](); + } catch { + // Discovery is best-effort; cleanup failure must not mask an available fallback. + } + } +} + +async function listPhysicalDevicesFromXctrace( + host: DeviceInventoryHostFor<'apple'>, + scope: PlatformRequestScope, +): Promise { + try { + const result = await host.appleTools.run( + { tool: 'xctrace', args: ['list', 'devices'], allowFailure: true }, + scope.signal, + ); + return result.exitCode === 0 ? parseXctracePhysicalAppleDevices(result.stdout) : []; + } catch { + scope.signal.throwIfAborted(); + return []; + } +} + +function mergeAppleDevices(primary: DeviceInfo[], supplemental: DeviceInfo[]): DeviceInfo[] { + const ids = new Set(primary.map((device) => device.id)); + const merged = [...primary]; + for (const device of supplemental) { + if (ids.has(device.id)) continue; + ids.add(device.id); + merged.push(device); + } + return merged; +} diff --git a/packages/platform-apple/src/simulator-inventory.test.ts b/packages/platform-apple/src/simulator-inventory.test.ts new file mode 100644 index 0000000000..501e42d74c --- /dev/null +++ b/packages/platform-apple/src/simulator-inventory.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { + buildSimctlListArgs, + listAppleSimulators, + parseSimctlAppleDevices, +} from './simulator-inventory.ts'; +import { commandResult, createInventoryHost, inventoryScope } from './inventory.fixtures.ts'; + +const simulatorPayload = { + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + name: 'iPhone 16', + udid: 'iphone-1', + state: 'Booted', + isAvailable: true, + }, + { + name: 'Unavailable iPad', + udid: 'ipad-1', + state: 'Shutdown', + isAvailable: false, + }, + ], + 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [ + { + name: 'Apple TV 4K', + udid: 'tv-1', + state: 'Shutdown', + isAvailable: true, + }, + ], + 'com.apple.CoreSimulator.SimRuntime.watchOS-11-0': [ + { + name: 'Apple Watch', + udid: 'watch-1', + state: 'Booted', + isAvailable: true, + }, + ], + }, +}; + +test('simctl parser keeps available supported runtimes and their target semantics', () => { + assert.deepEqual(parseSimctlAppleDevices(simulatorPayload, '/tmp/custom-set'), [ + { + platform: 'apple', + id: 'iphone-1', + name: 'iPhone 16', + kind: 'simulator', + target: 'mobile', + appleOs: 'ios', + booted: true, + simulatorSetPath: '/tmp/custom-set', + }, + { + platform: 'apple', + id: 'tv-1', + name: 'Apple TV 4K', + kind: 'simulator', + target: 'tv', + appleOs: 'tvos', + booted: false, + simulatorSetPath: '/tmp/custom-set', + }, + ]); +}); + +test('simulator inventory scopes bounded simctl and reports fresh booted observations', async () => { + const calls: Array<{ tool: string; args: readonly string[]; timeoutMs?: number }> = []; + const observed: string[] = []; + const host = createInventoryHost({ + observed, + run: async (request) => { + calls.push({ tool: request.tool, args: request.args, timeoutMs: request.timeoutMs }); + return commandResult(JSON.stringify(simulatorPayload)); + }, + }); + + const devices = await listAppleSimulators( + host, + { iosSimulatorSetPath: ' /tmp/custom-set ', target: 'mobile', booted: true }, + inventoryScope, + ); + + assert.deepEqual(calls, [ + { + tool: 'simctl', + args: ['--set', '/tmp/custom-set', 'list', 'devices', '-j'], + timeoutMs: 3_000, + }, + ]); + assert.deepEqual( + devices.map((device) => device.id), + ['iphone-1', 'tv-1'], + ); + assert.deepEqual(observed, ['iphone-1']); + assert.deepEqual(buildSimctlListArgs(undefined), ['list', 'devices', '-j']); +}); + +test('simulator inventory classifies malformed native output as a command failure', async () => { + const host = createInventoryHost({ run: async () => commandResult('not json') }); + await assert.rejects( + listAppleSimulators(host, {}, inventoryScope), + (error: unknown) => error instanceof AppError && error.code === 'COMMAND_FAILED', + ); +}); diff --git a/packages/platform-apple/src/simulator-inventory.ts b/packages/platform-apple/src/simulator-inventory.ts new file mode 100644 index 0000000000..48be1954c7 --- /dev/null +++ b/packages/platform-apple/src/simulator-inventory.ts @@ -0,0 +1,83 @@ +import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; +import type { + DeviceInventoryHostFor, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { sortAppleDevicesForSelection, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { + isSupportedAppleRuntime, + resolveAppleOs, + resolveAppleTargetFromRuntime, +} from './inventory-classification.ts'; + +type SimctlDeviceRecord = { + name: string; + udid: string; + state: string; + isAvailable: boolean; + deviceTypeIdentifier?: string; +}; + +type SimctlListDevicesPayload = { + devices: Record; +}; + +const BOOTED_SIMULATOR_PROBE_TIMEOUT_MS = 3_000; + +export function buildSimctlListArgs(simulatorSetPath: string | undefined): string[] { + const path = simulatorSetPath?.trim(); + return path ? ['--set', path, 'list', 'devices', '-j'] : ['list', 'devices', '-j']; +} + +export function parseSimctlAppleDevices( + payload: SimctlListDevicesPayload, + simulatorSetPath: string | undefined, +): DeviceInfo[] { + const devices: DeviceInfo[] = []; + for (const [runtime, runtimes] of Object.entries(payload.devices)) { + if (!isSupportedAppleRuntime(runtime)) continue; + for (const device of runtimes) { + if (!device.isAvailable) continue; + const target = resolveAppleTargetFromRuntime(runtime); + devices.push({ + platform: 'apple', + id: device.udid, + name: device.name, + kind: 'simulator', + target, + appleOs: resolveAppleOs(target, [device.deviceTypeIdentifier ?? '', device.name]), + booted: device.state === 'Booted', + ...(simulatorSetPath ? { simulatorSetPath } : {}), + }); + } + } + return devices; +} + +export async function listAppleSimulators( + host: DeviceInventoryHostFor<'apple'>, + request: Readonly, + scope: PlatformRequestScope, +): Promise { + const simulatorSetPath = request.iosSimulatorSetPath?.trim() || undefined; + const result = await host.appleTools.run( + { + tool: 'simctl', + args: buildSimctlListArgs(simulatorSetPath), + ...(request.booted === true ? { timeoutMs: BOOTED_SIMULATOR_PROBE_TIMEOUT_MS } : {}), + }, + scope.signal, + ); + let devices: DeviceInfo[]; + try { + const parsed = JSON.parse(result.stdout) as SimctlListDevicesPayload; + devices = parseSimctlAppleDevices(parsed, simulatorSetPath); + } catch (error) { + throw new AppError('COMMAND_FAILED', 'Failed to parse simctl devices JSON', undefined, error); + } + for (const device of devices) { + if (device.booted === true) await host.observations.deviceBooted(device); + } + return sortAppleDevicesForSelection(devices); +} diff --git a/packages/platform-apple/tsconfig.json b/packages/platform-apple/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/platform-apple/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/platform-harmonyos/package.json b/packages/platform-harmonyos/package.json new file mode 100644 index 0000000000..598d45bfcd --- /dev/null +++ b/packages/platform-harmonyos/package.json @@ -0,0 +1,17 @@ +{ + "name": "@agent-device/platform-harmonyos", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "HarmonyOS-family platform runtime metadata and implementations for agent-device.", + "dependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/platform-harmonyos/src/index.ts b/packages/platform-harmonyos/src/index.ts new file mode 100644 index 0000000000..1da93c6c8d --- /dev/null +++ b/packages/platform-harmonyos/src/index.ts @@ -0,0 +1,33 @@ +import type { + InventoryPlatformModule, + PlatformModuleMetadata, +} from '@agent-device/contracts/platform'; +import type { HarmonyInventoryConfig } from './inventory-config.ts'; + +const metadata = Object.freeze({ + family: 'harmonyos', +} satisfies PlatformModuleMetadata); + +export type { HarmonyInventoryConfig } from './inventory-config.ts'; + +export function createHarmonyInventoryModule( + config: HarmonyInventoryConfig, +): InventoryPlatformModule<'harmonyos'> { + const capturedConfig = Object.freeze({ + hdcSdkPath: normalizedRoot(config.hdcSdkPath), + devecoSdkHome: normalizedRoot(config.devecoSdkHome), + commandLineToolsHome: normalizedRoot(config.commandLineToolsHome), + }); + return Object.freeze({ + ...metadata, + loadInventory: async (host) => { + const { createHarmonyInventory } = await import('./inventory.ts'); + return createHarmonyInventory(host, capturedConfig); + }, + }); +} + +function normalizedRoot(root: string | undefined): string | undefined { + const trimmed = root?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/packages/platform-harmonyos/src/inventory-config.ts b/packages/platform-harmonyos/src/inventory-config.ts new file mode 100644 index 0000000000..facc5060fe --- /dev/null +++ b/packages/platform-harmonyos/src/inventory-config.ts @@ -0,0 +1,8 @@ +export type HarmonyInventoryConfig = Readonly<{ + /** Raw HDC_SDK_PATH captured inertly by the composition root. */ + hdcSdkPath?: string; + /** Raw DEVECO_SDK_HOME captured inertly by the composition root. */ + devecoSdkHome?: string; + /** Raw HARMONYOS_COMMAND_LINE_TOOLS captured inertly by the composition root. */ + commandLineToolsHome?: string; +}>; diff --git a/packages/platform-harmonyos/src/inventory.test.ts b/packages/platform-harmonyos/src/inventory.test.ts new file mode 100644 index 0000000000..f796a174c0 --- /dev/null +++ b/packages/platform-harmonyos/src/inventory.test.ts @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { + DeviceInventoryHostFor, + HostCommandRequest, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { createHarmonyInventory, parseHarmonyTargetList } from './inventory.ts'; + +const scope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => undefined }, + progress: { report: () => undefined }, +}; + +test('Harmony inventory parses targets, probes through the host port, and applies serial selection', async () => { + assert.deepEqual(parseHarmonyTargetList('\n127.0.0.1:5555\n[Empty]\n'), ['127.0.0.1:5555']); + const calls: HostCommandRequest[] = []; + const host = createHost(async (request) => { + calls.push(request); + const key = request.args.join('\0'); + if (key === 'list\0targets') return result('emulator-1\nphysical-1\n'); + if (key.includes('const.product.name')) { + return result(key.startsWith('-t\0emulator-1') ? 'emulator\n' : 'Mate 60\n'); + } + if (key.includes('const.build.characteristics')) return result('tv\n'); + throw new Error(`Unexpected HDC invocation: ${key}`); + }); + + const devices = await createHarmonyInventory(host).discover({ serial: 'physical-1' }, scope); + + assert.deepEqual(devices, [ + { + platform: 'harmonyos', + id: 'physical-1', + name: 'Mate 60', + kind: 'device', + target: 'tv', + booted: true, + }, + ]); + assert.ok(calls.every((call) => call.executable === 'hdc')); + assert.ok(calls.every((call) => call.timeoutMs === 10_000)); +}); + +test('Harmony inventory resolves captured HDC toolchain roots without mutating host state', async () => { + const host = createHost( + async (request) => { + assert.equal(request.executable, '/opt/deveco/default/openharmony/toolchains/hdc'); + return result(''); + }, + { + which: async () => undefined, + isExecutable: async (candidate) => + candidate === '/opt/deveco/default/openharmony/toolchains/hdc', + }, + ); + + assert.deepEqual( + await createHarmonyInventory(host, { devecoSdkHome: '/opt/deveco' }).discover({}, scope), + [], + ); +}); + +test('Harmony inventory prepares a configured legacy toolchain before discovery', async () => { + const preparedFamilies: string[] = []; + const host = { + ...createHost( + async (request) => { + assert.equal(request.executable, '/opt/deveco/default/openharmony/toolchains/hdc'); + return result(''); + }, + { + which: async () => undefined, + isExecutable: async (candidate) => + candidate === '/opt/deveco/default/openharmony/toolchains/hdc', + }, + ), + toolchains: { + prepare: async (family: string) => { + preparedFamilies.push(family); + }, + }, + }; + + await createHarmonyInventory(host, { devecoSdkHome: '/opt/deveco' }).discover({}, scope); + + assert.deepEqual(preparedFamilies, ['harmonyos']); +}); + +test('Harmony inventory preserves the legacy curated HDC failure shape', async () => { + const host = createHost(async () => result('ignored', 'transport failed', 7)); + + await assert.rejects(createHarmonyInventory(host).discover({}, scope), (error: unknown) => { + if (!(error instanceof Error) || !('details' in error)) return false; + assert.deepEqual(error.details, { + stderr: 'transport failed', + hint: 'Confirm the HarmonyOS emulator or device is running, then run hdc list targets.', + }); + return true; + }); +}); + +function createHost( + run: DeviceInventoryHostFor<'harmonyos'>['commands']['run'], + tools: { + which?: DeviceInventoryHostFor<'harmonyos'>['commands']['which']; + isExecutable?: DeviceInventoryHostFor<'harmonyos'>['files']['isExecutable']; + } = {}, +): DeviceInventoryHostFor<'harmonyos'> { + return { + commands: { which: tools.which ?? (async () => 'hdc'), run }, + toolchains: { prepare: async () => undefined }, + files: { + isExecutable: tools.isExecutable ?? (async () => false), + createTemporaryTextFile: async () => { + throw new Error('unused'); + }, + }, + }; +} + +function result(stdout: string, stderr = '', exitCode = 0) { + return { stdout, stderr, exitCode }; +} diff --git a/packages/platform-harmonyos/src/inventory.ts b/packages/platform-harmonyos/src/inventory.ts new file mode 100644 index 0000000000..08e3c31dd8 --- /dev/null +++ b/packages/platform-harmonyos/src/inventory.ts @@ -0,0 +1,118 @@ +import path from 'node:path'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + DeviceInventoryHostFor, + DeviceInventorySource, + HostCommandResult, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; +import type { HarmonyInventoryConfig } from './inventory-config.ts'; + +const PROBE_TIMEOUT_MS = 10_000; + +export function createHarmonyInventory( + host: DeviceInventoryHostFor<'harmonyos'>, + config: HarmonyInventoryConfig = {}, +): DeviceInventorySource { + return { + discover: async (request, scope) => await discoverHarmonyDevices(host, config, request, scope), + }; +} + +export function parseHarmonyTargetList(rawOutput: string): string[] { + return rawOutput + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && line !== '[Empty]'); +} + +async function discoverHarmonyDevices( + host: DeviceInventoryHostFor<'harmonyos'>, + config: HarmonyInventoryConfig, + request: Readonly, + scope: PlatformRequestScope, +): Promise { + await host.toolchains.prepare('harmonyos'); + const hdc = await resolveHdc(host, configuredToolchainRoots(config)); + if (!hdc) { + throw new AppError('TOOL_MISSING', 'hdc not found in PATH', { + hint: 'Install HarmonyOS Command Line Tools, then add its sdk/default/openharmony/toolchains directory to PATH.', + }); + } + const result = await runHdc(host, hdc, ['list', 'targets'], scope); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'hdc list targets failed', { + stderr: result.stderr, + hint: 'Confirm the HarmonyOS emulator or device is running, then run hdc list targets.', + }); + } + const targets = parseHarmonyTargetList(result.stdout).filter( + (target) => !request.serial || request.serial === target, + ); + const devices = await Promise.all( + targets.map(async (target) => await probeHarmonyDevice(host, hdc, target, scope)), + ); + return devices; +} + +function configuredToolchainRoots(config: HarmonyInventoryConfig): readonly string[] { + return [ + config.hdcSdkPath, + config.devecoSdkHome && path.join(config.devecoSdkHome, 'default', 'openharmony', 'toolchains'), + config.commandLineToolsHome && + path.join(config.commandLineToolsHome, 'sdk', 'default', 'openharmony', 'toolchains'), + ].filter((root): root is string => root !== undefined); +} + +async function resolveHdc( + host: DeviceInventoryHostFor<'harmonyos'>, + toolchainRoots: readonly string[], +): Promise { + for (const root of toolchainRoots) { + const candidate = path.join(root, 'hdc'); + if (await host.files.isExecutable(candidate)) return candidate; + } + return await host.commands.which('hdc'); +} + +async function probeHarmonyDevice( + host: DeviceInventoryHostFor<'harmonyos'>, + hdc: string, + target: string, + scope: PlatformRequestScope, +): Promise { + const prefix = ['-t', target, 'shell', 'param', 'get']; + const [nameResult, characteristicsResult] = await Promise.all([ + runHdc(host, hdc, [...prefix, 'const.product.name'], scope), + runHdc(host, hdc, [...prefix, 'const.build.characteristics'], scope), + ]); + const name = nameResult.exitCode === 0 ? nameResult.stdout.trim() : ''; + const emulator = name.toLowerCase() === 'emulator'; + return { + platform: 'harmonyos', + id: target, + name: name || target, + kind: emulator ? 'emulator' : 'device', + target: characteristicsResult.stdout.toLowerCase().includes('tv') ? 'tv' : 'mobile', + booted: true, + }; +} + +async function runHdc( + host: DeviceInventoryHostFor<'harmonyos'>, + executable: string, + args: readonly string[], + scope: PlatformRequestScope, +): Promise { + return await host.commands.run( + { + executable, + args, + allowFailure: true, + timeoutMs: PROBE_TIMEOUT_MS, + }, + scope.signal, + ); +} diff --git a/packages/platform-harmonyos/tsconfig.json b/packages/platform-harmonyos/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/platform-harmonyos/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/platform-linux/package.json b/packages/platform-linux/package.json new file mode 100644 index 0000000000..5ddd4534ba --- /dev/null +++ b/packages/platform-linux/package.json @@ -0,0 +1,17 @@ +{ + "name": "@agent-device/platform-linux", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Linux-family platform runtime metadata and implementations for agent-device.", + "dependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/platform-linux/src/index.ts b/packages/platform-linux/src/index.ts new file mode 100644 index 0000000000..404a7fe6cc --- /dev/null +++ b/packages/platform-linux/src/index.ts @@ -0,0 +1,16 @@ +import type { + InventoryPlatformModule, + PlatformModuleMetadata, +} from '@agent-device/contracts/platform'; + +const metadata = Object.freeze({ + family: 'linux', +} satisfies PlatformModuleMetadata); + +export const inventoryModule: InventoryPlatformModule<'linux'> = Object.freeze({ + ...metadata, + loadInventory: async (host) => { + const { createLinuxInventory } = await import('./inventory.ts'); + return createLinuxInventory(host); + }, +}); diff --git a/packages/platform-linux/src/inventory.test.ts b/packages/platform-linux/src/inventory.test.ts new file mode 100644 index 0000000000..8266ab535a --- /dev/null +++ b/packages/platform-linux/src/inventory.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { + DeviceInventoryHostFor, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { createLinuxInventory } from './inventory.ts'; + +const scope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => undefined }, + progress: { report: () => undefined }, +}; + +test('Linux inventory derives the local desktop solely from injected host facts', async () => { + const inventory = createLinuxInventory(createHost('linux')); + assert.deepEqual(await inventory.discover({}, scope), [ + { + platform: 'linux', + id: 'local', + name: 'linux-host', + kind: 'device', + target: 'desktop', + booted: true, + }, + ]); + assert.deepEqual(await createLinuxInventory(createHost('darwin')).discover({}, scope), []); +}); + +function createHost( + hostOs: DeviceInventoryHostFor<'linux'>['hostOs'], +): DeviceInventoryHostFor<'linux'> { + return { + hostOs, + hostName: 'linux-host', + }; +} diff --git a/packages/platform-linux/src/inventory.ts b/packages/platform-linux/src/inventory.ts new file mode 100644 index 0000000000..d9fe6a7266 --- /dev/null +++ b/packages/platform-linux/src/inventory.ts @@ -0,0 +1,24 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { + DeviceInventoryHostFor, + DeviceInventorySource, +} from '@agent-device/contracts/platform'; + +export function createLinuxInventory(host: DeviceInventoryHostFor<'linux'>): DeviceInventorySource { + return { + discover: async () => discoverLinuxDevices(host), + }; +} + +function discoverLinuxDevices(host: DeviceInventoryHostFor<'linux'>): readonly DeviceInfo[] { + if (host.hostOs !== 'linux') return []; + const device: DeviceInfo = { + platform: 'linux', + id: 'local', + name: host.hostName, + kind: 'device', + target: 'desktop', + booted: true, + }; + return [device]; +} diff --git a/packages/platform-linux/tsconfig.json b/packages/platform-linux/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/platform-linux/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/platform-vega/package.json b/packages/platform-vega/package.json new file mode 100644 index 0000000000..bbcda342bc --- /dev/null +++ b/packages/platform-vega/package.json @@ -0,0 +1,17 @@ +{ + "name": "@agent-device/platform-vega", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Vega-family platform runtime metadata and implementations for agent-device.", + "dependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/platform-vega/src/index.ts b/packages/platform-vega/src/index.ts new file mode 100644 index 0000000000..4759dd000f --- /dev/null +++ b/packages/platform-vega/src/index.ts @@ -0,0 +1,16 @@ +import type { + InventoryPlatformModule, + PlatformModuleMetadata, +} from '@agent-device/contracts/platform'; + +const metadata = Object.freeze({ + family: 'vega', +} satisfies PlatformModuleMetadata); + +export const inventoryModule: InventoryPlatformModule<'vega'> = Object.freeze({ + ...metadata, + loadInventory: async (host) => { + const { createVegaInventory } = await import('./inventory.ts'); + return createVegaInventory(host); + }, +}); diff --git a/packages/platform-vega/src/inventory.test.ts b/packages/platform-vega/src/inventory.test.ts new file mode 100644 index 0000000000..ccf1a30dcf --- /dev/null +++ b/packages/platform-vega/src/inventory.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + DeviceInventoryHostFor, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { createVegaInventory, parseVegaDeviceList } from './inventory.ts'; + +const scope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => undefined }, + progress: { report: () => undefined }, +}; + +test('Vega inventory resolves the default CLI lazily and accepts only its virtual device', async () => { + const host = createHost({ + which: async () => undefined, + isExecutable: async (path) => path === '/Users/test/vega/bin/vega', + run: async (request) => { + assert.equal(request.executable, '/Users/test/vega/bin/vega'); + assert.deepEqual(request.args, ['device', 'list']); + assert.equal(request.timeoutMs, 10_000); + return result('physical : tv\nVirtualDevice : tv - aarch64 - VegaOS\n'); + }, + }); + + const devices = await createVegaInventory(host).discover({}, scope); + + assert.deepEqual(devices, parseVegaDeviceList('VirtualDevice : tv\n')); +}); + +test('Vega inventory fails closed when only an unsupported physical target is present', async () => { + const host = createHost({ run: async () => result('physical : tv\n') }); + await assert.rejects( + createVegaInventory(host).discover({}, scope), + (error: unknown) => error instanceof AppError && error.code === 'DEVICE_NOT_FOUND', + ); +}); + +function createHost(options: { + which?: DeviceInventoryHostFor<'vega'>['commands']['which']; + isExecutable?: DeviceInventoryHostFor<'vega'>['files']['isExecutable']; + run: DeviceInventoryHostFor<'vega'>['commands']['run']; +}): DeviceInventoryHostFor<'vega'> { + return { + commands: { which: options.which ?? (async () => 'vega'), run: options.run }, + files: { + isExecutable: options.isExecutable ?? (async () => false), + createTemporaryTextFile: async () => { + throw new Error('unused'); + }, + }, + homeDirectory: '/Users/test', + }; +} + +function result(stdout: string, stderr = '', exitCode = 0) { + return { stdout, stderr, exitCode }; +} diff --git a/packages/platform-vega/src/inventory.ts b/packages/platform-vega/src/inventory.ts new file mode 100644 index 0000000000..c3e83e2447 --- /dev/null +++ b/packages/platform-vega/src/inventory.ts @@ -0,0 +1,99 @@ +import path from 'node:path'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + DeviceInventoryHostFor, + DeviceInventorySource, + HostCommandResult, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; + +const DISCOVERY_TIMEOUT_MS = 10_000; +const VVD_SERIAL = 'VirtualDevice'; + +export function createVegaInventory(host: DeviceInventoryHostFor<'vega'>): DeviceInventorySource { + return { + discover: async (_request, scope) => await discoverVegaDevices(host, scope), + }; +} + +export function parseVegaDeviceList(rawOutput: string): DeviceInfo[] { + if (!parseListedVegaSerials(rawOutput).some(isVegaVirtualDevice)) return []; + return [ + { + platform: 'vega', + id: VVD_SERIAL, + name: `Vega Virtual Device (${VVD_SERIAL})`, + kind: 'emulator', + target: 'tv', + booted: true, + }, + ]; +} + +async function discoverVegaDevices( + host: DeviceInventoryHostFor<'vega'>, + scope: PlatformRequestScope, +): Promise { + const executable = await resolveVegaExecutable(host); + if (!executable) { + throw new AppError('TOOL_MISSING', 'Vega CLI not found in PATH', { + hint: 'Install Vega Developer Tools, source ~/vega/env, and retry devices.', + }); + } + const result = await host.commands.run( + { + executable, + args: ['device', 'list'], + allowFailure: true, + timeoutMs: DISCOVERY_TIMEOUT_MS, + }, + scope.signal, + ); + assertCommandSuccess(result); + const devices = parseVegaDeviceList(result.stdout); + const listedSerials = parseListedVegaSerials(result.stdout); + if (devices.length === 0 && listedSerials.length > 0) { + throw new AppError( + 'DEVICE_NOT_FOUND', + 'Vega CLI found devices, but no supported Vega Virtual Device is running.', + { + listedSerials, + hint: 'Start the Vega Virtual Device, then retry with --platform vega --target tv.', + }, + ); + } + return devices; +} + +async function resolveVegaExecutable( + host: DeviceInventoryHostFor<'vega'>, +): Promise { + const resolved = await host.commands.which('vega'); + if (resolved) return resolved; + const defaultInstall = path.join(host.homeDirectory, 'vega', 'bin', 'vega'); + return (await host.files.isExecutable(defaultInstall)) ? defaultInstall : undefined; +} + +function assertCommandSuccess(result: HostCommandResult): void { + if (result.exitCode === 0) return; + throw new AppError('COMMAND_FAILED', 'Failed to list Vega devices', { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + processExitError: true, + }); +} + +function isVegaVirtualDevice(serial: string): boolean { + return serial.toLowerCase() === VVD_SERIAL.toLowerCase(); +} + +function parseListedVegaSerials(rawOutput: string): string[] { + const serials = new Set(); + for (const rawLine of rawOutput.split(/\r?\n/)) { + const serial = /^\s*([^\s:]+)\s*:\s*\S/.exec(rawLine)?.[1]?.trim(); + if (serial) serials.add(serial); + } + return [...serials]; +} diff --git a/packages/platform-vega/tsconfig.json b/packages/platform-vega/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/platform-vega/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json new file mode 100644 index 0000000000..190b4a4b20 --- /dev/null +++ b/packages/platform-web/package.json @@ -0,0 +1,16 @@ +{ + "name": "@agent-device/platform-web", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Web-family platform runtime metadata and implementations for agent-device.", + "dependencies": { + "@agent-device/contracts": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/platform-web/src/index.ts b/packages/platform-web/src/index.ts new file mode 100644 index 0000000000..c7932eb4fc --- /dev/null +++ b/packages/platform-web/src/index.ts @@ -0,0 +1,16 @@ +import type { + InventoryPlatformModule, + PlatformModuleMetadata, +} from '@agent-device/contracts/platform'; + +const metadata = Object.freeze({ + family: 'web', +} satisfies PlatformModuleMetadata); + +export const inventoryModule: InventoryPlatformModule<'web'> = Object.freeze({ + ...metadata, + loadInventory: async () => { + const { createWebInventory } = await import('./inventory.ts'); + return createWebInventory(); + }, +}); diff --git a/packages/platform-web/src/inventory.test.ts b/packages/platform-web/src/inventory.test.ts new file mode 100644 index 0000000000..9117facad9 --- /dev/null +++ b/packages/platform-web/src/inventory.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform'; +import { createWebInventory } from './inventory.ts'; + +const scope: PlatformRequestScope = { + signal: new AbortController().signal, + diagnostics: { emit: () => undefined }, + progress: { report: () => undefined }, +}; + +test('Web inventory exposes the established static browser target without host probing', async () => { + assert.deepEqual(await createWebInventory().discover({}, scope), [ + { + platform: 'web', + id: 'agent-browser-chrome', + name: 'Agent Browser Chrome', + kind: 'device', + target: 'desktop', + booted: true, + }, + ]); +}); diff --git a/packages/platform-web/src/inventory.ts b/packages/platform-web/src/inventory.ts new file mode 100644 index 0000000000..1cbc50431d --- /dev/null +++ b/packages/platform-web/src/inventory.ts @@ -0,0 +1,10 @@ +import { WEB_DESKTOP_DEVICE } from '@agent-device/contracts/device'; +import type { DeviceInventorySource } from '@agent-device/contracts/platform'; + +export function createWebInventory(): DeviceInventorySource { + return { + discover: async () => { + return [WEB_DESKTOP_DEVICE]; + }, + }; +} diff --git a/packages/platform-web/tsconfig.json b/packages/platform-web/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/platform-web/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cf9f466c7..da0f604c25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,24 @@ importers: '@agent-device/maestro': specifier: workspace:* version: link:packages/maestro + '@agent-device/platform-android': + specifier: workspace:* + version: link:packages/platform-android + '@agent-device/platform-apple': + specifier: workspace:* + version: link:packages/platform-apple + '@agent-device/platform-harmonyos': + specifier: workspace:* + version: link:packages/platform-harmonyos + '@agent-device/platform-linux': + specifier: workspace:* + version: link:packages/platform-linux + '@agent-device/platform-vega': + specifier: workspace:* + version: link:packages/platform-vega + '@agent-device/platform-web': + specifier: workspace:* + version: link:packages/platform-web '@agent-device/provider-limrun': specifier: workspace:* version: link:packages/provider-limrun @@ -171,6 +189,57 @@ importers: specifier: ^2.9.0 version: 2.9.0 + packages/platform-android: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + + packages/platform-apple: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + + packages/platform-harmonyos: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + + packages/platform-linux: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + + packages/platform-vega: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + + packages/platform-web: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + packages/provider-limrun: dependencies: '@agent-device/contracts': diff --git a/scripts/check-affected/checks.ts b/scripts/check-affected/checks.ts index 2b50649b5a..f6b2012f89 100644 --- a/scripts/check-affected/checks.ts +++ b/scripts/check-affected/checks.ts @@ -88,6 +88,13 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ // Needs a `pnpm build` output and the npm registry, both of which local runs already have. localRunnable: true, }, + { + id: 'integration-node', + label: 'Node integration smoke', + kind: { type: 'script', script: 'test:integration:node' }, + ciJobs: ['Integration Tests'], + localRunnable: true, + }, { id: 'vitest-related', label: 'Tests related by Vitest module graph', @@ -116,13 +123,6 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ ciJobs: ['Integration Tests', 'Coverage'], localRunnable: true, }, - { - id: 'integration-node', - label: 'Node integration smoke', - kind: { type: 'script', script: 'test:integration:node' }, - ciJobs: ['Integration Tests'], - localRunnable: true, - }, { id: 'integration-progress', label: 'Integration architecture-progress gate', diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index 47af4b9ccb..064ff2851b 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -53,11 +53,13 @@ export const ALL_CHECKS: readonly CheckId[] = [ 'mcp-metadata', 'build', 'package', + // Real daemon/process integration owns host-global lifecycle state and must + // run before the high-parallelism coverage workload heats the host. + 'integration-node', 'vitest-related', 'unit', 'coverage', 'provider-integration', - 'integration-node', 'integration-progress', 'swift-runner', 'android-helpers', @@ -255,6 +257,32 @@ const workspacePackageOwnership: OwnershipRule = ({ file, isTs }) => { return selections; }; +const platformPackageScenarioOwnership: OwnershipRule = ({ file, isTs }) => { + if (!isTs || !/^packages\/platform-[^/]+\/src\//.test(file)) { + return []; + } + return [ + reason( + 'unit', + file, + 'platform-package-contract', + 'platform packages must satisfy the shared runtime contract scenarios', + ), + reason( + 'provider-integration', + file, + 'platform-package-provider', + 'platform packages participate in provider-first ownership scenarios', + ), + reason( + 'coverage', + file, + 'platform-package-coverage', + 'platform package changes require affected contract coverage evidence', + ), + ]; +}; + const nodeIntegrationOwnership: OwnershipRule = ({ file }) => isNodeIntegrationPath(file) ? [reason('integration-node', file, 'node-integration', 'node --test integration smoke')] @@ -360,6 +388,7 @@ const OWNERSHIP_RULES: readonly OwnershipRule[] = [ srcProdGate, vitestRelatedOwnership, workspacePackageOwnership, + platformPackageScenarioOwnership, nodeIntegrationOwnership, testAppOwnership, replayCompatOwnership, diff --git a/scripts/check-affected/platform-packages.test.ts b/scripts/check-affected/platform-packages.test.ts new file mode 100644 index 0000000000..ea30a18eae --- /dev/null +++ b/scripts/check-affected/platform-packages.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { selectChecks } from './model.ts'; + +test('platform package source selects runtime contracts, provider scenarios, and coverage', () => { + for (const file of [ + 'packages/platform-apple/src/index.ts', + 'packages/platform-harmonyos/src/inventory.test.ts', + 'packages/platform-future/src/runtime.ts', + ]) { + const result = selectChecks({ changedFiles: [file] }); + assert.equal(result.failOpen, false, file); + for (const id of ['unit', 'provider-integration', 'coverage'] as const) { + assert.ok(result.checks.includes(id), `expected ${id} for ${file}`); + } + assert.ok( + result.reasons.some((reason) => reason.rule === 'platform-package-contract'), + `expected explicit platform contract ownership for ${file}`, + ); + } +}); diff --git a/scripts/check-affected/run.test.ts b/scripts/check-affected/run.test.ts index 063a070f15..96ca17593d 100644 --- a/scripts/check-affected/run.test.ts +++ b/scripts/check-affected/run.test.ts @@ -184,6 +184,12 @@ test('runChecks combines related tests with lightweight changed-line coverage', assert.equal(related.length, 1); assert.ok(related[0]?.includes('--coverage')); assert.ok(related[0]?.includes('--coverage.reporter=lcov')); + assert.ok(related[0]?.includes('--maxWorkers=4')); + assert.ok( + executed.findIndex((command) => command.includes('test:integration:node')) < + executed.findIndex((command) => command.includes('related')), + 'process-lifecycle integration must run before high-parallelism affected coverage', + ); assert.equal( executed.some((command) => command.includes('test:coverage')), false, diff --git a/scripts/check-affected/run.ts b/scripts/check-affected/run.ts index 0b894ef642..8960511a9c 100644 --- a/scripts/check-affected/run.ts +++ b/scripts/check-affected/run.ts @@ -238,6 +238,12 @@ function resolveAffectedCoverageCommands( 'related', '--run', '--passWithNoTests', + // `related` spans every configured Vitest project for broad diffs. The + // machine-derived default can start enough projects concurrently to + // starve otherwise-green subprocess/provider tests past their exact + // timeout budgets. Bound this aggregate feedback lane without changing + // the suites' own timeout or serialization contracts. + '--maxWorkers=4', '--coverage', '--coverage.reporter=lcov', '--coverage.thresholds.statements=0', diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 00eb72ae39..8d89e4b356 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -31,6 +31,10 @@ // - Over BIN.TS'S ALIAS RESOLUTION: it must delegate to the one alias registry instead of // re-declaring a parallel mapping of its own (R12) — the same "delegate to your single // owner" shape as R7's SessionState ownership, applied to bin.ts's `--help` fast path. +// - Over PLATFORM PACKAGE COMPOSITION: six private metadata façades meet at the exact root +// composition file; premature implementation loading and forbidden cross-boundary edges fail (R13). +// - Over the DEVICES COMMAND CUTOVER: the handler calls the neutral inventory gateway and no +// superseded inventory module, import, or identifier remains in production (R13). // Only `(root)` is unranked among src/ zones (see `UNRANKED_ZONES` in model.ts): // it holds entrypoints and composition roots. Extracted workspace package zones // are classified separately and held behind R11 instead of the src folder spine. @@ -76,13 +80,25 @@ import { packageBoundariesSummary, workspaceSpecifierTargets, } from './package-boundaries.ts'; +import { + checkPlatformPackagePolicy, + platformPackagePolicySummary, +} from './platform-package-policy.ts'; +import { + checkDeviceInventoryCutover, + deviceInventoryCutoverSummary, +} from './device-inventory-cutover-policy.ts'; +import { + listUntrackedProductionTypeScriptFiles, + readTrackedPlatformPackageDeclarations, +} from './platform-package-repository.ts'; import { policyLead, policyViolation, ZONE_POLICIES } from './zone-policy.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', }).trim(); -export function listSourceFiles(): string[] { +export function listTypeScriptFiles(): string[] { // `src/**/*.ts` only matches nested files; root-level `src/*.ts` (e.g. // src/cli.ts, src/command-catalog.ts) needs its own pathspec or it silently // drops out of cycle/back-edge analysis. @@ -97,7 +113,11 @@ export function listSourceFiles(): string[] { encoding: 'utf8', }, ); - return out.split('\n').filter(Boolean).filter(isProductionSourceFile); + return out.split('\n').filter(Boolean); +} + +export function listSourceFiles(): string[] { + return listTypeScriptFiles().filter(isProductionSourceFile); } function readSources(files: readonly string[]): Map { @@ -522,7 +542,8 @@ function report( `all ${sessionStateFieldCount()} SessionState fields are classified and every write is ` + `inside its declared owner (R7); every zero-dep CI job resolves without ` + `node_modules (R8); ${typeCycleNote(typeCycle)}; ${daemonModularitySummary()}; ` + - `${packageBoundariesSummary(repoRoot)}; and bin.ts imports normalizeCliCommandAlias, ` + + `${packageBoundariesSummary(repoRoot)}; ${platformPackagePolicySummary()}; ` + + `${deviceInventoryCutoverSummary()}; and bin.ts imports normalizeCliCommandAlias, ` + `actually passes it into buildCommandUsageText, and holds no local alias literals ` + `(R12).\n`, ); @@ -553,6 +574,7 @@ function report( export function main(): number { const sourceFiles = listSourceFiles(); const sources = readSources(sourceFiles); + const allTypeScriptSources = readSources(listTypeScriptFiles()); const edges = resolveImportEdges(sources, workspaceSpecifierTargets(repoRoot)); // Computed once and threaded: the rule and the success line must report the same number. const typeCycleMembers = largestTypeCycleMembers(edges); @@ -570,6 +592,12 @@ export function main(): number { repoRoot, zeroDepClosureFiles(repoZeroDepJobs(), readSourceOrNull, fileExists), ), + ...checkPlatformPackagePolicy( + allTypeScriptSources, + readTrackedPlatformPackageDeclarations(repoRoot), + { untrackedProductionFiles: listUntrackedProductionTypeScriptFiles(repoRoot) }, + ), + ...checkDeviceInventoryCutover(sources), ]; return report(sourceFiles, violations, typeCycle); } diff --git a/scripts/layering/device-inventory-cutover-policy.test.ts b/scripts/layering/device-inventory-cutover-policy.test.ts new file mode 100644 index 0000000000..48985ac273 --- /dev/null +++ b/scripts/layering/device-inventory-cutover-policy.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { checkDeviceInventoryCutover } from './device-inventory-cutover-policy.ts'; + +const HANDLER_FILE = 'src/daemon/handlers/session-inventory.ts'; + +function handlerSource(localName = 'listDeviceInventory'): string { + return [ + `import { listDeviceInventory as ${localName} } from '../../core/dispatch-resolve.ts';`, + `export async function handleDevices(request: Request) { return await ${localName}(request); }`, + ].join('\n'); +} + +function sources( + handler = handlerSource(), + extra: readonly (readonly [string, string])[] = [], +): ReadonlyMap { + return new Map([[HANDLER_FILE, handler], ...extra]); +} + +function messages(input: ReadonlyMap): string { + return checkDeviceInventoryCutover(input) + .map(({ message }) => message) + .join('\n'); +} + +test('devices keeps one gateway-owned handler route through an aliased import', () => { + assert.deepEqual(checkDeviceInventoryCutover(sources(handlerSource('discoverInventory'))), []); +}); + +test('devices rejects a handler that imports but never calls the inventory gateway', () => { + const handler = [ + "import { listDeviceInventory } from '../../core/dispatch-resolve.ts';", + 'export async function handleDevices() { return []; }', + ].join('\n'); + + assert.match(messages(sources(handler)), /must call its imported listDeviceInventory binding/); +}); + +test('devices does not accept a call through a binding that shadows the imported alias', () => { + const handler = [ + "import { listDeviceInventory as discoverInventory } from '../../core/dispatch-resolve.ts';", + 'export async function decoy(discoverInventory: (request: Request) => Promise) {', + ' return await discoverInventory({});', + '}', + ].join('\n'); + + assert.match(messages(sources(handler)), /shadows its imported listDeviceInventory binding/); +}); + +test('devices rejects an exact legacy inventory module path', () => { + assert.match( + messages( + sources(handlerSource(), [ + ['src/platforms/apple/devices.ts', 'export const staleInventoryRoute = true;'], + ]), + ), + /legacy device inventory module path remains/, + ); +}); + +test('devices rejects executable legacy imports and identifiers', () => { + const legacy = [ + "import { listAppleDevices } from './platforms/apple/devices.ts';", + 'export async function discover() { return await listAppleDevices(); }', + ].join('\n'); + const found = messages(sources(handlerSource(), [['src/legacy-inventory.ts', legacy]])); + + assert.match(found, /imports legacy device inventory module/); + assert.match(found, /legacy device inventory identifier 'listAppleDevices' remains/); +}); + +test('devices rejects dynamic imports and re-exports of legacy inventory modules', () => { + for (const route of [ + "export const legacy = import('./platforms/apple/devices.ts');", + "export { staleInventory } from './core/platform-inventory.ts';", + ]) { + assert.match( + messages(sources(handlerSource(), [['src/legacy-inventory.ts', route]])), + /imports legacy device inventory module/, + ); + } +}); + +test('devices ignores comments, strings, and similarly named non-route modules', () => { + const prose = [ + "const note = 'listAppleDevices from core/platform-inventory';", + '// discoverDevices used to live in platforms/apple/devices.ts', + 'export const cacheKey = note;', + ].join('\n'); + + assert.deepEqual( + checkDeviceInventoryCutover( + sources(handlerSource('discoverInventory'), [ + ['src/platforms/apple/devices-cache.ts', prose], + ]), + ), + [], + ); +}); diff --git a/scripts/layering/device-inventory-cutover-policy.ts b/scripts/layering/device-inventory-cutover-policy.ts new file mode 100644 index 0000000000..d905c230b2 --- /dev/null +++ b/scripts/layering/device-inventory-cutover-policy.ts @@ -0,0 +1,257 @@ +import { parseSync } from 'oxc-parser'; +import type { LayeringViolation } from './model.ts'; + +const RULE = 'R13 device-inventory-cutover'; +const HANDLER_FILE = 'src/daemon/handlers/session-inventory.ts'; +const INVENTORY_IMPORT_SOURCES = new Set([ + '../../core/device-inventory-context.ts', + '../../core/dispatch-resolve.ts', +]); +const LEGACY_INVENTORY_IDENTIFIERS = new Set([ + 'discoverDevices', + 'listAndroidDevices', + 'listAppleDevices', + 'listHarmonyDevices', + 'listHarmonyOsDevices', + 'listLinuxDevices', + 'listMacosDevices', + 'listVegaDevices', + 'listWebDevices', +]); + +type AstNode = Record; + +function violation(file: string, line: number, message: string): LayeringViolation { + return { rule: RULE, file, line, message }; +} + +function lineOf(source: string, node: AstNode): number { + return source.slice(0, (node['start'] as number | undefined) ?? 0).split('\n').length; +} + +function identifierName(node: unknown): string | undefined { + if (node === null || typeof node !== 'object') return undefined; + const record = node as AstNode; + return record['type'] === 'Identifier' && typeof record['name'] === 'string' + ? record['name'] + : undefined; +} + +function literalString(node: unknown): string | undefined { + if (node === null || typeof node !== 'object') return undefined; + const value = (node as AstNode)['value']; + return typeof value === 'string' ? value : undefined; +} + +function visitAst(node: unknown, visitor: (node: AstNode) => void): void { + if (node === null || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) visitAst(child, visitor); + return; + } + const record = node as AstNode; + visitor(record); + for (const value of Object.values(record)) visitAst(value, visitor); +} + +function isLegacyInventoryModulePath(file: string): boolean { + return ( + file === 'src/core/platform-inventory.ts' || + /^src\/platforms\/(?:[^/]+\/)*devices\.ts$/.test(file) + ); +} + +function isLegacyInventoryImport(specifier: string): boolean { + return ( + /(?:^|\/)core\/platform-inventory(?:\.[cm]?[jt]s)?$/.test(specifier) || + /(?:^|\/)platforms\/(?:[^/]+\/)*devices(?:\.[cm]?[jt]s)?$/.test(specifier) + ); +} + +function importedModuleSpecifier(node: AstNode): string | undefined { + if ( + node['type'] === 'ImportDeclaration' || + node['type'] === 'ImportExpression' || + node['type'] === 'ExportNamedDeclaration' || + node['type'] === 'ExportAllDeclaration' + ) { + return literalString(node['source']); + } + return undefined; +} + +function importedInventoryBinding(program: AstNode): string | undefined { + const body = program['body']; + if (!Array.isArray(body)) return undefined; + for (const statement of body as AstNode[]) { + if (statement['type'] !== 'ImportDeclaration') continue; + const specifier = literalString(statement['source']); + if (!specifier || !INVENTORY_IMPORT_SOURCES.has(specifier)) continue; + const importSpecifiers = statement['specifiers']; + if (!Array.isArray(importSpecifiers)) continue; + for (const imported of importSpecifiers as AstNode[]) { + if ( + imported['type'] === 'ImportSpecifier' && + identifierName(imported['imported']) === 'listDeviceInventory' + ) { + return identifierName(imported['local']); + } + } + } + return undefined; +} + +function callsBinding(program: AstNode, binding: string): boolean { + let called = false; + visitAst(program, (node) => { + if (node['type'] === 'CallExpression' && identifierName(node['callee']) === binding) { + called = true; + } + }); + return called; +} + +function patternBinds(node: unknown, binding: string): boolean { + if (node === null || typeof node !== 'object') return false; + const record = node as AstNode; + if (record['type'] === 'Identifier') return record['name'] === binding; + if (record['type'] === 'RestElement') return patternBinds(record['argument'], binding); + if (record['type'] === 'AssignmentPattern') return patternBinds(record['left'], binding); + if (record['type'] === 'TSParameterProperty') return patternBinds(record['parameter'], binding); + if (record['type'] === 'ArrayPattern') { + const elements = record['elements']; + return Array.isArray(elements) && elements.some((element) => patternBinds(element, binding)); + } + if (record['type'] === 'ObjectPattern') { + const properties = record['properties']; + return ( + Array.isArray(properties) && + properties.some((property) => { + if (property === null || typeof property !== 'object') return false; + const entry = property as AstNode; + return entry['type'] === 'Property' + ? patternBinds(entry['value'], binding) + : patternBinds(entry['argument'], binding); + }) + ); + } + return false; +} + +function shadowingBindingSite(program: AstNode, binding: string): AstNode | undefined { + let found: AstNode | undefined; + visitAst(program, (node) => { + if (found) return; + if ( + node['type'] === 'VariableDeclarator' || + node['type'] === 'FunctionDeclaration' || + node['type'] === 'FunctionExpression' || + node['type'] === 'ClassDeclaration' || + node['type'] === 'ClassExpression' + ) { + if (patternBinds(node['id'], binding)) found = node; + if ( + !found && + Array.isArray(node['params']) && + node['params'].some((param) => patternBinds(param, binding)) + ) { + found = node; + } + return; + } + if (node['type'] === 'ArrowFunctionExpression') { + if ( + Array.isArray(node['params']) && + node['params'].some((param) => patternBinds(param, binding)) + ) { + found = node; + } + return; + } + if (node['type'] === 'CatchClause' && patternBinds(node['param'], binding)) found = node; + }); + return found; +} + +function checkHandlerRoute(source: string | undefined): LayeringViolation[] { + if (source === undefined) { + return [violation(HANDLER_FILE, 1, 'devices gateway-owned handler module is missing')]; + } + const program = parseSync(HANDLER_FILE, source).program as AstNode; + const binding = importedInventoryBinding(program); + if (!binding) { + return [ + violation( + HANDLER_FILE, + 1, + 'devices handler must import listDeviceInventory from the neutral inventory owner', + ), + ]; + } + const shadow = shadowingBindingSite(program, binding); + if (shadow) { + return [ + violation( + HANDLER_FILE, + lineOf(source, shadow), + 'devices handler shadows its imported listDeviceInventory binding', + ), + ]; + } + if (!callsBinding(program, binding)) { + return [ + violation( + HANDLER_FILE, + 1, + 'devices handler must call its imported listDeviceInventory binding', + ), + ]; + } + return []; +} + +function checkLegacyRoutes(file: string, source: string): LayeringViolation[] { + const violations: LayeringViolation[] = []; + if (isLegacyInventoryModulePath(file)) { + violations.push(violation(file, 1, 'legacy device inventory module path remains')); + } + const program = parseSync(file, source).program as AstNode; + const seenIdentifiers = new Set(); + visitAst(program, (node) => { + const specifier = importedModuleSpecifier(node); + if (specifier && isLegacyInventoryImport(specifier)) { + violations.push( + violation( + file, + lineOf(source, node), + `production source imports legacy device inventory module '${specifier}'`, + ), + ); + } + if (node['type'] !== 'Identifier') return; + const name = identifierName(node); + if (!name || !LEGACY_INVENTORY_IDENTIFIERS.has(name) || seenIdentifiers.has(name)) return; + seenIdentifiers.add(name); + violations.push( + violation( + file, + lineOf(source, node), + `legacy device inventory identifier '${name}' remains in production code`, + ), + ); + }); + return violations; +} + +export function checkDeviceInventoryCutover( + sources: ReadonlyMap, +): LayeringViolation[] { + return [ + ...checkHandlerRoute(sources.get(HANDLER_FILE)), + ...[...sources].flatMap(([file, source]) => checkLegacyRoutes(file, source)), + ]; +} + +export function deviceInventoryCutoverSummary(): string { + return 'the devices command has one gateway-owned inventory route and no legacy route'; +} diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 2a7d04709e..434580f482 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -78,11 +78,18 @@ export function zoneRank(zone: string): number | null { // invent an order the architecture had not committed to. Once `utils` joined the spine and // `(root)` was emptied of shared contracts, every one of them turned out to have a // consistent rank already — so the order was there, just unasserted. -// `kernel`, `provider-webdriver`, `provider-limrun`, and `xml` are not src/ zones: R11 owns their -// physical seams, and their zone names only appear in workspace-aware graphs. +// Extracted workspace packages are not src/ zones: R11 owns their physical seams, and their zone +// names only appear in workspace-aware graphs. The platform packages additionally carry R13's +// exact-family/composition/laziness policy. export const UNRANKED_ZONES: ReadonlySet = new Set([ '(root)', 'kernel', + 'platform-apple', + 'platform-android', + 'platform-harmonyos', + 'platform-vega', + 'platform-linux', + 'platform-web', 'provider-webdriver', 'provider-limrun', 'xml', diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 345cab3c11..cae5f71254 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -422,6 +422,7 @@ test('the real tree parses, declares, and passes R11', () => { assert.deepEqual( listSourceFiles() .filter((file) => !file.startsWith('packages/selectors/')) + .filter((file) => fs.existsSync(path.join(repoRoot, file))) .filter((file) => fs.readFileSync(path.join(repoRoot, file), 'utf8').includes('@agent-device/selectors/ast'), ), diff --git a/scripts/layering/platform-composition-policy.ts b/scripts/layering/platform-composition-policy.ts new file mode 100644 index 0000000000..bb0a2e69e9 --- /dev/null +++ b/scripts/layering/platform-composition-policy.ts @@ -0,0 +1,97 @@ +import { parseSync } from 'oxc-parser'; +import { parseImports, type LayeringViolation } from './model.ts'; + +const COMPOSITION_FILE = 'src/platform-runtime.ts'; +const RULE = 'R13 platform-package-substrate'; + +function violation(line: number, message: string): LayeringViolation { + return { rule: RULE, file: COMPOSITION_FILE, line, message }; +} + +function lineOf(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} + +function memberPropertyName(node: unknown): string | undefined { + if (node === null || typeof node !== 'object') return undefined; + const record = node as Record; + if (record['type'] === 'ChainExpression') return memberPropertyName(record['expression']); + if (record['type'] !== 'MemberExpression') return undefined; + const property = record['property'] as Record | undefined; + if (property?.['type'] === 'Identifier') return property['name'] as string | undefined; + const value = property?.['value']; + return typeof value === 'string' ? value : undefined; +} + +function isFunction(node: Record): boolean { + return ( + node['type'] === 'FunctionDeclaration' || + node['type'] === 'FunctionExpression' || + node['type'] === 'ArrowFunctionExpression' + ); +} + +function eagerImplementationLoaderSites(source: string): number[] { + const sites: number[] = []; + const visit = (node: unknown): void => { + if (node === null || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) visit(child); + return; + } + const record = node as Record; + if (isFunction(record)) return; + if ( + record['type'] === 'CallExpression' && + ['loadInventory', 'loadRuntime'].includes(memberPropertyName(record['callee']) ?? '') + ) { + sites.push(lineOf(source, (record['start'] as number | undefined) ?? 0)); + return; + } + for (const value of Object.values(record)) visit(value); + }; + visit(parseSync(COMPOSITION_FILE, source).program); + return sites; +} + +function isAllowedCompositionImport(specifier: string): boolean { + return ( + /^@agent-device\/contracts(?:\/|$)/.test(specifier) || + /^@agent-device\/platform-[^/]+$/.test(specifier) || + specifier === './platform-runtime-device-inventory.ts' || + specifier === './platform-runtime-host.ts' || + specifier.startsWith('./platform-runtime-host/') + ); +} + +export function checkPlatformComposition(source: string | undefined): LayeringViolation[] { + if (source === undefined) { + return [violation(1, 'the exact platform composition root is missing')]; + } + const violations: LayeringViolation[] = []; + for (const site of parseImports(source)) { + if (!isAllowedCompositionImport(site.spec)) { + violations.push( + violation( + site.line, + `composition imports only runtime contracts, host-capability adapters, and concrete platform facades; found '${site.spec}'`, + ), + ); + } + if (/^@agent-device\/platform-/.test(site.spec) && (site.dynamic || site.typeOnly)) { + violations.push( + violation(site.line, 'platform inventory modules must be statically composed'), + ); + } + } + + for (const line of eagerImplementationLoaderSites(source)) { + violations.push( + violation( + line, + 'composition may not invoke a platform implementation loader before selected use', + ), + ); + } + return violations; +} diff --git a/scripts/layering/platform-package-policy.test.ts b/scripts/layering/platform-package-policy.test.ts new file mode 100644 index 0000000000..e919f210f4 --- /dev/null +++ b/scripts/layering/platform-package-policy.test.ts @@ -0,0 +1,271 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + CANONICAL_PLATFORM_FAMILIES, + checkPlatformPackagePolicy, + type PlatformPackageDeclaration, +} from './platform-package-policy.ts'; +import { classifyZone } from './model.ts'; + +const inventoryModuleNames = { + apple: 'appleInventoryModule', + android: 'androidInventoryModule', + harmonyos: 'harmonyosInventoryModule', + vega: 'vegaInventoryModule', + linux: 'linuxInventoryModule', + web: 'webInventoryModule', +} as const; + +function declarations(): PlatformPackageDeclaration[] { + return CANONICAL_PLATFORM_FAMILIES.map((family) => ({ + dir: `packages/platform-${family}`, + family, + name: `@agent-device/platform-${family}`, + private: true, + exportedSubpaths: [`@agent-device/platform-${family}`], + })); +} + +function facade(family: (typeof CANONICAL_PLATFORM_FAMILIES)[number]): string { + return [ + "import type { DeviceInventoryHost, InventoryPlatformModule, PlatformModuleMetadata } from '@agent-device/contracts/platform';", + 'const metadata = Object.freeze({', + ` family: '${family}',`, + '} satisfies PlatformModuleMetadata);', + 'export const inventoryModule = Object.freeze({', + ' ...metadata,', + ' loadInventory: async (host: DeviceInventoryHost) => {', + " const { createInventory } = await import('./inventory.ts');", + ' return createInventory(host);', + ' },', + '} satisfies InventoryPlatformModule);', + ].join('\n'); +} + +function composition(families = CANONICAL_PLATFORM_FAMILIES): string { + return [ + "import { createPlatformModuleRegistry } from '@agent-device/contracts/platform';", + ...families.map( + (family) => + `import { inventoryModule as ${inventoryModuleNames[family]} } from '@agent-device/platform-${family}';`, + ), + 'export const platformModuleRegistry = createPlatformModuleRegistry([', + ...families.map((family) => ` ${inventoryModuleNames[family]},`), + ']);', + ].join('\n'); +} + +function validSources(): Map { + return new Map([ + ['src/platform-runtime.ts', composition()], + ...CANONICAL_PLATFORM_FAMILIES.map( + (family) => [`packages/platform-${family}/src/index.ts`, facade(family)] as const, + ), + ]); +} + +function messages(sources: ReadonlyMap, packages = declarations()): string[] { + return checkPlatformPackagePolicy(sources, packages).map((violation) => violation.message); +} + +test('the inventory substrate has six private lazy packages and one exact composition root', () => { + assert.deepEqual(checkPlatformPackagePolicy(validSources(), declarations()), []); +}); + +test('platform workspace packages are R11-owned unranked zones', () => { + for (const family of CANONICAL_PLATFORM_FAMILIES) { + assert.equal(classifyZone(`platform-${family}`), 'unranked', family); + } +}); + +test('untracked production TypeScript makes committed-state evidence fail closed', () => { + const found = checkPlatformPackagePolicy(validSources(), declarations(), { + untrackedProductionFiles: ['packages/platform-apple/src/mechanics.ts'], + }); + assert.match(found.map(({ message }) => message).join('\n'), /untracked production source/); +}); + +test('family packages are exact, private, and expose only their root facade', () => { + const packages = declarations(); + packages[0] = { ...packages[0]!, private: false }; + packages[1] = { ...packages[1]!, name: '@agent-device/not-android' }; + packages[2] = { + ...packages[2]!, + exportedSubpaths: [packages[2]!.name, `${packages[2]!.name}/internal`], + }; + packages.pop(); + + const found = messages(validSources(), packages).join('\n'); + assert.match(found, /platform-apple must be private/); + assert.match(found, /platform-android must be named '@agent-device\/platform-android'/); + assert.match(found, /platform-harmonyos must export only its root façade/); + assert.match(found, /missing canonical platform package.*platform-web/); +}); + +test('composition policy does not pin local platform-module identifier spelling', () => { + const sources = validSources(); + sources.set( + 'src/platform-runtime.ts', + composition().replaceAll('appleInventoryModule', 'selectedAppleModule'), + ); + assert.deepEqual(checkPlatformPackagePolicy(sources, declarations()), []); +}); + +test('only src/platform-runtime.ts may import a concrete platform package', () => { + for (const statement of [ + "import { applePlatformMetadata } from '@agent-device/platform-apple';", + "import type { AppleThing } from '@agent-device/platform-apple';", + "void import('@agent-device/platform-apple');", + "export { applePlatformMetadata } from '@agent-device/platform-apple';", + ]) { + const sources = validSources(); + sources.set('src/daemon/not-the-root.test.ts', statement); + assert.match(messages(sources).join('\n'), /only src\/platform-runtime\.ts may import/); + } +}); + +test('contracts never depend on a concrete platform package in production or tests', () => { + for (const statement of [ + "import { applePlatformMetadata } from '@agent-device/platform-apple';", + "import type { AppleThing } from '@agent-device/platform-apple';", + "void import('@agent-device/platform-apple');", + "export { applePlatformMetadata } from '@agent-device/platform-apple';", + ]) { + const sources = validSources(); + sources.set('packages/contracts/src/platform-runtime.test.ts', statement); + assert.match(messages(sources).join('\n'), /contracts may never import a concrete platform/); + } +}); + +test('platform packages cannot escape to root, daemon, siblings, or raw process primitives', () => { + const planted = [ + "import type { DaemonRequest } from '../../../src/daemon/types.ts';", + "export { x } from '../../../src/core/x.ts';", + "void import('@agent-device/platform-android');", + "import { spawn } from 'node:child_process';", + "import type { ChildProcess } from 'child_process';", + ]; + for (const statement of planted) { + const sources = validSources(); + sources.set('packages/platform-apple/src/probe.test.ts', statement); + assert.ok( + messages(sources).some((message) => + /may not reach root|may not import sibling|raw process primitives/.test(message), + ), + statement, + ); + } +}); + +test('platform packages cannot depend on any other workspace implementation package', () => { + for (const specifier of [ + '@agent-device/selectors', + '@agent-device/provider-webdriver', + '@agent-device/ad-script', + ]) { + const sources = validSources(); + sources.set( + 'packages/platform-apple/src/probe.test.ts', + `import type { Forbidden } from '${specifier}';`, + ); + assert.match( + messages(sources).join('\n'), + /may import workspace code only from contracts or kernel/, + ); + } +}); + +test('package-owned tests may import their own public facade', () => { + const sources = validSources(); + sources.set( + 'packages/platform-apple/src/index.test.ts', + "import { inventoryModule } from '@agent-device/platform-apple';", + ); + assert.deepEqual(checkPlatformPackagePolicy(sources, declarations()), []); +}); + +test('platform facades cannot evaluate mechanics eagerly', () => { + for (const statement of [ + "import { helper } from './helper-manager.ts';", + "export { probe } from './probe.ts';", + "import './implementation.ts';", + ]) { + const sources = validSources(); + sources.set('packages/platform-apple/src/index.ts', `${statement}\n${facade('apple')}`); + assert.match(messages(sources).join('\n'), /facade must not eagerly evaluate/); + } + + const sources = validSources(); + sources.set( + 'packages/platform-apple/src/index.ts', + `${facade('apple')}\nexport const loadApple = () => import('./implementation.ts');`, + ); + assert.deepEqual(checkPlatformPackagePolicy(sources, declarations()), []); +}); + +test('composition policy does not infer host authority from function or class names', () => { + const sources = validSources(); + sources.set( + 'src/platform-runtime.ts', + `${composition()}\nclass AppleHelperManager {}\nnew AppleHelperManager();\nprobeAndroidSdk();`, + ); + assert.deepEqual(checkPlatformPackagePolicy(sources, declarations()), []); +}); + +test('composition keeps platform implementation loaders behind selected use', () => { + for (const method of ['loadInventory', 'loadRuntime']) { + const sources = validSources(); + sources.set( + 'src/platform-runtime.ts', + `${composition()}\nappleInventoryModule.${method}(host);`, + ); + assert.match(messages(sources).join('\n'), /implementation loader.*selected use/, method); + } +}); + +test('implementation-loader detection ignores comments, strings, and deferred function bodies', () => { + const sources = validSources(); + sources.set( + 'src/platform-runtime.ts', + [ + composition(), + "const documentation = 'appleInventoryModule.loadInventory(host)';", + '// appleInventoryModule.loadRuntime(host);', + 'export function loadSelectedFamily() { return appleInventoryModule.loadInventory(host); }', + ].join('\n'), + ); + assert.ok( + !messages(sources).some((message) => /implementation loader.*selected use/.test(message)), + ); +}); + +test('composition imports are category-based for future contracts and host adapters', () => { + const sources = validSources(); + sources.set( + 'src/platform-runtime.ts', + [ + composition(), + "import type { PlatformRequestScope } from '@agent-device/contracts/platform';", + "import { hostCommandRunner } from './platform-runtime-host/command.ts';", + "import { createComposedDeviceInventoryGateways } from './platform-runtime-device-inventory.ts';", + 'void hostCommandRunner;', + 'void createComposedDeviceInventoryGateways;', + 'export type Scope = PlatformRequestScope;', + ].join('\n'), + ); + assert.ok(!messages(sources).some((message) => /composition imports only/.test(message))); + + sources.set('src/platform-runtime.ts', `${composition()}\nimport { daemon } from './daemon.ts';`); + assert.match(messages(sources).join('\n'), /composition imports only/); +}); + +test('Node resolves only each platform package root facade', () => { + for (const family of CANONICAL_PLATFORM_FAMILIES) { + const specifier = `@agent-device/platform-${family}`; + assert.ok(import.meta.resolve(specifier).endsWith(`packages/platform-${family}/src/index.ts`)); + assert.throws( + () => import.meta.resolve(`${specifier}/src/index.ts`), + /ERR_PACKAGE_PATH_NOT_EXPORTED|Package subpath/, + ); + } +}); diff --git a/scripts/layering/platform-package-policy.ts b/scripts/layering/platform-package-policy.ts new file mode 100644 index 0000000000..5511c9d766 --- /dev/null +++ b/scripts/layering/platform-package-policy.ts @@ -0,0 +1,225 @@ +import path from 'node:path'; +import { parseImports, type LayeringViolation } from './model.ts'; +import { checkPlatformComposition } from './platform-composition-policy.ts'; +import { checkPlatformPackageSourcePolicy } from './platform-package-source-policy.ts'; + +export const CANONICAL_PLATFORM_FAMILIES = [ + 'apple', + 'android', + 'harmonyos', + 'vega', + 'linux', + 'web', +] as const; +type PlatformFamily = (typeof CANONICAL_PLATFORM_FAMILIES)[number]; +export type PlatformPackageDeclaration = { + dir: string; + family: string; + name: string; + private: boolean; + exportedSubpaths: readonly string[]; +}; +const COMPOSITION_FILE = 'src/platform-runtime.ts'; +const RULE = 'R13 platform-package-substrate'; +const RAW_PROCESS_SPECIFIERS = new Set(['child_process', 'node:child_process']); + +function violation(file: string, line: number, message: string): LayeringViolation { + return { rule: RULE, file, line, message }; +} + +function packageName(family: PlatformFamily): string { + return `@agent-device/platform-${family}`; +} + +function packageDir(family: PlatformFamily): string { + return `packages/platform-${family}`; +} + +function familyForPackageFile(file: string): string | undefined { + return /^packages\/platform-([^/]+)\//.exec(file)?.[1]; +} + +function isProductionSource(file: string): boolean { + return !file.endsWith('.test.ts') && !file.includes('/__tests__/'); +} + +function concretePlatformFamily(specifier: string): string | undefined { + return /^@agent-device\/platform-([^/]+)(?:\/|$)/.exec(specifier)?.[1]; +} + +function resolvesOutsidePackage(file: string, specifier: string, family: string): boolean { + if (!specifier.startsWith('.')) return false; + const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)); + return !resolved.startsWith(`packages/platform-${family}/`); +} + +function isPackageOwnedFacadeTest(file: string, family: string, specifier: string): boolean { + return ( + file.startsWith(`packages/platform-${family}/`) && + (file.endsWith('.test.ts') || file.includes('/__tests__/')) && + specifier === `@agent-device/platform-${family}` + ); +} + +function checkDeclarations(packages: readonly PlatformPackageDeclaration[]): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const family of CANONICAL_PLATFORM_FAMILIES) { + const expectedDir = packageDir(family); + const matches = packages.filter((pkg) => pkg.dir === expectedDir); + if (matches.length === 0) { + violations.push( + violation(expectedDir, 1, `missing canonical platform package ${expectedDir}`), + ); + continue; + } + if (matches.length > 1) { + violations.push(violation(expectedDir, 1, `${expectedDir} is declared more than once`)); + continue; + } + const declaration = matches[0]!; + const expectedName = packageName(family); + if (declaration.name !== expectedName) { + violations.push( + violation( + `${expectedDir}/package.json`, + 1, + `${expectedDir} must be named '${expectedName}', found '${declaration.name}'`, + ), + ); + } + if (!declaration.private) { + violations.push( + violation(`${expectedDir}/package.json`, 1, `${expectedDir} must be private`), + ); + } + if ( + declaration.exportedSubpaths.length !== 1 || + declaration.exportedSubpaths[0] !== expectedName + ) { + violations.push( + violation( + `${expectedDir}/package.json`, + 1, + `${expectedDir} must export only its root façade '${expectedName}'`, + ), + ); + } + } + for (const declaration of packages) { + if (!declaration.dir.startsWith('packages/platform-')) continue; + const family = declaration.dir.slice('packages/platform-'.length); + if (!(CANONICAL_PLATFORM_FAMILIES as readonly string[]).includes(family)) { + violations.push( + violation(declaration.dir, 1, `${declaration.dir} is not a canonical platform family`), + ); + } + } + return violations; +} + +function checkSource(file: string, source: string): LayeringViolation[] { + const violations: LayeringViolation[] = []; + const ownerFamily = familyForPackageFile(file); + if (ownerFamily && isProductionSource(file)) { + violations.push(...checkPlatformPackageSourcePolicy(file, source, ownerFamily)); + } + for (const site of parseImports(source)) { + const importedFamily = concretePlatformFamily(site.spec); + if (file.startsWith('packages/contracts/') && importedFamily) { + violations.push( + violation( + file, + site.line, + `contracts may never import a concrete platform package ('${site.spec}')`, + ), + ); + } + if ( + importedFamily && + file !== COMPOSITION_FILE && + !isPackageOwnedFacadeTest(file, importedFamily, site.spec) + ) { + violations.push( + violation( + file, + site.line, + `only ${COMPOSITION_FILE} may import '${site.spec}' outside its package-owned tests`, + ), + ); + } + if (!ownerFamily) continue; + if ( + site.spec.startsWith('@agent-device/') && + !site.spec.startsWith('@agent-device/contracts/') && + !site.spec.startsWith('@agent-device/kernel/') && + !isPackageOwnedFacadeTest(file, ownerFamily, site.spec) + ) { + violations.push( + violation( + file, + site.line, + `platform-${ownerFamily} may import workspace code only from contracts or kernel; found '${site.spec}'`, + ), + ); + } + if (importedFamily && importedFamily !== ownerFamily) { + violations.push( + violation(file, site.line, `platform-${ownerFamily} may not import sibling '${site.spec}'`), + ); + } + if ( + resolvesOutsidePackage(file, site.spec, ownerFamily) || + /^agent-device(?:\/|$)/.test(site.spec) + ) { + violations.push( + violation(file, site.line, `platform-${ownerFamily} may not reach root or daemon code`), + ); + } + if (RAW_PROCESS_SPECIFIERS.has(site.spec)) { + violations.push( + violation( + file, + site.line, + `platform-${ownerFamily} may not import raw process primitives ('${site.spec}'); use the host-command port`, + ), + ); + } + if ( + file === `packages/platform-${ownerFamily}/src/index.ts` && + !site.dynamic && + !site.typeOnly + ) { + violations.push( + violation( + file, + site.line, + `platform-${ownerFamily} facade must not eagerly evaluate '${site.spec}'`, + ), + ); + } + } + return violations; +} + +export function checkPlatformPackagePolicy( + sources: ReadonlyMap, + packages: readonly PlatformPackageDeclaration[], + options: { untrackedProductionFiles?: readonly string[] } = {}, +): LayeringViolation[] { + return [ + ...(options.untrackedProductionFiles ?? []).map((file) => + violation( + file, + 1, + 'untracked production source is not committed-state layering evidence; commit the complete slice before running the gate', + ), + ), + ...checkDeclarations(packages), + ...checkPlatformComposition(sources.get(COMPOSITION_FILE)), + ...[...sources].flatMap(([file, source]) => checkSource(file, source)), + ]; +} + +export function platformPackagePolicySummary(): string { + return 'R13 holds six private implementation-lazy platform inventory packages behind one composition root'; +} diff --git a/scripts/layering/platform-package-repository.test.ts b/scripts/layering/platform-package-repository.test.ts new file mode 100644 index 0000000000..3a4dc2c85e --- /dev/null +++ b/scripts/layering/platform-package-repository.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { + listUntrackedProductionTypeScriptFiles, + readTrackedPlatformPackageDeclarations, +} from './platform-package-repository.ts'; + +test('committed-state discovery rejects untracked production and ignores untracked manifests', () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'platform-package-policy-')); + fs.mkdirSync(path.join(repo, 'packages/platform-apple/src'), { recursive: true }); + fs.writeFileSync( + path.join(repo, 'packages/platform-apple/package.json'), + JSON.stringify({ + name: '@agent-device/platform-apple', + private: true, + exports: { '.': './src/index.ts' }, + }), + ); + fs.writeFileSync(path.join(repo, 'packages/platform-apple/src/index.ts'), 'export {};\n'); + execFileSync('git', ['init', '-q'], { cwd: repo }); + execFileSync('git', ['add', '.'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=Gate', '-c', 'user.email=gate@example.test', 'commit', '-qm', 'base'], + { cwd: repo }, + ); + + fs.writeFileSync( + path.join(repo, 'packages/platform-apple/src/mechanics.ts'), + 'export const mechanics = true;\n', + ); + fs.writeFileSync( + path.join(repo, 'packages/platform-apple/src/mechanics.test.ts'), + 'export {};\n', + ); + fs.mkdirSync(path.join(repo, 'packages/platform-android'), { recursive: true }); + fs.writeFileSync( + path.join(repo, 'packages/platform-android/package.json'), + JSON.stringify({ name: '@agent-device/platform-android', private: true }), + ); + + assert.deepEqual(listUntrackedProductionTypeScriptFiles(repo), [ + 'packages/platform-apple/src/mechanics.ts', + ]); + assert.deepEqual( + readTrackedPlatformPackageDeclarations(repo).map(({ family }) => family), + ['apple'], + ); +}); diff --git a/scripts/layering/platform-package-repository.ts b/scripts/layering/platform-package-repository.ts new file mode 100644 index 0000000000..e0531237e4 --- /dev/null +++ b/scripts/layering/platform-package-repository.ts @@ -0,0 +1,49 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { PlatformPackageDeclaration } from './platform-package-policy.ts'; + +function gitLines(repoRoot: string, args: readonly string[]): string[] { + return execFileSync('git', [...args], { cwd: repoRoot, encoding: 'utf8' }) + .split('\n') + .filter(Boolean); +} + +function isProductionTypeScript(file: string): boolean { + return file.endsWith('.ts') && !file.endsWith('.test.ts') && !file.includes('/__tests__/'); +} + +export function listUntrackedProductionTypeScriptFiles(repoRoot: string): string[] { + return gitLines(repoRoot, [ + 'ls-files', + '--others', + '--exclude-standard', + 'src/*.ts', + 'src/**/*.ts', + 'packages/*/src/*.ts', + 'packages/*/src/**/*.ts', + ]).filter(isProductionTypeScript); +} + +export function readTrackedPlatformPackageDeclarations( + repoRoot: string, +): PlatformPackageDeclaration[] { + return gitLines(repoRoot, ['ls-files', 'packages/platform-*/package.json']).map((file) => { + const dir = path.posix.dirname(file); + const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')) as { + name?: string; + private?: boolean; + exports?: Record; + }; + const family = path.posix.basename(dir).slice('platform-'.length); + return { + dir, + family, + name: manifest.name ?? '', + private: manifest.private === true, + exportedSubpaths: Object.keys(manifest.exports ?? {}).map((subpath) => + path.posix.join(manifest.name ?? '', subpath), + ), + }; + }); +} diff --git a/scripts/layering/platform-package-source-policy.test.ts b/scripts/layering/platform-package-source-policy.test.ts new file mode 100644 index 0000000000..808daf5dbb --- /dev/null +++ b/scripts/layering/platform-package-source-policy.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { checkPlatformPackageSourcePolicy } from './platform-package-source-policy.ts'; + +const facadeFile = 'packages/platform-apple/src/index.ts'; +const mechanicsFile = 'packages/platform-apple/src/mechanics.ts'; + +function messages(source: string, file = mechanicsFile): string { + return checkPlatformPackageSourcePolicy(file, source, 'apple') + .map(({ message }) => message) + .join('\n'); +} + +test('facade dynamic imports must be nested in deferred functions', () => { + assert.match( + messages("const eagerMechanics = import('./implementation.ts');", facadeFile), + /dynamic import.*must be nested in a deferred function/, + ); +}); + +test('platform production cannot acquire ambient filesystem, OS, process, or network authority', () => { + for (const statement of [ + "import { readFile } from 'node:fs/promises';", + "import { homedir } from 'node:os';", + "import process from 'node:process';", + 'const sdk = process.env.ANDROID_HOME;', + "export async function request() { return await fetch('https://example.test'); }", + ]) { + assert.match(messages(statement), /ambient host authority/, statement); + } +}); + +test('platform facades cannot probe injected host capabilities at module evaluation', () => { + assert.match( + messages("const appleTool = host.commands.which('apple-tool');", facadeFile), + /may not probe the host at module evaluation/, + ); +}); + +test('Apple production must route xcrun availability and execution through appleTools', () => { + for (const statement of [ + "export async function discover(host: Host) { return await host.commands.which('xcrun'); }", + "export async function discover(host: Host) { return await host.commands.run({ executable: 'xcrun', args: ['simctl', 'list'] }); }", + ]) { + assert.match( + messages(statement), + /platform-apple must route xcrun through the focused appleTools host port/, + statement, + ); + } +}); + +test('Apple xcrun routing guard ignores comments, strings, and non-xcrun generic commands', () => { + const source = [ + 'const documentation = "host.commands.which(\'xcrun\')";', + "// await host.commands.run({ executable: 'xcrun', args: [] });", + "export async function inspect(host: Host) { return await host.commands.which('log'); }", + ].join('\n'); + assert.deepEqual(checkPlatformPackageSourcePolicy(mechanicsFile, source, 'apple'), []); +}); + +test('syntax checks ignore comments and strings and allow deferred imports and host use', () => { + const source = [ + "const documentation = \"process.env.HOME; fetch('https://example.test'); import('./eager.ts')\";", + "// host.commands.which('apple-tool');", + 'export async function loadInventory(host: Host) {', + " const mechanics = await import('./implementation.ts');", + " const executable = await host.commands.which('apple-tool');", + ' return { mechanics, executable, documentation };', + '}', + ].join('\n'); + assert.deepEqual(checkPlatformPackageSourcePolicy(facadeFile, source, 'apple'), []); +}); diff --git a/scripts/layering/platform-package-source-policy.ts b/scripts/layering/platform-package-source-policy.ts new file mode 100644 index 0000000000..ddd243b79c --- /dev/null +++ b/scripts/layering/platform-package-source-policy.ts @@ -0,0 +1,171 @@ +import { parseSync } from 'oxc-parser'; +import { parseImports, type LayeringViolation } from './model.ts'; + +const RULE = 'R13 platform-package-substrate'; +const AMBIENT_HOST_SPECIFIERS = new Set([ + 'fs', + 'fs/promises', + 'node:fs', + 'node:fs/promises', + 'os', + 'node:os', + 'process', + 'node:process', +]); + +function violation(file: string, line: number, message: string): LayeringViolation { + return { rule: RULE, file, line, message }; +} + +function lineOf(source: string, offset: number): number { + return source.slice(0, offset).split('\n').length; +} + +function isFunction(node: Record): boolean { + return ( + node['type'] === 'FunctionDeclaration' || + node['type'] === 'FunctionExpression' || + node['type'] === 'ArrowFunctionExpression' + ); +} + +function rootIdentifier(node: unknown): string | undefined { + if (node === null || typeof node !== 'object') return undefined; + const record = node as Record; + if (record['type'] === 'Identifier') return record['name'] as string | undefined; + if (record['type'] === 'MemberExpression') return rootIdentifier(record['object']); + if (record['type'] === 'ChainExpression') return rootIdentifier(record['expression']); + return undefined; +} + +function memberPath(node: unknown): string[] | undefined { + if (node === null || typeof node !== 'object') return undefined; + const record = node as Record; + if (record['type'] === 'Identifier') { + const name = record['name']; + return typeof name === 'string' ? [name] : undefined; + } + if (record['type'] === 'ChainExpression') return memberPath(record['expression']); + if (record['type'] !== 'MemberExpression' || record['computed'] === true) return undefined; + const object = memberPath(record['object']); + const property = record['property'] as Record | undefined; + const name = property?.['type'] === 'Identifier' ? property['name'] : undefined; + return object && typeof name === 'string' ? [...object, name] : undefined; +} + +function literalString(node: unknown): string | undefined { + if (node === null || typeof node !== 'object') return undefined; + const value = (node as Record)['value']; + return typeof value === 'string' ? value : undefined; +} + +function objectPropertyValue(node: unknown, key: string): unknown { + if (node === null || typeof node !== 'object') return undefined; + const record = node as Record; + if (record['type'] !== 'ObjectExpression' || !Array.isArray(record['properties'])) + return undefined; + for (const property of record['properties'] as Array>) { + if (property['type'] !== 'Property' || property['computed'] === true) continue; + const propertyKey = property['key'] as Record | undefined; + const propertyName = + propertyKey?.['type'] === 'Identifier' ? propertyKey['name'] : literalString(propertyKey); + if (propertyName === key) return property['value']; + } + return undefined; +} + +function routesXcrunThroughGenericCommands(node: Record): boolean { + if (node['type'] !== 'CallExpression') return false; + const path = memberPath(node['callee']); + if (!path || path.at(-2) !== 'commands') return false; + const args = node['arguments']; + if (!Array.isArray(args)) return false; + if (path.at(-1) === 'which') return literalString(args[0]) === 'xcrun'; + return ( + path.at(-1) === 'run' && literalString(objectPropertyValue(args[0], 'executable')) === 'xcrun' + ); +} + +export function checkPlatformPackageSourcePolicy( + file: string, + source: string, + ownerFamily: string, +): LayeringViolation[] { + const violations: LayeringViolation[] = []; + const facade = file === `packages/platform-${ownerFamily}/src/index.ts`; + for (const site of parseImports(source)) { + if (!AMBIENT_HOST_SPECIFIERS.has(site.spec)) continue; + violations.push( + violation( + file, + site.line, + `platform-${ownerFamily} may not acquire ambient host authority from '${site.spec}'; inject inert configuration or a contract host port`, + ), + ); + } + + const visit = (node: unknown, deferredDepth: number): void => { + if (node === null || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) visit(child, deferredDepth); + return; + } + const record = node as Record; + const depth = isFunction(record) ? deferredDepth + 1 : deferredDepth; + const type = record['type']; + const line = lineOf(source, (record['start'] as number | undefined) ?? 0); + + if (facade && type === 'ImportExpression' && depth === 0) { + violations.push( + violation( + file, + line, + `platform-${ownerFamily} facade dynamic import must be nested in a deferred function`, + ), + ); + } + if (type === 'MemberExpression' && rootIdentifier(record) === 'process') { + violations.push( + violation( + file, + line, + `platform-${ownerFamily} may not acquire ambient host authority from process; inject inert configuration or a contract host port`, + ), + ); + return; + } + if (type === 'CallExpression') { + const calleeRoot = rootIdentifier(record['callee']); + if (calleeRoot === 'fetch') { + violations.push( + violation( + file, + line, + `platform-${ownerFamily} may not acquire ambient host authority from fetch; use a contract host port`, + ), + ); + } + if (depth === 0 && calleeRoot === 'host') { + violations.push( + violation( + file, + line, + `platform-${ownerFamily} may not probe the host at module evaluation; defer host-port calls until selected use`, + ), + ); + } + if (ownerFamily === 'apple' && routesXcrunThroughGenericCommands(record)) { + violations.push( + violation( + file, + line, + 'platform-apple must route xcrun through the focused appleTools host port', + ), + ); + } + } + for (const value of Object.values(record)) visit(value, depth); + }; + visit(parseSync(file, source).program, 0); + return violations; +} diff --git a/scripts/node-test-tmpdir.test.ts b/scripts/node-test-tmpdir.test.ts index 8f68b3f05a..b8fe2ce11e 100644 --- a/scripts/node-test-tmpdir.test.ts +++ b/scripts/node-test-tmpdir.test.ts @@ -182,6 +182,48 @@ test('probe records its inherited AGENT_DEVICE_SWIFT_CACHE_DIR', () => { } }); +test('the wrapper isolates advisory device claims inside its disposable run directory', async () => { + const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-claims-dir-')); + const evidencePath = path.join(evidenceRoot, 'claims-dir.txt'); + const probeName = `node-test-claims-dir-probe-${process.pid}-${crypto.randomUUID()}.test.ts`; + const probePath = path.join(REPOSITORY_ROOT, 'scripts', probeName); + fs.writeFileSync( + probePath, + `import fs from 'node:fs'; +import { test } from 'node:test'; + +test('probe records its inherited AGENT_DEVICE_CLAIMS_DIR', () => { + fs.writeFileSync(${JSON.stringify(evidencePath)}, process.env.AGENT_DEVICE_CLAIMS_DIR ?? ''); +}); +`, + ); + + try { + const result = await runCmd( + process.execPath, + ['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath], + { + cwd: REPOSITORY_ROOT, + timeoutMs: 30_000, + env: { ...process.env, AGENT_DEVICE_CLAIMS_DIR: '/host/device-claims' }, + }, + ); + assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`); + + const claimsDir = fs.readFileSync(evidencePath, 'utf8'); + assert.equal(path.basename(claimsDir), 'device-claims'); + assert.equal( + fs.existsSync(path.dirname(claimsDir)), + false, + 'the claims directory must be removed with the wrapper-owned run directory', + ); + assert.notEqual(claimsDir, '/host/device-claims'); + } finally { + fs.rmSync(probePath, { force: true }); + fs.rmSync(evidenceRoot, { recursive: true, force: true }); + } +}); + // The 13 lanes wrapped in package.json when this fix landed were a one-time // hand sweep; nothing stopped a 14th `node --test` script from being added // later without the wrapper, silently reopening #1595 for that one lane. diff --git a/scripts/node-test-tmpdir.ts b/scripts/node-test-tmpdir.ts index e031aab30a..65ef10dc04 100644 --- a/scripts/node-test-tmpdir.ts +++ b/scripts/node-test-tmpdir.ts @@ -40,7 +40,15 @@ const testRunTmpDir = fs.mkdtempSync( path.join(TEST_RUN_TMP_ROOT, `${TEST_RUN_TMP_PREFIX}${process.pid}-`), ); -const childEnv = { ...process.env, TMPDIR: testRunTmpDir }; +const childEnv = { + ...process.env, + TMPDIR: testRunTmpDir, + // Match Vitest's hermetic-env setup: integration daemons must exercise the + // real advisory-claim mechanism without scanning, locking, or pruning the + // host user's live device claims. The wrapper already owns cleanup of this + // run directory, so claim files cannot leak after the child exits. + AGENT_DEVICE_CLAIMS_DIR: path.join(testRunTmpDir, 'device-claims'), +}; // Mirrors vitest-tmpdir-global-setup.ts's own carve-out: the Swift compiler // cache is intentionally durable across runs (rebuilding it recompiles diff --git a/src/__tests__/contracts/platform-plugin-parity.test.ts b/src/__tests__/contracts/platform-plugin-parity.test.ts index 960e601504..ffe8d7c908 100644 --- a/src/__tests__/contracts/platform-plugin-parity.test.ts +++ b/src/__tests__/contracts/platform-plugin-parity.test.ts @@ -99,7 +99,6 @@ test('each registered platform resolves to a plugin that owns it', () => { `${platform} plugin lists ${platform} in its platforms`, ); assert.equal(typeof plugin.createInteractor, 'function'); - assert.equal(typeof plugin.discoverDevices, 'function'); } }); diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 4888770441..98635ae0db 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -31,16 +31,48 @@ test('provider device runtime registry delegates lifecycle, inventory, interacto assert.deepEqual(requestProviders.recoverableProviderIds, ['hit']); assert.deepEqual(world.recoveredLeases, [world.lease]); assert.deepEqual( - await requestProviders.deviceInventoryProvider?.({ - platform: 'ios', - leaseId: world.lease.leaseId, - leaseProvider: 'hit', - }), - [world.device], + await requestProviders.deviceInventorySource?.discover( + { + platform: 'ios', + leaseId: world.lease.leaseId, + leaseProvider: 'hit', + }, + new AbortController().signal, + ), + { kind: 'inventory', devices: [world.device] }, ); await assertProviderRuntimeDelegates(world); }); +test('provider device runtime registry rejects duplicate provider owners', () => { + const first = makeMissingRuntime(); + const second = makeMissingRuntime(); + assert.throws( + () => createProviderDeviceRuntimeRequestProviders([first, second]), + /Duplicate provider device runtime: miss/, + ); +}); + +test('provider inventory composition forwards cancellation into the legacy provider callback', async () => { + let observedSignal: AbortSignal | undefined; + const runtime: ProviderDeviceRuntime = { + ...makeMissingRuntime(), + deviceInventoryProvider: async (_request, signal) => { + observedSignal = signal; + return await new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }, + }; + const source = createProviderDeviceRuntimeRequestProviders([runtime]).deviceInventorySource; + const controller = new AbortController(); + const pending = source?.discover({ leaseProvider: 'miss' }, controller.signal); + controller.abort(new Error('provider request cancelled')); + + await assert.rejects(() => pending!, /provider request cancelled/); + assert.equal(observedSignal, controller.signal); +}); + function makeProviderRuntimeWorld() { const lease: SimulatorLease = { leaseId: 'lease-a', diff --git a/src/__tests__/test-utils/device-inventory-gateways.test.ts b/src/__tests__/test-utils/device-inventory-gateways.test.ts new file mode 100644 index 0000000000..2b369adff1 --- /dev/null +++ b/src/__tests__/test-utils/device-inventory-gateways.test.ts @@ -0,0 +1,20 @@ +import type { ProviderDeviceInventorySource } from '@agent-device/contracts/device'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform'; +import { expect, test } from 'vitest'; +import { createTestDeviceInventoryGateways } from './device-inventory-gateways.ts'; + +const scope: PlatformRequestScope = Object.freeze({ + signal: new AbortController().signal, + diagnostics: Object.freeze({ emit: () => {} }), + progress: Object.freeze({ report: () => {} }), +}); + +test('test inventory gateways preserve the production fail-closed provider contract', async () => { + const gateways = createTestDeviceInventoryGateways({ + provider: { discover: async () => undefined } as unknown as ProviderDeviceInventorySource, + }); + + await expect(gateways.providerFirst.discover({}, scope)).rejects.toMatchObject({ + details: { reason: 'provider_inventory_contract_violation' }, + }); +}); diff --git a/src/__tests__/test-utils/device-inventory-gateways.ts b/src/__tests__/test-utils/device-inventory-gateways.ts new file mode 100644 index 0000000000..57ab0b8fcb --- /dev/null +++ b/src/__tests__/test-utils/device-inventory-gateways.ts @@ -0,0 +1,117 @@ +import { + type DeviceInventoryProvider, + type DeviceInventoryRequest, + type ProviderDeviceInventorySource, +} from '@agent-device/contracts/device'; +import type { + ComposedDeviceInventoryGateways, + DeviceInventoryHost, + DeviceInventorySource, + InventoryPlatformModule, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { createPlatformModuleRegistry } from '@agent-device/contracts/platform'; +import type { DeviceInfo, Platform } from '@agent-device/kernel/device'; +import { withDeviceInventoryContext } from '../../core/device-inventory-context.ts'; +import { createComposedDeviceInventoryGateways } from '../../platform-runtime-device-inventory.ts'; + +type TestDeviceInventoryOptions = Readonly<{ + provider?: ProviderDeviceInventorySource; + local?: (request: Readonly) => Promise; +}>; + +const testRequestScope: PlatformRequestScope = Object.freeze({ + signal: new AbortController().signal, + diagnostics: Object.freeze({ emit: () => {} }), + progress: Object.freeze({ report: () => {} }), +}); + +export function createTestDeviceInventoryGateways( + options: TestDeviceInventoryOptions = {}, +): ComposedDeviceInventoryGateways { + const localDiscover = options.local ?? (async () => []); + return createComposedDeviceInventoryGateways({ + registry: createPlatformModuleRegistry(testInventoryModules(localDiscover)), + loadHost: async () => testInventoryHost, + provider: options.provider, + }); +} + +const testInventoryHost: DeviceInventoryHost = Object.freeze({ + commands: Object.freeze({ + which: async () => undefined, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }), + appleTools: Object.freeze({ + isXcrunAvailable: async () => false, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }), + toolchains: Object.freeze({ prepare: async () => {} }), + files: Object.freeze({ + isExecutable: async () => false, + createTemporaryTextFile: async () => { + throw new Error('unused test inventory temporary file'); + }, + }), + hostOs: 'other', + hostName: 'test-host', + homeDirectory: '/tmp/test-home', + observations: Object.freeze({ deviceBooted: async () => {} }), +}); + +function testInventoryModules( + discover: (request: Readonly) => Promise, +): readonly InventoryPlatformModule[] { + const source = (family: Platform): DeviceInventorySource => ({ + discover: async (request) => + (await discover(request)).filter((device) => device.platform === family), + }); + return [ + { family: 'apple', loadInventory: async () => source('apple') }, + { family: 'android', loadInventory: async () => source('android') }, + { family: 'harmonyos', loadInventory: async () => source('harmonyos') }, + { family: 'vega', loadInventory: async () => source('vega') }, + { family: 'linux', loadInventory: async () => source('linux') }, + { family: 'web', loadInventory: async () => source('web') }, + ]; +} + +/** Test-only bridge for fixtures that still implement the public nullable provider port. */ +export function createTestDeviceInventoryGatewaysFromProvider( + provider: DeviceInventoryProvider, +): ComposedDeviceInventoryGateways { + return createTestDeviceInventoryGateways({ + provider: { + discover: async (request, signal) => { + signal.throwIfAborted(); + const devices = await provider(request, signal); + return devices === null || devices === undefined + ? { kind: 'declined' } + : { kind: 'inventory', devices }; + }, + }, + }); +} + +export async function withTestDeviceInventory( + options: TestDeviceInventoryOptions, + task: () => Promise, +): Promise { + return await withDeviceInventoryContext( + { ...createTestDeviceInventoryGateways(options), requestScope: testRequestScope }, + task, + ); +} + +export async function withTestDeviceInventoryProvider( + provider: DeviceInventoryProvider, + task: () => Promise, +): Promise { + return await withDeviceInventoryContext( + { + ...createTestDeviceInventoryGatewaysFromProvider(provider), + requestScope: testRequestScope, + }, + task, + ); +} diff --git a/src/core/__tests__/dispatch-resolve.test.ts b/src/core/__tests__/dispatch-resolve.test.ts index fa9a5e79cd..531d3df85e 100644 --- a/src/core/__tests__/dispatch-resolve.test.ts +++ b/src/core/__tests__/dispatch-resolve.test.ts @@ -1,64 +1,26 @@ import { beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; -const { - mockFindBootableIosSimulator, - mockFindIosSimulatorInstalledApp, - mockListAppleDevices, - mockListAndroidDevices, -} = vi.hoisted(() => ({ - mockFindBootableIosSimulator: vi.fn(), +const { mockFindIosSimulatorInstalledApp, mockListAppleDevices } = vi.hoisted(() => ({ mockFindIosSimulatorInstalledApp: vi.fn(), mockListAppleDevices: vi.fn(), - mockListAndroidDevices: vi.fn(), })); -// Keep these as full mocks: the real device listers invoke host subprocesses, -// which would turn this resolver unit suite into environment-dependent integration coverage. -vi.mock('../../platforms/apple/core/devices.ts', () => { - return { - findBootableIosSimulator: mockFindBootableIosSimulator, - listAppleDevices: mockListAppleDevices, - }; -}); - -vi.mock('../../platforms/android/devices.ts', () => { - return { - listAndroidDevices: mockListAndroidDevices, - }; -}); - vi.mock('../../platforms/apple/core/apps.ts', () => { return { findIosSimulatorInstalledApp: mockFindIosSimulatorInstalledApp, }; }); -vi.mock('../../platforms/apple/os/macos/devices.ts', () => ({ - listMacosDevices: vi.fn(async () => [ - { - platform: 'apple', - id: 'host-macos-local', - name: 'Test Mac', - kind: 'device', - target: 'desktop', - appleOs: 'macos', - booted: true, - }, - ]), -})); -vi.mock('../../platforms/vega/devices.ts', () => ({ - listVegaDevices: vi.fn(async () => []), -})); -vi.mock('../../platforms/linux/devices.ts', () => ({ - listLinuxDevices: vi.fn(async () => []), -})); - import { - resolveTargetDevice, - withDeviceInventoryProvider, + resolveTargetDevice as resolveTargetDeviceInContext, withResolveTargetDeviceCacheScope, } from '../dispatch-resolve.ts'; +import { + withTestDeviceInventory, + withTestDeviceInventoryProvider as withDeviceInventoryProvider, +} from '../../__tests__/test-utils/device-inventory-gateways.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; import { AppError } from '@agent-device/kernel/errors'; const physical: DeviceInfo = { @@ -115,44 +77,76 @@ const androidEmulator: DeviceInfo = { booted: true, }; +const macDesktop: DeviceInfo = { + platform: 'apple', + id: 'host-macos-local', + name: 'Test Mac', + kind: 'device', + target: 'desktop', + appleOs: 'macos', + booted: true, +}; + +async function resolveTargetDevice( + ...args: Parameters +): ReturnType { + return await withTestDeviceInventory( + { + local: async (request) => + request.platform === 'macos' || + (request.platform === 'apple' && request.target === 'desktop') + ? [macDesktop] + : await mockListAppleDevices(request), + }, + async () => await resolveTargetDeviceInContext(...args), + ); +} + beforeEach(() => { - mockFindBootableIosSimulator.mockReset(); - mockFindBootableIosSimulator.mockResolvedValue(null); mockFindIosSimulatorInstalledApp.mockReset(); mockFindIosSimulatorInstalledApp.mockResolvedValue(undefined); mockListAppleDevices.mockReset(); - mockListAndroidDevices.mockReset(); }); test('resolveTargetDevice narrows local Android discovery to an explicit serial', async () => { - mockListAndroidDevices.mockResolvedValue([androidEmulator]); + let requestedInventory: DeviceInventoryRequest | undefined; - const result = await resolveTargetDevice({ - platform: 'android', - serial: androidEmulator.id, - }); + const result = await withDeviceInventoryProvider( + async (request) => { + requestedInventory = request; + return [androidEmulator]; + }, + async () => + await resolveTargetDeviceInContext({ + platform: 'android', + serial: androidEmulator.id, + }), + ); assert.equal(result.id, androidEmulator.id); - assert.deepEqual(Array.from(mockListAndroidDevices.mock.calls[0]?.[0]?.serialAllowlist ?? []), [ - androidEmulator.id, - ]); + assert.equal(requestedInventory!.serial, androidEmulator.id); + assert.equal(requestedInventory!.androidSerialAllowlist, undefined); }); test('resolveTargetDevice does not discover an explicit Android serial outside its allowlist', async () => { - mockListAndroidDevices.mockResolvedValue([]); + let requestedInventory: DeviceInventoryRequest | undefined; await expectDeviceNotFound(() => - resolveTargetDevice({ - platform: 'android', - serial: androidEmulator.id, - androidDeviceAllowlist: 'emulator-5556', - }), + withDeviceInventoryProvider( + async (request) => { + requestedInventory = request; + return []; + }, + async () => + await resolveTargetDeviceInContext({ + platform: 'android', + serial: androidEmulator.id, + androidDeviceAllowlist: 'emulator-5556', + }), + ), ); - assert.deepEqual( - Array.from(mockListAndroidDevices.mock.calls[0]?.[0]?.serialAllowlist ?? []), - [], - ); + assert.deepEqual(requestedInventory!.androidSerialAllowlist, ['emulator-5556']); }); test('resolveTargetDevice reuses request-scoped device resolution cache for identical selectors', async () => { @@ -206,7 +200,8 @@ test('resolveTargetDevice leaves platform-less static app selection to normal cr assert.equal(request.platform, undefined); return [androidEmulator, bootedSimulator, secondBootedSimulator]; }, - async () => await resolveTargetDevice({}, { appleSimulatorAppTarget: 'com.example.demo' }), + async () => + await resolveTargetDeviceInContext({}, { appleSimulatorAppTarget: 'com.example.demo' }), ); assert.equal(result.id, androidEmulator.id); @@ -313,7 +308,7 @@ test('resolveTargetDevice uses injected device inventory without local discovery assert.equal(request.deviceName, 'Remote iPhone'); return [{ ...bootedSimulator, id: 'remote-ios-1', name: 'Remote iPhone' }]; }, - async () => await resolveTargetDevice({ platform: 'ios', device: 'Remote iPhone' }), + async () => await resolveTargetDeviceInContext({ platform: 'ios', device: 'Remote iPhone' }), ); assert.equal(result.id, 'remote-ios-1'); @@ -323,7 +318,7 @@ test('resolveTargetDevice uses injected device inventory without local discovery test('resolveTargetDevice preserves physical-device backend evidence from injected inventory', async () => { const result = await withDeviceInventoryProvider( async () => [{ ...physical, iosPhysicalDeviceBackend: 'xctest' }], - async () => await resolveTargetDevice({ platform: 'ios', udid: physical.id }), + async () => await resolveTargetDeviceInContext({ platform: 'ios', udid: physical.id }), ); assert.equal(result.iosPhysicalDeviceBackend, 'xctest'); @@ -331,14 +326,17 @@ test('resolveTargetDevice preserves physical-device backend evidence from inject }); test('resolveTargetDevice preserves Apple simulator preference with injected inventory', async () => { - mockFindBootableIosSimulator.mockResolvedValue(simulator); - - const result = await withDeviceInventoryProvider( - async (request) => { - assert.equal(request.platform, 'ios'); - return [physical]; + const result = await withTestDeviceInventory( + { + provider: { + discover: async (request) => { + assert.equal(request.platform, 'ios'); + return { kind: 'inventory', devices: [physical] }; + }, + }, + local: async () => [simulator], }, - async () => await resolveTargetDevice({ platform: 'ios' }), + async () => await resolveTargetDeviceInContext({ platform: 'ios' }), ); assert.equal(result.id, 'sim-1'); @@ -346,11 +344,65 @@ test('resolveTargetDevice preserves Apple simulator preference with injected inv assert.equal(mockListAppleDevices.mock.calls.length, 0); }); +test('resolveTargetDevice propagates cancellation from the local Apple simulator probe', async () => { + const canceled = new AppError('COMMAND_FAILED', 'request canceled', { + reason: 'request_canceled', + }); + + await assert.rejects( + withTestDeviceInventory( + { + provider: { discover: async () => ({ kind: 'inventory', devices: [physical] }) }, + local: async () => { + throw canceled; + }, + }, + async () => await resolveTargetDeviceInContext({ platform: 'ios' }), + ), + (error) => error === canceled, + ); +}); + +test('resolveTargetDevice propagates missing inventory wiring from the local Apple simulator probe', async () => { + const unavailable = new AppError( + 'COMMAND_FAILED', + 'Device inventory gateway is unavailable outside request execution', + { reason: 'device_inventory_context_unavailable' }, + ); + + await assert.rejects( + withTestDeviceInventory( + { + provider: { discover: async () => ({ kind: 'inventory', devices: [physical] }) }, + local: async () => { + throw unavailable; + }, + }, + async () => await resolveTargetDeviceInContext({ platform: 'ios' }), + ), + (error) => error === unavailable, + ); +}); + +test('resolveTargetDevice keeps genuine local Apple simulator probe failures best-effort', async () => { + const result = await withTestDeviceInventory( + { + provider: { discover: async () => ({ kind: 'inventory', devices: [physical] }) }, + local: async () => { + throw new Error('simctl timed out'); + }, + }, + async () => await resolveTargetDeviceInContext({ platform: 'ios' }), + ); + + assert.equal(result.id, physical.id); +}); + test('resolveTargetDevice treats empty injected inventory as authoritative', async () => { await expectDeviceNotFound(() => withDeviceInventoryProvider( async () => [], - async () => await resolveTargetDevice({ platform: 'ios' }), + async () => await resolveTargetDeviceInContext({ platform: 'ios' }), ), ); @@ -364,12 +416,15 @@ test('resolveTargetDevice resolves web through generic inventory without Apple f assert.equal(request.deviceName, 'Agent Browser Chrome'); return [webDesktop]; }, - async () => await resolveTargetDevice({ platform: 'web', device: 'Agent Browser Chrome' }), + async () => + await resolveTargetDeviceInContext({ + platform: 'web', + device: 'Agent Browser Chrome', + }), ); assert.equal(result.platform, 'web'); assert.equal(result.id, 'agent-browser-chrome'); - assert.equal(mockFindBootableIosSimulator.mock.calls.length, 0); assert.equal(mockListAppleDevices.mock.calls.length, 0); }); diff --git a/src/core/command-descriptor/__tests__/platform-execution.test.ts b/src/core/command-descriptor/__tests__/platform-execution.test.ts new file mode 100644 index 0000000000..2bd4e8e1e7 --- /dev/null +++ b/src/core/command-descriptor/__tests__/platform-execution.test.ts @@ -0,0 +1,21 @@ +import { inventoryUse } from '@agent-device/contracts/platform'; +import { describe, expect, test } from 'vitest'; +import { commandDescriptors } from '../registry.ts'; +import { deriveDaemonCommandDescriptors } from '../derive.ts'; + +describe('command platform execution cutover', () => { + test('declares devices through the descriptor-owned inventory use', () => { + expect(commandDescriptors.find(({ name }) => name === 'devices')?.platformExecution).toEqual({ + kind: 'inventory', + use: inventoryUse, + }); + }); + + test('keeps the internal cutover discriminant out of daemon/public projections', () => { + const devices = deriveDaemonCommandDescriptors(commandDescriptors).find( + ({ command }) => command === 'devices', + ); + expect(devices).toBeDefined(); + expect(devices).not.toHaveProperty('platformExecution'); + }); +}); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 2913136ebe..4f9b2d9508 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -12,6 +12,7 @@ import { } from './timeout-policy.ts'; import { resolvePostActionObservationSupport } from './post-action-observation.ts'; import type { PostActionObservationSupport } from './post-action-observation.ts'; +import { assertCommandPlatformExecution, inventoryUse } from '@agent-device/contracts/platform'; import type { CommandCatalogGroup, CommandDescriptor, @@ -22,9 +23,10 @@ import type { } from './types.ts'; type RawCommandDescriptorShape = T extends CommandDescriptor - ? Omit & { + ? Omit & { mcpExposed?: boolean; ownerFiles?: readonly [string, ...string[]]; + platformExecution?: T['platformExecution']; } : never; type RawCommandDescriptor = RawCommandDescriptorShape; @@ -407,6 +409,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, + platformExecution: { kind: 'inventory', use: inventoryUse }, }, { name: 'capabilities', @@ -1358,10 +1361,16 @@ const CLI_COMMAND_NAMES = new Set( * union below a precise set of command-name literals rather than `string`. */ export const commandDescriptors = RAW_COMMAND_DESCRIPTORS.map((descriptor) => { + const platformExecution = + 'platformExecution' in descriptor + ? descriptor.platformExecution + : ({ kind: 'legacy' } as const); + assertCommandPlatformExecution(platformExecution); if (!ownerFilesEnabled) { return { ...descriptor, mcpExposed: resolveMcpExposure(descriptor), + platformExecution, }; } @@ -1369,6 +1378,7 @@ export const commandDescriptors = RAW_COMMAND_DESCRIPTORS.map((descriptor) => { return { ...runtimeDescriptor, mcpExposed: resolveMcpExposure(descriptor), + platformExecution, }; }) satisfies readonly CommandDescriptor[]; diff --git a/src/core/command-descriptor/types.ts b/src/core/command-descriptor/types.ts index f16589591f..bef1d63a89 100644 --- a/src/core/command-descriptor/types.ts +++ b/src/core/command-descriptor/types.ts @@ -4,6 +4,7 @@ import type { DaemonCommandDescriptor } from '../../daemon/daemon-command-regist // descriptors read `command`, `positionals` and `flags` and never touch `internal`. import type { DispatchedCommand } from '@agent-device/contracts/command'; import type { PostActionObservationSupport } from './post-action-observation.ts'; +import type { CommandPlatformExecution } from '@agent-device/contracts/platform'; export type ResponseDataFieldTransform = { defaultValue?: unknown; @@ -162,6 +163,8 @@ type CommandDescriptorBase = { responseDataTransform?: CommandResponseDataTransform; catalog: CommandCatalogFacet; dispatch?: CommandDispatchFacet; + /** Internal-only ADR 0019 cutover discriminant; public projections must ignore it. */ + platformExecution: CommandPlatformExecution; /** ADR 0012 / #1349: present iff this command's recorded steps can carry `target-v1` evidence. */ targetIdentityVerification?: TargetIdentityVerification; /** diff --git a/src/core/device-inventory-context.ts b/src/core/device-inventory-context.ts new file mode 100644 index 0000000000..eedff4f603 --- /dev/null +++ b/src/core/device-inventory-context.ts @@ -0,0 +1,83 @@ +import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; +import { + type DeviceInventoryDiscovery, + type DeviceInventoryGateway, + type PlatformRequestScope, + type ProviderAwareDeviceInventoryGateway, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { isRequestCanceledError } from '../request/cancel.ts'; + +const DEVICE_INVENTORY_CONTEXT_UNAVAILABLE_REASON = 'device_inventory_context_unavailable'; + +type DeviceInventoryContext = Readonly<{ + providerFirst: ProviderAwareDeviceInventoryGateway; + localOnly: DeviceInventoryGateway; + requestScope: PlatformRequestScope; +}>; + +const deviceInventoryContext = new AsyncLocalStorage(); + +export async function withDeviceInventoryContext( + context: DeviceInventoryContext, + task: () => Promise, +): Promise { + return await deviceInventoryContext.run(context, task); +} + +export async function listDeviceInventory(request: DeviceInventoryRequest): Promise { + return await discoverFrom('providerFirst', request); +} + +export async function readDeviceInventory( + request: DeviceInventoryRequest, +): Promise { + const context = requiredContext(); + const result = await context.providerFirst.discoverWithSource(request, context.requestScope); + return { + devices: result.devices.map((device) => ({ ...device })), + source: result.source, + }; +} + +export async function listLocalDeviceInventory( + request: DeviceInventoryRequest, +): Promise { + return await discoverFrom('localOnly', request); +} + +async function discoverFrom( + gateway: 'providerFirst' | 'localOnly', + request: DeviceInventoryRequest, +): Promise { + const context = requiredContext(); + const devices = await context[gateway].discover(request, context.requestScope); + return devices.map((device) => ({ ...device })); +} + +function requiredContext(): DeviceInventoryContext { + const context = deviceInventoryContext.getStore(); + if (!context) { + throw new AppError( + 'COMMAND_FAILED', + 'Device inventory gateway is unavailable outside request execution', + { reason: DEVICE_INVENTORY_CONTEXT_UNAVAILABLE_REASON }, + ); + } + return context; +} + +/** Control-flow and composition failures that a best-effort inventory probe must never hide. */ +export function shouldPropagateDeviceInventoryProbeError(error: unknown): boolean { + if (isRequestCanceledError(error)) return true; + if ( + error instanceof AppError && + error.code === 'COMMAND_FAILED' && + error.details?.reason === DEVICE_INVENTORY_CONTEXT_UNAVAILABLE_REASON + ) { + return true; + } + return error instanceof Error && error.name === 'AbortError'; +} diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index d44ec17379..f2df978209 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -1,8 +1,5 @@ import type { CliFlags } from '@agent-device/contracts/command'; -import type { - DeviceInventoryProvider, - DeviceInventoryRequest, -} from '@agent-device/contracts/device'; +import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; import { isApplePlatform, isIosFamily, @@ -21,7 +18,11 @@ import { } from '../utils/device-isolation.ts'; import { withDiagnosticTimer } from '../utils/diagnostics.ts'; import type { DeviceCandidateDetails } from '../utils/error-candidates.ts'; -import { listLocalDeviceInventory } from './platform-inventory.ts'; +import { + listLocalDeviceInventory, + readDeviceInventory, + shouldPropagateDeviceInventoryProbeError, +} from './device-inventory-context.ts'; export type ResolveDeviceFlags = Pick< CliFlags, | 'platform' @@ -39,7 +40,6 @@ export type ResolveDeviceFlags = Pick< }; const resolveTargetDeviceCacheScope = new AsyncLocalStorage>(); -const deviceInventoryProviderScope = new AsyncLocalStorage(); export type { DeviceInventoryRequest }; @@ -81,11 +81,17 @@ async function resolveAppleDevice( context.allowLocalSimulatorFallback !== false && shouldUseAppleSimulatorFallback(selector, selected) ) { - const { findBootableIosSimulator } = await import('../platforms/apple/core/devices.ts'); - const simulator = await findBootableIosSimulator({ - simulatorSetPath: context.simulatorSetPath, - target: selector.target, - }); + let simulator: DeviceInfo | undefined; + try { + [simulator] = await listLocalDeviceInventory({ + platform: selector.platform ?? 'ios', + target: selector.target, + iosSimulatorSetPath: context.simulatorSetPath, + kind: 'simulator', + }); + } catch (error) { + if (shouldPropagateDeviceInventoryProbeError(error)) throw error; + } if (simulator) return simulator; } @@ -217,31 +223,16 @@ export async function resolveTargetDevice( diagnosticData.cacheHit = true; return cached; } - const injectedDevices = await readInjectedDeviceInventory(inventoryRequest); - if (injectedDevices) { - if (shouldUseAppleResolution(selector)) { - return cacheResolvedTargetDevice( - cacheKey, - await resolveAppleDevice(injectedDevices, selector as AppleDeviceSelector, { - simulatorSetPath: iosSimulatorSetPath, - allowLocalSimulatorFallback: inventoryRequest.leaseProvider === undefined, - appleSimulatorAppTarget: options.appleSimulatorAppTarget, - }), - ); - } - return cacheResolvedTargetDevice( - cacheKey, - await resolveDevice(injectedDevices, selector, selectionContext), - ); - } - - const devices = await listLocalDeviceInventory(inventoryRequest); + const inventory = await readDeviceInventory(inventoryRequest); + const devices = [...inventory.devices]; if (shouldUseAppleResolution(selector)) { return cacheResolvedTargetDevice( cacheKey, await resolveAppleDevice(devices, selector as AppleDeviceSelector, { simulatorSetPath: iosSimulatorSetPath, + allowLocalSimulatorFallback: + inventory.source === 'local' || inventoryRequest.leaseProvider === undefined, appleSimulatorAppTarget: options.appleSimulatorAppTarget, }), ); @@ -294,38 +285,6 @@ export async function withResolveTargetDeviceCacheScope(task: () => Promise( - provider: DeviceInventoryProvider | undefined, - task: () => Promise, -): Promise { - if (!provider) return await task(); - return await deviceInventoryProviderScope.run(provider, task); -} - -export async function withTargetDeviceResolutionScope( - provider: DeviceInventoryProvider | undefined, - task: () => Promise, -): Promise { - return await withDeviceInventoryProvider( - provider, - async () => await withResolveTargetDeviceCacheScope(task), - ); -} - -export async function listDeviceInventory(request: DeviceInventoryRequest): Promise { - return (await readInjectedDeviceInventory(request)) ?? (await listLocalDeviceInventory(request)); -} - -async function readInjectedDeviceInventory( - request: DeviceInventoryRequest, -): Promise { - const provider = deviceInventoryProviderScope.getStore(); - if (!provider) return null; - const devices = await provider(request); - if (devices === undefined || devices === null) return null; - return devices.map((device) => ({ ...device })); -} - function isAppleResolutionSelector(selector: { platform?: PlatformSelector; target?: DeviceTarget; diff --git a/src/core/interactors/register-builtins.ts b/src/core/interactors/register-builtins.ts index 7ca744358d..fed858193e 100644 --- a/src/core/interactors/register-builtins.ts +++ b/src/core/interactors/register-builtins.ts @@ -5,9 +5,7 @@ import { applePlugin } from '../../platforms/apple/plugin.ts'; import { vegaPlugin } from '../../platforms/vega/plugin.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { isAudioProbeSupportedDevice } from '@agent-device/contracts/platform'; -import { WEB_DESKTOP_DEVICE, type DeviceInventoryRequest } from '@agent-device/contracts/device'; import type { Platform, DeviceInfo } from '@agent-device/kernel/device'; -import { resolveAndroidDiscoverySerialAllowlist } from '../platform-inventory.ts'; // The builtin-plugin wiring lives at the interactor seam (src/core/interactors/) — // the one place R3 (see scripts/layering/check.ts) permits a STATIC value import of @@ -16,10 +14,7 @@ import { resolveAndroidDiscoverySerialAllowlist } from '../platform-inventory.ts // stay in `core/` (src/core/platform-plugin/plugin.ts) where non-interactor core code // like `core/capabilities.ts` may import them. The Apple plugin instance and its // capability closures now live under `platforms/apple/`; the android/linux/web wiring -// stays here. Each plugin WRAPS today's existing factories (src/core/interactors/*) and -// the inventory if-chain (src/core/platform-inventory.ts) as LAZY methods: the dynamic -// `import()`s and per-platform list calls are byte-for-byte the same as the -// hand-authored `getInteractor` switch arms and `listLocalDeviceInventory` branches. +// stays here. Each plugin WRAPS today's existing interactor factories lazily. // `as const satisfies PlatformPlugin` preserves each plugin's literal `platforms` tuple // so the totality assertion below is a real compile-time check. @@ -54,12 +49,6 @@ const androidPlugin = { const { createAndroidInteractor } = await import('./android.ts'); return createAndroidInteractor(device, undefined, runner); }, - discoverDevices: async (request: DeviceInventoryRequest) => { - const { listAndroidDevices } = await import('../../platforms/android/devices.ts'); - return await listAndroidDevices({ - serialAllowlist: resolveAndroidDiscoverySerialAllowlist(request), - }); - }, } as const satisfies PlatformPlugin; const harmonyosPlugin = { @@ -77,10 +66,6 @@ const harmonyosPlugin = { const { createHarmonyInteractor } = await import('./harmonyos.ts'); return createHarmonyInteractor(device, runner); }, - discoverDevices: async (request: DeviceInventoryRequest) => { - const { listHarmonyDevices } = await import('../../platforms/harmonyos/devices.ts'); - return await listHarmonyDevices({ serial: request.serial }); - }, } as const satisfies PlatformPlugin; const linuxPlugin = { @@ -96,10 +81,6 @@ const linuxPlugin = { const { createLinuxInteractor } = await import('./linux.ts'); return createLinuxInteractor(); }, - discoverDevices: async () => { - const { listLinuxDevices } = await import('../../platforms/linux/devices.ts'); - return await listLinuxDevices(); - }, } as const satisfies PlatformPlugin; const webPlugin = { @@ -116,8 +97,6 @@ const webPlugin = { const { createWebInteractor } = await import('./web.ts'); return createWebInteractor(); }, - // Mirrors the `request.platform === 'web'` branch (the single static device). - discoverDevices: async () => [WEB_DESKTOP_DEVICE], } as const satisfies PlatformPlugin; /** diff --git a/src/core/platform-inventory.test.ts b/src/core/platform-inventory.test.ts deleted file mode 100644 index 3cdc0469ea..0000000000 --- a/src/core/platform-inventory.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import assert from 'node:assert/strict'; -import { test, vi } from 'vitest'; -import type { DeviceInfo } from '@agent-device/kernel/device'; - -const { - listVegaDevices, - listAndroidDevices, - listHarmonyDevices, - listAppleDevices, - listLinuxDevices, -} = vi.hoisted(() => ({ - listVegaDevices: vi.fn(), - listAndroidDevices: vi.fn(), - listHarmonyDevices: vi.fn(), - listAppleDevices: vi.fn(), - listLinuxDevices: vi.fn(), -})); - -vi.mock('../platforms/vega/devices.ts', () => ({ - listVegaDevices, -})); -vi.mock('../platforms/android/devices.ts', () => ({ - listAndroidDevices, -})); -vi.mock('../platforms/harmonyos/devices.ts', () => ({ - listHarmonyDevices, -})); -vi.mock('../platforms/apple/core/devices.ts', () => ({ - listAppleDevices, -})); -vi.mock('../platforms/linux/devices.ts', () => ({ - listLinuxDevices, -})); - -import { LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS } from '@agent-device/contracts/device'; -import { listLocalDeviceInventory } from './platform-inventory.ts'; - -const VEGA_EMULATOR: DeviceInfo = { - platform: 'vega', - id: 'VirtualDevice', - name: 'Vega Virtual Device', - kind: 'emulator', - target: 'tv', - booted: true, -}; - -test('explicit Vega inventory delegates to the Vega device module', async () => { - listVegaDevices.mockResolvedValueOnce([VEGA_EMULATOR]); - - const result = await listLocalDeviceInventory({ - platform: 'vega', - target: 'tv', - serial: VEGA_EMULATOR.id, - }); - - assert.deepEqual(result, [VEGA_EMULATOR]); - assert.deepEqual(listVegaDevices.mock.calls[0], []); -}); - -test('probes every platform concurrently and keeps selector order in the result', async () => { - // Sequential awaits made an unfiltered lookup cost the sum of every - // toolchain probe. Each stub records when it starts and resolves only once - // all five have started, so this deadlocks unless they genuinely overlap. - const PLATFORM_COUNT = LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS.length; - let started = 0; - let allStarted!: () => void; - const everyProbeStarted = new Promise((resolve) => { - allStarted = resolve; - }); - const gate = async () => { - started += 1; - if (started === PLATFORM_COUNT) allStarted(); - await everyProbeStarted; - }; - - listAndroidDevices.mockImplementation(async () => { - await gate(); - return [device('android', 'android-1')]; - }); - listHarmonyDevices.mockImplementation(async () => { - await gate(); - return [device('harmonyos', 'harmony-1')]; - }); - listAppleDevices.mockImplementation(async () => { - await gate(); - return [device('apple', 'apple-1')]; - }); - listVegaDevices.mockImplementation(async () => { - await gate(); - return [device('vega', 'vega-1')]; - }); - listLinuxDevices.mockImplementation(async () => { - await gate(); - return [device('linux', 'linux-1')]; - }); - - const result = await listLocalDeviceInventory({}); - - assert.equal(started, PLATFORM_COUNT); - assert.deepEqual( - result.map((entry) => entry.id), - ['android-1', 'harmony-1', 'apple-1', 'vega-1', 'linux-1'], - ); -}); - -test('a failing platform probe does not drop the devices found by the others', async () => { - listAndroidDevices.mockRejectedValue(new Error('adb exploded')); - listHarmonyDevices.mockResolvedValue([device('harmonyos', 'harmony-1')]); - listAppleDevices.mockResolvedValue([device('apple', 'apple-1')]); - listVegaDevices.mockRejectedValue(new Error('vega exploded')); - listLinuxDevices.mockResolvedValue([device('linux', 'linux-1')]); - - const result = await listLocalDeviceInventory({}); - - assert.deepEqual( - result.map((entry) => entry.id), - ['harmony-1', 'apple-1', 'linux-1'], - ); -}); - -function device(platform: DeviceInfo['platform'], id: string): DeviceInfo { - return { platform, id, name: id, kind: 'device', booted: true }; -} diff --git a/src/core/platform-inventory.ts b/src/core/platform-inventory.ts deleted file mode 100644 index 575363a2a1..0000000000 --- a/src/core/platform-inventory.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { type DeviceInfo } from '@agent-device/kernel/device'; -import { - LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS, - shouldUseHostMacFastPath, - WEB_DESKTOP_DEVICE, - type DeviceInventoryRequest, -} from '@agent-device/contracts/device'; - -export async function listLocalDeviceInventory( - request: DeviceInventoryRequest, -): Promise { - if (request.platform === 'web') { - return [WEB_DESKTOP_DEVICE]; - } - - if (shouldUseHostMacFastPath(request)) { - const { listMacosDevices } = await import('../platforms/apple/os/macos/devices.ts'); - return await listMacosDevices(); - } - - if (request.platform === 'linux') { - const { listLinuxDevices } = await import('../platforms/linux/devices.ts'); - return await listLinuxDevices(); - } - - if (request.platform === 'android') { - const { listAndroidDevices } = await import('../platforms/android/devices.ts'); - return await listAndroidDevices({ - serialAllowlist: resolveAndroidDiscoverySerialAllowlist(request), - }); - } - - if (request.platform === 'harmonyos') { - const { listHarmonyDevices } = await import('../platforms/harmonyos/devices.ts'); - return await listHarmonyDevices({ serial: request.serial }); - } - - if (request.platform === 'vega') { - const { listVegaDevices } = await import('../platforms/vega/devices.ts'); - return await listVegaDevices(); - } - - if (request.platform) { - const { listAppleDevices } = await import('../platforms/apple/core/devices.ts'); - return await listAppleDevices({ - simulatorSetPath: request.iosSimulatorSetPath, - udid: request.udid, - }); - } - - // Probed concurrently: each platform shells out to its own toolchain, and - // awaiting them in turn made an unfiltered lookup cost their sum — measured - // at 6.7s on a host with the Apple, Android and Vega toolchains installed, - // most of it spent enumerating platforms the request could not target. - // - // Results are still concatenated in selector order, so the Linux local device - // stays last and does not displace connected Android/Apple devices in - // implicit auto-selection. - const perPlatform = await Promise.all( - LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS.map(async (platform) => { - try { - const listed = await listLocalDeviceInventory({ ...request, platform }); - // A platform that answers with anything but a list contributes nothing, - // exactly as before: spreading a non-array used to throw into the catch. - return Array.isArray(listed) ? listed : []; - } catch { - return []; - } - }), - ); - return perPlatform.flat(); -} - -export function resolveAndroidDiscoverySerialAllowlist( - request: DeviceInventoryRequest, -): ReadonlySet | undefined { - const policyAllowlist = request.androidSerialAllowlist; - const selectedSerial = request.serial?.trim(); - if (!selectedSerial) return policyAllowlist ? new Set(policyAllowlist) : undefined; - if (!policyAllowlist) return new Set([selectedSerial]); - return new Set(policyAllowlist.includes(selectedSerial) ? [selectedSerial] : []); -} diff --git a/src/daemon/__tests__/device-ready.test.ts b/src/daemon/__tests__/device-ready.test.ts index 75bec2d6ff..f90f4f8cfd 100644 --- a/src/daemon/__tests__/device-ready.test.ts +++ b/src/daemon/__tests__/device-ready.test.ts @@ -10,12 +10,12 @@ vi.mock('../../utils/exec.ts', () => ({ vi.mock('../../platforms/apple/core/simulator.ts', () => ({ ensureBootedSimulator: vi.fn(async () => {}), })); -vi.mock('../../platforms/android/devices.ts', () => ({ +vi.mock('../../platforms/android/emulator-lifecycle.ts', () => ({ waitForAndroidBoot: vi.fn(async () => {}), })); import { runCmd } from '../../utils/exec.ts'; -import { waitForAndroidBoot } from '../../platforms/android/devices.ts'; +import { waitForAndroidBoot } from '../../platforms/android/emulator-lifecycle.ts'; import { ensureBootedSimulator } from '../../platforms/apple/core/simulator.ts'; import { ANDROID_EMULATOR, IOS_DEVICE, IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts'; import { DEVICE_READY_CACHE_TTL_MS, ensureDeviceReady } from '../device-ready.ts'; diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index 48cf5aa822..46e29c6784 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import { withTargetDeviceResolutionScope } from '../../core/dispatch-resolve.ts'; +import { withTestDeviceInventoryProvider as withTargetDeviceResolutionScope } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { getDaemonCommandRoute, type DaemonCommandRoute } from '../daemon-command-registry.ts'; diff --git a/src/daemon/__tests__/request-platform-providers.test.ts b/src/daemon/__tests__/request-platform-providers.test.ts index e09cf3bfcd..e087019fbc 100644 --- a/src/daemon/__tests__/request-platform-providers.test.ts +++ b/src/daemon/__tests__/request-platform-providers.test.ts @@ -8,7 +8,7 @@ import { makeIosSession, makeSession, } from '../../__tests__/test-utils/index.ts'; -import { withTargetDeviceResolutionScope } from '../../core/dispatch-resolve.ts'; +import { withTestDeviceInventoryProvider as withTargetDeviceResolutionScope } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { createLocalAppleToolProvider, runXcrun, diff --git a/src/daemon/__tests__/request-router-android-modal.test.ts b/src/daemon/__tests__/request-router-android-modal.test.ts index c75ea0cd30..fd84aae810 100644 --- a/src/daemon/__tests__/request-router-android-modal.test.ts +++ b/src/daemon/__tests__/request-router-android-modal.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { test, expect, vi } from 'vitest'; import os from 'node:os'; import path from 'node:path'; @@ -121,6 +122,7 @@ test('generic Android gesture commands dismiss blocking system dialogs during re token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -159,6 +161,7 @@ test('generic Android gesture commands continue when recording dialog inspection token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -202,6 +205,7 @@ test('generic Android gesture commands skip local dialog recovery for provider d token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), providerDeviceRuntimeScope: providers.providerDeviceRuntimeScope, trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/daemon/__tests__/request-router-android-perf.test.ts b/src/daemon/__tests__/request-router-android-perf.test.ts index f41c450801..bf8955f3b9 100644 --- a/src/daemon/__tests__/request-router-android-perf.test.ts +++ b/src/daemon/__tests__/request-router-android-perf.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { expect, test } from 'vitest'; import { createRequestHandler } from '../request-router.ts'; import { LeaseRegistry } from '../lease-registry.ts'; @@ -32,6 +33,7 @@ function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => Andro token: 'token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), androidAdbProvider, trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts index 5be75ef704..fbdfe6c45c 100644 --- a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts +++ b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { beforeEach, expect, test } from 'vitest'; import { createRequestHandler } from '../request-router.ts'; import { LeaseRegistry } from '../lease-registry.ts'; @@ -31,6 +32,7 @@ function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => Andro token: 'token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), androidAdbProvider, trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/daemon/__tests__/request-router-cost.test.ts b/src/daemon/__tests__/request-router-cost.test.ts index c6529f61a1..3b7a88bd63 100644 --- a/src/daemon/__tests__/request-router-cost.test.ts +++ b/src/daemon/__tests__/request-router-cost.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; @@ -58,6 +59,7 @@ function makeHandler(sessionStore = makeSessionStore('agent-device-router-cost-' token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }), }; diff --git a/src/daemon/__tests__/request-router-custom-action-flags.test.ts b/src/daemon/__tests__/request-router-custom-action-flags.test.ts index 49d8e8d03a..cc061933bc 100644 --- a/src/daemon/__tests__/request-router-custom-action-flags.test.ts +++ b/src/daemon/__tests__/request-router-custom-action-flags.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; /** * `--actions` reads custom actions through the private-AX snapshot path, which * a raw capture does not use. Answering the pair with a capture that @@ -18,6 +19,7 @@ function createHandler() { token: 'test-token', sessionStore: makeSessionStore('agent-device-router-custom-action-flags-'), leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); } diff --git a/src/daemon/__tests__/request-router-events.test.ts b/src/daemon/__tests__/request-router-events.test.ts index b5e4d5c354..a003d50556 100644 --- a/src/daemon/__tests__/request-router-events.test.ts +++ b/src/daemon/__tests__/request-router-events.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { test, expect } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -22,6 +23,7 @@ test('events reads the daemon-owned session timeline without appending poll nois token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -65,6 +67,7 @@ test('events accepts a blank limit placeholder for cursor-only reads', async () token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -103,6 +106,7 @@ test('events returns structured errors for invalid limit and cursor', async () = token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -149,6 +153,7 @@ test('events flushes pending event writes before reading', async () => { token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -177,6 +182,7 @@ test('request timeline records thrown request failures after scope creation', as token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -215,6 +221,7 @@ test('request timeline records setup failures after start is appended', async () token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index 3afb3b84d7..da85caf00f 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -1,3 +1,7 @@ +import { + createTestDeviceInventoryGateways, + createTestDeviceInventoryGatewaysFromProvider, +} from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; @@ -103,6 +107,7 @@ test('direct daemon requests cannot bypass reject lock policy for existing sessi token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -138,7 +143,9 @@ test('fresh named sessions with matching explicit serial bind and serialize on t token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), - deviceInventoryProvider: async () => [makeAndroidSession('inventory').device], + deviceInventoryGateways: createTestDeviceInventoryGatewaysFromProvider(async () => [ + makeAndroidSession('inventory').device, + ]), trackDownloadableArtifact: () => 'artifact-id', }); @@ -213,7 +220,10 @@ test('fresh named sessions with the same name serialize first binding before rej token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), - deviceInventoryProvider: async () => [firstDevice, secondDevice], + deviceInventoryGateways: createTestDeviceInventoryGatewaysFromProvider(async () => [ + firstDevice, + secondDevice, + ]), trackDownloadableArtifact: () => 'artifact-id', }); @@ -282,7 +292,9 @@ test('fresh named sessions with only lock platform default serialize on the sele token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), - deviceInventoryProvider: async () => [makeAndroidSession('inventory').device], + deviceInventoryGateways: createTestDeviceInventoryGatewaysFromProvider(async () => [ + makeAndroidSession('inventory').device, + ]), trackDownloadableArtifact: () => 'artifact-id', }); @@ -387,7 +399,9 @@ test('fresh named sessions reject incompatible selector combinations before bind token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), - deviceInventoryProvider: async () => [makeIosSession('inventory').device], + deviceInventoryGateways: createTestDeviceInventoryGatewaysFromProvider(async () => [ + makeIosSession('inventory').device, + ]), trackDownloadableArtifact: () => 'artifact-id', }); @@ -423,6 +437,7 @@ test('batch steps cannot bypass reject lock policy on nested direct requests', a token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -471,6 +486,7 @@ test('direct daemon requests apply strip lock policy for existing sessions befor token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -517,6 +533,7 @@ test('batch preserves tenant-scoped session names across nested requests', async token: 'test-token', sessionStore, leaseRegistry, + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 9ecec62eb8..96941a1455 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -55,6 +56,7 @@ function createOpenHandler( token: 'test-token', sessionStore, leaseRegistry, + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); } diff --git a/src/daemon/__tests__/request-router-record-flags.test.ts b/src/daemon/__tests__/request-router-record-flags.test.ts index a873625000..283729af09 100644 --- a/src/daemon/__tests__/request-router-record-flags.test.ts +++ b/src/daemon/__tests__/request-router-record-flags.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; /** * #1271 stage 2 (ADR 0012 amendment): `--record` and `--no-record` express * opposite recording intents for the same action, so both together is @@ -18,6 +19,7 @@ function createHandler() { token: 'test-token', sessionStore: makeSessionStore('agent-device-router-record-flags-'), leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); } diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index d6dc2a189d..23e6883aee 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; @@ -75,6 +76,7 @@ test('router blocks non-record commands when recording was invalidated', async ( token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -131,6 +133,7 @@ test('router allows canonical iOS simulator gestures during overlay recording af token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/daemon/__tests__/request-router-repair-expired.test.ts b/src/daemon/__tests__/request-router-repair-expired.test.ts index 4f11610bc3..25a1b172bb 100644 --- a/src/daemon/__tests__/request-router-repair-expired.test.ts +++ b/src/daemon/__tests__/request-router-repair-expired.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; /** * ADR 0012 decision 6, R7 (C5a): when a repair session was reaped before it was * finalized, the request router rewrites the resulting `SESSION_NOT_FOUND` into @@ -30,6 +31,7 @@ function makeHandler(prefix: string) { token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); return { sessionStore, handler }; diff --git a/src/daemon/__tests__/request-router-replay-env.test.ts b/src/daemon/__tests__/request-router-replay-env.test.ts index 70f47453fe..612661f99a 100644 --- a/src/daemon/__tests__/request-router-replay-env.test.ts +++ b/src/daemon/__tests__/request-router-replay-env.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import fs from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; @@ -16,6 +17,7 @@ function createHarness() { token: 'test-token', sessionStore: makeSessionStore('agent-device-router-replay-env-store-'), leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }), }; diff --git a/src/daemon/__tests__/request-router-replay-scope.test.ts b/src/daemon/__tests__/request-router-replay-scope.test.ts index 78017ae18b..6fae867c40 100644 --- a/src/daemon/__tests__/request-router-replay-scope.test.ts +++ b/src/daemon/__tests__/request-router-replay-scope.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -62,6 +63,7 @@ test('replay runs active-session actions inside the parent request provider scop token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), appleRunnerProvider, trackDownloadableArtifact: () => 'artifact-id', }); @@ -92,6 +94,7 @@ test('replay routes session-changing actions through the full request path', asy token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), appleRunnerProvider, trackDownloadableArtifact: () => 'artifact-id', }); @@ -120,6 +123,7 @@ test('session list includes a cwd-scoped session opened by replay', async () => token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), appleRunnerProvider: () => undefined, trackDownloadableArtifact: () => 'artifact-id', }); @@ -181,6 +185,7 @@ test('fresh replay retains a dynamically selected device through finalization', token: 'test-token', sessionStore, leaseRegistry, + deviceInventoryGateways: createTestDeviceInventoryGateways(), appleRunnerProvider: () => undefined, trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/daemon/__tests__/request-router-response-level.test.ts b/src/daemon/__tests__/request-router-response-level.test.ts index 58d8f395a0..b5427fe138 100644 --- a/src/daemon/__tests__/request-router-response-level.test.ts +++ b/src/daemon/__tests__/request-router-response-level.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import os from 'node:os'; import path from 'node:path'; @@ -69,6 +70,7 @@ function makeHandler() { token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }), }; diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index 18eeeacf99..c6458c95ff 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -94,6 +95,7 @@ test('screenshot resolves relative positional path against request cwd', async ( token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -187,6 +189,7 @@ test('router serializes concurrent commands for the same device across sessions' token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -243,6 +246,7 @@ test('screenshot forwards macOS session surface to dispatch', async () => { token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -271,6 +275,7 @@ test('click forwards macOS menubar session surface to dispatch', async () => { token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -311,6 +316,7 @@ test('screenshot keeps absolute positional path unchanged', async () => { token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -347,6 +353,7 @@ test('screenshot runtime supplies default output path when none is requested', a token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -384,6 +391,7 @@ test('iOS simulator screenshot response includes output dimensions and logical d token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -426,6 +434,7 @@ test('non-iOS screenshot response tolerates malformed PNG metadata', async () => token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -463,6 +472,7 @@ test('iOS simulator screenshot omits logical density metadata after --scale down token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -501,6 +511,7 @@ test('screenshot rejects the removed max-size field from older remote clients', token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -542,6 +553,7 @@ test('screenshot resolves --out flag path against request cwd', async () => { token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -592,6 +604,7 @@ test('screenshot --overlay-refs captures a fresh snapshot when the session has n token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -683,6 +696,7 @@ test('screenshot --overlay-refs uses interactive iOS presentation for row-like o token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -758,6 +772,7 @@ test('screenshot --overlay-refs uses a fresh snapshot instead of stale session s token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -824,6 +839,7 @@ test('screenshot --pixel-density keeps overlay refs aligned to scaled iOS simula token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -865,6 +881,7 @@ test('screenshot --pixel-density is rejected outside iOS-family simulators', asy token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/daemon/__tests__/request-router-typed-error.test.ts b/src/daemon/__tests__/request-router-typed-error.test.ts index ab49f5b720..c1bd5b5328 100644 --- a/src/daemon/__tests__/request-router-typed-error.test.ts +++ b/src/daemon/__tests__/request-router-typed-error.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -61,6 +62,7 @@ function makeHandler(sessionStore = makeSessionStore('agent-device-router-typed- token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }), }; diff --git a/src/daemon/__tests__/request-save-script-transports.test.ts b/src/daemon/__tests__/request-save-script-transports.test.ts index e8a826348f..782c5f4be3 100644 --- a/src/daemon/__tests__/request-save-script-transports.test.ts +++ b/src/daemon/__tests__/request-save-script-transports.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; /** * #1478 (P4-pre): raw-wire counterfactuals for the `flags.saveScript` seam. * @@ -65,6 +66,7 @@ function setup(): Harness { token: TOKEN, sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); return { root, sessionStore, session, handleRequest }; diff --git a/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts b/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts index 4a570db43c..de53dcc81c 100644 --- a/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts +++ b/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts @@ -1,3 +1,4 @@ +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; /** * Custom actions are read through the private-AX snapshot backend, which only * the iOS simulator has. Every other target would accept `--actions`, take an @@ -27,6 +28,7 @@ function handlerForDevice(device: DeviceInfo) { token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', }); } diff --git a/src/daemon/device-ready.ts b/src/daemon/device-ready.ts index 04411b56fb..898e25c63c 100644 --- a/src/daemon/device-ready.ts +++ b/src/daemon/device-ready.ts @@ -45,7 +45,7 @@ export async function ensureDeviceReady( } } if (device.platform === 'android') { - const { waitForAndroidBoot } = await import('../platforms/android/devices.ts'); + const { waitForAndroidBoot } = await import('../platforms/android/emulator-lifecycle.ts'); await waitForAndroidBoot(device.id); markDeviceReady(cacheKey); } diff --git a/src/daemon/handlers/__tests__/session-capabilities.test.ts b/src/daemon/handlers/__tests__/session-capabilities.test.ts index e37d55fc1d..21175bb9e4 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.test.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import os from 'node:os'; import { PUBLIC_COMMANDS } from '../../../command-catalog.ts'; import { makeAndroidSession, makeSessionStore } from '../../../__tests__/test-utils/index.ts'; -import { withTargetDeviceResolutionScope } from '../../../core/dispatch-resolve.ts'; +import { withTestDeviceInventoryProvider as withTargetDeviceResolutionScope } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { handleSessionCommands } from '../session.ts'; diff --git a/src/daemon/handlers/__tests__/session-devices-batch-runtime.test.ts b/src/daemon/handlers/__tests__/session-devices-batch-runtime.test.ts index ea9f84c830..924dc6ff6e 100644 --- a/src/daemon/handlers/__tests__/session-devices-batch-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-devices-batch-runtime.test.ts @@ -6,8 +6,6 @@ import { retainMaterializedPaths } from '../../materialized-path-registry.ts'; import { mockClearRuntimeHints, mockCleanupRetainedMaterializedPaths, - mockListAndroidDevices, - mockListAppleDevices, makeSessionStore, makeSession, noopInvoke, @@ -15,10 +13,12 @@ import { import type { DaemonRequest } from '../../types.ts'; import { handleSessionCommands } from '../session.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { withTestDeviceInventory } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; test('devices filters Apple-family platform selectors', async () => { const sessionStore = makeSessionStore(); - mockListAndroidDevices.mockResolvedValue([ + const inventory: DeviceInfo[] = [ { platform: 'android' as const, id: 'emulator-5554', @@ -27,8 +27,6 @@ test('devices filters Apple-family platform selectors', async () => { target: 'mobile' as const, booted: true, }, - ]); - mockListAppleDevices.mockResolvedValue([ { platform: 'apple' as const, id: 'sim-1', @@ -46,21 +44,25 @@ test('devices filters Apple-family platform selectors', async () => { target: 'desktop' as const, booted: true, }, - ]); + ]; const runDevices = async (flags: DaemonRequest['flags']) => - handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'devices', - positionals: [], - flags, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); + await withTestDeviceInventory( + { local: async () => inventory }, + async () => + await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'devices', + positionals: [], + flags, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ); const macosResponse = await runDevices({ platform: 'macos' }); expect(macosResponse?.ok).toBeTruthy(); @@ -86,8 +88,7 @@ test('devices filters Apple-family platform selectors', async () => { test('devices surfaces appleOs additively while keeping platform the public leaf', async () => { const sessionStore = makeSessionStore(); - mockListAndroidDevices.mockResolvedValue([]); - mockListAppleDevices.mockResolvedValue([ + const inventory = [ { platform: 'apple' as const, id: 'sim-1', @@ -98,21 +99,25 @@ test('devices surfaces appleOs additively while keeping platform the public leaf booted: true, simulatorSetPath: '/tmp/agent-device-sim-set', }, - ]); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'devices', - positionals: [], - flags: { platform: 'ios' }, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); + ]; + + const response = await withTestDeviceInventory( + { local: async () => inventory }, + async () => + await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'devices', + positionals: [], + flags: { platform: 'ios' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }), + ); expect(response?.ok).toBeTruthy(); if (response?.ok) { diff --git a/src/daemon/handlers/__tests__/session-doctor-device.test.ts b/src/daemon/handlers/__tests__/session-doctor-device.test.ts index b343b44ccc..bff3f58fc7 100644 --- a/src/daemon/handlers/__tests__/session-doctor-device.test.ts +++ b/src/daemon/handlers/__tests__/session-doctor-device.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { withDeviceInventoryProvider } from '../../../core/dispatch-resolve.ts'; +import { withTestDeviceInventoryProvider as withDeviceInventoryProvider } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { attachAdbFailureHint } from '../../../platforms/android/adb-executor.ts'; diff --git a/src/daemon/handlers/__tests__/session-doctor-toolchain.test.ts b/src/daemon/handlers/__tests__/session-doctor-toolchain.test.ts index 28baf6e762..581d52ff2c 100644 --- a/src/daemon/handlers/__tests__/session-doctor-toolchain.test.ts +++ b/src/daemon/handlers/__tests__/session-doctor-toolchain.test.ts @@ -4,6 +4,9 @@ import type { VegaToolProvider } from '../../../platforms/vega/tool-provider.ts' import { withVegaToolProvider } from '../../../platforms/vega/tool-provider.ts'; import { appendToolchainChecks } from '../session-doctor-toolchain.ts'; import type { DoctorCheck } from '@agent-device/contracts/observability'; +import { withTestDeviceInventory } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; vi.mock('../../../utils/exec.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -35,12 +38,16 @@ test('HarmonyOS doctor reports the HDC version', async () => { }); test('Vega doctor reports CLI version and connected-device readiness through semantic provider', async () => { - const provider = makeVegaProvider('VirtualDevice : tv - aarch64 - VegaOS\n'); - const checks = await withVegaToolProvider(provider, async () => { - const result: DoctorCheck[] = []; - await appendToolchainChecks(result, 'vega'); - return result; - }); + const provider = makeVegaProvider(); + const checks = await withTestDeviceInventory( + { local: async () => [VEGA_VVD] }, + async () => + await withVegaToolProvider(provider, async () => { + const result: DoctorCheck[] = []; + await appendToolchainChecks(result, 'vega'); + return result; + }), + ); assert.deepEqual(checks, [ { @@ -50,29 +57,131 @@ test('Vega doctor reports CLI version and connected-device readiness through sem hint: undefined, evidence: { vegaVersion: 'Vega CLI 1.3.2', - deviceList: 'VirtualDevice : tv - aarch64 - VegaOS', + deviceList: 'VirtualDevice', }, }, ]); }); test('Vega doctor does not report an unvalidated physical TV as supported readiness', async () => { - const provider = makeVegaProvider('G071R20720350DT6 : A1ZZ32RVTQ796E\n'); + const provider = makeVegaProvider(); + const checks = await withTestDeviceInventory( + { + local: async () => + Promise.reject( + new AppError( + 'DEVICE_NOT_FOUND', + 'Vega CLI found devices, but no supported Vega Virtual Device is running.', + { listedSerials: ['G071R20720350DT6'] }, + ), + ), + }, + async () => + await withVegaToolProvider(provider, async () => { + const result: DoctorCheck[] = []; + await appendToolchainChecks(result, 'vega'); + return result; + }), + ); + + assert.equal(checks[0]?.summary, 'Vega toolchain: Vega CLI 1.3.2; no running VVD.'); + assert.equal(checks[0]?.hint, 'Start the Vega Virtual Device and retry doctor.'); + assert.deepEqual(checks[0]?.evidence, { + vegaVersion: 'Vega CLI 1.3.2', + deviceList: 'G071R20720350DT6', + }); +}); + +test('Vega doctor propagates canonical request cancellation', async () => { + const provider = makeVegaProvider(); + + await assert.rejects( + withTestDeviceInventory( + { + local: async () => Promise.reject(new AppError('COMMAND_FAILED', 'request canceled')), + }, + async () => + await withVegaToolProvider(provider, async () => { + await appendToolchainChecks([], 'vega'); + }), + ), + (error: unknown) => error instanceof AppError && error.message === 'request canceled', + ); +}); + +test('Vega doctor propagates missing request inventory context', async () => { + const provider = makeVegaProvider(); + await assert.rejects( + withVegaToolProvider(provider, async () => { + await appendToolchainChecks([], 'vega'); + }), + (error: unknown) => + error instanceof AppError && error.details?.reason === 'device_inventory_context_unavailable', + ); +}); + +test('Vega doctor preserves the missing-tool diagnostic without probing inventory', async () => { + const provider: VegaToolProvider = { + ...makeVegaProvider(), + isAvailable: async () => false, + }; const checks = await withVegaToolProvider(provider, async () => { const result: DoctorCheck[] = []; await appendToolchainChecks(result, 'vega'); return result; }); - assert.equal(checks[0]?.summary, 'Vega toolchain: Vega CLI 1.3.2; no running VVD.'); - assert.equal(checks[0]?.hint, 'Start the Vega Virtual Device and retry doctor.'); + assert.deepEqual(checks, [ + { + id: 'toolchain', + status: 'info', + summary: 'Vega toolchain: Vega CLI not found.', + hint: 'Install Vega Developer Tools or ensure ~/vega/bin/vega is executable.', + command: 'vega --version', + }, + ]); +}); + +test('Vega doctor keeps version failures diagnostic when neutral inventory fails', async () => { + const provider: VegaToolProvider = { + ...makeVegaProvider(), + version: async () => ({ exitCode: 1, stdout: '', stderr: 'version failed' }), + }; + const checks = await withTestDeviceInventory( + { local: async () => Promise.reject(new Error('inventory failed')) }, + async () => + await withVegaToolProvider(provider, async () => { + const result: DoctorCheck[] = []; + await appendToolchainChecks(result, 'vega'); + return result; + }), + ); + + assert.deepEqual(checks, [ + { + id: 'toolchain', + status: 'info', + summary: 'Vega toolchain: CLI found but version check failed.', + hint: 'Start the Vega Virtual Device and retry doctor.', + evidence: { vegaVersion: null, deviceList: null }, + }, + ]); }); -function makeVegaProvider(deviceList: string): VegaToolProvider { +const VEGA_VVD: DeviceInfo = { + platform: 'vega', + id: 'VirtualDevice', + name: 'Vega Virtual Device (VirtualDevice)', + kind: 'emulator', + target: 'tv', + booted: true, +}; + +function makeVegaProvider(): VegaToolProvider { return { isAvailable: async () => true, version: async () => ({ exitCode: 0, stdout: 'Vega CLI 1.3.2\n', stderr: '' }), - listDevices: async () => ({ exitCode: 0, stdout: deviceList, stderr: '' }), + listDevices: unexpected, checkConnected: unexpected, launchApp: unexpected, terminateApp: unexpected, diff --git a/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts b/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts index ab01a1c13a..b1b1e9d6e9 100644 --- a/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts +++ b/src/daemon/handlers/__tests__/session-inventory-appleos.test.ts @@ -3,13 +3,13 @@ import { test, expect, vi, beforeEach } from 'vitest'; // The `devices` handler resolves its inventory through listDeviceInventory; mocking it // lets us drive the additive `appleOs` projection off the shared device fixtures without // touching real local discovery. -vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../core/device-inventory-context.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, listDeviceInventory: vi.fn(async () => []) }; }); import { handleSessionInventoryCommands } from '../session-inventory.ts'; -import { listDeviceInventory } from '../../../core/dispatch-resolve.ts'; +import { listDeviceInventory } from '../../../core/device-inventory-context.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import type { DaemonRequest, DaemonResponse } from '../../types.ts'; import type { AppleOS, DeviceInfo } from '@agent-device/kernel/device'; diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 4e64950c59..9d7351ead1 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -2,6 +2,7 @@ import { test, expect } from 'vitest'; import { handleSessionStateCommands } from '../session-state.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { withTestDeviceInventory } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; test('boot rejects --headless outside Android directly', async () => { const response = await handleSessionStateCommands({ @@ -48,18 +49,33 @@ test('appstate returns missing-session error for explicit session flag', async ( }); test('appstate rejects web before Android app-state backend dispatch', async () => { - const response = await handleSessionStateCommands({ - req: { - token: 't', - session: 'default', - command: 'appstate', - positionals: [], - flags: { platform: 'web' }, + const response = await withTestDeviceInventory( + { + local: async () => [ + { + platform: 'web', + id: 'agent-browser-chrome', + name: 'Agent Browser Chrome', + kind: 'device', + target: 'desktop', + booted: true, + }, + ], }, - sessionName: 'default', - logPath: '/tmp/daemon.log', - sessionStore: makeSessionStore('agent-device-session-state-'), - }); + async () => + await handleSessionStateCommands({ + req: { + token: 't', + session: 'default', + command: 'appstate', + positionals: [], + flags: { platform: 'web' }, + }, + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore: makeSessionStore('agent-device-session-state-'), + }), + ); expect(response).toBeTruthy(); expect(response?.ok).toBe(false); diff --git a/src/daemon/handlers/__tests__/session-test-harness.ts b/src/daemon/handlers/__tests__/session-test-harness.ts index a601c3eccd..d0b55a76f0 100644 --- a/src/daemon/handlers/__tests__/session-test-harness.ts +++ b/src/daemon/handlers/__tests__/session-test-harness.ts @@ -58,18 +58,14 @@ vi.mock('../../materialized-path-registry.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, cleanupRetainedMaterializedPathsForSession: vi.fn(async () => {}) }; }); -vi.mock('../../../platforms/android/devices.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../../../platforms/android/emulator-lifecycle.ts', async (importOriginal) => { + const actual = + await importOriginal(); return { ...actual, - listAndroidDevices: vi.fn(async () => []), ensureAndroidEmulatorBooted: vi.fn(), }; }); -vi.mock('../../../platforms/apple/core/devices.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, listAppleDevices: vi.fn(async () => []) }; -}); vi.mock('../../../platforms/apple/core/apps.ts', async (importOriginal) => { const actual = await importOriginal(); return { @@ -117,11 +113,7 @@ import { settleIosSimulator } from '../session-device-utils.ts'; import { resolveAndroidPackageForOpen } from '../session-open-target.ts'; import { runCmd } from '../../../utils/exec.ts'; import { shutdownSimulator } from '../../../platforms/apple/core/simulator.ts'; -import { - listAndroidDevices, - ensureAndroidEmulatorBooted, -} from '../../../platforms/android/devices.ts'; -import { listAppleDevices } from '../../../platforms/apple/core/devices.ts'; +import { ensureAndroidEmulatorBooted } from '../../../platforms/android/emulator-lifecycle.ts'; import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId, @@ -148,8 +140,6 @@ export const mockCleanupRetainedMaterializedPaths = vi.mocked( ); export const mockRunCmd = vi.mocked(runCmd); export const mockShutdownSimulator = vi.mocked(shutdownSimulator); -export const mockListAndroidDevices = vi.mocked(listAndroidDevices); -export const mockListAppleDevices = vi.mocked(listAppleDevices); export const mockResolveIosApp = vi.mocked(resolveIosApp); export const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked( resolveIosSimulatorDeepLinkBundleId, @@ -199,10 +189,6 @@ beforeEach(() => { mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); mockShutdownSimulator.mockReset(); mockShutdownSimulator.mockResolvedValue({ success: true, exitCode: 0, stdout: '', stderr: '' }); - mockListAndroidDevices.mockReset(); - mockListAndroidDevices.mockResolvedValue([]); - mockListAppleDevices.mockReset(); - mockListAppleDevices.mockResolvedValue([]); mockResolveIosApp.mockReset(); mockResolveIosApp.mockImplementation(async (device, app) => { const normalizedApp = app.toLowerCase(); diff --git a/src/daemon/handlers/__tests__/session-test-reporter-values.test.ts b/src/daemon/handlers/__tests__/session-test-reporter-values.test.ts index bae71d587d..4b9ea3a3a3 100644 --- a/src/daemon/handlers/__tests__/session-test-reporter-values.test.ts +++ b/src/daemon/handlers/__tests__/session-test-reporter-values.test.ts @@ -31,7 +31,7 @@ import { handleSessionCommands } from '../session.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../types.ts'; import { withRequestProgressSink } from '../../../request/progress.ts'; -import { withDeviceInventoryProvider } from '../../../core/dispatch-resolve.ts'; +import { withTestDeviceInventoryProvider as withDeviceInventoryProvider } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { getReplayTestReporterExitCode, diff --git a/src/daemon/handlers/__tests__/session-test-suite.test.ts b/src/daemon/handlers/__tests__/session-test-suite.test.ts index 6d4043fbef..c90719e108 100644 --- a/src/daemon/handlers/__tests__/session-test-suite.test.ts +++ b/src/daemon/handlers/__tests__/session-test-suite.test.ts @@ -31,7 +31,7 @@ import { markRequestCanceled, registerRequestAbort, } from '../../../request/cancel.ts'; -import { withDeviceInventoryProvider } from '../../../core/dispatch-resolve.ts'; +import { withTestDeviceInventoryProvider as withDeviceInventoryProvider } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { makeAndroidSession } from '../../../__tests__/test-utils/index.ts'; diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts index 3d67f35796..7cc29d4359 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -1,7 +1,5 @@ -import { - buildDeviceInventoryRequestFromFlags, - listDeviceInventory, -} from '../../core/dispatch-resolve.ts'; +import { buildDeviceInventoryRequestFromFlags } from '../../core/dispatch-resolve.ts'; +import { listDeviceInventory } from '../../core/device-inventory-context.ts'; import { countDeviceInventoryByGroup, LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS, diff --git a/src/daemon/handlers/session-doctor-toolchain.ts b/src/daemon/handlers/session-doctor-toolchain.ts index 2f1574a943..4251b89efd 100644 --- a/src/daemon/handlers/session-doctor-toolchain.ts +++ b/src/daemon/handlers/session-doctor-toolchain.ts @@ -1,9 +1,14 @@ import { access } from 'node:fs/promises'; import path from 'node:path'; -import type { PlatformSelector } from '@agent-device/kernel/device'; +import type { DeviceInfo, PlatformSelector } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; import { runCmd } from '../../utils/exec.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; import type { DoctorCheck } from '@agent-device/contracts/observability'; +import { + listLocalDeviceInventory, + shouldPropagateDeviceInventoryProbeError, +} from '../../core/device-inventory-context.ts'; const TOOLCHAIN_TIMEOUT_MS = 3_000; type AndroidLicenseState = 'accepted' | 'missing' | 'unknown'; @@ -16,6 +21,10 @@ type AppleToolchainProbe = { selectedPath: string | undefined; versionLine: string | undefined; }; +type VegaInventoryProbe = Readonly<{ + devices: readonly DeviceInfo[]; + listedSerials: readonly string[]; +}>; export async function appendToolchainChecks( checks: DoctorCheck[], @@ -59,7 +68,6 @@ async function harmonyToolchainCheck(): Promise { } async function vegaToolchainCheck(): Promise { - const { parseVegaDeviceList } = await import('../../platforms/vega/devices.ts'); const { resolveVegaToolProvider } = await import('../../platforms/vega/tool-provider.ts'); const provider = resolveVegaToolProvider(); if (!(await provider.isAvailable())) { @@ -76,13 +84,15 @@ async function vegaToolchainCheck(): Promise { allowFailure: true, timeoutMs: TOOLCHAIN_TIMEOUT_MS, }); - const inventory = await provider.listDevices({ - allowFailure: true, - timeoutMs: TOOLCHAIN_TIMEOUT_MS, - }); + const inventory = await readLocalVegaInventory(); const versionLine = firstOutputLine(version.stdout); - const hasRunningVvd = - inventory.exitCode === 0 && parseVegaDeviceList(inventory.stdout).length > 0; + const hasRunningVvd = inventory.devices.some( + (device) => + device.platform === 'vega' && + device.kind === 'emulator' && + device.target === 'tv' && + device.booted === true, + ); return { id: 'toolchain', @@ -93,11 +103,41 @@ async function vegaToolchainCheck(): Promise { hint: hasRunningVvd ? undefined : 'Start the Vega Virtual Device and retry doctor.', evidence: { vegaVersion: versionLine ?? null, - deviceList: inventory.stdout.trim() || null, + deviceList: vegaInventoryEvidence(inventory), }, }; } +async function readLocalVegaInventory(): Promise { + try { + return { + devices: await listLocalDeviceInventory({ platform: 'vega', target: 'tv' }), + listedSerials: [], + }; + } catch (error) { + if (shouldPropagateDeviceInventoryProbeError(error)) throw error; + return { devices: [], listedSerials: listedVegaSerials(error) }; + } +} + +function listedVegaSerials(error: unknown): readonly string[] { + if (!(error instanceof AppError) || error.code !== 'DEVICE_NOT_FOUND') return []; + const listed = error.details?.listedSerials; + return isStringArray(listed) ? listed : []; +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); +} + +function vegaInventoryEvidence(inventory: VegaInventoryProbe): string | null { + const serials = + inventory.devices.length > 0 + ? inventory.devices.map((device) => device.id) + : inventory.listedSerials; + return serials.join(', ') || null; +} + async function androidToolchainCheck(): Promise { const sdkRoot = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; const license = await androidLicenseState(sdkRoot); diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index c2f2d9dd2c..40e43f8ac9 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -1,5 +1,5 @@ import { isCommandSupportedOnDevice, listCapabilityCommands } from '../../core/capabilities.ts'; -import { listDeviceInventory } from '../../core/dispatch-resolve.ts'; +import { listDeviceInventory } from '../../core/device-inventory-context.ts'; import { assertResolvedAppsFilter } from '@agent-device/contracts/device'; import { asAppError } from '@agent-device/kernel/errors'; import { diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 5bcc5246e9..a6a0fa0f4a 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -11,7 +11,9 @@ import { SessionStore } from '../session-store.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { shutdownDeviceTarget } from '../target-shutdown.ts'; import { createAppleRunnerCachePrewarmOnColdBoot } from '../apple-runner-options.ts'; +import { listLocalDeviceInventory } from '../../core/device-inventory-context.ts'; import { prewarmAppleRunnerCache } from '../../platforms/apple/core/runner/runner-client.ts'; +import { resolveAndroidSerialAllowlist } from '../../utils/device-isolation.ts'; import { hasExplicitSessionFlag, requireSessionOrExplicitSelector, @@ -25,9 +27,14 @@ async function ensureAndroidEmulatorBoot(params: { avdName: string; serial?: string; headless?: boolean; + androidSerialAllowlist?: readonly string[]; }): Promise { - const { ensureAndroidEmulatorBooted } = await import('../../platforms/android/devices.ts'); - return await ensureAndroidEmulatorBooted(params); + const { ensureAndroidEmulatorBooted } = + await import('../../platforms/android/emulator-lifecycle.ts'); + return await ensureAndroidEmulatorBooted(params, { + discoverLocal: listLocalDeviceInventory, + androidSerialAllowlist: params.androidSerialAllowlist, + }); } const IOS_APPSTATE_SESSION_REQUIRED_MESSAGE = @@ -170,6 +177,12 @@ export async function handleSessionStateCommands(params: { const normalizedPlatform = flags.platform ?? session?.device.platform; const targetsAndroid = normalizedPlatform === 'android'; + const resolvedAndroidSerialAllowlist = resolveAndroidSerialAllowlist( + flags.androidDeviceAllowlist, + ); + const androidSerialAllowlist = resolvedAndroidSerialAllowlist + ? [...resolvedAndroidSerialAllowlist].sort() + : undefined; const wantsAndroidHeadless = flags.headless === true; if (wantsAndroidHeadless && !targetsAndroid) { return errorResponse( @@ -217,6 +230,7 @@ export async function handleSessionStateCommands(params: { avdName: fallbackAvdName, serial: flags.serial, headless: wantsAndroidHeadless, + androidSerialAllowlist, }); launchedAndroidEmulator = true; } @@ -251,6 +265,7 @@ export async function handleSessionStateCommands(params: { avdName, serial: flags.serial, headless: true, + androidSerialAllowlist, }); } await ensureDeviceReady(device); @@ -263,6 +278,7 @@ export async function handleSessionStateCommands(params: { avdName: device.name, serial: flags.serial, headless: false, + androidSerialAllowlist, }); await ensureDeviceReady(device); } else { diff --git a/src/daemon/handlers/session-test-shard-devices.ts b/src/daemon/handlers/session-test-shard-devices.ts index c536869788..ff1c97ac34 100644 --- a/src/daemon/handlers/session-test-shard-devices.ts +++ b/src/daemon/handlers/session-test-shard-devices.ts @@ -1,5 +1,6 @@ import type { CommandFlags } from '@agent-device/contracts/command'; -import { listDeviceInventory, type DeviceInventoryRequest } from '../../core/dispatch-resolve.ts'; +import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; +import { listDeviceInventory } from '../../core/device-inventory-context.ts'; import { resolveAndroidSerialAllowlist, resolveIosSimulatorDeviceSetPath, diff --git a/src/daemon/ios-app-session-hint.test.ts b/src/daemon/ios-app-session-hint.test.ts index b5110c6e27..d45923053e 100644 --- a/src/daemon/ios-app-session-hint.test.ts +++ b/src/daemon/ios-app-session-hint.test.ts @@ -1,9 +1,13 @@ +import { AppError } from '@agent-device/kernel/errors'; import { beforeEach, expect, test, vi } from 'vitest'; const listBootedIosSimulators = vi.hoisted(() => vi.fn()); const detectSoleRunningIosSimulatorApp = vi.hoisted(() => vi.fn()); -vi.mock('../platforms/apple/core/devices.ts', () => ({ listBootedIosSimulators })); +vi.mock('../core/device-inventory-context.ts', async (importOriginal) => ({ + ...(await importOriginal()), + listLocalDeviceInventory: listBootedIosSimulators, +})); vi.mock('../platforms/apple/core/app-resolution.ts', () => ({ detectSoleRunningIosSimulatorApp })); import { IOS_DEVICE, IOS_SIMULATOR } from '../__tests__/test-utils/index.ts'; @@ -86,6 +90,34 @@ test('a rejecting foreground-app probe is caught, not propagated', async () => { await expect(buildIosOpenCommandHint(IOS_SIMULATOR)).resolves.toBeUndefined(); }); +test('request cancellation from the inventory probe is propagated', async () => { + const canceled = new AppError('COMMAND_FAILED', 'request canceled', { + reason: 'request_canceled', + }); + listBootedIosSimulators.mockRejectedValue(canceled); + + await expect(buildIosOpenCommandHint(IOS_SIMULATOR)).rejects.toBe(canceled); +}); + +test('abort control flow from the foreground-app probe is propagated', async () => { + const canceled = new DOMException('This operation was aborted', 'AbortError'); + listBootedIosSimulators.mockResolvedValue([{ ...IOS_SIMULATOR, id: 'booted-1' }]); + detectSoleRunningIosSimulatorApp.mockRejectedValue(canceled); + + await expect(buildIosOpenCommandHint(IOS_SIMULATOR)).rejects.toBe(canceled); +}); + +test('missing inventory-context wiring is propagated', async () => { + const unavailable = new AppError( + 'COMMAND_FAILED', + 'Device inventory gateway is unavailable outside request execution', + { reason: 'device_inventory_context_unavailable' }, + ); + listBootedIosSimulators.mockRejectedValue(unavailable); + + await expect(buildIosOpenCommandHint(IOS_SIMULATOR)).rejects.toBe(unavailable); +}); + test('more than one booted simulator stays ambiguous and returns no hint', async () => { listBootedIosSimulators.mockResolvedValue([ { ...IOS_SIMULATOR, id: 'booted-1' }, @@ -132,7 +164,12 @@ test('resolveSoleForegroundIosApp resolves the device and app when unambiguous', const resolved = await resolveSoleForegroundIosApp({ simulatorSetPath: '/custom/set' }); expect(resolved).toEqual({ device: soleBootedDevice, app }); - expect(listBootedIosSimulators).toHaveBeenCalledWith({ simulatorSetPath: '/custom/set' }); + expect(listBootedIosSimulators).toHaveBeenCalledWith({ + platform: 'ios', + iosSimulatorSetPath: '/custom/set', + kind: 'simulator', + booted: true, + }); expect(detectSoleRunningIosSimulatorApp).toHaveBeenCalledWith(soleBootedDevice); }); diff --git a/src/daemon/ios-app-session-hint.ts b/src/daemon/ios-app-session-hint.ts index 4b5e484d7b..48ef5d3f74 100644 --- a/src/daemon/ios-app-session-hint.ts +++ b/src/daemon/ios-app-session-hint.ts @@ -1,7 +1,10 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { detectSoleRunningIosSimulatorApp } from '../platforms/apple/core/app-resolution.ts'; import type { IosAppInfo } from '../platforms/apple/core/app-info.ts'; -import { listBootedIosSimulators } from '../platforms/apple/core/devices.ts'; +import { + listLocalDeviceInventory, + shouldPropagateDeviceInventoryProbeError, +} from '../core/device-inventory-context.ts'; import { shellQuoteIfNeeded } from '../utils/shell-quote.ts'; export type ResolvedForegroundIosApp = { @@ -17,10 +20,9 @@ export type ResolvedForegroundIosApp = { * than one running app, or a probe failure — returns `undefined`. This must * never guess: a wrong-but-confident answer is worse than failing closed. * - * Strictly best-effort: a throw here (a bounded probe still rejects on - * timeout or a spawn failure — see app-resolution.ts) is caught and treated - * as inconclusive, never propagated — callers sit on error/decision paths - * where a probe failure must not replace their deterministic outcome. + * Probe failures (including bounded timeout/spawn failures) are best-effort + * and treated as inconclusive. Cancellation and missing request-context + * wiring are control-flow/composition failures and must propagate. * * `buildIosOpenCommandHint` (error-hint enrichment) and the bare * `open --foreground` auto-discovery form both compose this single probe @@ -30,7 +32,12 @@ export async function resolveSoleForegroundIosApp( options: { simulatorSetPath?: string } = {}, ): Promise { try { - const booted = await listBootedIosSimulators({ simulatorSetPath: options.simulatorSetPath }); + const booted = await listLocalDeviceInventory({ + platform: 'ios', + iosSimulatorSetPath: options.simulatorSetPath, + kind: 'simulator', + booted: true, + }); if (booted.length !== 1) return undefined; const [soleBootedDevice] = booted; if (!soleBootedDevice) return undefined; @@ -39,7 +46,8 @@ export async function resolveSoleForegroundIosApp( if (!app) return undefined; return { device: soleBootedDevice, app }; - } catch { + } catch (error) { + if (shouldPropagateDeviceInventoryProbeError(error)) throw error; return undefined; } } @@ -48,8 +56,8 @@ export async function resolveSoleForegroundIosApp( * Enriches the generic "Run open first" SESSION_NOT_FOUND hint with the exact * runnable command, but only when the environment is unambiguous (see * `resolveSoleForegroundIosApp`, which also owns the never-guess and - * never-propagate contract). Ambiguity or a probe failure returns `undefined` - * so the caller keeps the generic hint. + * best-effort probe contract). Ambiguity or a genuine probe failure returns + * `undefined` so the caller keeps the generic hint. */ // Wire-level details are redacted before send (packages/kernel/src/redaction.ts), // which silently truncates any string field over 400 chars — a truncated diff --git a/src/daemon/platform-request-scope.ts b/src/daemon/platform-request-scope.ts new file mode 100644 index 0000000000..524168c992 --- /dev/null +++ b/src/daemon/platform-request-scope.ts @@ -0,0 +1,28 @@ +import type { + PlatformDiagnosticEvent, + PlatformProgressUpdate, + PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { getRequestSignal } from '../request/cancel.ts'; +import { emitRequestProgress } from '../request/progress.ts'; +import { emitDiagnostic } from '../utils/diagnostics.ts'; +import type { DaemonRequest } from './types.ts'; + +const processLifetimeSignal = new AbortController().signal; + +export function createPlatformRequestScope(req: DaemonRequest): PlatformRequestScope { + return Object.freeze({ + signal: getRequestSignal(req.meta?.requestId) ?? processLifetimeSignal, + diagnostics: Object.freeze({ + emit: (event: PlatformDiagnosticEvent) => emitDiagnostic(event), + }), + progress: Object.freeze({ + report: (update: PlatformProgressUpdate) => + emitRequestProgress({ + type: 'command', + status: 'progress', + message: update.message, + }), + }), + }); +} diff --git a/src/daemon/request-platform-providers.ts b/src/daemon/request-platform-providers.ts index fcdfbf6f98..c5aad27b80 100644 --- a/src/daemon/request-platform-providers.ts +++ b/src/daemon/request-platform-providers.ts @@ -7,10 +7,7 @@ import type { AppleRunnerCommandExecutor, AppleRunnerProvider, } from '../platforms/apple/core/runner/runner-provider.ts'; -import type { - AppleToolCommandExecutor, - AppleToolProvider, -} from '../platforms/apple/core/tool-provider.ts'; +import type { AppleToolProvider } from '../platforms/apple/core/tool-provider.ts'; import type { LinuxToolProvider } from '../platforms/linux/tool-provider.ts'; import type { VegaToolProvider } from '../platforms/vega/tool-provider.ts'; import { withWebProvider, type WebProvider } from '../platforms/web/provider.ts'; @@ -38,9 +35,7 @@ export type AppleRunnerProviderResolver = PlatformProviderResolver< AppleRunnerProvider | AppleRunnerCommandExecutor | undefined >; -export type AppleToolProviderResolver = PlatformProviderResolver< - AppleToolProvider | AppleToolCommandExecutor | undefined ->; +export type AppleToolProviderResolver = PlatformProviderResolver; export type LinuxToolProviderResolver = PlatformProviderResolver; @@ -117,7 +112,7 @@ type ResolvedRequestPlatformProviders = { requestId?: string; }; appleTool?: { - provider?: AppleToolProvider | AppleToolCommandExecutor; + provider?: AppleToolProvider; }; linuxTool?: { provider?: LinuxToolProvider; diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index c792aba8fc..4c50287dfb 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -1,8 +1,7 @@ -import { withTargetDeviceResolutionScope } from '../core/dispatch-resolve.ts'; -import type { - DeviceInventoryProvider, - LeaseLifecycleProvider, -} from '@agent-device/contracts/device'; +import { withResolveTargetDeviceCacheScope } from '../core/dispatch-resolve.ts'; +import { withDeviceInventoryContext } from '../core/device-inventory-context.ts'; +import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; +import type { ComposedDeviceInventoryGateways } from '@agent-device/contracts/platform'; import { AppError, normalizeError, @@ -54,6 +53,7 @@ import { canRunReplayScopedAction } from './daemon-command-registry.ts'; import { createAgentBrowserWebProvider } from '../platforms/web/agent-browser-provider.ts'; import { openWebSessionNames } from './web-session-names.ts'; import { inferFillText } from './action-utils.ts'; +import { createPlatformRequestScope } from './platform-request-scope.ts'; // --------------------------------------------------------------------------- // Request handler API @@ -73,7 +73,7 @@ export type RequestRouterDeps = { webProvider?: WebProviderResolver; appLogProvider?: AppLogProviderResolver; recordingProvider?: RecordingProviderResolver; - deviceInventoryProvider?: DeviceInventoryProvider; + deviceInventoryGateways: ComposedDeviceInventoryGateways; providerRuntimeIds?: readonly string[]; providerRuntimeRequiredIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; @@ -100,7 +100,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { webProvider, appLogProvider, recordingProvider, - deviceInventoryProvider, + deviceInventoryGateways, providerRuntimeIds, providerRuntimeRequiredIds, leaseLifecycleProvider, @@ -158,14 +158,21 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { let scope: RequestExecutionScope | undefined; try { - return await withTargetDeviceResolutionScope(deviceInventoryProvider, async () => { - scope = await createRequestExecutionScope({ - req, - sessionStore, - leaseRegistry, - }); - return await executeRequestScope(scope); - }); + return await withDeviceInventoryContext( + { + ...deviceInventoryGateways, + requestScope: createPlatformRequestScope(req), + }, + async () => + await withResolveTargetDeviceCacheScope(async () => { + scope = await createRequestExecutionScope({ + req, + sessionStore, + leaseRegistry, + }); + return await executeRequestScope(scope); + }), + ); } catch (error) { const response = finalizeThrownRequestError(error); recordThrownRequestEvent(sessionStore, scope, response); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index d25efefa87..a2191db5dd 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -6,6 +6,7 @@ import { resolveDaemonPaths, resolveDaemonServerMode } from '../config.ts'; import { createDaemonHttpServer } from './http-server.ts'; import { trackDownloadableArtifact } from '../artifact-tracking.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../provider-device-runtime.ts'; +import { createPlatformDeviceInventoryGateways } from '../../platform-runtime.ts'; import { createDefaultProviderDeviceRuntimes, DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS, @@ -182,6 +183,9 @@ export async function startDaemonRuntime( }, }); const cloudArtifactProvider = providerRuntimeProviders.cloudArtifactProvider; + const deviceInventoryGateways = createPlatformDeviceInventoryGateways( + providerRuntimeProviders.deviceInventorySource, + ); const dispatchRequest = createRequestHandler({ logPath, @@ -191,7 +195,7 @@ export async function startDaemonRuntime( leaseRegistry, leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, cloudArtifactProvider, - deviceInventoryProvider: providerRuntimeProviders.deviceInventoryProvider, + deviceInventoryGateways, appleRunnerProvider: providerRuntimeProviders.appleRunnerProvider, providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds, providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds, diff --git a/src/platform-runtime-apple-tool-host.test.ts b/src/platform-runtime-apple-tool-host.test.ts new file mode 100644 index 0000000000..64290622d3 --- /dev/null +++ b/src/platform-runtime-apple-tool-host.test.ts @@ -0,0 +1,88 @@ +import { expect, test, vi } from 'vitest'; +import { + createLocalAppleToolProvider, + type AppleToolProvider, + withAppleToolProvider, +} from './platforms/apple/core/tool-provider.ts'; +import { createAppleToolHost } from './platform-runtime-apple-tool-host.ts'; + +test('Apple tool host uses a full scoped provider when local xcrun is unavailable', async () => { + const whichCommand = vi.fn(async () => true); + const run = vi.fn(async () => ({ stdout: 'devices', stderr: '', exitCode: 0 })); + const provider: AppleToolProvider = { + runCommand: async () => { + throw new Error('local command fallback is unavailable'); + }, + whichCommand, + devicectl: { run }, + }; + const host = createAppleToolHost(); + const signal = new AbortController().signal; + + await withAppleToolProvider(provider, async () => { + await expect(host.isXcrunAvailable(signal)).resolves.toBe(true); + await expect( + host.run( + { tool: 'devicectl', args: ['list', 'devices'], allowFailure: true, timeoutMs: 42 }, + signal, + ), + ).resolves.toEqual({ stdout: 'devices', stderr: '', exitCode: 0 }); + }); + + expect(whichCommand).toHaveBeenCalledWith('xcrun'); + expect(run).toHaveBeenCalledWith(['list', 'devices'], { + allowFailure: true, + signal, + timeoutMs: 42, + }); +}); + +test('Apple tool host rejects pre-aborted requests before invoking the provider', async () => { + const reason = new Error('cancelled'); + const controller = new AbortController(); + controller.abort(reason); + const runCommand = vi.fn(); + const provider = createLocalAppleToolProvider({ runCommand }); + const host = createAppleToolHost(); + + await withAppleToolProvider(provider, async () => { + await expect( + host.run({ tool: 'xctrace', args: ['list', 'devices'] }, controller.signal), + ).rejects.toBe(reason); + }); + expect(runCommand).not.toHaveBeenCalled(); +}); + +test('Apple tool host preserves the exact abort reason when the provider rejects differently', async () => { + const reason = new Error('cancelled'); + const transportError = new Error('provider transport failed'); + const controller = new AbortController(); + const runCommand = vi.fn(async () => { + controller.abort(reason); + throw transportError; + }); + const provider = createLocalAppleToolProvider({ runCommand }); + const host = createAppleToolHost(); + + await withAppleToolProvider(provider, async () => { + await expect( + host.run({ tool: 'xctrace', args: ['list', 'devices'] }, controller.signal), + ).rejects.toBe(reason); + }); +}); + +test('Apple tool availability preserves the exact abort reason when lookup rejects differently', async () => { + const reason = new Error('cancelled'); + const transportError = new Error('provider lookup failed'); + const controller = new AbortController(); + const whichCommand = vi.fn(async () => { + controller.abort(reason); + throw transportError; + }); + const provider = createLocalAppleToolProvider({ whichCommand }); + const host = createAppleToolHost(); + + await withAppleToolProvider(provider, async () => { + await expect(host.isXcrunAvailable(controller.signal)).rejects.toBe(reason); + }); +}); diff --git a/src/platform-runtime-apple-tool-host.ts b/src/platform-runtime-apple-tool-host.ts new file mode 100644 index 0000000000..70b5b08eb9 --- /dev/null +++ b/src/platform-runtime-apple-tool-host.ts @@ -0,0 +1,52 @@ +import type { AppleToolHost } from '@agent-device/contracts/platform'; + +export function createAppleToolHost(): AppleToolHost { + return Object.freeze({ + isXcrunAvailable: async (signal?: AbortSignal) => { + const { resolveAppleToolProvider } = await awaitPreservingAbortReason( + async () => await import('./platforms/apple/core/tool-provider.ts'), + signal, + ); + const available = await awaitPreservingAbortReason( + async () => await resolveAppleToolProvider().whichCommand('xcrun'), + signal, + ); + return available; + }, + run: async (request, signal) => { + const { runXcrun } = await awaitPreservingAbortReason( + async () => await import('./platforms/apple/core/tool-provider.ts'), + signal, + ); + const result = await awaitPreservingAbortReason( + async () => + await runXcrun([request.tool, ...request.args], { + allowFailure: request.allowFailure, + signal, + timeoutMs: request.timeoutMs, + }), + signal, + ); + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }; + }, + }); +} + +async function awaitPreservingAbortReason( + operation: () => Promise, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + try { + const result = await operation(); + signal?.throwIfAborted(); + return result; + } catch (error) { + signal?.throwIfAborted(); + throw error; + } +} diff --git a/src/platform-runtime-device-inventory.test.ts b/src/platform-runtime-device-inventory.test.ts new file mode 100644 index 0000000000..e0dfaa5765 --- /dev/null +++ b/src/platform-runtime-device-inventory.test.ts @@ -0,0 +1,283 @@ +import type { + DeviceInventoryRequest, + ProviderDeviceInventorySource, +} from '@agent-device/contracts/device'; +import { + createPlatformModuleRegistry, + type DeviceInventoryHost, + type DeviceInventorySource, + type InventoryPlatformModule, + type PlatformRequestScope, +} from '@agent-device/contracts/platform'; +import { PLATFORMS, type DeviceInfo, type Platform } from '@agent-device/kernel/device'; +import { describe, expect, test, vi } from 'vitest'; +import { createComposedDeviceInventoryGateways } from './platform-runtime-device-inventory.ts'; + +const scope: PlatformRequestScope = Object.freeze({ + signal: new AbortController().signal, + diagnostics: Object.freeze({ emit: () => {} }), + progress: Object.freeze({ report: () => {} }), +}); + +const host: DeviceInventoryHost = Object.freeze({ + commands: Object.freeze({ + which: async () => undefined, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }), + appleTools: Object.freeze({ + isXcrunAvailable: async () => false, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }), + toolchains: Object.freeze({ prepare: async () => {} }), + files: Object.freeze({ + isExecutable: async () => false, + createTemporaryTextFile: async () => { + throw new Error('unused'); + }, + }), + hostOs: 'darwin', + hostName: 'test-host', + homeDirectory: '/tmp/test-home', + observations: Object.freeze({ deviceBooted: async () => {} }), +}); + +describe('composed device inventory gateway', () => { + test('an authoritative provider inventory, including empty, loads no local host or source', async () => { + for (const devices of [[], [device('android', 'remote')]]) { + const local = inventoryWorld(); + const provider: ProviderDeviceInventorySource = { + discover: async () => ({ kind: 'inventory', devices }), + }; + const gateways = createComposedDeviceInventoryGateways({ + registry: local.registry, + loadHost: local.loadHost, + provider, + }); + + expect(await gateways.providerFirst.discover({ platform: 'android' }, scope)).toEqual( + devices, + ); + expect(local.hostLoads()).toBe(0); + expect(local.sourceLoads()).toEqual([]); + } + }); + + test('declined providers fall through, while malformed outcomes fail closed', async () => { + const local = inventoryWorld(); + const declined = createComposedDeviceInventoryGateways({ + registry: local.registry, + loadHost: local.loadHost, + provider: { discover: async () => ({ kind: 'declined' }) }, + }); + expect(await declined.providerFirst.discover({ platform: 'android' }, scope)).toEqual([ + device('android'), + ]); + + const malformed = createComposedDeviceInventoryGateways({ + registry: local.registry, + loadHost: local.loadHost, + provider: { discover: async () => undefined } as unknown as ProviderDeviceInventorySource, + }); + await expect( + malformed.providerFirst.discover({ platform: 'android' }, scope), + ).rejects.toMatchObject({ details: { reason: 'provider_inventory_contract_violation' } }); + }); + + test('forwards the request signal into an in-flight provider source', async () => { + const controller = new AbortController(); + const requestScope = { ...scope, signal: controller.signal }; + const observed = vi.fn(); + const provider: ProviderDeviceInventorySource = { + discover: async (_request, signal) => { + observed(signal); + return await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }, + }; + const local = inventoryWorld(); + const gateways = createComposedDeviceInventoryGateways({ + registry: local.registry, + loadHost: local.loadHost, + provider, + }); + const pending = gateways.providerFirst.discover({ platform: 'android' }, requestScope); + controller.abort(new Error('cancelled')); + + await expect(pending).rejects.toThrow('cancelled'); + expect(observed).toHaveBeenCalledWith(controller.signal); + expect(local.hostLoads()).toBe(0); + }); + + test('keeps local projection policy out of the provider request', async () => { + const received: unknown[] = []; + const provider: ProviderDeviceInventorySource = { + discover: async (request) => { + received.push(request); + return { kind: 'inventory', devices: [] }; + }, + }; + const local = inventoryWorld(); + const gateways = createComposedDeviceInventoryGateways({ + registry: local.registry, + loadHost: local.loadHost, + provider, + }); + + await gateways.providerFirst.discover( + { + platform: 'ios', + kind: 'simulator', + booted: true, + iosSimulatorSetPath: '/tmp/isolated-simulators', + androidSerialAllowlist: ['emulator-5554'], + }, + scope, + ); + + expect(received).toEqual([ + { + platform: 'ios', + iosSimulatorSetPath: '/tmp/isolated-simulators', + androidSerialAllowlist: ['emulator-5554'], + }, + ]); + expect(local.hostLoads()).toBe(0); + }); + + test('applies public Apple aliases and all internal projection filters centrally', async () => { + const devices = [ + { ...device('apple', 'ios-default'), appleOs: 'ios' as const, target: undefined }, + { ...device('apple', 'mac'), appleOs: 'macos' as const, target: 'desktop' as const }, + { ...device('apple', 'shutdown'), appleOs: 'ios' as const, booted: false }, + device('android', 'other-family'), + ]; + const provider: ProviderDeviceInventorySource = { + discover: async () => ({ kind: 'inventory', devices }), + }; + const local = inventoryWorld(); + const gateways = createComposedDeviceInventoryGateways({ + registry: local.registry, + loadHost: local.loadHost, + provider, + }); + + const projected = await gateways.providerFirst.discover( + { platform: 'ios', target: 'mobile', kind: 'device', booted: true }, + scope, + ); + expect(projected.map(({ id }) => id)).toEqual(['ios-default']); + }); + + test('loads only the selected family and retries failed host and source loads', async () => { + let hostAttempts = 0; + let androidAttempts = 0; + const local = inventoryWorld({ + android: async () => { + androidAttempts += 1; + if (androidAttempts === 1) throw new Error('source load failed'); + return source([device('android')]); + }, + }); + const gateways = createComposedDeviceInventoryGateways({ + registry: local.registry, + loadHost: async () => { + hostAttempts += 1; + if (hostAttempts === 1) throw new Error('host load failed'); + return host; + }, + }); + + await expect(gateways.localOnly.discover({ platform: 'android' }, scope)).rejects.toThrow( + 'host load failed', + ); + await expect(gateways.localOnly.discover({ platform: 'android' }, scope)).rejects.toThrow( + 'source load failed', + ); + expect(await gateways.localOnly.discover({ platform: 'android' }, scope)).toEqual([ + device('android'), + ]); + expect(hostAttempts).toBe(2); + expect(androidAttempts).toBe(2); + expect(local.sourceLoads()).toEqual(['android', 'android']); + }); + + test('unfiltered local discovery is fail-soft and preserves selector order', async () => { + const local = inventoryWorld({ + harmonyos: async () => source([], new Error('hdc unavailable')), + }); + const gateways = createComposedDeviceInventoryGateways({ + registry: local.registry, + loadHost: local.loadHost, + }); + + const devices = await gateways.localOnly.discover({}, scope); + expect(devices.map(({ platform }) => platform)).toEqual(['android', 'apple', 'vega', 'linux']); + expect(local.sourceLoads()).toEqual(['android', 'harmonyos', 'apple', 'vega', 'linux']); + }); + + test('explicit local failures propagate and family sources cannot return another family', async () => { + const failing = inventoryWorld({ android: async () => source([], new Error('adb failed')) }); + await expect( + createComposedDeviceInventoryGateways({ + registry: failing.registry, + loadHost: failing.loadHost, + }).localOnly.discover({ platform: 'android' }, scope), + ).rejects.toThrow('adb failed'); + + const lying = inventoryWorld({ + android: async () => source([device('linux', 'wrong-family')]), + }); + await expect( + createComposedDeviceInventoryGateways({ + registry: lying.registry, + loadHost: lying.loadHost, + }).localOnly.discover({ platform: 'android' }, scope), + ).rejects.toMatchObject({ + details: { expectedFamily: 'android', actualFamily: 'linux' }, + }); + }); +}); + +function inventoryWorld( + overrides: Partial Promise>> = {}, +) { + const loaded: Platform[] = []; + let hosts = 0; + const modules: InventoryPlatformModule[] = PLATFORMS.map((family) => ({ + family, + loadInventory: async () => { + loaded.push(family); + return await (overrides[family]?.() ?? Promise.resolve(source([device(family)]))); + }, + })); + return { + registry: createPlatformModuleRegistry(modules), + loadHost: async () => { + hosts += 1; + return host; + }, + hostLoads: () => hosts, + sourceLoads: () => [...loaded], + }; +} + +function source(devices: readonly DeviceInfo[], failure?: Error): DeviceInventorySource { + return { + discover: async (_request: Readonly) => { + if (failure) throw failure; + return devices; + }, + }; +} + +function device(platform: Platform, id = `${platform}-device`): DeviceInfo { + return { + platform, + id, + name: id, + kind: 'device', + target: platform === 'vega' ? 'tv' : 'mobile', + booted: true, + }; +} diff --git a/src/platform-runtime-device-inventory.ts b/src/platform-runtime-device-inventory.ts new file mode 100644 index 0000000000..16ee8381f8 --- /dev/null +++ b/src/platform-runtime-device-inventory.ts @@ -0,0 +1,225 @@ +import { + filterDeviceInventoryProjection, + LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS, + projectProviderDeviceInventoryRequest, + type ProviderDeviceInventorySource, +} from '@agent-device/contracts/device'; +import { + type ComposedDeviceInventoryGateways, + type DeviceInventoryGateway, + type DeviceInventoryHost, + type DeviceInventoryHostFor, + type DeviceInventorySource, + type InventoryPlatformModule, + type PlatformModuleRegistry, + type PlatformRequestScope, + type ProviderAwareDeviceInventoryGateway, +} from '@agent-device/contracts/platform'; +import { + isApplePlatform, + type DeviceInfo, + type Platform, + type PlatformSelector, +} from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +export function createComposedDeviceInventoryGateways( + options: Readonly<{ + registry: PlatformModuleRegistry; + loadHost: () => Promise; + provider?: ProviderDeviceInventorySource; + }>, +): ComposedDeviceInventoryGateways { + const localOnly = createLocalDeviceInventoryGateway(options.registry, options.loadHost); + const provider = options.provider; + const discoverWithSource: ProviderAwareDeviceInventoryGateway['discoverWithSource'] = provider + ? async (request, scope) => { + scope.signal.throwIfAborted(); + const provided = await provider.discover( + projectProviderDeviceInventoryRequest(request), + scope.signal, + ); + scope.signal.throwIfAborted(); + assertProviderOutcome(provided); + if (provided.kind === 'inventory') { + return { + devices: filterDeviceInventoryProjection( + provided.devices.map((device) => ({ ...device })), + request, + ), + source: 'provider', + }; + } + return { devices: await localOnly.discover(request, scope), source: 'local' }; + } + : async (request, scope) => ({ + devices: await localOnly.discover(request, scope), + source: 'local', + }); + const providerFirst: ProviderAwareDeviceInventoryGateway = Object.freeze({ + discover: async (request, scope) => (await discoverWithSource(request, scope)).devices, + discoverWithSource, + }); + return Object.freeze({ providerFirst, localOnly }); +} + +function createLocalDeviceInventoryGateway( + registry: PlatformModuleRegistry, + loadHost: () => Promise, +): DeviceInventoryGateway { + let hostLoad: Promise | undefined; + const requiredHost = async (): Promise => { + hostLoad ??= loadHost(); + try { + return await hostLoad; + } catch (error) { + hostLoad = undefined; + throw error; + } + }; + const sourceLoads = new Map>(); + const loadSource = async (family: Platform): Promise => { + const existing = sourceLoads.get(family); + if (existing) return await existing; + const pending = loadFamilyInventory(registry.get(family), requiredHost); + sourceLoads.set(family, pending); + try { + return await pending; + } catch (error) { + if (sourceLoads.get(family) === pending) sourceLoads.delete(family); + throw error; + } + }; + + return Object.freeze({ + discover: async (request, scope) => { + scope.signal.throwIfAborted(); + if (request.platform) { + return await discoverFamily( + loadSource, + familyForSelector(request.platform), + request, + scope, + ); + } + + const perFamily = await Promise.all( + LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS.map(async (selector) => { + try { + const devices = await discoverFamily( + loadSource, + familyForSelector(selector), + { ...request, platform: selector }, + scope, + ); + return Array.isArray(devices) ? devices : []; + } catch { + return []; + } + }), + ); + scope.signal.throwIfAborted(); + return perFamily.flat(); + }, + }); +} + +async function loadFamilyInventory( + module: InventoryPlatformModule, + requiredHost: () => Promise, +): Promise { + switch (module.family) { + case 'apple': + return await module.loadInventory(appleInventoryHost(await requiredHost())); + case 'android': + return await module.loadInventory(androidInventoryHost(await requiredHost())); + case 'harmonyos': + return await module.loadInventory(harmonyInventoryHost(await requiredHost())); + case 'vega': + return await module.loadInventory(vegaInventoryHost(await requiredHost())); + case 'linux': + return await module.loadInventory(linuxInventoryHost(await requiredHost())); + case 'web': + return await module.loadInventory(); + } +} + +function appleInventoryHost(host: DeviceInventoryHost): DeviceInventoryHostFor<'apple'> { + return Object.freeze({ + appleTools: host.appleTools, + files: host.files, + hostName: host.hostName, + hostOs: host.hostOs, + observations: host.observations, + }); +} + +function androidInventoryHost(host: DeviceInventoryHost): DeviceInventoryHostFor<'android'> { + return Object.freeze({ + commands: host.commands, + files: host.files, + homeDirectory: host.homeDirectory, + toolchains: host.toolchains, + }); +} + +function harmonyInventoryHost(host: DeviceInventoryHost): DeviceInventoryHostFor<'harmonyos'> { + return Object.freeze({ + commands: host.commands, + files: host.files, + toolchains: host.toolchains, + }); +} + +function vegaInventoryHost(host: DeviceInventoryHost): DeviceInventoryHostFor<'vega'> { + return Object.freeze({ + commands: host.commands, + files: host.files, + homeDirectory: host.homeDirectory, + }); +} + +function linuxInventoryHost(host: DeviceInventoryHost): DeviceInventoryHostFor<'linux'> { + return Object.freeze({ hostName: host.hostName, hostOs: host.hostOs }); +} + +async function discoverFamily( + loadSource: (family: Platform) => Promise, + family: Platform, + request: Parameters[0], + scope: PlatformRequestScope, +): Promise { + const source = await loadSource(family); + scope.signal.throwIfAborted(); + const devices = await source.discover(request, scope); + scope.signal.throwIfAborted(); + assertSourceFamily(family, devices); + return filterDeviceInventoryProjection(devices, request); +} + +function familyForSelector(selector: PlatformSelector): Platform { + return isApplePlatform(selector) ? 'apple' : selector; +} + +function assertSourceFamily(family: Platform, devices: readonly DeviceInfo[]): void { + const outside = devices.find((device) => device.platform !== family); + if (!outside) return; + throw new AppError( + 'COMMAND_FAILED', + `Platform inventory source ${family} returned a ${outside.platform} device.`, + { expectedFamily: family, actualFamily: outside.platform, deviceId: outside.id }, + ); +} + +function assertProviderOutcome( + value: unknown, +): asserts value is Awaited> { + if (value !== null && typeof value === 'object') { + const outcome = value as { kind?: unknown; devices?: unknown }; + if (outcome.kind === 'declined' && !('devices' in outcome)) return; + if (outcome.kind === 'inventory' && Array.isArray(outcome.devices)) return; + } + throw new AppError('COMMAND_FAILED', 'Provider device inventory returned an invalid outcome.', { + reason: 'provider_inventory_contract_violation', + }); +} diff --git a/src/platform-runtime-host.test.ts b/src/platform-runtime-host.test.ts new file mode 100644 index 0000000000..1634c0aeb6 --- /dev/null +++ b/src/platform-runtime-host.test.ts @@ -0,0 +1,44 @@ +import { expect, test, vi } from 'vitest'; + +const toolchains = vi.hoisted(() => ({ + appleEvaluations: 0, + appleAvailabilityChecks: [] as string[], + appleRuns: [] as Array<{ args: string[]; signal?: AbortSignal }>, +})); + +vi.mock('./platforms/apple/core/tool-provider.ts', () => { + toolchains.appleEvaluations += 1; + return { + resolveAppleToolProvider: () => ({ + whichCommand: async (command: string) => { + toolchains.appleAvailabilityChecks.push(command); + return true; + }, + }), + runXcrun: async (args: string[], options: { signal?: AbortSignal }) => { + toolchains.appleRuns.push({ args, signal: options.signal }); + return { stdout: 'ok', stderr: '', exitCode: 0 }; + }, + }; +}); + +import { createDeviceInventoryHost } from './platform-runtime-host.ts'; + +test('inventory host loads scoped Apple mechanics only on first Apple tool use', async () => { + const host = createDeviceInventoryHost(); + const signal = new AbortController().signal; + + expect(toolchains.appleEvaluations).toBe(0); + await expect(host.appleTools.isXcrunAvailable(signal)).resolves.toBe(true); + expect(toolchains.appleEvaluations).toBe(1); + expect(toolchains.appleAvailabilityChecks).toEqual(['xcrun']); + + await expect( + host.appleTools.run( + { tool: 'devicectl', args: ['list', 'devices'], allowFailure: true, timeoutMs: 42 }, + signal, + ), + ).resolves.toEqual({ stdout: 'ok', stderr: '', exitCode: 0 }); + expect(toolchains.appleEvaluations).toBe(1); + expect(toolchains.appleRuns).toEqual([{ args: ['devicectl', 'list', 'devices'], signal }]); +}); diff --git a/src/platform-runtime-host.ts b/src/platform-runtime-host.ts new file mode 100644 index 0000000000..9bed1fdb94 --- /dev/null +++ b/src/platform-runtime-host.ts @@ -0,0 +1,86 @@ +import type { + DeviceInventoryHost, + HostOperatingSystem, + HostCommandRequest, + HostTemporaryTextFile, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { constants } from 'node:fs'; +import { access, mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createAppleToolHost } from './platform-runtime-apple-tool-host.ts'; +import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.ts'; +import { runCmd, whichCmd } from './utils/exec.ts'; + +export function createDeviceInventoryHost(): DeviceInventoryHost { + return Object.freeze({ + commands: Object.freeze({ + which: async (executable: string) => ((await whichCmd(executable)) ? executable : undefined), + run: async (request: HostCommandRequest, signal?: AbortSignal) => { + const result = await runCmd(request.executable, [...request.args], { + allowFailure: request.allowFailure, + cwd: request.cwd, + env: request.env ? { ...process.env, ...request.env } : undefined, + signal, + timeoutMs: request.timeoutMs, + }); + return { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }; + }, + }), + appleTools: createAppleToolHost(), + toolchains: createHostToolchainPreparer(), + files: Object.freeze({ + isExecutable: async (candidate: string) => await isExecutable(candidate), + createTemporaryTextFile: async (options: { prefix: string; suffix: string }) => + await createTemporaryTextFile(options), + }), + hostOs: hostOperatingSystem(process.platform), + hostName: os.hostname(), + homeDirectory: os.homedir(), + observations: Object.freeze({ + deviceBooted: async (device: DeviceInfo) => { + const { markSimulatorBooted } = await import('./platforms/apple/core/simulator.ts'); + markSimulatorBooted(device); + }, + }), + }); +} + +function hostOperatingSystem(platform: NodeJS.Platform): HostOperatingSystem { + if (platform === 'darwin' || platform === 'linux' || platform === 'win32') return platform; + return 'other'; +} + +async function isExecutable(candidate: string): Promise { + try { + await access(candidate, constants.X_OK); + return true; + } catch { + return false; + } +} + +async function createTemporaryTextFile( + options: Readonly<{ + prefix: string; + suffix: string; + }>, +): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), options.prefix)); + const filePath = path.join(directory, `value${options.suffix}`); + let disposed = false; + return { + path: filePath, + readText: async () => await readFile(filePath, 'utf8'), + [Symbol.asyncDispose]: async () => { + if (disposed) return; + disposed = true; + await rm(directory, { recursive: true, force: true }); + }, + }; +} diff --git a/src/platform-runtime-toolchain-host.test.ts b/src/platform-runtime-toolchain-host.test.ts new file mode 100644 index 0000000000..6181e6a10f --- /dev/null +++ b/src/platform-runtime-toolchain-host.test.ts @@ -0,0 +1,55 @@ +import { expect, test, vi } from 'vitest'; + +const toolchains = vi.hoisted(() => ({ + androidEvaluations: 0, + androidPrepares: 0, + harmonyEvaluations: 0, + harmonyPrepares: 0, +})); + +vi.mock('./platforms/android/sdk.ts', () => { + toolchains.androidEvaluations += 1; + return { + ensureAndroidSdkPathConfigured: async () => { + toolchains.androidPrepares += 1; + }, + }; +}); + +vi.mock('./platforms/harmonyos/hdc.ts', () => { + toolchains.harmonyEvaluations += 1; + return { + ensureHarmonyToolchainPathConfigured: async () => { + toolchains.harmonyPrepares += 1; + }, + }; +}); + +import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.ts'; + +test('toolchain host loads and prepares only the selected family', async () => { + const host = createHostToolchainPreparer(); + + expect(toolchains).toEqual({ + androidEvaluations: 0, + androidPrepares: 0, + harmonyEvaluations: 0, + harmonyPrepares: 0, + }); + + await host.prepare('android'); + expect(toolchains).toEqual({ + androidEvaluations: 1, + androidPrepares: 1, + harmonyEvaluations: 0, + harmonyPrepares: 0, + }); + + await host.prepare('harmonyos'); + expect(toolchains).toEqual({ + androidEvaluations: 1, + androidPrepares: 1, + harmonyEvaluations: 1, + harmonyPrepares: 1, + }); +}); diff --git a/src/platform-runtime-toolchain-host.ts b/src/platform-runtime-toolchain-host.ts new file mode 100644 index 0000000000..08052b2aac --- /dev/null +++ b/src/platform-runtime-toolchain-host.ts @@ -0,0 +1,18 @@ +import type { HostToolchainPreparer } from '@agent-device/contracts/platform'; + +export function createHostToolchainPreparer(): HostToolchainPreparer { + return Object.freeze({ + prepare: async (family) => { + if (family === 'android') { + const { ensureAndroidSdkPathConfigured } = await import('./platforms/android/sdk.ts'); + await ensureAndroidSdkPathConfigured(); + return; + } + if (family === 'harmonyos') { + const { ensureHarmonyToolchainPathConfigured } = + await import('./platforms/harmonyos/hdc.ts'); + await ensureHarmonyToolchainPathConfigured(); + } + }, + }); +} diff --git a/src/platform-runtime.test.ts b/src/platform-runtime.test.ts new file mode 100644 index 0000000000..0401324ecd --- /dev/null +++ b/src/platform-runtime.test.ts @@ -0,0 +1,93 @@ +import { PLATFORMS } from '@agent-device/kernel/device'; +import { describe, expect, test, vi } from 'vitest'; + +const hostAdapter = vi.hoisted(() => ({ evaluations: 0 })); + +vi.mock('./platform-runtime-host.ts', () => { + hostAdapter.evaluations += 1; + return { + createDeviceInventoryHost: () => ({ + commands: { + which: async () => undefined, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + appleTools: { + isXcrunAvailable: async () => false, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + toolchains: { prepare: async () => {} }, + files: { + isExecutable: async () => false, + createTemporaryTextFile: async () => { + throw new Error('unused'); + }, + }, + hostOs: 'darwin', + hostName: 'test-host', + homeDirectory: '/tmp/test-home', + observations: { deviceBooted: async () => {} }, + }), + }; +}); + +import { + createPlatformDeviceInventoryGateways, + platformModuleRegistry, +} from './platform-runtime.ts'; + +describe('platform module metadata composition', () => { + test('registers each canonical family exactly once in canonical order', () => { + expect(platformModuleRegistry.families).toEqual(PLATFORMS); + expect(platformModuleRegistry.modules.map(({ family }) => family)).toEqual(PLATFORMS); + for (const family of PLATFORMS) { + expect(platformModuleRegistry.get(family).family).toBe(family); + } + }); + + test('publishes immutable inventory facades without evaluating platform mechanics', () => { + expect(Object.isFrozen(platformModuleRegistry)).toBe(true); + expect(Object.isFrozen(platformModuleRegistry.families)).toBe(true); + expect(Object.isFrozen(platformModuleRegistry.modules)).toBe(true); + for (const module of platformModuleRegistry.modules) { + expect(Object.isFrozen(module)).toBe(true); + expect(Object.keys(module).sort()).toEqual(['family', 'loadInventory']); + } + }); + + test('keeps the host adapter lazy through provider-authoritative discovery', async () => { + expect(hostAdapter.evaluations).toBe(0); + const scope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + } as const; + const providerGateways = createPlatformDeviceInventoryGateways({ + discover: async () => ({ + kind: 'inventory', + devices: [ + { + platform: 'web', + id: 'provider-web', + name: 'Provider Web', + kind: 'device', + target: 'desktop', + booted: true, + }, + ], + }), + }); + expect(await providerGateways.providerFirst.discover({ platform: 'web' }, scope)).toHaveLength( + 1, + ); + expect(hostAdapter.evaluations).toBe(0); + + const localGateways = createPlatformDeviceInventoryGateways(); + expect(await localGateways.localOnly.discover({ platform: 'web' }, scope)).toHaveLength(1); + expect(hostAdapter.evaluations).toBe(0); + + await expect( + localGateways.localOnly.discover({ platform: 'android' }, scope), + ).rejects.toMatchObject({ code: 'TOOL_MISSING' }); + expect(hostAdapter.evaluations).toBe(1); + }); +}); diff --git a/src/platform-runtime.ts b/src/platform-runtime.ts new file mode 100644 index 0000000000..32f4d8f5c8 --- /dev/null +++ b/src/platform-runtime.ts @@ -0,0 +1,51 @@ +import type { ProviderDeviceInventorySource } from '@agent-device/contracts/device'; +import { + createPlatformModuleRegistry, + type ComposedDeviceInventoryGateways, +} from '@agent-device/contracts/platform'; +import { inventoryModule as appleInventoryModule } from '@agent-device/platform-apple'; +import { createAndroidInventoryModule } from '@agent-device/platform-android'; +import { createHarmonyInventoryModule } from '@agent-device/platform-harmonyos'; +import { inventoryModule as vegaInventoryModule } from '@agent-device/platform-vega'; +import { inventoryModule as linuxInventoryModule } from '@agent-device/platform-linux'; +import { inventoryModule as webInventoryModule } from '@agent-device/platform-web'; +import { createComposedDeviceInventoryGateways } from './platform-runtime-device-inventory.ts'; + +const androidInventoryModule = createAndroidInventoryModule({ + sdkRoots: configuredValues(process.env.ANDROID_SDK_ROOT, process.env.ANDROID_HOME), +}); + +const harmonyosInventoryModule = createHarmonyInventoryModule({ + hdcSdkPath: process.env.HDC_SDK_PATH, + devecoSdkHome: process.env.DEVECO_SDK_HOME, + commandLineToolsHome: process.env.HARMONYOS_COMMAND_LINE_TOOLS, +}); + +export const platformModuleRegistry = createPlatformModuleRegistry([ + appleInventoryModule, + androidInventoryModule, + harmonyosInventoryModule, + vegaInventoryModule, + linuxInventoryModule, + webInventoryModule, +]); + +export function createPlatformDeviceInventoryGateways( + provider?: ProviderDeviceInventorySource, +): ComposedDeviceInventoryGateways { + return createComposedDeviceInventoryGateways({ + registry: platformModuleRegistry, + loadHost: async () => { + const { createDeviceInventoryHost } = await import('./platform-runtime-host.ts'); + return createDeviceInventoryHost(); + }, + provider, + }); +} + +function configuredValues(...values: Array): string[] { + return values.flatMap((value) => { + const configured = value?.trim(); + return configured ? [configured] : []; + }); +} diff --git a/src/platforms/android/__tests__/adb-executor.test.ts b/src/platforms/android/__tests__/adb-executor.test.ts index 0ab7aceaa7..696fca9621 100644 --- a/src/platforms/android/__tests__/adb-executor.test.ts +++ b/src/platforms/android/__tests__/adb-executor.test.ts @@ -490,10 +490,12 @@ test('attachAdbFailureHint classifies timeouts over partial stderr and keeps sit stderr: '', timeoutMs: 5000, hint: 'site-specific hint', + retriable: false, }); attachAdbFailureHint(withSiteHint); assert.equal(withSiteHint.details?.hint, 'site-specific hint'); assert.equal(withSiteHint.details?.adbFailure, 'timeout'); + assert.equal(withSiteHint.details?.retriable, false); }); test('attachAdbFailureHint preserves existing hints and ignores non-adb errors', () => { diff --git a/src/platforms/android/__tests__/devices.test.ts b/src/platforms/android/__tests__/devices.test.ts deleted file mode 100644 index 35bf02540a..0000000000 --- a/src/platforms/android/__tests__/devices.test.ts +++ /dev/null @@ -1,463 +0,0 @@ -import { beforeEach, test, vi } from 'vitest'; -import assert from 'node:assert/strict'; -import { appendFileSync, existsSync, promises as fs, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { mkdtempForTest } from '../../../__tests__/test-utils/tmp-dir.ts'; - -vi.mock('../../../utils/exec.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, runCmdDetached: vi.fn(actual.runCmdDetached) }; -}); - -const execActual = - await vi.importActual('../../../utils/exec.ts'); - -import { AppError } from '@agent-device/kernel/errors'; -import { runCmdDetached, withCommandExecutorOverride } from '../../../utils/exec.ts'; -import type { CommandExecutorOverride, ExecResult } from '../../../utils/exec.ts'; -import { - ensureAndroidEmulatorBooted, - listAndroidDevices, - parseAndroidAvdList, - parseAndroidEmulatorAvdNameOutput, - parseAndroidFeatureListForTv, - parseAndroidTargetFromCharacteristics, - resolveAndroidAvdName, - resolveAndroidEmulatorAvdName, -} from '../devices.ts'; - -const mockRunCmdDetached = vi.mocked(runCmdDetached); - -const MOCK_ANDROID_ADB_SCRIPT = [ - '#!/bin/sh', - 'if [ "$1" = "devices" ] && [ "$2" = "-l" ]; then', - ' echo "List of devices attached"', - ' if [ -f "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE" ]; then', - ' echo "emulator-5554 device product:sdk_gphone64 model:Pixel_9_Pro_XL device:emu64a transport_id:2"', - ' fi', - ' exit 0', - 'fi', - 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "emu" ] && [ "$4" = "avd" ] && [ "$5" = "name" ]; then', - ' if [ "$AGENT_DEVICE_TEST_AVD_NAME_MODE" = "missing" ]; then', - ' exit 0', - ' fi', - ' echo "Pixel_9_Pro_XL"', - ' exit 0', - 'fi', - 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "ro.boot.qemu.avd_name" ]; then', - ' if [ "$AGENT_DEVICE_TEST_AVD_NAME_MODE" = "missing" ]; then', - ' exit 0', - ' fi', - ' echo "Pixel_9_Pro_XL"', - ' exit 0', - 'fi', - 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "persist.sys.avd_name" ]; then', - ' if [ "$AGENT_DEVICE_TEST_AVD_NAME_MODE" = "missing" ]; then', - ' exit 0', - ' fi', - ' echo "Pixel_9_Pro_XL"', - ' exit 0', - 'fi', - 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "sys.boot_completed" ]; then', - ' if [ -f "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE" ]; then', - ' echo "1"', - ' else', - ' echo "0"', - ' fi', - ' exit 0', - 'fi', - 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "getprop" ] && [ "$5" = "ro.build.characteristics" ]; then', - ' echo "phone"', - ' exit 0', - 'fi', - 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "cmd" ] && [ "$5" = "package" ] && [ "$6" = "has-feature" ]; then', - ' echo "false"', - ' exit 0', - 'fi', - 'if [ "$1" = "-s" ] && [ "$2" = "emulator-5554" ] && [ "$3" = "shell" ] && [ "$4" = "pm" ] && [ "$5" = "list" ] && [ "$6" = "features" ]; then', - ' echo ""', - ' exit 0', - 'fi', - 'echo "unexpected adb args: $@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"', - 'exit 1', - '', -]; - -const MOCK_ANDROID_EMULATOR_SCRIPT = [ - '#!/bin/sh', - 'if [ "$1" = "-list-avds" ]; then', - ' echo "Pixel_9_Pro_XL"', - ' exit 0', - 'fi', - 'if [ "$1" = "-avd" ]; then', - ' echo "$@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"', - ' touch "$AGENT_DEVICE_TEST_EMU_BOOTED_FILE"', - ' exit 0', - 'fi', - 'echo "unexpected emulator args: $@" >> "$AGENT_DEVICE_TEST_EMU_LOG_FILE"', - 'exit 1', - '', -]; - -beforeEach(() => { - mockRunCmdDetached.mockReset(); - mockRunCmdDetached.mockImplementation(execActual.runCmdDetached); -}); - -async function writeExecutable(filePath: string, lines: readonly string[]): Promise { - await fs.writeFile(filePath, lines.join('\n'), 'utf8'); - await fs.chmod(filePath, 0o755); -} - -function mockDetachedEmulatorLaunch(emulatorLogPath: string, emulatorBootedPath: string): void { - mockRunCmdDetached.mockImplementation((cmd, args, options) => { - if (cmd === 'emulator' && args[0] === '-avd') { - appendFileSync(emulatorLogPath, `${args.join(' ')}\n`, 'utf8'); - writeFileSync(emulatorBootedPath, 'ready', 'utf8'); - return 1234; - } - return execActual.runCmdDetached(cmd, args, options); - }); -} - -async function withEnv( - overrides: Record, - run: () => Promise, -): Promise { - const saved = Object.fromEntries( - Object.keys(overrides).map((key) => [key, process.env[key]]), - ) as Record; - - for (const [key, value] of Object.entries(overrides)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - - try { - await run(); - } finally { - for (const [key, value] of Object.entries(saved)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - } -} - -test('parseAndroidTargetFromCharacteristics detects tv markers', () => { - assert.equal(parseAndroidTargetFromCharacteristics('tv,nosdcard'), 'tv'); - assert.equal(parseAndroidTargetFromCharacteristics('watch,leanback'), 'tv'); - assert.equal(parseAndroidTargetFromCharacteristics('phone,tablet'), null); -}); - -test('parseAndroidFeatureListForTv detects television and leanback features', () => { - const tvFeatures = [ - 'feature:android.software.leanback', - 'feature:android.hardware.type.television', - ].join('\n'); - assert.equal(parseAndroidFeatureListForTv(tvFeatures), true); - assert.equal(parseAndroidFeatureListForTv('feature:android.hardware.camera'), false); -}); - -test('parseAndroidAvdList drops empty lines', () => { - const listed = parseAndroidAvdList('\nPixel_9_Pro_XL\n\nWear_OS\n'); - assert.deepEqual(listed, ['Pixel_9_Pro_XL', 'Wear_OS']); -}); - -test('parseAndroidEmulatorAvdNameOutput drops trailing adb protocol status', () => { - assert.equal(parseAndroidEmulatorAvdNameOutput('Pixel_9_Pro_XL\r\nOK\r\n'), 'Pixel_9_Pro_XL'); - assert.equal(parseAndroidEmulatorAvdNameOutput('Pixel_9_Pro_XL\n'), 'Pixel_9_Pro_XL'); - assert.equal(parseAndroidEmulatorAvdNameOutput('\r\nOK\r\n'), undefined); -}); - -test('resolveAndroidAvdName supports space vs underscore matching', () => { - const avdNames = ['Pixel_9_Pro_XL', 'Medium_Tablet_API_35']; - assert.equal(resolveAndroidAvdName(avdNames, 'Pixel_9_Pro_XL'), 'Pixel_9_Pro_XL'); - assert.equal(resolveAndroidAvdName(avdNames, 'pixel 9 pro xl'), 'Pixel_9_Pro_XL'); - assert.equal(resolveAndroidAvdName(avdNames, 'unknown'), undefined); -}); - -async function withMockedAndroidTools( - run: (ctx: { emulatorLogPath: string; emulatorBootedPath: string }) => Promise, - options: { avdNameMode?: 'success' | 'missing' } = {}, -): Promise { - const tmpDir = await mkdtempForTest('agent-device-android-headless-'); - const emulatorLogPath = path.join(tmpDir, 'emulator.log'); - const emulatorBootedPath = path.join(tmpDir, 'emulator.booted'); - const adbPath = path.join(tmpDir, 'adb'); - const emulatorPath = path.join(tmpDir, 'emulator'); - - await writeExecutable(adbPath, MOCK_ANDROID_ADB_SCRIPT); - await writeExecutable(emulatorPath, MOCK_ANDROID_EMULATOR_SCRIPT); - - try { - mockDetachedEmulatorLaunch(emulatorLogPath, emulatorBootedPath); - await withEnv( - { - PATH: `${tmpDir}${path.delimiter}${process.env.PATH ?? ''}`, - AGENT_DEVICE_TEST_EMU_BOOTED_FILE: emulatorBootedPath, - AGENT_DEVICE_TEST_EMU_LOG_FILE: emulatorLogPath, - AGENT_DEVICE_TEST_AVD_NAME_MODE: options.avdNameMode ?? 'success', - HOME: tmpDir, - ANDROID_SDK_ROOT: undefined, - ANDROID_HOME: undefined, - }, - async () => - await withCommandExecutorOverride( - createMockedAndroidToolsCommandOverride({ - emulatorBootedPath, - avdNameMode: options.avdNameMode ?? 'success', - }), - async () => await run({ emulatorLogPath, emulatorBootedPath }), - ), - ); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); - } -} - -async function withMockedAndroidSdkRoot( - run: (ctx: { - emulatorLogPath: string; - emulatorBootedPath: string; - sdkRoot: string; - }) => Promise, -): Promise { - const tmpDir = await mkdtempForTest('agent-device-android-sdk-root-'); - const sdkRoot = path.join(tmpDir, 'Android', 'Sdk'); - const platformToolsDir = path.join(sdkRoot, 'platform-tools'); - const emulatorDir = path.join(sdkRoot, 'emulator'); - const emulatorLogPath = path.join(tmpDir, 'emulator.log'); - const emulatorBootedPath = path.join(tmpDir, 'emulator.booted'); - const adbPath = path.join(platformToolsDir, 'adb'); - const emulatorPath = path.join(emulatorDir, 'emulator'); - - await fs.mkdir(platformToolsDir, { recursive: true }); - await fs.mkdir(emulatorDir, { recursive: true }); - - await writeExecutable(adbPath, MOCK_ANDROID_ADB_SCRIPT); - await writeExecutable(emulatorPath, MOCK_ANDROID_EMULATOR_SCRIPT); - - try { - mockDetachedEmulatorLaunch(emulatorLogPath, emulatorBootedPath); - await withEnv( - { - PATH: process.env.PATH ?? '', - AGENT_DEVICE_TEST_EMU_BOOTED_FILE: emulatorBootedPath, - AGENT_DEVICE_TEST_EMU_LOG_FILE: emulatorLogPath, - ANDROID_SDK_ROOT: sdkRoot, - ANDROID_HOME: undefined, - }, - async () => - await withCommandExecutorOverride( - createMockedAndroidToolsCommandOverride({ emulatorBootedPath }), - async () => await run({ emulatorLogPath, emulatorBootedPath, sdkRoot }), - ), - ); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); - } -} - -function createMockedAndroidToolsCommandOverride(params: { - emulatorBootedPath: string; - avdNameMode?: 'success' | 'missing'; -}): CommandExecutorOverride { - return async (cmd, args) => { - if (cmd === 'emulator') return handleMockedEmulatorCommand(args); - if (cmd === 'adb') return handleMockedAdbCommand(args, params); - throw new Error(`Unexpected command: ${cmd} ${args.join(' ')}`); - }; -} - -function handleMockedEmulatorCommand(args: string[]): ExecResult { - const emulatorKey = args.join('\0'); - if (emulatorKey === '-list-avds') return execResult('Pixel_9_Pro_XL\n'); - throw new Error(`Unexpected emulator args: ${args.join(' ')}`); -} - -function handleMockedAdbCommand( - args: string[], - params: { emulatorBootedPath: string; avdNameMode?: 'success' | 'missing' }, -): ExecResult { - const adbArgs = stripAdbSerial(args); - const adbKey = adbArgs.join('\0'); - if (adbKey === 'devices\0-l') return mockedAdbDevicesResult(params.emulatorBootedPath); - if (isAdbAvdNameProbe(adbKey)) { - return execResult(params.avdNameMode === 'missing' ? '' : 'Pixel_9_Pro_XL\n'); - } - if (adbKey === 'shell\0getprop\0sys.boot_completed') { - return execResult(existsSync(params.emulatorBootedPath) ? '1\n' : '0\n'); - } - if (adbKey === 'shell\0getprop\0ro.build.characteristics') return execResult('phone\n'); - if (isAdbPackageProbe(adbArgs)) return execResult('false\n'); - if (adbKey === 'shell\0pm\0list\0features') return execResult('\n'); - throw new Error(`Unexpected adb args: ${args.join(' ')}`); -} - -function mockedAdbDevicesResult(emulatorBootedPath: string): ExecResult { - const bootedDevice = existsSync(emulatorBootedPath) - ? [ - 'emulator-5554 device product:sdk_gphone64 model:Pixel_9_Pro_XL device:emu64a transport_id:2', - ] - : []; - return execResult(['List of devices attached', ...bootedDevice, ''].join('\n')); -} - -function isAdbAvdNameProbe(adbKey: string): boolean { - return ( - adbKey === 'emu\0avd\0name' || - adbKey === 'shell\0getprop\0ro.boot.qemu.avd_name' || - adbKey === 'shell\0getprop\0persist.sys.avd_name' - ); -} - -function isAdbPackageProbe(adbArgs: string[]): boolean { - return adbArgs[0] === 'shell' && adbArgs[1] === 'cmd' && adbArgs[2] === 'package'; -} - -function stripAdbSerial(args: string[]): string[] { - if (args[0] === '-s') return args.slice(2); - return args; -} - -function execResult(stdout = '', stderr = '', exitCode = 0): ExecResult { - return { stdout, stderr, exitCode }; -} - -test('resolveAndroidEmulatorAvdName ignores probe timeouts and keeps probing', async () => { - const calls: string[][] = []; - const results = [ - new AppError('COMMAND_FAILED', 'adb timed out after 1500ms', { timeoutMs: 1500 }), - { stdout: '', stderr: '', exitCode: 0 }, - { stdout: 'Pixel_9_Pro_XL\n', stderr: '', exitCode: 0 }, - ]; - const runAdb = async ( - _cmd: string, - args: string[], - ): Promise<{ stdout: string; stderr: string; exitCode: number }> => { - calls.push(args); - const next = results.shift(); - if (next instanceof AppError) throw next; - assert.ok(next); - return next; - }; - - const avdName = await resolveAndroidEmulatorAvdName('emulator-5554', runAdb); - - assert.equal(avdName, 'Pixel_9_Pro_XL'); - assert.deepEqual( - calls.map((args) => args.slice(2)), - [ - ['shell', 'getprop', 'ro.boot.qemu.avd_name'], - ['shell', 'getprop', 'persist.sys.avd_name'], - ['emu', 'avd', 'name'], - ], - ); -}); - -test('resolveAndroidEmulatorAvdName rethrows non-timeout probe failures', async () => { - const failure = new AppError('COMMAND_FAILED', 'adb exited with code 1', { - stderr: 'device offline', - exitCode: 1, - processExitError: true, - }); - let callCount = 0; - const runAdb = async (): Promise<{ stdout: string; stderr: string; exitCode: number }> => { - callCount += 1; - throw failure; - }; - - await assert.rejects( - async () => await resolveAndroidEmulatorAvdName('emulator-5554', runAdb), - (error) => error === failure, - ); - assert.equal(callCount, 1); -}); - -test('listAndroidDevices falls back to model when emulator avd name is unavailable', async () => { - await withMockedAndroidTools( - async ({ emulatorBootedPath }) => { - await fs.writeFile(emulatorBootedPath, 'ready', 'utf8'); - - const devices = await listAndroidDevices(); - - assert.equal(devices.length, 1); - assert.equal(devices[0]?.id, 'emulator-5554'); - assert.equal(devices[0]?.name, 'Pixel 9 Pro XL'); - assert.equal(devices[0]?.kind, 'emulator'); - }, - { avdNameMode: 'missing' }, - ); -}); - -test('listAndroidDevices includes stopped AVDs as non-booted emulators', async () => { - await withMockedAndroidTools(async () => { - const devices = await listAndroidDevices(); - - assert.equal(devices.length, 1); - assert.equal(devices[0]?.id, 'Pixel_9_Pro_XL'); - assert.equal(devices[0]?.name, 'Pixel_9_Pro_XL'); - assert.equal(devices[0]?.kind, 'emulator'); - assert.equal(devices[0]?.booted, false); - }); -}); - -test('ensureAndroidEmulatorBooted launches emulator in headless mode when requested', async () => { - await withMockedAndroidTools(async ({ emulatorLogPath, emulatorBootedPath }) => { - const device = await ensureAndroidEmulatorBooted({ - avdName: 'Pixel 9 Pro XL', - timeoutMs: 5_000, - headless: true, - }); - assert.equal(device.platform, 'android'); - assert.equal(device.kind, 'emulator'); - assert.equal(device.id, 'emulator-5554'); - assert.equal(device.booted, true); - const log = await fs.readFile(emulatorLogPath, 'utf8'); - assert.match(log, /-avd Pixel_9_Pro_XL -no-window -no-audio/); - await fs.access(emulatorBootedPath); - }); -}, 10_000); - -test('ensureAndroidEmulatorBooted reuses running emulator for headless requests', async () => { - await withMockedAndroidTools(async ({ emulatorLogPath, emulatorBootedPath }) => { - await fs.writeFile(emulatorBootedPath, 'ready', 'utf8'); - const device = await ensureAndroidEmulatorBooted({ - avdName: 'Pixel_9_Pro_XL', - timeoutMs: 5_000, - headless: true, - }); - assert.equal(device.id, 'emulator-5554'); - const log = await fs.readFile(emulatorLogPath, 'utf8').catch(() => ''); - assert.equal(log.trim(), ''); - }); -}, 10_000); - -test('ensureAndroidEmulatorBooted launches emulator with GUI by default', async () => { - await withMockedAndroidTools(async ({ emulatorLogPath }) => { - const device = await ensureAndroidEmulatorBooted({ - avdName: 'Pixel_9_Pro_XL', - timeoutMs: 5_000, - }); - assert.equal(device.id, 'emulator-5554'); - const log = await fs.readFile(emulatorLogPath, 'utf8'); - assert.match(log, /-avd Pixel_9_Pro_XL/); - assert.doesNotMatch(log, /-no-window/); - }); -}, 10_000); - -test('ensureAndroidEmulatorBooted falls back to ANDROID_SDK_ROOT when PATH is incomplete', async () => { - await withMockedAndroidSdkRoot(async ({ emulatorLogPath, sdkRoot }) => { - const device = await ensureAndroidEmulatorBooted({ - avdName: 'Pixel 9 Pro XL', - timeoutMs: 5_000, - headless: true, - }); - assert.equal(device.id, 'emulator-5554'); - const log = await fs.readFile(emulatorLogPath, 'utf8'); - assert.match(log, /-avd Pixel_9_Pro_XL -no-window -no-audio/); - assert.ok((process.env.PATH ?? '').includes(path.join(sdkRoot, 'platform-tools'))); - assert.ok((process.env.PATH ?? '').includes(path.join(sdkRoot, 'emulator'))); - assert.equal(process.env.ANDROID_HOME, sdkRoot); - }); -}, 10_000); diff --git a/src/platforms/android/__tests__/emulator-lifecycle.test.ts b/src/platforms/android/__tests__/emulator-lifecycle.test.ts new file mode 100644 index 0000000000..9777495438 --- /dev/null +++ b/src/platforms/android/__tests__/emulator-lifecycle.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { beforeEach, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +vi.mock('../../../utils/exec.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCmd: vi.fn(), + runCmdDetached: vi.fn(), + whichCmd: vi.fn(), + }; +}); +vi.mock('../sdk.ts', () => ({ ensureAndroidSdkPathConfigured: vi.fn(async () => {}) })); + +import { runCmd, runCmdDetached, whichCmd } from '../../../utils/exec.ts'; +import { ensureAndroidEmulatorBooted } from '../emulator-lifecycle.ts'; + +const stopped: DeviceInfo = { + platform: 'android', + id: 'Pixel_9_Pro_XL', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: false, +}; +const running: DeviceInfo = { + ...stopped, + id: 'emulator-5554', + name: 'Pixel 9 Pro XL', + booted: true, +}; + +const mockRunCmd = vi.mocked(runCmd); +const mockRunCmdDetached = vi.mocked(runCmdDetached); +const mockWhichCmd = vi.mocked(whichCmd); + +beforeEach(() => { + mockRunCmd.mockReset(); + mockRunCmd.mockResolvedValue({ stdout: '1\n', stderr: '', exitCode: 0 }); + mockRunCmdDetached.mockReset(); + mockRunCmdDetached.mockReturnValue(1234); + mockWhichCmd.mockReset(); + mockWhichCmd.mockResolvedValue(true); +}); + +test('emulator boot adopts package-owned local discovery and launches a stopped AVD', async () => { + const requests: unknown[] = []; + const discoveries = [[stopped], [running], [running]]; + const discoverLocal = vi.fn(async (request) => { + requests.push(request); + return discoveries.shift() ?? [running]; + }); + + const device = await ensureAndroidEmulatorBooted( + { avdName: 'Pixel 9 Pro XL', timeoutMs: 5_000, headless: true }, + { discoverLocal }, + ); + + assert.equal(device.id, 'emulator-5554'); + assert.equal(device.booted, true); + assert.deepEqual(requests[0], { platform: 'android', kind: 'emulator' }); + assert.deepEqual(mockRunCmdDetached.mock.calls[0]?.slice(0, 2), [ + 'emulator', + ['-avd', 'Pixel_9_Pro_XL', '-no-window', '-no-audio'], + ]); +}); + +test('emulator boot reuses a matching running emulator without relaunching', async () => { + const discoverLocal = vi.fn(async () => [running]); + + const device = await ensureAndroidEmulatorBooted( + { avdName: 'Pixel_9_Pro_XL', timeoutMs: 5_000, headless: true }, + { discoverLocal }, + ); + + assert.equal(device.id, running.id); + assert.equal(mockRunCmdDetached.mock.calls.length, 0); +}); + +test('emulator boot excludes a running serial outside the resolved Android allowlist', async () => { + const requests: Array<{ androidSerialAllowlist?: string[] }> = []; + const dependencies = { + androidSerialAllowlist: ['emulator-9999'], + discoverLocal: vi.fn(async (request: { androidSerialAllowlist?: string[] }) => { + requests.push(request); + return request.androidSerialAllowlist ? [] : [running]; + }), + }; + + await assert.rejects( + ensureAndroidEmulatorBooted( + { avdName: 'Pixel_9_Pro_XL', timeoutMs: 5_000, headless: true }, + dependencies, + ), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'DEVICE_NOT_FOUND', + ); + + assert.deepEqual(requests[0]?.androidSerialAllowlist, ['emulator-9999']); + assert.equal(mockRunCmdDetached.mock.calls.length, 0); +}); diff --git a/src/platforms/android/adb-executor.ts b/src/platforms/android/adb-executor.ts index ec0395f1db..244f788a29 100644 --- a/src/platforms/android/adb-executor.ts +++ b/src/platforms/android/adb-executor.ts @@ -1,5 +1,10 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { Readable, Writable } from 'node:stream'; +import { + ANDROID_ADB_TIMEOUT_FAILURE, + classifyAndroidAdbFailure, + type AndroidAdbFailureClassification, +} from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Rect } from '@agent-device/kernel/snapshot'; import type { AndroidSnapshotHelperArtifact } from './snapshot-helper-types.ts'; @@ -176,132 +181,14 @@ type AndroidAdbProviderScope = { const androidAdbProviderScope = new AsyncLocalStorage(); -export type AdbFailureClassification = { - /** Machine-readable failure family, attached to error details as `adbFailure`. */ - reason: - | 'timeout' - | 'device_offline' - | 'device_unauthorized' - | 'device_not_found' - | 'multiple_devices' - | 'no_devices' - | 'connection_dropped' - | 'server_version_mismatch' - | 'install_insufficient_storage' - | 'install_update_incompatible' - | 'install_version_downgrade' - | 'install_failed'; - hint: string; - /** Set only for clearly transient families where an unchanged retry can succeed. */ - retriable?: boolean; -}; - -type AdbFailureMatcher = AdbFailureClassification & { - /** Tested against lowercased output. */ - pattern: RegExp; - /** - * Install verdicts often land on stdout (`Failure [INSTALL_FAILED_*]` from pm), - * so those matchers also scan stdout. Everything else is stderr-only to avoid - * misreading arbitrary `adb shell` output as a transport failure. - */ - matchStdout?: boolean; -}; - -// Ordered most-specific first; the first match wins. -const ADB_FAILURE_MATCHERS: readonly AdbFailureMatcher[] = [ - { - reason: 'device_unauthorized', - pattern: /device unauthorized|device still authorizing/, - hint: 'USB debugging is not authorized — accept the authorization prompt on the device screen (re-plug the cable if none appears), then retry.', - }, - { - reason: 'device_offline', - pattern: /device offline/, - hint: 'The device is connected but offline — wait for it to finish booting or run adb reconnect, then retry.', - retriable: true, - }, - { - reason: 'multiple_devices', - pattern: /more than one (?:device\/emulator|device and emulator)/, - hint: 'Multiple Android devices are connected — pass --serial (see adb devices) to select one.', - }, - { - reason: 'no_devices', - pattern: /no devices\/emulators found|no devices found/, - hint: 'No Android devices detected — boot an emulator or connect a device and verify it appears in adb devices.', - }, - { - reason: 'device_not_found', - pattern: /device (?:'[^']*' )?not found/, - hint: 'The device disconnected or is restarting — verify it is listed in adb devices, then retry.', - retriable: true, - }, - { - reason: 'server_version_mismatch', - pattern: /adb server version \(\d+\) doesn't match this client/, - hint: 'Multiple adb installs conflict — adb restarts its server automatically, so retry; align PATH to a single adb to stop recurrences.', - retriable: true, - }, - { - reason: 'connection_dropped', - pattern: /transport error|connection reset|broken pipe|protocol fault/, - hint: 'The adb connection dropped — retry; if it persists, run adb kill-server and reconnect the device.', - retriable: true, - }, - { - reason: 'install_insufficient_storage', - pattern: /install_failed_insufficient_storage/, - hint: 'The device is out of storage — free up space or uninstall unused apps, then retry the install.', - matchStdout: true, - }, - { - reason: 'install_update_incompatible', - pattern: /install_failed_update_incompatible/, - hint: 'The installed app has an incompatible signature — uninstall the existing app first, then retry the install.', - matchStdout: true, - }, - { - reason: 'install_version_downgrade', - pattern: /install_failed_version_downgrade/, - hint: 'The APK is older than the installed app — uninstall the app first (or install with downgrade allowed), then retry.', - matchStdout: true, - }, - { - reason: 'install_failed', - pattern: /install_failed_\w+|install_parse_failed_\w+/, - hint: 'The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.', - matchStdout: true, - }, -]; - -// A timed-out adb invocation leaves no failure output to key on — the exec -// layer kills the process and rejects with COMMAND_FAILED carrying `timeoutMs` -// in details (see createTimeoutError in utils/exec.ts), so timeouts classify on -// that structured signal instead of a stderr matcher. Not marked retriable: an -// unchanged retry against a wedged adb server times out identically. -const ADB_TIMEOUT_CLASSIFICATION: AdbFailureClassification = { - reason: 'timeout', - hint: 'adb timed out — the adb server may be wedged. Run adb kill-server && adb start-server, check adb devices, then retry.', -}; +export type AdbFailureClassification = AndroidAdbFailureClassification; /** * Maps well-known adb failure output to an actionable hint (and a `retriable` * flag for clearly transient families). Matches stderr; install verdicts also * match stdout. Returns undefined for unrecognized output. */ -export function classifyAdbFailure( - stderr: string, - stdout = '', -): AdbFailureClassification | undefined { - const stderrText = stderr.toLowerCase(); - const stdoutText = stdout.toLowerCase(); - for (const { pattern, matchStdout, ...classification } of ADB_FAILURE_MATCHERS) { - if (pattern.test(stderrText) || (matchStdout && pattern.test(stdoutText))) { - return classification; - } - } - return undefined; -} +export const classifyAdbFailure = classifyAndroidAdbFailure; /** * Enriches a failed adb command error in place with the classified hint, @@ -331,7 +218,7 @@ export function attachAdbFailureHint(error: T): T { // process is untrustworthy — classifying that partial stderr (e.g. flagging a // half-written transport line retriable) would point away from the real fix. function classifyAdbCommandError(error: AppError): AdbFailureClassification | undefined { - if (typeof error.details?.timeoutMs === 'number') return ADB_TIMEOUT_CLASSIFICATION; + if (typeof error.details?.timeoutMs === 'number') return ANDROID_ADB_TIMEOUT_FAILURE; const stderr = typeof error.details?.stderr === 'string' ? error.details.stderr : ''; const stdout = typeof error.details?.stdout === 'string' ? error.details.stdout : ''; return classifyAdbFailure(stderr, stdout); diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index 1dc4b17efa..92b80fcb62 100644 --- a/src/platforms/android/app-lifecycle.ts +++ b/src/platforms/android/app-lifecycle.ts @@ -8,7 +8,7 @@ import type { AppsFilter } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { isDeepLinkTarget } from '@agent-device/contracts/command'; import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts'; -import { waitForAndroidBoot } from './devices.ts'; +import { waitForAndroidBoot } from './emulator-lifecycle.ts'; import { runAndroidAdb } from './adb.ts'; import { androidAdbResultError, diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts deleted file mode 100644 index 6950cdc258..0000000000 --- a/src/platforms/android/devices.ts +++ /dev/null @@ -1,622 +0,0 @@ -import { requireExecSuccess, runCmd, runCmdDetached, whichCmd } from '../../utils/exec.ts'; -import type { ExecResult } from '../../utils/exec.ts'; -import { sleep } from '../../utils/timeouts.ts'; -import { AppError, asAppError } from '@agent-device/kernel/errors'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { Deadline, retryWithPolicy } from '../../utils/retry.ts'; -import { resolveAndroidSerialAllowlist } from '../../utils/device-isolation.ts'; -import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts'; -import { attachAdbFailureHint } from './adb-executor.ts'; -import { ensureAndroidSdkPathConfigured } from './sdk.ts'; - -const EMULATOR_SERIAL_PREFIX = 'emulator-'; -const ANDROID_BOOT_POLL_MS = 1000; -const ANDROID_BOOT_PROP_TIMEOUT_MS = 10_000; -const ANDROID_EMULATOR_BOOT_POLL_MS = 1000; -const ANDROID_EMULATOR_BOOT_TIMEOUT_MS = 120_000; -const ANDROID_EMULATOR_AVD_NAME_TIMEOUT_MS = 10_000; -const ANDROID_TV_FEATURES = [ - 'android.software.leanback', - 'android.software.leanback_only', - 'android.hardware.type.television', -] as const; -const ANDROID_DISCOVERY_CONCURRENCY = 3; -const ANDROID_FEATURE_PROBE_CONCURRENCY = 2; - -type AndroidDeviceDiscoveryOptions = { - serialAllowlist?: ReadonlySet; -}; - -type AndroidAdbRunner = typeof runCmd; - -async function mapWithConcurrency( - values: readonly T[], - concurrency: number, - mapper: (value: T) => Promise, -): Promise { - const results = new Array(values.length); - let nextIndex = 0; - const workerCount = Math.min(concurrency, values.length); - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (nextIndex < values.length) { - const index = nextIndex; - nextIndex += 1; - results[index] = await mapper(values[index]!); - } - }), - ); - return results; -} - -function commandOutput(result: ExecResult): string { - return `${result.stdout}\n${result.stderr}`; -} - -function adbArgs(serial: string, args: string[]): string[] { - return ['-s', serial, ...args]; -} - -function isEmulatorSerial(serial: string): boolean { - return serial.startsWith(EMULATOR_SERIAL_PREFIX); -} - -function normalizeAndroidName(value: string): string { - return value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); -} - -export function parseAndroidEmulatorAvdNameOutput(rawOutput: string): string | undefined { - const lines = rawOutput - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0); - if (lines.length === 0) return undefined; - if (lines.at(-1) === 'OK') { - lines.pop(); - } - return lines.join('\n').trim() || undefined; -} - -async function readAndroidBootProp( - serial: string, - timeoutMs = ANDROID_BOOT_PROP_TIMEOUT_MS, -): Promise { - return runCmd('adb', adbArgs(serial, ['shell', 'getprop', 'sys.boot_completed']), { - allowFailure: true, - timeoutMs, - }); -} - -async function resolveAndroidDeviceName(serial: string, rawModel: string): Promise { - const modelName = rawModel.replace(/_/g, ' ').trim(); - if (!isEmulatorSerial(serial)) return modelName || serial; - const avdName = await resolveAndroidEmulatorAvdName(serial); - if (avdName) return avdName.replace(/_/g, ' '); - return modelName || serial; -} - -async function runBestEffortAndroidEmulatorNameProbe( - serial: string, - args: string[], - runAdb: AndroidAdbRunner, -): Promise { - try { - return await runAdb('adb', adbArgs(serial, args), { - allowFailure: true, - timeoutMs: ANDROID_EMULATOR_AVD_NAME_TIMEOUT_MS, - }); - } catch (error) { - const appError = asAppError(error); - // Friendly-name lookup is optional during discovery, but only probe timeouts should fall back. - if (isAndroidEmulatorNameProbeTimeout(appError)) { - return undefined; - } - throw error; - } -} - -function isAndroidEmulatorNameProbeTimeout(error: AppError): boolean { - return error.code === 'COMMAND_FAILED' && typeof error.details?.timeoutMs === 'number'; -} - -export async function resolveAndroidEmulatorAvdName( - serial: string, - runAdb: AndroidAdbRunner = runCmd, -): Promise { - const avdPropKeys = ['ro.boot.qemu.avd_name', 'persist.sys.avd_name']; - for (const prop of avdPropKeys) { - const result = await runBestEffortAndroidEmulatorNameProbe( - serial, - ['shell', 'getprop', prop], - runAdb, - ); - if (!result) continue; - const value = result.stdout.trim(); - if (result.exitCode === 0 && value.length > 0) { - return value; - } - } - const emuResult = await runBestEffortAndroidEmulatorNameProbe( - serial, - ['emu', 'avd', 'name'], - runAdb, - ); - if (!emuResult) return undefined; - const emuValue = parseAndroidEmulatorAvdNameOutput(emuResult.stdout); - if (emuResult.exitCode === 0 && emuValue) { - return emuValue; - } - return undefined; -} - -export function parseAndroidTargetFromCharacteristics(rawOutput: string): 'tv' | null { - const normalized = rawOutput.toLowerCase(); - if (normalized.includes('tv') || normalized.includes('leanback')) { - return 'tv'; - } - return null; -} - -export function parseAndroidFeatureListForTv(rawOutput: string): boolean { - return /feature:android\.(software\.leanback(_only)?|hardware\.type\.television)\b/i.test( - rawOutput, - ); -} - -async function probeAndroidFeature(serial: string, feature: string): Promise { - const result = await runCmd( - 'adb', - adbArgs(serial, ['shell', 'cmd', 'package', 'has-feature', feature]), - { - allowFailure: true, - timeoutMs: ANDROID_BOOT_PROP_TIMEOUT_MS, - }, - ); - const output = commandOutput(result).toLowerCase(); - if (output.includes('true')) return true; - if (output.includes('false')) return false; - return null; -} - -async function hasAnyAndroidTvFeature(serial: string): Promise { - const featureChecks = await mapWithConcurrency( - ANDROID_TV_FEATURES, - ANDROID_FEATURE_PROBE_CONCURRENCY, - async (feature) => await probeAndroidFeature(serial, feature), - ); - return featureChecks.some((value) => value === true); -} - -async function resolveAndroidTarget(serial: string): Promise<'mobile' | 'tv'> { - const characteristicsResult = await runCmd( - 'adb', - adbArgs(serial, ['shell', 'getprop', 'ro.build.characteristics']), - { - allowFailure: true, - timeoutMs: ANDROID_BOOT_PROP_TIMEOUT_MS, - }, - ); - const characteristicsTarget = parseAndroidTargetFromCharacteristics( - commandOutput(characteristicsResult), - ); - if (characteristicsTarget === 'tv') { - return 'tv'; - } - - if (await hasAnyAndroidTvFeature(serial)) { - return 'tv'; - } - - const featureListResult = await runCmd( - 'adb', - adbArgs(serial, ['shell', 'pm', 'list', 'features']), - { - allowFailure: true, - timeoutMs: ANDROID_BOOT_PROP_TIMEOUT_MS, - }, - ); - if (parseAndroidFeatureListForTv(commandOutput(featureListResult))) { - return 'tv'; - } - - return 'mobile'; -} - -export async function listAndroidDevices( - options: AndroidDeviceDiscoveryOptions = {}, -): Promise { - await ensureAndroidSdkPathConfigured(); - const adbAvailable = await whichCmd('adb'); - if (!adbAvailable) { - throw new AppError('TOOL_MISSING', 'adb not found in PATH'); - } - const serialAllowlist = options.serialAllowlist ?? resolveAndroidSerialAllowlist(undefined); - - const entries = await listAndroidDeviceEntries(); - const filteredEntries = entries.filter( - (entry) => !serialAllowlist || serialAllowlist.has(entry.serial), - ); - - const devices = await mapWithConcurrency( - filteredEntries, - ANDROID_DISCOVERY_CONCURRENCY, - async ({ serial, rawModel }) => { - const [name, booted, target] = await Promise.all([ - resolveAndroidDeviceName(serial, rawModel), - isAndroidBooted(serial), - resolveAndroidTarget(serial), - ]); - return { - platform: 'android', - id: serial, - name, - kind: isEmulatorSerial(serial) ? 'emulator' : 'device', - target, - booted, - } satisfies DeviceInfo; - }, - ); - - return [...devices, ...(await listStoppedAndroidAvdDevices(devices))]; -} - -type AndroidDeviceEntry = { - serial: string; - rawModel: string; -}; - -function parseAndroidDeviceEntries(rawOutput: string): AndroidDeviceEntry[] { - const lines = rawOutput.split('\n').map((line) => line.trim()); - return lines - .filter((line) => line.length > 0 && !line.startsWith('List of devices')) - .map((line) => line.split(/\s+/)) - .flatMap((parts) => { - const serial = parts[0]; - if (serial === undefined || parts[1] !== 'device') return []; - return [ - { - serial, - rawModel: (parts.find((entry) => entry.startsWith('model:')) ?? '').replace('model:', ''), - }, - ]; - }); -} - -async function listAndroidDeviceEntries(): Promise { - try { - const result = await runCmd('adb', ['devices', '-l'], { - timeoutMs: ANDROID_BOOT_PROP_TIMEOUT_MS, - }); - return parseAndroidDeviceEntries(result.stdout); - } catch (error) { - throw attachAdbFailureHint(error); - } -} - -export function parseAndroidAvdList(rawOutput: string): string[] { - return rawOutput - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0); -} - -export function resolveAndroidAvdName( - avdNames: string[], - requestedName: string, -): string | undefined { - const direct = avdNames.find((name) => name === requestedName); - if (direct) return direct; - const target = normalizeAndroidName(requestedName); - return avdNames.find((name) => normalizeAndroidName(name) === target); -} - -async function listAndroidAvdNames(): Promise { - const result = requireExecSuccess( - await runCmd('emulator', ['-list-avds'], { - allowFailure: true, - timeoutMs: ANDROID_BOOT_PROP_TIMEOUT_MS, - }), - 'Failed to list Android emulator AVDs', - { - hint: 'Verify Android emulator tooling is installed and available in PATH.', - }, - ); - return parseAndroidAvdList(result.stdout); -} - -async function listStoppedAndroidAvdDevices(runningDevices: DeviceInfo[]): Promise { - const avdNames = await listAndroidAvdNames().catch(() => []); - const runningEmulatorNames = new Set( - runningDevices - .filter((device) => device.kind === 'emulator') - .map((device) => normalizeAndroidName(device.name)), - ); - return avdNames - .filter((avdName) => !runningEmulatorNames.has(normalizeAndroidName(avdName))) - .map((avdName) => ({ - platform: 'android', - id: avdName, - name: avdName, - kind: 'emulator', - target: inferAndroidAvdTarget(avdName), - booted: false, - })); -} - -function inferAndroidAvdTarget(avdName: string): 'mobile' | 'tv' { - return /\b(tv|television)\b/i.test(normalizeAndroidName(avdName)) ? 'tv' : 'mobile'; -} - -function findAndroidEmulatorByAvdName( - devices: DeviceInfo[], - avdName: string, - serial?: string, -): DeviceInfo | undefined { - const target = normalizeAndroidName(avdName); - return devices.find((device) => { - if (device.platform !== 'android' || device.kind !== 'emulator') return false; - if (serial && device.id !== serial) return false; - return normalizeAndroidName(device.name) === target; - }); -} - -async function waitForAndroidEmulatorByAvdName(params: { - avdName: string; - serial?: string; - timeoutMs: number; -}): Promise { - const startedAt = Date.now(); - // Poll cadence scales with the caller's budget: a 1Hz sample rate against a - // small budget wastes most of it between checks (a 5s budget deserves 250ms - // sampling), while large budgets keep the gentle 1s cadence. Floor of 50ms - // keeps tight budgets from busy-spinning. - const pollMs = Math.min( - ANDROID_EMULATOR_BOOT_POLL_MS, - Math.max(50, Math.floor(params.timeoutMs / 20)), - ); - while (Date.now() - startedAt < params.timeoutMs) { - try { - const serial = await findAndroidEmulatorSerialByAvdName(params.avdName, params.serial); - if (serial) { - return { - platform: 'android', - id: serial, - name: params.avdName, - kind: 'emulator', - target: 'mobile', - booted: false, - }; - } - } catch { - // Best-effort polling while adb/emulator process settles. - } - await sleep(pollMs); - } - throw new AppError('COMMAND_FAILED', 'Android emulator did not appear in time', { - avdName: params.avdName, - serial: params.serial, - timeoutMs: params.timeoutMs, - hint: 'Check emulator logs and verify the AVD can start from command line.', - }); -} - -async function findAndroidEmulatorSerialByAvdName( - avdName: string, - serial?: string, -): Promise { - const target = normalizeAndroidName(avdName); - const entries = await listAndroidDeviceEntries(); - const candidates = entries.filter((entry) => { - if (serial && entry.serial !== serial) return false; - return isEmulatorSerial(entry.serial); - }); - - for (const entry of candidates) { - if (normalizeAndroidName(entry.rawModel) === target) { - return entry.serial; - } - const resolvedName = await resolveAndroidDeviceName(entry.serial, entry.rawModel); - if (normalizeAndroidName(resolvedName) === target) { - return entry.serial; - } - } - return undefined; -} - -async function isAndroidBooted(serial: string): Promise { - try { - const result = await readAndroidBootProp(serial); - return result.stdout.trim() === '1'; - } catch { - return false; - } -} - -export async function ensureAndroidEmulatorBooted(params: { - avdName: string; - serial?: string; - timeoutMs?: number; - headless?: boolean; -}): Promise { - await ensureAndroidSdkPathConfigured(); - const requestedAvdName = params.avdName.trim(); - if (!requestedAvdName) { - throw new AppError('INVALID_ARGS', 'Android emulator boot requires a non-empty AVD name.'); - } - const timeoutMs = params.timeoutMs ?? ANDROID_EMULATOR_BOOT_TIMEOUT_MS; - - if (!(await whichCmd('adb'))) { - throw new AppError('TOOL_MISSING', 'adb not found in PATH'); - } - if (!(await whichCmd('emulator'))) { - throw new AppError('TOOL_MISSING', 'emulator not found in PATH'); - } - - const avdNames = await listAndroidAvdNames(); - const resolvedAvdName = resolveAndroidAvdName(avdNames, requestedAvdName); - if (!resolvedAvdName) { - throw new AppError('DEVICE_NOT_FOUND', `No Android emulator AVD named ${params.avdName}`, { - requestedAvdName, - availableAvds: avdNames, - hint: 'Run `emulator -list-avds` and pass an existing AVD name to --device.', - }); - } - - const startedAt = Date.now(); - const existing = findAndroidEmulatorByAvdName( - await listAndroidDevices(), - resolvedAvdName, - params.serial, - ); - const runningExisting = existing && isEmulatorSerial(existing.id) ? existing : undefined; - if (!runningExisting) { - const launchArgs = ['-avd', resolvedAvdName]; - if (params.headless) { - launchArgs.push('-no-window', '-no-audio'); - } - runCmdDetached('emulator', launchArgs); - } - - const discovered = - runningExisting ?? - (await waitForAndroidEmulatorByAvdName({ - avdName: resolvedAvdName, - serial: params.serial, - timeoutMs, - })); - - const elapsedMs = Date.now() - startedAt; - const remainingMs = Math.max(1_000, timeoutMs - elapsedMs); - await waitForAndroidBoot(discovered.id, remainingMs); - const refreshed = (await listAndroidDevices()).find((device) => device.id === discovered.id); - if (refreshed) { - return { - ...refreshed, - name: resolvedAvdName, - booted: true, - }; - } - return { - ...discovered, - name: resolvedAvdName, - booted: true, - }; -} - -export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise { - const timeoutBudget = timeoutMs; - const deadline = Deadline.fromTimeoutMs(timeoutBudget); - // Aim for at least 20 polls without busy-looping, capped at the production cadence. - const pollMs = Math.min(ANDROID_BOOT_POLL_MS, Math.max(50, Math.floor(timeoutBudget / 20))); - const maxAttempts = Math.max(1, Math.ceil(timeoutBudget / pollMs)); - let lastBootResult: ExecResult | undefined; - let timedOut = false; - try { - await retryWithPolicy( - async ({ deadline: attemptDeadline }) => { - if (attemptDeadline?.isExpired()) { - timedOut = true; - throw new AppError('COMMAND_FAILED', 'Android boot deadline exceeded', { - serial, - timeoutMs, - elapsedMs: deadline.elapsedMs(), - message: 'timeout', - }); - } - const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? timeoutBudget); - const result = await readAndroidBootProp( - serial, - Math.min(remainingMs, ANDROID_BOOT_PROP_TIMEOUT_MS), - ); - lastBootResult = result; - if (result.stdout.trim() === '1') return; - // exec-guard-allow: getprop exits 0 while the device is still booting; - // the throw is a retry-loop poll miss, not a process-exit wrap. - throw new AppError('COMMAND_FAILED', 'Android device is still booting', { - serial, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); - }, - { - maxAttempts, - baseDelayMs: pollMs, - maxDelayMs: pollMs, - jitter: 0, - shouldRetry: (error) => { - const reason = classifyBootFailure({ - error, - stdout: lastBootResult?.stdout, - stderr: lastBootResult?.stderr, - context: { platform: 'android', phase: 'boot' }, - }); - return reason !== 'ADB_TRANSPORT_UNAVAILABLE' && reason !== 'ANDROID_BOOT_TIMEOUT'; - }, - }, - { - deadline, - phase: 'boot', - classifyReason: (error) => - classifyBootFailure({ - error, - stdout: lastBootResult?.stdout, - stderr: lastBootResult?.stderr, - context: { platform: 'android', phase: 'boot' }, - }), - }, - ); - } catch (error) { - const appErr = asAppError(error); - const stdout = lastBootResult?.stdout; - const stderr = lastBootResult?.stderr; - const exitCode = lastBootResult?.exitCode; - let reason = classifyBootFailure({ - error, - stdout, - stderr, - context: { platform: 'android', phase: 'boot' }, - }); - if (reason === 'BOOT_COMMAND_FAILED' && appErr.message === 'Android device is still booting') { - reason = 'ANDROID_BOOT_TIMEOUT'; - } - const baseDetails = { - serial, - timeoutMs: timeoutBudget, - elapsedMs: deadline.elapsedMs(), - reason, - hint: bootFailureHint(reason), - stdout, - stderr, - exitCode, - }; - if (timedOut || reason === 'ANDROID_BOOT_TIMEOUT') { - throw new AppError( - 'COMMAND_FAILED', - 'Android device did not finish booting in time', - baseDetails, - ); - } - if (appErr.code === 'TOOL_MISSING') { - throw new AppError('TOOL_MISSING', appErr.message, { - ...baseDetails, - ...(appErr.details ?? {}), - }); - } - if (reason === 'ADB_TRANSPORT_UNAVAILABLE') { - throw new AppError('COMMAND_FAILED', appErr.message, { - ...baseDetails, - ...(appErr.details ?? {}), - }); - } - throw new AppError( - appErr.code, - appErr.message, - { - ...baseDetails, - ...(appErr.details ?? {}), - }, - appErr.cause, - ); - } -} diff --git a/src/platforms/android/emulator-lifecycle.ts b/src/platforms/android/emulator-lifecycle.ts new file mode 100644 index 0000000000..735ae4ee5e --- /dev/null +++ b/src/platforms/android/emulator-lifecycle.ts @@ -0,0 +1,290 @@ +import { + isAndroidEmulatorSerial, + normalizeAndroidDeviceName, + type DeviceInventoryRequest, +} from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError, asAppError } from '@agent-device/kernel/errors'; +import type { ExecResult } from '../../utils/exec.ts'; +import { runCmd, runCmdDetached, whichCmd } from '../../utils/exec.ts'; +import { Deadline, retryWithPolicy } from '../../utils/retry.ts'; +import { sleep } from '../../utils/timeouts.ts'; +import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts'; +import { ensureAndroidSdkPathConfigured } from './sdk.ts'; + +const ANDROID_BOOT_POLL_MS = 1_000; +const ANDROID_BOOT_PROP_TIMEOUT_MS = 10_000; +const ANDROID_EMULATOR_BOOT_POLL_MS = 1_000; +const ANDROID_EMULATOR_BOOT_TIMEOUT_MS = 120_000; + +export type AndroidEmulatorLifecycleDependencies = Readonly<{ + discoverLocal: (request: DeviceInventoryRequest) => Promise; + androidSerialAllowlist?: readonly string[]; +}>; + +export async function ensureAndroidEmulatorBooted( + params: Readonly<{ + avdName: string; + serial?: string; + timeoutMs?: number; + headless?: boolean; + }>, + dependencies: AndroidEmulatorLifecycleDependencies, +): Promise { + await ensureAndroidSdkPathConfigured(); + const requestedAvdName = params.avdName.trim(); + if (!requestedAvdName) { + throw new AppError('INVALID_ARGS', 'Android emulator boot requires a non-empty AVD name.'); + } + const timeoutMs = params.timeoutMs ?? ANDROID_EMULATOR_BOOT_TIMEOUT_MS; + if (!(await whichCmd('adb'))) throw new AppError('TOOL_MISSING', 'adb not found in PATH'); + if (!(await whichCmd('emulator'))) { + throw new AppError('TOOL_MISSING', 'emulator not found in PATH'); + } + + const available = await discoverAndroidEmulators(dependencies, params.serial); + const selected = findEmulatorByAvdName(available, requestedAvdName, params.serial); + if (!selected) { + throw new AppError('DEVICE_NOT_FOUND', `No Android emulator AVD named ${params.avdName}`, { + requestedAvdName, + availableAvds: available.map((device) => device.name), + hint: 'Run `emulator -list-avds` and pass an existing AVD name to --device.', + }); + } + + const resolvedAvdName = selected.name; + const startedAt = Date.now(); + const runningExisting = isRunningEmulator(selected) ? selected : undefined; + if (!runningExisting) { + const launchArgs = ['-avd', resolvedAvdName]; + if (params.headless) launchArgs.push('-no-window', '-no-audio'); + runCmdDetached('emulator', launchArgs); + } + + const discovered = + runningExisting ?? + (await waitForEmulatorDiscovery({ + dependencies, + avdName: resolvedAvdName, + serial: params.serial, + timeoutMs, + })); + const elapsedMs = Date.now() - startedAt; + await waitForAndroidBoot(discovered.id, Math.max(1_000, timeoutMs - elapsedMs)); + + const refreshed = (await discoverAndroidEmulators(dependencies, params.serial)).find( + (device) => device.id === discovered.id, + ); + return { + ...(refreshed ?? discovered), + name: resolvedAvdName, + booted: true, + }; +} + +async function discoverAndroidEmulators( + dependencies: AndroidEmulatorLifecycleDependencies, + serial?: string, +): Promise { + return await dependencies.discoverLocal({ + platform: 'android', + kind: 'emulator', + ...(serial ? { serial } : {}), + ...(dependencies.androidSerialAllowlist + ? { androidSerialAllowlist: [...dependencies.androidSerialAllowlist] } + : {}), + }); +} + +function findEmulatorByAvdName( + devices: readonly DeviceInfo[], + avdName: string, + serial?: string, +): DeviceInfo | undefined { + const normalizedName = normalizeAndroidDeviceName(avdName); + return devices.find( + (device) => + device.platform === 'android' && + device.kind === 'emulator' && + normalizeAndroidDeviceName(device.name) === normalizedName && + (!serial || !isRunningEmulator(device) || device.id === serial), + ); +} + +function isRunningEmulator(device: DeviceInfo): boolean { + return isAndroidEmulatorSerial(device.id); +} + +async function waitForEmulatorDiscovery(params: { + dependencies: AndroidEmulatorLifecycleDependencies; + avdName: string; + serial?: string; + timeoutMs: number; +}): Promise { + const startedAt = Date.now(); + const pollMs = Math.min( + ANDROID_EMULATOR_BOOT_POLL_MS, + Math.max(50, Math.floor(params.timeoutMs / 20)), + ); + while (Date.now() - startedAt < params.timeoutMs) { + try { + const devices = await discoverAndroidEmulators(params.dependencies, params.serial); + const device = findEmulatorByAvdName(devices, params.avdName, params.serial); + if (device && isRunningEmulator(device)) return device; + } catch { + // Best-effort polling while adb and the emulator process settle. + } + await sleep(pollMs); + } + throw new AppError('COMMAND_FAILED', 'Android emulator did not appear in time', { + avdName: params.avdName, + serial: params.serial, + timeoutMs: params.timeoutMs, + hint: 'Check emulator logs and verify the AVD can start from command line.', + }); +} + +async function readAndroidBootProp( + serial: string, + timeoutMs = ANDROID_BOOT_PROP_TIMEOUT_MS, +): Promise { + return await runCmd('adb', ['-s', serial, 'shell', 'getprop', 'sys.boot_completed'], { + allowFailure: true, + timeoutMs, + }); +} + +export async function waitForAndroidBoot(serial: string, timeoutMs = 60_000): Promise { + const deadline = Deadline.fromTimeoutMs(timeoutMs); + const pollMs = Math.min(ANDROID_BOOT_POLL_MS, Math.max(50, Math.floor(timeoutMs / 20))); + const maxAttempts = Math.max(1, Math.ceil(timeoutMs / pollMs)); + let lastBootResult: ExecResult | undefined; + let timedOut = false; + try { + await retryWithPolicy( + async ({ deadline: attemptDeadline }) => { + if (attemptDeadline?.isExpired()) { + timedOut = true; + throw new AppError('COMMAND_FAILED', 'Android boot deadline exceeded', { + serial, + timeoutMs, + elapsedMs: deadline.elapsedMs(), + message: 'timeout', + }); + } + const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? timeoutMs); + const result = await readAndroidBootProp( + serial, + Math.min(remainingMs, ANDROID_BOOT_PROP_TIMEOUT_MS), + ); + lastBootResult = result; + if (result.stdout.trim() === '1') return; + // exec-guard-allow: getprop exits 0 while the device is still booting; + // the throw is a retry-loop poll miss, not a process-exit wrap. + throw new AppError('COMMAND_FAILED', 'Android device is still booting', { + serial, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + }, + { + maxAttempts, + baseDelayMs: pollMs, + maxDelayMs: pollMs, + jitter: 0, + shouldRetry: (error) => { + const reason = classifyBootFailure({ + error, + stdout: lastBootResult?.stdout, + stderr: lastBootResult?.stderr, + context: { platform: 'android', phase: 'boot' }, + }); + return reason !== 'ADB_TRANSPORT_UNAVAILABLE' && reason !== 'ANDROID_BOOT_TIMEOUT'; + }, + }, + { + deadline, + phase: 'boot', + classifyReason: (error) => + classifyBootFailure({ + error, + stdout: lastBootResult?.stdout, + stderr: lastBootResult?.stderr, + context: { platform: 'android', phase: 'boot' }, + }), + }, + ); + } catch (error) { + throwAndroidBootFailure({ error, serial, timeoutMs, deadline, lastBootResult, timedOut }); + } +} + +function throwAndroidBootFailure(params: { + error: unknown; + serial: string; + timeoutMs: number; + deadline: Deadline; + lastBootResult?: ExecResult; + timedOut: boolean; +}): never { + const appError = asAppError(params.error); + const reason = normalizedAndroidBootFailureReason(params, appError); + throw buildAndroidBootError( + appError, + reason, + params.timedOut, + buildAndroidBootFailureDetails(params, reason), + ); +} + +function normalizedAndroidBootFailureReason( + params: Parameters[0], + appError: AppError, +): ReturnType { + const classified = classifyBootFailure({ + error: params.error, + stdout: params.lastBootResult?.stdout, + stderr: params.lastBootResult?.stderr, + context: { platform: 'android', phase: 'boot' }, + }); + return classified === 'BOOT_COMMAND_FAILED' && + appError.message === 'Android device is still booting' + ? 'ANDROID_BOOT_TIMEOUT' + : classified; +} + +function buildAndroidBootFailureDetails( + params: Parameters[0], + reason: ReturnType, +) { + return { + serial: params.serial, + timeoutMs: params.timeoutMs, + elapsedMs: params.deadline.elapsedMs(), + reason, + hint: bootFailureHint(reason), + stdout: params.lastBootResult?.stdout, + stderr: params.lastBootResult?.stderr, + exitCode: params.lastBootResult?.exitCode, + ...(asAppError(params.error).details ?? {}), + }; +} + +function buildAndroidBootError( + appError: AppError, + reason: ReturnType, + timedOut: boolean, + details: ReturnType, +): AppError { + if (timedOut || reason === 'ANDROID_BOOT_TIMEOUT') { + return new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', details); + } + if (appError.code === 'TOOL_MISSING') { + return new AppError('TOOL_MISSING', appError.message, details); + } + if (reason === 'ADB_TRANSPORT_UNAVAILABLE') { + return new AppError('COMMAND_FAILED', appError.message, details); + } + return new AppError(appError.code, appError.message, details, appError.cause); +} diff --git a/src/platforms/android/ime-lifecycle.ts b/src/platforms/android/ime-lifecycle.ts index b296d9cb93..4066d35c42 100644 --- a/src/platforms/android/ime-lifecycle.ts +++ b/src/platforms/android/ime-lifecycle.ts @@ -389,7 +389,7 @@ function normalizeSettingsValue(raw: string): string { return trimmed; } -// Serials only, no per-device name/booted/target lookups (unlike listAndroidDevices()). +// Serials only, with no full-inventory name, boot-state, or target probes. export async function listAndroidAdbSerialsQuick(): Promise { try { const result = await runCmd('adb', ['devices'], { timeoutMs: 5_000 }); diff --git a/src/platforms/apple/core/__tests__/devices.test.ts b/src/platforms/apple/core/__tests__/devices.test.ts deleted file mode 100644 index 58917b91c3..0000000000 --- a/src/platforms/apple/core/__tests__/devices.test.ts +++ /dev/null @@ -1,768 +0,0 @@ -import { isIosFamily } from '@agent-device/kernel/device'; -import { beforeEach, test } from 'vitest'; -import assert from 'node:assert/strict'; -import { promises as fs } from 'node:fs'; -import { - isAppleProductType, - isAppleTvProductType, - isSupportedAppleDevicectlDevice, - listAppleDevices, - listBootedIosSimulators, - parseXctracePhysicalAppleDevices, - resolveAppleTargetFromDevicectlDevice, -} from '../devices.ts'; -import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts'; -import type { ExecResult } from '../../../../utils/exec.ts'; - -const toolCalls: Array<[string, string[]]> = []; -let mockRunCommand: (cmd: string, args: string[]) => Promise; -let mockWhichCommand: (cmd: string) => Promise; - -beforeEach(() => { - toolCalls.length = 0; - mockRunCommand = async () => ({ stdout: '', stderr: '', exitCode: 0 }); - mockWhichCommand = async () => true; -}); - -async function withMockedPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { - const original = process.platform; - // Some Apple discovery paths are gated on process.platform at runtime, so - // these unit tests temporarily override it and always restore it in finally. - Object.defineProperty(process, 'platform', { value: platform, configurable: true }); - try { - return await fn(); - } finally { - Object.defineProperty(process, 'platform', { value: original, configurable: true }); - } -} - -function createSimctlDevicesPayload() { - return JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ - { - name: 'iPhone 16', - udid: 'sim-1', - state: 'Booted', - isAvailable: true, - }, - ], - }, - }); -} - -test('resolveAppleTargetFromDevicectlDevice detects tvOS from platform', () => { - const target = resolveAppleTargetFromDevicectlDevice({ - hardwareProperties: { platform: 'tvOS' }, - deviceProperties: { name: 'Living Room' }, - }); - assert.equal(target, 'tv'); -}); - -test('resolveAppleTargetFromDevicectlDevice detects AppleTV from product type', () => { - const target = resolveAppleTargetFromDevicectlDevice({ - hardwareProperties: { platform: '' }, - deviceProperties: { name: 'Living Room', productType: 'AppleTV11,1' }, - }); - assert.equal(target, 'tv'); -}); - -test('isSupportedAppleDevicectlDevice handles renamed AppleTV devices', () => { - assert.equal( - isSupportedAppleDevicectlDevice({ - hardwareProperties: { platform: '' }, - deviceProperties: { name: 'Living Room', productType: 'AppleTV11,1' }, - }), - true, - ); -}); - -test('apple product type helpers classify iOS and tvOS product families', () => { - assert.equal(isAppleProductType('iPhone16,2'), true); - assert.equal(isAppleProductType('AppleTV11,1'), true); - assert.equal(isAppleProductType('RealityDevice14,1'), true); - assert.equal(isAppleTvProductType('AppleTV11,1'), true); - assert.equal(isAppleTvProductType('iPhone16,2'), false); -}); - -test('listAppleDevices orders simulators by iPhone, iPad, tvOS, then physical devices', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.join(' ') === 'simctl list devices -j') { - return { - stdout: JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [ - { - name: 'Apple TV 4K (3rd generation)', - udid: 'tvos-sim', - state: 'Shutdown', - isAvailable: true, - }, - ], - 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ - { - name: 'iPad Pro 13-inch', - udid: 'ipad-sim', - state: 'Shutdown', - isAvailable: true, - }, - { - name: 'iPhone 16', - udid: 'iphone-sim', - state: 'Shutdown', - isAvailable: true, - }, - ], - }, - }), - stderr: '', - exitCode: 0, - }; - } - - if (args[0] === 'devicectl' && args[1] === 'list' && args[2] === 'devices') { - const jsonPath = String(args[4]); - await fs.writeFile( - jsonPath, - JSON.stringify({ - result: { - devices: [ - { - name: 'My iPhone', - hardwareProperties: { - platform: 'iOS', - udid: 'physical-iphone', - productType: 'iPhone16,2', - }, - }, - ], - }, - }), - 'utf8', - ); - return { stdout: '', stderr: '', exitCode: 0 }; - } - - if (args.join(' ') === 'xctrace list devices') { - return { stdout: '== Devices ==', stderr: '', exitCode: 0 }; - } - - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const devices = await withMockedPlatform( - 'darwin', - async () => await withMockedAppleTools(async () => await listAppleDevices()), - ); - - assert.deepEqual( - devices.slice(0, 4).map((device) => device.id), - ['iphone-sim', 'ipad-sim', 'tvos-sim', 'physical-iphone'], - ); -}); - -test('parseXctracePhysicalAppleDevices parses only physical devices from the Devices section', () => { - const parsed = parseXctracePhysicalAppleDevices( - [ - '== Devices ==', - 'My iPhone [00008020-001C2D2234567890]', - 'Living Room Apple TV [tv-udid-1]', - 'Studio Mac [mac-udid-1]', - '== Devices Offline ==', - 'Unknown', - 'Offline iPhone [offline-udid]', - '== Simulators ==', - 'iPhone 16 (18.0) (sim-1)', - ].join('\n'), - ); - - assert.deepEqual(parsed, [ - { - platform: 'apple', - id: '00008020-001C2D2234567890', - name: 'My iPhone', - kind: 'device', - target: 'mobile', - appleOs: 'ios', - iosPhysicalDeviceBackend: 'xctest', - booted: true, - }, - { - platform: 'apple', - id: 'tv-udid-1', - name: 'Living Room Apple TV', - kind: 'device', - target: 'tv', - appleOs: 'tvos', - iosPhysicalDeviceBackend: 'xctest', - booted: true, - }, - ]); -}); - -test('parseXctracePhysicalAppleDevices tags physical iPads as iPadOS', () => { - const parsed = parseXctracePhysicalAppleDevices( - ['== Devices ==', 'Studio iPad Pro [ipad-udid-1]'].join('\n'), - ); - assert.deepEqual(parsed, [ - { - platform: 'apple', - id: 'ipad-udid-1', - name: 'Studio iPad Pro', - kind: 'device', - target: 'mobile', - appleOs: 'ipados', - iosPhysicalDeviceBackend: 'xctest', - booted: true, - }, - ]); -}); - -test('parseXctracePhysicalAppleDevices tags Apple Vision devices as visionOS', () => { - const parsed = parseXctracePhysicalAppleDevices( - ['== Devices ==', 'Apple Vision Pro [vision-udid-1]'].join('\n'), - ); - assert.deepEqual(parsed, [ - { - platform: 'apple', - id: 'vision-udid-1', - name: 'Apple Vision Pro', - kind: 'device', - target: 'mobile', - appleOs: 'visionos', - iosPhysicalDeviceBackend: 'xctest', - booted: true, - }, - ]); -}); - -test('parseXctracePhysicalAppleDevices parses the parenthesized physical device format', () => { - const parsed = parseXctracePhysicalAppleDevices( - [ - '== Devices ==', - 'iPhone 8 Plus (16.7.16) (00008020-001C2D2234567890)', - 'Studio iPad Pro (17.0) (ipad-udid-1)', - 'Living Room Apple TV (16.0) (tv-udid-1)', - ].join('\n'), - ); - - assert.deepEqual(parsed, [ - { - platform: 'apple', - id: '00008020-001C2D2234567890', - name: 'iPhone 8 Plus', - kind: 'device', - target: 'mobile', - appleOs: 'ios', - iosPhysicalDeviceBackend: 'xctest', - booted: true, - }, - { - platform: 'apple', - id: 'ipad-udid-1', - name: 'Studio iPad Pro', - kind: 'device', - target: 'mobile', - appleOs: 'ipados', - iosPhysicalDeviceBackend: 'xctest', - booted: true, - }, - { - platform: 'apple', - id: 'tv-udid-1', - name: 'Living Room Apple TV', - kind: 'device', - target: 'tv', - appleOs: 'tvos', - iosPhysicalDeviceBackend: 'xctest', - booted: true, - }, - ]); -}); - -test('parseXctracePhysicalAppleDevices preserves parentheses in bracket-format names', () => { - const parsed = parseXctracePhysicalAppleDevices( - ['== Devices ==', "Alex's (iPhone) [alex-udid]", 'Office iPhone (2) [office-udid]'].join('\n'), - ); - - assert.deepEqual(parsed, [ - { - platform: 'apple', - id: 'alex-udid', - name: "Alex's (iPhone)", - kind: 'device', - target: 'mobile', - appleOs: 'ios', - iosPhysicalDeviceBackend: 'xctest', - booted: true, - }, - { - platform: 'apple', - id: 'office-udid', - name: 'Office iPhone (2)', - kind: 'device', - target: 'mobile', - appleOs: 'ios', - iosPhysicalDeviceBackend: 'xctest', - booted: true, - }, - ]); -}); - -test('listAppleDevices tags devicectl iPad product types as iPadOS', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.join(' ') === 'simctl list devices -j') { - return { stdout: createSimctlDevicesPayload(), stderr: '', exitCode: 0 }; - } - - if (args[0] === 'devicectl' && args[1] === 'list' && args[2] === 'devices') { - const jsonPath = String(args[4]); - await fs.writeFile( - jsonPath, - JSON.stringify({ - result: { - devices: [ - { - name: 'Field iPad', - hardwareProperties: { platform: 'iOS', udid: 'ipad-1', productType: 'iPad14,3' }, - }, - ], - }, - }), - 'utf8', - ); - return { stdout: '', stderr: '', exitCode: 0 }; - } - - if (args.join(' ') === 'xctrace list devices') { - return { stdout: '', stderr: '', exitCode: 0 }; - } - - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const devices = await withMockedPlatform( - 'darwin', - async () => await withMockedAppleTools(async () => await listAppleDevices()), - ); - - const iPad = devices.find((device) => device.id === 'ipad-1'); - assert.equal(iPad?.target, 'mobile'); - assert.equal(iPad?.appleOs, 'ipados'); -}); - -test('listAppleDevices tags iPhone simulators and the host Mac with appleOs', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.includes('simctl') && args.includes('list') && args.includes('devices')) { - return { - stdout: JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ - { name: 'iPhone 16', udid: 'sim-iphone', state: 'Booted', isAvailable: true }, - { - name: 'iPad Pro 11-inch (M4)', - udid: 'sim-ipad', - state: 'Shutdown', - isAvailable: true, - }, - ], - 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [ - { name: 'Apple TV 4K', udid: 'sim-tv', state: 'Shutdown', isAvailable: true }, - ], - }, - }), - stderr: '', - exitCode: 0, - }; - } - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const devices = await withMockedPlatform( - 'darwin', - async () => - await withMockedAppleTools( - async () => await listAppleDevices({ simulatorSetPath: '/tmp/agent-device-sim-set' }), - ), - ); - - const byId = new Map(devices.map((device) => [device.id, device.appleOs])); - assert.equal(byId.get('sim-iphone'), 'ios'); - assert.equal(byId.get('sim-ipad'), 'ipados'); - assert.equal(byId.get('sim-tv'), 'tvos'); - assert.equal(byId.get('host-macos-local'), 'macos'); -}); - -test('listAppleDevices tags visionOS simulators', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.includes('simctl') && args.includes('list') && args.includes('devices')) { - return { - stdout: JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.xrOS-26-2': [ - { - name: 'Apple Vision Pro', - udid: 'sim-vision', - state: 'Shutdown', - isAvailable: true, - deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.Apple-Vision-Pro-4K', - }, - ], - }, - }), - stderr: '', - exitCode: 0, - }; - } - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const devices = await withMockedPlatform( - 'darwin', - async () => - await withMockedAppleTools( - async () => await listAppleDevices({ simulatorSetPath: '/tmp/agent-device-sim-set' }), - ), - ); - - const vision = devices.find((device) => device.id === 'sim-vision'); - assert.equal(vision?.target, 'mobile'); - assert.equal(vision?.appleOs, 'visionos'); -}); - -test('listAppleDevices tags renamed iPad simulators as iPadOS from deviceTypeIdentifier', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.includes('simctl') && args.includes('list') && args.includes('devices')) { - return { - stdout: JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ - { - // Display name no longer mentions "iPad" (user-renamed), so the - // classification must come from deviceTypeIdentifier. - name: 'Work Tablet', - udid: 'sim-renamed-ipad', - state: 'Shutdown', - isAvailable: true, - deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M4', - }, - ], - }, - }), - stderr: '', - exitCode: 0, - }; - } - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const devices = await withMockedPlatform( - 'darwin', - async () => - await withMockedAppleTools( - async () => await listAppleDevices({ simulatorSetPath: '/tmp/agent-device-sim-set' }), - ), - ); - - const ipad = devices.find((device) => device.id === 'sim-renamed-ipad'); - assert.equal(ipad?.target, 'mobile'); - assert.equal(ipad?.appleOs, 'ipados'); -}); - -test('listAppleDevices supplements unsupported devicectl entries with xctrace physical devices', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.join(' ') === 'simctl list devices -j') { - return { stdout: createSimctlDevicesPayload(), stderr: '', exitCode: 0 }; - } - - if (args[0] === 'devicectl' && args[1] === 'list' && args[2] === 'devices') { - const jsonPath = String(args[4]); - await fs.writeFile( - jsonPath, - JSON.stringify({ - result: { - devices: [ - { - identifier: 'legacy-ecid', - connectionProperties: { tunnelState: 'unavailable' }, - deviceProperties: { bootState: 'booted' }, - }, - ], - }, - }), - 'utf8', - ); - return { stdout: '', stderr: '', exitCode: 0 }; - } - - if (args.join(' ') === 'xctrace list devices') { - return { - stdout: ['== Devices ==', 'My iPhone X [00008020-001C2D2234567890]'].join('\n'), - stderr: '', - exitCode: 0, - }; - } - - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const devices = await withMockedPlatform( - 'darwin', - async () => await withMockedAppleTools(async () => await listAppleDevices()), - ); - - assert.equal( - devices.some((device) => device.kind === 'device' && device.id === '00008020-001C2D2234567890'), - true, - ); - assert.equal( - devices.some((device) => device.id === 'host-macos-local'), - true, - ); - assert.equal( - devices.some((device) => device.kind === 'simulator' && device.id === 'sim-1'), - true, - ); -}); - -test('listAppleDevices prefers devicectl metadata when xctrace reports the same physical device', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.join(' ') === 'simctl list devices -j') { - return { stdout: createSimctlDevicesPayload(), stderr: '', exitCode: 0 }; - } - - if (args[0] === 'devicectl' && args[1] === 'list' && args[2] === 'devices') { - const jsonPath = String(args[4]); - await fs.writeFile( - jsonPath, - JSON.stringify({ - result: { - devices: [ - { - name: 'Primary Name', - hardwareProperties: { - platform: 'iOS', - udid: '00008020-001C2D2234567890', - productType: 'iPhone16,2', - }, - }, - ], - }, - }), - 'utf8', - ); - return { stdout: '', stderr: '', exitCode: 0 }; - } - - if (args.join(' ') === 'xctrace list devices') { - return { - stdout: ['== Devices ==', 'Fallback Name [00008020-001C2D2234567890]'].join('\n'), - stderr: '', - exitCode: 0, - }; - } - - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const devices = await withMockedPlatform( - 'darwin', - async () => await withMockedAppleTools(async () => await listAppleDevices()), - ); - const physicalDevices = devices.filter( - (device) => device.kind === 'device' && isIosFamily(device), - ); - - assert.equal(physicalDevices.length, 1); - assert.equal(physicalDevices[0]?.name, 'Primary Name'); -}); - -test('listAppleDevices falls back to xctrace parenthesized devices when devicectl reports none', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.join(' ') === 'simctl list devices -j') { - return { stdout: createSimctlDevicesPayload(), stderr: '', exitCode: 0 }; - } - - if (args[0] === 'devicectl' && args[1] === 'list' && args[2] === 'devices') { - const jsonPath = String(args[4]); - await fs.writeFile(jsonPath, JSON.stringify({ result: { devices: [] } }), 'utf8'); - return { stdout: '', stderr: '', exitCode: 0 }; - } - - if (args.join(' ') === 'xctrace list devices') { - return { - stdout: ['== Devices ==', 'iPhone 8 Plus (16.7.16) (00008020-001C2D2234567890)'].join('\n'), - stderr: '', - exitCode: 0, - }; - } - - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const devices = await withMockedPlatform( - 'darwin', - async () => await withMockedAppleTools(async () => await listAppleDevices()), - ); - - const physicalDevices = devices.filter( - (device) => device.kind === 'device' && isIosFamily(device), - ); - - assert.equal(physicalDevices.length, 1); - assert.equal(physicalDevices[0]?.id, '00008020-001C2D2234567890'); - assert.equal(physicalDevices[0]?.name, 'iPhone 8 Plus'); - assert.equal(physicalDevices[0]?.iosPhysicalDeviceBackend, 'xctest'); -}); - -test('listAppleDevices keeps physical discovery disabled for simulator-set scoped runs', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.includes('simctl') && args.includes('list') && args.includes('devices')) { - return { stdout: createSimctlDevicesPayload(), stderr: '', exitCode: 0 }; - } - - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const devices = await withMockedPlatform( - 'darwin', - async () => - await withMockedAppleTools( - async () => await listAppleDevices({ simulatorSetPath: '/tmp/agent-device-sim-set' }), - ), - ); - - assert.equal( - devices.some((device) => device.id === 'host-macos-local'), - true, - ); - assert.equal( - devices.some((device) => device.kind === 'device' && isIosFamily(device)), - false, - ); - assert.equal( - toolCalls.some(([, args]) => args.includes('devicectl') || args.includes('xctrace')), - false, - ); -}); - -test('listAppleDevices skips physical discovery when explicit udid matches a simulator', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.join(' ') === 'simctl list devices -j') { - return { stdout: createSimctlDevicesPayload(), stderr: '', exitCode: 0 }; - } - - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const devices = await withMockedPlatform( - 'darwin', - async () => await withMockedAppleTools(async () => await listAppleDevices({ udid: 'sim-1' })), - ); - - assert.equal( - devices.some((device) => device.kind === 'simulator' && device.id === 'sim-1'), - true, - ); - assert.equal( - toolCalls.some(([, args]) => args.includes('devicectl') || args.includes('xctrace')), - false, - ); -}); - -test('listAppleDevices keeps physical discovery when explicit udid is not a simulator', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.join(' ') === 'simctl list devices -j') { - return { stdout: createSimctlDevicesPayload(), stderr: '', exitCode: 0 }; - } - - if (args[0] === 'devicectl' && args[1] === 'list' && args[2] === 'devices') { - const jsonPath = String(args[4]); - await fs.writeFile(jsonPath, JSON.stringify({ result: { devices: [] } }), 'utf8'); - return { stdout: '', stderr: '', exitCode: 0 }; - } - - if (args.join(' ') === 'xctrace list devices') { - return { - stdout: ['== Devices ==', 'My iPhone [physical-1]'].join('\n'), - stderr: '', - exitCode: 0, - }; - } - - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const devices = await withMockedPlatform( - 'darwin', - async () => - await withMockedAppleTools(async () => await listAppleDevices({ udid: 'physical-1' })), - ); - - assert.equal( - devices.some((device) => device.kind === 'device' && device.id === 'physical-1'), - true, - ); - assert.equal( - toolCalls.some(([, args]) => args.includes('xctrace')), - true, - ); -}); - -test('listBootedIosSimulators returns only booted simulators, not shutdown ones or the host Mac', async () => { - mockRunCommand = async (_cmd, args) => { - if (args.join(' ') === 'simctl list devices -j') { - return { - stdout: JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ - { name: 'iPhone 16', udid: 'sim-booted', state: 'Booted', isAvailable: true }, - { name: 'iPhone 15', udid: 'sim-shutdown', state: 'Shutdown', isAvailable: true }, - ], - }, - }), - stderr: '', - exitCode: 0, - }; - } - throw new Error(`unexpected xcrun args: ${args.join(' ')}`); - }; - - const booted = await withMockedAppleTools(async () => await listBootedIosSimulators()); - - assert.deepEqual( - booted.map((device) => device.id), - ['sim-booted'], - ); -}); - -test('listBootedIosSimulators returns an empty list instead of throwing on tool failure', async () => { - mockRunCommand = async () => { - throw new Error('xcrun not found'); - }; - - const booted = await withMockedAppleTools(async () => await listBootedIosSimulators()); - - assert.deepEqual(booted, []); -}); - -test('listBootedIosSimulators returns an empty list on malformed simctl JSON', async () => { - mockRunCommand = async () => ({ stdout: 'not json', stderr: '', exitCode: 0 }); - - const booted = await withMockedAppleTools(async () => await listBootedIosSimulators()); - - assert.deepEqual(booted, []); -}); - -async function withMockedAppleTools(fn: () => Promise): Promise { - return await withAppleToolProvider( - createLocalAppleToolProvider({ - whichCommand: async (cmd) => await mockWhichCommand(cmd), - runCommand: async (cmd, args) => { - toolCalls.push([cmd, args]); - return await mockRunCommand(cmd, args); - }, - }), - fn, - ); -} diff --git a/src/platforms/apple/core/__tests__/physical-device-control.test.ts b/src/platforms/apple/core/__tests__/physical-device-control.test.ts index 418c6046f1..9270d7b139 100644 --- a/src/platforms/apple/core/__tests__/physical-device-control.test.ts +++ b/src/platforms/apple/core/__tests__/physical-device-control.test.ts @@ -11,7 +11,7 @@ import { closeIosApp, openIosApp } from '../app-launch.ts'; import { listIosApps } from '../app-resolution.ts'; import { resolveIosPhysicalDeviceControl } from '../physical-device-control.ts'; import { withAppleRunnerProvider } from '../runner/runner-provider.ts'; -import { withAppleToolProvider } from '../tool-provider.ts'; +import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts'; import { mkdtempForTest } from '../../../../__tests__/test-utils/tmp-dir.ts'; const IOS_DEVICE: DeviceInfo = { @@ -34,10 +34,12 @@ test('XCTest readiness uses xcdevice instead of devicectl', async () => { const calls: Array<{ cmd: string; args: string[] }> = []; await withAppleToolProvider( - async (cmd, args) => { - calls.push({ cmd, args }); - return { exitCode: 0, stdout: '', stderr: '' }; - }, + createLocalAppleToolProvider({ + runCommand: async (cmd, args) => { + calls.push({ cmd, args }); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }), async () => await resolveIosPhysicalDeviceControl(XCTEST_IOS_DEVICE).ensureReady(XCTEST_IOS_DEVICE), ); @@ -95,10 +97,12 @@ test('default interactor screenshots stay in-band without invoking devicectl', a const toolCalls: string[][] = []; try { await withAppleToolProvider( - async (_cmd, args) => { - toolCalls.push(args); - return { exitCode: 0, stdout: '', stderr: '' }; - }, + createLocalAppleToolProvider({ + runCommand: async (_cmd, args) => { + toolCalls.push(args); + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }), async () => await withAppleRunnerProvider( async (_device, command) => { diff --git a/src/platforms/apple/core/devices.ts b/src/platforms/apple/core/devices.ts deleted file mode 100644 index 457fb7f5db..0000000000 --- a/src/platforms/apple/core/devices.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { AppError } from '@agent-device/kernel/errors'; -import { - isIosFamily, - sortAppleDevicesForSelection, - type AppleOS, - type DeviceInfo, - type DeviceTarget, -} from '@agent-device/kernel/device'; -import { resolveIosSimulatorDeviceSetPath } from '../../../utils/device-isolation.ts'; -import { buildHostMacDevice } from '../os/macos/devices.ts'; -import { buildSimctlArgs } from './simctl.ts'; -import { markSimulatorBooted } from './simulator.ts'; -import { resolveAppleToolProvider, runXcrun } from './tool-provider.ts'; - -const IOS_DEVICECTL_LIST_TIMEOUT_MS = 8_000; -const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|realitydevice)/i; -const APPLE_IPAD_PATTERN = /ipad/i; -const APPLE_VISION_PATTERN = /\b(apple vision|vision pro|xros|visionos|realitydevice)\b/i; -const APPLE_MOBILE_LABEL_PATTERN = /\b(iphone|ipad|ipod)\b/i; -const APPLE_TV_PRODUCT_TYPE_PATTERN = /^appletv/i; -const APPLE_TV_LABEL_HINTS = ['apple tv', 'appletv', 'tvos'] as const; - -type SimctlDeviceRecord = { - name: string; - udid: string; - state: string; - isAvailable: boolean; - // Stable simulator device-type id (e.g. - // com.apple.CoreSimulator.SimDeviceType.iPad-Pro-11-inch-M4). Preferred over - // the user-editable display name when classifying the Apple OS. - deviceTypeIdentifier?: string; -}; - -type SimctlListDevicesPayload = { - devices: Record; -}; - -type DevicectlAppleDevice = { - identifier?: string; - name?: string; - hardwareProperties?: { platform?: string; udid?: string; productType?: string }; - deviceProperties?: { name?: string; productType?: string; deviceType?: string }; - connectionProperties?: { tunnelState?: string }; -}; - -type DevicectlListDevicesPayload = { - result?: { - devices?: DevicectlAppleDevice[]; - }; -}; - -type IosDeviceDiscoveryOptions = { - simulatorSetPath?: string; - udid?: string; -}; - -const XCTRACE_SECTION_HEADER_PATTERN = /^==\s*(.+?)\s*==$/; -const XCTRACE_DEVICE_LINE_PATTERN = - /^(?.+?)\s+(?:\[(?[^[\]]+)\]|\((?[^)]+)\)\s+\((?[^)]+)\))\s*$/; - -function normalizeAppleDescriptor(value: string | undefined): string { - return (value ?? '').trim().toLowerCase(); -} - -function resolveAppleRuntime(runtime: string): string { - return normalizeAppleDescriptor(runtime); -} - -function resolveDevicectlApplePlatform(device: DevicectlAppleDevice): string { - return normalizeAppleDescriptor(device.hardwareProperties?.platform); -} - -function isAppleTvPlatform(platform: string): boolean { - return platform.includes('tvos'); -} - -function isAppleVisionPlatform(platform: string): boolean { - return platform.includes('xros') || platform.includes('visionos'); -} - -function resolveAppleTargetFromRuntime(runtime: string): DeviceTarget { - return isAppleTvPlatform(resolveAppleRuntime(runtime)) ? 'tv' : 'mobile'; -} - -function isSupportedAppleRuntime(runtime: string): boolean { - const normalized = resolveAppleRuntime(runtime); - return ( - normalized.includes('ios') || - normalized.includes('tvos') || - normalized.includes('xros') || - normalized.includes('visionos') - ); -} - -function isAppleTvLabel(value: string): boolean { - const normalized = normalizeAppleDescriptor(value); - return APPLE_TV_LABEL_HINTS.some((hint) => normalized.includes(hint)); -} - -function isAppleMobileLabel(value: string): boolean { - return APPLE_MOBILE_LABEL_PATTERN.test(value.trim()); -} - -function resolveAppleTargetFromLabel(value: string): DeviceTarget | null { - if (isAppleTvLabel(value)) return 'tv'; - if (isAppleMobileLabel(value) || APPLE_VISION_PATTERN.test(value)) return 'mobile'; - return null; -} - -/** - * Derives the explicit Apple OS discriminant at discovery from the already - * resolved device target plus any available descriptors (product type and/or - * names). This is strictly additive metadata: it never widens discovery - * filters and never changes runner SDK/destination selection. - * - * tv targets map to tvOS; mobile targets split into iPadOS (when an iPad - * descriptor is present) and iOS otherwise. - */ -function resolveAppleOs(target: DeviceTarget, descriptors: string[]): AppleOS { - if (target === 'tv') return 'tvos'; - if (descriptors.some((descriptor) => APPLE_VISION_PATTERN.test(descriptor))) return 'visionos'; - if (descriptors.some((descriptor) => APPLE_IPAD_PATTERN.test(descriptor))) return 'ipados'; - return 'ios'; -} - -export function isAppleProductType(productType: string): boolean { - return APPLE_PRODUCT_TYPE_PATTERN.test(productType.trim()); -} - -export function isAppleTvProductType(productType: string): boolean { - return APPLE_TV_PRODUCT_TYPE_PATTERN.test(productType.trim()); -} - -function resolveDevicectlAppleLabels(device: DevicectlAppleDevice): string[] { - return [ - device.name ?? '', - device.deviceProperties?.name ?? '', - device.deviceProperties?.deviceType ?? '', - ]; -} - -function resolveDevicectlAppleProductType(device: DevicectlAppleDevice): string { - return device.hardwareProperties?.productType ?? device.deviceProperties?.productType ?? ''; -} - -export function resolveAppleTargetFromDevicectlDevice(device: DevicectlAppleDevice): DeviceTarget { - const platform = resolveDevicectlApplePlatform(device); - if (isAppleTvPlatform(platform)) return 'tv'; - const productType = resolveDevicectlAppleProductType(device); - if (isAppleTvProductType(productType)) return 'tv'; - return resolveDevicectlAppleLabels(device).some(isAppleTvLabel) ? 'tv' : 'mobile'; -} - -export function isSupportedAppleDevicectlDevice(device: DevicectlAppleDevice): boolean { - const platform = resolveDevicectlApplePlatform(device); - if (platform.includes('ios') || platform.includes('tvos') || isAppleVisionPlatform(platform)) { - return true; - } - const productType = resolveDevicectlAppleProductType(device); - if (isAppleProductType(productType)) return true; - return resolveDevicectlAppleLabels(device).some(isAppleTvLabel); -} - -type FindBootableSimulatorOptions = IosDeviceDiscoveryOptions & { - target?: DeviceTarget; -}; - -/** - * Finds an available iOS simulator by querying simctl directly. This is used - * as a fallback when `listIosDevices` returned no simulators (e.g. all filtered - * out) or only a physical device. Only simulators with `isAvailable: true` are - * considered so the caller can safely boot the result. - * - * Returns `null` when no suitable simulator can be found. - */ -export async function findBootableIosSimulator( - options: FindBootableSimulatorOptions = {}, -): Promise { - const simulatorSetPath = resolveIosSimulatorDeviceSetPath(options.simulatorSetPath); - const targetFilter = options.target; - - let simResult; - try { - simResult = await runXcrun(buildSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath })); - } catch { - return null; - } - - let payload: SimctlListDevicesPayload; - try { - payload = JSON.parse(simResult.stdout as string) as SimctlListDevicesPayload; - } catch { - return null; - } - - const simulators = sortAppleDevicesForSelection( - parseSimctlAppleDevices(payload, simulatorSetPath), - ); - return simulators.find((simulator) => !targetFilter || simulator.target === targetFilter) ?? null; -} - -// Bounded so an error-path probe (e.g. enriching a SESSION_NOT_FOUND hint) -// never turns into a long wait — `simctl list` normally answers in well under -// a second (see the memo comment in simulator.ts). -const IOS_BOOTED_SIMULATOR_PROBE_TIMEOUT_MS = 3_000; - -/** - * Lists every currently booted iOS simulator via a single `simctl list` - * call. Unlike `findBootableIosSimulator`, this returns all booted matches - * instead of the first sorted candidate — callers that need to detect - * ambiguity (more than one booted device) read this directly rather than - * inferring it from device resolution, which silently picks one. - * - * Returns `[]` on any discovery failure (tool missing, timeout, bad JSON) so - * callers on an error path can treat "unknown" the same as "ambiguous" and - * fall back safely. - */ -export async function listBootedIosSimulators( - options: IosDeviceDiscoveryOptions = {}, -): Promise { - const simulatorSetPath = resolveIosSimulatorDeviceSetPath(options.simulatorSetPath); - - let simResult; - try { - simResult = await runXcrun(buildSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath }), { - timeoutMs: IOS_BOOTED_SIMULATOR_PROBE_TIMEOUT_MS, - }); - } catch { - return []; - } - - try { - const payload = JSON.parse(simResult.stdout as string) as SimctlListDevicesPayload; - return parseSimctlAppleDevices(payload, simulatorSetPath).filter( - (device) => device.kind === 'simulator' && device.booted === true, - ); - } catch { - return []; - } -} - -function parseSimctlAppleDevices( - payload: SimctlListDevicesPayload, - simulatorSetPath: string | undefined, -): DeviceInfo[] { - const devices: DeviceInfo[] = []; - for (const [runtime, runtimes] of Object.entries(payload.devices)) { - if (!isSupportedAppleRuntime(runtime)) continue; - for (const device of runtimes) { - if (!device.isAvailable) continue; - const target = resolveAppleTargetFromRuntime(runtime); - const parsed: DeviceInfo = { - platform: 'apple', - id: device.udid, - name: device.name, - kind: 'simulator', - target, - // Prefer the stable device-type id so a user-renamed iPad simulator is - // still tagged iPadOS; fall back to the display name when it is absent. - appleOs: resolveAppleOs(target, [device.deviceTypeIdentifier ?? '', device.name]), - booted: device.state === 'Booted', - ...(simulatorSetPath ? { simulatorSetPath } : {}), - }; - if (parsed.booted) markSimulatorBooted(parsed); - devices.push(parsed); - } - } - return devices; -} - -function mapDevicectlAppleDevices(payload: DevicectlListDevicesPayload): DeviceInfo[] { - const devices: DeviceInfo[] = []; - for (const device of payload.result?.devices ?? []) { - if (!isSupportedAppleDevicectlDevice(device)) continue; - const id = device.hardwareProperties?.udid ?? device.identifier ?? ''; - const name = device.name ?? device.deviceProperties?.name ?? id; - if (!id) continue; - const target = resolveAppleTargetFromDevicectlDevice(device); - devices.push({ - platform: 'apple', - id, - name, - kind: 'device', - target, - appleOs: resolveAppleOs(target, [ - resolveDevicectlAppleProductType(device), - ...resolveDevicectlAppleLabels(device), - ]), - iosPhysicalDeviceBackend: 'coredevice', - booted: true, - }); - } - return devices; -} - -function parseXctraceDeviceLine( - line: string, -): { name: string; id: string; osVersion?: string } | null { - const match = XCTRACE_DEVICE_LINE_PATTERN.exec(line); - if (!match?.groups) return null; - - const name = match.groups.name?.trim() ?? ''; - const id = match.groups.idBracket?.trim() ?? match.groups.idParen?.trim() ?? ''; - const osVersion = match.groups.osVersion?.trim(); - if (!name || !id) return null; - - return { name, id, osVersion }; -} - -function buildXctracePhysicalDevice( - name: string, - id: string, - osVersion: string | undefined, -): DeviceInfo | null { - const target = resolveAppleTargetFromLabel(name); - if (!target) return null; - - return { - platform: 'apple', - id, - name, - kind: 'device', - target, - appleOs: resolveAppleOs(target, osVersion ? [name, osVersion] : [name]), - iosPhysicalDeviceBackend: 'xctest', - // xctrace lists currently connected devices in the "Devices" section. - // The "Devices Offline" section is excluded above, so treating these as - // booted preserves the existing physical-device selection semantics. - booted: true, - }; -} - -export function parseXctracePhysicalAppleDevices(output: string): DeviceInfo[] { - const devices: DeviceInfo[] = []; - let section: string | null = null; - - for (const rawLine of output.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line) continue; - - const sectionMatch = XCTRACE_SECTION_HEADER_PATTERN.exec(line); - if (sectionMatch) { - section = sectionMatch[1]?.trim() ?? null; - continue; - } - - if (section !== 'Devices') continue; - - const parsedLine = parseXctraceDeviceLine(line); - if (!parsedLine) continue; - - const device = buildXctracePhysicalDevice(parsedLine.name, parsedLine.id, parsedLine.osVersion); - if (device) devices.push(device); - } - - return devices; -} - -function mergeAppleDevices(primary: DeviceInfo[], supplemental: DeviceInfo[]): DeviceInfo[] { - const ids = new Set(primary.map((device) => device.id)); - const merged = [...primary]; - - for (const device of supplemental) { - if (ids.has(device.id)) continue; - ids.add(device.id); - merged.push(device); - } - - return merged; -} - -async function listApplePhysicalDevicesFromDevicectl(): Promise { - let jsonPath: string | null = null; - try { - jsonPath = path.join( - os.tmpdir(), - `agent-device-devicectl-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); - const devicectlResult = await runXcrun( - ['devicectl', 'list', 'devices', '--json-output', jsonPath], - { - allowFailure: true, - timeoutMs: IOS_DEVICECTL_LIST_TIMEOUT_MS, - }, - ); - if (devicectlResult.exitCode !== 0) { - return []; - } - const jsonText = await fs.readFile(jsonPath, 'utf8'); - return mapDevicectlAppleDevices(JSON.parse(jsonText) as DevicectlListDevicesPayload); - } catch { - // Ignore devicectl discovery failures so simulator and xctrace-based - // Apple discovery can still succeed. - return []; - } finally { - if (jsonPath) { - await fs.rm(jsonPath, { force: true }).catch(() => {}); - } - } -} - -async function listApplePhysicalDevicesFromXctrace(): Promise { - try { - const result = await runXcrun(['xctrace', 'list', 'devices'], { allowFailure: true }); - if (result.exitCode !== 0) return []; - return parseXctracePhysicalAppleDevices(result.stdout); - } catch { - // Ignore xctrace failures so modern CoreDevice discovery remains the - // source of truth when available. - return []; - } -} - -export async function listAppleDevices( - options: IosDeviceDiscoveryOptions = {}, -): Promise { - if (process.platform !== 'darwin') { - throw new AppError('UNSUPPORTED_PLATFORM', 'Apple tools are only available on macOS'); - } - - if (!(await resolveAppleToolProvider().whichCommand('xcrun'))) { - throw new AppError('TOOL_MISSING', 'xcrun not found in PATH'); - } - - const simulatorSetPath = resolveIosSimulatorDeviceSetPath(options.simulatorSetPath); - - const simResult = await runXcrun( - buildSimctlArgs(['list', 'devices', '-j'], { simulatorSetPath }), - ); - let devices: DeviceInfo[] = []; - try { - const payload = JSON.parse(simResult.stdout as string) as SimctlListDevicesPayload; - devices = parseSimctlAppleDevices(payload, simulatorSetPath); - } catch (err) { - throw new AppError('COMMAND_FAILED', 'Failed to parse simctl devices JSON', undefined, err); - } - - devices.push(buildHostMacDevice()); - if (options.udid && devices.some((device) => isIosFamily(device) && device.id === options.udid)) { - return devices; - } - - // When a simulator set is configured, keep iOS discovery strictly scoped to that set. - // Do not enumerate host-global physical devices, but keep the local Mac available - // because desktop targeting is independent of simulator sets. - if (simulatorSetPath) { - return sortAppleDevicesForSelection(devices); - } - - const [devicectlDevices, xctraceDevices] = await Promise.all([ - listApplePhysicalDevicesFromDevicectl(), - listApplePhysicalDevicesFromXctrace(), - ]); - - devices = mergeAppleDevices(devices, devicectlDevices); - return sortAppleDevicesForSelection(mergeAppleDevices(devices, xctraceDevices)); -} diff --git a/src/platforms/apple/core/tool-provider.ts b/src/platforms/apple/core/tool-provider.ts index 3bef590116..ba8dfd88f1 100644 --- a/src/platforms/apple/core/tool-provider.ts +++ b/src/platforms/apple/core/tool-provider.ts @@ -55,9 +55,7 @@ const localAppleToolProvider: AppleToolProvider = { whichCommand: whichCmd, }; -type AppleToolProviderInput = AppleToolProvider | AppleToolCommandExecutor; - -const appleToolProviderScope = createScopedProvider( +const appleToolProviderScope = createScopedProvider( localAppleToolProvider, normalizeAppleToolProvider, ); @@ -92,12 +90,12 @@ export function createLocalAppleToolProvider( }; } -export function resolveAppleToolProvider(provider?: AppleToolProviderInput): AppleToolProvider { +export function resolveAppleToolProvider(provider?: AppleToolProvider): AppleToolProvider { return appleToolProviderScope.resolve(provider); } export async function withAppleToolProvider( - provider: AppleToolProviderInput | undefined, + provider: AppleToolProvider | undefined, fn: () => Promise, ): Promise { return await appleToolProviderScope.run(provider, fn); @@ -135,10 +133,7 @@ export async function readApplePlistJson( return (await resolveAppleToolProvider().plist?.readJson(plistPath)) ?? null; } -function normalizeAppleToolProvider(provider: AppleToolProviderInput): AppleToolProvider { - if (typeof provider === 'function') { - return createLocalAppleToolProvider({ runCommand: coerceRunCommand(provider) }); - } +function normalizeAppleToolProvider(provider: AppleToolProvider): AppleToolProvider { return createLocalAppleToolProvider({ ...provider, runCommand: coerceRunCommand(provider.runCommand), diff --git a/src/platforms/apple/os/macos/devices.ts b/src/platforms/apple/os/macos/devices.ts deleted file mode 100644 index 354c2a58e9..0000000000 --- a/src/platforms/apple/os/macos/devices.ts +++ /dev/null @@ -1,20 +0,0 @@ -import os from 'node:os'; -import type { DeviceInfo } from '@agent-device/kernel/device'; - -const HOST_MAC_DEVICE_ID = 'host-macos-local'; - -export function buildHostMacDevice(): DeviceInfo { - return { - platform: 'apple', - id: HOST_MAC_DEVICE_ID, - name: os.hostname(), - kind: 'device', - target: 'desktop', - appleOs: 'macos', - booted: true, - }; -} - -export async function listMacosDevices(): Promise { - return [buildHostMacDevice()]; -} diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 755a448412..fe354aef10 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -2,10 +2,6 @@ import { appleOsCapabilities } from './capabilities.ts'; import type { PlatformPlugin } from '@agent-device/contracts/platform'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { isAudioProbeSupportedDevice } from '@agent-device/contracts/platform'; -import { - shouldUseHostMacFastPath, - type DeviceInventoryRequest, -} from '@agent-device/contracts/device'; import { isMacOs, isTvOsDevice, @@ -141,11 +137,8 @@ function coreDeviceOnlyPhysicalOperationHint(device: DeviceInfo): string | undef return 'This command requires a CoreDevice-backed physical iOS device. The selected XCTest backend supports open, close, interactions, snapshots, and screenshots.'; } -// The Apple plugin WRAPS today's existing factories (its `createInteractor` in -// `./interactor.ts`) and the inventory if-chain (src/core/platform-inventory.ts) as -// LAZY methods. No leaf code is rewritten: the dynamic `import()`s and the per-platform -// list calls are byte-for-byte the same as the hand-authored `getInteractor` switch arm -// and `listLocalDeviceInventory` branch. `as const satisfies PlatformPlugin` preserves +// The Apple plugin wraps today's existing interactor factory lazily. +// `as const satisfies PlatformPlugin` preserves // the plugin's literal `platforms` tuple so the registry totality assertion (in // `core/interactors/register-builtins.ts`) is a real compile-time check. @@ -185,17 +178,4 @@ export const applePlugin = { const { createAppleInteractor } = await import('./interactor.ts'); return createAppleInteractor(device, runner); }, - // Reproduces the macOS host fast-path + Apple-simulator branch of the - // inventory if-chain, reusing the SAME predicate (no divergent copy). - discoverDevices: async (request: DeviceInventoryRequest) => { - if (shouldUseHostMacFastPath(request)) { - const { listMacosDevices } = await import('./os/macos/devices.ts'); - return await listMacosDevices(); - } - const { listAppleDevices } = await import('./core/devices.ts'); - return await listAppleDevices({ - simulatorSetPath: request.iosSimulatorSetPath, - udid: request.udid, - }); - }, } as const satisfies PlatformPlugin; diff --git a/src/platforms/harmonyos/__tests__/devices.test.ts b/src/platforms/harmonyos/__tests__/devices.test.ts deleted file mode 100644 index cc7c087419..0000000000 --- a/src/platforms/harmonyos/__tests__/devices.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import assert from 'node:assert/strict'; -import { beforeEach, test, vi } from 'vitest'; - -const { ensureHdcAvailable, runHarmonyHdc, runCmd } = vi.hoisted(() => ({ - ensureHdcAvailable: vi.fn(), - runHarmonyHdc: vi.fn(), - runCmd: vi.fn(), -})); - -vi.mock('../hdc.ts', () => ({ - ensureHdcAvailable, - harmonyDeviceForTarget: (id: string, options: { name: string; emulator: boolean }) => ({ - platform: 'harmonyos', - id, - name: options.name, - kind: options.emulator ? 'emulator' : 'device', - target: 'mobile', - booted: true, - }), - runHarmonyHdc, -})); -vi.mock('../../../utils/exec.ts', () => ({ runCmd })); - -import { listHarmonyDevices, parseHarmonyTargetList } from '../devices.ts'; - -beforeEach(() => { - ensureHdcAvailable.mockReset(); - runHarmonyHdc.mockReset(); - runCmd.mockReset(); -}); - -test('parseHarmonyTargetList keeps HDC target ids and ignores its empty sentinel', () => { - assert.deepEqual(parseHarmonyTargetList('\n127.0.0.1:5555\n9CN0224725020054\n[Empty]\n'), [ - '127.0.0.1:5555', - '9CN0224725020054', - ]); -}); - -test('listHarmonyDevices probes name and characteristics concurrently for every target', async () => { - runCmd.mockResolvedValue({ - exitCode: 0, - stdout: '127.0.0.1:5555\n9CN0224725020054\n', - stderr: '', - }); - runHarmonyHdc - .mockResolvedValueOnce({ exitCode: 0, stdout: 'emulator\n', stderr: '' }) - .mockResolvedValueOnce({ exitCode: 0, stdout: 'phone\n', stderr: '' }) - .mockResolvedValueOnce({ exitCode: 0, stdout: 'HUAWEI Mate 60\n', stderr: '' }) - .mockResolvedValueOnce({ exitCode: 0, stdout: 'tv\n', stderr: '' }); - - assert.deepEqual(await listHarmonyDevices(), [ - { - platform: 'harmonyos', - id: '127.0.0.1:5555', - name: 'emulator', - kind: 'emulator', - target: 'mobile', - booted: true, - }, - { - platform: 'harmonyos', - id: '9CN0224725020054', - name: 'HUAWEI Mate 60', - kind: 'device', - target: 'tv', - booted: true, - }, - ]); - assert.equal(ensureHdcAvailable.mock.calls.length, 1); - assert.deepEqual(runCmd.mock.calls[0]?.[1], ['list', 'targets']); - assert.equal(runHarmonyHdc.mock.calls.length, 4); -}); - -test('listHarmonyDevices reports HDC target-list failures with recovery guidance', async () => { - runCmd.mockResolvedValue({ exitCode: 1, stdout: '', stderr: 'transport unavailable' }); - - await assert.rejects(() => listHarmonyDevices({ serial: 'missing' }), /hdc list targets failed/); -}); diff --git a/src/platforms/harmonyos/devices.ts b/src/platforms/harmonyos/devices.ts deleted file mode 100644 index 42f902195d..0000000000 --- a/src/platforms/harmonyos/devices.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; -import { runCmd } from '../../utils/exec.ts'; -import { ensureHdcAvailable, harmonyDeviceForTarget, runHarmonyHdc } from './hdc.ts'; - -const DEVICE_PROBE_TIMEOUT_MS = 10_000; - -export function parseHarmonyTargetList(rawOutput: string): string[] { - return rawOutput - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0 && line !== '[Empty]'); -} - -export async function listHarmonyDevices(options: { serial?: string } = {}): Promise { - await ensureHdcAvailable(); - const result = await runCmd('hdc', ['list', 'targets'], { - allowFailure: true, - timeoutMs: DEVICE_PROBE_TIMEOUT_MS, - }); - if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'hdc list targets failed', { - stderr: result.stderr, - hint: 'Confirm the HarmonyOS emulator or device is running, then run hdc list targets.', - }); - } - - const targets = parseHarmonyTargetList(result.stdout).filter( - (target) => !options.serial || target === options.serial, - ); - return await Promise.all(targets.map(async (target) => await probeHarmonyDevice(target))); -} - -async function probeHarmonyDevice(target: string): Promise { - const probeDevice = { id: target }; - const [nameResult, characteristicsResult] = await Promise.all([ - runHarmonyHdc(probeDevice, ['shell', 'param', 'get', 'const.product.name'], { - allowFailure: true, - timeoutMs: DEVICE_PROBE_TIMEOUT_MS, - }), - runHarmonyHdc(probeDevice, ['shell', 'param', 'get', 'const.build.characteristics'], { - allowFailure: true, - timeoutMs: DEVICE_PROBE_TIMEOUT_MS, - }), - ]); - const name = nameResult.exitCode === 0 ? nameResult.stdout.trim() : ''; - const characteristics = characteristicsResult.stdout.toLowerCase(); - const emulator = name.toLowerCase() === 'emulator'; - const device = harmonyDeviceForTarget(target, { name: name || target, emulator }); - return characteristics.includes('tv') ? { ...device, target: 'tv' } : device; -} diff --git a/src/platforms/harmonyos/hdc.ts b/src/platforms/harmonyos/hdc.ts index c61c6096a5..4b24ede5b0 100644 --- a/src/platforms/harmonyos/hdc.ts +++ b/src/platforms/harmonyos/hdc.ts @@ -35,7 +35,7 @@ export async function ensureHdcAvailable(): Promise { * DevEco's command-line tools do not amend PATH for non-interactive processes. * Honor the documented roots so the daemon sees the same HDC binary as a shell. */ -async function ensureHarmonyToolchainPathConfigured( +export async function ensureHarmonyToolchainPathConfigured( env: NodeJS.ProcessEnv = process.env, ): Promise { const toolchainRoots = [ diff --git a/src/platforms/linux/devices.ts b/src/platforms/linux/devices.ts deleted file mode 100644 index c090d16bc0..0000000000 --- a/src/platforms/linux/devices.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { hostname } from 'node:os'; -import type { DeviceInfo } from '@agent-device/kernel/device'; - -export async function listLinuxDevices(): Promise { - if (process.platform !== 'linux') { - return []; - } - - return [ - { - platform: 'linux', - id: 'local', - name: hostname(), - kind: 'device', - target: 'desktop', - booted: true, - }, - ]; -} diff --git a/src/platforms/vega/__tests__/devices.test.ts b/src/platforms/vega/__tests__/devices.test.ts deleted file mode 100644 index 647be729d1..0000000000 --- a/src/platforms/vega/__tests__/devices.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; -import { listVegaDevices, parseVegaDeviceList } from '../devices.ts'; -import { createLocalVegaToolProvider, withVegaToolProvider } from '../tool-provider.ts'; - -test('parseVegaDeviceList normalizes VVD entries and excludes unvalidated physical TVs', () => { - const devices = parseVegaDeviceList(` -Found the following devices: -G071R20720350DT6 : A1ZZ32RVTQ796E -VirtualDevice : tv - aarch64 - VegaOS - workstation -`); - - assert.deepEqual(devices, [ - { - platform: 'vega', - id: 'VirtualDevice', - name: 'Vega Virtual Device (VirtualDevice)', - kind: 'emulator', - target: 'tv', - booted: true, - }, - ]); -}); - -test('parseVegaDeviceList accepts the VirtualDevice identifier and de-duplicates serials', () => { - const devices = parseVegaDeviceList(` -VirtualDevice device -VirtualDevice : tv - aarch64 - VegaOS - workstation -VirtualDevice : tv - aarch64 - VegaOS - workstation -`); - - assert.deepEqual(devices, [ - { - platform: 'vega', - id: 'VirtualDevice', - name: 'Vega Virtual Device (VirtualDevice)', - kind: 'emulator', - target: 'tv', - booted: true, - }, - ]); -}); - -test('listVegaDevices invokes VDA through Vega with a bounded discovery call', async () => { - const calls: Array<{ args: string[]; allowFailure?: boolean; timeoutMs?: number }> = []; - const provider = createLocalVegaToolProvider({ - isAvailable: async () => true, - run: async (args, options) => { - calls.push({ args, allowFailure: options?.allowFailure, timeoutMs: options?.timeoutMs }); - return { - exitCode: 0, - stdout: 'VirtualDevice : tv - aarch64 - VegaOS - workstation\n', - stderr: '', - }; - }, - }); - - const devices = await withVegaToolProvider(provider, async () => await listVegaDevices()); - - assert.equal(devices[0]?.id, 'VirtualDevice'); - assert.deepEqual(calls, [ - { - args: ['device', 'list'], - allowFailure: true, - timeoutMs: 10_000, - }, - ]); -}); - -test('listVegaDevices reports a typed tool-missing error', async () => { - const provider = createLocalVegaToolProvider({ - isAvailable: async () => false, - run: async () => { - throw new Error('unexpected command'); - }, - }); - - await assert.rejects( - withVegaToolProvider(provider, async () => await listVegaDevices()), - (error: unknown) => - error instanceof AppError && - error.code === 'TOOL_MISSING' && - error.details?.hint === 'Install Vega Developer Tools, source ~/vega/env, and retry devices.', - ); -}); - -test('listVegaDevices explains when only unsupported physical devices are attached', async () => { - const provider = createLocalVegaToolProvider({ - isAvailable: async () => true, - run: async () => ({ - exitCode: 0, - stdout: 'G071R20720350DT6 : A1ZZ32RVTQ796E\n', - stderr: '', - }), - }); - - await assert.rejects( - withVegaToolProvider(provider, async () => await listVegaDevices()), - (error: unknown) => - error instanceof AppError && - error.code === 'DEVICE_NOT_FOUND' && - error.message === - 'Vega CLI found devices, but no supported Vega Virtual Device is running.' && - error.details?.hint === - 'Start the Vega Virtual Device, then retry with --platform vega --target tv.', - ); -}); - -test('listVegaDevices preserves structured process failure details', async () => { - const provider = createLocalVegaToolProvider({ - isAvailable: async () => true, - run: async () => ({ - exitCode: 17, - stdout: '', - stderr: 'VDA transport unavailable', - }), - }); - - await assert.rejects( - withVegaToolProvider(provider, async () => await listVegaDevices()), - (error: unknown) => - error instanceof AppError && - error.code === 'COMMAND_FAILED' && - error.details?.processExitError === true && - error.details?.exitCode === 17, - ); -}); diff --git a/src/platforms/vega/__tests__/plugin.test.ts b/src/platforms/vega/__tests__/plugin.test.ts index 3f613b1dac..d334927a49 100644 --- a/src/platforms/vega/__tests__/plugin.test.ts +++ b/src/platforms/vega/__tests__/plugin.test.ts @@ -1,12 +1,9 @@ import assert from 'node:assert/strict'; -import { beforeEach, test, vi } from 'vitest'; +import { test } from 'vitest'; import { PUBLIC_COMMANDS } from '../../../command-catalog.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { listVegaDevices } from '../devices.ts'; import { vegaPlugin } from '../plugin.ts'; -vi.mock('../devices.ts', () => ({ listVegaDevices: vi.fn() })); - const VEGA_VVD = { platform: 'vega', id: 'VirtualDevice', @@ -28,12 +25,6 @@ const VEGA_NON_TV: DeviceInfo = { target: 'mobile', }; -const mockListVegaDevices = vi.mocked(listVegaDevices); - -beforeEach(() => { - mockListVegaDevices.mockReset(); -}); - test('Vega plugin owns the Vega platform and tv-remote capability bucket', () => { assert.deepEqual(vegaPlugin.platforms, ['vega']); assert.equal(vegaPlugin.capability.bucket, 'vega'); @@ -71,17 +62,6 @@ test('Vega plugin owns the Vega platform and tv-remote capability bucket', () => } }); -test('Vega plugin delegates discovery to the Vega inventory', async () => { - const devices = [VEGA_VVD]; - mockListVegaDevices.mockResolvedValue(devices); - - const discovered = await vegaPlugin.discoverDevices({ platform: 'vega', target: 'tv' }); - - assert.equal(discovered, devices); - assert.equal(mockListVegaDevices.mock.calls.length, 1); - assert.deepEqual(mockListVegaDevices.mock.calls[0], []); -}); - test('Vega plugin creates the Vega interactor lazily', async () => { const interactor = await vegaPlugin.createInteractor(VEGA_VVD, {}); diff --git a/src/platforms/vega/devices.ts b/src/platforms/vega/devices.ts deleted file mode 100644 index d35d0135db..0000000000 --- a/src/platforms/vega/devices.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { AppError } from '@agent-device/kernel/errors'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { requireExecSuccess } from '../../utils/exec.ts'; -import { resolveVegaToolProvider } from './tool-provider.ts'; - -const VEGA_DISCOVERY_TIMEOUT_MS = 10_000; -const VEGA_VVD_SERIAL = 'VirtualDevice'; - -export async function listVegaDevices(): Promise { - const provider = resolveVegaToolProvider(); - if (!(await provider.isAvailable())) { - throw new AppError('TOOL_MISSING', 'Vega CLI not found in PATH', { - hint: 'Install Vega Developer Tools, source ~/vega/env, and retry devices.', - }); - } - - const result = requireExecSuccess( - await provider.listDevices({ - allowFailure: true, - timeoutMs: VEGA_DISCOVERY_TIMEOUT_MS, - }), - 'Failed to list Vega devices', - ); - const devices = parseVegaDeviceList(result.stdout); - const listedSerials = parseListedVegaSerials(result.stdout); - if (devices.length === 0 && listedSerials.length > 0) { - throw new AppError( - 'DEVICE_NOT_FOUND', - 'Vega CLI found devices, but no supported Vega Virtual Device is running.', - { - listedSerials, - hint: 'Start the Vega Virtual Device, then retry with --platform vega --target tv.', - }, - ); - } - return devices; -} - -export function parseVegaDeviceList(rawOutput: string): DeviceInfo[] { - if (!parseListedVegaSerials(rawOutput).some(isVegaVirtualDevice)) return []; - return [ - { - platform: 'vega', - id: VEGA_VVD_SERIAL, - name: `Vega Virtual Device (${VEGA_VVD_SERIAL})`, - kind: 'emulator', - target: 'tv', - booted: true, - }, - ]; -} - -function isVegaVirtualDevice(serial: string): boolean { - return serial.toLowerCase() === VEGA_VVD_SERIAL.toLowerCase(); -} - -function parseListedVegaSerials(rawOutput: string): string[] { - const serials = new Set(); - for (const rawLine of rawOutput.split(/\r?\n/)) { - const match = /^\s*([^\s:]+)\s*:\s*\S/.exec(rawLine); - const serial = match?.[1]?.trim(); - if (serial) serials.add(serial); - } - return [...serials]; -} diff --git a/src/platforms/vega/plugin.ts b/src/platforms/vega/plugin.ts index 9cc1d07dd1..3db0dc0afe 100644 --- a/src/platforms/vega/plugin.ts +++ b/src/platforms/vega/plugin.ts @@ -1,5 +1,4 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; import type { PlatformPlugin } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -46,8 +45,4 @@ export const vegaPlugin = { const { createVegaInteractor } = await import('./interactor.ts'); return createVegaInteractor(device, runnerContext); }, - discoverDevices: async (_request: DeviceInventoryRequest): Promise => { - const { listVegaDevices } = await import('./devices.ts'); - return await listVegaDevices(); - }, } as const satisfies PlatformPlugin; diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index d3793bdf29..d91012013b 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -1,5 +1,5 @@ import type { - DeviceInventoryProvider, + ProviderDeviceInventorySource, DeviceLease, LeaseLifecycleContext, LeaseLifecycleProvider, @@ -37,7 +37,7 @@ export type ProviderDeviceRuntimeRequestProviders = { leaseLifecycleProvider?: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifactProvider?: CloudArtifactProvider; - deviceInventoryProvider?: DeviceInventoryProvider; + deviceInventorySource?: ProviderDeviceInventorySource; appleRunnerProvider?: AppleRunnerProviderResolver; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; }; @@ -129,6 +129,7 @@ export function createProviderDeviceRuntimeRequestProviders( runtimes: ProviderDeviceRuntime[], options: { providerRuntimeRequiredIds?: readonly string[] } = {}, ): ProviderDeviceRuntimeRequestProviders { + assertUniqueProviderRuntimeIds(runtimes); const providerRuntimeIds = runtimes.map((runtime) => runtime.provider); return { providerRuntimeIds, @@ -142,7 +143,7 @@ export function createProviderDeviceRuntimeRequestProviders( .map((runtime) => runtime.provider), recoverExpiredLease: composeExpiredLeaseRecovery(runtimes), cloudArtifactProvider: composeCloudArtifactProvider(runtimes), - deviceInventoryProvider: composeDeviceInventoryProvider(runtimes), + deviceInventorySource: composeDeviceInventorySource(runtimes), appleRunnerProvider: composeAppleRunnerProviderResolver(runtimes), providerDeviceRuntimeScope: async (task) => await withProviderDeviceRuntimeScope(runtimes, task), @@ -217,20 +218,36 @@ function composeCloudArtifactProvider( }; } -function composeDeviceInventoryProvider( +function composeDeviceInventorySource( runtimes: ProviderDeviceRuntime[], -): DeviceInventoryProvider | undefined { +): ProviderDeviceInventorySource | undefined { if (runtimes.length === 0) return undefined; - return async (request) => { - for (const runtime of runtimes) { - if (!runtimeMatchesProvider(runtime, request.leaseProvider)) continue; - const devices = await runtime.deviceInventoryProvider(request); - if (devices) return devices; - } - return null; + return { + discover: async (request, signal) => { + signal.throwIfAborted(); + for (const runtime of runtimes) { + if (!runtimeMatchesProvider(runtime, request.leaseProvider)) continue; + const devices = await runtime.deviceInventoryProvider(request, signal); + signal.throwIfAborted(); + if (devices !== null && devices !== undefined) { + return { kind: 'inventory', devices }; + } + } + return { kind: 'declined' }; + }, }; } +function assertUniqueProviderRuntimeIds(runtimes: readonly ProviderDeviceRuntime[]): void { + const seen = new Set(); + for (const runtime of runtimes) { + if (seen.has(runtime.provider)) { + throw new TypeError(`Duplicate provider device runtime: ${runtime.provider}`); + } + seen.add(runtime.provider); + } +} + async function firstCloudArtifactsResult( runtimes: ProviderDeviceRuntime[], query: CloudArtifactsQuery, diff --git a/test/integration/nightly/concurrency-torture/bindings.ts b/test/integration/nightly/concurrency-torture/bindings.ts index fdf3bd4f6a..7b63c21677 100644 --- a/test/integration/nightly/concurrency-torture/bindings.ts +++ b/test/integration/nightly/concurrency-torture/bindings.ts @@ -25,7 +25,7 @@ import type { SessionStore } from '../../../../src/daemon/session-store.ts'; import { resolveRequestExecutionLockKeys } from '../../../../src/daemon/request-binding.ts'; import { shouldLockSessionExecution } from '../../../../src/daemon/daemon-command-registry.ts'; import { PUBLIC_COMMANDS } from '../../../../src/command-catalog.ts'; -import { withDeviceInventoryProvider } from '../../../../src/core/dispatch-resolve.ts'; +import { withTestDeviceInventoryProvider as withDeviceInventoryProvider } from '../../../../src/__tests__/test-utils/device-inventory-gateways.ts'; import type { LockKey } from './deterministic-scheduler.ts'; diff --git a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts index aca8d476a5..d88b553901 100644 --- a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts +++ b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts @@ -18,7 +18,7 @@ import path from 'node:path'; import { createRequestExecutionScope } from '../../../../src/daemon/request-execution-scope.ts'; import { LeaseRegistry } from '../../../../src/daemon/lease-registry.ts'; import { SessionStore } from '../../../../src/daemon/session-store.ts'; -import { withDeviceInventoryProvider } from '../../../../src/core/dispatch-resolve.ts'; +import { withTestDeviceInventoryProvider as withDeviceInventoryProvider } from '../../../../src/__tests__/test-utils/device-inventory-gateways.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { DaemonRequest } from '../../../../src/daemon/types.ts'; diff --git a/test/integration/provider-scenarios/cloud-webdriver-ios-text-entry.test.ts b/test/integration/provider-scenarios/cloud-webdriver-ios-text-entry.test.ts index 82153f49d2..584f981565 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-ios-text-entry.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-ios-text-entry.test.ts @@ -204,7 +204,7 @@ async function createCloudIosWorld() { const providers = createProviderDeviceRuntimeRequestProviders(runtimes); const daemon = await createProviderScenarioHarness({ ...providers, - deviceInventoryProvider: providers.deviceInventoryProvider!, + deviceInventorySource: providers.deviceInventorySource!, }); return { daemon, diff --git a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts index b3751fc682..d78f2d8d0a 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-runtime.test.ts @@ -226,7 +226,7 @@ async function createCloudWebDriverWorld() { const providers = createProviderDeviceRuntimeRequestProviders(runtimes); const daemon = await createProviderScenarioHarness({ ...providers, - deviceInventoryProvider: providers.deviceInventoryProvider!, + deviceInventorySource: providers.deviceInventorySource!, }); return { daemon, diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index a71076b8b9..ed9f20d3f6 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -15,6 +15,14 @@ import { LeaseRegistry } from '../../../src/daemon/lease-registry.ts'; import { SessionStore } from '../../../src/daemon/session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../../../src/daemon/types.ts'; import type { ExecResult } from '../../../src/utils/exec.ts'; +import type { + DeviceInventoryProvider, + ProviderDeviceInventorySource, +} from '@agent-device/contracts/device'; +import { + createTestDeviceInventoryGateways, + createTestDeviceInventoryGatewaysFromProvider, +} from '../../../src/__tests__/test-utils/device-inventory-gateways.ts'; const PROVIDER_SCENARIO_TOKEN = 'provider-scenario-token'; const PROVIDER_SCENARIO_TEMP_REMOVE_OPTIONS = { @@ -49,19 +57,27 @@ export type ClosableProviderScenarioResource = { }; export async function createProviderScenarioHarness( - deps: Partial & Pick, + deps: Partial> & + ( + | { deviceInventoryProvider: DeviceInventoryProvider; deviceInventorySource?: never } + | { deviceInventorySource: ProviderDeviceInventorySource; deviceInventoryProvider?: never } + ), ): Promise { const sessionDir = fs.mkdtempSync( path.join(os.tmpdir(), 'agent-device-provider-scenario-session-'), ); const sessionStore = new SessionStore(sessionDir); + const { deviceInventoryProvider, deviceInventorySource, ...routerDeps } = deps; const requestHandler = createRequestHandler({ logPath: path.join(os.tmpdir(), 'agent-device-provider-scenario-daemon.log'), token: PROVIDER_SCENARIO_TOKEN, sessionStore, leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: deviceInventorySource + ? createTestDeviceInventoryGateways({ provider: deviceInventorySource }) + : createTestDeviceInventoryGatewaysFromProvider(deviceInventoryProvider), trackDownloadableArtifact, - ...deps, + ...routerDeps, }); const handleRequest: typeof requestHandler = async (request) => { const response = await requestHandler(request); diff --git a/test/integration/provider-scenarios/linux-world.ts b/test/integration/provider-scenarios/linux-world.ts index f51235da7c..34326ab514 100644 --- a/test/integration/provider-scenarios/linux-world.ts +++ b/test/integration/provider-scenarios/linux-world.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; import assert from 'node:assert/strict'; -import { listLinuxDevices } from '../../../src/platforms/linux/devices.ts'; +import type { PlatformRequestScope } from '@agent-device/contracts/platform'; +import { inventoryModule as linuxInventoryModule } from '@agent-device/platform-linux'; +import { createDeviceInventoryHost } from '../../../src/platform-runtime-host.ts'; import { createLocalLinuxToolProvider } from '../../../src/platforms/linux/tool-provider.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { validPng } from './assertions.ts'; @@ -28,13 +30,13 @@ export async function createLinuxDesktopWorld(): Promise { const previousPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); - assert.deepEqual(await listLinuxDevices(), []); + assert.deepEqual(await discoverLocalLinuxInventory(), []); Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); process.env.XDG_SESSION_TYPE = 'x11'; delete process.env.WAYLAND_DISPLAY; delete process.env.AGENT_DEVICE_HTTP_AUTH_HOOK; - const localLinuxDevices = await listLinuxDevices(); + const localLinuxDevices = await discoverLocalLinuxInventory(); const toolCalls: Array<[string, string[]]> = []; const desktopCalls: Array<[string, string]> = []; const semanticCalls: FlatToolCall[] = []; @@ -150,6 +152,17 @@ export async function createLinuxDesktopWorld(): Promise { }; } +const inventoryScope: PlatformRequestScope = Object.freeze({ + signal: new AbortController().signal, + diagnostics: Object.freeze({ emit: () => {} }), + progress: Object.freeze({ report: () => {} }), +}); + +async function discoverLocalLinuxInventory(): Promise { + const source = await linuxInventoryModule.loadInventory(createDeviceInventoryHost()); + return [...(await source.discover({ platform: 'linux' }, inventoryScope))]; +} + function linuxCalculatorSnapshotNodes(): Array<{ index: number; role: string; diff --git a/test/integration/provider-scenarios/provider-device-runtime.test.ts b/test/integration/provider-scenarios/provider-device-runtime.test.ts index cab4bab8d3..2dbbafeb2b 100644 --- a/test/integration/provider-scenarios/provider-device-runtime.test.ts +++ b/test/integration/provider-scenarios/provider-device-runtime.test.ts @@ -301,7 +301,7 @@ async function createFakeProviderWorld(platform: 'android' | 'ios' = 'android') const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders([runtime]); const daemon = await createProviderScenarioHarness({ ...providerRuntimeProviders, - deviceInventoryProvider: providerRuntimeProviders.deviceInventoryProvider!, + deviceInventorySource: providerRuntimeProviders.deviceInventorySource!, }); return { daemon, diff --git a/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts b/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts index 61989eaa9c..c3fc957b3d 100644 --- a/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts +++ b/test/integration/provider-scenarios/provider-ios-runner-transport.test.ts @@ -185,7 +185,7 @@ async function createRunnerTransportWorld(options: { requestScope: boolean }) { const providers = createProviderDeviceRuntimeRequestProviders([runtime]); const daemon = await createProviderScenarioHarness({ ...providers, - deviceInventoryProvider: providers.deviceInventoryProvider!, + deviceInventorySource: providers.deviceInventorySource!, }); return { daemon, diff --git a/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts b/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts index 8a939271d0..d5caae0ac4 100644 --- a/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts +++ b/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts @@ -98,7 +98,7 @@ async function createProviderIosSelectorWorld() { const providers = createProviderDeviceRuntimeRequestProviders([runtime]); const daemon = await createProviderScenarioHarness({ ...providers, - deviceInventoryProvider: providers.deviceInventoryProvider!, + deviceInventorySource: providers.deviceInventorySource!, }); return { daemon, diff --git a/test/integration/smoke-daemon-http.test.ts b/test/integration/smoke-daemon-http.test.ts index 0156705bcf..3e03998e80 100644 --- a/test/integration/smoke-daemon-http.test.ts +++ b/test/integration/smoke-daemon-http.test.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import { DAEMON_RPC_PROTOCOL_VERSION } from '../../src/daemon/http-health.ts'; import { skipWhenLoopbackUnavailable } from '../../src/__tests__/test-utils/loopback.ts'; import { stopProcessForTakeover } from '../../src/daemon/daemon-process.ts'; +import { formatResultDebug } from './cli-json.ts'; import { runCliJson } from './test-helpers.ts'; type DaemonInfo = { @@ -23,17 +24,25 @@ test('daemon HTTP transport starts from CLI and accepts a command RPC', async (t const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-http-smoke-')); try { - const cli = runCliJson( - ['session', 'list', '--json', '--daemon-transport', 'http', '--state-dir', stateDir], - { + const args = [ + 'session', + 'list', + '--json', + '--daemon-transport', + 'http', + '--state-dir', + stateDir, + ]; + const run = () => + runCliJson(args, { env: { ...process.env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', }, - }, - ); + }); + const cli = run(); - assert.equal(cli.status, 0, `${cli.stderr}\n${cli.stdout}`); + assert.equal(cli.status, 0, formatResultDebug('start HTTP daemon', ['session', 'list'], cli)); assert.equal(cli.json?.success, true, JSON.stringify(cli.json)); const info = readDaemonInfo(stateDir);