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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// THE RUNS OF ONE HUB: who attaches, who runs beside whom, who waits.
|
|
2
|
+
//
|
|
3
|
+
// - a run with a key identical to one in flight attaches to it: the lines it
|
|
4
|
+
// printed so far, then the rest, then its exit code. To a queued one always
|
|
5
|
+
// (it reads the tree after both asks), to a running one only while the tree
|
|
6
|
+
// stamp equals the one taken at its start
|
|
7
|
+
// - a run that writes the tree (a repair pass) waits for every run ahead of
|
|
8
|
+
// it, and every later run waits for it
|
|
9
|
+
// - every other run starts at once: the machine slot pool each check body
|
|
10
|
+
// takes bounds the CPU, never this queue. It admits fewer slots on a
|
|
11
|
+
// loaded machine (core/slots.ts), so a run past it waits and says why
|
|
12
|
+
//
|
|
13
|
+
// the one constraint a reader breaks first: the stamp is taken when a run
|
|
14
|
+
// STARTS, before its body reads anything, so a tree that moves during the run
|
|
15
|
+
// can only make a later asker run on its own, never adopt a stale verdict.
|
|
16
|
+
import { createHash } from 'crypto'
|
|
17
|
+
import { statSync } from 'fs'
|
|
18
|
+
import { join } from 'path'
|
|
19
|
+
import { fixPassRuns } from '../core/fix-scope.ts'
|
|
20
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
21
|
+
|
|
22
|
+
export type LaneAsk = {
|
|
23
|
+
/** what makes two runs the same run, null: never shared */
|
|
24
|
+
key: string | null
|
|
25
|
+
/** the run rewrites tracked files (a repair pass) */
|
|
26
|
+
writes: boolean
|
|
27
|
+
print: (line: string) => void
|
|
28
|
+
/** no note lines: a --json run prints the json and nothing else */
|
|
29
|
+
quiet?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Entry = {
|
|
33
|
+
key: string | null
|
|
34
|
+
writes: boolean
|
|
35
|
+
running: boolean
|
|
36
|
+
stamp: string | null
|
|
37
|
+
lines: string[]
|
|
38
|
+
listeners: Set<(line: string) => void>
|
|
39
|
+
done: Promise<number>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const ATTACHED_NOTE = 'note: an identical run is in flight on this tree, so this one takes its lines and its verdict'
|
|
43
|
+
export const WAIT_NOTE = 'note: a run that rewrites files is in flight, so this one starts when it ends'
|
|
44
|
+
|
|
45
|
+
export class RunLanes {
|
|
46
|
+
private entries: Entry[] = []
|
|
47
|
+
constructor(private readonly stampOf: () => Promise<string | null> | string | null) {}
|
|
48
|
+
|
|
49
|
+
/** runs in flight, queued ones included */
|
|
50
|
+
get size(): number {
|
|
51
|
+
return this.entries.length
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async submit(ask: LaneAsk, body: (print: (line: string) => void) => Promise<number>): Promise<number> {
|
|
55
|
+
if (ask.key != null) {
|
|
56
|
+
const same = this.entries.find((e) => e.key === ask.key)
|
|
57
|
+
if (same != null && (!same.running || (same.stamp != null && same.stamp === (await this.stampOf())))) return this.attach(same, ask.print)
|
|
58
|
+
}
|
|
59
|
+
const ahead = this.entries.filter((e) => ask.writes || e.writes).map((e) => e.done.catch(() => 1))
|
|
60
|
+
let finish!: (code: number) => void
|
|
61
|
+
let fail!: (e: unknown) => void
|
|
62
|
+
const entry: Entry = {
|
|
63
|
+
key: ask.key,
|
|
64
|
+
writes: ask.writes,
|
|
65
|
+
running: false,
|
|
66
|
+
stamp: null,
|
|
67
|
+
lines: [],
|
|
68
|
+
listeners: new Set([ask.print]),
|
|
69
|
+
done: new Promise<number>((res, rej) => { finish = res; fail = rej }),
|
|
70
|
+
}
|
|
71
|
+
// the asker gets the throw; an attacher, if any, its own rejection
|
|
72
|
+
entry.done.catch(() => {})
|
|
73
|
+
this.entries.push(entry)
|
|
74
|
+
const tee = (line: string): void => {
|
|
75
|
+
entry.lines.push(line)
|
|
76
|
+
for (const l of entry.listeners) l(line)
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
if (ahead.length > 0) {
|
|
80
|
+
if (ask.quiet !== true) tee(WAIT_NOTE)
|
|
81
|
+
await Promise.all(ahead)
|
|
82
|
+
}
|
|
83
|
+
entry.stamp = ask.key == null ? null : await this.stampOf()
|
|
84
|
+
entry.running = true
|
|
85
|
+
const code = await body(tee)
|
|
86
|
+
finish(code)
|
|
87
|
+
return code
|
|
88
|
+
} catch (e) {
|
|
89
|
+
fail(e)
|
|
90
|
+
throw e
|
|
91
|
+
} finally {
|
|
92
|
+
this.entries = this.entries.filter((x) => x !== entry)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private attach(e: Entry, print: (line: string) => void): Promise<number> {
|
|
97
|
+
print(ATTACHED_NOTE)
|
|
98
|
+
for (const l of e.lines) print(l)
|
|
99
|
+
e.listeners.add(print)
|
|
100
|
+
return e.done.finally(() => e.listeners.delete(print))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** the key of a run: its kind and its options, whoever asked */
|
|
105
|
+
export function runKey(kind: string, opts: Record<string, unknown>): string {
|
|
106
|
+
const { invokedBy: _by, ...rest } = opts
|
|
107
|
+
const sorted = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
108
|
+
return `${kind} ${JSON.stringify(sorted)}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** a run whose repair pass runs (core/fix-scope.ts): the structural fixers
|
|
112
|
+
* and the hooks only ever run inside it */
|
|
113
|
+
export const writesTree = (opts: Record<string, unknown>): boolean =>
|
|
114
|
+
fixPassRuns({ fix: typeof opts.fix === 'boolean' ? opts.fix : undefined, onlyIds: Array.isArray(opts.onlyIds) ? (opts.onlyIds as string[]) : undefined })
|
|
115
|
+
|
|
116
|
+
/** HEAD, what git status lists, and the stat of each listed path, hashed:
|
|
117
|
+
* equal stamps mean no tracked or untracked file moved in between. Null
|
|
118
|
+
* outside git, where nothing attaches */
|
|
119
|
+
export function treeStamp(root: string): string | null {
|
|
120
|
+
const git = (args: string[]): string | null => {
|
|
121
|
+
const p = Bun.spawnSync([gitBin(), '--no-optional-locks', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
122
|
+
return p.exitCode === 0 ? p.stdout.toString() : null
|
|
123
|
+
}
|
|
124
|
+
const head = git(['rev-parse', '-q', '--verify', 'HEAD']) ?? 'none'
|
|
125
|
+
const status = git(['status', '--porcelain=v1', '-z', '-uall', '--no-renames', '--ignore-submodules'])
|
|
126
|
+
if (status == null) return null
|
|
127
|
+
const h = createHash('sha256').update(head).update('\0').update(status)
|
|
128
|
+
for (const entry of status.split('\0')) {
|
|
129
|
+
if (entry.length < 4) continue
|
|
130
|
+
try {
|
|
131
|
+
const st = statSync(join(root, entry.slice(3)))
|
|
132
|
+
h.update(`\0${st.size}:${st.mtimeMs}:${st.ctimeMs}`)
|
|
133
|
+
} catch {
|
|
134
|
+
h.update('\0gone')
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return h.digest('hex')
|
|
138
|
+
}
|
package/src/smoke/run-smoke.ts
CHANGED
|
@@ -27,6 +27,10 @@ export type SmokeRoute = {
|
|
|
27
27
|
* match's href after this route and judges the landed page as its own
|
|
28
28
|
* row. How a detail page with a real id gets visited without knowing one. */
|
|
29
29
|
open?: string
|
|
30
|
+
/** the visible text a settled route needs before it counts as rendered,
|
|
31
|
+
* default 40. A canvas app (a game) draws everything into one canvas and
|
|
32
|
+
* shows no text: `{ path: '/', waitFor: 'canvas', minChars: 0 }`. */
|
|
33
|
+
minChars?: number
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export type SmokeLogin = {
|
|
@@ -63,8 +67,9 @@ export type SmokeConfig = {
|
|
|
63
67
|
const SMOKE_KEYS = new Set(['base', 'routes', 'login', 'paceMs', 'shots'])
|
|
64
68
|
const LOGIN_KEYS = new Set(['path', 'email', 'emailSecret', 'passwordSecret', 'form'])
|
|
65
69
|
const FORM_KEYS = new Set(['email', 'password', 'submit'])
|
|
66
|
-
const ROUTE_KEYS = new Set(['path', 'waitFor', 'open'])
|
|
70
|
+
const ROUTE_KEYS = new Set(['path', 'waitFor', 'open', 'minChars'])
|
|
67
71
|
export const DEFAULT_LOGIN_PATH = '/login'
|
|
72
|
+
export const DEFAULT_MIN_CHARS = 40
|
|
68
73
|
|
|
69
74
|
export const DEFAULT_PACE_MS = 3000
|
|
70
75
|
export const DEFAULT_SHOTS = `${LAYOUT.tmp}/smoke`
|
|
@@ -86,6 +91,8 @@ export function validateSmokeConfig(c: unknown): SmokeConfig {
|
|
|
86
91
|
const v = (r as Record<string, unknown>)[k]
|
|
87
92
|
if (v != null && (typeof v !== 'string' || v === '')) throw new Error(`defineRepo: smoke.routes['${path}'].${k} must be a non-empty selector`)
|
|
88
93
|
}
|
|
94
|
+
const min = (r as Record<string, unknown>)['minChars']
|
|
95
|
+
if (min != null && (typeof min !== 'number' || !Number.isInteger(min) || min < 0)) throw new Error(`defineRepo: smoke.routes['${path}'].minChars must be a whole number of characters, 0 or more`)
|
|
89
96
|
}
|
|
90
97
|
}
|
|
91
98
|
if (s['login'] != null) {
|
|
@@ -152,8 +159,8 @@ export type SmokeReport = {
|
|
|
152
159
|
* nothing threw, an empty `waitFor` on a listing is exactly how a broken
|
|
153
160
|
* migration looks, and a page that scrolls sideways is a layout defect only
|
|
154
161
|
* a real browser can see (happy-dom does no layout). */
|
|
155
|
-
export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; overflowPx: number }): RouteReport {
|
|
156
|
-
const empty = r.chars <
|
|
162
|
+
export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; minChars?: number; overflowPx: number }): RouteReport {
|
|
163
|
+
const empty = r.chars < (r.minChars ?? DEFAULT_MIN_CHARS)
|
|
157
164
|
const noMatch = r.waitFor != null && r.matches === 0
|
|
158
165
|
const sideways = r.overflowPx > 1
|
|
159
166
|
const note = empty ? 'EMPTY PAGE' : noMatch ? `NO MATCH FOR ${r.waitFor}` : sideways ? `PAGE SCROLLS SIDEWAYS +${r.overflowPx}px` : r.problems > 0 ? `${r.problems} problem(s)` : ''
|
|
@@ -368,7 +375,7 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
|
|
|
368
375
|
}
|
|
369
376
|
|
|
370
377
|
/** visit one url, settle it, judge it, screenshot it */
|
|
371
|
-
const visit = async (label: string, url: string, waitFor?: string): Promise<RouteReport> => {
|
|
378
|
+
const visit = async (label: string, url: string, waitFor?: string, minChars?: number): Promise<RouteReport> => {
|
|
372
379
|
// let the previous page's requests drain out of the concurrency
|
|
373
380
|
// window BEFORE the label moves, or its late failures land on this row
|
|
374
381
|
if (paceMs > 0) await page.waitForTimeout(paceMs)
|
|
@@ -387,11 +394,11 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
|
|
|
387
394
|
return Math.max(de.scrollWidth - de.clientWidth, doc.body.scrollWidth - doc.body.clientWidth)
|
|
388
395
|
}).catch(() => 0)
|
|
389
396
|
await page.screenshot({ path: join(shots, `${routeSlug(label)}.png`), fullPage: true })
|
|
390
|
-
return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, overflowPx })
|
|
397
|
+
return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, minChars, overflowPx })
|
|
391
398
|
}
|
|
392
399
|
|
|
393
400
|
for (const route of routes) {
|
|
394
|
-
reports.push(await visit(route.path, route.path, route.waitFor))
|
|
401
|
+
reports.push(await visit(route.path, route.path, route.waitFor, route.minChars))
|
|
395
402
|
if (route.open == null) continue
|
|
396
403
|
// a detail page with a real id: the first link the selector matches
|
|
397
404
|
const href = await page.locator(route.open).first().getAttribute('href').catch(() => null)
|
|
@@ -24,6 +24,8 @@ export type TestsConfig = {
|
|
|
24
24
|
slow?: SlowSuite[]
|
|
25
25
|
/** suites that cannot share a run: they run one at a time after the parallel phase */
|
|
26
26
|
serial?: SerialSuite[]
|
|
27
|
+
/** true: the quick tier runs only the suites a change since the last run can reach (their import closure and what its modules read at runtime) and the ones that did not pass then. Off by default: every suite runs every time. The full tier always runs every suite */
|
|
28
|
+
select?: boolean
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export const DEFAULT_BUDGET_MS = 2000
|
|
@@ -39,17 +41,18 @@ export const DEFAULT_BUDGET_MS = 2000
|
|
|
39
41
|
* 5s default while the preload said otherwise. */
|
|
40
42
|
export const FULL_TIMEOUT_MS = 120_000
|
|
41
43
|
/** the wall ceiling of one `bun test` phase. bun's per test timeout never
|
|
42
|
-
* fires on a worker stuck in a SYNCHRONOUS call,
|
|
44
|
+
* fires on a worker stuck in a SYNCHRONOUS call, or in `expect(p).resolves`
|
|
45
|
+
* or `.rejects` on a promise that never settles, and bun's spawnSync has been
|
|
43
46
|
* seen spinning on a child that already exited (planPhases avoids the known
|
|
44
47
|
* trigger), so without this a hang lasts until something outside kills it. The quick
|
|
45
48
|
* tier answers in seconds, so two minutes is far from any real run. */
|
|
46
49
|
export const QUICK_CEILING_MS = 120_000
|
|
47
50
|
/** the full tier runs every suite one after the other, about three minutes */
|
|
48
51
|
export const FULL_CEILING_MS = 1_800_000
|
|
49
|
-
const TESTS_KEYS = ['budgetMs', 'slow', 'serial']
|
|
52
|
+
const TESTS_KEYS = ['budgetMs', 'slow', 'serial', 'select']
|
|
50
53
|
const SLOW_KEYS = ['file', 'catches', 'whyNotFast']
|
|
51
54
|
const SERIAL_KEYS = ['file', 'why']
|
|
52
|
-
const OWN_FLAGS = ['--budget-ms', '--slow', '--serial', '--declared-in', '--ceiling-ms', '--unit']
|
|
55
|
+
const OWN_FLAGS = ['--budget-ms', '--slow', '--serial', '--declared-in', '--ceiling-ms', '--unit', '--select']
|
|
53
56
|
|
|
54
57
|
export const normSuitePath = (file: string): string => posix.normalize(file.replace(/\\/g, '/')).replace(/^\.\//, '')
|
|
55
58
|
|
|
@@ -84,13 +87,14 @@ export function suiteFlags(t: TestsConfig | undefined): string[] {
|
|
|
84
87
|
...(t.budgetMs != null ? ['--budget-ms', String(t.budgetMs)] : []),
|
|
85
88
|
...(t.slow ?? []).flatMap((s) => ['--slow', s.file]),
|
|
86
89
|
...(t.serial ?? []).flatMap((s) => ['--serial', s.file]),
|
|
90
|
+
...(t.select === true ? ['--select', 'on'] : []),
|
|
87
91
|
]
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
/** the inverse of suiteFlags, plus where the declarations live (printed in
|
|
91
95
|
* every fix line) and the `bun test` arguments after `--` */
|
|
92
96
|
export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs: string[]; declaredIn: string; ceilingMs: number | null; unit: string } | string {
|
|
93
|
-
const tests: Required<TestsConfig> = { budgetMs: DEFAULT_BUDGET_MS, slow: [], serial: [] }
|
|
97
|
+
const tests: Required<TestsConfig> = { budgetMs: DEFAULT_BUDGET_MS, slow: [], serial: [], select: false }
|
|
94
98
|
let declaredIn = 'repo.config.ts tests'
|
|
95
99
|
let ceilingMs: number | null = null
|
|
96
100
|
let unit = ''
|
|
@@ -109,6 +113,7 @@ export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs:
|
|
|
109
113
|
else if (flag === '--serial') tests.serial.push({ file: value, why: '(declared)' })
|
|
110
114
|
else if (flag === '--ceiling-ms') ceilingMs = Number(value)
|
|
111
115
|
else if (flag === '--unit') unit = value
|
|
116
|
+
else if (flag === '--select') { if (value !== 'off' && value !== 'on') return '--select takes on or off'; tests.select = value === 'on' }
|
|
112
117
|
else declaredIn = value
|
|
113
118
|
}
|
|
114
119
|
if (!(tests.budgetMs > 0)) return '--budget-ms must be a positive number'
|
|
@@ -116,6 +121,8 @@ export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs:
|
|
|
116
121
|
return { tests, rawArgs: argv.slice(i), declaredIn, ceilingMs, unit }
|
|
117
122
|
}
|
|
118
123
|
|
|
124
|
+
const PARALLEL_COUNT = /^--parallel=([1-9]\d*)$/
|
|
125
|
+
|
|
119
126
|
/** what `bun test` receives in each phase. The serial phase names its files
|
|
120
127
|
* explicitly, because bun reports no tests when a path is both named and ignored.
|
|
121
128
|
*
|
|
@@ -124,8 +131,15 @@ export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs:
|
|
|
124
131
|
* did. `--parallel` implies per file isolation, and under it a worker's
|
|
125
132
|
* spawnSync can spin forever on a child that already exited (bun issue
|
|
126
133
|
* 34069), which hung one full run in eight. A repo that passes `--isolate`
|
|
127
|
-
* itself keeps it.
|
|
128
|
-
|
|
134
|
+
* itself keeps it.
|
|
135
|
+
*
|
|
136
|
+
* `workers` (the pool share) is a ceiling under the repo's own
|
|
137
|
+
* `--parallel=<n>`: the phase takes the smaller, as the one flag on its line,
|
|
138
|
+
* since bun reads the last `--parallel` it is given. No other line names a
|
|
139
|
+
* worker count: the serial phase, a phase one that is not parallel (the full
|
|
140
|
+
* tier) and `alone` (what a suspect's rerun puts before its file) each run
|
|
141
|
+
* one suite at a time, so the repo's `--parallel` stays off them. */
|
|
142
|
+
export function planPhases(p: { tests: TestsConfig; tier: 'quick' | 'full'; rawArgs: string[]; parallel: boolean; noIsolate?: boolean; configIgnores?: string[]; workers?: number }): { parallel: string[]; serial: string[] | null; alone: string[]; workers: number | null } {
|
|
129
143
|
const slow = (p.tests.slow ?? []).map((s) => normSuitePath(s.file))
|
|
130
144
|
const serial = (p.tests.serial ?? []).map((s) => normSuitePath(s.file))
|
|
131
145
|
const own = [...new Set([...(p.tier === 'quick' ? slow : []), ...serial])]
|
|
@@ -139,9 +153,15 @@ export function planPhases(p: { tests: TestsConfig; tier: 'quick' | 'full'; rawA
|
|
|
139
153
|
const named = p.rawArgs.some((a) => a === '--timeout' || a.startsWith('--timeout='))
|
|
140
154
|
const args = p.tier === 'full' && !named ? [`--timeout=${FULL_TIMEOUT_MS}`, ...p.rawArgs] : p.rawArgs
|
|
141
155
|
const isolated = p.rawArgs.some((a) => a === '--isolate' || a === '--no-isolate')
|
|
156
|
+
const repoCount = p.rawArgs.map((a) => PARALLEL_COUNT.exec(a)?.[1]).filter((n) => n != null).map(Number).at(-1)
|
|
157
|
+
const bounds = [p.workers, repoCount].filter((n) => n != null)
|
|
158
|
+
const workers = p.parallel && bounds.length > 0 ? Math.min(...bounds) : null
|
|
159
|
+
const solo = args.filter((a) => a !== '--parallel' && !PARALLEL_COUNT.test(a))
|
|
142
160
|
return {
|
|
143
|
-
parallel: [...
|
|
144
|
-
serial: serialRun.length > 0 ? [...
|
|
161
|
+
parallel: [...solo, ...(p.parallel ? [workers != null ? `--parallel=${workers}` : '--parallel'] : []), ...(p.parallel && p.noIsolate === true && !isolated ? ['--no-isolate'] : []), ...ignored.map((f) => `--path-ignore-patterns=${f}`)],
|
|
162
|
+
serial: serialRun.length > 0 ? [...solo, ...serialRun] : null,
|
|
163
|
+
alone: solo,
|
|
164
|
+
workers,
|
|
145
165
|
}
|
|
146
166
|
}
|
|
147
167
|
|
|
@@ -163,14 +183,16 @@ export function poolWorkers(p: { cores: number; size: number; held: number }): n
|
|
|
163
183
|
|
|
164
184
|
type Timed = { file: string; ms: number }
|
|
165
185
|
|
|
166
|
-
export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full' }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
|
|
186
|
+
export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full'; red?: string[] }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
|
|
167
187
|
const budget = p.tests.budgetMs ?? DEFAULT_BUDGET_MS
|
|
168
188
|
const slow = new Set((p.tests.slow ?? []).map((s) => normSuitePath(s.file)))
|
|
189
|
+
const red = new Set((p.red ?? []).map(normSuitePath))
|
|
169
190
|
const timed = Object.entries(p.timings).map(([file, ms]) => ({ file: normSuitePath(file), ms })).sort((a, b) => b.ms - a.ms)
|
|
170
191
|
return {
|
|
171
192
|
// the budget guards the tier every edit waits on. The full tier is
|
|
172
|
-
// where slow suites belong, and it only reports
|
|
173
|
-
|
|
193
|
+
// where slow suites belong, and it only reports. A red suite took
|
|
194
|
+
// the time of its failure (a timeout, a crash), which its red explains
|
|
195
|
+
over: p.tier === 'quick' ? timed.filter((t) => t.ms > budget && !slow.has(t.file) && !red.has(t.file)) : [],
|
|
174
196
|
// half the budget, so a declaration does not flap on timing noise
|
|
175
197
|
noLongerSlow: timed.filter((t) => slow.has(t.file) && t.ms < budget / 2),
|
|
176
198
|
slowest: timed.slice(0, 3),
|
|
@@ -234,6 +256,20 @@ export function confirmOver(suspects: Timed[], alone: Record<string, AloneRun>,
|
|
|
234
256
|
return { over: judged.filter((c) => c.over != null), cleared: judged.filter((c) => c.over == null) }
|
|
235
257
|
}
|
|
236
258
|
|
|
259
|
+
/** a lone run over the budget on CPU runs once more. Under load the
|
|
260
|
+
* scheduler can spill a lone run onto the efficiency cores, where the same
|
|
261
|
+
* retired instructions cost up to 4x the CPU time (measured on Apple
|
|
262
|
+
* silicon), and a spill only ever raises the figure. A wait or a wall
|
|
263
|
+
* verdict is not CPU, so it keeps its one run */
|
|
264
|
+
export const retryAlone = (t: Timed, run: AloneRun, budgetMs: number, load: MachineLoad): boolean =>
|
|
265
|
+
confirmOver([t], { [t.file]: run }, budgetMs, load).over[0]?.over === 'cpu'
|
|
266
|
+
|
|
267
|
+
/** the run charged less, as confirmOver charges it: the smaller of CPU and wall */
|
|
268
|
+
export const smallerRun = (a: AloneRun, b: AloneRun): AloneRun => {
|
|
269
|
+
const charge = (r: AloneRun): number => Math.min(r.cpuMs ?? r.wallMs, r.wallMs)
|
|
270
|
+
return charge(b) < charge(a) ? b : a
|
|
271
|
+
}
|
|
272
|
+
|
|
237
273
|
const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
|
|
238
274
|
|
|
239
275
|
/** the line a confirmed suite fails with */
|
|
@@ -339,6 +375,37 @@ export function failLines(output: string): Map<string, string> {
|
|
|
339
375
|
|
|
340
376
|
const clip = (s: string): string => (s.length > 300 ? `${s.slice(0, 299)}…` : s)
|
|
341
377
|
|
|
378
|
+
/** the clause the headline carries for the gate line: the command phase one
|
|
379
|
+
* ran, and the flags the runner added to the script's own. Per file ignores
|
|
380
|
+
* stay out, the slow and serial counts already say what they leave out */
|
|
381
|
+
export function ranAs(phase: string[], rawArgs: string[]): string {
|
|
382
|
+
const shown = phase.filter((a) => !a.startsWith('--path-ignore-patterns'))
|
|
383
|
+
const added = shown.filter((a) => !rawArgs.includes(a))
|
|
384
|
+
return `as \`${['bun', 'test', ...shown].join(' ')}\`${added.length > 0 ? ` (added ${added.join(' ')})` : ''}`
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** the suites bun lists under `Interrupted while still running:` when a
|
|
388
|
+
* phase is killed, one indented `<file> (<n>s)` line each */
|
|
389
|
+
export function interruptedSuites(output: string): string[] {
|
|
390
|
+
const out: string[] = []
|
|
391
|
+
let inBlock = false
|
|
392
|
+
for (const line of output.split('\n')) {
|
|
393
|
+
if (/^Interrupted while still running:\s*$/.test(line)) { inBlock = true; continue }
|
|
394
|
+
if (!inBlock) continue
|
|
395
|
+
const m = /^\s+(\S+)\s+\(\d+(?:\.\d+)?m?s\)\s*$/.exec(line)
|
|
396
|
+
if (m == null) { inBlock = false; continue }
|
|
397
|
+
out.push(normSuitePath(m[1]!))
|
|
398
|
+
}
|
|
399
|
+
return out
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** the line a phase killed at its ceiling fails with. Measured on bun 1.4.3:
|
|
403
|
+
* a plain await of a promise that never settles fails at the per test
|
|
404
|
+
* timeout, while `expect(p).resolves` or `.rejects` on one blocks the worker
|
|
405
|
+
* past it, as a synchronous loop does */
|
|
406
|
+
export const stallWords = (name: string, ceilingMs: number, files: string[]): string =>
|
|
407
|
+
`${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test blocked its worker past the per test timeout, either a synchronous loop or expect(...).resolves or .rejects on a promise that never settles`
|
|
408
|
+
|
|
342
409
|
/** the suites bun names as a crashed parallel worker */
|
|
343
410
|
export function crashedWorkers(output: string): { file: string; signal: string }[] {
|
|
344
411
|
return [...output.matchAll(/^\s*✗\s+(\S+)\s+\(worker crashed: (SIG[A-Z0-9]+)\)\s*$/gm)].map((m) => ({ file: normSuitePath(m[1]!), signal: m[2]! }))
|
|
@@ -348,6 +415,7 @@ export function testsConfigProblems(t: TestsConfig): string[] {
|
|
|
348
415
|
const problems: string[] = []
|
|
349
416
|
for (const k of Object.keys(t)) if (!TESTS_KEYS.includes(k)) problems.push(`tests.${k} is not a tests field (fields: ${TESTS_KEYS.join(', ')})`)
|
|
350
417
|
if (t.budgetMs != null && !(typeof t.budgetMs === 'number' && t.budgetMs > 0)) problems.push('tests.budgetMs must be a positive number of milliseconds')
|
|
418
|
+
if (t.select != null && typeof t.select !== 'boolean') problems.push('tests.select must be true or false')
|
|
351
419
|
const lists: [string, { file: string }[], string[], [string, string][]][] = [
|
|
352
420
|
['slow', t.slow ?? [], SLOW_KEYS, [['catches', 'what it catches that no fast test can'], ['whyNotFast', 'why it cannot be made fast']]],
|
|
353
421
|
['serial', t.serial ?? [], SERIAL_KEYS, [['why', 'what breaks when it shares a run']]],
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// the variables that hand the read recorder (suite-reads-preload.ts) its job:
|
|
2
|
+
// where to write, the root it charges reads under, and, in a spawned bun
|
|
3
|
+
// child, the module that spawned it
|
|
4
|
+
export const SUITE_READS_ENV = 'SHIPKIT_SUITE_READS'
|
|
5
|
+
export const SUITE_ROOT_ENV = 'SHIPKIT_SUITE_ROOT'
|
|
6
|
+
export const SUITE_OWNER_ENV = 'SHIPKIT_SUITE_READS_OWNER'
|
|
7
|
+
/** `0` runs every suite in the quick tier and records nothing, `1` selects even where tests.select is not true */
|
|
8
|
+
export const SUITE_SELECT_ENV = 'SHIPKIT_TEST_SELECT'
|