mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:40:43 +00:00
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:
@@ -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"] },
|
||||
];
|
||||
|
||||
138
scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs
Normal file
138
scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs
Normal 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);
|
||||
});
|
||||
96
scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs
Normal file
96
scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs
Normal 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>"}`);
|
||||
}
|
||||
70
scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh
Normal file
70
scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh
Normal 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."
|
||||
@@ -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;
|
||||
|
||||
27
scripts/e2e/plugin-lifecycle-matrix-docker.sh
Normal file
27
scripts/e2e/plugin-lifecycle-matrix-docker.sh
Normal 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."
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user