Skip to content

Commit fdacf18

Browse files
thymikeeclaude
andcommitted
Merge origin/main into perf/android-command-suite
#3885 squash-merged as d217300 with the same scripts/perf and perf-nightly.yml content as its final head aed5aa7, so every conflict keeps this branch's side. Co-Authored-By: Claude Opus 5.5 <[email protected]>
2 parents 50502ec + d217300 commit fdacf18

33 files changed

Lines changed: 1348 additions & 162 deletions

‎.github/workflows/android.yml‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,27 @@ jobs:
4141
- name: Setup toolchain
4242
uses: ./.github/actions/setup-node-pnpm
4343

44+
- name: Fetch the PR base for Android smoke selection
45+
if: github.event_name == 'pull_request'
46+
env:
47+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
48+
run: git fetch origin "$BASE_SHA" --depth=1 || echo "Base fetch failed; Android smoke selection will fail open."
49+
50+
- name: Select Android smoke tests
51+
id: impact
52+
env:
53+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
54+
run: node --experimental-strip-types scripts/device-lane-impact.ts replay-android android-helpers
55+
4456
- name: Setup Android replay host
57+
if: steps.impact.outputs.run != 'false'
4558
id: android-replay-host
4659
uses: ./.github/actions/setup-android-replay-host
4760
with:
4861
package-helpers: 'true'
4962

5063
- name: Restore fixture APK
64+
if: steps.impact.outputs.run != 'false'
5165
id: fixture-app
5266
uses: ./.github/actions/setup-fixture-app
5367
with:
@@ -56,12 +70,15 @@ jobs:
5670
wait-for-artifact-seconds: '1800'
5771

5872
- name: Report fixture cache source
73+
if: steps.impact.outputs.run != 'false'
5974
run: echo "Android fixture APK source=${{ steps.fixture-app.outputs.source }}"
6075

6176
- name: Run Android emulator catalog coverage contract
77+
if: steps.impact.outputs.run != 'false'
6278
run: node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-android-emulator-coverage.test.ts
6379

6480
- name: Run Android smoke checks
81+
if: steps.impact.outputs.run != 'false'
6582
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0
6683
with:
6784
api-level: 36
@@ -79,7 +96,7 @@ jobs:
7996
node --experimental-strip-types src/bin.ts test test/integration/replays/android/emulator/01-settings.ad --retries 2 --report-junit test/artifacts/replays-android-smoke.junit.xml
8097
8198
- name: Upload Android artifacts
82-
if: always()
99+
if: always() && steps.impact.outputs.run != 'false'
83100
uses: ./.github/actions/upload-agent-device-artifacts
84101
with:
85102
artifact-name: android-artifacts

