Files
elizaos--eliza/packages/app/scripts/lib/device-e2e-bundle.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

492 lines
15 KiB
JavaScript

/**
* Device-e2e bundle assembly for the mobile runners.
*
* The runners exercise real devices and fail at many layers: build, install,
* device boot, local agent startup, WebView driving, and cloud provisioning.
* This module keeps those phases visible in one run directory even when the
* process exits non-zero, and prepares inline-friendly evidence files for PR
* comments without sending new output to the retired repo evidence tree.
*/
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
const require = createRequire(import.meta.url);
const INLINE_EXTENSIONS = new Set([".jpg", ".jpeg", ".mp4"]);
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov"]);
const LOG_EXTENSIONS = new Set([".log", ".txt", ".json", ".jsonl"]);
function timestampId(date = new Date()) {
return date.toISOString().replace(/[:.]/g, "-");
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function isFile(filePath) {
try {
return fs.statSync(filePath).isFile();
} catch {
// error-policy:J3 Artifact paths can disappear while a failing runner unwinds.
return false;
}
}
function isNonEmptyFile(filePath) {
try {
return fs.statSync(filePath).size > 0;
} catch {
// error-policy:J3 Missing conversion output is an invalid artifact signal.
return false;
}
}
function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
function xmlEscape(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function uniquePath(dir, filename) {
const parsed = path.parse(filename);
let candidate = path.join(dir, filename);
let index = 2;
while (fs.existsSync(candidate)) {
candidate = path.join(dir, `${parsed.name}-${index}${parsed.ext}`);
index += 1;
}
return candidate;
}
function slugifyStepName(name) {
return String(name)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80);
}
function walkFiles(root) {
if (!root || !fs.existsSync(root)) return [];
const files = [];
const stack = [root];
while (stack.length > 0) {
const current = stack.pop();
let entries = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
// error-policy:J3 Unreadable artifact roots are skipped, not reported as collected.
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (entry.isFile()) {
files.push(fullPath);
}
}
}
return files;
}
function shellAvailable(cmd) {
return spawnSync(cmd, ["-version"], { stdio: "ignore" }).status === 0;
}
function convertPngToJpg(src, dest) {
if (process.platform === "darwin") {
const sips = spawnSync(
"sips",
["-s", "format", "jpeg", src, "--out", dest],
{ stdio: "ignore" },
);
if (sips.status === 0 && isNonEmptyFile(dest)) return true;
}
if (shellAvailable("ffmpeg")) {
// `-update 1` is required to write a single still to a fixed (non-pattern)
// filename: without it the image2 muxer demands a `%d` sequence pattern and
// older ffmpeg builds treat the missing pattern as a fatal error (non-zero
// exit, no file). Newer builds only warn, so the flag keeps the conversion
// deterministic across ffmpeg versions on the CI runners.
const ffmpeg = spawnSync(
"ffmpeg",
["-y", "-i", src, "-frames:v", "1", "-update", "1", "-q:v", "2", dest],
{ stdio: "ignore" },
);
if (ffmpeg.status === 0 && isNonEmptyFile(dest)) return true;
}
return convertPngToJpgWithSharp(src, dest);
}
// Final PNG->JPG fallback for hosts without sips/ffmpeg — notably the Linux CI
// runners, which ship neither. `sharp` is a declared packages/app dependency,
// so it is always installed here; run it out-of-process to keep this converter
// synchronous like the sips/ffmpeg spawns above (prepareInlineArtifacts and
// every runner call site depend on the sync contract, and sharp's API is async
// only). Returns false if sharp cannot be resolved or the encode fails.
function convertPngToJpgWithSharp(src, dest) {
let sharpEntry;
try {
sharpEntry = require.resolve("sharp");
} catch {
return false;
}
const encode =
"const sharp=require(process.argv[3]);" +
"sharp(process.argv[1]).jpeg({quality:82}).toFile(process.argv[2])" +
".then(()=>process.exit(0)).catch(()=>process.exit(1));";
const result = spawnSync(
process.execPath,
["-e", encode, src, dest, sharpEntry],
{ stdio: "ignore" },
);
return result.status === 0 && isNonEmptyFile(dest);
}
function remuxMovToMp4(src, dest) {
if (!shellAvailable("ffmpeg")) return false;
const result = spawnSync(
"ffmpeg",
["-y", "-i", src, "-c", "copy", "-movflags", "+faststart", dest],
{ stdio: "ignore" },
);
return result.status === 0 && isNonEmptyFile(dest);
}
export function parseOutputDirArg(argv) {
const index = argv.indexOf("--output");
return index >= 0 && index + 1 < argv.length ? argv[index + 1] : undefined;
}
export function defaultDeviceE2eOutputDir({ appDir, lane, date = new Date() }) {
return path.join(appDir, "device-e2e-output", `${lane}-${timestampId(date)}`);
}
export function createDeviceE2eBundle({
appDir,
lane,
outputDir,
startedAt = new Date(),
device = {},
build = {},
}) {
const root = path.resolve(
outputDir ?? defaultDeviceE2eOutputDir({ appDir, lane, date: startedAt }),
);
const bundle = {
root,
inlineDir: ensureDir(path.join(root, "inline")),
logsDir: ensureDir(path.join(root, "logs")),
rawDir: ensureDir(path.join(root, "raw")),
reportsDir: ensureDir(path.join(root, "reports")),
startedAt: startedAt.toISOString(),
lane,
device,
build,
steps: [],
artifacts: [],
warnings: [],
_activeStep: null,
};
ensureDir(root);
writeJson(path.join(root, "summary.json"), buildSummary(bundle, "running"));
return bundle;
}
export function setBundleDevice(bundle, device) {
bundle.device = { ...bundle.device, ...device };
return bundle.device;
}
export function setBundleBuild(bundle, build) {
bundle.build = { ...bundle.build, ...build };
return bundle.build;
}
export function startBundleStep(bundle, name) {
const step = {
name,
status: "running",
startedAt: new Date().toISOString(),
durationMs: 0,
artifacts: [],
};
bundle.steps.push(step);
bundle._activeStep = step;
return step;
}
export function finishBundleStep(bundle, step, status, error) {
const endedAt = new Date();
step.endedAt = endedAt.toISOString();
step.durationMs = Math.max(0, endedAt.getTime() - Date.parse(step.startedAt));
step.status = status;
if (error) step.error = String(error?.message ?? error);
if (bundle._activeStep === step) bundle._activeStep = null;
return step;
}
export function failureDirForStep(bundle, step) {
const slug = slugifyStepName(step.name) || "step";
return ensureDir(path.join(bundle.root, "failure", slug));
}
export function recordBundleArtifact(bundle, filePath, kind, step = null) {
const absolute = path.resolve(filePath);
if (!isFile(absolute)) return null;
const artifact = {
kind,
path: absolute,
relativePath: path.relative(bundle.root, absolute),
sizeBytes: fs.statSync(absolute).size,
};
bundle.artifacts.push(artifact);
const owner = step ?? bundle._activeStep;
if (owner) owner.artifacts.push(artifact.relativePath);
return artifact;
}
export function captureFailureForensics(bundle, step, capture, error) {
const failureDir = failureDirForStep(bundle, step);
step.failureDir = path.relative(bundle.root, failureDir);
try {
const captured = capture({ failureDir, error }) ?? [];
const files = Array.isArray(captured) ? captured : [captured];
for (const file of files.filter(Boolean)) {
const ext = path.extname(file).toLowerCase();
const kind = VIDEO_EXTENSIONS.has(ext)
? "video"
: IMAGE_EXTENSIONS.has(ext)
? "screenshot"
: "log";
recordBundleArtifact(bundle, file, kind, step);
}
} catch (captureError) {
// error-policy:J6 failure forensics is teardown/diagnostic work; preserve
// the original runner failure and record the capture problem as a warning.
bundle.warnings.push(
`failure forensics failed for ${step.name}: ${captureError?.message ?? captureError}`,
);
}
return failureDir;
}
export function formatFailureForensicsBlock(bundle, error) {
const failedStep = [...bundle.steps]
.reverse()
.find((step) => step.status === "failed");
if (!failedStep) return "";
const artifactPaths = failedStep.artifacts.map((relativePath) =>
path.join(bundle.root, relativePath),
);
const lines = [
"DEVICE E2E FAILURE FORENSICS",
`step: ${failedStep.name}`,
`cause: ${error?.message ?? failedStep.error ?? error}`,
];
if (failedStep.failureDir) {
lines.push(`failureDir: ${path.join(bundle.root, failedStep.failureDir)}`);
}
if (artifactPaths.length > 0) {
lines.push("artifacts:");
for (const artifactPath of artifactPaths) lines.push(` - ${artifactPath}`);
}
return `${lines.join("\n")}\n`;
}
export function appendRunnerLog(bundle, chunk) {
fs.appendFileSync(path.join(bundle.logsDir, "runner.log"), chunk);
}
export function runBundledCommand(
bundle,
name,
cmd,
args,
{ cwd, env = {}, onFailure } = {},
) {
const step = startBundleStep(bundle, name);
const result = spawnSync(cmd, args, {
cwd,
env: { ...process.env, ...env },
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
if (stdout) {
process.stdout.write(stdout);
appendRunnerLog(bundle, stdout);
}
if (stderr) {
process.stderr.write(stderr);
appendRunnerLog(bundle, stderr);
}
const ok = result.status === 0;
const failure = ok
? null
: `${cmd} ${args.join(" ")} ${
result.status === null
? "terminated by signal"
: `exited with ${result.status}`
}`;
if (!ok && onFailure) onFailure(step, failure);
finishBundleStep(bundle, step, ok ? "passed" : "failed", failure);
if (!ok) {
throw new Error(
`${cmd} ${args.join(" ")} ${
result.status === null
? "terminated by signal"
: `exited with code ${result.status}`
}`,
);
}
return result;
}
export function collectBundleArtifacts(bundle, sourceDirs) {
const seen = new Set(bundle.artifacts.map((artifact) => artifact.path));
for (const sourceDir of sourceDirs.filter(Boolean)) {
for (const file of walkFiles(sourceDir)) {
const ext = path.extname(file).toLowerCase();
if (seen.has(path.resolve(file))) continue;
if (VIDEO_EXTENSIONS.has(ext)) {
recordBundleArtifact(bundle, file, "video");
} else if (IMAGE_EXTENSIONS.has(ext)) {
recordBundleArtifact(bundle, file, "screenshot");
} else if (
LOG_EXTENSIONS.has(ext) ||
path.basename(file) === "junit.xml"
) {
recordBundleArtifact(bundle, file, "log");
}
seen.add(path.resolve(file));
}
}
}
export function prepareInlineArtifacts(bundle) {
for (const artifact of [...bundle.artifacts]) {
const ext = path.extname(artifact.path).toLowerCase();
if (INLINE_EXTENSIONS.has(ext)) {
const dest = uniquePath(bundle.inlineDir, path.basename(artifact.path));
if (path.resolve(artifact.path) !== path.resolve(dest)) {
fs.copyFileSync(artifact.path, dest);
}
recordBundleArtifact(bundle, dest, artifact.kind);
continue;
}
if (ext === ".png") {
const dest = uniquePath(
bundle.inlineDir,
`${path.basename(artifact.path, ext)}.jpg`,
);
if (convertPngToJpg(artifact.path, dest)) {
recordBundleArtifact(bundle, dest, "screenshot");
} else {
bundle.warnings.push(`could not convert PNG to JPG: ${artifact.path}`);
}
continue;
}
if (ext === ".mov") {
const dest = uniquePath(
bundle.inlineDir,
`${path.basename(artifact.path, ext)}.mp4`,
);
if (remuxMovToMp4(artifact.path, dest)) {
recordBundleArtifact(bundle, dest, "video");
} else {
bundle.warnings.push(`could not remux MOV to MP4: ${artifact.path}`);
}
}
}
}
export function writeBundleJunit(bundle, result) {
const failed = bundle.steps.filter((step) => step.status === "failed");
const tests = bundle.steps.length || 1;
const body =
bundle.steps.length === 0
? `<testcase classname="${xmlEscape(bundle.lane)}" name="runner" />`
: bundle.steps
.map((step) => {
const seconds = (step.durationMs / 1000).toFixed(3);
const failure =
step.status === "failed"
? `<failure message="${xmlEscape(step.error ?? "failed")}">${xmlEscape(step.error ?? "failed")}</failure>`
: "";
return `<testcase classname="${xmlEscape(bundle.lane)}" name="${xmlEscape(step.name)}" time="${seconds}">${failure}</testcase>`;
})
.join("\n");
const xml =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="${xmlEscape(bundle.lane)}" tests="${tests}" failures="${failed.length}" errors="0" time="${(totalDurationMs(bundle) / 1000).toFixed(3)}" result="${xmlEscape(result)}">\n` +
`${body}\n` +
`</testsuite>\n`;
const junitPath = path.join(bundle.root, "junit.xml");
fs.writeFileSync(junitPath, xml);
recordBundleArtifact(bundle, junitPath, "junit");
return junitPath;
}
function totalDurationMs(bundle) {
return bundle.steps.reduce((sum, step) => sum + (step.durationMs ?? 0), 0);
}
function buildSummary(bundle, result) {
return {
lane: bundle.lane,
startedAt: bundle.startedAt,
finishedAt: new Date().toISOString(),
device: bundle.device,
build: bundle.build,
steps: bundle.steps.map((step) => ({
name: step.name,
status: step.status,
startedAt: step.startedAt,
endedAt: step.endedAt ?? null,
durationMs: step.durationMs,
artifacts: step.artifacts,
...(step.failureDir ? { failureDir: step.failureDir } : {}),
...(step.error ? { error: step.error } : {}),
})),
artifacts: bundle.artifacts.map((artifact) => ({
kind: artifact.kind,
path: artifact.relativePath,
sizeBytes: artifact.sizeBytes,
})),
warnings: bundle.warnings,
result,
};
}
export function finalizeDeviceE2eBundle(bundle, result) {
collectBundleArtifacts(bundle, [
bundle.rawDir,
bundle.logsDir,
bundle.reportsDir,
path.join(bundle.root, "test-results"),
]);
prepareInlineArtifacts(bundle);
writeBundleJunit(bundle, result);
writeJson(
path.join(bundle.root, "summary.json"),
buildSummary(bundle, result),
);
return bundle.root;
}