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
492 lines
15 KiB
JavaScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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;
|
|
}
|