mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 13:28:10 +00:00
test: fold lifecycle and package proof into QA Lab (#93114)
* test: fold script coverage into qa scenarios * test: migrate script checks into qa e2e * test: point qa code refs at migrated e2e * test: fold plugin lifecycle probe into qa e2e * test: use shared temp dirs in plugin lifecycle probe * test: fold plugin lifecycle sweep into qa lab * test: trim lifecycle docker text assertions * test: keep followup script conversions split * test: make lifecycle docker runner script-safe * test: update changed helper routing expectation
This commit is contained in:
@@ -51,8 +51,15 @@ describe("qa scenario catalog", () => {
|
||||
expect(
|
||||
pack.scenarios
|
||||
.filter((scenario) => scenario.execution?.kind !== "flow")
|
||||
.map((scenario) => scenario.id),
|
||||
).toStrictEqual(["control-ui-chat-flow-playwright"]);
|
||||
.map((scenario) => scenario.id)
|
||||
.toSorted(),
|
||||
).toStrictEqual(
|
||||
[
|
||||
"control-ui-chat-flow-playwright",
|
||||
"package-openclaw-for-docker",
|
||||
"plugin-lifecycle-probe",
|
||||
].toSorted(),
|
||||
);
|
||||
expect(
|
||||
pack.scenarios
|
||||
.filter((scenario) => scenario.execution.kind === "flow")
|
||||
|
||||
26
qa/scenarios/plugins/plugin-lifecycle-probe.yaml
Normal file
26
qa/scenarios/plugins/plugin-lifecycle-probe.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
title: Plugin lifecycle probe evidence
|
||||
|
||||
scenario:
|
||||
id: plugin-lifecycle-probe
|
||||
surface: plugins
|
||||
coverage:
|
||||
primary:
|
||||
- plugins.lifecycle
|
||||
secondary:
|
||||
- plugin-validation-and-repair
|
||||
- plugin-setup
|
||||
objective: Exercise strict plugin load/uninstall proof parsing through QA Lab evidence.
|
||||
successCriteria:
|
||||
- Enabled loaded plugin inspect JSON is accepted as proof.
|
||||
- Pending or missing inspect JSON is rejected instead of treated as loaded.
|
||||
- Malformed config during uninstall proof fails with a bounded diagnostic.
|
||||
docsRefs:
|
||||
- docs/plugins/manifest.md
|
||||
- docs/cli/plugins.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
|
||||
summary: Vitest coverage for plugin lifecycle proof parsing.
|
||||
28
qa/scenarios/runtime/package-openclaw-for-docker.yaml
Normal file
28
qa/scenarios/runtime/package-openclaw-for-docker.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
title: Docker package artifact QA evidence
|
||||
|
||||
scenario:
|
||||
id: package-openclaw-for-docker
|
||||
surface: docker-podman-hosting
|
||||
coverage:
|
||||
primary:
|
||||
- docker-e2e-package-artifact-generation
|
||||
secondary:
|
||||
- package-manager-installs
|
||||
- runtime.package-update
|
||||
objective: Exercise bounded OpenClaw package artifact generation through QA Lab evidence.
|
||||
successCriteria:
|
||||
- Package artifact output flags are parsed strictly.
|
||||
- The Docker package path uses the single bounded build-all step before npm pack.
|
||||
- Changelog trimming is restored after successful and failed ignore-scripts packaging.
|
||||
- Timed-out and externally terminated child process groups are cleaned up without leaked descendants.
|
||||
- Captured command output is bounded.
|
||||
docsRefs:
|
||||
- docs/install/updating.md
|
||||
- docs/help/testing.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts
|
||||
summary: Vitest coverage for Docker package artifact creation and cleanup behavior.
|
||||
@@ -1,159 +0,0 @@
|
||||
// Probe script for plugin lifecycle matrix E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
|
||||
|
||||
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 readRequiredJson(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`failed to read JSON from ${file}: ${message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function records() {
|
||||
return readPluginInstallRecords();
|
||||
}
|
||||
|
||||
function recordFor(pluginId) {
|
||||
return records()[pluginId];
|
||||
}
|
||||
|
||||
function config() {
|
||||
return readJson(process.env.OPENCLAW_CONFIG_PATH ?? openclawPath("openclaw.json"));
|
||||
}
|
||||
|
||||
function requiredConfig() {
|
||||
return readRequiredJson(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 assertNpmProjectRoot(pluginId, packageName) {
|
||||
const record = recordFor(pluginId);
|
||||
assert(record?.installPath, `install path missing for ${pluginId}`);
|
||||
const relative = path.relative(openclawPath("npm", "projects"), record.installPath);
|
||||
assert(
|
||||
!relative.startsWith("..") && !path.isAbsolute(relative),
|
||||
`install path outside npm projects: ${record.installPath}`,
|
||||
);
|
||||
const segments = relative.split(path.sep);
|
||||
const packageSegments = packageName.split("/");
|
||||
assert(
|
||||
segments.length === 2 + packageSegments.length,
|
||||
`unexpected npm project install path: ${record.installPath}`,
|
||||
);
|
||||
assert(Boolean(segments[0]), `missing npm project directory: ${record.installPath}`);
|
||||
assert(
|
||||
segments[1] === "node_modules",
|
||||
`missing project node_modules segment: ${record.installPath}`,
|
||||
);
|
||||
for (let index = 0; index < packageSegments.length; index++) {
|
||||
assert(
|
||||
segments[index + 2] === packageSegments[index],
|
||||
`package path mismatch: ${record.installPath}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
!fs.existsSync(openclawPath("npm", "node_modules", ...packageSegments)),
|
||||
`legacy flat npm install path exists for ${packageName}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertInspectLoaded(pluginId, inspectPath) {
|
||||
assert(inspectPath, "inspect JSON path is required");
|
||||
const inspect = readRequiredJson(inspectPath);
|
||||
const plugin = inspect.plugin;
|
||||
assert(plugin?.id === pluginId, `expected inspected plugin id ${pluginId}, got ${plugin?.id}`);
|
||||
assert(plugin.enabled === true, `expected ${pluginId} inspect enabled=true`);
|
||||
assert(
|
||||
plugin.status === "loaded",
|
||||
`expected ${pluginId} inspect status loaded, got ${plugin.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
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 = requiredConfig();
|
||||
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-npm-project-root":
|
||||
assertNpmProjectRoot(pluginId, arg);
|
||||
break;
|
||||
case "assert-inspect-loaded":
|
||||
assertInspectLoaded(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>"}`);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/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="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-matrix.XXXXXX")"
|
||||
pack_root=""
|
||||
registry_root=""
|
||||
tarball_v1="$resource_dir/lifecycle-claw-1.0.0.tgz"
|
||||
tarball_v2="$resource_dir/lifecycle-claw-2.0.0.tgz"
|
||||
inspect_v1="$resource_dir/plugin-lifecycle-inspect-v1.json"
|
||||
|
||||
cleanup() {
|
||||
openclaw_plugins_cleanup_fixture_servers
|
||||
rm -rf "$resource_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
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 "$resource_dir/pack.XXXXXX")"
|
||||
registry_root="$(mktemp -d "$resource_dir/registry.XXXXXX")"
|
||||
pack_fixture_plugin "$pack_root/v1" "$tarball_v1" "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
|
||||
pack_fixture_plugin "$pack_root/v2" "$tarball_v2" "$plugin_id" 2.0.0 lifecycle.v2 "Lifecycle Claw"
|
||||
start_npm_fixture_registry "$package_name" 1.0.0 "$tarball_v1" "$registry_root" "$package_name" 2.0.0 "$tarball_v2"
|
||||
trap cleanup EXIT
|
||||
|
||||
run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0"
|
||||
node "$probe" assert-version "$plugin_id" 1.0.0
|
||||
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
|
||||
|
||||
run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >"$3"' bash "$entry" "$plugin_id" "$inspect_v1"
|
||||
node "$probe" assert-inspect-loaded "$plugin_id" "$inspect_v1"
|
||||
|
||||
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
|
||||
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
|
||||
|
||||
run_measured downgrade-v1 node "$entry" plugins update "$package_name@1.0.0"
|
||||
node "$probe" assert-version "$plugin_id" 1.0.0
|
||||
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
|
||||
|
||||
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."
|
||||
@@ -17,12 +17,10 @@ PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz plugin-lifecycle-matrix "${OPENCLA
|
||||
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)"
|
||||
DOCKER_ENV_ARGS=(
|
||||
-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"
|
||||
)
|
||||
if [ -n "${OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS:-}" ]; then
|
||||
DOCKER_ENV_ARGS+=(-e "OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS=$OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS")
|
||||
@@ -45,6 +43,6 @@ docker_e2e_run_with_harness \
|
||||
"${DOCKER_ENV_ARGS[@]}" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
"$IMAGE_NAME" \
|
||||
bash scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh
|
||||
tsx test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts --lifecycle-matrix
|
||||
|
||||
echo "Plugin lifecycle matrix Docker E2E passed."
|
||||
|
||||
@@ -251,6 +251,8 @@ docker_e2e_harness_mount_args() {
|
||||
DOCKER_E2E_HARNESS_ARGS=(
|
||||
-v "$ROOT_DIR/scripts/e2e:/app/scripts/e2e:ro"
|
||||
-v "$ROOT_DIR/scripts/lib:/app/scripts/lib:ro"
|
||||
-v "$ROOT_DIR/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"
|
||||
-v "$ROOT_DIR/test/helpers:/app/test/helpers:ro"
|
||||
-v "$ROOT_DIR/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -592,7 +592,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["scripts/package-changelog.mjs", ["test/scripts/package-changelog.test.ts"]],
|
||||
["scripts/package-mac-app.sh", ["test/scripts/package-mac-app.test.ts"]],
|
||||
["scripts/package-mac-dist.sh", ["test/scripts/package-mac-dist.test.ts"]],
|
||||
["scripts/package-openclaw-for-docker.mjs", ["test/scripts/package-openclaw-for-docker.test.ts"]],
|
||||
[
|
||||
"scripts/package-openclaw-for-docker.mjs",
|
||||
["test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],
|
||||
],
|
||||
["scripts/postinstall-bundled-plugins.mjs", ["test/scripts/postinstall-bundled-plugins.test.ts"]],
|
||||
["scripts/prepare-git-hooks.mjs", ["test/scripts/prepare-git-hooks.test.ts"]],
|
||||
[
|
||||
@@ -648,14 +651,6 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
"scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs",
|
||||
["test/scripts/plugin-lifecycle-measure.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs",
|
||||
["test/scripts/plugin-lifecycle-probe.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh",
|
||||
["test/scripts/plugin-lifecycle-probe.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/release-media-memory-docker.sh",
|
||||
["test/scripts/docker-e2e-plan.test.ts", "test/scripts/release-media-memory-scenario.test.ts"],
|
||||
|
||||
@@ -897,7 +897,10 @@ describe("test-projects args", () => {
|
||||
},
|
||||
{
|
||||
config: "test/vitest/vitest.e2e.config.ts",
|
||||
forwardedArgs: ["test/openclaw-launcher.e2e.test.ts"],
|
||||
forwardedArgs: [
|
||||
"test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts",
|
||||
"test/openclaw-launcher.e2e.test.ts",
|
||||
],
|
||||
includePatterns: null,
|
||||
watchMode: false,
|
||||
},
|
||||
|
||||
552
test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts
Normal file
552
test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
// Plugin Lifecycle Probe tests cover QA Lab plugin lifecycle evidence.
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import fs, { mkdirSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { readPluginInstallRecords } from "../../../../scripts/e2e/lib/plugin-index-sqlite.mjs";
|
||||
import { createTempDirTracker } from "../../../helpers/temp-dir.js";
|
||||
|
||||
const tempDirs = createTempDirTracker();
|
||||
|
||||
function makeTempDir(): string {
|
||||
return tempDirs.make("openclaw-plugin-lifecycle-probe-");
|
||||
}
|
||||
|
||||
type ProbeEnv = Pick<NodeJS.ProcessEnv, "HOME" | "OPENCLAW_CONFIG_PATH" | "OPENCLAW_STATE_DIR">;
|
||||
|
||||
type MatrixEnv = NodeJS.ProcessEnv & ProbeEnv;
|
||||
|
||||
interface CommandOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
outputFile?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface RegistryServer {
|
||||
env: NodeJS.ProcessEnv;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
function stateDir(env: ProbeEnv = process.env) {
|
||||
return env.OPENCLAW_STATE_DIR || path.join(env.HOME ?? os.homedir(), ".openclaw");
|
||||
}
|
||||
|
||||
function configPath(env: ProbeEnv = process.env) {
|
||||
return env.OPENCLAW_CONFIG_PATH || path.join(stateDir(env), "openclaw.json");
|
||||
}
|
||||
|
||||
function readJson(file: string) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readRequiredJson(file: string) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`failed to read JSON from ${file}: ${message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function records(env: ProbeEnv = process.env) {
|
||||
return readPluginInstallRecords({
|
||||
configPath: configPath(env),
|
||||
stateDir: stateDir(env),
|
||||
}) as Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function recordFor(pluginId: string, env: ProbeEnv = process.env) {
|
||||
return records(env)[pluginId];
|
||||
}
|
||||
|
||||
function config(env: ProbeEnv = process.env) {
|
||||
return readJson(configPath(env));
|
||||
}
|
||||
|
||||
function requiredConfig(env: ProbeEnv = process.env) {
|
||||
return readRequiredJson(configPath(env));
|
||||
}
|
||||
|
||||
function assertProbe(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertVersion(pluginId: string, version: string, env: ProbeEnv = process.env) {
|
||||
const record = recordFor(pluginId, env);
|
||||
assertProbe(record, `install record missing for ${pluginId}`);
|
||||
assertProbe(record.source === "npm", `expected npm source for ${pluginId}, got ${record.source}`);
|
||||
assertProbe(
|
||||
record.resolvedVersion === version || record.version === version,
|
||||
`expected ${pluginId} record version ${version}, got ${JSON.stringify(record)}`,
|
||||
);
|
||||
assertProbe(record.installPath, `install path missing for ${pluginId}`);
|
||||
const packageJson = readJson(path.join(String(record.installPath), "package.json"));
|
||||
assertProbe(
|
||||
packageJson.version === version,
|
||||
`expected installed package version ${version}, got ${packageJson.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertNpmProjectRoot(pluginId: string, packageName: string, env: ProbeEnv = process.env) {
|
||||
const record = recordFor(pluginId, env);
|
||||
assertProbe(record?.installPath, `install path missing for ${pluginId}`);
|
||||
const installPath = String(record.installPath);
|
||||
const relative = path.relative(path.join(stateDir(env), "npm", "projects"), installPath);
|
||||
assertProbe(
|
||||
!relative.startsWith("..") && !path.isAbsolute(relative),
|
||||
`install path outside npm projects: ${installPath}`,
|
||||
);
|
||||
const segments = relative.split(path.sep);
|
||||
const packageSegments = packageName.split("/");
|
||||
assertProbe(
|
||||
segments.length === 2 + packageSegments.length,
|
||||
`unexpected npm project install path: ${installPath}`,
|
||||
);
|
||||
assertProbe(Boolean(segments[0]), `missing npm project directory: ${installPath}`);
|
||||
assertProbe(
|
||||
segments[1] === "node_modules",
|
||||
`missing project node_modules segment: ${installPath}`,
|
||||
);
|
||||
for (let index = 0; index < packageSegments.length; index++) {
|
||||
assertProbe(
|
||||
segments[index + 2] === packageSegments[index],
|
||||
`package path mismatch: ${installPath}`,
|
||||
);
|
||||
}
|
||||
assertProbe(
|
||||
!fs.existsSync(path.join(stateDir(env), "npm", "node_modules", ...packageSegments)),
|
||||
`legacy flat npm install path exists for ${packageName}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function assertInspectLoaded(pluginId: string, inspectPath: string | undefined) {
|
||||
assertProbe(inspectPath, "inspect JSON path is required");
|
||||
const inspect = readRequiredJson(inspectPath);
|
||||
const plugin = inspect.plugin as
|
||||
| { enabled?: boolean; id?: string; status?: string }
|
||||
| null
|
||||
| undefined;
|
||||
assertProbe(
|
||||
plugin?.id === pluginId,
|
||||
`expected inspected plugin id ${pluginId}, got ${plugin?.id}`,
|
||||
);
|
||||
assertProbe(plugin.enabled === true, `expected ${pluginId} inspect enabled=true`);
|
||||
assertProbe(
|
||||
plugin.status === "loaded",
|
||||
`expected ${pluginId} inspect status loaded, got ${plugin.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertEnabled(pluginId: string, expected: boolean, env: ProbeEnv = process.env) {
|
||||
const cfg = config(env) as {
|
||||
plugins?: { entries?: Record<string, { enabled?: boolean }> };
|
||||
};
|
||||
const entry = cfg.plugins?.entries?.[pluginId];
|
||||
assertProbe(entry?.enabled === expected, `expected ${pluginId} enabled=${expected}`);
|
||||
}
|
||||
|
||||
function installPath(pluginId: string, env: ProbeEnv = process.env) {
|
||||
const record = recordFor(pluginId, env);
|
||||
assertProbe(record?.installPath, `install path missing for ${pluginId}`);
|
||||
return String(record.installPath);
|
||||
}
|
||||
|
||||
export function assertUninstalled(pluginId: string, env: ProbeEnv = process.env) {
|
||||
const cfg = requiredConfig(env) as {
|
||||
plugins?: {
|
||||
allow?: string[];
|
||||
deny?: string[];
|
||||
entries?: Record<string, unknown>;
|
||||
load?: { paths?: unknown[] };
|
||||
};
|
||||
};
|
||||
const record = recordFor(pluginId, env);
|
||||
assertProbe(!record, `install record still present for ${pluginId}`);
|
||||
assertProbe(
|
||||
!cfg.plugins?.entries?.[pluginId],
|
||||
`plugin config entry still present for ${pluginId}`,
|
||||
);
|
||||
assertProbe(
|
||||
!(cfg.plugins?.allow ?? []).includes(pluginId),
|
||||
`allowlist still contains ${pluginId}`,
|
||||
);
|
||||
assertProbe(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`);
|
||||
const loadPaths = cfg.plugins?.load?.paths ?? [];
|
||||
assertProbe(
|
||||
!loadPaths.some((entry) => String(entry).includes(pluginId)),
|
||||
`load path still references ${pluginId}: ${loadPaths.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseDurationMs(value: string | undefined, fallback: string) {
|
||||
const text = (value || fallback).trim();
|
||||
if (text === "0") {
|
||||
return undefined;
|
||||
}
|
||||
const match = /^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h)?$/u.exec(text);
|
||||
if (!match) {
|
||||
throw new Error(`unsupported duration value: ${text}`);
|
||||
}
|
||||
const amount = Number(match[1]);
|
||||
const unit = match[2] ?? "s";
|
||||
const multiplier = unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000;
|
||||
return Math.max(1, Math.ceil(amount * multiplier));
|
||||
}
|
||||
|
||||
function createMatrixStateEnv(resourceDir: string): MatrixEnv {
|
||||
const home = fs.mkdtempSync(path.join(resourceDir, "home."));
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const workspaceDir = path.join(home, "workspace");
|
||||
const configFile = path.join(stateDir, "openclaw.json");
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.mkdirSync(workspaceDir, { recursive: true });
|
||||
return {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
OPENCLAW_HOME: home,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_CONFIG_PATH: configFile,
|
||||
OPENCLAW_TEST_WORKSPACE_DIR: workspaceDir,
|
||||
OPENCLAW_AUTH_PROFILE_SECRET_KEY: randomBytes(32).toString("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function packageEntrypoint(prefix: string) {
|
||||
const packageRoot = path.join(prefix, "lib", "node_modules", "openclaw");
|
||||
for (const entry of ["dist/index.mjs", "dist/index.js"]) {
|
||||
const candidate = path.join(packageRoot, entry);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error(`OpenClaw package entrypoint not found under ${packageRoot}/dist/`);
|
||||
}
|
||||
|
||||
async function runCommand(command: string, args: readonly string[], options: CommandOptions = {}) {
|
||||
const outputFd =
|
||||
options.outputFile === undefined ? undefined : fs.openSync(options.outputFile, "a");
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: options.env ?? process.env,
|
||||
stdio: outputFd === undefined ? "inherit" : (["ignore", outputFd, outputFd] as const),
|
||||
});
|
||||
let settled = false;
|
||||
const timer =
|
||||
options.timeoutMs === undefined
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => child.kill("SIGKILL"), 2_000).unref();
|
||||
}, options.timeoutMs);
|
||||
timer?.unref();
|
||||
child.once("error", (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (code === 0 && !signal) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} ${args.join(" ")} failed with ${signal ?? `exit ${code}`}`));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (options.outputFile && fs.existsSync(options.outputFile)) {
|
||||
const log = fs.readFileSync(options.outputFile, "utf8");
|
||||
if (log.trim()) {
|
||||
process.stderr.write(`--- ${options.outputFile} ---\n${log}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (outputFd !== undefined) {
|
||||
fs.closeSync(outputFd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installOpenClawPackage(prefix: string, env: MatrixEnv) {
|
||||
const packageTgz = env.OPENCLAW_CURRENT_PACKAGE_TGZ;
|
||||
assertProbe(packageTgz, "OPENCLAW_CURRENT_PACKAGE_TGZ is required");
|
||||
const installLog = "/tmp/openclaw-plugin-lifecycle-install.log";
|
||||
process.stdout.write("Installing mounted OpenClaw package...\n");
|
||||
await runCommand(
|
||||
"npm",
|
||||
["install", "-g", "--prefix", prefix, packageTgz, "--no-fund", "--no-audit"],
|
||||
{
|
||||
env,
|
||||
outputFile: installLog,
|
||||
timeoutMs: parseDurationMs(env.OPENCLAW_E2E_NPM_INSTALL_TIMEOUT, "600s"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function packFixturePlugin(
|
||||
packDir: string,
|
||||
outputTgz: string,
|
||||
pluginId: string,
|
||||
version: string,
|
||||
method: string,
|
||||
name: string,
|
||||
) {
|
||||
const packageDir = path.join(packDir, "package");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
await runCommand("node", [
|
||||
"scripts/e2e/lib/fixture.mjs",
|
||||
"plugin",
|
||||
packageDir,
|
||||
pluginId,
|
||||
version,
|
||||
method,
|
||||
name,
|
||||
]);
|
||||
await runCommand("tar", ["-czf", outputTgz, "-C", packDir, "package"]);
|
||||
}
|
||||
|
||||
async function startNpmFixtureRegistry(
|
||||
registryRoot: string,
|
||||
packages: readonly [packageName: string, version: string, tarball: string][],
|
||||
env: MatrixEnv,
|
||||
): Promise<RegistryServer> {
|
||||
const serverLog = path.join(registryRoot, "npm-registry.log");
|
||||
const serverPortFile = path.join(registryRoot, "npm-registry-port");
|
||||
const logFd = fs.openSync(serverLog, "a");
|
||||
const child = spawn(
|
||||
"node",
|
||||
[
|
||||
"scripts/e2e/lib/plugins/npm-registry-server.mjs",
|
||||
serverPortFile,
|
||||
...packages.flatMap(([packageName, version, tarball]) => [packageName, version, tarball]),
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
},
|
||||
);
|
||||
fs.closeSync(logFd);
|
||||
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (fs.existsSync(serverPortFile) && fs.statSync(serverPortFile).size > 0) {
|
||||
const port = fs.readFileSync(serverPortFile, "utf8").trim();
|
||||
return {
|
||||
env: {
|
||||
...env,
|
||||
NPM_CONFIG_REGISTRY: `http://127.0.0.1:${port}`,
|
||||
},
|
||||
stop() {
|
||||
child.kill();
|
||||
},
|
||||
};
|
||||
}
|
||||
if (child.exitCode !== null) {
|
||||
const log = fs.existsSync(serverLog) ? fs.readFileSync(serverLog, "utf8") : "";
|
||||
throw new Error(`npm fixture registry exited early${log ? `\n${log}` : ""}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
child.kill();
|
||||
const log = fs.existsSync(serverLog) ? fs.readFileSync(serverLog, "utf8") : "";
|
||||
throw new Error(`timed out waiting for npm fixture registry${log ? `\n${log}` : ""}`);
|
||||
}
|
||||
|
||||
async function runMeasured(
|
||||
summaryTsv: string,
|
||||
phase: string,
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
env: MatrixEnv,
|
||||
) {
|
||||
process.stdout.write(`Running plugin lifecycle phase: ${phase}\n`);
|
||||
await runCommand(
|
||||
"node",
|
||||
[
|
||||
"scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs",
|
||||
summaryTsv,
|
||||
phase,
|
||||
"--",
|
||||
command,
|
||||
...args,
|
||||
],
|
||||
{ env },
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPluginLifecycleMatrix() {
|
||||
const pluginId = "lifecycle-claw";
|
||||
const packageName = "@openclaw/lifecycle-claw";
|
||||
const resourceDir = tempDirs.make("openclaw-plugin-lifecycle-matrix-");
|
||||
const npmPrefix = "/tmp/npm-prefix";
|
||||
const env = createMatrixStateEnv(resourceDir);
|
||||
const tarballV1 = path.join(resourceDir, "lifecycle-claw-1.0.0.tgz");
|
||||
const tarballV2 = path.join(resourceDir, "lifecycle-claw-2.0.0.tgz");
|
||||
const inspectV1 = path.join(resourceDir, "plugin-lifecycle-inspect-v1.json");
|
||||
const summaryTsv = path.join(resourceDir, "resource-summary.tsv");
|
||||
let registry: RegistryServer | undefined;
|
||||
|
||||
fs.writeFileSync(
|
||||
summaryTsv,
|
||||
"phase\tmax_rss_kb\tcpu_seconds\twall_ms\tcpu_core_ratio\tsignal\n",
|
||||
"utf8",
|
||||
);
|
||||
fs.rmSync(npmPrefix, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
await installOpenClawPackage(npmPrefix, env);
|
||||
const entry = packageEntrypoint(npmPrefix);
|
||||
const matrixEnv: MatrixEnv = {
|
||||
...env,
|
||||
PATH: `${path.join(npmPrefix, "bin")}:${env.PATH ?? ""}`,
|
||||
npm_config_audit: "false",
|
||||
npm_config_fund: "false",
|
||||
npm_config_loglevel: "error",
|
||||
};
|
||||
const packRoot = fs.mkdtempSync(path.join(resourceDir, "pack."));
|
||||
const registryRoot = fs.mkdtempSync(path.join(resourceDir, "registry."));
|
||||
await packFixturePlugin(
|
||||
path.join(packRoot, "v1"),
|
||||
tarballV1,
|
||||
pluginId,
|
||||
"1.0.0",
|
||||
"lifecycle.v1",
|
||||
"Lifecycle Claw",
|
||||
);
|
||||
await packFixturePlugin(
|
||||
path.join(packRoot, "v2"),
|
||||
tarballV2,
|
||||
pluginId,
|
||||
"2.0.0",
|
||||
"lifecycle.v2",
|
||||
"Lifecycle Claw",
|
||||
);
|
||||
registry = await startNpmFixtureRegistry(
|
||||
registryRoot,
|
||||
[
|
||||
[packageName, "1.0.0", tarballV1],
|
||||
[packageName, "2.0.0", tarballV2],
|
||||
],
|
||||
matrixEnv,
|
||||
);
|
||||
const runEnv = registry.env as MatrixEnv;
|
||||
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
"install-v1",
|
||||
"node",
|
||||
[entry, "plugins", "install", `npm:${packageName}@1.0.0`],
|
||||
runEnv,
|
||||
);
|
||||
assertVersion(pluginId, "1.0.0", runEnv);
|
||||
assertNpmProjectRoot(pluginId, packageName, runEnv);
|
||||
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
"inspect-v1",
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
'node "$1" plugins inspect "$2" --runtime --json >"$3"',
|
||||
"bash",
|
||||
entry,
|
||||
pluginId,
|
||||
inspectV1,
|
||||
],
|
||||
runEnv,
|
||||
);
|
||||
assertInspectLoaded(pluginId, inspectV1);
|
||||
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
"disable",
|
||||
"node",
|
||||
[entry, "plugins", "disable", pluginId],
|
||||
runEnv,
|
||||
);
|
||||
assertEnabled(pluginId, false, runEnv);
|
||||
|
||||
await runMeasured(summaryTsv, "enable", "node", [entry, "plugins", "enable", pluginId], runEnv);
|
||||
assertEnabled(pluginId, true, runEnv);
|
||||
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
"upgrade-v2",
|
||||
"node",
|
||||
[entry, "plugins", "update", `${packageName}@2.0.0`],
|
||||
runEnv,
|
||||
);
|
||||
assertVersion(pluginId, "2.0.0", runEnv);
|
||||
assertNpmProjectRoot(pluginId, packageName, runEnv);
|
||||
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
"downgrade-v1",
|
||||
"node",
|
||||
[entry, "plugins", "update", `${packageName}@1.0.0`],
|
||||
runEnv,
|
||||
);
|
||||
assertVersion(pluginId, "1.0.0", runEnv);
|
||||
assertNpmProjectRoot(pluginId, packageName, runEnv);
|
||||
|
||||
const installedPath = installPath(pluginId, runEnv);
|
||||
fs.rmSync(installedPath, { recursive: true, force: true });
|
||||
assertProbe(
|
||||
!fs.existsSync(installedPath),
|
||||
`failed to remove plugin code before missing-code uninstall: ${installedPath}`,
|
||||
);
|
||||
|
||||
await runMeasured(
|
||||
summaryTsv,
|
||||
"missing-code-uninstall",
|
||||
"node",
|
||||
[entry, "plugins", "uninstall", pluginId, "--force"],
|
||||
runEnv,
|
||||
);
|
||||
assertUninstalled(pluginId, runEnv);
|
||||
|
||||
process.stdout.write(
|
||||
`Plugin lifecycle resource summary:\n${fs.readFileSync(summaryTsv, "utf8")}`,
|
||||
);
|
||||
process.stdout.write("Plugin lifecycle matrix passed.\n");
|
||||
} finally {
|
||||
registry?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
const isLifecycleMatrixCli = process.argv[2] === "--lifecycle-matrix";
|
||||
|
||||
if (isLifecycleMatrixCli) {
|
||||
void (async () => {
|
||||
try {
|
||||
await runPluginLifecycleMatrix();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
tempDirs.cleanup();
|
||||
}
|
||||
})();
|
||||
}
|
||||
73
test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
Normal file
73
test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Plugin Lifecycle Probe tests cover QA Lab plugin lifecycle evidence.
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTempDirTracker } from "../../../helpers/temp-dir.js";
|
||||
import {
|
||||
assertInspectLoaded,
|
||||
assertUninstalled,
|
||||
parseDurationMs,
|
||||
} from "./plugin-lifecycle-probe-runtime.js";
|
||||
|
||||
const tempDirs = createTempDirTracker();
|
||||
|
||||
function makeTempDir(): string {
|
||||
return tempDirs.make("openclaw-plugin-lifecycle-probe-");
|
||||
}
|
||||
|
||||
afterEach(tempDirs.cleanup);
|
||||
|
||||
describe("plugin lifecycle matrix probe", () => {
|
||||
it("accepts inspect JSON for an enabled loaded plugin", async () => {
|
||||
const dir = makeTempDir();
|
||||
const inspectPath = path.join(dir, "inspect.json");
|
||||
writeFileSync(
|
||||
inspectPath,
|
||||
`${JSON.stringify({ plugin: { enabled: true, id: "lifecycle-claw", status: "loaded" } })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(() => assertInspectLoaded("lifecycle-claw", inspectPath)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects inspect JSON that does not prove the runtime loaded", async () => {
|
||||
const dir = makeTempDir();
|
||||
const inspectPath = path.join(dir, "inspect.json");
|
||||
writeFileSync(
|
||||
inspectPath,
|
||||
`${JSON.stringify({ plugin: { enabled: true, id: "lifecycle-claw", status: "pending" } })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(() => assertInspectLoaded("lifecycle-claw", inspectPath)).toThrow(
|
||||
"expected lifecycle-claw inspect status loaded, got pending",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing inspect JSON instead of treating it as an empty object", async () => {
|
||||
const dir = makeTempDir();
|
||||
const inspectPath = path.join(dir, "missing.json");
|
||||
|
||||
expect(() => assertInspectLoaded("lifecycle-claw", inspectPath)).toThrow(
|
||||
`failed to read JSON from ${inspectPath}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unreadable config during uninstall proof", async () => {
|
||||
const dir = makeTempDir();
|
||||
const configFile = path.join(dir, ".openclaw", "openclaw.json");
|
||||
mkdirSync(path.dirname(configFile), { recursive: true });
|
||||
writeFileSync(configFile, "{ malformed\n", "utf8");
|
||||
|
||||
expect(() =>
|
||||
assertUninstalled("lifecycle-claw", {
|
||||
HOME: dir,
|
||||
OPENCLAW_CONFIG_PATH: configFile,
|
||||
}),
|
||||
).toThrow(`failed to read JSON from ${configFile}`);
|
||||
});
|
||||
|
||||
it("preserves disabled npm install timeout semantics", () => {
|
||||
expect(parseDurationMs("0", "600s")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package Openclaw For Docker tests cover package openclaw for docker script behavior.
|
||||
// Package OpenClaw For Docker tests cover QA Lab package artifact evidence.
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
packOpenClawPackageForDocker,
|
||||
parseArgs,
|
||||
runCommandForTest,
|
||||
} from "../../scripts/package-openclaw-for-docker.mjs";
|
||||
} from "../../../../scripts/package-openclaw-for-docker.mjs";
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||
@@ -71,7 +71,6 @@ const PLUGIN_UPDATE_CORRUPT_SCENARIO_PATH =
|
||||
"scripts/e2e/lib/plugin-update/corrupt-update-scenario.sh";
|
||||
const PLUGIN_UPDATE_PROBE_PATH = "scripts/e2e/lib/plugin-update/probe.mjs";
|
||||
const PLUGIN_LIFECYCLE_MATRIX_DOCKER_E2E_PATH = "scripts/e2e/plugin-lifecycle-matrix-docker.sh";
|
||||
const PLUGIN_LIFECYCLE_MATRIX_SWEEP_PATH = "scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh";
|
||||
const DOCTOR_SWITCH_DOCKER_E2E_PATH = "scripts/e2e/doctor-install-switch-docker.sh";
|
||||
const DOCTOR_SWITCH_SCENARIO_PATH = "scripts/e2e/lib/doctor-install-switch/scenario.sh";
|
||||
const PACKAGE_COMPAT_PATH = "scripts/e2e/lib/package-compat.mjs";
|
||||
@@ -1511,27 +1510,6 @@ grep -qx -- "OPENCLAW_E2E_COMMAND_TIMEOUT=23s" "$TMPDIR/package-args"
|
||||
expect(runner).toContain('docker_e2e_run_with_harness \\\n "${DOCKER_ENV_ARGS[@]}"');
|
||||
});
|
||||
|
||||
it("cleans plugin lifecycle matrix temp roots on exit", () => {
|
||||
const sweep = readFileSync(PLUGIN_LIFECYCLE_MATRIX_SWEEP_PATH, "utf8");
|
||||
|
||||
expect(sweep).toContain("cleanup() {");
|
||||
expect(sweep).toContain("openclaw_plugins_cleanup_fixture_servers");
|
||||
expect(sweep).toContain(
|
||||
'resource_dir="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-matrix.XXXXXX")"',
|
||||
);
|
||||
expect(sweep).toContain('tarball_v1="$resource_dir/lifecycle-claw-1.0.0.tgz"');
|
||||
expect(sweep).toContain('tarball_v2="$resource_dir/lifecycle-claw-2.0.0.tgz"');
|
||||
expect(sweep).toContain('inspect_v1="$resource_dir/plugin-lifecycle-inspect-v1.json"');
|
||||
expect(sweep).toContain('pack_root="$(mktemp -d "$resource_dir/pack.XXXXXX")"');
|
||||
expect(sweep).toContain('registry_root="$(mktemp -d "$resource_dir/registry.XXXXXX")"');
|
||||
expect(sweep).toContain('rm -rf "$resource_dir"');
|
||||
expect(sweep).not.toContain('resource_dir="/tmp/openclaw-plugin-lifecycle-matrix"');
|
||||
expect(sweep).not.toContain("/tmp/lifecycle-claw-1.0.0.tgz");
|
||||
expect(sweep).not.toContain("/tmp/lifecycle-claw-2.0.0.tgz");
|
||||
expect(sweep).not.toContain("/tmp/plugin-lifecycle-inspect-v1.json");
|
||||
expect(sweep.match(/trap cleanup EXIT/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("wraps direct Docker E2E npm installs with the shared timeout helper", () => {
|
||||
const multiNode = readFileSync(MULTI_NODE_UPDATE_DOCKER_E2E_PATH, "utf8");
|
||||
const updateChannel = readFileSync(UPDATE_CHANNEL_SWITCH_DOCKER_E2E_PATH, "utf8");
|
||||
@@ -2660,6 +2638,8 @@ output="$(cat "$sampler_log")"
|
||||
expect(helper).toContain(
|
||||
'-v "$ROOT_DIR/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"',
|
||||
);
|
||||
expect(helper).toContain('-v "$ROOT_DIR/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"');
|
||||
expect(helper).toContain('-v "$ROOT_DIR/test/helpers:/app/test/helpers:ro"');
|
||||
});
|
||||
|
||||
it("preserves pnpm lookup paths for scheduled Docker child lanes", () => {
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// Plugin Lifecycle Probe tests cover plugin lifecycle probe script behavior.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const probePath = "scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs";
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "openclaw-plugin-lifecycle-probe-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function runProbe(args: string[], home = makeTempDir()) {
|
||||
return spawnSync(process.execPath, [probePath, ...args], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
OPENCLAW_CONFIG_PATH: path.join(home, ".openclaw", "openclaw.json"),
|
||||
USERPROFILE: home,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("plugin lifecycle matrix probe", () => {
|
||||
it("accepts inspect JSON for an enabled loaded plugin", () => {
|
||||
const dir = makeTempDir();
|
||||
const inspectPath = path.join(dir, "inspect.json");
|
||||
writeFileSync(
|
||||
inspectPath,
|
||||
`${JSON.stringify({ plugin: { enabled: true, id: "lifecycle-claw", status: "loaded" } })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = runProbe(["assert-inspect-loaded", "lifecycle-claw", inspectPath], dir);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects inspect JSON that does not prove the runtime loaded", () => {
|
||||
const dir = makeTempDir();
|
||||
const inspectPath = path.join(dir, "inspect.json");
|
||||
writeFileSync(
|
||||
inspectPath,
|
||||
`${JSON.stringify({ plugin: { enabled: true, id: "lifecycle-claw", status: "pending" } })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = runProbe(["assert-inspect-loaded", "lifecycle-claw", inspectPath], dir);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("expected lifecycle-claw inspect status loaded, got pending");
|
||||
});
|
||||
|
||||
it("rejects missing inspect JSON instead of treating it as an empty object", () => {
|
||||
const dir = makeTempDir();
|
||||
const inspectPath = path.join(dir, "missing.json");
|
||||
|
||||
const result = runProbe(["assert-inspect-loaded", "lifecycle-claw", inspectPath], dir);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain(`failed to read JSON from ${inspectPath}`);
|
||||
});
|
||||
|
||||
it("rejects unreadable config during uninstall proof", () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, ".openclaw", "openclaw.json");
|
||||
mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
writeFileSync(configPath, "{ malformed\n", "utf8");
|
||||
|
||||
const result = runProbe(["assert-uninstalled", "lifecycle-claw"], dir);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain(`failed to read JSON from ${configPath}`);
|
||||
});
|
||||
});
|
||||
@@ -608,7 +608,7 @@ describe("scripts/test-projects changed-target routing", () => {
|
||||
["scripts/generate-npm-shrinkwrap.mjs", ["test/scripts/generate-npm-shrinkwrap.test.ts"]],
|
||||
[
|
||||
"scripts/package-openclaw-for-docker.mjs",
|
||||
["test/scripts/package-openclaw-for-docker.test.ts"],
|
||||
["test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],
|
||||
],
|
||||
["scripts/ios-run.sh", ["test/scripts/ios-run.test.ts"]],
|
||||
["scripts/create-dmg.sh", ["test/scripts/create-dmg.test.ts"]],
|
||||
|
||||
Reference in New Issue
Block a user