Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// ClawHub release candidate producer tests cover blocked script evidence output.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { validateQaEvidenceSummaryJson } from "../../../../extensions/qa-lab/api.js";
|
||||
|
||||
const SOURCE_PATH = "test/e2e/qa-lab/plugins/clawhub-release-candidate-install.ts";
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("ClawHub release candidate install producer", () => {
|
||||
it("writes blocked evidence when no candidate tarball is available", async () => {
|
||||
const artifactBase = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "openclaw-clawhub-release-evidence-"),
|
||||
);
|
||||
tempRoots.push(artifactBase);
|
||||
const missingTarballEnv = "OPENCLAW_TEST_MISSING_RELEASE_CANDIDATE_TARBALL";
|
||||
const env = { ...process.env };
|
||||
delete env[missingTarballEnv];
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
SOURCE_PATH,
|
||||
"--artifact-base",
|
||||
artifactBase,
|
||||
"--tarball-env",
|
||||
missingTarballEnv,
|
||||
],
|
||||
{ cwd: process.cwd(), encoding: "utf8", env },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const evidence = validateQaEvidenceSummaryJson(
|
||||
JSON.parse(await fs.readFile(path.join(artifactBase, "qa-evidence.json"), "utf8")),
|
||||
);
|
||||
expect(evidence.entries[0]).toMatchObject({
|
||||
execution: {
|
||||
artifacts: [{ kind: "log", path: "parallels-npm-update.log", source: "script" }],
|
||||
},
|
||||
result: {
|
||||
status: "blocked",
|
||||
failure: {
|
||||
reason: expect.stringContaining(`${missingTarballEnv} is not set`),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.stdout).toContain("ClawHub release-candidate install status: blocked");
|
||||
});
|
||||
});
|
||||
456
test/e2e/qa-lab/plugins/clawhub-release-candidate-install.ts
Normal file
456
test/e2e/qa-lab/plugins/clawhub-release-candidate-install.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
// Produces QA Lab evidence for release-candidate npm package install proof.
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
QA_EVIDENCE_FILENAME,
|
||||
type QaEvidenceSummaryJson,
|
||||
} from "../../../../extensions/qa-lab/api.js";
|
||||
import { createBoundedChildOutput } from "../../../helpers/bounded-child-output.js";
|
||||
import {
|
||||
createQaScriptBlockedStatusTracker,
|
||||
createQaScriptEvidenceWriter,
|
||||
type QaScriptEvidenceStatus,
|
||||
} from "../runtime/script-evidence.js";
|
||||
|
||||
const SCENARIO_ID = "clawhub-release-candidate-checklist";
|
||||
const SCENARIO_TITLE = "ClawHub release candidate npm package install proof";
|
||||
const SOURCE_PATH = "test/e2e/qa-lab/plugins/clawhub-release-candidate-install.ts";
|
||||
const COVERAGE_ID = "clawhub.npm-pack-local-release-candidate-installs";
|
||||
const DEFAULT_TARBALL_ENV = "OPENCLAW_QA_RELEASE_CANDIDATE_TARBALL";
|
||||
const CHECKOUT_BUILD_RESULT_PREFIX = "__OPENCLAW_QA_RELEASE_CANDIDATE_TARBALL__";
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CLAWHUB_BLOCKED_PREREQUISITE_PATTERNS = [
|
||||
/\bprlctl\b/i,
|
||||
/failed to detect parallels host ip/i,
|
||||
/vm .*not found/i,
|
||||
/could not resolve .*vm/i,
|
||||
/no .*vm/i,
|
||||
/parallels desktop .*not/i,
|
||||
/api key/i,
|
||||
/provider auth/i,
|
||||
];
|
||||
|
||||
type ProducerOptions = {
|
||||
artifactBase: string;
|
||||
buildFromCheckout: boolean;
|
||||
platform?: string;
|
||||
repoRoot: string;
|
||||
tarballEnv: string;
|
||||
};
|
||||
|
||||
type ParallelsSummary = {
|
||||
freshTarget?: Record<string, string>;
|
||||
freshTargetSpec?: string;
|
||||
update?: Record<string, { status?: string; version?: string }>;
|
||||
updateTargetPackageVersion?: string;
|
||||
updateTargetTarball?: string;
|
||||
};
|
||||
|
||||
type ProofResult = {
|
||||
artifacts?: Array<{ filePath: string; kind: string }>;
|
||||
details?: string;
|
||||
durationMs: number;
|
||||
status: QaScriptEvidenceStatus;
|
||||
};
|
||||
|
||||
class ParallelsProofError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly evidenceStatus: QaScriptEvidenceStatus,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ParallelsProofError";
|
||||
}
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return `Usage: node --import tsx ${SOURCE_PATH} --artifact-base <dir> [options]
|
||||
|
||||
Produces QA Lab evidence for ClawHub release-candidate package install proof.
|
||||
|
||||
Options:
|
||||
--artifact-base <dir> Evidence artifact directory
|
||||
--repo-root <dir> Repository root
|
||||
--tarball-env <name> Env var containing candidate .tgz path
|
||||
Default: ${DEFAULT_TARBALL_ENV}
|
||||
--build-from-checkout Build a candidate .tgz from this checkout when no
|
||||
tarball env is set
|
||||
--platform <list> Optional Parallels platform list passed through
|
||||
-h, --help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function readOptionValue(argv: readonly string[], index: number, arg: string) {
|
||||
const value = argv[index + 1] ?? "";
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new Error(`${arg} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOptions(
|
||||
argv: readonly string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ProducerOptions {
|
||||
let artifactBase = "";
|
||||
let buildFromCheckout = env.OPENCLAW_QA_RELEASE_CANDIDATE_BUILD === "1";
|
||||
let platform: string | undefined;
|
||||
let repoRoot = process.cwd();
|
||||
let tarballEnv = DEFAULT_TARBALL_ENV;
|
||||
const seen = new Set<string>();
|
||||
const recordOnce = (flag: string) => {
|
||||
if (seen.has(flag)) {
|
||||
throw new Error(`${flag} was provided more than once`);
|
||||
}
|
||||
seen.add(flag);
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "--artifact-base") {
|
||||
recordOnce(arg);
|
||||
artifactBase = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--repo-root") {
|
||||
recordOnce(arg);
|
||||
repoRoot = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tarball-env") {
|
||||
recordOnce(arg);
|
||||
tarballEnv = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--build-from-checkout") {
|
||||
recordOnce(arg);
|
||||
buildFromCheckout = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--platform") {
|
||||
recordOnce(arg);
|
||||
platform = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unsupported release-candidate install producer arg: ${arg}`);
|
||||
}
|
||||
|
||||
if (!artifactBase.trim()) {
|
||||
throw new Error("--artifact-base is required");
|
||||
}
|
||||
if (!tarballEnv.trim()) {
|
||||
throw new Error("--tarball-env requires a non-empty env var name");
|
||||
}
|
||||
return {
|
||||
artifactBase: path.resolve(repoRoot, artifactBase),
|
||||
buildFromCheckout,
|
||||
platform,
|
||||
repoRoot: path.resolve(repoRoot),
|
||||
tarballEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeJson(filePath: string, value: unknown) {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function resolveCandidateTarball(options: ProducerOptions) {
|
||||
const explicitTarball = process.env[options.tarballEnv]?.trim();
|
||||
if (explicitTarball) {
|
||||
return path.resolve(options.repoRoot, explicitTarball);
|
||||
}
|
||||
if (!options.buildFromCheckout) {
|
||||
return undefined;
|
||||
}
|
||||
return await buildCandidateTarballFromCheckout(options);
|
||||
}
|
||||
|
||||
async function buildCandidateTarballFromCheckout(options: ProducerOptions) {
|
||||
const destination = path.join(options.artifactBase, "package");
|
||||
await fs.mkdir(destination, { recursive: true });
|
||||
const evalScript = `
|
||||
import { packOpenClaw } from "./scripts/e2e/parallels/package-artifact.ts";
|
||||
const artifact = await packOpenClaw({ destination: ${JSON.stringify(destination)} });
|
||||
process.stdout.write(${JSON.stringify(CHECKOUT_BUILD_RESULT_PREFIX)} + JSON.stringify({ path: artifact.path }) + "\\n");
|
||||
`;
|
||||
const result = await execFileAsync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "--input-type=module", "--eval", evalScript],
|
||||
{
|
||||
cwd: options.repoRoot,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const resultLine = result.stdout
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith(CHECKOUT_BUILD_RESULT_PREFIX));
|
||||
if (!resultLine) {
|
||||
throw new Error("checkout package build did not report a tarball marker");
|
||||
}
|
||||
const parsed = JSON.parse(resultLine.slice(CHECKOUT_BUILD_RESULT_PREFIX.length)) as {
|
||||
path?: unknown;
|
||||
};
|
||||
if (typeof parsed.path !== "string" || !parsed.path.trim()) {
|
||||
throw new Error("checkout package build did not report a tarball path");
|
||||
}
|
||||
return parsed.path;
|
||||
}
|
||||
|
||||
async function extractPackageJsonFromTgz<T>(tgzPath: string, entry: string): Promise<T> {
|
||||
const result = await execFileAsync("tar", ["-xOf", tgzPath, entry], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return JSON.parse(result.stdout) as T;
|
||||
}
|
||||
|
||||
async function validateCandidateTarball(tarballPath: string) {
|
||||
const [version, buildCommit] = await Promise.all([
|
||||
extractPackageJsonFromTgz<{ version?: string }>(tarballPath, "package/package.json").then(
|
||||
(pkg) => pkg.version ?? "",
|
||||
),
|
||||
extractPackageJsonFromTgz<{ commit?: string }>(
|
||||
tarballPath,
|
||||
"package/dist/build-info.json",
|
||||
).then((info) => info.commit ?? ""),
|
||||
]);
|
||||
if (!version || !buildCommit) {
|
||||
throw new Error(`target tarball is missing package or build metadata: ${tarballPath}`);
|
||||
}
|
||||
return { buildCommit, version };
|
||||
}
|
||||
|
||||
async function runParallelsProof(params: {
|
||||
options: ProducerOptions;
|
||||
tarballPath: string;
|
||||
writer: ReturnType<typeof createClawHubEvidenceWriter>;
|
||||
}) {
|
||||
const args = [
|
||||
"scripts/e2e/parallels-npm-update-smoke.sh",
|
||||
"--target-tarball",
|
||||
params.tarballPath,
|
||||
"--json",
|
||||
];
|
||||
if (params.options.platform) {
|
||||
args.push("--platform", params.options.platform);
|
||||
}
|
||||
params.writer.appendLog(`$ bash ${args.map((arg) => JSON.stringify(arg)).join(" ")}\n`);
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn("bash", args, {
|
||||
cwd: params.options.repoRoot,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stdout = createBoundedChildOutput(1024 * 1024);
|
||||
const statusTracker = createQaScriptBlockedStatusTracker(CLAWHUB_BLOCKED_PREREQUISITE_PATTERNS);
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
params.writer.appendLog(chunk);
|
||||
stdout.append(chunk);
|
||||
statusTracker.append(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
params.writer.appendLog(chunk);
|
||||
statusTracker.append(chunk);
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (status, signal) => {
|
||||
const stdoutText = stdout.text();
|
||||
if (status === 0 && !signal) {
|
||||
resolve(stdoutText);
|
||||
return;
|
||||
}
|
||||
const reason = signal
|
||||
? `Parallels npm-update proof terminated by ${signal}`
|
||||
: `Parallels npm-update proof exited with ${status ?? 1}`;
|
||||
reject(new ParallelsProofError(reason, statusTracker.status()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseParallelsSummary(stdout: string): ParallelsSummary {
|
||||
try {
|
||||
return JSON.parse(stdout) as ParallelsSummary;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Parallels npm-update proof did not print JSON summary: ${formatErrorMessage(error)}`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requireHostedCandidateTarball(params: { summary: ParallelsSummary; tarballPath: string }) {
|
||||
const rawUrl = params.summary.updateTargetTarball?.trim();
|
||||
if (!rawUrl) {
|
||||
throw new Error("summary missing updateTargetTarball");
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`updateTargetTarball is not a URL: ${rawUrl}`, { cause: error });
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`updateTargetTarball is not hosted over HTTP(S): ${rawUrl}`);
|
||||
}
|
||||
const hostedName = decodeURIComponent(path.posix.basename(url.pathname));
|
||||
const expectedName = path.basename(params.tarballPath);
|
||||
if (hostedName !== expectedName) {
|
||||
throw new Error(
|
||||
`updateTargetTarball does not point at the candidate tarball: expected ${expectedName}, got ${hostedName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertParallelsSummary(params: {
|
||||
summary: ParallelsSummary;
|
||||
tarballPath: string;
|
||||
version: string;
|
||||
}) {
|
||||
requireHostedCandidateTarball(params);
|
||||
if (!params.summary.updateTargetPackageVersion) {
|
||||
throw new Error("summary missing updateTargetPackageVersion");
|
||||
}
|
||||
if (params.summary.updateTargetPackageVersion !== params.version) {
|
||||
throw new Error(
|
||||
`summary target version ${params.summary.updateTargetPackageVersion} does not match candidate ${params.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
const freshTargetPasses = Object.entries(params.summary.freshTarget ?? {}).filter(
|
||||
([, status]) => status === "pass",
|
||||
);
|
||||
if (freshTargetPasses.length === 0) {
|
||||
throw new Error("summary has no passing freshTarget platform");
|
||||
}
|
||||
|
||||
const updatePasses = Object.entries(params.summary.update ?? {}).filter(
|
||||
([, result]) => result?.status === "pass",
|
||||
);
|
||||
if (updatePasses.length === 0) {
|
||||
throw new Error("summary has no passing update platform");
|
||||
}
|
||||
}
|
||||
|
||||
function isBlockedPrerequisiteFailure(message: string) {
|
||||
return CLAWHUB_BLOCKED_PREREQUISITE_PATTERNS.some((pattern) => pattern.test(message));
|
||||
}
|
||||
|
||||
function createClawHubEvidenceWriter(options: ProducerOptions) {
|
||||
return createQaScriptEvidenceWriter({
|
||||
artifactBase: options.artifactBase,
|
||||
logFileName: "parallels-npm-update.log",
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
providerMode: "mock-openai",
|
||||
repoRoot: options.repoRoot,
|
||||
target: {
|
||||
id: SCENARIO_ID,
|
||||
title: SCENARIO_TITLE,
|
||||
sourcePath: SOURCE_PATH,
|
||||
primaryCoverageIds: [COVERAGE_ID],
|
||||
docsRefs: ["docs/help/testing.md", "docs/concepts/qa-e2e-automation.md"],
|
||||
codeRefs: [
|
||||
SOURCE_PATH,
|
||||
"scripts/e2e/parallels-npm-update-smoke.sh",
|
||||
"scripts/e2e/parallels/npm-update-smoke.ts",
|
||||
"test/scripts/release-candidate-checklist.test.ts",
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function produceProof(
|
||||
options: ProducerOptions,
|
||||
writer: ReturnType<typeof createClawHubEvidenceWriter>,
|
||||
): Promise<ProofResult> {
|
||||
const startedAt = Date.now();
|
||||
await fs.mkdir(options.artifactBase, { recursive: true });
|
||||
const summaryPath = path.join(options.artifactBase, "parallels-summary.json");
|
||||
|
||||
try {
|
||||
const tarballPath = await resolveCandidateTarball(options);
|
||||
if (!tarballPath) {
|
||||
return {
|
||||
details: `${options.tarballEnv} is not set; provide a candidate .tgz or pass --build-from-checkout.`,
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "blocked",
|
||||
};
|
||||
}
|
||||
await fs.access(tarballPath);
|
||||
const metadata = await validateCandidateTarball(tarballPath);
|
||||
writer.appendLog(
|
||||
`candidate: ${tarballPath}\nversion: ${metadata.version}\nbuild commit: ${metadata.buildCommit}\n`,
|
||||
);
|
||||
const commandOutput = await runParallelsProof({ options, tarballPath, writer });
|
||||
const summary = parseParallelsSummary(commandOutput);
|
||||
assertParallelsSummary({
|
||||
summary,
|
||||
tarballPath,
|
||||
version: metadata.version,
|
||||
});
|
||||
await writeJson(summaryPath, summary);
|
||||
return {
|
||||
artifacts: [{ kind: "summary", filePath: "parallels-summary.json" }],
|
||||
details: `candidate ${metadata.version} installed fresh and updated through Parallels npm semantics`,
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "pass",
|
||||
};
|
||||
} catch (error) {
|
||||
const details = formatErrorMessage(error);
|
||||
const status: QaScriptEvidenceStatus =
|
||||
error instanceof ParallelsProofError
|
||||
? error.evidenceStatus
|
||||
: isBlockedPrerequisiteFailure(details)
|
||||
? "blocked"
|
||||
: "fail";
|
||||
writer.appendLog(`\n${status}: ${details}\n`);
|
||||
return {
|
||||
details,
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClawHubReleaseCandidateInstallProducer(
|
||||
options: ProducerOptions,
|
||||
): Promise<QaEvidenceSummaryJson> {
|
||||
const writer = createClawHubEvidenceWriter(options);
|
||||
const result = await produceProof(options, writer);
|
||||
return await writer.write(result);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
runClawHubReleaseCandidateInstallProducer(parseOptions(process.argv.slice(2)))
|
||||
.then((evidence) => {
|
||||
console.log(`ClawHub release-candidate install evidence: ${QA_EVIDENCE_FILENAME}`);
|
||||
console.log(
|
||||
`ClawHub release-candidate install status: ${evidence.entries[0]?.result.status}`,
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(formatErrorMessage(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
650
test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts
Normal file
650
test/e2e/qa-lab/plugins/plugin-lifecycle-probe-runtime.ts
Normal file
@@ -0,0 +1,650 @@
|
||||
// Plugin Lifecycle Probe tests cover QA Lab plugin lifecycle evidence.
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { readPluginInstallRecords } from "../../../../scripts/e2e/lib/plugin-index-sqlite.mjs";
|
||||
import { resolveWindowsTaskkillPath } from "../../../../scripts/lib/windows-taskkill.mjs";
|
||||
|
||||
// The Docker entrypoint runs without Vitest installed, so keep cleanup local to this runtime probe.
|
||||
const tempDirs = (() => {
|
||||
const dirs = new Set<string>();
|
||||
return {
|
||||
make(prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
dirs.add(dir);
|
||||
return dir;
|
||||
},
|
||||
cleanup(): void {
|
||||
for (const dir of dirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
||||
}
|
||||
dirs.clear();
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
type ProbeEnv = Pick<NodeJS.ProcessEnv, "HOME" | "OPENCLAW_CONFIG_PATH" | "OPENCLAW_STATE_DIR">;
|
||||
|
||||
type MatrixEnv = NodeJS.ProcessEnv & ProbeEnv;
|
||||
|
||||
interface CommandOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
outputFile?: string;
|
||||
spawnImpl?: typeof spawn;
|
||||
taskkillImpl?: typeof spawnSync;
|
||||
timeoutKillGraceMs?: number;
|
||||
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 spawnImpl = options.spawnImpl ?? spawn;
|
||||
const useProcessGroup = process.platform !== "win32";
|
||||
const child = spawnImpl(command, args, {
|
||||
cwd: process.cwd(),
|
||||
detached: useProcessGroup,
|
||||
env: options.env ?? process.env,
|
||||
stdio: outputFd === undefined ? "inherit" : (["ignore", outputFd, outputFd] as const),
|
||||
});
|
||||
let settled = false;
|
||||
let forceKillTimer: NodeJS.Timeout | undefined;
|
||||
let forceSettleTimer: NodeJS.Timeout | undefined;
|
||||
let timeoutTimer: NodeJS.Timeout | undefined;
|
||||
let timeoutError: Error | undefined;
|
||||
const clearTimers = () => {
|
||||
if (timeoutTimer) {
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
}
|
||||
if (forceSettleTimer) {
|
||||
clearTimeout(forceSettleTimer);
|
||||
}
|
||||
};
|
||||
const signalChild = (signal: NodeJS.Signals) => {
|
||||
if (useProcessGroup && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// The process group may already be gone; fall back to the direct child.
|
||||
}
|
||||
}
|
||||
if (!useProcessGroup && child.pid) {
|
||||
const runTaskkill = options.taskkillImpl ?? spawnSync;
|
||||
const taskkillPath = resolveWindowsTaskkillPath();
|
||||
const args = ["/PID", String(child.pid), "/T"];
|
||||
if (signal === "SIGKILL") {
|
||||
args.push("/F");
|
||||
}
|
||||
const result = runTaskkill(taskkillPath, args, { stdio: "ignore", windowsHide: true });
|
||||
if (!result.error && result.status === 0) {
|
||||
return;
|
||||
}
|
||||
if (signal !== "SIGKILL") {
|
||||
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
if (!forceResult.error && forceResult.status === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
};
|
||||
const isProcessGroupRunning = () => {
|
||||
if (!useProcessGroup || !child.pid) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
};
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimers();
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
timeoutTimer =
|
||||
options.timeoutMs === undefined
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
timeoutError = new Error(
|
||||
`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`,
|
||||
);
|
||||
signalChild("SIGTERM");
|
||||
forceKillTimer = setTimeout(() => {
|
||||
forceKillTimer = undefined;
|
||||
signalChild("SIGKILL");
|
||||
forceSettleTimer = setTimeout(
|
||||
() => finish(timeoutError),
|
||||
options.timeoutKillGraceMs ?? 2_000,
|
||||
);
|
||||
forceSettleTimer.unref();
|
||||
}, options.timeoutKillGraceMs ?? 2_000);
|
||||
forceKillTimer.unref();
|
||||
}, options.timeoutMs);
|
||||
timeoutTimer?.unref();
|
||||
child.once("error", (error) => {
|
||||
finish(error);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (timeoutError) {
|
||||
if (isProcessGroupRunning()) {
|
||||
return;
|
||||
}
|
||||
finish(timeoutError);
|
||||
return;
|
||||
}
|
||||
if (code === 0 && !signal) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
finish(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();
|
||||
}
|
||||
}
|
||||
|
||||
export const testing = { runCommand };
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
}
|
||||
240
test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
Normal file
240
test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// Plugin Lifecycle Probe tests cover QA Lab plugin lifecycle evidence.
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveWindowsTaskkillPath } from "../../../../scripts/lib/windows-taskkill.mjs";
|
||||
import { createTempDirTracker } from "../../../helpers/temp-dir.js";
|
||||
import {
|
||||
assertInspectLoaded,
|
||||
assertUninstalled,
|
||||
parseDurationMs,
|
||||
testing as probeTesting,
|
||||
} from "./plugin-lifecycle-probe-runtime.js";
|
||||
|
||||
const tempDirs = createTempDirTracker();
|
||||
|
||||
function expectedTaskkillPath(): string {
|
||||
return resolveWindowsTaskkillPath();
|
||||
}
|
||||
|
||||
function makeTempDir(): string {
|
||||
return tempDirs.make("openclaw-plugin-lifecycle-probe-");
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForFile(pathToCheck: string, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(pathToCheck)) {
|
||||
return;
|
||||
}
|
||||
await sleep(25);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${pathToCheck}`);
|
||||
}
|
||||
|
||||
class FakeCommandChild extends EventEmitter {
|
||||
readonly signals: string[] = [];
|
||||
|
||||
kill(signal?: NodeJS.Signals | number): boolean {
|
||||
this.signals.push(String(signal));
|
||||
if (signal === "SIGTERM") {
|
||||
queueMicrotask(() => this.emit("exit", 0, null));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("rejects timed commands that exit cleanly during kill grace", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const child = new FakeCommandChild();
|
||||
const runPromise = probeTesting.runCommand("fake-command", ["install"], {
|
||||
spawnImpl: (() => child) as unknown as typeof import("node:child_process").spawn,
|
||||
timeoutKillGraceMs: 100,
|
||||
timeoutMs: 10,
|
||||
});
|
||||
const runError = runPromise.catch((error: unknown) => error);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
const error = await runError;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe("fake-command install timed out after 10ms");
|
||||
expect(child.signals).toEqual(["SIGTERM"]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(child.signals).toEqual(["SIGTERM"]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("force-kills timed Windows commands with taskkill when graceful taskkill fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
try {
|
||||
const child = Object.assign(new FakeCommandChild(), { pid: 12345 });
|
||||
const taskkillImpl = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1 })
|
||||
.mockImplementationOnce(() => {
|
||||
queueMicrotask(() => child.emit("exit", null, "SIGTERM"));
|
||||
return { status: 0 };
|
||||
});
|
||||
const runPromise = probeTesting.runCommand("fake-command", ["install"], {
|
||||
spawnImpl: (() => child) as unknown as typeof import("node:child_process").spawn,
|
||||
taskkillImpl,
|
||||
timeoutKillGraceMs: 100,
|
||||
timeoutMs: 10,
|
||||
});
|
||||
const runError = runPromise.catch((error: unknown) => error);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(taskkillImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expectedTaskkillPath(),
|
||||
["/PID", "12345", "/T"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
expect(taskkillImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expectedTaskkillPath(),
|
||||
["/PID", "12345", "/T", "/F"],
|
||||
{
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
expect(child.signals).toEqual([]);
|
||||
|
||||
const error = await runError;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe("fake-command install timed out after 10ms");
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps fallback SIGKILL armed for ignored-stdio descendants", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = makeTempDir();
|
||||
const descendantPidPath = path.join(dir, "descendant.pid");
|
||||
let descendantPid: number | undefined;
|
||||
try {
|
||||
const childScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);";
|
||||
const parentScript = [
|
||||
"import { spawn } from 'node:child_process';",
|
||||
"import { writeFileSync } from 'node:fs';",
|
||||
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
|
||||
"child.unref();",
|
||||
"writeFileSync(process.env.OPENCLAW_TEST_DESCENDANT_PID, String(child.pid));",
|
||||
"process.on('SIGTERM', () => process.exit(0));",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
|
||||
const run = probeTesting.runCommand(
|
||||
process.execPath,
|
||||
["--input-type=module", "-e", parentScript],
|
||||
{
|
||||
env: { ...process.env, OPENCLAW_TEST_DESCENDANT_PID: descendantPidPath },
|
||||
timeoutKillGraceMs: 250,
|
||||
timeoutMs: 500,
|
||||
},
|
||||
);
|
||||
await waitForFile(descendantPidPath, 2_000);
|
||||
await sleep(300);
|
||||
|
||||
await expect(run).rejects.toThrow(/timed out after 500ms/u);
|
||||
|
||||
descendantPid = Number(readFileSync(descendantPidPath, "utf8"));
|
||||
expect(isProcessRunning(descendantPid)).toBe(false);
|
||||
} finally {
|
||||
if (descendantPid && isProcessRunning(descendantPid)) {
|
||||
process.kill(descendantPid, "SIGKILL");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user