mirror of
https://github.com/expo/explore.git
synced 2026-11-15 03:48:15 -10:00
244 lines
18 KiB
JavaScript
244 lines
18 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter, configure } from "@zip.js/zip.js";
|
|
|
|
const { waitForBaselineArtifact, awaitPendingBaseline } = await import("../../dist/ci/baseline-wait.js");
|
|
const { createTimings } = await import("../../dist/shared/timings.js");
|
|
const { findPendingBaselineRun, findOptionalBaseline } = await import("../../dist/ci/workflow-steps.js");
|
|
|
|
const sha = "a".repeat(40);
|
|
const environment = { GITHUB_REPOSITORY: "acme/app", GITHUB_TOKEN: "test-token" };
|
|
const trustedRun = { id: 20, workflow_id: 77, head_sha: sha, head_branch: "main", event: "push", head_repository: { full_name: "acme/app" } };
|
|
const job = (status, conclusion = null, target = "iphone") => ({ name: `baseline (${target})`, status, conclusion });
|
|
|
|
async function zipFiles(files) {
|
|
configure({ useWebWorkers: false });
|
|
const writer = new ZipWriter(new Uint8ArrayWriter(), { useWebWorkers: false });
|
|
for (const [name, content] of Object.entries(files)) await writer.add(name, new Uint8ArrayReader(new TextEncoder().encode(content)));
|
|
return writer.close();
|
|
}
|
|
|
|
/**
|
|
* GitHub as a review's wait sees it: baseline run 20 of `sha`, whose state moves to the next of `states` each time the
|
|
* run is read (the last one stays), and artifact 500 of the iphone target, served from a storage URL GitHub redirects to.
|
|
*/
|
|
function fakeGitHub({ states = [{}], run = {}, zip, runFailures = [], runs } = {}) {
|
|
let poll = -1;
|
|
const requests = [];
|
|
const state = () => states[Math.min(Math.max(poll, 0), states.length - 1)];
|
|
const transport = async (input, init = {}) => {
|
|
const url = new URL(input);
|
|
requests.push(`${url.pathname}${url.search}`);
|
|
if (url.origin === "https://blob.example") {
|
|
assert.equal(init.headers?.authorization, undefined, "the token never reaches the storage URL GitHub redirects to");
|
|
return new Response(zip);
|
|
}
|
|
assert.equal(url.origin, "https://api.github.com");
|
|
assert.equal(init.headers?.authorization, "Bearer test-token");
|
|
const path = url.pathname;
|
|
if (path === "/repos/acme/app") return Response.json({ default_branch: "main" });
|
|
if (path === "/repos/acme/app/actions/workflows/explore-baseline.yml") return Response.json({ id: 77 });
|
|
if (path === "/repos/acme/app/actions/artifacts") return Response.json({ artifacts: [] });
|
|
if (path === "/repos/acme/app/actions/workflows/77/runs") {
|
|
assert.equal(url.searchParams.get("head_sha"), sha);
|
|
assert.equal(url.searchParams.get("branch"), "main");
|
|
return Response.json({ workflow_runs: runs ?? [] });
|
|
}
|
|
if (path === "/repos/acme/app/actions/runs/20") {
|
|
poll += 1;
|
|
const failure = runFailures.shift();
|
|
if (failure) return new Response("unavailable", { status: failure });
|
|
return Response.json({ ...trustedRun, status: state().runStatus ?? "in_progress", conclusion: state().conclusion ?? null, ...run });
|
|
}
|
|
if (path === "/repos/acme/app/actions/runs/20/artifacts") {
|
|
const name = url.searchParams.get("name");
|
|
return Response.json({ artifacts: state().artifact && name === `explore-baseline-${sha}-iphone` ? [{ id: 500, name, expired: false, workflow_run: { id: 20 } }] : [] });
|
|
}
|
|
if (path === "/repos/acme/app/actions/runs/20/jobs") return Response.json({ jobs: [{ name: "targets", status: "completed", conclusion: "success" }, ...state().jobs ?? []] });
|
|
if (path === "/repos/acme/app/actions/artifacts/500/zip") {
|
|
assert.equal(init.redirect, "manual", "the redirect is followed by hand, without the token");
|
|
return new Response(null, { status: 302, headers: { location: "https://blob.example/artifact.zip?sig=1" } });
|
|
}
|
|
throw new Error(`unexpected request ${url}`);
|
|
};
|
|
return { transport, requests, polls: () => poll + 1 };
|
|
}
|
|
|
|
/** A clock the wait's pauses advance, so fifteen minutes of polling take no real time. */
|
|
function clock() {
|
|
let milliseconds = 0;
|
|
return { now: () => milliseconds, sleep: async pause => { milliseconds += pause; }, elapsed: () => milliseconds };
|
|
}
|
|
|
|
const waitOptions = (github, time, extra = {}) => ({ run: 20, sha, target: "iphone", timeoutMs: 15 * 60_000, environment, transport: github.transport,
|
|
now: time.now, sleep: time.sleep, ...extra });
|
|
|
|
test("a baseline job in progress, then finished: the review reuses its artifact, downloaded and unpacked as download-artifact would", async t => {
|
|
const root = await mkdtemp(join(tmpdir(), "explore-baseline-wait-"));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const zip = await zipFiles({ "bundle.json": "{\"side\":\"head\"}", "cache-metadata.json": "{}", "screenshots/home.png": "png" });
|
|
const github = fakeGitHub({ zip, states: [{ jobs: [] }, { jobs: [job("queued")] }, { jobs: [job("in_progress")] }, { jobs: [job("in_progress")] },
|
|
{ jobs: [job("completed", "success")], artifact: true }] });
|
|
const time = clock(); const lines = [];
|
|
const directory = join(root, "review", "baseline-download");
|
|
assert.equal(await awaitPendingBaseline({ ...waitOptions(github, time, { progress: line => lines.push(line) }), directory }), directory);
|
|
assert.deepEqual((await readdir(directory, { recursive: true })).sort(), ["bundle.json", "cache-metadata.json", "screenshots", "screenshots/home.png"]);
|
|
assert.equal(await readFile(join(directory, "screenshots/home.png"), "utf8"), "png");
|
|
assert.equal(github.polls(), 5);
|
|
assert.deepEqual(lines, [
|
|
"waiting for main's baseline run 20 of aaaaaaaa: https://github.com/acme/app/actions/runs/20",
|
|
"waiting for main's baseline run 20 of aaaaaaaa (iphone: not started, 0 min)",
|
|
"waiting for main's baseline run 20 of aaaaaaaa (iphone: queued, 0 min)",
|
|
"waiting for main's baseline run 20 of aaaaaaaa (iphone: in progress, 0 min)",
|
|
"waiting for main's baseline run 20 of aaaaaaaa (iphone: in progress, 1 min)",
|
|
"base: downloaded the baseline of aaaaaaaa that run 20 captured",
|
|
]);
|
|
assert.ok(github.requests.every(request => !request.startsWith("/repos/acme/app/actions/runs/") || request.startsWith("/repos/acme/app/actions/runs/20")),
|
|
"only the run ci:find-baseline named is read");
|
|
assert.deepEqual((await readdir(join(root, "review"))).sort(), ["baseline-download"], "the downloaded zip is removed once unpacked");
|
|
});
|
|
|
|
test("the wait names the step the baseline job is in, tells the review when it changes, and records the steps it took", async t => {
|
|
const root = await mkdtemp(join(tmpdir(), "explore-baseline-steps-"));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
// The clock starts at a real date so GitHub's step timestamps and the wait's clock agree, as they do on a runner.
|
|
let milliseconds = Date.parse("2026-10-23T21:53:00Z");
|
|
const time = { now: () => milliseconds, sleep: async pause => { milliseconds += pause; } };
|
|
const at = minutes => new Date(Date.parse("2026-10-23T21:53:00Z") + minutes * 60_000).toISOString();
|
|
const steps = (current) => [
|
|
{ number: 1, name: "Set up job", status: "completed", conclusion: "success", started_at: at(0), completed_at: at(0.5) },
|
|
{ number: 2, name: "Install the launch recording decoder https://mirror.example/?sig=SIG-CANARY", status: current === 2 ? "in_progress" : "completed",
|
|
conclusion: current === 2 ? null : "success", started_at: at(0.5), completed_at: current === 2 ? null : at(19) },
|
|
...(current === 3 ? [{ number: 3, name: "Capture complete single-commit baseline", status: "in_progress", conclusion: null, started_at: at(19), completed_at: null }] : []),
|
|
];
|
|
const states = [...Array(57).fill({ jobs: [{ ...job("in_progress"), steps: steps(2) }] }), { jobs: [{ ...job("in_progress"), steps: steps(3) }] }];
|
|
const github = fakeGitHub({ states });
|
|
const updates = []; const lines = [];
|
|
const path = join(root, "timings.jsonl");
|
|
const outcome = await waitForBaselineArtifact(waitOptions(github, time, { timeoutMs: 20 * 60_000, progress: line => lines.push(line),
|
|
onUpdate: update => updates.push(update), timings: createTimings({ path, now: time.now }) }));
|
|
assert.deepEqual(outcome, { reason: "its iphone job is still in progress after 20 min" });
|
|
assert.deepEqual(updates.map(update => [update.state, update.step]), [
|
|
["in progress", "Install the launch recording decoder [link]"],
|
|
["in progress", "Capture complete single-commit baseline"],
|
|
]);
|
|
assert.equal(updates[0].stepSince, Date.parse(at(0.5)));
|
|
assert.ok(lines.includes("waiting for main's baseline run 20 of aaaaaaaa (iphone: in progress, in \"Install the launch recording decoder [link]\" for 9 min, 9 min)"), lines.join("\n"));
|
|
const events = (await readFile(path, "utf8")).trim().split("\n").map(line => JSON.parse(line));
|
|
assert.deepEqual(events.filter(event => event.type === "baseline-step").map(event => [event.name, Math.round(event.ms / 1000), event.conclusion]), [
|
|
["Set up job", 30, "success"],
|
|
["Install the launch recording decoder [link]", 1110, "success"],
|
|
["Capture complete single-commit baseline", 60, "still running"],
|
|
]);
|
|
const wait = events.find(event => event.type === "end" && event.phase === "baseline-wait");
|
|
assert.equal(wait.outcome, "skipped");
|
|
assert.equal(wait.attributes.reason, "its iphone job is still in progress after 20 min");
|
|
assert.equal(JSON.stringify(events).includes("SIG-CANARY"), false);
|
|
});
|
|
|
|
test("this target's artifact is reused although another target's baseline job failed (#48)", async () => {
|
|
const github = fakeGitHub({ states: [{ jobs: [job("completed", "failure", "duo-open"), job("in_progress")] },
|
|
{ runStatus: "completed", conclusion: "failure", jobs: [job("completed", "failure", "duo-open"), job("completed", "success")], artifact: true }] });
|
|
assert.deepEqual(await waitForBaselineArtifact(waitOptions(github, clock())), { artifact: { id: 500, run: 20 } });
|
|
});
|
|
|
|
test("a baseline job that fails or is cancelled ends the wait, and the review captures the base itself", async t => {
|
|
for (const [conclusion, reason] of [["failure", "its iphone job failed"], ["cancelled", "its iphone job was cancelled"], ["skipped", "its iphone job ended (skipped)"]]) {
|
|
const github = fakeGitHub({ states: [{ jobs: [job("in_progress")] }, { jobs: [job("completed", conclusion)] }] });
|
|
assert.deepEqual(await waitForBaselineArtifact(waitOptions(github, clock())), { reason }, conclusion);
|
|
}
|
|
const root = await mkdtemp(join(tmpdir(), "explore-baseline-wait-"));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const github = fakeGitHub({ states: [{ jobs: [job("in_progress")] }, { jobs: [job("completed", "failure")] }] });
|
|
const lines = [];
|
|
assert.equal(await awaitPendingBaseline({ ...waitOptions(github, clock(), { progress: line => lines.push(line) }), directory: join(root, "baseline-download") }), undefined);
|
|
assert.equal(lines.at(-1), "base: stopped waiting for the baseline of aaaaaaaa (its iphone job failed); capturing the base too");
|
|
assert.ok(!github.requests.some(request => request.includes("/zip")), "nothing is downloaded");
|
|
await assert.rejects(stat(join(root, "baseline-download")), { code: "ENOENT" });
|
|
});
|
|
|
|
test("a run that ends without this target's job, or a job whose artifact is never listed, ends the wait after a minute's grace", async () => {
|
|
const ended = fakeGitHub({ states: [{ runStatus: "completed", conclusion: "failure", jobs: [] }] });
|
|
const time = clock();
|
|
assert.deepEqual(await waitForBaselineArtifact(waitOptions(ended, time)), { reason: "the run ended (failure) without a iphone baseline job" });
|
|
assert.equal(time.elapsed(), 60_000);
|
|
const unlisted = fakeGitHub({ states: [{ jobs: [job("completed", "success")] }] });
|
|
assert.deepEqual(await waitForBaselineArtifact(waitOptions(unlisted, clock())), { reason: `its iphone job finished, but GitHub lists no explore-baseline-${sha}-iphone artifact` });
|
|
});
|
|
|
|
test("the wait gives up at its time limit, polling every 20 s", async () => {
|
|
const github = fakeGitHub({ states: [{ jobs: [job("in_progress")] }] });
|
|
const time = clock(); const lines = [];
|
|
assert.deepEqual(await waitForBaselineArtifact(waitOptions(github, time, { progress: line => lines.push(line) })), { reason: "its iphone job is still in progress after 15 min" });
|
|
assert.equal(time.elapsed(), 15 * 60_000);
|
|
assert.equal(github.polls(), 15 * 3 + 1);
|
|
assert.equal(lines.length, 1 + 15, "the run's link once, then one line a minute, not one a poll");
|
|
assert.deepEqual(await waitForBaselineArtifact(waitOptions(fakeGitHub({ states: [{ jobs: [job("in_progress")] }] }), clock(), { timeoutMs: 50_000, pollMs: 30_000 })),
|
|
{ reason: "its iphone job is still in progress after 1 min" }, "the last pause ends at the limit");
|
|
});
|
|
|
|
test("only a baseline run of exactly the base commit, on the default branch of this repository, from a push or dispatch, is waited for", async () => {
|
|
for (const mutation of [{ head_branch: "feature" }, { event: "pull_request_target" }, { event: "pull_request" }, { workflow_id: 99 },
|
|
{ head_sha: "b".repeat(40) }, { head_repository: { full_name: "fork/app" } }]) {
|
|
const github = fakeGitHub({ run: mutation, states: [{ jobs: [job("completed", "success")], artifact: true }] });
|
|
assert.deepEqual(await waitForBaselineArtifact(waitOptions(github, clock())), { reason: "run 20 is not a baseline run of aaaaaaaa on main" }, JSON.stringify(mutation));
|
|
assert.ok(!github.requests.some(request => request.startsWith("/repos/acme/app/actions/runs/20/")), "nothing of an untrusted run is read");
|
|
}
|
|
const dispatched = fakeGitHub({ run: { event: "workflow_dispatch" }, states: [{ artifact: true }] });
|
|
assert.deepEqual(await waitForBaselineArtifact(waitOptions(dispatched, clock())), { artifact: { id: 500, run: 20 } });
|
|
});
|
|
|
|
test("ci:find-baseline names a trusted baseline run still capturing the base commit, preferring the one already capturing", async () => {
|
|
const run = (id, status, mutation = {}) => ({ ...trustedRun, id, status, conclusion: status === "completed" ? "success" : null, ...mutation });
|
|
const lookup = runs => findPendingBaselineRun({ sha, target: "iphone", environment, transport: fakeGitHub({ runs }).transport });
|
|
assert.deepEqual(await lookup([run(30, "completed"), run(31, "in_progress", { event: "pull_request_target" }), run(33, "queued"), run(32, "in_progress"),
|
|
run(34, "in_progress", { head_branch: "feature" }), run(35, "in_progress", { head_repository: { full_name: "fork/app" } })]), { run: 32 });
|
|
assert.deepEqual(await lookup([run(33, "queued"), run(36, "waiting")]), { run: 36 }, "otherwise the newest");
|
|
assert.equal(await lookup([run(30, "completed"), run(31, "in_progress", { workflow_id: 99 })]), undefined);
|
|
assert.deepEqual(await findOptionalBaseline({ sha, target: "iphone", environment, transport: fakeGitHub({ runs: [run(32, "in_progress")] }).transport }), { pending: { run: 32 } },
|
|
"with no finished baseline to download");
|
|
const outage = async input => new URL(input).pathname.endsWith("/77/runs") ? new Response("bad gateway", { status: 502 }) : fakeGitHub().transport(input, { headers: { authorization: "Bearer test-token" } });
|
|
const failed = await findOptionalBaseline({ sha, target: "iphone", environment, transport: outage });
|
|
assert.equal(failed.pending, undefined);
|
|
assert.match(failed.warning.message, /^No baseline run in progress is waited for, so capture captures the base too: GitHub answered HTTP 502/);
|
|
});
|
|
|
|
test("a GitHub 5xx while polling is retried, and a missing permission ends the wait without failing the review", async t => {
|
|
const github = fakeGitHub({ runFailures: [0, 502, 503], states: [{ jobs: [job("in_progress")] }, { jobs: [job("in_progress")] }, { jobs: [job("in_progress")] }, { artifact: true }] });
|
|
t.mock.method(process.stderr, "write", () => true);
|
|
assert.deepEqual(await waitForBaselineArtifact(waitOptions(github, clock())), { artifact: { id: 500, run: 20 } });
|
|
const outage = fakeGitHub({ runFailures: [502, 502, 502, 502, 502] });
|
|
assert.match((await waitForBaselineArtifact(waitOptions(outage, clock()))).reason, /^GITHUB_REQUEST_FAILED: GitHub answered HTTP 502/, "an outage that outlasts its retries ends the wait");
|
|
const denied = fakeGitHub({ runFailures: [403] });
|
|
assert.deepEqual(await waitForBaselineArtifact(waitOptions(denied, clock())),
|
|
{ reason: "GITHUB_PERMISSION_DENIED: GitHub answered HTTP 403 for repos/acme/app/actions/runs/20." });
|
|
assert.equal(denied.polls(), 1, "a missing permission is not retried");
|
|
const tokenless = await waitForBaselineArtifact(waitOptions(fakeGitHub(), clock(), { environment: { GITHUB_REPOSITORY: "acme/app" } }));
|
|
assert.match(tokenless.reason, /^GITHUB_TOKEN_MISSING/);
|
|
});
|
|
|
|
test("an artifact with a path outside its directory is refused before anything is written, and base captures as before", async t => {
|
|
const root = await mkdtemp(join(tmpdir(), "explore-baseline-wait-"));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const github = fakeGitHub({ zip: await zipFiles({ "bundle.json": "{}", "../escape.txt": "outside" }), states: [{ artifact: true }] });
|
|
const lines = [];
|
|
assert.equal(await awaitPendingBaseline({ ...waitOptions(github, clock(), { progress: line => lines.push(line) }), directory: join(root, "review", "baseline-download") }), undefined);
|
|
// zip.js refuses the entry as it reads the archive's directory, before the unpacking's own path check would.
|
|
assert.equal(lines.at(-1), "base: the baseline of aaaaaaaa from run 20 could not be downloaded (Unsafe filename); capturing the base too");
|
|
assert.deepEqual(await readdir(join(root, "review")), [], "neither the download nor a partial directory is left");
|
|
await assert.rejects(stat(join(root, "escape.txt")), { code: "ENOENT" });
|
|
});
|
|
|
|
test("a review that no longer needs the baseline stops waiting at once", async () => {
|
|
const github = fakeGitHub({ states: [{ jobs: [job("in_progress")] }] });
|
|
const controller = new AbortController();
|
|
const waiting = waitForBaselineArtifact({ run: 20, sha, target: "iphone", timeoutMs: 15 * 60_000, environment, transport: github.transport, signal: controller.signal });
|
|
setTimeout(() => controller.abort(), 50);
|
|
const started = Date.now();
|
|
assert.deepEqual(await waiting, { reason: "the review stopped waiting" });
|
|
assert.ok(Date.now() - started < 5_000);
|
|
});
|