@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,45 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { listFiles } from '../graph/js.mjs';
|
|
5
|
+
import { projectSelection } from './project.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every spec declares known areas on line 1, no new ticket-named spec, the smoke cap, then each
|
|
9
|
+
* config check (route table, drift guards).
|
|
10
|
+
* @param {string} root repo root @returns {string[]} one problem per line, sorted
|
|
11
|
+
*/
|
|
12
|
+
export function checkE2eAreas(root, cfg = projectSelection()) {
|
|
13
|
+
const problems = [];
|
|
14
|
+
const known = cfg.areas === null ? null : new Set(cfg.areas);
|
|
15
|
+
const { dir, match, header, ticketNamed, grandfathered, smokeMax } =
|
|
16
|
+
cfg.specs;
|
|
17
|
+
const specs = listFiles(root, dir, (p) => match.test(p));
|
|
18
|
+
let smoke = 0;
|
|
19
|
+
for (const p of specs) {
|
|
20
|
+
const name = p.slice(dir.length + 1);
|
|
21
|
+
const text = readFileSync(join(root, p), 'utf8');
|
|
22
|
+
const m = header.exec(text.split('\n', 1)[0].replace(/\r$/, ''));
|
|
23
|
+
if (!m) problems.push(`missing-header ${name}`);
|
|
24
|
+
else if (known)
|
|
25
|
+
for (const id of m[1].split(', '))
|
|
26
|
+
if (!known.has(id)) problems.push(`unknown-area ${name} ${id}`);
|
|
27
|
+
const base = name.split('/').pop();
|
|
28
|
+
if (ticketNamed?.test(base) && !grandfathered.has(base))
|
|
29
|
+
problems.push(`ticket-named ${name}`);
|
|
30
|
+
smoke += text.split(`'@smoke'`).length - 1;
|
|
31
|
+
}
|
|
32
|
+
if (smoke > smokeMax) problems.push(`smoke-cap ${smoke}`);
|
|
33
|
+
|
|
34
|
+
let entries = 0;
|
|
35
|
+
for (const check of cfg.checks) {
|
|
36
|
+
const out = check.run(root);
|
|
37
|
+
problems.push(...out.problems);
|
|
38
|
+
entries += out.entries ?? 0;
|
|
39
|
+
}
|
|
40
|
+
problems.sort();
|
|
41
|
+
Object.defineProperty(problems, 'counts', {
|
|
42
|
+
value: { specs: specs.length, entries },
|
|
43
|
+
});
|
|
44
|
+
return problems;
|
|
45
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { buildGraph, readText } from '../graph/js.mjs';
|
|
6
|
+
import { loadTimeUnits } from '../graph/load-time.mjs';
|
|
7
|
+
import { SelectionConfigError } from './define.mjs';
|
|
8
|
+
import { projectSelection } from './project.mjs';
|
|
9
|
+
import { select, specHeaders } from './select.mjs';
|
|
10
|
+
import { summarizeReport, testListLines } from '../runners/playwright.mjs';
|
|
11
|
+
import { forFiles, forLines, testCounts } from './for.mjs';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------- drift
|
|
14
|
+
|
|
15
|
+
/** Pages a spec visited outside its header's areas, mapped by the config's `drift.map`. */
|
|
16
|
+
export async function driftOf(report, headers, cfg = projectSelection()) {
|
|
17
|
+
if (!cfg.drift)
|
|
18
|
+
throw new SelectionConfigError('selection.drift is required for --drift');
|
|
19
|
+
return cfg.drift.map(summarizeReport(report).navigation, headers);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------- CLI
|
|
23
|
+
|
|
24
|
+
/** `quiet` drops git's stderr (a `git show` of a path the base lacks prints a fatal line). */
|
|
25
|
+
function git(root, args, quiet = false) {
|
|
26
|
+
return execFileSync('git', args, {
|
|
27
|
+
cwd: root,
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
maxBuffer: 1 << 28,
|
|
30
|
+
...(quiet ? { stdio: ['ignore', 'pipe', 'ignore'] } : {}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function driftCli(file, cfg) {
|
|
35
|
+
let report;
|
|
36
|
+
try {
|
|
37
|
+
report = JSON.parse(readFileSync(resolve(cfg.root, file), 'utf8'));
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error(
|
|
40
|
+
`${cfg.label}: cannot read ${file} (${e.message}). Run ${cfg.commands.verify} (it writes .turbo/e2e-report.json) or pass a Playwright JSON report.`,
|
|
41
|
+
);
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
let drift;
|
|
45
|
+
try {
|
|
46
|
+
drift = await driftOf(report, specHeaders(cfg.root, cfg), cfg);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
if (!(e instanceof SelectionConfigError)) throw e;
|
|
49
|
+
console.error(`${cfg.label}: ${e.message}`);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
for (const d of drift)
|
|
53
|
+
console.log(
|
|
54
|
+
`drift: ${d.file} visited ${d.pathname} (${d.area}) not in its header`,
|
|
55
|
+
);
|
|
56
|
+
console.log(
|
|
57
|
+
drift.length === 0
|
|
58
|
+
? 'drift: none'
|
|
59
|
+
: `drift: ${drift.length}. Add the area to that spec's // e2e-areas: header, then rerun the gate.`,
|
|
60
|
+
);
|
|
61
|
+
return drift.length === 0 ? 0 : 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Files every shell page evaluates that hold load-time code. */
|
|
65
|
+
function loadTimeCli(cfg) {
|
|
66
|
+
const root = cfg.root;
|
|
67
|
+
const g = buildGraph(root, cfg);
|
|
68
|
+
const rows = [];
|
|
69
|
+
let outside = 0;
|
|
70
|
+
for (const f of g.evaluationSet()) {
|
|
71
|
+
const text = readText(root, f);
|
|
72
|
+
if (text === null) continue;
|
|
73
|
+
const n = loadTimeUnits(text, f).filter((u) => u.loadTime).length;
|
|
74
|
+
if (n === 0) continue;
|
|
75
|
+
const shell = g.reach(f).areas.has('*');
|
|
76
|
+
if (!shell) outside += 1;
|
|
77
|
+
rows.push(`${f} ${n}${shell ? ' shell' : ''}`);
|
|
78
|
+
}
|
|
79
|
+
for (const r of rows) console.log(r);
|
|
80
|
+
console.log(
|
|
81
|
+
`load-time: ${rows.length} files in E (${outside} outside the shell closure)`,
|
|
82
|
+
);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** R9: the selection for each of the last n first-parent merges touching apps/web, graph and headers from HEAD. */
|
|
87
|
+
function replayCli(rev, count, cfg) {
|
|
88
|
+
const t0 = Date.now();
|
|
89
|
+
const root = cfg.root;
|
|
90
|
+
const merges = git(root, [
|
|
91
|
+
'log',
|
|
92
|
+
'--first-parent',
|
|
93
|
+
'--merges',
|
|
94
|
+
'--reverse',
|
|
95
|
+
`--max-count=${count}`,
|
|
96
|
+
'--format=%h%x09%s',
|
|
97
|
+
`${rev}`,
|
|
98
|
+
'--',
|
|
99
|
+
...cfg.replayPaths,
|
|
100
|
+
])
|
|
101
|
+
.split('\n')
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.map((l) => l.split('\t'));
|
|
104
|
+
// --reverse with --max-count keeps the newest n, printed oldest first.
|
|
105
|
+
const graph = buildGraph(root, cfg);
|
|
106
|
+
let full = 0;
|
|
107
|
+
for (const [sha, subject] of merges) {
|
|
108
|
+
const changed = git(root, [
|
|
109
|
+
'diff',
|
|
110
|
+
'--name-only',
|
|
111
|
+
'--no-renames',
|
|
112
|
+
`${sha}^1`,
|
|
113
|
+
sha,
|
|
114
|
+
])
|
|
115
|
+
.split('\n')
|
|
116
|
+
.filter(Boolean);
|
|
117
|
+
const readBase = (p) => {
|
|
118
|
+
try {
|
|
119
|
+
return git(root, ['show', `${sha}^1:${p}`], true);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const sel = select({ root, changed, readBase, graph, config: cfg });
|
|
125
|
+
if (sel.mode === 'full') full += 1;
|
|
126
|
+
const gone =
|
|
127
|
+
sel.mode === 'full' && sel.why.includes(': ')
|
|
128
|
+
? !existsSync(join(root, sel.why.split(': ')[0]))
|
|
129
|
+
: false;
|
|
130
|
+
const title = subject
|
|
131
|
+
.replace(/^Merge pull request /, '')
|
|
132
|
+
.replace(/ from [^/]+\//, ' ');
|
|
133
|
+
console.log(
|
|
134
|
+
`${sha} ${title} -> ${sel.mode}: ${sel.why}${gone ? ' (absent at HEAD)' : ''}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
console.log(`full: ${full} of ${merges.length}`);
|
|
138
|
+
console.log(`time: ${Date.now() - t0} ms`);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function forCli(forArg, baseArg, budgetArg, base, usage, cfg) {
|
|
143
|
+
const root = cfg.root;
|
|
144
|
+
const files = forArg.slice('--for='.length).split(',').filter(Boolean);
|
|
145
|
+
const budget = budgetArg ? Number(budgetArg.slice('--budget='.length)) : 150;
|
|
146
|
+
if (files.length === 0 || !Number.isInteger(budget) || budget < 1) {
|
|
147
|
+
console.error(
|
|
148
|
+
`${cfg.label}: --for needs files and a positive budget. ${usage}`,
|
|
149
|
+
);
|
|
150
|
+
return 2;
|
|
151
|
+
}
|
|
152
|
+
const rev = baseArg ? baseArg.slice('--base='.length) : base;
|
|
153
|
+
const read = (p) => {
|
|
154
|
+
try {
|
|
155
|
+
return git(root, ['show', `${rev}:${p}`]);
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
let counts;
|
|
161
|
+
try {
|
|
162
|
+
counts = testCounts(cfg.runner.list(root));
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.error(
|
|
165
|
+
`${cfg.label}: cannot list tests (${e.message}). Run: ${cfg.runner.command} --list`,
|
|
166
|
+
);
|
|
167
|
+
return 2;
|
|
168
|
+
}
|
|
169
|
+
const result = forFiles({
|
|
170
|
+
root,
|
|
171
|
+
files,
|
|
172
|
+
readBase: read,
|
|
173
|
+
counts,
|
|
174
|
+
budget,
|
|
175
|
+
config: cfg,
|
|
176
|
+
});
|
|
177
|
+
for (const l of forLines(result, budget)) console.log(l);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function main(argv, cfg = projectSelection()) {
|
|
182
|
+
const root = cfg.root;
|
|
183
|
+
const { baseRef, changedPaths } = await import('../tree.mjs');
|
|
184
|
+
const base = baseRef();
|
|
185
|
+
const readBase = (p) => {
|
|
186
|
+
try {
|
|
187
|
+
return git(root, ['show', `${base}:${p}`]);
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const testList = argv.find(
|
|
193
|
+
(a) => a === '--test-list' || a.startsWith('--test-list='),
|
|
194
|
+
);
|
|
195
|
+
const driftArg = argv.find((a) => a.startsWith('--drift='));
|
|
196
|
+
const replayArg = argv.find((a) => a.startsWith('--replay='));
|
|
197
|
+
const countArg = argv.find((a) => a.startsWith('--count='));
|
|
198
|
+
const loadTimeArg = argv.find((a) => a === '--load-time');
|
|
199
|
+
const forArg = argv.find((a) => a.startsWith('--for='));
|
|
200
|
+
const budgetArg = argv.find((a) => a.startsWith('--budget='));
|
|
201
|
+
const baseArg = argv.find((a) => a.startsWith('--base='));
|
|
202
|
+
const unknown = argv.filter(
|
|
203
|
+
(a) =>
|
|
204
|
+
a !== testList &&
|
|
205
|
+
a !== driftArg &&
|
|
206
|
+
a !== replayArg &&
|
|
207
|
+
a !== countArg &&
|
|
208
|
+
a !== loadTimeArg &&
|
|
209
|
+
a !== forArg &&
|
|
210
|
+
a !== budgetArg &&
|
|
211
|
+
a !== baseArg,
|
|
212
|
+
);
|
|
213
|
+
const usage = `Usage: ${cfg.commands.self} [--test-list[=<out>] | --drift=<report.json> | --replay=<rev> --count=<n> | --load-time | --for=<files> [--budget=<tests>] [--base=<rev>]]`;
|
|
214
|
+
if (unknown.length > 0 || (countArg && !replayArg)) {
|
|
215
|
+
console.error(
|
|
216
|
+
`${cfg.label}: unknown argument ${unknown[0] ?? countArg}. ${usage}`,
|
|
217
|
+
);
|
|
218
|
+
return 2;
|
|
219
|
+
}
|
|
220
|
+
const others = [testList, driftArg, replayArg, loadTimeArg].some(Boolean);
|
|
221
|
+
if ((!forArg && (budgetArg || baseArg)) || (forArg && others)) {
|
|
222
|
+
console.error(
|
|
223
|
+
`${cfg.label}: --for stands alone (--budget and --base need it). ${usage}`,
|
|
224
|
+
);
|
|
225
|
+
return 2;
|
|
226
|
+
}
|
|
227
|
+
if (forArg) return forCli(forArg, baseArg, budgetArg, base, usage, cfg);
|
|
228
|
+
if (loadTimeArg) return loadTimeCli(cfg);
|
|
229
|
+
if (driftArg) return driftCli(driftArg.slice('--drift='.length), cfg);
|
|
230
|
+
if (replayArg) {
|
|
231
|
+
const count = countArg ? Number(countArg.slice('--count='.length)) : 19;
|
|
232
|
+
if (!Number.isInteger(count) || count < 1) {
|
|
233
|
+
console.error(
|
|
234
|
+
`${cfg.label}: --count must be a positive integer. ${usage}`,
|
|
235
|
+
);
|
|
236
|
+
return 2;
|
|
237
|
+
}
|
|
238
|
+
return replayCli(replayArg.slice('--replay='.length), count, cfg);
|
|
239
|
+
}
|
|
240
|
+
const t0 = Date.now();
|
|
241
|
+
const selection = select({
|
|
242
|
+
root,
|
|
243
|
+
changed: changedPaths(base),
|
|
244
|
+
readBase,
|
|
245
|
+
config: cfg,
|
|
246
|
+
});
|
|
247
|
+
if (!testList) {
|
|
248
|
+
console.log(`${selection.mode}: ${selection.why} (${Date.now() - t0} ms)`);
|
|
249
|
+
for (const s of selection.specs) console.log(` ${s}`);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
if (selection.mode === 'full') {
|
|
253
|
+
console.log(`full: ${selection.why}`);
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
const out = resolve(
|
|
257
|
+
root,
|
|
258
|
+
testList.split('=')[1] ?? '.turbo/e2e-selection.txt',
|
|
259
|
+
);
|
|
260
|
+
const lines = testListLines(cfg.runner.list(root), selection) ?? [];
|
|
261
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
262
|
+
writeFileSync(out, `${lines.join('\n')}\n`);
|
|
263
|
+
console.log(
|
|
264
|
+
`${selection.why}; ${lines.length} tests -> ${relative(root, out)}`,
|
|
265
|
+
);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The selection config. `defineSelection(input)` fills the defaults and rejects a
|
|
3
|
+
* config that has no graph source or no spec location, so a wrong config fails at load, not mid-run.
|
|
4
|
+
* Dependency-free Node ESM.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class SelectionConfigError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'SelectionConfigError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const missing = (key) =>
|
|
15
|
+
new SelectionConfigError(`selection.${key} is required`);
|
|
16
|
+
|
|
17
|
+
export function defineSelection(input = {}) {
|
|
18
|
+
const specs = input.specs ?? {};
|
|
19
|
+
if (!specs.dir) throw missing('specs.dir');
|
|
20
|
+
if (!specs.match) throw missing('specs.match');
|
|
21
|
+
if (!specs.header) throw missing('specs.header');
|
|
22
|
+
const out = {
|
|
23
|
+
label: 'e2e-select',
|
|
24
|
+
root: process.cwd(),
|
|
25
|
+
areas: null,
|
|
26
|
+
...input,
|
|
27
|
+
specs: {
|
|
28
|
+
setup: new Set(),
|
|
29
|
+
smokeMax: Infinity,
|
|
30
|
+
ticketNamed: null,
|
|
31
|
+
grandfathered: new Set(),
|
|
32
|
+
...specs,
|
|
33
|
+
},
|
|
34
|
+
outside: {
|
|
35
|
+
prefixes: [],
|
|
36
|
+
rootRe: null,
|
|
37
|
+
testFile: null,
|
|
38
|
+
reach: [],
|
|
39
|
+
...input.outside,
|
|
40
|
+
},
|
|
41
|
+
triggers: input.triggers ?? [],
|
|
42
|
+
ignored: input.ignored ?? [],
|
|
43
|
+
rules: input.rules ?? [],
|
|
44
|
+
unreachedOk: input.unreachedOk ?? [],
|
|
45
|
+
checks: input.checks ?? [],
|
|
46
|
+
runner: input.runner ?? null,
|
|
47
|
+
locale: input.locale ?? null,
|
|
48
|
+
drift: input.drift ?? null,
|
|
49
|
+
replayPaths: input.replayPaths ?? [],
|
|
50
|
+
commands: input.commands ?? {},
|
|
51
|
+
};
|
|
52
|
+
if (!Array.isArray(out.graphs) || out.graphs.length === 0)
|
|
53
|
+
throw missing('graphs');
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { buildGraph, listFiles, readText } from '../graph/js.mjs';
|
|
5
|
+
import { lazyTs } from '../graph/typescript.mjs';
|
|
6
|
+
import { parseTable } from './value-rules.mjs';
|
|
7
|
+
import { projectSelection } from './project.mjs';
|
|
8
|
+
import { select, specHeaders } from './select.mjs';
|
|
9
|
+
|
|
10
|
+
const ts = lazyTs();
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------- --for
|
|
13
|
+
|
|
14
|
+
const LOCATOR_ATTRS = ['role', 'aria-label', 'data-testid', 'data-slot'];
|
|
15
|
+
|
|
16
|
+
/** Per-file test counts from a `playwright test --list --reporter=json` listing. */
|
|
17
|
+
export function testCounts(listJson) {
|
|
18
|
+
const counts = new Map();
|
|
19
|
+
const visit = (suites) => {
|
|
20
|
+
for (const s of suites ?? []) {
|
|
21
|
+
for (const spec of s.specs ?? []) {
|
|
22
|
+
const file = spec.file ?? s.file ?? '';
|
|
23
|
+
counts.set(file, (counts.get(file) ?? 0) + (spec.tests ?? []).length);
|
|
24
|
+
}
|
|
25
|
+
visit(s.suites);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
visit(listJson?.suites);
|
|
29
|
+
return counts;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Literal attribute values and t('key') keys of a TSX text. */
|
|
33
|
+
function locatorsIn(text) {
|
|
34
|
+
const values = new Set();
|
|
35
|
+
const keys = new Set();
|
|
36
|
+
if (text === null) return { values, keys };
|
|
37
|
+
const sf = ts.createSourceFile(
|
|
38
|
+
'x.tsx',
|
|
39
|
+
text,
|
|
40
|
+
ts.ScriptTarget.Latest,
|
|
41
|
+
true,
|
|
42
|
+
ts.ScriptKind.TSX,
|
|
43
|
+
);
|
|
44
|
+
const visit = (n) => {
|
|
45
|
+
if (ts.isJsxAttribute(n) && LOCATOR_ATTRS.includes(n.name.getText(sf))) {
|
|
46
|
+
let v = n.initializer;
|
|
47
|
+
if (v && ts.isJsxExpression(v)) v = v.expression;
|
|
48
|
+
if (v && (ts.isStringLiteral(v) || ts.isNoSubstitutionTemplateLiteral(v)))
|
|
49
|
+
values.add(v.text);
|
|
50
|
+
} else if (
|
|
51
|
+
ts.isCallExpression(n) &&
|
|
52
|
+
ts.isIdentifier(n.expression) &&
|
|
53
|
+
n.expression.text === 't' &&
|
|
54
|
+
n.arguments[0] &&
|
|
55
|
+
ts.isStringLiteral(n.arguments[0])
|
|
56
|
+
)
|
|
57
|
+
keys.add(n.arguments[0].text);
|
|
58
|
+
ts.forEachChild(n, visit);
|
|
59
|
+
};
|
|
60
|
+
visit(sf);
|
|
61
|
+
return { values, keys };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Locator values present in baseText and absent from headText, sorted. */
|
|
65
|
+
export function removedLocators(baseText, headText, en) {
|
|
66
|
+
const was = locatorsIn(baseText);
|
|
67
|
+
const now = locatorsIn(headText);
|
|
68
|
+
const removed = new Set();
|
|
69
|
+
for (const v of was.values) if (!now.values.has(v)) removed.add(v);
|
|
70
|
+
for (const key of was.keys) {
|
|
71
|
+
if (now.keys.has(key)) continue;
|
|
72
|
+
const enValue = en.get(key);
|
|
73
|
+
if (typeof enValue === 'string') removed.add(enValue);
|
|
74
|
+
}
|
|
75
|
+
return [...removed].filter((v) => v.length > 0).sort();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The narrow run for named files (a dev aid; the gate's selection is untouched). */
|
|
79
|
+
export function forFiles({
|
|
80
|
+
root: rootArg,
|
|
81
|
+
files,
|
|
82
|
+
readBase = () => null,
|
|
83
|
+
counts,
|
|
84
|
+
budget = 150,
|
|
85
|
+
graph = null,
|
|
86
|
+
config = null,
|
|
87
|
+
}) {
|
|
88
|
+
const cfg = config ?? projectSelection();
|
|
89
|
+
const root = rootArg ?? cfg.root;
|
|
90
|
+
const specDir = cfg.specs.dir;
|
|
91
|
+
const enFile = cfg.locale?.table;
|
|
92
|
+
const g = graph ?? buildGraph(root, cfg);
|
|
93
|
+
const headers = specHeaders(root, cfg);
|
|
94
|
+
const gated = [];
|
|
95
|
+
const perFile = [];
|
|
96
|
+
const listed = new Map(); // spec -> areas declaring it (reached ones)
|
|
97
|
+
const reached = new Set();
|
|
98
|
+
const present = [];
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
if (!existsSync(join(root, file))) {
|
|
101
|
+
perFile.push({ file, missing: true, areas: [] });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const one = select({
|
|
105
|
+
root,
|
|
106
|
+
changed: [file],
|
|
107
|
+
readBase,
|
|
108
|
+
graph: g,
|
|
109
|
+
config: cfg,
|
|
110
|
+
});
|
|
111
|
+
if (one.mode === 'full') {
|
|
112
|
+
gated.push({ file, rule: one.rule });
|
|
113
|
+
perFile.push({ file, rule: one.rule, areas: [] });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
present.push(file);
|
|
117
|
+
perFile.push({ file, areas: [] });
|
|
118
|
+
for (const a of one.areas) reached.add(a);
|
|
119
|
+
for (const s of one.specs) {
|
|
120
|
+
const declared = (headers.get(s) ?? []).filter((a) =>
|
|
121
|
+
one.areas.includes(a),
|
|
122
|
+
);
|
|
123
|
+
listed.set(s, new Set([...(listed.get(s) ?? []), ...declared]));
|
|
124
|
+
}
|
|
125
|
+
perFile[perFile.length - 1].areas = one.areas;
|
|
126
|
+
}
|
|
127
|
+
const testsOf = (s) => counts.get(s) ?? 0;
|
|
128
|
+
const sizes = new Map();
|
|
129
|
+
for (const a of reached) {
|
|
130
|
+
const declaring = [...headers].filter(([, d]) => d.includes(a));
|
|
131
|
+
sizes.set(a, {
|
|
132
|
+
n: declaring.length,
|
|
133
|
+
tests: declaring.reduce((t, [s]) => t + testsOf(s), 0),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const groups = [...reached].map((area) => ({
|
|
137
|
+
area,
|
|
138
|
+
tests: sizes.get(area).tests,
|
|
139
|
+
size: sizes.get(area).n,
|
|
140
|
+
specs: [],
|
|
141
|
+
}));
|
|
142
|
+
groups.sort((a, b) => a.tests - b.tests || a.area.localeCompare(b.area));
|
|
143
|
+
const rank = new Map(groups.map((grp, i) => [grp.area, i]));
|
|
144
|
+
for (const f of perFile)
|
|
145
|
+
f.areas = [...f.areas].sort((a, b) => rank.get(a) - rank.get(b));
|
|
146
|
+
const direct = { area: 'direct', tests: 0, size: 0, specs: [] };
|
|
147
|
+
const placed = new Set();
|
|
148
|
+
for (const grp of groups)
|
|
149
|
+
for (const [s, declared] of [...listed].sort()) {
|
|
150
|
+
if (!declared.has(grp.area) || placed.has(s)) continue;
|
|
151
|
+
placed.add(s);
|
|
152
|
+
grp.specs.push({ spec: s, tests: testsOf(s) });
|
|
153
|
+
}
|
|
154
|
+
for (const [s] of [...listed].sort())
|
|
155
|
+
if (!placed.has(s)) direct.specs.push({ spec: s, tests: testsOf(s) });
|
|
156
|
+
for (const grp of [...groups, direct]) {
|
|
157
|
+
if (grp.area === 'direct') {
|
|
158
|
+
grp.size = grp.specs.length;
|
|
159
|
+
grp.tests = grp.specs.reduce((t, x) => t + x.tests, 0);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const out = direct.specs.length > 0 ? [direct, ...groups] : groups;
|
|
163
|
+
const all = [...listed.keys()].sort();
|
|
164
|
+
const tests = all.reduce((t, s) => t + testsOf(s), 0);
|
|
165
|
+
const overBudget = tests > budget;
|
|
166
|
+
let specs = all;
|
|
167
|
+
let filtered = null;
|
|
168
|
+
if (overBudget) {
|
|
169
|
+
const headEn = enFile ? readText(root, enFile) : null;
|
|
170
|
+
const baseEn = enFile ? readBase(enFile) : null;
|
|
171
|
+
const en = new Map([
|
|
172
|
+
...(baseEn === null ? [] : (parseTable(baseEn, enFile)?.map ?? [])),
|
|
173
|
+
...(headEn === null ? [] : (parseTable(headEn, enFile)?.map ?? [])),
|
|
174
|
+
]);
|
|
175
|
+
const locators = [
|
|
176
|
+
...new Set(
|
|
177
|
+
present.flatMap((f) =>
|
|
178
|
+
removedLocators(readBase(f), readText(root, f), en),
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
].sort();
|
|
182
|
+
const keep = new Set();
|
|
183
|
+
const listedSet = new Set(all);
|
|
184
|
+
for (const f of listFiles(root, specDir, (p) => p.endsWith('.ts'))) {
|
|
185
|
+
const text = readText(root, f) ?? '';
|
|
186
|
+
if (!locators.some((l) => text.includes(l))) continue;
|
|
187
|
+
const rel = f.slice(specDir.length + 1);
|
|
188
|
+
for (const s of cfg.specs.match.test(f) ? [rel] : g.importersOf(f))
|
|
189
|
+
if (listedSet.has(s)) keep.add(s);
|
|
190
|
+
}
|
|
191
|
+
specs = all.filter((s) => keep.has(s));
|
|
192
|
+
filtered = { kept: specs.length, of: all.length, locators };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
gated,
|
|
196
|
+
files: perFile,
|
|
197
|
+
groups: out,
|
|
198
|
+
total: tests,
|
|
199
|
+
filtered,
|
|
200
|
+
specs,
|
|
201
|
+
command:
|
|
202
|
+
specs.length === 0
|
|
203
|
+
? 'none'
|
|
204
|
+
: `${cfg.runner.command} ${specs.map((s) => `${cfg.runner.specPrefix}${s}`).join(' ')}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Printed lines for a ForResult. */
|
|
209
|
+
export function forLines(result, budget) {
|
|
210
|
+
const lines = [];
|
|
211
|
+
for (const f of result.files) {
|
|
212
|
+
if (f.missing) lines.push(`${f.file}: not found`);
|
|
213
|
+
else if (f.rule) lines.push(`${f.file}: ${f.rule}: use the gate`);
|
|
214
|
+
else lines.push(`${f.file}: ${f.areas.join(', ') || 'no area'}`);
|
|
215
|
+
}
|
|
216
|
+
for (const grp of result.groups) {
|
|
217
|
+
lines.push(` ${grp.area} ${grp.size} specs / ${grp.tests} tests`);
|
|
218
|
+
for (const s of grp.specs) lines.push(` ${s.spec} (${s.tests})`);
|
|
219
|
+
}
|
|
220
|
+
const areas = result.groups.filter((x) => x.area !== 'direct').length;
|
|
221
|
+
const listedSpecs = result.groups.reduce((n, x) => n + x.specs.length, 0);
|
|
222
|
+
lines.push(
|
|
223
|
+
`for: ${result.files.length} files, ${areas} areas, ${listedSpecs} specs, ${result.total} tests (budget ${budget})`,
|
|
224
|
+
);
|
|
225
|
+
if (result.filtered) {
|
|
226
|
+
const f = result.filtered;
|
|
227
|
+
lines.push(
|
|
228
|
+
`filtered: ${f.kept} of ${f.of} specs by ${f.locators.length} removed locators`,
|
|
229
|
+
`locators: ${f.locators.join(', ')}`,
|
|
230
|
+
'limit: an implicit role change (a <button> becoming role="radio") is not a removed locator',
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
lines.push(result.command);
|
|
234
|
+
return lines;
|
|
235
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The union of graph sources. One source with no `expand` behaves exactly as that
|
|
3
|
+
* source. Dependency-free Node ESM.
|
|
4
|
+
*/
|
|
5
|
+
export function unionGraph(sources, root, cfg) {
|
|
6
|
+
const graphs = sources.map((s) => s.graph(root, cfg));
|
|
7
|
+
return {
|
|
8
|
+
get unresolved() {
|
|
9
|
+
for (const g of graphs) if (g.unresolved) return g.unresolved;
|
|
10
|
+
return null;
|
|
11
|
+
},
|
|
12
|
+
reach(path) {
|
|
13
|
+
const areas = new Set();
|
|
14
|
+
const specs = new Set();
|
|
15
|
+
const merge = (r) => {
|
|
16
|
+
for (const a of r.areas) areas.add(a);
|
|
17
|
+
for (const s of r.specs) specs.add(s);
|
|
18
|
+
};
|
|
19
|
+
const paths = [path, ...graphs.flatMap((g) => g.expand?.(path) ?? [])];
|
|
20
|
+
for (const g of graphs) for (const p of paths) merge(g.reach(p));
|
|
21
|
+
return { areas, specs };
|
|
22
|
+
},
|
|
23
|
+
expand: (path) => graphs.flatMap((g) => g.expand?.(path) ?? []),
|
|
24
|
+
evaluates: (path) => graphs.some((g) => g.evaluates?.(path) ?? false),
|
|
25
|
+
evaluationSet: () =>
|
|
26
|
+
[...new Set(graphs.flatMap((g) => g.evaluationSet?.() ?? []))].sort(),
|
|
27
|
+
newlyEvaluatedLoadTime(changed, readBase) {
|
|
28
|
+
const out = new Set();
|
|
29
|
+
for (const g of graphs)
|
|
30
|
+
for (const p of g.newlyEvaluatedLoadTime?.(changed, readBase) ?? [])
|
|
31
|
+
out.add(p);
|
|
32
|
+
return out;
|
|
33
|
+
},
|
|
34
|
+
importersOf: (file) => [
|
|
35
|
+
...new Set(graphs.flatMap((g) => g.importersOf?.(file) ?? [])),
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SelectionConfigError } from './define.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The engine reads no project default: every caller passes its selection config. Reaching this
|
|
5
|
+
* function means a caller omitted it.
|
|
6
|
+
*/
|
|
7
|
+
export const projectSelection = () => {
|
|
8
|
+
throw new SelectionConfigError(
|
|
9
|
+
'no selection config: pass one (define it under `selection` in gate.config.mjs)',
|
|
10
|
+
);
|
|
11
|
+
};
|