/**
* ci-duration's drill-down: the Gantt page and the /bench/gantt.svg image behind it.
* The image handler shells out to scripts/ci-gantt.ts and writes a temp file.
* The subprocess and the filesystem calls around it are replaced with
* stubs that record what the handler asked for and hand back a canned result.
*/
import { assert, assertEquals, assertStringIncludes } from "@std/assert";
import type { Ctx, Run } from "../types.ts";
import { CI_WORKFLOW, LOOM_CI_WORKFLOW, LOOM_REPO, REPO } from "../config.ts";
import type {
CiGanttInput,
CiGanttOptions,
CiHistorySource,
} from "../ci-job-history.ts";
import {
ciCommitGanttPage,
type CiGanttDataProvider,
ciGanttPage,
commitGanttUrl,
labsCiDuration,
loomCiDuration,
renderGantt,
renderGanttRoute,
weaverCiDuration,
} from "./ci-duration.ts";
import { PERFORMANCE_VIEW_STYLES } from "../performance-views.ts";
import { collectFromWorkingRuns } from "../test/github-attempts.ts";
import { STATUS_EDGE, STATUS_WASH } from "../palette.ts";
const SVG = new TextEncoder().encode(
'',
);
// Stands in for the collector, which writes the renderer's input file itself
// rather than handing back a chart to be serialized.
function provideGantt(
chart: (
source: CiHistorySource,
options: CiGanttOptions,
) => CiGanttInput["runs"],
): CiGanttDataProvider {
return async (source, options, destination) => {
const runs = chart(source, options);
await Deno.writeTextFile(destination, JSON.stringify({ runs }));
return runs.length;
};
}
function ctx(runs: Run[]): Ctx {
return {
runs: () => Promise.resolve(runs),
runsFor: () => Promise.resolve(runs),
env: () => undefined,
};
}
function run(over: Partial): Run {
const startedAt = new Date(Date.now() - 3_600_000).toISOString();
return {
id: 1,
status: "completed",
conclusion: "success",
run_attempt: 1,
event: "pull_request",
head_sha: "sha",
display_title: "t",
created_at: startedAt,
run_started_at: startedAt,
updated_at: new Date().toISOString(),
html_url: "",
head_commit: { message: "t (#1)" },
...over,
};
}
interface Fake {
success?: boolean; // what the ci-gantt run reports
stderr?: string; // what it printed on the way out
throws?: Error; // the spawn itself blowing up
abortOnOutput?: AbortController;
abortAfterOutput?: AbortController;
}
interface Result {
res: Response;
args: string[]; // the argv the handler built for scripts/ci-gantt.ts
logged: string; // everything the handler wrote to console.error
leftover: string[]; // temp files still on disk once the handler returned
input: CiGanttInput;
requested: { source: CiHistorySource; options: CiGanttOptions };
}
async function gantt(
query: string,
fake: Fake = {},
signal?: AbortSignal,
): Promise {
const origTemp = Deno.makeTempFile;
const origRead = Deno.readFile;
const origWrite = Deno.writeTextFile;
const origRemove = Deno.remove;
const origCommand = Object.getOwnPropertyDescriptor(Deno, "Command")!;
const origError = console.error;
const live = new Set();
const logged: string[] = [];
let args: string[] = [];
let seq = 0;
let written = "";
let commandSignal: AbortSignal | undefined;
let requested!: Result["requested"];
try {
Deno.makeTempFile = (opts?: Deno.MakeTempOptions) => {
const path = `/fake-tmp/${opts?.prefix ?? ""}${++seq}${
opts?.suffix ?? ""
}`;
live.add(path);
return Promise.resolve(path);
};
Deno.readFile = (path: string | URL) =>
live.has(String(path))
? Promise.resolve(SVG)
: Promise.reject(new Deno.errors.NotFound(String(path)));
Deno.writeTextFile = (
_path: string | URL,
data: string | ReadableStream,
) => {
assert(typeof data === "string");
written = data;
return Promise.resolve();
};
Deno.remove = (path: string | URL) => {
live.delete(String(path));
return Promise.resolve();
};
Object.defineProperty(Deno, "Command", {
configurable: true,
value: class {
constructor(
_cmd: string,
opts: { args: string[]; signal?: AbortSignal },
) {
args = opts.args;
commandSignal = opts.signal;
}
output() {
if (fake.abortOnOutput) {
assertEquals(commandSignal, fake.abortOnOutput.signal);
fake.abortOnOutput.abort();
return Promise.reject(fake.abortOnOutput.signal.reason);
}
if (fake.abortAfterOutput) {
assertEquals(commandSignal, fake.abortAfterOutput.signal);
fake.abortAfterOutput.abort();
}
if (fake.throws) return Promise.reject(fake.throws);
return Promise.resolve({
success: fake.success ?? true,
stdout: new Uint8Array(),
stderr: new TextEncoder().encode(fake.stderr ?? ""),
});
}
},
});
console.error = (...parts: unknown[]) =>
void logged.push(parts.map(String).join(" "));
const url = new URL(`http://d/bench/gantt.svg${query}`);
const res = await renderGantt(
url.searchParams,
provideGantt((source, options) => {
requested = { source, options };
return Array.from(
{ length: Math.min(options.limit, 60) },
(_, index) => ({
run: {
attempt: 1,
databaseId: index + 1,
status: "completed",
conclusion: "success",
event: "push",
headBranch: "main",
startedAt: "2026-06-20T18:00:00Z",
workflowName: source.workflow,
},
jobs: [],
}),
);
}),
signal,
);
return {
res,
args,
logged: logged.join("\n"),
leftover: [...live],
input: written ? JSON.parse(written) as CiGanttInput : { runs: [] },
requested,
};
} finally {
Deno.makeTempFile = origTemp;
Deno.readFile = origRead;
Deno.writeTextFile = origWrite;
Deno.remove = origRemove;
Object.defineProperty(Deno, "Command", origCommand);
console.error = origError;
}
}
// The flag ci-gantt is given for a named option, or undefined when unset.
function opt(args: string[], flag: string): string | undefined {
const i = args.indexOf(flag);
return i < 0 ? undefined : args[i + 1];
}
Deno.test("the Gantt page shares the performance view selector", async () => {
const url = new URL("http://d/bench?view=gantt");
const html = ciGanttPage(url);
assertStringIncludes(
html,
'',
);
assertStringIncludes(html, "CI run Gantt");
assertStringIncludes(html, PERFORMANCE_VIEW_STYLES);
for (const status of ["good", "warn", "bad"] as const) {
assertStringIncludes(
html,
`.brow.${status},.crow.${status}{border-color:color-mix(in srgb,var(--status-${status}) ${
Number((STATUS_EDGE[status] * 100).toFixed(4))
}%,transparent);background:color-mix(in srgb,var(--status-${status}) ${
Number((STATUS_WASH[status] * 0.75 * 100).toFixed(4))
}%,transparent)}`,
);
}
assertStringIncludes(
html,
`.fetch-progress.error,.fetch-progress.warning{border-color:color-mix(in srgb,var(--status-warn) ${
Number((STATUS_EDGE.warn * 100).toFixed(4))
}%,transparent)}`,
);
assertStringIncludes(html, `${REPO} · ${CI_WORKFLOW}`);
assertStringIncludes(html, `href="/"`); // a way back to the dashboard
assertStringIncludes(
html,
'href="/bench?view=runtime&repo=labs&days=45&sort=file&stat=p99">Runtime benchmarks',
);
assertStringIncludes(
html,
'href="/bench?view=ci&repo=labs&days=45&sort=job&stat=p99">CI duration history',
);
assertStringIncludes(
html,
'href="/bench?view=gantt&repo=labs&days=45&sort=job&stat=p99" aria-current="page">CI run Gantt',
);
assertStringIncludes(html, '');
assertStringIncludes(html, "/bench/gantt.svg?"); // the controls point at the image route
assertStringIncludes(html, "p.set('theme', document.documentElement.dataset.theme");
assertStringIncludes(html, 'id="fetch-progress"');
assertStringIncludes(html, 'id="fetch-title">Idle');
assertStringIncludes(html, 'aria-label="CI Gantt fetch progress"');
assertStringIncludes(html, "/bench/gantt-progress?");
assertStringIncludes(html, "new EventSource(progressUrl())");
assertStringIncludes(
html,
"state.discoveryRequestsMade + ' workflow requests made",
);
assertStringIncludes(html, "state.cachedRuns + ' cached");
assertStringIncludes(html, "state.sharedRequests + ' shared");
assertStringIncludes(html, "title.textContent = 'Generating chart image…'");
assertStringIncludes(html, "collectionError || text");
assertStringIncludes(html, "g.removeAttribute('src')");
assertStringIncludes(html, "const controller = new AbortController()");
assertStringIncludes(html, "signal: controller.signal");
assertStringIncludes(html, "sequence !== renderSequence");
assertStringIncludes(html, "controller.signal.aborted");
assert(!html.includes('class="spinner"'));
assert(!html.includes("min runs per job"));
assert(!html.includes("minRuns"));
// The runs slider offers only what the image route will honor, so dragging it
// to either end can't ask for a limit that comes back silently clamped.
assertStringIncludes(html, `id="limit" min="1" max="150"`);
assertEquals((await gantt("?limit=150")).args.indexOf("150") >= 0, true);
assertStringIncludes(html, "setInterval(() => {");
assertStringIncludes(html, "}, 1800000)");
assert(!html.includes("pendingRender"));
assertStringIncludes(html, "location.href = '/bench?' + params.toString()");
assert(
!html.includes(
".views a.on{background:#6ea8fe;border-color:#6ea8fe;color:#0d0e11;font-weight",
),
);
assert(
labsCiDuration.routes?.some((route) =>
route.path === "/bench/gantt-progress"
),
);
for (
const path of [
"/ci-gantt",
"/ci-gantt.svg",
"/ci-gantt.png",
"/ci-gantt-progress",
]
) {
assert(labsCiDuration.routes?.some((route) => route.path === path));
}
for (const path of ["/ci-gantt.svg", "/ci-gantt-progress"]) {
const route = labsCiDuration.routes?.find((route) => route.path === path);
assert(route);
const invalid = new URL(`http://d${path}?sha=invalid`);
assertEquals(
(await route.handler(new Request(invalid), invalid)).status,
400,
);
const tooMany = new URL(`http://d${path}`);
tooMany.searchParams.set("sha", "f".repeat(40));
for (let index = 0; index < 151; index++) {
tooMany.searchParams.append("run", `${1_000 + index}:1`);
}
assertEquals(
(await route.handler(new Request(tooMany), tooMany)).status,
400,
);
}
for (
const [path, target] of [
["/ci-gantt.png", "/ci-gantt.svg"],
["/bench/gantt.png", "/bench/gantt.svg"],
]
) {
const route = labsCiDuration.routes?.find((route) => route.path === path);
assert(route);
const legacy = new URL(`http://d${path}?repo=loom&limit=12`);
const response = await route.handler(new Request(legacy), legacy);
assertEquals(response.status, 308);
assertEquals(
response.headers.get("location"),
`${target}?repo=loom&limit=12`,
);
}
const loomHtml = ciGanttPage(
new URL(
"http://d/bench?view=gantt&repo=loom&days=9&sort=duration&stat=p75",
),
);
assertStringIncludes(loomHtml, `${LOOM_REPO} · ${LOOM_CI_WORKFLOW}`);
assertStringIncludes(
loomHtml,
'href="/bench?view=runtime&repo=loom&days=9&sort=duration&stat=p75">Runtime benchmarks',
);
assertStringIncludes(
loomHtml,
'href="/bench?view=ci&repo=loom&days=9&sort=duration&stat=p75">CI duration history',
);
assertStringIncludes(loomHtml, '');
});
Deno.test("the commit Gantt page contains only one commit selection", () => {
const sha = "b".repeat(40);
const url = new URL(
`http://d/ci-gantt?repo=loom&sha=${sha}&limit=2&mainOnly=1&run=701:1&run=702:3`,
);
const html = ciCommitGanttPage(url);
assertStringIncludes(html, `CI Gantt · ${sha.slice(0, 7)}`);
assertStringIncludes(html, `${LOOM_REPO} · `);
assertStringIncludes(
html,
`href="https://github.com/${LOOM_REPO}/commit/${sha}"`,
);
assertStringIncludes(html, 'aria-label="Commit CI Gantt fetch progress"');
assertStringIncludes(html, "/ci-gantt.svg?");
assertStringIncludes(html, "/ci-gantt-progress?");
assertStringIncludes(
html,
"Chart includes 2 successful runs for this commit.",
);
assertStringIncludes(html, "const image = new Image()");
assertStringIncludes(html, "image.onerror = () =>");
assertStringIncludes(html, "URL.revokeObjectURL(src)");
assertStringIncludes(html, "url.searchParams.set('theme'");
assertStringIncludes(html, "if (chartSrc) URL.revokeObjectURL(chartSrc)");
assertStringIncludes(html, "if (chartSettled) return");
assertStringIncludes(html, "chartSettled = true");
assertStringIncludes(
html,
"stream.close();\n if (chartSrc) URL.revokeObjectURL(chartSrc);\n chartSrc = src",
);
assert(!html.includes('class="controls"'));
assert(!html.includes('aria-label="Performance view"'));
const empty = ciCommitGanttPage(
new URL(`http://d/ci-gantt?repo=loom&sha=${sha}`),
);
assertStringIncludes(
empty,
"No successful main CI runs were supplied for this commit.",
);
assert(!empty.includes("/ci-gantt.svg?"));
});
Deno.test("the commit Gantt page stars a commit the green branch is at, and no commit it was never at", () => {
const held = "f".repeat(40);
const page = (sha: string) =>
ciCommitGanttPage(
new URL(`http://d/ci-gantt?repo=loom&sha=${sha}&run=701:1`),
(repo, asked) =>
repo === LOOM_REPO && asked === held
? { branch: "main-green", current: true }
: undefined,
);
assertStringIncludes(
page(held),
`${LOOM_REPO} · ★ {
const at = "e".repeat(40);
const route = labsCiDuration.routes?.find((route) => route.path === "/ci-gantt");
assert(route);
const requests: URL[] = [];
const realFetch = globalThis.fetch;
const realToken = Deno.env.get("GH_TOKEN");
Deno.env.set("GH_TOKEN", "test-token");
globalThis.fetch = ((input: string | URL | Request) => {
requests.push(new URL(input instanceof Request ? input.url : String(input)));
return Promise.resolve(Response.json([
{ id: 8801, after: at, timestamp: new Date().toISOString() },
]));
}) as typeof fetch;
try {
const serve = async (repo: string) => {
const url = new URL(`http://d/ci-gantt?repo=${repo}&sha=${at}&run=701:1`);
return await (await route.handler(new Request(url), url)).text();
};
assertStringIncludes(
await serve("loom"),
`aria-label="main-green is at this commit"`,
);
assertEquals(requests.map((url) => url.pathname), [
`/repos/${LOOM_REPO}/activity`,
]);
assert(!(await serve("labs")).includes(` {
const selection = `repo=labs&sha=${"e".repeat(40)}&run=42:1`;
for (const theme of ["dark", "light"]) {
const normalized = commitGanttUrl(
new URL(`http://d/ci-gantt.svg?${selection}&theme=${theme}`),
);
assert(normalized);
assertEquals(normalized.searchParams.get("theme"), theme);
}
const unknown = commitGanttUrl(
new URL(`http://d/ci-gantt.svg?${selection}&theme=sepia`),
);
assert(unknown);
assertEquals(unknown.searchParams.has("theme"), false);
});
Deno.test("commit Gantt data routes start the exact selected collection", async () => {
const previousGhToken = Deno.env.get("GH_TOKEN");
const previousGitHubToken = Deno.env.get("GITHUB_TOKEN");
const originalError = console.error;
Deno.env.delete("GH_TOKEN");
Deno.env.delete("GITHUB_TOKEN");
console.error = () => {};
const selection = `repo=labs&sha=${
"e".repeat(40)
}&limit=1&mainOnly=1&run=9007199254740991:1`;
try {
const imageRoute = labsCiDuration.routes?.find((route) =>
route.path === "/ci-gantt.svg"
);
assert(imageRoute);
const imageUrl = new URL(`http://d/ci-gantt.svg?${selection}`);
const image = await imageRoute.handler(new Request(imageUrl), imageUrl);
assertEquals(image.status, 500);
assertEquals(await image.text(), "set GH_TOKEN");
const progressRoute = labsCiDuration.routes?.find((route) =>
route.path === "/ci-gantt-progress"
);
assert(progressRoute);
const progressUrl = new URL(`http://d/ci-gantt-progress?${selection}`);
const progress = await progressRoute.handler(
new Request(progressUrl),
progressUrl,
);
assertEquals(progress.status, 200);
const events = await progress.text();
assertStringIncludes(events, '"phase":"error"');
assertStringIncludes(events, "set GH_TOKEN");
} finally {
console.error = originalError;
if (previousGhToken === undefined) Deno.env.delete("GH_TOKEN");
else Deno.env.set("GH_TOKEN", previousGhToken);
if (previousGitHubToken === undefined) Deno.env.delete("GITHUB_TOKEN");
else Deno.env.set("GITHUB_TOKEN", previousGitHubToken);
}
});
Deno.test("CI duration tiles link to their repository histories", async () => {
assertEquals(loomCiDuration.routes, undefined);
const loom = await collectFromWorkingRuns(loomCiDuration, ctx([run({})]));
assertEquals(loom.href, "/bench?view=ci&repo=loom");
assertEquals(loom.hint, "main run history ↗");
const labs = await collectFromWorkingRuns(labsCiDuration, ctx([run({})]));
assertEquals(labs.href, "/bench?view=ci&repo=labs");
assertEquals(labs.hint, "main run history ↗");
const unavailable: Ctx = {
runs: () => Promise.resolve([]),
runsFor: () => Promise.reject(new Error("set GH_TOKEN to use GitHub")),
env: () => undefined,
};
const coldLabs = await collectFromWorkingRuns(labsCiDuration, unavailable);
const coldLoom = await collectFromWorkingRuns(loomCiDuration, unavailable);
assertEquals(
[coldLabs.href, coldLabs.hint, coldLabs.sub],
["/bench?view=ci&repo=labs", "main run history ↗", "set GH_TOKEN"],
);
assertEquals(
[coldLoom.href, coldLoom.hint, coldLoom.sub],
["/bench?view=ci&repo=loom", "main run history ↗", "set GH_TOKEN"],
);
// Weaver has no history view to open, so its tile is not a link.
const weaver = await collectFromWorkingRuns(weaverCiDuration, ctx([run({})]));
assertEquals([weaver.href, weaver.hint], [undefined, undefined]);
const coldWeaver = await collectFromWorkingRuns(weaverCiDuration, unavailable);
assertEquals(
[coldWeaver.href, coldWeaver.hint, coldWeaver.sub],
[undefined, undefined, "set GH_TOKEN"],
);
});
Deno.test("/bench/gantt.svg: a successful render returns SVG uncached", async () => {
const { res, args, input, leftover, requested } = await gantt("?limit=30");
assertEquals(res.status, 200);
assertEquals(res.headers.get("content-type"), "image/svg+xml");
assertEquals(res.headers.get("cache-control"), "no-store");
assertEquals(new Uint8Array(await res.arrayBuffer()), SVG);
assertEquals(opt(args, "--repo"), REPO);
assertEquals(opt(args, "--workflow"), CI_WORKFLOW);
assertEquals(opt(args, "--limit"), "30");
assertEquals(opt(args, "--theme"), "dark");
assertEquals(opt(args, "--out"), "/fake-tmp/ci-gantt-2.svg"); // the bytes come from where it was told to write
assertEquals(opt(args, "--input"), "/fake-tmp/ci-gantt-input-1.json");
assert(!args.includes("--allow-net"));
assert(!args.includes("--allow-env"));
assert(args.includes("--allow-read=/fake-tmp/ci-gantt-input-1.json"));
assert(!args.includes("--allow-read"));
assert(!args.includes("--allow-write"));
assert(!args.includes("--allow-ffi"));
assert(!args.includes("--allow-sys=cpus,networkInterfaces,hostname"));
assert(args.includes("--allow-write=/fake-tmp/ci-gantt-2.svg"));
assertEquals(opt(args, "--scale"), undefined);
assertEquals(input.runs.length, 30);
assertEquals(requested.source.key, "labs");
assertEquals(requested.options, {
limit: 30,
mainOnly: false,
allConclusions: false,
});
assertEquals(leftover, []); // the temp file is cleaned up on the way out
const light = await gantt("?limit=1&theme=light");
assertEquals(opt(light.args, "--theme"), "default");
});
Deno.test("/bench/gantt.svg includes chart labels without server fonts", async () => {
const response = await renderGantt(
new URLSearchParams("limit=1&mainOnly=1"),
provideGantt(() => [
{
run: {
attempt: 1,
databaseId: 123,
status: "completed",
conclusion: "success",
event: "push",
headBranch: "main",
startedAt: "2026-07-28T18:00:00Z",
workflowName: CI_WORKFLOW,
},
jobs: [{
name: "Check dashboard labels",
status: "completed",
conclusion: "success",
started_at: "2026-07-28T18:00:05Z",
completed_at: "2026-07-28T18:01:05Z",
steps: [{
name: "🔎 Check",
number: 1,
conclusion: "success",
started_at: "2026-07-28T18:00:10Z",
completed_at: "2026-07-28T18:01:00Z",
}],
}],
},
]),
);
assertEquals(response.status, 200);
assertEquals(response.headers.get("content-type"), "image/svg+xml");
const svg = await response.text();
assertStringIncludes(svg, "