staysfixed 0.1.0 → 0.2.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 +1 -1
- package/README.md +34 -11
- package/package.json +1 -1
- package/src/cli/check.js +71 -4
- package/src/cli/index.js +109 -6
- package/src/cli/init.js +3 -1
- package/src/cli/walk.js +66 -3
- package/src/core/events.js +171 -0
- package/src/core/paths.js +8 -1
- package/src/drive/page.js +60 -4
- package/src/freeze/fonts.js +131 -41
- package/src/freeze/settle.js +177 -2
- package/src/guard/run.js +41 -2
- package/src/picture/capture.js +59 -5
- package/src/picture/compare.js +60 -0
- package/src/picture/run.js +163 -26
- package/src/picture/store.js +65 -0
- package/src/report/console.js +80 -1
- package/src/run.js +229 -39
- package/src/types.js +68 -0
- package/src/walk/run.js +76 -0
- package/src/watch/index.js +286 -0
- package/src/watch/panel.js +1678 -0
- package/src/watch/place.js +279 -0
- package/src/watch/window.js +1242 -0
package/CHANGELOG.md
CHANGED
|
@@ -48,7 +48,7 @@ not been used by many people yet.
|
|
|
48
48
|
|
|
49
49
|
### Known limits
|
|
50
50
|
|
|
51
|
-
- Not published to npm yet. Run it from GitHub: `npx
|
|
51
|
+
- Not published to npm yet. Run it from GitHub: `npx staysfixed`.
|
|
52
52
|
- Approved pictures are tied to the operating system that took them. A picture
|
|
53
53
|
approved on macOS will not match on Linux.
|
|
54
54
|
- Untested on Windows.
|
package/README.md
CHANGED
|
@@ -28,13 +28,14 @@ can check. Only a human can approve.**
|
|
|
28
28
|
|
|
29
29
|
## Install and first run
|
|
30
30
|
|
|
31
|
-
**It is not on npm yet.** Run it from GitHub — that is the way to use it today:
|
|
32
|
-
|
|
33
31
|
```
|
|
34
|
-
npx
|
|
35
|
-
npx
|
|
32
|
+
npx staysfixed init
|
|
33
|
+
npx staysfixed check
|
|
36
34
|
```
|
|
37
35
|
|
|
36
|
+
No install, no account, no sign-up. It works in any project, in any language —
|
|
37
|
+
it only needs to be able to open your app.
|
|
38
|
+
|
|
38
39
|
`init` asks what your app is, writes a `staysfixed.config.js` you can read, makes
|
|
39
40
|
the `.staysfixed/` folder and adds the two lines to your `.gitignore` that keep
|
|
40
41
|
the throwaway results out of git.
|
|
@@ -44,15 +45,15 @@ nobody has approved any of them yet. Look at them, and approve the ones that are
|
|
|
44
45
|
right:
|
|
45
46
|
|
|
46
47
|
```
|
|
47
|
-
npx
|
|
48
|
+
npx staysfixed approve --all
|
|
48
49
|
```
|
|
49
50
|
|
|
50
51
|
From then on, `check` is silent unless something actually moved.
|
|
51
52
|
|
|
52
53
|
Requirements: **Node 22 or newer**, and a Chromium-based browser on the machine
|
|
53
54
|
(Chrome, Chromium, Edge or Brave — or, for a desktop app, the Chromium already
|
|
54
|
-
inside your Electron build). `npx
|
|
55
|
-
|
|
55
|
+
inside your Electron build). `npx staysfixed doctor` tells you what it
|
|
56
|
+
found and what it is missing.
|
|
56
57
|
|
|
57
58
|
Two runtime dependencies, `pngjs` and `pixelmatch`. No build step: the JavaScript
|
|
58
59
|
in the repository is the JavaScript that runs.
|
|
@@ -185,6 +186,8 @@ Looked through 3 markers.
|
|
|
185
186
|
### And the rest
|
|
186
187
|
|
|
187
188
|
```
|
|
189
|
+
staysfixed check --watch watch the run in a panel beside your app
|
|
190
|
+
staysfixed check --profile where the time went, printed at the end
|
|
188
191
|
staysfixed status what is set up here, and how the last check went
|
|
189
192
|
staysfixed flake checks that have changed their mind
|
|
190
193
|
staysfixed doctor what is missing before any of this can run
|
|
@@ -210,6 +213,27 @@ Stays Fixed
|
|
|
210
213
|
|
|
211
214
|
---
|
|
212
215
|
|
|
216
|
+
## Watch it work
|
|
217
|
+
|
|
218
|
+
A check is normally something you start and then look away from. `--watch` opens
|
|
219
|
+
a slim panel beside your app and draws the run as it happens: every screen and
|
|
220
|
+
guard ticking over from waiting to done, a thumbnail of each picture the moment
|
|
221
|
+
it is taken, the approved one and the new one side by side for anything that
|
|
222
|
+
changed, and how long each check took.
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
staysfixed check --watch
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
It opens behind whatever you are using and keeps working there, and it only
|
|
229
|
+
reads the run — it never touches the app being photographed, so the pictures come
|
|
230
|
+
out the same whether you watch or not. If no browser will open it, you get one
|
|
231
|
+
line saying so and the run carries on without it. There is also `--profile`,
|
|
232
|
+
which needs no window at all and prints where the seconds went when the run ends:
|
|
233
|
+
[docs/watching.md](docs/watching.md).
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
213
237
|
## How it keeps pictures stable
|
|
214
238
|
|
|
215
239
|
A picture check is only worth having if it is silent when nothing changed. The
|
|
@@ -270,7 +294,7 @@ wired in, it can check before it tells you it is done.
|
|
|
270
294
|
Claude Code:
|
|
271
295
|
|
|
272
296
|
```
|
|
273
|
-
claude mcp add staysfixed -- npx -y
|
|
297
|
+
claude mcp add staysfixed -- npx -y staysfixed mcp
|
|
274
298
|
```
|
|
275
299
|
|
|
276
300
|
Cursor, Gemini CLI, and most other clients take the same block —
|
|
@@ -281,7 +305,7 @@ Cursor, Gemini CLI, and most other clients take the same block —
|
|
|
281
305
|
"mcpServers": {
|
|
282
306
|
"staysfixed": {
|
|
283
307
|
"command": "npx",
|
|
284
|
-
"args": ["-y", "
|
|
308
|
+
"args": ["-y", "staysfixed", "mcp"],
|
|
285
309
|
"cwd": "/absolute/path/to/your/project"
|
|
286
310
|
}
|
|
287
311
|
}
|
|
@@ -293,7 +317,7 @@ Codex keeps the same fields in TOML, in `~/.codex/config.toml`:
|
|
|
293
317
|
```toml
|
|
294
318
|
[mcp_servers.staysfixed]
|
|
295
319
|
command = "npx"
|
|
296
|
-
args = ["-y", "
|
|
320
|
+
args = ["-y", "staysfixed", "mcp"]
|
|
297
321
|
cwd = "/absolute/path/to/your/project"
|
|
298
322
|
```
|
|
299
323
|
|
|
@@ -501,7 +525,6 @@ That rule applies to the tool's own checks too.
|
|
|
501
525
|
|
|
502
526
|
Honestly, so you know before you invest an afternoon:
|
|
503
527
|
|
|
504
|
-
- **Not on npm yet.** Run it from GitHub: `npx github:asadev/staysfixed`.
|
|
505
528
|
- **No hosted service, no dashboard, no accounts, no teams, nothing paid.** It is
|
|
506
529
|
a command and a folder of files in your repository. There is no server
|
|
507
530
|
anywhere, and nothing is uploaded.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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/cli/check.js
CHANGED
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Results are printed the moment each one lands, because a run that goes quiet
|
|
5
5
|
* for two minutes feels broken. The summary at the end is the part that matters.
|
|
6
|
+
*
|
|
7
|
+
* With `--watch` the same run is also drawn in a panel beside the app. The panel
|
|
8
|
+
* is a listener and nothing more: it reads the event stream, it never touches the
|
|
9
|
+
* app being photographed, and if it fails to open the run carries on without it.
|
|
10
|
+
*
|
|
11
|
+
* The app it belongs beside is opened inside the run, so the run hands it over
|
|
12
|
+
* through `onApp` and the panel snaps itself flush against that window. Moving
|
|
13
|
+
* windows around a desk is all that is: `--no-snap` turns even that off.
|
|
6
14
|
*/
|
|
7
15
|
|
|
8
16
|
import { loadProject } from '../core/config.js';
|
|
9
17
|
import { runCheck } from '../run.js';
|
|
18
|
+
import { makeEvents, makeTimings } from '../core/events.js';
|
|
19
|
+
import { attachWatcher, watchOptionsFrom } from '../watch/index.js';
|
|
10
20
|
import { printPictureResult, printGuardResult, printRunSummary } from '../report/console.js';
|
|
11
|
-
import { setLogLevel } from '../core/log.js';
|
|
12
|
-
import { EXIT } from '../core/errors.js';
|
|
21
|
+
import { setLogLevel, warn } from '../core/log.js';
|
|
22
|
+
import { EXIT, messageOf } from '../core/errors.js';
|
|
23
|
+
import { watchFlags, watchSettings } from './index.js';
|
|
13
24
|
|
|
14
25
|
/**
|
|
15
26
|
* @param {import('./index.js').CliContext} ctx
|
|
@@ -24,6 +35,24 @@ export async function run(ctx) {
|
|
|
24
35
|
|
|
25
36
|
const onlyGuards = ctx.bool('guards');
|
|
26
37
|
const onlyPictures = ctx.bool('pictures');
|
|
38
|
+
const profile = ctx.bool('profile');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `--no-snap` is read here rather than in the shared flag reader because it is
|
|
42
|
+
* the only panel flag that says "change nothing at all", and it has to reach
|
|
43
|
+
* the panel from both commands identically.
|
|
44
|
+
* @type {import('../watch/index.js').WatchFlags}
|
|
45
|
+
*/
|
|
46
|
+
const wanted = { ...watchFlags(ctx) };
|
|
47
|
+
if (ctx.flags.snap !== undefined) wanted.snap = ctx.flags.snap === true;
|
|
48
|
+
|
|
49
|
+
let watching = wanted.enabled === true;
|
|
50
|
+
if (watching && asJson) {
|
|
51
|
+
// One asks for a window to look at, the other asks for output a script can
|
|
52
|
+
// read. Saying so is better than quietly picking one.
|
|
53
|
+
warn('--watch and --json want opposite things: a window to look at, and output for a script. Carrying on without the panel.');
|
|
54
|
+
watching = false;
|
|
55
|
+
}
|
|
27
56
|
|
|
28
57
|
/** Printed once, whether it arrived live or only in the summary. */
|
|
29
58
|
const shown = new Set();
|
|
@@ -44,6 +73,25 @@ export async function run(ctx) {
|
|
|
44
73
|
if (!asJson) printGuardResult(result);
|
|
45
74
|
};
|
|
46
75
|
|
|
76
|
+
const events = makeEvents();
|
|
77
|
+
const timings = makeTimings();
|
|
78
|
+
|
|
79
|
+
// The panel has to be listening before the run starts, or it misses the plan
|
|
80
|
+
// and the first screen. A panel that will not open is a disappointment, never
|
|
81
|
+
// a failed check: the whole point of the run is the pictures.
|
|
82
|
+
/** @type {import('../watch/index.js').Watcher|null} */
|
|
83
|
+
let watcher = null;
|
|
84
|
+
if (watching) {
|
|
85
|
+
try {
|
|
86
|
+
watcher = await attachWatcher(events, { project, watch: watchOptionsFrom(watchSettings(project), wanted) });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
watching = false;
|
|
89
|
+
warn(`The panel could not open, so the run is going ahead without it. ${messageOf(error)}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Held as a const so the run's callback below cannot be handed a null later.
|
|
93
|
+
const panel = watcher;
|
|
94
|
+
|
|
47
95
|
const opts = /** @type {any} */ ({
|
|
48
96
|
only: ctx.list('only'),
|
|
49
97
|
// These names are the contract runCheck reads. They were `pictures` and `guards`
|
|
@@ -55,18 +103,37 @@ export async function run(ctx) {
|
|
|
55
103
|
writeReport: ctx.flags.report !== false && !asJson,
|
|
56
104
|
onPicture: showPicture,
|
|
57
105
|
onGuard: showGuard,
|
|
106
|
+
events,
|
|
107
|
+
timings,
|
|
108
|
+
// Only true when a panel really is up: it is what tells the run whether the
|
|
109
|
+
// extra work of making thumbnails is worth doing.
|
|
110
|
+
watching,
|
|
111
|
+
// How the panel meets the app: called once, the moment the app is open and
|
|
112
|
+
// before anything is photographed.
|
|
113
|
+
onApp: panel ? (/** @type {import('../types.js').LaunchedApp} */ app) => panel.snapTo(app) : undefined,
|
|
58
114
|
tool: ctx.version,
|
|
59
115
|
});
|
|
60
116
|
|
|
61
117
|
/** @type {import('../types.js').RunSummary} */
|
|
62
|
-
|
|
118
|
+
let summary;
|
|
119
|
+
try {
|
|
120
|
+
summary = await runCheck(project, opts);
|
|
121
|
+
} finally {
|
|
122
|
+
if (watcher) {
|
|
123
|
+
try {
|
|
124
|
+
await watcher.stop();
|
|
125
|
+
} catch {
|
|
126
|
+
// A panel that will not close is not a reason to change the verdict.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
63
130
|
|
|
64
131
|
if (asJson) {
|
|
65
132
|
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
|
|
66
133
|
} else {
|
|
67
134
|
for (const picture of summary.pictures ?? []) showPicture(picture);
|
|
68
135
|
for (const guard of summary.guards ?? []) showGuard(guard);
|
|
69
|
-
printRunSummary(summary, project);
|
|
136
|
+
printRunSummary(summary, project, { profile, timings: profile ? timings.get() : undefined });
|
|
70
137
|
}
|
|
71
138
|
|
|
72
139
|
return summary.ok ? EXIT.ok : EXIT.failed;
|
package/src/cli/index.js
CHANGED
|
@@ -68,6 +68,25 @@ const GLOBAL_SPEC = {
|
|
|
68
68
|
/** Flags that swallow the next word, needed before we know which command it is. */
|
|
69
69
|
const GLOBAL_VALUE_FLAGS = new Set(['--config', '--cwd']);
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The live panel and the profiler. Both `check` and `walk` take them, and they
|
|
73
|
+
* are declared once here so the two commands cannot drift apart.
|
|
74
|
+
*/
|
|
75
|
+
const WATCH_SPEC = {
|
|
76
|
+
booleans: ['watch', 'watch-front', 'keep-open', 'profile'],
|
|
77
|
+
strings: ['watch-side', 'watch-width'],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** @type {[string, string][]} */
|
|
81
|
+
const WATCH_OPTIONS = [
|
|
82
|
+
['--watch', 'Open a small panel beside your app and watch the run happen.'],
|
|
83
|
+
['--watch-side <side>', 'Which side of the app the panel sits on: left or right. Default right.'],
|
|
84
|
+
['--watch-width <n>', 'How wide the panel is, in pixels. Default 460.'],
|
|
85
|
+
['--no-keep-open', 'Close the panel as soon as the run finishes.'],
|
|
86
|
+
['--watch-front', 'Bring the panel to the front. By default it opens behind your work.'],
|
|
87
|
+
['--profile', 'Print where the time went when the run is over.'],
|
|
88
|
+
];
|
|
89
|
+
|
|
71
90
|
/** @type {Record<string, CommandEntry>} */
|
|
72
91
|
const COMMANDS = {
|
|
73
92
|
init: {
|
|
@@ -85,7 +104,7 @@ const COMMANDS = {
|
|
|
85
104
|
},
|
|
86
105
|
check: {
|
|
87
106
|
summary: 'Photograph the screens and run the guards. This is the one you run.',
|
|
88
|
-
usage: 'staysfixed check [--only <name>] [--guards] [--pictures] [--json]',
|
|
107
|
+
usage: 'staysfixed check [--only <name>] [--guards] [--pictures] [--watch] [--json]',
|
|
89
108
|
describe:
|
|
90
109
|
'Opens the real app, takes a picture of every screen you named, compares each one\nagainst the picture a human approved, and runs every guard. It stops on nothing:\nyou get the whole list of what changed, and the exact command to accept it.',
|
|
91
110
|
options: [
|
|
@@ -95,9 +114,19 @@ const COMMANDS = {
|
|
|
95
114
|
['--record', 'Save the network replies this run, so later runs can replay them.'],
|
|
96
115
|
['--no-report', 'Skip writing the side-by-side HTML report.'],
|
|
97
116
|
['--json', 'Print the result as JSON and nothing else. For CI.'],
|
|
117
|
+
...WATCH_OPTIONS,
|
|
118
|
+
],
|
|
119
|
+
examples: [
|
|
120
|
+
'staysfixed check',
|
|
121
|
+
'staysfixed check --only sessions-empty',
|
|
122
|
+
'staysfixed check --guards',
|
|
123
|
+
'staysfixed check --watch',
|
|
98
124
|
],
|
|
99
|
-
|
|
100
|
-
|
|
125
|
+
spec: {
|
|
126
|
+
booleans: ['guards', 'pictures', 'record', 'report', 'json', ...WATCH_SPEC.booleans],
|
|
127
|
+
strings: [...WATCH_SPEC.strings],
|
|
128
|
+
arrays: ['only'],
|
|
129
|
+
},
|
|
101
130
|
load: () => import('./check.js'),
|
|
102
131
|
},
|
|
103
132
|
approve: {
|
|
@@ -115,15 +144,20 @@ const COMMANDS = {
|
|
|
115
144
|
},
|
|
116
145
|
walk: {
|
|
117
146
|
summary: 'Open the real app and photograph every screen, in order, before you ship.',
|
|
118
|
-
usage: 'staysfixed walk [--only <name>] [--open]',
|
|
147
|
+
usage: 'staysfixed walk [--only <name>] [--open] [--watch]',
|
|
119
148
|
describe:
|
|
120
149
|
'A walk is not a test. It opens the app you are about to release, visits each screen\nand photographs it into one page you can scroll — the last look before a release,\nwithout clicking through the app yourself.',
|
|
121
150
|
options: [
|
|
122
151
|
['--only <name>', 'Just this screen. Repeat it for several.'],
|
|
123
152
|
['--open', 'Open the contact sheet when it is done.'],
|
|
153
|
+
...WATCH_OPTIONS,
|
|
124
154
|
],
|
|
125
|
-
examples: ['staysfixed walk --open'],
|
|
126
|
-
spec: {
|
|
155
|
+
examples: ['staysfixed walk --open', 'staysfixed walk --watch'],
|
|
156
|
+
spec: {
|
|
157
|
+
booleans: ['open', ...WATCH_SPEC.booleans],
|
|
158
|
+
strings: [...WATCH_SPEC.strings],
|
|
159
|
+
arrays: ['only'],
|
|
160
|
+
},
|
|
127
161
|
load: () => import('./walk.js'),
|
|
128
162
|
},
|
|
129
163
|
mark: {
|
|
@@ -265,6 +299,74 @@ function contextFor(parsed, cwd, configFile) {
|
|
|
265
299
|
};
|
|
266
300
|
}
|
|
267
301
|
|
|
302
|
+
/**
|
|
303
|
+
* What the panel flags on the command line asked for. Anything the person did
|
|
304
|
+
* not mention is left undefined on purpose, so the settings file still decides it.
|
|
305
|
+
*
|
|
306
|
+
* @typedef {object} WatchFlags
|
|
307
|
+
* @property {boolean} enabled Whether --watch was asked for at all.
|
|
308
|
+
* @property {'left'|'right'} [side]
|
|
309
|
+
* @property {number} [width]
|
|
310
|
+
* @property {boolean} [keepOpen]
|
|
311
|
+
* @property {boolean} [foreground]
|
|
312
|
+
*/
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Read the panel flags. Shared by `check` and `walk` so the two behave the same.
|
|
316
|
+
* @param {CliContext} ctx
|
|
317
|
+
* @returns {WatchFlags}
|
|
318
|
+
*/
|
|
319
|
+
export function watchFlags(ctx) {
|
|
320
|
+
/** @type {WatchFlags} */
|
|
321
|
+
const flags = { enabled: ctx.bool('watch') };
|
|
322
|
+
|
|
323
|
+
const side = ctx.str('watch-side');
|
|
324
|
+
if (side !== undefined) {
|
|
325
|
+
if (side !== 'left' && side !== 'right') {
|
|
326
|
+
throw new StaysFixedError(`--watch-side has to be left or right, not "${side}".`, {
|
|
327
|
+
hint: 'Write it as `--watch-side left` or `--watch-side right`.',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
flags.side = side;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const width = ctx.str('watch-width');
|
|
334
|
+
if (width !== undefined) {
|
|
335
|
+
const n = Number(width);
|
|
336
|
+
// A panel narrower than this cannot show the before-and-after pictures side
|
|
337
|
+
// by side, which is the only reason to open it.
|
|
338
|
+
if (!Number.isFinite(n) || n < 240) {
|
|
339
|
+
throw new StaysFixedError(`--watch-width has to be a number of pixels, 240 or more — I got "${width}".`, {
|
|
340
|
+
hint: 'Write it as `--watch-width 520`.',
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
flags.width = Math.round(n);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Only mention these when they were actually typed, so --no-keep-open turns the
|
|
347
|
+
// panel off at the end without a bare --watch turning it on against the settings.
|
|
348
|
+
if (ctx.flags['keep-open'] !== undefined) flags.keepOpen = ctx.flags['keep-open'] === true;
|
|
349
|
+
if (ctx.bool('watch-front')) flags.foreground = true;
|
|
350
|
+
|
|
351
|
+
return flags;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The panel settings a project's settings file carries, if it carries any.
|
|
356
|
+
*
|
|
357
|
+
* A settings file may hold a `watch` block, and the resolved config type does not
|
|
358
|
+
* describe one, so it is read through a shape that names exactly what is being
|
|
359
|
+
* looked for rather than reaching in through `any`.
|
|
360
|
+
*
|
|
361
|
+
* @param {import('../types.js').Project} project
|
|
362
|
+
* @returns {{watch?: import('../types.js').WatchOptions|boolean}}
|
|
363
|
+
*/
|
|
364
|
+
export function watchSettings(project) {
|
|
365
|
+
return /** @type {{watch?: import('../types.js').WatchOptions|boolean}} */ (
|
|
366
|
+
/** @type {unknown} */ (project.config ?? {})
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
268
370
|
/**
|
|
269
371
|
* Change into `--cwd` so every relative path in the run means the same thing.
|
|
270
372
|
* @param {string|boolean|string[]|undefined} value
|
|
@@ -484,6 +586,7 @@ function printHelp() {
|
|
|
484
586
|
out(' staysfixed check check every screen and guard');
|
|
485
587
|
out(' staysfixed approve sessions-empty accept one new picture as correct');
|
|
486
588
|
out(' staysfixed walk --open photograph the whole app before a release');
|
|
589
|
+
out(' staysfixed check --watch watch it work in a panel beside your app');
|
|
487
590
|
out('');
|
|
488
591
|
out('It answers with 0 when nothing changed, 1 when something changed or broke,');
|
|
489
592
|
out('and 2 when it could not run at all.');
|
package/src/cli/init.js
CHANGED
|
@@ -298,8 +298,10 @@ function snippetFor(root, pkg) {
|
|
|
298
298
|
command = 'npx';
|
|
299
299
|
args = ['staysfixed', 'mcp'];
|
|
300
300
|
} else if (bin.includes(`${path.sep}_npx${path.sep}`)) {
|
|
301
|
+
// Run through npx and it was almost certainly fetched from npm, so tell people the
|
|
302
|
+
// short form rather than the path of a temporary folder that will not exist tomorrow.
|
|
301
303
|
command = 'npx';
|
|
302
|
-
args = ['
|
|
304
|
+
args = ['staysfixed', 'mcp'];
|
|
303
305
|
}
|
|
304
306
|
return mcpConfigSnippet({ command, args, cwd: root });
|
|
305
307
|
}
|
package/src/cli/walk.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `staysfixed walk` — the last look before a release.
|
|
3
|
+
*
|
|
4
|
+
* `--watch` opens the same live panel `check` uses, which is worth more here than
|
|
5
|
+
* anywhere else: a walk is something you sit and look at. The panel snaps itself
|
|
6
|
+
* flush against the app once the walk has opened it, so the two read as one
|
|
7
|
+
* window; `--no-snap` leaves both where they are.
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
import { spawn } from 'node:child_process';
|
|
6
11
|
import { loadProject } from '../core/config.js';
|
|
7
12
|
import { runWalk } from '../run.js';
|
|
8
|
-
import {
|
|
13
|
+
import { makeEvents, makeTimings } from '../core/events.js';
|
|
14
|
+
import { attachWatcher, watchOptionsFrom } from '../watch/index.js';
|
|
15
|
+
import { printWalkReport, printTimings } from '../report/console.js';
|
|
9
16
|
import { say, warn, paint, shortPath } from '../core/log.js';
|
|
10
|
-
import { EXIT } from '../core/errors.js';
|
|
17
|
+
import { EXIT, messageOf } from '../core/errors.js';
|
|
18
|
+
import { watchFlags, watchSettings } from './index.js';
|
|
11
19
|
|
|
12
20
|
/**
|
|
13
21
|
* @param {import('./index.js').CliContext} ctx
|
|
@@ -16,10 +24,65 @@ import { EXIT } from '../core/errors.js';
|
|
|
16
24
|
export async function run(ctx) {
|
|
17
25
|
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
|
|
18
26
|
|
|
27
|
+
const profile = ctx.bool('profile');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `--no-snap` is read here rather than in the shared flag reader because it is
|
|
31
|
+
* the only panel flag that says "change nothing at all", and it has to reach
|
|
32
|
+
* the panel from both commands identically.
|
|
33
|
+
* @type {import('../watch/index.js').WatchFlags}
|
|
34
|
+
*/
|
|
35
|
+
const wanted = { ...watchFlags(ctx) };
|
|
36
|
+
if (ctx.flags.snap !== undefined) wanted.snap = ctx.flags.snap === true;
|
|
37
|
+
|
|
38
|
+
let watching = wanted.enabled === true;
|
|
39
|
+
|
|
40
|
+
const events = makeEvents();
|
|
41
|
+
const timings = makeTimings();
|
|
42
|
+
|
|
43
|
+
// Listening has to start before the walk does, or the panel misses the first
|
|
44
|
+
// screen. It never gets to stop the walk: a panel is a nice-to-have.
|
|
45
|
+
/** @type {import('../watch/index.js').Watcher|null} */
|
|
46
|
+
let watcher = null;
|
|
47
|
+
if (watching) {
|
|
48
|
+
try {
|
|
49
|
+
watcher = await attachWatcher(events, { project, watch: watchOptionsFrom(watchSettings(project), wanted) });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
watching = false;
|
|
52
|
+
warn(`The panel could not open, so the walk is going ahead without it. ${messageOf(error)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Held as a const so the walk's callback below cannot be handed a null later.
|
|
56
|
+
const panel = watcher;
|
|
57
|
+
|
|
19
58
|
/** @type {import('../types.js').WalkReport} */
|
|
20
|
-
|
|
59
|
+
let report;
|
|
60
|
+
try {
|
|
61
|
+
report = await runWalk(
|
|
62
|
+
project,
|
|
63
|
+
/** @type {any} */ ({
|
|
64
|
+
only: ctx.list('only'),
|
|
65
|
+
events,
|
|
66
|
+
timings,
|
|
67
|
+
watching,
|
|
68
|
+
// How the panel meets the app: called once, the moment the app is open
|
|
69
|
+
// and before anything is photographed.
|
|
70
|
+
onApp: panel ? (/** @type {import('../types.js').LaunchedApp} */ app) => panel.snapTo(app) : undefined,
|
|
71
|
+
tool: ctx.version,
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
} finally {
|
|
75
|
+
if (watcher) {
|
|
76
|
+
try {
|
|
77
|
+
await watcher.stop();
|
|
78
|
+
} catch {
|
|
79
|
+
// A panel that will not close is not a reason to change the verdict.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
21
83
|
|
|
22
84
|
printWalkReport(report);
|
|
85
|
+
if (profile) printTimings(timings.get(), (report.steps ?? []).length);
|
|
23
86
|
|
|
24
87
|
const sheet = report.reportFile || report.dir;
|
|
25
88
|
if (ctx.bool('open')) {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a run says about itself while it is happening.
|
|
3
|
+
*
|
|
4
|
+
* A run used to tell the terminal what it was doing and tell nobody else, so
|
|
5
|
+
* anything that also wanted to watch — the live window, and whatever comes after
|
|
6
|
+
* it — had to be threaded through the engine as another callback. Instead a run
|
|
7
|
+
* now describes itself once, into this stream, and anyone who cares listens.
|
|
8
|
+
*
|
|
9
|
+
* Two rules hold the whole thing up. A listener that throws must never break the
|
|
10
|
+
* run: watching is a convenience, and a convenience that can take down a check is
|
|
11
|
+
* not worth having. And a listener that arrives late is handed everything that
|
|
12
|
+
* already happened, in order, which is what lets a window open in the middle of a
|
|
13
|
+
* run and still draw the screens that were photographed before it opened.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { detail } from './log.js';
|
|
17
|
+
import { messageOf } from './errors.js';
|
|
18
|
+
|
|
19
|
+
/** @typedef {import('../types.js').RunEvent} RunEvent */
|
|
20
|
+
/** @typedef {import('../types.js').RunEvents} RunEvents */
|
|
21
|
+
/** @typedef {import('../types.js').Timings} Timings */
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An event on its way in. Everything a `RunEvent` carries except `at`, which the
|
|
25
|
+
* stream stamps, so no caller has to hold a clock of its own.
|
|
26
|
+
* @typedef {Omit<RunEvent, 'at'> & {at?: number}} DraftEvent
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The parts of a run whose time we can name. `other` and `total` are worked out
|
|
31
|
+
* at the end rather than measured, so they can never disagree with the rest.
|
|
32
|
+
* @typedef {'launch'|'steps'|'prepare'|'settle'|'compare'|'guards'} TimingKey
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A fresh event stream. One per run.
|
|
37
|
+
*
|
|
38
|
+
* @returns {RunEvents}
|
|
39
|
+
*/
|
|
40
|
+
export function makeEvents() {
|
|
41
|
+
const born = process.hrtime.bigint();
|
|
42
|
+
/** @type {RunEvent[]} */
|
|
43
|
+
const history = [];
|
|
44
|
+
/** @type {Set<(event: RunEvent) => void>} */
|
|
45
|
+
const listeners = new Set();
|
|
46
|
+
|
|
47
|
+
/** @returns {number} Milliseconds since this stream was made. */
|
|
48
|
+
function elapsed() {
|
|
49
|
+
return Math.round(Number(process.hrtime.bigint() - born) / 1e6);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Hand one event to one listener, and swallow whatever it does with it.
|
|
54
|
+
*
|
|
55
|
+
* The message goes to `detail` on purpose: a watcher misbehaving is worth
|
|
56
|
+
* knowing about when somebody asks for detail, and is never worth a warning in
|
|
57
|
+
* the middle of a clean run.
|
|
58
|
+
*
|
|
59
|
+
* @param {(event: RunEvent) => void} listener
|
|
60
|
+
* @param {RunEvent} event
|
|
61
|
+
*/
|
|
62
|
+
function hand(listener, event) {
|
|
63
|
+
try {
|
|
64
|
+
listener(event);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
detail(`Something watching this run failed on a "${event.type}" event. ${messageOf(e)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {DraftEvent} event */
|
|
71
|
+
function emit(event) {
|
|
72
|
+
/** @type {RunEvent} */
|
|
73
|
+
const stamped =
|
|
74
|
+
typeof event.at === 'number' ? /** @type {RunEvent} */ (event) : { ...event, at: elapsed() };
|
|
75
|
+
history.push(stamped);
|
|
76
|
+
// A copy, because a listener is allowed to unsubscribe — or subscribe
|
|
77
|
+
// somebody else — while it is being called.
|
|
78
|
+
for (const listener of [...listeners]) hand(listener, stamped);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {(event: RunEvent) => void} listener
|
|
83
|
+
* @returns {() => void} Call it to stop listening.
|
|
84
|
+
*/
|
|
85
|
+
function on(listener) {
|
|
86
|
+
// Catch-up first, then live. A copy again: a listener that emits while it is
|
|
87
|
+
// catching up would otherwise grow the array it is being read from.
|
|
88
|
+
for (const past of [...history]) hand(listener, past);
|
|
89
|
+
listeners.add(listener);
|
|
90
|
+
return () => {
|
|
91
|
+
listeners.delete(listener);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { emit, on, elapsed, history: () => [...history] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Emit, when there may be nobody to emit to.
|
|
100
|
+
*
|
|
101
|
+
* Every place in the engine that describes itself is optional — a run with no
|
|
102
|
+
* watcher is the normal case — and this keeps that from being an `if` at every
|
|
103
|
+
* call site.
|
|
104
|
+
*
|
|
105
|
+
* @param {RunEvents|undefined} events
|
|
106
|
+
* @param {DraftEvent} event
|
|
107
|
+
* @returns {void}
|
|
108
|
+
*/
|
|
109
|
+
export function emitEvent(events, event) {
|
|
110
|
+
if (!events) return;
|
|
111
|
+
events.emit(/** @type {RunEvent} */ (event));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Where a run spent its time.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately dumb: a few numbers added up, `process.hrtime.bigint()` for the
|
|
118
|
+
* clock, and nothing allocated while a screen is being photographed. A profiler
|
|
119
|
+
* that shows up in its own measurements is worse than no profiler.
|
|
120
|
+
*
|
|
121
|
+
* @returns {{add: (key: TimingKey, ms: number) => void, mark: (key: TimingKey) => () => void, get: () => Timings}}
|
|
122
|
+
*/
|
|
123
|
+
export function makeTimings() {
|
|
124
|
+
const born = process.hrtime.bigint();
|
|
125
|
+
/** @type {Record<TimingKey, number>} */
|
|
126
|
+
const spent = { launch: 0, steps: 0, prepare: 0, settle: 0, compare: 0, guards: 0 };
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {TimingKey} key
|
|
130
|
+
* @param {number} ms
|
|
131
|
+
* @returns {void}
|
|
132
|
+
*/
|
|
133
|
+
function add(key, ms) {
|
|
134
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
135
|
+
spent[key] += ms;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Start the clock on one part of the run; the function it hands back stops it.
|
|
140
|
+
* @param {TimingKey} key
|
|
141
|
+
* @returns {() => void}
|
|
142
|
+
*/
|
|
143
|
+
function mark(key) {
|
|
144
|
+
const from = process.hrtime.bigint();
|
|
145
|
+
return () => {
|
|
146
|
+
spent[key] += Number(process.hrtime.bigint() - from) / 1e6;
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @returns {Timings} */
|
|
151
|
+
function get() {
|
|
152
|
+
const total = Number(process.hrtime.bigint() - born) / 1e6;
|
|
153
|
+
const named =
|
|
154
|
+
spent.launch + spent.steps + spent.prepare + spent.settle + spent.compare + spent.guards;
|
|
155
|
+
return {
|
|
156
|
+
launch: Math.round(spent.launch),
|
|
157
|
+
steps: Math.round(spent.steps),
|
|
158
|
+
prepare: Math.round(spent.prepare),
|
|
159
|
+
settle: Math.round(spent.settle),
|
|
160
|
+
compare: Math.round(spent.compare),
|
|
161
|
+
guards: Math.round(spent.guards),
|
|
162
|
+
// Everything nobody claimed: reading and writing pictures, git, the report.
|
|
163
|
+
// Clamped, because two parts of the run can overlap and the leftovers must
|
|
164
|
+
// never be printed as a negative number of milliseconds.
|
|
165
|
+
other: Math.round(Math.max(0, total - named)),
|
|
166
|
+
total: Math.round(total),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { add, mark, get };
|
|
171
|
+
}
|