import { spawnSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); const dispatchScript = join(process.cwd(), "scripts/pr-lib/ci-dispatch.mjs"); const sha = "0123456789abcdef0123456789abcdef01234567"; const changedSha = "fedcba9876543210fedcba9876543210fedcba98"; const describePosix = process.platform === "win32" ? describe.skip : describe; function createFakeGh() { const tempDir = tempDirs.make("openclaw-pr-ci-dispatch-"); const binDir = join(tempDir, "bin"); const pathGh = join(binDir, "gh"); const realGh = join(tempDir, "real-gh"); const calls = join(tempDir, "calls.log"); const dispatched = join(tempDir, "dispatched"); const seenRunList = join(tempDir, "seen-run-list"); mkdirSync(binDir); const fakeGhScript = `#!/usr/bin/env bash set -euo pipefail printf '%s\\t%s\\n' "$(basename "$0")" "$*" >> "$OPENCLAW_TEST_GH_CALLS" case "$1 $2" in "run list") if [ "\${OPENCLAW_TEST_GH_MODE:-}" = "pending-head-change" ]; then printf '[]\\n' elif [ -e "$OPENCLAW_TEST_GH_SEEN_RUN_LIST" ]; then printf '[{"databaseId":99,"url":"https://github.com/openclaw/openclaw/actions/runs/99","headSha":"%s","createdAt":"2026-01-01T00:00:00Z","status":"queued"}]\\n' "$OPENCLAW_TEST_HEAD_SHA" else : > "$OPENCLAW_TEST_GH_SEEN_RUN_LIST" printf '[]\\n' fi ;; "pr view") if [ -e "$OPENCLAW_TEST_GH_DISPATCHED" ] && [ -n "\${OPENCLAW_TEST_GH_MODE:-}" ]; then printf '%s\\n' "$OPENCLAW_TEST_CHANGED_HEAD_SHA" else printf '%s\\n' "$OPENCLAW_TEST_HEAD_SHA" fi ;; "workflow run") : > "$OPENCLAW_TEST_GH_DISPATCHED" ;; *) echo "unexpected gh invocation: $*" >&2; exit 2 ;; esac `; writeFileSync(pathGh, fakeGhScript); writeFileSync(realGh, fakeGhScript); chmodSync(pathGh, 0o755); chmodSync(realGh, 0o755); return { binDir, calls, dispatched, realGh, seenRunList }; } function runDispatch( fakeGh: ReturnType, options: { mode?: "observed-head-change" | "pending-head-change"; immediateTimers?: boolean; cwd?: string; } = {}, ) { let nodeOptions = process.env.NODE_OPTIONS ?? ""; if (options.immediateTimers) { const preload = join(tempDirs.make("openclaw-pr-ci-dispatch-timers-"), "immediate-timers.cjs"); writeFileSync(preload, "global.setTimeout = (callback) => { callback(); return 0; };\n"); nodeOptions = `${nodeOptions} --require ${preload}`.trim(); } return spawnSync( process.execPath, [dispatchScript, "12345", "contributor/fix-hosted-gates", sha, "false"], { cwd: options.cwd, encoding: "utf8", env: { ...process.env, NODE_OPTIONS: nodeOptions, OPENCLAW_GH_BIN: fakeGh.realGh, OPENCLAW_TEST_CHANGED_HEAD_SHA: changedSha, OPENCLAW_TEST_GH_CALLS: fakeGh.calls, OPENCLAW_TEST_GH_DISPATCHED: fakeGh.dispatched, OPENCLAW_TEST_GH_MODE: options.mode ?? "", OPENCLAW_TEST_GH_SEEN_RUN_LIST: fakeGh.seenRunList, OPENCLAW_TEST_HEAD_SHA: sha, PATH: `${fakeGh.binDir}:${process.env.PATH ?? ""}`, }, }, ); } describePosix("scripts/pr ci-dispatch", () => { it("warns when a same-named local branch points away from the dispatched remote head", () => { const repo = tempDirs.make("openclaw-pr-ci-dispatch-repo-"); const git = (...args: string[]) => spawnSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); git("init", "-q", "-b", "main"); git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-q", "-m", "x"); git("branch", "contributor/fix-hosted-gates"); const fakeGh = createFakeGh(); const result = runDispatch(fakeGh, { cwd: repo }); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); expect(result.stderr).toContain("warning: local branch contributor/fix-hosted-gates is at"); expect(result.stderr).toContain(`remote head ${sha}`); }); it("stays silent when no same-named local branch exists", () => { const repo = tempDirs.make("openclaw-pr-ci-dispatch-repo-"); spawnSync("git", ["init", "-q", "-b", "main"], { cwd: repo, encoding: "utf8" }); const fakeGh = createFakeGh(); const result = runDispatch(fakeGh, { cwd: repo }); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); expect(result.stderr).not.toContain("warning: local branch"); }); it("dispatches the exact CI workflow for the remote PR head", () => { const fakeGh = createFakeGh(); const result = runDispatch(fakeGh); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); expect(result.stdout).toContain( "observed_run_url=https://github.com/openclaw/openclaw/actions/runs/99", ); const calls = readFileSync(fakeGh.calls, "utf8"); const callLines = calls.trim().split("\n"); expect(callLines).toContain( `real-gh\tworkflow run ci.yml --ref contributor/fix-hosted-gates -f target_ref=${sha} -f release_gate=true -f pull_request_number=12345`, ); expect(callLines.some((call) => call.startsWith(`gh\trun list --commit ${sha}`))).toBe(true); expect(callLines.some((call) => call.startsWith("gh\tpr view 12345"))).toBe(true); expect(callLines.some((call) => /^real-gh\t(?:run list|pr view)/u.test(call))).toBe(false); }); it("refuses a fork-local branch name before invoking GitHub", () => { const fakeGh = createFakeGh(); const result = spawnSync( process.execPath, [dispatchScript, "12345", "fix-hosted-gates", sha, "true"], { encoding: "utf8", env: { ...process.env, OPENCLAW_GH_BIN: fakeGh.realGh, OPENCLAW_TEST_GH_CALLS: fakeGh.calls, OPENCLAW_TEST_GH_DISPATCHED: fakeGh.dispatched, OPENCLAW_TEST_GH_SEEN_RUN_LIST: fakeGh.seenRunList, OPENCLAW_TEST_HEAD_SHA: sha, PATH: `${fakeGh.binDir}:${process.env.PATH ?? ""}`, }, }, ); expect(result.status).not.toBe(0); expect(`${result.stdout}\n${result.stderr}`).toMatch(/comes from a fork/u); expect(existsSync(fakeGh.calls)).toBe(false); }); it("fails closed if the remote head changes while CI run indexing is pending", () => { const result = runDispatch(createFakeGh(), { immediateTimers: true, mode: "pending-head-change", }); expect(result.status).not.toBe(0); expect(`${result.stdout}\n${result.stderr}`).toMatch( /head changed while CI dispatch was being indexed/u, ); }); it("rechecks the remote head before returning an observed exact-SHA run", () => { const result = runDispatch(createFakeGh(), { mode: "observed-head-change" }); expect(result.status).not.toBe(0); expect(`${result.stdout}\n${result.stderr}`).toMatch( /head changed before an exact-SHA CI run became visible/u, ); }); });