Files
orca/mobile/scripts/hosted-android-emulator-session.mjs
T

282 lines
8.9 KiB
JavaScript

import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { pairingPublicKeyFromUrl } from './emulator-pairing-public-key.mjs'
import { hostedMobileMetroArguments } from './hosted-mobile-e2e-launch.mjs'
import { runAndroidAdb } from './hosted-android-mobile-web-cache.mjs'
import { getMobileExpoExecutablePath } from './mobile-expo-cli.mjs'
const metroStatusMaxBytes = 64 * 1024
const packageName = 'com.stably.orca.mobile'
const activity = `${packageName}/.MainActivity`
export function resolveHostedAndroidAdb(explicitCommand, environment = process.env) {
if (explicitCommand) {
return explicitCommand
}
const sdkRoots = [environment.ANDROID_HOME, environment.ANDROID_SDK_ROOT].filter(Boolean)
const candidates = [
...sdkRoots.map((root) => path.join(root, 'platform-tools', adbName())),
...(process.platform === 'darwin'
? ['/opt/homebrew/share/android-commandlinetools/platform-tools/adb']
: [])
]
return candidates.find((candidate) => existsSync(candidate)) ?? 'adb'
}
export async function buildHostedAndroidDebugApp({ adb, androidDir, environment = process.env }) {
const architecture = await runAndroidAdb(adb, ['shell', 'getprop', 'ro.product.cpu.abi'])
if (!/^[a-zA-Z0-9_-]+$/u.test(architecture)) {
throw new Error(`Android emulator returned an invalid architecture: ${architecture}`)
}
const homebrewJavaHome = '/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home'
const javaHome =
existingPath(environment.JAVA_HOME) ??
(process.platform === 'darwin' && existsSync(homebrewJavaHome) ? homebrewJavaHome : undefined)
const sdkRoot =
existingPath(environment.ANDROID_HOME) ??
existingPath(environment.ANDROID_SDK_ROOT) ??
(path.isAbsolute(adb) ? path.dirname(path.dirname(adb)) : undefined)
await runChild(
path.join(androidDir, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew'),
[':app:assembleDebug', `-PreactNativeArchitectures=${architecture}`],
{
cwd: androidDir,
env: {
...environment,
...(javaHome ? { JAVA_HOME: javaHome } : {}),
...(sdkRoot ? { ANDROID_HOME: sdkRoot, ANDROID_SDK_ROOT: sdkRoot } : {})
},
stdio: 'inherit'
}
)
}
export async function startHostedAndroidMetro({ mobileDir, pairingUrl, port }) {
const selectedPort = port ?? (await findAvailableLoopbackPort())
const expo = getMobileExpoExecutablePath(mobileDir)
if (!expo) {
throw new Error('Mobile Expo CLI is unavailable')
}
const output = { value: '' }
const child = spawn(expo, hostedMobileMetroArguments(selectedPort, true), {
cwd: mobileDir,
env: {
...process.env,
EXPO_NO_TELEMETRY: '1',
EXPO_PUBLIC_ORCA_E2E_MOBILE_WEB_HOST_PUBLIC_KEY: pairingPublicKeyFromUrl(pairingUrl)
},
stdio: ['ignore', 'pipe', 'pipe']
})
captureOutput(child.stdout, output)
captureOutput(child.stderr, output)
try {
await waitForMetro(child, selectedPort, output)
return { child, port: selectedPort, stop: () => stopChild(child) }
} catch (error) {
await stopChild(child)
throw error
}
}
export async function installAndResetHostedAndroidApp(adb, apk) {
await runAndroidAdb(adb, ['install', '-r', '-t', apk], 120_000)
await runAndroidAdb(adb, ['shell', 'pm', 'clear', packageName])
await runAndroidAdb(adb, [
'shell',
'pm',
'grant',
packageName,
'android.permission.POST_NOTIFICATIONS'
]).catch(() => {})
}
export async function launchHostedAndroidDevClient(adb, metroPort, probe) {
const metroUrl = `http://127.0.0.1:${metroPort}`
const devClientUrl = `exp+orca-mobile://expo-development-client/?url=${encodeURIComponent(
metroUrl
)}`
await runAndroidAdb(adb, ['shell', 'am', 'force-stop', packageName])
await runAndroidAdb(
adb,
[
'shell',
'am',
'start',
'-W',
'-n',
activity,
'-a',
'android.intent.action.VIEW',
'-d',
devClientUrl,
'--es',
'ORCA_E2E_MOBILE_WEB_NETWORK_PROBE_PORT',
String(probe.port),
'--es',
'ORCA_E2E_MOBILE_WEB_NETWORK_PROBE_TOKEN',
probe.token
],
60_000
)
}
export function openHostedAndroidUrl(adb, url) {
return runAndroidAdb(
adb,
[
'shell',
'am',
'start',
'-W',
'-n',
activity,
'-a',
'android.intent.action.VIEW',
'-c',
'android.intent.category.BROWSABLE',
'-d',
url
],
60_000
)
}
export async function forwardHostedAndroidInspector(adb, timeoutMs) {
const deadline = Date.now() + timeoutMs
let lastError = 'Android app process is unavailable'
while (Date.now() < deadline) {
try {
const pid = await runAndroidAdb(adb, ['shell', 'pidof', packageName])
if (!/^\d+$/u.test(pid)) {
throw new Error('Android app process is unavailable')
}
const socket = `webview_devtools_remote_${pid}`
const sockets = await runAndroidAdb(adb, ['shell', 'cat', '/proc/net/unix'])
if (!sockets.includes(`@${socket}`)) {
throw new Error('Android WebView inspector socket is unavailable')
}
const portValue = await runAndroidAdb(adb, ['forward', 'tcp:0', `localabstract:${socket}`])
const port = Number.parseInt(portValue, 10)
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error(`Invalid Android inspector port: ${portValue}`)
}
return { pid, port }
} catch (error) {
lastError = error instanceof Error ? error.message : String(error)
await delay(250)
}
}
throw new Error(`Android WebView inspector was unavailable: ${lastError}`)
}
export async function stopHostedAndroidApp(adb) {
await runAndroidAdb(adb, ['shell', 'am', 'force-stop', packageName]).catch(() => {})
}
export function findHostedAndroidBridgeLogFailures(logcat, appPid) {
return logcat.split(/\r?\n/u).filter((line) => {
const fatal = /FATAL EXCEPTION/iu.test(line)
if (fatal && appPid && !line.includes(`(${appPid})`)) {
return false
}
return /FATAL EXCEPTION|Call to function 'ExpoMobileWebShell\.[^']+' has been rejected|mobile_web_shell_view_unavailable|Cannot convert .* to a Kotlin type|ClassCastException.*MobileWebShellView/iu.test(
line
)
})
}
export async function assertHostedAndroidBridgeLogClean(adb) {
const [logcat, appPid] = await Promise.all([
runAndroidAdb(adb, ['logcat', '-d', '-v', 'brief']),
runAndroidAdb(adb, ['shell', 'pidof', packageName])
])
const failures = findHostedAndroidBridgeLogFailures(logcat, appPid)
if (failures.length > 0) {
throw new Error(`Android bridge emitted errors:\n${failures.slice(0, 16).join('\n')}`)
}
}
function adbName() {
return process.platform === 'win32' ? 'adb.exe' : 'adb'
}
function existingPath(value) {
return value && existsSync(value) ? value : undefined
}
function findAvailableLoopbackPort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.unref()
server.once('error', reject)
server.listen({ host: '127.0.0.1', port: 0 }, () => {
const address = server.address()
server.close(() =>
typeof address === 'object' && address
? resolve(address.port)
: reject(new Error('No loopback port'))
)
})
})
}
async function waitForMetro(child, port, output) {
const deadline = Date.now() + 120_000
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`Metro exited before readiness.\n${output.value}`)
}
try {
const response = await fetch(`http://127.0.0.1:${port}/status`, {
signal: AbortSignal.timeout(2_000)
})
const text = (await response.text()).slice(0, metroStatusMaxBytes)
if (response.ok && text.includes('packager-status:running')) {
return
}
} catch {}
await delay(250)
}
throw new Error(`Metro timed out.\n${output.value}`)
}
function captureOutput(stream, output) {
stream?.on('data', (chunk) => {
output.value = (output.value + String(chunk)).slice(-32 * 1024)
})
}
function runChild(command, args, options) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, options)
child.once('error', reject)
child.once('exit', (code, signal) => {
if (signal) {
reject(new Error(`${path.basename(command)} was terminated by ${signal}`))
} else if (code === 0) {
resolve()
} else {
reject(new Error(`${path.basename(command)} exited with code ${code}`))
}
})
})
}
async function stopChild(child) {
if (!child || child.exitCode !== null) {
return
}
child.kill('SIGTERM')
await Promise.race([
new Promise((resolve) => child.once('exit', resolve)),
delay(5_000).then(() => child.exitCode === null && child.kill('SIGKILL'))
])
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}