From 1fab2e28d1ba148e5a3a9cfcc274860fd14ec024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 14:27:43 +0200 Subject: [PATCH 01/12] test(daemon): move composition suites by module owner --- .../execution/__tests__/android-system-dialog-readiness.test.ts | 0 .../daemon/src/execution}/generic-settle.test.ts | 0 .../src/execution/interaction/index-capture-disclosure.test.ts | 0 .../daemon/src/execution}/request-dispatch-disclosure.test.ts | 0 .../daemon/src/execution/screenshot-runtime-execution.test.ts | 0 .../src/execution}/selector-runtime-ambiguity-issuance.test.ts | 0 .../src/execution/selector-runtime-backend-admission.test.ts | 0 .../daemon/src/execution/selector-runtime-get-lifecycle.test.ts | 0 .../daemon/src/execution/selector-runtime-is-admission.test.ts | 0 .../daemon/src/execution}/sparse-fallback-screenshot.test.ts | 0 .../daemon/src/execution}/wait-runtime.test.ts | 0 .../src/server/__tests__}/request-router-android-modal.test.ts | 0 .../src/server/__tests__}/request-router-lock-policy.test.ts | 0 .../src/server/__tests__}/request-router-macos-app-lease.test.ts | 0 .../server/__tests__}/request-router-open-claim-recovery.test.ts | 0 .../server/__tests__}/request-router-provider-credentials.test.ts | 0 .../server/__tests__}/request-router-record-runtime-lock.test.ts | 0 .../src/server/__tests__}/request-router-recording-health.test.ts | 0 .../src/server/__tests__}/request-router-replay-scope.test.ts | 0 .../src/server/__tests__}/request-router-screenshot.test.ts | 0 .../src/server/http}/http-server-limrun-uploaded-apps.test.ts | 0 .../daemon/src/server/request-platform-provider-context.test.ts | 0 .../src/sessions/handlers}/snapshot-runtime-disclosure.test.ts | 0 .../daemon/src/sessions/request-handler-chain-routing.test.ts | 0 .../src/sessions/snapshot-command-runtime-cancellation.test.ts | 0 .../daemon/src/sessions/snapshot-diff-runtime-admission.test.ts | 0 .../daemon/src/sessions/snapshot-quality-latch-disclosure.test.ts | 0 27 files changed, 0 insertions(+), 0 deletions(-) rename src/__tests__/daemon-composition/android-dialog-readiness.test.ts => packages/daemon/src/execution/__tests__/android-system-dialog-readiness.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/execution}/generic-settle.test.ts (100%) rename src/__tests__/daemon-composition/capture-disclosure.test.ts => packages/daemon/src/execution/interaction/index-capture-disclosure.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/execution}/request-dispatch-disclosure.test.ts (100%) rename src/__tests__/daemon-composition/screenshot-runtime.test.ts => packages/daemon/src/execution/screenshot-runtime-execution.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/execution}/selector-runtime-ambiguity-issuance.test.ts (100%) rename src/__tests__/daemon-composition/selector-runtime-backend.test.ts => packages/daemon/src/execution/selector-runtime-backend-admission.test.ts (100%) rename src/__tests__/daemon-composition/selector-runtime.test.ts => packages/daemon/src/execution/selector-runtime-get-lifecycle.test.ts (100%) rename src/__tests__/daemon-composition/is-runtime.test.ts => packages/daemon/src/execution/selector-runtime-is-admission.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/execution}/sparse-fallback-screenshot.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/execution}/wait-runtime.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-android-modal.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-lock-policy.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-macos-app-lease.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-open-claim-recovery.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-provider-credentials.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-record-runtime-lock.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-recording-health.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-replay-scope.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-screenshot.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/http}/http-server-limrun-uploaded-apps.test.ts (100%) rename src/__tests__/daemon-composition/request-platform-providers.test.ts => packages/daemon/src/server/request-platform-provider-context.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/sessions/handlers}/snapshot-runtime-disclosure.test.ts (100%) rename src/__tests__/daemon-composition/request-handler-chain.test.ts => packages/daemon/src/sessions/request-handler-chain-routing.test.ts (100%) rename src/__tests__/daemon-composition/snapshot-command-runtime.test.ts => packages/daemon/src/sessions/snapshot-command-runtime-cancellation.test.ts (100%) rename src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts => packages/daemon/src/sessions/snapshot-diff-runtime-admission.test.ts (100%) rename src/__tests__/daemon-composition/snapshot-quality-latch.test.ts => packages/daemon/src/sessions/snapshot-quality-latch-disclosure.test.ts (100%) diff --git a/src/__tests__/daemon-composition/android-dialog-readiness.test.ts b/packages/daemon/src/execution/__tests__/android-system-dialog-readiness.test.ts similarity index 100% rename from src/__tests__/daemon-composition/android-dialog-readiness.test.ts rename to packages/daemon/src/execution/__tests__/android-system-dialog-readiness.test.ts diff --git a/src/__tests__/daemon-composition/generic-settle.test.ts b/packages/daemon/src/execution/generic-settle.test.ts similarity index 100% rename from src/__tests__/daemon-composition/generic-settle.test.ts rename to packages/daemon/src/execution/generic-settle.test.ts diff --git a/src/__tests__/daemon-composition/capture-disclosure.test.ts b/packages/daemon/src/execution/interaction/index-capture-disclosure.test.ts similarity index 100% rename from src/__tests__/daemon-composition/capture-disclosure.test.ts rename to packages/daemon/src/execution/interaction/index-capture-disclosure.test.ts diff --git a/src/__tests__/daemon-composition/request-dispatch-disclosure.test.ts b/packages/daemon/src/execution/request-dispatch-disclosure.test.ts similarity index 100% rename from src/__tests__/daemon-composition/request-dispatch-disclosure.test.ts rename to packages/daemon/src/execution/request-dispatch-disclosure.test.ts diff --git a/src/__tests__/daemon-composition/screenshot-runtime.test.ts b/packages/daemon/src/execution/screenshot-runtime-execution.test.ts similarity index 100% rename from src/__tests__/daemon-composition/screenshot-runtime.test.ts rename to packages/daemon/src/execution/screenshot-runtime-execution.test.ts 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 100% rename from src/__tests__/daemon-composition/selector-runtime-ambiguity-issuance.test.ts rename to packages/daemon/src/execution/selector-runtime-ambiguity-issuance.test.ts diff --git a/src/__tests__/daemon-composition/selector-runtime-backend.test.ts b/packages/daemon/src/execution/selector-runtime-backend-admission.test.ts similarity index 100% rename from src/__tests__/daemon-composition/selector-runtime-backend.test.ts rename to packages/daemon/src/execution/selector-runtime-backend-admission.test.ts diff --git a/src/__tests__/daemon-composition/selector-runtime.test.ts b/packages/daemon/src/execution/selector-runtime-get-lifecycle.test.ts similarity index 100% rename from src/__tests__/daemon-composition/selector-runtime.test.ts rename to packages/daemon/src/execution/selector-runtime-get-lifecycle.test.ts diff --git a/src/__tests__/daemon-composition/is-runtime.test.ts b/packages/daemon/src/execution/selector-runtime-is-admission.test.ts similarity index 100% rename from src/__tests__/daemon-composition/is-runtime.test.ts rename to packages/daemon/src/execution/selector-runtime-is-admission.test.ts diff --git a/src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts b/packages/daemon/src/execution/sparse-fallback-screenshot.test.ts similarity index 100% rename from src/__tests__/daemon-composition/sparse-fallback-screenshot.test.ts rename to packages/daemon/src/execution/sparse-fallback-screenshot.test.ts diff --git a/src/__tests__/daemon-composition/wait-runtime.test.ts b/packages/daemon/src/execution/wait-runtime.test.ts similarity index 100% rename from src/__tests__/daemon-composition/wait-runtime.test.ts rename to packages/daemon/src/execution/wait-runtime.test.ts 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 100% 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 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 100% 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 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 100% 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 diff --git a/src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts b/packages/daemon/src/server/__tests__/request-router-open-claim-recovery.test.ts similarity index 100% rename from src/__tests__/daemon-composition/request-router-open-claim-recovery.test.ts rename to packages/daemon/src/server/__tests__/request-router-open-claim-recovery.test.ts 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% rename from src/__tests__/daemon-composition/request-router-screenshot.test.ts rename to packages/daemon/src/server/__tests__/request-router-screenshot.test.ts diff --git a/src/__tests__/daemon-composition/http-server-limrun-uploaded-apps.test.ts b/packages/daemon/src/server/http/http-server-limrun-uploaded-apps.test.ts similarity index 100% rename from src/__tests__/daemon-composition/http-server-limrun-uploaded-apps.test.ts rename to packages/daemon/src/server/http/http-server-limrun-uploaded-apps.test.ts diff --git a/src/__tests__/daemon-composition/request-platform-providers.test.ts b/packages/daemon/src/server/request-platform-provider-context.test.ts similarity index 100% rename from src/__tests__/daemon-composition/request-platform-providers.test.ts rename to packages/daemon/src/server/request-platform-provider-context.test.ts 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 100% rename from src/__tests__/daemon-composition/snapshot-runtime-disclosure.test.ts rename to packages/daemon/src/sessions/handlers/snapshot-runtime-disclosure.test.ts 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 100% rename from src/__tests__/daemon-composition/request-handler-chain.test.ts rename to packages/daemon/src/sessions/request-handler-chain-routing.test.ts 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 100% rename from src/__tests__/daemon-composition/snapshot-command-runtime.test.ts rename to packages/daemon/src/sessions/snapshot-command-runtime-cancellation.test.ts 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 100% rename from src/__tests__/daemon-composition/snapshot-diff-runtime.test.ts rename to packages/daemon/src/sessions/snapshot-diff-runtime-admission.test.ts 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 100% rename from src/__tests__/daemon-composition/snapshot-quality-latch.test.ts rename to packages/daemon/src/sessions/snapshot-quality-latch-disclosure.test.ts From 12dba1c74ceb852c3275fdab73533221fa7a39f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 14:28:59 +0200 Subject: [PATCH 02/12] test(daemon): colocate generic settle fixture --- .../daemon/src/execution/generic-settle.fixtures.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/__tests__/daemon-composition/presented-sheet-trees.ts => packages/daemon/src/execution/generic-settle.fixtures.ts (100%) diff --git a/src/__tests__/daemon-composition/presented-sheet-trees.ts b/packages/daemon/src/execution/generic-settle.fixtures.ts similarity index 100% rename from src/__tests__/daemon-composition/presented-sheet-trees.ts rename to packages/daemon/src/execution/generic-settle.fixtures.ts From 33c46ef8fea3665984bce3971b459873d9ccaa5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 14:41:32 +0200 Subject: [PATCH 03/12] test-kit: own eager import closure fixture --- .../test-kit/src/eager-import-closure-fixtures.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/__tests__/eager-import-closure.fixtures.ts => packages/test-kit/src/eager-import-closure-fixtures.ts (100%) diff --git a/src/__tests__/eager-import-closure.fixtures.ts b/packages/test-kit/src/eager-import-closure-fixtures.ts similarity index 100% rename from src/__tests__/eager-import-closure.fixtures.ts rename to packages/test-kit/src/eager-import-closure-fixtures.ts From 0698e9956b12ded2fa9d2a591ea16b9711ce42d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 14:47:08 +0200 Subject: [PATCH 04/12] test(root): colocate app event interactor coverage --- .../interactors-app-events.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{__tests__/daemon-composition/app-event-delivery.test.ts => core/interactors-app-events.test.ts} (100%) diff --git a/src/__tests__/daemon-composition/app-event-delivery.test.ts b/src/core/interactors-app-events.test.ts similarity index 100% rename from src/__tests__/daemon-composition/app-event-delivery.test.ts rename to src/core/interactors-app-events.test.ts From 13a6c588f9367d7ed63d15c622ee8cec33f86fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 14:51:29 +0200 Subject: [PATCH 05/12] test(daemon): move remaining composition suites --- .../daemon/src/core/session-capture-binding.test.ts | 0 .../src/server/__tests__}/request-router-android-perf.test.ts | 0 .../__tests__}/request-router-android-snapshot-helper.test.ts | 0 .../src/server/__tests__}/request-router-fill-text-stdin.test.ts | 0 .../platform-plugin-registry-provider-routing.test.ts} | 0 ...test.ts => platform-runtime-gateway-gesture-admission.test.ts} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename src/__tests__/daemon-composition/perf-capture-session-resource.test.ts => packages/daemon/src/core/session-capture-binding.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-android-perf.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-android-snapshot-helper.test.ts (100%) rename {src/__tests__/daemon-composition => packages/daemon/src/server/__tests__}/request-router-fill-text-stdin.test.ts (100%) rename src/{__tests__/daemon-composition/providers-plugin-routing-parity.test.ts => core/platform-plugin-registry-provider-routing.test.ts} (100%) rename src/{__tests__/daemon-composition/gesture-admission-parity.test.ts => platform-runtime-gateway-gesture-admission.test.ts} (100%) diff --git a/src/__tests__/daemon-composition/perf-capture-session-resource.test.ts b/packages/daemon/src/core/session-capture-binding.test.ts similarity index 100% rename from src/__tests__/daemon-composition/perf-capture-session-resource.test.ts rename to packages/daemon/src/core/session-capture-binding.test.ts diff --git a/src/__tests__/daemon-composition/request-router-android-perf.test.ts b/packages/daemon/src/server/__tests__/request-router-android-perf.test.ts similarity index 100% rename from src/__tests__/daemon-composition/request-router-android-perf.test.ts rename to packages/daemon/src/server/__tests__/request-router-android-perf.test.ts diff --git a/src/__tests__/daemon-composition/request-router-android-snapshot-helper.test.ts b/packages/daemon/src/server/__tests__/request-router-android-snapshot-helper.test.ts similarity index 100% rename from src/__tests__/daemon-composition/request-router-android-snapshot-helper.test.ts rename to packages/daemon/src/server/__tests__/request-router-android-snapshot-helper.test.ts 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 100% 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 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 100% rename from src/__tests__/daemon-composition/providers-plugin-routing-parity.test.ts rename to src/core/platform-plugin-registry-provider-routing.test.ts diff --git a/src/__tests__/daemon-composition/gesture-admission-parity.test.ts b/src/platform-runtime-gateway-gesture-admission.test.ts similarity index 100% rename from src/__tests__/daemon-composition/gesture-admission-parity.test.ts rename to src/platform-runtime-gateway-gesture-admission.test.ts From cd4cbc0c5007be42972d99c34999323e3c34cad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 19:04:57 +0200 Subject: [PATCH 06/12] test(daemon): retire composition export consumers --- .../__tests__/support/command-surface-fake.ts | 111 ++-- .../support/command-surface-override-fake.ts | 5 +- .../screenshot-command-surface-fake.ts | 49 ++ .../core/gesture-runtime-admission.test.ts | 97 +++ .../src/core/session-capture-binding.test.ts | 131 ++-- .../android-system-dialog-readiness.test.ts | 289 +++++---- .../daemon/src/execution/app-events.test.ts | 136 +++- .../src/execution/generic-settle.fixtures.ts | 81 --- .../src/execution/generic-settle.test.ts | 534 +++------------- .../index-capture-disclosure.test.ts | 63 +- .../request-dispatch-disclosure.test.ts | 65 +- .../screenshot-runtime-execution.test.ts | 40 +- ...elector-runtime-ambiguity-issuance.test.ts | 83 +-- ...selector-runtime-backend-admission.test.ts | 146 ----- .../selector-runtime-get-lifecycle.test.ts | 13 +- .../selector-runtime-is-admission.test.ts | 179 ++---- .../sparse-fallback-screenshot.test.ts | 28 +- .../daemon/src/execution/wait-runtime.test.ts | 596 ++---------------- .../request-router-android-modal.test.ts | 34 +- .../request-router-android-perf.test.ts | 80 --- ...est-router-android-snapshot-helper.test.ts | 154 ++--- .../request-router-fill-text-stdin.test.ts | 81 +-- .../request-router-lock-policy.test.ts | 16 +- .../request-router-macos-app-lease.test.ts | 14 +- ...request-router-open-claim-recovery.test.ts | 298 ++------- ...equest-router-provider-credentials.test.ts | 17 +- ...request-router-record-runtime-lock.test.ts | 70 +- .../request-router-recording-health.test.ts | 48 +- .../request-router-replay-scope.test.ts | 83 +-- .../request-router-screenshot.test.ts | 40 +- .../http-server-limrun-uploaded-apps.test.ts | 74 +-- .../request-platform-provider-context.test.ts | 390 +----------- ...on-replay-runtime-maestro-dispatch.test.ts | 92 ++- .../snapshot-runtime-disclosure.test.ts | 30 +- .../request-handler-chain-routing.test.ts | 201 +++--- .../session-open-execution-runtime.test.ts | 61 ++ .../__tests__/session-perf-runtime.test.ts | 42 ++ ...pshot-command-runtime-cancellation.test.ts | 20 +- .../snapshot-diff-runtime-admission.test.ts | 59 +- .../snapshot-quality-latch-disclosure.test.ts | 27 +- .../platform-android/src/lifecycle.test.ts | 9 + .../platform-linux/src/interactor.test.ts | 20 +- packages/test-kit/package.json | 3 +- .../src/dispatch-disclosure.fixtures.ts | 4 +- .../src/eager-import-closure-fixtures.ts | 2 +- packages/test-kit/src/index.ts | 9 + pnpm-lock.yaml | 3 + scripts/__tests__/closure-growth-rule.test.ts | 2 +- scripts/__tests__/committed-source-tree.ts | 2 +- .../__tests__/eager-closure-budgets.test.ts | 4 +- scripts/__tests__/eager-closure-budgets.ts | 2 +- .../cli-startup-import-closure.test.ts | 2 +- .../application-lifecycle-runtime-fixture.ts | 200 ------ .../lifecycle-runtime-gateway.ts | 17 - .../request-router-replay-test-ime.test.ts | 105 --- .../session-teardown-import-closure.test.ts | 2 +- .../snapshot-desktop-surface.test.ts | 2 +- .../interaction/runtime/selector-is.test.ts | 61 ++ .../interaction/runtime/wait-stable.test.ts | 39 ++ .../interaction/runtime/wait-text.test.ts | 173 ++++- src/core/interactors-app-events.test.ts | 320 ++-------- ...m-plugin-registry-provider-routing.test.ts | 85 +-- src/daemon-runtime-limrun-public-http.test.ts | 153 +++++ ...-runtime-gateway-gesture-admission.test.ts | 210 +++--- ...platform-runtime-gateway-recording.test.ts | 41 ++ ...platform-runtime-perf-capture-host.test.ts | 47 ++ src/platform-runtime-perf-host.test.ts | 65 +- src/platform-runtime-provider-scope.test.ts | 295 +++++++++ .../command-coverage/declarations.ts | 4 +- 69 files changed, 2647 insertions(+), 3811 deletions(-) create mode 100644 packages/daemon/src/__tests__/support/screenshot-command-surface-fake.ts create mode 100644 packages/daemon/src/core/gesture-runtime-admission.test.ts delete mode 100644 packages/daemon/src/execution/generic-settle.fixtures.ts delete mode 100644 packages/daemon/src/execution/selector-runtime-backend-admission.test.ts delete mode 100644 packages/daemon/src/server/__tests__/request-router-android-perf.test.ts delete mode 100644 src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts delete mode 100644 src/__tests__/daemon-composition/lifecycle-runtime-gateway.ts delete mode 100644 src/__tests__/daemon-composition/request-router-replay-test-ime.test.ts create mode 100644 src/daemon-runtime-limrun-public-http.test.ts create mode 100644 src/platform-runtime-gateway-recording.test.ts create mode 100644 src/platform-runtime-provider-scope.test.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..61f69bfaff --- /dev/null +++ b/packages/daemon/src/__tests__/support/screenshot-command-surface-fake.ts @@ -0,0 +1,49 @@ +import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; +import { resizePngFileToScale } from '@agent-device/capture-kit/png-resize'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +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 tempDir = + options.out?.kind === 'path' ? undefined : mkdtempForTestSync('agent-device-screenshot-'); + const outputPath = + options.out?.kind === 'path' ? options.out.path : path.join(tempDir!, 'screenshot.png'); + try { + const captured = await config.backend.captureScreenshot?.( + { + session: options.session ?? 'default', + requestId: options.requestId, + appBundleId: options.appBundleId, + signal: options.signal ?? config.signal, + }, + outputPath, + { + fullscreen: options.fullscreen, + overlayRefs: options.overlayRefs, + pixelDensity: options.pixelDensity, + stabilize: options.stabilize, + normalizeStatusBar: options.normalizeStatusBar, + surface: options.surface, + }, + ); + if (options.scale !== undefined) await resizePngFileToScale(outputPath, options.scale); + return { + path: outputPath, + ...(captured?.displayRotation ? { displayRotation: captured.displayRotation } : {}), + }; + } catch (error) { + if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }); + 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 index 6107ebb24e..fa22e37007 100644 --- a/packages/daemon/src/core/session-capture-binding.test.ts +++ b/packages/daemon/src/core/session-capture-binding.test.ts @@ -1,27 +1,19 @@ -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 { 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'; -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', @@ -32,18 +24,8 @@ const device: DeviceInfo = { 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 () => { +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 = { @@ -54,86 +36,59 @@ test('a perf stop whose pull failed re-collects the device-side trace the first }; 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, + mode: 'trace' as const, state: 'running' as const, - method: 'native', - message: 'started', + outPath: '/tmp/capture', }; - const remoteTraces = new Set([REMOTE_TRACE]); - let pullFails = true; const pullFailure = new AppError('COMMAND_FAILED', 'Failed to pull Android perfetto artifact', { - package: capture.packageName, + package: 'com.example.app', tool: 'perfetto', - remotePath: REMOTE_TRACE, + 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.', }); - androidNative.startAndroidPerfettoTrace.mockImplementation(async () => { - remoteTraces.add(capture.remotePath); - return capture; + let pullFails = true; + const finish = vi.fn(async () => { + if (pullFails) throw pullFailure; + return { + status: 'completed' as const, + result: { ...capture, state: 'stopped' as const }, + }; }); - 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 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)!); - 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, + 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, - }); + 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(forceCleanup).not.toHaveBeenCalled(); expect(sessionStore.get(sessionName)?.perfCapture?.handle).toBeDefined(); expect(perfCaptureResourceStore.read(resourcePath)).toMatchObject({ status: 'decoded', @@ -142,6 +97,6 @@ test('a perf stop whose pull failed re-collects the device-side trace the first pullFails = false; await expect(stop()).resolves.toMatchObject({ kind: 'perfetto', state: 'stopped' }); - expect(remoteTraces).toEqual(new Set()); + 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 index 5a099cd3bd..9d8be4e12c 100644 --- a/packages/daemon/src/execution/__tests__/android-system-dialog-readiness.test.ts +++ b/packages/daemon/src/execution/__tests__/android-system-dialog-readiness.test.ts @@ -1,55 +1,26 @@ -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 { 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 '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { withFakeAdb, type FakeAdbScript } from '../test-utils/fake-adb.ts'; +import { makeAndroidSession } from '../../__tests__/support/session-factories.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 }); +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 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}'; @@ -101,20 +72,68 @@ 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 { +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; - return (args) => { - if (args[1] === 'input' && args[2] === 'tap') { + 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 ''; - } - if (args[1] !== 'dumpsys') return ''; - const focus = dismissed ? null : dialog(); - return focus ?? NORMAL_FOCUS_DUMP; + 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, }; } @@ -125,25 +144,20 @@ test('an ANR that surfaces between two commands is recovered by the next pre-dis // 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 }; - }, - ); + 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', @@ -159,24 +173,19 @@ test('an ANR whose Close app is covered is not tapped, and recovery reports fail // 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 }; - }, - ); + 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([]); }); @@ -184,69 +193,57 @@ test('an ANR whose Close app is covered is not tapped, and recovery reports fail 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' }); - }, - ); + 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 }); - - 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'), - }); - }, - ); + 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 { 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']), - }; - }, - ), - ); + 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. 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.fixtures.ts b/packages/daemon/src/execution/generic-settle.fixtures.ts deleted file mode 100644 index 112821fbfc..0000000000 --- a/packages/daemon/src/execution/generic-settle.fixtures.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/packages/daemon/src/execution/generic-settle.test.ts b/packages/daemon/src/execution/generic-settle.test.ts index 121db28a3e..f285e9d528 100644 --- a/packages/daemon/src/execution/generic-settle.test.ts +++ b/packages/daemon/src/execution/generic-settle.test.ts @@ -1,34 +1,68 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; +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 { 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 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 '@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'; +} 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 '@agent-device/daemon/testing/__tests__/selector-capture-fixture'; -import { testCreateCommandSurface } from './command-surface-fixture.ts'; +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 @@ -36,9 +70,8 @@ import { testCreateCommandSurface } from './command-surface-fixture.ts'; // 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(); +vi.mock('./interaction/index.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, captureSnapshotForSession: vi.fn(async () => ({ @@ -50,9 +83,9 @@ vi.mock('@agent-device/daemon/testing/interaction/index', async (importOriginal) }; }); -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'; +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); @@ -121,7 +154,8 @@ async function emulateCaptureSnapshotForSession( ? await options.boundCapture({}) : ((await mockDispatch('snapshot')) as Parameters[0]); const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); - setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); + const current = sessionStore.resolveCurrent(ref); + if (current) setSessionSnapshot(current, snapshot); return snapshot; } @@ -140,28 +174,9 @@ function mockCommandDispatch( }); } -/** - * 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. - */ +/** Device work for the generic leaf under test. */ const platformExecution = vi.fn( async (params: { command: string }) => await mockDispatch(params.command), ); @@ -205,17 +220,6 @@ function snapshotPayload(tree: GenericSettleTree) { }; } -/** - * 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 @@ -238,8 +242,6 @@ async function dispatchGeneric(params: { 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', @@ -249,10 +251,8 @@ async function dispatchGeneric(params: { ...(params.flags ? { flags: params.flags } : {}), }; return await dispatchGenericCommand({ - createCommandSurface: params.clock - ? async (config) => await testCreateCommandSurface({ ...config, clock: params.clock }) - : testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + createCommandSurface: testCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), req, ref: params.sessionStore.lookup(params.sessionName)!, sessionName: params.sessionName, @@ -273,6 +273,7 @@ function expectOkData(response: DaemonResponse): Record { beforeEach(() => { captureObservations.length = 0; + settleObservationCalls.length = 0; mockDispatch.mockReset(); mockDispatch.mockResolvedValue({}); mockCaptureSnapshotForSession.mockReset(); @@ -284,12 +285,10 @@ beforeEach(() => { ); }); -test('scroll --settle answers with the settled diff against the stored pre-action tree', async () => { +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); - // 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({ @@ -301,35 +300,19 @@ test('scroll --settle answers with the settled diff against the stored pre-actio }); 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. + 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); - 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 () => { +test('back --settle attaches the observation alongside the command result', async () => { const sessionStore = makeSessionStore(); const sessionName = 'generic-settle-back'; seedSession(sessionName, sessionStore); @@ -347,140 +330,22 @@ test('back --settle answers with the settled diff alongside the command result', }); 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); + expect(data.settle).toMatchObject(SETTLE_OBSERVATION); }); -test('scroll --settle attaches no diff across a sheet-to-app surface change and discloses it', async () => { +test('generic settle preserves the stored surface facts on its baseline', 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. + const sessionName = 'generic-settle-surface-baseline'; seedSession(sessionName, sessionStore, { - nodes: WEB_SIGN_IN_SHEET_NODES, - systemSurface: WEB_SIGN_IN_SHEET, + nodes: BEFORE_NODES, + systemSurface: { bundleId: 'com.apple.SafariViewService', kind: 'web-auth' }, + presentedSurface: 'presented', }); - mockSurfaceDispatch({ nodes: APP_CONTENT_NODES }); + mockCommandDispatch([AFTER_NODES]); - const response = await dispatchGeneric({ + await dispatchGeneric({ sessionName, sessionStore, command: 'scroll', @@ -488,154 +353,40 @@ test('scroll --settle attaches no diff across a sheet-to-app surface change and 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(settleObservationCalls[0]?.baseline).toMatchObject({ + surfaceBundleId: 'com.apple.SafariViewService', + presentedSurface: 'presented', }); - 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', - }, + expect(settleObservationCalls[0]?.baseline.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Continue' })]), ); - - 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 () => { +test('generic settle drops an outdated presented-surface fact but keeps its stored nodes', 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, + const sessionName = 'generic-settle-outdated-baseline'; + const session = seedSession(sessionName, sessionStore, { + nodes: BEFORE_NODES, + systemSurface: { bundleId: 'com.apple.SafariViewService', kind: 'web-auth' }, + presentedSurface: 'absent', }); - mockSurfaceDispatch( - { nodes: APP_CONTENT_NODES }, - { - action: 'back', - mode: 'in-app', - message: 'Back', - }, - ); + markSessionSnapshotOutdated(session); + mockCommandDispatch([AFTER_NODES]); - const response = await dispatchGeneric({ + 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(), + expect(settleObservationCalls[0]?.baseline).toMatchObject({ + surfaceBundleId: 'com.apple.SafariViewService', }); - 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(settleObservationCalls[0]?.baseline.presentedSurface).toBeUndefined(); + expect(settleObservationCalls[0]?.baseline.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ label: 'Continue' })]), ); - - 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 () => { @@ -761,7 +512,7 @@ test('an ended generic lifetime is refused before dispatch or settle constructio await expect( dispatchGenericCommand({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: { token: 't', session: sessionName, @@ -907,80 +658,3 @@ for (const change of ['rebuild', 'retire'] as const) { } }); } - -// 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/packages/daemon/src/execution/interaction/index-capture-disclosure.test.ts b/packages/daemon/src/execution/interaction/index-capture-disclosure.test.ts index 05da0cd967..3f5dfb82bf 100644 --- a/packages/daemon/src/execution/interaction/index-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/packages/daemon/src/execution/request-dispatch-disclosure.test.ts b/packages/daemon/src/execution/request-dispatch-disclosure.test.ts index f5467e7b8d..a618235dae 100644 --- a/packages/daemon/src/execution/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/packages/daemon/src/execution/screenshot-runtime-execution.test.ts b/packages/daemon/src/execution/screenshot-runtime-execution.test.ts index 6cd5db8e8e..002d40a819 100644 --- a/packages/daemon/src/execution/screenshot-runtime-execution.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, @@ -205,7 +205,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 +225,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 }, @@ -277,7 +277,7 @@ test('the crop runs after the platform write and before the shared scale', async const resolved = await resolveScreenshotGenericExecution({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req, session, inspectFacts: fixture.inspectFacts, @@ -327,7 +327,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 +351,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 +388,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 +409,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/packages/daemon/src/execution/selector-runtime-ambiguity-issuance.test.ts b/packages/daemon/src/execution/selector-runtime-ambiguity-issuance.test.ts index 52206ee5a0..cd37558a1c 100644 --- a/packages/daemon/src/execution/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/packages/daemon/src/execution/selector-runtime-backend-admission.test.ts b/packages/daemon/src/execution/selector-runtime-backend-admission.test.ts deleted file mode 100644 index fdb066e063..0000000000 --- a/packages/daemon/src/execution/selector-runtime-backend-admission.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/packages/daemon/src/execution/selector-runtime-get-lifecycle.test.ts b/packages/daemon/src/execution/selector-runtime-get-lifecycle.test.ts index d92f524b19..519af80099 100644 --- a/packages/daemon/src/execution/selector-runtime-get-lifecycle.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/packages/daemon/src/execution/selector-runtime-is-admission.test.ts b/packages/daemon/src/execution/selector-runtime-is-admission.test.ts index b0d4e5f865..d6ec725513 100644 --- a/packages/daemon/src/execution/selector-runtime-is-admission.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/packages/daemon/src/execution/sparse-fallback-screenshot.test.ts b/packages/daemon/src/execution/sparse-fallback-screenshot.test.ts index d2ad407e78..8c318ffe44 100644 --- a/packages/daemon/src/execution/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 index 461a38d6f8..15aa911060 100644 --- a/packages/daemon/src/execution/wait-runtime.test.ts +++ b/packages/daemon/src/execution/wait-runtime.test.ts @@ -1,7 +1,7 @@ -import { composedPlatformServicesFixture } from './platform-services-fixture.ts'; +import { daemonPlatformServicesFixture } from '../__tests__/support/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 { WAIT_REASONS } from '@agent-device/contracts/wait'; +import { AppError } from '@agent-device/kernel/errors'; import { type DeviceBinding, type RuntimeFacts, @@ -20,19 +20,47 @@ import { 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 '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; +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 '@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'; +} 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 }; + } + await config.backend.captureSnapshot?.( + { session, requestId, signal: signal ?? config.signal }, + {}, + ); + 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', @@ -164,7 +192,7 @@ test.each(['rebuild', 'retire'] as const)( const ref = sessionStore.publish(address, makeSession('default', { device: harness.device })); const running = handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: waitRequest(['100']), sessionName: address, logPath: '/tmp/daemon.log', @@ -220,7 +248,7 @@ async function runWait( sessionStore.publish(session.name, session); const response = await handleSnapshotCommands({ createCommandSurface: testCreateCommandSurface, - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), req: waitRequest(positionals, flags), sessionName: session.name, logPath: '/tmp/daemon.log', @@ -255,7 +283,7 @@ test('an observing wait binds through the shared selector entry', async () => { const harness = waitRuntimeHarness({ findText: available }); const bound = await resolveBoundSelectorCapture({ - platformServices: composedPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture(), command: 'wait', session: undefined, device: harness.device, @@ -410,543 +438,3 @@ test('a timed-out wait decorates its failure through the same single binding', a 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/packages/daemon/src/server/__tests__/request-router-android-modal.test.ts b/packages/daemon/src/server/__tests__/request-router-android-modal.test.ts index 9b7176b6b1..f4b682cce7 100644 --- a/packages/daemon/src/server/__tests__/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-perf.test.ts b/packages/daemon/src/server/__tests__/request-router-android-perf.test.ts deleted file mode 100644 index 08ff3cf155..0000000000 --- a/packages/daemon/src/server/__tests__/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/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 index be50b47ea0..860293bae0 100644 --- 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 @@ -1,137 +1,83 @@ 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 { expect, test } from 'vitest'; 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'; +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 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, +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; }, - 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', + const handler = createRequestHandler({ + createCommandSurface: forwardingCreateCommandSurface, + logPath: `${mkdtempForTestSync('agent-device-snapshot-helper-response-')}/daemon.log`, token: 'token', sessionStore, leaseRegistry: new LeaseRegistry(), deviceInventoryGateways: createTestDeviceInventoryGateways(), - requestPlatformProviders: createRequestPlatformProviders({ - providers: { androidAdbProvider }, - }), - deviceRuntimeGateway, + deviceRuntimeGateway: runtime.gateway, trackDownloadableArtifact: () => 'artifact-id', }); + return handler; } -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({ +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) 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. + 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); }); -// 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, - }); +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, }, - snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, - }; - const handler = makeHandler(sessionStore, () => provider); - - const response = await handler({ - token: 'token', - session: 'default', - command: 'snapshot', - positionals: [], - flags: {}, - }); + ); + const response = await requestSnapshot(snapshotHandler(failure)); expect(response.ok).toBe(false); - if (response.ok) throw new Error('Expected snapshot to fail'); + 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/); diff --git a/packages/daemon/src/server/__tests__/request-router-fill-text-stdin.test.ts b/packages/daemon/src/server/__tests__/request-router-fill-text-stdin.test.ts index e3527c0557..1df40feeca 100644 --- a/packages/daemon/src/server/__tests__/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/packages/daemon/src/server/__tests__/request-router-lock-policy.test.ts b/packages/daemon/src/server/__tests__/request-router-lock-policy.test.ts index a72cda18ba..ad7aec6606 100644 --- a/packages/daemon/src/server/__tests__/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/packages/daemon/src/server/__tests__/request-router-macos-app-lease.test.ts b/packages/daemon/src/server/__tests__/request-router-macos-app-lease.test.ts index 7a7c571765..7e09a37289 100644 --- a/packages/daemon/src/server/__tests__/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 index 3e23d4b72c..0719f0275a 100644 --- 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 @@ -1,264 +1,98 @@ -import { test, expect, vi, beforeEach } from 'vitest'; +import crypto from 'node:crypto'; 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 { beforeEach, expect, test, vi } from 'vitest'; 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), - }; -}); - + 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 '@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'; +} 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()); -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'); +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-router-open-claims-'); - const foreignStateDir = mkdtempForTestSync('agent-device-router-open-foreign-'); + 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 orphan = await spawnOrphanRecorder(); + const recoverySessionDirs: string[] = []; + const composeClaimRecovery = (input: { sessionsDir: string }) => { + recoverySessionDirs.push(input.sessionsDir); + return unavailableDeviceRuntimeGateway; + }; 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); + 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 createOpenHandler(sessionStore)( + const response = await handler( 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); + 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 { - 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 }); } }); + +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/packages/daemon/src/server/__tests__/request-router-provider-credentials.test.ts b/packages/daemon/src/server/__tests__/request-router-provider-credentials.test.ts index 8282701a77..0c19817d93 100644 --- a/packages/daemon/src/server/__tests__/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/packages/daemon/src/server/__tests__/request-router-record-runtime-lock.test.ts b/packages/daemon/src/server/__tests__/request-router-record-runtime-lock.test.ts index 6f7046ce43..af4d60a2a0 100644 --- a/packages/daemon/src/server/__tests__/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/packages/daemon/src/server/__tests__/request-router-recording-health.test.ts b/packages/daemon/src/server/__tests__/request-router-recording-health.test.ts index 363c87248e..f8984eb5b4 100644 --- a/packages/daemon/src/server/__tests__/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/packages/daemon/src/server/__tests__/request-router-replay-scope.test.ts b/packages/daemon/src/server/__tests__/request-router-replay-scope.test.ts index f4739f822e..33754da684 100644 --- a/packages/daemon/src/server/__tests__/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/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts b/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts index b5e630b730..e49fc0c98e 100644 --- a/packages/daemon/src/server/__tests__/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', 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 index 3ff8eef2ef..544e2542fd 100644 --- 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 @@ -1,73 +1,30 @@ 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 type { DaemonResponse } from '@agent-device/kernel/contracts'; +import { expect, test, vi } from 'vitest'; 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(), -})); +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 } } }; -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) => { +test('public HTTP marks command and lease requests before dispatch', 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 requests: DaemonRequest[] = []; 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 handleRequest = vi.fn(async (request: DaemonRequest): Promise => { + requests.push(request); + return { ok: true, data: {} }; }); const server = await createDaemonHttpServer({ token, leaseRegistry, handleRequest }); @@ -96,13 +53,14 @@ test('public HTTP rejects Limrun uploaded-app listing and allocation before prov }, }); - 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(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); - await runtime.shutdown(); } }); diff --git a/packages/daemon/src/server/request-platform-provider-context.test.ts b/packages/daemon/src/server/request-platform-provider-context.test.ts index 9881ec8753..decbfc98f9 100644 --- a/packages/daemon/src/server/request-platform-provider-context.test.ts +++ b/packages/daemon/src/server/request-platform-provider-context.test.ts @@ -1,37 +1,11 @@ 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 { 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 { - 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'; +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', @@ -41,228 +15,50 @@ const OTHER_IOS_SIMULATOR: DeviceInfo = { 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( +test('request provider context follows an explicit device selector for an existing session', async () => { + const context = await withTestDeviceInventoryProvider( 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: '' }), - }, - }); - }, - }, + await resolvePlatformProviderRequestContext({ + req: { + ...request('apps'), + flags: { platform: 'ios', device: 'iPhone 17' }, }, - async () => - await runXcrun( - buildSimctlArgsForDevice(OTHER_IOS_SIMULATOR, ['listapps', OTHER_IOS_SIMULATOR.id]), - ), - ), + existingSession: makeIosSession('default'), + }), ); - assert.equal(result.stdout, 'apps-ok'); - assert.deepEqual(seenDevices, [`default:${OTHER_IOS_SIMULATOR.id}`]); + assert.equal(context?.device.id, OTHER_IOS_SIMULATOR.id); + assert.equal(context?.session?.name, 'default'); }); -test('request platform provider scope skips sharded test orchestration requests', async () => { - let providerCalls = 0; - - const result = await withTargetDeviceResolutionScope( +test('request provider context skips sharded test orchestration requests', async () => { + const context = await withTestDeviceInventoryProvider( async () => { - throw new Error('Sharded test orchestration should not resolve a provider device'); + throw new Error('A sharded test request must 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', - ), + await resolvePlatformProviderRequestContext({ + req: { ...request('test'), flags: { platform: 'ios', shardAll: 2 } }, + existingSession: undefined, + }), ); - assert.equal(result, 'unscoped'); - assert.equal(providerCalls, 0); + assert.equal(context, undefined); }); -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: '' }; - }, - }, - }), - }, - }, +test('request provider context follows an explicit sessionless web selector', async () => { + const context = await withTestDeviceInventoryProvider( + async () => [WEB_DESKTOP_DEVICE], async () => - (await runXcrun(buildSimctlArgsForDevice(IOS_SIMULATOR, ['list', 'devices', '-j']))).stdout, + await resolvePlatformProviderRequestContext({ + req: { ...request('snapshot'), flags: { platform: 'web' } }, + existingSession: undefined, + }), ); - 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); - }, - ); + 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 () => { @@ -272,95 +68,7 @@ test('request provider context preserves an explicitly empty request id', async }); 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']); + assert.equal(context?.device.id, IOS_SIMULATOR.id); }); function request(command: string): DaemonRequest { @@ -373,35 +81,3 @@ function request(command: string): DaemonRequest { 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/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/packages/daemon/src/sessions/handlers/snapshot-runtime-disclosure.test.ts b/packages/daemon/src/sessions/handlers/snapshot-runtime-disclosure.test.ts index 31654f3234..2820931367 100644 --- a/packages/daemon/src/sessions/handlers/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 }; }); @@ -143,11 +138,8 @@ test('a snapshot after a scroll that moved nothing warns and stamps the no-effec await vi.advanceTimersByTimeAsync(100); await new Promise((resolve) => realSetTimeout(resolve, 1)); } - const response = await pending; + await pending; - expect(response.ok && response.data?.warnings).toContain( - formatPostGestureOutcomeWarning(outcome), - ); expect(input.sessionStore.get(input.sessionName)?.snapshot?.postGestureOutcome).toEqual(outcome); }); diff --git a/packages/daemon/src/sessions/request-handler-chain-routing.test.ts b/packages/daemon/src/sessions/request-handler-chain-routing.test.ts index 14f42b640e..9e0c980177 100644 --- a/packages/daemon/src/sessions/request-handler-chain-routing.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/packages/daemon/src/sessions/snapshot-command-runtime-cancellation.test.ts b/packages/daemon/src/sessions/snapshot-command-runtime-cancellation.test.ts index 4442446d63..671d5f40e7 100644 --- a/packages/daemon/src/sessions/snapshot-command-runtime-cancellation.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/packages/daemon/src/sessions/snapshot-diff-runtime-admission.test.ts b/packages/daemon/src/sessions/snapshot-diff-runtime-admission.test.ts index 7f54de517d..ebeacd6e50 100644 --- a/packages/daemon/src/sessions/snapshot-diff-runtime-admission.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/packages/daemon/src/sessions/snapshot-quality-latch-disclosure.test.ts b/packages/daemon/src/sessions/snapshot-quality-latch-disclosure.test.ts index a468902b08..9d4162ef34 100644 --- a/packages/daemon/src/sessions/snapshot-quality-latch-disclosure.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..812b9efd0c 100644 --- a/packages/platform-linux/src/interactor.test.ts +++ b/packages/platform-linux/src/interactor.test.ts @@ -1,7 +1,13 @@ 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 +43,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/packages/test-kit/src/eager-import-closure-fixtures.ts b/packages/test-kit/src/eager-import-closure-fixtures.ts index 384ccb1078..c60f2fd9ff 100644 --- a/packages/test-kit/src/eager-import-closure-fixtures.ts +++ b/packages/test-kit/src/eager-import-closure-fixtures.ts @@ -217,7 +217,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/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/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/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/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__/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/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/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..5249c60926 100644 --- a/src/commands/interaction/runtime/wait-text.test.ts +++ b/src/commands/interaction/runtime/wait-text.test.ts @@ -1,15 +1,41 @@ import assert from 'node:assert/strict'; -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; +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'; + +function textWaitDevice(params: { + captureSnapshot: NonNullable; + findText?: AgentDeviceBackend['findText']; + readText?: AgentDeviceBackend['readText']; +}) { + 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: 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 }); @@ -34,6 +60,145 @@ test('runtime wait times out on text the polled snapshot does not carry', async ); }); +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('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); +}); + /** * #2484 follow-up: the iOS runner refuses every command with a retriable `RUNNER_BUSY` while it * drains the abandoned main-thread work of a command that exceeded its execution watchdog. A diff --git a/src/core/interactors-app-events.test.ts b/src/core/interactors-app-events.test.ts index c84810e602..cdf6bfbfe7 100644 --- a/src/core/interactors-app-events.test.ts +++ b/src/core/interactors-app-events.test.ts @@ -1,196 +1,68 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; 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 { - 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'; +import { getInteractor } from './interactors.ts'; -/** - * 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); +async function triggerEvent(device: DeviceInfo, eventUrl: string, appBundleId?: string) { const operations = bindAdmittedLocalInteractorOperations({ device, signal: new AbortController().signal, - resolveInteractor: async (owned: DeviceInfo, runner) => await getInteractor(owned, runner), + resolveInteractor: async (owned, runner) => await getInteractor(owned, runner), facts: { triggerAppEvent: { available: true } }, }); - await operations.triggerAppEvent?.({ eventUrl, options }); - return { event: eventName, eventUrl, transport: 'deep-link' }; + await operations.triggerAppEvent?.({ + eventUrl, + options: appBundleId === undefined ? {} : { appBundleId }, + }); } -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'); +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( - adbPath, + commandPath, '#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n', 'utf8', ); - await fs.chmod(adbPath, 0o755); + await fs.chmod(commandPath, 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); + return await run(argsLogPath); } finally { - process.env.PATH = previousPath; + 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; - 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 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 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); +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', @@ -200,127 +72,17 @@ test('trigger-app-event supports iOS device path and prefers iOS template', asyn '--device', 'ios-device-1', '--payload-url', - expectedUrl, + eventUrl, '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); +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, [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; - } + assert.deepEqual(args, [eventUrl]); + }); }); diff --git a/src/core/platform-plugin-registry-provider-routing.test.ts b/src/core/platform-plugin-registry-provider-routing.test.ts index da60815f8e..791b62b1de 100644 --- a/src/core/platform-plugin-registry-provider-routing.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..9daa3f1c17 --- /dev/null +++ b/src/daemon-runtime-limrun-public-http.test.ts @@ -0,0 +1,153 @@ +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: [] }); + const 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: () => {} }, + }); + + try { + if (!runtime?.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:${runtime.httpPort}/rpc`, { + method: 'POST', + headers: { + authorization: `Bearer ${runtime.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(); + assert.equal(rootPorts.applicationLifecycle.finalizeDaemonShutdown.mock.calls.length, 1); + fs.rmSync(paths.baseDir, { recursive: true, force: true }); + } +}); diff --git a/src/platform-runtime-gateway-gesture-admission.test.ts b/src/platform-runtime-gateway-gesture-admission.test.ts index 40b45e79b0..b257b4bf58 100644 --- a/src/platform-runtime-gateway-gesture-admission.test.ts +++ b/src/platform-runtime-gateway-gesture-admission.test.ts @@ -1,54 +1,15 @@ -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'; +import { expect, test } from 'vitest'; +import { createPlatformRuntimeGateway } from './platform-runtime.ts'; -/** - * 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', + outputPath: '/sessions/gesture-admission/app.log', + pidPath: '/sessions/gesture-admission/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', @@ -57,71 +18,31 @@ const device = (fields: Partial): DeviceInfo => ({ ...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); + 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 () => { - 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' })); - } + const supported = [ + device({ kind: 'device', target: 'mobile' }), + device({ platform: 'apple', appleOs: 'ios', kind: 'device', target: 'mobile' }), + device({ platform: 'apple', appleOs: 'ios', kind: 'simulator', target: 'mobile' }), + device({ platform: 'apple', appleOs: 'ipados', kind: 'device', 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); } - await expectAdmitted(drag, device({ platform: 'apple', kind: 'simulator', target: 'mobile' })); - const inexactBackends = [ + const inexact = [ device({ target: 'tv' }), device({ platform: 'apple', appleOs: 'tvos', kind: 'simulator', target: 'tv' }), device({ platform: 'apple', appleOs: 'macos', kind: 'device', target: 'desktop' }), @@ -131,52 +52,65 @@ test('target-authored drag is admitted only where adapters preserve every author 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/, - ); + 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 = 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/); + 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 = 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, + 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' }), ); - 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/); + 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', ), From f1d699192cb6276619b93083e36b685e2c82e63e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 19:05:13 +0200 Subject: [PATCH 07/12] chore(gates): remove freed daemon test exports --- packages/daemon/package.json | 144 ------------------ .../platform-linux/src/interactor.test.ts | 7 +- 2 files changed, 1 insertion(+), 150 deletions(-) diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 3d7ffb08b1..68207fd937 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,10 @@ "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/platform-linux/src/interactor.test.ts b/packages/platform-linux/src/interactor.test.ts index 812b9efd0c..db4b34d80e 100644 --- a/packages/platform-linux/src/interactor.test.ts +++ b/packages/platform-linux/src/interactor.test.ts @@ -2,12 +2,7 @@ import assert from 'node:assert/strict'; import { beforeEach, test, vi } from 'vitest'; import { createLinuxInteractor } from './interactor.ts'; import { buildGesturePlan } from '@agent-device/contracts/gesture-plan'; -import { - middleClickLinux, - pressLinux, - rightClickLinux, - swipeLinux, -} from './index.ts'; +import { middleClickLinux, pressLinux, rightClickLinux, swipeLinux } from './index.ts'; vi.mock('./index.ts', () => ({ backLinux: vi.fn(), From 21951140f5ccf0191374952d93c4695dd2546dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 21:06:12 +0200 Subject: [PATCH 08/12] fix: preserve wait fallback after native text errors --- .../daemon/src/execution/wait-runtime.test.ts | 44 ++++++++++++++----- .../interaction/runtime/wait-text.test.ts | 24 ++++++++++ src/commands/interaction/runtime/wait-text.ts | 8 +++- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/packages/daemon/src/execution/wait-runtime.test.ts b/packages/daemon/src/execution/wait-runtime.test.ts index 15aa911060..7f16d35235 100644 --- a/packages/daemon/src/execution/wait-runtime.test.ts +++ b/packages/daemon/src/execution/wait-runtime.test.ts @@ -40,10 +40,12 @@ const testCreateCommandSurface = commandSurfaceWithMembers((config) => ({ await new Promise((resolve) => setTimeout(resolve, target.durationMs)); return { kind: 'sleep', waitedMs: target.durationMs }; } - await config.backend.captureSnapshot?.( - { session, requestId, signal: signal ?? config.signal }, - {}, - ); + 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, @@ -269,14 +271,22 @@ async function runWait( // --------------------------------------------------------------------------- test('a duration wait inspects no facts and binds no device', async () => { - const harness = waitRuntimeHarness({ capture: captureUnavailable }); + vi.useFakeTimers(); + try { + const harness = waitRuntimeHarness({ capture: captureUnavailable }); + const pending = runWait(['5'], harness); - const { response } = await 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(); + 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 () => { @@ -318,6 +328,20 @@ test('a text wait binds the capture use once and polls through the bound operati 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('a selector wait binds the capture use once and polls through the bound operation', async () => { const harness = waitRuntimeHarness(); diff --git a/src/commands/interaction/runtime/wait-text.test.ts b/src/commands/interaction/runtime/wait-text.test.ts index 5249c60926..f7ba578db2 100644 --- a/src/commands/interaction/runtime/wait-text.test.ts +++ b/src/commands/interaction/runtime/wait-text.test.ts @@ -125,6 +125,30 @@ test('runtime wait surfaces a canonical capture failure after a native text miss assert.equal(captureSnapshot.mock.calls.length, 1); }); +test('runtime wait surfaces a canonical capture failure after native text reading throws', async () => { + const captureSnapshot = vi.fn(async (): Promise => { + throw new AppError('COMMAND_FAILED', 'canonical capture failed', { + reason: 'capture_failed', + }); + }); + const findText = vi.fn(async () => { + throw new Error('native observation failed'); + }); + 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' }]), diff --git a/src/commands/interaction/runtime/wait-text.ts b/src/commands/interaction/runtime/wait-text.ts index 70732cff07..76c043d5c2 100644 --- a/src/commands/interaction/runtime/wait-text.ts +++ b/src/commands/interaction/runtime/wait-text.ts @@ -40,8 +40,12 @@ async function observeText( text: string, ): Promise { if (runtime.backend.findText) { - const native = await runtime.backend.findText(backendContext(runtime, options), text); - if (native.found) return true; + try { + const native = await runtime.backend.findText(backendContext(runtime, options), text); + if (native.found) return true; + } catch (error) { + if (options.signal?.aborted || runtime.signal?.aborted) throw error; + } } return await snapshotContainsText(operations, runtime, options, text); } From 2757757ef909496106ae19c422c894ad113f1041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 21:06:26 +0200 Subject: [PATCH 09/12] test: preserve snapshot and daemon cleanup evidence --- .../snapshot-runtime-disclosure.test.ts | 3 +- src/commands/capture/runtime/snapshot.test.ts | 19 ++++++++ src/daemon-runtime-limrun-public-http.test.ts | 43 ++++++++++--------- ...-runtime-gateway-gesture-admission.test.ts | 2 - 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/packages/daemon/src/sessions/handlers/snapshot-runtime-disclosure.test.ts b/packages/daemon/src/sessions/handlers/snapshot-runtime-disclosure.test.ts index 2820931367..77c40fb734 100644 --- a/packages/daemon/src/sessions/handlers/snapshot-runtime-disclosure.test.ts +++ b/packages/daemon/src/sessions/handlers/snapshot-runtime-disclosure.test.ts @@ -138,8 +138,9 @@ test('a snapshot after a scroll that moved nothing warns and stamps the no-effec await vi.advanceTimersByTimeAsync(100); await new Promise((resolve) => realSetTimeout(resolve, 1)); } - await pending; + const response = await pending; + expect(response.ok).toBe(true); expect(input.sessionStore.get(input.sessionName)?.snapshot?.postGestureOutcome).toEqual(outcome); }); 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/daemon-runtime-limrun-public-http.test.ts b/src/daemon-runtime-limrun-public-http.test.ts index 9daa3f1c17..a1228e621f 100644 --- a/src/daemon-runtime-limrun-public-http.test.ts +++ b/src/daemon-runtime-limrun-public-http.test.ts @@ -77,33 +77,35 @@ test('public HTTP rejects Limrun uploaded-app listing and allocation before prov limrunTestDependencies, ); rootPorts.providerComposition.mockResolvedValue({ runtimes: [limrun], platformModules: [] }); - const 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: () => {} }, - }); + let runtime: Awaited> | undefined; try { - if (!runtime?.httpPort) throw new Error('HTTP daemon runtime did not start'); + 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:${runtime.httpPort}/rpc`, { + const response = await fetch(`http://127.0.0.1:${activeRuntime.httpPort}/rpc`, { method: 'POST', headers: { - authorization: `Bearer ${runtime.token}`, + authorization: `Bearer ${activeRuntime.token}`, 'content-type': 'application/json', [DAEMON_HTTP_NETWORK_ACCESS_HEADER]: DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, }, @@ -147,7 +149,8 @@ test('public HTTP rejects Limrun uploaded-app listing and allocation before prov } finally { await runtime?.shutdown(); await limrun.shutdown(); - assert.equal(rootPorts.applicationLifecycle.finalizeDaemonShutdown.mock.calls.length, 1); 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 index b257b4bf58..c0969b66f6 100644 --- a/src/platform-runtime-gateway-gesture-admission.test.ts +++ b/src/platform-runtime-gateway-gesture-admission.test.ts @@ -29,9 +29,7 @@ test('Android phones and emulators admit single- and multi-touch gesture plans', 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: 'device', target: 'mobile' }), device({ platform: 'apple', appleOs: 'ios', kind: 'simulator', target: 'mobile' }), - device({ platform: 'apple', appleOs: 'ipados', kind: 'device', target: 'mobile' }), device({ platform: 'apple', appleOs: 'ipados', kind: 'simulator', target: 'mobile' }), device({ platform: 'apple', kind: 'simulator', target: 'mobile' }), ]; From 00e44c6c3a50022cbcf0c1d0b7d9aeee737322ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 21:18:12 +0200 Subject: [PATCH 10/12] test: pin daemon wait fallback ownership --- .../daemon/src/execution/wait-runtime.test.ts | 22 +++++++++++++++++ .../interaction/runtime/wait-text.test.ts | 24 ------------------- src/commands/interaction/runtime/wait-text.ts | 8 ++----- 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/packages/daemon/src/execution/wait-runtime.test.ts b/packages/daemon/src/execution/wait-runtime.test.ts index 7f16d35235..dc907b92ca 100644 --- a/packages/daemon/src/execution/wait-runtime.test.ts +++ b/packages/daemon/src/execution/wait-runtime.test.ts @@ -342,6 +342,28 @@ test('a text wait returns a native owner hit without capturing a snapshot', asyn expect(harness.captureSnapshot).not.toHaveBeenCalled(); }); +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(); diff --git a/src/commands/interaction/runtime/wait-text.test.ts b/src/commands/interaction/runtime/wait-text.test.ts index f7ba578db2..5249c60926 100644 --- a/src/commands/interaction/runtime/wait-text.test.ts +++ b/src/commands/interaction/runtime/wait-text.test.ts @@ -125,30 +125,6 @@ test('runtime wait surfaces a canonical capture failure after a native text miss assert.equal(captureSnapshot.mock.calls.length, 1); }); -test('runtime wait surfaces a canonical capture failure after native text reading throws', async () => { - const captureSnapshot = vi.fn(async (): Promise => { - throw new AppError('COMMAND_FAILED', 'canonical capture failed', { - reason: 'capture_failed', - }); - }); - const findText = vi.fn(async () => { - throw new Error('native observation failed'); - }); - 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' }]), diff --git a/src/commands/interaction/runtime/wait-text.ts b/src/commands/interaction/runtime/wait-text.ts index 76c043d5c2..70732cff07 100644 --- a/src/commands/interaction/runtime/wait-text.ts +++ b/src/commands/interaction/runtime/wait-text.ts @@ -40,12 +40,8 @@ async function observeText( text: string, ): Promise { if (runtime.backend.findText) { - try { - const native = await runtime.backend.findText(backendContext(runtime, options), text); - if (native.found) return true; - } catch (error) { - if (options.signal?.aborted || runtime.signal?.aborted) throw error; - } + const native = await runtime.backend.findText(backendContext(runtime, options), text); + if (native.found) return true; } return await snapshotContainsText(operations, runtime, options, text); } From 03d2a9e87ab8eaaeab3bb923170d06f3232610e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 01:33:11 +0200 Subject: [PATCH 11/12] test: restore daemon composition wait branch coverage --- packages/daemon/package.json | 4 - .../daemon/src/execution/wait-runtime.test.ts | 39 ++++ .../runtime/settle-generic-surface.test.ts | 112 ++++++++++ .../interaction/runtime/wait-absent.test.ts | 165 +++++++++++++- .../interaction/runtime/wait-deadline.test.ts | 54 ++++- .../interaction/runtime/wait-polling.test.ts | 204 +++++++++++++++++- .../interaction/runtime/wait-text.test.ts | 129 ++++++++++- 7 files changed, 691 insertions(+), 16 deletions(-) create mode 100644 src/commands/interaction/runtime/settle-generic-surface.test.ts diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 68207fd937..940463268f 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -164,10 +164,6 @@ "types": "./src/core/session-store.ts", "default": "./src/core/session-store.ts" }, - "./testing/snapshot-interactor-capture": { - "types": "./src/core/snapshot-interactor-capture.ts", - "default": "./src/core/snapshot-interactor-capture.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/execution/wait-runtime.test.ts b/packages/daemon/src/execution/wait-runtime.test.ts index dc907b92ca..49f1002208 100644 --- a/packages/daemon/src/execution/wait-runtime.test.ts +++ b/packages/daemon/src/execution/wait-runtime.test.ts @@ -342,6 +342,31 @@ test('a text wait returns a native owner hit without capturing a snapshot', asyn 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')); @@ -375,6 +400,20 @@ test('a selector wait binds the capture use once and polls through the bound ope 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(); 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..f39e3cf315 100644 --- a/src/commands/interaction/runtime/wait-absent.test.ts +++ b/src/commands/interaction/runtime/wait-absent.test.ts @@ -1,12 +1,17 @@ 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'; const SELECTOR = 'label="Removed"'; @@ -52,6 +57,36 @@ 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; + if (!signal) throw new Error('the wait deadline never reached the capture'); + return await new Promise((_resolve, reject) => { + const cancel = () => reject(params.cancellation); + if (signal.aborted) cancel(); + else signal.addEventListener('abort', cancel, { once: true }); + }); + }); + 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 +257,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..a27216cfb0 100644 --- a/src/commands/interaction/runtime/wait-polling.test.ts +++ b/src/commands/interaction/runtime/wait-polling.test.ts @@ -1,10 +1,19 @@ 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'; +function abortWith(signal: AbortSignal, error: unknown): Promise { + return new Promise((_resolve, reject) => { + const cancel = () => reject(error); + if (signal.aborted) cancel(); + else signal.addEventListener('abort', cancel, { once: true }); + }); +} + test('poll delay is bounded by the remaining wait budget', async () => { let currentMs = 0; const sleeps: number[] = []; @@ -272,6 +281,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-text.test.ts b/src/commands/interaction/runtime/wait-text.test.ts index 5249c60926..061d36c7fa 100644 --- a/src/commands/interaction/runtime/wait-text.test.ts +++ b/src/commands/interaction/runtime/wait-text.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; +import { WAIT_REASONS } from '@agent-device/contracts/wait'; import type { AgentDeviceBackend, BackendCommandContext, @@ -21,6 +22,7 @@ function textWaitDevice(params: { captureSnapshot: NonNullable; findText?: AgentDeviceBackend['findText']; readText?: AgentDeviceBackend['readText']; + clock?: { now: () => number; sleep: (ms: number) => Promise }; }) { return createAgentDevice({ backend: { @@ -32,12 +34,24 @@ function textWaitDevice(params: { artifacts: createLocalArtifactAdapter(), sessions: createMemorySessionStore([{ name: 'default' }]), policy: localCommandPolicy(), - clock: createFakeClock(), + clock: params.clock ?? createFakeClock(), + }); +} + +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 }); }); } 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', @@ -45,18 +59,23 @@ 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; + }, ); }); @@ -86,6 +105,30 @@ test('runtime wait falls back to a snapshot after a native text miss', async () 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 => ({ @@ -199,6 +242,76 @@ test('runtime wait passes its polling deadline signal to snapshot capture', asyn 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(); + } +}); + /** * #2484 follow-up: the iOS runner refuses every command with a retriable `RUNNER_BUSY` while it * drains the abandoned main-thread work of a command that exceeded its execution watchdog. A From 933efafa2349a742db067f89b0eb13c2fd70d007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 05:12:56 +0200 Subject: [PATCH 12/12] fix daemon screenshot test adapter ownership --- .../screenshot-command-surface-fake.ts | 20 ++++----- .../screenshot-runtime-execution.test.ts | 12 +++--- .../request-router-screenshot.test.ts | 2 +- .../src/eager-import-closure-fixtures.ts | 6 ++- .../capture/runtime/screenshot.test.ts | 41 +++++++++++++++++++ .../__tests__/test-utils/abort-with.ts | 8 ++++ .../interaction/runtime/wait-absent.test.ts | 8 +--- .../interaction/runtime/wait-polling.test.ts | 9 +--- .../interaction/runtime/wait-text.test.ts | 10 +---- 9 files changed, 71 insertions(+), 45 deletions(-) create mode 100644 src/commands/capture/runtime/screenshot.test.ts create mode 100644 src/commands/interaction/runtime/__tests__/test-utils/abort-with.ts diff --git a/packages/daemon/src/__tests__/support/screenshot-command-surface-fake.ts b/packages/daemon/src/__tests__/support/screenshot-command-surface-fake.ts index 61f69bfaff..d86f505ffc 100644 --- a/packages/daemon/src/__tests__/support/screenshot-command-surface-fake.ts +++ b/packages/daemon/src/__tests__/support/screenshot-command-surface-fake.ts @@ -1,8 +1,4 @@ import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; -import { resizePngFileToScale } from '@agent-device/capture-kit/png-resize'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; -import fs from 'node:fs/promises'; -import path from 'node:path'; import { forwardingCreateCommandSurfaceWithAnnotations } from './command-surface-fake.ts'; export const screenshotCommandSurface: CreateDaemonCommandSurface = async (config) => { @@ -12,10 +8,11 @@ export const screenshotCommandSurface: CreateDaemonCommandSurface = async (confi capture: { ...forwarded.capture, screenshot: async (options) => { - const tempDir = - options.out?.kind === 'path' ? undefined : mkdtempForTestSync('agent-device-screenshot-'); - const outputPath = - options.out?.kind === 'path' ? options.out.path : path.join(tempDir!, 'screenshot.png'); + const reserved = await config.artifacts.reserveOutput(options.out, { + field: 'path', + ext: '.png', + artifactType: 'screenshot', + }); try { const captured = await config.backend.captureScreenshot?.( { @@ -24,7 +21,7 @@ export const screenshotCommandSurface: CreateDaemonCommandSurface = async (confi appBundleId: options.appBundleId, signal: options.signal ?? config.signal, }, - outputPath, + reserved.path, { fullscreen: options.fullscreen, overlayRefs: options.overlayRefs, @@ -34,13 +31,12 @@ export const screenshotCommandSurface: CreateDaemonCommandSurface = async (confi surface: options.surface, }, ); - if (options.scale !== undefined) await resizePngFileToScale(outputPath, options.scale); return { - path: outputPath, + path: reserved.path, ...(captured?.displayRotation ? { displayRotation: captured.displayRotation } : {}), }; } catch (error) { - if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }); + await reserved.cleanup?.(); throw error; } }, diff --git a/packages/daemon/src/execution/screenshot-runtime-execution.test.ts b/packages/daemon/src/execution/screenshot-runtime-execution.test.ts index 002d40a819..50690f1455 100644 --- a/packages/daemon/src/execution/screenshot-runtime-execution.test.ts +++ b/packages/daemon/src/execution/screenshot-runtime-execution.test.ts @@ -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); }); @@ -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,7 +273,7 @@ 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({ @@ -283,14 +284,11 @@ test('the crop runs after the platform write and before the shared scale', async 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, diff --git a/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts b/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts index e49fc0c98e..9c6c77165c 100644 --- a/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts @@ -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/test-kit/src/eager-import-closure-fixtures.ts b/packages/test-kit/src/eager-import-closure-fixtures.ts index c60f2fd9ff..6a82a76906 100644 --- a/packages/test-kit/src/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); 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/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/wait-absent.test.ts b/src/commands/interaction/runtime/wait-absent.test.ts index f39e3cf315..9fea3acc6e 100644 --- a/src/commands/interaction/runtime/wait-absent.test.ts +++ b/src/commands/interaction/runtime/wait-absent.test.ts @@ -13,6 +13,7 @@ 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"'; @@ -66,12 +67,7 @@ function cancelledAbsentDevice(params: { captures += 1; if (captures === 1 && params.firstSnapshot) return params.firstSnapshot; const signal = context.signal; - if (!signal) throw new Error('the wait deadline never reached the capture'); - return await new Promise((_resolve, reject) => { - const cancel = () => reject(params.cancellation); - if (signal.aborted) cancel(); - else signal.addEventListener('abort', cancel, { once: true }); - }); + return await abortWith(signal, params.cancellation); }); const clock = { now: () => 0, sleep: async (_durationMs: number) => {} }; const device = createAgentDevice({ diff --git a/src/commands/interaction/runtime/wait-polling.test.ts b/src/commands/interaction/runtime/wait-polling.test.ts index a27216cfb0..c7c55f795f 100644 --- a/src/commands/interaction/runtime/wait-polling.test.ts +++ b/src/commands/interaction/runtime/wait-polling.test.ts @@ -5,14 +5,7 @@ import { SELECTOR_PIPELINE_POLICIES } from '@agent-device/selectors/selector-pip import { AppError } from '@agent-device/kernel/errors'; import { WAIT_REASONS, type ReadinessPhase } from '@agent-device/contracts/wait'; import { createWaitPolling, waitTimeoutError } from './wait-polling.ts'; - -function abortWith(signal: AbortSignal, error: unknown): Promise { - return new Promise((_resolve, reject) => { - const cancel = () => reject(error); - if (signal.aborted) cancel(); - else signal.addEventListener('abort', cancel, { once: true }); - }); -} +import { abortWith } from './__tests__/test-utils/abort-with.ts'; test('poll delay is bounded by the remaining wait budget', async () => { let currentMs = 0; diff --git a/src/commands/interaction/runtime/wait-text.test.ts b/src/commands/interaction/runtime/wait-text.test.ts index 061d36c7fa..2f7fd387bf 100644 --- a/src/commands/interaction/runtime/wait-text.test.ts +++ b/src/commands/interaction/runtime/wait-text.test.ts @@ -17,6 +17,7 @@ import { 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; @@ -38,15 +39,6 @@ function textWaitDevice(params: { }); } -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 }); - }); -} - test('runtime wait resolves text from the polled snapshot', async () => { const captureSnapshot = vi.fn(async (): Promise => ({ snapshot: selectorReadSnapshot(),