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.
@@ -9,12 +9,25 @@
9
9
 
10
10
  import fsp from 'node:fs/promises';
11
11
  import path from 'node:path';
12
+ import { PNG } from 'pngjs';
12
13
  import { approvedPicture, resultPicture, safeName } from '../core/paths.js';
13
14
  import { sha256 } from '../core/hash.js';
14
15
  import { StaysFixedError } from '../core/errors.js';
15
16
  import { platformTag } from '../drive/find.js';
16
17
  import { pngSize } from './capture.js';
17
18
 
19
+ /**
20
+ * How wide a preview is, in real pixels.
21
+ *
22
+ * The watch panel is 460 CSS pixels across, and every screen a person reviews on is
23
+ * retina, so the panel is really 920 pixels wide. A 320-pixel preview stretched over
24
+ * that is the blur everybody complained about: you cannot read a label in it, and
25
+ * zooming in only makes the blur bigger. 900 is where a preview stops being a
26
+ * stand-in and starts being a picture — and it still weighs about 40KB, which a run
27
+ * can hand to a window a dozen times over without anybody noticing.
28
+ */
29
+ const PREVIEW_WIDTH = 900;
30
+
18
31
  /**
19
32
  * A small note written beside a result picture so `approve` knows things the PNG
20
33
  * cannot tell it — the screen density it was taken at, and its description.
@@ -23,6 +36,117 @@ import { pngSize } from './capture.js';
23
36
  * @property {string} [describe]
24
37
  */
25
38
 
