import { assert, assertEquals, assertRejects, assertStringIncludes, } from "@std/assert"; import { FakeTime } from "@std/testing/time"; import { baseJobName, buildCiJobHistory, CI_HISTORY_DAYS, CI_HISTORY_MIN_DAYS, CI_HISTORY_POINT_TARGET, CI_HISTORY_SOURCES, ciFetchProgressPanel, type CiGanttInput, ciGanttOptions, ciGanttProgressResponse, ciHistoryDays, type CiHistorySample, type CiHistorySource, type CiJobFetchProgress, ciJobHistoryCheckResponse, CiJobHistoryCollector as RateLimitedCiJobHistoryCollector, ciJobHistoryPage, ciJobHistoryProgressResponse, ciJobHistoryResponse, ciJobHistorySnapshotVersion, collectCiGanttInput, type GanttSelection, PROGRESS_RECORD_MAX, projectWorkflowRun, sampleWorkflowRuns, SNAPSHOT_CACHE_MAX, type WorkflowRun, } from "./ci-job-history.ts"; import { type CachedCiGanttJob, type CachedCiGanttRunFields, type CachedCiHistoryRefresh, type CachedCiRun, CI_JOB_CACHE_DAYS, CI_JOB_HISTORY_SAMPLING_VERSION, CiJobHistoryStore, ganttRunSummary, } from "./ci-job-cache.ts"; import { CiGanttDetailStore, ganttDetailDirectory } from "./ci-gantt-detail.ts"; import { CI_RUNS_MAX, CI_WORKFLOW, LOOM_CI_WORKFLOW, LOOM_REPO, REPO, } from "./config.ts"; import { type GitHubCredential, staticGitHubCredential, } from "./github-auth.ts"; import { github } from "./lib.ts"; import { GitHubRateLimitBudgetError } from "./github-rate-limit.ts"; import { PERFORMANCE_VIEW_STYLES } from "./performance-views.ts"; // Existing collector tests exercise history and persistence behavior against // precise request stubs. The rate-budget implementation has its own tests. class CiJobHistoryCollector extends RateLimitedCiJobHistoryCollector { constructor(store = new CiJobHistoryStore()) { super(store, github); } } type SeededCiRun = & Omit & { gantt: CachedCiGanttRunFields }; function cachedRunEntry( entry: SeededCiRun, ganttJobs: CachedCiGanttJob[] = [], ): CachedCiRun { return { ...entry, gantt: ganttRunSummary(entry.gantt, ganttJobs) }; } function detailStoreFor(store: CiJobHistoryStore): CiGanttDetailStore { return new CiGanttDetailStore(ganttDetailDirectory(store.file)); } // Places a run in the cache the way a collection does: the index entry names // the attempt, and the Gantt detail store beside it holds the jobs a chart // draws. async function seedCachedRun( store: CiJobHistoryStore, entry: SeededCiRun, ganttJobs: CachedCiGanttJob[] = [], ): Promise { const cached = cachedRunEntry(entry, ganttJobs); await detailStoreFor(store).write( cached, cached.runId, cached.runAttempt, ganttJobs, ); store.set(cached); } const DAY = 86_400_000; const HOUR = 3_600_000; const NOW = Date.parse("2026-06-20T18:00:00Z"); const HEAD_SHA = "a".repeat(40); // A workflow run as a run list carries it, with the creation time the // collector reads the list back to. type ListedRun = WorkflowRun & { created_at: string }; function workflowRun( id: number, at: number, overrides: Partial = {}, ): ListedRun { return { id, status: "completed", conclusion: "success", event: "push", head_branch: "main", run_attempt: 1, created_at: new Date(at).toISOString(), run_started_at: new Date(at).toISOString(), html_url: `https://github.com/${REPO}/actions/runs/${id}`, ...overrides, }; } // Answers a request for a page of a workflow's run list as GitHub does: with // the `page`th `per_page` runs of `runs`, which are every run of the workflow, // whatever it ran for, newest first. A run is numbered when it is created, so // the list's ids fall from the top down. A list narrowed by anything else is // refused, since GitHub serves those from an index that can be days behind. function runListPage( url: string, runs: readonly WorkflowRun[], ): { workflow_runs: WorkflowRun[] } { const query = new URL(url, "https://api.github.com/").searchParams; // The lists filtered to main or to pull requests, which name the runs that // may have been run again, name none here. for (const filter of ["branch", "event"]) { if (query.has(filter)) { assertEquals([...query.keys()].sort(), [filter, "page", "per_page"]); return { workflow_runs: [] }; } } assertEquals([...query.keys()].sort(), ["page", "per_page"]); runs.forEach((run, index) => assert(index === 0 || run.id < runs[index - 1].id, `run ${run.id} order`) ); const size = Number(query.get("per_page")); const start = (Number(query.get("page")) - 1) * size; return { workflow_runs: runs.slice(start, start + size) }; } function historySamples(): CiHistorySample[] { return Array.from({ length: 8 }, (_, day) => ({ runId: 100 + day, runUrl: `https://example.test/runs/${100 + day}`, at: NOW - (7 - day) * DAY, overallSeconds: 300 + day * 15, jobs: [ { name: "Test (1/2)", seconds: 100 + day * 2 }, { name: "Test (2/2)", seconds: 120 + day * 12 }, { name: "Check", seconds: 200 - day * 5 }, { name: "One platform (linux)", seconds: 60 }, ...(day === 0 ? [{ name: "Test (1/2)", seconds: 10 }] : []), ], })); } Deno.test("CI job history keeps every eligible build below the point target", () => { const sameWindowOld = workflowRun(1, NOW - HOUR * 2); const sameWindowNew = workflowRun(2, NOW - HOUR); const previousWindow = workflowRun(3, NOW - HOUR * 14); const runs = [ sameWindowOld, previousWindow, workflowRun(4, NOW, { conclusion: "failure" }), workflowRun(5, NOW, { event: "pull_request" }), workflowRun(6, NOW, { head_branch: "release" }), workflowRun(7, NOW - (CI_HISTORY_DAYS + 1) * DAY), sameWindowNew, ]; assertEquals( sampleWorkflowRuns(runs, NOW).map((run) => run.id), [3, 1, 2], ); }); Deno.test("CI Gantt options retain valid selected run attempts", () => { const parameters = new URLSearchParams(); parameters.append("run", "8101:1"); parameters.append("run", "not-a-run"); parameters.append("run", "8102:3"); parameters.set("limit", "2"); parameters.set("mainOnly", "1"); parameters.set("sha", HEAD_SHA.toUpperCase()); assertEquals(ciGanttOptions(parameters), { limit: 2, mainOnly: true, allConclusions: false, headSha: HEAD_SHA, selectedRuns: [ { runId: 8101, runAttempt: 1 }, { runId: 8102, runAttempt: 3 }, ], }); }); Deno.test("CI Gantt limits exact run selections before collecting", async () => { const directory = await Deno.makeTempDir({ prefix: "ci-gantt-selection-limit-test-", }); const requested: number[] = []; const collector = new RateLimitedCiJobHistoryCollector( new CiJobHistoryStore(`${directory}/history.json`), (path: string): Promise => { const match = path.match(/\/actions\/runs\/(\d+)\/attempts\/1$/); if (!match) throw new Error(`unexpected selected-run request: ${path}`); const id = Number(match[1]); requested.push(id); return Promise.resolve(workflowRun(id, NOW, { status: "in_progress", conclusion: null, head_sha: HEAD_SHA, path: `.github/workflows/${CI_WORKFLOW}`, }) as T); }, ); try { await assertRejects( () => collector.gantt( staticGitHubCredential("selection-limit-token"), CI_HISTORY_SOURCES.labs, { limit: 1, mainOnly: true, headSha: HEAD_SHA, selectedRuns: Array.from({ length: 151 }, (_, index) => ({ runId: 20_000 + index, runAttempt: 1, })), }, NOW, ), Error, "Every selected CI run must be a completed successful main push", ); assertEquals(requested.length, 150); assertEquals(requested.includes(20_150), false); } finally { await Deno.remove(directory, { recursive: true }); } }); Deno.test("CI job history keeps every eligible build at the point target", () => { const runs = Array.from( { length: CI_HISTORY_POINT_TARGET }, (_, index) => workflowRun(2_000 + index, NOW - HOUR - index * 1_000), ); assertEquals( sampleWorkflowRuns(runs, NOW).map((run) => run.id).sort((a, b) => a - b), runs.map((run) => run.id), ); }); Deno.test("CI job history sampling matches the main tile run limit", () => { assertEquals(CI_HISTORY_POINT_TARGET, CI_RUNS_MAX); }); Deno.test("CI job history keeps the exact target across clustered builds", () => { const clustered = Array.from( { length: CI_HISTORY_POINT_TARGET + 1 }, (_, index) => workflowRun(1_000 + index, NOW - HOUR - index * 1_000), ); const sampled = sampleWorkflowRuns(clustered, NOW); assertEquals(sampled.length, CI_HISTORY_POINT_TARGET); assertEquals(new Set(sampled.map((run) => run.id)).size, sampled.length); assertEquals(sampled[0].id, 1_000 + CI_HISTORY_POINT_TARGET); assertEquals(sampled.at(-1)?.id, 1_000); }); Deno.test("CI job history keeps a similar point count when the window gets shorter", () => { const denseRuns = (days: number, firstId: number) => Array.from( { length: CI_HISTORY_POINT_TARGET * 2 + 1 }, (_, sample) => workflowRun( firstId + sample, NOW - sample * days * DAY / (CI_HISTORY_POINT_TARGET * 2), ), ); const full = sampleWorkflowRuns( denseRuns(CI_HISTORY_DAYS, 20_000), NOW, CI_HISTORY_DAYS, ); const short = sampleWorkflowRuns( denseRuns(CI_HISTORY_MIN_DAYS, 30_000), NOW, CI_HISTORY_MIN_DAYS, ); assertEquals(full.length, CI_HISTORY_POINT_TARGET); assertEquals(short.length, CI_HISTORY_POINT_TARGET); assert( short.every((run) => Date.parse(run.run_started_at) >= NOW - CI_HISTORY_MIN_DAYS * DAY ), ); assertEquals(ciHistoryDays(null), CI_HISTORY_DAYS); assertEquals(ciHistoryDays("0"), CI_HISTORY_MIN_DAYS); assertEquals(ciHistoryDays("2"), 2); assertEquals(ciHistoryDays("100"), CI_HISTORY_DAYS); }); Deno.test("CI job history groups concurrent suffix jobs and keeps every shard", () => { const snapshot = buildCiJobHistory(historySamples()); assertEquals(snapshot.runCount, 8); assertEquals(snapshot.successfulRunTimes, null); assertEquals(snapshot.stale, false); assertEquals(snapshot.axisStart, NOW - 7 * DAY); assertEquals(snapshot.axisEnd, NOW); assertEquals(snapshot.overall?.kind, "overall"); assertEquals( snapshot.overall?.points.map((point) => point.seconds), Array.from({ length: 8 }, (_, day) => 300 + day * 15), ); assertEquals(snapshot.groups.length, 1); assertEquals(snapshot.groups[0].base, "Test"); assertEquals(snapshot.groups[0].maxConcurrent, 2); assertEquals( snapshot.groups[0].shards.map((series) => series.name), ["Test (1/2)", "Test (2/2)"], ); assertEquals( snapshot.groups[0].aggregate.points.map((point) => point.seconds), Array.from({ length: 8 }, (_, day) => 120 + day * 12), ); assertEquals( snapshot.groups[0].shards[0].points[0].seconds, 100, ); assertEquals( snapshot.jobs.map((series) => series.name), ["Check", "One platform (linux)"], ); assertEquals( baseJobName("Package Integration Tests (runner)"), "Package Integration Tests", ); }); Deno.test("CI job history preserves changing shard layouts and labels maximum concurrency", () => { const snapshot = buildCiJobHistory([ { runId: 1, runUrl: "https://example.test/runs/1", at: NOW - DAY, jobs: [ { name: "Test (1/3)", seconds: 40 }, { name: "Test (2/3)", seconds: 45 }, { name: "Test (3/3)", seconds: 60 }, ], }, { runId: 2, runUrl: "https://example.test/runs/2", at: NOW, jobs: [ { name: "Test (1/2)", seconds: 50 }, { name: "Test (2/2)", seconds: 55 }, ], }, ]); assertEquals(snapshot.groups[0].maxConcurrent, 3); assertEquals(snapshot.groups[0].shards.length, 5); assertStringIncludes( ciJobHistoryPage(snapshot, "job"), "up to 3 concurrent · 5 historical variants", ); assertStringIncludes( ciJobHistoryPage(snapshot, "job"), "Individual shard · last seen Jun 19", ); }); Deno.test("CI job history page shows the slowest shard, every shard, and unsharded jobs", () => { const html = ciJobHistoryPage( buildCiJobHistory( historySamples(), 0, undefined, [], Array.from({ length: 418 }, (_, index) => NOW - index), ), "job", ); assertStringIncludes(html, "CI job history"); assertStringIncludes(html, PERFORMANCE_VIEW_STYLES); assertStringIncludes( html, 'href="/bench?view=ci&repo=labs&days=45&sort=job&stat=p99" aria-current="page"', ); assertStringIncludes(html, '