staysfixed 0.2.3 → 0.3.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/package.json +1 -1
- package/src/core/events.js +174 -18
- package/src/guard/api.js +168 -12
- package/src/guard/run.js +104 -6
- package/src/picture/capture.js +134 -14
- package/src/picture/run.js +67 -1
- package/src/types.js +9 -2
- package/src/watch/panel.js +775 -253
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers — as a CLI and as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/core/events.js
CHANGED
|
@@ -246,6 +246,36 @@ export const CHECK_LABELS = Object.freeze({
|
|
|
246
246
|
failed: 'the picture could not be taken',
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The name of one line of the list.
|
|
251
|
+
*
|
|
252
|
+
* A step is announced twice — once the moment it starts, once when it settles — and
|
|
253
|
+
* the second announcement has to land on the SAME line rather than adding another.
|
|
254
|
+
* That only works if both sides agree on what to call it, so the names live here and
|
|
255
|
+
* neither side is free to invent its own.
|
|
256
|
+
*
|
|
257
|
+
* @typedef {'frozen'|'steps'|'settle'|'loaded'|'network'|'masks'|'size'|'pixels'|'console'|'retried'|'platform'|'failed'} CheckKey
|
|
258
|
+
*/
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Every line name, as values, so nothing has to spell one out in a string.
|
|
262
|
+
* @type {Readonly<Record<CheckKey, CheckKey>>}
|
|
263
|
+
*/
|
|
264
|
+
export const CHECK_KEYS = Object.freeze({
|
|
265
|
+
frozen: 'frozen',
|
|
266
|
+
steps: 'steps',
|
|
267
|
+
settle: 'settle',
|
|
268
|
+
loaded: 'loaded',
|
|
269
|
+
network: 'network',
|
|
270
|
+
masks: 'masks',
|
|
271
|
+
size: 'size',
|
|
272
|
+
pixels: 'pixels',
|
|
273
|
+
console: 'console',
|
|
274
|
+
retried: 'retried',
|
|
275
|
+
platform: 'platform',
|
|
276
|
+
failed: 'failed',
|
|
277
|
+
});
|
|
278
|
+
|
|
249
279
|
/**
|
|
250
280
|
* What was asked of the freeze layer for one screen. Everything here is a
|
|
251
281
|
* setting, not a measurement — it is how the list can say a check was switched
|
|
@@ -316,43 +346,169 @@ export function buildChecks(input) {
|
|
|
316
346
|
/** @type {import('../types.js').CheckStep[]} */
|
|
317
347
|
const out = [];
|
|
318
348
|
/**
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
349
|
+
* Every line is written down under its name. The live half of this — a step
|
|
350
|
+
* announced the moment it starts, before anything is known about how it went —
|
|
351
|
+
* settles onto the line with the same name, so the two have to be handed the
|
|
352
|
+
* same names from the same place.
|
|
353
|
+
*
|
|
354
|
+
* @param {CheckKey} key
|
|
355
|
+
* @returns {Say}
|
|
322
356
|
*/
|
|
323
|
-
const
|
|
324
|
-
out.push(detail ? { label, detail, state } : { label, state });
|
|
357
|
+
const at = (key) => (label, detail, state) => {
|
|
358
|
+
out.push(detail ? { label, detail, state, key } : { label, state, key });
|
|
325
359
|
};
|
|
326
360
|
|
|
327
361
|
const screen = input.screen ?? /** @type {import('../types.js').ScreenConfig} */ ({ name: '' });
|
|
328
362
|
const errors = input.consoleErrors ?? [];
|
|
329
363
|
|
|
330
|
-
frozenStep(
|
|
331
|
-
stepsStep(
|
|
364
|
+
frozenStep(at(CHECK_KEYS.frozen), input.frozen);
|
|
365
|
+
stepsStep(at(CHECK_KEYS.steps), screen, input.failure);
|
|
332
366
|
|
|
333
367
|
if (input.failure) {
|
|
334
368
|
// Nothing after this happened, so nothing after this is claimed. The one
|
|
335
369
|
// thing still worth saying is whether the page was shouting on its way down.
|
|
336
|
-
|
|
337
|
-
consoleStep(
|
|
370
|
+
at(CHECK_KEYS.failed)(CHECK_LABELS.failed, input.failure, 'bad');
|
|
371
|
+
consoleStep(at(CHECK_KEYS.console), errors);
|
|
338
372
|
return out;
|
|
339
373
|
}
|
|
340
374
|
|
|
341
|
-
settleStep(
|
|
342
|
-
loadedStep(
|
|
343
|
-
networkStep(
|
|
344
|
-
masksStep(
|
|
345
|
-
sizeStep(
|
|
346
|
-
pixelsStep(
|
|
347
|
-
consoleStep(
|
|
348
|
-
retryStep(
|
|
349
|
-
platformStep(
|
|
375
|
+
settleStep(at(CHECK_KEYS.settle), input.settle, input.frozen);
|
|
376
|
+
loadedStep(at(CHECK_KEYS.loaded), input.loaded, input.frozen);
|
|
377
|
+
networkStep(at(CHECK_KEYS.network), input.freeze, input.frozen);
|
|
378
|
+
masksStep(at(CHECK_KEYS.masks), input.masks, input.masksAsked);
|
|
379
|
+
sizeStep(at(CHECK_KEYS.size), input);
|
|
380
|
+
pixelsStep(at(CHECK_KEYS.pixels), input);
|
|
381
|
+
consoleStep(at(CHECK_KEYS.console), errors);
|
|
382
|
+
retryStep(at(CHECK_KEYS.retried), input);
|
|
383
|
+
platformStep(at(CHECK_KEYS.platform), input.platform);
|
|
350
384
|
|
|
351
385
|
return out;
|
|
352
386
|
}
|
|
353
387
|
|
|
354
388
|
/** @typedef {(label: string, detail: string|undefined, state: import('../types.js').CheckStep['state']) => void} Say */
|
|
355
389
|
|
|
390
|
+
/**
|
|
391
|
+
* One line of that same list, on its own, the moment it settles.
|
|
392
|
+
*
|
|
393
|
+
* The list above is built when a screen is finished, which is the only time every
|
|
394
|
+
* number is known — but a person watching wants the line to tick the moment the
|
|
395
|
+
* thing itself happens, not two seconds later in a table. This builds exactly one
|
|
396
|
+
* of those lines, from the same code and therefore in the same words: there is no
|
|
397
|
+
* second set of phrases to drift out of step with the first.
|
|
398
|
+
*
|
|
399
|
+
* Hands back nothing when there is nothing honest to say yet — a size step before
|
|
400
|
+
* anything has been measured, say — so a caller can offer what it knows and let
|
|
401
|
+
* this decide whether it amounts to a line.
|
|
402
|
+
*
|
|
403
|
+
* @param {CheckKey} key
|
|
404
|
+
* @param {ChecksInput} input Only the parts this line needs have to be filled in.
|
|
405
|
+
* @returns {import('../types.js').CheckStep|undefined}
|
|
406
|
+
*/
|
|
407
|
+
export function checkStep(key, input) {
|
|
408
|
+
/** @type {import('../types.js').CheckStep[]} */
|
|
409
|
+
const out = [];
|
|
410
|
+
/** @type {Say} */
|
|
411
|
+
const say = (label, detail, state) => {
|
|
412
|
+
out.push(detail ? { label, detail, state, key } : { label, state, key });
|
|
413
|
+
};
|
|
414
|
+
const screen = input.screen ?? /** @type {import('../types.js').ScreenConfig} */ ({ name: '' });
|
|
415
|
+
|
|
416
|
+
switch (key) {
|
|
417
|
+
case 'frozen':
|
|
418
|
+
frozenStep(say, input.frozen);
|
|
419
|
+
break;
|
|
420
|
+
case 'steps':
|
|
421
|
+
stepsStep(say, screen, input.failure);
|
|
422
|
+
break;
|
|
423
|
+
case 'settle':
|
|
424
|
+
settleStep(say, input.settle, input.frozen);
|
|
425
|
+
break;
|
|
426
|
+
case 'loaded':
|
|
427
|
+
loadedStep(say, input.loaded, input.frozen);
|
|
428
|
+
break;
|
|
429
|
+
case 'network':
|
|
430
|
+
networkStep(say, input.freeze, input.frozen);
|
|
431
|
+
break;
|
|
432
|
+
case 'masks':
|
|
433
|
+
masksStep(say, input.masks, input.masksAsked);
|
|
434
|
+
break;
|
|
435
|
+
case 'size':
|
|
436
|
+
sizeStep(say, input);
|
|
437
|
+
break;
|
|
438
|
+
case 'pixels':
|
|
439
|
+
pixelsStep(say, input);
|
|
440
|
+
break;
|
|
441
|
+
case 'console':
|
|
442
|
+
consoleStep(say, input.consoleErrors ?? []);
|
|
443
|
+
break;
|
|
444
|
+
case 'retried':
|
|
445
|
+
retryStep(say, input);
|
|
446
|
+
break;
|
|
447
|
+
case 'platform':
|
|
448
|
+
platformStep(say, input.platform);
|
|
449
|
+
break;
|
|
450
|
+
case 'failed':
|
|
451
|
+
if (input.failure) say(CHECK_LABELS.failed, input.failure, 'bad');
|
|
452
|
+
break;
|
|
453
|
+
default:
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
return out[0];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The same line again, said while the thing is still happening.
|
|
461
|
+
*
|
|
462
|
+
* A step is announced before it has an outcome, which means the words have to be
|
|
463
|
+
* chosen from what is already known — and what is already known before a step runs
|
|
464
|
+
* is the config. So the clock line says "left alone" up front when a project turned
|
|
465
|
+
* the clock freezing off, rather than claiming a freeze and taking it back.
|
|
466
|
+
*
|
|
467
|
+
* Nothing is ever announced early that only exists afterwards: whether a screen was
|
|
468
|
+
* photographed twice, or approved on another computer, is not a thing anybody can be
|
|
469
|
+
* told is "happening".
|
|
470
|
+
*
|
|
471
|
+
* @param {CheckKey} key
|
|
472
|
+
* @param {ChecksInput} input
|
|
473
|
+
* @returns {import('../types.js').CheckStep|undefined}
|
|
474
|
+
*/
|
|
475
|
+
export function runningStep(key, input) {
|
|
476
|
+
const label = runningLabel(key, input);
|
|
477
|
+
return label ? { label, state: 'running', key } : undefined;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* @param {CheckKey} key
|
|
482
|
+
* @param {ChecksInput} input
|
|
483
|
+
* @returns {string|undefined}
|
|
484
|
+
*/
|
|
485
|
+
function runningLabel(key, input) {
|
|
486
|
+
const frozen = input.frozen;
|
|
487
|
+
switch (key) {
|
|
488
|
+
case 'frozen':
|
|
489
|
+
return frozen && frozen.clock === false ? CHECK_LABELS.frozenOff : CHECK_LABELS.frozen;
|
|
490
|
+
case 'steps':
|
|
491
|
+
return CHECK_LABELS.steps;
|
|
492
|
+
case 'settle':
|
|
493
|
+
return CHECK_LABELS.settle;
|
|
494
|
+
case 'loaded':
|
|
495
|
+
return frozen && frozen.fonts === false ? CHECK_LABELS.loadedOff : CHECK_LABELS.loaded;
|
|
496
|
+
case 'network':
|
|
497
|
+
return frozen && frozen.network === 'live' ? CHECK_LABELS.networkLive : CHECK_LABELS.network;
|
|
498
|
+
case 'masks':
|
|
499
|
+
return (input.masksAsked ?? 0) > 0 ? CHECK_LABELS.masks : CHECK_LABELS.masksNone;
|
|
500
|
+
case 'size':
|
|
501
|
+
return input.hasApproved === false ? CHECK_LABELS.sizeNew : CHECK_LABELS.size;
|
|
502
|
+
case 'pixels':
|
|
503
|
+
return input.hasApproved === false ? CHECK_LABELS.pixelsNew : CHECK_LABELS.pixels;
|
|
504
|
+
case 'console':
|
|
505
|
+
return CHECK_LABELS.console;
|
|
506
|
+
default:
|
|
507
|
+
// Nothing else is a thing that can be watched happening.
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
356
512
|
/**
|
|
357
513
|
* @param {Say} say
|
|
358
514
|
* @param {FrozenPlan|undefined} frozen
|
package/src/guard/api.js
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
* who wrote it has forgotten the bug. That is why assertions are a sentence plus
|
|
6
6
|
* a check, and never a bare comparison: `expect('the sidebar is hidden', ...)`
|
|
7
7
|
* fails with "expected: the sidebar is hidden", which anyone can act on.
|
|
8
|
+
*
|
|
9
|
+
* Those sentences are also the answer to the fairest question anyone asks about
|
|
10
|
+
* this tool: "is it only about how things look?" It is not — a guard drives the
|
|
11
|
+
* app and asserts what it still does — but that was invisible, because a guard
|
|
12
|
+
* reported one line however many things it proved. So every claim, and every
|
|
13
|
+
* action between the claims, now says itself out loud the moment it happens:
|
|
14
|
+
* announced as it starts, settled as it finishes. The list a person watches tick
|
|
15
|
+
* off IS the guard's own words, in the guard's own order.
|
|
8
16
|
*/
|
|
9
17
|
|
|
10
18
|
import { exec } from 'node:child_process';
|
|
@@ -28,15 +36,79 @@ const DEFAULT_RUN_TIMEOUT = 60_000;
|
|
|
28
36
|
/** Commands can print a lot; 10MB before we cut them off. */
|
|
29
37
|
const MAX_OUTPUT = 10 * 1024 * 1024;
|
|
30
38
|
|
|
39
|
+
/** Longest a selector, path or command is shown before it is cut short. */
|
|
40
|
+
const MAX_LABEL = 80;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How a step's id says what kind of step it is.
|
|
44
|
+
*
|
|
45
|
+
* An assertion is the point of a guard; opening a page or clicking a button is
|
|
46
|
+
* the setup that gets there. `CheckStep` has no room for that difference — and
|
|
47
|
+
* should not grow one for the sake of a colour — so it rides on the id, which
|
|
48
|
+
* every step needs anyway. Anything watching can draw `claim-` lines loud and
|
|
49
|
+
* `did-` lines quiet; anything that does not care sees a perfectly ordinary list.
|
|
50
|
+
*/
|
|
51
|
+
const CLAIM = 'claim';
|
|
52
|
+
const ACTION = 'did';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a guard is told to report to, when anyone is collecting.
|
|
56
|
+
* @typedef {object} GuardApiOptions
|
|
57
|
+
* @property {(step: import('../types.js').CheckStep) => void} [onStep]
|
|
58
|
+
* Called as each claim and each action starts, and again as it settles.
|
|
59
|
+
* The two calls carry the same `key`.
|
|
60
|
+
*/
|
|
61
|
+
|
|
31
62
|
/**
|
|
32
63
|
* Build the object passed to a guard's `run`.
|
|
33
64
|
*
|
|
34
65
|
* @param {import('../types.js').PageApi} page
|
|
35
66
|
* @param {import('../types.js').Project} project
|
|
67
|
+
* @param {GuardApiOptions} [opts]
|
|
36
68
|
* @returns {import('../types.js').GuardApi}
|
|
37
69
|
*/
|
|
38
|
-
export function makeGuardApi(page, project) {
|
|
70
|
+
export function makeGuardApi(page, project, opts = {}) {
|
|
39
71
|
const root = project.paths.root;
|
|
72
|
+
const onStep = typeof opts.onStep === 'function' ? opts.onStep : null;
|
|
73
|
+
let counted = 0;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Hand one step out, and never let that matter.
|
|
77
|
+
*
|
|
78
|
+
* Reporting is a convenience laid over a check; a listener that throws must not
|
|
79
|
+
* change whether a bug that was fixed is still fixed.
|
|
80
|
+
*
|
|
81
|
+
* @param {import('../types.js').CheckStep} step
|
|
82
|
+
* @returns {void}
|
|
83
|
+
*/
|
|
84
|
+
function tell(step) {
|
|
85
|
+
if (!onStep) return;
|
|
86
|
+
try {
|
|
87
|
+
onStep(step);
|
|
88
|
+
} catch {
|
|
89
|
+
// Watching is never worth a guard.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Say a thing has started, and hand back the way to say how it went.
|
|
95
|
+
*
|
|
96
|
+
* When nobody is collecting this allocates nothing and returns a function that
|
|
97
|
+
* does nothing, so a guard run with no watcher costs exactly what it did before.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} kind CLAIM or ACTION.
|
|
100
|
+
* @param {string} label Plain language, already final — the same words settle it.
|
|
101
|
+
* @returns {(state: import('../types.js').CheckStep['state'], detail?: string) => void}
|
|
102
|
+
*/
|
|
103
|
+
function announce(kind, label) {
|
|
104
|
+
if (!onStep) return () => {};
|
|
105
|
+
counted += 1;
|
|
106
|
+
const key = `${kind}-${counted}`;
|
|
107
|
+
tell({ key, label, state: 'running' });
|
|
108
|
+
return (state, detail) => {
|
|
109
|
+
tell(detail ? { key, label, detail, state } : { key, label, state });
|
|
110
|
+
};
|
|
111
|
+
}
|
|
40
112
|
|
|
41
113
|
return {
|
|
42
114
|
page,
|
|
@@ -46,16 +118,30 @@ export function makeGuardApi(page, project) {
|
|
|
46
118
|
* @param {string} to
|
|
47
119
|
* @returns {Promise<void>}
|
|
48
120
|
*/
|
|
49
|
-
open(to) {
|
|
50
|
-
|
|
121
|
+
async open(to) {
|
|
122
|
+
const settle = announce(ACTION, `opened ${short(to)}`);
|
|
123
|
+
try {
|
|
124
|
+
await page.goto(to);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
settle('bad', reasonOf(error));
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
settle('ok');
|
|
51
130
|
},
|
|
52
131
|
|
|
53
132
|
/**
|
|
54
133
|
* @param {string} selector
|
|
55
134
|
* @returns {Promise<void>}
|
|
56
135
|
*/
|
|
57
|
-
click(selector) {
|
|
58
|
-
|
|
136
|
+
async click(selector) {
|
|
137
|
+
const settle = announce(ACTION, `clicked ${short(selector)}`);
|
|
138
|
+
try {
|
|
139
|
+
await page.click(selector);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
settle('bad', reasonOf(error));
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
settle('ok');
|
|
59
145
|
},
|
|
60
146
|
|
|
61
147
|
/**
|
|
@@ -75,18 +161,32 @@ export function makeGuardApi(page, project) {
|
|
|
75
161
|
});
|
|
76
162
|
}
|
|
77
163
|
|
|
164
|
+
// The claim goes out before it is checked, not after. A person watching
|
|
165
|
+
// sees what is being asked while it is being asked — which is the whole
|
|
166
|
+
// difference between a list that ticks and a table that appears.
|
|
167
|
+
const settle = announce(CLAIM, claim.trim());
|
|
168
|
+
|
|
78
169
|
let result;
|
|
79
170
|
try {
|
|
80
171
|
result = await check();
|
|
81
172
|
} catch (cause) {
|
|
82
173
|
// A nested expectation already reads well — do not bury it in another layer.
|
|
83
|
-
if (cause instanceof ExpectationFailed)
|
|
84
|
-
|
|
174
|
+
if (cause instanceof ExpectationFailed) {
|
|
175
|
+
settle('bad', cause.claim === claim.trim() ? undefined : `inside it: ${cause.claim}`);
|
|
176
|
+
throw cause;
|
|
177
|
+
}
|
|
178
|
+
const why = cause instanceof Error ? cause.message : String(cause);
|
|
179
|
+
settle('bad', firstLine(why));
|
|
180
|
+
throw new Error(`while checking '${claim}': ${why}`, {
|
|
85
181
|
cause,
|
|
86
182
|
});
|
|
87
183
|
}
|
|
88
184
|
|
|
89
|
-
if (isNegative(result))
|
|
185
|
+
if (isNegative(result)) {
|
|
186
|
+
settle('bad', 'this is not true any more');
|
|
187
|
+
throw new ExpectationFailed(claim);
|
|
188
|
+
}
|
|
189
|
+
settle('ok');
|
|
90
190
|
},
|
|
91
191
|
|
|
92
192
|
/**
|
|
@@ -94,15 +194,17 @@ export function makeGuardApi(page, project) {
|
|
|
94
194
|
* must still succeed, a file that must still be generated — live here.
|
|
95
195
|
*
|
|
96
196
|
* A non-zero exit is returned, never thrown: whether it means failure is the
|
|
97
|
-
* guard's decision, not ours.
|
|
197
|
+
* guard's decision, not ours. The step says so the same way — a command that
|
|
198
|
+
* came back unhappy is worth noticing, and is still not a verdict.
|
|
98
199
|
*
|
|
99
200
|
* @param {string} cmd
|
|
100
201
|
* @param {{cwd?: string, timeoutMs?: number}} [runOpts]
|
|
101
202
|
* @returns {Promise<{code: number, stdout: string, stderr: string}>}
|
|
102
203
|
*/
|
|
103
|
-
run(cmd, runOpts = {}) {
|
|
204
|
+
async run(cmd, runOpts = {}) {
|
|
104
205
|
const cwd = runOpts.cwd ? path.resolve(root, runOpts.cwd) : root;
|
|
105
206
|
const timeoutMs = runOpts.timeoutMs ?? DEFAULT_RUN_TIMEOUT;
|
|
207
|
+
const settle = announce(ACTION, `ran ${short(cmd)}`);
|
|
106
208
|
|
|
107
209
|
/** @type {Promise<{code: number, stdout: string, stderr: string}>} */
|
|
108
210
|
const finished = new Promise((resolve) => {
|
|
@@ -129,7 +231,13 @@ export function makeGuardApi(page, project) {
|
|
|
129
231
|
},
|
|
130
232
|
);
|
|
131
233
|
});
|
|
132
|
-
|
|
234
|
+
|
|
235
|
+
const outcome = await finished;
|
|
236
|
+
if (outcome.code === 0) settle('ok', 'finished cleanly, code 0');
|
|
237
|
+
else if (outcome.code === 124) {
|
|
238
|
+
settle('warn', `stopped after ${humanTime(timeoutMs)}, code 124`);
|
|
239
|
+
} else settle('warn', `came back with code ${outcome.code}`);
|
|
240
|
+
return outcome;
|
|
133
241
|
},
|
|
134
242
|
|
|
135
243
|
/**
|
|
@@ -149,20 +257,30 @@ export function makeGuardApi(page, project) {
|
|
|
149
257
|
});
|
|
150
258
|
}
|
|
151
259
|
|
|
260
|
+
const settle = announce(ACTION, `read ${short(relative)}`);
|
|
261
|
+
/** @type {string} */
|
|
262
|
+
let text;
|
|
152
263
|
try {
|
|
153
|
-
|
|
264
|
+
text = await fsp.readFile(full, 'utf8');
|
|
154
265
|
} catch (cause) {
|
|
155
266
|
const code = /** @type {any} */ (cause)?.code;
|
|
156
267
|
if (code === 'ENOENT') {
|
|
268
|
+
settle('bad', 'there is no such file');
|
|
157
269
|
throw new StaysFixedError(`There is no file called "${relative}" in the project.`, { cause });
|
|
158
270
|
}
|
|
159
271
|
if (code === 'EISDIR') {
|
|
272
|
+
settle('bad', 'that is a folder, not a file');
|
|
160
273
|
throw new StaysFixedError(`"${relative}" is a folder, not a file.`, { cause });
|
|
161
274
|
}
|
|
275
|
+
settle('bad', reasonOf(cause));
|
|
162
276
|
throw new StaysFixedError(`Could not read "${relative}": ${cause instanceof Error ? cause.message : String(cause)}`, {
|
|
163
277
|
cause,
|
|
164
278
|
});
|
|
165
279
|
}
|
|
280
|
+
|
|
281
|
+
const lines = text === '' ? 0 : text.split('\n').length;
|
|
282
|
+
settle('ok', `${count(lines)} ${lines === 1 ? 'line' : 'lines'}`);
|
|
283
|
+
return text;
|
|
166
284
|
},
|
|
167
285
|
};
|
|
168
286
|
}
|
|
@@ -186,6 +304,44 @@ function isNegative(value) {
|
|
|
186
304
|
return false;
|
|
187
305
|
}
|
|
188
306
|
|
|
307
|
+
/**
|
|
308
|
+
* A selector, path or command, short enough to read in a list.
|
|
309
|
+
*
|
|
310
|
+
* @param {string} text
|
|
311
|
+
* @returns {string}
|
|
312
|
+
*/
|
|
313
|
+
function short(text) {
|
|
314
|
+
const one = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
315
|
+
if (one === '') return 'nothing';
|
|
316
|
+
return one.length > MAX_LABEL ? `${one.slice(0, MAX_LABEL - 1)}…` : one;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @param {unknown} error
|
|
321
|
+
* @returns {string}
|
|
322
|
+
*/
|
|
323
|
+
function reasonOf(error) {
|
|
324
|
+
return firstLine(error instanceof Error ? error.message : String(error));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @param {string} text
|
|
329
|
+
* @returns {string}
|
|
330
|
+
*/
|
|
331
|
+
function firstLine(text) {
|
|
332
|
+
const line = String(text ?? '').split('\n')[0].trim();
|
|
333
|
+
if (line === '') return 'it did not say why';
|
|
334
|
+
return line.length > 120 ? `${line.slice(0, 119)}…` : line;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* @param {number} n
|
|
339
|
+
* @returns {string}
|
|
340
|
+
*/
|
|
341
|
+
function count(n) {
|
|
342
|
+
return Number.isFinite(n) ? Math.round(n).toLocaleString('en-US') : String(n);
|
|
343
|
+
}
|
|
344
|
+
|
|
189
345
|
/**
|
|
190
346
|
* @param {number} ms
|
|
191
347
|
* @returns {string}
|
package/src/guard/run.js
CHANGED
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
* from the same clean state, a guard that needs a second go is recorded as
|
|
7
7
|
* wobbly rather than green, and the failure message carries the story of the
|
|
8
8
|
* original bug so nobody has to go looking for it.
|
|
9
|
+
*
|
|
10
|
+
* A guard also has to be watchable while it happens. It is the part of this tool
|
|
11
|
+
* that checks behaviour rather than looks — it drives the app and asserts what it
|
|
12
|
+
* still does — and reporting it as a single line hid every one of those
|
|
13
|
+
* assertions. So each claim and each action is passed straight out as it starts
|
|
14
|
+
* and again as it settles, and the whole list travels with the result. A guard
|
|
15
|
+
* that fails on its fifth claim still shows the four that held: "these are fine,
|
|
16
|
+
* this one is not" is most of the value of running it at all.
|
|
9
17
|
*/
|
|
10
18
|
|
|
11
19
|
import { makeGuardApi, ExpectationFailed } from './api.js';
|
|
@@ -14,8 +22,14 @@ import { emitEvent } from '../core/events.js';
|
|
|
14
22
|
|
|
15
23
|
const DEFAULT_TIMEOUT = 30_000;
|
|
16
24
|
|
|
25
|
+
/** How the runner names its own first step — the clean start every guard gets. */
|
|
26
|
+
const FRESH_KEY = 'fresh';
|
|
27
|
+
|
|
17
28
|
/**
|
|
18
|
-
* @typedef {import('../types.js').GuardResult & {
|
|
29
|
+
* @typedef {import('../types.js').GuardResult & {
|
|
30
|
+
* retriedToPass?: boolean,
|
|
31
|
+
* checks?: import('../types.js').CheckStep[],
|
|
32
|
+
* }} GuardRunResult
|
|
19
33
|
*/
|
|
20
34
|
|
|
21
35
|
/**
|
|
@@ -25,6 +39,8 @@ const DEFAULT_TIMEOUT = 30_000;
|
|
|
25
39
|
* @property {string} [failedAt]
|
|
26
40
|
*/
|
|
27
41
|
|
|
42
|
+
/** @typedef {(step: import('../types.js').CheckStep) => void} StepSink */
|
|
43
|
+
|
|
28
44
|
/**
|
|
29
45
|
* Run every guard against an app that is already open.
|
|
30
46
|
*
|
|
@@ -91,10 +107,31 @@ export async function runGuards(project, app, guards, opts = {}) {
|
|
|
91
107
|
/** @type {AttemptOutcome} */
|
|
92
108
|
let outcome = { ok: false, message: 'This guard did not run.' };
|
|
93
109
|
let attempts = 0;
|
|
110
|
+
/** @type {import('../types.js').CheckStep[]} */
|
|
111
|
+
let checks = [];
|
|
94
112
|
|
|
95
113
|
while (attempts < retries + 1) {
|
|
96
114
|
attempts += 1;
|
|
97
|
-
|
|
115
|
+
// A second go starts the list again. What a person needs to see is what the
|
|
116
|
+
// verdict was actually made on, and that is the last attempt — the earlier
|
|
117
|
+
// one is already recorded, more usefully, as "it only passed on try 2".
|
|
118
|
+
const attempt = attempts;
|
|
119
|
+
/** @type {import('../types.js').CheckStep[]} */
|
|
120
|
+
const collected = [];
|
|
121
|
+
checks = collected;
|
|
122
|
+
|
|
123
|
+
/** @type {StepSink} */
|
|
124
|
+
const onStep = (step) => {
|
|
125
|
+
// Keys are unique inside one attempt; a retry re-announces the same
|
|
126
|
+
// claims, and a watcher must not mistake the second run of a claim for
|
|
127
|
+
// the settling of the first.
|
|
128
|
+
const stamped =
|
|
129
|
+
attempt > 1 && step.key ? { ...step, key: `try${attempt}-${step.key}` } : step;
|
|
130
|
+
record(collected, stamped);
|
|
131
|
+
emitEvent(events, { type: 'guard:step', name: guard.name, step: stamped });
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
outcome = await attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep);
|
|
98
135
|
if (outcome.ok) break;
|
|
99
136
|
if (opts.signal?.aborted) break;
|
|
100
137
|
}
|
|
@@ -108,6 +145,7 @@ export async function runGuards(project, app, guards, opts = {}) {
|
|
|
108
145
|
durationMs: Date.now() - startedAt,
|
|
109
146
|
attempts,
|
|
110
147
|
};
|
|
148
|
+
if (checks.length > 0) result.checks = checks;
|
|
111
149
|
|
|
112
150
|
if (outcome.ok) {
|
|
113
151
|
// Passing only on the second go is not passing. The flake register picks
|
|
@@ -127,6 +165,29 @@ export async function runGuards(project, app, guards, opts = {}) {
|
|
|
127
165
|
return results;
|
|
128
166
|
}
|
|
129
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Put one step into the list it belongs to.
|
|
170
|
+
*
|
|
171
|
+
* A step is said twice — once as it starts, once as it finishes — and the list
|
|
172
|
+
* should hold one line per thing, not two. The settled version replaces the
|
|
173
|
+
* running one in place, so the order stays the order it happened in.
|
|
174
|
+
*
|
|
175
|
+
* @param {import('../types.js').CheckStep[]} into
|
|
176
|
+
* @param {import('../types.js').CheckStep} step
|
|
177
|
+
* @returns {void}
|
|
178
|
+
*/
|
|
179
|
+
function record(into, step) {
|
|
180
|
+
if (step.key) {
|
|
181
|
+
for (let i = 0; i < into.length; i++) {
|
|
182
|
+
if (into[i].key === step.key) {
|
|
183
|
+
into[i] = step;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
into.push(step);
|
|
189
|
+
}
|
|
190
|
+
|
|
130
191
|
/**
|
|
131
192
|
* @param {import('../types.js').RunEvents|undefined} events
|
|
132
193
|
* @param {GuardRunResult} result
|
|
@@ -141,6 +202,10 @@ function emitGuardDone(events, result) {
|
|
|
141
202
|
message: result.message,
|
|
142
203
|
failedAt: result.failedAt,
|
|
143
204
|
because: result.because,
|
|
205
|
+
// Everything this guard actually asserted, in its own words and its own
|
|
206
|
+
// order — so a listener that arrived late, or one that only keeps the
|
|
207
|
+
// verdicts, still has the working.
|
|
208
|
+
checks: result.checks,
|
|
144
209
|
});
|
|
145
210
|
}
|
|
146
211
|
|
|
@@ -152,12 +217,26 @@ function emitGuardDone(events, result) {
|
|
|
152
217
|
* @param {import('../types.js').Guard} guard
|
|
153
218
|
* @param {string|undefined} baseUrl
|
|
154
219
|
* @param {number} timeoutMs
|
|
220
|
+
* @param {StepSink} [onStep]
|
|
155
221
|
* @returns {Promise<AttemptOutcome>}
|
|
156
222
|
*/
|
|
157
|
-
async function attemptGuard(project, app, guard, baseUrl, timeoutMs) {
|
|
223
|
+
async function attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep) {
|
|
158
224
|
/** @type {ReturnType<typeof setTimeout>|undefined} */
|
|
159
225
|
let timer;
|
|
160
226
|
|
|
227
|
+
/**
|
|
228
|
+
* @param {import('../types.js').CheckStep} step
|
|
229
|
+
* @returns {void}
|
|
230
|
+
*/
|
|
231
|
+
const tell = (step) => {
|
|
232
|
+
if (!onStep) return;
|
|
233
|
+
try {
|
|
234
|
+
onStep(step);
|
|
235
|
+
} catch {
|
|
236
|
+
// Watching a guard must never be able to fail one.
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
161
240
|
try {
|
|
162
241
|
await Promise.race([
|
|
163
242
|
(async () => {
|
|
@@ -171,10 +250,29 @@ async function attemptGuard(project, app, guard, baseUrl, timeoutMs) {
|
|
|
171
250
|
// own, which is the single most confusing shape a failure can take. So an
|
|
172
251
|
// Electron window is reloaded instead. Its main process keeps whatever it
|
|
173
252
|
// was holding; only the screen goes back to how it opened.
|
|
174
|
-
|
|
175
|
-
|
|
253
|
+
const fresh = 'started from a clean screen';
|
|
254
|
+
tell({ key: FRESH_KEY, label: fresh, state: 'running' });
|
|
255
|
+
try {
|
|
256
|
+
if (baseUrl) await app.page.goto(baseUrl);
|
|
257
|
+
else await resetWindow(app);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
tell({
|
|
260
|
+
key: FRESH_KEY,
|
|
261
|
+
label: fresh,
|
|
262
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
263
|
+
state: 'bad',
|
|
264
|
+
});
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
tell({
|
|
268
|
+
key: FRESH_KEY,
|
|
269
|
+
label: fresh,
|
|
270
|
+
detail: baseUrl ? 'back to the front door' : 'the window was reloaded',
|
|
271
|
+
state: 'ok',
|
|
272
|
+
});
|
|
273
|
+
|
|
176
274
|
clearConsole(app);
|
|
177
|
-
await guard.run(makeGuardApi(app.page, project));
|
|
275
|
+
await guard.run(makeGuardApi(app.page, project, onStep ? { onStep } : {}));
|
|
178
276
|
})(),
|
|
179
277
|
new Promise((_resolve, reject) => {
|
|
180
278
|
timer = setTimeout(() => {
|