mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(plugins): pretag pack checks stop hanging on stalled commands (#111349)
* fix(plugins): use maintained runner for pretag checks * fix(scripts): preserve pretag cancellation status and prove cleanup * test(release): exercise pretag pack timeout proof * test(release): isolate pretag proof source metadata * test(release): seed prebuilt runtime for pack proof * test(release): precompute pack manifest metadata * test(release): drive pack proof through ClawHub CLI * test(release): use portable hanging pack fixture * test(release): allow pretag process proof startup * test(release): prove runtime build process cleanup * test(release): keep asset proof outside runtime entries * test(release): complete ClawHub pack proof fixture * fix(release): budget each plugin runtime build * test(release): isolate managed process tree proof * test(plugins): prove pretag caller cleans stalled trees --------- Co-authored-by: Jason (Json) <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
parent
33e8e3c30f
commit
119520731c
3 changed files with 681 additions and 92 deletions
|
|
@ -1,14 +1,17 @@
|
|||
#!/usr/bin/env -S node --import tsx
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { delimiter, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { hasUnjoinedWork, runManagedCommand } from "./lib/managed-child-process.mts";
|
||||
import { collectClawHubPublishablePluginPackages } from "./lib/plugin-clawhub-release.ts";
|
||||
import { collectPublishablePluginPackages } from "./lib/plugin-npm-release.ts";
|
||||
|
||||
const DEFAULT_CLAWHUB_CLI_PACKAGE = "clawhub@0.23.3";
|
||||
// Match the existing npm release-check and shrinkwrap command 10-minute ceilings: complete
|
||||
// plugin builds and packs retain their normal budget while lifecycle or registry stalls stop.
|
||||
const PLUGIN_RELEASE_PRETAG_COMMAND_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
type PluginReleasePretagPackTarget = {
|
||||
packageDir: string;
|
||||
|
|
@ -17,6 +20,21 @@ type PluginReleasePretagPackTarget = {
|
|||
packNpm: boolean;
|
||||
};
|
||||
|
||||
/** Preserve conventional managed-command exit statuses at the executable boundary. */
|
||||
export function pluginReleasePretagExitCode(error: unknown): number {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
typeof error.code === "number" &&
|
||||
Number.isInteger(error.code) &&
|
||||
error.code >= 0 &&
|
||||
error.code <= 255
|
||||
) {
|
||||
return error.code;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function collectPluginReleasePretagPackTargets(
|
||||
rootDir = resolve("."),
|
||||
): PluginReleasePretagPackTarget[] {
|
||||
|
|
@ -45,19 +63,52 @@ export function collectPluginReleasePretagPackTargets(
|
|||
);
|
||||
}
|
||||
|
||||
function runCommand(
|
||||
async function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
params: { cwd: string; env?: NodeJS.ProcessEnv; quietStdout?: boolean },
|
||||
) {
|
||||
execFileSync(command, args, {
|
||||
cwd: params.cwd,
|
||||
env: params.env ?? process.env,
|
||||
stdio: params.quietStdout ? ["inherit", "ignore", "inherit"] : "inherit",
|
||||
});
|
||||
params: {
|
||||
commandLabel: string;
|
||||
cwd: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
quietStdout?: boolean;
|
||||
stage: string;
|
||||
timeoutMs: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
let status: number;
|
||||
try {
|
||||
status = await runManagedCommand({
|
||||
args,
|
||||
bin: command,
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
shell: false,
|
||||
requireProcessTreeExit: process.platform !== "win32",
|
||||
stdio: params.quietStdout ? ["inherit", "ignore", "inherit"] : "inherit",
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ETIMEDOUT")) {
|
||||
throw error;
|
||||
}
|
||||
throw Object.assign(
|
||||
new Error(`${params.stage} timed out after ${params.timeoutMs}ms: ${params.commandLabel}`),
|
||||
{ code: "ETIMEDOUT" as const },
|
||||
);
|
||||
}
|
||||
if (status !== 0) {
|
||||
throw Object.assign(
|
||||
new Error(`${params.stage} failed with exit code ${status}: ${params.commandLabel}`),
|
||||
{ code: status },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function runPluginReleasePretagPackCheck(rootDir = resolve(".")) {
|
||||
export async function runPluginReleasePretagPackCheck(
|
||||
rootDir = resolve("."),
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<void> {
|
||||
const timeoutMs = options.timeoutMs ?? PLUGIN_RELEASE_PRETAG_COMMAND_TIMEOUT_MS;
|
||||
const targets = collectPluginReleasePretagPackTargets(rootDir);
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-plugin-pretag-pack-"));
|
||||
const wrapperDir = join(tempRoot, "bin");
|
||||
|
|
@ -74,58 +125,87 @@ export function runPluginReleasePretagPackCheck(rootDir = resolve(".")) {
|
|||
);
|
||||
chmodSync(clawHubWrapper, 0o755);
|
||||
|
||||
let unjoinedWork = false;
|
||||
try {
|
||||
runCommand(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/check-plugin-npm-runtime-builds.mts",
|
||||
...targets.flatMap((target) => ["--package", target.packageDir]),
|
||||
],
|
||||
{
|
||||
cwd: rootDir,
|
||||
},
|
||||
);
|
||||
|
||||
const packEnv = {
|
||||
...process.env,
|
||||
CLAWHUB_CLI_PACKAGE: process.env.CLAWHUB_CLI_PACKAGE?.trim() || DEFAULT_CLAWHUB_CLI_PACKAGE,
|
||||
PATH: `${wrapperDir}:${process.env.PATH ?? ""}`,
|
||||
PATH: `${wrapperDir}${delimiter}${process.env.PATH ?? ""}`,
|
||||
};
|
||||
const prebuiltPackEnv = {
|
||||
...packEnv,
|
||||
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0",
|
||||
};
|
||||
for (const [index, target] of targets.entries()) {
|
||||
console.log(`plugin runtime build: ${target.packageName}`);
|
||||
await runCommand(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/check-plugin-npm-runtime-builds.mts",
|
||||
"--package",
|
||||
target.packageDir,
|
||||
],
|
||||
{
|
||||
commandLabel: `node --import tsx scripts/check-plugin-npm-runtime-builds.mts --package ${target.packageDir}`,
|
||||
cwd: rootDir,
|
||||
stage: `plugin runtime build for ${target.packageName}`,
|
||||
timeoutMs,
|
||||
},
|
||||
);
|
||||
if (target.packNpm) {
|
||||
console.log(`npm pack: ${target.packageName}`);
|
||||
runCommand("bash", ["scripts/plugin-npm-publish.sh", "--pack-dry-run", target.packageDir], {
|
||||
cwd: rootDir,
|
||||
env: prebuiltPackEnv,
|
||||
quietStdout: true,
|
||||
});
|
||||
await runCommand(
|
||||
"bash",
|
||||
["scripts/plugin-npm-publish.sh", "--pack-dry-run", target.packageDir],
|
||||
{
|
||||
commandLabel: `bash scripts/plugin-npm-publish.sh --pack-dry-run ${target.packageDir}`,
|
||||
cwd: rootDir,
|
||||
env: prebuiltPackEnv,
|
||||
quietStdout: true,
|
||||
stage: `npm pack for ${target.packageName}`,
|
||||
timeoutMs,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (target.packClawHub) {
|
||||
const outputDir = join(tempRoot, `clawhub-${index}`);
|
||||
console.log(`ClawHub pack: ${target.packageName}`);
|
||||
runCommand("bash", ["scripts/plugin-clawhub-publish.sh", "--pack", target.packageDir], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...prebuiltPackEnv,
|
||||
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: outputDir,
|
||||
await runCommand(
|
||||
"bash",
|
||||
["scripts/plugin-clawhub-publish.sh", "--pack", target.packageDir],
|
||||
{
|
||||
commandLabel: `bash scripts/plugin-clawhub-publish.sh --pack ${target.packageDir}`,
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...prebuiltPackEnv,
|
||||
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: outputDir,
|
||||
},
|
||||
quietStdout: true,
|
||||
stage: `ClawHub pack for ${target.packageName}`,
|
||||
timeoutMs,
|
||||
},
|
||||
quietStdout: true,
|
||||
});
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
unjoinedWork = hasUnjoinedWork(error);
|
||||
throw error;
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
if (!unjoinedWork) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`plugin-release-pretag-pack-check: packed ${targets.length} publishable plugins.`);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
runPluginReleasePretagPackCheck();
|
||||
try {
|
||||
await runPluginReleasePretagPackCheck();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = pluginReleasePretagExitCode(error);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,395 @@
|
|||
// This proof exercises the pretag caller against a real stalled runtime-build process tree.
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runPluginReleasePretagPackCheck } from "../../scripts/plugin-release-pretag-pack-check.ts";
|
||||
import { writePublishablePluginFixture } from "../helpers/publishable-plugin-fixture.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
import { writeJsonFile } from "../helpers/temp-repo.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
const posixIt = process.platform === "win32" ? it.skip : it;
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 1) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (process.platform !== "linux") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
// kill(pid, 0) also succeeds for a terminated process awaiting reaping.
|
||||
return stat.charAt(stat.lastIndexOf(")") + 2) !== "Z";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function killProcessIfAlive(pid: number): void {
|
||||
if (!isProcessAlive(pid)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// The managed runner may have reaped the fixture between the liveness check and signal.
|
||||
}
|
||||
}
|
||||
|
||||
function readPid(pidFile: string): number {
|
||||
return existsSync(pidFile) ? Number(readFileSync(pidFile, "utf8")) : 0;
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 5_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error("timed out waiting for proof fixture");
|
||||
}
|
||||
await delay(25);
|
||||
}
|
||||
}
|
||||
|
||||
function createProofRepo(): {
|
||||
descendantPidFile: string;
|
||||
directPidFile: string;
|
||||
repoDir: string;
|
||||
} {
|
||||
const repoDir = tempDirs.make("openclaw-plugin-pretag-proof-");
|
||||
const scriptsDir = join(repoDir, "scripts");
|
||||
const directPidFile = join(repoDir, "runtime-build.pid");
|
||||
const descendantPidFile = join(repoDir, "runtime-build-descendant.pid");
|
||||
mkdirSync(scriptsDir, { recursive: true });
|
||||
writeJsonFile(join(repoDir, "package.json"), { name: "openclaw-test-root", type: "module" });
|
||||
writePublishablePluginFixture(repoDir, {
|
||||
version: "2026.8.26",
|
||||
publishTo: "npm",
|
||||
});
|
||||
|
||||
// The production caller resolves the tsx loader from its cwd before launching this fixture.
|
||||
const nodeModulesDir = join(repoDir, "node_modules");
|
||||
mkdirSync(nodeModulesDir);
|
||||
symlinkSync(realpathSync(resolve("node_modules/tsx")), join(nodeModulesDir, "tsx"), "dir");
|
||||
writeFileSync(
|
||||
join(scriptsDir, "check-plugin-npm-runtime-builds.mts"),
|
||||
`import { spawn } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const descendant = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
writeFileSync(${JSON.stringify(directPidFile)}, String(process.pid));
|
||||
writeFileSync(${JSON.stringify(descendantPidFile)}, String(descendant.pid));
|
||||
setInterval(() => {}, 1000);
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
return { descendantPidFile, directPidFile, repoDir };
|
||||
}
|
||||
|
||||
describe("scripts/plugin-release-pretag-pack-check.ts process-tree proof", () => {
|
||||
posixIt(
|
||||
"bounds a stalled runtime build and leaves no process-tree descendant alive",
|
||||
async () => {
|
||||
const { descendantPidFile, directPidFile, repoDir } = createProofRepo();
|
||||
const timeoutMs = 2_000;
|
||||
let descendantPid = 0;
|
||||
let directPid = 0;
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await runPluginReleasePretagPackCheck(repoDir, { timeoutMs });
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
|
||||
expect(thrown).toMatchObject({
|
||||
code: "ETIMEDOUT",
|
||||
message:
|
||||
"plugin runtime build for @openclaw/demo-plugin timed out after 2000ms: node --import tsx scripts/check-plugin-npm-runtime-builds.mts --package extensions/demo-plugin",
|
||||
});
|
||||
expect(elapsedMs).toBeGreaterThanOrEqual(1_500);
|
||||
expect(elapsedMs).toBeLessThan(7_500);
|
||||
await waitFor(() => existsSync(directPidFile) && existsSync(descendantPidFile));
|
||||
directPid = readPid(directPidFile);
|
||||
descendantPid = readPid(descendantPidFile);
|
||||
expect(Number.isInteger(directPid) && directPid > 1).toBe(true);
|
||||
expect(Number.isInteger(descendantPid) && descendantPid > 1).toBe(true);
|
||||
await waitFor(() => !isProcessAlive(directPid) && !isProcessAlive(descendantPid));
|
||||
|
||||
const proof = {
|
||||
timeoutCode: (thrown as { code?: string }).code,
|
||||
elapsedMs,
|
||||
completionBounded: elapsedMs < 7_500,
|
||||
directExited: !isProcessAlive(directPid),
|
||||
descendantExited: !isProcessAlive(descendantPid),
|
||||
};
|
||||
console.log(`pretag-caller-process-tree-proof ${JSON.stringify(proof)}`);
|
||||
expect(proof).toMatchObject({
|
||||
timeoutCode: "ETIMEDOUT",
|
||||
completionBounded: true,
|
||||
directExited: true,
|
||||
descendantExited: true,
|
||||
});
|
||||
} finally {
|
||||
directPid ||= readPid(directPidFile);
|
||||
descendantPid ||= readPid(descendantPidFile);
|
||||
killProcessIfAlive(directPid);
|
||||
killProcessIfAlive(descendantPid);
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
posixIt(
|
||||
"rejects a build leader that exits successfully while a descendant remains",
|
||||
async () => {
|
||||
const { descendantPidFile, directPidFile, repoDir } = createProofRepo();
|
||||
writeFileSync(join(repoDir, "scripts/plugin-npm-publish.sh"), "#!/bin/bash\nexit 0\n");
|
||||
writeFileSync(
|
||||
join(repoDir, "scripts/check-plugin-npm-runtime-builds.mts"),
|
||||
`import { spawn } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
const descendant = spawn(process.execPath, ["-e", 'process.send("ready"); setInterval(() => {}, 1000);'], {
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
});
|
||||
descendant.once("message", () => {
|
||||
writeFileSync(${JSON.stringify(directPidFile)}, String(process.pid));
|
||||
writeFileSync(${JSON.stringify(descendantPidFile)}, String(descendant.pid));
|
||||
descendant.disconnect();
|
||||
descendant.unref();
|
||||
});
|
||||
`,
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
runPluginReleasePretagPackCheck(repoDir, { timeoutMs: 5_000 }),
|
||||
).rejects.toMatchObject({
|
||||
code: "EPROCESSGROUP_CLEANUP_FAILED",
|
||||
processTreeState: "terminated",
|
||||
});
|
||||
expect(readPid(descendantPidFile)).toBeGreaterThan(1);
|
||||
expect(isProcessAlive(readPid(directPidFile))).toBe(false);
|
||||
expect(isProcessAlive(readPid(descendantPidFile))).toBe(false);
|
||||
} finally {
|
||||
killProcessIfAlive(readPid(directPidFile));
|
||||
killProcessIfAlive(readPid(descendantPidFile));
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
});
|
||||
|
||||
function startProofCli(repoDir: string) {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../scripts/plugin-release-pretag-pack-check.ts", import.meta.url)),
|
||||
],
|
||||
{
|
||||
cwd: repoDir,
|
||||
env: { PATH: process.env.PATH, HOME: repoDir, TMPDIR: repoDir },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data;
|
||||
});
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data;
|
||||
});
|
||||
const completion = new Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>((resolveCompletion, reject) => {
|
||||
const deadline = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("pretag CLI fixture exceeded its outer safety deadline"));
|
||||
}, 15_000);
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(deadline);
|
||||
reject(error);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
clearTimeout(deadline);
|
||||
resolveCompletion({ code, signal, stdout, stderr });
|
||||
});
|
||||
});
|
||||
void completion.catch(() => {});
|
||||
return { child, completion };
|
||||
}
|
||||
|
||||
describe("pretag executable and per-stage deadlines", () => {
|
||||
for (const [signal, code] of [
|
||||
["SIGINT", 130],
|
||||
["SIGTERM", 143],
|
||||
] as const) {
|
||||
posixIt(
|
||||
`joins the build tree and returns ${code} for ${signal}`,
|
||||
async () => {
|
||||
const { descendantPidFile, directPidFile, repoDir } = createProofRepo();
|
||||
const cli = startProofCli(repoDir);
|
||||
try {
|
||||
await waitFor(() => readPid(directPidFile) > 1 && readPid(descendantPidFile) > 1);
|
||||
expect(isProcessAlive(readPid(directPidFile))).toBe(true);
|
||||
expect(isProcessAlive(readPid(descendantPidFile))).toBe(true);
|
||||
expect(cli.child.kill(signal)).toBe(true);
|
||||
const result = await cli.completion;
|
||||
expect(result).toMatchObject({ code, signal: null });
|
||||
expect(result.stderr).toContain(`failed with exit code ${code}`);
|
||||
expect(isProcessAlive(readPid(directPidFile))).toBe(false);
|
||||
expect(isProcessAlive(readPid(descendantPidFile))).toBe(false);
|
||||
expect(result.stdout).not.toContain("npm pack:");
|
||||
} finally {
|
||||
killProcessIfAlive(readPid(directPidFile));
|
||||
killProcessIfAlive(readPid(descendantPidFile));
|
||||
if (cli.child.exitCode === null && cli.child.signalCode === null) {
|
||||
cli.child.kill("SIGKILL");
|
||||
}
|
||||
await cli.completion.catch(() => {});
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
}
|
||||
|
||||
posixIt(
|
||||
"preserves a real build failure at the executable boundary and does not pack",
|
||||
async () => {
|
||||
const { repoDir } = createProofRepo();
|
||||
writeFileSync(
|
||||
join(repoDir, "scripts/check-plugin-npm-runtime-builds.mts"),
|
||||
"process.exit(7);\n",
|
||||
);
|
||||
const cli = startProofCli(repoDir);
|
||||
const result = await cli.completion;
|
||||
expect(result).toMatchObject({ code: 7, signal: null });
|
||||
expect(result.stderr).toContain("failed with exit code 7");
|
||||
expect(result.stdout).not.toContain("npm pack:");
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
|
||||
for (const stage of ["npm", "ClawHub"] as const) {
|
||||
posixIt(
|
||||
`bounds the real ${stage} pack command and joins its descendants`,
|
||||
async () => {
|
||||
const { repoDir, directPidFile, descendantPidFile } = createProofRepo();
|
||||
writePublishablePluginFixture(repoDir, { version: "2026.8.26", publishTo: "both" });
|
||||
const scriptsDir = join(repoDir, "scripts");
|
||||
writeFileSync(
|
||||
join(scriptsDir, "stall.mts"),
|
||||
readFileSync(join(scriptsDir, "check-plugin-npm-runtime-builds.mts")),
|
||||
);
|
||||
writeFileSync(
|
||||
join(scriptsDir, "check-plugin-npm-runtime-builds.mts"),
|
||||
"process.exit(0);\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(scriptsDir, "plugin-npm-publish.sh"),
|
||||
stage === "npm"
|
||||
? "#!/bin/bash\nexec node --import tsx scripts/stall.mts\n"
|
||||
: "#!/bin/bash\nexit 0\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(scriptsDir, "plugin-clawhub-publish.sh"),
|
||||
"#!/bin/bash\nexec node --import tsx scripts/stall.mts\n",
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
runPluginReleasePretagPackCheck(repoDir, { timeoutMs: 2_000 }),
|
||||
).rejects.toMatchObject({
|
||||
code: "ETIMEDOUT",
|
||||
message: expect.stringContaining(`${stage} pack for @openclaw/demo-plugin timed out`),
|
||||
});
|
||||
expect(readPid(descendantPidFile)).toBeGreaterThan(1);
|
||||
expect(isProcessAlive(readPid(directPidFile))).toBe(false);
|
||||
expect(isProcessAlive(readPid(descendantPidFile))).toBe(false);
|
||||
} finally {
|
||||
killProcessIfAlive(readPid(directPidFile));
|
||||
killProcessIfAlive(readPid(descendantPidFile));
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
}
|
||||
|
||||
posixIt(
|
||||
"lets two plugins complete real independent build and pack budgets",
|
||||
async () => {
|
||||
const { repoDir } = createProofRepo();
|
||||
for (const extensionId of ["demo-plugin", "second-plugin"]) {
|
||||
writePublishablePluginFixture(repoDir, {
|
||||
extensionId,
|
||||
version: "2026.8.26",
|
||||
publishTo: "both",
|
||||
});
|
||||
}
|
||||
const scriptsDir = join(repoDir, "scripts");
|
||||
const recordPath = join(repoDir, "stages.jsonl");
|
||||
const stageCode = `import { appendFileSync } from "node:fs";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
appendFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ args: process.argv.slice(2), prebuilt: process.env.OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD, outputDir: process.env.OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR }) + "\\n");
|
||||
await delay(600);
|
||||
`;
|
||||
writeFileSync(join(scriptsDir, "check-plugin-npm-runtime-builds.mts"), stageCode);
|
||||
writeFileSync(join(scriptsDir, "pack.mts"), stageCode);
|
||||
for (const name of ["plugin-npm-publish.sh", "plugin-clawhub-publish.sh"]) {
|
||||
writeFileSync(
|
||||
join(scriptsDir, name),
|
||||
'#!/bin/bash\nexec node --import tsx scripts/pack.mts "$@"\n',
|
||||
);
|
||||
}
|
||||
const started = Date.now();
|
||||
await runPluginReleasePretagPackCheck(repoDir, { timeoutMs: 2_000 });
|
||||
expect(Date.now() - started).toBeGreaterThan(2_000);
|
||||
const records = readFileSync(recordPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(
|
||||
(line) => JSON.parse(line) as { args: string[]; prebuilt?: string; outputDir?: string },
|
||||
);
|
||||
expect(records.map((record) => record.args)).toEqual([
|
||||
["--package", "extensions/demo-plugin"],
|
||||
["--pack-dry-run", "extensions/demo-plugin"],
|
||||
["--pack", "extensions/demo-plugin"],
|
||||
["--package", "extensions/second-plugin"],
|
||||
["--pack-dry-run", "extensions/second-plugin"],
|
||||
["--pack", "extensions/second-plugin"],
|
||||
]);
|
||||
for (const index of [1, 2, 4, 5]) {
|
||||
expect(records[index]?.prebuilt).toBe("0");
|
||||
}
|
||||
expect(records[2]?.outputDir).toContain("clawhub-0");
|
||||
expect(records[5]?.outputDir).toContain("clawhub-1");
|
||||
for (const index of [2, 5]) {
|
||||
expect(existsSync(records[index]!.outputDir!)).toBe(false);
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
|
@ -1,37 +1,35 @@
|
|||
// Plugin release pretag pack check tests cover its script-local target and command routing.
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectPluginReleasePretagPackTargets,
|
||||
pluginReleasePretagExitCode,
|
||||
runPluginReleasePretagPackCheck,
|
||||
} from "../../scripts/plugin-release-pretag-pack-check.ts";
|
||||
import { writePublishablePluginFixture } from "../helpers/publishable-plugin-fixture.js";
|
||||
import { cleanupTempDirs, makeTempDir as makeTempRepoRoot } from "../helpers/temp-dir.js";
|
||||
import { writeJsonFile } from "../helpers/temp-repo.js";
|
||||
|
||||
const { execFileSyncMock } = vi.hoisted(() => ({
|
||||
execFileSyncMock: vi.fn(),
|
||||
const { runManagedCommandMock } = vi.hoisted(() => ({
|
||||
runManagedCommandMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
vi.mock("../../scripts/lib/managed-child-process.mts", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../scripts/lib/managed-child-process.mts")
|
||||
>("../../scripts/lib/managed-child-process.mts");
|
||||
return {
|
||||
...actual,
|
||||
execFileSync: execFileSyncMock,
|
||||
runManagedCommand: runManagedCommandMock,
|
||||
};
|
||||
});
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
type ExecOptions = {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stdio?: unknown;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTempDirs(tempDirs);
|
||||
execFileSyncMock.mockReset();
|
||||
runManagedCommandMock.mockReset();
|
||||
});
|
||||
|
||||
function createDualPublishPluginRepo() {
|
||||
|
|
@ -44,10 +42,6 @@ function createDualPublishPluginRepo() {
|
|||
return repoDir;
|
||||
}
|
||||
|
||||
function callOptions(index: number): ExecOptions {
|
||||
return execFileSyncMock.mock.calls[index]?.[2] as ExecOptions;
|
||||
}
|
||||
|
||||
describe("scripts/plugin-release-pretag-pack-check.ts", () => {
|
||||
it("collects dual-published plugin targets for npm and ClawHub pack checks", () => {
|
||||
const repoDir = createDualPublishPluginRepo();
|
||||
|
|
@ -62,44 +56,164 @@ describe("scripts/plugin-release-pretag-pack-check.ts", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("runs runtime build, npm pack, and ClawHub pack commands for selected targets", () => {
|
||||
it("runs runtime build, npm pack, and ClawHub pack commands as managed process groups", async () => {
|
||||
const repoDir = createDualPublishPluginRepo();
|
||||
execFileSyncMock.mockImplementation(() => "");
|
||||
runManagedCommandMock.mockResolvedValue(0);
|
||||
|
||||
runPluginReleasePretagPackCheck(repoDir);
|
||||
await runPluginReleasePretagPackCheck(repoDir);
|
||||
|
||||
expect(execFileSyncMock).toHaveBeenCalledTimes(3);
|
||||
expect(execFileSyncMock.mock.calls[0]?.slice(0, 2)).toEqual([
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/check-plugin-npm-runtime-builds.mts",
|
||||
"--package",
|
||||
"extensions/demo-plugin",
|
||||
],
|
||||
]);
|
||||
expect(callOptions(0)).toMatchObject({ cwd: repoDir, stdio: "inherit" });
|
||||
|
||||
expect(execFileSyncMock.mock.calls[1]?.slice(0, 2)).toEqual([
|
||||
"bash",
|
||||
["scripts/plugin-npm-publish.sh", "--pack-dry-run", "extensions/demo-plugin"],
|
||||
]);
|
||||
expect(callOptions(1)).toMatchObject({
|
||||
cwd: repoDir,
|
||||
env: { OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0" },
|
||||
stdio: ["inherit", "ignore", "inherit"],
|
||||
});
|
||||
|
||||
expect(execFileSyncMock.mock.calls[2]?.slice(0, 2)).toEqual([
|
||||
"bash",
|
||||
["scripts/plugin-clawhub-publish.sh", "--pack", "extensions/demo-plugin"],
|
||||
]);
|
||||
expect(callOptions(2)).toMatchObject({
|
||||
cwd: repoDir,
|
||||
env: { OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0" },
|
||||
stdio: ["inherit", "ignore", "inherit"],
|
||||
});
|
||||
expect(callOptions(2).env?.OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR).toContain("clawhub-0");
|
||||
expect(runManagedCommandMock).toHaveBeenCalledTimes(3);
|
||||
expect(runManagedCommandMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
args: [
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/check-plugin-npm-runtime-builds.mts",
|
||||
"--package",
|
||||
"extensions/demo-plugin",
|
||||
],
|
||||
bin: process.execPath,
|
||||
cwd: repoDir,
|
||||
shell: false,
|
||||
requireProcessTreeExit: process.platform !== "win32",
|
||||
stdio: "inherit",
|
||||
timeoutMs: 600_000,
|
||||
}),
|
||||
);
|
||||
expect(runManagedCommandMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
args: ["scripts/plugin-npm-publish.sh", "--pack-dry-run", "extensions/demo-plugin"],
|
||||
bin: "bash",
|
||||
cwd: repoDir,
|
||||
env: expect.objectContaining({ OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0" }),
|
||||
stdio: ["inherit", "ignore", "inherit"],
|
||||
timeoutMs: 600_000,
|
||||
}),
|
||||
);
|
||||
expect(runManagedCommandMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
args: ["scripts/plugin-clawhub-publish.sh", "--pack", "extensions/demo-plugin"],
|
||||
bin: "bash",
|
||||
cwd: repoDir,
|
||||
env: expect.objectContaining({ OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0" }),
|
||||
stdio: ["inherit", "ignore", "inherit"],
|
||||
timeoutMs: 600_000,
|
||||
}),
|
||||
);
|
||||
const clawHubOptions = runManagedCommandMock.mock.calls[2]?.[0] as {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
expect(clawHubOptions.env?.OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR).toContain("clawhub-0");
|
||||
});
|
||||
|
||||
it("gives each selected runtime build its own managed deadline", async () => {
|
||||
const repoDir = createDualPublishPluginRepo();
|
||||
writePublishablePluginFixture(repoDir, {
|
||||
extensionId: "second-plugin",
|
||||
version: "2026.4.11",
|
||||
publishTo: "both",
|
||||
});
|
||||
runManagedCommandMock.mockResolvedValue(0);
|
||||
|
||||
await runPluginReleasePretagPackCheck(repoDir, { timeoutMs: 321 });
|
||||
|
||||
expect(runManagedCommandMock).toHaveBeenCalledTimes(6);
|
||||
expect(runManagedCommandMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
args: expect.arrayContaining(["--package", "extensions/demo-plugin"]),
|
||||
timeoutMs: 321,
|
||||
});
|
||||
expect(runManagedCommandMock.mock.calls[3]?.[0]).toMatchObject({
|
||||
args: expect.arrayContaining(["--package", "extensions/second-plugin"]),
|
||||
timeoutMs: 321,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies a caller-provided timeout to every managed command", async () => {
|
||||
const repoDir = createDualPublishPluginRepo();
|
||||
runManagedCommandMock.mockResolvedValue(0);
|
||||
|
||||
await runPluginReleasePretagPackCheck(repoDir, { timeoutMs: 321 });
|
||||
|
||||
expect(runManagedCommandMock).toHaveBeenCalledTimes(3);
|
||||
for (const [options] of runManagedCommandMock.mock.calls) {
|
||||
expect(options).toMatchObject({ timeoutMs: 321 });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves nonzero command failure semantics", async () => {
|
||||
const repoDir = createDualPublishPluginRepo();
|
||||
runManagedCommandMock.mockResolvedValueOnce(7);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await runPluginReleasePretagPackCheck(repoDir);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toMatchObject({ code: 7 });
|
||||
expect((thrown as Error).message).toBe(
|
||||
"plugin runtime build for @openclaw/demo-plugin failed with exit code 7: node --import tsx scripts/check-plugin-npm-runtime-builds.mts --package extensions/demo-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it("identifies the stalled release stage without exposing child details", async () => {
|
||||
const repoDir = createDualPublishPluginRepo();
|
||||
runManagedCommandMock.mockResolvedValueOnce(0).mockRejectedValueOnce(
|
||||
Object.assign(new Error("managed command ETIMEDOUT secret-marker"), {
|
||||
code: "ETIMEDOUT",
|
||||
}),
|
||||
);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await runPluginReleasePretagPackCheck(repoDir);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
expect(thrown).toMatchObject({ code: "ETIMEDOUT" });
|
||||
expect((thrown as Error).message).toBe(
|
||||
"npm pack for @openclaw/demo-plugin timed out after 600000ms: bash scripts/plugin-npm-publish.sh --pack-dry-run extensions/demo-plugin",
|
||||
);
|
||||
expect((thrown as Error).message).not.toContain("secret-marker");
|
||||
});
|
||||
|
||||
it("preserves managed cancellation statuses at the CLI boundary", () => {
|
||||
expect(
|
||||
pluginReleasePretagExitCode(Object.assign(new Error("interrupted"), { code: 130 })),
|
||||
).toBe(130);
|
||||
expect(pluginReleasePretagExitCode(Object.assign(new Error("terminated"), { code: 143 }))).toBe(
|
||||
143,
|
||||
);
|
||||
expect(
|
||||
pluginReleasePretagExitCode(Object.assign(new Error("timed out"), { code: "ETIMEDOUT" })),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it.each(["live", "indeterminate", "terminated"] as const)(
|
||||
"retains temporary inputs only when managed cleanup reports %s work",
|
||||
async (processTreeState) => {
|
||||
const repoDir = createDualPublishPluginRepo();
|
||||
runManagedCommandMock.mockResolvedValueOnce(0).mockRejectedValueOnce(
|
||||
Object.assign(new Error("managed cleanup failed"), {
|
||||
code: "EPROCESSGROUP_CLEANUP_FAILED",
|
||||
processTreeState,
|
||||
}),
|
||||
);
|
||||
await expect(runPluginReleasePretagPackCheck(repoDir)).rejects.toMatchObject({
|
||||
code: "EPROCESSGROUP_CLEANUP_FAILED",
|
||||
processTreeState,
|
||||
});
|
||||
const options = runManagedCommandMock.mock.calls[1]?.[0] as {
|
||||
env: NodeJS.ProcessEnv;
|
||||
};
|
||||
const wrapperDir = options.env.PATH!.split(delimiter)[0]!;
|
||||
const tempRoot = dirname(wrapperDir);
|
||||
tempDirs.push(tempRoot);
|
||||
expect(existsSync(tempRoot)).toBe(processTreeState !== "terminated");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue