import { expect, it } from 'vitest' import { runnerDemand, sampleWorkflowRuns } from './ci-runner-metrics.mjs' import { collectRunnerDemand } from './ci-runner-demand.mjs' const run = { id: 1, path: '.github/workflows/pr.yml', event: 'pull_request', conclusion: 'success' } const job = { runner_name: 'hosted', labels: ['ubuntu-latest'], created_at: '2026-09-27T00:00:00Z', started_at: '2026-09-27T00:03:00Z', completed_at: '2026-09-27T00:08:00Z' } it('separates occupancy, queueing, cancellations and zero-job observations', () => { const cancelled = { ...run, id: 2, conclusion: 'cancelled' } const report = runnerDemand( [run, cancelled], [ { run, weight: 2, jobs: [job, { ...job, runner_name: null }] }, { run: cancelled, weight: 3, jobs: [job, { ...job, completed_at: null }] } ] ) expect(report.workflows[0]).toMatchObject({ runnerMinutes: 25, cancelledRunnerMinutes: 15, runnerMinutesPerCompletedPrRun: 5 }) expect(report.pools[0].queueP95Minutes).toBe(3) expect(report.incompleteJobs).toBe(1) expect(runnerDemand([run], [{ run, weight: 1, jobs: [] }]).workflows[0].runnerMinutes).toBe(0) }) it('weights each workflow/outcome stratum back to the full inventory', () => { const runs = Array.from({ length: 20 }, (_, index) => ({ ...run, id: index, conclusion: index < 10 ? 'success' : 'failure' })) const sample = sampleWorkflowRuns(runs, 2, () => 0.5) expect(sample).toHaveLength(4) expect(sample.reduce((sum, row) => sum + row.weight, 0)).toBe(20) expect(new Set(sample.map((row) => row.run.id)).size).toBe(4) }) it('paginates jobs and bounds run discovery to 24 complete hours', async () => { const requests = [] const result = await collectRunnerDemand( { CI_METRICS_END: '2026-09-28T04:59:00Z' }, async (path) => { requests.push(path) if (path.includes('/jobs')) { return { total_count: 101, jobs: path.includes('page=2') ? [job] : Array.from({ length: 100 }, () => job) } } return { total_count: requests.length === 1 ? 1 : 0, workflow_runs: requests.length === 1 ? [run] : [] } } ) expect(result.report.start).toBe('2026-09-27T04:00:00.000Z') expect(result.report.end).toBe('2026-09-28T04:00:00.000Z') expect(requests.filter((path) => path.startsWith('actions/runs?'))).toHaveLength(24) expect(result.samples[0].jobs).toHaveLength(101) expect(requests.filter((path) => path.includes('/jobs'))).toEqual([ 'actions/runs/1/jobs?per_page=100&filter=all', 'actions/runs/1/jobs?per_page=100&filter=all&page=2' ]) expect(result.report.workflows[0].runnerMinutes).toBe(505) }) it('counts only later attempts of previously successful jobs, including failed repeats', () => { const observation = (name, run_attempt, conclusion, extra = {}) => ({ ...job, started_at: new Date(Date.parse(job.started_at) + (run_attempt ?? 0) * 600_000).toISOString(), completed_at: new Date( Date.parse(job.completed_at) + (run_attempt ?? 0) * 600_000 ).toISOString(), name, run_attempt, conclusion, ...extra }) const report = runnerDemand( [run], [ { run, weight: 2, jobs: [ observation('unit 1', 3, 'failure'), observation('unit 1', 1, 'success'), observation('unit 1', 2, 'success'), observation('unit 2', 1, 'failure'), observation('unit 2', 2, 'success'), observation('unit 2', 3, 'skipped', { runner_name: null }), observation('lint', 1, 'success'), observation('lint', 2, null, { completed_at: null }), observation('same attempt', 1, 'success'), observation('same attempt', 1, 'success'), observation('missing attempt', undefined, 'success'), observation('missing attempt', 2, 'failure') ] } ] ) expect(report.workflows[0]).toMatchObject({ repeatedSuccessfulJobs: 4, repeatedSuccessfulRunnerMinutes: 20, runnerMinutes: 100 }) expect(report.incompleteJobs).toBe(1) }) it('counts carried-forward results once while retaining fresh executions on the same runner', () => { const first = { ...job, id: 1, name: 'unit', run_attempt: 1, conclusion: 'success' } const carried = { ...first, id: 2, run_attempt: 2 } const fresh = { ...first, id: 3, run_attempt: 3, started_at: '2026-09-27T00:23:00Z', completed_at: '2026-09-27T00:28:00Z' } const failed = { ...first, id: 5, name: 'build', conclusion: 'failure' } const report = runnerDemand( [run], [ { run, weight: 2, jobs: [ { ...fresh, id: 4, run_attempt: 4 }, carried, first, fresh, failed, { ...failed, id: 6, run_attempt: 2 } ] } ] ) expect(report.workflows[0]).toMatchObject({ runnerMinutes: 30, repeatedSuccessfulJobs: 2, repeatedSuccessfulRunnerMinutes: 10 }) expect(report.pools[0].runnerMinutes).toBe(30) }) it('refuses to publish a silently truncated inventory', async () => { await expect( collectRunnerDemand({}, async () => ({ total_count: 1001, workflow_runs: [] })) ).rejects.toThrow('API limit') }) it('groups ref-qualified paths under the stable workflow ID', () => { const runs = [ { ...run, id: 1, workflow_id: 42, path: '.github/workflows/pr.yml@main' }, { ...run, id: 2, workflow_id: 42, path: '.github/workflows/pr.yml@feature' } ] const sample = sampleWorkflowRuns(runs, 1, () => 0.5) expect(sample).toHaveLength(1) expect(sample[0].weight).toBe(2) expect( runnerDemand( runs, sample.map((row) => ({ ...row, jobs: [job] })) ).workflows ).toMatchObject([{ workflow: '.github/workflows/pr.yml', runs: 2, runnerMinutes: 10 }]) })