Skip to content

Commit 8e88eb5

Browse files
authored
fix: android race condition for snapshot dump (#33)
* fix: android race condition for snapshot dump * ci * split workflows * udpate names
1 parent db339c9 commit 8e88eb5

4 files changed

Lines changed: 174 additions & 103 deletions

File tree

.github/workflows/android.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: Android
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: ci-${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
integration-android:
18+
name: Integration Tests
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 80
21+
continue-on-error: true
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
25+
26+
- name: Setup toolchain
27+
uses: ./.github/actions/setup-node-pnpm
28+
29+
- name: Resolve agent-device home
30+
id: android-agent-home
31+
run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT"
32+
33+
- name: Enable KVM
34+
run: |
35+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
36+
sudo udevadm control --reload-rules
37+
sudo udevadm trigger --name-match=kvm
38+
39+
- name: Run Android integration test
40+
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0
41+
with:
42+
api-level: 35
43+
arch: x86_64
44+
profile: pixel_7
45+
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics
46+
script: node --test test/integration/android.test.ts
47+
48+
- name: Upload Android artifacts
49+
if: always()
50+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
51+
with:
52+
name: android-artifacts
53+
if-no-files-found: ignore
54+
path: |
55+
${{ steps.android-agent-home.outputs.dir }}/daemon.log
56+
${{ steps.android-agent-home.outputs.dir }}/sessions/**
57+
test/screenshots/**

.github/workflows/ci.yml

Lines changed: 0 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -58,102 +58,3 @@ jobs:
5858
run: |
5959
pnpm build:all
6060
pnpm test:smoke
61-
62-
integration-android:
63-
name: Integration Android
64-
runs-on: ubuntu-latest
65-
timeout-minutes: 80
66-
continue-on-error: true
67-
steps:
68-
- name: Checkout
69-
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
70-
71-
- name: Setup toolchain
72-
uses: ./.github/actions/setup-node-pnpm
73-
74-
- name: Resolve agent-device home
75-
id: android-agent-home
76-
run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT"
77-
78-
- name: Enable KVM
79-
run: |
80-
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
81-
sudo udevadm control --reload-rules
82-
sudo udevadm trigger --name-match=kvm
83-
84-
- name: Run Android integration test
85-
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0
86-
with:
87-
api-level: 35
88-
arch: x86_64
89-
profile: pixel_7
90-
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics
91-
script: node --test test/integration/android.test.ts
92-
93-
- name: Upload Android artifacts
94-
if: always()
95-
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
96-
with:
97-
name: android-artifacts
98-
if-no-files-found: ignore
99-
path: |
100-
${{ steps.android-agent-home.outputs.dir }}/daemon.log
101-
${{ steps.android-agent-home.outputs.dir }}/sessions/**
102-
test/screenshots/**
103-
104-
integration-ios:
105-
name: Integration iOS
106-
runs-on: macos-latest
107-
timeout-minutes: 80
108-
continue-on-error: true
109-
steps:
110-
- name: Checkout
111-
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
112-
113-
- name: Setup toolchain
114-
uses: ./.github/actions/setup-node-pnpm
115-
116-
- name: Resolve agent-device home
117-
id: ios-agent-home
118-
run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT"
119-
120-
- name: Select and start iOS simulator
121-
run: |
122-
UDID="$(
123-
xcrun simctl list devices -j | node -e "
124-
const fs = require('node:fs');
125-
const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
126-
const all = Object.values(payload.devices ?? {}).flat();
127-
const available = all.filter((d) => d.isAvailable);
128-
const preferred =
129-
available.find((d) => d.state === 'Booted') ??
130-
available.find((d) => d.name === 'iPhone 17 Pro') ??
131-
available[0];
132-
if (!preferred?.udid) process.exit(1);
133-
process.stdout.write(preferred.udid);
134-
"
135-
)"
136-
xcrun simctl boot "$UDID" || true
137-
echo "IOS_UDID=$UDID" >> "$GITHUB_ENV"
138-
139-
- name: Build iOS integration artifacts
140-
run: pnpm build:xcuitest
141-
142-
- name: Wait for iOS simulator boot
143-
run: xcrun simctl bootstatus "$IOS_UDID" -b
144-
145-
- name: Run iOS integration test
146-
env:
147-
AGENT_DEVICE_DAEMON_TIMEOUT_MS: "300000"
148-
run: node --test test/integration/ios.test.ts
149-
150-
- name: Upload iOS artifacts
151-
if: always()
152-
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
153-
with:
154-
name: ios-artifacts
155-
if-no-files-found: ignore
156-
path: |
157-
${{ steps.ios-agent-home.outputs.dir }}/daemon.log
158-
${{ steps.ios-agent-home.outputs.dir }}/sessions/**
159-
test/screenshots/**

.github/workflows/ios.yml

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
name: iOS
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: ci-${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
integration-ios:
18+
name: Integration Tests
19+
runs-on: macos-latest
20+
timeout-minutes: 80
21+
continue-on-error: true
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
25+
26+
- name: Setup toolchain
27+
uses: ./.github/actions/setup-node-pnpm
28+
29+
- name: Resolve agent-device home
30+
id: ios-agent-home
31+
run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT"
32+
33+
- name: Select and start iOS simulator
34+
run: |
35+
UDID="$(
36+
xcrun simctl list devices -j | node -e "
37+
const fs = require('node:fs');
38+
const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
39+
const all = Object.values(payload.devices ?? {}).flat();
40+
const available = all.filter((d) => d.isAvailable);
41+
const preferred =
42+
available.find((d) => d.state === 'Booted') ??
43+
available.find((d) => d.name === 'iPhone 17 Pro') ??
44+
available[0];
45+
if (!preferred?.udid) process.exit(1);
46+
process.stdout.write(preferred.udid);
47+
"
48+
)"
49+
xcrun simctl boot "$UDID" || true
50+
echo "IOS_UDID=$UDID" >> "$GITHUB_ENV"
51+
52+
- name: Build iOS integration artifacts
53+
run: pnpm build:xcuitest
54+
55+
- name: Wait for iOS simulator boot
56+
run: xcrun simctl bootstatus "$IOS_UDID" -b
57+
58+
- name: Run iOS integration test
59+
env:
60+
AGENT_DEVICE_DAEMON_TIMEOUT_MS: "300000"
61+
run: node --test test/integration/ios.test.ts
62+
63+
- name: Upload iOS artifacts
64+
if: always()
65+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
66+
with:
67+
name: ios-artifacts
68+
if-no-files-found: ignore
69+
path: |
70+
${{ steps.ios-agent-home.outputs.dir }}/daemon.log
71+
${{ steps.ios-agent-home.outputs.dir }}/sessions/**
72+
test/screenshots/**

src/platforms/android/index.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -449,12 +449,52 @@ async function dumpUiHierarchy(device: DeviceInfo): Promise<string> {
449449
}
450450

451451
async function dumpUiHierarchyOnce(device: DeviceInfo): Promise<string> {
452-
await runCmd(
452+
// Preferred: stream XML directly to stdout, avoiding file I/O race conditions.
453+
const streamed = await runCmd(
454+
'adb',
455+
adbArgs(device, ['exec-out', 'uiautomator', 'dump', '/dev/tty']),
456+
{ allowFailure: true },
457+
);
458+
if (streamed.exitCode === 0) {
459+
const fromStream = extractUiDumpXml(streamed.stdout, streamed.stderr);
460+
if (fromStream) return fromStream;
461+
}
462+
463+
// Fallback: dump to file and read back.
464+
// If `cat` fails with "no such file", the outer withRetry (via isRetryableAdbError) handles it.
465+
const dumpPath = '/sdcard/window_dump.xml';
466+
const dumpResult = await runCmd(
453467
'adb',
454-
adbArgs(device, ['shell', 'uiautomator', 'dump', '/sdcard/window_dump.xml']),
468+
adbArgs(device, ['shell', 'uiautomator', 'dump', dumpPath]),
455469
);
456-
const result = await runCmd('adb', adbArgs(device, ['shell', 'cat', '/sdcard/window_dump.xml']));
457-
return result.stdout;
470+
const actualPath = resolveDumpPath(dumpPath, dumpResult.stdout, dumpResult.stderr);
471+
472+
const result = await runCmd('adb', adbArgs(device, ['shell', 'cat', actualPath]));
473+
const xml = extractUiDumpXml(result.stdout, result.stderr);
474+
if (!xml) {
475+
throw new AppError('COMMAND_FAILED', 'uiautomator dump did not return XML', {
476+
stdout: result.stdout,
477+
stderr: result.stderr,
478+
});
479+
}
480+
return xml;
481+
}
482+
483+
function resolveDumpPath(defaultPath: string, stdout: string, stderr: string): string {
484+
const text = `${stdout}\n${stderr}`;
485+
const match = /dumped to:\s*(\S+)/i.exec(text);
486+
return match?.[1] ?? defaultPath;
487+
}
488+
489+
function extractUiDumpXml(stdout: string, stderr: string): string | null {
490+
const text = `${stdout}\n${stderr}`;
491+
const start = text.indexOf('<?xml');
492+
const hierarchyStart = start >= 0 ? start : text.indexOf('<hierarchy');
493+
if (hierarchyStart < 0) return null;
494+
const end = text.lastIndexOf('</hierarchy>');
495+
if (end < 0 || end < hierarchyStart) return null;
496+
const xml = text.slice(hierarchyStart, end + '</hierarchy>'.length).trim();
497+
return xml.length > 0 ? xml : null;
458498
}
459499

460500
function isRetryableAdbError(err: unknown): boolean {
@@ -467,6 +507,7 @@ function isRetryableAdbError(err: unknown): boolean {
467507
if (stderr.includes('connection reset')) return true;
468508
if (stderr.includes('broken pipe')) return true;
469509
if (stderr.includes('timed out')) return true;
510+
if (stderr.includes('no such file or directory')) return true;
470511
return false;
471512
}
472513

0 commit comments

Comments
 (0)