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/src/types.js CHANGED
@@ -469,3 +469,78 @@ export {};
469
469
  * @property {number} markersSearched
470
470
  * @property {string} [message]
471
471
  */
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // Live events — what a run tells anyone watching, as it happens
475
+ // ---------------------------------------------------------------------------
476
+
477
+ /**
478
+ * One thing that happened during a run.
479
+ *
480
+ * The terminal, the watch window and any future listener all read the same stream, so a
481
+ * run only has to describe itself once. Every event carries `at` (milliseconds since the
482
+ * run began) so a watcher can draw a timeline without keeping its own clock.
483
+ *
484
+ * @typedef {object} RunEvent
485
+ * @property {'run:start'|'screen:start'|'screen:shot'|'screen:done'|'guard:start'|'guard:done'|'phase'|'note'|'run:done'} type
486
+ * @property {number} at Milliseconds since the run started.
487
+ * @property {string} [name] Screen or guard name.
488
+ * @property {string} [describe] The plain-language description.
489
+ * @property {number} [index] 1-based position within its phase.
490
+ * @property {number} [total] How many there are in this phase.
491
+ * @property {CheckStatus} [status]
492
+ * @property {number} [durationMs]
493
+ * @property {number} [diffPixels]
494
+ * @property {number} [diffRatio]
495
+ * @property {string} [message]
496
+ * @property {string} [failedAt] The plain-language expectation that failed.
497
+ * @property {string} [because] Why a guard exists.
498
+ * @property {string} [thumbnail] A small JPEG as a data: URI — an instant preview, shown
499
+ * while the real file is still being written.
500
+ * @property {string} [shotFile] file:// URL of the FULL-RESOLUTION picture just taken.
501
+ * The watch panel is itself a local page, so it can load the
502
+ * real PNG off disk and zoom into actual pixels — a scaled-up
503
+ * thumbnail is unreadable, which is the whole point of looking.
504
+ * @property {string} [approvedFile] file:// URL of the approved picture.
505
+ * @property {string} [diffFile] file:// URL of the difference image.
506
+ * @property {string} [approvedThumb]
507
+ * @property {string} [diffThumb]
508
+ * @property {RunSummary} [summary] Only on 'run:done'.
509
+ * @property {{screens: number, guards: number, app: string, project: string, watching: boolean}} [plan]
510
+ * Only on 'run:start'.
511
+ */
512
+
513
+ /**
514
+ * @typedef {object} RunEvents
515
+ * @property {(event: RunEvent) => void} emit
516
+ * @property {(listener: (event: RunEvent) => void) => () => void} on
517
+ * @property {() => number} elapsed
518
+ * @property {() => RunEvent[]} history Everything so far, so a late listener catches up.
519
+ */
520
+
521
+ /**
522
+ * @typedef {object} WatchOptions
523
+ * @property {boolean} [enabled]
524
+ * @property {number} [width] Panel width in CSS pixels. Default 460.
525
+ * @property {number} [height] Default: as tall as the app.
526
+ * @property {'right'|'left'} [side] Which side of the app to sit on. Default 'right'.
527
+ * @property {boolean} [keepOpen] Leave the panel up after the run. Default true.
528
+ * @property {boolean} [foreground] Bring the panel to the front. Default false.
529
+ * @property {boolean} [snap] Pin the app to a screen edge and sit flush against it. Default true.
530
+ * @property {'dark'|'light'|'system'} [theme] Default 'dark'. The panel opens on a brand new browser
531
+ * profile, and a fresh profile insists the computer is in light
532
+ * mode however it is really set — so the look is stated, not guessed.
533
+ */
534
+
535
+ /**
536
+ * Where a run spent its time. Printed by `--profile`, and drawn in the watch window.
537
+ * @typedef {object} Timings
538
+ * @property {number} launch
539
+ * @property {number} steps
540
+ * @property {number} prepare
541
+ * @property {number} settle
542
+ * @property {number} compare
543
+ * @property {number} guards
544
+ * @property {number} other
545
+ * @property {number} total
546
+ */
package/src/walk/run.js CHANGED
@@ -11,10 +11,12 @@
11
11
  import fsp from 'node:fs/promises';
