Files
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

321 lines
9.8 KiB
JavaScript

/**
* Shared script library for Android Capture capture and packaging helpers used
* by app automation.
*/
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { resolveAdb } from "./android-device.mjs";
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function isNonEmptyFile(filePath) {
try {
return fs.statSync(filePath).size > 0;
} catch {
return false;
}
}
export async function startAndroidScreenRecord({
adb = resolveAdb(),
serial,
artifactDir,
filename = "screenrecord.mp4",
remotePath = `/sdcard/${filename}`,
bitRate = "4000000",
timeLimitSeconds = 180,
log = () => {},
}) {
if (!serial) throw new Error("serial is required for Android screenrecord");
if (!artifactDir) {
throw new Error("artifactDir is required for Android screenrecord");
}
ensureDir(artifactDir);
const localPath = path.join(artifactDir, filename);
fs.rmSync(localPath, { force: true });
spawnSync(adb, ["-s", serial, "shell", "rm", "-f", remotePath], {
stdio: "ignore",
});
const recorder = spawn(
adb,
[
"-s",
serial,
"shell",
"screenrecord",
"--bit-rate",
String(bitRate),
"--time-limit",
String(timeLimitSeconds),
remotePath,
],
{ stdio: "ignore" },
);
recorder.on("error", () => {});
await delay(750);
log(`started Android screenrecord on ${serial}: ${remotePath}`);
return {
localPath,
remotePath,
async stop() {
spawnSync(adb, ["-s", serial, "shell", "pkill", "-INT", "screenrecord"], {
stdio: "ignore",
});
if (recorder.exitCode === null) recorder.kill("SIGINT");
await Promise.race([
new Promise((resolve) => recorder.once("close", resolve)),
delay(3_000),
]);
// The local adb process closing does not mean the on-device screenrecord
// has finished: it still has to append the trailing moov atom, and
// pulling at that instant yields an unplayable MP4. Wait (bounded) for
// the device-side process to exit, re-sending SIGINT while it lives —
// a single pkill has been observed not landing.
const exitDeadline = Date.now() + 15_000;
while (Date.now() < exitDeadline) {
const pid = spawnSync(
adb,
["-s", serial, "shell", "pidof", "screenrecord"],
{ encoding: "utf8" },
);
if (!pid.stdout || pid.stdout.trim() === "") break;
spawnSync(
adb,
["-s", serial, "shell", "pkill", "-INT", "screenrecord"],
{ stdio: "ignore" },
);
await delay(500);
}
// Belt over the pid check: require the remote file size to hold steady
// across consecutive samples so a mid-flush pull can never grab a
// truncated file (covers a transient pidof miss or the exit-wait
// timing out above).
let settledSize = -1;
for (let i = 0; i < 10; i += 1) {
const stat = spawnSync(
adb,
["-s", serial, "shell", "stat", "-c", "%s", remotePath],
{ encoding: "utf8" },
);
const size = Number.parseInt(stat.stdout?.trim() ?? "", 10);
if (Number.isFinite(size) && size > 0 && size === settledSize) break;
settledSize = Number.isFinite(size) ? size : -1;
await delay(500);
}
spawnSync(adb, ["-s", serial, "pull", remotePath, localPath], {
stdio: "ignore",
});
spawnSync(adb, ["-s", serial, "shell", "rm", "-f", remotePath], {
stdio: "ignore",
});
if (!isNonEmptyFile(localPath)) return null;
log(`wrote Android screenrecord: ${localPath}`);
return localPath;
},
};
}
/**
* Record a gesture walkthrough that outruns `screenrecord`'s hard 180s per-file
* cap: record back-to-back capped segments on the device, pull each as it ends,
* and concat them into one mp4 with ffmpeg (`-c copy`, no re-encode — every
* segment shares the same encoder settings). Falls back to the single recorded
* segment when ffmpeg is missing or only one segment exists. There is a
* sub-second gap between segments (the pull + respawn window); evidence video
* tolerates it, so it is not stitched over.
*/
export async function startChunkedAndroidScreenRecord({
adb = resolveAdb(),
serial,
artifactDir,
filename = "screenrecord.mp4",
segmentSeconds = 170,
bitRate = "4000000",
log = () => {},
}) {
if (!serial) throw new Error("serial is required for Android screenrecord");
if (!artifactDir) {
throw new Error("artifactDir is required for Android screenrecord");
}
ensureDir(artifactDir);
const localPath = path.join(artifactDir, filename);
fs.rmSync(localPath, { force: true });
const stem = path.basename(filename, path.extname(filename));
const remoteBase = `/sdcard/${stem}`;
const segments = [];
let stopped = false;
let currentChild = null;
const recordSegment = (index) => {
const remotePath = `${remoteBase}-seg${String(index).padStart(3, "0")}.mp4`;
spawnSync(adb, ["-s", serial, "shell", "rm", "-f", remotePath], {
stdio: "ignore",
});
const child = spawn(
adb,
[
"-s",
serial,
"shell",
"screenrecord",
"--bit-rate",
String(bitRate),
"--time-limit",
String(Math.min(180, Math.max(1, segmentSeconds))),
remotePath,
],
{ stdio: "ignore" },
);
currentChild = child;
return new Promise((resolve) => {
const done = () => resolve(remotePath);
child.once("close", done);
child.once("error", done);
});
};
const loop = (async () => {
let index = 0;
while (!stopped) {
const remotePath = await recordSegment(index);
currentChild = null;
const segmentLocal = path.join(artifactDir, `.${stem}-seg${index}.mp4`);
spawnSync(adb, ["-s", serial, "pull", remotePath, segmentLocal], {
stdio: "ignore",
});
spawnSync(adb, ["-s", serial, "shell", "rm", "-f", remotePath], {
stdio: "ignore",
});
if (isNonEmptyFile(segmentLocal)) {
segments.push(segmentLocal);
log(`pulled Android screenrecord segment ${index}: ${segmentLocal}`);
}
index += 1;
}
})();
await delay(750);
log(`started chunked Android screenrecord on ${serial}: ${remoteBase}-seg*`);
return {
localPath,
async stop() {
stopped = true;
spawnSync(adb, ["-s", serial, "shell", "pkill", "-INT", "screenrecord"], {
stdio: "ignore",
});
if (currentChild && currentChild.exitCode === null) {
currentChild.kill("SIGINT");
}
await Promise.race([loop, delay(8_000)]);
if (segments.length === 0) return null;
if (segments.length === 1) {
fs.copyFileSync(segments[0], localPath);
} else if (!concatSegments(segments, localPath, log)) {
// ffmpeg unavailable/failed: keep the longest single segment so the run
// still has watchable video rather than nothing.
const longest = segments
.map((file) => ({ file, size: fs.statSync(file).size }))
.sort((a, b) => b.size - a.size)[0];
fs.copyFileSync(longest.file, localPath);
log(`ffmpeg concat unavailable; kept longest segment ${longest.file}`);
}
for (const segment of segments) fs.rmSync(segment, { force: true });
if (!isNonEmptyFile(localPath)) return null;
log(`wrote chunked Android screenrecord: ${localPath}`);
return localPath;
},
};
}
function concatSegments(segments, outPath, log) {
const probe = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" });
if (probe.status !== 0) return false;
const listPath = `${outPath}.concat.txt`;
fs.writeFileSync(
listPath,
`${segments.map((file) => `file '${file.replace(/'/g, "'\\''")}'`).join("\n")}\n`,
);
const result = spawnSync(
"ffmpeg",
["-y", "-f", "concat", "-safe", "0", "-i", listPath, "-c", "copy", outPath],
{ stdio: "ignore" },
);
fs.rmSync(listPath, { force: true });
if (result.status !== 0 || !isNonEmptyFile(outPath)) {
log(`ffmpeg concat failed with status ${result.status}`);
return false;
}
return true;
}
export function captureAndroidScreenshot({
adb = resolveAdb(),
serial,
artifactDir,
filename = "screenshot.png",
log = () => {},
}) {
if (!serial) throw new Error("serial is required for Android screenshot");
if (!artifactDir) {
throw new Error("artifactDir is required for Android screenshot");
}
ensureDir(artifactDir);
const localPath = path.join(artifactDir, filename);
const result = spawnSync(adb, ["-s", serial, "exec-out", "screencap", "-p"]);
if (result.status !== 0 || !result.stdout?.length) {
const detail = result.stderr?.toString("utf8").trim();
throw new Error(
`adb screencap failed for ${serial}${detail ? `: ${detail}` : ""}`,
);
}
fs.writeFileSync(localPath, result.stdout);
if (!isNonEmptyFile(localPath)) {
throw new Error(`adb screencap wrote an empty file: ${localPath}`);
}
log(`wrote Android screenshot: ${localPath}`);
return localPath;
}
export function captureAndroidLogcat({
adb = resolveAdb(),
serial,
artifactDir,
filename = "logcat.txt",
lines = 500,
log = () => {},
}) {
if (!serial) throw new Error("serial is required for Android logcat");
if (!artifactDir)
throw new Error("artifactDir is required for Android logcat");
ensureDir(artifactDir);
const localPath = path.join(artifactDir, filename);
const result = spawnSync(
adb,
["-s", serial, "logcat", "-d", "-t", String(lines)],
{ encoding: "utf8" },
);
fs.writeFileSync(
localPath,
result.status === 0
? result.stdout
: result.stderr || `adb logcat exited with ${result.status}\n`,
);
log(`wrote Android logcat: ${localPath}`);
return localPath;
}