Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
"scripts/patch-xcuitest-runner-icon.ts",
"scripts/write-xcuitest-cache-metadata.ts",
"scripts/xcuitest-build-settings.ts",
// Shell-invoked executable: scripts/replays/android-manual.sh.
"scripts/replays/android-search-diagnostic.ts",
"packages/capture-kit/src/ios-snapshot-engine/replay.ts",
// #1596 regression fixture: runs as a real `node --experimental-strip-types`
// subprocess (test/integration/daemon-replace-exit-flush.test.ts), so
Expand Down
83 changes: 62 additions & 21 deletions .github/workflows/replays-manual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,23 @@ name: Replay Manual

on:
workflow_dispatch:
inputs:
readiness_wait:
description: 'Bounded #3077 comparison; normal preserves the ordinary full manual lane'
type: choice
options: [normal, enabled, disabled]
default: normal
android_diagnostic:
description: 'Android-only search diagnostic after comparison timing; use enabled or disabled'
type: boolean
default: false

permissions:
contents: read
actions: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
group: ci-${{ github.workflow }}-${{ github.ref }}-${{ inputs.readiness_wait != 'normal' && github.run_id || 'normal' }}
cancel-in-progress: true

jobs:
Expand All @@ -32,13 +42,20 @@ jobs:
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

- name: Prepare readiness comparison source
if: inputs.readiness_wait != 'normal'
env:
READINESS_MODE: ${{ inputs.readiness_wait }}
run: node --experimental-strip-types scripts/replays/readiness-experiment.ts prepare "$READINESS_MODE" "$GITHUB_SHA"

- name: Setup Android replay host
id: android-replay-host
uses: ./.github/actions/setup-android-replay-host
with:
package-helpers: 'true'

- name: Restore Android fixture APK
if: inputs.readiness_wait == 'normal'
id: fixture-app
uses: ./.github/actions/setup-fixture-app
with:
Expand All @@ -58,25 +75,7 @@ jobs:
profile: pixel_7
target: google_apis_playstore
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics
script: |
# android-emulator-runner executes this with /usr/bin/sh, which is dash on
# ubuntu runners and rejects `-o pipefail`. The serial assignment below is
# validated by the `test -n` guard instead.
set -eu
ANDROID_SERIAL="$(adb devices | awk 'NR > 1 && $2 == "device" { print $1; exit }')"
test -n "$ANDROID_SERIAL"
BOOT_FINISHED_AT="$(date +%s)"
pnpm gate build
pnpm clean:daemon
adb -s "$ANDROID_SERIAL" install -r "${{ steps.fixture-app.outputs.apk-path }}"
node --experimental-strip-types src/bin.ts replay examples/test-app/replays/drag-android.ad --platform android --serial "$ANDROID_SERIAL" --session nightly-android-target-drag --json
pnpm clean:daemon
FULL_STARTED_AT="$(date +%s)"
AGENT_DEVICE_ANDROID_E2E=1 AGENT_DEVICE_ANDROID_E2E_TIER=full 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 --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-android-emulator.test.ts
FULL_FINISHED_AT="$(date +%s)"
echo "Android setup before emulator: $((BOOT_FINISHED_AT - ${{ steps.android-setup.outputs.seconds }}))s"
echo "Android full scenario wall time: $((FULL_FINISHED_AT - FULL_STARTED_AT))s"
pnpm gate replay-android --retries 2 --artifacts-dir test/artifacts/replays-android --report-junit test/artifacts/replays-android.junit.xml
script: sh scripts/replays/android-manual.sh "${{ inputs.readiness_wait }}" "${{ steps.fixture-app.outputs.apk-path }}" "${{ steps.fixture-app.outputs.app-id }}" "${{ steps.android-setup.outputs.seconds }}" "${{ inputs.android_diagnostic }}"

- name: Upload Android artifacts
if: always()
Expand All @@ -86,6 +85,7 @@ jobs:
agent-state-dir: ${{ env.AGENT_DEVICE_STATE_DIR }}

manual-ios:
if: ${{ !inputs.android_diagnostic }}
name: iOS Replay Suite
runs-on: macos-26
timeout-minutes: 80
Expand All @@ -107,6 +107,12 @@ jobs:
pnpm-lock.yaml
examples/test-app/pnpm-lock.yaml

- name: Prepare readiness comparison source
if: inputs.readiness_wait != 'normal'
env:
READINESS_MODE: ${{ inputs.readiness_wait }}
run: node --experimental-strip-types scripts/replays/readiness-experiment.ts prepare "$READINESS_MODE" "$GITHUB_SHA"

- name: Restore and build iOS XCTest runner
uses: ./.github/actions/setup-apple-runner-build
with:
Expand All @@ -117,6 +123,12 @@ jobs:
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator

