rvlib-shipkit 1.7.0 → 1.9.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.
- package/CHANGELOG.md +48 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +31 -14
- package/src/assets/gen/generate-roll.ts +20 -7
- package/src/assets/kit-config.ts +9 -0
- package/src/assets/kits/identity-kit.ts +7 -1
- package/src/assets/run-assets.ts +3 -3
- package/src/assets/server/studio-server.ts +3 -3
- package/src/checks/SK027-agent-wiki-links.check.ts +6 -2
- package/src/checks/SK033-global-contribution.check.ts +47 -0
- package/src/cli/cmd_Ci.ts +5 -4
- package/src/cli/cmd_GlobalContext.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +38 -1
- package/src/cli.ts +4 -1
- package/src/collections/providers.ts +1 -1
- package/src/core/check-runner.ts +13 -4
- package/src/core/discover-checks.ts +37 -8
- package/src/core/fix-scope.ts +5 -0
- package/src/core/floor-resolve.ts +2 -1
- package/src/core/git-root.ts +22 -12
- package/src/core/global-contribution.ts +42 -0
- package/src/core/one-lib.ts +4 -2
- package/src/core/run-ci.ts +12 -11
- package/src/core/slots.ts +36 -6
- package/src/home/global-context.ts +145 -0
- package/src/home/gui-app/ShipkitGui.swift +96 -0
- package/src/home/home-routes.ts +10 -0
- package/src/home/resources.ts +3 -0
- package/src/plan/planner.ts +21 -4
- package/src/plan/repo-source.ts +7 -3
- package/src/release/run-release.ts +17 -8
- package/src/release/unit-gate.ts +12 -5
- package/src/repo-config.ts +7 -0
- package/src/serve/hub-runtime.ts +34 -23
- package/src/serve/project-gutter.ts +5 -2
- package/src/serve/run-lanes.ts +138 -0
- package/src/smoke/run-smoke.ts +13 -6
- package/src/suite/judge-suites.ts +79 -11
- package/src/suite/reads-env.ts +8 -0
- package/src/suite/select.ts +510 -0
- package/src/suite/suite-cli.ts +213 -23
- package/src/suite/suite-reads-preload.ts +335 -0
- package/src/ts/typecheck-cache.ts +11 -0
- package/src/ts/typecheck-cli.ts +2 -1
package/src/suite/suite-cli.ts
CHANGED
|
@@ -9,27 +9,39 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
10
10
|
import { availableParallelism, tmpdir } from 'os'
|
|
11
11
|
import { machineLoad } from '../core/budget.ts'
|
|
12
|
-
import { join } from 'path'
|
|
12
|
+
import { join, posix } from 'path'
|
|
13
13
|
import { FULL_TESTS_ENV } from '../ledger/gate-checks.ts'
|
|
14
14
|
import { CHECK_ID_ENV, recordSuites, type SuitePhase } from '../ledger/suite-log.ts'
|
|
15
15
|
import { killTree } from '../core/spawn-timeout.ts'
|
|
16
16
|
import { parallelCap } from '../core/parallel.ts'
|
|
17
17
|
import { poolDir, poolOccupancy } from '../core/slots.ts'
|
|
18
18
|
import { keepTimings, seedTimings, timingsHome } from './kept-timings.ts'
|
|
19
|
-
import {
|
|
19
|
+
import { SUITE_READS_ENV, SUITE_ROOT_ENV, SUITE_SELECT_ENV } from './reads-env.ts'
|
|
20
|
+
import { changedKeys, dropStaticEdges, extras, isBunSuite, envFilesOf, foldReads, loadErrorModules, loadState, preloadsOf, saveState, selectStatePath, selectSuites, snapshot, suiteFiles, taintedSuites, walkGraph, type GraphEntry, type Selection, type SelectState } from './select.ts'
|
|
21
|
+
import { gitRoot } from '../core/git-root.ts'
|
|
22
|
+
import { loadConfig } from '../ts/config.ts'
|
|
23
|
+
import { aloneRunsNeeded, bunfigTest, type BunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, interruptedSuites, overWords, ranAs, retryAlone, smallerRun, stallWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
|
|
20
24
|
|
|
21
25
|
type Phase = { code: number; signal: string | null; stalled: boolean; out: string; timings: Record<string, number>; cpuMs: number | null }
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
/** where the read recorder writes, and the repo root it charges reads under */
|
|
28
|
+
type Recording = { file: string; top: string }
|
|
29
|
+
|
|
30
|
+
async function runPhase(args: string[], timingsFile: string | null, ceilingMs: number, tmp: string, echo = true, cpuFile: string | null = null, rec: Recording | null = null): Promise<Phase> {
|
|
24
31
|
const timing = timingsFile != null ? [`--timings=${timingsFile}`, '--update-timings'] : []
|
|
25
32
|
// the per suite CPU figure rides in a preload of the runner's own, beside
|
|
26
33
|
// the repo's (a command line preload adds to bunfig's, never replaces it)
|
|
27
34
|
const cpu = cpuFile != null ? ['--preload', CPU_PRELOAD] : []
|
|
35
|
+
const reads = rec != null ? ['--preload', READS_PRELOAD] : []
|
|
28
36
|
// TMPDIR is the run's own dir, deleted with it: whatever a suite forgets to
|
|
29
37
|
// clean dies here, never in the machine temp dir, whose listing bun reads on
|
|
30
38
|
// the first import from a fixture there (90k leaked dirs cost ~100ms each time)
|
|
31
|
-
const env = {
|
|
32
|
-
|
|
39
|
+
const env = {
|
|
40
|
+
...process.env, TMPDIR: tmp,
|
|
41
|
+
...(cpuFile != null ? { [SUITE_CPU_ENV]: cpuFile } : {}),
|
|
42
|
+
...(rec != null ? { [SUITE_READS_ENV]: rec.file, [SUITE_ROOT_ENV]: rec.top } : {}),
|
|
43
|
+
}
|
|
44
|
+
const proc = Bun.spawn(['bun', 'test', ...cpu, ...reads, ...timing, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', env })
|
|
33
45
|
// the workers are children of `bun test`, and a stuck one is the process
|
|
34
46
|
// to reach, so the whole tree goes
|
|
35
47
|
let stalled = false
|
|
@@ -60,6 +72,8 @@ async function runPhase(args: string[], timingsFile: string | null, ceilingMs: n
|
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
const CPU_PRELOAD = join(import.meta.dir, 'suite-cpu-preload.ts')
|
|
75
|
+
const READS_PRELOAD = join(import.meta.dir, 'suite-reads-preload.ts')
|
|
76
|
+
const NO_PHASE: Phase = { code: 0, signal: null, stalled: false, out: '', timings: {}, cpuMs: 0 }
|
|
63
77
|
|
|
64
78
|
const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
|
|
65
79
|
|
|
@@ -68,12 +82,30 @@ const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
|
|
|
68
82
|
const phaseFiles = (phase: string[], rawArgs: string[]): string[] => phase.filter((a) => !a.startsWith('-') && !rawArgs.includes(a))
|
|
69
83
|
const lastCount = (out: string, re: RegExp): number => Number([...out.matchAll(re)].at(-1)?.[1] ?? 0)
|
|
70
84
|
|
|
85
|
+
/** what the selection carries from before the run to after it */
|
|
86
|
+
type SelectCtx = { sel: Selecting | null; /** the suites that ran and passed, time included */ ran: string[]; tier: 'quick' | 'full'; /** every suite ran to its end, red or green: no phase stalled or was killed */ complete: boolean; /** what every phase printed */ out: string }
|
|
87
|
+
|
|
71
88
|
export async function suiteCli(argv: string[]): Promise<number> {
|
|
89
|
+
const ctx: SelectCtx = { sel: null, ran: [], tier: 'quick', complete: false, out: '' }
|
|
90
|
+
try {
|
|
91
|
+
const code = await runSuites(argv, ctx)
|
|
92
|
+
if (ctx.sel != null) {
|
|
93
|
+
const problem = finishSelection(ctx.sel, ctx.ran, ctx.tier === 'full' && ctx.complete, ctx.out)
|
|
94
|
+
if (problem != null) console.log(problem)
|
|
95
|
+
}
|
|
96
|
+
return code
|
|
97
|
+
} finally {
|
|
98
|
+
if (ctx.sel != null) rmSync(ctx.sel.rec.file, { force: true })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
|
|
72
103
|
const t0 = Date.now()
|
|
73
104
|
const parsed = parseSuiteFlags(argv)
|
|
74
105
|
if (typeof parsed === 'string') { console.log(`FAIL: suite runner: ${parsed}`); return 2 }
|
|
75
106
|
const { tests, rawArgs, declaredIn, unit } = parsed
|
|
76
107
|
const tier = process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
|
|
108
|
+
ctx.tier = tier
|
|
77
109
|
const ceilingMs = parsed.ceilingMs ?? (tier === 'full' ? FULL_CEILING_MS : QUICK_CEILING_MS)
|
|
78
110
|
const budget = tests.budgetMs ?? DEFAULT_BUDGET_MS
|
|
79
111
|
|
|
@@ -97,8 +129,9 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
97
129
|
// the pool is read, never taken: this process runs on its check's slot
|
|
98
130
|
const cores = availableParallelism()
|
|
99
131
|
const held = parallel ? poolOccupancy({ dir: poolDir(), size: parallelCap() }).held.length : 0
|
|
100
|
-
const
|
|
101
|
-
const plan = planPhases({ tests, tier, rawArgs, parallel, noIsolate: helpText.includes('--no-isolate'), configIgnores: bunfig.ignores, workers })
|
|
132
|
+
const pool = parallel ? poolWorkers({ cores, size: parallelCap(), held }) : undefined
|
|
133
|
+
const plan = planPhases({ tests, tier, rawArgs, parallel, noIsolate: helpText.includes('--no-isolate'), configIgnores: bunfig.ignores, workers: pool })
|
|
134
|
+
const { workers } = plan
|
|
102
135
|
const dir = mkdtempSync(join(tmpdir(), 'shipkit-suite-'))
|
|
103
136
|
const tmp = join(dir, 'temp')
|
|
104
137
|
mkdirSync(tmp)
|
|
@@ -115,8 +148,28 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
115
148
|
// the load BEFORE this run: read after it, the one minute average
|
|
116
149
|
// carries this run's own parallel phase, which is no other process
|
|
117
150
|
const load = machineLoad()
|
|
151
|
+
const picked = prepareSelection({ tests, tier, rawArgs, bunfig, unit, plan })
|
|
152
|
+
if (typeof picked === 'string') notes.push(picked)
|
|
153
|
+
else {
|
|
154
|
+
ctx.sel = picked
|
|
155
|
+
for (const n of picked.notes) console.log(n)
|
|
156
|
+
if (picked.picked != null && picked.picked.length === 0) {
|
|
157
|
+
for (const n of notes) console.log(n)
|
|
158
|
+
console.log(`ok: no suite reaches what changed since the last run (${picked.changed} path(s)), and every one passed then, so none ran`)
|
|
159
|
+
return 0
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const rec = ctx.sel?.rec ?? null
|
|
163
|
+
const pickedSet = ctx.sel?.picked == null ? null : new Set(ctx.sel.picked)
|
|
164
|
+
const serialSet = new Set((tests.serial ?? []).map((x) => normSuitePath(x.file)))
|
|
165
|
+
const parallelArgs = pickedSet == null ? plan.parallel : [...plan.parallel, ...[...pickedSet].filter((f) => !serialSet.has(f)).map((f) => `./${f}`)]
|
|
166
|
+
const serialArgs = plan.serial == null || pickedSet == null ? plan.serial : (() => {
|
|
167
|
+
const files = [...serialSet].filter((f) => pickedSet.has(f))
|
|
168
|
+
return files.length === 0 ? null : [...plan.alone, ...files]
|
|
169
|
+
})()
|
|
170
|
+
const parallelEmpty = pickedSet != null && parallelArgs.length === plan.parallel.length
|
|
118
171
|
const p1At = Date.now()
|
|
119
|
-
const p1 = await runPhase(
|
|
172
|
+
const p1 = parallelEmpty ? { ...NO_PHASE } : await runPhase(parallelArgs, timed ? join(dir, 'parallel.json') : null, ceilingMs, tmp, true, cpuFile, rec)
|
|
120
173
|
const p1Ms = Date.now() - p1At
|
|
121
174
|
// bun merges what it read into what it writes, so a suite deleted since
|
|
122
175
|
// the last run is judged and kept only if the workers say it ran here
|
|
@@ -131,14 +184,19 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
131
184
|
keepTimings(join(dir, 'parallel.json'), kept)
|
|
132
185
|
}
|
|
133
186
|
}
|
|
187
|
+
// the kept durations keep every suite for the next run's schedule,
|
|
188
|
+
// and only the picked ones ran here, so only they are judged
|
|
189
|
+
if (pickedSet != null) p1.timings = Object.fromEntries(Object.entries(p1.timings).filter(([f]) => pickedSet.has(normSuitePath(f))))
|
|
134
190
|
const p2At = Date.now()
|
|
135
|
-
const p2 =
|
|
191
|
+
const p2 = serialArgs != null ? await runPhase(serialArgs, timed ? join(dir, 'serial.json') : null, ceilingMs, tmp, true, null, rec) : null
|
|
136
192
|
const p2Ms = p2 == null ? null : Date.now() - p2At
|
|
137
193
|
const phases = [p1, ...(p2 != null ? [p2] : [])]
|
|
138
194
|
const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
|
|
139
195
|
const failedTests = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) fail$/gm), 0)
|
|
140
196
|
const timings = Object.assign({}, ...phases.map((p) => p.timings)) as Record<string, number>
|
|
141
|
-
const
|
|
197
|
+
const crashed = crashedWorkers(p1.out)
|
|
198
|
+
const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashed.map((c) => c.file)])
|
|
199
|
+
const judged = judgeSuites({ timings, tests, tier, red: [...red] })
|
|
142
200
|
const slowest = judged.slowest.length > 0 ? ` (slowest: ${judged.slowest.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')})` : ''
|
|
143
201
|
|
|
144
202
|
// a parallel timing carries every other suite's load, so a suspect
|
|
@@ -149,10 +207,20 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
149
207
|
const parallelCpu = phaseCpu.cpu
|
|
150
208
|
const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
|
|
151
209
|
const aloneAt = Date.now()
|
|
152
|
-
|
|
153
|
-
const run = await runPhase([...
|
|
210
|
+
const runAlone = async (t: { file: string }, name: string): Promise<AloneRun | null> => {
|
|
211
|
+
const run = await runPhase([...plan.alone, t.file], join(dir, `${name}.json`), ceilingMs, tmp, false, null, rec)
|
|
154
212
|
const wallMs = Object.entries(run.timings).find(([f]) => normSuitePath(f) === t.file)?.[1]
|
|
155
|
-
|
|
213
|
+
return wallMs == null ? null : { wallMs, cpuMs: run.cpuMs }
|
|
214
|
+
}
|
|
215
|
+
for (const [i, t] of rerun.entries()) {
|
|
216
|
+
const first = await runAlone(t, `alone-${i}`)
|
|
217
|
+
if (first == null) continue
|
|
218
|
+
aloneRuns[t.file] = first
|
|
219
|
+
if (!retryAlone(t, first, budget, load)) continue
|
|
220
|
+
const second = await runAlone(t, `alone-${i}-again`)
|
|
221
|
+
if (second == null) continue
|
|
222
|
+
aloneRuns[t.file] = smallerRun(first, second)
|
|
223
|
+
notes.push(`note: ${t.file} ran alone twice, ${secs(first.cpuMs ?? first.wallMs)} then ${secs(second.cpuMs ?? second.wallMs)} of CPU, and the smaller stands: a lone run the scheduler spills onto slower cores burns more CPU for the same work`)
|
|
156
224
|
}
|
|
157
225
|
const aloneMs = Date.now() - aloneAt
|
|
158
226
|
const { over, cleared } = rerun.length > 0 ? confirmOver(rerun, aloneRuns, budget, load) : { over: [], cleared: [] }
|
|
@@ -162,26 +230,26 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
162
230
|
// a ceiling kill makes bun report every worker it held as crashed, and
|
|
163
231
|
// those are the suites that never finished, not an outside kill
|
|
164
232
|
const stalledIn = (name: string, p: Phase | null, files: string[]): string | null =>
|
|
165
|
-
p?.stalled === true
|
|
166
|
-
? `${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test worker stopped answering, most likely stuck in a synchronous call, which no per test timeout can interrupt`
|
|
167
|
-
: null
|
|
168
|
-
const crashed = crashedWorkers(p1.out)
|
|
233
|
+
p?.stalled === true ? stallWords(name, ceilingMs, [...new Set([...files, ...interruptedSuites(p.out)])]) : null
|
|
169
234
|
const killed = p1.stalled ? [] : crashed.map((c) => `killed by ${c.signal} while running ${c.file} (out of memory, or killed from outside)`)
|
|
170
|
-
const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(
|
|
235
|
+
const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2?.timings[normSuitePath(a)] == null).map(normSuitePath))]
|
|
171
236
|
for (const line of stalls) if (line != null) killed.unshift(line)
|
|
172
237
|
if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
|
|
173
238
|
if (p2?.signal != null) {
|
|
174
|
-
const unfinished = phaseFiles(
|
|
239
|
+
const unfinished = phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2.timings[normSuitePath(a)] == null)
|
|
175
240
|
killed.push(`killed by ${p2.signal} while running ${unfinished.join(' or ')} (out of memory, or killed from outside)`)
|
|
176
241
|
}
|
|
177
242
|
// the record behind the hub's test files to fix: every suite this run
|
|
178
243
|
// timed, with the alone timing where one was taken, and red when a
|
|
179
244
|
// test in it failed or its worker died
|
|
180
|
-
const
|
|
245
|
+
const overSet = new Set(over.map((o) => o.file))
|
|
246
|
+
ctx.complete = killed.length === 0
|
|
247
|
+
ctx.out = phases.map((p) => p.out).join('\n')
|
|
248
|
+
ctx.ran = Object.keys(timings).map(normSuitePath).filter((f) => !red.has(f) && !overSet.has(f))
|
|
181
249
|
const at = Date.now()
|
|
182
250
|
const alone = new Map([...over, ...cleared, ...clearedInPhase].map((t) => [t.file, t.ms]))
|
|
183
251
|
const why = failLines(phases.map((p) => p.out).join('\n'))
|
|
184
|
-
for (const c of
|
|
252
|
+
for (const c of crashed) if (!why.has(c.file)) why.set(c.file, `its worker crashed: ${c.signal}`)
|
|
185
253
|
// which phase timed each file, and the CPU spent on it: an alone
|
|
186
254
|
// rerun, the parallel phase's own figure, or a phase that ran that one file
|
|
187
255
|
const phaseOf = (f: string): SuitePhase => (p2 != null && Object.keys(p2.timings).some((k) => normSuitePath(k) === f) ? 'serial' : parallel ? 'parallel' : 'sequential')
|
|
@@ -227,12 +295,134 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
227
295
|
// the most actionable failure prints LAST, where the gate reads its headline
|
|
228
296
|
for (const f of fails.slice(1).reverse()) console.log(f)
|
|
229
297
|
if (fails.length > 0) { console.log(fails[0]); return 1 }
|
|
230
|
-
const serialCount =
|
|
231
|
-
|
|
298
|
+
const serialCount = serialArgs != null ? phaseFiles(serialArgs, rawArgs).length : 0
|
|
299
|
+
const chosen = ctx.sel?.picked != null ? `, the ${ctx.sel.picked.length} of ${ctx.sel.total} a change reaches` : ''
|
|
300
|
+
console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites${chosen}, ${parallel ? `parallel on ${workers} workers` : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier, ${ranAs(plan.parallel, rawArgs)}${slowest}`)
|
|
232
301
|
return 0
|
|
233
302
|
} finally {
|
|
234
303
|
rmSync(dir, { recursive: true, force: true })
|
|
235
304
|
}
|
|
236
305
|
}
|
|
237
306
|
|
|
307
|
+
/** a selection in flight: the recorder's file, the state it started from,
|
|
308
|
+
* the snapshot taken before the run, and the suites it picked (cwd
|
|
309
|
+
* relative, null = every suite) */
|
|
310
|
+
type Selecting = {
|
|
311
|
+
rec: Recording
|
|
312
|
+
statePath: string
|
|
313
|
+
state: SelectState
|
|
314
|
+
now: Record<string, string>
|
|
315
|
+
graph: Record<string, GraphEntry>
|
|
316
|
+
preloads: string[]
|
|
317
|
+
cwdRel: string
|
|
318
|
+
picked: string[] | null
|
|
319
|
+
/** when the snapshot was taken */
|
|
320
|
+
started: number
|
|
321
|
+
/** every suite of the run, repo-relative */
|
|
322
|
+
allSuites: string[]
|
|
323
|
+
total: number
|
|
324
|
+
changed: number
|
|
325
|
+
notes: string[]
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** a bun test argument that is neither a flag nor a flag's value: a path or
|
|
329
|
+
* a filter the repo's own script names, which a selection would override */
|
|
330
|
+
function positional(rawArgs: string[]): string | null {
|
|
331
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
332
|
+
const a = rawArgs[i]!
|
|
333
|
+
if (a.startsWith('-')) {
|
|
334
|
+
if (!a.includes('=') && VALUE_FLAGS.includes(a)) i++
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
return a
|
|
338
|
+
}
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
const VALUE_FLAGS = ['--preload', '-r', '--env-file', '--timeout', '-t', '--test-name-pattern', '--path-ignore-patterns', '--reporter', '--reporter-outfile', '--coverage-dir', '--rerun-each', '--seed', '--max-concurrency', '--cwd', '--config', '-c', '--tsconfig-override', '--define', '-d', '--loader', '-l', '--jsx-factory', '--jsx-fragment', '--jsx-import-source', '--conditions', '--timings']
|
|
342
|
+
|
|
343
|
+
/** the recorder's file and, in the quick tier, the suites a change since the
|
|
344
|
+
* last green run reaches. A string says why nothing is recorded */
|
|
345
|
+
function prepareSelection(o: { tests: TestsConfigLike; tier: 'quick' | 'full'; rawArgs: string[]; bunfig: BunfigTest; unit: string; plan: { parallel: string[] } }): Selecting | string {
|
|
346
|
+
const env = process.env[SUITE_SELECT_ENV]
|
|
347
|
+
if (env === '0') return `skip: suite selection (${SUITE_SELECT_ENV}=0): every suite runs`
|
|
348
|
+
if (o.tests.select !== true && env !== '1') return `skip: suite selection (tests.select is not true, ${SUITE_SELECT_ENV}=1 turns it on for one run): every suite runs`
|
|
349
|
+
// a run that filters tests by name passes suites it ran only in part: it can neither pick nor clear one
|
|
350
|
+
const filter = o.rawArgs.find((a) => /^(-t|--test-name-pattern|--only|--todo)(=|$)/.test(a))
|
|
351
|
+
if (filter != null) return `skip: suite selection (the run filters tests with ${filter}): every suite runs, and this run records nothing`
|
|
352
|
+
const g = gitRoot(process.cwd())
|
|
353
|
+
if (g == null) return 'skip: suite selection (not in a git work tree): every suite runs'
|
|
354
|
+
const top = g.top
|
|
355
|
+
const cwdRel = g.prefix.replace(/\/$/, '')
|
|
356
|
+
const notes: string[] = []
|
|
357
|
+
const t0 = performance.now()
|
|
358
|
+
try {
|
|
359
|
+
const statePath = selectStatePath(top, o.unit)
|
|
360
|
+
const state = loadState(statePath)
|
|
361
|
+
const cfg = loadConfig(top)
|
|
362
|
+
const ignores = [
|
|
363
|
+
...o.bunfig.ignores,
|
|
364
|
+
...o.plan.parallel.filter((a) => a.startsWith('--path-ignore-patterns=')).map((a) => a.slice('--path-ignore-patterns='.length)),
|
|
365
|
+
]
|
|
366
|
+
const suites = suiteFiles(top, cwdRel, o.bunfig.root, ignores, isBunSuite)
|
|
367
|
+
const toTop = (f: string): string => (cwdRel === '' ? f : `${cwdRel}/${f}`)
|
|
368
|
+
const bunfigText = existsSync('bunfig.toml') ? readFileSync('bunfig.toml', 'utf8') : null
|
|
369
|
+
const preloads = preloadsOf(bunfigText, o.rawArgs, cwdRel)
|
|
370
|
+
const loads = Object.values(state.modules).flatMap((m) => m.l ?? [])
|
|
371
|
+
const graph = walkGraph(cfg, [...suites.map(toTop), ...preloads, ...loads], state.graph)
|
|
372
|
+
const started = Date.now()
|
|
373
|
+
const now = snapshot(top, state.modules)
|
|
374
|
+
const rec: Recording = { file: join(tmpdir(), `shipkit-reads-${process.pid}-${Date.now()}.tsv`), top }
|
|
375
|
+
const base = { rec, statePath, state, now, started, graph, preloads, cwdRel, allSuites: suites.map(toTop), total: suites.length, notes, changed: 0 }
|
|
376
|
+
if (o.tier !== 'quick') return { ...base, picked: null }
|
|
377
|
+
const pos = positional(o.rawArgs)
|
|
378
|
+
if (pos != null) { notes.push(`note: every suite runs: the test script names ${pos}, which a selection would override`); return { ...base, picked: null } }
|
|
379
|
+
if (state.base == null) { notes.push('note: every suite runs: no run recorded yet, the next run selects'); return { ...base, picked: null } }
|
|
380
|
+
const changed = changedKeys(state.base, now)
|
|
381
|
+
if (state.bun !== Bun.version) { notes.push(`note: every suite runs: bun ${Bun.version} ran no recorded run yet`); return { ...base, picked: null } }
|
|
382
|
+
const roots = [...new Set([...cfg.roots, posix.normalize(posix.join(cwdRel, o.bunfig.root ?? '.')), ...preloads.map((p) => posix.dirname(p))])]
|
|
383
|
+
const sel: Selection = selectSuites({ suites: suites.map(toTop), preloads, envFiles: envFilesOf(o.rawArgs, cwdRel), graph, modules: state.modules, recorded: new Set(state.recorded), dirty: new Set(state.dirty), changed, roots })
|
|
384
|
+
if (sel.all) { notes.push(`note: every suite runs: ${sel.why}`); return { ...base, picked: null, changed: changed.size } }
|
|
385
|
+
const fromTop = (f: string): string => (cwdRel === '' ? f : f.slice(cwdRel.length + 1))
|
|
386
|
+
const picked = sel.run.map(fromTop)
|
|
387
|
+
const reasons = [...new Set(sel.why.values())]
|
|
388
|
+
if (picked.length > 0) notes.push(`note: ${picked.length} of ${suites.length} suites reach what changed since the last run (${changed.size} path(s)), or did not pass then, picked in ${Math.round(performance.now() - t0)}ms: ${reasons.slice(0, 3).join('; ')}${reasons.length > 3 ? `; ${reasons.length - 3} more` : ''}`)
|
|
389
|
+
return { ...base, picked, changed: changed.size }
|
|
390
|
+
} catch (e) {
|
|
391
|
+
return `WARN: suite selection failed, so every suite runs: ${e instanceof Error ? e.message : String(e)}`
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
type TestsConfigLike = { select?: boolean }
|
|
396
|
+
|
|
397
|
+
/** what a run leaves for the next: the reads it recorded, the snapshot it
|
|
398
|
+
* started from, and the suites it picked that did not pass. A full run where
|
|
399
|
+
* every suite ran to its end saw every read, so it replaces the reads
|
|
400
|
+
* instead of adding to them: a quick run sees only the paths its suites took */
|
|
401
|
+
function finishSelection(sel: Selecting, passed: string[], replace: boolean, out: string): string | null {
|
|
402
|
+
try {
|
|
403
|
+
const text = existsSync(sel.rec.file) ? readFileSync(sel.rec.file, 'utf8') : ''
|
|
404
|
+
const modules = dropStaticEdges(foldReads(replace ? {} : sel.state.modules, text), sel.graph)
|
|
405
|
+
const toTop = (f: string): string => (sel.cwdRel === '' ? f : `${sel.cwdRel}/${f}`)
|
|
406
|
+
const ok = new Set(passed.map(toTop))
|
|
407
|
+
const recorded = [...new Set([...(replace ? [] : sel.state.recorded), ...ok])].sort()
|
|
408
|
+
// a suite this run picked and did not see pass stays dirty; with no
|
|
409
|
+
// pick, every suite was picked. One the run did not pick keeps its state
|
|
410
|
+
const picked = sel.picked == null ? sel.allSuites : sel.picked.map(toTop)
|
|
411
|
+
const before = sel.picked == null ? [] : sel.state.dirty.filter((d) => !picked.includes(d))
|
|
412
|
+
// a module that threw while loading loads silently in the next file of
|
|
413
|
+
// a worker that shares its modules, so a suite reaching it is not
|
|
414
|
+
// trusted green, whatever bun said of it
|
|
415
|
+
const thrown = loadErrorModules(out, sel.rec.top)
|
|
416
|
+
const tainted = taintedSuites(picked, thrown, sel.graph, modules, sel.preloads)
|
|
417
|
+
const dirty = [...new Set([...before, ...picked.filter((s) => !ok.has(s) || tainted.has(s))])].sort()
|
|
418
|
+
// a read first recorded by this run joins the snapshot it started from,
|
|
419
|
+
// unless its path moved since the start
|
|
420
|
+
const base = { ...sel.now, ...extras(sel.rec.top, modules, sel.now, sel.started) }
|
|
421
|
+
saveState(sel.statePath, { ...sel.state, base, dirty, modules, recorded, graph: sel.graph, bun: Bun.version })
|
|
422
|
+
return null
|
|
423
|
+
} catch (e) {
|
|
424
|
+
return `WARN: suite selection could not keep this run (${e instanceof Error ? e.message : String(e)}), so the next run selects from the last one`
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
238
428
|
if (import.meta.main) process.exit(await suiteCli(process.argv.slice(2)))
|