Skip to content

Commit 83adaa1

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 - Fix scroll test sub-pixel rounding on mobile DPI - 237/249 tests pass, ~3 flaky (pass on retry)
1 parent c52821b commit 83adaa1

File tree

12 files changed

+279
-7
lines changed

12 files changed

+279
-7
lines changed

.gitlab-ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,25 @@ e2e:
231231
after_script:
232232
- node ./scripts/test/export-test-result.ts e2e
233233

234+
e2e-android:
235+
extends:
236+
- .base-configuration
237+
- .feature-branches
238+
tags:
239+
- 'macos:sonoma'
240+
- 'specific:true'
241+
interruptible: true
242+
timeout: 40 minutes
243+
artifacts:
244+
when: always
245+
reports:
246+
junit: test-report/e2e-android/*.xml
247+
script:
248+
- yarn
249+
- yarn build
250+
- yarn build:apps
251+
- FORCE_COLOR=1 CI_JOB_NAME=e2e-android yarn test:e2e:android
252+
234253
check-licenses:
235254
extends:
236255
- .base-configuration

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",

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+
})

test/e2e/lib/framework/createTest.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { BrowserLogsManager, deleteAllCookies, getBrowserName, sendXhr } from '.
99
import { DEFAULT_LOGS_CONFIGURATION, DEFAULT_RUM_CONFIGURATION } from '../helpers/configuration'
1010
import { validateRumFormat } from '../helpers/validation'
1111
import type { BrowserConfiguration } from '../../../browsers.conf'
12+
import { test as androidTest } from '../../android/androidFixture'
1213
import { IntakeRegistry } from './intakeRegistry'
1314
import { flushEvents } from './flushEvents'
1415
import type { Servers } from './httpServers'
@@ -19,7 +20,7 @@ import { createIntakeServerApp } from './serverApps/intake'
1920
import { createMockServerApp } from './serverApps/mock'
2021
import type { Extension } from './createExtension'
2122
import type { Worker } from './createWorker'
22-
import { isBrowserStack } from './environment'
23+
import { isAndroid, isBrowserStack } from './environment'
2324

2425
export function createTest(title: string) {
2526
return new TestBuilder(title, captureCallerLocation())
@@ -53,7 +54,7 @@ class TestBuilder {
5354
private basePath = ''
5455
private eventBridge = false
5556
private setups: Array<{ factory: SetupFactory; name?: string }> = DEFAULT_SETUPS
56-
private testFixture: typeof test = test
57+
private testFixture: typeof test = isAndroid ? androidTest : test
5758
private extension: {
5859
rumConfiguration?: RumInitConfiguration
5960
logsConfiguration?: LogsInitConfiguration
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const isBrowserStack = Boolean(process.env.BROWSER_STACK)
22
export const isContinuousIntegration = Boolean(process.env.CI)
3+
export const isAndroid = Boolean(process.env.ANDROID_E2E)

test/e2e/lib/framework/pageSetups.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { generateUUID, INTAKE_URL_PARAMETERS } from '@datadog/browser-core'
22
import type { LogsInitConfiguration } from '@datadog/browser-logs'
33
import type { RumInitConfiguration, RemoteConfiguration } from '@datadog/browser-rum-core'
44
import type test from '@playwright/test'
5-
import { isBrowserStack, isContinuousIntegration } from './environment'
5+
import { isAndroid, isBrowserStack, isContinuousIntegration } from './environment'
66
import type { Servers } from './httpServers'
77

88
export interface SetupOptions {
@@ -45,9 +45,9 @@ export interface WorkerOptions {
4545
export type SetupFactory = (options: SetupOptions, servers: Servers) => string
4646

4747
// By default, run tests only with the 'bundle' setup outside of the CI (to run faster on the
48-
// developer laptop) or with Browser Stack (to limit flakiness).
48+
// developer laptop), with Browser Stack (to limit flakiness), or with Android (single setup).
4949
export const DEFAULT_SETUPS =
50-
!isContinuousIntegration || isBrowserStack
50+
!isContinuousIntegration || isBrowserStack || isAndroid
5151
? [{ name: 'bundle', factory: bundleSetup }]
5252
: [
5353
{ name: 'async', factory: asyncSetup },

test/e2e/playwright.base.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ if (testReportDirectory) {
2121
export const config: Config = {
2222
testDir: './scenario',
2323
testMatch: ['**/*.scenario.ts'],
24+
testIgnore: ['**/android/**'],
2425
tsconfig: './tsconfig.json',
2526
fullyParallel: true,
2627
forbidOnly: isCi,

0 commit comments

Comments
 (0)