mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
223 lines
6.8 KiB
TypeScript
223 lines
6.8 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
// Release prepare tests cover shadow planning, cutover commands, and candidate manifests.
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildReleasePreparationManifest,
|
|
createReleasePrepareSteps,
|
|
parseReleasePrepareArgs,
|
|
readWorktreeState,
|
|
runReleasePrepareSteps,
|
|
} from "../../scripts/release-prepare.ts";
|
|
|
|
function worktreeState(
|
|
overrides: Partial<{
|
|
changedFiles: string[];
|
|
fingerprint: string;
|
|
head: string;
|
|
packageVersion: string;
|
|
status: string;
|
|
}> = {},
|
|
) {
|
|
return {
|
|
changedFiles: [],
|
|
fingerprint: "f".repeat(64),
|
|
head: "a".repeat(40),
|
|
packageVersion: "2026.6.11",
|
|
status: "",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("release preparation arguments", () => {
|
|
it("defaults to non-mutating shadow mode", () => {
|
|
expect(parseReleasePrepareArgs(["--version", "2026.7.2-beta.1"])).toMatchObject({
|
|
android: false,
|
|
jobs: 4,
|
|
mode: "shadow",
|
|
version: "2026.7.2-beta.1",
|
|
});
|
|
});
|
|
|
|
it("rejects ambiguous modes and invalid concurrency", () => {
|
|
expect(() => parseReleasePrepareArgs(["--version", "2026.7.2", "--check", "--write"])).toThrow(
|
|
"Use only one mode flag",
|
|
);
|
|
expect(() => parseReleasePrepareArgs(["--version", "2026.7.2", "--jobs", "17"])).toThrow(
|
|
"Expected 1 through 16",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("release preparation plan", () => {
|
|
it("builds the write cutover from atomic versioning and scoped preflight", () => {
|
|
const steps = createReleasePrepareSteps({
|
|
android: true,
|
|
jobs: 6,
|
|
mode: "write",
|
|
rootDir: "/repo",
|
|
version: "2026.7.2-beta.1",
|
|
});
|
|
|
|
expect(expectDefined(steps[0], "release version preparation step").args).toEqual([
|
|
"--import",
|
|
"tsx",
|
|
"scripts/release-version.ts",
|
|
"--root",
|
|
"/repo",
|
|
"--version",
|
|
"2026.7.2-beta.1",
|
|
"--android",
|
|
"--write",
|
|
]);
|
|
expect(expectDefined(steps[1], "release preflight preparation step").args).toEqual([
|
|
"scripts/release-preflight.mjs",
|
|
"--fix",
|
|
"--scope",
|
|
"version",
|
|
"--jobs",
|
|
"6",
|
|
]);
|
|
});
|
|
|
|
it("does not execute commands in shadow mode", () => {
|
|
const steps = createReleasePrepareSteps({
|
|
android: false,
|
|
jobs: 4,
|
|
mode: "shadow",
|
|
rootDir: "/repo",
|
|
version: "2026.7.2",
|
|
});
|
|
let calls = 0;
|
|
const results = runReleasePrepareSteps({
|
|
cwd: "/repo",
|
|
mode: "shadow",
|
|
runStep: () => {
|
|
calls += 1;
|
|
return 0;
|
|
},
|
|
steps,
|
|
});
|
|
|
|
expect(calls).toBe(0);
|
|
expect(results.map((result) => result.status)).toEqual(["planned", "planned"]);
|
|
});
|
|
|
|
it("stops after a failed prerequisite and records the blocked step", () => {
|
|
const steps = createReleasePrepareSteps({
|
|
android: false,
|
|
jobs: 4,
|
|
mode: "check",
|
|
rootDir: "/repo",
|
|
version: "2026.7.2",
|
|
});
|
|
const results = runReleasePrepareSteps({
|
|
cwd: "/repo",
|
|
mode: "check",
|
|
runStep: () => 1,
|
|
steps,
|
|
});
|
|
|
|
expect(results.map((result) => result.status)).toEqual(["failed", "skipped"]);
|
|
});
|
|
|
|
it("streams large JSON-mode child output to stderr without buffering", () => {
|
|
const childScript = [
|
|
'const { writeSync } = require("node:fs");',
|
|
'writeSync(1, "child stdout begin\\n" + "x".repeat(2 * 1024 * 1024) + "\\nchild stdout end\\n");',
|
|
'writeSync(2, "child stderr sentinel\\n");',
|
|
"process.exit(23);",
|
|
].join("");
|
|
const harness = `
|
|
import { runReleasePrepareStep } from ${JSON.stringify(new URL("../../scripts/release-prepare.ts", import.meta.url).href)};
|
|
const status = runReleasePrepareStep(
|
|
{ args: ["-e", ${JSON.stringify(childScript)}], command: process.execPath, id: "release-version", name: "JSON child" },
|
|
process.cwd(),
|
|
{ json: true },
|
|
);
|
|
process.exitCode = status;
|
|
`;
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
["--import", "tsx", "--input-type=module", "-e", harness],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf8",
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status).toBe(23);
|
|
expect(result.stdout).toBe("");
|
|
expect(result.stderr).toContain("[release-prepare] JSON child");
|
|
expect(result.stderr).toContain("child stdout begin");
|
|
expect(result.stderr).toContain("child stdout end");
|
|
expect(result.stderr).toContain("child stderr sentinel");
|
|
expect(Buffer.byteLength(result.stderr)).toBeGreaterThan(2 * 1024 * 1024);
|
|
});
|
|
});
|
|
|
|
describe("release preparation manifest", () => {
|
|
it("fingerprints generated diffs larger than Node's default child buffer", () => {
|
|
const rootDir = mkdtempSync(path.join(tmpdir(), "openclaw-release-prepare-"));
|
|
try {
|
|
execFileSync("git", ["init", "-q"], { cwd: rootDir });
|
|
execFileSync("git", ["config", "user.email", "release-test@openclaw.invalid"], {
|
|
cwd: rootDir,
|
|
});
|
|
execFileSync("git", ["config", "user.name", "OpenClaw Release Test"], { cwd: rootDir });
|
|
writeFileSync(path.join(rootDir, "package.json"), '{"version":"2026.7.2"}\n');
|
|
writeFileSync(path.join(rootDir, "generated.txt"), `${"a".repeat(2 * 1024 * 1024)}\n`);
|
|
execFileSync("git", ["add", "."], { cwd: rootDir });
|
|
execFileSync("git", ["commit", "-q", "-m", "test fixture"], { cwd: rootDir });
|
|
writeFileSync(path.join(rootDir, "generated.txt"), `${"b".repeat(2 * 1024 * 1024)}\n`);
|
|
|
|
const state = readWorktreeState(rootDir);
|
|
|
|
expect(state.changedFiles).toEqual(["generated.txt"]);
|
|
expect(state.fingerprint).toMatch(/^[0-9a-f]{64}$/u);
|
|
} finally {
|
|
rmSync(rootDir, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
it("binds the plan to the exact source and worktree fingerprint", () => {
|
|
const steps = runReleasePrepareSteps({
|
|
cwd: "/repo",
|
|
mode: "shadow",
|
|
steps: createReleasePrepareSteps({
|
|
android: false,
|
|
jobs: 4,
|
|
mode: "shadow",
|
|
rootDir: "/repo",
|
|
version: "2026.7.2",
|
|
}),
|
|
});
|
|
const manifest = buildReleasePreparationManifest({
|
|
after: worktreeState({
|
|
changedFiles: ["package.json"],
|
|
fingerprint: "b".repeat(64),
|
|
}),
|
|
before: worktreeState(),
|
|
mode: "shadow",
|
|
steps,
|
|
version: "2026.7.2",
|
|
});
|
|
|
|
expect(manifest).toMatchObject({
|
|
schemaVersion: 1,
|
|
requestedVersion: "2026.7.2",
|
|
mode: "shadow",
|
|
status: "shadow",
|
|
sourceHead: "a".repeat(40),
|
|
candidateFingerprint: "b".repeat(64),
|
|
});
|
|
expect(manifest.steps.map((step) => step.status)).toEqual(["planned", "planned"]);
|
|
});
|
|
});
|