Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2da93f9
test(android): add catalog emulator smoke coverage
thymikee Jul 29, 2026
125fb4e
test(android): use stable snapshot diff mutation
thymikee Jul 29, 2026
56c78f2
test(android): assert actual back destination
thymikee Jul 29, 2026
7b42d7c
test(android): separate keyboard and fill IMEs
thymikee Jul 29, 2026
7b1f2c1
fix(ci): keep Android timing report in one shell
thymikee Jul 29, 2026
5f2d48a
refactor(test): simplify simulator e2e coverage
thymikee Jul 29, 2026
f8034de
test(android): assert stable diff landmarks
thymikee Jul 29, 2026
82d968f
fix(android): release snapshot helper gracefully
thymikee Jul 29, 2026
fb739e8
fix(android): fully release snapshot helper runtime
thymikee Jul 29, 2026
bab9f68
test(android): report coverage classifications
thymikee Jul 29, 2026
9f3663f
fix(android): stabilize accessibility root capture
thymikee Jul 29, 2026
3437178
fix(android): bound UiAutomation connection
thymikee Jul 29, 2026
90313c9
ci: upload worktree daemon diagnostics
thymikee Jul 29, 2026
9ea092e
fix(android): cancel stalled wait captures
thymikee Jul 29, 2026
9587072
fix(android): bound helper fallback lifecycle
thymikee Jul 30, 2026
c2fcd0f
fix(android): harden emulator e2e lifecycle
thymikee Jul 30, 2026
54e3d10
fix: align e2e changes with kernel package
thymikee Jul 30, 2026
4418e2e
test(android): prove alert helper reuse directly
thymikee Jul 30, 2026
4c77fa3
fix(android): cancel stalled settle captures
thymikee Jul 30, 2026
1d9de9a
fix(android): separate helper retirement budgets
thymikee Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/actions/setup-android-replay-host/action.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -8,19 +8,19 @@ 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 }}

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
Expand Down
6 changes: 3 additions & 3 deletions .github/actions/upload-agent-device-artifacts/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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/**
Expand Down
17 changes: 14 additions & 3 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
2 changes: 1 addition & 1 deletion .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
2 changes: 1 addition & 1 deletion .github/workflows/macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
6 changes: 3 additions & 3 deletions .github/workflows/replays-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
4 changes: 3 additions & 1 deletion android/snapshot-helper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 extends Capture> {
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 extends Capture> T capture(Attempt<T> attempt, long timeoutMs)
throws InterruptedException, IncompleteCaptureException {
return capture(
attempt,
timeoutMs,
() -> System.nanoTime() / 1_000_000,
Thread::sleep);
}

static <T extends Capture> T capture(
Attempt<T> 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;
}
}
Loading
Loading