diff --git a/test/scripts/pr-ci-sweeper.reopen-timer.test.ts b/test/scripts/pr-ci-sweeper.reopen-timer.test.ts new file mode 100644 index 000000000000..20ccd807bebb --- /dev/null +++ b/test/scripts/pr-ci-sweeper.reopen-timer.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { runPrCiSweeper } from "../../scripts/github/pr-ci-sweeper.mjs"; +import { NOW, context, fakeGithub, pr, recordingCore } from "./pr-ci-sweeper.test-support.js"; + +describe("runPrCiSweeper", () => { + it("closes and reopens a dropped-CI PR without spending budget on stale heads", async () => { + const dropped = Array.from({ length: 11 }, (_, index) => ({ + ...pr(), + number: 200 + index, + state: "open", + head: { sha: index.toString(16).padStart(2, "0").repeat(20) }, + })); + const pullsGetByNumber = Object.fromEntries( + dropped + .slice(0, 10) + .map((candidate) => [ + candidate.number, + [candidate, { ...candidate, head: { sha: "f".repeat(40) } }], + ]), + ); + const { github, calls } = fakeGithub({ prs: dropped, runsBySha: {}, pullsGetByNumber }); + const { core: loggedCore, logs } = recordingCore(); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const operation = runPrCiSweeper({ + github: github as never, + context: context as never, + core: loggedCore as never, + appSlug: "openclaw-barnacle", + now: NOW, + }); + void operation.catch(() => {}); + try { + await vi.advanceTimersByTimeAsync(0); + expect(calls.at(-1)).toEqual({ + method: "pulls.update", + args: { owner: "openclaw", repo: "openclaw", pull_number: 210, state: "closed" }, + }); + await vi.advanceTimersByTimeAsync(4_999); + expect( + calls.filter((call) => call.method === "pulls.update").map((call) => call.args.state), + ).toEqual(["closed"]); + await vi.advanceTimersByTimeAsync(1); + const results = await operation; + + expect(results).toHaveLength(dropped.length); + expect(results.slice(0, 10)).toEqual( + dropped.slice(0, 10).map((candidate) => ({ + number: candidate.number, + sha: candidate.head.sha.slice(0, 12), + action: "skip", + reason: "changed-during-sweep", + })), + ); + expect(results.at(-1)).toEqual({ + number: 210, + sha: "0a".repeat(6), + action: "refire", + reason: "ci-run-missing", + }); + expect( + calls.filter((call) => call.method === "pulls.update").map((call) => call.args), + ).toEqual([ + { owner: "openclaw", repo: "openclaw", pull_number: 210, state: "closed" }, + { owner: "openclaw", repo: "openclaw", pull_number: 210, state: "open" }, + ]); + expect(logs.at(-1)).toContain("1 re-fire"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/test/scripts/pr-ci-sweeper.test-support.ts b/test/scripts/pr-ci-sweeper.test-support.ts new file mode 100644 index 000000000000..512f8525edbb --- /dev/null +++ b/test/scripts/pr-ci-sweeper.test-support.ts @@ -0,0 +1,211 @@ +import type { + classifyPrForSweep, + classifyRunForRevive, +} from "../../scripts/github/pr-ci-sweeper.mjs"; + +export const NOW = Date.parse("2026-07-18T12:00:00Z"); +export const MINUTES = 60 * 1000; +export const HOURS = 60 * MINUTES; + +export function pr(overrides: Partial[0]["pr"]> = {}) { + return { + draft: false, + created_at: new Date(NOW - 2 * HOURS).toISOString(), + updated_at: new Date(NOW - 30 * MINUTES).toISOString(), + mergeable: true, + auto_merge: null, + ...overrides, + }; +} + +type FakeCall = { method: string; args: Record }; +type FakeWorkflowRun = Parameters[0]["run"] & { + id: number; + workflow_id: number | null; +}; +type FakeCheckRun = { + id: number; + name: string; + status?: string; + conclusion: string | null; + app: { slug: string } | null; + details_url: string | null | undefined; +}; + +export function fakeGithub(options: { + prs: Array>; + runsBySha: Record< + string, + Array<{ conclusion: string | null; event?: string; id?: number; status?: string }> + >; + checksByRef?: Record; + workflowRunsById?: Record; + workflowRunErrorsById?: Record; + pullsGetByNumber?: Record | Array>>; + events?: Array>; + pageSize?: number; +}) { + const calls: FakeCall[] = []; + const pullsGetCallCounts = new Map(); + const checksListCallCounts = new Map(); + const record = (method: string, args: Record) => { + calls.push({ method, args }); + }; + const github = { + paginate: ( + endpoint: { endpointName: string }, + args: Record, + mapFn?: (response: { data: unknown[] }, done: () => void) => unknown[], + ) => { + record(endpoint.endpointName, args); + // Emulate octokit's paged mapFn contract: the page where done() fires is + // still included in the result, and later pages are never fetched. + const paged = (items: unknown[]) => { + if (!mapFn) { + return Promise.resolve(items); + } + const pageSize = options.pageSize ?? Math.max(items.length, 1); + const collected: unknown[] = []; + let stopped = false; + for (let start = 0; start < items.length; start += pageSize) { + record(`${endpoint.endpointName}.page`, { start }); + collected.push( + ...mapFn({ data: items.slice(start, start + pageSize) }, () => { + stopped = true; + }), + ); + if (stopped) { + break; + } + } + return Promise.resolve(collected); + }; + if (endpoint.endpointName === "pulls.list") { + return paged(options.prs); + } + if (endpoint.endpointName === "actions.listWorkflowRuns") { + return Promise.resolve( + Array.from(options.runsBySha[args.head_sha as string] ?? [], (run) => ({ + ...run, + event: run.event ?? "pull_request", + })).filter((run) => !args.event || run.event === args.event), + ); + } + if (endpoint.endpointName === "checks.listForRef") { + const ref = args.ref as string; + const configured = options.checksByRef?.[ref] ?? []; + if (Array.isArray(configured[0])) { + const snapshots = configured as FakeCheckRun[][]; + const callIndex = checksListCallCounts.get(ref) ?? 0; + checksListCallCounts.set(ref, callIndex + 1); + return Promise.resolve(snapshots[Math.min(callIndex, snapshots.length - 1)] ?? []); + } + return Promise.resolve(configured as FakeCheckRun[]); + } + if (endpoint.endpointName === "issues.listEvents") { + return Promise.resolve(options.events ?? []); + } + throw new Error(`unexpected paginate ${endpoint.endpointName}`); + }, + rest: { + pulls: { + list: { endpointName: "pulls.list" }, + get: (args: Record) => { + record("pulls.get", args); + const pullNumber = args.pull_number as number; + const configured = options.pullsGetByNumber?.[pullNumber]; + const callIndex = pullsGetCallCounts.get(pullNumber) ?? 0; + pullsGetCallCounts.set(pullNumber, callIndex + 1); + const match = Array.isArray(configured) + ? configured[Math.min(callIndex, configured.length - 1)] + : (configured ?? options.prs.find((entry) => entry.number === pullNumber)); + return Promise.resolve({ data: match }); + }, + update: (args: Record) => { + record("pulls.update", args); + return Promise.resolve({}); + }, + }, + actions: { + listWorkflowRuns: { endpointName: "actions.listWorkflowRuns" }, + getWorkflowRun: (args: Record) => { + record("actions.getWorkflowRun", args); + const runId = args.run_id as number; + const error = options.workflowRunErrorsById?.[runId]; + if (error) { + return Promise.reject(error); + } + return Promise.resolve({ data: options.workflowRunsById?.[runId] }); + }, + reRunWorkflow: (args: Record) => { + record("actions.reRunWorkflow", args); + return Promise.resolve({}); + }, + }, + checks: { listForRef: { endpointName: "checks.listForRef" } }, + issues: { + listEvents: { endpointName: "issues.listEvents" }, + createComment: (args: Record) => { + record("issues.createComment", args); + return Promise.resolve({}); + }, + }, + }, + }; + return { github, calls }; +} + +export const context = { repo: { owner: "openclaw", repo: "openclaw" } }; +export const core = { info: () => {}, setFailed: () => {} }; + +export function recordingCore() { + const logs: string[] = []; + return { + core: { + info: (message: string) => logs.push(message), + setFailed: () => {}, + }, + logs, + }; +} + +export function autoMergePr(number: number, headSha: string) { + return { + ...pr({ auto_merge: { merge_method: "squash" } }), + number, + state: "open", + head: { sha: headSha, ref: "automation/refresh" }, + }; +} + +export function githubActionsCheck( + runId: number, + overrides: Partial = {}, +): FakeCheckRun { + return { + id: runId, + name: "proof", + conclusion: "cancelled", + status: "completed", + app: { slug: "github-actions" }, + details_url: `https://github.com/openclaw/openclaw/actions/runs/${runId}/job/456`, + ...overrides, + }; +} + +export function cancelledRun( + runId: number, + overrides: Partial = {}, +): FakeWorkflowRun { + return { + id: runId, + workflow_id: 10, + conclusion: "cancelled", + event: "pull_request_target", + run_attempt: 1, + created_at: new Date(NOW - HOURS).toISOString(), + head_branch: "automation/refresh", + head_repository: { full_name: "openclaw/openclaw" }, + ...overrides, + }; +} diff --git a/test/scripts/pr-ci-sweeper.test.ts b/test/scripts/pr-ci-sweeper.test.ts index 52b8d304e9b7..ceba4380ea55 100644 --- a/test/scripts/pr-ci-sweeper.test.ts +++ b/test/scripts/pr-ci-sweeper.test.ts @@ -4,21 +4,19 @@ import { classifyRunForRevive, runPrCiSweeper, } from "../../scripts/github/pr-ci-sweeper.mjs"; - -const NOW = Date.parse("2026-07-18T12:00:00Z"); -const MINUTES = 60 * 1000; -const HOURS = 60 * MINUTES; - -function pr(overrides: Partial[0]["pr"]> = {}) { - return { - draft: false, - created_at: new Date(NOW - 2 * HOURS).toISOString(), - updated_at: new Date(NOW - 30 * MINUTES).toISOString(), - mergeable: true, - auto_merge: null, - ...overrides, - }; -} +import { + HOURS, + MINUTES, + NOW, + autoMergePr, + cancelledRun, + context, + core, + fakeGithub, + githubActionsCheck, + pr, + recordingCore, +} from "./pr-ci-sweeper.test-support.js"; describe("classifyPrForSweep", () => { const cases: Array<{ @@ -196,192 +194,6 @@ describe("classifyRunForRevive", () => { ); }); -type FakeCall = { method: string; args: Record }; -type FakeWorkflowRun = Parameters[0]["run"] & { - id: number; - workflow_id: number | null; -}; -type FakeCheckRun = { - id: number; - name: string; - status?: string; - conclusion: string | null; - app: { slug: string } | null; - details_url: string | null | undefined; -}; - -function fakeGithub(options: { - prs: Array>; - runsBySha: Record< - string, - Array<{ conclusion: string | null; event?: string; id?: number; status?: string }> - >; - checksByRef?: Record; - workflowRunsById?: Record; - workflowRunErrorsById?: Record; - pullsGetByNumber?: Record | Array>>; - events?: Array>; - pageSize?: number; -}) { - const calls: FakeCall[] = []; - const pullsGetCallCounts = new Map(); - const checksListCallCounts = new Map(); - const record = (method: string, args: Record) => { - calls.push({ method, args }); - }; - const github = { - paginate: ( - endpoint: { endpointName: string }, - args: Record, - mapFn?: (response: { data: unknown[] }, done: () => void) => unknown[], - ) => { - record(endpoint.endpointName, args); - // Emulate octokit's paged mapFn contract: the page where done() fires is - // still included in the result, and later pages are never fetched. - const paged = (items: unknown[]) => { - if (!mapFn) { - return Promise.resolve(items); - } - const pageSize = options.pageSize ?? Math.max(items.length, 1); - const collected: unknown[] = []; - let stopped = false; - for (let start = 0; start < items.length; start += pageSize) { - record(`${endpoint.endpointName}.page`, { start }); - collected.push( - ...mapFn({ data: items.slice(start, start + pageSize) }, () => { - stopped = true; - }), - ); - if (stopped) { - break; - } - } - return Promise.resolve(collected); - }; - if (endpoint.endpointName === "pulls.list") { - return paged(options.prs); - } - if (endpoint.endpointName === "actions.listWorkflowRuns") { - return Promise.resolve( - Array.from(options.runsBySha[args.head_sha as string] ?? [], (run) => ({ - ...run, - event: run.event ?? "pull_request", - })).filter((run) => !args.event || run.event === args.event), - ); - } - if (endpoint.endpointName === "checks.listForRef") { - const ref = args.ref as string; - const configured = options.checksByRef?.[ref] ?? []; - if (Array.isArray(configured[0])) { - const snapshots = configured as FakeCheckRun[][]; - const callIndex = checksListCallCounts.get(ref) ?? 0; - checksListCallCounts.set(ref, callIndex + 1); - return Promise.resolve(snapshots[Math.min(callIndex, snapshots.length - 1)] ?? []); - } - return Promise.resolve(configured as FakeCheckRun[]); - } - if (endpoint.endpointName === "issues.listEvents") { - return Promise.resolve(options.events ?? []); - } - throw new Error(`unexpected paginate ${endpoint.endpointName}`); - }, - rest: { - pulls: { - list: { endpointName: "pulls.list" }, - get: (args: Record) => { - record("pulls.get", args); - const pullNumber = args.pull_number as number; - const configured = options.pullsGetByNumber?.[pullNumber]; - const callIndex = pullsGetCallCounts.get(pullNumber) ?? 0; - pullsGetCallCounts.set(pullNumber, callIndex + 1); - const match = Array.isArray(configured) - ? configured[Math.min(callIndex, configured.length - 1)] - : (configured ?? options.prs.find((entry) => entry.number === pullNumber)); - return Promise.resolve({ data: match }); - }, - update: (args: Record) => { - record("pulls.update", args); - return Promise.resolve({}); - }, - }, - actions: { - listWorkflowRuns: { endpointName: "actions.listWorkflowRuns" }, - getWorkflowRun: (args: Record) => { - record("actions.getWorkflowRun", args); - const runId = args.run_id as number; - const error = options.workflowRunErrorsById?.[runId]; - if (error) { - return Promise.reject(error); - } - return Promise.resolve({ data: options.workflowRunsById?.[runId] }); - }, - reRunWorkflow: (args: Record) => { - record("actions.reRunWorkflow", args); - return Promise.resolve({}); - }, - }, - checks: { listForRef: { endpointName: "checks.listForRef" } }, - issues: { - listEvents: { endpointName: "issues.listEvents" }, - createComment: (args: Record) => { - record("issues.createComment", args); - return Promise.resolve({}); - }, - }, - }, - }; - return { github, calls }; -} - -const context = { repo: { owner: "openclaw", repo: "openclaw" } }; -const core = { info: () => {}, setFailed: () => {} }; - -function recordingCore() { - const logs: string[] = []; - return { - core: { - info: (message: string) => logs.push(message), - setFailed: () => {}, - }, - logs, - }; -} - -function autoMergePr(number: number, headSha: string) { - return { - ...pr({ auto_merge: { merge_method: "squash" } }), - number, - state: "open", - head: { sha: headSha, ref: "automation/refresh" }, - }; -} - -function githubActionsCheck(runId: number, overrides: Partial = {}): FakeCheckRun { - return { - id: runId, - name: "proof", - conclusion: "cancelled", - status: "completed", - app: { slug: "github-actions" }, - details_url: `https://github.com/openclaw/openclaw/actions/runs/${runId}/job/456`, - ...overrides, - }; -} - -function cancelledRun(runId: number, overrides: Partial = {}): FakeWorkflowRun { - return { - id: runId, - workflow_id: 10, - conclusion: "cancelled", - event: "pull_request_target", - run_attempt: 1, - created_at: new Date(NOW - HOURS).toISOString(), - head_branch: "automation/refresh", - head_repository: { full_name: "openclaw/openclaw" }, - ...overrides, - }; -} - describe("runPrCiSweeper", () => { it("classifies a dropped-CI PR as refire in dry-run without mutating", async () => { const dropped = { @@ -546,56 +358,6 @@ describe("runPrCiSweeper", () => { ).toEqual([]); }); - it("closes and reopens a dropped-CI PR without spending budget on stale heads", async () => { - const dropped = Array.from({ length: 11 }, (_, index) => ({ - ...pr(), - number: 200 + index, - state: "open", - head: { sha: index.toString(16).padStart(2, "0").repeat(20) }, - })); - const pullsGetByNumber = Object.fromEntries( - dropped - .slice(0, 10) - .map((candidate) => [ - candidate.number, - [candidate, { ...candidate, head: { sha: "f".repeat(40) } }], - ]), - ); - const { github, calls } = fakeGithub({ prs: dropped, runsBySha: {}, pullsGetByNumber }); - const { core: loggedCore, logs } = recordingCore(); - - const results = await runPrCiSweeper({ - github: github as never, - context: context as never, - core: loggedCore as never, - appSlug: "openclaw-barnacle", - now: NOW, - }); - - expect(results).toHaveLength(dropped.length); - expect(results.slice(0, 10)).toEqual( - dropped.slice(0, 10).map((candidate) => ({ - number: candidate.number, - sha: candidate.head.sha.slice(0, 12), - action: "skip", - reason: "changed-during-sweep", - })), - ); - expect(results.at(-1)).toEqual({ - number: 210, - sha: "0a".repeat(6), - action: "refire", - reason: "ci-run-missing", - }); - expect(calls.filter((call) => call.method === "pulls.update").map((call) => call.args)).toEqual( - [ - { owner: "openclaw", repo: "openclaw", pull_number: 210, state: "closed" }, - { owner: "openclaw", repo: "openclaw", pull_number: 210, state: "open" }, - ], - ); - expect(logs.at(-1)).toContain("1 re-fire"); - }); - it("stops listing pages once creation dates cross the lookback", async () => { const recent = { ...pr(), number: 30, state: "open", head: { sha: "7".repeat(40) } }; const oldA = { diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index acfc02afb231..05629a247ec5 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -2533,7 +2533,7 @@ describe("scripts/test-projects changed-target routing", () => { ["chunks broad shell helper globs after isolated targets", "test/scripts/*.test.ts"], ])("%s", (_title, target) => { const plans = buildVitestRunPlans([target], process.cwd()); - expect(plans.slice(0, 4)).toEqual([ + expect(plans.slice(0, 5)).toEqual([ expect.objectContaining({ config: "test/vitest/vitest.unit-fast.config.ts", includePatterns: expect.arrayContaining(["test/scripts/arg-utils.test.ts"]), @@ -2549,6 +2549,12 @@ describe("scripts/test-projects changed-target routing", () => { ], watchMode: false, }, + { + config: "test/vitest/vitest.unit-fast-fake-timers.config.ts", + forwardedArgs: [], + includePatterns: ["test/scripts/pr-ci-sweeper.reopen-timer.test.ts"], + watchMode: false, + }, { config: "test/vitest/vitest.tooling-docker.config.ts", forwardedArgs: [], @@ -2571,7 +2577,7 @@ describe("scripts/test-projects changed-target routing", () => { ]); const e2ePlans = plans.filter((plan) => plan.config === "test/vitest/vitest.e2e.config.ts"); const toolingPlans = plans - .slice(4) + .slice(5) .filter((plan) => plan.config === "test/vitest/vitest.tooling.config.ts"); const toolingTargets = toolingPlans.flatMap((plan) => plan.includePatterns ?? []); diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index 2181b2a5cbb1..014490c4eddd 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -149,6 +149,7 @@ export const forcedUnitFastTestFiles = [ "src/test-utils/temp-home.test.ts", "src/utils.test.ts", "src/version.test.ts", + "test/scripts/pr-ci-sweeper.reopen-timer.test.ts", ]; const forcedUnitFastTestFileSet = new Set(forcedUnitFastTestFiles); const unitFastCandidateExactFiles = [...pluginSdkLightTestFiles, ...commandsLightTestFiles];