‎.github/workflows/apple.yml‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,20 @@ jobs:
5656
shell: bash
5757
run: |
5858
git fetch origin "$BASE_SHA" --depth=1 || echo "Base fetch failed; Apple selection will fail open."
59+
- name: Select iOS smoke tests
60+
id: ios-impact
61+
shell: bash
62+
run: node --experimental-strip-types scripts/apple-ci-impact.ts smoke
63+
- name: Select macOS smoke tests
64+
id: macos-impact
65+
shell: bash
66+
run: node --experimental-strip-types scripts/apple-ci-impact.ts macos
5967
- name: Build the shared CLI
6068
id: build
69+
if: steps.ios-impact.outputs.run != 'false' || steps.macos-impact.outputs.run != 'false'
6170
uses: ./.github/actions/run-gate
6271
with:
6372
gate: build
64-
- name: Select iOS smoke tests
65-
id: ios-impact
66-
shell: bash
67-
run: node --experimental-strip-types scripts/apple-ci-impact.ts smoke
6873
# The macOS clean-install proof may raise permission UI. Run the iOS focus proof first.
6974
- name: iOS smoke tests
7075
id: ios
@@ -101,7 +106,7 @@ jobs:
101106
RESULT_BUNDLE_PATH: ${{ github.workspace }}/.tmp/xctest-host/RunnerTests.xcresult
102107
RESULT_SUMMARY_PATH: ${{ github.workspace }}/.tmp/xctest-host/test-results-summary.json
103108
XCTEST_LANE: host
104-
if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.ios-cleanup.outcome == 'success' }}
109+
if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.ios-cleanup.outcome == 'success' && steps.macos-impact.outputs.run != 'false' }}
105110
- name: Diagnose the Apple host after a phase failure
106111
if: ${{ always() && (steps.ios.outcome == 'failure' || steps.macos.outcome == 'failure' || steps.ios-cleanup.outcome == 'failure') }}
107112
continue-on-error: true
@@ -119,11 +124,12 @@ jobs:
119124
shell: bash
120125
run: |
121126
pnpm clean:daemon
122-
- name: Require both Apple phases to succeed
127+
- name: Require the selected Apple phases to succeed
123128
if: always()
124129
env:
125130
IOS_SELECTED: ${{ steps.ios-impact.outputs.run }}
126131
IOS_OUTCOME: ${{ steps.ios.outcome }}
132+
MACOS_SELECTED: ${{ steps.macos-impact.outputs.run }}
127133
MACOS_OUTCOME: ${{ steps.macos.outcome }}
128134
IOS_CLEANUP_OUTCOME: ${{ steps.ios-cleanup.outcome }}
129135
MACOS_CLEANUP_OUTCOME: ${{ steps.macos-cleanup.outcome }}
@@ -136,9 +142,18 @@ jobs:
136142
else
137143
test "$IOS_OUTCOME" = success
138144
fi
139-
test "$MACOS_OUTCOME" = success
140-
test "$IOS_CLEANUP_OUTCOME" = success
141-
test "$MACOS_CLEANUP_OUTCOME" = success
145+
if [ "$MACOS_SELECTED" = false ]; then
146+
test "$MACOS_OUTCOME" = skipped
147+
else
148+
test "$MACOS_OUTCOME" = success
149+
fi
150+
if [ "$IOS_SELECTED" = false ] && [ "$MACOS_SELECTED" = false ]; then
151+
test "$IOS_CLEANUP_OUTCOME" = skipped
152+
test "$MACOS_CLEANUP_OUTCOME" = skipped
153+
else
154+
test "$IOS_CLEANUP_OUTCOME" = success
155+
test "$MACOS_CLEANUP_OUTCOME" = success
156+
fi
142157
- name: Upload Apple artifacts
143158
if: always()
144159
uses: ./.github/actions/upload-agent-device-artifacts

‎.github/workflows/ci.yml‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,21 +395,35 @@ jobs:
395395
uses: ./.github/actions/run-gate
396396
with: { gate: fixture-cache }
397397

398+
- name: Fetch the PR base for web smoke selection
399+
if: github.event_name == 'pull_request'
400+
env:
401+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
402+
run: git fetch origin "$BASE_SHA" --depth=1 || echo "Base fetch failed; web smoke selection will fail open."
403+
404+
- name: Select web smoke tests
405+
id: web-impact
406+
env:
407+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
408+
run: node --experimental-strip-types scripts/device-lane-impact.ts web-smoke
409+
398410
# Shares this job's ubuntu toolchain. AGENT_DEVICE_WEB_E2E is step-scoped
399411
# so it cannot leak into the node/provider suites above.
400412
- name: Run live web smoke
413+
if: steps.web-impact.outputs.run != 'false'
401414
env:
402415
AGENT_DEVICE_WEB_E2E: '1'
403416
run: pnpm clean:daemon
404417

405418
- name: Execute live web smoke
419+
if: steps.web-impact.outputs.run != 'false'
406420
env:
407421
AGENT_DEVICE_WEB_E2E: '1'
408422
uses: ./.github/actions/run-gate
409423
with: { gate: web-smoke }
410424

411425
- name: Upload web smoke artifacts
412-
if: always()
426+
if: always() && steps.web-impact.outputs.run != 'false'
413427
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
414428
with:
415429
name: web-smoke-artifacts

‎.github/workflows/linux.yml‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ jobs:
4646
- name: Checkout
4747
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
4848

