From 452e14175a2f0871e99a810121c736c6f750032e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 16 Aug 2026 16:47:40 +0200 Subject: [PATCH 1/9] refactor: migrate snapshot to device runtime --- packages/contracts/src/facades/platform.ts | 15 +- packages/contracts/src/interactor-types.ts | 2 +- .../src/platform-runtime-operations.ts | 4 + .../src/platform-runtime-unavailable.ts | 5 + .../contracts/src/snapshot-runtime.test.ts | 68 ++++ packages/contracts/src/snapshot-runtime.ts | 110 +++++ packages/platform-android/src/runtime.test.ts | 6 + packages/platform-android/src/runtime.ts | 14 + .../platform-apple/src/runtime-snapshot.ts | 37 ++ packages/platform-apple/src/runtime.test.ts | 55 +++ packages/platform-apple/src/runtime.ts | 46 ++- .../platform-harmonyos/src/runtime.test.ts | 6 + packages/platform-harmonyos/src/runtime.ts | 17 + packages/platform-linux/src/runtime.test.ts | 35 +- packages/platform-linux/src/runtime.ts | 31 +- packages/platform-vega/src/runtime.test.ts | 5 + packages/platform-vega/src/runtime.ts | 1 + packages/platform-web/src/runtime.test.ts | 4 + packages/platform-web/src/runtime.ts | 9 + .../src/app-log-runtime.test.ts | 6 + .../provider-limrun/src/app-log-runtime.ts | 8 + .../src/platform-runtime.test.ts | 73 ++++ .../src/platform-runtime.ts | 32 ++ packages/provider-webdriver/src/runtime.ts | 1 + .../runtime-command-cutover-table.test.ts | 47 +++ .../layering/runtime-command-cutover-table.ts | 27 +- .../test-utils/runtime-operation-facts.ts | 3 +- .../capability-plugin-routing-parity.test.ts | 2 - ...ispatch-snapshot-preferred-backend.test.ts | 77 ++-- src/core/capabilities.ts | 11 +- .../__tests__/parity.test.ts | 7 +- .../snapshot-runtime-execution.test.ts | 50 +++ src/core/command-descriptor/registry.ts | 5 +- src/core/dispatch.ts | 21 - src/core/snapshot-desktop-surface.ts | 82 ++++ .../app-log-resource-recovery.test.ts | 4 +- .../application-lifecycle-recovery.test.ts | 4 +- .../device-claim-reconciliation.test.ts | 4 +- .../legacy-snapshot-capture-fixture.ts | 25 ++ ...est-router-android-snapshot-helper.test.ts | 9 + .../request-router-lock-policy.test.ts | 28 ++ ...request-router-record-runtime-lock.test.ts | 4 +- .../request-router-screenshot.test.ts | 5 + .../request-runtime-binding-router.test.ts | 4 +- .../__tests__/request-runtime-binding.test.ts | 4 +- ...screen-recording-resource-recovery.test.ts | 4 +- .../selector-capture-runtime.test.ts | 5 + ...shot-custom-actions-platform-guard.test.ts | 26 ++ .../__tests__/snapshot-quality-latch.test.ts | 8 + .../snapshot-runtime-cancellation.test.ts | 20 +- .../__tests__/snapshot-runtime-fixture.ts | 82 ++++ .../sparse-fallback-screenshot.test.ts | 3 + .../__tests__/test-device-runtime-gateway.ts | 6 +- .../application-lifecycle-runtime-harness.ts | 6 +- src/daemon/handlers/__tests__/find.test.ts | 5 + .../handlers/__tests__/install-source.test.ts | 1 + .../interaction-ios-tap-outcome.test.ts | 7 + .../interaction-target-evidence.test.ts | 5 + ...nteraction-touch-android-freshness.test.ts | 33 +- ...nteraction-touch-android-readiness.test.ts | 29 +- ...ction-touch-direct-ios-eligibility.test.ts | 29 +- .../interaction-touch-direct-ios.test.ts | 24 +- .../__tests__/interaction-touch-fill.test.ts | 24 +- .../__tests__/interaction-touch-fixtures.ts | 43 -- .../interaction-touch-payload.test.ts | 28 +- .../interaction-touch-press-admission.test.ts | 24 +- .../__tests__/interaction-touch-press.test.ts | 24 +- .../interaction-touch-response.test.ts | 24 +- .../interaction-touch-runtime.test.ts | 29 +- .../__tests__/interaction-touch.test.ts | 24 +- .../handlers/__tests__/interaction.test.ts | 25 +- .../__tests__/network-runtime-harness.ts | 4 +- .../__tests__/record-runtime.fixtures.ts | 4 +- .../session-capabilities.fixtures.ts | 1 + .../__tests__/session-capabilities.test.ts | 4 +- .../__tests__/session-command-harness.ts | 1 + .../session-doctor-app-runtime.test.ts | 4 +- .../session-inventory-apps-runtime.test.ts | 4 +- .../session-inventory-harmonyos.test.ts | 4 +- .../handlers/__tests__/session-logs.test.ts | 4 +- .../__tests__/session-open-foreground.test.ts | 17 + ...sion-replay-dispatch-selector-miss.test.ts | 8 + ...sion-replay-divergence-publication.test.ts | 8 + .../session-replay-divergence.test.ts | 8 + .../session-replay-maestro-failure.test.ts | 8 + .../session-replay-repair-acceptance.test.ts | 8 + .../session-replay-repair-empty-tail.test.ts | 8 + .../session-replay-repair-loop.test.ts | 8 + ...ion-replay-repair-record-exclusion.test.ts | 8 + .../session-replay-repair-transaction.test.ts | 8 + .../session-replay-runtime-binding.test.ts | 9 + ...on-replay-runtime-failure-response.test.ts | 8 + .../session-replay-runtime-failure.test.ts | 11 +- .../session-replay-runtime-maestro.test.ts | 12 +- .../session-replay-runtime-plan.test.ts | 9 + .../__tests__/session-replay-runtime.test.ts | 9 + .../session-replay-selector-routes.test.ts | 9 + ...replay-target-verification-runtime.test.ts | 11 +- .../handlers/__tests__/session-state.test.ts | 6 +- .../session-test-reporter-values.test.ts | 18 +- .../__tests__/session-test-suite.test.ts | 18 +- .../__tests__/snapshot-handler.test.ts | 36 +- .../__tests__/snapshot-scoped-refs.test.ts | 7 +- .../system-surface-disclosure.test.ts | 5 + .../__tests__/wait-landmark-recording.test.ts | 5 + .../handlers/session-open-foreground.ts | 5 + src/daemon/handlers/session.ts | 30 +- src/daemon/handlers/snapshot-capture.ts | 176 +++----- .../handlers/snapshot-interactor-capture.ts | 21 + .../handlers/snapshot-runtime-route.test.ts | 84 ++++ src/daemon/handlers/snapshot.ts | 7 +- src/daemon/request-handler-chain.ts | 2 + src/daemon/snapshot-runtime-binding.ts | 132 ++++++ src/daemon/snapshot-runtime.ts | 380 +++++++++--------- src/platform-runtime-gateway.fixtures.ts | 4 +- src/platform-runtime-operation-host.ts | 2 + src/platform-runtime-snapshot-host.test.ts | 91 +++++ src/platform-runtime-snapshot-host.ts | 43 ++ .../ios-alert-settings.test.ts | 4 +- .../provider-device-runtime.fixtures.ts | 11 +- .../provider-scenarios/web-desktop.test.ts | 2 +- 121 files changed, 2069 insertions(+), 768 deletions(-) create mode 100644 packages/contracts/src/snapshot-runtime.test.ts create mode 100644 packages/contracts/src/snapshot-runtime.ts create mode 100644 packages/platform-apple/src/runtime-snapshot.ts create mode 100644 src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts create mode 100644 src/core/snapshot-desktop-surface.ts create mode 100644 src/daemon/__tests__/legacy-snapshot-capture-fixture.ts create mode 100644 src/daemon/__tests__/snapshot-runtime-fixture.ts create mode 100644 src/daemon/handlers/snapshot-interactor-capture.ts create mode 100644 src/daemon/handlers/snapshot-runtime-route.test.ts create mode 100644 src/daemon/snapshot-runtime-binding.ts create mode 100644 src/platform-runtime-snapshot-host.test.ts create mode 100644 src/platform-runtime-snapshot-host.ts diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index 6ec7a58ba0..9093a63e91 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -209,7 +209,7 @@ export type { InstalledAppInfo, ListAppsInput, } from '../app-inventory-runtime.ts'; -export { appsRuntimeUse, defineUse } from '../platform-runtime-operations.ts'; +export { appsRuntimeUse, captureSnapshotUse, defineUse } from '../platform-runtime-operations.ts'; export type { PlatformRuntimeHost, PlatformRuntimeModule, @@ -227,6 +227,19 @@ export { shutdownTargetUse, } from '../platform-runtime-operations.ts'; export type { DeviceReadinessRuntimePlan } from '../platform-runtime-operations.ts'; +export { + bindLocalSnapshotInteractor, + bindProviderSnapshotInteractor, +} from '../snapshot-runtime.ts'; +export type { + CaptureSnapshotInput, + LocalSnapshotInteractorResolver, + ProviderSnapshotInteractorResolver, + SnapshotRuntimeExecution, + SnapshotRuntimeHost, + SnapshotRuntimeOperations, + SnapshotResult, +} from '../snapshot-runtime.ts'; export type { AppStateRuntimeCommand, AppStateRuntimeCommandResult, diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 2cade5fcbe..d9b0164f3d 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -173,7 +173,7 @@ export type SnapshotResult = Omit & nodes?: RawSnapshotNode[]; backend: Extract< SnapshotBackend, - 'android' | 'harmonyos-arkui' | 'xctest' | 'linux-atspi' | 'web' + 'android' | 'harmonyos-arkui' | 'xctest' | 'linux-atspi' | 'macos-helper' | 'web' >; }; diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 5fb8c26e03..24d8ce718e 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -12,6 +12,7 @@ import type { AppStateRuntimeHost, AppStateRuntimeOperations } from './app-state import type { NetworkRuntimeHost, NetworkRuntimeOperations } from './network-runtime.ts'; import type { ScreenRecordingRuntimeHost } from './screen-recording-runtime-host.ts'; import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtime.ts'; +import type { SnapshotRuntimeHost, SnapshotRuntimeOperations } from './snapshot-runtime.ts'; import type { DeviceReadinessRuntimeHost, DeviceReadinessRuntimeOperations, @@ -41,6 +42,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & AppStateRuntimeOperations & NetworkRuntimeOperations & ScreenRecordingRuntimeOperations & + SnapshotRuntimeOperations & DeviceReadinessRuntimeOperations & DeviceShutdownRuntimeOperations & ApplicationLifecycleRuntimeOperations; @@ -58,6 +60,7 @@ export const bootTargetHeadlessUse = defineUse({ required: ['bootTargetHeadless'], }); export const appsRuntimeUse = defineUse({ required: ['ensureReady', 'listApps'] }); +export const captureSnapshotUse = defineUse({ required: ['captureSnapshot'] }); export const deviceBootRuntimeUses = Object.freeze([bootTargetUse, bootTargetHeadlessUse] as const); export type DeviceReadinessRuntimePlan = @@ -111,6 +114,7 @@ export type PlatformRuntimeHost = AppLogRuntimeHost & ): Promise; }>; screenRecording: ScreenRecordingRuntimeHost; + snapshot: SnapshotRuntimeHost; deviceReadiness: DeviceReadinessRuntimeHost; deviceShutdown: DeviceShutdownRuntimeHost; localInteractors: LocalApplicationInteractorHost; diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index cb3f2cffad..aef1b336a7 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -22,6 +22,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ appState?: RuntimeOperationUnavailability; network: RuntimeOperationUnavailability; screenRecording?: RuntimeOperationUnavailability; + snapshot?: RuntimeOperationUnavailability; readiness?: RuntimeOperationUnavailability; shutdown?: RuntimeOperationUnavailability; lifecycle: ApplicationLifecycleOperationFacts; @@ -34,6 +35,7 @@ type FrozenUnavailablePlatformRuntimeFacts = Readonly<{ appState: RuntimeOperationUnavailability; network: RuntimeOperationUnavailability; screenRecording: RuntimeOperationUnavailability; + snapshot: RuntimeOperationUnavailability; readiness: RuntimeOperationUnavailability; shutdown: RuntimeOperationUnavailability; lifecycle: ApplicationLifecycleOperationFacts; @@ -65,6 +67,7 @@ export function createUnavailablePlatformRuntimeFacts( appState, network, screenRecording, + snapshot, readiness, shutdown, lifecycle, @@ -90,6 +93,7 @@ export function createUnavailablePlatformRuntimeFacts( screenRecordingStart: screenRecording, screenRecordingReattach: screenRecording, screenRecordingCleanup: screenRecording, + captureSnapshot: snapshot, ensureReady: readiness, bootTarget: readiness, bootTargetHeadless: readiness, @@ -111,6 +115,7 @@ function freezeUnavailableFacts( screenRecording: Object.freeze({ ...(unavailable.screenRecording ?? unavailable.network), }), + snapshot: Object.freeze({ ...(unavailable.snapshot ?? unavailable.network) }), readiness: Object.freeze({ ...(unavailable.readiness ?? unavailable.network) }), shutdown: Object.freeze({ ...(unavailable.shutdown ?? unavailable.network) }), lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle), diff --git a/packages/contracts/src/snapshot-runtime.test.ts b/packages/contracts/src/snapshot-runtime.test.ts new file mode 100644 index 0000000000..6b3858995c --- /dev/null +++ b/packages/contracts/src/snapshot-runtime.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import { bindLocalSnapshotInteractor, bindProviderSnapshotInteractor } from './snapshot-runtime.ts'; + +const device: DeviceInfo = { + id: 'snapshot-device', + name: 'Snapshot Device', + platform: 'android', + kind: 'emulator', + target: 'mobile', +}; + +test('local snapshot binding injects its signal and resolves only its selected device', async () => { + const controller = new AbortController(); + let resolvedDevice: DeviceInfo | undefined; + let resolvedRunner: RunnerContext | undefined; + let capturedOptions: Parameters[0]; + const operations = bindLocalSnapshotInteractor({ + device, + signal: controller.signal, + resolveInteractor: async (selectedDevice, runner) => { + resolvedDevice = selectedDevice; + resolvedRunner = runner; + return { + snapshot: async (options: Parameters[0]) => { + capturedOptions = options; + return { backend: 'android', nodes: [] }; + }, + } as unknown as Interactor; + }, + }); + + assert.equal(resolvedDevice, undefined, 'binding must not eagerly construct the interactor'); + + await operations.captureSnapshot({ + options: { + appBundleId: 'com.example.app', + interactiveOnly: true, + preferredBackend: 'private-ax', + }, + execution: { requestId: 'request-1', verbose: true }, + }); + + assert.equal(resolvedDevice, device); + assert.equal(resolvedRunner?.requestId, 'request-1'); + assert.equal(resolvedRunner?.appBundleId, 'com.example.app'); + assert.equal(resolvedRunner?.signal, controller.signal); + assert.equal(capturedOptions?.interactiveOnly, true); + assert.equal(capturedOptions?.preferredBackend, 'private-ax'); + assert.equal(capturedOptions?.signal, controller.signal); +}); + +test('provider snapshot binding fails closed when its selected owner loses the interactor', async () => { + const operations = bindProviderSnapshotInteractor({ + device, + signal: new AbortController().signal, + resolveInteractor: () => undefined, + }); + + await assert.rejects( + operations.captureSnapshot({}), + (error: unknown) => + error instanceof Error && + error.message === 'Provider-owned snapshot operation has no bound provider interactor.', + ); +}); diff --git a/packages/contracts/src/snapshot-runtime.ts b/packages/contracts/src/snapshot-runtime.ts new file mode 100644 index 0000000000..ab1adb7ef6 --- /dev/null +++ b/packages/contracts/src/snapshot-runtime.ts @@ -0,0 +1,110 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { + Interactor, + RunnerContext, + SnapshotOptions, + SnapshotResult, +} from './interactor-types.ts'; + +export type { SnapshotResult } from './interactor-types.ts'; + +/** Runner metadata needed by the selected snapshot implementation, without request-owned state. */ +export type SnapshotRuntimeExecution = Readonly>; + +/** Neutral snapshot intent. The request binding supplies cancellation and exact-owner authority. */ +export type CaptureSnapshotInput = Readonly<{ + options?: Readonly>; + execution?: SnapshotRuntimeExecution; +}>; + +export type SnapshotRuntimeOperations = Readonly<{ + captureSnapshot(input: CaptureSnapshotInput): Promise; +}>; + +/** Existing desktop-surface mechanics injected without exposing daemon/session ownership. */ +export type SnapshotRuntimeHost = Readonly<{ + apple: Readonly<{ + captureSurface( + device: DeviceInfo, + options: CaptureSnapshotInput['options'], + signal: AbortSignal, + ): Promise; + }>; + linux: Readonly<{ + captureSurface( + device: DeviceInfo, + options: CaptureSnapshotInput['options'], + signal: AbortSignal, + ): Promise; + }>; +}>; + +export type LocalSnapshotInteractorResolver = ( + device: DeviceInfo, + runner: RunnerContext, +) => Promise; + +export type ProviderSnapshotInteractorResolver = (runner: RunnerContext) => Interactor | undefined; + +type SnapshotInteractorBindingParams = + | Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + ownership: 'local'; + resolveInteractor: LocalSnapshotInteractorResolver; + }> + | Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + ownership: 'provider'; + resolveInteractor: ProviderSnapshotInteractorResolver; + }>; + +/** Captures one selected owner's interactor authority for the lifetime of a request binding. */ +function bindSnapshotInteractor( + params: SnapshotInteractorBindingParams, +): SnapshotRuntimeOperations { + return Object.freeze({ + captureSnapshot: async (input) => { + const runner: RunnerContext = { + ...input.execution, + appBundleId: input.options?.appBundleId, + signal: params.signal, + }; + const interactor = + params.ownership === 'local' + ? await params.resolveInteractor(params.device, runner) + : params.resolveInteractor(runner); + if (!interactor) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Provider-owned snapshot operation has no bound provider interactor.', + { reason: 'provider-runtime-interactor-missing', deviceId: params.device.id }, + ); + } + return await interactor.snapshot({ ...input.options, signal: params.signal }); + }, + }); +} + +export function bindLocalSnapshotInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: LocalSnapshotInteractorResolver; + }>, +): SnapshotRuntimeOperations { + return bindSnapshotInteractor({ ...params, ownership: 'local' }); +} + +/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */ +export function bindProviderSnapshotInteractor( + params: Readonly<{ + device: DeviceInfo; + signal: AbortSignal; + resolveInteractor: ProviderSnapshotInteractorResolver; + }>, +): SnapshotRuntimeOperations { + return bindSnapshotInteractor({ ...params, ownership: 'provider' }); +} diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 095c645f67..06bfef17ff 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -93,6 +93,8 @@ test.each([ expect(facts.operations.ensureReady).toEqual({ available: true }); expect(facts.operations.bootTarget).toEqual({ available: true }); expect(facts.operations.bootTargetHeadless.available).toBe(runtimeDevice.kind === 'emulator'); + expect(facts.operations.captureSnapshot).toEqual({ available: true }); + expect(binding.operations.captureSnapshot).toBeTypeOf('function'); await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({ id: runtimeDevice.id, @@ -259,6 +261,10 @@ test.each([ expect(facts.operations.ensureReady).toEqual({ available: true }); expect(facts.operations.bootTarget).toEqual({ available: true }); expect(facts.operations.bootTargetHeadless.available).toBe(runtimeDevice.kind === 'emulator'); + expect(facts.operations.captureSnapshot.available).toBe(runtimeDevice.kind !== 'simulator'); + expect(binding.operations.captureSnapshot).toBeTypeOf( + runtimeDevice.kind === 'simulator' ? 'undefined' : 'function', + ); expectLifecycleFacts(binding, legacy); }, ); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 723ce0198b..5302effb85 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -9,6 +9,7 @@ import type { import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, + bindLocalSnapshotInteractor, localRuntimeOwner, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -65,6 +66,11 @@ const shutdownKindUnavailable = Object.freeze({ reason: 'unsupported-device-kind', hint: 'shutdown is supported only for Apple simulators and Android emulators.', } as const); +const snapshotKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'snapshot is supported only for Android emulators and devices.', +} as const); function androidLifecycleFacts(device: DeviceInfo) { const openTarget = androidOpenTargetFact(device); @@ -114,6 +120,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor screenRecordingStart: available, screenRecordingReattach: available, screenRecordingCleanup: available, + captureSnapshot: device.kind === 'simulator' ? snapshotKindUnavailable : available, ensureReady: available, bootTarget: available, bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable, @@ -160,6 +167,13 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor networkDump: async (input: NetworkDumpInput) => await dumpAndroidNetworkTraffic(host, request.device, input, request.scope.signal), ...recording, + ...(facts.operations.captureSnapshot.available + ? bindLocalSnapshotInteractor({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }) + : {}), ensureReady: async (input: EnsureReadyInput) => await ensureAndroidReady( host, diff --git a/packages/platform-apple/src/runtime-snapshot.ts b/packages/platform-apple/src/runtime-snapshot.ts new file mode 100644 index 0000000000..4dd9ba0111 --- /dev/null +++ b/packages/platform-apple/src/runtime-snapshot.ts @@ -0,0 +1,37 @@ +import type { + CaptureSnapshotInput, + PlatformRuntimeHost, + PlatformRuntimeOperations, +} from '@agent-device/contracts/platform'; +import { bindLocalSnapshotInteractor } from '@agent-device/contracts/platform'; +import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; + +/** Apple-owned selection between app snapshots and explicit macOS surface snapshots. */ +export function bindAppleSnapshotRuntime( + host: PlatformRuntimeHost, + request: Readonly<{ device: DeviceInfo; signal: AbortSignal }>, +): SnapshotRuntimeOperation { + const appSnapshot = bindLocalSnapshotInteractor({ + device: request.device, + signal: request.signal, + resolveInteractor: host.localInteractors.resolve, + }); + return Object.freeze({ + captureSnapshot: async (input: CaptureSnapshotInput) => { + if ( + isMacOs(request.device) && + input.options?.surface !== undefined && + input.options.surface !== 'app' + ) { + return await host.snapshot.apple.captureSurface( + request.device, + input.options, + request.signal, + ); + } + return await appSnapshot.captureSnapshot(input); + }, + }); +} + +type SnapshotRuntimeOperation = Pick; diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index cb0de908e8..743f116398 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -98,8 +98,59 @@ test.each([ device.appleOs !== 'macos' && device.appleOs !== 'watchos', ); expect(facts.operations.bootTargetHeadless.available).toBe(false); + expectAppleSnapshotAvailability(binding, device); }); +function expectAppleSnapshotAvailability( + binding: DeviceBinding, + device: DeviceInfo, +): void { + const available = device.appleOs !== 'watchos'; + expect(binding.facts.operations.captureSnapshot.available).toBe(available); + expect(binding.operations.captureSnapshot).toBeTypeOf(available ? 'function' : 'undefined'); +} + +test.each(['frontmost-app', 'desktop', 'menubar'] as const)( + 'routes the macOS %s surface through the exact Apple surface host', + async (surface) => { + const host = platformRuntimeHostFixture(); + const captureSurface = vi.fn(async () => ({ + backend: 'macos-helper' as const, + nodes: [], + truncated: false, + })); + const resolve = vi.fn(async () => ({}) as never); + const binding = await createApplePlatformRuntime({ + ...host, + localInteractors: { resolve }, + snapshot: { + ...host.snapshot, + apple: { captureSurface }, + }, + }).bind({ + device: leaves.macos, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + await expect( + binding.operations.captureSnapshot?.({ + options: { surface, appBundleId: 'com.example.app', depth: 3 }, + }), + ).resolves.toMatchObject({ backend: 'macos-helper' }); + expect(captureSurface).toHaveBeenCalledWith( + leaves.macos, + { surface, appBundleId: 'com.example.app', depth: 3 }, + expect.any(AbortSignal), + ); + expect(resolve).not.toHaveBeenCalled(); + }, +); + test('readiness and boot keep the Apple automation helper warm inside the platform runtime', async () => { const host = platformRuntimeHostFixture(); const keepHot = vi.fn(); @@ -348,4 +399,8 @@ function expectLegacyLifecycleFactCell( expect(facts.operations[name].available).toBe(legacy[facet]); } } + const snapshotAvailable = + facts.device.appleOs !== 'watchos' && + (facts.device.kind === 'simulator' || facts.device.kind === 'device'); + expect(facts.operations.captureSnapshot.available).toBe(snapshotAvailable); } diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 5d61d772a5..68821afc89 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -28,6 +28,7 @@ import { appleAppDeploymentFacts, createAppleAppDeploymentOperations, } from './deployment/runtime.ts'; +import { bindAppleSnapshotRuntime } from './runtime-snapshot.ts'; const owner = localRuntimeOwner('apple'); const available = Object.freeze({ available: true } as const); @@ -66,21 +67,15 @@ const runtimeHintsUnavailable = Object.freeze({ hint: 'Runtime hints are supported only for local iOS-family simulators and Android devices.', } as const); -const appleOpenTargetKindUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-device-kind', - hint: 'open is supported only for Apple simulators and devices.', -} as const); -const applePrepareKindUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-device-kind', - hint: 'prepare is supported only for Apple simulators and devices.', -} as const); -const appleCloseTargetKindUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-device-kind', - hint: 'close is supported only for Apple simulators and devices.', -} as const); +const appleOpenTargetKindUnavailable = unsupportedAppleDeviceKind( + 'open is supported only for Apple simulators and devices.', +); +const applePrepareKindUnavailable = unsupportedAppleDeviceKind( + 'prepare is supported only for Apple simulators and devices.', +); +const appleCloseTargetKindUnavailable = unsupportedAppleDeviceKind( + 'close is supported only for Apple simulators and devices.', +); const portReverseUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -91,6 +86,13 @@ const shutdownKindUnavailable = Object.freeze({ reason: 'unsupported-device-kind', hint: 'shutdown is supported only for Apple simulators and Android emulators.', } as const); +const snapshotKindUnavailable = unsupportedAppleDeviceKind( + 'snapshot is supported only for Apple simulators and devices.', +); + +function unsupportedAppleDeviceKind(hint: string) { + return Object.freeze({ available: false, reason: 'unsupported-device-kind', hint } as const); +} function shutdownFact(device: DeviceInfo) { if (!isIosFamily(device) || device.appleOs === 'watchos') return unavailable; @@ -192,6 +194,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR screenRecordingStart: recordingFacts, screenRecordingReattach: recordingFacts, screenRecordingCleanup: recordingFacts, + captureSnapshot: appleSnapshotFact(device), ensureReady: readiness, bootTarget: boot, bootTargetHeadless: headlessUnavailable, @@ -230,6 +233,12 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR signal: request.scope.signal, }) : {}), + ...(facts.operations.captureSnapshot.available + ? bindAppleSnapshotRuntime(host, { + device: request.device, + signal: request.scope.signal, + }) + : {}), ...(facts.operations.ensureReady.available ? { ensureReady: async () => @@ -276,3 +285,10 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR shutdown: async () => await appLogs.shutdown(), }); } + +function appleSnapshotFact(device: DeviceInfo) { + if (resolveDeviceAppleOs(device) === 'watchos') return snapshotKindUnavailable; + return device.kind === 'simulator' || device.kind === 'device' + ? available + : snapshotKindUnavailable; +} diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 66890aa5e3..aae85918e9 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -180,6 +180,12 @@ test.each([ expect(facts.operations.ensureReady).toMatchObject({ available: true }); expect(facts.operations.bootTarget).toMatchObject({ available: false }); expect(facts.operations.bootTargetHeadless).toMatchObject({ available: false }); + expect(facts.operations.captureSnapshot.available).toBe( + runtimeDevice.kind === 'emulator' || runtimeDevice.kind === 'device', + ); + expect(binding.operations.captureSnapshot).toBeTypeOf( + runtimeDevice.kind === 'simulator' ? 'undefined' : 'function', + ); expectLegacyLifecycleCell(binding, legacy); }, ); diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 449f3defeb..a0967e6246 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -7,6 +7,7 @@ import type { import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, + bindLocalSnapshotInteractor, localRuntimeOwner, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -55,6 +56,11 @@ const portReverseUnavailable = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Port reverse is supported only by an owning provider runtime.', } as const); +const snapshotKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'snapshot is supported only for HarmonyOS emulators and devices.', +} as const); function harmonyLifecycleFacts(device: DeviceInfo) { const openTarget = harmonyOpenTargetFact(device); @@ -100,6 +106,10 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor screenRecordingStart: recordingFacts, screenRecordingReattach: recordingFacts, screenRecordingCleanup: recordingFacts, + captureSnapshot: + device.kind === 'emulator' || device.kind === 'device' + ? available + : snapshotKindUnavailable, ensureReady: available, bootTarget: unavailable, bootTargetHeadless: unavailable, @@ -147,6 +157,13 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor signal: request.scope.signal, }) : {}), + ...(facts.operations.captureSnapshot.available + ? bindLocalSnapshotInteractor({ + device: request.device, + signal: request.scope.signal, + resolveInteractor: host.localInteractors.resolve, + }) + : {}), listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) => await host.appInventory.harmonyos.listApps( input.device, diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index 50da620c89..6569272fb8 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; import type { DeviceBinding, PlatformRuntimeHost, @@ -76,7 +76,12 @@ test.each([ >)( 'classifies the Linux $name lifecycle denominator against the legacy dispatch cell', async ({ device, legacy }) => { - const binding = await createLinuxPlatformRuntime(lifecycleHost()).bind({ + const captureSurface = vi.fn(async () => ({ + backend: 'linux-atspi' as const, + nodes: [], + truncated: false, + })); + const binding = await createLinuxPlatformRuntime(lifecycleHost(captureSurface)).bind({ device, intent: { kind: 'ordinary' }, scope: { @@ -94,13 +99,37 @@ test.each([ expect(binding.facts.operations.bootTargetHeadless).toMatchObject({ available: false }); expect(binding.facts.operations.appState).toMatchObject({ available: false }); expect(binding.facts.operations.listApps).toMatchObject({ available: false }); + expect(binding.facts.operations.captureSnapshot.available).toBe(device.kind === 'device'); + expect(binding.operations.captureSnapshot).toBeTypeOf( + device.kind === 'device' ? 'function' : 'undefined', + ); + if (device.kind === 'device') { + await binding.operations.captureSnapshot?.({ + options: { interactiveOnly: true, depth: 2, scope: 'Settings', surface: 'desktop' }, + }); + expect(captureSurface).toHaveBeenCalledWith( + device, + { interactiveOnly: true, depth: 2, scope: 'Settings', surface: 'desktop' }, + expect.any(AbortSignal), + ); + } expectLifecycleFacts(binding, legacy); }, ); -function lifecycleHost(): PlatformRuntimeHost { +function lifecycleHost( + captureSurface = async () => ({ + backend: 'linux-atspi' as const, + nodes: [], + truncated: false, + }), +): PlatformRuntimeHost { return { localInteractors: { resolve: async () => ({}) }, + snapshot: { + apple: { captureSurface: async () => ({ backend: 'macos-helper', nodes: [] }) }, + linux: { captureSurface }, + }, } as unknown as PlatformRuntimeHost; } diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 4963277c8b..d0c9d8e6b2 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -1,5 +1,6 @@ import type { DeviceBinding, + CaptureSnapshotInput, PlatformRuntimeHost, PlatformRuntimeOperations, PlatformRuntimeOwner, @@ -40,6 +41,10 @@ const closeTargetKindUnavailable = unavailableLinuxRuntimeFact( 'unsupported-device-kind', 'close is supported only for the Linux desktop device.', ); +const snapshotKindUnavailable = unavailableLinuxRuntimeFact( + 'unsupported-device-kind', + 'snapshot is supported only for the Linux desktop device.', +); export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { return Object.freeze({ owner: linuxOwner, @@ -65,9 +70,19 @@ export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformR device: request.device, owner: linuxOwner, facts, - operations: Object.freeze( - availableApplicationLifecycleOperations(lifecycle, facts.operations), - ), + operations: Object.freeze({ + ...availableApplicationLifecycleOperations(lifecycle, facts.operations), + ...(facts.operations.captureSnapshot.available + ? { + captureSnapshot: async (input: CaptureSnapshotInput) => + await host.snapshot.linux.captureSurface( + request.device, + input.options, + request.scope.signal, + ), + } + : {}), + }), [Symbol.asyncDispose]: async () => undefined, }) satisfies DeviceBinding; }, @@ -78,9 +93,10 @@ export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformR function linuxFacts(device: DeviceInfo): RuntimeFacts { const openTarget = device.kind === 'device' ? supported : openTargetKindUnavailable; const closeTarget = device.kind === 'device' ? supported : closeTargetKindUnavailable; - return createUnavailablePlatformRuntimeFacts(device, linuxOwner, { + const unavailable = createUnavailablePlatformRuntimeFacts(device, linuxOwner, { appLog: unsupportedPlatformLeaf, network: unsupportedPlatformLeaf, + snapshot: snapshotKindUnavailable, readiness: unsupportedPlatformLeaf, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: openTarget, @@ -94,6 +110,13 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts configureProviderPortReverse: providerPortReverseUnavailable, }), }); + return Object.freeze({ + device: unavailable.device, + operations: { + ...unavailable.operations, + captureSnapshot: device.kind === 'device' ? supported : snapshotKindUnavailable, + }, + }); } function unavailableLinuxRuntimeFact( diff --git a/packages/platform-vega/src/runtime.test.ts b/packages/platform-vega/src/runtime.test.ts index 097cb3fb49..6a207f3aa2 100644 --- a/packages/platform-vega/src/runtime.test.ts +++ b/packages/platform-vega/src/runtime.test.ts @@ -130,6 +130,11 @@ test.each([ expect(binding.facts.operations.bootTargetHeadless).toMatchObject({ available: false }); expect(binding.facts.operations.appState).toMatchObject({ available: false }); expect(binding.facts.operations.listApps).toMatchObject({ available: false }); + expect(binding.facts.operations.captureSnapshot).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + }); + expect(binding.operations.captureSnapshot).toBeUndefined(); expectLifecycleFacts(binding, legacy); }, ); diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index a03e71f06c..788369f273 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -82,6 +82,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts return createUnavailablePlatformRuntimeFacts(device, vegaOwner, { appLog: unsupportedPlatformLeaf, network: unsupportedPlatformLeaf, + snapshot: unsupportedPlatformLeaf, readiness: unsupportedPlatformLeaf, lifecycle: applicationLifecycleOperationFacts({ resolveOpenTarget: openTarget, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index f44d1c98b0..2c969528f2 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -47,6 +47,8 @@ test('preserves a narrow web provider dump including empty successful entries', expect(binding.facts.operations.bootTarget).toMatchObject({ available: false }); expect(binding.facts.operations.bootTargetHeadless).toMatchObject({ available: false }); expect(binding.facts.operations.listApps).toMatchObject({ available: false }); + expect(binding.facts.operations.captureSnapshot).toEqual({ available: true }); + expect(binding.operations.captureSnapshot).toBeTypeOf('function'); expectLifecycleFacts(binding); }); @@ -128,6 +130,8 @@ test.each([ runtimeHints: false, portReverse: false, }); + expect(binding.facts.operations.captureSnapshot.available).toBe(false); + expect(binding.operations.captureSnapshot).toBeUndefined(); }, ); diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index b3c846c53b..e5cc499692 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -8,6 +8,7 @@ import type { import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, + bindLocalSnapshotInteractor, localRuntimeOwner, sameRuntimeOwner, } from '@agent-device/contracts/platform'; @@ -153,6 +154,13 @@ function bindWebRuntime( } : {}), ...recording.operations, + ...(facts.operations.captureSnapshot.available + ? bindLocalSnapshotInteractor({ + device, + signal, + resolveInteractor: host.localInteractors.resolve, + }) + : {}), ...availableApplicationLifecycleOperations( bindWebApplicationLifecycle({ host: host.localInteractors, device, signal }), facts.operations, @@ -195,6 +203,7 @@ function webRuntimeFacts( screenRecordingStart: recordingAvailable ? available : recordingUnavailable, screenRecordingReattach: recordingAvailable ? available : recordingUnavailable, screenRecordingCleanup: recordingAvailable ? available : recordingUnavailable, + captureSnapshot: device.kind === 'device' ? available : openTargetKindUnavailable, ensureReady: readinessUnavailable, bootTarget: readinessUnavailable, bootTargetHeadless: readinessUnavailable, diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index 1d0ee15e11..28be0be348 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -274,6 +274,8 @@ test.each([ expect(binding.facts.operations.bootTarget).toEqual({ available: true }); expect(binding.facts.operations.bootTargetHeadless).toMatchObject({ available: false }); expect(binding.facts.operations.listApps).toEqual({ available: true }); + expect(binding.facts.operations.captureSnapshot).toEqual({ available: true }); + expect(binding.operations.captureSnapshot).toBeTypeOf('function'); await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), ).resolves.toEqual([]); @@ -324,6 +326,10 @@ test('fails closed for a stale Android identity before exposing facts or binding available: false, reason: 'owner-capability-missing', }); + expect(facts.operations.captureSnapshot).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + }); await expect( owner.bind({ device: staleDevice, intent: { kind: 'ordinary' }, scope }), ).rejects.toMatchObject({ diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index f906cf2d6f..e72c31257e 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -21,6 +21,7 @@ import { import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, + bindProviderSnapshotInteractor, createUnavailablePlatformRuntimeFacts, providerRuntimeOwner, sameRuntimeOwner, @@ -334,6 +335,11 @@ function bindLimrunAppLogs( }), runtimeFacts.operations, ), + ...bindProviderSnapshotInteractor({ + device, + signal, + resolveInteractor: (runner) => options.getInteractor(device, runner), + }), ...createLimrunAppDeploymentOperations( deploymentOptions(options), device, @@ -405,6 +411,7 @@ function facts( screenRecordingStart: recordingUnavailable, screenRecordingReattach: recordingUnavailable, screenRecordingCleanup: recordingUnavailable, + captureSnapshot: available, ensureReady: available, bootTarget: available, bootTargetHeadless: headlessUnavailable, @@ -438,6 +445,7 @@ function recoveryFacts( screenRecordingStart: liveSessionUnavailable, screenRecordingReattach: liveSessionUnavailable, screenRecordingCleanup: liveSessionUnavailable, + captureSnapshot: liveSessionUnavailable, ensureReady: liveSessionUnavailable, bootTarget: liveSessionUnavailable, bootTargetHeadless: liveSessionUnavailable, diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index b4316c356a..8b47e5b102 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -2,6 +2,7 @@ import { expect, test, vi } from 'vitest'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform'; import { providerRuntimeOwner } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { Interactor } from '@agent-device/contracts/interaction'; import { createWebDriverPlatformRuntimeOwner } from './platform-runtime.ts'; const device: DeviceInfo = { @@ -211,6 +212,78 @@ test.each([ }, ); +test('captures through only the active exact WebDriver interactor', async () => { + const snapshot = vi.fn(async () => ({ backend: 'android' as const, nodes: [] })); + const getInteractor = vi.fn(() => ({ snapshot }) as unknown as Interactor); + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + owner: providerRuntimeOwner('browserstack', 'android'), + ownsDevice: () => true, + snapshotAvailable: true, + getInteractor, + }); + const binding = await owner.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + expect(binding.facts.operations.captureSnapshot).toEqual({ available: true }); + await expect( + binding.operations.captureSnapshot?.({ options: { interactiveOnly: true } }), + ).resolves.toEqual({ backend: 'android', nodes: [] }); + expect(getInteractor).toHaveBeenCalledWith(device, expect.any(Object)); + expect(snapshot).toHaveBeenCalledWith({ + interactiveOnly: true, + signal: expect.any(AbortSignal), + }); +}); + +test.each([ + ['inactive session', { isSessionActive: () => false, snapshotAvailable: true }], + ['unsupported capability', { isSessionActive: () => true, snapshotAvailable: false }], +] as const)('fails closed for an %s snapshot owner cell', async (_name, state) => { + const getInteractor = vi.fn(() => ({}) as unknown as Interactor); + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + owner: providerRuntimeOwner('browserstack', 'android'), + ownsDevice: () => true, + ...state, + getInteractor, + }); + const facts = await owner.inspectFacts(device); + expect(facts.operations.captureSnapshot.available).toBe(false); + if (state.isSessionActive()) { + const binding = await owner.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + expect(binding.operations.captureSnapshot).toBeUndefined(); + } else { + await expect( + owner.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' }); + } + expect(getInteractor).not.toHaveBeenCalled(); +}); + function host(run: PlatformRuntimeHost['commands']['run']): PlatformRuntimeHost { // This suite reaches direct network and the explicitly replaced materializer only. return { diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index a191ad6093..95eddb81f9 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -1,6 +1,7 @@ import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, + bindProviderSnapshotInteractor, createUnavailablePlatformRuntimeFacts, sameRuntimeOwner, type AppDeploymentInput, @@ -61,6 +62,11 @@ const inactiveSession = Object.freeze({ reason: 'owner-capability-missing', hint: 'The WebDriver provider session is no longer active for this device.', } as const); +const snapshotUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'This WebDriver provider runtime does not expose snapshot capture for this device.', +} as const); const appStateUnavailable = Object.freeze({ available: false, @@ -121,6 +127,7 @@ export function createWebDriverPlatformRuntimeOwner( ownsDevice(device: DeviceInfo): boolean; isSessionActive?(device: DeviceInfo): boolean; deployment?: WebDriverPlatformDeploymentRuntime; + snapshotAvailable?: boolean; getInteractor?(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; }>, ): PlatformRuntimeOwner { @@ -160,6 +167,7 @@ function bindWebDriverPlatformRuntime( ownsDevice(device: DeviceInfo): boolean; isSessionActive?(device: DeviceInfo): boolean; deployment?: WebDriverPlatformDeploymentRuntime; + snapshotAvailable?: boolean; getInteractor?(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; }>, device: DeviceInfo, @@ -179,6 +187,13 @@ function bindWebDriverPlatformRuntime( }), facts.operations, ), + ...(facts.operations.captureSnapshot.available + ? bindProviderSnapshotInteractor({ + device, + signal, + resolveInteractor: (runner) => options.getInteractor?.(device, runner), + }) + : {}), networkDump: async (input) => { const recent = await options.host.appLogs.readRecent(input.sessionId, input.maxScanLines); const dump = readRecentNetworkTrafficFromText(recent.text, { @@ -225,6 +240,8 @@ function webDriverFacts( ownsDevice(device: DeviceInfo): boolean; isSessionActive?(device: DeviceInfo): boolean; deployment?: WebDriverPlatformDeploymentRuntime; + snapshotAvailable?: boolean; + getInteractor?(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; }>, device: DeviceInfo, ): RuntimeFacts { @@ -263,6 +280,7 @@ function webDriverFacts( screenRecordingStart: inactiveSession, screenRecordingReattach: inactiveSession, screenRecordingCleanup: inactiveSession, + captureSnapshot: inactiveSession, ensureReady: inactiveSession, bootTarget: inactiveSession, bootTargetHeadless: inactiveSession, @@ -307,6 +325,12 @@ function webDriverFacts( screenRecordingStart: recordingUnavailable, screenRecordingReattach: recordingUnavailable, screenRecordingCleanup: recordingUnavailable, + captureSnapshot: + options.snapshotAvailable !== false && + options.getInteractor !== undefined && + webDriverSnapshotDevice(device) + ? available + : snapshotUnavailable, ensureReady: available, bootTarget: available, bootTargetHeadless: headlessUnavailable, @@ -321,6 +345,14 @@ function webDriverFacts( }); } +function webDriverSnapshotDevice(device: DeviceInfo): boolean { + return ( + device.kind === 'device' && + device.target === 'mobile' && + (device.platform === 'android' || (device.platform === 'apple' && device.appleOs === 'ios')) + ); +} + function webDriverSessionActive( options: Readonly<{ ownsDevice(device: DeviceInfo): boolean; diff --git a/packages/provider-webdriver/src/runtime.ts b/packages/provider-webdriver/src/runtime.ts index 4786568733..0827199ad8 100644 --- a/packages/provider-webdriver/src/runtime.ts +++ b/packages/provider-webdriver/src/runtime.ts @@ -165,6 +165,7 @@ class CloudWebDriverRuntimeImplementation implements CloudWebDriverRuntime { ownsDevice: (device) => this.ownsDevice(device), isSessionActive: (device) => this.sessions.findSessionForDevice(device) !== undefined, deployment: this.deployment, + snapshotAvailable: this.capabilities.operations.snapshot.support !== 'unsupported', getInteractor: (device) => this.getInteractor(device), }); } diff --git a/scripts/layering/runtime-command-cutover-table.test.ts b/scripts/layering/runtime-command-cutover-table.test.ts index 9d960ae2d8..b3dfbfee7e 100644 --- a/scripts/layering/runtime-command-cutover-table.test.ts +++ b/scripts/layering/runtime-command-cutover-table.test.ts @@ -44,6 +44,53 @@ test('R20 boot operation singularity is scoped to the session-state handler', () ); }); +test('R32 snapshot rejects legacy admission and dispatcher projection', () => { + assert.deepEqual( + summariesFor('R32 snapshot-runtime-cutover', [ + [ + 'src/core/command-descriptor/registry.ts', + ` + const descriptors = [{ + name: 'snapshot', + capability: { apple: {} }, + dispatch: {}, + platformExecution: { kind: 'device-runtime', use: snapshotCaptureUse }, + }]; + `, + ], + [ + 'src/daemon/snapshot-runtime.ts', + ` + function dispatchSnapshotViaRuntime() { + requireCommandSupported('snapshot', device); + runtime.operations.captureSnapshot(input); + } + function handleSnapshotCommands() { + dispatchSnapshotViaRuntime(request); + } + handleSnapshotCommands(request); + `, + ], + ['src/core/dispatch.ts', 'function handleSnapshotCommand() { return legacy.snapshot(); }'], + [ + 'src/core/capabilities.ts', + ` + const HARMONYOS_SUPPORTED_COMMANDS = new Set(['snapshot']); + const WEB_SUPPORTED_COMMANDS = new Set(['snapshot']); + `, + ], + ]), + [ + 'src/core/command-descriptor/registry.ts: snapshot descriptor retains legacy capability admission', + 'src/daemon/snapshot-runtime.ts: legacy snapshot capability admission requireCommandSupported', + 'src/core/dispatch.ts: legacy snapshot capture route handleSnapshotCommand', + 'src/core/capabilities.ts: static platform command set retains snapshot admission', + 'src/core/capabilities.ts: static platform command set retains snapshot admission', + 'src/core/command-descriptor/registry.ts: snapshot command still projects into the retired legacy dispatcher', + ], + ); +}); + // Per-row acceptance for the five migrated commands. Every older-row case here was carried over // from the per-command policy tests these rows replaced; the mechanism itself is proven // in runtime-command-cutover-policy.test.ts. diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index d394f6ebff..d76b94173e 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -11,6 +11,7 @@ import { sourceExecutedUsingDeclarationViolations, } from './runtime-command-cutover-extensions.ts'; import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts'; +import { retiredDispatchProjectionViolations } from './runtime-command-cutover-descriptor.ts'; /** * One row per migrated command (ADR 0019 §8). A new command unit adds a row here; the @@ -23,7 +24,7 @@ import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechani * * A row id is a report heading, so it must be unique across every stack that adds rows here. * `cutoverTableDefects` rejects a duplicate; lifecycle starts at R28 after the accepted - * shutdown and install/deploy allocations. + * shutdown, install/deploy, and application-lifecycle allocations. Snapshot starts at R32. */ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ { @@ -438,8 +439,32 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ singularExecution: { routeProof: runtimeLifecycleRouteBindingViolations }, lifecycleProof: applicationLifecycleDurableResourceViolations, }, + { + rule: 'R32 snapshot-runtime-cutover', + command: 'snapshot', + subject: 'snapshot capture', + tier: 'request-scoped', + execution: 'device-runtime', + legacyRetirement: { + routeNames: ['handleSnapshotCommand'], + }, + runtimeTypeNames: ['SnapshotRuntimeOperations'], + operations: { names: ['captureSnapshot'] }, + singularExecution: { + routes: ['handleSnapshotCommands'], + operations: ['captureSnapshot'], + operationOwners: { captureSnapshot: ['dispatchSnapshotViaRuntime'] }, + }, + extensions: [snapshotRetiredDispatchProjectionProof], + }, ]; +function snapshotRetiredDispatchProjectionProof( + sources: ReadonlyMap, +): UnruledViolation[] { + return retiredDispatchProjectionViolations(sources, 'snapshot'); +} + /** The record mechanics policy predates the row model and reports `path: message`. */ function recordDaemonMechanicsProof(sources: ReadonlyMap): UnruledViolation[] { const production = [...sources].map(([path, source]) => ({ path, source })); diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index ebc72135d2..402a3e869e 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -21,8 +21,9 @@ const unavailableShutdownOperationFacts = Object.freeze({ shutdownTarget: unavailable, }); -export const unavailableDeploymentAndShutdownOperationFacts = Object.freeze({ +export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.freeze({ ...unavailableDeploymentOperationFacts, + captureSnapshot: unavailable, ...unavailableShutdownOperationFacts, }); diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index 2f338923cf..c1678e790f 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -194,7 +194,6 @@ const HARMONYOS_SUPPORTED_COMMANDS_REF = new Set([ 'screenshot', 'scroll', 'settings', - 'snapshot', 'swipe', 'type', 'wait', @@ -285,7 +284,6 @@ test('HarmonyOS static capabilities omit runtime-backed command admissions', () 'screenshot', 'scroll', 'settings', - 'snapshot', 'swipe', 'type', 'wait', diff --git a/src/core/__tests__/dispatch-snapshot-preferred-backend.test.ts b/src/core/__tests__/dispatch-snapshot-preferred-backend.test.ts index fa31da88ad..3b3b8c1b14 100644 --- a/src/core/__tests__/dispatch-snapshot-preferred-backend.test.ts +++ b/src/core/__tests__/dispatch-snapshot-preferred-backend.test.ts @@ -1,48 +1,41 @@ -import { beforeEach, test, vi } from 'vitest'; +import { test } from 'vitest'; import assert from 'node:assert/strict'; - -vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: vi.fn() }; -}); - -import { dispatchCommand } from '../dispatch.ts'; -import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; -import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; - -const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); - -beforeEach(() => { - vi.resetAllMocks(); - mockRunAppleRunnerCommand.mockResolvedValue({ - nodes: [{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }], - }); -}); - -// #1634 P2: proves the whole daemon-side wire — dispatch context -> -// handleSnapshotCommand -> interactor -> emitted RunnerCommand. Removing the -// forwarding at any of those hops turns this red; the daemon corroboration -// test (mocked dispatch) and the Swift plan-gate test cover the layers on -// either side of it. -test('dispatch snapshot forwards snapshotPreferredBackend to the runner command', async () => { - await dispatchCommand(IOS_SIMULATOR, 'snapshot', [], undefined, { - snapshotPreferredBackend: 'private-ax', - }); - - const call = mockRunAppleRunnerCommand.mock.calls.find( - ([, command]) => command.command === 'snapshot', +import { buildRuntimeCaptureInput } from '../../daemon/snapshot-runtime-binding.ts'; + +// #1634 P2: the daemon maps the CLI preference into the request-scoped runtime +// operation. The contract binding test owns the adjacent operation -> interactor +// hop, and the Swift plan gate owns the emitted runner command. +test('snapshot runtime input forwards snapshotPreferredBackend', () => { + const input = buildRuntimeCaptureInput( + { + req: { + command: 'snapshot', + positionals: [], + session: 'snapshot-preferred-backend', + token: 'test-token', + flags: { snapshotPreferredBackend: 'private-ax' }, + }, + logPath: '/tmp/snapshot-preferred-backend.log', + }, + undefined, + undefined, ); - assert.ok(call, 'expected a snapshot runner command'); - assert.equal(call[1].preferredBackend, 'private-ax'); + assert.equal(input.options?.preferredBackend, 'private-ax'); }); -test('dispatch snapshot without a pin emits no preferredBackend', async () => { - await dispatchCommand(IOS_SIMULATOR, 'snapshot', [], undefined, {}); - - const call = mockRunAppleRunnerCommand.mock.calls.find( - ([, command]) => command.command === 'snapshot', +test('snapshot runtime input without a pin leaves preferredBackend absent', () => { + const input = buildRuntimeCaptureInput( + { + req: { + command: 'snapshot', + positionals: [], + session: 'snapshot-default-backend', + token: 'test-token', + }, + logPath: '/tmp/snapshot-default-backend.log', + }, + undefined, + undefined, ); - assert.ok(call, 'expected a snapshot runner command'); - assert.equal(call[1].preferredBackend, undefined); + assert.equal(input.options?.preferredBackend, undefined); }); diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 64e3100180..fabac3960e 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -54,20 +54,11 @@ const HARMONYOS_SUPPORTED_COMMANDS = new Set([ 'screenshot', 'scroll', 'settings', - 'snapshot', 'swipe', 'type', 'wait', ]); -const WEB_QUERY_COMMANDS = [ - 'audio', - 'find', - 'get', - 'is', - 'screenshot', - 'snapshot', - 'wait', -] as const; +const WEB_QUERY_COMMANDS = ['audio', 'find', 'get', 'is', 'screenshot', 'wait'] as const; const WEB_INTERACTION_COMMANDS = [ 'click', 'fill', diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 740f3e2033..e819139da6 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -67,6 +67,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.reinstall, PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.shutdown, + PUBLIC_COMMANDS.snapshot, PUBLIC_COMMANDS.test, PUBLIC_COMMANDS.trace, ]); @@ -307,7 +308,11 @@ test('capability-checked command list is built from descriptor capabilities', () .sort(); const expectedNames = new Set(expected); - assert.ok(expectedNames.has(PUBLIC_COMMANDS.snapshot), 'snapshot remains capability-checked'); + assert.equal( + expectedNames.has(PUBLIC_COMMANDS.snapshot), + false, + 'snapshot admission comes from exact device-runtime facts', + ); assert.ok(expectedNames.has(PUBLIC_COMMANDS.gesture), 'gesture remains capability-checked'); assert.equal( expectedNames.has(PUBLIC_COMMANDS.capabilities), diff --git a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts new file mode 100644 index 0000000000..23bb6fd676 --- /dev/null +++ b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import { captureSnapshotUse } from '@agent-device/contracts/platform'; +import { expect, test } from 'vitest'; +import { commandDescriptors } from '../registry.ts'; + +const snapshotRuntimeSource = readFileSync( + new URL('../../../daemon/snapshot-runtime.ts', import.meta.url), + 'utf8', +); +const snapshotRuntimeBindingSource = readFileSync( + new URL('../../../daemon/snapshot-runtime-binding.ts', import.meta.url), + 'utf8', +); + +test('snapshot descriptor declares one required capture operation with no legacy projection', () => { + const snapshot = commandDescriptors.find(({ name }) => name === 'snapshot'); + + expect(snapshot).not.toHaveProperty('capability'); + expect(snapshot).not.toHaveProperty('dispatch'); + expect(snapshot?.platformExecution).toEqual({ + kind: 'device-runtime', + use: captureSnapshotUse, + }); + expect(captureSnapshotUse).toEqual({ + required: ['captureSnapshot'], + preferred: [], + }); +}); + +test('snapshot public route inspects facts and binds the declared use exactly once', () => { + const publicRoute = snapshotRuntimeSource.slice( + snapshotRuntimeSource.indexOf('export async function dispatchSnapshotViaRuntime'), + snapshotRuntimeSource.indexOf('function publishedSnapshotGeneration'), + ); + + expect(publicRoute.match(/inspectSnapshotCaptureAdmission\(params\)/g)).toHaveLength(1); + expect( + publicRoute.match(/bindSnapshotCaptureRuntime\(params\.bindDevice, device\)/g), + ).toHaveLength(1); + expect( + snapshotRuntimeBindingSource.match(/requireRuntimeFacts\(params\.inspectFacts\)\(device\)/g), + ).toHaveLength(1); + expect( + snapshotRuntimeBindingSource.match( + /requireRuntimeBinding\(bindDevice\)\(device, captureSnapshotUse\)/g, + ), + ).toHaveLength(1); + expect(publicRoute.match(/runtime\.operations\.captureSnapshot\(/g)).toHaveLength(1); + expect(publicRoute).not.toContain("requireCommandSupported('snapshot'"); +}); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index a1c0f2858a..df756ccd56 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -25,6 +25,7 @@ import { readySendPushNotificationUse, openApplicationRuntimePlanUses, closeApplicationRuntimePlanUses, + captureSnapshotUse, prepareAppleRunnerRuntimeUse, runtimeCommandRuntimePlanUses, screenRecordingRuntimePlanUses, @@ -911,13 +912,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ recordsSessionAction: true, recordingEffect: 'observes-app', daemon: { route: 'snapshot', refFrameEffect: 'preserve' }, - dispatch: {}, - capability: ALL_DEVICE_COMMAND_CAPABILITY, // First Apple snapshot on a device can sit behind runner startup; --timeout // widens the envelope, and a timeout must not tear down the daemon. timeoutPolicy: { ...PRESERVE_DAEMON_TIMEOUT_POLICY, budget: { source: 'flag' } }, batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', use: captureSnapshotUse }, }, { name: 'diff', diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 934014b919..24bd55c71e 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -187,7 +187,6 @@ const DISPATCH_HANDLERS: Record = { handleTvRemoteCommand(device, interactor, positionals, context), settings: ({ device, interactor, positionals, context }) => handleSettingsCommand(device, interactor, positionals, context), - snapshot: ({ interactor, context }) => handleSnapshotCommand(interactor, context), read: ({ device, positionals, context }) => handleReadCommand(device, positionals, context), }; @@ -635,26 +634,6 @@ function buildSettingsDiagnosticPayload( return { setting, state, appBundleId, platform: device.platform }; } -async function handleSnapshotCommand( - interactor: Interactor, - context: DispatchContext | undefined, -): Promise> { - const snapshotContext = context ?? {}; - return await interactor.snapshot({ - appBundleId: snapshotContext.appBundleId, - signal: snapshotContext.signal, - interactiveOnly: snapshotContext.snapshotInteractiveOnly, - preferredBackend: snapshotContext.snapshotPreferredBackend, - depth: snapshotContext.snapshotDepth, - scope: snapshotContext.snapshotScope, - raw: snapshotContext.snapshotRaw, - customActions: snapshotContext.snapshotCustomActions, - includeRects: snapshotContext.snapshotIncludeRects, - includeHiddenContentHints: snapshotContext.snapshotIncludeHiddenContentHints, - surface: snapshotContext.surface, - }); -} - function readResultMessage(result: Record): string | undefined { return typeof result.message === 'string' && result.message.length > 0 ? result.message diff --git a/src/core/snapshot-desktop-surface.ts b/src/core/snapshot-desktop-surface.ts new file mode 100644 index 0000000000..f8fdd1ec5b --- /dev/null +++ b/src/core/snapshot-desktop-surface.ts @@ -0,0 +1,82 @@ +import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot'; +import type { SnapshotOptions, SnapshotResult } from '@agent-device/contracts/interaction'; +import { findNodeByLabel } from './snapshot-node-lookup.ts'; + +const INTERACTIVE_ROLE_TOKENS = [ + 'button', + 'menu', + 'textfield', + 'searchfield', + 'checkbox', + 'radio', + 'switch', +] as const; + +/** Applies the legacy desktop-surface projection once for both runtime hosts and legacy capture. */ +export function shapeDesktopSurfaceSnapshot( + data: SnapshotResult, + options: Pick, +): SnapshotResult { + let nodes = data.nodes ?? []; + if (options.scope) nodes = scopeSnapshotNodes(nodes, options.scope); + if (options.interactiveOnly) nodes = filterInteractiveSnapshotNodes(nodes); + if (typeof options.depth === 'number') nodes = filterSnapshotNodesByDepth(nodes, options.depth); + return { ...data, nodes }; +} + +export function scopeSnapshotNodes(nodes: RawSnapshotNode[], scope: string): RawSnapshotNode[] { + const scopedNodes = attachRefs(nodes); + const match = findNodeByLabel(scopedNodes, scope); + if (!match) return []; + const startIndex = nodes.findIndex((node) => node.index === match.index); + if (startIndex === -1) return []; + const startDepth = nodes[startIndex]?.depth ?? 0; + const slice: RawSnapshotNode[] = []; + for (let index = startIndex; index < nodes.length; index += 1) { + const node = nodes[index]; + if (!node) continue; + const depth = node.depth ?? 0; + if (index > startIndex && depth <= startDepth) break; + slice.push(node); + } + return reindexSnapshotNodes(slice, startDepth); +} + +function filterInteractiveSnapshotNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] { + if (nodes.length === 0) return nodes; + const byIndex = new Map(nodes.map((node) => [node.index, node])); + const keepIndexes = new Set(); + for (const node of nodes) { + if (!isInteractiveSnapshotNode(node)) continue; + let current: RawSnapshotNode | undefined = node; + while (current) { + if (keepIndexes.has(current.index)) break; + keepIndexes.add(current.index); + current = + typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined; + } + } + if (keepIndexes.size === 0) return nodes; + return reindexSnapshotNodes(nodes.filter((node) => keepIndexes.has(node.index))); +} + +function filterSnapshotNodesByDepth(nodes: RawSnapshotNode[], maxDepth: number): RawSnapshotNode[] { + return reindexSnapshotNodes(nodes.filter((node) => (node.depth ?? 0) <= maxDepth)); +} + +function reindexSnapshotNodes(nodes: RawSnapshotNode[], depthOffset = 0): RawSnapshotNode[] { + const indexMap = new Map(); + for (const [index, node] of nodes.entries()) indexMap.set(node.index, index); + return nodes.map((node, index) => ({ + ...node, + index, + depth: Math.max(0, (node.depth ?? 0) - depthOffset), + parentIndex: typeof node.parentIndex === 'number' ? indexMap.get(node.parentIndex) : undefined, + })); +} + +function isInteractiveSnapshotNode(node: RawSnapshotNode): boolean { + if ([node.focused, node.hittable, node.rect].some(Boolean)) return true; + const role = `${node.type ?? ''} ${node.role ?? ''} ${node.subrole ?? ''}`.toLowerCase(); + return INTERACTIVE_ROLE_TOKENS.some((token) => role.includes(token)); +} diff --git a/src/daemon/__tests__/app-log-resource-recovery.test.ts b/src/daemon/__tests__/app-log-resource-recovery.test.ts index 07d9d0ca94..c49b399a9a 100644 --- a/src/daemon/__tests__/app-log-resource-recovery.test.ts +++ b/src/daemon/__tests__/app-log-resource-recovery.test.ts @@ -12,7 +12,7 @@ import { } from '@agent-device/contracts/platform'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { createTestAppLogLiveHandle } from '../../__tests__/test-utils/app-log-live-handle.ts'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { recoverAppLogResourcesAfterDaemonLock } from '../app-log-resource-recovery.ts'; import { appLogResourceStore } from '../app-log-resource-store.ts'; @@ -356,7 +356,7 @@ function makeGateway( bootTarget: { available: true as const }, bootTargetHeadless: unavailableRecording, listApps: unavailableRecording, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailableRecording, prepareApplicationOpen: unavailableRecording, diff --git a/src/daemon/__tests__/application-lifecycle-recovery.test.ts b/src/daemon/__tests__/application-lifecycle-recovery.test.ts index 7ab7597343..1d2bbece67 100644 --- a/src/daemon/__tests__/application-lifecycle-recovery.test.ts +++ b/src/daemon/__tests__/application-lifecycle-recovery.test.ts @@ -11,7 +11,7 @@ import { type RuntimeFacts, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; import type { SessionState } from '../types.ts'; import { finalizeDaemonSessionApplicationLifecycle } from '../application-lifecycle-recovery.ts'; @@ -86,7 +86,7 @@ function lifecycleFacts( bootTarget: unavailable, bootTargetHeadless: unavailable, listApps: unavailable, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/daemon/__tests__/device-claim-reconciliation.test.ts b/src/daemon/__tests__/device-claim-reconciliation.test.ts index ba90063293..a3da865446 100644 --- a/src/daemon/__tests__/device-claim-reconciliation.test.ts +++ b/src/daemon/__tests__/device-claim-reconciliation.test.ts @@ -9,7 +9,7 @@ import { type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import { createTestAppLogLiveHandle } from '../../__tests__/test-utils/app-log-live-handle.ts'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { createDeviceClaimReconciler } from '../device-claim-reconciliation.ts'; import type { DeviceClaim } from '../device-claims.ts'; @@ -68,7 +68,7 @@ test('reconciles the dead owner session resources through their exact runtime ow bootTarget: unavailable, bootTargetHeadless: unavailable, listApps: unavailable, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts b/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts new file mode 100644 index 0000000000..08c582620f --- /dev/null +++ b/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts @@ -0,0 +1,25 @@ +import type { SnapshotResult } from '@agent-device/contracts/interaction'; +import { dispatchCommand } from '../../core/dispatch.ts'; +import type { captureSnapshotWithInteractor } from '../handlers/snapshot-interactor-capture.ts'; + +type CaptureParams = Parameters[0]; + +/** Adapts legacy dispatch mocks while production uses the snapshot-specific interactor seam. */ +export async function captureSnapshotThroughLegacyDispatchFixture({ + device, + runnerContext, + options, +}: CaptureParams): Promise { + return (await dispatchCommand(device, 'snapshot', [], undefined, { + ...runnerContext, + ...options, + snapshotInteractiveOnly: options.interactiveOnly, + snapshotPreferredBackend: options.preferredBackend, + snapshotDepth: options.depth, + snapshotScope: options.scope, + snapshotRaw: options.raw, + snapshotCustomActions: options.customActions, + snapshotIncludeRects: options.includeRects, + snapshotIncludeHiddenContentHints: options.includeHiddenContentHints, + })) as SnapshotResult; +} diff --git a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts index 02361eedd2..4a0817b215 100644 --- a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts +++ b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts @@ -7,6 +7,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { resetAndroidSnapshotHelperInstallCache } from '../../platforms/android/snapshot-helper-install.ts'; import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../__tests__/test-utils/index.ts'; import type { AndroidAdbProvider } from '../../platforms/android/adb-executor.ts'; +import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; function makeAndroidSessionStore(name: string): SessionStore { const sessionStore = new SessionStore(`/tmp/${name}`); @@ -27,6 +28,13 @@ function makeAndroidSessionStore(name: string): SessionStore { } function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => AndroidAdbProvider) { + const deviceRuntimeGateway = createPlatformRuntimeGateway({ + sessionsDir: '/tmp/agent-device-snapshot-helper-runtime', + resolveSessionArtifacts: (sessionId) => ({ + outputPath: `/tmp/agent-device-snapshot-helper-runtime/${sessionId}/app.log`, + pidPath: `/tmp/agent-device-snapshot-helper-runtime/${sessionId}/app-log.pid`, + }), + }); return createRequestHandler({ logPath: '/tmp/daemon.log', token: 'token', @@ -34,6 +42,7 @@ function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => Andro leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), androidAdbProvider, + deviceRuntimeGateway, trackDownloadableArtifact: () => 'artifact-id', }); } diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index e58c1e79ff..af7ef04219 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -21,12 +21,37 @@ vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) import { dispatchCommand } from '../../core/dispatch.ts'; import { createRequestHandler } from './test-device-runtime-gateway.ts'; +import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { + captureSnapshotUse, + type DeviceRuntimeGateway, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform'; const mockDispatch = vi.mocked(dispatchCommand); +function snapshotDeviceRuntimeGateway(): DeviceRuntimeGateway { + const runtime = snapshotRuntimeFixture(); + return { + inspectFacts: runtime.inspectFacts, + bind: async ({ device }) => { + const [facts, binding] = await Promise.all([ + runtime.inspectFacts(device), + runtime.bindDevice(device, captureSnapshotUse), + ]); + return { + ...binding, + facts, + [Symbol.asyncDispose]: async () => {}, + }; + }, + shutdown: async () => {}, + }; +} + function makeIosSession(name: string): SessionState { return { name, @@ -146,6 +171,7 @@ test('fresh named sessions with matching explicit serial bind and serialize on t deviceInventoryGateways: createTestDeviceInventoryGatewaysFromProvider(async () => [ makeAndroidSession('inventory').device, ]), + deviceRuntimeGateway: snapshotDeviceRuntimeGateway(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -224,6 +250,7 @@ test('fresh named sessions with the same name serialize first binding before rej firstDevice, secondDevice, ]), + deviceRuntimeGateway: snapshotDeviceRuntimeGateway(), trackDownloadableArtifact: () => 'artifact-id', }); @@ -295,6 +322,7 @@ test('fresh named sessions with only lock platform default serialize on the sele deviceInventoryGateways: createTestDeviceInventoryGatewaysFromProvider(async () => [ makeAndroidSession('inventory').device, ]), + deviceRuntimeGateway: snapshotDeviceRuntimeGateway(), trackDownloadableArtifact: () => 'artifact-id', }); diff --git a/src/daemon/__tests__/request-router-record-runtime-lock.test.ts b/src/daemon/__tests__/request-router-record-runtime-lock.test.ts index 15118e148e..783dbd429e 100644 --- a/src/daemon/__tests__/request-router-record-runtime-lock.test.ts +++ b/src/daemon/__tests__/request-router-record-runtime-lock.test.ts @@ -11,7 +11,7 @@ import { import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { LeaseRegistry } from '../lease-registry.ts'; @@ -173,7 +173,7 @@ function makeRecordingGateway(firstStartBlocked: Promise) { bootTarget: unavailable, bootTargetHeadless: unavailable, listApps: unavailable, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index fdb3cf1998..75abe25315 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -10,6 +10,11 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; }); +vi.mock('../handlers/snapshot-interactor-capture.ts', async () => { + const fixture = await import('./legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; +}); + vi.mock('../../platforms/android/app-lifecycle.ts', async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/src/daemon/__tests__/request-runtime-binding-router.test.ts b/src/daemon/__tests__/request-runtime-binding-router.test.ts index 8e160720e6..27bfd3c554 100644 --- a/src/daemon/__tests__/request-runtime-binding-router.test.ts +++ b/src/daemon/__tests__/request-runtime-binding-router.test.ts @@ -10,7 +10,7 @@ import { import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { createTestAppLogLiveHandle } from '../../__tests__/test-utils/app-log-live-handle.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { createRequestHandler } from './test-device-runtime-gateway.ts'; @@ -153,7 +153,7 @@ function makeGateway(disposeError?: Error) { bootTarget: { available: true as const }, bootTargetHeadless: unavailableRecording, listApps: unavailableRecording, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailableRecording, prepareApplicationOpen: unavailableRecording, diff --git a/src/daemon/__tests__/request-runtime-binding.test.ts b/src/daemon/__tests__/request-runtime-binding.test.ts index fc06b3b028..e05d624a6c 100644 --- a/src/daemon/__tests__/request-runtime-binding.test.ts +++ b/src/daemon/__tests__/request-runtime-binding.test.ts @@ -11,7 +11,7 @@ import { type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { acquireDurableCaptureRecoveryAuthorityBeforeDeadline } from '../durable-capture-recovery-authority.ts'; import { createRequestRuntimeBindings } from '../request-runtime-binding.ts'; @@ -387,7 +387,7 @@ function makeGateway(options: { inspectAvailable?: boolean } = {}) { bootTarget: { available: true } as const, bootTargetHeadless: { available: true } as const, listApps: { available: true } as const, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailableLifecycle, prepareApplicationOpen: unavailableLifecycle, diff --git a/src/daemon/__tests__/screen-recording-resource-recovery.test.ts b/src/daemon/__tests__/screen-recording-resource-recovery.test.ts index c2691b46f0..4ac64cf750 100644 --- a/src/daemon/__tests__/screen-recording-resource-recovery.test.ts +++ b/src/daemon/__tests__/screen-recording-resource-recovery.test.ts @@ -7,7 +7,7 @@ import { type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { recoverScreenRecordingResourceAfterDaemonLock } from '../screen-recording-resource-recovery.ts'; import { screenRecordingResourceStore } from '../screen-recording-resource-store.ts'; @@ -56,7 +56,7 @@ test('recovery cleans a recording through its exact runtime owner', async () => bootTarget: unavailable, bootTargetHeadless: unavailable, listApps: unavailable, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/daemon/__tests__/selector-capture-runtime.test.ts b/src/daemon/__tests__/selector-capture-runtime.test.ts index 37834df207..2524e5171f 100644 --- a/src/daemon/__tests__/selector-capture-runtime.test.ts +++ b/src/daemon/__tests__/selector-capture-runtime.test.ts @@ -13,6 +13,11 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { }; }); +vi.mock('../handlers/snapshot-interactor-capture.ts', async () => { + const fixture = await import('./legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; +}); + const mockDispatch = vi.mocked(dispatchCommand); beforeEach(() => { diff --git a/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts b/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts index 28d9636f30..619925a027 100644 --- a/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts +++ b/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts @@ -15,6 +15,31 @@ import { createRequestHandler } from './test-device-runtime-gateway.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import type { SessionState } from '../types.ts'; +import { + captureSnapshotUse, + type DeviceRuntimeGateway, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform'; +import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; + +function snapshotDeviceRuntimeGateway(): DeviceRuntimeGateway { + const runtime = snapshotRuntimeFixture(); + return { + inspectFacts: runtime.inspectFacts, + bind: async ({ device }) => { + const [facts, binding] = await Promise.all([ + runtime.inspectFacts(device), + runtime.bindDevice(device, captureSnapshotUse), + ]); + return { + ...binding, + facts, + [Symbol.asyncDispose]: async () => {}, + }; + }, + shutdown: async () => {}, + }; +} function handlerForDevice(device: DeviceInfo) { const sessionStore = makeSessionStore('agent-device-custom-actions-guard-'); @@ -29,6 +54,7 @@ function handlerForDevice(device: DeviceInfo) { sessionStore, leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: snapshotDeviceRuntimeGateway(), trackDownloadableArtifact: () => 'artifact-id', }); } diff --git a/src/daemon/__tests__/snapshot-quality-latch.test.ts b/src/daemon/__tests__/snapshot-quality-latch.test.ts index f10d7f0a97..995fc7af71 100644 --- a/src/daemon/__tests__/snapshot-quality-latch.test.ts +++ b/src/daemon/__tests__/snapshot-quality-latch.test.ts @@ -14,6 +14,7 @@ import { import { dispatchSnapshotDiffViaRuntime, dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../types.ts'; +import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; const dispatchCommandMock = vi.hoisted(() => vi.fn()); @@ -25,6 +26,11 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { }; }); +vi.mock('../handlers/snapshot-interactor-capture.ts', async () => { + const fixture = await import('./legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; +}); + const FULL_WARNING = recoveredSnapshotQualityWarning('private-ax'); function deferredVerdict(): SnapshotQualityVerdict { @@ -183,6 +189,7 @@ async function dispatchPublicSnapshot(input: ReturnType) { sessionName: input.sessionName, logPath: input.logPath, sessionStore: input.sessionStore, + ...snapshotRuntimeFixture(), }); } @@ -215,6 +222,7 @@ test('an internally armed penalty warns once on the first public deferred snapsh sessionName: input.sessionName, logPath: input.logPath, sessionStore: input.sessionStore, + ...snapshotRuntimeFixture(), }); expect(responseWarnings(internal)).not.toContain(FULL_WARNING); expect(storedLatch(input)).toBeUndefined(); diff --git a/src/daemon/__tests__/snapshot-runtime-cancellation.test.ts b/src/daemon/__tests__/snapshot-runtime-cancellation.test.ts index 585ae9f59c..90432b992d 100644 --- a/src/daemon/__tests__/snapshot-runtime-cancellation.test.ts +++ b/src/daemon/__tests__/snapshot-runtime-cancellation.test.ts @@ -7,6 +7,7 @@ import { registerRequestAbort, } from '../../request/cancel.ts'; import { dispatchSnapshotDiffViaRuntime, dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; +import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; const dispatchCommandMock = vi.hoisted(() => vi.fn()); @@ -18,6 +19,23 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => { }; }); +vi.mock('../handlers/snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn( + async ({ device, runnerContext, options }) => + await dispatchCommandMock(device, 'snapshot', [], undefined, { + ...runnerContext, + ...options, + snapshotInteractiveOnly: options.interactiveOnly, + snapshotPreferredBackend: options.preferredBackend, + snapshotDepth: options.depth, + snapshotScope: options.scope, + snapshotRaw: options.raw, + snapshotCustomActions: options.customActions, + snapshotIncludeHiddenContentHints: options.includeHiddenContentHints, + }), + ), +})); + type Deferred = { promise: Promise; resolve: () => void; @@ -71,7 +89,7 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { }; const running = command === 'snapshot' - ? dispatchSnapshotViaRuntime(input) + ? dispatchSnapshotViaRuntime({ ...input, ...snapshotRuntimeFixture(requestId) }) : dispatchSnapshotDiffViaRuntime(input); await dispatchEntered.promise; markRequestCanceled(requestId); diff --git a/src/daemon/__tests__/snapshot-runtime-fixture.ts b/src/daemon/__tests__/snapshot-runtime-fixture.ts new file mode 100644 index 0000000000..74d1c2ad13 --- /dev/null +++ b/src/daemon/__tests__/snapshot-runtime-fixture.ts @@ -0,0 +1,82 @@ +import { + localRuntimeOwner, + narrowDeviceBinding, + providerRuntimeOwner, + type CaptureSnapshotInput, + type PlatformRuntimeOperations, + type RuntimeFacts, + type SnapshotResult, +} from '@agent-device/contracts/platform'; +import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; +import { dispatchCommand, type DispatchContext } from '../../core/dispatch.ts'; +import { getRequestSignal } from '../../request/cancel.ts'; +import { isActiveProviderDevice } from '../../provider-device-runtime.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import { unavailableDeviceRuntimeGateway } from './test-device-runtime-gateway.ts'; + +/** Request-scoped snapshot seam for handler tests that mock the legacy leaf dispatch. */ +export function snapshotRuntimeFixture(requestId?: string): Readonly<{ + inspectFacts: InspectDeviceRuntimeFacts; + bindDevice: BindDeviceRuntime; +}> { + const requestSignal = getRequestSignal(requestId) ?? new AbortController().signal; + + const inspectFacts: InspectDeviceRuntimeFacts = async (device) => await snapshotFacts(device); + const bindDevice: BindDeviceRuntime = async (device, use) => { + const facts = await snapshotFacts(device); + const providerOwned = facts.device.providerMode === 'provider-runtime'; + return narrowDeviceBinding( + { + device, + owner: providerOwned + ? providerRuntimeOwner('test', 'snapshot-runtime-fixture') + : localRuntimeOwner(device.platform), + facts, + operations: { + captureSnapshot: async (input) => + await dispatchFixtureSnapshot(device, input, requestSignal), + }, + [Symbol.asyncDispose]: async () => {}, + }, + use, + ); + }; + + return { inspectFacts, bindDevice }; +} + +async function snapshotFacts(device: DeviceInfo): Promise> { + const base = await unavailableDeviceRuntimeGateway.inspectFacts(device); + return { + device: { + ...deviceShape(device), + providerMode: isActiveProviderDevice(device) ? 'provider-runtime' : 'local', + }, + operations: { + ...base.operations, + captureSnapshot: { available: true }, + }, + }; +} + +async function dispatchFixtureSnapshot( + device: DeviceInfo, + input: CaptureSnapshotInput, + signal: AbortSignal, +): Promise { + const options = input.options ?? {}; + const context: DispatchContext = { + ...input.execution, + signal, + appBundleId: options.appBundleId, + snapshotInteractiveOnly: options.interactiveOnly, + snapshotPreferredBackend: options.preferredBackend, + snapshotDepth: options.depth, + snapshotScope: options.scope, + snapshotRaw: options.raw, + snapshotCustomActions: options.customActions, + snapshotIncludeHiddenContentHints: options.includeHiddenContentHints, + surface: options.surface, + }; + return (await dispatchCommand(device, 'snapshot', [], undefined, context)) as SnapshotResult; +} diff --git a/src/daemon/__tests__/sparse-fallback-screenshot.test.ts b/src/daemon/__tests__/sparse-fallback-screenshot.test.ts index 12e871b10b..244c95a434 100644 --- a/src/daemon/__tests__/sparse-fallback-screenshot.test.ts +++ b/src/daemon/__tests__/sparse-fallback-screenshot.test.ts @@ -4,6 +4,7 @@ import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; import { makeIosSession, mkdtempForTest } from '../../__tests__/test-utils/index.ts'; import { SessionStore } from '../session-store.ts'; import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; +import { snapshotRuntimeFixture } from './snapshot-runtime-fixture.ts'; const dispatchCommandMock = vi.hoisted(() => vi.fn()); @@ -49,6 +50,7 @@ function seed( } async function dispatch(input: Awaited>, internalObservation = false) { + const runtime = snapshotRuntimeFixture(); const response = await dispatchSnapshotViaRuntime({ req: { command: 'snapshot', @@ -60,6 +62,7 @@ async function dispatch(input: Awaited>, internalObs sessionName: input.sessionName, logPath: input.logPath, sessionStore: input.sessionStore, + ...runtime, }); if (!response.ok) throw new Error('expected ok response'); return response.data ?? {}; diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index 24053eed09..a815995256 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -14,7 +14,7 @@ import { import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { applicationLifecycleRuntimeFixture } from './application-lifecycle-runtime-fixture.ts'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../__tests__/test-utils/runtime-operation-facts.ts'; import { withClientReplayScriptSources } from '../../__tests__/test-utils/replay-script-source.ts'; import type { DaemonInvokeFn } from '../types.ts'; @@ -55,7 +55,7 @@ async function lifecycleBindingForTest(device: DeviceInfo) { : { iosPhysicalDeviceBackend: device.iosPhysicalDeviceBackend }), }, operations: { - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, appLogInspect: unavailable, appLogDoctor: unavailable, appLogStart: unavailable, @@ -107,7 +107,7 @@ export const unavailableDeviceRuntimeGateway: DeviceRuntimeGateway { }; }); +vi.mock('../snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; +}); + import { dispatchCommand } from '../../../core/dispatch.ts'; const mockDispatch = vi.mocked(dispatchCommand); diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 8e7c8e6e56..687977ae25 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -350,6 +350,7 @@ function sourceRuntimeFacts( appLogCleanup: unavailable, appState: unavailable, listApps: unavailable, + captureSnapshot: unavailable, deployApp: unavailable, materializeAppSource: materializationAvailable ? { available: true } : unavailable, deployMaterializedApp: materializationAvailable ? { available: true } : unavailable, diff --git a/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts b/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts index 744d755fe0..340aea2d2f 100644 --- a/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts +++ b/src/daemon/handlers/__tests__/interaction-ios-tap-outcome.test.ts @@ -22,6 +22,7 @@ import { snapshot, snapshotPayload, } from './interaction-ios-tap-outcome-fixtures.ts'; +import { snapshotRuntimeFixture } from '../../__tests__/snapshot-runtime-fixture.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -31,6 +32,11 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }; }); +vi.mock('../snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; +}); + const mockDispatch = vi.mocked(dispatchCommand); const contextFromFlags = (flags: CommandFlags | undefined) => ({ @@ -567,6 +573,7 @@ test('a corroborated runtime coordinate tap does not schedule a no-change retry' sessionName, logPath: '/tmp/daemon.log', sessionStore, + ...snapshotRuntimeFixture(), }); expect(snapshotResponse?.ok).toBe(true); diff --git a/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts b/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts index aca0aed2fe..c42901dc74 100644 --- a/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts +++ b/src/daemon/handlers/__tests__/interaction-target-evidence.test.ts @@ -33,6 +33,11 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }; }); +vi.mock('../snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; +}); + vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); diff --git a/src/daemon/handlers/__tests__/interaction-touch-android-freshness.test.ts b/src/daemon/handlers/__tests__/interaction-touch-android-freshness.test.ts index f847e7c5b1..3cf72eee84 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-android-freshness.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-android-freshness.test.ts @@ -2,11 +2,7 @@ import { test, expect, vi, beforeEach } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { handleInteractionCommands } from '../interaction.ts'; -import { - contextFromFlags, - createEmulateCaptureSnapshotForSession, - makeAndroidSession, -} from './interaction-touch-fixtures.ts'; +import { contextFromFlags, makeAndroidSession } from './interaction-touch-fixtures.ts'; // The Android ref-refresh capture a @ref mutation takes before dispatch: when // it runs, what it may not retarget, and the comparison-safe baseline it hands @@ -37,17 +33,9 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -61,13 +49,14 @@ import { getAndroidBlockingDialogFocus, } from '../../../platforms/android/app-lifecycle.ts'; import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts'; -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatch.mockReset(); @@ -79,9 +68,7 @@ beforeEach(() => { mockGetAndroidScreenSize.mockReset(); mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 }); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); + mockCaptureSnapshotForSession.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); }); @@ -146,9 +133,9 @@ test('press @ref refreshes Android snapshot when freshness tracking is active', }); expect(response?.ok).toBe(true); - expect(mockCaptureSnapshotForSession.mock.calls[0]?.[4]).toEqual({ + expect(mockCaptureSnapshotForSession).toHaveBeenCalledTimes(1); + expect(mockCaptureSnapshotForSession.mock.calls[0]?.[0].options).toMatchObject({ interactiveOnly: true, - androidFreshnessMode: 'ref-refresh', }); expect(mockDispatch.mock.calls.map((call) => call[1])).toEqual(['snapshot', 'press']); expect(mockDispatch.mock.calls[1]?.[2]).toEqual(['140', '220']); diff --git a/src/daemon/handlers/__tests__/interaction-touch-android-readiness.test.ts b/src/daemon/handlers/__tests__/interaction-touch-android-readiness.test.ts index 5e10a338a5..11fd801e99 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-android-readiness.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-android-readiness.test.ts @@ -3,11 +3,7 @@ import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { expireRefFrame } from '../../ref-frame.ts'; import { handleInteractionCommands } from '../interaction.ts'; -import { - contextFromFlags, - createEmulateCaptureSnapshotForSession, - makeAndroidSession, -} from './interaction-touch-fixtures.ts'; +import { contextFromFlags, makeAndroidSession } from './interaction-touch-fixtures.ts'; // The Android device state a dispatch has to survive: an escape to launcher or // Settings fails the press, a permission prompt warns instead, and @@ -48,17 +44,9 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -80,13 +68,14 @@ import { getAndroidBlockingDialogFocus, } from '../../../platforms/android/app-lifecycle.ts'; import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts'; -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatch.mockReset(); @@ -98,9 +87,7 @@ beforeEach(() => { mockGetAndroidScreenSize.mockReset(); mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 }); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); + mockCaptureSnapshotForSession.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); androidDialogReadiness.spy.mockReset(); diff --git a/src/daemon/handlers/__tests__/interaction-touch-direct-ios-eligibility.test.ts b/src/daemon/handlers/__tests__/interaction-touch-direct-ios-eligibility.test.ts index e25108767a..3af2070f12 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-direct-ios-eligibility.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-direct-ios-eligibility.test.ts @@ -3,10 +3,7 @@ import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { handleInteractionCommands } from '../interaction.ts'; -import { - contextFromFlags, - createEmulateCaptureSnapshotForSession, -} from './interaction-touch-fixtures.ts'; +import { contextFromFlags } from './interaction-touch-fixtures.ts'; // Whether a click may take the direct iOS selector fast path: which selector // shapes qualify, what Maestro decoration rides along, and the exclusions that @@ -37,17 +34,9 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -61,13 +50,14 @@ import { getAndroidBlockingDialogFocus, } from '../../../platforms/android/app-lifecycle.ts'; import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts'; -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatch.mockReset(); @@ -79,9 +69,7 @@ beforeEach(() => { mockGetAndroidScreenSize.mockReset(); mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 }); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); + mockCaptureSnapshotForSession.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); }); @@ -151,7 +139,6 @@ test('fill simple iOS id selector resolves runtime text input evidence before co hittable: false, }, ]), - createdAt: Date.now(), backend: 'xctest', }); mockDispatch.mockResolvedValueOnce({ diff --git a/src/daemon/handlers/__tests__/interaction-touch-direct-ios.test.ts b/src/daemon/handlers/__tests__/interaction-touch-direct-ios.test.ts index 5e4da284d5..17a9ce157c 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-direct-ios.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-direct-ios.test.ts @@ -6,7 +6,6 @@ import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts import { handleInteractionCommands } from '../interaction.ts'; import { contextFromFlags, - createEmulateCaptureSnapshotForSession, makeStaleRefSession, makeTwoButtonNodes, runInteraction, @@ -41,17 +40,9 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -65,13 +56,14 @@ import { getAndroidBlockingDialogFocus, } from '../../../platforms/android/app-lifecycle.ts'; import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts'; -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatch.mockReset(); @@ -83,9 +75,7 @@ beforeEach(() => { mockGetAndroidScreenSize.mockReset(); mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 }); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); + mockCaptureSnapshotForSession.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); }); diff --git a/src/daemon/handlers/__tests__/interaction-touch-fill.test.ts b/src/daemon/handlers/__tests__/interaction-touch-fill.test.ts index 53e3723528..30109198ce 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-fill.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-fill.test.ts @@ -6,7 +6,6 @@ import { STALE_SNAPSHOT_REFS_WARNING } from '../../session-snapshot.ts'; import { handleInteractionCommands } from '../interaction.ts'; import { contextFromFlags, - createEmulateCaptureSnapshotForSession, makeSession, makeStaleRefSession, runInteraction, @@ -40,17 +39,9 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -64,13 +55,14 @@ import { getAndroidBlockingDialogFocus, } from '../../../platforms/android/app-lifecycle.ts'; import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts'; -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatch.mockReset(); @@ -82,9 +74,7 @@ beforeEach(() => { mockGetAndroidScreenSize.mockReset(); mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 }); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); + mockCaptureSnapshotForSession.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); }); diff --git a/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts b/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts index 958921bbe4..8f29cd9f8d 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-fixtures.ts @@ -6,9 +6,7 @@ import { makeMacOsSession as makeBaseMacOsSession, } from '../../../__tests__/test-utils/session-factories.ts'; import { makeTestScreenRecordingResource } from '../../../__tests__/test-utils/screen-recording-live-handle.ts'; -import type { dispatchCommand } from '../../../core/dispatch.ts'; import { activateCompleteRefFrame } from '../../ref-frame.ts'; -import { setSessionSnapshot } from '../../session-snapshot.ts'; import type { SessionStore } from '../../session-store.ts'; import type { SessionState } from '../../types.ts'; import { handleInteractionCommands } from '../interaction.ts'; @@ -72,47 +70,6 @@ export const contextFromFlags = (flags: CommandFlags | undefined) => ({ clickButton: flags?.clickButton, }); -/** - * Mirrors the real `captureSnapshotForSession` over the calling file's mocked - * dispatch: session snapshot writes go through `setSessionSnapshot` so the - * generation advances (#1076 versioned refs). - */ -export function createEmulateCaptureSnapshotForSession( - mockDispatch: (...args: Parameters) => Promise, -) { - return async function emulateCaptureSnapshotForSession( - session: SessionState, - flags: CommandFlags | undefined, - sessionStore: SessionStore, - contextFromCallerFlags: ( - flags: CommandFlags | undefined, - appBundleId?: string, - traceLogPath?: string, - ) => Record, - options: { - interactiveOnly: boolean; - androidFreshnessMode?: 'ref-refresh'; - includeRects?: boolean; - }, - ) { - const effectiveFlags = { - ...(flags ?? {}), - snapshotInteractiveOnly: options.interactiveOnly, - }; - const snapshotData = (await mockDispatch( - session.device, - 'snapshot', - [], - effectiveFlags.out, - contextFromCallerFlags(effectiveFlags, session.appBundleId, session.trace?.outPath), - )) as { nodes?: never[]; truncated?: boolean; backend?: SnapshotBackend }; - const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); - setSessionSnapshot(session, snapshot); - sessionStore.set(session.name, session); - return snapshot; - }; -} - // --- Stale @ref warnings (#1076) --- export function makeTwoButtonNodes() { diff --git a/src/daemon/handlers/__tests__/interaction-touch-payload.test.ts b/src/daemon/handlers/__tests__/interaction-touch-payload.test.ts index 4d3b524d6e..d23ddc3dec 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-payload.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-payload.test.ts @@ -4,7 +4,6 @@ import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts import { handleInteractionCommands } from '../interaction.ts'; import { contextFromFlags, - createEmulateCaptureSnapshotForSession, installTestScreenRecording, makeAndroidSession, makeSession, @@ -39,17 +38,9 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -63,13 +54,14 @@ import { getAndroidBlockingDialogFocus, } from '../../../platforms/android/app-lifecycle.ts'; import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts'; -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatch.mockReset(); @@ -81,9 +73,7 @@ beforeEach(() => { mockGetAndroidScreenSize.mockReset(); mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 }); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); + mockCaptureSnapshotForSession.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); }); @@ -179,7 +169,6 @@ test('press coordinates on iOS recording captures a full snapshot for the touch hittable: true, }, ]), - createdAt: Date.now(), backend: 'xctest', }); @@ -197,7 +186,8 @@ test('press coordinates on iOS recording captures a full snapshot for the touch }); expect(response?.ok).toBe(true); - expect(mockCaptureSnapshotForSession.mock.calls[0]?.[4]).toEqual({ + expect(mockCaptureSnapshotForSession).toHaveBeenCalledTimes(1); + expect(mockCaptureSnapshotForSession.mock.calls[0]?.[0].options).toMatchObject({ interactiveOnly: true, }); const event = sessionStore.get(sessionName)?.screenRecording?.handle.inspect().gestureEvents[0]; diff --git a/src/daemon/handlers/__tests__/interaction-touch-press-admission.test.ts b/src/daemon/handlers/__tests__/interaction-touch-press-admission.test.ts index c655698663..869987e0df 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-press-admission.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-press-admission.test.ts @@ -6,7 +6,6 @@ import { setSessionSnapshot, STALE_SNAPSHOT_REFS_WARNING } from '../../session-s import { handleInteractionCommands } from '../interaction.ts'; import { contextFromFlags, - createEmulateCaptureSnapshotForSession, findResolvedTarget, makeFindPreresolvedTree, makeSession, @@ -46,17 +45,9 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -70,13 +61,14 @@ import { getAndroidBlockingDialogFocus, } from '../../../platforms/android/app-lifecycle.ts'; import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts'; -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatch.mockReset(); @@ -88,9 +80,7 @@ beforeEach(() => { mockGetAndroidScreenSize.mockReset(); mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 }); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); + mockCaptureSnapshotForSession.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); }); diff --git a/src/daemon/handlers/__tests__/interaction-touch-press.test.ts b/src/daemon/handlers/__tests__/interaction-touch-press.test.ts index 91d25891d9..d677d6c376 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-press.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-press.test.ts @@ -4,7 +4,6 @@ import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts import { handleInteractionCommands } from '../interaction.ts'; import { contextFromFlags, - createEmulateCaptureSnapshotForSession, findResolvedTarget, makeFindPreresolvedTree, makeMacOsMenubarSession, @@ -43,17 +42,9 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -67,13 +58,14 @@ import { getAndroidBlockingDialogFocus, } from '../../../platforms/android/app-lifecycle.ts'; import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts'; -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatch.mockReset(); @@ -85,9 +77,7 @@ beforeEach(() => { mockGetAndroidScreenSize.mockReset(); mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 }); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); + mockCaptureSnapshotForSession.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); }); diff --git a/src/daemon/handlers/__tests__/interaction-touch-response.test.ts b/src/daemon/handlers/__tests__/interaction-touch-response.test.ts index 0edeb21630..d3c3108956 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-response.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-response.test.ts @@ -4,7 +4,6 @@ import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts import { handleInteractionCommands } from '../interaction.ts'; import { contextFromFlags, - createEmulateCaptureSnapshotForSession, installTestScreenRecording, makeSession, } from './interaction-touch-fixtures.ts'; @@ -38,17 +37,9 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -62,13 +53,14 @@ import { getAndroidBlockingDialogFocus, } from '../../../platforms/android/app-lifecycle.ts'; import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts'; -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatch.mockReset(); @@ -80,9 +72,7 @@ beforeEach(() => { mockGetAndroidScreenSize.mockReset(); mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 }); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); + mockCaptureSnapshotForSession.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); }); diff --git a/src/daemon/handlers/__tests__/interaction-touch-runtime.test.ts b/src/daemon/handlers/__tests__/interaction-touch-runtime.test.ts index c8aa484af3..cb4a80adcb 100644 --- a/src/daemon/handlers/__tests__/interaction-touch-runtime.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch-runtime.test.ts @@ -2,11 +2,7 @@ import { test, expect, vi, beforeEach } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { handleInteractionCommands } from '../interaction.ts'; -import { - contextFromFlags, - createEmulateCaptureSnapshotForSession, - makeSession, -} from './interaction-touch-fixtures.ts'; +import { contextFromFlags, makeSession } from './interaction-touch-fixtures.ts'; // What the shared runtime dispatch does with the resolved target: refuse // unusable frame evidence rather than recapture positionally (ADR 0014), refuse @@ -37,17 +33,9 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -61,13 +49,14 @@ import { getAndroidBlockingDialogFocus, } from '../../../platforms/android/app-lifecycle.ts'; import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts'; -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatch.mockReset(); @@ -79,9 +68,7 @@ beforeEach(() => { mockGetAndroidScreenSize.mockReset(); mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 }); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); + mockCaptureSnapshotForSession.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); }); diff --git a/src/daemon/handlers/__tests__/interaction-touch.test.ts b/src/daemon/handlers/__tests__/interaction-touch.test.ts index b3a7bc7016..55781682c1 100644 --- a/src/daemon/handlers/__tests__/interaction-touch.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch.test.ts @@ -6,7 +6,6 @@ import { withWebProvider, type WebProvider } from '../../../platforms/web/provid import { handleInteractionCommands } from '../interaction.ts'; import { contextFromFlags, - createEmulateCaptureSnapshotForSession, makeMacOsDesktopSession, makeMacOsMenubarSession, makeSession, @@ -40,17 +39,9 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -64,13 +55,14 @@ import { getAndroidBlockingDialogFocus, } from '../../../platforms/android/app-lifecycle.ts'; import { getAndroidScreenSize } from '../../../platforms/android/input-actions.ts'; -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatch = vi.mocked(dispatchCommand); const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); const mockGetAndroidScreenSize = vi.mocked(getAndroidScreenSize); -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatch.mockReset(); @@ -82,9 +74,7 @@ beforeEach(() => { mockGetAndroidScreenSize.mockReset(); mockGetAndroidScreenSize.mockResolvedValue({ width: 1344, height: 2992 }); mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); + mockCaptureSnapshotForSession.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); }); diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 5175183722..c9e58b056b 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -13,7 +13,6 @@ import { handleInteractionCommands } from '../interaction.ts'; import { buildSnapshotState } from '../snapshot-capture.ts'; import { contextFromFlags, - createEmulateCaptureSnapshotForSession, makeSession, makeStaleRefSession, makeVisibleButtonSnapshot, @@ -35,6 +34,11 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }; }); +vi.mock('../snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; +}); + vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -45,18 +49,6 @@ vi.mock('../../../platforms/android/app-lifecycle.ts', async (importOriginal) => }; }); -vi.mock('../interaction-snapshot.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest' as const, - })), - }; -}); - vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -74,9 +66,6 @@ import { } from '../../../platforms/android/app-lifecycle.ts'; const mockGetAndroidAppState = vi.mocked(getAndroidAppState); const mockGetAndroidBlockingDialogFocus = vi.mocked(getAndroidBlockingDialogFocus); -import { captureSnapshotForSession } from '../interaction-snapshot.ts'; -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); - beforeEach(() => { mockDispatch.mockReset(); mockDispatch.mockResolvedValue({}); @@ -84,10 +73,6 @@ beforeEach(() => { mockGetAndroidAppState.mockResolvedValue({}); mockGetAndroidBlockingDialogFocus.mockReset(); mockGetAndroidBlockingDialogFocus.mockResolvedValue(null); - mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - createEmulateCaptureSnapshotForSession(mockDispatch), - ); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); }); diff --git a/src/daemon/handlers/__tests__/network-runtime-harness.ts b/src/daemon/handlers/__tests__/network-runtime-harness.ts index 806df406e0..587a06a373 100644 --- a/src/daemon/handlers/__tests__/network-runtime-harness.ts +++ b/src/daemon/handlers/__tests__/network-runtime-harness.ts @@ -9,7 +9,7 @@ import { type RuntimeOperationFact, type RuntimeOwnerRef, } from '@agent-device/contracts/platform'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; @@ -57,7 +57,7 @@ export function createNetworkRuntime( bootTarget: unavailable, bootTargetHeadless: unavailable, listApps: unavailable, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/daemon/handlers/__tests__/record-runtime.fixtures.ts b/src/daemon/handlers/__tests__/record-runtime.fixtures.ts index 71c9feb2e9..8737a987d7 100644 --- a/src/daemon/handlers/__tests__/record-runtime.fixtures.ts +++ b/src/daemon/handlers/__tests__/record-runtime.fixtures.ts @@ -10,7 +10,7 @@ import { type RuntimeOperationUnavailability, type ScreenRecordingLiveHandle, } from '@agent-device/contracts/platform'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { createScreenRecordingAdmissionLedger } from '../../screen-recording-admission-ledger.ts'; @@ -200,7 +200,7 @@ function makeRuntime(session: SessionState, options: RuntimeOptions = {}) { bootTarget: unavailable, bootTargetHeadless: unavailable, listApps: unavailable, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts index e6ca7a8b7f..769e24590a 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts @@ -62,6 +62,7 @@ function createAdmissionFacts( appLogReattach: unavailable, appLogCleanup: unavailable, appState: unavailable, + captureSnapshot: unavailable, deployApp: options.deployAvailable ? available : unavailable, materializeAppSource: options.sourceAvailable ? available : unavailable, deployMaterializedApp: options.sourceAvailable ? available : unavailable, diff --git a/src/daemon/handlers/__tests__/session-capabilities.test.ts b/src/daemon/handlers/__tests__/session-capabilities.test.ts index 250be5a0a7..6af61f14e6 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.test.ts @@ -10,7 +10,7 @@ import { makeSessionStore, } from '../../../__tests__/test-utils/index.ts'; import { withTestDeviceInventoryProvider as withTargetDeviceResolutionScope } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { applicationLifecycleOperationFacts, @@ -582,7 +582,7 @@ function createAdmissionOperationFacts( lifecycleAvailable: boolean, ) { return { - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, appLogInspect: options.appLogAvailable ? { available: true as const } : unavailable, appLogDoctor: unavailable, appLogStart: unavailable, diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index fbefb79cc0..fa15ff745e 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -132,6 +132,7 @@ function readinessFacts(device: DeviceInfo): RuntimeFacts { @@ -108,7 +108,7 @@ test('doctor preserves the legacy unsupported HarmonyOS target-app check', async ensureReady: available, bootTarget: unavailable, bootTargetHeadless: unavailable, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts b/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts index cccf0e55d4..08c21647c9 100644 --- a/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts @@ -9,7 +9,7 @@ import { type PlatformRuntimeOperations, type RuntimeFacts, } from '@agent-device/contracts/platform'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, @@ -52,7 +52,7 @@ function runtimeFacts(): RuntimeFacts { ensureReady: appsAvailable, bootTarget: unavailable, bootTargetHeadless: unavailable, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/daemon/handlers/__tests__/session-inventory-harmonyos.test.ts b/src/daemon/handlers/__tests__/session-inventory-harmonyos.test.ts index a78c5549b6..75d62876b1 100644 --- a/src/daemon/handlers/__tests__/session-inventory-harmonyos.test.ts +++ b/src/daemon/handlers/__tests__/session-inventory-harmonyos.test.ts @@ -9,7 +9,7 @@ import { type PlatformRuntimeOperations, type RuntimeFacts, } from '@agent-device/contracts/platform'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, @@ -56,7 +56,7 @@ function runtimeFacts(): RuntimeFacts { ensureReady: available, bootTarget: unavailable, bootTargetHeadless: unavailable, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/daemon/handlers/__tests__/session-logs.test.ts b/src/daemon/handlers/__tests__/session-logs.test.ts index ded9e2a55b..c4f13ec32b 100644 --- a/src/daemon/handlers/__tests__/session-logs.test.ts +++ b/src/daemon/handlers/__tests__/session-logs.test.ts @@ -8,7 +8,7 @@ import { type DeviceBinding, type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { createTestAppLogLiveHandle } from '../../../__tests__/test-utils/app-log-live-handle.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -417,7 +417,7 @@ function createRuntimeHarness(options: { inspectAvailable?: boolean } = {}) { bootTarget: { available: true as const }, bootTargetHeadless: unavailableRecording, listApps: unavailableRecording, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailableRecording, prepareApplicationOpen: unavailableRecording, diff --git a/src/daemon/handlers/__tests__/session-open-foreground.test.ts b/src/daemon/handlers/__tests__/session-open-foreground.test.ts index 0cc49a6c10..98717aa021 100644 --- a/src/daemon/handlers/__tests__/session-open-foreground.test.ts +++ b/src/daemon/handlers/__tests__/session-open-foreground.test.ts @@ -11,6 +11,9 @@ import { resolveForegroundOpenRequest, } from '../session-open-foreground.ts'; +const inspectFacts = vi.fn(); +const bindDevice = vi.fn(); + function baseRequest(overrides: Partial = {}): DaemonRequest { return { token: 't', @@ -24,6 +27,8 @@ function baseRequest(overrides: Partial = {}): DaemonRequest { beforeEach(() => { dispatchSnapshotViaRuntime.mockReset(); + inspectFacts.mockReset(); + bindDevice.mockReset(); }); // --- resolveForegroundOpenRequest --- @@ -141,6 +146,8 @@ test('passes a failed open response through untouched', async () => { logPath: '/tmp/daemon.log', sessionStore: {} as never, openResponse: failedOpenResponse, + inspectFacts, + bindDevice, }); expect(result).toBe(failedOpenResponse); @@ -154,6 +161,8 @@ test('leaves a successful open response untouched when --foreground was not requ logPath: '/tmp/daemon.log', sessionStore: {} as never, openResponse: okOpenResponse, + inspectFacts, + bindDevice, }); expect(result).toBe(okOpenResponse); @@ -170,6 +179,8 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn logPath: '/tmp/daemon.log', sessionStore: {} as never, openResponse: okOpenResponse, + inspectFacts, + bindDevice, }); // #1670 P1: the composed dispatch must BE the `snapshot -i` path — the CLI @@ -184,6 +195,8 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn sessionName: 'default', logPath: '/tmp/daemon.log', sessionStore: {}, + inspectFacts, + bindDevice, }); expect(result).toEqual({ ok: true, @@ -215,6 +228,8 @@ test('a snapshot-capture failure never masks the successful open', async () => { logPath: '/tmp/daemon.log', sessionStore: {} as never, openResponse: { ok: true, data: { session: 'default', warnings: ['pre-existing warning'] } }, + inspectFacts, + bindDevice, }); expect(result.ok).toBe(true); @@ -255,6 +270,8 @@ test('a THROWN snapshot-capture failure never masks the successful open either', logPath: '/tmp/daemon.log', sessionStore: {} as never, openResponse: { ok: true, data: { session: 'default' } }, + inspectFacts, + bindDevice, }); expect(result.ok).toBe(true); diff --git a/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts b/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts index 850238c89f..310dace98f 100644 --- a/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-dispatch-selector-miss.test.ts @@ -26,12 +26,16 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { AppError } from '@agent-device/kernel/errors'; import { dispatchCommand } from '../../../core/dispatch.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq, @@ -41,12 +45,16 @@ import { bottomTabsRealCaptureFixture, recordArticleEvidence, } from './session-replay-target-classification-fixtures.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({}); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); function setupSession(root: string): { sessionStore: SessionStore; sessionName: string } { diff --git a/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts index 45431863b7..c6299d8392 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence-publication.test.ts @@ -7,8 +7,12 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})) }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); import { dispatchCommand } from '../../../core/dispatch.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import type { ReplayDivergence } from '@agent-device/contracts/divergence'; @@ -18,12 +22,16 @@ import { markSessionPartialRefsIssued, setSessionSnapshot } from '../../session- import { SessionStore } from '../../session-store.ts'; import { captureDivergenceObservation } from '../session-replay-divergence.ts'; import { boundReplayDivergenceForSession } from '../session-replay-divergence-publication.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({}); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); function scenario(refCount = 20) { diff --git a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts index 0b9147ce60..7bfb135f6e 100644 --- a/src/daemon/handlers/__tests__/session-replay-divergence.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-divergence.test.ts @@ -6,6 +6,9 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); // Stub the Android freshness-retry delay to a no-op so the capture-parity test // exercises the retry BRANCH without a real wall-clock wait (repo guidance: @@ -21,6 +24,7 @@ vi.mock('../../../utils/timeouts.ts', async (importOriginal) => { }); import { dispatchCommand } from '../../../core/dispatch.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -38,11 +42,15 @@ import { buildReplayFailureDivergence, captureDivergenceObservation, } from '../session-replay-divergence.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({}); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); test('buildReplayFailureDivergence dedupes suggestions using the strongest basis', async () => { diff --git a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts b/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts index fd4a3f54ca..de81fb611b 100644 --- a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts @@ -6,6 +6,9 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); import fs from 'node:fs'; import path from 'node:path'; import { stringify } from 'yaml'; @@ -23,15 +26,20 @@ import { import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq } from './session-replay-runtime.fixtures.ts'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({}); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); async function buildFailureScenario( diff --git a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts index 0b2d984d4f..043bcc36c0 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts @@ -13,12 +13,16 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import type { DaemonRequest } from '../../types.ts'; import { parseReplayScriptDetailed } from '@agent-device/ad-script'; @@ -27,11 +31,15 @@ import { writeReplayFile, } from './session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); // The "current" app state throughout this test: "save" was renamed to // "save-v2" (why the recorded step 2 diverges) and "confirm" is present. mockDispatchCommand.mockResolvedValue({ diff --git a/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts index e37d310769..018038fd67 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-empty-tail.test.ts @@ -29,12 +29,17 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { repairSessionBoundary } from '../../session-replay-transaction.ts'; import type { SessionState } from '../../types.ts'; @@ -57,9 +62,12 @@ function sessionRepairStatus(session: SessionState | undefined) { } const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); // The recorded container ("article", under a real nested ancestry chain) // stays present throughout — this is what routes the divergence to // `record-and-heal` rather than the `manual` fail-safe. diff --git a/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts index 5872762ce8..2a7e025884 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts @@ -17,11 +17,15 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession, repairPublication, @@ -34,6 +38,7 @@ import { writeReplayFile, } from './session-replay-runtime.fixtures.ts'; import { freshEvidence, makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; /** Repair-transaction status, or `undefined` outside a repair publication. */ function sessionRepairStatus(session: SessionState | undefined) { @@ -43,9 +48,12 @@ function sessionRepairStatus(session: SessionState | undefined) { } const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); // Matches every annotated step's recorded identity (id="save") — the // target-binding verification a couple of these scripts trigger. mockDispatchCommand.mockResolvedValue({ diff --git a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts index bab66f5c3f..d9700d5c65 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts @@ -48,6 +48,9 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; @@ -55,6 +58,7 @@ import { handleCloseCommand as handleProductionCloseCommand } from '../session-c import { SessionStore } from '../../session-store.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession, authoringPublication, @@ -69,8 +73,10 @@ import { bindLifecycleRuntime, inspectLifecycleRuntimeFacts, } from './application-lifecycle-runtime-harness.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); function handleCloseCommand( params: Omit[0], 'inspectFacts' | 'bindDevice'>, @@ -84,6 +90,8 @@ function handleCloseCommand( beforeEach(() => { mockDispatchCommand.mockReset(); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); // The "current" app state: "save" was renamed to "save-v2" (why the click // on `id="save"` diverges as a `selector-miss`) — nested under a "Toolbar" // container so the recorded ancestry (below) has a real, present container diff --git a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts index d354b59a59..c3816e2d77 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts @@ -50,6 +50,9 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; @@ -57,6 +60,8 @@ import { handleCloseCommand as handleProductionCloseCommand } from '../session-c import { SessionStore } from '../../session-store.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { dispatchApplicationLifecycleEffect } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; import { AppError } from '@agent-device/kernel/errors'; import { @@ -88,6 +93,7 @@ import { } from './application-lifecycle-runtime-harness.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); const mockLifecycleDispatch = vi.mocked(dispatchApplicationLifecycleEffect); function handleCloseCommand( @@ -119,6 +125,8 @@ function sessionCloseReceipt(session: SessionState | undefined): string | undefi beforeEach(() => { mockDispatchCommand.mockReset(); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); mockLifecycleDispatch.mockReset(); mockLifecycleDispatch.mockResolvedValue(undefined); // The "current" app state: "save" was renamed to "save-v2" (why step 2 diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts index c14263eb4d..7adb5c1967 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-binding.test.ts @@ -3,8 +3,10 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { resolveTargetDevice } from '../../../core/dispatch.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; import { SessionStore } from '../../session-store.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { baseReplayRequest as baseReq } from './session-replay-runtime.fixtures.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; @@ -13,11 +15,18 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); + const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockResolveTargetDevice.mockReset(); mockResolveTargetDevice.mockResolvedValue(makeIosSession('resolved').device); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); test('typed Maestro does not resolve a device before an explicit-platform flow needs one', async () => { diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts index 4111cf0ba2..c3952b82b8 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-failure-response.test.ts @@ -5,22 +5,30 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); import path from 'node:path'; import { buildReplayDivergenceFailureResponseFromDescriptor } from '../session-replay-runtime-failure-response.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { baseReplayRequest as baseReq, writeReplayFile, } from './session-replay-runtime.fixtures.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({}); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); test('native replay failure metadata keeps machine fields and daemon-owned paths intact', () => { diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts index 1c50e4ed35..522457505f 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-failure.test.ts @@ -5,6 +5,9 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; @@ -14,16 +17,21 @@ import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence'; import { maestroScriptSourceBundleFor } from '../../../__tests__/test-utils/replay-script-source.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { baseReplayRequest as baseReq, writeReplayFile, } from './session-replay-runtime.fixtures.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({}); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); test('a failing replay step returns REPLAY_DIVERGENCE with cause preserved and correct step provenance', async () => { const root = mkdtempForTestSync('agent-device-replay-divergence-'); @@ -33,7 +41,8 @@ test('a failing replay step returns REPLAY_DIVERGENCE with cause preserved and c const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); // The post-failure screen digest capture (and the suggestions re-resolution - // capture) both go through dispatchCommand('snapshot', ...); with no real + // capture) both reach the narrow snapshot seam, adapted here to the legacy + // dispatch mock; with no real // device backend in a unit test, this throws, so screen must degrade to // 'unavailable' rather than masking the original replay cause. mockDispatchCommand.mockImplementation(async (_device, command) => { diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts index fa2a902d98..80ee354ab0 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-maestro.test.ts @@ -4,7 +4,7 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; // ADR 0012 migration step 2: every replay step failure now attempts a // post-failure screen digest capture + suggestion re-resolution, both via -// dispatchCommand('snapshot', ...). None of this file's fixtures model a real +// the narrow snapshot interactor seam. None of this file's fixtures model a real // device runner, so without a mock those calls fall through to the real // (slow/hanging) runner dispatch path. Reject fast so failure-path tests keep // exercising `divergence.screen: unavailable` deterministically, exactly like @@ -48,6 +48,10 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); + import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -62,6 +66,12 @@ import { maestroScriptSourceBundleFor, replayScriptSourceBundleFor, } from '../../../__tests__/test-utils/replay-script-source.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; + +vi.mocked(captureSnapshotWithInteractor).mockImplementation( + captureSnapshotThroughLegacyDispatchFixture, +); type CapturedInvocation = { command: string; diff --git a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts index 7e16933338..405cba8f68 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime-plan.test.ts @@ -6,12 +6,18 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); + import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { createReplayCoordinator } from '../../session-replay-coordinator.ts'; import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeAndroidSession, makeIosSession, @@ -23,12 +29,15 @@ import { const mockDispatchCommand = vi.mocked(dispatchCommand); const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({}); mockResolveTargetDevice.mockReset(); mockResolveTargetDevice.mockResolvedValue(makeIosSession('resolved').device); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); test('resume skips steps 1..from-1 without invoking them and executes only from the reported step', async () => { const root = mkdtempForTestSync('agent-device-replay-resume-skip-'); diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts index eb7cd9ebe0..003d9ba1dc 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts @@ -7,11 +7,17 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); + import fs from 'node:fs'; import path from 'node:path'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { makeIosSession, makeRepairArmedSession, @@ -22,10 +28,13 @@ import { } from './session-replay-runtime.fixtures.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({}); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); test('a successful replay prints one line with the step count and wall time', async () => { const root = mkdtempForTestSync('agent-device-replay-success-message-'); diff --git a/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts b/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts index da23ec6182..a7ebdedb54 100644 --- a/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-selector-routes.test.ts @@ -6,6 +6,8 @@ import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { SessionStore } from '../../session-store.ts'; import { runReplayScriptSource } from '../session-replay-runtime.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; import { baseReplayRequest, writeReplayFile } from './session-replay-runtime.fixtures.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { @@ -13,11 +15,18 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { return { ...actual, dispatchCommand: vi.fn(), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); + const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); test('replay executes selector reads before reporting a covered-target divergence', async () => { diff --git a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts index 40b9f73152..b81413660f 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-verification-runtime.test.ts @@ -3,7 +3,7 @@ * `verifyReplayActionTarget` for every annotated resolved-target action * BEFORE dispatching it, and never send the device action on a non-verified * outcome. Mirrors the mocking pattern of `session-replay-runtime.test.ts` - * (mock `dispatchCommand` for the pre-action snapshot capture, mock `invoke` + * (adapt mocked `dispatchCommand` through the narrow snapshot interactor seam, mock `invoke` * for the actual action dispatch). */ import { test, expect, vi, beforeEach } from 'vitest'; @@ -14,6 +14,10 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); + // #1385's pre-dispatch launch-race retry (captureDivergenceObservation's // `retryLaunchRace`) awaits a real `sleep` between attempts; stub it to a // no-op so the retry tests below exercise the retry BRANCH (still runs each @@ -35,12 +39,17 @@ import { baseReplayRequest as baseReq, writeReplayFile, } from './session-replay-runtime.fixtures.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; const mockDispatchCommand = vi.mocked(dispatchCommand); +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({}); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); const SAVE_ANNOTATION = diff --git a/src/daemon/handlers/__tests__/session-state.test.ts b/src/daemon/handlers/__tests__/session-state.test.ts index a8bd90e93c..046384823f 100644 --- a/src/daemon/handlers/__tests__/session-state.test.ts +++ b/src/daemon/handlers/__tests__/session-state.test.ts @@ -3,7 +3,7 @@ import { test, expect, vi } from 'vitest'; import { handleSessionStateCommands } from '../session-state.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { withTestDeviceInventory } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; import { appStateUse, applicationLifecycleOperationFacts, @@ -185,7 +185,7 @@ test('appstate rejects a missing readiness fact even when appState is available' }, bootTarget: { available: false, reason: 'unsupported-device-kind' }, bootTargetHeadless: { available: false, reason: 'unsupported-device-kind' }, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: { available: false, reason: 'unsupported-device-kind' }, prepareApplicationOpen: { available: false, reason: 'unsupported-device-kind' }, @@ -261,7 +261,7 @@ test('sessionless Android appstate inspects once, binds once, and preserves oper ensureReady: available, bootTarget: available, bootTargetHeadless: unavailable, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, ...applicationLifecycleOperationFacts({ resolveOpenTarget: unavailable, prepareApplicationOpen: unavailable, diff --git a/src/daemon/handlers/__tests__/session-test-reporter-values.test.ts b/src/daemon/handlers/__tests__/session-test-reporter-values.test.ts index 2d2962544b..63db1e878f 100644 --- a/src/daemon/handlers/__tests__/session-test-reporter-values.test.ts +++ b/src/daemon/handlers/__tests__/session-test-reporter-values.test.ts @@ -8,20 +8,16 @@ // registry and pin what a reporter sees. They document today's behavior; they do not // propose behavior. -// The same reason session-test-suite.test.ts mocks it: ADR 0012 attempts a post-failure -// screen digest via dispatchCommand('snapshot', ...), and these fixtures model no runner. +// ADR 0012 attempts a post-failure screen digest through the narrow snapshot interactor seam; +// these fixtures model no runner, so reject that leaf capture deterministically. import { expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => { - throw new Error('no device runner available in this test'); - }), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(async () => { + throw new Error('no device runner available in this test'); + }), +})); import fs from 'node:fs'; import os from 'node:os'; diff --git a/src/daemon/handlers/__tests__/session-test-suite.test.ts b/src/daemon/handlers/__tests__/session-test-suite.test.ts index 350ed2295a..669e1807e8 100644 --- a/src/daemon/handlers/__tests__/session-test-suite.test.ts +++ b/src/daemon/handlers/__tests__/session-test-suite.test.ts @@ -3,20 +3,16 @@ import { test, expect, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; // ADR 0012 migration step 2: every replay step failure now attempts a -// post-failure screen digest capture + suggestion re-resolution via -// dispatchCommand('snapshot', ...). This file's fixtures don't model a real +// post-failure screen digest capture + suggestion re-resolution through the +// narrow snapshot interactor seam. This file's fixtures don't model a real // device runner, so without a mock those calls fall through to the real // (slow/hanging) runner dispatch path. Reject fast so failure-path tests keep // exercising `divergence.screen: unavailable` deterministically. -vi.mock('../../../core/dispatch.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchCommand: vi.fn(async () => { - throw new Error('no device runner available in this test'); - }), - }; -}); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(async () => { + throw new Error('no device runner available in this test'); + }), +})); import fs from 'node:fs'; import os from 'node:os'; diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index b3cdc765b2..c8a3e8504e 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -2,7 +2,7 @@ import { test, expect, vi, afterEach, beforeEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { PNG } from '../../../utils/png.ts'; -import { handleSnapshotCommands } from '../snapshot.ts'; +import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; import { withSessionlessRunnerCleanup } from '../snapshot-session.ts'; import { captureSnapshot } from '../snapshot-capture.ts'; import { SessionStore } from '../../session-store.ts'; @@ -16,15 +16,35 @@ import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; import { snapshotCliOutput } from '../../../commands/capture/output.ts'; import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { snapshotRuntimeFixture } from '../../__tests__/snapshot-runtime-fixture.ts'; + +const dispatchCommandMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => ({}))); vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - dispatchCommand: vi.fn(async () => ({})), + dispatchCommand: dispatchCommandMock, }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn( + async ({ device, runnerContext, options }) => + await dispatchCommandMock(device, 'snapshot', [], undefined, { + ...runnerContext, + ...options, + snapshotInteractiveOnly: options.interactiveOnly, + snapshotPreferredBackend: options.preferredBackend, + snapshotDepth: options.depth, + snapshotScope: options.scope, + snapshotRaw: options.raw, + snapshotCustomActions: options.customActions, + snapshotIncludeHiddenContentHints: options.includeHiddenContentHints, + }), + ), +})); + vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -65,6 +85,18 @@ const mockStopIosRunnerSession = vi.mocked(stopIosRunnerSession); const mockCloseIosApp = vi.mocked(closeIosApp); const mockBuildIosOpenCommandHint = vi.mocked(buildIosOpenCommandHint); +function handleSnapshotCommands( + params: Parameters[0], +): ReturnType { + if (params.req.command !== 'snapshot') return handleProductionSnapshotCommands(params); + const runtime = snapshotRuntimeFixture(params.req.meta?.requestId); + return handleProductionSnapshotCommands({ + ...params, + inspectFacts: params.inspectFacts ?? runtime.inspectFacts, + bindDevice: params.bindDevice ?? runtime.bindDevice, + }); +} + function makeSessionStore(): SessionStore { const root = mkdtempForTestSync('agent-device-snapshot-handler-'); return new SessionStore(path.join(root, 'sessions')); diff --git a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts index 874fac5230..e8f4d1ef6a 100644 --- a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts @@ -1,10 +1,11 @@ import { beforeEach, expect, test, vi } from 'vitest'; -import { handleSnapshotCommands } from '../snapshot.ts'; +import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; import type { RawSnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeAndroidSession } from '../../../__tests__/test-utils/index.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { expireRefFrame } from '../../ref-frame.ts'; +import { snapshotRuntimeFixture } from '../../__tests__/snapshot-runtime-fixture.ts'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -103,7 +104,8 @@ function requestScopedSnapshot( sessionStore: ReturnType, snapshotScope: string, ) { - return handleSnapshotCommands({ + const runtime = snapshotRuntimeFixture(); + return handleProductionSnapshotCommands({ req: { token: 't', session: sessionName, @@ -114,6 +116,7 @@ function requestScopedSnapshot( sessionName, logPath: '/tmp/daemon.log', sessionStore, + ...runtime, }); } diff --git a/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts b/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts index f429f06a31..4919ef60a7 100644 --- a/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts +++ b/src/daemon/handlers/__tests__/system-surface-disclosure.test.ts @@ -15,6 +15,11 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }; }); +vi.mock('../snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; +}); + vi.mock('../../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); diff --git a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts index 1331e06a53..9e8c7405b1 100644 --- a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts +++ b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts @@ -31,6 +31,11 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { }; }); +vi.mock('../snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; +}); + vi.mock('../../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); diff --git a/src/daemon/handlers/session-open-foreground.ts b/src/daemon/handlers/session-open-foreground.ts index 2accdc27a0..24f420f165 100644 --- a/src/daemon/handlers/session-open-foreground.ts +++ b/src/daemon/handlers/session-open-foreground.ts @@ -2,6 +2,7 @@ import { normalizeError, type NormalizedError } from '@agent-device/kernel/error import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import { errorResponse } from './response.ts'; export type ForegroundOpenResolution = @@ -101,6 +102,8 @@ export async function composeOpenWithInitialSnapshot(params: { logPath: string; sessionStore: SessionStore; openResponse: DaemonResponse; + inspectFacts: InspectDeviceRuntimeFacts; + bindDevice: BindDeviceRuntime; }): Promise { const { req, sessionName, logPath, sessionStore, openResponse } = params; if (!openResponse.ok || req.flags?.foreground !== true) return openResponse; @@ -119,6 +122,8 @@ export async function composeOpenWithInitialSnapshot(params: { sessionName, logPath, sessionStore, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, }); if (!snapshotResponse.ok) { return openWithInitialSnapshotFailure(openResponse.data, snapshotResponse.error); diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 2b90f73c43..472d211e29 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -10,6 +10,7 @@ import { requireSessionOrExplicitSelector, resolveCommandDevice } from './sessio import { errorResponse, requireCommandSupported } from './response.ts'; import { recordSessionAction } from './handler-utils.ts'; import { handleRuntimeCommand } from './session-runtime-command.ts'; +import { requireRuntimeBinding, requireRuntimeFacts } from './session-runtime-admission.ts'; import { handleOpenCommand } from './session-open.ts'; import { composeOpenWithInitialSnapshot } from './session-open-foreground.ts'; import { @@ -380,22 +381,27 @@ const SESSION_COMMAND_HANDLER_IMPLS = { inspectFacts, bindDevice, reconcileOrphanedDeviceClaim, - }) => - await composeOpenWithInitialSnapshot({ + }) => { + const openResponse = await handleOpenCommand({ req, sessionName, logPath, sessionStore, - openResponse: await handleOpenCommand({ - req, - sessionName, - logPath, - sessionStore, - inspectFacts, - bindDevice, - reconcileOrphanedDeviceClaim, - }), - }), + inspectFacts, + bindDevice, + reconcileOrphanedDeviceClaim, + }); + if (!openResponse.ok || req.flags?.foreground !== true) return openResponse; + return await composeOpenWithInitialSnapshot({ + req, + sessionName, + logPath, + sessionStore, + inspectFacts: requireRuntimeFacts(inspectFacts), + bindDevice: requireRuntimeBinding(bindDevice), + openResponse, + }); + }, replay: handleSessionReplayCommandGroup, test: handleSessionReplayCommandGroup, batch: async ({ req, sessionName, invoke }) => await runBatchCommands(req, sessionName, invoke), diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 68af1a8437..cae2459caf 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -16,11 +16,15 @@ import { type SnapshotBackend, type SnapshotState, } from '@agent-device/kernel/snapshot'; -import { dispatchCommand } from '../../core/dispatch.ts'; import { runMacOsSnapshotAction } from '../../platforms/apple/os/macos/helper.ts'; import { snapshotLinux } from '../../platforms/linux/snapshot.ts'; import { annotateCoveredSnapshotNodes } from '../../snapshot/snapshot-occlusion.ts'; -import { findNodeByLabel, resolveRefLabel } from '../../core/snapshot-node-lookup.ts'; +import { resolveRefLabel } from '../../core/snapshot-node-lookup.ts'; +import { + scopeSnapshotNodes, + shapeDesktopSurfaceSnapshot, +} from '../../core/snapshot-desktop-surface.ts'; +import { captureSnapshotWithInteractor } from './snapshot-interactor-capture.ts'; import { normalizeSnapshotTree, pruneGroupNodes } from '../../core/snapshot-tree-ingestion.ts'; import { clearAndroidSnapshotFreshness, @@ -42,6 +46,12 @@ type CaptureSnapshotParams = { snapshotScope?: string; androidFreshnessMode?: AndroidFreshnessMode; signal?: AbortSignal; + /** + * Request-bound platform capture. Migrated callers inject the selected + * runtime operation; legacy consumers keep the existing interactor path + * until their own command descriptor cuts over. + */ + captureData?: () => Promise; }; type SnapshotData = { @@ -83,15 +93,16 @@ export async function captureSnapshot( } export async function captureSnapshotData(params: CaptureSnapshotParams): Promise { - const { device, session, flags, outPath, logPath, snapshotScope } = params; + if (params.captureData) return await params.captureData(); + const { device, session, flags, logPath, snapshotScope } = params; if (device.platform === 'linux') { const linuxResult = await snapshotLinux(session?.surface, params.signal); return shapeDesktopSurfaceSnapshot( { nodes: linuxResult.nodes, truncated: linuxResult.truncated, backend: 'linux-atspi' }, { - snapshotDepth: flags?.snapshotDepth, - snapshotInteractiveOnly: flags?.snapshotInteractiveOnly, - snapshotScope, + depth: flags?.snapshotDepth, + interactiveOnly: flags?.snapshotInteractiveOnly, + scope: snapshotScope, }, ); } @@ -101,21 +112,45 @@ export async function captureSnapshotData(params: CaptureSnapshotParams): Promis signal: params.signal, }); return shapeDesktopSurfaceSnapshot(helperSnapshot, { - snapshotDepth: flags?.snapshotDepth, - snapshotInteractiveOnly: flags?.snapshotInteractiveOnly, - snapshotScope, + depth: flags?.snapshotDepth, + interactiveOnly: flags?.snapshotInteractiveOnly, + scope: snapshotScope, }); } - return (await dispatchCommand(device, 'snapshot', [], outPath, { - ...contextFromFlags( - logPath, - { ...flags, snapshotScope }, - session?.appBundleId, - session?.trace?.outPath, - ), - snapshotIncludeRects: params.includeRects, - signal: params.signal, - })) as SnapshotData; + const context = contextFromFlags( + logPath, + { ...flags, snapshotScope }, + session?.appBundleId, + session?.trace?.outPath, + ); + return await captureSnapshotWithInteractor({ + device, + runnerContext: { + requestId: context.requestId, + signal: params.signal, + appBundleId: context.appBundleId, + verbose: context.verbose, + logPath: context.logPath, + traceLogPath: context.traceLogPath, + iosXctestrunFile: context.iosXctestrunFile, + iosXctestDerivedDataPath: context.iosXctestDerivedDataPath, + iosXctestEnvDir: context.iosXctestEnvDir, + runnerLeaseContext: context.runnerLeaseContext, + }, + options: { + appBundleId: context.appBundleId, + signal: params.signal, + interactiveOnly: context.snapshotInteractiveOnly, + preferredBackend: context.snapshotPreferredBackend, + depth: context.snapshotDepth, + scope: context.snapshotScope, + raw: context.snapshotRaw, + customActions: context.snapshotCustomActions, + includeRects: params.includeRects, + includeHiddenContentHints: context.snapshotIncludeHiddenContentHints, + surface: session?.surface, + }, + }); } async function captureSnapshotAttempt(params: CaptureSnapshotParams): Promise { @@ -222,109 +257,6 @@ function isAndroidComparisonSafeSnapshot( ); } -function shapeDesktopSurfaceSnapshot( - data: SnapshotData, - options: { - snapshotDepth?: number; - snapshotInteractiveOnly?: boolean; - snapshotScope?: string; - }, -): SnapshotData { - let nodes = data.nodes ?? []; - if (options.snapshotScope) { - nodes = scopeSnapshotNodes(nodes, options.snapshotScope); - } - if (options.snapshotInteractiveOnly) { - nodes = filterInteractiveSnapshotNodes(nodes); - } - if (typeof options.snapshotDepth === 'number') { - nodes = filterSnapshotNodesByDepth(nodes, options.snapshotDepth); - } - return { ...data, nodes }; -} - -function scopeSnapshotNodes(nodes: RawSnapshotNode[], scope: string): RawSnapshotNode[] { - const scopedNodes = attachRefs(nodes); - const match = findNodeByLabel(scopedNodes, scope); - if (!match) { - return []; - } - const startIndex = nodes.findIndex((node) => node.index === match.index); - if (startIndex === -1) { - return []; - } - const startDepth = nodes[startIndex]?.depth ?? 0; - const slice: RawSnapshotNode[] = []; - for (let index = startIndex; index < nodes.length; index += 1) { - const node = nodes[index]; - if (!node) continue; - const depth = node.depth ?? 0; - if (index > startIndex && depth <= startDepth) { - break; - } - slice.push(node); - } - return reindexSnapshotNodes(slice, startDepth); -} - -function filterInteractiveSnapshotNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] { - if (nodes.length === 0) { - return nodes; - } - const byIndex = new Map(); - for (const node of nodes) { - byIndex.set(node.index, node); - } - const keepIndexes = new Set(); - for (const node of nodes) { - if (!isInteractiveSnapshotNode(node)) continue; - let current: RawSnapshotNode | undefined = node; - while (current) { - if (keepIndexes.has(current.index)) break; - keepIndexes.add(current.index); - current = - typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined; - } - } - if (keepIndexes.size === 0) { - return nodes; - } - return reindexSnapshotNodes(nodes.filter((node) => keepIndexes.has(node.index))); -} - -function filterSnapshotNodesByDepth(nodes: RawSnapshotNode[], maxDepth: number): RawSnapshotNode[] { - return reindexSnapshotNodes(nodes.filter((node) => (node.depth ?? 0) <= maxDepth)); -} - -function reindexSnapshotNodes(nodes: RawSnapshotNode[], depthOffset = 0): RawSnapshotNode[] { - const indexMap = new Map(); - for (const [index, node] of nodes.entries()) { - indexMap.set(node.index, index); - } - return nodes.map((node, index) => ({ - ...node, - index, - depth: Math.max(0, (node.depth ?? 0) - depthOffset), - parentIndex: typeof node.parentIndex === 'number' ? indexMap.get(node.parentIndex) : undefined, - })); -} - -function isInteractiveSnapshotNode(node: RawSnapshotNode): boolean { - if (node.focused) return true; - if (node.hittable) return true; - if (node.rect) return true; - const role = `${node.type ?? ''} ${node.role ?? ''} ${node.subrole ?? ''}`.toLowerCase(); - return ( - role.includes('button') || - role.includes('menu') || - role.includes('textfield') || - role.includes('searchfield') || - role.includes('checkbox') || - role.includes('radio') || - role.includes('switch') - ); -} - export function resolveSnapshotScope( snapshotScope: string | undefined, session: SessionState | undefined, diff --git a/src/daemon/handlers/snapshot-interactor-capture.ts b/src/daemon/handlers/snapshot-interactor-capture.ts new file mode 100644 index 0000000000..9deda28b45 --- /dev/null +++ b/src/daemon/handlers/snapshot-interactor-capture.ts @@ -0,0 +1,21 @@ +import type { + RunnerContext, + SnapshotOptions, + SnapshotResult, +} from '@agent-device/contracts/interaction'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { getInteractor } from '../../core/interactors.ts'; + +/** + * Legacy snapshot consumers that have not moved to a device-runtime operation + * still capture through the selected interactor. Keeping this seam snapshot- + * specific avoids restoring the retired generic command dispatcher route. + */ +export async function captureSnapshotWithInteractor(params: { + device: DeviceInfo; + runnerContext: RunnerContext; + options: SnapshotOptions; +}): Promise { + const interactor = await getInteractor(params.device, params.runnerContext); + return await interactor.snapshot(params.options); +} diff --git a/src/daemon/handlers/snapshot-runtime-route.test.ts b/src/daemon/handlers/snapshot-runtime-route.test.ts new file mode 100644 index 0000000000..804d721d24 --- /dev/null +++ b/src/daemon/handlers/snapshot-runtime-route.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +const { dispatchSnapshotDiffViaRuntime, dispatchSnapshotViaRuntime } = vi.hoisted(() => ({ + dispatchSnapshotDiffViaRuntime: vi.fn(), + dispatchSnapshotViaRuntime: vi.fn(), +})); + +vi.mock('../snapshot-runtime.ts', () => ({ + dispatchSnapshotDiffViaRuntime, + dispatchSnapshotViaRuntime, +})); + +import { SessionStore } from '../session-store.ts'; +import type { DaemonRequest } from '../types.ts'; +import { handleSnapshotCommands } from './snapshot.ts'; + +beforeEach(() => { + dispatchSnapshotDiffViaRuntime.mockReset(); + dispatchSnapshotViaRuntime.mockReset(); + dispatchSnapshotDiffViaRuntime.mockResolvedValue({ ok: true, data: {} }); + dispatchSnapshotViaRuntime.mockResolvedValue({ ok: true, data: {} }); +}); + +test('snapshot route forwards the request runtime facts and binding seams exactly once', async () => { + const inspectFacts = vi.fn(); + const bindDevice = vi.fn(); + const sessionStore = new SessionStore('/tmp/agent-device-snapshot-runtime-route-test'); + const req: DaemonRequest = { + command: 'snapshot', + positionals: [], + session: 'snapshot-route', + token: 'test-token', + }; + + await handleSnapshotCommands({ + req, + sessionName: 'snapshot-route', + logPath: '/tmp/snapshot-route.log', + sessionStore, + inspectFacts, + bindDevice, + }); + + expect(dispatchSnapshotViaRuntime).toHaveBeenCalledOnce(); + expect(dispatchSnapshotViaRuntime).toHaveBeenCalledWith({ + req, + sessionName: 'snapshot-route', + logPath: '/tmp/snapshot-route.log', + sessionStore, + inspectFacts, + bindDevice, + }); + expect(dispatchSnapshotDiffViaRuntime).not.toHaveBeenCalled(); +}); + +test('diff route stays on its independent legacy unit without runtime binding seams', async () => { + const inspectFacts = vi.fn(); + const bindDevice = vi.fn(); + const sessionStore = new SessionStore('/tmp/agent-device-snapshot-diff-route-test'); + const req: DaemonRequest = { + command: 'diff', + positionals: ['snapshot'], + session: 'diff-route', + token: 'test-token', + }; + + await handleSnapshotCommands({ + req, + sessionName: 'diff-route', + logPath: '/tmp/diff-route.log', + sessionStore, + inspectFacts, + bindDevice, + }); + + expect(dispatchSnapshotDiffViaRuntime).toHaveBeenCalledOnce(); + expect(dispatchSnapshotDiffViaRuntime).toHaveBeenCalledWith({ + req, + sessionName: 'diff-route', + logPath: '/tmp/diff-route.log', + sessionStore, + }); + expect(dispatchSnapshotViaRuntime).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index 1641be0209..b359f83588 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -6,23 +6,28 @@ import { handleSettingsCommand, parseSettingsArgs } from './snapshot-settings.ts import { dispatchSnapshotDiffViaRuntime, dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; import { dispatchWaitViaRuntime } from '../selector-runtime.ts'; import { resolveSessionDevice, withSessionlessRunnerCleanup } from './snapshot-session.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; type SnapshotCommandParams = { req: DaemonRequest; sessionName: string; logPath: string; sessionStore: SessionStore; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; }; type SnapshotCommandHandler = (params: SnapshotCommandParams) => Promise; const SNAPSHOT_COMMAND_HANDLER_IMPLS = { - snapshot: async ({ req, sessionName, logPath, sessionStore }) => + snapshot: async ({ req, sessionName, logPath, sessionStore, inspectFacts, bindDevice }) => await dispatchSnapshotViaRuntime({ req, sessionName, logPath, sessionStore, + inspectFacts, + bindDevice, }), diff: async ({ req, sessionName, logPath, sessionStore }) => { if (req.positionals?.[0] !== 'snapshot') { diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 0dfbc0492e..509696a941 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -166,6 +166,8 @@ async function runSnapshotHandler( sessionName: params.sessionName, logPath: params.logPath, sessionStore: params.sessionStore, + inspectFacts: params.inspectFacts, + bindDevice: params.bindDevice, }), ); } diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts new file mode 100644 index 0000000000..e36d9d673f --- /dev/null +++ b/src/daemon/snapshot-runtime-binding.ts @@ -0,0 +1,132 @@ +import { + captureSnapshotUse, + type BoundDeviceRuntime, + type CaptureSnapshotInput, +} from '@agent-device/contracts/platform'; +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import { buildIosOpenCommandHint } from './ios-app-session-hint.ts'; +import { contextFromFlags } from './context.ts'; +import { isIosSimulator } from './device-targets.ts'; +import { resolveSessionDevice } from './handlers/snapshot-session.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; +import type { SessionStore } from './session-store.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; +import { + requireRuntimeBinding, + requireRuntimeFacts, + unavailableRuntimeOperationResponse, +} from './handlers/session-runtime-admission.ts'; +import { errorResponse } from './handlers/response.ts'; + +type SnapshotCaptureAdmission = + | Readonly<{ admitted: false; response: DaemonResponse }> + | Readonly<{ + admitted: true; + session: SessionState | undefined; + device: SessionState['device']; + providerOwned: boolean; + }>; + +/** Facts-first admission for the one public snapshot runtime unit. */ +export async function inspectSnapshotCaptureAdmission(params: { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + inspectFacts?: InspectDeviceRuntimeFacts; +}): Promise { + const { session, device } = await resolveSessionDevice( + params.sessionStore, + params.sessionName, + params.req.flags, + ); + const facts = await requireRuntimeFacts(params.inspectFacts)(device); + const unsupported = unavailableRuntimeOperationResponse( + 'snapshot', + facts.operations.captureSnapshot, + ); + if (unsupported) return { admitted: false, response: unsupported }; + return { + admitted: true, + session, + device, + providerOwned: facts.device.providerMode === 'provider-runtime', + }; +} + +export async function bindSnapshotCaptureRuntime( + bindDevice: BindDeviceRuntime | undefined, + device: SessionState['device'], +): Promise> { + return await requireRuntimeBinding(bindDevice)(device, captureSnapshotUse); +} + +/** Flag/platform admission stays ahead of binding so invalid intent acquires no runtime. */ +export function requireSnapshotCustomActionsSupported( + req: DaemonRequest, + device: DeviceInfo, +): DaemonResponse | null { + if (req.flags?.snapshotCustomActions !== true || isIosSimulator(device)) return null; + return errorResponse( + 'UNSUPPORTED_OPERATION', + `--actions requires an iOS simulator: custom actions are read through the private accessibility snapshot backend, which ${device.platform}/${device.kind} targets do not have.`, + { hint: 'Re-run without --actions, or target an iOS simulator.' }, + ); +} + +export async function requireSnapshotIosAppSession(params: { + command: 'snapshot' | 'diff'; + session: SessionState | undefined; + device: SessionState['device']; + providerOwned: boolean; +}): Promise { + const { command, session, device, providerOwned } = params; + if (!isIosFamily(device) || session?.appBundleId || providerOwned) return null; + const openCommandHint = await buildIosOpenCommandHint(device); + return errorResponse( + 'SESSION_NOT_FOUND', + `iOS ${command} requires an active app session on the target device. Run open first (for example: open --session ${session?.name ?? 'sim'} --platform ios --device "" ).`, + { + reason: 'ios_app_session_required', + ...(openCommandHint ? { hint: openCommandHint } : {}), + }, + ); +} + +export function buildRuntimeCaptureInput( + params: Readonly<{ req: DaemonRequest; logPath: string }>, + session: SessionState | undefined, + snapshotScope: string | undefined, +): CaptureSnapshotInput { + const { req, logPath } = params; + const context = contextFromFlags( + logPath, + req.flags, + session?.appBundleId, + session?.trace?.outPath, + req.meta?.requestId, + req.meta, + ); + return { + options: { + appBundleId: session?.appBundleId, + interactiveOnly: req.flags?.snapshotInteractiveOnly, + preferredBackend: req.flags?.snapshotPreferredBackend, + depth: req.flags?.snapshotDepth, + scope: snapshotScope, + raw: req.flags?.snapshotRaw, + customActions: req.flags?.snapshotCustomActions, + includeHiddenContentHints: req.flags?.snapshotIncludeHiddenContentHints, + surface: session?.surface, + }, + execution: { + requestId: context.requestId, + verbose: context.verbose, + logPath: context.logPath, + traceLogPath: context.traceLogPath, + iosXctestrunFile: context.iosXctestrunFile, + iosXctestDerivedDataPath: context.iosXctestDerivedDataPath, + iosXctestEnvDir: context.iosXctestEnvDir, + runnerLeaseContext: context.runnerLeaseContext, + }, + }; +} diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index d2fb093b02..45266e50c9 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -3,23 +3,24 @@ import { summarizeSnapshotDiagnostics, type SnapshotDiffSummary, } from '@agent-device/contracts/capture'; -import { stripAndroidSystemChromeProvenance } from '@agent-device/contracts/platform'; -import { isIosFamily, publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; +import { + stripAndroidSystemChromeProvenance, + type SnapshotResult, +} from '@agent-device/contracts/platform'; +import { publicPlatformString } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { AgentDeviceBackend, BackendSnapshotResult } from '../backend.ts'; import type { CommandSessionRecord } from '../runtime.ts'; import { createAgentDevice } from '../runtime.ts'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; import { maybeBuildAndroidSnapshotTimeoutFailure } from './android-snapshot-timeout-evidence.ts'; -import { errorResponse, requireCommandSupported } from './handlers/response.ts'; -import { buildIosOpenCommandHint } from './ios-app-session-hint.ts'; +import { requireCommandSupported } from './handlers/response.ts'; import { captureSnapshot, resolveSnapshotScope } from './handlers/snapshot-capture.ts'; import { buildSnapshotSession, resolveSessionDevice, withSessionlessRunnerCleanup, } from './handlers/snapshot-session.ts'; -import { isIosSimulator } from './device-targets.ts'; import { activateCompleteRefFrame } from './ref-frame.ts'; import { applyRecoveredWarningLatch, @@ -29,62 +30,115 @@ import { createDaemonRuntimePolicy } from './runtime-policy.ts'; import { captureSparseFallbackScreenshot } from './sparse-fallback-screenshot.ts'; import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; import { getRequestSignal } from '../request/cancel.ts'; +import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; import { isInteractiveObservation } from './session-action-recorder.ts'; import { setSnapshotLineage } from './session-snapshot.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, DaemonResponseData, SessionState } from './types.ts'; +import { + bindSnapshotCaptureRuntime, + buildRuntimeCaptureInput, + inspectSnapshotCaptureAdmission, + requireSnapshotCustomActionsSupported, + requireSnapshotIosAppSession, +} from './snapshot-runtime-binding.ts'; export async function dispatchSnapshotViaRuntime(params: { req: DaemonRequest; sessionName: string; logPath: string; sessionStore: SessionStore; + inspectFacts?: InspectDeviceRuntimeFacts; + bindDevice?: BindDeviceRuntime; }): Promise { - return await dispatchSnapshotRuntimeCommand({ - ...params, + const { req } = params; + const admission = await inspectSnapshotCaptureAdmission(params); + if (!admission.admitted) return admission.response; + const { session, device } = admission; + + const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); + if (!resolvedScope.ok) return resolvedScope; + const customActionsGuard = requireSnapshotCustomActionsSupported(req, device); + if (customActionsGuard) return customActionsGuard; + + const iosAppSessionGuard = await requireSnapshotIosAppSession({ command: 'snapshot', - unsupportedMessage: 'snapshot is not supported on this device', - execute: async ({ runtime, sessionName, req, snapshotScope }) => { - const result = await runtime.capture.snapshot({ - session: sessionName, - interactiveOnly: req.flags?.snapshotInteractiveOnly, - depth: req.flags?.snapshotDepth, - scope: snapshotScope, - raw: req.flags?.snapshotRaw, - customActions: req.flags?.snapshotCustomActions, - forceFull: req.flags?.snapshotForceFull, - }); - const session = params.sessionStore.get(sessionName); - const refsGeneration = publishedSnapshotGeneration(req, session); - // ADR 0014: retain provenance in the immutable operational/ref-frame tree; - // project only the published copy so settle and replay keep the full evidence. - const publicNodes = stripAndroidSystemChromeProvenance(result.nodes); - const publicResult = - publicNodes === result.nodes ? result : { ...result, nodes: publicNodes }; - const fallbackScreenshot = await captureSparseFallbackScreenshot({ - req, + session, + device, + providerOwned: admission.providerOwned, + }); + if (iosAppSessionGuard) return iosAppSessionGuard; + + const runtime = await bindSnapshotCaptureRuntime(params.bindDevice, device); + return await withSessionlessRunnerCleanup( + session, + device, + async () => + await executeSnapshotRuntimeCommand({ + ...params, + command: 'snapshot', session, - sessionName, - logPath: params.logPath, - verdict: result.snapshotQuality, - }); - const published = fallbackScreenshot - ? { - ...publicResult, - fallbackScreenshotPath: fallbackScreenshot.path, - artifacts: [fallbackScreenshot.artifact], - } - : publicResult; - return { - data: refsGeneration === undefined ? published : { ...published, refsGeneration }, - record: { - kind: 'snapshot', - nodes: result.nodes.length, - truncated: result.truncated, + device, + snapshotScope: resolvedScope.scope, + captureSnapshotData: async () => + await runtime.operations.captureSnapshot( + buildRuntimeCaptureInput(params, session, resolvedScope.scope), + ), + execute: async ({ + runtime: agentRuntime, + sessionName: resolvedSessionName, + req: request, + snapshotScope, + }) => { + const result = await agentRuntime.capture.snapshot({ + session: resolvedSessionName, + interactiveOnly: request.flags?.snapshotInteractiveOnly, + depth: request.flags?.snapshotDepth, + scope: snapshotScope, + raw: request.flags?.snapshotRaw, + customActions: request.flags?.snapshotCustomActions, + forceFull: request.flags?.snapshotForceFull, + }); + // #1076 versioned refs: the snapshot response is a ref-issuing response, + // so it carries the stored tree's generation ONCE (`refsGeneration`) — + // the node tree itself stays plain `e12` refs (token economy). The + // capture above already stored the next session via setRecord, so the + // store holds the generation these refs were minted from. + const refsGeneration = publishedSnapshotGeneration( + request, + params.sessionStore.get(resolvedSessionName), + ); + // ADR 0014: retain provenance in the immutable operational/ref-frame tree; + // project only the published copy so settle and replay keep the full evidence. + const publicNodes = stripAndroidSystemChromeProvenance(result.nodes); + const publicResult = + publicNodes === result.nodes ? result : { ...result, nodes: publicNodes }; + const session = params.sessionStore.get(resolvedSessionName); + const fallbackScreenshot = await captureSparseFallbackScreenshot({ + req: request, + session, + sessionName: resolvedSessionName, + logPath: params.logPath, + verdict: result.snapshotQuality, + }); + const published = fallbackScreenshot + ? { + ...publicResult, + fallbackScreenshotPath: fallbackScreenshot.path, + artifacts: [fallbackScreenshot.artifact], + } + : publicResult; + return { + data: refsGeneration === undefined ? published : { ...published, refsGeneration }, + record: { + kind: 'snapshot', + nodes: result.nodes.length, + truncated: result.truncated, + }, + }; }, - }; - }, - }); + }), + ); } function publishedSnapshotGeneration( @@ -100,29 +154,54 @@ export async function dispatchSnapshotDiffViaRuntime(params: { logPath: string; sessionStore: SessionStore; }): Promise { - return await dispatchSnapshotRuntimeCommand({ - ...params, + const { req, sessionName, sessionStore } = params; + const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const unsupported = requireCommandSupported('diff', device, { + message: 'diff is not supported on this device', + }); + if (unsupported) return unsupported; + const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); + if (!resolvedScope.ok) return resolvedScope; + const customActionsGuard = requireSnapshotCustomActionsSupported(req, device); + if (customActionsGuard) return customActionsGuard; + const iosAppSessionGuard = await requireSnapshotIosAppSession({ command: 'diff', - unsupportedMessage: 'diff is not supported on this device', - execute: async ({ runtime, sessionName, req, snapshotScope }) => { - const result = await runtime.capture.diffSnapshot({ - session: sessionName, - interactiveOnly: req.flags?.snapshotInteractiveOnly, - depth: req.flags?.snapshotDepth, - scope: snapshotScope, - raw: req.flags?.snapshotRaw, - }); - return { - data: result, - record: { - kind: 'diff', - mode: 'snapshot', - baselineInitialized: result.baselineInitialized, - summary: result.summary, - }, - }; - }, + session, + device, + providerOwned: isActiveProviderDevice(device), }); + if (iosAppSessionGuard) return iosAppSessionGuard; + + return await withSessionlessRunnerCleanup( + session, + device, + async () => + await executeSnapshotRuntimeCommand({ + ...params, + command: 'diff', + session, + device, + snapshotScope: resolvedScope.scope, + execute: async ({ runtime, sessionName, req, snapshotScope }) => { + const result = await runtime.capture.diffSnapshot({ + session: sessionName, + interactiveOnly: req.flags?.snapshotInteractiveOnly, + depth: req.flags?.snapshotDepth, + scope: snapshotScope, + raw: req.flags?.snapshotRaw, + }); + return { + data: result, + record: { + kind: 'diff', + mode: 'snapshot', + baselineInitialized: result.baselineInitialized, + summary: result.summary, + }, + }; + }, + }), + ); } type SnapshotRuntimeCommandParams = { @@ -131,7 +210,10 @@ type SnapshotRuntimeCommandParams = { logPath: string; sessionStore: SessionStore; command: 'snapshot' | 'diff'; - unsupportedMessage: string; + session: SessionState | undefined; + device: SessionState['device']; + snapshotScope: string | undefined; + captureSnapshotData?: () => Promise; execute(params: { runtime: ReturnType; sessionName: string; @@ -149,130 +231,56 @@ type SnapshotRuntimeRecord = summary: SnapshotDiffSummary; }; -async function dispatchSnapshotRuntimeCommand( +async function executeSnapshotRuntimeCommand( params: SnapshotRuntimeCommandParams, ): Promise { - const { req, sessionName, logPath, sessionStore } = params; - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); - const unsupported = requireCommandSupported(params.command, device, { - message: params.unsupportedMessage, + const { req, sessionName, logPath, sessionStore, session, device } = params; + const capturedQuality: CapturedSnapshotQuality = {}; + const runtime = createSnapshotRuntime({ + req, + sessionName, + logPath, + sessionStore, + session, + device, + snapshotScope: params.snapshotScope, + capturedQuality, + captureSnapshotData: params.captureSnapshotData, }); - if (unsupported) return unsupported; - const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); - if (!resolvedScope.ok) return resolvedScope; - // Pure flags+device, so it answers before the session precondition: otherwise - // --actions on an iOS physical device costs a round trip ("run open first") - // before reporting that the flag is unserviceable there at all. - const customActionsGuard = requireCustomActionsSupported(req, device); - if (customActionsGuard) return customActionsGuard; - const iosAppSessionGuard = await requireIosAppSessionForSnapshot(params.command, session, device); - if (iosAppSessionGuard) return iosAppSessionGuard; - - return await withSessionlessRunnerCleanup(session, device, async () => { - const capturedQuality: CapturedSnapshotQuality = {}; - const runtime = createSnapshotRuntime({ - req, + let result: Awaited>; + try { + result = await params.execute({ + runtime, sessionName, + req, + snapshotScope: params.snapshotScope, + }); + } catch (error) { + const timeoutResponse = await maybeBuildAndroidSnapshotTimeoutFailure({ + error, + command: params.command, logPath, - sessionStore, session, device, - snapshotScope: resolvedScope.scope, - capturedQuality, }); - let result: Awaited>; - try { - result = await params.execute({ - runtime, - sessionName, - req, - snapshotScope: resolvedScope.scope, - }); - } catch (error) { - const timeoutResponse = await maybeBuildAndroidSnapshotTimeoutFailure({ - error, - command: params.command, - logPath, - session, - device, - }); - if (!timeoutResponse) throw error; - return timeoutResponse; - } - recordSnapshotRuntimeAction({ - req, - sessionName, - sessionStore, - result: result.record, - }); - return { - ok: true, - data: applyRecoveredWarningLatch({ - session: sessionStore.get(sessionName), - data: result.data, - verdict: capturedQuality.value, - internalObservation: req.internal?.observationOnly === true, - }), - }; - }); -} - -/** - * Custom actions come from the private-AX snapshot backend, which only exists - * on the iOS simulator. Every other target would accept the flag, take a normal - * capture, and return nodes with no `actions` — indistinguishable from "this - * screen has no custom actions". Refuse instead, naming the resolved target so - * the caller knows which half of the request was impossible. - */ -function requireCustomActionsSupported( - req: DaemonRequest, - device: DeviceInfo, -): DaemonResponse | null { - if (req.flags?.snapshotCustomActions !== true || isIosSimulator(device)) { - return null; + if (!timeoutResponse) throw error; + return timeoutResponse; } - return errorResponse( - 'UNSUPPORTED_OPERATION', - `--actions requires an iOS simulator: custom actions are read through the private accessibility snapshot backend, which ${device.platform}/${device.kind} targets do not have.`, - { - hint: 'Re-run without --actions, or target an iOS simulator.', - }, - ); -} - -/** - * The guard exists for the LOCAL Apple path: an iOS capture rides the XCUITest - * runner, which must attach to a target app, so a session with no app is a - * capture that cannot succeed. A provider-backed (cloud) device captures - * through the provider's own driver session instead — the page source needs no - * app identity, which is why every other command on that path works without - * one. Refusing there turned a healthy live session into an instant - * SESSION_NOT_FOUND with no device round trip at all (#1658). - * - * The provider check belongs in this early return rather than in the hint - * below: buildIosOpenCommandHint probes simctl, which can only ever see local - * simulators, so running it for a hosted device is a guaranteed-useless spawn. - */ -async function requireIosAppSessionForSnapshot( - command: 'snapshot' | 'diff', - session: SessionState | undefined, - device: SessionState['device'], -): Promise { - if (!isIosFamily(device) || session?.appBundleId || isActiveProviderDevice(device)) { - return null; - } - // An unambiguous environment (one booted simulator, one foreground app) - // gets the exact `open` command instead of the generic "run open first" - // nudge — cheap, error-path only, never guesses. - const openCommandHint = await buildIosOpenCommandHint(device); - return errorResponse( - 'SESSION_NOT_FOUND', - `iOS ${command} requires an active app session on the target device. Run open first (for example: open --session ${session?.name ?? 'sim'} --platform ios --device "" ).`, - { - reason: 'ios_app_session_required', - ...(openCommandHint ? { hint: openCommandHint } : {}), - }, - ); + recordSnapshotRuntimeAction({ + req, + sessionName, + sessionStore, + result: result.record, + }); + return { + ok: true, + data: applyRecoveredWarningLatch({ + session: sessionStore.get(sessionName), + data: result.data, + verdict: capturedQuality.value, + internalObservation: req.internal?.observationOnly === true, + }), + }; } function createSnapshotRuntime(params: { @@ -284,6 +292,7 @@ function createSnapshotRuntime(params: { device: SessionState['device']; snapshotScope: string | undefined; capturedQuality: CapturedSnapshotQuality; + captureSnapshotData?: () => Promise; }) { const { req, sessionName, logPath, sessionStore, session, device, snapshotScope } = params; return createAgentDevice({ @@ -294,6 +303,7 @@ function createSnapshotRuntime(params: { device, snapshotScope, capturedQuality: params.capturedQuality, + captureSnapshotData: params.captureSnapshotData, }), ...createDaemonRuntimePolicy('snapshot'), signal: getRequestSignal(req.meta?.requestId), @@ -403,6 +413,7 @@ function createDaemonSnapshotBackend(params: { device: SessionState['device']; snapshotScope: string | undefined; capturedQuality: CapturedSnapshotQuality; + captureSnapshotData?: () => Promise; }): AgentDeviceBackend { const { req, logPath, session, device, snapshotScope } = params; return { @@ -416,6 +427,7 @@ function createDaemonSnapshotBackend(params: { logPath, snapshotScope, signal: context.signal, + captureData: params.captureSnapshotData, }); const annotations = snapshotCaptureAnnotationsFrom(capture); // Feed the latch seam the capture's own verdict: the stored session diff --git a/src/platform-runtime-gateway.fixtures.ts b/src/platform-runtime-gateway.fixtures.ts index db8b3d1d00..6be191c90d 100644 --- a/src/platform-runtime-gateway.fixtures.ts +++ b/src/platform-runtime-gateway.fixtures.ts @@ -17,7 +17,7 @@ import { } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { LimrunRuntimeDependencies } from '@agent-device/provider-limrun'; -import { unavailableDeploymentAndShutdownOperationFacts } from './__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from './__tests__/test-utils/runtime-operation-facts.ts'; import { createComposedPlatformRuntimeGateway, type PlatformRuntimeProviderRegistration, @@ -132,7 +132,7 @@ function binding(options: { function unavailableFacts() { const unavailable = { available: false, reason: 'unsupported-provider-mode' } as const; return { - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, appLogInspect: unavailable, appLogDoctor: unavailable, appLogStart: unavailable, diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index 14df517aac..b57c7d184a 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -27,6 +27,7 @@ import { createAppleApplicationTools } from './platform-runtime-apple-applicatio import { createAndroidApplicationTools } from './platform-runtime-android-application-tools.ts'; import { createLocalApplicationInteractorHost } from './platform-runtime-local-application-interactors.ts'; import { createApplicationResourceLifecycle } from './platform-runtime-application-resources.ts'; +import { createSnapshotRuntimeHost } from './platform-runtime-snapshot-host.ts'; export function createPlatformRuntimeHost(options: { sessionsDir: string; @@ -104,6 +105,7 @@ export function createPlatformRuntimeHost(options: { options.shutdownLoaders, ), screenRecording: createScreenRecordingRuntimeHost(), + snapshot: createSnapshotRuntimeHost(), localInteractors: createLocalApplicationInteractorHost(), appleApplications, androidApplications, diff --git a/src/platform-runtime-snapshot-host.test.ts b/src/platform-runtime-snapshot-host.test.ts new file mode 100644 index 0000000000..2c71bbdca8 --- /dev/null +++ b/src/platform-runtime-snapshot-host.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +const { runMacOsSnapshotAction, snapshotLinux } = vi.hoisted(() => ({ + runMacOsSnapshotAction: vi.fn(), + snapshotLinux: vi.fn(), +})); + +vi.mock('./platforms/apple/os/macos/helper.ts', () => ({ runMacOsSnapshotAction })); +vi.mock('./platforms/linux/snapshot.ts', () => ({ snapshotLinux })); + +import { createSnapshotRuntimeHost } from './platform-runtime-snapshot-host.ts'; + +const macosDevice = { + id: 'desktop', + name: 'macOS Desktop', + platform: 'apple', + appleOs: 'macos', + kind: 'device', +} as DeviceInfo; + +const linuxDevice = { + id: 'linux-desktop', + name: 'Linux Desktop', + platform: 'linux', + kind: 'device', +} as DeviceInfo; + +beforeEach(() => { + runMacOsSnapshotAction.mockReset(); + snapshotLinux.mockReset(); +}); + +test('Apple snapshot host preserves non-app macOS surface capture and menubar identity', async () => { + runMacOsSnapshotAction.mockResolvedValue({ + nodes: [{ index: 0, depth: 0, type: 'MenuBar', label: 'System menu' }], + truncated: false, + }); + const signal = new AbortController().signal; + + const result = await createSnapshotRuntimeHost().apple.captureSurface( + macosDevice, + { surface: 'menubar', appBundleId: 'com.example.app' }, + signal, + ); + + expect(runMacOsSnapshotAction).toHaveBeenCalledWith('menubar', { + bundleId: 'com.example.app', + signal, + }); + expect(result).toEqual({ + nodes: [{ index: 0, depth: 0, type: 'MenuBar', label: 'System menu' }], + truncated: false, + backend: 'macos-helper', + }); +}); + +test('Linux snapshot host preserves interactive ancestor projection before depth filtering', async () => { + snapshotLinux.mockResolvedValue({ + truncated: false, + nodes: [ + { index: 0, depth: 0, type: 'Application', label: 'App' }, + { index: 1, parentIndex: 0, depth: 1, type: 'Group', label: 'Panel' }, + { index: 2, parentIndex: 1, depth: 2, type: 'Button', label: 'Continue', hittable: true }, + { index: 3, parentIndex: 1, depth: 2, type: 'StaticText', label: 'Details' }, + ], + }); + const signal = new AbortController().signal; + + const result = await createSnapshotRuntimeHost().linux.captureSurface( + linuxDevice, + { surface: 'desktop', interactiveOnly: true, depth: 1 }, + signal, + ); + + expect(snapshotLinux).toHaveBeenCalledWith('desktop', signal); + expect(result).toEqual({ + backend: 'linux-atspi', + truncated: false, + nodes: [ + { index: 0, depth: 0, type: 'Application', label: 'App', parentIndex: undefined }, + { + index: 1, + parentIndex: 0, + depth: 1, + type: 'Group', + label: 'Panel', + }, + ], + }); +}); diff --git a/src/platform-runtime-snapshot-host.ts b/src/platform-runtime-snapshot-host.ts new file mode 100644 index 0000000000..4dd78e63cb --- /dev/null +++ b/src/platform-runtime-snapshot-host.ts @@ -0,0 +1,43 @@ +import type { SnapshotRuntimeHost } from '@agent-device/contracts/platform'; +import { shapeDesktopSurfaceSnapshot } from './core/snapshot-desktop-surface.ts'; + +export function createSnapshotRuntimeHost(): SnapshotRuntimeHost { + return Object.freeze({ + apple: Object.freeze({ + captureSurface: captureAppleSurface, + }), + linux: Object.freeze({ + captureSurface: captureLinuxSurface, + }), + }); +} + +const captureAppleSurface: SnapshotRuntimeHost['apple']['captureSurface'] = async ( + _device, + options, + signal, +) => { + const surface = options?.surface; + if (!surface || surface === 'app') { + throw new TypeError('Apple surface capture requires a non-app macOS surface'); + } + const { runMacOsSnapshotAction } = await import('./platforms/apple/os/macos/helper.ts'); + const result = await runMacOsSnapshotAction(surface, { + bundleId: surface === 'menubar' ? options?.appBundleId : undefined, + signal, + }); + return shapeDesktopSurfaceSnapshot({ ...result, backend: 'macos-helper' }, options ?? {}); +}; + +const captureLinuxSurface: SnapshotRuntimeHost['linux']['captureSurface'] = async ( + _device, + options, + signal, +) => { + const { snapshotLinux } = await import('./platforms/linux/snapshot.ts'); + const result = await snapshotLinux(options?.surface, signal); + return shapeDesktopSurfaceSnapshot( + { nodes: result.nodes, truncated: result.truncated, backend: 'linux-atspi' }, + options ?? {}, + ); +}; diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index a40f0a14f5..de6f2d9696 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -9,7 +9,7 @@ import { type PlatformRuntimeOperations, type RuntimeFacts, } from '@agent-device/contracts/platform'; -import { unavailableDeploymentAndShutdownOperationFacts } from '../../../src/__tests__/test-utils/runtime-operation-facts.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../src/__tests__/test-utils/runtime-operation-facts.ts'; import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createTestAppLogLiveHandle } from '../../../src/__tests__/test-utils/app-log-live-handle.ts'; @@ -325,7 +325,7 @@ function recordingRuntimeFacts(device: DeviceInfo): RuntimeFacts runtime.getInteractor(request.device, runner), + }), deployApp: async (input: AppDeploymentInput): Promise => { const result = await runtime.installApp?.(request.device, input.app, input.appPath); if (result) return result; @@ -228,7 +234,8 @@ function providerScenarioRuntimeFacts( bootTarget: fakeProviderUnavailable, bootTargetHeadless: fakeProviderUnavailable, listApps: fakeProviderUnavailable, - ...unavailableDeploymentAndShutdownOperationFacts, + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, + captureSnapshot: fakeProviderAvailable, deployApp: runtime.installApp ? fakeProviderAvailable : fakeProviderUnavailable, ...applicationLifecycleOperationFacts({ resolveOpenTarget: fakeProviderAvailable, diff --git a/test/integration/provider-scenarios/web-desktop.test.ts b/test/integration/provider-scenarios/web-desktop.test.ts index bcf98d04e6..895f47ed2d 100644 --- a/test/integration/provider-scenarios/web-desktop.test.ts +++ b/test/integration/provider-scenarios/web-desktop.test.ts @@ -213,7 +213,7 @@ test('Provider-backed integration web desktop flow uses semantic web provider ca assertFlatToolCall(semanticCalls, ['web', 'open', WEB_URL, '']); assertFlatToolCall(semanticCalls, ['web', 'recordStart', recordingPath]); - assertFlatToolCall(semanticCalls, ['web', 'snapshot', 'true', '']); + assertFlatToolCall(semanticCalls, ['web', 'snapshot', 'true', 'app']); assertFlatToolCall(semanticCalls, ['web', 'hoverRef', '@e4']); assertFlatToolCall(semanticCalls, ['web', 'clickRef', '@e4']); assertFlatToolCall(semanticCalls, ['web', 'fillRef', '@e3', 'qa@example.test', '1']); From 9d09ac93020b9e36055ce316e30449918fb14b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 17 Aug 2026 10:48:22 +0200 Subject: [PATCH 2/9] refactor: complete snapshot runtime policy cutover --- packages/contracts/src/facades/platform.ts | 11 +- .../src/platform-runtime-operations.test.ts | 43 ++++++ .../src/platform-runtime-operations.ts | 69 +++++++++ .../src/platform-runtime-unavailable.ts | 7 +- packages/contracts/src/snapshot-runtime.ts | 84 ++++++----- packages/platform-android/src/runtime.test.ts | 2 + packages/platform-android/src/runtime.ts | 12 +- .../platform-apple/src/runtime-snapshot.ts | 32 +++-- packages/platform-apple/src/runtime.test.ts | 11 +- packages/platform-apple/src/runtime.ts | 25 +++- .../platform-harmonyos/src/runtime.test.ts | 3 + packages/platform-harmonyos/src/runtime.ts | 21 ++- packages/platform-linux/src/runtime.test.ts | 9 +- packages/platform-linux/src/runtime.ts | 33 +++-- packages/platform-web/src/runtime.test.ts | 2 + packages/platform-web/src/runtime.ts | 12 +- .../src/app-log-runtime.test.ts | 6 + .../provider-limrun/src/app-log-runtime.ts | 23 ++- .../src/platform-runtime.test.ts | 4 + .../src/platform-runtime.ts | 33 ++++- .../runtime-command-cutover-extensions.ts | 39 +++++ .../runtime-command-cutover-table.test.ts | 36 ++++- .../layering/runtime-command-cutover-table.ts | 23 ++- .../test-utils/runtime-operation-facts.ts | 7 +- .../snapshot-runtime-execution.test.ts | 29 ++-- src/core/command-descriptor/registry.ts | 4 +- src/core/interactors/linux.ts | 14 +- src/core/snapshot-node-lookup.ts | 13 +- .../request-router-lock-policy.test.ts | 4 +- .../__tests__/snapshot-runtime-fixture.ts | 31 +++- .../handlers/__tests__/install-source.test.ts | 7 +- .../session-capabilities.fixtures.ts | 7 +- .../__tests__/session-command-harness.ts | 7 +- .../__tests__/snapshot-handler.test.ts | 19 +++ src/daemon/handlers/snapshot-capture.ts | 31 +--- src/daemon/snapshot-diff-legacy-admission.ts | 36 +++++ src/daemon/snapshot-runtime-binding.ts | 133 ++++++++++++------ src/daemon/snapshot-runtime.ts | 34 ++--- src/platform-runtime-snapshot-host.test.ts | 4 +- src/platform-runtime-snapshot-host.ts | 55 ++++---- src/platforms/apple/interactor.ts | 104 +++++++++----- .../snapshot-desktop-surface.ts | 2 +- src/snapshot/snapshot-node-label.ts | 13 ++ 43 files changed, 808 insertions(+), 286 deletions(-) create mode 100644 src/daemon/snapshot-diff-legacy-admission.ts rename src/{core => snapshot}/snapshot-desktop-surface.ts (98%) create mode 100644 src/snapshot/snapshot-node-label.ts diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index 9093a63e91..9bac268463 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -209,7 +209,14 @@ export type { InstalledAppInfo, ListAppsInput, } from '../app-inventory-runtime.ts'; -export { appsRuntimeUse, captureSnapshotUse, defineUse } from '../platform-runtime-operations.ts'; +export { + appsRuntimeUse, + captureSnapshotUse, + defineUse, + resolveSnapshotRuntimePlan, + snapshotRuntimePlanUses, +} from '../platform-runtime-operations.ts'; +export type { SnapshotRuntimePlan } from '../platform-runtime-operations.ts'; export type { PlatformRuntimeHost, PlatformRuntimeModule, @@ -230,6 +237,7 @@ export type { DeviceReadinessRuntimePlan } from '../platform-runtime-operations. export { bindLocalSnapshotInteractor, bindProviderSnapshotInteractor, + snapshotRuntimeOperationFacts, } from '../snapshot-runtime.ts'; export type { CaptureSnapshotInput, @@ -238,6 +246,7 @@ export type { SnapshotRuntimeExecution, SnapshotRuntimeHost, SnapshotRuntimeOperations, + SnapshotRuntimeOperationFacts, SnapshotResult, } from '../snapshot-runtime.ts'; export type { diff --git a/packages/contracts/src/platform-runtime-operations.test.ts b/packages/contracts/src/platform-runtime-operations.test.ts index 54a64285db..7a3a8710f1 100644 --- a/packages/contracts/src/platform-runtime-operations.test.ts +++ b/packages/contracts/src/platform-runtime-operations.test.ts @@ -4,7 +4,9 @@ import { localRuntimeOwner, providerRuntimeOwner } from './platform-runtime.ts'; import { bootTargetHeadlessUse, bootTargetUse, + captureSnapshotUse, resolveDeviceReadinessRuntimePlan, + resolveSnapshotRuntimePlan, type PlatformRuntimeProviderModule, } from './platform-runtime-operations.ts'; @@ -50,3 +52,44 @@ test.each([ }); }, ); + +test.each([ + [false, true, 'active-app', 'captureSnapshot', captureSnapshotUse], + [ + true, + true, + 'custom-actions-active-app', + 'captureSnapshotWithCustomActions', + { required: ['captureSnapshot', 'captureSnapshotWithCustomActions'], preferred: [] }, + ], + [ + false, + false, + 'without-active-app', + 'captureSnapshotWithoutActiveApp', + { required: ['captureSnapshot', 'captureSnapshotWithoutActiveApp'], preferred: [] }, + ], + [ + true, + false, + 'custom-actions-without-active-app', + 'captureSnapshotWithCustomActions', + { + required: [ + 'captureSnapshot', + 'captureSnapshotWithCustomActions', + 'captureSnapshotWithoutActiveApp', + ], + preferred: [], + }, + ], +] as const)( + 'normalizes snapshot customActions=%s activeApp=%s into %s', + (customActions, hasActiveApp, kind, operation, use) => { + assert.deepEqual(resolveSnapshotRuntimePlan({ customActions, hasActiveApp }), { + kind, + operation, + use, + }); + }, +); diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 24d8ce718e..01a7366c09 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -61,6 +61,75 @@ export const bootTargetHeadlessUse = defineUse({ }); export const appsRuntimeUse = defineUse({ required: ['ensureReady', 'listApps'] }); export const captureSnapshotUse = defineUse({ required: ['captureSnapshot'] }); +const captureSnapshotWithCustomActionsUse = defineUse({ + required: ['captureSnapshot', 'captureSnapshotWithCustomActions'], +}); +const captureSnapshotWithoutActiveAppUse = defineUse({ + required: ['captureSnapshot', 'captureSnapshotWithoutActiveApp'], +}); +const captureSnapshotWithCustomActionsWithoutActiveAppUse = defineUse({ + required: [ + 'captureSnapshot', + 'captureSnapshotWithCustomActions', + 'captureSnapshotWithoutActiveApp', + ], +}); + +export const snapshotRuntimePlanUses = Object.freeze([ + captureSnapshotUse, + captureSnapshotWithCustomActionsUse, + captureSnapshotWithoutActiveAppUse, + captureSnapshotWithCustomActionsWithoutActiveAppUse, +] as const); + +export type SnapshotRuntimePlan = + | Readonly<{ + kind: 'active-app'; + operation: 'captureSnapshot'; + use: typeof captureSnapshotUse; + }> + | Readonly<{ + kind: 'custom-actions-active-app'; + operation: 'captureSnapshotWithCustomActions'; + use: typeof captureSnapshotWithCustomActionsUse; + }> + | Readonly<{ + kind: 'custom-actions-without-active-app'; + operation: 'captureSnapshotWithCustomActions'; + use: typeof captureSnapshotWithCustomActionsWithoutActiveAppUse; + }> + | Readonly<{ + kind: 'without-active-app'; + operation: 'captureSnapshotWithoutActiveApp'; + use: typeof captureSnapshotWithoutActiveAppUse; + }>; + +/** Selects one owner-fact-backed capture plan from normalized command/session intent. */ +export function resolveSnapshotRuntimePlan(input: { + customActions: boolean; + hasActiveApp: boolean; +}): SnapshotRuntimePlan { + if (input.customActions) { + return input.hasActiveApp + ? Object.freeze({ + kind: 'custom-actions-active-app', + operation: 'captureSnapshotWithCustomActions', + use: captureSnapshotWithCustomActionsUse, + }) + : Object.freeze({ + kind: 'custom-actions-without-active-app', + operation: 'captureSnapshotWithCustomActions', + use: captureSnapshotWithCustomActionsWithoutActiveAppUse, + }); + } + return input.hasActiveApp + ? Object.freeze({ kind: 'active-app', operation: 'captureSnapshot', use: captureSnapshotUse }) + : Object.freeze({ + kind: 'without-active-app', + operation: 'captureSnapshotWithoutActiveApp', + use: captureSnapshotWithoutActiveAppUse, + }); +} export const deviceBootRuntimeUses = Object.freeze([bootTargetUse, bootTargetHeadlessUse] as const); export type DeviceReadinessRuntimePlan = diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index aef1b336a7..210b8ef456 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -10,6 +10,7 @@ import type { RuntimeOperationUnavailability, RuntimeOwnerRef, } from './platform-runtime.ts'; +import { snapshotRuntimeOperationFacts } from './snapshot-runtime.ts'; /** * A runtime-contract helper for provider ownership gaps. It never assigns lifecycle semantics: @@ -93,7 +94,11 @@ export function createUnavailablePlatformRuntimeFacts( screenRecordingStart: screenRecording, screenRecordingReattach: screenRecording, screenRecordingCleanup: screenRecording, - captureSnapshot: snapshot, + ...snapshotRuntimeOperationFacts({ + capture: snapshot, + customActions: snapshot, + withoutActiveApp: snapshot, + }), ensureReady: readiness, bootTarget: readiness, bootTargetHeadless: readiness, diff --git a/packages/contracts/src/snapshot-runtime.ts b/packages/contracts/src/snapshot-runtime.ts index ab1adb7ef6..1ddbdb2e03 100644 --- a/packages/contracts/src/snapshot-runtime.ts +++ b/packages/contracts/src/snapshot-runtime.ts @@ -6,6 +6,7 @@ import type { SnapshotOptions, SnapshotResult, } from './interactor-types.ts'; +import type { RuntimeOperationFact } from './platform-runtime.ts'; export type { SnapshotResult } from './interactor-types.ts'; @@ -20,24 +21,38 @@ export type CaptureSnapshotInput = Readonly<{ export type SnapshotRuntimeOperations = Readonly<{ captureSnapshot(input: CaptureSnapshotInput): Promise; + captureSnapshotWithCustomActions(input: CaptureSnapshotInput): Promise; + captureSnapshotWithoutActiveApp(input: CaptureSnapshotInput): Promise; }>; +export type SnapshotRuntimeOperationFacts = Readonly<{ + captureSnapshot: RuntimeOperationFact; + captureSnapshotWithCustomActions: RuntimeOperationFact; + captureSnapshotWithoutActiveApp: RuntimeOperationFact; +}>; + +/** Builds the exhaustive owner claims for the three composable snapshot requirements. */ +export function snapshotRuntimeOperationFacts( + input: Readonly<{ + capture: RuntimeOperationFact; + customActions: RuntimeOperationFact; + withoutActiveApp: RuntimeOperationFact; + }>, +): SnapshotRuntimeOperationFacts { + return Object.freeze({ + captureSnapshot: input.capture, + captureSnapshotWithCustomActions: input.customActions, + captureSnapshotWithoutActiveApp: input.withoutActiveApp, + }); +} + /** Existing desktop-surface mechanics injected without exposing daemon/session ownership. */ export type SnapshotRuntimeHost = Readonly<{ - apple: Readonly<{ - captureSurface( - device: DeviceInfo, - options: CaptureSnapshotInput['options'], - signal: AbortSignal, - ): Promise; - }>; - linux: Readonly<{ - captureSurface( - device: DeviceInfo, - options: CaptureSnapshotInput['options'], - signal: AbortSignal, - ): Promise; - }>; + captureSurface( + device: DeviceInfo, + options: CaptureSnapshotInput['options'], + signal: AbortSignal, + ): Promise; }>; export type LocalSnapshotInteractorResolver = ( @@ -65,26 +80,29 @@ type SnapshotInteractorBindingParams = function bindSnapshotInteractor( params: SnapshotInteractorBindingParams, ): SnapshotRuntimeOperations { + const captureSnapshot = async (input: CaptureSnapshotInput) => { + const runner: RunnerContext = { + ...input.execution, + appBundleId: input.options?.appBundleId, + signal: params.signal, + }; + const interactor = + params.ownership === 'local' + ? await params.resolveInteractor(params.device, runner) + : params.resolveInteractor(runner); + if (!interactor) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'Provider-owned snapshot operation has no bound provider interactor.', + { reason: 'provider-runtime-interactor-missing', deviceId: params.device.id }, + ); + } + return await interactor.snapshot({ ...input.options, signal: params.signal }); + }; return Object.freeze({ - captureSnapshot: async (input) => { - const runner: RunnerContext = { - ...input.execution, - appBundleId: input.options?.appBundleId, - signal: params.signal, - }; - const interactor = - params.ownership === 'local' - ? await params.resolveInteractor(params.device, runner) - : params.resolveInteractor(runner); - if (!interactor) { - throw new AppError( - 'UNSUPPORTED_OPERATION', - 'Provider-owned snapshot operation has no bound provider interactor.', - { reason: 'provider-runtime-interactor-missing', deviceId: params.device.id }, - ); - } - return await interactor.snapshot({ ...input.options, signal: params.signal }); - }, + captureSnapshot, + captureSnapshotWithCustomActions: captureSnapshot, + captureSnapshotWithoutActiveApp: captureSnapshot, }); } diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 06bfef17ff..6821a8f434 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -94,6 +94,8 @@ test.each([ expect(facts.operations.bootTarget).toEqual({ available: true }); expect(facts.operations.bootTargetHeadless.available).toBe(runtimeDevice.kind === 'emulator'); expect(facts.operations.captureSnapshot).toEqual({ available: true }); + expect(facts.operations.captureSnapshotWithCustomActions.available).toBe(false); + expect(facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true }); expect(binding.operations.captureSnapshot).toBeTypeOf('function'); await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({ diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 5302effb85..6e09092aa3 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -11,6 +11,7 @@ import { availableApplicationLifecycleOperations, bindLocalSnapshotInteractor, localRuntimeOwner, + snapshotRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createAndroidAppLogRuntime } from './logs/runtime.ts'; @@ -71,6 +72,11 @@ const snapshotKindUnavailable = Object.freeze({ reason: 'unsupported-device-kind', hint: 'snapshot is supported only for Android emulators and devices.', } as const); +const snapshotCustomActionsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'Re-run without --actions, or target an iOS simulator.', +} as const); function androidLifecycleFacts(device: DeviceInfo) { const openTarget = androidOpenTargetFact(device); @@ -120,7 +126,11 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor screenRecordingStart: available, screenRecordingReattach: available, screenRecordingCleanup: available, - captureSnapshot: device.kind === 'simulator' ? snapshotKindUnavailable : available, + ...snapshotRuntimeOperationFacts({ + capture: device.kind === 'simulator' ? snapshotKindUnavailable : available, + customActions: snapshotCustomActionsUnavailable, + withoutActiveApp: device.kind === 'simulator' ? snapshotKindUnavailable : available, + }), ensureReady: available, bootTarget: available, bootTargetHeadless: device.kind === 'emulator' ? available : headlessUnavailable, diff --git a/packages/platform-apple/src/runtime-snapshot.ts b/packages/platform-apple/src/runtime-snapshot.ts index 4dd9ba0111..fea587a39c 100644 --- a/packages/platform-apple/src/runtime-snapshot.ts +++ b/packages/platform-apple/src/runtime-snapshot.ts @@ -16,22 +16,24 @@ export function bindAppleSnapshotRuntime( signal: request.signal, resolveInteractor: host.localInteractors.resolve, }); + const captureSnapshot = async (input: CaptureSnapshotInput) => { + if ( + isMacOs(request.device) && + input.options?.surface !== undefined && + input.options.surface !== 'app' + ) { + return await host.snapshot.captureSurface(request.device, input.options, request.signal); + } + return await appSnapshot.captureSnapshot(input); + }; return Object.freeze({ - captureSnapshot: async (input: CaptureSnapshotInput) => { - if ( - isMacOs(request.device) && - input.options?.surface !== undefined && - input.options.surface !== 'app' - ) { - return await host.snapshot.apple.captureSurface( - request.device, - input.options, - request.signal, - ); - } - return await appSnapshot.captureSnapshot(input); - }, + captureSnapshot, + captureSnapshotWithCustomActions: captureSnapshot, + captureSnapshotWithoutActiveApp: captureSnapshot, }); } -type SnapshotRuntimeOperation = Pick; +type SnapshotRuntimeOperation = Pick< + PlatformRuntimeOperations, + 'captureSnapshot' | 'captureSnapshotWithCustomActions' | 'captureSnapshotWithoutActiveApp' +>; diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index 743f116398..6785c7dc21 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -107,6 +107,12 @@ function expectAppleSnapshotAvailability( ): void { const available = device.appleOs !== 'watchos'; expect(binding.facts.operations.captureSnapshot.available).toBe(available); + expect(binding.facts.operations.captureSnapshotWithCustomActions.available).toBe( + device.appleOs !== 'macos' && device.appleOs !== 'watchos' && device.kind === 'simulator', + ); + expect(binding.facts.operations.captureSnapshotWithoutActiveApp.available).toBe( + device.appleOs === 'macos', + ); expect(binding.operations.captureSnapshot).toBeTypeOf(available ? 'function' : 'undefined'); } @@ -123,10 +129,7 @@ test.each(['frontmost-app', 'desktop', 'menubar'] as const)( const binding = await createApplePlatformRuntime({ ...host, localInteractors: { resolve }, - snapshot: { - ...host.snapshot, - apple: { captureSurface }, - }, + snapshot: { captureSurface }, }).bind({ device: leaves.macos, intent: { kind: 'ordinary' }, diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 68821afc89..833637b41d 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -9,6 +9,7 @@ import { applicationLifecycleOperationFacts, availableApplicationLifecycleOperations, localRuntimeOwner, + snapshotRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import { isIosFamily, @@ -89,6 +90,16 @@ const shutdownKindUnavailable = Object.freeze({ const snapshotKindUnavailable = unsupportedAppleDeviceKind( 'snapshot is supported only for Apple simulators and devices.', ); +const snapshotCustomActionsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'Re-run without --actions, or target an iOS simulator.', +} as const); +const snapshotActiveAppRequired = Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: 'Open the app under test before capturing its snapshot.', +} as const); function unsupportedAppleDeviceKind(hint: string) { return Object.freeze({ available: false, reason: 'unsupported-device-kind', hint } as const); @@ -194,7 +205,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR screenRecordingStart: recordingFacts, screenRecordingReattach: recordingFacts, screenRecordingCleanup: recordingFacts, - captureSnapshot: appleSnapshotFact(device), + ...appleSnapshotFacts(device), ensureReady: readiness, bootTarget: boot, bootTargetHeadless: headlessUnavailable, @@ -292,3 +303,15 @@ function appleSnapshotFact(device: DeviceInfo) { ? available : snapshotKindUnavailable; } + +function appleSnapshotFacts(device: DeviceInfo) { + const capture = appleSnapshotFact(device); + return snapshotRuntimeOperationFacts({ + capture, + customActions: + capture.available && isIosFamily(device) && device.kind === 'simulator' + ? available + : snapshotCustomActionsUnavailable, + withoutActiveApp: isIosFamily(device) ? snapshotActiveAppRequired : capture, + }); +} diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index aae85918e9..880fd74a9f 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -65,6 +65,9 @@ test.each([ expect(facts.operations.bootTarget).toMatchObject({ available: false }); expect(facts.operations.bootTargetHeadless).toMatchObject({ available: false }); expect(facts.operations.listApps).toEqual({ available: true }); + expect(facts.operations.captureSnapshot).toEqual({ available: true }); + expect(facts.operations.captureSnapshotWithCustomActions.available).toBe(false); + expect(facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true }); await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({ booted: true }); await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index a0967e6246..766db07817 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -9,6 +9,7 @@ import { availableApplicationLifecycleOperations, bindLocalSnapshotInteractor, localRuntimeOwner, + snapshotRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createHarmonyAppLogRuntime } from './logs/runtime.ts'; @@ -61,6 +62,11 @@ const snapshotKindUnavailable = Object.freeze({ reason: 'unsupported-device-kind', hint: 'snapshot is supported only for HarmonyOS emulators and devices.', } as const); +const snapshotCustomActionsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'Re-run without --actions, or target an iOS simulator.', +} as const); function harmonyLifecycleFacts(device: DeviceInfo) { const openTarget = harmonyOpenTargetFact(device); @@ -106,10 +112,17 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor screenRecordingStart: recordingFacts, screenRecordingReattach: recordingFacts, screenRecordingCleanup: recordingFacts, - captureSnapshot: - device.kind === 'emulator' || device.kind === 'device' - ? available - : snapshotKindUnavailable, + ...snapshotRuntimeOperationFacts({ + capture: + device.kind === 'emulator' || device.kind === 'device' + ? available + : snapshotKindUnavailable, + customActions: snapshotCustomActionsUnavailable, + withoutActiveApp: + device.kind === 'emulator' || device.kind === 'device' + ? available + : snapshotKindUnavailable, + }), ensureReady: available, bootTarget: unavailable, bootTargetHeadless: unavailable, diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index 6569272fb8..30368f5447 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -100,6 +100,10 @@ test.each([ expect(binding.facts.operations.appState).toMatchObject({ available: false }); expect(binding.facts.operations.listApps).toMatchObject({ available: false }); expect(binding.facts.operations.captureSnapshot.available).toBe(device.kind === 'device'); + expect(binding.facts.operations.captureSnapshotWithCustomActions.available).toBe(false); + expect(binding.facts.operations.captureSnapshotWithoutActiveApp.available).toBe( + device.kind === 'device', + ); expect(binding.operations.captureSnapshot).toBeTypeOf( device.kind === 'device' ? 'function' : 'undefined', ); @@ -126,10 +130,7 @@ function lifecycleHost( ): PlatformRuntimeHost { return { localInteractors: { resolve: async () => ({}) }, - snapshot: { - apple: { captureSurface: async () => ({ backend: 'macos-helper', nodes: [] }) }, - linux: { captureSurface }, - }, + snapshot: { captureSurface }, } as unknown as PlatformRuntimeHost; } diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index d0c9d8e6b2..5919a83cd9 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -13,6 +13,7 @@ import { createUnavailablePlatformRuntimeFacts, localRuntimeOwner, sameRuntimeOwner, + snapshotRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; @@ -45,6 +46,10 @@ const snapshotKindUnavailable = unavailableLinuxRuntimeFact( 'unsupported-device-kind', 'snapshot is supported only for the Linux desktop device.', ); +const snapshotCustomActionsUnavailable = unavailableLinuxRuntimeFact( + 'unsupported-platform-leaf', + 'Re-run without --actions, or target an iOS simulator.', +); export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { return Object.freeze({ owner: linuxOwner, @@ -73,14 +78,7 @@ export function createLinuxPlatformRuntime(host: PlatformRuntimeHost): PlatformR operations: Object.freeze({ ...availableApplicationLifecycleOperations(lifecycle, facts.operations), ...(facts.operations.captureSnapshot.available - ? { - captureSnapshot: async (input: CaptureSnapshotInput) => - await host.snapshot.linux.captureSurface( - request.device, - input.options, - request.scope.signal, - ), - } + ? linuxSnapshotOperations(host, request) : {}), }), [Symbol.asyncDispose]: async () => undefined, @@ -114,11 +112,28 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts device: unavailable.device, operations: { ...unavailable.operations, - captureSnapshot: device.kind === 'device' ? supported : snapshotKindUnavailable, + ...snapshotRuntimeOperationFacts({ + capture: device.kind === 'device' ? supported : snapshotKindUnavailable, + customActions: snapshotCustomActionsUnavailable, + withoutActiveApp: device.kind === 'device' ? supported : snapshotKindUnavailable, + }), }, }); } +function linuxSnapshotOperations( + host: PlatformRuntimeHost, + request: Parameters[0], +) { + const captureSnapshot = async (input: CaptureSnapshotInput) => + await host.snapshot.captureSurface(request.device, input.options, request.scope.signal); + return Object.freeze({ + captureSnapshot, + captureSnapshotWithCustomActions: captureSnapshot, + captureSnapshotWithoutActiveApp: captureSnapshot, + }); +} + function unavailableLinuxRuntimeFact( reason: RuntimeOperationUnavailability['reason'], hint?: string, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index 2c969528f2..f9d121ba25 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -48,6 +48,8 @@ test('preserves a narrow web provider dump including empty successful entries', expect(binding.facts.operations.bootTargetHeadless).toMatchObject({ available: false }); expect(binding.facts.operations.listApps).toMatchObject({ available: false }); expect(binding.facts.operations.captureSnapshot).toEqual({ available: true }); + expect(binding.facts.operations.captureSnapshotWithCustomActions.available).toBe(false); + expect(binding.facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true }); expect(binding.operations.captureSnapshot).toBeTypeOf('function'); expectLifecycleFacts(binding); }); diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index e5cc499692..59efc16906 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -10,6 +10,7 @@ import { availableApplicationLifecycleOperations, bindLocalSnapshotInteractor, localRuntimeOwner, + snapshotRuntimeOperationFacts, sameRuntimeOwner, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -28,6 +29,11 @@ const recordingUnavailable = Object.freeze({ reason: 'owner-capability-missing', hint: 'record is not supported by this web provider', } as const); +const snapshotCustomActionsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'Re-run without --actions, or target an iOS simulator.', +} as const); const readinessUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', @@ -203,7 +209,11 @@ function webRuntimeFacts( screenRecordingStart: recordingAvailable ? available : recordingUnavailable, screenRecordingReattach: recordingAvailable ? available : recordingUnavailable, screenRecordingCleanup: recordingAvailable ? available : recordingUnavailable, - captureSnapshot: device.kind === 'device' ? available : openTargetKindUnavailable, + ...snapshotRuntimeOperationFacts({ + capture: device.kind === 'device' ? available : openTargetKindUnavailable, + customActions: snapshotCustomActionsUnavailable, + withoutActiveApp: device.kind === 'device' ? available : openTargetKindUnavailable, + }), ensureReady: readinessUnavailable, bootTarget: readinessUnavailable, bootTargetHeadless: readinessUnavailable, diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index 28be0be348..5c82703146 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -275,6 +275,10 @@ test.each([ expect(binding.facts.operations.bootTargetHeadless).toMatchObject({ available: false }); expect(binding.facts.operations.listApps).toEqual({ available: true }); expect(binding.facts.operations.captureSnapshot).toEqual({ available: true }); + expect(binding.facts.operations.captureSnapshotWithCustomActions.available).toBe( + runtimeDevice.platform === 'apple', + ); + expect(binding.facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true }); expect(binding.operations.captureSnapshot).toBeTypeOf('function'); await expect( binding.operations.listApps?.({ device: runtimeDevice, filter: 'all' }), @@ -330,6 +334,8 @@ test('fails closed for a stale Android identity before exposing facts or binding available: false, reason: 'owner-capability-missing', }); + expect(facts.operations.captureSnapshotWithCustomActions).toMatchObject({ available: false }); + expect(facts.operations.captureSnapshotWithoutActiveApp).toMatchObject({ available: false }); await expect( owner.bind({ device: staleDevice, intent: { kind: 'ordinary' }, scope }), ).rejects.toMatchObject({ diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index e72c31257e..96eb5fe272 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -1,4 +1,4 @@ -import type { DeviceInfo } from '@agent-device/kernel/device'; +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import type { AppsFilter, ProviderPortReverseOptions } from '@agent-device/contracts/device'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interaction'; import { AppError } from '@agent-device/kernel/errors'; @@ -25,6 +25,7 @@ import { createUnavailablePlatformRuntimeFacts, providerRuntimeOwner, sameRuntimeOwner, + snapshotRuntimeOperationFacts, } from '@agent-device/contracts/platform'; import { createLimrunAppLogEnvelope, @@ -78,6 +79,11 @@ function deploymentOptions( } const available = Object.freeze({ available: true } as const); +const customSnapshotUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'Custom snapshot actions are available only for Limrun iOS simulator sessions.', +} as const); const recordingUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -411,7 +417,14 @@ function facts( screenRecordingStart: recordingUnavailable, screenRecordingReattach: recordingUnavailable, screenRecordingCleanup: recordingUnavailable, - captureSnapshot: available, + ...snapshotRuntimeOperationFacts({ + capture: available, + customActions: + isIosFamily(device) && device.kind === 'simulator' + ? available + : customSnapshotUnavailable, + withoutActiveApp: available, + }), ensureReady: available, bootTarget: available, bootTargetHeadless: headlessUnavailable, @@ -445,7 +458,11 @@ function recoveryFacts( screenRecordingStart: liveSessionUnavailable, screenRecordingReattach: liveSessionUnavailable, screenRecordingCleanup: liveSessionUnavailable, - captureSnapshot: liveSessionUnavailable, + ...snapshotRuntimeOperationFacts({ + capture: liveSessionUnavailable, + customActions: liveSessionUnavailable, + withoutActiveApp: liveSessionUnavailable, + }), ensureReady: liveSessionUnavailable, bootTarget: liveSessionUnavailable, bootTargetHeadless: liveSessionUnavailable, diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 8b47e5b102..43d22df10f 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -233,6 +233,8 @@ test('captures through only the active exact WebDriver interactor', async () => }); expect(binding.facts.operations.captureSnapshot).toEqual({ available: true }); + expect(binding.facts.operations.captureSnapshotWithCustomActions.available).toBe(false); + expect(binding.facts.operations.captureSnapshotWithoutActiveApp).toEqual({ available: true }); await expect( binding.operations.captureSnapshot?.({ options: { interactiveOnly: true } }), ).resolves.toEqual({ backend: 'android', nodes: [] }); @@ -257,6 +259,8 @@ test.each([ }); const facts = await owner.inspectFacts(device); expect(facts.operations.captureSnapshot.available).toBe(false); + expect(facts.operations.captureSnapshotWithCustomActions.available).toBe(false); + expect(facts.operations.captureSnapshotWithoutActiveApp.available).toBe(false); if (state.isSessionActive()) { const binding = await owner.bind({ device, diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 95eddb81f9..3ec02c0090 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -4,6 +4,7 @@ import { bindProviderSnapshotInteractor, createUnavailablePlatformRuntimeFacts, sameRuntimeOwner, + snapshotRuntimeOperationFacts, type AppDeploymentInput, type DeployMaterializedAppInput, type DeviceBinding, @@ -67,6 +68,11 @@ const snapshotUnavailable = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'This WebDriver provider runtime does not expose snapshot capture for this device.', } as const); +const snapshotCustomActionsUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', + hint: 'WebDriver provider runtimes do not expose iOS simulator custom snapshot actions.', +} as const); const appStateUnavailable = Object.freeze({ available: false, @@ -280,7 +286,11 @@ function webDriverFacts( screenRecordingStart: inactiveSession, screenRecordingReattach: inactiveSession, screenRecordingCleanup: inactiveSession, - captureSnapshot: inactiveSession, + ...snapshotRuntimeOperationFacts({ + capture: inactiveSession, + customActions: inactiveSession, + withoutActiveApp: inactiveSession, + }), ensureReady: inactiveSession, bootTarget: inactiveSession, bootTargetHeadless: inactiveSession, @@ -325,12 +335,21 @@ function webDriverFacts( screenRecordingStart: recordingUnavailable, screenRecordingReattach: recordingUnavailable, screenRecordingCleanup: recordingUnavailable, - captureSnapshot: - options.snapshotAvailable !== false && - options.getInteractor !== undefined && - webDriverSnapshotDevice(device) - ? available - : snapshotUnavailable, + ...snapshotRuntimeOperationFacts({ + capture: + options.snapshotAvailable !== false && + options.getInteractor !== undefined && + webDriverSnapshotDevice(device) + ? available + : snapshotUnavailable, + customActions: snapshotCustomActionsUnavailable, + withoutActiveApp: + options.snapshotAvailable !== false && + options.getInteractor !== undefined && + webDriverSnapshotDevice(device) + ? available + : snapshotUnavailable, + }), ensureReady: available, bootTarget: available, bootTargetHeadless: headlessUnavailable, diff --git a/scripts/layering/runtime-command-cutover-extensions.ts b/scripts/layering/runtime-command-cutover-extensions.ts index 1261b6dfcc..577e188817 100644 --- a/scripts/layering/runtime-command-cutover-extensions.ts +++ b/scripts/layering/runtime-command-cutover-extensions.ts @@ -29,6 +29,12 @@ const APP_STATE_LEGACY_IMPORT_SOURCES = new Set([ '../../platforms/harmonyos/app-lifecycle.ts', ]); const APP_STATE_LEGACY_CALLS = new Set(['getAndroidAppState', 'getHarmonyAppState']); +const SNAPSHOT_RUNTIME_BINDING_FILE = 'src/daemon/snapshot-runtime-binding.ts'; +const SNAPSHOT_PLATFORM_POLICY_IDENTIFIERS = new Set([ + 'isIosFamily', + 'isIosSimulator', + 'providerOwned', +]); /** * `devices` proves its single inventory route by binding identity, not by name: the @@ -189,6 +195,39 @@ export function appStateLegacySessionHandlerViolations( return violations; } +/** Snapshot admission consumes owner facts; it must not reconstruct platform/provider policy. */ +export function snapshotPlatformPolicyBranchViolations( + sources: ReadonlyMap, +): UnruledViolation[] { + const source = sources.get(SNAPSHOT_RUNTIME_BINDING_FILE); + if (source === undefined) { + return [ + { + file: SNAPSHOT_RUNTIME_BINDING_FILE, + line: 1, + message: 'snapshot facts-first admission module is missing', + }, + ]; + } + const violations: UnruledViolation[] = []; + const seen = new Set(); + const program = parseSync(SNAPSHOT_RUNTIME_BINDING_FILE, source).program as AstNode; + visitAst(program, (node) => { + if (node['type'] !== 'Identifier') return; + const name = identifierName(node); + if (name === undefined || !SNAPSHOT_PLATFORM_POLICY_IDENTIFIERS.has(name) || seen.has(name)) { + return; + } + seen.add(name); + violations.push({ + file: SNAPSHOT_RUNTIME_BINDING_FILE, + line: lineOf(source, node), + message: `snapshot admission reconstructs owner policy through ${name}`, + }); + }); + return violations; +} + function productionSources(sources: ReadonlyMap): ProductionSource[] { return [...sources].map(([path, source]) => ({ path, source })); } diff --git a/scripts/layering/runtime-command-cutover-table.test.ts b/scripts/layering/runtime-command-cutover-table.test.ts index b3dfbfee7e..c3f6beeb48 100644 --- a/scripts/layering/runtime-command-cutover-table.test.ts +++ b/scripts/layering/runtime-command-cutover-table.test.ts @@ -5,6 +5,7 @@ import { rowFor, sources, summariesFor } from './runtime-command-cutover-fixture import { cutoverTableDefects } from './runtime-command-cutover-model.ts'; import { MIGRATED_COMMAND_CUTOVERS } from './runtime-command-cutover-table.ts'; import { commandDescriptors } from '../../src/core/command-descriptor/registry.ts'; +import { snapshotPlatformPolicyBranchViolations } from './runtime-command-cutover-extensions.ts'; test('R20 boot rejects the superseded root readiness adapter', () => { assert.deepEqual( @@ -61,9 +62,15 @@ test('R32 snapshot rejects legacy admission and dispatcher projection', () => { [ 'src/daemon/snapshot-runtime.ts', ` + function selectActiveAppSnapshot() { runtime.operations.captureSnapshot(input); } + function selectCustomActionsSnapshot() { + runtime.operations.captureSnapshotWithCustomActions(input); + } + function selectSnapshotWithoutActiveApp() { + runtime.operations.captureSnapshotWithoutActiveApp(input); + } function dispatchSnapshotViaRuntime() { requireCommandSupported('snapshot', device); - runtime.operations.captureSnapshot(input); } function handleSnapshotCommands() { dispatchSnapshotViaRuntime(request); @@ -71,6 +78,10 @@ test('R32 snapshot rejects legacy admission and dispatcher projection', () => { handleSnapshotCommands(request); `, ], + [ + 'src/daemon/snapshot-runtime-binding.ts', + 'export function inspectSnapshotCaptureAdmission() {}', + ], ['src/core/dispatch.ts', 'function handleSnapshotCommand() { return legacy.snapshot(); }'], [ 'src/core/capabilities.ts', @@ -91,6 +102,29 @@ test('R32 snapshot rejects legacy admission and dispatcher projection', () => { ); }); +test('R32 snapshot rejects platform and provider policy reconstructed in daemon admission', () => { + assert.deepEqual( + snapshotPlatformPolicyBranchViolations( + new Map([ + [ + 'src/daemon/snapshot-runtime-binding.ts', + ` + import { isIosFamily, isIosSimulator } from '@agent-device/kernel/device'; + function inspectSnapshotCaptureAdmission(device, providerOwned) { + return isIosFamily(device) && isIosSimulator(device) && providerOwned; + } + `, + ], + ]), + ).map(({ file, message }) => `${file}: ${message}`), + [ + 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reconstructs owner policy through isIosFamily', + 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reconstructs owner policy through isIosSimulator', + 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reconstructs owner policy through providerOwned', + ], + ); +}); + // Per-row acceptance for the five migrated commands. Every older-row case here was carried over // from the per-command policy tests these rows replaced; the mechanism itself is proven // in runtime-command-cutover-policy.test.ts. diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index d76b94173e..f60aa3e027 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -8,6 +8,7 @@ import { openLifecycleRouteBindingViolations, prepareLifecycleRouteBindingViolations, runtimeLifecycleRouteBindingViolations, + snapshotPlatformPolicyBranchViolations, sourceExecutedUsingDeclarationViolations, } from './runtime-command-cutover-extensions.ts'; import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts'; @@ -449,13 +450,27 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ routeNames: ['handleSnapshotCommand'], }, runtimeTypeNames: ['SnapshotRuntimeOperations'], - operations: { names: ['captureSnapshot'] }, + operations: { + names: [ + 'captureSnapshot', + 'captureSnapshotWithCustomActions', + 'captureSnapshotWithoutActiveApp', + ], + }, singularExecution: { routes: ['handleSnapshotCommands'], - operations: ['captureSnapshot'], - operationOwners: { captureSnapshot: ['dispatchSnapshotViaRuntime'] }, + operations: [ + 'captureSnapshot', + 'captureSnapshotWithCustomActions', + 'captureSnapshotWithoutActiveApp', + ], + operationOwners: { + captureSnapshot: ['selectActiveAppSnapshot'], + captureSnapshotWithCustomActions: ['selectCustomActionsSnapshot'], + captureSnapshotWithoutActiveApp: ['selectSnapshotWithoutActiveApp'], + }, }, - extensions: [snapshotRetiredDispatchProjectionProof], + extensions: [snapshotRetiredDispatchProjectionProof, snapshotPlatformPolicyBranchViolations], }, ]; diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 402a3e869e..454290b794 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -1,5 +1,6 @@ import { applicationLifecycleOperationFacts, + snapshotRuntimeOperationFacts, type RuntimeOperationFact, } from '@agent-device/contracts/platform'; @@ -23,7 +24,11 @@ const unavailableShutdownOperationFacts = Object.freeze({ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.freeze({ ...unavailableDeploymentOperationFacts, - captureSnapshot: unavailable, + ...snapshotRuntimeOperationFacts({ + capture: unavailable, + customActions: unavailable, + withoutActiveApp: unavailable, + }), ...unavailableShutdownOperationFacts, }); diff --git a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts index 23bb6fd676..a9e9d043a7 100644 --- a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts +++ b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import { captureSnapshotUse } from '@agent-device/contracts/platform'; +import { snapshotRuntimePlanUses } from '@agent-device/contracts/platform'; import { expect, test } from 'vitest'; import { commandDescriptors } from '../registry.ts'; @@ -12,19 +12,21 @@ const snapshotRuntimeBindingSource = readFileSync( 'utf8', ); -test('snapshot descriptor declares one required capture operation with no legacy projection', () => { +test('snapshot descriptor declares its complete planned capture uses with no legacy projection', () => { const snapshot = commandDescriptors.find(({ name }) => name === 'snapshot'); expect(snapshot).not.toHaveProperty('capability'); expect(snapshot).not.toHaveProperty('dispatch'); expect(snapshot?.platformExecution).toEqual({ kind: 'device-runtime', - use: captureSnapshotUse, - }); - expect(captureSnapshotUse).toEqual({ - required: ['captureSnapshot'], - preferred: [], + uses: snapshotRuntimePlanUses, }); + expect(snapshotRuntimePlanUses.map(({ required }) => required)).toEqual([ + ['captureSnapshot'], + ['captureSnapshot', 'captureSnapshotWithCustomActions'], + ['captureSnapshot', 'captureSnapshotWithoutActiveApp'], + ['captureSnapshot', 'captureSnapshotWithCustomActions', 'captureSnapshotWithoutActiveApp'], + ]); }); test('snapshot public route inspects facts and binds the declared use exactly once', () => { @@ -33,18 +35,19 @@ test('snapshot public route inspects facts and binds the declared use exactly on snapshotRuntimeSource.indexOf('function publishedSnapshotGeneration'), ); - expect(publicRoute.match(/inspectSnapshotCaptureAdmission\(params\)/g)).toHaveLength(1); + expect(publicRoute.match(/inspectSnapshotCaptureAdmission\(\{/g)).toHaveLength(1); expect( - publicRoute.match(/bindSnapshotCaptureRuntime\(params\.bindDevice, device\)/g), + publicRoute.match(/bindSnapshotCaptureRuntime\(params\.bindDevice, device, plan\)/g), ).toHaveLength(1); expect( snapshotRuntimeBindingSource.match(/requireRuntimeFacts\(params\.inspectFacts\)\(device\)/g), ).toHaveLength(1); expect( - snapshotRuntimeBindingSource.match( - /requireRuntimeBinding\(bindDevice\)\(device, captureSnapshotUse\)/g, - ), + snapshotRuntimeBindingSource.match(/const bind = requireRuntimeBinding\(bindDevice\)/g), ).toHaveLength(1); - expect(publicRoute.match(/runtime\.operations\.captureSnapshot\(/g)).toHaveLength(1); + expect(publicRoute.match(/runtime\.captureSnapshot\(/g)).toHaveLength(1); expect(publicRoute).not.toContain("requireCommandSupported('snapshot'"); + for (const policyName of ['isIosFamily', 'isIosSimulator', 'providerOwned']) { + expect(snapshotRuntimeBindingSource).not.toContain(policyName); + } }); diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index df756ccd56..18c66a1302 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -25,7 +25,7 @@ import { readySendPushNotificationUse, openApplicationRuntimePlanUses, closeApplicationRuntimePlanUses, - captureSnapshotUse, + snapshotRuntimePlanUses, prepareAppleRunnerRuntimeUse, runtimeCommandRuntimePlanUses, screenRecordingRuntimePlanUses, @@ -916,7 +916,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ // widens the envelope, and a timeout must not tear down the daemon. timeoutPolicy: { ...PRESERVE_DAEMON_TIMEOUT_POLICY, budget: { source: 'flag' } }, batchable: true, - platformExecution: { kind: 'device-runtime', use: captureSnapshotUse }, + platformExecution: { kind: 'device-runtime', uses: snapshotRuntimePlanUses }, }, { name: 'diff', diff --git a/src/core/interactors/linux.ts b/src/core/interactors/linux.ts index 4f5b64bf59..ff299f1abc 100644 --- a/src/core/interactors/linux.ts +++ b/src/core/interactors/linux.ts @@ -20,6 +20,7 @@ import { import { singlePointerPlanEndpoints } from '@agent-device/contracts/interaction'; import { screenshotLinux } from '../../platforms/linux/screenshot.ts'; import { snapshotLinux } from '../../platforms/linux/snapshot.ts'; +import { shapeDesktopSurfaceSnapshot } from '../../snapshot/snapshot-desktop-surface.ts'; import type { Interactor } from '@agent-device/contracts/interaction'; export function createLinuxInteractor(): Interactor { @@ -51,11 +52,14 @@ export function createLinuxInteractor(): Interactor { async () => await snapshotLinux(options?.surface, options?.signal), { backend: 'linux-atspi' }, ); - return { - nodes: result.nodes ?? [], - truncated: result.truncated ?? false, - backend: 'linux-atspi', - }; + return shapeDesktopSurfaceSnapshot( + { + nodes: result.nodes ?? [], + truncated: result.truncated ?? false, + backend: 'linux-atspi', + }, + options ?? {}, + ); }, back: () => backLinux(), home: () => homeLinux(), diff --git a/src/core/snapshot-node-lookup.ts b/src/core/snapshot-node-lookup.ts index 06bc8569c7..43e0bca4a5 100644 --- a/src/core/snapshot-node-lookup.ts +++ b/src/core/snapshot-node-lookup.ts @@ -1,17 +1,8 @@ import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { extractNodeText, isMeaningfulLabel } from '@agent-device/contracts/snapshot'; +import { findNodeByLabel } from '../snapshot/snapshot-node-label.ts'; -export function findNodeByLabel(nodes: SnapshotState['nodes'], label: string) { - const query = label.toLowerCase(); - return ( - nodes.find((node) => { - const labelValue = (node.label ?? '').toLowerCase(); - const valueValue = (node.value ?? '').toLowerCase(); - const idValue = (node.identifier ?? '').toLowerCase(); - return labelValue.includes(query) || valueValue.includes(query) || idValue.includes(query); - }) ?? null - ); -} +export { findNodeByLabel }; export function resolveRefLabel( node: SnapshotState['nodes'][number], diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index af7ef04219..4cb231bc02 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -26,7 +26,7 @@ import type { SessionState } from '../types.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { - captureSnapshotUse, + snapshotRuntimePlanUses, type DeviceRuntimeGateway, type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; @@ -40,7 +40,7 @@ function snapshotDeviceRuntimeGateway(): DeviceRuntimeGateway { const [facts, binding] = await Promise.all([ runtime.inspectFacts(device), - runtime.bindDevice(device, captureSnapshotUse), + runtime.bindDevice(device, snapshotRuntimePlanUses[2]), ]); return { ...binding, diff --git a/src/daemon/__tests__/snapshot-runtime-fixture.ts b/src/daemon/__tests__/snapshot-runtime-fixture.ts index 74d1c2ad13..b9401ee59e 100644 --- a/src/daemon/__tests__/snapshot-runtime-fixture.ts +++ b/src/daemon/__tests__/snapshot-runtime-fixture.ts @@ -2,12 +2,13 @@ import { localRuntimeOwner, narrowDeviceBinding, providerRuntimeOwner, + snapshotRuntimeOperationFacts, type CaptureSnapshotInput, type PlatformRuntimeOperations, type RuntimeFacts, type SnapshotResult, } from '@agent-device/contracts/platform'; -import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; +import { deviceShape, isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { dispatchCommand, type DispatchContext } from '../../core/dispatch.ts'; import { getRequestSignal } from '../../request/cancel.ts'; import { isActiveProviderDevice } from '../../provider-device-runtime.ts'; @@ -25,6 +26,8 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ const bindDevice: BindDeviceRuntime = async (device, use) => { const facts = await snapshotFacts(device); const providerOwned = facts.device.providerMode === 'provider-runtime'; + const captureSnapshot = async (input: CaptureSnapshotInput) => + await dispatchFixtureSnapshot(device, input, requestSignal); return narrowDeviceBinding( { device, @@ -33,8 +36,9 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ : localRuntimeOwner(device.platform), facts, operations: { - captureSnapshot: async (input) => - await dispatchFixtureSnapshot(device, input, requestSignal), + captureSnapshot, + captureSnapshotWithCustomActions: captureSnapshot, + captureSnapshotWithoutActiveApp: captureSnapshot, }, [Symbol.asyncDispose]: async () => {}, }, @@ -47,14 +51,31 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ async function snapshotFacts(device: DeviceInfo): Promise> { const base = await unavailableDeviceRuntimeGateway.inspectFacts(device); + const providerOwned = isActiveProviderDevice(device); + const available = { available: true } as const; + const customActionsUnavailable = { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'Re-run without --actions, or target an iOS simulator.', + } as const; + const activeAppRequired = { + available: false, + reason: 'owner-capability-missing', + hint: 'Local Apple snapshot capture requires an active app session.', + } as const; return { device: { ...deviceShape(device), - providerMode: isActiveProviderDevice(device) ? 'provider-runtime' : 'local', + providerMode: providerOwned ? 'provider-runtime' : 'local', }, operations: { ...base.operations, - captureSnapshot: { available: true }, + ...snapshotRuntimeOperationFacts({ + capture: available, + customActions: + isIosFamily(device) && device.kind === 'simulator' ? available : customActionsUnavailable, + withoutActiveApp: providerOwned || !isIosFamily(device) ? available : activeAppRequired, + }), }, }; } diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 687977ae25..d1076eafb6 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -5,6 +5,7 @@ import { localRuntimeOwner, narrowDeviceBinding, providerRuntimeOwner, + snapshotRuntimeOperationFacts, type AppDeploymentResult, type DeviceBinding, type MaterializedAppSource, @@ -350,7 +351,11 @@ function sourceRuntimeFacts( appLogCleanup: unavailable, appState: unavailable, listApps: unavailable, - captureSnapshot: unavailable, + ...snapshotRuntimeOperationFacts({ + capture: unavailable, + customActions: unavailable, + withoutActiveApp: unavailable, + }), deployApp: unavailable, materializeAppSource: materializationAvailable ? { available: true } : unavailable, deployMaterializedApp: materializationAvailable ? { available: true } : unavailable, diff --git a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts index 769e24590a..64343e4b30 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.fixtures.ts @@ -3,6 +3,7 @@ import { localRuntimeOwner, narrowDeviceBinding, providerRuntimeOwner, + snapshotRuntimeOperationFacts, type DeviceBinding, type PlatformRuntimeOperations, type RuntimeFacts, @@ -62,7 +63,11 @@ function createAdmissionFacts( appLogReattach: unavailable, appLogCleanup: unavailable, appState: unavailable, - captureSnapshot: unavailable, + ...snapshotRuntimeOperationFacts({ + capture: unavailable, + customActions: unavailable, + withoutActiveApp: unavailable, + }), deployApp: options.deployAvailable ? available : unavailable, materializeAppSource: options.sourceAvailable ? available : unavailable, deployMaterializedApp: options.sourceAvailable ? available : unavailable, diff --git a/src/daemon/handlers/__tests__/session-command-harness.ts b/src/daemon/handlers/__tests__/session-command-harness.ts index fa15ff745e..05ed8a22b6 100644 --- a/src/daemon/handlers/__tests__/session-command-harness.ts +++ b/src/daemon/handlers/__tests__/session-command-harness.ts @@ -6,6 +6,7 @@ import { applicationLifecycleOperationFacts, localRuntimeOwner, narrowDeviceBinding, + snapshotRuntimeOperationFacts, type AppDeploymentInput, type AppDeploymentResult, type DeviceBinding, @@ -132,7 +133,11 @@ function readinessFacts(device: DeviceInfo): RuntimeFacts vi.fn(async (..._args: unknown[]) => ({}))); @@ -414,6 +415,12 @@ test('snapshot on iOS rejects sessions without a tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-no-app'; sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + const runtime = snapshotRuntimeFixture(); + let bindCount = 0; + const bindDevice: BindDeviceRuntime = async (device, use) => { + bindCount += 1; + return await runtime.bindDevice(device, use); + }; const response = await handleSnapshotCommands({ req: { @@ -426,6 +433,8 @@ test('snapshot on iOS rejects sessions without a tracked app', async () => { sessionName, logPath: '/tmp/daemon.log', sessionStore, + inspectFacts: runtime.inspectFacts, + bindDevice, }); expect(response?.ok).toBe(false); @@ -436,6 +445,7 @@ test('snapshot on iOS rejects sessions without a tracked app', async () => { expect(response.error.details?.hint).toBeUndefined(); } expect(mockDispatch).not.toHaveBeenCalled(); + expect(bindCount).toBe(0); }); test('snapshot on iOS without a tracked app carries the detected open command as its hint', async () => { @@ -485,6 +495,12 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { truncated: false, backend: 'xctest', }); + const runtime = snapshotRuntimeFixture(); + let bindCount = 0; + const bindDevice: BindDeviceRuntime = async (device, use) => { + bindCount += 1; + return await runtime.bindDevice(device, use); + }; const response = await handleSnapshotCommands({ req: { @@ -497,6 +513,8 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { sessionName, logPath: '/tmp/daemon.log', sessionStore, + inspectFacts: runtime.inspectFacts, + bindDevice, }); expect(response?.ok).toBe(true); @@ -505,6 +523,7 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { // simctl and can only ever see local simulators — for a hosted device it is a // guaranteed-useless spawn on what is now a success path. expect(mockBuildIosOpenCommandHint).not.toHaveBeenCalled(); + expect(bindCount).toBe(1); }); test('diff on local iOS still requires a tracked app', async () => { diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index cae2459caf..afd473a108 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -4,7 +4,7 @@ import { snapshotCaptureAnnotationsFrom, type SnapshotCaptureAnnotations, } from '@agent-device/contracts/capture'; -import { isMacOs, publicPlatformString } from '@agent-device/kernel/device'; +import { publicPlatformString } from '@agent-device/kernel/device'; import { isAndroidInputMethodNode } from '@agent-device/contracts/platform'; import { attachRefs, @@ -16,14 +16,9 @@ import { type SnapshotBackend, type SnapshotState, } from '@agent-device/kernel/snapshot'; -import { runMacOsSnapshotAction } from '../../platforms/apple/os/macos/helper.ts'; -import { snapshotLinux } from '../../platforms/linux/snapshot.ts'; import { annotateCoveredSnapshotNodes } from '../../snapshot/snapshot-occlusion.ts'; import { resolveRefLabel } from '../../core/snapshot-node-lookup.ts'; -import { - scopeSnapshotNodes, - shapeDesktopSurfaceSnapshot, -} from '../../core/snapshot-desktop-surface.ts'; +import { scopeSnapshotNodes } from '../../snapshot/snapshot-desktop-surface.ts'; import { captureSnapshotWithInteractor } from './snapshot-interactor-capture.ts'; import { normalizeSnapshotTree, pruneGroupNodes } from '../../core/snapshot-tree-ingestion.ts'; import { @@ -95,28 +90,6 @@ export async function captureSnapshot( export async function captureSnapshotData(params: CaptureSnapshotParams): Promise { if (params.captureData) return await params.captureData(); const { device, session, flags, logPath, snapshotScope } = params; - if (device.platform === 'linux') { - const linuxResult = await snapshotLinux(session?.surface, params.signal); - return shapeDesktopSurfaceSnapshot( - { nodes: linuxResult.nodes, truncated: linuxResult.truncated, backend: 'linux-atspi' }, - { - depth: flags?.snapshotDepth, - interactiveOnly: flags?.snapshotInteractiveOnly, - scope: snapshotScope, - }, - ); - } - if (isMacOs(device) && session?.surface && session.surface !== 'app') { - const helperSnapshot = await runMacOsSnapshotAction(session.surface, { - bundleId: session.surface === 'menubar' ? session.appBundleId : undefined, - signal: params.signal, - }); - return shapeDesktopSurfaceSnapshot(helperSnapshot, { - depth: flags?.snapshotDepth, - interactiveOnly: flags?.snapshotInteractiveOnly, - scope: snapshotScope, - }); - } const context = contextFromFlags( logPath, { ...flags, snapshotScope }, diff --git a/src/daemon/snapshot-diff-legacy-admission.ts b/src/daemon/snapshot-diff-legacy-admission.ts new file mode 100644 index 0000000000..1adac3c569 --- /dev/null +++ b/src/daemon/snapshot-diff-legacy-admission.ts @@ -0,0 +1,36 @@ +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import { buildIosOpenCommandHint } from './ios-app-session-hint.ts'; +import { isIosSimulator } from './device-targets.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; +import { errorResponse } from './handlers/response.ts'; + +/** Legacy diff-only policy retained until that descriptor receives its own runtime cutover. */ +export function requireLegacyDiffCustomActionsSupported( + req: DaemonRequest, + device: DeviceInfo, +): DaemonResponse | null { + if (req.flags?.snapshotCustomActions !== true || isIosSimulator(device)) return null; + return errorResponse( + 'UNSUPPORTED_OPERATION', + `--actions requires an iOS simulator: custom actions are read through the private accessibility snapshot backend, which ${device.platform}/${device.kind} targets do not have.`, + { hint: 'Re-run without --actions, or target an iOS simulator.' }, + ); +} + +export async function requireLegacyDiffIosAppSession(params: { + session: SessionState | undefined; + device: SessionState['device']; + providerOwned: boolean; +}): Promise { + const { session, device, providerOwned } = params; + if (!isIosFamily(device) || session?.appBundleId || providerOwned) return null; + const openCommandHint = await buildIosOpenCommandHint(device); + return errorResponse( + 'SESSION_NOT_FOUND', + `iOS diff requires an active app session on the target device. Run open first (for example: open --session ${session?.name ?? 'sim'} --platform ios --device "" ).`, + { + reason: 'ios_app_session_required', + ...(openCommandHint ? { hint: openCommandHint } : {}), + }, + ); +} diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index e36d9d673f..6985b513ae 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -1,15 +1,14 @@ import { - captureSnapshotUse, - type BoundDeviceRuntime, + resolveSnapshotRuntimePlan, type CaptureSnapshotInput, + type RuntimeOperationFact, + type SnapshotResult, + type SnapshotRuntimeOperations, + type SnapshotRuntimePlan, } from '@agent-device/contracts/platform'; -import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { buildIosOpenCommandHint } from './ios-app-session-hint.ts'; import { contextFromFlags } from './context.ts'; -import { isIosSimulator } from './device-targets.ts'; -import { resolveSessionDevice } from './handlers/snapshot-session.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; -import type { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { requireRuntimeBinding, @@ -24,67 +23,119 @@ type SnapshotCaptureAdmission = admitted: true; session: SessionState | undefined; device: SessionState['device']; - providerOwned: boolean; + plan: SnapshotRuntimePlan; }>; /** Facts-first admission for the one public snapshot runtime unit. */ export async function inspectSnapshotCaptureAdmission(params: { req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; + session: SessionState | undefined; + device: SessionState['device']; inspectFacts?: InspectDeviceRuntimeFacts; }): Promise { - const { session, device } = await resolveSessionDevice( - params.sessionStore, - params.sessionName, - params.req.flags, - ); + const { session, device } = params; const facts = await requireRuntimeFacts(params.inspectFacts)(device); - const unsupported = unavailableRuntimeOperationResponse( - 'snapshot', - facts.operations.captureSnapshot, - ); - if (unsupported) return { admitted: false, response: unsupported }; + const plan = resolveSnapshotRuntimePlan({ + customActions: params.req.flags?.snapshotCustomActions === true, + hasActiveApp: session?.appBundleId !== undefined, + }); + for (const operation of plan.use.required) { + const fact = facts.operations[operation]; + if (fact.available) continue; + return { + admitted: false, + response: await snapshotPlanUnavailableResponse({ operation, fact, session, device }), + }; + } return { admitted: true, session, device, - providerOwned: facts.device.providerMode === 'provider-runtime', + plan, }; } export async function bindSnapshotCaptureRuntime( bindDevice: BindDeviceRuntime | undefined, device: SessionState['device'], -): Promise> { - return await requireRuntimeBinding(bindDevice)(device, captureSnapshotUse); + plan: SnapshotRuntimePlan, +): Promise }>> { + const bind = requireRuntimeBinding(bindDevice); + switch (plan.kind) { + case 'active-app': { + const runtime = await bind(device, plan.use); + return selectActiveAppSnapshot(runtime); + } + case 'custom-actions-active-app': { + const runtime = await bind(device, plan.use); + return selectCustomActionsSnapshot(runtime); + } + case 'without-active-app': { + const runtime = await bind(device, plan.use); + return selectSnapshotWithoutActiveApp(runtime); + } + case 'custom-actions-without-active-app': { + const runtime = await bind(device, plan.use); + return selectCustomActionsSnapshot(runtime); + } + } } -/** Flag/platform admission stays ahead of binding so invalid intent acquires no runtime. */ -export function requireSnapshotCustomActionsSupported( - req: DaemonRequest, - device: DeviceInfo, -): DaemonResponse | null { - if (req.flags?.snapshotCustomActions !== true || isIosSimulator(device)) return null; - return errorResponse( - 'UNSUPPORTED_OPERATION', - `--actions requires an iOS simulator: custom actions are read through the private accessibility snapshot backend, which ${device.platform}/${device.kind} targets do not have.`, - { hint: 'Re-run without --actions, or target an iOS simulator.' }, - ); +type BoundSnapshotOperation = Readonly<{ + operations: Readonly>; +}>; + +function selectActiveAppSnapshot(runtime: BoundSnapshotOperation<'captureSnapshot'>) { + return Object.freeze({ + captureSnapshot: async (input: CaptureSnapshotInput) => + await runtime.operations.captureSnapshot(input), + }); +} + +function selectCustomActionsSnapshot( + runtime: BoundSnapshotOperation<'captureSnapshotWithCustomActions'>, +) { + return Object.freeze({ + captureSnapshot: async (input: CaptureSnapshotInput) => + await runtime.operations.captureSnapshotWithCustomActions(input), + }); +} + +function selectSnapshotWithoutActiveApp( + runtime: BoundSnapshotOperation<'captureSnapshotWithoutActiveApp'>, +) { + return Object.freeze({ + captureSnapshot: async (input: CaptureSnapshotInput) => + await runtime.operations.captureSnapshotWithoutActiveApp(input), + }); } -export async function requireSnapshotIosAppSession(params: { - command: 'snapshot' | 'diff'; +async function snapshotPlanUnavailableResponse(params: { + operation: SnapshotRuntimePlan['use']['required'][number]; + fact: RuntimeOperationFact; session: SessionState | undefined; device: SessionState['device']; - providerOwned: boolean; -}): Promise { - const { command, session, device, providerOwned } = params; - if (!isIosFamily(device) || session?.appBundleId || providerOwned) return null; - const openCommandHint = await buildIosOpenCommandHint(device); +}): Promise { + if (params.operation === 'captureSnapshot') { + return unavailableRuntimeOperationResponse('snapshot', params.fact)!; + } + if (params.operation === 'captureSnapshotWithCustomActions') { + return errorResponse( + 'UNSUPPORTED_OPERATION', + `--actions requires an iOS simulator: custom actions are unavailable on this ${params.device.platform}/${params.device.kind} target.`, + { reason: params.fact.available ? undefined : params.fact.reason }, + { + hint: + params.fact.available || !params.fact.hint + ? 'Re-run without --actions, or target an iOS simulator.' + : params.fact.hint, + }, + ); + } + const openCommandHint = await buildIosOpenCommandHint(params.device); return errorResponse( 'SESSION_NOT_FOUND', - `iOS ${command} requires an active app session on the target device. Run open first (for example: open --session ${session?.name ?? 'sim'} --platform ios --device "" ).`, + `iOS snapshot requires an active app session on the target device. Run open first (for example: open --session ${params.session?.name ?? 'sim'} --platform ios --device "" ).`, { reason: 'ios_app_session_required', ...(openCommandHint ? { hint: openCommandHint } : {}), diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index 45266e50c9..8d85925ab4 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -39,9 +39,11 @@ import { bindSnapshotCaptureRuntime, buildRuntimeCaptureInput, inspectSnapshotCaptureAdmission, - requireSnapshotCustomActionsSupported, - requireSnapshotIosAppSession, } from './snapshot-runtime-binding.ts'; +import { + requireLegacyDiffCustomActionsSupported, + requireLegacyDiffIosAppSession, +} from './snapshot-diff-legacy-admission.ts'; export async function dispatchSnapshotViaRuntime(params: { req: DaemonRequest; @@ -51,25 +53,20 @@ export async function dispatchSnapshotViaRuntime(params: { inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; }): Promise { - const { req } = params; - const admission = await inspectSnapshotCaptureAdmission(params); - if (!admission.admitted) return admission.response; - const { session, device } = admission; + const { req, sessionName, sessionStore } = params; + const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); if (!resolvedScope.ok) return resolvedScope; - const customActionsGuard = requireSnapshotCustomActionsSupported(req, device); - if (customActionsGuard) return customActionsGuard; - - const iosAppSessionGuard = await requireSnapshotIosAppSession({ - command: 'snapshot', + const admission = await inspectSnapshotCaptureAdmission({ + req, session, device, - providerOwned: admission.providerOwned, + inspectFacts: params.inspectFacts, }); - if (iosAppSessionGuard) return iosAppSessionGuard; - - const runtime = await bindSnapshotCaptureRuntime(params.bindDevice, device); + if (!admission.admitted) return admission.response; + const { plan } = admission; + const runtime = await bindSnapshotCaptureRuntime(params.bindDevice, device, plan); return await withSessionlessRunnerCleanup( session, device, @@ -81,7 +78,7 @@ export async function dispatchSnapshotViaRuntime(params: { device, snapshotScope: resolvedScope.scope, captureSnapshotData: async () => - await runtime.operations.captureSnapshot( + await runtime.captureSnapshot( buildRuntimeCaptureInput(params, session, resolvedScope.scope), ), execute: async ({ @@ -162,10 +159,9 @@ export async function dispatchSnapshotDiffViaRuntime(params: { if (unsupported) return unsupported; const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); if (!resolvedScope.ok) return resolvedScope; - const customActionsGuard = requireSnapshotCustomActionsSupported(req, device); + const customActionsGuard = requireLegacyDiffCustomActionsSupported(req, device); if (customActionsGuard) return customActionsGuard; - const iosAppSessionGuard = await requireSnapshotIosAppSession({ - command: 'diff', + const iosAppSessionGuard = await requireLegacyDiffIosAppSession({ session, device, providerOwned: isActiveProviderDevice(device), diff --git a/src/platform-runtime-snapshot-host.test.ts b/src/platform-runtime-snapshot-host.test.ts index 2c71bbdca8..f7755029cc 100644 --- a/src/platform-runtime-snapshot-host.test.ts +++ b/src/platform-runtime-snapshot-host.test.ts @@ -38,7 +38,7 @@ test('Apple snapshot host preserves non-app macOS surface capture and menubar id }); const signal = new AbortController().signal; - const result = await createSnapshotRuntimeHost().apple.captureSurface( + const result = await createSnapshotRuntimeHost().captureSurface( macosDevice, { surface: 'menubar', appBundleId: 'com.example.app' }, signal, @@ -67,7 +67,7 @@ test('Linux snapshot host preserves interactive ancestor projection before depth }); const signal = new AbortController().signal; - const result = await createSnapshotRuntimeHost().linux.captureSurface( + const result = await createSnapshotRuntimeHost().captureSurface( linuxDevice, { surface: 'desktop', interactiveOnly: true, depth: 1 }, signal, diff --git a/src/platform-runtime-snapshot-host.ts b/src/platform-runtime-snapshot-host.ts index 4dd78e63cb..449bcfa67c 100644 --- a/src/platform-runtime-snapshot-host.ts +++ b/src/platform-runtime-snapshot-host.ts @@ -1,43 +1,44 @@ import type { SnapshotRuntimeHost } from '@agent-device/contracts/platform'; -import { shapeDesktopSurfaceSnapshot } from './core/snapshot-desktop-surface.ts'; +import { shapeDesktopSurfaceSnapshot } from './snapshot/snapshot-desktop-surface.ts'; export function createSnapshotRuntimeHost(): SnapshotRuntimeHost { return Object.freeze({ - apple: Object.freeze({ - captureSurface: captureAppleSurface, - }), - linux: Object.freeze({ - captureSurface: captureLinuxSurface, - }), + captureSurface, }); } -const captureAppleSurface: SnapshotRuntimeHost['apple']['captureSurface'] = async ( - _device, - options, - signal, -) => { - const surface = options?.surface; - if (!surface || surface === 'app') { - throw new TypeError('Apple surface capture requires a non-app macOS surface'); +const captureSurface: SnapshotRuntimeHost['captureSurface'] = async (device, options, signal) => { + if (device.platform === 'linux') { + return await captureLinuxSurface(options, signal); } - const { runMacOsSnapshotAction } = await import('./platforms/apple/os/macos/helper.ts'); - const result = await runMacOsSnapshotAction(surface, { - bundleId: surface === 'menubar' ? options?.appBundleId : undefined, - signal, - }); - return shapeDesktopSurfaceSnapshot({ ...result, backend: 'macos-helper' }, options ?? {}); + return await captureMacOsSurface(device, options, signal); }; -const captureLinuxSurface: SnapshotRuntimeHost['linux']['captureSurface'] = async ( - _device, - options, - signal, -) => { +async function captureLinuxSurface( + options: Parameters[1], + signal: AbortSignal, +) { const { snapshotLinux } = await import('./platforms/linux/snapshot.ts'); const result = await snapshotLinux(options?.surface, signal); return shapeDesktopSurfaceSnapshot( { nodes: result.nodes, truncated: result.truncated, backend: 'linux-atspi' }, options ?? {}, ); -}; +} + +async function captureMacOsSurface( + device: Parameters[0], + options: Parameters[1], + signal: AbortSignal, +) { + const surface = options?.surface; + if (device.platform !== 'apple' || device.appleOs !== 'macos' || !surface || surface === 'app') { + throw new TypeError('Apple surface capture requires a non-app macOS surface'); + } + const { runMacOsSnapshotAction } = await import('./platforms/apple/os/macos/helper.ts'); + const result = await runMacOsSnapshotAction(surface, { + bundleId: surface === 'menubar' ? options?.appBundleId : undefined, + signal, + }); + return shapeDesktopSurfaceSnapshot({ ...result, backend: 'macos-helper' }, options ?? {}); +} diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index 23ae39bbd3..1cbae52851 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -10,7 +10,7 @@ import { import { captureScreenshotViaRunner } from './core/screenshot.ts'; import { iosRunnerOverrides, resolveAppleBackRunnerCommand } from './interactions.ts'; import { appleRemotePressCommand } from './os/tvos/remote.ts'; -import { runMacOsScreenshotAction } from './os/macos/helper.ts'; +import { runMacOsScreenshotAction, runMacOsSnapshotAction } from './os/macos/helper.ts'; import { runAppleRunnerCommand } from './core/runner/runner-client.ts'; import { withAppleRunnerProvider, @@ -29,8 +29,10 @@ import type { RunnerCallOptions, RunnerContext, ScreenshotOptions, + SnapshotOptions, } from '@agent-device/contracts/interaction'; import { readSnapshotQualityVerdict } from '../../snapshot-quality/verdict.ts'; +import { shapeDesktopSurfaceSnapshot } from '../../snapshot/snapshot-desktop-surface.ts'; export function createAppleInteractor( device: DeviceInfo, @@ -61,41 +63,7 @@ export function createAppleInteractor( openDevice: () => openIosDevice(device), close: (app) => closeIosApp(device, app, runnerOpts), screenshot: (outPath, options) => runAppleScreenshot(device, outPath, options, runnerOpts), - snapshot: async (options) => { - const result = readAppleSnapshotResult( - await withDiagnosticTimer( - 'snapshot_capture', - async () => - await runAppleRunnerCommand( - device, - { - command: 'snapshot', - appBundleId: options?.appBundleId, - interactiveOnly: options?.interactiveOnly, - preferredBackend: options?.preferredBackend, - customActions: options?.customActions, - depth: options?.depth, - scope: options?.scope, - raw: options?.raw, - }, - mergeRunnerCallSignal(runnerOpts, options?.signal), - ), - { backend: 'xctest' }, - ), - ); - const nodes = result.nodes ?? []; - if (nodes.length === 0 && device.kind === 'simulator') { - throw new AppError('COMMAND_FAILED', 'XCTest snapshot returned 0 nodes on iOS simulator.'); - } - return { - nodes, - truncated: result.truncated ?? false, - backend: 'xctest', - ...(result.quality ? { quality: result.quality } : {}), - // Legacy runners without a quality verdict still surface their message text. - ...(!result.quality && result.message ? { warnings: [result.message] } : {}), - }; - }, + snapshot: async (options) => await captureAppleSnapshot(device, options, runnerOpts), back: async (mode) => { if (isTvOsDevice(device)) { // tvOS focus-only navigation: the Menu button pops focus, not a coordinate tap. @@ -177,6 +145,70 @@ export function createAppleInteractor( return withInjectedAppleRunnerTransport(device, runnerContext, interactor, runnerProvider); } +async function captureAppleSnapshot( + device: DeviceInfo, + options: SnapshotOptions | undefined, + runnerOpts: RunnerCallOptions, +) { + if (isMacOs(device) && options?.surface && options.surface !== 'app') { + return await captureMacOsSurfaceSnapshot(options.surface, options); + } + return await captureAppleRunnerSnapshot(device, options, runnerOpts); +} + +async function captureMacOsSurfaceSnapshot( + surface: Exclude, 'app'>, + options: SnapshotOptions, +) { + return shapeDesktopSurfaceSnapshot( + await runMacOsSnapshotAction(surface, { + bundleId: surface === 'menubar' ? options.appBundleId : undefined, + signal: options.signal, + }), + options, + ); +} + +async function captureAppleRunnerSnapshot( + device: DeviceInfo, + options: SnapshotOptions | undefined, + runnerOpts: RunnerCallOptions, +) { + const result = readAppleSnapshotResult( + await withDiagnosticTimer( + 'snapshot_capture', + async () => + await runAppleRunnerCommand( + device, + { + command: 'snapshot', + appBundleId: options?.appBundleId, + interactiveOnly: options?.interactiveOnly, + preferredBackend: options?.preferredBackend, + customActions: options?.customActions, + depth: options?.depth, + scope: options?.scope, + raw: options?.raw, + }, + mergeRunnerCallSignal(runnerOpts, options?.signal), + ), + { backend: 'xctest' }, + ), + ); + const nodes = result.nodes ?? []; + if (nodes.length === 0 && device.kind === 'simulator') { + throw new AppError('COMMAND_FAILED', 'XCTest snapshot returned 0 nodes on iOS simulator.'); + } + return { + nodes, + truncated: result.truncated ?? false, + backend: 'xctest' as const, + ...(result.quality ? { quality: result.quality } : {}), + // Legacy runners without a quality verdict still surface their message text. + ...(!result.quality && result.message ? { warnings: [result.message] } : {}), + }; +} + function mergeRunnerCallSignal( options: RunnerCallOptions, signal: AbortSignal | undefined, diff --git a/src/core/snapshot-desktop-surface.ts b/src/snapshot/snapshot-desktop-surface.ts similarity index 98% rename from src/core/snapshot-desktop-surface.ts rename to src/snapshot/snapshot-desktop-surface.ts index f8fdd1ec5b..c5ccf8f014 100644 --- a/src/core/snapshot-desktop-surface.ts +++ b/src/snapshot/snapshot-desktop-surface.ts @@ -1,6 +1,6 @@ import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot'; import type { SnapshotOptions, SnapshotResult } from '@agent-device/contracts/interaction'; -import { findNodeByLabel } from './snapshot-node-lookup.ts'; +import { findNodeByLabel } from './snapshot-node-label.ts'; const INTERACTIVE_ROLE_TOKENS = [ 'button', diff --git a/src/snapshot/snapshot-node-label.ts b/src/snapshot/snapshot-node-label.ts new file mode 100644 index 0000000000..e0e1023023 --- /dev/null +++ b/src/snapshot/snapshot-node-label.ts @@ -0,0 +1,13 @@ +import type { SnapshotState } from '@agent-device/kernel/snapshot'; + +export function findNodeByLabel(nodes: SnapshotState['nodes'], label: string) { + const query = label.toLowerCase(); + return ( + nodes.find((node) => { + const labelValue = (node.label ?? '').toLowerCase(); + const valueValue = (node.value ?? '').toLowerCase(); + const idValue = (node.identifier ?? '').toLowerCase(); + return labelValue.includes(query) || valueValue.includes(query) || idValue.includes(query); + }) ?? null + ); +} From 1b94c3eb10d82ef3165ad810879268d3278f096d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 17 Aug 2026 12:46:50 +0200 Subject: [PATCH 3/9] test: enforce snapshot owner-facts admission --- .../runtime-command-cutover-extensions.ts | 39 -------- .../runtime-command-cutover-snapshot.test.ts | 54 ++++++++++ .../runtime-command-cutover-snapshot.ts | 99 +++++++++++++++++++ .../runtime-command-cutover-table.test.ts | 42 ++++---- .../layering/runtime-command-cutover-table.ts | 2 +- 5 files changed, 171 insertions(+), 65 deletions(-) create mode 100644 scripts/layering/runtime-command-cutover-snapshot.test.ts create mode 100644 scripts/layering/runtime-command-cutover-snapshot.ts diff --git a/scripts/layering/runtime-command-cutover-extensions.ts b/scripts/layering/runtime-command-cutover-extensions.ts index 577e188817..1261b6dfcc 100644 --- a/scripts/layering/runtime-command-cutover-extensions.ts +++ b/scripts/layering/runtime-command-cutover-extensions.ts @@ -29,12 +29,6 @@ const APP_STATE_LEGACY_IMPORT_SOURCES = new Set([ '../../platforms/harmonyos/app-lifecycle.ts', ]); const APP_STATE_LEGACY_CALLS = new Set(['getAndroidAppState', 'getHarmonyAppState']); -const SNAPSHOT_RUNTIME_BINDING_FILE = 'src/daemon/snapshot-runtime-binding.ts'; -const SNAPSHOT_PLATFORM_POLICY_IDENTIFIERS = new Set([ - 'isIosFamily', - 'isIosSimulator', - 'providerOwned', -]); /** * `devices` proves its single inventory route by binding identity, not by name: the @@ -195,39 +189,6 @@ export function appStateLegacySessionHandlerViolations( return violations; } -/** Snapshot admission consumes owner facts; it must not reconstruct platform/provider policy. */ -export function snapshotPlatformPolicyBranchViolations( - sources: ReadonlyMap, -): UnruledViolation[] { - const source = sources.get(SNAPSHOT_RUNTIME_BINDING_FILE); - if (source === undefined) { - return [ - { - file: SNAPSHOT_RUNTIME_BINDING_FILE, - line: 1, - message: 'snapshot facts-first admission module is missing', - }, - ]; - } - const violations: UnruledViolation[] = []; - const seen = new Set(); - const program = parseSync(SNAPSHOT_RUNTIME_BINDING_FILE, source).program as AstNode; - visitAst(program, (node) => { - if (node['type'] !== 'Identifier') return; - const name = identifierName(node); - if (name === undefined || !SNAPSHOT_PLATFORM_POLICY_IDENTIFIERS.has(name) || seen.has(name)) { - return; - } - seen.add(name); - violations.push({ - file: SNAPSHOT_RUNTIME_BINDING_FILE, - line: lineOf(source, node), - message: `snapshot admission reconstructs owner policy through ${name}`, - }); - }); - return violations; -} - function productionSources(sources: ReadonlyMap): ProductionSource[] { return [...sources].map(([path, source]) => ({ path, source })); } diff --git a/scripts/layering/runtime-command-cutover-snapshot.test.ts b/scripts/layering/runtime-command-cutover-snapshot.test.ts new file mode 100644 index 0000000000..98aedbcd39 --- /dev/null +++ b/scripts/layering/runtime-command-cutover-snapshot.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { snapshotPlatformPolicyBranchViolations } from './runtime-command-cutover-snapshot.ts'; + +const SNAPSHOT_RUNTIME_BINDING_FILE = 'src/daemon/snapshot-runtime-binding.ts'; +const SNAPSHOT_FACTS_FIRST_ADMISSION = ` + const facts = await requireRuntimeFacts(params.inspectFacts)(device); + const plan = resolveSnapshotRuntimePlan({ + customActions: params.req.flags?.snapshotCustomActions === true, + hasActiveApp: session?.appBundleId !== undefined, + }); + for (const operation of plan.use.required) { + const fact = facts.operations[operation]; + if (fact.available) continue; + } +`; + +function violationsFor(extraAdmission = ''): string[] { + return snapshotPlatformPolicyBranchViolations( + new Map([ + [ + SNAPSHOT_RUNTIME_BINDING_FILE, + ` + function inspectSnapshotCaptureAdmission(params, device, session) { + ${SNAPSHOT_FACTS_FIRST_ADMISSION} + ${extraAdmission} + } + `, + ], + ]), + ).map(({ file, message }) => `${file}: ${message}`); +} + +test('R32 snapshot accepts only the normalized plan and selected operation facts seam', () => { + assert.deepEqual(violationsFor(), []); +}); + +test('R32 snapshot rejects a direct device-leaf branch in daemon admission', () => { + assert.deepEqual( + violationsFor("if (device.platform === 'apple' && device.kind === 'simulator') return plan;"), + [ + 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', + ], + ); +}); + +test('R32 snapshot rejects a provider-mode branch in daemon admission', () => { + assert.deepEqual( + violationsFor("if (facts.device.providerMode === 'provider-runtime') return plan;"), + [ + 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', + ], + ); +}); diff --git a/scripts/layering/runtime-command-cutover-snapshot.ts b/scripts/layering/runtime-command-cutover-snapshot.ts new file mode 100644 index 0000000000..e7a08eca4a --- /dev/null +++ b/scripts/layering/runtime-command-cutover-snapshot.ts @@ -0,0 +1,99 @@ +import { parseSync } from 'oxc-parser'; +import { propertyName, visitAst } from './cutover-policy-ast.ts'; +import { countNamedCalls, lineOf, namedFunction } from './runtime-command-cutover-ast.ts'; +import type { UnruledViolation } from './runtime-command-cutover-model.ts'; + +type AstNode = Record; + +const SNAPSHOT_RUNTIME_BINDING_FILE = 'src/daemon/snapshot-runtime-binding.ts'; +const SNAPSHOT_ADMISSION_FUNCTION = 'inspectSnapshotCaptureAdmission'; + +/** Snapshot admission consumes the selected plan and operation facts, never device-owner identity. */ +export function snapshotPlatformPolicyBranchViolations( + sources: ReadonlyMap, +): UnruledViolation[] { + const source = sources.get(SNAPSHOT_RUNTIME_BINDING_FILE); + if (source === undefined) { + return [ + { + file: SNAPSHOT_RUNTIME_BINDING_FILE, + line: 1, + message: 'snapshot facts-first admission module is missing', + }, + ]; + } + const program = parseSync(SNAPSHOT_RUNTIME_BINDING_FILE, source).program as AstNode; + const admission = namedFunction(program, SNAPSHOT_ADMISSION_FUNCTION); + if (admission === undefined) { + return [ + { + file: SNAPSHOT_RUNTIME_BINDING_FILE, + line: 1, + message: `snapshot admission must be owned by ${SNAPSHOT_ADMISSION_FUNCTION}`, + }, + ]; + } + + const violations: UnruledViolation[] = []; + const seenLines = new Set(); + let readsRequiredOperations = false; + let readsOperationFacts = false; + let admitsAvailableFacts = false; + visitAst(admission, (node) => { + const path = memberPath(node); + if (path === undefined) return; + if (samePath(path, ['plan', 'use', 'required'])) readsRequiredOperations = true; + if (samePath(path, ['facts', 'operations'])) readsOperationFacts = true; + if (samePath(path, ['fact', 'available'])) admitsAvailableFacts = true; + + const readsDeviceLeaf = + (path[0] === 'device' && path.length > 1) || + (path[0] === 'params' && path[1] === 'device' && path.length > 2); + const readsOwnerIdentity = path[0] === 'facts' && path[1] === 'device' && path.length > 2; + if (!readsDeviceLeaf && !readsOwnerIdentity) return; + const line = lineOf(source, node); + if (seenLines.has(line)) return; + seenLines.add(line); + violations.push({ + file: SNAPSHOT_RUNTIME_BINDING_FILE, + line, + message: 'snapshot admission reads device-owner identity instead of selected operation facts', + }); + }); + + if (countNamedCalls(admission, 'resolveSnapshotRuntimePlan') !== 1) { + violations.push({ + file: SNAPSHOT_RUNTIME_BINDING_FILE, + line: lineOf(source, admission), + message: 'snapshot admission must select exactly one normalized runtime plan', + }); + } + if (!readsRequiredOperations || !readsOperationFacts || !admitsAvailableFacts) { + violations.push({ + file: SNAPSHOT_RUNTIME_BINDING_FILE, + line: lineOf(source, admission), + message: 'snapshot admission must admit every selected operation through owner facts', + }); + } + return violations; +} + +function memberPath(node: unknown): string[] | undefined { + if (node === null || typeof node !== 'object') return undefined; + const record = node as AstNode; + if (record['type'] === 'Identifier') { + const name = record['name']; + return typeof name === 'string' ? [name] : undefined; + } + if (record['type'] === 'ChainExpression') return memberPath(record['expression']); + if (record['type'] !== 'MemberExpression' || record['computed'] === true) return undefined; + const object = memberPath(record['object']); + const name = propertyName(record['property']); + return object && name ? [...object, name] : undefined; +} + +function samePath(actual: readonly string[], expected: readonly string[]): boolean { + return ( + actual.length === expected.length && actual.every((part, index) => part === expected[index]) + ); +} diff --git a/scripts/layering/runtime-command-cutover-table.test.ts b/scripts/layering/runtime-command-cutover-table.test.ts index c3f6beeb48..55b34d11a5 100644 --- a/scripts/layering/runtime-command-cutover-table.test.ts +++ b/scripts/layering/runtime-command-cutover-table.test.ts @@ -5,7 +5,18 @@ import { rowFor, sources, summariesFor } from './runtime-command-cutover-fixture import { cutoverTableDefects } from './runtime-command-cutover-model.ts'; import { MIGRATED_COMMAND_CUTOVERS } from './runtime-command-cutover-table.ts'; import { commandDescriptors } from '../../src/core/command-descriptor/registry.ts'; -import { snapshotPlatformPolicyBranchViolations } from './runtime-command-cutover-extensions.ts'; + +const SNAPSHOT_FACTS_FIRST_ADMISSION = ` + const facts = await requireRuntimeFacts(params.inspectFacts)(device); + const plan = resolveSnapshotRuntimePlan({ + customActions: params.req.flags?.snapshotCustomActions === true, + hasActiveApp: session?.appBundleId !== undefined, + }); + for (const operation of plan.use.required) { + const fact = facts.operations[operation]; + if (fact.available) continue; + } +`; test('R20 boot rejects the superseded root readiness adapter', () => { assert.deepEqual( @@ -80,7 +91,11 @@ test('R32 snapshot rejects legacy admission and dispatcher projection', () => { ], [ 'src/daemon/snapshot-runtime-binding.ts', - 'export function inspectSnapshotCaptureAdmission() {}', + ` + export async function inspectSnapshotCaptureAdmission(params, device, session) { + ${SNAPSHOT_FACTS_FIRST_ADMISSION} + } + `, ], ['src/core/dispatch.ts', 'function handleSnapshotCommand() { return legacy.snapshot(); }'], [ @@ -102,29 +117,6 @@ test('R32 snapshot rejects legacy admission and dispatcher projection', () => { ); }); -test('R32 snapshot rejects platform and provider policy reconstructed in daemon admission', () => { - assert.deepEqual( - snapshotPlatformPolicyBranchViolations( - new Map([ - [ - 'src/daemon/snapshot-runtime-binding.ts', - ` - import { isIosFamily, isIosSimulator } from '@agent-device/kernel/device'; - function inspectSnapshotCaptureAdmission(device, providerOwned) { - return isIosFamily(device) && isIosSimulator(device) && providerOwned; - } - `, - ], - ]), - ).map(({ file, message }) => `${file}: ${message}`), - [ - 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reconstructs owner policy through isIosFamily', - 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reconstructs owner policy through isIosSimulator', - 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reconstructs owner policy through providerOwned', - ], - ); -}); - // Per-row acceptance for the five migrated commands. Every older-row case here was carried over // from the per-command policy tests these rows replaced; the mechanism itself is proven // in runtime-command-cutover-policy.test.ts. diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index f60aa3e027..79c05a8666 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -8,9 +8,9 @@ import { openLifecycleRouteBindingViolations, prepareLifecycleRouteBindingViolations, runtimeLifecycleRouteBindingViolations, - snapshotPlatformPolicyBranchViolations, sourceExecutedUsingDeclarationViolations, } from './runtime-command-cutover-extensions.ts'; +import { snapshotPlatformPolicyBranchViolations } from './runtime-command-cutover-snapshot.ts'; import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts'; import { retiredDispatchProjectionViolations } from './runtime-command-cutover-descriptor.ts'; From 4693b41ecd1264d0dd887d69ddf923d15728b8b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 17 Aug 2026 16:07:29 +0200 Subject: [PATCH 4/9] refactor: consolidate desktop snapshot capture --- scripts/layering/cutover-policy-ast.ts | 13 +++++ .../platform-package-source-policy.ts | 16 +----- .../runtime-command-cutover-snapshot.ts | 16 +----- src/core/interactors/linux.ts | 15 ++---- src/platform-runtime-operation-host.ts | 2 +- src/platform-runtime-snapshot-host.ts | 44 ---------------- src/platforms/apple/interactor.ts | 19 ++----- .../snapshot-desktop-surface.test.ts} | 7 +-- src/snapshot/snapshot-desktop-surface.ts | 52 ++++++++++++++++++- 9 files changed, 77 insertions(+), 107 deletions(-) delete mode 100644 src/platform-runtime-snapshot-host.ts rename src/{platform-runtime-snapshot-host.test.ts => snapshot/snapshot-desktop-surface.test.ts} (90%) diff --git a/scripts/layering/cutover-policy-ast.ts b/scripts/layering/cutover-policy-ast.ts index 9521c33626..d5cd8ae70c 100644 --- a/scripts/layering/cutover-policy-ast.ts +++ b/scripts/layering/cutover-policy-ast.ts @@ -19,6 +19,19 @@ export function propertyName(node: unknown): string | undefined { : undefined; } +export function memberPath(node: unknown): string[] | undefined { + if (node === null || typeof node !== 'object') return undefined; + const record = node as Record; + if (record.type === 'Identifier') { + return typeof record.name === 'string' ? [record.name] : undefined; + } + if (record.type === 'ChainExpression') return memberPath(record.expression); + if (record.type !== 'MemberExpression' || record.computed === true) return undefined; + const object = memberPath(record.object); + const name = propertyName(record.property); + return object && name ? [...object, name] : undefined; +} + export function visitAst(node: unknown, visitor: (node: Record) => void): void { if (node === null || typeof node !== 'object') return; if (Array.isArray(node)) { diff --git a/scripts/layering/platform-package-source-policy.ts b/scripts/layering/platform-package-source-policy.ts index ddd243b79c..3afcf431f3 100644 --- a/scripts/layering/platform-package-source-policy.ts +++ b/scripts/layering/platform-package-source-policy.ts @@ -1,4 +1,5 @@ import { parseSync } from 'oxc-parser'; +import { memberPath } from './cutover-policy-ast.ts'; import { parseImports, type LayeringViolation } from './model.ts'; const RULE = 'R13 platform-package-substrate'; @@ -38,21 +39,6 @@ function rootIdentifier(node: unknown): string | undefined { return undefined; } -function memberPath(node: unknown): string[] | undefined { - if (node === null || typeof node !== 'object') return undefined; - const record = node as Record; - if (record['type'] === 'Identifier') { - const name = record['name']; - return typeof name === 'string' ? [name] : undefined; - } - if (record['type'] === 'ChainExpression') return memberPath(record['expression']); - if (record['type'] !== 'MemberExpression' || record['computed'] === true) return undefined; - const object = memberPath(record['object']); - const property = record['property'] as Record | undefined; - const name = property?.['type'] === 'Identifier' ? property['name'] : undefined; - return object && typeof name === 'string' ? [...object, name] : undefined; -} - function literalString(node: unknown): string | undefined { if (node === null || typeof node !== 'object') return undefined; const value = (node as Record)['value']; diff --git a/scripts/layering/runtime-command-cutover-snapshot.ts b/scripts/layering/runtime-command-cutover-snapshot.ts index e7a08eca4a..06dca7ec46 100644 --- a/scripts/layering/runtime-command-cutover-snapshot.ts +++ b/scripts/layering/runtime-command-cutover-snapshot.ts @@ -1,5 +1,5 @@ import { parseSync } from 'oxc-parser'; -import { propertyName, visitAst } from './cutover-policy-ast.ts'; +import { memberPath, visitAst } from './cutover-policy-ast.ts'; import { countNamedCalls, lineOf, namedFunction } from './runtime-command-cutover-ast.ts'; import type { UnruledViolation } from './runtime-command-cutover-model.ts'; @@ -78,20 +78,6 @@ export function snapshotPlatformPolicyBranchViolations( return violations; } -function memberPath(node: unknown): string[] | undefined { - if (node === null || typeof node !== 'object') return undefined; - const record = node as AstNode; - if (record['type'] === 'Identifier') { - const name = record['name']; - return typeof name === 'string' ? [name] : undefined; - } - if (record['type'] === 'ChainExpression') return memberPath(record['expression']); - if (record['type'] !== 'MemberExpression' || record['computed'] === true) return undefined; - const object = memberPath(record['object']); - const name = propertyName(record['property']); - return object && name ? [...object, name] : undefined; -} - function samePath(actual: readonly string[], expected: readonly string[]): boolean { return ( actual.length === expected.length && actual.every((part, index) => part === expected[index]) diff --git a/src/core/interactors/linux.ts b/src/core/interactors/linux.ts index ff299f1abc..9d57c990d5 100644 --- a/src/core/interactors/linux.ts +++ b/src/core/interactors/linux.ts @@ -19,8 +19,7 @@ import { } from '../../platforms/linux/input-actions.ts'; import { singlePointerPlanEndpoints } from '@agent-device/contracts/interaction'; import { screenshotLinux } from '../../platforms/linux/screenshot.ts'; -import { snapshotLinux } from '../../platforms/linux/snapshot.ts'; -import { shapeDesktopSurfaceSnapshot } from '../../snapshot/snapshot-desktop-surface.ts'; +import { captureLinuxSurfaceSnapshot } from '../../snapshot/snapshot-desktop-surface.ts'; import type { Interactor } from '@agent-device/contracts/interaction'; export function createLinuxInteractor(): Interactor { @@ -47,19 +46,11 @@ export function createLinuxInteractor(): Interactor { }, screenshot: (outPath, options) => screenshotLinux(outPath, options), snapshot: async (options) => { - const result = await withDiagnosticTimer( + return await withDiagnosticTimer( 'snapshot_capture', - async () => await snapshotLinux(options?.surface, options?.signal), + async () => await captureLinuxSurfaceSnapshot(options, options?.signal), { backend: 'linux-atspi' }, ); - return shapeDesktopSurfaceSnapshot( - { - nodes: result.nodes ?? [], - truncated: result.truncated ?? false, - backend: 'linux-atspi', - }, - options ?? {}, - ); }, back: () => backLinux(), home: () => homeLinux(), diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index b57c7d184a..5efe999939 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -27,7 +27,7 @@ import { createAppleApplicationTools } from './platform-runtime-apple-applicatio import { createAndroidApplicationTools } from './platform-runtime-android-application-tools.ts'; import { createLocalApplicationInteractorHost } from './platform-runtime-local-application-interactors.ts'; import { createApplicationResourceLifecycle } from './platform-runtime-application-resources.ts'; -import { createSnapshotRuntimeHost } from './platform-runtime-snapshot-host.ts'; +import { createSnapshotRuntimeHost } from './snapshot/snapshot-desktop-surface.ts'; export function createPlatformRuntimeHost(options: { sessionsDir: string; diff --git a/src/platform-runtime-snapshot-host.ts b/src/platform-runtime-snapshot-host.ts deleted file mode 100644 index 449bcfa67c..0000000000 --- a/src/platform-runtime-snapshot-host.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { SnapshotRuntimeHost } from '@agent-device/contracts/platform'; -import { shapeDesktopSurfaceSnapshot } from './snapshot/snapshot-desktop-surface.ts'; - -export function createSnapshotRuntimeHost(): SnapshotRuntimeHost { - return Object.freeze({ - captureSurface, - }); -} - -const captureSurface: SnapshotRuntimeHost['captureSurface'] = async (device, options, signal) => { - if (device.platform === 'linux') { - return await captureLinuxSurface(options, signal); - } - return await captureMacOsSurface(device, options, signal); -}; - -async function captureLinuxSurface( - options: Parameters[1], - signal: AbortSignal, -) { - const { snapshotLinux } = await import('./platforms/linux/snapshot.ts'); - const result = await snapshotLinux(options?.surface, signal); - return shapeDesktopSurfaceSnapshot( - { nodes: result.nodes, truncated: result.truncated, backend: 'linux-atspi' }, - options ?? {}, - ); -} - -async function captureMacOsSurface( - device: Parameters[0], - options: Parameters[1], - signal: AbortSignal, -) { - const surface = options?.surface; - if (device.platform !== 'apple' || device.appleOs !== 'macos' || !surface || surface === 'app') { - throw new TypeError('Apple surface capture requires a non-app macOS surface'); - } - const { runMacOsSnapshotAction } = await import('./platforms/apple/os/macos/helper.ts'); - const result = await runMacOsSnapshotAction(surface, { - bundleId: surface === 'menubar' ? options?.appBundleId : undefined, - signal, - }); - return shapeDesktopSurfaceSnapshot({ ...result, backend: 'macos-helper' }, options ?? {}); -} diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index 1cbae52851..6f586e9657 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -10,7 +10,7 @@ import { import { captureScreenshotViaRunner } from './core/screenshot.ts'; import { iosRunnerOverrides, resolveAppleBackRunnerCommand } from './interactions.ts'; import { appleRemotePressCommand } from './os/tvos/remote.ts'; -import { runMacOsScreenshotAction, runMacOsSnapshotAction } from './os/macos/helper.ts'; +import { runMacOsScreenshotAction } from './os/macos/helper.ts'; import { runAppleRunnerCommand } from './core/runner/runner-client.ts'; import { withAppleRunnerProvider, @@ -32,7 +32,7 @@ import type { SnapshotOptions, } from '@agent-device/contracts/interaction'; import { readSnapshotQualityVerdict } from '../../snapshot-quality/verdict.ts'; -import { shapeDesktopSurfaceSnapshot } from '../../snapshot/snapshot-desktop-surface.ts'; +import { captureMacOsSurfaceSnapshot } from '../../snapshot/snapshot-desktop-surface.ts'; export function createAppleInteractor( device: DeviceInfo, @@ -151,24 +151,11 @@ async function captureAppleSnapshot( runnerOpts: RunnerCallOptions, ) { if (isMacOs(device) && options?.surface && options.surface !== 'app') { - return await captureMacOsSurfaceSnapshot(options.surface, options); + return await captureMacOsSurfaceSnapshot(options, options.signal); } return await captureAppleRunnerSnapshot(device, options, runnerOpts); } -async function captureMacOsSurfaceSnapshot( - surface: Exclude, 'app'>, - options: SnapshotOptions, -) { - return shapeDesktopSurfaceSnapshot( - await runMacOsSnapshotAction(surface, { - bundleId: surface === 'menubar' ? options.appBundleId : undefined, - signal: options.signal, - }), - options, - ); -} - async function captureAppleRunnerSnapshot( device: DeviceInfo, options: SnapshotOptions | undefined, diff --git a/src/platform-runtime-snapshot-host.test.ts b/src/snapshot/snapshot-desktop-surface.test.ts similarity index 90% rename from src/platform-runtime-snapshot-host.test.ts rename to src/snapshot/snapshot-desktop-surface.test.ts index f7755029cc..6028952818 100644 --- a/src/platform-runtime-snapshot-host.test.ts +++ b/src/snapshot/snapshot-desktop-surface.test.ts @@ -6,10 +6,10 @@ const { runMacOsSnapshotAction, snapshotLinux } = vi.hoisted(() => ({ snapshotLinux: vi.fn(), })); -vi.mock('./platforms/apple/os/macos/helper.ts', () => ({ runMacOsSnapshotAction })); -vi.mock('./platforms/linux/snapshot.ts', () => ({ snapshotLinux })); +vi.mock('../platforms/apple/os/macos/helper.ts', () => ({ runMacOsSnapshotAction })); +vi.mock('../platforms/linux/snapshot.ts', () => ({ snapshotLinux })); -import { createSnapshotRuntimeHost } from './platform-runtime-snapshot-host.ts'; +import { createSnapshotRuntimeHost } from './snapshot-desktop-surface.ts'; const macosDevice = { id: 'desktop', @@ -35,6 +35,7 @@ test('Apple snapshot host preserves non-app macOS surface capture and menubar id runMacOsSnapshotAction.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'MenuBar', label: 'System menu' }], truncated: false, + backend: 'macos-helper', }); const signal = new AbortController().signal; diff --git a/src/snapshot/snapshot-desktop-surface.ts b/src/snapshot/snapshot-desktop-surface.ts index c5ccf8f014..e4e941cfb4 100644 --- a/src/snapshot/snapshot-desktop-surface.ts +++ b/src/snapshot/snapshot-desktop-surface.ts @@ -1,7 +1,57 @@ import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot'; import type { SnapshotOptions, SnapshotResult } from '@agent-device/contracts/interaction'; +import type { CaptureSnapshotInput, SnapshotRuntimeHost } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import { findNodeByLabel } from './snapshot-node-label.ts'; +type SnapshotSurfaceOptions = NonNullable; + +export function createSnapshotRuntimeHost(): SnapshotRuntimeHost { + return Object.freeze({ captureSurface }); +} + +export async function captureLinuxSurfaceSnapshot( + options: CaptureSnapshotInput['options'], + signal?: AbortSignal, +) { + const { snapshotLinux } = await import('../platforms/linux/snapshot.ts'); + const result = await snapshotLinux(options?.surface, signal); + return shapeDesktopSurfaceSnapshot( + { nodes: result.nodes, truncated: result.truncated, backend: 'linux-atspi' }, + options ?? {}, + ); +} + +export async function captureMacOsSurfaceSnapshot( + options: SnapshotSurfaceOptions, + signal?: AbortSignal, +) { + const surface = options.surface; + if (!surface || surface === 'app') { + throw new TypeError('Apple surface capture requires a non-app macOS surface'); + } + const { runMacOsSnapshotAction } = await import('../platforms/apple/os/macos/helper.ts'); + const result = await runMacOsSnapshotAction(surface, { + bundleId: surface === 'menubar' ? options.appBundleId : undefined, + signal, + }); + return shapeDesktopSurfaceSnapshot(result, options); +} + +const captureSurface: SnapshotRuntimeHost['captureSurface'] = async (device, options, signal) => { + if (device.platform === 'linux') { + return await captureLinuxSurfaceSnapshot(options, signal); + } + requireMacOsSurfaceDevice(device); + return await captureMacOsSurfaceSnapshot(options ?? {}, signal); +}; + +function requireMacOsSurfaceDevice(device: DeviceInfo): void { + if (device.platform !== 'apple' || device.appleOs !== 'macos') { + throw new TypeError('Apple surface capture requires a non-app macOS surface'); + } +} + const INTERACTIVE_ROLE_TOKENS = [ 'button', 'menu', @@ -13,7 +63,7 @@ const INTERACTIVE_ROLE_TOKENS = [ ] as const; /** Applies the legacy desktop-surface projection once for both runtime hosts and legacy capture. */ -export function shapeDesktopSurfaceSnapshot( +function shapeDesktopSurfaceSnapshot( data: SnapshotResult, options: Pick, ): SnapshotResult { From 1b5cb69b7006d947e026c8893b47a033556fcdec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 17 Aug 2026 16:38:47 +0200 Subject: [PATCH 5/9] fix: scroll to visible iOS smoke targets --- ...os-simulator-e2e-visibility-scroll.test.ts | 54 +++++++++++++++++++ .../ios-simulator-e2e/live-assertions.ts | 54 ++++++++++++------- 2 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 test/integration/ios-simulator-e2e-visibility-scroll.test.ts diff --git a/test/integration/ios-simulator-e2e-visibility-scroll.test.ts b/test/integration/ios-simulator-e2e-visibility-scroll.test.ts new file mode 100644 index 0000000000..4824634682 --- /dev/null +++ b/test/integration/ios-simulator-e2e-visibility-scroll.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { CliJsonResult } from './cli-json.ts'; +import { searchForVisibleElement } from './ios-simulator-e2e/live-assertions.ts'; + +function result(status: number, details?: Record): CliJsonResult { + return { + json: details === undefined ? undefined : { error: { details } }, + status, + stderr: '', + stdout: '', + }; +} + +test('an existing offscreen element scrolls until the visibility probe passes', async () => { + const probes = [result(1), result(0)]; + const probeAttempts: number[] = []; + const scrollAttempts: number[] = []; + + await searchForVisibleElement( + 'id="automation-longpress"', + async (attempt) => { + probeAttempts.push(attempt); + return probes.shift() ?? result(1); + }, + async (attempt) => { + scrollAttempts.push(attempt); + }, + ); + + assert.deepEqual(probeAttempts, [1, 2]); + assert.deepEqual(scrollAttempts, [1]); +}); + +test('a stalled capture retries without scrolling or consuming an attempt', async () => { + const probes = [result(1, { captureStalled: true }), result(0)]; + const probeAttempts: number[] = []; + const scrollAttempts: number[] = []; + + await searchForVisibleElement( + 'id="automation-longpress"', + async (attempt) => { + probeAttempts.push(attempt); + return probes.shift() ?? result(1); + }, + async (attempt) => { + scrollAttempts.push(attempt); + }, + ); + + assert.deepEqual(probeAttempts, [1, 1]); + assert.deepEqual(scrollAttempts, []); +}); diff --git a/test/integration/ios-simulator-e2e/live-assertions.ts b/test/integration/ios-simulator-e2e/live-assertions.ts index 5ca9386c7d..4808970064 100644 --- a/test/integration/ios-simulator-e2e/live-assertions.ts +++ b/test/integration/ios-simulator-e2e/live-assertions.ts @@ -19,10 +19,6 @@ export const { assertElementText, assertWaitText, capturePng } = createLiveDevic LiveContext >(runStep, verifyCommand, PUBLIC_COMMANDS.wait); -// The whole budget goes to the first capture of each attempt, so it has to cover one snapshot of -// the current surface — 1s did not on a loaded simulator, and every sibling wait in these scenarios -// already budgets 2.5s or more. -const SCROLL_SEARCH_WAIT_MS = 2500; const SCROLL_SEARCH_ATTEMPTS = 4; // A stalled capture says nothing about where the element is, so it must not consume the scroll // budget outright; a couple of retries absorb a slow runner without masking a real absence. @@ -32,37 +28,59 @@ export async function assertElementTextAfterScrolling( context: LiveContext, selector: string, expected: string, +): Promise { + await searchForVisibleElement( + selector, + (attempt) => + runStep( + context, + `check ${selector} visibility after scroll (attempt ${attempt})`, + ['is', 'visible', selector], + { allowFailure: true }, + ), + (attempt) => + runStep(context, `scroll toward ${selector} after attempt ${attempt}`, [ + 'scroll', + 'down', + '0.75', + ]).then(() => undefined), + ); + await assertElementText(context, selector, expected); +} + +/** + * Searches by semantic visibility rather than selector existence. An offscreen node can exist in + * the accessibility tree, so a successful `wait ` is not sufficient evidence to skip + * scrolling. The callbacks keep this live-device policy deterministic and unit-testable without a + * simulator. + */ +export async function searchForVisibleElement( + selector: string, + probeVisibility: (attempt: number) => Promise, + scrollAfterAttempt: (attempt: number) => Promise, ): Promise { let stallRetriesLeft = SCROLL_SEARCH_STALL_RETRIES; let lastFailure: CliJsonResult | undefined; for (let attempt = 1; attempt <= SCROLL_SEARCH_ATTEMPTS; ) { - const visible = await runStep( - context, - `wait for ${selector} after scroll (attempt ${attempt})`, - ['wait', selector, String(SCROLL_SEARCH_WAIT_MS)], - { allowFailure: true }, - ); - if (visible.status === 0) { - await assertElementText(context, selector, expected); - return; - } - lastFailure = visible; + const probe = await probeVisibility(attempt); + if (probe.status === 0) return; + lastFailure = probe; // The snapshot never came back, so the surface was never read. Scrolling here would move the // surface for a reason unrelated to visibility and spend an attempt on no evidence. - if (visible.json?.error?.details?.captureStalled === true && stallRetriesLeft > 0) { + if (probe.json?.error?.details?.captureStalled === true && stallRetriesLeft > 0) { stallRetriesLeft -= 1; continue; } attempt += 1; if (attempt <= SCROLL_SEARCH_ATTEMPTS) { - await runStep(context, `scroll toward ${selector}`, ['scroll', 'down', '0.75']); + await scrollAfterAttempt(attempt - 1); } } assert.fail( - `${selector} did not become visible after scrolling\nlast wait: ${JSON.stringify(lastFailure?.json ?? null)}`, + `${selector} did not become visible after scrolling\nlast visibility probe: ${JSON.stringify(lastFailure?.json ?? null)}`, ); } From 2c852f699e5d917168832cbc4509b8b6c29a6467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 17 Aug 2026 17:29:59 +0200 Subject: [PATCH 6/9] fix: close snapshot cutover alias bypasses --- .../runtime-command-cutover-snapshot.test.ts | 51 +++++ .../runtime-command-cutover-snapshot.ts | 178 +++++++++++++++++- 2 files changed, 220 insertions(+), 9 deletions(-) diff --git a/scripts/layering/runtime-command-cutover-snapshot.test.ts b/scripts/layering/runtime-command-cutover-snapshot.test.ts index 98aedbcd39..56f78be787 100644 --- a/scripts/layering/runtime-command-cutover-snapshot.test.ts +++ b/scripts/layering/runtime-command-cutover-snapshot.test.ts @@ -52,3 +52,54 @@ test('R32 snapshot rejects a provider-mode branch in daemon admission', () => { ], ); }); + +test('R32 snapshot rejects device-owner policy through chained aliases', () => { + assert.deepEqual( + violationsFor(` + const identity = device; + const owner = identity; + if (owner.platform === 'apple') return plan; + `), + [ + 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', + ], + ); +}); + +test('R32 snapshot rejects destructured provider-owner policy', () => { + assert.deepEqual( + violationsFor(` + const { providerMode } = facts.device; + if (providerMode === 'provider-runtime') return plan; + `), + [ + 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', + ], + ); +}); + +test('R32 snapshot rejects nested device-leaf destructuring from admission params', () => { + assert.deepEqual( + violationsFor(` + const { device: selectedDevice } = params; + const { kind } = selectedDevice; + if (kind === 'simulator') return plan; + `), + [ + 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', + ], + ); +}); + +test('R32 snapshot rejects device-owner policy through an assigned alias', () => { + assert.deepEqual( + violationsFor(` + let identity; + identity = device; + if (identity.kind === 'simulator') return plan; + `), + [ + 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', + ], + ); +}); diff --git a/scripts/layering/runtime-command-cutover-snapshot.ts b/scripts/layering/runtime-command-cutover-snapshot.ts index 06dca7ec46..179c30854b 100644 --- a/scripts/layering/runtime-command-cutover-snapshot.ts +++ b/scripts/layering/runtime-command-cutover-snapshot.ts @@ -1,5 +1,5 @@ import { parseSync } from 'oxc-parser'; -import { memberPath, visitAst } from './cutover-policy-ast.ts'; +import { propertyName, visitAst } from './cutover-policy-ast.ts'; import { countNamedCalls, lineOf, namedFunction } from './runtime-command-cutover-ast.ts'; import type { UnruledViolation } from './runtime-command-cutover-model.ts'; @@ -34,23 +34,35 @@ export function snapshotPlatformPolicyBranchViolations( ]; } + const aliases = snapshotAdmissionAliases(admission); const violations: UnruledViolation[] = []; const seenLines = new Set(); let readsRequiredOperations = false; let readsOperationFacts = false; let admitsAvailableFacts = false; visitAst(admission, (node) => { - const path = memberPath(node); + const binding = aliasBinding(node); + if (binding !== undefined) { + for (const path of boundPatternPaths( + binding.pattern, + initializerPath(binding.value, aliases), + )) { + if (readsDeviceOwnerIdentity(path)) addOwnerIdentityViolation(node); + } + } + if (node.type !== 'MemberExpression' && node.type !== 'ChainExpression') return; + const path = canonicalMemberPath(node, aliases); if (path === undefined) return; if (samePath(path, ['plan', 'use', 'required'])) readsRequiredOperations = true; if (samePath(path, ['facts', 'operations'])) readsOperationFacts = true; - if (samePath(path, ['fact', 'available'])) admitsAvailableFacts = true; + if (path[0] === 'facts' && path[1] === 'operations' && path[path.length - 1] === 'available') { + admitsAvailableFacts = true; + } - const readsDeviceLeaf = - (path[0] === 'device' && path.length > 1) || - (path[0] === 'params' && path[1] === 'device' && path.length > 2); - const readsOwnerIdentity = path[0] === 'facts' && path[1] === 'device' && path.length > 2; - if (!readsDeviceLeaf && !readsOwnerIdentity) return; + if (readsDeviceOwnerIdentity(path)) addOwnerIdentityViolation(node); + }); + + function addOwnerIdentityViolation(node: AstNode): void { const line = lineOf(source, node); if (seenLines.has(line)) return; seenLines.add(line); @@ -59,7 +71,7 @@ export function snapshotPlatformPolicyBranchViolations( line, message: 'snapshot admission reads device-owner identity instead of selected operation facts', }); - }); + } if (countNamedCalls(admission, 'resolveSnapshotRuntimePlan') !== 1) { violations.push({ @@ -78,6 +90,154 @@ export function snapshotPlatformPolicyBranchViolations( return violations; } +function snapshotAdmissionAliases(admission: AstNode): ReadonlyMap { + const aliases = new Map(); + const params = admission.params; + if (Array.isArray(params)) { + const canonicalParams = [['params'], ['device'], ['session']] as const; + for (const [index, parameter] of params.entries()) { + if (index >= canonicalParams.length || !isIdentifier(parameter)) continue; + aliases.set(parameter.name, canonicalParams[index]); + } + } + + const bindings: AstNode[] = []; + visitAst(admission, (node) => { + if (aliasBinding(node) !== undefined) bindings.push(node); + }); + // Every acyclic alias chain stabilizes within this bound; malformed cycles cannot hang the gate. + for (let pass = 0; pass <= bindings.length; pass += 1) { + let changed = false; + for (const node of bindings) { + const binding = aliasBinding(node)!; + const path = initializerPath(binding.value, aliases); + for (const [name, aliasPath] of boundPatternAliases(binding.pattern, path)) { + if (samePath(aliases.get(name) ?? [], aliasPath)) continue; + aliases.set(name, aliasPath); + changed = true; + } + } + if (!changed) break; + } + return aliases; +} + +function aliasBinding(node: AstNode): Readonly<{ pattern: unknown; value: unknown }> | undefined { + if (node.type === 'VariableDeclarator') return { pattern: node.id, value: node.init }; + if (node.type === 'AssignmentExpression' && node.operator === '=') { + return { pattern: node.left, value: node.right }; + } + return undefined; +} + +function initializerPath( + node: unknown, + aliases: ReadonlyMap, +): readonly string[] | undefined { + const value = unwrapExpression(node); + if (value === undefined) return undefined; + if (value.type === 'CallExpression') { + if (containsNamedCall(value, 'requireRuntimeFacts')) return ['facts']; + if (containsNamedCall(value, 'resolveSnapshotRuntimePlan')) return ['plan']; + } + return canonicalMemberPath(value, aliases); +} + +function canonicalMemberPath( + node: unknown, + aliases: ReadonlyMap, +): readonly string[] | undefined { + const value = unwrapExpression(node); + if (value === undefined) return undefined; + if (value.type === 'Identifier') { + const name = typeof value.name === 'string' ? value.name : undefined; + return name === undefined ? undefined : (aliases.get(name) ?? [name]); + } + if (value.type !== 'MemberExpression') return undefined; + const object = canonicalMemberPath(value.object, aliases); + const name = propertyName(value.property); + return object === undefined || name === undefined + ? undefined + : canonicalizePath([...object, name]); +} + +function canonicalizePath(path: readonly string[]): readonly string[] { + if (path[0] === 'params' && path[1] === 'device') return ['device', ...path.slice(2)]; + if (path[0] === 'params' && path[1] === 'session') return ['session', ...path.slice(2)]; + if (path[0] === 'session' && path[1] === 'device') return ['device', ...path.slice(2)]; + return path; +} + +function boundPatternAliases( + pattern: unknown, + path: readonly string[] | undefined, +): ReadonlyArray { + if (path === undefined || pattern === null || typeof pattern !== 'object') return []; + const value = pattern as AstNode; + if (value.type === 'Identifier' && typeof value.name === 'string') { + return [[value.name, canonicalizePath(path)]]; + } + if (value.type === 'AssignmentPattern') return boundPatternAliases(value.left, path); + if (value.type !== 'ObjectPattern' || !Array.isArray(value.properties)) return []; + const aliases: Array = []; + for (const property of value.properties) { + if (property === null || typeof property !== 'object') continue; + const entry = property as AstNode; + if (entry.type !== 'Property') continue; + const name = propertyName(entry.key); + if (name === undefined) continue; + aliases.push(...boundPatternAliases(entry.value, canonicalizePath([...path, name]))); + } + return aliases; +} + +function boundPatternPaths( + pattern: unknown, + path: readonly string[] | undefined, +): readonly (readonly string[])[] { + return boundPatternAliases(pattern, path).map(([, aliasPath]) => aliasPath); +} + +function readsDeviceOwnerIdentity(path: readonly string[]): boolean { + return ( + (path[0] === 'device' && path.length > 1) || + (path[0] === 'facts' && path[1] === 'device' && path.length > 2) + ); +} + +function containsNamedCall(node: unknown, name: string): boolean { + let found = false; + visitAst(node, (candidate) => { + if (candidate.type !== 'CallExpression') return; + const callee = unwrapExpression(candidate.callee); + if (callee?.type === 'Identifier' && callee.name === name) found = true; + }); + return found; +} + +function unwrapExpression(node: unknown): AstNode | undefined { + if (node === null || typeof node !== 'object') return undefined; + const value = node as AstNode; + if ( + value.type === 'AwaitExpression' || + value.type === 'ChainExpression' || + value.type === 'TSAsExpression' || + value.type === 'TSNonNullExpression' + ) { + return unwrapExpression(value.expression); + } + return value; +} + +function isIdentifier(node: unknown): node is AstNode & { name: string } { + return ( + node !== null && + typeof node === 'object' && + (node as AstNode).type === 'Identifier' && + typeof (node as AstNode).name === 'string' + ); +} + function samePath(actual: readonly string[], expected: readonly string[]): boolean { return ( actual.length === expected.length && actual.every((part, index) => part === expected[index]) From db2f4e4b74cdfae540bbf0762e34579e89676480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 17 Aug 2026 18:00:35 +0200 Subject: [PATCH 7/9] fix: constrain snapshot admission identity flow --- .../runtime-command-cutover-snapshot.test.ts | 18 ++++++ .../runtime-command-cutover-snapshot.ts | 55 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/scripts/layering/runtime-command-cutover-snapshot.test.ts b/scripts/layering/runtime-command-cutover-snapshot.test.ts index 56f78be787..c9f982106c 100644 --- a/scripts/layering/runtime-command-cutover-snapshot.test.ts +++ b/scripts/layering/runtime-command-cutover-snapshot.test.ts @@ -103,3 +103,21 @@ test('R32 snapshot rejects device-owner policy through an assigned alias', () => ], ); }); + +test('R32 snapshot rejects device-owner policy delegated to a renamed helper', () => { + assert.deepEqual(violationsFor('if (supportsIosSimulator(device)) return plan;'), [ + 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', + ]); +}); + +test('R32 snapshot rejects device-owner policy through object-rest identity', () => { + assert.deepEqual( + violationsFor(` + const { ...identity } = device; + if (identity.platform === 'apple') return plan; + `), + [ + 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', + ], + ); +}); diff --git a/scripts/layering/runtime-command-cutover-snapshot.ts b/scripts/layering/runtime-command-cutover-snapshot.ts index 179c30854b..9846e8c9b8 100644 --- a/scripts/layering/runtime-command-cutover-snapshot.ts +++ b/scripts/layering/runtime-command-cutover-snapshot.ts @@ -50,6 +50,13 @@ export function snapshotPlatformPolicyBranchViolations( if (readsDeviceOwnerIdentity(path)) addOwnerIdentityViolation(node); } } + if ( + node.type === 'CallExpression' && + !isAllowedDeviceIdentitySink(node) && + callConsumesDeviceIdentity(node, aliases) + ) { + addOwnerIdentityViolation(node); + } if (node.type !== 'MemberExpression' && node.type !== 'ChainExpression') return; const path = canonicalMemberPath(node, aliases); if (path === undefined) return; @@ -183,6 +190,10 @@ function boundPatternAliases( for (const property of value.properties) { if (property === null || typeof property !== 'object') continue; const entry = property as AstNode; + if (entry.type === 'RestElement') { + aliases.push(...boundPatternAliases(entry.argument, canonicalizePath(path))); + continue; + } if (entry.type !== 'Property') continue; const name = propertyName(entry.key); if (name === undefined) continue; @@ -191,6 +202,46 @@ function boundPatternAliases( return aliases; } +function callConsumesDeviceIdentity( + call: AstNode, + aliases: ReadonlyMap, +): boolean { + if (!Array.isArray(call.arguments)) return false; + return call.arguments.some((argument) => expressionCarriesDeviceIdentity(argument, aliases)); +} + +function expressionCarriesDeviceIdentity( + node: unknown, + aliases: ReadonlyMap, +): boolean { + const value = unwrapExpression(node); + if (value === undefined) return false; + const path = canonicalMemberPath(value, aliases); + if (path !== undefined && isDeviceIdentityPath(path)) return true; + if (value.type === 'SpreadElement') { + return expressionCarriesDeviceIdentity(value.argument, aliases); + } + if (value.type === 'ObjectExpression' && Array.isArray(value.properties)) { + return value.properties.some((property) => { + if (property === null || typeof property !== 'object') return false; + const entry = property as AstNode; + return entry.type === 'SpreadElement' + ? expressionCarriesDeviceIdentity(entry.argument, aliases) + : entry.type === 'Property' && expressionCarriesDeviceIdentity(entry.value, aliases); + }); + } + if (value.type === 'ArrayExpression' && Array.isArray(value.elements)) { + return value.elements.some((element) => expressionCarriesDeviceIdentity(element, aliases)); + } + return false; +} + +function isAllowedDeviceIdentitySink(call: AstNode): boolean { + const callee = unwrapExpression(call.callee); + const factory = callee?.type === 'CallExpression' ? unwrapExpression(callee.callee) : undefined; + if (factory?.type === 'Identifier' && factory.name === 'requireRuntimeFacts') return true; + return callee?.type === 'Identifier' && callee.name === 'snapshotPlanUnavailableResponse'; +} function boundPatternPaths( pattern: unknown, path: readonly string[] | undefined, @@ -205,6 +256,10 @@ function readsDeviceOwnerIdentity(path: readonly string[]): boolean { ); } +function isDeviceIdentityPath(path: readonly string[]): boolean { + return path[0] === 'device' || (path[0] === 'facts' && path[1] === 'device'); +} + function containsNamedCall(node: unknown, name: string): boolean { let found = false; visitAst(node, (candidate) => { From 52a467cb167339a37ff393a9fb389dbdef9747de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 14:13:51 +0200 Subject: [PATCH 8/9] fix: enforce snapshot admission through owner facts --- .../runtime-command-cutover-snapshot.test.ts | 139 +++----- .../runtime-command-cutover-snapshot.ts | 319 +++--------------- .../runtime-command-cutover-table.test.ts | 27 +- .../snapshot-runtime-execution.test.ts | 5 +- .../__tests__/interaction-touch.test.ts | 1 - .../handlers/session-runtime-admission.ts | 33 +- src/daemon/snapshot-runtime-binding.ts | 44 +-- src/daemon/snapshot-runtime.ts | 23 +- 8 files changed, 154 insertions(+), 437 deletions(-) diff --git a/scripts/layering/runtime-command-cutover-snapshot.test.ts b/scripts/layering/runtime-command-cutover-snapshot.test.ts index c9f982106c..47fefae1d0 100644 --- a/scripts/layering/runtime-command-cutover-snapshot.test.ts +++ b/scripts/layering/runtime-command-cutover-snapshot.test.ts @@ -2,122 +2,63 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { snapshotPlatformPolicyBranchViolations } from './runtime-command-cutover-snapshot.ts'; -const SNAPSHOT_RUNTIME_BINDING_FILE = 'src/daemon/snapshot-runtime-binding.ts'; -const SNAPSHOT_FACTS_FIRST_ADMISSION = ` - const facts = await requireRuntimeFacts(params.inspectFacts)(device); - const plan = resolveSnapshotRuntimePlan({ - customActions: params.req.flags?.snapshotCustomActions === true, - hasActiveApp: session?.appBundleId !== undefined, - }); - for (const operation of plan.use.required) { - const fact = facts.operations[operation]; - if (fact.available) continue; - } -`; +const ROUTE = 'src/daemon/snapshot-runtime.ts'; +const BINDING = 'src/daemon/snapshot-runtime-binding.ts'; -function violationsFor(extraAdmission = ''): string[] { +function violationsFor(route: string, binding = ''): string[] { return snapshotPlatformPolicyBranchViolations( new Map([ - [ - SNAPSHOT_RUNTIME_BINDING_FILE, - ` - function inspectSnapshotCaptureAdmission(params, device, session) { - ${SNAPSHOT_FACTS_FIRST_ADMISSION} - ${extraAdmission} - } - `, - ], + [ROUTE, route], + [BINDING, binding], ]), - ).map(({ file, message }) => `${file}: ${message}`); + ).map(({ message }) => message); } -test('R32 snapshot accepts only the normalized plan and selected operation facts seam', () => { - assert.deepEqual(violationsFor(), []); -}); - -test('R32 snapshot rejects a direct device-leaf branch in daemon admission', () => { - assert.deepEqual( - violationsFor("if (device.platform === 'apple' && device.kind === 'simulator') return plan;"), - [ - 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', - ], - ); -}); - -test('R32 snapshot rejects a provider-mode branch in daemon admission', () => { - assert.deepEqual( - violationsFor("if (facts.device.providerMode === 'provider-runtime') return plan;"), - [ - 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', - ], - ); -}); - -test('R32 snapshot rejects device-owner policy through chained aliases', () => { - assert.deepEqual( - violationsFor(` - const identity = device; - const owner = identity; - if (owner.platform === 'apple') return plan; - `), - [ - 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', - ], - ); -}); - -test('R32 snapshot rejects destructured provider-owner policy', () => { - assert.deepEqual( - violationsFor(` - const { providerMode } = facts.device; - if (providerMode === 'provider-runtime') return plan; - `), - [ - 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', - ], - ); -}); +const FACTS_FIRST_ROUTE = ` + async function dispatchSnapshotViaRuntime(params) { + const plan = resolveSnapshotRuntimePlan(normalizedIntent); + const admission = await inspectRequiredRuntimeUse({ + device, + use: plan.use, + inspectFacts: params.inspectFacts, + }); + if (!admission.admitted) return unavailable(admission); + return bindSnapshotCaptureRuntime(params.bindDevice, device, plan); + } +`; -test('R32 snapshot rejects nested device-leaf destructuring from admission params', () => { - assert.deepEqual( - violationsFor(` - const { device: selectedDevice } = params; - const { kind } = selectedDevice; - if (kind === 'simulator') return plan; - `), - [ - 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', - ], - ); +test('R32 accepts the normalized plan through the shared facts-first seam', () => { + assert.deepEqual(violationsFor(FACTS_FIRST_ROUTE), []); }); -test('R32 snapshot rejects device-owner policy through an assigned alias', () => { +test('R32 rejects a locally reimplemented admission policy, including object-wrapped identity', () => { assert.deepEqual( - violationsFor(` - let identity; - identity = device; - if (identity.kind === 'simulator') return plan; - `), + violationsFor( + ` + async function dispatchSnapshotViaRuntime(params) { + const plan = resolveSnapshotRuntimePlan(normalizedIntent); + return inspectSnapshotCaptureAdmission(params, plan); + } + `, + ` + function inspectSnapshotCaptureAdmission(device) { + const wrapped = { device }; + if (wrapped.device.platform === 'apple') return { admitted: true }; + } + `, + ), [ - 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', + 'snapshot route must admit exactly once through inspectRequiredRuntimeUse(device, plan.use, inspectFacts)', + 'snapshot admission must not be reimplemented beside the shared facts seam', ], ); }); -test('R32 snapshot rejects device-owner policy delegated to a renamed helper', () => { - assert.deepEqual(violationsFor('if (supportsIosSimulator(device)) return plan;'), [ - 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', - ]); -}); - -test('R32 snapshot rejects device-owner policy through object-rest identity', () => { +test('R32 rejects an admission call not coupled to the selected plan use', () => { assert.deepEqual( - violationsFor(` - const { ...identity } = device; - if (identity.platform === 'apple') return plan; - `), + violationsFor(FACTS_FIRST_ROUTE.replace('use: plan.use', 'use: captureSnapshotUse')), [ - 'src/daemon/snapshot-runtime-binding.ts: snapshot admission reads device-owner identity instead of selected operation facts', + 'snapshot route must admit exactly once through inspectRequiredRuntimeUse(device, plan.use, inspectFacts)', ], ); }); diff --git a/scripts/layering/runtime-command-cutover-snapshot.ts b/scripts/layering/runtime-command-cutover-snapshot.ts index 9846e8c9b8..d512d538bb 100644 --- a/scripts/layering/runtime-command-cutover-snapshot.ts +++ b/scripts/layering/runtime-command-cutover-snapshot.ts @@ -1,300 +1,87 @@ import { parseSync } from 'oxc-parser'; -import { propertyName, visitAst } from './cutover-policy-ast.ts'; +import { memberPath, propertyName, visitAst } from './cutover-policy-ast.ts'; import { countNamedCalls, lineOf, namedFunction } from './runtime-command-cutover-ast.ts'; import type { UnruledViolation } from './runtime-command-cutover-model.ts'; type AstNode = Record; -const SNAPSHOT_RUNTIME_BINDING_FILE = 'src/daemon/snapshot-runtime-binding.ts'; -const SNAPSHOT_ADMISSION_FUNCTION = 'inspectSnapshotCaptureAdmission'; +const SNAPSHOT_ROUTE_FILE = 'src/daemon/snapshot-runtime.ts'; +const SNAPSHOT_BINDING_FILE = 'src/daemon/snapshot-runtime-binding.ts'; -/** Snapshot admission consumes the selected plan and operation facts, never device-owner identity. */ +/** R32 admits only through the shared facts-first seam selected by the normalized runtime plan. */ export function snapshotPlatformPolicyBranchViolations( sources: ReadonlyMap, ): UnruledViolation[] { - const source = sources.get(SNAPSHOT_RUNTIME_BINDING_FILE); - if (source === undefined) { - return [ - { - file: SNAPSHOT_RUNTIME_BINDING_FILE, - line: 1, - message: 'snapshot facts-first admission module is missing', - }, - ]; - } - const program = parseSync(SNAPSHOT_RUNTIME_BINDING_FILE, source).program as AstNode; - const admission = namedFunction(program, SNAPSHOT_ADMISSION_FUNCTION); - if (admission === undefined) { - return [ - { - file: SNAPSHOT_RUNTIME_BINDING_FILE, - line: 1, - message: `snapshot admission must be owned by ${SNAPSHOT_ADMISSION_FUNCTION}`, - }, - ]; - } + const source = sources.get(SNAPSHOT_ROUTE_FILE); + if (source === undefined) return [violation(1, 'snapshot runtime route is missing')]; - const aliases = snapshotAdmissionAliases(admission); - const violations: UnruledViolation[] = []; - const seenLines = new Set(); - let readsRequiredOperations = false; - let readsOperationFacts = false; - let admitsAvailableFacts = false; - visitAst(admission, (node) => { - const binding = aliasBinding(node); - if (binding !== undefined) { - for (const path of boundPatternPaths( - binding.pattern, - initializerPath(binding.value, aliases), - )) { - if (readsDeviceOwnerIdentity(path)) addOwnerIdentityViolation(node); - } - } - if ( - node.type === 'CallExpression' && - !isAllowedDeviceIdentitySink(node) && - callConsumesDeviceIdentity(node, aliases) - ) { - addOwnerIdentityViolation(node); - } - if (node.type !== 'MemberExpression' && node.type !== 'ChainExpression') return; - const path = canonicalMemberPath(node, aliases); - if (path === undefined) return; - if (samePath(path, ['plan', 'use', 'required'])) readsRequiredOperations = true; - if (samePath(path, ['facts', 'operations'])) readsOperationFacts = true; - if (path[0] === 'facts' && path[1] === 'operations' && path[path.length - 1] === 'available') { - admitsAvailableFacts = true; - } + const program = parseSync(SNAPSHOT_ROUTE_FILE, source).program as AstNode; + const route = namedFunction(program, 'dispatchSnapshotViaRuntime'); + if (route === undefined) return [violation(1, 'snapshot public runtime route is missing')]; - if (readsDeviceOwnerIdentity(path)) addOwnerIdentityViolation(node); - }); - - function addOwnerIdentityViolation(node: AstNode): void { - const line = lineOf(source, node); - if (seenLines.has(line)) return; - seenLines.add(line); - violations.push({ - file: SNAPSHOT_RUNTIME_BINDING_FILE, - line, - message: 'snapshot admission reads device-owner identity instead of selected operation facts', - }); + const violations: UnruledViolation[] = []; + if (countNamedCalls(route, 'resolveSnapshotRuntimePlan') !== 1) { + violations.push( + violation(lineOf(source, route), 'snapshot route must select exactly one normalized plan'), + ); } - if (countNamedCalls(admission, 'resolveSnapshotRuntimePlan') !== 1) { - violations.push({ - file: SNAPSHOT_RUNTIME_BINDING_FILE, - line: lineOf(source, admission), - message: 'snapshot admission must select exactly one normalized runtime plan', - }); + const admissions = namedCalls(route, 'inspectRequiredRuntimeUse'); + if (admissions.length !== 1 || !hasExactAdmissionInput(admissions[0])) { + violations.push( + violation( + lineOf(source, admissions[0] ?? route), + 'snapshot route must admit exactly once through inspectRequiredRuntimeUse(device, plan.use, inspectFacts)', + ), + ); } - if (!readsRequiredOperations || !readsOperationFacts || !admitsAvailableFacts) { + + const bindingSource = sources.get(SNAPSHOT_BINDING_FILE) ?? ''; + if (bindingSource.includes('inspectSnapshotCaptureAdmission')) { violations.push({ - file: SNAPSHOT_RUNTIME_BINDING_FILE, - line: lineOf(source, admission), - message: 'snapshot admission must admit every selected operation through owner facts', + file: SNAPSHOT_BINDING_FILE, + line: 1, + message: 'snapshot admission must not be reimplemented beside the shared facts seam', }); } return violations; } -function snapshotAdmissionAliases(admission: AstNode): ReadonlyMap { - const aliases = new Map(); - const params = admission.params; - if (Array.isArray(params)) { - const canonicalParams = [['params'], ['device'], ['session']] as const; - for (const [index, parameter] of params.entries()) { - if (index >= canonicalParams.length || !isIdentifier(parameter)) continue; - aliases.set(parameter.name, canonicalParams[index]); - } - } - - const bindings: AstNode[] = []; - visitAst(admission, (node) => { - if (aliasBinding(node) !== undefined) bindings.push(node); +function namedCalls(node: AstNode, name: string): AstNode[] { + const calls: AstNode[] = []; + visitAst(node, (candidate) => { + if (candidate.type !== 'CallExpression') return; + const callee = candidate.callee as AstNode | undefined; + if (callee?.type === 'Identifier' && callee.name === name) calls.push(candidate); }); - // Every acyclic alias chain stabilizes within this bound; malformed cycles cannot hang the gate. - for (let pass = 0; pass <= bindings.length; pass += 1) { - let changed = false; - for (const node of bindings) { - const binding = aliasBinding(node)!; - const path = initializerPath(binding.value, aliases); - for (const [name, aliasPath] of boundPatternAliases(binding.pattern, path)) { - if (samePath(aliases.get(name) ?? [], aliasPath)) continue; - aliases.set(name, aliasPath); - changed = true; - } - } - if (!changed) break; - } - return aliases; -} - -function aliasBinding(node: AstNode): Readonly<{ pattern: unknown; value: unknown }> | undefined { - if (node.type === 'VariableDeclarator') return { pattern: node.id, value: node.init }; - if (node.type === 'AssignmentExpression' && node.operator === '=') { - return { pattern: node.left, value: node.right }; - } - return undefined; -} - -function initializerPath( - node: unknown, - aliases: ReadonlyMap, -): readonly string[] | undefined { - const value = unwrapExpression(node); - if (value === undefined) return undefined; - if (value.type === 'CallExpression') { - if (containsNamedCall(value, 'requireRuntimeFacts')) return ['facts']; - if (containsNamedCall(value, 'resolveSnapshotRuntimePlan')) return ['plan']; - } - return canonicalMemberPath(value, aliases); -} - -function canonicalMemberPath( - node: unknown, - aliases: ReadonlyMap, -): readonly string[] | undefined { - const value = unwrapExpression(node); - if (value === undefined) return undefined; - if (value.type === 'Identifier') { - const name = typeof value.name === 'string' ? value.name : undefined; - return name === undefined ? undefined : (aliases.get(name) ?? [name]); - } - if (value.type !== 'MemberExpression') return undefined; - const object = canonicalMemberPath(value.object, aliases); - const name = propertyName(value.property); - return object === undefined || name === undefined - ? undefined - : canonicalizePath([...object, name]); -} - -function canonicalizePath(path: readonly string[]): readonly string[] { - if (path[0] === 'params' && path[1] === 'device') return ['device', ...path.slice(2)]; - if (path[0] === 'params' && path[1] === 'session') return ['session', ...path.slice(2)]; - if (path[0] === 'session' && path[1] === 'device') return ['device', ...path.slice(2)]; - return path; + return calls; } -function boundPatternAliases( - pattern: unknown, - path: readonly string[] | undefined, -): ReadonlyArray { - if (path === undefined || pattern === null || typeof pattern !== 'object') return []; - const value = pattern as AstNode; - if (value.type === 'Identifier' && typeof value.name === 'string') { - return [[value.name, canonicalizePath(path)]]; +function hasExactAdmissionInput(call: AstNode | undefined): boolean { + const argument = Array.isArray(call?.arguments) ? call.arguments[0] : undefined; + if (!isNode(argument, 'ObjectExpression') || !Array.isArray(argument.properties)) return false; + const properties = new Map(); + for (const candidate of argument.properties) { + if (!isNode(candidate, 'Property')) continue; + const name = propertyName(candidate.key); + if (name !== undefined) properties.set(name, candidate.value); } - if (value.type === 'AssignmentPattern') return boundPatternAliases(value.left, path); - if (value.type !== 'ObjectPattern' || !Array.isArray(value.properties)) return []; - const aliases: Array = []; - for (const property of value.properties) { - if (property === null || typeof property !== 'object') continue; - const entry = property as AstNode; - if (entry.type === 'RestElement') { - aliases.push(...boundPatternAliases(entry.argument, canonicalizePath(path))); - continue; - } - if (entry.type !== 'Property') continue; - const name = propertyName(entry.key); - if (name === undefined) continue; - aliases.push(...boundPatternAliases(entry.value, canonicalizePath([...path, name]))); - } - return aliases; -} - -function callConsumesDeviceIdentity( - call: AstNode, - aliases: ReadonlyMap, -): boolean { - if (!Array.isArray(call.arguments)) return false; - return call.arguments.some((argument) => expressionCarriesDeviceIdentity(argument, aliases)); -} - -function expressionCarriesDeviceIdentity( - node: unknown, - aliases: ReadonlyMap, -): boolean { - const value = unwrapExpression(node); - if (value === undefined) return false; - const path = canonicalMemberPath(value, aliases); - if (path !== undefined && isDeviceIdentityPath(path)) return true; - if (value.type === 'SpreadElement') { - return expressionCarriesDeviceIdentity(value.argument, aliases); - } - if (value.type === 'ObjectExpression' && Array.isArray(value.properties)) { - return value.properties.some((property) => { - if (property === null || typeof property !== 'object') return false; - const entry = property as AstNode; - return entry.type === 'SpreadElement' - ? expressionCarriesDeviceIdentity(entry.argument, aliases) - : entry.type === 'Property' && expressionCarriesDeviceIdentity(entry.value, aliases); - }); - } - if (value.type === 'ArrayExpression' && Array.isArray(value.elements)) { - return value.elements.some((element) => expressionCarriesDeviceIdentity(element, aliases)); - } - return false; -} - -function isAllowedDeviceIdentitySink(call: AstNode): boolean { - const callee = unwrapExpression(call.callee); - const factory = callee?.type === 'CallExpression' ? unwrapExpression(callee.callee) : undefined; - if (factory?.type === 'Identifier' && factory.name === 'requireRuntimeFacts') return true; - return callee?.type === 'Identifier' && callee.name === 'snapshotPlanUnavailableResponse'; -} -function boundPatternPaths( - pattern: unknown, - path: readonly string[] | undefined, -): readonly (readonly string[])[] { - return boundPatternAliases(pattern, path).map(([, aliasPath]) => aliasPath); -} - -function readsDeviceOwnerIdentity(path: readonly string[]): boolean { return ( - (path[0] === 'device' && path.length > 1) || - (path[0] === 'facts' && path[1] === 'device' && path.length > 2) + hasPath(properties.get('device'), ['device']) && + hasPath(properties.get('use'), ['plan', 'use']) && + hasPath(properties.get('inspectFacts'), ['params', 'inspectFacts']) ); } -function isDeviceIdentityPath(path: readonly string[]): boolean { - return path[0] === 'device' || (path[0] === 'facts' && path[1] === 'device'); +function hasPath(node: unknown, expected: readonly string[]): boolean { + const path = memberPath(node); + return path?.length === expected.length && path.every((part, index) => part === expected[index]); } -function containsNamedCall(node: unknown, name: string): boolean { - let found = false; - visitAst(node, (candidate) => { - if (candidate.type !== 'CallExpression') return; - const callee = unwrapExpression(candidate.callee); - if (callee?.type === 'Identifier' && callee.name === name) found = true; - }); - return found; -} - -function unwrapExpression(node: unknown): AstNode | undefined { - if (node === null || typeof node !== 'object') return undefined; - const value = node as AstNode; - if ( - value.type === 'AwaitExpression' || - value.type === 'ChainExpression' || - value.type === 'TSAsExpression' || - value.type === 'TSNonNullExpression' - ) { - return unwrapExpression(value.expression); - } - return value; +function isNode(node: unknown, type: string): node is AstNode { + return node !== null && typeof node === 'object' && (node as AstNode).type === type; } -function isIdentifier(node: unknown): node is AstNode & { name: string } { - return ( - node !== null && - typeof node === 'object' && - (node as AstNode).type === 'Identifier' && - typeof (node as AstNode).name === 'string' - ); -} - -function samePath(actual: readonly string[], expected: readonly string[]): boolean { - return ( - actual.length === expected.length && actual.every((part, index) => part === expected[index]) - ); +function violation(line: number, message: string): UnruledViolation { + return { file: SNAPSHOT_ROUTE_FILE, line, message }; } diff --git a/scripts/layering/runtime-command-cutover-table.test.ts b/scripts/layering/runtime-command-cutover-table.test.ts index 55b34d11a5..f989a5d191 100644 --- a/scripts/layering/runtime-command-cutover-table.test.ts +++ b/scripts/layering/runtime-command-cutover-table.test.ts @@ -6,18 +6,6 @@ import { cutoverTableDefects } from './runtime-command-cutover-model.ts'; import { MIGRATED_COMMAND_CUTOVERS } from './runtime-command-cutover-table.ts'; import { commandDescriptors } from '../../src/core/command-descriptor/registry.ts'; -const SNAPSHOT_FACTS_FIRST_ADMISSION = ` - const facts = await requireRuntimeFacts(params.inspectFacts)(device); - const plan = resolveSnapshotRuntimePlan({ - customActions: params.req.flags?.snapshotCustomActions === true, - hasActiveApp: session?.appBundleId !== undefined, - }); - for (const operation of plan.use.required) { - const fact = facts.operations[operation]; - if (fact.available) continue; - } -`; - test('R20 boot rejects the superseded root readiness adapter', () => { assert.deepEqual( summariesFor('R20 boot-runtime-cutover', [ @@ -81,6 +69,12 @@ test('R32 snapshot rejects legacy admission and dispatcher projection', () => { runtime.operations.captureSnapshotWithoutActiveApp(input); } function dispatchSnapshotViaRuntime() { + const plan = resolveSnapshotRuntimePlan(normalizedIntent); + inspectRequiredRuntimeUse({ + device, + use: plan.use, + inspectFacts: params.inspectFacts, + }); requireCommandSupported('snapshot', device); } function handleSnapshotCommands() { @@ -89,14 +83,7 @@ test('R32 snapshot rejects legacy admission and dispatcher projection', () => { handleSnapshotCommands(request); `, ], - [ - 'src/daemon/snapshot-runtime-binding.ts', - ` - export async function inspectSnapshotCaptureAdmission(params, device, session) { - ${SNAPSHOT_FACTS_FIRST_ADMISSION} - } - `, - ], + ['src/daemon/snapshot-runtime-binding.ts', ''], ['src/core/dispatch.ts', 'function handleSnapshotCommand() { return legacy.snapshot(); }'], [ 'src/core/capabilities.ts', diff --git a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts index a9e9d043a7..9652cad848 100644 --- a/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts +++ b/src/core/command-descriptor/__tests__/snapshot-runtime-execution.test.ts @@ -35,12 +35,13 @@ test('snapshot public route inspects facts and binds the declared use exactly on snapshotRuntimeSource.indexOf('function publishedSnapshotGeneration'), ); - expect(publicRoute.match(/inspectSnapshotCaptureAdmission\(\{/g)).toHaveLength(1); + expect(publicRoute.match(/resolveSnapshotRuntimePlan\(\{/g)).toHaveLength(1); + expect(publicRoute.match(/inspectRequiredRuntimeUse\(\{/g)).toHaveLength(1); expect( publicRoute.match(/bindSnapshotCaptureRuntime\(params\.bindDevice, device, plan\)/g), ).toHaveLength(1); expect( - snapshotRuntimeBindingSource.match(/requireRuntimeFacts\(params\.inspectFacts\)\(device\)/g), + publicRoute.match(/use: plan\.use,[\s\S]*inspectFacts: params\.inspectFacts/g), ).toHaveLength(1); expect( snapshotRuntimeBindingSource.match(/const bind = requireRuntimeBinding\(bindDevice\)/g), diff --git a/src/daemon/handlers/__tests__/interaction-touch.test.ts b/src/daemon/handlers/__tests__/interaction-touch.test.ts index 55781682c1..cccf539713 100644 --- a/src/daemon/handlers/__tests__/interaction-touch.test.ts +++ b/src/daemon/handlers/__tests__/interaction-touch.test.ts @@ -285,7 +285,6 @@ test('hover selector on web resolves the target and dispatches coordinate hover' hittable: true, }, ]), - createdAt: Date.now(), backend: 'web', }); diff --git a/src/daemon/handlers/session-runtime-admission.ts b/src/daemon/handlers/session-runtime-admission.ts index 462b5b1269..edea741da5 100644 --- a/src/daemon/handlers/session-runtime-admission.ts +++ b/src/daemon/handlers/session-runtime-admission.ts @@ -1,4 +1,9 @@ -import type { RuntimeOperationFact } from '@agent-device/contracts/platform'; +import type { + PlatformRuntimeOperations, + RuntimeOperationFact, + RuntimeOperationKey, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { SessionStore } from '../session-store.ts'; @@ -34,6 +39,32 @@ export function requireRuntimeFacts( throw new AppError('COMMAND_FAILED', 'Device runtime facts inspection is unavailable.'); } +export async function inspectRequiredRuntimeUse< + const Use extends Readonly<{ + required: readonly RuntimeOperationKey[]; + }>, +>( + params: Readonly<{ + device: DeviceInfo; + use: Use; + inspectFacts?: InspectDeviceRuntimeFacts; + }>, +): Promise< + | Readonly<{ admitted: true }> + | Readonly<{ + admitted: false; + operation: Use['required'][number]; + fact: RuntimeOperationFact; + }> +> { + const facts = await requireRuntimeFacts(params.inspectFacts)(params.device); + for (const operation of params.use.required) { + const fact = facts.operations[operation]; + if (!fact.available) return { admitted: false, operation, fact }; + } + return { admitted: true }; +} + export function requireRuntimeBinding( bindDevice: BindDeviceRuntime | undefined, ): BindDeviceRuntime { diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index 6985b513ae..d1b913059e 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -1,5 +1,4 @@ import { - resolveSnapshotRuntimePlan, type CaptureSnapshotInput, type RuntimeOperationFact, type SnapshotResult, @@ -8,53 +7,14 @@ import { } from '@agent-device/contracts/platform'; import { buildIosOpenCommandHint } from './ios-app-session-hint.ts'; import { contextFromFlags } from './context.ts'; -import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; +import type { BindDeviceRuntime } from './request-runtime-binding.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { requireRuntimeBinding, - requireRuntimeFacts, unavailableRuntimeOperationResponse, } from './handlers/session-runtime-admission.ts'; import { errorResponse } from './handlers/response.ts'; -type SnapshotCaptureAdmission = - | Readonly<{ admitted: false; response: DaemonResponse }> - | Readonly<{ - admitted: true; - session: SessionState | undefined; - device: SessionState['device']; - plan: SnapshotRuntimePlan; - }>; - -/** Facts-first admission for the one public snapshot runtime unit. */ -export async function inspectSnapshotCaptureAdmission(params: { - req: DaemonRequest; - session: SessionState | undefined; - device: SessionState['device']; - inspectFacts?: InspectDeviceRuntimeFacts; -}): Promise { - const { session, device } = params; - const facts = await requireRuntimeFacts(params.inspectFacts)(device); - const plan = resolveSnapshotRuntimePlan({ - customActions: params.req.flags?.snapshotCustomActions === true, - hasActiveApp: session?.appBundleId !== undefined, - }); - for (const operation of plan.use.required) { - const fact = facts.operations[operation]; - if (fact.available) continue; - return { - admitted: false, - response: await snapshotPlanUnavailableResponse({ operation, fact, session, device }), - }; - } - return { - admitted: true, - session, - device, - plan, - }; -} - export async function bindSnapshotCaptureRuntime( bindDevice: BindDeviceRuntime | undefined, device: SessionState['device'], @@ -110,7 +70,7 @@ function selectSnapshotWithoutActiveApp( }); } -async function snapshotPlanUnavailableResponse(params: { +export async function snapshotPlanUnavailableResponse(params: { operation: SnapshotRuntimePlan['use']['required'][number]; fact: RuntimeOperationFact; session: SessionState | undefined; diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index 8d85925ab4..6749e3f82d 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -4,6 +4,7 @@ import { type SnapshotDiffSummary, } from '@agent-device/contracts/capture'; import { + resolveSnapshotRuntimePlan, stripAndroidSystemChromeProvenance, type SnapshotResult, } from '@agent-device/contracts/platform'; @@ -15,6 +16,7 @@ import { createAgentDevice } from '../runtime.ts'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; import { maybeBuildAndroidSnapshotTimeoutFailure } from './android-snapshot-timeout-evidence.ts'; import { requireCommandSupported } from './handlers/response.ts'; +import { inspectRequiredRuntimeUse } from './handlers/session-runtime-admission.ts'; import { captureSnapshot, resolveSnapshotScope } from './handlers/snapshot-capture.ts'; import { buildSnapshotSession, @@ -38,7 +40,7 @@ import type { DaemonRequest, DaemonResponse, DaemonResponseData, SessionState } import { bindSnapshotCaptureRuntime, buildRuntimeCaptureInput, - inspectSnapshotCaptureAdmission, + snapshotPlanUnavailableResponse, } from './snapshot-runtime-binding.ts'; import { requireLegacyDiffCustomActionsSupported, @@ -58,14 +60,23 @@ export async function dispatchSnapshotViaRuntime(params: { const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); if (!resolvedScope.ok) return resolvedScope; - const admission = await inspectSnapshotCaptureAdmission({ - req, - session, + const plan = resolveSnapshotRuntimePlan({ + customActions: req.flags?.snapshotCustomActions === true, + hasActiveApp: session?.appBundleId !== undefined, + }); + const admission = await inspectRequiredRuntimeUse({ device, + use: plan.use, inspectFacts: params.inspectFacts, }); - if (!admission.admitted) return admission.response; - const { plan } = admission; + if (!admission.admitted) { + return await snapshotPlanUnavailableResponse({ + operation: admission.operation, + fact: admission.fact, + session, + device, + }); + } const runtime = await bindSnapshotCaptureRuntime(params.bindDevice, device, plan); return await withSessionlessRunnerCleanup( session, From bea6ce36dc22969651b5b386b1973867f6c0af63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 15:11:48 +0200 Subject: [PATCH 9/9] fix: adapt replay source tests to snapshot runtime --- .../__tests__/session-replay-script-source.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts index 46bc805bb0..14de3c5dae 100644 --- a/src/daemon/handlers/__tests__/session-replay-script-source.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-script-source.test.ts @@ -12,6 +12,9 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; }); +vi.mock('../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); import fs from 'node:fs'; import path from 'node:path'; @@ -24,10 +27,16 @@ import { replayScriptSourceBundleFor, } from '../../../__tests__/test-utils/replay-script-source.ts'; import { REPLAY_SCRIPT_SOURCE_REQUIRED_MESSAGE } from '../../../replay/script-source-bundle.ts'; +import { captureSnapshotThroughLegacyDispatchFixture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { captureSnapshotWithInteractor } from '../snapshot-interactor-capture.ts'; + +const mockCaptureSnapshotWithInteractor = vi.mocked(captureSnapshotWithInteractor); beforeEach(() => { vi.mocked(dispatchCommand).mockReset(); vi.mocked(dispatchCommand).mockResolvedValue({}); + mockCaptureSnapshotWithInteractor.mockReset(); + mockCaptureSnapshotWithInteractor.mockImplementation(captureSnapshotThroughLegacyDispatchFixture); }); test('a bundled replay runs after the script file is gone from the daemon host', async () => {