diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 000000000..a8d1793e4 --- /dev/null +++ b/.github/workflows/android.yml @@ -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/** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 887300b22..2d41d05d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/** diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 000000000..4a75ab9eb --- /dev/null +++ b/.github/workflows/ios.yml @@ -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/** diff --git a/src/platforms/android/index.ts b/src/platforms/android/index.ts index 0166bda77..c08fba025 100644 --- a/src/platforms/android/index.ts +++ b/src/platforms/android/index.ts @@ -449,12 +449,52 @@ async function dumpUiHierarchy(device: DeviceInfo): Promise { } async function dumpUiHierarchyOnce(device: DeviceInfo): Promise { - 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('= 0 ? start : text.indexOf(''); + if (end < 0 || end < hierarchyStart) return null; + const xml = text.slice(hierarchyStart, end + ''.length).trim(); + return xml.length > 0 ? xml : null; } function isRetryableAdbError(err: unknown): boolean { @@ -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; }