39
+ /**
40
+ * A picture already decoded into pixels: what `PNG.sync.read` hands back, and what the
41
+ * mask painter works on. Described by its shape rather than by pngjs's class, because
42
+ * pngjs does not hand back one of those.
43
+ * @typedef {{width: number, height: number, data: Uint8Array}} Pixels
44
+ */
45
+
46
+ /**
47
+ * A small copy of a picture, ready to drop straight into a page.
48
+ *
49
+ * This is the INSTANT preview, and only that. The watch panel loads the real PNG off
50
+ * disk the moment it exists, so this only has to hold the frame for the fraction of a
51
+ * second before that file arrives — but it has to hold it honestly, at a size a person
52
+ * can actually read.
53
+ *
54
+ * It is built from the finished picture rather than asked of the app again: what a
55
+ * watcher sees is exactly what was compared, blackout boxes and all, and never a second
56
+ * photograph taken a moment later that shows something slightly different.
57
+ *
58
+ * Points are averaged now, not sampled. Dropping nine pixels out of every ten is what
59
+ * made the old previews look broken rather than merely small — a one-pixel border or a
60
+ * line of text either survived or vanished depending on where it happened to land.
61
+ * Averaging every pixel that falls inside an output pixel costs about forty
62
+ * milliseconds on a retina screenshot, and it is the difference between a picture and a
63
+ * smear.
64
+ *
65
+ * @param {Buffer|Pixels} source
66
+ * The bytes of a PNG, or a picture already decoded. Hand over the decoded one when
67
+ * you have it — a retina screenshot costs about eighty milliseconds to decode, and
68
+ * decoding the same megapixels twice for one screen is the whole cost of this
69
+ * function paid for nothing.
70
+ * @returns {Promise<string|null>} a data: address for an <img>, or null if it cannot be read
71
+ */
72
+ export async function thumbnailOf(source) {
73
+ try {
74
+ if (Buffer.isBuffer(source) && pngSize(source).width <= PREVIEW_WIDTH) {
75
+ // Already small enough to be its own preview. Saves decoding and re-encoding a
76
+ // picture only to hand back what we were given.
77
+ return `data:image/png;base64,${source.toString('base64')}`;
78
+ }
79
+ } catch {
80
+ // Not a PNG we can measure from its header. Fall through and let the decode below
81
+ // be the one that decides whether there is a picture here at all.
82
+ }
83
+
84
+ try {
85
+ // Told apart by what it IS, not by `instanceof PNG`: pngjs's sync reader hands back
86
+ // a plain object rather than an instance of its own class, so an instance check
87
+ // here quietly says "no" to every decoded picture ever passed in.
88
+ const full = Buffer.isBuffer(source) ? PNG.sync.read(source) : source;
89
+ if (!(full.width > 0) || !(full.height > 0)) return null;
90
+ const scale = full.width > PREVIEW_WIDTH ? full.width / PREVIEW_WIDTH : 1;
91
+ const width = Math.max(1, Math.round(full.width / scale));
92
+ const height = Math.max(1, Math.round(full.height / scale));
93
+
94
+ const small = new PNG({ width, height });
95
+ const from = full.data;
96
+ const into = small.data;
97
+
98
+ for (let y = 0; y < height; y += 1) {
99
+ const top = Math.floor(y * scale);
100
+ // Always at least one row, even when the picture is barely bigger than the preview.
101
+ const bottom = Math.min(full.height, Math.max(top + 1, Math.floor((y + 1) * scale)));
102
+ for (let x = 0; x < width; x += 1) {
103
+ const left = Math.floor(x * scale);
104
+ const right = Math.min(full.width, Math.max(left + 1, Math.floor((x + 1) * scale)));
105
+ let r = 0;
106
+ let g = 0;
107
+ let b = 0;
108
+ let a = 0;
109
+ let n = 0;
110
+ for (let sy = top; sy < bottom; sy += 1) {
111
+ let i = (sy * full.width + left) * 4;
112
+ for (let sx = left; sx < right; sx += 1) {
113
+ r += from[i];
114
+ g += from[i + 1];
115
+ b += from[i + 2];
116
+ a += from[i + 3];
117
+ n += 1;
118
+ i += 4;
119
+ }
120
+ }
121
+ const at = (y * width + x) * 4;
122
+ into[at] = (r / n + 0.5) | 0;
123
+ into[at + 1] = (g / n + 0.5) | 0;
124
+ into[at + 2] = (b / n + 0.5) | 0;
125
+ into[at + 3] = (a / n + 0.5) | 0;
126
+ }
127
+ }
128
+ return `data:image/png;base64,${PNG.sync.write(small).toString('base64')}`;
129
+ } catch {
130
+ // A picture nobody can decode is not worth failing a run over — the run itself has
131
+ // already said what it thinks of the screen, and a watcher simply sees no picture.
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * The fingerprint of a picture.
138
+ *
139
+ * The same sha256 that is written into an approved picture's note, so anyone holding that
140
+ * note can tell whether a fresh photograph is the same file without reading the old one
141
+ * off disk or decoding either of them.
142
+ *
143
+ * @param {Buffer|Uint8Array|string} buffer
144
+ * @returns {string} hex sha256
145
+ */
146
+ export function fingerprint(buffer) {
147
+ return sha256(buffer);
148
+ }
149
+
26
150
  /**
27
151
  * @param {import('../types.js').ProjectPaths} paths
28
152
  * @param {string} name
@@ -268,9 +268,12 @@ export function printGuardResult(r) {
268
268
  * The closing block of `staysfixed check`.
269
269
  * @param {import('../types.js').RunSummary} run
270
270
  * @param {import('../types.js').Project} [project]
271
+ * @param {{profile?: boolean, timings?: import('../types.js').Timings|null}} [opts]
272
+ * Ask for the timing block with --profile; pass `timings` when the caller
273
+ * kept its own record rather than reading it back off the summary.
271
274
  * @returns {void}
272
275
  */
273
- export function printRunSummary(run, project) {
276
+ export function printRunSummary(run, project, opts = {}) {
274
277
  const pictures = run.pictures ?? [];
275
278
  const guards = run.guards ?? [];
276
279
  const verdict = verdictFor(run);
@@ -284,6 +287,13 @@ export function printRunSummary(run, project) {
284
287
  if (guards.length) counted.push(`${countText(guards.length)} ${plural(guards.length, 'guard', 'guards')}`);
285
288
  if (counted.length) say(paint.grey(` ${counted.join(', ')}, ${plainTime(run.durationMs ?? 0)}.`));
286
289
 
290
+ // Nobody asked for numbers unless they asked for numbers. The run reads the
291
+ // same with or without this block.
292
+ if (opts.profile) {
293
+ const measured = opts.timings ?? /** @type {{timings?: import('../types.js').Timings}} */ (run).timings;
294
+ printTimings(measured, pictures.length);
295
+ }
296
+
287
297
  /** @type {string[][]} */
288
298
  const rows = [];
289
299
  for (const p of pictures) {
@@ -324,6 +334,75 @@ export function printRunSummary(run, project) {
324
334
  blank();
325
335
  }
326
336
 
337
+ /**
338
+ * Where the seconds went, in the order of biggest first, because the only reason
339
+ * anyone reads this is to find the one part worth speeding up.
340
+ *
341
+ * The names are what each phase actually does, not what the code calls it: nobody
342
+ * outside this repository knows what "settle" or "prepare" mean.
343
+ */
344
+ const TIMING_LABELS = /** @type {[string, string][]} */ ([
345
+ ['launch', 'opening the app'],
346
+ ['steps', 'running the steps'],
347
+ ['prepare', 'waiting for fonts and images'],
348
+ ['settle', 'taking the pictures until two agree'],
349
+ ['compare', 'comparing against the approved pictures'],
350
+ ['guards', 'running the guards'],
351
+ ['other', 'everything else'],
352
+ ]);
353
+
354
+ /**
355
+ * The `--profile` block. Rounded to something a person would say out loud: this
356
+ * is here to point at the slow part, not to be a benchmark.
357
+ *
358
+ * @param {import('../types.js').Timings|null|undefined} timings
359
+ * @param {number} [screenCount] Screens photographed, for the per-screen average.
360
+ * @returns {void}
361
+ */
362
+ export function printTimings(timings, screenCount = 0) {
363
+ if (!timings) {
364
+ heading('Where the time went');
365
+ say(paint.grey(' This run did not record its timings.'));
366
+ return;
367
+ }
368
+
369
+ // Timings is a fixed shape, but reading it by name keeps the table and the
370
+ // type from drifting apart.
371
+ const t = /** @type {Record<string, number>} */ (/** @type {unknown} */ (timings));
372
+ const total = Number(t.total) || 0;
373
+
374
+ /** @type {string[][]} */
375
+ const rows = [];
376
+ const parts = TIMING_LABELS.map(([key, label]) => ({ label, ms: Number(t[key]) || 0 }))
377
+ .filter((part) => part.ms > 0)
378
+ .sort((a, b) => b.ms - a.ms);
379
+
380
+ for (const part of parts) {
381
+ const share = total > 0 ? `${Math.round((part.ms / total) * 100)}%` : '';
382
+ rows.push([part.label, duration(part.ms), paint.grey(share)]);
383
+ }
384
+
385
+ heading('Where the time went');
386
+ if (rows.length === 0) {
387
+ say(paint.grey(' Nothing took long enough to measure.'));
388
+ return;
389
+ }
390
+ table(rows, { indent: 2 });
391
+
392
+ // The two closing lines are written by hand rather than added as rows, so the
393
+ // totals sit under the parts instead of being sorted in among them.
394
+ const labelWidth = Math.max(...rows.map((row) => row[0].length));
395
+ say(paint.grey(` ${'in total'.padEnd(labelWidth)} ${duration(total)}`));
396
+ if (screenCount > 0) {
397
+ const each = duration(total / screenCount);
398
+ say(
399
+ paint.grey(
400
+ ` ${'each screen'.padEnd(labelWidth)} ${each} on average, across ${countText(screenCount)} ${plural(screenCount, 'screen', 'screens')}`,
401
+ ),
402
+ );
403
+ }
404
+ }
405
+
327
406
  /**
328
407
  * The closing block of `staysfixed walk`.
329
408
  * @param {import('../types.js').WalkReport} report
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