12
12
  import path from 'node:path';
13
13
  import { captureScreen } from '../picture/capture.js';
14
+ import { accountForCapture } from '../picture/run.js';
14
15
  import { settingsForScreen } from '../core/config.js';
15
16
  import { gitInfo } from '../core/git.js';
16
17
  import { StaysFixedError, messageOf } from '../core/errors.js';
17
18
  import { safeName } from '../core/paths.js';
19
+ import { emitEvent, fileUrl } from '../core/events.js';
18
20
 
19
21
  /**
20
22
  * Progress handed to `opts.onStep`, once when a step starts and once when it is done.
@@ -37,12 +39,16 @@ import { safeName } from '../core/paths.js';
37
39
  * only?: string|string[],
38
40
  * signal?: AbortSignal,
39
41
  * record?: boolean,
42
+ * events?: import('../types.js').RunEvents,
43
+ * thumbnail?: boolean,
44
+ * timings?: ReturnType<typeof import('../core/events.js').makeTimings>,
40
45
  * }} [opts]
41
46
  * @returns {Promise<import('../types.js').WalkReport>}
42
47
  */
43
48
  export async function walkApp(project, app, opts = {}) {
44
49
  const { config, paths } = project;
45
50
  const chosen = chooseSteps(config, opts.only);
51
+ const events = opts.events;
46
52
 
47
53
  const id = walkId(new Date());
48
54
  const dir = await makeWalkDir(paths.results, id);
@@ -71,15 +77,46 @@ export async function walkApp(project, app, opts = {}) {
71
77
  ...(screen.describe !== undefined ? { describe: screen.describe } : {}),
72
78
  });
73
79
 
80
+ // A walk goes through the same events as a check, so the live window draws a
81
+ // walkthrough exactly the way it draws a run of picture checks.
82
+ emitEvent(events, {
83
+ type: 'screen:start',
84
+ name: screen.name,
85
+ describe: screen.describe,
86
+ index,
87
+ total: chosen.length,
88
+ });
89
+
90
+ /** @type {{shot?: string, shotFile?: string}} */
91
+ const thumbs = {};
74
92
  const step = await walkOneStep(page, screen, {
75
93
  index,
76
94
  dir,
77
95
  settings: settingsForScreen(config, screen),
78
96
  fixturesDir: paths.fixtures,
79
97
  record: opts.record ?? false,
98
+ events,
99
+ thumbnail: opts.thumbnail === true,
100
+ thumbs,
101
+ timings: opts.timings,
80
102
  });
81
103
  steps.push(step);
82
104
 
