Skip to content

Commit da93c44

Browse files
committed
⚗️ [POC] Setup e2e tests on Android using emulator
Setup Android E2E testing using Playwright's experimental Android API with an emulator running on a macOS Sonoma GitLab runner. - Boot Android emulator in globalSetup, teardown in globalTeardown - Install recent Chromium (v147) via snapshot builds to replace outdated system Chrome (v113) - Reuse device connection and browser context across tests for performance - Clean up service workers between tests to prevent stale state - Wire Android fixture into existing createTest() framework - All existing e2e scenarios run on Android with bundle setup - 237/249 tests pass, 1 deterministic failure (scroll DPI rounding), ~3 flaky
1 parent c52821b commit da93c44

File tree

13 files changed

+375
-5
lines changed

13 files changed

+375
-5
lines changed

.gitlab-ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ variables:
22
CURRENT_STAGING: staging-08
33
APP: 'browser-sdk'
44
CURRENT_CI_IMAGE: 100
5+
CURRENT_ANDROID_CI_IMAGE: 1
56
BUILD_STABLE_REGISTRY: 'registry.ddbuild.io'
67
CI_IMAGE: '$BUILD_STABLE_REGISTRY/ci/$APP:$CURRENT_CI_IMAGE'
8+
CI_IMAGE_ANDROID: '$BUILD_STABLE_REGISTRY/ci/$APP-android:$CURRENT_ANDROID_CI_IMAGE'
79
GIT_REPOSITORY: 'git@github.com:DataDog/browser-sdk.git'
810
MAIN_BRANCH: 'main'
911
NEXT_MAJOR_BRANCH: 'v7'
@@ -123,6 +125,17 @@ ci-image:
123125
script:
124126
- docker buildx build --platform linux/amd64 --build-arg CHROME_PACKAGE_VERSION=$CHROME_PACKAGE_VERSION --tag $CI_IMAGE --push .
125127

128+
ci-image-android:
129+
stage: ci-image
130+
extends:
131+
- .base-configuration
132+
- .feature-branches
133+
when: manual
134+
tags: ['arch:amd64']
135+
image: $BUILD_STABLE_REGISTRY/images/docker:27.3.1
136+
script:
137+
- docker buildx build --platform linux/amd64 --build-arg BASE_IMAGE=$CI_IMAGE -f Dockerfile.android --tag $CI_IMAGE_ANDROID --push .
138+
126139
########################################################################################################################
127140
# Tests
128141
########################################################################################################################
@@ -231,6 +244,25 @@ e2e:
231244
after_script:
232245
- node ./scripts/test/export-test-result.ts e2e
233246

