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/src/run.js CHANGED
@@ -16,13 +16,14 @@ import { ensureDirs, clearResults, resultPicture, safeName } from './core/paths.
16
16
  import { gitInfo } from './core/git.js';
17
17
  import { loadHistory, saveHistory, foldRun, condemned } from './core/history.js';
18
18
  import { warn, detail, shortPath } from './core/log.js';
19
+ import { makeTimings, emitEvent } from './core/events.js';
19
20
  import { launchApp } from './drive/launch.js';
20
21
  import { platformTag } from './drive/find.js';
21
22
  import { runPictures } from './picture/run.js';
22
23
  import { approveFromResult, listApproved } from './picture/store.js';
23
24
  import { loadGuards } from './guard/load.js';
24
25
  import { runGuards } from './guard/run.js';
25
- import { walkApp, writeWalkContactSheet } from './walk/run.js';
26
+ import { walkApp, writeWalkContactSheet, countWalkSteps } from './walk/run.js';
26
27
  import { listMarkers } from './marker/mark.js';
27
28
  import { writeRunReport } from './report/html.js';
28
29
  import { printPictureResult, printGuardResult } from './report/console.js';
@@ -83,6 +84,10 @@ const LAST_RUN = 'last-run.json';
83
84
  * onPicture?: (result: import('./types.js').PictureResult) => void,
84
85
  * onGuard?: (result: import('./types.js').GuardResult) => void,
85
86
  * writeReport?: boolean,
87
+ * events?: import('./types.js').RunEvents,
88
+ * watching?: boolean,
89
+ * onApp?: (app: import('./types.js').LaunchedApp) => Promise<void>,
90
+ * timings?: ReturnType<typeof makeTimings>,
86
91
  * }} [opts]
87
92
  * @returns {Promise<import('./types.js').RunSummary>}
88
93
  */
@@ -90,6 +95,12 @@ export async function runCheck(project, opts = {}) {
90
95
  const { config, paths } = project;
91
96
  const startedAt = new Date();
92
97
  const started = Date.now();
98
+ const events = opts.events;
99
+ const watching = opts.watching === true;
100
+ // A run always knows where its time went, whether or not anybody asked. It
101
+ // costs two numbers per phase, and the alternative is being unable to answer
102
+ // "why did that take three minutes" without running it all again.
103
+ const timings = opts.timings ?? makeTimings();
93
104
 
94
105
  await ensureDirs(paths);
95
106
  // Yesterday's evidence goes in the bin before today's is taken. A stale diff
@@ -117,6 +128,17 @@ export async function runCheck(project, opts = {}) {
117
128
  });
118
129
  }
119
130
 
131
+ emitEvent(events, {
132
+ type: 'run:start',
133
+ plan: {
134
+ screens: screens.length,
135
+ guards: guards.length,
136
+ app: describeApp(config.app),
137
+ project: path.basename(paths.root),
138
+ watching,
139
+ },
140
+ });
141
+
120
142
  const onPicture = opts.onPicture ?? (opts.quiet ? undefined : (/** @type {import('./types.js').PictureResult} */ r) => printPictureResult(r));
121
143
  const onGuard = opts.onGuard ?? (opts.quiet ? undefined : (/** @type {import('./types.js').GuardResult} */ r) => printGuardResult(r));
122
144
 
