rvlib-shipkit 1.6.0 → 1.8.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 +26 -2
- package/package.json +1 -1
- 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_GlobalContext.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +38 -1
- package/src/cli/cmd_Insights.ts +78 -0
- package/src/cli.ts +6 -1
- package/src/core/global-contribution.ts +42 -0
- package/src/core/trace-audit.ts +16 -7
- package/src/home/global-context.ts +145 -0
- package/src/home/gui-app/ShipkitGui.swift +125 -41
- package/src/home/home-routes.ts +25 -0
- package/src/home/insights-shape.ts +124 -0
- package/src/home/insights.ts +90 -0
- package/src/home/serve-web.ts +3 -3
- package/src/home/widgets.ts +12 -2
- package/src/serve/hub-app/home-st.ts +12 -5
- package/src/serve/hub-app/home-strip.css +2 -0
- package/src/serve/hub-app/home-strip.tsx +4 -0
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +2 -0
- package/src/serve/hub-app/icons.tsx +1 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-st.ts +111 -0
- package/src/serve/hub-app/insights-view.tsx +112 -0
- package/src/serve/hub-app/insights.css +24 -0
- package/src/serve/hub-app/keys.ts +1 -1
- package/src/serve/hub-app/machine-st.ts +23 -0
- package/src/serve/hub-app/palette-st.ts +2 -1
- package/src/serve/hub-app/project-list.tsx +3 -2
- package/src/serve/hub-app/projects-svc.ts +1 -1
- package/src/serve/hub-app/repo-st.ts +1 -1
- package/src/serve/hub-runtime.ts +31 -21
- package/src/serve/run-lanes.ts +134 -0
- package/src/shipkit-files.ts +3 -2
- package/src/suite/judge-suites.ts +27 -7
- package/src/suite/reads-env.ts +8 -0
- package/src/suite/select.ts +510 -0
- package/src/suite/suite-cli.ts +196 -14
- package/src/suite/suite-reads-preload.ts +335 -0
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, overWords, 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,8 +184,11 @@ 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)
|
|
@@ -150,7 +206,7 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
150
206
|
const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
|
|
151
207
|
const aloneAt = Date.now()
|
|
152
208
|
for (const [i, t] of rerun.entries()) {
|
|
153
|
-
const run = await runPhase([...
|
|
209
|
+
const run = await runPhase([...plan.alone, t.file], join(dir, `alone-${i}.json`), ceilingMs, tmp, false, null, rec)
|
|
154
210
|
const wallMs = Object.entries(run.timings).find(([f]) => normSuitePath(f) === t.file)?.[1]
|
|
155
211
|
if (wallMs != null) aloneRuns[t.file] = { wallMs, cpuMs: run.cpuMs }
|
|
156
212
|
}
|
|
@@ -167,17 +223,21 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
167
223
|
: null
|
|
168
224
|
const crashed = crashedWorkers(p1.out)
|
|
169
225
|
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(
|
|
226
|
+
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))]
|
|
171
227
|
for (const line of stalls) if (line != null) killed.unshift(line)
|
|
172
228
|
if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
|
|
173
229
|
if (p2?.signal != null) {
|
|
174
|
-
const unfinished = phaseFiles(
|
|
230
|
+
const unfinished = phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2.timings[normSuitePath(a)] == null)
|
|
175
231
|
killed.push(`killed by ${p2.signal} while running ${unfinished.join(' or ')} (out of memory, or killed from outside)`)
|
|
176
232
|
}
|
|
177
233
|
// the record behind the hub's test files to fix: every suite this run
|
|
178
234
|
// timed, with the alone timing where one was taken, and red when a
|
|
179
235
|
// test in it failed or its worker died
|
|
180
236
|
const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashedWorkers(p1.out).map((c) => c.file)])
|
|
237
|
+
const overSet = new Set(over.map((o) => o.file))
|
|
238
|
+
ctx.complete = killed.length === 0
|
|
239
|
+
ctx.out = phases.map((p) => p.out).join('\n')
|
|
240
|
+
ctx.ran = Object.keys(timings).map(normSuitePath).filter((f) => !red.has(f) && !overSet.has(f))
|
|
181
241
|
const at = Date.now()
|
|
182
242
|
const alone = new Map([...over, ...cleared, ...clearedInPhase].map((t) => [t.file, t.ms]))
|
|
183
243
|
const why = failLines(phases.map((p) => p.out).join('\n'))
|
|
@@ -227,12 +287,134 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
227
287
|
// the most actionable failure prints LAST, where the gate reads its headline
|
|
228
288
|
for (const f of fails.slice(1).reverse()) console.log(f)
|
|
229
289
|
if (fails.length > 0) { console.log(fails[0]); return 1 }
|
|
230
|
-
const serialCount =
|
|
231
|
-
|
|
290
|
+
const serialCount = serialArgs != null ? phaseFiles(serialArgs, rawArgs).length : 0
|
|
291
|
+
const chosen = ctx.sel?.picked != null ? `, the ${ctx.sel.picked.length} of ${ctx.sel.total} a change reaches` : ''
|
|
292
|
+
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${slowest}`)
|
|
232
293
|
return 0
|
|
233
294
|
} finally {
|
|
234
295
|
rmSync(dir, { recursive: true, force: true })
|
|
235
296
|
}
|
|
236
297
|
}
|
|
237
298
|
|
|
299
|
+
/** a selection in flight: the recorder's file, the state it started from,
|
|
300
|
+
* the snapshot taken before the run, and the suites it picked (cwd
|
|
301
|
+
* relative, null = every suite) */
|
|
302
|
+
type Selecting = {
|
|
303
|
+
rec: Recording
|
|
304
|
+
statePath: string
|
|
305
|
+
state: SelectState
|
|
306
|
+
now: Record<string, string>
|
|
307
|
+
graph: Record<string, GraphEntry>
|
|
308
|
+
preloads: string[]
|
|
309
|
+
cwdRel: string
|
|
310
|
+
picked: string[] | null
|
|
311
|
+
/** when the snapshot was taken */
|
|
312
|
+
started: number
|
|
313
|
+
/** every suite of the run, repo-relative */
|
|
314
|
+
allSuites: string[]
|
|
315
|
+
total: number
|
|
316
|
+
changed: number
|
|
317
|
+
notes: string[]
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** a bun test argument that is neither a flag nor a flag's value: a path or
|
|
321
|
+
* a filter the repo's own script names, which a selection would override */
|
|
322
|
+
function positional(rawArgs: string[]): string | null {
|
|
323
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
324
|
+
const a = rawArgs[i]!
|
|
325
|
+
if (a.startsWith('-')) {
|
|
326
|
+
if (!a.includes('=') && VALUE_FLAGS.includes(a)) i++
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
return a
|
|
330
|
+
}
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
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']
|
|
334
|
+
|
|
335
|
+
/** the recorder's file and, in the quick tier, the suites a change since the
|
|
336
|
+
* last green run reaches. A string says why nothing is recorded */
|
|
337
|
+
function prepareSelection(o: { tests: TestsConfigLike; tier: 'quick' | 'full'; rawArgs: string[]; bunfig: BunfigTest; unit: string; plan: { parallel: string[] } }): Selecting | string {
|
|
338
|
+
const env = process.env[SUITE_SELECT_ENV]
|
|
339
|
+
if (env === '0') return `skip: suite selection (${SUITE_SELECT_ENV}=0): every suite runs`
|
|
340
|
+
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`
|
|
341
|
+
// a run that filters tests by name passes suites it ran only in part: it can neither pick nor clear one
|
|
342
|
+
const filter = o.rawArgs.find((a) => /^(-t|--test-name-pattern|--only|--todo)(=|$)/.test(a))
|
|
343
|
+
if (filter != null) return `skip: suite selection (the run filters tests with ${filter}): every suite runs, and this run records nothing`
|
|
344
|
+
const g = gitRoot(process.cwd())
|
|
345
|
+
if (g == null) return 'skip: suite selection (not in a git work tree): every suite runs'
|
|
346
|
+
const top = g.top
|
|
347
|
+
const cwdRel = g.prefix.replace(/\/$/, '')
|
|
348
|
+
const notes: string[] = []
|
|
349
|
+
const t0 = performance.now()
|
|
350
|
+
try {
|
|
351
|
+
const statePath = selectStatePath(top, o.unit)
|
|
352
|
+
const state = loadState(statePath)
|
|
353
|
+
const cfg = loadConfig(top)
|
|
354
|
+
const ignores = [
|
|
355
|
+
...o.bunfig.ignores,
|
|
356
|
+
...o.plan.parallel.filter((a) => a.startsWith('--path-ignore-patterns=')).map((a) => a.slice('--path-ignore-patterns='.length)),
|
|
357
|
+
]
|
|
358
|
+
const suites = suiteFiles(top, cwdRel, o.bunfig.root, ignores, isBunSuite)
|
|
359
|
+
const toTop = (f: string): string => (cwdRel === '' ? f : `${cwdRel}/${f}`)
|
|
360
|
+
const bunfigText = existsSync('bunfig.toml') ? readFileSync('bunfig.toml', 'utf8') : null
|
|
361
|
+
const preloads = preloadsOf(bunfigText, o.rawArgs, cwdRel)
|
|
362
|
+
const loads = Object.values(state.modules).flatMap((m) => m.l ?? [])
|
|
363
|
+
const graph = walkGraph(cfg, [...suites.map(toTop), ...preloads, ...loads], state.graph)
|
|
364
|
+
const started = Date.now()
|
|
365
|
+
const now = snapshot(top, state.modules)
|
|
366
|
+
const rec: Recording = { file: join(tmpdir(), `shipkit-reads-${process.pid}-${Date.now()}.tsv`), top }
|
|
367
|
+
const base = { rec, statePath, state, now, started, graph, preloads, cwdRel, allSuites: suites.map(toTop), total: suites.length, notes, changed: 0 }
|
|
368
|
+
if (o.tier !== 'quick') return { ...base, picked: null }
|
|
369
|
+
const pos = positional(o.rawArgs)
|
|
370
|
+
if (pos != null) { notes.push(`note: every suite runs: the test script names ${pos}, which a selection would override`); return { ...base, picked: null } }
|
|
371
|
+
if (state.base == null) { notes.push('note: every suite runs: no run recorded yet, the next run selects'); return { ...base, picked: null } }
|
|
372
|
+
const changed = changedKeys(state.base, now)
|
|
373
|
+
if (state.bun !== Bun.version) { notes.push(`note: every suite runs: bun ${Bun.version} ran no recorded run yet`); return { ...base, picked: null } }
|
|
374
|
+
const roots = [...new Set([...cfg.roots, posix.normalize(posix.join(cwdRel, o.bunfig.root ?? '.')), ...preloads.map((p) => posix.dirname(p))])]
|
|
375
|
+
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 })
|
|
376
|
+
if (sel.all) { notes.push(`note: every suite runs: ${sel.why}`); return { ...base, picked: null, changed: changed.size } }
|
|
377
|
+
const fromTop = (f: string): string => (cwdRel === '' ? f : f.slice(cwdRel.length + 1))
|
|
378
|
+
const picked = sel.run.map(fromTop)
|
|
379
|
+
const reasons = [...new Set(sel.why.values())]
|
|
380
|
+
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` : ''}`)
|
|
381
|
+
return { ...base, picked, changed: changed.size }
|
|
382
|
+
} catch (e) {
|
|
383
|
+
return `WARN: suite selection failed, so every suite runs: ${e instanceof Error ? e.message : String(e)}`
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
type TestsConfigLike = { select?: boolean }
|
|
388
|
+
|
|
389
|
+
/** what a run leaves for the next: the reads it recorded, the snapshot it
|
|
390
|
+
* started from, and the suites it picked that did not pass. A full run where
|
|
391
|
+
* every suite ran to its end saw every read, so it replaces the reads
|
|
392
|
+
* instead of adding to them: a quick run sees only the paths its suites took */
|
|
393
|
+
function finishSelection(sel: Selecting, passed: string[], replace: boolean, out: string): string | null {
|
|
394
|
+
try {
|
|
395
|
+
const text = existsSync(sel.rec.file) ? readFileSync(sel.rec.file, 'utf8') : ''
|
|
396
|
+
const modules = dropStaticEdges(foldReads(replace ? {} : sel.state.modules, text), sel.graph)
|
|
397
|
+
const toTop = (f: string): string => (sel.cwdRel === '' ? f : `${sel.cwdRel}/${f}`)
|
|
398
|
+
const ok = new Set(passed.map(toTop))
|
|
399
|
+
const recorded = [...new Set([...(replace ? [] : sel.state.recorded), ...ok])].sort()
|
|
400
|
+
// a suite this run picked and did not see pass stays dirty; with no
|
|
401
|
+
// pick, every suite was picked. One the run did not pick keeps its state
|
|
402
|
+
const picked = sel.picked == null ? sel.allSuites : sel.picked.map(toTop)
|
|
403
|
+
const before = sel.picked == null ? [] : sel.state.dirty.filter((d) => !picked.includes(d))
|
|
404
|
+
// a module that threw while loading loads silently in the next file of
|
|
405
|
+
// a worker that shares its modules, so a suite reaching it is not
|
|
406
|
+
// trusted green, whatever bun said of it
|
|
407
|
+
const thrown = loadErrorModules(out, sel.rec.top)
|
|
408
|
+
const tainted = taintedSuites(picked, thrown, sel.graph, modules, sel.preloads)
|
|
409
|
+
const dirty = [...new Set([...before, ...picked.filter((s) => !ok.has(s) || tainted.has(s))])].sort()
|
|
410
|
+
// a read first recorded by this run joins the snapshot it started from,
|
|
411
|
+
// unless its path moved since the start
|
|
412
|
+
const base = { ...sel.now, ...extras(sel.rec.top, modules, sel.now, sel.started) }
|
|
413
|
+
saveState(sel.statePath, { ...sel.state, base, dirty, modules, recorded, graph: sel.graph, bun: Bun.version })
|
|
414
|
+
return null
|
|
415
|
+
} catch (e) {
|
|
416
|
+
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`
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
238
420
|
if (import.meta.main) process.exit(await suiteCli(process.argv.slice(2)))
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// WHAT EACH MODULE READ, preloaded into every test worker and every bun
|
|
2
|
+
// process a suite spawns. The quick tier runs only the suites whose reads
|
|
3
|
+
// changed since the last green run (select.ts), and a module's static imports
|
|
4
|
+
// are only part of what it reads: a suite also reads files by path, lists
|
|
5
|
+
// dirs, globs and spawns. This preload records each of those, attributed to
|
|
6
|
+
// every module of the repo on the call stack. A memo filled by one suite and
|
|
7
|
+
// used by the next is still charged to the module that holds it, and every
|
|
8
|
+
// suite whose import closure holds that module depends on the read.
|
|
9
|
+
//
|
|
10
|
+
// Lines, appended to the file SHIPKIT_SUITE_READS names: `<kind>\t<module>\t<path>`.
|
|
11
|
+
// r a file read, probed or stat'ed (its content, or its absence, matters)
|
|
12
|
+
// d a dir listed (its names matter)
|
|
13
|
+
// g a tree walked by a glob (every name under it matters)
|
|
14
|
+
// o a process spawned that reads what nobody records (git or a shell run
|
|
15
|
+
// in the repo): the module is opaque, and every suite that reaches it
|
|
16
|
+
// runs every time
|
|
17
|
+
// l a repo module loaded at runtime from a call this module was on the
|
|
18
|
+
// stack of (a require or an import() in a function body)
|
|
19
|
+
// L a repo module loaded while this module itself was being loaded
|
|
20
|
+
// A read with no repo frame on its stack is charged to `-`, which every suite
|
|
21
|
+
// depends on. A bun child records under the module that spawned it.
|
|
22
|
+
//
|
|
23
|
+
// node:fs is replaced through bun:test's mock.module, the one hook that also
|
|
24
|
+
// reaches a named ESM import, and it updates modules imported before it.
|
|
25
|
+
import { mock, afterAll } from 'bun:test'
|
|
26
|
+
import * as fsLive from 'node:fs'
|
|
27
|
+
import * as fspLive from 'node:fs/promises'
|
|
28
|
+
import * as cpLive from 'node:child_process'
|
|
29
|
+
import { appendFileSync, realpathSync } from 'node:fs'
|
|
30
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
import { SUITE_OWNER_ENV, SUITE_READS_ENV, SUITE_ROOT_ENV } from './reads-env.ts'
|
|
33
|
+
|
|
34
|
+
const out = process.env[SUITE_READS_ENV]
|
|
35
|
+
const root = process.env[SUITE_ROOT_ENV]
|
|
36
|
+
const owner = process.env[SUITE_OWNER_ENV]
|
|
37
|
+
const SELF = import.meta.path
|
|
38
|
+
|
|
39
|
+
if (out != null && out !== '' && root != null && root !== '') install(out, resolve(root))
|
|
40
|
+
|
|
41
|
+
function install(outFile: string, rootDir: string): void {
|
|
42
|
+
const realFs = { ...fsLive } as Record<string, unknown>
|
|
43
|
+
const realFsp = { ...fspLive } as Record<string, unknown>
|
|
44
|
+
const realCp = { ...cpLive } as Record<string, unknown>
|
|
45
|
+
// the root as git prints it is a realpath, and a path a module names can
|
|
46
|
+
// go through a symlink to it (macOS: /var is /private/var), so each
|
|
47
|
+
// spelling of the root counts
|
|
48
|
+
const real = ((): string => { try { return realpathSync(rootDir) } catch { return rootDir } })()
|
|
49
|
+
const roots = [...new Set([rootDir, real, ...[rootDir, real].filter((r) => r.startsWith('/private/')).map((r) => r.slice('/private'.length))])]
|
|
50
|
+
const seen = new Set<string>()
|
|
51
|
+
let buf: string[] = []
|
|
52
|
+
|
|
53
|
+
/** repo-relative posix path, null outside the repo, in an install, or in git's own dir */
|
|
54
|
+
const rel = (p: unknown): string | null => {
|
|
55
|
+
let s: string
|
|
56
|
+
if (typeof p === 'string') s = p
|
|
57
|
+
else if (p instanceof URL) { if (p.protocol !== 'file:') return null; s = fileURLToPath(p) }
|
|
58
|
+
else if (p instanceof Uint8Array) s = Buffer.from(p).toString()
|
|
59
|
+
else return null
|
|
60
|
+
if (s.startsWith('file:')) { try { s = fileURLToPath(s) } catch { return null } }
|
|
61
|
+
const abs = isAbsolute(s) ? resolve(s) : resolve(process.cwd(), s)
|
|
62
|
+
const base = roots.find((r) => abs === r || abs.startsWith(r.endsWith(sep) ? r : `${r}${sep}`))
|
|
63
|
+
if (base == null) return null
|
|
64
|
+
const r = relative(base, abs).split(sep).join('/')
|
|
65
|
+
if (r === '' ) return '.'
|
|
66
|
+
if (r === 'node_modules' || r.startsWith('node_modules/') || r.includes('/node_modules/')) return null
|
|
67
|
+
return r
|
|
68
|
+
}
|
|
69
|
+
/** the innermost repo module on the stack, outside this file */
|
|
70
|
+
// a read is charged to EVERY repo module on the stack: the one that read
|
|
71
|
+
// it, the ones that hold a memo of what it read, and the one that asked. A
|
|
72
|
+
// cache a preload installs is asked through the module that holds its
|
|
73
|
+
// hook, and every suite reusing the memo calls that module again
|
|
74
|
+
const callers = (): string[] => {
|
|
75
|
+
if (owner != null && owner !== '') return [owner]
|
|
76
|
+
const limit = Error.stackTraceLimit
|
|
77
|
+
Error.stackTraceLimit = 80
|
|
78
|
+
const stack = new Error().stack ?? ''
|
|
79
|
+
Error.stackTraceLimit = limit
|
|
80
|
+
const out: string[] = []
|
|
81
|
+
for (const line of stack.split('\n')) {
|
|
82
|
+
const m = /\(?((?:\/|[A-Za-z]:\\)[^():]+):\d+:\d+\)?\s*$/.exec(line)
|
|
83
|
+
const file = m?.[1]
|
|
84
|
+
if (file == null || file === SELF) continue
|
|
85
|
+
const r = rel(file)
|
|
86
|
+
if (r != null && r !== '.' && !out.includes(r)) out.push(r)
|
|
87
|
+
}
|
|
88
|
+
return out.length > 0 ? out : ['-']
|
|
89
|
+
}
|
|
90
|
+
/** the module a child records under: the innermost one that spawned it */
|
|
91
|
+
const caller = (): string => callers()[0]!
|
|
92
|
+
/** a read through a symlink reads its target too */
|
|
93
|
+
const target = (path: string): string | null => {
|
|
94
|
+
try {
|
|
95
|
+
const real = rel((realFs['realpathSync'] as (p: string) => string)(join(rootDir, path)))
|
|
96
|
+
return real != null && real !== path ? real : null
|
|
97
|
+
} catch {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const note = (kind: string, path: string | null, mods: string | string[] = callers()): void => {
|
|
102
|
+
if (path == null) return
|
|
103
|
+
if (kind !== 'o') {
|
|
104
|
+
const t = target(path)
|
|
105
|
+
if (t != null) note(kind, t, mods)
|
|
106
|
+
}
|
|
107
|
+
for (const mod of typeof mods === 'string' ? [mods] : mods) {
|
|
108
|
+
const line = `${kind}\t${mod}\t${path}`
|
|
109
|
+
if (seen.has(line)) continue
|
|
110
|
+
seen.add(line)
|
|
111
|
+
buf.push(line)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const flush = (): void => {
|
|
115
|
+
if (buf.length === 0) return
|
|
116
|
+
try { appendFileSync(outFile, `${buf.join('\n')}\n`) } catch { /* no record: the next full run fills it */ }
|
|
117
|
+
buf = []
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const READS = ['readFileSync', 'readFile', 'existsSync', 'exists', 'statSync', 'stat', 'lstatSync', 'lstat', 'accessSync', 'access', 'openSync', 'open', 'createReadStream', 'readlinkSync', 'readlink', 'realpathSync', 'realpath', 'opendirSync', 'opendir', 'copyFileSync', 'copyFile', 'watchFile', 'statfsSync', 'statfs']
|
|
121
|
+
// a copy or a watch of a dir takes the whole tree under it
|
|
122
|
+
const TREES = ['cpSync', 'cp', 'watch']
|
|
123
|
+
const LISTS = ['readdirSync', 'readdir']
|
|
124
|
+
const GLOBS = ['globSync', 'glob']
|
|
125
|
+
const wrap = (real: Record<string, unknown>, kind: string, names: string[]): Record<string, unknown> => {
|
|
126
|
+
const shim: Record<string, unknown> = {}
|
|
127
|
+
for (const n of names) {
|
|
128
|
+
const f = real[n]
|
|
129
|
+
if (typeof f !== 'function') continue
|
|
130
|
+
const g = function (this: unknown, ...a: unknown[]): unknown {
|
|
131
|
+
if (kind === 'g') note('g', globRoot(a[0], a[1]))
|
|
132
|
+
else if (kind === 't') {
|
|
133
|
+
const r = rel(a[0])
|
|
134
|
+
note(r != null && exists(join(rootDir, r), false) ? 'r' : 'g', r)
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
const r = rel(a[0])
|
|
138
|
+
// a recursive listing is a walk of the whole tree
|
|
139
|
+
const recursive = kind === 'd' && typeof a[1] === 'object' && a[1] != null && (a[1] as { recursive?: unknown }).recursive === true
|
|
140
|
+
note(recursive ? 'g' : n.startsWith('opendir') ? 'd' : kind, r)
|
|
141
|
+
}
|
|
142
|
+
return (f as (...x: unknown[]) => unknown).apply(this, a)
|
|
143
|
+
}
|
|
144
|
+
Object.assign(g, f)
|
|
145
|
+
shim[n] = g
|
|
146
|
+
}
|
|
147
|
+
return shim
|
|
148
|
+
}
|
|
149
|
+
const globRoot = (pattern: unknown, opts: unknown): string | null => {
|
|
150
|
+
const pats = Array.isArray(pattern) ? pattern : [pattern]
|
|
151
|
+
const cwd = typeof opts === 'object' && opts != null && typeof (opts as { cwd?: unknown }).cwd === 'string' ? (opts as { cwd: string }).cwd : process.cwd()
|
|
152
|
+
const roots = pats.filter((p): p is string => typeof p === 'string').map((p) => {
|
|
153
|
+
const lit = p.split('/').filter((_, i, a) => !/[*?[{]/.test(a.slice(0, i + 1).join('/'))).join('/')
|
|
154
|
+
return rel(join(cwd, lit))
|
|
155
|
+
})
|
|
156
|
+
return roots.length === 1 ? roots[0]! : rel(cwd)
|
|
157
|
+
}
|
|
158
|
+
const fsShim: Record<string, unknown> = { ...realFs, ...wrap(realFs, 'r', READS), ...wrap(realFs, 'd', LISTS), ...wrap(realFs, 'g', GLOBS), ...wrap(realFs, 't', TREES) }
|
|
159
|
+
const fspShim: Record<string, unknown> = { ...realFsp, ...wrap(realFsp, 'r', READS), ...wrap(realFsp, 'd', LISTS), ...wrap(realFsp, 'g', GLOBS), ...wrap(realFsp, 't', TREES) }
|
|
160
|
+
fsShim['promises'] = fspShim
|
|
161
|
+
mock.module('node:fs', () => ({ ...fsShim, default: fsShim }))
|
|
162
|
+
mock.module('fs', () => ({ ...fsShim, default: fsShim }))
|
|
163
|
+
mock.module('node:fs/promises', () => ({ ...fspShim, default: fspShim }))
|
|
164
|
+
mock.module('fs/promises', () => ({ ...fspShim, default: fspShim }))
|
|
165
|
+
|
|
166
|
+
// a bun child runs this same preload and records under the spawning module.
|
|
167
|
+
// Any other child reads what nobody records
|
|
168
|
+
/** a path that stands there (a file, or a dir when `dir`) */
|
|
169
|
+
const exists = (abs: string, dir: boolean): boolean => {
|
|
170
|
+
try {
|
|
171
|
+
const st = (realFs['statSync'] as (p: string) => { isFile(): boolean; isDirectory(): boolean })(abs)
|
|
172
|
+
return st.isFile() || (dir && st.isDirectory())
|
|
173
|
+
} catch { return false }
|
|
174
|
+
}
|
|
175
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'fish', 'env', 'xargs', 'nice', 'time', 'timeout'])
|
|
176
|
+
// tools that read only the files their arguments name, nothing they find through them
|
|
177
|
+
const FILE_TOOLS = new Set(['true', 'false', 'echo', 'sleep', 'kill', 'rm', 'mkdir', 'touch', 'cwebp', 'dwebp'])
|
|
178
|
+
const isBun = (bin: unknown): boolean => typeof bin === 'string' && (bin === 'bun' || bin === process.execPath || /(^|\/)bun$/.test(bin))
|
|
179
|
+
const childEnv = (env: unknown): Record<string, string | undefined> => {
|
|
180
|
+
const base = (typeof env === 'object' && env != null ? env : process.env) as Record<string, string | undefined>
|
|
181
|
+
const opts = base['BUN_OPTIONS'] ?? ''
|
|
182
|
+
return {
|
|
183
|
+
...base,
|
|
184
|
+
[SUITE_READS_ENV]: outFile,
|
|
185
|
+
[SUITE_ROOT_ENV]: rootDir,
|
|
186
|
+
[SUITE_OWNER_ENV]: caller(),
|
|
187
|
+
BUN_OPTIONS: opts.includes(SELF) ? opts : `${opts} --preload=${SELF}`.trim(),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// a child that is not bun reads what nobody records. It is trusted only
|
|
191
|
+
// when it runs outside the repo and names nothing in it (git in a temp
|
|
192
|
+
// repo); a tool known to read only the files it names (cwebp) records
|
|
193
|
+
// them; any other child in or about the repo makes its module opaque
|
|
194
|
+
const spawned = (cmd: unknown, cwdOpt: unknown): void => {
|
|
195
|
+
const argv = (Array.isArray(cmd) ? cmd : typeof cmd === 'string' ? [cmd] : []).map(String)
|
|
196
|
+
if (isBun(argv[0])) return
|
|
197
|
+
const tool = (argv[0] ?? '?').split('/').pop() ?? '?'
|
|
198
|
+
const cwd = typeof cwdOpt === 'string' ? resolve(cwdOpt) : cwdOpt instanceof URL ? fileURLToPath(cwdOpt) : process.cwd()
|
|
199
|
+
const shell = SHELLS.has(tool)
|
|
200
|
+
const words = argv.slice(1).flatMap((a) => (shell ? a.split(/[\s;&|<>()'"`=]+/) : [a.replace(/^--?[\w-]+=/, '')])).filter((w) => w !== '' && !w.startsWith('-'))
|
|
201
|
+
const inRepo: string[] = []
|
|
202
|
+
for (const w of words) {
|
|
203
|
+
const abs = isAbsolute(w) ? w : join(cwd, w)
|
|
204
|
+
if (!isAbsolute(w) && !w.includes('/') && !exists(abs, true)) continue
|
|
205
|
+
const r = rel(abs)
|
|
206
|
+
if (r != null) inRepo.push(r)
|
|
207
|
+
}
|
|
208
|
+
// a tool known to read only the files it names records them; any other
|
|
209
|
+
// tool is trusted only far from the repo: run outside it, naming nothing in it
|
|
210
|
+
if (!shell && FILE_TOOLS.has(tool)) {
|
|
211
|
+
for (const r of inRepo) note(exists(join(rootDir, r), false) ? 'r' : 'g', r)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
if (rel(cwd) != null || inRepo.length > 0) note('o', tool)
|
|
215
|
+
}
|
|
216
|
+
const realSpawn = Bun.spawn
|
|
217
|
+
const realSpawnSync = Bun.spawnSync
|
|
218
|
+
const viaBun = (real: typeof Bun.spawn | typeof Bun.spawnSync) => function (this: unknown, ...a: unknown[]): unknown {
|
|
219
|
+
const first = a[0]
|
|
220
|
+
const cmd = Array.isArray(first) ? first : (first as { cmd?: unknown } | undefined)?.cmd
|
|
221
|
+
spawned(cmd, ((Array.isArray(first) ? a[1] : first) as { cwd?: unknown } | undefined)?.cwd)
|
|
222
|
+
if (Array.isArray(first)) {
|
|
223
|
+
const opts = (a[1] ?? {}) as { env?: unknown }
|
|
224
|
+
return (real as (...x: unknown[]) => unknown).call(this, first, { ...opts, env: childEnv(opts.env) })
|
|
225
|
+
}
|
|
226
|
+
const opts = (first ?? {}) as { env?: unknown }
|
|
227
|
+
return (real as (...x: unknown[]) => unknown).call(this, { ...opts, env: childEnv(opts.env) })
|
|
228
|
+
}
|
|
229
|
+
Bun.spawn = viaBun(realSpawn) as typeof Bun.spawn
|
|
230
|
+
Bun.spawnSync = viaBun(realSpawnSync) as typeof Bun.spawnSync
|
|
231
|
+
const cpWrap = (n: string, cmdAt: (a: unknown[]) => unknown, optsAt: number | ((a: unknown[]) => number)): unknown => {
|
|
232
|
+
const f = realCp[n]
|
|
233
|
+
if (typeof f !== 'function') return f
|
|
234
|
+
return function (this: unknown, ...a: unknown[]): unknown {
|
|
235
|
+
const cmd = cmdAt(a)
|
|
236
|
+
const i = typeof optsAt === 'number' ? optsAt : optsAt(a)
|
|
237
|
+
const opts = (typeof a[i] === 'object' && a[i] != null && !Array.isArray(a[i]) ? a[i] : {}) as { env?: unknown; cwd?: unknown }
|
|
238
|
+
if (n === 'exec' || n === 'execSync') spawned(['sh', '-c', cmd], opts.cwd)
|
|
239
|
+
else spawned([cmd, ...(Array.isArray(a[1]) ? a[1] : [])], opts.cwd)
|
|
240
|
+
const args = [...a]
|
|
241
|
+
while (args.length < i) args.push(undefined)
|
|
242
|
+
args[i] = { ...opts, env: childEnv(opts.env) }
|
|
243
|
+
return (f as (...x: unknown[]) => unknown).apply(this, args)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const withArgs = (a: unknown[]): number => (Array.isArray(a[1]) ? 2 : 1)
|
|
247
|
+
const cpShim: Record<string, unknown> = {
|
|
248
|
+
...realCp,
|
|
249
|
+
spawn: cpWrap('spawn', (a) => a[0], withArgs),
|
|
250
|
+
spawnSync: cpWrap('spawnSync', (a) => a[0], withArgs),
|
|
251
|
+
execFile: cpWrap('execFile', (a) => a[0], withArgs),
|
|
252
|
+
execFileSync: cpWrap('execFileSync', (a) => a[0], withArgs),
|
|
253
|
+
exec: cpWrap('exec', (a) => a[0], 1),
|
|
254
|
+
execSync: cpWrap('execSync', (a) => a[0], 1),
|
|
255
|
+
fork: cpWrap('fork', () => 'bun', withArgs),
|
|
256
|
+
}
|
|
257
|
+
mock.module('node:child_process', () => ({ ...cpShim, default: cpShim }))
|
|
258
|
+
mock.module('child_process', () => ({ ...cpShim, default: cpShim }))
|
|
259
|
+
const realShell = Bun.$
|
|
260
|
+
Bun.$ = new Proxy(realShell, { apply: (t, self, a) => { note('o', 'sh'); return Reflect.apply(t, self, a) } })
|
|
261
|
+
|
|
262
|
+
// a file:// fetch is a read; a Worker runs code no preload of ours sees
|
|
263
|
+
const realFetch = globalThis.fetch
|
|
264
|
+
globalThis.fetch = Object.assign(function (this: unknown, input: unknown, init?: unknown): unknown {
|
|
265
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as { url?: unknown } | null)?.url
|
|
266
|
+
if (typeof url === 'string' && url.startsWith('file:')) note('r', rel(url))
|
|
267
|
+
return (realFetch as (...x: unknown[]) => unknown).call(this, input, init)
|
|
268
|
+
}, realFetch) as typeof fetch
|
|
269
|
+
for (const name of ['Worker', 'SharedWorker']) {
|
|
270
|
+
const Real = (globalThis as Record<string, unknown>)[name] as (new (...a: unknown[]) => object) | undefined
|
|
271
|
+
if (typeof Real !== 'function') continue
|
|
272
|
+
const Wrapped = function (this: unknown, ...a: unknown[]): object {
|
|
273
|
+
note('o', name.toLowerCase())
|
|
274
|
+
return new Real(...a)
|
|
275
|
+
} as unknown as typeof Real
|
|
276
|
+
Wrapped.prototype = Real.prototype
|
|
277
|
+
;(globalThis as Record<string, unknown>)[name] = Wrapped
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const realFile = Bun.file
|
|
281
|
+
Bun.file = function (this: unknown, p: unknown, o?: unknown): unknown {
|
|
282
|
+
note('r', rel(p))
|
|
283
|
+
return (realFile as (...x: unknown[]) => unknown).call(this, p, o)
|
|
284
|
+
} as typeof Bun.file
|
|
285
|
+
// a Glob keeps no pattern a caller can read, so each one remembers its own:
|
|
286
|
+
// the walked tree is the cwd joined with the pattern's literal head
|
|
287
|
+
const RealGlob = Bun.Glob
|
|
288
|
+
const patterns = new WeakMap<object, string>()
|
|
289
|
+
const Glob = function (this: unknown, pattern: string): unknown {
|
|
290
|
+
const g = new RealGlob(pattern)
|
|
291
|
+
patterns.set(g, pattern)
|
|
292
|
+
return g
|
|
293
|
+
} as unknown as typeof Bun.Glob
|
|
294
|
+
Glob.prototype = RealGlob.prototype
|
|
295
|
+
Bun.Glob = Glob
|
|
296
|
+
const G = RealGlob.prototype as unknown as Record<string, (...a: unknown[]) => unknown>
|
|
297
|
+
for (const n of ['scan', 'scanSync']) {
|
|
298
|
+
const f = G[n]
|
|
299
|
+
if (typeof f !== 'function') continue
|
|
300
|
+
G[n] = function (this: object, opts?: unknown): unknown {
|
|
301
|
+
const cwd = typeof opts === 'string' ? opts : typeof opts === 'object' && opts != null && typeof (opts as { cwd?: unknown }).cwd === 'string' ? (opts as { cwd: string }).cwd : process.cwd()
|
|
302
|
+
note('g', globRoot(patterns.get(this) ?? '**', { cwd }))
|
|
303
|
+
return f.call(this, opts)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// every import resolved at runtime, as an edge from its importer and from
|
|
308
|
+
// every module on the stack: a module a computed import loads is in no
|
|
309
|
+
// static closure, and the edge charges it wherever the load was asked
|
|
310
|
+
// from, whichever suite ran first. A key already seen asks nothing again
|
|
311
|
+
const edges = new Set<string>()
|
|
312
|
+
const imported = (spec: string, importer: string | undefined): void => {
|
|
313
|
+
if (importer == null || importer === '' || spec.startsWith('node:') || spec.startsWith('bun:')) return
|
|
314
|
+
const from = rel(importer)
|
|
315
|
+
if (from == null || from === '.') return
|
|
316
|
+
const key = `${from}\0${spec}\0${new Error().stack ?? ''}`
|
|
317
|
+
if (edges.has(key)) return
|
|
318
|
+
edges.add(key)
|
|
319
|
+
const bare = spec.replace(/\?.*$/, '')
|
|
320
|
+
const base = isAbsolute(bare) ? bare : bare.startsWith('.') ? join(importer, '..', bare) : join(rootDir, bare)
|
|
321
|
+
const abs = [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts')].find((x) => exists(x, false))
|
|
322
|
+
if (abs == null) return
|
|
323
|
+
const to = rel(abs)
|
|
324
|
+
// charged like a read: a lazy load inside a hook a preload installed
|
|
325
|
+
// is reached through the modules that called the hook
|
|
326
|
+
if (to == null || to === from) return
|
|
327
|
+
const asked = callers().filter((c) => c !== from && c !== '-')
|
|
328
|
+
if (asked.length === 0) note('L', to, from)
|
|
329
|
+
else note('l', to, [from, ...asked])
|
|
330
|
+
}
|
|
331
|
+
Bun.plugin({ name: 'shipkit-suite-reads', setup(b) { b.onResolve({ filter: /.*/ }, (a) => { imported(a.path, a.importer); return undefined }) } })
|
|
332
|
+
|
|
333
|
+
try { afterAll(flush) } catch { /* not under bun test: the exit hook below records it */ }
|
|
334
|
+
process.on('exit', flush)
|
|
335
|
+
}
|