test(plugins): add lifecycle matrix coverage

Add plugin lifecycle matrix Docker E2E coverage, resource metrics, fixture registry version support, and gauntlet handling for bundled plugin ids / required config.
This commit is contained in:
Vincent Koc
2026-05-03 01:18:31 -07:00
committed by GitHub
parent 2ffdb5d248
commit ea45950a9d
10 changed files with 381 additions and 24 deletions

View File

@@ -389,11 +389,11 @@ function runPluginLifecycle(params) {
const commands = [
{
phase: "install",
args: ["install", plugin.dir, "--link", "--dangerously-force-unsafe-install"],
args: ["install", plugin.id],
},
{ phase: "inspect", args: ["inspect", plugin.id, "--json"] },
{ phase: "disable", args: ["disable", plugin.id] },
{ phase: "enable", args: ["enable", plugin.id] },
...(plugin.hasRequiredConfigFields ? [] : [{ phase: "enable", args: ["enable", plugin.id] }]),
{ phase: "doctor", args: ["doctor"] },
{ phase: "uninstall", args: ["uninstall", plugin.id, "--force"] },
];

View File

@@ -0,0 +1,138 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const [summaryPath, phase, separator, command, ...args] = process.argv.slice(2);
if (!summaryPath || !phase || separator !== "--" || !command) {
console.error("usage: measure.mjs <summary.tsv> <phase> -- <command> [args...]");
process.exit(2);
}
const pageSize = Number.parseInt(process.env.OPENCLAW_PROC_PAGE_SIZE || "4096", 10);
const clockTicks = Number.parseInt(process.env.OPENCLAW_PROC_CLK_TCK || "100", 10);
const pollMs = Number.parseInt(process.env.OPENCLAW_PLUGIN_LIFECYCLE_METRIC_POLL_MS || "100", 10);
if (!fs.existsSync("/proc")) {
console.error("plugin lifecycle resource sampler requires Linux /proc");
process.exit(2);
}
function readProcSnapshot() {
const stats = new Map();
for (const entry of fs.readdirSync("/proc", { withFileTypes: true })) {
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) {
continue;
}
const pid = Number.parseInt(entry.name, 10);
const statPath = path.join("/proc", entry.name, "stat");
try {
const raw = fs.readFileSync(statPath, "utf8");
const closeParen = raw.lastIndexOf(")");
if (closeParen === -1) {
continue;
}
const fields = raw
.slice(closeParen + 2)
.trim()
.split(/\s+/u);
const ppid = Number.parseInt(fields[1] ?? "", 10);
const userTicks = Number.parseInt(fields[11] ?? "", 10);
const systemTicks = Number.parseInt(fields[12] ?? "", 10);
const rssPages = Number.parseInt(fields[21] ?? "", 10);
if (
!Number.isFinite(ppid) ||
!Number.isFinite(userTicks) ||
!Number.isFinite(systemTicks) ||
!Number.isFinite(rssPages)
) {
continue;
}
stats.set(pid, {
ppid,
cpuTicks: userTicks + systemTicks,
rssBytes: Math.max(0, rssPages) * pageSize,
});
} catch {
// Processes can exit while /proc is being scanned.
}
}
return stats;
}
function descendantsOf(rootPid, stats) {
const children = new Map();
for (const [pid, stat] of stats.entries()) {
const siblings = children.get(stat.ppid) ?? [];
siblings.push(pid);
children.set(stat.ppid, siblings);
}
const seen = new Set([rootPid]);
const queue = [rootPid];
for (let index = 0; index < queue.length; index += 1) {
for (const child of children.get(queue[index]) ?? []) {
if (!seen.has(child)) {
seen.add(child);
queue.push(child);
}
}
}
return seen;
}
function sample(rootPid) {
const stats = readProcSnapshot();
const pids = descendantsOf(rootPid, stats);
let rssBytes = 0;
let cpuTicks = 0;
for (const pid of pids) {
const stat = stats.get(pid);
if (!stat) {
continue;
}
rssBytes += stat.rssBytes;
cpuTicks += stat.cpuTicks;
}
return { rssBytes, cpuTicks };
}
const started = performance.now();
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
let maxRssBytes = 0;
let maxCpuTicks = 0;
const updateMetrics = () => {
if (!child.pid) {
return;
}
const current = sample(child.pid);
maxRssBytes = Math.max(maxRssBytes, current.rssBytes);
maxCpuTicks = Math.max(maxCpuTicks, current.cpuTicks);
};
updateMetrics();
const interval = setInterval(updateMetrics, pollMs);
child.on("exit", (code, signal) => {
updateMetrics();
clearInterval(interval);
const wallMs = performance.now() - started;
const cpuSeconds = maxCpuTicks / clockTicks;
const maxRssKb = Math.round(maxRssBytes / 1024);
const cpuCoreRatio = wallMs > 0 ? cpuSeconds / (wallMs / 1000) : 0;
fs.appendFileSync(
summaryPath,
`${phase}\t${maxRssKb}\t${cpuSeconds.toFixed(3)}\t${wallMs.toFixed(0)}\t${cpuCoreRatio.toFixed(3)}\t${signal ?? ""}\n`,
);
console.log(
`plugin lifecycle resource: phase=${phase} max_rss_kb=${maxRssKb} cpu_s=${cpuSeconds.toFixed(3)} wall_ms=${wallMs.toFixed(0)} cpu_core_ratio=${cpuCoreRatio.toFixed(3)}`,
);
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});