@@ -128,24 +150,41 @@ export async function runCheck(project, opts = {}) {
128
150
  // Nothing to look at means nothing to open. Starting a browser to check zero
129
151
  // screens is thirty seconds of somebody's life for no answer.
130
152
  if (screens.length > 0 || guards.length > 0) {
131
- await withApp(project, async (app) => {
132
- if (screens.length > 0) {
133
- pictures = await runPictures(project, app, {
134
- only: screens.map((s) => s.name),
135
- record: opts.record ?? false,
136
- retries: config.retries,
137
- tool: TOOL,
138
- onResult: onPicture,
139
- signal: opts.signal,
140
- });
141
- }
142
- if (guards.length > 0) {
143
- guardResults = await runGuards(project, app, guards, {
144
- onResult: onGuard,
145
- signal: opts.signal,
146
- });
147
- }
148
- });
153
+ await withApp(
154
+ project,
155
+ async (app) => {
156
+ if (screens.length > 0) {
157
+ emitEvent(events, { type: 'phase', message: 'photographing' });
158
+ pictures = await runPictures(project, app, {
159
+ only: screens.map((s) => s.name),
160
+ record: opts.record ?? false,
161
+ retries: config.retries,
162
+ tool: TOOL,
163
+ onResult: onPicture,
164
+ signal: opts.signal,
165
+ events,
166
+ timings,
167
+ // Small pictures cost time to make, so they are only made when there
168
+ // is a window open to show them in.
169
+ thumbnail: Boolean(events && watching),
170
+ });
171
+ }
172
+ if (guards.length > 0) {
173
+ emitEvent(events, { type: 'phase', message: 'running the guards' });
174
+ const stopGuards = timings.mark('guards');
175
+ try {
176
+ guardResults = await runGuards(project, app, guards, {
177
+ onResult: onGuard,
178
+ signal: opts.signal,
179
+ events,
180
+ });
181
+ } finally {
182
+ stopGuards();
183
+ }
184
+ }
185
+ },
186
+ { events, timings, onApp: opts.onApp },
187
+ );
149
188
  }
150
189
 
151
190
  const git = await gitInfo(paths.root);
@@ -171,7 +210,7 @@ export async function runCheck(project, opts = {}) {
171
210
 
172
211
  const totals = countUp(pictures, guardResults);
173
212
 
174
- /** @type {import('./types.js').RunSummary} */
213
+ /** @type {import('./types.js').RunSummary & {timings?: import('./types.js').Timings}} */
175
214
  const summary = {
176
215
  id: runId(startedAt),
177
216
  startedAt: startedAt.toISOString(),
@@ -187,6 +226,9 @@ export async function runCheck(project, opts = {}) {
187
226
  tool: TOOL,
188
227
  platform: platformTag(),
189
228
  condemned: condemnedNames,
229
+ // Read here rather than at the very end: what follows is writing files, and
230
+ // where the run spent its time is a fact about the run, not about the report.
231
+ timings: timings.get(),
190
232
  };
191
233
 
192
234
  if (opts.writeReport !== false) {
@@ -204,6 +246,10 @@ export async function runCheck(project, opts = {}) {
204
246
  warn(`The run finished, but its result could not be saved for \`staysfixed status\`. ${messageOf(e)}`);
205
247
  }
206
248
 
249
+ // Last, so anything watching that closes on the verdict does not race the
250
+ // report being written.
251
+ emitEvent(events, { type: 'run:done', summary });
252
+
207
253
  return summary;
208
254
  }
209
255
 
@@ -222,7 +268,15 @@ export async function runCheck(project, opts = {}) {
222
268
  *
223
269
  * @param {import('./types.js').Project} project
224
270
  * @param {string} screenName
225
- * @param {{record?: boolean, retries?: number, signal?: AbortSignal, onResult?: (r: import('./types.js').PictureResult) => void}} [opts]
271
+ * @param {{
272
+ * record?: boolean,
273
+ * retries?: number,
274
+ * signal?: AbortSignal,
275
+ * onResult?: (r: import('./types.js').PictureResult) => void,
276
+ * events?: import('./types.js').RunEvents,
277
+ * watching?: boolean,
278
+ * timings?: ReturnType<typeof makeTimings>,
279
+ * }} [opts]
226
280
  * @returns {Promise<{png: Buffer, result: import('./types.js').PictureResult, path: string}>}
227
281
  */
228
282
  export async function captureOne(project, screenName, opts = {}) {
@@ -240,17 +294,47 @@ export async function captureOne(project, screenName, opts = {}) {
240
294
 
241
295
  await ensureDirs(paths);
242
296
 
243
- const results = await withApp(project, (app) =>
244
- runPictures(project, app, {
245
- only: [screen.name],
246
- record: opts.record ?? false,
247
- retries: opts.retries ?? config.retries,
248
- tool: TOOL,
249
- onResult: opts.onResult,
250
- signal: opts.signal,
251
- }),
297
+ const events = opts.events;
298
+ const watching = opts.watching === true;
299
+ const timings = opts.timings ?? makeTimings();
300
+
301
+ // One screen is still a run as far as anyone watching is concerned, so it
302
+ // describes itself the same way. This is what lets an agent's own capture be
303
+ // watched in the same window as a full check.
304
+ emitEvent(events, {
305
+ type: 'run:start',
306
+ plan: {
307
+ screens: 1,
308
+ guards: 0,
309
+ app: describeApp(config.app),
310
+ project: path.basename(paths.root),
311
+ watching,
312
+ },
313
+ });
314
+
315
+ const results = await withApp(
316
+ project,
317
+ (app) => {
318
+ emitEvent(events, { type: 'phase', message: 'photographing' });
319
+ return runPictures(project, app, {
320
+ only: [screen.name],
321
+ record: opts.record ?? false,
322
+ retries: opts.retries ?? config.retries,
323
+ tool: TOOL,
324
+ onResult: opts.onResult,
325
+ signal: opts.signal,
326
+ events,
327
+ timings,
328
+ thumbnail: Boolean(events && watching),
329
+ });
330
+ },
331
+ { events, timings },
252
332
  );
253
333
 
334
+ // Said as soon as the app is shut. What is left is reading a file off disk,
335
+ // and a watcher should not be left with a spinner turning through it.
336
+ emitEvent(events, { type: 'run:done' });
337
+
254
338
  const result = results[0];
255
339
  if (!result) {
256
340
  throw new StaysFixedError(`"${screen.name}" was not photographed.`, {
@@ -281,21 +365,58 @@ export async function captureOne(project, screenName, opts = {}) {
281
365
  * signal?: AbortSignal,
282
366
  * onStep?: (update: import('./walk/run.js').WalkProgress) => void,
283
367
  * writeReport?: boolean,
368
+ * events?: import('./types.js').RunEvents,
369
+ * watching?: boolean,
370
+ * onApp?: (app: import('./types.js').LaunchedApp) => Promise<void>,
371
+ * timings?: ReturnType<typeof makeTimings>,
284
372
  * }} [opts]
285
373
  * @returns {Promise<import('./types.js').WalkReport>}
286
374
  */
287
375
  export async function runWalk(project, opts = {}) {
288
376
  await ensureDirs(project.paths);
289
377
 
290
- const report = await withApp(project, (app) =>
291
- walkApp(project, app, {
292
- only: opts.only,
293
- record: opts.record ?? false,
294
- onStep: opts.onStep,
295
- signal: opts.signal,
296
- }),
378
+ const events = opts.events;
379
+ const watching = opts.watching === true;
380
+ // The caller's stopwatch when it brought one — `--profile` reads it back out
381
+ // afterwards, so a walk that quietly kept its own would print all zeros.
382
+ const timings = opts.timings ?? makeTimings();
383
+
384
+ emitEvent(events, {
385
+ type: 'run:start',
386
+ plan: {
387
+ // Counted before anything is opened so the window can draw the whole list
388
+ // straight away. A walk with nothing to walk through says nothing here and
389
+ // fails a moment later, in one place, with a sentence a person can act on.
390
+ screens: countWalk(project.config, opts.only),
391
+ guards: 0,
392
+ app: describeApp(project.config.app),
393
+ project: path.basename(project.paths.root),
394
+ watching,
395
+ },
396
+ });
397
+
398
+ const report = await withApp(
399
+ project,
400
+ (app) => {
401
+ emitEvent(events, { type: 'phase', message: 'photographing' });
402
+ return walkApp(project, app, {
403
+ only: opts.only,
404
+ record: opts.record ?? false,
405
+ onStep: opts.onStep,
406
+ signal: opts.signal,
407
+ events,
408
+ thumbnail: Boolean(events && watching),
409
+ timings,
410
+ });
411
+ },
412
+ { events, timings, onApp: opts.onApp },
297
413
  );
298
414
 
415
+ // A walk has no verdict to hand over — the pictures are the point — so this
416
+ // says only that it is over. Said here, with the app shut and every photo
417
+ // taken; the page that shows them is written after.
418
+ emitEvent(events, { type: 'run:done' });
419
+
299
420
  if (opts.writeReport === false) return report;
300
421
 
301
422
  try {
@@ -441,16 +562,43 @@ export async function approveScreens(project, names, opts = {}) {
441
562
  * Electron window left running is a leaked process on somebody's machine, and
442
563
  * the next run will fight it for the debug port.
443
564
  *
565
+ * `onApp` is the one hook in here. It is handed the app the moment it is open
566
+ * and before a single picture is taken, which is what lets the watch panel put
567
+ * itself beside a window that did not exist when the panel opened. It moves
568
+ * windows around a desk; it never touches the page, and the picture comes from
569
+ * the viewport the capture sets, not from the window — so where the window ends
570
+ * up cannot change what was photographed.
571
+ *
444
572
  * @template T
445
573
  * @param {import('./types.js').Project} project
446
574
  * @param {(app: import('./types.js').LaunchedApp) => Promise<T>} work
575
+ * @param {{
576
+ * events?: import('./types.js').RunEvents,
577
+ * timings?: ReturnType<typeof makeTimings>,
578
+ * onApp?: (app: import('./types.js').LaunchedApp) => Promise<void>,
579
+ * }} [ctx]
447
580
  * @returns {Promise<T>}
448
581
  */
449
- async function withApp(project, work) {
450
- const app = await launchApp(project);
582
+ async function withApp(project, work, ctx = {}) {
583
+ emitEvent(ctx.events, { type: 'phase', message: 'opening the app' });
584
+ const stopLaunch = ctx.timings?.mark('launch');
585
+ // Stopped even when the app never opened: a launch that gave up after fifty
586
+ // seconds is exactly the number somebody wants to see.
587
+ const app = await launchApp(project).finally(() => stopLaunch?.());
588
+ if (ctx.onApp) {
589
+ try {
590
+ await ctx.onApp(app);
591
+ } catch (e) {
592
+ // Whatever wanted a look at the app is a spectator. A spectator that
593
+ // trips over must not take the run down with it, and is not worth a
594
+ // warning in the middle of a clean one.
595
+ detail(`Something watching this run could not be shown the app. ${messageOf(e)}`);
596
+ }
597
+ }
451
598
  try {
452
599
  return await work(app);
453
600
  } finally {
601
+ emitEvent(ctx.events, { type: 'phase', message: 'closing' });
454
602
  try {
455
603
  await app.close();
456
604
  } catch (e) {
@@ -459,6 +607,48 @@ async function withApp(project, work) {
459
607
  }
460
608
  }
461
609
 
610
+ /**
611
+ * How many screens a walk will visit, or none when there is nothing to walk.
612
+ *
613
+ * @param {import('./types.js').ResolvedConfig} config
614
+ * @param {string|string[]} [only]
615
+ * @returns {number}
616
+ */
617
+ function countWalk(config, only) {
618
+ try {
619
+ return countWalkSteps(config, only);
620
+ } catch {
621
+ // Nothing to walk through. The walk itself says so properly, in one place.
622
+ return 0;
623
+ }
624
+ }
625
+
626
+ /**
627
+ * The app being opened, in a few words: what kind it is and which one it is.
628
+ *
629
+ * A watcher shows this at the top of a narrow panel, so the whole path or the
630
+ * whole address would be noise — the name of the binary, or the host being
631
+ * opened, is what a person recognises.
632
+ *
633
+ * @param {import('./types.js').AppConfig} app
634
+ * @returns {string}
635
+ */
636
+ function describeApp(app) {
637
+ if (app.kind === 'electron') {
638
+ const binary = app.binary ?? app.attach ?? '';
639
+ return binary ? `electron — ${path.basename(binary)}` : 'electron';
640
+ }
641
+ const url = app.url ?? app.attach ?? '';
642
+ if (!url) return 'web';
643
+ try {
644
+ // A web address has no useful last part — "/" is not a name — so the host is
645
+ // what gets shown: "web — localhost:5173".
646
+ return `web — ${new URL(url).host || url}`;
647
+ } catch {
648
+ return `web — ${url}`;
649
+ }
650
+ }
651
+
462
652
  /**
463
653
  * Flatten both kinds of result into the one shape the flake register folds.
464
654
  * @param {PictureRunResult[]} pictures
package/src/types.js CHANGED
@@ -469,3 +469,71 @@ 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, for watching.
499
+ * @property {string} [approvedThumb]
500
+ * @property {string} [diffThumb]
501
+ * @property {RunSummary} [summary] Only on 'run:done'.
502
+ * @property {{screens: number, guards: number, app: string, project: string, watching: boolean}} [plan]
503
+ * Only on 'run:start'.
504
+ */
505
+
506
+ /**
507
+ * @typedef {object} RunEvents
508
+ * @property {(event: RunEvent) => void} emit
509
+ * @property {(listener: (event: RunEvent) => void) => () => void} on
510
+ * @property {() => number} elapsed
511
+ * @property {() => RunEvent[]} history Everything so far, so a late listener catches up.
512
+ */
513
+
514
+ /**
515
+ * @typedef {object} WatchOptions
516
+ * @property {boolean} [enabled]
517
+ * @property {number} [width] Panel width in CSS pixels. Default 460.
518
+ * @property {number} [height] Default: as tall as the app.
519
+ * @property {'right'|'left'} [side] Which side of the app to sit on. Default 'right'.
520
+ * @property {boolean} [keepOpen] Leave the panel up after the run. Default true.
521
+ * @property {boolean} [foreground] Bring the panel to the front. Default false.
522
+ * @property {boolean} [snap] Pin the app to a screen edge and sit flush against it. Default true.
523
+ * @property {'dark'|'light'|'system'} [theme] Default 'dark'. The panel opens on a brand new browser
524
+ * profile, and a fresh profile insists the computer is in light
525
+ * mode however it is really set — so the look is stated, not guessed.
526
+ */
527
+
528
+ /**
529
+ * Where a run spent its time. Printed by `--profile`, and drawn in the watch window.
530
+ * @typedef {object} Timings
531
+ * @property {number} launch
532
+ * @property {number} steps
533
+ * @property {number} prepare
534
+ * @property {number} settle
535
+ * @property {number} compare
536
+ * @property {number} guards
537
+ * @property {number} other
538
+ * @property {number} total
539
+ */
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 } 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,43 @@ 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}} */
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
+ });
116
+
83
117
  opts.onStep?.({
84
118
  phase: 'done',
85
119
  index,
@@ -115,6 +149,10 @@ export async function walkApp(project, app, opts = {}) {
115
149
  * settings: ReturnType<typeof settingsForScreen>,
116
150
  * fixturesDir: string,
117
151
  * record: boolean,
152
+ * events?: import('../types.js').RunEvents,
153
+ * thumbnail?: boolean,
154
+ * thumbs?: {shot?: string},
155
+ * timings?: ReturnType<typeof import('../core/events.js').makeTimings>,
118
156
  * }} ctx
119
157
  * @returns {Promise<import('../types.js').WalkStep>}
120
158
  */
@@ -136,10 +174,16 @@ async function walkOneStep(page, screen, ctx) {
136
174
  const shot = await captureScreen(page, screen, ctx.settings, {
137
175
  fixturesDir: ctx.fixturesDir,
138
176
  record: ctx.record,
177
+ thumbnail: ctx.thumbnail === true,
139
178
  });
179
+ accountForCapture(ctx.timings, shot);
140
180
  await fsp.writeFile(target, shot.png);
141
181
  file = target;
142
182
  consoleErrors = shot.consoleErrors;
183
+ if (shot.thumbnail) {
184
+ if (ctx.thumbs) ctx.thumbs.shot = shot.thumbnail;
185
+ emitEvent(ctx.events, { type: 'screen:shot', name: screen.name, thumbnail: shot.thumbnail });
186
+ }
143
187
  } catch (cause) {
144
188
  error = messageOf(cause);
145
189
  consoleErrors = readConsole(page);
@@ -173,6 +217,38 @@ async function walkOneStep(page, screen, ctx) {
173
217
  return step;
174
218
  }
175
219
 
220
+ /**
221
+ * How many screens a walk is about to visit.
222
+ *
223
+ * The engine needs this before it opens anything, so it can tell a watcher how
224
+ * long the list will be. It asks the same function the walk itself asks, because
225
+ * two places counting the same thing differently is how a progress bar starts
226
+ * lying.
227
+ *
228
+ * @param {import('../types.js').ResolvedConfig} config
229
+ * @param {string|string[]} [only]
230
+ * @returns {number}
231
+ */
232
+ export function countWalkSteps(config, only) {
233
+ return chooseSteps(config, only).length;
234
+ }
235
+
236
+ /**
237
+ * What to say about a finished step, in plain language, or nothing when it went
238
+ * through cleanly.
239
+ *
240
+ * @param {import('../types.js').WalkStep} step
241
+ * @returns {string|undefined}
242
+ */
243
+ function stepMessage(step) {
244
+ if (step.error) return step.error;
245
+ const complaints = (step.consoleErrors ?? []).length;
246
+ if (complaints === 0) return undefined;
247
+ return complaints === 1
248
+ ? 'The app logged one error while this screen was open.'
249
+ : `The app logged ${complaints} errors while this screen was open.`;
250
+ }
251
+
176
252
  /**
177
253
  * Which screens the walk visits: `walk.steps` when the project spelled one out,
178
254
  * otherwise every screen it already checks, in the order they are written.