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,382 @@
1
+ /**
2
+ * The watch window, from the check's point of view.
3
+ *
4
+ * A check describes itself into an event stream and carries on. This subscribes to that
5
+ * stream, opens a window beside whatever is being checked, and forwards everything to it. It
6
+ * has no other effect on the check: it takes nothing away from it, it holds nothing up, and
7
+ * when the window cannot open the check does not notice.
8
+ *
9
+ * THREE THINGS HERE ARE NOT OBVIOUS, and all three are the owner's one requirement said in
10
+ * different places:
11
+ *
12
+ * "That window should come up... But once we minimise it, it should keep working headless
13
+ * in the background. Not invisible. It should not keep bringing itself to the front."
14
+ *
15
+ * ONE. The window is opened WITHOUT being waited for. `attachWatcher` hands back a watcher
16
+ * immediately and the browser starts behind it, so the check begins in the same millisecond it
17
+ * would have begun with no window at all. That is safe only because the event stream hands a
18
+ * late listener everything that already happened, in order — so a window that took two seconds
19
+ * to open still draws the two seconds it missed.
20
+ *
21
+ * TWO. The ENGINE works everything out and the PANEL only draws. Every sentence and every
22
+ * number a person reads is made here, on this side, by `events.js`, and pushed over finished.
23
+ * Nothing in the page is computed on a clock of its own. That is what makes minimising the
24
+ * window harmless: a browser slows a hidden page right down, and a page that is only drawing
25
+ * what it was handed loses nothing by being slow. It catches up the moment it is looked at.
26
+ *
27
+ * THREE. Nothing here ever waits on the window. `push` hands back nothing, so there is no
28
+ * promise to await by accident; every send has a hard timeout on it in `window.js`; and
29
+ * stopping the watcher never blocks on a window that has stopped answering.
30
+ *
31
+ * The division of labour across these four files: `events.js` decides WHAT to say, `panel.js`
32
+ * decides how it LOOKS, `window.js` owns the window it is said in, and this file is the seam
33
+ * between all of that and a check.
34
+ */
35
+
36
+ import { detail } from '../../core/log.js';
37
+ import { messageOf } from '../../core/errors.js';
38
+ import { openPanel } from './window.js';
39
+ import { attachPanel, panelPlan } from './events.js';
40
+
41
+ /** @typedef {import('../types.js').Journey} Journey */
42
+ /** @typedef {import('./events.js').PanelPlanShape} PanelPlan */
43
+ /** @typedef {import('./events.js').PanelReference} PanelReference */
44
+ /** @typedef {import('./window.js').Panel} Panel */
45
+ /** @typedef {import('./window.js').BesideThis} BesideThis */
46
+ /** @typedef {import('./window.js').PanelHealth} PanelHealth */
47
+
48
+ /** Panel width when nobody says otherwise. */
49
+ const DEFAULT_WIDTH = 480;
50
+
51
+ /** How long `snapTo` will wait for a window that is still opening before shrugging. */
52
+ const SNAP_WAIT_MS = 6000;
53
+
54
+ /**
55
+ * How long stopping will wait for a window that is STILL opening.
56
+ *
57
+ * Short, and it has to be. A check that finishes in two seconds on a machine where a browser
58
+ * takes twenty to start would otherwise sit there at the end, done, with nothing to say,
59
+ * waiting on a window nobody is going to read. So stopping calls the opening off and gives it
60
+ * a moment to tidy up; a window that arrives after that closes itself, because the opening
61
+ * sequence already knows it was stopped.
62
+ */
63
+ const STOP_WAIT_MS = 1500;
64
+
65
+ /**
66
+ * Anything with the two halves of an event stream on it.
67
+ *
68
+ * Written structurally rather than as the engine's own type so this can be handed a real
69
+ * check's stream, a stub in a test, or whatever the stream grows into next, without any of
70
+ * them having to import each other.
71
+ *
72
+ * @typedef {object} Watchable
73
+ * @property {(listener: (event: any) => void) => () => void} on
74
+ */
75
+
76
+ /**
77
+ * What the panel is told to do.
78
+ *
79
+ * `snap` lives here rather than in the shared options so that whether the two windows are
80
+ * pushed together stays a decision of the watch code.
81
+ *
82
+ * @typedef {import('../../types.js').WatchOptions & {snap?: boolean}} PanelOptions
83
+ */
84
+
85
+ /**
86
+ * @typedef {object} AttachOptions
87
+ * @property {string} [product] One repository can build five. This names the one being checked.
88
+ * @property {string} [project] The folder it is being run in.
89
+ * @property {Journey[]} [journeys] Every journey about to be walked, in order.
90
+ * @property {PanelReference} [reference] What it is being compared against, already in words.
91
+ * @property {PanelPlan} [plan] The finished plan, when a caller has built one itself.
92
+ * @property {PanelOptions} [watch]
93
+ * @property {string} [dir] Where to remember the window position. The project's own folder.
94
+ * @property {{width: number, height: number}} [appViewport]
95
+ */
96
+
97
+ /**
98
+ * A check's handle on its window.
99
+ *
100
+ * `snapTo` is how the check introduces the thing being observed once it is up. It is safe to
101
+ * call when there is no panel, when the panel cannot move windows, when the panel is still
102
+ * opening, and when the person asked for no snapping: in every one of those cases it does
103
+ * nothing and costs nothing.
104
+ *
105
+ * `health` is there so the claim this whole file makes — that the window never held the check
106
+ * up — has a number behind it rather than being taken on trust.
107
+ *
108
+ * @typedef {object} Watcher
109
+ * @property {() => Promise<void>} stop
110
+ * @property {(beside: BesideThis) => Promise<void>} snapTo
111
+ * @property {() => PanelHealth|null} health
112
+ * @property {() => boolean} open Is there a window right now.
113
+ */
114
+
115
+ /**
116
+ * Wait for something, but not for long, and never mind if it does not arrive.
117
+ *
118
+ * The whole of "stopping never blocks on a window", in five lines. The promise itself carries
119
+ * on — nothing here can cancel it, and it does not need to, because whatever it eventually
120
+ * produces knows it was stopped and puts itself away.
121
+ *
122
+ * @template T
123
+ * @param {Promise<T>} work
124
+ * @param {number} ms
125
+ * @returns {Promise<T|null>}
126
+ */
127
+ function soon(work, ms) {
128
+ /** @type {Promise<null>} */
129
+ const giveUp = new Promise((resolve) => {
130
+ const timer = setTimeout(() => resolve(null), ms);
131
+ // Never hold the program open waiting for a window.
132
+ if (typeof timer.unref === 'function') timer.unref();
133
+ });
134
+ return Promise.race([work.catch(() => null), giveUp]);
135
+ }
136
+
137
+ /**
138
+ * A watcher that is not watching anything.
139
+ *
140
+ * Handed back whenever there is no window — switched off, no browser, a page that would not
141
+ * build — so every caller has the same shape to stop, whatever happened.
142
+ *
143
+ * @returns {Watcher}
144
+ */
145
+ function noWatcher() {
146
+ return { stop: async () => {}, snapTo: async () => {}, health: () => null, open: () => false };
147
+ }
148
+
149
+ /**
150
+ * The opening plan: what the window draws before anything has happened.
151
+ *
152
+ * A window that opens empty and fills up looks broken for the first few seconds, so it is
153
+ * given the product, the surfaces in play and every journey it is about to walk, in order,
154
+ * from the moment it appears.
155
+ *
156
+ * @param {AttachOptions} opts
157
+ * @returns {PanelPlan}
158
+ */
159
+ function planFor(opts) {
160
+ if (opts.plan) return opts.plan;
161
+ return panelPlan({
162
+ ...(opts.product ? { product: opts.product } : {}),
163
+ ...(opts.project ? { project: opts.project } : {}),
164
+ ...(opts.journeys ? { journeys: opts.journeys } : {}),
165
+ ...(opts.reference ? { reference: opts.reference } : {}),
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Open a window and point it at a check.
171
+ *
172
+ * Never throws, and never waits for the window. Every way this can go wrong ends the same way:
173
+ * a line of detail, a watcher that does nothing, and a check that carries on at full speed.
174
+ *
175
+ * @param {Watchable} events
176
+ * @param {AttachOptions} [opts]
177
+ * @returns {Promise<Watcher>}
178
+ */
179
+ export async function attachWatcher(events, opts = {}) {
180
+ const watch = opts.watch ?? {};
181
+ if (watch.enabled === false) return noWatcher();
182
+ if (!events || typeof events.on !== 'function') return noWatcher();
183
+
184
+ const plan = { ...planFor(opts), theme: watch.theme ?? 'dark' };
185
+
186
+ /** @type {Panel|null} */
187
+ let panel = null;
188
+ /** @type {(() => void)|null} */
189
+ let unsubscribe = null;
190
+ let stopped = false;
191
+ // How stopping reaches a window that has not finished opening. Every wait inside `openPanel`
192
+ // watches this, so calling it off ends them all at once instead of one timeout at a time.
193
+ const givingUp = new AbortController();
194
+
195
+ /**
196
+ * Start the window and, when it is up, start feeding it.
197
+ *
198
+ * Nothing awaits this. Everything that can go wrong inside it ends with no panel and a check
199
+ * that never knew there was going to be one.
200
+ *
201
+ * @type {Promise<Panel|null>}
202
+ */
203
+ const opening = openPanel({
204
+ plan,
205
+ watch,
206
+ signal: givingUp.signal,
207
+ ...(opts.dir ? { dir: opts.dir } : {}),
208
+ ...(opts.appViewport ? { appViewport: opts.appViewport } : {}),
209
+ })
210
+ .then((open) => {
211
+ if (!open) return null;
212
+ if (stopped) {
213
+ // Stopped while it was still opening. Close it rather than leave a window nobody asked
214
+ // for standing on somebody's screen.
215
+ void open.close().catch(() => {});
216
+ return null;
217
+ }
218
+ panel = open;
219
+ // Everything that already happened arrives here first, in order, before the first live
220
+ // event does — which is the whole reason opening the window in the background is safe.
221
+ //
222
+ // `push` hands back nothing and swallows everything, so there is nothing in this
223
+ // callback that could hold a check up even by accident.
224
+ unsubscribe = attachPanel(events, (drawn) => open.push(drawn), {
225
+ plan,
226
+ onProblem: (problem) => {
227
+ detail(`The watch window refused an update. The check is unaffected. ${messageOf(problem)}`);
228
+ },
229
+ });
230
+ return open;
231
+ })
232
+ .catch((e) => {
233
+ // openPanel reports its own trouble and hands back null; this is only here for whatever
234
+ // it could not have seen coming.
235
+ detail(`The watch window could not open, so this check has no live view. ${messageOf(e)}`);
236
+ return null;
237
+ });
238
+
239
+ /**
240
+ * Wait a little for a window that is still opening, and give up cheerfully.
241
+ *
242
+ * Used only by `snapTo`, which happens once, early, at the one moment the window and the
243
+ * thing being checked are both about to exist. Everything else in here refuses to wait at all.
244
+ *
245
+ * @returns {Promise<Panel|null>}
246
+ */
247
+ async function panelSoon() {
248
+ if (panel) return panel;
249
+ return await soon(opening, SNAP_WAIT_MS);
250
+ }
251
+
252
+ const maySnap = watch.snap !== false;
253
+
254
+ /** @type {Promise<void>|null} */
255
+ let stopping = null;
256
+
257
+ return {
258
+ stop: () => {
259
+ stopping ??= (async () => {
260
+ stopped = true;
261
+ // Stop listening first, so nothing new is queued while we are putting it away.
262
+ try {
263
+ if (unsubscribe) unsubscribe();
264
+ } catch {
265
+ // Already gone. Nothing left to stop listening to.
266
+ }
267
+ // Call off a window that is still opening, and do not wait it out. A window that
268
+ // arrives late finds `stopped` already true and closes itself.
269
+ givingUp.abort();
270
+ const open = panel ?? (await soon(opening, STOP_WAIT_MS));
271
+ if (open) await open.close().catch(() => {});
272
+ })();
273
+ return stopping;
274
+ },
275
+
276
+ snapTo: async (beside) => {
277
+ if (!maySnap) return;
278
+ try {
279
+ const open = await panelSoon();
280
+ if (open) await open.snapTo(beside);
281
+ } catch (e) {
282
+ // Where two windows sit is a nicety. Losing it must never cost a check, and it is not
283
+ // worth a warning in the middle of a clean one.
284
+ detail(`The panel could not put itself beside what is being checked. ${messageOf(e)}`);
285
+ }
286
+ },
287
+
288
+ health: () => (panel ? panel.health() : null),
289
+ open: () => panel !== null,
290
+ };
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Settings
295
+ // ---------------------------------------------------------------------------
296
+
297
+ /**
298
+ * What the command line hands over. Anything the person did not type is left out, so the
299
+ * settings file still gets a say.
300
+ *
301
+ * @typedef {object} WatchFlags
302
+ * @property {boolean} [enabled]
303
+ * @property {'left'|'right'} [side]
304
+ * @property {number} [width]
305
+ * @property {number} [height]
306
+ * @property {boolean} [keepOpen]
307
+ * @property {boolean} [foreground]
308
+ * @property {boolean} [snap]
309
+ * @property {'dark'|'light'|'system'} [theme]
310
+ */
311
+
312
+ /**
313
+ * Settle what the panel should do: the settings file first, then anything typed on the command
314
+ * line, then the defaults.
315
+ *
316
+ * `--watch` can only ever turn the panel on. A person who did not type it has not said no to
317
+ * it — they have said nothing — so it never switches off a panel the settings file asked for.
318
+ *
319
+ * @param {{watch?: import('../../types.js').WatchOptions|boolean}|null} [config]
320
+ * @param {WatchFlags|null} [cli]
321
+ * @returns {PanelOptions}
322
+ */
323
+ export function watchOptionsFrom(config, cli) {
324
+ const raw = config?.watch;
325
+ // Read as PanelOptions on the way in: a settings file is free to carry a `snap` that the
326
+ // shared shape does not yet describe.
327
+ const settings = /** @type {PanelOptions} */ (
328
+ raw === true ? { enabled: true } : raw && typeof raw === 'object' ? raw : {}
329
+ );
330
+ const flags = cli ?? {};
331
+
332
+ const width = firstNumber(flags.width, settings.width) ?? DEFAULT_WIDTH;
333
+ const height = firstNumber(flags.height, settings.height);
334
+ const side = flags.side ?? settings.side ?? 'right';
335
+ const theme = flags.theme ?? settings.theme;
336
+
337
+ /** @type {PanelOptions} */
338
+ const merged = {
339
+ enabled: flags.enabled === true || settings.enabled === true,
340
+ width,
341
+ side: side === 'left' ? 'left' : 'right',
342
+ keepOpen: firstBoolean(flags.keepOpen, settings.keepOpen) ?? true,
343
+ // Never, unless somebody explicitly asks. This is the whole complaint: it must not keep
344
+ // bringing itself to the front.
345
+ foreground: firstBoolean(flags.foreground, settings.foreground) ?? false,
346
+ // On by default: the point of the panel is that it reads as a side panel of the thing it
347
+ // is checking, and it cannot do that sitting somewhere else on the screen.
348
+ snap: firstBoolean(flags.snap, settings.snap) ?? true,
349
+ // Dark unless somebody asks otherwise. The panel opens on a brand new browser profile, and
350
+ // a fresh profile insists the computer is in light mode however it is really set, so this
351
+ // is stated rather than detected.
352
+ theme: theme === 'light' || theme === 'system' ? theme : 'dark',
353
+ };
354
+ // Left out rather than guessed: with no height the panel is as tall as whatever it is
355
+ // standing next to.
356
+ if (height !== undefined) merged.height = height;
357
+ return merged;
358
+ }
359
+
360
+ /**
361
+ * @param {...unknown} values
362
+ * @returns {number|undefined}
363
+ */
364
+ function firstNumber(...values) {
365
+ for (const value of values) {
366
+ if (value === undefined || value === null || value === '') continue;
367
+ const n = Number(value);
368
+ if (Number.isFinite(n) && n > 0) return Math.round(n);
369
+ }
370
+ return undefined;
371
+ }
372
+
373
+ /**
374
+ * @param {...unknown} values
375
+ * @returns {boolean|undefined}
376
+ */
377
+ function firstBoolean(...values) {
378
+ for (const value of values) {
379
+ if (typeof value === 'boolean') return value;
380
+ }
381
+ return undefined;
382
+ }