blob: 46e55c1776941b2cb55ff7b8be3f520506724e88 [file]
#!/usr/bin/env node
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
// Test fork CI workflows with stubbed GitHub APIs. No API calls are made.
const assert = require("node:assert/strict");
const { execFileSync } = require("node:child_process");
const { readFileSync } = require("node:fs");
const path = require("node:path");
const { test } = require("node:test");
function loadWorkflow(name) {
return JSON.parse(
execFileSync(
"python3",
[
"-c",
"import json, sys, yaml; print(json.dumps(yaml.safe_load(sys.stdin)))",
],
{
input: readFileSync(
path.join(__dirname, "../../.github/workflows", name)
),
encoding: "utf8",
}
)
);
}
const script =
loadWorkflow("fork_ci_status.yml").jobs.bridge.steps[0].with.script;
const rustWorkflow = loadWorkflow("rust.yml");
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const bridge = new AsyncFunction("github", "context", "core", "Date", script);
const now = Date.UTC(2026, 0, 1);
class Clock extends Date {
static now() {
return now;
}
}
const sha = "a".repeat(40);
const pr = {
number: 1,
updated_at: new Date(now).toISOString(),
head: {
sha,
repo: { owner: { login: "contributor" }, name: "datafusion", fork: true },
},
base: { ref: "main" },
};
const run = (id, event = "push", conclusion = "success", run_attempt = 1) => ({
id,
event,
conclusion,
run_attempt,
status: "completed",
html_url: `https://github.com/contributor/datafusion/actions/runs/${id}`,
});
const checkFor = (run) => ({
id: 10,
status: run.status,
conclusion: run.conclusion,
output: {
text: JSON.stringify({
owner: "contributor",
repo: "datafusion",
head_sha: sha,
run_id: run.id,
run_attempt: run.run_attempt,
}),
},
});
async function execute({
check,
runs = [],
recordedRun,
event = "schedule",
workflowState = "active",
recentPrs = [pr],
slice = [pr],
calls = [],
}) {
const writes = [];
const github = {
rest: {
checks: {
listForRef: async () => {
calls.push("checks.listForRef");
return { data: { check_runs: check ? [check] : [] } };
},
create: async (payload) =>
writes.push({ method: "create", ...payload }),
update: async (payload) =>
writes.push({ method: "update", ...payload }),
},
actions: {
listWorkflowRuns: async (params) => {
calls.push("actions.listWorkflowRuns");
assert.equal(params.owner, "contributor");
assert.equal(params.repo, "datafusion");
assert.equal(params.workflow_id, "rust.yml");
assert.equal(params.head_sha, sha);
return { data: { workflow_runs: runs } };
},
getWorkflowRun: async () => {
if (!recordedRun) throw new Error("Not Found");
return { data: recordedRun };
},
getWorkflow: async () => ({ data: { state: workflowState } }),
},
pulls: {
list: async ({ per_page }) => {
if (per_page === 1) return { data: [pr], headers: {} };
assert.equal(per_page, 25);
return { data: slice };
},
},
},
paginate: {
iterator: async function* () {
yield { data: recentPrs };
},
},
};
await bridge(
github,
{
repo: { owner: "apache", repo: "datafusion" },
eventName: event,
payload: { pull_request: pr },
},
{ info: () => {} },
Clock
);
return writes;
}
function assertRun(write, method, expected) {
assert.equal(write.method, method);
assert.equal(write.status, expected.status);
assert.equal(write.conclusion, expected.conclusion);
assert.equal(write.details_url, expected.html_url);
assert.equal(JSON.parse(write.output.text).run_id, expected.id);
assert.equal(JSON.parse(write.output.text).run_attempt, expected.run_attempt);
}
test("notification selects a newer manual run instead of a push run", async () => {
const latest = run(2, "workflow_dispatch");
const writes = await execute({
event: "pull_request_target",
runs: [latest, run(1)],
});
assert.equal(writes.length, 1);
assertRun(writes[0], "create", latest);
});
for (const conclusion of ["cancelled", "failure"]) {
test(`sweep replaces a ${conclusion} push run with a newer manual run`, async () => {
const previous = run(1, "push", conclusion);
const latest = run(2, "workflow_dispatch");
const writes = await execute({
check: checkFor(previous),
recordedRun: previous,
runs: [latest, previous],
});
assert.equal(writes.length, 1);
assertRun(writes[0], "update", latest);
});
}
test("sweep recovers when the recorded run was deleted", async () => {
const latest = run(2, "workflow_dispatch");
const writes = await execute({ check: checkFor(run(1)), runs: [latest] });
assert.equal(writes.length, 1);
assertRun(writes[0], "update", latest);
});
test("sweep creates a missing check from the PR head", async () => {
const latest = run(2);
const writes = await execute({ runs: [latest] });
assert.equal(writes.length, 1);
assert.equal(writes[0].head_sha, sha);
assertRun(writes[0], "create", latest);
});
for (const [state, conclusion] of [
["active", "skipped"],
["disabled_fork", "action_required"],
]) {
test(`sweep creates a missing check when the workflow is ${state} and has no run`, async () => {
const writes = await execute({ workflowState: state });
assert.equal(writes.length, 1);
assert.equal(writes[0].method, "create");
assert.equal(writes[0].head_sha, sha);
assert.equal(writes[0].conclusion, conclusion);
assert.equal(JSON.parse(writes[0].output.text).run_id, null);
});
}
test("sweep picks up a rerun of the same workflow run", async () => {
const previous = run(1, "push", "failure");
const latest = run(1, "push", "success", 2);
const writes = await execute({
check: checkFor(previous),
recordedRun: latest,
runs: [latest],
});
assert.equal(writes.length, 1);
assertRun(writes[0], "update", latest);
});
test("sweep does not rewrite an unchanged check", async () => {
const latest = run(1);
assert.deepEqual(
await execute({
check: checkFor(latest),
recordedRun: latest,
runs: [latest],
}),
[]
);
});
for (const conclusion of ["success", "failure", "cancelled"]) {
test(`recent completed ${conclusion} checks wait for their sweep slice`, async () => {
const calls = [];
const previous = run(1, "push", conclusion);
const writes = await execute({
check: checkFor(previous),
runs: [run(2, "workflow_dispatch")],
slice: [],
calls,
});
assert.deepEqual(writes, []);
assert.deepEqual(calls, ["checks.listForRef"]);
});
}
test("the sweep finds a rerun on a recently updated completed PR", async () => {
const calls = [];
const latest = run(2, "workflow_dispatch");
const writes = await execute({
check: checkFor(run(1)),
runs: [latest],
calls,
});
assert.equal(writes.length, 1);
assertRun(writes[0], "update", latest);
assert.deepEqual(calls, ["checks.listForRef", "actions.listWorkflowRuns"]);
});
test("the sweep still checks completed runs on older PRs", async () => {
const oldPr = {
...pr,
updated_at: new Date(now - 24 * 3600 * 1000).toISOString(),
};
const latest = run(2, "workflow_dispatch");
const writes = await execute({
check: checkFor(run(1)),
runs: [latest],
recentPrs: [oldPr],
slice: [oldPr],
});
assert.equal(writes.length, 1);
assertRun(writes[0], "update", latest);
});
for (const status of ["queued", "in_progress"]) {
test(`recent ${status} runs are polled outside the sweep slice`, async () => {
const previous = { ...run(1), status, conclusion: null };
const latest = run(1);
const writes = await execute({
check: checkFor(previous),
runs: [latest],
slice: [],
});
assert.equal(writes.length, 1);
assertRun(writes[0], "update", latest);
});
}
test("recent checks with no fork run keep looking outside the sweep slice", async () => {
const missing = checkFor(run(null, "push", "skipped"));
const latest = run(2);
const writes = await execute({ check: missing, runs: [latest], slice: [] });
assert.equal(writes.length, 1);
assertRun(writes[0], "update", latest);
});
test("recent missing checks are recovered outside the sweep slice", async () => {
const latest = run(2);
const writes = await execute({ runs: [latest], slice: [] });
assert.equal(writes.length, 1);
assertRun(writes[0], "create", latest);
});
test("a recent active PR in the sweep slice is only polled once", async () => {
const calls = [];
const active = { ...run(1), status: "in_progress", conclusion: null };
await execute({ check: checkFor(active), runs: [active], calls });
assert.deepEqual(calls, ["checks.listForRef", "actions.listWorkflowRuns"]);
});
// The concurrency expressions use string lookups and &&/||, whose semantics
// match JavaScript for these non-empty strings and booleans.
function concurrencyGroup(github) {
return rustWorkflow.concurrency.group.replace(
/\$\{\{\s*(.*?)\s*\}\}/g,
(_, expression) => new Function("github", `return (${expression});`)(github)
);
}
const forkPush = {
repository: "contributor/datafusion",
head_ref: "",
sha: "first",
ref: "refs/heads/feature",
workflow: "Rust",
};
test("successive pushes to a fork branch cancel the previous run", () => {
assert.equal(rustWorkflow.concurrency["cancel-in-progress"], true);
assert.equal(
concurrencyGroup(forkPush),
concurrencyGroup({ ...forkPush, sha: "second" })
);
});
test("different fork branches have separate concurrency groups", () => {
assert.notEqual(
concurrencyGroup(forkPush),
concurrencyGroup({ ...forkPush, ref: "refs/heads/other" })
);
});
for (const ref of [
"refs/heads/main",
"refs/heads/gh-readonly-queue/main/pr-1",
]) {
test(`upstream ${ref} runs keep separate groups for each SHA`, () => {
const upstream = { ...forkPush, repository: "apache/datafusion", ref };
assert.notEqual(
concurrencyGroup(upstream),
concurrencyGroup({ ...upstream, sha: "second" })
);
});
}
test("upstream PR updates still share a concurrency group", () => {
const upstream = {
...forkPush,
repository: "apache/datafusion",
head_ref: "feature",
ref: "refs/pull/1/merge",
};
assert.equal(
concurrencyGroup(upstream),
concurrencyGroup({ ...upstream, sha: "second" })
);
});