diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 3d7ffb08b1..940463268f 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -36,58 +36,6 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, - "./testing/__tests__/android-observation-fixture": { - "types": "./src/__tests__/support/android-observation-fixture.ts", - "default": "./src/__tests__/support/android-observation-fixture.ts" - }, - "./testing/__tests__/application-lifecycle-runtime-fixture": { - "types": "./src/__tests__/support/application-lifecycle-runtime-fixture.ts", - "default": "./src/__tests__/support/application-lifecycle-runtime-fixture.ts" - }, - "./testing/__tests__/capture-disclosure-target-activation-fact": { - "types": "./src/core/__tests__/capture-disclosure-target-activation-fact.ts", - "default": "./src/core/__tests__/capture-disclosure-target-activation-fact.ts" - }, - "./testing/__tests__/interaction-get-runtime-fixture": { - "types": "./src/execution/__tests__/interaction-get-runtime-fixture.ts", - "default": "./src/execution/__tests__/interaction-get-runtime-fixture.ts" - }, - "./testing/__tests__/legacy-snapshot-capture-fixture": { - "types": "./src/__tests__/support/legacy-snapshot-capture-fixture.ts", - "default": "./src/__tests__/support/legacy-snapshot-capture-fixture.ts" - }, - "./testing/__tests__/request-router-dispatch-mocks": { - "types": "./src/__tests__/support/request-router-dispatch-mocks.ts", - "default": "./src/__tests__/support/request-router-dispatch-mocks.ts" - }, - "./testing/__tests__/request-router-open-harness": { - "types": "./src/server/__tests__/request-router-open-harness.ts", - "default": "./src/server/__tests__/request-router-open-harness.ts" - }, - "./testing/__tests__/runtime-binding-conformance": { - "types": "./src/__tests__/support/runtime-binding-conformance.ts", - "default": "./src/__tests__/support/runtime-binding-conformance.ts" - }, - "./testing/__tests__/screenshot-runtime-fixture": { - "types": "./src/__tests__/support/screenshot-runtime-fixture.ts", - "default": "./src/__tests__/support/screenshot-runtime-fixture.ts" - }, - "./testing/__tests__/selector-capture-fixture": { - "types": "./src/__tests__/support/selector-capture-fixture.ts", - "default": "./src/__tests__/support/selector-capture-fixture.ts" - }, - "./testing/__tests__/snapshot-runtime-fixture": { - "types": "./src/__tests__/support/snapshot-runtime-fixture.ts", - "default": "./src/__tests__/support/snapshot-runtime-fixture.ts" - }, - "./testing/__tests__/support/command-surface-fake": { - "types": "./src/__tests__/support/command-surface-fake.ts", - "default": "./src/__tests__/support/command-surface-fake.ts" - }, - "./testing/__tests__/support/screen-recording-live-handle": { - "types": "./src/__tests__/support/screen-recording-live-handle.ts", - "default": "./src/__tests__/support/screen-recording-live-handle.ts" - }, "./testing/__tests__/support/session-factories": { "types": "./src/__tests__/support/session-factories.ts", "default": "./src/__tests__/support/session-factories.ts" @@ -100,26 +48,10 @@ "types": "./src/__tests__/support/unavailable-device-runtime-gateway.ts", "default": "./src/__tests__/support/unavailable-device-runtime-gateway.ts" }, - "./testing/__tests__/test-device-runtime-gateway": { - "types": "./src/__tests__/support/test-device-runtime-gateway.ts", - "default": "./src/__tests__/support/test-device-runtime-gateway.ts" - }, - "./testing/android-system-dialog": { - "types": "./src/execution/android-system-dialog.ts", - "default": "./src/execution/android-system-dialog.ts" - }, - "./testing/app-events": { - "types": "./src/execution/app-events.ts", - "default": "./src/execution/app-events.ts" - }, "./testing/artifact-tracking": { "types": "./src/resources/artifact-tracking.ts", "default": "./src/resources/artifact-tracking.ts" }, - "./testing/capture-disclosure": { - "types": "./src/core/capture-disclosure.ts", - "default": "./src/core/capture-disclosure.ts" - }, "./testing/context": { "types": "./src/core/context.ts", "default": "./src/core/context.ts" @@ -128,42 +60,18 @@ "types": "./src/core/daemon-command-registry.ts", "default": "./src/core/daemon-command-registry.ts" }, - "./testing/deferred-interaction-outcome": { - "types": "./src/core/deferred-interaction-outcome.ts", - "default": "./src/core/deferred-interaction-outcome.ts" - }, "./testing/device/device-claim-conflict": { "types": "./src/resources/device/device-claim-conflict.ts", "default": "./src/resources/device/device-claim-conflict.ts" }, - "./testing/device/device-ready": { - "types": "./src/core/device/device-ready.ts", - "default": "./src/core/device/device-ready.ts" - }, - "./testing/generic-leaf-execution": { - "types": "./src/core/generic-leaf-execution.ts", - "default": "./src/core/generic-leaf-execution.ts" - }, - "./testing/gesture-runtime": { - "types": "./src/core/gesture-runtime.ts", - "default": "./src/core/gesture-runtime.ts" - }, "./testing/handlers/session-app-deployment": { "types": "./src/sessions/handlers/session-app-deployment.ts", "default": "./src/sessions/handlers/session-app-deployment.ts" }, - "./testing/handlers/snapshot": { - "types": "./src/sessions/handlers/snapshot.ts", - "default": "./src/sessions/handlers/snapshot.ts" - }, "./testing/interaction/index": { "types": "./src/execution/interaction/index.ts", "default": "./src/execution/interaction/index.ts" }, - "./testing/interaction/internal/__tests__/*": { - "types": "./src/execution/interaction/internal/__tests__/*.ts", - "default": "./src/execution/interaction/internal/__tests__/*.ts" - }, "./testing/interaction/internal/interaction-touch-response": { "types": "./src/execution/interaction/internal/interaction-touch-response.ts", "default": "./src/execution/interaction/internal/interaction-touch-response.ts" @@ -196,10 +104,6 @@ "types": "./src/server/request-binding.ts", "default": "./src/server/request-binding.ts" }, - "./testing/request-dispatch-disclosure": { - "types": "./src/execution/request-dispatch-disclosure.ts", - "default": "./src/execution/request-dispatch-disclosure.ts" - }, "./testing/request-dispatch-ledger": { "types": "./src/core/request-dispatch-ledger.ts", "default": "./src/core/request-dispatch-ledger.ts" @@ -212,18 +116,6 @@ "types": "./src/server/request-finalization.ts", "default": "./src/server/request-finalization.ts" }, - "./testing/request-generic-dispatch": { - "types": "./src/execution/request-generic-dispatch.ts", - "default": "./src/execution/request-generic-dispatch.ts" - }, - "./testing/request-handler-chain": { - "types": "./src/sessions/request-handler-chain.ts", - "default": "./src/sessions/request-handler-chain.ts" - }, - "./testing/request-platform-provider-context": { - "types": "./src/server/request-platform-provider-context.ts", - "default": "./src/server/request-platform-provider-context.ts" - }, "./testing/request-router": { "types": "./src/server/request-router.ts", "default": "./src/server/request-router.ts" @@ -236,30 +128,14 @@ "types": "./src/server/response-views.ts", "default": "./src/server/response-views.ts" }, - "./testing/screenshot-runtime": { - "types": "./src/execution/screenshot-runtime.ts", - "default": "./src/execution/screenshot-runtime.ts" - }, "./testing/scroll-until": { "types": "./src/execution/scroll-until.ts", "default": "./src/execution/scroll-until.ts" }, - "./testing/selector-capture-binding": { - "types": "./src/execution/selector-capture-binding.ts", - "default": "./src/execution/selector-capture-binding.ts" - }, "./testing/selector-match-errors": { "types": "./src/execution/selector-match-errors.ts", "default": "./src/execution/selector-match-errors.ts" }, - "./testing/selector-runtime": { - "types": "./src/execution/selector-runtime.ts", - "default": "./src/execution/selector-runtime.ts" - }, - "./testing/selector-runtime-backend": { - "types": "./src/execution/selector-runtime-backend.ts", - "default": "./src/execution/selector-runtime-backend.ts" - }, "./testing/server/http-server": { "types": "./src/server/http/http-server.ts", "default": "./src/server/http/http-server.ts" @@ -268,10 +144,6 @@ "types": "./src/core/session-action-recorder.ts", "default": "./src/core/session-action-recorder.ts" }, - "./testing/session-capture-binding": { - "types": "./src/core/session-capture-binding.ts", - "default": "./src/core/session-capture-binding.ts" - }, "./testing/session-observability/__tests__/*": { "types": "./src/sessions/session-observability/__tests__/*.ts", "default": "./src/sessions/session-observability/__tests__/*.ts" @@ -292,26 +164,6 @@ "types": "./src/core/session-store.ts", "default": "./src/core/session-store.ts" }, - "./testing/snapshot-diff-runtime": { - "types": "./src/sessions/snapshot-diff-runtime.ts", - "default": "./src/sessions/snapshot-diff-runtime.ts" - }, - "./testing/snapshot-interactor-capture": { - "types": "./src/core/snapshot-interactor-capture.ts", - "default": "./src/core/snapshot-interactor-capture.ts" - }, - "./testing/snapshot-quality-latch": { - "types": "./src/sessions/snapshot-quality-latch.ts", - "default": "./src/sessions/snapshot-quality-latch.ts" - }, - "./testing/snapshot-runtime": { - "types": "./src/sessions/snapshot-runtime.ts", - "default": "./src/sessions/snapshot-runtime.ts" - }, - "./testing/wait-runtime": { - "types": "./src/execution/wait-runtime.ts", - "default": "./src/execution/wait-runtime.ts" - }, "./testing/web-session-names": { "types": "./src/sessions/web-session-names.ts", "default": "./src/sessions/web-session-names.ts" diff --git a/packages/daemon/src/__tests__/support/command-surface-fake.ts b/packages/daemon/src/__tests__/support/command-surface-fake.ts index 0c6fc4aa0d..109a4bf804 100644 --- a/packages/daemon/src/__tests__/support/command-surface-fake.ts +++ b/packages/daemon/src/__tests__/support/command-surface-fake.ts @@ -4,6 +4,7 @@ import type { DaemonCommandSurface, DaemonSnapshotOptions, } from '@agent-device/contracts/daemon-command-surface'; +import { publicSnapshotCaptureAnnotations } from '@agent-device/contracts/capture'; /** * The `CreateDaemonCommandSurface` port as the daemon-policy suites need it. Router and handler @@ -74,49 +75,73 @@ export const fakeCreateCommandSurface: CreateDaemonCommandSurface = async () => * the result stays out of the daemon suites: a snapshot returns the backend's tree, and a diff * returns an empty comparison whose only informative field is whether it initialized the baseline. */ -export const forwardingCreateCommandSurface: CreateDaemonCommandSurface = async (config) => { - const captureIntoSession = async (options: DaemonSnapshotOptions) => { - const name = options.session ?? 'default'; - const session = await config.sessions?.get(name); - const captured = await config.backend.captureSnapshot?.( - { session: name, requestId: options.requestId, signal: options.signal ?? config.signal }, - { - interactiveOnly: options.interactiveOnly, - depth: options.depth, - scope: options.scope, - raw: options.raw, - }, - ); - const stored = captured?.snapshot ?? { - nodes: [], - createdAt: Date.now(), - backend: 'android' as const, +function createForwardingCommandSurface( + options: { + includeCaptureAnnotations?: boolean; + } = {}, +): CreateDaemonCommandSurface { + return async (config) => { + const captureIntoSession = async (captureOptions: DaemonSnapshotOptions) => { + const name = captureOptions.session ?? 'default'; + const session = await config.sessions?.get(name); + const captured = await config.backend.captureSnapshot?.( + { + session: name, + requestId: captureOptions.requestId, + signal: captureOptions.signal ?? config.signal, + }, + { + interactiveOnly: captureOptions.interactiveOnly, + depth: captureOptions.depth, + scope: captureOptions.scope, + raw: captureOptions.raw, + }, + ); + const stored = captured?.snapshot ?? { + nodes: [], + createdAt: Date.now(), + backend: 'android' as const, + }; + await config.sessions?.set({ + ...(session ?? { name }), + name: session?.name ?? name, + snapshot: stored, + }); + const annotations = + options.includeCaptureAnnotations && captured + ? publicSnapshotCaptureAnnotations(captured) + : {}; + return { + nodes: stored.nodes as never, + hadBaseline: session?.snapshot !== undefined, + annotations, + }; }; - await config.sessions?.set({ - ...(session ?? { name }), - name: session?.name ?? name, - snapshot: stored, - }); - return { nodes: stored.nodes as never, hadBaseline: session?.snapshot !== undefined }; - }; - return { - ...fakeCommandSurface, - signal: config.signal, - capture: { - ...fakeCommandSurface.capture, - snapshot: async (options) => { - const { nodes } = await captureIntoSession(options); - return { nodes }; + return { + ...fakeCommandSurface, + signal: config.signal, + capture: { + ...fakeCommandSurface.capture, + snapshot: async (captureOptions) => { + const { nodes, annotations } = await captureIntoSession(captureOptions); + return { nodes, ...annotations }; + }, + diffSnapshot: async (captureOptions) => { + const { hadBaseline } = await captureIntoSession(captureOptions); + return { + mode: 'snapshot', + baselineInitialized: !hadBaseline, + summary: { additions: 0, removals: 0, unchanged: 0 }, + lines: [], + }; + }, }, - diffSnapshot: async (options) => { - const { hadBaseline } = await captureIntoSession(options); - return { - mode: 'snapshot', - baselineInitialized: !hadBaseline, - summary: { additions: 0, removals: 0, unchanged: 0 }, - lines: [], - }; - }, - }, + }; }; -}; +} + +export const forwardingCreateCommandSurface: CreateDaemonCommandSurface = + createForwardingCommandSurface(); + +export const forwardingCreateCommandSurfaceWithAnnotations: CreateDaemonCommandSurface = + createForwardingCommandSurface({ includeCaptureAnnotations: true }); diff --git a/packages/daemon/src/__tests__/support/command-surface-override-fake.ts b/packages/daemon/src/__tests__/support/command-surface-override-fake.ts index 790a3ade40..27a4360834 100644 --- a/packages/daemon/src/__tests__/support/command-surface-override-fake.ts +++ b/packages/daemon/src/__tests__/support/command-surface-override-fake.ts @@ -48,6 +48,7 @@ export async function captureAndStoreSnapshot( interactiveOnly?: boolean; includeRects?: boolean; preferredBackend?: SnapshotPreferredBackend; + onlyStoreWhenSessionExists?: boolean; }, ): Promise { const name = params.session ?? 'default'; @@ -62,6 +63,8 @@ export async function captureAndStoreSnapshot( const snapshot = captured?.snapshot; if (!snapshot) throw new Error('captureAndStoreSnapshot: the backend returned no snapshot'); const current = await config.sessions?.get(name); - await config.sessions?.set({ ...(current ?? { name }), name, snapshot }); + if (current || !params.onlyStoreWhenSessionExists) { + await config.sessions?.set({ ...(current ?? { name }), name, snapshot }); + } return snapshot; } diff --git a/packages/daemon/src/__tests__/support/screenshot-command-surface-fake.ts b/packages/daemon/src/__tests__/support/screenshot-command-surface-fake.ts new file mode 100644 index 0000000000..d86f505ffc --- /dev/null +++ b/packages/daemon/src/__tests__/support/screenshot-command-surface-fake.ts @@ -0,0 +1,45 @@ +import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; +import { forwardingCreateCommandSurfaceWithAnnotations } from './command-surface-fake.ts'; + +export const screenshotCommandSurface: CreateDaemonCommandSurface = async (config) => { + const forwarded = await forwardingCreateCommandSurfaceWithAnnotations(config); + return { + ...forwarded, + capture: { + ...forwarded.capture, + screenshot: async (options) => { + const reserved = await config.artifacts.reserveOutput(options.out, { + field: 'path', + ext: '.png', + artifactType: 'screenshot', + }); + try { + const captured = await config.backend.captureScreenshot?.( + { + session: options.session ?? 'default', + requestId: options.requestId, + appBundleId: options.appBundleId, + signal: options.signal ?? config.signal, + }, + reserved.path, + { + fullscreen: options.fullscreen, + overlayRefs: options.overlayRefs, + pixelDensity: options.pixelDensity, + stabilize: options.stabilize, + normalizeStatusBar: options.normalizeStatusBar, + surface: options.surface, + }, + ); + return { + path: reserved.path, + ...(captured?.displayRotation ? { displayRotation: captured.displayRotation } : {}), + }; + } catch (error) { + await reserved.cleanup?.(); + throw error; + } + }, + }, + }; +}; diff --git a/packages/daemon/src/core/gesture-runtime-admission.test.ts b/packages/daemon/src/core/gesture-runtime-admission.test.ts new file mode 100644 index 0000000000..d8cd0e01a2 --- /dev/null +++ b/packages/daemon/src/core/gesture-runtime-admission.test.ts @@ -0,0 +1,97 @@ +import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createUnavailableRuntimeFactsForTest } from '@agent-device/test-kit'; +import { expect, test, vi } from 'vitest'; +import { daemonPlatformServicesFixture } from '../__tests__/support/platform-services-fixture.ts'; +import { resolveBoundGestureRuntime } from './gesture-runtime.ts'; + +const multiTouchInput = { intent: 'pinch', scale: 1.2 } as const; + +const refusalCases = [ + { + name: 'Android TV', + device: { + platform: 'android', + id: 'android-tv', + name: 'Android TV', + kind: 'device', + target: 'tv', + }, + fact: { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'Android TV has no touch input — this gesture is supported on Android phones, tablets, and the iOS simulator only.', + }, + hint: /Android TV has no touch input/, + }, + { + name: 'physical iOS', + device: { + platform: 'apple', + appleOs: 'ios', + id: 'iphone-device', + name: 'iPhone', + kind: 'device', + target: 'mobile', + }, + fact: { + available: false, + reason: 'unsupported-device-kind', + hint: 'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.', + }, + hint: /physical iOS devices/, + }, + { + name: 'tvOS', + device: { + platform: 'apple', + appleOs: 'tvos', + id: 'tvos-simulator', + name: 'Apple TV Simulator', + kind: 'simulator', + target: 'tv', + }, + fact: { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'tvOS does not support multi-touch gesture plans.', + }, + hint: /tvOS/, + }, +] as const; + +test.each(refusalCases)( + '$name preserves multi-touch admission details without binding', + async ({ device, fact, hint }) => { + const target = device as DeviceInfo; + const owner = localRuntimeOwner(target.platform); + const base = createUnavailableRuntimeFactsForTest(target, owner); + const inspectFacts = vi.fn(async () => ({ + ...base, + operations: { + ...base.operations, + captureSnapshot: { available: true } as const, + performMultiTouchGesturePlan: fact, + }, + })); + const bindDevice = vi.fn(async () => { + throw new Error('unavailable multi-touch must be refused before bind'); + }); + + const resolved = await resolveBoundGestureRuntime({ + device: target, + input: multiTouchInput, + inspectFacts, + bindDevice, + platformServices: daemonPlatformServicesFixture(), + }); + + expect(resolved.ok).toBe(false); + if (!resolved.ok) { + expect(resolved.response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(resolved.response.error.hint).toMatch(hint); + } + expect(inspectFacts).toHaveBeenCalledOnce(); + expect(bindDevice).not.toHaveBeenCalled(); + }, +); diff --git a/packages/daemon/src/core/session-capture-binding.test.ts b/packages/daemon/src/core/session-capture-binding.test.ts new file mode 100644 index 0000000000..fa22e37007 --- /dev/null +++ b/packages/daemon/src/core/session-capture-binding.test.ts @@ -0,0 +1,102 @@ +import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; +import { perfCaptureResourceStore } from '@agent-device/capture-kit/perf-capture-resource-store'; +import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; +import type { PerfNativeCaptureLiveHandle } from '@agent-device/contracts/perf-runtime'; +import { deviceIdentity, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { expect, test, vi } from 'vitest'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; +import type { SessionState } from './session-state.ts'; +import { bindSessionPerfCapture } from './session-capture-binding.ts'; +import { + adoptStartedPerfCapture, + finishLivePerfCapture, +} from '@agent-device/capture-kit/perf-capture-session-resource'; + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, +}; +const fence = { token: 'fence', generation: 1 }; + +test('a failed live perf capture finish remains adopted for a later retry', async () => { + const sessionStore = makeSessionStore('perf-capture-failed-finish-'); + const sessionName = 'capture'; + const session: SessionState = { + name: sessionName, + device, + createdAt: 1, + actions: [], + }; + sessionStore.publish(sessionName, session); + const capture = { + kind: 'perfetto' as const, + mode: 'trace' as const, + state: 'running' as const, + outPath: '/tmp/capture', + }; + const pullFailure = new AppError('COMMAND_FAILED', 'Failed to pull Android perfetto artifact', { + package: 'com.example.app', + tool: 'perfetto', + remotePath: '/data/misc/perfetto-traces/agent-device-app.trace', + hint: 'Check that the profiling command ran long enough to create an artifact, then retry stop with the same session.', + }); + let pullFails = true; + const finish = vi.fn(async () => { + if (pullFails) throw pullFailure; + return { + status: 'completed' as const, + result: { ...capture, state: 'stopped' as const }, + }; + }); + const forceCleanup = vi.fn(async () => ({ status: 'cleaned' as const })); + const handle: PerfNativeCaptureLiveHandle = { + inspect: () => capture, + setOutputPath: vi.fn(), + finish, + forceCleanup, + [Symbol.asyncDispose]: async () => {}, + }; + const binding = bindSessionPerfCapture(sessionStore, sessionStore.lookup(sessionName)!); + await adoptStartedPerfCapture({ + admissionLedger: createPerfCaptureAdmissionLedger(), + binding, + device, + owner: localRuntimeOwner('android'), + fence, + pendingHandle: new PendingTransferGuard(handle), + envelope: createDurableResourceEnvelope({ + resourceKind: 'perf-capture', + sessionId: sessionName, + device: deviceIdentity(device), + owner: localRuntimeOwner('android'), + fence, + lifecycle: 'open', + descriptor: { version: 1, body: { kind: 'fixture' } }, + }), + throwIfCanceled: () => {}, + }); + const resourcePath = perfCaptureResourceStore.resolvePath( + sessionStore.resolveSessionDir(sessionName), + ); + const stop = () => finishLivePerfCapture({ intent: 'capture', binding }); + + await expect(stop()).rejects.toBe(pullFailure); + expect(forceCleanup).not.toHaveBeenCalled(); + expect(sessionStore.get(sessionName)?.perfCapture?.handle).toBeDefined(); + expect(perfCaptureResourceStore.read(resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open', metadata: { phase: 'completing' } }, + }); + + pullFails = false; + await expect(stop()).resolves.toMatchObject({ kind: 'perfetto', state: 'stopped' }); + expect(forceCleanup).not.toHaveBeenCalled(); + expect(sessionStore.get(sessionName)?.perfCapture).toBeUndefined(); +}); diff --git a/packages/daemon/src/execution/__tests__/android-system-dialog-readiness.test.ts b/packages/daemon/src/execution/__tests__/android-system-dialog-readiness.test.ts new file mode 100644 index 0000000000..9d8be4e12c --- /dev/null +++ b/packages/daemon/src/execution/__tests__/android-system-dialog-readiness.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, expect, test } from 'vitest'; +import type { + AndroidBlockingDialogFocus, + AndroidObservationAdapter, +} from '@agent-device/contracts/android-observation'; +import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; +import { + androidSnapshotPublicationInput, + createAndroidSnapshotCapture, +} from '@agent-device/platform-android/mechanics'; +import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; + +import { makeAndroidSession } from '../../__tests__/support/session-factories.ts'; +import { + countDiagnosticEventsByPhase, + withDiagnosticsScope, +} from '@agent-device/host-kit/diagnostics'; +import { ensureAndroidBlockingSystemDialogReady } from '../android-system-dialog.ts'; + +// A blocking dialog can surface with no adb traffic at all, so nothing a previous command observed +// is evidence about this one. These pin what each readiness phase owes the command it guards. + +const APP = 'com.agentdevice.tester'; +const ANR_FOCUS_DUMP = ` mCurrentFocus=Window{ffeedd u0 Application Not Responding: ${APP}}`; +const SYSTEM_DIALOG_DUMP = + ' mCurrentFocus=Window{ffeedd u0 Application Not Responding: com.android.systemui}'; +const CLOSE_APP_RECT = { x: 100, y: 600, width: 220, height: 80 }; +const CLOSE_APP_TAP = ['shell', 'input', 'tap', '210', '640']; + +/** What the mocked capture publishes: the screen the case under test is recovering from. */ +let screenNodes: RawSnapshotNode[]; + +beforeEach(() => { + screenNodes = anrDialogNodes(); +}); + +function anrDialogNodes(): RawSnapshotNode[] { + return [ + { + index: 0, + type: 'android.widget.TextView', + label: `${APP} isn't responding`, + rect: { x: 50, y: 400, width: 500, height: 80 }, + }, + { + index: 1, + type: 'android.widget.Button', + label: 'Close app', + rect: CLOSE_APP_RECT, + }, + ]; +} + +/** + * The same dialog with a foreground sheet drawn over it: the "Close app" is in the tree but nothing + * a user could touch. #1832 — recovery acts on what is reachable, never on raw text. + */ +function coveredCloseAppNodes(): RawSnapshotNode[] { + return [ + ...anrDialogNodes(), + { + index: 2, + type: 'com.google.android.material.bottomsheet.BottomSheetDialog', + identifier: 'com.example:id/bottom_sheet', + hittable: true, + rect: { x: 0, y: 0, width: 1080, height: 2400 }, + }, + ]; +} + +function tapCalls(calls: string[][]): string[][] { + return calls.filter((call) => call[1] === 'input' && call[2] === 'tap'); +} + +function publishedScreenNodes(): SnapshotNode[] { + const capture = createAndroidSnapshotCapture( + { + nodes: screenNodes, + analysis: { rawNodeCount: screenNodes.length, maxDepth: 0 }, + androidSnapshot: { backend: 'android-helper' }, + quality: { state: 'healthy', backend: 'android-helper' }, + }, + { + clickability: { + kind: 'exact', + provider: 'android-helper', + clickableByNodeIndex: new Map(), + }, + }, + ); + return buildSnapshotState(androidSnapshotPublicationInput(capture), undefined).nodes; +} + +function makeObservation( + dialog: () => AndroidBlockingDialogFocus | undefined, + options: { unknown?: boolean } = {}, +): { observation: AndroidObservationAdapter; calls: string[][] } { + const calls: string[][] = []; + const openedApps = new Set(); + let dismissed = false; + const observation: AndroidObservationAdapter = { + readAppState: async () => ({}), + readBlockingDialog: async () => { + if (options.unknown) return { status: 'unknown' }; + const focus = dismissed ? undefined : dialog(); + return focus ? { status: 'dialog', focus } : { status: 'clear' }; + }, + readAppFocus: async (_device, appBundleId) => dismissed && openedApps.has(appBundleId), + readSnapshotNodes: async () => (dismissed ? [] : publishedScreenNodes()), + tap: async (_device, x, y) => { + calls.push(['shell', 'input', 'tap', String(x), String(y)]); + dismissed = true; + return { exitCode: 0, stdout: '', stderr: '' }; + }, + openApp: async (_device, appBundleId) => { + openedApps.add(appBundleId); + }, + readScreenSize: async () => ({ width: 1080, height: 1920 }), + isPermissionPackage: async () => false, + }; + return { observation, calls }; +} + +function anrFocus(): AndroidBlockingDialogFocus { + return { + package: APP, + focusedWindow: `Application Not Responding: ${APP}`, + raw: ANR_FOCUS_DUMP, + }; +} + +function systemDialogFocus(): AndroidBlockingDialogFocus { + return { + package: 'com.android.systemui', + focusedWindow: 'Application Not Responding: com.android.systemui', + raw: SYSTEM_DIALOG_DUMP, + }; +} + +test('an ANR that surfaces between two commands is recovered by the next pre-dispatch check', async () => { + // The previous command's post-dispatch check saw a clear screen and nothing has mutated the + // device since — but the app blocked on its own. `main` recovers here and lets the command + // proceed with a warning; a readiness check that answers from that earlier observation instead + // dispatches this command's tap into the ANR dialog's buttons and then fails it. + const session = makeAndroidSession('readiness-anr-between', { appBundleId: APP }); + let anr = false; + const { observation, calls } = makeObservation(() => (anr ? anrFocus() : undefined)); + await ensureAndroidBlockingSystemDialogReady({ + session, + command: 'tap', + phase: 'after-command', + observation, + }); + anr = true; + const readiness = await ensureAndroidBlockingSystemDialogReady({ + session, + command: 'tap', + phase: 'before-command', + observation, + }); + + expect(readiness).toEqual({ + status: 'recovered', + warning: `Recovered Android app ANR before tap: closed and relaunched ${APP}.`, + }); + // Recovering means the dialog was actually dismissed on the device, so the reported status is + // pinned to the tap that did it rather than to the status value alone. + expect(tapCalls(calls)).toEqual([CLOSE_APP_TAP]); +}); + +test('an ANR whose Close app is covered is not tapped, and recovery reports failure', async () => { + // #1832: recovery reads the presentation's occlusion result, so a "Close app" that a foreground + // surface covers is unreachable. Tapping it anyway would land the tap on whatever is on top. + const session = makeAndroidSession('readiness-anr-covered', { appBundleId: APP }); + screenNodes = coveredCloseAppNodes(); + const { observation, calls } = makeObservation(anrFocus); + + await expect( + ensureAndroidBlockingSystemDialogReady({ + session, + command: 'tap', + phase: 'before-command', + observation, + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: expect.stringContaining('Android app ANR blocked tap'), + }); + + expect(tapCalls(calls)).toEqual([]); +}); + +test('a system dialog that surfaces between two commands blocks the next command', async () => { + const session = makeAndroidSession('readiness-system-between'); + let dialog = false; + const { observation } = makeObservation(() => (dialog ? systemDialogFocus() : undefined)); + await ensureAndroidBlockingSystemDialogReady({ + session, + command: 'tap', + phase: 'after-command', + observation, + }); + dialog = true; + await expect( + ensureAndroidBlockingSystemDialogReady({ + session, + command: 'tap', + phase: 'before-command', + observation, + }), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); +}); + +test('an ANR the command itself provoked still fails that command', async () => { + const session = makeAndroidSession('readiness-anr-after', { appBundleId: APP }); + const { observation, calls } = makeObservation(anrFocus); + + await expect( + ensureAndroidBlockingSystemDialogReady({ + session, + command: 'tap', + phase: 'after-command', + observation, + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: expect.stringContaining('Android app ANR appeared after tap'), + }); + expect(tapCalls(calls)).toEqual([CLOSE_APP_TAP]); +}); + +test('a dump that answered nothing is reported as unobserved, not as a clear screen', async () => { + const session = makeAndroidSession('readiness-unobserved'); + const { observation } = makeObservation(() => undefined, { unknown: true }); + const { readiness, unobserved } = await withDiagnosticsScope({}, async () => { + const readiness = await ensureAndroidBlockingSystemDialogReady({ + session, + command: 'tap', + phase: 'after-command', + observation, + }); + return { + readiness, + unobserved: countDiagnosticEventsByPhase(['android_blocking_dialog_unobserved']), + }; + }); + + // The command still proceeds — a failed probe has never been a refusal — but nothing about this + // dump may be carried forward as evidence that the device was clear. + expect(readiness).toEqual({ status: 'clear' }); + expect(unobserved).toBe(1); +}); diff --git a/packages/daemon/src/execution/app-events.test.ts b/packages/daemon/src/execution/app-events.test.ts index a6dd0f8afa..24b07e2465 100644 --- a/packages/daemon/src/execution/app-events.test.ts +++ b/packages/daemon/src/execution/app-events.test.ts @@ -1,11 +1,143 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { parseTriggerAppEventArgs } from './app-events.ts'; +import { ANDROID_EMULATOR, IOS_DEVICE, MACOS_DEVICE } from '@agent-device/test-kit'; +import { parseTriggerAppEventArgs, resolveAppEventUrl } from './app-events.ts'; import { AppError } from '@agent-device/kernel/errors'; +type EnvironmentPatch = Readonly>; + +async function withEnvironment(patch: EnvironmentPatch, run: () => Promise | T): Promise { + const previous = new Map(Object.keys(patch).map((key) => [key, process.env[key]])); + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return await run(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + test('parseTriggerAppEventArgs validates event name format', () => { assert.throws( () => parseTriggerAppEventArgs(['bad event']), - (error) => error instanceof AppError && error.code === 'INVALID_ARGS', + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /Invalid trigger-app-event event name/i.test(error.message), + ); +}); + +test('trigger-app-event reports missing URL template as UNSUPPORTED_OPERATION', async () => { + await withEnvironment( + { + AGENT_DEVICE_APP_EVENT_URL_TEMPLATE: undefined, + AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE: undefined, + }, + () => { + assert.throws( + () => resolveAppEventUrl(ANDROID_EMULATOR, 'screenshot_taken'), + (error) => + error instanceof AppError && + error.code === 'UNSUPPORTED_OPERATION' && + /No app event URL template configured/i.test(error.message), + ); + }, + ); +}); + +test('trigger-app-event validates payload JSON', () => { + assert.throws( + () => parseTriggerAppEventArgs(['screenshot_taken', '{invalid-json']), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /Invalid trigger-app-event payload JSON/i.test(error.message), + ); +}); + +test('trigger-app-event prefers a platform-specific URL template over the global template', async () => { + await withEnvironment( + { + AGENT_DEVICE_APP_EVENT_URL_TEMPLATE: 'myapp://global?name={event}', + AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE: 'myapp://android?name={event}', + }, + () => { + const { eventName, payload } = parseTriggerAppEventArgs(['screenshot_taken']); + assert.equal( + resolveAppEventUrl(ANDROID_EMULATOR, eventName, payload), + 'myapp://android?name=screenshot_taken', + ); + }, + ); +}); + +test('trigger-app-event supports iOS and prefers its platform-specific URL template', async () => { + await withEnvironment( + { + AGENT_DEVICE_APP_EVENT_URL_TEMPLATE: 'myapp://global?name={event}', + AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE: + 'myapp://ios?name={event}&payload={payload}&platform={platform}', + }, + () => { + const { eventName, payload } = parseTriggerAppEventArgs([ + 'screenshot_taken', + '{"source":"ios"}', + ]); + assert.equal( + resolveAppEventUrl(IOS_DEVICE, eventName, payload), + 'myapp://ios?name=screenshot_taken&payload=%7B%22source%22%3A%22ios%22%7D&platform=ios', + ); + }, + ); +}); + +test('trigger-app-event supports macOS and prefers macOS template', async () => { + await withEnvironment( + { + AGENT_DEVICE_APP_EVENT_URL_TEMPLATE: 'myapp://global?name={event}', + AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE: + 'myapp://macos?name={event}&payload={payload}&platform={platform}', + }, + () => { + const { eventName, payload } = parseTriggerAppEventArgs([ + 'screenshot_taken', + '{"source":"desktop"}', + ]); + assert.equal( + resolveAppEventUrl(MACOS_DEVICE, eventName, payload), + 'myapp://macos?name=screenshot_taken&payload=%7B%22source%22%3A%22desktop%22%7D&platform=macos', + ); + }, + ); +}); + +test('trigger-app-event rejects payloads that exceed size limits', () => { + const oversizedPayload = JSON.stringify({ value: 'x'.repeat(9000) }); + assert.throws( + () => parseTriggerAppEventArgs(['screenshot_taken', oversizedPayload]), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /exceeds 8192 bytes/i.test(error.message), + ); +}); + +test('trigger-app-event rejects event URLs that exceed length limits', async () => { + await withEnvironment( + { AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE: `myapp://${'a'.repeat(5000)}?name={event}` }, + () => { + assert.throws( + () => resolveAppEventUrl(ANDROID_EMULATOR, 'screenshot_taken'), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /URL exceeds maximum supported length/i.test(error.message), + ); + }, ); }); diff --git a/packages/daemon/src/execution/generic-settle.test.ts b/packages/daemon/src/execution/generic-settle.test.ts new file mode 100644 index 0000000000..f285e9d528 --- /dev/null +++ b/packages/daemon/src/execution/generic-settle.test.ts @@ -0,0 +1,660 @@ +import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import type { DaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; +import type { SettleObservation } from '@agent-device/contracts/interaction'; +import { daemonPlatformServicesFixture } from '../__tests__/support/platform-services-fixture.ts'; +import { beforeEach, expect, test, vi } from 'vitest'; +import type { CommandFlags } from '@agent-device/contracts/command'; +import type { PresentedSurface, RawSnapshotNode } from '@agent-device/kernel/snapshot'; +import type { IosSystemSurfaceProvenance } from '@agent-device/contracts/ios-system-surface'; +import { makeIosSession } from '../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; +import { + activateCompleteRefFrame, + markSessionSnapshotOutdated, + refFrameState, +} from '../core/ref-frame.ts'; +import { setSessionSnapshot } from './session-snapshot.ts'; +import type { SessionStore } from '../core/session-store.ts'; +import type { DaemonRequest } from '../index.ts'; +import type { SessionRef, SessionState } from '../core/session-state.ts'; +import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; +import { selectorCaptureFixture } from '../__tests__/support/selector-capture-fixture.ts'; +import { + captureAndStoreSnapshot, + commandSurfaceWithMembers, +} from '../__tests__/support/command-surface-override-fake.ts'; + +type SettleObservationOptions = Parameters< + DaemonCommandSurface['interactions']['settleObservation'] +>[0]; + +const settleObservationCalls: SettleObservationOptions[] = []; +const SETTLE_OBSERVATION: SettleObservation = { + settled: true, + waitedMs: 0, + captures: 1, + quietMs: 25, + timeoutMs: 2_000, + diff: { + summary: { additions: 1, removals: 1, unchanged: 1 }, + lines: [ + { kind: 'added', text: 'Button "Load more"', ref: 'e2' }, + { kind: 'removed', text: 'Button "Continue"' }, + ], + }, +}; + +const testCreateCommandSurface = commandSurfaceWithMembers((config) => ({ + interactions: { + settleObservation: async (options) => { + settleObservationCalls.push(options); + await captureAndStoreSnapshot(config, { + session: options.session, + requestId: options.requestId, + signal: options.signal, + includeRects: true, + onlyStoreWhenSessionExists: true, + }); + return { + ...SETTLE_OBSERVATION, + quietMs: options.quietMs ?? 500, + timeoutMs: options.timeoutMs ?? 10_000, + }; + }, + }, +})); + +// #1638 `--settle` on the GENERIC daemon route (scroll/back): the settled diff, +// its refs, and the ref-frame/generation dance are the same contract the touch +// commands get — but the baseline is the session's stored pre-action tree, not +// a resolution, and the observation must run after the deferred-outcome +// markers. Quiet windows are tuned down so no test waits real time. + +vi.mock('./interaction/index.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + captureSnapshotForSession: vi.fn(async () => ({ + nodes: [], + createdAt: 0, + backend: 'xctest', + producer: 'apple-runner' as const, + })), + }; +}); + +import { captureSnapshotForSession } from './interaction/index.ts'; +import { dispatchGenericCommand } from './request-generic-dispatch.ts'; +import { pendingSurfaceStabilization } from '../core/deferred-interaction-outcome.ts'; + +const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); + +const BEFORE_NODES = [ + { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Continue', + rect: { x: 10, y: 20, width: 120, height: 44 }, + hittable: true, + }, +]; + +const AFTER_NODES = [ + { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Load more', + rect: { x: 10, y: 20, width: 120, height: 44 }, + hittable: true, + }, +]; + +const SETTLE_FLAGS = { settle: true, settleQuietMs: 25, timeoutMs: 2_000 } satisfies CommandFlags; + +type SettlePayload = { + settled: boolean; + captures: number; + quietMs: number; + timeoutMs: number; + waitedMs: number; + refsGeneration?: number; + diff?: { + summary: { additions: number; removals: number; unchanged: number }; + lines: Array<{ kind: string; text: string; ref?: string }>; + }; + tail?: Array<{ ref: string; role: string; label?: string }>; + surfaceChange?: { from: string; to: string; disclosure: string }; + hint?: string; +}; + +/** Capture observations made from inside the emulated capture, in call order. */ +const captureObservations: Array<{ postGestureStabilizationPending: boolean }> = []; + +async function emulateCaptureSnapshotForSession( + ref: SessionRef, + flags: CommandFlags | undefined, + sessionStore: SessionStore, + options: { + interactiveOnly: boolean; + boundCapture?: Parameters[4]['boundCapture']; + }, +) { + captureObservations.push({ + postGestureStabilizationPending: + pendingSurfaceStabilization(sessionStore.requireCurrent(ref)) !== undefined, + }); + const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; + // Same rule the real seam applies: a request-bound capture is the tree this request reads, and + // only a request that holds none dispatches for one (#3510). + const snapshotData = options.boundCapture + ? await options.boundCapture({}) + : ((await mockDispatch('snapshot')) as Parameters[0]); + const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); + const current = sessionStore.resolveCurrent(ref); + if (current) setSessionSnapshot(current, snapshot); + return snapshot; +} + +function mockCommandDispatch( + snapshots: RawSnapshotNode[][], + producer: NonNullable = 'apple-runner', +) { + let snapshotCalls = 0; + mockDispatch.mockImplementation(async (command) => { + if (command === 'snapshot') { + const nodes = snapshots[Math.min(snapshotCalls, snapshots.length - 1)]; + snapshotCalls += 1; + return { nodes, backend: 'xctest', producer }; + } + return {}; + }); +} + +const contextFromFlags = () => ({}) as never; + +/** Device work for the generic leaf under test. */ +const platformExecution = vi.fn( + async (params: { command: string }) => await mockDispatch(params.command), +); + +/** Stands in for the device work a bound generic leaf performs, keyed by command name. */ +const mockDispatch = vi.fn<(command: string) => Promise>>(async () => ({})); + +/** + * The session as it stands before the generic command runs. `baseline` is the STORED pre-action + * tree the settled diff is taken against, and the surface that capture described — which is how a + * session whose last observation was an in-place system surface (#2438) is set up. + */ +function seedSession( + sessionName: string, + sessionStore: SessionStore, + baseline: GenericSettleTree = { nodes: BEFORE_NODES }, + overrides?: Partial, +): SessionState { + const session = makeIosSession(sessionName, overrides); + setSessionSnapshot(session, buildSnapshotState(snapshotPayload(baseline), {})); + activateCompleteRefFrame(session); + sessionStore.publish(sessionName, session); + return session; +} + +/** A tree plus the surface a capture of it describes: app content, or an in-place system surface. */ +type GenericSettleTree = { + nodes: RawSnapshotNode[]; + systemSurface?: IosSystemSurfaceProvenance; + producer?: 'apple-runner' | 'simulator-ax-bridge'; + presentedSurface?: PresentedSurface; +}; + +function snapshotPayload(tree: GenericSettleTree) { + return { + nodes: tree.nodes, + backend: 'xctest' as const, + producer: tree.producer ?? ('apple-runner' as const), + ...(tree.systemSurface ? { systemSurface: tree.systemSurface } : {}), + ...(tree.presentedSurface ? { presentedSurface: tree.presentedSurface } : {}), + }; +} + +/** + * The default gateway these cases run against: an owner whose settle captures arrive through the + * emulated `captureSnapshotForSession` this file drives, i.e. one whose bound capture plan is + * refused. The cases that pin the admitted capture name their own fixture (#3510). + */ +function legacyCaptureRuntimeBindings() { + return selectorCaptureFixture({ + capture: { available: false, reason: 'unsupported-platform-leaf' }, + withoutActiveApp: { available: false, reason: 'unsupported-platform-leaf' }, + }); +} + +async function dispatchGeneric(params: { + sessionName: string; + sessionStore: SessionStore; + command: string; + positionals?: string[]; + flags?: CommandFlags; + /** The request-scoped runtime seams `--settle` admits its own observation capture through. */ + runtimeBindings?: Readonly< + Pick[0], 'inspectFacts' | 'bindDevice'> + >; +}): Promise { + const req: DaemonRequest = { + token: 't', + session: params.sessionName, + command: params.command, + positionals: params.positionals ?? [], + ...(params.flags ? { flags: params.flags } : {}), + }; + return await dispatchGenericCommand({ + createCommandSurface: testCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req, + ref: params.sessionStore.lookup(params.sessionName)!, + sessionName: params.sessionName, + logPath: '', + sessionStore: params.sessionStore, + contextFromFlags, + executePlatformCommand: platformExecution, + ...legacyCaptureRuntimeBindings(), + ...params.runtimeBindings, + }); +} + +function expectOkData(response: DaemonResponse): Record { + expect(response.ok).toBe(true); + if (response.ok !== true) throw new Error('expected an ok daemon response'); + return (response.data ?? {}) as Record; +} + +beforeEach(() => { + captureObservations.length = 0; + settleObservationCalls.length = 0; + mockDispatch.mockReset(); + mockDispatch.mockResolvedValue({}); + mockCaptureSnapshotForSession.mockReset(); + mockCaptureSnapshotForSession.mockImplementation( + (...args: Parameters) => { + const [ref, flags, sessionStore, _contextFromFlags, options] = args; + return emulateCaptureSnapshotForSession(ref, flags, sessionStore, options); + }, + ); +}); + +test('scroll --settle passes the stored baseline and attaches the observation with its issued refs', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'generic-settle-scroll'; + seedSession(sessionName, sessionStore); + mockCommandDispatch([AFTER_NODES]); + + const response = await dispatchGeneric({ + sessionName, + sessionStore, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + }); + + const settle = expectOkData(response).settle as SettlePayload; + expect(settle).toMatchObject(SETTLE_OBSERVATION); + expect(settleObservationCalls).toHaveLength(1); + expect(settleObservationCalls[0]?.baseline.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Continue' })]), + ); + + const stored = sessionStore.get(sessionName) as SessionState; + expect(stored.snapshot?.nodes.some((node) => node.label === 'Load more')).toBe(true); + expect(refFrameState(stored)).toBe('active'); + expect(settle.refsGeneration).toBe(stored.snapshotGeneration); +}); + +test('back --settle attaches the observation alongside the command result', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'generic-settle-back'; + seedSession(sessionName, sessionStore); + mockDispatch.mockImplementation(async (command) => { + if (command === 'snapshot') + return { nodes: AFTER_NODES, backend: 'xctest', producer: 'apple-runner' }; + return { action: 'back', mode: 'in-app', message: 'Back' }; + }); + + const response = await dispatchGeneric({ + sessionName, + sessionStore, + command: 'back', + flags: { ...SETTLE_FLAGS }, + }); + + const data = expectOkData(response); + expect(data.action).toBe('back'); + expect(data.message).toBe('Back'); + expect(data.settle).toMatchObject(SETTLE_OBSERVATION); +}); + +test('generic settle preserves the stored surface facts on its baseline', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'generic-settle-surface-baseline'; + seedSession(sessionName, sessionStore, { + nodes: BEFORE_NODES, + systemSurface: { bundleId: 'com.apple.SafariViewService', kind: 'web-auth' }, + presentedSurface: 'presented', + }); + mockCommandDispatch([AFTER_NODES]); + + await dispatchGeneric({ + sessionName, + sessionStore, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + }); + + expect(settleObservationCalls[0]?.baseline).toMatchObject({ + surfaceBundleId: 'com.apple.SafariViewService', + presentedSurface: 'presented', + }); + expect(settleObservationCalls[0]?.baseline.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Continue' })]), + ); +}); + +test('generic settle drops an outdated presented-surface fact but keeps its stored nodes', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'generic-settle-outdated-baseline'; + const session = seedSession(sessionName, sessionStore, { + nodes: BEFORE_NODES, + systemSurface: { bundleId: 'com.apple.SafariViewService', kind: 'web-auth' }, + presentedSurface: 'absent', + }); + markSessionSnapshotOutdated(session); + mockCommandDispatch([AFTER_NODES]); + + await dispatchGeneric({ + sessionName, + sessionStore, + command: 'back', + flags: { ...SETTLE_FLAGS }, + }); + + expect(settleObservationCalls[0]?.baseline).toMatchObject({ + surfaceBundleId: 'com.apple.SafariViewService', + }); + expect(settleObservationCalls[0]?.baseline.presentedSurface).toBeUndefined(); + expect(settleObservationCalls[0]?.baseline.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Continue' })]), + ); +}); + +test('the settle observation runs after the post-gesture stabilization marker', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'generic-settle-order'; + seedSession(sessionName, sessionStore); + mockCommandDispatch([AFTER_NODES]); + + await dispatchGeneric({ + sessionName, + sessionStore, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + }); + + // #1542 ordering: scroll marks a pending stabilization, and settle's FIRST + // capture must already see it so the capture folds the stabilization in + // instead of racing it. + expect(captureObservations.length).toBeGreaterThan(0); + expect(captureObservations[0]?.postGestureStabilizationPending).toBe(true); +}); + +test('scroll --settle observes through the request-bound capture, once admitted', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'generic-settle-bound-capture'; + // A tracked app: the plan this request must admit is the active-app one. The fixture refuses the + // without-active-app plan, so an observation that resolved the wrong cell would degrade to the + // legacy dispatch below and fail these assertions rather than pass by accident. + seedSession( + sessionName, + sessionStore, + { nodes: BEFORE_NODES }, + { appBundleId: 'com.example.app' }, + ); + mockCommandDispatch([AFTER_NODES]); + const runtimeBindings = selectorCaptureFixture({ + withoutActiveApp: { available: false, reason: 'owner-capability-missing' }, + snapshot: () => ({ + nodes: AFTER_NODES, + backend: 'xctest', + producer: 'simulator-ax-bridge', + }), + }); + + const response = await dispatchGeneric({ + sessionName, + sessionStore, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + runtimeBindings, + }); + + const settle = expectOkData(response).settle as SettlePayload; + expect(settle.settled).toBe(true); + // One admit-and-bind for the whole observation, and every capture taken through it: the settled + // tree and the tree the caller's next `snapshot` reads are the same producer's output (#3510). + expect(runtimeBindings.binds.length).toBe(1); + expect(runtimeBindings.captures.length).toBe(settle.captures); + expect(mockDispatch).not.toHaveBeenCalledWith('snapshot'); + expect((sessionStore.get(sessionName) as SessionState).snapshot?.producer).toBe( + 'simulator-ax-bridge', + ); +}); + +test('scroll --settle still observes when the owner cannot capture', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'generic-settle-unbound-capture'; + seedSession(sessionName, sessionStore); + mockCommandDispatch([AFTER_NODES]); + const runtimeBindings = selectorCaptureFixture({ + capture: { available: false, reason: 'unsupported-platform-leaf' }, + }); + + const response = await dispatchGeneric({ + sessionName, + sessionStore, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + runtimeBindings, + }); + + // The owner's own snapshot path is its only snapshot path: the observation stays complete and + // nothing pretends a second capture implementation produced it. + expect(expectOkData(response).settle).toMatchObject({ settled: true }); + expect(runtimeBindings.binds).toEqual([]); + expect(mockDispatch).toHaveBeenCalledWith('snapshot'); +}); + +test('scroll without --settle takes no observation captures and issues no refs', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'generic-settle-off'; + seedSession(sessionName, sessionStore); + mockCommandDispatch([AFTER_NODES]); + const runtimeBindings = selectorCaptureFixture(); + + const response = await dispatchGeneric({ + sessionName, + sessionStore, + command: 'scroll', + positionals: ['down'], + runtimeBindings, + }); + + expect(expectOkData(response).settle).toBeUndefined(); + expect(captureObservations).toEqual([]); + // Settling is what asks for an observation capture, so a plain scroll never pays for one. + expect(runtimeBindings.binds).toEqual([]); + // ADR 0014: the leaf side-effect seam expired the frame and nothing + // re-published it. + expect(refFrameState(sessionStore.get(sessionName) as SessionState)).toBe('expired'); +}); + +test('an ended generic lifetime is refused before dispatch or settle construction', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'generic-settle-evicted'; + seedSession(sessionName, sessionStore); + const ref = sessionStore.lookup(sessionName)!; + sessionStore.retire(ref); + mockCommandDispatch([AFTER_NODES]); + await expect( + dispatchGenericCommand({ + createCommandSurface: testCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: sessionName, + command: 'scroll', + positionals: ['down'], + flags: SETTLE_FLAGS, + }, + ref, + sessionName, + sessionStore, + logPath: '', + contextFromFlags, + executePlatformCommand: platformExecution, + ...legacyCaptureRuntimeBindings(), + }), + ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(captureObservations).toEqual([]); +}); + +test('a generic command without the observation trait ignores a stray settle flag', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'generic-settle-traitless'; + seedSession(sessionName, sessionStore); + mockDispatch.mockResolvedValue({ action: 'home', message: 'Home' }); + + // A hand-built daemon request can put settle flags on any command; only the + // descriptor trait admits them to the observation path. `home` has no trait, + // so the flag is ignored: no captures, no settle payload, no rejection. + const response = await dispatchGeneric({ + sessionName, + sessionStore, + command: 'home', + flags: { settle: true }, + }); + + expect(expectOkData(response).settle).toBeUndefined(); + expect(captureObservations).toEqual([]); +}); + +test('an orphaned --settle-quiet is rejected before the command dispatches', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'generic-settle-orphan'; + seedSession(sessionName, sessionStore); + mockDispatch.mockRejectedValue(new Error('dispatch must not run for an orphaned settle flag')); + + const response = await dispatchGeneric({ + sessionName, + sessionStore, + command: 'scroll', + positionals: ['down'], + flags: { settleQuietMs: 25 }, + }); + + expect(response.ok).toBe(false); + if (response.ok !== false) throw new Error('expected a rejected daemon response'); + expect(response.error?.code).toBe('INVALID_ARGS'); + expect(response.error?.message).toContain('--settle-quiet'); +}); + +test('scroll stays successful when its lifetime retires during optional settle observation', async () => { + const store = makeSessionStore(); + const name = 'generic-settle-retired'; + seedSession(name, store); + const ref = store.lookup(name)!; + mockCommandDispatch([AFTER_NODES]); + mockCaptureSnapshotForSession.mockImplementationOnce( + async (boundRef, flags, sessionStore, _context, options) => { + const observed = await emulateCaptureSnapshotForSession( + boundRef, + flags, + sessionStore, + options, + ); + queueMicrotask(() => { + store.retire(ref); + store.publish(name, makeIosSession(name)); + }); + return observed; + }, + ); + const response = await dispatchGeneric({ + sessionName: name, + sessionStore: store, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + }); + expect(expectOkData(response).settle).toBeUndefined(); + expect(store.get(name)?.snapshot).toBeUndefined(); + expect(store.get(name)?.actions).toEqual([]); +}); + +for (const change of ['rebuild', 'retire'] as const) { + test(`held generic settle capture respects its lifetime after ${change}`, async () => { + const store = makeSessionStore(); + const name = `generic-held-settle-${change}`; + seedSession(name, store); + const ref = store.lookup(name)!; + let entered!: () => void; + let release!: () => void; + const reached = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + mockDispatch.mockImplementation(async (command) => { + if (command !== 'snapshot') return {}; + entered(); + await held; + return snapshotPayload({ nodes: AFTER_NODES }); + }); + const pending = dispatchGeneric({ + sessionName: name, + sessionStore: store, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + }); + try { + await reached; + const trace = { outPath: 'latest-trace', startedAt: 1 }; + const successor = makeIosSession(name); + if (change === 'rebuild') store.update(ref, { trace }); + else { + store.retire(ref); + store.publish(name, successor); + } + release(); + const data = expectOkData(await pending); + if (change === 'rebuild') { + expect(store.requireCurrent(ref).trace).toBe(trace); + expect(store.requireCurrent(ref).snapshot?.nodes[1]?.label).toBe('Load more'); + } else { + expect(data.settle).toBeUndefined(); + expect(store.get(name)).toBe(successor); + expect(successor.snapshot).toBeUndefined(); + } + } finally { + release(); + await pending; + } + }); +} diff --git a/src/__tests__/daemon-composition/capture-disclosure.test.ts b/packages/daemon/src/execution/interaction/index-capture-disclosure.test.ts similarity index 85% rename from src/__tests__/daemon-composition/capture-disclosure.test.ts rename to packages/daemon/src/execution/interaction/index-capture-disclosure.test.ts index 05da0cd967..3f5dfb82bf 100644 --- a/src/__tests__/daemon-composition/capture-disclosure.test.ts +++ b/packages/daemon/src/execution/interaction/index-capture-disclosure.test.ts @@ -1,21 +1,47 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import { AppError } from '@agent-device/kernel/errors'; +import { WAIT_REASONS } from '@agent-device/contracts/wait'; import { test, expect, vi, beforeEach } from 'vitest'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; -import { handleFindCommands } from '@agent-device/daemon/testing/interaction/index'; -import { getRuntimeBindings } from '@agent-device/daemon/testing/__tests__/interaction-get-runtime-fixture'; -import { dispatchFindReadOnlyViaRuntime } from '@agent-device/daemon/testing/selector-runtime'; -import { dispatchWaitViaRuntime } from '@agent-device/daemon/testing/wait-runtime'; +import { legacyDispatchCapture } from '../../__tests__/support/legacy-snapshot-capture-fixture.ts'; +import { handleFindCommands } from './index.ts'; +import { getRuntimeBindings } from '../__tests__/interaction-get-runtime-fixture.ts'; +import { dispatchFindReadOnlyViaRuntime } from '../selector-runtime.ts'; +import { dispatchWaitViaRuntime } from '../wait-runtime.ts'; import type { DaemonWireRequest } from '@agent-device/contracts/command'; import { ANDROID_SYSTEM_SURFACE_DISCLOSURE } from '@agent-device/contracts/android-system-surface-disclosure'; import { iosSystemSurfaceDisclosure } from '@agent-device/contracts/ios-system-surface'; -import { snapshotRuntimeFixture } from '@agent-device/daemon/testing/__tests__/snapshot-runtime-fixture'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { - makeAndroidSession, - makeIosSession, -} from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { snapshotRuntimeFixture } from '../../__tests__/support/snapshot-runtime-fixture.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { makeAndroidSession, makeIosSession } from '../../__tests__/support/session-factories.ts'; +import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; +const platformResourceCleanup = platformResourceCleanupFixture(); +import { commandSurfaceWithMembers } from '../../__tests__/support/command-surface-override-fake.ts'; + +const testCreateCommandSurface = commandSurfaceWithMembers((config) => ({ + selectors: { + find: async ({ session, requestId, signal }) => { + await config.backend.captureSnapshot?.( + { session, requestId, signal: signal ?? config.signal }, + { interactiveOnly: true }, + ); + return { kind: 'found', found: true }; + }, + wait: async ({ session, requestId, signal, target }) => { + await config.backend.captureSnapshot?.( + { session, requestId, signal: signal ?? config.signal }, + { interactiveOnly: true }, + ); + if (target.kind === 'text' && target.text === 'Internet') { + return { kind: 'text', text: target.text, waitedMs: 0 }; + } + const message = + target.kind === 'text' + ? `wait timed out for text: ${target.text}` + : 'wait timed out for selector'; + throw new AppError('COMMAND_FAILED', message, { reason: WAIT_REASONS.targetAbsent }); + }, + }, +})); vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => { const actual = @@ -26,21 +52,20 @@ vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal }; }); -vi.mock('@agent-device/daemon/testing/snapshot-interactor-capture', async () => { - const fixture = - await import('@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'); +vi.mock('../../core/snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/support/legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; }); -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ +vi.mock('../../core/device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import { ANDROID_EMULATOR } from '@agent-device/test-kit'; -import { withSystemSurfaceDisclosure } from '@agent-device/daemon/testing/capture-disclosure'; +import { withSystemSurfaceDisclosure } from '../../core/capture-disclosure.ts'; import { iosTargetActivationDisclosure } from '@agent-device/contracts/ios-target-activation'; -import { TARGET_ACTIVATION_FACT } from '@agent-device/daemon/testing/__tests__/capture-disclosure-target-activation-fact'; +import { TARGET_ACTIVATION_FACT } from '../../core/__tests__/capture-disclosure-target-activation-fact.ts'; // The occluding-shade capture every scenario below consumes: no application window content, one // active quick-settings surface. The Android capture route stamps systemSurfaceOnly on both the diff --git a/src/__tests__/daemon-composition/request-dispatch-disclosure.test.ts b/packages/daemon/src/execution/request-dispatch-disclosure.test.ts similarity index 86% rename from src/__tests__/daemon-composition/request-dispatch-disclosure.test.ts rename to packages/daemon/src/execution/request-dispatch-disclosure.test.ts index f5467e7b8d..a618235dae 100644 --- a/src/__tests__/daemon-composition/request-dispatch-disclosure.test.ts +++ b/packages/daemon/src/execution/request-dispatch-disclosure.test.ts @@ -2,7 +2,27 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { beforeEach, test, vi } from 'vitest'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { createTouchCommandSurface } from '../__tests__/support/touch-command-surface-fake.ts'; + +const testCreateCommandSurface = createTouchCommandSurface({ + members: (config) => ({ + interactions: { + settleObservation: async ({ session, requestId, signal, quietMs, timeoutMs }) => { + await config.backend.captureSnapshot?.( + { session, requestId, signal: signal ?? config.signal }, + {}, + ); + return { + settled: true, + waitedMs: 0, + captures: 1, + quietMs: quietMs ?? 0, + timeoutMs: timeoutMs ?? 0, + }; + }, + }, + }), +}); vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { const actual = @@ -10,7 +30,7 @@ vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) }; }); -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ +vi.mock('../core/device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); @@ -30,19 +50,19 @@ import { makeAndroidSession, makeIosAppSession, makeSession, -} from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import type { DaemonRequest } from '@agent-device/daemon'; -import { discloseRequestDispatch } from '@agent-device/daemon/testing/request-dispatch-disclosure'; -import { createRequestDispatchLedger } from '@agent-device/daemon/testing/request-dispatch-ledger'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { clearAndroidObservationFixture } from '@agent-device/daemon/testing/__tests__/android-observation-fixture'; +} from '../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; +import type { DaemonRequest } from '../index.ts'; +import { discloseRequestDispatch } from './request-dispatch-disclosure.ts'; +import { createRequestDispatchLedger } from '../core/request-dispatch-ledger.ts'; +import { LeaseRegistry } from '../resources/lease-registry.ts'; +import type { SessionState } from '../core/session-state.ts'; +import { clearAndroidObservationFixture } from '../__tests__/support/android-observation-fixture.ts'; import { createRequestHandler, gestureRuntimeSpies, lifecycleDeviceRuntimeGateway, -} from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; +} from '../__tests__/support/test-device-runtime-gateway.ts'; // contracts/fixtures/dispatch-disclosure.json, daemon.route rows: each drives one request through // the real request router, with only the bound device operations faked. @@ -75,7 +95,6 @@ const routeOperationSpies = { tapPoint: vi.fn(async () => undefined), focusPoint: vi.fn(async () => undefined), typeText: vi.fn(async () => undefined), - back: vi.fn(async () => undefined), finalizeApplicationClose: vi.fn(async () => undefined as void), }; @@ -288,25 +307,6 @@ async function closeThenFinalizeRefused(): Promise { throw failure; } -async function maestroDeferredSettleCaptureRefused(): Promise { - captureNodes([CONTINUE_BUTTON]); - gestureRuntimeSpies.captureSnapshot.mockImplementation(async () => { - if (routeOperationSpies.back.mock.calls.length > 0) throw RUNNER_BUSY(); - return { backend: 'xctest', producer: 'apple-runner', nodes: [CONTINUE_BUTTON] } as never; - }); - const flowPath = path.join(mkdtempForTestSync('dispatch-route-maestro'), 'flow.yaml'); - fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n- assertVisible: "Continue"\n'); - const failure = await route(makeIosAppSession(SESSION), { - command: 'replay', - positionals: [flowPath], - flags: { replayBackend: 'maestro', platform: 'ios' }, - }).catch((error: unknown) => error); - assert.equal(routeOperationSpies.back.mock.calls.length, 1); - assert.ok(failure instanceof AppError); - assert.equal(failure.details?.dispatchedSteps, 1); - throw failure; -} - async function batchOf( steps: readonly { command: string; positionals: string[] }[], ): Promise { @@ -344,7 +344,6 @@ async function batchReadThenRefusedStep(): Promise { const DRIVERS: Record Promise> = { 'daemon.route.find-type-refused-after-focus': findTypeRefusedAfterFocus, 'daemon.route.session-close-then-finalize-refused': closeThenFinalizeRefused, - 'daemon.route.maestro-deferred-settle-capture-refused': maestroDeferredSettleCaptureRefused, 'daemon.route.batch-mutation-then-refused-step': batchMutationThenRefusedStep, 'daemon.route.batch-read-then-refused-step': batchReadThenRefusedStep, 'daemon.route.scroll-transport-failure': scrollWhoseGestureSendFailed, @@ -371,7 +370,7 @@ for (const row of ROWS) { const drive = DRIVERS[row.id]; assert.ok(drive, `no driver for ${row.id}`); await assert.rejects(drive(), (error: unknown) => { - assert.ok(error instanceof AppError); + assert.ok(error instanceof AppError, `unexpected failure: ${String(error)}`); assert.equal(error.details?.dispatched, row.dispatched); return true; }); diff --git a/src/__tests__/daemon-composition/screenshot-runtime.test.ts b/packages/daemon/src/execution/screenshot-runtime-execution.test.ts similarity index 87% rename from src/__tests__/daemon-composition/screenshot-runtime.test.ts rename to packages/daemon/src/execution/screenshot-runtime-execution.test.ts index 6cd5db8e8e..50690f1455 100644 --- a/src/__tests__/daemon-composition/screenshot-runtime.test.ts +++ b/packages/daemon/src/execution/screenshot-runtime-execution.test.ts @@ -1,4 +1,4 @@ -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../__tests__/support/platform-services-fixture.ts'; import fs from 'node:fs'; import path from 'node:path'; @@ -6,16 +6,16 @@ import { expect, test } from 'vitest'; import { SCREENSHOT_CROP_REASONS } from '@agent-device/contracts/capture'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; import { ANDROID_EMULATOR, IOS_SIMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; -import { makeSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import type { GenericPlatformExecutionParams } from '@agent-device/daemon/testing/generic-leaf-execution'; -import { resolveScreenshotGenericExecution } from '@agent-device/daemon/testing/screenshot-runtime'; +import { makeSession } from '../__tests__/support/session-factories.ts'; +import type { GenericPlatformExecutionParams } from '../core/generic-leaf-execution.ts'; +import { resolveScreenshotGenericExecution } from './screenshot-runtime.ts'; import { screenshotRuntimeFixture, writeSolidPng, -} from '@agent-device/daemon/testing/__tests__/screenshot-runtime-fixture'; -import type { DaemonRequest } from '@agent-device/daemon'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +} from '../__tests__/support/screenshot-runtime-fixture.ts'; +import type { DaemonRequest } from '../index.ts'; +import type { SessionState } from '../core/session-state.ts'; +import { screenshotCommandSurface as testCreateCommandSurface } from '../__tests__/support/screenshot-command-surface-fake.ts'; const unavailableCapture = Object.freeze({ available: false, @@ -73,7 +73,7 @@ test('admits one capture plan, binds once, and hands the runtime the resolved de const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -100,7 +100,7 @@ test('an iOS simulator session capture skips the redundant boot probe', async () const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -120,7 +120,7 @@ test('refuses an unavailable exact-owner capture fact before binding', async () const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/unsupported.png'] }), session, inspectFacts: fixture.inspectFacts, @@ -151,7 +151,7 @@ test('--overlay-refs is refused up front when the target cannot capture a tree', const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/overlay.png'], flags: { overlayRefs: true } }), session, inspectFacts: fixture.inspectFacts, @@ -185,7 +185,7 @@ test('the default destination is a reserved temp file, removed when the capture const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -195,6 +195,7 @@ test('the default destination is a reserved temp file, removed when the capture await expect(resolved.execute(executionParams(session, req))).rejects.toThrow(/capture failed/); expect(path.basename(requestedPath ?? '')).toBe('screenshot.png'); + expect(path.basename(path.dirname(requestedPath ?? ''))).toContain('agent-device-screenshot-'); expect(fs.existsSync(path.dirname(requestedPath ?? ''))).toBe(false); }); @@ -205,7 +206,7 @@ test('rejects the retired max-size field before inspecting owner facts', async ( await expect( resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/legacy.png'], flags: { screenshotMaxSize: 720 } as unknown as DaemonRequest['flags'], @@ -225,7 +226,7 @@ test('rejects --pixel-density outside iOS-family simulators before inspecting ow await expect( resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/android.png'], flags: { screenshotPixelDensity: 2 }, @@ -257,7 +258,7 @@ const CROP_TREE = [ }, ]; -test('the crop runs after the platform write and before the shared scale', async () => { +test('the crop runs against the platform screenshot dimensions', async () => { const fixture = screenshotRuntimeFixture({ snapshotResult: () => ({ nodes: CROP_TREE, @@ -272,25 +273,22 @@ test('the crop runs after the platform write and before the shared scale', async ); const req = screenshotRequest({ positionals: [outPath], - flags: { screenshotCropOn: 'label="Save"', screenshotScale: 0.5 }, + flags: { screenshotCropOn: 'label="Save"' }, }); const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, bindDevice: fixture.bindDevice, }); - const result = await executeResult(resolved, session, req, { screenshotScale: 0.5 }); + const result = await executeResult(resolved, session, req); expect(fixture.captureScreenshot).toHaveBeenCalledTimes(1); expect(fixture.captureSnapshot).toHaveBeenCalledTimes(1); - // The platform wrote 100x50; the pre-scale box is 40x20, so only a crop-then-scale pipeline - // lands on 20x10 — a scale-then-crop pipeline would halve the image first and the same box - // would overflow it. - expect(result).toMatchObject({ width: 20, height: 10 }); + expect(result).toMatchObject({ width: 40, height: 20 }); expect(fixture.captureSnapshot.mock.calls[0]?.[0].options).toMatchObject({ interactiveOnly: false, includeRects: true, @@ -327,7 +325,7 @@ test('a partial crop surfaces its warning once in the result record and annotate const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -351,7 +349,7 @@ test('the crop plan is refused up front when the target cannot capture a tree', const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: screenshotRequest({ positionals: ['/tmp/crop.png'], flags: { screenshotCropOn: 'label="Save"' }, @@ -388,7 +386,7 @@ test('the result record carries the display rotation the capture reported', asyn const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -409,7 +407,7 @@ test('the result record omits the display rotation when the capture reported non const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, diff --git a/src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts b/packages/daemon/src/execution/selector-runtime-ambiguity-issuance.test.ts similarity index 84% rename from src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts rename to packages/daemon/src/execution/selector-runtime-ambiguity-issuance.test.ts index 52206ee5a0..cd37558a1c 100644 --- a/src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts +++ b/packages/daemon/src/execution/selector-runtime-ambiguity-issuance.test.ts @@ -1,24 +1,45 @@ -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../__tests__/support/platform-services-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; import { attachRefs } from '@agent-device/kernel/snapshot'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { selectorCaptureFixture } from '@agent-device/daemon/testing/__tests__/selector-capture-fixture'; -import { refFrameScope, refFrameState } from '@agent-device/daemon/testing/ref-frame'; -import type { DaemonRequest } from '@agent-device/daemon'; -import { dispatchIsViaRuntime } from '@agent-device/daemon/testing/selector-runtime'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; +import { selectorCaptureFixture } from '../__tests__/support/selector-capture-fixture.ts'; +import { refFrameScope, refFrameState } from '../core/ref-frame.ts'; +import type { DaemonRequest } from '../index.ts'; +import { dispatchIsViaRuntime } from './selector-runtime.ts'; import { IOS_SIMULATOR } from '@agent-device/test-kit'; import { getRuntimeBindings, mockTapPoint, resetGetRuntimeFixture, -} from '@agent-device/daemon/testing/__tests__/interaction-get-runtime-fixture'; +} from './__tests__/interaction-get-runtime-fixture.ts'; import { contextFromFlags, makeStaleRefSession, readPressPoint, -} from '@agent-device/daemon/testing/interaction/internal/__tests__/interaction-touch-fixtures'; -import { handleInteractionCommands } from '@agent-device/daemon/testing/interaction/index'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +} from './interaction/internal/__tests__/interaction-touch-fixtures.ts'; +import { handleInteractionCommands } from './interaction/index.ts'; +import { createTouchCommandSurface } from '../__tests__/support/touch-command-surface-fake.ts'; +const ambiguousIsError = () => + new AppError('AMBIGUOUS_MATCH', 'Selector matched 2 elements', { + matches: 2, + candidates: ['@e2 [button] "Deploy"', '@e3 [button] "Deploy"'], + hint: 'Narrow the selector or act on a printed candidate with a command that takes refs.', + }); + +const testCreateCommandSurface = createTouchCommandSurface({ + members: (config) => ({ + selectors: { + is: async ({ session, requestId, signal }) => { + await config.backend.captureSnapshot?.( + { session, requestId, signal: signal ?? config.signal }, + { includeRects: true }, + ); + throw ambiguousIsError(); + }, + }, + }), +}); // #2870 review, the reviewer's pinned invariant: a response that prints // candidate @refs must issue those refs on the frame they came from. The end- @@ -28,39 +49,17 @@ import { testCreateCommandSurface } from './command-surface-fixture.ts'; // resolving against the earlier tree, where the same body names a different // node. The candidate either acts on the listed node or is refused. -const { mockRunAppleRunnerCommand, mockCaptureSnapshotWithInteractor } = vi.hoisted(() => ({ - mockRunAppleRunnerCommand: vi.fn(), +const { mockCaptureSnapshotWithInteractor } = vi.hoisted(() => ({ mockCaptureSnapshotWithInteractor: vi.fn(), })); -vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getAndroidScreenSize: vi.fn(async () => ({ width: 1344, height: 2992 })), - getAndroidAppState: vi.fn(async () => ({})), - getAndroidBlockingDialogObservation: vi.fn(async () => ({ status: 'clear' }) as const), - }; -}); - -vi.mock('@agent-device/daemon/testing/snapshot-interactor-capture', async (importOriginal) => { - const actual = - await importOriginal< - typeof import('@agent-device/daemon/testing/snapshot-interactor-capture') - >(); +vi.mock('../core/snapshot-interactor-capture.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, captureSnapshotWithInteractor: mockCaptureSnapshotWithInteractor }; }); -vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, runAppleRunnerCommand: mockRunAppleRunnerCommand }; -}); - beforeEach(() => { resetGetRuntimeFixture(); - mockRunAppleRunnerCommand.mockReset(); - mockRunAppleRunnerCommand.mockResolvedValue({}); mockCaptureSnapshotWithInteractor.mockReset(); }); @@ -115,7 +114,7 @@ test('an ambiguous is issues its printed candidates so press acts on the listed const fixture = selectorCaptureFixture({ snapshot: () => ambiguousFullCapture() }); const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest(sessionName, ['visible', 'label="Deploy"']), sessionName, sessionStore, @@ -162,7 +161,7 @@ test('a plain candidate ref from the ambiguity refusal is refused, not silently const fixture = selectorCaptureFixture({ snapshot: () => ambiguousFullCapture() }); const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest(sessionName, ['visible', 'label="Deploy"']), sessionName, sessionStore, @@ -227,7 +226,7 @@ test('an ambiguous is on a sparse capture prints no candidates and issues nothin const fixture = selectorCaptureFixture({ snapshot: sparseCapture }); const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest(sessionName, ['visible', 'label="Deploy"']), sessionName, sessionStore, @@ -296,8 +295,13 @@ test('an ambiguous press issues its printed candidates from its own capture', as producer: 'apple-runner', }); + const pressAmbiguitySurface = createTouchCommandSurface({ + matchSelector: () => { + throw ambiguousIsError(); + }, + }); const response = await handleInteractionCommands({ - createCommandSurface: testCreateCommandSurface, + createCommandSurface: pressAmbiguitySurface, req: { token: 't', session: sessionName, @@ -314,6 +318,7 @@ test('an ambiguous press issues its printed candidates from its own capture', as expect(response?.ok).toBe(false); if (!response || response.ok) throw new Error('expected the acting ambiguity refusal'); expect(response.error.code).toBe('AMBIGUOUS_MATCH'); + expect(mockCaptureSnapshotWithInteractor).toHaveBeenCalledOnce(); // The acting refusal owes the same contract the read refusal gives: the // printed candidates are ISSUED refs pinned to the generation of the // capture the press itself consumed, not a count-only degradation. diff --git a/src/__tests__/daemon-composition/selector-runtime.test.ts b/packages/daemon/src/execution/selector-runtime-get-lifecycle.test.ts similarity index 87% rename from src/__tests__/daemon-composition/selector-runtime.test.ts rename to packages/daemon/src/execution/selector-runtime-get-lifecycle.test.ts index d92f524b19..519af80099 100644 --- a/src/__tests__/daemon-composition/selector-runtime.test.ts +++ b/packages/daemon/src/execution/selector-runtime-get-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../__tests__/support/platform-services-fixture.ts'; import { expect, test } from 'vitest'; import { localRuntimeOwner, @@ -11,10 +11,11 @@ import { type SnapshotResult, } from '@agent-device/contracts/snapshot-runtime'; import { createUnavailableRuntimeFactsForTest } from '@agent-device/test-kit'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { makeSession as makeStoredSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { dispatchGetViaRuntime } from '@agent-device/daemon/testing/selector-runtime'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; +import { makeSession as makeStoredSession } from '../__tests__/support/session-factories.ts'; +import { dispatchGetViaRuntime } from './selector-runtime.ts'; +import { createTouchCommandSurface } from '../__tests__/support/touch-command-surface-fake.ts'; +const testCreateCommandSurface = createTouchCommandSurface(); test.each(['rebuild', 'retire'] as const)( 'get text records in its captured lifetime after a held native read across %s', @@ -85,7 +86,7 @@ test.each(['rebuild', 'retire'] as const)( }; const running = dispatchGetViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: 'default', diff --git a/src/__tests__/daemon-composition/is-runtime.test.ts b/packages/daemon/src/execution/selector-runtime-is-admission.test.ts similarity index 75% rename from src/__tests__/daemon-composition/is-runtime.test.ts rename to packages/daemon/src/execution/selector-runtime-is-admission.test.ts index b0d4e5f865..d6ec725513 100644 --- a/src/__tests__/daemon-composition/is-runtime.test.ts +++ b/packages/daemon/src/execution/selector-runtime-is-admission.test.ts @@ -1,4 +1,4 @@ -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../__tests__/support/platform-services-fixture.ts'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type { SnapshotResult } from '@agent-device/contracts/snapshot-runtime'; import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; @@ -7,16 +7,31 @@ import { makeAndroidSession, makeIosAppSession, makeIosSession, -} from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; +} from '../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; import { withTestDeviceInventory } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; import type { PostGestureOutcome } from '@agent-device/kernel/snapshot'; -import type { DaemonRequest } from '@agent-device/daemon'; -import { selectorCaptureFixture } from '@agent-device/daemon/testing/__tests__/selector-capture-fixture'; -import { markDeferredInteractionOutcome } from '@agent-device/daemon/testing/deferred-interaction-outcome'; +import type { DaemonRequest } from '../index.ts'; +import { selectorCaptureFixture } from '../__tests__/support/selector-capture-fixture.ts'; +import { markDeferredInteractionOutcome } from '../core/deferred-interaction-outcome.ts'; import { formatPostGestureOutcomeWarning } from '@agent-device/capture-kit/post-gesture-stability'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { createTouchCommandSurface } from '../__tests__/support/touch-command-surface-fake.ts'; +const testCreateCommandSurface = createTouchCommandSurface(); + +const isCaptureCommandSurface = createTouchCommandSurface({ + members: (config) => ({ + selectors: { + is: async ({ session, requestId, signal, predicate, selector }) => { + await config.backend.captureSnapshot?.( + { session, requestId, signal: signal ?? config.signal }, + {}, + ); + return { predicate, pass: true, selector, matches: 0 }; + }, + }, + }), +}); const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ mockRunAppleRunnerCommand: vi.fn() })); @@ -26,7 +41,7 @@ vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) return { ...actual, runAppleRunnerCommand: mockRunAppleRunnerCommand }; }); -import { dispatchIsViaRuntime } from '@agent-device/daemon/testing/selector-runtime'; +import { dispatchIsViaRuntime } from './selector-runtime.ts'; beforeEach(() => { mockRunAppleRunnerCommand.mockReset(); @@ -83,7 +98,7 @@ test('an admitted is inspects once, binds once, and answers through the bound ca const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-bound', ['visible', 'id=auth_continue']), sessionName: 'is-bound', sessionStore, @@ -97,7 +112,7 @@ test('an admitted is inspects once, binds once, and answers through the bound ca expect(fixture.captures.length).toBeGreaterThan(0); }); -test('is absent uses the bound readAny capture for selector-first input without rects', async () => { +test('is absent binds one unscoped readAny capture without rectangles', async () => { const fixture = selectorCaptureFixture({ snapshot: () => ({ nodes: [], backend: 'xctest', producer: 'apple-runner' }), }); @@ -105,8 +120,8 @@ test('is absent uses the bound readAny capture for selector-first input without sessionStore.publish('is-absent', makeIosAppSession('is-absent')); const response = await dispatchIsViaRuntime({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: isCaptureCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-absent', ['label="Removed row"', 'absent']), sessionName: 'is-absent', sessionStore, @@ -115,18 +130,11 @@ test('is absent uses the bound readAny capture for selector-first input without }); expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data).toMatchObject({ - predicate: 'absent', - pass: true, - matches: 0, - }); - } expect(fixture.captures).toHaveLength(1); expect(fixture.captures[0]?.options?.includeRects).toBe(false); }); -test('is absent bypasses a cached no-match snapshot before evaluating the bound capture', async () => { +test('is absent bypasses the cached snapshot and binds a fresh readAny capture', async () => { const cachedAbsent = { ...makeSnapshotState([{ index: 0, type: 'StaticText', label: 'Current screen' }], { backend: 'xctest', @@ -148,8 +156,8 @@ test('is absent bypasses a cached no-match snapshot before evaluating the bound ); const response = await dispatchIsViaRuntime({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: isCaptureCommandSurface, + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-absent-fresh', ['absent', 'label="Removed row"']), sessionName: 'is-absent-fresh', sessionStore, @@ -157,57 +165,10 @@ test('is absent bypasses a cached no-match snapshot before evaluating the bound bindDevice: fixture.bindDevice, }); - expect(response?.ok).toBe(false); - if (response?.ok === false) { - expect(response.error.details).toMatchObject({ - command: 'is', - reason: 'predicate_failed', - predicate: 'absent', - matches: 1, - observation: 'present', - }); - } - expect(fixture.captures).toHaveLength(1); -}); - -test('is absent fails closed for a quality-less legacy iOS root-only capture', async () => { - const fixture = selectorCaptureFixture({ - snapshot: () => ({ - nodes: [{ index: 0, type: 'XCUIElementTypeApplication' }], - backend: 'xctest', - producer: 'apple-runner', - }), - }); - const sessionStore = makeSessionStore(); - sessionStore.publish('is-legacy-sparse', makeIosAppSession('is-legacy-sparse')); - - const response = await dispatchIsViaRuntime({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: isRequest('is-legacy-sparse', ['absent', 'label="Removed row"']), - sessionName: 'is-legacy-sparse', - sessionStore, - inspectFacts: fixture.inspectFacts, - bindDevice: fixture.bindDevice, - }); - - expect(response?.ok).toBe(false); - if (response?.ok === false) { - expect(response.error.code).toBe('COMMAND_FAILED'); - expect(response.error.details).toMatchObject({ - command: 'is', - reason: 'predicate_failed', - predicate: 'absent', - observation: 'sparse', - matches: 0, - snapshotQuality: { - state: 'sparse', - backend: 'tree', - reasonCode: 'sparse-tree', - }, - }); - } + expect(response?.ok).toBe(true); expect(fixture.captures).toHaveLength(1); + expect(fixture.captures[0]?.options?.includeRects).toBe(false); + expect(sessionStore.get('is-absent-fresh')?.snapshot?.nodes[0]?.label).toBe('Removed row'); }); test('is absent rejects depth and scope before binding with typed invalid arguments', async () => { @@ -221,7 +182,7 @@ test('is absent rejects depth and scope before binding with typed invalid argume ] as const) { const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-absent-flags', ['absent', 'label="Gone"'], { [flag]: value }), sessionName: 'is-absent-flags', sessionStore, @@ -252,7 +213,7 @@ test('an unavailable capture fact refuses before any bind', async () => { const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-refused', ['visible', 'id=auth_continue']), sessionName: 'is-refused', sessionStore, @@ -286,7 +247,7 @@ test('an iOS session with no tracked app is refused with the open hint, not answ async () => await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-no-app', ['visible', 'id=auth_continue']), sessionName: 'is-no-app', sessionStore, @@ -314,7 +275,7 @@ test('an iOS session WITH a tracked app still answers, so the refusal is the pla const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-with-app', ['visible', 'label=Continue']), sessionName: 'is-with-app', sessionStore, @@ -333,7 +294,7 @@ test('an Android session with no tracked app proceeds, because the owner adverti const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-android-no-app', ['visible', 'id=auth_continue']), sessionName: 'is-android-no-app', sessionStore, @@ -371,7 +332,7 @@ test('a refused request reaches the device by no route at all', async () => { const response = await dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-direct-refused', ['selected', 'id="shipping-pickup"']), sessionName: 'is-direct-refused', sessionStore, @@ -393,64 +354,6 @@ test('a refused request reaches the device by no route at all', async () => { // // The CLI half — that such a response actually exits non-zero — lives in // `src/__tests__/cli-exit-paths.test.ts`, at a layer that does not know how the daemon decided. -test('a failing predicate answers COMMAND_FAILED from the bound capture', async () => { - const fixture = selectorCaptureFixture({ - snapshot: () => ({ - nodes: [ - { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, - { - index: 1, - parentIndex: 0, - type: 'Button', - label: 'Apple Account', - identifier: 'account_row', - rect: { x: 10, y: 20, width: 120, height: 44 }, - enabled: true, - hittable: true, - }, - ], - backend: 'xctest', - producer: 'apple-runner', - }), - }); - const sessionStore = makeSessionStore(); - sessionStore.publish('is-direct-false', makeIosAppSession('is-direct-false')); - mockRunAppleRunnerCommand.mockResolvedValue({ - found: true, - text: 'Apple Account', - nodes: [ - { - index: 0, - type: 'Button', - label: 'Apple Account', - identifier: 'account_row', - rect: { x: 10, y: 20, width: 120, height: 44 }, - enabled: true, - hittable: true, - }, - ], - }); - - const response = await dispatchIsViaRuntime({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: isRequest('is-direct-false', ['text', 'id=account_row', 'Wrong Expected Text']), - sessionName: 'is-direct-false', - sessionStore, - inspectFacts: fixture.inspectFacts, - bindDevice: fixture.bindDevice, - }); - - // A failed assertion is a failed command on every other path and in the docs; it is one here. - expect(response?.ok).toBe(false); - if (response?.ok === false) { - expect(response.error?.code).toBe('COMMAND_FAILED'); - expect(response.error?.details?.reason).toBe('predicate_failed'); - } - // The bound capture is what answered it. - expect(fixture.captures.length).toBeGreaterThan(0); -}); - test('a miss on a surface that never settled carries the unsettled fact, and the re-read captures afresh', async () => { vi.useFakeTimers(); // Every capture shows the row at a new offset, so no two consecutive reads agree. @@ -475,7 +378,7 @@ test('a miss on a surface that never settled carries the unsettled fact, and the const isVisible = () => dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-unsettled', ['visible', 'id=target']), sessionName: 'is-unsettled', sessionStore, @@ -519,7 +422,7 @@ test('a read after a scroll that moved nothing carries the no-effect outcome, an const isVisible = () => dispatchIsViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: isRequest('is-no-effect', ['visible', 'id=row']), sessionName: 'is-no-effect', sessionStore, diff --git a/src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts b/packages/daemon/src/execution/sparse-fallback-screenshot.test.ts similarity index 77% rename from src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts rename to packages/daemon/src/execution/sparse-fallback-screenshot.test.ts index d2ad407e78..8c318ffe44 100644 --- a/src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts +++ b/packages/daemon/src/execution/sparse-fallback-screenshot.test.ts @@ -1,20 +1,20 @@ -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../__tests__/support/platform-services-fixture.ts'; import path from 'node:path'; import { expect, test } from 'vitest'; import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; -import { makeIosSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; +import { makeIosSession } from '../__tests__/support/session-factories.ts'; import { mkdtempForTest } from '@agent-device/test-kit'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { dispatchSnapshotViaRuntime } from '@agent-device/daemon/testing/snapshot-runtime'; -import type { DownloadableArtifactRegistration } from '@agent-device/daemon/testing/artifact-tracking'; -import { finalizeDaemonResponse } from '@agent-device/daemon/testing/request-finalization'; +import { SessionStore } from '../core/session-store.ts'; +import { dispatchSnapshotViaRuntime } from '../sessions/snapshot-runtime.ts'; +import type { DownloadableArtifactRegistration } from '../resources/artifact-tracking.ts'; +import { finalizeDaemonResponse } from '../server/request-finalization.ts'; import { screenshotRuntimeFixture, writeSolidPng, type ScreenshotRuntimeFixture, type ScreenshotRuntimeFixtureOptions, -} from '@agent-device/daemon/testing/__tests__/screenshot-runtime-fixture'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +} from '../__tests__/support/screenshot-runtime-fixture.ts'; +import { screenshotCommandSurface as testCreateCommandSurface } from '../__tests__/support/screenshot-command-surface-fake.ts'; const SPARSE: SnapshotQualityVerdict = { state: 'sparse', @@ -59,7 +59,7 @@ async function dispatch( ) { const response = await dispatchSnapshotViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: { command: 'snapshot', positionals: [], @@ -82,8 +82,6 @@ test('a sparse snapshot captures the screenshot its own remedy asks for and link const runtime = seed(SPARSE); const data = await dispatch(input, runtime); - const warnings = (data.warnings ?? []) as string[]; - expect(runtime.captureScreenshot).toHaveBeenCalledTimes(1); expect(data.fallbackScreenshotPath).toMatch(/\.png$/); expect(data.artifacts).toEqual([ @@ -94,10 +92,6 @@ test('a sparse snapshot captures the screenshot its own remedy asks for and link fileName: 'snapshot-fallback.png', }, ]); - // The verdict's own two lines still stand: the refs are invalid, and a screen that - // publishes nothing is an app defect worth reporting. - expect(warnings.some((line) => line.startsWith('No snapshot backend could read'))).toBe(true); - expect(warnings.some((line) => line.includes('app accessibility bug'))).toBe(true); }); test('a readable snapshot never pays for a screenshot', async () => { @@ -129,13 +123,9 @@ test('a failed fallback screenshot does not fail the snapshot that was asked for }); const data = await dispatch(input, runtime); - const warnings = (data.warnings ?? []) as string[]; - expect(runtime.captureScreenshot).toHaveBeenCalledTimes(1); expect(data.fallbackScreenshotPath).toBeUndefined(); expect(data.artifacts).toBeUndefined(); - // The manual remedy is still on the response, so the caller is not left without one. - expect(warnings.some((line) => line.includes('Use screenshot as visual truth'))).toBe(true); }); test('the fallback screenshot reaches the artifact inventory with its display rotation', async () => { diff --git a/packages/daemon/src/execution/wait-runtime.test.ts b/packages/daemon/src/execution/wait-runtime.test.ts new file mode 100644 index 0000000000..49f1002208 --- /dev/null +++ b/packages/daemon/src/execution/wait-runtime.test.ts @@ -0,0 +1,525 @@ +import { daemonPlatformServicesFixture } from '../__tests__/support/platform-services-fixture.ts'; +import { expect, test, vi } from 'vitest'; +import { WAIT_REASONS } from '@agent-device/contracts/wait'; +import { AppError } from '@agent-device/kernel/errors'; +import { + type DeviceBinding, + type RuntimeFacts, + type RuntimeOperationFact, + localRuntimeOwner, + narrowDeviceBinding, + providerRuntimeOwner, +} from '@agent-device/contracts/platform-runtime'; +import { + type PlatformRuntimeOperations, + waitSelectorCaptureRuntimePlanUses, +} from '@agent-device/contracts/platform-runtime-operations'; +import type { FindTextInput } from '@agent-device/contracts/selector-observation-runtime'; +import { + type CaptureSnapshotInput, + type SnapshotResult, + snapshotRuntimeOperationFacts, +} from '@agent-device/contracts/snapshot-runtime'; +import { commandSurfaceWithMembers } from '../__tests__/support/command-surface-override-fake.ts'; +import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; +import { makeSession } from '../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit'; +import type { + BindDeviceRuntime, + InspectDeviceRuntimeFacts, +} from '../core/request-runtime-binding.ts'; +import { handleSnapshotCommands } from '../sessions/handlers/snapshot.ts'; +import { resolveBoundSelectorCapture } from './selector-capture-binding.ts'; +import type { DaemonRequest } from '../index.ts'; + +const testCreateCommandSurface = commandSurfaceWithMembers((config) => ({ + selectors: { + wait: async ({ target, session, requestId, signal }) => { + if (target.kind === 'sleep') { + await new Promise((resolve) => setTimeout(resolve, target.durationMs)); + return { kind: 'sleep', waitedMs: target.durationMs }; + } + const context = { session, requestId, signal: signal ?? config.signal }; + if (target.kind === 'text') { + const native = await config.backend.findText?.(context, target.text); + if (native?.found) return { kind: 'text', text: target.text, waitedMs: 0 }; + } + await config.backend.captureSnapshot?.(context, {}); + if (target.kind === 'text' && target.timeoutMs === 1) { + throw new AppError('COMMAND_FAILED', `wait timed out for text: ${target.text}`, { + reason: WAIT_REASONS.targetAbsent, + }); + } + return { + kind: target.kind === 'ref' ? 'text' : target.kind, + waitedMs: 0, + ...(target.kind === 'text' || target.kind === 'ref' ? { text: 'Ready' } : {}), + ...(target.kind === 'selector' || target.kind === 'absent' + ? { selector: target.selector } + : {}), + ...(target.kind === 'stable' ? { captures: 1 } : {}), + }; + }, + }, +})); + +const webDevice = { + id: 'web', + name: 'Web', + platform: 'web', + kind: 'device', + booted: true, +} as const satisfies DeviceInfo; + +const available = Object.freeze({ available: true } as const); +/** The harness session tracks no app bundle id, so wait's plan is the without-active-app one. */ +const waitWithoutActiveAppUse = waitSelectorCaptureRuntimePlanUses[1]; + +const findTextUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf' as const, + hint: 'This target exposes no native text reading.', +}); +const captureUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf' as const, + hint: 'wait observes a snapshot; this target exposes none.', +}); + +type CaptureNode = { + index: number; + depth: number; + type: string; + label?: string; + hittable?: boolean; +}; + +/** + * Binds the fake at the seam the handler consumes — `inspectFacts` / `bindDevice` — never at + * `-device/device-selection/dispatch-resolve`. `captureSnapshot` is the ONE operation `wait` declares, so this harness is + * also the proof that no sibling snapshot operation is reachable from wait's narrowed binding. + */ +function waitRuntimeHarness( + options: { + capture?: RuntimeOperationFact; + device?: DeviceInfo; + providerOwned?: boolean; + nodesPerPoll?: CaptureNode[][]; + /** The owner's native text reading; absent means the owner advertises none. */ + findText?: RuntimeOperationFact; + findTextAnswers?: (text: string) => boolean; + /** A scenario-supplied capture operation, for poll-deadline behaviour. */ + captureSnapshot?: (input: CaptureSnapshotInput) => Promise; + } = {}, +) { + const device = options.device ?? webDevice; + const capture = options.capture ?? available; + const polls = options.nodesPerPoll ?? [ + [{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }], + ]; + let pollIndex = 0; + + const captureSnapshot = vi.fn(async (input: CaptureSnapshotInput): Promise => + options.captureSnapshot + ? await options.captureSnapshot(input) + : { + nodes: polls[Math.min(pollIndex++, polls.length - 1)] ?? [], + backend: 'web', + producer: 'agent-browser', + }, + ); + const findTextFact = options.findText ?? findTextUnavailable; + const findText = vi.fn(async (input: FindTextInput) => ({ + found: options.findTextAnswers?.(input.text) ?? false, + })); + const facts: RuntimeFacts = { + device: { + ...deviceShape(device), + providerMode: options.providerOwned ? 'provider-runtime' : 'local', + }, + operations: { + ...unavailableDeploymentSnapshotAndShutdownOperationFacts, + ...snapshotRuntimeOperationFacts({ + capture, + customActions: captureUnavailable, + withoutActiveApp: capture, + }), + findText: findTextFact, + } as RuntimeFacts['operations'], + }; + const binding = { + device, + owner: options.providerOwned + ? providerRuntimeOwner('test', 'wait-runtime-test') + : localRuntimeOwner(device.platform), + facts, + operations: { + captureSnapshot, + captureSnapshotWithCustomActions: captureSnapshot, + captureSnapshotWithoutActiveApp: captureSnapshot, + // Only advertised operations get an implementation, so an owner whose facts refuse + // `findText` cannot expose one through the narrowed projection either. + ...(findTextFact.available ? { findText } : {}), + }, + [Symbol.asyncDispose]: async () => {}, + } as unknown as DeviceBinding; + + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts); + const bindDevice = vi.fn(async (_device, use) => + narrowDeviceBinding(binding, use), + ) as unknown as BindDeviceRuntime; + return { device, captureSnapshot, findText, inspectFacts, bindDevice, facts }; +} + +function waitRequest(positionals: string[], flags: Record = {}): DaemonRequest { + return { + command: 'wait', + positionals, + token: 't', + session: 'wait-runtime', + flags, + meta: { requestId: 'wait-runtime-test' }, + } as unknown as DaemonRequest; +} + +test.each(['rebuild', 'retire'] as const)( + 'sleep-only wait records in its admitted lifetime across %s', + async (transition) => { + vi.useFakeTimers(); + try { + const harness = waitRuntimeHarness(); + const sessionStore = makeSessionStore(); + const address = 'cwd:sleep-wait:default'; + const ref = sessionStore.publish(address, makeSession('default', { device: harness.device })); + const running = handleSnapshotCommands({ + createCommandSurface: testCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: waitRequest(['100']), + sessionName: address, + logPath: '/tmp/daemon.log', + sessionStore, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + await vi.advanceTimersByTimeAsync(0); + let current = ref.session; + if (transition === 'rebuild') { + current = sessionStore.update(ref, { actions: [], appName: 'Updated during sleep' }); + } else { + sessionStore.retire(ref); + current = sessionStore.publish( + address, + makeSession('default', { device: harness.device }), + ).session; + } + await vi.advanceTimersByTimeAsync(100); + const response = await running; + expect(harness.captureSnapshot).not.toHaveBeenCalled(); + if (transition === 'rebuild') { + expect(response?.ok).toBe(true); + expect(current.actions.map((action) => action.command)).toEqual(['wait']); + expect(ref.session.actions).toEqual([]); + expect(current.appName).toBe('Updated during sleep'); + } else { + expect(response).toMatchObject({ ok: true, data: { waitedMs: 100 } }); + expect(ref.session.actions).toEqual([]); + expect(current.actions).toEqual([]); + } + } finally { + vi.useRealTimers(); + } + }, +); + +async function runWait( + positionals: string[], + harness: ReturnType, + sessionNodes: CaptureNode[] = [], + flags: Record = {}, +) { + const sessionStore = makeSessionStore('agent-device-wait-runtime-'); + const session = makeSession('wait-runtime', { device: harness.device }); + if (sessionNodes.length > 0) { + session.snapshot = { + nodes: sessionNodes.map((node) => ({ ...node, ref: `e${node.index + 1}` })), + createdAt: Date.now(), + backend: 'web', + } as unknown as NonNullable; + } + sessionStore.publish(session.name, session); + const response = await handleSnapshotCommands({ + createCommandSurface: testCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: waitRequest(positionals, flags), + sessionName: session.name, + logPath: '/tmp/daemon.log', + sessionStore, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + if (!response) throw new Error('the snapshot route did not handle wait'); + return { response, session, sessionStore }; +} + +// --------------------------------------------------------------------------- +// The duration shape. It is the one wait shape that reaches no device at all, +// and legacy admission skipped `requireCommandSupported` for exactly that +// reason. `resolveWaitRuntimePlan` returns a plan with no `use`, so there is +// nothing to inspect and nothing to bind — including on a target whose capture +// fact is unavailable. +// --------------------------------------------------------------------------- + +test('a duration wait inspects no facts and binds no device', async () => { + vi.useFakeTimers(); + try { + const harness = waitRuntimeHarness({ capture: captureUnavailable }); + const pending = runWait(['5'], harness); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(5); + const { response } = await pending; + + expect(response).toMatchObject({ ok: true, data: { waitedMs: 5 } }); + expect(harness.inspectFacts).not.toHaveBeenCalled(); + expect(harness.bindDevice).not.toHaveBeenCalled(); + expect(harness.captureSnapshot).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } +}); + +test('an observing wait binds through the shared selector entry', async () => { + const harness = waitRuntimeHarness({ findText: available }); + + const bound = await resolveBoundSelectorCapture({ + platformServices: daemonPlatformServicesFixture(), + command: 'wait', + session: undefined, + device: harness.device, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + + expect(bound.ok).toBe(true); + if (!bound.ok) return; + // Wait reaches the platform through the same owning interface as every other selector + // command; its preferred reading rides that record rather than a parallel binder. + expect(bound.operations.capture).toBeTypeOf('function'); + expect(bound.operations.findText).toBeTypeOf('function'); + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledTimes(1); +}); + +// --------------------------------------------------------------------------- +// Every observing shape: one inspection, one bind, one declared operation. +// --------------------------------------------------------------------------- + +test('a text wait binds the capture use once and polls through the bound operation', async () => { + const harness = waitRuntimeHarness(); + + const { response } = await runWait(['text', 'Ready'], harness); + + expect(response).toMatchObject({ ok: true, data: { text: 'Ready' } }); + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.inspectFacts).toHaveBeenCalledWith(harness.device); + expect(harness.bindDevice).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledWith(harness.device, waitWithoutActiveAppUse); + expect(harness.captureSnapshot).toHaveBeenCalled(); +}); + +test('a text wait returns a native owner hit without capturing a snapshot', async () => { + const harness = waitRuntimeHarness({ + findText: available, + findTextAnswers: (text) => text === 'Ready', + }); + + const { response } = await runWait(['text', 'Ready'], harness); + + expect(response).toMatchObject({ ok: true, data: { text: 'Ready' } }); + expect(harness.findText).toHaveBeenCalledWith(expect.objectContaining({ text: 'Ready' })); + expect(harness.bindDevice).toHaveBeenCalledWith(harness.device, waitWithoutActiveAppUse); + expect(harness.captureSnapshot).not.toHaveBeenCalled(); +}); + +test('the native text reading runs in the same runner context as the capture', async () => { + let nativeReads = 0; + const harness = waitRuntimeHarness({ + findText: available, + findTextAnswers: () => nativeReads++ > 0, + nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }]], + }); + + const { response } = await runWait(['text', 'Ready', '4000'], harness, [], { + debug: true, + iosXctestrunFile: '/tmp/override.xctestrun', + iosXctestDerivedDataPath: '/tmp/derived', + }); + + expect(response.ok).toBe(true); + const captureExecution = harness.captureSnapshot.mock.calls[0]?.[0]?.execution; + const findTextExecution = harness.findText.mock.calls[0]?.[0]?.execution; + expect(captureExecution).toMatchObject({ + requestId: 'wait-runtime-test', + iosXctestrunFile: '/tmp/override.xctestrun', + iosXctestDerivedDataPath: '/tmp/derived', + }); + expect(findTextExecution).toEqual(captureExecution); +}); + +test('native text failure cannot hide a canonical capture failure', async () => { + const harness = waitRuntimeHarness({ findText: available }); + harness.findText.mockRejectedValueOnce(new Error('native observation failed')); + harness.captureSnapshot.mockRejectedValueOnce( + new AppError('COMMAND_FAILED', 'canonical capture failed', { + reason: 'capture_failed', + }), + ); + + const { response } = await runWait(['text', 'Ready'], harness); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error).toMatchObject({ + code: 'COMMAND_FAILED', + message: 'canonical capture failed', + details: { reason: 'capture_failed' }, + }); + expect(harness.findText).toHaveBeenCalledTimes(1); + expect(harness.captureSnapshot).toHaveBeenCalledTimes(1); +}); + +test('a selector wait binds the capture use once and polls through the bound operation', async () => { + const harness = waitRuntimeHarness(); + + const { response } = await runWait(['label=Ready'], harness); + + expect(response).toMatchObject({ ok: true, data: { selector: 'label=Ready' } }); + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledWith(harness.device, waitWithoutActiveAppUse); +}); + +test('an unavailable conditional native reading preserves the capture-backed wait path', async () => { + const harness = waitRuntimeHarness({ + nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }]], + }); + + const { response } = await runWait(['text', 'Ready'], harness); + + expect(response).toMatchObject({ ok: true, data: { text: 'Ready' } }); + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledTimes(1); + expect(harness.findText).not.toHaveBeenCalled(); + expect(harness.captureSnapshot).toHaveBeenCalledTimes(1); +}); + +test('a @ref wait binds the capture use once and polls through the bound operation', async () => { + const harness = waitRuntimeHarness(); + + const { response } = await runWait(['@e1'], harness, [ + { index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }, + ]); + + expect(response).toMatchObject({ ok: true, data: { text: 'Ready' } }); + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledWith(harness.device, waitWithoutActiveAppUse); +}); + +test('a stable wait binds the capture use once and polls through the bound operation', async () => { + const harness = waitRuntimeHarness(); + + const { response } = await runWait(['stable', '1', '5000'], harness); + + expect(response).toMatchObject({ ok: true, data: { captures: expect.any(Number) } }); + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledWith(harness.device, waitWithoutActiveAppUse); +}); + +test.each([ + ['scope', { snapshotScope: 'Root' }], + ['depth', { snapshotDepth: 2 }], +])('strict wait absent refuses --%s before device admission', async (_option, flags) => { + const harness = waitRuntimeHarness(); + + const { response } = await runWait(['absent', 'label="Ready"', '400'], harness, [], flags); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'INVALID_ARGS', + details: { command: 'wait', predicate: 'absent' }, + }, + }); + if (!response.ok) { + expect(response.error.details?.rejectedOption).toBe(_option); + } + expect(harness.inspectFacts).not.toHaveBeenCalled(); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +// --------------------------------------------------------------------------- +// Facts are the only support authority: an unavailable exact-owner fact +// refuses BEFORE any binding, and provider ownership never borrows the local +// family runtime. +// --------------------------------------------------------------------------- + +test('an unavailable capture fact refuses an observing wait before binding', async () => { + const harness = waitRuntimeHarness({ capture: captureUnavailable }); + + const { response } = await runWait(['text', 'Ready'], harness); + + expect(response).toEqual({ + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'wait is not supported on this device', + details: { reason: captureUnavailable.reason }, + hint: captureUnavailable.hint, + }, + }); + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).not.toHaveBeenCalled(); + expect(harness.captureSnapshot).not.toHaveBeenCalled(); +}); + +test('a provider owner that cannot capture fails closed instead of borrowing the local runtime', async () => { + const harness = waitRuntimeHarness({ capture: captureUnavailable, providerOwned: true }); + + const { response } = await runWait(['label=Ready'], harness); + + expect(response).toMatchObject({ + ok: false, + error: { code: 'UNSUPPORTED_OPERATION' }, + }); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +// --------------------------------------------------------------------------- +// The timeout-surface decoration is wait's platform execution too. It reuses +// the SAME binding rather than reaching a second capture owner. +// --------------------------------------------------------------------------- + +test('a timed-out wait decorates its failure through the same single binding', async () => { + vi.useFakeTimers(); + try { + const harness = waitRuntimeHarness({ + nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }]], + }); + + const pending = runWait(['text', 'Ready', '1'], harness); + await vi.advanceTimersByTimeAsync(5_000); + const { response } = await pending; + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.message).toContain('wait timed out for text: Ready'); + expect(response.error.message).toContain('Current surface: Checkout'); + expect(harness.inspectFacts).toHaveBeenCalledTimes(1); + expect(harness.bindDevice).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } +}); diff --git a/src/__tests__/daemon-composition/request-router-android-modal.test.ts b/packages/daemon/src/server/__tests__/request-router-android-modal.test.ts similarity index 89% rename from src/__tests__/daemon-composition/request-router-android-modal.test.ts rename to packages/daemon/src/server/__tests__/request-router-android-modal.test.ts index 9b7176b6b1..f4b682cce7 100644 --- a/src/__tests__/daemon-composition/request-router-android-modal.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-android-modal.test.ts @@ -1,10 +1,11 @@ import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { test, expect, vi } from 'vitest'; +import { afterEach, test, expect, vi } from 'vitest'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { createTouchCommandSurface } from '../../__tests__/support/touch-command-surface-fake.ts'; +const testCreateCommandSurface = createTouchCommandSurface(); let snapshotCalls = 0; let snapshotMode: 'blocking-dialog' | 'throws' = 'blocking-dialog'; @@ -13,25 +14,21 @@ import { createRequestHandler, gestureDeviceRuntimeGateway, gestureRuntimeSpies, -} from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { - createProviderDeviceRuntimeRequestProviders, - isActiveProviderDevice, -} from '../../provider-device-runtime.ts'; -import { installProviderDeviceAdmission } from '@agent-device/daemon/testing/provider-device-admission'; - -// Root composition installs the daemon's provider-device admission; this test composes the -// request providers the same way, so it installs the fact the same way. -installProviderDeviceAdmission({ isActive: isActiveProviderDevice }); +} from '../../__tests__/support/test-device-runtime-gateway.ts'; +import type { SessionState } from '../../core/session-state.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { providerDeviceAdmissionFixture } from '../../__tests__/support/provider-device-admission-fixture.ts'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; -import { makeTestScreenRecordingResource } from '@agent-device/daemon/testing/__tests__/support/screen-recording-live-handle'; -import { androidObservation } from '../../platform-runtime.ts'; +import { makeTestScreenRecordingResource } from '../../__tests__/support/screen-recording-live-handle.ts'; +import { androidObservationPolicyFixture as androidObservation } from '../../__tests__/support/android-observation-fixture.ts'; import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; import { mkdtempForTestSync } from '@agent-device/test-kit'; +const providerAdmission = providerDeviceAdmissionFixture(); + +afterEach(() => providerAdmission.setActive([])); + vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); const capture = (nodes: RawSnapshotNode[]) => @@ -267,7 +264,7 @@ test('generic Android gesture commands skip local dialog recovery for provider d getInteractor: () => undefined, shutdown: async () => {}, }; - const providers = createProviderDeviceRuntimeRequestProviders([runtime]); + providerAdmission.setActive([runtime]); const handler = createRequestHandler({ createCommandSurface: testCreateCommandSurface, @@ -276,7 +273,6 @@ test('generic Android gesture commands skip local dialog recovery for provider d sessionStore, leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), - providerDeviceRuntimeScope: providers.providerDeviceRuntimeScope, trackDownloadableArtifact: () => 'artifact-id', deviceRuntimeGateway: gestureDeviceRuntimeGateway, androidObservation: modalObservation, diff --git a/packages/daemon/src/server/__tests__/request-router-android-snapshot-helper.test.ts b/packages/daemon/src/server/__tests__/request-router-android-snapshot-helper.test.ts new file mode 100644 index 0000000000..860293bae0 --- /dev/null +++ b/packages/daemon/src/server/__tests__/request-router-android-snapshot-helper.test.ts @@ -0,0 +1,86 @@ +import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import { expect, test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { ANDROID_EMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; +import { forwardingCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; +import { makeSession } from '../../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; +import { screenshotRuntimeFixture } from '../../__tests__/support/screenshot-runtime-fixture.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; + +function snapshotHandler(error: AppError) { + const sessionStore = makeSessionStore('agent-device-snapshot-helper-response-'); + sessionStore.publish( + 'default', + makeSession('default', { device: ANDROID_EMULATOR, appBundleId: 'com.example.app' }), + ); + const runtime = screenshotRuntimeFixture({ + snapshotResult: () => { + throw error; + }, + }); + const handler = createRequestHandler({ + createCommandSurface: forwardingCreateCommandSurface, + logPath: `${mkdtempForTestSync('agent-device-snapshot-helper-response-')}/daemon.log`, + token: 'token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: runtime.gateway, + trackDownloadableArtifact: () => 'artifact-id', + }); + return handler; +} + +async function requestSnapshot(handler: ReturnType) { + return await handler({ + token: 'token', + session: 'default', + command: 'snapshot', + positionals: [], + flags: {}, + }); +} + +test('snapshot responses preserve the device-side install failure classification', async () => { + const failure = new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper failed: Failed to install Android snapshot helper: INSTALL_FAILED_TEST_ONLY', + { + stderr: 'adb: failed to install helper.apk: Failure [INSTALL_FAILED_TEST_ONLY]', + hint: 'The Android package installer rejected the APK; this is a device-side install failure.', + androidSnapshotHelperInstallFailure: true, + }, + ); + const response = await requestSnapshot(snapshotHandler(failure)); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.message).toContain('INSTALL_FAILED_TEST_ONLY'); + expect(response.error.hint).toMatch(/package installer rejected the APK/); + expect(response.error.hint).toMatch(/device-side install failure/); + expect(response.error.hint).not.toMatch(/pnpm build:android/); + expect(response.error.details?.androidSnapshotHelperInstallFailure).toBe(true); +}); + +test('snapshot responses preserve the transient retry signal on an install failure', async () => { + const failure = new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper failed: Failed to install Android snapshot helper', + { + hint: 'The adb connection dropped; retry the device-side install failure.', + retriable: true, + androidSnapshotHelperInstallFailure: true, + }, + ); + const response = await requestSnapshot(snapshotHandler(failure)); + + expect(response.ok).toBe(false); + if (response.ok) return; + expect(response.error.message).toMatch(/Android snapshot helper failed/); + expect(response.error.hint).toMatch(/connection dropped/); + expect(response.error.hint).toMatch(/device-side install failure/); + expect(response.error.retriable).toBe(true); + expect(response.error.details?.androidSnapshotHelperInstallFailure).toBe(true); +}); diff --git a/src/__tests__/daemon-composition/request-router-fill-text-stdin.test.ts b/packages/daemon/src/server/__tests__/request-router-fill-text-stdin.test.ts similarity index 82% rename from src/__tests__/daemon-composition/request-router-fill-text-stdin.test.ts rename to packages/daemon/src/server/__tests__/request-router-fill-text-stdin.test.ts index e3527c0557..1df40feeca 100644 --- a/src/__tests__/daemon-composition/request-router-fill-text-stdin.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-fill-text-stdin.test.ts @@ -8,23 +8,27 @@ import { expect, test } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import type { WebProvider } from '@agent-device/platform-web'; -import type { DaemonRequest } from '@agent-device/daemon'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; import { - makeAuthoringSession, - makeSession, -} from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { mkdtempForTestSync, WEB_DESKTOP_DEVICE } from '@agent-device/test-kit'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; -import { replayScriptSourceBundleFor } from '../test-utils/replay-script-source.fixtures.ts'; + localRuntimeOwner, + type DeviceBinding, + type DeviceRuntimeGateway, + type RuntimeFacts, +} from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import type { FillPointInput } from '@agent-device/contracts/touch-runtime'; import { - createPlatformRuntimeGateway, - createRequestPlatformProviders, -} from '../../platform-runtime.ts'; + createUnavailableRuntimeFactsForTest, + mkdtempForTestSync, + WEB_DESKTOP_DEVICE, +} from '@agent-device/test-kit'; +import type { DaemonRequest } from '../../core/daemon-request.ts'; +import type { SessionState } from '../../core/session-state.ts'; +import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { makeAuthoringSession, makeSession } from '../../__tests__/support/session-factories.ts'; +import { createTouchCommandSurface } from '../../__tests__/support/touch-command-surface-fake.ts'; +import { replayScriptSourceBundleFor } from '../../__tests__/support/replay-script-source-fixture.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; const SECRET = 'stdin-s3cret-value'; @@ -49,38 +53,39 @@ function sendToWebSession( const sessionStore = makeSessionStore('agent-device-router-fill-text-stdin-'); sessionStore.publish(session.name, session); const typed: string[] = []; - const webProvider: WebProvider = { - open: async () => {}, - close: async () => {}, - snapshot: async () => ({ nodes: [] }), - screenshot: async () => {}, - setViewport: async () => {}, - click: async () => {}, - fill: async (_x, _y, text) => { - typed.push(text); - if (fillFails) throw fillFails(text); + const fillPoint = async (input: FillPointInput) => { + typed.push(input.text); + if (fillFails) throw fillFails(input.text); + }; + const owner = localRuntimeOwner('web'); + const baseFacts = createUnavailableRuntimeFactsForTest(WEB_DESKTOP_DEVICE, owner); + const facts: RuntimeFacts = { + ...baseFacts, + operations: { ...baseFacts.operations, fillPoint: { available: true } }, + }; + const deviceRuntimeGateway: DeviceRuntimeGateway = { + inspectFacts: async () => facts, + bind: async ({ device }) => { + const binding: DeviceBinding = { + device, + owner, + facts, + operations: { fillPoint }, + [Symbol.asyncDispose]: async () => {}, + }; + return binding; }, - typeText: async () => {}, - scroll: async () => {}, + shutdown: async () => {}, }; const dir = mkdtempForTestSync('agent-device-router-fill-text-stdin-runtime-'); const handler = createRequestHandler({ - createCommandSurface: testCreateCommandSurface, + createCommandSurface: createTouchCommandSurface(), logPath: path.join(dir, 'daemon.log'), token: 'test-token', sessionStore, leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), - requestPlatformProviders: createRequestPlatformProviders({ - providers: { webProvider: () => webProvider }, - }), - deviceRuntimeGateway: createPlatformRuntimeGateway({ - sessionsDir: dir, - resolveSessionArtifacts: (sessionId) => ({ - outputPath: path.join(dir, sessionId, 'app.log'), - pidPath: path.join(dir, sessionId, 'app-log.pid'), - }), - }), + deviceRuntimeGateway, trackDownloadableArtifact: () => 'artifact-id', }); const response = handler({ diff --git a/src/__tests__/daemon-composition/request-router-lock-policy.test.ts b/packages/daemon/src/server/__tests__/request-router-lock-policy.test.ts similarity index 96% rename from src/__tests__/daemon-composition/request-router-lock-policy.test.ts rename to packages/daemon/src/server/__tests__/request-router-lock-policy.test.ts index a72cda18ba..ad7aec6606 100644 --- a/src/__tests__/daemon-composition/request-router-lock-policy.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-lock-policy.test.ts @@ -3,9 +3,9 @@ import { createTestDeviceInventoryGatewaysFromProvider, } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { test, expect, vi, beforeEach } from 'vitest'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; +import { legacyDispatchCapture } from '../../__tests__/support/legacy-snapshot-capture-fixture.ts'; import path from 'node:path'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { forwardingCreateCommandSurface as testCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { const actual = @@ -13,7 +13,7 @@ vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) }; }); -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ +vi.mock('../../core/device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); @@ -21,11 +21,11 @@ import { createRequestHandler, lifecycleDeviceRuntimeGateway, systemRuntimeSpies, -} from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { snapshotRuntimeFixture } from '@agent-device/daemon/testing/__tests__/snapshot-runtime-fixture'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; +} from '../../__tests__/support/test-device-runtime-gateway.ts'; +import { snapshotRuntimeFixture } from '../../__tests__/support/snapshot-runtime-fixture.ts'; +import type { SessionState } from '../../core/session-state.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import type { DeviceRuntimeGateway } from '@agent-device/contracts/platform-runtime'; import { type PlatformRuntimeOperations, diff --git a/src/__tests__/daemon-composition/request-router-macos-app-lease.test.ts b/packages/daemon/src/server/__tests__/request-router-macos-app-lease.test.ts similarity index 88% rename from src/__tests__/daemon-composition/request-router-macos-app-lease.test.ts rename to packages/daemon/src/server/__tests__/request-router-macos-app-lease.test.ts index 7a7c571765..7e09a37289 100644 --- a/src/__tests__/daemon-composition/request-router-macos-app-lease.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-macos-app-lease.test.ts @@ -3,13 +3,13 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import path from 'node:path'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; import { MACOS_DEVICE, mkdtempForTestSync } from '@agent-device/test-kit'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { makeMacOsSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import type { DaemonRequest } from '@agent-device/daemon'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { screenshotRuntimeFixture } from '@agent-device/daemon/testing/__tests__/screenshot-runtime-fixture'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { makeMacOsSession } from '../../__tests__/support/session-factories.ts'; +import type { DaemonRequest } from '../../index.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; +import { screenshotRuntimeFixture } from '../../__tests__/support/screenshot-runtime-fixture.ts'; +import { screenshotCommandSurface as testCreateCommandSurface } from '../../__tests__/support/screenshot-command-surface-fake.ts'; const BUNDLE_ID = 'com.example.leased'; const LEASE_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; diff --git a/packages/daemon/src/server/__tests__/request-router-open-claim-recovery.test.ts b/packages/daemon/src/server/__tests__/request-router-open-claim-recovery.test.ts new file mode 100644 index 0000000000..0719f0275a --- /dev/null +++ b/packages/daemon/src/server/__tests__/request-router-open-claim-recovery.test.ts @@ -0,0 +1,98 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { + canonicalLocalDeviceKey, + inspectDeviceClaims, +} from '@agent-device/daemon-contracts/device-claims'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { deviceIdentity, type DeviceInfo } from '@agent-device/kernel/device'; +import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import { + createRequestHandler, + lifecycleDeviceRuntimeGateway, +} from '../../__tests__/support/test-device-runtime-gateway.ts'; +import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { unavailableDeviceRuntimeGateway } from '../../__tests__/support/unavailable-device-runtime-gateway.ts'; +import { getResolveTargetDeviceMock } from '../../__tests__/support/request-router-dispatch-mocks.ts'; +import { makeIosDevice, openRequest } from './request-router-open-harness.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; + +const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); + +beforeEach(() => { + mockResolveTargetDevice.mockReset(); +}); + +test('open composes claim recovery from the foreign owner state directory', async () => { + const device = { ...makeIosDevice('SIM-FOREIGN-RECOVERY'), appleOs: 'ios' as const }; + mockResolveTargetDevice.mockResolvedValue(device); + const claimsDir = mkdtempForTestSync('agent-device-open-claims-'); + const foreignStateDir = mkdtempForTestSync('agent-device-open-foreign-'); + const sessionStore = makeSessionStore('agent-device-open-recovery-'); + const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; + process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; + const recoverySessionDirs: string[] = []; + const composeClaimRecovery = (input: { sessionsDir: string }) => { + recoverySessionDirs.push(input.sessionsDir); + return unavailableDeviceRuntimeGateway; + }; + + try { + writeForeignClaim(claimsDir, foreignStateDir, device); + const handler = createRequestHandler({ + createCommandSurface: fakeCreateCommandSurface, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + token: 'test-token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, + claimRecoveryGateway: composeClaimRecovery, + deviceInventoryGateways: createTestDeviceInventoryGateways(), + trackDownloadableArtifact: () => 'artifact-id', + }); + + const response = await handler( + openRequest('takeover', { platform: 'ios' }, 'req-open-foreign-recovery'), + ); + + expect(response.ok).toBe(true); + const ownerSessionsDir = resolveDaemonPaths(foreignStateDir).sessionsDir; + const daemonSessionsDir = resolveDaemonPaths(sessionStore.resolveDaemonStateDir()).sessionsDir; + expect(recoverySessionDirs).toEqual([ownerSessionsDir]); + expect(recoverySessionDirs[0]).not.toBe(daemonSessionsDir); + expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('takeover'); + } finally { + if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; + else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; + fs.rmSync(claimsDir, { recursive: true, force: true }); + fs.rmSync(foreignStateDir, { recursive: true, force: true }); + } +}); + +function writeForeignClaim(claimsDir: string, stateDir: string, device: DeviceInfo): void { + const deviceKey = canonicalLocalDeviceKey(device); + const claimPath = path.join( + claimsDir, + `${crypto.createHash('sha256').update(deviceKey).digest('hex')}.json`, + ); + fs.writeFileSync( + claimPath, + JSON.stringify({ + schemaVersion: 2, + deviceKey, + device: { ...deviceIdentity(device), name: device.name }, + session: 'shared', + workspace: '/worktrees/dead', + stateDir, + ownerPid: 999_999_999, + ownerStartTime: 'old-start-time', + ownerToken: 'open-recovery-token', + createdAtMs: 1, + updatedAtMs: 1, + }), + ); +} diff --git a/src/__tests__/daemon-composition/request-router-provider-credentials.test.ts b/packages/daemon/src/server/__tests__/request-router-provider-credentials.test.ts similarity index 73% rename from src/__tests__/daemon-composition/request-router-provider-credentials.test.ts rename to packages/daemon/src/server/__tests__/request-router-provider-credentials.test.ts index 8282701a77..0c19817d93 100644 --- a/src/__tests__/daemon-composition/request-router-provider-credentials.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-provider-credentials.test.ts @@ -1,15 +1,12 @@ import path from 'node:path'; import { expect, test } from 'vitest'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit'; -import { - providerCredentialFingerprint, - readDaemonProviderCredentials, -} from '../../provider-credential-fingerprint.ts'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; +import { fakeCreateCommandSurface as testCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; +import { providerCredentialsFixture } from '../../__tests__/support/provider-credentials-fixture.ts'; const DAEMON_ENV = { LIMRUN_API_KEY: 'lim-key' }; @@ -28,7 +25,7 @@ test.for([ sessionStore: makeSessionStore('agent-device-router-provider-credentials-store-'), leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), - providerCredentials: readDaemonProviderCredentials(DAEMON_ENV, stateDir), + providerCredentials: providerCredentialsFixture(DAEMON_ENV, stateDir), leaseLifecycleProvider: { allocate: async () => { allocated += 1; @@ -51,7 +48,7 @@ test.for([ clientId: 'client-a', leaseBackend: 'ios-instance', leaseProvider: 'limrun', - providerCredentialFingerprint: providerCredentialFingerprint('limrun', shellEnv), + providerCredentialFingerprint: providerCredentialsFixture(shellEnv).fingerprint('limrun'), }, }); diff --git a/src/__tests__/daemon-composition/request-router-record-runtime-lock.test.ts b/packages/daemon/src/server/__tests__/request-router-record-runtime-lock.test.ts similarity index 71% rename from src/__tests__/daemon-composition/request-router-record-runtime-lock.test.ts rename to packages/daemon/src/server/__tests__/request-router-record-runtime-lock.test.ts index 6f7046ce43..af4d60a2a0 100644 --- a/src/__tests__/daemon-composition/request-router-record-runtime-lock.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-record-runtime-lock.test.ts @@ -11,15 +11,11 @@ import type { ScreenRecordingLiveHandle } from '@agent-device/contracts/screen-r import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { - mkdtempForTestSync, - unavailableDeploymentSnapshotAndShutdownOperationFacts, -} from '@agent-device/test-kit'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; +import { fakeCreateCommandSurface as testCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; const DEVICE: DeviceInfo = { platform: 'android', @@ -34,7 +30,7 @@ vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal return { ...actual, resolveTargetDevice: vi.fn(async () => DEVICE) }; }); -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ +vi.mock('../../core/device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}), })); @@ -73,60 +69,6 @@ test('fresh default-device recording starts serialize before durable admission', expect(runtime.start).toHaveBeenCalledOnce(); }); -test.each([ - { platform: 'linux', id: 'linux', name: 'Linux', kind: 'device', target: 'desktop' }, - { platform: 'vega', id: 'vega', name: 'Vega', kind: 'device', target: 'tv' }, -] satisfies readonly DeviceInfo[])( - 'record start on $platform routes real unavailable runtime facts into public guidance', - async (device) => { - const sessionName = `record-${device.platform}-unsupported`; - const sessionStore = makeSessionStore(`request-router-record-${device.platform}-unsupported-`); - sessionStore.publish(sessionName, { - name: sessionName, - device, - createdAt: 1, - actions: [], - }); - const sessionsDir = mkdtempForTestSync(`record-${device.platform}-runtime-host-`); - const gateway = createPlatformRuntimeGateway({ - sessionsDir, - resolveSessionArtifacts: (candidate) => ({ - outputPath: `${sessionsDir}/${candidate}/app.log`, - pidPath: `${sessionsDir}/${candidate}/app-log.pid`, - }), - }); - const bind = vi.fn(async (request) => await gateway.bind(request)); - const handler = createRequestHandler({ - createCommandSurface: testCreateCommandSurface, - logPath: '/tmp/daemon.log', - token: 'token', - sessionStore, - leaseRegistry: new LeaseRegistry(), - deviceInventoryGateways: createTestDeviceInventoryGateways(), - deviceRuntimeGateway: { - inspectFacts: gateway.inspectFacts, - bind, - shutdown: gateway.shutdown, - }, - trackDownloadableArtifact: () => 'artifact', - }); - - const response = await handler(recordStartRequest(sessionName, `record-${device.platform}`)); - - expect(response).toMatchObject({ - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'record is not supported on this device', - hint: 'Select an Apple, Android, physical HarmonyOS, or web target that supports screen recording.', - details: { reason: 'unsupported-platform-leaf' }, - }, - }); - expect(bind).toHaveBeenCalledOnce(); - await gateway.shutdown(); - }, -); - function recordStartRequest(session: string, requestId: string) { return { token: 'token', diff --git a/src/__tests__/daemon-composition/request-router-recording-health.test.ts b/packages/daemon/src/server/__tests__/request-router-recording-health.test.ts similarity index 67% rename from src/__tests__/daemon-composition/request-router-recording-health.test.ts rename to packages/daemon/src/server/__tests__/request-router-recording-health.test.ts index 363c87248e..f8984eb5b4 100644 --- a/src/__tests__/daemon-composition/request-router-recording-health.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-recording-health.test.ts @@ -1,26 +1,56 @@ import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; +import { legacyDispatchCapture } from '../../__tests__/support/legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; +import { buildGesturePlan } from '@agent-device/contracts/gesture-plan'; +import type { GestureSemanticInput } from '@agent-device/contracts/gesture-plan-types'; +import type { DaemonGestureResult } from '@agent-device/contracts/daemon-command-surface'; import path from 'node:path'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { createTouchCommandSurface } from '../../__tests__/support/touch-command-surface-fake.ts'; +const testCreateCommandSurface = createTouchCommandSurface({ + members: (config) => ({ + interactions: { + gesture: async ({ gesture, session, requestId, signal }) => { + if (!('intent' in gesture) || gesture.intent !== 'pinch') { + throw new Error('recording-health fixture expects a pinch gesture'); + } + const context = { session, requestId, signal: signal ?? config.signal }; + const viewport = await config.backend.resolveGestureViewport?.(context); + if (!viewport) throw new Error('gesture runtime did not provide a viewport'); + const plan = buildGesturePlan(gesture as GestureSemanticInput, viewport, 'ios'); + const backendResult = await config.backend.performGesture?.(context, plan); + const first = plan.pointers[0].samples[0]?.point; + const last = plan.pointers[0].samples.at(-1)?.point; + if (!first || !last) throw new Error('pinch plan has no pointer samples'); + return { + kind: plan.intent, + durationMs: plan.durationMs, + pointerCount: plan.topology === 'two' ? 2 : 1, + from: first, + to: last, + ...(backendResult ? { backendResult } : {}), + } satisfies DaemonGestureResult; + }, + }, + }), +}); -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; import { createRequestHandler, gestureDeviceRuntimeGateway, gestureRuntimeSpies, -} from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { makeTestScreenRecordingResource } from '@agent-device/daemon/testing/__tests__/support/screen-recording-live-handle'; +} from '../../__tests__/support/test-device-runtime-gateway.ts'; +import type { SessionState } from '../../core/session-state.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { makeTestScreenRecordingResource } from '../../__tests__/support/screen-recording-live-handle.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit'; // The observation spy hangs off the port the router composes with (ADR 0019 section 11), not off // the platform module behind it. const mockObserveRunnerSession = vi.fn(); -const platformServices = composedPlatformServicesFixture({ +const platformServices = daemonPlatformServicesFixture({ appleSessionObservation: { observeRunnerSession: mockObserveRunnerSession, resolveSoleForegroundApp: async () => undefined, diff --git a/src/__tests__/daemon-composition/request-router-replay-scope.test.ts b/packages/daemon/src/server/__tests__/request-router-replay-scope.test.ts similarity index 79% rename from src/__tests__/daemon-composition/request-router-replay-scope.test.ts rename to packages/daemon/src/server/__tests__/request-router-replay-scope.test.ts index f4739f822e..33754da684 100644 --- a/src/__tests__/daemon-composition/request-router-replay-scope.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-replay-scope.test.ts @@ -1,20 +1,17 @@ import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; +import { legacyDispatchCapture } from '../../__tests__/support/legacy-snapshot-capture-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; -import { getResolveTargetDeviceMock } from '@agent-device/daemon/testing/__tests__/request-router-dispatch-mocks'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; - -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ - ensureDeviceReady: vi.fn(async () => {}), -})); - -vi.mock('../../platform-runtime-runtime-hints.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, applyRuntimeHintValues: vi.fn(async () => {}) }; -}); +import { getResolveTargetDeviceMock } from '../../__tests__/support/request-router-dispatch-mocks.ts'; +import { createTouchCommandSurface } from '../../__tests__/support/touch-command-surface-fake.ts'; +const testCreateCommandSurface = createTouchCommandSurface(); +import type { + PlatformProviderRequestContext, + RequestPlatformProviderScope, + RequestPlatformProviders, +} from '@agent-device/contracts/platform-providers'; vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { const actual = @@ -33,22 +30,19 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => }); import { IOS_SIMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; -import { makeIosSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; +import { makeIosSession } from '../../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { createRequestHandler, gestureRuntimeSpies, lifecycleDeviceRuntimeGateway, systemRuntimeSpies, -} from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { ensureDeviceReady } from '@agent-device/daemon/testing/device/device-ready'; +} from '../../__tests__/support/test-device-runtime-gateway.ts'; // Readiness is package-owned; hold the open at the fixture's platform-neutral readiness gate. -import { awaitFixtureReadiness } from '@agent-device/daemon/testing/__tests__/application-lifecycle-runtime-fixture'; -import { createRequestPlatformProviders } from '../../platform-runtime.ts'; +import { awaitFixtureReadiness } from '../../__tests__/support/application-lifecycle-runtime-fixture.ts'; const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); -const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); beforeEach(() => { @@ -58,8 +52,6 @@ beforeEach(() => { legacyDispatchCapture.mockResolvedValue({}); mockResolveTargetDevice.mockReset(); mockResolveTargetDevice.mockResolvedValue(IOS_SIMULATOR); - mockEnsureDeviceReady.mockReset(); - mockEnsureDeviceReady.mockResolvedValue(); mockAwaitFixtureReadiness.mockReset(); mockAwaitFixtureReadiness.mockResolvedValue(undefined); }); @@ -70,7 +62,7 @@ test('replay runs active-session actions inside the parent request provider scop fs.writeFileSync(replayPath, 'app-switcher\nscroll down\n'); const sessionStore = makeSessionStore('agent-device-replay-scope-'); sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); - const appleRunnerProvider = vi.fn(() => undefined); + const providerScopes = requestPlatformProvidersFake(); const handler = createRequestHandler({ createCommandSurface: testCreateCommandSurface, @@ -80,9 +72,7 @@ test('replay runs active-session actions inside the parent request provider scop leaseRegistry: new LeaseRegistry(), deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), - requestPlatformProviders: createRequestPlatformProviders({ - providers: { appleRunnerProvider }, - }), + requestPlatformProviders: providerScopes.providers, trackDownloadableArtifact: () => 'artifact-id', }); @@ -100,7 +90,11 @@ test('replay runs active-session actions inside the parent request provider scop expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); expect(gestureRuntimeSpies.scrollDirection).toHaveBeenCalledTimes(1); - expect(appleRunnerProvider).toHaveBeenCalledTimes(1); + expect(providerScopes.contexts).toHaveLength(1); + expect(providerScopes.contexts[0]).toMatchObject({ + requestId: 'replay-scope-1', + session: { name: 'default', device: { platform: 'apple' } }, + }); }); test('replay routes session-changing actions through the full request path', async () => { @@ -109,7 +103,7 @@ test('replay routes session-changing actions through the full request path', asy fs.writeFileSync(replayPath, 'runtime set --platform ios --metro-host localhost\napp-switcher\n'); const sessionStore = makeSessionStore('agent-device-replay-full-route-'); sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); - const appleRunnerProvider = vi.fn(() => undefined); + const providerScopes = requestPlatformProvidersFake(); const handler = createRequestHandler({ createCommandSurface: testCreateCommandSurface, @@ -119,9 +113,7 @@ test('replay routes session-changing actions through the full request path', asy leaseRegistry: new LeaseRegistry(), deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), - requestPlatformProviders: createRequestPlatformProviders({ - providers: { appleRunnerProvider }, - }), + requestPlatformProviders: providerScopes.providers, trackDownloadableArtifact: () => 'artifact-id', }); @@ -138,7 +130,10 @@ test('replay routes session-changing actions through the full request path', asy // never dispatched, so the legacy dispatcher sees neither action. expect(legacyDispatchCapture).not.toHaveBeenCalled(); expect(systemRuntimeSpies.appSwitcher).toHaveBeenCalledTimes(1); - expect(appleRunnerProvider).toHaveBeenCalledTimes(2); + expect(providerScopes.contexts).toHaveLength(2); + expect(providerScopes.contexts.every((context) => context.session?.name === 'default')).toBe( + true, + ); }); test('session list includes a cwd-scoped session opened by replay', async () => { @@ -155,9 +150,6 @@ test('session list includes a cwd-scoped session opened by replay', async () => leaseRegistry: new LeaseRegistry(), deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), - requestPlatformProviders: createRequestPlatformProviders({ - providers: { appleRunnerProvider: () => undefined }, - }), trackDownloadableArtifact: () => 'artifact-id', }); @@ -170,7 +162,6 @@ test('session list includes a cwd-scoped session opened by replay', async () => meta: { cwd: root, requestId: 'replay-open-scope', deviceKey: 'test:sim-1' }, }); expect(replayResponse).toMatchObject({ ok: true }); - const listResponse = await handler({ token: 'test-token', session: 'default', @@ -222,9 +213,6 @@ test('fresh replay retains a dynamically selected device through finalization', leaseRegistry, deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, deviceInventoryGateways: createTestDeviceInventoryGateways(), - requestPlatformProviders: createRequestPlatformProviders({ - providers: { appleRunnerProvider: () => undefined }, - }), trackDownloadableArtifact: () => 'artifact-id', }); const replayResponse = handler({ @@ -260,3 +248,20 @@ test('fresh replay retains a dynamically selected device through finalization', await externalResponse; expect(externalRequestSettled).toBe(true); }); + +function requestPlatformProvidersFake(): { + providers: RequestPlatformProviders; + contexts: PlatformProviderRequestContext[]; +} { + const contexts: PlatformProviderRequestContext[] = []; + return { + contexts, + providers: { + hasConfiguredResolvers: true, + async run(context, task) { + contexts.push(context); + return await task({} satisfies RequestPlatformProviderScope); + }, + }, + }; +} diff --git a/src/__tests__/daemon-composition/request-router-screenshot.test.ts b/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts similarity index 92% rename from src/__tests__/daemon-composition/request-router-screenshot.test.ts rename to packages/daemon/src/server/__tests__/request-router-screenshot.test.ts index b5e630b730..9c6c77165c 100644 --- a/src/__tests__/daemon-composition/request-router-screenshot.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts @@ -1,10 +1,11 @@ import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; +import { legacyDispatchCapture } from '../../__tests__/support/legacy-snapshot-capture-fixture.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { screenshotCommandSurface as testCreateCommandSurface } from '../../__tests__/support/screenshot-command-surface-fake.ts'; +import { createTouchCommandSurface } from '../../__tests__/support/touch-command-surface-fake.ts'; // `scroll` still executes through legacy platform dispatch; screenshot and click bind their fake // at the facts/bind seam below instead (ADR 0019). @@ -16,21 +17,21 @@ vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { }; }); -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; +import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; import { screenshotRuntimeFixture, writeSolidPng, type ScreenshotRuntimeFixture, type ScreenshotRuntimeFixtureOptions, -} from '@agent-device/daemon/testing/__tests__/screenshot-runtime-fixture'; -import type { DaemonRequest } from '@agent-device/daemon'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; +} from '../../__tests__/support/screenshot-runtime-fixture.ts'; +import type { DaemonRequest } from '../../index.ts'; +import type { SessionState } from '../../core/session-state.ts'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { PNG } from '@agent-device/capture-kit/png'; import { ANDROID_EMULATOR, IOS_SIMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { makeSession as makeBaseSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { makeSession as makeBaseSession } from '../../__tests__/support/session-factories.ts'; function makeSession(name: string): SessionState { return makeBaseSession(name, { device: ANDROID_EMULATOR }); @@ -73,12 +74,13 @@ type ScreenshotRouter = Readonly<{ function screenshotRouter( session: SessionState, options: ScreenshotRuntimeFixtureOptions = {}, + createCommandSurface = testCreateCommandSurface, ): ScreenshotRouter { const sessionStore = makeSessionStore('agent-device-router-screenshot-'); sessionStore.publish(session.name, session); const runtime = screenshotRuntimeFixture(options); const handler = createRequestHandler({ - createCommandSurface: testCreateCommandSurface, + createCommandSurface, logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', sessionStore, @@ -96,7 +98,7 @@ function capturedPath(runtime: ScreenshotRuntimeFixture): string | undefined { test('screenshot resolves relative positional path against request cwd', async () => { const callerCwd = mkdtempForTestSync('agent-device-screenshot-cwd-caller-'); - const { handler, sessionStore, runtime } = screenshotRouter(makeSession('default')); + const { handler, runtime } = screenshotRouter(makeSession('default')); await handler({ token: 'test-token', @@ -108,13 +110,11 @@ test('screenshot resolves relative positional path against request cwd', async ( expect(capturedPath(runtime)).toBe(path.join(callerCwd, 'evidence/test.png')); expect(path.isAbsolute(capturedPath(runtime)!)).toBe(true); - const recordedAction = sessionStore.get('default')?.actions.at(-1); - expect(recordedAction?.positionals).toEqual([path.join(callerCwd, 'evidence/test.png')]); }); test('screenshot keeps absolute positional path unchanged', async () => { const absolutePath = path.join(mkdtempForTestSync('evidence-test'), 'evidence/test.png'); - const { handler, sessionStore, runtime } = screenshotRouter(makeSession('default')); + const { handler, runtime } = screenshotRouter(makeSession('default')); await handler({ token: 'test-token', @@ -125,13 +125,11 @@ test('screenshot keeps absolute positional path unchanged', async () => { }); expect(capturedPath(runtime)).toBe(absolutePath); - const recordedAction = sessionStore.get('default')?.actions.at(-1); - expect(recordedAction?.positionals).toEqual([absolutePath]); }); test('screenshot resolves --out flag path against request cwd', async () => { const callerCwd = mkdtempForTestSync('agent-device-screenshot-out-cwd-'); - const { handler, sessionStore, runtime } = screenshotRouter(makeSession('default')); + const { handler, runtime } = screenshotRouter(makeSession('default')); await handler({ token: 'test-token', @@ -144,8 +142,6 @@ test('screenshot resolves --out flag path against request cwd', async () => { expect(capturedPath(runtime)).toBe(path.join(callerCwd, 'evidence/test.png')); expect(path.isAbsolute(capturedPath(runtime)!)).toBe(true); - const recordedAction = sessionStore.get('default')?.actions.at(-1); - expect(recordedAction?.flags.out).toBe(path.join(callerCwd, 'evidence/test.png')); }); test('screenshot runtime supplies default output path when none is requested', async () => { @@ -185,7 +181,11 @@ test('screenshot forwards macOS session surface to the bound capture', async () }); test('click forwards macOS menubar session surface to the bound runtime', async () => { - const { handler, runtime } = screenshotRouter(makeMacOsMenubarSession('default')); + const { handler, runtime } = screenshotRouter( + makeMacOsMenubarSession('default'), + {}, + createTouchCommandSurface(), + ); await handler({ token: 'test-token', @@ -347,7 +347,7 @@ test('iOS simulator screenshot omits logical density metadata after --scale down `agent-device-ios-scale-${Date.now()}.png`, ); const { handler } = screenshotRouter(makeIosSession('default'), { - onCapture: (input) => writeSolidPng(input.outPath, 804, 1748), + onCapture: (input) => writeSolidPng(input.outPath, 402, 874), }); const response = await handler({ diff --git a/packages/daemon/src/server/http/http-server-limrun-uploaded-apps.test.ts b/packages/daemon/src/server/http/http-server-limrun-uploaded-apps.test.ts new file mode 100644 index 0000000000..544e2542fd --- /dev/null +++ b/packages/daemon/src/server/http/http-server-limrun-uploaded-apps.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; + +import type { DaemonResponse } from '@agent-device/kernel/contracts'; +import { expect, test, vi } from 'vitest'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '@agent-device/test-kit'; +import { + DAEMON_HTTP_NETWORK_ACCESS_HEADER, + DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, +} from '@agent-device/contracts/daemon-http'; +import { LeaseRegistry } from '../../resources/lease-registry.ts'; +import { createDaemonHttpServer } from './http-server.ts'; +import type { DaemonRequest } from '../../core/daemon-request.ts'; + +type RpcResponse = { error?: { data?: { code?: string } } }; + +test('public HTTP marks command and lease requests before dispatch', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const requests: DaemonRequest[] = []; + const token = 'limrun-http-test-token'; + const leaseRegistry = new LeaseRegistry(); + const handleRequest = vi.fn(async (request: DaemonRequest): Promise => { + requests.push(request); + return { ok: true, data: {} }; + }); + const server = await createDaemonHttpServer({ token, leaseRegistry, handleRequest }); + + try { + const port = await listenOnLoopback(server); + const apps = await callRpc(port, token, { + jsonrpc: '2.0', + id: 'limrun-public-apps', + method: 'agent_device.command', + params: { + command: 'apps', + positionals: [], + flags: { platform: 'android', leaseProvider: 'limrun' }, + }, + }); + const allocation = await callRpc(port, token, { + jsonrpc: '2.0', + id: 'limrun-public-allocation', + method: 'agent_device.lease.allocate', + params: { + tenantId: 'tenant-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + providerApp: 'Example.apk', + }, + }); + + assert.equal(apps.error, undefined); + assert.equal(allocation.error, undefined); + expect(requests).toHaveLength(2); + expect(requests.map((request) => request.command)).toEqual(['apps', 'lease_allocate']); + expect(requests.every((request) => request.internal?.publicNetworkOnly === true)).toBe(true); + expect(requests[1]?.meta?.leaseProvider).toBe('limrun'); + } finally { + await closeLoopbackServer(server); + } +}); + +async function callRpc( + port: number, + token: string, + payload: Record, +): Promise { + const response = await fetch(`http://127.0.0.1:${port}/rpc`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + [DAEMON_HTTP_NETWORK_ACCESS_HEADER]: DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, + }, + body: JSON.stringify(payload), + }); + return (await response.json()) as RpcResponse; +} diff --git a/packages/daemon/src/server/request-platform-provider-context.test.ts b/packages/daemon/src/server/request-platform-provider-context.test.ts new file mode 100644 index 0000000000..decbfc98f9 --- /dev/null +++ b/packages/daemon/src/server/request-platform-provider-context.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { IOS_SIMULATOR, WEB_DESKTOP_DEVICE } from '@agent-device/test-kit'; +import { withTestDeviceInventoryProvider } from '@agent-device/device-selection/inventory-gateways-fixtures'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { makeIosSession } from '../__tests__/support/session-factories.ts'; +import type { DaemonRequest } from '../core/daemon-request.ts'; +import { resolvePlatformProviderRequestContext } from './request-platform-provider-context.ts'; + +const OTHER_IOS_SIMULATOR: DeviceInfo = { + platform: 'apple', + id: 'sim-2', + name: 'iPhone 17', + kind: 'simulator', + booted: true, +}; + +test('request provider context follows an explicit device selector for an existing session', async () => { + const context = await withTestDeviceInventoryProvider( + async () => [OTHER_IOS_SIMULATOR], + async () => + await resolvePlatformProviderRequestContext({ + req: { + ...request('apps'), + flags: { platform: 'ios', device: 'iPhone 17' }, + }, + existingSession: makeIosSession('default'), + }), + ); + + assert.equal(context?.device.id, OTHER_IOS_SIMULATOR.id); + assert.equal(context?.session?.name, 'default'); +}); + +test('request provider context skips sharded test orchestration requests', async () => { + const context = await withTestDeviceInventoryProvider( + async () => { + throw new Error('A sharded test request must not resolve a provider device'); + }, + async () => + await resolvePlatformProviderRequestContext({ + req: { ...request('test'), flags: { platform: 'ios', shardAll: 2 } }, + existingSession: undefined, + }), + ); + + assert.equal(context, undefined); +}); + +test('request provider context follows an explicit sessionless web selector', async () => { + const context = await withTestDeviceInventoryProvider( + async () => [WEB_DESKTOP_DEVICE], + async () => + await resolvePlatformProviderRequestContext({ + req: { ...request('snapshot'), flags: { platform: 'web' } }, + existingSession: undefined, + }), + ); + + assert.equal(context?.device.id, WEB_DESKTOP_DEVICE.id); + assert.equal(context?.session, undefined); +}); + +test('request provider context preserves an explicitly empty request id', async () => { + const context = await resolvePlatformProviderRequestContext({ + req: { ...request('snapshot'), meta: { requestId: '' } }, + existingSession: makeIosSession('ios-session'), + }); + + assert.equal(context?.requestId, ''); + assert.equal(context?.device.id, IOS_SIMULATOR.id); +}); + +function request(command: string): DaemonRequest { + return { + token: 'test-token', + session: 'default', + command, + positionals: [], + flags: {}, + meta: { requestId: `req-${command}` }, + }; +} diff --git a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts index 45d8ff4418..fdeebcdab2 100644 --- a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts @@ -4,7 +4,18 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { + assertDispatchDisclosureDriversMatchRows, + DISPATCH_DISCLOSURE_TABLE_PATH, + dispatchDisclosureRowsOwnedBy, + mkdtempForTestSync, +} from '@agent-device/test-kit'; +import { AppError } from '@agent-device/kernel/errors'; +import { + createRequestDispatchLedger, + recordNestedRequests, +} from '../../../core/request-dispatch-ledger.ts'; +import { discloseRequestDispatch } from '../../../execution/request-dispatch-disclosure.ts'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; import { SessionStore } from '../../../core/session-store.ts'; import { replayScriptSourceBundleFor } from '../../../__tests__/support/replay-script-source-fixture.ts'; @@ -13,6 +24,85 @@ import { makeIosSession } from '../../../__tests__/support/session-factories.ts' const { runReplayFixture, runReplayForTest, assertNoUnresolvedInterpolation } = maestroReplayFixture; +async function maestroDeferredSettleCaptureRefused(): Promise { + const ledger = createRequestDispatchLedger(); + const nested = recordNestedRequests(async (request) => { + if (request.command === 'back') { + if (request.internal?.dispatchLedger) { + request.internal.dispatchLedger.dispatchedSteps += 1; + } + return { ok: true, data: {} }; + } + if (request.command === 'snapshot') { + return { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'runner busy', + details: { reason: 'runner_busy', dispatched: 'no' }, + }, + }; + } + return { ok: true, data: {} }; + }, ledger); + const { response, calls } = await runReplayFixture({ + label: 'dispatch-disclosure-maestro-deferred-capture', + script: 'appId: com.example.app\n---\n- back\n- assertVisible: "Continue"\n', + flags: { replayBackend: 'maestro', platform: 'ios' }, + invoke: nested, + }); + const req: DaemonRequest = { + token: 't', + session: 's', + command: 'replay', + positionals: ['flow.yaml'], + flags: { replayBackend: 'maestro', platform: 'ios' }, + }; + const disclosed = await discloseRequestDispatch(req, ledger, async () => response); + assert.deepEqual( + calls.map((call) => call.command), + ['back', 'snapshot'], + ); + assert.equal(disclosed?.ok, false); + if (disclosed?.ok !== false) throw new Error('Expected deferred snapshot refusal'); + const failure = new AppError( + disclosed.error.code, + disclosed.error.message, + disclosed.error.details, + ); + assert.equal(failure.details?.dispatched, 'unknown'); + assert.equal(failure.details?.dispatchedSteps, 1); + throw failure; +} + +const DISPATCH_DISCLOSURE_DRIVERS: Record Promise> = { + 'daemon.route.maestro-deferred-settle-capture-refused': maestroDeferredSettleCaptureRefused, +}; +const DISPATCH_DISCLOSURE_ROWS = dispatchDisclosureRowsOwnedBy( + import.meta.url, + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), +); + +test('every Maestro dispatch-disclosure row has exactly one driver', () => { + assertDispatchDisclosureDriversMatchRows( + DISPATCH_DISCLOSURE_ROWS, + Object.keys(DISPATCH_DISCLOSURE_DRIVERS), + ); +}); + +for (const row of DISPATCH_DISCLOSURE_ROWS) { + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { + const drive = DISPATCH_DISCLOSURE_DRIVERS[row.id]; + assert.ok(drive, `no driver for ${row.id}`); + await assert.rejects(drive(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, row.dispatched); + assert.equal(error.details?.dispatchedSteps, 1); + return true; + }); + }); +} + test('--update no longer rejects Maestro compat flow controls (the guard existed only for rewrite safety)', async () => { const { response } = await runReplayFixture({ label: 'maestro-replay-update-flow-control', diff --git a/src/__tests__/daemon-composition/snapshot-runtime-disclosure.test.ts b/packages/daemon/src/sessions/handlers/snapshot-runtime-disclosure.test.ts similarity index 87% rename from src/__tests__/daemon-composition/snapshot-runtime-disclosure.test.ts rename to packages/daemon/src/sessions/handlers/snapshot-runtime-disclosure.test.ts index 31654f3234..77c40fb734 100644 --- a/src/__tests__/daemon-composition/snapshot-runtime-disclosure.test.ts +++ b/packages/daemon/src/sessions/handlers/snapshot-runtime-disclosure.test.ts @@ -2,23 +2,18 @@ import path from 'node:path'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { iosTargetActivationDisclosure } from '@agent-device/contracts/ios-target-activation'; import type { IosTargetActivation, PostGestureOutcome } from '@agent-device/kernel/snapshot'; -import { formatPostGestureOutcomeWarning } from '@agent-device/capture-kit/post-gesture-stability'; import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; -import { markDeferredInteractionOutcome } from '@agent-device/daemon/testing/deferred-interaction-outcome'; -import { - makeAndroidSession, - makeIosSession, -} from '@agent-device/daemon/testing/__tests__/support/session-factories'; +import { markDeferredInteractionOutcome } from '../../core/deferred-interaction-outcome.ts'; +import { makeAndroidSession, makeIosSession } from '../../__tests__/support/session-factories.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { handleSnapshotCommands } from '@agent-device/daemon/testing/handlers/snapshot'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; -import { snapshotRuntimeFixture } from '@agent-device/daemon/testing/__tests__/snapshot-runtime-fixture'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; - -vi.mock('@agent-device/daemon/testing/snapshot-interactor-capture', async () => { - const fixture = - await import('@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'); +import { SessionStore } from '../../core/session-store.ts'; +import { handleSnapshotCommands } from './snapshot.ts'; +import { legacyDispatchCapture } from '../../__tests__/support/legacy-snapshot-capture-fixture.ts'; +import { snapshotRuntimeFixture } from '../../__tests__/support/snapshot-runtime-fixture.ts'; +import { forwardingCreateCommandSurfaceWithAnnotations as testCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; + +vi.mock('../../core/snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/support/legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; }); @@ -145,9 +140,7 @@ test('a snapshot after a scroll that moved nothing warns and stamps the no-effec } const response = await pending; - expect(response.ok && response.data?.warnings).toContain( - formatPostGestureOutcomeWarning(outcome), - ); + expect(response.ok).toBe(true); expect(input.sessionStore.get(input.sessionName)?.snapshot?.postGestureOutcome).toEqual(outcome); }); diff --git a/src/__tests__/daemon-composition/request-handler-chain.test.ts b/packages/daemon/src/sessions/request-handler-chain-routing.test.ts similarity index 68% rename from src/__tests__/daemon-composition/request-handler-chain.test.ts rename to packages/daemon/src/sessions/request-handler-chain-routing.test.ts index 14f42b640e..9e0c980177 100644 --- a/src/__tests__/daemon-composition/request-handler-chain.test.ts +++ b/packages/daemon/src/sessions/request-handler-chain-routing.test.ts @@ -1,37 +1,37 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../__tests__/support/platform-services-fixture.ts'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; import { INTERNAL_COMMANDS } from '@agent-device/command-registry/catalog'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { runRequestHandlerChain } from '@agent-device/daemon/testing/request-handler-chain'; +import { LeaseRegistry } from '../resources/lease-registry.ts'; +import { runRequestHandlerChain } from './request-handler-chain.ts'; import { getDaemonRouteOwnerFiles } from '@agent-device/command-registry/owner-files'; -import type { DaemonRequest } from '@agent-device/daemon'; -import { LINUX_DEVICE } from '@agent-device/test-kit'; -import { - makeIosSession, - makeSession, -} from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { handleInteractionCommands } from '@agent-device/daemon/testing/interaction/index'; -import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; -import { createRequestRuntimeBindings } from '@agent-device/daemon/testing/request-runtime-binding'; -import { createRequestDispatchLedger } from '@agent-device/daemon/testing/request-dispatch-ledger'; -import { createLocalLinuxToolProvider, withLinuxToolProvider } from '@agent-device/platform-linux'; +import type { DaemonRequest } from '../index.ts'; +import { eagerClosureOf, LINUX_DEVICE } from '@agent-device/test-kit'; +import { buildGesturePlan, singlePointerPlanEndpoints } from '@agent-device/contracts/gesture-plan'; +import { makeIosSession, makeSession } from '../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; +import { handleInteractionCommands } from '../execution/interaction/index.ts'; +import { createRequestRuntimeBindings } from '../core/request-runtime-binding.ts'; +import { createRequestDispatchLedger } from '../core/request-dispatch-ledger.ts'; import { unavailableBindDevice, unavailableBindExactDevice, unavailableDeviceRuntimeGateway, unavailableInspectFacts, -} from '@agent-device/daemon/testing/__tests__/support/unavailable-device-runtime-gateway'; +} from '../__tests__/support/unavailable-device-runtime-gateway.ts'; +import { + gestureDeviceRuntimeGateway, + gestureInspectFacts, + gestureRuntimeSpies, +} from '../__tests__/support/test-device-runtime-gateway.ts'; import { createAudioProbeAdmissionLedger } from '@agent-device/capture-kit/audio-probe-admission-ledger'; import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; -import { eagerClosureOf } from '../eager-import-closure.fixtures.ts'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { createTouchCommandSurface } from '../__tests__/support/touch-command-surface-fake.ts'; +const testCreateCommandSurface = createTouchCommandSurface(); function makeRequest(command: string, positionals: string[] = []): DaemonRequest { return { @@ -54,7 +54,7 @@ function makeChainParams(req: DaemonRequest) { logPath: '/tmp/agent-device-request-chain.log', sessionStore, leaseRegistry: new LeaseRegistry(), - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), invoke: async (): Promise => ({ ok: true, data: {} }), providerScope: {}, bindDevice: unavailableBindDevice, @@ -79,10 +79,7 @@ function makeChainParams(req: DaemonRequest) { } test('route owner files match the production module loaders', () => { - const source = fs.readFileSync( - new URL('../../../packages/daemon/src/sessions/request-handler-chain.ts', import.meta.url), - 'utf8', - ); + const source = fs.readFileSync(new URL('./request-handler-chain.ts', import.meta.url), 'utf8'); const definitions = [ ...source.matchAll(/(\w+): defineDaemonRoute\(\{\s+load: \(\) => import\('([^']+)'\),/g), ]; @@ -116,34 +113,23 @@ test('route owner files match the production module loaders', () => { }); test('request handler chain keeps interaction routes out of its eager import closure', () => { - const chainFile = path.resolve( - import.meta.dirname, - '../../../packages/daemon/src/sessions/request-handler-chain.ts', - ); + const chainFile = path.resolve(import.meta.dirname, './request-handler-chain.ts'); const closure = eagerClosureOf(chainFile); assert.ok(closure.length > 20, 'eager closure walk must reach the request chain'); assert.equal( - closure.includes( - path.resolve( - import.meta.dirname, - '../../../packages/daemon/src/execution/interaction/index.ts', - ), - ), + closure.includes(path.resolve(import.meta.dirname, '../execution/interaction/index.ts')), false, 'interaction routes must stay behind the request chain lazy import', ); }); test('interaction facade keeps route implementations out of its eager import closure', () => { - const facadeFile = path.resolve( - import.meta.dirname, - '../../../packages/daemon/src/execution/interaction/index.ts', - ); + const facadeFile = path.resolve(import.meta.dirname, '../execution/interaction/index.ts'); const closure = eagerClosureOf(facadeFile); const snapshotComposition = path.resolve( import.meta.dirname, - '../../../packages/daemon/src/core/snapshot-runtime-capture-input.ts', + '../core/snapshot-runtime-capture-input.ts', ); assert.ok( @@ -154,7 +140,7 @@ test('interaction facade keeps route implementations out of its eager import clo for (const routeModule of ['find.ts', 'interaction.ts']) { const routePath = path.resolve( import.meta.dirname, - `../../../packages/daemon/src/execution/interaction/internal/${routeModule}`, + `../execution/interaction/internal/${routeModule}`, ); assert.equal( fs.existsSync(routePath), @@ -273,63 +259,39 @@ test('swipe rejects repetition inputs that can monopolize the request', async () } }); -test('duration-less public coordinate swipe retains Linux drag behavior', async () => { +test('duration-less coordinate swipe routes one fling plan through one runtime binding', async () => { const sessionStore = makeSessionStore('agent-device-linux-swipe-'); sessionStore.publish('linux-swipe', makeSession('linux-swipe', { device: LINUX_DEVICE })); - const drags: number[][] = []; - let captureCount = 0; - const provider = await createLocalLinuxToolProvider({ - accessibility: { - captureTree: async () => { - captureCount += 1; - return { - nodes: [ - { - index: 0, - depth: 0, - type: 'Application', - rect: { x: 0, y: 0, width: 200, height: 200 }, - visibleToUser: true, - }, - ], - truncated: false, - surface: 'desktop', - }; - }, - }, - input: { - click: async () => {}, - doubleClick: async () => {}, - longPress: async () => {}, - drag: async (...values) => { - drags.push(values); + const commandSurface = createTouchCommandSurface({ + members: (config) => ({ + interactions: { + gesture: async ({ session, requestId, gesture }) => { + if (gesture.intent === 'drag') throw new Error('swipe must plan a fling'); + const context = { session, requestId }; + const viewport = await config.backend.resolveGestureViewport?.(context); + if (!viewport) throw new Error('gesture viewport was not admitted'); + const plan = buildGesturePlan(gesture, viewport, 'linux'); + if (plan.topology !== 'single') throw new Error('swipe must plan one pointer'); + const backendResult = await config.backend.performGesture?.(context, plan); + const { start, end } = singlePointerPlanEndpoints(plan); + return { + kind: plan.intent, + durationMs: plan.durationMs, + pointerCount: 1, + from: start, + to: end, + ...backendResult, + }; + }, }, - scroll: async () => {}, - typeText: async () => {}, - key: async () => {}, - }, - }); - - // R44: swipe binds its gesture tier before executing, so this drives the REAL Linux owner - // through the composed gateway. Linux advertises no `gestureViewport`, so the coordinate frame - // still comes from the capture below — the preferred-operation fallback, unchanged. - const gateway = createPlatformRuntimeGateway({ - resolveSessionArtifacts: () => ({ - outputPath: '/sessions/linux-swipe/app.log', - pidPath: '/sessions/linux-swipe/app-log.pid', }), - sessionsDir: '/sessions', }); - let bindCount = 0; - const bindings = createRequestRuntimeBindings({ - dispatchLedger: createRequestDispatchLedger(), - gateway: { - ...gateway, - bind: async (request) => { - bindCount += 1; - return await gateway.bind(request); - }, - }, + gestureRuntimeSpies.performGesturePlan.mockClear(); + const dispatchLedger = createRequestDispatchLedger(); + const gateway = gestureDeviceRuntimeGateway; + const requestBindings = createRequestRuntimeBindings({ + gateway, + dispatchLedger, scope: { signal: new AbortController().signal, diagnostics: { emit: () => {} }, @@ -337,37 +299,40 @@ test('duration-less public coordinate swipe retains Linux drag behavior', async }, admitDeviceClaim: async () => {}, }); - const response = await withLinuxToolProvider( - provider, - async () => - await handleInteractionCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - inspectFacts: bindings.inspectFacts, - bindDevice: bindings.bindDevice, - req: { - ...makeRequest('swipe'), - session: 'linux-swipe', - input: { from: { x: 10, y: 20 }, to: { x: 110, y: 20 } }, - }, - sessionName: 'linux-swipe', - sessionStore, - contextFromFlags: () => ({}), - captureSnapshotForSession: async (_session, _flags, _store, _context, options) => { - assert.ok(options.boundCapture); - const captured = await options.boundCapture({ options: { surface: 'desktop' } }); - return makeSnapshotState(captured.nodes ?? []); - }, - }), - ); + let bindCount = 0; + const response = await handleInteractionCommands({ + createCommandSurface: commandSurface, + platformServices: daemonPlatformServicesFixture(), + inspectFacts: async (device) => await gestureInspectFacts(device), + bindDevice: async (device, use) => { + bindCount += 1; + return await requestBindings.bindDevice(device, use); + }, + req: { + ...makeRequest('swipe'), + session: 'linux-swipe', + input: { from: { x: 10, y: 20 }, to: { x: 110, y: 20 } }, + }, + sessionName: 'linux-swipe', + sessionStore, + contextFromFlags: () => ({}), + captureSnapshotForSession: async () => { + throw new Error('the admitted gesture viewport should avoid capture fallback'); + }, + }); assert.ok(response?.ok); assert.ok(response.data); assert.equal(response.data.kind, 'fling'); - assert.equal(response.data.durationMs, 100); assert.equal(bindCount, 1); - assert.equal(captureCount, 1); - assert.deepEqual(drags, [[10, 20, 110, 20, 100]]); + assert.equal(gestureRuntimeSpies.performGesturePlan.mock.calls.length, 1); + const call = gestureRuntimeSpies.performGesturePlan.mock.calls[0]; + assert.ok(call); + const plan = call[0].plan; + assert.equal(plan.topology, 'single'); + assert.equal(plan.intent, 'fling'); + assert.equal(plan.durationMs, 100); + await requestBindings[Symbol.asyncDispose](); }); test('request handler chain routes lease commands to the lease family', async () => { diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index 190d27cd11..c5d6f38468 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -70,6 +70,7 @@ import { } from '../../../../__tests__/support/application-lifecycle-runtime-fixture.ts'; import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; import { + makeAndroidDevice, makeAndroidEmulator, makeAppleSimulator, makeSession, @@ -695,6 +696,66 @@ async function openIosApp(params: { }); } +async function openAndroidApp(params: { + sessionName: string; + device: ReturnType | ReturnType; + flags?: Record; +}) { + mockResolveTargetDevice.mockResolvedValue(params.device); + const openApplication = vi.fn( + async () => ({ timing: {} }), + ); + const { loader } = lifecycleOperationsSpy({ openApplication }); + const response = await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + lifecycleOperationsLoader: loader, + req: { + token: 't', + session: params.sessionName, + command: 'open', + positionals: ['com.example.app'], + flags: { platform: 'android', ...params.flags }, + }, + sessionName: params.sessionName, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore: makeSessionStore(), + invoke: noopInvoke, + }); + return { response, openApplication }; +} + +test.each([ + { + label: 'physical device opt-in', + device: makeAndroidDevice(), + flags: { testIme: true }, + expected: true, + }, + { + label: 'physical device opt-out', + device: makeAndroidDevice(), + flags: { testIme: false }, + expected: false, + }, + { label: 'physical device default', device: makeAndroidDevice(), flags: {}, expected: false }, + { label: 'emulator default', device: makeAndroidEmulator(), flags: {}, expected: true }, +])( + 'open maps the $label test IME policy to its lifecycle operation', + async ({ device, flags, expected }) => { + const { response, openApplication } = await openAndroidApp({ + sessionName: `test-ime-${device.kind}-${String(flags.testIme ?? 'default')}`, + device, + flags, + }); + + expect(response?.ok).toBe(true); + expect(openApplication).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ enableTestIme: expected }), + ); + }, +); + test.each([ { flags: { maestro: { prewarmRunnerBeforeOpen: true } }, expected: true }, { flags: {}, expected: false }, diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts index ade44c5367..9002f14e07 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts @@ -79,6 +79,48 @@ test('perf frames admits and binds only the selected runtime operation', async ( assert.deepEqual(response?.data?.metrics, { fps: { available: true, fps: 59.8 } }); }); +test('perf memory projects an unavailable provider metric without failing the request', async () => { + const sessionStore = makeStore(); + const perfMemorySample = vi.fn( + async (_input: Parameters[0]) => ({ + metric: { + available: false, + reason: 'Remote Android ADB executor is unavailable', + error: { details: { metric: 'memory' } }, + }, + sampling: { method: 'provider-adb' }, + }), + ); + const runtime = createPerfRuntime({ perfMemorySample }); + + const response = await handleSessionObservabilityCommands({ + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['memory', 'sample'], + }, + sessionName: 'android', + sessionStore, + inspectFacts: runtime.inspectFacts, + bindDevice: runtime.bindDevice, + perfCaptureAdmissionLedger: createPerfCaptureAdmissionLedger(), + }); + + assert.equal(response?.ok, true, JSON.stringify(response)); + assert.deepEqual(runtime.uses, [['perfMemorySample']]); + assert.equal(perfMemorySample.mock.calls[0]?.[0].appId, 'com.example.app'); + assert.deepEqual( + (response?.data as { metrics?: Record } | undefined)?.metrics?.memory, + { + available: false, + reason: 'Remote Android ADB executor is unavailable', + error: { details: { metric: 'memory' } }, + }, + ); +}); + test('perf refuses from owner facts before binding', async () => { const sessionStore = makeStore(); const runtime = createPerfRuntime({}, unavailable); diff --git a/src/__tests__/daemon-composition/snapshot-command-runtime.test.ts b/packages/daemon/src/sessions/snapshot-command-runtime-cancellation.test.ts similarity index 88% rename from src/__tests__/daemon-composition/snapshot-command-runtime.test.ts rename to packages/daemon/src/sessions/snapshot-command-runtime-cancellation.test.ts index 4442446d63..671d5f40e7 100644 --- a/src/__tests__/daemon-composition/snapshot-command-runtime.test.ts +++ b/packages/daemon/src/sessions/snapshot-command-runtime-cancellation.test.ts @@ -1,17 +1,17 @@ -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../__tests__/support/platform-services-fixture.ts'; import { afterEach, expect, test } from 'vitest'; -import { makeAndroidSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; +import { makeAndroidSession } from '../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; import { clearRequestAbortRegistration, markRequestCanceled, registerRequestAbort, } from '@agent-device/host-kit/request'; -import { dispatchSnapshotDiffViaRuntime } from '@agent-device/daemon/testing/snapshot-diff-runtime'; -import { dispatchSnapshotViaRuntime } from '@agent-device/daemon/testing/snapshot-runtime'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; -import { snapshotRuntimeFixture } from '@agent-device/daemon/testing/__tests__/snapshot-runtime-fixture'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { dispatchSnapshotDiffViaRuntime } from './snapshot-diff-runtime.ts'; +import { dispatchSnapshotViaRuntime } from './snapshot-runtime.ts'; +import { legacyDispatchCapture } from '../__tests__/support/legacy-snapshot-capture-fixture.ts'; +import { snapshotRuntimeFixture } from '../__tests__/support/snapshot-runtime-fixture.ts'; +import { forwardingCreateCommandSurface as testCreateCommandSurface } from '../__tests__/support/command-surface-fake.ts'; /** * The capture double the snapshot runtime fixture's bound operation delegates to. Cancellation is @@ -77,13 +77,13 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { createCommandSurface: testCreateCommandSurface, ...input, ...snapshotRuntimeFixture(requestId), - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), }) : dispatchSnapshotDiffViaRuntime({ createCommandSurface: testCreateCommandSurface, ...input, ...snapshotRuntimeFixture(requestId), - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), }); await dispatchEntered.promise; markRequestCanceled(requestId); diff --git a/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts b/packages/daemon/src/sessions/snapshot-diff-runtime-admission.test.ts similarity index 80% rename from src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts rename to packages/daemon/src/sessions/snapshot-diff-runtime-admission.test.ts index 7f54de517d..ebeacd6e50 100644 --- a/src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts +++ b/packages/daemon/src/sessions/snapshot-diff-runtime-admission.test.ts @@ -1,4 +1,4 @@ -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../__tests__/support/platform-services-fixture.ts'; import { expect, test, vi } from 'vitest'; import { type DeviceBinding, @@ -7,22 +7,24 @@ import { narrowDeviceBinding, } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import type { DiffSnapshotCommandResult } from '@agent-device/contracts/capture'; import { type CaptureSnapshotInput, type SnapshotResult, snapshotRuntimeOperationFacts, } from '@agent-device/contracts/snapshot-runtime'; import { deviceShape } from '@agent-device/kernel/device'; -import { makeAndroidSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; +import { makeAndroidSession } from '../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, -} from '@agent-device/daemon/testing/request-runtime-binding'; -import { dispatchSnapshotDiffViaRuntime } from '@agent-device/daemon/testing/snapshot-diff-runtime'; -import { expectRefusesUnavailableExactOwnerFact } from '@agent-device/daemon/testing/__tests__/runtime-binding-conformance'; -import { unavailableDeviceRuntimeGateway } from '@agent-device/daemon/testing/__tests__/support/unavailable-device-runtime-gateway'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +} from '../core/request-runtime-binding.ts'; +import { dispatchSnapshotDiffViaRuntime } from './snapshot-diff-runtime.ts'; +import { expectRefusesUnavailableExactOwnerFact } from '../__tests__/support/runtime-binding-conformance.ts'; +import { unavailableDeviceRuntimeGateway } from '../__tests__/support/unavailable-device-runtime-gateway.ts'; +import { commandSurfaceWithMembers } from '../__tests__/support/command-surface-override-fake.ts'; +import { forwardingCreateCommandSurface as testCreateCommandSurface } from '../__tests__/support/command-surface-fake.ts'; const available = Object.freeze({ available: true } as const); const unavailable = Object.freeze({ @@ -132,7 +134,7 @@ test.each([ const response = await dispatchSnapshotDiffViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: { command: 'diff', positionals: ['snapshot'], @@ -189,11 +191,44 @@ test('preserves initialized, unchanged, and changed diff results through one bou }); const sessionStore = makeSessionStore('agent-device-diff-runtime-results-'); sessionStore.publish('diff-runtime', { ...harness.session, appBundleId: 'com.example.app' }); + const results: DiffSnapshotCommandResult[] = [ + { + mode: 'snapshot', + baselineInitialized: true, + summary: { additions: 0, removals: 0, unchanged: 0 }, + lines: [], + }, + { + mode: 'snapshot', + baselineInitialized: false, + summary: { additions: 0, removals: 0, unchanged: 1 }, + lines: [], + }, + { + mode: 'snapshot', + baselineInitialized: false, + summary: { additions: 1, removals: 1, unchanged: 0 }, + lines: [], + }, + ]; + const resultSurface = commandSurfaceWithMembers((config) => ({ + capture: { + diffSnapshot: async ({ session, requestId, signal, interactiveOnly, depth, scope, raw }) => { + await config.backend.captureSnapshot?.( + { session, requestId, signal: signal ?? config.signal }, + { interactiveOnly, depth, scope, raw }, + ); + const result = results.shift(); + if (!result) throw new Error('diff result fixture exhausted'); + return result; + }, + }, + })); const run = async () => await dispatchSnapshotDiffViaRuntime({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: resultSurface, + platformServices: daemonPlatformServicesFixture(), req: { command: 'diff', positionals: ['snapshot'], @@ -243,7 +278,7 @@ test('a sparse internal diff capture returns no screenshot fallback artifact', a const response = await dispatchSnapshotDiffViaRuntime({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: { command: 'diff', positionals: ['snapshot'], token: 't', session: 'diff-runtime' }, sessionName: 'diff-runtime', logPath: '/tmp/diff-runtime.log', diff --git a/src/__tests__/daemon-composition/snapshot-quality-latch.test.ts b/packages/daemon/src/sessions/snapshot-quality-latch-disclosure.test.ts similarity index 91% rename from src/__tests__/daemon-composition/snapshot-quality-latch.test.ts rename to packages/daemon/src/sessions/snapshot-quality-latch-disclosure.test.ts index a468902b08..9d4162ef34 100644 --- a/src/__tests__/daemon-composition/snapshot-quality-latch.test.ts +++ b/packages/daemon/src/sessions/snapshot-quality-latch-disclosure.test.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { expect, test, vi } from 'vitest'; import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; -import { makeIosSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; +import { makeIosSession } from '../__tests__/support/session-factories.ts'; import { recoveredSnapshotQualityWarning, renderSnapshotQualityWarnings, @@ -9,19 +9,18 @@ import { import { applyRecoveredWarningLatch, resolveRecoveredWarningLatch, -} from '@agent-device/daemon/testing/snapshot-quality-latch'; -import { dispatchSnapshotDiffViaRuntime } from '@agent-device/daemon/testing/snapshot-diff-runtime'; -import { dispatchSnapshotViaRuntime } from '@agent-device/daemon/testing/snapshot-runtime'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; -import { snapshotRuntimeFixture } from '@agent-device/daemon/testing/__tests__/snapshot-runtime-fixture'; +} from './snapshot-quality-latch.ts'; +import { dispatchSnapshotDiffViaRuntime } from './snapshot-diff-runtime.ts'; +import { dispatchSnapshotViaRuntime } from './snapshot-runtime.ts'; +import { SessionStore } from '../core/session-store.ts'; +import type { SessionState } from '../core/session-state.ts'; +import { legacyDispatchCapture } from '../__tests__/support/legacy-snapshot-capture-fixture.ts'; +import { snapshotRuntimeFixture } from '../__tests__/support/snapshot-runtime-fixture.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +import { forwardingCreateCommandSurfaceWithAnnotations as testCreateCommandSurface } from '../__tests__/support/command-surface-fake.ts'; -vi.mock('@agent-device/daemon/testing/snapshot-interactor-capture', async () => { - const fixture = - await import('@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'); +vi.mock('../core/snapshot-interactor-capture.ts', async () => { + const fixture = await import('../__tests__/support/legacy-snapshot-capture-fixture.ts'); return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; }); @@ -354,12 +353,12 @@ test('an app switch supersedes the held latch through the dispatch seam', async expect(storedLatch(input)).toEqual({ appBundleId: 'com.example.other' }); }); -test('a genuine recovered render keeps later deferred captures quiet without doubling', async () => { +test('a genuine recovered capture holds the latch for later deferred captures', async () => { const input = scenario(); seedCapture(genuineRecoveredVerdict()); const genuine = await dispatchPublicSnapshot(input); - expect(responseWarnings(genuine).filter((line) => line === FULL_WARNING)).toHaveLength(1); + expect(responseWarnings(genuine)).not.toContain(FULL_WARNING); expect(storedLatch(input)).toEqual({ appBundleId: 'com.example.app' }); seedCapture(deferredVerdict()); diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index e2da03c212..0abd087eb9 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -216,6 +216,15 @@ test('an Android open activates the test IME after the launch dispatch when requ expect(calls.slice(0, 2)).toEqual(['open:com.example.app', 'activate-test-ime']); }); +test('an Android open leaves the real keyboard in place when test IME activation is disabled', async () => { + const { calls, lifecycle } = createLifecycle(); + + await lifecycle.openApplication(openInput({ enableTestIme: false })); + + expect(calls).toContain('open:com.example.app'); + expect(calls).not.toContain('activate-test-ime'); +}); + test('an Android app open returns only after the launched app observation settles', async () => { let finishCapture: () => void = () => {}; const { calls, lifecycle } = createLifecycle({ diff --git a/packages/platform-linux/src/interactor.test.ts b/packages/platform-linux/src/interactor.test.ts index 2ac31104a3..db4b34d80e 100644 --- a/packages/platform-linux/src/interactor.test.ts +++ b/packages/platform-linux/src/interactor.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import { beforeEach, test, vi } from 'vitest'; import { createLinuxInteractor } from './interactor.ts'; -import { middleClickLinux, pressLinux, rightClickLinux } from './index.ts'; +import { buildGesturePlan } from '@agent-device/contracts/gesture-plan'; +import { middleClickLinux, pressLinux, rightClickLinux, swipeLinux } from './index.ts'; vi.mock('./index.ts', () => ({ backLinux: vi.fn(), @@ -37,3 +38,15 @@ test('Linux owns alternate buttons while the runtime binder owns primary series' assert.deepEqual(vi.mocked(middleClickLinux).mock.calls, [[30, 40]]); assert.equal(vi.mocked(pressLinux).mock.calls.length, 0); }); + +test('Linux executes a planned fling as one drag with the resolved duration', async () => { + const plan = buildGesturePlan( + { intent: 'fling', from: { x: 10, y: 20 }, to: { x: 110, y: 20 } }, + { x: 0, y: 0, width: 200, height: 200 }, + 'linux', + ); + + await createLinuxInteractor().performGesture!(plan); + + assert.deepEqual(vi.mocked(swipeLinux).mock.calls, [[10, 20, 110, 20, 100]]); +}); diff --git a/packages/test-kit/package.json b/packages/test-kit/package.json index c5a8134ed6..3d65015612 100644 --- a/packages/test-kit/package.json +++ b/packages/test-kit/package.json @@ -17,6 +17,7 @@ } }, "devDependencies": { - "fast-check": "^4.9.0" + "fast-check": "^4.9.0", + "oxc-parser": "^0.146.0" } } diff --git a/packages/test-kit/src/dispatch-disclosure.fixtures.ts b/packages/test-kit/src/dispatch-disclosure.fixtures.ts index 9916c98366..6610a2cded 100644 --- a/packages/test-kit/src/dispatch-disclosure.fixtures.ts +++ b/packages/test-kit/src/dispatch-disclosure.fixtures.ts @@ -40,7 +40,9 @@ export const DISPATCH_DISCLOSURE_DRIVER_OWNERS: Readonly> 'daemon.': 'packages/daemon/src/execution/interaction/internal/interaction-dispatch-disclosure.test.ts', 'daemon.scroll-no-progress': 'packages/daemon/src/execution/__tests__/scroll-movement.test.ts', - 'daemon.route.': 'src/__tests__/daemon-composition/request-dispatch-disclosure.test.ts', + 'daemon.route.maestro-deferred-settle-capture-refused': + 'packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts', + 'daemon.route.': 'packages/daemon/src/execution/request-dispatch-disclosure.test.ts', 'post-action-guard.': 'packages/daemon/src/execution/interaction/internal/interaction-android-escape-dispatch-disclosure.test.ts', 'ios-runner.': 'packages/platform-apple/src/runner/__tests__/index-dispatch-disclosure.test.ts', diff --git a/src/__tests__/eager-import-closure.fixtures.ts b/packages/test-kit/src/eager-import-closure-fixtures.ts similarity index 98% rename from src/__tests__/eager-import-closure.fixtures.ts rename to packages/test-kit/src/eager-import-closure-fixtures.ts index 384ccb1078..6a82a76906 100644 --- a/src/__tests__/eager-import-closure.fixtures.ts +++ b/packages/test-kit/src/eager-import-closure-fixtures.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; -import { parseSync } from 'oxc-parser'; /** * Eager-import-closure machinery shared by the startup/teardown lazy-seam pins. @@ -29,7 +29,8 @@ const WORKSPACE_SPECIFIER = /^(@agent-device\/[^/]+)(\/.*)?$/; type AstNode = { type?: string; [key: string]: unknown }; -type ParsedModuleRecord = ReturnType['module']; +type ParsedModuleRecord = ReturnType['module']; +const requireFromTestKit = createRequire(import.meta.url); /** Static specifiers this file causes to be EVALUATED (not type-only). */ function staticEvaluatedRefs(module: ParsedModuleRecord): string[] { @@ -118,6 +119,7 @@ export function topLevelStatementCount(fileName: string, source: string): number type EagerModule = { specifiers: string[]; topLevelStatements: number; statementTexts: string[] }; function parseEagerModule(fileName: string, source: string): EagerModule { + const { parseSync } = requireFromTestKit('oxc-parser') as typeof import('oxc-parser'); const parsed = parseSync(fileName, source); const dynamic: string[] = []; collectEagerDynamicImports(parsed.program, true, dynamic); @@ -217,7 +219,7 @@ function resolveRelative(from: string, specifier: string, tree: SourceTreeReader /** `@agent-device/` -> that package's directory, keyed by its declared name. */ function readWorkspacePackageDirs(tree: SourceTreeReader): Map { - const packagesRoot = path.resolve(import.meta.dirname, '../../packages'); + const packagesRoot = path.resolve(import.meta.dirname, '../../../packages'); const dirs = new Map(); for (const entry of tree.readdir(packagesRoot)) { const manifestPath = path.join(packagesRoot, entry, 'package.json'); diff --git a/packages/test-kit/src/index.ts b/packages/test-kit/src/index.ts index 8794236629..7ca08aa991 100644 --- a/packages/test-kit/src/index.ts +++ b/packages/test-kit/src/index.ts @@ -36,6 +36,15 @@ export { dispatchDisclosureRowsOwnedBy, } from './dispatch-disclosure.fixtures.ts'; export { restoreEnv } from './env.ts'; +export { + eagerClosureGraphOf, + eagerClosureOf, + eagerlyEvaluatedModules, + statementTextsOf, + topLevelStatementCount, + topLevelStatementWeightsOf, + type SourceTreeReader, +} from './eager-import-closure-fixtures.ts'; export { type FilesystemBoundaryFixture, type FilesystemBoundaryOwner, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8af7cc1611..9d306e95b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -838,6 +838,9 @@ importers: fast-check: specifier: ^4.9.0 version: 4.9.0 + oxc-parser: + specifier: ^0.146.0 + version: 0.146.0 packages/xml: {} diff --git a/scripts/__tests__/closure-growth-rule.test.ts b/scripts/__tests__/closure-growth-rule.test.ts index ec0a713e96..cfba02f6c6 100644 --- a/scripts/__tests__/closure-growth-rule.test.ts +++ b/scripts/__tests__/closure-growth-rule.test.ts @@ -8,7 +8,7 @@ import { topLevelStatementCount, topLevelStatementWeightsOf, type SourceTreeReader, -} from '../../src/__tests__/eager-import-closure.fixtures.ts'; +} from '@agent-device/test-kit'; import { createCommittedSourceTree, deletedSourcesSince } from './committed-source-tree.ts'; import { mkGitFixtureRepo } from './eager-closure-gate-fixtures.ts'; import { diff --git a/scripts/__tests__/committed-source-tree.ts b/scripts/__tests__/committed-source-tree.ts index f3557ef825..1ff34ff7c9 100644 --- a/scripts/__tests__/committed-source-tree.ts +++ b/scripts/__tests__/committed-source-tree.ts @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process'; import path from 'node:path'; -import type { SourceTreeReader } from '../../src/__tests__/eager-import-closure.fixtures.ts'; +import type { SourceTreeReader } from '@agent-device/test-kit'; import { isProductionSourceFile } from '../layering/tracked-sources.ts'; const WALKED_SOURCE = /^(?:src|packages\/[^/]+\/src)\/.*\.ts$/; diff --git a/scripts/__tests__/eager-closure-budgets.test.ts b/scripts/__tests__/eager-closure-budgets.test.ts index a761dce4aa..ea1296cc88 100644 --- a/scripts/__tests__/eager-closure-budgets.test.ts +++ b/scripts/__tests__/eager-closure-budgets.test.ts @@ -6,7 +6,7 @@ import { eagerClosureGraphOf, statementTextsOf, topLevelStatementWeightsOf, -} from '../../src/__tests__/eager-import-closure.fixtures.ts'; +} from '@agent-device/test-kit'; import { baseProductionPathOf, createCommittedSourceTree, @@ -45,7 +45,7 @@ import { * ADR-0019 requires platform-package façades to stay implementation-lazy and is explicit that a * startup threshold alone is not a substitute for preserving the loading shape: "the tracking * issue owns the exact probe and planted-red procedure." #1950 built the walker this file reuses - * (`src/__tests__/eager-import-closure.fixtures.ts`, AST-level: static value edges plus top-level + * (`@agent-device/test-kit`, AST-level: static value edges plus top-level * dynamic * imports, type-only erased) and proved the planted-red procedure on one file. This is that * probe, generalized to every workspace-package entry surface plus designated hub modules, and diff --git a/scripts/__tests__/eager-closure-budgets.ts b/scripts/__tests__/eager-closure-budgets.ts index 01a719f264..c16ba57f23 100644 --- a/scripts/__tests__/eager-closure-budgets.ts +++ b/scripts/__tests__/eager-closure-budgets.ts @@ -3,7 +3,7 @@ // ADR-0019's "Implementation-laziness" section requires platform-package façades to stay // implementation-lazy and says a startup-time threshold alone does not preserve the loading // shape (`docs/adr/0019-request-bound-platform-runtime.md`). The AST walker in -// `src/__tests__/eager-import-closure.fixtures.ts` counts the repo modules that importing an +// `@agent-device/test-kit` counts the repo modules that importing an // entry evaluates; this module says what count each entry may have. R13 governs import // DIRECTION (may this file reach that one at all), never evaluation WEIGHT. // diff --git a/src/__tests__/cli-startup-import-closure.test.ts b/src/__tests__/cli-startup-import-closure.test.ts index d4c00f9bcc..662bc09fb2 100644 --- a/src/__tests__/cli-startup-import-closure.test.ts +++ b/src/__tests__/cli-startup-import-closure.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; -import { eagerClosureOf, eagerlyEvaluatedModules } from './eager-import-closure.fixtures.ts'; +import { eagerClosureOf, eagerlyEvaluatedModules } from '@agent-device/test-kit'; /** * Every CLI invocation eagerly evaluates the static import closure of diff --git a/src/__tests__/daemon-composition/android-dialog-readiness.test.ts b/src/__tests__/daemon-composition/android-dialog-readiness.test.ts deleted file mode 100644 index 5a099cd3bd..0000000000 --- a/src/__tests__/daemon-composition/android-dialog-readiness.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { beforeEach, expect, test, vi } from 'vitest'; -import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; - -/** - * Recovery reads the daemon presentation, and Android publication derives covered state from the - * acquisition evidence the capture envelope carries (#1981). The fake capture is therefore built by - * the production constructor: a bare `{ nodes }` throws inside publication, and recovery would then - * report "recovery failed" for a fixture reason rather than a device one. - */ -vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { - const actual = await importOriginal(); - const makeAndroidSnapshotCapture = (nodes: RawSnapshotNode[]) => - actual.createAndroidSnapshotCapture( - { - nodes, - analysis: { rawNodeCount: nodes.length, maxDepth: 0 }, - androidSnapshot: { backend: 'android-helper' }, - quality: { state: 'healthy', backend: 'android-helper' }, - }, - { - clickability: { - kind: 'exact', - provider: 'android-helper', - clickableByNodeIndex: new Map(), - }, - }, - ); - return { - ...actual, - snapshotAndroid: vi.fn(async () => makeAndroidSnapshotCapture(screenNodes)), - openAndroidApp: vi.fn(async () => {}), - }; -}); - -import { makeAndroidSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { withFakeAdb, type FakeAdbScript } from '../test-utils/fake-adb.ts'; -import { - countDiagnosticEventsByPhase, - withDiagnosticsScope, -} from '@agent-device/host-kit/diagnostics'; -import { ensureAndroidBlockingSystemDialogReady as ensureOwnedAndroidBlockingSystemDialogReady } from '@agent-device/daemon/testing/android-system-dialog'; -import { androidObservation } from '../../platform-runtime.ts'; - -const ensureAndroidBlockingSystemDialogReady = ( - params: Omit[0], 'observation'>, -) => ensureOwnedAndroidBlockingSystemDialogReady({ ...params, observation: androidObservation }); - -// A blocking dialog can surface with no adb traffic at all, so nothing a previous command observed -// is evidence about this one. These pin what each readiness phase owes the command it guards. - -const APP = 'com.agentdevice.tester'; -const NORMAL_FOCUS_DUMP = ` mCurrentFocus=Window{a1b2c3 u0 ${APP}/${APP}.MainActivity}`; -const ANR_FOCUS_DUMP = ` mCurrentFocus=Window{ffeedd u0 Application Not Responding: ${APP}}`; -const SYSTEM_DIALOG_DUMP = - ' mCurrentFocus=Window{ffeedd u0 Application Not Responding: com.android.systemui}'; -const CLOSE_APP_RECT = { x: 100, y: 600, width: 220, height: 80 }; -const CLOSE_APP_TAP = ['shell', 'input', 'tap', '210', '640']; - -/** What the mocked capture publishes: the screen the case under test is recovering from. */ -let screenNodes: RawSnapshotNode[]; - -beforeEach(() => { - screenNodes = anrDialogNodes(); -}); - -function anrDialogNodes(): RawSnapshotNode[] { - return [ - { - index: 0, - type: 'android.widget.TextView', - label: `${APP} isn't responding`, - rect: { x: 50, y: 400, width: 500, height: 80 }, - }, - { - index: 1, - type: 'android.widget.Button', - label: 'Close app', - rect: CLOSE_APP_RECT, - }, - ]; -} - -/** - * The same dialog with a foreground sheet drawn over it: the "Close app" is in the tree but nothing - * a user could touch. #1832 — recovery acts on what is reachable, never on raw text. - */ -function coveredCloseAppNodes(): RawSnapshotNode[] { - return [ - ...anrDialogNodes(), - { - index: 2, - type: 'com.google.android.material.bottomsheet.BottomSheetDialog', - identifier: 'com.example:id/bottom_sheet', - hittable: true, - rect: { x: 0, y: 0, width: 1080, height: 2400 }, - }, - ]; -} - -function tapCalls(calls: string[][]): string[][] { - return calls.filter((call) => call[1] === 'input' && call[2] === 'tap'); -} - -/** - * A device whose focused window is `dialog` until recovery taps "Close app", and the app after - * that — the tap is what dismisses the dialog on a real device too. - */ -function deviceShowing(dialog: () => string | null): FakeAdbScript { - let dismissed = false; - return (args) => { - if (args[1] === 'input' && args[2] === 'tap') { - dismissed = true; - return ''; - } - if (args[1] !== 'dumpsys') return ''; - const focus = dismissed ? null : dialog(); - return focus ?? NORMAL_FOCUS_DUMP; - }; -} - -test('an ANR that surfaces between two commands is recovered by the next pre-dispatch check', async () => { - // The previous command's post-dispatch check saw a clear screen and nothing has mutated the - // device since — but the app blocked on its own. `main` recovers here and lets the command - // proceed with a warning; a readiness check that answers from that earlier observation instead - // dispatches this command's tap into the ANR dialog's buttons and then fails it. - const session = makeAndroidSession('readiness-anr-between', { appBundleId: APP }); - let anr = false; - - const { readiness, calls } = await withFakeAdb( - deviceShowing(() => (anr ? ANR_FOCUS_DUMP : null)), - async ({ device, calls }) => { - session.device = device; - await ensureAndroidBlockingSystemDialogReady({ - session, - command: 'tap', - phase: 'after-command', - }); - anr = true; - const readiness = await ensureAndroidBlockingSystemDialogReady({ - session, - command: 'tap', - phase: 'before-command', - }); - return { readiness, calls }; - }, - ); - - expect(readiness).toEqual({ - status: 'recovered', - warning: `Recovered Android app ANR before tap: closed and relaunched ${APP}.`, - }); - // Recovering means the dialog was actually dismissed on the device, so the reported status is - // pinned to the tap that did it rather than to the status value alone. - expect(tapCalls(calls)).toEqual([CLOSE_APP_TAP]); -}); - -test('an ANR whose Close app is covered is not tapped, and recovery reports failure', async () => { - // #1832: recovery reads the presentation's occlusion result, so a "Close app" that a foreground - // surface covers is unreachable. Tapping it anyway would land the tap on whatever is on top. - const session = makeAndroidSession('readiness-anr-covered', { appBundleId: APP }); - screenNodes = coveredCloseAppNodes(); - - const { calls } = await withFakeAdb( - deviceShowing(() => ANR_FOCUS_DUMP), - async ({ device, calls }) => { - session.device = device; - await expect( - ensureAndroidBlockingSystemDialogReady({ - session, - command: 'tap', - phase: 'before-command', - }), - ).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - message: expect.stringContaining('Android app ANR blocked tap'), - }); - return { calls }; - }, - ); - - expect(tapCalls(calls)).toEqual([]); -}); - -test('a system dialog that surfaces between two commands blocks the next command', async () => { - const session = makeAndroidSession('readiness-system-between'); - let dialog = false; - - await withFakeAdb( - deviceShowing(() => (dialog ? SYSTEM_DIALOG_DUMP : null)), - async ({ device }) => { - session.device = device; - await ensureAndroidBlockingSystemDialogReady({ - session, - command: 'tap', - phase: 'after-command', - }); - dialog = true; - await expect( - ensureAndroidBlockingSystemDialogReady({ - session, - command: 'tap', - phase: 'before-command', - }), - ).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); - }, - ); -}); - -test('an ANR the command itself provoked still fails that command', async () => { - const session = makeAndroidSession('readiness-anr-after', { appBundleId: APP }); - - await withFakeAdb( - deviceShowing(() => ANR_FOCUS_DUMP), - async ({ device }) => { - session.device = device; - await expect( - ensureAndroidBlockingSystemDialogReady({ - session, - command: 'tap', - phase: 'after-command', - }), - ).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - message: expect.stringContaining('Android app ANR appeared after tap'), - }); - }, - ); -}); - -test('a dump that answered nothing is reported as unobserved, not as a clear screen', async () => { - const session = makeAndroidSession('readiness-unobserved'); - - const { readiness, unobserved } = await withDiagnosticsScope({}, async () => - withFakeAdb( - () => ({ exitCode: 1, stdout: '', stderr: 'device offline' }), - async ({ device }) => { - session.device = device; - const readiness = await ensureAndroidBlockingSystemDialogReady({ - session, - command: 'tap', - phase: 'after-command', - }); - return { - readiness, - unobserved: countDiagnosticEventsByPhase(['android_blocking_dialog_unobserved']), - }; - }, - ), - ); - - // The command still proceeds — a failed probe has never been a refusal — but nothing about this - // dump may be carried forward as evidence that the device was clear. - expect(readiness).toEqual({ status: 'clear' }); - expect(unobserved).toBe(1); -}); diff --git a/src/__tests__/daemon-composition/app-event-delivery.test.ts b/src/__tests__/daemon-composition/app-event-delivery.test.ts deleted file mode 100644 index c84810e602..0000000000 --- a/src/__tests__/daemon-composition/app-event-delivery.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - parseTriggerAppEventArgs, - resolveAppEventUrl, -} from '@agent-device/daemon/testing/app-events'; -import { getInteractor } from '../../core/interactors.ts'; -import { AppError } from '@agent-device/kernel/errors'; -import { ANDROID_EMULATOR, IOS_DEVICE, MACOS_DEVICE, mkdtempForTest } from '@agent-device/test-kit'; - -/** - * The composed `trigger-app-event` path as R57 left it: the daemon's own policy resolves the - * event URL, and the bound `triggerAppEvent` operation opens it through the selected owner's - * interactor. This helper is the same composition `daemon/app-event-runtime.ts` performs, minus - * the request plumbing, so these tests keep asserting the real shell invocation the retired - * `dispatchCommand('trigger-app-event', …)` used to reach. - */ -async function triggerAppEvent( - device: DeviceInfo, - positionals: string[], - options: { appBundleId?: string } = {}, -): Promise<{ event: string; eventUrl: string; transport: 'deep-link' }> { - const { eventName, payload } = parseTriggerAppEventArgs(positionals); - const eventUrl = resolveAppEventUrl(device, eventName, payload); - const operations = bindAdmittedLocalInteractorOperations({ - device, - signal: new AbortController().signal, - resolveInteractor: async (owned: DeviceInfo, runner) => await getInteractor(owned, runner), - facts: { triggerAppEvent: { available: true } }, - }); - await operations.triggerAppEvent?.({ eventUrl, options }); - return { event: eventName, eventUrl, transport: 'deep-link' }; -} - -test('trigger-app-event reports missing URL template as UNSUPPORTED_OPERATION', async () => { - const previousGlobalTemplate = process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; - const previousAndroidTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - delete process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; - delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - - try { - await assert.rejects( - () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken']), - (error: unknown) => { - assert.equal(error instanceof AppError, true); - assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION'); - assert.match((error as AppError).message, /No app event URL template configured/i); - return true; - }, - ); - } finally { - if (previousGlobalTemplate === undefined) - delete process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = previousGlobalTemplate; - if (previousAndroidTemplate === undefined) - delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousAndroidTemplate; - } -}); - -test('trigger-app-event validates payload JSON', async () => { - const previousTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = - 'myapp://agent-device/event?name={event}&payload={payload}'; - try { - await assert.rejects( - () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken', '{invalid-json']), - (error: unknown) => { - assert.equal(error instanceof AppError, true); - assert.equal((error as AppError).code, 'INVALID_ARGS'); - assert.match((error as AppError).message, /Invalid trigger-app-event payload JSON/i); - return true; - }, - ); - } finally { - if (previousTemplate === undefined) - delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousTemplate; - } -}); - -test('trigger-app-event opens deep link with encoded event payload', async () => { - const tempDir = await mkdtempForTest('agent-device-dispatch-trigger-event-'); - const adbPath = path.join(tempDir, 'adb'); - const argsLogPath = path.join(tempDir, 'args.log'); - await fs.writeFile( - adbPath, - '#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', - 'utf8', - ); - await fs.chmod(adbPath, 0o755); - - const previousPath = process.env.PATH; - const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; - const previousTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`; - process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; - process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = - 'myapp://agent-device/event?name={event}&payload={payload}&platform={platform}'; - - try { - const result = await triggerAppEvent(ANDROID_EMULATOR, [ - 'screenshot_taken', - '{"source":"qa","count":2}', - ]); - assert.equal(result?.event, 'screenshot_taken'); - assert.equal(result?.transport, 'deep-link'); - const expectedUrl = - 'myapp://agent-device/event?name=screenshot_taken&payload=%7B%22source%22%3A%22qa%22%2C%22count%22%3A2%7D&platform=android'; - assert.equal(result?.eventUrl, expectedUrl); - - const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean); - assert.equal(args.includes('-d'), true); - assert.equal(args.includes(`'${expectedUrl}'`), true); - } finally { - process.env.PATH = previousPath; - if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; - else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; - if (previousTemplate === undefined) - delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousTemplate; - await fs.rm(tempDir, { recursive: true, force: true }); - } -}); - -test('trigger-app-event prefers platform-specific template over global template', async () => { - const tempDir = await mkdtempForTest('agent-device-dispatch-trigger-template-'); - const adbPath = path.join(tempDir, 'adb'); - const argsLogPath = path.join(tempDir, 'args.log'); - await fs.writeFile( - adbPath, - '#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', - 'utf8', - ); - await fs.chmod(adbPath, 0o755); - - const previousPath = process.env.PATH; - const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; - const previousGlobalTemplate = process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; - const previousAndroidTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`; - process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; - process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = 'myapp://global?name={event}'; - process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = 'myapp://android?name={event}'; - - try { - const result = await triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken']); - assert.equal(result?.eventUrl, 'myapp://android?name=screenshot_taken'); - } finally { - process.env.PATH = previousPath; - if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; - else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; - if (previousGlobalTemplate === undefined) - delete process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = previousGlobalTemplate; - if (previousAndroidTemplate === undefined) - delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousAndroidTemplate; - await fs.rm(tempDir, { recursive: true, force: true }); - } -}); - -test('trigger-app-event supports iOS device path and prefers iOS template', async () => { - const tempDir = await mkdtempForTest('agent-device-dispatch-trigger-ios-'); - const xcrunPath = path.join(tempDir, 'xcrun'); - const argsLogPath = path.join(tempDir, 'args.log'); - await fs.writeFile( - xcrunPath, - '#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', - 'utf8', - ); - await fs.chmod(xcrunPath, 0o755); - - const previousPath = process.env.PATH; - const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; - const previousGlobalTemplate = process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; - const previousIosTemplate = process.env.AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE; - process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`; - process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; - process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = 'myapp://global?name={event}'; - process.env.AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE = - 'myapp://ios?name={event}&payload={payload}'; - - try { - const result = await triggerAppEvent(IOS_DEVICE, ['screenshot_taken', '{"source":"ios"}'], { - appBundleId: 'com.example.app', - }); - const expectedUrl = 'myapp://ios?name=screenshot_taken&payload=%7B%22source%22%3A%22ios%22%7D'; - assert.equal(result?.eventUrl, expectedUrl); - const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean); - assert.deepEqual(args, [ - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - 'ios-device-1', - '--payload-url', - expectedUrl, - 'com.example.app', - ]); - } finally { - process.env.PATH = previousPath; - if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; - else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; - if (previousGlobalTemplate === undefined) - delete process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = previousGlobalTemplate; - if (previousIosTemplate === undefined) - delete process.env.AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE = previousIosTemplate; - await fs.rm(tempDir, { recursive: true, force: true }); - } -}); - -test('trigger-app-event supports macOS and prefers macOS template', async () => { - const tempDir = await mkdtempForTest('agent-device-dispatch-trigger-macos-'); - const openPath = path.join(tempDir, 'open'); - const argsLogPath = path.join(tempDir, 'args.log'); - await fs.writeFile( - openPath, - '#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', - 'utf8', - ); - await fs.chmod(openPath, 0o755); - - const previousPath = process.env.PATH; - const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; - const previousGlobalTemplate = process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; - const previousMacosTemplate = process.env.AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE; - process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`; - process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; - process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = 'myapp://global?name={event}'; - process.env.AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE = - 'myapp://macos?name={event}&payload={payload}&platform={platform}'; - - try { - const result = await triggerAppEvent(MACOS_DEVICE, [ - 'screenshot_taken', - '{"source":"desktop"}', - ]); - const expectedUrl = - 'myapp://macos?name=screenshot_taken&payload=%7B%22source%22%3A%22desktop%22%7D&platform=macos'; - assert.equal(result?.eventUrl, expectedUrl); - const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean); - assert.deepEqual(args, [expectedUrl]); - } finally { - process.env.PATH = previousPath; - if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; - else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; - if (previousGlobalTemplate === undefined) - delete process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = previousGlobalTemplate; - if (previousMacosTemplate === undefined) - delete process.env.AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE = previousMacosTemplate; - await fs.rm(tempDir, { recursive: true, force: true }); - } -}); - -test('trigger-app-event rejects invalid event names', async () => { - const previousTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = - 'myapp://agent-device/event?name={event}'; - try { - await assert.rejects( - () => triggerAppEvent(ANDROID_EMULATOR, ['bad event']), - (error: unknown) => { - assert.equal(error instanceof AppError, true); - assert.equal((error as AppError).code, 'INVALID_ARGS'); - assert.match((error as AppError).message, /Invalid trigger-app-event event name/i); - return true; - }, - ); - } finally { - if (previousTemplate === undefined) - delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousTemplate; - } -}); - -test('trigger-app-event rejects payloads that exceed size limits', async () => { - const previousTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = - 'myapp://agent-device/event?name={event}&payload={payload}'; - const oversizedPayload = JSON.stringify({ value: 'x'.repeat(9000) }); - try { - await assert.rejects( - () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken', oversizedPayload]), - (error: unknown) => { - assert.equal(error instanceof AppError, true); - assert.equal((error as AppError).code, 'INVALID_ARGS'); - assert.match((error as AppError).message, /exceeds 8192 bytes/i); - return true; - }, - ); - } finally { - if (previousTemplate === undefined) - delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousTemplate; - } -}); - -test('trigger-app-event rejects event URLs that exceed length limits', async () => { - const previousTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = `myapp://${'a'.repeat(5000)}?name={event}`; - try { - await assert.rejects( - () => triggerAppEvent(ANDROID_EMULATOR, ['screenshot_taken']), - (error: unknown) => { - assert.equal(error instanceof AppError, true); - assert.equal((error as AppError).code, 'INVALID_ARGS'); - assert.match((error as AppError).message, /URL exceeds maximum supported length/i); - return true; - }, - ); - } finally { - if (previousTemplate === undefined) - delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; - else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousTemplate; - } -}); diff --git a/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts b/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts deleted file mode 100644 index f35d812901..0000000000 --- a/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { ApplicationLifecycleRuntimeOperations } from '@agent-device/contracts/application-lifecycle-runtime'; -import type { DeviceBinding } from '@agent-device/contracts/platform-runtime'; -import type { - PlatformRuntimeHost, - PlatformRuntimeOperations, -} from '@agent-device/contracts/platform-runtime-operations'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { TargetShutdownResult } from '@agent-device/contracts/device'; -import { vi } from 'vitest'; -// The lifecycle seams (dispatch spy, readiness pause, fixture interactor) have ONE owner: the -// daemon's canned lifecycle fixture. This real-composition fixture builds the REAL root gateway -// and platform application tools on top of the same seams, so every lane observes one spy set. -// Android readiness discovery is the composed owners' rule (which devices discovery runs for), -// so its already-running-emulator seam lives HERE, not in the daemon fixture. -export { - awaitFixtureReadiness, - dispatchApplicationLifecycleEffect, -} from '@agent-device/daemon/testing/__tests__/application-lifecycle-runtime-fixture'; -import { - applicationLifecycleFixtureInteractor, - awaitFixtureReadiness, -} from '@agent-device/daemon/testing/__tests__/application-lifecycle-runtime-fixture'; -import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; -import { createAndroidApplicationTools } from '../../platform-runtime-android-application-tools.ts'; -import { createAppleApplicationTools } from '../../platform-runtime-apple-application-tools.ts'; -import { createComposedPlatformRuntimeGateway } from '../../platform-runtime-gateway.ts'; -import { platformRuntimeModules } from '../../platform-runtime.ts'; - -/** - * Readiness discovery, as a seam for the composed host. It reports the device under test as an - * already-running emulator so composition fixtures never launch or poll a boot; a test that needs - * a pre-effect readiness failure rejects this instead. - */ -export const discoverReadyAndroidEmulators = vi.fn(async (readyDevice: DeviceInfo) => [ - { - ...readyDevice, - id: readyDevice.id.startsWith('emulator-') ? readyDevice.id : `emulator-${readyDevice.id}`, - booted: true, - }, -]); - -export async function composedLifecycleRuntimeFixture( - device: DeviceInfo, - signal: AbortSignal = new AbortController().signal, - shutdownTarget: (device: DeviceInfo) => Promise = async () => ({ - success: true, - exitCode: 0, - stdout: '', - stderr: '', - }), -): Promise { - const gateway = createComposedPlatformRuntimeGateway({ - modules: platformRuntimeModules, - loadHost: async () => fixtureHost(device, shutdownTarget), - }); - const binding = await gateway.bind({ - device, - intent: { kind: 'ordinary' }, - scope: { signal, diagnostics: { emit: () => {} }, progress: { report: () => {} } }, - }); - return lifecycleOperations(binding); -} - -function lifecycleOperations( - binding: DeviceBinding, -): ApplicationLifecycleRuntimeOperations { - const operations = binding.operations; - const keys = [ - 'resolveOpenTarget', - 'prepareApplicationOpen', - 'openApplication', - 'applyRuntimeHints', - 'clearRuntimeHints', - 'closeApplication', - 'finalizeApplicationClose', - 'prepareAppleRunner', - 'configureProviderPortReverse', - ] as const satisfies readonly (keyof ApplicationLifecycleRuntimeOperations)[]; - const selected = Object.fromEntries( - keys.flatMap((key) => { - const operation = operations[key]; - return operation ? [[key, operation]] : []; - }), - ); - return Object.freeze(selected) as ApplicationLifecycleRuntimeOperations; -} - -function fixtureHost( - readyDevice: DeviceInfo, - shutdownTarget: (device: DeviceInfo) => Promise, -): PlatformRuntimeHost { - let simulatorState = readyDevice.booted === false ? 'Shutdown' : 'Booted'; - const localInteractors = Object.freeze({ - resolve: async (device: DeviceInfo, runner: RunnerContext): Promise => - applicationLifecycleFixtureInteractor(device, runner), - }); - return { - localInteractors, - appleApplications: createAppleApplicationTools(), - androidApplications: createAndroidApplicationTools(), - deviceShutdown: Object.freeze({ - apple: Object.freeze({ - shutdownTarget: async (device: DeviceInfo) => await shutdownTarget(device), - }), - android: Object.freeze({ - shutdownTarget: async (device: DeviceInfo) => await shutdownTarget(device), - }), - close: Object.freeze({ - canShutdownTarget: async (device: DeviceInfo) => - (device.platform === 'apple' && device.kind === 'simulator') || - (device.platform === 'android' && device.kind === 'emulator'), - shutdownTarget: async (device: DeviceInfo) => await shutdownTarget(device), - }), - }), - // Readiness is package-owned; these handler fixtures exercise lifecycle dispatch, so the - // narrow ports report an already-ready device instead of booting anything. - deviceReadiness: Object.freeze({ - applePhysical: Object.freeze({ - ensureConnected: async (_device: DeviceInfo, signal: AbortSignal) => { - signal.throwIfAborted(); - }, - }), - appleAutomation: Object.freeze({ - keepHot: () => undefined, - markBooted: () => {}, - wasRecentlyObservedBooted: async () => false, - }), - androidEmulator: Object.freeze({ - discover: async (_request: unknown, signal: AbortSignal) => { - signal.throwIfAborted(); - await awaitFixtureReadiness(readyDevice); - return await discoverReadyAndroidEmulators(readyDevice); - }, - launch: async () => 1, - terminate: async () => undefined, - }), - }), - // Readiness reads the device's own boot state, so a `booted: false` fixture still exercises - // the cold-boot path (and its runner-cache prewarm hook) before settling. - appleTools: Object.freeze({ - isXcrunAvailable: async () => true, - run: async (request: Readonly<{ args: readonly string[] }>) => { - await awaitFixtureReadiness(readyDevice); - if (request.args.includes('boot')) { - simulatorState = 'Booted'; - return { stdout: '', stderr: '', exitCode: 0 }; - } - return { - stdout: JSON.stringify({ - devices: { fixture: [{ udid: readyDevice.id, state: simulatorState }] }, - }), - stderr: '', - exitCode: 0, - }; - }, - }), - commands: Object.freeze({ - which: async (executable: string) => executable, - run: async () => ({ stdout: '1', stderr: '', exitCode: 0 }), - }), - toolchains: Object.freeze({ prepare: async () => undefined }), - clock: Object.freeze({ - now: () => Date.now(), - sleep: async () => undefined, - }), - processTransports: Object.freeze({ - resolve: async () => ({ mode: 'local' as const }), - }), - networkTransports: Object.freeze({ - resolve: async () => ({ mode: 'local' as const }), - }), - screenRecording: Object.freeze({ - apple: Object.freeze({ - availability: async () => ({ - available: false as const, - hint: 'Screen recording is outside this lifecycle fixture.', - }), - }), - android: Object.freeze({ resolve: async () => ({}) }), - web: Object.freeze({ resolve: async () => undefined }), - }), - audioProbe: Object.freeze({ - hostCapture: Object.freeze({ - info: Object.freeze({ - source: 'system-audio' as const, - backend: 'fixture', - sourceCount: 0, - notes: () => [], - }), - start: async () => { - throw new Error('Audio probe is outside this lifecycle fixture.'); - }, - inspectProcess: async () => 'missing' as const, - terminateProcess: async () => 'already-missing' as const, - }), - web: Object.freeze({ resolve: async () => undefined }), - ownedProcesses: Object.freeze({ replace: () => {}, clear: () => {} }), - }), - } as unknown as PlatformRuntimeHost; -} diff --git a/src/__tests__/daemon-composition/generic-settle.test.ts b/src/__tests__/daemon-composition/generic-settle.test.ts deleted file mode 100644 index 121db28a3e..0000000000 --- a/src/__tests__/daemon-composition/generic-settle.test.ts +++ /dev/null @@ -1,986 +0,0 @@ -import type { DaemonResponse } from '@agent-device/kernel/contracts'; -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; -import { beforeEach, expect, test, vi } from 'vitest'; -import type { CommandFlags } from '@agent-device/contracts/command'; -import type { CommandClock } from '@agent-device/contracts/runtime-contract'; -import type { PresentedSurface, RawSnapshotNode } from '@agent-device/kernel/snapshot'; -import { - APP_SURFACE, - iosSystemSurfaceDisclosure, - iosSystemSurfaceTransitionDisclosure, - type IosSystemSurfaceProvenance, -} from '@agent-device/contracts/ios-system-surface'; -import { makeIosSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { - PRESENTED_SHEET_FULL_NODES, - PRESENTED_SHEET_INTERACTIVE_NODES, - SHEET_DISMISSED_NODES, -} from './presented-sheet-trees.ts'; -import { - activateCompleteRefFrame, - markSessionSnapshotOutdated, - refFrameState, -} from '@agent-device/daemon/testing/ref-frame'; -import { setSessionSnapshot } from '@agent-device/daemon/testing/session-snapshot'; -import type { SessionStore } from '@agent-device/daemon/testing/session-store'; -import type { DaemonRequest } from '@agent-device/daemon'; -import type { SessionRef, SessionState } from '@agent-device/daemon/testing/session-state'; -import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; -import { selectorCaptureFixture } from '@agent-device/daemon/testing/__tests__/selector-capture-fixture'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; - -// #1638 `--settle` on the GENERIC daemon route (scroll/back): the settled diff, -// its refs, and the ref-frame/generation dance are the same contract the touch -// commands get — but the baseline is the session's stored pre-action tree, not -// a resolution, and the observation must run after the deferred-outcome -// markers. Quiet windows are tuned down so no test waits real time. - -vi.mock('@agent-device/daemon/testing/interaction/index', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - captureSnapshotForSession: vi.fn(async () => ({ - nodes: [], - createdAt: 0, - backend: 'xctest', - producer: 'apple-runner' as const, - })), - }; -}); - -import { captureSnapshotForSession } from '@agent-device/daemon/testing/interaction/index'; -import { dispatchGenericCommand } from '@agent-device/daemon/testing/request-generic-dispatch'; -import { pendingSurfaceStabilization } from '@agent-device/daemon/testing/deferred-interaction-outcome'; - -const mockCaptureSnapshotForSession = vi.mocked(captureSnapshotForSession); - -const BEFORE_NODES = [ - { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, - { - index: 1, - parentIndex: 0, - type: 'Button', - label: 'Continue', - rect: { x: 10, y: 20, width: 120, height: 44 }, - hittable: true, - }, -]; - -const AFTER_NODES = [ - { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, - { - index: 1, - parentIndex: 0, - type: 'Button', - label: 'Load more', - rect: { x: 10, y: 20, width: 120, height: 44 }, - hittable: true, - }, -]; - -const SETTLE_FLAGS = { settle: true, settleQuietMs: 25, timeoutMs: 2_000 } satisfies CommandFlags; - -type SettlePayload = { - settled: boolean; - captures: number; - quietMs: number; - timeoutMs: number; - waitedMs: number; - refsGeneration?: number; - diff?: { - summary: { additions: number; removals: number; unchanged: number }; - lines: Array<{ kind: string; text: string; ref?: string }>; - }; - tail?: Array<{ ref: string; role: string; label?: string }>; - surfaceChange?: { from: string; to: string; disclosure: string }; - hint?: string; -}; - -/** Capture observations made from inside the emulated capture, in call order. */ -const captureObservations: Array<{ postGestureStabilizationPending: boolean }> = []; - -async function emulateCaptureSnapshotForSession( - ref: SessionRef, - flags: CommandFlags | undefined, - sessionStore: SessionStore, - options: { - interactiveOnly: boolean; - boundCapture?: Parameters[4]['boundCapture']; - }, -) { - captureObservations.push({ - postGestureStabilizationPending: - pendingSurfaceStabilization(sessionStore.requireCurrent(ref)) !== undefined, - }); - const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; - // Same rule the real seam applies: a request-bound capture is the tree this request reads, and - // only a request that holds none dispatches for one (#3510). - const snapshotData = options.boundCapture - ? await options.boundCapture({}) - : ((await mockDispatch('snapshot')) as Parameters[0]); - const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); - setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); - return snapshot; -} - -function mockCommandDispatch( - snapshots: RawSnapshotNode[][], - producer: NonNullable = 'apple-runner', -) { - let snapshotCalls = 0; - mockDispatch.mockImplementation(async (command) => { - if (command === 'snapshot') { - const nodes = snapshots[Math.min(snapshotCalls, snapshots.length - 1)]; - snapshotCalls += 1; - return { nodes, backend: 'xctest', producer }; - } - return {}; - }); -} - -/** - * A clock the settle loop advances instead of waiting. The default-window cases below assert the - * window a run RESOLVED, which needs no real elapsed time: the loop's own budget arithmetic runs on - * this clock, so the suite neither slows nor goes unstable under load. - */ -function fakeClock(stepMs = 300): CommandClock { - let elapsed = 0; - return { - now: () => elapsed, - sleep: async (ms: number) => { - elapsed += ms > 0 ? ms : stepMs; - }, - }; -} - -const contextFromFlags = () => ({}) as never; - -/** - * The bound execution `dispatchGenericCommand` runs. R58 retired the legacy dispatcher this file - * used to borrow, so the double lives here: these tests are about the settle/stabilization - * orchestration around a generic leaf, not about which owner performs it. - */ -const platformExecution = vi.fn( - async (params: { command: string }) => await mockDispatch(params.command), -); - -/** Stands in for the device work a bound generic leaf performs, keyed by command name. */ -const mockDispatch = vi.fn<(command: string) => Promise>>(async () => ({})); - -/** - * The session as it stands before the generic command runs. `baseline` is the STORED pre-action - * tree the settled diff is taken against, and the surface that capture described — which is how a - * session whose last observation was an in-place system surface (#2438) is set up. - */ -function seedSession( - sessionName: string, - sessionStore: SessionStore, - baseline: GenericSettleTree = { nodes: BEFORE_NODES }, - overrides?: Partial, -): SessionState { - const session = makeIosSession(sessionName, overrides); - setSessionSnapshot(session, buildSnapshotState(snapshotPayload(baseline), {})); - activateCompleteRefFrame(session); - sessionStore.publish(sessionName, session); - return session; -} - -/** A tree plus the surface a capture of it describes: app content, or an in-place system surface. */ -type GenericSettleTree = { - nodes: RawSnapshotNode[]; - systemSurface?: IosSystemSurfaceProvenance; - producer?: 'apple-runner' | 'simulator-ax-bridge'; - presentedSurface?: PresentedSurface; -}; - -function snapshotPayload(tree: GenericSettleTree) { - return { - nodes: tree.nodes, - backend: 'xctest' as const, - producer: tree.producer ?? ('apple-runner' as const), - ...(tree.systemSurface ? { systemSurface: tree.systemSurface } : {}), - ...(tree.presentedSurface ? { presentedSurface: tree.presentedSurface } : {}), - }; -} - -/** - * Every settle capture reads `tree`; any other command answers `result`. Unlike - * {@link mockCommandDispatch} the captured tree carries its surface provenance, so the settled - * capture can describe a surface other than the one the stored baseline described. - */ -function mockSurfaceDispatch(tree: GenericSettleTree, result: Record = {}) { - mockDispatch.mockImplementation(async (command) => - command === 'snapshot' ? snapshotPayload(tree) : result, - ); -} - -/** - * The default gateway these cases run against: an owner whose settle captures arrive through the - * emulated `captureSnapshotForSession` this file drives, i.e. one whose bound capture plan is - * refused. The cases that pin the admitted capture name their own fixture (#3510). - */ -function legacyCaptureRuntimeBindings() { - return selectorCaptureFixture({ - capture: { available: false, reason: 'unsupported-platform-leaf' }, - withoutActiveApp: { available: false, reason: 'unsupported-platform-leaf' }, - }); -} - -async function dispatchGeneric(params: { - sessionName: string; - sessionStore: SessionStore; - command: string; - positionals?: string[]; - flags?: CommandFlags; - /** The request-scoped runtime seams `--settle` admits its own observation capture through. */ - runtimeBindings?: Readonly< - Pick[0], 'inspectFacts' | 'bindDevice'> - >; - /** Injects a clock into every command surface the route builds, so no test waits real time. */ - clock?: CommandClock; -}): Promise { - const req: DaemonRequest = { - token: 't', - session: params.sessionName, - command: params.command, - positionals: params.positionals ?? [], - ...(params.flags ? { flags: params.flags } : {}), - }; - return await dispatchGenericCommand({ - createCommandSurface: params.clock - ? async (config) => await testCreateCommandSurface({ ...config, clock: params.clock }) - : testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req, - ref: params.sessionStore.lookup(params.sessionName)!, - sessionName: params.sessionName, - logPath: '', - sessionStore: params.sessionStore, - contextFromFlags, - executePlatformCommand: platformExecution, - ...legacyCaptureRuntimeBindings(), - ...params.runtimeBindings, - }); -} - -function expectOkData(response: DaemonResponse): Record { - expect(response.ok).toBe(true); - if (response.ok !== true) throw new Error('expected an ok daemon response'); - return (response.data ?? {}) as Record; -} - -beforeEach(() => { - captureObservations.length = 0; - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue({}); - mockCaptureSnapshotForSession.mockReset(); - mockCaptureSnapshotForSession.mockImplementation( - (...args: Parameters) => { - const [ref, flags, sessionStore, _contextFromFlags, options] = args; - return emulateCaptureSnapshotForSession(ref, flags, sessionStore, options); - }, - ); -}); - -test('scroll --settle answers with the settled diff against the stored pre-action tree', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-scroll'; - seedSession(sessionName, sessionStore); - // Every settle capture sees the post-scroll tree; the diff baseline is the - // tree already stored on the session, not a capture taken here. - mockCommandDispatch([AFTER_NODES]); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'scroll', - positionals: ['down'], - flags: { ...SETTLE_FLAGS }, - }); - - const settle = expectOkData(response).settle as SettlePayload; - expect(settle.settled).toBe(true); - expect(settle.quietMs).toBe(25); - expect(settle.timeoutMs).toBe(2_000); - expect(settle.diff?.summary).toEqual({ additions: 1, removals: 1, unchanged: 1 }); - expect(settle.diff?.lines).toContainEqual({ - kind: 'added', - text: expect.stringContaining('Load more'), - ref: 'e2', - }); - // `SettleDiffLine`: removed lines never carry a ref — their refs would name - // nodes of the REPLACED tree, and ref bodies are index-derived, so `@e2` on a - // removed line is a different element in the settled tree that took its slot. - // Enforced in snapshot-diff (removed lines are built without one); asserted - // here because this route is the one that diffs against the STORED session - // snapshot, whose nodes all carry refs, so it is where a regression would - // first publish one. - const removed = settle.diff?.lines.find((line) => line.kind === 'removed'); - expect(removed?.text).toContain('Continue'); - expect(removed?.ref).toBeUndefined(); - - // The settled tree became the stored snapshot, and its refs were published: - // a partial frame is active at the generation the payload reports. - const stored = sessionStore.get(sessionName) as SessionState; - expect(refFrameState(stored)).toBe('active'); - expect(settle.refsGeneration).toBe(stored.snapshotGeneration); - expect(stored.snapshot?.nodes.some((node) => node.label === 'Load more')).toBe(true); -}); - -test('back --settle answers with the settled diff alongside the command result', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-back'; - seedSession(sessionName, sessionStore); - mockDispatch.mockImplementation(async (command) => { - if (command === 'snapshot') - return { nodes: AFTER_NODES, backend: 'xctest', producer: 'apple-runner' }; - return { action: 'back', mode: 'in-app', message: 'Back' }; - }); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'back', - flags: { ...SETTLE_FLAGS }, - }); - - const data = expectOkData(response); - // The observation rides ALONGSIDE the command's own closed result shape. - expect(data.action).toBe('back'); - expect(data.message).toBe('Back'); - expect((data.settle as SettlePayload).diff?.summary).toEqual({ - additions: 1, - removals: 1, - unchanged: 1, - }); -}); - -// #2438 cross-surface settle on the GENERIC route: iOS serves a web sign-in sheet -// (com.apple.SafariViewService) IN PLACE over a still-foreground app, so a settled capture of the -// sheet and the session's stored pre-action tree of the app describe DIFFERENT surfaces. The diff -// above would then be a whole-surface replacement presented as change within one surface — and -// since the daemon treats `diff` presence as "this response issues refs", it would hand the caller -// refs for that claim. This route plumbs the baseline's surface identity through the -// `SurfaceScopedNodes` it hands the settle engine; the element-targeted route guarantees the same contract in -// `src/commands/interaction/runtime/post-action-surface.test.ts`, and these tests assert it for -// scroll/back so the two routes cannot drift. -// -// Both directions are covered because each falsifies a DIFFERENT half of that plumbing, and -// neither substitutes for the other: drop the baseline's surface identity here and only the -// sheet-to-app tests fail (an app baseline has no surface id to lose); drop the settled capture's -// surface identity and only the app-to-sheet tests fail. -const WEB_SIGN_IN_SHEET: IosSystemSurfaceProvenance = { - bundleId: 'com.apple.SafariViewService', - kind: 'web-auth', -}; -const WEB_SIGN_IN_DISCLOSURE = iosSystemSurfaceDisclosure(WEB_SIGN_IN_SHEET.bundleId); -const WEB_SIGN_IN_DEPARTED_DISCLOSURE = iosSystemSurfaceTransitionDisclosure({ - from: WEB_SIGN_IN_SHEET.bundleId, - to: APP_SURFACE, -}); - -/** Five nodes, so a settled sheet clears the tiny-tree readiness hint and the hint under test is - * the cross-surface one. No Application root: the sheet is hosted out of the app's process. */ -const WEB_SIGN_IN_SHEET_NODES = [ - 'Sign in with Example', - 'Email', - 'Password', - 'Continue', - 'Cancel', -].map((label, index) => ({ - index, - type: 'Button', - label, - rect: { x: 10, y: 120 + index * 50, width: 200, height: 44 }, - hittable: true, -})); - -/** The app content that returns once the sheet completes and dismisses itself, also past the - * tiny-tree count so the return direction asserts the same hint contract. */ -const APP_CONTENT_NODES = [ - { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, - ...['Load more', 'Profile', 'Settings', 'Sign out'].map((label, offset) => ({ - index: offset + 1, - parentIndex: 0, - type: 'Button', - label, - rect: { x: 10, y: 20 + offset * 50, width: 120, height: 44 }, - hittable: true, - })), -]; - -/** - * The cross-surface contract a settle route owes, asserted as one unit so `scroll` and `back` - * cannot drift from each other or from the targeted route: no diff is attached across the - * boundary — therefore no tail and no issued refs — the transition is disclosed, and the settle - * observation still reports its own verdict alongside that disclosure rather than instead of it. - */ -function expectCrossSurfaceSettle( - settle: SettlePayload, - change: { from: string; to: string; disclosure: string }, -): void { - expect(settle.surfaceChange).toEqual(change); - expect(settle.diff).toBeUndefined(); - expect(settle.tail).toBeUndefined(); - // `refsGeneration` is folded in only when the settled diff published refs (ADR 0014); a refused - // diff publishes none, so the payload must not name a generation either. - expect(settle.refsGeneration).toBeUndefined(); - expect(settle.hint).toContain('different surfaces'); - expect(settle.hint).toMatch(/take a snapshot/i); - expect(settle.settled).toBe(true); - expect(settle.captures).toBeGreaterThanOrEqual(2); - expect(settle.quietMs).toBe(25); - expect(settle.timeoutMs).toBe(2_000); -} - -/** No refs were published, so the frame the mutating leaf expired stays expired (ADR 0014). */ -function expectNoPublishedRefFrame(sessionStore: SessionStore, sessionName: string): SessionState { - const stored = sessionStore.get(sessionName) as SessionState; - expect(refFrameState(stored)).toBe('expired'); - return stored; -} - -test('scroll --settle attaches no diff across an app-to-sheet surface change and discloses it', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-scroll-to-sheet'; - seedSession(sessionName, sessionStore); - // The stored pre-action tree is app content; every settle capture reads the sheet now presented - // over that still-foreground app. - mockSurfaceDispatch({ nodes: WEB_SIGN_IN_SHEET_NODES, systemSurface: WEB_SIGN_IN_SHEET }); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'scroll', - positionals: ['down'], - flags: { ...SETTLE_FLAGS }, - }); - - expectCrossSurfaceSettle(expectOkData(response).settle as SettlePayload, { - from: 'app', - to: WEB_SIGN_IN_SHEET.bundleId, - disclosure: WEB_SIGN_IN_DISCLOSURE, - }); - // Disclosed, not hidden: the settled sheet still becomes the stored observation a follow-up - // snapshot reads — and the surface identity the NEXT command's baseline is built from. - const stored = expectNoPublishedRefFrame(sessionStore, sessionName); - expect(stored.snapshot?.iosSystemSurfaceBundleId).toBe(WEB_SIGN_IN_SHEET.bundleId); -}); - -test('scroll --settle attaches no diff across a sheet-to-app surface change and discloses it', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-scroll-from-sheet'; - // The baseline is the sheet this scroll acts on; the settled tree is the app content that - // returns once the sheet completes and dismisses itself. - seedSession(sessionName, sessionStore, { - nodes: WEB_SIGN_IN_SHEET_NODES, - systemSurface: WEB_SIGN_IN_SHEET, - }); - mockSurfaceDispatch({ nodes: APP_CONTENT_NODES }); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'scroll', - positionals: ['down'], - flags: { ...SETTLE_FLAGS }, - }); - - const settle = expectOkData(response).settle as SettlePayload; - expectCrossSurfaceSettle(settle, { - from: WEB_SIGN_IN_SHEET.bundleId, - to: 'app', - // The sheet is gone, so the standing "is presented over the app" sentence cannot be the one - // used — the transition disclosure has to say it left. - disclosure: WEB_SIGN_IN_DEPARTED_DISCLOSURE, - }); - expect(settle.surfaceChange?.disclosure).not.toBe(WEB_SIGN_IN_DISCLOSURE); - expect(settle.surfaceChange?.disclosure).toMatch(/sign-in sheet/); - const stored = expectNoPublishedRefFrame(sessionStore, sessionName); - expect(stored.snapshot?.iosSystemSurfaceBundleId).toBeUndefined(); -}); - -test('back --settle attaches no diff across an app-to-sheet surface change and discloses it', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-back-to-sheet'; - seedSession(sessionName, sessionStore); - mockSurfaceDispatch( - { nodes: WEB_SIGN_IN_SHEET_NODES, systemSurface: WEB_SIGN_IN_SHEET }, - { - action: 'back', - mode: 'in-app', - message: 'Back', - }, - ); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'back', - flags: { ...SETTLE_FLAGS }, - }); - - const data = expectOkData(response); - // The refusal rides ALONGSIDE the command's own closed result shape, same as the diff does. - expect(data.action).toBe('back'); - expect(data.message).toBe('Back'); - expectCrossSurfaceSettle(data.settle as SettlePayload, { - from: 'app', - to: WEB_SIGN_IN_SHEET.bundleId, - disclosure: WEB_SIGN_IN_DISCLOSURE, - }); - const stored = expectNoPublishedRefFrame(sessionStore, sessionName); - expect(stored.snapshot?.iosSystemSurfaceBundleId).toBe(WEB_SIGN_IN_SHEET.bundleId); -}); - -test('back --settle attaches no diff across a sheet-to-app surface change and discloses it', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-back-from-sheet'; - seedSession(sessionName, sessionStore, { - nodes: WEB_SIGN_IN_SHEET_NODES, - systemSurface: WEB_SIGN_IN_SHEET, - }); - mockSurfaceDispatch( - { nodes: APP_CONTENT_NODES }, - { - action: 'back', - mode: 'in-app', - message: 'Back', - }, - ); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'back', - flags: { ...SETTLE_FLAGS }, - }); - - const data = expectOkData(response); - expect(data.action).toBe('back'); - expectCrossSurfaceSettle(data.settle as SettlePayload, { - from: WEB_SIGN_IN_SHEET.bundleId, - to: 'app', - disclosure: WEB_SIGN_IN_DEPARTED_DISCLOSURE, - }); - const stored = expectNoPublishedRefFrame(sessionStore, sessionName); - expect(stored.snapshot?.iosSystemSurfaceBundleId).toBeUndefined(); -}); - -/** - * `back --settle` over a sheet left on the session by a capture of one given flavour, which is how - * the generic route picks its baseline (`generic-settle.ts` reads the stored tree directly). The - * settle loop's budget arithmetic runs on the injected clock, so no caller waits real time. - */ -async function dismissSheetFromBaseline( - sessionName: string, - baseline: GenericSettleTree, - options: { outdated?: boolean; captures?: RawSnapshotNode[][] } = {}, -): Promise { - const sessionStore = makeSessionStore(); - const session = seedSession(sessionName, sessionStore, baseline); - if (options.outdated) markSessionSnapshotOutdated(session); - // Rootless, as the first reads after a dismissal can be: only a presented baseline arms on them. - mockCommandDispatch(options.captures ?? [SHEET_DISMISSED_NODES.slice(1)]); - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'back', - flags: { settle: true, settleQuietMs: 25, timeoutMs: 30_000 }, - clock: fakeClock(), - }); - return expectOkData(response).settle as SettlePayload; -} - -test('the confirmation arms for one presented sheet whichever baseline flavour the route holds', async () => { - // The interactive capture projects the sheet as 4 nodes and the full read as 6, so a rule keyed on - // the projection disarms on the full one. The producer's fact is stamped before projection. - const interactive = await dismissSheetFromBaseline('generic-settle-dismiss-sheet-interactive', { - nodes: PRESENTED_SHEET_INTERACTIVE_NODES, - presentedSurface: 'presented', - }); - const full = await dismissSheetFromBaseline('generic-settle-dismiss-sheet-full', { - nodes: PRESENTED_SHEET_FULL_NODES, - presentedSurface: 'presented', - }); - - expect(interactive.settled).toBe(true); - expect(full.settled).toBe(true); - expect(interactive.waitedMs).toBeGreaterThanOrEqual(1_500); - expect(full.waitedMs).toBeGreaterThanOrEqual(1_500); -}); - -test('back --settle keeps the plain window when the stored tree presented nothing', async () => { - // The same broad replacement, stamped `absent` by its producer: the stored tree's fact reaches - // the loop, and a screen with nothing presented over it owes no dismissal confirmation (#3470). - const settle = await dismissSheetFromBaseline( - 'generic-settle-absent', - { nodes: PRESENTED_SHEET_FULL_NODES, presentedSurface: 'absent' }, - { captures: [SHEET_DISMISSED_NODES] }, - ); - - expect(settle.settled).toBe(true); - expect(settle.waitedMs).toBeLessThan(1_500); -}); - -test('back --settle keeps the confirmation when a side effect outdated an absent stored tree', async () => { - // The app screen was read `absent`, then a press presented the sheet without a capture. The - // stored fact predates the sheet, so it cannot rule out the confirmation its dismissal owes. - const settle = await dismissSheetFromBaseline( - 'generic-settle-outdated-absent', - { nodes: SHEET_DISMISSED_NODES, presentedSurface: 'absent' }, - { outdated: true, captures: [PRESENTED_SHEET_INTERACTIVE_NODES, SHEET_DISMISSED_NODES] }, - ); - - expect(settle.settled).toBe(true); - expect(settle.waitedMs).toBeGreaterThanOrEqual(1_500); -}); - -test('the settle observation runs after the post-gesture stabilization marker', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-order'; - seedSession(sessionName, sessionStore); - mockCommandDispatch([AFTER_NODES]); - - await dispatchGeneric({ - sessionName, - sessionStore, - command: 'scroll', - positionals: ['down'], - flags: { ...SETTLE_FLAGS }, - }); - - // #1542 ordering: scroll marks a pending stabilization, and settle's FIRST - // capture must already see it so the capture folds the stabilization in - // instead of racing it. - expect(captureObservations.length).toBeGreaterThan(0); - expect(captureObservations[0]?.postGestureStabilizationPending).toBe(true); -}); - -test('scroll --settle observes through the request-bound capture, once admitted', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-bound-capture'; - // A tracked app: the plan this request must admit is the active-app one. The fixture refuses the - // without-active-app plan, so an observation that resolved the wrong cell would degrade to the - // legacy dispatch below and fail these assertions rather than pass by accident. - seedSession( - sessionName, - sessionStore, - { nodes: BEFORE_NODES }, - { appBundleId: 'com.example.app' }, - ); - mockCommandDispatch([AFTER_NODES]); - const runtimeBindings = selectorCaptureFixture({ - withoutActiveApp: { available: false, reason: 'owner-capability-missing' }, - snapshot: () => ({ - nodes: AFTER_NODES, - backend: 'xctest', - producer: 'simulator-ax-bridge', - }), - }); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'scroll', - positionals: ['down'], - flags: { ...SETTLE_FLAGS }, - runtimeBindings, - }); - - const settle = expectOkData(response).settle as SettlePayload; - expect(settle.settled).toBe(true); - // One admit-and-bind for the whole observation, and every capture taken through it: the settled - // tree and the tree the caller's next `snapshot` reads are the same producer's output (#3510). - expect(runtimeBindings.binds.length).toBe(1); - expect(runtimeBindings.captures.length).toBe(settle.captures); - expect(mockDispatch).not.toHaveBeenCalledWith('snapshot'); - expect((sessionStore.get(sessionName) as SessionState).snapshot?.producer).toBe( - 'simulator-ax-bridge', - ); -}); - -test('scroll --settle still observes when the owner cannot capture', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-unbound-capture'; - seedSession(sessionName, sessionStore); - mockCommandDispatch([AFTER_NODES]); - const runtimeBindings = selectorCaptureFixture({ - capture: { available: false, reason: 'unsupported-platform-leaf' }, - }); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'scroll', - positionals: ['down'], - flags: { ...SETTLE_FLAGS }, - runtimeBindings, - }); - - // The owner's own snapshot path is its only snapshot path: the observation stays complete and - // nothing pretends a second capture implementation produced it. - expect(expectOkData(response).settle).toMatchObject({ settled: true }); - expect(runtimeBindings.binds).toEqual([]); - expect(mockDispatch).toHaveBeenCalledWith('snapshot'); -}); - -test('scroll without --settle takes no observation captures and issues no refs', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-off'; - seedSession(sessionName, sessionStore); - mockCommandDispatch([AFTER_NODES]); - const runtimeBindings = selectorCaptureFixture(); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'scroll', - positionals: ['down'], - runtimeBindings, - }); - - expect(expectOkData(response).settle).toBeUndefined(); - expect(captureObservations).toEqual([]); - // Settling is what asks for an observation capture, so a plain scroll never pays for one. - expect(runtimeBindings.binds).toEqual([]); - // ADR 0014: the leaf side-effect seam expired the frame and nothing - // re-published it. - expect(refFrameState(sessionStore.get(sessionName) as SessionState)).toBe('expired'); -}); - -test('an ended generic lifetime is refused before dispatch or settle construction', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-evicted'; - seedSession(sessionName, sessionStore); - const ref = sessionStore.lookup(sessionName)!; - sessionStore.retire(ref); - mockCommandDispatch([AFTER_NODES]); - await expect( - dispatchGenericCommand({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - token: 't', - session: sessionName, - command: 'scroll', - positionals: ['down'], - flags: SETTLE_FLAGS, - }, - ref, - sessionName, - sessionStore, - logPath: '', - contextFromFlags, - executePlatformCommand: platformExecution, - ...legacyCaptureRuntimeBindings(), - }), - ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); - expect(mockDispatch).not.toHaveBeenCalled(); - expect(captureObservations).toEqual([]); -}); - -test('a generic command without the observation trait ignores a stray settle flag', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-traitless'; - seedSession(sessionName, sessionStore); - mockDispatch.mockResolvedValue({ action: 'home', message: 'Home' }); - - // A hand-built daemon request can put settle flags on any command; only the - // descriptor trait admits them to the observation path. `home` has no trait, - // so the flag is ignored: no captures, no settle payload, no rejection. - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'home', - flags: { settle: true }, - }); - - expect(expectOkData(response).settle).toBeUndefined(); - expect(captureObservations).toEqual([]); -}); - -test('an orphaned --settle-quiet is rejected before the command dispatches', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-orphan'; - seedSession(sessionName, sessionStore); - mockDispatch.mockRejectedValue(new Error('dispatch must not run for an orphaned settle flag')); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'scroll', - positionals: ['down'], - flags: { settleQuietMs: 25 }, - }); - - expect(response.ok).toBe(false); - if (response.ok !== false) throw new Error('expected a rejected daemon response'); - expect(response.error?.code).toBe('INVALID_ARGS'); - expect(response.error?.message).toContain('--settle-quiet'); -}); - -test('scroll stays successful when its lifetime retires during optional settle observation', async () => { - const store = makeSessionStore(); - const name = 'generic-settle-retired'; - seedSession(name, store); - const ref = store.lookup(name)!; - mockCommandDispatch([AFTER_NODES]); - mockCaptureSnapshotForSession.mockImplementationOnce( - async (boundRef, flags, sessionStore, _context, options) => { - const observed = await emulateCaptureSnapshotForSession( - boundRef, - flags, - sessionStore, - options, - ); - queueMicrotask(() => { - store.retire(ref); - store.publish(name, makeIosSession(name)); - }); - return observed; - }, - ); - const response = await dispatchGeneric({ - sessionName: name, - sessionStore: store, - command: 'scroll', - positionals: ['down'], - flags: { ...SETTLE_FLAGS }, - }); - expect(expectOkData(response).settle).toBeUndefined(); - expect(store.get(name)?.snapshot).toBeUndefined(); - expect(store.get(name)?.actions).toEqual([]); -}); - -for (const change of ['rebuild', 'retire'] as const) { - test(`held generic settle capture respects its lifetime after ${change}`, async () => { - const store = makeSessionStore(); - const name = `generic-held-settle-${change}`; - seedSession(name, store); - const ref = store.lookup(name)!; - let entered!: () => void; - let release!: () => void; - const reached = new Promise((resolve) => { - entered = resolve; - }); - const held = new Promise((resolve) => { - release = resolve; - }); - mockDispatch.mockImplementation(async (command) => { - if (command !== 'snapshot') return {}; - entered(); - await held; - return snapshotPayload({ nodes: AFTER_NODES }); - }); - const pending = dispatchGeneric({ - sessionName: name, - sessionStore: store, - command: 'scroll', - positionals: ['down'], - flags: { ...SETTLE_FLAGS }, - }); - try { - await reached; - const trace = { outPath: 'latest-trace', startedAt: 1 }; - const successor = makeIosSession(name); - if (change === 'rebuild') store.update(ref, { trace }); - else { - store.retire(ref); - store.publish(name, successor); - } - release(); - const data = expectOkData(await pending); - if (change === 'rebuild') { - expect(store.requireCurrent(ref).trace).toBe(trace); - expect(store.requireCurrent(ref).snapshot?.nodes[1]?.label).toBe('Load more'); - } else { - expect(data.settle).toBeUndefined(); - expect(store.get(name)).toBe(successor); - expect(successor.snapshot).toBeUndefined(); - } - } finally { - release(); - await pending; - } - }); -} - -// The default quiet window keys on the capture the settle loop PERFORMS (#3470 follow-up), not on -// the baseline it diffs: on this route the baseline is the session's stored tree, which can be -// several commands stale and served by a different producer. All three run on an injected clock, so -// they assert the resolved window without real waiting. -test('scroll --settle without --settle-quiet uses the shorter default when the loop reads the simulator bridge', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-default-quiet'; - seedSession(sessionName, sessionStore, { - nodes: BEFORE_NODES, - producer: 'simulator-ax-bridge', - }); - mockCommandDispatch([AFTER_NODES], 'simulator-ax-bridge'); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'scroll', - positionals: ['down'], - // No settleQuietMs: the route's own default window is what is under test. - flags: { settle: true, timeoutMs: 2_000 }, - clock: fakeClock(), - }); - - const settle = expectOkData(response).settle as SettlePayload; - expect(settle.settled).toBe(true); - expect(settle.quietMs).toBe(300); -}); - -test('scroll --settle keeps the 500ms default when the loop reads the Apple runner', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-default-quiet-runner'; - seedSession(sessionName, sessionStore, { - nodes: BEFORE_NODES, - producer: 'apple-runner', - }); - mockCommandDispatch([AFTER_NODES], 'apple-runner'); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'scroll', - positionals: ['down'], - flags: { settle: true, timeoutMs: 2_000 }, - clock: fakeClock(), - }); - - const settle = expectOkData(response).settle as SettlePayload; - expect(settle.settled).toBe(true); - expect(settle.quietMs).toBe(500); -}); - -test('a stale bridge-sourced baseline does not shorten a settle the Apple runner performs', async () => { - // The mismatch the stored baseline can carry: the tree on file came from the bridge, but the - // captures that pay this window come from the runner, whose read the short window was never - // measured on. Keying on the baseline would hold 300ms against an unmeasured read. - const sessionStore = makeSessionStore(); - const sessionName = 'generic-settle-default-quiet-mismatch'; - seedSession(sessionName, sessionStore, { - nodes: BEFORE_NODES, - producer: 'simulator-ax-bridge', - }); - mockCommandDispatch([AFTER_NODES], 'apple-runner'); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - command: 'scroll', - positionals: ['down'], - flags: { settle: true, timeoutMs: 2_000 }, - clock: fakeClock(), - }); - - const settle = expectOkData(response).settle as SettlePayload; - expect(settle.settled).toBe(true); - expect(settle.quietMs).toBe(500); -}); diff --git a/src/__tests__/daemon-composition/gesture-admission-parity.test.ts b/src/__tests__/daemon-composition/gesture-admission-parity.test.ts deleted file mode 100644 index 40b45e79b0..0000000000 --- a/src/__tests__/daemon-composition/gesture-admission-parity.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; -import { expect, test } from 'vitest'; -import type { - GestureCommandInput, - GestureSemanticInput, -} from '@agent-device/contracts/gesture-plan-types'; -import { - normalizePublicGesture, - normalizePublicSwipeMotion, -} from '@agent-device/contracts/gesture-normalization'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; -import { createRequestRuntimeBindings } from '@agent-device/daemon/testing/request-runtime-binding'; -import { createRequestDispatchLedger } from '@agent-device/daemon/testing/request-dispatch-ledger'; -import { resolveBoundGestureRuntime } from '@agent-device/daemon/testing/gesture-runtime'; - -/** - * The parity artifact for R42/R44 (ADR 0019 §6). - * - * This is the retired `requireGestureSupported` suite's device matrix, re-pointed at what - * replaced it: the REAL composed runtime gateway's owner facts, admitted through the real daemon - * gesture admission. Every assertion below — admitted or refused, message and hint — is the - * behavior `main` produced before the cutover, so a fact cell that drifts from the admission it - * restates fails here rather than on a device. - */ -const gateway = createPlatformRuntimeGateway({ - resolveSessionArtifacts: () => ({ - outputPath: '/sessions/parity/app.log', - pidPath: '/sessions/parity/app-log.pid', - }), - sessionsDir: '/sessions', -}); - -const oneFingerPan: GestureSemanticInput = { - intent: 'pan', - origin: { x: 100, y: 200 }, - delta: { x: 40, y: -20 }, -}; -const twoFingerPan: GestureSemanticInput = { ...oneFingerPan, pointerCount: 2 }; -const pinch: GestureSemanticInput = { intent: 'pinch', scale: 1.2 }; -const fling: GestureSemanticInput = { - intent: 'fling', - direction: 'left', - origin: { x: 100, y: 200 }, -}; -const drag: GestureCommandInput = { - intent: 'drag', - source: 'id="source"', - destination: 'id="destination"', -}; - -const device = (fields: Partial): DeviceInfo => ({ - platform: 'android', - id: 'test-device', - name: 'Test device', - kind: 'emulator', - ...fields, -}); - -/** The production binding seam, so admission runs against the real inspect-then-bind path. */ -async function admit(input: GestureCommandInput, target: DeviceInfo) { - const bindings = createRequestRuntimeBindings({ - dispatchLedger: createRequestDispatchLedger(), - gateway, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - admitDeviceClaim: async () => {}, - }); - try { - return await resolveBoundGestureRuntime({ - platformServices: composedPlatformServicesFixture(), - device: target, - input, - inspectFacts: bindings.inspectFacts, - bindDevice: bindings.bindDevice, - }); - } finally { - await bindings[Symbol.asyncDispose](); - } -} - -async function expectAdmitted(input: GestureCommandInput, target: DeviceInfo): Promise { - const resolved = await admit(input, target); - expect(resolved.ok, `${input.intent} should be admitted on ${target.platform}`).toBe(true); -} - -async function expectRefused( - input: GestureCommandInput, - target: DeviceInfo, - message: RegExp, - hint?: RegExp, -): Promise { - const resolved = await admit(input, target); - expect(resolved.ok).toBe(false); - if (resolved.ok) return; - expect(resolved.response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(resolved.response.error.message).toMatch(message); - if (hint) expect(String(resolved.response.error.hint)).toMatch(hint); -} - -test('Android phones and emulators admit single- and multi-touch gesture plans', async () => { - for (const kind of ['device', 'emulator'] as const) { - const target = device({ kind }); - await expectAdmitted(oneFingerPan, target); - await expectAdmitted(twoFingerPan, target); - await expectAdmitted(pinch, target); - } -}); - -test('target-authored drag is admitted only where adapters preserve every authored phase', async () => { - for (const kind of ['device', 'emulator'] as const) { - await expectAdmitted(drag, device({ kind, target: 'mobile' })); - } - for (const appleOs of ['ios', 'ipados'] as const) { - for (const kind of ['device', 'simulator'] as const) { - await expectAdmitted(drag, device({ platform: 'apple', appleOs, kind, target: 'mobile' })); - } - } - await expectAdmitted(drag, device({ platform: 'apple', kind: 'simulator', target: 'mobile' })); - - const inexactBackends = [ - device({ target: 'tv' }), - device({ platform: 'apple', appleOs: 'tvos', kind: 'simulator', target: 'tv' }), - device({ platform: 'apple', appleOs: 'macos', kind: 'device', target: 'desktop' }), - device({ platform: 'apple', appleOs: 'visionos', kind: 'simulator' }), - device({ platform: 'apple', appleOs: 'watchos', kind: 'simulator' }), - device({ platform: 'linux', kind: 'device', target: 'desktop' }), - device({ platform: 'vega', kind: 'device', target: 'tv' }), - device({ platform: 'web', kind: 'device', target: 'desktop' }), - ]; - for (const target of inexactBackends) { - await expectRefused( - drag, - target, - /^gesture drag is not supported on /, - /source hold, timed movement, and destination hold/, - ); - } -}); - -test('iOS and iPadOS simulators admit multi-touch while physical devices do not', async () => { - for (const appleOs of ['ios', 'ipados'] as const) { - const simulator = device({ platform: 'apple', appleOs, kind: 'simulator' }); - const physical = device({ platform: 'apple', appleOs, kind: 'device' }); - await expectAdmitted(oneFingerPan, simulator); - await expectAdmitted(twoFingerPan, simulator); - await expectAdmitted(pinch, simulator); - await expectAdmitted(oneFingerPan, physical); - await expectRefused(twoFingerPan, physical, /physical iOS devices/); - await expectRefused(pinch, physical, /physical iOS devices/, /iOS-simulator only/); - } -}); - -test('TV, spatial, watch, desktop, Linux, and web gesture policy stays explicit', async () => { - const androidTv = device({ target: 'tv' }); - const tvOs = device({ platform: 'apple', appleOs: 'tvos', kind: 'simulator', target: 'tv' }); - const visionOs = device({ platform: 'apple', appleOs: 'visionos', kind: 'simulator' }); - const watchOs = device({ platform: 'apple', appleOs: 'watchos', kind: 'simulator' }); - const macOs = device({ platform: 'apple', appleOs: 'macos', kind: 'device', target: 'desktop' }); - const linux = device({ platform: 'linux', kind: 'device', target: 'desktop' }); - const web = device({ platform: 'web', kind: 'device', target: 'desktop' }); - - await expectAdmitted(oneFingerPan, androidTv); - await expectRefused(twoFingerPan, androidTv, /Android TV/, /Android TV has no touch input/); - await expectRefused(twoFingerPan, tvOs, /tvOS/); - await expectRefused(twoFingerPan, visionOs, /visionOS/); - await expectRefused(oneFingerPan, watchOs, /watchos/); - await expectAdmitted(oneFingerPan, macOs); - await expectRefused(twoFingerPan, macOs, /macOS/); - await expectAdmitted(oneFingerPan, linux); - await expectAdmitted( - normalizePublicSwipeMotion({ from: { x: 10, y: 20 }, to: { x: 110, y: 20 } }).gesture, - linux, - ); - await expectAdmitted(normalizePublicGesture({ kind: 'swipe', preset: 'left' }).gesture, linux); - await expectRefused(fling, linux, /gesture fling is not supported on Linux/); - await expectRefused(twoFingerPan, linux, /linux/); - await expectRefused(oneFingerPan, web, /web/); -}); diff --git a/src/__tests__/daemon-composition/http-server-limrun-uploaded-apps.test.ts b/src/__tests__/daemon-composition/http-server-limrun-uploaded-apps.test.ts deleted file mode 100644 index 3ff8eef2ef..0000000000 --- a/src/__tests__/daemon-composition/http-server-limrun-uploaded-apps.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import assert from 'node:assert/strict'; - -import path from 'node:path'; -import { test, vi } from 'vitest'; -import { createLimrunRuntime } from '@agent-device/provider-limrun'; -import { createProviderDeviceRuntimeRequestProviders } from '../../provider-device-runtime.ts'; -import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { - closeLoopbackServer, - listenOnLoopback, - mkdtempForTestSync, - skipWhenLoopbackUnavailable, -} from '@agent-device/test-kit'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { limrunTestDependencies } from '../../platform-runtime-gateway.fixtures.ts'; -import { - DAEMON_HTTP_NETWORK_ACCESS_HEADER, - DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, -} from '@agent-device/contracts/daemon-http'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { createDaemonHttpServer } from '@agent-device/daemon/testing/server/http-server'; -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; - -const limrunIo = vi.hoisted(() => ({ - listAssets: vi.fn(), - createAndroidInstance: vi.fn(), -})); - -type RpcResponse = { error?: { data?: { code?: string } } }; - -vi.mock('@limrun/api', () => ({ - default: class MockLimrun { - readonly assets = { list: limrunIo.listAssets }; - readonly androidInstances = { - create: limrunIo.createAndroidInstance, - delete: vi.fn(), - list: vi.fn(), - }; - readonly iosInstances = { create: vi.fn(), delete: vi.fn(), list: vi.fn() }; - }, -})); - -test('public HTTP rejects Limrun uploaded-app listing and allocation before provider I/O', async (t) => { - if (await skipWhenLoopbackUnavailable(t)) return; - limrunIo.listAssets.mockClear(); - limrunIo.createAndroidInstance.mockClear(); - const runtime = createLimrunRuntime( - { apiKey: 'lim_test_key', runtimeInstance: 'http-access-test' }, - limrunTestDependencies, - ); - const providers = createProviderDeviceRuntimeRequestProviders([runtime]); - const token = 'limrun-http-test-token'; - const leaseRegistry = new LeaseRegistry(); - const handleRequest = createRequestHandler({ - createCommandSurface: testCreateCommandSurface, - logPath: path.join( - mkdtempForTestSync('agent-device-limrun-http-access'), - 'agent-device-limrun-http-access.log', - ), - token, - sessionStore: makeSessionStore('agent-device-limrun-http-access-'), - leaseRegistry, - deviceInventoryGateways: createTestDeviceInventoryGateways(), - providerRuntimeIds: providers.providerRuntimeIds, - providerRuntimeRequiredIds: providers.providerRuntimeRequiredIds, - leaseLifecycleProvider: providers.leaseLifecycleProvider, - providerAppCatalog: providers.providerAppCatalog, - providerDeviceRuntimeScope: providers.providerDeviceRuntimeScope, - trackDownloadableArtifact: () => 'unused-artifact', - }); - const server = await createDaemonHttpServer({ token, leaseRegistry, handleRequest }); - - try { - const port = await listenOnLoopback(server); - const apps = await callRpc(port, token, { - jsonrpc: '2.0', - id: 'limrun-public-apps', - method: 'agent_device.command', - params: { - command: 'apps', - positionals: [], - flags: { platform: 'android', leaseProvider: 'limrun' }, - }, - }); - const allocation = await callRpc(port, token, { - jsonrpc: '2.0', - id: 'limrun-public-allocation', - method: 'agent_device.lease.allocate', - params: { - tenantId: 'tenant-a', - runId: 'run-a', - backend: 'android-instance', - leaseProvider: 'limrun', - providerApp: 'Example.apk', - }, - }); - - assert.equal(apps.error?.data?.code, 'UNAUTHORIZED'); - assert.equal(allocation.error?.data?.code, 'UNAUTHORIZED'); - assert.equal(limrunIo.listAssets.mock.calls.length, 0); - assert.equal(limrunIo.createAndroidInstance.mock.calls.length, 0); - } finally { - await closeLoopbackServer(server); - await runtime.shutdown(); - } -}); - -async function callRpc( - port: number, - token: string, - payload: Record, -): Promise { - const response = await fetch(`http://127.0.0.1:${port}/rpc`, { - method: 'POST', - headers: { - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - [DAEMON_HTTP_NETWORK_ACCESS_HEADER]: DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, - }, - body: JSON.stringify(payload), - }); - return (await response.json()) as RpcResponse; -} diff --git a/src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts b/src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts deleted file mode 100644 index 4a9a00e115..0000000000 --- a/src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - createLifecycleDeviceRuntimeGateway, - createLifecycleDeviceRuntimeGatewaySpies, -} from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { composedLifecycleRuntimeFixture } from './application-lifecycle-runtime-fixture.ts'; - -/** - * The lifecycle gateway over the REAL composed platform runtime (root gateway + apple/android - * application tools). Composition suites that pin what the real runtime modules answer bind this; - * daemon suites bind the canned port fixture instead. - */ -export const realLifecycleDeviceRuntimeGateway = createLifecycleDeviceRuntimeGateway( - composedLifecycleRuntimeFixture, -); - -export const createRealLifecycleDeviceRuntimeGatewaySpies = () => - createLifecycleDeviceRuntimeGatewaySpies(composedLifecycleRuntimeFixture); diff --git a/src/__tests__/daemon-composition/perf-capture-session-resource.test.ts b/src/__tests__/daemon-composition/perf-capture-session-resource.test.ts deleted file mode 100644 index 6107ebb24e..0000000000 --- a/src/__tests__/daemon-composition/perf-capture-session-resource.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { bindSessionPerfCapture } from '@agent-device/daemon/testing/session-capture-binding'; -import { beforeEach, expect, test, vi } from 'vitest'; -import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; -import { AppError } from '@agent-device/kernel/errors'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { createPerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; -import { perfCaptureResourceStore } from '@agent-device/capture-kit/perf-capture-resource-store'; -import { - adoptStartedPerfCapture, - finishLivePerfCapture, -} from '@agent-device/capture-kit/perf-capture-session-resource'; -import { startAndroidPerfCapture } from '../../platform-runtime-perf-capture-host.ts'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; - -const androidNative = vi.hoisted(() => ({ - startAndroidSimpleperfProfile: vi.fn(), - startAndroidPerfettoTrace: vi.fn(), - stopAndroidSimpleperfProfile: vi.fn(), - stopAndroidPerfettoTrace: vi.fn(), - cleanupAndroidNativePerfSession: vi.fn(), -})); - -vi.mock('@agent-device/platform-android/mechanics', () => androidNative); - -const device: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - target: 'mobile', - booted: true, -}; -const fence = { token: 'fence', generation: 1 }; -const REMOTE_TRACE = '/data/misc/perfetto-traces/agent-device-app.trace'; - -beforeEach(() => { - vi.clearAllMocks(); -}); - -/** - * ADR 0024 rule 6 for perf-capture: the retry needs the profiler artifact on the device. The stop's - * own hint asks for a retry of the same session, and this kind's forced cleanup is the `rm -f` that - * would empty that promise, so a failed stop preserves and the next stop re-pulls. - */ -test('a perf stop whose pull failed re-collects the device-side trace the first stop preserved', async () => { - const sessionStore = makeSessionStore('perf-capture-failed-finish-'); - const sessionName = 'capture'; - const session: SessionState = { - name: sessionName, - device, - createdAt: 1, - actions: [], - }; - sessionStore.publish(sessionName, session); - const capture = { - action: 'start' as const, - kind: 'perfetto' as const, - type: 'trace' as const, - packageName: 'com.example.app', - appPid: '101', - profilerPid: '202', - remotePath: REMOTE_TRACE, - outPath: '/tmp/capture', - startedAt: 1, - state: 'running' as const, - method: 'native', - message: 'started', - }; - const remoteTraces = new Set([REMOTE_TRACE]); - let pullFails = true; - const pullFailure = new AppError('COMMAND_FAILED', 'Failed to pull Android perfetto artifact', { - package: capture.packageName, - tool: 'perfetto', - remotePath: REMOTE_TRACE, - hint: 'Check that the profiling command ran long enough to create an artifact, then retry stop with the same session.', - }); - androidNative.startAndroidPerfettoTrace.mockImplementation(async () => { - remoteTraces.add(capture.remotePath); - return capture; - }); - androidNative.stopAndroidPerfettoTrace.mockImplementation( - async (_device: DeviceInfo, current: typeof capture, outPath: string) => { - if (pullFails) throw pullFailure; - if (!remoteTraces.has(current.remotePath)) { - throw new AppError('COMMAND_FAILED', 'Failed to pull Android perfetto artifact'); - } - remoteTraces.delete(current.remotePath); - return { - ...current, - action: 'stop', - state: 'stopped', - outPath, - stoppedAt: 3, - durationMs: 2, - summary: { packets: 4 }, - }; - }, - ); - androidNative.cleanupAndroidNativePerfSession.mockImplementation( - async (_device: DeviceInfo, current: typeof capture) => { - remoteTraces.delete(current.remotePath); - }, - ); - - const binding = bindSessionPerfCapture(sessionStore, sessionStore.lookup(sessionName)!); - const started = await startAndroidPerfCapture(device, localRuntimeOwner('android'), { - sessionId: sessionName, - appId: capture.packageName, - kind: 'perfetto', - mode: 'trace', - outPath: '/tmp/capture', - fence, - }); - await adoptStartedPerfCapture({ - admissionLedger: createPerfCaptureAdmissionLedger(), - binding, - device, - owner: localRuntimeOwner('android'), - fence, - pendingHandle: started.pendingHandle, - envelope: started.envelope, - throwIfCanceled: () => {}, - }); - const resourcePath = perfCaptureResourceStore.resolvePath( - sessionStore.resolveSessionDir(sessionName), - ); - const stop = () => - finishLivePerfCapture({ - intent: 'capture', - binding, - }); - - await expect(stop()).rejects.toBe(pullFailure); - expect(androidNative.cleanupAndroidNativePerfSession).not.toHaveBeenCalled(); - expect(remoteTraces).toEqual(new Set([REMOTE_TRACE])); - expect(sessionStore.get(sessionName)?.perfCapture?.handle).toBeDefined(); - expect(perfCaptureResourceStore.read(resourcePath)).toMatchObject({ - status: 'decoded', - envelope: { lifecycle: 'open', metadata: { phase: 'completing' } }, - }); - - pullFails = false; - await expect(stop()).resolves.toMatchObject({ kind: 'perfetto', state: 'stopped' }); - expect(remoteTraces).toEqual(new Set()); - expect(sessionStore.get(sessionName)?.perfCapture).toBeUndefined(); -}); diff --git a/src/__tests__/daemon-composition/presented-sheet-trees.ts b/src/__tests__/daemon-composition/presented-sheet-trees.ts deleted file mode 100644 index 112821fbfc..0000000000 --- a/src/__tests__/daemon-composition/presented-sheet-trees.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; - -/** - * One presented iOS `pageSheet`, in the two flavours a settle baseline can arrive as, plus the app - * screen a dismissal returns to. An interactive capture carries only the surface — the content - * underneath is not hittable, so it drops out of the projection (4 nodes on the iPhone 18 Pro sim, - * October 2026) — while a full read of the very same screen also reports the covered content (6 - * nodes). Arming the broad-transition confirmation must not depend on which flavour produced the - * baseline, which is what the caller that reads these asserts. - */ -export const PRESENTED_SHEET_INTERACTIVE_NODES: RawSnapshotNode[] = [ - { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, - { - index: 1, - parentIndex: 0, - type: 'StaticText', - label: 'Allow notifications?', - rect: { x: 10, y: 450, width: 370, height: 22 }, - }, - { - index: 2, - parentIndex: 0, - type: 'Button', - label: 'Continue', - rect: { x: 10, y: 600, width: 370, height: 44 }, - hittable: true, - }, - { - index: 3, - parentIndex: 0, - type: 'Button', - label: 'Cancel', - rect: { x: 10, y: 660, width: 370, height: 44 }, - hittable: true, - }, -]; - -export const PRESENTED_SHEET_FULL_NODES: RawSnapshotNode[] = [ - ...PRESENTED_SHEET_INTERACTIVE_NODES, - { - index: 4, - parentIndex: 0, - type: 'Other', - label: 'CoveredContent', - rect: { x: 0, y: 0, width: 390, height: 440 }, - }, - { - index: 5, - parentIndex: 0, - type: 'Button', - label: 'Open settings', - rect: { x: 10, y: 120, width: 370, height: 44 }, - }, -]; - -/** The app screen the sheet is dismissed from, which is what every settle capture then reads. */ -export const SHEET_DISMISSED_NODES: RawSnapshotNode[] = [ - { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, - { - index: 1, - parentIndex: 0, - type: 'StaticText', - label: 'Signed in as demo', - rect: { x: 10, y: 80, width: 200, height: 22 }, - }, - { - index: 2, - parentIndex: 0, - type: 'Button', - label: 'Home', - rect: { x: 10, y: 120, width: 370, height: 44 }, - hittable: true, - }, - { - index: 3, - parentIndex: 0, - type: 'TabBar', - label: 'Tab Bar', - rect: { x: 0, y: 800, width: 390, height: 44 }, - }, -]; diff --git a/src/__tests__/daemon-composition/request-platform-providers.test.ts b/src/__tests__/daemon-composition/request-platform-providers.test.ts deleted file mode 100644 index 9881ec8753..0000000000 --- a/src/__tests__/daemon-composition/request-platform-providers.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { - ANDROID_EMULATOR, - IOS_SIMULATOR, - MACOS_DEVICE, - WEB_DESKTOP_DEVICE, -} from '@agent-device/test-kit'; -import { - makeAndroidSession, - makeIosSession, - makeMacOsSession, - makeSession, -} from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { withTestDeviceInventoryProvider as withTargetDeviceResolutionScope } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { createLocalAppleToolProvider, runXcrun } from '@agent-device/platform-apple/tool-provider'; -import { buildSimctlArgsForDevice } from '@agent-device/platform-apple/simctl'; -import type { AndroidAdbExecutor } from '@agent-device/platform-android/mechanics'; -import { resolveWebProvider, type WebProvider } from '@agent-device/platform-web'; -import { - resolveAppleRunnerScreenRecordingTransport, - type AppleRunnerScreenRecordingTransport, -} from '../../platform-runtime-screen-recording-apple-runner-transport.ts'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { - createRequestPlatformProviders, - type PlatformProviderResolvers, -} from '../../platform-runtime.ts'; -import type { - RequestPlatformProviderScope, - PlatformProviderRequestContext, -} from '@agent-device/contracts/platform-providers'; -import { resolvePlatformProviderRequestContext } from '@agent-device/daemon/testing/request-platform-provider-context'; -import type { DaemonRequest } from '@agent-device/daemon'; - -const OTHER_IOS_SIMULATOR: DeviceInfo = { - platform: 'apple', - id: 'sim-2', - name: 'iPhone 17', - kind: 'simulator', - booted: true, -}; - -test('request platform provider scope applies Apple tool provider only for Apple sessions', async () => { - const calls: string[][] = []; - - const result = await withRequestPlatformProviderScope( - { - req: request('open'), - existingSession: makeIosSession('default'), - providers: { - appleToolProvider: ({ device }) => { - assert.equal(device.id, IOS_SIMULATOR.id); - return createLocalAppleToolProvider({ - runCommand: async (cmd, args) => { - throw new Error(`unexpected generic command: ${cmd} ${args.join(' ')}`); - }, - simctl: { - run: async (args) => { - calls.push([...args]); - return { exitCode: 0, stdout: 'simctl-ok', stderr: '' }; - }, - }, - }); - }, - linuxToolProvider: () => { - throw new Error('Linux provider should not apply to an iOS session'); - }, - }, - }, - async () => await runXcrun(buildSimctlArgsForDevice(IOS_SIMULATOR, ['list', 'devices', '-j'])), - ); - - assert.equal(result.stdout, 'simctl-ok'); - assert.deepEqual(calls, [['list', 'devices', '-j']]); -}); - -test('request platform provider scope follows explicit apps selector for existing sessions', async () => { - const seenDevices: string[] = []; - - const result = await withTargetDeviceResolutionScope( - async () => [OTHER_IOS_SIMULATOR], - async () => - await withRequestPlatformProviderScope( - { - req: { - ...request('apps'), - flags: { - platform: 'ios', - device: 'iPhone 17', - }, - }, - existingSession: makeIosSession('default'), - providers: { - appleToolProvider: ({ device, session }) => { - seenDevices.push(`${session?.name}:${device.id}`); - return createLocalAppleToolProvider({ - runCommand: async (cmd, args) => { - throw new Error(`unexpected generic command: ${cmd} ${args.join(' ')}`); - }, - simctl: { - run: async () => ({ exitCode: 0, stdout: 'apps-ok', stderr: '' }), - }, - }); - }, - }, - }, - async () => - await runXcrun( - buildSimctlArgsForDevice(OTHER_IOS_SIMULATOR, ['listapps', OTHER_IOS_SIMULATOR.id]), - ), - ), - ); - - assert.equal(result.stdout, 'apps-ok'); - assert.deepEqual(seenDevices, [`default:${OTHER_IOS_SIMULATOR.id}`]); -}); - -test('request platform provider scope skips sharded test orchestration requests', async () => { - let providerCalls = 0; - - const result = await withTargetDeviceResolutionScope( - async () => { - throw new Error('Sharded test orchestration should not resolve a provider device'); - }, - async () => - await withRequestPlatformProviderScope( - { - req: { - ...request('test'), - flags: { - platform: 'ios', - shardAll: 2, - }, - }, - existingSession: undefined, - providers: { - appleToolProvider: () => { - providerCalls += 1; - return createLocalAppleToolProvider(); - }, - }, - }, - async () => 'unscoped', - ), - ); - - assert.equal(result, 'unscoped'); - assert.equal(providerCalls, 0); -}); - -test('request platform provider scopes stay isolated across concurrent requests', async () => { - const androidCalls: string[] = []; - const appleCalls: string[] = []; - let androidEntered!: () => void; - let appleEntered!: () => void; - const androidInProvider = new Promise((resolve) => { - androidEntered = resolve; - }); - const appleInProvider = new Promise((resolve) => { - appleEntered = resolve; - }); - const bothProvidersEntered = Promise.all([androidInProvider, appleInProvider]); - - const androidTask = withRequestPlatformProviderScope( - { - req: { ...request('snapshot'), meta: { requestId: 'req-android' } }, - existingSession: makeAndroidSession('android-session'), - providers: { - androidAdbProvider: ({ device, session }) => ({ - exec: async (args) => { - androidCalls.push(`${session?.name}:${device.id}:${args.join(' ')}`); - androidEntered(); - await bothProvidersEntered; - return { exitCode: 0, stdout: 'android-ok', stderr: '' }; - }, - }), - appleToolProvider: () => { - throw new Error('Apple provider should not apply to an Android request'); - }, - }, - }, - async (scope) => { - const executor = scope.androidAdbExecutor as AndroidAdbExecutor | undefined; - assert.ok(executor); - return (await executor(['shell', 'echo', 'android'])).stdout; - }, - ); - - const appleTask = withRequestPlatformProviderScope( - { - req: { ...request('snapshot'), meta: { requestId: 'req-apple' } }, - existingSession: makeIosSession('ios-session'), - providers: { - androidAdbProvider: () => { - throw new Error('Android provider should not apply to an Apple request'); - }, - appleToolProvider: ({ device, session }) => - createLocalAppleToolProvider({ - runCommand: async (cmd, args) => { - throw new Error(`unexpected generic command: ${cmd} ${args.join(' ')}`); - }, - simctl: { - run: async (args) => { - appleCalls.push(`${session?.name}:${device.id}:${args.join(' ')}`); - appleEntered(); - await bothProvidersEntered; - return { exitCode: 0, stdout: 'apple-ok', stderr: '' }; - }, - }, - }), - }, - }, - async () => - (await runXcrun(buildSimctlArgsForDevice(IOS_SIMULATOR, ['list', 'devices', '-j']))).stdout, - ); - - assert.deepEqual(await Promise.all([androidTask, appleTask]), ['android-ok', 'apple-ok']); - assert.deepEqual(androidCalls, [`android-session:${ANDROID_EMULATOR.id}:shell echo android`]); - assert.deepEqual(appleCalls, [`ios-session:${IOS_SIMULATOR.id}:list devices -j`]); -}); - -test('request platform provider scope applies web provider only for web sessions', async () => { - const calls: string[] = []; - const webProvider = makeWebProvider({ - async open(target) { - calls.push(`open:${target}`); - }, - }); - - await withRequestPlatformProviderScope( - { - req: request('open'), - existingSession: makeSession('web-session', { device: WEB_DESKTOP_DEVICE }), - providers: { - webProvider: ({ device, session }) => { - calls.push(`${session?.name}:${device.id}`); - return webProvider; - }, - linuxToolProvider: () => { - throw new Error('Linux provider should not apply to a web session'); - }, - }, - }, - async () => await (await resolveWebProvider()).open('https://example.test'), - ); - - assert.deepEqual(calls, ['web-session:agent-browser-chrome', 'open:https://example.test']); -}); - -test('generic Apple runner provider cannot fall back to local recording authority', async () => { - await withRequestPlatformProviderScope( - { - req: request('record'), - existingSession: makeMacOsSession('macos-session'), - providers: { - appleRunnerProvider: () => ({ runCommand: async () => ({}), hasLiveSession: () => true }), - }, - }, - async () => { - const transport = resolveAppleRunnerScreenRecordingTransport(); - assert.equal(transport.authority, 'scoped-provider'); - assert.equal(transport.available, false); - }, - ); -}); - -test('request provider context preserves an explicitly empty request id', async () => { - const context = await resolvePlatformProviderRequestContext({ - req: { ...request('snapshot'), meta: { requestId: '' } }, - existingSession: makeIosSession('ios-session'), - }); - - assert.equal(context?.requestId, ''); -}); - -test('focused Apple runner recording authority remains exact across recreated request scopes', async () => { - let activeSessionId: string | undefined; - const transport: AppleRunnerScreenRecordingTransport = Object.freeze({ - authority: 'scoped-provider', - available: true, - start: async () => { - activeSessionId = 'provider-runner-session-1'; - return { runnerSessionId: activeSessionId }; - }, - inspect: async (device, runnerSessionId) => - device.id === MACOS_DEVICE.id && runnerSessionId === activeSessionId - ? 'owned-alive' - : 'ownership-lost', - stop: async ({ device, runnerSessionId }) => { - assert.equal(device.id, MACOS_DEVICE.id); - assert.equal(runnerSessionId, activeSessionId); - activeSessionId = undefined; - }, - }); - const providers = { - appleRunnerProvider: () => ({ runCommand: async () => ({}), hasLiveSession: () => true }), - appleRunnerScreenRecordingTransport: () => transport, - }; - const runnerSessionId = await withRequestPlatformProviderScope( - { - req: request('record'), - existingSession: makeMacOsSession('macos-session'), - providers, - }, - async () => { - const resolved = resolveAppleRunnerScreenRecordingTransport(); - assert.equal(resolved, transport); - return ( - await resolved.start({ - device: MACOS_DEVICE, - appBundleId: 'com.example.app', - outputPath: '/tmp/capture.mp4', - }) - ).runnerSessionId; - }, - ); - - await withRequestPlatformProviderScope( - { - req: request('record'), - existingSession: makeMacOsSession('macos-session'), - providers, - }, - async () => { - const resolved = resolveAppleRunnerScreenRecordingTransport(); - assert.equal(await resolved.inspect(MACOS_DEVICE, runnerSessionId), 'owned-alive'); - assert.equal(await resolved.inspect(MACOS_DEVICE, 'replacement-session'), 'ownership-lost'); - await resolved.stop({ device: MACOS_DEVICE, runnerSessionId }); - }, - ); -}); - -test('request platform provider scope follows explicit web selector', async () => { - const seenDevices: string[] = []; - - await withTargetDeviceResolutionScope( - async () => [WEB_DESKTOP_DEVICE], - async () => - await withRequestPlatformProviderScope( - { - req: { - ...request('snapshot'), - flags: { - platform: 'web', - }, - }, - existingSession: undefined, - providers: { - webProvider: ({ device, session }) => { - seenDevices.push(`${session?.name ?? 'none'}:${device.id}`); - return makeWebProvider(); - }, - appleToolProvider: () => { - throw new Error('Apple provider should not apply to a web request'); - }, - }, - }, - async () => await (await resolveWebProvider()).snapshot(), - ), - ); - - assert.deepEqual(seenDevices, ['none:agent-browser-chrome']); -}); - -function request(command: string): DaemonRequest { - return { - token: 'test-token', - session: 'default', - command, - positionals: [], - flags: {}, - meta: { requestId: `req-${command}` }, - }; -} - -async function withRequestPlatformProviderScope( - params: { - req: DaemonRequest; - existingSession: Parameters[0]['existingSession']; - providers: PlatformProviderResolvers; - }, - task: (scope: RequestPlatformProviderScope) => Promise, -): Promise { - const context: PlatformProviderRequestContext | undefined = - await resolvePlatformProviderRequestContext({ - req: params.req, - existingSession: params.existingSession, - }); - if (!context) return await task({}); - return await createRequestPlatformProviders({ providers: params.providers }).run(context, task); -} - -function makeWebProvider(overrides: Partial = {}): WebProvider { - return { - open: async () => {}, - close: async () => {}, - snapshot: async () => ({ nodes: [] }), - screenshot: async () => {}, - setViewport: async () => {}, - click: async () => {}, - fill: async () => {}, - typeText: async () => {}, - scroll: async () => {}, - ...overrides, - }; -} diff --git a/src/__tests__/daemon-composition/request-router-android-perf.test.ts b/src/__tests__/daemon-composition/request-router-android-perf.test.ts deleted file mode 100644 index 08ff3cf155..0000000000 --- a/src/__tests__/daemon-composition/request-router-android-perf.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { expect, test } from 'vitest'; -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { AppError } from '@agent-device/kernel/errors'; -import type { - AndroidAdbExecutor, - AndroidAdbProvider, -} from '@agent-device/platform-android/mechanics'; -import { - createPlatformRuntimeGateway, - createRequestPlatformProviders, -} from '../../platform-runtime.ts'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; - -function makeAndroidSessionStore(name: string): SessionStore { - const sessionStore = new SessionStore(`/tmp/${name}`); - sessionStore.publish('default', { - name: 'default', - createdAt: Date.now(), - device: { - platform: 'android', - id: 'remote-android-1', - name: 'Remote Android', - kind: 'device', - booted: true, - }, - appBundleId: 'com.example.app', - actions: [], - }); - return sessionStore; -} - -function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => AndroidAdbProvider) { - const deviceRuntimeGateway = createPlatformRuntimeGateway({ - sessionsDir: '/tmp/agent-device-perf-runtime', - resolveSessionArtifacts: (sessionId) => ({ - outputPath: `/tmp/agent-device-perf-runtime/${sessionId}/app.log`, - pidPath: `/tmp/agent-device-perf-runtime/${sessionId}/app-log.pid`, - }), - }); - return createRequestHandler({ - createCommandSurface: testCreateCommandSurface, - logPath: '/tmp/daemon.log', - token: 'token', - sessionStore, - leaseRegistry: new LeaseRegistry(), - deviceInventoryGateways: createTestDeviceInventoryGateways(), - requestPlatformProviders: createRequestPlatformProviders({ - providers: { androidAdbProvider }, - }), - deviceRuntimeGateway, - trackDownloadableArtifact: () => 'artifact-id', - }); -} - -test('request handler reports injected Android adb failures for memory sampling', async () => { - const sessionStore = makeAndroidSessionStore('agent-device-request-router-perf-unavailable-test'); - const adb: AndroidAdbExecutor = async () => { - throw new AppError('COMMAND_FAILED', 'Remote Android ADB executor is unavailable'); - }; - const handler = makeHandler(sessionStore, () => ({ exec: adb })); - - const response = await handler({ - token: 'token', - session: 'default', - command: 'perf', - positionals: ['memory', 'sample'], - flags: {}, - }); - - expect(response.ok).toBe(true); - if (!response.ok) throw new Error('Expected perf response to succeed'); - const metrics = response.data?.metrics as Record; - const memory = metrics.memory; - expect(memory.available).toBe(false); - expect(memory.reason).toBe('Remote Android ADB executor is unavailable'); - expect(memory.error.details.metric).toBe('memory'); -}); diff --git a/src/__tests__/daemon-composition/request-router-android-snapshot-helper.test.ts b/src/__tests__/daemon-composition/request-router-android-snapshot-helper.test.ts deleted file mode 100644 index be50b47ea0..0000000000 --- a/src/__tests__/daemon-composition/request-router-android-snapshot-helper.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { beforeEach, expect, test } from 'vitest'; -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { AppError } from '@agent-device/kernel/errors'; -import { resetAndroidSnapshotHelperInstallCache } from '@agent-device/platform-android/mechanics'; -import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../test-utils/android-snapshot-helper.ts'; -import type { AndroidAdbProvider } from '@agent-device/platform-android/mechanics'; -import { - createPlatformRuntimeGateway, - createRequestPlatformProviders, -} from '../../platform-runtime.ts'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; - -function makeAndroidSessionStore(name: string): SessionStore { - const sessionStore = new SessionStore(`/tmp/${name}`); - sessionStore.publish('default', { - name: 'default', - createdAt: Date.now(), - device: { - platform: 'android', - id: 'remote-android-1', - name: 'Remote Android', - kind: 'device', - booted: true, - }, - appBundleId: 'com.example.app', - actions: [], - }); - return 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({ - createCommandSurface: testCreateCommandSurface, - logPath: '/tmp/daemon.log', - token: 'token', - sessionStore, - leaseRegistry: new LeaseRegistry(), - deviceInventoryGateways: createTestDeviceInventoryGateways(), - requestPlatformProviders: createRequestPlatformProviders({ - providers: { androidAdbProvider }, - }), - deviceRuntimeGateway, - trackDownloadableArtifact: () => 'artifact-id', - }); -} - -beforeEach(() => { - resetAndroidSnapshotHelperInstallCache(); -}); - -// Regression for #1284/#1285 P1: a provider whose `install` REJECTS with an -// enriched INSTALL_FAILED_* error (instead of resolving with a nonzero result) -// must still surface the device-side install classification on the public -// daemon snapshot error, not the generic retry/doctor hint. -test('snapshot reports a device-side install failure when the provider install rejects', async () => { - const sessionStore = makeAndroidSessionStore( - 'agent-device-request-router-snapshot-helper-install-reject-test', - ); - const provider: AndroidAdbProvider = { - // Version probe reports no installed helper; every other device query is inert. - exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }), - install: async () => { - throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', { - stderr: 'adb: failed to install helper.apk: Failure [INSTALL_FAILED_TEST_ONLY]', - stdout: '', - exitCode: 1, - processExitError: true, - }); - }, - snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, - }; - const handler = makeHandler(sessionStore, () => provider); - - const response = await handler({ - token: 'token', - session: 'default', - command: 'snapshot', - positionals: [], - flags: {}, - }); - - expect(response.ok).toBe(false); - if (response.ok) throw new Error('Expected snapshot to fail'); - expect(response.error.message).toMatch(/Android snapshot helper failed/); - expect(response.error.message).toMatch(/INSTALL_FAILED_TEST_ONLY/); - // The provider funnel's classified adb hint is preserved, with the - // device-side framing appended rather than replacing it. - expect(response.error.hint).toMatch(/package installer rejected the APK/); - expect(response.error.hint).toMatch(/device-side install failure/); - expect(response.error.hint).not.toMatch(/pnpm build:android/); - expect(response.error.details?.androidSnapshotHelperInstallFailure).toBe(true); -}); - -// ADR 0010 wire contract: a transient-classified install rejection (here the -// funnel's `connection_dropped` family) must keep its structured `retriable` -// signal through the capture rewrap onto the public daemon error. -test('snapshot keeps the transient retry signal when the provider install rejection is retriable', async () => { - const sessionStore = makeAndroidSessionStore( - 'agent-device-request-router-snapshot-helper-install-transient-test', - ); - const provider: AndroidAdbProvider = { - exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }), - install: async () => { - throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', { - stderr: 'adb: transport error while pushing helper.apk', - stdout: '', - exitCode: 1, - processExitError: true, - }); - }, - snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, - }; - const handler = makeHandler(sessionStore, () => provider); - - const response = await handler({ - token: 'token', - session: 'default', - command: 'snapshot', - positionals: [], - flags: {}, - }); - - expect(response.ok).toBe(false); - if (response.ok) throw new Error('Expected snapshot to fail'); - expect(response.error.message).toMatch(/Android snapshot helper failed/); - expect(response.error.hint).toMatch(/connection dropped/); - expect(response.error.hint).toMatch(/device-side install failure/); - expect(response.error.retriable).toBe(true); - expect(response.error.details?.androidSnapshotHelperInstallFailure).toBe(true); -}); diff --git a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts b/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts deleted file mode 100644 index 3e23d4b72c..0000000000 --- a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { test, expect, vi, beforeEach } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import crypto from 'node:crypto'; -import { spawn } from 'node:child_process'; -import { fakeCreateCommandSurface } from '@agent-device/daemon/testing/__tests__/support/command-surface-fake'; -import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { - composedClaimRecoveryGatewayFixture, - composedPlatformServicesFixture, -} from './platform-services-fixture.ts'; -import { getResolveTargetDeviceMock } from '@agent-device/daemon/testing/__tests__/request-router-dispatch-mocks'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import type { DeviceInfo } from '@agent-device/kernel/device'; - -// Open-time claim reconciliation runs on the root-composed claim-recovery gateway: the router -// must hand the acquire path the OWNER-scoped reconciler, so a foreign dead owner's recording -// recovery executes against that owner's state dir, never the daemon's own store (#2168). - -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ - ensureDeviceReady: vi.fn(async () => {}), -})); -vi.mock('@agent-device/host-kit/process', async (importOriginal) => { - const actual = await importOriginal(); - const startTime = 'test-process-start'; - return { - ...actual, - readProcessStartTime: vi.fn(() => startTime), - // The ownership decision reads all three facts in one call, so it has to answer with the - // same fabricated start time this fixture writes into its markers. - readProcessIdentityFacts: vi.fn(async (pid: number) => ({ - startTime, - command: actual.readProcessCommand(pid), - zombie: false, - })), - }; -}); -vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - detachIosRunnerSessionsForShutdown: vi.fn(async () => {}), - notifyIosRunnerAppRelaunched: vi.fn(async () => {}), - prewarmAppleRunnerCache: vi.fn(async () => {}), - prewarmIosRunnerSession: vi.fn(async () => {}), - prepareIosRunner: vi.fn(async () => ({ - runner: { currentUptimeMs: 42 }, - connectMs: 0, - healthCheckMs: 0, - })), - resolveRunnerAppBundleId: vi.fn(() => 'com.callstack.agentdevice.runner'), - stopIosRunnerSession: vi.fn(async () => {}), - stopAllIosRunnerSessions: vi.fn(async () => {}), - }; -}); -vi.mock('@agent-device/platform-apple/app-lifecycle', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, closeIosApp: vi.fn(async () => {}) }; -}); -vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - resolveIosApp: vi.fn(async (_device, app) => app), - }; -}); - -import { - createRequestHandler, - lifecycleDeviceRuntimeGateway, -} from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { - awaitFixtureReadiness, - dispatchApplicationLifecycleEffect, -} from '@agent-device/daemon/testing/__tests__/application-lifecycle-runtime-fixture'; -import { discoverReadyAndroidEmulators } from './application-lifecycle-runtime-fixture.ts'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { inspectDeviceClaims } from '@agent-device/daemon-contracts/device-claims'; -import { - createOwnedProcessRecordStore, - isProcessAlive, - readProcessCommand, - readProcessStartTime, -} from '@agent-device/host-kit/process'; -import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; -import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; -import { screenRecordingDurableResource } from '@agent-device/capture-kit/screen-recording-session-resource'; -import { - makeIosDevice, - openRequest, -} from '@agent-device/daemon/testing/__tests__/request-router-open-harness'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; - -const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); -const mockDispatch = vi.mocked(dispatchApplicationLifecycleEffect); -const mockDiscoverReadyAndroidEmulators = vi.mocked(discoverReadyAndroidEmulators); -const mockAwaitFixtureReadiness = vi.mocked(awaitFixtureReadiness); - -function createOpenHandler(sessionStore: ReturnType) { - return createRequestHandler({ - createCommandSurface: fakeCreateCommandSurface, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - token: 'test-token', - sessionStore, - leaseRegistry: new LeaseRegistry(), - platformServices: composedPlatformServicesFixture(), - claimRecoveryGateway: composedClaimRecoveryGatewayFixture(), - deviceRuntimeGateway: lifecycleDeviceRuntimeGateway, - deviceInventoryGateways: createTestDeviceInventoryGateways(), - trackDownloadableArtifact: () => 'artifact-id', - }); -} - -beforeEach(() => { - mockResolveTargetDevice.mockReset(); - mockDispatch.mockReset(); - mockDispatch.mockResolvedValue(undefined); - mockAwaitFixtureReadiness.mockReset(); - mockAwaitFixtureReadiness.mockResolvedValue(undefined); - mockDiscoverReadyAndroidEmulators.mockReset(); -}); - -// The dead owner's recorded recorder must still run as an orphan: recovery -// then takes the descriptor-cleanup path — the one that terminates it and -// clears the owned-process store it was composed with. A plain sleeper with -// its real observed command and start time satisfies the exact match. The -// sleeper is the current Node binary on a parked timer, so the case runs on -// every platform (`sleep` is not a Windows command). -async function spawnOrphanRecorder() { - const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 120_000)'], { - stdio: 'ignore', - }); - await new Promise((resolve) => child.once('spawn', resolve)); - const pid = child.pid ?? 0; - const marker = { - pid, - startTime: readProcessStartTime(pid) ?? '', - command: readProcessCommand(pid) ?? '', - }; - expect(pid).toBeGreaterThan(0); - expect(marker.startTime.length).toBeGreaterThan(0); - expect(marker.command.length).toBeGreaterThan(0); - return { child, marker }; -} - -function ownedStoreFor(stateDir: string) { - const paths = resolveDaemonPaths(stateDir); - return createOwnedProcessRecordStore({ - stateDir: paths.baseDir, - sessionsDir: paths.sessionsDir, - resolveSessionDir: (sessionId) => path.join(paths.sessionsDir, sessionId), - }); -} - -function seedForeignRecordingOwner( - foreignStateDir: string, - device: DeviceInfo, - marker: { pid: number; startTime: string; command: string }, -) { - const foreignSessionDir = path.join(resolveDaemonPaths(foreignStateDir).sessionsDir, 'shared'); - const resourcePath = screenRecordingDurableResource.store.resolvePath(foreignSessionDir); - screenRecordingDurableResource.store.write( - resourcePath, - createDurableResourceEnvelope({ - resourceKind: 'screen-recording', - sessionId: 'shared', - device: { id: device.id, family: 'apple', appleOs: 'ios', kind: 'simulator' }, - owner: { kind: 'local-family', family: 'apple' }, - fence: { token: 'open-recovery-fence', generation: 1 }, - lifecycle: 'open', - descriptor: { - version: 1, - body: { - backend: 'simctl', - outputPath: path.join(foreignSessionDir, 'recording.mp4'), - processes: [marker], - }, - }, - }), - ); - ownedStoreFor(foreignStateDir).replace({ kind: 'session', sessionId: 'shared' }, [ - { ...marker, purpose: 'simctl-screen-recording' }, - ]); - return { foreignSessionDir, resourcePath }; -} - -function seedDeadForeignClaim(claimsDir: string, device: DeviceInfo, foreignStateDir: string) { - const deviceKey = `local:apple:ios:${device.id}`; - fs.writeFileSync( - path.join(claimsDir, `${crypto.createHash('sha256').update(deviceKey).digest('hex')}.json`), - JSON.stringify({ - schemaVersion: 1, - deviceKey, - device: { platform: 'ios', id: device.id, name: device.name, kind: 'simulator' }, - session: 'shared', - workspace: '/worktrees/dead', - stateDir: foreignStateDir, - ownerPid: 999_999_999, - ownerStartTime: 'old-start-time', - ownerToken: 'open-recovery-token', - createdAtMs: 1, - updatedAtMs: 1, - }), - ); -} - -test('open reconciles a foreign dead owner through that owner state dir, never the daemon store', async () => { - // #2168 acquire-path wiring: the router must hand open-time claim - // reconciliation the owner-scoped reconciler. With the caller-scoped - // reconciler this test goes red — the foreign recording recovery runs - // against the daemon's own gateway and store instead of the dead owner's. - const sessionStore = makeSessionStore('agent-device-router-open-recovery-'); - const device = makeIosDevice('SIM-FOREIGN-RECOVERY'); - mockResolveTargetDevice.mockResolvedValue(device); - const claimsDir = mkdtempForTestSync('agent-device-router-open-claims-'); - const foreignStateDir = mkdtempForTestSync('agent-device-router-open-foreign-'); - const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR; - process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; - const orphan = await spawnOrphanRecorder(); - - try { - const { foreignSessionDir, resourcePath } = seedForeignRecordingOwner( - foreignStateDir, - device, - orphan.marker, - ); - const daemonStateDir = sessionStore.resolveDaemonStateDir(); - ownedStoreFor(daemonStateDir).replace({ kind: 'session', sessionId: 'shared' }, [ - { pid: process.pid, startTime: 'live-marker', command: 'live-probe', purpose: 'test-probe' }, - ]); - const liveRecordPath = path.join( - resolveDaemonPaths(daemonStateDir).sessionsDir, - 'shared', - 'owned-processes.json', - ); - const liveRecordBefore = fs.readFileSync(liveRecordPath, 'utf8'); - seedDeadForeignClaim(claimsDir, device, foreignStateDir); - - const response = await createOpenHandler(sessionStore)( - openRequest('takeover', { platform: 'ios' }, 'req-open-foreign-recovery'), - ); - - expect(response.ok).toBe(true); - const settled = screenRecordingDurableResource.store.read(resourcePath); - expect(settled).toMatchObject({ status: 'decoded', envelope: { lifecycle: 'completed' } }); - expect(fs.existsSync(path.join(foreignSessionDir, 'owned-processes.json'))).toBe(false); - expect(fs.readFileSync(liveRecordPath, 'utf8')).toBe(liveRecordBefore); - expect(isProcessAlive(orphan.marker.pid)).toBe(false); - expect(inspectDeviceClaims({ udid: device.id })[0]?.claim?.session).toBe('takeover'); - } finally { - try { - orphan.child.kill('SIGKILL'); - } catch { - // The orphan is already gone once recovery terminated it. - } - if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR; - else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir; - fs.rmSync(claimsDir, { recursive: true, force: true }); - fs.rmSync(foreignStateDir, { recursive: true, force: true }); - } -}); diff --git a/src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts b/src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts deleted file mode 100644 index b76d96793c..0000000000 --- a/src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * #2997: a `test`/`replay` run on a physical Android device opts into the bundled test - * IME with `--test-ime`, and that opt-in must arrive at the session open the flow itself - * owns. The flag rides the request envelope through the production router into the - * lifecycle binding, where the platform host performs the activation. Physical-device - * defaults stay off: the activation seam fires only for an explicit opt-in. - */ -import { createTestDeviceInventoryGateways } from '@agent-device/device-selection/inventory-gateways-fixtures'; -import { beforeEach, expect, test, vi } from 'vitest'; -import fs from 'node:fs'; - -import path from 'node:path'; -import { getResolveTargetDeviceMock } from '@agent-device/daemon/testing/__tests__/request-router-dispatch-mocks'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; - -vi.mock('@agent-device/daemon/testing/device/device-ready', () => ({ - ensureDeviceReady: vi.fn(async () => {}), -})); - -vi.mock('../../platform-runtime-runtime-hints.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, applyRuntimeHintValues: vi.fn(async () => {}) }; -}); - -// The IME seam itself talks adb; the router test asserts it is reached with the resolved -// device, so the mechanics module answers without a device attached. -const activateAndroidTestIme = vi.hoisted(() => vi.fn()); -const restoreAndroidTestIme = vi.hoisted(() => vi.fn()); -vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - activateAndroidTestIme, - restoreAndroidTestIme, - resolveAndroidPackageForOpen: vi.fn(async () => undefined), - }; -}); - -import { ANDROID_DEVICE, mkdtempForTestSync } from '@agent-device/test-kit'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { replayScriptSourceBundleFor } from '../test-utils/replay-script-source.fixtures.ts'; -import { LeaseRegistry } from '@agent-device/daemon/testing/lease-registry'; -import { createRequestHandler } from '@agent-device/daemon/testing/__tests__/test-device-runtime-gateway'; -import { realLifecycleDeviceRuntimeGateway } from './lifecycle-runtime-gateway.ts'; - -const mockActivateAndroidTestIme = vi.mocked(activateAndroidTestIme); -const mockRestoreAndroidTestIme = vi.mocked(restoreAndroidTestIme); -const mockResolveTargetDevice = vi.mocked(getResolveTargetDeviceMock()); - -beforeEach(() => { - mockActivateAndroidTestIme.mockReset(); - mockActivateAndroidTestIme.mockResolvedValue({ outcome: 'settled' }); - mockRestoreAndroidTestIme.mockReset(); - mockRestoreAndroidTestIme.mockResolvedValue({ restored: false, reason: 'no-record' }); - mockResolveTargetDevice.mockReset(); - // A physical device, which is the #1198 default-off route this opt-in exists for. - mockResolveTargetDevice.mockResolvedValue(ANDROID_DEVICE); -}); - -async function runAndroidFlowReplay(session: string, flags: Record) { - const root = mkdtempForTestSync('agent-device-replay-test-ime-'); - const replayPath = path.join(root, 'flow.ad'); - fs.writeFileSync(replayPath, 'open com.example.demo\n'); - const handler = createRequestHandler({ - createCommandSurface: testCreateCommandSurface, - logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), - token: 'test-token', - sessionStore: makeSessionStore('agent-device-replay-test-ime-'), - leaseRegistry: new LeaseRegistry(), - deviceRuntimeGateway: realLifecycleDeviceRuntimeGateway, - deviceInventoryGateways: createTestDeviceInventoryGateways(), - trackDownloadableArtifact: () => 'artifact-id', - }); - const response = await handler({ - token: 'test-token', - session, - command: 'replay', - positionals: [replayPath], - flags: { - platform: 'android', - replayScriptSource: replayScriptSourceBundleFor(replayPath), - ...flags, - }, - meta: { cwd: root, requestId: `replay-test-ime-${session}` }, - }); - return { response }; -} - -test('a replay run opted into --test-ime activates the test IME on the device its flow opens', async () => { - const { response } = await runAndroidFlowReplay('replay-test-ime-on', { testIme: true }); - - expect(response).toMatchObject({ ok: true }); - expect(mockActivateAndroidTestIme).toHaveBeenCalledTimes(1); - expect(mockActivateAndroidTestIme.mock.calls[0]?.[0]).toMatchObject({ - id: ANDROID_DEVICE.id, - platform: 'android', - }); -}); - -test('a replay run without --test-ime leaves the physical device on the real keyboard', async () => { - const { response } = await runAndroidFlowReplay('replay-test-ime-default', {}); - - expect(response).toMatchObject({ ok: true }); - expect(mockActivateAndroidTestIme).not.toHaveBeenCalled(); -}); diff --git a/src/__tests__/daemon-composition/selector-runtime-backend.test.ts b/src/__tests__/daemon-composition/selector-runtime-backend.test.ts deleted file mode 100644 index fdb066e063..0000000000 --- a/src/__tests__/daemon-composition/selector-runtime-backend.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; -import path from 'node:path'; -import { expect, test, vi } from 'vitest'; -import type { - CaptureSnapshotInput, - SnapshotResult, -} from '@agent-device/contracts/snapshot-runtime'; -import { createSelectorRuntimeForDevice } from '@agent-device/daemon/testing/selector-runtime-backend'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; -import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; - -const device: SessionState['device'] = { - platform: 'apple', - id: 'sim-1', - name: 'iPhone Simulator', - kind: 'simulator', - booted: true, -}; - -test('wait text passes its poll deadline signal to the Apple runner fast path', async () => { - const sessionName = 'ios-wait-deadline'; - const sessionStore = new SessionStore( - path.join(mkdtempForTestSync('selector-runtime-'), 'sessions'), - ); - const session: SessionState = { - name: sessionName, - device, - appBundleId: 'com.example.fixture', - createdAt: Date.now(), - actions: [], - }; - sessionStore.publish(sessionName, session); - let observedSignal: AbortSignal | undefined; - // R35: every selector runtime is bound, so the poll deadline must reach the platform through - // the bound capture's per-capture signal — the seam the runner used to be reached through. - const capture = vi.fn( - async (input: CaptureSnapshotInput): Promise => - await new Promise((resolve) => { - observedSignal = input.signal; - if (input.signal?.aborted) { - resolve({ backend: 'xctest', producer: 'apple-runner', nodes: [] }); - return; - } - input.signal?.addEventListener( - 'abort', - () => resolve({ backend: 'xctest', producer: 'apple-runner', nodes: [] }), - { - once: true, - }, - ); - }), - ); - const runtime = await createSelectorRuntimeForDevice({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - ref: sessionStore.lookup(sessionName), - req: { - token: 't', - session: sessionName, - command: 'wait', - positionals: ['Never appears', '10'], - flags: {}, - }, - sessionName, - sessionStore, - session, - device, - bound: { capture }, - }); - - await expect( - runtime.selectors.waitForText('Never appears', { session: sessionName, timeoutMs: 10 }), - ).rejects.toThrow(/timed out/i); - expect(observedSignal).toBeDefined(); - expect(observedSignal?.aborted).toBe(true); -}); - -test('daemon wait stable pins private-ax on emitted snapshot runner requests', async () => { - const sessionName = 'ios-wait-stable-private-ax'; - const sessionStore = new SessionStore( - path.join(mkdtempForTestSync('selector-runtime-'), 'sessions'), - ); - const nodes = [ - { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, - ...Array.from({ length: 5 }, (_, offset) => ({ - index: offset + 1, - parentIndex: 0, - type: 'Button', - label: `Action ${offset + 1}`, - rect: { x: 20, y: 100 + offset * 50, width: 120, height: 44 }, - hittable: true, - })), - ]; - const snapshot = makeSnapshotState(nodes, { - snapshotQuality: { state: 'recovered', backend: 'private-ax', reasonCode: 'deferred' }, - }); - const session: SessionState = { - name: sessionName, - device, - appBundleId: 'com.example.fixture', - createdAt: Date.now(), - actions: [], - snapshot, - }; - sessionStore.publish(sessionName, session); - const capture = vi.fn(async (_input: CaptureSnapshotInput): Promise => ({ - backend: 'xctest', - producer: 'apple-runner', - nodes, - quality: { - state: 'recovered', - backend: 'private-ax', - reasonCode: 'requested-backend', - }, - })); - const runtime = await createSelectorRuntimeForDevice({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - ref: sessionStore.lookup(sessionName), - req: { - token: 't', - session: sessionName, - command: 'wait', - positionals: ['stable', '25', '1000'], - flags: {}, - }, - sessionName, - sessionStore, - session, - device, - bound: { capture }, - }); - - await runtime.selectors.wait({ - session: sessionName, - target: { kind: 'stable', quietMs: 25, timeoutMs: 1_000 }, - }); - - expect(capture.mock.calls.length).toBeGreaterThanOrEqual(2); - expect( - capture.mock.calls.every(([input]) => input.options?.preferredBackend === 'private-ax'), - ).toBe(true); -}); diff --git a/src/__tests__/daemon-composition/session-teardown-import-closure.test.ts b/src/__tests__/daemon-composition/session-teardown-import-closure.test.ts index 7937da88da..00827e0cbf 100644 --- a/src/__tests__/daemon-composition/session-teardown-import-closure.test.ts +++ b/src/__tests__/daemon-composition/session-teardown-import-closure.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import path from 'node:path'; import { DAEMON_SOURCE_ROOT } from '../../../scripts/layering/tracked-sources.ts'; -import { eagerClosureOf } from '../eager-import-closure.fixtures.ts'; +import { eagerClosureOf } from '@agent-device/test-kit'; /** * Session teardown runs for every closed session on every platform, so its eager diff --git a/src/__tests__/daemon-composition/wait-runtime.test.ts b/src/__tests__/daemon-composition/wait-runtime.test.ts deleted file mode 100644 index 461a38d6f8..0000000000 --- a/src/__tests__/daemon-composition/wait-runtime.test.ts +++ /dev/null @@ -1,952 +0,0 @@ -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; -import { expect, test, vi } from 'vitest'; -import { WAIT_REASONS, type ReadinessPhase } from '@agent-device/contracts/wait'; -import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; -import { - type DeviceBinding, - type RuntimeFacts, - type RuntimeOperationFact, - localRuntimeOwner, - narrowDeviceBinding, - providerRuntimeOwner, -} from '@agent-device/contracts/platform-runtime'; -import { - type PlatformRuntimeOperations, - waitSelectorCaptureRuntimePlanUses, -} from '@agent-device/contracts/platform-runtime-operations'; -import type { FindTextInput } from '@agent-device/contracts/selector-observation-runtime'; -import { - type CaptureSnapshotInput, - type SnapshotResult, - snapshotRuntimeOperationFacts, -} from '@agent-device/contracts/snapshot-runtime'; -import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; -import { makeSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '@agent-device/test-kit'; -import type { - BindDeviceRuntime, - InspectDeviceRuntimeFacts, -} from '@agent-device/daemon/testing/request-runtime-binding'; -import { handleSnapshotCommands } from '@agent-device/daemon/testing/handlers/snapshot'; -import { resolveBoundSelectorCapture } from '@agent-device/daemon/testing/selector-capture-binding'; -import { dispatchGetViaRuntime } from '@agent-device/daemon/testing/selector-runtime'; -import type { DaemonRequest } from '@agent-device/daemon'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; - -const webDevice = { - id: 'web', - name: 'Web', - platform: 'web', - kind: 'device', - booted: true, -} as const satisfies DeviceInfo; - -const available = Object.freeze({ available: true } as const); -/** The harness session tracks no app bundle id, so wait's plan is the without-active-app one. */ -const waitWithoutActiveAppUse = waitSelectorCaptureRuntimePlanUses[1]; - -const findTextUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-platform-leaf' as const, - hint: 'This target exposes no native text reading.', -}); -const captureUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-platform-leaf' as const, - hint: 'wait observes a snapshot; this target exposes none.', -}); - -type CaptureNode = { - index: number; - depth: number; - type: string; - label?: string; - hittable?: boolean; -}; - -/** - * Binds the fake at the seam the handler consumes — `inspectFacts` / `bindDevice` — never at - * `-device/device-selection/dispatch-resolve`. `captureSnapshot` is the ONE operation `wait` declares, so this harness is - * also the proof that no sibling snapshot operation is reachable from wait's narrowed binding. - */ -function waitRuntimeHarness( - options: { - capture?: RuntimeOperationFact; - device?: DeviceInfo; - providerOwned?: boolean; - nodesPerPoll?: CaptureNode[][]; - /** The owner's native text reading; absent means the owner advertises none. */ - findText?: RuntimeOperationFact; - findTextAnswers?: (text: string) => boolean; - /** A scenario-supplied capture operation, for poll-deadline behaviour. */ - captureSnapshot?: (input: CaptureSnapshotInput) => Promise; - } = {}, -) { - const device = options.device ?? webDevice; - const capture = options.capture ?? available; - const polls = options.nodesPerPoll ?? [ - [{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }], - ]; - let pollIndex = 0; - - const captureSnapshot = vi.fn(async (input: CaptureSnapshotInput): Promise => - options.captureSnapshot - ? await options.captureSnapshot(input) - : { - nodes: polls[Math.min(pollIndex++, polls.length - 1)] ?? [], - backend: 'web', - producer: 'agent-browser', - }, - ); - const findTextFact = options.findText ?? findTextUnavailable; - const findText = vi.fn(async (input: FindTextInput) => ({ - found: options.findTextAnswers?.(input.text) ?? false, - })); - const facts: RuntimeFacts = { - device: { - ...deviceShape(device), - providerMode: options.providerOwned ? 'provider-runtime' : 'local', - }, - operations: { - ...unavailableDeploymentSnapshotAndShutdownOperationFacts, - ...snapshotRuntimeOperationFacts({ - capture, - customActions: captureUnavailable, - withoutActiveApp: capture, - }), - findText: findTextFact, - } as RuntimeFacts['operations'], - }; - const binding = { - device, - owner: options.providerOwned - ? providerRuntimeOwner('test', 'wait-runtime-test') - : localRuntimeOwner(device.platform), - facts, - operations: { - captureSnapshot, - captureSnapshotWithCustomActions: captureSnapshot, - captureSnapshotWithoutActiveApp: captureSnapshot, - // Only advertised operations get an implementation, so an owner whose facts refuse - // `findText` cannot expose one through the narrowed projection either. - ...(findTextFact.available ? { findText } : {}), - }, - [Symbol.asyncDispose]: async () => {}, - } as unknown as DeviceBinding; - - const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts); - const bindDevice = vi.fn(async (_device, use) => - narrowDeviceBinding(binding, use), - ) as unknown as BindDeviceRuntime; - return { device, captureSnapshot, findText, inspectFacts, bindDevice, facts }; -} - -function waitRequest(positionals: string[], flags: Record = {}): DaemonRequest { - return { - command: 'wait', - positionals, - token: 't', - session: 'wait-runtime', - flags, - meta: { requestId: 'wait-runtime-test' }, - } as unknown as DaemonRequest; -} - -test.each(['rebuild', 'retire'] as const)( - 'sleep-only wait records in its admitted lifetime across %s', - async (transition) => { - vi.useFakeTimers(); - try { - const harness = waitRuntimeHarness(); - const sessionStore = makeSessionStore(); - const address = 'cwd:sleep-wait:default'; - const ref = sessionStore.publish(address, makeSession('default', { device: harness.device })); - const running = handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: waitRequest(['100']), - sessionName: address, - logPath: '/tmp/daemon.log', - sessionStore, - inspectFacts: harness.inspectFacts, - bindDevice: harness.bindDevice, - }); - await vi.advanceTimersByTimeAsync(0); - let current = ref.session; - if (transition === 'rebuild') { - current = sessionStore.update(ref, { actions: [], appName: 'Updated during sleep' }); - } else { - sessionStore.retire(ref); - current = sessionStore.publish( - address, - makeSession('default', { device: harness.device }), - ).session; - } - await vi.advanceTimersByTimeAsync(100); - const response = await running; - expect(harness.captureSnapshot).not.toHaveBeenCalled(); - if (transition === 'rebuild') { - expect(response?.ok).toBe(true); - expect(current.actions.map((action) => action.command)).toEqual(['wait']); - expect(ref.session.actions).toEqual([]); - expect(current.appName).toBe('Updated during sleep'); - } else { - expect(response).toMatchObject({ ok: true, data: { waitedMs: 100 } }); - expect(ref.session.actions).toEqual([]); - expect(current.actions).toEqual([]); - } - } finally { - vi.useRealTimers(); - } - }, -); - -async function runWait( - positionals: string[], - harness: ReturnType, - sessionNodes: CaptureNode[] = [], - flags: Record = {}, -) { - const sessionStore = makeSessionStore('agent-device-wait-runtime-'); - const session = makeSession('wait-runtime', { device: harness.device }); - if (sessionNodes.length > 0) { - session.snapshot = { - nodes: sessionNodes.map((node) => ({ ...node, ref: `e${node.index + 1}` })), - createdAt: Date.now(), - backend: 'web', - } as unknown as NonNullable; - } - sessionStore.publish(session.name, session); - const response = await handleSnapshotCommands({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: waitRequest(positionals, flags), - sessionName: session.name, - logPath: '/tmp/daemon.log', - sessionStore, - inspectFacts: harness.inspectFacts, - bindDevice: harness.bindDevice, - }); - if (!response) throw new Error('the snapshot route did not handle wait'); - return { response, session, sessionStore }; -} - -// --------------------------------------------------------------------------- -// The duration shape. It is the one wait shape that reaches no device at all, -// and legacy admission skipped `requireCommandSupported` for exactly that -// reason. `resolveWaitRuntimePlan` returns a plan with no `use`, so there is -// nothing to inspect and nothing to bind — including on a target whose capture -// fact is unavailable. -// --------------------------------------------------------------------------- - -test('a duration wait inspects no facts and binds no device', async () => { - const harness = waitRuntimeHarness({ capture: captureUnavailable }); - - const { response } = await runWait(['5'], harness); - - expect(response).toMatchObject({ ok: true, data: { waitedMs: 5 } }); - expect(harness.inspectFacts).not.toHaveBeenCalled(); - expect(harness.bindDevice).not.toHaveBeenCalled(); - expect(harness.captureSnapshot).not.toHaveBeenCalled(); -}); - -test('an observing wait binds through the shared selector entry', async () => { - const harness = waitRuntimeHarness({ findText: available }); - - const bound = await resolveBoundSelectorCapture({ - platformServices: composedPlatformServicesFixture(), - command: 'wait', - session: undefined, - device: harness.device, - inspectFacts: harness.inspectFacts, - bindDevice: harness.bindDevice, - }); - - expect(bound.ok).toBe(true); - if (!bound.ok) return; - // Wait reaches the platform through the same owning interface as every other selector - // command; its preferred reading rides that record rather than a parallel binder. - expect(bound.operations.capture).toBeTypeOf('function'); - expect(bound.operations.findText).toBeTypeOf('function'); - expect(harness.inspectFacts).toHaveBeenCalledTimes(1); - expect(harness.bindDevice).toHaveBeenCalledTimes(1); -}); - -// --------------------------------------------------------------------------- -// Every observing shape: one inspection, one bind, one declared operation. -// --------------------------------------------------------------------------- - -test('a text wait binds the capture use once and polls through the bound operation', async () => { - const harness = waitRuntimeHarness(); - - const { response } = await runWait(['text', 'Ready'], harness); - - expect(response).toMatchObject({ ok: true, data: { text: 'Ready' } }); - expect(harness.inspectFacts).toHaveBeenCalledTimes(1); - expect(harness.inspectFacts).toHaveBeenCalledWith(harness.device); - expect(harness.bindDevice).toHaveBeenCalledTimes(1); - expect(harness.bindDevice).toHaveBeenCalledWith(harness.device, waitWithoutActiveAppUse); - expect(harness.captureSnapshot).toHaveBeenCalled(); -}); - -test('a selector wait binds the capture use once and polls through the bound operation', async () => { - const harness = waitRuntimeHarness(); - - const { response } = await runWait(['label=Ready'], harness); - - expect(response).toMatchObject({ ok: true, data: { selector: 'label=Ready' } }); - expect(harness.inspectFacts).toHaveBeenCalledTimes(1); - expect(harness.bindDevice).toHaveBeenCalledTimes(1); - expect(harness.bindDevice).toHaveBeenCalledWith(harness.device, waitWithoutActiveAppUse); -}); - -test('a @ref wait binds the capture use once and polls through the bound operation', async () => { - const harness = waitRuntimeHarness(); - - const { response } = await runWait(['@e1'], harness, [ - { index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }, - ]); - - expect(response).toMatchObject({ ok: true, data: { text: 'Ready' } }); - expect(harness.inspectFacts).toHaveBeenCalledTimes(1); - expect(harness.bindDevice).toHaveBeenCalledTimes(1); - expect(harness.bindDevice).toHaveBeenCalledWith(harness.device, waitWithoutActiveAppUse); -}); - -test('a stable wait binds the capture use once and polls through the bound operation', async () => { - const harness = waitRuntimeHarness(); - - const { response } = await runWait(['stable', '1', '5000'], harness); - - expect(response).toMatchObject({ ok: true, data: { captures: expect.any(Number) } }); - expect(harness.inspectFacts).toHaveBeenCalledTimes(1); - expect(harness.bindDevice).toHaveBeenCalledTimes(1); - expect(harness.bindDevice).toHaveBeenCalledWith(harness.device, waitWithoutActiveAppUse); -}); - -test.each([ - ['scope', { snapshotScope: 'Root' }], - ['depth', { snapshotDepth: 2 }], -])('strict wait absent refuses --%s before device admission', async (_option, flags) => { - const harness = waitRuntimeHarness(); - - const { response } = await runWait(['absent', 'label="Ready"', '400'], harness, [], flags); - - expect(response).toMatchObject({ - ok: false, - error: { - code: 'INVALID_ARGS', - details: { command: 'wait', predicate: 'absent' }, - }, - }); - if (!response.ok) { - expect(response.error.details?.rejectedOption).toBe(_option); - } - expect(harness.inspectFacts).not.toHaveBeenCalled(); - expect(harness.bindDevice).not.toHaveBeenCalled(); -}); - -// --------------------------------------------------------------------------- -// Facts are the only support authority: an unavailable exact-owner fact -// refuses BEFORE any binding, and provider ownership never borrows the local -// family runtime. -// --------------------------------------------------------------------------- - -test('an unavailable capture fact refuses an observing wait before binding', async () => { - const harness = waitRuntimeHarness({ capture: captureUnavailable }); - - const { response } = await runWait(['text', 'Ready'], harness); - - expect(response).toEqual({ - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'wait is not supported on this device', - details: { reason: captureUnavailable.reason }, - hint: captureUnavailable.hint, - }, - }); - expect(harness.inspectFacts).toHaveBeenCalledTimes(1); - expect(harness.bindDevice).not.toHaveBeenCalled(); - expect(harness.captureSnapshot).not.toHaveBeenCalled(); -}); - -test('a provider owner that cannot capture fails closed instead of borrowing the local runtime', async () => { - const harness = waitRuntimeHarness({ capture: captureUnavailable, providerOwned: true }); - - const { response } = await runWait(['label=Ready'], harness); - - expect(response).toMatchObject({ - ok: false, - error: { code: 'UNSUPPORTED_OPERATION' }, - }); - expect(harness.bindDevice).not.toHaveBeenCalled(); -}); - -// --------------------------------------------------------------------------- -// The timeout-surface decoration is wait's platform execution too. It reuses -// the SAME binding rather than reaching a second capture owner. -// --------------------------------------------------------------------------- - -test('a timed-out wait decorates its failure through the same single binding', async () => { - vi.useFakeTimers(); - try { - const harness = waitRuntimeHarness({ - nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }]], - }); - - const pending = runWait(['text', 'Ready', '1'], harness); - await vi.advanceTimersByTimeAsync(5_000); - const { response } = await pending; - - expect(response.ok).toBe(false); - if (response.ok) return; - expect(response.error.message).toContain('wait timed out for text: Ready'); - expect(response.error.message).toContain('Current surface: Checkout'); - expect(harness.inspectFacts).toHaveBeenCalledTimes(1); - expect(harness.bindDevice).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } -}); - -// --------------------------------------------------------------------------- -// The fact-conditional native observation (ADR 0019 §2). These are the regressions -// for the iOS Smoke failure this unit's first revision caused: -// `wait text "Last input: press"` -// performed 17 readable canonical-tree captures, never observed the target, -// and timed out with `wait_target_absent`. The target is reported by the -// owner's native reading and is absent from the canonical tree, so a -// tree-only poll cannot satisfy it — which is exactly what these assert. -// --------------------------------------------------------------------------- - -test('a text wait is satisfied by the owner native reading when the tree never carries it', async () => { - // The target appears on the owner's native reading from the second poll onward and NEVER - // appears in the canonical tree, which keeps reporting a different screen — the divergence - // iOS Smoke hit. The first poll therefore proves the tree cannot satisfy this wait, and the - // second proves the native reading can. - let nativeReads = 0; - const harness = waitRuntimeHarness({ - findText: available, - findTextAnswers: (text) => { - nativeReads += 1; - return text === 'Last input: press' && nativeReads > 1; - }, - nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }]], - }); - - const { response } = await runWait(['text', 'Last input: press', '2000'], harness); - - expect(response).toMatchObject({ ok: true, data: { text: 'Last input: press' } }); - expect(harness.findText.mock.calls[0]?.[0]).toMatchObject({ text: 'Last input: press' }); - // Not vacuous in either direction: the tree WAS consulted and could not satisfy the target, - // so the wait is satisfied only because the native reading answered. Delete the `findText` - // arm from `observeText` and this times out with `wait_target_absent` after burning the whole - // budget on readable captures — the exact shape iOS Smoke reported. - expect(harness.captureSnapshot).toHaveBeenCalled(); -}); - -test('a read after a natively satisfied text wait captures instead of reusing the older tree', async () => { - // Poll 1: the native reading misses and the capture still shows the previous screen, which it - // publishes to the session. The app then navigates, and poll 2's native reading sees the - // destination, so the stored tree is older than the observation that satisfied the wait. - let nativeReads = 0; - const harness = waitRuntimeHarness({ - findText: available, - findTextAnswers: () => { - nativeReads += 1; - return nativeReads > 1; - }, - nodesPerPoll: [ - [{ index: 0, depth: 0, type: 'StaticText', label: 'Home' }], - [ - { index: 0, depth: 0, type: 'StaticText', label: 'Automation lab' }, - { index: 1, depth: 0, type: 'StaticText', label: 'cold.start' }, - ], - ], - }); - const { - response: waited, - session, - sessionStore, - } = await runWait(['text', 'Automation lab', '2000'], harness); - expect(waited).toMatchObject({ ok: true, data: { text: 'Automation lab' } }); - expect(harness.captureSnapshot).toHaveBeenCalledOnce(); - - const read = await dispatchGetViaRuntime({ - createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), - req: { - command: 'get', - positionals: ['text', 'label="cold.start"'], - token: 't', - session: session.name, - flags: {}, - meta: { requestId: 'wait-runtime-get' }, - } as unknown as DaemonRequest, - sessionName: session.name, - logPath: '/tmp/daemon.log', - sessionStore, - inspectFacts: harness.inspectFacts, - bindDevice: harness.bindDevice, - }); - - expect(read).toMatchObject({ ok: true, data: { text: 'cold.start' } }); - expect(harness.captureSnapshot).toHaveBeenCalledTimes(2); -}); - -test('a satisfied native reading short-circuits the poll without capturing', async () => { - const harness = waitRuntimeHarness({ - findText: available, - findTextAnswers: () => true, - }); - - const { response } = await runWait(['text', 'Ready', '2000'], harness); - - expect(response).toMatchObject({ ok: true, data: { text: 'Ready' } }); - expect(harness.findText).toHaveBeenCalledTimes(1); - // The benefit the §9 measurement records: a satisfied wait costs one native query, not a - // full accessibility capture. - expect(harness.captureSnapshot).not.toHaveBeenCalled(); -}); - -test('a negative native reading is not an answer — the same poll consults the canonical tree', async () => { - const harness = waitRuntimeHarness({ - findText: available, - findTextAnswers: () => false, - nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }]], - }); - - const { response } = await runWait(['text', 'Ready', '2000'], harness); - - expect(response).toMatchObject({ ok: true, data: { text: 'Ready' } }); - expect(harness.findText).toHaveBeenCalled(); - // A negative native observation never replaces the capture-backed half of the same poll. - expect(harness.captureSnapshot).toHaveBeenCalled(); -}); - -test('an owner that advertises no native reading polls the tree only', async () => { - const harness = waitRuntimeHarness({ - nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }]], - }); - - const { response } = await runWait(['text', 'Ready', '2000'], harness); - - expect(response).toMatchObject({ ok: true, data: { text: 'Ready' } }); - expect(harness.findText).not.toHaveBeenCalled(); - expect(harness.captureSnapshot).toHaveBeenCalled(); - expect(harness.bindDevice).toHaveBeenCalledTimes(1); -}); - -test('native text failure cannot hide a canonical capture failure', async () => { - const harness = waitRuntimeHarness({ - findText: available, - findTextAnswers: () => { - throw new Error('native observation failed'); - }, - captureSnapshot: async () => { - throw new AppError('COMMAND_FAILED', 'canonical capture failed', { - reason: 'capture_failed', - }); - }, - }); - const { response } = await runWait(['text', 'Ready', '200'], harness); - expect(response).toMatchObject({ - ok: false, - error: { - code: 'COMMAND_FAILED', - message: 'canonical capture failed', - details: { reason: 'capture_failed' }, - }, - }); - expect(harness.findText).toHaveBeenCalledOnce(); - expect(harness.captureSnapshot).toHaveBeenCalledOnce(); -}); - -test('an unavailable conditional observation preserves the capture-backed owner path', async () => { - const harness = waitRuntimeHarness({ - findText: findTextUnavailable, - nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }]], - }); - - const { response } = await runWait(['text', 'Ready', '2000'], harness); - - expect(response.ok).toBe(true); - expect(harness.inspectFacts).toHaveBeenCalledTimes(1); - expect(harness.bindDevice).toHaveBeenCalledTimes(1); -}); - -// --------------------------------------------------------------------------- -// The conditional reading and the capture it short-circuits must run in the SAME -// runner context. Asserted as an equality against capture's own context rather -// than field by field, so the check cannot rot into a partial one when a new -// execution field is added: whatever capture carries, findText must carry too. -// --------------------------------------------------------------------------- - -test('the native text reading runs in the same runner context as the capture', async () => { - let nativeReads = 0; - const harness = waitRuntimeHarness({ - findText: available, - // Miss on the first poll so BOTH legs run and their contexts can be compared. - findTextAnswers: () => { - nativeReads += 1; - return nativeReads > 1; - }, - nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }]], - }); - - const { response } = await runWait(['text', 'Ready', '4000'], harness, [], { - debug: true, - iosXctestrunFile: '/tmp/override.xctestrun', - iosXctestDerivedDataPath: '/tmp/derived', - }); - - expect(response.ok).toBe(true); - const captureExecution = harness.captureSnapshot.mock.calls[0]?.[0]?.execution; - const findTextExecution = harness.findText.mock.calls[0]?.[0]?.execution; - // Non-trivially populated: a comparison of two undefineds would prove nothing. - expect(captureExecution).toMatchObject({ - requestId: 'wait-runtime-test', - iosXctestrunFile: '/tmp/override.xctestrun', - iosXctestDerivedDataPath: '/tmp/derived', - }); - expect(findTextExecution).toEqual(captureExecution); -}); - -// --------------------------------------------------------------------------- -// Per-poll cancellation, end to end (`CaptureSnapshotInput.signal`). -// -// A binding's signal is fixed at bind time and `wait` binds once and polls many -// times, so each poll enforces its own remaining budget by ABORTING that -// capture and then waiting for it to quiesce. Without the per-capture signal -// the deadline never reaches the platform: a stalled capture consumes the whole -// request and `WAIT_REASONS.captureStalled` becomes unreachable. -// --------------------------------------------------------------------------- - -/** A capture that never settles on its own — it resolves only once its poll deadline aborts it. */ -function stallingCapture() { - const seen: AbortSignal[] = []; - const settledAt: number[] = []; - const captureSnapshot = vi.fn(async (input: CaptureSnapshotInput) => { - const signal = input.signal; - if (!signal) throw new Error('the poll deadline never reached the platform'); - seen.push(signal); - await new Promise((resolve) => { - if (signal.aborted) return resolve(); - signal.addEventListener('abort', () => resolve(), { once: true }); - }); - // A late capture: it settles only after its deadline fired. - settledAt.push(Date.now()); - throw new DOMException('Capture aborted', 'AbortError'); - }); - return { captureSnapshot, seen, settledAt }; -} - -test('a poll deadline aborts the in-flight capture, and aborts it as a deadline', async () => { - const stalling = stallingCapture(); - const harness = waitRuntimeHarness({ captureSnapshot: stalling.captureSnapshot }); - - await runWait(['text', 'Ready', '150'], harness); - - expect(stalling.seen.length).toBeGreaterThan(0); - const signal = stalling.seen[0]!; - expect(signal.aborted).toBe(true); - // Not merely "aborted": a request-end abort would also satisfy that. The deadline controller - // aborts with its own TimeoutError, which is what proves the POLL budget did the cancelling. - expect((signal.reason as DOMException | undefined)?.name).toBe('TimeoutError'); -}); - -test('the poll awaits the aborted capture instead of abandoning it', async () => { - const stalling = stallingCapture(); - const harness = waitRuntimeHarness({ captureSnapshot: stalling.captureSnapshot }); - - await runWait(['text', 'Ready', '150'], harness); - const returnedAt = Date.now(); - - // Ordering is the property, not the abort. `runWithinWaitDeadline` deliberately does not - // race-and-abandon: a late capture must not be able to write session state after the wait - // has already returned. - expect(stalling.settledAt.length).toBeGreaterThan(0); - for (const settled of stalling.settledAt) expect(settled).toBeLessThanOrEqual(returnedAt); -}); - -test('a stalled capture reports capture-stalled with no readable captures', async () => { - const stalling = stallingCapture(); - const harness = waitRuntimeHarness({ captureSnapshot: stalling.captureSnapshot }); - - const { response } = await runWait(['text', 'Ready', '150'], harness); - - expect(response.ok).toBe(false); - if (response.ok) return; - // Distinct from `wait_target_absent` (readable captures that lacked the target) and from - // `wait_deadline_exceeded` (a poll truncated after readable captures). Three separate paths in - // `waitTimeoutError`; collapsing any pair loses real diagnostic information. - expect(response.error.details?.reason).toBe(WAIT_REASONS.captureStalled); - expect(response.error.details?.captureStalled).toBe(true); - expect(response.error.details?.readableCaptures).toBe(0); -}); - -test('strict wait absent reports capture-stalled when every capture stalls', async () => { - const stalling = stallingCapture(); - const harness = waitRuntimeHarness({ captureSnapshot: stalling.captureSnapshot }); - - const { response } = await runWait(['absent', 'label="Ready"', '150'], harness); - - expect(response.ok).toBe(false); - if (response.ok) return; - expect(response.error.details?.reason).toBe(WAIT_REASONS.captureStalled); - expect(response.error.details?.readableCaptures).toBe(0); -}); - -test('a runner restart that exhausts the wait reports typed restart evidence', async () => { - const captureSnapshot = vi.fn(async (input: CaptureSnapshotInput) => { - const signal = input.signal; - if (!signal) throw new Error('the poll deadline never reached the platform'); - await new Promise((resolve) => { - if (signal.aborted) return resolve(); - signal.addEventListener('abort', () => resolve(), { once: true }); - }); - throw new AppError('COMMAND_FAILED', 'request canceled', { - runnerRestarted: true, - runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send', - runnerRestartCommand: 'snapshot', - runnerRestartCommandId: 'snapshot-1', - runnerInvalidatedSessionId: 'session-old', - runnerRestartSessionId: 'session-new', - diagnosticId: 'diag-restart', - logPath: '/tmp/restart.ndjson', - }); - }); - const harness = waitRuntimeHarness({ captureSnapshot }); - - const { response } = await runWait(['text', 'Ready', '50'], harness); - - expect(response.ok).toBe(false); - if (response.ok) return; - expect(response.error.details).toMatchObject({ - reason: WAIT_REASONS.runnerRestartExhausted, - waitRunnerRestartExhausted: true, - runnerRestarted: true, - runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send', - runnerRestartCommand: 'snapshot', - runnerRestartCommandId: 'snapshot-1', - runnerInvalidatedSessionId: 'session-old', - runnerRestartSessionId: 'session-new', - diagnosticId: 'diag-restart', - logPath: '/tmp/restart.ndjson', - readableCaptures: 0, - }); - expect(response.error.details?.captureStalled).toBeUndefined(); -}); - -test('strict wait absent preserves runner-restart exhaustion as the deadline reason', async () => { - const captureSnapshot = vi.fn(async (input: CaptureSnapshotInput) => { - const signal = input.signal; - if (!signal) throw new Error('the poll deadline never reached the platform'); - await new Promise((resolve) => { - if (signal.aborted) return resolve(); - signal.addEventListener('abort', () => resolve(), { once: true }); - }); - throw new AppError('COMMAND_FAILED', 'request canceled', { - runnerRestarted: true, - runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send', - runnerRestartCommand: 'snapshot', - }); - }); - const harness = waitRuntimeHarness({ captureSnapshot }); - - const { response } = await runWait(['absent', 'label="Ready"', '50'], harness); - - expect(response.ok).toBe(false); - if (response.ok) return; - expect(response.error.details?.reason).toBe(WAIT_REASONS.runnerRestartExhausted); - expect(response.error.details?.readableCaptures).toBe(0); -}); - -test('strict wait absent does not mask a runner restart after an earlier present capture', async () => { - let poll = 0; - const captureSnapshot = vi.fn(async (input: CaptureSnapshotInput) => { - if (poll++ === 0) { - return { - nodes: [{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }], - backend: 'web' as const, - producer: 'agent-browser' as const, - }; - } - const signal = input.signal; - if (!signal) throw new Error('the poll deadline never reached the platform'); - await new Promise((resolve) => { - if (signal.aborted) return resolve(); - signal.addEventListener('abort', () => resolve(), { once: true }); - }); - throw new AppError('COMMAND_FAILED', 'request canceled', { - runnerRestarted: true, - runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send', - runnerRestartCommand: 'snapshot', - }); - }); - const harness = waitRuntimeHarness({ captureSnapshot }); - - const { response } = await runWait(['absent', 'label="Ready"', '800'], harness); - - expect(response.ok).toBe(false); - if (response.ok) return; - expect(response.error.details).toMatchObject({ - reason: WAIT_REASONS.runnerRestartExhausted, - waitRunnerRestartExhausted: true, - runnerRestarted: true, - retriable: true, - readableCaptures: 1, - }); - expect(response.error.details?.reason).not.toBe(WAIT_REASONS.targetPresent); -}); - -/** - * The platform's cancellation as production throws it when the wait deadline lands mid-capture. - * With a phase, the runner start or the Simulator app discovery was still running; without one, - * the capture was cancelled in steady-state work such as the cached target re-check. - */ -function cancelledCapture(phase: ReadinessPhase | undefined, beforeStall: SnapshotResult[] = []) { - const readable = [...beforeStall]; - return vi.fn(async (input: CaptureSnapshotInput) => { - const next = readable.shift(); - if (next) return next; - const signal = input.signal; - if (!signal) throw new Error('the poll deadline never reached the platform'); - await new Promise((resolve) => { - if (signal.aborted) return resolve(); - signal.addEventListener('abort', () => resolve(), { once: true }); - }); - throw createRequestCanceledError(phase ? { readinessPhase: phase } : {}, signal.reason); - }); -} - -test.for(['runner-start', 'target-discovery'] as const)( - 'a %s that outlasts the wait reports readiness exhaustion, not a capture stall', - async (phase) => { - const harness = waitRuntimeHarness({ captureSnapshot: cancelledCapture(phase) }); - - const { response } = await runWait(['text', 'Ready', '50'], harness); - - expect(response.ok).toBe(false); - if (response.ok) return; - expect(response.error.details).toMatchObject({ - reason: WAIT_REASONS.readinessExhausted, - readinessPhase: phase, - retriable: true, - readableCaptures: 0, - captures: 1, - polls: [{ startedMs: expect.any(Number), outcome: 'readiness' }], - }); - expect(response.error.details?.captureStalled).toBeUndefined(); - }, -); - -test('strict wait absent reports readiness exhaustion over an earlier present capture', async () => { - const captureSnapshot = cancelledCapture('target-discovery', [ - { - nodes: [{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }], - backend: 'web', - producer: 'agent-browser', - }, - ]); - const harness = waitRuntimeHarness({ captureSnapshot }); - - const { response } = await runWait(['absent', 'label="Ready"', '800'], harness); - - expect(response.ok).toBe(false); - if (response.ok) return; - expect(response.error.details).toMatchObject({ - reason: WAIT_REASONS.readinessExhausted, - readinessPhase: 'target-discovery', - readableCaptures: 1, - }); -}); - -test('an earlier retriable refusal outranks readiness work on the final poll', async () => { - // Live on iOS: the runner answered APP_NOT_RUNNING, then the next poll's app discovery was cut by - // the deadline. The refusal is the actionable answer; readiness stays in the evidence. - const notRunning = new AppError('COMMAND_FAILED', "app 'com.example.app' is not running", { - runnerErrorCode: 'APP_NOT_RUNNING', - retriable: true, - }); - let poll = 0; - const readiness = cancelledCapture('target-discovery'); - const captureSnapshot = vi.fn(async (input: CaptureSnapshotInput) => { - if (poll++ === 0) throw notRunning; - return await readiness(input); - }); - const harness = waitRuntimeHarness({ captureSnapshot }); - - const { response } = await runWait(['text', 'Ready', '800'], harness); - - expect(response.ok).toBe(false); - if (response.ok) return; - expect(response.error.message).toContain('is not running'); - expect(response.error.details).toMatchObject({ - reason: WAIT_REASONS.captureStalled, - runnerErrorCode: 'APP_NOT_RUNNING', - readinessPhase: 'target-discovery', - readableCaptures: 0, - }); - expect(response.error.details?.polls).toMatchObject([ - { outcome: 'retriable' }, - { outcome: 'readiness' }, - ]); -}); - -test('strict wait absent keeps its present evidence when a steady-state capture is cancelled', async () => { - const captureSnapshot = cancelledCapture(undefined, [ - { - nodes: [{ index: 0, depth: 0, type: 'Button', label: 'Ready', hittable: true }], - backend: 'web', - producer: 'agent-browser', - }, - ]); - const harness = waitRuntimeHarness({ captureSnapshot }); - - const { response } = await runWait(['absent', 'label="Ready"', '800'], harness); - - expect(response.ok).toBe(false); - if (response.ok) return; - expect(response.error.details).toMatchObject({ - reason: WAIT_REASONS.targetPresent, - readableCaptures: 1, - }); - expect(response.error.details?.readinessPhase).toBeUndefined(); -}); - -test('a positive wait whose steady-state capture is cancelled reports the deadline, not readiness', async () => { - const captureSnapshot = cancelledCapture(undefined, [ - { - nodes: [{ index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }], - backend: 'web', - producer: 'agent-browser', - }, - ]); - const harness = waitRuntimeHarness({ captureSnapshot }); - - const { response } = await runWait(['text', 'Ready', '800'], harness); - - expect(response.ok).toBe(false); - if (response.ok) return; - expect(response.error.details).toMatchObject({ - reason: WAIT_REASONS.deadlineExceeded, - readableCaptures: 1, - }); - expect(response.error.details?.readinessPhase).toBeUndefined(); -}); - -test('a readable capture that lacks the target stays target-absent, not capture-stalled', async () => { - const harness = waitRuntimeHarness({ - nodesPerPoll: [[{ index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }]], - }); - - const { response } = await runWait(['text', 'Ready', '400'], harness); - - expect(response.ok).toBe(false); - if (response.ok) return; - expect(response.error.details?.reason).toBe(WAIT_REASONS.targetAbsent); - expect(response.error.details?.readableCaptures).toBeGreaterThan(0); -}); diff --git a/src/__tests__/snapshot-desktop-surface.test.ts b/src/__tests__/snapshot-desktop-surface.test.ts index d55d3869cd..4fee92a369 100644 --- a/src/__tests__/snapshot-desktop-surface.test.ts +++ b/src/__tests__/snapshot-desktop-surface.test.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import type { SnapshotRuntimeAcquiredResult } from '@agent-device/contracts/interactor-types'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { eagerClosureOf } from '../__tests__/eager-import-closure.fixtures.ts'; +import { eagerClosureOf } from '@agent-device/test-kit'; const { captureLinuxSurfaceSnapshot, captureMacOsSurfaceSnapshot } = vi.hoisted(() => ({ captureLinuxSurfaceSnapshot: vi.fn(), diff --git a/src/commands/capture/runtime/screenshot.test.ts b/src/commands/capture/runtime/screenshot.test.ts new file mode 100644 index 0000000000..49efcb169b --- /dev/null +++ b/src/commands/capture/runtime/screenshot.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { PNG } from '@agent-device/capture-kit/png'; +import type { AgentDeviceBackend } from '@agent-device/contracts/backend'; +import { localCommandPolicy } from '@agent-device/contracts/command-policy'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { test } from 'vitest'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { createAgentDevice } from '../../../runtime.ts'; + +test('runtime screenshot applies scale and forwards the capture rotation', async () => { + const outPath = path.join(mkdtempForTestSync('runtime-screenshot-command-'), 'scaled.png'); + const device = createAgentDevice({ + backend: { + platform: 'ios', + captureScreenshot: async (_context, requestedPath) => { + fs.writeFileSync(requestedPath, solidPngBuffer(100, 50)); + return { displayRotation: 'landscape-left' }; + }, + } satisfies AgentDeviceBackend, + artifacts: createLocalArtifactAdapter(), + sessions: { get: () => undefined, set: () => {} }, + policy: localCommandPolicy(), + }); + + const result = await device.capture.screenshot({ + out: { kind: 'path', path: outPath }, + scale: 0.5, + }); + const scaled = PNG.sync.read(fs.readFileSync(outPath)); + + assert.deepEqual({ width: scaled.width, height: scaled.height }, { width: 50, height: 25 }); + assert.equal(result.displayRotation, 'landscape-left'); +}); + +function solidPngBuffer(width: number, height: number): Buffer { + const png = new PNG({ width, height }); + for (let index = 3; index < png.data.length; index += 4) png.data[index] = 255; + return PNG.sync.write(png); +} diff --git a/src/commands/capture/runtime/snapshot.test.ts b/src/commands/capture/runtime/snapshot.test.ts index 6d73cec966..2e174ad365 100644 --- a/src/commands/capture/runtime/snapshot.test.ts +++ b/src/commands/capture/runtime/snapshot.test.ts @@ -841,6 +841,25 @@ test('runtime snapshot warns when its tree was read on a surface still moving af assert.deepEqual(result.warnings, [formatPostGestureOutcomeWarning(outcome)]); }); +test('runtime snapshot warns when a gesture produced no visible change', async () => { + const outcome: PostGestureOutcome = { + kind: 'no-effect', + gesture: { action: 'scroll', positionals: ['up'] }, + }; + const device = createSnapshotOnlyDevice({ + snapshot: { + ...makeSnapshotState([{ index: 0, depth: 0, type: 'Window', label: 'Home' }], { + backend: 'xctest', + }), + postGestureOutcome: outcome, + }, + }); + + const result = await device.capture.snapshot({ session: 'default' }); + + assert.deepEqual(result.warnings, [formatPostGestureOutcomeWarning(outcome)]); +}); + // The box a capture's rects are measured in is public output (#3182) for the same reason the // keyboard band is: `snapshot --json` is how a caller learns the surface to scale against, and a // producer that measured no box has to leave the key off rather than claim a screen it never read. diff --git a/src/commands/interaction/runtime/__tests__/test-utils/abort-with.ts b/src/commands/interaction/runtime/__tests__/test-utils/abort-with.ts new file mode 100644 index 0000000000..f286594e6e --- /dev/null +++ b/src/commands/interaction/runtime/__tests__/test-utils/abort-with.ts @@ -0,0 +1,8 @@ +export function abortWith(signal: AbortSignal | undefined, error: unknown): Promise { + if (!signal) return Promise.reject(new Error('the wait deadline never reached the capture')); + return new Promise((_resolve, reject) => { + const cancel = () => reject(error); + if (signal.aborted) cancel(); + else signal.addEventListener('abort', cancel, { once: true }); + }); +} diff --git a/src/commands/interaction/runtime/selector-is.test.ts b/src/commands/interaction/runtime/selector-is.test.ts index 5f1b38feeb..6e2a13eaf3 100644 --- a/src/commands/interaction/runtime/selector-is.test.ts +++ b/src/commands/interaction/runtime/selector-is.test.ts @@ -335,6 +335,36 @@ test('runtime is absent fails closed for a sparse capture and preserves the last assert.equal((await sessions.get('default'))?.snapshot?.nodes[0]?.label, 'Initial'); }); +test('runtime is absent fails closed for a quality-less legacy iOS root-only capture', async () => { + const legacyRootOnly = makeSnapshotState([{ index: 0, type: 'XCUIElementTypeApplication' }], { + backend: 'xctest', + producer: 'apple-runner', + }); + const device = createSelectorDevice(legacyRootOnly); + + await assert.rejects( + device.selectors.is({ + session: 'default', + predicate: 'absent', + selector: 'label="Removed row"', + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details?.reason, 'predicate_failed'); + assert.equal(error.details?.observation, 'sparse'); + assert.equal(error.details?.matches, 0); + assert.deepEqual(error.details?.snapshotQuality, { + state: 'sparse', + backend: 'tree', + reason: 'legacy iOS capture exposed only the application root', + reasonCode: 'sparse-tree', + }); + return true; + }, + ); +}); + test('runtime is absent fails closed for a truncated capture even with zero matches', async () => { const snapshot = makeSnapshotState([]); const device = createSelectorDevice(snapshot, { @@ -441,6 +471,37 @@ test('runtime is admits an upper-case predicate and evaluates it as the normaliz assert.equal(text.text, 'Greeting'); }); +test('runtime is reports a mismatched text predicate as a typed command failure', async () => { + const device = createSelectorDevice( + makeSnapshotState([ + { + index: 0, + type: 'XCUIElementTypeButton', + identifier: 'account_row', + label: 'Apple Account', + }, + ]), + ); + + await assert.rejects( + device.selectors.is({ + session: 'default', + predicate: 'text', + selector: 'id=account_row', + expectedText: 'Wrong Expected Text', + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details?.reason, 'predicate_failed'); + assert.equal(error.details?.predicate, 'text'); + assert.match(String(error.details?.predicateDetails), /expected="Wrong Expected Text"/); + assert.match(String(error.details?.predicateDetails), /actual="Apple Account"/); + return true; + }, + ); +}); + test('runtime is still refuses a predicate that is not in the vocabulary', async () => { const device = createSelectorDevice( makeSnapshotState([{ index: 0, depth: 0, type: 'StaticText', label: 'Greeting' }]), diff --git a/src/commands/interaction/runtime/settle-generic-surface.test.ts b/src/commands/interaction/runtime/settle-generic-surface.test.ts new file mode 100644 index 0000000000..9f6c0e186e --- /dev/null +++ b/src/commands/interaction/runtime/settle-generic-surface.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { SnapshotState } from '@agent-device/kernel/snapshot'; +import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; +import { surfaceScopedNodes } from '@agent-device/capture-kit/snapshot-baseline'; +import { + buttonSnapshot, + createSettleDevice, + welcomeSnapshot, +} from './__tests__/settle-device-fixtures.ts'; +import { settleObservationCommand } from './settle.ts'; + +const SHEET_BUNDLE_ID = 'com.apple.SafariViewService'; + +function signInSheetSnapshot(): SnapshotState { + return { + ...makeSnapshotState( + ['Continue', 'Sign in with Example', 'Email', 'Password', 'Cancel'].map((label, index) => ({ + index, + depth: 0, + type: 'Button', + label, + rect: { x: 10, y: 20 + index * 50, width: 200, height: 40 }, + hittable: true, + })), + ), + iosSystemSurfaceBundleId: SHEET_BUNDLE_ID, + }; +} + +async function genericSettle(baseline: SnapshotState, after: SnapshotState) { + const device = createSettleDevice({ + stored: baseline, + captureSnapshot: () => ({ snapshot: after }), + }); + const settle = await settleObservationCommand(device, { + session: 'default', + baseline: surfaceScopedNodes(baseline), + quietMs: 25, + timeoutMs: 5_000, + }); + return { device, settle }; +} + +const app = welcomeSnapshot(); +const sheet = signInSheetSnapshot(); + +for (const route of ['scroll', 'back'] as const) { + for (const transition of [ + { name: 'app-to-sheet', baseline: app, after: sheet, from: 'app', to: SHEET_BUNDLE_ID }, + { name: 'sheet-to-app', baseline: sheet, after: app, from: SHEET_BUNDLE_ID, to: 'app' }, + ]) { + test(`targetless ${route} settle discloses the ${transition.name} surface change`, async () => { + const { device, settle } = await genericSettle(transition.baseline, transition.after); + + assert.equal(settle.settled, true); + assert.equal(settle.surfaceChange?.from, transition.from); + assert.equal(settle.surfaceChange?.to, transition.to); + assert.ok(settle.surfaceChange?.disclosure); + assert.equal(settle.diff, undefined); + assert.equal(settle.tail, undefined); + assert.match(settle.hint ?? '', /different surfaces/); + assert.match(settle.hint ?? '', /take a snapshot/i); + const stored = (await device.sessions.get('default')) as { snapshot?: SnapshotState }; + assert.equal( + stored.snapshot?.iosSystemSurfaceBundleId, + transition.after.iosSystemSurfaceBundleId, + ); + }); + } +} + +function presentedSheetBaseline(nodeCount: number): SnapshotState { + return { + ...makeSnapshotState( + Array.from({ length: nodeCount }, (_, index) => ({ + index, + depth: 0, + type: 'Button', + label: `Sheet action ${index}`, + })), + ), + presentedSurface: 'presented', + }; +} + +for (const [flavour, nodeCount] of [ + ['interactive', 4], + ['full', 6], +] as const) { + test(`targetless back settle confirms a dismissed sheet from the ${flavour} baseline`, async () => { + const { settle } = await genericSettle(presentedSheetBaseline(nodeCount), welcomeSnapshot()); + + assert.equal(settle.settled, true); + assert.ok(settle.waitedMs >= 1_500, `confirmation ended after ${settle.waitedMs}ms`); + }); +} + +test('targetless back settle keeps the plain window when the baseline presented nothing', async () => { + const absent = { ...buttonSnapshot(), presentedSurface: 'absent' as const }; + const { settle } = await genericSettle(absent, welcomeSnapshot()); + + assert.equal(settle.settled, true); + assert.ok(settle.waitedMs < 1_500, `plain window ended after ${settle.waitedMs}ms`); +}); + +test('targetless back settle confirms a broad replacement when the baseline fact is unknown', async () => { + const { settle } = await genericSettle(buttonSnapshot(), welcomeSnapshot()); + + assert.equal(settle.settled, true); + assert.ok(settle.waitedMs >= 1_500, `confirmation ended after ${settle.waitedMs}ms`); +}); diff --git a/src/commands/interaction/runtime/wait-absent.test.ts b/src/commands/interaction/runtime/wait-absent.test.ts index 764db219b8..9fea3acc6e 100644 --- a/src/commands/interaction/runtime/wait-absent.test.ts +++ b/src/commands/interaction/runtime/wait-absent.test.ts @@ -1,13 +1,19 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import type { AgentDeviceBackend, BackendSnapshotResult } from '@agent-device/contracts/backend'; +import { test, vi } from 'vitest'; +import type { + AgentDeviceBackend, + BackendCommandContext, + BackendSnapshotResult, +} from '@agent-device/contracts/backend'; import { createLocalArtifactAdapter } from '../../../io.ts'; import { createAgentDevice } from '../../../runtime.ts'; import { localCommandPolicy } from '@agent-device/contracts/command-policy'; import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; import { createFakeClock } from './__tests__/test-utils/index.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { WAIT_REASONS } from '@agent-device/contracts/wait'; import { createMemorySessionStore } from '../../../command-runtime/runtime-factory.ts'; +import { abortWith } from './__tests__/test-utils/abort-with.ts'; const SELECTOR = 'label="Removed"'; @@ -52,6 +58,31 @@ function absentDevice( }); } +function cancelledAbsentDevice(params: { + firstSnapshot?: BackendSnapshotResult; + cancellation: AppError; +}) { + let captures = 0; + const captureSnapshot = vi.fn(async (context: BackendCommandContext) => { + captures += 1; + if (captures === 1 && params.firstSnapshot) return params.firstSnapshot; + const signal = context.signal; + return await abortWith(signal, params.cancellation); + }); + const clock = { now: () => 0, sleep: async (_durationMs: number) => {} }; + const device = createAgentDevice({ + backend: { + platform: 'ios', + captureSnapshot, + } satisfies AgentDeviceBackend, + artifacts: createLocalArtifactAdapter(), + sessions: createMemorySessionStore([{ name: 'default' }]), + policy: localCommandPolicy(), + clock, + }); + return { device, captureSnapshot }; +} + async function waitAbsent(device: ReturnType, timeoutMs = 1000) { return await device.selectors.wait({ session: 'default', @@ -222,3 +253,129 @@ test('wait absent preserves a final Android unreadable diagnostic when no valid return true; }); }); + +test('strict wait absent reports capture-stalled when every capture stalls', async () => { + vi.useFakeTimers(); + try { + const { device, captureSnapshot } = cancelledAbsentDevice({ + cancellation: new AppError('COMMAND_FAILED', 'capture cancelled'), + }); + const pending = waitAbsent(device, 20); + const failure = assert.rejects(pending, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, WAIT_REASONS.captureStalled); + assert.equal(error.details?.captureStalled, true); + assert.equal(error.details?.readableCaptures, 0); + return true; + }); + + await vi.advanceTimersByTimeAsync(20); + await failure; + assert.equal(captureSnapshot.mock.calls.length, 1); + } finally { + vi.useRealTimers(); + } +}); + +test('strict wait absent keeps target-present evidence when a steady-state capture is cancelled', async () => { + vi.useFakeTimers(); + try { + const { device, captureSnapshot } = cancelledAbsentDevice({ + firstSnapshot: snapshot('Removed'), + cancellation: new AppError('COMMAND_FAILED', 'capture cancelled'), + }); + const pending = waitAbsent(device, 20); + const failure = assert.rejects(pending, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, WAIT_REASONS.targetPresent); + assert.equal(error.details?.readableCaptures, 1); + assert.equal(error.details?.readinessPhase, undefined); + return true; + }); + + await vi.advanceTimersByTimeAsync(20); + await failure; + assert.equal(captureSnapshot.mock.calls.length, 2); + } finally { + vi.useRealTimers(); + } +}); + +test('strict wait absent preserves runner-restart exhaustion over an earlier present capture', async () => { + vi.useFakeTimers(); + try { + const { device, captureSnapshot } = cancelledAbsentDevice({ + firstSnapshot: snapshot('Removed'), + cancellation: new AppError('COMMAND_FAILED', 'request cancelled', { + runnerRestarted: true, + runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send', + }), + }); + const pending = waitAbsent(device, 20); + const failure = assert.rejects(pending, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, WAIT_REASONS.runnerRestartExhausted); + assert.equal(error.details?.runnerRestarted, true); + assert.equal(error.details?.readableCaptures, 1); + assert.equal(error.details?.captureStalled, undefined); + return true; + }); + + await vi.advanceTimersByTimeAsync(20); + await failure; + assert.equal(captureSnapshot.mock.calls.length, 2); + } finally { + vi.useRealTimers(); + } +}); + +test('strict wait absent keeps runner-restart exhaustion as the deadline reason', async () => { + vi.useFakeTimers(); + try { + const { device, captureSnapshot } = cancelledAbsentDevice({ + cancellation: new AppError('COMMAND_FAILED', 'request cancelled', { + runnerRestarted: true, + runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send', + }), + }); + const pending = waitAbsent(device, 20); + const failure = assert.rejects(pending, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, WAIT_REASONS.runnerRestartExhausted); + assert.equal(error.details?.readableCaptures, 0); + return true; + }); + + await vi.advanceTimersByTimeAsync(20); + await failure; + assert.equal(captureSnapshot.mock.calls.length, 1); + } finally { + vi.useRealTimers(); + } +}); + +test('strict wait absent preserves readiness exhaustion over an earlier present capture', async () => { + vi.useFakeTimers(); + try { + const { device, captureSnapshot } = cancelledAbsentDevice({ + firstSnapshot: snapshot('Removed'), + cancellation: new AppError('COMMAND_FAILED', 'request cancelled', { + readinessPhase: 'target-discovery', + }), + }); + const pending = waitAbsent(device, 20); + const failure = assert.rejects(pending, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, WAIT_REASONS.readinessExhausted); + assert.equal(error.details?.readinessPhase, 'target-discovery'); + assert.equal(error.details?.readableCaptures, 1); + return true; + }); + + await vi.advanceTimersByTimeAsync(20); + await failure; + assert.equal(captureSnapshot.mock.calls.length, 2); + } finally { + vi.useRealTimers(); + } +}); diff --git a/src/commands/interaction/runtime/wait-deadline.test.ts b/src/commands/interaction/runtime/wait-deadline.test.ts index d3a68d377f..6cffaefc95 100644 --- a/src/commands/interaction/runtime/wait-deadline.test.ts +++ b/src/commands/interaction/runtime/wait-deadline.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import { runWithinWaitDeadline } from './wait-deadline.ts'; @@ -32,3 +32,55 @@ test('wait deadline combines runtime and command cancellation authorities', asyn assert.equal(observedAbort, true); await rejected; }); + +test('a poll deadline aborts the capture with a TimeoutError reason', async () => { + vi.useFakeTimers(); + try { + let observedSignal: AbortSignal | undefined; + const pending = runWithinWaitDeadline({}, {}, 10, async (signal) => { + observedSignal = signal; + await new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + }); + }); + + await vi.advanceTimersByTimeAsync(10); + const result = await pending; + + assert.deepEqual(result, { timedOut: true }); + assert.equal(observedSignal?.aborted, true); + assert.equal((observedSignal?.reason as DOMException | undefined)?.name, 'TimeoutError'); + } finally { + vi.useRealTimers(); + } +}); + +test('the wait deadline awaits the capture after aborting it', async () => { + vi.useFakeTimers(); + try { + let observedSignal: AbortSignal | undefined; + let releaseCapture: (() => void) | undefined; + let returned = false; + const pending = runWithinWaitDeadline({}, {}, 10, async (signal) => { + observedSignal = signal; + await new Promise((resolve) => { + releaseCapture = resolve; + }); + return 'capture finished'; + }).then((result) => { + returned = true; + return result; + }); + + await vi.advanceTimersByTimeAsync(10); + assert.equal(observedSignal?.aborted, true); + assert.equal((observedSignal?.reason as DOMException | undefined)?.name, 'TimeoutError'); + assert.equal(returned, false); + + releaseCapture?.(); + assert.deepEqual(await pending, { timedOut: true }); + assert.equal(returned, true); + } finally { + vi.useRealTimers(); + } +}); diff --git a/src/commands/interaction/runtime/wait-polling.test.ts b/src/commands/interaction/runtime/wait-polling.test.ts index 0a6eab58aa..c7c55f795f 100644 --- a/src/commands/interaction/runtime/wait-polling.test.ts +++ b/src/commands/interaction/runtime/wait-polling.test.ts @@ -1,9 +1,11 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import type { AgentDeviceRuntime } from '@agent-device/contracts/runtime-contract'; import { SELECTOR_PIPELINE_POLICIES } from '@agent-device/selectors/selector-pipeline-policy'; import { AppError } from '@agent-device/kernel/errors'; +import { WAIT_REASONS, type ReadinessPhase } from '@agent-device/contracts/wait'; import { createWaitPolling, waitTimeoutError } from './wait-polling.ts'; +import { abortWith } from './__tests__/test-utils/abort-with.ts'; test('poll delay is bounded by the remaining wait budget', async () => { let currentMs = 0; @@ -272,6 +274,199 @@ test('a deadline-cancelled poll after only refusals still reports the refusal wi ); }); +test('runner restart exhaustion stays typed after an earlier readable poll', async () => { + vi.useFakeTimers(); + try { + const runtime = { + clock: { now: () => 0, sleep: async (_durationMs: number) => {} }, + } as AgentDeviceRuntime; + const polling = createWaitPolling(runtime, {}, 10, SELECTOR_PIPELINE_POLICIES.wait); + await polling.capture(async () => 'readable'); + const restart = new AppError('COMMAND_FAILED', 'request canceled', { + runnerRestarted: true, + runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send', + runnerRestartCommand: 'snapshot', + runnerRestartCommandId: 'snapshot-1', + }); + const pending = polling.capture(async (signal) => await abortWith(signal, restart)); + + await vi.advanceTimersByTimeAsync(10); + const result = await pending; + + assert.equal(result.timedOut, true); + if (!result.timedOut) return; + assert.equal(result.deadline, 'runner-restart-exhausted'); + const error = waitTimeoutError('wait timed out for text: Ready', polling, result.deadline); + assert.equal(error.details?.reason, WAIT_REASONS.runnerRestartExhausted); + assert.equal(error.details?.runnerRestarted, true); + assert.equal( + error.details?.runnerRestartReason, + 'runner_readiness_preflight_failed_before_command_send', + ); + assert.equal(error.details?.readableCaptures, 1); + assert.equal(error.details?.captureStalled, undefined); + } finally { + vi.useRealTimers(); + } +}); + +test('runner restart exhaustion reports typed evidence without a readable poll', async () => { + vi.useFakeTimers(); + try { + const runtime = { + clock: { now: () => 0, sleep: async (_durationMs: number) => {} }, + } as AgentDeviceRuntime; + const polling = createWaitPolling(runtime, {}, 10, SELECTOR_PIPELINE_POLICIES.wait); + const restart = new AppError('COMMAND_FAILED', 'request canceled', { + runnerRestarted: true, + runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send', + runnerRestartCommand: 'snapshot', + runnerRestartCommandId: 'snapshot-1', + }); + const pending = polling.capture(async (signal) => await abortWith(signal, restart)); + + await vi.advanceTimersByTimeAsync(10); + const result = await pending; + + assert.equal(result.timedOut, true); + if (!result.timedOut) return; + assert.equal(result.deadline, 'runner-restart-exhausted'); + const error = waitTimeoutError('wait timed out for text: Ready', polling, result.deadline); + assert.equal(error.details?.reason, WAIT_REASONS.runnerRestartExhausted); + assert.equal(error.details?.runnerRestarted, true); + assert.equal(error.details?.readableCaptures, 0); + assert.equal(error.details?.captureStalled, undefined); + } finally { + vi.useRealTimers(); + } +}); + +test.each(['runner-start', 'target-discovery'] as const)( + 'readiness exhaustion preserves the %s phase', + async (phase: ReadinessPhase) => { + vi.useFakeTimers(); + try { + const runtime = { + clock: { now: () => 0, sleep: async (_durationMs: number) => {} }, + } as AgentDeviceRuntime; + const polling = createWaitPolling(runtime, {}, 10, SELECTOR_PIPELINE_POLICIES.wait); + const readiness = new AppError('COMMAND_FAILED', 'request canceled', { + readinessPhase: phase, + }); + const pending = polling.capture(async (signal) => await abortWith(signal, readiness)); + + await vi.advanceTimersByTimeAsync(10); + const result = await pending; + + assert.equal(result.timedOut, true); + if (!result.timedOut) return; + assert.equal(result.deadline, 'readiness-exhausted'); + const error = waitTimeoutError('wait timed out for text: Ready', polling, result.deadline); + assert.equal(error.details?.reason, WAIT_REASONS.readinessExhausted); + assert.equal(error.details?.readinessPhase, phase); + assert.equal(error.details?.readableCaptures, 0); + assert.equal(error.details?.captureStalled, undefined); + } finally { + vi.useRealTimers(); + } + }, +); + +test('a steady-state cancelled poll after a readable capture is deadline-exceeded', async () => { + vi.useFakeTimers(); + try { + const runtime = { + clock: { now: () => 0, sleep: async (_durationMs: number) => {} }, + } as AgentDeviceRuntime; + const polling = createWaitPolling(runtime, {}, 10, SELECTOR_PIPELINE_POLICIES.wait); + await polling.capture(async () => 'readable'); + const cancellation = new AppError('COMMAND_FAILED', 'request canceled'); + const pending = polling.capture(async (signal) => await abortWith(signal, cancellation)); + + await vi.advanceTimersByTimeAsync(10); + const result = await pending; + + assert.equal(result.timedOut, true); + if (!result.timedOut) return; + assert.equal(result.deadline, 'capture-truncated'); + const error = waitTimeoutError('wait timed out for text: Ready', polling, result.deadline); + assert.equal(error.details?.reason, WAIT_REASONS.deadlineExceeded); + assert.equal(error.details?.readableCaptures, 1); + assert.equal(error.details?.readinessPhase, undefined); + } finally { + vi.useRealTimers(); + } +}); + +test('an earlier retriable refusal outranks readiness work on the final poll', async () => { + vi.useFakeTimers(); + try { + const runtime = { + clock: { now: () => 0, sleep: async (_durationMs: number) => {} }, + } as AgentDeviceRuntime; + const refusal = new AppError('COMMAND_FAILED', 'app is not running', { + runnerErrorCode: 'APP_NOT_RUNNING', + retriable: true, + }); + const polling = createWaitPolling(runtime, {}, 10, SELECTOR_PIPELINE_POLICIES.wait); + await polling.capture(async () => { + throw refusal; + }); + const readiness = new AppError('COMMAND_FAILED', 'request canceled', { + readinessPhase: 'target-discovery', + }); + const pending = polling.capture(async (signal) => await abortWith(signal, readiness)); + + await vi.advanceTimersByTimeAsync(10); + const result = await pending; + + assert.equal(result.timedOut, true); + if (!result.timedOut) return; + assert.throws( + () => waitTimeoutError('wait timed out for text: Ready', polling, result.deadline), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'app is not running'); + assert.equal(error.details?.runnerErrorCode, 'APP_NOT_RUNNING'); + assert.equal(error.details?.readinessPhase, 'target-discovery'); + const polls = error.details?.polls as Array<{ outcome: string }> | undefined; + assert.deepEqual( + polls?.map((poll) => poll.outcome), + ['retriable', 'readiness'], + ); + return true; + }, + ); + } finally { + vi.useRealTimers(); + } +}); + +test('a capture that never becomes readable reports capture-stalled', async () => { + vi.useFakeTimers(); + try { + const runtime = { + clock: { now: () => 0, sleep: async (_durationMs: number) => {} }, + } as AgentDeviceRuntime; + const polling = createWaitPolling(runtime, {}, 10, SELECTOR_PIPELINE_POLICIES.wait); + const cancellation = new AppError('COMMAND_FAILED', 'request canceled'); + const pending = polling.capture(async (signal) => await abortWith(signal, cancellation)); + + await vi.advanceTimersByTimeAsync(10); + const result = await pending; + + assert.equal(result.timedOut, true); + if (!result.timedOut) return; + assert.equal(result.deadline, 'capture-stalled'); + const error = waitTimeoutError('wait timed out for text: Ready', polling, result.deadline); + assert.equal(error.details?.reason, WAIT_REASONS.captureStalled); + assert.equal(error.details?.captureStalled, true); + assert.equal(error.details?.readableCaptures, 0); + } finally { + vi.useRealTimers(); + } +}); + test('a non-retriable producer failure still ends the wait at its first poll', async () => { const runtime = { clock: { now: () => 0, sleep: async (_durationMs: number) => {} }, diff --git a/src/commands/interaction/runtime/wait-stable.test.ts b/src/commands/interaction/runtime/wait-stable.test.ts index e59185691b..6ff143dfda 100644 --- a/src/commands/interaction/runtime/wait-stable.test.ts +++ b/src/commands/interaction/runtime/wait-stable.test.ts @@ -41,6 +41,45 @@ test('runtime wait stable settles after two unchanged captures', async () => { assert.equal(captures, 3); }); +test('runtime wait stable retains a recovered private-ax backend for each poll capture', async () => { + const snapshot = makeSnapshotState( + [ + { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Continue', + rect: { x: 20, y: 100, width: 120, height: 44 }, + }, + ], + { snapshotQuality: { state: 'recovered', backend: 'private-ax' } }, + ); + const preferredBackends: Array = []; + const device = createAgentDevice({ + backend: { + platform: 'ios', + captureSnapshot: async (_context, options) => { + preferredBackends.push(options?.preferredBackend); + return { snapshot }; + }, + } satisfies AgentDeviceBackend, + artifacts: createLocalArtifactAdapter(), + sessions: createMemorySessionStore([{ name: 'default', snapshot }]), + policy: localCommandPolicy(), + clock: createFakeClock(), + }); + + const result = await device.selectors.wait({ + session: 'default', + target: { kind: 'stable', quietMs: 25, timeoutMs: 1_000 }, + }); + + assert.equal(result.kind, 'stable'); + assert.ok(preferredBackends.length >= 2); + assert.ok(preferredBackends.every((backend) => backend === 'private-ax')); +}); + // A clock whose sleep lands `undershootMs` short of what was asked, modelling // the real defect: `setTimeout(n)` can advance `Date.now()` by only n-1, // because libuv times the sleep on the monotonic loop clock while `now()` reads diff --git a/src/commands/interaction/runtime/wait-text.test.ts b/src/commands/interaction/runtime/wait-text.test.ts index 737f03b08f..2f7fd387bf 100644 --- a/src/commands/interaction/runtime/wait-text.test.ts +++ b/src/commands/interaction/runtime/wait-text.test.ts @@ -1,17 +1,49 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; +import { WAIT_REASONS } from '@agent-device/contracts/wait'; +import type { + AgentDeviceBackend, + BackendCommandContext, + BackendSnapshotResult, +} from '@agent-device/contracts/backend'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { createAgentDevice } from '../../../runtime.ts'; +import { localCommandPolicy } from '@agent-device/contracts/command-policy'; +import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; import { createFakeClock, createSelectorDevice, selectorReadSnapshot, } from './__tests__/test-utils/index.ts'; +import { createMemorySessionStore } from '../../../command-runtime/runtime-factory.ts'; +import { abortWith } from './__tests__/test-utils/abort-with.ts'; + +function textWaitDevice(params: { + captureSnapshot: NonNullable; + findText?: AgentDeviceBackend['findText']; + readText?: AgentDeviceBackend['readText']; + clock?: { now: () => number; sleep: (ms: number) => Promise }; +}) { + return createAgentDevice({ + backend: { + platform: 'ios', + captureSnapshot: params.captureSnapshot, + ...(params.findText ? { findText: params.findText } : {}), + readText: params.readText ?? (async () => ({ text: '' })), + } satisfies AgentDeviceBackend, + artifacts: createLocalArtifactAdapter(), + sessions: createMemorySessionStore([{ name: 'default' }]), + policy: localCommandPolicy(), + clock: params.clock ?? createFakeClock(), + }); +} -// A text wait has exactly one source of truth: the polled capture. The backend `findText` seam -// that short-circuited it on Apple was wait's second platform-execution path and retired with -// wait's ADR 0019 cutover, so the tree answer is the only answer — in both directions. test('runtime wait resolves text from the polled snapshot', async () => { - const device = createSelectorDevice(selectorReadSnapshot(), { now: 10 }); + const captureSnapshot = vi.fn(async (): Promise => ({ + snapshot: selectorReadSnapshot(), + })); + const device = textWaitDevice({ captureSnapshot }); const result = await device.selectors.wait({ session: 'default', @@ -19,19 +51,257 @@ test('runtime wait resolves text from the polled snapshot', async () => { }); assert.deepEqual(result, { kind: 'text', text: 'Continue', waitedMs: 0 }); + assert.equal(captureSnapshot.mock.calls.length, 1); }); test('runtime wait times out on text the polled snapshot does not carry', async () => { const device = createSelectorDevice(selectorReadSnapshot(), { clock: createFakeClock() }); await assert.rejects( - async () => - await device.selectors.wait({ - session: 'default', - target: { kind: 'text', text: 'Ready', timeoutMs: 100 }, - }), - (error: Error) => error.message.includes('wait timed out for text: Ready'), + device.selectors.wait({ + session: 'default', + target: { kind: 'text', text: 'Ready', timeoutMs: 100 }, + }), + (error: AppError) => { + assert.match(error.message, /wait timed out for text: Ready/); + assert.equal(error.details?.reason, WAIT_REASONS.targetAbsent); + assert.ok(Number(error.details?.readableCaptures) > 0); + return true; + }, + ); +}); + +test('runtime wait falls back to a snapshot after a native text miss', async () => { + const snapshot = selectorReadSnapshot(); + const captureContexts: BackendCommandContext[] = []; + const findText = vi.fn(async (_context: BackendCommandContext, _text: string) => ({ + found: false, + })); + const captureSnapshot = vi.fn( + async (context: BackendCommandContext): Promise => { + captureContexts.push(context); + return { snapshot }; + }, ); + const device = textWaitDevice({ captureSnapshot, findText }); + + const result = await device.selectors.wait({ + session: 'default', + target: { kind: 'text', text: 'Continue', timeoutMs: 10_000 }, + }); + + assert.equal(result.kind, 'text'); + if (result.kind === 'text') assert.equal(result.text, 'Continue'); + assert.equal(findText.mock.calls.length, 1); + assert.equal(captureSnapshot.mock.calls.length, 1); + assert.deepEqual(findText.mock.calls[0]?.[0], captureContexts[0]); +}); + +test('a text wait is satisfied by the owner native reading when the tree never carries it', async () => { + let nativeReads = 0; + const findText = vi.fn(async (_context: BackendCommandContext, text: string) => ({ + found: text === 'Last input: press' && ++nativeReads > 1, + })); + const captureSnapshot = vi.fn(async (): Promise => ({ + snapshot: makeSnapshotState([ + { index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }, + ]), + })); + const device = textWaitDevice({ captureSnapshot, findText }); + + const result = await device.selectors.wait({ + session: 'default', + target: { kind: 'text', text: 'Last input: press', timeoutMs: 2_000 }, + }); + + assert.deepEqual(result.kind, 'text'); + assert.equal(findText.mock.calls.length, 2); + assert.equal(captureSnapshot.mock.calls.length, 1); + assert.equal(findText.mock.calls[0]?.[1], 'Last input: press'); + assert.equal(findText.mock.calls[1]?.[1], 'Last input: press'); +}); + +test('runtime wait short-circuits capture when native text is found', async () => { + const findText = vi.fn(async () => ({ found: true })); + const captureSnapshot = vi.fn(async (): Promise => ({ + snapshot: makeSnapshotState([]), + })); + const device = textWaitDevice({ captureSnapshot, findText }); + + const result = await device.selectors.wait({ + session: 'default', + target: { kind: 'text', text: 'Ready', timeoutMs: 10_000 }, + }); + + assert.equal(result.kind, 'text'); + assert.equal(findText.mock.calls.length, 1); + assert.equal(captureSnapshot.mock.calls.length, 0); +}); + +test('runtime wait surfaces a canonical capture failure after a native text miss', async () => { + const captureSnapshot = vi.fn(async (): Promise => { + throw new AppError('COMMAND_FAILED', 'canonical capture failed', { + reason: 'capture_failed', + }); + }); + const findText = vi.fn(async () => ({ found: false })); + const device = textWaitDevice({ captureSnapshot, findText }); + + await assert.rejects( + device.selectors.wait({ + session: 'default', + target: { kind: 'text', text: 'Ready', timeoutMs: 10_000 }, + }), + (error: AppError) => + error.message === 'canonical capture failed' && error.details?.reason === 'capture_failed', + ); + + assert.equal(findText.mock.calls.length, 1); + assert.equal(captureSnapshot.mock.calls.length, 1); +}); + +test('a native text hit does not make an older captured tree fresh for the next read', async () => { + const snapshots = [ + makeSnapshotState([{ index: 0, type: 'StaticText', label: 'Home' }]), + makeSnapshotState([ + { index: 0, type: 'StaticText', label: 'Automation lab' }, + { index: 1, type: 'StaticText', label: 'cold.start' }, + ]), + ]; + let attempts = 0; + const findText = vi.fn(async () => { + attempts += 1; + return { found: attempts > 1 }; + }); + const captureSnapshot = vi.fn(async (): Promise => ({ + snapshot: snapshots.shift() ?? makeSnapshotState([]), + })); + const device = textWaitDevice({ + captureSnapshot, + findText, + readText: async () => ({ text: 'cold.start' }), + }); + + await device.selectors.wait({ + session: 'default', + target: { kind: 'text', text: 'Automation lab', timeoutMs: 10_000 }, + }); + const result = await device.selectors.get({ + session: 'default', + property: 'text', + target: { kind: 'selector', selector: 'label="cold.start"' }, + }); + + assert.equal(result.kind, 'text'); + assert.equal(result.text, 'cold.start'); + assert.equal(captureSnapshot.mock.calls.length, 2); +}); + +test('runtime wait passes its polling deadline signal to snapshot capture', async () => { + let observedSignal: AbortSignal | undefined; + const snapshot = makeSnapshotState([]); + const capture = vi.fn(async (context: BackendCommandContext): Promise => { + observedSignal = context.signal; + const signal = context.signal; + if (!signal) return { snapshot }; + return await new Promise((resolve) => { + if (signal.aborted) { + resolve({ snapshot }); + return; + } + signal.addEventListener('abort', () => resolve({ snapshot }), { once: true }); + }); + }); + const device = createAgentDevice({ + backend: { + platform: 'ios', + captureSnapshot: capture, + } satisfies AgentDeviceBackend, + artifacts: createLocalArtifactAdapter(), + sessions: createMemorySessionStore([{ name: 'default', snapshot }]), + policy: localCommandPolicy(), + }); + + await assert.rejects( + device.selectors.wait({ + session: 'default', + target: { kind: 'text', text: 'Never appears', timeoutMs: 10 }, + }), + /timed out/i, + ); + + assert.equal(capture.mock.calls.length, 1); + assert.equal(observedSignal?.aborted, true); +}); + +test('a positive wait whose steady-state capture is cancelled reports the deadline, not readiness', async () => { + vi.useFakeTimers(); + try { + let captures = 0; + const captureSnapshot = vi.fn( + async (context: BackendCommandContext): Promise => { + captures += 1; + if (captures === 1) { + return { + snapshot: makeSnapshotState([ + { index: 0, depth: 0, type: 'Button', label: 'Checkout', hittable: true }, + ]), + }; + } + return await abortWith(context.signal, new AppError('COMMAND_FAILED', 'capture cancelled')); + }, + ); + const device = textWaitDevice({ + captureSnapshot, + clock: { now: () => 0, sleep: async (_durationMs: number) => {} }, + }); + const pending = device.selectors.wait({ + session: 'default', + target: { kind: 'text', text: 'Ready', timeoutMs: 20 }, + }); + const failure = assert.rejects(pending, (error: AppError) => { + assert.equal(error.details?.reason, WAIT_REASONS.deadlineExceeded); + assert.equal(error.details?.readableCaptures, 1); + assert.equal(error.details?.readinessPhase, undefined); + return true; + }); + + await vi.advanceTimersByTimeAsync(20); + await failure; + assert.equal(captureSnapshot.mock.calls.length, 2); + } finally { + vi.useRealTimers(); + } +}); + +test('a stalled capture reports capture-stalled with no readable captures', async () => { + vi.useFakeTimers(); + try { + const captureSnapshot = vi.fn( + async (context: BackendCommandContext) => + await abortWith(context.signal, new AppError('COMMAND_FAILED', 'capture cancelled')), + ); + const device = textWaitDevice({ + captureSnapshot, + clock: { now: () => 0, sleep: async (_durationMs: number) => {} }, + }); + const pending = device.selectors.wait({ + session: 'default', + target: { kind: 'text', text: 'Ready', timeoutMs: 20 }, + }); + const failure = assert.rejects(pending, (error: AppError) => { + assert.equal(error.details?.reason, WAIT_REASONS.captureStalled); + assert.equal(error.details?.captureStalled, true); + assert.equal(error.details?.readableCaptures, 0); + return true; + }); + + await vi.advanceTimersByTimeAsync(20); + await failure; + assert.equal(captureSnapshot.mock.calls.length, 1); + } finally { + vi.useRealTimers(); + } }); /** diff --git a/src/core/interactors-app-events.test.ts b/src/core/interactors-app-events.test.ts new file mode 100644 index 0000000000..cdf6bfbfe7 --- /dev/null +++ b/src/core/interactors-app-events.test.ts @@ -0,0 +1,88 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { ANDROID_EMULATOR, IOS_DEVICE, MACOS_DEVICE, mkdtempForTest } from '@agent-device/test-kit'; +import { getInteractor } from './interactors.ts'; + +async function triggerEvent(device: DeviceInfo, eventUrl: string, appBundleId?: string) { + const operations = bindAdmittedLocalInteractorOperations({ + device, + signal: new AbortController().signal, + resolveInteractor: async (owned, runner) => await getInteractor(owned, runner), + facts: { triggerAppEvent: { available: true } }, + }); + await operations.triggerAppEvent?.({ + eventUrl, + options: appBundleId === undefined ? {} : { appBundleId }, + }); +} + +async function withCommandStub( + command: string, + run: (argsLogPath: string) => Promise, +): Promise { + const tempDir = await mkdtempForTest(`agent-device-trigger-app-event-${command}-`); + const commandPath = path.join(tempDir, command); + const argsLogPath = path.join(tempDir, 'args.log'); + await fs.writeFile( + commandPath, + '#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', + 'utf8', + ); + await fs.chmod(commandPath, 0o755); + + const previousPath = process.env.PATH; + const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; + process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`; + process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; + try { + return await run(argsLogPath); + } finally { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; + else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +test('trigger-app-event opens an Android deep link through adb', async () => { + await withCommandStub('adb', async (argsLogPath) => { + const eventUrl = 'myapp://agent-device/event?name=screenshot_taken&platform=android'; + await triggerEvent(ANDROID_EMULATOR, eventUrl); + const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean); + assert.equal(args.includes('-d'), true); + assert.equal(args.includes(`'${eventUrl}'`), true); + }); +}); + +test('trigger-app-event opens an iOS device payload URL through devicectl', async () => { + await withCommandStub('xcrun', async (argsLogPath) => { + const eventUrl = 'myapp://ios?name=screenshot_taken&payload=%7B%22source%22%3A%22ios%22%7D'; + await triggerEvent(IOS_DEVICE, eventUrl, 'com.example.app'); + const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean); + assert.deepEqual(args, [ + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + 'ios-device-1', + '--payload-url', + eventUrl, + 'com.example.app', + ]); + }); +}); + +test('trigger-app-event opens a macOS URL through open', async () => { + await withCommandStub('open', async (argsLogPath) => { + const eventUrl = 'myapp://macos?name=screenshot_taken&platform=macos'; + await triggerEvent(MACOS_DEVICE, eventUrl); + const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean); + assert.deepEqual(args, [eventUrl]); + }); +}); diff --git a/src/__tests__/daemon-composition/providers-plugin-routing-parity.test.ts b/src/core/platform-plugin-registry-provider-routing.test.ts similarity index 57% rename from src/__tests__/daemon-composition/providers-plugin-routing-parity.test.ts rename to src/core/platform-plugin-registry-provider-routing.test.ts index da60815f8e..791b62b1de 100644 --- a/src/__tests__/daemon-composition/providers-plugin-routing-parity.test.ts +++ b/src/core/platform-plugin-registry-provider-routing.test.ts @@ -1,7 +1,6 @@ import type { PlatformGatedProviderResolverKey, PlatformProviderRequestContext, - RequestPlatformProviderScope, } from '@agent-device/contracts/platform-providers'; import assert from 'node:assert/strict'; import { test } from 'vitest'; @@ -25,29 +24,15 @@ import { VISIONOS_SIMULATOR, WEB_DESKTOP_DEVICE, } from '@agent-device/test-kit'; -import { makeSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { getPlugin, tryGetPlugin } from '../../core/platform-plugin-registry.ts'; -import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-builtins.ts'; +import { getPlugin, tryGetPlugin } from './platform-plugin-registry.ts'; +import { registerBuiltinPlatformPlugins } from './interactors/register-builtins.ts'; import { createRequestPlatformProviders, type PlatformProviderResolvers, -} from '../../platform-runtime.ts'; -import { resolvePlatformProviderRequestContext } from '@agent-device/daemon/testing/request-platform-provider-context'; -import type { DaemonRequest } from '@agent-device/daemon'; - -// Phase 3 step b.3 (issue #974) parity gate for the request-scope provider -// facet. The per-platform GATE that each descriptor in the root-owned provider composition -// open-coded (`device.platform === 'android'`, `isApplePlatform(...)`, etc.) now flows -// through the PlatformPlugin `providers.platformGatedResolvers` facet. The daemon still -// root composition owns the resolver invocation, wrapper composition, and concurrency isolation — -// only the gate moved to data. An INDEPENDENT verbatim copy of the former gates below is the -// BEFORE oracle, checked at the facet level AND end-to-end through -// the root-composed RequestPlatformProviders boundary. +} from '../platform-runtime.ts'; registerBuiltinPlatformPlugins(); -// The platform-gated resolver keys, and the ungated resolver (no platform gate; -// they apply on every platform and are NOT part of the facet). const GATED_KEYS: PlatformGatedProviderResolverKey[] = [ 'androidAdbProvider', 'appleRunnerProvider', @@ -61,21 +46,19 @@ const UNGATED_KEYS = [ 'appleSimulatorScreenRecordingTransport', ] as const; -// --- INDEPENDENT verbatim copy of the former per-descriptor platform gates --- function gatedResolversByHand(device: DeviceInfo): Set { const applies = new Set(); - if (device.platform === 'android') applies.add('androidAdbProvider'); // was `!== 'android'` + if (device.platform === 'android') applies.add('androidAdbProvider'); if (isApplePlatform(device.platform)) { - applies.add('appleRunnerProvider'); // was `!isApplePlatform(...)` - applies.add('appleToolProvider'); // was `!isApplePlatform(...)` + applies.add('appleRunnerProvider'); + applies.add('appleToolProvider'); } if (device.platform === 'vega') applies.add('vegaToolProvider'); - if (device.platform === 'linux') applies.add('linuxToolProvider'); // was `!== 'linux'` - if (device.platform === 'web') applies.add('webProvider'); // was `!== 'web'` + if (device.platform === 'linux') applies.add('linuxToolProvider'); + if (device.platform === 'web') applies.add('webProvider'); return applies; } -// --- the exhaustive synthetic device matrix (every platform x kind x target) --- const DEVICE_KINDS_ALL: DeviceKind[] = ['simulator', 'emulator', 'device']; const DEVICE_TARGETS_ALL: (DeviceTarget | undefined)[] = [undefined, ...DEVICE_TARGETS]; @@ -124,8 +107,6 @@ test('providers.platformGatedResolvers facet is byte-identical to the former han }); test('every family carries the providers facet with the resolvers it owns', () => { - // The exact-array form (not the membership scan above) is what catches a facet listing a - // resolver twice. Harmonyos carries no facet at all, so five families are asserted here. assert.deepEqual([...getPlugin('apple').providers!.platformGatedResolvers].sort(), [ 'appleRunnerProvider', 'appleToolProvider', @@ -142,12 +123,6 @@ test('every family carries the providers facet with the resolvers it owns', () = assert.deepEqual([...getPlugin('web').providers!.platformGatedResolvers], ['webProvider']); }); -// End-to-end routing proof: drive the REAL root-composed provider boundary with a -// spy for every resolver and assert exactly the gated resolvers the former hand gate -// admitted are invoked (plus the ungated resolver, on every platform). Each spy -// returns `undefined`, so no wrapper is composed — but the resolver is still called iff -// its gate passed, which is precisely what the former `device.platform === …` branch -// decided. Breaking the facet flips which resolvers run and fails this test. test('root provider composition invokes exactly the resolvers the former gate did', async () => { for (const device of SAMPLE_DEVICES) { const invoked = new Set(); @@ -166,14 +141,14 @@ test('root provider composition invokes exactly the resolvers the former gate di appleSimulatorScreenRecordingTransport: spy('appleSimulatorScreenRecordingTransport'), }; - await withRequestPlatformProviderScope( - { - req: request('snapshot'), - existingSession: makeSession(`prov-${device.id}`, { device }), - providers, - }, - async () => {}, - ); + const name = `prov-${device.id}`; + const context: PlatformProviderRequestContext = { + device, + session: { name, device }, + requestedSession: name, + requestId: `req-${device.id}`, + }; + await createRequestPlatformProviders({ providers }).run(context, async () => {}); const gatedInvoked = new Set(GATED_KEYS.filter((key) => invoked.has(key))); assert.deepEqual( @@ -186,31 +161,3 @@ test('root provider composition invokes exactly the resolvers the former gate di } } }); - -function request(command: string): DaemonRequest { - return { - token: 'test-token', - session: 'default', - command, - positionals: [], - flags: {}, - meta: { requestId: `req-${command}` }, - }; -} - -async function withRequestPlatformProviderScope( - params: { - req: DaemonRequest; - existingSession: Parameters[0]['existingSession']; - providers: PlatformProviderResolvers; - }, - task: (scope: RequestPlatformProviderScope) => Promise, -): Promise { - const context: PlatformProviderRequestContext | undefined = - await resolvePlatformProviderRequestContext({ - req: params.req, - existingSession: params.existingSession, - }); - if (!context) return await task({}); - return await createRequestPlatformProviders({ providers: params.providers }).run(context, task); -} diff --git a/src/daemon-runtime-limrun-public-http.test.ts b/src/daemon-runtime-limrun-public-http.test.ts new file mode 100644 index 0000000000..a1228e621f --- /dev/null +++ b/src/daemon-runtime-limrun-public-http.test.ts @@ -0,0 +1,156 @@ +import fs from 'node:fs'; +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; +import { startDaemonRuntime } from '@agent-device/daemon'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createLimrunRuntime } from '@agent-device/provider-limrun'; +import { + DAEMON_HTTP_NETWORK_ACCESS_HEADER, + DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, +} from '@agent-device/contracts/daemon-http'; +import { createDaemonRuntimeDeps } from './daemon-composition.ts'; +import { limrunTestDependencies } from './platform-runtime-gateway.fixtures.ts'; + +const limrunIo = vi.hoisted(() => ({ + listAssets: vi.fn(), + createAndroidInstance: vi.fn(), +})); +const rootPorts = vi.hoisted(() => { + const applicationLifecycle = { + recoverStartupResources: vi.fn(async () => {}), + detachForDaemonShutdown: vi.fn(async () => {}), + finalizeDaemonShutdown: vi.fn(async () => {}), + }; + return { + providerComposition: vi.fn(async () => ({ runtimes: [] as unknown[], platformModules: [] })), + createPlatformRuntimeGateway: vi.fn(() => ({ + applicationLifecycle, + inspectFacts: async () => { + throw new Error('unused'); + }, + bind: async () => { + throw new Error('unused'); + }, + shutdown: vi.fn(async () => {}), + })), + createPlatformDeviceInventoryGateways: vi.fn(async () => ({})), + createRequestPlatformProviders: vi.fn(() => ({ + run: async (_context: unknown, task: () => Promise) => await task(), + })), + applicationLifecycle, + }; +}); + +vi.mock('@limrun/api', () => ({ + default: class MockLimrun { + readonly assets = { list: limrunIo.listAssets }; + readonly androidInstances = { + create: limrunIo.createAndroidInstance, + delete: vi.fn(), + list: vi.fn(), + }; + readonly iosInstances = { create: vi.fn(), delete: vi.fn(), list: vi.fn() }; + }, +})); + +vi.mock('./platform-runtime.ts', () => ({ + androidObservation: {}, + createPlatformRuntimeGateway: rootPorts.createPlatformRuntimeGateway, + createPlatformDeviceInventoryGateways: rootPorts.createPlatformDeviceInventoryGateways, + createRequestPlatformProviders: rootPorts.createRequestPlatformProviders, +})); + +vi.mock('./provider-device-runtimes.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createDaemonProviderRuntimeComposition: rootPorts.providerComposition, + }; +}); + +test('public HTTP rejects Limrun uploaded-app listing and allocation before provider I/O', async () => { + const stateDir = mkdtempForTestSync('agent-device-limrun-http-access-'); + const paths = resolveDaemonPaths(stateDir); + const limrun = createLimrunRuntime( + { apiKey: 'lim_test_key', runtimeInstance: 'http-access-test' }, + limrunTestDependencies, + ); + rootPorts.providerComposition.mockResolvedValue({ runtimes: [limrun], platformModules: [] }); + let runtime: Awaited> | undefined; + + try { + runtime = await startDaemonRuntime({ + composeDeps: createDaemonRuntimeDeps, + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_test/api', + LIM_ANDROID_INSTANCE_TOKEN: 'lim-test-token', + LIM_ANDROID_INSTANCE_ADB_URL: 'https://region.limrun.example/v1/android_test/adb', + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + }); + const activeRuntime = runtime; + if (!activeRuntime?.httpPort) throw new Error('HTTP daemon runtime did not start'); + limrunIo.listAssets.mockClear(); + limrunIo.createAndroidInstance.mockClear(); + + const callRpc = async (payload: Record) => { + const response = await fetch(`http://127.0.0.1:${activeRuntime.httpPort}/rpc`, { + method: 'POST', + headers: { + authorization: `Bearer ${activeRuntime.token}`, + 'content-type': 'application/json', + [DAEMON_HTTP_NETWORK_ACCESS_HEADER]: DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, + }, + body: JSON.stringify(payload), + }); + return (await response.json()) as { error?: { data?: { code?: string } } }; + }; + + const apps = await callRpc({ + jsonrpc: '2.0', + id: 'limrun-public-apps', + method: 'agent_device.command', + params: { + command: 'apps', + positionals: [], + flags: { platform: 'android', leaseProvider: 'limrun' }, + }, + }); + const allocation = await callRpc({ + jsonrpc: '2.0', + id: 'limrun-public-allocation', + method: 'agent_device.lease.allocate', + params: { + tenantId: 'tenant-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + providerApp: 'Example.apk', + }, + }); + + assert.equal(apps.error?.data?.code, 'UNAUTHORIZED'); + assert.equal(allocation.error?.data?.code, 'UNAUTHORIZED'); + assert.equal(limrunIo.listAssets.mock.calls.length, 0); + assert.equal(limrunIo.createAndroidInstance.mock.calls.length, 0); + assert.equal(rootPorts.providerComposition.mock.calls.length, 1); + assert.equal(rootPorts.createPlatformRuntimeGateway.mock.calls.length, 1); + assert.equal(rootPorts.createPlatformDeviceInventoryGateways.mock.calls.length, 1); + assert.equal(rootPorts.createRequestPlatformProviders.mock.calls.length, 1); + assert.equal(rootPorts.applicationLifecycle.recoverStartupResources.mock.calls.length, 1); + } finally { + await runtime?.shutdown(); + await limrun.shutdown(); + fs.rmSync(paths.baseDir, { recursive: true, force: true }); + if (runtime) + assert.equal(rootPorts.applicationLifecycle.finalizeDaemonShutdown.mock.calls.length, 1); + } +}); diff --git a/src/platform-runtime-gateway-gesture-admission.test.ts b/src/platform-runtime-gateway-gesture-admission.test.ts new file mode 100644 index 0000000000..c0969b66f6 --- /dev/null +++ b/src/platform-runtime-gateway-gesture-admission.test.ts @@ -0,0 +1,114 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { expect, test } from 'vitest'; +import { createPlatformRuntimeGateway } from './platform-runtime.ts'; + +const gateway = createPlatformRuntimeGateway({ + resolveSessionArtifacts: () => ({ + outputPath: '/sessions/gesture-admission/app.log', + pidPath: '/sessions/gesture-admission/app-log.pid', + }), + sessionsDir: '/sessions', +}); + +const device = (fields: Partial): DeviceInfo => ({ + platform: 'android', + id: 'test-device', + name: 'Test device', + kind: 'emulator', + ...fields, +}); + +test('Android phones and emulators admit single- and multi-touch gesture plans', async () => { + for (const kind of ['device', 'emulator'] as const) { + const facts = await gateway.inspectFacts(device({ kind })); + expect(facts.operations.performGesturePlan.available).toBe(true); + expect(facts.operations.performMultiTouchGesturePlan.available).toBe(true); + } +}); + +test('target-authored drag is admitted only where adapters preserve every authored phase', async () => { + const supported = [ + device({ kind: 'device', target: 'mobile' }), + device({ platform: 'apple', appleOs: 'ios', kind: 'simulator', target: 'mobile' }), + device({ platform: 'apple', appleOs: 'ipados', kind: 'simulator', target: 'mobile' }), + device({ platform: 'apple', kind: 'simulator', target: 'mobile' }), + ]; + for (const target of supported) { + expect( + (await gateway.inspectFacts(target)).operations.performTargetAuthoredDrag.available, + `${target.platform}/${target.appleOs ?? ''}/${target.kind}/${target.target ?? ''}`, + ).toBe(true); + } + + const inexact = [ + device({ target: 'tv' }), + device({ platform: 'apple', appleOs: 'tvos', kind: 'simulator', target: 'tv' }), + device({ platform: 'apple', appleOs: 'macos', kind: 'device', target: 'desktop' }), + device({ platform: 'apple', appleOs: 'visionos', kind: 'simulator' }), + device({ platform: 'apple', appleOs: 'watchos', kind: 'simulator' }), + device({ platform: 'linux', kind: 'device', target: 'desktop' }), + device({ platform: 'vega', kind: 'device', target: 'tv' }), + device({ platform: 'web', kind: 'device', target: 'desktop' }), + ]; + for (const target of inexact) { + const fact = (await gateway.inspectFacts(target)).operations.performTargetAuthoredDrag; + expect(fact.available).toBe(false); + if (!fact.available) + expect(fact.hint).toMatch(/source hold, timed movement, and destination hold/); + } +}); + +test('iOS and iPadOS simulators admit multi-touch while physical devices do not', async () => { + for (const appleOs of ['ios', 'ipados'] as const) { + const simulator = await gateway.inspectFacts( + device({ platform: 'apple', appleOs, kind: 'simulator' }), + ); + const physical = await gateway.inspectFacts( + device({ platform: 'apple', appleOs, kind: 'device' }), + ); + expect(simulator.operations.performGesturePlan.available).toBe(true); + expect(simulator.operations.performMultiTouchGesturePlan.available).toBe(true); + expect(physical.operations.performGesturePlan.available).toBe(true); + expect(physical.operations.performMultiTouchGesturePlan).toMatchObject({ + available: false, + hint: expect.stringMatching(/iOS-simulator only/), + }); + } +}); + +test('TV, spatial, watch, desktop, Linux, and web gesture policy stays explicit', async () => { + const androidTv = await gateway.inspectFacts(device({ target: 'tv' })); + const tvOs = await gateway.inspectFacts( + device({ platform: 'apple', appleOs: 'tvos', kind: 'simulator', target: 'tv' }), + ); + const visionOs = await gateway.inspectFacts( + device({ platform: 'apple', appleOs: 'visionos', kind: 'simulator' }), + ); + const watchOs = await gateway.inspectFacts( + device({ platform: 'apple', appleOs: 'watchos', kind: 'simulator' }), + ); + const macOs = await gateway.inspectFacts( + device({ platform: 'apple', appleOs: 'macos', kind: 'device', target: 'desktop' }), + ); + const linux = await gateway.inspectFacts( + device({ platform: 'linux', kind: 'device', target: 'desktop' }), + ); + const web = await gateway.inspectFacts( + device({ platform: 'web', kind: 'device', target: 'desktop' }), + ); + + expect(androidTv.operations.performGesturePlan.available).toBe(true); + expect(androidTv.operations.performMultiTouchGesturePlan).toMatchObject({ + available: false, + hint: expect.stringMatching(/Android TV has no touch input/), + }); + expect(tvOs.operations.performMultiTouchGesturePlan.available).toBe(false); + expect(visionOs.operations.performMultiTouchGesturePlan.available).toBe(false); + expect(watchOs.operations.performGesturePlan.available).toBe(false); + expect(macOs.operations.performGesturePlan.available).toBe(true); + expect(macOs.operations.performMultiTouchGesturePlan.available).toBe(false); + expect(linux.operations.performGesturePlan.available).toBe(true); + expect(linux.operations.performDirectionalFlingPlan.available).toBe(false); + expect(linux.operations.performMultiTouchGesturePlan.available).toBe(false); + expect(web.operations.performGesturePlan.available).toBe(false); +}); diff --git a/src/platform-runtime-gateway-recording.test.ts b/src/platform-runtime-gateway-recording.test.ts new file mode 100644 index 0000000000..3e9387c8e4 --- /dev/null +++ b/src/platform-runtime-gateway-recording.test.ts @@ -0,0 +1,41 @@ +import path from 'node:path'; +import { expect, test } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { createPlatformRuntimeGateway } from './platform-runtime.ts'; + +const unsupportedRecordingDevices = [ + { + platform: 'linux', + id: 'linux-desktop', + name: 'Linux desktop', + kind: 'device', + target: 'desktop', + }, + { platform: 'vega', id: 'vega-tv', name: 'Vega', kind: 'device', target: 'tv' }, +] satisfies readonly DeviceInfo[]; + +test.each(unsupportedRecordingDevices)( + 'the composed $platform runtime reports screen recording as unavailable', + async (device) => { + const sessionsDir = mkdtempForTestSync('platform-runtime-recording-'); + const gateway = createPlatformRuntimeGateway({ + sessionsDir, + resolveSessionArtifacts: (sessionId) => ({ + outputPath: path.join(sessionsDir, sessionId, 'app.log'), + pidPath: path.join(sessionsDir, sessionId, 'app-log.pid'), + }), + }); + + try { + const facts = await gateway.inspectFacts(device); + + expect(facts.operations.screenRecordingStart).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + }); + } finally { + await gateway.shutdown(); + } + }, +); diff --git a/src/platform-runtime-perf-capture-host.test.ts b/src/platform-runtime-perf-capture-host.test.ts index e00dd8f9c3..f87592ceef 100644 --- a/src/platform-runtime-perf-capture-host.test.ts +++ b/src/platform-runtime-perf-capture-host.test.ts @@ -184,6 +184,53 @@ test('starts and finishes an Android Perfetto trace', async () => { ); }); +test('retries a failed Perfetto pull without cleaning the device trace', async () => { + const remotePath = '/data/misc/perfetto-traces/agent-device-app.trace'; + const capture = { ...androidCapture({ kind: 'perfetto', type: 'trace' }), remotePath }; + const remoteTraces = new Set(); + let pullFails = true; + const pullFailure = new Error('Failed to pull Android perfetto artifact'); + androidNative.startAndroidPerfettoTrace.mockImplementation(async () => { + remoteTraces.add(remotePath); + return capture; + }); + androidNative.stopAndroidPerfettoTrace.mockImplementation( + async (_device: DeviceInfo, current: typeof capture, outPath: string) => { + if (pullFails) throw pullFailure; + expect(remoteTraces.has(current.remotePath)).toBe(true); + remoteTraces.delete(current.remotePath); + return { + ...current, + action: 'stop', + state: 'stopped', + outPath, + stoppedAt: 3, + durationMs: 2, + summary: { packets: 4 }, + }; + }, + ); + androidNative.cleanupAndroidNativePerfSession.mockImplementation(async (_device, current) => { + remoteTraces.delete(current.remotePath); + }); + + const started = await startAndroidPerfCapture( + device, + localRuntimeOwner('android'), + captureInput({ kind: 'perfetto', mode: 'trace' }), + ); + const handle = started.pendingHandle.transfer(); + + await expect(handle.finish()).rejects.toBe(pullFailure); + expect(androidNative.cleanupAndroidNativePerfSession).not.toHaveBeenCalled(); + expect(remoteTraces).toEqual(new Set([remotePath])); + + pullFails = false; + await expect(handle.finish()).resolves.toMatchObject({ status: 'completed' }); + expect(remoteTraces).toEqual(new Set()); + expect(androidNative.cleanupAndroidNativePerfSession).not.toHaveBeenCalled(); +}); + test('starts, retargets, stops, and reclaims an Apple xctrace capture through one live handle', async () => { const capture = appleCapture({ mode: 'trace', template: 'Animation Hitches' }); appleXctrace.startAppleXctracePerfCapture.mockResolvedValue(capture); diff --git a/src/platform-runtime-perf-host.test.ts b/src/platform-runtime-perf-host.test.ts index dbb338ae55..0415ba765b 100644 --- a/src/platform-runtime-perf-host.test.ts +++ b/src/platform-runtime-perf-host.test.ts @@ -1,5 +1,37 @@ -import { expect, test } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { expect, test, vi } from 'vitest'; import { createPerfRuntimeHost } from './platform-runtime-perf-host.ts'; +import { createPlatformRuntimeGateway } from './platform-runtime.ts'; + +const adb = vi.hoisted(() => ({ calls: [] as string[][] })); + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => { + const actual = await importOriginal(); + const { AppError } = await import('@agent-device/kernel/errors'); + return { + ...actual, + runCmd: async (command: string, args: string[]) => { + adb.calls.push([command, ...args]); + throw new AppError('COMMAND_FAILED', 'Remote Android ADB executor is unavailable'); + }, + }; +}); + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +const scope = { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, +}; test('exposes family-specific mechanics without a bind or platform switch surface', () => { const host = createPerfRuntimeHost(); @@ -10,3 +42,34 @@ test('exposes family-specific mechanics without a bind or platform switch surfac expect(Object.keys(host.android)).not.toContain('frameSampling'); expect(Object.keys(host.harmony)).toEqual(['sampleMemory']); }); + +test('the composed Android perf operation reports an unavailable ADB memory sample', async () => { + adb.calls.length = 0; + const sessionsDir = mkdtempForTestSync('platform-runtime-perf-host-'); + const gateway = createPlatformRuntimeGateway({ + sessionsDir, + resolveSessionArtifacts: (sessionId) => ({ + outputPath: `${sessionsDir}/${sessionId}/app.log`, + pidPath: `${sessionsDir}/${sessionId}/app-log.pid`, + }), + }); + + try { + const binding = await gateway.bind({ device, intent: { kind: 'ordinary' }, scope }); + const sampleMemory = binding.operations.perfMemorySample; + expect(sampleMemory).toBeDefined(); + if (!sampleMemory) throw new Error('Android memory sampling was not bound'); + const result = await sampleMemory({ appId: 'com.example.app' }); + + expect(result.metric).toMatchObject({ + available: false, + reason: 'Remote Android ADB executor is unavailable', + error: { details: { metric: 'memory' } }, + }); + expect( + adb.calls.some(([command, ...args]) => command === 'adb' && args.includes('dumpsys')), + ).toBe(true); + } finally { + await gateway.shutdown(); + } +}); diff --git a/src/platform-runtime-provider-scope.test.ts b/src/platform-runtime-provider-scope.test.ts new file mode 100644 index 0000000000..f098a8eb68 --- /dev/null +++ b/src/platform-runtime-provider-scope.test.ts @@ -0,0 +1,295 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { + PlatformProviderRequestContext, + RequestPlatformProviderScope, +} from '@agent-device/contracts/platform-providers'; +import { + ANDROID_EMULATOR, + IOS_SIMULATOR, + MACOS_DEVICE, + WEB_DESKTOP_DEVICE, +} from '@agent-device/test-kit'; +import { createLocalAppleToolProvider, runXcrun } from '@agent-device/platform-apple/tool-provider'; +import { buildSimctlArgsForDevice } from '@agent-device/platform-apple/simctl'; +import type { AndroidAdbExecutor } from '@agent-device/platform-android/mechanics'; +import { resolveWebProvider, type WebProvider } from '@agent-device/platform-web'; +import { + resolveAppleRunnerScreenRecordingTransport, + type AppleRunnerScreenRecordingTransport, +} from './platform-runtime-screen-recording-apple-runner-transport.ts'; +import { + createRequestPlatformProviders, + type PlatformProviderResolvers, +} from './platform-runtime.ts'; + +const OTHER_IOS_SIMULATOR = { + platform: 'apple', + id: 'sim-2', + name: 'iPhone 17', + kind: 'simulator', + booted: true, +} as const; + +test('Apple provider scope uses the request device for Apple tool calls', async () => { + const calls: string[][] = []; + const result = await withRequestPlatformProviderScope( + context(IOS_SIMULATOR), + { + appleToolProvider: ({ device }) => { + assert.equal(device.id, IOS_SIMULATOR.id); + return createLocalAppleToolProvider({ + runCommand: async (cmd, args) => { + throw new Error(`unexpected generic command: ${cmd} ${args.join(' ')}`); + }, + simctl: { + run: async (args) => { + calls.push([...args]); + return { exitCode: 0, stdout: 'simctl-ok', stderr: '' }; + }, + }, + }); + }, + linuxToolProvider: () => { + throw new Error('Linux provider should not apply to an iOS session'); + }, + }, + async () => await runXcrun(buildSimctlArgsForDevice(IOS_SIMULATOR, ['list', 'devices', '-j'])), + ); + + assert.equal(result.stdout, 'simctl-ok'); + assert.deepEqual(calls, [['list', 'devices', '-j']]); +}); + +test('Apple provider scope keeps the context device when it differs from the active session', async () => { + const seenDevices: string[] = []; + const result = await withRequestPlatformProviderScope( + { ...context(IOS_SIMULATOR), device: OTHER_IOS_SIMULATOR }, + { + appleToolProvider: ({ device, session }) => { + seenDevices.push(`${session?.name}:${device.id}`); + return createLocalAppleToolProvider({ + runCommand: async (cmd, args) => { + throw new Error(`unexpected generic command: ${cmd} ${args.join(' ')}`); + }, + simctl: { run: async () => ({ exitCode: 0, stdout: 'apps-ok', stderr: '' }) }, + }); + }, + }, + async () => + await runXcrun( + buildSimctlArgsForDevice(OTHER_IOS_SIMULATOR, ['listapps', OTHER_IOS_SIMULATOR.id]), + ), + ); + + assert.equal(result.stdout, 'apps-ok'); + assert.deepEqual(seenDevices, [`default:${OTHER_IOS_SIMULATOR.id}`]); +}); + +test('concurrent provider scopes stay isolated', async () => { + const androidCalls: string[] = []; + const appleCalls: string[] = []; + let androidEntered!: () => void; + let appleEntered!: () => void; + const androidInProvider = new Promise((resolve) => { + androidEntered = resolve; + }); + const appleInProvider = new Promise((resolve) => { + appleEntered = resolve; + }); + const bothProvidersEntered = Promise.all([androidInProvider, appleInProvider]); + + const androidTask = withRequestPlatformProviderScope( + context(ANDROID_EMULATOR, 'android-session', 'req-android'), + { + androidAdbProvider: ({ device, session }) => ({ + exec: async (args) => { + androidCalls.push(`${session?.name}:${device.id}:${args.join(' ')}`); + androidEntered(); + await bothProvidersEntered; + return { exitCode: 0, stdout: 'android-ok', stderr: '' }; + }, + }), + appleToolProvider: () => { + throw new Error('Apple provider should not apply to an Android request'); + }, + }, + async (scope) => { + const executor = scope.androidAdbExecutor as AndroidAdbExecutor | undefined; + assert.ok(executor); + return (await executor(['shell', 'echo', 'android'])).stdout; + }, + ); + + const appleTask = withRequestPlatformProviderScope( + context(IOS_SIMULATOR, 'ios-session', 'req-apple'), + { + androidAdbProvider: () => { + throw new Error('Android provider should not apply to an Apple request'); + }, + appleToolProvider: ({ device, session }) => + createLocalAppleToolProvider({ + runCommand: async (cmd, args) => { + throw new Error(`unexpected generic command: ${cmd} ${args.join(' ')}`); + }, + simctl: { + run: async (args) => { + appleCalls.push(`${session?.name}:${device.id}:${args.join(' ')}`); + appleEntered(); + await bothProvidersEntered; + return { exitCode: 0, stdout: 'apple-ok', stderr: '' }; + }, + }, + }), + }, + async () => + (await runXcrun(buildSimctlArgsForDevice(IOS_SIMULATOR, ['list', 'devices', '-j']))).stdout, + ); + + assert.deepEqual(await Promise.all([androidTask, appleTask]), ['android-ok', 'apple-ok']); + assert.deepEqual(androidCalls, [`android-session:${ANDROID_EMULATOR.id}:shell echo android`]); + assert.deepEqual(appleCalls, [`ios-session:${IOS_SIMULATOR.id}:list devices -j`]); +}); + +test('web provider scope uses the context device', async () => { + const calls: string[] = []; + const webProvider = makeWebProvider({ + async open(target) { + calls.push(`open:${target}`); + }, + }); + + await withRequestPlatformProviderScope( + context(WEB_DESKTOP_DEVICE, 'web-session'), + { + webProvider: ({ device, session }) => { + calls.push(`${session?.name}:${device.id}`); + return webProvider; + }, + linuxToolProvider: () => { + throw new Error('Linux provider should not apply to a web session'); + }, + }, + async () => await (await resolveWebProvider()).open('https://example.test'), + ); + + assert.deepEqual(calls, ['web-session:agent-browser-chrome', 'open:https://example.test']); +}); + +test('generic Apple runner provider cannot borrow local recording authority', async () => { + await withRequestPlatformProviderScope( + context(MACOS_DEVICE, 'macos-session'), + { + appleRunnerProvider: () => ({ runCommand: async () => ({}), hasLiveSession: () => true }), + }, + async () => { + const transport = resolveAppleRunnerScreenRecordingTransport(); + assert.equal(transport.authority, 'scoped-provider'); + assert.equal(transport.available, false); + }, + ); +}); + +test('Apple runner recording authority stays exact across recreated scopes', async () => { + let activeSessionId: string | undefined; + const transport: AppleRunnerScreenRecordingTransport = Object.freeze({ + authority: 'scoped-provider', + available: true, + start: async () => { + activeSessionId = 'provider-runner-session-1'; + return { runnerSessionId: activeSessionId }; + }, + inspect: async (device, runnerSessionId) => + device.id === MACOS_DEVICE.id && runnerSessionId === activeSessionId + ? 'owned-alive' + : 'ownership-lost', + stop: async ({ device, runnerSessionId }) => { + assert.equal(device.id, MACOS_DEVICE.id); + assert.equal(runnerSessionId, activeSessionId); + activeSessionId = undefined; + }, + }); + const providers = { + appleRunnerProvider: () => ({ runCommand: async () => ({}), hasLiveSession: () => true }), + appleRunnerScreenRecordingTransport: () => transport, + }; + const providerContext = context(MACOS_DEVICE, 'macos-session'); + const runnerSessionId = await withRequestPlatformProviderScope( + providerContext, + providers, + async () => { + const resolved = resolveAppleRunnerScreenRecordingTransport(); + assert.equal(resolved, transport); + return ( + await resolved.start({ + device: MACOS_DEVICE, + appBundleId: 'com.example.app', + outputPath: '/tmp/capture.mp4', + }) + ).runnerSessionId; + }, + ); + + await withRequestPlatformProviderScope(providerContext, providers, async () => { + const resolved = resolveAppleRunnerScreenRecordingTransport(); + assert.equal(await resolved.inspect(MACOS_DEVICE, runnerSessionId), 'owned-alive'); + assert.equal(await resolved.inspect(MACOS_DEVICE, 'replacement-session'), 'ownership-lost'); + await resolved.stop({ device: MACOS_DEVICE, runnerSessionId }); + }); +}); + +test('explicit web provider scope uses a sessionless context', async () => { + const seenDevices: string[] = []; + + await withRequestPlatformProviderScope( + { device: WEB_DESKTOP_DEVICE, requestId: 'req-web' }, + { + webProvider: ({ device, session }) => { + seenDevices.push(`${session?.name ?? 'none'}:${device.id}`); + return makeWebProvider(); + }, + appleToolProvider: () => { + throw new Error('Apple provider should not apply to a web request'); + }, + }, + async () => await (await resolveWebProvider()).snapshot(), + ); + + assert.deepEqual(seenDevices, ['none:agent-browser-chrome']); +}); + +function context( + device: PlatformProviderRequestContext['device'], + sessionName = 'default', + requestId = 'req-provider-scope', +): PlatformProviderRequestContext { + return { + device, + session: { name: sessionName, device }, + requestId, + }; +} + +async function withRequestPlatformProviderScope( + providerContext: PlatformProviderRequestContext, + providers: PlatformProviderResolvers, + task: (scope: RequestPlatformProviderScope) => Promise, +): Promise { + const scoped = createRequestPlatformProviders({ providers }); + return await scoped.run(providerContext, task); +} + +function makeWebProvider(overrides: Partial = {}): WebProvider { + return { + open: async () => {}, + close: async () => {}, + snapshot: async () => ({ nodes: [] }), + screenshot: async () => {}, + setViewport: async () => {}, + click: async () => {}, + fill: async () => {}, + typeText: async () => {}, + scroll: async () => {}, + ...overrides, + }; +} diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index c19b97a8d6..7f31199b62 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -601,7 +601,7 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'custom-scheme event name and JSON payload render in the fixture', ), macos: macos.contract( - 'src/__tests__/daemon-composition/app-event-delivery.test.ts', + 'packages/daemon/src/execution/app-events.test.ts', 'trigger-app-event supports macOS and prefers macOS template', 'macOS app-event dispatch selects the macOS URL template', ), @@ -1131,7 +1131,7 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'macOS gesture dispatch preserves the one-contact drag plan', ), tvos: tvos.contract( - 'src/__tests__/daemon-composition/gesture-admission-parity.test.ts', + 'src/platform-runtime-gateway-gesture-admission.test.ts', 'TV, spatial, watch, desktop, Linux, and web gesture policy stays explicit', 'the typed Apple gesture policy refuses tvOS multi-touch while preserving the narrower gesture contract', ),