staysfixed 0.2.3 → 0.3.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.
@@ -13,6 +13,11 @@ import { applyFreeze, prepareForShutter } from '../freeze/index.js';
13
13
  import { settle } from '../freeze/settle.js';
14
14
  import { resolveMasks, paintMasks } from '../freeze/mask.js';
15
15
  import { StaysFixedError, isExpected, messageOf } from '../core/errors.js';
16
+ // The words a step is described by, and the two builders that turn what is known at
17
+ // this instant into one line of the list. Imported rather than written again here so
18
+ // the line a person watches tick is the same line, in the same words, that the
19
+ // finished list hands back a moment later.
20
+ import { CHECK_KEYS, CHECK_LABELS, checkStep, runningStep } from '../core/events.js';
16
21
  // store.js reads `pngSize` back out of this file. Two modules about the same pictures
17
22
  // leaning on each other is fine here — both sides are plain functions, so neither is
18
23
  // half-built when the other asks for it.
@@ -51,10 +56,23 @@ const KNOWN_KEYS = new Set([...ACTION_ORDER, 'text', 'note']);
51
56
  * freeze: import('../types.js').FreezeConfig,
52
57
  * masks: import('../types.js').Mask[],
53
58
  * }} settings
54
- * @param {{fixturesDir: string, record?: boolean, timeoutMs?: number, thumbnail?: boolean}} ctx
59
+ * @param {{
60
+ * fixturesDir: string,
61
+ * record?: boolean,
62
+ * timeoutMs?: number,
63
+ * thumbnail?: boolean,
64
+ * onStep?: (step: import('../types.js').CheckStep) => void,
65
+ * }} ctx
55
66
  * `thumbnail` is asked for only while somebody is watching the run happen; it costs a
56
67
  * decode of the picture that was just taken (skipped when the masks already decoded it)
57
68
  * plus about forty milliseconds of shrinking, so it is off unless it is wanted.
