staysfixed 0.6.0 → 0.6.2

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.
@@ -0,0 +1,1087 @@
1
+ /**
2
+ * The bridge between a check and the window watching it.
3
+ *
4
+ * The engine already describes itself into one stream — the same stream v1 used, kept on
5
+ * purpose, because its two rules are already written and already tested: a listener that
6
+ * throws can never take a run down, and a listener that arrives late is handed everything it
7
+ * missed. This file does not build a second one. It does three small things instead.
8
+ *
9
+ * 1. It names the few events v2 needs that v1 never had. A journey starting on a NAMED
10
+ * SURFACE, because one repository builds a website and a phone app and a command-line
11
+ * tool at once and the window has to say which one it is walking. An address count
12
+ * rising while a journey runs. A wobble measured. A finding clustered. Coverage folded.
13
+ *
14
+ * 2. It enriches. The engine says "walking checkout"; the panel needs to know that checkout
15
+ * is a journey on the website, the fourth of nine, read out of the test suite. All of
16
+ * that is already in the plan, so it is filled in here rather than being carried through
17
+ * the engine on every event.
18
+ *
19
+ * 3. It trims. A finding can stand for five hundred differences and a verdict can carry
20
+ * every one of them. None of that belongs in a window: the panel is handed the count and
21
+ * one example, never the five hundred.
22
+ *
23
+ * Everything here is data. Nothing in this file touches a browser, so it can be exercised
24
+ * without opening one.
25
+ */
26
+
27
+ /** @typedef {import('../types.js').Surface} Surface */
28
+ /** @typedef {import('../types.js').Journey} Journey */
29
+ /** @typedef {import('../types.js').Finding} Finding */
30
+ /** @typedef {import('../types.js').FindingClass} FindingClass */
31
+ /** @typedef {import('../types.js').Difference} Difference */
32
+ /** @typedef {import('../types.js').Coverage} Coverage */
33
+ /** @typedef {import('../types.js').CoverageGap} CoverageGap */
34
+ /** @typedef {import('../types.js').Verdict} Verdict */
35
+ /** @typedef {import('../types.js').Wobble} Wobble */
36
+ /** @typedef {import('../types.js').WobbleEntry} WobbleEntry */
37
+ /** @typedef {import('../types.js').ReferenceMode} ReferenceMode */
38
+ /** @typedef {import('../types.js').BuildFingerprint} BuildFingerprint */
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Surfaces, in words a person uses
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * What each surface is called on screen.
46
+ *
47
+ * Not 'electron', not 'cli'. A person reading this window is being told what is being walked,
48
+ * and "Desktop app" is what that is. The panel embeds this map rather than keeping its own
49
+ * copy, so the two can never drift apart.
50
+ *
51
+ * @type {Readonly<Record<Surface, string>>}
52
+ */
53
+ export const SURFACE_WORDS = Object.freeze({
54
+ cli: 'Command line',
55
+ library: 'Library',
56
+ server: 'Server',
57
+ web: 'Website',
58
+ electron: 'Desktop app',
59
+ android: 'Android phone',
60
+ ios: 'iPhone',
61
+ windows: 'Windows app',
62
+ });
63
+
64
+ /**
65
+ * One line describing a surface for somebody who has not seen the product.
66
+ * @type {Readonly<Record<Surface, string>>}
67
+ */
68
+ export const SURFACE_NOTES = Object.freeze({
69
+ cli: 'run as a command, watched through what it printed, wrote and spawned',
70
+ library: 'imported and called, watched through what it exported and returned',
71
+ server: 'started on its own port, watched through what it answered',
72
+ web: 'opened in a browser, watched through what the page says its controls do',
73
+ electron: 'launched with its own data folder, watched through the app and its channels',
74
+ android: 'installed on an emulator, watched through what is on the screen',
75
+ ios: 'installed on a simulator, watched through what is on the screen',
76
+ windows: 'driven on a real Windows desktop, watched through what is on the screen',
77
+ });
78
+
79
+ /**
80
+ * The word for a surface, or the raw name when it is one we have not met.
81
+ * @param {string|undefined} surface
82
+ * @returns {string}
83
+ */
84
+ export function surfaceWord(surface) {
85
+ if (!surface) return 'Unknown surface';
86
+ const known = /** @type {Record<string, string>} */ (SURFACE_WORDS)[surface];
87
+ return known || surface;
88
+ }
89
+
90
+ /**
91
+ * Where a journey came from, in words.
92
+ * @type {Readonly<Record<string, string>>}
93
+ */
94
+ export const SOURCE_WORDS = Object.freeze({
95
+ code: 'read out of the code',
96
+ suite: 'from the project’s own tests',
97
+ recorded: 'a real session, recorded',
98
+ explored: 'explored by an agent',
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // The events
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Everything the window understands.
107
+ *
108
+ * The first eleven are the engine's own words, unchanged. The four after them are v2's new
109
+ * ones, and they exist because the window has something to draw that the terminal never
110
+ * needed: which surface is being walked, how many addresses have been watched so far, each
111
+ * finding as it is formed, and the coverage once it is folded.
112
+ *
113
+ * @typedef {'plan'|'check:start'|'reference'|'journey:start'|'journey:done'|'wobble'|'suspicion'|'proof:start'|'proof:done'|'cluster'|'note'|'check:done'|'journey:addresses'|'finding'|'coverage'} PanelEventType
114
+ */
115
+
116
+ /**
117
+ * One thing said to the window.
118
+ *
119
+ * Every field but `type` and `at` is optional, and every field is JSON-safe, because this
120
+ * crosses into a browser as text. `message` is the line a person reads and is always plain
121
+ * English; everything else is what the panel draws around it.
122
+ *
123
+ * @typedef {object} PanelEvent
124
+ * @property {PanelEventType} type
125
+ * @property {number} at Milliseconds since the check started.
126
+ * @property {string} [message] Plain English. Always safe to show on its own.
127
+ * @property {string} [journey]
128
+ * @property {string} [describe] What the journey does, in one sentence.
129
+ * @property {Surface} [surface]
130
+ * @property {string} [surfaceWord] 'Website', 'iPhone'. Filled in here, not upstream.
131
+ * @property {string} [source] code / suite / recorded / explored.
132
+ * @property {string} [run] 'a', 'b' or 'single' — which pass this is.
133
+ * @property {number} [index] 1-based position in the walk.
134
+ * @property {number} [total] How many journeys there are.
135
+ * @property {number} [count] Whatever this event is counting.
136
+ * @property {number} [watched] Addresses watched so far, across every journey.
137
+ * @property {number} [durationMs]
138
+ * @property {PanelReference} [reference] Only on 'reference'.
139
+ * @property {PanelWobble} [wobble] Only on 'wobble'.
140
+ * @property {Finding|PanelFinding} [finding] Only on 'finding'. Whole on the way in, cut
141
+ * down on the way out: the mapper is the only place
142
+ * that trims, so an event a window receives is always
143
+ * the small shape.
144
+ * @property {Coverage|PanelCoverage} [coverage] Only on 'coverage'. Same rule.
145
+ * @property {Verdict|PanelVerdict} [verdict] Only on 'check:done'. Same rule.
146
+ * @property {PanelPlanShape} [plan] Only on 'plan'.
147
+ */
148
+
149
+ /**
150
+ * Which build this one is being measured against, and how much that is worth.
151
+ *
152
+ * `weak` is the whole reason this shape exists. A run compared against a stored record is a
153
+ * genuinely weaker run, and the tool admitting that is more important than it looking
154
+ * confident — so the panel is handed a flag it cannot miss rather than a sentence it might
155
+ * put in small type.
156
+ *
157
+ * @typedef {object} PanelReference
158
+ * @property {string} name What to call it: a version, a marker, a short sha.
159
+ * @property {ReferenceMode|'none'} mode
160
+ * @property {boolean} weak True for a stored record, and for no reference at all.
161
+ * @property {string} how Plain English: how this build came to be the reference.
162
+ * @property {string} [warning] Present whenever `weak`. The sentence to show loudly.
163
+ * @property {string} [setAt] When it became the reference.
164
+ * @property {string} [setBy] 'ship-everywhere', a person, a command.
165
+ * @property {string} [candidate] What to call the build being checked.
166
+ */
167
+
168
+ /**
169
+ * What the build could not answer the same way twice.
170
+ * @typedef {object} PanelWobble
171
+ * @property {boolean} measured False when only one pass ran, so nothing was measured.
172
+ * @property {number} unstable Addresses that would not sit still. Subtracted.
173
+ * @property {number} steady Addresses that gave the same answer both times.
174
+ * @property {number} newlyUnstable Steady in the old build, wobbling now. A bug, even
175
+ * though no value is wrong.
176
+ * @property {boolean} [couldTellNewly] False when there was no stability record to compare
177
+ * against, so a zero means no evidence, not no problem.
178
+ * @property {string[]} [newlyUnstablePaths] A few of them, for reading. Never all of them.
179
+ * @property {string} [note] One plain sentence.
180
+ */
181
+
182
+ /**
183
+ * A finding, cut down to what a window shows.
184
+ * @typedef {object} PanelFinding
185
+ * @property {string} id
186
+ * @property {string} title
187
+ * @property {string} why
188
+ * @property {FindingClass} class
189
+ * @property {boolean} sealed No agent may wave this through.
190
+ * @property {number} count Differences this one finding stands for.
191
+ * @property {number} rank
192
+ * @property {string[]} paths At most a handful. The count above says the real size.
193
+ * @property {string[]} [nearFiles]
194
+ * @property {string} [summary]
195
+ * @property {string} [sample] One difference, written out as a line.
196
+ * @property {string} [evidence] A file address. Fetched when asked for, never pushed.
197
+ * @property {string} [journey]
198
+ * @property {Surface} [surface]
199
+ */
200
+
201
+ /**
202
+ * Coverage, cut down, and deliberately without a percentage.
203
+ *
204
+ * A percentage invites a target and a target invites gaming. Counts and named gaps cannot be
205
+ * gamed without the number of named gaps going down, which is the thing anybody would notice.
206
+ *
207
+ * @typedef {object} PanelCoverage
208
+ * @property {number} paths
209
+ * @property {number} journeys
210
+ * @property {Record<string, number>} byChannel
211
+ * @property {number} [doorsKnown]
212
+ * @property {number} [doorsWalked]
213
+ * @property {number} [doorsUnopened] Worked out here so the window never does arithmetic.
214
+ * @property {PanelGap[]} gaps
215
+ * @property {number} [gapsHidden] Gaps beyond the ones listed.
216
+ */
217
+
218
+ /**
219
+ * One hole, and what would fill it.
220
+ * @typedef {object} PanelGap
221
+ * @property {string} what
222
+ * @property {string} why
223
+ * @property {string} [unlockedBy]
224
+ * @property {number} [doors]
225
+ * @property {Surface} [surface]
226
+ * @property {string} [surfaceWord]
227
+ */
228
+
229
+ /**
230
+ * The end of a run, as much of it as a window needs.
231
+ * @typedef {object} PanelVerdict
232
+ * @property {boolean} ok
233
+ * @property {ReferenceMode} mode
234
+ * @property {string} [modeWarning]
235
+ * @property {string} summary
236
+ * @property {number} findings
237
+ * @property {number} sealed How many need a person.
238
+ * @property {number} differencesReal
239
+ * @property {number} differencesNoise
240
+ * @property {number} durationMs
241
+ * @property {string} [reference]
242
+ * @property {string} [candidate]
243
+ */
244
+
245
+ /**
246
+ * One journey, as the window lists it before anything has happened.
247
+ * @typedef {object} PanelJourney
248
+ * @property {string} name
249
+ * @property {string} [describe]
250
+ * @property {Surface} [surface]
251
+ * @property {string} [surfaceWord]
252
+ * @property {string} [source]
253
+ * @property {string} [sourceWord]
254
+ * @property {string} [skip] Switched off, and therefore missing coverage.
255
+ */
256
+
257
+ /**
258
+ * What the window is told before the check starts.
259
+ * @typedef {object} PanelPlanShape
260
+ * @property {string} [product] One repository can build five. This names one.
261
+ * @property {string} [project] The folder it is being run in.
262
+ * @property {PanelJourney[]} [journeys]
263
+ * @property {string[]} [surfaces] The surfaces in play, in words.
264
+ * @property {Surface|string} [surface] When a check walks exactly one, naming it here is
265
+ * enough and the list above can be left out.
266
+ * @property {PanelReference|string} [reference] The shape, or just the sentence when that is
267
+ * all the host has.
268
+ * @property {ReferenceMode} [mode] Filled in when the reference is known up front.
269
+ * @property {string} [modeWarning] Present whenever the mode is the weaker one.
270
+ * @property {'dark'|'light'|'system'} [theme]
271
+ */
272
+
273
+ /**
274
+ * Anywhere an event can be dropped.
275
+ *
276
+ * Loose about the event on purpose: the engine types its own stream narrowly, and a lane that
277
+ * wants to say one of v2's new events should not have to widen a typedef it does not own to be
278
+ * allowed to say it.
279
+ *
280
+ * @typedef {object} EventSink
281
+ * @property {(event: any) => void} emit
282
+ * @property {() => number} [elapsed]
283
+ */
284
+
285
+ /**
286
+ * Anything that can be listened to. Only `on` is asked for, because that is all a window needs
287
+ * and asking for less is what lets the engine's own stream be handed straight over.
288
+ *
289
+ * @typedef {object} Watchable
290
+ * @property {(listener: (event: any) => void) => (() => void)} on
291
+ */
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Saying the new things
295
+ // ---------------------------------------------------------------------------
296
+
297
+ /**
298
+ * Put an event on a stream that may not be there.
299
+ *
300
+ * Watching is a convenience everywhere in this tool: a check with nobody watching is the
301
+ * normal case, and this keeps that from being an `if` at every call site.
302
+ *
303
+ * @param {EventSink|undefined|null} events
304
+ * @param {PanelEvent} event
305
+ * @returns {void}
306
+ */
307
+ export function say(events, event) {
308
+ if (!events || typeof events.emit !== 'function') return;
309
+ try {
310
+ events.emit(event);
311
+ } catch {
312
+ // A stream that cannot take an event is not a reason for a check to stop. It is the
313
+ // window's problem, and the window is optional.
314
+ }
315
+ }
316
+
317
+ /**
318
+ * How many milliseconds in, according to the stream itself when it can say.
319
+ * @param {EventSink|undefined|null} events
320
+ * @returns {number}
321
+ */
322
+ function now(events) {
323
+ try {
324
+ return typeof events?.elapsed === 'function' ? events.elapsed() : 0;
325
+ } catch {
326
+ return 0;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * The plan, said out loud, for a window that opened after the check started.
332
+ *
333
+ * @param {EventSink|undefined|null} events
334
+ * @param {PanelPlanShape} plan
335
+ * @returns {void}
336
+ */
337
+ export function sayPlan(events, plan) {
338
+ say(events, { type: 'plan', at: now(events), plan });
339
+ }
340
+
341
+ /**
342
+ * Which build this is being measured against — and, when it is the weaker kind, that it is.
343
+ *
344
+ * @param {EventSink|undefined|null} events
345
+ * @param {PanelReference} reference
346
+ * @returns {void}
347
+ */
348
+ export function sayReference(events, reference) {
349
+ say(events, {
350
+ type: 'reference',
351
+ at: now(events),
352
+ reference,
353
+ message: reference.weak
354
+ ? `${reference.warning || 'This is a weaker check than usual.'} ${reference.how}`.trim()
355
+ : `Measured against ${reference.name}. ${reference.how}`.trim(),
356
+ });
357
+ }
358
+
359
+ /**
360
+ * A journey starting, on a named surface.
361
+ *
362
+ * The surface is the new part. One repository builds a website, a desktop app and a phone app,
363
+ * and a window that only says "walking checkout" leaves a person guessing which of the three
364
+ * they are watching.
365
+ *
366
+ * @param {EventSink|undefined|null} events
367
+ * @param {object} what
368
+ * @param {string} what.journey
369
+ * @param {Surface} [what.surface]
370
+ * @param {string} [what.describe]
371
+ * @param {string} [what.source]
372
+ * @param {string} [what.run] 'a', 'b' or 'single'.
373
+ * @param {number} [what.index]
374
+ * @param {number} [what.total]
375
+ * @returns {void}
376
+ */
377
+ export function sayJourneyStart(events, what) {
378
+ // "Website: buying one item with a saved card." The surface leads, because on a repository
379
+ // that builds five products the surface is the thing a person is trying to work out.
380
+ const where = what.surface ? `${surfaceWord(what.surface)}: ` : '';
381
+ const doing = what.describe || `walking ${what.journey}`;
382
+ say(events, {
383
+ type: 'journey:start',
384
+ at: now(events),
385
+ journey: what.journey,
386
+ describe: what.describe,
387
+ surface: what.surface,
388
+ surfaceWord: what.surface ? surfaceWord(what.surface) : undefined,
389
+ source: what.source,
390
+ run: what.run,
391
+ index: what.index,
392
+ total: what.total,
393
+ message: `${where}${doing}.`,
394
+ });
395
+ }
396
+
397
+ /**
398
+ * The address count rising while a journey is still walking.
399
+ *
400
+ * This is the number that makes the window worth having open: proof that something is
401
+ * happening, on a run where nothing is going to be wrong and there will be nothing to show.
402
+ *
403
+ * @param {EventSink|undefined|null} events
404
+ * @param {string} journey
405
+ * @param {number} count Addresses this journey has watched so far.
406
+ * @returns {void}
407
+ */
408
+ export function sayAddresses(events, journey, count) {
409
+ say(events, { type: 'journey:addresses', at: now(events), journey, count });
410
+ }
411
+
412
+ /**
413
+ * A journey finished.
414
+ * @param {EventSink|undefined|null} events
415
+ * @param {object} what
416
+ * @param {string} what.journey
417
+ * @param {number} what.count Addresses watched.
418
+ * @param {number} [what.unstable] Of those, how many would not sit still.
419
+ * @param {number} [what.durationMs]
420
+ * @param {Surface} [what.surface]
421
+ * @param {string} [what.message]
422
+ * @returns {void}
423
+ */
424
+ export function sayJourneyDone(events, what) {
425
+ const unstable = Number(what.unstable ?? 0);
426
+ say(events, {
427
+ type: 'journey:done',
428
+ at: now(events),
429
+ journey: what.journey,
430
+ surface: what.surface,
431
+ surfaceWord: what.surface ? surfaceWord(what.surface) : undefined,
432
+ count: what.count,
433
+ durationMs: what.durationMs,
434
+ message:
435
+ what.message ||
436
+ `${plural(what.count, 'address', 'addresses')} watched` +
437
+ (unstable > 0 ? `, ${unstable} of which this build cannot answer the same way twice.` : '.'),
438
+ });
439
+ }
440
+
441
+ /**
442
+ * The wobble, measured.
443
+ *
444
+ * Nobody else's tool has this number, so it does not get buried. Everything that would not sit
445
+ * still between two runs of the SAME build was not caused by the change, and is subtracted
446
+ * arithmetically rather than allowed for by a tolerance somebody guessed.
447
+ *
448
+ * @param {EventSink|undefined|null} events
449
+ * @param {PanelWobble} wobble
450
+ * @returns {void}
451
+ */
452
+ export function sayWobble(events, wobble) {
453
+ const note = wobble.note || wobbleSentence(wobble);
454
+ say(events, { type: 'wobble', at: now(events), wobble: { ...wobble, note }, count: wobble.unstable, message: note });
455
+ }
456
+
457
+ /**
458
+ * One finding, the moment it is formed.
459
+ *
460
+ * The finding goes on the stream whole. Cutting it down is the mapper's job and only the
461
+ * mapper's job — two places trimming the same shape is how a window ends up drawing a
462
+ * finding that has already had its findings taken out of it.
463
+ *
464
+ * @param {EventSink|undefined|null} events
465
+ * @param {Finding} finding
466
+ * @returns {void}
467
+ */
468
+ export function sayFinding(events, finding) {
469
+ say(events, { type: 'finding', at: now(events), finding, message: finding?.title });
470
+ }
471
+
472
+ /**
473
+ * The coverage, folded — which is mostly the list of what was NOT looked at.
474
+ * @param {EventSink|undefined|null} events
475
+ * @param {Coverage} coverage
476
+ * @returns {void}
477
+ */
478
+ export function sayCoverage(events, coverage) {
479
+ const gaps = Array.isArray(coverage?.gaps) ? coverage.gaps.length : 0;
480
+ say(events, {
481
+ type: 'coverage',
482
+ at: now(events),
483
+ coverage,
484
+ count: gaps,
485
+ message: coverageSentence(trimCoverage(coverage)),
486
+ });
487
+ }
488
+
489
+ /**
490
+ * A plain note, for anything that does not have a shape of its own.
491
+ * @param {EventSink|undefined|null} events
492
+ * @param {string} message
493
+ * @returns {void}
494
+ */
495
+ export function sayNote(events, message) {
496
+ say(events, { type: 'note', at: now(events), message });
497
+ }
498
+
499
+ /**
500
+ * The end.
501
+ * @param {EventSink|undefined|null} events
502
+ * @param {Verdict} verdict
503
+ * @returns {void}
504
+ */
505
+ export function sayCheckDone(events, verdict) {
506
+ say(events, { type: 'check:done', at: now(events), verdict, durationMs: verdict?.durationMs, message: verdict?.summary });
507
+ }
508
+
509
+ // ---------------------------------------------------------------------------
510
+ // Trimming — what crosses into the window, and what stays out
511
+ // ---------------------------------------------------------------------------
512
+
513
+ /** How many addresses of a finding travel with it. The count says the real size. */
514
+ const PATHS_SHOWN = 6;
515
+ /** How many gaps travel with the coverage. The rest are counted. */
516
+ const GAPS_SHOWN = 12;
517
+ /** How many unstable addresses are named. */
518
+ const WOBBLE_PATHS_SHOWN = 5;
519
+
520
+ /**
521
+ * A finding, cut down to what a window can show without becoming a database viewer.
522
+ *
523
+ * One missing stylesheet is ONE finding standing for four hundred differences. The window is
524
+ * told the four hundred and shown one of them: a list of four hundred lines is not
525
+ * information, it is a place information goes to hide.
526
+ *
527
+ * Takes whatever arrives, including a finding that has already been through here once: this
528
+ * is the boundary between the engine's shapes and the window's, and a boundary that refuses
529
+ * unexpected input is a boundary that breaks the window it was meant to protect.
530
+ *
531
+ * @param {any} finding
532
+ * @returns {PanelFinding}
533
+ */
534
+ export function trimFinding(finding) {
535
+ const differences = Array.isArray(finding.differences) ? finding.differences : [];
536
+ const paths =
537
+ Array.isArray(finding.paths) && finding.paths.length
538
+ ? finding.paths.slice(0, PATHS_SHOWN)
539
+ : differences.slice(0, PATHS_SHOWN).map((/** @type {any} */ d) => String(d?.path ?? ''));
540
+ const sample = finding.sample || differences[0];
541
+ return {
542
+ id: String(finding.id ?? ''),
543
+ title: String(finding.title ?? 'Something changed.'),
544
+ why: String(finding.why ?? ''),
545
+ class: finding.class || 'ordinary',
546
+ sealed: Boolean(finding.sealed) || isSealedClass(finding.class),
547
+ count: Number(finding.count ?? differences.length ?? 0) || differences.length,
548
+ rank: Number(finding.rank ?? 0),
549
+ paths: paths.filter(Boolean),
550
+ nearFiles: Array.isArray(finding.nearFiles) ? finding.nearFiles.slice(0, 3) : undefined,
551
+ summary: finding.summary,
552
+ sample: sample ? differenceLine(sample) : undefined,
553
+ evidence: finding.evidence,
554
+ journey: differences.find((/** @type {any} */ d) => d && d.journey)?.journey,
555
+ };
556
+ }
557
+
558
+ /**
559
+ * The classes an agent may never wave through.
560
+ * @param {string|undefined} klass
561
+ * @returns {boolean}
562
+ */
563
+ export function isSealedClass(klass) {
564
+ return klass === 'money' || klass === 'sign-in' || klass === 'data-loss' || klass === 'crash' || klass === 'guard';
565
+ }
566
+
567
+ /**
568
+ * What each class is called on screen, for somebody who is not a programmer.
569
+ * @type {Readonly<Record<string, string>>}
570
+ */
571
+ export const CLASS_WORDS = Object.freeze({
572
+ money: 'money',
573
+ 'sign-in': 'signing in',
574
+ 'data-loss': 'losing data',
575
+ crash: 'a crash',
576
+ guard: 'a bug you already reported',
577
+ ordinary: 'ordinary',
578
+ });
579
+
580
+ /**
581
+ * One difference, written out as a line somebody can read.
582
+ * @param {Difference|string} d
583
+ * @returns {string}
584
+ */
585
+ export function differenceLine(d) {
586
+ // Already written out once. Trimming is allowed to happen twice and must not lose anything.
587
+ if (typeof d === 'string') return d;
588
+ if (!d || typeof d !== 'object') return '';
589
+ const path = String(d.path ?? '');
590
+ if (d.kind === 'appeared') return `${path} — appeared: ${short(d.candidate)}`;
591
+ if (d.kind === 'vanished') return `${path} — no longer there (was ${short(d.reference)})`;
592
+ return `${path} — was ${short(d.reference)}, now ${short(d.candidate)}`;
593
+ }
594
+
595
+ /**
596
+ * A value, short enough to sit on one line.
597
+ * @param {unknown} value
598
+ * @returns {string}
599
+ */
600
+ function short(value) {
601
+ if (value === null) return 'nothing';
602
+ if (value === undefined) return 'absent';
603
+ let text;
604
+ try {
605
+ text = typeof value === 'string' ? value : JSON.stringify(value);
606
+ } catch {
607
+ text = String(value);
608
+ }
609
+ text = String(text ?? '').replace(/\s+/g, ' ').trim();
610
+ return text.length > 90 ? `${text.slice(0, 89)}…` : text;
611
+ }
612
+
613
+ /**
614
+ * Coverage, cut down, and still without a percentage. Safe to run twice.
615
+ * @param {any} coverage
616
+ * @returns {PanelCoverage}
617
+ */
618
+ export function trimCoverage(coverage) {
619
+ const gaps = Array.isArray(coverage?.gaps) ? coverage.gaps : [];
620
+ const doorsKnown = numberOr(coverage?.doorsKnown);
621
+ const doorsWalked = numberOr(coverage?.doorsWalked);
622
+ /** @type {Record<string, number>} */
623
+ const byChannel = {};
624
+ const source = coverage?.byChannel ?? {};
625
+ for (const key of Object.keys(source)) {
626
+ const n = Number(/** @type {Record<string, unknown>} */ (source)[key]);
627
+ if (Number.isFinite(n)) byChannel[key] = n;
628
+ }
629
+ return {
630
+ paths: Number(coverage?.paths ?? 0) || 0,
631
+ journeys: Number(coverage?.journeys ?? 0) || 0,
632
+ byChannel,
633
+ doorsKnown,
634
+ doorsWalked,
635
+ doorsUnopened:
636
+ doorsKnown !== undefined && doorsWalked !== undefined ? Math.max(0, doorsKnown - doorsWalked) : undefined,
637
+ gaps: gaps.slice(0, GAPS_SHOWN).map((/** @type {any} */ g) => ({
638
+ what: String(g?.what ?? ''),
639
+ why: String(g?.why ?? ''),
640
+ unlockedBy: g?.unlockedBy,
641
+ doors: numberOr(g?.doors),
642
+ surface: g?.surface,
643
+ surfaceWord: g?.surface ? surfaceWord(g.surface) : undefined,
644
+ })),
645
+ gapsHidden: Math.max(0, gaps.length - GAPS_SHOWN),
646
+ };
647
+ }
648
+
649
+ /**
650
+ * A whole verdict, cut down to the handful of numbers a window states. Safe to run twice.
651
+ * @param {any} verdict
652
+ * @returns {PanelVerdict}
653
+ */
654
+ export function trimVerdict(verdict) {
655
+ // A verdict that has already been cut down carries a COUNT of findings where a whole one
656
+ // carries the list. Both are accepted, because trimming twice must never quietly report
657
+ // that a run with four findings had none.
658
+ const list = Array.isArray(verdict?.findings) ? verdict.findings : [];
659
+ const counted = typeof verdict?.findings === 'number' ? verdict.findings : list.length;
660
+ const sealed =
661
+ typeof verdict?.sealed === 'number'
662
+ ? Number(verdict.sealed)
663
+ : list.filter((/** @type {any} */ f) => f?.sealed || isSealedClass(f?.class)).length;
664
+ return {
665
+ ok: Boolean(verdict?.ok),
666
+ mode: verdict?.mode ?? 'stored-record',
667
+ modeWarning: verdict?.modeWarning,
668
+ summary: String(verdict?.summary ?? ''),
669
+ findings: counted,
670
+ sealed,
671
+ differencesReal: Number(verdict?.differencesReal ?? 0) || 0,
672
+ differencesNoise: Number(verdict?.differencesNoise ?? 0) || 0,
673
+ durationMs: Number(verdict?.durationMs ?? 0) || 0,
674
+ reference: verdict?.reference ? buildName(verdict.reference) : undefined,
675
+ candidate: verdict?.candidate ? buildName(verdict.candidate) : undefined,
676
+ };
677
+ }
678
+
679
+ /**
680
+ * What to call a build on screen.
681
+ *
682
+ * Takes a name that has already been worked out as readily as the whole fingerprint, because
683
+ * trimming can happen twice and a build must not lose its name the second time round.
684
+ *
685
+ * @param {BuildFingerprint|string|undefined} build
686
+ * @returns {string}
687
+ */
688
+ export function buildName(build) {
689
+ if (typeof build === 'string') return build || 'an unnamed build';
690
+ if (!build || typeof build !== 'object') return 'an unnamed build';
691
+ if (build.version) return build.version;
692
+ if (build.gitSha) return String(build.gitSha).slice(0, 8);
693
+ return String(build.id ?? 'an unnamed build');
694
+ }
695
+
696
+ /**
697
+ * The wobble, as one sentence.
698
+ * @param {PanelWobble} w
699
+ * @returns {string}
700
+ */
701
+ export function wobbleSentence(w) {
702
+ if (!w.measured) {
703
+ return 'This build was only run once, so its own wobble was never measured. Anything below could be the product arguing with itself.';
704
+ }
705
+ if (w.unstable === 0) return 'This build gives the same answer twice, everywhere.';
706
+ return `${plural(w.unstable, 'address', 'addresses')} this build cannot answer the same way twice. Subtracted, not counted.`;
707
+ }
708
+
709
+ /**
710
+ * Coverage, as one sentence — the one that has to arrive in the same breath as the good news.
711
+ * @param {PanelCoverage} c
712
+ * @returns {string}
713
+ */
714
+ export function coverageSentence(c) {
715
+ const parts = [`${plural(c.paths, 'address', 'addresses')} watched across ${plural(c.journeys, 'journey', 'journeys')}`];
716
+ if (c.doorsUnopened !== undefined && c.doorsUnopened > 0) {
717
+ parts.push(`${plural(c.doorsUnopened, 'door', 'doors')} in the code that no journey has ever opened`);
718
+ }
719
+ const holes = c.gaps.length + (c.gapsHidden ?? 0);
720
+ if (holes > 0) parts.push(`${plural(holes, 'thing', 'things')} that could not be checked at all`);
721
+ return `${parts.join(', ')}.`;
722
+ }
723
+
724
+ /**
725
+ * "1 address", "17 addresses". With the number, because a bare word is not a count.
726
+ * @param {number|undefined} n
727
+ * @param {string} one
728
+ * @param {string} many
729
+ * @returns {string}
730
+ */
731
+ export function plural(n, one, many) {
732
+ const v = Math.round(Number(n) || 0);
733
+ return `${v.toLocaleString('en-US')} ${v === 1 ? one : many}`;
734
+ }
735
+
736
+ /**
737
+ * @param {unknown} value
738
+ * @returns {number|undefined}
739
+ */
740
+ function numberOr(value) {
741
+ const n = Number(value);
742
+ return Number.isFinite(n) ? n : undefined;
743
+ }
744
+
745
+ // ---------------------------------------------------------------------------
746
+ // The plan the window opens with
747
+ // ---------------------------------------------------------------------------
748
+
749
+ /**
750
+ * Build the opening plan from what a check already knows.
751
+ *
752
+ * A window that opens empty and fills up looks broken for the first few seconds. This is what
753
+ * it draws before anything has happened: the product, the surfaces in play, and every journey
754
+ * it is about to walk, in order.
755
+ *
756
+ * @param {object} input
757
+ * @param {string} [input.product]
758
+ * @param {string} [input.project]
759
+ * @param {Journey[]} [input.journeys]
760
+ * @param {PanelReference} [input.reference]
761
+ * @param {'dark'|'light'|'system'} [input.theme]
762
+ * @returns {PanelPlanShape}
763
+ */
764
+ export function panelPlan(input) {
765
+ const journeys = Array.isArray(input?.journeys) ? input.journeys : [];
766
+ /** @type {PanelJourney[]} */
767
+ const rows = [];
768
+ /** @type {string[]} */
769
+ const surfaces = [];
770
+ for (const j of journeys) {
771
+ if (!j || typeof j !== 'object' || !j.name) continue;
772
+ const word = j.surface ? surfaceWord(j.surface) : undefined;
773
+ if (word && !surfaces.includes(word)) surfaces.push(word);
774
+ rows.push({
775
+ name: String(j.name),
776
+ describe: j.describe ? String(j.describe) : undefined,
777
+ surface: j.surface,
778
+ surfaceWord: word,
779
+ source: j.source,
780
+ sourceWord: j.source ? /** @type {Record<string, string>} */ (SOURCE_WORDS)[j.source] : undefined,
781
+ skip: j.skip,
782
+ });
783
+ }
784
+ return {
785
+ product: input?.product ? String(input.product) : undefined,
786
+ project: input?.project ? String(input.project) : undefined,
787
+ journeys: rows,
788
+ surfaces,
789
+ reference: input?.reference,
790
+ theme: input?.theme,
791
+ };
792
+ }
793
+
794
+ // ---------------------------------------------------------------------------
795
+ // The mapping
796
+ // ---------------------------------------------------------------------------
797
+
798
+ /**
799
+ * Turns the engine's stream into what the window draws.
800
+ *
801
+ * It carries a little memory — the plan, which journey is where, how many addresses have gone
802
+ * by — because the alternative is threading all of that through the engine on every event, and
803
+ * the engine has a difference machine to run.
804
+ *
805
+ * @typedef {object} Mapper
806
+ * @property {(event: any) => PanelEvent[]} map
807
+ * @property {PanelPlanShape} plan
808
+ */
809
+
810
+ /**
811
+ * A mapper, holding the plan it was opened with.
812
+ *
813
+ * @param {PanelPlanShape} [plan]
814
+ * @returns {Mapper}
815
+ */
816
+ export function makeMapper(plan = {}) {
817
+ /** @type {Map<string, PanelJourney>} */
818
+ const known = new Map();
819
+ for (const j of plan.journeys ?? []) known.set(j.name, j);
820
+
821
+ /** @type {Map<string, number>} */
822
+ const perJourney = new Map();
823
+ /** @type {Set<string>} */
824
+ const started = new Set();
825
+ let watched = 0;
826
+ let announcedWobble = false;
827
+
828
+ /** @param {string|undefined} name */
829
+ function about(name) {
830
+ return (name && known.get(name)) || undefined;
831
+ }
832
+
833
+ /**
834
+ * @param {any} event
835
+ * @returns {PanelEvent[]}
836
+ */
837
+ function map(event) {
838
+ if (!event || typeof event !== 'object' || typeof event.type !== 'string') return [];
839
+ const at = Number(event.at) || 0;
840
+ const type = /** @type {string} */ (event.type);
841
+ const message = typeof event.message === 'string' ? event.message : undefined;
842
+
843
+ switch (type) {
844
+ case 'plan': {
845
+ const next = /** @type {PanelPlanShape} */ (event.plan ?? {});
846
+ for (const j of next.journeys ?? []) known.set(j.name, j);
847
+ return [{ type: 'plan', at, plan: next, message }];
848
+ }
849
+
850
+ case 'check:start':
851
+ return [{ type: 'check:start', at, message }];
852
+
853
+ case 'reference': {
854
+ const reference = /** @type {PanelReference|undefined} */ (event.reference);
855
+ return [{ type: 'reference', at, message, reference: reference ?? inferReference(message) }];
856
+ }
857
+
858
+ case 'journey:start': {
859
+ const name = str(event.journey);
860
+ const meta = about(name);
861
+ if (name && !started.has(name)) started.add(name);
862
+ return [
863
+ {
864
+ type: 'journey:start',
865
+ at,
866
+ message,
867
+ journey: name,
868
+ describe: str(event.describe) ?? meta?.describe,
869
+ surface: event.surface ?? meta?.surface,
870
+ surfaceWord: event.surfaceWord ?? meta?.surfaceWord,
871
+ source: str(event.source) ?? meta?.source,
872
+ run: str(event.run),
873
+ index: numberOr(event.index) ?? started.size,
874
+ total: numberOr(event.total) ?? (plan.journeys?.length || undefined),
875
+ },
876
+ ];
877
+ }
878
+
879
+ case 'journey:addresses': {
880
+ const name = str(event.journey) ?? '';
881
+ const count = Number(event.count) || 0;
882
+ const before = perJourney.get(name) ?? 0;
883
+ if (count > before) {
884
+ watched += count - before;
885
+ perJourney.set(name, count);
886
+ }
887
+ return [{ type: 'journey:addresses', at, journey: name, count, watched }];
888
+ }
889
+
890
+ case 'journey:done': {
891
+ const name = str(event.journey) ?? '';
892
+ const count = Number(event.count) || 0;
893
+ const before = perJourney.get(name) ?? 0;
894
+ if (count > before) {
895
+ watched += count - before;
896
+ perJourney.set(name, count);
897
+ }
898
+ const meta = about(name);
899
+ return [
900
+ {
901
+ type: 'journey:done',
902
+ at,
903
+ message,
904
+ journey: name,
905
+ count,
906
+ watched,
907
+ durationMs: numberOr(event.durationMs),
908
+ surface: event.surface ?? meta?.surface,
909
+ surfaceWord: event.surfaceWord ?? meta?.surfaceWord,
910
+ },
911
+ ];
912
+ }
913
+
914
+ case 'wobble': {
915
+ announcedWobble = true;
916
+ const wobble = /** @type {PanelWobble|undefined} */ (event.wobble) ?? {
917
+ measured: true,
918
+ unstable: Number(event.count) || 0,
919
+ steady: Math.max(0, watched - (Number(event.count) || 0)),
920
+ newlyUnstable: 0,
921
+ };
922
+ return [{ type: 'wobble', at, message: message ?? wobbleSentence(wobble), wobble, count: wobble.unstable }];
923
+ }
924
+
925
+ case 'suspicion':
926
+ case 'proof:start':
927
+ case 'proof:done':
928
+ case 'cluster':
929
+ return [{ type: /** @type {PanelEventType} */ (type), at, message, count: numberOr(event.count) }];
930
+
931
+ case 'note':
932
+ return message ? [{ type: 'note', at, message }] : [];
933
+
934
+ case 'finding': {
935
+ if (!event.finding) return [];
936
+ const finding = trimFinding(event.finding);
937
+ return [{ type: 'finding', at, finding, message: finding.title }];
938
+ }
939
+
940
+ case 'coverage': {
941
+ if (!event.coverage) return [];
942
+ const coverage = trimCoverage(event.coverage);
943
+ return [{ type: 'coverage', at, coverage, message: message ?? coverageSentence(coverage) }];
944
+ }
945
+
946
+ case 'check:done':
947
+ return finish(event, at, message);
948
+
949
+ default:
950
+ // v1's own events can land on this stream. They are not v2's vocabulary and they are
951
+ // not an error either: they are simply not drawn.
952
+ return [];
953
+ }
954
+ }
955
+
956
+ /**
957
+ * The end, fanned out.
958
+ *
959
+ * A verdict carries everything — every finding, every difference, the whole coverage — and
960
+ * the window wants it as a handful of things it can draw one after another. Findings first,
961
+ * then what was not checked, then the verdict itself, so the last thing to land is the
962
+ * sentence a person reads.
963
+ *
964
+ * @param {any} event
965
+ * @param {number} at
966
+ * @param {string|undefined} message
967
+ * @returns {PanelEvent[]}
968
+ */
969
+ function finish(event, at, message) {
970
+ /** @type {PanelEvent[]} */
971
+ const out = [];
972
+ const verdict = /** @type {Verdict|undefined} */ (event.verdict);
973
+ if (verdict) {
974
+ const findings = Array.isArray(verdict.findings) ? verdict.findings : [];
975
+ const noise = Number(verdict.differencesNoise ?? 0) || 0;
976
+ for (const f of findings) out.push({ type: 'finding', at, finding: trimFinding(f), message: f?.title });
977
+ if (verdict.coverage) {
978
+ const coverage = trimCoverage(verdict.coverage);
979
+ out.push({ type: 'coverage', at, coverage, message: coverageSentence(coverage) });
980
+ }
981
+ if (!announcedWobble && Array.isArray(verdict.newlyUnstable)) {
982
+ /** @type {PanelWobble} */
983
+ const wobble = {
984
+ measured: true,
985
+ unstable: noise,
986
+ steady: Math.max(0, watched - noise),
987
+ newlyUnstable: verdict.newlyUnstable.length,
988
+ newlyUnstablePaths: verdict.newlyUnstable.slice(0, WOBBLE_PATHS_SHOWN).map((e) => String(e?.path ?? '')),
989
+ };
990
+ out.push({ type: 'wobble', at, wobble, message: wobbleSentence(wobble), count: wobble.unstable });
991
+ }
992
+ out.push({
993
+ type: 'check:done',
994
+ at,
995
+ verdict: trimVerdict(verdict),
996
+ durationMs: verdict.durationMs,
997
+ message: message ?? verdict.summary,
998
+ });
999
+ return out;
1000
+ }
1001
+ out.push({ type: 'check:done', at, message, verdict: /** @type {PanelVerdict|undefined} */ (event.panelVerdict) });
1002
+ return out;
1003
+ }
1004
+
1005
+ return { map, plan };
1006
+ }
1007
+
1008
+ /**
1009
+ * When the engine says something about the reference but hands over no shape, take it at its
1010
+ * word rather than inventing a build name.
1011
+ *
1012
+ * @param {string|undefined} message
1013
+ * @returns {PanelReference|undefined}
1014
+ */
1015
+ function inferReference(message) {
1016
+ if (!message) return undefined;
1017
+ const nothing = /no build on record/i.test(message);
1018
+ if (!nothing) return undefined;
1019
+ return {
1020
+ name: 'nothing yet',
1021
+ mode: 'none',
1022
+ weak: true,
1023
+ how: 'Nothing has been recorded as working yet, so this run has nothing to measure against.',
1024
+ warning: 'There is no reference. This run can describe the product, but it cannot prove anything is unchanged.',
1025
+ };
1026
+ }
1027
+
1028
+ /**
1029
+ * @param {unknown} value
1030
+ * @returns {string|undefined}
1031
+ */
1032
+ function str(value) {
1033
+ return typeof value === 'string' && value !== '' ? value : undefined;
1034
+ }
1035
+
1036
+ // ---------------------------------------------------------------------------
1037
+ // Wiring a window to a check
1038
+ // ---------------------------------------------------------------------------
1039
+
1040
+ /**
1041
+ * Point a window at a check.
1042
+ *
1043
+ * `push` is whatever gets one event into the page — over a debugging connection, down a pipe,
1044
+ * into a test's array. This file never opens a browser and never knows there is one.
1045
+ *
1046
+ * The window is a convenience and is treated like one throughout: a push that throws is
1047
+ * swallowed, and the check carries on. Minimising the window, closing it, or never opening it
1048
+ * changes nothing about what gets checked.
1049
+ *
1050
+ * @param {Watchable} events
1051
+ * @param {(event: PanelEvent) => void} push
1052
+ * @param {object} [options]
1053
+ * @param {PanelPlanShape} [options.plan]
1054
+ * @param {(problem: unknown) => void} [options.onProblem] Told when a push fails. Optional.
1055
+ * @returns {() => void} Call it to stop listening.
1056
+ */
1057
+ export function attachPanel(events, push, options = {}) {
1058
+ const mapper = makeMapper(options.plan ?? {});
1059
+ let stopped = false;
1060
+
1061
+ const off = events.on((event) => {
1062
+ if (stopped) return;
1063
+ for (const drawn of mapper.map(event)) {
1064
+ try {
1065
+ push(drawn);
1066
+ } catch (problem) {
1067
+ // Never the reason a check looks broken.
1068
+ if (options.onProblem) {
1069
+ try {
1070
+ options.onProblem(problem);
1071
+ } catch {
1072
+ // Even the complaint is optional.
1073
+ }
1074
+ }
1075
+ }
1076
+ }
1077
+ });
1078
+
1079
+ return () => {
1080
+ stopped = true;
1081
+ try {
1082
+ off();
1083
+ } catch {
1084
+ // Already gone.
1085
+ }
1086
+ };
1087
+ }