@ticket-gate/engine 0.0.0-stage → 0.1.0-rc.1

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.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +81 -2
  3. package/bin/ticket-gate.mjs +261 -0
  4. package/checks/assertions-unguarded.mjs +31 -0
  5. package/checks/assertions.mjs +319 -0
  6. package/checks/cache-report.mjs +313 -0
  7. package/checks/docs-stale.mjs +69 -0
  8. package/checks/duplication.mjs +258 -0
  9. package/checks/ratchet-options.mjs +51 -0
  10. package/checks/surface.mjs +397 -0
  11. package/cli.mjs +379 -0
  12. package/commands-config.mjs +55 -0
  13. package/config.mjs +597 -0
  14. package/digest.mjs +195 -0
  15. package/env-name.mjs +24 -0
  16. package/failures.mjs +256 -0
  17. package/fu.mjs +458 -0
  18. package/graph/js.mjs +517 -0
  19. package/graph/load-time.mjs +309 -0
  20. package/graph/typescript.mjs +28 -0
  21. package/hooks/pre-tool-use.mjs +213 -0
  22. package/hooks/stop.mjs +495 -0
  23. package/is-main.mjs +15 -0
  24. package/kill-group.mjs +13 -0
  25. package/ledger.mjs +186 -0
  26. package/lock.d.mts +72 -0
  27. package/lock.mjs +329 -0
  28. package/move-proof.mjs +206 -0
  29. package/mutations/check.mjs +506 -0
  30. package/mutations/cli.mjs +361 -0
  31. package/mutations/exec.mjs +120 -0
  32. package/mutations/gate.mjs +148 -0
  33. package/mutations/harness.mjs +240 -0
  34. package/mutations/kinds.mjs +148 -0
  35. package/mutations/locate.mjs +71 -0
  36. package/mutations/project.mjs +23 -0
  37. package/mutations/registry.mjs +173 -0
  38. package/mutations/rotate.mjs +419 -0
  39. package/mutations/rotation-status.mjs +93 -0
  40. package/mutations/run.mjs +252 -0
  41. package/mutations/select.mjs +333 -0
  42. package/mutations/served.mjs +216 -0
  43. package/mutations/validate.mjs +86 -0
  44. package/mutations/verdict.mjs +159 -0
  45. package/nightly/cli.mjs +30 -0
  46. package/nightly/define.mjs +111 -0
  47. package/nightly/e2e-runs.mjs +73 -0
  48. package/nightly/gate.mjs +25 -0
  49. package/nightly/io.mjs +109 -0
  50. package/nightly/launchd.mjs +42 -0
  51. package/nightly/record.mjs +231 -0
  52. package/nightly/run.mjs +470 -0
  53. package/nightly/schedule.mjs +76 -0
  54. package/nightly/seed.mjs +78 -0
  55. package/nightly/status.mjs +189 -0
  56. package/nightly/suites.mjs +43 -0
  57. package/nightly-config.mjs +38 -0
  58. package/nightly-window.mjs +6 -0
  59. package/pack-hooks-all.mjs +15 -0
  60. package/pack-hooks.mjs +26 -0
  61. package/package.json +19 -4
  62. package/plan.mjs +272 -0
  63. package/precheck.mjs +90 -0
  64. package/project.mjs +21 -0
  65. package/ratchet.mjs +431 -0
  66. package/receipt.mjs +34 -0
  67. package/review-map.mjs +432 -0
  68. package/root.mjs +33 -0
  69. package/run.mjs +291 -0
  70. package/runners/playwright.mjs +132 -0
  71. package/score/adapters/stryker.mjs +111 -0
  72. package/score/base.mjs +82 -0
  73. package/score/cli.mjs +182 -0
  74. package/score/judge.mjs +107 -0
  75. package/score/lines.mjs +40 -0
  76. package/score/schema.mjs +68 -0
  77. package/score/survivors.mjs +103 -0
  78. package/score/targets.mjs +34 -0
  79. package/score-config.mjs +120 -0
  80. package/selection/check.mjs +45 -0
  81. package/selection/cli.mjs +267 -0
  82. package/selection/define.mjs +55 -0
  83. package/selection/for.mjs +235 -0
  84. package/selection/graphs.mjs +38 -0
  85. package/selection/project.mjs +11 -0
  86. package/selection/select.mjs +159 -0
  87. package/selection/units.mjs +646 -0
  88. package/selection/value-rules.mjs +142 -0
  89. package/selection-config.mjs +76 -0
  90. package/serve.mjs +142 -0
  91. package/summary.mjs +34 -0
  92. package/tree.mjs +211 -0
