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 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 github:asadev/staysfixed`.
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 github:asadev/staysfixed init
35
- npx github:asadev/staysfixed check
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 github:asadev/staysfixed approve --all
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 github:asadev/staysfixed doctor` tells you what
55
- it found and what it is missing.
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 github:asadev/staysfixed mcp
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", "github:asadev/staysfixed", "mcp"],
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", "github:asadev/staysfixed", "mcp"]
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.1.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
- const summary = await runCheck(project, opts);
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
- examples: ['staysfixed check', 'staysfixed check --only sessions-empty', 'staysfixed check --guards'],
100
- spec: { booleans: ['guards', 'pictures', 'record', 'report', 'json'], arrays: ['only'] },
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: { booleans: ['open'], arrays: ['only'] },
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 = ['github:asadev/staysfixed', 'mcp'];
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 { printWalkReport } from '../report/console.js';
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
- const report = await runWalk(project, /** @type {any} */ ({ only: ctx.list('only'), tool: ctx.version }));
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
+ }