Files
orca/config/scripts/ci-runner-metrics.test.mjs
T

179 lines
5.7 KiB
JavaScript

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 }])
})