From 0a6736af09f6c074436320de6eb3f6103b8343ea Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Wed, 17 Jun 2026 14:22:04 -0700 Subject: [PATCH] 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 --- .../qa-lab/src/scenario-catalog.test.ts | 11 +- .../plugins/plugin-lifecycle-probe.yaml | 26 + .../runtime/package-openclaw-for-docker.yaml | 28 + .../e2e/lib/plugin-lifecycle-matrix/probe.mjs | 159 ----- .../e2e/lib/plugin-lifecycle-matrix/sweep.sh | 86 --- scripts/e2e/plugin-lifecycle-matrix-docker.sh | 4 +- scripts/lib/docker-e2e-package.sh | 2 + scripts/test-projects.test-support.mjs | 13 +- src/scripts/test-projects.test.ts | 5 +- .../plugins/plugin-lifecycle-probe-runtime.ts | 552 ++++++++++++++++++ .../plugin-lifecycle-probe.e2e.test.ts | 73 +++ .../package-openclaw-for-docker.e2e.test.ts} | 4 +- test/scripts/docker-build-helper.test.ts | 24 +- test/scripts/plugin-lifecycle-probe.test.ts | 87 --- test/scripts/test-projects.test.ts | 2 +- 15 files changed, 704 insertions(+), 372 deletions(-) create mode 100644 qa/scenarios/plugins/plugin-lifecycle-probe.yaml create mode 100644 qa/scenarios/runtime/package-openclaw-for-docker.yaml delete mode 100644 scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs delete mode 100644 scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh create mode 100644 test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts create mode 100644 test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts rename test/{scripts/package-openclaw-for-docker.test.ts => e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts} (99%) delete mode 100644 test/scripts/plugin-lifecycle-probe.test.ts diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index 0175d47b6c8..e931e99af65 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -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") diff --git a/qa/scenarios/plugins/plugin-lifecycle-probe.yaml b/qa/scenarios/plugins/plugin-lifecycle-probe.yaml new file mode 100644 index 00000000000..e095d91771d --- /dev/null +++ b/qa/scenarios/plugins/plugin-lifecycle-probe.yaml @@ -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. diff --git a/qa/scenarios/runtime/package-openclaw-for-docker.yaml b/qa/scenarios/runtime/package-openclaw-for-docker.yaml new file mode 100644 index 00000000000..8589603d67c --- /dev/null +++ b/qa/scenarios/runtime/package-openclaw-for-docker.yaml @@ -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. diff --git a/scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs b/scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs deleted file mode 100644 index 779e47d5437..00000000000 --- a/scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs +++ /dev/null @@ -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 ?? ""}`); -} diff --git a/scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh b/scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh deleted file mode 100644 index 9fbc0ce544e..00000000000 --- a/scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh +++ /dev/null @@ -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." diff --git a/scripts/e2e/plugin-lifecycle-matrix-docker.sh b/scripts/e2e/plugin-lifecycle-matrix-docker.sh index c2b81f77734..438250003ac 100755 --- a/scripts/e2e/plugin-lifecycle-matrix-docker.sh +++ b/scripts/e2e/plugin-lifecycle-matrix-docker.sh @@ -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." diff --git a/scripts/lib/docker-e2e-package.sh b/scripts/lib/docker-e2e-package.sh index d32141c0a90..42fa8ba511d 100644 --- a/scripts/lib/docker-e2e-package.sh +++ b/scripts/lib/docker-e2e-package.sh @@ -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" ) } diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index a3b2f5d4936..ddf80180e4a 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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"], diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index 802bcf055aa..e1418faae2f 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.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, }, diff --git a/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts new file mode 100644 index 00000000000..9495650b577 --- /dev/null +++ b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts @@ -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; + +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; + } catch { + return {}; + } +} + +function readRequiredJson(file: string) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as Record; + } 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>; +} + +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 }; + }; + 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; + 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((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 { + 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(); + } + })(); +} diff --git a/test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts new file mode 100644 index 00000000000..d4f309d3e3d --- /dev/null +++ b/test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts @@ -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(); + }); +}); diff --git a/test/scripts/package-openclaw-for-docker.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts similarity index 99% rename from test/scripts/package-openclaw-for-docker.test.ts rename to test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index 13a65d6a5ce..546d785e8c6 100644 --- a/test/scripts/package-openclaw-for-docker.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -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) { diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 2bfa1cc655e..7d46b35a296 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -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", () => { diff --git a/test/scripts/plugin-lifecycle-probe.test.ts b/test/scripts/plugin-lifecycle-probe.test.ts deleted file mode 100644 index 35a452eb377..00000000000 --- a/test/scripts/plugin-lifecycle-probe.test.ts +++ /dev/null @@ -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}`); - }); -}); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 0b791376f9c..434ab922a26 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -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"]],