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,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
|
+
}
|