diff --git a/docs/ci/scope-and-routing/node-test-lanes.md b/docs/ci/scope-and-routing/node-test-lanes.md index b675e20b96a9..58a7307edad0 100644 --- a/docs/ci/scope-and-routing/node-test-lanes.md +++ b/docs/ci/scope-and-routing/node-test-lanes.md @@ -21,7 +21,8 @@ The slowest Node test families are split or balanced so each job stays small wit - A changed tooling test or owner selects the full canonical tooling family, including changes that the precise resolver could narrow to individual files. The tooling configs retain their file-parallelism policy, worker pins, runtime prerequisites, logical backend routing, and capacity promotion. Ordinary tooling prices admitted file workers while retaining the longest-file floor; Docker helper fixtures keep their separate serial config. They do not become generic changed-target jobs. The existing dist boundary and TUI descriptors retain their declared prerequisites. - Precisely resolved metadata-bearing files, including the four embedded-agent owners, select their canonical groups after config expansion and packing. They retain the original job's runner, worker/admission policy, preparation and conservative timing floor while removing unrelated groups and narrowing single-config groups to their selected files. The known Docker/isolated tooling pair retains both complete configs once, with its canonical timing identity. Subset timing identities cannot overwrite complete-parent observations. Ambiguous config ownership, native Vitest shard arguments and unproven dist/TUI selections still fall back. Test-only nondist selections retain the separate full boundary gate. - When canonical pull requests fall back to compact planning, directly changed, existing tests owned by the release-only plugin shard remain as exact-file selections in the existing compact jobs. Canonical Vitest routing keeps unit-fast, contract, bundled, and E2E tests with their own suites; source files, directories, deleted tests, and live tests do not widen plugin coverage. The broad `agentic-plugins` sweep remains release-only, and push and manual CI plans are unchanged. -- The planner's `RELEASE_ONLY_TOOLING_SHARDS` set and matching maintainer leaves in mixed fast configs defer the complete maintainer-tooling family on product-only PRs, in both precise and compact fallback plans. The tooling Vitest configs own the ordinary inventory and isolated/Docker catalogs. Maintainer leaves selected by fast configs retain their ordinary, isolated, or fake-timer owner and process pins; filtering mixed groups preserves product neighbors and gives the subsets separate timing identities. Dedicated product E2E and live tests, including the five `test/scripts/*.e2e.test.ts` gates, stay outside this tier. PRs touching a tooling test or owner run the full family: `scripts/**`, `src/scripts/**`, `test/**`, `.github/**`, `config/**`, root package/pnpm inputs, tooling configs, and other inputs classified as tooling by the shared changed-path owner in `scripts/test-projects.test-support.mts`. That owner also covers Docker, agent/Crabbox tooling, app scripts/Fastlane, and extension scripts/package inputs. Directly changed release-only tests therefore retain coverage on their own PRs. Every CI `workflow_dispatch` includes the family, including Full Release Validation's `normal_ci` child against the frozen candidate. Its `Run Node test shard` step runs these unchanged tests before regular publication admission; an independent duplicate release test is not required. The existing approved preflight-only beta publication exception remains unchanged. Plugin Prerelease separately owns `agentic-plugins`; CI's plugin exclusion does not control the tooling tier. Fork repositories keep full tooling because they do not use canonical PR targeting. Product tests retain their existing tiers except for the explicit runtime proof inventory below. +- The planner's `RELEASE_ONLY_TOOLING_SHARDS` set and matching maintainer leaves in mixed fast configs defer the complete maintainer-tooling family on product-only PRs, in both precise and compact fallback plans. The tooling Vitest configs own the ordinary inventory and isolated/Docker catalogs. Maintainer leaves selected by fast configs retain their ordinary, isolated, or fake-timer owner and process pins; filtering mixed groups preserves product neighbors and gives the subsets separate timing identities. Dedicated product E2E and live tests, including the five `test/scripts/*.e2e.test.ts` gates, stay outside this tier. PRs touching a tooling test or owner run the family, except the explicit process proofs below: `scripts/**`, `src/scripts/**`, `test/**`, `.github/**`, `config/**`, root package/pnpm inputs, tooling configs, and other inputs classified as tooling by the shared changed-path owner in `scripts/test-projects.test-support.mts`. That owner also covers Docker, agent/Crabbox tooling, app scripts/Fastlane, and extension scripts/package inputs. Directly changed tooling tests retain coverage on their own PRs except for the explicit process proofs. Every CI `workflow_dispatch` includes the family, including Full Release Validation's `normal_ci` child against the frozen candidate. Its `Run Node test shard` step runs these unchanged tests before regular publication admission; an independent duplicate release test is not required. The existing approved preflight-only beta publication exception remains unchanged. Plugin Prerelease separately owns `agentic-plugins`; CI's plugin exclusion does not control the tooling tier. Fork repositories keep full tooling because they do not use canonical PR targeting. Product tests retain their existing tiers except for the explicit runtime proof inventory below. +- The explicit `CI_PROOF_TEST_FILES` inventory keeps `test/scripts/frv.release.test.ts` and `test/scripts/install-ps1.release.test.ts` out of PR plans, including directly edited tests and compact fallback. Main already omits their tooling owner; manual CI and Full Release Validation retain both complete process tours in the canonical tooling config. FRV keeps its in-process continuation contracts in `frv.test.ts`; the installer keeps its source guards in `install-ps1.test.ts`. No cases or assertions are removed. - The explicit `RELEASE_ONLY_RUNTIME_TEST_FILES` inventory defers expensive native runtime proof files from canonical automatic PR and push plans. It includes the Doctor startup/rollback CLI and plugin-install process tours, the heap-limited session import, the native ACP execution and Git exact-state race tours, and the native lifecycle, launchd recovery, and systemd recovery handoff matrices. The existing release inventory remains: `src/flows/doctor-health.test.ts`, `src/infra/update-managed-service-handoff-foreground.test.ts`, `src/node-host/node-worker-supervisor.recovery.test.ts`, `src/state/openclaw-database-preflight.lifecycle.test.ts`, and all eight `src/config/state-startup-corpus*.test.ts` wrappers. The startup-corpus paths owner supplies the complete family in its existing partition order. The files keep their canonical configs, process isolation, runtime prerequisites, worker limits, cases, and assertions. Parallel command groups preserve the reduced coverage timing identity when applying worker policy, so automatic CI samples cannot overwrite full release estimates. Directly edited existing test files run on their PRs, including the former main-only Doctor refusal proof, compact fallback, and exact-head release-gate substitutes; source/helper changes, missing or deleted files, directories, and broad fallback do not admit the remaining release matrix. Manual CI, Full Release Validation's `normal_ci` child, local full plans, and noncanonical repositories retain the complete inventory. The release tier also keeps the separate `published-upgrade-survivor` Docker cell with the published baseline, `legacy-operator-state` scenario, and `auto-auth` restart mode; the synthetic foreground-handoff cases do not substitute for that published-driver × candidate proof. - `config-startup-corpus.test.ts` remains in automatic CI. The manifest resolves one startup-corpus inventory for both Node coverage receipts and the baseline-ratchets fallback: the regular config corpus plus directly edited state wrappers on automatic PRs, and the complete family on manual/release validation. A receipt covers that selected inventory on the exact checkout/workflow revision, not the deferred full matrix. The fallback still builds `qaRuntime` once and runs the selected files with at most four available workers. Older targets without selection capability keep their full split-file or legacy sharded fallback. Canonical main's full Node plan owns its regular corpus once. - Canonical `main` pushes use a Blacksmith integration compact with nondist Node jobs plus the dist boundary descriptor. Former multi-config walls (CLI plus CLI-process, isolated plus fake-timers unit fast, and the logging/process/runtime-config trio) are split into per-config shards so no single group floors a lane. Real Node+TSX command tests belong to the isolated CLI-process catalog, so ordinary CLI tests do not prepare a runtime. The process catalog splits by complete file costs, with split sizing bounded below by its complete file costs and runtime prerequisite so an older aggregate timing cannot hide newly owned work. File packing also includes the prerequisite; runtime-consuming CLI children may share one preparation in the same serial job when their complete combined estimate fits the existing 150-second budget. Each child retains its selected files, isolated process and two-worker limit; deliberately separated fixed stripe families remain apart. The gateway process file stays alone because its cold proof already takes 200 seconds. CLI children retain the 150-second sizing target and their two-worker limit. Ordinary hybrid bins containing only non-build CLI children may combine up to 250 predicted seconds, with each original child still admitted separately below 150 seconds; the hosted runtime prerequisite itself has a 160-second floor. They omit the low-signal-per-push tooling and TUI PTY groups while retaining product-runtime groups outside the explicit release tier, including three file-weighted stripes apiece for unit-src, Control UI, and gateway-core. Blacksmith serial admission stays at 200 seconds for the large class and 276 seconds for the small class. Ordinary groups that can share two process slots admit 360 predicted aggregate seconds; a group already above its serial cap stays alone. Manual dispatches and Full Release Validation retain the full named per-shard matrix. No scheduled workflow currently runs that full Node suite; this is a known coverage-timing gap, not coverage supplied by this compact plan. diff --git a/scripts/lib/ci-proof-test-inventory.mts b/scripts/lib/ci-proof-test-inventory.mts index f894e91da024..91be86eb0028 100644 --- a/scripts/lib/ci-proof-test-inventory.mts +++ b/scripts/lib/ci-proof-test-inventory.mts @@ -1,6 +1,7 @@ import { stateStartupCorpusTestFiles } from "../../test/vitest/vitest.startup-corpus-paths.mjs"; -// Complete process/lifecycle proofs run on main and release verification. +// Complete process/lifecycle proofs stay outside PR CI. Main retains runtime +// owners; manual/release validation also retains the tooling owner. // Keep this explicit: E2E-named package and browser boundary tests stay on PRs. export const CI_PROOF_TEST_FILES = [ "extensions/browser/src/browser/extension-install.native-host.e2e.test.ts", @@ -8,6 +9,8 @@ export const CI_PROOF_TEST_FILES = [ "test/e2e/qa-lab/plugins/discord-show-widget-contextual-presenter.e2e.test.ts", "test/e2e/qa-lab/runtime/sessions-send-visible-child.product-proof.e2e.test.ts", "test/scripts/doctor-config-preflight-plugin-index.built-cli.e2e.test.ts", + "test/scripts/frv.release.test.ts", + "test/scripts/install-ps1.release.test.ts", "test/scripts/sqlite-sessions-transcripts-flip-proof.built-cli.e2e.test.ts", "test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts", ] as const; diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index 662564711c97..b26d8bbe2834 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -3846,7 +3846,16 @@ describe("scripts/lib/ci-node-test-plan.mts", () => { toolingGroups.every((group) => group.configs[0] === "test/vitest/vitest.tooling.config.ts"), ).toBe(true); expect(new Set(toolingFiles).size).toBe(toolingFiles.length); - expect(toolingFiles.toSorted((a, b) => a.localeCompare(b))).toEqual(listAllToolingTestFiles()); + const allToolingFiles = listAllToolingTestFiles(); + expect(toolingFiles.toSorted((a, b) => a.localeCompare(b))).toEqual( + allToolingFiles.filter((file) => !isCiProofTestFile(file)), + ); + expect( + base + .filter((shard) => shard.configs[0] === "test/vitest/vitest.tooling.config.ts") + .flatMap((shard) => shard.includePatterns ?? []) + .toSorted((a, b) => a.localeCompare(b)), + ).toEqual(allToolingFiles); } it.each(plannerHosts)( "preserves coverage and execution policies with committed compact measurements ($label)", diff --git a/test/scripts/frv.release.test.ts b/test/scripts/frv.release.test.ts new file mode 100644 index 000000000000..2b15b00a87b9 --- /dev/null +++ b/test/scripts/frv.release.test.ts @@ -0,0 +1,1533 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import JSZip from "jszip"; +import { describe, expect, it } from "vitest"; +import { validateParentManifest } from "../../scripts/release-ci-summary.mjs"; +import { + SHA, + TARGET_SHA, + SOURCE_REF, + REPOSITORY, + job, + requiredChildren, + executionPlanArtifact, + historicalExecutionPlanArtifact, + runFor, + rootRun, +} from "./frv.test-support.js"; + +describe("FRV protected gh evidence reads", () => { + const jobLogArgs = [ + "api", + `repos/${REPOSITORY}/actions/jobs/1/logs`, + "-H", + "Cache-Control: max-age=0", + ]; + + it.each([ + ["getRun", ["101"], "actions/runs/101", { run_attempt: 2 }], + ["getRunAttempt", ["101", 2], "actions/runs/101/attempts/2", { run_attempt: 2 }], + [ + "getAttemptJobs", + ["101", 2], + "actions/runs/101/attempts/2/jobs?per_page=100", + [{ id: 1 }, { id: 2 }], + ], + [ + "getParentJobs", + ["77"], + "actions/runs/77/jobs?filter=all&per_page=100", + [{ id: 1 }, { id: 2 }], + ], + ["getJobLog", [1], "actions/jobs/1/logs", "job evidence"], + ])("revalidates %s through the default protected route", (method, args, endpoint, expected) => { + const result = runProtectedFrv(method, args as Array, endpoint); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(expected); + expect(result.calls).toHaveLength(1); + }); + + it("falls back once when gh does not support the escape-sequence flag", () => { + const result = runProtectedFrv("getJobLog", [1], "actions/jobs/1/logs", "legacy-flag"); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toBe("job evidence"); + expect(result.calls).toEqual([[...jobLogArgs, "--allow-escape-sequences"], jobLogArgs]); + }); + + it("does not fall back after an unrelated job-log error", () => { + const result = runProtectedFrv("getJobLog", [1], "actions/jobs/1/logs", "unrelated"); + expect(result.status).toBe(23); + expect(result.stderr).toContain("unrelated log failure"); + expect(result.calls).toEqual([[...jobLogArgs, "--allow-escape-sequences"]]); + }); + + it("preserves protected refusal status without retry or alternate execution", () => { + const result = runProtectedFrv("getRun", ["101"], "actions/runs/101", "protected"); + expect(result.status).toBe(19); + expect(result.stderr).toContain("protected refusal"); + expect(result.calls).toHaveLength(1); + }); +}); + +function runProtectedFrv( + method: string, + args: Array, + endpoint: string, + failure: "none" | "legacy-flag" | "protected" | "unrelated" = "none", +) { + const root = mkdtempSync(join(tmpdir(), "frv-protected-")); + const gh = join(root, "gh"); + writeFileSync( + gh, + `#!${process.execPath} +const fs = require("node:fs"); +const args = process.argv.slice(2); +fs.appendFileSync("calls.jsonl", JSON.stringify(args) + "\\n"); +const fail = (message, code) => { console.error(message); process.exit(code); }; +const failure = ${JSON.stringify(failure)}; +if (failure === "protected") fail("protected refusal", 19); +if (args[0] !== "api" || !args.includes(${JSON.stringify(`repos/${REPOSITORY}/${endpoint}`)})) fail("unexpected request", 17); +if (!args.some((arg, i) => ["-H", "--header"].includes(arg) && args[i+1] === "Cache-Control: max-age=0")) fail("missing live header", 18); +if (${endpoint.endsWith("/logs")} && failure === "legacy-flag" && args.includes("--allow-escape-sequences")) fail("unknown flag: --allow-escape-sequences", 1); +if (${endpoint.endsWith("/logs")} && failure === "unrelated") fail("unrelated log failure", 23); +if (${endpoint.endsWith("/logs")} && failure === "none" && !args.includes("--allow-escape-sequences")) fail("missing escape-sequence flag", 20); +if (${endpoint.includes("/jobs?")}) { + if (!args.includes("--paginate") || !args.includes(".jobs[] | @json")) fail("missing pagination", 17); + console.log('{"id":1}\\n{"id":2}'); +} else console.log(${endpoint.endsWith("/logs") ? JSON.stringify("job evidence") : JSON.stringify('{"run_attempt":2}')}); +`, + ); + chmodSync(gh, 0o755); + try { + const moduleUrl = pathToFileURL(join(process.cwd(), "scripts/frv.mjs")).href; + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import {createClient} from ${JSON.stringify(moduleUrl)}; + try { + console.log(JSON.stringify(await createClient(${JSON.stringify(REPOSITORY)})[${JSON.stringify(method)}](...${JSON.stringify(args)}))); + } catch (error) { console.error(error.message); process.exitCode = error.code; } + `, + ], + { + cwd: root, + encoding: "utf8", + env: { HOME: root, PATH: `${root}${delimiter}${process.env.PATH ?? ""}` }, + }, + ); + return { + ...result, + calls: readFileSync(join(root, "calls.jsonl"), "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const PUBLISH_SHA = "c".repeat(40); +const PUBLISH_REF = `release-publish/${PUBLISH_SHA.slice(0, 12)}-88`; +const DIAGNOSTIC_FILE = "release-postpublish-diagnostics.json"; +const PUBLISH_PATH = ".github/workflows/openclaw-release-publish.yml"; +const FRV_PATH = ".github/workflows/full-release-validation.yml"; + +function publicationFixture() { + const executionPlan = executionPlanArtifact(); + const publisher = { + ...rootRun(), + id: 88, + workflow_id: 800, + path: `${PUBLISH_PATH}@refs/tags/${PUBLISH_REF}`, + head_branch: PUBLISH_REF, + head_sha: PUBLISH_SHA, + repository: { full_name: REPOSITORY }, + }; + const root = { + ...rootRun(2, "success"), + id: 77, + workflow_id: 700, + path: `${FRV_PATH}@${SOURCE_REF}`, + head_branch: SOURCE_REF, + head_sha: SHA, + repository: { full_name: REPOSITORY }, + }; + const manifest = { + version: 3, + workflowName: "Full Release Validation", + runId: "77", + runAttempt: "2", + workflowRef: SOURCE_REF, + workflowFullRef: `refs/heads/${SOURCE_REF}`, + workflowRefType: "branch", + workflowSha: SHA, + targetSha: TARGET_SHA, + releaseProfile: "beta", + rerunGroup: "all", + runReleaseSoak: "false", + controls: { performanceReportPublication: "artifact-only" }, + validationInputs: {}, + candidateBinding: null, + executionPlanSha256: executionPlan.sha256, + sourceParentRunAttempt: 1, + childRuns: { + normalCi: "101", + npmTelegram: "", + pluginPrerelease: "202", + releaseChecks: "303", + productPerformance: { runId: "404" }, + }, + }; + const stage = (state = "unattempted", publication = "unknown") => ({ + state, + publication, + error: null, + packages: [], + packagesTruncated: false, + }); + const diagnostic = { + schemaVersion: 1, + kind: "release-postpublish-diagnostics", + invocationId: "12345678-1234-4234-8234-123456789abc", + context: { + repository: REPOSITORY, + releaseVersion: "2026.9.9", + releaseTag: "v2026.9.9", + npmDistTag: "latest", + requestedSourceSha: TARGET_SHA, + toolingSha: PUBLISH_SHA, + suppliedToolingSha: PUBLISH_SHA, + suppliedToolingRef: `refs/tags/${PUBLISH_REF}`, + parentRunId: "88", + parentRunAttempt: "1", + validationEvidence: { mode: "full-release-validation", runId: "77", runAttempt: "2" }, + }, + selection: { + plugins: [], + pluginsTruncated: false, + workflowRef: PUBLISH_REF, + clawHubWorkflowRef: PUBLISH_REF, + }, + verification: "failure", + currentStage: "pluginNpm", + stages: { + checkout: stage("success"), + githubRelease: stage("skipped"), + coreNpm: stage("success", "observed"), + postpublish: stage("success"), + pluginNpm: stage("failure"), + clawHub: stage(), + fullReleaseValidation: stage(), + pluginNpmRun: stage(), + pluginClawHubRun: stage(), + pluginClawHubBootstrap: stage("skipped"), + openclawNpm: stage(), + npmTelegram: stage("skipped"), + evidence: stage(), + binding: stage(), + assets: stage(), + }, + children: Object.fromEntries( + [ + "fullReleaseValidation", + "openclawNpm", + "pluginNpm", + "pluginClawHub", + "pluginClawHubBootstrap", + "npmTelegram", + ].map((name) => [ + name, + { + suppliedRunId: null, + runAttempt: null, + producerRunAttempt: null, + status: "unknown", + conclusion: "unknown", + failedJobCount: null, + readbackArtifactId: null, + packageArtifactId: null, + }, + ]), + ), + jobOutcomeBeforeArtifactUploads: "failure", + stepOutcomes: { coreStart: "success", completion: "failure" }, + }; + const publisherJobs = [ + { + ...job("Publish plugins, then OpenClaw", "failure"), + id: 8801, + run_id: 88, + run_attempt: 1, + steps: [ + { + number: 1, + name: "Upload postpublish diagnostics", + status: "completed", + conclusion: "success", + }, + ], + }, + ]; + return { executionPlan, manifest, diagnostic, publisher, root, publisherJobs }; +} + +async function runPublicationCli( + fixture = publicationFixture(), + args = ["status", "--run", "77", "--publication-run", "88", "--json"], + amend: ( + responses: Record, + addArtifact: ( + id: number, + run: typeof fixture.root, + name: string, + filename: string, + value: unknown, + zipChange?: (zip: JSZip) => void, + ) => Promise, + ) => void | Promise = () => {}, + explicitBinary = false, + clockStep = 0, +) { + const directory = mkdtempSync(join(tmpdir(), "frv-publication-")); + const legacy = !args.includes("--publication-run"); + const responses: Record = {}; + const endpoint = (path: string) => `repos/${REPOSITORY}/${path}`; + const artifactLists = new Map(); + async function artifact( + id: number, + run: typeof fixture.root, + name: string, + filename: string, + value: unknown, + zipChange?: (zip: JSZip) => void, + ) { + const zip = new JSZip(); + zip.file(filename, JSON.stringify(value), { unixPermissions: 0o100644 }); + zipChange?.(zip); + const bytes = await zip.generateAsync({ + type: "nodebuffer", + platform: "UNIX", + compression: "DEFLATE", + }); + const metadata = { + id, + name, + digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + size_in_bytes: bytes.length, + expired: false, + expires_at: "2099-01-01T00:00:00Z", + workflow_run: { id: run.id, head_sha: run.head_sha }, + }; + responses[endpoint(`actions/artifacts/${id}`)] = metadata; + responses[endpoint(`actions/artifacts/${id}/zip`)] = { binary: bytes.toString("base64") }; + const artifacts = [ + ...(artifactLists.get(run.id) ?? []).filter((item) => (item as { id: number }).id !== id), + metadata, + ]; + artifactLists.set(run.id, artifacts); + responses[endpoint(`actions/runs/${run.id}/artifacts?per_page=100&page=1`)] = { + total_count: artifacts.length, + artifacts, + }; + } + await artifact( + 1, + fixture.root, + "full-release-execution-plan-77", + "full-release-execution-plan.json", + fixture.executionPlan, + ); + await artifact( + 2, + fixture.root, + "full-release-validation-77-2", + "full-release-validation-manifest.json", + fixture.manifest, + ); + await artifact( + 3, + fixture.publisher, + "openclaw-release-postpublish-diagnostics-88-1", + DIAGNOSTIC_FILE, + fixture.diagnostic, + ); + for (const run of [fixture.root, fixture.publisher]) { + responses[endpoint(`actions/runs/${run.id}`)] = run; + responses[endpoint(`actions/runs/${run.id}/attempts/${run.run_attempt}`)] = run; + responses[endpoint(`actions/workflows/${run.workflow_id}`)] = { + id: run.workflow_id, + path: run.id === 77 ? FRV_PATH : PUBLISH_PATH, + }; + const artifacts = artifactLists.get(run.id) ?? []; + responses[endpoint(`actions/runs/${run.id}/artifacts?per_page=100&page=1`)] = { + total_count: artifacts.length, + artifacts, + }; + } + responses[endpoint("actions/runs/88/attempts/1/jobs?per_page=100&page=1")] = { + total_count: fixture.publisherJobs.length, + jobs: fixture.publisherJobs, + }; + for (const entry of requiredChildren()) { + const run = { ...runFor(entry, 1, "success"), workflow_id: Number(entry.runId) + 1000 }; + responses[endpoint(`actions/runs/${entry.runId}`)] = run; + responses[endpoint(`actions/workflows/${run.workflow_id}`)] = { + id: run.workflow_id, + path: run.path, + }; + responses[endpoint(`actions/runs/${entry.runId}/attempts/1/jobs?per_page=100&page=1`)] = { + total_count: 1, + jobs: [{ ...job("test"), id: Number(entry.runId) * 10, run_id: run.id, run_attempt: 1 }], + }; + } + await amend(responses, artifact); + writeFileSync(join(directory, "responses.json"), JSON.stringify(responses)); + writeFileSync(join(directory, "legacy-plan.json"), JSON.stringify(fixture.executionPlan)); + writeFileSync( + join(directory, "no-network.mjs"), + `import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +globalThis.fetch = () => { throw new Error('unplanned Node fetch'); }; +for (const name of ["execFileSync", "execFile", "spawn", "spawnSync"]) { + const original = childProcess[name]; + const guarded = (method) => (command, ...args) => { + if (command !== "gh" && command !== ${JSON.stringify(join(directory, "gh"))}) throw new Error("unplanned executable"); + return method(command, ...args); + }; + childProcess[name] = guarded(original); + const custom = Symbol.for("nodejs.util.promisify.custom"); + if (original[custom]) childProcess[name][custom] = guarded(original[custom]); +} +syncBuiltinESMExports(); +${clockStep ? `let ticks = 0; const now = Date.now(); Date.now = () => now + ticks++ * ${clockStep};` : ""} +`, + ); + const gh = join(directory, "gh"); + writeFileSync( + gh, + `#!${process.execPath} +const fs = require("node:fs"); +const args = process.argv.slice(2); +fs.appendFileSync("calls.jsonl", JSON.stringify(args) + "\\n"); +const reject = () => { console.error("unplanned or mutating request"); process.exit(23); }; +const legacy = ${legacy}; +if (legacy && args[0] === "run" && args[1] === "download" && args[2] === "77" && args[args.indexOf("--name") + 1] === "full-release-execution-plan-77") { + fs.copyFileSync("legacy-plan.json", require("node:path").join(args[args.indexOf("--dir") + 1], "full-release-execution-plan.json")); + process.exit(0); +} +if (args[0] !== "api" || (!legacy && (!args.includes("GET") || !args.includes("github.com"))) || !args.includes("Cache-Control: max-age=0")) reject(); +if (args.includes("--include") || (!legacy && args.includes("--paginate"))) reject(); +let path = args.find(a => a.startsWith("repos/")); +if (legacy && path.endsWith("/jobs?per_page=100")) path += "&page=1"; +const table = JSON.parse(fs.readFileSync("responses.json", "utf8")); +if (!Object.hasOwn(table, path)) reject(); +let value = table[path]; +if (value.sequence) { + const reads = fs.readFileSync("calls.jsonl", "utf8").trim().split("\\n").map(JSON.parse).filter(a => a.includes(path)).length; + value = value.sequence[Math.min(reads - 1, value.sequence.length - 1)]; +} +if (value.failure) { console.error(value.failure); process.exit(1); } +if (legacy && value.jobs) process.stdout.write(value.jobs.map(job => JSON.stringify(job)).join("\\n")); +else if (value.binary) process.stdout.write(Buffer.from(value.binary, "base64")); +else if (value.raw) process.stdout.write(value.raw); +else process.stdout.write(JSON.stringify(value)); +`, + ); + chmodSync(gh, 0o755); + try { + const result = spawnSync( + process.execPath, + [ + "--import", + join(directory, "no-network.mjs"), + join(process.cwd(), "scripts/frv.mjs"), + ...args, + ], + { + cwd: directory, + encoding: "utf8", + timeout: 30_000, + env: { + HOME: directory, + PATH: directory, + ...(explicitBinary ? { OPENCLAW_GH_BIN: gh, GH_TOKEN: "synthetic-fixture-token" } : {}), + }, + }, + ); + let calls: string[][] = []; + try { + calls = readFileSync(join(directory, "calls.jsonl"), "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + } catch { + // Usage rejection must happen before the first CLI read. + } + return { ...result, calls }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +describe("publication status real CLI", () => { + it("joins a failed publisher to selected validation attempt two without erasing known readback", async () => { + const fixture = publicationFixture(); + validateParentManifest(fixture.manifest, { + runId: "77", + runAttempt: 2, + workflowRef: SOURCE_REF, + workflowSha: SHA, + }); + const result = await runPublicationCli(fixture); + expect( + result.status, + `${result.stderr}\n${JSON.stringify(JSON.parse(result.stdout).publication.relationship)}\n${JSON.stringify(JSON.parse(result.stdout).publication.collection)}`, + ).toBe(0); + const value = JSON.parse(result.stdout); + expect(value.publication.relationship).toMatchObject({ + status: "verified", + originalPlanAttempt: 1, + validationAttempt: 2, + }); + expect(value.publication.publisher).toMatchObject({ + runId: "88", + runAttempt: 1, + conclusion: "failure", + }); + expect(value.publication.surfaces.coreNpm.verification.state).toBe("success"); + expect(value.publication.surfaces.pluginNpm.verification.state).toBe("failure"); + expect(value.publication.surfaces.activation.operation.state).toBe("unknown"); + expect(value.children).toHaveLength(4); + expect(result.calls.length).toBeGreaterThan(0); + }); + + it.each([ + ["status", "--run", "77", "--publication-run"], + ["status", "--run", "77", "--publication-run", "0"], + ["status", "--run", "77", "--publication-run", "9007199254740992"], + ["status", "--run", "77", "--publication-run", "88", "--publication-run", "89"], + ["status", "--run", "77", "--run", "78", "--publication-run", "88"], + ["status", "--run", "77", "--publication-run", "88", "--repo", "../private"], + ["continue", "--failed", "--run", "77", "--publication-run", "88"], + ["verify", "--run", "77", "--publication-run", "88"], + ])("rejects invalid selectors before any read: %j", async (...args) => { + const result = await runPublicationCli(publicationFixture(), args); + expect(result.status).toBe(1); + expect(result.calls).toEqual([]); + expect(result.stderr).toContain("publication observation: usage"); + }); + + it("uses the selected explicit binary without Node fetch and keeps the text section separate", async () => { + const result = await runPublicationCli( + publicationFixture(), + ["status", "--run", "77", "--publication-run", "88"], + undefined, + true, + ); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Publication observation (not release authorization)"); + expect(result.stdout).toContain("selected-attempt=2"); + expect(result.stdout).toContain( + "pluginNpm: selection=unknown operation=unknown verification=failure", + ); + expect(result.calls.every((call) => call[0] === "api" && call.includes("GET"))).toBe(true); + expect(result.stdout + result.stderr).not.toContain("synthetic-fixture-token"); + }); + + it.each([ + [ + "foreign repository", + (fixture: ReturnType) => { + fixture.publisher.repository.full_name = "other/repository"; + }, + ], + [ + "foreign event", + (fixture) => { + fixture.publisher.event = "push"; + }, + ], + [ + "foreign path", + (fixture) => { + fixture.publisher.path = ".github/workflows/other.yml"; + }, + ], + [ + "foreign path suffix", + (fixture) => { + fixture.publisher.path = `${PUBLISH_PATH}@main`; + }, + ], + [ + "wrong producer run", + (fixture) => { + fixture.diagnostic.context.parentRunId = "89"; + }, + ], + [ + "wrong producer attempt", + (fixture) => { + fixture.diagnostic.context.parentRunAttempt = "2"; + }, + ], + [ + "wrong tooling", + (fixture) => { + fixture.diagnostic.context.toolingSha = SHA; + }, + ], + [ + "wrong full ref", + (fixture) => { + fixture.diagnostic.context.suppliedToolingRef = "refs/heads/main"; + }, + ], + [ + "wrong validation root", + (fixture) => { + fixture.diagnostic.context.validationEvidence.runId = "78"; + }, + ], + [ + "future validation attempt", + (fixture) => { + fixture.diagnostic.context.validationEvidence.runAttempt = "3"; + }, + ], + [ + "wrong candidate", + (fixture) => { + fixture.diagnostic.context.requestedSourceSha = PUBLISH_SHA; + }, + ], + [ + "wrong source attempt", + (fixture) => { + fixture.manifest.sourceParentRunAttempt = 2; + }, + ], + [ + "wrong plan checksum", + (fixture) => { + fixture.manifest.executionPlanSha256 = "0".repeat(64); + }, + ], + [ + "wrong manifest tooling", + (fixture) => { + fixture.manifest.workflowSha = PUBLISH_SHA; + }, + ], + [ + "unsuccessful upload", + (fixture) => { + fixture.publisherJobs[0]!.steps[0]!.conclusion = "failure"; + }, + ], + ] satisfies [string, (fixture: ReturnType) => void][])( + "refuses %s", + async (_name, mutate) => { + const fixture = publicationFixture(); + mutate(fixture); + const result = await runPublicationCli(fixture); + expect(result.status, result.stdout).toBe(1); + expect(JSON.parse(result.stdout).publication.collection.complete).toBe(false); + expect(result.calls.every((call) => call[0] === "api" && call.includes("GET"))).toBe(true); + }, + ); + + it.each([ + "workflow-id", + "artifact-digest", + "artifact-size", + "artifact-producer", + "duplicate-name", + "duplicate-id", + "count-gap", + "attempt-limit", + ])("rejects independently observed %s", async (kind) => { + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + const prefix = `repos/${REPOSITORY}/actions/`; + const metadata = responses[`${prefix}artifacts/3`] as Record; + const list = responses[`${prefix}runs/88/artifacts?per_page=100&page=1`] as { + total_count: number; + artifacts: unknown[]; + }; + if (kind === "workflow-id") { + Object.assign(responses[`${prefix}workflows/800`] as object, { id: 801 }); + } + if (kind === "artifact-digest") { + metadata.digest = `sha256:${"0".repeat(64)}`; + } + if (kind === "artifact-size") { + metadata.size_in_bytes = 2 * 1024 * 1024 + 1; + } + if (kind === "artifact-producer") { + metadata.workflow_run = { id: 89, head_sha: PUBLISH_SHA }; + } + if (kind === "duplicate-name") { + list.artifacts.push({ ...metadata, id: 33 }); + list.total_count++; + } + if (kind === "duplicate-id") { + list.artifacts.push(metadata); + list.total_count++; + } + if (kind === "count-gap") { + list.total_count++; + } + if (kind === "attempt-limit") { + Object.assign(responses[`${prefix}runs/101`] as object, { run_attempt: 100000000 }); + } + }); + expect(result.status, result.stdout).toBe(1); + expect(JSON.parse(result.stdout).publication.collection.complete).toBe(false); + }); + + it.each(["88", "77", "101"])( + "refuses advancing run %s without restarting observation", + async (runId) => { + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + const path = `repos/${REPOSITORY}/actions/runs/${runId}`; + const before = responses[path] as { run_attempt: number }; + responses[path] = { + sequence: [before, { ...before, run_attempt: before.run_attempt + 1 }], + }; + }); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).publication.collection.error).toBe("attempt-changed"); + expect( + result.calls.filter((call) => call.includes(`repos/${REPOSITORY}/actions/runs/${runId}`)), + ).toHaveLength(2); + }, + ); + + it.each(["missing", "expired", "legacy-only", "unsupported", "incomplete-link"])( + "keeps authenticated historical %s unknown, not failed publication", + async (kind) => { + const fixture = publicationFixture(); + if (kind === "unsupported") { + fixture.diagnostic.schemaVersion = 2; + } + if (kind === "incomplete-link") { + Reflect.set(fixture.diagnostic.context.validationEvidence, "runAttempt", null); + } + const result = await runPublicationCli(fixture, undefined, (responses) => { + const listPath = `repos/${REPOSITORY}/actions/runs/88/artifacts?per_page=100&page=1`; + if (kind === "missing") { + responses[listPath] = { total_count: 0, artifacts: [] }; + } + if (kind === "legacy-only") { + responses[listPath] = { + total_count: 1, + artifacts: [{ id: 99, name: "openclaw-release-postpublish-evidence-v2026.9.9" }], + }; + } + if (kind === "expired") { + Object.assign(responses[`repos/${REPOSITORY}/actions/artifacts/3`] as object, { + expired: true, + }); + } + }); + expect(result.status, result.stdout).toBe(0); + const publication = JSON.parse(result.stdout).publication; + expect(publication.collection.complete).toBe(true); + expect(publication.relationship.status).toBe("unverified"); + expect(publication.surfaces.coreNpm.operation.state).toBe("unknown"); + expect(publication.diagnostics.state).toBe(kind === "incomplete-link" ? "available" : kind); + }, + ); + + it.each(["403 quota", "404 unavailable", "network timeout"])( + "classifies %s as unavailable, never absence", + async (failure) => { + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + responses[`repos/${REPOSITORY}/actions/runs/88/artifacts?per_page=100&page=1`] = { + failure: `${failure}: /private/fixture/credential synthetic-secret`, + }; + }); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).publication.collection).toEqual({ + complete: false, + error: "transport", + }); + expect(result.stdout + result.stderr).not.toMatch( + /synthetic-secret|private\/fixture|quota|404 unavailable/u, + ); + }, + ); + + it("retains partial package successes and truncation without declaring a complete observation", async () => { + const fixture = publicationFixture(); + Object.assign(fixture.diagnostic.stages.pluginNpm, { + packages: [ + { name: "@openclaw/first", state: "success", publication: "observed", error: null }, + { + name: "@openclaw/second", + state: "failure", + publication: "unknown", + error: { class: "registry-not-visible", status: 1 }, + }, + ], + packagesTruncated: true, + }); + const result = await runPublicationCli(fixture); + const publication = JSON.parse(result.stdout).publication; + expect(result.status).toBe(1); + expect(publication.relationship.status).toBe("verified"); + expect(publication.collection.error).toBe("incomplete"); + expect(publication.surfaces.pluginNpm.packages).toHaveLength(2); + expect(publication.surfaces.pluginNpm.packages[0].publication).toBe("observed"); + expect(publication.surfaces.pluginNpm.packages[1].error.class).toBe("registry-not-visible"); + }); + + it("keeps successful verification separate from failed binding, assets and skipped activation", async () => { + const fixture = publicationFixture(); + fixture.diagnostic.verification = "success"; + fixture.diagnostic.stages.binding.state = "failure"; + fixture.diagnostic.stages.assets.state = "failure"; + fixture.publisher.conclusion = "success"; + fixture.publisherJobs.push({ + ...job("Finalize GitHub release", "skipped"), + id: 8802, + run_id: 88, + run_attempt: 1, + steps: [], + }); + const result = await runPublicationCli(fixture); + const publication = JSON.parse(result.stdout).publication; + expect(result.status).toBe(0); + expect(publication.verification.state).toBe("success"); + expect(publication.binding.state).toBe("failure"); + expect(publication.assets.state).toBe("failure"); + expect(publication.surfaces.activation.operation.state).toBe("unknown"); + expect(publication.surfaces.activation.jobs[0].conclusion).toBe("skipped"); + }); + + it("retains unattempted verification when an earlier publisher prerequisite failed", async () => { + const fixture = publicationFixture(); + fixture.diagnostic.verification = "unattempted"; + fixture.diagnostic.stages.coreNpm.state = "unattempted"; + fixture.diagnostic.stages.coreNpm.publication = "unknown"; + const result = await runPublicationCli(fixture); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).publication.surfaces.coreNpm).toMatchObject({ + selection: "unknown", + verificationSelection: "unknown", + operation: { state: "unknown" }, + verification: { state: "unattempted" }, + }); + }); + + it("keeps Docker-only readback and advisory VCR API step conclusions separate from writer receipts", async () => { + const fixture = publicationFixture(); + fixture.publisherJobs.push( + { + ...job("Verify already-published core npm package"), + id: 8802, + run_id: 88, + run_attempt: 1, + steps: [], + }, + { + ...job("Publish Docker images / publish"), + id: 8803, + run_id: 88, + run_attempt: 1, + steps: [], + }, + { + ...job("Mirror Docker images to Vercel Container Registry / mirror", "failure"), + id: 8804, + run_id: 88, + run_attempt: 1, + steps: [ + { + number: 1, + name: "Copy and verify immutable release images", + status: "completed", + conclusion: "success", + }, + { + number: 2, + name: "Run custom-image Sandbox smoke", + status: "completed", + conclusion: "failure", + }, + { + number: 3, + name: "Promote and verify channel aliases", + status: "completed", + conclusion: "skipped", + }, + ], + }, + ); + const result = await runPublicationCli(fixture); + expect(result.status).toBe(0); + const surfaces = JSON.parse(result.stdout).publication.surfaces; + expect(surfaces.coreNpm.registryObservation.state).toBe("observed"); + expect(surfaces.coreNpm.operation.state).toBe("unknown"); + expect(surfaces.docker.children).toEqual([]); + expect(surfaces.vcr.advisory).toBe(true); + expect( + surfaces.vcr.jobs[0].steps.map((step: { conclusion: string }) => step.conclusion), + ).toEqual(["success", "failure", "skipped"]); + }); + + it("observes a supplied original core child without inventing a publisher-attempt receipt", async () => { + const fixture = publicationFixture(); + Reflect.set(fixture.diagnostic.children.openclawNpm!, "suppliedRunId", "909"); + const result = await runPublicationCli(fixture, undefined, (responses) => { + responses[`repos/${REPOSITORY}/actions/runs/909`] = { + ...fixture.publisher, + id: 909, + run_attempt: 3, + workflow_id: 9090, + head_sha: SHA, + head_branch: "older-tooling", + path: ".github/workflows/openclaw-npm-release.yml", + conclusion: "success", + }; + responses[`repos/${REPOSITORY}/actions/workflows/9090`] = { + id: 9090, + path: ".github/workflows/openclaw-npm-release.yml", + }; + }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).publication.surfaces.coreNpm.children).toEqual([ + { + runId: "909", + workflowSha: SHA, + workflowRef: "older-tooling", + recordedAttempt: null, + observedAttempt: 3, + status: "completed", + conclusion: "success", + relation: "supplied", + }, + ]); + }); + + it("retains a verified relationship when an unrelated child workflow is invalid", async () => { + const fixture = publicationFixture(); + Reflect.set(fixture.diagnostic.children.openclawNpm!, "suppliedRunId", "909"); + const result = await runPublicationCli(fixture, undefined, (responses) => { + responses[`repos/${REPOSITORY}/actions/runs/909`] = { + ...fixture.publisher, + id: 909, + workflow_id: 9090, + path: ".github/workflows/wrong.yml", + }; + responses[`repos/${REPOSITORY}/actions/workflows/9090`] = { + id: 9090, + path: ".github/workflows/wrong.yml", + }; + }); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).publication.collection.error).toBe("identity-mismatch"); + expect(JSON.parse(result.stdout).publication.relationship.status).toBe("verified"); + }); + + it("retains a verified relationship when a validation child advances after the join", async () => { + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + const path = `repos/${REPOSITORY}/actions/runs/101`; + const before = responses[path] as object; + responses[path] = { sequence: [before, { ...before, run_attempt: 2 }] }; + }); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).publication.collection.error).toBe("attempt-changed"); + expect(JSON.parse(result.stdout).publication.relationship.status).toBe("verified"); + }); + + it.each(["transport", "workflow", "attempt-limit"])( + "authenticates the publisher before a validation child %s failure", + async (kind) => { + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + const path = `repos/${REPOSITORY}/actions/runs/101`; + if (kind === "transport") { + responses[path] = { failure: "403" }; + } else { + Object.assign( + responses[path] as object, + kind === "workflow" + ? { path: ".github/workflows/wrong.yml" } + : { run_attempt: 100000000 }, + ); + } + }); + expect(result.status).toBe(1); + const publication = JSON.parse(result.stdout).publication; + expect(publication.collection.complete).toBe(false); + expect(publication.relationship.status).toBe("verified"); + expect(publication.surfaces.coreNpm.registryObservation.state).toBe("observed"); + }, + ); + + it.each([ + ["77", "advances"], + ["88", "advances"], + ["77", "is unavailable"], + ["88", "is unavailable"], + ])( + "rechecks joined run %s when it %s after a child collection failure", + async (runId, outcome) => { + const path = `repos/${REPOSITORY}/actions/runs/${runId}`; + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + const before = responses[path] as { run_attempt: number }; + responses[path] = { + sequence: [ + before, + outcome === "advances" + ? { ...before, run_attempt: before.run_attempt + 1 } + : { failure: "403" }, + ], + }; + responses[`repos/${REPOSITORY}/actions/runs/101`] = { failure: "403" }; + }); + expect(result.status).toBe(1); + const publication = JSON.parse(result.stdout).publication; + expect(publication.relationship.status).toBe( + outcome === "advances" ? "invalid" : "unverified", + ); + expect(publication.relationship.reason).toBe( + outcome === "advances" ? "attempt-changed" : "transport", + ); + expect(publication.collection.complete).toBe(false); + expect(publication.surfaces.coreNpm.registryObservation.state).toBe("observed"); + expect(result.calls.filter((call) => call.includes(path))).toHaveLength(2); + }, + ); + + it.each(["77", "88"])( + "does not retain a verified relationship when final joined run %s cannot be rechecked", + async (runId) => { + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + const path = `repos/${REPOSITORY}/actions/runs/${runId}`; + responses[path] = { sequence: [responses[path], { failure: "403" }] }; + }); + expect(result.status).toBe(1); + const publication = JSON.parse(result.stdout).publication; + expect(publication.collection).toEqual({ complete: false, error: "transport" }); + expect(publication.relationship.status).toBe("unverified"); + expect(publication.relationship.reason).toBe("transport"); + }, + ); + + it("preserves producer-valid passive twenty-digit diagnostic IDs", async () => { + const fixture = publicationFixture(); + Object.assign(fixture.diagnostic.children.pluginNpm!, { + readbackArtifactId: "12345678901234567890", + packageArtifactId: "12345678901234567", + producerRunAttempt: "12345678901234567890", + }); + const result = await runPublicationCli(fixture); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).publication.relationship.status).toBe("verified"); + expect(JSON.parse(result.stdout).publication.surfaces.coreNpm.registryObservation.state).toBe( + "observed", + ); + expect(result.calls.flat().join(" ")).not.toContain("123456789012345"); + }); + + it.each(["77", "88"])( + "invalidates the relationship when joined run %s advances", + async (runId) => { + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + const path = `repos/${REPOSITORY}/actions/runs/${runId}`; + const before = responses[path] as { run_attempt: number }; + responses[path] = { + sequence: [before, { ...before, run_attempt: before.run_attempt + 1 }], + }; + }); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).publication.relationship.status).toBe("invalid"); + }, + ); + + it.each(["77", "88"])( + "retains the relationship when joined run %s completes the same attempt", + async (runId) => { + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + const path = `repos/${REPOSITORY}/actions/runs/${runId}`; + const before = responses[path] as object; + responses[path] = { + sequence: [ + { ...before, status: "in_progress", conclusion: null }, + { ...before, display_title: "Updated workflow display title" }, + ], + }; + }); + expect(result.status, result.stderr).toBe(0); + const publication = JSON.parse(result.stdout).publication; + expect(publication.relationship.status).toBe("verified"); + expect(publication.collection.complete).toBe(true); + }, + ); + + it.each(["77", "88"])( + "rejects an immutable SHA change in joined run %s without attempt advancement", + async (runId) => { + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + const path = `repos/${REPOSITORY}/actions/runs/${runId}`; + responses[path] = { + sequence: [responses[path], { ...(responses[path] as object), head_sha: "f".repeat(40) }], + }; + }); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).publication.relationship.status).toBe("invalid"); + }, + ); + + it.each(["main", "foreign"])( + "binds a normal ClawHub child to its recorded %s ref, not the alpha publisher ref", + async (childRef) => { + const fixture = publicationFixture(); + const parentRef = "tideclaw/alpha/fixture"; + fixture.publisher.head_branch = parentRef; + fixture.publisher.path = `${PUBLISH_PATH}@refs/heads/${parentRef}`; + fixture.diagnostic.context.suppliedToolingRef = `refs/heads/${parentRef}`; + fixture.diagnostic.selection.clawHubWorkflowRef = "main"; + const result = await runPublicationCli(fixture, undefined, async (responses, artifact) => { + await artifact(4, fixture.publisher, "openclaw-release-children-88-1", "dispatch.json", { + schemaVersion: 1, + repository: REPOSITORY, + parentRunId: "88", + parentRunAttempt: "1", + parentWorkflow: PUBLISH_PATH, + toolingRef: parentRef, + toolingFullRef: `refs/heads/${parentRef}`, + toolingSha: PUBLISH_SHA, + candidateSha: TARGET_SHA, + normalClawHubRunId: "909", + normalClawHubRunAttempt: "1", + }); + responses[`repos/${REPOSITORY}/actions/runs/909`] = { + ...fixture.publisher, + id: 909, + workflow_id: 9090, + head_branch: childRef, + path: ".github/workflows/plugin-clawhub-release.yml", + }; + responses[`repos/${REPOSITORY}/actions/workflows/9090`] = { + id: 9090, + path: ".github/workflows/plugin-clawhub-release.yml", + }; + }); + expect(result.status).toBe(childRef === "main" ? 0 : 1); + const publication = JSON.parse(result.stdout).publication; + expect(publication.relationship.status).toBe("verified"); + if (childRef === "main") { + expect(publication.surfaces.clawHub.children[0]).toMatchObject({ + runId: "909", + workflowRef: "main", + workflowSha: PUBLISH_SHA, + recordedAttempt: 1, + }); + } + }, + ); + + it("bounds active twenty-digit child IDs before attempting a metadata GET", async () => { + const fixture = publicationFixture(); + Reflect.set(fixture.diagnostic.children.pluginNpm!, "suppliedRunId", "12345678901234567890"); + const result = await runPublicationCli(fixture); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).publication.collection.error).toBe("limits"); + expect(JSON.parse(result.stdout).publication.relationship.status).toBe("verified"); + expect(result.calls.flat().join(" ")).not.toContain("12345678901234567890"); + }); + + it.each(["in_progress", "failure"])( + "observes detached Windows %s without equating acknowledgement and promotion", + async (state) => { + const fixture = publicationFixture(); + fixture.publisherJobs.push({ + ...job("Dispatch Windows assets after publication"), + id: 8802, + run_id: 88, + run_attempt: 1, + steps: [ + { + number: 1, + name: "Upload Windows dispatch evidence", + status: "completed", + conclusion: "success", + }, + ], + }); + const native = { + ...fixture.publisher, + id: 909, + workflow_id: 9090, + path: ".github/workflows/windows-node-release.yml", + status: state === "failure" ? "completed" : "in_progress", + conclusion: state === "failure" ? "failure" : null, + }; + const dispatch = { + tag: "v2026.9.9", + sourceTag: "windows-v1", + installerDigests: "fixture-digests", + state: "dispatched", + childRunId: "909", + }; + const result = await runPublicationCli(fixture, undefined, async (responses, artifact) => { + await artifact( + 4, + fixture.publisher, + "windows-release-dispatch-88-1", + "windows-dispatch.json", + dispatch, + ); + responses[`repos/${REPOSITORY}/actions/runs/909`] = native; + responses[`repos/${REPOSITORY}/actions/workflows/9090`] = { id: 9090, path: native.path }; + responses[`repos/${REPOSITORY}/actions/runs/909/artifacts?per_page=100&page=1`] = { + total_count: 0, + artifacts: [], + }; + if (state === "failure") { + await artifact(5, native, "windows-release-promotion-909-1", "windows-promotion.json", { + schemaVersion: 1, + ...dispatch, + outcome: "success", + runUrl: `https://github.com/${REPOSITORY}/actions/runs/909`, + }); + responses[`repos/${REPOSITORY}/actions/runs/909/attempts/1/jobs?per_page=100&page=1`] = { + total_count: 1, + jobs: [ + { + ...job("Promote signed Windows installers", "failure"), + id: 9091, + run_id: 909, + run_attempt: 1, + steps: [ + { + number: 1, + name: "Upload Windows promotion evidence", + status: "completed", + conclusion: "success", + }, + ], + }, + ], + }; + } + }); + expect(result.status, result.stdout).toBe(0); + const surface = JSON.parse(result.stdout).publication.surfaces.nativeWindows; + expect(surface.children[0].recordedAttempt).toBeNull(); + expect(surface.children[0].status).toBe(native.status); + expect(surface.children[0].conclusion).toBe(native.conclusion); + expect(surface.operation.state).toBe("unknown"); + expect(surface.terminalMarker.state).toBe(state === "failure" ? "success" : "unknown"); + if (state === "failure") { + expect(surface.jobs).toContainEqual({ + jobId: 9091, + runId: "909", + runAttempt: 1, + status: "completed", + conclusion: "failure", + steps: [], + }); + } + }, + ); + + it("limits the dispatch inventory claim to normal ClawHub", async () => { + const fixture = publicationFixture(); + const result = await runPublicationCli(fixture, undefined, async (_responses, artifact) => { + await artifact(4, fixture.publisher, "openclaw-release-children-88-1", "dispatch.json", { + schemaVersion: 1, + repository: REPOSITORY, + parentRunId: "88", + parentRunAttempt: "1", + parentWorkflow: PUBLISH_PATH, + toolingRef: PUBLISH_REF, + toolingFullRef: `refs/tags/${PUBLISH_REF}`, + toolingSha: PUBLISH_SHA, + candidateSha: TARGET_SHA, + normalClawHubRunId: null, + normalClawHubRunAttempt: null, + }); + }); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).publication.dispatches[0]).toMatchObject({ + scope: "normal-clawhub", + state: "not-dispatched", + }); + expect(JSON.parse(result.stdout).publication.surfaces.pluginNpm.selection).toBe("unknown"); + }); + + it.each(["complete", "denied", "duplicate", "changed-total"])( + "handles a second artifact page: %s", + async (kind) => { + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + const prefix = `repos/${REPOSITORY}/actions/runs/88/artifacts?per_page=100&page=`; + const first = responses[`${prefix}1`] as { artifacts: unknown[] }; + const diagnostic = first.artifacts[0]; + responses[`${prefix}1`] = { + total_count: 101, + artifacts: Array.from({ length: 100 }, (_, i) => ({ id: 1000 + i, name: `other-${i}` })), + }; + responses[`${prefix}2`] = + kind === "denied" + ? { failure: "403" } + : { + total_count: kind === "changed-total" ? 102 : 101, + artifacts: [kind === "duplicate" ? { id: 1000, name: "duplicate" } : diagnostic], + }; + }); + expect(result.status, result.stdout).toBe(kind === "complete" ? 0 : 1); + expect(JSON.parse(result.stdout).publication.collection.complete).toBe(kind === "complete"); + }, + ); + + it.each(["traversal", "unexpected-entry", "expanded", "truncated", "corrupt", "duplicate-entry"])( + "refuses %s archives before projecting diagnostics", + async (kind) => { + const fixture = publicationFixture(); + const result = await runPublicationCli(fixture, undefined, async (responses, artifact) => { + await artifact( + 3, + fixture.publisher, + "openclaw-release-postpublish-diagnostics-88-1", + DIAGNOSTIC_FILE, + fixture.diagnostic, + (zip) => { + if (kind === "traversal") { + zip.file("../escape.json", "{}"); + } + if (kind === "unexpected-entry") { + zip.file("extra.json", "{}"); + } + if (kind === "expanded") { + zip.file(DIAGNOSTIC_FILE, " ".repeat(128 * 1024 + 1)); + } + if (kind === "duplicate-entry") { + zip.file("x".repeat(DIAGNOSTIC_FILE.length), "{}"); + } + }, + ); + const archive = responses[`repos/${REPOSITORY}/actions/artifacts/3/zip`] as { + binary: string; + }; + let bytes = Buffer.from(archive.binary, "base64"); + if (kind === "truncated") { + bytes = bytes.subarray(0, -10); + } + if (kind === "corrupt") { + bytes[0] = 0; + } + if (kind === "duplicate-entry") { + const needle = Buffer.from("x".repeat(DIAGNOSTIC_FILE.length)); + for (let offset = bytes.indexOf(needle); offset !== -1; offset = bytes.indexOf(needle)) { + bytes.set(Buffer.from(DIAGNOSTIC_FILE), offset); + } + } + archive.binary = bytes.toString("base64"); + Object.assign(responses[`repos/${REPOSITORY}/actions/artifacts/3`] as object, { + size_in_bytes: bytes.length, + digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + }); + }); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).publication.verification.state).toBe("unknown"); + }, + ); + + it.each(["metadata-drift", "postread-expiry", "page-limit", "json-limit"])( + "bounds %s without fallback", + async (kind) => { + const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { + const metadataPath = `repos/${REPOSITORY}/actions/artifacts/3`; + if (kind === "metadata-drift" || kind === "postread-expiry") { + const original = responses[metadataPath] as object; + responses[metadataPath] = { + sequence: [ + original, + { + ...original, + ...(kind === "metadata-drift" ? { id: 4 } : { expires_at: "2000-01-01T00:00:00Z" }), + }, + ], + }; + } + if (kind === "page-limit") { + responses[`repos/${REPOSITORY}/actions/runs/88/artifacts?per_page=100&page=1`] = { + total_count: 1001, + artifacts: [], + }; + } + if (kind === "json-limit") { + responses[`repos/${REPOSITORY}/actions/runs/88`] = { + raw: " ".repeat(2 * 1024 * 1024 + 1), + }; + } + }); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).publication.collection.complete).toBe(false); + }, + ); + + it("enforces the whole-command deadline independently of continuation settings", async () => { + const result = await runPublicationCli( + publicationFixture(), + undefined, + undefined, + false, + 100000, + ); + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout).publication.collection.error).toBe("deadline"); + expect(result.calls).toHaveLength(1); + }); + + it.each([ + [1, true], + [100, true], + [100, false], + ] as const)( + "caps final output with %i steps (JSON=%s) without erasing observations", + async (steps, json) => { + const fixture = publicationFixture(); + Reflect.set( + fixture.diagnostic.stages.pluginNpm, + "packages", + Array.from({ length: 256 }, (_, index) => ({ + name: `@openclaw/fixture-${index}`, + state: "success", + publication: "observed", + error: null, + })), + ); + const args = [ + "status", + "--run", + "77", + "--publication-run", + "88", + ...(json ? ["--json"] : []), + ]; + const result = await runPublicationCli(fixture, args, (responses) => { + const jobs = Array.from({ length: 999 }, (_, i) => ({ + ...job("Mirror Docker images to Vercel Container Registry / mirror"), + id: 10000 + i, + run_id: 88, + run_attempt: 1, + steps: Array.from({ length: steps }, (_step, index) => ({ + number: index + 1, + name: "Copy and verify immutable release images", + status: "completed", + conclusion: "success", + })), + })); + jobs.unshift(publicationFixture().publisherJobs[0]!); + for (let page = 1; page <= 10; page++) { + responses[ + `repos/${REPOSITORY}/actions/runs/88/attempts/1/jobs?per_page=100&page=${page}` + ] = { + total_count: jobs.length, + jobs: jobs.slice((page - 1) * 100, page * 100), + }; + } + }); + expect(result.status).toBe(1); + if (json) { + const publication = JSON.parse(result.stdout).publication; + expect(publication.collection).toEqual({ + complete: false, + error: "limits", + outputTruncated: true, + validationStatusOmitted: true, + }); + expect(publication.publisher).toMatchObject({ runId: "88", runAttempt: 1 }); + expect(publication.relationship).toMatchObject({ + status: "verified", + originalPlanAttempt: 1, + validationAttempt: 2, + }); + expect(publication.diagnostics).toMatchObject({ state: "available", artifactId: 3 }); + expect(publication.surfaces.coreNpm.registryObservation.state).toBe("observed"); + expect(publication.surfaces.vcr.jobs).toHaveLength(4); + expect(publication.surfaces.vcr.jobsOmitted).toBe(995); + expect(publication.surfaces.vcr.jobs[0].steps).toHaveLength(steps); + expect(publication.surfaces.vcr.jobs[0].steps[0].conclusion).toBe("success"); + expect(publication.surfaces.pluginNpm.packages).toHaveLength(4); + expect(publication.surfaces.pluginNpm.packagesOmitted).toBe(252); + expect(publication.surfaces.pluginNpm.packages[0]).toMatchObject({ + name: "@openclaw/fixture-0", + state: "success", + publication: "observed", + }); + } else { + expect(result.stdout).toContain("relationship: verified"); + expect(result.stdout).toContain("publisher: 88 attempt=1"); + expect(result.stdout).toContain("registry-observation=observed"); + expect(result.stdout).toContain("jobs omitted: 995"); + expect(result.stdout).toContain("packages omitted: 252"); + expect(result.stdout).toContain("validation detail omitted: output limit"); + } + expect(Buffer.byteLength(result.stdout)).toBeLessThan(256 * 1024); + }, + ); + + it("preserves legacy status JSON and performs no publication reads without the selector", async () => { + const result = await runPublicationCli(publicationFixture(), [ + "status", + "--run", + "77", + "--json", + ]); + expect(result.status, result.stderr).toBe(0); + const value = JSON.parse(result.stdout); + expect(Object.keys(value)).toEqual(["children", "failed", "active", "missing", "passed"]); + expect(value.children).toHaveLength(4); + expect(value.passed).toHaveLength(4); + expect(result.calls).toHaveLength(9); + expect(result.calls.flat().join(" ")).not.toMatch( + /publication|runs\/88|workflows\/|artifacts\//u, + ); + }); + + it.each(["continue", "verify"])( + "preserves legacy %s plan refusal without publication reads", + async (command) => { + const fixture = publicationFixture(); + Object.assign(fixture.executionPlan, historicalExecutionPlanArtifact()); + for (const key of ["attemptEvidenceVersion", "candidate", "candidateRequest", "repository"]) { + Reflect.deleteProperty(fixture.executionPlan, key); + } + const result = await runPublicationCli(fixture, [ + command, + "--run", + "77", + ...(command === "continue" ? ["--failed"] : []), + ]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("predates attempt-aware immutable plans"); + expect(result.calls).toHaveLength(1); + expect(result.calls[0]?.slice(0, 3)).toEqual(["run", "download", "77"]); + }, + ); + + it("strips arbitrary artifact extras, job names, URLs and control characters from output", async () => { + const fixture = publicationFixture(); + const unsafe = "synthetic-secret /private/fixture/key \u001b[31m"; + Reflect.set(fixture.diagnostic, "rawError", unsafe); + fixture.publisher.display_title = unsafe; + fixture.publisherJobs.push({ ...job(unsafe), id: 8899, run_id: 88, run_attempt: 1, steps: [] }); + const result = await runPublicationCli(fixture); + expect(result.status).toBe(0); + expect(result.stdout + result.stderr).not.toMatch(/synthetic-secret|private\/fixture/u); + expect(result.stdout + result.stderr).not.toContain("\u001b"); + }); +}); diff --git a/test/scripts/frv.test-support.ts b/test/scripts/frv.test-support.ts new file mode 100644 index 000000000000..78c932856a6d --- /dev/null +++ b/test/scripts/frv.test-support.ts @@ -0,0 +1,208 @@ +import { buildFullReleaseCandidateRequest } from "../../scripts/full-release-candidate-contract.mjs"; +import { + buildReleaseExecutionPlan, + buildReleaseExecutionPlanArtifact, + releaseChildSpec, + releaseExecutionPlanSha256, +} from "../../scripts/full-release-validation-policy.mjs"; + +export const SHA = "a".repeat(40); +export const TARGET_SHA = "b".repeat(40); +export const SOURCE_REF = `release-ci/${SHA.slice(0, 12)}-77`; +export const REPOSITORY = "openclaw/openclaw"; + +export function job(name: string, conclusion = "success") { + return { + completed_at: "2026-08-22T00:01:00Z", + conclusion, + html_url: `https://example.invalid/jobs/${name}`, + name, + started_at: "2026-08-22T00:00:00Z", + status: "completed", + }; +} + +export function child(key: string, runId: string) { + const spec = releaseChildSpec(key); + return { + displayTitle: `${spec.displayName} full-release-validation-77-1${spec.suffix}`, + key, + required: true, + runAttempt: 1, + runId, + selected: true, + sourceParentAttempt: 1, + url: `https://github.com/${REPOSITORY}/actions/runs/${runId}`, + workflow: spec.workflow, + workflowRef: SOURCE_REF, + workflowSha: SHA, + }; +} + +export function withoutChildRunIdentity(entry: ReturnType) { + const missing = structuredClone(entry); + Reflect.set(missing, "runAttempt", null); + Reflect.set(missing, "runId", ""); + Reflect.set(missing, "url", ""); + return missing; +} + +export function requiredChildren() { + return [ + child("normalCi", "101"), + child("pluginPrerelease", "202"), + child("releaseChecks", "303"), + child("productPerformance", "404"), + ]; +} + +export function plan(children = requiredChildren()) { + return { + attemptEvidenceVersion: 2, + children, + parentRunAttempt: 1, + parentRunId: "77", + releaseProfile: "beta", + rerunGroup: "all", + targetSha: TARGET_SHA, + trustedWorkflow: { fullRef: "refs/heads/main", ref: "main", sha: SHA }, + workflowRef: SOURCE_REF, + workflowSha: SHA, + }; +} + +export function executionPlanArtifact({ + children = requiredChildren(), + evidenceReuse = { requested: false }, +}: { + children?: ReturnType; + evidenceReuse?: Record; +} = {}) { + const built = buildReleaseExecutionPlan({ + children: Object.fromEntries( + children.map((entry) => [ + entry.key, + { + result: "success", + runAttempt: entry.runAttempt, + runId: entry.runId, + url: entry.url, + }, + ]), + ), + dockerPreflightResult: "success", + evidenceReuse: evidenceReuse.requested === true, + parentRunAttempt: 1, + parentRunId: "77", + candidateBindingResult: "success", + rerunGroup: "all", + resolveTargetResult: "success", + workflowRef: SOURCE_REF, + workflowSha: SHA, + }); + const candidateRequest = buildFullReleaseCandidateRequest({ + repository: REPOSITORY, + targetSha: TARGET_SHA, + toolingSha: SHA, + releaseProfile: "beta", + releaseSoak: false, + upgradeSurvivorBaseline: "openclaw@latest", + upgradeSurvivorBaselines: "", + upgradeSurvivorScenarios: "", + allowFrozenTargetScenarioOmissions: false, + allowUnreleasedChangelog: false, + packagePublished: false, + sharedImagePolicy: "no-push-artifact", + }); + const selectedKeys = new Set(children.map((entry) => entry.key)); + return buildReleaseExecutionPlanArtifact({ + attemptEvidenceVersion: 2, + candidate: null, + children: built.children.map((entry) => + selectedKeys.has(entry.key) + ? entry + : { + ...entry, + required: false, + result: "skipped", + runAttempt: null, + runId: "", + selected: false, + url: "", + }, + ), + evidenceReuse, + expected: { + candidateRequest, + parentRunAttempt: 1, + parentRunId: "77", + repository: REPOSITORY, + targetSha: TARGET_SHA, + workflowRef: SOURCE_REF, + workflowSha: SHA, + }, + gates: built.gates, + releaseProfile: "beta", + rerunGroup: "all", + trustedWorkflow: { fullRef: "refs/heads/main", ref: "main", sha: SHA }, + }); +} + +export function historicalExecutionPlanArtifact() { + const artifact = structuredClone(executionPlanArtifact()); + delete artifact.attemptEvidenceVersion; + delete artifact.candidate; + delete artifact.candidateRequest; + delete artifact.repository; + for (const entry of artifact.children) { + delete entry.sourceParentAttempt; + } + artifact.sha256 = releaseExecutionPlanSha256(artifact); + return artifact; +} + +export function runFor( + entry: ReturnType, + attempt: number, + conclusion: string | null, + status = conclusion === null ? "in_progress" : "completed", +) { + return { + actor: { login: "github-actions[bot]" }, + conclusion, + display_title: entry.displayTitle, + event: "workflow_dispatch", + head_branch: entry.workflowRef, + head_sha: entry.workflowSha, + html_url: entry.url, + id: Number(entry.runId), + path: `.github/workflows/${entry.workflow}`, + repository: { full_name: REPOSITORY }, + run_attempt: attempt, + status, + triggering_actor: { + login: attempt === entry.runAttempt ? "github-actions[bot]" : "release-operator", + }, + }; +} + +export function rootRun( + attempt = 1, + conclusion: string | null = "failure", + status = conclusion === null ? "in_progress" : "completed", +) { + return { + actor: { login: "github-actions[bot]" }, + conclusion, + display_title: "Full Release Validation", + event: "workflow_dispatch", + head_branch: SOURCE_REF, + head_sha: SHA, + id: 77, + path: ".github/workflows/full-release-validation.yml", + repository: { full_name: REPOSITORY }, + run_attempt: attempt, + status, + triggering_actor: { login: attempt === 1 ? "github-actions[bot]" : "release-operator" }, + }; +} diff --git a/test/scripts/frv.test.ts b/test/scripts/frv.test.ts index 6cf0950edf80..bad80ee6129e 100644 --- a/test/scripts/frv.test.ts +++ b/test/scripts/frv.test.ts @@ -1,10 +1,3 @@ -import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; -import { pathToFileURL } from "node:url"; -import JSZip from "jszip"; import { describe, expect, it, vi } from "vitest"; import { continueFailed, @@ -13,220 +6,27 @@ import { loadPlan, preflightContinuation, } from "../../scripts/frv.mjs"; -import { buildFullReleaseCandidateRequest } from "../../scripts/full-release-candidate-contract.mjs"; import { - buildReleaseExecutionPlan, - buildReleaseExecutionPlanArtifact, releaseChildSpec, releaseCompositeJobsSha256, releaseExecutionPlanSha256, validateReleaseExecutionPlanArtifact, } from "../../scripts/full-release-validation-policy.mjs"; +import { createReleaseEvidenceClient } from "../../scripts/release-ci-summary.mjs"; import { - createReleaseEvidenceClient, - validateParentManifest, -} from "../../scripts/release-ci-summary.mjs"; - -const SHA = "a".repeat(40); -const TARGET_SHA = "b".repeat(40); -const SOURCE_REF = `release-ci/${SHA.slice(0, 12)}-77`; -const REPOSITORY = "openclaw/openclaw"; - -function job(name: string, conclusion = "success") { - return { - completed_at: "2026-08-22T00:01:00Z", - conclusion, - html_url: `https://example.invalid/jobs/${name}`, - name, - started_at: "2026-08-22T00:00:00Z", - status: "completed", - }; -} - -function child(key: string, runId: string) { - const spec = releaseChildSpec(key); - return { - displayTitle: `${spec.displayName} full-release-validation-77-1${spec.suffix}`, - key, - required: true, - runAttempt: 1, - runId, - selected: true, - sourceParentAttempt: 1, - url: `https://github.com/${REPOSITORY}/actions/runs/${runId}`, - workflow: spec.workflow, - workflowRef: SOURCE_REF, - workflowSha: SHA, - }; -} - -function withoutChildRunIdentity(entry: ReturnType) { - const missing = structuredClone(entry); - Reflect.set(missing, "runAttempt", null); - Reflect.set(missing, "runId", ""); - Reflect.set(missing, "url", ""); - return missing; -} - -function requiredChildren() { - return [ - child("normalCi", "101"), - child("pluginPrerelease", "202"), - child("releaseChecks", "303"), - child("productPerformance", "404"), - ]; -} - -function plan(children = requiredChildren()) { - return { - attemptEvidenceVersion: 2, - children, - parentRunAttempt: 1, - parentRunId: "77", - releaseProfile: "beta", - rerunGroup: "all", - targetSha: TARGET_SHA, - trustedWorkflow: { fullRef: "refs/heads/main", ref: "main", sha: SHA }, - workflowRef: SOURCE_REF, - workflowSha: SHA, - }; -} - -function executionPlanArtifact({ - children = requiredChildren(), - evidenceReuse = { requested: false }, -}: { - children?: ReturnType; - evidenceReuse?: Record; -} = {}) { - const built = buildReleaseExecutionPlan({ - children: Object.fromEntries( - children.map((entry) => [ - entry.key, - { - result: "success", - runAttempt: entry.runAttempt, - runId: entry.runId, - url: entry.url, - }, - ]), - ), - dockerPreflightResult: "success", - evidenceReuse: evidenceReuse.requested === true, - parentRunAttempt: 1, - parentRunId: "77", - candidateBindingResult: "success", - rerunGroup: "all", - resolveTargetResult: "success", - workflowRef: SOURCE_REF, - workflowSha: SHA, - }); - const candidateRequest = buildFullReleaseCandidateRequest({ - repository: REPOSITORY, - targetSha: TARGET_SHA, - toolingSha: SHA, - releaseProfile: "beta", - releaseSoak: false, - upgradeSurvivorBaseline: "openclaw@latest", - upgradeSurvivorBaselines: "", - upgradeSurvivorScenarios: "", - allowFrozenTargetScenarioOmissions: false, - allowUnreleasedChangelog: false, - packagePublished: false, - sharedImagePolicy: "no-push-artifact", - }); - const selectedKeys = new Set(children.map((entry) => entry.key)); - return buildReleaseExecutionPlanArtifact({ - attemptEvidenceVersion: 2, - candidate: null, - children: built.children.map((entry) => - selectedKeys.has(entry.key) - ? entry - : { - ...entry, - required: false, - result: "skipped", - runAttempt: null, - runId: "", - selected: false, - url: "", - }, - ), - evidenceReuse, - expected: { - candidateRequest, - parentRunAttempt: 1, - parentRunId: "77", - repository: REPOSITORY, - targetSha: TARGET_SHA, - workflowRef: SOURCE_REF, - workflowSha: SHA, - }, - gates: built.gates, - releaseProfile: "beta", - rerunGroup: "all", - trustedWorkflow: { fullRef: "refs/heads/main", ref: "main", sha: SHA }, - }); -} - -function historicalExecutionPlanArtifact() { - const artifact = structuredClone(executionPlanArtifact()); - delete artifact.attemptEvidenceVersion; - delete artifact.candidate; - delete artifact.candidateRequest; - delete artifact.repository; - for (const entry of artifact.children) { - delete entry.sourceParentAttempt; - } - artifact.sha256 = releaseExecutionPlanSha256(artifact); - return artifact; -} - -function runFor( - entry: ReturnType, - attempt: number, - conclusion: string | null, - status = conclusion === null ? "in_progress" : "completed", -) { - return { - actor: { login: "github-actions[bot]" }, - conclusion, - display_title: entry.displayTitle, - event: "workflow_dispatch", - head_branch: entry.workflowRef, - head_sha: entry.workflowSha, - html_url: entry.url, - id: Number(entry.runId), - path: `.github/workflows/${entry.workflow}`, - repository: { full_name: REPOSITORY }, - run_attempt: attempt, - status, - triggering_actor: { - login: attempt === entry.runAttempt ? "github-actions[bot]" : "release-operator", - }, - }; -} - -function rootRun( - attempt = 1, - conclusion: string | null = "failure", - status = conclusion === null ? "in_progress" : "completed", -) { - return { - actor: { login: "github-actions[bot]" }, - conclusion, - display_title: "Full Release Validation", - event: "workflow_dispatch", - head_branch: SOURCE_REF, - head_sha: SHA, - id: 77, - path: ".github/workflows/full-release-validation.yml", - repository: { full_name: REPOSITORY }, - run_attempt: attempt, - status, - triggering_actor: { login: attempt === 1 ? "github-actions[bot]" : "release-operator" }, - }; -} + SHA, + TARGET_SHA, + SOURCE_REF, + REPOSITORY, + job, + child, + withoutChildRunIdentity, + plan, + executionPlanArtifact, + historicalExecutionPlanArtifact, + runFor, + rootRun, +} from "./frv.test-support.js"; function preflightMethods( children: ReturnType[], @@ -1439,1515 +1239,3 @@ describe("FRV strict verifier", () => { ).rejects.toThrow("verification failed"); }); }); - -describe("FRV protected gh evidence reads", () => { - const jobLogArgs = [ - "api", - `repos/${REPOSITORY}/actions/jobs/1/logs`, - "-H", - "Cache-Control: max-age=0", - ]; - - it.each([ - ["getRun", ["101"], "actions/runs/101", { run_attempt: 2 }], - ["getRunAttempt", ["101", 2], "actions/runs/101/attempts/2", { run_attempt: 2 }], - [ - "getAttemptJobs", - ["101", 2], - "actions/runs/101/attempts/2/jobs?per_page=100", - [{ id: 1 }, { id: 2 }], - ], - [ - "getParentJobs", - ["77"], - "actions/runs/77/jobs?filter=all&per_page=100", - [{ id: 1 }, { id: 2 }], - ], - ["getJobLog", [1], "actions/jobs/1/logs", "job evidence"], - ])("revalidates %s through the default protected route", (method, args, endpoint, expected) => { - const result = runProtectedFrv(method, args as Array, endpoint); - expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(result.stdout)).toEqual(expected); - expect(result.calls).toHaveLength(1); - }); - - it("falls back once when gh does not support the escape-sequence flag", () => { - const result = runProtectedFrv("getJobLog", [1], "actions/jobs/1/logs", "legacy-flag"); - expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(result.stdout)).toBe("job evidence"); - expect(result.calls).toEqual([[...jobLogArgs, "--allow-escape-sequences"], jobLogArgs]); - }); - - it("does not fall back after an unrelated job-log error", () => { - const result = runProtectedFrv("getJobLog", [1], "actions/jobs/1/logs", "unrelated"); - expect(result.status).toBe(23); - expect(result.stderr).toContain("unrelated log failure"); - expect(result.calls).toEqual([[...jobLogArgs, "--allow-escape-sequences"]]); - }); - - it("preserves protected refusal status without retry or alternate execution", () => { - const result = runProtectedFrv("getRun", ["101"], "actions/runs/101", "protected"); - expect(result.status).toBe(19); - expect(result.stderr).toContain("protected refusal"); - expect(result.calls).toHaveLength(1); - }); -}); - -function runProtectedFrv( - method: string, - args: Array, - endpoint: string, - failure: "none" | "legacy-flag" | "protected" | "unrelated" = "none", -) { - const root = mkdtempSync(join(tmpdir(), "frv-protected-")); - const gh = join(root, "gh"); - writeFileSync( - gh, - `#!${process.execPath} -const fs = require("node:fs"); -const args = process.argv.slice(2); -fs.appendFileSync("calls.jsonl", JSON.stringify(args) + "\\n"); -const fail = (message, code) => { console.error(message); process.exit(code); }; -const failure = ${JSON.stringify(failure)}; -if (failure === "protected") fail("protected refusal", 19); -if (args[0] !== "api" || !args.includes(${JSON.stringify(`repos/${REPOSITORY}/${endpoint}`)})) fail("unexpected request", 17); -if (!args.some((arg, i) => ["-H", "--header"].includes(arg) && args[i+1] === "Cache-Control: max-age=0")) fail("missing live header", 18); -if (${endpoint.endsWith("/logs")} && failure === "legacy-flag" && args.includes("--allow-escape-sequences")) fail("unknown flag: --allow-escape-sequences", 1); -if (${endpoint.endsWith("/logs")} && failure === "unrelated") fail("unrelated log failure", 23); -if (${endpoint.endsWith("/logs")} && failure === "none" && !args.includes("--allow-escape-sequences")) fail("missing escape-sequence flag", 20); -if (${endpoint.includes("/jobs?")}) { - if (!args.includes("--paginate") || !args.includes(".jobs[] | @json")) fail("missing pagination", 17); - console.log('{"id":1}\\n{"id":2}'); -} else console.log(${endpoint.endsWith("/logs") ? JSON.stringify("job evidence") : JSON.stringify('{"run_attempt":2}')}); -`, - ); - chmodSync(gh, 0o755); - try { - const moduleUrl = pathToFileURL(join(process.cwd(), "scripts/frv.mjs")).href; - const result = spawnSync( - process.execPath, - [ - "--input-type=module", - "-e", - ` - import {createClient} from ${JSON.stringify(moduleUrl)}; - try { - console.log(JSON.stringify(await createClient(${JSON.stringify(REPOSITORY)})[${JSON.stringify(method)}](...${JSON.stringify(args)}))); - } catch (error) { console.error(error.message); process.exitCode = error.code; } - `, - ], - { - cwd: root, - encoding: "utf8", - env: { HOME: root, PATH: `${root}${delimiter}${process.env.PATH ?? ""}` }, - }, - ); - return { - ...result, - calls: readFileSync(join(root, "calls.jsonl"), "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line)), - }; - } finally { - rmSync(root, { recursive: true, force: true }); - } -} - -const PUBLISH_SHA = "c".repeat(40); -const PUBLISH_REF = `release-publish/${PUBLISH_SHA.slice(0, 12)}-88`; -const DIAGNOSTIC_FILE = "release-postpublish-diagnostics.json"; -const PUBLISH_PATH = ".github/workflows/openclaw-release-publish.yml"; -const FRV_PATH = ".github/workflows/full-release-validation.yml"; - -function publicationFixture() { - const executionPlan = executionPlanArtifact(); - const publisher = { - ...rootRun(), - id: 88, - workflow_id: 800, - path: `${PUBLISH_PATH}@refs/tags/${PUBLISH_REF}`, - head_branch: PUBLISH_REF, - head_sha: PUBLISH_SHA, - repository: { full_name: REPOSITORY }, - }; - const root = { - ...rootRun(2, "success"), - id: 77, - workflow_id: 700, - path: `${FRV_PATH}@${SOURCE_REF}`, - head_branch: SOURCE_REF, - head_sha: SHA, - repository: { full_name: REPOSITORY }, - }; - const manifest = { - version: 3, - workflowName: "Full Release Validation", - runId: "77", - runAttempt: "2", - workflowRef: SOURCE_REF, - workflowFullRef: `refs/heads/${SOURCE_REF}`, - workflowRefType: "branch", - workflowSha: SHA, - targetSha: TARGET_SHA, - releaseProfile: "beta", - rerunGroup: "all", - runReleaseSoak: "false", - controls: { performanceReportPublication: "artifact-only" }, - validationInputs: {}, - candidateBinding: null, - executionPlanSha256: executionPlan.sha256, - sourceParentRunAttempt: 1, - childRuns: { - normalCi: "101", - npmTelegram: "", - pluginPrerelease: "202", - releaseChecks: "303", - productPerformance: { runId: "404" }, - }, - }; - const stage = (state = "unattempted", publication = "unknown") => ({ - state, - publication, - error: null, - packages: [], - packagesTruncated: false, - }); - const diagnostic = { - schemaVersion: 1, - kind: "release-postpublish-diagnostics", - invocationId: "12345678-1234-4234-8234-123456789abc", - context: { - repository: REPOSITORY, - releaseVersion: "2026.9.9", - releaseTag: "v2026.9.9", - npmDistTag: "latest", - requestedSourceSha: TARGET_SHA, - toolingSha: PUBLISH_SHA, - suppliedToolingSha: PUBLISH_SHA, - suppliedToolingRef: `refs/tags/${PUBLISH_REF}`, - parentRunId: "88", - parentRunAttempt: "1", - validationEvidence: { mode: "full-release-validation", runId: "77", runAttempt: "2" }, - }, - selection: { - plugins: [], - pluginsTruncated: false, - workflowRef: PUBLISH_REF, - clawHubWorkflowRef: PUBLISH_REF, - }, - verification: "failure", - currentStage: "pluginNpm", - stages: { - checkout: stage("success"), - githubRelease: stage("skipped"), - coreNpm: stage("success", "observed"), - postpublish: stage("success"), - pluginNpm: stage("failure"), - clawHub: stage(), - fullReleaseValidation: stage(), - pluginNpmRun: stage(), - pluginClawHubRun: stage(), - pluginClawHubBootstrap: stage("skipped"), - openclawNpm: stage(), - npmTelegram: stage("skipped"), - evidence: stage(), - binding: stage(), - assets: stage(), - }, - children: Object.fromEntries( - [ - "fullReleaseValidation", - "openclawNpm", - "pluginNpm", - "pluginClawHub", - "pluginClawHubBootstrap", - "npmTelegram", - ].map((name) => [ - name, - { - suppliedRunId: null, - runAttempt: null, - producerRunAttempt: null, - status: "unknown", - conclusion: "unknown", - failedJobCount: null, - readbackArtifactId: null, - packageArtifactId: null, - }, - ]), - ), - jobOutcomeBeforeArtifactUploads: "failure", - stepOutcomes: { coreStart: "success", completion: "failure" }, - }; - const publisherJobs = [ - { - ...job("Publish plugins, then OpenClaw", "failure"), - id: 8801, - run_id: 88, - run_attempt: 1, - steps: [ - { - number: 1, - name: "Upload postpublish diagnostics", - status: "completed", - conclusion: "success", - }, - ], - }, - ]; - return { executionPlan, manifest, diagnostic, publisher, root, publisherJobs }; -} - -async function runPublicationCli( - fixture = publicationFixture(), - args = ["status", "--run", "77", "--publication-run", "88", "--json"], - amend: ( - responses: Record, - addArtifact: ( - id: number, - run: typeof fixture.root, - name: string, - filename: string, - value: unknown, - zipChange?: (zip: JSZip) => void, - ) => Promise, - ) => void | Promise = () => {}, - explicitBinary = false, - clockStep = 0, -) { - const directory = mkdtempSync(join(tmpdir(), "frv-publication-")); - const legacy = !args.includes("--publication-run"); - const responses: Record = {}; - const endpoint = (path: string) => `repos/${REPOSITORY}/${path}`; - const artifactLists = new Map(); - async function artifact( - id: number, - run: typeof fixture.root, - name: string, - filename: string, - value: unknown, - zipChange?: (zip: JSZip) => void, - ) { - const zip = new JSZip(); - zip.file(filename, JSON.stringify(value), { unixPermissions: 0o100644 }); - zipChange?.(zip); - const bytes = await zip.generateAsync({ - type: "nodebuffer", - platform: "UNIX", - compression: "DEFLATE", - }); - const metadata = { - id, - name, - digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, - size_in_bytes: bytes.length, - expired: false, - expires_at: "2099-01-01T00:00:00Z", - workflow_run: { id: run.id, head_sha: run.head_sha }, - }; - responses[endpoint(`actions/artifacts/${id}`)] = metadata; - responses[endpoint(`actions/artifacts/${id}/zip`)] = { binary: bytes.toString("base64") }; - const artifacts = [ - ...(artifactLists.get(run.id) ?? []).filter((item) => (item as { id: number }).id !== id), - metadata, - ]; - artifactLists.set(run.id, artifacts); - responses[endpoint(`actions/runs/${run.id}/artifacts?per_page=100&page=1`)] = { - total_count: artifacts.length, - artifacts, - }; - } - await artifact( - 1, - fixture.root, - "full-release-execution-plan-77", - "full-release-execution-plan.json", - fixture.executionPlan, - ); - await artifact( - 2, - fixture.root, - "full-release-validation-77-2", - "full-release-validation-manifest.json", - fixture.manifest, - ); - await artifact( - 3, - fixture.publisher, - "openclaw-release-postpublish-diagnostics-88-1", - DIAGNOSTIC_FILE, - fixture.diagnostic, - ); - for (const run of [fixture.root, fixture.publisher]) { - responses[endpoint(`actions/runs/${run.id}`)] = run; - responses[endpoint(`actions/runs/${run.id}/attempts/${run.run_attempt}`)] = run; - responses[endpoint(`actions/workflows/${run.workflow_id}`)] = { - id: run.workflow_id, - path: run.id === 77 ? FRV_PATH : PUBLISH_PATH, - }; - const artifacts = artifactLists.get(run.id) ?? []; - responses[endpoint(`actions/runs/${run.id}/artifacts?per_page=100&page=1`)] = { - total_count: artifacts.length, - artifacts, - }; - } - responses[endpoint("actions/runs/88/attempts/1/jobs?per_page=100&page=1")] = { - total_count: fixture.publisherJobs.length, - jobs: fixture.publisherJobs, - }; - for (const entry of requiredChildren()) { - const run = { ...runFor(entry, 1, "success"), workflow_id: Number(entry.runId) + 1000 }; - responses[endpoint(`actions/runs/${entry.runId}`)] = run; - responses[endpoint(`actions/workflows/${run.workflow_id}`)] = { - id: run.workflow_id, - path: run.path, - }; - responses[endpoint(`actions/runs/${entry.runId}/attempts/1/jobs?per_page=100&page=1`)] = { - total_count: 1, - jobs: [{ ...job("test"), id: Number(entry.runId) * 10, run_id: run.id, run_attempt: 1 }], - }; - } - await amend(responses, artifact); - writeFileSync(join(directory, "responses.json"), JSON.stringify(responses)); - writeFileSync(join(directory, "legacy-plan.json"), JSON.stringify(fixture.executionPlan)); - writeFileSync( - join(directory, "no-network.mjs"), - `import childProcess from "node:child_process"; -import { syncBuiltinESMExports } from "node:module"; -globalThis.fetch = () => { throw new Error('unplanned Node fetch'); }; -for (const name of ["execFileSync", "execFile", "spawn", "spawnSync"]) { - const original = childProcess[name]; - const guarded = (method) => (command, ...args) => { - if (command !== "gh" && command !== ${JSON.stringify(join(directory, "gh"))}) throw new Error("unplanned executable"); - return method(command, ...args); - }; - childProcess[name] = guarded(original); - const custom = Symbol.for("nodejs.util.promisify.custom"); - if (original[custom]) childProcess[name][custom] = guarded(original[custom]); -} -syncBuiltinESMExports(); -${clockStep ? `let ticks = 0; const now = Date.now(); Date.now = () => now + ticks++ * ${clockStep};` : ""} -`, - ); - const gh = join(directory, "gh"); - writeFileSync( - gh, - `#!${process.execPath} -const fs = require("node:fs"); -const args = process.argv.slice(2); -fs.appendFileSync("calls.jsonl", JSON.stringify(args) + "\\n"); -const reject = () => { console.error("unplanned or mutating request"); process.exit(23); }; -const legacy = ${legacy}; -if (legacy && args[0] === "run" && args[1] === "download" && args[2] === "77" && args[args.indexOf("--name") + 1] === "full-release-execution-plan-77") { - fs.copyFileSync("legacy-plan.json", require("node:path").join(args[args.indexOf("--dir") + 1], "full-release-execution-plan.json")); - process.exit(0); -} -if (args[0] !== "api" || (!legacy && (!args.includes("GET") || !args.includes("github.com"))) || !args.includes("Cache-Control: max-age=0")) reject(); -if (args.includes("--include") || (!legacy && args.includes("--paginate"))) reject(); -let path = args.find(a => a.startsWith("repos/")); -if (legacy && path.endsWith("/jobs?per_page=100")) path += "&page=1"; -const table = JSON.parse(fs.readFileSync("responses.json", "utf8")); -if (!Object.hasOwn(table, path)) reject(); -let value = table[path]; -if (value.sequence) { - const reads = fs.readFileSync("calls.jsonl", "utf8").trim().split("\\n").map(JSON.parse).filter(a => a.includes(path)).length; - value = value.sequence[Math.min(reads - 1, value.sequence.length - 1)]; -} -if (value.failure) { console.error(value.failure); process.exit(1); } -if (legacy && value.jobs) process.stdout.write(value.jobs.map(job => JSON.stringify(job)).join("\\n")); -else if (value.binary) process.stdout.write(Buffer.from(value.binary, "base64")); -else if (value.raw) process.stdout.write(value.raw); -else process.stdout.write(JSON.stringify(value)); -`, - ); - chmodSync(gh, 0o755); - try { - const result = spawnSync( - process.execPath, - [ - "--import", - join(directory, "no-network.mjs"), - join(process.cwd(), "scripts/frv.mjs"), - ...args, - ], - { - cwd: directory, - encoding: "utf8", - timeout: 30_000, - env: { - HOME: directory, - PATH: directory, - ...(explicitBinary ? { OPENCLAW_GH_BIN: gh, GH_TOKEN: "synthetic-fixture-token" } : {}), - }, - }, - ); - let calls: string[][] = []; - try { - calls = readFileSync(join(directory, "calls.jsonl"), "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - } catch { - // Usage rejection must happen before the first CLI read. - } - return { ...result, calls }; - } finally { - rmSync(directory, { recursive: true, force: true }); - } -} - -describe("publication status real CLI", () => { - it("joins a failed publisher to selected validation attempt two without erasing known readback", async () => { - const fixture = publicationFixture(); - validateParentManifest(fixture.manifest, { - runId: "77", - runAttempt: 2, - workflowRef: SOURCE_REF, - workflowSha: SHA, - }); - const result = await runPublicationCli(fixture); - expect( - result.status, - `${result.stderr}\n${JSON.stringify(JSON.parse(result.stdout).publication.relationship)}\n${JSON.stringify(JSON.parse(result.stdout).publication.collection)}`, - ).toBe(0); - const value = JSON.parse(result.stdout); - expect(value.publication.relationship).toMatchObject({ - status: "verified", - originalPlanAttempt: 1, - validationAttempt: 2, - }); - expect(value.publication.publisher).toMatchObject({ - runId: "88", - runAttempt: 1, - conclusion: "failure", - }); - expect(value.publication.surfaces.coreNpm.verification.state).toBe("success"); - expect(value.publication.surfaces.pluginNpm.verification.state).toBe("failure"); - expect(value.publication.surfaces.activation.operation.state).toBe("unknown"); - expect(value.children).toHaveLength(4); - expect(result.calls.length).toBeGreaterThan(0); - }); - - it.each([ - ["status", "--run", "77", "--publication-run"], - ["status", "--run", "77", "--publication-run", "0"], - ["status", "--run", "77", "--publication-run", "9007199254740992"], - ["status", "--run", "77", "--publication-run", "88", "--publication-run", "89"], - ["status", "--run", "77", "--run", "78", "--publication-run", "88"], - ["status", "--run", "77", "--publication-run", "88", "--repo", "../private"], - ["continue", "--failed", "--run", "77", "--publication-run", "88"], - ["verify", "--run", "77", "--publication-run", "88"], - ])("rejects invalid selectors before any read: %j", async (...args) => { - const result = await runPublicationCli(publicationFixture(), args); - expect(result.status).toBe(1); - expect(result.calls).toEqual([]); - expect(result.stderr).toContain("publication observation: usage"); - }); - - it("uses the selected explicit binary without Node fetch and keeps the text section separate", async () => { - const result = await runPublicationCli( - publicationFixture(), - ["status", "--run", "77", "--publication-run", "88"], - undefined, - true, - ); - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain("Publication observation (not release authorization)"); - expect(result.stdout).toContain("selected-attempt=2"); - expect(result.stdout).toContain( - "pluginNpm: selection=unknown operation=unknown verification=failure", - ); - expect(result.calls.every((call) => call[0] === "api" && call.includes("GET"))).toBe(true); - expect(result.stdout + result.stderr).not.toContain("synthetic-fixture-token"); - }); - - it.each([ - [ - "foreign repository", - (fixture: ReturnType) => { - fixture.publisher.repository.full_name = "other/repository"; - }, - ], - [ - "foreign event", - (fixture) => { - fixture.publisher.event = "push"; - }, - ], - [ - "foreign path", - (fixture) => { - fixture.publisher.path = ".github/workflows/other.yml"; - }, - ], - [ - "foreign path suffix", - (fixture) => { - fixture.publisher.path = `${PUBLISH_PATH}@main`; - }, - ], - [ - "wrong producer run", - (fixture) => { - fixture.diagnostic.context.parentRunId = "89"; - }, - ], - [ - "wrong producer attempt", - (fixture) => { - fixture.diagnostic.context.parentRunAttempt = "2"; - }, - ], - [ - "wrong tooling", - (fixture) => { - fixture.diagnostic.context.toolingSha = SHA; - }, - ], - [ - "wrong full ref", - (fixture) => { - fixture.diagnostic.context.suppliedToolingRef = "refs/heads/main"; - }, - ], - [ - "wrong validation root", - (fixture) => { - fixture.diagnostic.context.validationEvidence.runId = "78"; - }, - ], - [ - "future validation attempt", - (fixture) => { - fixture.diagnostic.context.validationEvidence.runAttempt = "3"; - }, - ], - [ - "wrong candidate", - (fixture) => { - fixture.diagnostic.context.requestedSourceSha = PUBLISH_SHA; - }, - ], - [ - "wrong source attempt", - (fixture) => { - fixture.manifest.sourceParentRunAttempt = 2; - }, - ], - [ - "wrong plan checksum", - (fixture) => { - fixture.manifest.executionPlanSha256 = "0".repeat(64); - }, - ], - [ - "wrong manifest tooling", - (fixture) => { - fixture.manifest.workflowSha = PUBLISH_SHA; - }, - ], - [ - "unsuccessful upload", - (fixture) => { - fixture.publisherJobs[0]!.steps[0]!.conclusion = "failure"; - }, - ], - ] satisfies [string, (fixture: ReturnType) => void][])( - "refuses %s", - async (_name, mutate) => { - const fixture = publicationFixture(); - mutate(fixture); - const result = await runPublicationCli(fixture); - expect(result.status, result.stdout).toBe(1); - expect(JSON.parse(result.stdout).publication.collection.complete).toBe(false); - expect(result.calls.every((call) => call[0] === "api" && call.includes("GET"))).toBe(true); - }, - ); - - it.each([ - "workflow-id", - "artifact-digest", - "artifact-size", - "artifact-producer", - "duplicate-name", - "duplicate-id", - "count-gap", - "attempt-limit", - ])("rejects independently observed %s", async (kind) => { - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - const prefix = `repos/${REPOSITORY}/actions/`; - const metadata = responses[`${prefix}artifacts/3`] as Record; - const list = responses[`${prefix}runs/88/artifacts?per_page=100&page=1`] as { - total_count: number; - artifacts: unknown[]; - }; - if (kind === "workflow-id") { - Object.assign(responses[`${prefix}workflows/800`] as object, { id: 801 }); - } - if (kind === "artifact-digest") { - metadata.digest = `sha256:${"0".repeat(64)}`; - } - if (kind === "artifact-size") { - metadata.size_in_bytes = 2 * 1024 * 1024 + 1; - } - if (kind === "artifact-producer") { - metadata.workflow_run = { id: 89, head_sha: PUBLISH_SHA }; - } - if (kind === "duplicate-name") { - list.artifacts.push({ ...metadata, id: 33 }); - list.total_count++; - } - if (kind === "duplicate-id") { - list.artifacts.push(metadata); - list.total_count++; - } - if (kind === "count-gap") { - list.total_count++; - } - if (kind === "attempt-limit") { - Object.assign(responses[`${prefix}runs/101`] as object, { run_attempt: 100000000 }); - } - }); - expect(result.status, result.stdout).toBe(1); - expect(JSON.parse(result.stdout).publication.collection.complete).toBe(false); - }); - - it.each(["88", "77", "101"])( - "refuses advancing run %s without restarting observation", - async (runId) => { - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - const path = `repos/${REPOSITORY}/actions/runs/${runId}`; - const before = responses[path] as { run_attempt: number }; - responses[path] = { - sequence: [before, { ...before, run_attempt: before.run_attempt + 1 }], - }; - }); - expect(result.status).toBe(1); - expect(JSON.parse(result.stdout).publication.collection.error).toBe("attempt-changed"); - expect( - result.calls.filter((call) => call.includes(`repos/${REPOSITORY}/actions/runs/${runId}`)), - ).toHaveLength(2); - }, - ); - - it.each(["missing", "expired", "legacy-only", "unsupported", "incomplete-link"])( - "keeps authenticated historical %s unknown, not failed publication", - async (kind) => { - const fixture = publicationFixture(); - if (kind === "unsupported") { - fixture.diagnostic.schemaVersion = 2; - } - if (kind === "incomplete-link") { - Reflect.set(fixture.diagnostic.context.validationEvidence, "runAttempt", null); - } - const result = await runPublicationCli(fixture, undefined, (responses) => { - const listPath = `repos/${REPOSITORY}/actions/runs/88/artifacts?per_page=100&page=1`; - if (kind === "missing") { - responses[listPath] = { total_count: 0, artifacts: [] }; - } - if (kind === "legacy-only") { - responses[listPath] = { - total_count: 1, - artifacts: [{ id: 99, name: "openclaw-release-postpublish-evidence-v2026.9.9" }], - }; - } - if (kind === "expired") { - Object.assign(responses[`repos/${REPOSITORY}/actions/artifacts/3`] as object, { - expired: true, - }); - } - }); - expect(result.status, result.stdout).toBe(0); - const publication = JSON.parse(result.stdout).publication; - expect(publication.collection.complete).toBe(true); - expect(publication.relationship.status).toBe("unverified"); - expect(publication.surfaces.coreNpm.operation.state).toBe("unknown"); - expect(publication.diagnostics.state).toBe(kind === "incomplete-link" ? "available" : kind); - }, - ); - - it.each(["403 quota", "404 unavailable", "network timeout"])( - "classifies %s as unavailable, never absence", - async (failure) => { - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - responses[`repos/${REPOSITORY}/actions/runs/88/artifacts?per_page=100&page=1`] = { - failure: `${failure}: /private/fixture/credential synthetic-secret`, - }; - }); - expect(result.status).toBe(1); - expect(JSON.parse(result.stdout).publication.collection).toEqual({ - complete: false, - error: "transport", - }); - expect(result.stdout + result.stderr).not.toMatch( - /synthetic-secret|private\/fixture|quota|404 unavailable/u, - ); - }, - ); - - it("retains partial package successes and truncation without declaring a complete observation", async () => { - const fixture = publicationFixture(); - Object.assign(fixture.diagnostic.stages.pluginNpm, { - packages: [ - { name: "@openclaw/first", state: "success", publication: "observed", error: null }, - { - name: "@openclaw/second", - state: "failure", - publication: "unknown", - error: { class: "registry-not-visible", status: 1 }, - }, - ], - packagesTruncated: true, - }); - const result = await runPublicationCli(fixture); - const publication = JSON.parse(result.stdout).publication; - expect(result.status).toBe(1); - expect(publication.relationship.status).toBe("verified"); - expect(publication.collection.error).toBe("incomplete"); - expect(publication.surfaces.pluginNpm.packages).toHaveLength(2); - expect(publication.surfaces.pluginNpm.packages[0].publication).toBe("observed"); - expect(publication.surfaces.pluginNpm.packages[1].error.class).toBe("registry-not-visible"); - }); - - it("keeps successful verification separate from failed binding, assets and skipped activation", async () => { - const fixture = publicationFixture(); - fixture.diagnostic.verification = "success"; - fixture.diagnostic.stages.binding.state = "failure"; - fixture.diagnostic.stages.assets.state = "failure"; - fixture.publisher.conclusion = "success"; - fixture.publisherJobs.push({ - ...job("Finalize GitHub release", "skipped"), - id: 8802, - run_id: 88, - run_attempt: 1, - steps: [], - }); - const result = await runPublicationCli(fixture); - const publication = JSON.parse(result.stdout).publication; - expect(result.status).toBe(0); - expect(publication.verification.state).toBe("success"); - expect(publication.binding.state).toBe("failure"); - expect(publication.assets.state).toBe("failure"); - expect(publication.surfaces.activation.operation.state).toBe("unknown"); - expect(publication.surfaces.activation.jobs[0].conclusion).toBe("skipped"); - }); - - it("retains unattempted verification when an earlier publisher prerequisite failed", async () => { - const fixture = publicationFixture(); - fixture.diagnostic.verification = "unattempted"; - fixture.diagnostic.stages.coreNpm.state = "unattempted"; - fixture.diagnostic.stages.coreNpm.publication = "unknown"; - const result = await runPublicationCli(fixture); - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout).publication.surfaces.coreNpm).toMatchObject({ - selection: "unknown", - verificationSelection: "unknown", - operation: { state: "unknown" }, - verification: { state: "unattempted" }, - }); - }); - - it("keeps Docker-only readback and advisory VCR API step conclusions separate from writer receipts", async () => { - const fixture = publicationFixture(); - fixture.publisherJobs.push( - { - ...job("Verify already-published core npm package"), - id: 8802, - run_id: 88, - run_attempt: 1, - steps: [], - }, - { - ...job("Publish Docker images / publish"), - id: 8803, - run_id: 88, - run_attempt: 1, - steps: [], - }, - { - ...job("Mirror Docker images to Vercel Container Registry / mirror", "failure"), - id: 8804, - run_id: 88, - run_attempt: 1, - steps: [ - { - number: 1, - name: "Copy and verify immutable release images", - status: "completed", - conclusion: "success", - }, - { - number: 2, - name: "Run custom-image Sandbox smoke", - status: "completed", - conclusion: "failure", - }, - { - number: 3, - name: "Promote and verify channel aliases", - status: "completed", - conclusion: "skipped", - }, - ], - }, - ); - const result = await runPublicationCli(fixture); - expect(result.status).toBe(0); - const surfaces = JSON.parse(result.stdout).publication.surfaces; - expect(surfaces.coreNpm.registryObservation.state).toBe("observed"); - expect(surfaces.coreNpm.operation.state).toBe("unknown"); - expect(surfaces.docker.children).toEqual([]); - expect(surfaces.vcr.advisory).toBe(true); - expect( - surfaces.vcr.jobs[0].steps.map((step: { conclusion: string }) => step.conclusion), - ).toEqual(["success", "failure", "skipped"]); - }); - - it("observes a supplied original core child without inventing a publisher-attempt receipt", async () => { - const fixture = publicationFixture(); - Reflect.set(fixture.diagnostic.children.openclawNpm!, "suppliedRunId", "909"); - const result = await runPublicationCli(fixture, undefined, (responses) => { - responses[`repos/${REPOSITORY}/actions/runs/909`] = { - ...fixture.publisher, - id: 909, - run_attempt: 3, - workflow_id: 9090, - head_sha: SHA, - head_branch: "older-tooling", - path: ".github/workflows/openclaw-npm-release.yml", - conclusion: "success", - }; - responses[`repos/${REPOSITORY}/actions/workflows/9090`] = { - id: 9090, - path: ".github/workflows/openclaw-npm-release.yml", - }; - }); - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout).publication.surfaces.coreNpm.children).toEqual([ - { - runId: "909", - workflowSha: SHA, - workflowRef: "older-tooling", - recordedAttempt: null, - observedAttempt: 3, - status: "completed", - conclusion: "success", - relation: "supplied", - }, - ]); - }); - - it("retains a verified relationship when an unrelated child workflow is invalid", async () => { - const fixture = publicationFixture(); - Reflect.set(fixture.diagnostic.children.openclawNpm!, "suppliedRunId", "909"); - const result = await runPublicationCli(fixture, undefined, (responses) => { - responses[`repos/${REPOSITORY}/actions/runs/909`] = { - ...fixture.publisher, - id: 909, - workflow_id: 9090, - path: ".github/workflows/wrong.yml", - }; - responses[`repos/${REPOSITORY}/actions/workflows/9090`] = { - id: 9090, - path: ".github/workflows/wrong.yml", - }; - }); - expect(result.status).toBe(1); - expect(JSON.parse(result.stdout).publication.collection.error).toBe("identity-mismatch"); - expect(JSON.parse(result.stdout).publication.relationship.status).toBe("verified"); - }); - - it("retains a verified relationship when a validation child advances after the join", async () => { - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - const path = `repos/${REPOSITORY}/actions/runs/101`; - const before = responses[path] as object; - responses[path] = { sequence: [before, { ...before, run_attempt: 2 }] }; - }); - expect(result.status).toBe(1); - expect(JSON.parse(result.stdout).publication.collection.error).toBe("attempt-changed"); - expect(JSON.parse(result.stdout).publication.relationship.status).toBe("verified"); - }); - - it.each(["transport", "workflow", "attempt-limit"])( - "authenticates the publisher before a validation child %s failure", - async (kind) => { - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - const path = `repos/${REPOSITORY}/actions/runs/101`; - if (kind === "transport") { - responses[path] = { failure: "403" }; - } else { - Object.assign( - responses[path] as object, - kind === "workflow" - ? { path: ".github/workflows/wrong.yml" } - : { run_attempt: 100000000 }, - ); - } - }); - expect(result.status).toBe(1); - const publication = JSON.parse(result.stdout).publication; - expect(publication.collection.complete).toBe(false); - expect(publication.relationship.status).toBe("verified"); - expect(publication.surfaces.coreNpm.registryObservation.state).toBe("observed"); - }, - ); - - it.each([ - ["77", "advances"], - ["88", "advances"], - ["77", "is unavailable"], - ["88", "is unavailable"], - ])( - "rechecks joined run %s when it %s after a child collection failure", - async (runId, outcome) => { - const path = `repos/${REPOSITORY}/actions/runs/${runId}`; - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - const before = responses[path] as { run_attempt: number }; - responses[path] = { - sequence: [ - before, - outcome === "advances" - ? { ...before, run_attempt: before.run_attempt + 1 } - : { failure: "403" }, - ], - }; - responses[`repos/${REPOSITORY}/actions/runs/101`] = { failure: "403" }; - }); - expect(result.status).toBe(1); - const publication = JSON.parse(result.stdout).publication; - expect(publication.relationship.status).toBe( - outcome === "advances" ? "invalid" : "unverified", - ); - expect(publication.relationship.reason).toBe( - outcome === "advances" ? "attempt-changed" : "transport", - ); - expect(publication.collection.complete).toBe(false); - expect(publication.surfaces.coreNpm.registryObservation.state).toBe("observed"); - expect(result.calls.filter((call) => call.includes(path))).toHaveLength(2); - }, - ); - - it.each(["77", "88"])( - "does not retain a verified relationship when final joined run %s cannot be rechecked", - async (runId) => { - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - const path = `repos/${REPOSITORY}/actions/runs/${runId}`; - responses[path] = { sequence: [responses[path], { failure: "403" }] }; - }); - expect(result.status).toBe(1); - const publication = JSON.parse(result.stdout).publication; - expect(publication.collection).toEqual({ complete: false, error: "transport" }); - expect(publication.relationship.status).toBe("unverified"); - expect(publication.relationship.reason).toBe("transport"); - }, - ); - - it("preserves producer-valid passive twenty-digit diagnostic IDs", async () => { - const fixture = publicationFixture(); - Object.assign(fixture.diagnostic.children.pluginNpm!, { - readbackArtifactId: "12345678901234567890", - packageArtifactId: "12345678901234567", - producerRunAttempt: "12345678901234567890", - }); - const result = await runPublicationCli(fixture); - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout).publication.relationship.status).toBe("verified"); - expect(JSON.parse(result.stdout).publication.surfaces.coreNpm.registryObservation.state).toBe( - "observed", - ); - expect(result.calls.flat().join(" ")).not.toContain("123456789012345"); - }); - - it.each(["77", "88"])( - "invalidates the relationship when joined run %s advances", - async (runId) => { - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - const path = `repos/${REPOSITORY}/actions/runs/${runId}`; - const before = responses[path] as { run_attempt: number }; - responses[path] = { - sequence: [before, { ...before, run_attempt: before.run_attempt + 1 }], - }; - }); - expect(result.status).toBe(1); - expect(JSON.parse(result.stdout).publication.relationship.status).toBe("invalid"); - }, - ); - - it.each(["77", "88"])( - "retains the relationship when joined run %s completes the same attempt", - async (runId) => { - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - const path = `repos/${REPOSITORY}/actions/runs/${runId}`; - const before = responses[path] as object; - responses[path] = { - sequence: [ - { ...before, status: "in_progress", conclusion: null }, - { ...before, display_title: "Updated workflow display title" }, - ], - }; - }); - expect(result.status, result.stderr).toBe(0); - const publication = JSON.parse(result.stdout).publication; - expect(publication.relationship.status).toBe("verified"); - expect(publication.collection.complete).toBe(true); - }, - ); - - it.each(["77", "88"])( - "rejects an immutable SHA change in joined run %s without attempt advancement", - async (runId) => { - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - const path = `repos/${REPOSITORY}/actions/runs/${runId}`; - responses[path] = { - sequence: [responses[path], { ...(responses[path] as object), head_sha: "f".repeat(40) }], - }; - }); - expect(result.status).toBe(1); - expect(JSON.parse(result.stdout).publication.relationship.status).toBe("invalid"); - }, - ); - - it.each(["main", "foreign"])( - "binds a normal ClawHub child to its recorded %s ref, not the alpha publisher ref", - async (childRef) => { - const fixture = publicationFixture(); - const parentRef = "tideclaw/alpha/fixture"; - fixture.publisher.head_branch = parentRef; - fixture.publisher.path = `${PUBLISH_PATH}@refs/heads/${parentRef}`; - fixture.diagnostic.context.suppliedToolingRef = `refs/heads/${parentRef}`; - fixture.diagnostic.selection.clawHubWorkflowRef = "main"; - const result = await runPublicationCli(fixture, undefined, async (responses, artifact) => { - await artifact(4, fixture.publisher, "openclaw-release-children-88-1", "dispatch.json", { - schemaVersion: 1, - repository: REPOSITORY, - parentRunId: "88", - parentRunAttempt: "1", - parentWorkflow: PUBLISH_PATH, - toolingRef: parentRef, - toolingFullRef: `refs/heads/${parentRef}`, - toolingSha: PUBLISH_SHA, - candidateSha: TARGET_SHA, - normalClawHubRunId: "909", - normalClawHubRunAttempt: "1", - }); - responses[`repos/${REPOSITORY}/actions/runs/909`] = { - ...fixture.publisher, - id: 909, - workflow_id: 9090, - head_branch: childRef, - path: ".github/workflows/plugin-clawhub-release.yml", - }; - responses[`repos/${REPOSITORY}/actions/workflows/9090`] = { - id: 9090, - path: ".github/workflows/plugin-clawhub-release.yml", - }; - }); - expect(result.status).toBe(childRef === "main" ? 0 : 1); - const publication = JSON.parse(result.stdout).publication; - expect(publication.relationship.status).toBe("verified"); - if (childRef === "main") { - expect(publication.surfaces.clawHub.children[0]).toMatchObject({ - runId: "909", - workflowRef: "main", - workflowSha: PUBLISH_SHA, - recordedAttempt: 1, - }); - } - }, - ); - - it("bounds active twenty-digit child IDs before attempting a metadata GET", async () => { - const fixture = publicationFixture(); - Reflect.set(fixture.diagnostic.children.pluginNpm!, "suppliedRunId", "12345678901234567890"); - const result = await runPublicationCli(fixture); - expect(result.status).toBe(1); - expect(JSON.parse(result.stdout).publication.collection.error).toBe("limits"); - expect(JSON.parse(result.stdout).publication.relationship.status).toBe("verified"); - expect(result.calls.flat().join(" ")).not.toContain("12345678901234567890"); - }); - - it.each(["in_progress", "failure"])( - "observes detached Windows %s without equating acknowledgement and promotion", - async (state) => { - const fixture = publicationFixture(); - fixture.publisherJobs.push({ - ...job("Dispatch Windows assets after publication"), - id: 8802, - run_id: 88, - run_attempt: 1, - steps: [ - { - number: 1, - name: "Upload Windows dispatch evidence", - status: "completed", - conclusion: "success", - }, - ], - }); - const native = { - ...fixture.publisher, - id: 909, - workflow_id: 9090, - path: ".github/workflows/windows-node-release.yml", - status: state === "failure" ? "completed" : "in_progress", - conclusion: state === "failure" ? "failure" : null, - }; - const dispatch = { - tag: "v2026.9.9", - sourceTag: "windows-v1", - installerDigests: "fixture-digests", - state: "dispatched", - childRunId: "909", - }; - const result = await runPublicationCli(fixture, undefined, async (responses, artifact) => { - await artifact( - 4, - fixture.publisher, - "windows-release-dispatch-88-1", - "windows-dispatch.json", - dispatch, - ); - responses[`repos/${REPOSITORY}/actions/runs/909`] = native; - responses[`repos/${REPOSITORY}/actions/workflows/9090`] = { id: 9090, path: native.path }; - responses[`repos/${REPOSITORY}/actions/runs/909/artifacts?per_page=100&page=1`] = { - total_count: 0, - artifacts: [], - }; - if (state === "failure") { - await artifact(5, native, "windows-release-promotion-909-1", "windows-promotion.json", { - schemaVersion: 1, - ...dispatch, - outcome: "success", - runUrl: `https://github.com/${REPOSITORY}/actions/runs/909`, - }); - responses[`repos/${REPOSITORY}/actions/runs/909/attempts/1/jobs?per_page=100&page=1`] = { - total_count: 1, - jobs: [ - { - ...job("Promote signed Windows installers", "failure"), - id: 9091, - run_id: 909, - run_attempt: 1, - steps: [ - { - number: 1, - name: "Upload Windows promotion evidence", - status: "completed", - conclusion: "success", - }, - ], - }, - ], - }; - } - }); - expect(result.status, result.stdout).toBe(0); - const surface = JSON.parse(result.stdout).publication.surfaces.nativeWindows; - expect(surface.children[0].recordedAttempt).toBeNull(); - expect(surface.children[0].status).toBe(native.status); - expect(surface.children[0].conclusion).toBe(native.conclusion); - expect(surface.operation.state).toBe("unknown"); - expect(surface.terminalMarker.state).toBe(state === "failure" ? "success" : "unknown"); - if (state === "failure") { - expect(surface.jobs).toContainEqual({ - jobId: 9091, - runId: "909", - runAttempt: 1, - status: "completed", - conclusion: "failure", - steps: [], - }); - } - }, - ); - - it("limits the dispatch inventory claim to normal ClawHub", async () => { - const fixture = publicationFixture(); - const result = await runPublicationCli(fixture, undefined, async (_responses, artifact) => { - await artifact(4, fixture.publisher, "openclaw-release-children-88-1", "dispatch.json", { - schemaVersion: 1, - repository: REPOSITORY, - parentRunId: "88", - parentRunAttempt: "1", - parentWorkflow: PUBLISH_PATH, - toolingRef: PUBLISH_REF, - toolingFullRef: `refs/tags/${PUBLISH_REF}`, - toolingSha: PUBLISH_SHA, - candidateSha: TARGET_SHA, - normalClawHubRunId: null, - normalClawHubRunAttempt: null, - }); - }); - expect(result.status).toBe(0); - expect(JSON.parse(result.stdout).publication.dispatches[0]).toMatchObject({ - scope: "normal-clawhub", - state: "not-dispatched", - }); - expect(JSON.parse(result.stdout).publication.surfaces.pluginNpm.selection).toBe("unknown"); - }); - - it.each(["complete", "denied", "duplicate", "changed-total"])( - "handles a second artifact page: %s", - async (kind) => { - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - const prefix = `repos/${REPOSITORY}/actions/runs/88/artifacts?per_page=100&page=`; - const first = responses[`${prefix}1`] as { artifacts: unknown[] }; - const diagnostic = first.artifacts[0]; - responses[`${prefix}1`] = { - total_count: 101, - artifacts: Array.from({ length: 100 }, (_, i) => ({ id: 1000 + i, name: `other-${i}` })), - }; - responses[`${prefix}2`] = - kind === "denied" - ? { failure: "403" } - : { - total_count: kind === "changed-total" ? 102 : 101, - artifacts: [kind === "duplicate" ? { id: 1000, name: "duplicate" } : diagnostic], - }; - }); - expect(result.status, result.stdout).toBe(kind === "complete" ? 0 : 1); - expect(JSON.parse(result.stdout).publication.collection.complete).toBe(kind === "complete"); - }, - ); - - it.each(["traversal", "unexpected-entry", "expanded", "truncated", "corrupt", "duplicate-entry"])( - "refuses %s archives before projecting diagnostics", - async (kind) => { - const fixture = publicationFixture(); - const result = await runPublicationCli(fixture, undefined, async (responses, artifact) => { - await artifact( - 3, - fixture.publisher, - "openclaw-release-postpublish-diagnostics-88-1", - DIAGNOSTIC_FILE, - fixture.diagnostic, - (zip) => { - if (kind === "traversal") { - zip.file("../escape.json", "{}"); - } - if (kind === "unexpected-entry") { - zip.file("extra.json", "{}"); - } - if (kind === "expanded") { - zip.file(DIAGNOSTIC_FILE, " ".repeat(128 * 1024 + 1)); - } - if (kind === "duplicate-entry") { - zip.file("x".repeat(DIAGNOSTIC_FILE.length), "{}"); - } - }, - ); - const archive = responses[`repos/${REPOSITORY}/actions/artifacts/3/zip`] as { - binary: string; - }; - let bytes = Buffer.from(archive.binary, "base64"); - if (kind === "truncated") { - bytes = bytes.subarray(0, -10); - } - if (kind === "corrupt") { - bytes[0] = 0; - } - if (kind === "duplicate-entry") { - const needle = Buffer.from("x".repeat(DIAGNOSTIC_FILE.length)); - for (let offset = bytes.indexOf(needle); offset !== -1; offset = bytes.indexOf(needle)) { - bytes.set(Buffer.from(DIAGNOSTIC_FILE), offset); - } - } - archive.binary = bytes.toString("base64"); - Object.assign(responses[`repos/${REPOSITORY}/actions/artifacts/3`] as object, { - size_in_bytes: bytes.length, - digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, - }); - }); - expect(result.status).toBe(1); - expect(JSON.parse(result.stdout).publication.verification.state).toBe("unknown"); - }, - ); - - it.each(["metadata-drift", "postread-expiry", "page-limit", "json-limit"])( - "bounds %s without fallback", - async (kind) => { - const result = await runPublicationCli(publicationFixture(), undefined, (responses) => { - const metadataPath = `repos/${REPOSITORY}/actions/artifacts/3`; - if (kind === "metadata-drift" || kind === "postread-expiry") { - const original = responses[metadataPath] as object; - responses[metadataPath] = { - sequence: [ - original, - { - ...original, - ...(kind === "metadata-drift" ? { id: 4 } : { expires_at: "2000-01-01T00:00:00Z" }), - }, - ], - }; - } - if (kind === "page-limit") { - responses[`repos/${REPOSITORY}/actions/runs/88/artifacts?per_page=100&page=1`] = { - total_count: 1001, - artifacts: [], - }; - } - if (kind === "json-limit") { - responses[`repos/${REPOSITORY}/actions/runs/88`] = { - raw: " ".repeat(2 * 1024 * 1024 + 1), - }; - } - }); - expect(result.status).toBe(1); - expect(JSON.parse(result.stdout).publication.collection.complete).toBe(false); - }, - ); - - it("enforces the whole-command deadline independently of continuation settings", async () => { - const result = await runPublicationCli( - publicationFixture(), - undefined, - undefined, - false, - 100000, - ); - expect(result.status).toBe(1); - expect(JSON.parse(result.stdout).publication.collection.error).toBe("deadline"); - expect(result.calls).toHaveLength(1); - }); - - it.each([ - [1, true], - [100, true], - [100, false], - ] as const)( - "caps final output with %i steps (JSON=%s) without erasing observations", - async (steps, json) => { - const fixture = publicationFixture(); - Reflect.set( - fixture.diagnostic.stages.pluginNpm, - "packages", - Array.from({ length: 256 }, (_, index) => ({ - name: `@openclaw/fixture-${index}`, - state: "success", - publication: "observed", - error: null, - })), - ); - const args = [ - "status", - "--run", - "77", - "--publication-run", - "88", - ...(json ? ["--json"] : []), - ]; - const result = await runPublicationCli(fixture, args, (responses) => { - const jobs = Array.from({ length: 999 }, (_, i) => ({ - ...job("Mirror Docker images to Vercel Container Registry / mirror"), - id: 10000 + i, - run_id: 88, - run_attempt: 1, - steps: Array.from({ length: steps }, (_step, index) => ({ - number: index + 1, - name: "Copy and verify immutable release images", - status: "completed", - conclusion: "success", - })), - })); - jobs.unshift(publicationFixture().publisherJobs[0]!); - for (let page = 1; page <= 10; page++) { - responses[ - `repos/${REPOSITORY}/actions/runs/88/attempts/1/jobs?per_page=100&page=${page}` - ] = { - total_count: jobs.length, - jobs: jobs.slice((page - 1) * 100, page * 100), - }; - } - }); - expect(result.status).toBe(1); - if (json) { - const publication = JSON.parse(result.stdout).publication; - expect(publication.collection).toEqual({ - complete: false, - error: "limits", - outputTruncated: true, - validationStatusOmitted: true, - }); - expect(publication.publisher).toMatchObject({ runId: "88", runAttempt: 1 }); - expect(publication.relationship).toMatchObject({ - status: "verified", - originalPlanAttempt: 1, - validationAttempt: 2, - }); - expect(publication.diagnostics).toMatchObject({ state: "available", artifactId: 3 }); - expect(publication.surfaces.coreNpm.registryObservation.state).toBe("observed"); - expect(publication.surfaces.vcr.jobs).toHaveLength(4); - expect(publication.surfaces.vcr.jobsOmitted).toBe(995); - expect(publication.surfaces.vcr.jobs[0].steps).toHaveLength(steps); - expect(publication.surfaces.vcr.jobs[0].steps[0].conclusion).toBe("success"); - expect(publication.surfaces.pluginNpm.packages).toHaveLength(4); - expect(publication.surfaces.pluginNpm.packagesOmitted).toBe(252); - expect(publication.surfaces.pluginNpm.packages[0]).toMatchObject({ - name: "@openclaw/fixture-0", - state: "success", - publication: "observed", - }); - } else { - expect(result.stdout).toContain("relationship: verified"); - expect(result.stdout).toContain("publisher: 88 attempt=1"); - expect(result.stdout).toContain("registry-observation=observed"); - expect(result.stdout).toContain("jobs omitted: 995"); - expect(result.stdout).toContain("packages omitted: 252"); - expect(result.stdout).toContain("validation detail omitted: output limit"); - } - expect(Buffer.byteLength(result.stdout)).toBeLessThan(256 * 1024); - }, - ); - - it("preserves legacy status JSON and performs no publication reads without the selector", async () => { - const result = await runPublicationCli(publicationFixture(), [ - "status", - "--run", - "77", - "--json", - ]); - expect(result.status, result.stderr).toBe(0); - const value = JSON.parse(result.stdout); - expect(Object.keys(value)).toEqual(["children", "failed", "active", "missing", "passed"]); - expect(value.children).toHaveLength(4); - expect(value.passed).toHaveLength(4); - expect(result.calls).toHaveLength(9); - expect(result.calls.flat().join(" ")).not.toMatch( - /publication|runs\/88|workflows\/|artifacts\//u, - ); - }); - - it.each(["continue", "verify"])( - "preserves legacy %s plan refusal without publication reads", - async (command) => { - const fixture = publicationFixture(); - Object.assign(fixture.executionPlan, historicalExecutionPlanArtifact()); - for (const key of ["attemptEvidenceVersion", "candidate", "candidateRequest", "repository"]) { - Reflect.deleteProperty(fixture.executionPlan, key); - } - const result = await runPublicationCli(fixture, [ - command, - "--run", - "77", - ...(command === "continue" ? ["--failed"] : []), - ]); - expect(result.status).toBe(1); - expect(result.stderr).toContain("predates attempt-aware immutable plans"); - expect(result.calls).toHaveLength(1); - expect(result.calls[0]?.slice(0, 3)).toEqual(["run", "download", "77"]); - }, - ); - - it("strips arbitrary artifact extras, job names, URLs and control characters from output", async () => { - const fixture = publicationFixture(); - const unsafe = "synthetic-secret /private/fixture/key \u001b[31m"; - Reflect.set(fixture.diagnostic, "rawError", unsafe); - fixture.publisher.display_title = unsafe; - fixture.publisherJobs.push({ ...job(unsafe), id: 8899, run_id: 88, run_attempt: 1, steps: [] }); - const result = await runPublicationCli(fixture); - expect(result.status).toBe(0); - expect(result.stdout + result.stderr).not.toMatch(/synthetic-secret|private\/fixture/u); - expect(result.stdout + result.stderr).not.toContain("\u001b"); - }); -}); diff --git a/test/scripts/install-ps1.release.test.ts b/test/scripts/install-ps1.release.test.ts new file mode 100644 index 000000000000..19aaacc34113 --- /dev/null +++ b/test/scripts/install-ps1.release.test.ts @@ -0,0 +1,2219 @@ +import { spawn, spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import http from "node:http"; +import { tmpdir } from "node:os"; +import { join, parse } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; +import { isSupportedOpenClawNodeVersion } from "../../node-version.mjs"; +import { NODE_RELEASE_VERSION_CASES } from "../helpers/node-version-cases.js"; +import { extractFunctionBody } from "./install-ps1.test-support.js"; +import { createScriptTestHarness } from "./test-helpers.js"; + +const SCRIPT_PATH = "scripts/install.ps1"; +const ENTRYPOINT_RE = /\r?\n\$null = Main\r?\nComplete-Install\s*$/m; + +function extractEntrypointLines(source: string): string[] { + const match = source.match(ENTRYPOINT_RE); + if (!match) { + throw new Error("Missing PowerShell installer entrypoint"); + } + return match[0].trim().split(/\r?\n/); +} + +function findPowerShell(candidates = ["pwsh", "powershell"]): string | undefined { + for (const candidate of candidates) { + const result = spawnSync( + candidate, + ["-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion"], + { + encoding: "utf8", + }, + ); + if (result.status === 0) { + return candidate; + } + } + return undefined; +} + +function toPowerShellSingleQuotedLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function createFailingNodeFixture(source: string): string { + const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, ""); + const entrypointLines = extractEntrypointLines(source); + expect(scriptWithoutEntryPoint).not.toBe(source); + + return [ + scriptWithoutEntryPoint, + "", + "function Write-Banner { }", + "function Ensure-ExecutionPolicy { return $true }", + "function Check-Node { return $false }", + "function Install-Node { return $false }", + "", + ...entrypointLines, + "", + ].join("\n"); +} + +function createDeferredPathSuccessFixture(source: string): string { + const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, ""); + const entrypointLines = extractEntrypointLines(source); + expect(scriptWithoutEntryPoint).not.toBe(source); + + return [ + scriptWithoutEntryPoint, + "", + "function Write-Banner { }", + "function Ensure-ExecutionPolicy { return $true }", + "function Check-Node { return $true }", + "function Check-ExistingOpenClaw { return $false }", + "function Add-ToPath { param([string]$Path) }", + "function Install-OpenClaw { return $true }", + "function Ensure-OpenClawOnPath { return $false }", + "$NoOnboard = $true", + "", + ...entrypointLines, + "", + ].join("\n"); +} + +describe("install.ps1 failure handling", () => { + const harness = createScriptTestHarness(); + const source = readFileSync(SCRIPT_PATH, "utf8"); + const powershell = findPowerShell(); + const bootstrapShells = + process.platform === "win32" + ? ["powershell", "pwsh"].filter((candidate) => findPowerShell([candidate])) + : []; + const runIfPowerShell = powershell ? it : it.skip; + const runConcurrentIfPowerShell = powershell ? it.concurrent : it.skip; + const runPowerShell = (args: string[]) => { + if (!powershell) { + throw new Error("PowerShell is not available"); + } + return spawnSync(powershell, args, { encoding: "utf8" }); + }; + const runInstallerFile = (args: string[], env: NodeJS.ProcessEnv = {}) => { + if (!powershell) { + throw new Error("PowerShell is not available"); + } + return spawnSync( + powershell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", SCRIPT_PATH, ...args], + { + encoding: "utf8", + env: { ...process.env, ...env }, + }, + ); + }; + const runPowerShellAsync = (args: string[]) => { + if (!powershell) { + throw new Error("PowerShell is not available"); + } + return new Promise<{ status: number | null; stderr: string; stdout: string }>( + (resolve, reject) => { + const child = spawn(powershell, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (status) => resolve({ status, stderr, stdout })); + }, + ); + }; + const batchedPowerShellResults = new Map(); + + beforeAll(() => { + if (!powershell) { + return; + } + const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, ""); + const entrypointLines = extractEntrypointLines(source); + const cases = [ + { + name: "private-node-update", + source: [ + scriptWithoutEntryPoint, + String.raw` +$root = Join-Path $script:InstallerTempDirectory ('openclaw-private-node-test-' + [guid]::NewGuid().ToString('N')) +$NodeOnly = $true +$NodePrefix = Join-Path $root 'private tools/node' +$originalTemp = $script:InstallerTempDirectory +$beforePath = $env:PATH +$beforeUserPath = [Environment]::GetEnvironmentVariable('Path', 'User') +$beforeMachinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') +$script:InstallerTempDirectory = Join-Path $root 'temp' +$script:Scenario = '' +$script:Extractions = 0 +function Check-ExistingOpenClaw { throw 'unexpected OpenClaw lookup' } +function Install-Node { throw 'unexpected package-manager install' } +function Install-OpenClaw { throw 'unexpected OpenClaw install' } +function Ensure-OpenClawOnPath { throw 'unexpected OpenClaw PATH update' } +function Add-ToProcessPath { throw 'unexpected process PATH update' } +function Add-ToUserPath { throw 'unexpected user PATH update' } +function Refresh-GatewayServiceIfLoaded { throw 'unexpected Gateway update' } +function Invoke-NpmCommand { throw 'unexpected npm invocation' } +function Invoke-RestMethod { + param([string]$Uri, [int]$TimeoutSec) + if ($Uri -ne 'https://nodejs.org/dist/index.json') { throw "unexpected metadata URL: $Uri" } + return @([pscustomobject]@{ version = 'v26.1.0'; files = @('win-x64-zip', 'win-arm64-zip') }) +} +function Save-InstallerDownload { + param([string]$Uri, [string]$OutFile) + if ($script:Scenario -eq 'download') { throw 'fixture download failure' } + if ($Uri -eq 'https://nodejs.org/dist/v26.1.0/SHASUMS256.txt') { + $archive = Get-ChildItem -LiteralPath (Split-Path -Parent $OutFile) -Filter '*.zip' | Select-Object -First 1 + $hash = (Get-FileHash -LiteralPath $archive.FullName -Algorithm SHA256).Hash + if ($script:Scenario -eq 'checksum') { $hash = '0' * 64 } + $name = if ($script:Scenario -eq 'missing-checksum') { 'another-node.zip' } else { $archive.Name } + [IO.File]::WriteAllText($OutFile, "$hash $name") + return + } + if ($Uri -notmatch '^https://nodejs\.org/dist/v26\.1\.0/node-v26\.1\.0-win-(x64|arm64)\.zip$') { throw "unexpected archive URL: $Uri" } + [IO.File]::WriteAllText($OutFile, 'downloaded archive bytes') +} +function Expand-PortableNodeArchive { + param([string]$ZipPath, [string]$DestinationPath) + $script:Extractions++ + New-Item -ItemType Directory -Path $DestinationPath | Out-Null + [IO.File]::WriteAllText((Join-Path $DestinationPath 'node.exe'), 'new node') + [IO.File]::WriteAllText((Join-Path $DestinationPath 'npm.cmd'), 'matching npm') + [IO.File]::WriteAllText((Join-Path $DestinationPath 'npx.cmd'), 'matching npx') + if ($script:Scenario -eq 'archive') { throw 'fixture extraction failure' } +} +function Check-Node { + param([string]$NodePath) + if (-not $NodePath -or -not (Test-Path -LiteralPath $NodePath -PathType Leaf)) { throw 'runtime was not checked by its explicit path' } + if ([IO.File]::ReadAllText($NodePath) -ne 'new node') { throw 'the downloaded runtime was not checked' } + return ($script:Scenario -ne 'runtime') +} +try { + New-Item -ItemType Directory -Force -Path $script:InstallerTempDirectory, $NodePrefix | Out-Null + foreach ($scenario in @('download', 'checksum', 'missing-checksum', 'archive', 'runtime', 'success', 'fresh')) { + $script:Scenario = $scenario + $script:Extractions = 0 + $script:InstallExitCode = 0 + [IO.File]::WriteAllText((Join-Path $NodePrefix 'node.exe'), 'previous node') + if ($scenario -eq 'fresh') { Remove-Item -LiteralPath $NodePrefix -Recurse -Force } + $output = @(Main *>&1 | ForEach-Object { $_.ToString() }) + $success = $scenario -in @('success', 'fresh') + if (($script:InstallExitCode -eq 0) -ne $success) { throw "incorrect result for $($scenario): $output" } + $expectedExtractions = if ($scenario -in @('download', 'checksum', 'missing-checksum')) { 0 } else { 1 } + if ($script:Extractions -ne $expectedExtractions) { throw "extraction boundary violated for $scenario" } + $expectedNode = if ($success) { 'new node' } else { 'previous node' } + if ([IO.File]::ReadAllText((Join-Path $NodePrefix 'node.exe')) -ne $expectedNode) { throw "previous runtime not preserved for $scenario" } + if ($success) { + if ([IO.File]::ReadAllText((Join-Path $NodePrefix 'npm.cmd')) -ne 'matching npm') { throw 'matching npm missing' } + if ([IO.File]::ReadAllText((Join-Path $NodePrefix 'npx.cmd')) -ne 'matching npx') { throw 'matching npx missing' } + } + if (@(Get-ChildItem -LiteralPath $script:InstallerTempDirectory -Force).Count -ne 0) { throw 'download temporary files remain' } + if (@(Get-ChildItem -LiteralPath (Split-Path -Parent $NodePrefix) -Force).Count -ne 1) { throw 'publication temporary directories remain' } + if ($env:PATH -cne $beforePath) { throw 'process PATH changed' } + if ([Environment]::GetEnvironmentVariable('Path', 'User') -cne $beforeUserPath) { throw 'user PATH changed' } + if ([Environment]::GetEnvironmentVariable('Path', 'Machine') -cne $beforeMachinePath) { throw 'machine PATH changed' } + } +} finally { + $script:InstallerTempDirectory = $originalTemp + Remove-Item -LiteralPath $root -Recurse -Force +} +`, + ].join("\n"), + }, + { + name: "native-npm-stderr", + source: [ + scriptWithoutEntryPoint, + `$node = ${toPowerShellSingleQuotedLiteral(process.execPath)}`, + String.raw` +$ErrorActionPreference = 'Stop' +$beforeLocation = (Get-Location).Path +$root = Join-Path ([IO.Path]::GetTempPath()) ('openclaw-native-stderr-' + [Guid]::NewGuid().ToString('N')) +[void](New-Item -ItemType Directory -Path $root) +$child = Join-Path $root 'child.cjs' +[IO.File]::WriteAllText($child, 'if (process.argv[2] === "marker") { require("node:fs").writeFileSync(process.argv[3], "spawned"); process.exit(0); } if (process.argv[2] === "warning") process.stderr.write("npm warn proof\n"); process.stdout.write("native-complete\n"); process.exit(Number(process.argv[3]));') +try { + foreach ($wrapper in @('Invoke-NpmCommand', 'Invoke-CommandFromWindowsSafeDirectory')) { + foreach ($stream in @('warning', 'quiet')) { + foreach ($code in @(0, 17)) { + $output = @(& $wrapper -CommandPath $node -Arguments @($child, $stream, [string]$code) -WorkingDirectory $root 2>&1) + if ($LASTEXITCODE -ne $code) { throw "$wrapper changed native exit $code" } + $text = ($output | ForEach-Object { $_.ToString() }) -join " " + if (-not $text.Contains('native-complete')) { throw "$wrapper lost stdout" } + if ($text.Contains('npm warn proof') -ne ($stream -eq 'warning')) { throw "$wrapper changed stderr" } + if ($ErrorActionPreference -ne 'Stop' -or (Get-Location).Path -ne $beforeLocation) { throw "$wrapper leaked caller state" } + } + } + } + $marker = Join-Path $root 'unexpected-spawn' + foreach ($entry in @( + @{command=$node; directory=(Join-Path $root 'missing')}, + @{command=(Join-Path $root 'missing.exe'); directory=$root} + )) { + $caught = $false + try { Invoke-NpmCommand -CommandPath $entry.command -Arguments @($child, 'marker', $marker) -WorkingDirectory $entry.directory 2>&1 | Out-Null } catch { $caught = $true } + if (-not $caught -or (Test-Path -LiteralPath $marker)) { throw 'PowerShell setup failure did not stop the child' } + if ($ErrorActionPreference -ne 'Stop' -or (Get-Location).Path -ne $beforeLocation) { throw 'PowerShell failure leaked caller state' } + } +} finally { Remove-Item -LiteralPath $root -Recurse -Force } +`, + ].join("\n"), + }, + { + name: "openclaw-native-command-exit", + source: [ + scriptWithoutEntryPoint, + "", + "function Get-OpenClawCommandPath { return (Get-Process -Id $PID).Path }", + "$caught = $false", + "try {", + " Invoke-OpenClawCommand -NoLogo -NoProfile -Command 'exit 17'", + "} catch {", + " if ($_.Exception.Message -notmatch 'failed with exit code 17') { throw }", + " $caught = $true", + "}", + "if (-not $caught) { throw 'nonzero native exit was accepted' }", + "", + ].join("\n"), + }, + { + name: "doctor-failure-output", + source: [ + scriptWithoutEntryPoint, + "", + "function Invoke-OpenClawCommand { throw 'doctor failed' }", + "$output = @(Run-Doctor *>&1 | ForEach-Object { $_.ToString() })", + '$text = $output -join "`n"', + "if ($text -match 'Migration complete') { throw 'doctor failure reported success' }", + "if ($text -notmatch 'Migration failed') { throw \"missing error: $text\" }", + "if ($output[-1] -ne $false) { throw 'doctor failure did not propagate' }", + "", + ].join("\n"), + }, + { + name: "npm-lifecycle-policy", + source: [ + scriptWithoutEntryPoint, + "", + "$script:NpmVersion = ''", + "function Invoke-NpmCommand {", + " param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)", + " if ($Arguments[0] -eq '--version') { Write-Output $script:NpmVersion; $global:LASTEXITCODE = 0; return }", + " throw 'unexpected npm mutation'", + "}", + "$cases = @{ '11.15.0' = $null; '11.16.0' = '--allow-scripts=openclaw'; '12.0.0' = '--allow-scripts=openclaw' }", + "foreach ($entry in $cases.GetEnumerator()) {", + " $script:NpmVersion = $entry.Key", + " $actual = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'openclaw@latest'", + ' if ($actual -ne $entry.Value) { throw "version=$($entry.Key) actual=$actual" }', + "}", + "$script:NpmVersion = '12.0.0'", + "$tool = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'pnpm@12.0.0' -ExactIdentity 'pnpm@12.0.0'", + 'if ($tool -ne "--allow-scripts=pnpm@12.0.0") { throw "tool=$tool" }', + "$alias = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'openclaw@npm:@scope/candidate@1.0.0'", + "if ($alias -ne '--allow-scripts=@scope/candidate') { throw \"alias=$alias\" }", + "$archiveAlias = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'openclaw@npm:@scope/candidate.tgz@1.0.0'", + "if ($archiveAlias -ne '--allow-scripts=@scope/candidate.tgz') { throw \"alias=$archiveAlias\" }", + "$tarball = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'https://example.invalid/openclaw.tgz'", + "if ($tarball -ne '--allow-scripts=https://example.invalid/openclaw.tgz') { throw \"tarball=$tarball\" }", + '$archiveRoot = Join-Path ([System.IO.Path]::GetTempPath()) "openclaw-archive-identity"', + '$safeCwd = Join-Path $archiveRoot "work"', + '$candidate = Join-Path $archiveRoot "candidate.tgz"', + '$archiveUrl = "file:///" + $candidate.Replace("\\", "/").TrimStart("/")', + 'foreach ($spec in @($candidate, "../candidate.tgz", "file:$candidate", "file:../candidate.tgz", "file:/../candidate.tgz", "file:///../candidate.tgz", $archiveUrl)) {', + ' $protocol = if ($spec.StartsWith("file:")) { "file:" } else { "" }', + " $actual = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec $spec -NpmCwd $safeCwd", + ' if ($actual -ne "--allow-scripts=$protocol$candidate") { throw "archive=$actual" }', + "}", + '$commaRoot = Join-Path ([System.IO.Path]::GetTempPath()) "openclaw,identity"', + "$caught = $false", + "try { Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec (Join-Path $commaRoot 'candidate.tgz') -NpmCwd $commaRoot } catch {", + " if ($_.Exception.Message -notmatch 'without commas') { throw }", + " $caught = $true", + "}", + "if (-not $caught) { throw 'comma archive policy was accepted' }", + "$script:NpmVersion = '11.16.0'", + "$legacy = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec (Join-Path $commaRoot 'candidate.tgz') -NpmCwd $commaRoot", + "if ($legacy -notmatch '^--allow-scripts=\\.[\\\\/]candidate\\.tgz$') { throw \"legacy=$legacy\" }", + "$script:NpmVersion = '12.0.0'", + '$safeCwd = Join-Path $commaRoot "safe"', + '$candidate = Join-Path $commaRoot "candidate"', + "$relative = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec $candidate -NpmCwd $safeCwd", + "if ($relative -match ',' -or $relative -notmatch '^--allow-scripts=\\.\\.[\\\\/]candidate$') { throw \"relative=$relative\" }", + "foreach ($invalidVersion in @('invalid', 'npm 12.0.0 warning')) {", + " $script:NpmVersion = $invalidVersion", + " $caught = $false", + " try { Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'openclaw@latest' } catch { $caught = $true }", + ' if (-not $caught) { throw "invalid npm version was accepted: $invalidVersion" }', + "}", + "", + ].join("\n"), + }, + { + name: "pnpm-prefer-offline-policy", + source: [ + scriptWithoutEntryPoint, + "", + '$root = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-pnpm-policy-" + [guid]::NewGuid().ToString("N"))', + '$project = Join-Path $root "project"', + "$previousUpper = $env:PNPM_CONFIG_PREFER_OFFLINE", + "$previousLower = $env:pnpm_config_prefer_offline", + "$script:PnpmConfigValue = 'undefined'", + "function Get-TestPnpmConfig {", + " param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments)", + " if ($Arguments -join ' ' -ne 'config get prefer-offline') { throw \"unexpected pnpm command: $($Arguments -join ' ')\" }", + ' if ((Get-Location).Path -ne $project) { throw "unexpected pnpm cwd: $(Get-Location)" }', + " if ($script:PnpmConfigValue -eq 'failure') { $global:LASTEXITCODE = 1; return }", + " $global:LASTEXITCODE = 0", + " return $script:PnpmConfigValue", + "}", + "try {", + " New-Item -ItemType Directory -Force -Path $project | Out-Null", + " Remove-Item Env:PNPM_CONFIG_PREFER_OFFLINE -ErrorAction SilentlyContinue", + " Remove-Item Env:pnpm_config_prefer_offline -ErrorAction SilentlyContinue", + " if (-not (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig')) { throw 'default was disabled' }", + " $script:PnpmConfigValue = 'false'", + " if (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig') { throw 'false pnpm config was ignored' }", + " $script:PnpmConfigValue = 'true'", + " if (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig') { throw 'true pnpm config was ignored' }", + " $script:PnpmConfigValue = 'failure'", + " if (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig') { throw 'failed pnpm config query enabled the default' }", + " $env:PNPM_CONFIG_PREFER_OFFLINE = 'false'", + " if (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig') { throw 'uppercase override was ignored' }", + " Remove-Item Env:PNPM_CONFIG_PREFER_OFFLINE -ErrorAction SilentlyContinue", + " $env:pnpm_config_prefer_offline = 'false'", + " if (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig') { throw 'lowercase override was ignored' }", + "} finally {", + " $env:PNPM_CONFIG_PREFER_OFFLINE = $previousUpper", + " $env:pnpm_config_prefer_offline = $previousLower", + " Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue", + "}", + "", + ].join("\n"), + }, + { + name: "npm-candidate-validation", + source: [ + scriptWithoutEntryPoint, + "", + '$root = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-missing-candidate-" + [guid]::NewGuid().ToString("N"))', + "New-Item -ItemType Directory -Path $root | Out-Null", + "function Check-ExistingOpenClaw { return $true }", + "function Check-Node { return $true }", + "function Ensure-Git { return $true }", + "function Test-PreviousGitWrapper { return $false }", + "function Get-NpmCommandPath { return 'npm.cmd' }", + "function Get-WindowsCommandSafeDirectory { return $root }", + "function Resolve-NpmOpenClawInstallSpec { return 'openclaw@latest' }", + "function Test-NpmConfigRawKey { return $true }", + "function Get-NpmDebugLogRootCandidates { return @() }", + "function Invoke-NpmCommand {", + " param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)", + " $global:LASTEXITCODE = 0", + " if ($Arguments[0] -eq '--version') { return '12.0.0' }", + " if ($Arguments[0] -eq 'root') { return $root }", + " if ($Arguments[0] -eq 'config') { return $root }", + " if ($Arguments[0] -eq 'install') { return }", + " throw \"unexpected npm command: $($Arguments -join ' ')\"", + "}", + "function Ensure-OpenClawOnPath { throw 'old PATH command was accepted after missing candidate' }", + "$InstallMethod = 'npm'", + "$NoOnboard = $true", + "$Tag = 'latest'", + "try {", + " $null = Main", + ' if ($script:InstallExitCode -ne 1) { throw "InstallExitCode=$script:InstallExitCode" }', + "} finally {", + " Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue", + "}", + "", + ].join("\n"), + }, + { + name: "method-switch-preservation", + source: [ + scriptWithoutEntryPoint, + "", + "$script:OldOwnerRemoved = $false", + "function Check-ExistingOpenClaw { return $true }", + "function Check-Node { return $true }", + "function Get-NpmCommandPath { return 'npm.cmd' }", + "function Invoke-NpmCommand {", + " param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)", + " if ($Arguments[0] -eq 'list') { $global:LASTEXITCODE = 0; return }", + " if ($Arguments[0] -eq 'uninstall') { $script:OldOwnerRemoved = $true; $global:LASTEXITCODE = 0; return }", + " throw 'unexpected npm command'", + "}", + "function Install-OpenClawFromGit { return $false }", + "$InstallMethod = 'git'", + "$NoOnboard = $true", + "$null = Main", + "if ($script:OldOwnerRemoved) { throw 'failed candidate retired the working npm owner' }", + "", + ].join("\n"), + }, + { + name: "node-versions", + source: [ + scriptWithoutEntryPoint, + "", + "$cases = @{", + ...NODE_RELEASE_VERSION_CASES.map( + (version) => + ` ${toPowerShellSingleQuotedLiteral(version)} = $${isSupportedOpenClawNodeVersion(version)}`, + ), + "}", + "foreach ($entry in $cases.GetEnumerator()) {", + " $actual = Test-NodeVersionSupported -Version $entry.Key", + ' if ($actual -ne $entry.Value) { throw "Version=$($entry.Key) Actual=$actual" }', + "}", + "", + ].join("\n"), + }, + { + name: "node-capabilities", + source: [ + scriptWithoutEntryPoint, + "function Get-Command { [pscustomobject]@{ Source = 'Invoke-FixtureNode' } }", + "function Invoke-FixtureNode {", + " $global:LASTEXITCODE = 0", + " if ($args[0] -eq '-v') { return $script:FixtureVersion }", + " $input | Out-Null", + " return $script:FixtureSqlite", + "}", + "foreach ($case in @(", + " @{ version = 'v24.19.0'; text = $true; expected = $true },", + " @{ version = 'v24.19.0'; text = $false; expected = $false },", + " @{ version = 'v24.15.0+vendor.1'; text = $true; expected = $false },", + " @{ version = 'v26.0.0+vendor.1'; text = $true; expected = $false },", + " @{ version = 'v24.15.0'; text = $false; expected = $false },", + " @{ version = 'v22.23.2'; text = $true; expected = $false }", + ")) {", + " $script:FixtureVersion = $case.version", + " $script:FixtureSqlite = @{ available = $true; version = '3.51.3'; text = $case.text; blob = $true; json = $true } | ConvertTo-Json -Compress", + " $actual = Check-Node", + ' if ($actual -ne $case.expected) { throw "Version=$($case.version) Text=$($case.text) Actual=$actual" }', + "}", + ].join("\n"), + }, + { + name: "same-prefix-shim-transaction", + source: [ + scriptWithoutEntryPoint, + "", + '$root = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-shim-transaction-" + [guid]::NewGuid().ToString("N"))', + '$target = Join-Path $root "openclaw.cmd"', + "try {", + " New-Item -ItemType Directory -Force -Path $root | Out-Null", + ' $old = "@echo off`r`nnode `"C:\\old\\dist\\entry.js`" %*`r`n"', + ' $launcher = Join-Path $root "node_modules\\openclaw\\openclaw.mjs"', + ' $candidate = "@ECHO off`r`nGOTO start`r`n:find_dp0`r`nSET dp0=%~dp0`r`nEXIT /b`r`n:start`r`nSETLOCAL`r`nCALL :find_dp0`r`nnode `"%dp0%\\node_modules\\openclaw\\openclaw.mjs`" %*`r`n"', + " [System.IO.File]::WriteAllText($target, $old)", + " $backup = Start-NpmShimBackup -Path $target -ExpectedLauncher $launcher", + " [System.IO.File]::WriteAllText($target, $candidate)", + " Restore-NpmShimBackup -Backup $backup", + " if ([System.IO.File]::ReadAllText($target) -ne $old) { throw 'failure did not restore old wrapper' }", + " $backup = Start-NpmShimBackup -Path $target -ExpectedLauncher $launcher", + " [System.IO.File]::WriteAllText($target, $candidate)", + " Complete-NpmShimBackup -Backup $backup", + " if ([System.IO.File]::ReadAllText($target) -ne $candidate) { throw 'success did not retain npm shim' }", + " if (Test-Path -LiteralPath $backup.BackupPath) { throw 'committed backup remains' }", + " [System.IO.File]::WriteAllText($target, $old)", + " $backup = Start-NpmShimBackup -Path $target -ExpectedLauncher $launcher", + ' [System.IO.File]::WriteAllText($target, "@echo off`r`necho unrelated`r`n")', + " $refused = $false", + " try { Restore-NpmShimBackup -Backup $backup } catch { $refused = $true }", + " if (-not $refused) { throw 'unrelated replacement was deleted' }", + " if ([System.IO.File]::ReadAllText($target) -notmatch 'unrelated') { throw 'unrelated replacement changed' }", + "} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }", + "", + ].join("\n"), + }, + { + name: "canonical-temp-root", + source: [ + scriptWithoutEntryPoint, + "", + "$originalTemp = $env:TEMP", + "$originalTmp = $env:TMP", + '$sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-install-temp-test-" + [guid]::NewGuid().ToString("N"))', + '$longTemp = Join-Path $sandbox "Long Temp"', + "try {", + " New-Item -ItemType Directory -Force -Path $longTemp | Out-Null", + " $env:TEMP = $longTemp", + " $env:TMP = $longTemp", + " $resolved = Resolve-InstallerTempDirectory", + " $expected = (Get-Item -LiteralPath $longTemp -ErrorAction Stop).FullName", + ' if ($resolved -ne $expected) { throw "default=$resolved expected=$expected" }', + " $env:TEMP = '\\\\?\\' + $longTemp", + " $env:TMP = $env:TEMP", + ' $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -ne $longTemp) { throw "prefix not stripped: $candidate" }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }', + ' if ($resolved -ne $expected) { throw "extended=$resolved expected=$expected" }', + " # Windows PowerShell 5.1 proof: FSO Folder.Path echoes 8.3; Get-Item.FullName expands it.", + " $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp'", + " $env:TMP = $longTemp", + " $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -match '~') { return $longTemp }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }", + ' if ($resolved -ne $longTemp) { throw "short=$resolved" }', + " $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Missing'", + " $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -match '~') { throw 'unresolvable short alias' }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }", + ' if ($resolved -ne $longTemp) { throw "fallback=$resolved" }', + " $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp'", + " $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) return $candidate }", + ' if ($resolved -ne $longTemp) { throw "unchanged-short=$resolved" }', + " $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp'", + " Initialize-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -match '~') { return $longTemp }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }", + ' if ($script:InstallerTempDirectory -ne $longTemp) { throw "canonical=$script:InstallerTempDirectory" }', + ' if ($env:TEMP -ne $longTemp) { throw "TEMP=$env:TEMP" }', + ' if ($env:TMP -ne $longTemp) { throw "TMP=$env:TMP" }', + "} finally {", + " $env:TEMP = $originalTemp", + " $env:TMP = $originalTmp", + " if (Test-Path -LiteralPath $sandbox) { Remove-Item -LiteralPath $sandbox -Recurse -Force }", + "}", + "", + ].join("\n"), + }, + { + name: "portable-git-layout", + source: [ + scriptWithoutEntryPoint, + "", + '$sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-portable-git-test-" + [guid]::NewGuid().ToString("N"))', + '$portableRoot = Join-Path $sandbox "portable-git"', + "try {", + " New-Item -ItemType Directory -Force -Path $sandbox | Out-Null", + " $script:InstallerTempDirectory = $sandbox", + " function Get-PortableGitRoot { return $portableRoot }", + " function Resolve-PortableGitDownload { return @{ Tag = 'test'; Name = 'MinGit.zip'; Url = 'https://example.test/MinGit.zip' } }", + " function Ensure-PortableGitOnUserPath { }", + " function Use-PortableGitIfPresent { return (Test-Path -LiteralPath (Join-Path $portableRoot 'cmd/git.exe')) }", + " function Save-InstallerDownload {", + " param($Uri, $OutFile)", + " New-Item -ItemType File -Force -Path $OutFile | Out-Null", + " }", + " function Expand-Archive {", + " param($Path, $DestinationPath, [switch]$Force)", + " New-Item -ItemType Directory -Force -Path (Join-Path $DestinationPath 'cmd') | Out-Null", + " New-Item -ItemType Directory -Force -Path (Join-Path $DestinationPath 'etc') | Out-Null", + " New-Item -ItemType File -Force -Path (Join-Path $DestinationPath 'cmd/git.exe') | Out-Null", + " New-Item -ItemType File -Force -Path (Join-Path $DestinationPath 'etc/gitconfig') | Out-Null", + " }", + " Install-PortableGit", + " if (-not (Test-Path -LiteralPath (Join-Path $portableRoot 'cmd/git.exe'))) { throw 'missing cmd/git.exe' }", + " if (-not (Test-Path -LiteralPath (Join-Path $portableRoot 'etc/gitconfig'))) { throw 'missing etc/gitconfig' }", + " if (@(Get-ChildItem -LiteralPath $sandbox -Filter 'openclaw-portable-git-*').Count -ne 0) { throw 'temporary Git files remain' }", + "} finally {", + " if (Test-Path -LiteralPath $sandbox) { Remove-Item -LiteralPath $sandbox -Recurse -Force }", + "}", + "", + ].join("\n"), + }, + { + name: "sqlite-versions", + source: [ + scriptWithoutEntryPoint, + "", + "function private-node-fixture {", + " $global:LASTEXITCODE = 0", + " if ($args[0] -eq '-v') { return 'v26.1.0' }", + " $input | Out-Null", + " return (@{ available = $true; version = $script:FixtureSqliteVersion; text = $true; blob = $true; json = $true } | ConvertTo-Json -Compress)", + "}", + "function Get-Command { throw 'unexpected ambient runtime lookup' }", + "$cases = @{", + " '3.44.5' = $false", + " '3.44.6' = $true", + " '3.46.1' = $false", + " '3.50.6' = $false", + " '3.50.7' = $true", + " '3.51.2' = $false", + " '3.51.3' = $true", + " '3.53.1' = $true", + " 'unavailable' = $false", + "}", + "foreach ($entry in $cases.GetEnumerator()) {", + " $actual = Test-NodeSqliteSupported -Version $entry.Key", + ' if ($actual -ne $entry.Value) { throw "Version=$($entry.Key) Actual=$actual" }', + " $script:FixtureSqliteVersion = $entry.Key", + " $actual = Check-Node -NodePath 'private-node-fixture'", + ' if ($actual -ne $entry.Value) { throw "Explicit runtime SQLite=$($entry.Key) Actual=$actual" }', + "}", + "", + ].join("\n"), + }, + { + name: "native-arm64-git", + source: [ + scriptWithoutEntryPoint, + "", + "$env:PROCESSOR_ARCHITEW6432 = $null", + "$env:PROCESSOR_ARCHITECTURE = 'ARM64'", + "function Invoke-RestMethod {", + " param([string]$Uri, [object]$Headers, [int]$TimeoutSec)", + ' if ($TimeoutSec -ne 300) { throw "TimeoutSec=$TimeoutSec" }', + " [pscustomobject]@{", + " tag_name = 'v2.54.0.windows.1'", + " assets = @(", + " [pscustomobject]@{ name = 'MinGit-2.54.0-64-bit.zip'; browser_download_url = 'https://example.test/x64.zip' },", + " [pscustomobject]@{ name = 'MinGit-2.54.0-arm64.zip'; browser_download_url = 'https://example.test/arm64.zip' },", + " [pscustomobject]@{ name = 'MinGit-2.54.0-busybox-64-bit.zip'; browser_download_url = 'https://example.test/busybox.zip' }", + " )", + " }", + "}", + "$download = Resolve-PortableGitDownload", + "if ($download.Name -ne 'MinGit-2.54.0-arm64.zip') { throw \"Name=$($download.Name)\" }", + "if ($download.Url -ne 'https://example.test/arm64.zip') { throw \"Url=$($download.Url)\" }", + "", + ].join("\n"), + }, + { + name: "emulated-arm64-downloads", + source: [ + scriptWithoutEntryPoint, + "", + "$env:PROCESSOR_ARCHITEW6432 = $null", + "$env:PROCESSOR_ARCHITECTURE = 'AMD64'", + "function Get-CimInstance {", + " [CmdletBinding()]", + " param([string]$ClassName)", + " if ($ClassName -eq 'Win32_Processor') { return [pscustomobject]@{ Architecture = 12; Name = 'Cobalt 100' } }", + " if ($ClassName -eq 'Win32_ComputerSystem') { return [pscustomobject]@{ SystemType = 'ARM64-based PC' } }", + ' throw "Unexpected CIM class $ClassName"', + "}", + "function Invoke-RestMethod {", + " param([string]$Uri, [object]$Headers, [int]$OperationTimeoutSeconds)", + ' if ($OperationTimeoutSeconds -ne 300) { throw "OperationTimeoutSeconds=$OperationTimeoutSeconds" }', + " if ($Uri -eq 'https://nodejs.org/dist/index.json') {", + " return @(", + " [pscustomobject]@{ version = 'v26.5.0'; files = @('win-arm64-zip', 'win-x64-zip') },", + " [pscustomobject]@{ version = 'v24.17.0'; files = @('win-arm64-zip', 'win-x64-zip') }", + " )", + " }", + " [pscustomobject]@{", + " tag_name = 'v2.54.0.windows.1'", + " assets = @(", + " [pscustomobject]@{ name = 'MinGit-2.54.0-64-bit.zip'; browser_download_url = 'https://example.test/x64.zip' },", + " [pscustomobject]@{ name = 'MinGit-2.54.0-arm64.zip'; browser_download_url = 'https://example.test/arm64.zip' }", + " )", + " }", + "}", + "$nodeDownload = Resolve-PortableNodeDownload", + "if ($nodeDownload.Name -ne 'node-v26.5.0-win-arm64.zip') { throw \"NodeName=$($nodeDownload.Name)\" }", + "$exactNode = Resolve-PortableNodeDownload -Version 24.17.0", + "if ($exactNode.Name -ne 'node-v24.17.0-win-arm64.zip') { throw \"ExactNode=$($exactNode.Name)\" }", + "$gitDownload = Resolve-PortableGitDownload", + "if ($gitDownload.Name -ne 'MinGit-2.54.0-arm64.zip') { throw \"GitName=$($gitDownload.Name)\" }", + "", + ].join("\n"), + }, + { + name: "node-options", + source: [ + scriptWithoutEntryPoint, + "", + '$result = Resolve-NodeOptionsWithMinOldSpace -NodeOptions "--trace-warnings --max_old_space_size=8192" -MinOldSpaceMb 8192', + 'if ($result -ne "--trace-warnings --max-old-space-size=8192") { throw "alias result=$result" }', + '$result = Resolve-NodeOptionsWithMinOldSpace -NodeOptions "--max_old_space_size 8192 --trace-warnings" -MinOldSpaceMb 8192', + 'if ($result -ne "--max-old-space-size=8192 --trace-warnings") { throw "split alias result=$result" }', + '$result = Resolve-NodeOptionsWithMinOldSpace -NodeOptions "--max-old-space-size=4096" -MinOldSpaceMb 8192', + 'if ($result -ne "--max-old-space-size=8192") { throw "minimum result=$result" }', + '$result = Resolve-NodeOptionsWithMinOldSpace -NodeOptions "`"--max-old-space-size=12288`"" -MinOldSpaceMb 8192', + 'if ($result -ne "--max-old-space-size=12288") { throw "quoted token result=$result" }', + '$result = Resolve-NodeOptionsWithMinOldSpace -NodeOptions "--max-old-space-size=`"12288`"" -MinOldSpaceMb 8192', + 'if ($result -ne "--max-old-space-size=12288") { throw "quoted value result=$result" }', + "", + ].join("\n"), + }, + { + name: "winget-node-delayed-path", + // Refresh-ProcessPath reads machine PATH, which may already contain the real Node install. + source: [ + scriptWithoutEntryPoint, + "", + "$env:ProgramW6432 = 'C:\\openclaw-winget-test-' + [guid]::NewGuid().ToString('N')", + '$env:ProgramFiles = "$env:ProgramW6432 (x86)"', + '$script:wingetNodeDir = "$env:ProgramW6432\\nodejs"', + "function Get-Command {", + " [CmdletBinding()]", + " param([string]$Name)", + " if ($Name -eq 'winget') { return $true }", + " return $null", + "}", + "function Join-Path {", + " param([string]$Path, [string]$ChildPath)", + " return \"$($Path.TrimEnd('\\'))\\$ChildPath\"", + "}", + "function Test-Path {", + " param([string]$Path)", + ' return ($Path -eq "$script:wingetNodeDir\\node.exe")', + "}", + "filter Out-Host { }", + "$env:Path = 'C:\\Windows\\System32'", + "function winget {", + " $global:LASTEXITCODE = 0", + " Write-Output 'winget output'", + "}", + "function Check-Node {", + " return (($env:Path -split ';') -contains $script:wingetNodeDir)", + "}", + "$result = @(Install-Node)", + 'if ($result.Count -ne 1 -or $result[0] -ne $true) { throw "Install-Node returned $result" }', + "if (($env:Path -split ';')[0] -ne $script:wingetNodeDir) { throw \"Path=$env:Path\" }", + "", + ].join("\n"), + }, + { + name: "chocolatey-node-upgrade", + source: [ + scriptWithoutEntryPoint, + "", + "function Get-Command {", + " [CmdletBinding()]", + " param([string]$Name)", + " if ($Name -eq 'choco') { return $true }", + " return $null", + "}", + "filter Out-Host { }", + "function choco {", + " $script:chocoArgs = $args -join ' '", + " $global:LASTEXITCODE = 0", + " Write-Output 'Chocolatey output'", + "}", + "function Check-Node { return $true }", + "$result = @(Install-Node)", + 'if ($result.Count -ne 1 -or $result[0] -ne $true) { throw "Install-Node returned $result" }', + "if ($script:chocoArgs -ne 'upgrade nodejs-lts -y --install-if-not-installed') {", + ' throw "Args=$script:chocoArgs"', + "}", + "", + ].join("\n"), + }, + { + name: "scoop-node-update", + source: [ + scriptWithoutEntryPoint, + "", + "function Get-Command {", + " [CmdletBinding()]", + " param([string]$Name)", + " if ($Name -eq 'scoop') { return $true }", + " return $null", + "}", + "filter Out-Host { }", + "$env:Path = 'C:\\session-bin'", + "$script:scoopCalls = @()", + "function scoop {", + " $script:scoopCalls += ($args -join ' ')", + " $global:LASTEXITCODE = 0", + " Write-Output 'Scoop output'", + "}", + "function Check-Node { return $true }", + "$result = @(Install-Node)", + 'if ($result.Count -ne 1 -or $result[0] -ne $true) { throw "Install-Node returned $result" }', + "if (($script:scoopCalls -join '|') -ne 'update|install nodejs-lts|update nodejs-lts') {", + " throw \"Calls=$($script:scoopCalls -join '|')\"", + "}", + "if (($env:Path -split ';') -notcontains 'C:\\session-bin') { throw \"Path=$env:Path\" }", + "", + ].join("\n"), + }, + { + name: "package-manager-node-validation-failure", + source: [ + scriptWithoutEntryPoint, + "", + "function Get-Command {", + " [CmdletBinding()]", + " param([string]$Name)", + " if ($Name -eq 'choco') { return $true }", + " return $null", + "}", + "filter Out-Host { }", + "function choco {", + " $global:LASTEXITCODE = 0", + " Write-Output 'Chocolatey output'", + "}", + "$script:portableCalled = $false", + "function Install-PortableNode { $script:portableCalled = $true }", + "function Check-Node { return $script:portableCalled }", + "$result = @(Install-Node)", + 'if ($result.Count -ne 1 -or $result[0] -ne $true) { throw "Install-Node returned $result" }', + "if (-not $script:portableCalled) { throw 'Portable Node fallback was not attempted' }", + "", + ].join("\n"), + }, + { + name: "package-manager-node-command-failures", + source: [ + scriptWithoutEntryPoint, + String.raw` +function Get-Command { + [CmdletBinding()] + param([string]$Name) + if ($Name -eq $script:manager) { return $true } + return $null +} +filter Out-Host { } +function Invoke-FixtureManager { + $script:attempts += 1 + Write-Output 'package-manager output must not become a success result' + if ($script:failure -eq 'throw') { throw 'fixture package-manager failure' } + $global:LASTEXITCODE = if ($script:failure -eq 'exit') { 17 } else { 0 } +} +function winget { Invoke-FixtureManager } +function choco { Invoke-FixtureManager } +function scoop { Invoke-FixtureManager } +function Refresh-ProcessPath { $script:refreshes += 1 } +function Add-InstalledNodeToProcessPath { $script:discoveries += 1; return $true } +function Check-Node { return $script:portableReady } +function Install-PortableNode { $script:portableCalls += 1; $script:portableReady = $true } +foreach ($script:manager in @('winget', 'choco', 'scoop')) { + foreach ($script:failure in @('exit', 'throw', 'unsupported')) { + $script:attempts = 0 + $script:refreshes = 0 + $script:discoveries = 0 + $script:portableCalls = 0 + $script:portableReady = $false + $global:LASTEXITCODE = 0 + $result = @(Install-Node) + if ($result.Count -ne 1 -or $result[0] -isnot [bool] -or -not $result[0]) { + throw "manager=$script:manager failure=$script:failure result=$result" + } + $expectedAttempts = if ($script:manager -eq 'scoop' -and $script:failure -eq 'unsupported') { 3 } else { 1 } + $expectedDiscoveries = if ($script:manager -eq 'winget') { 1 } else { 0 } + if ($script:attempts -ne $expectedAttempts -or $script:refreshes -ne 1 -or $script:discoveries -ne $expectedDiscoveries -or $script:portableCalls -ne 1) { + throw "unexpected recovery order/count: $script:manager $script:failure" + } + if ($ErrorActionPreference -ne 'Stop') { throw 'caller error policy changed' } + } +} +`, + ].join("\n"), + }, + { + name: "package-manager-node-next-manager-success", + source: [ + scriptWithoutEntryPoint, + String.raw` +$script:events = New-Object System.Collections.Generic.List[string] +$script:ready = $false +function Get-Command { + [CmdletBinding()] + param([string]$Name) + if ($Name -in @('winget', 'choco', 'scoop')) { return $true } + return $null +} +filter Out-Host { } +function winget { $script:events.Add('winget'); $global:LASTEXITCODE = 17; Write-Output 'winget failed' } +function choco { $script:events.Add('choco'); $global:LASTEXITCODE = 0; $script:ready = $true; Write-Output 'choco success' } +function scoop { throw 'Scoop must not run after supported Node is found' } +function Refresh-ProcessPath { $script:events.Add('refresh') } +function Add-InstalledNodeToProcessPath { $script:events.Add('discover'); return $false } +function Check-Node { $script:events.Add('check'); return $script:ready } +function Install-PortableNode { throw 'portable fallback must not run after supported Node is found' } +$result = @(Install-Node) +if ($result.Count -ne 1 -or $result[0] -isnot [bool] -or -not $result[0]) { throw "result=$result" } +if (($script:events -join '|') -ne 'winget|refresh|discover|check|choco|refresh|check') { + throw "events=$($script:events -join '|')" +} +`, + ].join("\n"), + }, + { + name: "package-manager-node-entrypoint-refusal", + source: [ + scriptWithoutEntryPoint, + String.raw` +$NodeOnly = $false +$NodePrefix = '' +$DryRun = $false +$InstallMethod = 'npm' +$NoOnboard = $true +function Check-ExistingOpenClaw { return $false } +function Get-Command { + [CmdletBinding()] + param([string]$Name) + if ($Name -eq 'choco') { return $true } + return $null +} +filter Out-Host { } +function choco { $global:LASTEXITCODE = 17; Write-Output 'fixture choco failure' } +function Refresh-ProcessPath { } +function Check-Node { return $false } +function Install-PortableNode { + $script:portableCalls += 1 + if ($script:portableFailure -eq 'throw') { throw 'fixture portable failure' } +} +function Install-OpenClaw { throw 'package install must not run without a supported Node' } +foreach ($script:portableFailure in @('throw', 'unsupported')) { + $script:InstallExitCode = 0 + $script:portableCalls = 0 + $caught = $false + try { +`, + ...entrypointLines.map((line) => ` ${line}`), + String.raw` + } catch { + if ($_.Exception.Message -ne 'OpenClaw installation failed with exit code 1.') { throw } + $caught = $true + } + if (-not $caught -or $script:InstallExitCode -ne 1 -or $script:portableCalls -ne 1) { + throw 'failed recovery did not preserve the installer refusal contract' + } + if ($ErrorActionPreference -ne 'Stop') { throw 'caller error policy changed' } +} +`, + ].join("\n"), + }, + { + name: "scriptblock-failure", + source: [ + scriptWithoutEntryPoint, + "", + "function Write-Banner { }", + "function Ensure-ExecutionPolicy { return $true }", + "function Check-Node { return $false }", + "function Install-Node { return $false }", + "$caught = $false", + "try {", + ...entrypointLines.map((line) => ` ${line}`), + "} catch {", + " if ($_.Exception.Message -ne 'OpenClaw installation failed with exit code 1.') { throw }", + " $caught = $true", + "}", + "if (-not $caught) { throw 'Install failure did not reach the caller' }", + "", + ].join("\n"), + }, + { + name: "scriptblock-deferred-path-success", + source: createDeferredPathSuccessFixture(source), + }, + { + name: "noisy-git-failure", + source: [ + scriptWithoutEntryPoint, + "", + "function Write-Banner { }", + "function Ensure-ExecutionPolicy { return $true }", + "function Check-Node { return $true }", + "function Check-ExistingOpenClaw { return $false }", + "function Get-NpmCommandPath { return $null }", + "function Install-OpenClawFromGit {", + " Write-Output 'pnpm stdout before failure'", + " return $false", + "}", + "function Ensure-OpenClawOnPath { throw 'should not continue after failed git install' }", + "$InstallMethod = 'git'", + "$GitDir = 'C:\\\\openclaw-test'", + "$NoOnboard = $true", + "$null = Main", + 'if ($script:InstallExitCode -ne 1) { throw "InstallExitCode=$script:InstallExitCode" }', + "", + ].join("\n"), + }, + { + name: "quiet-main-success", + source: [ + scriptWithoutEntryPoint, + "", + "function Write-Banner { }", + "function Ensure-ExecutionPolicy { return $true }", + "function Check-Node { return $true }", + "function Check-ExistingOpenClaw { return $false }", + "function Add-ToPath { param([string]$Path) }", + "function Install-OpenClaw { Write-Output 'npm stdout'; return $true }", + "function Ensure-OpenClawOnPath { return $true }", + "function Refresh-GatewayServiceIfLoaded { }", + "function Invoke-OpenClawCommand { return 'OpenClaw test-version' }", + "$NoOnboard = $true", + "$result = Main", + "if ($result -is [array]) { throw 'Main returned an array' }", + 'if ($result -ne $true) { throw "Main returned $result" }', + "", + ].join("\n"), + }, + { + name: "terminal-code-success", + source: [ + scriptWithoutEntryPoint, + "", + "function Write-Banner { }", + "function Ensure-ExecutionPolicy { return $true }", + "function Check-Node { return $true }", + "function Check-ExistingOpenClaw { return $false }", + "function Add-ToPath { param([string]$Path) }", + "function Install-OpenClaw {", + " Write-Output 'native chatter'", + " return $true", + "}", + "function Ensure-OpenClawOnPath { return $true }", + "function Refresh-GatewayServiceIfLoaded { }", + "function Invoke-OpenClawCommand { return 'OpenClaw test-version' }", + "$NoOnboard = $true", + ...entrypointLines, + "", + ].join("\n"), + }, + { + name: "transactional-git-clone", + source: [ + scriptWithoutEntryPoint, + "", + '$sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-transactional-clone-" + [guid]::NewGuid().ToString("N"))', + "New-Item -ItemType Directory -Path $sandbox | Out-Null", + "$script:CloneMode = 'success'", + "$script:GitFilterSupport = $true", + "$script:LastCloneArgs = @()", + "$script:ConcurrentRepo = $null", + "$script:AliasPath = $null", + "$script:AliasReplacement = $null", + "function git {", + " if ($args[0] -eq 'clone' -and $args[1] -eq '-h') {", + " if ($script:GitFilterSupport) { Write-Output ' --[no-]filter ' }", + " $global:LASTEXITCODE = 129", + " return", + " }", + " if ($args[0] -eq 'clone') { $script:LastCloneArgs = @($args) }", + " $target = $args[-1]", + " New-Item -ItemType Directory -Force -Path (Join-Path $target '.git') | Out-Null", + " Set-Content -LiteralPath (Join-Path $target 'checkout.marker') -Value 'complete'", + " if ($script:CloneMode -eq 'failure') { $global:LASTEXITCODE = 42; return }", + " if ($script:CloneMode -eq 'concurrent') {", + " New-Item -ItemType Directory -Path $script:ConcurrentRepo | Out-Null", + " Set-Content -LiteralPath (Join-Path $script:ConcurrentRepo 'user.marker') -Value 'keep'", + " }", + " if ($script:CloneMode -eq 'retarget-alias') {", + " Remove-Item -LiteralPath $script:AliasPath -Force", + " $linkType = if ($IsWindows -or $env:OS -eq 'Windows_NT') { 'Junction' } else { 'SymbolicLink' }", + " New-Item -ItemType $linkType -Path $script:AliasPath -Target $script:AliasReplacement | Out-Null", + " }", + " $global:LASTEXITCODE = 0", + "}", + "try {", + " $successRepo = Join-Path $sandbox 'success'", + " New-TransactionalGitCheckout -RepoUrl 'https://example.invalid/openclaw.git' -RepoDir $successRepo", + " if (-not (Test-Path -LiteralPath (Join-Path $successRepo 'checkout.marker'))) { throw 'complete checkout was not published' }", + " if ($script:LastCloneArgs -notcontains '--filter=blob:none') { throw 'supported Git did not use a filtered clone' }", + "", + " $emptyRepo = Join-Path $sandbox 'empty'", + " New-Item -ItemType Directory -Path $emptyRepo | Out-Null", + " $script:GitFilterSupport = $false", + " New-TransactionalGitCheckout -RepoUrl 'https://example.invalid/openclaw.git' -RepoDir $emptyRepo", + " if (-not (Test-Path -LiteralPath (Join-Path $emptyRepo 'checkout.marker'))) { throw 'empty destination was not populated' }", + " if ($script:LastCloneArgs -contains '--filter=blob:none') { throw 'unsupported Git used a filtered clone' }", + "", + " $aliasTarget = Join-Path $sandbox 'alias-target'", + " $script:AliasReplacement = Join-Path $sandbox 'alias-replacement'", + " $script:AliasPath = Join-Path $sandbox 'alias'", + " New-Item -ItemType Directory -Path $aliasTarget | Out-Null", + " New-Item -ItemType Directory -Path $script:AliasReplacement | Out-Null", + " $linkType = if ($IsWindows -or $env:OS -eq 'Windows_NT') { 'Junction' } else { 'SymbolicLink' }", + " New-Item -ItemType $linkType -Path $script:AliasPath -Target $aliasTarget | Out-Null", + " $script:CloneMode = 'retarget-alias'", + " New-TransactionalGitCheckout -RepoUrl 'https://example.invalid/openclaw.git' -RepoDir $script:AliasPath", + " if (-not (Test-Path -LiteralPath (Join-Path $aliasTarget 'checkout.marker'))) { throw 'original alias target was not populated' }", + " if (@(Get-ChildItem -LiteralPath $script:AliasReplacement -Force).Count -ne 0) { throw 'replacement alias target was modified' }", + "", + " $script:CloneMode = 'failure'", + " $failedRepo = Join-Path $sandbox 'failure'", + " $cloneFailed = $false", + " try { New-TransactionalGitCheckout -RepoUrl 'https://example.invalid/openclaw.git' -RepoDir $failedRepo } catch { $cloneFailed = $true }", + " if (-not $cloneFailed) { throw 'failed clone was accepted' }", + " if (Test-Path -LiteralPath $failedRepo) { throw 'failed clone published its destination' }", + "", + " $script:CloneMode = 'concurrent'", + " $script:ConcurrentRepo = Join-Path $sandbox 'concurrent'", + " $publicationFailed = $false", + " try { New-TransactionalGitCheckout -RepoUrl 'https://example.invalid/openclaw.git' -RepoDir $script:ConcurrentRepo } catch { $publicationFailed = $true }", + " if (-not $publicationFailed) { throw 'concurrent destination was replaced' }", + " if ((Get-Content -LiteralPath (Join-Path $script:ConcurrentRepo 'user.marker') -Raw).Trim() -ne 'keep') { throw 'concurrent destination changed' }", + " if (Test-Path -LiteralPath (Join-Path $script:ConcurrentRepo 'checkout.marker')) { throw 'clone leaked into concurrent destination' }", + " if (@(Get-ChildItem -LiteralPath $sandbox -Filter '.openclaw-clone-*' -Force).Count -ne 0) { throw 'staging directories remain' }", + "} finally {", + " Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue", + "}", + "", + ].join("\n"), + }, + ]; + if (process.platform === "win32") { + cases.push({ + name: "pnpm-source-bootstrap-lifecycle", + source: [ + scriptWithoutEntryPoint, + `$nodeExe = ${toPowerShellSingleQuotedLiteral(process.execPath)}`, + String.raw` +$root = Join-Path $script:InstallerTempDirectory ("openclaw pnpm boundary " + [guid]::NewGuid().ToString("N")) +$contextNames = @('COREPACK_ENABLE_DOWNLOAD_PROMPT', 'NPM_CONFIG_WORKSPACE_DIR', 'PNPM_CONFIG_LOCKFILE_DIR', 'PNPM_CONFIG_CHILD_CONCURRENCY', 'PNPM_CONFIG_NETWORK_CONCURRENCY', 'PNPM_CONFIG_WORKSPACE_CONCURRENCY', 'PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN', 'PNPM_CONFIG_SIDE_EFFECTS_CACHE', 'NODE_OPTIONS') +$saved = @{} +foreach ($name in (@('PATH', 'PATHEXT', 'USERPROFILE', 'OPENCLAW_TEST_BOOTSTRAP_ROOT', 'PNPM_CONFIG_PREFER_OFFLINE') + $contextNames)) { + $saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') +} +$previousTemp = $script:InstallerTempDirectory +$previousLocation = (Get-Location).Path +function Ensure-Git { return $true } +function Assert-GitCheckoutHasCommit { param([string]$RepoDir) } +function Remove-LegacySubmodule { param([string]$RepoDir) } +function git { throw 'unexpected Git mutation' } +function New-TransactionalGitCheckout { throw 'unexpected clone' } +function Main { throw 'unexpected installer entrypoint' } +function Run-Doctor { throw 'unexpected doctor' } +function Refresh-GatewayServiceIfLoaded { throw 'unexpected gateway refresh' } +function Invoke-OpenClawCommand { throw 'unexpected live CLI' } +function Publish-TextFileAtomically { + param([string]$Path, [string]$Contents) + $expectedPath = Join-Path $env:USERPROFILE '.local\bin\openclaw.cmd' + # Duplicate separators can name the same Windows wrapper; require the exact normalized path. + if ([IO.Path]::GetFullPath($Path) -ne [IO.Path]::GetFullPath($expectedPath)) { throw 'publication escaped fixture' } + $script:Published += 1 +} +function Add-ToUserPath { param([string]$Path); $script:PathPublished += 1; return $false } +$commandSource = @' +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const root = process.env.OPENCLAW_TEST_BOOTSTRAP_ROOT; +const spec = JSON.parse(fs.readFileSync(path.join(root, 'case.json'), 'utf8')); +const target = path.join(root, 'target'); +const log = path.join(root, 'calls.jsonl'); +const [kind, launcher, ...args] = process.argv.slice(2); +const samePath = (a, b) => assert.equal(path.resolve(a).toLowerCase(), path.resolve(b).toLowerCase()); +const calls = () => fs.readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse); +if (kind === 'verify') { + const events = calls(); + assert.equal(events.some((e) => e.kind === 'ambient'), false, 'ambient pnpm ran'); + const stages = events.filter((e) => e.kind === 'selected' && e.args[0] !== '--version').map((e) => e.args[0]); + const expected = spec.failure === 'bootstrap' || spec.failure === 'version' ? [] + : spec.failure === 'install' ? ['config', 'install', 'install'] + : spec.failure === 'build' ? ['config', 'install', 'ui:build', 'nested-ui', 'build'] + : ['config', 'install', 'ui:build', 'nested-ui', 'build', 'nested-build']; + assert.deepEqual(stages, expected); + const npmInstalls = events.filter((e) => e.kind === 'npm' && e.args[0] === 'install'); + assert.equal(npmInstalls.length, spec.mode === 'corepack' ? 0 : 1); + assert.equal(events.filter((e) => e.kind === 'corepack').length, spec.mode === 'missing' ? 0 : 1); + const selected = events.filter((e) => e.kind === 'selected'); + assert.ok(selected.length || spec.failure === 'bootstrap'); + for (const event of selected) { + samePath(event.cwd, target); + if (event.args[0] !== '--version') samePath(event.path.split(';')[0], path.dirname(event.launcher)); + } + process.exit(0); +} +const roots = Object.fromEntries(['NPM_CONFIG_WORKSPACE_DIR', 'npm_config_workspace_dir', 'PNPM_CONFIG_LOCKFILE_DIR', 'pnpm_config_lockfile_dir'].map((key) => [key, process.env[key] ?? null])); +fs.appendFileSync(log, JSON.stringify({ kind, launcher, args, cwd: process.cwd(), path: process.env.PATH, roots, prompt: process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT ?? null, nodeOptions: process.env.NODE_OPTIONS ?? null }) + '\n'); +if (kind === 'ambient') { + fs.writeFileSync(path.join(target, 'pnpm-lock.yaml'), 'corrupted'); + console.log(spec.version); + process.exit(0); +} +assert.equal(process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT, '0'); +for (const key of ['NPM_CONFIG_WORKSPACE_DIR', 'npm_config_workspace_dir', 'PNPM_CONFIG_LOCKFILE_DIR', 'pnpm_config_lockfile_dir']) { + samePath(process.env[key], target); +} +for (const [key, value] of Object.entries({ CHILD_CONCURRENCY: '1', NETWORK_CONCURRENCY: '4', WORKSPACE_CONCURRENCY: '1', VERIFY_DEPS_BEFORE_RUN: 'false', SIDE_EFFECTS_CACHE: 'false' })) { + assert.equal(process.env['PNPM_CONFIG_' + key], value, key); +} +const writeSelected = (dir) => { + assert.equal(path.dirname(dir) === path.join(root, 'tools') || path.dirname(path.dirname(dir)) === path.join(root, 'tools'), true); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'pnpm.cmd'), '@echo off\r\n"' + process.execPath + '" "' + __filename + '" selected "%~f0" %*\r\nexit /b %errorlevel%\r\n'); +}; +if (kind === 'corepack') { + assert.deepEqual(args.slice(0, 2), ['enable', '--install-directory']); + assert.equal(args.length, 4); + assert.equal(args[3], 'pnpm'); + samePath(path.dirname(args[2]), path.join(root, 'tools')); + if (spec.mode === 'failing') process.exit(42); + writeSelected(args[2]); +} else if (kind === 'npm') { + if (args[0] === '--version') { + assert.deepEqual(args, ['--version']); + console.log('12.0.0'); + } else { + assert.deepEqual(args.slice(0, 3), ['install', '-g', '--prefix']); + assert.deepEqual(args.slice(4), ['pnpm@' + spec.version, '--allow-scripts=pnpm@' + spec.version]); + assert.equal(path.basename(args[3]), 'npm'); + samePath(path.dirname(path.dirname(args[3])), path.join(root, 'tools')); + if (spec.failure === 'bootstrap') process.exit(42); + writeSelected(args[3]); + } +} else { + assert.equal(kind, 'selected'); + samePath(process.cwd(), target); + if (args[0] === '--version') { + assert.deepEqual(args, ['--version']); + const wrongVersion = spec.failure === 'version' || (spec.mode === 'wrong-version' && path.basename(path.dirname(launcher)) !== 'npm'); + console.log(wrongVersion ? '0.0.0' : spec.version); + } else if (args[0] === 'config') { + assert.deepEqual(args, ['config', 'get', 'prefer-offline']); + console.log('undefined'); + } else if (args[0] === 'install') { + assert.deepEqual(args, ['install', '--prefer-offline', '--config.node-linker=hoisted', '--config.engine-strict=false', '--config.enable-pre-post-scripts=true', '--config.side-effects-cache=false', '--no-frozen-lockfile', '--config.child-concurrency=1', '--config.network-concurrency=4', '--config.workspace-concurrency=1']); + if (spec.failure === 'install') process.exit(42); + } else if (args[0] === 'ui:build' || args[0] === 'build') { + assert.equal(args.length, 1); + if (args[0] === 'build') { + assert.match(process.env.NODE_OPTIONS, /--max-old-space-size=8192/); + if (spec.failure === 'build') process.exit(42); + fs.mkdirSync(path.join(target, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(target, 'dist', 'entry.js'), 'process.exit(0);'); + } + const child = spawnSync(process.env.ComSpec, ['/d', '/s', '/c', 'pnpm ' + (args[0] === 'build' ? 'nested-build' : 'nested-ui')], { stdio: 'inherit', windowsVerbatimArguments: true }); + assert.equal(child.error, undefined); + process.exit(child.status ?? 1); + } else { + assert.ok(['nested-ui', 'nested-build'].includes(args[0])); + assert.equal(args.length, 1); + } +} +'@ +$scenarios = @( + @{ Mode = 'corepack'; Failure = ''; Present = $true; Version = '12.0.0' }, + @{ Mode = 'corepack'; Failure = ''; Present = $false; Version = '11.15.1' }, + @{ Mode = 'missing'; Failure = ''; Present = $false; Version = '12.0.0' }, + @{ Mode = 'failing'; Failure = ''; Present = $true; Version = '12.0.0' }, + @{ Mode = 'wrong-version'; Failure = ''; Present = $false; Version = '12.0.0' }, + @{ Mode = 'missing'; Failure = 'bootstrap'; Present = $true; Version = '12.0.0' }, + @{ Mode = 'missing'; Failure = 'version'; Present = $false; Version = '12.0.0' }, + @{ Mode = 'corepack'; Failure = 'install'; Present = $false; Version = '12.0.0' }, + @{ Mode = 'missing'; Failure = 'build'; Present = $true; Version = '12.0.0' } +) +try { + foreach ($scenario in $scenarios) { + $caseRoot = Join-Path $root ([guid]::NewGuid().ToString('N')) + $bin = Join-Path $caseRoot 'bin' + $target = Join-Path $caseRoot 'target' + $foreign = Join-Path $caseRoot 'foreign' + $script:InstallerTempDirectory = Join-Path $caseRoot 'tools' + foreach ($dir in @($bin, $target, $foreign, $script:InstallerTempDirectory)) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + } + $env:OPENCLAW_TEST_BOOTSTRAP_ROOT = $caseRoot + $env:USERPROFILE = $caseRoot + $env:PATH = $bin + $env:PATHEXT = '.COM;.EXE;.BAT;.CMD' + $env:PNPM_CONFIG_PREFER_OFFLINE = $null + [IO.File]::WriteAllText((Join-Path $caseRoot 'command.cjs'), $commandSource) + $caseSpec = @{ mode = $scenario.Mode; failure = $scenario.Failure; version = $scenario.Version } + [IO.File]::WriteAllText((Join-Path $caseRoot 'case.json'), ($caseSpec | ConvertTo-Json -Compress)) + [IO.File]::WriteAllText((Join-Path $target 'package.json'), ('{"packageManager":"pnpm@' + $scenario.Version + '"}')) + foreach ($dir in @($target, $foreign)) { [IO.File]::WriteAllText((Join-Path $dir 'pnpm-lock.yaml'), 'unchanged') } + $manifest = [IO.File]::ReadAllText((Join-Path $target 'package.json')) + foreach ($kind in @('npm', 'ambient', 'corepack')) { + if ($kind -eq 'corepack' -and $scenario.Mode -eq 'missing') { continue } + $name = if ($kind -eq 'ambient') { 'pnpm' } else { $kind } + $cmd = '@echo off' + [Environment]::NewLine + '"' + $nodeExe + '" "' + (Join-Path $caseRoot 'command.cjs') + '" ' + $kind + ' "%~f0" %*' + [Environment]::NewLine + 'exit /b %errorlevel%' + [IO.File]::WriteAllText((Join-Path $bin ($name + '.cmd')), $cmd) + } + [IO.File]::WriteAllText((Join-Path $bin 'node.cmd'), ('@"' + $nodeExe + '" %*' + [Environment]::NewLine + '@exit /b %errorlevel%')) + foreach ($name in $contextNames) { + $value = if (-not $scenario.Present) { $null } elseif ($name -eq 'COREPACK_ENABLE_DOWNLOAD_PROMPT') { '1' } elseif ($name -eq 'NODE_OPTIONS') { '--no-warnings' } elseif ($name -match '_DIR$') { $foreign } else { '7' } + Set-Item -LiteralPath "Env:$name" -Value $value + } + $caller = @{} + foreach ($name in (@('PATH') + $contextNames)) { $caller[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } + Set-Location -LiteralPath $foreign + $script:Published = 0 + $script:PathPublished = 0 + $outsideRejected = try { + Publish-TextFileAtomically -Path (Join-Path $caseRoot '..\openclaw.cmd') -Contents '' + $false + } catch { + if ($_.Exception.Message -ne 'publication escaped fixture') { throw } + $true + } + if (-not $outsideRejected -or $script:Published -ne 0) { throw 'outside publication was accepted' } + $caught = $null + $ownerOutput = @() + try { $ownerOutput = @(Install-OpenClawFromGit -RepoDir $target -SkipUpdate) } catch { $caught = $_ } + $success = Test-BooleanSuccessResult -Results $ownerOutput + if ($scenario.Failure -in @('bootstrap', 'version')) { + if (-not $caught -or $caught.Exception.Message -notmatch 'Could not (install|provision)') { throw "missing bootstrap failure: $caught" } + } elseif ($caught) { throw $caught } + $expectedSuccess = $scenario.Failure -eq '' + if ($success -ne $expectedSuccess) { throw "unexpected owner result: $($scenario | ConvertTo-Json -Compress)" } + if ($script:Published -ne [int]$expectedSuccess -or $script:PathPublished -ne [int]$expectedSuccess) { throw 'publication boundary was violated' } + foreach ($name in $caller.Keys) { + if ([Environment]::GetEnvironmentVariable($name, 'Process') -cne $caller[$name]) { throw "caller environment leaked: $name" } + } + if ((Get-Location).Path -ne $foreign) { throw 'caller location leaked' } + if (@(Get-ChildItem -LiteralPath $script:InstallerTempDirectory -Force).Count -ne 0) { throw 'temporary pnpm prefix leaked' } + foreach ($dir in @($target, $foreign)) { + if ([IO.File]::ReadAllText((Join-Path $dir 'pnpm-lock.yaml')) -ne 'unchanged') { throw 'lockfile changed' } + } + if ([IO.File]::ReadAllText((Join-Path $target 'package.json')) -ne $manifest) { throw 'manifest changed' } + & $nodeExe (Join-Path $caseRoot 'command.cjs') verify + if ($LASTEXITCODE -ne 0) { throw "child boundary verification failed: $($scenario | ConvertTo-Json -Compress)" } + } +} finally { + Set-Location -LiteralPath $previousLocation + foreach ($name in $saved.Keys) { Set-Item -LiteralPath "Env:$name" -Value $saved[$name] } + $script:InstallerTempDirectory = $previousTemp + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } +} +`, + ].join("\n"), + }); + cases.push({ + name: "portable-node-tar-fallback", + source: [ + scriptWithoutEntryPoint, + String.raw` +$root = Join-Path $script:InstallerTempDirectory ("openclaw portable node " + [guid]::NewGuid().ToString("N")) +$bin = Join-Path $root "bin" +$archiveRoot = Join-Path $root "archive" +$nodeRoot = Join-Path $archiveRoot "node-fixture" +$zip = Join-Path $root "node archive.zip" +$destination = Join-Path $root "portable node" +$tarArgsLog = Join-Path $root "tar-args.txt" +$previousPath = $env:PATH +$previousLocation = (Get-Location).Path +try { + New-Item -ItemType Directory -Force -Path $bin, $nodeRoot | Out-Null + [IO.File]::WriteAllText((Join-Path $nodeRoot "node.exe"), "node fixture bytes") + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::CreateFromDirectory($archiveRoot, $zip) + $tarScript = @( + "@echo off", + ('echo %~1 > "' + $tarArgsLog + '"'), + ('echo %~2 >> "' + $tarArgsLog + '"'), + ('echo %~3 >> "' + $tarArgsLog + '"'), + ('echo %~4 >> "' + $tarArgsLog + '"'), + ('echo %~5 >> "' + $tarArgsLog + '"'), + ('echo %~6 >> "' + $tarArgsLog + '"'), + 'echo partial> "%~4\partial.marker"', + "echo tar fixture failure 1>&2", + "exit /b 17" + ) + [IO.File]::WriteAllLines((Join-Path $bin "tar.cmd"), $tarScript) + $env:PATH = "$bin;$env:PATH" + # Explicit PowerShell redirection preserves the Windows PowerShell 5.1 failure mode. + $output = @(Expand-PortableNodeArchive -ZipPath $zip -DestinationPath $destination 2>&1) + if ($LASTEXITCODE -ne 17) { throw "native exit changed: $LASTEXITCODE" } + $expectedArguments = @("-xf", $zip, "-C", $destination, "--strip-components", "1") + $actualArguments = @(Get-Content -LiteralPath $tarArgsLog | ForEach-Object { $_.TrimEnd() }) + if (($actualArguments -join "|") -cne ($expectedArguments -join "|")) { throw "tar argument mismatch" } + if ([IO.File]::ReadAllText((Join-Path $destination "node.exe")) -cne "node fixture bytes") { throw "fallback bytes changed" } + if (Test-Path -LiteralPath (Join-Path $destination "partial.marker")) { throw "partial tar output remains" } + if (@(Get-ChildItem -LiteralPath $root -Filter "portable-node-extract-*").Count -ne 0) { throw "fallback temporary directory remains" } + if (($output | Out-String) -notmatch "tar fixture failure") { throw "native stderr lost" } + if ($ErrorActionPreference -ne "Stop" -or (Get-Location).Path -ne $previousLocation) { throw "caller state leaked" } +} finally { + $env:PATH = $previousPath + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } +} +`, + ].join("\n"), + }); + } + const tempDir = harness.createTempDir("openclaw-install-ps1-batch-"); + const fixtures = cases.map((testCase, index) => { + const scriptPath = join(tempDir, `case-${index}.ps1`); + writeFileSync(scriptPath, testCase.source); + return { name: testCase.name, scriptPath }; + }); + const command = [ + "$ErrorActionPreference = 'Stop'", + "$cases = @(", + fixtures + .map( + (fixture) => + ` @{ Name = ${toPowerShellSingleQuotedLiteral(fixture.name)}; Path = ${toPowerShellSingleQuotedLiteral(fixture.scriptPath)} }`, + ) + .join(",\n"), + ")", + "$results = foreach ($case in $cases) {", + " $caseOutput = New-Object System.Collections.Generic.List[string]", + " try {", + " & ([scriptblock]::Create((Get-Content -LiteralPath $case.Path -Raw))) *>&1 | ForEach-Object { $caseOutput.Add([string]$_) }", + " [pscustomobject]@{ name = $case.Name; ok = $true; error = '' }", + " } catch {", + ' $details = ($caseOutput | Select-Object -Last 80) -join "`n"', + ' [pscustomobject]@{ name = $case.Name; ok = $false; error = "$( $_.Exception.Message )`nNative exit: $LASTEXITCODE`n$details" }', + " }", + "}", + "$results | ConvertTo-Json -Compress", + ].join("\n"); + const result = runPowerShell(["-NoLogo", "-NoProfile", "-Command", command]); + if (result.status !== 0) { + throw new Error(`PowerShell batch failed: ${result.stderr}`); + } + const parsed = JSON.parse(result.stdout) as Array<{ error: string; name: string; ok: boolean }>; + for (const entry of parsed) { + batchedPowerShellResults.set(entry.name, { error: entry.error, ok: entry.ok }); + } + // The hosted installer supports Windows PowerShell 5.1 as well as PowerShell 7. + for (const engine of bootstrapShells) { + if (engine === powershell) { + continue; + } + for (const name of [ + "native-npm-stderr", + "pnpm-source-bootstrap-lifecycle", + "portable-git-layout", + "portable-node-tar-fallback", + "package-manager-node-command-failures", + "package-manager-node-next-manager-success", + "package-manager-node-entrypoint-refusal", + ]) { + const fixture = fixtures.find((entry) => entry.name === name); + if (!fixture) { + throw new Error(`Missing PowerShell fixture ${name}`); + } + const invocation = `$ErrorActionPreference = 'Stop'; & ([scriptblock]::Create((Get-Content -LiteralPath ${toPowerShellSingleQuotedLiteral(fixture.scriptPath)} -Raw)))`; + const engineResult = spawnSync(engine, ["-NoLogo", "-NoProfile", "-Command", invocation], { + encoding: "utf8", + }); + batchedPowerShellResults.set(`${name}:${engine}`, { + ok: engineResult.status === 0, + error: + engineResult.status === 0 + ? "" + : (engineResult.error?.message ?? engineResult.stdout + engineResult.stderr), + }); + } + } + }); + + function expectBatchedPowerShellCase(name: string): void { + expect(batchedPowerShellResults.get(name)).toEqual({ error: "", ok: true }); + } + + runIfPowerShell( + "renews legacy download watchdogs while bytes arrive and aborts stalled bodies", + async () => { + const server = http.createServer((request, response) => { + if (request.url === "/redirect") { + response.writeHead(302, { location: "/stream" }); + response.end(); + return; + } + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.write("start"); + if (request.url === "/stall") { + const timer = setTimeout(() => response.end("late"), 3000); + response.once("close", () => clearTimeout(timer)); + return; + } + let chunks = 0; + const timer = setInterval(() => { + response.write("."); + if (++chunks === 4) { + response.end(); + } + }, 400); + response.once("close", () => clearInterval(timer)); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Download fixture did not bind a TCP port"); + } + const directory = harness.createTempDir("openclaw-installer-network-"); + const output = join(directory, "download.bin"); + const scriptPath = join(directory, "download.ps1"); + writeFileSync( + scriptPath, + [ + "$ErrorActionPreference = 'Stop'", + "$script:UpdateNetworkTimeoutSeconds = 1", + ...["Get-WebRequestTimeoutParameters", "Save-InstallerDownload"].map( + (name) => `function ${name} {\n${extractFunctionBody(source, name)}}`, + ), + "function Invoke-WebRequest { throw 'Legacy downloads must stream directly' }", + `$output = ${toPowerShellSingleQuotedLiteral(output)}`, + `$base = 'http://127.0.0.1:${address.port}'`, + 'Save-InstallerDownload -Uri "$base/redirect" -OutFile $output', + "if ([IO.File]::ReadAllText($output) -ne 'start....') { throw 'Incomplete slow download' }", + "$failed = $false", + 'try { Save-InstallerDownload -Uri "$base/stall" -OutFile $output } catch { $failed = $true }', + "if (-not $failed) { throw 'Stalled download was accepted' }", + "$exclusive = [IO.File]::Open($output, 'Open', 'ReadWrite', 'None')", + "$exclusive.Dispose()", + "Write-Output 'slow-download-complete; stalled-download-aborted; file-released'", + ].join("\n"), + ); + const result = await runPowerShellAsync(["-NoLogo", "-NoProfile", "-File", scriptPath]); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout).toContain( + "slow-download-complete; stalled-download-aborted; file-released", + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }, + ); + + runIfPowerShell("rejects unknown and positional options before starting the installer", () => { + const cases = [ + ["-Frobnicate"], + ["-DryRnu"], + ["-NoOnbord"], + ["-InstallMthod", "git"], + ["-DryRun", "beta"], + ["beta", "git"], + ]; + + for (const args of cases) { + const result = runInstallerFile(args, { + OPENCLAW_DRY_RUN: "1", + OPENCLAW_NO_ONBOARD: "1", + }); + expect(result.status, args.join(" ")).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("[OK] Windows detected"); + } + }); + + runIfPowerShell("validates environment options before starting the installer", () => { + const result = runInstallerFile(["-NoOnboard"], { + OPENCLAW_DRY_RUN: "1", + OPENCLAW_INSTALL_METHOD: "bogus", + }); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("[OK] Windows detected"); + }); + + runIfPowerShell("shows help without starting the installer", () => { + const fileResult = runInstallerFile(["-?"]); + expect(fileResult.status).toBe(0); + expect(`${fileResult.stdout}\n${fileResult.stderr}`).toContain("install.ps1"); + expect(`${fileResult.stdout}\n${fileResult.stderr}`).not.toContain("[OK] Windows detected"); + + const scriptPath = toPowerShellSingleQuotedLiteral(join(process.cwd(), SCRIPT_PATH)); + const scriptblockResult = runPowerShell([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + `& ([scriptblock]::Create((Get-Content -LiteralPath ${scriptPath} -Raw))) -Help`, + ]); + expect(scriptblockResult.status).toBe(0); + expect(scriptblockResult.stdout).toContain("Usage:"); + expect(scriptblockResult.stdout).toContain("-DryRun"); + expect(scriptblockResult.stdout).not.toContain("[OK] Windows detected"); + }); + + runIfPowerShell("accepts the documented named options", () => { + const result = runInstallerFile([ + "-DryRun", + "-NoOnboard", + "-InstallMethod", + "git", + "-NoGitUpdate", + "-Tag", + "main", + ]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("[OK] Install method: git"); + expect(result.stdout).toContain("[OK] Git update: disabled"); + expect(result.stdout).toContain("[OK] Onboard: skipped"); + }); + + runIfPowerShell("requires an explicit absolute private prefix for Node-only updates", () => { + const root = harness.createTempDir("openclaw-node-only-options-"); + for (const args of [ + ["-NodeOnly"], + ["-NodeOnly", "-NodePrefix", "relative/node"], + ["-NodeOnly", "-NodePrefix", parse(root).root], + ["-NodePrefix", join(root, "private-node")], + ]) { + const result = runInstallerFile([...args, "-DryRun"]); + expect(result.status, args.join(" ")).toBe(2); + expect(result.stdout).toContain("Error:"); + } + const result = runInstallerFile([ + "-NodeOnly", + "-NodePrefix", + join(root, "private node"), + "-DryRun", + ]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("PATH unchanged"); + expect(result.stdout).not.toContain("Install method:"); + }); + + runIfPowerShell( + "updates only the private runtime after checksum and compatibility checks", + () => { + expectBatchedPowerShellCase("private-node-update"); + }, + ); + + runIfPowerShell("accepts only supported Node versions", () => { + expectBatchedPowerShellCase("node-versions"); + expectBatchedPowerShellCase("sqlite-versions"); + }); + + runIfPowerShell("requires the numeric floor and SQLite round trips before reusing Node", () => { + expectBatchedPowerShellCase("node-capabilities"); + }); + + runIfPowerShell("normalizes and exports one installer temp root", () => { + expectBatchedPowerShellCase("canonical-temp-root"); + }); + + runIfPowerShell("applies the canonical npm lifecycle version policy", () => { + expectBatchedPowerShellCase("npm-lifecycle-policy"); + }); + + runIfPowerShell( + "preserves native stderr and exit codes without softening PowerShell failures", + () => { + if (process.platform === "win32") { + expect(bootstrapShells).toContain("powershell"); + } + expectBatchedPowerShellCase("native-npm-stderr"); + for (const engine of bootstrapShells) { + if (engine !== powershell) { + expectBatchedPowerShellCase(`native-npm-stderr:${engine}`); + } + } + }, + ); + + runIfPowerShell("preserves explicit pnpm prefer-offline settings for Git installs", () => { + expectBatchedPowerShellCase("pnpm-prefer-offline-policy"); + }); + + (process.platform === "win32" ? it : it.skip)( + "scopes native pnpm children and restores the caller across source-install outcomes", + () => { + expect(bootstrapShells).toContain("powershell"); + for (const engine of bootstrapShells) { + const name = "pnpm-source-bootstrap-lifecycle"; + expectBatchedPowerShellCase(engine === powershell ? name : `${name}:${engine}`); + } + }, + ); + + (process.platform === "win32" ? it : it.skip)( + "reaches portable Node ZIP fallback after redirected native tar stderr", + () => { + expect(bootstrapShells).toContain("powershell"); + for (const engine of bootstrapShells) { + const name = "portable-node-tar-fallback"; + expectBatchedPowerShellCase(engine === powershell ? name : `${name}:${engine}`); + } + }, + ); + + runIfPowerShell("rejects npm success without a usable candidate package", () => { + expectBatchedPowerShellCase("npm-candidate-validation"); + }); + + runIfPowerShell("preserves the npm owner when a git replacement fails", () => { + expectBatchedPowerShellCase("method-switch-preservation"); + }); + + runIfPowerShell("restores or commits the same-prefix npm shim transaction", () => { + expectBatchedPowerShellCase("same-prefix-shim-transaction"); + }); + + runIfPowerShell("installs portable Git from multiple archive roots without collisions", () => { + if (process.platform === "win32") { + expect(bootstrapShells).toContain("powershell"); + } + expectBatchedPowerShellCase("portable-git-layout"); + for (const engine of bootstrapShells) { + if (engine !== powershell) { + expectBatchedPowerShellCase(`portable-git-layout:${engine}`); + } + } + }); + + runIfPowerShell("upgrades and validates Node installed by Windows package managers", () => { + expectBatchedPowerShellCase("winget-node-delayed-path"); + expectBatchedPowerShellCase("chocolatey-node-upgrade"); + expectBatchedPowerShellCase("scoop-node-update"); + expectBatchedPowerShellCase("package-manager-node-validation-failure"); + }); + + runIfPowerShell("recovers from package-manager failures and preserves installer refusal", () => { + if (process.platform === "win32") { + expect(bootstrapShells).toContain("powershell"); + } + for (const name of [ + "package-manager-node-command-failures", + "package-manager-node-next-manager-success", + "package-manager-node-entrypoint-refusal", + ]) { + expectBatchedPowerShellCase(name); + for (const engine of bootstrapShells) { + if (engine !== powershell) { + expectBatchedPowerShellCase(`${name}:${engine}`); + } + } + } + }); + + runIfPowerShell("publishes fresh Git clones transactionally", () => { + expectBatchedPowerShellCase("transactional-git-clone"); + }); + + runIfPowerShell("selects native ARM64 MinGit when the release publishes it", () => { + expectBatchedPowerShellCase("native-arm64-git"); + }); + + runIfPowerShell("selects native ARM64 downloads when x64 PowerShell is emulated", () => { + expectBatchedPowerShellCase("emulated-arm64-downloads"); + }); + + runConcurrentIfPowerShell( + "fails install when interactive onboarding exits non-zero", + async () => { + const tempDir = mkdtempSync(join(tmpdir(), "openclaw-install-ps1-")); + const scriptPath = join(tempDir, "install.ps1"); + try { + const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, ""); + writeFileSync( + scriptPath, + [ + scriptWithoutEntryPoint, + "", + "function Write-Banner { }", + "function Ensure-ExecutionPolicy { return $true }", + "function Check-Node { return $true }", + "function Check-ExistingOpenClaw { return $false }", + "function Get-NpmCommandPath { return 'npm.cmd' }", + "function Invoke-NpmCommand {", + " param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)", + " if ($Arguments[0] -eq 'config' -and $Arguments[2] -eq 'prefix') { Write-Output $env:USERPROFILE; $global:LASTEXITCODE = 0; return }", + " throw 'unexpected npm command'", + "}", + "function Install-OpenClaw { return $true }", + "function Ensure-OpenClawOnPath { return $true }", + "function Add-ToUserPath { param([string]$Path) }", + "function Get-OpenClawCommandPath { return 'cmd.exe' }", + "function Start-Process {", + " param([string]$FilePath, [string[]]$ArgumentList, [switch]$NoNewWindow, [switch]$Wait, [switch]$PassThru)", + " [pscustomobject]@{ ExitCode = 17 }", + "}", + "$InstallMethod = 'npm'", + "$NoOnboard = $false", + "", + ...extractEntrypointLines(source), + "", + ].join("\n"), + ); + chmodSync(scriptPath, 0o755); + + const result = await runPowerShellAsync([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); + + expect(result.status).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "openclaw onboard failed with exit code 17", + ); + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } + }, + ); + + runConcurrentIfPowerShell("exits non-zero when run as a script file", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "openclaw-install-ps1-")); + const scriptPath = join(tempDir, "install.ps1"); + try { + writeFileSync(scriptPath, createFailingNodeFixture(source)); + chmodSync(scriptPath, 0o755); + + const result = await runPowerShellAsync([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); + + expect(result.status).toBe(1); + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } + }); + + runConcurrentIfPowerShell( + "exits zero after install succeeds with deferred PATH discovery", + async () => { + const tempDir = mkdtempSync(join(tmpdir(), "openclaw-install-ps1-")); + const scriptPath = join(tempDir, "install.ps1"); + try { + writeFileSync(scriptPath, createDeferredPathSuccessFixture(source)); + chmodSync(scriptPath, 0o755); + + const result = await runPowerShellAsync([ + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ]); + + expect(result.status).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("installation failed"); + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } + }, + ); + + runIfPowerShell("throws without killing the caller when run as a scriptblock", () => { + expectBatchedPowerShellCase("scriptblock-failure"); + }); + + runIfPowerShell("accepts deferred PATH discovery when run as a scriptblock", () => { + expectBatchedPowerShellCase("scriptblock-deferred-path-success"); + }); + + runIfPowerShell("treats noisy Git install false as failure", () => { + expectBatchedPowerShellCase("noisy-git-failure"); + }); + + runIfPowerShell("preserves larger old-space NODE_OPTIONS aliases", () => { + expectBatchedPowerShellCase("node-options"); + }); + + runIfPowerShell("keeps npm chatter out of Main's success return value", () => { + expectBatchedPowerShellCase("quiet-main-success"); + }); + + runIfPowerShell("uses the terminal exit code when helper output precedes success", () => { + expectBatchedPowerShellCase("terminal-code-success"); + }); +}); + +describe("install.ps1 stale Winget repair", () => { + const { createTempDir } = createScriptTestHarness(); + const source = readFileSync(SCRIPT_PATH, "utf8"); + const powershell = findPowerShell(); + const runIfPowerShell = powershell ? it : it.skip; + const cases = [ + { + name: "repairs stale registration after a probe overwrites LASTEXITCODE", + afterInstall: "text", + afterRepair: "healthy", + repair: true, + success: true, + }, + { + name: "discovers Node after repairing a missing runtime", + afterInstall: "missing", + afterRepair: "healthy", + repair: true, + success: true, + }, + { + name: "accepts a normal successful install without repair", + installExit: 0, + afterInstall: "healthy", + success: true, + }, + { + name: "accepts a healthy no-upgrade result without repair", + afterInstall: "healthy", + success: true, + }, + { name: "does not repair generic Winget failure", installExit: 1 }, + { name: "does not repair another HRESULT", installExit: -1978335188 }, + { name: "does not repair successful install with missing Node", installExit: 0 }, + { + name: "recovers unsupported repair through Chocolatey", + repairExit: -1978335174, + repair: true, + fallback: "choco", + success: true, + }, + { + name: "recovers failed repair through Scoop", + repairExit: 1, + repair: true, + fallback: "scoop", + success: true, + }, + { + name: "recovers unusable repair through portable Node", + afterRepair: "old-sqlite", + repair: true, + fallback: "portable", + success: true, + }, + { + name: "rejects unusable Chocolatey fallback after failed repair", + repairExit: 1, + repair: true, + fallback: "choco", + afterFallback: "old-sqlite", + }, + { + name: "rejects unusable portable fallback after failed repair", + repairExit: 1, + repair: true, + fallback: "portable", + afterFallback: "text", + }, + { + name: "recovers a generic Winget failure through portable Node", + installExit: 1, + fallback: "portable", + success: true, + }, + { + name: "recovers a thrown Winget invocation through portable Node", + installThrows: true, + fallback: "portable", + success: true, + }, + { + name: "recovers a generic Winget failure through the next package manager", + installExit: 1, + fallback: "choco", + success: true, + }, + { + name: "rejects failed repair even if Node becomes healthy", + repairExit: 1, + afterRepair: "healthy", + repair: true, + }, + { name: "rejects repair that leaves Node missing", repair: true }, + { name: "rejects old Node after repair", afterRepair: "old-node", repair: true }, + { name: "rejects old SQLite after repair", afterRepair: "old-sqlite", repair: true }, + ...["text", "blob", "json", "probe-error"].map((capability) => ({ + name: `rejects broken SQLite ${capability} after repair`, + afterRepair: capability, + repair: true, + })), + ]; + + runIfPowerShell.each(cases)("$name", (testCase) => { + if (!powershell) { + throw new Error("PowerShell is not available"); + } + const fixtureNode = join(createTempDir("openclaw-winget-node-"), "node.ps1"); + writeFileSync(fixtureNode, "$input | Invoke-FixtureNode @args\n"); + const options = { + installExit: -1978335189, + repairExit: 0, + afterInstall: "missing", + afterRepair: "missing", + repair: false, + success: false, + installThrows: false, + fallback: "none", + afterFallback: "healthy", + ...testCase, + }; + const functions = [ + "Fail-Install", + "Test-BooleanSuccessResult", + "Test-NodeVersionSupported", + "Test-NodeSqliteSupported", + "Check-Node", + "Invoke-NodePackageManagerInstall", + "Install-Node", + "Main", + ] + .map((name) => `function ${name} {\n${extractFunctionBody(source, name)}}`) + .join("\n"); + const fixture = [ + "$ErrorActionPreference = 'Stop'", + `$fixtureNode = ${toPowerShellSingleQuotedLiteral(fixtureNode)}`, + functions, + `$case = ${toPowerShellSingleQuotedLiteral(JSON.stringify(options))} | ConvertFrom-Json`, + String.raw` +function Reset-Fixture { + $global:State = 'missing' + $global:PendingState = 'missing' + $global:Events = New-Object 'System.Collections.Generic.List[string]' + $global:WingetCalls = New-Object 'System.Collections.Generic.List[object]' + $global:Fallbacks = New-Object 'System.Collections.Generic.List[string]' + $global:Messages = New-Object 'System.Collections.Generic.List[string]' + $global:InstallExitCode = 0 + $global:Advanced = 0 + $global:ProbeCount = 0 + $global:LASTEXITCODE = 0 +} +function Get-Command { + [CmdletBinding()] + param([string]$Name, [string]$CommandType) + if ($Name -eq 'winget') { return $true } + if ($Name -eq 'choco') { return ($case.fallback -eq 'choco') } + if ($Name -eq 'scoop') { return ($case.fallback -eq 'scoop') } + if ($Name -eq 'node') { + $global:Events.Add("check:$global:State") + if ($global:State -eq 'missing') { throw 'fixture Node is missing' } + return [pscustomobject]@{ Source = $fixtureNode } + } + throw "unexpected command lookup: $Name" +} +function Invoke-FixtureNode { + $global:LASTEXITCODE = 0 + if ($args[0] -eq '-v') { + if ($global:State -eq 'old-node') { return 'v22.15.0' } + return 'v26.1.0' + } + $probe = @($input) -join [Environment]::NewLine + if (-not $probe.Contains('CREATE TABLE probe') -or -not $probe.Contains('a\u0000b\u0000')) { + throw 'current SQLite capability probe was not executed' + } + $global:ProbeCount++ + if ($global:State -eq 'probe-error') { $global:LASTEXITCODE = 1; return } + $version = if ($global:State -eq 'old-sqlite') { '3.50.6' } else { '3.51.3' } + return (@{ available = $true; version = $version; text = ($global:State -ne 'text'); blob = ($global:State -ne 'blob'); json = ($global:State -ne 'json') } | ConvertTo-Json -Compress) +} +function winget { + $global:Events.Add($args[0]) + $global:WingetCalls.Add(@($args)) + if ($args[0] -eq 'install') { + if ($case.installThrows) { throw 'fixture Winget invocation failed' } + $global:LASTEXITCODE = $case.installExit + $global:PendingState = $case.afterInstall + } elseif ($args[0] -eq 'repair') { + $global:LASTEXITCODE = $case.repairExit + $global:PendingState = $case.afterRepair + } else { throw "unexpected Winget command: $args" } + Write-Output 'native command output must not become a Boolean result' +} +function Refresh-ProcessPath { $global:Events.Add('refresh') } +function Add-InstalledNodeToProcessPath { + $global:Events.Add('discover') + $global:State = $global:PendingState + return $true +} +function choco { + $global:Fallbacks.Add('choco') + $global:State = $case.afterFallback + $global:LASTEXITCODE = 0 + Write-Output 'Chocolatey output must not become a Boolean result' +} +function scoop { + $global:Fallbacks.Add("scoop:$($args -join ' ')") + $global:State = $case.afterFallback + $global:LASTEXITCODE = 0 + Write-Output 'Scoop output must not become a Boolean result' +} +function Write-Host { $global:Messages.Add(($args -join ' ')) } +function Install-PortableNode { + $global:Fallbacks.Add('portable') + if ($case.fallback -eq 'portable') { $global:State = $case.afterFallback; return } + throw 'fixture portable recovery unavailable' +} +function Check-ExistingOpenClaw { return $false } +function Test-PreviousGitWrapper { return $false } +function Get-NpmCommandPath { return 'fixture-npm' } +function Get-WindowsCommandSafeDirectory { return $env:USERPROFILE } +function Invoke-NpmCommand { return $env:USERPROFILE } +function Install-OpenClaw { $global:Advanced++; return $true } +function Ensure-OpenClawOnPath { return $false } +function Refresh-GatewayServiceIfLoaded { throw 'unexpected service mutation' } +$env:USERPROFILE = [System.IO.Path]::GetTempPath() +$InstallMethod = 'npm' +Reset-Fixture +$result = @(Install-Node) +if ($result.Count -ne 1 -or $result[0] -isnot [bool]) { throw "Install-Node output leaked: $result" } +$direct = @{ success = $result[0]; events = $global:Events.ToArray(); calls = $global:WingetCalls.ToArray(); probes = $global:ProbeCount; fallbacks = $global:Fallbacks.ToArray(); messages = $global:Messages.ToArray() } +Reset-Fixture +$null = Main +$main = @{ advanced = $global:Advanced; exit = $global:InstallExitCode; events = $global:Events.ToArray(); calls = $global:WingetCalls.ToArray(); probes = $global:ProbeCount; fallbacks = $global:Fallbacks.ToArray(); messages = $global:Messages.ToArray() } +Reset-Fixture +$global:State = 'healthy' +$null = Main +$healthy = @{ advanced = $global:Advanced; calls = $global:WingetCalls.Count } +Write-Output ('RESULT:' + (@{ direct = $direct; main = $main; healthy = $healthy } | ConvertTo-Json -Depth 8 -Compress)) +`, + ].join("\n"); + const result = spawnSync( + powershell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", fixture], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr || result.stdout).toBe(0); + const line = result.stdout.split(/\r?\n/u).find((value) => value.startsWith("RESULT:")); + expect(line, result.stdout).toBeDefined(); + const proof = JSON.parse(line!.slice("RESULT:".length)); + expect(proof.direct.success).toBe(options.success); + expect(proof.main.advanced).toBe(options.success ? 1 : 0); + expect(proof.main.exit).toBe(options.success ? 0 : 1); + expect(proof.healthy).toEqual({ advanced: 1, calls: 0 }); + const installArgs = [ + "install", + "OpenJS.NodeJS.LTS", + "--source", + "winget", + "--accept-package-agreements", + "--accept-source-agreements", + ]; + const repairArgs = [ + "repair", + "--id", + "OpenJS.NodeJS.LTS", + "--exact", + "--source", + "winget", + "--accept-package-agreements", + "--accept-source-agreements", + ]; + for (const run of [proof.direct, proof.main]) { + expect(run.calls).toEqual(options.repair ? [installArgs, repairArgs] : [installArgs]); + const repaired = + options.repair && options.repairExit === 0 && options.afterRepair === "healthy"; + const fallbackExpected = + !repaired && (options.installThrows || options.afterInstall !== "healthy"); + const fallbacks: string[] = []; + if (fallbackExpected) { + if (options.fallback === "choco") { + fallbacks.push("choco"); + } else if (options.fallback === "scoop") { + fallbacks.push("scoop:update", "scoop:install nodejs-lts", "scoop:update nodejs-lts"); + } + if (!["choco", "scoop"].includes(options.fallback) || options.afterFallback !== "healthy") { + fallbacks.push("portable"); + } + } + expect(run.fallbacks).toEqual(fallbacks); + expect( + run.messages.filter((message: string) => message.includes("Node.js repaired via winget")), + ).toHaveLength(repaired ? 1 : 0); + const events = run === proof.main ? run.events.slice(1) : run.events; + expect(events.slice(0, 4)).toEqual([ + "install", + "refresh", + "discover", + `check:${options.afterInstall}`, + ]); + if (options.repair) { + expect(events.slice(4, 7)).toEqual(["repair", "refresh", "discover"]); + } + if (options.success) { + expect(events.at(-1)).toBe("check:healthy"); + expect(run.probes).toBeGreaterThan(0); + } + } + }); +}); diff --git a/test/scripts/install-ps1.test-support.ts b/test/scripts/install-ps1.test-support.ts new file mode 100644 index 000000000000..f10266952f2b --- /dev/null +++ b/test/scripts/install-ps1.test-support.ts @@ -0,0 +1,25 @@ +export function extractFunctionBody(source: string, name: string): string { + const lines = source.split(/\r?\n/u); + const start = lines.indexOf(`function ${name} {`); + if (start < 0) { + throw new Error(`Missing PowerShell function body ${name}`); + } + const body: string[] = []; + let hereStringEnd: string | undefined; + for (const line of lines.slice(start + 1)) { + if (hereStringEnd) { + if (line.startsWith(hereStringEnd)) { + hereStringEnd = undefined; + } + } else if (line === "}") { + return `${body.join("\n")}\n`; + } else { + const hereStringStart = /(?:^|[\s=])@(['"])\s*$/u.exec(line); + if (hereStringStart) { + hereStringEnd = `${hereStringStart[1]}@`; + } + } + body.push(line); + } + throw new Error(`Missing PowerShell function body ${name}`); +} diff --git a/test/scripts/install-ps1.test.ts b/test/scripts/install-ps1.test.ts index d8bc42fff6c6..d57ad1e983c7 100644 --- a/test/scripts/install-ps1.test.ts +++ b/test/scripts/install-ps1.test.ts @@ -1,1688 +1,11 @@ -// Install Ps1 tests cover install ps1 script behavior. -import { spawn, spawnSync } from "node:child_process"; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import http from "node:http"; -import { tmpdir } from "node:os"; -import { join, parse } from "node:path"; -import { beforeAll, describe, expect, it } from "vitest"; -import { isSupportedOpenClawNodeVersion } from "../../node-version.mjs"; -import { NODE_RELEASE_VERSION_CASES } from "../helpers/node-version-cases.js"; -import { createScriptTestHarness } from "./test-helpers.js"; +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { extractFunctionBody } from "./install-ps1.test-support.js"; const SCRIPT_PATH = "scripts/install.ps1"; -const ENTRYPOINT_RE = /\r?\n\$null = Main\r?\nComplete-Install\s*$/m; -function extractEntrypointLines(source: string): string[] { - const match = source.match(ENTRYPOINT_RE); - if (!match) { - throw new Error("Missing PowerShell installer entrypoint"); - } - return match[0].trim().split(/\r?\n/); -} - -function extractFunctionBody(source: string, name: string): string { - const lines = source.split(/\r?\n/u); - const start = lines.indexOf(`function ${name} {`); - if (start < 0) { - throw new Error(`Missing PowerShell function body ${name}`); - } - const body: string[] = []; - let hereStringEnd: string | undefined; - for (const line of lines.slice(start + 1)) { - if (hereStringEnd) { - if (line.startsWith(hereStringEnd)) { - hereStringEnd = undefined; - } - } else if (line === "}") { - return `${body.join("\n")}\n`; - } else { - const hereStringStart = /(?:^|[\s=])@(['"])\s*$/u.exec(line); - if (hereStringStart) { - hereStringEnd = `${hereStringStart[1]}@`; - } - } - body.push(line); - } - throw new Error(`Missing PowerShell function body ${name}`); -} - -function findPowerShell(candidates = ["pwsh", "powershell"]): string | undefined { - for (const candidate of candidates) { - const result = spawnSync( - candidate, - ["-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion"], - { - encoding: "utf8", - }, - ); - if (result.status === 0) { - return candidate; - } - } - return undefined; -} - -function toPowerShellSingleQuotedLiteral(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -function createFailingNodeFixture(source: string): string { - const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, ""); - const entrypointLines = extractEntrypointLines(source); - expect(scriptWithoutEntryPoint).not.toBe(source); - - return [ - scriptWithoutEntryPoint, - "", - "function Write-Banner { }", - "function Ensure-ExecutionPolicy { return $true }", - "function Check-Node { return $false }", - "function Install-Node { return $false }", - "", - ...entrypointLines, - "", - ].join("\n"); -} - -function createDeferredPathSuccessFixture(source: string): string { - const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, ""); - const entrypointLines = extractEntrypointLines(source); - expect(scriptWithoutEntryPoint).not.toBe(source); - - return [ - scriptWithoutEntryPoint, - "", - "function Write-Banner { }", - "function Ensure-ExecutionPolicy { return $true }", - "function Check-Node { return $true }", - "function Check-ExistingOpenClaw { return $false }", - "function Add-ToPath { param([string]$Path) }", - "function Install-OpenClaw { return $true }", - "function Ensure-OpenClawOnPath { return $false }", - "$NoOnboard = $true", - "", - ...entrypointLines, - "", - ].join("\n"); -} - -describe("install.ps1 failure handling", () => { - const harness = createScriptTestHarness(); +describe("install.ps1 source contracts", () => { const source = readFileSync(SCRIPT_PATH, "utf8"); - const powershell = findPowerShell(); - const bootstrapShells = - process.platform === "win32" - ? ["powershell", "pwsh"].filter((candidate) => findPowerShell([candidate])) - : []; - const runIfPowerShell = powershell ? it : it.skip; - const runConcurrentIfPowerShell = powershell ? it.concurrent : it.skip; - const runPowerShell = (args: string[]) => { - if (!powershell) { - throw new Error("PowerShell is not available"); - } - return spawnSync(powershell, args, { encoding: "utf8" }); - }; - const runInstallerFile = (args: string[], env: NodeJS.ProcessEnv = {}) => { - if (!powershell) { - throw new Error("PowerShell is not available"); - } - return spawnSync( - powershell, - ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", SCRIPT_PATH, ...args], - { - encoding: "utf8", - env: { ...process.env, ...env }, - }, - ); - }; - const runPowerShellAsync = (args: string[]) => { - if (!powershell) { - throw new Error("PowerShell is not available"); - } - return new Promise<{ status: number | null; stderr: string; stdout: string }>( - (resolve, reject) => { - const child = spawn(powershell, args, { stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - child.once("error", reject); - child.once("close", (status) => resolve({ status, stderr, stdout })); - }, - ); - }; - const batchedPowerShellResults = new Map(); - - beforeAll(() => { - if (!powershell) { - return; - } - const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, ""); - const entrypointLines = extractEntrypointLines(source); - const cases = [ - { - name: "private-node-update", - source: [ - scriptWithoutEntryPoint, - String.raw` -$root = Join-Path $script:InstallerTempDirectory ('openclaw-private-node-test-' + [guid]::NewGuid().ToString('N')) -$NodeOnly = $true -$NodePrefix = Join-Path $root 'private tools/node' -$originalTemp = $script:InstallerTempDirectory -$beforePath = $env:PATH -$beforeUserPath = [Environment]::GetEnvironmentVariable('Path', 'User') -$beforeMachinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') -$script:InstallerTempDirectory = Join-Path $root 'temp' -$script:Scenario = '' -$script:Extractions = 0 -function Check-ExistingOpenClaw { throw 'unexpected OpenClaw lookup' } -function Install-Node { throw 'unexpected package-manager install' } -function Install-OpenClaw { throw 'unexpected OpenClaw install' } -function Ensure-OpenClawOnPath { throw 'unexpected OpenClaw PATH update' } -function Add-ToProcessPath { throw 'unexpected process PATH update' } -function Add-ToUserPath { throw 'unexpected user PATH update' } -function Refresh-GatewayServiceIfLoaded { throw 'unexpected Gateway update' } -function Invoke-NpmCommand { throw 'unexpected npm invocation' } -function Invoke-RestMethod { - param([string]$Uri, [int]$TimeoutSec) - if ($Uri -ne 'https://nodejs.org/dist/index.json') { throw "unexpected metadata URL: $Uri" } - return @([pscustomobject]@{ version = 'v26.1.0'; files = @('win-x64-zip', 'win-arm64-zip') }) -} -function Save-InstallerDownload { - param([string]$Uri, [string]$OutFile) - if ($script:Scenario -eq 'download') { throw 'fixture download failure' } - if ($Uri -eq 'https://nodejs.org/dist/v26.1.0/SHASUMS256.txt') { - $archive = Get-ChildItem -LiteralPath (Split-Path -Parent $OutFile) -Filter '*.zip' | Select-Object -First 1 - $hash = (Get-FileHash -LiteralPath $archive.FullName -Algorithm SHA256).Hash - if ($script:Scenario -eq 'checksum') { $hash = '0' * 64 } - $name = if ($script:Scenario -eq 'missing-checksum') { 'another-node.zip' } else { $archive.Name } - [IO.File]::WriteAllText($OutFile, "$hash $name") - return - } - if ($Uri -notmatch '^https://nodejs\.org/dist/v26\.1\.0/node-v26\.1\.0-win-(x64|arm64)\.zip$') { throw "unexpected archive URL: $Uri" } - [IO.File]::WriteAllText($OutFile, 'downloaded archive bytes') -} -function Expand-PortableNodeArchive { - param([string]$ZipPath, [string]$DestinationPath) - $script:Extractions++ - New-Item -ItemType Directory -Path $DestinationPath | Out-Null - [IO.File]::WriteAllText((Join-Path $DestinationPath 'node.exe'), 'new node') - [IO.File]::WriteAllText((Join-Path $DestinationPath 'npm.cmd'), 'matching npm') - [IO.File]::WriteAllText((Join-Path $DestinationPath 'npx.cmd'), 'matching npx') - if ($script:Scenario -eq 'archive') { throw 'fixture extraction failure' } -} -function Check-Node { - param([string]$NodePath) - if (-not $NodePath -or -not (Test-Path -LiteralPath $NodePath -PathType Leaf)) { throw 'runtime was not checked by its explicit path' } - if ([IO.File]::ReadAllText($NodePath) -ne 'new node') { throw 'the downloaded runtime was not checked' } - return ($script:Scenario -ne 'runtime') -} -try { - New-Item -ItemType Directory -Force -Path $script:InstallerTempDirectory, $NodePrefix | Out-Null - foreach ($scenario in @('download', 'checksum', 'missing-checksum', 'archive', 'runtime', 'success', 'fresh')) { - $script:Scenario = $scenario - $script:Extractions = 0 - $script:InstallExitCode = 0 - [IO.File]::WriteAllText((Join-Path $NodePrefix 'node.exe'), 'previous node') - if ($scenario -eq 'fresh') { Remove-Item -LiteralPath $NodePrefix -Recurse -Force } - $output = @(Main *>&1 | ForEach-Object { $_.ToString() }) - $success = $scenario -in @('success', 'fresh') - if (($script:InstallExitCode -eq 0) -ne $success) { throw "incorrect result for $($scenario): $output" } - $expectedExtractions = if ($scenario -in @('download', 'checksum', 'missing-checksum')) { 0 } else { 1 } - if ($script:Extractions -ne $expectedExtractions) { throw "extraction boundary violated for $scenario" } - $expectedNode = if ($success) { 'new node' } else { 'previous node' } - if ([IO.File]::ReadAllText((Join-Path $NodePrefix 'node.exe')) -ne $expectedNode) { throw "previous runtime not preserved for $scenario" } - if ($success) { - if ([IO.File]::ReadAllText((Join-Path $NodePrefix 'npm.cmd')) -ne 'matching npm') { throw 'matching npm missing' } - if ([IO.File]::ReadAllText((Join-Path $NodePrefix 'npx.cmd')) -ne 'matching npx') { throw 'matching npx missing' } - } - if (@(Get-ChildItem -LiteralPath $script:InstallerTempDirectory -Force).Count -ne 0) { throw 'download temporary files remain' } - if (@(Get-ChildItem -LiteralPath (Split-Path -Parent $NodePrefix) -Force).Count -ne 1) { throw 'publication temporary directories remain' } - if ($env:PATH -cne $beforePath) { throw 'process PATH changed' } - if ([Environment]::GetEnvironmentVariable('Path', 'User') -cne $beforeUserPath) { throw 'user PATH changed' } - if ([Environment]::GetEnvironmentVariable('Path', 'Machine') -cne $beforeMachinePath) { throw 'machine PATH changed' } - } -} finally { - $script:InstallerTempDirectory = $originalTemp - Remove-Item -LiteralPath $root -Recurse -Force -} -`, - ].join("\n"), - }, - { - name: "native-npm-stderr", - source: [ - scriptWithoutEntryPoint, - `$node = ${toPowerShellSingleQuotedLiteral(process.execPath)}`, - String.raw` -$ErrorActionPreference = 'Stop' -$beforeLocation = (Get-Location).Path -$root = Join-Path ([IO.Path]::GetTempPath()) ('openclaw-native-stderr-' + [Guid]::NewGuid().ToString('N')) -[void](New-Item -ItemType Directory -Path $root) -$child = Join-Path $root 'child.cjs' -[IO.File]::WriteAllText($child, 'if (process.argv[2] === "marker") { require("node:fs").writeFileSync(process.argv[3], "spawned"); process.exit(0); } if (process.argv[2] === "warning") process.stderr.write("npm warn proof\n"); process.stdout.write("native-complete\n"); process.exit(Number(process.argv[3]));') -try { - foreach ($wrapper in @('Invoke-NpmCommand', 'Invoke-CommandFromWindowsSafeDirectory')) { - foreach ($stream in @('warning', 'quiet')) { - foreach ($code in @(0, 17)) { - $output = @(& $wrapper -CommandPath $node -Arguments @($child, $stream, [string]$code) -WorkingDirectory $root 2>&1) - if ($LASTEXITCODE -ne $code) { throw "$wrapper changed native exit $code" } - $text = ($output | ForEach-Object { $_.ToString() }) -join " " - if (-not $text.Contains('native-complete')) { throw "$wrapper lost stdout" } - if ($text.Contains('npm warn proof') -ne ($stream -eq 'warning')) { throw "$wrapper changed stderr" } - if ($ErrorActionPreference -ne 'Stop' -or (Get-Location).Path -ne $beforeLocation) { throw "$wrapper leaked caller state" } - } - } - } - $marker = Join-Path $root 'unexpected-spawn' - foreach ($entry in @( - @{command=$node; directory=(Join-Path $root 'missing')}, - @{command=(Join-Path $root 'missing.exe'); directory=$root} - )) { - $caught = $false - try { Invoke-NpmCommand -CommandPath $entry.command -Arguments @($child, 'marker', $marker) -WorkingDirectory $entry.directory 2>&1 | Out-Null } catch { $caught = $true } - if (-not $caught -or (Test-Path -LiteralPath $marker)) { throw 'PowerShell setup failure did not stop the child' } - if ($ErrorActionPreference -ne 'Stop' -or (Get-Location).Path -ne $beforeLocation) { throw 'PowerShell failure leaked caller state' } - } -} finally { Remove-Item -LiteralPath $root -Recurse -Force } -`, - ].join("\n"), - }, - { - name: "openclaw-native-command-exit", - source: [ - scriptWithoutEntryPoint, - "", - "function Get-OpenClawCommandPath { return (Get-Process -Id $PID).Path }", - "$caught = $false", - "try {", - " Invoke-OpenClawCommand -NoLogo -NoProfile -Command 'exit 17'", - "} catch {", - " if ($_.Exception.Message -notmatch 'failed with exit code 17') { throw }", - " $caught = $true", - "}", - "if (-not $caught) { throw 'nonzero native exit was accepted' }", - "", - ].join("\n"), - }, - { - name: "doctor-failure-output", - source: [ - scriptWithoutEntryPoint, - "", - "function Invoke-OpenClawCommand { throw 'doctor failed' }", - "$output = @(Run-Doctor *>&1 | ForEach-Object { $_.ToString() })", - '$text = $output -join "`n"', - "if ($text -match 'Migration complete') { throw 'doctor failure reported success' }", - "if ($text -notmatch 'Migration failed') { throw \"missing error: $text\" }", - "if ($output[-1] -ne $false) { throw 'doctor failure did not propagate' }", - "", - ].join("\n"), - }, - { - name: "npm-lifecycle-policy", - source: [ - scriptWithoutEntryPoint, - "", - "$script:NpmVersion = ''", - "function Invoke-NpmCommand {", - " param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)", - " if ($Arguments[0] -eq '--version') { Write-Output $script:NpmVersion; $global:LASTEXITCODE = 0; return }", - " throw 'unexpected npm mutation'", - "}", - "$cases = @{ '11.15.0' = $null; '11.16.0' = '--allow-scripts=openclaw'; '12.0.0' = '--allow-scripts=openclaw' }", - "foreach ($entry in $cases.GetEnumerator()) {", - " $script:NpmVersion = $entry.Key", - " $actual = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'openclaw@latest'", - ' if ($actual -ne $entry.Value) { throw "version=$($entry.Key) actual=$actual" }', - "}", - "$script:NpmVersion = '12.0.0'", - "$tool = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'pnpm@12.0.0' -ExactIdentity 'pnpm@12.0.0'", - 'if ($tool -ne "--allow-scripts=pnpm@12.0.0") { throw "tool=$tool" }', - "$alias = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'openclaw@npm:@scope/candidate@1.0.0'", - "if ($alias -ne '--allow-scripts=@scope/candidate') { throw \"alias=$alias\" }", - "$archiveAlias = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'openclaw@npm:@scope/candidate.tgz@1.0.0'", - "if ($archiveAlias -ne '--allow-scripts=@scope/candidate.tgz') { throw \"alias=$archiveAlias\" }", - "$tarball = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'https://example.invalid/openclaw.tgz'", - "if ($tarball -ne '--allow-scripts=https://example.invalid/openclaw.tgz') { throw \"tarball=$tarball\" }", - '$archiveRoot = Join-Path ([System.IO.Path]::GetTempPath()) "openclaw-archive-identity"', - '$safeCwd = Join-Path $archiveRoot "work"', - '$candidate = Join-Path $archiveRoot "candidate.tgz"', - '$archiveUrl = "file:///" + $candidate.Replace("\\", "/").TrimStart("/")', - 'foreach ($spec in @($candidate, "../candidate.tgz", "file:$candidate", "file:../candidate.tgz", "file:/../candidate.tgz", "file:///../candidate.tgz", $archiveUrl)) {', - ' $protocol = if ($spec.StartsWith("file:")) { "file:" } else { "" }', - " $actual = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec $spec -NpmCwd $safeCwd", - ' if ($actual -ne "--allow-scripts=$protocol$candidate") { throw "archive=$actual" }', - "}", - '$commaRoot = Join-Path ([System.IO.Path]::GetTempPath()) "openclaw,identity"', - "$caught = $false", - "try { Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec (Join-Path $commaRoot 'candidate.tgz') -NpmCwd $commaRoot } catch {", - " if ($_.Exception.Message -notmatch 'without commas') { throw }", - " $caught = $true", - "}", - "if (-not $caught) { throw 'comma archive policy was accepted' }", - "$script:NpmVersion = '11.16.0'", - "$legacy = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec (Join-Path $commaRoot 'candidate.tgz') -NpmCwd $commaRoot", - "if ($legacy -notmatch '^--allow-scripts=\\.[\\\\/]candidate\\.tgz$') { throw \"legacy=$legacy\" }", - "$script:NpmVersion = '12.0.0'", - '$safeCwd = Join-Path $commaRoot "safe"', - '$candidate = Join-Path $commaRoot "candidate"', - "$relative = Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec $candidate -NpmCwd $safeCwd", - "if ($relative -match ',' -or $relative -notmatch '^--allow-scripts=\\.\\.[\\\\/]candidate$') { throw \"relative=$relative\" }", - "foreach ($invalidVersion in @('invalid', 'npm 12.0.0 warning')) {", - " $script:NpmVersion = $invalidVersion", - " $caught = $false", - " try { Get-NpmLifecycleAllowArgument -NpmCommand 'npm.cmd' -InstallSpec 'openclaw@latest' } catch { $caught = $true }", - ' if (-not $caught) { throw "invalid npm version was accepted: $invalidVersion" }', - "}", - "", - ].join("\n"), - }, - { - name: "pnpm-prefer-offline-policy", - source: [ - scriptWithoutEntryPoint, - "", - '$root = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-pnpm-policy-" + [guid]::NewGuid().ToString("N"))', - '$project = Join-Path $root "project"', - "$previousUpper = $env:PNPM_CONFIG_PREFER_OFFLINE", - "$previousLower = $env:pnpm_config_prefer_offline", - "$script:PnpmConfigValue = 'undefined'", - "function Get-TestPnpmConfig {", - " param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments)", - " if ($Arguments -join ' ' -ne 'config get prefer-offline') { throw \"unexpected pnpm command: $($Arguments -join ' ')\" }", - ' if ((Get-Location).Path -ne $project) { throw "unexpected pnpm cwd: $(Get-Location)" }', - " if ($script:PnpmConfigValue -eq 'failure') { $global:LASTEXITCODE = 1; return }", - " $global:LASTEXITCODE = 0", - " return $script:PnpmConfigValue", - "}", - "try {", - " New-Item -ItemType Directory -Force -Path $project | Out-Null", - " Remove-Item Env:PNPM_CONFIG_PREFER_OFFLINE -ErrorAction SilentlyContinue", - " Remove-Item Env:pnpm_config_prefer_offline -ErrorAction SilentlyContinue", - " if (-not (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig')) { throw 'default was disabled' }", - " $script:PnpmConfigValue = 'false'", - " if (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig') { throw 'false pnpm config was ignored' }", - " $script:PnpmConfigValue = 'true'", - " if (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig') { throw 'true pnpm config was ignored' }", - " $script:PnpmConfigValue = 'failure'", - " if (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig') { throw 'failed pnpm config query enabled the default' }", - " $env:PNPM_CONFIG_PREFER_OFFLINE = 'false'", - " if (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig') { throw 'uppercase override was ignored' }", - " Remove-Item Env:PNPM_CONFIG_PREFER_OFFLINE -ErrorAction SilentlyContinue", - " $env:pnpm_config_prefer_offline = 'false'", - " if (Test-ShouldPreferOfflinePnpmInstall -ProjectDir $project -PnpmCommand 'Get-TestPnpmConfig') { throw 'lowercase override was ignored' }", - "} finally {", - " $env:PNPM_CONFIG_PREFER_OFFLINE = $previousUpper", - " $env:pnpm_config_prefer_offline = $previousLower", - " Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue", - "}", - "", - ].join("\n"), - }, - { - name: "npm-candidate-validation", - source: [ - scriptWithoutEntryPoint, - "", - '$root = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-missing-candidate-" + [guid]::NewGuid().ToString("N"))', - "New-Item -ItemType Directory -Path $root | Out-Null", - "function Check-ExistingOpenClaw { return $true }", - "function Check-Node { return $true }", - "function Ensure-Git { return $true }", - "function Test-PreviousGitWrapper { return $false }", - "function Get-NpmCommandPath { return 'npm.cmd' }", - "function Get-WindowsCommandSafeDirectory { return $root }", - "function Resolve-NpmOpenClawInstallSpec { return 'openclaw@latest' }", - "function Test-NpmConfigRawKey { return $true }", - "function Get-NpmDebugLogRootCandidates { return @() }", - "function Invoke-NpmCommand {", - " param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)", - " $global:LASTEXITCODE = 0", - " if ($Arguments[0] -eq '--version') { return '12.0.0' }", - " if ($Arguments[0] -eq 'root') { return $root }", - " if ($Arguments[0] -eq 'config') { return $root }", - " if ($Arguments[0] -eq 'install') { return }", - " throw \"unexpected npm command: $($Arguments -join ' ')\"", - "}", - "function Ensure-OpenClawOnPath { throw 'old PATH command was accepted after missing candidate' }", - "$InstallMethod = 'npm'", - "$NoOnboard = $true", - "$Tag = 'latest'", - "try {", - " $null = Main", - ' if ($script:InstallExitCode -ne 1) { throw "InstallExitCode=$script:InstallExitCode" }', - "} finally {", - " Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue", - "}", - "", - ].join("\n"), - }, - { - name: "method-switch-preservation", - source: [ - scriptWithoutEntryPoint, - "", - "$script:OldOwnerRemoved = $false", - "function Check-ExistingOpenClaw { return $true }", - "function Check-Node { return $true }", - "function Get-NpmCommandPath { return 'npm.cmd' }", - "function Invoke-NpmCommand {", - " param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)", - " if ($Arguments[0] -eq 'list') { $global:LASTEXITCODE = 0; return }", - " if ($Arguments[0] -eq 'uninstall') { $script:OldOwnerRemoved = $true; $global:LASTEXITCODE = 0; return }", - " throw 'unexpected npm command'", - "}", - "function Install-OpenClawFromGit { return $false }", - "$InstallMethod = 'git'", - "$NoOnboard = $true", - "$null = Main", - "if ($script:OldOwnerRemoved) { throw 'failed candidate retired the working npm owner' }", - "", - ].join("\n"), - }, - { - name: "node-versions", - source: [ - scriptWithoutEntryPoint, - "", - "$cases = @{", - ...NODE_RELEASE_VERSION_CASES.map( - (version) => - ` ${toPowerShellSingleQuotedLiteral(version)} = $${isSupportedOpenClawNodeVersion(version)}`, - ), - "}", - "foreach ($entry in $cases.GetEnumerator()) {", - " $actual = Test-NodeVersionSupported -Version $entry.Key", - ' if ($actual -ne $entry.Value) { throw "Version=$($entry.Key) Actual=$actual" }', - "}", - "", - ].join("\n"), - }, - { - name: "node-capabilities", - source: [ - scriptWithoutEntryPoint, - "function Get-Command { [pscustomobject]@{ Source = 'Invoke-FixtureNode' } }", - "function Invoke-FixtureNode {", - " $global:LASTEXITCODE = 0", - " if ($args[0] -eq '-v') { return $script:FixtureVersion }", - " $input | Out-Null", - " return $script:FixtureSqlite", - "}", - "foreach ($case in @(", - " @{ version = 'v24.19.0'; text = $true; expected = $true },", - " @{ version = 'v24.19.0'; text = $false; expected = $false },", - " @{ version = 'v24.15.0+vendor.1'; text = $true; expected = $false },", - " @{ version = 'v26.0.0+vendor.1'; text = $true; expected = $false },", - " @{ version = 'v24.15.0'; text = $false; expected = $false },", - " @{ version = 'v22.23.2'; text = $true; expected = $false }", - ")) {", - " $script:FixtureVersion = $case.version", - " $script:FixtureSqlite = @{ available = $true; version = '3.51.3'; text = $case.text; blob = $true; json = $true } | ConvertTo-Json -Compress", - " $actual = Check-Node", - ' if ($actual -ne $case.expected) { throw "Version=$($case.version) Text=$($case.text) Actual=$actual" }', - "}", - ].join("\n"), - }, - { - name: "same-prefix-shim-transaction", - source: [ - scriptWithoutEntryPoint, - "", - '$root = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-shim-transaction-" + [guid]::NewGuid().ToString("N"))', - '$target = Join-Path $root "openclaw.cmd"', - "try {", - " New-Item -ItemType Directory -Force -Path $root | Out-Null", - ' $old = "@echo off`r`nnode `"C:\\old\\dist\\entry.js`" %*`r`n"', - ' $launcher = Join-Path $root "node_modules\\openclaw\\openclaw.mjs"', - ' $candidate = "@ECHO off`r`nGOTO start`r`n:find_dp0`r`nSET dp0=%~dp0`r`nEXIT /b`r`n:start`r`nSETLOCAL`r`nCALL :find_dp0`r`nnode `"%dp0%\\node_modules\\openclaw\\openclaw.mjs`" %*`r`n"', - " [System.IO.File]::WriteAllText($target, $old)", - " $backup = Start-NpmShimBackup -Path $target -ExpectedLauncher $launcher", - " [System.IO.File]::WriteAllText($target, $candidate)", - " Restore-NpmShimBackup -Backup $backup", - " if ([System.IO.File]::ReadAllText($target) -ne $old) { throw 'failure did not restore old wrapper' }", - " $backup = Start-NpmShimBackup -Path $target -ExpectedLauncher $launcher", - " [System.IO.File]::WriteAllText($target, $candidate)", - " Complete-NpmShimBackup -Backup $backup", - " if ([System.IO.File]::ReadAllText($target) -ne $candidate) { throw 'success did not retain npm shim' }", - " if (Test-Path -LiteralPath $backup.BackupPath) { throw 'committed backup remains' }", - " [System.IO.File]::WriteAllText($target, $old)", - " $backup = Start-NpmShimBackup -Path $target -ExpectedLauncher $launcher", - ' [System.IO.File]::WriteAllText($target, "@echo off`r`necho unrelated`r`n")', - " $refused = $false", - " try { Restore-NpmShimBackup -Backup $backup } catch { $refused = $true }", - " if (-not $refused) { throw 'unrelated replacement was deleted' }", - " if ([System.IO.File]::ReadAllText($target) -notmatch 'unrelated') { throw 'unrelated replacement changed' }", - "} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }", - "", - ].join("\n"), - }, - { - name: "canonical-temp-root", - source: [ - scriptWithoutEntryPoint, - "", - "$originalTemp = $env:TEMP", - "$originalTmp = $env:TMP", - '$sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-install-temp-test-" + [guid]::NewGuid().ToString("N"))', - '$longTemp = Join-Path $sandbox "Long Temp"', - "try {", - " New-Item -ItemType Directory -Force -Path $longTemp | Out-Null", - " $env:TEMP = $longTemp", - " $env:TMP = $longTemp", - " $resolved = Resolve-InstallerTempDirectory", - " $expected = (Get-Item -LiteralPath $longTemp -ErrorAction Stop).FullName", - ' if ($resolved -ne $expected) { throw "default=$resolved expected=$expected" }', - " $env:TEMP = '\\\\?\\' + $longTemp", - " $env:TMP = $env:TEMP", - ' $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -ne $longTemp) { throw "prefix not stripped: $candidate" }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }', - ' if ($resolved -ne $expected) { throw "extended=$resolved expected=$expected" }', - " # Windows PowerShell 5.1 proof: FSO Folder.Path echoes 8.3; Get-Item.FullName expands it.", - " $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp'", - " $env:TMP = $longTemp", - " $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -match '~') { return $longTemp }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }", - ' if ($resolved -ne $longTemp) { throw "short=$resolved" }', - " $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Missing'", - " $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -match '~') { throw 'unresolvable short alias' }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }", - ' if ($resolved -ne $longTemp) { throw "fallback=$resolved" }', - " $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp'", - " $resolved = Resolve-InstallerTempDirectory -LongPathResolver { param($candidate) return $candidate }", - ' if ($resolved -ne $longTemp) { throw "unchanged-short=$resolved" }', - " $env:TEMP = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp'", - " Initialize-InstallerTempDirectory -LongPathResolver { param($candidate) if ($candidate -match '~') { return $longTemp }; return (Get-Item -LiteralPath $candidate -ErrorAction Stop).FullName }", - ' if ($script:InstallerTempDirectory -ne $longTemp) { throw "canonical=$script:InstallerTempDirectory" }', - ' if ($env:TEMP -ne $longTemp) { throw "TEMP=$env:TEMP" }', - ' if ($env:TMP -ne $longTemp) { throw "TMP=$env:TMP" }', - "} finally {", - " $env:TEMP = $originalTemp", - " $env:TMP = $originalTmp", - " if (Test-Path -LiteralPath $sandbox) { Remove-Item -LiteralPath $sandbox -Recurse -Force }", - "}", - "", - ].join("\n"), - }, - { - name: "portable-git-layout", - source: [ - scriptWithoutEntryPoint, - "", - '$sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-portable-git-test-" + [guid]::NewGuid().ToString("N"))', - '$portableRoot = Join-Path $sandbox "portable-git"', - "try {", - " New-Item -ItemType Directory -Force -Path $sandbox | Out-Null", - " $script:InstallerTempDirectory = $sandbox", - " function Get-PortableGitRoot { return $portableRoot }", - " function Resolve-PortableGitDownload { return @{ Tag = 'test'; Name = 'MinGit.zip'; Url = 'https://example.test/MinGit.zip' } }", - " function Ensure-PortableGitOnUserPath { }", - " function Use-PortableGitIfPresent { return (Test-Path -LiteralPath (Join-Path $portableRoot 'cmd/git.exe')) }", - " function Save-InstallerDownload {", - " param($Uri, $OutFile)", - " New-Item -ItemType File -Force -Path $OutFile | Out-Null", - " }", - " function Expand-Archive {", - " param($Path, $DestinationPath, [switch]$Force)", - " New-Item -ItemType Directory -Force -Path (Join-Path $DestinationPath 'cmd') | Out-Null", - " New-Item -ItemType Directory -Force -Path (Join-Path $DestinationPath 'etc') | Out-Null", - " New-Item -ItemType File -Force -Path (Join-Path $DestinationPath 'cmd/git.exe') | Out-Null", - " New-Item -ItemType File -Force -Path (Join-Path $DestinationPath 'etc/gitconfig') | Out-Null", - " }", - " Install-PortableGit", - " if (-not (Test-Path -LiteralPath (Join-Path $portableRoot 'cmd/git.exe'))) { throw 'missing cmd/git.exe' }", - " if (-not (Test-Path -LiteralPath (Join-Path $portableRoot 'etc/gitconfig'))) { throw 'missing etc/gitconfig' }", - " if (@(Get-ChildItem -LiteralPath $sandbox -Filter 'openclaw-portable-git-*').Count -ne 0) { throw 'temporary Git files remain' }", - "} finally {", - " if (Test-Path -LiteralPath $sandbox) { Remove-Item -LiteralPath $sandbox -Recurse -Force }", - "}", - "", - ].join("\n"), - }, - { - name: "sqlite-versions", - source: [ - scriptWithoutEntryPoint, - "", - "function private-node-fixture {", - " $global:LASTEXITCODE = 0", - " if ($args[0] -eq '-v') { return 'v26.1.0' }", - " $input | Out-Null", - " return (@{ available = $true; version = $script:FixtureSqliteVersion; text = $true; blob = $true; json = $true } | ConvertTo-Json -Compress)", - "}", - "function Get-Command { throw 'unexpected ambient runtime lookup' }", - "$cases = @{", - " '3.44.5' = $false", - " '3.44.6' = $true", - " '3.46.1' = $false", - " '3.50.6' = $false", - " '3.50.7' = $true", - " '3.51.2' = $false", - " '3.51.3' = $true", - " '3.53.1' = $true", - " 'unavailable' = $false", - "}", - "foreach ($entry in $cases.GetEnumerator()) {", - " $actual = Test-NodeSqliteSupported -Version $entry.Key", - ' if ($actual -ne $entry.Value) { throw "Version=$($entry.Key) Actual=$actual" }', - " $script:FixtureSqliteVersion = $entry.Key", - " $actual = Check-Node -NodePath 'private-node-fixture'", - ' if ($actual -ne $entry.Value) { throw "Explicit runtime SQLite=$($entry.Key) Actual=$actual" }', - "}", - "", - ].join("\n"), - }, - { - name: "native-arm64-git", - source: [ - scriptWithoutEntryPoint, - "", - "$env:PROCESSOR_ARCHITEW6432 = $null", - "$env:PROCESSOR_ARCHITECTURE = 'ARM64'", - "function Invoke-RestMethod {", - " param([string]$Uri, [object]$Headers, [int]$TimeoutSec)", - ' if ($TimeoutSec -ne 300) { throw "TimeoutSec=$TimeoutSec" }', - " [pscustomobject]@{", - " tag_name = 'v2.54.0.windows.1'", - " assets = @(", - " [pscustomobject]@{ name = 'MinGit-2.54.0-64-bit.zip'; browser_download_url = 'https://example.test/x64.zip' },", - " [pscustomobject]@{ name = 'MinGit-2.54.0-arm64.zip'; browser_download_url = 'https://example.test/arm64.zip' },", - " [pscustomobject]@{ name = 'MinGit-2.54.0-busybox-64-bit.zip'; browser_download_url = 'https://example.test/busybox.zip' }", - " )", - " }", - "}", - "$download = Resolve-PortableGitDownload", - "if ($download.Name -ne 'MinGit-2.54.0-arm64.zip') { throw \"Name=$($download.Name)\" }", - "if ($download.Url -ne 'https://example.test/arm64.zip') { throw \"Url=$($download.Url)\" }", - "", - ].join("\n"), - }, - { - name: "emulated-arm64-downloads", - source: [ - scriptWithoutEntryPoint, - "", - "$env:PROCESSOR_ARCHITEW6432 = $null", - "$env:PROCESSOR_ARCHITECTURE = 'AMD64'", - "function Get-CimInstance {", - " [CmdletBinding()]", - " param([string]$ClassName)", - " if ($ClassName -eq 'Win32_Processor') { return [pscustomobject]@{ Architecture = 12; Name = 'Cobalt 100' } }", - " if ($ClassName -eq 'Win32_ComputerSystem') { return [pscustomobject]@{ SystemType = 'ARM64-based PC' } }", - ' throw "Unexpected CIM class $ClassName"', - "}", - "function Invoke-RestMethod {", - " param([string]$Uri, [object]$Headers, [int]$OperationTimeoutSeconds)", - ' if ($OperationTimeoutSeconds -ne 300) { throw "OperationTimeoutSeconds=$OperationTimeoutSeconds" }', - " if ($Uri -eq 'https://nodejs.org/dist/index.json') {", - " return @(", - " [pscustomobject]@{ version = 'v26.5.0'; files = @('win-arm64-zip', 'win-x64-zip') },", - " [pscustomobject]@{ version = 'v24.17.0'; files = @('win-arm64-zip', 'win-x64-zip') }", - " )", - " }", - " [pscustomobject]@{", - " tag_name = 'v2.54.0.windows.1'", - " assets = @(", - " [pscustomobject]@{ name = 'MinGit-2.54.0-64-bit.zip'; browser_download_url = 'https://example.test/x64.zip' },", - " [pscustomobject]@{ name = 'MinGit-2.54.0-arm64.zip'; browser_download_url = 'https://example.test/arm64.zip' }", - " )", - " }", - "}", - "$nodeDownload = Resolve-PortableNodeDownload", - "if ($nodeDownload.Name -ne 'node-v26.5.0-win-arm64.zip') { throw \"NodeName=$($nodeDownload.Name)\" }", - "$exactNode = Resolve-PortableNodeDownload -Version 24.17.0", - "if ($exactNode.Name -ne 'node-v24.17.0-win-arm64.zip') { throw \"ExactNode=$($exactNode.Name)\" }", - "$gitDownload = Resolve-PortableGitDownload", - "if ($gitDownload.Name -ne 'MinGit-2.54.0-arm64.zip') { throw \"GitName=$($gitDownload.Name)\" }", - "", - ].join("\n"), - }, - { - name: "node-options", - source: [ - scriptWithoutEntryPoint, - "", - '$result = Resolve-NodeOptionsWithMinOldSpace -NodeOptions "--trace-warnings --max_old_space_size=8192" -MinOldSpaceMb 8192', - 'if ($result -ne "--trace-warnings --max-old-space-size=8192") { throw "alias result=$result" }', - '$result = Resolve-NodeOptionsWithMinOldSpace -NodeOptions "--max_old_space_size 8192 --trace-warnings" -MinOldSpaceMb 8192', - 'if ($result -ne "--max-old-space-size=8192 --trace-warnings") { throw "split alias result=$result" }', - '$result = Resolve-NodeOptionsWithMinOldSpace -NodeOptions "--max-old-space-size=4096" -MinOldSpaceMb 8192', - 'if ($result -ne "--max-old-space-size=8192") { throw "minimum result=$result" }', - '$result = Resolve-NodeOptionsWithMinOldSpace -NodeOptions "`"--max-old-space-size=12288`"" -MinOldSpaceMb 8192', - 'if ($result -ne "--max-old-space-size=12288") { throw "quoted token result=$result" }', - '$result = Resolve-NodeOptionsWithMinOldSpace -NodeOptions "--max-old-space-size=`"12288`"" -MinOldSpaceMb 8192', - 'if ($result -ne "--max-old-space-size=12288") { throw "quoted value result=$result" }', - "", - ].join("\n"), - }, - { - name: "winget-node-delayed-path", - // Refresh-ProcessPath reads machine PATH, which may already contain the real Node install. - source: [ - scriptWithoutEntryPoint, - "", - "$env:ProgramW6432 = 'C:\\openclaw-winget-test-' + [guid]::NewGuid().ToString('N')", - '$env:ProgramFiles = "$env:ProgramW6432 (x86)"', - '$script:wingetNodeDir = "$env:ProgramW6432\\nodejs"', - "function Get-Command {", - " [CmdletBinding()]", - " param([string]$Name)", - " if ($Name -eq 'winget') { return $true }", - " return $null", - "}", - "function Join-Path {", - " param([string]$Path, [string]$ChildPath)", - " return \"$($Path.TrimEnd('\\'))\\$ChildPath\"", - "}", - "function Test-Path {", - " param([string]$Path)", - ' return ($Path -eq "$script:wingetNodeDir\\node.exe")', - "}", - "filter Out-Host { }", - "$env:Path = 'C:\\Windows\\System32'", - "function winget {", - " $global:LASTEXITCODE = 0", - " Write-Output 'winget output'", - "}", - "function Check-Node {", - " return (($env:Path -split ';') -contains $script:wingetNodeDir)", - "}", - "$result = @(Install-Node)", - 'if ($result.Count -ne 1 -or $result[0] -ne $true) { throw "Install-Node returned $result" }', - "if (($env:Path -split ';')[0] -ne $script:wingetNodeDir) { throw \"Path=$env:Path\" }", - "", - ].join("\n"), - }, - { - name: "chocolatey-node-upgrade", - source: [ - scriptWithoutEntryPoint, - "", - "function Get-Command {", - " [CmdletBinding()]", - " param([string]$Name)", - " if ($Name -eq 'choco') { return $true }", - " return $null", - "}", - "filter Out-Host { }", - "function choco {", - " $script:chocoArgs = $args -join ' '", - " $global:LASTEXITCODE = 0", - " Write-Output 'Chocolatey output'", - "}", - "function Check-Node { return $true }", - "$result = @(Install-Node)", - 'if ($result.Count -ne 1 -or $result[0] -ne $true) { throw "Install-Node returned $result" }', - "if ($script:chocoArgs -ne 'upgrade nodejs-lts -y --install-if-not-installed') {", - ' throw "Args=$script:chocoArgs"', - "}", - "", - ].join("\n"), - }, - { - name: "scoop-node-update", - source: [ - scriptWithoutEntryPoint, - "", - "function Get-Command {", - " [CmdletBinding()]", - " param([string]$Name)", - " if ($Name -eq 'scoop') { return $true }", - " return $null", - "}", - "filter Out-Host { }", - "$env:Path = 'C:\\session-bin'", - "$script:scoopCalls = @()", - "function scoop {", - " $script:scoopCalls += ($args -join ' ')", - " $global:LASTEXITCODE = 0", - " Write-Output 'Scoop output'", - "}", - "function Check-Node { return $true }", - "$result = @(Install-Node)", - 'if ($result.Count -ne 1 -or $result[0] -ne $true) { throw "Install-Node returned $result" }', - "if (($script:scoopCalls -join '|') -ne 'update|install nodejs-lts|update nodejs-lts') {", - " throw \"Calls=$($script:scoopCalls -join '|')\"", - "}", - "if (($env:Path -split ';') -notcontains 'C:\\session-bin') { throw \"Path=$env:Path\" }", - "", - ].join("\n"), - }, - { - name: "package-manager-node-validation-failure", - source: [ - scriptWithoutEntryPoint, - "", - "function Get-Command {", - " [CmdletBinding()]", - " param([string]$Name)", - " if ($Name -eq 'choco') { return $true }", - " return $null", - "}", - "filter Out-Host { }", - "function choco {", - " $global:LASTEXITCODE = 0", - " Write-Output 'Chocolatey output'", - "}", - "$script:portableCalled = $false", - "function Install-PortableNode { $script:portableCalled = $true }", - "function Check-Node { return $script:portableCalled }", - "$result = @(Install-Node)", - 'if ($result.Count -ne 1 -or $result[0] -ne $true) { throw "Install-Node returned $result" }', - "if (-not $script:portableCalled) { throw 'Portable Node fallback was not attempted' }", - "", - ].join("\n"), - }, - { - name: "package-manager-node-command-failures", - source: [ - scriptWithoutEntryPoint, - String.raw` -function Get-Command { - [CmdletBinding()] - param([string]$Name) - if ($Name -eq $script:manager) { return $true } - return $null -} -filter Out-Host { } -function Invoke-FixtureManager { - $script:attempts += 1 - Write-Output 'package-manager output must not become a success result' - if ($script:failure -eq 'throw') { throw 'fixture package-manager failure' } - $global:LASTEXITCODE = if ($script:failure -eq 'exit') { 17 } else { 0 } -} -function winget { Invoke-FixtureManager } -function choco { Invoke-FixtureManager } -function scoop { Invoke-FixtureManager } -function Refresh-ProcessPath { $script:refreshes += 1 } -function Add-InstalledNodeToProcessPath { $script:discoveries += 1; return $true } -function Check-Node { return $script:portableReady } -function Install-PortableNode { $script:portableCalls += 1; $script:portableReady = $true } -foreach ($script:manager in @('winget', 'choco', 'scoop')) { - foreach ($script:failure in @('exit', 'throw', 'unsupported')) { - $script:attempts = 0 - $script:refreshes = 0 - $script:discoveries = 0 - $script:portableCalls = 0 - $script:portableReady = $false - $global:LASTEXITCODE = 0 - $result = @(Install-Node) - if ($result.Count -ne 1 -or $result[0] -isnot [bool] -or -not $result[0]) { - throw "manager=$script:manager failure=$script:failure result=$result" - } - $expectedAttempts = if ($script:manager -eq 'scoop' -and $script:failure -eq 'unsupported') { 3 } else { 1 } - $expectedDiscoveries = if ($script:manager -eq 'winget') { 1 } else { 0 } - if ($script:attempts -ne $expectedAttempts -or $script:refreshes -ne 1 -or $script:discoveries -ne $expectedDiscoveries -or $script:portableCalls -ne 1) { - throw "unexpected recovery order/count: $script:manager $script:failure" - } - if ($ErrorActionPreference -ne 'Stop') { throw 'caller error policy changed' } - } -} -`, - ].join("\n"), - }, - { - name: "package-manager-node-next-manager-success", - source: [ - scriptWithoutEntryPoint, - String.raw` -$script:events = New-Object System.Collections.Generic.List[string] -$script:ready = $false -function Get-Command { - [CmdletBinding()] - param([string]$Name) - if ($Name -in @('winget', 'choco', 'scoop')) { return $true } - return $null -} -filter Out-Host { } -function winget { $script:events.Add('winget'); $global:LASTEXITCODE = 17; Write-Output 'winget failed' } -function choco { $script:events.Add('choco'); $global:LASTEXITCODE = 0; $script:ready = $true; Write-Output 'choco success' } -function scoop { throw 'Scoop must not run after supported Node is found' } -function Refresh-ProcessPath { $script:events.Add('refresh') } -function Add-InstalledNodeToProcessPath { $script:events.Add('discover'); return $false } -function Check-Node { $script:events.Add('check'); return $script:ready } -function Install-PortableNode { throw 'portable fallback must not run after supported Node is found' } -$result = @(Install-Node) -if ($result.Count -ne 1 -or $result[0] -isnot [bool] -or -not $result[0]) { throw "result=$result" } -if (($script:events -join '|') -ne 'winget|refresh|discover|check|choco|refresh|check') { - throw "events=$($script:events -join '|')" -} -`, - ].join("\n"), - }, - { - name: "package-manager-node-entrypoint-refusal", - source: [ - scriptWithoutEntryPoint, - String.raw` -$NodeOnly = $false -$NodePrefix = '' -$DryRun = $false -$InstallMethod = 'npm' -$NoOnboard = $true -function Check-ExistingOpenClaw { return $false } -function Get-Command { - [CmdletBinding()] - param([string]$Name) - if ($Name -eq 'choco') { return $true } - return $null -} -filter Out-Host { } -function choco { $global:LASTEXITCODE = 17; Write-Output 'fixture choco failure' } -function Refresh-ProcessPath { } -function Check-Node { return $false } -function Install-PortableNode { - $script:portableCalls += 1 - if ($script:portableFailure -eq 'throw') { throw 'fixture portable failure' } -} -function Install-OpenClaw { throw 'package install must not run without a supported Node' } -foreach ($script:portableFailure in @('throw', 'unsupported')) { - $script:InstallExitCode = 0 - $script:portableCalls = 0 - $caught = $false - try { -`, - ...entrypointLines.map((line) => ` ${line}`), - String.raw` - } catch { - if ($_.Exception.Message -ne 'OpenClaw installation failed with exit code 1.') { throw } - $caught = $true - } - if (-not $caught -or $script:InstallExitCode -ne 1 -or $script:portableCalls -ne 1) { - throw 'failed recovery did not preserve the installer refusal contract' - } - if ($ErrorActionPreference -ne 'Stop') { throw 'caller error policy changed' } -} -`, - ].join("\n"), - }, - { - name: "scriptblock-failure", - source: [ - scriptWithoutEntryPoint, - "", - "function Write-Banner { }", - "function Ensure-ExecutionPolicy { return $true }", - "function Check-Node { return $false }", - "function Install-Node { return $false }", - "$caught = $false", - "try {", - ...entrypointLines.map((line) => ` ${line}`), - "} catch {", - " if ($_.Exception.Message -ne 'OpenClaw installation failed with exit code 1.') { throw }", - " $caught = $true", - "}", - "if (-not $caught) { throw 'Install failure did not reach the caller' }", - "", - ].join("\n"), - }, - { - name: "scriptblock-deferred-path-success", - source: createDeferredPathSuccessFixture(source), - }, - { - name: "noisy-git-failure", - source: [ - scriptWithoutEntryPoint, - "", - "function Write-Banner { }", - "function Ensure-ExecutionPolicy { return $true }", - "function Check-Node { return $true }", - "function Check-ExistingOpenClaw { return $false }", - "function Get-NpmCommandPath { return $null }", - "function Install-OpenClawFromGit {", - " Write-Output 'pnpm stdout before failure'", - " return $false", - "}", - "function Ensure-OpenClawOnPath { throw 'should not continue after failed git install' }", - "$InstallMethod = 'git'", - "$GitDir = 'C:\\\\openclaw-test'", - "$NoOnboard = $true", - "$null = Main", - 'if ($script:InstallExitCode -ne 1) { throw "InstallExitCode=$script:InstallExitCode" }', - "", - ].join("\n"), - }, - { - name: "quiet-main-success", - source: [ - scriptWithoutEntryPoint, - "", - "function Write-Banner { }", - "function Ensure-ExecutionPolicy { return $true }", - "function Check-Node { return $true }", - "function Check-ExistingOpenClaw { return $false }", - "function Add-ToPath { param([string]$Path) }", - "function Install-OpenClaw { Write-Output 'npm stdout'; return $true }", - "function Ensure-OpenClawOnPath { return $true }", - "function Refresh-GatewayServiceIfLoaded { }", - "function Invoke-OpenClawCommand { return 'OpenClaw test-version' }", - "$NoOnboard = $true", - "$result = Main", - "if ($result -is [array]) { throw 'Main returned an array' }", - 'if ($result -ne $true) { throw "Main returned $result" }', - "", - ].join("\n"), - }, - { - name: "terminal-code-success", - source: [ - scriptWithoutEntryPoint, - "", - "function Write-Banner { }", - "function Ensure-ExecutionPolicy { return $true }", - "function Check-Node { return $true }", - "function Check-ExistingOpenClaw { return $false }", - "function Add-ToPath { param([string]$Path) }", - "function Install-OpenClaw {", - " Write-Output 'native chatter'", - " return $true", - "}", - "function Ensure-OpenClawOnPath { return $true }", - "function Refresh-GatewayServiceIfLoaded { }", - "function Invoke-OpenClawCommand { return 'OpenClaw test-version' }", - "$NoOnboard = $true", - ...entrypointLines, - "", - ].join("\n"), - }, - { - name: "transactional-git-clone", - source: [ - scriptWithoutEntryPoint, - "", - '$sandbox = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-transactional-clone-" + [guid]::NewGuid().ToString("N"))', - "New-Item -ItemType Directory -Path $sandbox | Out-Null", - "$script:CloneMode = 'success'", - "$script:GitFilterSupport = $true", - "$script:LastCloneArgs = @()", - "$script:ConcurrentRepo = $null", - "$script:AliasPath = $null", - "$script:AliasReplacement = $null", - "function git {", - " if ($args[0] -eq 'clone' -and $args[1] -eq '-h') {", - " if ($script:GitFilterSupport) { Write-Output ' --[no-]filter ' }", - " $global:LASTEXITCODE = 129", - " return", - " }", - " if ($args[0] -eq 'clone') { $script:LastCloneArgs = @($args) }", - " $target = $args[-1]", - " New-Item -ItemType Directory -Force -Path (Join-Path $target '.git') | Out-Null", - " Set-Content -LiteralPath (Join-Path $target 'checkout.marker') -Value 'complete'", - " if ($script:CloneMode -eq 'failure') { $global:LASTEXITCODE = 42; return }", - " if ($script:CloneMode -eq 'concurrent') {", - " New-Item -ItemType Directory -Path $script:ConcurrentRepo | Out-Null", - " Set-Content -LiteralPath (Join-Path $script:ConcurrentRepo 'user.marker') -Value 'keep'", - " }", - " if ($script:CloneMode -eq 'retarget-alias') {", - " Remove-Item -LiteralPath $script:AliasPath -Force", - " $linkType = if ($IsWindows -or $env:OS -eq 'Windows_NT') { 'Junction' } else { 'SymbolicLink' }", - " New-Item -ItemType $linkType -Path $script:AliasPath -Target $script:AliasReplacement | Out-Null", - " }", - " $global:LASTEXITCODE = 0", - "}", - "try {", - " $successRepo = Join-Path $sandbox 'success'", - " New-TransactionalGitCheckout -RepoUrl 'https://example.invalid/openclaw.git' -RepoDir $successRepo", - " if (-not (Test-Path -LiteralPath (Join-Path $successRepo 'checkout.marker'))) { throw 'complete checkout was not published' }", - " if ($script:LastCloneArgs -notcontains '--filter=blob:none') { throw 'supported Git did not use a filtered clone' }", - "", - " $emptyRepo = Join-Path $sandbox 'empty'", - " New-Item -ItemType Directory -Path $emptyRepo | Out-Null", - " $script:GitFilterSupport = $false", - " New-TransactionalGitCheckout -RepoUrl 'https://example.invalid/openclaw.git' -RepoDir $emptyRepo", - " if (-not (Test-Path -LiteralPath (Join-Path $emptyRepo 'checkout.marker'))) { throw 'empty destination was not populated' }", - " if ($script:LastCloneArgs -contains '--filter=blob:none') { throw 'unsupported Git used a filtered clone' }", - "", - " $aliasTarget = Join-Path $sandbox 'alias-target'", - " $script:AliasReplacement = Join-Path $sandbox 'alias-replacement'", - " $script:AliasPath = Join-Path $sandbox 'alias'", - " New-Item -ItemType Directory -Path $aliasTarget | Out-Null", - " New-Item -ItemType Directory -Path $script:AliasReplacement | Out-Null", - " $linkType = if ($IsWindows -or $env:OS -eq 'Windows_NT') { 'Junction' } else { 'SymbolicLink' }", - " New-Item -ItemType $linkType -Path $script:AliasPath -Target $aliasTarget | Out-Null", - " $script:CloneMode = 'retarget-alias'", - " New-TransactionalGitCheckout -RepoUrl 'https://example.invalid/openclaw.git' -RepoDir $script:AliasPath", - " if (-not (Test-Path -LiteralPath (Join-Path $aliasTarget 'checkout.marker'))) { throw 'original alias target was not populated' }", - " if (@(Get-ChildItem -LiteralPath $script:AliasReplacement -Force).Count -ne 0) { throw 'replacement alias target was modified' }", - "", - " $script:CloneMode = 'failure'", - " $failedRepo = Join-Path $sandbox 'failure'", - " $cloneFailed = $false", - " try { New-TransactionalGitCheckout -RepoUrl 'https://example.invalid/openclaw.git' -RepoDir $failedRepo } catch { $cloneFailed = $true }", - " if (-not $cloneFailed) { throw 'failed clone was accepted' }", - " if (Test-Path -LiteralPath $failedRepo) { throw 'failed clone published its destination' }", - "", - " $script:CloneMode = 'concurrent'", - " $script:ConcurrentRepo = Join-Path $sandbox 'concurrent'", - " $publicationFailed = $false", - " try { New-TransactionalGitCheckout -RepoUrl 'https://example.invalid/openclaw.git' -RepoDir $script:ConcurrentRepo } catch { $publicationFailed = $true }", - " if (-not $publicationFailed) { throw 'concurrent destination was replaced' }", - " if ((Get-Content -LiteralPath (Join-Path $script:ConcurrentRepo 'user.marker') -Raw).Trim() -ne 'keep') { throw 'concurrent destination changed' }", - " if (Test-Path -LiteralPath (Join-Path $script:ConcurrentRepo 'checkout.marker')) { throw 'clone leaked into concurrent destination' }", - " if (@(Get-ChildItem -LiteralPath $sandbox -Filter '.openclaw-clone-*' -Force).Count -ne 0) { throw 'staging directories remain' }", - "} finally {", - " Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue", - "}", - "", - ].join("\n"), - }, - ]; - if (process.platform === "win32") { - cases.push({ - name: "pnpm-source-bootstrap-lifecycle", - source: [ - scriptWithoutEntryPoint, - `$nodeExe = ${toPowerShellSingleQuotedLiteral(process.execPath)}`, - String.raw` -$root = Join-Path $script:InstallerTempDirectory ("openclaw pnpm boundary " + [guid]::NewGuid().ToString("N")) -$contextNames = @('COREPACK_ENABLE_DOWNLOAD_PROMPT', 'NPM_CONFIG_WORKSPACE_DIR', 'PNPM_CONFIG_LOCKFILE_DIR', 'PNPM_CONFIG_CHILD_CONCURRENCY', 'PNPM_CONFIG_NETWORK_CONCURRENCY', 'PNPM_CONFIG_WORKSPACE_CONCURRENCY', 'PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN', 'PNPM_CONFIG_SIDE_EFFECTS_CACHE', 'NODE_OPTIONS') -$saved = @{} -foreach ($name in (@('PATH', 'PATHEXT', 'USERPROFILE', 'OPENCLAW_TEST_BOOTSTRAP_ROOT', 'PNPM_CONFIG_PREFER_OFFLINE') + $contextNames)) { - $saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') -} -$previousTemp = $script:InstallerTempDirectory -$previousLocation = (Get-Location).Path -function Ensure-Git { return $true } -function Assert-GitCheckoutHasCommit { param([string]$RepoDir) } -function Remove-LegacySubmodule { param([string]$RepoDir) } -function git { throw 'unexpected Git mutation' } -function New-TransactionalGitCheckout { throw 'unexpected clone' } -function Main { throw 'unexpected installer entrypoint' } -function Run-Doctor { throw 'unexpected doctor' } -function Refresh-GatewayServiceIfLoaded { throw 'unexpected gateway refresh' } -function Invoke-OpenClawCommand { throw 'unexpected live CLI' } -function Publish-TextFileAtomically { - param([string]$Path, [string]$Contents) - $expectedPath = Join-Path $env:USERPROFILE '.local\bin\openclaw.cmd' - # Duplicate separators can name the same Windows wrapper; require the exact normalized path. - if ([IO.Path]::GetFullPath($Path) -ne [IO.Path]::GetFullPath($expectedPath)) { throw 'publication escaped fixture' } - $script:Published += 1 -} -function Add-ToUserPath { param([string]$Path); $script:PathPublished += 1; return $false } -$commandSource = @' -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { spawnSync } = require('node:child_process'); -const root = process.env.OPENCLAW_TEST_BOOTSTRAP_ROOT; -const spec = JSON.parse(fs.readFileSync(path.join(root, 'case.json'), 'utf8')); -const target = path.join(root, 'target'); -const log = path.join(root, 'calls.jsonl'); -const [kind, launcher, ...args] = process.argv.slice(2); -const samePath = (a, b) => assert.equal(path.resolve(a).toLowerCase(), path.resolve(b).toLowerCase()); -const calls = () => fs.readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse); -if (kind === 'verify') { - const events = calls(); - assert.equal(events.some((e) => e.kind === 'ambient'), false, 'ambient pnpm ran'); - const stages = events.filter((e) => e.kind === 'selected' && e.args[0] !== '--version').map((e) => e.args[0]); - const expected = spec.failure === 'bootstrap' || spec.failure === 'version' ? [] - : spec.failure === 'install' ? ['config', 'install', 'install'] - : spec.failure === 'build' ? ['config', 'install', 'ui:build', 'nested-ui', 'build'] - : ['config', 'install', 'ui:build', 'nested-ui', 'build', 'nested-build']; - assert.deepEqual(stages, expected); - const npmInstalls = events.filter((e) => e.kind === 'npm' && e.args[0] === 'install'); - assert.equal(npmInstalls.length, spec.mode === 'corepack' ? 0 : 1); - assert.equal(events.filter((e) => e.kind === 'corepack').length, spec.mode === 'missing' ? 0 : 1); - const selected = events.filter((e) => e.kind === 'selected'); - assert.ok(selected.length || spec.failure === 'bootstrap'); - for (const event of selected) { - samePath(event.cwd, target); - if (event.args[0] !== '--version') samePath(event.path.split(';')[0], path.dirname(event.launcher)); - } - process.exit(0); -} -const roots = Object.fromEntries(['NPM_CONFIG_WORKSPACE_DIR', 'npm_config_workspace_dir', 'PNPM_CONFIG_LOCKFILE_DIR', 'pnpm_config_lockfile_dir'].map((key) => [key, process.env[key] ?? null])); -fs.appendFileSync(log, JSON.stringify({ kind, launcher, args, cwd: process.cwd(), path: process.env.PATH, roots, prompt: process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT ?? null, nodeOptions: process.env.NODE_OPTIONS ?? null }) + '\n'); -if (kind === 'ambient') { - fs.writeFileSync(path.join(target, 'pnpm-lock.yaml'), 'corrupted'); - console.log(spec.version); - process.exit(0); -} -assert.equal(process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT, '0'); -for (const key of ['NPM_CONFIG_WORKSPACE_DIR', 'npm_config_workspace_dir', 'PNPM_CONFIG_LOCKFILE_DIR', 'pnpm_config_lockfile_dir']) { - samePath(process.env[key], target); -} -for (const [key, value] of Object.entries({ CHILD_CONCURRENCY: '1', NETWORK_CONCURRENCY: '4', WORKSPACE_CONCURRENCY: '1', VERIFY_DEPS_BEFORE_RUN: 'false', SIDE_EFFECTS_CACHE: 'false' })) { - assert.equal(process.env['PNPM_CONFIG_' + key], value, key); -} -const writeSelected = (dir) => { - assert.equal(path.dirname(dir) === path.join(root, 'tools') || path.dirname(path.dirname(dir)) === path.join(root, 'tools'), true); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'pnpm.cmd'), '@echo off\r\n"' + process.execPath + '" "' + __filename + '" selected "%~f0" %*\r\nexit /b %errorlevel%\r\n'); -}; -if (kind === 'corepack') { - assert.deepEqual(args.slice(0, 2), ['enable', '--install-directory']); - assert.equal(args.length, 4); - assert.equal(args[3], 'pnpm'); - samePath(path.dirname(args[2]), path.join(root, 'tools')); - if (spec.mode === 'failing') process.exit(42); - writeSelected(args[2]); -} else if (kind === 'npm') { - if (args[0] === '--version') { - assert.deepEqual(args, ['--version']); - console.log('12.0.0'); - } else { - assert.deepEqual(args.slice(0, 3), ['install', '-g', '--prefix']); - assert.deepEqual(args.slice(4), ['pnpm@' + spec.version, '--allow-scripts=pnpm@' + spec.version]); - assert.equal(path.basename(args[3]), 'npm'); - samePath(path.dirname(path.dirname(args[3])), path.join(root, 'tools')); - if (spec.failure === 'bootstrap') process.exit(42); - writeSelected(args[3]); - } -} else { - assert.equal(kind, 'selected'); - samePath(process.cwd(), target); - if (args[0] === '--version') { - assert.deepEqual(args, ['--version']); - const wrongVersion = spec.failure === 'version' || (spec.mode === 'wrong-version' && path.basename(path.dirname(launcher)) !== 'npm'); - console.log(wrongVersion ? '0.0.0' : spec.version); - } else if (args[0] === 'config') { - assert.deepEqual(args, ['config', 'get', 'prefer-offline']); - console.log('undefined'); - } else if (args[0] === 'install') { - assert.deepEqual(args, ['install', '--prefer-offline', '--config.node-linker=hoisted', '--config.engine-strict=false', '--config.enable-pre-post-scripts=true', '--config.side-effects-cache=false', '--no-frozen-lockfile', '--config.child-concurrency=1', '--config.network-concurrency=4', '--config.workspace-concurrency=1']); - if (spec.failure === 'install') process.exit(42); - } else if (args[0] === 'ui:build' || args[0] === 'build') { - assert.equal(args.length, 1); - if (args[0] === 'build') { - assert.match(process.env.NODE_OPTIONS, /--max-old-space-size=8192/); - if (spec.failure === 'build') process.exit(42); - fs.mkdirSync(path.join(target, 'dist'), { recursive: true }); - fs.writeFileSync(path.join(target, 'dist', 'entry.js'), 'process.exit(0);'); - } - const child = spawnSync(process.env.ComSpec, ['/d', '/s', '/c', 'pnpm ' + (args[0] === 'build' ? 'nested-build' : 'nested-ui')], { stdio: 'inherit', windowsVerbatimArguments: true }); - assert.equal(child.error, undefined); - process.exit(child.status ?? 1); - } else { - assert.ok(['nested-ui', 'nested-build'].includes(args[0])); - assert.equal(args.length, 1); - } -} -'@ -$scenarios = @( - @{ Mode = 'corepack'; Failure = ''; Present = $true; Version = '12.0.0' }, - @{ Mode = 'corepack'; Failure = ''; Present = $false; Version = '11.15.1' }, - @{ Mode = 'missing'; Failure = ''; Present = $false; Version = '12.0.0' }, - @{ Mode = 'failing'; Failure = ''; Present = $true; Version = '12.0.0' }, - @{ Mode = 'wrong-version'; Failure = ''; Present = $false; Version = '12.0.0' }, - @{ Mode = 'missing'; Failure = 'bootstrap'; Present = $true; Version = '12.0.0' }, - @{ Mode = 'missing'; Failure = 'version'; Present = $false; Version = '12.0.0' }, - @{ Mode = 'corepack'; Failure = 'install'; Present = $false; Version = '12.0.0' }, - @{ Mode = 'missing'; Failure = 'build'; Present = $true; Version = '12.0.0' } -) -try { - foreach ($scenario in $scenarios) { - $caseRoot = Join-Path $root ([guid]::NewGuid().ToString('N')) - $bin = Join-Path $caseRoot 'bin' - $target = Join-Path $caseRoot 'target' - $foreign = Join-Path $caseRoot 'foreign' - $script:InstallerTempDirectory = Join-Path $caseRoot 'tools' - foreach ($dir in @($bin, $target, $foreign, $script:InstallerTempDirectory)) { - New-Item -ItemType Directory -Force -Path $dir | Out-Null - } - $env:OPENCLAW_TEST_BOOTSTRAP_ROOT = $caseRoot - $env:USERPROFILE = $caseRoot - $env:PATH = $bin - $env:PATHEXT = '.COM;.EXE;.BAT;.CMD' - $env:PNPM_CONFIG_PREFER_OFFLINE = $null - [IO.File]::WriteAllText((Join-Path $caseRoot 'command.cjs'), $commandSource) - $caseSpec = @{ mode = $scenario.Mode; failure = $scenario.Failure; version = $scenario.Version } - [IO.File]::WriteAllText((Join-Path $caseRoot 'case.json'), ($caseSpec | ConvertTo-Json -Compress)) - [IO.File]::WriteAllText((Join-Path $target 'package.json'), ('{"packageManager":"pnpm@' + $scenario.Version + '"}')) - foreach ($dir in @($target, $foreign)) { [IO.File]::WriteAllText((Join-Path $dir 'pnpm-lock.yaml'), 'unchanged') } - $manifest = [IO.File]::ReadAllText((Join-Path $target 'package.json')) - foreach ($kind in @('npm', 'ambient', 'corepack')) { - if ($kind -eq 'corepack' -and $scenario.Mode -eq 'missing') { continue } - $name = if ($kind -eq 'ambient') { 'pnpm' } else { $kind } - $cmd = '@echo off' + [Environment]::NewLine + '"' + $nodeExe + '" "' + (Join-Path $caseRoot 'command.cjs') + '" ' + $kind + ' "%~f0" %*' + [Environment]::NewLine + 'exit /b %errorlevel%' - [IO.File]::WriteAllText((Join-Path $bin ($name + '.cmd')), $cmd) - } - [IO.File]::WriteAllText((Join-Path $bin 'node.cmd'), ('@"' + $nodeExe + '" %*' + [Environment]::NewLine + '@exit /b %errorlevel%')) - foreach ($name in $contextNames) { - $value = if (-not $scenario.Present) { $null } elseif ($name -eq 'COREPACK_ENABLE_DOWNLOAD_PROMPT') { '1' } elseif ($name -eq 'NODE_OPTIONS') { '--no-warnings' } elseif ($name -match '_DIR$') { $foreign } else { '7' } - Set-Item -LiteralPath "Env:$name" -Value $value - } - $caller = @{} - foreach ($name in (@('PATH') + $contextNames)) { $caller[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } - Set-Location -LiteralPath $foreign - $script:Published = 0 - $script:PathPublished = 0 - $outsideRejected = try { - Publish-TextFileAtomically -Path (Join-Path $caseRoot '..\openclaw.cmd') -Contents '' - $false - } catch { - if ($_.Exception.Message -ne 'publication escaped fixture') { throw } - $true - } - if (-not $outsideRejected -or $script:Published -ne 0) { throw 'outside publication was accepted' } - $caught = $null - $ownerOutput = @() - try { $ownerOutput = @(Install-OpenClawFromGit -RepoDir $target -SkipUpdate) } catch { $caught = $_ } - $success = Test-BooleanSuccessResult -Results $ownerOutput - if ($scenario.Failure -in @('bootstrap', 'version')) { - if (-not $caught -or $caught.Exception.Message -notmatch 'Could not (install|provision)') { throw "missing bootstrap failure: $caught" } - } elseif ($caught) { throw $caught } - $expectedSuccess = $scenario.Failure -eq '' - if ($success -ne $expectedSuccess) { throw "unexpected owner result: $($scenario | ConvertTo-Json -Compress)" } - if ($script:Published -ne [int]$expectedSuccess -or $script:PathPublished -ne [int]$expectedSuccess) { throw 'publication boundary was violated' } - foreach ($name in $caller.Keys) { - if ([Environment]::GetEnvironmentVariable($name, 'Process') -cne $caller[$name]) { throw "caller environment leaked: $name" } - } - if ((Get-Location).Path -ne $foreign) { throw 'caller location leaked' } - if (@(Get-ChildItem -LiteralPath $script:InstallerTempDirectory -Force).Count -ne 0) { throw 'temporary pnpm prefix leaked' } - foreach ($dir in @($target, $foreign)) { - if ([IO.File]::ReadAllText((Join-Path $dir 'pnpm-lock.yaml')) -ne 'unchanged') { throw 'lockfile changed' } - } - if ([IO.File]::ReadAllText((Join-Path $target 'package.json')) -ne $manifest) { throw 'manifest changed' } - & $nodeExe (Join-Path $caseRoot 'command.cjs') verify - if ($LASTEXITCODE -ne 0) { throw "child boundary verification failed: $($scenario | ConvertTo-Json -Compress)" } - } -} finally { - Set-Location -LiteralPath $previousLocation - foreach ($name in $saved.Keys) { Set-Item -LiteralPath "Env:$name" -Value $saved[$name] } - $script:InstallerTempDirectory = $previousTemp - if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } -} -`, - ].join("\n"), - }); - cases.push({ - name: "portable-node-tar-fallback", - source: [ - scriptWithoutEntryPoint, - String.raw` -$root = Join-Path $script:InstallerTempDirectory ("openclaw portable node " + [guid]::NewGuid().ToString("N")) -$bin = Join-Path $root "bin" -$archiveRoot = Join-Path $root "archive" -$nodeRoot = Join-Path $archiveRoot "node-fixture" -$zip = Join-Path $root "node archive.zip" -$destination = Join-Path $root "portable node" -$tarArgsLog = Join-Path $root "tar-args.txt" -$previousPath = $env:PATH -$previousLocation = (Get-Location).Path -try { - New-Item -ItemType Directory -Force -Path $bin, $nodeRoot | Out-Null - [IO.File]::WriteAllText((Join-Path $nodeRoot "node.exe"), "node fixture bytes") - Add-Type -AssemblyName System.IO.Compression.FileSystem - [IO.Compression.ZipFile]::CreateFromDirectory($archiveRoot, $zip) - $tarScript = @( - "@echo off", - ('echo %~1 > "' + $tarArgsLog + '"'), - ('echo %~2 >> "' + $tarArgsLog + '"'), - ('echo %~3 >> "' + $tarArgsLog + '"'), - ('echo %~4 >> "' + $tarArgsLog + '"'), - ('echo %~5 >> "' + $tarArgsLog + '"'), - ('echo %~6 >> "' + $tarArgsLog + '"'), - 'echo partial> "%~4\partial.marker"', - "echo tar fixture failure 1>&2", - "exit /b 17" - ) - [IO.File]::WriteAllLines((Join-Path $bin "tar.cmd"), $tarScript) - $env:PATH = "$bin;$env:PATH" - # Explicit PowerShell redirection preserves the Windows PowerShell 5.1 failure mode. - $output = @(Expand-PortableNodeArchive -ZipPath $zip -DestinationPath $destination 2>&1) - if ($LASTEXITCODE -ne 17) { throw "native exit changed: $LASTEXITCODE" } - $expectedArguments = @("-xf", $zip, "-C", $destination, "--strip-components", "1") - $actualArguments = @(Get-Content -LiteralPath $tarArgsLog | ForEach-Object { $_.TrimEnd() }) - if (($actualArguments -join "|") -cne ($expectedArguments -join "|")) { throw "tar argument mismatch" } - if ([IO.File]::ReadAllText((Join-Path $destination "node.exe")) -cne "node fixture bytes") { throw "fallback bytes changed" } - if (Test-Path -LiteralPath (Join-Path $destination "partial.marker")) { throw "partial tar output remains" } - if (@(Get-ChildItem -LiteralPath $root -Filter "portable-node-extract-*").Count -ne 0) { throw "fallback temporary directory remains" } - if (($output | Out-String) -notmatch "tar fixture failure") { throw "native stderr lost" } - if ($ErrorActionPreference -ne "Stop" -or (Get-Location).Path -ne $previousLocation) { throw "caller state leaked" } -} finally { - $env:PATH = $previousPath - if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } -} -`, - ].join("\n"), - }); - } - const tempDir = harness.createTempDir("openclaw-install-ps1-batch-"); - const fixtures = cases.map((testCase, index) => { - const scriptPath = join(tempDir, `case-${index}.ps1`); - writeFileSync(scriptPath, testCase.source); - return { name: testCase.name, scriptPath }; - }); - const command = [ - "$ErrorActionPreference = 'Stop'", - "$cases = @(", - fixtures - .map( - (fixture) => - ` @{ Name = ${toPowerShellSingleQuotedLiteral(fixture.name)}; Path = ${toPowerShellSingleQuotedLiteral(fixture.scriptPath)} }`, - ) - .join(",\n"), - ")", - "$results = foreach ($case in $cases) {", - " $caseOutput = New-Object System.Collections.Generic.List[string]", - " try {", - " & ([scriptblock]::Create((Get-Content -LiteralPath $case.Path -Raw))) *>&1 | ForEach-Object { $caseOutput.Add([string]$_) }", - " [pscustomobject]@{ name = $case.Name; ok = $true; error = '' }", - " } catch {", - ' $details = ($caseOutput | Select-Object -Last 80) -join "`n"', - ' [pscustomobject]@{ name = $case.Name; ok = $false; error = "$( $_.Exception.Message )`nNative exit: $LASTEXITCODE`n$details" }', - " }", - "}", - "$results | ConvertTo-Json -Compress", - ].join("\n"); - const result = runPowerShell(["-NoLogo", "-NoProfile", "-Command", command]); - if (result.status !== 0) { - throw new Error(`PowerShell batch failed: ${result.stderr}`); - } - const parsed = JSON.parse(result.stdout) as Array<{ error: string; name: string; ok: boolean }>; - for (const entry of parsed) { - batchedPowerShellResults.set(entry.name, { error: entry.error, ok: entry.ok }); - } - // The hosted installer supports Windows PowerShell 5.1 as well as PowerShell 7. - for (const engine of bootstrapShells) { - if (engine === powershell) { - continue; - } - for (const name of [ - "native-npm-stderr", - "pnpm-source-bootstrap-lifecycle", - "portable-git-layout", - "portable-node-tar-fallback", - "package-manager-node-command-failures", - "package-manager-node-next-manager-success", - "package-manager-node-entrypoint-refusal", - ]) { - const fixture = fixtures.find((entry) => entry.name === name); - if (!fixture) { - throw new Error(`Missing PowerShell fixture ${name}`); - } - const invocation = `$ErrorActionPreference = 'Stop'; & ([scriptblock]::Create((Get-Content -LiteralPath ${toPowerShellSingleQuotedLiteral(fixture.scriptPath)} -Raw)))`; - const engineResult = spawnSync(engine, ["-NoLogo", "-NoProfile", "-Command", invocation], { - encoding: "utf8", - }); - batchedPowerShellResults.set(`${name}:${engine}`, { - ok: engineResult.status === 0, - error: - engineResult.status === 0 - ? "" - : (engineResult.error?.message ?? engineResult.stdout + engineResult.stderr), - }); - } - } - }); - - function expectBatchedPowerShellCase(name: string): void { - expect(batchedPowerShellResults.get(name)).toEqual({ error: "", ok: true }); - } - - runIfPowerShell( - "renews legacy download watchdogs while bytes arrive and aborts stalled bodies", - async () => { - const server = http.createServer((request, response) => { - if (request.url === "/redirect") { - response.writeHead(302, { location: "/stream" }); - response.end(); - return; - } - response.writeHead(200, { "content-type": "application/octet-stream" }); - response.write("start"); - if (request.url === "/stall") { - const timer = setTimeout(() => response.end("late"), 3000); - response.once("close", () => clearTimeout(timer)); - return; - } - let chunks = 0; - const timer = setInterval(() => { - response.write("."); - if (++chunks === 4) { - response.end(); - } - }, 400); - response.once("close", () => clearInterval(timer)); - }); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", resolve); - }); - try { - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("Download fixture did not bind a TCP port"); - } - const directory = harness.createTempDir("openclaw-installer-network-"); - const output = join(directory, "download.bin"); - const scriptPath = join(directory, "download.ps1"); - writeFileSync( - scriptPath, - [ - "$ErrorActionPreference = 'Stop'", - "$script:UpdateNetworkTimeoutSeconds = 1", - ...["Get-WebRequestTimeoutParameters", "Save-InstallerDownload"].map( - (name) => `function ${name} {\n${extractFunctionBody(source, name)}}`, - ), - "function Invoke-WebRequest { throw 'Legacy downloads must stream directly' }", - `$output = ${toPowerShellSingleQuotedLiteral(output)}`, - `$base = 'http://127.0.0.1:${address.port}'`, - 'Save-InstallerDownload -Uri "$base/redirect" -OutFile $output', - "if ([IO.File]::ReadAllText($output) -ne 'start....') { throw 'Incomplete slow download' }", - "$failed = $false", - 'try { Save-InstallerDownload -Uri "$base/stall" -OutFile $output } catch { $failed = $true }', - "if (-not $failed) { throw 'Stalled download was accepted' }", - "$exclusive = [IO.File]::Open($output, 'Open', 'ReadWrite', 'None')", - "$exclusive.Dispose()", - "Write-Output 'slow-download-complete; stalled-download-aborted; file-released'", - ].join("\n"), - ); - const result = await runPowerShellAsync(["-NoLogo", "-NoProfile", "-File", scriptPath]); - expect(result.status, result.stdout + result.stderr).toBe(0); - expect(result.stdout).toContain( - "slow-download-complete; stalled-download-aborted; file-released", - ); - } finally { - server.closeAllConnections(); - await new Promise((resolve) => { - server.close(() => resolve()); - }); - } - }, - ); - - runIfPowerShell("rejects unknown and positional options before starting the installer", () => { - const cases = [ - ["-Frobnicate"], - ["-DryRnu"], - ["-NoOnbord"], - ["-InstallMthod", "git"], - ["-DryRun", "beta"], - ["beta", "git"], - ]; - - for (const args of cases) { - const result = runInstallerFile(args, { - OPENCLAW_DRY_RUN: "1", - OPENCLAW_NO_ONBOARD: "1", - }); - expect(result.status, args.join(" ")).not.toBe(0); - expect(`${result.stdout}\n${result.stderr}`).not.toContain("[OK] Windows detected"); - } - }); - - runIfPowerShell("validates environment options before starting the installer", () => { - const result = runInstallerFile(["-NoOnboard"], { - OPENCLAW_DRY_RUN: "1", - OPENCLAW_INSTALL_METHOD: "bogus", - }); - - expect(result.status).not.toBe(0); - expect(`${result.stdout}\n${result.stderr}`).not.toContain("[OK] Windows detected"); - }); - - runIfPowerShell("shows help without starting the installer", () => { - const fileResult = runInstallerFile(["-?"]); - expect(fileResult.status).toBe(0); - expect(`${fileResult.stdout}\n${fileResult.stderr}`).toContain("install.ps1"); - expect(`${fileResult.stdout}\n${fileResult.stderr}`).not.toContain("[OK] Windows detected"); - - const scriptPath = toPowerShellSingleQuotedLiteral(join(process.cwd(), SCRIPT_PATH)); - const scriptblockResult = runPowerShell([ - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-Command", - `& ([scriptblock]::Create((Get-Content -LiteralPath ${scriptPath} -Raw))) -Help`, - ]); - expect(scriptblockResult.status).toBe(0); - expect(scriptblockResult.stdout).toContain("Usage:"); - expect(scriptblockResult.stdout).toContain("-DryRun"); - expect(scriptblockResult.stdout).not.toContain("[OK] Windows detected"); - }); - - runIfPowerShell("accepts the documented named options", () => { - const result = runInstallerFile([ - "-DryRun", - "-NoOnboard", - "-InstallMethod", - "git", - "-NoGitUpdate", - "-Tag", - "main", - ]); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("[OK] Install method: git"); - expect(result.stdout).toContain("[OK] Git update: disabled"); - expect(result.stdout).toContain("[OK] Onboard: skipped"); - }); - - runIfPowerShell("requires an explicit absolute private prefix for Node-only updates", () => { - const root = harness.createTempDir("openclaw-node-only-options-"); - for (const args of [ - ["-NodeOnly"], - ["-NodeOnly", "-NodePrefix", "relative/node"], - ["-NodeOnly", "-NodePrefix", parse(root).root], - ["-NodePrefix", join(root, "private-node")], - ]) { - const result = runInstallerFile([...args, "-DryRun"]); - expect(result.status, args.join(" ")).toBe(2); - expect(result.stdout).toContain("Error:"); - } - const result = runInstallerFile([ - "-NodeOnly", - "-NodePrefix", - join(root, "private node"), - "-DryRun", - ]); - expect(result.status).toBe(0); - expect(result.stdout).toContain("PATH unchanged"); - expect(result.stdout).not.toContain("Install method:"); - }); - - runIfPowerShell( - "updates only the private runtime after checksum and compatibility checks", - () => { - expectBatchedPowerShellCase("private-node-update"); - }, - ); it("does not exit directly from inside Main", () => { const mainBody = extractFunctionBody(source, "Main"); @@ -1722,113 +45,6 @@ try { expect(source).toContain("Please install Node.js 26 manually:"); }); - runIfPowerShell("accepts only supported Node versions", () => { - expectBatchedPowerShellCase("node-versions"); - expectBatchedPowerShellCase("sqlite-versions"); - }); - - runIfPowerShell("requires the numeric floor and SQLite round trips before reusing Node", () => { - expectBatchedPowerShellCase("node-capabilities"); - }); - - runIfPowerShell("normalizes and exports one installer temp root", () => { - expectBatchedPowerShellCase("canonical-temp-root"); - }); - - runIfPowerShell("applies the canonical npm lifecycle version policy", () => { - expectBatchedPowerShellCase("npm-lifecycle-policy"); - }); - - runIfPowerShell( - "preserves native stderr and exit codes without softening PowerShell failures", - () => { - if (process.platform === "win32") { - expect(bootstrapShells).toContain("powershell"); - } - expectBatchedPowerShellCase("native-npm-stderr"); - for (const engine of bootstrapShells) { - if (engine !== powershell) { - expectBatchedPowerShellCase(`native-npm-stderr:${engine}`); - } - } - }, - ); - - runIfPowerShell("preserves explicit pnpm prefer-offline settings for Git installs", () => { - expectBatchedPowerShellCase("pnpm-prefer-offline-policy"); - }); - - (process.platform === "win32" ? it : it.skip)( - "scopes native pnpm children and restores the caller across source-install outcomes", - () => { - expect(bootstrapShells).toContain("powershell"); - for (const engine of bootstrapShells) { - const name = "pnpm-source-bootstrap-lifecycle"; - expectBatchedPowerShellCase(engine === powershell ? name : `${name}:${engine}`); - } - }, - ); - - (process.platform === "win32" ? it : it.skip)( - "reaches portable Node ZIP fallback after redirected native tar stderr", - () => { - expect(bootstrapShells).toContain("powershell"); - for (const engine of bootstrapShells) { - const name = "portable-node-tar-fallback"; - expectBatchedPowerShellCase(engine === powershell ? name : `${name}:${engine}`); - } - }, - ); - - runIfPowerShell("rejects npm success without a usable candidate package", () => { - expectBatchedPowerShellCase("npm-candidate-validation"); - }); - - runIfPowerShell("preserves the npm owner when a git replacement fails", () => { - expectBatchedPowerShellCase("method-switch-preservation"); - }); - - runIfPowerShell("restores or commits the same-prefix npm shim transaction", () => { - expectBatchedPowerShellCase("same-prefix-shim-transaction"); - }); - - runIfPowerShell("installs portable Git from multiple archive roots without collisions", () => { - if (process.platform === "win32") { - expect(bootstrapShells).toContain("powershell"); - } - expectBatchedPowerShellCase("portable-git-layout"); - for (const engine of bootstrapShells) { - if (engine !== powershell) { - expectBatchedPowerShellCase(`portable-git-layout:${engine}`); - } - } - }); - - runIfPowerShell("upgrades and validates Node installed by Windows package managers", () => { - expectBatchedPowerShellCase("winget-node-delayed-path"); - expectBatchedPowerShellCase("chocolatey-node-upgrade"); - expectBatchedPowerShellCase("scoop-node-update"); - expectBatchedPowerShellCase("package-manager-node-validation-failure"); - }); - - runIfPowerShell("recovers from package-manager failures and preserves installer refusal", () => { - if (process.platform === "win32") { - expect(bootstrapShells).toContain("powershell"); - } - for (const name of [ - "package-manager-node-command-failures", - "package-manager-node-next-manager-success", - "package-manager-node-entrypoint-refusal", - ]) { - expectBatchedPowerShellCase(name); - for (const engine of bootstrapShells) { - if (engine !== powershell) { - expectBatchedPowerShellCase(`${name}:${engine}`); - } - } - } - }); - it("discovers a winget Node install before the machine PATH refreshes", () => { const installNodeBody = extractFunctionBody(source, "Install-Node"); const packageManagerBody = extractFunctionBody(source, "Invoke-NodePackageManagerInstall"); @@ -1894,10 +110,6 @@ try { expect(gitInstallBody).toContain("Assert-GitCheckoutHasCommit -RepoDir $RepoDir"); }); - runIfPowerShell("publishes fresh Git clones transactionally", () => { - expectBatchedPowerShellCase("transactional-git-clone"); - }); - it("runs Windows command shims from a Windows-local cwd", () => { const commandSafeBody = extractFunctionBody(source, "Invoke-CommandFromWindowsSafeDirectory"); const npmCommandBody = extractFunctionBody(source, "Invoke-NpmCommand"); @@ -2093,14 +305,6 @@ try { ); }); - runIfPowerShell("selects native ARM64 MinGit when the release publishes it", () => { - expectBatchedPowerShellCase("native-arm64-git"); - }); - - runIfPowerShell("selects native ARM64 downloads when x64 PowerShell is emulated", () => { - expectBatchedPowerShellCase("emulated-arm64-downloads"); - }); - it("preserves git install budgets and guards with scoped pnpm selection", () => { const pnpmVersionBody = extractFunctionBody(source, "Get-RepoPnpmVersion"); const pnpmVersionMatchBody = extractFunctionBody(source, "Test-PnpmCommandMatchesVersion"); @@ -2219,444 +423,4 @@ try { expect(mainBody).toContain('Write-Host "Starting setup..." -ForegroundColor Cyan'); expect(mainBody).toContain("Invoke-InteractiveOpenClawCommand onboard"); }); - - runConcurrentIfPowerShell( - "fails install when interactive onboarding exits non-zero", - async () => { - const tempDir = mkdtempSync(join(tmpdir(), "openclaw-install-ps1-")); - const scriptPath = join(tempDir, "install.ps1"); - try { - const scriptWithoutEntryPoint = source.replace(ENTRYPOINT_RE, ""); - writeFileSync( - scriptPath, - [ - scriptWithoutEntryPoint, - "", - "function Write-Banner { }", - "function Ensure-ExecutionPolicy { return $true }", - "function Check-Node { return $true }", - "function Check-ExistingOpenClaw { return $false }", - "function Get-NpmCommandPath { return 'npm.cmd' }", - "function Invoke-NpmCommand {", - " param([string[]]$Arguments = @(), [string]$CommandPath, [string]$WorkingDirectory)", - " if ($Arguments[0] -eq 'config' -and $Arguments[2] -eq 'prefix') { Write-Output $env:USERPROFILE; $global:LASTEXITCODE = 0; return }", - " throw 'unexpected npm command'", - "}", - "function Install-OpenClaw { return $true }", - "function Ensure-OpenClawOnPath { return $true }", - "function Add-ToUserPath { param([string]$Path) }", - "function Get-OpenClawCommandPath { return 'cmd.exe' }", - "function Start-Process {", - " param([string]$FilePath, [string[]]$ArgumentList, [switch]$NoNewWindow, [switch]$Wait, [switch]$PassThru)", - " [pscustomobject]@{ ExitCode = 17 }", - "}", - "$InstallMethod = 'npm'", - "$NoOnboard = $false", - "", - ...extractEntrypointLines(source), - "", - ].join("\n"), - ); - chmodSync(scriptPath, 0o755); - - const result = await runPowerShellAsync([ - "-NoLogo", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - scriptPath, - ]); - - expect(result.status).toBe(1); - expect(`${result.stdout}\n${result.stderr}`).toContain( - "openclaw onboard failed with exit code 17", - ); - } finally { - rmSync(tempDir, { force: true, recursive: true }); - } - }, - ); - - runConcurrentIfPowerShell("exits non-zero when run as a script file", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "openclaw-install-ps1-")); - const scriptPath = join(tempDir, "install.ps1"); - try { - writeFileSync(scriptPath, createFailingNodeFixture(source)); - chmodSync(scriptPath, 0o755); - - const result = await runPowerShellAsync([ - "-NoLogo", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - scriptPath, - ]); - - expect(result.status).toBe(1); - } finally { - rmSync(tempDir, { force: true, recursive: true }); - } - }); - - runConcurrentIfPowerShell( - "exits zero after install succeeds with deferred PATH discovery", - async () => { - const tempDir = mkdtempSync(join(tmpdir(), "openclaw-install-ps1-")); - const scriptPath = join(tempDir, "install.ps1"); - try { - writeFileSync(scriptPath, createDeferredPathSuccessFixture(source)); - chmodSync(scriptPath, 0o755); - - const result = await runPowerShellAsync([ - "-NoLogo", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - scriptPath, - ]); - - expect(result.status).toBe(0); - expect(`${result.stdout}\n${result.stderr}`).not.toContain("installation failed"); - } finally { - rmSync(tempDir, { force: true, recursive: true }); - } - }, - ); - - runIfPowerShell("throws without killing the caller when run as a scriptblock", () => { - expectBatchedPowerShellCase("scriptblock-failure"); - }); - - runIfPowerShell("accepts deferred PATH discovery when run as a scriptblock", () => { - expectBatchedPowerShellCase("scriptblock-deferred-path-success"); - }); - - runIfPowerShell("treats noisy Git install false as failure", () => { - expectBatchedPowerShellCase("noisy-git-failure"); - }); - - runIfPowerShell("preserves larger old-space NODE_OPTIONS aliases", () => { - expectBatchedPowerShellCase("node-options"); - }); - - runIfPowerShell("keeps npm chatter out of Main's success return value", () => { - expectBatchedPowerShellCase("quiet-main-success"); - }); - - runIfPowerShell("uses the terminal exit code when helper output precedes success", () => { - expectBatchedPowerShellCase("terminal-code-success"); - }); -}); - -describe("install.ps1 stale Winget repair", () => { - const { createTempDir } = createScriptTestHarness(); - const source = readFileSync(SCRIPT_PATH, "utf8"); - const powershell = findPowerShell(); - const runIfPowerShell = powershell ? it : it.skip; - const cases = [ - { - name: "repairs stale registration after a probe overwrites LASTEXITCODE", - afterInstall: "text", - afterRepair: "healthy", - repair: true, - success: true, - }, - { - name: "discovers Node after repairing a missing runtime", - afterInstall: "missing", - afterRepair: "healthy", - repair: true, - success: true, - }, - { - name: "accepts a normal successful install without repair", - installExit: 0, - afterInstall: "healthy", - success: true, - }, - { - name: "accepts a healthy no-upgrade result without repair", - afterInstall: "healthy", - success: true, - }, - { name: "does not repair generic Winget failure", installExit: 1 }, - { name: "does not repair another HRESULT", installExit: -1978335188 }, - { name: "does not repair successful install with missing Node", installExit: 0 }, - { - name: "recovers unsupported repair through Chocolatey", - repairExit: -1978335174, - repair: true, - fallback: "choco", - success: true, - }, - { - name: "recovers failed repair through Scoop", - repairExit: 1, - repair: true, - fallback: "scoop", - success: true, - }, - { - name: "recovers unusable repair through portable Node", - afterRepair: "old-sqlite", - repair: true, - fallback: "portable", - success: true, - }, - { - name: "rejects unusable Chocolatey fallback after failed repair", - repairExit: 1, - repair: true, - fallback: "choco", - afterFallback: "old-sqlite", - }, - { - name: "rejects unusable portable fallback after failed repair", - repairExit: 1, - repair: true, - fallback: "portable", - afterFallback: "text", - }, - { - name: "recovers a generic Winget failure through portable Node", - installExit: 1, - fallback: "portable", - success: true, - }, - { - name: "recovers a thrown Winget invocation through portable Node", - installThrows: true, - fallback: "portable", - success: true, - }, - { - name: "recovers a generic Winget failure through the next package manager", - installExit: 1, - fallback: "choco", - success: true, - }, - { - name: "rejects failed repair even if Node becomes healthy", - repairExit: 1, - afterRepair: "healthy", - repair: true, - }, - { name: "rejects repair that leaves Node missing", repair: true }, - { name: "rejects old Node after repair", afterRepair: "old-node", repair: true }, - { name: "rejects old SQLite after repair", afterRepair: "old-sqlite", repair: true }, - ...["text", "blob", "json", "probe-error"].map((capability) => ({ - name: `rejects broken SQLite ${capability} after repair`, - afterRepair: capability, - repair: true, - })), - ]; - - runIfPowerShell.each(cases)("$name", (testCase) => { - if (!powershell) { - throw new Error("PowerShell is not available"); - } - const fixtureNode = join(createTempDir("openclaw-winget-node-"), "node.ps1"); - writeFileSync(fixtureNode, "$input | Invoke-FixtureNode @args\n"); - const options = { - installExit: -1978335189, - repairExit: 0, - afterInstall: "missing", - afterRepair: "missing", - repair: false, - success: false, - installThrows: false, - fallback: "none", - afterFallback: "healthy", - ...testCase, - }; - const functions = [ - "Fail-Install", - "Test-BooleanSuccessResult", - "Test-NodeVersionSupported", - "Test-NodeSqliteSupported", - "Check-Node", - "Invoke-NodePackageManagerInstall", - "Install-Node", - "Main", - ] - .map((name) => `function ${name} {\n${extractFunctionBody(source, name)}}`) - .join("\n"); - const fixture = [ - "$ErrorActionPreference = 'Stop'", - `$fixtureNode = ${toPowerShellSingleQuotedLiteral(fixtureNode)}`, - functions, - `$case = ${toPowerShellSingleQuotedLiteral(JSON.stringify(options))} | ConvertFrom-Json`, - String.raw` -function Reset-Fixture { - $global:State = 'missing' - $global:PendingState = 'missing' - $global:Events = New-Object 'System.Collections.Generic.List[string]' - $global:WingetCalls = New-Object 'System.Collections.Generic.List[object]' - $global:Fallbacks = New-Object 'System.Collections.Generic.List[string]' - $global:Messages = New-Object 'System.Collections.Generic.List[string]' - $global:InstallExitCode = 0 - $global:Advanced = 0 - $global:ProbeCount = 0 - $global:LASTEXITCODE = 0 -} -function Get-Command { - [CmdletBinding()] - param([string]$Name, [string]$CommandType) - if ($Name -eq 'winget') { return $true } - if ($Name -eq 'choco') { return ($case.fallback -eq 'choco') } - if ($Name -eq 'scoop') { return ($case.fallback -eq 'scoop') } - if ($Name -eq 'node') { - $global:Events.Add("check:$global:State") - if ($global:State -eq 'missing') { throw 'fixture Node is missing' } - return [pscustomobject]@{ Source = $fixtureNode } - } - throw "unexpected command lookup: $Name" -} -function Invoke-FixtureNode { - $global:LASTEXITCODE = 0 - if ($args[0] -eq '-v') { - if ($global:State -eq 'old-node') { return 'v22.15.0' } - return 'v26.1.0' - } - $probe = @($input) -join [Environment]::NewLine - if (-not $probe.Contains('CREATE TABLE probe') -or -not $probe.Contains('a\u0000b\u0000')) { - throw 'current SQLite capability probe was not executed' - } - $global:ProbeCount++ - if ($global:State -eq 'probe-error') { $global:LASTEXITCODE = 1; return } - $version = if ($global:State -eq 'old-sqlite') { '3.50.6' } else { '3.51.3' } - return (@{ available = $true; version = $version; text = ($global:State -ne 'text'); blob = ($global:State -ne 'blob'); json = ($global:State -ne 'json') } | ConvertTo-Json -Compress) -} -function winget { - $global:Events.Add($args[0]) - $global:WingetCalls.Add(@($args)) - if ($args[0] -eq 'install') { - if ($case.installThrows) { throw 'fixture Winget invocation failed' } - $global:LASTEXITCODE = $case.installExit - $global:PendingState = $case.afterInstall - } elseif ($args[0] -eq 'repair') { - $global:LASTEXITCODE = $case.repairExit - $global:PendingState = $case.afterRepair - } else { throw "unexpected Winget command: $args" } - Write-Output 'native command output must not become a Boolean result' -} -function Refresh-ProcessPath { $global:Events.Add('refresh') } -function Add-InstalledNodeToProcessPath { - $global:Events.Add('discover') - $global:State = $global:PendingState - return $true -} -function choco { - $global:Fallbacks.Add('choco') - $global:State = $case.afterFallback - $global:LASTEXITCODE = 0 - Write-Output 'Chocolatey output must not become a Boolean result' -} -function scoop { - $global:Fallbacks.Add("scoop:$($args -join ' ')") - $global:State = $case.afterFallback - $global:LASTEXITCODE = 0 - Write-Output 'Scoop output must not become a Boolean result' -} -function Write-Host { $global:Messages.Add(($args -join ' ')) } -function Install-PortableNode { - $global:Fallbacks.Add('portable') - if ($case.fallback -eq 'portable') { $global:State = $case.afterFallback; return } - throw 'fixture portable recovery unavailable' -} -function Check-ExistingOpenClaw { return $false } -function Test-PreviousGitWrapper { return $false } -function Get-NpmCommandPath { return 'fixture-npm' } -function Get-WindowsCommandSafeDirectory { return $env:USERPROFILE } -function Invoke-NpmCommand { return $env:USERPROFILE } -function Install-OpenClaw { $global:Advanced++; return $true } -function Ensure-OpenClawOnPath { return $false } -function Refresh-GatewayServiceIfLoaded { throw 'unexpected service mutation' } -$env:USERPROFILE = [System.IO.Path]::GetTempPath() -$InstallMethod = 'npm' -Reset-Fixture -$result = @(Install-Node) -if ($result.Count -ne 1 -or $result[0] -isnot [bool]) { throw "Install-Node output leaked: $result" } -$direct = @{ success = $result[0]; events = $global:Events.ToArray(); calls = $global:WingetCalls.ToArray(); probes = $global:ProbeCount; fallbacks = $global:Fallbacks.ToArray(); messages = $global:Messages.ToArray() } -Reset-Fixture -$null = Main -$main = @{ advanced = $global:Advanced; exit = $global:InstallExitCode; events = $global:Events.ToArray(); calls = $global:WingetCalls.ToArray(); probes = $global:ProbeCount; fallbacks = $global:Fallbacks.ToArray(); messages = $global:Messages.ToArray() } -Reset-Fixture -$global:State = 'healthy' -$null = Main -$healthy = @{ advanced = $global:Advanced; calls = $global:WingetCalls.Count } -Write-Output ('RESULT:' + (@{ direct = $direct; main = $main; healthy = $healthy } | ConvertTo-Json -Depth 8 -Compress)) -`, - ].join("\n"); - const result = spawnSync( - powershell, - ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", fixture], - { encoding: "utf8" }, - ); - expect(result.status, result.stderr || result.stdout).toBe(0); - const line = result.stdout.split(/\r?\n/u).find((value) => value.startsWith("RESULT:")); - expect(line, result.stdout).toBeDefined(); - const proof = JSON.parse(line!.slice("RESULT:".length)); - expect(proof.direct.success).toBe(options.success); - expect(proof.main.advanced).toBe(options.success ? 1 : 0); - expect(proof.main.exit).toBe(options.success ? 0 : 1); - expect(proof.healthy).toEqual({ advanced: 1, calls: 0 }); - const installArgs = [ - "install", - "OpenJS.NodeJS.LTS", - "--source", - "winget", - "--accept-package-agreements", - "--accept-source-agreements", - ]; - const repairArgs = [ - "repair", - "--id", - "OpenJS.NodeJS.LTS", - "--exact", - "--source", - "winget", - "--accept-package-agreements", - "--accept-source-agreements", - ]; - for (const run of [proof.direct, proof.main]) { - expect(run.calls).toEqual(options.repair ? [installArgs, repairArgs] : [installArgs]); - const repaired = - options.repair && options.repairExit === 0 && options.afterRepair === "healthy"; - const fallbackExpected = - !repaired && (options.installThrows || options.afterInstall !== "healthy"); - const fallbacks: string[] = []; - if (fallbackExpected) { - if (options.fallback === "choco") { - fallbacks.push("choco"); - } else if (options.fallback === "scoop") { - fallbacks.push("scoop:update", "scoop:install nodejs-lts", "scoop:update nodejs-lts"); - } - if (!["choco", "scoop"].includes(options.fallback) || options.afterFallback !== "healthy") { - fallbacks.push("portable"); - } - } - expect(run.fallbacks).toEqual(fallbacks); - expect( - run.messages.filter((message: string) => message.includes("Node.js repaired via winget")), - ).toHaveLength(repaired ? 1 : 0); - const events = run === proof.main ? run.events.slice(1) : run.events; - expect(events.slice(0, 4)).toEqual([ - "install", - "refresh", - "discover", - `check:${options.afterInstall}`, - ]); - if (options.repair) { - expect(events.slice(4, 7)).toEqual(["repair", "refresh", "discover"]); - } - if (options.success) { - expect(events.at(-1)).toBe("check:healthy"); - expect(run.probes).toBeGreaterThan(0); - } - } - }); });