staysfixed 0.7.2 → 0.9.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 +429 -0
- package/README.md +193 -57
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +19 -6
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +564 -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/status.js +45 -1
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +54 -3
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +70 -3
- package/src/report/console.js +50 -6
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +18 -11
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +783 -71
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +134 -24
- package/src/v2/adapters/web.js +149 -18
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +66 -3
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +653 -69
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +65 -42
- package/src/v2/cluster.js +220 -14
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +353 -54
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +183 -66
- 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 +90 -16
- 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 +160 -24
- 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 +55 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"pixelmatch": "^7.2.0",
|
|
58
|
+
"playwright-core": "^1.56.0",
|
|
58
59
|
"pngjs": "^7.0.0"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
package/src/cli/index.js
CHANGED
|
@@ -12,6 +12,8 @@ import { setLogLevel } from '../core/log.js';
|
|
|
12
12
|
import { V2_COMMANDS } from '../v2/cli.js';
|
|
13
13
|
import { SHIP_COMMANDS } from '../v2/ship.js';
|
|
14
14
|
import { INIT_COMMANDS } from '../v2/init.js';
|
|
15
|
+
import { BROWSERS_COMMAND } from '../v2/browsers.js';
|
|
16
|
+
export { watchFlags } from './watch-flags.js';
|
|
15
17
|
|
|
16
18
|
/** @type {{version?: string}} */
|
|
17
19
|
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
@@ -61,9 +63,22 @@ export const VERSION = pkg.version ?? '0.0.0';
|
|
|
61
63
|
* @property {() => Promise<{run: (ctx: CliContext) => Promise<number>}>} [load]
|
|
62
64
|
*/
|
|
63
65
|
|
|
64
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* The flags every command takes.
|
|
68
|
+
*
|
|
69
|
+
* `no-color` is declared as its own switch rather than as `color`, and that is not a
|
|
70
|
+
* spelling choice. Colour is settled in bin/staysfixed.js, before anything else is
|
|
71
|
+
* imported, because src/core/log.js decides once at load whether it may paint — so by the
|
|
72
|
+
* time a command is parsed the answer is already fixed and nothing here could change it.
|
|
73
|
+
* Declaring `color` made `--color` a real flag that turned nothing on, which is the worst
|
|
74
|
+
* kind: a person types it, the tool accepts it, and nothing happens. There is no way to
|
|
75
|
+
* force colour ON from here, so the only honest thing to offer is the half that works.
|
|
76
|
+
* `--no-color` is named so `--help` and the parser agree; the work is already done.
|
|
77
|
+
*
|
|
78
|
+
* @type {ArgSpec}
|
|
79
|
+
*/
|
|
65
80
|
const GLOBAL_SPEC = {
|
|
66
|
-
booleans: ['verbose', 'quiet', 'help', 'version', 'color'],
|
|
81
|
+
booleans: ['verbose', 'quiet', 'help', 'version', 'no-color'],
|
|
67
82
|
strings: ['config', 'cwd'],
|
|
68
83
|
alias: { v: 'verbose', q: 'quiet', h: 'help', V: 'version' },
|
|
69
84
|
};
|
|
@@ -76,7 +91,7 @@ const GLOBAL_VALUE_FLAGS = new Set(['--config', '--cwd']);
|
|
|
76
91
|
* are declared once here so the two commands cannot drift apart.
|
|
77
92
|
*/
|
|
78
93
|
const WATCH_SPEC = {
|
|
79
|
-
booleans: ['watch', 'watch-front', 'keep-open', 'profile'],
|
|
94
|
+
booleans: ['watch', 'watch-front', 'keep-open', 'profile', 'snap'],
|
|
80
95
|
strings: ['watch-side', 'watch-width'],
|
|
81
96
|
};
|
|
82
97
|
|
|
@@ -86,12 +101,20 @@ const WATCH_OPTIONS = [
|
|
|
86
101
|
['--watch-side <side>', 'Which side of the app the panel sits on: left or right. Default right.'],
|
|
87
102
|
['--watch-width <n>', 'How wide the panel is, in pixels. Default 460.'],
|
|
88
103
|
['--no-keep-open', 'Close the panel as soon as the run finishes.'],
|
|
104
|
+
['--no-snap', 'Leave both windows exactly where they are instead of putting them side by side.'],
|
|
89
105
|
['--watch-front', 'Bring the panel to the front. By default it opens behind your work.'],
|
|
90
106
|
['--profile', 'Print where the time went when the run is over.'],
|
|
91
107
|
];
|
|
92
108
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Every command, and the only list of them. `--help` is printed from this, and every
|
|
111
|
+
* flag any command accepts is declared in its `spec` here — so a flag that prints in the
|
|
112
|
+
* help and a flag the parser knows about cannot drift apart. It is exported so that can
|
|
113
|
+
* be checked from outside rather than by reading two lists side by side.
|
|
114
|
+
*
|
|
115
|
+
* @type {Record<string, CommandEntry>}
|
|
116
|
+
*/
|
|
117
|
+
export const COMMANDS = {
|
|
95
118
|
init: {
|
|
96
119
|
summary: 'Set this project up. Takes about thirty seconds.',
|
|
97
120
|
usage: 'staysfixed init [--force] [--json]',
|
|
@@ -227,6 +250,16 @@ const COMMANDS = {
|
|
|
227
250
|
examples: ['staysfixed mcp', 'staysfixed mcp --v1'],
|
|
228
251
|
spec: { booleans: ['v1'] },
|
|
229
252
|
},
|
|
253
|
+
|
|
254
|
+
/*
|
|
255
|
+
* `browsers` was written, tested, given a finished command entry in src/v2/browsers.js
|
|
256
|
+
* with a comment saying "wiring it up is one line" — and that line was never written.
|
|
257
|
+
* README.md told people to run `npx staysfixed browsers` and `--clean` to tidy up after
|
|
258
|
+
* an interrupted run, and both answered "There is no command called browsers". Somebody
|
|
259
|
+
* whose disk was filling with abandoned browser profiles had no way to clear them and no
|
|
260
|
+
* reason to doubt the page telling them there was.
|
|
261
|
+
*/
|
|
262
|
+
browsers: BROWSERS_COMMAND,
|
|
230
263
|
};
|
|
231
264
|
|
|
232
265
|
/*
|
|
@@ -352,45 +385,8 @@ function contextFor(parsed, cwd, configFile) {
|
|
|
352
385
|
* @property {boolean} [foreground]
|
|
353
386
|
*/
|
|
354
387
|
|
|
355
|
-
|
|
356
|
-
* Read the panel flags. Shared by `check` and `walk` so the two behave the same.
|
|
357
|
-
* @param {CliContext} ctx
|
|
358
|
-
* @returns {WatchFlags}
|
|
359
|
-
*/
|
|
360
|
-
export function watchFlags(ctx) {
|
|
361
|
-
/** @type {WatchFlags} */
|
|
362
|
-
const flags = { enabled: ctx.bool('watch') };
|
|
363
|
-
|
|
364
|
-
const side = ctx.str('watch-side');
|
|
365
|
-
if (side !== undefined) {
|
|
366
|
-
if (side !== 'left' && side !== 'right') {
|
|
367
|
-
throw new StaysFixedError(`--watch-side has to be left or right, not "${side}".`, {
|
|
368
|
-
hint: 'Write it as `--watch-side left` or `--watch-side right`.',
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
flags.side = side;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const width = ctx.str('watch-width');
|
|
375
|
-
if (width !== undefined) {
|
|
376
|
-
const n = Number(width);
|
|
377
|
-
// A panel narrower than this cannot show the before-and-after pictures side
|
|
378
|
-
// by side, which is the only reason to open it.
|
|
379
|
-
if (!Number.isFinite(n) || n < 240) {
|
|
380
|
-
throw new StaysFixedError(`--watch-width has to be a number of pixels, 240 or more — I got "${width}".`, {
|
|
381
|
-
hint: 'Write it as `--watch-width 520`.',
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
flags.width = Math.round(n);
|
|
385
|
-
}
|
|
388
|
+
// `watchFlags` moved to ./watch-flags.js — see the note there about the import cycle.
|
|
386
389
|
|
|
387
|
-
// Only mention these when they were actually typed, so --no-keep-open turns the
|
|
388
|
-
// panel off at the end without a bare --watch turning it on against the settings.
|
|
389
|
-
if (ctx.flags['keep-open'] !== undefined) flags.keepOpen = ctx.flags['keep-open'] === true;
|
|
390
|
-
if (ctx.bool('watch-front')) flags.foreground = true;
|
|
391
|
-
|
|
392
|
-
return flags;
|
|
393
|
-
}
|
|
394
390
|
|
|
395
391
|
/**
|
|
396
392
|
* The panel settings a project's settings file carries, if it carries any.
|
|
@@ -446,15 +442,28 @@ function splitCommand(argv) {
|
|
|
446
442
|
}
|
|
447
443
|
|
|
448
444
|
/**
|
|
445
|
+
* The global flags plus one command's own — with the command winning any name they share.
|
|
446
|
+
*
|
|
447
|
+
* That last part is the whole reason this is not a concatenation. `--version` is global and
|
|
448
|
+
* means "print the tool's version"; `staysfixed ship --version 0.14.0` means "the release
|
|
449
|
+
* that went out was called 0.14.0", and it is in that command's own help. Merged naively,
|
|
450
|
+
* the name landed in both lists, the parser reads booleans first, and `staysfixed ship
|
|
451
|
+
* --version 0.14.0` printed `0.7.2` and shipped nothing at all — no error, no clue, and the
|
|
452
|
+
* release script that called it carried on. A command's own list of flags is the more
|
|
453
|
+
* specific statement of what that command means, so it wins.
|
|
454
|
+
*
|
|
449
455
|
* @param {ArgSpec} base
|
|
450
456
|
* @param {ArgSpec} extra
|
|
451
457
|
* @returns {ArgSpec}
|
|
452
458
|
*/
|
|
453
459
|
function mergeSpec(base, extra) {
|
|
460
|
+
const claimed = new Set([...(extra.booleans ?? []), ...(extra.strings ?? []), ...(extra.arrays ?? [])]);
|
|
461
|
+
/** @param {string[]|undefined} names */
|
|
462
|
+
const keep = (names) => (names ?? []).filter((name) => !claimed.has(name));
|
|
454
463
|
return {
|
|
455
|
-
booleans: [...(base.booleans
|
|
456
|
-
strings: [...(base.strings
|
|
457
|
-
arrays: [...(base.arrays
|
|
464
|
+
booleans: [...keep(base.booleans), ...(extra.booleans ?? [])],
|
|
465
|
+
strings: [...keep(base.strings), ...(extra.strings ?? [])],
|
|
466
|
+
arrays: [...keep(base.arrays), ...(extra.arrays ?? [])],
|
|
458
467
|
alias: { ...(base.alias ?? {}), ...(extra.alias ?? {}) },
|
|
459
468
|
};
|
|
460
469
|
}
|
package/src/cli/status.js
CHANGED
|
@@ -2,11 +2,55 @@
|
|
|
2
2
|
* `staysfixed status` — reads what is on disk and says it. Launches nothing.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
5
8
|
import { loadProject } from '../core/config.js';
|
|
6
9
|
import { projectStatus } from '../run.js';
|
|
7
10
|
import { printStatus } from '../report/console.js';
|
|
8
11
|
import { EXIT } from '../core/errors.js';
|
|
9
12
|
|
|
13
|
+
/**
|
|
14
|
+
* What version 2 has recorded here, if anything.
|
|
15
|
+
*
|
|
16
|
+
* `status` only ever counted version 1's things — approved pictures, screens, guards,
|
|
17
|
+
* markers — so on a project that had just been checked and shipped it said "Nothing has been
|
|
18
|
+
* checked here yet. Start with: staysfixed check". Measured on 2026-08-30, one command after
|
|
19
|
+
* a run that walked 36 addresses and a ship that cut the reference. The command whose whole
|
|
20
|
+
* promise is to say instantly what is going on here was the one saying nothing had happened.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} root
|
|
23
|
+
* @returns {{at: string, verdict: string, reference: string|null, findings: number}|null}
|
|
24
|
+
*/
|
|
25
|
+
export function versionTwoState(root) {
|
|
26
|
+
try {
|
|
27
|
+
const file = path.join(root, '.staysfixed', 'v2', 'last-check.json');
|
|
28
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
if (!raw || typeof raw.at !== 'string') return null;
|
|
30
|
+
// The reference is read from where it is KEPT, not from the last check's memory of it.
|
|
31
|
+
// A check writes what it compared against at the time; ship cuts a reference after
|
|
32
|
+
// that, so on the ordinary first-run order — check, then ship — the check's field still
|
|
33
|
+
// says "no-reference-yet" while a reference plainly exists.
|
|
34
|
+
let reference = null;
|
|
35
|
+
try {
|
|
36
|
+
const cuts = JSON.parse(fs.readFileSync(path.join(root, '.staysfixed', 'v2', 'reference-log.json'), 'utf8'));
|
|
37
|
+
const newest = Array.isArray(cuts) && cuts.length ? cuts[cuts.length - 1] : null;
|
|
38
|
+
if (newest && typeof newest.id === 'string') reference = newest.id;
|
|
39
|
+
} catch {
|
|
40
|
+
if (typeof raw.reference === 'string' && raw.reference !== 'no-reference-yet') reference = raw.reference;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
at: raw.at,
|
|
44
|
+
verdict: typeof raw.verdict === 'string' ? raw.verdict : 'ran',
|
|
45
|
+
reference,
|
|
46
|
+
findings: Array.isArray(raw.findings) ? raw.findings.length : 0,
|
|
47
|
+
};
|
|
48
|
+
} catch {
|
|
49
|
+
// Nothing recorded, or nothing readable. Either way there is nothing to add.
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
10
54
|
/**
|
|
11
55
|
* @param {import('./index.js').CliContext} ctx
|
|
12
56
|
* @returns {Promise<number>}
|
|
@@ -14,6 +58,6 @@ import { EXIT } from '../core/errors.js';
|
|
|
14
58
|
export async function run(ctx) {
|
|
15
59
|
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
|
|
16
60
|
const status = await projectStatus(project);
|
|
17
|
-
printStatus(/** @type {any} */ (status));
|
|
61
|
+
printStatus(/** @type {any} */ ({ ...status, v2: versionTwoState(project.paths?.root ?? ctx.cwd) }));
|
|
18
62
|
return EXIT.ok;
|
|
19
63
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the --watch flags off a command line.
|
|
3
|
+
*
|
|
4
|
+
* This lives on its own, and not in `src/cli/index.js` where it started, because both
|
|
5
|
+
* halves of the tool need it and the import went in a circle: version 1's command table
|
|
6
|
+
* imports version 2's commands, and version 2's command file imported this back out of
|
|
7
|
+
* version 1's table. That worked only while the modules happened to load in a helpful
|
|
8
|
+
* order — the day another import was added to version 2, the whole command line failed
|
|
9
|
+
* with "cannot access V2_COMMANDS before initialization" and nothing ran at all.
|
|
10
|
+
*
|
|
11
|
+
* A shared thing that both sides need belongs to neither of them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { StaysFixedError } from '../core/errors.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read the panel flags. Shared by `check` and `walk` so the two behave the same.
|
|
18
|
+
* @param {import('./index.js').CliContext} ctx
|
|
19
|
+
* @returns {import('./index.js').WatchFlags}
|
|
20
|
+
*/
|
|
21
|
+
export function watchFlags(ctx) {
|
|
22
|
+
/** @type {import('./index.js').WatchFlags} */
|
|
23
|
+
const flags = { enabled: ctx.bool('watch') };
|
|
24
|
+
|
|
25
|
+
const side = ctx.str('watch-side');
|
|
26
|
+
if (side !== undefined) {
|
|
27
|
+
if (side !== 'left' && side !== 'right') {
|
|
28
|
+
throw new StaysFixedError(`--watch-side has to be left or right, not "${side}".`, {
|
|
29
|
+
hint: 'Write it as `--watch-side left` or `--watch-side right`.',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
flags.side = side;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const width = ctx.str('watch-width');
|
|
36
|
+
if (width !== undefined) {
|
|
37
|
+
const n = Number(width);
|
|
38
|
+
// A panel narrower than this cannot show the before-and-after pictures side
|
|
39
|
+
// by side, which is the only reason to open it.
|
|
40
|
+
if (!Number.isFinite(n) || n < 240) {
|
|
41
|
+
throw new StaysFixedError(`--watch-width has to be a number of pixels, 240 or more — I got "${width}".`, {
|
|
42
|
+
hint: 'Write it as `--watch-width 520`.',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
flags.width = Math.round(n);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Only mention these when they were actually typed, so --no-keep-open turns the
|
|
49
|
+
// panel off at the end without a bare --watch turning it on against the settings.
|
|
50
|
+
if (ctx.flags['keep-open'] !== undefined) flags.keepOpen = ctx.flags['keep-open'] === true;
|
|
51
|
+
if (ctx.bool('watch-front')) flags.foreground = true;
|
|
52
|
+
|
|
53
|
+
return flags;
|
|
54
|
+
}
|
package/src/core/config.js
CHANGED
|
@@ -49,9 +49,29 @@ export const DEFAULT_FREEZE = {
|
|
|
49
49
|
|
|
50
50
|
/** @type {Required<Omit<import('../types.js').ToleranceConfig,'maxPixels'>>} */
|
|
51
51
|
export const DEFAULT_TOLERANCE = {
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
52
|
+
// Nothing is allowed through by default, and that is a change made after measuring.
|
|
53
|
+
//
|
|
54
|
+
// This used to be 0.0005 — 0.05% of the picture — with a comment saying it was "enough for
|
|
55
|
+
// font hinting noise, nowhere near enough to hide a missing stylesheet". The first half was
|
|
56
|
+
// a guess and the second half was wrong. On a 2880x1800 picture, 0.05% is **2,592 pixels**.
|
|
57
|
+
// Changing `<h1>Welcome</h1>` to `<h1>Welcom</h1>` — one letter missing from the main
|
|
58
|
+
// heading of the page, plainly visible to anybody looking at it — moves **593**. So the
|
|
59
|
+
// check reported "Everything that worked still works" over a page that was visibly wrong.
|
|
60
|
+
// That is the exact failure this tool exists to prevent, produced by the tool itself.
|
|
61
|
+
//
|
|
62
|
+
// The number that replaces it was measured rather than chosen. Ten fresh takes of the same
|
|
63
|
+
// build, on a real page, compared against the approved picture: **zero differing pixels,
|
|
64
|
+
// every time.** The freeze layer underneath — the stopped clock, the killed motion, the
|
|
65
|
+
// seeded randomness, the pinned text rendering, and the settle loop that keeps shooting
|
|
66
|
+
// until two frames come back identical — is what makes that true. Where nothing wobbles,
|
|
67
|
+
// an allowance buys nothing at all and costs you the one thing you came for.
|
|
68
|
+
//
|
|
69
|
+
// Version 2 answers this properly by measuring each product's own wobble and subtracting
|
|
70
|
+
// it, which is why it has no tolerance setting and never will. Version 1 cannot do that
|
|
71
|
+
// without becoming version 2, so it does the honest next-best thing: allow nothing, and
|
|
72
|
+
// let a project that genuinely wobbles say so out loud with `tolerance.pixels`. A run that
|
|
73
|
+
// uses an allowance now says so, and says how much of it was used.
|
|
74
|
+
pixels: 0,
|
|
55
75
|
threshold: 0.12,
|
|
56
76
|
antialiasing: true,
|
|
57
77
|
};
|
|
@@ -126,6 +146,27 @@ export function resolveConfig(raw, file = '(inline)') {
|
|
|
126
146
|
}
|
|
127
147
|
const c = /** @type {import('../types.js').StaysFixedConfig} */ (raw);
|
|
128
148
|
|
|
149
|
+
// Version 2's settings describe a website under `web:` and a desktop app under
|
|
150
|
+
// `electron:`. These commands are version 1's and only ever knew about `app:` — so on the
|
|
151
|
+
// settings file `staysfixed init` writes for a website, `status`, `walk` and `flake` all
|
|
152
|
+
// answered "these settings do not name anything to open", and then listed `process,
|
|
153
|
+
// source` as the shape of the project. `init` had said, one command earlier, "The website
|
|
154
|
+
// can be checked here now ... watched by opening it in a throwaway browser". Both about
|
|
155
|
+
// the same file, seconds apart, and `status` is the command whose whole promise is to say
|
|
156
|
+
// instantly what is set up here.
|
|
157
|
+
//
|
|
158
|
+
// Where the address is actually knowable, take it and let the command work. Booting is
|
|
159
|
+
// version 2's job and these commands cannot do it, so `web.start` alone is not enough —
|
|
160
|
+
// that case falls through to the message below, which now says so honestly.
|
|
161
|
+
if ((!c.app || typeof c.app !== 'object')) {
|
|
162
|
+
const v2 = /** @type {Record<string, any>} */ (/** @type {unknown} */ (c));
|
|
163
|
+
if (v2.web && typeof v2.web === 'object' && typeof v2.web.url === 'string' && v2.web.url) {
|
|
164
|
+
c.app = { kind: 'web', url: v2.web.url };
|
|
165
|
+
} else if (v2.electron && typeof v2.electron === 'object' && typeof v2.electron.binary === 'string' && v2.electron.binary) {
|
|
166
|
+
c.app = { kind: 'electron', binary: v2.electron.binary };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
129
170
|
if (!c.app || typeof c.app !== 'object') {
|
|
130
171
|
// Every command that lands here — status, walk, approve, mark, trace, flake, and
|
|
131
172
|
// `check --pictures` — works by OPENING something and photographing it. A settings
|
|
@@ -135,6 +176,16 @@ export function resolveConfig(raw, file = '(inline)') {
|
|
|
135
176
|
// half of the tool needs it, and names the half that does not.
|
|
136
177
|
const anything = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (c));
|
|
137
178
|
const notVisual = ['process', 'http', 'source', 'android', 'ios', 'windows'].filter((k) => anything[k] && typeof anything[k] === 'object');
|
|
179
|
+
// A project that DOES have a screen, described the version 2 way, must never be told it
|
|
180
|
+
// has none. It is told the true thing instead: this half of the tool photographs an
|
|
181
|
+
// address you can point it at, and version 2 finds the address by booting the product,
|
|
182
|
+
// which is why `check` covers it and these do not.
|
|
183
|
+
const started = anything.web && typeof anything.web === 'object' && typeof (/** @type {any} */ (anything.web).start) === 'string';
|
|
184
|
+
if (started) {
|
|
185
|
+
throw new StaysFixedError('This project has a website, but these settings start it rather than name an address, and this command photographs an address.', {
|
|
186
|
+
hint: "`staysfixed check` covers it exactly as it is — it boots `web.start` and finds the address itself. These picture commands need one they can point at, so add `url: 'http://localhost:3000'` beside `start` in the `web` block if you want them too.",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
138
189
|
throw new StaysFixedError('These settings do not name anything to open, and this command works by opening your product and photographing it.', {
|
|
139
190
|
hint: notVisual.length
|
|
140
191
|
? `That is the right shape for what this project is — ${notVisual.join(', ')} settings need nothing to open. Run \`staysfixed check\`, which covers it without a picture. If there IS a screen here too, add \`app: { kind: 'web', url: 'http://localhost:3000' }\` or \`app: { kind: 'electron', binary: '...' }\`.`
|
package/src/core/paths.js
CHANGED
|
@@ -139,4 +139,19 @@ export const GITIGNORE_LINES = [
|
|
|
139
139
|
// Where one person dragged the watch panel on one screen. Nobody else's business,
|
|
140
140
|
// and it would otherwise turn up in their commits.
|
|
141
141
|
'.staysfixed/watch-window.json',
|
|
142
|
+
// Version 2's evidence, which none of the lines above match — they were all written for
|
|
143
|
+
// version 1's folders. Measured 2026-08-30 on a Next.js project: 151 untracked files and
|
|
144
|
+
// 1.9 MB of run evidence sitting in `git status` after nine checks, and every release then
|
|
145
|
+
// warning that it was made from a dirty tree. `builds/` is the bulk of it: one folder per
|
|
146
|
+
// build, holding what each run observed.
|
|
147
|
+
'.staysfixed/v2/builds/',
|
|
148
|
+
'.staysfixed/v2/last-check.json',
|
|
149
|
+
// The lock a run holds while it changes the record. Never anybody's to commit.
|
|
150
|
+
'.staysfixed/**/*.lock',
|
|
142
151
|
];
|
|
152
|
+
|
|
153
|
+
/*
|
|
154
|
+
* Deliberately NOT ignored: `references.json` and `reference-log.json`. Those are the record
|
|
155
|
+
* of what this product calls working and which release said so — the one thing here a team
|
|
156
|
+
* genuinely shares, and the thing a new clone needs in order to compare against anything.
|
|
157
|
+
*/
|
package/src/guard/run.js
CHANGED
|
@@ -28,6 +28,7 @@ const FRESH_KEY = 'fresh';
|
|
|
28
28
|
/**
|
|
29
29
|
* @typedef {import('../types.js').GuardResult & {
|
|
30
30
|
* retriedToPass?: boolean,
|
|
31
|
+
* assertedNothing?: boolean,
|
|
31
32
|
* checks?: import('../types.js').CheckStep[],
|
|
32
33
|
* }} GuardRunResult
|
|
33
34
|
*/
|
|
@@ -136,10 +137,22 @@ export async function runGuards(project, app, guards, opts = {}) {
|
|
|
136
137
|
if (opts.signal?.aborted) break;
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
// A guard that asserted NOTHING has not held. It cannot hold, and it cannot fail — it is
|
|
141
|
+
// a name over an empty room. Measured on 2026-08-30: a guard called "the checkout total
|
|
142
|
+
// is never charged twice", whose `run()` was an empty function, came back as
|
|
143
|
+
// "ok ... still holds". That is a false all-clear wearing the friendliest face this tool
|
|
144
|
+
// has, and it would go on saying it every day for ever. The whole promise here is one
|
|
145
|
+
// plain-English rule per bug somebody already had; a rule that checks nothing is worse
|
|
146
|
+
// than no rule, because somebody believes it.
|
|
147
|
+
// Its OWN questions, not the runner's. Every guard gets a "fresh start" step from this
|
|
148
|
+
// file whether it asks anything or not, so counting the whole list would always find one.
|
|
149
|
+
const asked = checks.filter((c) => c.key !== FRESH_KEY && !String(c.key ?? '').endsWith(`-${FRESH_KEY}`));
|
|
150
|
+
const assertedNothing = outcome.ok && asked.length === 0;
|
|
151
|
+
|
|
139
152
|
/** @type {GuardRunResult} */
|
|
140
153
|
const result = {
|
|
141
154
|
name: guard.name,
|
|
142
|
-
status: outcome.ok ? 'passed' : 'failed',
|
|
155
|
+
status: outcome.ok && !assertedNothing ? 'passed' : 'failed',
|
|
143
156
|
file: guard.file,
|
|
144
157
|
because: guard.because,
|
|
145
158
|
durationMs: Date.now() - startedAt,
|
|
@@ -147,7 +160,13 @@ export async function runGuards(project, app, guards, opts = {}) {
|
|
|
147
160
|
};
|
|
148
161
|
if (checks.length > 0) result.checks = checks;
|
|
149
162
|
|
|
150
|
-
if (
|
|
163
|
+
if (assertedNothing) {
|
|
164
|
+
result.assertedNothing = true;
|
|
165
|
+
result.message =
|
|
166
|
+
`This guard checked nothing. Its \`run()\` finished without asking a single question, so it cannot fail ` +
|
|
167
|
+
`and it is not protecting anything — it would report "still holds" every day for ever. ` +
|
|
168
|
+
`Give it at least one \`expect(...)\`. ${guard.because ? `What it is meant to protect: ${guard.because}` : ''}`.trim();
|
|
169
|
+
} else if (outcome.ok) {
|
|
151
170
|
// Passing only on the second go is not passing. The flake register picks
|
|
152
171
|
// this up and condemns the guard, because a guard nobody trusts is worse
|
|
153
172
|
// than no guard: people learn to re-run it until it goes green.
|
|
@@ -289,7 +308,7 @@ async function attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep) {
|
|
|
289
308
|
};
|
|
290
309
|
}
|
|
291
310
|
const raw = error instanceof Error ? error.message : String(error);
|
|
292
|
-
return { ok: false, message: `${raw}${consoleNote(app)}` };
|
|
311
|
+
return { ok: false, message: `${explainApiSlip(raw, app.page, project)}${consoleNote(app)}` };
|
|
293
312
|
} finally {
|
|
294
313
|
// The losing side of the race keeps running otherwise, and a stray timer
|
|
295
314
|
// holds the process open long after the run is reported.
|
|
@@ -299,6 +318,54 @@ async function attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep) {
|
|
|
299
318
|
return { ok: true };
|
|
300
319
|
}
|
|
301
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Turn "page.goto is not a function" into one sentence a person can act on.
|
|
323
|
+
*
|
|
324
|
+
* A guard is the first code most people write against this tool, and the object it is handed
|
|
325
|
+
* is not the shape anybody arrives expecting. Reach for a name from a browser library that is
|
|
326
|
+
* not there and JavaScript answers with its own sentence, which is true, useless, and exactly
|
|
327
|
+
* the kind of raw error this project promises never to print. The first guard written against
|
|
328
|
+
* it while proving the tool still worked failed this way.
|
|
329
|
+
*
|
|
330
|
+
* Two things it must not do, both learned by getting them wrong first:
|
|
331
|
+
*
|
|
332
|
+
* - **Do not trust the receiver's name.** The guard above called its one parameter `page`,
|
|
333
|
+
* so the error read `page.goto is not a function` — but the parameter holds `app`, and the
|
|
334
|
+
* honest answer is `app.open()`. Reading that name as if it meant the page suggested
|
|
335
|
+
* "did you mean goto()", which is the very thing they had just written.
|
|
336
|
+
* - **Do not list every method.** An earlier version printed all thirty names on the page
|
|
337
|
+
* handle inline. It was complete, unreadable, and it destroyed the results table it sat in.
|
|
338
|
+
*
|
|
339
|
+
* So: name the six things on `app`, say where the rest live, and stop.
|
|
340
|
+
*
|
|
341
|
+
* @param {string} raw
|
|
342
|
+
* @param {import('../types.js').PageHandle} page
|
|
343
|
+
* @param {import('../types.js').Project} project
|
|
344
|
+
* @returns {string}
|
|
345
|
+
*/
|
|
346
|
+
export function explainApiSlip(raw, page, project) {
|
|
347
|
+
const missing = /^(?:\w+\.)?(\w+) is not a function$/.exec(String(raw || ''));
|
|
348
|
+
if (!missing) return raw;
|
|
349
|
+
const method = missing[1];
|
|
350
|
+
const api = makeGuardApi(page, project, {});
|
|
351
|
+
const onApp = Object.keys(api).filter((k) => typeof (/** @type {any} */ (api))[k] === 'function').sort();
|
|
352
|
+
if (onApp.includes(method)) return raw;
|
|
353
|
+
|
|
354
|
+
// Only ever suggested from what `app` itself offers, and only when one name is clearly the
|
|
355
|
+
// one meant. A guess between three is worse than no guess.
|
|
356
|
+
const near = onApp.filter((n) => n.toLowerCase().includes(method.toLowerCase()) || method.toLowerCase().includes(n.toLowerCase()));
|
|
357
|
+
const browserish = /^(goto|navigate|visit|load|open|click|type|fill|press|hover|wait|screenshot|querySelector|\$)/i.test(method);
|
|
358
|
+
const meant = near.length === 1 ? ` You probably want \`app.${near[0]}()\`.`
|
|
359
|
+
: browserish ? ' To go to a page it is `app.open(\'/path\')`; anything a browser does is on `app.page`.'
|
|
360
|
+
: '';
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
`This guard called \`${method}()\` on what it was handed, and there is no such thing there.${meant} ` +
|
|
364
|
+
`A guard is given one object — call it \`app\` — with ${onApp.map((n) => `\`${n}()\``).join(', ')}. ` +
|
|
365
|
+
'The whole page is `app.page`. There is a worked example in `examples/guards/`.'
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
302
369
|
/** A timeout, kept apart from a real error so the wording stays ours. */
|
|
303
370
|
class TookTooLong extends Error {
|
|
304
371
|
/** @param {string} message */
|
package/src/report/console.js
CHANGED
|
@@ -124,7 +124,11 @@ function tally(run) {
|
|
|
124
124
|
missing: pictures.filter((p) => p.status === 'missing').length,
|
|
125
125
|
broken: pictures.filter((p) => p.status === 'failed').length,
|
|
126
126
|
wobbled: pictures.filter((p) => p.status === 'flaky').length,
|
|
127
|
-
|
|
127
|
+
// Two different things wear the same status, and calling both of them "a bug is back"
|
|
128
|
+
// sends somebody hunting a regression that never happened. A guard that asked no
|
|
129
|
+
// question at all has not caught anything; it has admitted it cannot.
|
|
130
|
+
guardsFailed: guards.filter((g) => g.status === 'failed' && !(/** @type {any} */ (g).assertedNothing)).length,
|
|
131
|
+
guardsEmpty: guards.filter((g) => /** @type {any} */ (g).assertedNothing === true).length,
|
|
128
132
|
};
|
|
129
133
|
}
|
|
130
134
|
|
|
@@ -139,6 +143,20 @@ export function verdictFor(run) {
|
|
|
139
143
|
const parts = [];
|
|
140
144
|
if (t.guardsFailed === 1) parts.push({ n: 1, text: '1 guard failed — a bug that was already fixed is back.' });
|
|
141
145
|
else if (t.guardsFailed > 1) parts.push({ n: t.guardsFailed, text: `${countText(t.guardsFailed)} guards failed — bugs that were already fixed are back.` });
|
|
146
|
+
// Said even on a green run, because that is the run it changes the meaning of.
|
|
147
|
+
const left = /** @type {any} */ (run).leftOut;
|
|
148
|
+
if (left && (left.screens > 0 || left.guards > 0)) {
|
|
149
|
+
const bits = [];
|
|
150
|
+
if (left.screens > 0) bits.push(`${left.screens} ${left.screens === 1 ? 'screen' : 'screens'}`);
|
|
151
|
+
if (left.guards > 0) bits.push(`${left.guards} ${left.guards === 1 ? 'guard' : 'guards'}`);
|
|
152
|
+
const how_many = (left.screens ?? 0) + (left.guards ?? 0);
|
|
153
|
+
parts.push({
|
|
154
|
+
n: 0,
|
|
155
|
+
text: `${bits.join(' and ')} ${how_many === 1 ? 'was' : 'were'} left out by --only, so this covers a slice and not the whole.`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (t.guardsEmpty === 1) parts.push({ n: 1, text: '1 guard checks nothing, so it is not protecting anything.' });
|
|
159
|
+
else if (t.guardsEmpty > 1) parts.push({ n: t.guardsEmpty, text: `${countText(t.guardsEmpty)} guards check nothing, so they are not protecting anything.` });
|
|
142
160
|
if (t.changed === 1) parts.push({ n: 1, text: '1 thing changed. Look at it before you ship.' });
|
|
143
161
|
else if (t.changed > 1) parts.push({ n: t.changed, text: `${countText(t.changed)} things changed. Look at them before you ship.` });
|
|
144
162
|
if (t.fresh === 1) parts.push({ n: 1, text: '1 new screen is waiting for a person to approve it.' });
|
|
@@ -162,9 +180,12 @@ export function verdictFor(run) {
|
|
|
162
180
|
* @param {import('../types.js').RunSummary} run
|
|
163
181
|
* @returns {boolean}
|
|
164
182
|
*/
|
|
165
|
-
function allClear(run) {
|
|
183
|
+
export function allClear(run) {
|
|
166
184
|
const t = tally(run);
|
|
167
|
-
|
|
185
|
+
// `guardsEmpty` counts too. Splitting it out of `guardsFailed` was so the SENTENCE could
|
|
186
|
+
// tell a returned bug from a guard that asks nothing — not so that one of them could
|
|
187
|
+
// quietly become a pass.
|
|
188
|
+
return t.changed + t.fresh + t.missing + t.broken + t.wobbled + t.guardsFailed + t.guardsEmpty === 0;
|
|
168
189
|
}
|
|
169
190
|
|
|
170
191
|
/**
|
|
@@ -210,9 +231,22 @@ export function printPictureResult(r) {
|
|
|
210
231
|
const time = paint.grey(duration(r.durationMs ?? 0));
|
|
211
232
|
const line = `${r.name.padEnd(NAME_WIDTH)} ${pictureOutcome(r)}`;
|
|
212
233
|
switch (r.status) {
|
|
213
|
-
case 'passed':
|
|
214
|
-
|
|
234
|
+
case 'passed': {
|
|
235
|
+
// "Still the same" has to mean the same, or it is the most expensive sentence here.
|
|
236
|
+
//
|
|
237
|
+
// A picture that differs and is waved through by an allowance was reported as
|
|
238
|
+
// identical, in the same words as one that matched byte for byte. That is how a
|
|
239
|
+
// missing letter in a heading — 593 pixels, plainly visible — came back as "still the
|
|
240
|
+
// same" while an allowance of 2,592 quietly absorbed it. Nothing is allowed through by
|
|
241
|
+
// default any more, so this is rare; when a project sets `tolerance.pixels` because its
|
|
242
|
+
// product genuinely wobbles, the line says what its setting just swallowed.
|
|
243
|
+
const swallowed = r.diffPixels ?? 0;
|
|
244
|
+
const note = swallowed > 0
|
|
245
|
+
? paint.grey(`the same, apart from ${swallowed} ${swallowed === 1 ? 'pixel your tolerance allowed' : 'pixels your tolerance allowed'}`)
|
|
246
|
+
: paint.grey('still the same');
|
|
247
|
+
say(`${paint.green(sym(mark.pass))} ${r.name.padEnd(NAME_WIDTH)} ${note} ${time}`);
|
|
215
248
|
break;
|
|
249
|
+
}
|
|
216
250
|
case 'changed':
|
|
217
251
|
say(`${paint.red(sym(mark.fail))} ${paint.red(line)} ${time}`);
|
|
218
252
|
if (r.approvedSize && r.size && (r.approvedSize.width !== r.size.width || r.approvedSize.height !== r.size.height)) {
|
|
@@ -548,6 +582,8 @@ export function printFlakes(history, flakeLimit = 2) {
|
|
|
548
582
|
* @property {number} [markers] Known-good markers saved.
|
|
549
583
|
* @property {{label: string, at?: string}|null} [lastMarker]
|
|
550
584
|
* @property {import('../types.js').RunSummary|null} [lastRun]
|
|
585
|
+
* @property {{at: string, verdict: string, reference: string|null, findings: number}|null} [v2]
|
|
586
|
+
* What version 2 has recorded here. Version 1's counts say nothing about it.
|
|
551
587
|
* @property {string[]} [condemned] Names from the flake register, if the CLI already has them.
|
|
552
588
|
* @property {string} [configFile]
|
|
553
589
|
* @property {string} [root]
|
|
@@ -578,7 +614,15 @@ export function printStatus(status) {
|
|
|
578
614
|
|
|
579
615
|
blank();
|
|
580
616
|
const run = s.lastRun ?? null;
|
|
581
|
-
if (!run) {
|
|
617
|
+
if (!run && s.v2) {
|
|
618
|
+
// Version 2 has run here even though version 1's picture record has not. Saying
|
|
619
|
+
// "nothing has been checked here yet" one command after a real run is the sort of
|
|
620
|
+
// wrongness that costs a person their trust in everything else the tool says.
|
|
621
|
+
say(paint.grey(` last checked ${ago(s.v2.at)} — ${s.v2.verdict}`));
|
|
622
|
+
if (s.v2.findings > 0) say(paint.grey(` ${s.v2.findings} ${plural(s.v2.findings, 'thing', 'things')} nobody had accounted for`));
|
|
623
|
+
if (s.v2.reference) say(paint.grey(` compared against ${s.v2.reference}`));
|
|
624
|
+
else say(` Nothing is on record as working yet — run ${paint.cyan('staysfixed check')}, then ${paint.cyan('staysfixed ship')}.`);
|
|
625
|
+
} else if (!run) {
|
|
582
626
|
say(' Nothing has been checked here yet.');
|
|
583
627
|
say(` Start with: ${paint.cyan('staysfixed check')}`);
|
|
584
628
|
} else {
|
package/src/run.js
CHANGED
|
@@ -226,6 +226,17 @@ export async function runCheck(project, opts = {}) {
|
|
|
226
226
|
tool: TOOL,
|
|
227
227
|
platform: platformTag(),
|
|
228
228
|
condemned: condemnedNames,
|
|
229
|
+
// What `--only` left out. A narrowed run that says "everything that worked still works"
|
|
230
|
+
// is describing a slice and sounding like the whole: measured 2026-08-30 with five of
|
|
231
|
+
// six guards filtered away and one of the five failing, and the run still exited 0
|
|
232
|
+
// saying everything works. A pass has to carry the size of what it looked at.
|
|
233
|
+
leftOut: terms
|
|
234
|
+
? {
|
|
235
|
+
screens: Math.max(0, allScreens.length - screens.length),
|
|
236
|
+
guards: Math.max(0, allGuards.length - guards.length),
|
|
237
|
+
terms,
|
|
238
|
+
}
|
|
239
|
+
: undefined,
|
|
229
240
|
// Read here rather than at the very end: what follows is writing files, and
|
|
230
241
|
// where the run spent its time is a fact about the run, not about the report.
|
|
231
242
|
timings: timings.get(),
|
package/src/types.js
CHANGED
|
@@ -328,6 +328,9 @@
|
|
|
328
328
|
* @property {string} tool
|
|
329
329
|
* @property {string} platform
|
|
330
330
|
* @property {string[]} [condemned] Names of checks that have flaked past the limit.
|
|
331
|
+
* @property {{screens: number, guards: number, terms: string[]}} [leftOut]
|
|
332
|
+
* What `--only` filtered away. A narrowed run that reads as a full pass is describing a
|
|
333
|
+
* slice and sounding like the whole.
|
|
331
334
|
*/
|
|
332
335
|
|
|
333
336
|
/**
|
|
@@ -1393,8 +1393,13 @@ export async function permissionsHeld(device, pkg) {
|
|
|
1393
1393
|
* and pids are stripped from the text kept, since all three differ on every run and none of
|
|
1394
1394
|
* them is ever the finding.
|
|
1395
1395
|
*
|
|
1396
|
+
* There is no time window here, and there used to be a `sinceMs` in this signature that
|
|
1397
|
+
* nothing read. The window comes from the other end: the adapter clears both log buffers
|
|
1398
|
+
* before it walks anything, so what is left is this run. An option that silently does
|
|
1399
|
+
* nothing is worse than no option, because a caller passing it believes it worked.
|
|
1400
|
+
*
|
|
1396
1401
|
* @param {Device} device
|
|
1397
|
-
* @param {{pkg: string, pid?: number
|
|
1402
|
+
* @param {{pkg: string, pid?: number}} what
|
|
1398
1403
|
* @returns {Promise<{crashes: string[], anrs: string[], errors: string[], lines: string[], raw: string}>}
|
|
1399
1404
|
*/
|
|
1400
1405
|
export async function complaints(device, what) {
|