Files
elizaos--eliza/packages/app/scripts/android-e2e.mjs
T
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

667 lines
21 KiB
JavaScript

#!/usr/bin/env node
// Android end-to-end orchestrator. Single entrypoint that brings the device into
// a known-good state and runs the real-backend e2e suites, surfacing every
// failure loudly (non-zero exit). Steps:
// 1. Ensure an emulator/device is attached (boots an AVD with adequate RAM if
// none is running) and, for emulators, SELinux is permissive so the
// embedded on-device agent can run.
// 2. Ensure the WebView-debuggable debug APK is installed.
// 3. Local route: bring up the on-device agent + smallest model and assert a
// real chat round-trip (mobile-local-chat-smoke). Loud fail if the local
// runtime or model does not come up.
// 4. Playwright route coverage: drive the real WebView across every route.
// 5. (optional) Cloud route: real Hetzner provisioning probe.
//
// Flags: --serial <s> --skip-local-chat --skip-route-coverage --cloud
// --launcher-loop (≥200-action seeded launcher gesture loop; opt-in)
// --force-build/--build (build the APK first) --skip-build
// --no-emulator-boot --no-wait
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
captureAndroidLogcat,
captureAndroidScreenshot,
startAndroidScreenRecord,
} from "./lib/android-capture.mjs";
import {
androidApkNeedsBuild,
androidDistNeedsBuild,
androidInstallDecision,
ensureEmulatorBooted,
ensureEmulatorPermissive,
installApk,
listDevices,
readFreshAndroidRendererStamp,
readInstalledRendererStamp,
readRendererStampFromApk,
resolveAdb,
resolveApk,
resolveSerial,
verifyInstalledApkMatches,
} from "./lib/android-device.mjs";
import {
captureFailureForensics,
createDeviceE2eBundle,
finalizeDeviceE2eBundle,
finishBundleStep,
formatFailureForensicsBlock,
parseOutputDirArg,
recordBundleArtifact,
runBundledCommand,
setBundleBuild,
setBundleDevice,
startBundleStep,
} from "./lib/device-e2e-bundle.mjs";
import { acquireDeviceLease, isDeviceLeased } from "./lib/device-lease.mjs";
const appDir = path.resolve(fileURLToPath(import.meta.url), "..", "..");
const elizaRoot = path.resolve(appDir, "..", "..");
const has = (flag) => process.argv.includes(flag);
const val = (flag, fb) => {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : fb;
};
const log = (m) => console.log(`[android-e2e] ${m}`);
// Smallest local tier; same id the smoke + catalog use.
const SMOKE_MODEL = {
id: "eliza-1-2b",
file: "eliza-1-e2b-32k.gguf",
sizeBytes: 1_270_808_512,
url: "https://huggingface.co/elizaos/eliza-1/resolve/main/bundles/e2b/text/eliza-1-e2b-32k.gguf?download=true",
cacheDir: path.join(
process.env.HOME ?? process.env.USERPROFILE ?? ".",
".cache/eliza/android-smoke-models",
),
};
// On-device voice (STT/TTS) GGUFs the voice-selftest needs alongside the chat
// model. Unlike the chat smoke model these are not auto-downloaded by the
// harness; they live in the host's local-inference cache. Defaults match where
// the desktop runtime stores them; override per env for CI.
const VOICE_MODELS = (() => {
const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
const asrDir =
process.env.ELIZA_ANDROID_ASR_MODEL_DIR ??
path.join(home, ".cache/eliza/asr-model");
const ttsDir =
process.env.ELIZA_ANDROID_TTS_MODEL_DIR ??
path.join(home, ".local/state/eliza/local-inference/models/omnivoice");
const dev = "/data/data/ai.elizaos.app/files/.eliza/local-inference/models";
return [
{
host: path.join(asrDir, "eliza-1-asr.gguf"),
dev: `${dev}/asr/eliza-1-asr.gguf`,
},
{
host: path.join(asrDir, "eliza-1-asr-mmproj.gguf"),
dev: `${dev}/asr/eliza-1-asr-mmproj.gguf`,
},
{
host: path.join(ttsDir, "omnivoice-base-q4_k_m.gguf"),
dev: `${dev}/tts/omnivoice-base-q4_k_m.gguf`,
},
{
host: path.join(ttsDir, "omnivoice-tokenizer-q4_k_m.gguf"),
dev: `${dev}/tts/omnivoice-tokenizer-q4_k_m.gguf`,
},
];
})();
// Stage the ASR/TTS GGUFs the voice round-trip needs. Idempotent (skips files
// already present at the right size, so it no-ops on a real device that already
// carries them), and never the failure point — if the host cache lacks them we
// log and move on so voice-selftest fails loudly with the real "ASR assets
// missing" rather than a push error. Emulators are root (ensureEmulatorPermissive
// ran), so the push into the app data dir succeeds.
function stageVoiceModels(adb, serial) {
const toStage = VOICE_MODELS.filter((m) => {
if (!fs.existsSync(m.host)) return false;
const probe = spawnSync(
adb,
["-s", serial, "shell", "stat", "-c", "%s", m.dev],
{
encoding: "utf8",
},
);
return (probe.stdout ?? "").trim() !== String(fs.statSync(m.host).size);
});
const missingHost = VOICE_MODELS.filter((m) => !fs.existsSync(m.host));
if (missingHost.length > 0) {
log(
`voice models: ${missingHost.length}/${VOICE_MODELS.length} absent from the host cache ` +
`(${missingHost.map((m) => path.basename(m.host)).join(", ")}) — skipping voice-model staging; ` +
`voice-selftest will report the real on-device gap. Set ELIZA_ANDROID_ASR_MODEL_DIR / ELIZA_ANDROID_TTS_MODEL_DIR.`,
);
return;
}
if (toStage.length === 0) {
log("voice models already staged on device.");
return;
}
const devModels =
"/data/data/ai.elizaos.app/files/.eliza/local-inference/models";
spawnSync(
adb,
[
"-s",
serial,
"shell",
"mkdir",
"-p",
`${devModels}/asr`,
`${devModels}/tts`,
],
{
stdio: "ignore",
},
);
for (const m of toStage) {
log(`staging voice model ${path.basename(m.host)}…`);
const res = spawnSync(adb, ["-s", serial, "push", m.host, m.dev], {
stdio: "inherit",
});
if (res.status !== 0) {
throw new Error(`adb push ${m.host} exited with code ${res.status}`);
}
}
spawnSync(
adb,
[
"-s",
serial,
"shell",
"chmod",
"-R",
"755",
`${devModels}/asr`,
`${devModels}/tts`,
],
{
stdio: "ignore",
},
);
log(`voice models staged for on-device ASR/TTS (${toStage.length} pushed).`);
}
function run(bundle, name, cmd, args, env = {}) {
return runBundledCommand(bundle, name, cmd, args, {
cwd: appDir,
env,
onFailure: (step, error) => captureAndroidFailure(bundle, step, error),
});
}
let activeAndroidContext = { adb: null, serial: null };
function captureAndroidFailure(bundle, step, error) {
const { adb, serial } = activeAndroidContext;
return captureFailureForensics(
bundle,
step,
({ failureDir }) => {
const files = [];
const causePath = path.join(failureDir, "failure-cause.txt");
fs.writeFileSync(causePath, `${error?.message ?? error}\n`);
files.push(causePath);
if (adb && serial) {
files.push(
captureAndroidScreenshot({
adb,
serial,
artifactDir: failureDir,
filename: "screen.png",
log,
}),
);
files.push(
captureAndroidLogcat({
adb,
serial,
artifactDir: failureDir,
filename: "logcat.txt",
lines: 2000,
log,
}),
);
}
return files;
},
error,
);
}
function failAndroidStep(bundle, step, error) {
captureAndroidFailure(bundle, step, error);
finishBundleStep(bundle, step, "failed", error);
}
function currentHeadCommit() {
try {
return execFileSync("git", ["rev-parse", "HEAD"], {
cwd: elizaRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return null;
}
}
function buildAndroidApk(bundle) {
log("building WebView-debuggable APK…");
run(bundle, "build Android APK", "bun", ["run", "build:android"], {
ELIZA_MOBILE_REPO_ROOT: elizaRoot,
ELIZA_WEBVIEW_DEBUG: "1",
ELIZA_BUN_RISCV64_OPTIONAL: "1",
});
}
function stampLabel(stamp) {
if (!stamp) return "missing";
const buildId = String(stamp.buildId ?? "unknown").slice(0, 12);
const commit = stamp.commit
? ` commit=${String(stamp.commit).slice(0, 12)}`
: "";
return `${buildId}${commit}`;
}
function readApkRendererStamp(apk) {
try {
return readRendererStampFromApk(apk);
} catch (error) {
log(
`APK renderer stamp unavailable from ${apk}: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}
function ensureFreshApkInstalled(bundle, adb, serial) {
const forceBuild = has("--force-build") || has("--build");
const skipBuild = has("--skip-build");
const headCommit = currentHeadCommit();
let freshStamp = readFreshAndroidRendererStamp();
const buildDecision = androidDistNeedsBuild({ freshStamp, headCommit });
if (forceBuild) {
buildAndroidApk(bundle);
} else if (buildDecision.build) {
if (skipBuild) {
throw new Error(
`--skip-build requested, but Android dist is not usable: ${buildDecision.reason}`,
);
}
log(`${buildDecision.reason} — rebuilding before install check.`);
buildAndroidApk(bundle);
} else {
log(`fresh dist renderer stamp: ${stampLabel(freshStamp)}`);
}
freshStamp = readFreshAndroidRendererStamp();
if (!freshStamp) {
throw new Error(
"Android build did not produce dist/eliza-renderer-build.json; refusing to install an unverifiable APK.",
);
}
let apk = resolveApk(process.env.ELIZA_ANDROID_APK);
let apkStamp = readApkRendererStamp(apk);
let apkDecision = androidApkNeedsBuild({ freshStamp, apkStamp });
if (apkDecision.build) {
if (skipBuild) {
throw new Error(
`--skip-build requested, but Android APK is not usable: ${apkDecision.reason}`,
);
}
if (!forceBuild) {
log(`${apkDecision.reason} — rebuilding APK before install.`);
buildAndroidApk(bundle);
freshStamp = readFreshAndroidRendererStamp();
if (!freshStamp) {
throw new Error(
"Android build did not produce dist/eliza-renderer-build.json; refusing to install an unverifiable APK.",
);
}
apk = resolveApk(process.env.ELIZA_ANDROID_APK);
apkStamp = readApkRendererStamp(apk);
apkDecision = androidApkNeedsBuild({ freshStamp, apkStamp });
}
}
if (apkDecision.build) {
throw new Error(
`Android build did not produce an APK with the fresh renderer stamp: ${apkDecision.reason}`,
);
}
log(`${apkDecision.reason} in ${apk}`);
const installedStamp = readInstalledRendererStamp(adb, serial, { log });
const installDecision = forceBuild
? { install: true, reason: "--force-build/--build requested" }
: androidInstallDecision({ freshStamp, installedStamp });
if (installDecision.install) {
log(`${installDecision.reason} — installing ${apk}`);
const step = startBundleStep(bundle, "install Android APK");
try {
installApk(adb, serial, apk);
const hash = verifyInstalledApkMatches(adb, serial, apk);
log(`installed APK bytes verified: sha256=${hash.sha256.slice(0, 12)}…`);
finishBundleStep(bundle, step, "passed");
} catch (error) {
failAndroidStep(bundle, step, error);
throw error;
}
// Byte identity with the local APK is the strongest post-install check:
// the renderer stamp lives inside the verified bytes, so the stamp equals
// the already-validated local `apkStamp` and no `adb pull` readback of the
// whole APK is needed.
setBundleBuild(bundle, {
buildId: apkStamp?.buildId ?? freshStamp.buildId,
commit: apkStamp?.commit ?? freshStamp.commit ?? null,
});
return;
}
setBundleBuild(bundle, {
buildId: installedStamp?.buildId ?? freshStamp.buildId,
commit: installedStamp?.commit ?? freshStamp.commit ?? null,
});
log(`${installDecision.reason} — skipping APK install.`);
}
// Node's fetch chokes on the HF Xet LFS redirect; curl handles it. Pre-cache the
// model so the smoke reuses it offline instead of failing on the redirect.
function ensureSmokeModelCached() {
const dest = path.join(SMOKE_MODEL.cacheDir, SMOKE_MODEL.file);
if (fs.existsSync(dest) && fs.statSync(dest).size === SMOKE_MODEL.sizeBytes) {
log(`smoke model cached: ${dest} (${SMOKE_MODEL.sizeBytes} bytes)`);
return dest;
}
fs.mkdirSync(SMOKE_MODEL.cacheDir, { recursive: true });
log(`downloading smoke model ${SMOKE_MODEL.id} via curl…`);
execFileSync("curl", ["-fsSL", "-o", dest, SMOKE_MODEL.url], {
stdio: "inherit",
});
const actualSize = fs.statSync(dest).size;
if (actualSize !== SMOKE_MODEL.sizeBytes) {
throw new Error(
`downloaded smoke model ${SMOKE_MODEL.file} size mismatch: expected ${SMOKE_MODEL.sizeBytes} bytes, got ${actualSize} bytes`,
);
}
return dest;
}
async function main() {
const bundle = createDeviceE2eBundle({
appDir,
lane: "android",
outputDir: parseOutputDirArg(process.argv),
});
let adb = null;
let serial;
let lease = null;
let finalResult = "failed";
let finalError = null;
let routeRecording = null;
try {
{
const step = startBundleStep(bundle, "resolve Android SDK");
try {
adb = resolveAdb();
activeAndroidContext = { adb, serial: null };
finishBundleStep(bundle, step, "passed");
} catch (error) {
failAndroidStep(bundle, step, error);
throw error;
}
}
{
const step = startBundleStep(bundle, "resolve Android device");
try {
serial = val("--serial", process.env.ANDROID_SERIAL);
if (!serial && has("--no-emulator-boot")) {
const unleased = listDevices(adb).find(
(candidate) => !isDeviceLeased(`android:${candidate}`),
);
if (unleased) serial = unleased;
}
if (!has("--no-emulator-boot")) {
const bootStep = startBundleStep(bundle, "boot Android device");
try {
serial = await ensureEmulatorBooted({
adb,
avd: val("--avd"),
log,
});
finishBundleStep(bundle, bootStep, "passed");
} catch (error) {
finishBundleStep(bundle, bootStep, "failed", error);
throw error;
}
}
serial = resolveSerial(adb, serial);
activeAndroidContext = { adb, serial };
finishBundleStep(bundle, step, "passed");
} catch (error) {
failAndroidStep(bundle, step, error);
throw error;
}
}
process.env.ANDROID_SERIAL = serial;
activeAndroidContext = { adb, serial };
setBundleDevice(bundle, { serial, kind: "android" });
log(`device serial=${serial}`);
lease = await acquireDeviceLease(`android:${serial}`, {
waitMs: has("--no-wait") ? 0 : undefined,
log,
});
{
const step = startBundleStep(bundle, "prepare Android device");
try {
await ensureEmulatorPermissive(adb, serial, { log });
finishBundleStep(bundle, step, "passed");
} catch (error) {
failAndroidStep(bundle, step, error);
throw error;
}
}
ensureFreshApkInstalled(bundle, adb, serial);
if (!has("--skip-local-chat")) {
const modelPath = ensureSmokeModelCached();
log("local route: on-device agent + smallest model + real chat…");
run(
bundle,
"local chat smoke",
"node",
[
"scripts/mobile-local-chat-smoke.mjs",
"--platform",
"android",
"--require-installed",
"--live",
"--android-select-local",
"--android-stage-smoke-model",
"--serial",
serial,
],
{ ANDROID_SMOKE_MODEL_PATH: modelPath, ANDROID_SERIAL: serial },
);
}
if (!has("--skip-route-coverage")) {
// The Playwright config runs route-coverage AND the on-device voice
// round-trip; the latter needs the ASR/TTS GGUFs staged (the chat smoke only
// stages the text model, and an `adb install -r` cycle can drop the
// separately-pushed voice models).
{
const step = startBundleStep(bundle, "stage Android voice models");
try {
stageVoiceModels(adb, serial);
finishBundleStep(bundle, step, "passed");
} catch (error) {
failAndroidStep(bundle, step, error);
throw error;
}
}
log("route coverage: driving every route on the real WebView…");
routeRecording = await startAndroidScreenRecord({
adb,
serial,
artifactDir: bundle.rawDir,
filename: "android-route-coverage.mp4",
remotePath: "/sdcard/eliza-android-route-coverage.mp4",
log,
});
try {
run(
bundle,
"Android route coverage",
"node",
[
"scripts/run-ui-playwright.mjs",
"--config",
"playwright.android.config.ts",
],
{
ANDROID_SERIAL: serial,
ELIZA_DEVICE_E2E_ARTIFACT_DIR: path.join(
bundle.root,
"test-results",
),
ELIZA_ANDROID_ARTIFACT_DIR: path.join(
bundle.root,
"test-results",
"android",
),
ELIZA_ANDROID_PLAYWRIGHT_JUNIT: path.join(
bundle.reportsDir,
"android-playwright.junit.xml",
),
ELIZA_ANDROID_PLAYWRIGHT_JSON: path.join(
bundle.reportsDir,
"android-playwright.json",
),
PLAYWRIGHT_HTML_REPORT: path.join(
bundle.reportsDir,
"android-playwright-html",
),
},
);
} finally {
const videoPath = await routeRecording.stop();
routeRecording = null;
if (videoPath) recordBundleArtifact(bundle, videoPath, "video");
}
}
if (has("--launcher-loop")) {
// Long seeded launcher gesture loop (≥200 real device actions). Opt-in: it
// adds several minutes, so it does not run in the default sweep. The seed is
// printed by the spec and honored via ELIZA_LOOP_SEED for reproduction.
log(
"launcher loop: ≥200 real device gestures with per-action invariants…",
);
run(
bundle,
"Android launcher loop",
"bunx",
[
"playwright",
"test",
"--config",
"playwright.android.config.ts",
"test/android/launcher-gesture-loop.android.spec.ts",
],
{
ELIZA_ANDROID_BACKEND: process.env.ELIZA_ANDROID_BACKEND ?? "host",
ELIZA_ANDROID_REQUIRE_AGENT:
process.env.ELIZA_ANDROID_REQUIRE_AGENT ?? "1",
ANDROID_SERIAL: serial,
ELIZA_DEVICE_E2E_ARTIFACT_DIR: path.join(bundle.root, "test-results"),
ELIZA_ANDROID_ARTIFACT_DIR: path.join(
bundle.root,
"test-results",
"android",
),
},
);
}
if (has("--cloud")) {
log(
"cloud route: real Hetzner provisioning probe (loud-fails if it can't)…",
);
run(bundle, "cloud provisioning", "node", [
"scripts/cloud-provisioning-e2e.mjs",
]);
}
finalResult = "passed";
log("ALL ANDROID E2E PASSED ✅");
} catch (error) {
finalError = error;
throw error;
} finally {
if (routeRecording) {
const videoPath = await routeRecording.stop();
if (videoPath) recordBundleArtifact(bundle, videoPath, "video");
}
if (adb && serial) {
try {
recordBundleArtifact(
bundle,
captureAndroidScreenshot({
adb,
serial,
artifactDir: bundle.rawDir,
filename: "android-final.png",
log,
}),
"screenshot",
);
} catch (error) {
// error-policy:J7 Bundle capture is diagnostic; preserve the runner result.
bundle.warnings.push(
`final Android screenshot failed: ${error?.message ?? error}`,
);
}
try {
recordBundleArtifact(
bundle,
captureAndroidLogcat({
adb,
serial,
artifactDir: bundle.logsDir,
filename: "android-logcat.txt",
log,
}),
"log",
);
} catch (error) {
// error-policy:J7 Bundle capture is diagnostic; preserve the runner result.
bundle.warnings.push(
`Android logcat capture failed: ${error?.message ?? error}`,
);
}
}
lease?.release();
const bundleRoot = finalizeDeviceE2eBundle(bundle, finalResult);
if (finalError) {
const block = formatFailureForensicsBlock(bundle, finalError);
if (block) process.stderr.write(`\n${block}`);
}
log(`bundle: ${bundleRoot}`);
}
}
main().catch((error) => {
console.error(`[android-e2e] FAILED: ${error?.message ?? error}`);
process.exit(1);
});