View File

@@ -0,0 +1,96 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const home = os.homedir();
function openclawPath(...parts) {
return path.join(home, ".openclaw", ...parts);
}
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return {};
}
}
function records() {
const index = readJson(openclawPath("plugins", "installs.json"));
return index.installRecords ?? index.records ?? {};
}
function recordFor(pluginId) {
return records()[pluginId];
}
function config() {
return readJson(process.env.OPENCLAW_CONFIG_PATH ?? openclawPath("openclaw.json"));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function assertVersion(pluginId, version) {
const record = recordFor(pluginId);
assert(record, `install record missing for ${pluginId}`);
assert(record.source === "npm", `expected npm source for ${pluginId}, got ${record.source}`);
assert(
record.resolvedVersion === version || record.version === version,
`expected ${pluginId} record version ${version}, got ${JSON.stringify(record)}`,
);
assert(record.installPath, `install path missing for ${pluginId}`);
const packageJson = readJson(path.join(record.installPath, "package.json"));
assert(
packageJson.version === version,
`expected installed package version ${version}, got ${packageJson.version}`,
);
}
function assertEnabled(pluginId, expectedRaw) {
const expected = expectedRaw === "true";
const entry = config().plugins?.entries?.[pluginId];
assert(entry?.enabled === expected, `expected ${pluginId} enabled=${expected}`);
}
function printInstallPath(pluginId) {
const record = recordFor(pluginId);
assert(record?.installPath, `install path missing for ${pluginId}`);
process.stdout.write(record.installPath);
}
function assertUninstalled(pluginId) {
const cfg = config();
const record = recordFor(pluginId);
assert(!record, `install record still present for ${pluginId}`);
assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`);
assert(!(cfg.plugins?.allow ?? []).includes(pluginId), `allowlist still contains ${pluginId}`);
assert(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`);
const loadPaths = cfg.plugins?.load?.paths ?? [];
assert(
!loadPaths.some((entry) => String(entry).includes(pluginId)),
`load path still references ${pluginId}: ${loadPaths.join(", ")}`,
);
}
const [command, pluginId, arg] = process.argv.slice(2);
switch (command) {
case "assert-version":
assertVersion(pluginId, arg);
break;
case "assert-enabled":
assertEnabled(pluginId, arg);
break;
case "install-path":
printInstallPath(pluginId);
break;
case "assert-uninstalled":
assertUninstalled(pluginId);
break;
default:
throw new Error(`unknown plugin lifecycle matrix probe command: ${command ?? "<missing>"}`);
}

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
openclaw_e2e_install_package /tmp/openclaw-plugin-lifecycle-install.log "mounted OpenClaw package" /tmp/npm-prefix
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
export PATH="/tmp/npm-prefix/bin:$PATH"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
source scripts/e2e/lib/plugins/fixtures.sh
plugin_id="lifecycle-claw"
package_name="@openclaw/lifecycle-claw"
probe="scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs"
measure="scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs"
resource_dir="/tmp/openclaw-plugin-lifecycle-matrix"
mkdir -p "$resource_dir"
summary_tsv="$resource_dir/resource-summary.tsv"
printf "phase\tmax_rss_kb\tcpu_seconds\twall_ms\tcpu_core_ratio\tsignal\n" >"$summary_tsv"
run_measured() {
local phase="$1"
shift
echo "Running plugin lifecycle phase: $phase"
node "$measure" "$summary_tsv" "$phase" -- "$@"
}
pack_root="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-pack.XXXXXX")"
registry_root="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-registry.XXXXXX")"
pack_fixture_plugin "$pack_root/v1" /tmp/lifecycle-claw-1.0.0.tgz "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
pack_fixture_plugin "$pack_root/v2" /tmp/lifecycle-claw-2.0.0.tgz "$plugin_id" 2.0.0 lifecycle.v2 "Lifecycle Claw"
start_npm_fixture_registry "$package_name" 1.0.0 /tmp/lifecycle-claw-1.0.0.tgz "$registry_root" "$package_name" 2.0.0 /tmp/lifecycle-claw-2.0.0.tgz
run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0"
node "$probe" assert-version "$plugin_id" 1.0.0
run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >/tmp/plugin-lifecycle-inspect-v1.json' bash "$entry" "$plugin_id"
run_measured disable node "$entry" plugins disable "$plugin_id"
node "$probe" assert-enabled "$plugin_id" false
run_measured enable node "$entry" plugins enable "$plugin_id"
node "$probe" assert-enabled "$plugin_id" true
run_measured upgrade-v2 node "$entry" plugins update "$package_name@2.0.0"
node "$probe" assert-version "$plugin_id" 2.0.0
run_measured downgrade-v1 node "$entry" plugins update "$package_name@1.0.0"
node "$probe" assert-version "$plugin_id" 1.0.0
install_path="$(node "$probe" install-path "$plugin_id")"
rm -rf "$install_path"
if [[ -e "$install_path" ]]; then
echo "Failed to remove plugin code before missing-code uninstall: $install_path" >&2
exit 1
fi
run_measured missing-code-uninstall node "$entry" plugins uninstall "$plugin_id" --force
node "$probe" assert-uninstalled "$plugin_id"
echo "Plugin lifecycle resource summary:"
cat "$summary_tsv"
echo "Plugin lifecycle matrix passed."