@@ -0,0 +1,646 @@
1
+ // The nightly-backed units step: plans a partial (affected) run of a test suite from the selection
2
+ // engine, the newest nightly record and the spec ledger, and judges its result. `nightlyUnits`
3
+ // is the `units` slot of the step that runs the suite. gate.config.mjs imports this file, and the
4
+ // digest, the ledger and the pack hooks all reach gate.config.mjs, so they load lazily inside the
5
+ // async planner and never at import.
6
+ import { execFileSync, execSync } from 'node:child_process';
7
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ import { AFFECTED_NIGHTLY, NIGHTLY_MAX_AGE_MS } from '../nightly-window.mjs';
11
+ import { ROOT } from '../root.mjs';
12
+ import { shadowOf, walkReport } from '../runners/playwright.mjs';
13
+
14
+ // The suite's Playwright JSON, read only by the shadow.
15
+ export const E2E_REPORT = join(ROOT, '.turbo', 'e2e-report.json');
16
+
17
+ // Written by the step's `prepare`, after the stack lock is held.
18
+ export const E2E_LIST = join(ROOT, '.turbo', 'e2e-selection.txt');
19
+
20
+ export const E2E_KNOWN_RED = join(ROOT, '.turbo', 'e2e-known-red.txt');
21
+
22
+ // A nightly record counts for known red only when its run reached the browser tests.
23
+ const REACHED = (r) => r.reachedPlaywright === true;
24
+ const byRanAtDesc = (a, b) => Date.parse(b.ranAt) - Date.parse(a.ranAt);
25
+
26
+ // An affected run always also runs the smoke tests and the setup specs (affectedListLines), so it
27
+ // never has zero units.
28
+ const SMOKE = { id: '@smoke+setup', inputs: [] };
29
+
30
+ /**
31
+ * One nightly record for one suite: a v2 record keeps each suite's result under `suites[suite]`
32
+ * (the run's sha and ranAt apply to every suite); a v1 record is the suite itself.
33
+ */
34
+ export const recordOf = (run, suite) =>
35
+ run.suites?.[suite]
36
+ ? { sha: run.sha, ranAt: run.ranAt, ...run.suites[suite] }
37
+ : run;
38
+
39
+ /** `--e2e=full` is the only value; no flag is `auto`. */
40
+ export function parseE2eFlag(args) {
41
+ const arg = args.find((a) => a === '--e2e' || a.startsWith('--e2e='));
42
+ if (arg === undefined) return { flag: 'auto' };
43
+ const value = arg.slice('--e2e='.length);
44
+ if (arg.startsWith('--e2e=') && value === 'full') return { flag: 'full' };
45
+ return {
46
+ error: `unknown --e2e value "${arg.startsWith('--e2e=') ? value : ''}". Only --e2e=full is valid.`,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * G4: `affected` only for an affected selection with a fresh green or amber
52
+ * nightly. Every other input gives `full` with the reason; nothing here fails.
53
+ */
54
+ export function chooseE2eMode({ selection, nightly, flag, nowMs }) {
55
+ if (flag === 'full') return { mode: 'full', why: '--e2e=full' };
56
+ // prettier-ignore
57
+ if (selection.mode !== 'affected') return { mode: 'full', why: selection.why };
58
+ // prettier-ignore
59
+ if (!AFFECTED_NIGHTLY.includes(nightly?.status)) return { mode: 'full', why: `nightly ${nightly?.status ?? 'missing'}` };
60
+ // prettier-ignore
61
+ if (!(nowMs - Date.parse(nightly.ranAt) <= NIGHTLY_MAX_AGE_MS)) return { mode: 'full', why: 'nightly stale' };
62
+ return { mode: 'affected', why: selection.why };
63
+ }
64
+
65
+ /**
66
+ * G5, R5: the specs the newest nightly (at the base or an ancestor of it) saw
67
+ * red, minus this branch's own selection. `runs` is every nightly record;
68
+ * `isAncestor(sha)` says whether sha is an ancestor of the base.
69
+ */
70
+ export function knownRedOf({ runs, baseSha, isAncestor, branch, selected }) {
71
+ if (/^fix\/nightly-/.test(branch)) return [];
72
+ const newest = runs
73
+ .filter((r) => REACHED(r) && typeof r.sha === 'string' && r.sha !== '')
74
+ .sort(byRanAtDesc)
75
+ .find((r) => r.sha === baseSha || isAncestor(r.sha));
76
+ if (newest?.status !== 'red') return [];
77
+ return [...(newest.redSpecs ?? [])]
78
+ .filter((f) => !selected.includes(f))
79
+ .sort();
80
+ }
81
+
82
+ /**
83
+ * R2: spec file -> the changed paths that select it. A path whose own
84
+ * selection is `full` (a trigger) is an input of every spec.
85
+ */
86
+ export function specInputs({ changed, specs, selectOne }) {
87
+ const out = new Map(specs.map((f) => [f, []]));
88
+ for (const p of changed) {
89
+ const s = selectOne(p);
90
+ for (const f of s.mode === 'full' ? specs : s.specs) {
91
+ if (!out.has(f)) out.set(f, []);
92
+ out.get(f).push(p);
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * R1, R3: which selected specs run and which are reused. A spec is reused only
100
+ * when its newest spec-ledger line on the same digest is a pass; an amber
101
+ * flaky spec (`alwaysRun`) and `--fresh` never reuse.
102
+ */
103
+ export function planSpecs({
104
+ selected,
105
+ inputs,
106
+ digestOf,
107
+ specLedger,
108
+ fresh,
109
+ alwaysRun,
110
+ }) {
111
+ const digests = new Map();
112
+ for (const f of new Set([...inputs.keys(), ...selected]))
113
+ digests.set(f, digestOf(f, inputs.get(f) ?? []));
114
+ const run = [];
115
+ const reused = [];
116
+ for (const file of selected) {
117
+ const digest = digests.get(file);
118
+ const line = specLedger.findLast(
119
+ (l) => l.id === 'e2e-spec' && l.file === file && l.digest === digest,
120
+ );
121
+ if (!fresh && !alwaysRun.has(file) && line?.status === 'pass') {
122
+ reused.push({
123
+ file,
124
+ digest,
125
+ inputs: inputs.get(file) ?? [],
126
+ reusedFrom: line.ranAt,
127
+ });
128
+ } else run.push(file);
129
+ }
130
+ return { run, reused, digests };
131
+ }
132
+
133
+ const isSmoke = (spec, t) =>
134
+ [...(spec.tags ?? []), ...(t.tags ?? [])].some(
135
+ (x) => String(x).replace(/^@/, '') === 'smoke',
136
+ ) || /@smoke\b/.test(spec.title ?? '');
137
+
138
+ /** Every test of `playwright test --list --reporter=json`: { file, line, smoke }, `line` as --test-list spells it. */
139
+ function listedTests(listJson) {
140
+ const out = [];
141
+ const visit = (suites, titles) => {
142
+ for (const s of suites ?? []) {
143
+ const own = titles.length === 0 && s.title === s.file ? [] : [s.title];
144
+ const path = [...titles, ...own.filter(Boolean)];
145
+ for (const spec of s.specs ?? []) {
146
+ const file = spec.file ?? s.file ?? '';
147
+ for (const t of spec.tests ?? [])
148
+ out.push({
149
+ file,
150
+ line: `${file} › ${[...path, spec.title].join(' › ')}`,
151
+ smoke: isSmoke(spec, t),
152
+ });
153
+ }
154
+ visit(s.suites, path);
155
+ }
156
+ };
157
+ visit(listJson?.suites, []);
158
+ return out;
159
+ }
160
+
161
+ const unique = (lines) => [...new Set(lines)];
162
+
163
+ /** `--test-list` lines for an affected run: the run specs, smoke tests and the `setup` files, never a knownRed file. */
164
+ export function affectedListLines(listJson, { run, knownRed, setup }) {
165
+ return unique(
166
+ listedTests(listJson)
167
+ .filter((t) => !knownRed.includes(t.file))
168
+ .filter((t) => run.includes(t.file) || t.smoke || setup.has(t.file))
169
+ .map((t) => t.line),
170
+ );
171
+ }
172
+
173
+ /** `--test-list-invert` lines: every test of a knownRed file. */
174
+ export function knownRedLines(listJson, knownRed) {
175
+ return unique(
176
+ listedTests(listJson)
177
+ .filter((t) => knownRed.includes(t.file))
178
+ .map((t) => t.line),
179
+ );
180
+ }
181
+
182
+ /** Fallback when playwright has no --test-list-invert: every test outside knownRed. */
183
+ export function fullListLines(listJson, knownRed) {
184
+ return unique(
185
+ listedTests(listJson)
186
+ .filter((t) => !knownRed.includes(t.file))
187
+ .map((t) => t.line),
188
+ );
189
+ }
190
+
191
+ /**
192
+ * G2: one entry per spec. `unexpected` is a fail; `expected`, `flaky` and
193
+ * `skipped` pass. A run spec absent from the report fails. In `full` every
194
+ * spec file of the report is an entry (`setup` files are not).
195
+ */
196
+ export function specResults(report, { mode, run, reused, digests, setup }) {
197
+ const seen = new Map();
198
+ walkReport(report?.suites, (spec, t) => {
199
+ const file = spec.file ?? '';
200
+ if (setup.has(file)) return;
201
+ const cur = seen.get(file) ?? { fail: false, ms: 0 };
202
+ if (t.status === 'unexpected') cur.fail = true;
203
+ cur.ms += (t.results ?? []).reduce((a, r) => a + (r.duration ?? 0), 0);
204
+ seen.set(file, cur);
205
+ });
206
+ const entry = (file) => {
207
+ const got = seen.get(file);
208
+ const digest = digests.get(file);
209
+ return {
210
+ file,
211
+ ...(digest === undefined ? {} : { digest }),
212
+ status: got !== undefined && !got.fail ? 'pass' : 'fail',
213
+ ...(got === undefined ? {} : { ms: Math.round(got.ms) }),
214
+ };
215
+ };
216
+ const files = new Set(run);
217
+ if (mode === 'full') for (const f of seen.keys()) files.add(f);
218
+ return [
219
+ ...[...files].map(entry),
220
+ ...reused.map((r) => ({
221
+ file: r.file,
222
+ digest: r.digest,
223
+ status: 'reused',
224
+ inputs: r.inputs,
225
+ reusedFrom: r.reusedFrom,
226
+ })),
227
+ ].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
228
+ }
229
+
230
+ /**
231
+ * The suite's result of the receipt (G2): buildResults' result plus mode, why,
232
+ * selected, specs, nightly and knownRed. A spec that failed or never ran turns
233
+ * a passing step into a fail.
234
+ */
235
+ export function finishE2e(result, ctx) {
236
+ const { report, mode, why, selected, nightly, knownRed, run, reused } = ctx;
237
+ const specs = specResults(report, { ...ctx, run, reused });
238
+ const bad = specs.filter((s) => s.status === 'fail').map((s) => s.file);
239
+ const status =
240
+ result.status === 'pass' && bad.length > 0 ? 'fail' : result.status;
241
+ const ran = specs.filter((s) => s.status !== 'reused').length;
242
+ const tail =
243
+ result.status === 'pass' && bad.length > 0
244
+ ? `; spec failed or absent from the report: ${bad.join(', ')}`
245
+ : '';
246
+ return {
247
+ ...result,
248
+ status,
249
+ summary: `${mode} (${why}): ${ran} run, ${reused.length} reused, ${knownRed.length} known red; ${result.summary}${tail}`,
250
+ mode,
251
+ why,
252
+ selected,
253
+ specs,
254
+ nightly:
255
+ nightly === null || nightly === undefined
256
+ ? null
257
+ : {
258
+ sha: nightly.sha,
259
+ ranAt: nightly.ranAt,
260
+ status: nightly.status,
261
+ checkedAt: ctx.checkedAt,
262
+ },
263
+ knownRed,
264
+ };
265
+ }
266
+
267
+ let selectorMemo = null;
268
+
269
+ /**
270
+ * The selector loads only when the suite can run (it parses TypeScript). One graph and one base
271
+ * reader are shared by the selection and the per-path inputs; the memo is keyed on the base and
272
+ * the selection.
273
+ */
274
+ async function loadSelector(baseSha, selection) {
275
+ if (selectorMemo?.baseSha === baseSha && selectorMemo.selection === selection)
276
+ return selectorMemo;
277
+ const { select, specHeaders } = await import('./select.mjs');
278
+ const { buildGraph } = await import('../graph/js.mjs');
279
+ const readBase = (p) => {
280
+ try {
281
+ return execFileSync('git', ['show', `${baseSha}:${p}`], {
282
+ cwd: ROOT,
283
+ encoding: 'utf8',
284
+ maxBuffer: 1 << 28,
285
+ stdio: ['ignore', 'pipe', 'ignore'],
286
+ });
287
+ } catch {
288
+ return null;
289
+ }
290
+ };
291
+ selectorMemo = {
292
+ baseSha,
293
+ selection,
294
+ select,
295
+ readBase,
296
+ specs: () => [...specHeaders(ROOT, selection).keys()],
297
+ graph: buildGraph(ROOT, selection),
298
+ };
299
+ return selectorMemo;
300
+ }
301
+
302
+ /**
303
+ * R4 (e): a changed path that is part of the test harness (`selection.outside.reach`, minus test
304
+ * files) which the suite imports. A selector that cannot run counts as yes.
305
+ */
306
+ export async function reachesHarness(paths, baseSha, { selection }) {
307
+ const { reach, testFile } = selection.outside;
308
+ const changed = paths.filter(
309
+ (p) =>
310
+ reach.some((x) => p.startsWith(x)) && !(testFile?.test(p) ?? false),
311
+ );
312
+ if (changed.length === 0) return false;
313
+ try {
314
+ const sel = await loadSelector(baseSha, selection);
315
+ const got = sel.select({
316
+ root: ROOT,
317
+ changed,
318
+ readBase: sel.readBase,
319
+ graph: sel.graph,
320
+ config: selection,
321
+ });
322
+ return got.mode === 'full' || got.specs.length > 0;
323
+ } catch {
324
+ return true;
325
+ }
326
+ }
327
+
328
+ const isAncestorOf = (baseSha) => (sha) => {
329
+ try {
330
+ execFileSync('git', ['merge-base', '--is-ancestor', sha, baseSha], {
331
+ cwd: ROOT,
332
+ stdio: 'ignore',
333
+ });
334
+ return true;
335
+ } catch {
336
+ return false;
337
+ }
338
+ };
339
+
340
+ const branchName = () => {
341
+ try {
342
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
343
+ cwd: ROOT,
344
+ encoding: 'utf8',
345
+ stdio: ['ignore', 'pipe', 'ignore'],
346
+ }).trim();
347
+ } catch {
348
+ return '';
349
+ }
350
+ };
351
+
352
+ const writeList = (path, lines) => {
353
+ mkdirSync(dirname(path), { recursive: true });
354
+ writeFileSync(path, `${lines.join('\n')}\n`);
355
+ };
356
+
357
+ /** Whether the runner takes its invert flag; a runner that cannot say counts as yes. */
358
+ const supportsInvert = (runner) => {
359
+ try {
360
+ return execSync(`${runner.command} --help`, {
361
+ cwd: ROOT,
362
+ encoding: 'utf8',
363
+ }).includes(runner.invertFlag);
364
+ } catch {
365
+ return true;
366
+ }
367
+ };
368
+
369
+ /**
370
+ * The selection keeps only specs on disk: an amber nightly's flaky spec that
371
+ * was renamed or deleted since would otherwise be run, be absent from the
372
+ * report, and fail the step. An empty listing (no selector) keeps it as is.
373
+ */
374
+ export function onDisk(selected, specNames) {
375
+ return specNames.length === 0
376
+ ? selected
377
+ : selected.filter((f) => specNames.includes(f));
378
+ }
379
+
380
+ /**
381
+ * The newest nightly record and every record, read from the gate's nightly record
382
+ * (`{ latest, history }`). No `nightly` key, or a read that throws, is no record.
383
+ */
384
+ async function nightlyRecords(suite, gate) {
385
+ try {
386
+ const { nightlyRecord } = await import('../nightly/gate.mjs');
387
+ const got = nightlyRecord(gate ?? (await import('../project.mjs')).GATE);
388
+ if (!got) return { nightly: null, runs: [] };
389
+ const nightly = got.latest ? recordOf(got.latest, suite) : null;
390
+ const runs = [
391
+ ...(got.history ?? []).map((r) => recordOf(r, suite)),
392
+ ...(nightly ? [nightly] : []),
393
+ ];
394
+ return { nightly, runs };
395
+ } catch {
396
+ return { nightly: null, runs: [] };
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Everything the step needs to be planned and judged. Any failure of the selector is data: the
402
+ * mode falls back to `full` with the reason, never an error. `opts` = { selection, suite, full,
403
+ * runFrom, gate? } (`gate` replaces the project gate when reading the gate's nightly record).
404
+ */
405
+ export async function planE2e(
406
+ paths,
407
+ baseSha,
408
+ { flag, nowMs, fresh, state },
409
+ opts,
410
+ ) {
411
+ const { selection: config, suite, full, runFrom } = opts;
412
+ const { runner } = config;
413
+ const setup = config.specs.setup;
414
+ const { nightly, runs } = await nightlyRecords(suite, opts.gate);
415
+ let selection;
416
+ let selectorError = null;
417
+ let sel = null;
418
+ try {
419
+ sel = await loadSelector(baseSha, config);
420
+ selection = sel.select({
421
+ root: ROOT,
422
+ changed: paths,
423
+ readBase: sel.readBase,
424
+ nightly,
425
+ graph: sel.graph,
426
+ config,
427
+ });
428
+ } catch (e) {
429
+ selectorError = `selector: ${e.message}`;
430
+ selection = { mode: 'full', why: selectorError, specs: [], areas: [] };
431
+ }
432
+ const { mode, why } = chooseE2eMode({ selection, nightly, flag, nowMs });
433
+ let specNames = [];
434
+ let inputs = new Map();
435
+ if (sel) {
436
+ try {
437
+ specNames = sel.specs();
438
+ inputs = specInputs({
439
+ changed: paths,
440
+ specs: specNames,
441
+ selectOne: (p) =>
442
+ sel.select({
443
+ root: ROOT,
444
+ changed: [p],
445
+ readBase: sel.readBase,
446
+ graph: sel.graph,
447
+ config,
448
+ }),
449
+ });
450
+ } catch {
451
+ specNames = [];
452
+ inputs = new Map();
453
+ }
454
+ }
455
+ const selected = onDisk(
456
+ selection.mode === 'affected' ? [...selection.specs] : [],
457
+ specNames,
458
+ );
459
+ const knownRed = knownRedOf({
460
+ runs,
461
+ baseSha,
462
+ isAncestor: isAncestorOf(baseSha),
463
+ branch: branchName(),
464
+ selected,
465
+ });
466
+ const { specDigest } = await import('../digest.mjs');
467
+ const { SPEC_LEDGER, readLedger } = await import('../ledger.mjs');
468
+ const digestOf = (file, inp) => specDigest(file, inp, { state });
469
+ const alwaysRun = new Set(
470
+ nightly?.status === 'amber' ? (nightly.flakySpecs ?? []) : [],
471
+ );
472
+ const planned = planSpecs({
473
+ selected: mode === 'affected' ? selected : [],
474
+ inputs,
475
+ digestOf,
476
+ specLedger: readLedger(SPEC_LEDGER),
477
+ fresh,
478
+ alwaysRun,
479
+ });
480
+ const run =
481
+ mode === 'affected'
482
+ ? planned.run
483
+ : specNames.filter((f) => !knownRed.includes(f));
484
+ const reused = mode === 'affected' ? planned.reused : [];
485
+
486
+ let cmd = full;
487
+ let prepare;
488
+ if (mode === 'affected') {
489
+ cmd = `${runner.command} --test-list ${runFrom}/.turbo/e2e-selection.txt`;
490
+ prepare = () =>
491
+ writeList(
492
+ E2E_LIST,
493
+ affectedListLines(runner.list(ROOT), { run, knownRed, setup }),
494
+ );
495
+ } else if (knownRed.length > 0) {
496
+ const invert = supportsInvert(runner);
497
+ cmd = invert
498
+ ? `${runner.command} --test-list-invert ${runFrom}/.turbo/e2e-known-red.txt`
499
+ : `${runner.command} --test-list ${runFrom}/.turbo/e2e-selection.txt`;
500
+ prepare = () =>
501
+ invert
502
+ ? writeList(E2E_KNOWN_RED, knownRedLines(runner.list(ROOT), knownRed))
503
+ : writeList(E2E_LIST, fullListLines(runner.list(ROOT), knownRed));
504
+ }
505
+ return {
506
+ mode,
507
+ why,
508
+ nightly,
509
+ selection,
510
+ selectorError,
511
+ selected,
512
+ knownRed,
513
+ run,
514
+ reused,
515
+ digests: planned.digests,
516
+ inputs,
517
+ cmd,
518
+ prepare,
519
+ setup,
520
+ checkedAt: new Date(nowMs).toISOString(),
521
+ };
522
+ }
523
+
524
+ export const e2eDryLine = (p) =>
525
+ `e2e: ${p.mode} (${p.why}); run ${p.mode === 'affected' ? p.run.length : 'all'}, reuse ${p.reused.length}, known red ${p.knownRed.length}`;
526
+
527
+ /**
528
+ * The receipt's `shadow`: null unless the step (`opts.id`) ran, else
529
+ * shadowOf(selection, report, opts.selection).
530
+ */
531
+ export async function finishShadow(plan, results, opts) {
532
+ const e2e = results.find((r) => r.id === opts.id);
533
+ if (!plan || !e2e || !['pass', 'fail'].includes(e2e.status)) return null;
534
+ if (plan.error)
535
+ return {
536
+ mode: 'unknown',
537
+ why: plan.error,
538
+ selected: [],
539
+ missed: null,
540
+ error: plan.error,
541
+ };
542
+ try {
543
+ let report = null;
544
+ try {
545
+ report = JSON.parse(readFileSync(E2E_REPORT, 'utf8'));
546
+ } catch {
547
+ report = null;
548
+ }
549
+ return shadowOf(plan.selection, report, opts.selection);
550
+ } catch (e) {
551
+ return {
552
+ mode: plan.selection.mode,
553
+ why: plan.selection.why,
554
+ selected: [...plan.selection.specs],
555
+ missed: null,
556
+ error: `selector: ${e.message}`,
557
+ };
558
+ }
559
+ }
560
+
561
+ export function shadowLine(sh) {
562
+ const missed = sh.error
563
+ ? `unknown (${sh.error})`
564
+ : sh.missed.length === 0
565
+ ? 'none'
566
+ : sh.missed.join(', ');
567
+ return `e2e shadow: ${sh.mode} (${sh.why}); selected ${sh.selected.length} specs; missed: ${missed}`;
568
+ }
569
+
570
+ /**
571
+ * The `units` slot of the step that runs `suite`: `selection` (omitted: the gate's `selection`,
572
+ * read at call time) says what a changed path selects; `full` is the command of a full run;
573
+ * `runFrom` is the path from the runner's working directory to the repo root. `ctx.run` =
574
+ * { e2eFlag, nowMs, fresh, state } is what the CLI adds to the plan ctx. Sel.plan is the full
575
+ * plan (finishE2e, the spec ledger and the shadow read it); the engine reads mode, why, units,
576
+ * cmd, prepare and knownRedCount.
577
+ */
578
+ export function nightlyUnits({ selection, suite, full, runFrom = '.' } = {}) {
579
+ if (
580
+ typeof suite !== 'string' ||
581
+ suite === '' ||
582
+ typeof full !== 'string' ||
583
+ full === ''
584
+ )
585
+ throw new Error('nightlyUnits: suite and full are required');
586
+ const optsOf = async () => {
587
+ const config =
588
+ selection ?? (await import('../project.mjs')).GATE.selection;
589
+ if (!config?.runner)
590
+ throw new Error('nightlyUnits: the selection has no test runner');
591
+ return { selection: config, suite, full, runFrom };
592
+ };
593
+ return {
594
+ reaches: async (ctx) =>
595
+ reachesHarness(ctx.paths, ctx.base, await optsOf()),
596
+ select: async (ctx) => {
597
+ const run = ctx.run ?? {};
598
+ const plan = await planE2e(
599
+ ctx.paths,
600
+ ctx.base,
601
+ {
602
+ flag: run.e2eFlag,
603
+ nowMs: run.nowMs ?? Date.now(),
604
+ fresh: run.fresh ?? false,
605
+ state: run.state,
606
+ },
607
+ await optsOf(),
608
+ );
609
+ return {
610
+ mode: plan.mode,
611
+ why: plan.why,
612
+ units:
613
+ plan.mode === 'affected'
614
+ ? [
615
+ ...plan.selected.map((id) => ({
616
+ id,
617
+ inputs: plan.inputs.get(id) ?? [],
618
+ })),
619
+ SMOKE,
620
+ ]
621
+ : [],
622
+ cmd: plan.cmd,
623
+ prepare: plan.prepare,
624
+ knownRedCount: plan.knownRed.length,
625
+ plan,
626
+ };
627
+ },
628
+ cmd: (_units, sel) => sel.cmd ?? full,
629
+ nightly: {
630
+ suite,
631
+ reportPath: E2E_REPORT,
632
+ parseFlag: parseE2eFlag,
633
+ dryLine: e2eDryLine,
634
+ finish: finishE2e,
635
+ // A null plan (no affected run) needs no selection; resolving it first would throw on a runner-less config.
636
+ finishShadow: async (plan, results, o) =>
637
+ plan
638
+ ? finishShadow(plan, results, {
639
+ selection: (await optsOf()).selection,
640
+ ...o,
641
+ })
642
+ : null,
643
+ shadowLine,
644
+ },
645
+ };
646
+ }