49+
- name: Setup toolchain
50+
uses: ./.github/actions/setup-node-pnpm
51+
52+
- name: Fetch the PR base for Linux smoke selection
53+
if: github.event_name == 'pull_request'
54+
env:
55+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
56+
run: git fetch origin "$BASE_SHA" --depth=1 || echo "Base fetch failed; Linux smoke selection will fail open."
57+
58+
- name: Select Linux smoke tests
59+
id: impact
60+
env:
61+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
62+
run: node --experimental-strip-types scripts/device-lane-impact.ts replay-linux linux-command-evidence
63+
4964
# Unbounded, this step could not fail — it could only stall. On 2026-08-19 a slow mirror
5065
# held apt past the job's 30-minute budget on four main-branch runs and several unrelated
5166
# PRs, which reported as a red check on branches that never ran a line of project code.
@@ -54,6 +69,7 @@ jobs:
5469
# that drips bytes slowly enough to defeat the socket timeout. A healthy install takes
5570
# about a minute, so six is generous.
5671
- name: Install Linux desktop dependencies
72+
if: steps.impact.outputs.run != 'false'
5773
timeout-minutes: 6
5874
run: |
5975
set -euo pipefail
@@ -77,10 +93,8 @@ jobs:
7793
gnome-calculator \
7894
wmctrl
7995
80-
- name: Setup toolchain
81-
uses: ./.github/actions/setup-node-pnpm
82-
8396
- name: Start Xvfb and D-Bus
97+
if: steps.impact.outputs.run != 'false'
8498
run: |
8599
# Start virtual framebuffer (1280x1024, 24-bit color)
86100
Xvfb :99 -screen 0 1280x1024x24 &
@@ -93,6 +107,7 @@ jobs:
93107
echo "DBUS_SESSION_BUS_PID=$DBUS_SESSION_BUS_PID" >> "$GITHUB_ENV"
94108
95109
- name: Start AT-SPI2 registry
110+
if: steps.impact.outputs.run != 'false'
96111
run: |
97112
# The registry must start AFTER DBUS_SESSION_BUS_ADDRESS is available
98113
# (it was written to GITHUB_ENV in the previous step).
@@ -112,6 +127,7 @@ jobs:
112127
fi
113128
114129
- name: Verify environment
130+
if: steps.impact.outputs.run != 'false'
115131
run: |
116132
echo "=== Display ==="
117133
xdotool getdisplaygeometry
@@ -125,9 +141,11 @@ jobs:
125141
xdotool version
126142
127143
- name: Run Linux replay smoke test
144+
if: steps.impact.outputs.run != 'false'
128145
run: pnpm clean:daemon
129146

130147
- name: Execute Linux replay smoke test
148+
if: steps.impact.outputs.run != 'false'
131149
uses: ./.github/actions/run-gate
132150
with:
133151
gate: replay-linux
@@ -138,15 +156,17 @@ jobs:
138156
test/artifacts/replays-linux.junit.xml
139157
140158
- name: Reset daemon before Linux command evidence
159+
if: steps.impact.outputs.run != 'false'
141160
run: pnpm clean:daemon
142161

143162
- name: Execute Linux command evidence
163+
if: steps.impact.outputs.run != 'false'
144164
uses: ./.github/actions/run-gate
145165
with:
146166
gate: linux-command-evidence
147167

148168
- name: Upload Linux artifacts
149-
if: always()
169+
if: always() && steps.impact.outputs.run != 'false'
150170
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
151171
with:
152172
name: linux-artifacts

‎android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityNodeReads.java‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,30 @@ static AccessibilityNodeInfo root(AccessibilityWindowInfo window) {
3030
return window.getRoot();
3131
}
3232

33+
interface Read<T> {
34+
T read();
35+
}
36+
3337
/**
3438
* The child, or null when it is gone. When the child's window closes during the read, the
3539
* framework can throw a NullPointerException from its result integrity check instead of answering
3640
* null (seen on API 35 while an alert dialog closed), so that throw means the same as null.
41+
*
42+
* <p>A null {@code node} is a programmer error, but the same catch hides its NullPointerException
43+
* as a gone child.
3744
*/
3845
static AccessibilityNodeInfo child(AccessibilityNodeInfo node, int index) {
46+
return nullIfGone(
47+
() ->
48+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
49+
? node.getChild(index, PREFETCH_STRATEGY)
50+
: node.getChild(index));
51+
}
52+
53+
/** Runs a framework read that answers null for a vanished node and can throw instead. */
54+
static <T> T nullIfGone(Read<T> read) {
3955
try {
40-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
41-
return node.getChild(index, PREFETCH_STRATEGY);
42-
}
43-
return node.getChild(index);
56+
return read.read();
4457
} catch (NullPointerException vanished) {
4558
return null;
4659
}

