staysfixed 0.7.2 → 0.8.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/CHANGELOG.md +342 -0
- package/README.md +191 -55
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +18 -5
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +549 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +23 -3
- package/src/guard/run.js +49 -1
- package/src/report/console.js +15 -2
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +11 -2
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +728 -66
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +94 -24
- package/src/v2/adapters/web.js +142 -9
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +9 -1
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +530 -66
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +42 -24
- package/src/v2/cluster.js +164 -13
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +285 -45
- package/src/v2/init.js +162 -61
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +10 -11
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +40 -10
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +29 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- package/src/v2/watch/panel.js +167 -17
|
@@ -36,13 +36,16 @@
|
|
|
36
36
|
import fs from 'node:fs';
|
|
37
37
|
import fsp from 'node:fs/promises';
|
|
38
38
|
import path from 'node:path';
|
|
39
|
-
import os from 'node:os';
|
|
40
39
|
import crypto from 'node:crypto';
|
|
41
40
|
import { spawn } from 'node:child_process';
|
|
42
41
|
import {
|
|
43
42
|
defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
|
|
44
43
|
trimForStorage, undoOurFootprint,
|
|
45
44
|
} from './contract.js';
|
|
45
|
+
// The harvest writes the test-file steps this adapter walks, and it owns reading a runner's
|
|
46
|
+
// output back. One place on purpose: a journey read differently from the way it was
|
|
47
|
+
// harvested is not the same journey.
|
|
48
|
+
import { quietenRunnerOutput, readChecks } from '../journeys/from-suite.js';
|
|
46
49
|
|
|
47
50
|
// ---------------------------------------------------------------------------
|
|
48
51
|
// The environment every run gets
|
|
@@ -150,16 +153,30 @@ export function watcherScript(opts) {
|
|
|
150
153
|
"} catch { /* no child_process, nothing to watch */ }",
|
|
151
154
|
"",
|
|
152
155
|
"// --- what it tried to reach ----------------------------------------------",
|
|
153
|
-
"const loopback = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0'
|
|
156
|
+
"const loopback = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0']);",
|
|
154
157
|
"try {",
|
|
155
158
|
" const net = require('node:net');",
|
|
156
159
|
" const connect = net.Socket.prototype.connect;",
|
|
157
160
|
" net.Socket.prototype.connect = function (...args) {",
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
"
|
|
161
|
+
" // Node normalises the arguments before they ever reach here, so what arrives is",
|
|
162
|
+
" // usually the ARRAY [options, callback] and not the port and host somebody typed.",
|
|
163
|
+
" // Reading `.host` off that array gives undefined, and an empty host used to mean",
|
|
164
|
+
" // 'nowhere named, therefore this machine' - so every fetch, every http.get and every",
|
|
165
|
+
" // net.connect walked straight out through a boundary that then reported nothing at",
|
|
166
|
+
" // all. Measured on 2026-08-30: all three got a 200 back from the open internet and",
|
|
167
|
+
" // the watcher's report was empty. Unwrap it first, and treat a shape nobody",
|
|
168
|
+
" // recognises as somewhere to refuse rather than somewhere to allow, because a",
|
|
169
|
+
" // boundary that fails open is not a boundary.",
|
|
170
|
+
" const given = Array.isArray(args[0]) ? args[0][0] : args[0];",
|
|
171
|
+
" const options = typeof given === 'object' && given !== null ? given : null;",
|
|
172
|
+
" const host = options ? String(options.host ?? '') : String(args[1] ?? '');",
|
|
173
|
+
" const port = options ? options.port : given;",
|
|
174
|
+
" const readable = options !== null || typeof given === 'number' || typeof given === 'string';",
|
|
175
|
+
" // A socket file is on this machine by definition, and a port with no host beside it",
|
|
176
|
+
" // is the one case where 'nowhere named' really does mean here.",
|
|
177
|
+
" const local = Boolean(options && options.path) || (readable && (host === '' || loopback.has(host)));",
|
|
162
178
|
" if (local && settings.allowLoopback) return connect.apply(this, args);",
|
|
179
|
+
|
|
163
180
|
" write('reached out', { host: host || 'somewhere it did not name', port: port ?? null });",
|
|
164
181
|
" // Refused, not allowed through. Whatever this was going to do out there, it does not",
|
|
165
182
|
" // do it twice, and the run is reported as having a hole rather than as having passed.",
|
|
@@ -177,9 +194,27 @@ export function watcherScript(opts) {
|
|
|
177
194
|
"const settingsRead = new Set();",
|
|
178
195
|
"try {",
|
|
179
196
|
" const real = process.env;",
|
|
197
|
+
" const note = (key) => { if (typeof key === 'string') settingsRead.add(key); };",
|
|
198
|
+
" // EVERY trap forwards, and every trap that changes something forwards with the TARGET",
|
|
199
|
+
" // as the receiver. A proxy carrying only the traps we happen to care about is not a",
|
|
200
|
+
" // window, it is a wall with a window in it. With no `set` trap, `process.env.X = 'y'`",
|
|
201
|
+
" // takes the default, which reflects onto the PROXY, which lands on defineProperty",
|
|
202
|
+
" // against Node's own env object and quietly does nothing at all. npm sets",
|
|
203
|
+
" // npm_lifecycle_event and its npm_config_ family that way, reads them back, finds",
|
|
204
|
+
" // nothing and exits 1 without printing one word - so every product whose start command",
|
|
205
|
+
" // went through npm died here, and the report said, in good faith, that the product",
|
|
206
|
+
" // would not boot. `staysfixed init` writes `npm run start` by default, so this was the",
|
|
207
|
+
" // default path. Found by installing the published copy and pointing it at an ordinary",
|
|
208
|
+
" // Express app.",
|
|
180
209
|
" const watched = new Proxy(real, {",
|
|
181
|
-
" get(target, key) {
|
|
182
|
-
" has(target, key) {
|
|
210
|
+
" get(target, key) { note(key); return Reflect.get(target, key); },",
|
|
211
|
+
" has(target, key) { note(key); return Reflect.has(target, key); },",
|
|
212
|
+
" set(target, key, value) { return Reflect.set(target, key, value); },",
|
|
213
|
+
" deleteProperty(target, key) { return Reflect.deleteProperty(target, key); },",
|
|
214
|
+
" ownKeys(target) { return Reflect.ownKeys(target); },",
|
|
215
|
+
" getOwnPropertyDescriptor(target, key) { return Reflect.getOwnPropertyDescriptor(target, key); },",
|
|
216
|
+
" defineProperty(target, key, descriptor) { return Reflect.defineProperty(target, key, descriptor); },",
|
|
217
|
+
" getPrototypeOf(target) { return Reflect.getPrototypeOf(target); },",
|
|
183
218
|
" });",
|
|
184
219
|
" Object.defineProperty(process, 'env', { value: watched, configurable: true, writable: true });",
|
|
185
220
|
"} catch { /* some hosts freeze this; the other channels still work */ }",
|
|
@@ -197,6 +232,7 @@ export function watcherScript(opts) {
|
|
|
197
232
|
* @property {Map<string, number>} ran Command as written, and how many times.
|
|
198
233
|
* @property {Array<{host: string, port: number|null}>} reachedOut
|
|
199
234
|
* @property {string[]} settingsRead
|
|
235
|
+
* @property {number} torn Lines of the report that could not be read back.
|
|
200
236
|
*/
|
|
201
237
|
|
|
202
238
|
/**
|
|
@@ -207,7 +243,7 @@ export function watcherScript(opts) {
|
|
|
207
243
|
*/
|
|
208
244
|
export async function readWatcher(reportFile) {
|
|
209
245
|
/** @type {WatchedEvents} */
|
|
210
|
-
const seen = { inForce: false, ran: new Map(), reachedOut: [], settingsRead: [] };
|
|
246
|
+
const seen = { inForce: false, ran: new Map(), reachedOut: [], settingsRead: [], torn: 0 };
|
|
211
247
|
let text;
|
|
212
248
|
try {
|
|
213
249
|
text = await fsp.readFile(reportFile, 'utf8');
|
|
@@ -218,7 +254,9 @@ export async function readWatcher(reportFile) {
|
|
|
218
254
|
for (const line of text.split('\n')) {
|
|
219
255
|
if (line.trim() === '') continue;
|
|
220
256
|
let event;
|
|
221
|
-
|
|
257
|
+
// A half-written line is a program that started or a host that was reached and is now
|
|
258
|
+
// reported as neither. Counted, so the run can say it saw less than it saw.
|
|
259
|
+
try { event = JSON.parse(line); } catch { seen.torn += 1; continue; }
|
|
222
260
|
if (event.kind === 'ran') {
|
|
223
261
|
const command = String(event.what?.command ?? '');
|
|
224
262
|
seen.ran.set(command, (seen.ran.get(command) ?? 0) + 1);
|
|
@@ -243,25 +281,111 @@ export async function readWatcher(reportFile) {
|
|
|
243
281
|
|
|
244
282
|
/** @typedef {Map<string, string>} TreeSnapshot relative path -> fingerprint of its contents */
|
|
245
283
|
|
|
246
|
-
/**
|
|
247
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Folders left out of a snapshot: enormous, and not what anybody means by "it wrote a file".
|
|
286
|
+
*
|
|
287
|
+
* `node_modules` is the one that had to be argued out rather than assumed. A build step that
|
|
288
|
+
* writes in there — a patch, a generated client, a native rebuild — is a real change to what
|
|
289
|
+
* ships, and leaving it out means that change is invisible. It stays out anyway, because
|
|
290
|
+
* fingerprinting thirty thousand files twice per journey per build turns a check that takes
|
|
291
|
+
* seconds into one that takes minutes, and a check nobody runs catches nothing.
|
|
292
|
+
*
|
|
293
|
+
* What is NOT acceptable is skipping it quietly. Every run says which folders it did not
|
|
294
|
+
* watch, as missing coverage rather than as a clean result, and `process.alsoWatch` takes any
|
|
295
|
+
* of these back off the list for a project that needs it. See `snapshotSkip`.
|
|
296
|
+
*/
|
|
297
|
+
export const SNAPSHOT_SKIP = new Set(['node_modules', '.git', '.staysfixed']);
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The folders this run will not watch, after the project has had its say.
|
|
301
|
+
*
|
|
302
|
+
* @param {Record<string, unknown>|undefined} config The `process` section of the settings.
|
|
303
|
+
* @returns {Set<string>}
|
|
304
|
+
*/
|
|
305
|
+
export function snapshotSkip(config) {
|
|
306
|
+
const skip = new Set(SNAPSHOT_SKIP);
|
|
307
|
+
const alsoWatch = Array.isArray(config?.alsoWatch) ? config.alsoWatch : [];
|
|
308
|
+
for (const name of alsoWatch) skip.delete(String(name));
|
|
309
|
+
return skip;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A file recorded by its size because hashing it would have meant reading past the ceiling. */
|
|
313
|
+
export const BY_SIZE_ALONE = 'compared by size alone, ';
|
|
314
|
+
|
|
315
|
+
/** A file or folder nothing could read. Kept in the snapshot so it is never a silence. */
|
|
316
|
+
export const COULD_NOT_READ = 'could not be looked at: ';
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Why a path would not open, in words rather than in an errno.
|
|
320
|
+
* @param {unknown} error
|
|
321
|
+
* @returns {string}
|
|
322
|
+
*/
|
|
323
|
+
function whyItWouldNotOpen(error) {
|
|
324
|
+
const code = String(/** @type {{code?: unknown}} */ (error)?.code ?? '');
|
|
325
|
+
if (code === 'EACCES' || code === 'EPERM') return 'no permission to read it';
|
|
326
|
+
if (code === 'EIO') return 'the disk would not answer';
|
|
327
|
+
if (code === 'ELOOP') return 'the symlinks point at each other';
|
|
328
|
+
if (code === 'EMFILE' || code === 'ENFILE') return 'this machine ran out of open files';
|
|
329
|
+
if (code === 'ENAMETOOLONG') return 'the name is longer than this machine allows';
|
|
330
|
+
if (code !== '') return code;
|
|
331
|
+
return error instanceof Error ? error.message : String(error);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** A path that is simply not there any more was not there to begin with. */
|
|
335
|
+
const isGone = (/** @type {unknown} */ error) => {
|
|
336
|
+
const code = String(/** @type {{code?: unknown}} */ (error)?.code ?? '');
|
|
337
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Fingerprint one file that is bigger than the read-it-all-at-once limit.
|
|
342
|
+
*
|
|
343
|
+
* Streamed rather than read into memory, so the size of the file is the machine's problem and
|
|
344
|
+
* not this process's. There is still a ceiling, because a file measured in tens of gigabytes
|
|
345
|
+
* would be read twice per journey per build and nobody would wait for it — and above that
|
|
346
|
+
* ceiling the answer is the size bucket, with the marker that says so, so the run can report
|
|
347
|
+
* it as a hole instead of as a match.
|
|
348
|
+
*
|
|
349
|
+
* @param {string} file
|
|
350
|
+
* @param {number} size
|
|
351
|
+
* @param {number} ceilingBytes
|
|
352
|
+
* @returns {Promise<string>}
|
|
353
|
+
*/
|
|
354
|
+
async function fingerprintBigFile(file, size, ceilingBytes) {
|
|
355
|
+
if (size > ceilingBytes) return `${BY_SIZE_ALONE}${sizeBucket(size)}`;
|
|
356
|
+
const hash = crypto.createHash('sha256');
|
|
357
|
+
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
|
|
358
|
+
return hash.digest('hex').slice(0, 16);
|
|
359
|
+
}
|
|
248
360
|
|
|
249
361
|
/**
|
|
250
362
|
* Fingerprint every file under a folder.
|
|
251
363
|
*
|
|
252
364
|
* By CONTENTS, never by timestamp or size. A run that rewrites a file with the same bytes
|
|
253
365
|
* has not changed anything, and reporting it as a change is how a tool teaches people to
|
|
254
|
-
* ignore it.
|
|
255
|
-
*
|
|
366
|
+
* ignore it.
|
|
367
|
+
*
|
|
368
|
+
* A big file is streamed rather than bucketed. It used to be recorded as "too big to
|
|
369
|
+
* fingerprint, tens of megabytes", which meant a build that wrote a COMPLETELY DIFFERENT
|
|
370
|
+
* forty-megabyte bundle compared equal to the old one as long as the size landed in the same
|
|
371
|
+
* bucket — the exact file a bundler rewrites, silently passing. Reading a large file is cheap
|
|
372
|
+
* next to running the whole product twice, so it is read.
|
|
373
|
+
*
|
|
374
|
+
* Anything that cannot be read at all — a folder with no permission on it, a disk that will
|
|
375
|
+
* not answer — goes into the snapshot as its own entry rather than being dropped. Dropping a
|
|
376
|
+
* folder takes everything under it with it, and a file nobody looked at cannot be seen
|
|
377
|
+
* changing; the run reports those as holes.
|
|
256
378
|
*
|
|
257
379
|
* @param {string} root
|
|
258
380
|
* @param {object} [opts]
|
|
259
|
-
* @param {number} [opts.maxBytes]
|
|
381
|
+
* @param {number} [opts.maxBytes] Read files up to this size in one go. Default 8MB.
|
|
382
|
+
* @param {number} [opts.ceilingBytes] Above this, record the size instead of hashing. Default 4GB.
|
|
260
383
|
* @param {Set<string>} [opts.skip]
|
|
261
384
|
* @returns {Promise<TreeSnapshot>}
|
|
262
385
|
*/
|
|
263
386
|
export async function snapshotTree(root, opts = {}) {
|
|
264
387
|
const maxBytes = opts.maxBytes ?? 8 * 1024 * 1024;
|
|
388
|
+
const ceilingBytes = opts.ceilingBytes ?? 4 * 1024 * 1024 * 1024;
|
|
265
389
|
const skip = opts.skip ?? SNAPSHOT_SKIP;
|
|
266
390
|
/** @type {TreeSnapshot} */
|
|
267
391
|
const snapshot = new Map();
|
|
@@ -270,7 +394,19 @@ export async function snapshotTree(root, opts = {}) {
|
|
|
270
394
|
const walk = async (dir) => {
|
|
271
395
|
/** @type {import('node:fs').Dirent[]} */
|
|
272
396
|
let entries;
|
|
273
|
-
try {
|
|
397
|
+
try {
|
|
398
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
399
|
+
} catch (error) {
|
|
400
|
+
// A folder that will not open used to be dropped here without a word, and everything
|
|
401
|
+
// under it with it — so a file inside it could be created, rewritten or deleted and the
|
|
402
|
+
// run would report the folder as unchanged. It goes in the snapshot instead, and
|
|
403
|
+
// `describeRun` reports it as a hole.
|
|
404
|
+
if (!isGone(error)) {
|
|
405
|
+
const at = path.relative(root, dir);
|
|
406
|
+
snapshot.set(at === '' ? '.' : at, `${COULD_NOT_READ}${whyItWouldNotOpen(error)}`);
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
274
410
|
for (const entry of entries) {
|
|
275
411
|
const full = path.join(dir, entry.name);
|
|
276
412
|
if (entry.isDirectory()) {
|
|
@@ -285,12 +421,15 @@ export async function snapshotTree(root, opts = {}) {
|
|
|
285
421
|
if (!entry.isFile()) continue;
|
|
286
422
|
try {
|
|
287
423
|
const stat = await fsp.stat(full);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
424
|
+
snapshot.set(relative, stat.size > maxBytes
|
|
425
|
+
? await fingerprintBigFile(full, stat.size, ceilingBytes)
|
|
426
|
+
: crypto.createHash('sha256').update(await fsp.readFile(full)).digest('hex').slice(0, 16));
|
|
427
|
+
} catch (error) {
|
|
428
|
+
// A file that vanished between the listing and the read was not there to begin with.
|
|
429
|
+
// Anything else is a file nobody looked at, and staying quiet about it reads exactly
|
|
430
|
+
// like "it did not change".
|
|
431
|
+
if (!isGone(error)) snapshot.set(relative, `${COULD_NOT_READ}${whyItWouldNotOpen(error)}`);
|
|
432
|
+
}
|
|
294
433
|
}
|
|
295
434
|
};
|
|
296
435
|
|
|
@@ -337,6 +476,10 @@ export function compareTrees(before, after) {
|
|
|
337
476
|
* @property {string|null} signal Set when it was killed rather than finishing.
|
|
338
477
|
* @property {boolean} timedOut
|
|
339
478
|
* @property {number} ms
|
|
479
|
+
* @property {string} [couldNotStart] Why the command never ran at all. A missing exit code
|
|
480
|
+
* means two different things without this — killed, or
|
|
481
|
+
* never started — and the second one compares equal on
|
|
482
|
+
* both builds, which reads exactly like a clean run.
|
|
340
483
|
*/
|
|
341
484
|
|
|
342
485
|
/**
|
|
@@ -377,6 +520,9 @@ export function runCommand(command, opts) {
|
|
|
377
520
|
if (opts.stdin !== undefined) child.stdin?.end(opts.stdin);
|
|
378
521
|
else child.stdin?.end();
|
|
379
522
|
|
|
523
|
+
/** @type {string|undefined} */
|
|
524
|
+
let couldNotStart;
|
|
525
|
+
|
|
380
526
|
const finish = (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
|
|
381
527
|
if (settled) return;
|
|
382
528
|
settled = true;
|
|
@@ -390,6 +536,7 @@ export function runCommand(command, opts) {
|
|
|
390
536
|
signal,
|
|
391
537
|
timedOut,
|
|
392
538
|
ms: Date.now() - started,
|
|
539
|
+
...(couldNotStart ? { couldNotStart } : {}),
|
|
393
540
|
});
|
|
394
541
|
};
|
|
395
542
|
|
|
@@ -405,6 +552,10 @@ export function runCommand(command, opts) {
|
|
|
405
552
|
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
406
553
|
|
|
407
554
|
child.on('error', (error) => {
|
|
555
|
+
// Nothing ran. Said out loud rather than folded into "exit code null", which is what a
|
|
556
|
+
// killed run also looks like — and which is identical on both builds, so the comparison
|
|
557
|
+
// saw no difference and the run passed for the worst possible reason.
|
|
558
|
+
couldNotStart = error.message;
|
|
408
559
|
err.push(Buffer.from(`${error.message}\n`));
|
|
409
560
|
finish(null, null);
|
|
410
561
|
});
|
|
@@ -416,41 +567,161 @@ export function runCommand(command, opts) {
|
|
|
416
567
|
// Making the scratch copy
|
|
417
568
|
// ---------------------------------------------------------------------------
|
|
418
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Folders not worth copying into a scratch build.
|
|
572
|
+
*
|
|
573
|
+
* The bar for this list is deliberately high: anything skipped that turns out to matter
|
|
574
|
+
* produces a run that passes for the wrong reason, and a false pass is the one failure
|
|
575
|
+
* this whole tool exists to prevent. So it holds only things that are *regenerated on
|
|
576
|
+
* demand and read by nothing* — caches and coverage reports — plus the two that are ours
|
|
577
|
+
* and git's. Build output, `node_modules`, lockfiles, fixtures and configuration are all
|
|
578
|
+
* copied, because a check that runs against a different set of files than the real
|
|
579
|
+
* product is not checking the real product.
|
|
580
|
+
*/
|
|
581
|
+
export const SKIP_BY_DEFAULT = [
|
|
582
|
+
'.git',
|
|
583
|
+
'.staysfixed',
|
|
584
|
+
'.turbo',
|
|
585
|
+
'.nyc_output',
|
|
586
|
+
'coverage',
|
|
587
|
+
'.pytest_cache',
|
|
588
|
+
'__pycache__',
|
|
589
|
+
'.DS_Store',
|
|
590
|
+
];
|
|
591
|
+
|
|
419
592
|
/**
|
|
420
593
|
* Copy a project into a scratch folder so a run can write whatever it likes.
|
|
421
594
|
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
595
|
+
* ## Why this is a clone and not a copy
|
|
596
|
+
*
|
|
597
|
+
* The real projects this gets pointed at are enormous — the one it was built against is
|
|
598
|
+
* twelve gigabytes, most of it an iOS build folder. Copying that byte by byte before every
|
|
599
|
+
* single run would take minutes and fill a disk, and a check nobody can afford to run is
|
|
600
|
+
* a check nobody runs.
|
|
601
|
+
*
|
|
602
|
+
* So it asks the filesystem to *clone* instead: on macOS that is `cp -c`, one APFS call
|
|
603
|
+
* per file that copies no bytes at all and shares the blocks until something writes to
|
|
604
|
+
* them; on Linux it is `cp --reflink=auto`, which does the same where the filesystem
|
|
605
|
+
* supports it and a real copy where it does not. Measured on the twelve-gigabyte project:
|
|
606
|
+
* the six-hundred-megabyte `node_modules` alone went from a long wait to under three
|
|
607
|
+
* seconds, and used no extra disk.
|
|
608
|
+
*
|
|
609
|
+
* Never a symlink and never a hardlink. Both of those point back at the real project,
|
|
610
|
+
* which is the one thing this function exists to protect — the first thing a broken build
|
|
611
|
+
* does is write to a file, and with a link that write lands in his actual working tree.
|
|
612
|
+
*
|
|
613
|
+
* It falls back to a plain recursive copy whenever the clone is unavailable or fails, so
|
|
614
|
+
* a filesystem without reflinks is slower here and never wrong.
|
|
427
615
|
*
|
|
428
616
|
* @param {string} from
|
|
429
617
|
* @param {string} to
|
|
430
618
|
* @param {object} [opts]
|
|
431
|
-
* @param {string[]} [opts.skip]
|
|
432
|
-
*
|
|
433
|
-
* @
|
|
619
|
+
* @param {string[]} [opts.skip] Names not to copy. See `SKIP_BY_DEFAULT`.
|
|
620
|
+
* @param {string[]} [opts.also] Extra names to skip, on top of the defaults.
|
|
621
|
+
* @param {AbortSignal} [opts.signal]
|
|
622
|
+
* @returns {Promise<{copied: boolean, why: string, cloned: boolean, tookMs: number, skipped: string[]}>}
|
|
434
623
|
*/
|
|
435
624
|
export async function copyForScratch(from, to, opts = {}) {
|
|
436
|
-
const
|
|
625
|
+
const began = Date.now();
|
|
626
|
+
const skip = new Set([...(opts.skip ?? SKIP_BY_DEFAULT), ...(opts.also ?? [])]);
|
|
437
627
|
await fsp.mkdir(to, { recursive: true });
|
|
628
|
+
|
|
629
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
630
|
+
let entries;
|
|
438
631
|
try {
|
|
439
|
-
await fsp.
|
|
440
|
-
recursive: true,
|
|
441
|
-
force: true,
|
|
442
|
-
dereference: false,
|
|
443
|
-
preserveTimestamps: true,
|
|
444
|
-
filter: (source) => {
|
|
445
|
-
const name = path.basename(source);
|
|
446
|
-
if (skip.has(name)) return false;
|
|
447
|
-
return true;
|
|
448
|
-
},
|
|
449
|
-
});
|
|
450
|
-
return { copied: true, why: `Copied the project into a scratch folder, so the run can write anywhere it likes without touching the real one.` };
|
|
632
|
+
entries = await fsp.readdir(from, { withFileTypes: true });
|
|
451
633
|
} catch (error) {
|
|
452
|
-
return {
|
|
634
|
+
return {
|
|
635
|
+
copied: false, cloned: false, tookMs: Date.now() - began, skipped: [],
|
|
636
|
+
why: `The project could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
637
|
+
};
|
|
453
638
|
}
|
|
639
|
+
|
|
640
|
+
const skipped = entries.filter((e) => skip.has(e.name)).map((e) => e.name);
|
|
641
|
+
const wanted = entries.filter((e) => !skip.has(e.name));
|
|
642
|
+
|
|
643
|
+
let cloned = 0;
|
|
644
|
+
let copied = 0;
|
|
645
|
+
for (const entry of wanted) {
|
|
646
|
+
const source = path.join(from, entry.name);
|
|
647
|
+
const target = path.join(to, entry.name);
|
|
648
|
+
if (await cloneOne(source, target, opts.signal)) {
|
|
649
|
+
cloned += 1;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
await fsp.cp(source, target, {
|
|
654
|
+
recursive: true,
|
|
655
|
+
force: true,
|
|
656
|
+
dereference: false,
|
|
657
|
+
preserveTimestamps: true,
|
|
658
|
+
filter: (p) => !skip.has(path.basename(p)),
|
|
659
|
+
});
|
|
660
|
+
copied += 1;
|
|
661
|
+
} catch (error) {
|
|
662
|
+
return {
|
|
663
|
+
copied: false, cloned: cloned > 0, tookMs: Date.now() - began, skipped,
|
|
664
|
+
why: `The project could not be copied into a scratch folder: ${error instanceof Error ? error.message : String(error)}`,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const tookMs = Date.now() - began;
|
|
670
|
+
const how = cloned > 0 && copied === 0
|
|
671
|
+
? 'The project was cloned into a scratch folder — the filesystem shared the blocks, so no bytes moved'
|
|
672
|
+
: cloned > 0
|
|
673
|
+
? 'The project was cloned into a scratch folder where the filesystem allowed it and copied where it did not'
|
|
674
|
+
: 'The project was copied into a scratch folder';
|
|
675
|
+
const left = skipped.length ? ` Left behind: ${skipped.join(', ')}.` : '';
|
|
676
|
+
return {
|
|
677
|
+
copied: true,
|
|
678
|
+
cloned: cloned > 0,
|
|
679
|
+
tookMs,
|
|
680
|
+
skipped,
|
|
681
|
+
why: `${how} (${(tookMs / 1000).toFixed(1)}s), so the run can write anywhere it likes without touching the real one.${left}`,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Ask the filesystem to clone one entry. False means "it would not", not "it broke".
|
|
687
|
+
*
|
|
688
|
+
* @param {string} source
|
|
689
|
+
* @param {string} target
|
|
690
|
+
* @param {AbortSignal} [signal]
|
|
691
|
+
* @returns {Promise<boolean>}
|
|
692
|
+
*/
|
|
693
|
+
function cloneOne(source, target, signal) {
|
|
694
|
+
// Windows has no reflink through `cp`, and there is no `cp`. Straight to the fallback.
|
|
695
|
+
if (process.platform === 'win32') return Promise.resolve(false);
|
|
696
|
+
const args = process.platform === 'darwin'
|
|
697
|
+
? ['-Rc', source, target]
|
|
698
|
+
: ['-a', '--reflink=auto', source, target];
|
|
699
|
+
return new Promise((resolve) => {
|
|
700
|
+
let settled = false;
|
|
701
|
+
/** @param {boolean} ok */
|
|
702
|
+
const done = (ok) => {
|
|
703
|
+
if (settled) return;
|
|
704
|
+
settled = true;
|
|
705
|
+
resolve(ok);
|
|
706
|
+
};
|
|
707
|
+
let child;
|
|
708
|
+
try {
|
|
709
|
+
child = spawn('cp', args, { stdio: 'ignore', signal });
|
|
710
|
+
} catch {
|
|
711
|
+
done(false);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
child.on('error', () => done(false));
|
|
715
|
+
child.on('close', (code) => {
|
|
716
|
+
if (code === 0) {
|
|
717
|
+
done(true);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// A half-written target from a failed clone would make the fallback copy merge into
|
|
721
|
+
// it. Clear it out so the fallback starts from nothing.
|
|
722
|
+
fsp.rm(target, { recursive: true, force: true }).then(() => done(false), () => done(false));
|
|
723
|
+
});
|
|
724
|
+
});
|
|
454
725
|
}
|
|
455
726
|
|
|
456
727
|
// ---------------------------------------------------------------------------
|
|
@@ -471,6 +742,73 @@ export async function copyForScratch(from, to, opts = {}) {
|
|
|
471
742
|
* @property {string} module A path inside the project, or a package entry name.
|
|
472
743
|
*/
|
|
473
744
|
|
|
745
|
+
/**
|
|
746
|
+
* The command a run line actually opens, named the way the code reader names it.
|
|
747
|
+
*
|
|
748
|
+
* These two lists only ever meet here. A command door is read out of package.json and comes
|
|
749
|
+
* out as either `staysfixed` (something the package installs) or `npm run build` (a script),
|
|
750
|
+
* while a journey is named by whoever wrote the settings — "build the app". So a journey
|
|
751
|
+
* carrying nothing but its own name matched no door at all.
|
|
752
|
+
*
|
|
753
|
+
* `pnpm run build` opens the same door as `npm run build`, because the door is the script in
|
|
754
|
+
* package.json and the package manager standing in front of it is not a second door.
|
|
755
|
+
*
|
|
756
|
+
* A line this cannot read plainly — a pipeline, a shell one-liner, anything with an operator
|
|
757
|
+
* in it — gets null instead of a guess. Missing a walked command leaves a job on the queue;
|
|
758
|
+
* naming the wrong one marks a door walked that nobody touched, and that is the single
|
|
759
|
+
* direction the coverage ledger is never allowed to be wrong in.
|
|
760
|
+
*
|
|
761
|
+
* @param {string} run The command line, exactly as the settings wrote it.
|
|
762
|
+
* @returns {string|null}
|
|
763
|
+
*/
|
|
764
|
+
export function commandDoorName(run) {
|
|
765
|
+
const line = String(run ?? '').trim();
|
|
766
|
+
if (line === '') return null;
|
|
767
|
+
// An operator means the line runs more than one thing, and this cannot say which of them
|
|
768
|
+
// the door is.
|
|
769
|
+
if (/[|;&<>`$]/.test(line)) return null;
|
|
770
|
+
const words = line.split(/\s+/);
|
|
771
|
+
// FOO=bar in front of a command is the environment it runs in, not the command.
|
|
772
|
+
while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
|
|
773
|
+
// A runner in front fetches or finds the real command and then runs it; the door is what
|
|
774
|
+
// comes after it.
|
|
775
|
+
while (words.length > 1 && (/^(npx|bunx)$/.test(words[0]) || (/^(pnpm|yarn|bun|npm)$/.test(words[0]) && /^(exec|dlx)$/.test(words[1])))) {
|
|
776
|
+
words.splice(0, words[0] === 'npx' || words[0] === 'bunx' ? 1 : 2);
|
|
777
|
+
}
|
|
778
|
+
// Flags to the runner itself — `npx --yes staysfixed` — belong to the runner.
|
|
779
|
+
while (words.length > 1 && words[0].startsWith('-')) words.shift();
|
|
780
|
+
if (words.length === 0) return null;
|
|
781
|
+
const program = path.basename(words[0]);
|
|
782
|
+
const rest = words.slice(1).filter((w) => !w.startsWith('-'));
|
|
783
|
+
if (/^(npm|pnpm|yarn|bun)$/.test(program)) {
|
|
784
|
+
if (words[1] === 'run' && rest[1]) return `npm run ${rest[1]}`;
|
|
785
|
+
// npm's own shorthands for four scripts. `yarn <anything>` is deliberately not read this
|
|
786
|
+
// way: with yarn a bare word may be a script or a command, and this cannot tell.
|
|
787
|
+
if (program !== 'yarn' && rest[0] && /^(test|start|stop|restart)$/.test(rest[0])) return `npm run ${rest[0]}`;
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
// An interpreter with something after it is running that something, and the door is whatever
|
|
791
|
+
// that file installs as — which this cannot know. Null, rather than reporting a door called
|
|
792
|
+
// "node" that the code reader never found.
|
|
793
|
+
if (words.length > 1 && /^(node|deno|sh|bash|zsh|dash|env|python|python3|ruby|perl)$/.test(program)) return null;
|
|
794
|
+
return program;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* The door fields a command journey's step carries, or nothing when the command line is not
|
|
799
|
+
* plain enough to name one honestly. `door` in the settings overrides the reading, which is
|
|
800
|
+
* the way out for a project whose command line this cannot make sense of.
|
|
801
|
+
*
|
|
802
|
+
* @param {Record<string, unknown>} entry
|
|
803
|
+
* @returns {{door: string, kind: 'command'}|{}}
|
|
804
|
+
*/
|
|
805
|
+
function doorFields(entry) {
|
|
806
|
+
const named = typeof entry.door === 'string' && entry.door.trim() !== ''
|
|
807
|
+
? entry.door.trim()
|
|
808
|
+
: commandDoorName(String(entry.run ?? ''));
|
|
809
|
+
return named ? { door: named, kind: /** @type {const} */ ('command') } : {};
|
|
810
|
+
}
|
|
811
|
+
|
|
474
812
|
/** Everything a prepared build needs to remember between journeys. */
|
|
475
813
|
const prepared = new Map();
|
|
476
814
|
|
|
@@ -481,7 +819,7 @@ export const processAdapter = defineAdapter({
|
|
|
481
819
|
name: 'process',
|
|
482
820
|
title: 'CLI tools and libraries',
|
|
483
821
|
describe:
|
|
484
|
-
'Runs a command,
|
|
822
|
+
'Runs a command, imports a module, or walks one of the project\'s own test files, in a scratch copy of the project — and reports what it printed, what it exited with, every file it created or changed, every program it started, every outbound connection it tried — all of which are refused — and roughly how long it took. A test file also reports each of its checks by name and why any failing one failed, so a check that goes red on the new build alone names itself. Outbound calls and started programs are only visible when the thing being run is Node; for anything else those two channels are reported as not checked rather than as clean.',
|
|
485
823
|
channels: ['results', 'complaints', 'effects', 'counters'],
|
|
486
824
|
|
|
487
825
|
/** @param {import('./contract.js').AdapterProject} project */
|
|
@@ -535,7 +873,17 @@ export const processAdapter = defineAdapter({
|
|
|
535
873
|
surface: 'cli',
|
|
536
874
|
from: 'the project config',
|
|
537
875
|
channels: ['results', 'complaints', 'effects', 'counters'],
|
|
538
|
-
|
|
876
|
+
// `door` and `kind` are how the coverage ledger learns this journey ran that command.
|
|
877
|
+
// Without them a command counted as walked only if an observation landed at its own
|
|
878
|
+
// address, and this adapter writes everything under `cli.<journey name>` — so
|
|
879
|
+
// {"name": "build the app", "run": "npm run build"} produced `cli.build the app.*`
|
|
880
|
+
// against a door addressed `cli.npm run build`, and every command in the project read
|
|
881
|
+
// as never walked on a run that had just walked all of them. The address rule cannot
|
|
882
|
+
// rescue this one: it is switched off for commands on purpose.
|
|
883
|
+
steps: [{
|
|
884
|
+
act: 'run', run: String(entry.run), cwd: entry.cwd, stdin: entry.stdin, env: entry.env,
|
|
885
|
+
...doorFields(entry),
|
|
886
|
+
}],
|
|
539
887
|
irreversible: entry.irreversible === true,
|
|
540
888
|
timeoutMs: entry.timeoutMs,
|
|
541
889
|
});
|
|
@@ -548,6 +896,11 @@ export const processAdapter = defineAdapter({
|
|
|
548
896
|
surface: 'library',
|
|
549
897
|
from: 'the project config',
|
|
550
898
|
channels: ['results', 'complaints', 'effects', 'counters'],
|
|
899
|
+
// No `door` here on purpose, and it was checked rather than assumed: `apiSurface`
|
|
900
|
+
// writes every exported name at `export.<journey name>.<name>`, which is exactly the
|
|
901
|
+
// branch the ledger already reads exports through, so these doors open on their own.
|
|
902
|
+
// Naming the module as the door instead would claim a door of that name that the code
|
|
903
|
+
// reader never found.
|
|
551
904
|
steps: [{ act: 'import', module: String(entry.module) }],
|
|
552
905
|
timeoutMs: entry.timeoutMs,
|
|
553
906
|
});
|
|
@@ -567,7 +920,17 @@ export const processAdapter = defineAdapter({
|
|
|
567
920
|
await fsp.mkdir(home, { recursive: true });
|
|
568
921
|
await fsp.mkdir(tmp, { recursive: true });
|
|
569
922
|
|
|
570
|
-
|
|
923
|
+
// A project may name more to leave behind — a giant build folder no command reads,
|
|
924
|
+
// say. It can only ADD to the defaults: a setting that could switch off `.git` being
|
|
925
|
+
// skipped would only ever make runs slower.
|
|
926
|
+
const alsoSkip = Array.isArray(ctx.config?.skip) ? ctx.config.skip.map(String) : [];
|
|
927
|
+
const copy = await copyForScratch(build.root, work, { also: alsoSkip, signal: ctx.signal });
|
|
928
|
+
if (copy.copied && copy.tookMs > 20_000) {
|
|
929
|
+
ctx.log?.(
|
|
930
|
+
`Making a scratch copy of this project took ${Math.round(copy.tookMs / 1000)} seconds. ` +
|
|
931
|
+
`If a large folder here is not read by any command, name it under "process.skip" in the config and it will be left behind.`,
|
|
932
|
+
);
|
|
933
|
+
}
|
|
571
934
|
if (!copy.copied) {
|
|
572
935
|
return {
|
|
573
936
|
build, root: work, ready: false, why: copy.why,
|
|
@@ -636,15 +999,23 @@ export const processAdapter = defineAdapter({
|
|
|
636
999
|
extra: {
|
|
637
1000
|
...step.env,
|
|
638
1001
|
// `--import` is how a module gets to run before anything else does. It is appended
|
|
639
|
-
// rather than assigned so a project that needs its own options keeps them
|
|
640
|
-
|
|
1002
|
+
// rather than assigned so a project that needs its own options keeps them — and the
|
|
1003
|
+
// journey's own NODE_OPTIONS is the one that has to survive, which it did not: this
|
|
1004
|
+
// line spread `step.env` and then overwrote it with the OUTER machine's value, so a
|
|
1005
|
+
// journey that asked for `--max-old-space-size` got the comment's promise and none of
|
|
1006
|
+
// its behaviour.
|
|
1007
|
+
NODE_OPTIONS: `${step.env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS ?? ''} --import ${pathToUrl(places.watcher)}`.trim(),
|
|
641
1008
|
},
|
|
642
1009
|
});
|
|
643
1010
|
|
|
644
1011
|
// A step that names nothing to run must say so. Handing `undefined` to a shell runs a
|
|
645
1012
|
// command called "undefined", which fails identically on both builds and therefore
|
|
646
1013
|
// reports NO difference - a silent nothing that looks exactly like a clean check.
|
|
647
|
-
const nothingToRun = step.act === 'import'
|
|
1014
|
+
const nothingToRun = step.act === 'import'
|
|
1015
|
+
? !step.module
|
|
1016
|
+
: step.act === 'run-tests'
|
|
1017
|
+
? !step.file || !(step.command || step.run)
|
|
1018
|
+
: !step.run;
|
|
648
1019
|
if (nothingToRun) {
|
|
649
1020
|
return [notCovered({
|
|
650
1021
|
channel: 'results',
|
|
@@ -652,22 +1023,53 @@ export const processAdapter = defineAdapter({
|
|
|
652
1023
|
reason: 'refused',
|
|
653
1024
|
says:
|
|
654
1025
|
`"${journey.describe}" says nothing to run. A command journey needs a "run" with the command line in it, ` +
|
|
655
|
-
`
|
|
1026
|
+
`an import journey needs a "module", and a test-file journey needs the "file" it walks and the command ` +
|
|
1027
|
+
`that runs it. Nothing was run, and that is a hole, not a pass.`,
|
|
656
1028
|
})];
|
|
657
1029
|
}
|
|
658
1030
|
|
|
659
|
-
const
|
|
1031
|
+
const runner = /** @type {import('../journeys/from-suite.js').Runner} */ (step.runner ?? 'node:test');
|
|
1032
|
+
// The same list for both snapshots, and handed on to the report: a folder that is not
|
|
1033
|
+
// watched has to be named in the run that did not watch it, not left to be discovered.
|
|
1034
|
+
const skip = snapshotSkip(ctx.config);
|
|
1035
|
+
const before = await snapshotTree(places.work, { skip });
|
|
660
1036
|
const result = step.act === 'import'
|
|
661
1037
|
? await runCommand(importProbeCommand(String(step.module)), { cwd, env, timeoutMs: journey.timeoutMs ?? 60000, signal: ctx.signal })
|
|
662
|
-
:
|
|
663
|
-
|
|
1038
|
+
: step.act === 'run-tests'
|
|
1039
|
+
? await runCommand(testFileCommand(step), { cwd, env, timeoutMs: journey.timeoutMs ?? 120000, signal: ctx.signal })
|
|
1040
|
+
: await runCommand(String(step.run), { cwd, env, timeoutMs: journey.timeoutMs ?? 120000, stdin: step.stdin, signal: ctx.signal });
|
|
1041
|
+
const after = await snapshotTree(places.work, { skip });
|
|
664
1042
|
const watched = await readWatcher(reportFile);
|
|
665
1043
|
|
|
666
1044
|
const observations = await describeRun({
|
|
667
|
-
journey, result, before, after, watched, ctx,
|
|
1045
|
+
journey, result, before, after, watched, ctx, skipped: [...skip].sort(),
|
|
668
1046
|
footprint: { dirs: [places.base, places.tmp, places.home], projectRoot: build.build.root },
|
|
1047
|
+
// A test runner narrates its own stopwatch and nothing else moves between two runs of
|
|
1048
|
+
// identical bytes, so taking the stopwatch out is what makes the whole of what it
|
|
1049
|
+
// printed worth comparing. See `withoutRunnerTiming`.
|
|
1050
|
+
quieten: step.act === 'run-tests' ? (text) => quietenRunnerOutput(runner, text) : undefined,
|
|
669
1051
|
});
|
|
670
1052
|
if (step.act === 'import') observations.push(...apiSurface(journey, result));
|
|
1053
|
+
if (step.act === 'run-tests') {
|
|
1054
|
+
observations.push(...(await suiteObservations({ journey, step, runner, result, root: places.work })));
|
|
1055
|
+
}
|
|
1056
|
+
// Only the first step is walked, and until now the rest were dropped without a word — a
|
|
1057
|
+
// journey of three steps reported on one of them and read as a clean, complete walk.
|
|
1058
|
+
// Nothing here builds a multi-step CLI journey today; a recording or an agent easily
|
|
1059
|
+
// could, and a silent drop is how that arrives as a false pass.
|
|
1060
|
+
const rest = (journey.steps ?? []).slice(1);
|
|
1061
|
+
if (rest.length > 0) {
|
|
1062
|
+
observations.push(notCovered({
|
|
1063
|
+
channel: 'results',
|
|
1064
|
+
path: joinPath('cli', journey.name, 'the rest of its steps'),
|
|
1065
|
+
reason: 'not supported here',
|
|
1066
|
+
says:
|
|
1067
|
+
`"${journey.describe}" has ${rest.length + 1} steps and this adapter walks one command per journey, so ` +
|
|
1068
|
+
`${rest.length} of them ${rest.length === 1 ? 'was' : 'were'} not walked: ` +
|
|
1069
|
+
`${rest.map((/** @type {any} */ s) => s.run ?? s.module ?? s.file ?? s.act).join(', ')}. ` +
|
|
1070
|
+
`Split them into a journey each. This is a hole, not a pass.`,
|
|
1071
|
+
}));
|
|
1072
|
+
}
|
|
671
1073
|
return observations;
|
|
672
1074
|
},
|
|
673
1075
|
|
|
@@ -699,11 +1101,194 @@ export function importProbeCommand(moduleId) {
|
|
|
699
1101
|
" : t === 'object' ? ('an object with ' + Object.keys(v).sort().join(', '))",
|
|
700
1102
|
" : t === 'string' ? 'some text' : t;",
|
|
701
1103
|
"}",
|
|
702
|
-
"process.stdout.write(JSON.stringify(out, null, 2));",
|
|
1104
|
+
"process.stdout.write('\\n' + " + JSON.stringify(EXPORTS_MARKER) + " + '\\n' + JSON.stringify(out, null, 2));",
|
|
703
1105
|
].join('\n');
|
|
704
1106
|
return `node --input-type=module -e ${shellQuote(probe)} ${shellQuote(moduleId)}`;
|
|
705
1107
|
}
|
|
706
1108
|
|
|
1109
|
+
/**
|
|
1110
|
+
* How the probe's answer is told apart from anything the module printed on the way in.
|
|
1111
|
+
*
|
|
1112
|
+
* Without it the whole of stdout was handed to `JSON.parse`, so ONE line printed at import
|
|
1113
|
+
* time — a dotenv banner, a deprecation warning, anything — made the parse fail, and the run
|
|
1114
|
+
* then said "could not be imported, so nothing is known about what it exports". Both halves
|
|
1115
|
+
* false: it imported perfectly, and its whole exported surface was sitting in the same
|
|
1116
|
+
* string. Every export on that module read as never walked, which is the coverage ledger
|
|
1117
|
+
* lying, and the API comparison that is the entire point of an import journey was off.
|
|
1118
|
+
*/
|
|
1119
|
+
const EXPORTS_MARKER = '<<< staysfixed: what it exports >>>';
|
|
1120
|
+
|
|
1121
|
+
// ---------------------------------------------------------------------------
|
|
1122
|
+
// Walking a test file the harvest found
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* The command line for a test-file journey, exactly as it was harvested.
|
|
1127
|
+
*
|
|
1128
|
+
* The harvest wrote the program and its arguments down separately, and they are put back
|
|
1129
|
+
* together with every part quoted, because a project with a space in its path is not a
|
|
1130
|
+
* project this tool gets to be wrong about.
|
|
1131
|
+
*
|
|
1132
|
+
* ONE SUBSTITUTION, and only one. The program the harvest recorded is an absolute path to the
|
|
1133
|
+
* Node binary on the machine that did the harvesting. A journey saved in a repository and
|
|
1134
|
+
* walked on somebody else's laptop names a file that is not there, and the shell then fails
|
|
1135
|
+
* the same way on BOTH builds - which produces no difference at all and reads exactly like a
|
|
1136
|
+
* clean check. So a missing absolute Node is replaced with the Node running this, and
|
|
1137
|
+
* anything else is left alone for the shell to find on the path.
|
|
1138
|
+
*
|
|
1139
|
+
* @param {{command?: string, argv?: string[], run?: string, file?: string}} step
|
|
1140
|
+
* @returns {string}
|
|
1141
|
+
*/
|
|
1142
|
+
export function testFileCommand(step) {
|
|
1143
|
+
if (!step.command) return String(step.run ?? '');
|
|
1144
|
+
let program = String(step.command);
|
|
1145
|
+
if (path.isAbsolute(program) && !exists(program) && /^node(\.exe)?$/.test(path.basename(program))) {
|
|
1146
|
+
program = process.execPath;
|
|
1147
|
+
}
|
|
1148
|
+
return [program, ...(step.argv ?? []).map(String)].map(shellQuote).join(' ');
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* What a walked test file says, beyond what any command says.
|
|
1153
|
+
*
|
|
1154
|
+
* Four things, and each one answers a question an exit code cannot.
|
|
1155
|
+
*
|
|
1156
|
+
* EACH CHECK, BY NAME, passed or failed. A suite that was already red stays red on both
|
|
1157
|
+
* builds and reports nothing, which is right: it was already failing and you did not break
|
|
1158
|
+
* it. A check that goes green-to-red on the new build alone is the finding, and it names
|
|
1159
|
+
* itself instead of arriving as "the exit code changed".
|
|
1160
|
+
*
|
|
1161
|
+
* WHY EACH FAILING CHECK FAILED. "Still failing" and "failing for a completely different
|
|
1162
|
+
* reason" are different facts, and a flag cannot hold both.
|
|
1163
|
+
*
|
|
1164
|
+
* THE CHECKS THE FILE CONTAINS, as a list. Add, rename or delete a test and this moves.
|
|
1165
|
+
*
|
|
1166
|
+
* THE TEST FILE ITSELF, as a fingerprint of its contents. This is the one that stops the
|
|
1167
|
+
* whole feature crying wolf. If you edited the test, then every difference underneath it is
|
|
1168
|
+
* a difference you made on purpose, and the reader has to be told so in the same breath as
|
|
1169
|
+
* the difference rather than left to work it out. It is compared rather than merely noted,
|
|
1170
|
+
* so it appears exactly when it is true and never otherwise.
|
|
1171
|
+
*
|
|
1172
|
+
* FLAKES ARE NOT DEALT WITH HERE, on purpose. A check that flips between two runs of the same
|
|
1173
|
+
* build lands in the wobble measurement like anything else that cannot answer twice, and is
|
|
1174
|
+
* subtracted there. A second mechanism for the same problem is how two mechanisms end up
|
|
1175
|
+
* disagreeing with each other.
|
|
1176
|
+
*
|
|
1177
|
+
* @param {object} input
|
|
1178
|
+
* @param {import('./contract.js').Journey} input.journey
|
|
1179
|
+
* @param {{file?: string, tests?: string[]}} input.step
|
|
1180
|
+
* @param {import('../journeys/from-suite.js').Runner} input.runner
|
|
1181
|
+
* @param {CommandResult} input.result
|
|
1182
|
+
* @param {string} input.root The scratch copy this build was walked in.
|
|
1183
|
+
* @returns {Promise<import('./contract.js').Observation[]>}
|
|
1184
|
+
*/
|
|
1185
|
+
export async function suiteObservations(input) {
|
|
1186
|
+
const { journey, step, runner, result, root } = input;
|
|
1187
|
+
const id = journey.name;
|
|
1188
|
+
const file = String(step.file ?? '');
|
|
1189
|
+
/** @type {import('./contract.js').Observation[]} */
|
|
1190
|
+
const out = [];
|
|
1191
|
+
|
|
1192
|
+
out.push(observation({
|
|
1193
|
+
channel: 'results',
|
|
1194
|
+
path: joinPath('test', id, 'the test file itself'),
|
|
1195
|
+
value: await fingerprintOf(path.join(root, file)),
|
|
1196
|
+
says:
|
|
1197
|
+
`${file} as it stands in this build. If this is one of the things that changed, then whatever moved below ` +
|
|
1198
|
+
`moved because you edited the test, and it cannot tell you whether the product still works - run the check ` +
|
|
1199
|
+
`again once the test is the way you want it.`,
|
|
1200
|
+
where: { file },
|
|
1201
|
+
}));
|
|
1202
|
+
|
|
1203
|
+
const read = readChecks(runner, result.stdout);
|
|
1204
|
+
if (!read.read) {
|
|
1205
|
+
out.push(notCovered({
|
|
1206
|
+
channel: 'results',
|
|
1207
|
+
path: joinPath('test', id, 'the checks it reported'),
|
|
1208
|
+
reason: 'crashed',
|
|
1209
|
+
says:
|
|
1210
|
+
`Nothing could be read back from ${file}: ${read.why} It was run, and what it printed and how it finished ` +
|
|
1211
|
+
`are still compared exactly - but which of its checks passed is not known, and that is a hole, not a pass.`,
|
|
1212
|
+
where: { file },
|
|
1213
|
+
}));
|
|
1214
|
+
return out;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
out.push(observation({
|
|
1218
|
+
channel: 'results',
|
|
1219
|
+
path: joinPath('test', id, 'the checks it contains'),
|
|
1220
|
+
value: read.checks.map((c) => c.name).sort(),
|
|
1221
|
+
says:
|
|
1222
|
+
`The ${read.checks.length} ${read.checks.length === 1 ? 'check' : 'checks'} ${file} reported. A name appearing ` +
|
|
1223
|
+
`or disappearing here means the test file itself was added to or cut down.`,
|
|
1224
|
+
where: { file },
|
|
1225
|
+
}));
|
|
1226
|
+
|
|
1227
|
+
for (const check of read.checks) {
|
|
1228
|
+
out.push(observation({
|
|
1229
|
+
channel: 'results',
|
|
1230
|
+
path: joinPath('test', id, check.name),
|
|
1231
|
+
value: check.ok ? 'passed' : 'failed',
|
|
1232
|
+
says: check.ok
|
|
1233
|
+
? `"${check.name}" in ${file} passed.`
|
|
1234
|
+
: `"${check.name}" in ${file} failed. If it failed on the build you were happy with too, nothing is ` +
|
|
1235
|
+
`reported - it was already broken, and you did not break it.`,
|
|
1236
|
+
where: { file },
|
|
1237
|
+
}));
|
|
1238
|
+
if (check.detail) {
|
|
1239
|
+
// A long failure message gets its middle cut out, and that used to be stored with
|
|
1240
|
+
// nothing saying so — two different failures whose ends match would then compare equal
|
|
1241
|
+
// and report "still failing for the same reason" when the reason had changed.
|
|
1242
|
+
const kept = trimForStorage(check.detail);
|
|
1243
|
+
out.push(observation({
|
|
1244
|
+
channel: 'complaints',
|
|
1245
|
+
path: joinPath('test', id, check.name, 'why it failed'),
|
|
1246
|
+
value: kept.text,
|
|
1247
|
+
says:
|
|
1248
|
+
`What ${file} said when "${check.name}" failed. A check that was already failing and is now failing for a ` +
|
|
1249
|
+
`different reason is a change, and this is where it shows.` +
|
|
1250
|
+
(kept.truncated
|
|
1251
|
+
? ` It is ${sizeBucket(kept.bytes)}, so only the two ends are compared: a different failure with the same ` +
|
|
1252
|
+
`ends and the same length would not be seen.`
|
|
1253
|
+
: ''),
|
|
1254
|
+
where: { file },
|
|
1255
|
+
covered: kept.truncated ? false : undefined,
|
|
1256
|
+
reason: kept.truncated ? 'too big' : undefined,
|
|
1257
|
+
}));
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
out.push(observation({
|
|
1262
|
+
channel: 'counters',
|
|
1263
|
+
path: joinPath('count', id, 'checks that failed'),
|
|
1264
|
+
value: read.checks.filter((c) => !c.ok).length,
|
|
1265
|
+
says:
|
|
1266
|
+
`How many of ${file}'s checks did not pass. Compared against the build you were happy with, so a suite that ` +
|
|
1267
|
+
`was already red is not news.`,
|
|
1268
|
+
where: { file },
|
|
1269
|
+
}));
|
|
1270
|
+
|
|
1271
|
+
return out;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* A file's contents in one short string, or a plain sentence saying it is not there.
|
|
1276
|
+
*
|
|
1277
|
+
* @param {string} file
|
|
1278
|
+
* @returns {Promise<string>}
|
|
1279
|
+
*/
|
|
1280
|
+
async function fingerprintOf(file) {
|
|
1281
|
+
try {
|
|
1282
|
+
const bytes = await fsp.readFile(file);
|
|
1283
|
+
return `${crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 16)} (${bytes.length} bytes)`;
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
// "It is not here" and "it is here and would not open" are different facts, and one
|
|
1286
|
+
// sentence for both means a permissions problem reads as a deleted test file.
|
|
1287
|
+
if (isGone(error)) return 'there is no such file in this build';
|
|
1288
|
+
return `${COULD_NOT_READ}${whyItWouldNotOpen(error)}`;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
707
1292
|
/** @param {string} text */
|
|
708
1293
|
function shellQuote(text) {
|
|
709
1294
|
return `'${text.split("'").join(`'\\''`)}'`;
|
|
@@ -739,6 +1324,15 @@ function sanitise(name) {
|
|
|
739
1324
|
* @param {WatchedEvents} input.watched
|
|
740
1325
|
* @param {import('./contract.js').RunContext} input.ctx
|
|
741
1326
|
* @param {{dirs: string[], projectRoot?: string, ports?: number[]}} input.footprint
|
|
1327
|
+
* @param {string[]} [input.skipped] Folders this run did not watch, so it can say so.
|
|
1328
|
+
* @param {(text: string) => string} [input.quieten]
|
|
1329
|
+
* Applied to what the program printed, after our own footprint is rubbed out and before
|
|
1330
|
+
* anything is compared. It exists for one narrow case and has to stay narrow: a harness the
|
|
1331
|
+
* journey itself started - a test runner - that narrates its own stopwatch into the output.
|
|
1332
|
+
* That is the harness talking about the machine, not the product talking about itself, and
|
|
1333
|
+
* it is the same reason durations are never compared anywhere else in here. It is NOT for
|
|
1334
|
+
* the product's own volatile output; that is the noise-control layer's job, where the rules
|
|
1335
|
+
* live in the project's git and a person can see and argue with them.
|
|
742
1336
|
* @returns {Promise<import('./contract.js').Observation[]>}
|
|
743
1337
|
*/
|
|
744
1338
|
export async function describeRun(input) {
|
|
@@ -752,7 +1346,8 @@ export async function describeRun(input) {
|
|
|
752
1346
|
['to the screen', result.stdout, 'results', 'printed to the screen', 'printed nothing at all'],
|
|
753
1347
|
['as a complaint', result.stderr, 'complaints', 'complained about', 'complained about nothing'],
|
|
754
1348
|
])) {
|
|
755
|
-
const
|
|
1349
|
+
const plain = undoOurFootprint(raw, footprint);
|
|
1350
|
+
const text = input.quieten ? input.quieten(plain) : plain;
|
|
756
1351
|
const kept = trimForStorage(text);
|
|
757
1352
|
let evidence;
|
|
758
1353
|
if (kept.truncated) {
|
|
@@ -783,6 +1378,17 @@ export async function describeRun(input) {
|
|
|
783
1378
|
}
|
|
784
1379
|
|
|
785
1380
|
// ---- how it finished
|
|
1381
|
+
if (result.couldNotStart) {
|
|
1382
|
+
out.push(notCovered({
|
|
1383
|
+
channel: 'complaints',
|
|
1384
|
+
path: joinPath('cli', id, 'ran at all'),
|
|
1385
|
+
reason: 'crashed',
|
|
1386
|
+
says:
|
|
1387
|
+
`"${journey.describe}" never started: ${result.couldNotStart}. Nothing about the product was observed here, ` +
|
|
1388
|
+
`and a command that fails to start fails the same way on both builds — so without this line the comparison ` +
|
|
1389
|
+
`would have found no difference and called it clean.`,
|
|
1390
|
+
}));
|
|
1391
|
+
}
|
|
786
1392
|
out.push(observation({
|
|
787
1393
|
channel: 'complaints',
|
|
788
1394
|
path: joinPath('cli', id, 'exit'),
|
|
@@ -808,6 +1414,54 @@ export async function describeRun(input) {
|
|
|
808
1414
|
: `"${journey.describe}" ${change.what} ${change.file}. Only the contents are compared, so rewriting the same bytes is not a change.`,
|
|
809
1415
|
}));
|
|
810
1416
|
}
|
|
1417
|
+
// ---- and the three ways looking at files can come up short. All of them used to be
|
|
1418
|
+
// silent, and a silence here is indistinguishable from "nothing changed", which is the one
|
|
1419
|
+
// shape of wrong answer this whole tool exists to prevent.
|
|
1420
|
+
const bySize = [...input.after].filter(([, mark]) => mark.startsWith(BY_SIZE_ALONE)).map(([file]) => file).sort();
|
|
1421
|
+
if (bySize.length > 0) {
|
|
1422
|
+
out.push(observation({
|
|
1423
|
+
channel: 'effects',
|
|
1424
|
+
path: joinPath('file', id, 'compared by size alone'),
|
|
1425
|
+
value: bySize,
|
|
1426
|
+
says:
|
|
1427
|
+
`${bySize.length} file${bySize.length === 1 ? ' was' : 's were'} too big to read through, so ${bySize.length === 1 ? 'it was' : 'they were'} ` +
|
|
1428
|
+
`compared by size rather than by contents: ${bySize.join(', ')}. A rewrite of the same rough size would NOT be seen. ` +
|
|
1429
|
+
`This is a hole in what was checked, not a pass.`,
|
|
1430
|
+
covered: false,
|
|
1431
|
+
reason: 'too big',
|
|
1432
|
+
}));
|
|
1433
|
+
}
|
|
1434
|
+
const unreadable = [...new Map([...input.before, ...input.after])]
|
|
1435
|
+
.filter(([, mark]) => mark.startsWith(COULD_NOT_READ))
|
|
1436
|
+
.map(([file, mark]) => `${file} (${mark.slice(COULD_NOT_READ.length)})`)
|
|
1437
|
+
.sort();
|
|
1438
|
+
if (unreadable.length > 0) {
|
|
1439
|
+
out.push(observation({
|
|
1440
|
+
channel: 'effects',
|
|
1441
|
+
path: joinPath('file', id, 'could not be looked at'),
|
|
1442
|
+
value: unreadable,
|
|
1443
|
+
says:
|
|
1444
|
+
`${unreadable.length} place${unreadable.length === 1 ? '' : 's'} in the scratch copy could not be read, so anything written ` +
|
|
1445
|
+
`there — and anything underneath, for a folder — was not seen: ${unreadable.join(', ')}. This is a hole, not a pass.`,
|
|
1446
|
+
covered: false,
|
|
1447
|
+
reason: 'refused',
|
|
1448
|
+
}));
|
|
1449
|
+
}
|
|
1450
|
+
if (input.skipped && input.skipped.length > 0) {
|
|
1451
|
+
out.push(observation({
|
|
1452
|
+
channel: 'effects',
|
|
1453
|
+
path: joinPath('file', id, 'folders left unwatched'),
|
|
1454
|
+
value: input.skipped,
|
|
1455
|
+
says:
|
|
1456
|
+
`Files written into ${input.skipped.join(', ')} were not watched. ` +
|
|
1457
|
+
`node_modules is the one that costs something: a build step that patches a dependency, generates a client into it, ` +
|
|
1458
|
+
`or rebuilds a native module changes what ships and is not seen here. It is left out because fingerprinting it twice ` +
|
|
1459
|
+
`per run makes a check nobody waits for. Name it under "process.alsoWatch" in the settings to watch it anyway.`,
|
|
1460
|
+
covered: false,
|
|
1461
|
+
reason: 'too big',
|
|
1462
|
+
}));
|
|
1463
|
+
}
|
|
1464
|
+
|
|
811
1465
|
out.push(observation({
|
|
812
1466
|
channel: 'counters',
|
|
813
1467
|
path: joinPath('count', id, 'files touched'),
|
|
@@ -843,6 +1497,18 @@ export async function describeRun(input) {
|
|
|
843
1497
|
reason: 'irreversible',
|
|
844
1498
|
}));
|
|
845
1499
|
}
|
|
1500
|
+
if (watched.torn > 0) {
|
|
1501
|
+
out.push(observation({
|
|
1502
|
+
channel: 'effects',
|
|
1503
|
+
path: joinPath('proc', id, 'events that could not be read back'),
|
|
1504
|
+
value: watched.torn,
|
|
1505
|
+
says:
|
|
1506
|
+
`${watched.torn} line${watched.torn === 1 ? '' : 's'} of what the watcher wrote could not be read back, so ` +
|
|
1507
|
+
`that many programs started or connections attempted are missing from this run. This is a hole, not a pass.`,
|
|
1508
|
+
covered: false,
|
|
1509
|
+
reason: 'crashed',
|
|
1510
|
+
}));
|
|
1511
|
+
}
|
|
846
1512
|
if (watched.settingsRead.length > 0) {
|
|
847
1513
|
out.push(observation({
|
|
848
1514
|
channel: 'effects',
|
|
@@ -890,8 +1556,12 @@ export async function describeRun(input) {
|
|
|
890
1556
|
export function apiSurface(journey, result) {
|
|
891
1557
|
/** @type {Record<string, string>} */
|
|
892
1558
|
let surface;
|
|
1559
|
+
const at = result.stdout.lastIndexOf(EXPORTS_MARKER);
|
|
893
1560
|
try {
|
|
894
|
-
|
|
1561
|
+
// Only what comes after the marker. Anything the module printed while importing sits in
|
|
1562
|
+
// front of it and is compared under "printed", where it belongs.
|
|
1563
|
+
surface = JSON.parse(at === -1 ? result.stdout : result.stdout.slice(at + EXPORTS_MARKER.length));
|
|
1564
|
+
if (surface === null || typeof surface !== 'object' || Array.isArray(surface)) throw new Error('not a list of names');
|
|
895
1565
|
} catch {
|
|
896
1566
|
return [notCovered({
|
|
897
1567
|
channel: 'results',
|
|
@@ -917,14 +1587,6 @@ export function apiSurface(journey, result) {
|
|
|
917
1587
|
return out;
|
|
918
1588
|
}
|
|
919
1589
|
|
|
920
|
-
/**
|
|
921
|
-
* A scratch folder under the system temp directory, for callers that do not have one.
|
|
922
|
-
* @param {string} [label]
|
|
923
|
-
*/
|
|
924
|
-
export async function scratchFolder(label = 'staysfixed') {
|
|
925
|
-
return fsp.mkdtemp(path.join(os.tmpdir(), `${label}-`));
|
|
926
|
-
}
|
|
927
|
-
|
|
928
1590
|
/** True when a path exists. Small enough to inline, useful enough to name. */
|
|
929
1591
|
export function exists(/** @type {string} */ file) {
|
|
930
1592
|
return fs.existsSync(file);
|