@ticket-gate/engine 0.0.0 → 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.
- package/LICENSE +202 -0
- package/README.md +82 -0
- package/bin/ticket-gate.mjs +261 -0
- package/checks/assertions-unguarded.mjs +31 -0
- package/checks/assertions.mjs +319 -0
- package/checks/cache-report.mjs +313 -0
- package/checks/docs-stale.mjs +69 -0
- package/checks/duplication.mjs +258 -0
- package/checks/ratchet-options.mjs +51 -0
- package/checks/surface.mjs +397 -0
- package/cli.mjs +379 -0
- package/commands-config.mjs +55 -0
- package/config.mjs +597 -0
- package/digest.mjs +195 -0
- package/env-name.mjs +24 -0
- package/failures.mjs +256 -0
- package/fu.mjs +458 -0
- package/graph/js.mjs +517 -0
- package/graph/load-time.mjs +309 -0
- package/graph/typescript.mjs +28 -0
- package/hooks/pre-tool-use.mjs +213 -0
- package/hooks/stop.mjs +495 -0
- package/is-main.mjs +15 -0
- package/kill-group.mjs +13 -0
- package/ledger.mjs +186 -0
- package/lock.d.mts +72 -0
- package/lock.mjs +329 -0
- package/move-proof.mjs +206 -0
- package/mutations/check.mjs +506 -0
- package/mutations/cli.mjs +361 -0
- package/mutations/exec.mjs +120 -0
- package/mutations/gate.mjs +148 -0
- package/mutations/harness.mjs +240 -0
- package/mutations/kinds.mjs +148 -0
- package/mutations/locate.mjs +71 -0
- package/mutations/project.mjs +23 -0
- package/mutations/registry.mjs +173 -0
- package/mutations/rotate.mjs +419 -0
- package/mutations/rotation-status.mjs +93 -0
- package/mutations/run.mjs +252 -0
- package/mutations/select.mjs +333 -0
- package/mutations/served.mjs +216 -0
- package/mutations/validate.mjs +86 -0
- package/mutations/verdict.mjs +159 -0
- package/nightly/cli.mjs +30 -0
- package/nightly/define.mjs +111 -0
- package/nightly/e2e-runs.mjs +73 -0
- package/nightly/gate.mjs +25 -0
- package/nightly/io.mjs +109 -0
- package/nightly/launchd.mjs +42 -0
- package/nightly/record.mjs +231 -0
- package/nightly/run.mjs +470 -0
- package/nightly/schedule.mjs +76 -0
- package/nightly/seed.mjs +78 -0
- package/nightly/status.mjs +189 -0
- package/nightly/suites.mjs +43 -0
- package/nightly-config.mjs +38 -0
- package/nightly-window.mjs +6 -0
- package/pack-hooks-all.mjs +15 -0
- package/pack-hooks.mjs +26 -0
- package/package.json +21 -1
- package/plan.mjs +272 -0
- package/precheck.mjs +90 -0
- package/project.mjs +21 -0
- package/ratchet.mjs +431 -0
- package/receipt.mjs +34 -0
- package/review-map.mjs +432 -0
- package/root.mjs +33 -0
- package/run.mjs +291 -0
- package/runners/playwright.mjs +132 -0
- package/score/adapters/stryker.mjs +111 -0
- package/score/base.mjs +82 -0
- package/score/cli.mjs +182 -0
- package/score/judge.mjs +107 -0
- package/score/lines.mjs +40 -0
- package/score/schema.mjs +68 -0
- package/score/survivors.mjs +103 -0
- package/score/targets.mjs +34 -0
- package/score-config.mjs +120 -0
- package/selection/check.mjs +45 -0
- package/selection/cli.mjs +267 -0
- package/selection/define.mjs +55 -0
- package/selection/for.mjs +235 -0
- package/selection/graphs.mjs +38 -0
- package/selection/project.mjs +11 -0
- package/selection/select.mjs +159 -0
- package/selection/units.mjs +646 -0
- package/selection/value-rules.mjs +142 -0
- package/selection-config.mjs +76 -0
- package/serve.mjs +142 -0
- package/summary.mjs +34 -0
- 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
|
+
}
|