105
+ emitEvent(events, {
106
+ type: 'screen:done',
107
+ name: step.name,
108
+ describe: step.describe,
109
+ // A walk has nothing to compare against, so a step either happened or it
110
+ // did not. Anything the app complained about is said in the message.
111
+ status: step.error ? 'failed' : 'passed',
112
+ durationMs: step.durationMs,
113
+ message: stepMessage(step),
114
+ thumbnail: thumbs.shot,
115
+ // The real photo of this step, at full resolution. A walk has nothing to compare
116
+ // against, so there is no approved picture and no difference to point at.
117
+ shotFile: thumbs.shotFile,
118
+ });
119
+
83
120
  opts.onStep?.({
84
121
  phase: 'done',
85
122
  index,
@@ -115,6 +152,10 @@ export async function walkApp(project, app, opts = {}) {
115
152
  * settings: ReturnType<typeof settingsForScreen>,
116
153
  * fixturesDir: string,
117
154
  * record: boolean,
155
+ * events?: import('../types.js').RunEvents,
156
+ * thumbnail?: boolean,
157
+ * thumbs?: {shot?: string, shotFile?: string},
158
+ * timings?: ReturnType<typeof import('../core/events.js').makeTimings>,
118
159
  * }} ctx
119
160
  * @returns {Promise<import('../types.js').WalkStep>}
120
161
  */
@@ -136,10 +177,13 @@ async function walkOneStep(page, screen, ctx) {
136
177
  const shot = await captureScreen(page, screen, ctx.settings, {
137
178
  fixturesDir: ctx.fixturesDir,
138
179
  record: ctx.record,
180
+ thumbnail: ctx.thumbnail === true,
139
181
  });
182
+ accountForCapture(ctx.timings, shot);
140
183
  await fsp.writeFile(target, shot.png);
141
184
  file = target;
142
185
  consoleErrors = shot.consoleErrors;
186
+ announceShot(ctx, screen.name, target, shot.thumbnail);
143
187
  } catch (cause) {
144
188
  error = messageOf(cause);
145
189
  consoleErrors = readConsole(page);
@@ -149,6 +193,10 @@ async function walkOneStep(page, screen, ctx) {
149
193
  try {
150
194
  await fsp.writeFile(target, await page.shoot());
151
195
  file = target;
196
+ // No preview for this one: the picture was taken by hand after the capture
197
+ // broke, so nothing shrank it. The panel loads the real file instead, which is
198
+ // the one a person needs to see anyway.
199
+ announceShot(ctx, screen.name, target, undefined);
152
200
  } catch {
153
201
  file = '';
154
202
  }
@@ -173,6 +221,61 @@ async function walkOneStep(page, screen, ctx) {
173
221
  return step;
174
222
  }
175
223
 
224
+ /**
225
+ * Tell anyone watching that this step has a picture now.
226
+ *
227
+ * Called only once the photo is ON DISK. The watch panel loads the real file so a
228
+ * person can zoom into true pixels, and an <img> pointed at a file that does not exist
229
+ * yet draws a torn page and never tries again — so the address is never announced early.
230
+ * The shrunken preview rides along on the same event and covers the moment the real
231
+ * file takes to load.
232
+ *
233
+ * @param {{events?: import('../types.js').RunEvents, thumbs?: {shot?: string, shotFile?: string}}} ctx
234
+ * @param {string} name
235
+ * @param {string} file Absolute path to the photo, already written.
236
+ * @param {string|undefined} thumbnail
237
+ * @returns {void}
238
+ */
239
+ function announceShot(ctx, name, file, thumbnail) {
240
+ if (ctx.thumbs) {
241
+ if (thumbnail) ctx.thumbs.shot = thumbnail;
242
+ ctx.thumbs.shotFile = fileUrl(file);
243
+ }
244
+ emitEvent(ctx.events, { type: 'screen:shot', name, thumbnail, shotFile: fileUrl(file) });
245
+ }
246
+
247
+ /**
248
+ * How many screens a walk is about to visit.
249
+ *
250
+ * The engine needs this before it opens anything, so it can tell a watcher how
251
+ * long the list will be. It asks the same function the walk itself asks, because
252
+ * two places counting the same thing differently is how a progress bar starts
253
+ * lying.
254
+ *
255
+ * @param {import('../types.js').ResolvedConfig} config
256
+ * @param {string|string[]} [only]
257
+ * @returns {number}
258
+ */
259
+ export function countWalkSteps(config, only) {
260
+ return chooseSteps(config, only).length;
261
+ }
262
+
263
+ /**
264
+ * What to say about a finished step, in plain language, or nothing when it went
265
+ * through cleanly.
266
+ *
267
+ * @param {import('../types.js').WalkStep} step
268
+ * @returns {string|undefined}
269
+ */
270
+ function stepMessage(step) {
271
+ if (step.error) return step.error;
272
+ const complaints = (step.consoleErrors ?? []).length;
273
+ if (complaints === 0) return undefined;
274
+ return complaints === 1
275
+ ? 'The app logged one error while this screen was open.'
276
+ : `The app logged ${complaints} errors while this screen was open.`;
277
+ }
278
+
176
279
  /**
177
280
  * Which screens the walk visits: `walk.steps` when the project spelled one out,
178
281
  * otherwise every screen it already checks, in the order they are written.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * The live watch window, from the run's point of view.
3
+ *
4
+ * A run describes itself into an event stream and carries on. This subscribes to
5
+ * that stream, opens a panel beside the app, and forwards everything to it. It
6
+ * has no other effect on the run: it takes nothing away from it, it holds
7
+ * nothing up, and when the window cannot open the run does not notice.
8
+ *
9
+ * Because the stream hands a new listener everything that already happened, a
10
+ * panel that takes two seconds to open still draws the screens photographed
11
+ * before it was there.
12
+ *
13
+ * The panel opens before the run does, and the app it belongs beside is opened
14
+ * inside the run — so the two are introduced afterwards, through `snapTo`. That
15
+ * is the whole of the seam: the run says "here is the app", the panel moves
16
+ * itself flush against it, and nothing about the app's page is touched either way.
17
+ */
18
+
19
+ import path from 'node:path';
20
+
21
+ import { warn, detail } from '../core/log.js';
22
+ import { messageOf } from '../core/errors.js';
23
+ import { loadGuards } from '../guard/load.js';
24
+ import { openPanel } from './window.js';
25
+
26
+ /** Panel width when nobody says otherwise. */
27
+ const DEFAULT_WIDTH = 460;
28
+
29
+ /**
30
+ * What the panel is told to do.
31
+ *
32
+ * `snap` is written down here rather than in `src/types.js` so that whether the
33
+ * two windows are pushed together stays a decision of the watch code. When
34
+ * `WatchOptions` grows a `snap` of its own this intersection quietly becomes a
35
+ * no-op.
36
+ *
37
+ * @typedef {import('../types.js').WatchOptions & {snap?: boolean}} PanelOptions
38
+ */
39
+
40
+ /**
41
+ * What the CLI hands over. Anything the person did not type is left out, so the
42
+ * settings file still gets a say.
43
+ * @typedef {object} WatchFlags
44
+ * @property {boolean} [enabled]
45
+ * @property {'left'|'right'} [side]
46
+ * @property {number} [width]
47
+ * @property {number} [height]
48
+ * @property {boolean} [keepOpen]
49
+ * @property {boolean} [foreground]
50
+ * @property {boolean} [snap]
51
+ * @property {'dark'|'light'|'system'} [theme]
52
+ */
53
+
54
+ /**
55
+ * @typedef {object} AttachOptions
56
+ * @property {import('../types.js').Project} [project]
57
+ * @property {import('./window.js').PlanInput} [plan] Given only when the caller knows better than the project does.
58
+ * @property {PanelOptions} [watch]
59
+ * @property {{width: number, height: number}} [appViewport]
60
+ */
61
+
62
+ /**
63
+ * A run's handle on its panel.
64
+ *
65
+ * `snapTo` is how the run introduces the app once it is open. It is safe to call
66
+ * when there is no panel, when the panel cannot move windows, and when the
67
+ * person asked for `--no-snap`: in every one of those cases it does nothing.
68
+ *
69
+ * @typedef {object} Watcher
70
+ * @property {() => Promise<void>} stop
71
+ * @property {(app: import('../types.js').LaunchedApp) => Promise<void>} snapTo
72
+ */
73
+
74
+ /**
75
+ * The one part of a panel this file asks for by name.
76
+ *
77
+ * `snapTo` is built in `window.js` and is optional here on purpose: a panel that
78
+ * cannot move windows is still a perfectly good panel, and is simply never asked
79
+ * to move.
80
+ *
81
+ * @typedef {{snapTo?: (app: import('../types.js').LaunchedApp) => Promise<void>}} Snappable
82
+ */
83
+
84
+ /**
85
+ * A watcher that is not watching anything. Handed back whenever the panel could
86
+ * not open, so every caller has the same shape to stop, whatever happened.
87
+ * @returns {Watcher}
88
+ */
89
+ function noWatcher() {
90
+ return { stop: async () => {}, snapTo: async () => {} };
91
+ }
92
+
93
+ /**
94
+ * The guards, for the opening list.
95
+ *
96
+ * They live in files, so this reads the folder the same way the run will. It is
97
+ * cheap, and it is the difference between a window that shows the whole plan
98
+ * from the first frame and one that fills in as it goes. A guards folder that
99
+ * will not load is the run's problem to report, not the panel's — here it just
100
+ * means the guards appear as they start.
101
+ *
102
+ * @param {import('../types.js').Project|undefined} project
103
+ * @returns {Promise<import('./panel.js').PanelRow[]>}
104
+ */
105
+ async function guardRows(project) {
106
+ if (!project) return [];
107
+ try {
108
+ const guards = await loadGuards(project);
109
+ return guards
110
+ .filter((guard) => guard && guard.skip !== true)
111
+ .map((guard) => ({ name: String(guard.name), describe: guard.because ? String(guard.because) : undefined }));
112
+ } catch {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ /**
118
+ * @param {import('../types.js').Project|undefined} project
119
+ * @returns {import('./panel.js').PanelRow[]}
120
+ */
121
+ function screenRows(project) {
122
+ const screens = project?.config?.screens ?? [];
123
+ return screens
124
+ .filter((screen) => screen && screen.skip !== true)
125
+ .map((screen) => ({ name: String(screen.name), describe: screen.describe ? String(screen.describe) : undefined }));
126
+ }
127
+
128
+ /**
129
+ * @param {AttachOptions} opts
130
+ * @returns {Promise<import('./window.js').PlanInput>}
131
+ */
132
+ async function planFor(opts) {
133
+ const given = opts.plan ?? {};
134
+ const project = opts.project;
135
+ return {
136
+ project: given.project ?? (project ? path.basename(project.paths.root) : undefined),
137
+ app: given.app ?? project?.config?.app,
138
+ screens: given.screens ?? screenRows(project),
139
+ guards: given.guards ?? (await guardRows(project)),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Open a panel and point it at a run.
145
+ *
146
+ * Never throws. A live view is worth having and worth nothing next to the
147
+ * pictures, so every way this can go wrong ends the same way: a warning in
148
+ * plain words, a watcher that does nothing, and a run that carries on.
149
+ *
150
+ * @param {import('../types.js').RunEvents} events
151
+ * @param {AttachOptions} [opts]
152
+ * @returns {Promise<Watcher>}
153
+ */
154
+ export async function attachWatcher(events, opts = {}) {
155
+ const watch = opts.watch ?? {};
156
+ if (watch.enabled === false) return noWatcher();
157
+ if (!events || typeof events.on !== 'function') return noWatcher();
158
+
159
+ /** @type {import('./window.js').Panel|null} */
160
+ let panel = null;
161
+ try {
162
+ panel = await openPanel({
163
+ project: opts.project,
164
+ plan: await planFor(opts),
165
+ watch,
166
+ appViewport: opts.appViewport ?? opts.project?.config?.viewport,
167
+ });
168
+ } catch (e) {
169
+ // openPanel reports its own trouble and hands back null; this is only here
170
+ // for whatever it could not have seen coming.
171
+ warn(`The watch window could not open, so this run has no live view. ${messageOf(e)}`);
172
+ panel = null;
173
+ }
174
+ if (!panel) return noWatcher();
175
+ const open = panel;
176
+
177
+ // Everything that already happened arrives here first, in order, before the
178
+ // first live event does.
179
+ const unsubscribe = events.on((event) => {
180
+ // Queued, never awaited: the run must not wait on a window.
181
+ void open.push(event);
182
+ });
183
+
184
+ // `--no-snap` is honoured here, not in the panel, so that "leave both windows
185
+ // exactly where they are" holds however the panel is built.
186
+ const maySnap = watch.snap !== false;
187
+ const snappable = /** @type {Snappable} */ (/** @type {unknown} */ (open));
188
+
189
+ /** @type {Promise<void>|null} */
190
+ let stopping = null;
191
+
192
+ return {
193
+ stop: () => {
194
+ stopping ??= (async () => {
195
+ try {
196
+ unsubscribe();
197
+ } catch {
198
+ // Already gone. Nothing left to stop listening to.
199
+ }
200
+ await open.close().catch(() => {});
201
+ })();
202
+ return stopping;
203
+ },
204
+
205
+ snapTo: async (app) => {
206
+ if (!maySnap || typeof snappable.snapTo !== 'function') return;
207
+ try {
208
+ await snappable.snapTo(app);
209
+ } catch (e) {
210
+ // Where two windows sit is a nicety. Losing it must never cost a run,
211
+ // and it is not worth a warning in the middle of a clean one.
212
+ detail(`The panel could not put itself beside the app. ${messageOf(e)}`);
213
+ }
214
+ },
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Settle what the panel should do: the settings file first, then anything typed
220
+ * on the command line, then the defaults written down in `src/types.js`.
221
+ *
222
+ * `--watch` can only ever turn the panel on. A person who did not type it has
223
+ * not said no to it — they have said nothing — so it never switches off a panel
224
+ * the settings file asked for.
225
+ *
226
+ * @param {{watch?: import('../types.js').WatchOptions|boolean}|null} [config]
227
+ * @param {WatchFlags|null} [cli]
228
+ * @returns {PanelOptions}
229
+ */
230
+ export function watchOptionsFrom(config, cli) {
231
+ const raw = config?.watch;
232
+ // Read as a PanelOptions on the way in: a settings file is free to carry a
233
+ // `snap` that the shared `WatchOptions` shape does not yet describe.
234
+ const settings = /** @type {PanelOptions} */ (
235
+ raw === true ? { enabled: true } : raw && typeof raw === 'object' ? raw : {}
236
+ );
237
+ const flags = cli ?? {};
238
+
239
+ const width = firstNumber(flags.width, settings.width) ?? DEFAULT_WIDTH;
240
+ const height = firstNumber(flags.height, settings.height);
241
+ const side = flags.side ?? settings.side ?? 'right';
242
+ const theme = /** @type {any} */ (flags).theme ?? settings.theme;
243
+
244
+ /** @type {PanelOptions} */
245
+ const merged = {
246
+ enabled: flags.enabled === true || settings.enabled === true,
247
+ width,
248
+ side: side === 'left' ? 'left' : 'right',
249
+ keepOpen: firstBoolean(flags.keepOpen, settings.keepOpen) ?? true,
250
+ foreground: firstBoolean(flags.foreground, settings.foreground) ?? false,
251
+ // On by default: the point of the panel is that it reads as a side panel of
252
+ // the app it is checking, and it cannot do that sitting somewhere else.
253
+ snap: firstBoolean(flags.snap, settings.snap) ?? true,
254
+ // Dark unless somebody asks otherwise. The panel opens on a brand new browser
255
+ // profile, and a fresh profile insists the computer is in light mode however it
256
+ // is actually set, so this is stated rather than detected.
257
+ theme: theme === 'light' || theme === 'system' ? theme : 'dark',
258
+ };
259
+ // Left out rather than guessed: with no height the panel is as tall as the app.
260
+ if (height !== undefined) merged.height = height;
261
+ return merged;
262
+ }
263
+
264
+ /**
265
+ * @param {...unknown} values
266
+ * @returns {number|undefined}
267
+ */
268
+ function firstNumber(...values) {
269
+ for (const value of values) {
270
+ if (value === undefined || value === null || value === '') continue;
271
+ const n = Number(value);
272
+ if (Number.isFinite(n) && n > 0) return Math.round(n);
273
+ }
274
+ return undefined;
275
+ }
276
+
277
+ /**
278
+ * @param {...unknown} values
279
+ * @returns {boolean|undefined}
280
+ */
281
+ function firstBoolean(...values) {
282
+ for (const value of values) {
283
+ if (typeof value === 'boolean') return value;
284
+ }
285
+ return undefined;
286
+ }