- name: Build readiness comparison runtime
if: inputs.readiness_wait != 'normal'
uses: ./.github/actions/run-gate
with:
gate: build

- name: Boot iOS test simulator
id: ios-simulator
uses: ./.github/actions/boot-ios-test-simulator
Expand All @@ -130,6 +142,7 @@ jobs:
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --json

- name: Run iOS simulator replay suite
if: inputs.readiness_wait == 'normal'
uses: ./.github/actions/run-gate
with:
gate: replay-ios
Expand All @@ -143,22 +156,50 @@ jobs:
--report-junit
test/artifacts/replays-ios-simulator.junit.xml

- name: Begin iOS readiness comparison
if: inputs.readiness_wait != 'normal'
run: node --experimental-strip-types scripts/replays/readiness-experiment.ts begin ios

- name: Run iOS readiness comparison
if: inputs.readiness_wait != 'normal'
uses: ./.github/actions/run-gate
with:
gate: replay-ios
args: |
--udid
${{ steps.ios-simulator.outputs.simulator-udid }}
--debug
--retries
0
--artifacts-dir
test/artifacts/replays-ios-simulator
--report-junit
test/artifacts/replays-ios-simulator.junit.xml

- name: Collect iOS readiness evidence
if: always() && inputs.readiness_wait != 'normal'
run: node --experimental-strip-types scripts/replays/readiness-experiment.ts summarize ios "$AGENT_DEVICE_STATE_DIR" test/artifacts/replays-ios-simulator.junit.xml

- name: Fetch current fixture app
if: inputs.readiness_wait == 'normal'
id: fixture-app
uses: ./.github/actions/setup-fixture-app
with:
install: 'false'

- name: Report fixture cache source
if: inputs.readiness_wait == 'normal'
run: |
echo "Fixture app source: ${{ steps.fixture-app.outputs.source }}" >> "$GITHUB_STEP_SUMMARY"

- name: Prove selector drag reaches its destination on iOS
if: inputs.readiness_wait == 'normal'
run: |
xcrun simctl install "${{ steps.ios-simulator.outputs.simulator-udid }}" "${{ steps.fixture-app.outputs.app-path }}"
node --experimental-strip-types src/bin.ts replay examples/test-app/replays/drag.ad --platform ios --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --session nightly-ios-target-drag --json

- name: Run full fixture-backed iOS simulator E2E
if: inputs.readiness_wait == 'normal'
env:
AGENT_DEVICE_FIXTURE_APP_ID: ${{ steps.fixture-app.outputs.app-id }}
AGENT_DEVICE_FIXTURE_APP_PATH: ${{ steps.fixture-app.outputs.app-path }}
Expand All @@ -170,7 +211,7 @@ jobs:
node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-ios-simulator-coverage.test.ts test/integration/smoke-ios-simulator.test.ts

- name: Run iOS physical device replay suite
if: env.IOS_UDID != ''
if: inputs.readiness_wait == 'normal' && env.IOS_UDID != ''
env:
IOS_UDID: ${{ vars.IOS_UDID }}
uses: ./.github/actions/run-gate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export function createAdReplayStepRuntime(params: {
});
},

