@patronage/factory-ci 0.2.1 → 1.0.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,631 @@
1
+ /**
2
+ * Vitest suite profiling — the measurement mechanics behind a CI runner
3
+ * comparison (#640, #647).
4
+ *
5
+ * A profile is a machine-readable record of N serial Vitest runs at one worker
6
+ * count, carrying per-file and per-test timings, aggregate duration statistics,
7
+ * and the hardware the samples actually ran on. The hardware capture is the
8
+ * point: the #640 Depot comparison only resolved because every sample recorded
9
+ * its `cpuModel`, which split otherwise-identical 4-CPU runs into two
10
+ * non-overlapping populations.
11
+ *
12
+ * Everything a repository decides stays with the repository: worker counts,
13
+ * sample counts, output paths, runner labels, and which suite to run at all.
14
+ * This module owns the schema, the report parsing, the environment capture, and
15
+ * the atomic write.
16
+ */
17
+
18
+ import { execFileSync, spawn } from "node:child_process";
19
+ import type { StdioOptions } from "node:child_process";
20
+ import { randomUUID } from "node:crypto";
21
+ import { once } from "node:events";
22
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
23
+ import { createRequire } from "node:module";
24
+ import {
25
+ arch,
26
+ availableParallelism,
27
+ cpus,
28
+ platform,
29
+ release,
30
+ totalmem,
31
+ } from "node:os";
32
+ import path from "node:path";
33
+ import { performance } from "node:perf_hooks";
34
+
35
+ /** Schema version of the emitted profile document. */
36
+ export const VITEST_PROFILE_SCHEMA_VERSION = 1;
37
+
38
+ /** `tool` discriminator every emitted profile carries. */
39
+ export const VITEST_PROFILE_TOOL = "factory-ci-vitest-profile";
40
+
41
+ const RUN_TIMEOUT_MS = 15 * 60_000;
42
+ const TERMINATION_GRACE_MS = 5000;
43
+
44
+ /** Per-assertion status as Vitest's JSON reporter spells it. */
45
+ export type VitestTestStatus =
46
+ | "disabled"
47
+ | "failed"
48
+ | "passed"
49
+ | "pending"
50
+ | "skipped"
51
+ | "todo";
52
+
53
+ /** The subset of Vitest's `--reporter=json` document this module reads. */
54
+ export interface VitestJsonReport {
55
+ numFailedTests: number;
56
+ numPassedTests: number;
57
+ numPendingTests: number;
58
+ numTodoTests: number;
59
+ numTotalTests: number;
60
+ numTotalTestSuites: number;
61
+ success: boolean;
62
+ testResults: {
63
+ assertionResults: {
64
+ duration?: number | null;
65
+ fullName: string;
66
+ status: VitestTestStatus;
67
+ }[];
68
+ endTime: number;
69
+ name: string;
70
+ startTime: number;
71
+ status: "failed" | "passed";
72
+ }[];
73
+ }
74
+
75
+ /** Duration statistics over one population of samples, in milliseconds. */
76
+ export interface VitestProfileDurationSummary {
77
+ maximum: number;
78
+ mean: number;
79
+ median: number;
80
+ minimum: number;
81
+ }
82
+
83
+ /**
84
+ * The machine a sample ran on, plus the commit it measured. `cpuModel` is the
85
+ * field that makes two runs comparable at all: hosted runner pools mix silicon
86
+ * behind one label.
87
+ */
88
+ export interface VitestProfileEnvironment {
89
+ arch: string;
90
+ availableParallelism: number;
91
+ cpuCount: number;
92
+ cpuModel: string | null;
93
+ gitDirty: boolean | null;
94
+ gitHead: string | null;
95
+ node: string;
96
+ osRelease: string;
97
+ platform: string;
98
+ totalMemoryBytes: number;
99
+ vitest: string;
100
+ }
101
+
102
+ /** One complete Vitest run inside a profile. */
103
+ export interface VitestProfileSample {
104
+ counts: {
105
+ failed: number;
106
+ passed: number;
107
+ pending: number;
108
+ suites: number;
109
+ tests: number;
110
+ todo: number;
111
+ } | null;
112
+ durationMs: number;
113
+ endedAt: string;
114
+ exitCode: number;
115
+ failure: string | null;
116
+ files: {
117
+ durationMs: number;
118
+ path: string;
119
+ status: "failed" | "passed";
120
+ }[];
121
+ reportAvailable: boolean;
122
+ sample: number;
123
+ startedAt: string;
124
+ tests: {
125
+ durationMs: number;
126
+ file: string;
127
+ name: string;
128
+ status: VitestTestStatus;
129
+ }[];
130
+ }
131
+
132
+ /** The emitted profile document. */
133
+ export interface VitestProfile {
134
+ command: string[];
135
+ endedAt: string;
136
+ environment: VitestProfileEnvironment;
137
+ options: {
138
+ maxWorkers: number;
139
+ samples: number;
140
+ slowLimit: number;
141
+ };
142
+ rawReportDirectory: string;
143
+ runs: VitestProfileSample[];
144
+ schemaVersion: typeof VITEST_PROFILE_SCHEMA_VERSION;
145
+ startedAt: string;
146
+ summary: {
147
+ durationMs: VitestProfileDurationSummary;
148
+ slowFiles: {
149
+ durationMs: VitestProfileDurationSummary;
150
+ path: string;
151
+ samples: number;
152
+ }[];
153
+ slowTests: {
154
+ durationMs: VitestProfileDurationSummary;
155
+ file: string;
156
+ name: string;
157
+ samples: number;
158
+ }[];
159
+ };
160
+ tool: typeof VITEST_PROFILE_TOOL;
161
+ }
162
+
163
+ /** What a caller must decide before a profile can run. */
164
+ export interface VitestProfileOptions {
165
+ /** Directory Vitest runs in; file paths are recorded relative to it. */
166
+ cwd: string;
167
+ /** Directory whose Git state is recorded. Defaults to `cwd`. */
168
+ gitDirectory?: string;
169
+ /** Vitest `--maxWorkers` for every sample. */
170
+ maxWorkers: number;
171
+ /** Absolute path of the profile document to write. */
172
+ outputPath: string;
173
+ /** Called before each sample starts. */
174
+ onSampleStart?: (input: {
175
+ maxWorkers: number;
176
+ sample: number;
177
+ samples: number;
178
+ }) => void;
179
+ /** Called after each sample is normalized and persisted. */
180
+ onSampleComplete?: (input: {
181
+ result: VitestProfileSample;
182
+ sample: number;
183
+ samples: number;
184
+ }) => void;
185
+ /** How many serial runs to take. */
186
+ samples: number;
187
+ /** How many slow files and slow tests to keep in the summary. */
188
+ slowLimit: number;
189
+ /**
190
+ * `stdio` for the Vitest child. Defaults to `"inherit"`, which is what a
191
+ * caller printing progress to a terminal wants; a caller whose own stdout is
192
+ * structured passes `"ignore"` to silence the run.
193
+ */
194
+ stdio?: StdioOptions;
195
+ }
196
+
197
+ /** Outcome of one Vitest invocation, as `runSample` reports it. */
198
+ export interface VitestProfileSampleExecution {
199
+ durationMs: number;
200
+ exitCode: number;
201
+ failure?: string | null;
202
+ report: VitestJsonReport | null;
203
+ }
204
+
205
+ /** Injectable seams; production passes none of them. */
206
+ export interface VitestProfileDependencies {
207
+ now?: () => Date;
208
+ runSample?: (input: {
209
+ cwd: string;
210
+ maxWorkers: number;
211
+ reportPath: string;
212
+ sample: number;
213
+ stdio: StdioOptions;
214
+ }) => Promise<VitestProfileSampleExecution>;
215
+ writeResult?: (outputPath: string, profile: VitestProfile) => Promise<void>;
216
+ }
217
+
218
+ /**
219
+ * A sample failed. The partial profile is already on disk; `exitCode` is the
220
+ * status a caller should exit with.
221
+ */
222
+ export class VitestProfileError extends Error {
223
+ readonly exitCode: number;
224
+
225
+ constructor(message: string, exitCode: number) {
226
+ super(message);
227
+ this.name = "VitestProfileError";
228
+ this.exitCode = exitCode;
229
+ }
230
+ }
231
+
232
+ const durationSummary = (durations: number[]): VitestProfileDurationSummary => {
233
+ const sorted = durations.toSorted((left, right) => left - right);
234
+ const middle = Math.floor(sorted.length / 2);
235
+ const median =
236
+ sorted.length % 2 === 0
237
+ ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
238
+ : (sorted[middle] ?? 0);
239
+ return {
240
+ maximum: sorted.at(-1) ?? 0,
241
+ mean: durations.reduce((sum, value) => sum + value, 0) / durations.length,
242
+ median,
243
+ minimum: sorted[0] ?? 0,
244
+ };
245
+ };
246
+
247
+ const relativePath = (cwd: string, file: string): string =>
248
+ path.relative(cwd, file).split(path.sep).join("/");
249
+
250
+ /**
251
+ * Fold one Vitest JSON report into a profile sample: file and test timings,
252
+ * both sorted slowest first, plus the run's counts. A missing report (crash,
253
+ * timeout, unwritable output) yields a sample with `reportAvailable: false`
254
+ * rather than nothing at all.
255
+ */
256
+ export const normalizeVitestProfileSample = (
257
+ report: VitestJsonReport | null,
258
+ input: {
259
+ cwd: string;
260
+ durationMs: number;
261
+ endedAt: Date;
262
+ exitCode: number;
263
+ failure?: string | null;
264
+ sample: number;
265
+ startedAt: Date;
266
+ }
267
+ ): VitestProfileSample => {
268
+ const files = (report?.testResults ?? [])
269
+ .map((file) => ({
270
+ durationMs: Math.max(0, file.endTime - file.startTime),
271
+ path: relativePath(input.cwd, file.name),
272
+ status: file.status,
273
+ }))
274
+ .toSorted((left, right) => right.durationMs - left.durationMs);
275
+ const tests = (report?.testResults ?? [])
276
+ .flatMap((file) =>
277
+ file.assertionResults.map((test) => ({
278
+ durationMs: Math.max(0, test.duration ?? 0),
279
+ file: relativePath(input.cwd, file.name),
280
+ name: test.fullName,
281
+ status: test.status,
282
+ }))
283
+ )
284
+ .toSorted((left, right) => right.durationMs - left.durationMs);
285
+
286
+ return {
287
+ counts: report
288
+ ? {
289
+ failed: report.numFailedTests,
290
+ passed: report.numPassedTests,
291
+ pending: report.numPendingTests,
292
+ suites: report.numTotalTestSuites,
293
+ tests: report.numTotalTests,
294
+ todo: report.numTodoTests,
295
+ }
296
+ : null,
297
+ durationMs: input.durationMs,
298
+ endedAt: input.endedAt.toISOString(),
299
+ exitCode: input.exitCode,
300
+ failure: input.failure ?? null,
301
+ files,
302
+ reportAvailable: report !== null,
303
+ sample: input.sample,
304
+ startedAt: input.startedAt.toISOString(),
305
+ tests,
306
+ };
307
+ };
308
+
309
+ const aggregateSlowFiles = (
310
+ runs: VitestProfileSample[],
311
+ limit: number
312
+ ): VitestProfile["summary"]["slowFiles"] => {
313
+ const durations = new Map<string, number[]>();
314
+ for (const file of runs.flatMap((run) => run.files)) {
315
+ const recorded = durations.get(file.path) ?? [];
316
+ recorded.push(file.durationMs);
317
+ durations.set(file.path, recorded);
318
+ }
319
+ return [...durations]
320
+ .map(([filePath, values]) => ({
321
+ durationMs: durationSummary(values),
322
+ path: filePath,
323
+ samples: values.length,
324
+ }))
325
+ .toSorted((left, right) => right.durationMs.median - left.durationMs.median)
326
+ .slice(0, limit);
327
+ };
328
+
329
+ const aggregateSlowTests = (
330
+ runs: VitestProfileSample[],
331
+ limit: number
332
+ ): VitestProfile["summary"]["slowTests"] => {
333
+ const timings = new Map<
334
+ string,
335
+ { durations: number[]; file: string; name: string }
336
+ >();
337
+ for (const test of runs.flatMap((run) => run.tests)) {
338
+ const key = `${test.file}\0${test.name}`;
339
+ const recorded = timings.get(key) ?? {
340
+ durations: [],
341
+ file: test.file,
342
+ name: test.name,
343
+ };
344
+ recorded.durations.push(test.durationMs);
345
+ timings.set(key, recorded);
346
+ }
347
+ return [...timings.values()]
348
+ .map(({ durations, file, name }) => ({
349
+ durationMs: durationSummary(durations),
350
+ file,
351
+ name,
352
+ samples: durations.length,
353
+ }))
354
+ .toSorted((left, right) => right.durationMs.median - left.durationMs.median)
355
+ .slice(0, limit);
356
+ };
357
+
358
+ const gitValue = (cwd: string, args: string[]): string | null => {
359
+ try {
360
+ return execFileSync("git", args, {
361
+ cwd,
362
+ encoding: "utf-8",
363
+ stdio: ["ignore", "pipe", "ignore"],
364
+ }).trim();
365
+ } catch {
366
+ return null;
367
+ }
368
+ };
369
+
370
+ const resolveVitestPackage = (cwd: string): string =>
371
+ createRequire(path.join(path.resolve(cwd), "noop.js")).resolve(
372
+ "vitest/package.json"
373
+ );
374
+
375
+ /**
376
+ * Record the machine and commit a profile was taken on. Vitest's version is
377
+ * resolved from `cwd`, so it is the consumer's Vitest and not this package's.
378
+ * Git failures degrade to `null` — an artifact from a tarball checkout is still
379
+ * a usable measurement.
380
+ */
381
+ export const captureVitestProfileEnvironment = async (options: {
382
+ cwd: string;
383
+ gitDirectory?: string;
384
+ }): Promise<VitestProfileEnvironment> => {
385
+ const gitDirectory = options.gitDirectory ?? options.cwd;
386
+ let vitestVersion = "unknown";
387
+ try {
388
+ const vitestPackage = JSON.parse(
389
+ await readFile(resolveVitestPackage(options.cwd), "utf-8")
390
+ ) as { version?: string };
391
+ vitestVersion = vitestPackage.version ?? "unknown";
392
+ } catch {
393
+ vitestVersion = "unknown";
394
+ }
395
+ const processors = cpus();
396
+ const status = gitValue(gitDirectory, ["status", "--porcelain"]);
397
+ return {
398
+ arch: arch(),
399
+ availableParallelism: availableParallelism(),
400
+ cpuCount: processors.length,
401
+ cpuModel: processors[0]?.model ?? null,
402
+ gitDirty: status === null ? null : status.length > 0,
403
+ gitHead: gitValue(gitDirectory, ["rev-parse", "HEAD"]),
404
+ node: process.version,
405
+ osRelease: release(),
406
+ platform: platform(),
407
+ totalMemoryBytes: totalmem(),
408
+ vitest: vitestVersion,
409
+ };
410
+ };
411
+
412
+ const spawnVitest = async ({
413
+ cwd,
414
+ maxWorkers,
415
+ reportPath,
416
+ stdio,
417
+ }: {
418
+ cwd: string;
419
+ maxWorkers: number;
420
+ reportPath: string;
421
+ stdio: StdioOptions;
422
+ }): Promise<VitestProfileSampleExecution> => {
423
+ const vitestCli = path.join(
424
+ path.dirname(resolveVitestPackage(cwd)),
425
+ "vitest.mjs"
426
+ );
427
+ const started = performance.now();
428
+ const child = spawn(
429
+ process.execPath,
430
+ [
431
+ vitestCli,
432
+ "run",
433
+ "--reporter=json",
434
+ `--outputFile=${reportPath}`,
435
+ `--maxWorkers=${maxWorkers}`,
436
+ ],
437
+ { cwd, stdio }
438
+ );
439
+
440
+ let timedOut = false;
441
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
442
+ // A timed-out sample is terminated rather than abandoned: SIGTERM, then
443
+ // SIGKILL after a grace period. Either way the child exits, so waiting on
444
+ // `exit` covers the timeout too.
445
+ const timeoutTimer = setTimeout(() => {
446
+ timedOut = true;
447
+ child.kill("SIGTERM");
448
+ forceKillTimer = setTimeout(() => {
449
+ child.kill("SIGKILL");
450
+ }, TERMINATION_GRACE_MS);
451
+ }, RUN_TIMEOUT_MS);
452
+ let exitCode = 0;
453
+ let failure: string | null = null;
454
+ try {
455
+ // `once` rejects if the child emits `error` first, which is how a Vitest
456
+ // that never started is told apart from one that ran and failed.
457
+ const [code, signal] = await once(child, "exit");
458
+ exitCode = (code as number | null) ?? (signal ? 1 : 0);
459
+ } catch (error) {
460
+ exitCode = 1;
461
+ failure = `Vitest could not start: ${
462
+ error instanceof Error ? error.message : String(error)
463
+ }`;
464
+ } finally {
465
+ clearTimeout(timeoutTimer);
466
+ if (forceKillTimer) {
467
+ clearTimeout(forceKillTimer);
468
+ }
469
+ }
470
+ if (timedOut) {
471
+ failure = `Vitest profile sample exceeded ${RUN_TIMEOUT_MS}ms and was terminated.`;
472
+ }
473
+ let report: VitestJsonReport | null = null;
474
+ try {
475
+ report = JSON.parse(
476
+ await readFile(reportPath, "utf-8")
477
+ ) as VitestJsonReport;
478
+ } catch (error) {
479
+ const reportFailure = `Vitest JSON report unavailable: ${
480
+ error instanceof Error ? error.message : String(error)
481
+ }`;
482
+ failure = failure ? `${failure} ${reportFailure}` : reportFailure;
483
+ }
484
+ return {
485
+ durationMs: Math.max(0, performance.now() - started),
486
+ exitCode: timedOut ? 1 : exitCode,
487
+ failure,
488
+ report,
489
+ };
490
+ };
491
+
492
+ /**
493
+ * Write a profile document atomically: a partial file must never be readable
494
+ * as a complete measurement, and the profile is rewritten after every sample.
495
+ */
496
+ export const writeVitestProfile = async (
497
+ outputPath: string,
498
+ profile: VitestProfile
499
+ ): Promise<void> => {
500
+ await mkdir(path.dirname(outputPath), { recursive: true });
501
+ const temporaryPath = `${outputPath}.tmp`;
502
+ await writeFile(
503
+ temporaryPath,
504
+ `${JSON.stringify(profile, null, 2)}\n`,
505
+ "utf-8"
506
+ );
507
+ await rename(temporaryPath, outputPath);
508
+ };
509
+
510
+ /**
511
+ * Take `samples` serial Vitest runs at one worker count and persist the profile
512
+ * after each one. Samples never overlap: concurrent runs would measure CPU and
513
+ * I/O contention instead of the worker count under test. A failing sample
514
+ * throws `VitestProfileError` with the partial profile already written.
515
+ */
516
+ export const runVitestProfile = async (
517
+ options: VitestProfileOptions,
518
+ dependencies: VitestProfileDependencies = {}
519
+ ): Promise<VitestProfile> => {
520
+ // A sample count below one would resolve green having measured nothing and
521
+ // written no artifact — the one input whose bad value looks like success.
522
+ if (!Number.isSafeInteger(options.samples) || options.samples < 1) {
523
+ throw new Error("runVitestProfile: samples must be a positive integer.");
524
+ }
525
+ const now = dependencies.now ?? (() => new Date());
526
+ const runSample = dependencies.runSample ?? spawnVitest;
527
+ const writeResult = dependencies.writeResult ?? writeVitestProfile;
528
+ const startedAt = now();
529
+ const rawRoot = path.join(`${options.outputPath}.raw`, randomUUID());
530
+ await mkdir(rawRoot, { recursive: true });
531
+ const profile: VitestProfile = {
532
+ command: [
533
+ "vitest",
534
+ "run",
535
+ "--reporter=json",
536
+ `--maxWorkers=${options.maxWorkers}`,
537
+ ],
538
+ endedAt: startedAt.toISOString(),
539
+ environment: await captureVitestProfileEnvironment({
540
+ cwd: options.cwd,
541
+ gitDirectory: options.gitDirectory,
542
+ }),
543
+ options: {
544
+ maxWorkers: options.maxWorkers,
545
+ samples: options.samples,
546
+ slowLimit: options.slowLimit,
547
+ },
548
+ rawReportDirectory: rawRoot,
549
+ runs: [],
550
+ schemaVersion: VITEST_PROFILE_SCHEMA_VERSION,
551
+ startedAt: startedAt.toISOString(),
552
+ summary: {
553
+ durationMs: { maximum: 0, mean: 0, median: 0, minimum: 0 },
554
+ slowFiles: [],
555
+ slowTests: [],
556
+ },
557
+ tool: VITEST_PROFILE_TOOL,
558
+ };
559
+
560
+ for (let sample = 1; sample <= options.samples; sample += 1) {
561
+ // Both reporting hooks are the caller's console, not controls: a throwing
562
+ // hook is captured and deferred so it can never decide whether the sample
563
+ // ran or why it failed. See the classification below.
564
+ let hookFailure: { error: unknown } | undefined;
565
+ try {
566
+ options.onSampleStart?.({
567
+ maxWorkers: options.maxWorkers,
568
+ sample,
569
+ samples: options.samples,
570
+ });
571
+ } catch (error) {
572
+ hookFailure = { error };
573
+ }
574
+ const sampleStartedAt = now();
575
+ const reportPath = path.join(rawRoot, `sample-${sample}.json`);
576
+ // Samples are intentionally serial: overlapping runs would measure CPU and
577
+ // I/O contention rather than the selected worker count.
578
+ // oxlint-disable-next-line no-await-in-loop
579
+ const execution = await runSample({
580
+ cwd: options.cwd,
581
+ maxWorkers: options.maxWorkers,
582
+ reportPath,
583
+ sample,
584
+ stdio: options.stdio ?? "inherit",
585
+ });
586
+ const sampleEndedAt = now();
587
+ const normalized = normalizeVitestProfileSample(execution.report, {
588
+ cwd: options.cwd,
589
+ durationMs: execution.durationMs,
590
+ endedAt: sampleEndedAt,
591
+ exitCode: execution.exitCode,
592
+ failure: execution.failure,
593
+ sample,
594
+ startedAt: sampleStartedAt,
595
+ });
596
+ profile.runs.push(normalized);
597
+ profile.endedAt = sampleEndedAt.toISOString();
598
+ profile.summary = {
599
+ durationMs: durationSummary(profile.runs.map((run) => run.durationMs)),
600
+ slowFiles: aggregateSlowFiles(profile.runs, options.slowLimit),
601
+ slowTests: aggregateSlowTests(profile.runs, options.slowLimit),
602
+ };
603
+ // Persist after every sample so a later red run still leaves useful data.
604
+ // oxlint-disable-next-line no-await-in-loop
605
+ await writeResult(options.outputPath, profile);
606
+
607
+ try {
608
+ options.onSampleComplete?.({
609
+ result: normalized,
610
+ sample,
611
+ samples: options.samples,
612
+ });
613
+ } catch (error) {
614
+ // An earlier hook failure in this sample is the one reported; both are
615
+ // deferred behind the classification either way.
616
+ hookFailure ??= { error };
617
+ }
618
+
619
+ if (execution.exitCode !== 0 || execution.report?.success !== true) {
620
+ throw new VitestProfileError(
621
+ `Vitest profile sample ${sample} failed; partial results saved.`,
622
+ execution.exitCode || 1
623
+ );
624
+ }
625
+ if (hookFailure) {
626
+ throw hookFailure.error;
627
+ }
628
+ }
629
+
630
+ return profile;
631
+ };