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"]); }); });