staysfixed 0.1.1 → 0.2.1

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/README.md CHANGED
@@ -186,6 +186,8 @@ Looked through 3 markers.
186
186
  ### And the rest
187
187
 
188
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
189
191
  staysfixed status what is set up here, and how the last check went
190
192
  staysfixed flake checks that have changed their mind
191
193
  staysfixed doctor what is missing before any of this can run
@@ -211,6 +213,27 @@ Stays Fixed
211
213
 
212
214
  ---
213
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
+
214
237
  ## How it keeps pictures stable
215
238
 
216
239
  A picture check is only worth having if it is silent when nothing changed. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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/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,199 @@
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 { pathToFileURL } from 'node:url';
17
+ import { detail } from './log.js';
18
+ import { messageOf } from './errors.js';
19
+
20
+ /** @typedef {import('../types.js').RunEvent} RunEvent */
21
+ /** @typedef {import('../types.js').RunEvents} RunEvents */
22
+ /** @typedef {import('../types.js').Timings} Timings */
23
+
24
+ /**
25
+ * An event on its way in. Everything a `RunEvent` carries except `at`, which the
26
+ * stream stamps, so no caller has to hold a clock of its own.
27
+ * @typedef {Omit<RunEvent, 'at'> & {at?: number}} DraftEvent
28
+ */
29
+
30
+ /**
31
+ * The parts of a run whose time we can name. `other` and `total` are worked out
32
+ * at the end rather than measured, so they can never disagree with the rest.
33
+ * @typedef {'launch'|'steps'|'prepare'|'settle'|'compare'|'guards'} TimingKey
34
+ */
35
+
36
+ /**
37
+ * A fresh event stream. One per run.
38
+ *
39
+ * @returns {RunEvents}
40
+ */
41
+ export function makeEvents() {
42
+ const born = process.hrtime.bigint();
43
+ /** @type {RunEvent[]} */
44
+ const history = [];
45
+ /** @type {Set<(event: RunEvent) => void>} */
46
+ const listeners = new Set();
47
+
48
+ /** @returns {number} Milliseconds since this stream was made. */
49
+ function elapsed() {
50
+ return Math.round(Number(process.hrtime.bigint() - born) / 1e6);
51
+ }
52
+
53
+ /**
54
+ * Hand one event to one listener, and swallow whatever it does with it.
55
+ *
56
+ * The message goes to `detail` on purpose: a watcher misbehaving is worth
57
+ * knowing about when somebody asks for detail, and is never worth a warning in
58
+ * the middle of a clean run.
59
+ *
60
+ * @param {(event: RunEvent) => void} listener
61
+ * @param {RunEvent} event
62
+ */
63
+ function hand(listener, event) {
64
+ try {
65
+ listener(event);
66
+ } catch (e) {
67
+ detail(`Something watching this run failed on a "${event.type}" event. ${messageOf(e)}`);
68
+ }
69
+ }
70
+
71
+ /** @param {DraftEvent} event */
72
+ function emit(event) {
73
+ /** @type {RunEvent} */
74
+ const stamped =
75
+ typeof event.at === 'number' ? /** @type {RunEvent} */ (event) : { ...event, at: elapsed() };
76
+ history.push(stamped);
77
+ // A copy, because a listener is allowed to unsubscribe — or subscribe
78
+ // somebody else — while it is being called.
79
+ for (const listener of [...listeners]) hand(listener, stamped);
80
+ }
81
+
82
+ /**
83
+ * @param {(event: RunEvent) => void} listener
84
+ * @returns {() => void} Call it to stop listening.
85
+ */
86
+ function on(listener) {
87
+ // Catch-up first, then live. A copy again: a listener that emits while it is
88
+ // catching up would otherwise grow the array it is being read from.
89
+ for (const past of [...history]) hand(listener, past);
90
+ listeners.add(listener);
91
+ return () => {
92
+ listeners.delete(listener);
93
+ };
94
+ }
95
+
96
+ return { emit, on, elapsed, history: () => [...history] };
97
+ }
98
+
99
+ /**
100
+ * Emit, when there may be nobody to emit to.
101
+ *
102
+ * Every place in the engine that describes itself is optional — a run with no
103
+ * watcher is the normal case — and this keeps that from being an `if` at every
104
+ * call site.
105
+ *
106
+ * @param {RunEvents|undefined} events
107
+ * @param {DraftEvent} event
108
+ * @returns {void}
109
+ */
110
+ export function emitEvent(events, event) {
111
+ if (!events) return;
112
+ events.emit(/** @type {RunEvent} */ (event));
113
+ }
114
+
115
+ /**
116
+ * A file on disk, as an address a local page can load.
117
+ *
118
+ * The watch panel is itself a local `file://` page, which means it can open the real
119
+ * full-resolution PNGs this run just wrote instead of a shrunken copy pasted into the
120
+ * event. That is the difference between a picture you can zoom into and a picture you
121
+ * cannot read — and it costs nothing to send, because the address is a few dozen
122
+ * characters and the pixels never move.
123
+ *
124
+ * Hands back `undefined` rather than a broken address for anything that is not a real
125
+ * path: an <img> pointed at a file that is not there draws the browser's torn-page
126
+ * icon, which looks like the tool is broken. Nothing at all looks like nothing at all.
127
+ *
128
+ * @param {string|undefined|null} file An absolute path to a file that EXISTS. Callers
129
+ * pass the path they have just written, or one they have just read — this function
130
+ * does not touch the disk, so it cannot tell the difference itself.
131
+ * @returns {string|undefined}
132
+ */
133
+ export function fileUrl(file) {
134
+ if (typeof file !== 'string' || file === '') return undefined;
135
+ try {
136
+ return pathToFileURL(file).href;
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Where a run spent its time.
144
+ *
145
+ * Deliberately dumb: a few numbers added up, `process.hrtime.bigint()` for the
146
+ * clock, and nothing allocated while a screen is being photographed. A profiler
147
+ * that shows up in its own measurements is worse than no profiler.
148
+ *
149
+ * @returns {{add: (key: TimingKey, ms: number) => void, mark: (key: TimingKey) => () => void, get: () => Timings}}
150
+ */
151
+ export function makeTimings() {
152
+ const born = process.hrtime.bigint();
153
+ /** @type {Record<TimingKey, number>} */
154
+ const spent = { launch: 0, steps: 0, prepare: 0, settle: 0, compare: 0, guards: 0 };
155
+
156
+ /**
157
+ * @param {TimingKey} key
158
+ * @param {number} ms
159
+ * @returns {void}
160
+ */
161
+ function add(key, ms) {
162
+ if (!Number.isFinite(ms) || ms <= 0) return;
163
+ spent[key] += ms;
164
+ }
165
+
166
+ /**
167
+ * Start the clock on one part of the run; the function it hands back stops it.
168
+ * @param {TimingKey} key
169
+ * @returns {() => void}
170
+ */
171
+ function mark(key) {
172
+ const from = process.hrtime.bigint();
173
+ return () => {
174
+ spent[key] += Number(process.hrtime.bigint() - from) / 1e6;
175
+ };
176
+ }
177
+
178
+ /** @returns {Timings} */
179
+ function get() {
180
+ const total = Number(process.hrtime.bigint() - born) / 1e6;
181
+ const named =
182
+ spent.launch + spent.steps + spent.prepare + spent.settle + spent.compare + spent.guards;
183
+ return {
184
+ launch: Math.round(spent.launch),
185
+ steps: Math.round(spent.steps),
186
+ prepare: Math.round(spent.prepare),
187
+ settle: Math.round(spent.settle),
188
+ compare: Math.round(spent.compare),
189
+ guards: Math.round(spent.guards),
190
+ // Everything nobody claimed: reading and writing pictures, git, the report.
191
+ // Clamped, because two parts of the run can overlap and the leftovers must
192
+ // never be printed as a negative number of milliseconds.
193
+ other: Math.round(Math.max(0, total - named)),
194
+ total: Math.round(total),
195
+ };
196
+ }
197
+
198
+ return { add, mark, get };
199
+ }
package/src/core/paths.js CHANGED
@@ -132,4 +132,11 @@ export async function clearResults(paths) {
132
132
  /**
133
133
  * The .gitignore lines a project needs. Written by `init`, checked by `doctor`.
134
134
  */
135
- export const GITIGNORE_LINES = ['# Stays Fixed — evidence from the last run, not the promise', '.staysfixed/results/', '.staysfixed/report.html'];
135
+ export const GITIGNORE_LINES = [
136
+ '# Stays Fixed — evidence from the last run, not the promise',
137
+ '.staysfixed/results/',
138
+ '.staysfixed/report.html',
139
+ // Where one person dragged the watch panel on one screen. Nobody else's business,
140
+ // and it would otherwise turn up in their commits.
141
+ '.staysfixed/watch-window.json',
142
+ ];