diff --git a/.github/actions/setup-android-replay-host/action.yml b/.github/actions/setup-android-replay-host/action.yml index 27ef93920..7582342f9 100644 --- a/.github/actions/setup-android-replay-host/action.yml +++ b/.github/actions/setup-android-replay-host/action.yml @@ -1,5 +1,5 @@ name: 'Setup Android Replay Host' -description: 'Prepare the Android CI host for replay tests and expose the agent-device home path' +description: 'Prepare the Android CI host for replay tests and expose the daemon state path' inputs: package-helpers: @@ -8,9 +8,9 @@ inputs: default: 'false' outputs: - agent-home-dir: - description: 'Resolved agent-device home directory' - value: ${{ steps.agent-home.outputs.dir }} + agent-state-dir: + description: 'Resolved agent-device daemon state directory' + value: ${{ steps.agent-state.outputs.dir }} helpers-cache-hit: description: 'Whether packaged Android helpers were restored from cache' value: ${{ steps.android-helpers-cache.outputs.cache-hit }} @@ -18,9 +18,9 @@ outputs: runs: using: 'composite' steps: - - name: Resolve agent-device home - id: agent-home - run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT" + - name: Resolve agent-device daemon state + id: agent-state + run: echo "dir=$(pnpm --silent daemon:state-dir)" >> "$GITHUB_OUTPUT" shell: bash - name: Enable KVM diff --git a/.github/actions/upload-agent-device-artifacts/action.yml b/.github/actions/upload-agent-device-artifacts/action.yml index e527683d6..55c34e964 100644 --- a/.github/actions/upload-agent-device-artifacts/action.yml +++ b/.github/actions/upload-agent-device-artifacts/action.yml @@ -5,7 +5,7 @@ inputs: artifact-name: description: 'Artifact name' required: true - agent-home-dir: + agent-state-dir: description: 'Resolved agent-device daemon state directory' required: true runner-derived-path: @@ -23,9 +23,9 @@ runs: if-no-files-found: ignore include-hidden-files: true path: | - ${{ inputs.agent-home-dir }}/daemon.log + ${{ inputs.agent-state-dir }}/daemon.log ~/.agent-device/logs/** - ${{ inputs.agent-home-dir }}/sessions/** + ${{ inputs.agent-state-dir }}/sessions/** ${{ inputs.runner-derived-path }}/.agent-device-runner-cache.json ${{ inputs.runner-derived-path }}/Logs/** test/artifacts/** diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 155aff259..c1ffcf67a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -51,6 +51,15 @@ jobs: - name: Report fixture cache source run: echo "Android fixture APK source=${{ steps.fixture-app.outputs.source }}" + - name: Run Android emulator catalog coverage contract + run: node --test test/integration/smoke-android-emulator-coverage.test.ts + + - name: Build agent-device CLI + run: pnpm build + + - name: Reset daemon state + run: pnpm clean:daemon + - name: Run Android smoke checks uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0 with: @@ -60,13 +69,15 @@ jobs: target: google_apis_playstore emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics script: | - sh ./test/scripts/android-fixture-cache-smoke.sh "${{ steps.fixture-app.outputs.apk-path }}" "${{ steps.fixture-app.outputs.app-id }}" + set -eu + ANDROID_SERIAL="$(adb devices | awk 'NR > 1 && $2 == "device" { print $1; exit }')" + test -n "$ANDROID_SERIAL" + AGENT_DEVICE_ANDROID_E2E=1 AGENT_DEVICE_ANDROID_SERIAL="$ANDROID_SERIAL" AGENT_DEVICE_FIXTURE_APP_PATH="${{ steps.fixture-app.outputs.apk-path }}" AGENT_DEVICE_FIXTURE_APP_ID="${{ steps.fixture-app.outputs.app-id }}" node --test test/integration/smoke-android-emulator.test.ts node --experimental-strip-types src/bin.ts test test/integration/replays/android/01-settings.ad --retries 2 --report-junit test/artifacts/replays-android-smoke.junit.xml - sh ./test/scripts/android-snapshot-helper-release-smoke.sh - name: Upload Android artifacts if: always() uses: ./.github/actions/upload-agent-device-artifacts with: artifact-name: android-artifacts - agent-home-dir: ${{ steps.android-replay-host.outputs.agent-home-dir }} + agent-state-dir: ${{ steps.android-replay-host.outputs.agent-state-dir }} diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index f3958fb15..07b6c572e 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -155,5 +155,5 @@ jobs: uses: ./.github/actions/upload-agent-device-artifacts with: artifact-name: ios-artifacts - agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} + agent-state-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index a03691ebf..63352b0e6 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -56,5 +56,5 @@ jobs: uses: ./.github/actions/upload-agent-device-artifacts with: artifact-name: macos-artifacts - agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} + agent-state-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} diff --git a/.github/workflows/replays-nightly.yml b/.github/workflows/replays-nightly.yml index b4f063eac..3c23d115e 100644 --- a/.github/workflows/replays-nightly.yml +++ b/.github/workflows/replays-nightly.yml @@ -121,7 +121,7 @@ jobs: uses: ./.github/actions/upload-agent-device-artifacts with: artifact-name: replay-nightly-android-artifacts - agent-home-dir: ${{ steps.android-replay-host.outputs.agent-home-dir }} + agent-state-dir: ${{ steps.android-replay-host.outputs.agent-state-dir }} nightly-ios: name: iOS Replay Suite @@ -202,7 +202,7 @@ jobs: uses: ./.github/actions/upload-agent-device-artifacts with: artifact-name: replay-nightly-ios-artifacts - agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} + agent-state-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} nightly-macos: @@ -239,5 +239,5 @@ jobs: uses: ./.github/actions/upload-agent-device-artifacts with: artifact-name: replay-nightly-macos-artifacts - agent-home-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} + agent-state-dir: ${{ env.AGENT_DEVICE_STATE_DIR }} runner-derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }} diff --git a/android/snapshot-helper/README.md b/android/snapshot-helper/README.md index 2cc3fdb4d..0cfd92d7c 100644 --- a/android/snapshot-helper/README.md +++ b/android/snapshot-helper/README.md @@ -44,7 +44,9 @@ Devices or providers that block test-package installs must allow this package be can run. `waitForIdleTimeoutMs` defaults to `500`, which is a maximum wait, not a fixed sleep. Direct helper -invocations can pass `0` when immediate capture during ongoing animation is preferred. +invocations can pass `0` when immediate capture during ongoing animation is preferred. Root +acquisition has a separate 500 ms stabilization bound that is used only when no root is available or +an active/focused window is temporarily missing its root; complete captures pay no additional wait. ## One-Shot Modes diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityCaptureStabilizer.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityCaptureStabilizer.java new file mode 100644 index 000000000..f99efecb2 --- /dev/null +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityCaptureStabilizer.java @@ -0,0 +1,80 @@ +package com.callstack.agentdevice.snapshothelper; + +final class AccessibilityCaptureStabilizer { + private static final long RETRY_INTERVAL_MS = 50; + + interface Capture { + boolean isComplete(); + } + + interface Attempt { + T capture(); + } + + interface Clock { + long nowMs(); + } + + interface Sleeper { + void sleep(long durationMs) throws InterruptedException; + } + + static final class IncompleteCaptureException extends Exception { + IncompleteCaptureException(long timeoutMs) { + super("Android accessibility capture remained incomplete after " + timeoutMs + " ms"); + } + } + + private AccessibilityCaptureStabilizer() {} + + static boolean requiresActiveWindowFallback( + int capturedWindowCount, boolean activeWindowRootMissing) { + return capturedWindowCount == 0 || activeWindowRootMissing; + } + + static boolean canAppendActiveWindowFallback( + int capturedWindowCount, boolean activeWindowMetadataAvailable) { + return capturedWindowCount == 0 || activeWindowMetadataAvailable; + } + + static T capture(Attempt attempt, long timeoutMs) + throws InterruptedException, IncompleteCaptureException { + return capture( + attempt, + timeoutMs, + () -> System.nanoTime() / 1_000_000, + Thread::sleep); + } + + static T capture( + Attempt attempt, long timeoutMs, Clock clock, Sleeper sleeper) + throws InterruptedException, IncompleteCaptureException { + T capture = attempt.capture(); + if (capture.isComplete()) { + return capture; + } + if (timeoutMs <= 0) { + throw new IncompleteCaptureException(timeoutMs); + } + + long startedAtMs = clock.nowMs(); + long deadlineMs = startedAtMs + timeoutMs; + if (deadlineMs < startedAtMs) { + deadlineMs = Long.MAX_VALUE; + } + while (!capture.isComplete()) { + long remainingMs = deadlineMs - clock.nowMs(); + if (remainingMs <= 0) { + throw new IncompleteCaptureException(timeoutMs); + } + try { + sleeper.sleep(Math.min(RETRY_INTERVAL_MS, remainingMs)); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw error; + } + capture = attempt.capture(); + } + return capture; + } +} diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java new file mode 100644 index 000000000..9f8898b38 --- /dev/null +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeCapture.java @@ -0,0 +1,225 @@ +package com.callstack.agentdevice.snapshothelper; + +import android.accessibilityservice.AccessibilityServiceInfo; +import android.app.UiAutomation; +import android.os.Build; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityWindowInfo; +import java.util.List; + +/** Captures and serializes the Android accessibility window tree. */ +final class AccessibilityTreeCapture { + private AccessibilityTreeCapture() {} + + static Result capture( + UiAutomation automation, int maxDepth, int maxNodes, long stabilizationTimeoutMs) + throws InterruptedException, AccessibilityCaptureStabilizer.IncompleteCaptureException { + clearAccessibilityCache(automation); + return AccessibilityCaptureStabilizer.capture( + () -> captureOnce(automation, maxDepth, maxNodes), stabilizationTimeoutMs); + } + + @SuppressWarnings("deprecation") + private static Result captureOnce(UiAutomation automation, int maxDepth, int maxNodes) { + AccessibilityTreeXml.Stats stats = new AccessibilityTreeXml.Stats(); + StringBuilder xml = new StringBuilder(); + xml.append(""); + xml.append(""); + int windowCount = appendInteractiveWindowRoots(xml, automation, maxDepth, maxNodes, stats); + String captureMode = "interactive-windows"; + if (AccessibilityCaptureStabilizer.requiresActiveWindowFallback( + windowCount, stats.activeWindowRootMissing)) { + boolean hasInteractiveWindowRoot = windowCount > 0; + AccessibilityNodeInfo root = automation.getRootInActiveWindow(); + try { + AccessibilityTreeXml.WindowMetadata fallbackMetadata = + stats.activeWindowMetadata == null + ? null + : stats.activeWindowMetadata.withIndex(windowCount); + if (root != null + && AccessibilityCaptureStabilizer.canAppendActiveWindowFallback( + windowCount, fallbackMetadata != null)) { + AccessibilityTreeXml.appendNode( + xml, root, windowCount, 0, maxDepth, maxNodes, stats, fallbackMetadata); + windowCount += 1; + stats.activeWindowRootMissing = false; + } + if (!hasInteractiveWindowRoot) { + captureMode = "active-window"; + } + } finally { + if (root != null) { + root.recycle(); + } + } + } + xml.append(""); + return new Result( + xml.toString(), + windowCount > 0, + !stats.activeWindowRootMissing && !stats.focusedNonActiveWindowRootMissing, + captureMode, + windowCount, + stats.nodeCount, + stats.truncated); + } + + private static void clearAccessibilityCache(UiAutomation automation) { + // Navigation 3 and any in-place content swap that keeps the same window/Activity render every + // destination inside one AndroidComposeView. A persistent helper session reuses a single + // UiAutomation connection across captures, and its per-connection accessibility node cache is + // not always invalidated by such a swap. Drop the cache before each traversal. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + try { + automation.clearCache(); + return; + } catch (RuntimeException ignored) { + // Fall back to the setServiceInfo() flush below if the platform rejects the public API. + } + } + clearAccessibilityCacheViaServiceInfo(automation); + } + + private static void clearAccessibilityCacheViaServiceInfo(UiAutomation automation) { + // Before API 34, re-applying service info is the supported public-API cache reset. + try { + automation.setServiceInfo(automation.getServiceInfo()); + } catch (RuntimeException ignored) { + // Best-effort: proceed when this platform cannot reset the cache. + } + } + + static void enableInteractiveWindowRetrieval(UiAutomation automation) { + AccessibilityServiceInfo serviceInfo; + try { + serviceInfo = automation.getServiceInfo(); + } catch (RuntimeException error) { + return; + } + if (serviceInfo == null + || (serviceInfo.flags & AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS) != 0) { + return; + } + serviceInfo.flags |= AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS; + try { + automation.setServiceInfo(serviceInfo); + } catch (RuntimeException ignored) { + // Fall back to active-window capture if the platform rejects dynamic service flags. + } + } + + @SuppressWarnings("deprecation") + private static int appendInteractiveWindowRoots( + StringBuilder xml, + UiAutomation automation, + int maxDepth, + int maxNodes, + AccessibilityTreeXml.Stats stats) { + List windows; + try { + windows = automation.getWindows(); + } catch (RuntimeException error) { + return 0; + } + int windowCount = 0; + for (int index = 0; index < windows.size(); index += 1) { + if (stats.nodeCount >= maxNodes) { + stats.truncated = true; + break; + } + AccessibilityWindowInfo window = windows.get(index); + AccessibilityNodeInfo root = null; + boolean activeWindow = isActiveWindow(window); + boolean focusedNonActiveWindow = !activeWindow && isFocusedWindow(window); + AccessibilityTreeXml.WindowMetadata windowMetadata = null; + try { + windowMetadata = AccessibilityTreeXml.readWindowMetadata(window, windowCount); + root = window.getRoot(); + if (root == null) { + stats.activeWindowRootMissing |= activeWindow; + stats.focusedNonActiveWindowRootMissing |= focusedNonActiveWindow; + if (activeWindow) { + stats.activeWindowMetadata = windowMetadata; + } + continue; + } + StringBuilder windowXml = new StringBuilder(); + AccessibilityTreeXml.Stats windowStats = stats.copy(); + AccessibilityTreeXml.appendNode( + windowXml, + root, + windowCount, + 0, + maxDepth, + maxNodes, + windowStats, + windowMetadata); + xml.append(windowXml); + stats.copyFrom(windowStats); + windowCount += 1; + } catch (RuntimeException ignored) { + // Accessibility windows can disappear while traversing; keep the rest of the snapshot. + stats.activeWindowRootMissing |= activeWindow; + stats.focusedNonActiveWindowRootMissing |= focusedNonActiveWindow; + if (activeWindow && windowMetadata != null) { + stats.activeWindowMetadata = windowMetadata; + } + } finally { + if (root != null) { + root.recycle(); + } + window.recycle(); + } + } + return windowCount; + } + + private static boolean isActiveWindow(AccessibilityWindowInfo window) { + try { + return window.isActive(); + } catch (RuntimeException ignored) { + return false; + } + } + + private static boolean isFocusedWindow(AccessibilityWindowInfo window) { + try { + return window.isFocused(); + } catch (RuntimeException ignored) { + return false; + } + } + + static final class Result implements AccessibilityCaptureStabilizer.Capture { + final String xml; + final boolean rootPresent; + final boolean foregroundWindowRootsPresent; + final String captureMode; + final int windowCount; + final int nodeCount; + final boolean truncated; + + Result( + String xml, + boolean rootPresent, + boolean foregroundWindowRootsPresent, + String captureMode, + int windowCount, + int nodeCount, + boolean truncated) { + this.xml = xml; + this.rootPresent = rootPresent; + this.foregroundWindowRootsPresent = foregroundWindowRootsPresent; + this.captureMode = captureMode; + this.windowCount = windowCount; + this.nodeCount = nodeCount; + this.truncated = truncated; + } + + @Override + public boolean isComplete() { + return rootPresent && foregroundWindowRootsPresent; + } + } + +} diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java new file mode 100644 index 000000000..23cd09ddf --- /dev/null +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java @@ -0,0 +1,245 @@ +package com.callstack.agentdevice.snapshothelper; + +import android.graphics.Rect; +import android.os.Build; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; +import android.view.accessibility.AccessibilityWindowInfo; +import java.util.List; +import java.util.Locale; + +/** Serializes accessibility nodes using the helper's host-consumed XML contract. */ +final class AccessibilityTreeXml { + private AccessibilityTreeXml() {} + + @SuppressWarnings("deprecation") + static void appendNode( + StringBuilder xml, + AccessibilityNodeInfo node, + int nodeIndex, + int depth, + int maxDepth, + int maxNodes, + Stats stats, + WindowMetadata windowMetadata) { + if (stats.nodeCount >= maxNodes) { + stats.truncated = true; + return; + } + stats.nodeCount += 1; + Rect bounds = new Rect(); + node.getBoundsInScreen(bounds); + xml.append("= maxDepth ? 0 : node.getChildCount(); + if (depth >= maxDepth && node.getChildCount() > 0) { + stats.truncated = true; + } + if (childCount <= 0) { + xml.append(" />"); + return; + } + + xml.append(">"); + for (int index = 0; index < childCount; index += 1) { + if (stats.nodeCount >= maxNodes) { + stats.truncated = true; + break; + } + AccessibilityNodeInfo child = node.getChild(index); + if (child == null) { + continue; + } + try { + appendNode(xml, child, index, depth + 1, maxDepth, maxNodes, stats, null); + } finally { + child.recycle(); + } + } + xml.append(""); + } + + @SuppressWarnings("deprecation") + static WindowMetadata readWindowMetadata(AccessibilityWindowInfo window, int index) { + Rect bounds = new Rect(); + window.getBoundsInScreen(bounds); + return new WindowMetadata( + index, window.getType(), window.getLayer(), window.isActive(), window.isFocused(), bounds); + } + + private static void appendNonEmptyAttribute( + StringBuilder xml, String name, CharSequence value) { + if (value == null || value.length() == 0) { + return; + } + appendAttribute(xml, name, value); + } + + private static void appendTrueAttribute(StringBuilder xml, String name, boolean value) { + if (value) { + appendAttribute(xml, name, "true"); + } + } + + private static void appendDrawingOrderAttribute(StringBuilder xml, AccessibilityNodeInfo node) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + appendAttribute(xml, "drawing-order", Integer.toString(node.getDrawingOrder())); + } + } + + private static void appendWindowMetadata(StringBuilder xml, WindowMetadata metadata) { + appendAttribute(xml, "window-index", Integer.toString(metadata.index)); + appendAttribute(xml, "window-type", Integer.toString(metadata.type)); + appendAttribute(xml, "window-layer", Integer.toString(metadata.layer)); + appendAttribute(xml, "window-active", Boolean.toString(metadata.active)); + appendAttribute(xml, "window-focused", Boolean.toString(metadata.focused)); + appendAttribute( + xml, + "window-bounds", + String.format( + Locale.ROOT, + "[%d,%d][%d,%d]", + metadata.bounds.left, + metadata.bounds.top, + metadata.bounds.right, + metadata.bounds.bottom)); + } + + private static void appendAttribute(StringBuilder xml, String name, CharSequence value) { + String stringValue = value == null ? "" : value.toString(); + xml.append(' '); + xml.append(name); + xml.append("=\""); + appendEscaped(xml, stringValue); + xml.append('"'); + } + + private static boolean hasAccessibilityAction( + AccessibilityNodeInfo node, AccessibilityAction action) { + List actions = node.getActionList(); + return actions != null && actions.contains(action); + } + + private static void appendEscaped(StringBuilder xml, String value) { + for (int index = 0; index < value.length(); index += 1) { + char character = value.charAt(index); + switch (character) { + case '&': + xml.append("&"); + break; + case '<': + xml.append("<"); + break; + case '>': + xml.append(">"); + break; + case '"': + xml.append("""); + break; + case '\'': + xml.append("'"); + break; + case '\n': + xml.append(" "); + break; + case '\r': + xml.append(" "); + break; + case '\t': + xml.append(" "); + break; + default: + xml.append(character); + break; + } + } + } + + static final class Stats { + int nodeCount; + boolean truncated; + boolean activeWindowRootMissing; + boolean focusedNonActiveWindowRootMissing; + WindowMetadata activeWindowMetadata; + + Stats copy() { + Stats next = new Stats(); + next.nodeCount = nodeCount; + next.truncated = truncated; + next.activeWindowRootMissing = activeWindowRootMissing; + next.focusedNonActiveWindowRootMissing = focusedNonActiveWindowRootMissing; + next.activeWindowMetadata = activeWindowMetadata; + return next; + } + + void copyFrom(Stats next) { + nodeCount = next.nodeCount; + truncated = next.truncated; + activeWindowRootMissing = next.activeWindowRootMissing; + focusedNonActiveWindowRootMissing = next.focusedNonActiveWindowRootMissing; + activeWindowMetadata = next.activeWindowMetadata; + } + } + + static final class WindowMetadata { + final int index; + final int type; + final int layer; + final boolean active; + final boolean focused; + final Rect bounds; + + WindowMetadata(int index, int type, int layer, boolean active, boolean focused, Rect bounds) { + this.index = index; + this.type = type; + this.layer = layer; + this.active = active; + this.focused = focused; + this.bounds = bounds; + } + + WindowMetadata withIndex(int nextIndex) { + return new WindowMetadata(nextIndex, type, layer, active, focused, bounds); + } + } +} diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/BoundedUiAutomationConnection.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/BoundedUiAutomationConnection.java new file mode 100644 index 000000000..712f0713d --- /dev/null +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/BoundedUiAutomationConnection.java @@ -0,0 +1,64 @@ +package com.callstack.agentdevice.snapshothelper; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Bounds Android's UiAutomation connection independently of the framework call. + * + *

For modern target SDKs, Instrumentation.getUiAutomation() can apply its own 60-second + * connection timeout. Running the attempt on a daemon worker keeps the helper's shorter command + * deadline authoritative even when the framework call does not respond to interruption. + * Cancellation bounds the caller; it cannot stop a non-cooperative framework call. After a + * timeout, the session owner must retire the instrumentation process before starting another + * attempt. Both the persistent-session failure path and one-shot Instrumentation.finish() do so. + */ +final class BoundedUiAutomationConnection { + private static final long RETRY_INTERVAL_MS = 50; + + interface Attempt { + T connect(); + } + + private BoundedUiAutomationConnection() {} + + static T await(Attempt attempt, long timeoutMs) + throws InterruptedException, TimeoutException { + FutureTask task = new FutureTask<>(() -> connectWhenReady(attempt)); + Thread worker = new Thread(task, "agent-device-ui-automation-connect"); + worker.setDaemon(true); + worker.start(); + + try { + return task.get(Math.max(1, timeoutMs), TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.TimeoutException error) { + task.cancel(true); + throw new TimeoutException("Timed out waiting for Android UiAutomation to connect"); + } catch (InterruptedException error) { + task.cancel(true); + Thread.currentThread().interrupt(); + throw error; + } catch (ExecutionException error) { + Throwable cause = error.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw new IllegalStateException("Android UiAutomation connection failed", cause); + } + } + + private static T connectWhenReady(Attempt attempt) throws InterruptedException { + while (true) { + T connection = attempt.connect(); + if (connection != null) { + return connection; + } + Thread.sleep(RETRY_INTERVAL_MS); + } + } +} diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java index b322a2c57..9abf3bdb8 100644 --- a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/SnapshotInstrumentation.java @@ -1,15 +1,9 @@ package com.callstack.agentdevice.snapshothelper; -import android.accessibilityservice.AccessibilityServiceInfo; import android.app.Instrumentation; import android.app.UiAutomation; -import android.graphics.Rect; -import android.os.Build; import android.os.Bundle; import android.util.Base64; -import android.view.accessibility.AccessibilityNodeInfo; -import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; -import android.view.accessibility.AccessibilityWindowInfo; import java.io.BufferedReader; import java.io.File; import java.io.FileOutputStream; @@ -21,7 +15,6 @@ import java.net.Socket; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; -import java.util.List; import java.util.Locale; import java.util.concurrent.TimeoutException; @@ -33,6 +26,7 @@ public final class SnapshotInstrumentation extends Instrumentation { private static final long DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 500; private static final long DEFAULT_WAIT_FOR_IDLE_QUIET_MS = 100; private static final long DEFAULT_TIMEOUT_MS = 8_000; + private static final long ROOT_CAPTURE_STABILIZATION_TIMEOUT_MS = 500; private static final int DEFAULT_MAX_DEPTH = 128; private static final int DEFAULT_MAX_NODES = 5_000; private Bundle arguments; @@ -82,7 +76,7 @@ public void onStart() { return; } long startedAtMs = System.currentTimeMillis(); - CaptureResult capture = + AccessibilityTreeCapture.Result capture = captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes); writeOutputFile(outputPath, capture.xml); if (emitChunks) { @@ -118,7 +112,8 @@ private static void putBaseMetadata( result.putString("maxNodes", Integer.toString(maxNodes)); } - private static void putCaptureMetadata(Bundle result, CaptureResult capture, long elapsedMs) { + private static void putCaptureMetadata( + Bundle result, AccessibilityTreeCapture.Result capture, long elapsedMs) { result.putString("rootPresent", Boolean.toString(capture.rootPresent)); result.putString("captureMode", capture.captureMode); result.putString("windowCount", Integer.toString(capture.windowCount)); @@ -140,6 +135,9 @@ private void runOneShotGesture(Bundle result, String payloadBase64) throws Excep private UiAutomation getConnectedUiAutomationUnchecked() { try { return getConnectedUiAutomation(GESTURE_UI_AUTOMATION_CONNECT_TIMEOUT_MS); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while connecting Android UiAutomation", error); } catch (TimeoutException error) { throw new IllegalStateException(error.getMessage(), error); } @@ -228,7 +226,7 @@ private void writeSessionSnapshot( result.putString("requestId", requestId); try { long startedAtMs = System.currentTimeMillis(); - CaptureResult capture = + AccessibilityTreeCapture.Result capture = captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes); result.putString("ok", "true"); putCaptureMetadata(result, capture, System.currentTimeMillis() - startedAtMs); @@ -321,15 +319,17 @@ private static void sleep(long millis) { } @SuppressWarnings("deprecation") - private CaptureResult captureXml( + private AccessibilityTreeCapture.Result captureXml( long waitForIdleQuietMs, long waitForIdleTimeoutMs, long timeoutMs, int maxDepth, int maxNodes) - throws TimeoutException { + throws TimeoutException, + InterruptedException, + AccessibilityCaptureStabilizer.IncompleteCaptureException { UiAutomation automation = getConnectedUiAutomation(timeoutMs); - enableInteractiveWindowRetrieval(automation); + AccessibilityTreeCapture.enableInteractiveWindowRetrieval(automation); if (waitForIdleTimeoutMs > 0) { try { // Best-effort settle: wait for the accessibility stream to become idle, but require only @@ -341,161 +341,29 @@ private CaptureResult captureXml( // Busy or animated apps can still expose a usable root; capture whatever is available. } } - clearAccessibilityCache(automation); - - CaptureStats stats = new CaptureStats(); - StringBuilder xml = new StringBuilder(); - xml.append(""); - xml.append(""); - int windowCount = appendInteractiveWindowRoots(xml, automation, maxDepth, maxNodes, stats); - String captureMode = "interactive-windows"; - if (windowCount == 0) { - AccessibilityNodeInfo root = automation.getRootInActiveWindow(); - try { - if (root != null) { - appendNode(xml, root, 0, 0, maxDepth, maxNodes, stats, null); - windowCount = 1; - } - captureMode = "active-window"; - } finally { - if (root != null) { - root.recycle(); - } - } - } - xml.append(""); - return new CaptureResult( - xml.toString(), windowCount > 0, captureMode, windowCount, stats.nodeCount, stats.truncated); - } - - private static void clearAccessibilityCache(UiAutomation automation) { - // Navigation 3 and any in-place content swap that keeps the same window/Activity render every - // destination inside one AndroidComposeView. A persistent helper session reuses a single - // UiAutomation connection across captures, and its per-connection accessibility node cache is - // not always invalidated by such a swap, so getWindows()/getRootInActiveWindow() can keep - // returning the previous screen. Drop the cache before each traversal so every capture re-reads - // the live tree, the way a fresh `uiautomator dump` (new connection, empty cache) does. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - try { - automation.clearCache(); - return; - } catch (RuntimeException ignored) { - // Fall back to the setServiceInfo() flush below if the platform rejects the public API. - } - } - clearAccessibilityCacheViaServiceInfo(automation); + return AccessibilityTreeCapture.capture( + automation, maxDepth, maxNodes, ROOT_CAPTURE_STABILIZATION_TIMEOUT_MS); } - private static void clearAccessibilityCacheViaServiceInfo(UiAutomation automation) { - // Platforms before API 34 lack UiAutomation.clearCache(). Re-applying the current service info - // is the supported public-API way to drop the cache there: UiAutomation.setServiceInfo() calls - // AccessibilityInteractionClient.clearCache() before forwarding the config (verified in AOSP - // from API 24 through 33). Reflecting the internal clearCache() directly is not an option — it - // is a blocklisted non-SDK member, so hidden-API enforcement makes it unreachable from the - // helper process. Best-effort: if the flush fails the capture proceeds with the current tree. - try { - automation.setServiceInfo(automation.getServiceInfo()); - } catch (RuntimeException ignored) { - // No reliable cache reset on this platform; leave capture behavior unchanged. - } + private UiAutomation getConnectedUiAutomation(long timeoutMs) + throws InterruptedException, TimeoutException { + return BoundedUiAutomationConnection.await(this::tryGetConnectedUiAutomation, timeoutMs); } - private UiAutomation getConnectedUiAutomation(long timeoutMs) throws TimeoutException { - long deadlineMs = System.currentTimeMillis() + Math.max(1, timeoutMs); + private UiAutomation tryGetConnectedUiAutomation() { UiAutomation automation = getUiAutomation(); - RuntimeException lastError = null; - while (System.currentTimeMillis() <= deadlineMs) { - try { - automation.getServiceInfo(); - return automation; - } catch (IllegalStateException error) { - if (!isUiAutomationConnectingError(error) && !isUiAutomationNotConnectedError(error)) { - throw error; - } - lastError = error; - } - sleep(50); - } - TimeoutException timeout = - new TimeoutException("Timed out waiting for Android UiAutomation to connect"); - if (lastError != null) { - timeout.initCause(lastError); - } - throw timeout; - } - - private static void enableInteractiveWindowRetrieval(UiAutomation automation) { - AccessibilityServiceInfo serviceInfo; - try { - serviceInfo = automation.getServiceInfo(); - } catch (RuntimeException error) { - return; - } - if (serviceInfo == null) { - return; - } - if ((serviceInfo.flags & AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS) != 0) { - return; + if (automation == null) { + return null; } - serviceInfo.flags |= AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS; try { - automation.setServiceInfo(serviceInfo); - } catch (RuntimeException ignored) { - // Fall back to active-window capture if the platform rejects dynamic service flags. - } - } - - @SuppressWarnings("deprecation") - private static int appendInteractiveWindowRoots( - StringBuilder xml, - UiAutomation automation, - int maxDepth, - int maxNodes, - CaptureStats stats) { - List windows; - try { - windows = automation.getWindows(); - } catch (RuntimeException error) { - return 0; - } - int windowCount = 0; - for (int index = 0; index < windows.size(); index += 1) { - if (stats.nodeCount >= maxNodes) { - stats.truncated = true; - break; - } - AccessibilityWindowInfo window = windows.get(index); - AccessibilityNodeInfo root = null; - try { - root = window.getRoot(); - if (root == null) { - continue; - } - StringBuilder windowXml = new StringBuilder(); - CaptureStats windowStats = stats.copy(); - appendNode( - windowXml, - root, - windowCount, - 0, - maxDepth, - maxNodes, - windowStats, - readWindowMetadata(window, windowCount)); - xml.append(windowXml); - stats.copyFrom(windowStats); - windowCount += 1; - } catch (RuntimeException ignored) { - // Accessibility windows can disappear while traversing; keep the rest of the snapshot. - } finally { - if (root != null) { - root.recycle(); - } - // UiAutomation.getWindows() transfers recyclable AccessibilityWindowInfo instances. - window.recycle(); + automation.getServiceInfo(); + return automation; + } catch (IllegalStateException error) { + if (isUiAutomationConnectingError(error) || isUiAutomationNotConnectedError(error)) { + return null; } + throw error; } - return windowCount; } private void emitChunks(String payload) { @@ -516,191 +384,6 @@ private void emitChunks(String payload) { } } - @SuppressWarnings("deprecation") - private static void appendNode( - StringBuilder xml, - AccessibilityNodeInfo node, - int nodeIndex, - int depth, - int maxDepth, - int maxNodes, - CaptureStats stats, - WindowMetadata windowMetadata) { - if (stats.nodeCount >= maxNodes) { - stats.truncated = true; - return; - } - stats.nodeCount += 1; - Rect bounds = new Rect(); - node.getBoundsInScreen(bounds); - xml.append("= maxDepth ? 0 : node.getChildCount(); - if (depth >= maxDepth && node.getChildCount() > 0) { - stats.truncated = true; - } - if (childCount <= 0) { - xml.append(" />"); - return; - } - - xml.append(">"); - for (int index = 0; index < childCount; index += 1) { - if (stats.nodeCount >= maxNodes) { - stats.truncated = true; - break; - } - AccessibilityNodeInfo child = node.getChild(index); - if (child == null) { - continue; - } - try { - appendNode(xml, child, index, depth + 1, maxDepth, maxNodes, stats, null); - } finally { - child.recycle(); - } - } - xml.append(""); - } - - private static void appendNonEmptyAttribute(StringBuilder xml, String name, CharSequence value) { - if (value == null || value.length() == 0) { - return; - } - appendAttribute(xml, name, value); - } - - private static void appendTrueAttribute(StringBuilder xml, String name, boolean value) { - if (value) { - appendAttribute(xml, name, "true"); - } - } - - private static void appendDrawingOrderAttribute(StringBuilder xml, AccessibilityNodeInfo node) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - appendAttribute(xml, "drawing-order", Integer.toString(node.getDrawingOrder())); - } - } - - private static void appendWindowMetadata(StringBuilder xml, WindowMetadata metadata) { - appendAttribute(xml, "window-index", Integer.toString(metadata.index)); - appendAttribute(xml, "window-type", Integer.toString(metadata.type)); - appendAttribute(xml, "window-layer", Integer.toString(metadata.layer)); - appendAttribute(xml, "window-active", Boolean.toString(metadata.active)); - appendAttribute(xml, "window-focused", Boolean.toString(metadata.focused)); - appendAttribute( - xml, - "window-bounds", - String.format( - Locale.ROOT, - "[%d,%d][%d,%d]", - metadata.bounds.left, - metadata.bounds.top, - metadata.bounds.right, - metadata.bounds.bottom)); - } - - @SuppressWarnings("deprecation") - private static WindowMetadata readWindowMetadata(AccessibilityWindowInfo window, int index) { - Rect bounds = new Rect(); - window.getBoundsInScreen(bounds); - return new WindowMetadata( - index, window.getType(), window.getLayer(), window.isActive(), window.isFocused(), bounds); - } - - private static void appendAttribute(StringBuilder xml, String name, CharSequence value) { - String stringValue = value == null ? "" : value.toString(); - xml.append(' '); - xml.append(name); - xml.append("=\""); - appendEscaped(xml, stringValue); - xml.append('"'); - } - - private static boolean hasAccessibilityAction( - AccessibilityNodeInfo node, AccessibilityAction action) { - List actions = node.getActionList(); - return actions != null && actions.contains(action); - } - - private static void appendEscaped(StringBuilder xml, String value) { - for (int index = 0; index < value.length(); index += 1) { - char character = value.charAt(index); - switch (character) { - case '&': - xml.append("&"); - break; - case '<': - xml.append("<"); - break; - case '>': - xml.append(">"); - break; - case '"': - xml.append("""); - break; - case '\'': - xml.append("'"); - break; - case '\n': - xml.append(" "); - break; - case '\r': - xml.append(" "); - break; - case '\t': - xml.append(" "); - break; - default: - xml.append(character); - break; - } - } - } - private static long readLongArgument(Bundle arguments, String name, long fallback) { if (arguments == null) { return fallback; @@ -741,63 +424,4 @@ private static boolean readBooleanArgument(Bundle arguments, String name, boolea } return Boolean.parseBoolean(raw.trim()); } - - private static final class CaptureStats { - int nodeCount; - boolean truncated; - - CaptureStats copy() { - CaptureStats next = new CaptureStats(); - next.nodeCount = nodeCount; - next.truncated = truncated; - return next; - } - - void copyFrom(CaptureStats next) { - nodeCount = next.nodeCount; - truncated = next.truncated; - } - } - - private static final class CaptureResult { - final String xml; - final boolean rootPresent; - final String captureMode; - final int windowCount; - final int nodeCount; - final boolean truncated; - - CaptureResult( - String xml, - boolean rootPresent, - String captureMode, - int windowCount, - int nodeCount, - boolean truncated) { - this.xml = xml; - this.rootPresent = rootPresent; - this.captureMode = captureMode; - this.windowCount = windowCount; - this.nodeCount = nodeCount; - this.truncated = truncated; - } - } - - private static final class WindowMetadata { - final int index; - final int type; - final int layer; - final boolean active; - final boolean focused; - final Rect bounds; - - WindowMetadata(int index, int type, int layer, boolean active, boolean focused, Rect bounds) { - this.index = index; - this.type = type; - this.layer = layer; - this.active = active; - this.focused = focused; - this.bounds = bounds; - } - } } diff --git a/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/AccessibilityCaptureStabilizerTest.java b/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/AccessibilityCaptureStabilizerTest.java new file mode 100644 index 000000000..e6dc17b2f --- /dev/null +++ b/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/AccessibilityCaptureStabilizerTest.java @@ -0,0 +1,147 @@ +package com.callstack.agentdevice.snapshothelper; + +public final class AccessibilityCaptureStabilizerTest { + private AccessibilityCaptureStabilizerTest() {} + + static void run() throws Exception { + recoversFromTransientRootGap(); + rejectsIncompleteCaptureAtTheDeadline(); + rejectsIncompleteCaptureWithoutRetry(); + propagatesInterruption(); + supplementsCapturedWindowsWhenTheActiveRootIsMissing(); + } + + private static void recoversFromTransientRootGap() throws Exception { + int[] attempts = {0}; + FakeTime time = new FakeTime(); + FakeCapture capture = + AccessibilityCaptureStabilizer.capture( + () -> { + attempts[0] += 1; + return new FakeCapture(attempts[0] >= 2); + }, + 500, + time, + time); + + if (!capture.isComplete()) { + throw new AssertionError("Expected capture to recover when a root becomes available"); + } + if (attempts[0] != 2) { + throw new AssertionError("Expected 2 capture attempts, got " + attempts[0]); + } + } + + private static void rejectsIncompleteCaptureAtTheDeadline() throws InterruptedException { + int[] attempts = {0}; + FakeTime time = new FakeTime(); + try { + AccessibilityCaptureStabilizer.capture( + () -> { + attempts[0] += 1; + return new FakeCapture(false); + }, + 100, + time, + time); + throw new AssertionError("Expected incomplete capture to fail at the deadline"); + } catch (AccessibilityCaptureStabilizer.IncompleteCaptureException expected) { + if (!expected.getMessage().contains("100 ms")) { + throw new AssertionError("Expected the failure to identify the 100 ms budget"); + } + } + if (attempts[0] != 3) { + throw new AssertionError("Expected 3 bounded capture attempts, got " + attempts[0]); + } + if (time.nowMs() != 100) { + throw new AssertionError("Expected the capture deadline at 100 ms, got " + time.nowMs()); + } + } + + private static void rejectsIncompleteCaptureWithoutRetry() throws InterruptedException { + int[] attempts = {0}; + FakeTime time = new FakeTime(); + try { + AccessibilityCaptureStabilizer.capture( + () -> { + attempts[0] += 1; + return new FakeCapture(false); + }, + 0, + time, + time); + throw new AssertionError("Expected immediate incomplete capture to fail"); + } catch (AccessibilityCaptureStabilizer.IncompleteCaptureException expected) { + // A zero budget disables retries, not the completeness requirement. + } + + if (attempts[0] != 1) { + throw new AssertionError("Expected immediate capture mode to make one attempt"); + } + } + + private static void propagatesInterruption() { + Thread.interrupted(); + try { + AccessibilityCaptureStabilizer.capture( + () -> new FakeCapture(false), + 500, + () -> 0, + durationMs -> { + throw new InterruptedException("capture canceled"); + }); + throw new AssertionError("Expected capture interruption to propagate"); + } catch (InterruptedException expected) { + if (!Thread.currentThread().isInterrupted()) { + throw new AssertionError("Expected capture interruption to restore the thread flag"); + } + } catch (AccessibilityCaptureStabilizer.IncompleteCaptureException error) { + throw new AssertionError("Interruption should win over the capture deadline", error); + } finally { + Thread.interrupted(); + } + } + + private static void supplementsCapturedWindowsWhenTheActiveRootIsMissing() { + if (!AccessibilityCaptureStabilizer.requiresActiveWindowFallback(1, true)) { + throw new AssertionError("Expected an unresolved active root to use the active-window fallback"); + } + if (AccessibilityCaptureStabilizer.requiresActiveWindowFallback(1, false)) { + throw new AssertionError("Expected a complete interactive-window capture to skip fallback"); + } + if (AccessibilityCaptureStabilizer.canAppendActiveWindowFallback(1, false)) { + throw new AssertionError("Expected a mixed fallback without window metadata to be rejected"); + } + if (!AccessibilityCaptureStabilizer.canAppendActiveWindowFallback(1, true)) { + throw new AssertionError("Expected a metadata-backed mixed fallback to be accepted"); + } + } + + private static final class FakeCapture implements AccessibilityCaptureStabilizer.Capture { + private final boolean complete; + + FakeCapture(boolean complete) { + this.complete = complete; + } + + @Override + public boolean isComplete() { + return complete; + } + } + + private static final class FakeTime + implements AccessibilityCaptureStabilizer.Clock, AccessibilityCaptureStabilizer.Sleeper { + private long nowMs; + + @Override + public long nowMs() { + return nowMs; + } + + @Override + public void sleep(long durationMs) { + nowMs += durationMs; + } + } +} diff --git a/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/BoundedUiAutomationConnectionTest.java b/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/BoundedUiAutomationConnectionTest.java new file mode 100644 index 000000000..0e8d9a742 --- /dev/null +++ b/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/BoundedUiAutomationConnectionTest.java @@ -0,0 +1,119 @@ +package com.callstack.agentdevice.snapshothelper; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public final class BoundedUiAutomationConnectionTest { + private BoundedUiAutomationConnectionTest() {} + + static void run() throws Exception { + returnsAReadyConnection(); + retriesWhileTheConnectionIsUnavailable(); + boundsABlockingPlatformCall(); + propagatesConnectionFailures(); + propagatesInterruption(); + } + + private static void returnsAReadyConnection() throws Exception { + Object expected = new Object(); + Object actual = BoundedUiAutomationConnection.await(() -> expected, 1_000); + if (actual != expected) { + throw new AssertionError("Expected the ready connection"); + } + } + + private static void retriesWhileTheConnectionIsUnavailable() throws Exception { + int[] attempts = {0}; + Object connection = + BoundedUiAutomationConnection.await( + () -> { + attempts[0] += 1; + return attempts[0] == 1 ? null : "connected"; + }, + 1_000); + + if (!"connected".equals(connection)) { + throw new AssertionError("Expected the connection returned by the retry"); + } + if (attempts[0] != 2) { + throw new AssertionError("Expected 2 connection attempts, got " + attempts[0]); + } + } + + private static void boundsABlockingPlatformCall() throws Exception { + CountDownLatch release = new CountDownLatch(1); + Thread eventualRelease = + new Thread( + () -> { + try { + Thread.sleep(2_000); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } + release.countDown(); + }, + "bounded-ui-automation-test-release"); + eventualRelease.setDaemon(true); + eventualRelease.start(); + + long startedAtNanos = System.nanoTime(); + try { + BoundedUiAutomationConnection.await( + () -> { + boolean interrupted = false; + while (true) { + try { + release.await(); + if (interrupted) { + Thread.currentThread().interrupt(); + } + return "connected"; + } catch (InterruptedException error) { + // Model the framework connection call, which may outlive cancellation. + interrupted = true; + } + } + }, + 25); + throw new AssertionError("Expected a blocking connection attempt to time out"); + } catch (TimeoutException expected) { + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos); + if (elapsedMs > 1_000) { + throw new AssertionError("Expected the outer timeout to bound the platform call"); + } + } finally { + release.countDown(); + } + } + + private static void propagatesConnectionFailures() throws Exception { + IllegalStateException expected = new IllegalStateException("connection rejected"); + try { + BoundedUiAutomationConnection.await( + () -> { + throw expected; + }, + 1_000); + throw new AssertionError("Expected the connection failure"); + } catch (IllegalStateException actual) { + if (actual != expected) { + throw new AssertionError("Expected the original connection failure", actual); + } + } + } + + private static void propagatesInterruption() throws TimeoutException { + Thread.currentThread().interrupt(); + try { + BoundedUiAutomationConnection.await(() -> null, 1_000); + throw new AssertionError("Expected connection interruption to propagate"); + } catch (InterruptedException expected) { + if (!Thread.currentThread().isInterrupted()) { + throw new AssertionError("Expected interruption to restore the thread flag"); + } + } finally { + Thread.interrupted(); + } + } +} diff --git a/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/PointerEventScheduleTest.java b/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/PointerEventScheduleTest.java index 8d274add3..6f8998eab 100644 --- a/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/PointerEventScheduleTest.java +++ b/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/PointerEventScheduleTest.java @@ -5,7 +5,7 @@ public final class PointerEventScheduleTest { private PointerEventScheduleTest() {} - public static void main(String[] args) { + static void run() { assertSteps( PointerEventSchedule.create(1, new long[] {0, 16, 32}), "DOWN:0:1:0:true", diff --git a/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/SnapshotHelperTestSuite.java b/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/SnapshotHelperTestSuite.java new file mode 100644 index 000000000..22453fc33 --- /dev/null +++ b/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/SnapshotHelperTestSuite.java @@ -0,0 +1,11 @@ +package com.callstack.agentdevice.snapshothelper; + +public final class SnapshotHelperTestSuite { + private SnapshotHelperTestSuite() {} + + public static void main(String[] args) throws Exception { + PointerEventScheduleTest.run(); + AccessibilityCaptureStabilizerTest.run(); + BoundedUiAutomationConnectionTest.run(); + } +} diff --git a/examples/test-app/src/screens/AutomationLabScreen.tsx b/examples/test-app/src/screens/AutomationLabScreen.tsx index e95022926..e0a138c4e 100644 --- a/examples/test-app/src/screens/AutomationLabScreen.tsx +++ b/examples/test-app/src/screens/AutomationLabScreen.tsx @@ -103,6 +103,7 @@ export function AutomationLabScreen(props: { testID="automation-title" /> + - diff --git a/scripts/build-android-helper.sh b/scripts/build-android-helper.sh index 3da4fdd5b..98c7630a0 100755 --- a/scripts/build-android-helper.sh +++ b/scripts/build-android-helper.sh @@ -27,7 +27,7 @@ case "$HELPER" in snapshot) HELPER_DIR="$PROJECT_DIR/android/snapshot-helper" PACKAGE_NAME="com.callstack.agentdevice.snapshothelper" - RUN_TEST_CLASS="com.callstack.agentdevice.snapshothelper.PointerEventScheduleTest" + RUN_TEST_CLASS="com.callstack.agentdevice.snapshothelper.SnapshotHelperTestSuite" RESOURCE_DIR="" ;; ime) diff --git a/src/commands/interaction/runtime/__tests__/test-utils/index.ts b/src/commands/interaction/runtime/__tests__/test-utils/index.ts index c7f0fe479..fb7b77ac9 100644 --- a/src/commands/interaction/runtime/__tests__/test-utils/index.ts +++ b/src/commands/interaction/runtime/__tests__/test-utils/index.ts @@ -7,7 +7,7 @@ import { localCommandPolicy, type CommandSessionStore, } from '../../../../../runtime.ts'; -import { ref } from '../../selector-read.ts'; +import { ref } from '../../selector-read-utils.ts'; import { makeSnapshotState } from '../../../../../__tests__/test-utils/index.ts'; export function selectorSnapshot(): SnapshotState { diff --git a/src/commands/interaction/runtime/gestures.test.ts b/src/commands/interaction/runtime/gestures.test.ts index 47379141a..790cc4906 100644 --- a/src/commands/interaction/runtime/gestures.test.ts +++ b/src/commands/interaction/runtime/gestures.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { ref, selector } from './selector-read.ts'; +import { ref, selector } from './selector-read-utils.ts'; import { AppError } from '@agent-device/kernel/errors'; import { createInteractionDevice, diff --git a/src/commands/interaction/runtime/index.test.ts b/src/commands/interaction/runtime/index.test.ts index 1b4060fdb..229004b00 100644 --- a/src/commands/interaction/runtime/index.test.ts +++ b/src/commands/interaction/runtime/index.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { commands } from '../../index.ts'; -import { selector } from './selector-read.ts'; +import { selector } from './selector-read-utils.ts'; import { createInteractionDevice, selectorSnapshot } from './__tests__/test-utils/index.ts'; test('runtime interaction commands are available from the command namespace', async () => { diff --git a/src/commands/interaction/runtime/interactions.test.ts b/src/commands/interaction/runtime/interactions.test.ts index 651af4758..7e31f78cc 100644 --- a/src/commands/interaction/runtime/interactions.test.ts +++ b/src/commands/interaction/runtime/interactions.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { AgentDeviceBackend } from '../../../backend.ts'; -import { ref, selector } from './selector-read.ts'; +import { ref, selector } from './selector-read-utils.ts'; import { createLocalArtifactAdapter } from '../../../io.ts'; import { createAgentDevice, diff --git a/src/commands/interaction/runtime/resolution.test.ts b/src/commands/interaction/runtime/resolution.test.ts index b3a09482d..dd1cc6428 100644 --- a/src/commands/interaction/runtime/resolution.test.ts +++ b/src/commands/interaction/runtime/resolution.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { BackendSnapshotOptions } from '../../../backend.ts'; -import { ref, selector } from './selector-read.ts'; +import { ref, selector } from './selector-read-utils.ts'; import { resolveActionableTouchResolution } from '../../../core/interaction-targeting.ts'; import { tryResolveRefNode } from './resolution.ts'; import { parseSelectorChain, resolveSelectorChain } from '../../../selectors/index.ts'; diff --git a/src/commands/interaction/runtime/selector-read-shared.ts b/src/commands/interaction/runtime/selector-read-shared.ts index fd53f8960..7617ec2c8 100644 --- a/src/commands/interaction/runtime/selector-read-shared.ts +++ b/src/commands/interaction/runtime/selector-read-shared.ts @@ -81,6 +81,7 @@ export async function captureSelectorSnapshot( ...(result.quality ? { snapshotQuality: result.quality } : {}), createdAt: now(runtime), } satisfies SnapshotState); + (options.signal ?? runtime.signal)?.throwIfAborted(); if ( captureOptions.updateSession && session && diff --git a/src/commands/interaction/runtime/selector-read-utils.ts b/src/commands/interaction/runtime/selector-read-utils.ts index f2c3330c0..6c34ea30e 100644 --- a/src/commands/interaction/runtime/selector-read-utils.ts +++ b/src/commands/interaction/runtime/selector-read-utils.ts @@ -1,5 +1,42 @@ +import type { RefTarget, SelectorTarget } from '../../../contracts/interaction.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import type { SnapshotQualityVerdict } from '../../../snapshot/snapshot-quality.ts'; +import type { FindLocator } from '../../../selectors/find.ts'; +import type { SelectorChain } from '../../../selectors/parse.ts'; + export { findNodeByLabel, resolveRefLabel } from '../../../snapshot/snapshot-processing.ts'; -export function shouldScopeFind(locator: string): boolean { +function shouldScopeFind(locator: string): boolean { return locator === 'text' || locator === 'label' || locator === 'any'; } + +/** @internal Target helper used by tests/examples; runtime callers compose `ElementTarget` directly. */ +export function selector(expression: string): SelectorTarget { + return { kind: 'selector', selector: expression }; +} + +/** @internal Target helper used by tests/examples; runtime callers compose `ElementTarget` directly. */ +export function ref(refInput: string, options: { fallbackLabel?: string } = {}): RefTarget { + return { + kind: 'ref', + ref: refInput, + ...(options.fallbackLabel ? { fallbackLabel: options.fallbackLabel } : {}), + }; +} + +export function findSnapshotScope( + platform: string, + locator: FindLocator, + query: string, + selectorChain: SelectorChain | null, +): string | undefined { + if (selectorChain || platform === 'web') return undefined; + return shouldScopeFind(locator) ? query : undefined; +} + +export function sparseSelectorSnapshotError(verdict: SnapshotQualityVerdict): AppError { + return new AppError('COMMAND_FAILED', 'find could not read the current accessibility tree', { + reason: verdict.reason, + hint: 'The snapshot quality verdict is sparse. Use screenshot as visual truth, navigate with coordinates if needed, then retry find after reaching a readable screen.', + }); +} diff --git a/src/commands/interaction/runtime/selector-read.test.ts b/src/commands/interaction/runtime/selector-read.test.ts index 74fb39095..2abffbdab 100644 --- a/src/commands/interaction/runtime/selector-read.test.ts +++ b/src/commands/interaction/runtime/selector-read.test.ts @@ -8,7 +8,7 @@ import { localCommandPolicy, type CommandSessionStore, } from '../../../runtime.ts'; -import { ref, selector } from './selector-read.ts'; +import { ref, selector } from './selector-read-utils.ts'; import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; import { createFakeClock, @@ -248,41 +248,6 @@ test('runtime focused predicate reads focused Android TV nodes from the full tre assert.equal(captureOptions?.interactiveOnly, false); }); -test('runtime focused selector waits against a full snapshot', async () => { - const snapshot = makeSnapshotState([ - { - index: 0, - depth: 0, - type: 'Cell', - label: 'Profiles and Accounts', - focused: true, - }, - ]); - let captureOptions: BackendSnapshotOptions | undefined; - const device = createAgentDevice({ - backend: { - platform: 'ios', - captureSnapshot: async (_context, options) => { - captureOptions = options; - return { snapshot }; - }, - } satisfies AgentDeviceBackend, - artifacts: createLocalArtifactAdapter(), - sessions: createMemorySessionStore([{ name: 'default', snapshot }]), - policy: localCommandPolicy(), - }); - - const result = await device.selectors.wait({ - session: 'default', - target: { kind: 'selector', selector: 'focused=true', timeoutMs: 1000 }, - }); - - assert.equal(result.kind, 'selector'); - assert.equal(result.selector, 'focused=true'); - assert.equal(result.waitedMs >= 0, true); - assert.equal(captureOptions?.interactiveOnly, false); -}); - test('runtime is validates selector predicates', async () => { const device = createSelectorDevice(selectorReadSnapshot()); @@ -457,18 +422,57 @@ test('runtime find wait skips hidden-content hint derivation on every poll (#127 } }); -test('runtime wait can use backend text search', async () => { - const device = createSelectorDevice(selectorReadSnapshot(), { - findText: true, - now: 10, - }); - - const result = await device.selectors.wait({ - session: 'default', - target: { kind: 'text', text: 'Ready', timeoutMs: 100 }, +test('runtime find wait cancels and joins a capture that consumes its full deadline', async () => { + const initial = selectorReadSnapshot(); + const sessions = createMemorySessionStore([{ name: 'default', snapshot: initial }]); + let captureCount = 0; + const device = createAgentDevice({ + backend: { + platform: 'android', + captureSnapshot: async (context) => { + captureCount += 1; + return await new Promise((resolve) => { + context.signal?.addEventListener( + 'abort', + () => { + setTimeout( + () => + resolve({ + snapshot: makeSnapshotState([ + { index: 0, depth: 0, type: 'Other', label: 'Late screen' }, + ]), + }), + 5, + ); + }, + { once: true }, + ); + }); + }, + } satisfies AgentDeviceBackend, + artifacts: createLocalArtifactAdapter(), + sessions, + policy: localCommandPolicy(), }); - assert.deepEqual(result, { kind: 'text', text: 'Ready', waitedMs: 0 }); + await assert.rejects( + device.selectors.find({ + session: 'default', + locator: 'text', + query: 'Never appears', + action: 'wait', + timeoutMs: 20, + }), + (error: unknown) => { + assert.ok(error instanceof Error); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'wait_capture_stalled'); + assert.equal(details?.captureStalled, true); + return true; + }, + ); + assert.equal(captureCount, 1); + assert.deepEqual((await sessions.get('default'))?.snapshot, initial); }); test('runtime selector convenience methods use explicit target helpers', async () => { @@ -723,6 +727,105 @@ test('runtime wait keeps the plain timeout when readable polls simply never matc ); }); +test('runtime wait reports a deadline-truncated final capture over an earlier unreadable verdict', async () => { + let captureCount = 0; + const initial = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Initial' }]); + const sessions = createMemorySessionStore([{ name: 'default', snapshot: initial }]); + const device = createAgentDevice({ + backend: { + platform: 'android', + captureSnapshot: (context) => { + captureCount += 1; + if (captureCount === 1) throw unreadableCaptureError(); + return new Promise((resolve) => { + context.signal?.addEventListener( + 'abort', + () => { + setTimeout( + () => + resolve({ + snapshot: makeSnapshotState([ + { index: 0, depth: 0, type: 'Other', label: 'Late capture' }, + ]), + }), + 5, + ); + }, + { once: true }, + ); + }); + }, + } satisfies AgentDeviceBackend, + artifacts: createLocalArtifactAdapter(), + sessions, + policy: localCommandPolicy(), + clock: createFakeClock(), + }); + + await assert.rejects( + device.selectors.wait({ + session: 'default', + target: { kind: 'selector', selector: 'label="Screen X"', timeoutMs: 400 }, + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, 'wait timed out for selector: label="Screen X"'); + assert.equal( + (error as { details?: Record }).details?.reason, + 'wait_deadline_exceeded', + ); + return true; + }, + ); + assert.deepEqual((await sessions.get('default'))?.snapshot, initial); +}); + +test('runtime wait does not call a deadline-truncated poll stalled after a readable capture', async () => { + let captureCount = 0; + const loading = makeSnapshotState([{ index: 0, depth: 0, type: 'Other', label: 'Loading' }]); + const sessions = createMemorySessionStore([{ name: 'default' }]); + const device = createAgentDevice({ + backend: { + platform: 'android', + captureSnapshot: async (context) => { + captureCount += 1; + if (captureCount === 1) return { snapshot: loading }; + return await new Promise((resolve) => { + context.signal?.addEventListener( + 'abort', + () => { + setTimeout(() => resolve({ snapshot: loading }), 5); + }, + { once: true }, + ); + }); + }, + } satisfies AgentDeviceBackend, + artifacts: createLocalArtifactAdapter(), + sessions, + policy: localCommandPolicy(), + clock: createFakeClock(), + }); + + await assert.rejects( + device.selectors.wait({ + session: 'default', + target: { kind: 'selector', selector: 'label="Screen X"', timeoutMs: 400 }, + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, 'wait timed out for selector: label="Screen X"'); + const details = (error as { details?: Record }).details; + assert.equal(details?.reason, 'wait_deadline_exceeded'); + assert.equal(details?.captureTruncated, true); + assert.equal(details?.captureStalled, undefined); + return true; + }, + ); + assert.equal(captureCount, 2); + assert.deepEqual((await sessions.get('default'))?.snapshot, loading); +}); + function failingWaitDevice(produceError: () => Error): { device: ReturnType; attempts: () => number; diff --git a/src/commands/interaction/runtime/selector-read.ts b/src/commands/interaction/runtime/selector-read.ts index 3891ad11a..3d0176c34 100644 --- a/src/commands/interaction/runtime/selector-read.ts +++ b/src/commands/interaction/runtime/selector-read.ts @@ -6,43 +6,21 @@ import { type FindLocator, } from '../../../selectors/find.ts'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; -import { findNodeByRef, normalizeRef } from '@agent-device/kernel/snapshot'; -import { - isSparseSnapshotQualityVerdict, - isUnreadableCaptureContentError, - type SnapshotQualityVerdict, -} from '../../../snapshot/snapshot-quality.ts'; +import { isSparseSnapshotQualityVerdict } from '../../../snapshot/snapshot-quality.ts'; import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { parseSelectorChain, type SelectorChain } from '../../../selectors/parse.ts'; +import { parseSelectorChain } from '../../../selectors/parse.ts'; import { findSelectorChainMatch, formatSelectorFailure, - listSelectorChainMatches, resolveSelectorChain, selectorFailureHint, } from '../../../selectors/index.ts'; -import type { SelectorChainMatchList } from '../../../selectors/resolve.ts'; -import { - readNodeLocalIdentity, - WAIT_LANDMARK_MISMATCH_REASON, - type WaitLandmarkMismatchEvidence, -} from '../../../replay/target-identity-node.ts'; -import { - buildAncestryChain, - buildIndexMap, - filterIdentitySet, -} from '../../../replay/target-evidence-tree.ts'; -import { - annotationLocalIdentity, - type TargetAnnotationV1, -} from '../../../replay/target-identity.ts'; import { buildSelectorChainForNode } from '../../../selectors/build.ts'; import { checkIsPredicate, evaluateIsPredicate } from '../../../selectors/predicates.ts'; -import { checkWaitText, IS_TEXT_VALUE_REQUIRED_MESSAGE } from '../../../selectors/arguments.ts'; +import { IS_TEXT_VALUE_REQUIRED_MESSAGE } from '../../../selectors/arguments.ts'; import type { ElementTarget, - RefTarget, ResolvedTarget, SelectorTarget, } from '../../../contracts/interaction.ts'; @@ -56,18 +34,34 @@ import { requireSnapshotSession, resolveRefNode, } from './selector-read-shared.ts'; -import { findNodeByLabel, resolveRefLabel, shouldScopeFind } from './selector-read-utils.ts'; +import { findSnapshotScope, sparseSelectorSnapshotError } from './selector-read-utils.ts'; +import { deriveSelectorCapturePolicy } from './selector-capture-policy.ts'; +import { + createWaitPolling, + type WaitPollDeadline, + waitCaptureStalledError, + waitDeadlineExceededError, +} from './wait-polling.ts'; +import { + createSelectorWaitCommands, + type WaitCommandOptions, + type WaitCommandResult, + type WaitForTextCommandOptions, +} from './selector-wait.ts'; import { DEFAULT_STABLE_QUIET_MS, runStableCaptureLoop, TINY_STABLE_TREE_HINT, TINY_STABLE_TREE_NODE_COUNT, } from './stable-capture.ts'; -import { now, sleep, toBackendContext } from '../../runtime-common.ts'; -import { deriveSelectorCapturePolicy } from './selector-capture-policy.ts'; export type { SelectorSnapshotOptions } from './selector-read-shared.ts'; -export type { ElementTarget, RefTarget, ResolvedTarget, SelectorTarget }; +export type { + WaitCommandOptions, + WaitCommandResult, + WaitForTextCommandOptions, +} from './selector-wait.ts'; +export type { ElementTarget, ResolvedTarget, SelectorTarget }; export type FindReadCommandOptions = CommandContext & { locator?: FindLocator; @@ -139,78 +133,29 @@ export type IsCommandResult = { preActionNodes?: SnapshotNode[]; }; -export type WaitCommandOptions = CommandContext & - SelectorSnapshotOptions & { - target: - | { kind: 'sleep'; durationMs: number } - | { kind: 'text'; text: string; timeoutMs?: number | null } - | { kind: 'ref'; ref: string; timeoutMs?: number | null } - | { - kind: 'selector'; - selector: string; - timeoutMs?: number | null; - /** - * ADR 0012 / #1349, replay-only: the recorded landmark identity this - * wait must observe before reporting success. Polling is unchanged — - * the loop keeps waiting while no selector match carries this - * identity, and a timeout with rejected candidates throws the - * `WAIT_LANDMARK_MISMATCH_REASON` refusal instead of success. - */ - recordedLandmark?: TargetAnnotationV1; - } - | { kind: 'stable'; quietMs?: number | null; timeoutMs?: number | null }; - }; - -export type WaitCommandResult = - | { kind: 'sleep'; waitedMs: number } - | { kind: 'text'; waitedMs: number; text: string } - | { - kind: 'selector'; - waitedMs: number; - selector: string; - /** ADR 0012 decision 3: the satisfying match and the tree it came from, for record-time evidence. */ - node?: SnapshotNode; - preActionNodes?: SnapshotNode[]; - } - | { - kind: 'stable'; - waitedMs: number; - captures: number; - nodeCount: number; - hint?: string; - }; - -export type WaitForTextCommandOptions = CommandContext & - SelectorSnapshotOptions & { - text: string; - timeoutMs?: number | null; - }; - export type IsSelectorCommandOptions = CommandContext & SelectorSnapshotOptions & { target: SelectorTarget; }; -/** - * @internal Target helper used by tests/examples; runtime callers compose `ElementTarget` directly. - */ -export function selector(expression: string): SelectorTarget { - return { kind: 'selector', selector: expression }; -} +const selectorWaitCommands = createSelectorWaitCommands({ + captureSnapshot: captureSelectorSnapshot, + requireSnapshot: requireSnapshotSession, + stable: { + defaultQuietMs: DEFAULT_STABLE_QUIET_MS, + tinyTreeHint: TINY_STABLE_TREE_HINT, + tinyTreeNodeCount: TINY_STABLE_TREE_NODE_COUNT, + capture: runStableCaptureLoop, + }, +}); -/** - * @internal Target helper used by tests/examples; runtime callers compose `ElementTarget` directly. - */ -export function ref(refInput: string, options: { fallbackLabel?: string } = {}): RefTarget { - return { - kind: 'ref', - ref: refInput, - ...(options.fallbackLabel ? { fallbackLabel: options.fallbackLabel } : {}), - }; -} +export const waitCommand: RuntimeCommand = + selectorWaitCommands.waitCommand; -const DEFAULT_TIMEOUT_MS = 10_000; -const POLL_INTERVAL_MS = 300; +export const waitForTextCommand: RuntimeCommand< + WaitForTextCommandOptions, + Extract +> = selectorWaitCommands.waitForTextCommand; export const findCommand: RuntimeCommand = async ( runtime, @@ -442,74 +387,40 @@ export const isHiddenCommand: RuntimeCommand = async ( - runtime, - options, -): Promise => { - if (options.target.kind === 'sleep') { - await sleep(runtime, options.target.durationMs); - return { kind: 'sleep', waitedMs: options.target.durationMs }; - } - if (options.target.kind === 'ref') { - const capture = await requireSnapshotSession(runtime, options.session); - const ref = normalizeRef(options.target.ref); - if (!ref) throw new AppError('INVALID_ARGS', `Invalid ref: ${options.target.ref}`); - const node = findNodeByRef(capture.snapshot.nodes, ref); - const text = node ? resolveRefLabel(node, capture.snapshot.nodes) : undefined; - if (!text) { - throw new AppError('COMMAND_FAILED', `Ref ${options.target.ref} not found or has no label`); - } - return await waitForText(runtime, options, text, options.target.timeoutMs); - } - if (options.target.kind === 'selector') { - return await waitForSelector( - runtime, - options, - options.target.selector, - options.target.timeoutMs, - options.target.recordedLandmark, - ); - } - if (options.target.kind === 'stable') { - return await waitForStable(runtime, options, options.target.quietMs, options.target.timeoutMs); - } - const waitText = checkWaitText(options.target.text); - if (!waitText.ok) throw new AppError(waitText.code, waitText.message); - return await waitForText(runtime, options, options.target.text, options.target.timeoutMs); -}; - -export const waitForTextCommand: RuntimeCommand< - WaitForTextCommandOptions, - Extract -> = async (runtime, options): Promise> => { - const result = await waitCommand(runtime, { - ...options, - target: { kind: 'text', text: options.text, timeoutMs: options.timeoutMs }, - }); - if (result.kind !== 'text') { - throw new AppError('COMMAND_FAILED', 'waitForText returned non-text result'); - } - return result; -}; - async function waitForFindMatch( runtime: AgentDeviceRuntime, options: FindReadCommandOptions, locator: FindLocator, ): Promise { - const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const start = now(runtime); - while (now(runtime) - start < timeout) { + const polling = createWaitPolling(runtime, options, options.timeoutMs); + let deadline: WaitPollDeadline | undefined; + while (polling.hasTimeRemaining()) { // A presence check never consumes scroll hints, so every poll skips deriving them — // otherwise a single pathological `dumpsys activity top` call can eat the whole wait // budget from inside this loop (#1270). - const { match } = await findFirstLocatorMatch(runtime, options, locator, { - includeHiddenContentHints: false, - }); - if (match) return { kind: 'found', found: true, waitedMs: now(runtime) - start }; - await sleep(runtime, POLL_INTERVAL_MS); + const poll = await polling.capture( + async (signal) => + await findFirstLocatorMatch(runtime, { ...options, signal }, locator, { + includeHiddenContentHints: false, + }), + ); + if (poll.timedOut) { + deadline = poll.deadline; + break; + } + if (poll.value?.match) { + return { kind: 'found', found: true, waitedMs: polling.waitedMs() }; + } + await polling.sleepUntilNextPoll(); } - throw new AppError('COMMAND_FAILED', 'find wait timed out'); + if (deadline === 'capture-stalled') { + throw waitCaptureStalledError('find wait timed out', polling.timeoutMs); + } + if (deadline === 'capture-truncated') { + throw waitDeadlineExceededError('find wait timed out', polling.timeoutMs, true); + } + polling.rethrowIfNeverReadable(); + throw waitDeadlineExceededError('find wait timed out', polling.timeoutMs, false); } async function findFirstLocatorMatch( @@ -521,7 +432,7 @@ async function findFirstLocatorMatch( const selectorChain = parseFindSelectorExpression(locator, options.query); const capture = await captureSelectorSnapshot(runtime, options, { updateSession: true, - scope: findSnapshotScope(runtime, locator, options.query, selectorChain), + scope: findSnapshotScope(runtime.backend.platform, locator, options.query, selectorChain), includeHiddenContentHints: captureOverrides?.includeHiddenContentHints, ...deriveSelectorCapturePolicy({ selectorChain }), }); @@ -542,231 +453,6 @@ async function findFirstLocatorMatch( return { capture, match }; } -function findSnapshotScope( - runtime: AgentDeviceRuntime, - locator: FindLocator, - query: string, - selectorChain: SelectorChain | null, -): string | undefined { - if (selectorChain) return undefined; - if (runtime.backend.platform === 'web') return undefined; - return shouldScopeFind(locator) ? query : undefined; -} - -function sparseSelectorSnapshotError(verdict: SnapshotQualityVerdict): AppError { - return new AppError('COMMAND_FAILED', 'find could not read the current accessibility tree', { - reason: verdict.reason, - hint: 'The snapshot quality verdict is sparse. Use screenshot as visual truth, navigate with coordinates if needed, then retry find after reaching a readable screen.', - }); -} - -async function waitForSelector( - runtime: AgentDeviceRuntime, - options: WaitCommandOptions, - selectorExpression: string, - timeoutMs: number | null | undefined, - recordedLandmark: TargetAnnotationV1 | undefined, -): Promise { - const timeout = timeoutMs ?? DEFAULT_TIMEOUT_MS; - const start = now(runtime); - const chain = parseSelectorChain(selectorExpression); - const capturePolicy = deriveSelectorCapturePolicy({ selectorChain: chain }); - // ADR 0012 / #1349: the LAST poll whose capture matched the recorded - // selector without any match carrying the recorded landmark identity. A - // transient same-selector impostor (the previous screen mid-transition) - // must not abort a wait whose job is to wait through it, so the loop keeps - // polling; only the deadline turns this into the fail-closed refusal. - let landmarkMismatch: WaitLandmarkMismatchEvidence | undefined; - const unreadable = createUnreadablePollTracker(); - while (now(runtime) - start < timeout) { - // Presence-only poll: skip scroll-hint derivation (#1270), same as waitForFindMatch. - const capture = await unreadable.attempt(() => - captureSelectorSnapshot(runtime, options, { - updateSession: true, - includeHiddenContentHints: false, - ...capturePolicy, - }), - ); - if (capture) { - const nodes = capture.snapshot.nodes; - const matchList = listSelectorChainMatches(nodes, chain, { - platform: runtime.backend.platform, - }); - if (matchList) { - const landmark = resolveLandmarkMatch(nodes, matchList, recordedLandmark); - if (landmark.kind === 'satisfied') { - return { - kind: 'selector', - selector: matchList.selector.raw, - waitedMs: now(runtime) - start, - node: landmark.node, - preActionNodes: nodes, - }; - } - landmarkMismatch = landmark.evidence; - } - } - await sleep(runtime, POLL_INTERVAL_MS); - } - if (landmarkMismatch) { - throw new AppError( - 'COMMAND_FAILED', - `wait matched selector ${selectorExpression} but no candidate carried the recorded landmark identity`, - { reason: WAIT_LANDMARK_MISMATCH_REASON, ...landmarkMismatch }, - ); - } - unreadable.rethrowIfNeverReadable(); - throw new AppError('COMMAND_FAILED', `wait timed out for selector: ${selectorExpression}`); -} - -/** - * A wait poll rides out a capture that judged the current screen unreadable - * (`isUnreadableCaptureContentError` — the mid-transition state a wait exists - * to wait through; iOS surfaces the same state as a sparse verdict with no - * matches). Any other capture failure still throws immediately, and a wait - * whose screen NEVER became readable rethrows the last content verdict at the - * deadline so persistent breakage keeps its capture diagnosis instead of a - * generic timeout. - */ -function createUnreadablePollTracker(): { - attempt: (capture: () => Promise) => Promise; - rethrowIfNeverReadable: () => void; -} { - let sawReadableCapture = false; - let lastUnreadableError: unknown; - return { - attempt: async (capture: () => Promise): Promise => { - try { - const result = await capture(); - sawReadableCapture = true; - return result; - } catch (error) { - if (!isUnreadableCaptureContentError(error)) throw error; - lastUnreadableError = error; - return undefined; - } - }, - rethrowIfNeverReadable: () => { - if (!sawReadableCapture && lastUnreadableError !== undefined) throw lastUnreadableError; - }, - }; -} - -type LandmarkMatchOutcome = - | { kind: 'satisfied'; node: SnapshotNode } - | { kind: 'identity-mismatch'; evidence: WaitLandmarkMismatchEvidence }; - -/** - * #1349 landmark check: the wait is satisfied when SOME selector match - * carries the recorded identity (local identity + leaf-anchored ancestry - * prefix). Positional disambiguation signals are deliberately not consulted — - * a destination guard proves the landmark exists on the ready screen, not - * that it kept its list position. - */ -function resolveLandmarkMatch( - nodes: SnapshotNode[], - matchList: SelectorChainMatchList, - recorded: TargetAnnotationV1 | undefined, -): LandmarkMatchOutcome { - const firstMatch = matchList.matchedNodes[0]!; - if (!recorded) return { kind: 'satisfied', node: firstMatch }; - const byIndex = buildIndexMap(nodes); - const identitySet = filterIdentitySet( - matchList.matchedNodes, - byIndex, - annotationLocalIdentity(recorded), - recorded.ancestry, - ); - const member = identitySet[0]; - if (member) return { kind: 'satisfied', node: member }; - return { - kind: 'identity-mismatch', - evidence: { - matchCount: matchList.matchedNodes.length, - observed: readNodeLocalIdentity(firstMatch), - observedAncestry: buildAncestryChain( - firstMatch, - byIndex, - Math.max(recorded.ancestry.length, 1), - ).chain, - }, - }; -} - -async function waitForText( - runtime: AgentDeviceRuntime, - options: WaitCommandOptions, - text: string, - timeoutMs: number | null | undefined, -): Promise { - const timeout = timeoutMs ?? DEFAULT_TIMEOUT_MS; - const start = now(runtime); - const unreadable = createUnreadablePollTracker(); - while (now(runtime) - start < timeout) { - const found = await unreadable.attempt(async () => - runtime.backend.findText - ? (await runtime.backend.findText(toBackendContext(runtime, options), text)).found - : await snapshotContainsText(runtime, options, text), - ); - if (found) return { kind: 'text', text, waitedMs: now(runtime) - start }; - await sleep(runtime, POLL_INTERVAL_MS); - } - unreadable.rethrowIfNeverReadable(); - throw new AppError('COMMAND_FAILED', `wait timed out for text: ${text}`); -} - -async function snapshotContainsText( - runtime: AgentDeviceRuntime, - options: WaitCommandOptions, - text: string, -): Promise { - // Presence-only poll: skip scroll-hint derivation (#1270), same as waitForFindMatch. - const capture = await captureSelectorSnapshot(runtime, options, { - updateSession: true, - includeHiddenContentHints: false, - }); - return Boolean(findNodeByLabel(capture.snapshot.nodes, text)); -} - -// The quiet-window loop itself lives in stable-capture.ts and is shared with -// the interaction `--settle` flag (#1101); this wrapper maps the loop outcome -// to wait's throwing semantics. -async function waitForStable( - runtime: AgentDeviceRuntime, - options: WaitCommandOptions, - quietMs: number | null | undefined, - timeoutMs: number | null | undefined, -): Promise> { - const timeout = timeoutMs ?? DEFAULT_TIMEOUT_MS; - const quiet = quietMs ?? DEFAULT_STABLE_QUIET_MS; - const outcome = await runStableCaptureLoop(runtime, options, { - quietMs: quiet, - timeoutMs: timeout, - }); - if (!outcome.settled) { - throw new AppError('COMMAND_FAILED', 'wait timed out waiting for a stable UI', { - reason: 'wait_stable_timeout', - ...(outcome.stalled ? { captureStalled: true } : {}), - quietMs: quiet, - timeoutMs: timeout, - captures: outcome.captures, - nodeCount: outcome.nodeCount, - ...(outcome.stalled - ? { - hint: 'A snapshot capture stalled past the wait timeout, so no settle verdict is available. The UI may still be readable: retry, or use screenshot to inspect the surface.', - } - : {}), - }); - } - return { - kind: 'stable', - waitedMs: outcome.waitedMs, - captures: outcome.captures, - nodeCount: outcome.nodeCount, - ...(outcome.nodeCount < TINY_STABLE_TREE_NODE_COUNT ? { hint: TINY_STABLE_TREE_HINT } : {}), - }; -} - async function resolveSelectorNode( runtime: AgentDeviceRuntime, options: GetCommandOptions, diff --git a/src/commands/interaction/runtime/selector-read-stable.test.ts b/src/commands/interaction/runtime/selector-wait-stable.test.ts similarity index 96% rename from src/commands/interaction/runtime/selector-read-stable.test.ts rename to src/commands/interaction/runtime/selector-wait-stable.test.ts index 497e020dd..b0d93f17e 100644 --- a/src/commands/interaction/runtime/selector-read-stable.test.ts +++ b/src/commands/interaction/runtime/selector-wait-stable.test.ts @@ -338,12 +338,23 @@ test('runtime wait stable times out with capture stats when never settling', asy test('runtime wait stable times out when a backend capture stalls past the wait budget', async () => { const clock = createFakeClock(); + let captureAborted = false; const device = createAgentDevice({ backend: { platform: 'macos', - // Simulates the stalled macOS AX capture: the promise never settles, so - // only the real-timer deadline can end the wait. - captureSnapshot: () => new Promise(() => {}), + // Simulates a stalled macOS AX capture that honors the backend cancellation contract. + captureSnapshot: (context) => + new Promise((_resolve, reject) => { + assert.ok(context.signal); + context.signal.addEventListener( + 'abort', + () => { + captureAborted = true; + reject(context.signal?.reason); + }, + { once: true }, + ); + }), } satisfies AgentDeviceBackend, artifacts: createLocalArtifactAdapter(), sessions: createMemorySessionStore([{ name: 'default' }]), @@ -367,6 +378,7 @@ test('runtime wait stable times out when a backend capture stalls past the wait return true; }, ); + assert.equal(captureAborted, true); }); test('runtime wait stable uses provided defaults when quietMs/timeoutMs are omitted', async () => { diff --git a/src/commands/interaction/runtime/selector-wait.test.ts b/src/commands/interaction/runtime/selector-wait.test.ts new file mode 100644 index 000000000..1e2b2d297 --- /dev/null +++ b/src/commands/interaction/runtime/selector-wait.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { AgentDeviceBackend, BackendSnapshotOptions } from '../../../backend.ts'; +import { createLocalArtifactAdapter } from '../../../io.ts'; +import { + createAgentDevice, + createMemorySessionStore, + localCommandPolicy, +} from '../../../runtime.ts'; +import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; +import { createSelectorDevice, selectorReadSnapshot } from './__tests__/test-utils/index.ts'; + +test('runtime focused selector waits against a full snapshot', async () => { + const snapshot = makeSnapshotState([ + { + index: 0, + depth: 0, + type: 'Cell', + label: 'Profiles and Accounts', + focused: true, + }, + ]); + let captureOptions: BackendSnapshotOptions | undefined; + const device = createAgentDevice({ + backend: { + platform: 'ios', + captureSnapshot: async (_context, options) => { + captureOptions = options; + return { snapshot }; + }, + } satisfies AgentDeviceBackend, + artifacts: createLocalArtifactAdapter(), + sessions: createMemorySessionStore([{ name: 'default', snapshot }]), + policy: localCommandPolicy(), + }); + + const result = await device.selectors.wait({ + session: 'default', + target: { kind: 'selector', selector: 'focused=true', timeoutMs: 1000 }, + }); + + assert.equal(result.kind, 'selector'); + assert.equal(result.selector, 'focused=true'); + assert.equal(result.waitedMs >= 0, true); + assert.equal(captureOptions?.interactiveOnly, false); +}); + +test('runtime wait can use backend text search', async () => { + const device = createSelectorDevice(selectorReadSnapshot(), { + findText: true, + now: 10, + }); + + const result = await device.selectors.wait({ + session: 'default', + target: { kind: 'text', text: 'Ready', timeoutMs: 100 }, + }); + + assert.deepEqual(result, { kind: 'text', text: 'Ready', waitedMs: 0 }); +}); diff --git a/src/commands/interaction/runtime/selector-wait.ts b/src/commands/interaction/runtime/selector-wait.ts new file mode 100644 index 000000000..c5eab399e --- /dev/null +++ b/src/commands/interaction/runtime/selector-wait.ts @@ -0,0 +1,447 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { findNodeByRef, normalizeRef, type SnapshotNode } from '@agent-device/kernel/snapshot'; +import { + readNodeLocalIdentity, + WAIT_LANDMARK_MISMATCH_REASON, + type WaitLandmarkMismatchEvidence, +} from '../../../replay/target-identity-node.ts'; +import { + buildAncestryChain, + buildIndexMap, + filterIdentitySet, +} from '../../../replay/target-evidence-tree.ts'; +import { + annotationLocalIdentity, + type TargetAnnotationV1, +} from '../../../replay/target-identity.ts'; +import type { PublicPlatform } from '@agent-device/kernel/device'; +import { checkWaitText } from '../../../selectors/arguments.ts'; +import { listSelectorChainMatches } from '../../../selectors/index.ts'; +import { parseSelectorChain } from '../../../selectors/parse.ts'; +import type { SelectorChainMatchList } from '../../../selectors/resolve.ts'; +import { deriveSelectorCapturePolicy } from './selector-capture-policy.ts'; +import { findNodeByLabel, resolveRefLabel } from './selector-read-utils.ts'; +import { + createWaitPolling, + DEFAULT_WAIT_TIMEOUT_MS, + type WaitPollDeadline, + waitCaptureStalledError, + waitDeadlineExceededError, +} from './wait-polling.ts'; + +type WaitCommandContext = { + session?: string; + requestId?: string; + signal?: AbortSignal; + metadata?: Record; +}; + +type SelectorSnapshotOptions = { + depth?: number; + scope?: string; + raw?: boolean; +}; + +export type WaitCommandOptions = WaitCommandContext & + SelectorSnapshotOptions & { + target: + | { kind: 'sleep'; durationMs: number } + | { kind: 'text'; text: string; timeoutMs?: number | null } + | { kind: 'ref'; ref: string; timeoutMs?: number | null } + | { + kind: 'selector'; + selector: string; + timeoutMs?: number | null; + /** + * ADR 0012 / #1349, replay-only: the recorded landmark identity this + * wait must observe before reporting success. Polling is unchanged — + * the loop keeps waiting while no selector match carries this + * identity, and a timeout with rejected candidates throws the + * `WAIT_LANDMARK_MISMATCH_REASON` refusal instead of success. + */ + recordedLandmark?: TargetAnnotationV1; + } + | { kind: 'stable'; quietMs?: number | null; timeoutMs?: number | null }; + }; + +export type WaitCommandResult = + | { kind: 'sleep'; waitedMs: number } + | { kind: 'text'; waitedMs: number; text: string } + | { + kind: 'selector'; + waitedMs: number; + selector: string; + /** ADR 0012 decision 3: the satisfying match and the tree it came from, for record-time evidence. */ + node?: SnapshotNode; + preActionNodes?: SnapshotNode[]; + } + | { + kind: 'stable'; + waitedMs: number; + captures: number; + nodeCount: number; + hint?: string; + }; + +export type WaitForTextCommandOptions = WaitCommandContext & + SelectorSnapshotOptions & { + text: string; + timeoutMs?: number | null; + }; + +type SelectorWaitRuntime = { + backend: { + platform: PublicPlatform; + findText?: (context: WaitCommandContext, text: string) => Promise<{ found: boolean }>; + }; + clock?: { + now(): number; + sleep(ms: number): Promise; + }; + signal?: AbortSignal; +}; + +type StableCaptureResult = { + settled: boolean; + stalled: boolean; + waitedMs: number; + captures: number; + nodeCount: number; +}; + +export type SelectorWaitOperations = { + captureSnapshot: ( + runtime: Runtime, + options: WaitCommandContext & SelectorSnapshotOptions, + captureOptions: { + updateSession: boolean; + scope?: string; + includeRects?: boolean; + interactiveOnly?: boolean; + includeHiddenContentHints?: boolean; + }, + ) => Promise<{ snapshot: { nodes: SnapshotNode[] } }>; + requireSnapshot: ( + runtime: Runtime, + requestedName: string | undefined, + ) => Promise<{ snapshot: { nodes: SnapshotNode[] } }>; + stable: { + defaultQuietMs: number; + tinyTreeHint: string; + tinyTreeNodeCount: number; + capture: ( + runtime: Runtime, + options: WaitCommandContext & SelectorSnapshotOptions, + params: { quietMs: number; timeoutMs: number }, + ) => Promise; + }; +}; + +export function createSelectorWaitCommands( + operations: SelectorWaitOperations, +): { + waitCommand: (runtime: Runtime, options: WaitCommandOptions) => Promise; + waitForTextCommand: ( + runtime: Runtime, + options: WaitForTextCommandOptions, + ) => Promise>; +} { + const waitCommand = async ( + runtime: Runtime, + options: WaitCommandOptions, + ): Promise => { + if (options.target.kind === 'sleep') { + await sleep(runtime, options.target.durationMs); + return { kind: 'sleep', waitedMs: options.target.durationMs }; + } + if (options.target.kind === 'ref') { + const capture = await operations.requireSnapshot(runtime, options.session); + const ref = normalizeRef(options.target.ref); + if (!ref) throw new AppError('INVALID_ARGS', `Invalid ref: ${options.target.ref}`); + const node = findNodeByRef(capture.snapshot.nodes, ref); + const text = node ? resolveRefLabel(node, capture.snapshot.nodes) : undefined; + if (!text) { + throw new AppError('COMMAND_FAILED', `Ref ${options.target.ref} not found or has no label`); + } + return await waitForText(operations, runtime, options, text, options.target.timeoutMs); + } + if (options.target.kind === 'selector') { + return await waitForSelector( + operations, + runtime, + options, + options.target.selector, + options.target.timeoutMs, + options.target.recordedLandmark, + ); + } + if (options.target.kind === 'stable') { + return await waitForStable( + operations, + runtime, + options, + options.target.quietMs, + options.target.timeoutMs, + ); + } + const waitText = checkWaitText(options.target.text); + if (!waitText.ok) throw new AppError(waitText.code, waitText.message); + return await waitForText( + operations, + runtime, + options, + options.target.text, + options.target.timeoutMs, + ); + }; + + const waitForTextCommand = async ( + runtime: Runtime, + options: WaitForTextCommandOptions, + ): Promise> => { + const result = await waitCommand(runtime, { + ...options, + target: { kind: 'text', text: options.text, timeoutMs: options.timeoutMs }, + }); + if (result.kind !== 'text') { + throw new AppError('COMMAND_FAILED', 'waitForText returned non-text result'); + } + return result; + }; + + return { waitCommand, waitForTextCommand }; +} + +async function waitForSelector( + operations: SelectorWaitOperations, + runtime: Runtime, + options: WaitCommandOptions, + selectorExpression: string, + timeoutMs: number | null | undefined, + recordedLandmark: TargetAnnotationV1 | undefined, +): Promise { + const polling = createWaitPolling(runtime, options, timeoutMs); + const chain = parseSelectorChain(selectorExpression); + const capturePolicy = deriveSelectorCapturePolicy({ selectorChain: chain }); + // ADR 0012 / #1349: the LAST poll whose capture matched the recorded + // selector without any match carrying the recorded landmark identity. A + // transient same-selector impostor (the previous screen mid-transition) + // must not abort a wait whose job is to wait through it, so the loop keeps + // polling; only the deadline turns this into the fail-closed refusal. + let landmarkMismatch: WaitLandmarkMismatchEvidence | undefined; + let deadline: WaitPollDeadline | undefined; + while (polling.hasTimeRemaining()) { + // Presence-only poll: skip scroll-hint derivation (#1270), same as waitForFindMatch. + const poll = await polling.capture( + async (signal) => + await operations.captureSnapshot( + runtime, + { ...options, signal }, + { + updateSession: true, + includeHiddenContentHints: false, + ...capturePolicy, + }, + ), + ); + if (poll.timedOut) { + deadline = poll.deadline; + break; + } + const capture = poll.value; + if (capture) { + const nodes = capture.snapshot.nodes; + const matchList = listSelectorChainMatches(nodes, chain, { + platform: runtime.backend.platform, + }); + if (matchList) { + const landmark = resolveLandmarkMatch(nodes, matchList, recordedLandmark); + if (landmark.kind === 'satisfied') { + return { + kind: 'selector', + selector: matchList.selector.raw, + waitedMs: polling.waitedMs(), + node: landmark.node, + preActionNodes: nodes, + }; + } + landmarkMismatch = landmark.evidence; + } + } + await polling.sleepUntilNextPoll(); + } + if (deadline === 'capture-stalled') { + throw waitCaptureStalledError( + `wait timed out for selector: ${selectorExpression}`, + polling.timeoutMs, + ); + } + if (landmarkMismatch) { + throw new AppError( + 'COMMAND_FAILED', + `wait matched selector ${selectorExpression} but no candidate carried the recorded landmark identity`, + { reason: WAIT_LANDMARK_MISMATCH_REASON, ...landmarkMismatch }, + ); + } + if (deadline === 'capture-truncated') { + throw waitDeadlineExceededError( + `wait timed out for selector: ${selectorExpression}`, + polling.timeoutMs, + true, + ); + } + polling.rethrowIfNeverReadable(); + throw waitDeadlineExceededError( + `wait timed out for selector: ${selectorExpression}`, + polling.timeoutMs, + false, + ); +} + +type LandmarkMatchOutcome = + | { kind: 'satisfied'; node: SnapshotNode } + | { kind: 'identity-mismatch'; evidence: WaitLandmarkMismatchEvidence }; + +/** + * #1349 landmark check: the wait is satisfied when SOME selector match + * carries the recorded identity (local identity + leaf-anchored ancestry + * prefix). Positional disambiguation signals are deliberately not consulted — + * a destination guard proves the landmark exists on the ready screen, not + * that it kept its list position. + */ +function resolveLandmarkMatch( + nodes: SnapshotNode[], + matchList: SelectorChainMatchList, + recorded: TargetAnnotationV1 | undefined, +): LandmarkMatchOutcome { + const firstMatch = matchList.matchedNodes[0]!; + if (!recorded) return { kind: 'satisfied', node: firstMatch }; + const byIndex = buildIndexMap(nodes); + const identitySet = filterIdentitySet( + matchList.matchedNodes, + byIndex, + annotationLocalIdentity(recorded), + recorded.ancestry, + ); + const member = identitySet[0]; + if (member) return { kind: 'satisfied', node: member }; + return { + kind: 'identity-mismatch', + evidence: { + matchCount: matchList.matchedNodes.length, + observed: readNodeLocalIdentity(firstMatch), + observedAncestry: buildAncestryChain( + firstMatch, + byIndex, + Math.max(recorded.ancestry.length, 1), + ).chain, + }, + }; +} + +async function waitForText( + operations: SelectorWaitOperations, + runtime: Runtime, + options: WaitCommandOptions, + text: string, + timeoutMs: number | null | undefined, +): Promise { + const polling = createWaitPolling(runtime, options, timeoutMs); + let deadline: WaitPollDeadline | undefined; + while (polling.hasTimeRemaining()) { + const poll = await polling.capture(async (signal) => + runtime.backend.findText + ? (await runtime.backend.findText(backendContext(runtime, { ...options, signal }), text)) + .found + : await snapshotContainsText(operations, runtime, { ...options, signal }, text), + ); + if (poll.timedOut) { + deadline = poll.deadline; + break; + } + const found = poll.value; + if (found) return { kind: 'text', text, waitedMs: polling.waitedMs() }; + await polling.sleepUntilNextPoll(); + } + if (deadline === 'capture-stalled') { + throw waitCaptureStalledError(`wait timed out for text: ${text}`, polling.timeoutMs); + } + if (deadline === 'capture-truncated') { + throw waitDeadlineExceededError(`wait timed out for text: ${text}`, polling.timeoutMs, true); + } + polling.rethrowIfNeverReadable(); + throw waitDeadlineExceededError(`wait timed out for text: ${text}`, polling.timeoutMs, false); +} + +async function snapshotContainsText( + operations: SelectorWaitOperations, + runtime: Runtime, + options: WaitCommandOptions, + text: string, +): Promise { + // Presence-only poll: skip scroll-hint derivation (#1270), same as waitForFindMatch. + const capture = await operations.captureSnapshot(runtime, options, { + updateSession: true, + includeHiddenContentHints: false, + }); + return Boolean(findNodeByLabel(capture.snapshot.nodes, text)); +} + +// The quiet-window loop itself lives in stable-capture.ts and is shared with +// the interaction `--settle` flag (#1101); this wrapper maps the loop outcome +// to wait's throwing semantics. +async function waitForStable( + operations: SelectorWaitOperations, + runtime: Runtime, + options: WaitCommandOptions, + quietMs: number | null | undefined, + timeoutMs: number | null | undefined, +): Promise> { + const timeout = timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS; + const quiet = quietMs ?? operations.stable.defaultQuietMs; + const outcome = await operations.stable.capture(runtime, options, { + quietMs: quiet, + timeoutMs: timeout, + }); + if (!outcome.settled) { + throw new AppError('COMMAND_FAILED', 'wait timed out waiting for a stable UI', { + reason: 'wait_stable_timeout', + ...(outcome.stalled ? { captureStalled: true } : {}), + quietMs: quiet, + timeoutMs: timeout, + captures: outcome.captures, + nodeCount: outcome.nodeCount, + ...(outcome.stalled + ? { + hint: 'A snapshot capture stalled past the wait timeout, so no settle verdict is available. The UI may still be readable: retry, or use screenshot to inspect the surface.', + } + : {}), + }); + } + return { + kind: 'stable', + waitedMs: outcome.waitedMs, + captures: outcome.captures, + nodeCount: outcome.nodeCount, + ...(outcome.nodeCount < operations.stable.tinyTreeNodeCount + ? { hint: operations.stable.tinyTreeHint } + : {}), + }; +} + +function backendContext( + runtime: SelectorWaitRuntime, + options: WaitCommandContext, +): WaitCommandContext { + return { + session: options.session, + requestId: options.requestId, + signal: options.signal ?? runtime.signal, + metadata: options.metadata, + }; +} + +async function sleep(runtime: SelectorWaitRuntime, durationMs: number): Promise { + if (runtime.clock) await runtime.clock.sleep(durationMs); + else await new Promise((resolve) => setTimeout(resolve, durationMs)); +} diff --git a/src/commands/interaction/runtime/settle.test.ts b/src/commands/interaction/runtime/settle.test.ts index f34cfaf9d..343576dc9 100644 --- a/src/commands/interaction/runtime/settle.test.ts +++ b/src/commands/interaction/runtime/settle.test.ts @@ -9,7 +9,7 @@ import { localCommandPolicy, } from '../../../runtime.ts'; import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; -import { ref, selector } from './selector-read.ts'; +import { ref, selector } from './selector-read-utils.ts'; import { buildSettleTailEntries, NEVER_SETTLED_HINT } from './settle.ts'; // #1101 --settle: quiet-window settle loop composition on the interaction diff --git a/src/commands/interaction/runtime/stable-capture.ts b/src/commands/interaction/runtime/stable-capture.ts index 6aef76032..406e94d4d 100644 --- a/src/commands/interaction/runtime/stable-capture.ts +++ b/src/commands/interaction/runtime/stable-capture.ts @@ -6,6 +6,7 @@ import { type CapturedSnapshot, type SelectorSnapshotOptions, } from './selector-read-shared.ts'; +import { runWithinWaitDeadline } from './wait-deadline.ts'; /** * The quiet-window stable-capture loop shared by `wait stable` and the @@ -184,35 +185,24 @@ function stableCaptureDelayMs(params: { // Resolves undefined when the capture does not return within remainingMs. A // stalled backend capture (observed with macOS AX captures) must not push the // stable wait past the user-supplied timeout into the daemon request timeout. -// The deadline uses a real timer even when runtime.clock is injected: test -// clocks advance synthetic time synchronously and cannot represent a hung -// backend call. +// The deadline cancels and joins the capture before returning so no late capture +// can overwrite session state or retain a platform helper. async function captureStableSignalWithinDeadline( runtime: AgentDeviceRuntime, options: CommandContext & SelectorSnapshotOptions, remainingMs: number, ): Promise { - const capture = captureSelectorSnapshot(runtime, options, { - updateSession: false, - interactiveOnly: true, + const result = await runWithinWaitDeadline(runtime, options, remainingMs, async (signal) => { + return await captureSelectorSnapshot( + runtime, + { ...options, signal }, + { + updateSession: false, + interactiveOnly: true, + }, + ); }); - let timer: NodeJS.Timeout | undefined; - try { - const result = await Promise.race([ - capture, - new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), remainingMs); - }), - ]); - if (result === undefined) { - // The abandoned capture settles (or fails) on its own; swallow it so it - // cannot surface as an unhandled rejection after the wait already threw. - capture.catch(() => {}); - } - return result; - } finally { - if (timer !== undefined) clearTimeout(timer); - } + return result.timedOut ? undefined : result.value; } function digestSnapshotNodes(nodes: SnapshotNode[]): string { diff --git a/src/commands/interaction/runtime/wait-deadline.test.ts b/src/commands/interaction/runtime/wait-deadline.test.ts new file mode 100644 index 000000000..d3a68d377 --- /dev/null +++ b/src/commands/interaction/runtime/wait-deadline.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; + +import { runWithinWaitDeadline } from './wait-deadline.ts'; + +test('wait deadline combines runtime and command cancellation authorities', async () => { + const runtime = new AbortController(); + const command = new AbortController(); + let observedAbort = false; + const pending = runWithinWaitDeadline( + { signal: runtime.signal }, + { signal: command.signal }, + 1_000, + async (signal) => + await new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + observedAbort = true; + reject(signal.reason); + }, + { once: true }, + ); + }), + ); + const rejected = assert.rejects(pending, /runtime canceled/); + + runtime.abort(new DOMException('runtime canceled', 'AbortError')); + await new Promise((resolve) => setTimeout(resolve, 0)); + if (!observedAbort) command.abort(new DOMException('test cleanup', 'AbortError')); + + assert.equal(observedAbort, true); + await rejected; +}); diff --git a/src/commands/interaction/runtime/wait-deadline.ts b/src/commands/interaction/runtime/wait-deadline.ts new file mode 100644 index 000000000..c96075e9a --- /dev/null +++ b/src/commands/interaction/runtime/wait-deadline.ts @@ -0,0 +1,46 @@ +export type WaitDeadlineResult = { timedOut: false; value: T } | { timedOut: true }; + +type AbortSignalSource = { signal?: AbortSignal }; + +/** + * Applies a wait's remaining budget by cancellation, then waits for the operation to quiesce. + * This deliberately does not race-and-abandon the task: a late snapshot could otherwise mutate + * session state or keep ownership of a platform helper after the wait has returned. + */ +export async function runWithinWaitDeadline( + runtime: AbortSignalSource, + options: AbortSignalSource, + remainingMs: number, + task: (signal: AbortSignal) => Promise, +): Promise> { + const deadlineController = new AbortController(); + const parentSignals = [options.signal, runtime.signal].filter( + (signal): signal is AbortSignal => signal !== undefined, + ); + const signal = AbortSignal.any([...parentSignals, deadlineController.signal]); + let deadlineExpired = false; + const timer = setTimeout( + () => { + deadlineExpired = true; + deadlineController.abort(new DOMException('Wait deadline exceeded', 'TimeoutError')); + }, + Math.max(0, remainingMs), + ); + timer.unref(); + + try { + const value = await task(signal); + if (deadlineExpired && !parentSignals.some((parent) => parent.aborted)) { + return { timedOut: true }; + } + signal.throwIfAborted(); + return { timedOut: false, value }; + } catch (error) { + if (deadlineExpired && !parentSignals.some((parent) => parent.aborted)) { + return { timedOut: true }; + } + throw error; + } finally { + clearTimeout(timer); + } +} diff --git a/src/commands/interaction/runtime/wait-polling.test.ts b/src/commands/interaction/runtime/wait-polling.test.ts new file mode 100644 index 000000000..1fad78281 --- /dev/null +++ b/src/commands/interaction/runtime/wait-polling.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { AgentDeviceRuntime } from '../../../runtime-contract.ts'; +import { createWaitPolling } from './wait-polling.ts'; + +test('poll delay is bounded by the remaining wait budget', async () => { + let currentMs = 0; + const sleeps: number[] = []; + const runtime = { + clock: { + now: () => currentMs, + sleep: async (durationMs: number) => { + sleeps.push(durationMs); + currentMs += durationMs; + }, + }, + } as AgentDeviceRuntime; + const polling = createWaitPolling(runtime, {}, 125); + + assert.equal(await polling.sleepUntilNextPoll(), true); + assert.deepEqual(sleeps, [125]); + assert.equal(polling.hasTimeRemaining(), false); +}); + +test('poll delay observes both runtime and command cancellation', async () => { + for (const authority of ['runtime', 'command'] as const) { + const runtimeController = new AbortController(); + const commandController = new AbortController(); + const polling = createWaitPolling( + { signal: runtimeController.signal } as AgentDeviceRuntime, + { signal: commandController.signal }, + 10_000, + ); + const sleeping = polling.sleepUntilNextPoll(); + + const reason = new Error(`${authority} canceled`); + (authority === 'runtime' ? runtimeController : commandController).abort(reason); + + await assert.rejects(sleeping, reason); + } +}); diff --git a/src/commands/interaction/runtime/wait-polling.ts b/src/commands/interaction/runtime/wait-polling.ts new file mode 100644 index 000000000..14b3be539 --- /dev/null +++ b/src/commands/interaction/runtime/wait-polling.ts @@ -0,0 +1,152 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { isUnreadableCaptureContentError } from '../../../snapshot/snapshot-quality.ts'; +import { runWithinWaitDeadline } from './wait-deadline.ts'; + +export const DEFAULT_WAIT_TIMEOUT_MS = 10_000; +const WAIT_POLL_INTERVAL_MS = 300; + +export type WaitPollDeadline = 'capture-stalled' | 'capture-truncated'; + +type WaitPollingRuntime = { + clock?: { + now(): number; + sleep(ms: number): Promise; + }; + signal?: AbortSignal; +}; + +type WaitPollingOptions = { + signal?: AbortSignal; +}; + +type UnreadablePollTracker = { + attempt: (capture: () => Promise) => Promise; + rethrowIfNeverReadable: () => void; +}; + +export function createWaitPolling( + runtime: WaitPollingRuntime, + options: WaitPollingOptions, + requestedTimeoutMs: number | null | undefined, +) { + const timeoutMs = requestedTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS; + const startedAtMs = now(runtime); + const unreadable = createUnreadablePollTracker(); + let capturesStarted = 0; + const remainingMs = () => Math.max(0, timeoutMs - (now(runtime) - startedAtMs)); + + return { + capture: async (capture: (signal: AbortSignal) => Promise) => { + const receivedWholeWaitBudget = capturesStarted === 0; + capturesStarted += 1; + const result = await runWithinWaitDeadline( + runtime, + options, + remainingMs(), + async (signal) => await unreadable.attempt(() => capture(signal)), + ); + if (!result.timedOut) return result; + return { + timedOut: true as const, + // Only the first capture receives the wait's entire budget. A later poll is canceled by + // the enclosing deadline, so classifying it as a backend stall would overstate the evidence. + deadline: receivedWholeWaitBudget + ? ('capture-stalled' as const) + : ('capture-truncated' as const), + }; + }, + hasTimeRemaining: () => remainingMs() > 0, + rethrowIfNeverReadable: unreadable.rethrowIfNeverReadable, + sleepUntilNextPoll: async () => + await sleepWithinWait(runtime, options, Math.min(WAIT_POLL_INTERVAL_MS, remainingMs())), + timeoutMs, + waitedMs: () => now(runtime) - startedAtMs, + }; +} + +export function waitCaptureStalledError(message: string, timeoutMs: number): AppError { + return new AppError('COMMAND_FAILED', message, { + reason: 'wait_capture_stalled', + captureStalled: true, + timeoutMs, + hint: 'A snapshot capture stalled past the wait timeout. Retry, or use screenshot to inspect the current surface.', + }); +} + +export function waitDeadlineExceededError( + message: string, + timeoutMs: number, + captureTruncated: boolean, +): AppError { + return new AppError( + 'COMMAND_FAILED', + message, + captureTruncated + ? { + reason: 'wait_deadline_exceeded', + captureTruncated: true, + timeoutMs, + } + : undefined, + ); +} + +function createUnreadablePollTracker(): UnreadablePollTracker { + let sawReadableCapture = false; + let lastUnreadableError: unknown; + return { + attempt: async (capture: () => Promise): Promise => { + try { + const result = await capture(); + sawReadableCapture = true; + return result; + } catch (error) { + if (!isUnreadableCaptureContentError(error)) throw error; + lastUnreadableError = error; + return undefined; + } + }, + rethrowIfNeverReadable: () => { + if (!sawReadableCapture && lastUnreadableError !== undefined) throw lastUnreadableError; + }, + }; +} + +function now(runtime: WaitPollingRuntime): number { + return runtime.clock?.now() ?? Date.now(); +} + +async function sleepWithinWait( + runtime: WaitPollingRuntime, + options: WaitPollingOptions, + durationMs: number, +): Promise { + const parentSignals = [options.signal, runtime.signal].filter( + (signal): signal is AbortSignal => signal !== undefined, + ); + for (const signal of parentSignals) signal.throwIfAborted(); + if (durationMs <= 0) return false; + + if (runtime.clock) { + await runtime.clock.sleep(durationMs); + for (const signal of parentSignals) signal.throwIfAborted(); + return true; + } + + await new Promise((resolve, reject) => { + const signal = parentSignals.length > 0 ? AbortSignal.any(parentSignals) : undefined; + const cleanup = () => signal?.removeEventListener('abort', onAbort); + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, durationMs); + const onAbort = () => { + clearTimeout(timer); + cleanup(); + reject(signal?.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + timer.unref(); + }); + return true; +} diff --git a/src/contracts/interactor-types.ts b/src/contracts/interactor-types.ts index fb0dc29b1..4de12184c 100644 --- a/src/contracts/interactor-types.ts +++ b/src/contracts/interactor-types.ts @@ -17,6 +17,7 @@ import type { export type RunnerContext = { requestId?: string; + signal?: AbortSignal; appBundleId?: string; verbose?: boolean; logPath?: string; @@ -30,6 +31,7 @@ export type RunnerContext = { /** Subset of {@link RunnerContext} forwarded to runner command invocations. */ export type RunnerCallOptions = Pick< RunnerContext, + | 'signal' | 'verbose' | 'logPath' | 'traceLogPath' @@ -71,6 +73,7 @@ export const MAESTRO_NON_HITTABLE_FALLBACK_MESSAGE = 'tapped via non-hittable co export type SnapshotOptions = BaseSnapshotOptions & { appBundleId?: string; + signal?: AbortSignal; includeRects?: boolean; includeHiddenContentHints?: boolean; surface?: SessionSurface; diff --git a/src/core/__tests__/dispatch-trigger-app-event.test.ts b/src/core/__tests__/dispatch-trigger-app-event.test.ts index 98f8cc1a6..fe0b51e81 100644 --- a/src/core/__tests__/dispatch-trigger-app-event.test.ts +++ b/src/core/__tests__/dispatch-trigger-app-event.test.ts @@ -94,7 +94,7 @@ test('trigger-app-event opens deep link with encoded event payload', async () => const args = (await fs.readFile(argsLogPath, 'utf8')).trim().split('\n').filter(Boolean); assert.equal(args.includes('-d'), true); - assert.equal(args.includes(expectedUrl), true); + assert.equal(args.includes(`'${expectedUrl}'`), true); } finally { process.env.PATH = previousPath; if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index c22040afe..85e77d258 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -13,6 +13,7 @@ import type { Point } from '@agent-device/kernel/snapshot'; export type DispatchContext = ScreenshotDispatchFlags & { requestId?: string; + signal?: AbortSignal; appBundleId?: string; activity?: string; launchConsole?: string; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 459065301..0343a1ca3 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -98,6 +98,7 @@ export async function dispatchGestureViewport( function runnerContextFromDispatchContext(context?: DispatchContext): RunnerContext { return { requestId: context?.requestId, + signal: context?.signal, appBundleId: context?.appBundleId, verbose: context?.verbose, logPath: context?.logPath, @@ -631,15 +632,17 @@ async function handleSnapshotCommand( interactor: Interactor, context: DispatchContext | undefined, ): Promise> { + const snapshotContext = context ?? {}; return await interactor.snapshot({ - appBundleId: context?.appBundleId, - interactiveOnly: context?.snapshotInteractiveOnly, - depth: context?.snapshotDepth, - scope: context?.snapshotScope, - raw: context?.snapshotRaw, - includeRects: context?.snapshotIncludeRects, - includeHiddenContentHints: context?.snapshotIncludeHiddenContentHints, - surface: context?.surface, + appBundleId: snapshotContext.appBundleId, + signal: snapshotContext.signal, + interactiveOnly: snapshotContext.snapshotInteractiveOnly, + depth: snapshotContext.snapshotDepth, + scope: snapshotContext.snapshotScope, + raw: snapshotContext.snapshotRaw, + includeRects: snapshotContext.snapshotIncludeRects, + includeHiddenContentHints: snapshotContext.snapshotIncludeHiddenContentHints, + surface: snapshotContext.surface, }); } diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 23129754a..1b43fa403 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -34,12 +34,13 @@ import { screenshotAndroid } from '../../platforms/android/screenshot.ts'; import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { withMethodScope } from '../../utils/method-scope.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { Interactor } from '../../contracts/interactor-types.ts'; +import type { Interactor, RunnerContext } from '../../contracts/interactor-types.ts'; import { snapshotCaptureAnnotationsFrom } from '../../contracts/snapshot-capture-annotations.ts'; export function createAndroidInteractor( device: DeviceInfo, provider?: AndroidAdbProvider, + runnerContext?: Pick, ): Interactor { const interactor: Interactor = { open: (app, options) => @@ -65,19 +66,21 @@ export function createAndroidInteractor( gestureViewport: () => readAndroidGestureViewport(device), screenshot: (outPath, options) => screenshotAndroid(device, outPath, options), snapshot: async (options) => { + const snapshotOptions = options ?? {}; const result = await withDiagnosticTimer( 'snapshot_capture', async () => await snapshotAndroid(device, { - appBundleId: options?.appBundleId, - interactiveOnly: options?.interactiveOnly, - depth: options?.depth, - scope: options?.scope, - raw: options?.raw, - includeHiddenContentHints: options?.includeHiddenContentHints, + appBundleId: snapshotOptions.appBundleId, + signal: snapshotOptions.signal ?? runnerContext?.signal, + interactiveOnly: snapshotOptions.interactiveOnly, + depth: snapshotOptions.depth, + scope: snapshotOptions.scope, + raw: snapshotOptions.raw, + includeHiddenContentHints: snapshotOptions.includeHiddenContentHints, // appBundleId is present for app-backed daemon sessions; keep the helper warm there, // but release it after standalone device snapshots so UiAutomation is not squatted. - helperSessionScope: options?.appBundleId ? 'daemon-session' : 'command', + helperSessionScope: snapshotOptions.appBundleId ? 'daemon-session' : 'command', }), { backend: 'android' }, ); diff --git a/src/core/interactors/linux.ts b/src/core/interactors/linux.ts index fceb98ea7..fa2753fd1 100644 --- a/src/core/interactors/linux.ts +++ b/src/core/interactors/linux.ts @@ -48,7 +48,7 @@ export function createLinuxInteractor(): Interactor { snapshot: async (options) => { const result = await withDiagnosticTimer( 'snapshot_capture', - async () => await snapshotLinux(options?.surface), + async () => await snapshotLinux(options?.surface, options?.signal), { backend: 'linux-atspi' }, ); return { diff --git a/src/core/interactors/register-builtins.ts b/src/core/interactors/register-builtins.ts index daadbf19a..bba5ee638 100644 --- a/src/core/interactors/register-builtins.ts +++ b/src/core/interactors/register-builtins.ts @@ -8,6 +8,7 @@ import { type DeviceInventoryRequest, } from '../../contracts/device-inventory.ts'; import type { Platform, DeviceInfo } from '@agent-device/kernel/device'; +import type { RunnerContext } from '../../contracts/interactor-types.ts'; import { resolveAndroidDiscoverySerialAllowlist } from '../platform-inventory.ts'; // The builtin-plugin wiring lives at the interactor seam (src/core/interactors/) — @@ -51,9 +52,9 @@ const androidPlugin = { // Declares the platform-gated request provider resolver the Android family owns (the // adb provider, formerly gated by `device.platform === 'android'`). providers: { platformGatedResolvers: ['androidAdbProvider'] }, - createInteractor: async (device: DeviceInfo) => { + createInteractor: async (device: DeviceInfo, runner: RunnerContext) => { const { createAndroidInteractor } = await import('./android.ts'); - return createAndroidInteractor(device); + return createAndroidInteractor(device, undefined, runner); }, discoverDevices: async (request: DeviceInventoryRequest) => { const { listAndroidDevices } = await import('../../platforms/android/devices.ts'); diff --git a/src/daemon/__tests__/http-server-artifacts.coverage.ts b/src/daemon/__tests__/http-server-artifacts.coverage.ts new file mode 100644 index 000000000..b0abf41f6 --- /dev/null +++ b/src/daemon/__tests__/http-server-artifacts.coverage.ts @@ -0,0 +1,7 @@ +import { PUBLIC_COMMANDS as C } from '../../command-catalog.ts'; + +export const ANDROID_ARTIFACTS_CONTRACT_EVIDENCE = { + commands: [C.artifacts], + owner: 'daemon/http-server-artifacts', + testName: 'downloadable artifact inventory is filtered by tenant', +} as const; diff --git a/src/daemon/__tests__/http-server-artifacts.test.ts b/src/daemon/__tests__/http-server-artifacts.test.ts index 99d46a2ba..0e726d184 100644 --- a/src/daemon/__tests__/http-server-artifacts.test.ts +++ b/src/daemon/__tests__/http-server-artifacts.test.ts @@ -16,6 +16,7 @@ import { listenOnLoopback, skipWhenLoopbackUnavailable, } from '../../__tests__/test-utils/index.ts'; +import { ANDROID_ARTIFACTS_CONTRACT_EVIDENCE } from './http-server-artifacts.coverage.ts'; type ArtifactInventoryResponse = { artifacts: Array<{ @@ -29,7 +30,7 @@ type ArtifactInventoryResponse = { }>; }; -test('downloadable artifact inventory is filtered by tenant', async () => { +test(ANDROID_ARTIFACTS_CONTRACT_EVIDENCE.testName, async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-artifacts-tenants-')); const publicPath = path.join(tempDir, 'public.txt'); const tenantAPath = path.join(tempDir, 'tenant-a.txt'); diff --git a/src/daemon/handlers/__tests__/interaction-settle.test.ts b/src/daemon/handlers/__tests__/interaction-settle.test.ts index 21cf1928b..4a644df46 100644 --- a/src/daemon/handlers/__tests__/interaction-settle.test.ts +++ b/src/daemon/handlers/__tests__/interaction-settle.test.ts @@ -9,6 +9,8 @@ import { setSessionSnapshot } from '../../session-snapshot.ts'; import { activateCompleteRefFrame } from '../../ref-frame.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { createInteractionRuntime } from '../interaction-runtime.ts'; +import { clearRequestAbortRegistration, registerRequestAbort } from '../../../request/cancel.ts'; // #1101 --settle daemon response shape: the settle payload (diff + settled + // refsGeneration) rides the wire response through the shared builder, and a @@ -100,6 +102,35 @@ function seedSession(sessionName: string, sessionStore: ReturnType { + const sessionStore = makeSessionStore(); + const sessionName = 'request-signal'; + const requestId = 'request-signal-id'; + seedSession(sessionName, sessionStore); + const registration = registerRequestAbort(requestId); + expect(registration).toBeDefined(); + + try { + const runtime = createInteractionRuntime({ + req: { + token: 't', + session: sessionName, + command: 'press', + positionals: ['label=Continue'], + meta: { requestId }, + }, + sessionName, + sessionStore, + contextFromFlags, + captureSnapshotForSession: mockCaptureSnapshotForSession, + }); + + expect(runtime.signal).toBe(registration?.controller.signal); + } finally { + clearRequestAbortRegistration(registration); + } +}); + function mockCommandDispatch(params: { snapshots: Array }) { let snapshotCalls = 0; mockDispatch.mockImplementation(async (_device, command) => { @@ -335,6 +366,57 @@ test('a settle observation without a diff leaves ref staleness untouched', async expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); }); +test('a stalled settle capture receives its deadline signal and leaves the interaction responsive', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'settle-capture-deadline'; + seedSession(sessionName, sessionStore); + let captureCalls = 0; + let observedAbort = false; + mockCaptureSnapshotForSession.mockImplementation( + async (_session, _flags, _sessionStore, _contextFromFlags, options) => { + captureCalls += 1; + if (captureCalls === 1) { + return buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest' }, {}); + } + return await new Promise((_resolve, reject) => { + const fallback = setTimeout( + () => reject(new Error('settle capture did not receive cancellation')), + 1_000, + ); + const onAbort = () => { + clearTimeout(fallback); + observedAbort = true; + reject(options.signal?.reason); + }; + options.signal?.addEventListener('abort', onAbort, { once: true }); + if (options.signal?.aborted) onAbort(); + }); + }, + ); + + const startedAt = Date.now(); + const response = await handleInteractionCommands({ + req: { + token: 't', + session: sessionName, + command: 'press', + positionals: ['label=Continue'], + flags: { settle: true, settleQuietMs: 25, timeoutMs: 75 }, + }, + sessionName, + sessionStore, + contextFromFlags, + }); + + const data = expectOkData(response); + const settle = data.settle as SettlePayload; + expect(settle.settled).toBe(false); + expect(settle.diff).toBeUndefined(); + expect(settle.hint).toMatch(/capture stalled past the settle budget/i); + expect(observedAbort).toBe(true); + expect(Date.now() - startedAt).toBeLessThan(500); +}); + test('bare timeout without --settle stays compatible', async () => { const sessionStore = makeSessionStore(); const sessionName = 'settle-guard'; diff --git a/src/daemon/handlers/__tests__/session-audio.coverage.ts b/src/daemon/handlers/__tests__/session-audio.coverage.ts new file mode 100644 index 000000000..94111385b --- /dev/null +++ b/src/daemon/handlers/__tests__/session-audio.coverage.ts @@ -0,0 +1,7 @@ +import { PUBLIC_COMMANDS as C } from '../../../command-catalog.ts'; + +export const ANDROID_AUDIO_CONTRACT_EVIDENCE = { + commands: [C.audio], + owner: 'daemon/session-audio', + testName: 'audio probe starts host helper for Android emulator audio', +} as const; diff --git a/src/daemon/handlers/__tests__/session-audio.test.ts b/src/daemon/handlers/__tests__/session-audio.test.ts index 8feee3826..3935f0357 100644 --- a/src/daemon/handlers/__tests__/session-audio.test.ts +++ b/src/daemon/handlers/__tests__/session-audio.test.ts @@ -28,6 +28,7 @@ vi.mock('../../../platforms/apple/os/macos/helper.ts', async (importOriginal) => }; }); import { handleSessionObservabilityCommands } from '../session-observability.ts'; +import { ANDROID_AUDIO_CONTRACT_EVIDENCE } from './session-audio.coverage.ts'; beforeEach(() => { vi.resetAllMocks(); @@ -203,7 +204,7 @@ test('audio probe starts host helper for iOS simulator audio', async () => { assert.match(String(response.data.notes[0]), /iOS simulator/); }); -test('audio probe starts host helper for Android emulator audio', async () => { +test(ANDROID_AUDIO_CONTRACT_EVIDENCE.testName, async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); sessionStore.set('android', makeAndroidSession('android')); mockHostAudioProbeStart({ diff --git a/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts b/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts index 065e766aa..fab27ce25 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts @@ -16,7 +16,7 @@ import { makeSnapshotState } from '../../../__tests__/test-utils/index.ts'; import { ref as interactionRef, selector, -} from '../../../commands/interaction/runtime/selector-read.ts'; +} from '../../../commands/interaction/runtime/selector-read-utils.ts'; import { createInteractionDevice } from '../../../commands/interaction/runtime/__tests__/test-utils/index.ts'; import { classifyReplayTarget } from '../session-replay-target-classification.ts'; diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 09429198b..43b070f77 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -1530,7 +1530,8 @@ test('wait text on Android uses freshness-aware capture instead of one-shot snap token: 't', session: sessionName, command: 'wait', - positionals: ['Create document', '50'], + // The wait budget includes Android's 250 ms freshness retry delay. + positionals: ['Create document', '500'], flags: {}, }, sessionName, diff --git a/src/daemon/handlers/interaction-runtime.ts b/src/daemon/handlers/interaction-runtime.ts index d13f5ea3e..6f4bc5d9e 100644 --- a/src/daemon/handlers/interaction-runtime.ts +++ b/src/daemon/handlers/interaction-runtime.ts @@ -22,6 +22,7 @@ import { resolveWebProvider, type WebProvider } from '../../platforms/web/provid import { stripAtPrefix } from './interaction-touch-targets.ts'; import { NO_ACTIVE_SESSION_MESSAGE } from './response.ts'; import type { Rect } from '@agent-device/kernel/snapshot'; +import { getRequestSignal } from '../../request/cancel.ts'; type InteractionRuntimeParams = InteractionHandlerParams & { captureSnapshotForSession: CaptureSnapshotForSession; @@ -50,6 +51,7 @@ export function createInteractionRuntime(params: InteractionRuntimeParams) { params.sessionStore.set(params.sessionName, session); }, }), + signal: getRequestSignal(params.req.meta?.requestId), }); } @@ -60,7 +62,7 @@ function createInteractionBackend( const webProvider = resolveNativeWebInteractionProvider(session); return { platform: publicPlatformString(session.device), - captureSnapshot: async (_context, options): Promise => ({ + captureSnapshot: async (context, options): Promise => ({ snapshot: await params.captureSnapshotForSession( session, req.flags, @@ -69,6 +71,7 @@ function createInteractionBackend( { interactiveOnly: options?.interactiveOnly === true, includeRects: options?.includeRects === true, + signal: context.signal, }, ), }), diff --git a/src/daemon/handlers/interaction-snapshot.ts b/src/daemon/handlers/interaction-snapshot.ts index 8c248b0a7..3355ad097 100644 --- a/src/daemon/handlers/interaction-snapshot.ts +++ b/src/daemon/handlers/interaction-snapshot.ts @@ -16,6 +16,7 @@ export type CaptureSnapshotForSession = ( interactiveOnly: boolean; androidFreshnessMode?: 'ref-refresh'; includeRects?: boolean; + signal?: AbortSignal; }, ) => Promise; @@ -28,6 +29,7 @@ export async function captureSnapshotForSession( interactiveOnly: boolean; androidFreshnessMode?: 'ref-refresh'; includeRects?: boolean; + signal?: AbortSignal; }, ): Promise { const effectiveFlags = { @@ -47,6 +49,7 @@ export async function captureSnapshotForSession( logPath: dispatchContext.logPath ?? '', includeRects: options.includeRects, androidFreshnessMode: options.androidFreshnessMode, + signal: options.signal, }); if (!isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) { setSessionSnapshot(session, snapshot); diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 61eeee3a5..0eae9875a 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -59,6 +59,7 @@ type CaptureSnapshotParams = { logPath: string; snapshotScope?: string; androidFreshnessMode?: AndroidFreshnessMode; + signal?: AbortSignal; }; type SnapshotData = { @@ -199,7 +200,7 @@ async function waitForDelayedInteractionSurfaceChange( export async function captureSnapshotData(params: CaptureSnapshotParams): Promise { const { device, session, flags, outPath, logPath, snapshotScope } = params; if (device.platform === 'linux') { - const linuxResult = await snapshotLinux(session?.surface); + const linuxResult = await snapshotLinux(session?.surface, params.signal); return shapeDesktopSurfaceSnapshot( { nodes: linuxResult.nodes, truncated: linuxResult.truncated, backend: 'linux-atspi' }, { @@ -212,6 +213,7 @@ export async function captureSnapshotData(params: CaptureSnapshotParams): Promis if (isMacOs(device) && session?.surface && session.surface !== 'app') { const helperSnapshot = await runMacOsSnapshotAction(session.surface, { bundleId: session.surface === 'menubar' ? session.appBundleId : undefined, + signal: params.signal, }); return shapeDesktopSurfaceSnapshot(helperSnapshot, { snapshotDepth: flags?.snapshotDepth, @@ -227,6 +229,7 @@ export async function captureSnapshotData(params: CaptureSnapshotParams): Promis session?.trace?.outPath, ), snapshotIncludeRects: params.includeRects, + signal: params.signal, })) as SnapshotData; } diff --git a/src/daemon/selector-capture-runtime.ts b/src/daemon/selector-capture-runtime.ts index 6d56b1109..35deaaf0e 100644 --- a/src/daemon/selector-capture-runtime.ts +++ b/src/daemon/selector-capture-runtime.ts @@ -50,6 +50,7 @@ type SelectorCaptureRecoveryPolicy = { type SelectorCaptureRequest = { flags: CommandFlags | undefined; + signal?: AbortSignal; includeRects?: boolean; outPath?: string; snapshotScope?: string; @@ -95,6 +96,7 @@ export function createSelectorCaptureRuntime(params: SelectorCaptureRuntimeParam } const snapshot = await captureSelectorSnapshot({ params, request }); + request.signal?.throwIfAborted(); const result = { snapshot }; updateSessionSnapshot({ session, sessionStore, sessionName, snapshot }); lastSnapshotAt = timestamp; @@ -178,6 +180,7 @@ async function runCapture( logPath: params.logPath ?? '', snapshotScope, includeRects: request.includeRects, + signal: request.signal, }); return capture.snapshot; } diff --git a/src/daemon/selector-runtime-backend.test.ts b/src/daemon/selector-runtime-backend.test.ts new file mode 100644 index 000000000..a94ad6073 --- /dev/null +++ b/src/daemon/selector-runtime-backend.test.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { createSelectorRuntimeForDevice } from './selector-runtime-backend.ts'; +import { SessionStore } from './session-store.ts'; +import type { SessionState } from './types.ts'; + +vi.mock('../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + runAppleRunnerCommand: vi.fn(async () => ({ found: false })), + }; +}); + +import { runAppleRunnerCommand } from '../platforms/apple/core/runner/runner-client.ts'; + +const mockRunnerCommand = vi.mocked(runAppleRunnerCommand); +const device: SessionState['device'] = { + platform: 'apple', + id: 'sim-1', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, +}; + +beforeEach(() => { + mockRunnerCommand.mockReset(); +}); + +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(fs.mkdtempSync(path.join(os.tmpdir(), 'selector-runtime-')), 'sessions'), + ); + const session: SessionState = { + name: sessionName, + device, + appBundleId: 'com.example.fixture', + createdAt: Date.now(), + actions: [], + }; + sessionStore.set(sessionName, session); + let observedSignal: AbortSignal | undefined; + mockRunnerCommand.mockImplementation( + async (_device, _command, options) => + await new Promise((resolve) => { + observedSignal = options?.signal; + if (options?.signal?.aborted) { + resolve({ found: false }); + return; + } + options?.signal?.addEventListener('abort', () => resolve({ found: false }), { once: true }); + }), + ); + const runtime = createSelectorRuntimeForDevice({ + req: { + token: 't', + session: sessionName, + command: 'wait', + positionals: ['Never appears', '10'], + flags: {}, + }, + sessionName, + sessionStore, + session, + device, + }); + + await expect( + runtime.selectors.waitForText('Never appears', { session: sessionName, timeoutMs: 10 }), + ).rejects.toThrow(/timed out/i); + expect(observedSignal).toBeDefined(); + expect(observedSignal?.aborted).toBe(true); +}); diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index a025aab75..1f1b58e97 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -22,6 +22,7 @@ import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { createSelectorCaptureRuntime } from './selector-capture-runtime.ts'; import { isActiveProviderDevice } from '../provider-device-runtime.ts'; +import { getRequestSignal } from '../request/cancel.ts'; export type SelectorRuntimeParams = { req: DaemonRequest; @@ -32,6 +33,7 @@ export type SelectorRuntimeParams = { // Filled by the capture runtime with the snapshot each selector command actually consumed; // sessionless routes disclose from here because no session record stores the capture. consumedSnapshot?: { state?: SnapshotState }; + signal?: AbortSignal; }; type SelectorRuntimeDeviceParams = SelectorRuntimeParams & { @@ -70,6 +72,7 @@ export function createSelectorRuntimeForDevice(params: SelectorRuntimeDevicePara params.sessionStore.set(params.sessionName, params.session); }, }), + signal: params.signal ?? getRequestSignal(params.req.meta?.requestId), }); } @@ -115,7 +118,7 @@ function createSelectorBackend(params: SelectorRuntimeDeviceParams): AgentDevice }); return { platform: publicPlatformString(device), - captureSnapshot: async (_context, options): Promise => { + captureSnapshot: async (context, options): Promise => { const flags = { ...req.flags, ...snapshotFlagOverrides(options), @@ -128,6 +131,7 @@ function createSelectorBackend(params: SelectorRuntimeDeviceParams): AgentDevice (includeRects && device.platform === 'web'); return await captureRuntime.capture({ flags, + signal: context.signal, snapshotScope, includeRects, cache: { @@ -151,8 +155,8 @@ function createSelectorBackend(params: SelectorRuntimeDeviceParams): AgentDevice contextFromFlags(logPath ?? '', flags, appBundleId, traceLogPath)), }), }), - findText: async (_context, text) => ({ - found: await findText(params, text), + findText: async (context, text) => ({ + found: await findText(params, text, context.signal), }), }; } @@ -170,35 +174,41 @@ function snapshotFlagOverrides(options: BackendSnapshotOptions | undefined): Sna return flags; } -async function findText(params: SelectorRuntimeDeviceParams, text: string): Promise { - const macosSurfaceResult = await findTextInMacosNonAppSurface(params, text); +async function findText( + params: SelectorRuntimeDeviceParams, + text: string, + signal?: AbortSignal, +): Promise { + const macosSurfaceResult = await findTextInMacosNonAppSurface(params, text, signal); if (macosSurfaceResult !== null) return macosSurfaceResult; - const appleRunnerResult = await findTextWithAppleRunner(params, text); + const appleRunnerResult = await findTextWithAppleRunner(params, text, signal); // The runner query is a fast path, not the semantic source of truth. XCTest can report a // transient miss for visible SwiftUI text that the canonical snapshot already contains. if (appleRunnerResult === true) return true; - return await findTextInWaitSnapshot(params, text); + return await findTextInWaitSnapshot(params, text, signal); } async function findTextInMacosNonAppSurface( params: SelectorRuntimeDeviceParams, text: string, + signal?: AbortSignal, ): Promise { if (!isMacOs(params.device)) return null; if (!params.session?.surface || params.session.surface === 'app') return null; - return await findTextInWaitSnapshot(params, text); + return await findTextInWaitSnapshot(params, text, signal); } async function findTextWithAppleRunner( params: SelectorRuntimeDeviceParams, text: string, + signal?: AbortSignal, ): Promise { const target = readAppleRunnerFindTextTarget(params); if (!target) return null; const result = (await runAppleRunnerCommand( target.device, { command: 'findText', text, appBundleId: target.appBundleId }, - buildAppleRunnerFindTextOptions(params, target), + { ...buildAppleRunnerFindTextOptions(params, target), signal }, )) as { found?: boolean }; return result?.found === true; } @@ -230,12 +240,13 @@ function buildAppleRunnerFindTextOptions( async function findTextInWaitSnapshot( params: SelectorRuntimeDeviceParams, text: string, + signal?: AbortSignal, ): Promise { - const snapshot = await captureWaitSnapshot(params); + const snapshot = await captureWaitSnapshot(params, signal); return Boolean(findNodeByLabel(snapshot.nodes, text)); } -async function captureWaitSnapshot(params: SelectorRuntimeDeviceParams) { +async function captureWaitSnapshot(params: SelectorRuntimeDeviceParams, signal?: AbortSignal) { const captureRuntime = createSelectorCaptureRuntime({ device: params.device, session: params.session, @@ -253,6 +264,7 @@ async function captureWaitSnapshot(params: SelectorRuntimeDeviceParams) { // skip scroll-hint derivation (#1270). snapshotIncludeHiddenContentHints: false, }, + signal, cache: { forceFresh: true, bypassForPostGestureStabilization: true, diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 6a40cd1fe..e8a96259c 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -31,6 +31,7 @@ import { withDiagnosticsScope, } from '../../utils/diagnostics.ts'; import { isEnvTruthy } from '../../utils/retry.ts'; +import { resetAndroidSnapshotHelperSessions } from '../../platforms/android/snapshot-helper.ts'; import { acquireDaemonLock, parseIntegerEnv, @@ -386,6 +387,15 @@ export async function startDaemonRuntime( } catch {} expiredProviderLeaseReleaser.beginShutdown(); await teardownDaemonSessions(); + try { + await resetAndroidSnapshotHelperSessions(); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'daemon_shutdown_android_snapshot_helper_cleanup_failed', + data: { error: error instanceof Error ? error.message : String(error) }, + }); + } const providerReleaseDrain = await expiredProviderLeaseReleaser.drain( DAEMON_PROVIDER_RELEASE_DRAIN_TIMEOUT_MS, ); diff --git a/src/daemon/wait-current-surface.test.ts b/src/daemon/wait-current-surface.test.ts new file mode 100644 index 000000000..b30bb5ac8 --- /dev/null +++ b/src/daemon/wait-current-surface.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +const captureSnapshot = vi.hoisted(() => vi.fn()); + +vi.mock('./handlers/snapshot-capture.ts', () => ({ captureSnapshot })); + +import { ANDROID_EMULATOR } from '../__tests__/test-utils/index.ts'; +import { maybeWaitTimeoutSurfaceResponse } from './wait-current-surface.ts'; + +beforeEach(() => { + captureSnapshot.mockReset(); +}); + +test('deadline-truncated wait does not start a post-deadline diagnostic capture', async () => { + const response = { + ok: false as const, + error: { + code: 'COMMAND_FAILED' as const, + message: 'wait timed out for text: Agent Device Tester', + details: { + reason: 'wait_deadline_exceeded', + captureTruncated: true, + timeoutMs: 10_000, + }, + }, + }; + + const result = await maybeWaitTimeoutSurfaceResponse( + { + req: { + command: 'wait', + positionals: ['Agent Device Tester', '10000'], + session: 'android-e2e', + token: 'test-token', + }, + session: undefined, + device: ANDROID_EMULATOR, + }, + response, + ); + + expect(result).toBe(response); + expect(captureSnapshot).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/wait-current-surface.ts b/src/daemon/wait-current-surface.ts index 34277517a..2c7b3e900 100644 --- a/src/daemon/wait-current-surface.ts +++ b/src/daemon/wait-current-surface.ts @@ -24,10 +24,15 @@ export async function maybeWaitTimeoutSurfaceResponse( response: DaemonResponse, ): Promise { if (response.ok || !isWaitTimeoutMessage(response.error.message)) return response; - // A stable wait that just observed a stalled capture must not fire another - // capture for decoration: it would be just as slow (or hung) and push the - // response even further past the user-supplied timeout. - if (response.error.details?.captureStalled === true) return response; + // A wait whose final capture consumed the remaining budget must not fire another capture for + // decoration. A genuinely stalled capture would repeat the hang; an ordinary deadline truncation + // would still push the response further past the user-supplied timeout. + if ( + response.error.details?.captureStalled === true || + response.error.details?.captureTruncated === true + ) { + return response; + } const currentSurface = await inspectCurrentSurface(params).catch(() => null); if (!currentSurface) return response; return errorResponse( diff --git a/src/platforms/__tests__/install-source.coverage.ts b/src/platforms/__tests__/install-source.coverage.ts new file mode 100644 index 000000000..b01333685 --- /dev/null +++ b/src/platforms/__tests__/install-source.coverage.ts @@ -0,0 +1,7 @@ +import { PUBLIC_COMMANDS as C } from '../../command-catalog.ts'; + +export const ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE = { + commands: [C.installFromSource], + owner: 'platforms/install-source', + testName: 'prepareAndroidInstallArtifact resolves package identity for direct APK URL sources', +} as const; diff --git a/src/platforms/__tests__/install-source.test.ts b/src/platforms/__tests__/install-source.test.ts index 4a94f5239..f38cfac3b 100644 --- a/src/platforms/__tests__/install-source.test.ts +++ b/src/platforms/__tests__/install-source.test.ts @@ -22,6 +22,7 @@ import { createLocalAppleToolProvider, withAppleToolProvider, } from '../apple/core/tool-provider.ts'; +import { ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE } from './install-source.coverage.ts'; test('validateDownloadSourceUrl rejects localhost and private literal addresses by default', async () => { await assert.rejects( @@ -181,7 +182,7 @@ test('prepareIosInstallArtifact rejects untrusted URL sources', async () => { ); }); -test('prepareAndroidInstallArtifact resolves package identity for direct APK URL sources', async () => { +test(ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE.testName, async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-direct-apk-url-')); try { const manifestPath = path.join(tempRoot, 'AndroidManifest.xml'); diff --git a/src/platforms/android/__tests__/app-lifecycle-open.test.ts b/src/platforms/android/__tests__/app-lifecycle-open.test.ts index e11074b05..748a66638 100644 --- a/src/platforms/android/__tests__/app-lifecycle-open.test.ts +++ b/src/platforms/android/__tests__/app-lifecycle-open.test.ts @@ -310,7 +310,7 @@ test('openAndroidApp ensures Android reverse before IPv6 localhost deep link lau '-a', 'android.intent.action.VIEW', '-d', - 'http://[::1]:8081/status', + "'http://[::1]:8081/status'", ], }, ]); @@ -526,6 +526,18 @@ test('openAndroidApp appends launchArgs to am start for deep link URL opens', as ); }); +test('openAndroidApp quotes deep link URL shell characters', async () => { + await withScriptedAdb( + 'agent-device-android-open-deep-link-shell-characters-', + androidOpenAdbScript(), + async ({ argsLogPath, device }) => { + await openAndroidApp(device, 'myapp://item/42?event=cold.start&source=smoke'); + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.match(logged, /-d\n'myapp:\/\/item\/42\?event=cold\.start&source=smoke'/); + }, + ); +}); + test('openAndroidApp appends launchArgs to am start for app-bound URL opens', async () => { await withScriptedAdb( 'agent-device-android-open-launch-args-app-bound-url-', diff --git a/src/platforms/android/__tests__/snapshot-helper-capture.test.ts b/src/platforms/android/__tests__/snapshot-helper-capture.test.ts new file mode 100644 index 000000000..f6927689c --- /dev/null +++ b/src/platforms/android/__tests__/snapshot-helper-capture.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { beforeEach, test } from 'vitest'; +import { captureAndroidSnapshotWithHelper } from '../snapshot-helper-capture.ts'; +import { resetAndroidSnapshotHelperRetirements } from '../snapshot-helper-retirement.ts'; +import type { AndroidAdbExecutor } from '../snapshot-helper-types.ts'; + +beforeEach(() => { + resetAndroidSnapshotHelperRetirements(); +}); + +test('one-shot capture that resolves during cancellation retires before rejecting', async () => { + const controller = new AbortController(); + const cancellation = new Error('wait deadline exceeded'); + const events: string[] = []; + let releaseRetirement: (() => void) | undefined; + const retirementCanFinish = new Promise((resolve) => { + releaseRetirement = resolve; + }); + const adb: AndroidAdbExecutor = async (args, options) => { + if (args.join(' ').includes('am instrument')) { + return await new Promise((resolve) => { + const onAbort = () => { + events.push('instrumentation-resolved'); + resolve({ + exitCode: 0, + stdout: helperOutput(''), + stderr: '', + }); + }; + options?.signal?.addEventListener('abort', onAbort, { once: true }); + if (options?.signal?.aborted) onAbort(); + }); + } + assert.deepEqual(args, [ + 'shell', + 'am', + 'force-stop', + 'com.callstack.agentdevice.snapshothelper', + ]); + assert.notEqual(options?.signal, controller.signal); + assert.equal(options?.signal?.aborted, false); + events.push('retirement-started'); + await retirementCanFinish; + events.push('retirement-finished'); + return { exitCode: 0, stdout: '', stderr: '' }; + }; + const capture = captureAndroidSnapshotWithHelper({ + adb, + deviceKey: 'android:emulator-5554', + signal: controller.signal, + }); + let rejected = false; + void capture.catch(() => { + rejected = true; + events.push('capture-rejected'); + }); + + controller.abort(cancellation); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(rejected, false); + assert.deepEqual(events, ['instrumentation-resolved', 'retirement-started']); + + releaseRetirement?.(); + await assert.rejects(capture, cancellation); + assert.deepEqual(events, [ + 'instrumentation-resolved', + 'retirement-started', + 'retirement-finished', + 'capture-rejected', + ]); +}); + +test('uncertain one-shot retirement is quarantined until the next capture recovers it', async () => { + const controller = new AbortController(); + const events: string[] = []; + let forceStopCount = 0; + const adb: AndroidAdbExecutor = async (args, options) => { + if (args.join(' ').includes('am force-stop')) { + forceStopCount += 1; + events.push(`force-stop-${forceStopCount}`); + return { + exitCode: forceStopCount === 1 ? 1 : 0, + stdout: '', + stderr: forceStopCount === 1 ? 'runtime still busy' : '', + }; + } + events.push(`instrument-${forceStopCount}`); + if (forceStopCount === 0) { + return await new Promise((_resolve, reject) => { + const onAbort = () => reject(options?.signal?.reason); + options?.signal?.addEventListener('abort', onAbort, { once: true }); + if (options?.signal?.aborted) onAbort(); + }); + } + return { + exitCode: 0, + stdout: helperOutput(''), + stderr: '', + }; + }; + const canceledCapture = captureAndroidSnapshotWithHelper({ + adb, + deviceKey: 'android:emulator-5554', + signal: controller.signal, + }); + controller.abort(new Error('wait deadline exceeded')); + + await assert.rejects( + canceledCapture, + (error: unknown) => + (error as { details?: { reason?: string } }).details?.reason === + 'android_snapshot_helper_retirement_unconfirmed', + ); + const recovered = await captureAndroidSnapshotWithHelper({ + adb, + deviceKey: 'android:emulator-5554', + }); + + assert.match(recovered.xml, /recovered/); + assert.deepEqual(events, ['instrument-0', 'force-stop-1', 'force-stop-2', 'instrument-2']); +}); + +function helperOutput(xml: string): string { + return [ + 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_STATUS: helperApiVersion=1', + 'INSTRUMENTATION_STATUS: outputFormat=uiautomator-xml', + 'INSTRUMENTATION_STATUS: chunkIndex=0', + 'INSTRUMENTATION_STATUS: chunkCount=1', + `INSTRUMENTATION_STATUS: payloadBase64=${Buffer.from(xml, 'utf8').toString('base64')}`, + 'INSTRUMENTATION_STATUS_CODE: 1', + 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_RESULT: helperApiVersion=1', + 'INSTRUMENTATION_RESULT: ok=true', + 'INSTRUMENTATION_RESULT: outputFormat=uiautomator-xml', + 'INSTRUMENTATION_CODE: 0', + ].join('\n'); +} diff --git a/src/platforms/android/__tests__/snapshot-helper-retirement.test.ts b/src/platforms/android/__tests__/snapshot-helper-retirement.test.ts new file mode 100644 index 000000000..c68542baa --- /dev/null +++ b/src/platforms/android/__tests__/snapshot-helper-retirement.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { beforeEach, test } from 'vitest'; +import { + recoverAndroidSnapshotHelperRetirement, + resetAndroidSnapshotHelperRetirements, + retireCanceledAndroidSnapshotHelperCapture, +} from '../snapshot-helper-retirement.ts'; +import type { AndroidAdbExecutor } from '../snapshot-helper-types.ts'; + +beforeEach(() => { + resetAndroidSnapshotHelperRetirements(); +}); + +test('requires positive recovery evidence after uncertain runtime retirement', async () => { + const calls: string[][] = []; + let forceStopCount = 0; + const adb: AndroidAdbExecutor = async (args) => { + calls.push(args); + forceStopCount += 1; + return { + exitCode: forceStopCount === 1 ? 1 : 0, + stdout: '', + stderr: forceStopCount === 1 ? 'runtime still busy' : '', + }; + }; + + await assert.rejects( + retireCanceledAndroidSnapshotHelperCapture({ + deviceKey: 'android:emulator-5554', + packageName: 'com.callstack.agentdevice.snapshothelper', + adb, + cause: new Error('capture canceled'), + }), + (error: unknown) => + (error as { details?: { reason?: string } }).details?.reason === + 'android_snapshot_helper_retirement_unconfirmed', + ); + await recoverAndroidSnapshotHelperRetirement({ + deviceKey: 'android:emulator-5554', + adb, + }); + await recoverAndroidSnapshotHelperRetirement({ + deviceKey: 'android:emulator-5554', + adb, + }); + + assert.equal(forceStopCount, 2); + assert.deepEqual( + calls, + Array.from({ length: 2 }, () => [ + 'shell', + 'am', + 'force-stop', + 'com.callstack.agentdevice.snapshothelper', + ]), + ); +}); diff --git a/src/platforms/android/__tests__/snapshot-helper-session-protocol.test.ts b/src/platforms/android/__tests__/snapshot-helper-session-protocol.test.ts new file mode 100644 index 000000000..557e6c634 --- /dev/null +++ b/src/platforms/android/__tests__/snapshot-helper-session-protocol.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + assertAndroidSnapshotHelperTouchSessionHeaders, + isAndroidSnapshotHelperSessionCommandAcknowledged, + parseAndroidSnapshotHelperSessionHeaders, + parseAndroidSnapshotHelperSessionSnapshotResponse, +} from '../snapshot-helper-session-protocol.ts'; + +test('parses the session envelope and snapshot metadata', () => { + const xml = ''; + const response = sessionResponse({ + requestId: 'snapshot-1', + xml, + metadata: { + captureMode: 'interactive-windows', + windowCount: '2', + nodeCount: '1', + }, + }); + + assert.deepEqual(parseAndroidSnapshotHelperSessionSnapshotResponse(response, 'snapshot-1'), { + xml, + metadata: { + helperApiVersion: '1', + outputFormat: 'uiautomator-xml', + captureMode: 'interactive-windows', + windowCount: 2, + nodeCount: 1, + waitForIdleTimeoutMs: undefined, + waitForIdleQuietMs: undefined, + timeoutMs: undefined, + maxDepth: undefined, + maxNodes: undefined, + rootPresent: undefined, + truncated: undefined, + elapsedMs: undefined, + }, + }); +}); + +test('rejects stale and truncated session snapshot responses', () => { + const response = sessionResponse({ + requestId: 'snapshot-old', + xml: '', + }); + assert.throws( + () => parseAndroidSnapshotHelperSessionSnapshotResponse(response, 'snapshot-new'), + /stale output/, + ); + assert.throws( + () => + parseAndroidSnapshotHelperSessionSnapshotResponse( + response.replace('byteLength=23', 'byteLength=99'), + 'snapshot-old', + ), + /truncated XML/, + ); +}); + +test('validates touch and quit response identity from the same protocol headers', () => { + const response = sessionResponse({ requestId: 'gesture-1', xml: '' }); + const headers = parseAndroidSnapshotHelperSessionHeaders(response); + + assertAndroidSnapshotHelperTouchSessionHeaders(headers, 'gesture-1'); + assert.equal(isAndroidSnapshotHelperSessionCommandAcknowledged(response, 'gesture-1'), true); + assert.throws( + () => assertAndroidSnapshotHelperTouchSessionHeaders(headers, 'gesture-stale'), + /stale output/, + ); +}); + +function sessionResponse(params: { + requestId: string; + xml: string; + metadata?: Record; +}): string { + const headers = { + agentDeviceProtocol: 'android-snapshot-helper-v1', + helperApiVersion: '1', + outputFormat: 'uiautomator-xml', + requestId: params.requestId, + ok: 'true', + byteLength: String(Buffer.byteLength(params.xml, 'utf8')), + ...params.metadata, + }; + return `${Object.entries(headers) + .map(([key, value]) => `${key}=${value}`) + .join('\n')}\n\n${params.xml}`; +} diff --git a/src/platforms/android/__tests__/snapshot-helper-session.test.ts b/src/platforms/android/__tests__/snapshot-helper-session.test.ts index 26cb0d2ee..9c8675be8 100644 --- a/src/platforms/android/__tests__/snapshot-helper-session.test.ts +++ b/src/platforms/android/__tests__/snapshot-helper-session.test.ts @@ -6,8 +6,9 @@ import { afterEach, beforeEach, test } from 'vitest'; import { captureAndroidSnapshotWithHelperSession, resetAndroidSnapshotHelperSessions, - resolveAndroidSnapshotHelperSessionRequestTimeoutMs, } from '../snapshot-helper-session.ts'; +import { resolveAndroidSnapshotHelperSessionRequestTimeoutMs } from '../snapshot-helper-session-protocol.ts'; +import { recoverAndroidSnapshotHelperRetirement } from '../snapshot-helper-retirement.ts'; import type { AndroidAdbExecutor, AndroidAdbProcess, AndroidAdbProvider } from '../adb-executor.ts'; beforeEach(async () => { @@ -77,6 +78,7 @@ test('disables repeated persistent session attempts after startup failure', asyn assert.equal(first, undefined); assert.equal(second, undefined); assert.equal(spawnArgs.length, 1); + assert.equal(readSessionArgument(spawnArgs[0]!, 'timeoutMs'), '2000'); assert.equal(calls.filter((args) => args[0] === 'forward').length, 2); }); @@ -123,7 +125,7 @@ test('allows a persistent session snapshot to use the helper command budget', as timeoutMs: 10, commandTimeoutMs: 4_000, }), - 4_000, + 3_010, ); const output = await captureAndroidSnapshotWithHelperSession({ @@ -139,28 +141,118 @@ test('allows a persistent session snapshot to use the helper command budget', as assert.equal(output?.metadata.sessionReused, false); }); -test('caps a persistent session snapshot at the helper command budget', async () => { +test('retires a persistent session that exceeds the helper command budget', async () => { const calls: string[][] = []; const provider = createSessionProvider({ calls, responseDelayMs: 50 }); + const output = await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + timeoutMs: 10, + commandTimeoutMs: 20, + }); + + assert.equal(output, undefined); + assert.equal( + calls.some((args) => args[0] === 'forward' && args[1] === '--remove'), + true, + ); +}); + +test('cancels a stalled snapshot, retires its helper, and starts the next capture cleanly', async () => { + const calls: string[][] = []; + const processes: FakeAndroidProcess[] = []; + const provider = createSessionProvider({ calls, processes, stalledSnapshots: 1 }); + const controller = new AbortController(); + const capture = captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + signal: controller.signal, + }); + setTimeout(() => controller.abort(new Error('wait deadline exceeded')), 10); + + await assert.rejects(capture, /wait deadline exceeded/); + assert.equal(processes[0]?.killed, true); + assert.equal( + calls.some((args) => args[0] === 'forward' && args[1] === '--remove'), + true, + ); + + const next = await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + }); + assert.match(next?.xml ?? '', /snapshot 1/); + assert.equal(next?.metadata.sessionReused, false); + assert.equal(processes.length, 2); +}); + +test('canceled capture joins canceled external cleanup before returning', async () => { + const calls: string[][] = []; + const cleanupAborts: string[][] = []; + const provider = createSessionProvider({ + calls, + cleanupAborts, + stalledSnapshots: 1, + stalledCleanup: true, + }); + const controller = new AbortController(); + const capture = captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + signal: controller.signal, + }); + setTimeout(() => controller.abort(new Error('wait deadline exceeded')), 10); + + const outcome = await Promise.race([ + capture.then( + () => 'resolved', + () => 'rejected', + ), + new Promise<'still-pending'>((resolve) => setTimeout(() => resolve('still-pending'), 750)), + ]); + + assert.equal(outcome, 'rejected'); + assert.equal(cleanupAborts.length, 2); +}); + +test('failed capture does not fall back when device runtime retirement is unconfirmed', async () => { + const calls: string[][] = []; + const processes: FakeAndroidProcess[] = []; + const provider = createSessionProvider({ + calls, + processes, + recoveryFailure: true, + responseMode: 'malformed', + stalledCleanup: true, + }); + await assert.rejects( - () => - captureAndroidSnapshotWithHelperSession({ - adb: provider.exec, - adbProvider: provider, - deviceKey: 'android:emulator-5554', - timeoutMs: 10, - commandTimeoutMs: 20, - }), - (error) => { - assert.equal((error as Error).message, 'Android snapshot helper session request timed out'); - const details = (error as { details?: Record }).details; - assert.equal(details?.timeoutMs, 20); - assert.match(String(details?.command), /^snapshot snapshot-/); - assert.equal(typeof details?.port, 'number'); - return true; - }, + captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + }), + (error: unknown) => + (error as { details?: { reason?: string } }).details?.reason === + 'android_snapshot_helper_retirement_unconfirmed', + ); + + await assert.rejects( + captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: { exec: provider.exec }, + deviceKey: 'android:emulator-5554', + }), + (error: unknown) => + (error as { details?: { reason?: string } }).details?.reason === + 'android_snapshot_helper_retirement_unconfirmed', ); + assert.equal(processes.length, 1); }); test('restarts the helper session when capture options change', async () => { @@ -189,58 +281,181 @@ test('restarts the helper session when capture options change', async () => { ); }); -test('invalidates the helper session after a malformed response', async () => { +test('allows an acknowledged helper quit to release UiAutomation before forcing termination', async () => { const calls: string[][] = []; - const provider = createSessionProvider({ calls, responseMode: 'malformed' }); + const processes: FakeAndroidProcess[] = []; + const provider = createSessionProvider({ calls, processes, quitExitDelayMs: 25 }); + + await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + }); + await resetAndroidSnapshotHelperSessions(); + + assert.equal(processes.length, 1); + assert.equal(processes[0]?.killed, false); + assert.equal( + calls.some( + (args) => args.join(' ') === 'shell am force-stop com.callstack.agentdevice.snapshothelper', + ), + true, + ); +}); + +test('force terminates the helper when quit is not acknowledged', async () => { + const calls: string[][] = []; + const processes: FakeAndroidProcess[] = []; + const provider = createSessionProvider({ calls, processes, quitResponseMode: 'malformed' }); + + await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + }); + await resetAndroidSnapshotHelperSessions(); + assert.equal(processes.length, 1); + assert.equal(processes[0]?.killed, true); +}); + +test('failed whole-module reset preserves quarantine until recovery is confirmed', async () => { + const options: SessionProviderOptions = { + calls: [], + quitResponseMode: 'malformed', + recoveryFailure: true, + }; + const provider = createSessionProvider(options); + const deviceKey = 'android:emulator-5554'; + + await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey, + }); await assert.rejects( - () => - captureAndroidSnapshotWithHelperSession({ - adb: provider.exec, - adbProvider: provider, - deviceKey: 'android:emulator-5554', - }), - { - message: 'Android snapshot helper session returned malformed output', - }, + resetAndroidSnapshotHelperSessions(), + /Failed to retire every Android snapshot helper session/, + ); + const forceStopsBeforeRecovery = options.calls.filter((args) => + args.join(' ').includes('am force-stop'), + ).length; + + options.recoveryFailure = false; + await recoverAndroidSnapshotHelperRetirement({ + deviceKey, + adb: provider.exec, + }); + + assert.equal( + options.calls.filter((args) => args.join(' ').includes('am force-stop')).length, + forceStopsBeforeRecovery + 1, ); +}); + +test('allows device retirement beyond host-process grace before falling back', async () => { + const calls: string[][] = []; + const processes: FakeAndroidProcess[] = []; + const provider = createSessionProvider({ + calls, + processes, + responseMode: 'ui-automation-timeout', + forceStopDelayMs: 300, + }); + + const output = await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + }); + + assert.equal(output, undefined); + assert.equal(processes[0]?.killed, true); +}); + +test('invalidates and falls back from the helper session after a malformed response', async () => { + const calls: string[][] = []; + const provider = createSessionProvider({ calls, responseMode: 'malformed' }); + + const output = await captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + }); + assert.equal(output, undefined); assert.equal( calls.some((args) => args[0] === 'forward' && args[1] === '--remove'), true, ); }); -function createSessionProvider(options: { +type SessionProviderOptions = { calls: string[][]; + cleanupAborts?: string[][]; + processes?: FakeAndroidProcess[]; + quitExitDelayMs?: number; + quitResponseMode?: 'ok' | 'malformed'; spawnArgs?: string[][]; - responseMode?: 'ok' | 'malformed'; + responseMode?: 'ok' | 'malformed' | 'ui-automation-timeout'; responseDelayMs?: number; -}): AndroidAdbProvider { + forceStopDelayMs?: number; + recoveryFailure?: boolean; + stalledCleanup?: boolean; + stalledSnapshots?: number; +}; + +function createSessionProvider(options: SessionProviderOptions): AndroidAdbProvider { + let stalledSnapshots = options.stalledSnapshots ?? 0; return { - exec: async (args) => { - options.calls.push(args); - return { exitCode: 0, stdout: '', stderr: '' }; - }, + exec: createSessionExec(options), spawn: (args) => { options.spawnArgs?.push(args); const port = readSessionPort(args); const process = new FakeAndroidProcess(); + options.processes?.push(process); let snapshotCount = 0; + const sockets = new Set(); const server = net.createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); socket.once('data', (chunk) => { const command = chunk.toString('utf8').trim(); const [, requestId = ''] = command.split(/\s+/, 2); if (command.startsWith('quit')) { + if (options.quitResponseMode === 'malformed') { + socket.end('not a session response'); + return; + } socket.end(sessionResponse({ requestId, body: '' })); - server.close(() => process.emitExit(0, null)); + server.close(() => { + setTimeout(() => process.emitExit(0, null), options.quitExitDelayMs ?? 0); + }); return; } if (options.responseMode === 'malformed') { socket.end('not a session response'); return; } + if (options.responseMode === 'ui-automation-timeout') { + socket.end( + sessionResponse({ + requestId, + body: '', + metadata: { + ok: 'false', + errorType: 'java.util.concurrent.TimeoutException', + message: 'Timed out waiting for Android UiAutomation to connect', + }, + }), + ); + return; + } snapshotCount += 1; + if (stalledSnapshots > 0) { + stalledSnapshots -= 1; + return; + } const body = ``; setTimeout(() => { socket.end( @@ -276,13 +491,73 @@ function createSessionProvider(options: { ); }); process.onKill = () => { - server.close(() => process.emitExit(0, null)); + for (const socket of sockets) socket.destroy(); + if (server.listening) { + server.close(() => process.emitExit(0, null)); + } else { + process.emitExit(0, null); + } }; return process; }, }; } +function createSessionExec(options: SessionProviderOptions): AndroidAdbExecutor { + return async (args, execOptions) => { + options.calls.push(args); + const forceStopsRuntime = args.join(' ').includes('am force-stop'); + await stallSessionCleanupIfConfigured(options, args, execOptions?.signal, forceStopsRuntime); + if (options.recoveryFailure && forceStopsRuntime) { + return { exitCode: 1, stdout: '', stderr: 'runtime still busy' }; + } + await delayForceStopIfConfigured(options, execOptions?.signal, forceStopsRuntime); + return { exitCode: 0, stdout: '', stderr: '' }; + }; +} + +async function stallSessionCleanupIfConfigured( + options: SessionProviderOptions, + args: string[], + signal: AbortSignal | undefined, + forceStopsRuntime: boolean, +): Promise { + const removesForward = args[0] === 'forward' && args[1] === '--remove'; + if (!options.stalledCleanup || !signal || (!removesForward && !forceStopsRuntime)) return; + await new Promise((_resolve, reject) => { + const onAbort = () => { + options.cleanupAborts?.push(args); + reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); +} + +async function delayForceStopIfConfigured( + options: SessionProviderOptions, + signal: AbortSignal | undefined, + forceStopsRuntime: boolean, +): Promise { + if (!forceStopsRuntime || !options.forceStopDelayMs) return; + await waitForDelay(options.forceStopDelayMs, signal); +} + +async function waitForDelay(delayMs: number, signal: AbortSignal | undefined): Promise { + await new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, delayMs); + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + }); +} + function sessionResponse(params: { requestId: string; body: string; @@ -309,10 +584,17 @@ function readSessionPort(args: string[]): number { return Number(args[index + 1]); } +function readSessionArgument(args: string[], name: string): string | undefined { + const index = args.indexOf(name); + return index < 0 ? undefined : args[index + 1]; +} + class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess { stdin = new PassThrough(); stdout = new PassThrough(); stderr = new PassThrough(); + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; killed = false; onKill: (() => void) | undefined; @@ -323,6 +605,8 @@ class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess { } emitExit(code: number | null, signal: NodeJS.Signals | null): void { + this.exitCode = code; + this.signalCode = signal; this.emit('exit', code, signal); this.emit('close', code, signal); } diff --git a/src/platforms/android/__tests__/snapshot-helper.test.ts b/src/platforms/android/__tests__/snapshot-helper.test.ts index c9bae04a4..137da7a14 100644 --- a/src/platforms/android/__tests__/snapshot-helper.test.ts +++ b/src/platforms/android/__tests__/snapshot-helper.test.ts @@ -21,6 +21,7 @@ import type { AndroidSnapshotHelperManifest, } from '../snapshot-helper-types.ts'; import type { AndroidAdbProvider } from '../adb-executor.ts'; +import { resetAndroidSnapshotHelperRetirements } from '../snapshot-helper-retirement.ts'; const manifest: AndroidSnapshotHelperManifest = { name: 'android-snapshot-helper', @@ -39,6 +40,7 @@ const manifest: AndroidSnapshotHelperManifest = { beforeEach(() => { resetAndroidSnapshotHelperInstallCache(); + resetAndroidSnapshotHelperRetirements(); }); test('parseAndroidSnapshotHelperOutput reconstructs XML chunks and metadata', () => { diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts index dddd49f35..2652d42a9 100644 --- a/src/platforms/android/__tests__/snapshot.test.ts +++ b/src/platforms/android/__tests__/snapshot.test.ts @@ -98,24 +98,25 @@ function helperAdbOperation(args: string[]): 'instrument' | 'activity' | undefin return args.includes('dumpsys') && args.includes('activity') ? 'activity' : undefined; } -function createPersistentSnapshotHelperProvider(options: { +type PersistentSnapshotHelperProviderOptions = { calls: string[][]; spawnArgs: string[][]; - killedProcesses: FakeAndroidProcess[]; -}): AndroidAdbProvider { + processes: FakeAndroidProcess[]; + sessionResponseMode?: 'ok' | 'malformed'; + stalledSessionCleanup?: boolean; + oneShotAttempts?: string[][]; + oneShotXml?: string; +}; + +function createPersistentSnapshotHelperProvider( + options: PersistentSnapshotHelperProviderOptions, +): AndroidAdbProvider { return { - exec: async (args) => { - options.calls.push(args); - if (args.includes('--show-versioncode')) return installedHelperProbe; - if (args[0] === 'forward') return { exitCode: 0, stdout: '', stderr: '' }; - if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'force-stop') { - return { exitCode: 0, stdout: '', stderr: '' }; - } - throw new Error(`unexpected persistent helper adb args: ${args.join(' ')}`); - }, + exec: createPersistentSnapshotExec(options), spawn: (args) => { options.spawnArgs.push(args); const process = new FakeAndroidProcess(); + options.processes.push(process); const port = readSessionPort(args); let snapshotCount = 0; const server = net.createServer((socket) => { @@ -124,6 +125,11 @@ function createPersistentSnapshotHelperProvider(options: { const [, requestId = ''] = command.split(/\s+/, 2); if (command.startsWith('quit')) { socket.end(sessionResponse({ requestId, body: '' })); + server.close(() => process.emitExit(0, null)); + return; + } + if (options.sessionResponseMode === 'malformed') { + socket.end('malformed session response'); return; } snapshotCount += 1; @@ -160,7 +166,6 @@ function createPersistentSnapshotHelperProvider(options: { ); }); process.onKill = () => { - options.killedProcesses.push(process); server.close(() => process.emitExit(0, null)); }; return process; @@ -168,6 +173,61 @@ function createPersistentSnapshotHelperProvider(options: { }; } +function createPersistentSnapshotExec( + options: PersistentSnapshotHelperProviderOptions, +): AndroidAdbExecutor { + return async (args, execOptions) => { + options.calls.push(args); + const stalledCleanup = stalledPersistentCleanup(options, args, execOptions?.signal); + if (stalledCleanup) return await stalledCleanup; + return persistentSnapshotExecResult(options, args); + }; +} + +function stalledPersistentCleanup( + options: PersistentSnapshotHelperProviderOptions, + args: string[], + signal: AbortSignal | undefined, +): ReturnType | undefined { + if (!options.stalledSessionCleanup || !signal) return undefined; + const removesForward = args[0] === 'forward' && args[1] === '--remove'; + const forceStopsRuntime = args[0] === 'shell' && args[1] === 'am' && args[2] === 'force-stop'; + return removesForward || forceStopsRuntime ? rejectWhenAborted(signal) : undefined; +} + +function persistentSnapshotExecResult( + options: PersistentSnapshotHelperProviderOptions, + args: string[], +): ReturnType { + if (args.includes('--show-versioncode')) return Promise.resolve(installedHelperProbe); + if (args[0] === 'forward' || isHelperRuntimeReset(args)) { + return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); + } + if (args.includes('instrument')) { + options.oneShotAttempts?.push(args); + if (options.oneShotXml) { + return Promise.resolve({ + exitCode: 0, + stdout: helperOutput(options.oneShotXml), + stderr: '', + }); + } + } + return Promise.reject(new Error(`unexpected persistent helper adb args: ${args.join(' ')}`)); +} + +function rejectWhenAborted(signal: AbortSignal): Promise<{ + exitCode: number; + stdout: string; + stderr: string; +}> { + return new Promise((_resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); +} + function sessionResponse(params: { requestId: string; body: string; @@ -197,6 +257,8 @@ class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess { stdin = new PassThrough(); stdout = new PassThrough(); stderr = new PassThrough(); + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; killed = false; onKill: (() => void) | undefined; @@ -208,6 +270,8 @@ class FakeAndroidProcess extends EventEmitter implements AndroidAdbProcess { } emitExit(code: number | null, signal: NodeJS.Signals | null): void { + this.exitCode = code; + this.signalCode = signal; this.emit('exit', code, signal); this.emit('close', code, signal); } @@ -515,35 +579,6 @@ test('snapshotAndroid reports helper-side truncation on the public snapshot resu assert.equal(result.androidSnapshot.helperTruncated, true); }); -test('snapshotAndroid forwards alert-style helper idle timeout override', async () => { - let instrumentArgs: string[] | undefined; - const helperAdb: AndroidAdbExecutor = async (args) => { - if (args.includes('--show-versioncode')) { - return installedHelperProbe; - } - if (args.includes('instrument')) { - instrumentArgs = args; - return { - exitCode: 0, - stdout: helperOutput(''), - stderr: '', - }; - } - throw new Error(`unexpected helper adb args: ${args.join(' ')}`); - }; - - await snapshotAndroid(device, { - helperAdb, - helperArtifact, - helperWaitForIdleTimeoutMs: 0, - }); - - assert.ok(instrumentArgs); - assert.equal(instrumentArgs[instrumentArgs.indexOf('waitForIdleTimeoutMs') + 1], '0'); - assert.equal(instrumentArgs.includes('outputPath'), false); - assert.equal(instrumentArgs.includes('emitChunks'), false); -}); - test('snapshotAndroid emits helper phase diagnostics', async () => { const helperAdb: AndroidAdbExecutor = async (args) => { if (args.includes('--show-versioncode')) { @@ -631,11 +666,11 @@ test('snapshotAndroid resolves helper adb through scoped provider', async () => test('snapshotAndroid stops command-scoped persistent helper session after capture', async () => { const adbCalls: string[][] = []; const spawnArgs: string[][] = []; - const killedProcesses: FakeAndroidProcess[] = []; + const processes: FakeAndroidProcess[] = []; const provider = createPersistentSnapshotHelperProvider({ calls: adbCalls, spawnArgs, - killedProcesses, + processes, }); const result = await snapshotAndroid(device, { @@ -647,7 +682,8 @@ test('snapshotAndroid stops command-scoped persistent helper session after captu assert.equal(result.androidSnapshot.helperTransport, 'persistent-session'); assert.equal(result.androidSnapshot.helperSessionReused, false); assert.equal(spawnArgs.length, 1); - assert.equal(killedProcesses.length, 1); + assert.equal(processes[0]?.exitCode, 0); + assert.equal(processes[0]?.killed, false); assert.equal( adbCalls.some((args) => args[0] === 'forward' && args[1] === '--remove'), true, @@ -657,11 +693,11 @@ test('snapshotAndroid stops command-scoped persistent helper session after captu test('snapshotAndroid keeps daemon-session helper alive for reuse until session cleanup', async () => { const adbCalls: string[][] = []; const spawnArgs: string[][] = []; - const killedProcesses: FakeAndroidProcess[] = []; + const processes: FakeAndroidProcess[] = []; const provider = createPersistentSnapshotHelperProvider({ calls: adbCalls, spawnArgs, - killedProcesses, + processes, }); const first = await snapshotAndroid(device, { @@ -679,7 +715,8 @@ test('snapshotAndroid keeps daemon-session helper alive for reuse until session assert.equal(second.androidSnapshot.helperSessionReused, true); assert.equal(second.nodes[0]?.label, 'persistent helper snapshot 2'); assert.equal(spawnArgs.length, 1); - assert.equal(killedProcesses.length, 0); + assert.equal(processes[0]?.exitCode, null); + assert.equal(processes[0]?.killed, false); assert.equal( adbCalls.some((args) => args[0] === 'forward' && args[1] === '--remove'), false, @@ -687,13 +724,66 @@ test('snapshotAndroid keeps daemon-session helper alive for reuse until session await resetAndroidSnapshotHelperSessions(); - assert.equal(killedProcesses.length, 1); + assert.equal(processes[0]?.exitCode, 0); + assert.equal(processes[0]?.killed, false); assert.equal( adbCalls.some((args) => args[0] === 'forward' && args[1] === '--remove'), true, ); }); +test('snapshotAndroid falls back to one-shot capture after retiring a failed session', async () => { + const adbCalls: string[][] = []; + const spawnArgs: string[][] = []; + const processes: FakeAndroidProcess[] = []; + const provider = createPersistentSnapshotHelperProvider({ + calls: adbCalls, + spawnArgs, + processes, + sessionResponseMode: 'malformed', + oneShotXml: '', + }); + + const result = await snapshotAndroid(device, { + helperAdb: provider, + helperArtifact, + helperSessionScope: 'daemon-session', + }); + + assert.equal(result.nodes[0]?.label, 'one-shot fallback'); + assert.equal(result.androidSnapshot.helperTransport, 'instrumentation'); + assert.equal(processes[0]?.killed, true); + assert.equal( + adbCalls.some((args) => args[0] === 'forward' && args[1] === '--remove'), + true, + ); +}); + +test('snapshotAndroid does not start one-shot capture when session retirement is unconfirmed', async () => { + const adbCalls: string[][] = []; + const oneShotAttempts: string[][] = []; + const provider = createPersistentSnapshotHelperProvider({ + calls: adbCalls, + spawnArgs: [], + processes: [], + sessionResponseMode: 'malformed', + stalledSessionCleanup: true, + oneShotAttempts, + oneShotXml: '', + }); + + await assert.rejects( + snapshotAndroid(device, { + helperAdb: provider, + helperArtifact, + helperSessionScope: 'daemon-session', + }), + /could not confirm release of device automation ownership/, + ); + + assert.equal(oneShotAttempts.length, 0); +}); + test('snapshotAndroid fails closed when the helper fails', async () => { const adbCalls: string[][] = []; const helperAdb: AndroidAdbExecutor = async (args) => { diff --git a/src/platforms/android/__tests__/touch-helper-session.test.ts b/src/platforms/android/__tests__/touch-helper-session.test.ts index 2c040d876..34dce4b2b 100644 --- a/src/platforms/android/__tests__/touch-helper-session.test.ts +++ b/src/platforms/android/__tests__/touch-helper-session.test.ts @@ -107,9 +107,23 @@ function snapshotSessionResponse(requestId: string): string { function createFakeTouchHelperSessionProvider( handleCommand: TouchSessionCommandHandler, + options: { stallCleanup?: boolean } = {}, ): AndroidAdbProvider { return { - exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }), + exec: async (args, execOptions) => { + const cleanupCommand = + (args[0] === 'forward' && args[1] === '--remove') || + (args[0] === 'shell' && args[1] === 'am' && args[2] === 'force-stop'); + const signal = execOptions?.signal; + if (options.stallCleanup && cleanupCommand && signal) { + return await new Promise((_resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); + } + return { exitCode: 0, stdout: '', stderr: '' }; + }, spawn: (args) => { const port = readSessionPort(args); const process = new FakeAndroidProcess(); @@ -117,6 +131,17 @@ function createFakeTouchHelperSessionProvider( socket.once('data', (chunk) => { const command = chunk.toString('utf8').trim(); const [, requestId = ''] = command.split(/\s+/, 2); + if (command.startsWith('quit')) { + socket.end( + sessionHeaderResponse({ + agentDeviceProtocol: 'android-snapshot-helper-v1', + requestId, + ok: 'true', + }), + ); + server.close(() => process.emitExit(0, null)); + return; + } socket.end(handleCommand(command, requestId)); }); }); @@ -138,6 +163,47 @@ function createFakeTouchHelperSessionProvider( }; } +test('touch helper does not run one-shot while snapshot retirement is unconfirmed', async () => { + const device = makeIsolatedDevice(); + const deviceKey = getAndroidSnapshotHelperSessionDeviceKey(device); + const provider = createFakeTouchHelperSessionProvider(() => 'malformed snapshot response', { + stallCleanup: true, + }); + + await assert.rejects( + captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey, + }), + (error: unknown) => + (error as { details?: { reason?: string } }).details?.reason === + 'android_snapshot_helper_retirement_unconfirmed', + ); + + let oneShotCalled = false; + await assert.rejects( + withAndroidAdbProvider( + { + exec: currentVersionAdb(async (args) => { + if (args[0] === 'shell' && args[1] === 'am' && args[2] === 'force-stop') { + return { exitCode: 1, stdout: '', stderr: 'runtime still busy' }; + } + if (args.includes('instrument')) oneShotCalled = true; + return { exitCode: 0, stdout: '', stderr: '' }; + }), + }, + { serial: device.id }, + async () => await executeAndroidTouchHelperPlan(device, flingPlan()), + ), + (error: unknown) => + (error as { details?: { reason?: string } }).details?.reason === + 'android_snapshot_helper_retirement_unconfirmed', + ); + + assert.equal(oneShotCalled, false); +}); + async function startFakeTouchHelperSession( device: DeviceInfo, handleCommand: TouchSessionCommandHandler, diff --git a/src/platforms/android/adb-executor.ts b/src/platforms/android/adb-executor.ts index 2167f7be1..ec0395f1d 100644 --- a/src/platforms/android/adb-executor.ts +++ b/src/platforms/android/adb-executor.ts @@ -30,6 +30,8 @@ export type AndroidAdbExecutorResult = Pick< export type AndroidAdbProcess = { pid?: number; + exitCode?: number | null; + signalCode?: NodeJS.Signals | null; stdin: Writable | null; stdout: Readable | null; stderr: Readable | null; diff --git a/src/platforms/android/alert.ts b/src/platforms/android/alert.ts index 837848402..97f8c7cf8 100644 --- a/src/platforms/android/alert.ts +++ b/src/platforms/android/alert.ts @@ -126,7 +126,6 @@ async function readAndroidAlertCandidate( 'snapshot_capture', async () => await snapshotAndroid(device, { - helperWaitForIdleTimeoutMs: 0, includeHiddenContentHints: false, }), { backend: 'android', purpose: 'alert' }, diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index bdfaaf23c..24af6ee5f 100644 --- a/src/platforms/android/app-lifecycle.ts +++ b/src/platforms/android/app-lifecycle.ts @@ -303,9 +303,9 @@ export type OpenAndroidAppOptions = { // `adb shell` joins its argv with spaces and feeds the result to a device // shell, which re-tokenises. The other `am start` arguments (action, category, // component, etc.) are well-known and never contain shell-significant -// characters, so they round-trip untouched. Launch arguments are user-supplied -// and may contain JSON, spaces, `#`, etc.; each is single-quoted unless it -// consists entirely of safe shell characters. +// characters, so they round-trip untouched. URLs and launch arguments are +// user-supplied and may contain JSON, spaces, `#`, or `&`; each is single-quoted +// unless it consists entirely of safe shell characters. function quoteAndroidShellArg(arg: string): string { if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg)) return arg; return `'${arg.replace(/'/g, `'\\''`)}'`; @@ -367,7 +367,7 @@ async function openAndroidDeepLink( '-a', 'android.intent.action.VIEW', '-d', - target, + quoteAndroidShellArg(target), ...androidDeepLinkPackageArgs(options.appBundleId), ...androidLaunchArgs(options), ]); @@ -398,7 +398,7 @@ async function openAndroidAppBoundDeepLink( '-a', 'android.intent.action.VIEW', '-d', - deepLinkUrl, + quoteAndroidShellArg(deepLinkUrl), '-p', resolved, ...androidLaunchArgs(options), diff --git a/src/platforms/android/helper-package-install.ts b/src/platforms/android/helper-package-install.ts index 8e887db0a..586ecd3d5 100644 --- a/src/platforms/android/helper-package-install.ts +++ b/src/platforms/android/helper-package-install.ts @@ -112,21 +112,32 @@ export async function inspectInstalledAndroidHelper(options: { packageName: string; versionCode: number; sha256: string; + signal?: AbortSignal; }): Promise { - const { adb, adbProvider, packageName, versionCode, sha256 } = options; - const installedVersionCode = await readInstalledAndroidPackageVersionCode(adb, packageName); + const { adb, adbProvider, packageName, versionCode, sha256, signal } = options; + const installedVersionCode = await readInstalledAndroidPackageVersionCode( + adb, + packageName, + signal, + ); if (installedVersionCode === undefined) return { reason: 'missing' }; if (installedVersionCode < versionCode) return { installedVersionCode, reason: 'outdated' }; if (installedVersionCode > versionCode) return { installedVersionCode, reason: 'current' }; try { - const installedSha256 = await readInstalledAndroidPackageSha256(adb, adbProvider, packageName); + const installedSha256 = await readInstalledAndroidPackageSha256( + adb, + adbProvider, + packageName, + signal, + ); return { installedVersionCode, installedSha256, reason: installedSha256 === sha256 ? 'current' : 'mismatched', }; } catch { + signal?.throwIfAborted(); return { installedVersionCode, reason: 'unverifiable' }; } } @@ -193,10 +204,11 @@ export function makeEnsureAndroidHelperInstalled< async function readInstalledAndroidPackageVersionCode( adb: AndroidAdbExecutor, packageName: string, + signal?: AbortSignal, ): Promise { const result = await adb( ['shell', 'cmd', 'package', 'list', 'packages', '--show-versioncode', packageName], - { allowFailure: true, timeoutMs: 5_000 }, + { allowFailure: true, timeoutMs: 5_000, signal }, ); if (result.exitCode !== 0) return undefined; const match = new RegExp( @@ -209,10 +221,12 @@ async function readInstalledAndroidPackageSha256( adb: AndroidAdbExecutor, adbProvider: AndroidAdbProvider, packageName: string, + signal?: AbortSignal, ): Promise { const pathResult = await adb(['shell', 'pm', 'path', packageName], { allowFailure: true, timeoutMs: ANDROID_HELPER_IDENTITY_TIMEOUT_MS, + signal, }); const remotePath = readBaseApkPath(`${pathResult.stdout}\n${pathResult.stderr}`); if (pathResult.exitCode !== 0 || !remotePath) { @@ -226,6 +240,7 @@ async function readInstalledAndroidPackageSha256( provider: adbProvider, allowFailure: true, timeoutMs: ANDROID_HELPER_IDENTITY_TIMEOUT_MS, + signal, }); if (pull.exitCode !== 0) { throw androidAdbResultError('Could not read installed Android helper APK', pull, { diff --git a/src/platforms/android/snapshot-helper-capture.ts b/src/platforms/android/snapshot-helper-capture.ts index 8d5e7554c..33c7fdd9a 100644 --- a/src/platforms/android/snapshot-helper-capture.ts +++ b/src/platforms/android/snapshot-helper-capture.ts @@ -18,6 +18,10 @@ import type { AndroidSnapshotHelperMetadata, AndroidSnapshotHelperOutput, } from './snapshot-helper-types.ts'; +import { + recoverAndroidSnapshotHelperRetirement, + retireCanceledAndroidSnapshotHelperCapture, +} from './snapshot-helper-retirement.ts'; type AndroidSnapshotHelperChunk = { index: number | undefined; @@ -47,10 +51,39 @@ export async function captureAndroidSnapshotWithHelper( options: AndroidSnapshotHelperCaptureOptions, ): Promise { const resolved = resolveAndroidSnapshotHelperCaptureOptions(options); - const result = await options.adb(buildAndroidSnapshotHelperArgs(resolved), { - allowFailure: true, - timeoutMs: resolved.commandTimeoutMs, + const deviceKey = options.deviceKey ?? 'android:default'; + await recoverAndroidSnapshotHelperRetirement({ + deviceKey, + adb: options.adb, + signal: options.signal, }); + let result: Awaited>; + try { + result = await options.adb(buildAndroidSnapshotHelperArgs(resolved), { + allowFailure: true, + timeoutMs: resolved.commandTimeoutMs, + signal: options.signal, + }); + } catch (error) { + if (!options.signal?.aborted) throw error; + await retireCanceledAndroidSnapshotHelperCapture({ + deviceKey, + packageName: resolved.packageName, + adb: options.adb, + cause: error, + }); + options.signal.throwIfAborted(); + throw error; + } + if (options.signal?.aborted) { + await retireCanceledAndroidSnapshotHelperCapture({ + deviceKey, + packageName: resolved.packageName, + adb: options.adb, + cause: options.signal.reason, + }); + options.signal.throwIfAborted(); + } const { output, cleanupDone } = await readAndroidSnapshotHelperOutput(options, resolved, result); if (resolved.outputPath && !cleanupDone) { await removeHelperOutputFile(options.adb, resolved.outputPath); @@ -412,10 +445,5 @@ function readOptionalCaptureMode( return value === 'interactive-windows' || value === 'active-window' ? value : undefined; } -export { - readInstrumentationResultNumber as readAndroidSnapshotHelperMetadataNumber, - readInstrumentationResultBoolean as readAndroidSnapshotHelperMetadataBoolean, -}; - const readOptionalNumber = readInstrumentationResultNumber; const readOptionalBoolean = readInstrumentationResultBoolean; diff --git a/src/platforms/android/snapshot-helper-install.ts b/src/platforms/android/snapshot-helper-install.ts index b08c44b01..714f62a7d 100644 --- a/src/platforms/android/snapshot-helper-install.ts +++ b/src/platforms/android/snapshot-helper-install.ts @@ -75,6 +75,7 @@ export async function ensureAndroidSnapshotHelper(options: { deviceKey?: string; installPolicy?: AndroidSnapshotHelperInstallPolicy; timeoutMs?: number; + signal?: AbortSignal; }): Promise { const { adb, artifact } = options; const installPolicy = options.installPolicy ?? 'missing-or-outdated'; @@ -107,7 +108,12 @@ export async function ensureAndroidSnapshotHelper(options: { const installedState = installPolicy === 'always' ? { - installedVersionCode: await readInstalledVersionCode(adb, packageName, options.timeoutMs), + installedVersionCode: await readInstalledVersionCode( + adb, + packageName, + options.timeoutMs, + options.signal, + ), reason: 'forced' as const, } : await inspectInstalledAndroidHelper({ @@ -116,6 +122,7 @@ export async function ensureAndroidSnapshotHelper(options: { packageName, versionCode, sha256, + signal: options.signal, }); const { installedVersionCode } = installedState; const installedSha256 = @@ -147,6 +154,7 @@ export async function ensureAndroidSnapshotHelper(options: { { packageName, timeoutMs: options.timeoutMs, + signal: options.signal, }, ); } catch (error) { @@ -203,12 +211,14 @@ async function readInstalledVersionCode( adb: AndroidAdbExecutor, packageName: string, timeoutMs: number | undefined, + signal: AbortSignal | undefined, ): Promise { const result = await adb( ['shell', 'cmd', 'package', 'list', 'packages', '--show-versioncode', packageName], { allowFailure: true, timeoutMs, + signal, }, ); if (result.exitCode === 0) { @@ -222,7 +232,7 @@ async function installAndroidSnapshotHelper( adbProvider: AndroidAdbProvider | AndroidAdbExecutor, apkPath: string, installOptions: AndroidSnapshotHelperInstallOptions, - options: { packageName: string; timeoutMs?: number }, + options: { packageName: string; timeoutMs?: number; signal?: AbortSignal }, ): Promise>> { const install = async () => await installAndroidAdbPackage(apkPath, { @@ -230,6 +240,7 @@ async function installAndroidSnapshotHelper( provider: adbProvider, ...installOptions, timeoutMs: options.timeoutMs, + signal: options.signal, }); const result = await install(); @@ -240,6 +251,7 @@ async function installAndroidSnapshotHelper( const uninstall = await adb(['uninstall', options.packageName], { allowFailure: true, timeoutMs: options.timeoutMs, + signal: options.signal, }); const retry = await install(); if (retry.exitCode === 0) { diff --git a/src/platforms/android/snapshot-helper-retirement.ts b/src/platforms/android/snapshot-helper-retirement.ts new file mode 100644 index 000000000..76d951164 --- /dev/null +++ b/src/platforms/android/snapshot-helper-retirement.ts @@ -0,0 +1,212 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AndroidAdbProcess } from './adb-executor.ts'; +import type { AndroidAdbExecutor } from './snapshot-helper-types.ts'; + +const RETIREMENT_RECOVERY_TIMEOUT_MS = 5_000; +// Host-process termination is local and should be nearly immediate. Device-side force-stop is an +// adb round trip and needs its own budget; sharing the host grace caused healthy CI force-stops to +// time out before Android could confirm UiAutomation release. +export const ANDROID_SNAPSHOT_HELPER_HOST_PROCESS_EXIT_GRACE_MS = 250; +export const ANDROID_SNAPSHOT_HELPER_DEVICE_RETIREMENT_TIMEOUT_MS = 2_000; +const RETIREMENT_UNCONFIRMED_REASON = 'android_snapshot_helper_retirement_unconfirmed'; + +type UnconfirmedRetirement = { + packageName: string; + cause: string; +}; + +const unconfirmedRetirements = new Map(); + +export function getAndroidSnapshotHelperSessionDeviceKey( + device: Pick, +): string { + return `${device.platform}:${device.id}`; +} + +export async function retireCanceledAndroidSnapshotHelperCapture(params: { + deviceKey: string; + packageName: string; + adb: AndroidAdbExecutor; + cause: unknown; +}): Promise { + const runtimeForceStopped = await forceStopAndroidSnapshotHelperRuntime({ + adb: params.adb, + packageName: params.packageName, + timeoutMs: ANDROID_SNAPSHOT_HELPER_DEVICE_RETIREMENT_TIMEOUT_MS, + }); + if (!runtimeForceStopped) { + quarantineAndroidSnapshotHelperRetirement(params); + } +} + +export async function recoverAndroidSnapshotHelperRetirement(params: { + deviceKey: string; + adb: AndroidAdbExecutor; + signal?: AbortSignal; +}): Promise { + const retirement = unconfirmedRetirements.get(params.deviceKey); + if (!retirement) return; + try { + const result = await params.adb(['shell', 'am', 'force-stop', retirement.packageName], { + allowFailure: true, + timeoutMs: RETIREMENT_RECOVERY_TIMEOUT_MS, + signal: params.signal, + }); + params.signal?.throwIfAborted(); + if (result.exitCode === 0) { + unconfirmedRetirements.delete(params.deviceKey); + return; + } + } catch { + params.signal?.throwIfAborted(); + } + quarantineAndroidSnapshotHelperRetirement({ + deviceKey: params.deviceKey, + packageName: retirement.packageName, + cause: retirement.cause, + }); +} + +export function quarantineAndroidSnapshotHelperRetirement(params: { + deviceKey: string; + packageName: string; + cause: unknown; +}): never { + const causeMessage = params.cause instanceof Error ? params.cause.message : String(params.cause); + unconfirmedRetirements.set(params.deviceKey, { + packageName: params.packageName, + cause: causeMessage, + }); + throw new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper could not confirm release of device automation ownership', + { + reason: RETIREMENT_UNCONFIRMED_REASON, + deviceKey: params.deviceKey, + cause: causeMessage, + hint: 'Retry after the helper process exits, or restart the device if Android still reports automation as busy.', + }, + ); +} + +export function isAndroidSnapshotHelperRetirementUnconfirmedError(error: unknown): boolean { + return error instanceof AppError && error.details?.reason === RETIREMENT_UNCONFIRMED_REASON; +} + +export async function settleAndroidSnapshotHelperSessionCleanup(params: { + adb: AndroidAdbExecutor; + process: AndroidAdbProcess; + port: number; + packageName: string; + timeoutMs: number; +}): Promise<{ timedOut: boolean; runtimeForceStopped: boolean }> { + const signal = AbortSignal.timeout(params.timeoutMs); + const results = await Promise.allSettled([ + forceStopAndroidSnapshotHelperRuntime({ + adb: params.adb, + packageName: params.packageName, + timeoutMs: params.timeoutMs, + signal, + }), + removeAndroidSnapshotHelperSessionForward({ ...params, signal }), + ] as const); + const [runtimeStopResult] = results; + return { + timedOut: signal.aborted, + runtimeForceStopped: + runtimeStopResult?.status === 'fulfilled' ? runtimeStopResult.value : false, + }; +} + +export function observeAndroidSnapshotHelperProcessExit(process: AndroidAdbProcess): Promise { + if (process.exitCode != null || process.signalCode != null) { + return Promise.resolve(); + } + return new Promise((resolve) => { + process.once('close', () => resolve()); + process.once('exit', () => resolve()); + }); +} + +export async function waitForAndroidSnapshotHelperProcessExit( + processExit: Promise, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + let timer: NodeJS.Timeout | undefined; + let onAbort: (() => void) | undefined; + const exited = await Promise.race([ + processExit.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }), + ...(signal + ? [ + new Promise((resolve) => { + onAbort = () => resolve(false); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + }), + ] + : []), + ]); + if (timer) clearTimeout(timer); + if (onAbort) signal?.removeEventListener('abort', onAbort); + return exited; +} + +export async function stopAndroidSnapshotHelperHostProcess(params: { + process: AndroidAdbProcess; + processExit: Promise; + alreadyExited: boolean; + timeoutMs: number; +}): Promise { + if (params.alreadyExited) return true; + try { + params.process.kill('SIGTERM'); + } catch { + // A completed instrumentation process can reject or ignore the signal. + } + return await waitForAndroidSnapshotHelperProcessExit(params.processExit, params.timeoutMs); +} + +export function resetAndroidSnapshotHelperRetirements(): void { + unconfirmedRetirements.clear(); +} + +async function forceStopAndroidSnapshotHelperRuntime(params: { + adb: AndroidAdbExecutor; + packageName: string; + timeoutMs: number; + signal?: AbortSignal; +}): Promise { + const signal = params.signal ?? AbortSignal.timeout(params.timeoutMs); + try { + const result = await params.adb(['shell', 'am', 'force-stop', params.packageName], { + allowFailure: true, + timeoutMs: params.timeoutMs, + signal, + }); + return result.exitCode === 0; + } catch { + return false; + } +} + +async function removeAndroidSnapshotHelperSessionForward(params: { + adb: AndroidAdbExecutor; + process: AndroidAdbProcess; + port: number; + timeoutMs: number; + signal: AbortSignal; +}): Promise { + params.process.stdin?.end(); + params.process.stdout?.destroy(); + params.process.stderr?.destroy(); + await params.adb(['forward', '--remove', `tcp:${params.port}`], { + allowFailure: true, + timeoutMs: params.timeoutMs, + signal: params.signal, + }); +} diff --git a/src/platforms/android/snapshot-helper-session-protocol.ts b/src/platforms/android/snapshot-helper-session-protocol.ts new file mode 100644 index 000000000..277e422b7 --- /dev/null +++ b/src/platforms/android/snapshot-helper-session-protocol.ts @@ -0,0 +1,295 @@ +import net from 'node:net'; +import { AppError } from '@agent-device/kernel/errors'; +import { + readInstrumentationResultBoolean, + readInstrumentationResultNumber, +} from './instrumentation-helper.ts'; +import { + ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, + ANDROID_SNAPSHOT_HELPER_PROTOCOL, + type AndroidSnapshotHelperMetadata, + type AndroidSnapshotHelperOutput, +} from './snapshot-helper-types.ts'; +import type { AndroidAdbProcess } from './adb-executor.ts'; + +const SESSION_REQUEST_OVERHEAD_MS = 3_000; + +export function allocateAndroidSnapshotHelperSessionPort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate a local TCP port'))); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + }); +} + +export function sendAndroidSnapshotHelperSessionCommand( + port: number, + command: string, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect({ host: '127.0.0.1', port }); + const chunks: Buffer[] = []; + const timer = setTimeout(() => { + cleanup(); + socket.destroy(); + reject( + new AppError('COMMAND_FAILED', 'Android snapshot helper session request timed out', { + command, + timeoutMs, + port, + }), + ); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + }; + const onAbort = () => { + cleanup(); + socket.destroy(); + reject(signal?.reason ?? new DOMException('The operation was aborted', 'AbortError')); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) { + onAbort(); + return; + } + socket.on('connect', () => socket.write(`${command}\n`)); + socket.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + socket.on('error', (error) => { + cleanup(); + reject(error); + }); + socket.on('close', () => { + cleanup(); + resolve(Buffer.concat(chunks).toString('utf8')); + }); + }); +} + +export async function requestAndroidSnapshotHelperSessionSnapshot(params: { + port: number; + timeoutMs: number; + commandTimeoutMs: number; + signal?: AbortSignal; +}): Promise { + const requestId = `snapshot-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const response = await sendAndroidSnapshotHelperSessionCommand( + params.port, + `snapshot ${requestId}`, + resolveAndroidSnapshotHelperSessionRequestTimeoutMs(params), + params.signal, + ); + return parseAndroidSnapshotHelperSessionSnapshotResponse(response, requestId); +} + +export function resolveAndroidSnapshotHelperSessionRequestTimeoutMs(params: { + timeoutMs: number; + commandTimeoutMs: number; +}): number { + return Math.min( + params.commandTimeoutMs, + Math.max(params.timeoutMs + SESSION_REQUEST_OVERHEAD_MS, 3_000), + ); +} + +export function waitForAndroidSnapshotHelperSessionReady( + process: AndroidAdbProcess, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + let output = ''; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + reject( + new AppError('COMMAND_FAILED', 'Android snapshot helper session did not become ready', { + output, + timeoutMs, + }), + ); + }, timeoutMs); + const onData = (chunk: Buffer | string) => { + output += chunk.toString(); + if ( + output.includes(`agentDeviceProtocol=${ANDROID_SNAPSHOT_HELPER_PROTOCOL}`) && + output.includes('sessionReady=true') + ) { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + resolve(); + } + }; + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new DOMException('The operation was aborted', 'AbortError')); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) { + onAbort(); + return; + } + process.stdout?.on('data', onData); + process.stderr?.on('data', onData); + process.once('exit', (code, exitSignal) => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + reject( + new AppError('COMMAND_FAILED', 'Android snapshot helper session exited before ready', { + output, + exitCode: code, + signal: exitSignal, + }), + ); + }); + process.on('error', (error) => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + reject(error); + }); + }); +} + +export function parseAndroidSnapshotHelperSessionHeaders(response: string): Record { + const separator = response.indexOf('\n\n'); + return parseHeaders(response.slice(0, separator < 0 ? response.length : separator)); +} + +export function assertAndroidSnapshotHelperTouchSessionHeaders( + headers: Record, + requestId: string, +): void { + if (headers.agentDeviceProtocol !== ANDROID_SNAPSHOT_HELPER_PROTOCOL) { + throw new AppError( + 'COMMAND_FAILED', + 'Android automation helper session returned wrong protocol', + { headers }, + ); + } + if (headers.requestId !== requestId) { + throw new AppError( + 'COMMAND_FAILED', + 'Android automation helper session returned stale output', + { headers, requestId }, + ); + } +} + +export function isAndroidSnapshotHelperSessionCommandAcknowledged( + response: string, + requestId: string, +): boolean { + const headers = parseAndroidSnapshotHelperSessionHeaders(response); + return ( + headers.agentDeviceProtocol === ANDROID_SNAPSHOT_HELPER_PROTOCOL && + headers.requestId === requestId && + headers.ok === 'true' + ); +} + +export function parseAndroidSnapshotHelperSessionSnapshotResponse( + response: string, + requestId: string, +): AndroidSnapshotHelperOutput { + const separator = response.indexOf('\n\n'); + if (separator < 0) { + throw new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper session returned malformed output', + { response }, + ); + } + const headers = parseHeaders(response.slice(0, separator)); + const xml = response.slice(separator + 2); + validateSnapshotHeaders(headers, requestId); + validateSnapshotXml(headers, xml); + return { xml, metadata: readSessionMetadata(headers) }; +} + +function validateSnapshotHeaders(headers: Record, requestId: string): void { + if (headers.agentDeviceProtocol !== ANDROID_SNAPSHOT_HELPER_PROTOCOL) { + throw new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper session returned wrong protocol', + { headers }, + ); + } + if (headers.outputFormat !== ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT) { + throw new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper session returned wrong output format', + { headers }, + ); + } + if (headers.requestId !== requestId) { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper session returned stale output', { + headers, + requestId, + }); + } + if (headers.ok !== 'true') { + throw new AppError( + 'COMMAND_FAILED', + headers.message || headers.errorType || 'Android snapshot helper session returned an error', + { helper: headers }, + ); + } +} + +function validateSnapshotXml(headers: Record, xml: string): void { + const byteLength = readInstrumentationResultNumber(headers.byteLength); + if (byteLength !== undefined && Buffer.byteLength(xml, 'utf8') !== byteLength) { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper session returned truncated XML', { + headers, + actualByteLength: Buffer.byteLength(xml, 'utf8'), + }); + } + if (!xml.includes('')) { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper session did not return XML', { + headers, + xml, + }); + } +} + +function parseHeaders(headerText: string): Record { + const headers: Record = {}; + for (const line of headerText.split(/\r?\n/)) { + const separator = line.indexOf('='); + if (separator < 0) continue; + headers[line.slice(0, separator)] = line.slice(separator + 1); + } + return headers; +} + +function readSessionMetadata(headers: Record): AndroidSnapshotHelperMetadata { + return { + helperApiVersion: headers.helperApiVersion, + outputFormat: ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, + waitForIdleTimeoutMs: readInstrumentationResultNumber(headers.waitForIdleTimeoutMs), + waitForIdleQuietMs: readInstrumentationResultNumber(headers.waitForIdleQuietMs), + timeoutMs: readInstrumentationResultNumber(headers.timeoutMs), + maxDepth: readInstrumentationResultNumber(headers.maxDepth), + maxNodes: readInstrumentationResultNumber(headers.maxNodes), + rootPresent: readInstrumentationResultBoolean(headers.rootPresent), + captureMode: + headers.captureMode === 'interactive-windows' || headers.captureMode === 'active-window' + ? headers.captureMode + : undefined, + windowCount: readInstrumentationResultNumber(headers.windowCount), + nodeCount: readInstrumentationResultNumber(headers.nodeCount), + truncated: readInstrumentationResultBoolean(headers.truncated), + elapsedMs: readInstrumentationResultNumber(headers.elapsedMs), + }; +} diff --git a/src/platforms/android/snapshot-helper-session.ts b/src/platforms/android/snapshot-helper-session.ts index a3ea61ddf..dd1488491 100644 --- a/src/platforms/android/snapshot-helper-session.ts +++ b/src/platforms/android/snapshot-helper-session.ts @@ -1,28 +1,54 @@ -import net from 'node:net'; import type { AndroidAdbProcess } from './adb-executor.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { - ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, - ANDROID_SNAPSHOT_HELPER_PROTOCOL, type AndroidAdbExecutor, type AndroidSnapshotHelperCaptureOptions, - type AndroidSnapshotHelperMetadata, type AndroidSnapshotHelperOutput, } from './snapshot-helper-types.ts'; import { buildAndroidSnapshotHelperArgs, - readAndroidSnapshotHelperMetadataBoolean, - readAndroidSnapshotHelperMetadataNumber, resolveAndroidSnapshotHelperCaptureOptions, type AndroidSnapshotHelperResolvedCaptureOptions, } from './snapshot-helper-capture.ts'; - +import { + allocateAndroidSnapshotHelperSessionPort, + assertAndroidSnapshotHelperTouchSessionHeaders, + isAndroidSnapshotHelperSessionCommandAcknowledged, + parseAndroidSnapshotHelperSessionHeaders, + requestAndroidSnapshotHelperSessionSnapshot, + sendAndroidSnapshotHelperSessionCommand, + waitForAndroidSnapshotHelperSessionReady, +} from './snapshot-helper-session-protocol.ts'; +import { + ANDROID_SNAPSHOT_HELPER_DEVICE_RETIREMENT_TIMEOUT_MS, + ANDROID_SNAPSHOT_HELPER_HOST_PROCESS_EXIT_GRACE_MS, + getAndroidSnapshotHelperSessionDeviceKey, + isAndroidSnapshotHelperRetirementUnconfirmedError, + observeAndroidSnapshotHelperProcessExit, + quarantineAndroidSnapshotHelperRetirement, + recoverAndroidSnapshotHelperRetirement, + resetAndroidSnapshotHelperRetirements, + settleAndroidSnapshotHelperSessionCleanup, + stopAndroidSnapshotHelperHostProcess, + waitForAndroidSnapshotHelperProcessExit, +} from './snapshot-helper-retirement.ts'; +export { + getAndroidSnapshotHelperSessionDeviceKey, + isAndroidSnapshotHelperRetirementUnconfirmedError, + recoverAndroidSnapshotHelperRetirement, +} from './snapshot-helper-retirement.ts'; const SESSION_READY_TIMEOUT_MS = 10_000; const SESSION_STOP_TIMEOUT_MS = 1_000; +// SnapshotInstrumentation.finishSafely can spend up to 10 seconds waiting for Android to finish +// connecting UiAutomation. Let an acknowledged quit complete that release before force-killing adb. +const SESSION_GRACEFUL_EXIT_TIMEOUT_MS = 11_000; const SESSION_PROCESS_EXIT_TIMEOUT_MS = 2_000; -const SESSION_REQUEST_OVERHEAD_MS = 10_000; +// Persistent capture is an optimization before the required one-shot path. Keep its native and +// transport budgets shorter so a wedged UiAutomation connection leaves time for a clean fallback. +const SESSION_CAPTURE_TIMEOUT_MS = 2_000; +const SESSION_REQUEST_OVERHEAD_MS = 3_000; const FORWARD_TIMEOUT_MS = 5_000; type AndroidSnapshotHelperSessionHelperIdentity = { @@ -50,12 +76,41 @@ const disabledSessionIdentities = new Map(); export async function captureAndroidSnapshotWithHelperSession( options: AndroidSnapshotHelperCaptureOptions, ): Promise { + const deviceKey = options.deviceKey ?? 'android:default'; + await recoverAndroidSnapshotHelperRetirement({ + deviceKey, + adb: options.adb, + signal: options.signal, + }); if (!isAndroidSnapshotHelperSessionEnabled() || !options.adbProvider?.spawn) { return undefined; } - const resolved = resolveAndroidSnapshotHelperCaptureOptions(options); - const deviceKey = options.deviceKey ?? 'android:default'; + const resolved = resolvePersistentSessionCaptureOptions( + resolveAndroidSnapshotHelperCaptureOptions(options), + ); const identity = createSessionIdentity(deviceKey, resolved, options); + const session = await resolveAndroidSnapshotHelperSession({ + deviceKey, + identity, + options, + resolved, + }); + if (!session) return undefined; + return await captureFromAndroidSnapshotHelperSession({ + session, + deviceKey, + options, + resolved, + }); +} + +async function resolveAndroidSnapshotHelperSession(params: { + deviceKey: string; + identity: string; + options: AndroidSnapshotHelperCaptureOptions; + resolved: AndroidSnapshotHelperResolvedCaptureOptions; +}): Promise { + const { deviceKey, identity, options, resolved } = params; if (disabledSessionIdentities.get(deviceKey) === identity) { return undefined; } @@ -73,6 +128,7 @@ export async function captureAndroidSnapshotWithHelperSession( resolved, }); } catch (error) { + options.signal?.throwIfAborted(); disabledSessionIdentities.set(deviceKey, identity); emitDiagnostic({ level: 'warn', @@ -82,12 +138,30 @@ export async function captureAndroidSnapshotWithHelperSession( reason: error instanceof Error ? error.message : String(error), }, }); + if (isAndroidSnapshotHelperRetirementUnconfirmedError(error)) { + throw error; + } return undefined; } } + return session; +} + +async function captureFromAndroidSnapshotHelperSession(params: { + session: AndroidSnapshotHelperSession; + deviceKey: string; + options: AndroidSnapshotHelperCaptureOptions; + resolved: AndroidSnapshotHelperResolvedCaptureOptions; +}): Promise { + const { session, deviceKey, options, resolved } = params; try { const reused = session.capturedCount > 0; - const output = await requestSessionSnapshot(session, resolved); + const output = await requestAndroidSnapshotHelperSessionSnapshot({ + port: session.port, + timeoutMs: resolved.timeoutMs, + commandTimeoutMs: resolved.commandTimeoutMs, + signal: options.signal, + }); session.capturedCount += 1; return { xml: output.xml, @@ -98,11 +172,43 @@ export async function captureAndroidSnapshotWithHelperSession( }, }; } catch (error) { - await stopAndroidSnapshotHelperSession(deviceKey); - throw error; + await stopAndroidSnapshotHelperSession(deviceKey, { + force: true, + signal: options.signal, + cause: error, + }); + options.signal?.throwIfAborted(); + emitDiagnostic({ + level: 'warn', + phase: 'android_snapshot_helper_session_fallback', + data: { + deviceKey, + reason: error instanceof Error ? error.message : String(error), + uiAutomationConnectionTimeout: isUiAutomationConnectionTimeoutResponse(error), + }, + }); + return undefined; } } +function resolvePersistentSessionCaptureOptions( + resolved: AndroidSnapshotHelperResolvedCaptureOptions, +): AndroidSnapshotHelperResolvedCaptureOptions { + const timeoutMs = Math.min(resolved.timeoutMs, SESSION_CAPTURE_TIMEOUT_MS); + return { + ...resolved, + timeoutMs, + commandTimeoutMs: Math.min(resolved.commandTimeoutMs, timeoutMs + SESSION_REQUEST_OVERHEAD_MS), + }; +} + +function isUiAutomationConnectionTimeoutResponse(error: unknown): boolean { + if (!(error instanceof AppError)) return false; + const helper = error.details?.helper; + if (!helper || typeof helper !== 'object') return false; + return (helper as Record).errorType === 'java.util.concurrent.TimeoutException'; +} + // Touch commands piggyback on a live snapshot session so gestures do not restart instrumentation // (Android permits one UiAutomation owner). They never start a session: without one, callers use // the same helper APK through a one-shot `am instrument` run instead. @@ -137,9 +243,13 @@ export async function runAndroidSnapshotHelperSessionTouchCommand(params: { : `${params.action} ${requestId}`; let headers: Record; try { - const response = await sendSessionCommand(session, command, params.timeoutMs); - headers = parseSessionHeaders(response.slice(0, findSessionHeaderEnd(response))); - assertTouchSessionHeaders(headers, requestId); + const response = await sendAndroidSnapshotHelperSessionCommand( + session.port, + command, + params.timeoutMs, + ); + headers = parseAndroidSnapshotHelperSessionHeaders(response); + assertAndroidSnapshotHelperTouchSessionHeaders(headers, requestId); } catch (error) { // Transport-level failure: the session process can no longer be trusted. Stop it so the next // command runs against a fresh helper instead of a wedged socket. @@ -174,53 +284,39 @@ function matchesWhenBothDefined(a: Value | undefined, b: Value | undefine return a === undefined || b === undefined || a === b; } -function findSessionHeaderEnd(response: string): number { - const separator = response.indexOf('\n\n'); - return separator < 0 ? response.length : separator; -} - -function assertTouchSessionHeaders(headers: Record, requestId: string): void { - if (headers.agentDeviceProtocol !== ANDROID_SNAPSHOT_HELPER_PROTOCOL) { - throw new AppError( - 'COMMAND_FAILED', - 'Android automation helper session returned wrong protocol', - { - headers, - }, - ); - } - if (headers.requestId !== requestId) { - throw new AppError( - 'COMMAND_FAILED', - 'Android automation helper session returned stale output', - { - headers, - requestId, - }, - ); - } -} - -export async function stopAndroidSnapshotHelperSession(deviceKey: string): Promise { +export async function stopAndroidSnapshotHelperSession( + deviceKey: string, + options: { force?: boolean; signal?: AbortSignal; cause?: unknown } = {}, +): Promise { const session = sessions.get(deviceKey); if (!session) return; sessions.delete(deviceKey); - try { - await sendSessionCommand(session, `quit ${Date.now()}`, SESSION_STOP_TIMEOUT_MS); - } catch { - // The process may already be gone; adb forward cleanup and kill below are still enough. - } - try { - await session.process.kill('SIGTERM'); - } catch { - // Best effort. A completed instrumentation process can reject/ignore kill. - } - await waitForProcessExit(session.process, SESSION_PROCESS_EXIT_TIMEOUT_MS); - try { - await removeForward(session); - } catch { - // Stale forwards are harmless and the next start overwrites its chosen local port. - } + const processExit = observeAndroidSnapshotHelperProcessExit(session.process); + const force = options.force === true || options.signal?.aborted === true; + const graceful = await requestGracefulSessionExit(session, processExit, force, options.signal); + const cleanupTimeoutMs = !force + ? FORWARD_TIMEOUT_MS + : options.signal?.aborted === true + ? ANDROID_SNAPSHOT_HELPER_HOST_PROCESS_EXIT_GRACE_MS + : ANDROID_SNAPSHOT_HELPER_DEVICE_RETIREMENT_TIMEOUT_MS; + const processExitTimeoutMs = force + ? ANDROID_SNAPSHOT_HELPER_HOST_PROCESS_EXIT_GRACE_MS + : SESSION_PROCESS_EXIT_TIMEOUT_MS; + const [processStopped, cleanup] = await Promise.all([ + stopAndroidSnapshotHelperHostProcess({ + process: session.process, + processExit, + alreadyExited: graceful.exited, + timeoutMs: processExitTimeoutMs, + }), + settleAndroidSnapshotHelperSessionCleanup({ + adb: session.adb, + process: session.process, + port: session.port, + packageName: session.helper.packageName, + timeoutMs: cleanupTimeoutMs, + }), + ]); emitDiagnostic({ phase: 'android_snapshot_helper_session_stop', data: { @@ -228,8 +324,49 @@ export async function stopAndroidSnapshotHelperSession(deviceKey: string): Promi port: session.port, capturedCount: session.capturedCount, lifetimeMs: Date.now() - session.startedAtMs, + quitAcknowledged: graceful.acknowledged, + forceKilled: !graceful.exited && processStopped, + forced: force || options.signal?.aborted === true, + runtimeForceStopped: cleanup.runtimeForceStopped, + externalCleanupTimedOut: cleanup.timedOut, }, }); + if (!graceful.exited && !cleanup.runtimeForceStopped) { + quarantineAndroidSnapshotHelperRetirement({ + deviceKey, + packageName: session.helper.packageName, + cause: options.cause, + }); + } +} + +async function requestGracefulSessionExit( + session: AndroidSnapshotHelperSession, + processExit: Promise, + force: boolean, + signal: AbortSignal | undefined, +): Promise<{ acknowledged: boolean; exited: boolean }> { + if (force) return { acknowledged: false, exited: false }; + const requestId = `quit-${Date.now()}`; + try { + const response = await sendAndroidSnapshotHelperSessionCommand( + session.port, + `quit ${requestId}`, + SESSION_STOP_TIMEOUT_MS, + signal, + ); + const acknowledged = isAndroidSnapshotHelperSessionCommandAcknowledged(response, requestId); + const exited = + acknowledged && + (await waitForAndroidSnapshotHelperProcessExit( + processExit, + SESSION_GRACEFUL_EXIT_TIMEOUT_MS, + signal, + )); + return { acknowledged, exited }; + } catch { + return { acknowledged: false, exited: false }; + } } export async function stopAndroidSnapshotHelperSessionForDevice( @@ -238,31 +375,18 @@ export async function stopAndroidSnapshotHelperSessionForDevice( await stopAndroidSnapshotHelperSession(getAndroidSnapshotHelperSessionDeviceKey(device)); } -export function getAndroidSnapshotHelperSessionDeviceKey( - device: Pick, -): string { - return `${device.platform}:${device.id}`; -} - -// This pure seam verifies timeout budgets without making unit tests wait for real time. -export function resolveAndroidSnapshotHelperSessionRequestTimeoutMs(params: { - timeoutMs: number; - commandTimeoutMs: number; -}): number { - return Math.min( - params.commandTimeoutMs, - Math.max(params.timeoutMs + SESSION_REQUEST_OVERHEAD_MS, 3_000), - ); -} - -/** - * @internal Test isolation hook for persistent snapshot helper sessions. - */ export async function resetAndroidSnapshotHelperSessions(): Promise { - await Promise.all( + const retirements = await Promise.allSettled( [...sessions.keys()].map((deviceKey) => stopAndroidSnapshotHelperSession(deviceKey)), ); disabledSessionIdentities.clear(); + const failures = retirements + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result) => result.reason); + if (failures.length > 0) { + throw new AggregateError(failures, 'Failed to retire every Android snapshot helper session'); + } + resetAndroidSnapshotHelperRetirements(); } async function startAndroidSnapshotHelperSession(params: { @@ -271,10 +395,11 @@ async function startAndroidSnapshotHelperSession(params: { options: AndroidSnapshotHelperCaptureOptions; resolved: AndroidSnapshotHelperResolvedCaptureOptions; }): Promise { - const port = await getFreePort(); + const port = await allocateAndroidSnapshotHelperSessionPort(); await params.options.adb(['forward', `tcp:${port}`, `tcp:${port}`], { allowFailure: false, timeoutMs: FORWARD_TIMEOUT_MS, + signal: params.options.signal, }); const args = buildAndroidSnapshotHelperArgs({ ...params.resolved, @@ -307,7 +432,11 @@ async function startAndroidSnapshotHelperSession(params: { capturedCount: 0, }; try { - await waitForSessionReady(process, SESSION_READY_TIMEOUT_MS); + await waitForAndroidSnapshotHelperSessionReady( + process, + SESSION_READY_TIMEOUT_MS, + params.options.signal, + ); sessions.set(params.deviceKey, session); emitDiagnostic({ phase: 'android_snapshot_helper_session_ready', @@ -320,238 +449,36 @@ async function startAndroidSnapshotHelperSession(params: { }); return session; } catch (error) { - await removeForward(session); + const processExit = observeAndroidSnapshotHelperProcessExit(process); try { process.kill('SIGTERM'); } catch { // Best effort after startup failure. } - await waitForProcessExit(process, SESSION_PROCESS_EXIT_TIMEOUT_MS); + const [, cleanup] = await Promise.all([ + waitForAndroidSnapshotHelperProcessExit( + processExit, + ANDROID_SNAPSHOT_HELPER_HOST_PROCESS_EXIT_GRACE_MS, + ), + settleAndroidSnapshotHelperSessionCleanup({ + adb: session.adb, + process: session.process, + port: session.port, + packageName: session.helper.packageName, + timeoutMs: ANDROID_SNAPSHOT_HELPER_DEVICE_RETIREMENT_TIMEOUT_MS, + }), + ]); + if (!cleanup.runtimeForceStopped) { + quarantineAndroidSnapshotHelperRetirement({ + deviceKey: params.deviceKey, + packageName: session.helper.packageName, + cause: error, + }); + } throw error; } } -function waitForProcessExit(process: AndroidAdbProcess, timeoutMs: number): Promise { - return new Promise((resolve) => { - const timer = setTimeout(resolve, timeoutMs); - process.once('close', () => { - clearTimeout(timer); - resolve(); - }); - process.once('exit', () => { - clearTimeout(timer); - resolve(); - }); - }); -} - -function waitForSessionReady(process: AndroidAdbProcess, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let output = ''; - const timer = setTimeout(() => { - reject( - new AppError('COMMAND_FAILED', 'Android snapshot helper session did not become ready', { - output, - timeoutMs, - }), - ); - }, timeoutMs); - const onData = (chunk: Buffer | string) => { - output += chunk.toString(); - if ( - output.includes(`agentDeviceProtocol=${ANDROID_SNAPSHOT_HELPER_PROTOCOL}`) && - output.includes('sessionReady=true') - ) { - clearTimeout(timer); - resolve(); - } - }; - process.stdout?.on('data', onData); - process.stderr?.on('data', onData); - process.once('exit', (code, signal) => { - clearTimeout(timer); - reject( - new AppError('COMMAND_FAILED', 'Android snapshot helper session exited before ready', { - output, - exitCode: code, - signal, - }), - ); - }); - process.on('error', (error) => { - clearTimeout(timer); - reject(error); - }); - }); -} - -async function requestSessionSnapshot( - session: AndroidSnapshotHelperSession, - resolved: AndroidSnapshotHelperResolvedCaptureOptions, -): Promise { - const requestId = `snapshot-${Date.now()}-${Math.random().toString(16).slice(2)}`; - // Keep the session request generous enough for slow UIAutomator captures, but never - // beyond the command budget the caller already assigned to this snapshot. - const timeoutMs = resolveAndroidSnapshotHelperSessionRequestTimeoutMs(resolved); - const response = await sendSessionCommand(session, `snapshot ${requestId}`, timeoutMs); - return parseSessionSnapshotResponse(response, requestId); -} - -function sendSessionCommand( - session: AndroidSnapshotHelperSession, - command: string, - timeoutMs: number, -): Promise { - return new Promise((resolve, reject) => { - const socket = net.connect({ host: '127.0.0.1', port: session.port }); - const chunks: Buffer[] = []; - const timer = setTimeout(() => { - socket.destroy(); - reject( - new AppError('COMMAND_FAILED', 'Android snapshot helper session request timed out', { - command, - timeoutMs, - port: session.port, - }), - ); - }, timeoutMs); - socket.on('connect', () => { - socket.write(`${command}\n`); - }); - socket.on('data', (chunk) => { - chunks.push(Buffer.from(chunk)); - }); - socket.on('error', (error) => { - clearTimeout(timer); - reject(error); - }); - socket.on('close', () => { - clearTimeout(timer); - resolve(Buffer.concat(chunks).toString('utf8')); - }); - }); -} - -function parseSessionSnapshotResponse( - response: string, - requestId: string, -): AndroidSnapshotHelperOutput { - const { headers, xml } = splitSessionResponse(response); - validateSessionHeaders(headers, requestId); - validateSessionXml(headers, xml); - return { xml, metadata: readSessionMetadata(headers) }; -} - -function splitSessionResponse(response: string): { headers: Record; xml: string } { - const separator = response.indexOf('\n\n'); - if (separator < 0) { - throw new AppError( - 'COMMAND_FAILED', - 'Android snapshot helper session returned malformed output', - { - response, - }, - ); - } - return { - headers: parseSessionHeaders(response.slice(0, separator)), - xml: response.slice(separator + 2), - }; -} - -function validateSessionHeaders(headers: Record, requestId: string): void { - if (headers.agentDeviceProtocol !== ANDROID_SNAPSHOT_HELPER_PROTOCOL) { - throw new AppError( - 'COMMAND_FAILED', - 'Android snapshot helper session returned wrong protocol', - { - headers, - }, - ); - } - if (headers.outputFormat !== ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT) { - throw new AppError( - 'COMMAND_FAILED', - 'Android snapshot helper session returned wrong output format', - { headers }, - ); - } - if (headers.requestId !== requestId) { - throw new AppError('COMMAND_FAILED', 'Android snapshot helper session returned stale output', { - headers, - requestId, - }); - } - if (headers.ok !== 'true') { - throw new AppError( - 'COMMAND_FAILED', - headers.message || headers.errorType || 'Android snapshot helper session returned an error', - { helper: headers }, - ); - } -} - -function validateSessionXml(headers: Record, xml: string): void { - const byteLength = readAndroidSnapshotHelperMetadataNumber(headers.byteLength); - if (byteLength !== undefined && Buffer.byteLength(xml, 'utf8') !== byteLength) { - throw new AppError('COMMAND_FAILED', 'Android snapshot helper session returned truncated XML', { - headers, - actualByteLength: Buffer.byteLength(xml, 'utf8'), - }); - } - if (!xml.includes('')) { - throw new AppError('COMMAND_FAILED', 'Android snapshot helper session did not return XML', { - headers, - xml, - }); - } -} - -function parseSessionHeaders(headerText: string): Record { - const headers: Record = {}; - for (const line of headerText.split(/\r?\n/)) { - const separator = line.indexOf('='); - if (separator < 0) continue; - headers[line.slice(0, separator)] = line.slice(separator + 1); - } - return headers; -} - -function readSessionMetadata(headers: Record): AndroidSnapshotHelperMetadata { - return { - helperApiVersion: headers.helperApiVersion, - outputFormat: ANDROID_SNAPSHOT_HELPER_OUTPUT_FORMAT, - waitForIdleTimeoutMs: readAndroidSnapshotHelperMetadataNumber(headers.waitForIdleTimeoutMs), - waitForIdleQuietMs: readAndroidSnapshotHelperMetadataNumber(headers.waitForIdleQuietMs), - timeoutMs: readAndroidSnapshotHelperMetadataNumber(headers.timeoutMs), - maxDepth: readAndroidSnapshotHelperMetadataNumber(headers.maxDepth), - maxNodes: readAndroidSnapshotHelperMetadataNumber(headers.maxNodes), - rootPresent: readAndroidSnapshotHelperMetadataBoolean(headers.rootPresent), - captureMode: - headers.captureMode === 'interactive-windows' || headers.captureMode === 'active-window' - ? headers.captureMode - : undefined, - windowCount: readAndroidSnapshotHelperMetadataNumber(headers.windowCount), - nodeCount: readAndroidSnapshotHelperMetadataNumber(headers.nodeCount), - truncated: readAndroidSnapshotHelperMetadataBoolean(headers.truncated), - elapsedMs: readAndroidSnapshotHelperMetadataNumber(headers.elapsedMs), - }; -} - -async function removeForward(session: AndroidSnapshotHelperSession): Promise { - await session.process.stdin?.end(); - await session.process.stdout?.destroy(); - await session.process.stderr?.destroy(); - await sessionForwardRemove(session); -} - -async function sessionForwardRemove(session: AndroidSnapshotHelperSession): Promise { - await session.adb(['forward', '--remove', `tcp:${session.port}`], { - allowFailure: true, - timeoutMs: FORWARD_TIMEOUT_MS, - }); -} - function createSessionIdentity( deviceKey: string, resolved: AndroidSnapshotHelperResolvedCaptureOptions, @@ -577,20 +504,3 @@ function isAndroidSnapshotHelperSessionEnabled(): boolean { const value = process.env.AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION; return value === undefined || !/^(0|false|no|off)$/i.test(value); } - -function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Failed to allocate a local TCP port'))); - return; - } - const port = address.port; - server.close(() => resolve(port)); - }); - }); -} diff --git a/src/platforms/android/snapshot-helper-types.ts b/src/platforms/android/snapshot-helper-types.ts index 2fb115da9..353a2d698 100644 --- a/src/platforms/android/snapshot-helper-types.ts +++ b/src/platforms/android/snapshot-helper-types.ts @@ -59,6 +59,7 @@ export type AndroidSnapshotHelperInstallResult = { export type AndroidSnapshotHelperCaptureOptions = { adb: AndroidAdbExecutor; + signal?: AbortSignal; adbProvider?: AndroidAdbProvider; deviceKey?: string; helperVersion?: string; diff --git a/src/platforms/android/snapshot-helper.ts b/src/platforms/android/snapshot-helper.ts index 7b0f6f548..49979cc7f 100644 --- a/src/platforms/android/snapshot-helper.ts +++ b/src/platforms/android/snapshot-helper.ts @@ -3,6 +3,8 @@ export { captureAndroidSnapshotWithHelper } from './snapshot-helper-capture.ts'; export { captureAndroidSnapshotWithHelperSession, getAndroidSnapshotHelperSessionDeviceKey, + isAndroidSnapshotHelperRetirementUnconfirmedError, + resetAndroidSnapshotHelperSessions, stopAndroidSnapshotHelperSession, stopAndroidSnapshotHelperSessionForDevice, } from './snapshot-helper-session.ts'; diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index be0dd3598..fdd2d3b2c 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -33,6 +33,7 @@ import { ensureAndroidSnapshotHelper, forgetAndroidSnapshotHelperInstall, getAndroidSnapshotHelperSessionDeviceKey, + isAndroidSnapshotHelperRetirementUnconfirmedError, parseAndroidSnapshotHelperManifest, stopAndroidSnapshotHelperSession, type AndroidAdbExecutor, @@ -54,11 +55,11 @@ const HELPER_RUNTIME_RESET_DELAY_MS = 150; const HELPER_RUNTIME_RESET_TIMEOUT_MS = 2_000; export type AndroidSnapshotOptions = SnapshotOptions & { appBundleId?: string; + signal?: AbortSignal; helperArtifact?: AndroidSnapshotHelperArtifact; helperInstallPolicy?: AndroidSnapshotHelperInstallPolicy; helperSessionScope?: 'command' | 'daemon-session'; helperAdb?: AndroidAdbExecutor | AndroidAdbProvider; - helperWaitForIdleTimeoutMs?: number; includeHiddenContentHints?: boolean; }; @@ -192,7 +193,7 @@ async function captureAndroidUiHierarchyWithHelper( await stopAndroidSnapshotHelperSession(helperDeviceKey); } const capture = await captureAndroidUiHierarchyFromHelper({ - options, + signal: options.signal, adb, adbProvider, artifact, @@ -200,6 +201,7 @@ async function captureAndroidUiHierarchyWithHelper( }); helperCapture = formatAndroidHelperCaptureResult(capture, artifact, install.reason); } catch (error) { + options.signal?.throwIfAborted(); return await rejectAndroidHelperCaptureFailure({ error, helperDeviceKey, @@ -252,6 +254,7 @@ async function installAndroidSnapshotHelper( deviceKey, installPolicy: options.helperInstallPolicy, timeoutMs: HELPER_INSTALL_TIMEOUT_MS, + signal: options.signal, }), { packageName: artifact.manifest.packageName, @@ -275,13 +278,13 @@ async function installAndroidSnapshotHelper( } async function captureAndroidUiHierarchyFromHelper(params: { - options: AndroidSnapshotOptions; + signal?: AbortSignal; adb: AndroidAdbExecutor; adbProvider: AndroidAdbProvider; artifact: AndroidSnapshotHelperArtifact; helperDeviceKey: string; }): Promise { - const { options, adb, adbProvider, artifact, helperDeviceKey } = params; + const { signal, adb, adbProvider, artifact, helperDeviceKey } = params; const captureOptions = { adb, adbProvider, @@ -291,10 +294,10 @@ async function captureAndroidUiHierarchyFromHelper(params: { helperSha256: artifact.manifest.sha256, packageName: artifact.manifest.packageName, instrumentationRunner: artifact.manifest.instrumentationRunner, - waitForIdleTimeoutMs: - options.helperWaitForIdleTimeoutMs ?? ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS, + waitForIdleTimeoutMs: ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS, timeoutMs: HELPER_CAPTURE_TIMEOUT_MS, commandTimeoutMs: HELPER_COMMAND_TIMEOUT_MS, + signal, }; try { const sessionCapture = await withDiagnosticTimer( @@ -308,6 +311,10 @@ async function captureAndroidUiHierarchyFromHelper(params: { ); if (sessionCapture) return sessionCapture; } catch (error) { + signal?.throwIfAborted(); + if (isAndroidSnapshotHelperRetirementUnconfirmedError(error)) { + throw error; + } emitDiagnostic({ level: 'warn', phase: 'android_snapshot_helper_session_fallback', diff --git a/src/platforms/android/touch-helper.ts b/src/platforms/android/touch-helper.ts index 6484090d1..d01fb9502 100644 --- a/src/platforms/android/touch-helper.ts +++ b/src/platforms/android/touch-helper.ts @@ -16,6 +16,7 @@ import { parseAndroidSnapshotHelperManifest } from './snapshot-helper-artifact.t import { ensureAndroidSnapshotHelper } from './snapshot-helper-install.ts'; import { getAndroidSnapshotHelperSessionDeviceKey, + recoverAndroidSnapshotHelperRetirement, runAndroidSnapshotHelperSessionTouchCommand, stopAndroidSnapshotHelperSession, } from './snapshot-helper-session.ts'; @@ -159,6 +160,10 @@ async function prepareAndroidTouchHelper(device: DeviceInfo): Promise diff --git a/src/platforms/apple/__tests__/interactor-runner-provider.test.ts b/src/platforms/apple/__tests__/interactor-runner-provider.test.ts index b4e1a7762..47372713d 100644 --- a/src/platforms/apple/__tests__/interactor-runner-provider.test.ts +++ b/src/platforms/apple/__tests__/interactor-runner-provider.test.ts @@ -110,6 +110,38 @@ test('injected transport still resolves when the runner context carries a reques assert.equal(calls[0]!.options.requestId, 'req-42'); }); +test('injected transport receives the active interaction cancellation signal', async () => { + const calls: RecordedRunnerCall[] = []; + const controller = new AbortController(); + const interactor = createAppleInteractor( + IOS_SIMULATOR, + { signal: controller.signal }, + recordingRunnerProvider(calls), + ); + + await interactor.snapshot(); + + assert.equal(calls[0]?.options.signal, controller.signal); +}); + +test('snapshot merges its per-call cancellation signal with the interaction context', async () => { + const calls: RecordedRunnerCall[] = []; + const contextController = new AbortController(); + const snapshotController = new AbortController(); + const interactor = createAppleInteractor( + IOS_SIMULATOR, + { signal: contextController.signal }, + recordingRunnerProvider(calls), + ); + + await interactor.snapshot({ signal: snapshotController.signal }); + + const signal = calls[0]?.options.signal; + assert.ok(signal); + snapshotController.abort(); + assert.equal(signal.aborted, true); +}); + test('snapshot over the injected transport keeps the shared xctest result shape', async () => { const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([])); const result = await interactor.snapshot(); diff --git a/src/platforms/apple/core/__tests__/runner-request-cancellation.test.ts b/src/platforms/apple/core/__tests__/runner-request-cancellation.test.ts index 8e0ee3249..bb93d4ef3 100644 --- a/src/platforms/apple/core/__tests__/runner-request-cancellation.test.ts +++ b/src/platforms/apple/core/__tests__/runner-request-cancellation.test.ts @@ -136,6 +136,27 @@ beforeEach(async () => { mockWaitForRunner.mockResolvedValue(runnerResponse({ uptimeMs: 1 })); }); +test('direct command cancellation reaches runner launch without a registered request', async () => { + const controller = new AbortController(); + const device = { ...IOS_SIMULATOR, id: 'runner-direct-signal-sim' }; + mockRunCmdBackground.mockImplementationOnce((_cmd, _args, options) => { + assert.equal(options?.signal, controller.signal); + controller.abort(new Error('wait deadline exceeded')); + return makeBackgroundRunner(4141); + }); + + await assert.rejects( + executeRunnerCommand( + device, + { command: 'snapshot', appBundleId: 'com.example.demo' }, + { signal: controller.signal, logPath: '/tmp/runner.log' }, + ), + (error: unknown) => isRequestCanceledError(error), + ); + + assert.equal(getRunnerSessionSnapshot(device.id), null); +}); + test('prepare cancellation stops only its runner and preserves unrelated prep', async () => { const survivorRequestId = 'prepare-runner-survivor-B'; const canceledRequestId = 'prepare-runner-canceled-A'; diff --git a/src/platforms/apple/core/runner/runner-contract.ts b/src/platforms/apple/core/runner/runner-contract.ts index 181926bb9..7f94de8d0 100644 --- a/src/platforms/apple/core/runner/runner-contract.ts +++ b/src/platforms/apple/core/runner/runner-contract.ts @@ -5,7 +5,11 @@ import type { DeviceRotation } from '../../../../contracts/device-rotation.ts'; import type { ScrollDirection } from '../../../../contracts/scroll-gesture.ts'; import type { GesturePlan } from '../../../../contracts/gesture-plan.ts'; import type { ElementSelectorKey } from '../../../../contracts/interactor-types.ts'; -import { createRequestCanceledError, isRequestCanceled } from '../../../../request/cancel.ts'; +import { + createRequestCanceledError, + getRequestSignal, + isRequestCanceled, +} from '../../../../request/cancel.ts'; import { bootFailureHint, classifyBootFailure } from '../../../boot-diagnostics.ts'; import type { RunnerSession } from './runner-session-types.ts'; @@ -109,6 +113,16 @@ export type RunnerSequenceStep = { synthesized?: boolean; }; +export function resolveRunnerRequestSignal(options: { + requestId?: string; + signal?: AbortSignal; +}): AbortSignal | undefined { + const registeredSignal = getRequestSignal(options.requestId); + if (!options.signal) return registeredSignal; + if (!registeredSignal || registeredSignal === options.signal) return options.signal; + return AbortSignal.any([registeredSignal, options.signal]); +} + export function isRetryableRunnerError(err: unknown): boolean { if (!(err instanceof AppError)) return false; if (err.code !== 'COMMAND_FAILED') return false; diff --git a/src/platforms/apple/core/runner/runner-lifecycle.ts b/src/platforms/apple/core/runner/runner-lifecycle.ts index dedab8555..97df1c0d8 100644 --- a/src/platforms/apple/core/runner/runner-lifecycle.ts +++ b/src/platforms/apple/core/runner/runner-lifecycle.ts @@ -1,7 +1,7 @@ import { AppError, asAppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { emitDiagnostic } from '../../../../utils/diagnostics.ts'; -import { getRequestSignal, isRequestCanceledError } from '../../../../request/cancel.ts'; +import { isRequestCanceledError } from '../../../../request/cancel.ts'; import { RUNNER_COMMAND_TIMEOUT_MS, RUNNER_STARTUP_TIMEOUT_MS } from './runner-transport.ts'; import { type RunnerSession, @@ -14,6 +14,7 @@ import { import { assertRunnerRequestActive, isRetryableRunnerError, + resolveRunnerRequestSignal, shouldRetryRunnerConnectError, withRunnerCommandId, type RunnerCommand, @@ -48,7 +49,7 @@ export async function prepareLocalIosRunner( options: PrepareIosRunnerOptions, ): Promise { assertRunnerRequestActive(options.requestId); - const signal = getRequestSignal(options.requestId); + const signal = resolveRunnerRequestSignal(options); const command = withRunnerCommandId({ command: 'uptime' }); let recoveryReason: string | undefined; for (let attempt = 1; attempt <= PREPARE_RUNNER_HEALTH_MAX_SESSION_ATTEMPTS; attempt += 1) { @@ -256,7 +257,7 @@ export async function executeRunnerCommand( options: AppleRunnerCommandOptions, ): Promise> { assertRunnerRequestActive(options.requestId); - const signal = getRequestSignal(options.requestId); + const signal = resolveRunnerRequestSignal(options); const recycleKey = runnerRecycleLedgerKey(options, command); let session: RunnerSession | undefined; let recycleBootBegun = false; diff --git a/src/platforms/apple/core/runner/runner-provider.ts b/src/platforms/apple/core/runner/runner-provider.ts index 7f2f5a3c5..3598c50e3 100644 --- a/src/platforms/apple/core/runner/runner-provider.ts +++ b/src/platforms/apple/core/runner/runner-provider.ts @@ -10,6 +10,7 @@ import type { } from './runner-xctestrun.ts'; export type AppleRunnerCommandOptions = ExternalXctestRunnerOptions & { + signal?: AbortSignal; verbose?: boolean; logPath?: string; traceLogPath?: string; diff --git a/src/platforms/apple/core/runner/runner-session.ts b/src/platforms/apple/core/runner/runner-session.ts index a01818bb6..36e30e132 100644 --- a/src/platforms/apple/core/runner/runner-session.ts +++ b/src/platforms/apple/core/runner/runner-session.ts @@ -10,7 +10,7 @@ import { isIosFamily, isApplePlatform, type DeviceInfo } from '@agent-device/ker import type { RunnerLogicalLeaseContext } from '../../../../contracts/runner-lease-context.ts'; import type { AppleRunnerLifecycleOptions } from './runner-provider.ts'; import { emitRequestProgress } from '../../../../request/progress.ts'; -import { createRequestCanceledError, getRequestSignal } from '../../../../request/cancel.ts'; +import { createRequestCanceledError } from '../../../../request/cancel.ts'; import { emitDiagnostic, withDiagnosticTimer } from '../../../../utils/diagnostics.ts'; import { buildSimctlArgsForDevice } from '../simctl.ts'; import { runAppleToolCommand, runXcrun } from '../tool-provider.ts'; @@ -32,7 +32,11 @@ import { resolveExpectedRunnerCacheMetadata, resolveRunnerDerivedPath, } from './runner-xctestrun.ts'; -import { withRunnerCommandId, type RunnerCommand } from './runner-contract.ts'; +import { + resolveRunnerRequestSignal, + withRunnerCommandId, + type RunnerCommand, +} from './runner-contract.ts'; import { canSkipRunnerReadinessPreflightAfterHealthyMutation, isReadOnlyRunnerCommand, @@ -131,7 +135,7 @@ async function startRunnerSessionWithLease( // xctestrun build and runner launch (killProcessTree via exec) instead of // orphaning them. Request-scoped: only this request's device startup reacts, // and a signal-less internal caller (shutdown) simply gets undefined. - const signal = getRequestSignal(options.requestId); + const signal = resolveRunnerRequestSignal(options); const logicalLeaseContext = normalizeRunnerLogicalLeaseContext( options.runnerLeaseContext, device.id, diff --git a/src/platforms/apple/interactions.ts b/src/platforms/apple/interactions.ts index 873f2c38c..f7d975a58 100644 --- a/src/platforms/apple/interactions.ts +++ b/src/platforms/apple/interactions.ts @@ -60,6 +60,7 @@ export function iosRunnerOverrides( runnerOpts: RunnerOpts; } { const runnerOpts = { + signal: ctx.signal, verbose: ctx.verbose, logPath: ctx.logPath, traceLogPath: ctx.traceLogPath, diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index 62a64c487..4153dcdb5 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -79,7 +79,7 @@ export function createAppleInteractor( scope: options?.scope, raw: options?.raw, }, - runnerOpts, + mergeRunnerCallSignal(runnerOpts, options?.signal), ), { backend: 'xctest' }, ), @@ -178,6 +178,17 @@ export function createAppleInteractor( return withInjectedAppleRunnerTransport(device, runnerContext, interactor, runnerProvider); } +function mergeRunnerCallSignal( + options: RunnerCallOptions, + signal: AbortSignal | undefined, +): RunnerCallOptions { + if (!signal) return options; + return { + ...options, + signal: options.signal ? AbortSignal.any([options.signal, signal]) : signal, + }; +} + function readRunnerOrientation(result: Record): DeviceRotation { const orientation = result.orientation; if (typeof orientation === 'string' && DEVICE_ROTATIONS.includes(orientation as DeviceRotation)) { diff --git a/src/platforms/apple/os/macos/helper.test.ts b/src/platforms/apple/os/macos/helper.test.ts new file mode 100644 index 000000000..1ad8a22ee --- /dev/null +++ b/src/platforms/apple/os/macos/helper.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { createLocalAppleToolProvider, withAppleToolProvider } from '../../core/tool-provider.ts'; +import { runMacOsSnapshotAction } from './helper.ts'; + +test('macOS helper snapshot passes cancellation to the helper process', async () => { + const controller = new AbortController(); + let receivedSignal: AbortSignal | undefined; + const provider = createLocalAppleToolProvider({ + macosHelper: { + run: async (_args, options) => { + receivedSignal = options?.signal; + return { + exitCode: 0, + stdout: JSON.stringify({ + ok: true, + data: { + surface: 'desktop', + nodes: [], + truncated: false, + backend: 'macos-helper', + }, + }), + stderr: '', + }; + }, + }, + }); + + await withAppleToolProvider( + provider, + async () => await runMacOsSnapshotAction('desktop', { signal: controller.signal }), + ); + + assert.equal(receivedSignal, controller.signal); +}); diff --git a/src/platforms/apple/os/macos/helper.ts b/src/platforms/apple/os/macos/helper.ts index 9e1ac2830..72a45527a 100644 --- a/src/platforms/apple/os/macos/helper.ts +++ b/src/platforms/apple/os/macos/helper.ts @@ -252,10 +252,14 @@ export async function startMacOsAudioProbeProcess(options: { ); } -async function runMacOsHelper>(args: string[]): Promise { +async function runMacOsHelper>( + args: string[], + options: { signal?: AbortSignal } = {}, +): Promise { const helperOptions = { allowFailure: true, timeoutMs: 30_000, + signal: options.signal, }; const helperProvider = resolveAppleToolProvider().macosHelper; const helperPath = helperProvider @@ -343,7 +347,7 @@ export async function runMacOsAlertAction( export async function runMacOsSnapshotAction( surface: Exclude, - options: { bundleId?: string } = {}, + options: { bundleId?: string; signal?: AbortSignal } = {}, ): Promise<{ surface: Exclude; nodes: MacOsSnapshotNode[]; @@ -352,7 +356,7 @@ export async function runMacOsSnapshotAction( }> { const args = ['snapshot', '--surface', surface]; appendMacOsHelperContextArgs(args, options); - return await runMacOsHelper(args); + return await runMacOsHelper(args, { signal: options.signal }); } export async function runMacOsReadTextAction( diff --git a/src/platforms/linux/__tests__/atspi-bridge.test.ts b/src/platforms/linux/__tests__/atspi-bridge.test.ts index 16f30ed05..c993ad378 100644 --- a/src/platforms/linux/__tests__/atspi-bridge.test.ts +++ b/src/platforms/linux/__tests__/atspi-bridge.test.ts @@ -109,6 +109,19 @@ test('passes surface and limit args to Python script', async () => { assert.ok(callArgs.includes('10')); }); +test('passes capture cancellation to the AT-SPI process', async () => { + mockRunCmd.mockResolvedValue({ + exitCode: 0, + stdout: makePythonResult([]), + stderr: '', + }); + const controller = new AbortController(); + + await captureAccessibilityTree('desktop', { signal: controller.signal }); + + assert.equal(mockRunCmd.mock.calls[0]?.[2]?.signal, controller.signal); +}); + test('throws TOOL_MISSING when python3 is not found', async () => { mockWhichCmd.mockResolvedValue(false); diff --git a/src/platforms/linux/accessibility-types.ts b/src/platforms/linux/accessibility-types.ts index 69e23f4a3..fd42ccc04 100644 --- a/src/platforms/linux/accessibility-types.ts +++ b/src/platforms/linux/accessibility-types.ts @@ -6,6 +6,7 @@ export type LinuxTraversalOptions = { maxNodes?: number; maxDepth?: number; maxApps?: number; + signal?: AbortSignal; }; export type LinuxAccessibilityTree = { diff --git a/src/platforms/linux/atspi-bridge.ts b/src/platforms/linux/atspi-bridge.ts index 22ed0ec43..ea9798f8e 100644 --- a/src/platforms/linux/atspi-bridge.ts +++ b/src/platforms/linux/atspi-bridge.ts @@ -122,6 +122,7 @@ export async function captureAccessibilityTree( const result = await runLinuxToolCommand('python3', args, { allowFailure: true, timeoutMs: 30_000, + signal: options.signal, }); if (result.exitCode !== 0) { diff --git a/src/platforms/linux/snapshot.ts b/src/platforms/linux/snapshot.ts index 740eae653..55d464cc1 100644 --- a/src/platforms/linux/snapshot.ts +++ b/src/platforms/linux/snapshot.ts @@ -22,12 +22,15 @@ function resolveLinuxSurface(surface: SessionSurface | undefined): SnapshotSurfa return 'desktop'; } -export async function snapshotLinux(surface: SessionSurface | undefined): Promise<{ +export async function snapshotLinux( + surface: SessionSurface | undefined, + signal?: AbortSignal, +): Promise<{ nodes: RawSnapshotNode[]; truncated?: boolean; }> { const linuxSurface = resolveLinuxSurface(surface); - const result = await captureAccessibilityTree(linuxSurface); + const result = await captureAccessibilityTree(linuxSurface, { signal }); return { nodes: result.nodes, diff --git a/src/platforms/web/agent-browser-provider.test.ts b/src/platforms/web/agent-browser-provider.test.ts index 9a248e8ff..996a30e93 100644 --- a/src/platforms/web/agent-browser-provider.test.ts +++ b/src/platforms/web/agent-browser-provider.test.ts @@ -164,6 +164,26 @@ test('agent-browser provider normalizes snapshot refs, labels, values, and paren }); }); +test('agent-browser provider passes snapshot cancellation to the CLI process', async () => { + await withManagedAgentBrowserProvider({ session: 'web-session' }, async (provider) => { + const controller = new AbortController(); + let receivedSignal: AbortSignal | undefined; + + await withCommandExecutorOverride( + async (_cmd, _args, options) => { + receivedSignal = options?.signal; + return jsonResult({ + success: true, + data: { nodes: [], refs: [], truncated: false }, + }); + }, + async () => await provider.snapshot({ signal: controller.signal }), + ); + + assert.equal(receivedSignal, controller.signal); + }); +}); + test('agent-browser provider fetches snapshot rects only when requested', async () => { await withManagedAgentBrowserProvider({ session: 'web-session' }, async (provider) => { const calls: AgentBrowserCall[] = []; diff --git a/src/platforms/web/agent-browser-provider.ts b/src/platforms/web/agent-browser-provider.ts index 81e1e0d7d..9e573fe3d 100644 --- a/src/platforms/web/agent-browser-provider.ts +++ b/src/platforms/web/agent-browser-provider.ts @@ -38,8 +38,8 @@ export function createAgentBrowserWebProvider( options: AgentBrowserProviderOptions = {}, ): WebProvider { const session = options.session?.trim(); - const runJson = async (args: string[]): Promise => - await runAgentBrowserJson(args, { session, options }); + const runJson = async (args: string[], signal?: AbortSignal): Promise => + await runAgentBrowserJson(args, { session, options, signal }); return { async open(target) { @@ -55,7 +55,10 @@ export function createAgentBrowserWebProvider( await runJson(['record', 'stop']); }, async snapshot(snapshotOptions) { - return await captureAgentBrowserSnapshot(runJson, snapshotOptions); + return await captureAgentBrowserSnapshot( + (args) => runJson(args, snapshotOptions?.signal), + snapshotOptions, + ); }, async screenshot(outPath, screenshotOptions) { await runJson(['screenshot', ...(screenshotOptions?.fullscreen ? ['--full'] : []), outPath]); @@ -206,11 +209,15 @@ function isIgnorableBoxError(error: unknown): boolean { async function runAgentBrowserJson( args: string[], - params: { session: string | undefined; options: AgentBrowserProviderOptions }, + params: { + session: string | undefined; + options: AgentBrowserProviderOptions; + signal?: AbortSignal; + }, ): Promise { - const { session, options } = params; + const { session, options, signal } = params; const cliArgs = [...args, '--json', ...(session ? ['--session', session] : [])]; - const result = await runAgentBrowserCommand(cliArgs, options); + const result = await runAgentBrowserCommand(cliArgs, options, signal); const parsed = parseAgentBrowserJson(result.stdout, result.stderr, cliArgs, result.exitCode); return unwrapAgentBrowserJson(parsed, result, cliArgs); } @@ -218,6 +225,7 @@ async function runAgentBrowserJson( async function runAgentBrowserCommand( cliArgs: string[], options: AgentBrowserProviderOptions, + signal?: AbortSignal, ): Promise<{ stdout: string; stderr: string; @@ -233,6 +241,7 @@ async function runAgentBrowserCommand( allowFailure: true, env: tool.env, timeoutMs: AGENT_BROWSER_TIMEOUT_MS, + signal, }); stdout = result.stdout; stderr = result.stderr; diff --git a/src/platforms/web/provider.ts b/src/platforms/web/provider.ts index 68a17db05..6c52a4217 100644 --- a/src/platforms/web/provider.ts +++ b/src/platforms/web/provider.ts @@ -23,6 +23,7 @@ export type WebSnapshotOptions = { raw?: boolean; includeRects?: boolean; surface?: SessionSurface; + signal?: AbortSignal; }; export type WebSnapshotResult = { diff --git a/test/ci/android-workflow-evidence.json b/test/ci/android-workflow-evidence.json new file mode 100644 index 000000000..61e84a363 --- /dev/null +++ b/test/ci/android-workflow-evidence.json @@ -0,0 +1,6 @@ +{ + "workflow": ".github/workflows/android.yml", + "job": "smoke-android", + "step": "Run Android smoke checks", + "invocation": "node --experimental-strip-types src/bin.ts test test/integration/replays/android/01-settings.ad --retries 2 --report-junit test/artifacts/replays-android-smoke.junit.xml" +} diff --git a/test/ci/trusted-fixture-artifact.test.mjs b/test/ci/trusted-fixture-artifact.test.mjs index ee653590d..c1dc95a9d 100644 --- a/test/ci/trusted-fixture-artifact.test.mjs +++ b/test/ci/trusted-fixture-artifact.test.mjs @@ -64,11 +64,16 @@ test('producer, consumers, upload, and concurrency use the canonical platform-sc ); }); -test('Android smoke keeps its install/open/snapshot evidence in a checked-in script', (t) => { +test('Android smoke consumes the restored APK through catalog fixture E2E', (t) => { const workflow = parse(fs.readFileSync('.github/workflows/android.yml', 'utf8')); - const smokeStep = workflow.jobs['smoke-android'].steps.find( - (step) => step.name === 'Run Android smoke checks', + const replayEvidence = JSON.parse( + fs.readFileSync('test/ci/android-workflow-evidence.json', 'utf8'), ); + assert.equal(replayEvidence.workflow, '.github/workflows/android.yml'); + const evidenceJob = workflow.jobs[replayEvidence.job]; + assert.ok(evidenceJob, `missing declared Android replay job: ${replayEvidence.job}`); + const smokeStep = evidenceJob.steps.find((step) => step.name === replayEvidence.step); + assert.ok(smokeStep, `missing declared Android replay step: ${replayEvidence.step}`); const restoreStep = workflow.jobs['smoke-android'].steps.find( (step) => step.name === 'Restore fixture APK', ); @@ -76,17 +81,21 @@ test('Android smoke keeps its install/open/snapshot evidence in a checked-in scr (step) => step.name === 'Report fixture cache source', ); const assertion = fs.readFileSync('test/scripts/assert-android-fixture-snapshot.mjs', 'utf8'); - const smokeScript = fs.readFileSync('test/scripts/android-fixture-cache-smoke.sh', 'utf8'); const packageVersion = JSON.parse(fs.readFileSync('package.json', 'utf8')).version; - assert.match(smokeStep.with.script, /android-fixture-cache-smoke\.sh/); + assert.match(smokeStep.with.script, /AGENT_DEVICE_ANDROID_E2E=1/); assert.match(smokeStep.with.script, /steps\.fixture-app\.outputs\.apk-path/); assert.match(smokeStep.with.script, /steps\.fixture-app\.outputs\.app-id/); + assert.match(smokeStep.with.script, /smoke-android-emulator\.test\.ts/); + assert.ok( + smokeStep.with.script + .split('\n') + .map((line) => line.trim()) + .includes(replayEvidence.invocation), + `missing declared Android replay invocation: ${replayEvidence.invocation}`, + ); assert.equal(restoreStep.with['wait-for-artifact-seconds'], '600'); assert.match(sourceStep.run, /steps\.fixture-app\.outputs\.source/); - assert.match(smokeScript, /snapshot -i .*--json > "\$SNAPSHOT_PATH"/); - assert.match(smokeScript, /\[ -z "\$1" \] \|\| \[ -z "\$2" \]/); - assert.match(smokeScript, /assert-android-fixture-snapshot\.mjs/); assert.match(assertion, /metadata\.backend !== 'android-helper'/); assert.match(assertion, /metadata\.helperVersion !== packageVersion/); assert.match(assertion, /Agent Device Tester/); diff --git a/test/ci/upload-agent-device-artifacts.test.ts b/test/ci/upload-agent-device-artifacts.test.ts index a44d489f4..81fe7bbf6 100644 --- a/test/ci/upload-agent-device-artifacts.test.ts +++ b/test/ci/upload-agent-device-artifacts.test.ts @@ -41,9 +41,9 @@ test('the shared diagnostics artifact includes hidden files only from its declar 'hidden daemon, session, and runner diagnostics must be included', ).toBe(true); expect(paths(upload!)).toEqual([ - '${{ inputs.agent-home-dir }}/daemon.log', + '${{ inputs.agent-state-dir }}/daemon.log', '~/.agent-device/logs/**', - '${{ inputs.agent-home-dir }}/sessions/**', + '${{ inputs.agent-state-dir }}/sessions/**', '${{ inputs.runner-derived-path }}/.agent-device-runner-cache.json', '${{ inputs.runner-derived-path }}/Logs/**', 'test/artifacts/**', @@ -52,7 +52,7 @@ test('the shared diagnostics artifact includes hidden files only from its declar expect( paths(upload!).every((entry) => [ - '${{ inputs.agent-home-dir }}/', + '${{ inputs.agent-state-dir }}/', '${{ inputs.runner-derived-path }}/', '~/.agent-device/logs/', 'test/', diff --git a/test/integration/android-emulator-e2e/behavior-coverage.ts b/test/integration/android-emulator-e2e/behavior-coverage.ts new file mode 100644 index 000000000..ce09a8e77 --- /dev/null +++ b/test/integration/android-emulator-e2e/behavior-coverage.ts @@ -0,0 +1,52 @@ +export type AndroidEmulatorBehaviorId = + | 'android-resource-id-selectors' + | 'cold-start-deep-link-navigation' + | 'home-recents-restoration' + | 'orientation-fixture-state' + | 'safe-keyboard-dismissal' + | 'system-ime-keyboard' + | 'test-ime-restoration' + | 'test-ime-unicode-input'; + +type BehaviorCoverageEntry = { + assertion: string; + owner: string; +}; + +export const ANDROID_EMULATOR_BEHAVIOR_COVERAGE = { + 'android-resource-id-selectors': { + assertion: 'fixture test IDs are observed as Android resource IDs before their selectors act', + owner: 'smoke:automation-system', + }, + 'cold-start-deep-link-navigation': { + assertion: 'cold deep link renders payload and normal navigation returns through Back', + owner: 'smoke:automation-system', + }, + 'home-recents-restoration': { + assertion: + 'Home and Recents produce distinct Android system evidence before fixture restoration', + owner: 'smoke:automation-system', + }, + 'orientation-fixture-state': { + assertion: + 'fixture window state observes landscape and portrait after Android rotation commands', + owner: 'smoke:automation-system', + }, + 'safe-keyboard-dismissal': { + assertion: 'keyboard dismiss hides the IME while Checkout form remains on screen', + owner: 'smoke:keyboard-ime', + }, + 'system-ime-keyboard': { + assertion: 'visible keyboard belongs to the emulator system IME rather than the test helper', + owner: 'smoke:keyboard-ime', + }, + 'test-ime-restoration': { + assertion: + 'closing the form session restores the prior system IME with structured doctor proof', + owner: 'smoke:form-input', + }, + 'test-ime-unicode-input': { + assertion: 'the exact test IME package commits a Unicode form value that is read back', + owner: 'smoke:form-input', + }, +} as const satisfies Record; diff --git a/test/integration/android-emulator-e2e/contract-evidence.ts b/test/integration/android-emulator-e2e/contract-evidence.ts new file mode 100644 index 000000000..2a439ee08 --- /dev/null +++ b/test/integration/android-emulator-e2e/contract-evidence.ts @@ -0,0 +1,22 @@ +import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; + +export type AndroidContractCommand = (typeof PUBLIC_COMMANDS)[keyof typeof PUBLIC_COMMANDS]; + +export type AndroidContractEvidence = { + commands: readonly AndroidContractCommand[]; + owner: string; + testName: string; +}; + +export function defineAndroidContractEvidence( + owner: string, + commands: readonly AndroidContractCommand[], + testName: string, +): AndroidContractEvidence { + if (commands.length === 0) throw new Error(`${owner} must declare at least one command`); + if (new Set(commands).size !== commands.length) { + throw new Error(`${owner} declares duplicate Android contract commands`); + } + if (testName.trim().length === 0) throw new Error(`${owner} must name its executable test`); + return Object.freeze({ commands: Object.freeze([...commands]), owner, testName }); +} diff --git a/test/integration/android-emulator-e2e/coverage-manifest.ts b/test/integration/android-emulator-e2e/coverage-manifest.ts new file mode 100644 index 000000000..46d9ca32d --- /dev/null +++ b/test/integration/android-emulator-e2e/coverage-manifest.ts @@ -0,0 +1,248 @@ +import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; +import { ANDROID_ARTIFACTS_CONTRACT_EVIDENCE } from '../../../src/daemon/__tests__/http-server-artifacts.coverage.ts'; +import { ANDROID_AUDIO_CONTRACT_EVIDENCE } from '../../../src/daemon/handlers/__tests__/session-audio.coverage.ts'; +import { ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE } from '../../../src/platforms/__tests__/install-source.coverage.ts'; +import { + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + ANDROID_TOUCH_CONTRACT_EVIDENCE, +} from '../provider-scenarios/android-lifecycle.coverage.ts'; +import { ANDROID_RECORDING_CONTRACT_EVIDENCE } from '../provider-scenarios/android-recording.coverage.ts'; +import { ANDROID_TEST_SUITE_CONTRACT_EVIDENCE } from '../provider-scenarios/android-test-suite.coverage.ts'; +import type { AndroidContractEvidence } from './contract-evidence.ts'; +import androidReplayWorkflowEvidence from '../../ci/android-workflow-evidence.json' with { type: 'json' }; + +type PublicCommand = (typeof PUBLIC_COMMANDS)[keyof typeof PUBLIC_COMMANDS]; + +export type AndroidEmulatorCoverageEntry = + | { assertion: string; level: 'live'; scenario: string } + | { + assertion: string; + evidence: AndroidContractEvidence; + level: 'command-contract'; + } + | { assertion: string; level: 'capability-denial' } + | { + assertion: string; + evidence: typeof androidReplayWorkflowEvidence; + level: 'workflow-live'; + }; + +export type AndroidEmulatorCoverageClassificationSummary = { + capabilityDenial: number; + contract: number; + gap: number; + live: number; + total: number; +}; + +const C = PUBLIC_COMMANDS; +const live = (scenario: string, assertion: string): AndroidEmulatorCoverageEntry => ({ + assertion, + level: 'live', + scenario, +}); +const contract = ( + evidence: AndroidContractEvidence, + assertion: string, +): AndroidEmulatorCoverageEntry => ({ + assertion, + evidence, + level: 'command-contract', +}); + +/** One primary, observable owner for every public command on an Android emulator. */ +export const ANDROID_EMULATOR_E2E_COVERAGE = { + [C.alert]: live('smoke:automation-system', 'native alert actions update fixture-visible results'), + [C.appSwitcher]: live( + 'smoke:automation-system', + 'Recents pixels differ from Home and the fixture restores through Android app state', + ), + [C.apps]: live('smoke:inventory', 'installed fixture package appears in app inventory'), + [C.appState]: live( + 'smoke:automation-system', + 'Android foreground package changes on Home and returns to the fixture after restoration', + ), + [C.artifacts]: contract( + ANDROID_ARTIFACTS_CONTRACT_EVIDENCE, + 'daemon inventory exposes typed downloadable artifact bytes', + ), + [C.audio]: contract( + ANDROID_AUDIO_CONTRACT_EVIDENCE, + 'host audio probing has an Android-emulator session contract', + ), + [C.back]: live('smoke:automation-system', 'back returns from automation to the Settings tab'), + [C.batch]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario asserts typed nested Android batch outcomes', + ), + [C.boot]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario asserts typed Android boot result', + ), + [C.capabilities]: live( + 'smoke:inventory', + 'typed capability response includes fixture-driving Android commands', + ), + [C.click]: live('smoke:automation-system', 'resource-id selector opens fixture controls'), + [C.clipboard]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario round-trips Android clipboard text', + ), + [C.close]: live('smoke:capture-close', 'session inventory proves fixture lease removal'), + [C.devices]: live('smoke:inventory', 'selected emulator serial appears in inventory'), + [C.diff]: live( + 'smoke:automation-system', + 'snapshot diff observes the Automation-to-Settings transition', + ), + [C.doctor]: live('smoke:inventory', 'doctor discovers the installed fixture package'), + [C.events]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario records Android session command events', + ), + [C.fill]: live('smoke:form-input', 'replacement form text is read back from Android UI'), + [C.find]: live('smoke:automation-system', 'find observes the automation landmark'), + [C.focus]: live('smoke:form-input', 'snapshot-derived Android field point receives typed text'), + [C.gesture]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario verifies Android single- and multi-touch plans', + ), + [C.get]: live('smoke:automation-system', 'get returns fixture automation canary text'), + [C.home]: live( + 'smoke:automation-system', + 'Home changes Android foreground evidence before the fixture is restored', + ), + [C.install]: live('smoke:fixture-bootstrap', 'public CLI installs cached/repacked fixture APK'), + [C.installFromSource]: contract( + ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE, + 'Android install-source resolves an installable artifact with typed identity', + ), + [C.is]: live('smoke:automation-system', 'visible predicate passes for Android fixture node'), + [C.keyboard]: live('smoke:keyboard-ime', 'safe dismissal hides keyboard without navigating Back'), + [C.logs]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario starts, inspects, restarts, and stops Android logcat', + ), + [C.longPress]: live('smoke:automation-system', '800ms hold increments durable fixture counter'), + [C.network]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario returns typed Android network entries', + ), + [C.open]: live( + 'smoke:automation-system', + 'cold deep link and normal fixture launch render landmarks', + ), + [C.orientation]: live( + 'smoke:automation-system', + 'fixture window state observes landscape then portrait Android rotation', + ), + [C.perf]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario validates typed Android process metrics', + ), + [C.prepare]: { + assertion: 'Android emulator capability model rejects Apple runner preparation', + level: 'capability-denial', + }, + [C.press]: live('smoke:automation-system', 'semantic press updates durable fixture input state'), + [C.push]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario validates Android intent action and extras delivery', + ), + [C.reactNative]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario returns Android overlay dismissal state', + ), + [C.record]: contract( + ANDROID_RECORDING_CONTRACT_EVIDENCE, + 'Android recording finalizes through its durable manifest and pull contract', + ), + [C.reinstall]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario validates APK and bundle reinstall identities', + ), + [C.replay]: { + assertion: 'narrow Android Settings replay remains an additive live workflow check', + evidence: androidReplayWorkflowEvidence, + level: 'workflow-live', + }, + [C.screenshot]: live('smoke:capture-close', 'captured fixture file has a valid PNG signature'), + [C.scroll]: contract( + ANDROID_TOUCH_CONTRACT_EVIDENCE, + 'provider scenario validates Android scroll plans and resulting actions', + ), + [C.settings]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario validates Android device setting mutations', + ), + [C.shutdown]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario asserts typed Android shutdown result', + ), + [C.snapshot]: live( + 'smoke:automation-system', + 'interactive tree exposes Android resource-id fixture nodes', + ), + [C.swipe]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario validates Android swipe execution', + ), + [C.test]: contract( + ANDROID_TEST_SUITE_CONTRACT_EVIDENCE, + 'Android replay suite reports attempt outcomes and JUnit evidence', + ), + [C.trace]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario verifies Android trace lifecycle output', + ), + [C.triggerAppEvent]: contract( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + 'provider scenario validates Android deep-link event delivery', + ), + [C.tvRemote]: { + assertion: 'Android mobile emulator capability model rejects TV remote input', + level: 'capability-denial', + }, + [C.type]: live('smoke:form-input', 'typed suffix is read back from focused Android field'), + [C.viewport]: { + assertion: 'Android emulator capability model rejects standalone viewport control', + level: 'capability-denial', + }, + [C.wait]: live('smoke:automation-system', 'wait observes durable fixture landmarks'), +} satisfies Record; + +export const ANDROID_EMULATOR_COVERAGE_CLASSIFICATION_SUMMARY = buildCoverageClassificationSummary( + Object.values(ANDROID_EMULATOR_E2E_COVERAGE), +); + +export function liveCommandsForScenario(scenarioId: string): PublicCommand[] { + return Object.entries(ANDROID_EMULATOR_E2E_COVERAGE) + .filter(([, entry]) => entry.level === 'live' && entry.scenario === scenarioId) + .map(([command]) => command as PublicCommand); +} + +function buildCoverageClassificationSummary( + entries: readonly AndroidEmulatorCoverageEntry[], +): AndroidEmulatorCoverageClassificationSummary { + const summary: AndroidEmulatorCoverageClassificationSummary = { + capabilityDenial: 0, + contract: 0, + gap: 0, + live: 0, + total: entries.length, + }; + for (const entry of entries) { + switch (entry.level) { + case 'live': + case 'workflow-live': + summary.live += 1; + break; + case 'command-contract': + summary.contract += 1; + break; + case 'capability-denial': + summary.capabilityDenial += 1; + break; + } + } + return summary; +} diff --git a/test/integration/android-emulator-e2e/live-assertions.ts b/test/integration/android-emulator-e2e/live-assertions.ts new file mode 100644 index 000000000..70079c338 --- /dev/null +++ b/test/integration/android-emulator-e2e/live-assertions.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; + +import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; +import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; +import type { SnapshotDiffLine } from '../../../src/snapshot/snapshot-diff.ts'; +import { + assertFilesDiffer, + assertJsonContains, + createLiveDeviceAssertions, +} from '../live-device-e2e/assertions.ts'; +import type { CliJsonResult } from '../cli-json.ts'; +import type { AndroidEmulatorBehaviorId } from './behavior-coverage.ts'; +import { type LiveContext, runStep, verifyCommand } from './live-harness.ts'; + +export { assertFilesDiffer, assertJsonContains }; + +export const { assertElementText, assertWaitSelector, assertWaitText, capturePng } = + createLiveDeviceAssertions( + runStep, + verifyCommand, + PUBLIC_COMMANDS.wait, + ); + +export function assertDiffLine( + result: CliJsonResult, + kind: SnapshotDiffLine['kind'], + expectedText: string, +): void { + const lines: unknown = result.json?.data?.lines; + assert.ok(Array.isArray(lines), `snapshot diff has no lines: ${JSON.stringify(result.json)}`); + assert.ok( + lines.some( + (line: unknown) => + isSnapshotDiffLine(line) && line.kind === kind && line.text.includes(expectedText), + ), + `expected ${kind} snapshot line containing ${expectedText}: ${JSON.stringify(result.json)}`, + ); +} + +export function requireAndroidResourceId( + result: CliJsonResult, + suffix: string, +): { + identifier: string; + rect: { height: number; width: number; x: number; y: number }; +} { + const nodes: unknown = result.json?.data?.nodes; + assert.ok(Array.isArray(nodes), `snapshot has no nodes: ${JSON.stringify(result.json)}`); + const node = nodes.find( + (candidate: unknown): candidate is RawSnapshotNode & { identifier: string } => + isSnapshotNode(candidate) && + typeof candidate.identifier === 'string' && + (candidate.identifier === suffix || candidate.identifier.endsWith(`:id/${suffix}`)), + ); + assert.ok( + node, + `snapshot missing Android resource-id for ${suffix}: ${JSON.stringify(result.json)}`, + ); + assert.ok(node.rect, `resource-id ${suffix} has no rect`); + for (const value of [node.rect.x, node.rect.y, node.rect.width, node.rect.height]) { + assert.ok(Number.isFinite(value), `resource-id ${suffix} has invalid rect`); + } + return { identifier: node.identifier, rect: node.rect }; +} + +export function assertPersistentAndroidHelper( + result: CliJsonResult, + options: { reused?: boolean } = {}, +): void { + const metadata: unknown = result.json?.data?.androidSnapshot; + assert.ok( + typeof metadata === 'object' && metadata !== null, + `snapshot has no Android helper metadata: ${JSON.stringify(result.json)}`, + ); + const helper = metadata as Record; + assert.equal(helper.backend, 'android-helper', JSON.stringify(metadata)); + assert.equal(helper.helperTransport, 'persistent-session', JSON.stringify(metadata)); + if (options.reused !== undefined) { + assert.equal(helper.helperSessionReused, options.reused, JSON.stringify(metadata)); + } +} + +function isSnapshotNode(value: unknown): value is RawSnapshotNode & { identifier: string } { + if (typeof value !== 'object' || value === null) return false; + const node = value as RawSnapshotNode; + return typeof node.identifier === 'string'; +} + +function isSnapshotDiffLine(value: unknown): value is SnapshotDiffLine { + if (typeof value !== 'object' || value === null) return false; + const line = value as Partial; + return ( + (line.kind === 'added' || line.kind === 'removed' || line.kind === 'unchanged') && + typeof line.text === 'string' + ); +} diff --git a/test/integration/android-emulator-e2e/live-automation-scenario.ts b/test/integration/android-emulator-e2e/live-automation-scenario.ts new file mode 100644 index 000000000..81010436d --- /dev/null +++ b/test/integration/android-emulator-e2e/live-automation-scenario.ts @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; +import { + assertElementText, + assertDiffLine, + assertFilesDiffer, + assertJsonContains, + assertPersistentAndroidHelper, + assertWaitSelector, + assertWaitText, + capturePng, + requireAndroidResourceId, +} from './live-assertions.ts'; +import { type LiveContext, runStep, verifyBehavior, verifyCommand } from './live-harness.ts'; + +const C = PUBLIC_COMMANDS; + +export async function assertAutomationSystem(context: LiveContext): Promise { + await assertElementText(context, 'id="automation-event-name"', 'cold.start'); + await assertElementText( + context, + 'id="automation-event-payload"', + '{"source":"android-deep-link"}', + ); + verifyCommand(context, C.open, 'cold Android deep link renders decoded fixture event payload'); + verifyBehavior( + context, + 'cold-start-deep-link-navigation', + 'cold deep link rendered exact event and payload before normal navigation resumed', + ); + + await runStep(context, 'continue from cold deep link to fixture catalog', [ + 'click', + 'id="automation-continue-catalog"', + ]); + await assertWaitText(context, 'Catalog'); + const catalogSnapshot = await runStep(context, 'capture Android catalog navigation', [ + 'snapshot', + '-i', + ]); + assertPersistentAndroidHelper(catalogSnapshot, { reused: true }); + const settingsTab = ( + catalogSnapshot.json?.data?.nodes as { label?: unknown; ref?: unknown }[] | undefined + )?.find((node) => typeof node.label === 'string' && node.label.startsWith('Settings')); + const settingsRef = settingsTab?.ref; + assert.ok(typeof settingsRef === 'string', JSON.stringify(catalogSnapshot.json)); + await runStep(context, 'open Settings tab', ['click', `@${settingsRef}`]); + await assertWaitText(context, 'Settings'); + await runStep(context, 'open automation lab', ['click', 'id="open-automation-lab"']); + await assertWaitText(context, 'Automation lab'); + + const snapshot = await runStep(context, 'capture Android automation tree', ['snapshot', '-i']); + const eventName = requireAndroidResourceId(snapshot, 'automation-event-name'); + assert.match(eventName.identifier, /(^|:id\/)automation-event-name$/, eventName.identifier); + verifyCommand( + context, + C.snapshot, + `interactive tree exposes Android resource-id ${eventName.identifier}`, + ); + verifyBehavior( + context, + 'android-resource-id-selectors', + `observed ${eventName.identifier} before id selector-driven fixture actions`, + ); + + const heading = await runStep(context, 'read Android automation heading', [ + 'get', + 'text', + 'text="Automation lab"', + ]); + assertJsonContains(heading, 'Automation lab', 'get text should return automation heading'); + verifyCommand(context, C.get, 'get returns exact automation heading text'); + + const appState = await runStep(context, 'read Android fixture foreground app state', [ + 'appstate', + ]); + assert.equal(appState.json?.data?.package, context.appId, JSON.stringify(appState.json)); + verifyCommand( + context, + C.appState, + 'Android foreground package names the fixture before system UI', + ); + + const visible = await runStep(context, 'assert automation title visible', [ + 'is', + 'visible', + 'id="automation-title"', + ]); + assert.equal(visible.json?.data?.pass, true, JSON.stringify(visible.json)); + verifyCommand(context, C.is, 'visible predicate passes for automation resource-id'); + + const found = await runStep(context, 'find automation heading', [ + 'find', + 'text', + 'Automation lab', + 'exists', + ]); + assert.equal(found.json?.data?.found, true, JSON.stringify(found.json)); + verifyCommand(context, C.find, 'find observes the automation landmark'); + + await runStep(context, 'open Android fixture modal', ['click', 'id="automation-open-sheet"']); + await assertWaitText(context, 'Automation sheet'); + await runStep(context, 'close Android fixture modal', ['click', 'id="automation-close-sheet"']); + await assertWaitText(context, 'Automation lab'); + verifyCommand(context, C.click, 'resource-id selectors open and close fixture modal'); + + await assertOrientationFixtureState(context, 'landscape-left', 'landscape'); + await assertOrientationFixtureState(context, 'portrait', 'portrait'); + verifyCommand( + context, + C.orientation, + 'fixture window state observed landscape then portrait Android rotation', + ); + verifyBehavior( + context, + 'orientation-fixture-state', + 'fixture automation-window value changed to landscape and back to portrait', + ); + + await runStep(context, 'reveal input canaries', ['scroll', 'down', '0.7']); + await runStep(context, 'press semantic canary', ['press', 'id="automation-press"']); + await assertWaitText(context, 'Last input: press'); + verifyCommand(context, C.press, 'semantic press updates durable fixture input state'); + + await runStep(context, 'long press semantic canary', [ + 'longpress', + 'id="automation-longpress"', + '800', + ]); + await assertWaitText(context, 'Long presses: 1'); + verifyCommand(context, C.longPress, '800ms hold increments durable fixture long-press counter'); + + await runStep(context, 'open Android native alert', ['click', 'id="automation-open-alert"']); + await assertWaitText(context, 'Automation confirmation'); + const alertSnapshot = await runStep(context, 'capture native alert through persistent helper', [ + 'snapshot', + '-i', + ]); + assertPersistentAndroidHelper(alertSnapshot); + assertJsonContains( + alertSnapshot, + 'Automation confirmation', + 'persistent helper snapshot should expose fixture dialog', + ); + const reusedAlertSnapshot = await runStep(context, 'reuse persistent helper on native alert', [ + 'snapshot', + '-i', + ]); + assertPersistentAndroidHelper(reusedAlertSnapshot, { reused: true }); + assertJsonContains( + reusedAlertSnapshot, + 'Automation confirmation', + 'reused persistent helper snapshot should retain the fixture dialog', + ); + const alert = await runStep(context, 'inspect Android native alert', ['alert', 'get']); + assertJsonContains(alert, 'Automation confirmation', 'alert get should expose fixture dialog'); + await runStep(context, 'dismiss Android native alert', ['alert', 'dismiss']); + await assertWaitText(context, 'Alert result: cancelled'); + await runStep(context, 'reopen Android native alert', ['click', 'id="automation-open-alert"']); + await runStep(context, 'accept Android native alert', ['alert', 'accept']); + await assertWaitText(context, 'Alert result: accepted'); + verifyCommand(context, C.alert, 'alert wait/get/dismiss/accept produce fixture-visible results'); + + await assertHomeAndRecentsRestoration(context); + await runStep(context, 'establish automation diff baseline', ['snapshot', '-i']); + await runStep(context, 'return from automation route with Back', ['back']); + const diff = await runStep(context, 'observe automation-to-settings diff', [ + 'diff', + 'snapshot', + '-i', + ]); + assertDiffLine(diff, 'removed', 'Open automation alert'); + assertDiffLine(diff, 'added', 'Open automation lab'); + await assertWaitText(context, 'Settings'); + verifyCommand(context, C.diff, 'snapshot diff reports the Automation-to-Settings transition'); + verifyCommand(context, C.back, 'Android Back returns from automation route to Settings'); +} + +async function assertOrientationFixtureState( + context: LiveContext, + orientation: 'landscape-left' | 'portrait', + expectedWindow: 'landscape' | 'portrait', +): Promise { + await runStep(context, `set Android ${orientation} orientation`, ['orientation', orientation]); + await assertWaitText(context, expectedWindow); + await assertElementText(context, 'id="automation-window"', expectedWindow); +} + +async function assertHomeAndRecentsRestoration(context: LiveContext): Promise { + const foreground = path.join(context.artifactDir, 'foreground.png'); + await capturePng(context, 'capture Android fixture foreground', foreground); + await runStep(context, 'send Android fixture Home', ['home']); + const homeState = await runStep(context, 'read Android Home foreground state', ['appstate']); + assert.notEqual(homeState.json?.data?.package, context.appId, JSON.stringify(homeState.json)); + const home = path.join(context.artifactDir, 'home.png'); + await capturePng(context, 'capture Android Home surface', home); + assertFilesDiffer(foreground, home, 'Android Home should replace fixture pixels'); + verifyCommand(context, C.home, 'Home changes Android foreground package and system pixels'); + + await runStep(context, 'open Android Recents', ['app-switcher']); + const recents = path.join(context.artifactDir, 'recents.png'); + await capturePng(context, 'capture Android Recents surface', recents); + assertFilesDiffer(home, recents, 'Android Recents should differ from Home pixels'); + verifyCommand(context, C.appSwitcher, 'Recents pixels differ from Home system surface'); + + await runStep(context, 'restore fixture after Android system UI', ['open', context.appId]); + await assertWaitSelector(context, 'id="automation-open-sheet"'); + const restored = await runStep(context, 'verify restored Android fixture foreground state', [ + 'appstate', + ]); + assert.equal(restored.json?.data?.package, context.appId, JSON.stringify(restored.json)); + verifyBehavior( + context, + 'home-recents-restoration', + 'Android foreground package and distinct Home/Recents screenshots prove restoration path', + ); +} diff --git a/test/integration/android-emulator-e2e/live-bootstrap.ts b/test/integration/android-emulator-e2e/live-bootstrap.ts new file mode 100644 index 000000000..8079406b2 --- /dev/null +++ b/test/integration/android-emulator-e2e/live-bootstrap.ts @@ -0,0 +1,10 @@ +import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; +import { type LiveContext, runStep, verifyCommand } from './live-harness.ts'; + +export async function installCachedFixture(context: LiveContext): Promise { + await runStep(context, 'install cached fixture APK through public CLI', [ + 'install', + context.appPath, + ]); + verifyCommand(context, PUBLIC_COMMANDS.install, 'cached fixture APK installs through public CLI'); +} diff --git a/test/integration/android-emulator-e2e/live-capture-scenario.ts b/test/integration/android-emulator-e2e/live-capture-scenario.ts new file mode 100644 index 000000000..d25f769db --- /dev/null +++ b/test/integration/android-emulator-e2e/live-capture-scenario.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; +import { assertPngFile } from '../provider-scenarios/assertions.ts'; +import { type LiveContext, runStep, verifyCommand } from './live-harness.ts'; + +const C = PUBLIC_COMMANDS; + +export async function assertCaptureAndClose(context: LiveContext): Promise { + const screenshotPath = path.join(context.artifactDir, 'fixture-smoke.png'); + const screenshot = await runStep(context, 'capture fixture screenshot', [ + 'screenshot', + screenshotPath, + '--max-size', + '900', + ]); + assert.ok( + JSON.stringify(screenshot.json?.data).includes(screenshotPath), + JSON.stringify(screenshot.json), + ); + assertPngFile(screenshotPath); + verifyCommand(context, C.screenshot, 'captured Android fixture file has a valid PNG signature'); + + await runStep(context, 'close fixture session', ['close']); + const sessions = await runStep(context, 'verify fixture session released', ['session', 'list'], { + commonFlags: false, + }); + const inventory = Array.isArray(sessions.json?.data?.sessions) ? sessions.json.data.sessions : []; + assert.equal( + inventory.some((session: { name?: unknown }) => session.name === context.session), + false, + JSON.stringify(sessions.json), + ); + verifyCommand(context, C.close, 'session inventory proves Android fixture lease was removed'); +} diff --git a/test/integration/android-emulator-e2e/live-coverage-report.ts b/test/integration/android-emulator-e2e/live-coverage-report.ts new file mode 100644 index 000000000..d785ba98c --- /dev/null +++ b/test/integration/android-emulator-e2e/live-coverage-report.ts @@ -0,0 +1,38 @@ +import { + assertCoverageComplete as assertLiveCoverageComplete, + writeCoverageReport as writeLiveCoverageReport, +} from '../live-device-e2e/coverage.ts'; +import { + ANDROID_EMULATOR_BEHAVIOR_COVERAGE, + type AndroidEmulatorBehaviorId, +} from './behavior-coverage.ts'; +import { + ANDROID_EMULATOR_COVERAGE_CLASSIFICATION_SUMMARY, + liveCommandsForScenario, +} from './coverage-manifest.ts'; +import type { LiveContext } from './live-harness.ts'; + +export function assertCoverageComplete( + context: LiveContext, + selectedScenarios: readonly { id: string }[], +): void { + assertLiveCoverageComplete( + context, + selectedScenarios, + liveCommandsForScenario, + liveBehaviorsForScenario, + 'Android emulator E2E coverage is incomplete', + ); +} + +export function writeCoverageReport(context: LiveContext): string { + return writeLiveCoverageReport(context, { + classificationSummary: ANDROID_EMULATOR_COVERAGE_CLASSIFICATION_SUMMARY, + }); +} + +export function liveBehaviorsForScenario(scenarioId: string): AndroidEmulatorBehaviorId[] { + return Object.entries(ANDROID_EMULATOR_BEHAVIOR_COVERAGE) + .filter(([, entry]) => entry.owner === scenarioId) + .map(([behavior]) => behavior as AndroidEmulatorBehaviorId); +} diff --git a/test/integration/android-emulator-e2e/live-form-scenario.ts b/test/integration/android-emulator-e2e/live-form-scenario.ts new file mode 100644 index 000000000..8edce3c0c --- /dev/null +++ b/test/integration/android-emulator-e2e/live-form-scenario.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; +import { + assertElementText, + assertFilesDiffer, + assertJsonContains, + assertWaitText, + capturePng, + requireAndroidResourceId, +} from './live-assertions.ts'; +import { type LiveContext, runStep, verifyBehavior, verifyCommand } from './live-harness.ts'; + +const C = PUBLIC_COMMANDS; +const ANDROID_TEST_IME_PACKAGE = 'com.callstack.agentdevice.imehelper'; +const ANDROID_TEST_IME_SERVICE = `${ANDROID_TEST_IME_PACKAGE}/.TestInputMethodService`; + +export async function assertFormInput(context: LiveContext): Promise { + const keyboardStatus = await runStep(context, 'verify deterministic test IME active', [ + 'keyboard', + 'status', + ]); + assert.equal( + keyboardStatus.json?.data?.inputMethodPackage, + ANDROID_TEST_IME_PACKAGE, + JSON.stringify(keyboardStatus.json), + ); + + await runStep(context, 'fill Unicode full name through test IME', [ + 'fill', + 'id="field-name"', + 'Ada Łovelace', + ]); + const name = await runStep(context, 'read filled full name', ['get', 'text', 'id="field-name"']); + assertJsonContains(name, 'Ada Łovelace', 'Unicode name should be observable in Android UI'); + verifyCommand(context, C.fill, 'Unicode replacement text is read back from Android UI'); + verifyBehavior( + context, + 'test-ime-unicode-input', + `active ${ANDROID_TEST_IME_PACKAGE} committed and read back Ada Łovelace`, + ); + + await runStep(context, 'fill email field', ['fill', 'id="field-email"', 'ada@example']); + const snapshot = await runStep(context, 'locate email Android resource-id', ['snapshot', '-i']); + const email = requireAndroidResourceId(snapshot, 'field-email'); + await runStep(context, 'focus email by snapshot-derived point', [ + 'focus', + String(email.rect.x + email.rect.width / 2), + String(email.rect.y + email.rect.height / 2), + ]); + await runStep(context, 'append email suffix', ['type', '.test']); + await assertElementText(context, 'id="field-email"', 'ada@example.test'); + verifyCommand(context, C.focus, `snapshot resource-id ${email.identifier} directs focus`); + verifyCommand(context, C.type, 'typed suffix is read back from focused Android field'); + + await runStep(context, 'close test IME session and restore prior input method', ['close']); + const doctor = await runStep(context, 'verify prior Android IME restored', [ + 'doctor', + '--app', + context.appId, + ]); + const checks: unknown = doctor.json?.data?.checks; + assert.ok(Array.isArray(checks), JSON.stringify(doctor.json)); + const imeCheck = checks.find( + ( + check: unknown, + ): check is { evidence?: { currentIme?: unknown }; id: string; status?: unknown } => + typeof check === 'object' && + check !== null && + (check as { id?: unknown }).id === 'android-test-ime', + ); + assert.ok(imeCheck, JSON.stringify(doctor.json)); + assert.equal(imeCheck.status, 'pass', JSON.stringify(imeCheck)); + assert.equal(typeof imeCheck.evidence?.currentIme, 'string', JSON.stringify(imeCheck)); + assert.notEqual( + imeCheck.evidence?.currentIme, + ANDROID_TEST_IME_SERVICE, + JSON.stringify(imeCheck), + ); + verifyBehavior( + context, + 'test-ime-restoration', + `close restored ${String(imeCheck.evidence?.currentIme)} instead of ${ANDROID_TEST_IME_SERVICE}`, + ); +} + +export async function assertKeyboardIme(context: LiveContext): Promise { + await runStep(context, 'focus email with the emulator keyboard', [ + 'click', + 'id="field-email"', + '--settle', + ]); + const keyboardStatus = await runStep(context, 'verify Android keyboard visible', [ + 'keyboard', + 'status', + ]); + assert.equal(keyboardStatus.json?.data?.visible, true, JSON.stringify(keyboardStatus.json)); + assert.equal( + typeof keyboardStatus.json?.data?.inputMethodPackage, + 'string', + JSON.stringify(keyboardStatus.json), + ); + assert.notEqual( + keyboardStatus.json?.data?.inputMethodPackage, + ANDROID_TEST_IME_PACKAGE, + JSON.stringify(keyboardStatus.json), + ); + + const keyboardVisiblePath = path.join(context.artifactDir, 'keyboard-visible.png'); + await capturePng(context, 'capture visible Android keyboard', keyboardVisiblePath); + const dismiss = await runStep(context, 'dismiss Android keyboard safely', [ + 'keyboard', + 'dismiss', + ]); + assert.equal(dismiss.json?.data?.dismissed, true, JSON.stringify(dismiss.json)); + assert.equal(dismiss.json?.data?.visible, false, JSON.stringify(dismiss.json)); + assert.notEqual( + dismiss.json?.data?.inputMethodPackage, + ANDROID_TEST_IME_PACKAGE, + JSON.stringify(dismiss.json), + ); + + const keyboardHiddenPath = path.join(context.artifactDir, 'keyboard-hidden.png'); + await capturePng(context, 'capture dismissed Android keyboard', keyboardHiddenPath); + assertFilesDiffer( + keyboardVisiblePath, + keyboardHiddenPath, + 'keyboard dismissal should change emulator pixels', + ); + await assertWaitText(context, 'Checkout form'); + verifyCommand( + context, + C.keyboard, + 'dismiss reports hidden keyboard while the Checkout form remains on screen', + ); + verifyBehavior( + context, + 'safe-keyboard-dismissal', + 'before/after pixels changed and Checkout form remained visible after non-Back dismissal', + ); + verifyBehavior( + context, + 'system-ime-keyboard', + `visible and dismissed keyboard used ${String(keyboardStatus.json?.data?.inputMethodPackage)}`, + ); +} diff --git a/test/integration/android-emulator-e2e/live-harness.ts b/test/integration/android-emulator-e2e/live-harness.ts new file mode 100644 index 000000000..208e07baf --- /dev/null +++ b/test/integration/android-emulator-e2e/live-harness.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + createLiveDeviceContext, + createLiveDeviceHarness, + requiredEnv, + type LiveDeviceContext, +} from '../live-device-e2e/runtime.ts'; +import type { AndroidEmulatorBehaviorId } from './behavior-coverage.ts'; +import { liveCommandsForScenario } from './coverage-manifest.ts'; +import { liveBehaviorsForScenario, writeCoverageReport } from './live-coverage-report.ts'; + +export { assertCoverageComplete, writeCoverageReport } from './live-coverage-report.ts'; + +export type LiveContext = LiveDeviceContext & { + appId: string; + appPath: string; + serial: string; +}; + +export function createContext(): LiveContext { + return { + ...createLiveDeviceContext({ + artifactRoot: 'test/artifacts/android-emulator', + session: `android-e2e-${process.pid.toString(36)}`, + }), + appId: requiredEnv('AGENT_DEVICE_FIXTURE_APP_ID', 'AGENT_DEVICE_ANDROID_E2E'), + appPath: requiredEnv('AGENT_DEVICE_FIXTURE_APP_PATH', 'AGENT_DEVICE_ANDROID_E2E'), + serial: requiredEnv('AGENT_DEVICE_ANDROID_SERIAL', 'AGENT_DEVICE_ANDROID_E2E'), + }; +} + +const harness = createLiveDeviceHarness({ + behaviorsForScenario: liveBehaviorsForScenario, + commandsForScenario: liveCommandsForScenario, + commonFlags: (context, args) => [ + ...args, + '--platform', + 'android', + '--serial', + context.serial, + '--session', + context.session, + ...(args.includes('--json') ? [] : ['--json']), + ], + writeCoverageReport, +}); + +export const { runScenario, runStep, sessionExists, verifyBehavior, verifyCommand } = harness; + +export async function cleanupSession(context: LiveContext): Promise { + const failures: unknown[] = []; + if (context.sessionOpen) { + for (const [step, args] of [ + ['restore portrait orientation', ['orientation', 'portrait']], + ['close fixture session', ['close']], + ] as const) { + try { + await runStep(context, `cleanup: ${step}`, [...args]); + } catch (error) { + failures.push(error); + } + } + } + if (failures.length === 0) return; + const errorPath = path.join(context.artifactDir, 'cleanup-error.txt'); + fs.writeFileSync(errorPath, failures.map(String).join('\n\n')); + throw new AggregateError(failures, `Android E2E cleanup failed; details: ${errorPath}`); +} diff --git a/test/integration/android-emulator-e2e/live-inventory-scenario.ts b/test/integration/android-emulator-e2e/live-inventory-scenario.ts new file mode 100644 index 000000000..64f87b27d --- /dev/null +++ b/test/integration/android-emulator-e2e/live-inventory-scenario.ts @@ -0,0 +1,37 @@ +import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; +import { assertJsonContains } from './live-assertions.ts'; +import { type LiveContext, runStep, verifyCommand } from './live-harness.ts'; + +const C = PUBLIC_COMMANDS; + +export async function assertInventoryAndInstall(context: LiveContext): Promise { + const devices = await runStep(context, 'list Android devices', ['devices']); + assertJsonContains( + devices, + context.serial, + 'device inventory should include selected emulator serial', + ); + verifyCommand(context, C.devices, 'selected emulator serial appears in typed inventory'); + + const capabilities = await runStep(context, 'read Android capabilities', ['capabilities']); + for (const command of ['click', 'fill', 'snapshot', 'type']) { + assertJsonContains(capabilities, command, `capabilities should include ${command}`); + } + verifyCommand( + context, + C.capabilities, + 'typed capability response includes fixture-driving commands', + ); + + const apps = await runStep(context, 'list installed user apps', ['apps']); + assertJsonContains(apps, context.appId, 'app inventory should include fixture package'); + verifyCommand(context, C.apps, 'installed fixture package appears in app inventory'); + + const doctor = await runStep(context, 'doctor fixture package discovery', [ + 'doctor', + '--app', + context.appId, + ]); + assertJsonContains(doctor, context.appId, 'doctor should discover fixture package'); + verifyCommand(context, C.doctor, 'doctor discovers the installed fixture package'); +} diff --git a/test/integration/android-emulator-e2e/live-runner.ts b/test/integration/android-emulator-e2e/live-runner.ts new file mode 100644 index 000000000..eaa858d51 --- /dev/null +++ b/test/integration/android-emulator-e2e/live-runner.ts @@ -0,0 +1,78 @@ +import { + assertCoverageComplete, + cleanupSession, + createContext, + runScenario, + sessionExists, + writeCoverageReport, +} from './live-harness.ts'; +import { ANDROID_EMULATOR_FIXTURE_BOOTSTRAP, selectAndroidEmulatorScenarios } from './scenarios.ts'; +import { prepareAndroidEmulatorScenario } from './scenario-start.ts'; + +const SCENARIO_FILTER_ENV = 'AGENT_DEVICE_ANDROID_E2E_SCENARIOS'; + +export type AndroidEmulatorE2EOptions = { + scenarioIds?: readonly string[]; +}; + +export async function runAndroidEmulatorE2E( + options: AndroidEmulatorE2EOptions = {}, +): Promise { + const requestedIds = options.scenarioIds ?? scenarioIdsFromEnv(process.env[SCENARIO_FILTER_ENV]); + const selectedScenarios = selectAndroidEmulatorScenarios(requestedIds); + const executedScenarios = [ANDROID_EMULATOR_FIXTURE_BOOTSTRAP, ...selectedScenarios]; + const context = createContext(); + let primaryError: unknown; + try { + for (const scenario of executedScenarios) { + await runScenario(context, { + id: scenario.id, + run: async (scenarioContext) => { + await prepareAndroidEmulatorScenario(scenarioContext, scenario.start); + await scenario.run(scenarioContext); + }, + }); + } + assertCoverageComplete(context, executedScenarios); + } catch (error) { + primaryError = error; + } + + let cleanupError: unknown; + try { + context.sessionOpen = context.sessionOpen || (await sessionExists(context)); + await cleanupSession(context); + } catch (error) { + cleanupError = error; + } + try { + const reportPath = writeCoverageReport(context); + console.log(`Android emulator coverage report: ${reportPath}`); + console.log(`Android emulator live run: ${Date.now() - context.startedAtMs}ms`); + for (const timing of context.timings) { + console.log(` ${timing.id}: ${timing.durationMs}ms`); + } + } catch (error) { + cleanupError = cleanupError ?? error; + } + if (primaryError !== undefined && cleanupError !== undefined) { + throw new AggregateError( + [primaryError, cleanupError], + 'Android E2E failed and cleanup also failed', + ); + } + if (primaryError !== undefined) throw primaryError; + if (cleanupError !== undefined) throw cleanupError; +} + +export function scenarioIdsFromEnv(value: string | undefined): string[] | undefined { + if (value === undefined) return undefined; + const scenarioIds = value + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + if (scenarioIds.length === 0) { + throw new Error(`${SCENARIO_FILTER_ENV} contains no Android emulator E2E scenario ids`); + } + return scenarioIds; +} diff --git a/test/integration/android-emulator-e2e/scenario-start.ts b/test/integration/android-emulator-e2e/scenario-start.ts new file mode 100644 index 000000000..964c13894 --- /dev/null +++ b/test/integration/android-emulator-e2e/scenario-start.ts @@ -0,0 +1,27 @@ +import { assertWaitText } from './live-assertions.ts'; +import { type LiveContext, runStep } from './live-harness.ts'; + +export type AndroidEmulatorScenarioStart = { + ime: 'system' | 'test'; + landmark: string; + url: string; +}; + +export async function prepareAndroidEmulatorScenario( + context: LiveContext, + start?: AndroidEmulatorScenarioStart, +): Promise { + if (context.sessionOpen) { + await runStep(context, 'close prior scenario session', ['close']); + } + if (!start) return; + + await runStep(context, `open deterministic fixture route with ${start.ime} IME`, [ + 'open', + context.appId, + '--relaunch', + ...(start.ime === 'system' ? ['--no-test-ime'] : []), + start.url, + ]); + await assertWaitText(context, start.landmark); +} diff --git a/test/integration/android-emulator-e2e/scenarios.ts b/test/integration/android-emulator-e2e/scenarios.ts new file mode 100644 index 000000000..bf20445cd --- /dev/null +++ b/test/integration/android-emulator-e2e/scenarios.ts @@ -0,0 +1,125 @@ +import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; +import type { AndroidEmulatorBehaviorId } from './behavior-coverage.ts'; +import { assertAutomationSystem } from './live-automation-scenario.ts'; +import { installCachedFixture } from './live-bootstrap.ts'; +import { assertCaptureAndClose } from './live-capture-scenario.ts'; +import { assertFormInput, assertKeyboardIme } from './live-form-scenario.ts'; +import type { LiveContext } from './live-harness.ts'; +import { assertInventoryAndInstall } from './live-inventory-scenario.ts'; +import type { AndroidEmulatorScenarioStart } from './scenario-start.ts'; + +const C = PUBLIC_COMMANDS; +const AUTOMATION_DEEP_LINK = + 'agent-device-test-app:///automation?event=cold.start&payload=%7B%22source%22%3A%22android-deep-link%22%7D'; + +export type AndroidEmulatorScenario = { + behaviors: readonly AndroidEmulatorBehaviorId[]; + commands: readonly string[]; + id: string; + run: (context: LiveContext) => Promise; + start?: AndroidEmulatorScenarioStart; +}; + +export const ANDROID_EMULATOR_FIXTURE_BOOTSTRAP: AndroidEmulatorScenario = { + behaviors: [], + commands: [C.install], + id: 'smoke:fixture-bootstrap', + run: installCachedFixture, +}; + +export const ANDROID_EMULATOR_LIVE_SCENARIOS = [ + { + behaviors: [], + commands: [C.devices, C.capabilities, C.apps, C.doctor], + id: 'smoke:inventory', + run: assertInventoryAndInstall, + }, + { + behaviors: [ + 'android-resource-id-selectors', + 'cold-start-deep-link-navigation', + 'home-recents-restoration', + 'orientation-fixture-state', + ], + commands: [ + C.alert, + C.appSwitcher, + C.appState, + C.back, + C.click, + C.diff, + C.find, + C.get, + C.home, + C.is, + C.longPress, + C.open, + C.orientation, + C.press, + C.snapshot, + C.wait, + ], + id: 'smoke:automation-system', + run: assertAutomationSystem, + start: { + ime: 'system', + landmark: 'Automation lab', + url: AUTOMATION_DEEP_LINK, + }, + }, + { + behaviors: ['test-ime-restoration', 'test-ime-unicode-input'], + commands: [C.fill, C.focus, C.type], + id: 'smoke:form-input', + run: assertFormInput, + start: { + ime: 'test', + landmark: 'Checkout form', + url: 'agent-device-test-app:///form', + }, + }, + { + behaviors: ['safe-keyboard-dismissal', 'system-ime-keyboard'], + commands: [C.keyboard], + id: 'smoke:keyboard-ime', + run: assertKeyboardIme, + start: { + ime: 'system', + landmark: 'Checkout form', + url: 'agent-device-test-app:///form', + }, + }, + { + behaviors: [], + commands: [C.close, C.screenshot], + id: 'smoke:capture-close', + run: assertCaptureAndClose, + start: { + ime: 'system', + landmark: 'Agent Device Tester', + url: 'agent-device-test-app:///', + }, + }, +] as const satisfies readonly AndroidEmulatorScenario[]; + +export type AndroidEmulatorScenarioId = (typeof ANDROID_EMULATOR_LIVE_SCENARIOS)[number]['id']; + +export function selectAndroidEmulatorScenarios( + requestedIds?: readonly string[], +): readonly AndroidEmulatorScenario[] { + if (requestedIds === undefined) return ANDROID_EMULATOR_LIVE_SCENARIOS; + if (requestedIds.length === 0) { + throw new Error('select at least one Android emulator E2E scenario'); + } + const byId = new Map(ANDROID_EMULATOR_LIVE_SCENARIOS.map((scenario) => [scenario.id, scenario])); + const selected: AndroidEmulatorScenario[] = []; + const seen = new Set(); + for (const id of requestedIds) { + if (seen.has(id)) throw new Error(`duplicate Android emulator E2E scenario: ${id}`); + const scenario = byId.get(id as AndroidEmulatorScenarioId); + if (!scenario) throw new Error(`unknown Android emulator E2E scenario: ${id}`); + seen.add(id); + selected.push(scenario); + } + return selected; +} diff --git a/test/integration/interaction-contract/native-ref.contract.test.ts b/test/integration/interaction-contract/native-ref.contract.test.ts index 5365e6780..854739ec0 100644 --- a/test/integration/interaction-contract/native-ref.contract.test.ts +++ b/test/integration/interaction-contract/native-ref.contract.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; -import { ref } from '../../../src/commands/interaction/runtime/selector-read.ts'; +import { ref } from '../../../src/commands/interaction/runtime/selector-read-utils.ts'; import { scenarioName } from './coverage-manifest.ts'; import { buildInteractionResponseData } from '../../../src/daemon/handlers/interaction-touch-response.ts'; import { NATIVE_REF_COVERAGE } from './native-ref.coverage.ts'; diff --git a/test/integration/interaction-contract/resolution-disclosure-mutation.contract.test.ts b/test/integration/interaction-contract/resolution-disclosure-mutation.contract.test.ts index fbaefe565..4bdca0fee 100644 --- a/test/integration/interaction-contract/resolution-disclosure-mutation.contract.test.ts +++ b/test/integration/interaction-contract/resolution-disclosure-mutation.contract.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { ref, selector } from '../../../src/commands/interaction/runtime/selector-read.ts'; +import { ref, selector } from '../../../src/commands/interaction/runtime/selector-read-utils.ts'; import { drawerWithVisibleTwinSnapshot } from './fixtures.ts'; import { createContractDevice } from './runtime-harness.ts'; diff --git a/test/integration/interaction-contract/runtime-ref.contract.test.ts b/test/integration/interaction-contract/runtime-ref.contract.test.ts index 4d3118ac5..ee377c960 100644 --- a/test/integration/interaction-contract/runtime-ref.contract.test.ts +++ b/test/integration/interaction-contract/runtime-ref.contract.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts'; import type { Point } from '@agent-device/kernel/snapshot'; -import { ref } from '../../../src/commands/interaction/runtime/selector-read.ts'; +import { ref } from '../../../src/commands/interaction/runtime/selector-read-utils.ts'; import { assertRpcOk } from '../provider-scenarios/assertions.ts'; import { scenarioName, scenarioNames } from './coverage-manifest.ts'; import { RUNTIME_REF_COVERAGE } from './runtime-ref.coverage.ts'; diff --git a/test/integration/interaction-contract/runtime-selector.contract.test.ts b/test/integration/interaction-contract/runtime-selector.contract.test.ts index 6fade22f9..fa8d03190 100644 --- a/test/integration/interaction-contract/runtime-selector.contract.test.ts +++ b/test/integration/interaction-contract/runtime-selector.contract.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { InteractionGuarantee } from '../../../src/contracts/interaction-guarantees.ts'; import type { Point } from '@agent-device/kernel/snapshot'; -import { selector } from '../../../src/commands/interaction/runtime/selector-read.ts'; +import { selector } from '../../../src/commands/interaction/runtime/selector-read-utils.ts'; import { assertRpcOk } from '../provider-scenarios/assertions.ts'; import { scenarioName, scenarioNames } from './coverage-manifest.ts'; import { RUNTIME_SELECTOR_COVERAGE } from './runtime-selector.coverage.ts'; diff --git a/test/integration/ios-simulator-e2e/live-assertions.ts b/test/integration/ios-simulator-e2e/live-assertions.ts index 73bca634b..a259b65f2 100644 --- a/test/integration/ios-simulator-e2e/live-assertions.ts +++ b/test/integration/ios-simulator-e2e/live-assertions.ts @@ -3,38 +3,21 @@ import fs from 'node:fs'; import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts'; import { isPlayableVideo } from '../../../src/utils/video.ts'; -import { assertPngFile } from '../provider-scenarios/assertions.ts'; +import { + assertFilesDiffer, + assertJsonContains, + createLiveDeviceAssertions, +} from '../live-device-e2e/assertions.ts'; import type { CliJsonResult } from '../cli-json.ts'; +import type { IosSimulatorBehaviorId } from './behavior-coverage.ts'; import { type LiveContext, runStep, verifyCommand } from './live-harness.ts'; -export function assertJsonContains(result: CliJsonResult, expected: string, message: string): void { - const serialized = JSON.stringify(result.json?.data ?? result.json); - assert.ok(serialized.includes(expected), `${message}\nreceived: ${serialized}`); -} - -export async function assertWaitText(context: LiveContext, expected: string): Promise { - const result = await runStep(context, `wait for ${expected}`, [ - 'wait', - 'text', - expected, - '10000', - ]); - assertJsonContains(result, expected, `wait should observe ${expected}`); - verifyCommand(context, PUBLIC_COMMANDS.wait, `wait observes durable text: ${expected}`); -} +export { assertFilesDiffer, assertJsonContains }; -export async function assertElementText( - context: LiveContext, - selector: string, - expected: string, -): Promise { - const result = await runStep(context, `read ${selector}`, ['get', 'text', selector]); - assert.equal( - result.json?.data?.text, - expected, - `${selector} should expose ${expected}: ${JSON.stringify(result.json)}`, - ); -} +export const { assertElementText, assertWaitText, capturePng } = createLiveDeviceAssertions< + IosSimulatorBehaviorId, + LiveContext +>(runStep, verifyCommand, PUBLIC_COMMANDS.wait); export async function assertElementTextAfterScrolling( context: LiveContext, @@ -70,19 +53,6 @@ export async function assertMp4File(filePath: string): Promise { ); } -export async function capturePng( - context: LiveContext, - step: string, - outputPath: string, -): Promise { - await runStep(context, step, ['screenshot', outputPath, '--max-size', '900']); - assertPngFile(outputPath); -} - -export function assertFilesDiffer(first: string, second: string, message: string): void { - assert.notDeepEqual(fs.readFileSync(first), fs.readFileSync(second), message); -} - function requireNode( result: CliJsonResult, identifier: string, diff --git a/test/integration/ios-simulator-e2e/live-coverage-report.ts b/test/integration/ios-simulator-e2e/live-coverage-report.ts index a252d7917..266dd41d1 100644 --- a/test/integration/ios-simulator-e2e/live-coverage-report.ts +++ b/test/integration/ios-simulator-e2e/live-coverage-report.ts @@ -1,7 +1,8 @@ -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; - +import { + assertCoverageComplete as assertLiveCoverageComplete, + computeMissingCoverage, + writeCoverageReport as writeLiveCoverageReport, +} from '../live-device-e2e/coverage.ts'; import { IOS_SIMULATOR_BEHAVIOR_COVERAGE, type IosSimulatorBehaviorId, @@ -11,45 +12,26 @@ import type { LiveContext, Tier } from './live-harness.ts'; import { IOS_SIMULATOR_LIVE_SCENARIOS, type IosSimulatorScenario } from './scenarios.ts'; export function assertCoverageComplete(context: LiveContext): void { - const missing = computeMissing(context); - assert.deepEqual( - missing, - { missingBehaviors: [], missingCommands: [], missingScenarios: [] }, + assertLiveCoverageComplete( + context, + scenariosForTier(context.tier), + liveCommandsForScenario, + liveBehaviorsForScenario, 'iOS simulator E2E coverage is incomplete', ); } export function writeCoverageReport(context: LiveContext): void { - const missing = computeMissing(context); - fs.writeFileSync( - path.join(context.artifactDir, 'coverage-report.json'), - JSON.stringify( - { - behaviorEvidence: context.behaviorEvidence, - commandEvidence: context.commandEvidence, - completedScenarios: context.completedScenarios, - ...missing, - tier: context.tier, - }, - null, - 2, + const scenarios = scenariosForTier(context.tier); + writeLiveCoverageReport(context, { + ...computeMissingCoverage( + context, + scenarios, + liveCommandsForScenario, + liveBehaviorsForScenario, ), - ); -} - -function computeMissing(context: LiveContext) { - const requiredScenarios = scenariosForTier(context.tier); - return { - missingBehaviors: requiredScenarios - .flatMap((scenario) => liveBehaviorsForScenario(scenario.id)) - .filter((behavior) => (context.behaviorEvidence[behavior]?.length ?? 0) === 0), - missingCommands: requiredScenarios - .flatMap((scenario) => liveCommandsForScenario(scenario.id)) - .filter((command) => (context.commandEvidence[command]?.length ?? 0) === 0), - missingScenarios: requiredScenarios - .map((scenario) => scenario.id) - .filter((id) => !context.completedScenarios.includes(id)), - }; + tier: context.tier, + }); } function scenariosForTier(tier: Tier): readonly IosSimulatorScenario[] { diff --git a/test/integration/ios-simulator-e2e/live-harness.ts b/test/integration/ios-simulator-e2e/live-harness.ts index 4bd5ff6c8..0944e5f97 100644 --- a/test/integration/ios-simulator-e2e/live-harness.ts +++ b/test/integration/ios-simulator-e2e/live-harness.ts @@ -3,8 +3,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { resolveDaemonPaths } from '../../../src/daemon/config.ts'; -import { type CliJsonResult, formatResultDebug, runBuiltCliJson } from '../cli-json.ts'; -import { type IosSimulatorBehaviorId } from './behavior-coverage.ts'; +import { + createLiveDeviceContext, + createLiveDeviceHarness, + requiredEnv, + type LiveDeviceContext, +} from '../live-device-e2e/runtime.ts'; +import type { IosSimulatorBehaviorId } from './behavior-coverage.ts'; import { liveCommandsForScenario } from './coverage-manifest.ts'; import { liveBehaviorsForScenario, writeCoverageReport } from './live-coverage-report.ts'; @@ -12,137 +17,54 @@ export { assertCoverageComplete, writeCoverageReport } from './live-coverage-rep export type Tier = 'smoke' | 'full'; -type StepRecord = { - accepted: boolean; - command: string; - commandName?: string; - durationMs: number; - errorCode?: string; - errorMessage?: string; - scenario: string; - status: number; - step: string; -}; - -export type LiveContext = { +export type LiveContext = LiveDeviceContext & { appId: string; appPath: string; - artifactDir: string; - behaviorEvidence: Partial>; - commandEvidence: Record; - completedScenarios: string[]; - currentScenario: string; - env: NodeJS.ProcessEnv; - session: string; - sessionOpen: boolean; stateDir: string; - stepHistory: StepRecord[]; tier: Tier; udid: string; }; export function createContext(): LiveContext { - const tier = requiredEnv('AGENT_DEVICE_IOS_E2E_TIER'); + const tier = requiredEnv('AGENT_DEVICE_IOS_E2E_TIER', 'AGENT_DEVICE_IOS_E2E'); assert.ok(tier === 'smoke' || tier === 'full', `unsupported iOS E2E tier: ${tier}`); - if (tier === 'full') requiredEnv('AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE'); - const runId = `${Date.now()}-${process.pid}`; - const artifactDir = path.resolve('test/artifacts/ios-simulator', tier, runId); - fs.mkdirSync(artifactDir, { recursive: true }); - + if (tier === 'full') { + requiredEnv('AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE', 'AGENT_DEVICE_IOS_E2E'); + } return { - appId: requiredEnv('AGENT_DEVICE_FIXTURE_APP_ID'), - appPath: requiredEnv('AGENT_DEVICE_FIXTURE_APP_PATH'), - artifactDir, - behaviorEvidence: {}, - commandEvidence: {}, - completedScenarios: [], - currentScenario: 'bootstrap', - env: process.env, - session: `ios-e2e-${tier}-${process.pid.toString(36)}`, - sessionOpen: false, + ...createLiveDeviceContext({ + artifactRoot: `test/artifacts/ios-simulator/${tier}`, + session: `ios-e2e-${tier}-${process.pid.toString(36)}`, + }), + appId: requiredEnv('AGENT_DEVICE_FIXTURE_APP_ID', 'AGENT_DEVICE_IOS_E2E'), + appPath: requiredEnv('AGENT_DEVICE_FIXTURE_APP_PATH', 'AGENT_DEVICE_IOS_E2E'), stateDir: resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR, { env: process.env, }).baseDir, - stepHistory: [], tier, - udid: requiredEnv('AGENT_DEVICE_IOS_UDID'), + udid: requiredEnv('AGENT_DEVICE_IOS_UDID', 'AGENT_DEVICE_IOS_E2E'), }; } -export async function runScenario( - context: LiveContext, - scenario: { - id: string; - run: (context: LiveContext) => Promise; - }, -): Promise { - context.currentScenario = scenario.id; - const commands = liveCommandsForScenario(scenario.id); - const evidenceCounts = new Map( - commands.map((command) => [command, context.commandEvidence[command]?.length ?? 0]), - ); - const behaviorCounts = new Map( - liveBehaviorsForScenario(scenario.id).map((behavior) => [ - behavior, - context.behaviorEvidence[behavior]?.length ?? 0, - ]), - ); - await scenario.run(context); - assertScenarioCommandsVerified(scenario.id, commands, context, evidenceCounts); - assertScenarioBehaviorsVerified(scenario.id, context, behaviorCounts); - context.completedScenarios.push(scenario.id); - writeCoverageReport(context); -} - -export async function runStep( - context: LiveContext, - step: string, - args: string[], - options: { - allowFailure?: boolean; - commonFlags?: boolean; - expectFailure?: boolean; - timeoutMs?: number; - } = {}, -): Promise { - const fullArgs = options.commonFlags === false ? withJson(args) : withCommonFlags(context, args); - const startedAt = Date.now(); - const result = await runBuiltCliJson(fullArgs, context.env, { - timeoutMs: options.timeoutMs, - }); - context.stepHistory.push({ - accepted: result.status === 0 || (options.expectFailure === true && result.status !== 0), - command: `agent-device ${fullArgs.join(' ')}`, - commandName: args[0], - durationMs: Date.now() - startedAt, - errorCode: stringValue(result.json?.error?.code), - errorMessage: stringValue(result.json?.error?.message), - scenario: context.currentScenario, - status: result.status, - step, - }); - writeStepHistory(context); - const failedAsExpected = options.expectFailure === true && result.status !== 0; - if (result.status !== 0 && !failedAsExpected && options.allowFailure !== true) { - const message = [ - formatResultDebug(step, fullArgs, result), - `scenario: ${context.currentScenario}`, - `artifacts: ${context.artifactDir}`, - ].join('\n'); - fs.writeFileSync(path.join(context.artifactDir, 'failed-step.txt'), message); - assert.fail(message); - } - if (options.expectFailure === true && result.status === 0) { - assert.fail(`${step} unexpectedly succeeded\ncommand: agent-device ${fullArgs.join(' ')}`); - } - if (result.status === 0 && args[0] === 'open') context.sessionOpen = true; - if (result.status === 0 && args[0] === 'close') context.sessionOpen = false; - return result; -} +const harness = createLiveDeviceHarness({ + behaviorsForScenario: liveBehaviorsForScenario, + commandsForScenario: liveCommandsForScenario, + commonFlags: (context, args) => [ + ...args, + '--platform', + 'ios', + '--udid', + context.udid, + '--session', + context.session, + '--state-dir', + context.stateDir, + ...(args.includes('--json') ? [] : ['--json']), + ], + writeCoverageReport, +}); -export function verifyCommand(context: LiveContext, command: string, evidence: string): void { - recordCommandEvidence(context, command, command, evidence); -} +export const { runScenario, runStep, sessionExists, verifyBehavior, verifyCommand } = harness; export function verifyNestedReplayCommand( context: LiveContext, @@ -150,35 +72,7 @@ export function verifyNestedReplayCommand( executedVia: 'replay' | 'test', evidence: string, ): void { - recordCommandEvidence(context, command, executedVia, evidence); -} - -function recordCommandEvidence( - context: LiveContext, - command: string, - executedCommand: string, - evidence: string, -): void { - assert.ok( - context.stepHistory.some( - (step) => - step.scenario === context.currentScenario && - step.commandName === executedCommand && - step.accepted, - ), - `${context.currentScenario} credited ${command} without a successful ${executedCommand} execution`, - ); - const existing = context.commandEvidence[command] ?? []; - context.commandEvidence[command] = [...existing, evidence]; -} - -export function verifyBehavior( - context: LiveContext, - behavior: IosSimulatorBehaviorId, - evidence: string, -): void { - const existing = context.behaviorEvidence[behavior] ?? []; - context.behaviorEvidence[behavior] = [...existing, evidence]; + harness.verifyNestedCommand(context, command, executedVia, evidence); } export async function cleanupSession(context: LiveContext): Promise { @@ -211,74 +105,3 @@ export async function cleanupSession(context: LiveContext): Promise { fs.writeFileSync(errorPath, failures.map(String).join('\n\n')); throw new AggregateError(failures, `iOS E2E cleanup failed; details: ${errorPath}`); } - -export async function sessionExists(context: LiveContext): Promise { - const inventory = await runStep(context, 'inspect final session ownership', ['session', 'list'], { - commonFlags: false, - }); - const sessions = Array.isArray(inventory.json?.data?.sessions) - ? inventory.json.data.sessions - : []; - return sessions.some((session: { name?: unknown }) => session.name === context.session); -} - -function assertScenarioCommandsVerified( - scenarioId: string, - commands: readonly string[], - context: LiveContext, - evidenceCounts: ReadonlyMap, -): void { - for (const command of commands) { - const before = evidenceCounts.get(command) ?? 0; - const after = context.commandEvidence[command]?.length ?? 0; - assert.ok(after > before, `${scenarioId} produced no command-specific evidence for ${command}`); - } -} - -function assertScenarioBehaviorsVerified( - scenarioId: string, - context: LiveContext, - evidenceCounts: ReadonlyMap, -): void { - for (const behavior of liveBehaviorsForScenario(scenarioId)) { - const before = evidenceCounts.get(behavior) ?? 0; - const after = context.behaviorEvidence[behavior]?.length ?? 0; - assert.ok(after > before, `${scenarioId} produced no behavior evidence for ${behavior}`); - } -} - -function withCommonFlags(context: LiveContext, args: string[]): string[] { - return [ - ...args, - '--platform', - 'ios', - '--udid', - context.udid, - '--session', - context.session, - '--state-dir', - context.stateDir, - ...(args.includes('--json') ? [] : ['--json']), - ]; -} - -function withJson(args: string[]): string[] { - return args.includes('--json') ? args : [...args, '--json']; -} - -function writeStepHistory(context: LiveContext): void { - fs.writeFileSync( - path.join(context.artifactDir, 'step-history.json'), - JSON.stringify(context.stepHistory, null, 2), - ); -} - -function requiredEnv(name: string): string { - const value = process.env[name]?.trim(); - assert.ok(value, `${name} is required when AGENT_DEVICE_IOS_E2E=1`); - return value; -} - -function stringValue(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} diff --git a/test/integration/live-device-e2e/assertions.ts b/test/integration/live-device-e2e/assertions.ts new file mode 100644 index 000000000..18c31625e --- /dev/null +++ b/test/integration/live-device-e2e/assertions.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +import { assertPngFile } from '../provider-scenarios/assertions.ts'; +import type { CliJsonResult } from '../cli-json.ts'; +import type { LiveDeviceContext } from './runtime.ts'; + +type RunStep = ( + context: Context, + step: string, + args: string[], + options?: { allowFailure?: boolean }, +) => Promise; + +export function createLiveDeviceAssertions< + BehaviorId extends string, + Context extends LiveDeviceContext, +>( + runStep: RunStep, + verifyCommand: (context: Context, command: string, evidence: string) => void, + waitCommand: string, +) { + async function assertWaitText(context: Context, expected: string): Promise { + const result = await runStep(context, `wait for ${expected}`, [ + 'wait', + 'text', + expected, + '10000', + ]); + assertJsonContains(result, expected, `wait should observe ${expected}`); + verifyCommand(context, waitCommand, `wait observes durable text: ${expected}`); + } + + async function assertWaitSelector(context: Context, selector: string): Promise { + await runStep(context, `wait for ${selector}`, ['wait', selector, '10000']); + verifyCommand(context, waitCommand, `wait observes durable selector: ${selector}`); + } + + async function assertElementText( + context: Context, + selector: string, + expected: string, + ): Promise { + const result = await runStep(context, `read ${selector}`, ['get', 'text', selector]); + assert.equal( + result.json?.data?.text, + expected, + `${selector} should expose ${expected}: ${JSON.stringify(result.json)}`, + ); + } + + async function capturePng(context: Context, step: string, outputPath: string): Promise { + await runStep(context, step, ['screenshot', outputPath, '--max-size', '900']); + assertPngFile(outputPath); + } + + return { assertElementText, assertWaitSelector, assertWaitText, capturePng }; +} + +export function assertJsonContains(result: CliJsonResult, expected: string, message: string): void { + const serialized = JSON.stringify(result.json?.data ?? result.json); + assert.ok(serialized.includes(expected), `${message}\nreceived: ${serialized}`); +} + +export function assertFilesDiffer(first: string, second: string, message: string): void { + assert.notDeepEqual(fs.readFileSync(first), fs.readFileSync(second), message); +} diff --git a/test/integration/live-device-e2e/coverage.ts b/test/integration/live-device-e2e/coverage.ts new file mode 100644 index 000000000..ddf237446 --- /dev/null +++ b/test/integration/live-device-e2e/coverage.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { LiveDeviceContext } from './runtime.ts'; + +export function computeMissingCoverage( + context: LiveDeviceContext, + scenarios: readonly { id: string }[], + commandsForScenario: (scenarioId: string) => readonly string[], + behaviorsForScenario: (scenarioId: string) => readonly BehaviorId[], +) { + return { + missingBehaviors: scenarios + .flatMap((scenario) => behaviorsForScenario(scenario.id)) + .filter((behavior) => (context.behaviorEvidence[behavior]?.length ?? 0) === 0), + missingCommands: scenarios + .flatMap((scenario) => commandsForScenario(scenario.id)) + .filter((command) => (context.commandEvidence[command]?.length ?? 0) === 0), + missingScenarios: scenarios + .map((scenario) => scenario.id) + .filter((id) => !context.completedScenarios.includes(id)), + }; +} + +export function assertCoverageComplete( + context: LiveDeviceContext, + scenarios: readonly { id: string }[], + commandsForScenario: (scenarioId: string) => readonly string[], + behaviorsForScenario: (scenarioId: string) => readonly BehaviorId[], + message: string, +): void { + assert.deepEqual( + computeMissingCoverage(context, scenarios, commandsForScenario, behaviorsForScenario), + { missingBehaviors: [], missingCommands: [], missingScenarios: [] }, + message, + ); +} + +export function writeCoverageReport( + context: LiveDeviceContext, + extra: Record = {}, +): string { + const reportPath = path.join(context.artifactDir, 'coverage-report.json'); + fs.writeFileSync( + reportPath, + JSON.stringify( + { + behaviorEvidence: context.behaviorEvidence, + commandEvidence: context.commandEvidence, + completedScenarios: context.completedScenarios, + ...extra, + timings: { + runDurationMs: Date.now() - context.startedAtMs, + scenarios: context.timings, + }, + }, + null, + 2, + ), + ); + return reportPath; +} diff --git a/test/integration/live-device-e2e/runtime.ts b/test/integration/live-device-e2e/runtime.ts new file mode 100644 index 000000000..8b259dbfb --- /dev/null +++ b/test/integration/live-device-e2e/runtime.ts @@ -0,0 +1,286 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { type CliJsonResult, formatResultDebug, runBuiltCliJson } from '../cli-json.ts'; + +export type StepRecord = { + accepted: boolean; + command: string; + commandName?: string; + durationMs: number; + errorCode?: string; + errorMessage?: string; + scenario: string; + status: number; + step: string; +}; + +export type ScenarioTiming = { + durationMs: number; + id: string; +}; + +export type LiveDeviceContext = { + artifactDir: string; + behaviorEvidence: Partial>; + commandEvidence: Record; + completedScenarios: string[]; + currentScenario: string; + env: NodeJS.ProcessEnv; + session: string; + sessionOpen: boolean; + startedAtMs: number; + stepHistory: StepRecord[]; + timings: ScenarioTiming[]; +}; + +export type LiveScenario = { + id: string; + run: (context: Context) => Promise; +}; + +type HarnessOptions = { + behaviorsForScenario: (scenarioId: string) => readonly BehaviorId[]; + commandsForScenario: (scenarioId: string) => readonly string[]; + commonFlags: (context: Context, args: readonly string[]) => string[]; + writeCoverageReport: (context: Context) => void; +}; + +type RunStepOptions = { + allowFailure?: boolean; + commonFlags?: boolean; + expectFailure?: boolean; + timeoutMs?: number; +}; + +export function createLiveDeviceContext(options: { + artifactRoot: string; + session: string; +}): LiveDeviceContext { + const runId = `${Date.now()}-${process.pid}`; + const artifactDir = path.resolve(options.artifactRoot, runId); + fs.mkdirSync(artifactDir, { recursive: true }); + return { + artifactDir, + behaviorEvidence: {}, + commandEvidence: {}, + completedScenarios: [], + currentScenario: 'bootstrap', + env: process.env, + session: options.session, + sessionOpen: false, + startedAtMs: Date.now(), + stepHistory: [], + timings: [], + }; +} + +export function createLiveDeviceHarness< + Context extends LiveDeviceContext, + BehaviorId extends string, +>(options: HarnessOptions) { + async function runScenario(context: Context, scenario: LiveScenario): Promise { + context.currentScenario = scenario.id; + const commandCounts = evidenceCounts( + options.commandsForScenario(scenario.id), + context.commandEvidence, + ); + const behaviorCounts = evidenceCounts( + options.behaviorsForScenario(scenario.id), + context.behaviorEvidence, + ); + const startedAt = Date.now(); + try { + await scenario.run(context); + assertNewEvidence( + scenario.id, + options.commandsForScenario(scenario.id), + context.commandEvidence, + commandCounts, + ); + assertNewEvidence( + scenario.id, + options.behaviorsForScenario(scenario.id), + context.behaviorEvidence, + behaviorCounts, + ); + context.completedScenarios.push(scenario.id); + } finally { + context.timings.push({ durationMs: Date.now() - startedAt, id: scenario.id }); + options.writeCoverageReport(context); + } + } + + async function runStep( + context: Context, + step: string, + args: string[], + stepOptions: RunStepOptions = {}, + ): Promise { + const fullArgs = buildStepArgs(context, args, stepOptions); + const startedAt = Date.now(); + const result = await runBuiltCliJson(fullArgs, context.env, { + timeoutMs: stepOptions.timeoutMs, + }); + const failedAsExpected = stepOptions.expectFailure === true && result.status !== 0; + recordStep(context, { + accepted: result.status === 0 || failedAsExpected, + command: `agent-device ${fullArgs.join(' ')}`, + commandName: args[0], + durationMs: Date.now() - startedAt, + errorCode: stringValue(result.json?.error?.code), + errorMessage: stringValue(result.json?.error?.message), + scenario: context.currentScenario, + status: result.status, + step, + }); + assertStepOutcome(context, step, fullArgs, result, failedAsExpected, stepOptions); + updateSessionState(context, args[0], result.status); + return result; + } + + function buildStepArgs( + context: Context, + args: readonly string[], + stepOptions: RunStepOptions, + ): string[] { + return stepOptions.commonFlags === false ? withJson(args) : options.commonFlags(context, args); + } + + function recordStep(context: Context, record: StepRecord): void { + context.stepHistory.push(record); + writeStepHistory(context); + } + + function assertStepOutcome( + context: Context, + step: string, + fullArgs: string[], + result: CliJsonResult, + failedAsExpected: boolean, + stepOptions: RunStepOptions, + ): void { + const unexpectedFailure = + result.status !== 0 && !failedAsExpected && stepOptions.allowFailure !== true; + if (unexpectedFailure) { + const message = [ + formatResultDebug(step, fullArgs, result), + `scenario: ${context.currentScenario}`, + `artifacts: ${context.artifactDir}`, + ].join('\n'); + fs.writeFileSync(path.join(context.artifactDir, 'failed-step.txt'), message); + assert.fail(message); + } + if (stepOptions.expectFailure === true && result.status === 0) { + assert.fail(`${step} unexpectedly succeeded\ncommand: agent-device ${fullArgs.join(' ')}`); + } + } + + function updateSessionState(context: Context, command: string | undefined, status: number): void { + if (status !== 0) return; + if (command === 'open') context.sessionOpen = true; + if (command === 'close') context.sessionOpen = false; + } + + function verifyCommand(context: Context, command: string, evidence: string): void { + recordCommandEvidence(context, command, command, evidence); + } + + function verifyNestedCommand( + context: Context, + command: string, + executedCommand: string, + evidence: string, + ): void { + recordCommandEvidence(context, command, executedCommand, evidence); + } + + function recordCommandEvidence( + context: Context, + command: string, + executedCommand: string, + evidence: string, + ): void { + assert.ok( + context.stepHistory.some( + (record) => + record.scenario === context.currentScenario && + record.commandName === executedCommand && + record.accepted, + ), + `${context.currentScenario} credited ${command} without a successful ${executedCommand} execution`, + ); + context.commandEvidence[command] = [...(context.commandEvidence[command] ?? []), evidence]; + } + + function verifyBehavior(context: Context, behavior: BehaviorId, evidence: string): void { + context.behaviorEvidence[behavior] = [...(context.behaviorEvidence[behavior] ?? []), evidence]; + } + + async function sessionExists(context: Context): Promise { + const inventory = await runStep( + context, + 'inspect final session ownership', + ['session', 'list'], + { + commonFlags: false, + }, + ); + const sessions = Array.isArray(inventory.json?.data?.sessions) + ? inventory.json.data.sessions + : []; + return sessions.some((session: { name?: unknown }) => session.name === context.session); + } + + return { + runScenario, + runStep, + sessionExists, + verifyBehavior, + verifyCommand, + verifyNestedCommand, + }; +} + +export function requiredEnv(name: string, enabledFlag: string): string { + const value = process.env[name]?.trim(); + assert.ok(value, `${name} is required when ${enabledFlag}=1`); + return value; +} + +function evidenceCounts( + keys: readonly Key[], + evidence: Partial>, +): ReadonlyMap { + return new Map(keys.map((key) => [key, evidence[key]?.length ?? 0])); +} + +function assertNewEvidence( + scenarioId: string, + keys: readonly Key[], + evidence: Partial>, + counts: ReadonlyMap, +): void { + for (const key of keys) { + assert.ok( + (evidence[key]?.length ?? 0) > (counts.get(key) ?? 0), + `${scenarioId} produced no specific evidence for ${key}`, + ); + } +} + +function withJson(args: readonly string[]): string[] { + return args.includes('--json') ? [...args] : [...args, '--json']; +} + +function writeStepHistory(context: LiveDeviceContext): void { + fs.writeFileSync( + path.join(context.artifactDir, 'step-history.json'), + JSON.stringify(context.stepHistory, null, 2), + ); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} diff --git a/test/integration/provider-scenarios/android-lifecycle.coverage.ts b/test/integration/provider-scenarios/android-lifecycle.coverage.ts new file mode 100644 index 000000000..73d081d31 --- /dev/null +++ b/test/integration/provider-scenarios/android-lifecycle.coverage.ts @@ -0,0 +1,31 @@ +import { PUBLIC_COMMANDS as C } from '../../../src/command-catalog.ts'; +import { defineAndroidContractEvidence } from '../android-emulator-e2e/contract-evidence.ts'; + +export const ANDROID_LIFECYCLE_CONTRACT_EVIDENCE = defineAndroidContractEvidence( + 'provider-scenarios/android-lifecycle', + [ + C.batch, + C.boot, + C.clipboard, + C.events, + C.gesture, + C.logs, + C.network, + C.perf, + C.push, + C.reactNative, + C.reinstall, + C.settings, + C.shutdown, + C.swipe, + C.trace, + C.triggerAppEvent, + ], + 'Provider-backed integration Android Settings flow uses scripted ADB provider', +); + +export const ANDROID_TOUCH_CONTRACT_EVIDENCE = defineAndroidContractEvidence( + 'provider-scenarios/android-touch', + [C.scroll], + 'Provider-backed integration Android touch provider handles multi-touch gestures', +); diff --git a/test/integration/provider-scenarios/android-lifecycle.test.ts b/test/integration/provider-scenarios/android-lifecycle.test.ts index 15b148639..52139225f 100644 --- a/test/integration/provider-scenarios/android-lifecycle.test.ts +++ b/test/integration/provider-scenarios/android-lifecycle.test.ts @@ -13,6 +13,10 @@ import { import { createAndroidSettingsWorld, waitForFileContent } from './android-world.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; import { createProviderScenarioTempPath, withProviderScenarioResource } from './harness.ts'; +import { + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE, + ANDROID_TOUCH_CONTRACT_EVIDENCE, +} from './android-lifecycle.coverage.ts'; type AndroidSettingsWorld = Awaited>; @@ -27,15 +31,19 @@ const ANDROID_SYSTEM_SURFACE_XML = ` `; -test('Provider-backed integration Android Settings flow uses scripted ADB provider', async () => { - await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => { - const client = world.daemon.client(); - await runAndroidSetupAndInstallWorkflow(world, client); - await runAndroidAppControlAndObservabilityWorkflow(world, client); - await runAndroidCaptureInteractionAndReplayWorkflow(world, client); - assertAndroidProviderContract(world); - }); -}, 15_000); +test( + ANDROID_LIFECYCLE_CONTRACT_EVIDENCE.testName, + async () => { + await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => { + const client = world.daemon.client(); + await runAndroidSetupAndInstallWorkflow(world, client); + await runAndroidAppControlAndObservabilityWorkflow(world, client); + await runAndroidCaptureInteractionAndReplayWorkflow(world, client); + assertAndroidProviderContract(world); + }); + }, + 15_000, +); test('Provider-backed Android reads keep chrome provenance internal across public node payloads', async () => { await withProviderScenarioResource( @@ -116,7 +124,7 @@ test('Provider-backed integration Android text provider handles Unicode without ); }); -test('Provider-backed integration Android touch provider handles multi-touch gestures', async () => { +test(ANDROID_TOUCH_CONTRACT_EVIDENCE.testName, async () => { await withProviderScenarioResource( async () => await createAndroidSettingsWorld(), async (world) => { @@ -821,6 +829,10 @@ async function runAndroidAppControlAndObservabilityWorkflow( ), JSON.stringify(sessionAfterTriggeredEvent?.actions), ); + const tracePath = path.join(world.tempRoot, 'android-provider.adtrace'); + const finalTracePath = path.join(world.tempRoot, 'android-provider-final.adtrace'); + const traceStart = await daemon.callCommand('trace', ['start', tracePath], selection); + assert.equal(traceStart.json?.result?.data?.trace, 'started'); await client.settings.update({ setting: 'permission', state: 'grant', @@ -903,6 +915,18 @@ async function runAndroidAppControlAndObservabilityWorkflow( JSON.stringify(metrics.fps), ); + const events = await client.observability.events({ limit: 100, ...selection }); + const eventEntries = Array.isArray(events.events) + ? (events.events as Array<{ command?: string }>) + : []; + assert.ok(eventEntries.some((event) => event.command === 'trigger-app-event')); + assert.ok(eventEntries.some((event) => event.command === 'perf')); + + const traceStop = await daemon.callCommand('trace', ['stop', finalTracePath], selection); + assert.equal(traceStop.json?.result?.data?.trace, 'stopped'); + assert.equal(traceStop.json?.result?.data?.outPath, finalTracePath); + assert.equal(fs.existsSync(finalTracePath), true); + const explicitMetrics = await client.observability.perf({ area: 'metrics', ...selection }); assert.deepEqual(Object.keys(explicitMetrics.metrics as Record).sort(), [ 'cpu', @@ -1428,7 +1452,7 @@ function assertAndroidPushAndEventContract(world: AndroidSettingsWorld): void { '-a', 'android.intent.action.VIEW', '-d', - 'demo://agent-device/event?name=pre_open_ping&payload=%7B%22stage%22%3A%22explicit-selector%22%7D&platform=android', + "'demo://agent-device/event?name=pre_open_ping&payload=%7B%22stage%22%3A%22explicit-selector%22%7D&platform=android'", ]); assertCommandCall(adbCalls, [ 'shell', @@ -1438,7 +1462,7 @@ function assertAndroidPushAndEventContract(world: AndroidSettingsWorld): void { '-a', 'android.intent.action.VIEW', '-d', - 'demo://agent-device/event?name=screenshot_taken&payload=%7B%22source%22%3A%22provider-scenario%22%2C%22foreground%22%3Atrue%7D&platform=android', + "'demo://agent-device/event?name=screenshot_taken&payload=%7B%22source%22%3A%22provider-scenario%22%2C%22foreground%22%3Atrue%7D&platform=android'", '-p', 'com.example.demo', ]); diff --git a/test/integration/provider-scenarios/android-recording.coverage.ts b/test/integration/provider-scenarios/android-recording.coverage.ts new file mode 100644 index 000000000..7d32444d9 --- /dev/null +++ b/test/integration/provider-scenarios/android-recording.coverage.ts @@ -0,0 +1,8 @@ +import { PUBLIC_COMMANDS as C } from '../../../src/command-catalog.ts'; +import { defineAndroidContractEvidence } from '../android-emulator-e2e/contract-evidence.ts'; + +export const ANDROID_RECORDING_CONTRACT_EVIDENCE = defineAndroidContractEvidence( + 'provider-scenarios/android-recording', + [C.record], + 'Provider-backed integration Android recording flow uses scripted ADB provider pull capability', +); diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index e6ecefc46..1f0dc9cbf 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -19,12 +19,13 @@ import { likelyPlayableMp4Container, withProviderScenarioTempDir, } from './harness.ts'; +import { ANDROID_RECORDING_CONTRACT_EVIDENCE } from './android-recording.coverage.ts'; type ProviderScenarioDaemon = Awaited>; type ProviderScenarioRpcResult = Awaited>; type PullCall = { remotePath: string; localPath: string }; -test('Provider-backed integration Android recording flow uses scripted ADB provider pull capability', async () => { +test(ANDROID_RECORDING_CONTRACT_EVIDENCE.testName, async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-android-record-', runAndroidRecordingFlowScenario, diff --git a/test/integration/provider-scenarios/android-test-suite.coverage.ts b/test/integration/provider-scenarios/android-test-suite.coverage.ts new file mode 100644 index 000000000..4920895d0 --- /dev/null +++ b/test/integration/provider-scenarios/android-test-suite.coverage.ts @@ -0,0 +1,8 @@ +import { PUBLIC_COMMANDS as C } from '../../../src/command-catalog.ts'; +import { defineAndroidContractEvidence } from '../android-emulator-e2e/contract-evidence.ts'; + +export const ANDROID_TEST_SUITE_CONTRACT_EVIDENCE = defineAndroidContractEvidence( + 'provider-scenarios/android-test-suite', + [C.test], + 'Provider-backed integration Android replay test suite covers retries and fail-fast flags', +); diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index b053722a5..347a9a9da 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -4,8 +4,9 @@ import path from 'node:path'; import { test } from 'vitest'; import { createAndroidSettingsWorld } from './android-world.ts'; import { withProviderScenarioResource } from './harness.ts'; +import { ANDROID_TEST_SUITE_CONTRACT_EVIDENCE } from './android-test-suite.coverage.ts'; -test('Provider-backed integration Android replay test suite covers retries and fail-fast flags', async () => { +test(ANDROID_TEST_SUITE_CONTRACT_EVIDENCE.testName, async () => { await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => { const client = world.daemon.client(); const suiteRoot = path.join(world.tempRoot, 'suite-flags'); diff --git a/test/integration/smoke-android-emulator-coverage.test.ts b/test/integration/smoke-android-emulator-coverage.test.ts new file mode 100644 index 000000000..0d3dbfb66 --- /dev/null +++ b/test/integration/smoke-android-emulator-coverage.test.ts @@ -0,0 +1,281 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PUBLIC_COMMANDS } from '../../src/command-catalog.ts'; +import { isCommandSupportedOnDevice } from '../../src/core/capabilities.ts'; +import { ANDROID_EMULATOR_BEHAVIOR_COVERAGE } from './android-emulator-e2e/behavior-coverage.ts'; +import { + ANDROID_EMULATOR_COVERAGE_CLASSIFICATION_SUMMARY, + ANDROID_EMULATOR_E2E_COVERAGE, + liveCommandsForScenario, +} from './android-emulator-e2e/coverage-manifest.ts'; +import { assertCoverageComplete } from './android-emulator-e2e/live-coverage-report.ts'; +import { scenarioIdsFromEnv } from './android-emulator-e2e/live-runner.ts'; +import { + ANDROID_EMULATOR_FIXTURE_BOOTSTRAP, + ANDROID_EMULATOR_LIVE_SCENARIOS, + selectAndroidEmulatorScenarios, +} from './android-emulator-e2e/scenarios.ts'; + +const ANDROID_EMULATOR = { + id: 'ci-android-emulator', + kind: 'emulator' as const, + name: 'CI Android Emulator', + platform: 'android' as const, + target: 'mobile' as const, +}; + +test('Android emulator coverage exhaustively classifies the public catalog', () => { + const publicCommands = Object.values(PUBLIC_COMMANDS).sort(); + assert.deepEqual(Object.keys(ANDROID_EMULATOR_E2E_COVERAGE).sort(), publicCommands); + for (const command of publicCommands) { + const entry = ANDROID_EMULATOR_E2E_COVERAGE[command]; + assert.ok(entry.assertion.trim().length > 0, `${command} needs an observable assertion`); + if (entry.level === 'live') { + assert.ok(entry.scenario.trim().length > 0, `${command} needs a scenario owner`); + } else if (entry.level === 'command-contract') { + assert.ok(entry.evidence.owner.trim().length > 0, `${command} needs a contract owner`); + assert.ok( + entry.evidence.commands.includes(command), + `${entry.evidence.owner} must declare ${command}`, + ); + } + } +}); + +test('Android coverage report summary accounts for every manifest classification', () => { + const summary = ANDROID_EMULATOR_COVERAGE_CLASSIFICATION_SUMMARY; + assert.deepEqual(summary, { + capabilityDenial: 3, + contract: 22, + gap: 0, + live: 28, + total: 53, + }); + assert.equal( + summary.live + summary.contract + summary.gap + summary.capabilityDenial, + summary.total, + ); +}); + +test('Android live command ownership is structural and exhaustive', () => { + assert.deepEqual( + [...ANDROID_EMULATOR_FIXTURE_BOOTSTRAP.commands].sort(), + liveCommandsForScenario(ANDROID_EMULATOR_FIXTURE_BOOTSTRAP.id).sort(), + 'fixture bootstrap command declaration must match the coverage manifest', + ); + for (const scenario of ANDROID_EMULATOR_LIVE_SCENARIOS) { + assert.deepEqual( + [...scenario.commands].sort(), + liveCommandsForScenario(scenario.id).sort(), + `${scenario.id} command declaration must match the coverage manifest`, + ); + } + const claimed = [ + ...ANDROID_EMULATOR_FIXTURE_BOOTSTRAP.commands, + ...ANDROID_EMULATOR_LIVE_SCENARIOS.flatMap((scenario) => scenario.commands), + ]; + const liveCommands = Object.entries(ANDROID_EMULATOR_E2E_COVERAGE) + .filter(([, entry]) => entry.level === 'live') + .map(([command]) => command); + assert.deepEqual([...claimed].sort(), liveCommands.sort()); + assert.equal(new Set(claimed).size, claimed.length, 'live commands need one primary owner'); +}); + +test('Android app scenarios declare deterministic starting surfaces and IME modes', () => { + assert.deepEqual( + { + id: ANDROID_EMULATOR_FIXTURE_BOOTSTRAP.id, + start: ANDROID_EMULATOR_FIXTURE_BOOTSTRAP.start, + }, + { id: 'smoke:fixture-bootstrap', start: undefined }, + ); + assert.deepEqual( + ANDROID_EMULATOR_LIVE_SCENARIOS.map((scenario) => ({ + id: scenario.id, + start: 'start' in scenario ? scenario.start : undefined, + })), + [ + { id: 'smoke:inventory', start: undefined }, + { + id: 'smoke:automation-system', + start: { + ime: 'system', + landmark: 'Automation lab', + url: 'agent-device-test-app:///automation?event=cold.start&payload=%7B%22source%22%3A%22android-deep-link%22%7D', + }, + }, + { + id: 'smoke:form-input', + start: { + ime: 'test', + landmark: 'Checkout form', + url: 'agent-device-test-app:///form', + }, + }, + { + id: 'smoke:keyboard-ime', + start: { + ime: 'system', + landmark: 'Checkout form', + url: 'agent-device-test-app:///form', + }, + }, + { + id: 'smoke:capture-close', + start: { + ime: 'system', + landmark: 'Agent Device Tester', + url: 'agent-device-test-app:///', + }, + }, + ], + ); +}); + +test('Android emulator scenarios can be selected as an ordered subset', () => { + assert.deepEqual( + selectAndroidEmulatorScenarios(['smoke:keyboard-ime', 'smoke:automation-system']).map( + ({ id }) => id, + ), + ['smoke:keyboard-ime', 'smoke:automation-system'], + ); + assert.deepEqual( + selectAndroidEmulatorScenarios().map(({ id }) => id), + ANDROID_EMULATOR_LIVE_SCENARIOS.map(({ id }) => id), + ); + assert.throws( + () => selectAndroidEmulatorScenarios(['smoke:keyboard-ime', 'smoke:keyboard-ime']), + /duplicate Android emulator E2E scenario: smoke:keyboard-ime/, + ); + assert.throws( + () => selectAndroidEmulatorScenarios(['smoke:not-real']), + /unknown Android emulator E2E scenario: smoke:not-real/, + ); + assert.throws( + () => selectAndroidEmulatorScenarios([]), + /select at least one Android emulator E2E scenario/, + ); + assert.deepEqual(scenarioIdsFromEnv(' smoke:capture-close,smoke:inventory '), [ + 'smoke:capture-close', + 'smoke:inventory', + ]); + assert.throws(() => scenarioIdsFromEnv(''), /contains no Android emulator E2E scenario ids/); + assert.equal(scenarioIdsFromEnv(undefined), undefined); +}); + +test('Android emulator coverage completeness is scoped to the executed subset', () => { + const captureScenario = selectAndroidEmulatorScenarios(['smoke:capture-close'])[0]; + assert.ok(captureScenario); + const selected = [ANDROID_EMULATOR_FIXTURE_BOOTSTRAP, captureScenario]; + const context = { + appId: 'com.example.fixture', + appPath: '/fixture.apk', + artifactDir: '/tmp/not-written', + behaviorEvidence: {}, + commandEvidence: { + [PUBLIC_COMMANDS.close]: ['session released'], + [PUBLIC_COMMANDS.install]: ['fixture installed'], + [PUBLIC_COMMANDS.screenshot]: ['PNG captured'], + }, + completedScenarios: selected.map(({ id }) => id), + currentScenario: captureScenario.id, + env: {}, + serial: 'emulator-5554', + session: 'scoped-coverage', + sessionOpen: false, + startedAtMs: 0, + stepHistory: [], + timings: [], + }; + + assert.doesNotThrow(() => assertCoverageComplete(context, selected)); + const incomplete = { + ...context, + commandEvidence: { + [PUBLIC_COMMANDS.close]: ['session released'], + [PUBLIC_COMMANDS.install]: ['fixture installed'], + }, + }; + assert.throws( + () => assertCoverageComplete(incomplete, selected), + /Android emulator E2E coverage is incomplete/, + ); +}); + +test('Android command-contract declarations are unique and exhaustive', () => { + const contractEntries = Object.entries(ANDROID_EMULATOR_E2E_COVERAGE).filter( + ( + entry, + ): entry is [ + string, + Extract< + (typeof ANDROID_EMULATOR_E2E_COVERAGE)[keyof typeof ANDROID_EMULATOR_E2E_COVERAGE], + { level: 'command-contract' } + >, + ] => entry[1].level === 'command-contract', + ); + const declarations = new Map( + contractEntries.map(([, entry]) => [entry.evidence.owner, entry.evidence] as const), + ); + const declared = [...declarations.values()].flatMap((evidence) => evidence.commands); + + for (const evidence of declarations.values()) { + assert.ok(evidence.testName.trim().length > 0, `${evidence.owner} needs an executable test`); + } + assert.equal(new Set(declared).size, declared.length, 'contract commands need one owner'); + assert.deepEqual([...declared].sort(), contractEntries.map(([command]) => command).sort()); +}); + +test('Android behavior patterns are owned by live fixture journeys', () => { + const claimedBehaviors = ANDROID_EMULATOR_LIVE_SCENARIOS.flatMap( + (scenario) => scenario.behaviors, + ); + for (const [behavior, entry] of Object.entries(ANDROID_EMULATOR_BEHAVIOR_COVERAGE)) { + assert.ok(entry.assertion.trim().length > 0, `${behavior} needs observable assertion`); + const scenario = ANDROID_EMULATOR_LIVE_SCENARIOS.find( + (candidate) => candidate.id === entry.owner, + ); + assert.ok(scenario, `${behavior} references missing scenario ${entry.owner}`); + assert.ok( + scenario.behaviors.some((claimed) => claimed === behavior), + `${scenario.id} must declare ${behavior}`, + ); + } + assert.deepEqual( + [...claimedBehaviors].sort(), + Object.keys(ANDROID_EMULATOR_BEHAVIOR_COVERAGE).sort(), + ); + assert.equal( + new Set(claimedBehaviors).size, + claimedBehaviors.length, + 'live behaviors need one primary owner', + ); +}); + +test('Android emulator capability denial matches the public catalog', () => { + for (const [command, entry] of Object.entries(ANDROID_EMULATOR_E2E_COVERAGE)) { + const supported = isCommandSupportedOnDevice(command, ANDROID_EMULATOR); + if (command === PUBLIC_COMMANDS.audio) { + assert.equal( + supported, + process.platform === 'darwin', + 'Android emulator audio admission follows host audio-probe availability', + ); + continue; + } + assert.equal( + supported, + entry.level !== 'capability-denial', + `${command} ownership must match Android emulator capability admission`, + ); + } + for (const command of [ + PUBLIC_COMMANDS.prepare, + PUBLIC_COMMANDS.tvRemote, + PUBLIC_COMMANDS.viewport, + ]) { + assert.equal(isCommandSupportedOnDevice(command, ANDROID_EMULATOR), false, command); + assert.equal(ANDROID_EMULATOR_E2E_COVERAGE[command].level, 'capability-denial', command); + } +}); diff --git a/test/integration/smoke-android-emulator.test.ts b/test/integration/smoke-android-emulator.test.ts new file mode 100644 index 000000000..954db400e --- /dev/null +++ b/test/integration/smoke-android-emulator.test.ts @@ -0,0 +1,15 @@ +import test from 'node:test'; + +import { runAndroidEmulatorE2E } from './android-emulator-e2e/live-runner.ts'; + +const enabled = process.env.AGENT_DEVICE_ANDROID_E2E === '1'; + +test( + 'live Android emulator fixture E2E', + { + skip: enabled + ? false + : 'Set AGENT_DEVICE_ANDROID_E2E=1 with fixture APK path/id and emulator serial to run.', + }, + () => runAndroidEmulatorE2E(), +); diff --git a/test/scripts/android-fixture-cache-smoke.sh b/test/scripts/android-fixture-cache-smoke.sh deleted file mode 100644 index b2ee3770b..000000000 --- a/test/scripts/android-fixture-cache-smoke.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -set -eu - -if [ "$#" -ne 2 ] || [ -z "$1" ] || [ -z "$2" ]; then - echo "Usage: android-fixture-cache-smoke.sh " >&2 - exit 2 -fi - -APK_PATH="$1" -APP_ID="$2" -PROJECT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" -SESSION="fixture-cache" -SNAPSHOT_PATH="$PROJECT_DIR/test/artifacts/android-fixture-cache-snapshot.json" - -cleanup() { - node --experimental-strip-types "$PROJECT_DIR/src/bin.ts" close --platform android --session "$SESSION" >/dev/null 2>&1 || true -} -trap cleanup EXIT - -mkdir -p "$(dirname -- "$SNAPSHOT_PATH")" -node --experimental-strip-types "$PROJECT_DIR/src/bin.ts" install "$APK_PATH" --platform android -node --experimental-strip-types "$PROJECT_DIR/src/bin.ts" open "$APP_ID" --platform android --session "$SESSION" --relaunch -node --experimental-strip-types "$PROJECT_DIR/src/bin.ts" snapshot -i --platform android --session "$SESSION" --json > "$SNAPSHOT_PATH" -node "$PROJECT_DIR/test/scripts/assert-android-fixture-snapshot.mjs" "$SNAPSHOT_PATH" "$APP_ID" diff --git a/test/scripts/android-snapshot-helper-release-smoke.sh b/test/scripts/android-snapshot-helper-release-smoke.sh deleted file mode 100755 index 365cc5c8d..000000000 --- a/test/scripts/android-snapshot-helper-release-smoke.sh +++ /dev/null @@ -1,165 +0,0 @@ -#!/bin/sh -set -eu - -PROJECT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" -PACKAGE_NAME="com.callstack.agentdevice.snapshotsmoke" -APP_LABEL="Agent Device Snapshot Smoke" -# Keep these in sync with the Android workflow SDK packages and emulator API level. -MIN_SDK=23 -TARGET_SDK=36 - -SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" -if [ -z "$SDK_ROOT" ] || [ ! -d "$SDK_ROOT" ]; then - echo "ANDROID_HOME or ANDROID_SDK_ROOT must point to an Android SDK" >&2 - exit 1 -fi - -ANDROID_JAR="$SDK_ROOT/platforms/android-$TARGET_SDK/android.jar" -if [ ! -f "$ANDROID_JAR" ]; then - echo "Missing Android platform jar: $ANDROID_JAR" >&2 - exit 1 -fi - -BUILD_TOOLS_DIR="$( - find "$SDK_ROOT/build-tools" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -n 1 -)" -if [ -z "$BUILD_TOOLS_DIR" ] || [ ! -x "$BUILD_TOOLS_DIR/aapt2" ]; then - echo "Missing Android build tools under $SDK_ROOT/build-tools" >&2 - exit 1 -fi - -WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/agent-device-android-helper-smoke.XXXXXX")" -cleanup() { - rm -rf "$WORK_DIR" -} -trap cleanup EXIT - -SRC_DIR="$WORK_DIR/src/com/callstack/agentdevice/snapshotsmoke" -CLASSES_DIR="$WORK_DIR/classes" -DEX_DIR="$WORK_DIR/dex" -UNSIGNED_APK="$WORK_DIR/app-unsigned.apk" -ALIGNED_APK="$WORK_DIR/app-aligned.apk" -APK_PATH="$WORK_DIR/app-release.apk" -KEYSTORE="$PROJECT_DIR/android/snapshot-helper/debug.keystore" - -# This APK is throwaway test code signed with the helper debug keystore only for CI smoke coverage. -mkdir -p "$SRC_DIR" "$CLASSES_DIR" "$DEX_DIR" - -cat > "$WORK_DIR/AndroidManifest.xml" < - - - - - - - - - -EOF_MANIFEST - -mkdir -p "$WORK_DIR/res/values" -cat > "$WORK_DIR/res/values/styles.xml" < -