async observeTarget({ action, token }) {
async observeTarget({ action, token, index }) {
const observeOnce = async (): Promise<AdReplayTargetObservation> => {
const observation = await captureTargetObservation(action);
lastObservation = observation;
Expand Down Expand Up @@ -251,7 +251,13 @@ export function createAdReplayStepRuntime(params: {
emitDiagnostic({
level: 'debug',
phase: 'interaction_target_readiness',
data: { ...gateWait.readiness, command: action.command },
data: {
...gateWait.readiness,
command: action.command,
replaySession: ctx.sessionName,
replayPath: ctx.resolved,
step: index + 1,
},
});
}
if (observed.kind === 'done') return observed.result;
Expand Down
62 changes: 62 additions & 0 deletions scripts/replays/android-manual.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/bin/sh
set -eu

READINESS_MODE="$1"
FIXTURE_APK="$2"
FIXTURE_APP_ID="$3"
ANDROID_SETUP_SECONDS="$4"
ANDROID_DIAGNOSTIC="${5:-false}"
ANDROID_SERIAL="$(adb devices | awk 'NR > 1 && $2 == "device" { print $1; exit }')"
test -n "$ANDROID_SERIAL"
BOOT_FINISHED_AT="$(date +%s)"
pnpm gate build
pnpm clean:daemon

if [ "$READINESS_MODE" != normal ]; then
LOGCAT_PID=
LOGCAT_EXIT=0
stop_native_logcat() {
if [ -n "$LOGCAT_PID" ]; then
kill "$LOGCAT_PID" 2>/dev/null || :
wait "$LOGCAT_PID" 2>/dev/null || LOGCAT_EXIT=$?
printf '%s %s\n' "$(date -u +%FT%TZ)" "$LOGCAT_EXIT" > "$NATIVE_DIR/native-logcat-end.txt"
LOGCAT_PID=
fi
}
trap stop_native_logcat 0
trap 'exit 130' INT
trap 'exit 143' TERM
if [ "$ANDROID_DIAGNOSTIC" = true ]; then
NATIVE_DIR=test/artifacts/readiness-experiment/android-diagnostic
mkdir -p "$NATIVE_DIR"
(ulimit -f 8192; exec timeout --kill-after=2 --signal=TERM 300 adb -s "$ANDROID_SERIAL" logcat -v threadtime -T 1 ActivityTaskManager:I ActivityManager:I AndroidRuntime:E WindowManager:I InputMethodManagerService:V InputMethodManager:V UiAutomationConnection:V '*:S') > "$NATIVE_DIR/native-logcat.stdout.txt" 2> "$NATIVE_DIR/native-logcat.stderr.txt" &
LOGCAT_PID=$!
printf '%s %s\n' "$(date -u +%FT%TZ)" "$LOGCAT_PID" > "$NATIVE_DIR/native-logcat-start.txt"
fi
node --experimental-strip-types scripts/replays/readiness-experiment.ts begin android
REPLAY_EXIT=0
pnpm gate replay-android --serial "$ANDROID_SERIAL" --debug --retries 0 --artifacts-dir test/artifacts/replays-android --report-junit test/artifacts/replays-android.junit.xml || REPLAY_EXIT=$?
SUMMARY_EXIT=0
DIAGNOSTIC_ONLY=
if [ "$ANDROID_DIAGNOSTIC" = true ]; then DIAGNOSTIC_ONLY=diagnostic-only; fi
node --experimental-strip-types scripts/replays/readiness-experiment.ts summarize android "$AGENT_DEVICE_STATE_DIR" test/artifacts/replays-android.junit.xml $DIAGNOSTIC_ONLY || SUMMARY_EXIT=$?
stop_native_logcat
DIAGNOSTIC_EXIT=0
if [ "$ANDROID_DIAGNOSTIC" = true ]; then
node --experimental-strip-types scripts/replays/android-search-diagnostic.ts "$ANDROID_SERIAL" "$NATIVE_DIR" || DIAGNOSTIC_EXIT=$?
echo "Android native diagnostic exit: $DIAGNOSTIC_EXIT; measured replay exit: $REPLAY_EXIT"
fi
if [ "$REPLAY_EXIT" -ne 0 ]; then exit "$REPLAY_EXIT"; fi
if [ "$SUMMARY_EXIT" -ne 0 ]; then exit "$SUMMARY_EXIT"; fi
exit "$DIAGNOSTIC_EXIT"
fi

adb -s "$ANDROID_SERIAL" install -r "$FIXTURE_APK"
node --experimental-strip-types src/bin.ts replay examples/test-app/replays/drag-android.ad --platform android --serial "$ANDROID_SERIAL" --session nightly-android-target-drag --json
pnpm clean:daemon
FULL_STARTED_AT="$(date +%s)"
AGENT_DEVICE_ANDROID_E2E=1 AGENT_DEVICE_ANDROID_E2E_TIER=full AGENT_DEVICE_ANDROID_SERIAL="$ANDROID_SERIAL" AGENT_DEVICE_FIXTURE_APP_PATH="$FIXTURE_APK" AGENT_DEVICE_FIXTURE_APP_ID="$FIXTURE_APP_ID" node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-android-emulator.test.ts
FULL_FINISHED_AT="$(date +%s)"
echo "Android setup before emulator: $((BOOT_FINISHED_AT - ANDROID_SETUP_SECONDS))s"
echo "Android full scenario wall time: $((FULL_FINISHED_AT - FULL_STARTED_AT))s"
pnpm gate replay-android --retries 2 --artifacts-dir test/artifacts/replays-android --report-junit test/artifacts/replays-android.junit.xml
95 changes: 95 additions & 0 deletions scripts/replays/android-manual.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import fs from 'node:fs';
import path from 'node:path';
import { parse } from 'yaml';
import { expect, test } from 'vitest';
import { runCmdSync } from '@agent-device/host-kit/command';
import { mkdtempForTest } from '@agent-device/test-kit/tmp-dir';

test.each([
[7, false, 7],
[0, true, 2],
[7, true, 7],
[0, true, 11],
] as const)(
'emulator action preserves suite status %i and separates diagnostic failure',
async (replayExit, diagnostic, expectedExit) => {
const cwd = await mkdtempForTest('android-manual-invocation-');
const bin = path.join(cwd, 'bin');
fs.mkdirSync(bin);
const writeTool = (name: string, script: string) =>
fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${script}\n`, { mode: 0o755 });
writeTool(
'adb',
String.raw`if [ "$1" = devices ]; then printf "List of devices attached\nemulator-7000\tdevice\n"; else printf '%s\n' "$*"; trap 'touch logger-stopped; exit 0' TERM; for tick in 1 2 3 4 5 6 7 8 9 10; do sleep 0.1; done; fi`,
);
writeTool('timeout', 'shift; shift; shift; exec "$@"');
writeTool(
'pnpm',
String.raw`printf "%s\n" "$*" >> "$READINESS_TEST_LOG"
if [ "$1 $2" = "gate replay-android" ]; then
if [ "$READINESS_TEST_DIAGNOSTIC" = true ]; then read stamp logger < test/artifacts/readiness-experiment/android-diagnostic/native-logcat-start.txt; kill -0 "$logger" || exit 42; sleep 0.1; fi
exit "$READINESS_TEST_REPLAY_EXIT"
fi`,
);
writeTool(
'node',
String.raw`if [ "$3" = begin ]; then touch started; sleep 0.1; exit "$READINESS_TEST_BEGIN_EXIT"; fi
if [ "$3" = summarize ]; then touch summarized; exit "$READINESS_TEST_SUMMARY_EXIT"; fi
if [ "$2" = scripts/replays/android-search-diagnostic.ts ]; then read stamp logger < test/artifacts/readiness-experiment/android-diagnostic/native-logcat-start.txt; if kill -0 "$logger" 2>/dev/null; then exit 42; fi; touch diagnosed; exit 2; fi`,
);
fs.mkdirSync(path.join(cwd, 'scripts/replays'), { recursive: true });
fs.copyFileSync(
'scripts/replays/android-manual.sh',
path.join(cwd, 'scripts/replays/android-manual.sh'),
);
const workflow = parse(fs.readFileSync('.github/workflows/replays-manual.yml', 'utf8'));
const runner = workflow.jobs['manual-android'].steps.find((step: { uses?: string }) =>
step.uses?.startsWith('reactivecircus/android-emulator-runner@'),
);
const script = runner.with.script
.replaceAll(/\$\{\{ inputs.readiness_wait \}\}/g, 'enabled')
.replaceAll(/\$\{\{ inputs.android_diagnostic \}\}/g, String(diagnostic))
.replaceAll(/\$\{\{ steps.fixture-app.outputs.apk-path \}\}/g, '')
.replaceAll(/\$\{\{ steps.fixture-app.outputs.app-id \}\}/g, '')
.replaceAll(/\$\{\{ steps.android-setup.outputs.seconds \}\}/g, '0');
const commands = script
.split('\n')
.map((line: string) => line.trim())
.filter((line: string) => line.length > 0 && !line.startsWith('#'));
const log = path.join(cwd, 'calls.log');
let exitCode = 0;
for (const command of commands) {
exitCode = runCmdSync('sh', ['-c', command], {
cwd,
allowFailure: true,
env: {
PATH: `${bin}:${process.env.PATH}`,
ANDROID_SERIAL: 'emulator-legacy',
AGENT_DEVICE_STATE_DIR: path.join(cwd, 'state'),
READINESS_TEST_LOG: log,
READINESS_TEST_REPLAY_EXIT: String(replayExit),
READINESS_TEST_DIAGNOSTIC: String(diagnostic),
READINESS_TEST_SUMMARY_EXIT: replayExit === 7 ? '9' : '0',
READINESS_TEST_BEGIN_EXIT: expectedExit === 11 ? '11' : '0',
},
}).exitCode;
if (exitCode !== 0) break;
}
expect(exitCode).toBe(expectedExit);
expect(fs.existsSync(path.join(cwd, 'started'))).toBe(true);
expect(fs.existsSync(path.join(cwd, 'summarized'))).toBe(expectedExit !== 11);
expect(fs.existsSync(path.join(cwd, 'diagnosed'))).toBe(diagnostic && expectedExit !== 11);
const native = path.join(cwd, 'test/artifacts/readiness-experiment/android-diagnostic');
expect(fs.existsSync(native)).toBe(diagnostic);
if (diagnostic) {
expect(fs.existsSync(path.join(cwd, 'logger-stopped'))).toBe(true);
expect(fs.readFileSync(path.join(native, 'native-logcat.stdout.txt'), 'utf8')).toContain(
'-s emulator-7000 logcat',
);
}
if (expectedExit !== 11)
expect(fs.readFileSync(log, 'utf8')).toContain(
'replay-android --serial emulator-7000 --debug --retries 0',
);
},
);
Loading
Loading