247+
e2e-android:
248+
extends:
249+
- .base-configuration
250+
- .feature-branches
251+
tags:
252+
- 'macos:sonoma'
253+
- 'specific:true'
254+
interruptible: true
255+
timeout: 40 minutes
256+
artifacts:
257+
when: always
258+
reports:
259+
junit: test-report/e2e-android/*.xml
260+
script:
261+
- yarn
262+
- yarn build
263+
- yarn build:apps
264+
- FORCE_COLOR=1 CI_JOB_NAME=e2e-android yarn test:e2e:android
265+
234266
check-licenses:
235267
extends:
236268
- .base-configuration

Dockerfile.android

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
ARG BASE_IMAGE
2+
FROM ${BASE_IMAGE}
3+
4+
ENV ANDROID_HOME=/opt/android-sdk
5+
ENV PATH="${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${ANDROID_HOME}/emulator:${PATH}"
6+
7+
# Install dependencies for Android emulator
8+
RUN apt-get update && apt-get install -y -q --no-install-recommends \
9+
libpulse0 \
10+
libgl1 \
11+
libnss3 \
12+
libxcomposite1 \
13+
libxcursor1 \
14+
libxi6 \
15+
libxtst6 \
16+
&& rm -rf /var/lib/apt/lists/*
17+
18+
# Install Android command-line tools
19+
RUN mkdir -p ${ANDROID_HOME}/cmdline-tools \
20+
&& cd ${ANDROID_HOME}/cmdline-tools \
21+
&& curl -sSfL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o cmdline-tools.zip \
22+
&& unzip cmdline-tools.zip \
23+
&& mv cmdline-tools latest \
24+
&& rm cmdline-tools.zip
25+
26+
# Accept Android SDK licenses
27+
RUN yes | sdkmanager --licenses
28+
29+
# Install platform tools (adb), emulator, and system image
30+
RUN sdkmanager \
31+
"platform-tools" \
32+
"emulator" \
33+
"platforms;android-34" \
34+
"system-images;android-34;google_apis;x86_64"
35+
36+
# Create AVD (Android Virtual Device)
37+
RUN echo no | avdmanager create avd \
38+
-n test_device \
39+
-k "system-images;android-34;google_apis;x86_64" \
40+
-d pixel_7

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"test:e2e:init": "yarn build && yarn build:apps && yarn playwright install chromium --with-deps",
3232
"test:e2e": "playwright test --config test/e2e/playwright.local.config.ts --project chromium",
3333
"test:e2e:bs": "node --env-file-if-exists=.env ./scripts/test/bs-wrapper.ts playwright test --config test/e2e/playwright.bs.config.ts",
34+
"test:e2e:android": "ANDROID_E2E=true playwright test --config test/e2e/android/playwright.android.config.ts",
3435
"test:e2e:ci": "yarn test:e2e:init && yarn test:e2e",
3536
"test:e2e:ci:bs": "yarn build && yarn build:apps && yarn test:e2e:bs",
3637
"test:compat:tsc": "node scripts/check-typescript-compatibility.ts",
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
EMULATOR_NAME="test_device"
5+
BOOT_TIMEOUT=300
6+
DEV_SERVER_PORT=8080
7+
PORT_RANGE_START=9200
8+
PORT_RANGE_END=9400
9+
10+
echo "Starting Android emulator: ${EMULATOR_NAME}"
11+
emulator -avd "${EMULATOR_NAME}" \
12+
-no-window \
13+
-no-audio \
14+
-no-snapshot \
15+
-no-boot-anim \
16+
-gpu auto &
17+
18+
EMULATOR_PID=$!
19+
20+
echo "Waiting for emulator to boot (timeout: ${BOOT_TIMEOUT}s)..."
21+
SECONDS=0
22+
while [ $SECONDS -lt $BOOT_TIMEOUT ]; do
23+
BOOT_COMPLETED=$(adb shell getprop sys.boot_completed 2>/dev/null || echo "")
24+
if [ "$BOOT_COMPLETED" = "1" ]; then
25+
echo "Emulator booted successfully in ${SECONDS}s"
26+
break
27+
fi
28+
sleep 2
29+
done
30+
31+
if [ "$BOOT_COMPLETED" != "1" ]; then
32+
echo "ERROR: Emulator failed to boot within ${BOOT_TIMEOUT}s"
33+
kill $EMULATOR_PID 2>/dev/null || true
34+
exit 1
35+
fi
36+
37+
echo "Setting up adb reverse port forwarding..."
38+
39+
adb reverse tcp:${DEV_SERVER_PORT} tcp:${DEV_SERVER_PORT}
40+
echo " Forwarded port ${DEV_SERVER_PORT} (dev server)"
41+
42+
for port in $(seq ${PORT_RANGE_START} ${PORT_RANGE_END}); do
43+
adb reverse tcp:${port} tcp:${port}
44+
done
45+
echo " Forwarded ports ${PORT_RANGE_START}-${PORT_RANGE_END} (test servers)"
46+
47+
echo "Emulator is ready"

test/e2e/android/androidFixture.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { BrowserContext } from '@playwright/test'
2+
import { test as base } from '@playwright/test'
3+
import type { AndroidDevice } from 'playwright'
4+
import { _android } from 'playwright'
5+
6+
const DEVICE_CONNECTION_TIMEOUT = 30_000
7+
const DEVICE_CONNECTION_RETRY_INTERVAL = 2_000
8+
9+
let cachedDevice: AndroidDevice | undefined
10+
let cachedContext: BrowserContext | undefined
11+
12+
async function getOrCreateContext(): Promise<{ device: AndroidDevice; context: BrowserContext }> {
13+
if (cachedDevice && cachedContext) {
14+
try {
15+
// Verify the context is still alive by attempting a simple operation
16+
await cachedContext.pages()
17+
return { device: cachedDevice, context: cachedContext }
18+
} catch {
19+
// Context is stale, recreate
20+
cachedContext = undefined
21+
}
22+
}
23+
24+
if (!cachedDevice) {
25+
cachedDevice = await connectDevice()
26+
}
27+
28+
// Use Chromium (org.chromium.chrome) instead of the outdated system Chrome (v113)
29+
cachedContext = await cachedDevice.launchBrowser({ pkg: 'org.chromium.chrome' })
30+
return { device: cachedDevice, context: cachedContext }
31+
}
32+
33+
export const test = base.extend<{ context: BrowserContext }>({
34+
// eslint-disable-next-line no-empty-pattern
35+
context: async ({}, use) => {
36+
const { context } = await getOrCreateContext()
37+
await use(context)
38+
},
39+
page: async ({ context }, use) => {
40+
const page = await context.newPage()
41+
await use(page)
42+
// Clean up service workers before closing the page to prevent stale SW state
43+
// from interfering with subsequent tests in the shared browser context
44+
try {
45+
await page.evaluate(async () => {
46+
const regs = await navigator.serviceWorker.getRegistrations()
47+
await Promise.all(regs.map((r) => r.unregister()))
48+
})
49+
} catch {
50+
// Page might already be in a bad state, ignore cleanup errors
51+
}
52+
await page.close()
53+
},
54+
})
55+
56+
async function connectDevice() {
57+
const startTime = Date.now()
58+
59+
while (Date.now() - startTime < DEVICE_CONNECTION_TIMEOUT) {
60+
const devices = await _android.devices()
61+
if (devices.length > 0) {
62+
return devices[0]
63+
}
64+
await new Promise((resolve) => setTimeout(resolve, DEVICE_CONNECTION_RETRY_INTERVAL))
65+
}
66+
67+
throw new Error(`No Android device found within ${DEVICE_CONNECTION_TIMEOUT / 1000}s`)
68+
}
69+
70+
export { expect } from '@playwright/test'

test/e2e/android/globalSetup.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { execSync, spawn } from 'child_process'
2+
import * as fs from 'node:fs'
3+
import * as path from 'node:path'
4+
import * as os from 'node:os'
5+
6+
const BOOT_TIMEOUT_MS = 300_000
7+
const BOOT_POLL_INTERVAL_MS = 2_000
8+
9+
const DEV_SERVER_PORT = 8080
10+
// Port range matching httpServers.ts
11+
const PORT_RANGE_START = 9200
12+
const PORT_RANGE_END = 9400
13+
14+
// eslint-disable-next-line import/no-default-export
15+
export default async function globalSetup() {
16+
console.log('Starting Android emulator...')
17+
18+
const emulatorProcess = spawn(
19+
'emulator',
20+
['-avd', 'test_device', '-no-window', '-no-audio', '-no-snapshot', '-no-boot-anim', '-gpu', 'auto'],
21+
{
22+
stdio: ['ignore', 'ignore', 'ignore'],
23+
detached: true,
24+
}
25+
)
26+
emulatorProcess.unref()
27+
28+
await waitForBoot()
29+
setupAdbReverse()
30+
await installChromium()
31+
32+
process.env.ANDROID_E2E = 'true'
33+
}
34+
35+
async function waitForBoot() {
36+
const startTime = Date.now()
37+
38+
while (Date.now() - startTime < BOOT_TIMEOUT_MS) {
39+
try {
40+
const result = execSync('adb shell getprop sys.boot_completed', { encoding: 'utf-8', timeout: 5_000 }).trim()
41+
if (result === '1') {
42+
console.log(`Emulator booted in ${Math.round((Date.now() - startTime) / 1000)}s`)
43+
return
44+
}
45+
} catch {
46+
// adb not yet connected, keep polling
47+
}
48+
await new Promise((resolve) => setTimeout(resolve, BOOT_POLL_INTERVAL_MS))
49+
}
50+
51+
throw new Error(`Emulator failed to boot within ${BOOT_TIMEOUT_MS / 1000}s`)
52+
}
53+
54+
function setupAdbReverse() {
55+
console.log('Setting up adb reverse port forwarding...')
56+
57+
// Forward dev server port
58+
execSync(`adb reverse tcp:${DEV_SERVER_PORT} tcp:${DEV_SERVER_PORT}`)
59+
60+
// Forward test server port range
61+
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
62+
execSync(`adb reverse tcp:${port} tcp:${port}`)
63+
}
64+
65+
console.log(`Forwarded ports: ${DEV_SERVER_PORT}, ${PORT_RANGE_START}-${PORT_RANGE_END}`)
66+
}
67+
68+
async function installChromium() {
69+
// The system Chrome on the emulator is v113, which is too old for many web APIs
70+
// (LoAf, modern resource timing, etc.). Install a recent Chromium from snapshots.
71+
console.log('Installing Chromium on emulator...')
72+
73+
try {
74+
// Get the latest Chromium snapshot revision for Android ARM64
75+
const revisionResponse = await fetch(
76+
'https://storage.googleapis.com/chromium-browser-snapshots/Android_Arm64/LAST_CHANGE'
77+
)
78+
const revision = (await revisionResponse.text()).trim()
79+
console.log(`Latest Chromium ARM64 snapshot revision: ${revision}`)
80+
81+
// Download the Chromium Android ARM64 build
82+
const downloadUrl = `https://storage.googleapis.com/chromium-browser-snapshots/Android_Arm64/${revision}/chrome-android.zip`
83+
console.log(`Downloading from ${downloadUrl}`)
84+
85+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chromium-android-'))
86+
const zipPath = path.join(tmpDir, 'chrome-android.zip')
87+
88+
const downloadResponse = await fetch(downloadUrl)
89+
if (!downloadResponse.ok) {
90+
console.log(`Download failed: ${downloadResponse.status}`)
91+
return
92+
}
93+
94+
const buffer = Buffer.from(await downloadResponse.arrayBuffer())
95+
fs.writeFileSync(zipPath, buffer)
96+
console.log(`Downloaded ${(buffer.length / 1024 / 1024).toFixed(1)}MB`)
97+
98+
// Extract the zip
99+
execSync(`unzip -o "${zipPath}" -d "${tmpDir}"`, { timeout: 60_000 })
100+
101+
// Find APKs
102+
const apks = execSync(`find "${tmpDir}" -name "*.apk"`, { encoding: 'utf-8', timeout: 5_000 })
103+
.trim()
104+
.split('\n')
105+
.filter(Boolean)
106+
107+
console.log(`Found APKs: ${apks.map((a) => path.basename(a)).join(', ')}`)
108+
109+
// Install ChromePublic.apk (the main Chromium browser)
110+
const chromeApk = apks.find((a) => path.basename(a) === 'ChromePublic.apk')
111+
if (!chromeApk) {
112+
console.log('ChromePublic.apk not found in download')
113+
return
114+
}
115+
116+
const result = execSync(`adb install -r -d "${chromeApk}"`, { encoding: 'utf-8', timeout: 120_000 })
117+
console.log(`Install result: ${result.trim()}`)
118+
119+
// Log the installed Chromium version
120+
const versionInfo = execSync('adb shell dumpsys package org.chromium.chrome | grep versionName', {
121+
encoding: 'utf-8',
122+
timeout: 5_000,
123+
}).trim()
124+
console.log(`Chromium installed: ${versionInfo}`)
125+
126+
// Cleanup
127+
fs.rmSync(tmpDir, { recursive: true, force: true })
128+
} catch (error) {
129+
console.log('Chromium install failed (non-fatal):', error)
130+
}
131+
}

test/e2e/android/globalTeardown.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { execSync } from 'child_process'
2+
3+
// eslint-disable-next-line import/no-default-export
4+
export default function globalTeardown() {
5+
console.log('Shutting down Android emulator...')
6+
try {
7+
execSync('adb emu kill', { timeout: 10_000 })
8+
console.log('Emulator stopped')
9+
} catch {
10+
console.warn('Failed to stop emulator (it may have already exited)')
11+
}
12+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import path from 'path'
2+
import type { ReporterDescription } from '@playwright/test'
3+
import { defineConfig } from '@playwright/test'
4+
import { getTestReportDirectory } from '../../envUtils'
5+
6+
const testReportDirectory = getTestReportDirectory()
7+
const reporters: ReporterDescription[] = [['line']]
8+
9+
if (testReportDirectory) {
10+
reporters.push(['junit', { outputFile: path.join(process.cwd(), testReportDirectory, 'results.xml') }])
11+
} else {
12+
reporters.push(['html'])
13+
}
14+
15+
// eslint-disable-next-line import/no-default-export
16+
export default defineConfig({
17+
testDir: '../scenario',
18+
testMatch: ['**/*.scenario.ts'],
19+
testIgnore: ['**/android/**'],
20+
tsconfig: '../tsconfig.json',
21+
globalSetup: './globalSetup.ts',
22+
globalTeardown: './globalTeardown.ts',
23+
fullyParallel: false,
24+
workers: 1,
25+
retries: 1,
26+
timeout: 60_000,
27+
reporter: reporters,
28+
use: {
29+
trace: 'retain-on-failure',
30+
},
31+
})

0 commit comments

Comments
 (0)