Skip to content
Merged
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
57 changes: 57 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Android

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
integration-android:
name: Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 80
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

- name: Resolve agent-device home
id: android-agent-home
run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT"

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Run Android integration test
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0
with:
api-level: 35
arch: x86_64
profile: pixel_7
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics
script: node --test test/integration/android.test.ts

- name: Upload Android artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-artifacts
if-no-files-found: ignore
path: |
${{ steps.android-agent-home.outputs.dir }}/daemon.log
${{ steps.android-agent-home.outputs.dir }}/sessions/**
test/screenshots/**
99 changes: 0 additions & 99 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,102 +58,3 @@ jobs:
run: |
pnpm build:all
pnpm test:smoke

integration-android:
name: Integration Android
runs-on: ubuntu-latest
timeout-minutes: 80
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

- name: Resolve agent-device home
id: android-agent-home
run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT"

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Run Android integration test
uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0
with:
api-level: 35
arch: x86_64
profile: pixel_7
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -no-metrics
script: node --test test/integration/android.test.ts

- name: Upload Android artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-artifacts
if-no-files-found: ignore
path: |
${{ steps.android-agent-home.outputs.dir }}/daemon.log
${{ steps.android-agent-home.outputs.dir }}/sessions/**
test/screenshots/**

integration-ios:
name: Integration iOS
runs-on: macos-latest
timeout-minutes: 80
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

- name: Resolve agent-device home
id: ios-agent-home
run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT"

- name: Select and start iOS simulator
run: |
UDID="$(
xcrun simctl list devices -j | node -e "
const fs = require('node:fs');
const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
const all = Object.values(payload.devices ?? {}).flat();
const available = all.filter((d) => d.isAvailable);
const preferred =
available.find((d) => d.state === 'Booted') ??
available.find((d) => d.name === 'iPhone 17 Pro') ??
available[0];
if (!preferred?.udid) process.exit(1);
process.stdout.write(preferred.udid);
"
)"
xcrun simctl boot "$UDID" || true
echo "IOS_UDID=$UDID" >> "$GITHUB_ENV"

- name: Build iOS integration artifacts
run: pnpm build:xcuitest

- name: Wait for iOS simulator boot
run: xcrun simctl bootstatus "$IOS_UDID" -b

- name: Run iOS integration test
env:
AGENT_DEVICE_DAEMON_TIMEOUT_MS: "300000"
run: node --test test/integration/ios.test.ts

- name: Upload iOS artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-artifacts
if-no-files-found: ignore
path: |
${{ steps.ios-agent-home.outputs.dir }}/daemon.log
${{ steps.ios-agent-home.outputs.dir }}/sessions/**
test/screenshots/**
72 changes: 72 additions & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: iOS

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
integration-ios:
name: Integration Tests
runs-on: macos-latest
timeout-minutes: 80
continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

- name: Resolve agent-device home
id: ios-agent-home
run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT"

- name: Select and start iOS simulator
run: |
UDID="$(
xcrun simctl list devices -j | node -e "
const fs = require('node:fs');
const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
const all = Object.values(payload.devices ?? {}).flat();
const available = all.filter((d) => d.isAvailable);
const preferred =
available.find((d) => d.state === 'Booted') ??
available.find((d) => d.name === 'iPhone 17 Pro') ??
available[0];
if (!preferred?.udid) process.exit(1);
process.stdout.write(preferred.udid);
"
)"
xcrun simctl boot "$UDID" || true
echo "IOS_UDID=$UDID" >> "$GITHUB_ENV"

- name: Build iOS integration artifacts
run: pnpm build:xcuitest

- name: Wait for iOS simulator boot
run: xcrun simctl bootstatus "$IOS_UDID" -b

- name: Run iOS integration test
env:
AGENT_DEVICE_DAEMON_TIMEOUT_MS: "300000"
run: node --test test/integration/ios.test.ts

- name: Upload iOS artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-artifacts
if-no-files-found: ignore
path: |
${{ steps.ios-agent-home.outputs.dir }}/daemon.log
${{ steps.ios-agent-home.outputs.dir }}/sessions/**
test/screenshots/**
49 changes: 45 additions & 4 deletions src/platforms/android/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,12 +449,52 @@ async function dumpUiHierarchy(device: DeviceInfo): Promise<string> {
}

async function dumpUiHierarchyOnce(device: DeviceInfo): Promise<string> {
await runCmd(
// Preferred: stream XML directly to stdout, avoiding file I/O race conditions.
const streamed = await runCmd(
'adb',
adbArgs(device, ['exec-out', 'uiautomator', 'dump', '/dev/tty']),
{ allowFailure: true },
);
if (streamed.exitCode === 0) {
const fromStream = extractUiDumpXml(streamed.stdout, streamed.stderr);
if (fromStream) return fromStream;
}

// Fallback: dump to file and read back.
// If `cat` fails with "no such file", the outer withRetry (via isRetryableAdbError) handles it.
const dumpPath = '/sdcard/window_dump.xml';
const dumpResult = await runCmd(
'adb',
adbArgs(device, ['shell', 'uiautomator', 'dump', '/sdcard/window_dump.xml']),
adbArgs(device, ['shell', 'uiautomator', 'dump', dumpPath]),
);
const result = await runCmd('adb', adbArgs(device, ['shell', 'cat', '/sdcard/window_dump.xml']));
return result.stdout;
const actualPath = resolveDumpPath(dumpPath, dumpResult.stdout, dumpResult.stderr);

const result = await runCmd('adb', adbArgs(device, ['shell', 'cat', actualPath]));
const xml = extractUiDumpXml(result.stdout, result.stderr);
if (!xml) {
throw new AppError('COMMAND_FAILED', 'uiautomator dump did not return XML', {
stdout: result.stdout,
stderr: result.stderr,
});
}
return xml;
}

function resolveDumpPath(defaultPath: string, stdout: string, stderr: string): string {
const text = `${stdout}\n${stderr}`;
const match = /dumped to:\s*(\S+)/i.exec(text);
return match?.[1] ?? defaultPath;
}

function extractUiDumpXml(stdout: string, stderr: string): string | null {
const text = `${stdout}\n${stderr}`;
const start = text.indexOf('<?xml');
const hierarchyStart = start >= 0 ? start : text.indexOf('<hierarchy');
if (hierarchyStart < 0) return null;
const end = text.lastIndexOf('</hierarchy>');
if (end < 0 || end < hierarchyStart) return null;
const xml = text.slice(hierarchyStart, end + '</hierarchy>'.length).trim();
return xml.length > 0 ? xml : null;
}

function isRetryableAdbError(err: unknown): boolean {
Expand All @@ -467,6 +507,7 @@ function isRetryableAdbError(err: unknown): boolean {
if (stderr.includes('connection reset')) return true;
if (stderr.includes('broken pipe')) return true;
if (stderr.includes('timed out')) return true;
if (stderr.includes('no such file or directory')) return true;
return false;
}

Expand Down
Loading