‎android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/AccessibilityReadBatchTest.java‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ static void run() throws Exception {
1414
releasesUnreadResultsAfterFailure();
1515
waitsForReadersBeforeReleasingTheParent();
1616
preservesInterruptionDuringCleanup();
17+
readsAChildThatVanishedMidReadAsGone();
1718
}
1819

1920
private static void queuesConcurrentReadsAndPreservesOrder() throws Exception {
@@ -134,6 +135,41 @@ private static void preservesInterruptionDuringCleanup() {
134135
}
135136
}
136137

138+
private static void readsAChildThatVanishedMidReadAsGone() {
139+
List<Integer> recycled = new ArrayList<>();
140+
try (AccessibilityReadBatch<Integer> batch =
141+
new AccessibilityReadBatch<>(
142+
0, 3,
143+
index ->
144+
AccessibilityNodeReads.nullIfGone(
145+
() -> {
146+
if (index == 1) {
147+
throw new NullPointerException("window closed");
148+
}
149+
if (index == 2) {
150+
throw new IllegalStateException("not a vanished child");
151+
}
152+
return index;
153+
}),
154+
recycled::add)) {
155+
if (batch.get(0) != 0) {
156+
throw new AssertionError("Expected the first child");
157+
}
158+
if (batch.get(1) != null) {
159+
throw new AssertionError("A child that vanished mid-read must read as gone");
160+
}
161+
try {
162+
batch.get(2);
163+
throw new AssertionError("Other read failures must propagate");
164+
} catch (IllegalStateException expected) {
165+
// Only the vanished-window throw means a gone child.
166+
}
167+
}
168+
if (!recycled.equals(Arrays.asList(0))) {
169+
throw new AssertionError("Only the present child must be released: " + recycled);
170+
}
171+
}
172+
137173
private static void await(CountDownLatch latch) {
138174
try {
139175
if (!latch.await(2, TimeUnit.SECONDS)) {

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,26 @@ extension RunnerTests {
129129
else {
130130
return .failure(RunnerAppScreenCaptureFailure(resolution))
131131
}
132+
return capturedScreen(captureScreen, displayID: displayID)
133+
}
134+
135+
/// Samples the same sole-screen route that synthesized gestures use when the app tree refuses
136+
/// its window. This route cannot name a screen on an iPad or a multi-screen iPhone.
137+
func captureSoleAppScreen() -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure> {
138+
var screen: AnyObject?
139+
var displayID: UInt = 0
140+
guard RunnerResolveSoleScreen(&screen, &displayID) == nil,
141+
let captureScreen = screen as? XCUIScreen
142+
else {
143+
return .failure(.unresolvedScreen)
144+
}
145+
return Self.capturedScreen(captureScreen, displayID: displayID)
146+
}
147+
148+
private static func capturedScreen(
149+
_ captureScreen: XCUIScreen,
150+
displayID: UInt
151+
) -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure> {
132152
let image = captureScreen.screenshot().image
133153
guard let upright = runnerUprightCaptureImage(image) else {
134154
return .failure(.unrenderableImage)

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ extension RunnerTests {
132132
privateAXAcceptedDepthBundleId = bundleId
133133
privateAXAcceptedDepthProcessIdentifier = processIdentifier
134134
privateAXAcceptedDepth = depth
135-
privateAXAcceptedDepthUntil = Date().addingTimeInterval(snapshotXCTestChannelPenaltyDuration)
135+
privateAXAcceptedDepthUntil = Date().addingTimeInterval(SnapshotXCTestChannelPenalty.duration)
136136
privateAXAcceptedDepthLock.unlock()
137137
NSLog("AGENT_DEVICE_RUNNER_PRIVATE_AX_DEPTH_REMEMBERED depth=%ld bundle=%@", depth, bundleId ?? "")
138138
}

0 commit comments

Comments
 (0)