Files
orca/mobile/scripts/run-hosted-android-hardware-back-e2e.mjs

239 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
import { execFile } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
import { resolveEmulatorOrcaCli } from './emulator-orca-cli-selection.mjs'
import {
assertHostedAndroidBridgeLogClean,
buildHostedAndroidDebugApp,
forwardHostedAndroidInspector,
installAndResetHostedAndroidApp,
launchHostedAndroidDevClient,
openHostedAndroidUrl,
resolveHostedAndroidAdb,
startHostedAndroidMetro,
stopHostedAndroidApp,
waitForHostedAndroidReactReady
} from './hosted-android-emulator-session.mjs'
import { verifyHostedAndroidHardwareBackJourney } from './hosted-android-hardware-back-journey.mjs'
import { runAndroidAdb } from './hosted-android-mobile-web-cache.mjs'
import { pairHostedAndroidApp } from './hosted-android-pairing.mjs'
import {
activateHostedAndroidWorkspaceControl,
prepareHostedAndroidWorkspaceInput
} from './hosted-android-workspace-activation.mjs'
import { HOSTED_MOBILE_APP_ROUTE_URL } from './hosted-mobile-e2e-launch.mjs'
import { waitForVisibleHostedWebView } from './hosted-webview-cdp-session.mjs'
import { resolveHostedWebViewRuntimeDirectory } from './hosted-webview-runtime-directory.mjs'
import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs'
import { startHostedWebViewSecurityProbe } from './hosted-ios-webview-security-probe.mjs'
import {
registerWorktreeForPairingRuntime,
startHeadlessPairingRuntime
} from './start-emulator-pairing-runtime.mjs'
const execFileAsync = promisify(execFile)
const worktree = path.resolve(import.meta.dirname, '../..')
const mobileDir = path.join(worktree, 'mobile')
const androidDir = path.join(mobileDir, 'android')
const defaultApk = path.join(androidDir, 'app/build/outputs/apk/debug/app-debug.apk')
const options = parseOptions(process.argv.slice(2))
const adb = resolveHostedAndroidAdb(options.adb)
const runtimeDirectory = resolveHostedWebViewRuntimeDirectory({
worktree,
override: process.env.ORCA_E2E_MOBILE_WEBVIEW_RUN_DIRECTORY
})
const orcaCli = resolveEmulatorOrcaCli({
explicitCommand: process.env.ORCA_CLI,
managedCommand: process.env.ORCA_CLI_COMMAND,
devRepoRoot: process.env.ORCA_DEV_REPO_ROOT,
worktree,
cwd: worktree
}).command
async function main() {
let runtime
let metro
let probe
let inspector
const reversePorts = new Set()
try {
await stage('Android emulator', () => runAndroidAdb(adb, ['get-state']))
await stage('Android log reset', () => runAndroidAdb(adb, ['logcat', '-c']))
if (!options.skipNativeBuild) {
await stage('Android debug app build', () => buildHostedAndroidDebugApp({ adb, androidDir }))
}
probe = await stage('network isolation sentinel', startHostedWebViewSecurityProbe)
runtime = await stage('temporary paired desktop runtime', () =>
startHeadlessPairingRuntime({
enabled: true,
orcaCli,
cwd: worktree,
runDirectory: path.join(runtimeDirectory, 'paired-host'),
lanIpCandidates: () => ['127.0.0.1'],
logStep: () => {},
logSuccess: () => {}
})
)
await stage('test workspace registration', () =>
registerWorktreeForPairingRuntime(runtime, worktree, {
orca: runOrca,
logStep: () => {},
logSuccess: () => {}
})
)
metro = await stage('Metro', () =>
startHostedAndroidMetro({ mobileDir, pairingUrl: runtime.pairingUrl })
)
for (const port of [runtime.port, metro.port, probe.port]) {
await runAndroidAdb(adb, ['reverse', `tcp:${port}`, `tcp:${port}`])
reversePorts.add(port)
}
await stage('exact Android app install', () =>
installAndResetHostedAndroidApp(adb, options.apk)
)
await stage('development client launch', () =>
launchHostedAndroidDevClient(adb, metro.port, probe)
)
await stage('React runtime', () => waitForHostedAndroidReactReady(adb, options.timeoutMs))
await stage('native pairing', () =>
pairHostedAndroidApp({ adb, pairingUrl: runtime.pairingUrl, timeoutMs: options.timeoutMs })
)
await stage('native hybrid route handoff', () =>
openHostedAndroidUrl(adb, HOSTED_MOBILE_APP_ROUTE_URL)
)
inspector = await stage('Android WebView inspector', () =>
forwardHostedAndroidInspector(adb, options.timeoutMs)
)
const discoveryUrl = `http://127.0.0.1:${inspector.port}`
const workspaceName = path.basename(worktree)
let workspaceDocument = await stage('hosted workspace data', () =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: workspaceName.toLocaleUpperCase(),
timeoutMs: options.timeoutMs
})
)
const sessionDocument = await stage('hosted Session route', async () => {
await prepareHostedAndroidWorkspaceInput({ adb })
await activateHostedWorkspaceRow(
workspaceDocument,
workspaceName,
(document, target) => activateHostedAndroidWorkspaceControl({ adb }, document, target),
options.timeoutMs,
async () => {
workspaceDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: workspaceName.toLocaleUpperCase(),
timeoutMs: options.timeoutMs
})
return workspaceDocument
}
)
return waitForVisibleHostedWebView({
discoveryUrl,
expectedText: '1 tab',
expectedHrefIncludes: '/session/',
requireInteractiveControls: false,
timeoutMs: options.timeoutMs
})
})
const evidence = await stage('real Android hardware Back journey', () =>
verifyHostedAndroidHardwareBackJourney({
adb,
discoveryUrl,
emulator: { adb },
sessionDocument,
timeoutMs: options.timeoutMs,
workspaceMarker: workspaceName.toLocaleUpperCase()
})
)
await stage('Android bridge log audit', () => assertHostedAndroidBridgeLogClean(adb))
console.log(
JSON.stringify(
{
ok: true,
device: await runAndroidAdb(adb, ['shell', 'getprop', 'ro.product.model']),
workspace: workspaceName,
...evidence
},
null,
2
)
)
} finally {
await stopHostedAndroidApp(adb)
if (inspector) {
await runAndroidAdb(adb, ['forward', '--remove', `tcp:${inspector.port}`]).catch(() => {})
}
for (const port of reversePorts) {
await runAndroidAdb(adb, ['reverse', '--remove', `tcp:${port}`]).catch(() => {})
}
await metro?.stop()
await runtime?.stop({ shutdownDaemon: true })
await probe?.stop()
}
}
async function runOrca(args, runOptions) {
const result = await execFileAsync(orcaCli, args, {
cwd: runOptions.cwd,
env: runOptions.env,
encoding: 'utf8',
timeout: runOptions.timeout
})
return { stdout: result.stdout.trim(), stderr: result.stderr.trim() }
}
async function stage(label, run) {
process.stderr.write(`[android-back-e2e] ${label}...\n`)
try {
const result = await run()
process.stderr.write(`[android-back-e2e] ${label}: ok\n`)
return result
} catch (error) {
throw new Error(`${label} failed: ${error instanceof Error ? error.message : String(error)}`, {
cause: error
})
}
}
function parseOptions(args) {
const result = { adb: null, apk: defaultApk, skipNativeBuild: false, timeoutMs: 90_000 }
for (let index = 0; index < args.length; index += 1) {
const option = args[index]
if (option === '--') {
continue
}
if (option === '--adb') {
result.adb = requireValue(args, ++index, option)
} else if (option === '--apk') {
result.apk = path.resolve(requireValue(args, ++index, option))
} else if (option === '--skip-native-build') {
result.skipNativeBuild = true
} else if (option === '--timeout-ms') {
result.timeoutMs = Number.parseInt(requireValue(args, ++index, option), 10)
} else {
throw new Error(`Unknown option: ${option}`)
}
}
if (!Number.isInteger(result.timeoutMs) || result.timeoutMs < 1_000) {
throw new Error('--timeout-ms must be an integer of at least 1000')
}
return result
}
function requireValue(args, index, option) {
if (!args[index]) {
throw new Error(`${option} requires a value`)
}
return args[index]
}
main().catch((error) => {
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error))
process.exitCode = 1
})