69
+ *
70
+ * `onStep` is the same idea for words instead of pixels: hand one in and this says
71
+ * what it is doing as it does it — each phase named the moment it starts and named
72
+ * again the moment it settles — so a watcher can tick the list off live instead of
73
+ * being shown a finished table. Gated exactly the way `thumbnail` is: the run hands
74
+ * one in only while a panel is open, so the ordinary case is one `typeof` check per
75
+ * phase and nothing allocated at all.
58
76
  * @returns {Promise<import('../types.js').CaptureReport & {
59
77
  * masks: import('../types.js').MaskRect[],
60
78
  * timings: {steps: number, prepare: number, settle: number},
@@ -83,12 +101,88 @@ export async function captureScreen(page, screen, settings, ctx) {
83
101
  await page.setViewport(settings.viewport);
84
102
  page.clearConsole();
85
103
 
104
+ // What the freeze layer is being ASKED for. Worked out before it is asked, because a
105
+ // line announced as it starts can only be worded from what is already known — and a
106
+ // project that switched the clock freezing off has to be told that at the moment it
107
+ // does not happen, not have a freeze claimed and then taken back.
108
+ /** @type {import('../core/events.js').FrozenPlan} */
109
+ const plan = {
110
+ clock: settings.freeze.clock !== false,
111
+ motion: settings.freeze.motion !== false,
112
+ random: settings.freeze.random !== 'off',
113
+ fonts: settings.freeze.fonts !== false,
114
+ network: settings.freeze.network ?? 'block-external',
115
+ frames: settleConfig.frames ?? 2,
116
+ maxDriftPixels: settleConfig.maxDriftPixels ?? 0,
117
+ };
118
+ const masksAsked = (settings.masks ?? []).length;
119
+
120
+ // Gated exactly the way `thumbnail` is: the run hands an `onStep` in only while a
121
+ // panel is open. Nobody watching means this is null, every announcement below is one
122
+ // `if` that does nothing, and not a single object is made.
123
+ const onStep = typeof ctx.onStep === 'function' ? ctx.onStep : null;
124
+ /**
125
+ * The line announced as started and not yet settled — so a failure can close it
126
+ * instead of leaving a row spinning forever on somebody's screen.
127
+ * @type {import('../types.js').CheckStep|null}
128
+ */
129
+ let pending = null;
130
+
131
+ /**
132
+ * Name a phase as it begins.
133
+ * @param {import('../core/events.js').CheckKey} key
134
+ * @param {import('../core/events.js').ChecksInput} input
135
+ * @returns {void}
136
+ */
137
+ const begins = (key, input) => {
138
+ if (!onStep) return;
139
+ const step = runningStep(key, input);
140
+ pending = step ?? null;
141
+ if (step) onStep(step);
142
+ };
143
+
144
+ /**
145
+ * Name the same phase again, settled, under the same key.
146
+ * @param {import('../core/events.js').CheckKey} key
147
+ * @param {import('../core/events.js').ChecksInput} input
148
+ * @param {import('../types.js').CheckStep} [instead] For a line whose number is not
149
+ * knowable yet; the finished list fills that in.
150
+ * @returns {void}
151
+ */
152
+ const settled = (key, input, instead) => {
153
+ if (!onStep) return;
154
+ pending = null;
155
+ const step = instead ?? checkStep(key, input);
156
+ if (step) onStep(step);
157
+ };
158
+
159
+ /**
160
+ * Close whatever was in flight when something went wrong.
161
+ * @param {unknown} error
162
+ * @returns {void}
163
+ */
164
+ const closePending = (error) => {
165
+ if (!onStep || !pending) return;
166
+ const why = messageOf(error);
167
+ // Where a phase has its own wording for going wrong, use it: "could not reach the
168
+ // screen" is what happened, and leaving "reached the screen" up with a cross beside
169
+ // it says the opposite of the truth for as long as anybody is reading it.
170
+ const label = pending.key === CHECK_KEYS.steps ? CHECK_LABELS.stepsFailed : pending.label;
171
+ onStep({ ...pending, label, state: 'bad', detail: why });
172
+ pending = null;
173
+ };
174
+
175
+ begins(CHECK_KEYS.frozen, { screen, frozen: plan });
86
176
  const frozen = await applyFreeze(page, settings.freeze, {
87
177
  fixturesDir: ctx.fixturesDir,
88
178
  screenName: screen.name,
89
179
  record: ctx.record ?? false,
90
180
  deviceScaleFactor,
181
+ }).catch((error) => {
182
+ closePending(error);
183
+ throw error;
91
184
  });
185
+ settled(CHECK_KEYS.frozen, { screen, frozen: plan });
92
186
 
93
187
  // A frozen clock cannot time anything, so the stopwatch is the host's own, and it is
94
188
  // the monotonic one: a machine that adjusts its clock mid-run must not be able to
@@ -99,11 +193,13 @@ export async function captureScreen(page, screen, settings, ctx) {
99
193
  const spent = { steps: 0, prepare: 0, settle: 0 };
100
194
 
101
195
  try {
196
+ begins(CHECK_KEYS.steps, { screen });
102
197
  if (typeof screen.do === 'function') {
103
198
  await screen.do(page);
104
199
  } else {
105
200
  await runSteps(page, screen.steps ?? []);
106
201
  }
202
+ settled(CHECK_KEYS.steps, { screen });
107
203
 
108
204
  // Scrolling back to the top is the deterministic default, but a screen that
109
205
  // deliberately scrolled somewhere must be photographed where it landed.
@@ -113,11 +209,21 @@ export async function captureScreen(page, screen, settings, ctx) {
113
209
  const startedPrepare = clock();
114
210
  spent.steps = since(startedSteps, startedPrepare);
115
211
 
212
+ // Waiting for the page to be ready. WHAT was still loading can only be read off the
213
+ // frame that ends up being kept, which does not exist yet, so this line settles on
214
+ // the one thing that is true here — the shutter waited — and the finished list fills
215
+ // in the count of faces and pictures a moment later, on the same line.
216
+ begins(CHECK_KEYS.loaded, { screen, frozen: plan });
116
217
  await prepareForShutter(page, {
117
218
  fonts: settings.freeze.fonts !== false,
118
219
  timeoutMs,
119
220
  keepScroll: scrolledOnPurpose,
120
221
  });
222
+ settled(
223
+ CHECK_KEYS.loaded,
224
+ { screen, frozen: plan },
225
+ plan.fonts ? { key: CHECK_KEYS.loaded, label: CHECK_LABELS.loaded, state: 'ok' } : undefined,
226
+ );
121
227
 
122
228
  /** @type {import('../types.js').CaptureOptions} */
123
229
  const shotOptions = {};
@@ -133,6 +239,10 @@ export async function captureScreen(page, screen, settings, ctx) {
133
239
  const startedSettle = clock();
134
240
  spent.prepare = since(startedPrepare, startedSettle);
135
241
 
242
+ // Holding still — and the shutter is inside it. The settle loop photographs the
243
+ // screen over and over until two frames in a row are identical and keeps the last of
244
+ // them, so this is ONE line rather than two: there is one thing happening.
245
+ begins(CHECK_KEYS.settle, { screen, frozen: plan });
136
246
  const held = await settle(page, {
137
247
  frames: settleConfig.frames ?? 2,
138
248
  intervalMs: settleConfig.intervalMs ?? 250,
@@ -142,6 +252,7 @@ export async function captureScreen(page, screen, settings, ctx) {
142
252
  probe: () => page.shoot(probeOptions),
143
253
  });
144
254
  spent.settle = since(startedSettle, clock());
255
+ settled(CHECK_KEYS.settle, { screen, settle: held.report, frozen: plan });
145
256
 
146
257
  // Asked the moment the picture exists, and never before: this is a statement about
147
258
  // the frame that was kept. Reading it is a single round trip that touches nothing —
@@ -151,6 +262,7 @@ export async function captureScreen(page, screen, settings, ctx) {
151
262
  // question later; by then the app has moved on.
152
263
  const loaded = await readLoaded(page);
153
264
 
265
+ begins(CHECK_KEYS.masks, { screen, masksAsked });
154
266
  const rects = await resolveMasks(page, settings.masks ?? [], { deviceScaleFactor });
155
267
  // Masks force us to decode the picture; hold on to those pixels. The preview a
156
268
  // watcher is shown is made from the very same ones — the picture that gets
@@ -159,6 +271,9 @@ export async function captureScreen(page, screen, settings, ctx) {
159
271
  const painted = rects.length > 0 ? paintInto(held.png, rects) : null;
160
272
  const png = painted ? painted.png : held.png;
161
273
  const size = pngSize(png);
274
+ // Said after the painting, not after the finding: the line claims the boxes are on
275
+ // the picture, so it waits until they are.
276
+ settled(CHECK_KEYS.masks, { screen, masks: rects, masksAsked });
162
277
 
163
278
  // The picture is taken; now put the app back if this screen asked us to.
164
279
  //
@@ -173,6 +288,13 @@ export async function captureScreen(page, screen, settings, ctx) {
173
288
  await runSteps(page, screen.after);
174
289
  }
175
290
 
291
+ // Requests are not a phase — the outside world is kept out for the whole capture —
292
+ // so this is one line, said once, at the end. Counted here rather than a few lines
293
+ // earlier so the number a watcher sees is the same number the finished list carries,
294
+ // including anything the putting-back steps asked for.
295
+ const stats = frozen.stats();
296
+ settled(CHECK_KEYS.network, { screen, freeze: stats, frozen: plan });
297
+
176
298
  /** @type {import('../types.js').CaptureReport & {masks: import('../types.js').MaskRect[], timings: typeof spent, spent: typeof spent, frozen: import('../core/events.js').FrozenPlan, loaded?: import('../core/events.js').LoadedReport, thumbnail?: string}} */
177
299
  const report = {
178
300
  png,
@@ -180,20 +302,13 @@ export async function captureScreen(page, screen, settings, ctx) {
180
302
  height: size.height,
181
303
  settle: held.report,
182
304
  consoleErrors: page.consoleErrors(),
183
- freeze: frozen.stats(),
305
+ freeze: stats,
184
306
  masks: rects,
185
- // What was asked of the freeze layer, in the same words the config used. Written
186
- // down here rather than worked out later, because by the time anybody reports on
187
- // this screen the settings have been merged away.
188
- frozen: {
189
- clock: settings.freeze.clock !== false,
190
- motion: settings.freeze.motion !== false,
191
- random: settings.freeze.random !== 'off',
192
- fonts: settings.freeze.fonts !== false,
193
- network: settings.freeze.network ?? 'block-external',
194
- frames: settleConfig.frames ?? 2,
195
- maxDriftPixels: settleConfig.maxDriftPixels ?? 0,
196
- },
307
+ // What was asked of the freeze layer, in the same words the config used. Worked
308
+ // out at the top rather than here, because by the time anybody reports on this
309
+ // screen the settings have been merged away — and because the live list needs the
310
+ // same answer before any of this happens, and the two must never disagree.
311
+ frozen: plan,
197
312
  // The same three numbers under both names the rest of the tool asks for them by.
198
313
  timings: spent,
199
314
  spent,
@@ -204,6 +319,11 @@ export async function captureScreen(page, screen, settings, ctx) {
204
319
  if (small) report.thumbnail = small;
205
320
  }
206
321
  return report;
322
+ } catch (error) {
323
+ // A row that stays "running" for the rest of the run is a lie about what happened.
324
+ // The run reports the failure itself; this only closes the line it fell over on.
325
+ closePending(error);
326
+ throw error;
207
327
  } finally {
208
328
  // Releasing must never be the thing that hides a real failure.
209
329
  try {
@@ -21,7 +21,7 @@ import { resetWindow } from '../drive/launch.js';
21
21
  import { gitInfo } from '../core/git.js';
22
22
  import { messageOf } from '../core/errors.js';
23
23
  import { detail } from '../core/log.js';
24
- import { emitEvent, fileUrl, buildChecks } from '../core/events.js';
24
+ import { emitEvent, fileUrl, buildChecks, checkStep, runningStep, CHECK_KEYS } from '../core/events.js';
25
25
 
26
26
  /**
27
27
  * A picture result plus two things the plain result has no room for: whether it
@@ -231,6 +231,50 @@ async function runOneScreen(project, page, screen, ctx) {
231
231
  /** @type {import('../types.js').CompareReport|null} */
232
232
  let compare = null;
233
233
  let attempts = 0;
234
+
235
+ /**
236
+ * Say one thing about this screen the moment it is true.
237
+ *
238
+ * The finished list at the end is still the authority — a watcher that missed every
239
+ * one of these ends up with exactly the same rows — but a person who is watching
240
+ * wants to see the working tick past as it happens rather than appear all at once
241
+ * when the screen is already over.
242
+ *
243
+ * Only while somebody is watching, and gated on the very same flag the small pictures
244
+ * are: the run sets `thumbnail` when a panel is open. Nobody watching means this is
245
+ * undefined, nothing is built and nothing is sent.
246
+ *
247
+ * @type {((step: import('../types.js').CheckStep) => void)|undefined}
248
+ */
249
+ const onStep =
250
+ ctx.thumbnail === true && ctx.events
251
+ ? (/** @type {import('../types.js').CheckStep} */ step) => {
252
+ emitEvent(ctx.events, { type: 'screen:step', name: screen.name, step });
253
+ }
254
+ : undefined;
255
+
256
+ /**
257
+ * One line of the working, built from what is known at this instant.
258
+ *
259
+ * @param {import('../core/events.js').CheckKey} key
260
+ * @param {boolean} [starting] True to name it as it begins, false once it has settled.
261
+ * @returns {void}
262
+ */
263
+ function tell(key, starting) {
264
+ if (!onStep) return;
265
+ /** @type {import('../core/events.js').ChecksInput} */
266
+ const known = {
267
+ screen,
268
+ size,
269
+ approvedSize: compare?.approvedSize,
270
+ compare,
271
+ hasApproved: Boolean(approved),
272
+ tolerance: settings.tolerance,
273
+ consoleErrors,
274
+ };
275
+ const step = starting ? runningStep(key, known) : checkStep(key, known);
276
+ if (step) onStep(step);
277
+ }
234
278
  /** @type {Awaited<ReturnType<typeof captureScreen>>|undefined} */
235
279
  let last;
236
280
 
@@ -288,6 +332,10 @@ async function runOneScreen(project, page, screen, ctx) {
288
332
  record: ctx.record ?? false,
289
333
  timeoutMs: settings.freeze.settle?.timeoutMs,
290
334
  thumbnail: ctx.thumbnail === true,
335
+ // The phases inside one photograph — freezing, the recipe, waiting, holding
336
+ // still, painting over the live areas — can only be announced by the thing
337
+ // doing them. Everything after the shutter is announced here instead.
338
+ onStep,
291
339
  });
292
340
  accountForCapture(ctx.timings, shot);
293
341
  last = shot;
@@ -318,6 +366,12 @@ async function runOneScreen(project, page, screen, ctx) {
318
366
 
319
367
  if (!approved) break;
320
368
 
369
+ // These two lines exist only once there is something to compare against, so they
370
+ // are said here rather than by the capture: the size match and the pixel count are
371
+ // both answered by the single call below.
372
+ tell(CHECK_KEYS.size, true);
373
+ tell(CHECK_KEYS.pixels, true);
374
+
321
375
  const stopCompare = ctx.timings?.mark('compare');
322
376
  // compareFast, not comparePng: identical bytes are answered from the PNG header
323
377
  // instead of decoding two retina images pixel by pixel. Nothing changed on most
@@ -325,11 +379,23 @@ async function runOneScreen(project, page, screen, ctx) {
325
379
  // comparing stage from about a quarter of a second a screen to nothing at all.
326
380
  compare = compareFast(approved.png, shot.png, settings.tolerance, shot.masks);
327
381
  stopCompare?.();
382
+ tell(CHECK_KEYS.size);
383
+ tell(CHECK_KEYS.pixels);
328
384
  if (compare.equal) break;
329
385
  if (attempt <= ctx.retries) {
330
386
  detail(`${screen.name} looked different on attempt ${attempt} — taking it again.`);
331
387
  }
332
388
  }
389
+
390
+ if (!approved) {
391
+ // Nothing to measure this against, which is an answer rather than a gap. Said
392
+ // here because the loop above never reached a comparison to say it.
393
+ tell(CHECK_KEYS.size);
394
+ tell(CHECK_KEYS.pixels);
395
+ }
396
+ // Whatever the page shouted while nobody was looking. Known from the moment the
397
+ // picture was taken, and the last line of the working either way.
398
+ tell(CHECK_KEYS.console);
333
399
  } catch (error) {
334
400
  return done(
335
401
  {
package/src/types.js CHANGED
@@ -304,6 +304,9 @@
304
304
  * @property {string} [failedAt] The plain-language expectation that failed.
305
305
  * @property {string} [file]
306
306
  * @property {string} [because]
307
+ * @property {CheckStep[]} [checks] Every claim the guard asserted and every action it took,
308
+ * in order. A guard that failed still shows the claims that
309
+ * held before it — that is most of the value.
307
310
  * @property {number} durationMs
308
311
  * @property {number} [attempts]
309
312
  */
@@ -482,7 +485,7 @@ export {};
482
485
  * run began) so a watcher can draw a timeline without keeping its own clock.
483
486
  *
484
487
  * @typedef {object} RunEvent
485
- * @property {'run:start'|'screen:start'|'screen:shot'|'screen:done'|'guard:start'|'guard:done'|'phase'|'note'|'run:done'} type
488
+ * @property {'run:start'|'screen:start'|'screen:step'|'screen:shot'|'screen:done'|'guard:start'|'guard:step'|'guard:done'|'phase'|'note'|'run:done'} type
486
489
  * @property {number} at Milliseconds since the run started.
487
490
  * @property {string} [name] Screen or guard name.
488
491
  * @property {string} [describe] The plain-language description.
@@ -503,6 +506,8 @@ export {};
503
506
  * thumbnail is unreadable, which is the whole point of looking.
504
507
  * @property {string} [approvedFile] file:// URL of the approved picture.
505
508
  * @property {string} [diffFile] file:// URL of the difference image.
509
+ * @property {CheckStep} [step] One step, reported the moment it happens, so a watcher can
510
+ * tick the list off live instead of waiting for the verdict.
506
511
  * @property {CheckStep[]} [checks] What was actually done to this screen, in order.
507
512
  * A verdict on its own ("matches") does not tell anyone what
508
513
  * was verified, and a row showing only a name and a duration
@@ -559,5 +564,7 @@ export {};
559
564
  * @typedef {object} CheckStep
560
565
  * @property {string} label Plain language: "held still", "every pixel compared".
561
566
  * @property {string} [detail] The number behind it: "5,184,000 pixels, none different".
562
- * @property {'ok'|'warn'|'bad'|'skipped'} state
567
+ * @property {'running'|'ok'|'warn'|'bad'|'skipped'} state
568
+ * @property {string} [key] Stable id, so a step reported as 'running' can be found
569
+ * again and settled when it finishes.
563
570
  */