View File

@@ -18,33 +18,42 @@ for (let index = 0; index < packageArgs.length; index += 3) {
const version = packageArgs[index + 1];
const tarballPath = packageArgs[index + 2];
const archive = fs.readFileSync(tarballPath);
packages.set(packageName, {
const existing = packages.get(packageName) ?? {
encodedPackageName: encodeURIComponent(packageName).replace("%40", "@"),
packageName,
latestVersion: version,
versions: new Map(),
};
existing.latestVersion = version;
existing.versions.set(version, {
archive,
dependencies: packageName === "@openclaw/demo-plugin-npm" ? { "is-number": "7.0.0" } : {},
encodedPackageName: encodeURIComponent(packageName).replace("%40", "@"),
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
packageName,
shasum: crypto.createHash("sha1").update(archive).digest("hex"),
tarballName: path.basename(tarballPath),
version,
});
packages.set(packageName, existing);
}
const metadataFor = (entry, baseUrl) => ({
name: entry.packageName,
"dist-tags": { latest: entry.version },
versions: {
[entry.version]: {
dependencies: entry.dependencies,
name: entry.packageName,
version: entry.version,
dist: {
integrity: entry.integrity,
shasum: entry.shasum,
tarball: `${baseUrl}/${entry.encodedPackageName}/-/${entry.tarballName}`,
"dist-tags": { latest: entry.latestVersion },
versions: Object.fromEntries(
[...entry.versions.entries()].map(([version, versionEntry]) => [
version,
{
dependencies: versionEntry.dependencies,
name: entry.packageName,
version,
dist: {
integrity: versionEntry.integrity,
shasum: versionEntry.shasum,
tarball: `${baseUrl}/${entry.encodedPackageName}/-/${versionEntry.tarballName}`,
},
},
},
},
]),
),
});
function findPackageForPath(pathname) {
@@ -54,11 +63,13 @@ function findPackageForPath(pathname) {
function findTarballForPath(pathname) {
for (const entry of packages.values()) {
const prefix = `/${entry.encodedPackageName}/-/`;
if (
pathname.toLowerCase().startsWith(prefix.toLowerCase()) &&
pathname.endsWith(`/${entry.tarballName}`)
) {
return entry;
if (!pathname.toLowerCase().startsWith(prefix.toLowerCase())) {
continue;
}
for (const versionEntry of entry.versions.values()) {
if (pathname.endsWith(`/${versionEntry.tarballName}`)) {
return versionEntry;
}
}
}
return undefined;

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Bare package-level plugin lifecycle matrix with resource metrics.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-plugin-lifecycle-matrix-e2e" OPENCLAW_PLUGIN_LIFECYCLE_MATRIX_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_PLUGIN_LIFECYCLE_MATRIX_E2E_SKIP_BUILD:-0}"
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz plugin-lifecycle-matrix "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
docker_e2e_build_or_reuse "$IMAGE_NAME" plugin-lifecycle-matrix "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 plugin-lifecycle-matrix empty)"
echo "Running plugin lifecycle matrix Docker E2E..."
docker_e2e_run_with_harness \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENCLAW_SKIP_CHANNELS=1 \
-e OPENCLAW_SKIP_PROVIDERS=1 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
"$IMAGE_NAME" \
bash scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh
echo "Plugin lifecycle matrix Docker E2E passed."

View File

@@ -255,6 +255,14 @@ export const mainLanes = [
npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update", {
stateScenario: "empty",
}),
npmLane(
"plugin-lifecycle-matrix",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-lifecycle-matrix",
{
stateScenario: "empty",
timeoutMs: 12 * 60 * 1000,
},
),
serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload", {
stateScenario: "empty",
}),