staysfixed 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- package/src/watch/panel.js +73 -44
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journeys harvested from the project's own test suite.
|
|
3
|
+
*
|
|
4
|
+
* This is the highest-value source in the tool, and the reason is arithmetic: Terminal Deck
|
|
5
|
+
* has 693 test files that somebody already wrote, that already walk the real paths through
|
|
6
|
+
* the real code, and that are sitting there being used for exactly one thing — saying pass
|
|
7
|
+
* or fail. Run them once with the instrumentation on and you have hundreds of journeys
|
|
8
|
+
* nobody had to write, each one already known to reach real code.
|
|
9
|
+
*
|
|
10
|
+
* THE ASSERTIONS ARE NOT THE POINT. A test that fails still exercised the product, and its
|
|
11
|
+
* journey is still worth keeping — what is harvested is what the test TOUCHED, not what it
|
|
12
|
+
* concluded. A suite that is red today is still a map of the product.
|
|
13
|
+
*
|
|
14
|
+
* HOW "WHAT IT TOUCHED" IS MEASURED. Node writes V8 coverage for a process and its children
|
|
15
|
+
* when `NODE_V8_COVERAGE` is set: every script that executed, and every function inside it
|
|
16
|
+
* that ran. That is exactly the question, answered by the runtime itself, with nothing
|
|
17
|
+
* patched and nothing about the tests changed. Vitest is the exception — its tests run in
|
|
18
|
+
* worker threads whose coverage never reaches that folder, verified here rather than
|
|
19
|
+
* assumed — so vitest is asked for its own V8 coverage report instead, and when the package
|
|
20
|
+
* that produces one is not installed the tool says so and hands over the exact command,
|
|
21
|
+
* rather than quietly harvesting journeys that know nothing about what they touch.
|
|
22
|
+
*
|
|
23
|
+
* WHAT THIS DELIBERATELY DOES NOT DO. It does not patch `fs`, `child_process` or `fetch` to
|
|
24
|
+
* watch effects. Effects are the adapter's job when the journey is WALKED; a harvester that
|
|
25
|
+
* rewrote the runtime under somebody's test suite would be changing the product in order to
|
|
26
|
+
* measure it, and the first strange failure would cost a day of somebody's life.
|
|
27
|
+
*
|
|
28
|
+
* SEQUENTIAL, ALWAYS. One test file at a time, twice each. Two runs of the same suite at
|
|
29
|
+
* once fight over ports, fixtures and temporary folders, and every difference that comes out
|
|
30
|
+
* of that fight is a difference this tool would then report as real.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import fsp from 'node:fs/promises';
|
|
35
|
+
import os from 'node:os';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { spawn } from 'node:child_process';
|
|
38
|
+
|
|
39
|
+
/** @typedef {import('../types.js').Journey} Journey */
|
|
40
|
+
/** @typedef {import('../types.js').JourneyStep} JourneyStep */
|
|
41
|
+
/** @typedef {import('../types.js').Surface} Surface */
|
|
42
|
+
/** @typedef {import('../adapters/contract.js').Missing} Missing */
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A journey harvested from a test file, carrying what that test touched.
|
|
46
|
+
*
|
|
47
|
+
* `touched` is not compared and is not part of the journey's identity. It is what makes the
|
|
48
|
+
* coverage ledger possible: match these files and function names against the doors the code
|
|
49
|
+
* reader found, and "how deep is this really" stops being a claim and becomes a number.
|
|
50
|
+
*
|
|
51
|
+
* `reproducible` is the receipt for the rule that matters most here: a journey that does not
|
|
52
|
+
* do the same thing twice on the same build is rejected at birth rather than admitted and
|
|
53
|
+
* condemned later. Harvesting runs every file twice, so a harvested journey arrives with
|
|
54
|
+
* that check already done, and `index.js` does not pay for it again.
|
|
55
|
+
*
|
|
56
|
+
* @typedef {Journey & {touched?: Touched, reproducible?: {how: string, at: string}}} SuiteJourney
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {object} Touched
|
|
61
|
+
* @property {string[]} files Project files that executed, relative to the root.
|
|
62
|
+
* @property {string[]} functions Named functions that ran, as `file:name`.
|
|
63
|
+
* @property {boolean} measured False when nothing could be measured — see `why`.
|
|
64
|
+
* @property {string} why Plain English, always filled in.
|
|
65
|
+
* @property {number} [ranButNotListed]
|
|
66
|
+
* Functions that ran and were cut from `functions` to keep
|
|
67
|
+
* the list readable. It has to be a number rather than a
|
|
68
|
+
* silent slice, because the coverage ledger reads this list
|
|
69
|
+
* to decide which doors were opened — and a truncated list
|
|
70
|
+
* read as a complete one reports work as still to do when
|
|
71
|
+
* it is already done.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Which runner
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {'vitest'|'node:test'|'none'} Runner
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @typedef {object} RunnerDetection
|
|
84
|
+
* @property {Runner} runner
|
|
85
|
+
* @property {number} confidence 0..1.
|
|
86
|
+
* @property {string} why Plain English, always filled in, including for 'none'.
|
|
87
|
+
* @property {string} [binary] The program that runs one test file.
|
|
88
|
+
* @property {string} [configFile] Relative path to the runner's config, when there is one.
|
|
89
|
+
* @property {Missing[]} missing What would unlock more, with the command to get it.
|
|
90
|
+
* @property {string[]} notes
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/** Folders never worth walking into looking for tests. */
|
|
94
|
+
const SKIP_DIRS = new Set([
|
|
95
|
+
'node_modules', '.git', 'dist', 'build', 'out', 'release', 'coverage', '.next', '.turbo',
|
|
96
|
+
'.staysfixed', '.cache', 'vendor', '__snapshots__', '.venv', 'venv', 'ios', 'android',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What a test file is called, in every convention either runner accepts.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately not "anything inside a folder called test". That rule sweeps up the shared
|
|
103
|
+
* helper every suite has — `test/support.mjs` in this very repository — and a helper is not
|
|
104
|
+
* a journey: running it on its own reports nothing, and it would be rejected two minutes
|
|
105
|
+
* later having cost a process launch to find out.
|
|
106
|
+
*/
|
|
107
|
+
const TEST_FILE = /(^|[./-])(test|spec)\.[cm]?[jt]sx?$|(^|\/)__tests__\/[^/]+\.[cm]?[jt]sx?$/;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Work out which test runner this project uses, and say how sure that is.
|
|
111
|
+
*
|
|
112
|
+
* The `test` script wins over the dependency list, because a project can have vitest
|
|
113
|
+
* installed for one workspace and run `node --test` in another, and the script is what the
|
|
114
|
+
* person actually runs.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} root
|
|
117
|
+
* @returns {Promise<RunnerDetection>}
|
|
118
|
+
*/
|
|
119
|
+
export async function detectRunner(root) {
|
|
120
|
+
/** @type {Record<string, any>} */
|
|
121
|
+
let pkg = {};
|
|
122
|
+
try {
|
|
123
|
+
pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
|
|
124
|
+
} catch {
|
|
125
|
+
return {
|
|
126
|
+
runner: 'none',
|
|
127
|
+
confidence: 1,
|
|
128
|
+
why: 'There is no package.json here, so there is no test suite this tool knows how to run.',
|
|
129
|
+
missing: [],
|
|
130
|
+
notes: [],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const scripts = /** @type {Record<string, string>} */ (pkg.scripts ?? {});
|
|
135
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
136
|
+
const testScript = String(scripts.test ?? '');
|
|
137
|
+
const configFile = await firstExisting(root, [
|
|
138
|
+
'vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs', 'vitest.config.mts',
|
|
139
|
+
'vite.config.ts', 'vite.config.js', 'vite.config.mjs',
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
/** @type {Missing[]} */
|
|
143
|
+
const missing = [];
|
|
144
|
+
/** @type {string[]} */
|
|
145
|
+
const notes = [];
|
|
146
|
+
|
|
147
|
+
const saysVitest = /\bvitest\b/.test(testScript);
|
|
148
|
+
const saysNodeTest = /node\s+--test|--test\b/.test(testScript);
|
|
149
|
+
const hasVitest = 'vitest' in deps;
|
|
150
|
+
|
|
151
|
+
if (saysVitest || (hasVitest && !saysNodeTest)) {
|
|
152
|
+
const binary = await firstExisting(root, [path.join('node_modules', '.bin', 'vitest')]);
|
|
153
|
+
if (!binary) {
|
|
154
|
+
missing.push({
|
|
155
|
+
what: 'vitest, installed in this project',
|
|
156
|
+
unlocks: 'running the suite one file at a time so each one becomes a journey',
|
|
157
|
+
howToGet: 'npm install',
|
|
158
|
+
blocking: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (!('@vitest/coverage-v8' in deps)) {
|
|
162
|
+
// Verified on this machine: with vitest the tests run in worker threads, and Node's
|
|
163
|
+
// own NODE_V8_COVERAGE folder comes back with the runner's own files in it and none
|
|
164
|
+
// of the project's. Vitest's coverage package is the way to see what a test touched.
|
|
165
|
+
missing.push({
|
|
166
|
+
what: '@vitest/coverage-v8',
|
|
167
|
+
unlocks: 'seeing which source files and functions each test file actually exercised, which is what turns a list of tests into a coverage ledger',
|
|
168
|
+
howToGet: 'npm install --save-dev @vitest/coverage-v8',
|
|
169
|
+
});
|
|
170
|
+
notes.push('Without the coverage package the journeys are still harvested; they just do not know what they touched.');
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
runner: 'vitest',
|
|
174
|
+
confidence: saysVitest ? 1 : 0.8,
|
|
175
|
+
why: saysVitest
|
|
176
|
+
? `The test script runs vitest (${testScript}).`
|
|
177
|
+
: 'vitest is installed as a dependency and nothing else claims the test script.',
|
|
178
|
+
binary: binary ?? undefined,
|
|
179
|
+
configFile: configFile ?? undefined,
|
|
180
|
+
missing,
|
|
181
|
+
notes,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (saysNodeTest || (await hasNodeTestFiles(root))) {
|
|
186
|
+
return {
|
|
187
|
+
runner: 'node:test',
|
|
188
|
+
confidence: saysNodeTest ? 1 : 0.7,
|
|
189
|
+
why: saysNodeTest
|
|
190
|
+
? `The test script uses Node's own test runner (${testScript}).`
|
|
191
|
+
: "Test files import node:test, so Node's own runner will run them.",
|
|
192
|
+
binary: process.execPath,
|
|
193
|
+
missing,
|
|
194
|
+
notes,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
runner: 'none',
|
|
200
|
+
confidence: 0.9,
|
|
201
|
+
why: testScript
|
|
202
|
+
? `The test script is "${testScript}", which is not a runner this tool knows how to instrument yet. Only vitest and Node's own test runner are supported.`
|
|
203
|
+
: 'This project has no test script, so there is no suite to harvest journeys from.',
|
|
204
|
+
missing: [
|
|
205
|
+
{
|
|
206
|
+
what: 'a test suite run by vitest or by Node\'s own test runner',
|
|
207
|
+
unlocks: 'journeys nobody has to write — every test file becomes a journey through real code',
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
notes,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {string} root
|
|
216
|
+
* @param {string[]} candidates
|
|
217
|
+
* @returns {Promise<string|null>}
|
|
218
|
+
*/
|
|
219
|
+
async function firstExisting(root, candidates) {
|
|
220
|
+
for (const candidate of candidates) {
|
|
221
|
+
if (fs.existsSync(path.join(root, candidate))) return candidate;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {string} root
|
|
228
|
+
* @returns {Promise<boolean>}
|
|
229
|
+
*/
|
|
230
|
+
async function hasNodeTestFiles(root) {
|
|
231
|
+
const files = await listTestFiles(root, { limit: 20 });
|
|
232
|
+
for (const rel of files.files) {
|
|
233
|
+
try {
|
|
234
|
+
const text = await fsp.readFile(path.join(root, rel), 'utf8');
|
|
235
|
+
if (/from\s+['"]node:test['"]|require\(['"]node:test['"]\)/.test(text)) return true;
|
|
236
|
+
} catch { /* an unreadable file answers nothing */ }
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Which files
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Every test file in the project, in a fixed order.
|
|
247
|
+
*
|
|
248
|
+
* Ordered, because the order journeys come out in ends up as the order they are walked in,
|
|
249
|
+
* and an order that changes between runs is one more thing that looks like a difference.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} root
|
|
252
|
+
* @param {{limit?: number, only?: string[], skipDirs?: Set<string>}} [opts]
|
|
253
|
+
* @returns {Promise<{files: string[], scanned: number, cappedAt?: number}>}
|
|
254
|
+
*/
|
|
255
|
+
export async function listTestFiles(root, opts = {}) {
|
|
256
|
+
const skip = opts.skipDirs ?? SKIP_DIRS;
|
|
257
|
+
/** @type {string[]} */
|
|
258
|
+
const found = [];
|
|
259
|
+
let scanned = 0;
|
|
260
|
+
/** @type {string[]} */
|
|
261
|
+
const stack = [root];
|
|
262
|
+
while (stack.length > 0) {
|
|
263
|
+
const dir = /** @type {string} */ (stack.pop());
|
|
264
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
265
|
+
let entries;
|
|
266
|
+
try {
|
|
267
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
268
|
+
} catch {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
const full = path.join(dir, entry.name);
|
|
273
|
+
if (entry.isDirectory()) {
|
|
274
|
+
if (!skip.has(entry.name) && !entry.name.startsWith('.')) stack.push(full);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!entry.isFile()) continue;
|
|
278
|
+
scanned++;
|
|
279
|
+
const rel = path.relative(root, full).split(path.sep).join('/');
|
|
280
|
+
if (!TEST_FILE.test(rel)) continue;
|
|
281
|
+
if (opts.only && !opts.only.some((pattern) => rel.includes(pattern))) continue;
|
|
282
|
+
found.push(rel);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
found.sort();
|
|
286
|
+
if (opts.limit !== undefined && found.length > opts.limit) {
|
|
287
|
+
return { files: found.slice(0, opts.limit), scanned, cappedAt: opts.limit };
|
|
288
|
+
}
|
|
289
|
+
return { files: found, scanned };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Running one file, watched
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @typedef {object} OneRun
|
|
298
|
+
* @property {string[]} tests Names of the checks that reported, in a fixed order.
|
|
299
|
+
* @property {number} passed
|
|
300
|
+
* @property {number} failed
|
|
301
|
+
* @property {number} exitCode
|
|
302
|
+
* @property {boolean} ran False means the file never got as far as reporting anything.
|
|
303
|
+
* @property {Touched} touched
|
|
304
|
+
* @property {number} durationMs
|
|
305
|
+
* @property {string} [trouble] One plain sentence when something went wrong.
|
|
306
|
+
*/
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The command that runs exactly one test file. Kept in one place because the harvested
|
|
310
|
+
* journey carries it, and whatever walks the journey later has to run the same thing —
|
|
311
|
+
* a journey that is walked differently from the way it was harvested is not the same journey.
|
|
312
|
+
*
|
|
313
|
+
* @param {Runner} runner
|
|
314
|
+
* @param {string} file Relative to the root.
|
|
315
|
+
* @param {{binary?: string, root: string, coverageDir?: string, resultFile?: string}} opts
|
|
316
|
+
* @returns {{command: string, argv: string[]}}
|
|
317
|
+
*/
|
|
318
|
+
export function runnerCommand(runner, file, opts) {
|
|
319
|
+
if (runner === 'vitest') {
|
|
320
|
+
const command = opts.binary ? path.resolve(opts.root, opts.binary) : 'npx';
|
|
321
|
+
const argv = opts.binary ? [] : ['vitest'];
|
|
322
|
+
argv.push('run', '--pool=forks', '--no-file-parallelism', '--reporter=json');
|
|
323
|
+
if (opts.resultFile) argv.push(`--outputFile=${opts.resultFile}`);
|
|
324
|
+
if (opts.coverageDir) {
|
|
325
|
+
argv.push(
|
|
326
|
+
'--coverage',
|
|
327
|
+
'--coverage.provider=v8',
|
|
328
|
+
'--coverage.reporter=json',
|
|
329
|
+
'--coverage.all=false',
|
|
330
|
+
`--coverage.reportsDirectory=${opts.coverageDir}`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
argv.push(file);
|
|
334
|
+
return { command, argv };
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
command: opts.binary ?? process.execPath,
|
|
338
|
+
argv: ['--test', '--test-reporter=tap', file],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Run one test file and watch what it touches.
|
|
344
|
+
*
|
|
345
|
+
* Never throws. A test file that hangs, crashes or refuses to start comes back as a run
|
|
346
|
+
* that did not happen, with the reason in plain English, because one bad file must not cost
|
|
347
|
+
* the other six hundred.
|
|
348
|
+
*
|
|
349
|
+
* @param {object} opts
|
|
350
|
+
* @param {string} opts.root
|
|
351
|
+
* @param {string} opts.file Relative to the root.
|
|
352
|
+
* @param {Runner} opts.runner
|
|
353
|
+
* @param {string} opts.scratchDir Somewhere to write coverage and reports. Not the project.
|
|
354
|
+
* @param {string} [opts.binary]
|
|
355
|
+
* @param {boolean} [opts.coverage] Default true.
|
|
356
|
+
* @param {number} [opts.timeoutMs] Default two minutes.
|
|
357
|
+
* @param {AbortSignal} [opts.signal]
|
|
358
|
+
* @returns {Promise<OneRun>}
|
|
359
|
+
*/
|
|
360
|
+
export async function runOneFile(opts) {
|
|
361
|
+
const started = Date.now();
|
|
362
|
+
const stamp = `${path.basename(opts.file).replace(/[^a-zA-Z0-9]+/g, '-')}-${started}-${Math.random().toString(36).slice(2, 8)}`;
|
|
363
|
+
const coverageDir = path.join(opts.scratchDir, `cov-${stamp}`);
|
|
364
|
+
const resultFile = path.join(opts.scratchDir, `result-${stamp}.json`);
|
|
365
|
+
await fsp.mkdir(coverageDir, { recursive: true });
|
|
366
|
+
|
|
367
|
+
const wantCoverage = opts.coverage !== false;
|
|
368
|
+
const { command, argv } = runnerCommand(opts.runner, opts.file, {
|
|
369
|
+
binary: opts.binary,
|
|
370
|
+
root: opts.root,
|
|
371
|
+
coverageDir: wantCoverage ? coverageDir : undefined,
|
|
372
|
+
resultFile: opts.runner === 'vitest' ? resultFile : undefined,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
376
|
+
const env = { ...process.env, STAYSFIXED_HARVEST: '1' };
|
|
377
|
+
// Node's own runner writes coverage for the process and every child it spawns, which is
|
|
378
|
+
// exactly how a test file's own run gets measured. Vitest is asked for its own instead.
|
|
379
|
+
if (wantCoverage && opts.runner === 'node:test') env.NODE_V8_COVERAGE = coverageDir;
|
|
380
|
+
else delete env.NODE_V8_COVERAGE;
|
|
381
|
+
|
|
382
|
+
const result = await runToEnd(command, argv, {
|
|
383
|
+
cwd: opts.root,
|
|
384
|
+
env,
|
|
385
|
+
timeoutMs: opts.timeoutMs ?? 120_000,
|
|
386
|
+
signal: opts.signal,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
/** @type {OneRun} */
|
|
390
|
+
const run = {
|
|
391
|
+
tests: [],
|
|
392
|
+
passed: 0,
|
|
393
|
+
failed: 0,
|
|
394
|
+
exitCode: result.code,
|
|
395
|
+
ran: false,
|
|
396
|
+
touched: { files: [], functions: [], measured: false, why: 'Nothing was measured.' },
|
|
397
|
+
durationMs: Date.now() - started,
|
|
398
|
+
};
|
|
399
|
+
if (result.trouble) run.trouble = result.trouble;
|
|
400
|
+
|
|
401
|
+
if (opts.runner === 'vitest') {
|
|
402
|
+
const parsed = await readVitestResults(resultFile);
|
|
403
|
+
run.tests = parsed.tests;
|
|
404
|
+
run.passed = parsed.passed;
|
|
405
|
+
run.failed = parsed.failed;
|
|
406
|
+
run.ran = parsed.tests.length > 0;
|
|
407
|
+
run.touched = wantCoverage
|
|
408
|
+
? await readVitestCoverage(path.join(coverageDir, 'coverage-final.json'), opts.root)
|
|
409
|
+
: { files: [], functions: [], measured: false, why: 'Coverage was switched off for this run.' };
|
|
410
|
+
} else {
|
|
411
|
+
const parsed = parseTap(result.stdout);
|
|
412
|
+
run.tests = parsed.tests;
|
|
413
|
+
run.passed = parsed.passed;
|
|
414
|
+
run.failed = parsed.failed;
|
|
415
|
+
run.ran = parsed.tests.length > 0;
|
|
416
|
+
run.touched = wantCoverage
|
|
417
|
+
? await readNodeCoverage(coverageDir, opts.root)
|
|
418
|
+
: { files: [], functions: [], measured: false, why: 'Coverage was switched off for this run.' };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (!run.ran && !run.trouble) {
|
|
422
|
+
run.trouble =
|
|
423
|
+
result.code === 0
|
|
424
|
+
? 'It finished without reporting a single check, so there is nothing here to walk.'
|
|
425
|
+
: `It stopped with exit code ${result.code} before reporting anything.`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
await fsp.rm(coverageDir, { recursive: true, force: true }).catch(() => {});
|
|
429
|
+
await fsp.rm(resultFile, { force: true }).catch(() => {});
|
|
430
|
+
return run;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Run a command to the end and keep what it said. Kills the whole process group on a
|
|
435
|
+
* timeout, because a test runner that hangs usually has children hanging with it.
|
|
436
|
+
*
|
|
437
|
+
* @param {string} command
|
|
438
|
+
* @param {string[]} argv
|
|
439
|
+
* @param {{cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number, signal?: AbortSignal}} opts
|
|
440
|
+
* @returns {Promise<{code: number, stdout: string, stderr: string, trouble?: string}>}
|
|
441
|
+
*/
|
|
442
|
+
function runToEnd(command, argv, opts) {
|
|
443
|
+
return new Promise((resolve) => {
|
|
444
|
+
/** @type {import('node:child_process').ChildProcess} */
|
|
445
|
+
let child;
|
|
446
|
+
try {
|
|
447
|
+
child = spawn(command, argv, {
|
|
448
|
+
cwd: opts.cwd,
|
|
449
|
+
env: opts.env,
|
|
450
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
451
|
+
detached: process.platform !== 'win32',
|
|
452
|
+
});
|
|
453
|
+
} catch (error) {
|
|
454
|
+
resolve({ code: -1, stdout: '', stderr: '', trouble: `It could not be started: ${String(error)}` });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let stdout = '';
|
|
459
|
+
let stderr = '';
|
|
460
|
+
let settled = false;
|
|
461
|
+
/** @type {string|undefined} */
|
|
462
|
+
let trouble;
|
|
463
|
+
|
|
464
|
+
const stop = () => {
|
|
465
|
+
if (!child.pid) return;
|
|
466
|
+
try {
|
|
467
|
+
if (process.platform === 'win32') child.kill('SIGKILL');
|
|
468
|
+
else process.kill(-child.pid, 'SIGKILL');
|
|
469
|
+
} catch { /* it had already gone */ }
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const timer = setTimeout(() => {
|
|
473
|
+
trouble = `It was still running after ${Math.round(opts.timeoutMs / 1000)} seconds, so it was stopped.`;
|
|
474
|
+
stop();
|
|
475
|
+
}, opts.timeoutMs);
|
|
476
|
+
|
|
477
|
+
const onAbort = () => {
|
|
478
|
+
trouble = 'The harvest was stopped before this file finished.';
|
|
479
|
+
stop();
|
|
480
|
+
};
|
|
481
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
482
|
+
|
|
483
|
+
child.stdout?.on('data', (chunk) => { if (stdout.length < 4_000_000) stdout += chunk; });
|
|
484
|
+
child.stderr?.on('data', (chunk) => { if (stderr.length < 1_000_000) stderr += chunk; });
|
|
485
|
+
|
|
486
|
+
/** @param {number} code */
|
|
487
|
+
const done = (code) => {
|
|
488
|
+
if (settled) return;
|
|
489
|
+
settled = true;
|
|
490
|
+
clearTimeout(timer);
|
|
491
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
492
|
+
resolve({ code, stdout, stderr, trouble });
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
child.on('error', (error) => {
|
|
496
|
+
trouble = `It could not be started: ${error.message}`;
|
|
497
|
+
done(-1);
|
|
498
|
+
});
|
|
499
|
+
child.on('close', (code) => done(code ?? -1));
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// Reading what came back
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Pull the check names out of TAP.
|
|
509
|
+
*
|
|
510
|
+
* Names rather than counts, because two runs producing thirteen checks each is not the same
|
|
511
|
+
* evidence as two runs producing the same thirteen checks.
|
|
512
|
+
*
|
|
513
|
+
* @param {string} output
|
|
514
|
+
* @returns {{tests: string[], passed: number, failed: number}}
|
|
515
|
+
*/
|
|
516
|
+
export function parseTap(output) {
|
|
517
|
+
/** @type {string[]} */
|
|
518
|
+
const tests = [];
|
|
519
|
+
let passed = 0;
|
|
520
|
+
let failed = 0;
|
|
521
|
+
for (const line of output.split('\n')) {
|
|
522
|
+
const match = /^\s*(not )?ok\s+\d+\s*-?\s*(.*)$/.exec(line);
|
|
523
|
+
if (!match) continue;
|
|
524
|
+
const name = match[2].replace(/\s*#\s*(SKIP|TODO).*$/i, '').trim();
|
|
525
|
+
if (name === '') continue;
|
|
526
|
+
tests.push(name);
|
|
527
|
+
if (match[1]) failed++;
|
|
528
|
+
else passed++;
|
|
529
|
+
}
|
|
530
|
+
tests.sort();
|
|
531
|
+
return { tests, passed, failed };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* @param {string} file
|
|
536
|
+
* @returns {Promise<{tests: string[], passed: number, failed: number}>}
|
|
537
|
+
*/
|
|
538
|
+
async function readVitestResults(file) {
|
|
539
|
+
try {
|
|
540
|
+
const report = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
541
|
+
/** @type {string[]} */
|
|
542
|
+
const tests = [];
|
|
543
|
+
let passed = 0;
|
|
544
|
+
let failed = 0;
|
|
545
|
+
for (const suite of report.testResults ?? []) {
|
|
546
|
+
for (const assertion of suite.assertionResults ?? []) {
|
|
547
|
+
const name = [assertion.ancestorTitles?.join(' > '), assertion.title].filter(Boolean).join(' > ');
|
|
548
|
+
tests.push(name);
|
|
549
|
+
if (assertion.status === 'passed') passed++;
|
|
550
|
+
else if (assertion.status === 'failed') failed++;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
tests.sort();
|
|
554
|
+
return { tests, passed, failed };
|
|
555
|
+
} catch {
|
|
556
|
+
return { tests: [], passed: 0, failed: 0 };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* How many functions from one file are worth writing down. A journey that lists nine
|
|
562
|
+
* hundred function names is not evidence, it is a wall, and nobody reads a wall.
|
|
563
|
+
*/
|
|
564
|
+
export const TOUCHED_FUNCTION_LIMIT = 400;
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* What a run of Node's own test runner touched, out of the raw V8 coverage it wrote.
|
|
568
|
+
*
|
|
569
|
+
* @param {string} dir Folder NODE_V8_COVERAGE was pointed at.
|
|
570
|
+
* @param {string} root Project root, so paths come back relative and readable.
|
|
571
|
+
* @returns {Promise<Touched>}
|
|
572
|
+
*/
|
|
573
|
+
export async function readNodeCoverage(dir, root) {
|
|
574
|
+
/** @type {Set<string>} */
|
|
575
|
+
const files = new Set();
|
|
576
|
+
/** @type {Set<string>} */
|
|
577
|
+
const functions = new Set();
|
|
578
|
+
/** @type {string[]} */
|
|
579
|
+
let entries = [];
|
|
580
|
+
try {
|
|
581
|
+
entries = await fsp.readdir(dir);
|
|
582
|
+
} catch {
|
|
583
|
+
return { files: [], functions: [], measured: false, why: 'Node wrote no coverage folder, so what the tests touched is not known.' };
|
|
584
|
+
}
|
|
585
|
+
for (const entry of entries) {
|
|
586
|
+
if (!entry.endsWith('.json')) continue;
|
|
587
|
+
/** @type {any} */
|
|
588
|
+
let report;
|
|
589
|
+
try {
|
|
590
|
+
report = JSON.parse(await fsp.readFile(path.join(dir, entry), 'utf8'));
|
|
591
|
+
} catch {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
for (const script of report.result ?? []) {
|
|
595
|
+
const rel = relativeIfInside(script.url, root);
|
|
596
|
+
if (!rel) continue;
|
|
597
|
+
files.add(rel);
|
|
598
|
+
for (const fn of script.functions ?? []) {
|
|
599
|
+
if (!fn.functionName) continue;
|
|
600
|
+
const ran = (fn.ranges ?? []).some((/** @type {any} */ range) => range.count > 0);
|
|
601
|
+
if (ran) functions.add(`${rel}:${fn.functionName}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (files.size === 0) {
|
|
606
|
+
return {
|
|
607
|
+
files: [],
|
|
608
|
+
functions: [],
|
|
609
|
+
measured: false,
|
|
610
|
+
why: 'Coverage came back with none of the project\'s own files in it, so what the tests touched is not known.',
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
return keptFunctions(files, functions, `Node reported ${files.size} of the project's own files executing.`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The function list, cut to something readable, saying out loud how much was cut.
|
|
618
|
+
*
|
|
619
|
+
* The cut itself is old: a journey listing nine hundred function names is a wall and nobody
|
|
620
|
+
* reads a wall. Announcing it is not. The coverage ledger matches this list against the
|
|
621
|
+
* doors the code reader found, so a list quietly missing three hundred entries makes the
|
|
622
|
+
* ledger ask for work that has already been done — which is the same failure as a green run
|
|
623
|
+
* that means less than it looks like, pointed the other way.
|
|
624
|
+
*
|
|
625
|
+
* @param {Set<string>} files
|
|
626
|
+
* @param {Set<string>} functions
|
|
627
|
+
* @param {string} why
|
|
628
|
+
* @returns {Touched}
|
|
629
|
+
*/
|
|
630
|
+
function keptFunctions(files, functions, why) {
|
|
631
|
+
const all = [...functions].sort();
|
|
632
|
+
const kept = all.slice(0, TOUCHED_FUNCTION_LIMIT);
|
|
633
|
+
/** @type {Touched} */
|
|
634
|
+
const touched = { files: [...files].sort(), functions: kept, measured: true, why };
|
|
635
|
+
if (all.length > kept.length) {
|
|
636
|
+
touched.ranButNotListed = all.length - kept.length;
|
|
637
|
+
touched.why = `${why} ${all.length} named functions ran and the ${kept.length} listed here are the first of them in alphabetical order, so ${touched.ranButNotListed} that really did run are not named. Anything reading this list to work out what was covered will undercount by that much.`;
|
|
638
|
+
}
|
|
639
|
+
return touched;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The same question, answered by vitest's own coverage report.
|
|
644
|
+
*
|
|
645
|
+
* @param {string} file coverage-final.json
|
|
646
|
+
* @param {string} root
|
|
647
|
+
* @returns {Promise<Touched>}
|
|
648
|
+
*/
|
|
649
|
+
export async function readVitestCoverage(file, root) {
|
|
650
|
+
/** @type {any} */
|
|
651
|
+
let report;
|
|
652
|
+
try {
|
|
653
|
+
report = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
654
|
+
} catch {
|
|
655
|
+
return {
|
|
656
|
+
files: [],
|
|
657
|
+
functions: [],
|
|
658
|
+
measured: false,
|
|
659
|
+
why: 'Vitest wrote no coverage report. Its tests run in worker threads, so this needs @vitest/coverage-v8 installed — without it the journey is still good, it just does not know what it touched.',
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
/** @type {Set<string>} */
|
|
663
|
+
const files = new Set();
|
|
664
|
+
/** @type {Set<string>} */
|
|
665
|
+
const functions = new Set();
|
|
666
|
+
for (const [absolute, entry] of Object.entries(/** @type {Record<string, any>} */ (report))) {
|
|
667
|
+
const rel = relativeIfInside(absolute, root);
|
|
668
|
+
if (!rel) continue;
|
|
669
|
+
files.add(rel);
|
|
670
|
+
for (const [id, meta] of Object.entries(/** @type {Record<string, any>} */ (entry.fnMap ?? {}))) {
|
|
671
|
+
const hits = entry.f?.[id] ?? 0;
|
|
672
|
+
if (hits > 0 && meta?.name) functions.add(`${rel}:${meta.name}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (files.size === 0) {
|
|
676
|
+
return { files: [], functions: [], measured: false, why: 'The coverage report named none of the project\'s own files.' };
|
|
677
|
+
}
|
|
678
|
+
return keptFunctions(files, functions, `Vitest reported ${files.size} of the project's own files executing.`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* A path inside the project, relative and readable — or null for anything outside it, which
|
|
683
|
+
* is node's own internals and everybody's dependencies.
|
|
684
|
+
*
|
|
685
|
+
* @param {string} url A file URL or an absolute path.
|
|
686
|
+
* @param {string} root
|
|
687
|
+
* @returns {string|null}
|
|
688
|
+
*/
|
|
689
|
+
export function relativeIfInside(url, root) {
|
|
690
|
+
let absolute = String(url);
|
|
691
|
+
if (absolute.startsWith('file://')) {
|
|
692
|
+
try {
|
|
693
|
+
absolute = new URL(absolute).pathname;
|
|
694
|
+
} catch {
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (!path.isAbsolute(absolute)) return null;
|
|
699
|
+
const rel = path.relative(root, absolute).split(path.sep).join('/');
|
|
700
|
+
if (rel === '' || rel.startsWith('..')) return null;
|
|
701
|
+
if (rel.includes('node_modules/')) return null;
|
|
702
|
+
// Node reports a script it was handed on the command line as `[eval1]`, resolved against
|
|
703
|
+
// the working folder, so it arrives looking exactly like a file in the project. It is not
|
|
704
|
+
// one, and a file that does not exist cannot be a door anything walked through.
|
|
705
|
+
if (!/\.[cm]?[jt]sx?$/.test(rel)) return null;
|
|
706
|
+
return rel;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// The harvest
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* @typedef {object} HarvestOptions
|
|
715
|
+
* @property {string} root
|
|
716
|
+
* @property {Runner} [runner] Detected when not given.
|
|
717
|
+
* @property {string} [binary]
|
|
718
|
+
* @property {Surface} [surface] Default 'library'.
|
|
719
|
+
* @property {string[]} [files] Exact files, relative to the root. Overrides listing.
|
|
720
|
+
* @property {string[]} [only] Substrings a test file's path must contain.
|
|
721
|
+
* @property {number} [limit] Stop after this many files. Coverage says so out loud.
|
|
722
|
+
* @property {1|2} [repeat] Runs per file. Two is the default and it is the point:
|
|
723
|
+
* a journey that does not reproduce twice on the same
|
|
724
|
+
* build is rejected at birth rather than admitted and
|
|
725
|
+
* condemned later.
|
|
726
|
+
* @property {boolean} [coverage] Measure what each file touched. Default true.
|
|
727
|
+
* @property {number} [timeoutMs]
|
|
728
|
+
* @property {string} [scratchDir] Somewhere to write. A temp folder by default. NEVER
|
|
729
|
+
* the project.
|
|
730
|
+
* @property {(message: string) => void} [log]
|
|
731
|
+
* @property {AbortSignal} [signal]
|
|
732
|
+
* @property {boolean} [dryRun] List what would be run and run nothing.
|
|
733
|
+
*/
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* @typedef {object} HarvestReport
|
|
737
|
+
* @property {Runner} runner
|
|
738
|
+
* @property {string} why Why that runner, in plain English.
|
|
739
|
+
* @property {number} testFilesFound
|
|
740
|
+
* @property {number} testFilesRun
|
|
741
|
+
* @property {number} journeys
|
|
742
|
+
* @property {number} checks Individual checks the harvested files contain.
|
|
743
|
+
* @property {number} touchedFiles Distinct project files the suite reached.
|
|
744
|
+
* @property {boolean} touchedMeasured False when nothing could see what was touched.
|
|
745
|
+
* @property {{file: string, why: string}[]} rejected
|
|
746
|
+
* Files that produced no journey, and why. This is
|
|
747
|
+
* missing coverage, not a pass.
|
|
748
|
+
* @property {{file: string, failed: number}[]} failing
|
|
749
|
+
* Files whose checks did not all pass. Kept anyway —
|
|
750
|
+
* what a test exercises is useful even when it is red.
|
|
751
|
+
* @property {Missing[]} missing
|
|
752
|
+
* @property {string[]} notes
|
|
753
|
+
* @property {number} durationMs
|
|
754
|
+
*/
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Harvest journeys out of a project's own test suite.
|
|
758
|
+
*
|
|
759
|
+
* @param {HarvestOptions} opts
|
|
760
|
+
* @returns {Promise<{journeys: SuiteJourney[], report: HarvestReport}>}
|
|
761
|
+
*/
|
|
762
|
+
export async function harvestJourneys(opts) {
|
|
763
|
+
const started = Date.now();
|
|
764
|
+
const root = path.resolve(opts.root);
|
|
765
|
+
const log = opts.log ?? (() => {});
|
|
766
|
+
const detection = opts.runner
|
|
767
|
+
? { runner: opts.runner, why: 'The runner was named by the caller.', binary: opts.binary, missing: [], notes: [] }
|
|
768
|
+
: await detectRunner(root);
|
|
769
|
+
const runner = /** @type {Runner} */ (detection.runner);
|
|
770
|
+
|
|
771
|
+
/** @type {HarvestReport} */
|
|
772
|
+
const report = {
|
|
773
|
+
runner,
|
|
774
|
+
why: detection.why,
|
|
775
|
+
testFilesFound: 0,
|
|
776
|
+
testFilesRun: 0,
|
|
777
|
+
journeys: 0,
|
|
778
|
+
checks: 0,
|
|
779
|
+
touchedFiles: 0,
|
|
780
|
+
touchedMeasured: false,
|
|
781
|
+
rejected: [],
|
|
782
|
+
failing: [],
|
|
783
|
+
missing: [...(detection.missing ?? [])],
|
|
784
|
+
notes: [...(detection.notes ?? [])],
|
|
785
|
+
durationMs: 0,
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
if (runner === 'none') {
|
|
789
|
+
report.durationMs = Date.now() - started;
|
|
790
|
+
return { journeys: [], report };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const listed = opts.files
|
|
794
|
+
? { files: opts.files, scanned: opts.files.length, cappedAt: undefined }
|
|
795
|
+
: await listTestFiles(root, { limit: opts.limit, only: opts.only });
|
|
796
|
+
report.testFilesFound = listed.files.length;
|
|
797
|
+
if (listed.cappedAt !== undefined) {
|
|
798
|
+
report.notes.push(
|
|
799
|
+
`Only the first ${listed.cappedAt} test files were harvested. The rest of the suite is not covered by these journeys.`,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (opts.dryRun) {
|
|
804
|
+
report.notes.push('This was a dry run: the files were listed and none of them were run.');
|
|
805
|
+
report.durationMs = Date.now() - started;
|
|
806
|
+
return { journeys: [], report };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const scratchDir = opts.scratchDir ?? (await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-harvest-')));
|
|
810
|
+
await fsp.mkdir(scratchDir, { recursive: true });
|
|
811
|
+
const repeat = opts.repeat ?? 2;
|
|
812
|
+
|
|
813
|
+
/** @type {SuiteJourney[]} */
|
|
814
|
+
const journeys = [];
|
|
815
|
+
/** @type {Set<string>} */
|
|
816
|
+
const touchedEverything = new Set();
|
|
817
|
+
|
|
818
|
+
for (const file of listed.files) {
|
|
819
|
+
if (opts.signal?.aborted) {
|
|
820
|
+
report.rejected.push({ file, why: 'The harvest was stopped before this file was reached.' });
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
log(`Running ${file}${repeat > 1 ? ' (twice, to see whether it repeats)' : ''}.`);
|
|
824
|
+
|
|
825
|
+
/** @type {OneRun[]} */
|
|
826
|
+
const runs = [];
|
|
827
|
+
for (let i = 0; i < repeat; i++) {
|
|
828
|
+
// Sequential on purpose. Two runs of the same test file at the same time share
|
|
829
|
+
// ports, fixtures and temporary folders, and every difference that comes out of
|
|
830
|
+
// that fight is one this tool would go on to report as real.
|
|
831
|
+
runs.push(
|
|
832
|
+
await runOneFile({
|
|
833
|
+
root,
|
|
834
|
+
file,
|
|
835
|
+
runner,
|
|
836
|
+
scratchDir,
|
|
837
|
+
binary: detection.binary,
|
|
838
|
+
coverage: opts.coverage,
|
|
839
|
+
timeoutMs: opts.timeoutMs,
|
|
840
|
+
signal: opts.signal,
|
|
841
|
+
}),
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
report.testFilesRun++;
|
|
845
|
+
|
|
846
|
+
const first = runs[0];
|
|
847
|
+
if (!first.ran) {
|
|
848
|
+
report.rejected.push({ file, why: first.trouble ?? 'It reported nothing at all.' });
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
const disagreement = repeat > 1 ? disagree(runs[0], runs[1]) : null;
|
|
852
|
+
if (disagreement) {
|
|
853
|
+
report.rejected.push({ file, why: disagreement });
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const touched = first.touched.measured ? first.touched : runs[runs.length - 1].touched;
|
|
858
|
+
for (const touchedFile of touched.files) touchedEverything.add(touchedFile);
|
|
859
|
+
if (first.failed > 0) report.failing.push({ file, failed: first.failed });
|
|
860
|
+
report.checks += first.tests.length;
|
|
861
|
+
|
|
862
|
+
journeys.push(journeyForTestFile({
|
|
863
|
+
root,
|
|
864
|
+
file,
|
|
865
|
+
runner,
|
|
866
|
+
surface: opts.surface ?? 'library',
|
|
867
|
+
binary: detection.binary,
|
|
868
|
+
run: first,
|
|
869
|
+
repeated: repeat > 1,
|
|
870
|
+
timeoutMs: opts.timeoutMs,
|
|
871
|
+
}));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (!opts.scratchDir) await fsp.rm(scratchDir, { recursive: true, force: true }).catch(() => {});
|
|
875
|
+
|
|
876
|
+
report.journeys = journeys.length;
|
|
877
|
+
report.touchedFiles = touchedEverything.size;
|
|
878
|
+
report.touchedMeasured = journeys.some((j) => j.touched?.measured === true);
|
|
879
|
+
if (!report.touchedMeasured && journeys.length > 0) {
|
|
880
|
+
report.notes.push(
|
|
881
|
+
'These journeys do not know which files they touch, so they cannot say which of the doors in the code they open.',
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
report.durationMs = Date.now() - started;
|
|
885
|
+
return { journeys, report };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Did the same test file do the same thing twice?
|
|
890
|
+
*
|
|
891
|
+
* The comparison is on WHAT WAS EXERCISED — the checks that reported and the files that
|
|
892
|
+
* executed — and never on how long anything took or how many times a line ran. A test that
|
|
893
|
+
* reports different checks, or reaches different code, on two runs of identical bytes is
|
|
894
|
+
* not a journey: whatever it later says about a change, it was already saying about nothing.
|
|
895
|
+
*
|
|
896
|
+
* @param {OneRun} a
|
|
897
|
+
* @param {OneRun} b
|
|
898
|
+
* @returns {string|null} the reason it is rejected, or null when it repeats
|
|
899
|
+
*/
|
|
900
|
+
export function disagree(a, b) {
|
|
901
|
+
if (!b.ran) return 'It reported checks the first time and nothing the second, so it does not repeat.';
|
|
902
|
+
const missingNames = onlyIn(a.tests, b.tests);
|
|
903
|
+
const extraNames = onlyIn(b.tests, a.tests);
|
|
904
|
+
if (missingNames.length > 0 || extraNames.length > 0) {
|
|
905
|
+
const example = missingNames[0] ?? extraNames[0];
|
|
906
|
+
return `Two runs of the same code reported different checks (for instance "${example}"), so this file does not repeat.`;
|
|
907
|
+
}
|
|
908
|
+
if (a.touched.measured && b.touched.measured) {
|
|
909
|
+
const missingFiles = onlyIn(a.touched.files, b.touched.files);
|
|
910
|
+
const extraFiles = onlyIn(b.touched.files, a.touched.files);
|
|
911
|
+
if (missingFiles.length > 0 || extraFiles.length > 0) {
|
|
912
|
+
const example = missingFiles[0] ?? extraFiles[0];
|
|
913
|
+
return `Two runs of the same code went through different files (for instance ${example}), so this file does not repeat.`;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* @param {string[]} a
|
|
921
|
+
* @param {string[]} b
|
|
922
|
+
* @returns {string[]}
|
|
923
|
+
*/
|
|
924
|
+
function onlyIn(a, b) {
|
|
925
|
+
const other = new Set(b);
|
|
926
|
+
return a.filter((item) => !other.has(item));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* One test file, as a journey.
|
|
931
|
+
*
|
|
932
|
+
* @param {object} spec
|
|
933
|
+
* @param {string} spec.root
|
|
934
|
+
* @param {string} spec.file
|
|
935
|
+
* @param {Runner} spec.runner
|
|
936
|
+
* @param {Surface} spec.surface
|
|
937
|
+
* @param {string} [spec.binary]
|
|
938
|
+
* @param {OneRun} spec.run
|
|
939
|
+
* @param {boolean} spec.repeated
|
|
940
|
+
* @param {number} [spec.timeoutMs]
|
|
941
|
+
* @returns {SuiteJourney}
|
|
942
|
+
*/
|
|
943
|
+
export function journeyForTestFile(spec) {
|
|
944
|
+
const { command, argv } = runnerCommand(spec.runner, spec.file, { binary: spec.binary, root: spec.root });
|
|
945
|
+
const count = spec.run.tests.length;
|
|
946
|
+
/** @type {JourneyStep} */
|
|
947
|
+
const step = {
|
|
948
|
+
act: 'run-tests',
|
|
949
|
+
runner: spec.runner,
|
|
950
|
+
file: spec.file,
|
|
951
|
+
command,
|
|
952
|
+
argv,
|
|
953
|
+
tests: spec.run.tests,
|
|
954
|
+
note: 'Run this exactly as it was harvested. A test file run a different way is a different journey.',
|
|
955
|
+
};
|
|
956
|
+
/** @type {SuiteJourney} */
|
|
957
|
+
const journey = {
|
|
958
|
+
name: `suite-${slugPath(spec.file)}`,
|
|
959
|
+
describe: `run the ${count} ${count === 1 ? 'check' : 'checks'} in ${spec.file} and watch what they touch`,
|
|
960
|
+
source: 'suite',
|
|
961
|
+
surface: spec.surface,
|
|
962
|
+
from: spec.file,
|
|
963
|
+
channels: ['results', 'complaints', 'counters'],
|
|
964
|
+
steps: [step],
|
|
965
|
+
timeoutMs: spec.timeoutMs,
|
|
966
|
+
touched: spec.run.touched,
|
|
967
|
+
};
|
|
968
|
+
if (spec.repeated) {
|
|
969
|
+
journey.reproducible = {
|
|
970
|
+
how: 'It was run twice while it was harvested, and both runs reported the same checks and went through the same files.',
|
|
971
|
+
at: new Date().toISOString(),
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
return journey;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* A file path as a journey name: readable, file-safe, and unique to that path.
|
|
979
|
+
* @param {string} file
|
|
980
|
+
* @returns {string}
|
|
981
|
+
*/
|
|
982
|
+
export function slugPath(file) {
|
|
983
|
+
return String(file)
|
|
984
|
+
.replace(/\.[cm]?[jt]sx?$/, '')
|
|
985
|
+
.toLowerCase()
|
|
986
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
987
|
+
.replace(/^-+|-+$/g, '');
|
|
988
|
+
}
|