diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 7ff3ae3f7a..19a2890e39 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -827,6 +827,25 @@ matching the layering scanner's scope) to `src/platforms/**` and concrete `@agent-device/platform-*` packages. R13 governs concrete package imports across the whole tree, while `retired-platforms-zone` prevents the old root seam from being recreated; platform freedom is therefore structurally enforced rather than periodically measured. +### 11. The platform-services port carries host asks that bind no device + +Some request-path work asks the host machine something without ever taking a device runtime: +concrete local readiness, boot-time and runner-session observation, and open-target +classification. Those asks now cross the daemon boundary as one required dependency, +`DaemonPlatformServices` (`src/daemon/platform-services.ts`), composed by the root in +`src/platform-runtime-daemon-services.ts` and threaded root → `RequestRouterDeps` → the request +execution scope → the handlers and consumers. + +The port is not a raw escape hatch back into platform mechanics. Its observation members are the +already-published neutral contract types from `@agent-device/contracts`, readiness is a boolean, +and the three open-target members are questions over neutral inputs (`DeviceInfo`, flag strings, +`SessionSurface`) declared at the port itself — none of them carries a raw selector, gesture, or +runner call, and no platform type appears in a signature; device +execution stays on the request-bound `RequestExecutionScope.bindDevice` path, whose owner facts +remain the only admission authority. Its members are also all required: there is no throwing +placeholder an un-composed process silently runs on — a daemon process either receives the root's +composition or cannot start, and a test passes the same shared fixture rather than mocking a +platform module behind the port. ## Relationship to prior decisions diff --git a/scripts/layering/daemon-platform-runtime-inventory.test.ts b/scripts/layering/daemon-platform-runtime-inventory.test.ts index 2bd73cd8f0..3d90eb54f8 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.test.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.test.ts @@ -8,9 +8,11 @@ import { isRootPlatformRuntimeTarget, } from './daemon-platform-runtime-inventory.ts'; -const DEVICE_READY_TARGET = 'src/platform-runtime-device-ready.ts'; -const DEVICE_READY_STUB = - 'export async function ensureLocalPlatformDeviceReady(device: unknown) { return false; }\n'; +// The classified pair these fixtures plant is the platform-services composition factory: the +// edge this port introduced, and the shortest one whose symbols the rule still classifies. +const CLASSIFIED_TARGET = 'src/platform-runtime-daemon-services.ts'; +const CLASSIFIED_STUB = 'export function createDaemonPlatformServices() { return {}; }\n'; +const CLASSIFIED_IMPORTER = 'src/daemon/server/daemon-runtime.ts'; function violations(sources: Record): LayeringViolation[] { return checkDaemonPlatformRuntimeInventory(resolveImportEdges(new Map(Object.entries(sources)))); @@ -22,12 +24,12 @@ function edgeViolations(sources: Record, file: string): Layering test('R76 accepts a classified edge with the exact recorded symbols', () => { const sources = { - [DEVICE_READY_TARGET]: DEVICE_READY_STUB, - 'src/daemon/device/device-ready.ts': - "import { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + - 'void ensureLocalPlatformDeviceReady;\n', + [CLASSIFIED_TARGET]: CLASSIFIED_STUB, + [CLASSIFIED_IMPORTER]: + "import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" + + 'void createDaemonPlatformServices;\n', }; - assert.deepEqual(edgeViolations(sources, 'src/daemon/device/device-ready.ts'), []); + assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []); }); for (const [file, target, symbol] of [ @@ -50,20 +52,18 @@ for (const [file, target, symbol] of [ test('R76 reports every classified edge missing from the tree as stale, not the other way around', () => { const sources = { - [DEVICE_READY_TARGET]: DEVICE_READY_STUB, - 'src/daemon/device/device-ready.ts': - "import { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + - 'void ensureLocalPlatformDeviceReady;\n', + [CLASSIFIED_TARGET]: CLASSIFIED_STUB, + [CLASSIFIED_IMPORTER]: + "import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" + + 'void createDaemonPlatformServices;\n', }; const stale = violations(sources).filter( (violation) => violation.file === 'scripts/layering/daemon-platform-runtime-inventory.ts', ); assert.equal(stale.length, DAEMON_PLATFORM_RUNTIME_EDGES.length - 1); assert.ok(stale.every((violation) => violation.message.includes('stale classified edge'))); - const deviceReadyStale = stale.find((violation) => - violation.message.includes(DEVICE_READY_TARGET), - ); - assert.equal(deviceReadyStale, undefined); + const classifiedStale = stale.find((violation) => violation.message.includes(CLASSIFIED_TARGET)); + assert.equal(classifiedStale, undefined); }); test('R76 rejects an unclassified edge with the pair and its line', () => { @@ -86,35 +86,32 @@ test('R76 rejects an unclassified edge with the pair and its line', () => { test('R76 rejects new symbols on a classified edge', () => { const sources = { - [DEVICE_READY_TARGET]: DEVICE_READY_STUB + 'export function extraReadiness() {}\n', - 'src/daemon/device/device-ready.ts': - "import { ensureLocalPlatformDeviceReady, extraReadiness } from '../../platform-runtime-device-ready.ts';\n" + - 'void [ensureLocalPlatformDeviceReady, extraReadiness];\n', + [CLASSIFIED_TARGET]: CLASSIFIED_STUB + 'export function extraServicesKnob() {}\n', + [CLASSIFIED_IMPORTER]: + "import { createDaemonPlatformServices, extraServicesKnob } from '../../platform-runtime-daemon-services.ts';\n" + + 'void [createDaemonPlatformServices, extraServicesKnob];\n', }; - const found = edgeViolations(sources, 'src/daemon/device/device-ready.ts'); + const found = edgeViolations(sources, CLASSIFIED_IMPORTER); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /classified symbols drifted/); - assert.match(found[0]!.message, /ensureLocalPlatformDeviceReady, extraReadiness/); + assert.match(found[0]!.message, /createDaemonPlatformServices, extraServicesKnob/); }); -test('R76 rejects a reintroduced Android-mechanics import on the selector-dispatch edge', () => { +test('R76 rejects a services object widened through an expanded composition edge', () => { const sources = { - 'src/platform-runtime-open-target.ts': - 'export async function resolveSessionAppBundleIdForTarget() { return undefined; }\n' + - 'export async function resolveAndroidPackageForOpen() { return undefined; }\n', - 'src/daemon/handlers/session-selector-dispatch.ts': - "import { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } from '../../platform-runtime-open-target.ts';\n" + - 'void [resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget];\n', + 'src/platform-runtime-daemon-services.ts': + 'export function createDaemonPlatformServices() { return {}; }\n' + + 'export function createProviderPlatformServices() { return {}; }\n', + 'src/daemon/server/daemon-runtime.ts': + "import {\n createDaemonPlatformServices,\n createProviderPlatformServices,\n} from '../../platform-runtime-daemon-services.ts';\n" + + 'void [createDaemonPlatformServices, createProviderPlatformServices];\n', }; - const found = edgeViolations(sources, 'src/daemon/handlers/session-selector-dispatch.ts'); + const found = edgeViolations(sources, 'src/daemon/server/daemon-runtime.ts'); assert.equal(found.length, 1); assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); assert.match(found[0]!.message, /classified symbols drifted/); - assert.match( - found[0]!.message, - /resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget/, - ); + assert.match(found[0]!.message, /createDaemonPlatformServices, createProviderPlatformServices/); }); test('R76 matches a destructured dynamic import by target with the recorded bindings', () => { @@ -211,13 +208,13 @@ test('R76 rejects a namespace import alongside the recorded named binding on the test('R76 treats the import and re-export of one classified pair as one entry', () => { const sources = { - [DEVICE_READY_TARGET]: DEVICE_READY_STUB, - 'src/daemon/device/device-ready.ts': - "import { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + - "export { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts';\n" + - 'void ensureLocalPlatformDeviceReady;\n', + [CLASSIFIED_TARGET]: CLASSIFIED_STUB, + [CLASSIFIED_IMPORTER]: + "import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" + + "export { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts';\n" + + 'void createDaemonPlatformServices;\n', }; - assert.deepEqual(edgeViolations(sources, 'src/daemon/device/device-ready.ts'), []); + assert.deepEqual(edgeViolations(sources, CLASSIFIED_IMPORTER), []); }); test('R76 ignores test-shaped and non-daemon importers', () => { diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts index 67cc9e1be5..acc8a3b8e0 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -133,16 +133,6 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] '(#2168); the process root cannot carry a per-claim sessionsDir, so the scoped ' + "composition belongs to the recovery policy's own module.", }, - { - file: 'src/daemon/device/device-ready.ts', - target: 'src/platform-runtime-device-ready.ts', - symbols: ['ensureLocalPlatformDeviceReady'], - classification: 'composition-essential', - rationale: - 'neutral local-device-readiness port assembled at the root composition layer (the ' + - 'platform dispatch is internal to the root module); the daemon keeps its TTL cache ' + - 'and provider-device policy locally.', - }, { file: 'src/daemon/direct-ios-selector.ts', target: 'src/platform-runtime-apple-resources.ts', @@ -154,54 +144,6 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] 'audit deliberately adds no second selector producer.', deepenedBy: '#2273, #2274', }, - { - file: 'src/daemon/ios-app-session-hint.ts', - target: 'src/platform-runtime-apple-resources.ts', - symbols: ['appleSessionObservation'], - classification: 'daemon-policy-essential', - rationale: - 'daemon-owned hint composition and length limits consume the neutral foreground-app ' + - 'observation; the Apple package owns ambiguity and probe mechanics.', - }, - { - file: 'src/daemon/request-recording-health.ts', - target: 'src/platform-runtime-apple-resources.ts', - symbols: ['appleSessionObservation'], - classification: 'daemon-policy-essential', - rationale: - 'daemon-owned recording invalidation consumes only liveness and session identity ' + - 'through the neutral observation contract; runner mechanics stay Apple-owned.', - }, - { - file: 'src/daemon/session-device-resolution.ts', - target: 'src/platform-runtime-apple-resources.ts', - symbols: ['appleSessionObservation'], - classification: 'daemon-policy-essential', - rationale: - 'daemon-owned device refresh uses the neutral runner-session observation as boot ' + - 'evidence; inventory selection and provider exclusions remain local policy.', - }, - { - file: 'src/daemon/session-lifecycle/internal/session-open-execution.ts', - target: 'src/platform-runtime-device-boot.ts', - symbols: ['deviceBootObservation'], - classification: 'daemon-policy-essential', - rationale: - 'daemon-owned claim reconciliation asks the device when it last booted to decide whether a ' + - 'foreign claim can still describe live ownership; the per-family probe mechanics stay in the ' + - 'Apple and Android packages behind the neutral observation contract (#2538).', - }, - { - file: 'src/daemon/handlers/session-selector-dispatch.ts', - target: 'src/platform-runtime-open-target.ts', - symbols: ['resolveSessionAppBundleIdForTarget'], - classification: 'daemon-policy-essential', - rationale: - 'selector dispatch reconstructs the session app-bundle identity after a trigger-app-event ' + - 'deep link through the one neutral open-plan resolver (#2334); Android package resolution ' + - 'moved behind the Android owning seam in packages/platform-android, so the resolver is the ' + - 'only symbol this edge names.', - }, { file: 'src/daemon/server/daemon-runtime.ts', target: 'src/core/interactors.ts', @@ -236,15 +178,15 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] 'src/daemon/provider-device-admission.ts, so this edge is the composition and nothing else.', }, { - file: 'src/daemon/session-lifecycle/internal/session-open-prepare.ts', - target: 'src/platform-runtime-open-target.ts', - symbols: ['resolveRequestedOpenSurface', 'validateOpenRelaunchTarget'], - classification: 'daemon-policy-essential', + file: 'src/daemon/server/daemon-runtime.ts', + target: 'src/platform-runtime-daemon-services.ts', + symbols: ['createDaemonPlatformServices'], + classification: 'composition-essential', rationale: - 'open-prepare policy consumes only the neutral open plan/result surface (#2334): surface ' + - 'classification and relaunch-target validation. The platform mechanics that used to share ' + - 'the file (Android package resolution) moved behind the Android owning seam, leaving this ' + - 'edge daemon policy over two neutral, non-mechanics functions.', + 'process-root assembly of the daemon platform-services port (ADR 0019): local readiness, ' + + 'boot and runner-session observation, and open-target classification reach the request ' + + 'path only as this frozen object, so no daemon module names a platform adapter. Device ' + + 'execution never enters the port; it stays behind the request-bound runtime binding.', }, ] as const; diff --git a/scripts/layering/root-module-zones.ts b/scripts/layering/root-module-zones.ts index 1821281106..72d3a90c2d 100644 --- a/scripts/layering/root-module-zones.ts +++ b/scripts/layering/root-module-zones.ts @@ -52,6 +52,7 @@ export const ROOT_MODULE_ZONES: Readonly> = { 'src/platform-runtime-apple-runner-owner.ts', 'src/platform-runtime-daemon-lifecycle.ts', 'src/platform-runtime-daemon-owner-cleanup.ts', + 'src/platform-runtime-daemon-services.ts', 'src/platform-runtime-device-boot.ts', 'src/platform-runtime-device-inventory.ts', 'src/platform-runtime-device-ready.ts', diff --git a/src/cli/replay-test/__tests__/session-test-reporter-values-maestro.test.ts b/src/cli/replay-test/__tests__/session-test-reporter-values-maestro.test.ts index a282e01176..e1fc8ec8ca 100644 --- a/src/cli/replay-test/__tests__/session-test-reporter-values-maestro.test.ts +++ b/src/cli/replay-test/__tests__/session-test-reporter-values-maestro.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../daemon/__tests__/platform-services-fixture.ts'; // Characterization of the shipped reporter contract for the MAESTRO adapter (#1478 P3). // // The sibling session-test-reporter-values.test.ts covers the same contract for native `.ad`. @@ -112,6 +113,7 @@ async function runMaestroSuiteThroughReporter(params: { (event) => runReplayTestReporterProgress(reporters, event, reporterContext), async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/cli/replay-test/__tests__/session-test-reporter-values.test.ts b/src/cli/replay-test/__tests__/session-test-reporter-values.test.ts index e6916bc326..5b64aaea2b 100644 --- a/src/cli/replay-test/__tests__/session-test-reporter-values.test.ts +++ b/src/cli/replay-test/__tests__/session-test-reporter-values.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../daemon/__tests__/platform-services-fixture.ts'; // Characterization of the shipped reporter contract across the scheduler seam (#1478 P3). // // P3 moves the scheduler into `packages/replay-test` and makes attempt identity @@ -102,6 +103,7 @@ async function runSuiteThroughReporter(params: { (event) => runReplayTestReporterProgress(reporters, event, reporterContext), async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/__tests__/back-runtime.test.ts b/src/daemon/__tests__/back-runtime.test.ts index 3822dc6384..9e3e518801 100644 --- a/src/daemon/__tests__/back-runtime.test.ts +++ b/src/daemon/__tests__/back-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { backRuntimeOperationFacts } from '@agent-device/contracts/back-runtime'; import { @@ -90,6 +91,7 @@ test('resolves one admitted binding and drives one back navigation', async () => const harness = runtimeHarness(backRuntimeOperationFacts({ back: available }).back); const resolved = await resolveBoundBackRuntime({ + platformServices: daemonPlatformServicesFixture(), device: appleDevice, inspectFacts: harness.inspectFacts, bindDevice: harness.bindDevice, @@ -113,6 +115,7 @@ test('forwards the requested back mode from the resolved dispatch context', asyn const harness = runtimeHarness(); const resolved = await resolveBoundBackRuntime({ + platformServices: daemonPlatformServicesFixture(), device: appleDevice, inspectFacts: harness.inspectFacts, bindDevice: harness.bindDevice, diff --git a/src/daemon/__tests__/focus-runtime.test.ts b/src/daemon/__tests__/focus-runtime.test.ts index a1b0f3d847..54af0db735 100644 --- a/src/daemon/__tests__/focus-runtime.test.ts +++ b/src/daemon/__tests__/focus-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { focusRuntimeOperationFacts } from '@agent-device/contracts/focus-runtime'; import { @@ -86,6 +87,7 @@ test('resolves one admitted binding and exposes one point focus', async () => { const harness = runtimeHarness(focusRuntimeOperationFacts({ focus: available }).focusPoint); const resolved = await resolveBoundFocusRuntime({ + platformServices: daemonPlatformServicesFixture(), device: appleDevice, positionals: ['40', '90'], inspectFacts: harness.inspectFacts, @@ -123,6 +125,7 @@ test('forwards the request context the retired leaf passed through its runner co const harness = runtimeHarness(); const resolved = await resolveBoundFocusRuntime({ + platformServices: daemonPlatformServicesFixture(), device: appleDevice, positionals: ['40', '90'], inspectFacts: harness.inspectFacts, @@ -159,6 +162,7 @@ test('rejects a missing coordinate before inspection or binding', async () => { await expect( resolveBoundFocusRuntime({ + platformServices: daemonPlatformServicesFixture(), device: appleDevice, positionals: ['40'], inspectFacts: harness.inspectFacts, diff --git a/src/daemon/__tests__/fold-runtime.test.ts b/src/daemon/__tests__/fold-runtime.test.ts index 0e40d3ed5f..54284a41ba 100644 --- a/src/daemon/__tests__/fold-runtime.test.ts +++ b/src/daemon/__tests__/fold-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { recordActionEntry } from '../session-action-recorder.ts'; import { expect, test, vi } from 'vitest'; @@ -103,6 +104,7 @@ test('resolves one admitted binding and reports the pose the owner read back', a ); const resolved = await resolveBoundFoldRuntime({ + platformServices: daemonPlatformServicesFixture(), device: testDevice, positionals: ['unfolded'], inspectFacts: harness.inspectFacts, @@ -126,6 +128,7 @@ test('resolves one admitted binding and reports the pose the owner read back', a test('reports a pose without a panel reading when the owner could not name the lit panel', async () => { const harness = runtimeHarness(); const resolved = await resolveBoundFoldRuntime({ + platformServices: daemonPlatformServicesFixture(), device: testDevice, positionals: ['open'], inspectFacts: harness.inspectFacts, @@ -145,6 +148,7 @@ test('rejects an invalid pose before inspection or binding', async () => { const harness = runtimeHarness(); await expect( resolveBoundFoldRuntime({ + platformServices: daemonPlatformServicesFixture(), device: testDevice, positionals: ['sideways'], inspectFacts: harness.inspectFacts, @@ -177,6 +181,7 @@ test('refuses a scoped simulator set on the route with the typed reason, never b const harness = runtimeHarness(scopeRefusal, vi.fn(), scopedDevice); const resolved = await resolveBoundFoldRuntime({ + platformServices: daemonPlatformServicesFixture(), device: scopedDevice, positionals: ['half-open'], inspectFacts: harness.inspectFacts, @@ -206,6 +211,7 @@ test('passes the validated timed intent to the admitted fold owner', async () => { atMs: 5000, angle: 100 }, ]; const resolved = await resolveBoundFoldRuntime({ + platformServices: daemonPlatformServicesFixture(), device: testDevice, positionals: [], keyframes: JSON.stringify(keyframes), diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index 38469c4ac8..c37af36f13 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; @@ -186,6 +187,7 @@ async function dispatchGeneric(params: { ...(params.flags ? { flags: params.flags } : {}), }; return await dispatchGenericCommand({ + platformServices: daemonPlatformServicesFixture(), req, ref: params.sessionStore.lookup(params.sessionName)!, sessionName: params.sessionName, @@ -558,6 +560,7 @@ test('an ended generic lifetime is refused before dispatch or settle constructio mockCommandDispatch([AFTER_NODES]); await expect( dispatchGenericCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/__tests__/gesture-admission-parity.test.ts b/src/daemon/__tests__/gesture-admission-parity.test.ts index 7a89504542..57296e8a5f 100644 --- a/src/daemon/__tests__/gesture-admission-parity.test.ts +++ b/src/daemon/__tests__/gesture-admission-parity.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test } from 'vitest'; import type { GestureCommandInput, @@ -70,6 +71,7 @@ async function admit(input: GestureCommandInput, target: DeviceInfo) { }); try { return await resolveBoundGestureRuntime({ + platformServices: daemonPlatformServicesFixture(), device: target, input, inspectFacts: bindings.inspectFacts, diff --git a/src/daemon/__tests__/human-control-router-fixture.ts b/src/daemon/__tests__/human-control-router-fixture.ts index f4af99ddcb..c5c4bf97f9 100644 --- a/src/daemon/__tests__/human-control-router-fixture.ts +++ b/src/daemon/__tests__/human-control-router-fixture.ts @@ -1,4 +1,5 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { makeIosAppSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { buildSessionLeaseFromRequest } from '../lease-context.ts'; @@ -22,6 +23,7 @@ export function createHumanControlHarness() { ); const handleRequest = createRequestHandler({ logPath: '/tmp/agent-device-human-control.log', + platformServices: daemonPlatformServicesFixture(), token: 'test-token', sessionStore, leaseRegistry: registry, diff --git a/src/daemon/__tests__/interaction-get-runtime-fixture.ts b/src/daemon/__tests__/interaction-get-runtime-fixture.ts index 26439faa33..4faf4310f1 100644 --- a/src/daemon/__tests__/interaction-get-runtime-fixture.ts +++ b/src/daemon/__tests__/interaction-get-runtime-fixture.ts @@ -26,8 +26,10 @@ import { HOVER_UNAVAILABLE_HINT, } from '@agent-device/contracts/touch-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { DaemonPlatformServices } from '../platform-services.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { androidObservationFixture } from './android-observation-fixture.ts'; import { createUnavailableRuntimeFactsForTest } from '../../__tests__/test-utils/runtime-operation-facts.ts'; @@ -238,11 +240,13 @@ const mockBindElementReadRuntime: BindDeviceRuntime = vi.fn(async (device: Devic export function getRuntimeBindings(): Readonly<{ inspectFacts: InspectDeviceRuntimeFacts; bindDevice: BindDeviceRuntime; + platformServices: DaemonPlatformServices; androidObservation: typeof androidObservationFixture; }> { return { inspectFacts: mockInspectElementReadFacts, bindDevice: mockBindElementReadRuntime, + platformServices: daemonPlatformServicesFixture(), androidObservation: androidObservationFixture, }; } diff --git a/src/daemon/__tests__/is-runtime.test.ts b/src/daemon/__tests__/is-runtime.test.ts index 68fb57ee65..1facdf6acf 100644 --- a/src/daemon/__tests__/is-runtime.test.ts +++ b/src/daemon/__tests__/is-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { SnapshotResult } from '@agent-device/contracts/snapshot-runtime'; import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; @@ -80,6 +81,7 @@ test('an admitted is inspects once, binds once, and answers through the bound ca ); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-bound', ['visible', 'id=auth_continue']), sessionName: 'is-bound', sessionStore, @@ -101,6 +103,7 @@ test('is absent uses the bound readAny capture for selector-first input without sessionStore.publish('is-absent', makeIosAppSession('is-absent')); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-absent', ['label="Removed row"', 'absent']), sessionName: 'is-absent', sessionStore, @@ -142,6 +145,7 @@ test('is absent bypasses a cached no-match snapshot before evaluating the bound ); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-absent-fresh', ['absent', 'label="Removed row"']), sessionName: 'is-absent-fresh', sessionStore, @@ -174,6 +178,7 @@ test('is absent fails closed for a quality-less legacy iOS root-only capture', a sessionStore.publish('is-legacy-sparse', makeIosAppSession('is-legacy-sparse')); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-legacy-sparse', ['absent', 'label="Removed row"']), sessionName: 'is-legacy-sparse', sessionStore, @@ -210,6 +215,7 @@ test('is absent rejects depth and scope before binding with typed invalid argume ['snapshotDepth', 2], ] as const) { const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-absent-flags', ['absent', 'label="Gone"'], { [flag]: value }), sessionName: 'is-absent-flags', sessionStore, @@ -239,6 +245,7 @@ test('an unavailable capture fact refuses before any bind', async () => { sessionStore.publish('is-refused', makeAndroidSession('is-refused', { appBundleId: 'com.a' })); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-refused', ['visible', 'id=auth_continue']), sessionName: 'is-refused', sessionStore, @@ -271,6 +278,7 @@ test('an iOS session with no tracked app is refused with the open hint, not answ {}, async () => await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-no-app', ['visible', 'id=auth_continue']), sessionName: 'is-no-app', sessionStore, @@ -297,6 +305,7 @@ test('an iOS session WITH a tracked app still answers, so the refusal is the pla sessionStore.publish('is-with-app', makeIosAppSession('is-with-app')); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-with-app', ['visible', 'label=Continue']), sessionName: 'is-with-app', sessionStore, @@ -314,6 +323,7 @@ test('an Android session with no tracked app proceeds, because the owner adverti sessionStore.publish('is-android-no-app', makeAndroidSession('is-android-no-app')); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-android-no-app', ['visible', 'id=auth_continue']), sessionName: 'is-android-no-app', sessionStore, @@ -350,6 +360,7 @@ test('a refused request reaches the device by no route at all', async () => { }); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-direct-refused', ['selected', 'id="shipping-pickup"']), sessionName: 'is-direct-refused', sessionStore, @@ -410,6 +421,7 @@ test('a failing predicate answers COMMAND_FAILED from the bound capture', async }); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-direct-false', ['text', 'id=account_row', 'Wrong Expected Text']), sessionName: 'is-direct-false', sessionStore, @@ -450,6 +462,7 @@ test('a miss on a surface that never settled carries the unsettled fact, and the sessionStore.publish('is-unsettled', session); const isVisible = () => dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-unsettled', ['visible', 'id=target']), sessionName: 'is-unsettled', sessionStore, @@ -492,6 +505,7 @@ test('a read after a scroll that moved nothing carries the no-effect outcome, an sessionStore.publish('is-no-effect', session); const isVisible = () => dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-no-effect', ['visible', 'id=row']), sessionName: 'is-no-effect', sessionStore, diff --git a/src/daemon/__tests__/keyboard-runtime.test.ts b/src/daemon/__tests__/keyboard-runtime.test.ts index dcd55296e0..a9caf533e9 100644 --- a/src/daemon/__tests__/keyboard-runtime.test.ts +++ b/src/daemon/__tests__/keyboard-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { keyboardRuntimeOperationFacts, @@ -113,6 +114,7 @@ test('android status admits keyboardStatusUse and reports the platform-shaped st }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: androidDevice, positionals: ['status'], inspectFacts: harness.inspectFacts, @@ -144,6 +146,7 @@ test('`get` is an alias for `status`', async () => { }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: androidDevice, positionals: ['get'], inspectFacts: harness.inspectFacts, @@ -163,6 +166,7 @@ test('`return` is an alias for `enter`', async () => { }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: androidDevice, positionals: ['return'], inspectFacts: harness.inspectFacts, @@ -181,6 +185,7 @@ test('android status is refused on iOS with the retired in-handler hint', async }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: iosDevice, positionals: ['status'], inspectFacts: harness.inspectFacts, @@ -217,6 +222,7 @@ test('iOS dismiss reports the mechanism disclosure and its own message', async ( }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: iosDevice, positionals: ['dismiss'], inspectFacts: harness.inspectFacts, @@ -254,6 +260,7 @@ test('iOS dismiss degrades an unrecognized mechanism to the bare message', async }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: iosDevice, positionals: ['dismiss'], inspectFacts: harness.inspectFacts, @@ -279,6 +286,7 @@ test('iOS dismiss omits a message mechanism claim when every mechanism failed', }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: iosDevice, positionals: ['dismiss'], inspectFacts: harness.inspectFacts, @@ -308,6 +316,7 @@ test('iOS dismiss omits a mechanism claim when the keyboard was never visible', }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: iosDevice, positionals: ['dismiss'], inspectFacts: harness.inspectFacts, @@ -332,6 +341,7 @@ test('harmonyos dismiss reports success with no structured fields beyond the mes harness.keyboardDismiss.mockResolvedValue({ kind: 'acknowledged' }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: harmonyDevice, positionals: ['dismiss'], inspectFacts: harness.inspectFacts, @@ -368,6 +378,7 @@ test('android dismiss reports the full IME probe evidence', async () => { }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: androidDevice, positionals: ['dismiss'], inspectFacts: harness.inspectFacts, @@ -403,6 +414,7 @@ test('iOS enter reports visibility evidence; android enter reports only success' wasVisible: true, }); const iosResolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: iosDevice, positionals: ['enter'], inspectFacts: iosHarness.inspectFacts, @@ -426,6 +438,7 @@ test('iOS enter reports visibility evidence; android enter reports only success' enter: available, }); const androidResolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: androidDevice, positionals: ['enter'], inspectFacts: androidHarness.inspectFacts, @@ -450,6 +463,7 @@ test('harmonyos enter reports only success, distinctly from android despite an i harness.keyboardEnter.mockResolvedValue({ kind: 'harmonyos-acknowledged' }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: harmonyDevice, positionals: ['enter'], inspectFacts: harness.inspectFacts, @@ -474,6 +488,7 @@ test('rejects an unknown subcommand before inspection or binding', async () => { await expect( resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: androidDevice, positionals: ['sideways'], inspectFacts: harness.inspectFacts, @@ -494,6 +509,7 @@ test('rejects more than one subcommand argument', async () => { await expect( resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: androidDevice, positionals: ['dismiss', 'extra'], inspectFacts: harness.inspectFacts, @@ -511,6 +527,7 @@ test('defaults to status with no positional', async () => { }); const resolved = await resolveBoundKeyboardRuntime({ + platformServices: daemonPlatformServicesFixture(), device: androidDevice, positionals: [], inspectFacts: harness.inspectFacts, diff --git a/src/daemon/__tests__/orientation-runtime.test.ts b/src/daemon/__tests__/orientation-runtime.test.ts index 3aa7195221..0a86a7186d 100644 --- a/src/daemon/__tests__/orientation-runtime.test.ts +++ b/src/daemon/__tests__/orientation-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; // `orientation` carries `androidBlockingDialogGuard: true` (like every other generic-route leaf @@ -126,6 +127,7 @@ test('resolves one admitted binding and reports the owner-observed rotation', as ); const resolved = await resolveBoundOrientationRuntime({ + platformServices: daemonPlatformServicesFixture(), device: testDevice, positionals: ['landscape-left'], inspectFacts: harness.inspectFacts, @@ -146,6 +148,7 @@ test('keeps the requested rotation but discloses that the owner reported nothing const harness = runtimeHarness(); const resolved = await resolveBoundOrientationRuntime({ + platformServices: daemonPlatformServicesFixture(), device: testDevice, positionals: ['portrait'], inspectFacts: harness.inspectFacts, @@ -168,6 +171,7 @@ test('rejects an invalid rotation before inspection or binding', async () => { await expect( resolveBoundOrientationRuntime({ + platformServices: daemonPlatformServicesFixture(), device: testDevice, positionals: ['sideways'], inspectFacts: harness.inspectFacts, diff --git a/src/daemon/__tests__/platform-services-fixture.ts b/src/daemon/__tests__/platform-services-fixture.ts new file mode 100644 index 0000000000..538cac8bd7 --- /dev/null +++ b/src/daemon/__tests__/platform-services-fixture.ts @@ -0,0 +1,15 @@ +import { createDaemonPlatformServices } from '../../platform-runtime-daemon-services.ts'; +import type { DaemonPlatformServices } from '../platform-services.ts'; + +/** + * The port the production composition root builds, handed to a test the same way the shared router + * fixture hands the real resource-cleanup capability: tests that exercise command behaviour rather + * than readiness or observation policy keep seeing the real adapters, and a test that cares about + * one ask replaces just that member — a readiness spy overrides `ensureLocalDeviceReady` rather + * than mocking a platform module behind the port. + */ +export function daemonPlatformServicesFixture( + overrides: Partial = {}, +): DaemonPlatformServices { + return Object.freeze({ ...createDaemonPlatformServices(), ...overrides }); +} diff --git a/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts index 2ad7742cdd..5c3240141b 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { describe, expect, test, vi } from 'vitest'; @@ -71,6 +72,7 @@ async function runWithNetworkFlag(publicNetworkOnly: boolean | undefined) { // the asserted path: dropping it in the handler must fail this test, since an // unset field reads as trusted and a remote flow would run evalScript. const response = await handleReplayCommand({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: 'default', logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts index 81a7d9fbdb..40f7fa3f09 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * #1271 stage 2 (ADR 0012 amendment): repair-segment default exclusion of @@ -244,6 +245,7 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by // --- Finalize: `close --save-script` commits the healed `.ad`. --- const closeResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: ctx.sessionName, @@ -322,6 +324,7 @@ test("a --record'ed diagnostic read lands in the healed script (the diverged-ste expect(session.actions.map((a) => a.command)).toEqual(['open', 'get', 'click']); const closeResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: ctx.sessionName, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts index f618e7a798..af3cc2c0df 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 repair-transaction close-ordering guarantees (BLOCKER 2/3 sequencing): the @@ -111,6 +112,7 @@ test('BLOCKER 2 (new): a repair close whose PLATFORM close fails never commits a mockLifecycleDispatch.mockRejectedValueOnce(platformCloseError); const closeResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -151,6 +153,7 @@ test('BLOCKER 2 (new): a repair close whose PLATFORM close fails never commits a // Retry once the platform close succeeds: commits cleanly. mockLifecycleDispatch.mockResolvedValueOnce(undefined); const retry = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -192,6 +195,7 @@ test('BLOCKER 3 (second follow-up): a retry after a SUCCESSFUL platform close bu const before = fs.readFileSync(healedPath, 'utf8'); const closeResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -219,6 +223,7 @@ test('BLOCKER 3 (second follow-up): a retry after a SUCCESSFUL platform close bu // wedge recovery entirely) on a second close of an already-closed target. const retryPath = path.join(root, 'flow.promoted.ad'); const retry = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -289,6 +294,7 @@ test('BLOCKER 3 (third follow-up): an untargeted close that performed NO platfor // (no positional target, not `web`), so the platform close never dispatches // at all; the commit then fails (no-clobber). const first = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, command: 'close', positionals: [], flags: {} }, sessionName, logPath, @@ -300,6 +306,7 @@ test('BLOCKER 3 (third follow-up): an untargeted close that performed NO platfor expect(sessionStore.get(sessionName)).toBeDefined(); const retry = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -331,6 +338,7 @@ test('BLOCKER 3 (third follow-up): a retry targeting a DIFFERENT app than the su // First attempt targets app-a; the platform close succeeds, the commit fails. const first = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -352,6 +360,7 @@ test('BLOCKER 3 (third follow-up): a retry targeting a DIFFERENT app than the su // already closed just because SOME close succeeded. It must dispatch again, // against app-b specifically. const retry = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts index 034783ee9f..5ada44c7f0 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 repair-transaction `--force`/`--overwrite` semantics (#1258): a @@ -121,6 +122,7 @@ test('#1258: close --save-script --force overwrites an existing COMPLETE healed ); const closeResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -373,6 +375,7 @@ test('#1258 preflight is per-target: a --from continuation RETARGETING to an exi // (never the rejected ) — proof the rejected retarget corrupted neither // the completion flag nor the target path. const closeResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, command: 'close', positionals: [], flags: {} }, sessionName, logPath, @@ -412,6 +415,7 @@ test('#1258 force is per-target: re-arming --save-script= WITHOUT --force dro // `close --save-script=` (NO --force): retargeting from to // without a live opt-in drops the force that was granted for . const closeResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -455,6 +459,7 @@ test('#1258 force per-target, contrast: re-arming --save-script= WITH --force // `close --save-script= --force`: the live opt-in re-grants force for the // new target, overwriting it. const closeResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts index 8412b5609d..8f0821f1ce 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 "repair transaction" lifecycle fixes (Q1/Q2a/Q2b/Q2c): @@ -190,6 +191,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res // --- The agent finalizes: `close --save-script` (the real handler, not a // direct writer call) commits the now-COMPLETE healed `.ad`. --- const closeResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -254,6 +256,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res // The agent walks away: a plain `close` (no --save-script) reaches the // still repair-armed session — Fix 1/2's "abort/discard", not a commit. const abandonedCloseResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: abandoned.sessionName, @@ -434,6 +437,7 @@ test('BLOCKER 2b/2c: a close whose commit FAILS (no-clobber) keeps the session f const before = fs.readFileSync(path.join(root, 'flow.healed.ad'), 'utf8'); const closeResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, command: 'close', positionals: [], flags: {} }, sessionName, logPath, @@ -467,6 +471,7 @@ test('BLOCKER 2b/2c: a close whose commit FAILS (no-clobber) keeps the session f // Retry with an explicit path commits cleanly — exactly ONE terminal close. const retryPath = path.join(root, 'flow.promoted.ad'); const retry = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/__tests__/replay-suite/session-test-fail-fast.test.ts b/src/daemon/__tests__/replay-suite/session-test-fail-fast.test.ts index 126b0c979a..a8e3f08cb0 100644 --- a/src/daemon/__tests__/replay-suite/session-test-fail-fast.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-fail-fast.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; @@ -14,6 +15,7 @@ test('test --fail-fast continues after passing scripts', async () => { const invokedPaths: string[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/__tests__/replay-suite/session-test-runner.test.ts b/src/daemon/__tests__/replay-suite/session-test-runner.test.ts index 9ae03948b9..7d2cd08116 100644 --- a/src/daemon/__tests__/replay-suite/session-test-runner.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-runner.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { test, expect, vi } from 'vitest'; import fs from 'node:fs'; import * as path from 'node:path'; @@ -66,6 +67,7 @@ test('session_list includes device_udid and ios_simulator_device_set for iOS ses ); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', command: 'session_list', positionals: [] }, sessionName: 'default', logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -101,6 +103,7 @@ test('test filters replay scripts by context platform and skips untyped files', const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -171,6 +174,7 @@ test('test binds each replay script to its declared platform metadata', async () let response; try { response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -209,6 +213,7 @@ test('test cleans up suite-owned sessions after each executed script', async () fs.writeFileSync(path.join(root, '01-android.ad'), 'context platform=android\nopen "Demo"\n'); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -248,6 +253,7 @@ test('test retries failed scripts with fresh suite-owned sessions', async () => const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -301,6 +307,7 @@ test('test applies per-script timeout and writes attempt artifacts', async () => let invocationCount = 0; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -368,6 +375,7 @@ test('open does not retain a session when the request was canceled before comple markRequestCanceled(requestId); try { const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -399,6 +407,7 @@ test('test returns invalid args when no replay scripts match the platform filter fs.writeFileSync(path.join(root, '01-ios.ad'), 'context platform=ios\nopen "Settings"\n'); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -428,6 +437,7 @@ test('test rejects duplicate replay test metadata in the context header', async ); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts b/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts index 2b68cd939a..1e81b54c3f 100644 --- a/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; /** Per-attempt recording is finalized exactly once after cancellation. */ import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -266,6 +267,7 @@ test('test finalizes replay video exactly once when cancellation arrives after s meta: { cwd: root, requestId: 'record-video-suite' }, }; const video = createReplayTestVideoOwner({ + platformServices: daemonPlatformServicesFixture(), sessionStore, bindDevice: unavailableBindDevice, bindExactDevice: unavailableBindExactDevice, diff --git a/src/daemon/__tests__/replay-suite/session-test-suite-infrastructure.test.ts b/src/daemon/__tests__/replay-suite/session-test-suite-infrastructure.test.ts index 361085a8d3..329701e398 100644 --- a/src/daemon/__tests__/replay-suite/session-test-suite-infrastructure.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-suite-infrastructure.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import fs from 'node:fs'; @@ -21,6 +22,7 @@ test('test --json marks a typed live device claim as infrastructure without retr let attempts = 0; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -68,6 +70,7 @@ test('test --json retries DEVICE_IN_USE without typed device-claim provenance', let attempts = 0; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/__tests__/replay-suite/session-test-suite.test.ts b/src/daemon/__tests__/replay-suite/session-test-suite.test.ts index a3b8b1fc1e..3d36505d3a 100644 --- a/src/daemon/__tests__/replay-suite/session-test-suite.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-suite.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../platform-services-fixture.ts'; import type { RequestProgressEvent } from '@agent-device/contracts/progress'; import { test, expect, vi } from 'vitest'; @@ -63,6 +64,7 @@ test('test does not retry infrastructure startup failures and stops the suite', const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -110,6 +112,7 @@ test('test --fail-fast stops the suite after the first failure and leaves the re const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -156,6 +159,7 @@ test('test surfaces a suite-level failure when a source fails to parse', async ( const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -190,6 +194,7 @@ test('test discovers Maestro YAML suites when replay backend is set', async () = const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -225,6 +230,7 @@ test('test emits progress when attempts retry and pass', async () => { (event) => events.push(event), async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -332,6 +338,7 @@ test('test stops before retrying when a rejected close leaves the prior macOS se let replayAttempts = 0; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -383,6 +390,7 @@ test('test stops retrying after maxAttempts when every attempt fails', async () let attemptCount = 0; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -426,6 +434,7 @@ test('test emits skip progress without synthetic duration', async () => { (event) => events.push(event), async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -478,6 +487,7 @@ test('test aggregates snapshot diagnostics from replay session samples', async ( let captures = 0; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -536,6 +546,7 @@ test('test aggregates snapshot diagnostics from failed replay session samples', fs.writeFileSync(path.join(root, '01-fail.ad'), 'context platform=android\nopen "Demo"\n'); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -608,6 +619,7 @@ test('test stops the suite when the parent request is canceled during an active (event) => events.push(event), async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -673,6 +685,7 @@ test('test --shard-all runs each runnable entry on each selected device', async async () => [ANDROID_ONE, ANDROID_TWO], async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -748,6 +761,7 @@ test('test --shard-split distributes runnable entries by modulo and keeps skips async () => [ANDROID_TWO, ANDROID_ONE], async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -796,6 +810,7 @@ test('test sharding rejects mutually exclusive shard modes', async () => { fs.writeFileSync(path.join(root, '01-a.ad'), 'context platform=android\nopen "Demo"\n'); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -822,6 +837,7 @@ test('test sharding rejects non-positive shard counts', async () => { fs.writeFileSync(path.join(root, '01-a.ad'), 'context platform=android\nopen "Demo"\n'); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -851,6 +867,7 @@ test('test sharding rejects fewer matched devices than requested shards', async async () => [ANDROID_ONE], async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -885,6 +902,7 @@ test('test sharding does not require devices when every entry is skipped', async }, async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts b/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts index 744997ecfe..3809c50bd6 100644 --- a/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts +++ b/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; import { LINUX_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; @@ -45,6 +46,7 @@ test('expired leases remove owned sessions before the next command and free capa now = 1_011; const scope = await createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -98,6 +100,7 @@ test.each(['rebuild', 'retire'] as const)( }); now = 1_011; const scope = await createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ session: address, flags: { session: address } }), sessionStore: store, leaseRegistry: leases, diff --git a/src/daemon/__tests__/request-execution-scope-macos-app-lease.test.ts b/src/daemon/__tests__/request-execution-scope-macos-app-lease.test.ts index a6018838fd..83034aa6af 100644 --- a/src/daemon/__tests__/request-execution-scope-macos-app-lease.test.ts +++ b/src/daemon/__tests__/request-execution-scope-macos-app-lease.test.ts @@ -1,4 +1,5 @@ import { expect, test } from 'vitest'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { makeMacOsSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { parseDaemonPolicy } from '../../daemon-policy-file.ts'; @@ -37,6 +38,7 @@ async function runScoped(params: { daemonPolicy?: ReturnType; }): Promise<{ ran: boolean; error?: unknown }> { const scope = await createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', positionals: [], ...params.req }, sessionStore: params.sessionStore, leaseRegistry: params.leaseRegistry, @@ -118,6 +120,7 @@ test('a request under a pid-pinned macos-app lease does not run once that proces }), ); const scope = await createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', command: 'snapshot', positionals: [], flags: {} }, sessionStore, leaseRegistry, diff --git a/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts b/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts index b533eedfd2..82ec94ee4b 100644 --- a/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts +++ b/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts @@ -1,4 +1,5 @@ import { afterAll, test, expect, vi } from 'vitest'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import fs from 'node:fs'; import { getFlagDefinitionsForKey } from '@agent-device/command-registry/flag-registry'; import type { CommandFlags } from '@agent-device/contracts/command'; @@ -115,11 +116,13 @@ test('an open that lost the race to a free device re-waits and opens rather than setTimeout(() => sessionStore.retire(holder), 50); const first = await createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: openRequest('first-opener'), sessionStore, leaseRegistry, }); const second = await createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: openRequest('second-opener'), sessionStore, leaseRegistry, @@ -152,6 +155,7 @@ test('a close that frees the device mid-wait gets through while the open is wait const order: string[] = []; const opened = createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: openRequest('waiter'), sessionStore, leaseRegistry, @@ -164,6 +168,7 @@ test('a close that frees the device mid-wait gets through while the open is wait ); await sleep(50); const closer = await createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: closeRequest('holder'), sessionStore, leaseRegistry, @@ -195,6 +200,7 @@ test('an out-of-range wait budget is refused before resolving the target device' const resolveCallsBefore = vi.mocked(resolveTargetDevice).mock.calls.length; await expect( createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: openRequest('unbounded-opener', max + 1, 'request-unbounded'), sessionStore: makeSessionStore('agent-device-open-wait-bounds-'), leaseRegistry: new LeaseRegistry(), diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index 8c6fe6c221..3aeea64bc6 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { afterAll, test, expect, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -42,7 +43,7 @@ test('createRequestExecutionScope applies tenant scoping and locked lease admiss deviceKey: 'ios:sim-1', }); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ session: 'default', command: 'snapshot', @@ -73,21 +74,21 @@ test('createRequestExecutionScope is the single defaulting authority for the app const sessionStore = makeSessionStore('agent-device-request-scope-defaults-'); const leaseRegistry = new LeaseRegistry(); - const defaulted = await createRequestExecutionScope({ + const defaulted = await createTestRequestScope({ req: makeRequest({ command: 'apps' }), sessionStore, leaseRegistry, }); expect(defaulted.req.flags?.appsFilter).toBe('user-installed'); - const overridden = await createRequestExecutionScope({ + const overridden = await createTestRequestScope({ req: makeRequest({ command: 'apps', flags: { appsFilter: 'all' } }), sessionStore, leaseRegistry, }); expect(overridden.req.flags?.appsFilter).toBe('all'); - const untouched = await createRequestExecutionScope({ + const untouched = await createTestRequestScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -103,7 +104,7 @@ test('createRequestExecutionScope resolves session-scoped request and runner log const scope = await withDiagnosticsScope( { command: 'snapshot', requestId: 'request-logs-1', logPath: LOG_PATH }, async () => - await createRequestExecutionScope({ + await createTestRequestScope({ req: makeRequest({ meta: { cwd, requestId: 'request-logs-1' } }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -124,7 +125,7 @@ test('a relative session name is rejected before any session artifact path is wr await withDiagnosticsScope({ command: 'snapshot', requestId, logPath: LOG_PATH }, async () => { await expect( - createRequestExecutionScope({ + createTestRequestScope({ req: makeRequest({ session: '..', meta: { requestId } }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -148,7 +149,7 @@ test('request diagnostics flush into the effective session request log', async ( const result = await withDiagnosticsScope( { command: 'snapshot', requestId: 'request-diag-1', logPath: LOG_PATH }, async () => { - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ meta: { cwd, requestId: 'request-diag-1' } }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -165,7 +166,7 @@ test('request diagnostics flush into the effective session request log', async ( }); test('runLocked rejects tenant requests without an active lease', async () => { - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ session: 'default', command: 'snapshot', @@ -211,7 +212,7 @@ test('leased session admission uses stored lease metadata and heartbeats', async ); now = 2_000; - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -253,12 +254,12 @@ test('leased session heartbeat is serialized with the request execution lock', a }), ); - const first = await createRequestExecutionScope({ + const first = await createTestRequestScope({ req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, }); - const second = await createRequestExecutionScope({ + const second = await createTestRequestScope({ req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, @@ -295,12 +296,12 @@ test('a later external command cannot interleave with replay observation finaliz const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeIosSession('default')); const leaseRegistry = new LeaseRegistry(); - const replay = await createRequestExecutionScope({ + const replay = await createTestRequestScope({ req: makeRequest({ command: 'replay' }), sessionStore, leaseRegistry, }); - const laterSnapshot = await createRequestExecutionScope({ + const laterSnapshot = await createTestRequestScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -336,7 +337,7 @@ test('a later external command cannot interleave with replay observation finaliz test('a fresh replay keeps its session lock after a nested open binds the device', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); - const replay = await createRequestExecutionScope({ + const replay = await createTestRequestScope({ req: makeRequest({ command: 'replay' }), sessionStore, leaseRegistry, @@ -357,7 +358,7 @@ test('a fresh replay keeps its session lock after a nested open binds the device ); await sessionOpenedPromise; - const laterSnapshot = await createRequestExecutionScope({ + const laterSnapshot = await createTestRequestScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -391,7 +392,7 @@ test('leased session rejects mismatched lease id before dispatch', async () => { }), ); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ command: 'snapshot', meta: { leaseId: '1'.repeat(32) } }), sessionStore, leaseRegistry, @@ -425,7 +426,7 @@ test.each([ }), ); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ command: 'snapshot', meta }), sessionStore, leaseRegistry, @@ -440,7 +441,7 @@ test('local unleased session admission still succeeds', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeIosSession('default')); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -452,7 +453,7 @@ test('local unleased session admission still succeeds', async () => { test('local unleased session ignores stale lease id without tenant scope', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeIosSession('default')); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ command: 'snapshot', meta: { leaseId: '1'.repeat(32) }, @@ -486,7 +487,7 @@ test('provider lease admission succeeds without a device key', async () => { }), ); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -522,7 +523,7 @@ test('an admitted request that outlives the lease TTL keeps its lease and sessio }), ); - const slow = await createRequestExecutionScope({ + const slow = await createTestRequestScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -531,7 +532,7 @@ test('an admitted request that outlives the lease TTL keeps its lease and sessio // page-source read does on a screen that never goes idle. expect(await slow.runLocked(async () => (now = 1_011))).toBe(1_011); - const next = await createRequestExecutionScope({ + const next = await createTestRequestScope({ req: makeRequest({ command: 'screenshot' }), sessionStore, leaseRegistry, @@ -562,12 +563,12 @@ test('expired leased session cleanup waits for the request execution lock', asyn }, }), ); - const first = await createRequestExecutionScope({ + const first = await createTestRequestScope({ req: makeRequest({ command: 'click', meta: { requestId } }), sessionStore, leaseRegistry, }); - const second = await createRequestExecutionScope({ + const second = await createTestRequestScope({ req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, @@ -612,7 +613,7 @@ test('tenant lease rejection flushes diagnostics into the effective session requ let flushedPath: string | null = null; await withDiagnosticsScope({ command: 'snapshot', requestId, logPath: LOG_PATH }, async () => { - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ session: 'default', command: 'snapshot', @@ -642,7 +643,7 @@ test('tenant lease rejection flushes diagnostics into the effective session requ test('prepareLockedRequestScope preserves existing-session selector validation', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeAndroidSession('default')); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ command: 'snapshot', flags: { @@ -673,7 +674,7 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor invalidatedReason: 'iOS runner session restarted during recording', }); sessionStore.publish('default', session); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -701,7 +702,7 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor test('prepareLockedRequestScope passes the session runner log path into handler context', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeIosSession('default')); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -729,7 +730,7 @@ test('prepareLockedRequestScope streams ordinary diagnostics into the active tra trace: { outPath: tracePath, startedAt: Date.now() }, }), ); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -750,7 +751,7 @@ test('prepareLockedRequestScope streams ordinary diagnostics into the active tra test('runLocked rejects a canceled request before executing work', async () => { const requestId = 'request-scope-canceled-before-lock'; - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ meta: { requestId } }), sessionStore: makeSessionStore('agent-device-request-scope-'), leaseRegistry: new LeaseRegistry(), @@ -769,12 +770,12 @@ test('runLocked rejects a request canceled while waiting for its execution lock' const sessionStore = makeSessionStore('agent-device-request-scope-'); sessionStore.publish('default', makeIosSession('default')); const leaseRegistry = new LeaseRegistry(); - const first = await createRequestExecutionScope({ + const first = await createTestRequestScope({ req: makeRequest({ command: 'click' }), sessionStore, leaseRegistry, }); - const second = await createRequestExecutionScope({ + const second = await createTestRequestScope({ req: makeRequest({ command: 'click', meta: { requestId } }), sessionStore, leaseRegistry, @@ -808,7 +809,7 @@ test('router: deferred tenant connect with no daemon session closes as SESSION_N const leaseRegistry = new LeaseRegistry(); const release = vi.fn(async () => ({})); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ session: 'default', command: 'close', @@ -821,6 +822,7 @@ test('router: deferred tenant connect with no daemon session closes as SESSION_N const response = await scope.runLocked(async () => handleSessionCloseCommands({ + platformServices: daemonPlatformServicesFixture(), req: scope.req, sessionName: scope.sessionName, logPath: scope.requestLogPath, @@ -845,7 +847,7 @@ test('router: deferred tenant connect still refuses an app-target close before d const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ session: 'default', command: 'close', @@ -870,7 +872,7 @@ test('router: an existing lease-less session under tenant isolation still refuse sessionStore.publish('tenant-a:default', makeIosSession('tenant-a:default')); const leaseRegistry = new LeaseRegistry(); - const scope = await createRequestExecutionScope({ + const scope = await createTestRequestScope({ req: makeRequest({ session: 'default', command: 'close', @@ -886,6 +888,15 @@ test('router: an existing lease-less session under tenant isolation still refuse ); }); +function createTestRequestScope( + params: Omit[0], 'platformServices'>, +) { + return createRequestExecutionScope({ + ...params, + platformServices: daemonPlatformServicesFixture(), + }); +} + function makeRequest(overrides: Partial = {}): DaemonRequest { return { token: 'test-token', @@ -914,7 +925,7 @@ async function createScopeAcrossTwoImplicitWorkspaceSessions(command: string) { `cwd:${scope.id}:android`, makeAndroidSession('default', { sessionScope: { kind: 'cwd', id: scope.id } }), ); - return await createRequestExecutionScope({ + return await createTestRequestScope({ req: makeRequest({ command, meta: { cwd: root, requestId: `ambiguity-${command}` } }), sessionStore, leaseRegistry: new LeaseRegistry(), diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index e1a8e0ed1e..c9b3ae490f 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -506,6 +507,7 @@ async function runCatalogCommandThroughHandlerChain( async () => [], async () => await runRequestHandlerChain({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: req.session, logPath: '/tmp/agent-device-catalog-route.log', diff --git a/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts b/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts index 52cbdf6e35..6795bd9417 100644 --- a/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts +++ b/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; // Proves the exact androidAdbExecutor reference reaches the session handler // through the neutral providerScope, and that an empty scope forwards none. import assert from 'node:assert/strict'; @@ -75,6 +76,7 @@ test('the android adb executor from the generic provider scope reaches the sessi }); await runRequestHandlerChain({ + platformServices: daemonPlatformServicesFixture(), ...baseChainParams('provider-scope-test'), providerScope: { androidAdbExecutor }, }); @@ -90,6 +92,7 @@ test('an empty provider scope forwards no android adb executor to the session ha handleSessionCommandsMock.mockClear(); await runRequestHandlerChain({ + platformServices: daemonPlatformServicesFixture(), ...baseChainParams('provider-scope-empty'), providerScope: {}, }); diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index ee766f96b5..11345712c9 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -47,6 +48,7 @@ function makeChainParams(req: DaemonRequest) { logPath: '/tmp/agent-device-request-chain.log', sessionStore, leaseRegistry: new LeaseRegistry(), + platformServices: daemonPlatformServicesFixture(), invoke: async (): Promise => ({ ok: true, data: {} }), providerScope: {}, bindDevice: unavailableBindDevice, @@ -310,6 +312,7 @@ test('duration-less public coordinate swipe retains Linux drag behavior', async provider, async () => await handleInteractionCommands({ + platformServices: daemonPlatformServicesFixture(), inspectFacts: bindings.inspectFacts, bindDevice: bindings.bindDevice, req: { diff --git a/src/daemon/__tests__/request-recording-health.test.ts b/src/daemon/__tests__/request-recording-health.test.ts index d2dd73d068..bb5f520411 100644 --- a/src/daemon/__tests__/request-recording-health.test.ts +++ b/src/daemon/__tests__/request-recording-health.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import type { SessionState } from '../session-state.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; @@ -8,15 +9,17 @@ import { import { LeaseRegistry } from '../lease-registry.ts'; import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; -vi.mock('../../platform-runtime-apple-resources.ts', async (importOriginal) => ({ - ...(await importOriginal()), - appleSessionObservation: { observeRunnerSession: vi.fn() }, -})); - -import { appleSessionObservation } from '../../platform-runtime-apple-resources.ts'; import { refreshRecordingHealth } from '../request-recording-health.ts'; -const mockObserveRunnerSession = vi.mocked(appleSessionObservation.observeRunnerSession); +// The observation spy hangs off the port, not the platform module behind it +// (ADR 0019 section 11): the fixture's appleSessionObservation member is replaced wholesale. +const mockObserveRunnerSession = vi.fn(); +const platformServices = daemonPlatformServicesFixture({ + appleSessionObservation: { + observeRunnerSession: mockObserveRunnerSession, + resolveSoleForegroundApp: async () => undefined, + }, +}); beforeEach(() => { mockObserveRunnerSession.mockReset(); @@ -62,7 +65,7 @@ test('runner-backed iOS recordings still invalidate on runner restarts', async ( const store = makeSessionStore(); const ref = store.publish(session.name, session); - await refreshRecordingHealth(store, ref); + await refreshRecordingHealth(store, ref, platformServices); expect(mockObserveRunnerSession).toHaveBeenCalledWith('sim-1'); expect(session.screenRecording?.handle.inspect().invalidatedReason).toBe( @@ -88,7 +91,7 @@ test.each([ const store = makeSessionStore(); const ref = store.publish(session.name, session); - await refreshRecordingHealth(store, ref); + await refreshRecordingHealth(store, ref, platformServices); expect(session.screenRecording.handle.inspect().invalidatedReason).toBe(reason); }); @@ -103,7 +106,7 @@ test('a recording without a runner identity adopts the first live observation', const store = makeSessionStore(); const ref = store.publish(session.name, session); - await refreshRecordingHealth(store, ref); + await refreshRecordingHealth(store, ref, platformServices); const recording = session.screenRecording.handle.inspect(); expect(recording.runnerSessionId).toBe('runner-first'); @@ -129,7 +132,7 @@ test.each(['rebuild', 'retire', 'handle', 'token', 'generation'] as const)( finish = resolve; }), ); - const observation = refreshRecordingHealth(store, ref); + const observation = refreshRecordingHealth(store, ref, platformServices); expect(mockObserveRunnerSession).toHaveBeenCalledWith('sim-1'); if (change === 'rebuild') { store.update(ref, { appName: 'Intervening app' }); @@ -196,6 +199,9 @@ test.each(['rebuild', 'retire'] as const)( req: { token: 'token', session: 'default', command: 'snapshot', positionals: [] }, sessionStore: store, leaseRegistry: new LeaseRegistry(), + // Locked preparation refreshes recording health through the port, so the scope carries + // the fixture whose observation member is the test's spy. + platformServices: platformServices, }); const prepared = scope.runLocked(() => prepareLockedRequestScope({ diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 06052e4fb5..d90ded8607 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -1,4 +1,5 @@ import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; @@ -489,6 +490,7 @@ test('close fails synchronously when root composition omits platform resource cl }); const handler = createProductionRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + platformServices: daemonPlatformServicesFixture(), token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index b46d1930f9..c521fda2b3 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -4,12 +4,7 @@ import { test, expect, vi, beforeEach } from 'vitest'; import path from 'node:path'; -vi.mock('../../platform-runtime-apple-resources.ts', async (importOriginal) => ({ - ...(await importOriginal()), - appleSessionObservation: { observeRunnerSession: vi.fn() }, -})); - -import { appleSessionObservation } from '../../platform-runtime-apple-resources.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { createRequestHandler, gestureDeviceRuntimeGateway, @@ -21,7 +16,15 @@ import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -const mockObserveRunnerSession = vi.mocked(appleSessionObservation.observeRunnerSession); +// The observation spy hangs off the port the router composes with (ADR 0019 section 11), not off +// the platform module behind it. +const mockObserveRunnerSession = vi.fn(); +const platformServices = daemonPlatformServicesFixture({ + appleSessionObservation: { + observeRunnerSession: mockObserveRunnerSession, + resolveSoleForegroundApp: async () => undefined, + }, +}); beforeEach(() => { legacyDispatchCapture.mockReset(); @@ -62,6 +65,7 @@ test('router blocks non-record commands when recording was invalidated', async ( leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', + platformServices, }); const response = await handler({ @@ -117,6 +121,7 @@ test('router allows canonical iOS simulator gestures during overlay recording af deviceInventoryGateways: createTestDeviceInventoryGateways(), trackDownloadableArtifact: () => 'artifact-id', deviceRuntimeGateway: gestureDeviceRuntimeGateway, + platformServices, }); const response = await handler({ diff --git a/src/daemon/__tests__/request-runtime-binding.test.ts b/src/daemon/__tests__/request-runtime-binding.test.ts index c2aa2c93c4..3843f6aac3 100644 --- a/src/daemon/__tests__/request-runtime-binding.test.ts +++ b/src/daemon/__tests__/request-runtime-binding.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; import { @@ -44,6 +45,8 @@ const scope = { const admitDeviceClaim = async () => {}; const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); +// Bound readiness forwards the port to the local readiness ask, so the test asserts on it. +const platformServices = daemonPlatformServicesFixture(); beforeEach(() => { mockEnsureDeviceReady.mockReset(); @@ -53,16 +56,22 @@ beforeEach(() => { test('bound readiness preserves local behavior after the binding fence', async () => { const selected = device('ready-after-bind'); - await ensureBoundDeviceReady({ device: selected, owner: localRuntimeOwner('android') }); + await ensureBoundDeviceReady( + { device: selected, owner: localRuntimeOwner('android') }, + platformServices, + ); - expect(mockEnsureDeviceReady).toHaveBeenCalledWith(selected); + expect(mockEnsureDeviceReady).toHaveBeenCalledWith(selected, platformServices); }); test('bound readiness leaves provider-owned devices alone', async () => { - await ensureBoundDeviceReady({ - device: device('provider-ready'), - owner: providerRuntimeOwner('test', 'provider-ready'), - }); + await ensureBoundDeviceReady( + { + device: device('provider-ready'), + owner: providerRuntimeOwner('test', 'provider-ready'), + }, + daemonPlatformServicesFixture(), + ); expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); }); @@ -84,6 +93,7 @@ test('runtime readiness follows device claim admission', async () => { }); const admission = await admitRuntimeUse({ + platformServices: daemonPlatformServicesFixture(), command: 'logs', device: device('claim-order'), use: appLogInspectUse, diff --git a/src/daemon/__tests__/runtime-binding-conformance.ts b/src/daemon/__tests__/runtime-binding-conformance.ts index a1a896154f..3cbfb8673f 100644 --- a/src/daemon/__tests__/runtime-binding-conformance.ts +++ b/src/daemon/__tests__/runtime-binding-conformance.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, vi } from 'vitest'; import { localRuntimeOwner, @@ -216,7 +217,11 @@ export async function refuseUnavailableExactOwnerFact( const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts); const bindDevice = vi.fn() as unknown as BindDeviceRuntime; - const resolved = await binding.resolve(testCase.device, { inspectFacts, bindDevice }); + const resolved = await binding.resolve(testCase.device, { + inspectFacts, + bindDevice, + platformServices: daemonPlatformServicesFixture(), + }); expect(inspectFacts).toHaveBeenCalledTimes(1); expect(inspectFacts).toHaveBeenCalledWith(testCase.device); diff --git a/src/daemon/__tests__/screenshot-runtime.test.ts b/src/daemon/__tests__/screenshot-runtime.test.ts index 851d99cdb0..776a346381 100644 --- a/src/daemon/__tests__/screenshot-runtime.test.ts +++ b/src/daemon/__tests__/screenshot-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; @@ -68,6 +69,7 @@ test('admits one capture plan, binds once, and hands the runtime the resolved de const req = screenshotRequest({ positionals: [outPath] }); const resolved = await resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -93,6 +95,7 @@ test('an iOS simulator session capture skips the redundant boot probe', async () const req = screenshotRequest({ positionals: [outPath] }); const resolved = await resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -111,6 +114,7 @@ test('refuses an unavailable exact-owner capture fact before binding', async () const session = makeSession('default', { device: ANDROID_EMULATOR }); const resolved = await resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/unsupported.png'] }), session, inspectFacts: fixture.inspectFacts, @@ -140,6 +144,7 @@ test('--overlay-refs is refused up front when the target cannot capture a tree', const session = makeSession('default', { device: ANDROID_EMULATOR }); const resolved = await resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/overlay.png'], flags: { overlayRefs: true } }), session, inspectFacts: fixture.inspectFacts, @@ -172,6 +177,7 @@ test('the default destination is a reserved temp file, removed when the capture const req = screenshotRequest(); const resolved = await resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -190,6 +196,7 @@ test('rejects the retired max-size field before inspecting owner facts', async ( await expect( resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/legacy.png'], flags: { screenshotMaxSize: 720 } as unknown as DaemonRequest['flags'], @@ -208,6 +215,7 @@ test('rejects --pixel-density outside iOS-family simulators before inspecting ow await expect( resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/android.png'], flags: { screenshotPixelDensity: 2 }, @@ -258,6 +266,7 @@ test('the crop runs after the platform write and before the shared scale', async }); const resolved = await resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -306,6 +315,7 @@ test('a partial crop surfaces its warning once in the result record and annotate }); const resolved = await resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -328,6 +338,7 @@ test('the crop plan is refused up front when the target cannot capture a tree', const session = makeSession('default', { device: ANDROID_EMULATOR }); const resolved = await resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/crop.png'], flags: { screenshotCropOn: 'label="Save"' }, @@ -363,6 +374,7 @@ test('the result record carries the display rotation the capture reported', asyn const req = screenshotRequest({ positionals: [outPath] }); const resolved = await resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -382,6 +394,7 @@ test('the result record omits the display rotation when the capture reported non const req = screenshotRequest({ positionals: [outPath] }); const resolved = await resolveScreenshotGenericExecution({ + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, diff --git a/src/daemon/__tests__/scroll-runtime.test.ts b/src/daemon/__tests__/scroll-runtime.test.ts index 99b5861321..6e42ab8fac 100644 --- a/src/daemon/__tests__/scroll-runtime.test.ts +++ b/src/daemon/__tests__/scroll-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, expectTypeOf, test } from 'vitest'; import assert from 'node:assert/strict'; import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; @@ -82,6 +83,7 @@ async function runScroll( flags: dispatch.flags, }; const resolved = await resolveBoundScrollRuntime({ + platformServices: daemonPlatformServicesFixture(), device: session.device, positionals, context: context as DaemonCommandContext, @@ -202,6 +204,7 @@ test('bound scroll rejects duration above the shared cap', async () => { test('bound scroll bottom refuses at admission when the owner declares no capture', async () => { const calls: ScrollCall[] = []; const resolved = await resolveBoundScrollRuntime({ + platformServices: daemonPlatformServicesFixture(), device: IOS_SIMULATOR, session: makeIosSession('scroll-runtime'), positionals: ['bottom'], @@ -475,6 +478,7 @@ test('bound scroll rejects --until on an edge direction before any device work', test('bound scroll --until is refused at admission when the owner declares no capture', async () => { const resolved = await resolveBoundScrollRuntime({ + platformServices: daemonPlatformServicesFixture(), device: IOS_SIMULATOR, session: makeIosSession('scroll-runtime'), positionals: ['down'], diff --git a/src/daemon/__tests__/selector-capture-binding.test.ts b/src/daemon/__tests__/selector-capture-binding.test.ts index d0046d80d7..64865648d8 100644 --- a/src/daemon/__tests__/selector-capture-binding.test.ts +++ b/src/daemon/__tests__/selector-capture-binding.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test } from 'vitest'; import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { @@ -17,6 +18,7 @@ test('an available plan inspects once and binds once, on the admitted device', a const fixture = selectorCaptureFixture(); const bound = await resolveBoundSelectorCapture({ + platformServices: daemonPlatformServicesFixture(), command: 'find', device: ANDROID_EMULATOR, session: makeAndroidSession('selector'), @@ -32,6 +34,7 @@ test('an available plan inspects once and binds once, on the admitted device', a test('repeated captures reuse the one binding the plan was admitted for', async () => { const fixture = selectorCaptureFixture(); const bound = await resolveBoundSelectorCapture({ + platformServices: daemonPlatformServicesFixture(), command: 'wait', device: ANDROID_EMULATOR, session: makeAndroidSession('selector'), @@ -53,6 +56,7 @@ test('an unavailable required operation refuses before any bind', async () => { }); const bound = await resolveBoundSelectorCapture({ + platformServices: daemonPlatformServicesFixture(), command: 'is', device: ANDROID_EMULATOR, session: makeAndroidSession('selector'), @@ -70,6 +74,7 @@ test.each(['find', 'get', 'is'] as const)( const fixture = selectorCaptureFixture({ findText: { available: true } }); const bound = await resolveBoundSelectorCapture({ + platformServices: daemonPlatformServicesFixture(), command, device: ANDROID_EMULATOR, session: makeAndroidSession('selector'), @@ -87,6 +92,7 @@ test('wait rejects an advertised observation with no implementation', async () = await expect( resolveBoundSelectorCapture({ + platformServices: daemonPlatformServicesFixture(), command: 'wait', device: ANDROID_EMULATOR, session: makeAndroidSession('selector'), @@ -104,6 +110,7 @@ test('a session without a tracked app selects the without-active-app plan', asyn }); const withApp = await resolveBoundSelectorCapture({ + platformServices: daemonPlatformServicesFixture(), command: 'get', device: IOS_SIMULATOR, session: makeIosSession('with-app', { appBundleId: 'com.example.app' }), @@ -115,6 +122,7 @@ test('a session without a tracked app selects the without-active-app plan', asyn {}, async () => await resolveBoundSelectorCapture({ + platformServices: daemonPlatformServicesFixture(), command: 'get', device: IOS_SIMULATOR, session: makeIosSession('no-app'), @@ -138,6 +146,7 @@ test('the bound construction path admits and binds before it builds a runtime', const resolved = await createBoundSelectorRuntime( { + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'bound', command: 'get', positionals: [], flags: {} }, sessionName: 'bound', logPath: '/tmp/bound.log', @@ -162,6 +171,7 @@ test('the bound construction path refuses an unavailable operation without build const resolved = await createBoundSelectorRuntime( { + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'bound', command: 'is', positionals: [], flags: {} }, sessionName: 'bound', logPath: '/tmp/bound.log', diff --git a/src/daemon/__tests__/selector-runtime-ambiguity-issuance.test.ts b/src/daemon/__tests__/selector-runtime-ambiguity-issuance.test.ts index 55c1c62f11..11044b97f2 100644 --- a/src/daemon/__tests__/selector-runtime-ambiguity-issuance.test.ts +++ b/src/daemon/__tests__/selector-runtime-ambiguity-issuance.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; @@ -109,6 +110,7 @@ test('an ambiguous is issues its printed candidates so press acts on the listed const fixture = selectorCaptureFixture({ snapshot: () => ambiguousFullCapture() }); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest(sessionName, ['visible', 'label="Deploy"']), sessionName, sessionStore, @@ -153,6 +155,7 @@ test('a plain candidate ref from the ambiguity refusal is refused, not silently const fixture = selectorCaptureFixture({ snapshot: () => ambiguousFullCapture() }); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest(sessionName, ['visible', 'label="Deploy"']), sessionName, sessionStore, @@ -215,6 +218,7 @@ test('an ambiguous is on a sparse capture prints no candidates and issues nothin }); const fixture = selectorCaptureFixture({ snapshot: sparseCapture }); const response = await dispatchIsViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: isRequest(sessionName, ['visible', 'label="Deploy"']), sessionName, sessionStore, diff --git a/src/daemon/__tests__/selector-runtime.test.ts b/src/daemon/__tests__/selector-runtime.test.ts index dafdd1af84..f35d162094 100644 --- a/src/daemon/__tests__/selector-runtime.test.ts +++ b/src/daemon/__tests__/selector-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; @@ -101,6 +102,7 @@ test.each(['rebuild', 'retire'] as const)( [Symbol.asyncDispose]: async () => {}, }; const running = dispatchGetViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/__tests__/session-device-resolution.test.ts b/src/daemon/__tests__/session-device-resolution.test.ts index 8384984c44..42a989aa1a 100644 --- a/src/daemon/__tests__/session-device-resolution.test.ts +++ b/src/daemon/__tests__/session-device-resolution.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import type { SessionState } from '../session-state.ts'; @@ -7,16 +8,11 @@ import { refreshSessionDeviceIfNeeded, selectorTargetsSessionDevice, } from '../session-device-resolution.ts'; -import { appleSessionObservation } from '../../platform-runtime-apple-resources.ts'; import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import { isActiveProviderDevice } from '../provider-device-admission.ts'; import { ensureDeviceReady } from '../device/device-ready.ts'; import { PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors'; -vi.mock('../../platform-runtime-apple-resources.ts', async (importOriginal) => ({ - ...(await importOriginal()), - appleSessionObservation: { observeRunnerSession: vi.fn() }, -})); vi.mock('@agent-device/device-selection/dispatch-resolve', () => ({ resolveTargetDevice: vi.fn(), })); @@ -27,7 +23,15 @@ vi.mock('../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); -const mockObserveRunnerSession = vi.mocked(appleSessionObservation.observeRunnerSession); +// The runner-session probe is stubbed through the port (ADR 0019 section 11), never by mocking +// the platform module behind it. +const mockObserveRunnerSession = vi.fn(); +const platformServices = daemonPlatformServicesFixture({ + appleSessionObservation: { + observeRunnerSession: mockObserveRunnerSession, + resolveSoleForegroundApp: async () => undefined, + }, +}); const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); const mockIsActiveProviderDevice = vi.mocked(isActiveProviderDevice); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); @@ -67,7 +71,7 @@ async function withMockedPlatform(platform: NodeJS.Platform, fn: () => Promis test('refreshSessionDeviceIfNeeded keeps iOS simulator session device on non-mac hosts', async () => { const device = await withMockedPlatform('linux', async () => - refreshSessionDeviceIfNeeded(iosSimulatorSession.device), + refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices), ); expect(device).toBe(iosSimulatorSession.device); @@ -78,6 +82,7 @@ test('resolveCommandDevice keeps an existing session for a platform-only filter' 'linux', async () => await resolveCommandDevice({ + platformServices: platformServices, session: iosSimulatorSession, flags: { platform: 'ios' }, }), @@ -91,7 +96,11 @@ test('resolveCommandDevice does not prepare a sessionless device', async () => { const device = { ...iosSimulatorSession.device, id: 'sessionless-sim' }; mockResolveTargetDevice.mockResolvedValue(device); - await resolveCommandDevice({ session: undefined, flags: { platform: 'ios' } }); + await resolveCommandDevice({ + session: undefined, + flags: { platform: 'ios' }, + platformServices: platformServices, + }); expect(mockResolveTargetDevice).toHaveBeenCalledOnce(); expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); @@ -101,10 +110,10 @@ test('refreshSessionDeviceIfNeeded keeps provider-owned iOS simulators out of lo mockIsActiveProviderDevice.mockReturnValue(true); const device = await withMockedPlatform('darwin', async () => - refreshSessionDeviceIfNeeded({ - ...iosSimulatorSession.device, - id: 'limrun:ios:lease-1', - }), + refreshSessionDeviceIfNeeded( + { ...iosSimulatorSession.device, id: 'limrun:ios:lease-1' }, + platformServices, + ), ); expect(device.id).toBe('limrun:ios:lease-1'); @@ -118,7 +127,7 @@ test('refreshSessionDeviceIfNeeded skips re-resolve while the iOS runner session }); const device = await withMockedPlatform('darwin', async () => - refreshSessionDeviceIfNeeded(iosSimulatorSession.device), + refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices), ); expect(device).toEqual({ ...iosSimulatorSession.device, booted: true }); @@ -134,7 +143,7 @@ test('refreshSessionDeviceIfNeeded re-resolves when the iOS runner session is go mockResolveTargetDevice.mockResolvedValue(resolved); const device = await withMockedPlatform('darwin', async () => - refreshSessionDeviceIfNeeded(iosSimulatorSession.device), + refreshSessionDeviceIfNeeded(iosSimulatorSession.device, platformServices), ); expect(device).toBe(resolved); diff --git a/src/daemon/__tests__/session-idle-activity.test.ts b/src/daemon/__tests__/session-idle-activity.test.ts index cb591d4773..ef0242cc2b 100644 --- a/src/daemon/__tests__/session-idle-activity.test.ts +++ b/src/daemon/__tests__/session-idle-activity.test.ts @@ -7,6 +7,8 @@ * cannot keep another agent's abandoned claim alive; and a request whose client hung up preserves * nothing, exactly as a canceled request renews no lease (ADR 0007). */ + +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { afterAll, test, expect, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -54,6 +56,7 @@ test('a command that completes under the session lock stamps the session it ran const sessionStore = storeWithSession(); const scope = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, () => createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -69,6 +72,7 @@ test('a lock-exempt inventory command reports no activity, even against a claim- const sessionStore = storeWithSession(); const scope = await withDiagnosticsScope({ command: 'devices', logPath: LOG_PATH }, () => createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ command: 'devices' }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -89,6 +93,7 @@ test('a command whose client hung up mid-flight preserves no activity', async () { command: 'snapshot', requestId, logPath: LOG_PATH }, () => createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ command: 'snapshot', meta: { requestId } }), sessionStore, leaseRegistry: new LeaseRegistry(), @@ -116,6 +121,7 @@ test('a command still running when the expiry arrives blocks it until the stamp const locks = getLeaseRegistryExecutionLocks(leaseRegistry); const scope = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, () => createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, @@ -166,6 +172,7 @@ test('the reaper never observes a mid-command session through the shared map', a const leaseRegistry = new LeaseRegistry(); const scope = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, () => createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ command: 'snapshot' }), sessionStore, leaseRegistry, diff --git a/src/daemon/__tests__/snapshot-command-runtime.test.ts b/src/daemon/__tests__/snapshot-command-runtime.test.ts index 347ad83554..08f9de99b1 100644 --- a/src/daemon/__tests__/snapshot-command-runtime.test.ts +++ b/src/daemon/__tests__/snapshot-command-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { afterEach, expect, test } from 'vitest'; import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; @@ -71,8 +72,16 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { }; const running = command === 'snapshot' - ? dispatchSnapshotViaRuntime({ ...input, ...snapshotRuntimeFixture(requestId) }) - : dispatchSnapshotDiffViaRuntime({ ...input, ...snapshotRuntimeFixture(requestId) }); + ? dispatchSnapshotViaRuntime({ + ...input, + ...snapshotRuntimeFixture(requestId), + platformServices: daemonPlatformServicesFixture(), + }) + : dispatchSnapshotDiffViaRuntime({ + ...input, + ...snapshotRuntimeFixture(requestId), + platformServices: daemonPlatformServicesFixture(), + }); await dispatchEntered.promise; markRequestCanceled(requestId); releaseDispatch.resolve(); diff --git a/src/daemon/__tests__/snapshot-diff-runtime.test.ts b/src/daemon/__tests__/snapshot-diff-runtime.test.ts index 7659a251ee..bf6e829991 100644 --- a/src/daemon/__tests__/snapshot-diff-runtime.test.ts +++ b/src/daemon/__tests__/snapshot-diff-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { type DeviceBinding, @@ -126,6 +127,7 @@ test.each([ }); const response = await dispatchSnapshotDiffViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'diff', positionals: ['snapshot'], @@ -185,6 +187,7 @@ test('preserves initialized, unchanged, and changed diff results through one bou const run = async () => await dispatchSnapshotDiffViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'diff', positionals: ['snapshot'], @@ -233,6 +236,7 @@ test('a sparse internal diff capture returns no screenshot fallback artifact', a sessionStore.publish('diff-runtime', { ...harness.session, appBundleId: 'com.example.app' }); const response = await dispatchSnapshotDiffViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'diff', positionals: ['snapshot'], token: 't', session: 'diff-runtime' }, sessionName: 'diff-runtime', logPath: '/tmp/diff-runtime.log', diff --git a/src/daemon/__tests__/snapshot-runtime-binding.test.ts b/src/daemon/__tests__/snapshot-runtime-binding.test.ts index 3b87f1b483..bd79845f69 100644 --- a/src/daemon/__tests__/snapshot-runtime-binding.test.ts +++ b/src/daemon/__tests__/snapshot-runtime-binding.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test, vi } from 'vitest'; import { resolveSnapshotRuntimePlan } from '@agent-device/contracts/platform-runtime-operations'; @@ -42,6 +43,7 @@ test('the owning interface binds exactly the session device the facts were admit const resolved = await resolveBoundSnapshotCaptureRuntime( { + platformServices: daemonPlatformServicesFixture(), req, sessionName: 'bind-test', logPath: '/tmp/bind-test.log', @@ -69,6 +71,7 @@ test('sessionless capture readiness follows runtime binding', async () => { }; const resolved = await admitAndBindSnapshotCapture({ + platformServices: daemonPlatformServicesFixture(), command: 'snapshot', device: IOS_SIMULATOR, session: undefined, diff --git a/src/daemon/__tests__/snapshot-runtime-fixture.ts b/src/daemon/__tests__/snapshot-runtime-fixture.ts index 8e99ba5884..b522f00baa 100644 --- a/src/daemon/__tests__/snapshot-runtime-fixture.ts +++ b/src/daemon/__tests__/snapshot-runtime-fixture.ts @@ -5,6 +5,8 @@ import { providerRuntimeOwner, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; +import type { DaemonPlatformServices } from '../platform-services.ts'; import { legacyDispatchCapture } from './legacy-snapshot-capture-fixture.ts'; import { type AlertRuntimeInput, @@ -80,6 +82,7 @@ export function resetSnapshotRuntimeFixture(): void { export function snapshotRuntimeFixture(requestId?: string): Readonly<{ inspectFacts: InspectDeviceRuntimeFacts; bindDevice: BindDeviceRuntime; + platformServices: DaemonPlatformServices; }> { const requestSignal = getRequestSignal(requestId) ?? new AbortController().signal; @@ -147,7 +150,7 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ ); }; - return { inspectFacts, bindDevice }; + return { inspectFacts, bindDevice, platformServices: daemonPlatformServicesFixture() }; } /** A physical Apple device that is not the macOS host: the one settings refusal these suites use. */ diff --git a/src/daemon/__tests__/sparse-fallback-screenshot.test.ts b/src/daemon/__tests__/sparse-fallback-screenshot.test.ts index 703c779cdb..fdc0aa4373 100644 --- a/src/daemon/__tests__/sparse-fallback-screenshot.test.ts +++ b/src/daemon/__tests__/sparse-fallback-screenshot.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import path from 'node:path'; import { expect, test } from 'vitest'; import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; @@ -56,6 +57,7 @@ async function dispatch( internalObservation = false, ) { const response = await dispatchSnapshotViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'snapshot', positionals: [], diff --git a/src/daemon/__tests__/system-button-runtime.test.ts b/src/daemon/__tests__/system-button-runtime.test.ts index 6bd6971cb4..18bc3c460f 100644 --- a/src/daemon/__tests__/system-button-runtime.test.ts +++ b/src/daemon/__tests__/system-button-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { systemButtonRuntimeOperationFacts } from '@agent-device/contracts/system-button-runtime'; import { @@ -137,6 +138,7 @@ test.each(Object.keys(EXPECTED) as SystemButtonCommand[])( const harness = runtimeHarness(expected.button); const resolved = await resolveBoundSystemButtonRuntime(command, { + platformServices: daemonPlatformServicesFixture(), device: iosSimulator, inspectFacts: harness.inspectFacts, bindDevice: harness.bindDevice, diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index 1e60d65474..b66bf6081d 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -25,6 +25,7 @@ import { createUnavailableRuntimeFactsForTest } from '../../__tests__/test-utils import { withClientReplayScriptSources } from '../../__tests__/test-utils/replay-script-source.ts'; import type { DaemonInvokeFn } from '../daemon-request.ts'; import { clearAndroidObservationFixture } from './android-observation-fixture.ts'; +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; import { readDaemonProviderCredentials } from '../../provider-credential-fingerprint.ts'; @@ -222,18 +223,25 @@ export const unavailableBindExactDevice: BindExactDeviceRuntime = async ( ); export function createRequestHandler( - deps: Omit & - Partial>, + deps: Omit< + RequestRouterDeps, + 'deviceRuntimeGateway' | 'providerCredentials' | 'platformServices' + > & + Partial< + Pick + >, ) { const { deviceRuntimeGateway = unavailableDeviceRuntimeGateway, providerCredentials = readDaemonProviderCredentials({}, path.dirname(deps.logPath)), + platformServices = daemonPlatformServicesFixture(), ...rest } = deps; const handle = createProductionRequestHandler({ androidObservation: clearAndroidObservationFixture, platformResourceCleanup, ...rest, + platformServices, deviceRuntimeGateway, providerCredentials, }); diff --git a/src/daemon/__tests__/tv-remote-runtime.test.ts b/src/daemon/__tests__/tv-remote-runtime.test.ts index d5557556b7..4633b9d168 100644 --- a/src/daemon/__tests__/tv-remote-runtime.test.ts +++ b/src/daemon/__tests__/tv-remote-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { localRuntimeOwner, @@ -86,6 +87,7 @@ test('resolves one admitted binding and presses one remote button', async () => const harness = runtimeHarness(tvRemoteRuntimeOperationFacts({ tvRemote: available }).tvRemote); const resolved = await resolveBoundTvRemoteRuntime({ + platformServices: daemonPlatformServicesFixture(), device: vegaVvd, positionals: ['down'], inspectFacts: harness.inspectFacts, @@ -109,6 +111,7 @@ test('forwards a validated duration and reports it in the response', async () => const harness = runtimeHarness(); const resolved = await resolveBoundTvRemoteRuntime({ + platformServices: daemonPlatformServicesFixture(), device: vegaVvd, positionals: ['select'], durationMs: 500, @@ -133,6 +136,7 @@ test('rejects an out-of-range duration before inspection or binding', async () = await expect( resolveBoundTvRemoteRuntime({ + platformServices: daemonPlatformServicesFixture(), device: vegaVvd, positionals: ['down'], durationMs: 50_000, @@ -148,6 +152,7 @@ test('rejects a missing button before inspection or binding', async () => { await expect( resolveBoundTvRemoteRuntime({ + platformServices: daemonPlatformServicesFixture(), device: vegaVvd, positionals: [], inspectFacts: harness.inspectFacts, diff --git a/src/daemon/__tests__/type-text-runtime.test.ts b/src/daemon/__tests__/type-text-runtime.test.ts index c0bd09e0f4..0a842678b3 100644 --- a/src/daemon/__tests__/type-text-runtime.test.ts +++ b/src/daemon/__tests__/type-text-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { type DeviceBinding, @@ -61,6 +62,7 @@ test('resolves one admitted binding and reproduces the retired leaf result exact const harness = runtimeHarness(typeTextRuntimeOperationFacts({ type: available }).typeText); const resolved = await resolveBoundTypeTextRuntime({ + platformServices: daemonPlatformServicesFixture(), device: appleDevice, inspectFacts: harness.inspectFacts, bindDevice: harness.bindDevice, @@ -104,6 +106,7 @@ test('omits textEntryRoute when the owner types blind, exactly as the leaf did', harness.typeText.mockResolvedValueOnce(undefined as never); const resolved = await resolveBoundTypeTextRuntime({ + platformServices: daemonPlatformServicesFixture(), device: appleDevice, inspectFacts: harness.inspectFacts, bindDevice: harness.bindDevice, @@ -119,6 +122,7 @@ test('omits textEntryRoute when the owner types blind, exactly as the leaf did', test('parses exactly as the retired leaf did: refs rejected, spaces joined, delay bounded', async () => { const harness = runtimeHarness(); const resolved = await resolveBoundTypeTextRuntime({ + platformServices: daemonPlatformServicesFixture(), device: appleDevice, inspectFacts: harness.inspectFacts, bindDevice: harness.bindDevice, diff --git a/src/daemon/__tests__/viewport-runtime.test.ts b/src/daemon/__tests__/viewport-runtime.test.ts index 7ec24ef764..9d34baaab9 100644 --- a/src/daemon/__tests__/viewport-runtime.test.ts +++ b/src/daemon/__tests__/viewport-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { type DeviceBinding, @@ -104,6 +105,7 @@ test('resolves one admitted binding and exposes one normalized viewport operatio ); const resolved = await resolveBoundViewportRuntime({ + platformServices: daemonPlatformServicesFixture(), device: webDevice, positionals: ['1280', '900'], inspectFacts: harness.inspectFacts, @@ -130,6 +132,7 @@ test('rejects invalid dimensions before inspection or binding', async () => { await expect( resolveBoundViewportRuntime({ + platformServices: daemonPlatformServicesFixture(), device: webDevice, positionals: ['0', '900'], inspectFacts: harness.inspectFacts, diff --git a/src/daemon/__tests__/wait-runtime.test.ts b/src/daemon/__tests__/wait-runtime.test.ts index 3871013494..d4983686eb 100644 --- a/src/daemon/__tests__/wait-runtime.test.ts +++ b/src/daemon/__tests__/wait-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { WAIT_REASONS, type ReadinessPhase } from '@agent-device/contracts/wait'; import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; @@ -158,6 +159,7 @@ test.each(['rebuild', 'retire'] as const)( const address = 'cwd:sleep-wait:default'; const ref = sessionStore.publish(address, makeSession('default', { device: harness.device })); const running = handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: waitRequest(['100']), sessionName: address, logPath: '/tmp/daemon.log', @@ -212,6 +214,7 @@ async function runWait( } sessionStore.publish(session.name, session); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: waitRequest(positionals, flags), sessionName: session.name, logPath: '/tmp/daemon.log', @@ -246,6 +249,7 @@ test('an observing wait binds through the shared selector entry', async () => { const harness = waitRuntimeHarness({ findText: available }); const bound = await resolveBoundSelectorCapture({ + platformServices: daemonPlatformServicesFixture(), command: 'wait', session: undefined, device: harness.device, @@ -465,6 +469,7 @@ test('a read after a natively satisfied text wait captures instead of reusing th expect(harness.captureSnapshot).toHaveBeenCalledOnce(); const read = await dispatchGetViaRuntime({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'get', positionals: ['text', 'label="cold.start"'], diff --git a/src/daemon/android-snapshot-timeout-evidence.ts b/src/daemon/android-snapshot-timeout-evidence.ts index 039d3bb7e5..11b8b68197 100644 --- a/src/daemon/android-snapshot-timeout-evidence.ts +++ b/src/daemon/android-snapshot-timeout-evidence.ts @@ -72,6 +72,7 @@ async function captureAndroidSnapshotTimeoutEvidence( device: params.device, overlayRefs: false, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (!capture.ok) { diff --git a/src/daemon/app-event-runtime.ts b/src/daemon/app-event-runtime.ts index 4136d54cf8..e74790bb32 100644 --- a/src/daemon/app-event-runtime.ts +++ b/src/daemon/app-event-runtime.ts @@ -62,13 +62,14 @@ export async function resolveBoundAppEventRuntime( }> & RuntimeAdmissionBindings, ): Promise { - const { device, positionals, inspectFacts, bindDevice, readiness } = params; + const { device, positionals, inspectFacts, bindDevice, platformServices, readiness } = params; const admission = await admitRuntimeUse({ command: 'trigger-app-event', device, use: appEventRuntimeUse, inspectFacts, bindDevice, + platformServices, readiness, }); if (admission.type === 'response') return { ok: false, response: admission.response }; diff --git a/src/daemon/back-runtime.ts b/src/daemon/back-runtime.ts index 8b40b7bef9..e63c7ba663 100644 --- a/src/daemon/back-runtime.ts +++ b/src/daemon/back-runtime.ts @@ -34,6 +34,7 @@ export async function resolveBoundBackRuntime( device: params.device, use: backRuntimeUse, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }, executeBack, diff --git a/src/daemon/device/__tests__/device-claim-admission.test.ts b/src/daemon/device/__tests__/device-claim-admission.test.ts index ef15cbf793..2ebad41d23 100644 --- a/src/daemon/device/__tests__/device-claim-admission.test.ts +++ b/src/daemon/device/__tests__/device-claim-admission.test.ts @@ -1,4 +1,5 @@ import { expect, test } from 'vitest'; +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { localRuntimeOwner, providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; @@ -216,6 +217,7 @@ test('the none policy still reaches no device state at all', async () => { /** Claims visible while one command holds a device binding from the real request scope. */ async function claimsWhileBound(command: string, stateDir: string) { const scope = await createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', command, positionals: [], flags: {} }, sessionStore: new SessionStore(path.join(stateDir, 'sessions')), leaseRegistry: new LeaseRegistry(), diff --git a/src/daemon/device/__tests__/device-ready.test.ts b/src/daemon/device/__tests__/device-ready.test.ts index 9fbf9012fb..0aaad90758 100644 --- a/src/daemon/device/__tests__/device-ready.test.ts +++ b/src/daemon/device/__tests__/device-ready.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { promises as fs } from 'node:fs'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -32,6 +33,9 @@ import { DEVICE_READY_CACHE_TTL_MS, ensureDeviceReady } from '../device-ready.ts const mockRunCmd = vi.mocked(runCmd); const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator); const mockWaitForAndroidBoot = vi.mocked(waitForAndroidBoot); +// Readiness mechanics stay the real ones: the fixture delegates to the root adapter, so the +// platform-leaf mocks above still own what the port actually calls. +const platformServices = daemonPlatformServicesFixture(); beforeEach(() => { vi.useFakeTimers(); @@ -50,8 +54,8 @@ afterEach(() => { test('ensureDeviceReady caches successful simulator readiness checks', async () => { const device: DeviceInfo = { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' }; - await ensureDeviceReady(device); - await ensureDeviceReady({ ...device }); + await ensureDeviceReady(device, platformServices); + await ensureDeviceReady({ ...device }, platformServices); expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(1); expect(mockEnsureBootedSimulator).toHaveBeenCalledWith(device); @@ -73,25 +77,34 @@ test('ensureDeviceReady caches successful iOS physical device readiness checks', return { stdout: '', stderr: '', exitCode: 0 }; }); - await ensureDeviceReady(IOS_DEVICE); - await ensureDeviceReady({ ...IOS_DEVICE, simulatorSetPath: '/ignored-for-physical-device' }); + await ensureDeviceReady(IOS_DEVICE, platformServices); + await ensureDeviceReady( + { ...IOS_DEVICE, simulatorSetPath: '/ignored-for-physical-device' }, + platformServices, + ); expect(mockRunCmd).toHaveBeenCalledTimes(1); }); test('ensureDeviceReady includes simulator set path in the cache key', async () => { - await ensureDeviceReady({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' }); - await ensureDeviceReady({ ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-b' }); + await ensureDeviceReady( + { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-a' }, + platformServices, + ); + await ensureDeviceReady( + { ...IOS_SIMULATOR, simulatorSetPath: '/tmp/simset-b' }, + platformServices, + ); expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2); }); test('ensureDeviceReady expires cached readiness checks after the ttl', async () => { - await ensureDeviceReady(ANDROID_EMULATOR); + await ensureDeviceReady(ANDROID_EMULATOR, platformServices); vi.setSystemTime(new Date(Date.now() + DEVICE_READY_CACHE_TTL_MS - 1)); - await ensureDeviceReady({ ...ANDROID_EMULATOR }); + await ensureDeviceReady({ ...ANDROID_EMULATOR }, platformServices); vi.setSystemTime(new Date(Date.now() + 1)); - await ensureDeviceReady({ ...ANDROID_EMULATOR }); + await ensureDeviceReady({ ...ANDROID_EMULATOR }, platformServices); expect(mockWaitForAndroidBoot).toHaveBeenCalledTimes(2); }); @@ -99,8 +112,8 @@ test('ensureDeviceReady expires cached readiness checks after the ttl', async () test('ensureDeviceReady does not cache failed readiness checks', async () => { mockEnsureBootedSimulator.mockRejectedValueOnce(new Error('boot failed')); - await expect(ensureDeviceReady(IOS_SIMULATOR)).rejects.toThrow('boot failed'); - await ensureDeviceReady(IOS_SIMULATOR); + await expect(ensureDeviceReady(IOS_SIMULATOR, platformServices)).rejects.toThrow('boot failed'); + await ensureDeviceReady(IOS_SIMULATOR, platformServices); expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2); }); diff --git a/src/daemon/device/device-ready.ts b/src/daemon/device/device-ready.ts index 6ee073517e..3f1d2f7d5d 100644 --- a/src/daemon/device/device-ready.ts +++ b/src/daemon/device/device-ready.ts @@ -1,5 +1,5 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; -import { ensureLocalPlatformDeviceReady } from '../../platform-runtime-device-ready.ts'; +import type { DaemonPlatformServices } from '../platform-services.ts'; import { isActiveProviderDevice } from '../provider-device-admission.ts'; import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; @@ -8,13 +8,21 @@ export const DEVICE_READY_CACHE_TTL_MS = 5_000; const readyCache = createTtlMemo({ ttlMs: DEVICE_READY_CACHE_TTL_MS }); -export async function ensureDeviceReady(device: DeviceInfo): Promise { +/** + * Local readiness for one admitted device. The provider-ownership check and the scoped TTL cache + * stay here; the concrete mechanics arrive through the request's platform-services port, so this + * module names no platform and loads none. + */ +export async function ensureDeviceReady( + device: DeviceInfo, + platformServices: DaemonPlatformServices, +): Promise { if (isActiveProviderDevice(device)) return; const cacheKey = deviceReadyCacheKey(device); if (readyCache.get(cacheKey) === true) return; - const handled = await ensureLocalPlatformDeviceReady(device); + const handled = await platformServices.ensureLocalDeviceReady(device); if (handled) { markDeviceReady(cacheKey); } diff --git a/src/daemon/focus-runtime.ts b/src/daemon/focus-runtime.ts index 9eedb3935a..c16e628e66 100644 --- a/src/daemon/focus-runtime.ts +++ b/src/daemon/focus-runtime.ts @@ -41,6 +41,7 @@ export async function resolveBoundFocusRuntime( device: params.device, use: focusRuntimeUse, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }, (runtime, context) => executeFocusPoint(runtime, point, context), diff --git a/src/daemon/fold-runtime.ts b/src/daemon/fold-runtime.ts index cbf21a97e8..d94abe7f4e 100644 --- a/src/daemon/fold-runtime.ts +++ b/src/daemon/fold-runtime.ts @@ -42,6 +42,7 @@ export async function resolveBoundFoldRuntime( device: params.device, use: foldRuntimeUse, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }, (runtime) => executeSetFoldPose(runtime, input), diff --git a/src/daemon/generic-runtime-execution.ts b/src/daemon/generic-runtime-execution.ts index 6f7d89cc3d..97f1799a4e 100644 --- a/src/daemon/generic-runtime-execution.ts +++ b/src/daemon/generic-runtime-execution.ts @@ -36,6 +36,7 @@ export async function resolveGenericRuntimeExecution( return await resolveBoundSystemButtonRuntime(params.req.command, { device: params.session.device, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); } @@ -52,6 +53,7 @@ export async function resolveGenericRuntimeExecution( device: params.session.device, positionals: params.req.positionals ?? [], inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); case 'scroll': @@ -65,6 +67,7 @@ export async function resolveGenericRuntimeExecution( session: params.session, flags: params.req.flags, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); case 'viewport': @@ -72,12 +75,14 @@ export async function resolveGenericRuntimeExecution( device: params.session.device, positionals: params.req.positionals ?? [], inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); case 'back': return await resolveBoundBackRuntime({ device: params.session.device, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); case 'orientation': @@ -85,6 +90,7 @@ export async function resolveGenericRuntimeExecution( device: params.session.device, positionals: params.req.positionals ?? [], inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); case 'fold': @@ -93,6 +99,7 @@ export async function resolveGenericRuntimeExecution( device: params.session.device, positionals: params.req.positionals ?? [], inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); case 'tv-remote': @@ -101,6 +108,7 @@ export async function resolveGenericRuntimeExecution( positionals: params.req.positionals ?? [], durationMs: params.req.flags?.durationMs, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); default: diff --git a/src/daemon/generic-settle.ts b/src/daemon/generic-settle.ts index 9bd5d09a63..6be5c1b48a 100644 --- a/src/daemon/generic-settle.ts +++ b/src/daemon/generic-settle.ts @@ -12,6 +12,7 @@ import { settleFlagGuardResponse, } from './interaction/index.ts'; import type { BoundContextFromFlags } from './context.ts'; +import type { DaemonPlatformServices } from './platform-services.ts'; import { issueSettleRefs } from './session-snapshot.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; @@ -62,6 +63,7 @@ type GenericSettleContext = { logPath: string; sessionStore: SessionStore; contextFromFlags: BoundContextFromFlags; + platformServices: DaemonPlatformServices; }; /** @@ -127,6 +129,7 @@ function createGenericSettleRuntime( logPath: context.logPath, sessionStore: context.sessionStore, contextFromFlags: context.contextFromFlags, + platformServices: context.platformServices, captureSnapshotForSession, }); } catch { diff --git a/src/daemon/gesture-runtime.ts b/src/daemon/gesture-runtime.ts index c72fb31053..2727fc9ff0 100644 --- a/src/daemon/gesture-runtime.ts +++ b/src/daemon/gesture-runtime.ts @@ -65,6 +65,7 @@ export async function resolveBoundGestureViewportRuntime( device: params.device, use: gestureViewportRuntimeUse, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (admission.type === 'response') return { ok: false, response: admission.response }; @@ -93,6 +94,7 @@ export async function resolveBoundGestureRuntime( device: params.device, required: plan.use.required, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, // The retired admission THREW an `AppError`, which the gesture handler's catch normalized — // so the refusal is built the same way here. Going through `errorResponse` instead would diff --git a/src/daemon/handlers/__tests__/human-control.test.ts b/src/daemon/handlers/__tests__/human-control.test.ts index ea816cfd55..2463a74190 100644 --- a/src/daemon/handlers/__tests__/human-control.test.ts +++ b/src/daemon/handlers/__tests__/human-control.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { test } from 'vitest'; import { createRequestExecutionScope } from '../../request-execution-scope.ts'; import { @@ -31,6 +32,7 @@ test('lease-owner takeover uses the production admission gate, preserves the ses assert.equal(release.ok, true); assert.deepEqual(registry.listHumanControlHolds({ kind: 'host' }), []); const scope = await createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: humanControlRequest(lease, 'click', []), sessionStore, leaseRegistry: registry, @@ -82,7 +84,12 @@ test('admitted tenants cannot retarget takeover or release provider-host holds', test('a fresh-session mutation without an advisory device lock still drains before host activation', async () => { const { registry, lease, sessionStore } = createHumanControlHarness(); const req = { ...humanControlRequest(lease, 'click', []), session: 'fresh-session' }; - const scope = await createRequestExecutionScope({ req, sessionStore, leaseRegistry: registry }); + const scope = await createRequestExecutionScope({ + req, + sessionStore, + leaseRegistry: registry, + platformServices: daemonPlatformServicesFixture(), + }); const started = createControlLatch(); const finish = createControlLatch(); const mutation = scope.runLocked(async () => { @@ -99,6 +106,7 @@ test('a fresh-session mutation without an advisory device lock still drains befo await Promise.resolve(); assert.equal(active, false); const later = await createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: { ...req, session: 'another-fresh-session' }, sessionStore, leaseRegistry: registry, @@ -116,13 +124,19 @@ test('a fresh-session mutation without an advisory device lock still drains befo test('a nested mutation is stopped when takeover begins during its parent request', async () => { const { registry, lease, sessionStore } = createHumanControlHarness(); const req = humanControlRequest(lease, 'replay', []); - const scope = await createRequestExecutionScope({ req, sessionStore, leaseRegistry: registry }); + const scope = await createRequestExecutionScope({ + req, + sessionStore, + leaseRegistry: registry, + platformServices: daemonPlatformServicesFixture(), + }); let activation: Promise | undefined; await scope.runLocked(async () => { activation = registry.putHumanControlHold({ kind: 'host' }, 'host', { scope: HUMAN_CONTROL_SCOPE, }); const nested = await createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: humanControlRequest(lease, 'click', []), sessionStore, leaseRegistry: registry, diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index e1243b8412..09f66ea6d9 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; @@ -88,6 +89,7 @@ test('install_source materializes and deploys through one admitted runtime bindi const runtime = createSourceRuntime(session.device, materialize, deploy); const response = await handleInstallFromSourceDeploymentCommand({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip', headers: {} }), sessionName: session.name, sessionStore: store, @@ -133,6 +135,7 @@ test('install_source rejects unavailable facts before binding or materializing', }); const response = await handleInstallFromSourceDeploymentCommand({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, sessionStore: store, @@ -170,6 +173,7 @@ test('install_source fails closed when a provider owner does not expose readines }); const response = await handleInstallFromSourceDeploymentCommand({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, sessionStore: store, @@ -226,6 +230,7 @@ test('install_source cleans materialized paths when deployment fails after admis ); const response = await handleInstallFromSourceDeploymentCommand({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, sessionStore: store, @@ -257,6 +262,7 @@ test('install_source preserves the Android identity failure when its runtime can ); const response = await handleInstallFromSourceDeploymentCommand({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'url', url: 'https://example.com/app.zip' }), sessionName: session.name, sessionStore: store, @@ -292,6 +298,7 @@ test('install_source returns the typed iOS artifact identity supplied by its run ); const response = await handleInstallFromSourceDeploymentCommand({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'path', path: '/tmp/AgentDeviceTester.app' }), sessionName: session.name, sessionStore: store, @@ -338,6 +345,7 @@ test('install_source accepts the public leaf selector of an Apple session it is ); const response = await handleInstallFromSourceDeploymentCommand({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'path', path: '/tmp/App.app' }, { platform: 'ios' }), sessionName: session.name, sessionStore: store, @@ -366,6 +374,7 @@ test('install_source still refuses a leaf selector that names a different platfo ); const response = await handleInstallFromSourceDeploymentCommand({ + platformServices: daemonPlatformServicesFixture(), req: makeRequest({ kind: 'path', path: '/tmp/App.app' }, { platform: 'android' }), sessionName: session.name, sessionStore: store, diff --git a/src/daemon/handlers/__tests__/record-runtime.fixtures.ts b/src/daemon/handlers/__tests__/record-runtime.fixtures.ts index d7afd8e640..0a146169d0 100644 --- a/src/daemon/handlers/__tests__/record-runtime.fixtures.ts +++ b/src/daemon/handlers/__tests__/record-runtime.fixtures.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { expect, vi } from 'vitest'; import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; @@ -73,6 +74,7 @@ export function makeRecordRuntimeHarness( runtime, run: (positionals: string[], meta?: DaemonRequest['meta']) => handleRecordCommand({ + platformServices: daemonPlatformServicesFixture(), ...common, req: { token: 'token', diff --git a/src/daemon/handlers/__tests__/session-appstate-input.test.ts b/src/daemon/handlers/__tests__/session-appstate-input.test.ts index 308f84dcc8..6d3b65af03 100644 --- a/src/daemon/handlers/__tests__/session-appstate-input.test.ts +++ b/src/daemon/handlers/__tests__/session-appstate-input.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { test, expect } from 'vitest'; import * as path from 'node:path'; import { @@ -54,6 +55,7 @@ test('appstate on iOS requires active session on selected device', async () => { mockDispatch.mockRejectedValue(new Error('snapshot dispatch should not run')); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -100,6 +102,7 @@ test('appstate returns session appName when bundle id is unavailable', async () mockDispatch.mockRejectedValue(new Error('snapshot dispatch should not run')); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -149,6 +152,7 @@ test('appstate fails when iOS session has no tracked app', async () => { mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -182,6 +186,7 @@ test('appstate without session on iOS selector returns SESSION_NOT_FOUND', async mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -205,6 +210,7 @@ test('appstate without session on iOS selector returns SESSION_NOT_FOUND', async test('appstate with explicit missing session returns SESSION_NOT_FOUND', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'sim', @@ -230,6 +236,7 @@ test('appstate with explicit missing session returns SESSION_NOT_FOUND', async ( test('clipboard requires an active session or explicit device selector', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -270,6 +277,7 @@ test('clipboard rejects unsupported iOS physical devices', async () => { mockDispatch.mockRejectedValue(new Error('dispatch should not run for unsupported targets')); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -355,6 +363,7 @@ test('appstate on iOS reads the session app state from the runner when its owner }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: iosSessionRequest(sessionName), sessionName, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -386,6 +395,7 @@ test('appstate on iOS keeps the session answer, with no state, when no runner is const runtime = appleAppStateRuntime(async () => ({})); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: iosSessionRequest(sessionName), sessionName, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -415,6 +425,7 @@ test('appstate on iOS keeps the session answer, with no state, when the runner c }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: iosSessionRequest(sessionName), sessionName, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts b/src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts index 8c1b619c22..be2e1142b7 100644 --- a/src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-boot-shutdown-device-claims.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { expect, test } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -67,6 +68,7 @@ async function runStateCommand( sessionStore: store, leaseRegistry: new LeaseRegistry(), deviceRuntimeGateway: readinessDeviceRuntimeGateway, + platformServices: daemonPlatformServicesFixture(), platformRequestScope: { signal: new AbortController().signal, diagnostics: { emit: () => {} }, @@ -75,6 +77,7 @@ async function runStateCommand( }); try { return await handleSessionStateCommands({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: 'default', sessionStore: store, diff --git a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts index a9b87ae488..b938fc53ce 100644 --- a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { test, expect } from 'vitest'; import * as path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; @@ -22,6 +23,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; test('boot requires session or explicit selector', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -64,6 +66,7 @@ test('boot prefers explicit device selector over active session device', async ( mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -108,6 +111,7 @@ test('boot --timeout forwards a startup deadline to bootTarget (#3004)', async ( const beforeMs = Date.now(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -141,6 +145,7 @@ test('boot without --timeout leaves the startup deadline unset', async () => { mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -176,6 +181,7 @@ test('boot --headless admits a stopped Android emulator through facts and binds booted: true, }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -220,6 +226,7 @@ test('boot rejects the macOS host boot cell after one facts inspection and befor mockResolveTargetDevice.mockResolvedValue(device); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -256,6 +263,7 @@ test('boot admits a stopped Android emulator through normal readiness', async () booted: true, }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -293,6 +301,7 @@ test('boot forwards Android serial admission policy to readiness', async () => { }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -327,6 +336,7 @@ test('boot --headless requires avd selector when device cannot be resolved', asy const sessionStore = makeSessionStore(); mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -362,6 +372,7 @@ test('boot --headless rejects non-Android selectors', async () => { booted: false, }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -395,6 +406,7 @@ test('boot keeps --target validation before facts inspection', async () => { booted: false, }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -431,6 +443,7 @@ test('shutdown turns off selected iOS simulator', async () => { mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -477,6 +490,7 @@ test('shutdown rejects active session device and points to close --shutdown', as mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -516,6 +530,7 @@ test('shutdown turns off selected Android emulator', async () => { }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -563,6 +578,7 @@ test('shutdown rejects unsupported physical devices', async () => { }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -606,6 +622,7 @@ test('shutdown returns an error response when selected target shutdown fails', a }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/handlers/__tests__/session-clipboard.test.ts b/src/daemon/handlers/__tests__/session-clipboard.test.ts index b6cb57eb3d..0111a11351 100644 --- a/src/daemon/handlers/__tests__/session-clipboard.test.ts +++ b/src/daemon/handlers/__tests__/session-clipboard.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; import { @@ -91,7 +92,11 @@ function request(positionals: string[]) { test('clipboard read admits clipboardReadUse and reports the platform-labelled text', async () => { const spies = harness({ read: available, write: available }); - const response = await handleSessionClipboardCommand({ ...request(['read']), ...spies }); + const response = await handleSessionClipboardCommand({ + ...request(['read']), + ...spies, + platformServices: daemonPlatformServicesFixture(), + }); expect(response.ok).toBe(true); expect(response.ok && response.data).toEqual({ @@ -105,7 +110,11 @@ test('clipboard read admits clipboardReadUse and reports the platform-labelled t test('clipboard readiness follows runtime binding', async () => { const spies = harness({ read: available, write: available }); - const response = await handleSessionClipboardCommand({ ...request(['read']), ...spies }); + const response = await handleSessionClipboardCommand({ + ...request(['read']), + ...spies, + platformServices: daemonPlatformServicesFixture(), + }); expect(response.ok).toBe(true); expect(spies.bindDevice).toHaveBeenCalledTimes(1); @@ -121,6 +130,7 @@ test('clipboard readiness follows runtime binding', async () => { test('clipboard write joins its positionals and reports the code-point length', async () => { const spies = harness({ read: available, write: available }); const response = await handleSessionClipboardCommand({ + platformServices: daemonPlatformServicesFixture(), ...request(['write', 'hello', 'wörld']), ...spies, }); @@ -142,7 +152,11 @@ test('clipboard write joins its positionals and reports the code-point length', // forward — not the missing argument the length check would otherwise reject. test('clipboard write forwards an explicit empty string as a clear', async () => { const spies = harness({ read: available, write: available }); - const response = await handleSessionClipboardCommand({ ...request(['write', '']), ...spies }); + const response = await handleSessionClipboardCommand({ + ...request(['write', '']), + ...spies, + platformServices: daemonPlatformServicesFixture(), + }); expect(response.ok).toBe(true); expect(spies.writeClipboard).toHaveBeenCalledWith(expect.objectContaining({ text: '' })); @@ -155,7 +169,11 @@ test.each([{ positionals: ['read'] }, { positionals: ['write', 'text'] }])( 'clipboard $positionals.0 inspects facts once and binds once', async ({ positionals }) => { const spies = harness({ read: available, write: available }); - await handleSessionClipboardCommand({ ...request(positionals), ...spies }); + await handleSessionClipboardCommand({ + ...request(positionals), + ...spies, + platformServices: daemonPlatformServicesFixture(), + }); expect(spies.inspectFacts).toHaveBeenCalledTimes(1); expect(spies.bindDevice).toHaveBeenCalledTimes(1); @@ -164,7 +182,11 @@ test.each([{ positionals: ['read'] }, { positionals: ['write', 'text'] }])( test('an unadmitted cell refuses with the retired capability gate wording and its owner hint', async () => { const spies = harness({ read: unavailable, write: unavailable }); - const response = await handleSessionClipboardCommand({ ...request(['read']), ...spies }); + const response = await handleSessionClipboardCommand({ + ...request(['read']), + ...spies, + platformServices: daemonPlatformServicesFixture(), + }); expect(response.ok).toBe(false); if (!response.ok) { @@ -180,18 +202,27 @@ test('a write-only refusal still admits the read', async () => { const spies = harness({ read: available, write: unavailable }); const write = await handleSessionClipboardCommand({ + platformServices: daemonPlatformServicesFixture(), ...request(['write', 'text']), ...spies, }); expect(write.ok).toBe(false); - const read = await handleSessionClipboardCommand({ ...request(['read']), ...spies }); + const read = await handleSessionClipboardCommand({ + ...request(['read']), + ...spies, + platformServices: daemonPlatformServicesFixture(), + }); expect(read.ok).toBe(true); }); test('an unknown subcommand fails before any device is resolved', async () => { const spies = harness({ read: available, write: available }); - const response = await handleSessionClipboardCommand({ ...request(['paste']), ...spies }); + const response = await handleSessionClipboardCommand({ + ...request(['paste']), + ...spies, + platformServices: daemonPlatformServicesFixture(), + }); expect(response.ok).toBe(false); if (!response.ok) { @@ -206,15 +237,23 @@ test('an unknown subcommand fails before any device is resolved', async () => { test('clipboard read rejects extra arguments', async () => { const spies = harness({ read: available, write: available }); await expect( - handleSessionClipboardCommand({ ...request(['read', 'extra']), ...spies }), + handleSessionClipboardCommand({ + ...request(['read', 'extra']), + ...spies, + platformServices: daemonPlatformServicesFixture(), + }), ).rejects.toThrow('clipboard read does not accept additional arguments'); }); test('clipboard write with no text argument reports how to clear instead', async () => { const spies = harness({ read: available, write: available }); - await expect(handleSessionClipboardCommand({ ...request(['write']), ...spies })).rejects.toThrow( - 'clipboard write requires text (use "" to clear clipboard)', - ); + await expect( + handleSessionClipboardCommand({ + ...request(['write']), + ...spies, + platformServices: daemonPlatformServicesFixture(), + }), + ).rejects.toThrow('clipboard write requires text (use "" to clear clipboard)'); }); for (const action of ['read', 'write'] as const) { @@ -229,6 +268,7 @@ for (const action of ['read', 'write'] as const) { }); await expect( handleSessionClipboardCommand({ + platformServices: daemonPlatformServicesFixture(), ...input, req: { ...input.req, flags: { platform: 'android', serial: androidDevice.id } }, ...spies, @@ -252,7 +292,11 @@ test('clipboard read succeeds without journaling after its admitted lifetime end input.sessionStore.publish(input.sessionName, successor); return 'copied text'; }); - const response = await handleSessionClipboardCommand({ ...input, ...spies }); + const response = await handleSessionClipboardCommand({ + ...input, + ...spies, + platformServices: daemonPlatformServicesFixture(), + }); expect(response?.ok).toBe(true); expect(spies.readClipboard).toHaveBeenCalledOnce(); expect(retired.session.actions).toEqual([]); diff --git a/src/daemon/handlers/__tests__/session-command-replay.test.ts b/src/daemon/handlers/__tests__/session-command-replay.test.ts index 53cd598574..8532d84fef 100644 --- a/src/daemon/handlers/__tests__/session-command-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-command-replay.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { test, expect } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -16,6 +17,7 @@ test('replay parses open --relaunch flag and replays open with relaunch semantic const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -54,6 +56,7 @@ test('replay parses runtime set flags and replays runtime command', async () => const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -93,6 +96,7 @@ test('replay parses inline open runtime flags and replays open with runtime payl const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -137,6 +141,7 @@ test('replay delivers scroll --until and wait --raw flags to the dispatched step const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -172,6 +177,7 @@ test('replay inherits parent device selectors for each invoked step', async () = const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -211,6 +217,7 @@ test('replay inherits the parent web platform selector for each invoked step', a const invoked: DaemonRequest[] = []; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -254,6 +261,7 @@ test('test --platform web reports no matching scripts, typed or untyped, because fs.writeFileSync(path.join(root, '02-android.ad'), 'context platform=android\nopen "Demo"\n'); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index 71a09d2061..2434d93318 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -145,6 +146,7 @@ test('failed local open before device setup rolls its device claim back', async await assert.rejects( async () => handleOpenCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'open', token: 'test', @@ -172,6 +174,7 @@ test('failed local open after dispatch retains its device claim for recovery', a await assert.rejects( async () => handleOpenCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'open', token: 'test', @@ -202,6 +205,7 @@ test('failed local runtime-hint setup retains its device claim before open dispa await assert.rejects( async () => handleOpenCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'open', token: 'test', @@ -228,6 +232,7 @@ test('failed local open response rolls its device claim back', async () => { mockResolveTargetDevice.mockResolvedValue(android); const response = await handleOpenCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'open', token: 'test', @@ -254,6 +259,7 @@ test('cancellation after local device setup retains the device claim for recover try { const response = await handleOpenCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'open', token: 'test', @@ -285,6 +291,7 @@ test('a canceled attempt lets the next attempt of the same suite open the device markRequestCanceled(requestId); try { const timedOut = await handleOpenCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'open', token: 'test', @@ -304,6 +311,7 @@ test('a canceled attempt lets the next attempt of the same suite open the device assert.equal(store.get('suite:1-gesture-pan-duration:attempt-1'), undefined); const retry = await handleOpenCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'open', token: 'test', @@ -332,6 +340,7 @@ test('provider-owned open creates no host-local device claim from its selected o mockDispatch.mockResolvedValue(undefined); const response = await handleOpenCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'open', token: 'test', @@ -368,6 +377,7 @@ test('a foreign live claim rejects open before platform preparation or mutation' if (acquired.status !== 'acquired') return; const response = await handleOpenCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'open', token: 'test', @@ -422,6 +432,7 @@ test('local close clears its matching device claim after teardown', async () => mockDispatch.mockResolvedValue(undefined); const response = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'close', token: 'test', session: 'close-claim', positionals: [], flags: {} }, sessionName: 'close-claim', logPath: path.join(stateDir, 'daemon.log'), @@ -466,6 +477,7 @@ test('#1391: a close-time script save failure still clears the device claim and let thrown: unknown; try { await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'close', token: 'test', @@ -539,6 +551,7 @@ test('an open under a macos-app lease holds only its app, beside other claims on }); const response = await handleOpenCommand({ + platformServices: daemonPlatformServicesFixture(), req: { command: 'open', token: 'test', diff --git a/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts b/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts index fc0fcfc6ea..e4277ea1a2 100644 --- a/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts +++ b/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import type { InspectDeviceRuntimeFacts } from '../../request-runtime-binding.ts'; import type { DaemonRequest } from '../../daemon-request.ts'; @@ -114,6 +115,7 @@ async function dispatchInstallSource(params: { meta: { installSource: { kind: 'path', path: '/tmp/App.apk' } }, }; return await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: 'default', logPath: '/tmp/daemon.log', diff --git a/src/daemon/handlers/__tests__/session-prepare.test.ts b/src/daemon/handlers/__tests__/session-prepare.test.ts index 2906f6442f..89d413aeeb 100644 --- a/src/daemon/handlers/__tests__/session-prepare.test.ts +++ b/src/daemon/handlers/__tests__/session-prepare.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import type { CommandFlags } from '@agent-device/contracts/command'; import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; @@ -85,6 +86,7 @@ async function runPrepare(flags: { timeoutMs?: number }): Promise<{ ...flags, }; const response = await handlePrepareCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts b/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts index 97409a3a41..0fea568128 100644 --- a/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts +++ b/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import type { InspectDeviceRuntimeFacts } from '../../request-runtime-binding.ts'; import type { DaemonRequest } from '../../daemon-request.ts'; @@ -89,6 +90,7 @@ async function dispatchPush(params: { flags: {}, }; return await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: 'default', logPath: '/tmp/daemon.log', diff --git a/src/daemon/handlers/__tests__/session-push.test.ts b/src/daemon/handlers/__tests__/session-push.test.ts index 12649806cc..a5999b561e 100644 --- a/src/daemon/handlers/__tests__/session-push.test.ts +++ b/src/daemon/handlers/__tests__/session-push.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; @@ -27,6 +28,7 @@ const invoke = async (_req: DaemonRequest): Promise => { test('push requires active session or explicit device selector', async () => { const sessionStore = makeSessionStore('agent-device-session-push-'); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -64,6 +66,7 @@ test('push validates payload before runtime facts admission', async () => { await expect( handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -104,6 +107,7 @@ test('push runs readiness and notification through one admitted runtime binding' }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -169,6 +173,7 @@ test('push treats an existing brace-prefixed payload as a file before inline JSO try { const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -207,6 +212,7 @@ test('push stops at unavailable facts without binding', async () => { }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -261,6 +267,7 @@ test('push fails closed before binding when a provider owner lacks readiness', a }; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/handlers/__tests__/session-reinstall.test.ts b/src/daemon/handlers/__tests__/session-reinstall.test.ts index c255a49ad0..6129cc945a 100644 --- a/src/daemon/handlers/__tests__/session-reinstall.test.ts +++ b/src/daemon/handlers/__tests__/session-reinstall.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; @@ -48,6 +49,7 @@ beforeEach(() => { test('reinstall requires active session or explicit device selector', async () => { const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -82,6 +84,7 @@ test('reinstall validates required args before runtime admission', async () => { }), ); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -118,6 +121,7 @@ test('install binds the deployment use exactly once after one facts admission', }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: session.name, @@ -181,6 +185,7 @@ test('install fails closed before binding when its provider owner lacks deployme }; const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: session.name, @@ -225,6 +230,7 @@ test('reinstall cleans an uploaded artifact after the request-scoped operation', vi.mocked(resolveTargetDevice).mockResolvedValue(session.device); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: session.name, @@ -259,6 +265,7 @@ test('HarmonyOS reinstall updates the active session identity from the runtime r mockDeployAppRuntime.mockResolvedValue({ packageName: 'com.example.application' }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: session.name, @@ -308,6 +315,7 @@ test.each(['rebuild', 'retire'] as const)( return { packageName: 'com.example.updated' }; }); const running = handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts index c36126c636..bbe1249523 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { test, expect } from 'vitest'; import * as path from 'node:path'; import { @@ -15,6 +16,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; test('open --relaunch rejects URL targets', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -39,6 +41,7 @@ test('open --relaunch rejects URL targets', async () => { test('open --relaunch fails without app when no session exists', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -63,6 +66,7 @@ test('open --relaunch fails without app when no session exists', async () => { test('open --relaunch rejects Android app binary paths', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -85,6 +89,7 @@ test('open --relaunch rejects Android app binary paths', async () => { test('open --relaunch rejects bare Android app binary filenames', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -118,6 +123,7 @@ test('open --relaunch rejects Android app binary paths for active sessions', asy sessionStore.publish('default', session); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -140,6 +146,7 @@ test('open --relaunch rejects Android app binary paths for active sessions', asy test('open --relaunch rejects Android app binary paths before resolving a new device', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -183,6 +190,7 @@ test('open on in-use device returns DEVICE_IN_USE before readiness checks', asyn }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -237,6 +245,7 @@ test('open on device owned by recording session returns recording recovery hint' }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'test-attempt', diff --git a/src/daemon/handlers/__tests__/session-replay-command.test.ts b/src/daemon/handlers/__tests__/session-replay-command.test.ts index 35b2536b52..3a93a9df1d 100644 --- a/src/daemon/handlers/__tests__/session-replay-command.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-command.test.ts @@ -9,6 +9,7 @@ import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/ import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; import type { SessionCommandParams } from '../session-command-input.ts'; import { handleReplayTestCommand } from '../session-replay-command.ts'; +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { makeSessionStore } from './session-test-harness.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; @@ -26,6 +27,7 @@ test('replay test handler threads the complete video owner into the application sessionName: 'default', logPath: path.join(root, 'daemon.log'), sessionStore: makeSessionStore(), + platformServices: daemonPlatformServicesFixture(), leaseRegistry: new LeaseRegistry(), invoke: async () => ({ ok: true, data: {} }), bindDevice: unavailableBindDevice, diff --git a/src/daemon/handlers/__tests__/session-runtime-command.test.ts b/src/daemon/handlers/__tests__/session-runtime-command.test.ts index c9c8dfc034..623a05d2a0 100644 --- a/src/daemon/handlers/__tests__/session-runtime-command.test.ts +++ b/src/daemon/handlers/__tests__/session-runtime-command.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { expect, test } from 'vitest'; import path from 'node:path'; @@ -30,6 +31,7 @@ test('runtime set/show/clear manages session-scoped runtime hints before open', } satisfies Pick; const setResponse = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { ...baseRequest, command: 'runtime', @@ -49,6 +51,7 @@ test('runtime set/show/clear manages session-scoped runtime hints before open', expect(setResponse?.ok).toBe(true); const showResponse = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { ...baseRequest, command: 'runtime', @@ -73,6 +76,7 @@ test('runtime set/show/clear manages session-scoped runtime hints before open', } const clearResponse = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { ...baseRequest, command: 'runtime', @@ -108,6 +112,7 @@ test('runtime clear removes applied transport hints for the active app', async ( }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -157,6 +162,7 @@ test('runtime clear expires the ref frame at the admitted hint mutation boundary }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -210,6 +216,7 @@ test('runtime clear rejects a false runtime-hints fact before its one implementa }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -260,6 +267,7 @@ test('runtime gesture-viewport admits and binds the exact viewport operation onc gestureRuntimeSpies.gestureViewport.mockClear(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -328,6 +336,7 @@ for (const phase of ['admission', 'effect'] as const) { } await expect( handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -369,6 +378,7 @@ test('runtime clear expires and uses the latest matching record after admission' return lifecycleRuntimeFacts(target); }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: ref.address, diff --git a/src/daemon/handlers/__tests__/session-runtime-port-reverse.test.ts b/src/daemon/handlers/__tests__/session-runtime-port-reverse.test.ts index 5a4de4d74d..814bd09f02 100644 --- a/src/daemon/handlers/__tests__/session-runtime-port-reverse.test.ts +++ b/src/daemon/handlers/__tests__/session-runtime-port-reverse.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import type { DaemonRequest } from '../../daemon-request.ts'; import { handlePortReverseCommand } from '../session-runtime-port-reverse.ts'; @@ -13,6 +14,7 @@ test('port reverse rejects a missing resolved lease before runtime admission', a const inspectFacts = vi.fn(); const bindDevice = vi.fn(); const response = await handlePortReverseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { ...baseRequest, flags: { platform: 'android', leaseProvider: 'limrun', devicePort: 8097 }, @@ -37,6 +39,7 @@ test('sessionless port reverse requires an explicit device selector', async () = const inspectFacts = vi.fn(); const bindDevice = vi.fn(); const response = await handlePortReverseCommand({ + platformServices: daemonPlatformServicesFixture(), req: { ...baseRequest, flags: { diff --git a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts index 3b8750fc95..c0a8265b5a 100644 --- a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts +++ b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { test, expect, vi } from 'vitest'; import * as path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; @@ -75,6 +76,7 @@ test.each(['rebuild', 'retire'] as const)( [Symbol.asyncDispose]: async () => {}, }; const running = handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -175,6 +177,7 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves // dismiss mutates the device → frame expires. const dismissRef = sessionStore.publish(sessionName, makeSession(sessionName, device)); await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -195,6 +198,7 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves sessionStore.retire(dismissRef); sessionStore.publish(sessionName, makeSession(sessionName, device)); await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -238,6 +242,7 @@ test('keyboard dismiss expires the frame before the invocation runs, even when i sessionStore.publish(sessionName, makeSession(sessionName, device)); await expect( handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -259,6 +264,7 @@ test('keyboard dismiss expires the frame before the invocation runs, even when i test('keyboard requires an active session or explicit device selector', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -286,6 +292,7 @@ test('keyboard dismiss requires active iOS session for explicit selectors', asyn const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/handlers/__tests__/session-state-boot-warmup.test.ts b/src/daemon/handlers/__tests__/session-state-boot-warmup.test.ts index 540ed32f2e..7a9e258fbe 100644 --- a/src/daemon/handlers/__tests__/session-state-boot-warmup.test.ts +++ b/src/daemon/handlers/__tests__/session-state-boot-warmup.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { makeSessionStore, mockPrewarmAppleRunnerCache, @@ -19,6 +20,7 @@ test('boot leaves Apple keep-hot policy inside the platform runtime', async () = }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index 10c380d406..70cc1ee5ba 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { test, expect, vi } from 'vitest'; import { handleSessionStateCommands } from '../session-state.ts'; @@ -33,6 +34,7 @@ test('boot rejects --headless outside Android directly', async () => { { local: async () => [device] }, async () => await handleSessionStateCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -98,6 +100,7 @@ test('boot rejects --headless outside Android directly', async () => { test('appstate returns missing-session error for explicit session flag', async () => { const response = await handleSessionStateCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'named', @@ -137,6 +140,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () }, async () => await handleSessionStateCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -257,6 +261,7 @@ test('appstate rejects a missing readiness fact even when appState is available' { local: async () => [device] }, async () => await handleSessionStateCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -355,6 +360,7 @@ test('sessionless Android appstate inspects once, binds once, and preserves oper { local: async () => [device] }, async () => await handleSessionStateCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/handlers/__tests__/session-trigger.test.ts b/src/daemon/handlers/__tests__/session-trigger.test.ts index 8cbffef1b4..2ab42015b6 100644 --- a/src/daemon/handlers/__tests__/session-trigger.test.ts +++ b/src/daemon/handlers/__tests__/session-trigger.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { test, expect } from 'vitest'; import { handleSessionCommands } from './session-command-harness.ts'; @@ -14,6 +15,7 @@ const invoke = async (_req: DaemonRequest): Promise => { test('trigger-app-event requires active session or explicit device selector', async () => { const sessionStore = makeSessionStore('agent-device-session-trigger-'); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts b/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts index 7e709aa4bb..19c34b8a99 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { test, expect, vi, afterEach, beforeEach } from 'vitest'; import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { resetGetRuntimeFixture } from '../../__tests__/interaction-get-runtime-fixture.ts'; @@ -177,6 +178,7 @@ test('snapshot annotations survive a deferred post-gesture capture into CLI JSON }); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -207,6 +209,7 @@ test('snapshot timeout captures Android screenshot evidence with overlay refs', const sessionStore = makeAndroidTimeoutEvidenceSession(sessionName); mockAndroidTimeoutEvidenceDispatch(); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -236,6 +239,7 @@ test('snapshot warns when recent snapshot node count collapses sharply', async ( ); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -282,6 +286,7 @@ test('snapshot does not warn on expected node drop across presentation modes', a }); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), sessionName, logPath: '/tmp/daemon.log', @@ -319,6 +324,7 @@ test('snapshot automatically retries stale Android trees after recent navigation ); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), sessionName, logPath: '/tmp/daemon.log', @@ -347,6 +353,7 @@ test('snapshot warns when Android freshness retries still return the previous ro ); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), sessionName, logPath: '/tmp/daemon.log', @@ -395,6 +402,7 @@ test('snapshot response includes normalized visibility metadata', async () => { }); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true } }), sessionName, logPath: '/tmp/daemon.log', @@ -423,6 +431,7 @@ test('diff snapshot carries stale-tree warnings for recent Android presses', asy ); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'], flags: { snapshotInteractiveOnly: true }, diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 255eb439a5..6b754d4316 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { test, expect, vi, afterEach, beforeEach } from 'vitest'; import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { resetGetRuntimeFixture } from '../../__tests__/interaction-get-runtime-fixture.ts'; @@ -81,6 +82,7 @@ test('snapshot rejects @ref scope without existing session snapshot', async () = ); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotScope: '@e1' } }), sessionName, logPath: '/tmp/daemon.log', @@ -102,6 +104,7 @@ test('snapshot on iOS rejects sessions without a tracked app', async () => { const runtime = countingSnapshotRuntime(); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -130,7 +133,9 @@ test('snapshot on iOS without a tracked app carries the detected open command as const sessionName = 'ios-sim-no-app-hinted'; sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); + const platformServices = daemonPlatformServicesFixture(); const response = await handleSnapshotCommands({ + platformServices, req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -145,7 +150,7 @@ test('snapshot on iOS without a tracked app carries the detected open command as 'running. Run: agent-device open xyz.blueskyweb.app --platform ios --udid sim-1', ); } - expect(mockBuildIosOpenCommandHint).toHaveBeenCalledWith(iosSimulatorDevice); + expect(mockBuildIosOpenCommandHint).toHaveBeenCalledWith(iosSimulatorDevice, platformServices); }); // #1658: the app-session requirement is the local XCUITest runner's, not the @@ -165,6 +170,7 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { const runtime = countingSnapshotRuntime(); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -188,6 +194,7 @@ test('diff on local iOS still requires a tracked app', async () => { sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'] }), sessionName, logPath: '/tmp/daemon.log', @@ -216,6 +223,7 @@ test('snapshot on iOS runs when the session tracks an app', async () => { }); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -251,6 +259,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy }); const snapshotResponse = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot'), sessionName, logPath: '/tmp/daemon.log', @@ -263,6 +272,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy expect(refFrameState(sessionStore.get(sessionName)!)).toBe('active'); const diffResponse = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'] }), sessionName, logPath: '/tmp/daemon.log', @@ -282,6 +292,7 @@ async function runVersionedRefsCommand(params: { command: 'snapshot' | 'diff'; }): Promise | undefined> { const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(params.sessionName, params.command, { positionals: params.command === 'diff' ? ['snapshot'] : [], }), @@ -367,6 +378,7 @@ test('daemon-private snapshot observation advances capture state without publish }); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { internal: { observationOnly: true } }), sessionName, logPath: '/tmp/daemon.log', @@ -389,6 +401,7 @@ test('snapshot surfaces filtered-to-zero Android guidance for interactive snapsh legacyDispatchCapture.mockResolvedValue(androidCapture([], { rawNodeCount: 42, maxDepth: 8 })); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'snapshot', { flags: { snapshotInteractiveOnly: true, snapshotDepth: 3 }, }), @@ -409,6 +422,7 @@ test('snapshot surfaces filtered-to-zero Android guidance for interactive snapsh test('diff rejects unsupported kind', async () => { const sessionStore = makeSessionStore(); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest('default', 'diff', { positionals: ['unknown'] }), sessionName: 'default', logPath: '/tmp/daemon.log', @@ -426,6 +440,7 @@ test('diff rejects unsupported kind', async () => { test('diff screenshot is not handled daemon-side (client-backed command)', async () => { const sessionStore = makeSessionStore(); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest('default', 'diff', { positionals: ['screenshot'] }), sessionName: 'default', logPath: '/tmp/daemon.log', diff --git a/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts index e3d89abb81..b9fddc8d5a 100644 --- a/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { test, expect, vi, afterEach, beforeEach } from 'vitest'; import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; @@ -94,6 +95,7 @@ test('settings rejects unsupported iOS physical devices', async () => { ); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['wifi', 'on'] }), sessionName, logPath: '/tmp/daemon.log', @@ -114,6 +116,7 @@ test('settings clear-app-state dispatches explicit app id without an active app sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['clear-app-state', 'org.reactnavigation.playground'], }), @@ -136,6 +139,7 @@ test('settings clear-app-state rejects missing app id when no app session is bou sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['clear-app-state'] }), sessionName, logPath: '/tmp/daemon.log', @@ -158,6 +162,7 @@ test('settings reset-keychain dispatches without an app id or active app session sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['reset-keychain', 'clear'], }), @@ -179,6 +184,7 @@ test('settings reset-keychain rejects an extra app argument instead of dropping sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['reset-keychain', 'clear', 'com.example.app'], }), @@ -200,6 +206,7 @@ test('settings text-size reads the category the owner holds without mutating any sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size'] }), sessionName, logPath: '/tmp/daemon.log', @@ -223,12 +230,14 @@ test('settings text-size refuses the macOS host on both legs with the same code' sessionStore.publish(sessionName, makeSession(sessionName, macOsDevice)); const read = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size'] }), sessionName, logPath: '/tmp/daemon.log', sessionStore, }); const write = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size', 'large'] }), sessionName, logPath: '/tmp/daemon.log', @@ -254,6 +263,7 @@ test('settings text-size applies a ladder category through the write leg', async sessionStore.publish(sessionName, session); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size', 'accessibility-extra-large'], }), @@ -286,6 +296,7 @@ test('settings text-size refuses an Apple leaf with no content size before it ex for (const positionals of [['text-size'], ['text-size', 'large']]) { const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals }), sessionName, logPath: '/tmp/daemon.log', @@ -311,6 +322,7 @@ test('settings text-size refuses an off-ladder category with the whole ladder', sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size', 'gigantic'] }), sessionName, logPath: '/tmp/daemon.log', @@ -333,6 +345,7 @@ test('settings text-size refuses an off-ladder category with the whole ladder', test('settings usage hint documents canonical faceid states', async () => { const sessionStore = makeSessionStore(); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest('default', 'settings'), sessionName: 'default', logPath: '/tmp/daemon.log', @@ -356,6 +369,7 @@ test('settings on macOS rejects wifi before dispatch with explicit subset guidan sessionStore.publish(sessionName, makeSession(sessionName, macOsDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['wifi', 'on'] }), sessionName, logPath: '/tmp/daemon.log', @@ -386,6 +400,7 @@ test('settings permission grant targets an explicit app with no session app boun sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['permission', 'grant', 'camera'], input: { app: 'com.example.app' }, @@ -415,6 +430,7 @@ test('settings permission grant keeps the session app when no app is named', asy ); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['permission', 'grant', 'camera'], }), @@ -438,6 +454,7 @@ test('settings permission grant prefers an explicit app over the session app', a ); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['permission', 'grant', 'camera'], input: { app: 'com.other.app' }, @@ -457,6 +474,7 @@ test('settings location on targets an explicit app on an iOS simulator', async ( sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['location', 'on'], input: { app: 'com.example.app' }, @@ -480,6 +498,7 @@ test('settings Android permission grant targets an explicit app with no session sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['permission', 'grant', 'camera'], input: { app: 'com.example.app' }, @@ -505,6 +524,7 @@ test('settings Android location on refuses a named app before reaching the owner sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['location', 'on'], input: { app: 'com.example.app' }, @@ -537,6 +557,7 @@ test('settings iOS location 1 targets an explicit app over the session app', asy ); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['location', '1'], input: { app: 'com.example.app' }, @@ -560,6 +581,7 @@ test('settings Android location true refuses a named app before reaching the own sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['location', 'true'], input: { app: 'com.example.app' }, @@ -583,6 +605,7 @@ test('settings Android location 0 refuses a named app before reaching the owner' sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['location', '0'], input: { app: 'com.example.app' }, @@ -608,6 +631,7 @@ test('settings iOS location set refuses a named app before reaching the owner', sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['location', 'set', '37.7', '-122.4'], input: { app: 'com.example.app' }, @@ -634,6 +658,7 @@ test('settings macOS permission grant refuses a named app before reaching the ow sessionStore.publish(sessionName, makeSession(sessionName, macOsDevice)); const response = await handleSnapshotCommands({ + platformServices: daemonPlatformServicesFixture(), req: snapshotRequest(sessionName, 'settings', { positionals: ['permission', 'grant', 'screen-recording'], input: { app: 'com.example.app' }, diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index 5711d69785..5c9a4bbc8e 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -51,6 +51,7 @@ export async function handleReactNativeCommands( command: 'press', requiresCapture: false, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, unavailableResponse: (unavailable) => errorResponse( diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index 6fb7e4fa59..58ef483f0d 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import path from 'node:path'; import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { @@ -63,6 +64,7 @@ export type RecordRuntimeHandlerParams = Readonly<{ sessionName: string; sessionStore: SessionStore; bindDevice: BindDeviceRuntime; + platformServices: DaemonPlatformServices; bindExactDevice: BindExactDeviceRuntime; admissionLedger: ScreenRecordingAdmissionLedger; requestScope: PlatformRequestScope; @@ -150,7 +152,7 @@ async function startRecording( const binding = ref ? bindSessionScreenRecording(params.sessionStore, ref) : draft!.binding; binding.assertAdoptable(); const admission = await params.bindDevice(session.device, screenRecordingAdmissionUse); - if (needsReadiness) await ensureBoundDeviceReady(admission); + if (needsReadiness) await ensureBoundDeviceReady(admission, params.platformServices); const startFact = admission.facts.screenRecordingStart; if (!startFact.available) return buildRecordingUnsupportedResponse(startFact); const runtime = await params.bindDevice(session.device, use); @@ -309,7 +311,7 @@ async function finishRecovered( screenRecordingRecoveryUse, recoveryScope, ); - if (needsReadiness) await ensureBoundDeviceReady(runtime); + if (needsReadiness) await ensureBoundDeviceReady(runtime, params.platformServices); return createScreenRecordingRecoveryControl({ runtime, dispose: async () => {} }); }, }); diff --git a/src/daemon/handlers/record-trace.ts b/src/daemon/handlers/record-trace.ts index 293dfada15..9eadd601c7 100644 --- a/src/daemon/handlers/record-trace.ts +++ b/src/daemon/handlers/record-trace.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { SessionStore } from '../session-store.ts'; import { handleRecordCommand } from './record-runtime.ts'; @@ -12,6 +13,7 @@ export async function handleRecordTraceCommands(params: { sessionStore: SessionStore; logPath?: string; bindDevice: BindDeviceRuntime; + platformServices: DaemonPlatformServices; bindExactDevice: BindExactDeviceRuntime; admissionLedger: ScreenRecordingAdmissionLedger; requestScope: PlatformRequestScope; @@ -27,6 +29,7 @@ export async function handleRecordTraceCommands(params: { sessionName, sessionStore, bindDevice: params.bindDevice, + platformServices: params.platformServices, bindExactDevice: params.bindExactDevice, admissionLedger: params.admissionLedger, requestScope: params.requestScope, diff --git a/src/daemon/handlers/session-app-deployment-route.ts b/src/daemon/handlers/session-app-deployment-route.ts index d994b4b07e..36068d66ed 100644 --- a/src/daemon/handlers/session-app-deployment-route.ts +++ b/src/daemon/handlers/session-app-deployment-route.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; @@ -13,9 +14,10 @@ export async function handleSessionAppDeploymentCommand(params: { sessionName: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { - const { req, sessionName, sessionStore, inspectFacts, bindDevice } = params; + const { req, sessionName, sessionStore, inspectFacts, bindDevice, platformServices } = params; if (req.command === 'install' || req.command === 'reinstall') { return await handleAppDeploymentCommand({ req, @@ -24,6 +26,7 @@ export async function handleSessionAppDeploymentCommand(params: { sessionStore, inspectFacts, bindDevice, + platformServices, }); } if (req.command === 'install_source') { @@ -33,6 +36,7 @@ export async function handleSessionAppDeploymentCommand(params: { sessionStore, inspectFacts, bindDevice, + platformServices, }); } if (req.command === 'push') { @@ -42,6 +46,7 @@ export async function handleSessionAppDeploymentCommand(params: { sessionStore, inspectFacts, bindDevice, + platformServices, }); } return null; diff --git a/src/daemon/handlers/session-app-deployment.ts b/src/daemon/handlers/session-app-deployment.ts index d1ae923904..744dc30cc0 100644 --- a/src/daemon/handlers/session-app-deployment.ts +++ b/src/daemon/handlers/session-app-deployment.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import fs from 'node:fs'; import type { AppDeploymentResult } from '@agent-device/contracts/app-deployment-runtime'; @@ -49,6 +50,7 @@ export async function handleAppDeploymentCommand(params: { sessionName: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { const { req, command, sessionName, sessionStore } = params; @@ -69,7 +71,11 @@ export async function handleAppDeploymentCommand(params: { return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`); } - const device = await resolveCommandDevice({ session, flags }); + const device = await resolveCommandDevice({ + session, + flags, + platformServices: params.platformServices, + }); const facts = await requireRuntimeFacts(params.inspectFacts)(device); const unsupported = unavailableRuntimeOperationResponse(command, facts.operations.deployApp); if (unsupported) return unsupported; @@ -115,7 +121,11 @@ export async function handlePushNotificationCommand( ); } const payload = await readNotificationPayload(resolvePushPayload(payloadArg, req.meta?.cwd)); - const device = await resolveCommandDevice({ session, flags }); + const device = await resolveCommandDevice({ + session, + flags, + platformServices: params.platformServices, + }); const facts = await requireRuntimeFacts(params.inspectFacts)(device); const unsupported = unavailableRuntimeOperationResponse('push', facts.operations.ensureReady) ?? diff --git a/src/daemon/handlers/session-app-source-deployment.ts b/src/daemon/handlers/session-app-source-deployment.ts index 2507643d83..fffcd5ac94 100644 --- a/src/daemon/handlers/session-app-source-deployment.ts +++ b/src/daemon/handlers/session-app-source-deployment.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import type { AppDeploymentResult, MaterializedAppSource, @@ -48,6 +49,7 @@ export async function handleInstallFromSourceDeploymentCommand(params: { sessionName: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { const { req, sessionName, sessionStore } = params; @@ -59,7 +61,7 @@ export async function handleInstallFromSourceDeploymentCommand(params: { try { resolvedSource = resolveInstallSource(req); const retention = resolveRetention(req); - const device = await resolveInstallDevice(session, req.flags); + const device = await resolveInstallDevice(session, req.flags, params.platformServices); const facts = await requireRuntimeFacts(params.inspectFacts)(device); const unsupported = unavailableRuntimeOperationResponse('install_from_source', facts.operations.ensureReady) ?? @@ -130,6 +132,7 @@ export async function handleReleaseMaterializedPathsCommand(params: { async function resolveInstallDevice( session: SessionState | undefined, flags: DaemonRequest['flags'] | undefined, + platformServices: DaemonPlatformServices, ): Promise { const requestedPlatform = normalizePlatform(flags?.platform); if (session && requestedPlatform && !matchesPlatformSelector(session.device, requestedPlatform)) { @@ -150,7 +153,7 @@ async function resolveInstallDevice( if (session) { return session.device; } - return await resolveCommandDevice({ session, flags }); + return await resolveCommandDevice({ session, flags, platformServices }); } function normalizePlatform( diff --git a/src/daemon/handlers/session-clipboard.ts b/src/daemon/handlers/session-clipboard.ts index b4522ca903..61badf6386 100644 --- a/src/daemon/handlers/session-clipboard.ts +++ b/src/daemon/handlers/session-clipboard.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import type { ClipboardReadInput, ClipboardWriteInput, @@ -107,7 +108,7 @@ async function resolveBoundClipboardRuntime( }> & RuntimeAdmissionBindings, ): Promise { - const { device, action, positionals, inspectFacts, bindDevice } = params; + const { device, action, positionals, inspectFacts, bindDevice, platformServices } = params; if (action === 'read') { const admission = await admitRuntimeUse({ command: 'clipboard', @@ -115,6 +116,7 @@ async function resolveBoundClipboardRuntime( use: clipboardReadUse, inspectFacts, bindDevice, + platformServices, readiness: true, }); if (admission.type === 'response') return { ok: false, response: admission.response }; @@ -127,6 +129,7 @@ async function resolveBoundClipboardRuntime( use: clipboardWriteUse, inspectFacts, bindDevice, + platformServices, readiness: true, }); if (admission.type === 'response') return { ok: false, response: admission.response }; @@ -140,9 +143,11 @@ export async function handleSessionClipboardCommand(params: { logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { - const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; + const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice, platformServices } = + params; const ref = sessionStore.lookup(sessionName); const session = ref?.session; const flags = req.flags ?? {}; @@ -155,7 +160,7 @@ export async function handleSessionClipboardCommand(params: { return errorResponse('INVALID_ARGS', 'clipboard requires a subcommand: read or write'); } - const device = await resolveCommandDevice({ session, flags }); + const device = await resolveCommandDevice({ session, flags, platformServices }); if (ref) sessionStore.requireCurrent(ref); const bound = await resolveBoundClipboardRuntime({ device, @@ -163,6 +168,7 @@ export async function handleSessionClipboardCommand(params: { positionals, inspectFacts, bindDevice, + platformServices, }); if (!bound.ok) return bound.response; const current = ref ? sessionStore.requireCurrent(ref) : undefined; diff --git a/src/daemon/handlers/session-command-input.ts b/src/daemon/handlers/session-command-input.ts index 4daf14fb47..8f963219fc 100644 --- a/src/daemon/handlers/session-command-input.ts +++ b/src/daemon/handlers/session-command-input.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import type { LeaseLifecycleProvider, ProviderAppCatalog } from '@agent-device/contracts/device'; import type { HostDiagnostics } from '@agent-device/contracts/host-diagnostics'; import type { PlatformResourceCleanup } from '../platform-resource-cleanup.ts'; @@ -29,6 +30,7 @@ export type SessionCommandInput = { /** Request-scoped Android adb transport override, opaque to the daemon. */ androidAdbExecutor?: unknown; bindDevice?: BindDeviceRuntime; + platformServices: DaemonPlatformServices; inspectFacts?: InspectDeviceRuntimeFacts; bindExactDevice?: BindExactDeviceRuntime; appLogAdmissionLedger?: AppLogAdmissionLedger; diff --git a/src/daemon/handlers/session-prepare.ts b/src/daemon/handlers/session-prepare.ts index 0a6944b5fd..28ff44820a 100644 --- a/src/daemon/handlers/session-prepare.ts +++ b/src/daemon/handlers/session-prepare.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import { prepareAppleRunnerRuntimeUse } from '@agent-device/contracts/application-lifecycle-runtime-plan'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; @@ -24,6 +25,7 @@ type PrepareRuntime = BoundDeviceRuntime; async function admitPrepareRuntime(params: { device: DeviceInfo; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }) { return await admitRuntimeUse({ @@ -39,6 +41,7 @@ export async function handlePrepareCommand(params: { logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { const { req, sessionName, logPath, sessionStore } = params; @@ -54,10 +57,15 @@ export async function handlePrepareCommand(params: { // Device selection is side-effect free enough for facts admission. The bound lifecycle owns // readiness, keeping provider-first facts as the sole support authority. - const device = await resolveCommandDevice({ session, flags }); + const device = await resolveCommandDevice({ + session, + flags, + platformServices: params.platformServices, + }); const admission = await admitPrepareRuntime({ device, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (admission.type === 'response') return admission.response; diff --git a/src/daemon/handlers/session-replay-command.ts b/src/daemon/handlers/session-replay-command.ts index 29b3321094..1047cd4966 100644 --- a/src/daemon/handlers/session-replay-command.ts +++ b/src/daemon/handlers/session-replay-command.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { DaemonRequest } from '../daemon-request.ts'; import type { SessionState } from '../session-state.ts'; @@ -53,6 +54,7 @@ export const handleReplayTestCommand: SessionCommandHandler = async ({ invoke, invokeReplayAction, bindDevice, + platformServices, inspectFacts, bindExactDevice, screenRecordingAdmissionLedger, @@ -70,6 +72,7 @@ export const handleReplayTestCommand: SessionCommandHandler = async ({ const video = createReplayTestVideoOwner({ sessionStore, bindDevice, + platformServices, bindExactDevice, screenRecordingAdmissionLedger, requestScope, @@ -92,6 +95,7 @@ export const handleReplayTestCommand: SessionCommandHandler = async ({ leaseRegistry, inspectFacts, bindDevice, + platformServices, platformResourceCleanup, }), ...(video ? { video } : {}), @@ -175,6 +179,7 @@ type ReplayTestSessionCleanupParams = Readonly<{ sessionStore: SessionStore; leaseRegistry: LeaseRegistry; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; platformResourceCleanup: PlatformResourceCleanup; }>; @@ -196,6 +201,7 @@ async function closeReplayTestSession(params: ReplayTestSessionCleanupParams): P sessionStore, leaseRegistry: params.leaseRegistry, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, platformResourceCleanup: params.platformResourceCleanup, }); diff --git a/src/daemon/handlers/session-replay-video-owner.ts b/src/daemon/handlers/session-replay-video-owner.ts index fea6dc2e04..d14d3f17f8 100644 --- a/src/daemon/handlers/session-replay-video-owner.ts +++ b/src/daemon/handlers/session-replay-video-owner.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import { handleRecordCommand } from './record-runtime.ts'; import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; import { type ScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; @@ -11,6 +12,7 @@ type ReplayRecordVideoRequest = Parameters[0]; export type ReplayTestVideoOwnerParams = Readonly<{ sessionStore: SessionStore; bindDevice?: BindDeviceRuntime; + platformServices: DaemonPlatformServices; bindExactDevice?: BindExactDeviceRuntime; screenRecordingAdmissionLedger?: ScreenRecordingAdmissionLedger; requestScope?: PlatformRequestScope; @@ -24,6 +26,7 @@ export function createReplayTestVideoOwner( const { sessionStore, bindDevice, + platformServices, bindExactDevice, screenRecordingAdmissionLedger, requestScope, @@ -46,6 +49,7 @@ export function createReplayTestVideoOwner( sessionName: request.sessionName, sessionStore, bindDevice, + platformServices, bindExactDevice, admissionLedger: screenRecordingAdmissionLedger, requestScope, diff --git a/src/daemon/handlers/session-runtime-command.ts b/src/daemon/handlers/session-runtime-command.ts index 0bea2f5106..2fecd9bb43 100644 --- a/src/daemon/handlers/session-runtime-command.ts +++ b/src/daemon/handlers/session-runtime-command.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { publicPlatformString } from '@agent-device/kernel/device'; import { clearRuntimeHintsRuntimeUse } from '@agent-device/contracts/application-lifecycle-runtime-plan'; @@ -25,6 +26,7 @@ type RuntimeCommandDevice = NonNullable>['device type RuntimeCommandAdmission = Readonly<{ device: RuntimeCommandDevice; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }>; @@ -44,6 +46,7 @@ export async function handleRuntimeCommand(params: { logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { const { req, sessionName, sessionStore } = params; @@ -53,6 +56,7 @@ export async function handleRuntimeCommand(params: { req, session: sessionStore.get(sessionName), inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); } @@ -75,6 +79,7 @@ export async function handleRuntimeCommand(params: { ref, current, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); } @@ -90,6 +95,7 @@ async function readGestureViewport(params: { logPath: string; session: ReturnType; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { if (!params.session) { @@ -98,6 +104,7 @@ async function readGestureViewport(params: { const runtime = await resolveBoundGestureViewportRuntime({ device: params.session.device, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (!runtime.ok) return runtime.response; @@ -123,15 +130,18 @@ async function clearRuntimeCommand(params: { ref: SessionRef | undefined; current: ReturnType; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { - const { sessionName, sessionStore, ref, current, inspectFacts, bindDevice } = params; + const { sessionName, sessionStore, ref, current, inspectFacts, bindDevice, platformServices } = + params; const session = ref ? sessionStore.requireCurrent(ref) : undefined; if (hasRuntimeTransportHints(current) && session?.appBundleId) { const admission = await admitClearRuntime({ device: session.device, inspectFacts, bindDevice, + platformServices, }); if (admission.type === 'response') return admission.response; // Native hint removal can change the app's reachable surface. Expire the existing frame at diff --git a/src/daemon/handlers/session-runtime-port-reverse.ts b/src/daemon/handlers/session-runtime-port-reverse.ts index 700f617de9..b97af9a1c0 100644 --- a/src/daemon/handlers/session-runtime-port-reverse.ts +++ b/src/daemon/handlers/session-runtime-port-reverse.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import type { ProviderPortReverseOptions } from '@agent-device/contracts/device'; import { configureProviderPortReverseRuntimeUse } from '@agent-device/contracts/application-lifecycle-runtime-plan'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; @@ -24,9 +25,10 @@ export async function handlePortReverseCommand(params: { req: DaemonRequest; session: ReturnType; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { - const { req, session, inspectFacts, bindDevice } = params; + const { req, session, inspectFacts, bindDevice, platformServices } = params; const parsed = readPortReverseOptions(req); if (!parsed.ok) return parsed.response; const selectorError = requireSessionOrExplicitSelector( @@ -38,11 +40,13 @@ export async function handlePortReverseCommand(params: { const device = await resolveCommandDevice({ session, flags: req.flags, + platformServices: params.platformServices, }); const admission = await admitRuntimeUse({ device, inspectFacts, bindDevice, + platformServices, command: 'runtime port-reverse', use: configureProviderPortReverseRuntimeUse, }); diff --git a/src/daemon/handlers/session-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index 078a26e61b..edf0b3b228 100644 --- a/src/daemon/handlers/session-selector-dispatch.ts +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; @@ -13,7 +14,6 @@ import { resolveBoundAppEventRuntime } from '../app-event-runtime.ts'; import { resolveBoundKeyboardRuntime } from '../keyboard-runtime.ts'; import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; import { expireRefFrame } from '../ref-frame.ts'; -import { resolveSessionAppBundleIdForTarget } from '../../platform-runtime-open-target.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { DaemonCommandContext } from '../context.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; @@ -51,6 +51,7 @@ async function runSessionOrSelectorDispatch(params: { device: DeviceInfo, ref: SessionRef | undefined, ) => Promise; + platformServices: DaemonPlatformServices; }): Promise { const { req, @@ -71,6 +72,7 @@ async function runSessionOrSelectorDispatch(params: { const device = await resolveCommandDevice({ session, flags, + platformServices: params.platformServices, }); if (ref) sessionStore.requireCurrent(ref); const prepared = await prepare(device, ref); @@ -122,6 +124,7 @@ type SessionRouteHandlerParams = Readonly<{ logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }>; @@ -131,6 +134,7 @@ type SessionRouteRuntimeResolver = ( positionals: string[]; readiness?: boolean; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }>, ) => Promise< @@ -161,7 +165,8 @@ async function runBoundSessionRoute( updateSession?: Parameters[0]['updateSession']; }>, ): Promise { - const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; + const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice, platformServices } = + params; const positionals = req.positionals ?? []; return await runSessionOrSelectorDispatch({ req, @@ -169,6 +174,7 @@ async function runBoundSessionRoute( sessionStore, command: params.command, positionals, + platformServices, ...(params.updateSession ? { updateSession: params.updateSession } : {}), prepare: async (device, ref) => { const bound = await params.resolveRuntime({ @@ -177,6 +183,7 @@ async function runBoundSessionRoute( readiness: true, inspectFacts, bindDevice, + platformServices, }); if (!bound.ok) return { ok: false, response: bound.response }; const session = ref ? sessionStore.requireCurrent(ref) : undefined; @@ -216,7 +223,7 @@ export async function handleAppEventCommand( const eventUrl = typeof result?.eventUrl === 'string' ? result.eventUrl : undefined; if (!eventUrl) return; const session = params.sessionStore.requireCurrent(ref); - const appBundleId = await resolveSessionAppBundleIdForTarget( + const appBundleId = await params.platformServices.resolveSessionAppBundleIdForTarget( session.device, eventUrl, session.appBundleId, diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index cf4b6519aa..e9ed47c444 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import { asAppError, isRequestCanceledError } from '@agent-device/kernel/errors'; import type { TargetShutdownResult } from '@agent-device/contracts/device'; import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; @@ -92,6 +93,7 @@ async function readAppleSessionAppState( device: session.device, use: appStateUse, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (admitted.type === 'response') return undefined; @@ -189,6 +191,7 @@ async function handleAppStateCommand(params: RuntimeCommandHandlerParams): Promi const device = await resolveCommandDevice({ session, flags, + platformServices: params.platformServices, }); if (isIosFamily(device)) { return errorResponse('SESSION_NOT_FOUND', IOS_APPSTATE_SESSION_REQUIRED_MESSAGE); @@ -201,6 +204,7 @@ async function handleAppStateCommand(params: RuntimeCommandHandlerParams): Promi device, use: appStateUse, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, unavailableResponse: (unavailable) => errorResponse( @@ -234,6 +238,7 @@ export async function handleSessionStateCommands(params: { sessionName: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { const { req, sessionName, sessionStore } = params; @@ -258,6 +263,7 @@ export async function handleSessionStateCommands(params: { session, flags, androidAvdSelection: 'include-stopped', + platformServices: params.platformServices, }); } catch (error) { const appErr = asAppError(error); @@ -286,6 +292,7 @@ export async function handleSessionStateCommands(params: { device, required: plan.use.required, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, unavailableResponse: bootUnavailableResponse(plan.kind === 'boot-target-headless'), }); @@ -329,6 +336,7 @@ export async function handleSessionStateCommands(params: { flags, session: activeSession, androidAvdSelection: 'include-stopped', + platformServices: params.platformServices, }); const inspectFacts = requireRuntimeFacts(params.inspectFacts); const facts = await inspectFacts(device); @@ -397,6 +405,7 @@ export async function handleSessionStateCommands(params: { sessionName, sessionStore, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); } diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 3ec370b214..c0428ab17c 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -41,6 +41,7 @@ const handleSessionOpenCommandGroup: SessionCommandHandler = (params) => logPath: params.logPath, sessionStore: params.sessionStore, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, } satisfies SessionOpenCommandInput); @@ -54,6 +55,7 @@ const handleSessionCloseCommandGroup: SessionCommandHandler = (params) => leaseRegistry: params.leaseRegistry, leaseLifecycleProvider: params.leaseLifecycleProvider, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, platformResourceCleanup: params.platformResourceCleanup, } satisfies SessionCloseCommandInput); @@ -64,6 +66,7 @@ const handleSessionStateCommandGroup: SessionCommandHandler = async ({ sessionStore, inspectFacts, bindDevice, + platformServices, }) => await handleSessionStateCommands({ req, @@ -71,6 +74,7 @@ const handleSessionStateCommandGroup: SessionCommandHandler = async ({ sessionStore, inspectFacts, bindDevice, + platformServices, }); const handleSessionObservabilityCommandGroup: SessionCommandHandler = async ({ @@ -78,6 +82,7 @@ const handleSessionObservabilityCommandGroup: SessionCommandHandler = async ({ sessionName, sessionStore, bindDevice, + platformServices, inspectFacts, appLogAdmissionLedger, audioProbeAdmissionLedger, @@ -89,6 +94,7 @@ const handleSessionObservabilityCommandGroup: SessionCommandHandler = async ({ sessionName, sessionStore, bindDevice, + platformServices, inspectFacts, appLogAdmissionLedger, audioProbeAdmissionLedger, @@ -134,7 +140,15 @@ const SESSION_COMMAND_HANDLER_IMPLS = { appstate: handleSessionStateCommandGroup, session_save_script: async ({ req, sessionName, sessionStore }) => handleSessionScriptPublication({ req, sessionName, sessionStore }), - runtime: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => + runtime: async ({ + req, + sessionName, + logPath, + sessionStore, + inspectFacts, + bindDevice, + platformServices, + }) => await handleRuntimeCommand({ req, sessionName, @@ -142,8 +156,17 @@ const SESSION_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformServices, }), - clipboard: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => + clipboard: async ({ + req, + sessionName, + logPath, + sessionStore, + inspectFacts, + bindDevice, + platformServices, + }) => await handleSessionClipboardCommand({ req, sessionName, @@ -151,6 +174,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformServices, }), keyboard: handleKeyboardCommand, perf: handleSessionObservabilityCommandGroup, @@ -158,7 +182,15 @@ const SESSION_COMMAND_HANDLER_IMPLS = { events: handleSessionObservabilityCommandGroup, network: handleSessionObservabilityCommandGroup, audio: handleSessionObservabilityCommandGroup, - prepare: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => + prepare: async ({ + req, + sessionName, + logPath, + sessionStore, + inspectFacts, + bindDevice, + platformServices, + }) => await handlePrepareCommand({ req, sessionName, @@ -166,6 +198,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformServices, }), install: handleSessionAppDeploymentCommand, reinstall: handleSessionAppDeploymentCommand, @@ -197,6 +230,7 @@ export async function handleSessionCommands( androidAdbExecutor, inspectFacts, bindDevice, + platformServices, bindExactDevice, appLogAdmissionLedger, audioProbeAdmissionLedger, @@ -227,6 +261,7 @@ export async function handleSessionCommands( androidAdbExecutor, inspectFacts, bindDevice, + platformServices, bindExactDevice, appLogAdmissionLedger, audioProbeAdmissionLedger, diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index 892db5decd..295529ebd2 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import type { AlertRuntimeInput } from '@agent-device/contracts/alert-runtime'; import { type AlertAction, @@ -31,6 +32,7 @@ type HandleAlertCommandParams = { ref: SessionRef | undefined; device: SessionState['device']; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }; @@ -55,12 +57,13 @@ async function resolveBoundAlertRuntime( }> & RuntimeAdmissionBindings, ): Promise { - const { device, action, inspectFacts, bindDevice } = params; + const { device, action, inspectFacts, bindDevice, platformServices } = params; const shared = { command: 'alert', device, inspectFacts, bindDevice, + platformServices, readiness: !params.session, }; if (action === 'wait') { @@ -118,7 +121,8 @@ async function executeDismissAlert( export async function handleAlertCommand( params: HandleAlertCommandParams, ): Promise { - const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice, platformServices } = + params; let session = ref ? sessionStore.requireCurrent(ref) : undefined; const action = normalizeAlertAction(req.positionals?.[0]); const bound = await resolveBoundAlertRuntime({ @@ -127,6 +131,7 @@ export async function handleAlertCommand( session, inspectFacts, bindDevice, + platformServices, }); if (!bound.ok) return bound.response; session = ref ? sessionStore.requireCurrent(ref) : undefined; diff --git a/src/daemon/handlers/snapshot-runtime-route.test.ts b/src/daemon/handlers/snapshot-runtime-route.test.ts index 800c4542af..c469c28d8d 100644 --- a/src/daemon/handlers/snapshot-runtime-route.test.ts +++ b/src/daemon/handlers/snapshot-runtime-route.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; const { dispatchSnapshotDiffViaRuntime, dispatchSnapshotViaRuntime } = vi.hoisted(() => ({ @@ -35,7 +36,9 @@ test('snapshot route forwards the request runtime facts and binding seams exactl token: 'test-token', }; + const platformServices = daemonPlatformServicesFixture(); await handleSnapshotCommands({ + platformServices, req, sessionName: 'snapshot-route', logPath: '/tmp/snapshot-route.log', @@ -52,6 +55,7 @@ test('snapshot route forwards the request runtime facts and binding seams exactl sessionStore, inspectFacts, bindDevice, + platformServices, }); expect(dispatchSnapshotDiffViaRuntime).not.toHaveBeenCalled(); }); @@ -67,7 +71,9 @@ test('diff route forwards the request runtime facts and binding seams exactly on token: 'test-token', }; + const platformServices = daemonPlatformServicesFixture(); await handleSnapshotCommands({ + platformServices, req, sessionName: 'diff-route', logPath: '/tmp/diff-route.log', @@ -84,6 +90,7 @@ test('diff route forwards the request runtime facts and binding seams exactly on sessionStore, inspectFacts, bindDevice, + platformServices, }); expect(dispatchSnapshotViaRuntime).not.toHaveBeenCalled(); }); diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index 59ace7433f..ae27c9338c 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import { isApplePlatform, isHandheldAppleSimulator, @@ -70,6 +71,7 @@ type HandleSettingsCommandParams = { device: SessionState['device']; parsed: ParsedSettingsRequest; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }; @@ -221,7 +223,8 @@ async function executeSettingsRead( params: HandleSettingsCommandParams, setting: ReadableSetting, ): Promise { - const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice, platformServices } = + params; let session = ref ? sessionStore.requireCurrent(ref) : undefined; const refusal = settingsRequestRefusal(device, setting); if (refusal !== undefined) return refusal; @@ -231,6 +234,7 @@ async function executeSettingsRead( use: settingReadUse, inspectFacts, bindDevice, + platformServices, readiness: !session, }); if (admission.type === 'response') return admission.response; @@ -264,7 +268,8 @@ async function executeSettingsWrite( params: HandleSettingsCommandParams, parsed: ParsedSettingsArgs, ): Promise { - const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice, platformServices } = + params; let session = ref ? sessionStore.requireCurrent(ref) : undefined; const { setting, state } = parsed; const refusal = settingsRequestRefusal(device, setting); @@ -277,6 +282,7 @@ async function executeSettingsWrite( use: settingsRuntimeUse, inspectFacts, bindDevice, + platformServices, readiness: !session, }); if (admission.type === 'response') return admission.response; diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index 62b63cb1f9..37e34f74ac 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../platform-services.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { SessionStore } from '../session-store.ts'; import { handleAlertCommand } from './snapshot-alert.ts'; @@ -16,6 +17,7 @@ type SnapshotCommandParams = { logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; platformResourceCleanup?: PlatformResourceCleanup; }; @@ -30,6 +32,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformServices, platformResourceCleanup, }) => await dispatchSnapshotViaRuntime({ @@ -39,6 +42,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformServices, platformResourceCleanup, }), diff: async ({ @@ -48,6 +52,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformServices, platformResourceCleanup, }) => { if (req.positionals?.[0] !== 'snapshot') { @@ -60,6 +65,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformServices, platformResourceCleanup, }); }, @@ -70,6 +76,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformServices, platformResourceCleanup, }) => await dispatchWaitViaRuntime({ @@ -79,6 +86,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformServices, platformResourceCleanup, }), alert: async ({ @@ -88,6 +96,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformServices, platformResourceCleanup, }) => { const { ref, session, device } = await resolveSessionDevice( @@ -107,6 +116,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { device, inspectFacts, bindDevice, + platformServices, }); }, platformResourceCleanup, @@ -119,6 +129,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { sessionStore, inspectFacts, bindDevice, + platformServices, platformResourceCleanup, }) => { const parsedSettings = parseSettingsArgs(req); @@ -141,6 +152,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { parsed: parsedSettings.parsed, inspectFacts, bindDevice, + platformServices, }); }, platformResourceCleanup, diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 4c3a0187b2..ed38ce5f8b 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { beforeEach, test, vi } from 'vitest'; @@ -191,6 +192,7 @@ async function swipeRefusedOnSecondRepetition(): Promise { const session = makeSession('dispatch-disclosure-swipe'); sessionStore.publish(session.name, session); const response = await routeInteraction({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: session.name, @@ -223,6 +225,7 @@ async function gestureDragRefusedBeforeDispatch(): Promise { expireRefFrame(session); sessionStore.publish(session.name, session); const response = await routeInteraction({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: session.name, @@ -280,6 +283,7 @@ async function refRefusedAfterAndroidRecovery(command: 'press' | 'gesture'): Pro openApp, }; const response = await routeInteraction({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: session.name, diff --git a/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts b/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts index 79bc1e2b24..2c22b17b8d 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { @@ -61,6 +62,7 @@ function makeDragSession(sessionName: string) { async function runDrag(sessionStore: ReturnType, sessionName: string) { return await handleInteractionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts index a8b8e4a7a1..6e05343860 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; import { legacyDispatchCapture } from '../../../__tests__/legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; @@ -111,6 +112,7 @@ test('interaction runtime inherits the registered daemon request signal', () => try { const runtime = createInteractionRuntime({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts index 3f0a119d67..dfd13ff020 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { @@ -145,6 +146,7 @@ test.each([false, true])('selector-touch observation after retirement=%s', async sessionRef: ref, sessionStore, contextFromFlags, + platformServices: daemonPlatformServicesFixture(), captureSnapshotForSession: vi.fn(), }, result, @@ -220,6 +222,7 @@ test.each([ sessionRef: ref, sessionStore, contextFromFlags, + platformServices: daemonPlatformServicesFixture(), captureSnapshotForSession: async () => { startProbe(); await released; @@ -652,6 +655,7 @@ test('an already retired coordinate touch skips its frame probe without a warnin sessionRef: ref, sessionStore, contextFromFlags, + platformServices: daemonPlatformServicesFixture(), captureSnapshotForSession: capture, }, result: { kind: 'point', point: { x: 1, y: 2 } }, diff --git a/src/daemon/interaction/internal/find.ts b/src/daemon/interaction/internal/find.ts index 1057e97804..a3255356b4 100644 --- a/src/daemon/interaction/internal/find.ts +++ b/src/daemon/interaction/internal/find.ts @@ -101,6 +101,7 @@ export async function handleFindCommands(params: FindRouteInput): Promise vi.fn()); -vi.mock('../platform-runtime-apple-resources.ts', () => ({ - appleSessionObservation: { resolveSoleForegroundApp }, -})); +// The foreground-app probe is stubbed through the port (ADR 0019 section 11), never by mocking +// the platform module behind it. +const resolveSoleForegroundApp = vi.fn(); +const platformServices = daemonPlatformServicesFixture({ + appleSessionObservation: { + observeRunnerSession: async () => undefined, + resolveSoleForegroundApp, + }, +}); beforeEach(() => { resolveSoleForegroundApp.mockReset(); @@ -18,7 +24,7 @@ test('an unambiguous environment gets the exact runnable open command', async () app: { bundleId: 'xyz.blueskyweb.app' }, }); - const hint = await buildIosOpenCommandHint(IOS_SIMULATOR); + const hint = await buildIosOpenCommandHint(IOS_SIMULATOR, platformServices); expect(hint).toBe( 'One booted device found ("iPhone 16", udid booted-1) with xyz.blueskyweb.app running. ' + @@ -39,7 +45,7 @@ test('a custom simulator set is echoed back so the printed command is the one th app: { bundleId: 'xyz.blueskyweb.app' }, }); - const hint = await buildIosOpenCommandHint(soleBootedDevice); + const hint = await buildIosOpenCommandHint(soleBootedDevice, platformServices); expect(hint).toBe( 'One booted device found ("iPhone 16", udid booted-1) with xyz.blueskyweb.app running. ' + @@ -64,21 +70,23 @@ test('a hint that would exceed the wire-redaction truncation length falls back t app: { bundleId: 'xyz.blueskyweb.app' }, }); - await expect(buildIosOpenCommandHint(soleBootedDevice)).resolves.toBeUndefined(); + await expect( + buildIosOpenCommandHint(soleBootedDevice, platformServices), + ).resolves.toBeUndefined(); }); test('an inconclusive observation keeps the generic hint', async () => { resolveSoleForegroundApp.mockResolvedValue(undefined); - await expect(buildIosOpenCommandHint(IOS_SIMULATOR)).resolves.toBeUndefined(); + await expect(buildIosOpenCommandHint(IOS_SIMULATOR, platformServices)).resolves.toBeUndefined(); }); test('control-flow failures from the observation port propagate', async () => { const canceled = new DOMException('This operation was aborted', 'AbortError'); resolveSoleForegroundApp.mockRejectedValue(canceled); - await expect(buildIosOpenCommandHint(IOS_SIMULATOR)).rejects.toBe(canceled); + await expect(buildIosOpenCommandHint(IOS_SIMULATOR, platformServices)).rejects.toBe(canceled); }); test('a physical iOS device never probes for a hint', async () => { - await expect(buildIosOpenCommandHint(IOS_DEVICE)).resolves.toBeUndefined(); + await expect(buildIosOpenCommandHint(IOS_DEVICE, platformServices)).resolves.toBeUndefined(); expect(resolveSoleForegroundApp).not.toHaveBeenCalled(); }); diff --git a/src/daemon/ios-app-session-hint.ts b/src/daemon/ios-app-session-hint.ts index 7886499bcb..629e3072cd 100644 --- a/src/daemon/ios-app-session-hint.ts +++ b/src/daemon/ios-app-session-hint.ts @@ -1,6 +1,6 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; -import { appleSessionObservation } from '../platform-runtime-apple-resources.ts'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; +import type { DaemonPlatformServices } from './platform-services.ts'; /** * Enriches the generic "Run open first" SESSION_NOT_FOUND hint with the exact @@ -14,10 +14,13 @@ import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; // comfortably under it and falls back rather than risk truncation. const MAX_HINT_LENGTH = 350; -export async function buildIosOpenCommandHint(device: DeviceInfo): Promise { +export async function buildIosOpenCommandHint( + device: DeviceInfo, + platformServices: DaemonPlatformServices, +): Promise { if (!isIosFamily(device) || device.kind !== 'simulator') return undefined; - const resolved = await appleSessionObservation.resolveSoleForegroundApp({ + const resolved = await platformServices.appleSessionObservation.resolveSoleForegroundApp({ simulatorSetPath: device.simulatorSetPath, }); if (!resolved) return undefined; diff --git a/src/daemon/keyboard-runtime.ts b/src/daemon/keyboard-runtime.ts index 8a086e542c..5330e5c184 100644 --- a/src/daemon/keyboard-runtime.ts +++ b/src/daemon/keyboard-runtime.ts @@ -225,7 +225,7 @@ export async function resolveBoundKeyboardRuntime( } & RuntimeAdmissionBindings & { positionals: readonly string[] }, ): Promise { const action = readKeyboardAction(params.positionals); - const { device, inspectFacts, bindDevice, readiness } = params; + const { device, inspectFacts, bindDevice, platformServices, readiness } = params; if (action === 'status') { return await admitKeyboardAction( { @@ -234,6 +234,7 @@ export async function resolveBoundKeyboardRuntime( use: keyboardStatusUse, inspectFacts, bindDevice, + platformServices, readiness, }, (runtime, context) => executeKeyboardStatus(runtime, context), @@ -247,6 +248,7 @@ export async function resolveBoundKeyboardRuntime( use: keyboardDismissUse, inspectFacts, bindDevice, + platformServices, readiness, }, (runtime, context) => executeKeyboardDismiss(runtime, context), @@ -259,6 +261,7 @@ export async function resolveBoundKeyboardRuntime( use: keyboardEnterUse, inspectFacts, bindDevice, + platformServices, readiness, }, (runtime, context) => executeKeyboardEnter(runtime, context), diff --git a/src/daemon/orientation-runtime.ts b/src/daemon/orientation-runtime.ts index b965835c13..170d1fb87b 100644 --- a/src/daemon/orientation-runtime.ts +++ b/src/daemon/orientation-runtime.ts @@ -44,6 +44,7 @@ export async function resolveBoundOrientationRuntime( device: params.device, use: orientationRuntimeUse, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }, (runtime, context) => executeSetOrientation(runtime, rotation, context), diff --git a/src/daemon/platform-services.ts b/src/daemon/platform-services.ts new file mode 100644 index 0000000000..f865f1a9db --- /dev/null +++ b/src/daemon/platform-services.ts @@ -0,0 +1,40 @@ +import type { AppleSessionObservation } from '@agent-device/contracts/apple-session-observation'; +import type { DeviceBootObservationService } from '@agent-device/contracts/device-boot'; +import type { SessionSurface } from '@agent-device/contracts/session'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +/** + * Concrete local readiness for one device, answering "does this local leaf have a readiness step, + * and did it settle?" — the boolean keeps the daemon's TTL cache honest without a platform branch. + */ +export type EnsureLocalDeviceReady = (device: DeviceInfo) => Promise; + +/** + * Root-composed platform services the daemon asks of the host machine for work that binds no + * device runtime, over mechanics the daemon must never name or load itself. The two observation + * members are published contracts types; readiness and open-target classification are signatures + * declared here over neutral types. Device execution is NOT in this port and never + * enters it (ADR 0019): it goes through the request-bound runtime binding + * (`RequestExecutionScope.bindDevice`), whose owner facts stay the only admission authority. + */ +export type DaemonPlatformServices = Readonly<{ + appleSessionObservation: AppleSessionObservation; + deviceBootObservation: DeviceBootObservationService; + ensureLocalDeviceReady: EnsureLocalDeviceReady; + resolveRequestedOpenSurface(params: { + device: DeviceInfo; + surfaceFlag: string | undefined; + openTarget: string | undefined; + existingSurface?: SessionSurface; + }): SessionSurface; + validateOpenRelaunchTarget(params: { + target: string | undefined; + platform: string | undefined; + surface?: SessionSurface; + }): Promise; + resolveSessionAppBundleIdForTarget( + device: DeviceInfo, + openTarget: string | undefined, + currentAppBundleId: string | undefined, + ): Promise; +}>; diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 947092ece5..d1c7cac1f1 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -66,6 +66,7 @@ import { resolveCommandDeviceClaimPolicy, } from '@agent-device/command-registry/registry'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; +import type { DaemonPlatformServices } from './platform-services.ts'; import { assertDaemonPolicyAdmitsDevice, assertDaemonPolicyAdmitsRequest, @@ -100,6 +101,8 @@ export type RequestExecutionScope = AsyncDisposable & { runLocked(task: () => Promise): Promise; retainDeviceExecutionLock(deviceId: string): Promise; bindDevice: BindDeviceRuntime; + /** Root-composed platform services for this request's asks that bind no device runtime. */ + platformServices: DaemonPlatformServices; inspectFacts: InspectDeviceRuntimeFacts; bindExactDevice: BindExactDeviceRuntime; /** The request's mutations, recorded by every bound operation this scope hands out. */ @@ -114,6 +117,7 @@ export type LockedRequestScope = { existingSession: SessionState | undefined; retainDeviceExecutionLock(deviceId: string): Promise; bindDevice: BindDeviceRuntime; + platformServices: DaemonPlatformServices; inspectFacts: InspectDeviceRuntimeFacts; bindExactDevice: BindExactDeviceRuntime; dispatchLedger: RequestDispatchLedger; @@ -139,6 +143,7 @@ export async function createRequestExecutionScope(params: { sessionStore: SessionStore; leaseRegistry: LeaseRegistry; deviceRuntimeGateway?: DeviceRuntimeGateway; + platformServices: DaemonPlatformServices; platformRequestScope?: PlatformRequestScope; platformResourceCleanup?: PlatformResourceCleanup; providerAppCatalog?: ProviderAppCatalog; @@ -245,6 +250,7 @@ export async function createRequestExecutionScope(params: { bindDevice: runtimeBindings?.bindDevice ?? gatewayMissing, inspectFacts: runtimeBindings?.inspectFacts ?? gatewayMissing, bindExactDevice: runtimeBindings?.bindExactDevice ?? gatewayMissing, + platformServices: params.platformServices, dispatchLedger, throwIfCanceled: () => throwIfRequestCanceled(scopedReq.meta?.requestId), runAdmitted: async (task) => { @@ -472,7 +478,7 @@ export async function prepareLockedRequestScope(params: { scope.throwIfCanceled(); const seededRef = sessionStore.lookup(scope.sessionName); if (seededRef) { - await refreshRecordingHealth(sessionStore, seededRef); + await refreshRecordingHealth(sessionStore, seededRef, scope.platformServices); scope.throwIfCanceled(); sessionStore.requireCurrent(seededRef); } @@ -537,6 +543,7 @@ export async function prepareLockedRequestScope(params: { existingSession, retainDeviceExecutionLock: scope.retainDeviceExecutionLock, bindDevice: scope.bindDevice, + platformServices: scope.platformServices, inspectFacts: scope.inspectFacts, bindExactDevice: scope.bindExactDevice, dispatchLedger: scope.dispatchLedger, diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index 1eceee1494..e52f8b03ea 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -5,6 +5,7 @@ import type { SessionStore } from './session-store.ts'; import type { DaemonCommandContext } from './context.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; import type { SessionRef, SessionState } from './session-state.ts'; +import type { DaemonPlatformServices } from './platform-services.ts'; import { ensureAndroidBlockingSystemDialogReady, recoverAndroidBlockingSystemDialog, @@ -39,6 +40,7 @@ export async function dispatchGenericCommand(params: { executePlatformCommand: GenericPlatformExecution; recordedRequest?: RecordedGenericRequest; androidObservation?: AndroidObservationAdapter; + platformServices: DaemonPlatformServices; }): Promise { const { req, ref: sessionRef, logPath, sessionStore, contextFromFlags } = params; const session = sessionStore.requireCurrent(sessionRef); @@ -60,6 +62,7 @@ export async function dispatchGenericCommand(params: { logPath, sessionStore, contextFromFlags, + platformServices: params.platformServices, command: platformCommand, flags: req.flags, }); @@ -214,6 +217,7 @@ async function planGenericSettleObservation(params: { appBundleId?: string, traceLogPath?: string, ) => DaemonCommandContext; + platformServices: DaemonPlatformServices; command: string; flags: CommandFlags | undefined; }): Promise< diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 9b976611dc..c9cc6f8de2 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from './platform-services.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { CloudArtifactProvider } from '@agent-device/contracts/observability'; import { AppError } from '@agent-device/kernel/errors'; @@ -50,6 +51,7 @@ type RequestHandlerChainParams = { androidObservation?: AndroidObservationAdapter; platformResourceCleanup?: PlatformResourceCleanup; bindDevice: BindDeviceRuntime; + platformServices: DaemonPlatformServices; inspectFacts: InspectDeviceRuntimeFacts; bindExactDevice: BindExactDeviceRuntime; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; @@ -178,6 +180,7 @@ async function runSessionHandler( invokeReplayAction: params.invokeReplayAction, androidAdbExecutor: params.providerScope.androidAdbExecutor, bindDevice: params.bindDevice, + platformServices: params.platformServices, inspectFacts: params.inspectFacts, bindExactDevice: params.bindExactDevice, reconcileOrphanedDeviceClaim: params.reconcileOrphanedDeviceClaim, @@ -207,6 +210,7 @@ async function runSnapshotHandler( logPath: params.logPath, sessionStore: params.sessionStore, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, platformResourceCleanup: params.platformResourceCleanup, }), @@ -230,6 +234,7 @@ async function runReactNativeHandler( // to pass the request's bindings through. Before R61 this leg reached the device through the // retired dispatcher and needed none, which is why the arm had no bindings to forward. inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }), ); @@ -248,6 +253,7 @@ async function runRecordTraceHandler( sessionStore: params.sessionStore, logPath: params.logPath, bindDevice: params.bindDevice, + platformServices: params.platformServices, bindExactDevice: params.bindExactDevice, admissionLedger: params.screenRecordingAdmissionLedger, requestScope: params.requestScope, @@ -271,6 +277,7 @@ async function runFindHandler( sessionStore: params.sessionStore, invoke: params.invoke, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }), ); @@ -290,6 +297,7 @@ async function runInteractionHandler( sessionStore: params.sessionStore, contextFromFlags: params.contextFromFlags, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, androidObservation: params.androidObservation, }), diff --git a/src/daemon/request-recording-health.ts b/src/daemon/request-recording-health.ts index df3db9e55a..1b5a4bf8a9 100644 --- a/src/daemon/request-recording-health.ts +++ b/src/daemon/request-recording-health.ts @@ -1,9 +1,13 @@ import { isIosFamily } from '@agent-device/kernel/device'; -import { appleSessionObservation } from '../platform-runtime-apple-resources.ts'; import type { SessionRef, SessionState } from './session-state.ts'; import type { SessionStore } from './session-store.ts'; +import type { DaemonPlatformServices } from './platform-services.ts'; -export async function refreshRecordingHealth(store: SessionStore, ref: SessionRef): Promise { +export async function refreshRecordingHealth( + store: SessionStore, + ref: SessionRef, + platformServices: DaemonPlatformServices, +): Promise { const session = store.requireCurrent(ref); if (!recordingRequiresRunnerHealth(session)) { return; @@ -11,7 +15,9 @@ export async function refreshRecordingHealth(store: SessionStore, ref: SessionRe const resource = session.screenRecording!; const recording = resource.handle; - const snapshot = await appleSessionObservation.observeRunnerSession(session.device.id); + const snapshot = await platformServices.appleSessionObservation.observeRunnerSession( + session.device.id, + ); if (store.resolveCurrent(ref)?.screenRecording !== resource) return; const state = recording.inspect(); if (!state.runnerSessionId) { diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 9e04ca2f25..e22e2ed9f3 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -90,6 +90,7 @@ import { discloseRequestDispatch, refusedBeforeDispatch } from './request-dispat import { recordNestedRequests } from './request-dispatch-ledger.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; +import type { DaemonPlatformServices } from './platform-services.ts'; import type { DaemonProviderCredentials } from '../provider-credential-fingerprint.ts'; import { restrictDeviceInventoryToDaemonPolicy } from './daemon-policy.ts'; import type { DaemonPolicy } from '../daemon-policy-file.ts'; @@ -106,6 +107,12 @@ export type RequestRouterDeps = { requestPlatformProviders?: RequestPlatformProviders; deviceInventoryGateways: ComposedDeviceInventoryGateways; deviceRuntimeGateway: DeviceRuntimeGateway; + /** + * Root-composed platform services for the request-path asks that bind no device runtime + * (readiness, boot and runner-session observation, open-target classification). Required: a + * process that never ran root composition cannot build one, and no daemon module may. + */ + platformServices: DaemonPlatformServices; appLogAdmissionLedger?: AppLogAdmissionLedger; audioProbeAdmissionLedger?: AudioProbeAdmissionLedger; perfCaptureAdmissionLedger?: PerfCaptureAdmissionLedger; @@ -164,6 +171,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { token, requestPlatformProviders = EMPTY_REQUEST_PLATFORM_PROVIDERS, deviceRuntimeGateway, + platformServices, appLogAdmissionLedger = createAppLogAdmissionLedger(), audioProbeAdmissionLedger = createAudioProbeAdmissionLedger(), perfCaptureAdmissionLedger = createPerfCaptureAdmissionLedger(), @@ -254,6 +262,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { sessionStore, leaseRegistry, deviceRuntimeGateway, + platformServices, platformRequestScope, platformResourceCleanup, providerAppCatalog, @@ -367,6 +376,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { androidObservation, platformResourceCleanup, bindDevice: lockedScope.bindDevice, + platformServices: lockedScope.platformServices, inspectFacts: lockedScope.inspectFacts, bindExactDevice: lockedScope.bindExactDevice, reconcileOrphanedDeviceClaim: createOwnerScopedDeviceClaimReconciler(requestScope), @@ -410,6 +420,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { sessionStore, leaseRegistry, deviceRuntimeGateway, + platformServices, platformRequestScope: createPlatformRequestScope(scopedReq), platformResourceCleanup, providerAppCatalog, @@ -598,6 +609,7 @@ async function dispatchGenericForLockedScope(params: { session.trace?.outPath, ), inspectFacts: lockedScope.inspectFacts, + platformServices: lockedScope.platformServices, bindDevice: lockedScope.bindDevice, }); if (!runtimeExecution.ok) return refusedBeforeDispatch(runtimeExecution.response); @@ -610,6 +622,7 @@ async function dispatchGenericForLockedScope(params: { logPath, sessionStore, contextFromFlags: lockedScope.contextFromFlags, + platformServices: lockedScope.platformServices, executePlatformCommand: runtimeExecution.execute, androidObservation, ...(runtimeExecution.recorded ? { recordedRequest: runtimeExecution.recorded } : {}), diff --git a/src/daemon/request-runtime-binding.ts b/src/daemon/request-runtime-binding.ts index 43a1f63064..9736622b51 100644 --- a/src/daemon/request-runtime-binding.ts +++ b/src/daemon/request-runtime-binding.ts @@ -16,6 +16,7 @@ import { import type { PlatformRequestScope } from '@agent-device/contracts/platform-runtime-host'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import { ensureDeviceReady } from './device/device-ready.ts'; +import type { DaemonPlatformServices } from './platform-services.ts'; import { recordBoundMutations, type RequestDispatchLedger } from './request-dispatch-ledger.ts'; export type BindDeviceRuntime = < @@ -35,18 +36,6 @@ export type BindDeviceRuntime = < BoundDeviceRuntime> >; -/** - * The two request-scoped seams a caller threads down to whichever route finally admits a device - * cell. It lives here, beside the two function types it is composed of, rather than beside the - * admission entry point that consumes it: callers that only forward the seams (a deferred capture - * handing them to a retry, say) would otherwise have to import the whole admission module and - * close a type cycle through it. - */ -export type RuntimeAdmissionBindings = Readonly<{ - inspectFacts?: InspectDeviceRuntimeFacts; - bindDevice?: BindDeviceRuntime; -}>; - export type BindExactDeviceRuntime = < const Required extends readonly RuntimeOperationKey[], const Preferred extends readonly Exclude< @@ -76,16 +65,35 @@ export type BoundDeviceIdentity = Readonly<{ owner: RuntimeOwnerRef; }>; -/** Runs local readiness after binding and claim admission; a provider runtime owns its own. */ -export async function ensureBoundDeviceReady(bound: BoundDeviceIdentity): Promise { +/** + * Runs local readiness after binding and claim admission; a provider runtime owns its own. The + * concrete mechanics arrive through the request's platform-services port, so no route can reach a + * platform it did not receive from root composition. + */ +export async function ensureBoundDeviceReady( + bound: BoundDeviceIdentity, + platformServices: DaemonPlatformServices, +): Promise { switch (bound.owner.kind) { case 'provider-runtime': return; case 'local-family': - await ensureDeviceReady(bound.device); + await ensureDeviceReady(bound.device, platformServices); } } +/** + * The two request-scoped seams a route needs to admit and prepare a device, beside the two + * function types it is composed of. `platformServices` rides along because every admitted-device + * route runs local readiness through the same port; a caller that only forwards the seams still + * forwards one object rather than one member per platform ask. + */ +export type RuntimeAdmissionBindings = Readonly<{ + inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; + bindDevice?: BindDeviceRuntime; +}>; + export type RequestRuntimeBindings = AsyncDisposable & Readonly<{ inspectFacts: InspectDeviceRuntimeFacts; diff --git a/src/daemon/runtime-admission.ts b/src/daemon/runtime-admission.ts index e6e4ca0592..e041bae28c 100644 --- a/src/daemon/runtime-admission.ts +++ b/src/daemon/runtime-admission.ts @@ -107,7 +107,7 @@ export async function admitRuntimeUse< const admitted = await admitRuntimeOperations({ ...request, required: request.use.required }); if (admitted.type === 'response') return admitted; const runtime = await admitted.bind(request.device, request.use); - if (request.readiness) await ensureBoundDeviceReady(runtime); + if (request.readiness) await ensureBoundDeviceReady(runtime, request.platformServices); return { type: 'runtime', runtime }; } diff --git a/src/daemon/screenshot-runtime-binding.ts b/src/daemon/screenshot-runtime-binding.ts index d02fa7bd75..a0c75f0969 100644 --- a/src/daemon/screenshot-runtime-binding.ts +++ b/src/daemon/screenshot-runtime-binding.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from './platform-services.ts'; import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import { type ScreenshotRuntimePlan, @@ -27,6 +28,7 @@ import { errorResponse } from '@agent-device/kernel/contracts'; export type ScreenshotRuntimeBindings = Readonly<{ inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }>; diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index e342c4e9f2..76723d292a 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -75,6 +75,7 @@ export async function resolveScreenshotGenericExecution( overlayRefs: req.flags?.overlayRefs === true, cropOn, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (!resolved.ok) return resolved; diff --git a/src/daemon/scroll-runtime.ts b/src/daemon/scroll-runtime.ts index 8db07e69a3..40ea1212e7 100644 --- a/src/daemon/scroll-runtime.ts +++ b/src/daemon/scroll-runtime.ts @@ -124,6 +124,7 @@ export async function resolveBoundScrollRuntime( command: 'scroll', device: params.device, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }; switch (plan.kind) { diff --git a/src/daemon/selector-capture-binding.ts b/src/daemon/selector-capture-binding.ts index 22b568c5a4..aa721933d4 100644 --- a/src/daemon/selector-capture-binding.ts +++ b/src/daemon/selector-capture-binding.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from './platform-services.ts'; import type { ElementTextRuntimeOperations } from '@agent-device/contracts/element-text-runtime'; import { resolveSelectorCaptureRuntimePlan } from '@agent-device/contracts/platform-runtime-operations'; import type { FindTextRuntimeOperations } from '@agent-device/contracts/selector-observation-runtime'; @@ -51,6 +52,7 @@ export async function resolveBoundSelectorCapture( device: SessionState['device']; session: SessionState | undefined; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }>, ): Promise { diff --git a/src/daemon/selector-runtime-backend.test.ts b/src/daemon/selector-runtime-backend.test.ts index a1d9a82584..d9d0d4e763 100644 --- a/src/daemon/selector-runtime-backend.test.ts +++ b/src/daemon/selector-runtime-backend.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './__tests__/platform-services-fixture.ts'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; import type { @@ -52,6 +53,7 @@ test('wait text passes its poll deadline signal to the Apple runner fast path', }), ); const runtime = createSelectorRuntimeForDevice({ + platformServices: daemonPlatformServicesFixture(), ref: sessionStore.lookup(sessionName), req: { token: 't', @@ -113,6 +115,7 @@ test('daemon wait stable pins private-ax on emitted snapshot runner requests', a }, })); const runtime = createSelectorRuntimeForDevice({ + platformServices: daemonPlatformServicesFixture(), ref: sessionStore.lookup(sessionName), req: { token: 't', diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index d0a432f812..c7428d6eeb 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from './platform-services.ts'; import type { AgentDeviceBackend, BackendCommandContext, @@ -45,6 +46,7 @@ export type SelectorRuntimeParams = { captureProof?: RequestCaptureProof; signal?: AbortSignal; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; androidObservation?: AndroidObservationAdapter; platformResourceCleanup?: PlatformResourceCleanup; @@ -136,6 +138,7 @@ export async function createBoundSelectorRuntime( device: resolved.device, session: resolved.session, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (!bound.ok) return { ok: false, response: bound.response }; diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 9b189e9f6a..1c9dc1a259 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -411,6 +411,11 @@ export async function startDaemonRuntime( const deviceInventoryGateways = createPlatformDeviceInventoryGateways( providerRuntimeProviders.deviceInventorySource, ); + // Root composition of the platform-services port: it names concrete platform adapters, so it + // loads here — the process root's own startup — rather than riding the daemon entry's eager + // closure, and it stays lazy to the entry's import graph. + const { createDaemonPlatformServices } = + await import('../../platform-runtime-daemon-services.ts'); const dispatchRequest = createRequestHandler({ logPath, @@ -422,6 +427,7 @@ export async function startDaemonRuntime( providerAppCatalog, deviceInventoryGateways, deviceRuntimeGateway, + platformServices: createDaemonPlatformServices(), appLogAdmissionLedger, audioProbeAdmissionLedger, perfCaptureAdmissionLedger, diff --git a/src/daemon/session-device-resolution.ts b/src/daemon/session-device-resolution.ts index 9350057573..d2153f0f3e 100644 --- a/src/daemon/session-device-resolution.ts +++ b/src/daemon/session-device-resolution.ts @@ -1,7 +1,7 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError, sessionOrDeviceSelectorRequiredDetails } from '@agent-device/kernel/errors'; import { isActiveProviderDevice } from './provider-device-admission.ts'; -import { appleSessionObservation } from '../platform-runtime-apple-resources.ts'; +import type { DaemonPlatformServices } from './platform-services.ts'; import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; import type { SessionState } from './session-state.ts'; @@ -35,6 +35,7 @@ export async function resolveCommandDevice(params: { session: SessionState | undefined; flags: DaemonRequest['flags'] | undefined; androidAvdSelection?: 'running-only' | 'include-stopped'; + platformServices: DaemonPlatformServices; }): Promise { const shouldUseExplicitIdentity = hasExplicitDeviceSelector(params.flags); const device = @@ -42,11 +43,14 @@ export async function resolveCommandDevice(params: { ? await resolveTargetDevice(params.flags ?? {}, { androidAvdSelection: params.androidAvdSelection, }) - : await refreshSessionDeviceIfNeeded(params.session.device); + : await refreshSessionDeviceIfNeeded(params.session.device, params.platformServices); return device; } -export async function refreshSessionDeviceIfNeeded(device: DeviceInfo): Promise { +export async function refreshSessionDeviceIfNeeded( + device: DeviceInfo, + platformServices: DaemonPlatformServices, +): Promise { if (isActiveProviderDevice(device)) { return device; } @@ -59,7 +63,7 @@ export async function refreshSessionDeviceIfNeeded(device: DeviceInfo): Promise< // A live XCUITest runner session is attached to this exact UDID, which // proves the simulator still exists and is booted — the two facts the // ~0.7s re-resolve inventory listing exists to establish. - if ((await appleSessionObservation.observeRunnerSession(device.id))?.alive) { + if ((await platformServices.appleSessionObservation.observeRunnerSession(device.id))?.alive) { return { ...device, booted: true }; } diff --git a/src/daemon/session-lifecycle/__tests__/application.test.ts b/src/daemon/session-lifecycle/__tests__/application.test.ts index b76557050b..f60168c1d0 100644 --- a/src/daemon/session-lifecycle/__tests__/application.test.ts +++ b/src/daemon/session-lifecycle/__tests__/application.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; @@ -50,6 +51,7 @@ function request(command: DaemonRequest['command']): DaemonRequest { async function run(command: DaemonRequest['command']): Promise { return await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: request(command), sessionName: 'default', logPath: '/tmp/agent-device-session-lifecycle-route.log', @@ -109,6 +111,7 @@ test('open routes only its lifecycle input through the public facade', async () 'bindDevice', 'inspectFacts', 'logPath', + 'platformServices', 'reconcileOrphanedDeviceClaim', 'req', 'sessionName', @@ -137,6 +140,7 @@ test('close routes only its lifecycle input through the public facade', async () 'leaseRegistry', 'logPath', 'platformResourceCleanup', + 'platformServices', 'req', 'sessionName', 'sessionStore', diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts index 8b64cca232..8152f61409 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { expect, test } from 'vitest'; import path from 'node:path'; @@ -153,6 +154,7 @@ async function dispatchCapabilities(params: { inspectFacts: InspectDeviceRuntimeFacts; }) { return await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: params.sessionName, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts index c02e9d3e85..4df3e81b28 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { test, expect, vi } from 'vitest'; import path from 'node:path'; @@ -60,6 +61,7 @@ async function projectAndroidCapabilities(sessionName: string) { providerMode: 'local', }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -142,6 +144,7 @@ test('capabilities excludes logs from an unavailable provider-mode XCTest runtim }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -185,6 +188,7 @@ test('capabilities excludes network when the runtime fact is unavailable', async }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -229,6 +233,7 @@ test('capabilities includes apps for the available HarmonyOS runtime fact', asyn async (request) => (request.platform === 'harmonyos' ? [harmonyDevice] : []), async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -307,6 +312,7 @@ test.each(APPS_UNAVAILABLE_CAPABILITY_CASES)( }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -341,6 +347,7 @@ test('capabilities excludes appstate when its runtime fact is unavailable', asyn }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -374,6 +381,7 @@ test('capabilities excludes appstate when its readiness fact is unavailable', as }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -449,6 +457,7 @@ test.each([ async () => [device], async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -485,6 +494,7 @@ test('capabilities accepts a stopped Android AVD placeholder for explicit platfo async (request) => (request.platform === 'android' ? [stoppedAvd] : []), async () => await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts index 012fbc97d4..7c230ac081 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import type { AppLogLiveHandle } from '@agent-device/contracts/app-log-runtime'; import type { AudioProbeLiveHandle } from '@agent-device/contracts/audio-probe-runtime'; @@ -51,6 +52,7 @@ const IOS_SIM: SessionState['device'] = { async function closeAtAddress(sessionStore: ReturnType) { return await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: ADDRESS, command: 'close', positionals: [], flags: {} }, sessionName: ADDRESS, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts index ae89f86624..cc66dca3e2 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import { sessionCloseShutdownFixture, @@ -48,6 +49,7 @@ test('targeted close preserves the platform-close AppError and still runs later await expect( handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -126,6 +128,7 @@ test('a failed platform close retains the device claim and reports it', async () let caught: unknown; try { await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -239,6 +242,7 @@ test('a failing best-effort cleanup also retains the device claim and reports it let caught: unknown; try { await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -324,6 +328,7 @@ test('a successful close clears the device claim', async () => { sessionStore.publish(sessionName, session); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -376,6 +381,7 @@ test('targeted close skips platform dispatch and preserves the error when the re await expect( handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts index 58f8e46dcc..a3e3587f78 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import path from 'node:path'; @@ -63,6 +64,7 @@ async function close(params: { internal?: DaemonRequest['internal']; }) { return await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: closeRequest(params.sessionName, params.positionals ?? [], params.internal), sessionName: params.sessionName, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts index a1d49f05e8..0f563d5859 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import type { AudioProbeLiveHandle } from '@agent-device/contracts/audio-probe-runtime'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; @@ -42,6 +43,7 @@ test('close stops Android snapshot helper session before deleting session', asyn }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -137,6 +139,7 @@ test('close stops active host audio probe before deleting session', async () => ); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -161,6 +164,7 @@ test('close dispatches web session cleanup without a positional target', async ( sessionStore.publish(sessionName, makeSession(sessionName, WEB_DESKTOP_DEVICE)); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -233,6 +237,7 @@ test('close preserves the session and lease when provider release fails so it ca }, }, invoke: noopInvoke, + platformServices: daemonPlatformServicesFixture(), }; const failed = await handleSessionCommands(request); @@ -290,6 +295,7 @@ test('close cannot retire a replacement session while its provider release waits return { releasedBy: 'provider' }; }); const closing = handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: address, command: 'close', positionals: [], flags: {} }, sessionName: address, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts index 68d45ca903..8f9bd57ff8 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test } from 'vitest'; import { sessionCloseShutdownFixture } from './session-close-shutdown.fixtures.ts'; import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; @@ -36,6 +37,7 @@ test('close --save-script on a never-armed session is rejected before teardown, await expect( handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -70,6 +72,7 @@ test('close --save-script on a never-armed session is rejected before teardown, // A plain close (no --save-script) still closes the same session cleanly afterward, and now // teardown genuinely does run: the recorder is signaled and the session deleted. const plainClose = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -109,6 +112,7 @@ test('close --save-script on a session with an active .ad repair transaction is sessionStore.publish(sessionName, session); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts index 63cdbda0e4..d384ba045c 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import { sessionCloseShutdownFixture, @@ -71,6 +72,7 @@ test('close --shutdown calls shutdownSimulator for iOS simulator and includes re }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -136,6 +138,7 @@ test('close --shutdown keeps a selected provider-owned iOS simulator off local s }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -178,6 +181,7 @@ test('close --shutdown calls shutdownAndroidEmulator for Android emulator and in }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -221,6 +225,7 @@ test('close --shutdown is ignored for non-simulator iOS devices', async () => { ); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -259,6 +264,7 @@ test('close --shutdown is ignored for Android devices', async () => { ); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -305,6 +311,7 @@ test('close --shutdown returns success and failure payload when shutdownAndroidE }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -345,6 +352,7 @@ test('close --shutdown returns success and failure payload when shutdownSimulato }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts index 0eafb52f3d..b931126845 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { test, expect, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -39,6 +40,7 @@ async function runDevices( { local: async () => inventory }, async () => await handleSessionInventoryCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', command: 'devices', positionals: [], flags }, sessionName: 'default', sessionStore: makeSessionStore('agent-device-devices-batch-runtime-'), @@ -387,6 +389,7 @@ test('close clears retained materialized install paths bound to the session', as mockCleanupRetainedMaterializedPaths.mockImplementation(realCleanup); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, command: 'close', positionals: [], flags: {} }, sessionName, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-appleos.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-appleos.test.ts index 64bd393aa2..aa7be9780c 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-appleos.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-appleos.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; // The `devices` handler resolves its inventory through listDeviceInventory; mocking it @@ -43,6 +44,7 @@ async function runDevices(): Promise { flags: {}, }; return handleSessionInventoryCommands({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: 'default', sessionStore: makeSessionStore('agent-device-inventory-appleos-'), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts index 81d4f960a5..402abbeb7b 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import type { DaemonRequest } from '../../../daemon-request.ts'; import { makeSession, makeSessionStore } from '../../../handlers/__tests__/session-test-harness.ts'; @@ -114,6 +115,7 @@ test('macOS apps consumes generic readiness and app inventory through one runtim }; const response = await handleSessionInventoryCommands({ + platformServices: daemonPlatformServicesFixture(), req, sessionName, sessionStore, @@ -143,6 +145,7 @@ test('deferred provider apps returns uploaded assets without resolving a device' }; const response = await handleSessionInventoryCommands({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: req.session, sessionStore, @@ -190,6 +193,7 @@ test('deferred provider apps forwards request cancellation to the catalog', asyn try { const response = handleSessionInventoryCommands({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: req.session, sessionStore: makeSessionStore(), @@ -217,6 +221,7 @@ test('deferred provider apps forwards public daemon access to the catalog', asyn }; await handleSessionInventoryCommands({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: req.session, sessionStore: makeSessionStore(), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts index ad184e7641..af29c210cb 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import type { DaemonRequest, DaemonResponse } from '../../../daemon-request.ts'; import { makeSession, makeSessionStore } from '../../../handlers/__tests__/session-test-harness.ts'; @@ -107,6 +108,7 @@ async function listApps(appsFilter: 'all' | 'user-installed'): Promise { flags: {}, }; return await handleSessionInventoryCommands({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: SCOPED_KEY, sessionStore, @@ -84,6 +86,7 @@ test('session list returns the caller cwd and local named sessions without cross }); const response = await handleSessionInventoryCommands({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: callerSessionAddress, sessionStore, @@ -126,6 +129,7 @@ test('session list returns only sessions owned by the requesting tenant', async }; const response = await handleSessionInventoryCommands({ + platformServices: daemonPlatformServicesFixture(), req, sessionName: req.session, sessionStore, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index 80cae04eca..da6ac1150f 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import path from 'node:path'; @@ -130,6 +131,7 @@ test('open runtime payload replaces stored session runtime atomically', async () }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -209,6 +211,7 @@ test('open runtime payload clears stale applied transport hints before launch', }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -260,6 +263,7 @@ test('open runtime payload rejects invalid metro port before app launch', async }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'runtime-open-invalid-port', @@ -300,6 +304,7 @@ test('open runtime payload rejects malformed runtime objects without mutating se mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator()); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -343,6 +348,7 @@ test('open runtime payload does not persist replacement when launch fails', asyn await expect( handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -384,6 +390,7 @@ test('a first open keeps both positionals so the deep link still reaches the app }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'deep-link-open', @@ -428,6 +435,7 @@ test('open reports the launch confirmation its platform answered', async () => { }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'launch-confirmation-open', @@ -469,6 +477,7 @@ async function openReportingObservation( }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -531,6 +540,7 @@ function invokeHeldOpen( req?: Partial, ) { return handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', command: 'open', diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts index e0d28abc70..04d31b60eb 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { test, expect } from 'vitest'; import * as path from 'node:path'; import { buildSnapshotSignatures } from '@agent-device/capture-kit/snapshot-freshness'; @@ -38,6 +39,7 @@ test('open web URL on iOS device session without active app falls back to Safari }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -88,6 +90,7 @@ test('open web URL on iOS simulator session without active app falls back to Saf }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -133,6 +136,7 @@ test('open app and URL on existing iOS device session keeps app context', async }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -180,6 +184,7 @@ test('open app on existing macOS session resolves and stores bundle id', async ( }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -212,6 +217,7 @@ test('open rejects --surface on non-macOS devices', async () => { }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'ios-surface', @@ -263,6 +269,7 @@ test('open on existing macOS frontmost-app session preserves surface without --s try { const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -331,6 +338,7 @@ test('open on existing iOS session refreshes unavailable simulator by name', asy const response = await withMockedPlatform('darwin', async () => handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -380,6 +388,7 @@ test('open app on existing Android session resolves and stores package id', asyn mockResolveAndroidPackage.mockResolvedValue('org.reactjs.native.example.RNCLI83'); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -424,6 +433,7 @@ test('open intent target on existing Android session clears stale package contex mockResolveAndroidPackage.mockResolvedValue(undefined); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -477,6 +487,7 @@ test('open on existing Android session preserves a comparable freshness baseline mockResolveAndroidPackage.mockResolvedValue('com.android.settings'); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts index 7794912797..a4169e20c0 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; const dispatchSnapshotViaRuntime = vi.hoisted(() => vi.fn()); @@ -150,6 +151,7 @@ const failedOpenResponse: DaemonResponse = { test('passes a failed open response through untouched', async () => { const result = await composeOpenWithInitialSnapshot({ + platformServices: daemonPlatformServicesFixture(), req: baseRequest({ flags: { foreground: true } }), ref, logPath: '/tmp/daemon.log', @@ -165,6 +167,7 @@ test('passes a failed open response through untouched', async () => { test('leaves a successful open response untouched when --foreground was not requested', async () => { const result = await composeOpenWithInitialSnapshot({ + platformServices: daemonPlatformServicesFixture(), req: baseRequest(), ref, logPath: '/tmp/daemon.log', @@ -183,6 +186,7 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn const req = baseRequest({ flags: { foreground: true }, positionals: ['xyz.blueskyweb.app'] }); const result = await composeOpenWithInitialSnapshot({ + platformServices: daemonPlatformServicesFixture(), req, ref, logPath: '/tmp/daemon.log', @@ -207,6 +211,7 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn sessionStore, inspectFacts, bindDevice, + platformServices: daemonPlatformServicesFixture(), }); expect(result).toEqual({ ok: true, @@ -233,6 +238,7 @@ test('a snapshot-capture failure never masks the successful open', async () => { dispatchSnapshotViaRuntime.mockResolvedValue(snapshotFailure); const result = await composeOpenWithInitialSnapshot({ + platformServices: daemonPlatformServicesFixture(), req: baseRequest({ flags: { foreground: true } }), ref, logPath: '/tmp/daemon.log', @@ -275,6 +281,7 @@ test('a THROWN snapshot-capture failure never masks the successful open either', ); const result = await composeOpenWithInitialSnapshot({ + platformServices: daemonPlatformServicesFixture(), req: baseRequest({ flags: { foreground: true } }), ref, logPath: '/tmp/daemon.log', diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts index 7f61090b65..e28c074117 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import path from 'node:path'; @@ -123,6 +124,7 @@ test('open applies stored runtime launchUrl and reports runtime hints', async () }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'runtime-open', @@ -186,6 +188,7 @@ test('open rejects a false runtime-hints fact before its one implementation bind }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'runtime-open-false-fact', @@ -237,6 +240,7 @@ test('open applies launch-only flags only to the direct app launch before runtim }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'launch-console-runtime', @@ -279,6 +283,7 @@ test('open --metro-port alone defaults the host to 10.0.2.2 on an Android emulat }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -310,6 +315,7 @@ test('open --metro-port alone defaults the host to 127.0.0.1 on an iOS simulator }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -341,6 +347,7 @@ test('open --metro-port alone stays host-ambiguous on a physical Android device' }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -372,6 +379,7 @@ test('open --relaunch allows Android package names ending with apk-like suffix', }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts index 5c3f52f660..c264b9a674 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import { sessionCloseShutdownFixture, @@ -41,6 +42,7 @@ test('close finalizes an active iOS simulator recording before deleting the sess sessionStore.publish(sessionName, session); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -79,6 +81,7 @@ test('close surfaces a recording finalization failure through the cleanup-failur await expect( handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/session-lifecycle/internal/inventory.ts b/src/daemon/session-lifecycle/internal/inventory.ts index 8ba3118a75..f6fd19ad5b 100644 --- a/src/daemon/session-lifecycle/internal/inventory.ts +++ b/src/daemon/session-lifecycle/internal/inventory.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../../platform-services.ts'; import { commandRuntimeUseRequirements, listRuntimeFactCommands, @@ -59,6 +60,7 @@ export type SessionInventoryCommandInput = Readonly<{ sessionName: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; providerAppCatalog?: ProviderAppCatalog; }>; @@ -78,6 +80,7 @@ export async function handleSessionInventoryCommands( sessionName, sessionStore, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, }); case 'apps': return await handleAppsInventory({ @@ -85,6 +88,7 @@ export async function handleSessionInventoryCommands( sessionName, sessionStore, bindDevice: params.bindDevice, + platformServices: params.platformServices, inspectFacts: params.inspectFacts, providerAppCatalog: params.providerAppCatalog, }); @@ -238,6 +242,7 @@ async function capabilitiesInventoryResponse(params: { sessionName: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; }): Promise { const resolution = await resolveInventoryCommandDevice({ ...params, @@ -330,10 +335,11 @@ async function handleAppsInventory(params: { sessionName: string; sessionStore: SessionStore; bindDevice?: BindDeviceRuntime; + platformServices: DaemonPlatformServices; providerAppCatalog?: ProviderAppCatalog; inspectFacts?: InspectDeviceRuntimeFacts; }): Promise { - const { req, sessionName, sessionStore, bindDevice, inspectFacts } = params; + const { req, sessionName, sessionStore, bindDevice, inspectFacts, platformServices } = params; const providerCatalogResponse = await resolveProviderAppCatalogResponse( req, params.providerAppCatalog, @@ -344,6 +350,7 @@ async function handleAppsInventory(params: { sessionName, sessionStore, androidAvdSelection: 'include-stopped', + platformServices, }); if ('response' in resolution) return resolution.response; const { device } = resolution; @@ -352,7 +359,12 @@ async function handleAppsInventory(params: { const resolvedAndroidSerialAllowlist = resolveAndroidSerialAllowlist( req.flags?.androidDeviceAllowlist, ); - const runtimeResolution = await resolveAppsRuntime({ device, inspectFacts, bindDevice }); + const runtimeResolution = await resolveAppsRuntime({ + device, + inspectFacts, + bindDevice, + platformServices, + }); if ('response' in runtimeResolution) return runtimeResolution.response; const runtime = runtimeResolution.runtime; const readyDevice = await ensureAppsRuntimeReady(runtime, { @@ -407,6 +419,7 @@ async function inspectCapabilityFacts( async function resolveAppsRuntime(params: { device: DeviceInfo; inspectFacts: InspectDeviceRuntimeFacts | undefined; + platformServices: DaemonPlatformServices; bindDevice: BindDeviceRuntime | undefined; }): Promise<{ response: DaemonResponse } | { runtime: BoundDeviceRuntime }> { const facts = await requireRuntimeFacts(params.inspectFacts)(params.device); @@ -444,8 +457,9 @@ async function resolveInventoryCommandDevice(params: { sessionName: string; sessionStore: SessionStore; androidAvdSelection?: 'running-only' | 'include-stopped'; + platformServices: DaemonPlatformServices; }): Promise<{ device: DeviceInfo } | { response: DaemonResponse }> { - const { req, sessionName, sessionStore, androidAvdSelection } = params; + const { req, sessionName, sessionStore, androidAvdSelection, platformServices } = params; const session = sessionStore.get(sessionName); const flags = req.flags ?? {}; const response = requireSessionOrExplicitSelector(req.command, session, flags); @@ -455,6 +469,7 @@ async function resolveInventoryCommandDevice(params: { device: await resolveCommandDevice({ session, flags, + platformServices, androidAvdSelection, }), }; diff --git a/src/daemon/session-lifecycle/internal/session-close-runtime-admission.ts b/src/daemon/session-lifecycle/internal/session-close-runtime-admission.ts index d70fd34a50..ec26998ad0 100644 --- a/src/daemon/session-lifecycle/internal/session-close-runtime-admission.ts +++ b/src/daemon/session-lifecycle/internal/session-close-runtime-admission.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../../platform-services.ts'; import { closeApplicationRuntimeUse, closeApplicationWithRuntimeHintClearUse, @@ -33,6 +34,7 @@ export async function admitCloseRuntime(params: { device: DeviceInfo; clearRuntimeHints: boolean; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { const use = params.clearRuntimeHints diff --git a/src/daemon/session-lifecycle/internal/session-close.ts b/src/daemon/session-lifecycle/internal/session-close.ts index 7654c1bc0e..bd04ff3c2f 100644 --- a/src/daemon/session-lifecycle/internal/session-close.ts +++ b/src/daemon/session-lifecycle/internal/session-close.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../../platform-services.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import type { LeaseLifecycleProvider, TargetShutdownResult } from '@agent-device/contracts/device'; @@ -47,6 +48,7 @@ export type SessionCloseCommandInput = Readonly<{ leaseRegistry: LeaseRegistry; leaseLifecycleProvider?: LeaseLifecycleProvider; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; platformResourceCleanup?: PlatformResourceCleanup; }>; @@ -200,6 +202,7 @@ export async function handleSessionCloseCommands( req, logPath, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); } @@ -223,6 +226,7 @@ export async function handleSessionCloseCommands( Boolean(session.appBundleId) && hasRuntimeTransportHints(sessionStore.getRuntimeHints(sessionName)), inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (admission.type === 'response') return admission.response; @@ -416,20 +420,23 @@ async function closeWithoutSession(params: { req: DaemonRequest; logPath: string; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { - const { req, logPath, inspectFacts, bindDevice } = params; + const { req, logPath, inspectFacts, bindDevice, platformServices } = params; if (!req.positionals || req.positionals.length === 0) return errorResponse('SESSION_NOT_FOUND', 'No active session'); const device = await resolveCommandDevice({ session: undefined, flags: req.flags, + platformServices, }); const admission = await admitCloseRuntime({ device, clearRuntimeHints: false, inspectFacts, bindDevice, + platformServices, }); if (admission.type === 'response') return admission.response; await admission.runtime.operations.closeApplication({ diff --git a/src/daemon/session-lifecycle/internal/session-open-execution.ts b/src/daemon/session-lifecycle/internal/session-open-execution.ts index 7998d06d81..5ff7dd3486 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -11,6 +11,7 @@ import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtim import type { SessionSurface } from '@agent-device/contracts/session'; import type { OpenApplicationTiming } from '@agent-device/contracts/application-lifecycle-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { DaemonPlatformServices } from '../../platform-services.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import type { SessionRef, SessionState } from '../../session-state.ts'; import { @@ -59,7 +60,6 @@ import { type DeviceClaimReconciler, } from '../../device/device-claims.ts'; import type { TakenOverDeviceClaim } from '../../device/device-claim-reboot.ts'; -import { deviceBootObservation } from '../../../platform-runtime-device-boot.ts'; import { appendResponseWarning } from './session-open-warnings.ts'; import { buildDeviceClaimConflictError } from '../../device/device-claim-conflict.ts'; import { deviceClaimRuleForOwner } from '../../device/device-claim-rule.ts'; @@ -406,8 +406,17 @@ async function acquireDeviceClaimForOwner(params: { sessionName: string; sessionStore: SessionStore; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; + platformServices: DaemonPlatformServices; }): Promise { - const { req, device, owner, sessionName, sessionStore, reconcileOrphanedDeviceClaim } = params; + const { + req, + device, + owner, + sessionName, + sessionStore, + reconcileOrphanedDeviceClaim, + platformServices, + } = params; switch (deviceClaimRuleForOwner(owner, req.internal?.admittedLease)) { case 'none': return { status: 'not-required' }; @@ -418,7 +427,7 @@ async function acquireDeviceClaimForOwner(params: { workspace: req.meta?.cwd ?? process.cwd(), stateDir: sessionStore.resolveDaemonStateDir(), reconcileOrphanedDeviceClaim, - observeDeviceBoot: deviceBootObservation, + observeDeviceBoot: platformServices.deviceBootObservation, }); } } @@ -437,6 +446,7 @@ export async function openNewSessionWithDeviceClaim(params: { clearRuntimeHints?: RuntimeHintClearOperation; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; selection?: DeviceSelectionResult; + platformServices: DaemonPlatformServices; }): Promise { const { req, @@ -452,6 +462,7 @@ export async function openNewSessionWithDeviceClaim(params: { clearRuntimeHints, reconcileOrphanedDeviceClaim, selection, + platformServices, } = params; requireOpenSessionAdmission(sessionStore, sessionName, undefined); const conflict = findNewSessionDeviceConflict({ req, device, sessionStore }); @@ -464,6 +475,7 @@ export async function openNewSessionWithDeviceClaim(params: { sessionName, sessionStore, reconcileOrphanedDeviceClaim, + platformServices, }); if (ownerClaim.status === 'conflict') return { diff --git a/src/daemon/session-lifecycle/internal/session-open-foreground.ts b/src/daemon/session-lifecycle/internal/session-open-foreground.ts index 201b68c740..da558269fe 100644 --- a/src/daemon/session-lifecycle/internal/session-open-foreground.ts +++ b/src/daemon/session-lifecycle/internal/session-open-foreground.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../../platform-services.ts'; import { normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import { dispatchSnapshotViaRuntime } from '../../snapshot-runtime.ts'; import type { SessionRef } from '../../session-state.ts'; @@ -108,6 +109,7 @@ export async function composeOpenWithInitialSnapshot(params: { sessionStore: SessionStore; openResponse: DaemonResponse; inspectFacts: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice: BindDeviceRuntime; }): Promise { const { req, ref, logPath, sessionStore, openResponse } = params; @@ -129,6 +131,7 @@ export async function composeOpenWithInitialSnapshot(params: { logPath, sessionStore, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (!snapshotResponse.ok) diff --git a/src/daemon/session-lifecycle/internal/session-open-prepare.ts b/src/daemon/session-lifecycle/internal/session-open-prepare.ts index 59c45ec654..4d6f787af7 100644 --- a/src/daemon/session-lifecycle/internal/session-open-prepare.ts +++ b/src/daemon/session-lifecycle/internal/session-open-prepare.ts @@ -7,10 +7,7 @@ import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import { startupDeadlineAtMs } from '../../startup-deadline.ts'; import type { SessionRuntimeHints, SessionState } from '../../session-state.ts'; import { SessionStore } from '../../session-store.ts'; -import { - resolveRequestedOpenSurface, - validateOpenRelaunchTarget, -} from '../../../platform-runtime-open-target.ts'; +import type { DaemonPlatformServices } from '../../platform-services.ts'; import { hasRuntimeTransportHints, maybeClearRemovedRuntimeTransportHints, @@ -52,13 +49,14 @@ export function invalidOpenArgs(message: string): DaemonResponse { } export function resolveOpenSurfaceResponse( + platformServices: DaemonPlatformServices, device: DeviceInfo, surfaceFlag: string | undefined, openTarget: string | undefined, existingSurface?: SessionSurface, ): SessionSurface | DaemonResponse { try { - return resolveRequestedOpenSurface({ + return platformServices.resolveRequestedOpenSurface({ device, surfaceFlag, openTarget, @@ -77,10 +75,11 @@ export async function validateResolvedOpenRequest(params: { openTarget: string | undefined; surface: SessionSurface; device: DeviceInfo; + platformServices: DaemonPlatformServices; }): Promise { - const { shouldRelaunch, openTarget, surface, device } = params; + const { shouldRelaunch, openTarget, surface, device, platformServices } = params; if (!shouldRelaunch) return null; - const message = await validateOpenRelaunchTarget({ + const message = await platformServices.validateOpenRelaunchTarget({ target: openTarget, platform: device.platform, surface, @@ -92,10 +91,14 @@ export async function validatePreResolvedOpenRequest(params: { shouldRelaunch: boolean; openTarget: string | undefined; platform: DeviceInfo['platform'] | undefined; + platformServices: DaemonPlatformServices; }): Promise { - const { shouldRelaunch, openTarget, platform } = params; + const { shouldRelaunch, openTarget, platform, platformServices } = params; if (!shouldRelaunch) return null; - const message = await validateOpenRelaunchTarget({ target: openTarget, platform }); + const message = await platformServices.validateOpenRelaunchTarget({ + target: openTarget, + platform, + }); return message ? invalidOpenArgs(message) : null; } diff --git a/src/daemon/session-lifecycle/internal/session-open.ts b/src/daemon/session-lifecycle/internal/session-open.ts index 8cc765fa3c..7ae7ac96be 100644 --- a/src/daemon/session-lifecycle/internal/session-open.ts +++ b/src/daemon/session-lifecycle/internal/session-open.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../../platform-services.ts'; import { resolveTargetDeviceSelection } from '@agent-device/device-selection/dispatch-resolve'; import { openApplicationRuntimeUse, @@ -53,6 +54,7 @@ export type SessionOpenCommandInput = Readonly<{ logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; }>; @@ -83,6 +85,7 @@ async function admitOpenRuntime(params: { device: DeviceInfo; runtimeHintPlan: ResolvedOpenRuntimeHintPlan; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { const plan = resolveOpenApplicationRuntimePlan({ @@ -136,6 +139,7 @@ async function resolveOpenRuntimePlanAdmission(params: { device: DeviceInfo; existingSession?: SessionState; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }): Promise { const runtimeHintPlan = resolveOpenRuntimeHintPlan(params); @@ -144,6 +148,7 @@ async function resolveOpenRuntimePlanAdmission(params: { device: params.device, runtimeHintPlan: runtimeHintPlan.plan, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (admission.type === 'response') return admission; @@ -179,6 +184,7 @@ async function handleOpenCommand(params: SessionOpenCommandInput): Promise { return await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: request(command), sessionName: 'default', logPath: '/tmp/agent-device-session-observability-route.log', @@ -57,6 +59,7 @@ test('observability commands retain their route response and narrow facade input 'bindDevice', 'inspectFacts', 'perfCaptureAdmissionLedger', + 'platformServices', 'req', 'sessionName', 'sessionStore', diff --git a/src/daemon/session-observability/internal/__tests__/session-logs.test.ts b/src/daemon/session-observability/internal/__tests__/session-logs.test.ts index 86b19e980f..396dfb7e73 100644 --- a/src/daemon/session-observability/internal/__tests__/session-logs.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-logs.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import fs from 'node:fs'; import { beforeEach, expect, test, vi } from 'vitest'; import type { AppLogRuntimeOperations } from '@agent-device/contracts/app-log-runtime'; @@ -296,6 +297,7 @@ async function runLogs( throwIfCanceled?: () => void, ) { return await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, command: 'logs', positionals, flags }, sessionName, logPath: '/tmp/daemon.log', diff --git a/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts b/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts index 78f56520df..677dbb3a19 100644 --- a/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import path from 'node:path'; import { expect, test } from 'vitest'; import { providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; @@ -59,6 +60,7 @@ test.each(NETWORK_RUNTIME_PROJECTION_PARITY)( const runtime = createNetworkRuntime(session.device, async () => result); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -112,6 +114,7 @@ test('network admission remains fail-closed before parsing invalid input', async }); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: session.name, @@ -209,6 +212,7 @@ async function runNetwork( bindDevice: BindDeviceRuntime, ) { return await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, diff --git a/src/daemon/session-observability/internal/__tests__/session-network.test.ts b/src/daemon/session-observability/internal/__tests__/session-network.test.ts index 1bb7d3a0b3..40df394236 100644 --- a/src/daemon/session-observability/internal/__tests__/session-network.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-network.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import path from 'node:path'; import { expect, test } from 'vitest'; import { handleSessionCommands } from '../../../handlers/__tests__/session-command-harness.ts'; @@ -12,6 +13,7 @@ test('network requires an active session before requesting a runtime binding', a const sessionStore = makeSessionStore(); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'missing', @@ -45,6 +47,7 @@ test('network validates the legacy limit after runtime-fact admission', async () ); const response = await handleSessionCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: session.name, diff --git a/src/daemon/session-observability/internal/__tests__/session-observability.test.ts b/src/daemon/session-observability/internal/__tests__/session-observability.test.ts index a3df8a5f50..0e814f0633 100644 --- a/src/daemon/session-observability/internal/__tests__/session-observability.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-observability.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import assert from 'node:assert/strict'; import { test } from 'vitest'; import { makeAndroidSession } from '../../../../__tests__/test-utils/session-factories.ts'; @@ -13,6 +14,7 @@ test('network dump validates include mode directly', async () => { emptyAppLogResult('android', input), ); const response = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', @@ -40,6 +42,7 @@ test('network dump accepts explicit include flag and rejects conflicting values' emptyAppLogResult('android', input), ); const okResponse = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', @@ -55,6 +58,7 @@ test('network dump accepts explicit include flag and rejects conflicting values' if (okResponse?.ok) assert.equal(okResponse.data?.include, 'headers'); const conflictResponse = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', @@ -88,6 +92,7 @@ test.each(REMOVED_AGGREGATE_PERF_POSITIONALS.map((positionals) => [positionals] async (positionals) => { const sessionStore = makeAndroidStore(); const response = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', command: 'perf', positionals, flags: {} }, sessionName: 'android', sessionStore, diff --git a/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts b/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts index c87b1c2582..ed502e9257 100644 --- a/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/platform-services-fixture.ts'; import assert from 'node:assert/strict'; import { test, vi } from 'vitest'; import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; @@ -35,6 +36,7 @@ const unavailable = Object.freeze({ test('perf requires an active session', async () => { const sessionStore = makeSessionStore(); const response = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', @@ -62,6 +64,7 @@ test('perf frames admits and binds only the selected runtime operation', async ( const runtime = createPerfRuntime({ perfFrames }); const response = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', command: 'perf', positionals: ['frames'] }, sessionName: 'android', sessionStore, @@ -80,6 +83,7 @@ test('perf refuses from owner facts before binding', async () => { const sessionStore = makeStore(); const runtime = createPerfRuntime({}, unavailable); const response = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', command: 'perf', positionals: ['memory', 'sample'] }, sessionName: 'android', sessionStore, @@ -146,6 +150,7 @@ test('perf native capture is adopted durably and stop uses the live handle witho }; const startResponse = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), ...common, req: { token: 't', @@ -163,6 +168,7 @@ test('perf native capture is adopted durably and stop uses the live handle witho ); const wrongModeResponse = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), ...common, req: { token: 't', @@ -183,6 +189,7 @@ test('perf native capture is adopted durably and stop uses the live handle witho assert.equal(sessionStore.get('android')?.actions.length, 1); const stopResponse = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), ...common, req: { token: 't', @@ -215,6 +222,7 @@ test.each(['shutdown', 'retire'] as const)( return bound; }; const response = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', @@ -320,6 +328,7 @@ test('a perf result does not record into a successor occupying the same address' }); const runtime = createPerfRuntime({ perfFrames }); const response = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'android', command: 'perf', positionals: ['frames'] }, sessionName: 'android', sessionStore, diff --git a/src/daemon/session-observability/internal/session-audio.ts b/src/daemon/session-observability/internal/session-audio.ts index 9a947fa1ce..76513ace47 100644 --- a/src/daemon/session-observability/internal/session-audio.ts +++ b/src/daemon/session-observability/internal/session-audio.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../../platform-services.ts'; import path from 'node:path'; import { normalizeError } from '@agent-device/kernel/errors'; import { @@ -29,6 +30,7 @@ type AudioParams = { sessionName: string; sessionStore: SessionStore; inspectFacts: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice: BindDeviceRuntime; audioProbeAdmissionLedger: AudioProbeAdmissionLedger; throwIfCanceled(): void; diff --git a/src/daemon/session-observability/internal/session-network.ts b/src/daemon/session-observability/internal/session-network.ts index 9c3fdbf705..67c77c034e 100644 --- a/src/daemon/session-observability/internal/session-network.ts +++ b/src/daemon/session-observability/internal/session-network.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../../platform-services.ts'; import type { NetworkDumpResult } from '@agent-device/contracts/network-runtime'; import { networkAdmissionUse, @@ -25,6 +26,7 @@ export type NetworkHandlerParams = Readonly<{ sessionName: string; sessionStore: SessionStore; bindDevice?: BindDeviceRuntime; + platformServices: DaemonPlatformServices; }>; type NetworkRequest = Readonly<{ diff --git a/src/daemon/session-observability/internal/session-observability.ts b/src/daemon/session-observability/internal/session-observability.ts index 8e54d0205c..674f20efd5 100644 --- a/src/daemon/session-observability/internal/session-observability.ts +++ b/src/daemon/session-observability/internal/session-observability.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from '../../platform-services.ts'; import type { LogBackend } from '@agent-device/contracts/observability'; import type { AppLogFailure, @@ -43,6 +44,7 @@ export type SessionObservabilityCommandInput = { sessionName: string; sessionStore: SessionStore; bindDevice?: BindDeviceRuntime; + platformServices: DaemonPlatformServices; inspectFacts?: InspectDeviceRuntimeFacts; appLogAdmissionLedger?: AppLogAdmissionLedger; audioProbeAdmissionLedger?: AudioProbeAdmissionLedger; @@ -54,6 +56,7 @@ type LogsHandlerParams = Omit {}), @@ -432,6 +436,7 @@ function requireLogsHandlerParams( return { ...params, bindDevice: params.bindDevice, + platformServices: params.platformServices, appLogAdmissionLedger: params.appLogAdmissionLedger, }; } diff --git a/src/daemon/session-runtime-admission.ts b/src/daemon/session-runtime-admission.ts index 71716e8c90..340ab88272 100644 --- a/src/daemon/session-runtime-admission.ts +++ b/src/daemon/session-runtime-admission.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from './platform-services.ts'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; import type { RuntimeOperationFact, @@ -15,6 +16,7 @@ export type RuntimeCommandHandlerParams = Readonly<{ sessionName: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; }>; diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index 607df4a263..8b1b04456b 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -93,6 +93,7 @@ export async function dispatchSnapshotRuntimeCommand( session, device, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (!timeoutResponse) throw error; diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index f88d502159..4af8c430af 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -1,3 +1,4 @@ +import type { DaemonPlatformServices } from './platform-services.ts'; import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import { type SelectorCaptureRuntimePlan, @@ -47,6 +48,7 @@ export type SnapshotRuntimeRouteParams = { logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; platformResourceCleanup?: PlatformResourceCleanup; }; @@ -103,6 +105,7 @@ export async function admitAndBindSnapshotCapture( session: SessionState | undefined; plan: SnapshotRuntimePlan | SelectorCaptureRuntimePlan; inspectFacts?: InspectDeviceRuntimeFacts; + platformServices: DaemonPlatformServices; bindDevice?: BindDeviceRuntime; readiness?: boolean; }>, @@ -118,10 +121,16 @@ export async function admitAndBindSnapshotCapture( session, device, command, + platformServices: params.platformServices, }), }; } - const bound = await bindSnapshotCaptureRuntime(params.bindDevice, admission, params.readiness); + const bound = await bindSnapshotCaptureRuntime( + params.bindDevice, + admission, + params.readiness, + params.platformServices, + ); return Object.freeze({ ok: true, capture: async (input: CaptureSnapshotInput) => await bound.captureSnapshot(input), @@ -165,6 +174,7 @@ export async function resolveBoundSnapshotCaptureRuntime( hasActiveApp: session?.appBundleId !== undefined, }), inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, readiness: !session, }); @@ -196,6 +206,7 @@ async function bindSnapshotCaptureRuntime( bindDevice: BindDeviceRuntime | undefined, admission: AdmittedRuntimePlan, readiness: boolean | undefined, + platformServices: DaemonPlatformServices, ): Promise< Readonly<{ captureSnapshot(input: CaptureSnapshotInput): Promise; @@ -212,7 +223,7 @@ async function bindSnapshotCaptureRuntime( const bind = requireRuntimeBinding(bindDevice); const bindReady: BindDeviceRuntime = async (device, use) => { const runtime = await bind(device, use); - if (readiness) await ensureBoundDeviceReady(runtime); + if (readiness) await ensureBoundDeviceReady(runtime, platformServices); return runtime; }; const { device, plan } = unwrapAdmittedRuntimePlan(admission); @@ -373,6 +384,7 @@ type SnapshotPlanUnavailableParams = { session: SessionState | undefined; device: SessionState['device']; command: string; + platformServices: DaemonPlatformServices; }; async function snapshotPlanUnavailableResponse( @@ -390,7 +402,7 @@ async function snapshotPlanUnavailableResponse( if (params.operation === 'captureSnapshotWithCustomActions') { return snapshotCustomActionsUnavailableResponse(params); } - const openCommandHint = await buildIosOpenCommandHint(params.device); + const openCommandHint = await buildIosOpenCommandHint(params.device, params.platformServices); return errorResponse( 'SESSION_NOT_FOUND', `iOS ${params.command} requires an active app session on the target device. Run open first (for example: open --session ${params.session?.name ?? 'sim'} --platform ios --device "" ).`, diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index 7475f43781..fdf7a2f5a8 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -49,6 +49,7 @@ export async function dispatchSnapshotViaRuntime( logPath: params.logPath, verdict: result.snapshotQuality, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); const published = copySnapshotClickabilityEvidence( diff --git a/src/daemon/sparse-fallback-screenshot.ts b/src/daemon/sparse-fallback-screenshot.ts index 18d42336c3..b9d34d365a 100644 --- a/src/daemon/sparse-fallback-screenshot.ts +++ b/src/daemon/sparse-fallback-screenshot.ts @@ -76,6 +76,7 @@ async function captureFallbackScreenshot( device: session.device, overlayRefs: false, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); // A target that cannot capture pixels owes the caller nothing here: the sparse verdict's own diff --git a/src/daemon/system-button-runtime.ts b/src/daemon/system-button-runtime.ts index f01f73d831..aa01452482 100644 --- a/src/daemon/system-button-runtime.ts +++ b/src/daemon/system-button-runtime.ts @@ -72,6 +72,7 @@ export async function resolveBoundSystemButtonRuntime( device: params.device, use: row.use, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }, async (runtime: BoundDeviceRuntime, context) => { diff --git a/src/daemon/touch-runtime.ts b/src/daemon/touch-runtime.ts index 521150604a..addeb01273 100644 --- a/src/daemon/touch-runtime.ts +++ b/src/daemon/touch-runtime.ts @@ -82,6 +82,7 @@ export async function resolveBoundTouchRuntime( const shared = { device: params.device, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }; const plan = resolveTouchRuntimePlan(params.command, params.requiresCapture); diff --git a/src/daemon/tv-remote-runtime.ts b/src/daemon/tv-remote-runtime.ts index 76c4956731..b2263a5d2f 100644 --- a/src/daemon/tv-remote-runtime.ts +++ b/src/daemon/tv-remote-runtime.ts @@ -56,6 +56,7 @@ export async function resolveBoundTvRemoteRuntime( device: params.device, use: tvRemoteRuntimeUse, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }, (runtime, context) => executeTvRemote(runtime, button, durationMs, context), diff --git a/src/daemon/type-text-runtime.ts b/src/daemon/type-text-runtime.ts index ae14643069..9771599ca7 100644 --- a/src/daemon/type-text-runtime.ts +++ b/src/daemon/type-text-runtime.ts @@ -47,6 +47,7 @@ export async function resolveBoundTypeTextRuntime( device: params.device, use: typeTextRuntimeUse, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (admission.type === 'response') return { ok: false, response: admission.response }; diff --git a/src/daemon/viewport-runtime.ts b/src/daemon/viewport-runtime.ts index abd289f9db..2b824e3a12 100644 --- a/src/daemon/viewport-runtime.ts +++ b/src/daemon/viewport-runtime.ts @@ -16,6 +16,7 @@ export async function resolveBoundViewportRuntime( device: params.device, use: viewportRuntimeUse, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); if (admission.type === 'response') return { ok: false, response: admission.response }; diff --git a/src/daemon/wait-runtime.ts b/src/daemon/wait-runtime.ts index 9cd1281cd7..73fb1176f2 100644 --- a/src/daemon/wait-runtime.ts +++ b/src/daemon/wait-runtime.ts @@ -118,6 +118,7 @@ async function bindWaitOperations( device, session, inspectFacts: params.inspectFacts, + platformServices: params.platformServices, bindDevice: params.bindDevice, }); return bound.ok ? bound.operations : bound.response; diff --git a/src/platform-runtime-daemon-services.ts b/src/platform-runtime-daemon-services.ts new file mode 100644 index 0000000000..916aab5574 --- /dev/null +++ b/src/platform-runtime-daemon-services.ts @@ -0,0 +1,26 @@ +import { appleSessionObservation } from './platform-runtime-apple-resources.ts'; +import { deviceBootObservation } from './platform-runtime-device-boot.ts'; +import { ensureLocalPlatformDeviceReady } from './platform-runtime-device-ready.ts'; +import { + resolveRequestedOpenSurface, + resolveSessionAppBundleIdForTarget, + validateOpenRelaunchTarget, +} from './platform-runtime-open-target.ts'; +import type { DaemonPlatformServices } from './daemon/platform-services.ts'; + +/** + * Root composition of the daemon's platform-services port (ADR 0019 section 11). Each member is + * already the neutral contract the daemon consumes; this module is what names the concrete + * adapters, so no daemon file has to. Device execution never enters here — it reaches the device + * through the request-bound runtime binding, whose owner facts stay the only admission authority. + */ +export function createDaemonPlatformServices(): DaemonPlatformServices { + return Object.freeze({ + appleSessionObservation, + deviceBootObservation, + ensureLocalDeviceReady: ensureLocalPlatformDeviceReady, + resolveRequestedOpenSurface, + validateOpenRelaunchTarget, + resolveSessionAppBundleIdForTarget, + }); +} diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index d68006c341..0d0d2bfaf7 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from './daemon/__tests__/platform-services-fixture.ts'; import { type DeviceBinding, localRuntimeOwner, @@ -344,6 +345,7 @@ describe('composed platform runtime gateway', () => { { local: async () => [staleDevice] }, async () => await handleSessionStateCommands({ + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts index d63746b9b9..618d27210b 100644 --- a/test/integration/nightly/concurrency-torture/real-scope-serialization.ts +++ b/test/integration/nightly/concurrency-torture/real-scope-serialization.ts @@ -1,4 +1,5 @@ // Real-scope same-device serialization guard for the concurrency torture lane +import { daemonPlatformServicesFixture } from '../../../../src/daemon/__tests__/platform-services-fixture.ts'; // (#1416). The seeded sweep models the mutex GRANT (a seed cannot reproduce // `withKeyedLock`'s native microtask hand-off), so on its own it could stay // green if the PRODUCTION lock APPLICATION path regressed. This guard closes @@ -65,6 +66,7 @@ export async function measureRealScopeMaxOverlap(concurrency: number): Promise createRequestExecutionScope({ + platformServices: daemonPlatformServicesFixture(), req: openRequest(`real-${i}`, device.id), sessionStore, leaseRegistry, diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 3f62a2653f..b54de2a458 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -16,6 +16,7 @@ import { androidObservation, type PlatformProviderResolvers, } from '../../../src/platform-runtime.ts'; +import { createDaemonPlatformServices } from '../../../src/platform-runtime-daemon-services.ts'; import { platformResourceCleanup } from '../../../src/platform-runtime-resource-cleanup.ts'; import { readDaemonProviderCredentials } from '../../../src/provider-credential-fingerprint.ts'; import type { AppleSimulatorScreenRecordingProcess } from '../../../src/platform-runtime-screen-recording-apple-transport.ts'; @@ -179,6 +180,7 @@ export async function createProviderScenarioHarness( hostDiagnostics: createHostDiagnostics(), androidObservation, platformResourceCleanup, + platformServices: createDaemonPlatformServices(), requestPlatformProviders: configuredRequestPlatformProviders ?? createRequestPlatformProviders({ diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts index 8b6895ff1b..0f4654f4c7 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts @@ -1,3 +1,4 @@ +import { daemonPlatformServicesFixture } from '../../../src/daemon/__tests__/platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; import { vi } from 'vitest'; @@ -81,6 +82,7 @@ export async function inspectProviderDeploymentAdmission(params: { device: params.device, }); const response = await handleAppDeploymentCommand({ + platformServices: daemonPlatformServicesFixture(), req: { token: 'test', session: 'default',