rvlib-shipkit 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env bun
2
+ // the floor `test` slot for a plain `bun test` script: quick tier suites in
3
+ // parallel workers, the full tier one at a time, the declared serial ones after,
4
+ // and each quick tier suite timed against the budget.
5
+ // The LAST stdout line is the task headline the gate prints, and it keeps bun's
6
+ // `Ran N tests` so the gate can still report what ran.
7
+ //
8
+ // usage: suite-cli [--budget-ms N] [--slow file]... [--serial file]... [--declared-in <where>] -- <bun test args>
9
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
10
+ import { tmpdir } from 'os'
11
+ import { join } from 'path'
12
+ import { FULL_TESTS_ENV } from '../ledger/gate-units.ts'
13
+ import { confirmOver, crashedWorkers, DEFAULT_BUDGET_MS, judgeSuites, normSuitePath, parseSuiteFlags, planPhases } from './judge-suites.ts'
14
+
15
+ type Phase = { code: number; signal: string | null; out: string; timings: Record<string, number> }
16
+
17
+ async function runPhase(args: string[], timingsFile: string | null, echo = true): Promise<Phase> {
18
+ const timing = timingsFile != null ? [`--timings=${timingsFile}`, '--update-timings'] : []
19
+ const proc = Bun.spawn(['bun', 'test', ...args, ...timing], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' })
20
+ let out = ''
21
+ const pump = async (stream: ReadableStream<Uint8Array>, sink: NodeJS.WriteStream): Promise<void> => {
22
+ const dec = new TextDecoder()
23
+ for await (const chunk of stream) {
24
+ const text = dec.decode(chunk)
25
+ out += text
26
+ if (echo) sink.write(text)
27
+ }
28
+ }
29
+ await Promise.all([pump(proc.stdout, process.stdout), pump(proc.stderr, process.stderr)])
30
+ const code = await proc.exited
31
+ let timings: Record<string, number> = {}
32
+ try { if (timingsFile != null && existsSync(timingsFile)) timings = (JSON.parse(readFileSync(timingsFile, 'utf8')) as { files?: Record<string, number> }).files ?? {} }
33
+ catch { timings = {} }
34
+ return { code, signal: proc.signalCode ?? null, out, timings }
35
+ }
36
+
37
+ const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
38
+ const lastCount = (out: string, re: RegExp): number => Number([...out.matchAll(re)].at(-1)?.[1] ?? 0)
39
+
40
+ export async function suiteCli(argv: string[]): Promise<number> {
41
+ const parsed = parseSuiteFlags(argv)
42
+ if (typeof parsed === 'string') { console.log(`FAIL: suite runner: ${parsed}`); return 2 }
43
+ const { tests, rawArgs, declaredIn } = parsed
44
+ const tier = process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
45
+ const budget = tests.budgetMs ?? DEFAULT_BUDGET_MS
46
+
47
+ // a declaration naming a gone suite would silently stop excluding anything
48
+ const gone = [...(tests.slow ?? []).map((s) => ['slow', s.file]), ...(tests.serial ?? []).map((s) => ['serial', s.file])]
49
+ .filter(([, f]) => !existsSync(f!))
50
+ if (gone.length > 0) {
51
+ for (const [list, f] of gone) console.log(`FAIL: ${declaredIn}.${list} names ${normSuitePath(f!)}, which does not exist`)
52
+ return 1
53
+ }
54
+
55
+ const help = Bun.spawnSync(['bun', 'test', '--help'], { stdout: 'pipe', stderr: 'pipe' })
56
+ const helpText = `${help.stdout.toString()}${help.stderr.toString()}`
57
+ // the full tier runs one suite at a time: its suites spawn git and whole
58
+ // gates, and sharing a run measured slower than sequential and timed out
59
+ const parallel = helpText.includes('--parallel') && tier === 'quick'
60
+ const timed = helpText.includes('--update-timings')
61
+ const plan = planPhases({ tests, tier, rawArgs, parallel })
62
+ const dir = mkdtempSync(join(tmpdir(), 'shipkit-suite-'))
63
+ const notes: string[] = []
64
+ if (!helpText.includes('--parallel')) notes.push(`skip: parallel suites (bun ${Bun.version} has no --parallel)`)
65
+ if (!timed) notes.push(`skip: suite budget (bun ${Bun.version} writes no --timings)`)
66
+ try {
67
+ const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null)
68
+ const p2 = plan.serial != null ? await runPhase(plan.serial, timed ? join(dir, 'serial.json') : null) : null
69
+ const phases = [p1, ...(p2 != null ? [p2] : [])]
70
+ const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
71
+ const failedTests = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) fail$/gm), 0)
72
+ const timings = Object.assign({}, ...phases.map((p) => p.timings)) as Record<string, number>
73
+ const judged = judgeSuites({ timings, tests, tier })
74
+ const slowest = judged.slowest.length > 0 ? ` (slowest: ${judged.slowest.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')})` : ''
75
+
76
+ // a parallel timing carries every other suite's load, so a suspect is
77
+ // timed again alone, one process, before it can fail the task
78
+ let over = judged.over
79
+ if (over.length > 0) {
80
+ const alone = await runPhase([...rawArgs, ...over.map((t) => t.file)], join(dir, 'alone.json'), false)
81
+ over = confirmOver(over, alone.timings, budget)
82
+ }
83
+
84
+ const killed = crashedWorkers(p1.out).map((c) => `killed by ${c.signal} while running ${c.file} (out of memory, or killed from outside)`)
85
+ if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
86
+ if (p2?.signal != null) {
87
+ const unfinished = (plan.serial ?? []).filter((a) => !a.startsWith('-') && !rawArgs.includes(a) && p2.timings[normSuitePath(a)] == null)
88
+ killed.push(`killed by ${p2.signal} while running ${unfinished.join(' or ')} (out of memory, or killed from outside)`)
89
+ }
90
+ // never blocking: a suite that got faster must not stop a release
91
+ const stale = judged.noLongerSlow.map((t) => `WARN ${declaredIn}.slow declares ${t.file}, which ran in ${secs(t.ms)}, under half the ${budget}ms budget: remove the declaration`)
92
+
93
+ for (const n of notes) console.log(n)
94
+ for (const s of stale) console.log(s)
95
+ const fails: string[] = [
96
+ ...killed.map((k) => `FAIL: ${k}`),
97
+ ...(failedTests > 0 || (phases.some((p) => p.code !== 0 && p.signal == null) && killed.length === 0)
98
+ ? [`FAIL: ${failedTests} test(s) failed, Ran ${ran} tests${slowest}`] : []),
99
+ ...(over.length > 0
100
+ ? [`FAIL: ${over.length} suite(s) over the ${budget}ms budget: ${over.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')} (timed alone). Make each fast, or declare it in ${declaredIn}.slow with what it catches and why it cannot be fast`]
101
+ : []),
102
+ ]
103
+ // the most actionable failure prints LAST, where the gate reads its headline
104
+ for (const f of fails.slice(1).reverse()) console.log(f)
105
+ if (fails.length > 0) { console.log(fails[0]); return 1 }
106
+ const serialCount = plan.serial != null ? plan.serial.length - rawArgs.length : 0
107
+ console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites, ${parallel ? 'parallel' : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier${slowest}`)
108
+ return 0
109
+ } finally {
110
+ rmSync(dir, { recursive: true, force: true })
111
+ }
112
+ }
113
+
114
+ if (import.meta.main) process.exit(await suiteCli(process.argv.slice(2)))