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.
- package/CHANGELOG.md +11 -0
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/v2/adapters/android-driver.js +8 -1
- package/src/v2/adapters/android.js +36 -4
- package/src/v2/watch/events.js +1087 -0
- package/src/v2/watch/index.js +382 -0
- package/src/v2/watch/panel.js +1660 -0
- package/src/v2/watch/window.js +1671 -0
|
@@ -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
|
+
}
|