staysfixed 0.1.0
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 +61 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/bin/staysfixed.js +18 -0
- package/examples/guards/the-sidebar-still-collapses.js +91 -0
- package/examples/staysfixed.config.electron.js +172 -0
- package/examples/staysfixed.config.web.js +277 -0
- package/package.json +61 -0
- package/src/cli/approve.js +126 -0
- package/src/cli/check.js +73 -0
- package/src/cli/doctor.js +379 -0
- package/src/cli/flake.js +61 -0
- package/src/cli/index.js +519 -0
- package/src/cli/init.js +564 -0
- package/src/cli/mark.js +69 -0
- package/src/cli/status.js +19 -0
- package/src/cli/trace.js +73 -0
- package/src/cli/walk.js +57 -0
- package/src/core/config.js +226 -0
- package/src/core/errors.js +48 -0
- package/src/core/git.js +90 -0
- package/src/core/hash.js +32 -0
- package/src/core/history.js +173 -0
- package/src/core/log.js +144 -0
- package/src/core/paths.js +135 -0
- package/src/drive/browser.js +540 -0
- package/src/drive/cdp.js +382 -0
- package/src/drive/electron.js +326 -0
- package/src/drive/find.js +331 -0
- package/src/drive/launch.js +263 -0
- package/src/drive/page.js +1042 -0
- package/src/freeze/clock.js +213 -0
- package/src/freeze/fonts.js +243 -0
- package/src/freeze/index.js +234 -0
- package/src/freeze/mask.js +187 -0
- package/src/freeze/motion.js +206 -0
- package/src/freeze/network.js +455 -0
- package/src/freeze/random.js +87 -0
- package/src/freeze/settle.js +178 -0
- package/src/guard/api.js +197 -0
- package/src/guard/load.js +324 -0
- package/src/guard/name.js +327 -0
- package/src/guard/run.js +224 -0
- package/src/index.js +61 -0
- package/src/marker/mark.js +260 -0
- package/src/marker/trace.js +293 -0
- package/src/mcp/server.js +377 -0
- package/src/mcp/tools.js +978 -0
- package/src/picture/capture.js +276 -0
- package/src/picture/compare.js +103 -0
- package/src/picture/run.js +284 -0
- package/src/picture/store.js +208 -0
- package/src/report/console.js +540 -0
- package/src/report/html.js +579 -0
- package/src/run.js +614 -0
- package/src/types.js +471 -0
- package/src/walk/run.js +541 -0
package/src/drive/cdp.js
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny Chrome DevTools Protocol client.
|
|
3
|
+
*
|
|
4
|
+
* Chrome, Edge and Electron all speak the same protocol over one WebSocket, so
|
|
5
|
+
* this is the only "driver" the tool needs — no puppeteer, no playwright.
|
|
6
|
+
* Node 22 gives us `fetch` and `WebSocket` as globals, so there is nothing to install.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { StaysFixedError } from '../core/errors.js';
|
|
10
|
+
import { warn } from '../core/log.js';
|
|
11
|
+
|
|
12
|
+
/** Trim a trailing slash so `${endpoint}/json/version` never doubles up. */
|
|
13
|
+
function tidyEndpoint(/** @type {string} */ endpoint) {
|
|
14
|
+
return String(endpoint).replace(/\/+$/, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Combine an optional caller signal with our own deadline, so a hung port
|
|
19
|
+
* cannot wedge a run forever.
|
|
20
|
+
* @param {number} timeoutMs
|
|
21
|
+
* @param {AbortSignal} [signal]
|
|
22
|
+
* @returns {AbortSignal}
|
|
23
|
+
*/
|
|
24
|
+
function deadline(timeoutMs, signal) {
|
|
25
|
+
const own = AbortSignal.timeout(timeoutMs);
|
|
26
|
+
return signal ? AbortSignal.any([own, signal]) : own;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {number} ms
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function seconds(ms) {
|
|
34
|
+
const s = Math.max(1, Math.round(ms / 1000));
|
|
35
|
+
return `${s} second${s === 1 ? '' : 's'}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ask the app which WebSocket to talk to.
|
|
40
|
+
* @param {string} endpoint e.g. "http://127.0.0.1:9333"
|
|
41
|
+
* @param {{timeoutMs?: number, signal?: AbortSignal}} [opts]
|
|
42
|
+
* @returns {Promise<{webSocketDebuggerUrl: string, Browser: string} & Record<string, any>>}
|
|
43
|
+
*/
|
|
44
|
+
export async function fetchVersion(endpoint, opts = {}) {
|
|
45
|
+
const base = tidyEndpoint(endpoint);
|
|
46
|
+
/** @type {Response} */
|
|
47
|
+
let res;
|
|
48
|
+
try {
|
|
49
|
+
res = await fetch(`${base}/json/version`, {
|
|
50
|
+
signal: deadline(opts.timeoutMs ?? 5000, opts.signal),
|
|
51
|
+
headers: { Accept: 'application/json' },
|
|
52
|
+
});
|
|
53
|
+
} catch (e) {
|
|
54
|
+
throw new StaysFixedError(`Nothing is answering at ${base}.`, {
|
|
55
|
+
hint: 'The app is either not running yet or is listening somewhere else.',
|
|
56
|
+
cause: e,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
throw new StaysFixedError(
|
|
61
|
+
`The app answered on ${base} but would not say what it is (it replied ${res.status}).`,
|
|
62
|
+
{ hint: 'Something else may be listening on that port. Pick another one with app.debugPort.' },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const body = /** @type {any} */ (await res.json());
|
|
66
|
+
if (!body || typeof body.webSocketDebuggerUrl !== 'string') {
|
|
67
|
+
throw new StaysFixedError(
|
|
68
|
+
`The app on ${base} did not offer a debug connection.`,
|
|
69
|
+
{ hint: 'Start it with --remote-debugging-port so Stays Fixed can drive it.' },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return body;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Poll until the app opens its debug port. Apps take a moment to boot; this is
|
|
77
|
+
* the difference between "flaky" and "reliable" on a cold machine.
|
|
78
|
+
* @param {string} endpoint
|
|
79
|
+
* @param {{timeoutMs?: number, intervalMs?: number, signal?: AbortSignal}} [opts]
|
|
80
|
+
* @returns {Promise<{webSocketDebuggerUrl: string, Browser: string} & Record<string, any>>}
|
|
81
|
+
*/
|
|
82
|
+
export async function waitForEndpoint(endpoint, opts = {}) {
|
|
83
|
+
const base = tidyEndpoint(endpoint);
|
|
84
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
85
|
+
const intervalMs = opts.intervalMs ?? 200;
|
|
86
|
+
const until = Date.now() + timeoutMs;
|
|
87
|
+
/** @type {unknown} */
|
|
88
|
+
let last = null;
|
|
89
|
+
|
|
90
|
+
for (;;) {
|
|
91
|
+
if (opts.signal?.aborted) {
|
|
92
|
+
throw new StaysFixedError('Stopped while waiting for the app to open its debug port.');
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return await fetchVersion(base, { timeoutMs: Math.min(3000, timeoutMs), signal: opts.signal });
|
|
96
|
+
} catch (e) {
|
|
97
|
+
last = e;
|
|
98
|
+
}
|
|
99
|
+
if (Date.now() >= until) break;
|
|
100
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
throw new StaysFixedError(
|
|
104
|
+
`The app never opened its debug port at ${base}. Stays Fixed waited ${seconds(timeoutMs)}.`,
|
|
105
|
+
{
|
|
106
|
+
hint: 'Check that the app really starts (run the start command yourself), that it is not already running on that port, and that app.startTimeoutMs is long enough.',
|
|
107
|
+
cause: last,
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* List the tabs / windows the app is showing.
|
|
114
|
+
* @param {string} endpoint
|
|
115
|
+
* @returns {Promise<Array<{id: string, type: string, title: string, url: string, webSocketDebuggerUrl?: string, attached?: boolean}>>}
|
|
116
|
+
*/
|
|
117
|
+
export async function listTargets(endpoint) {
|
|
118
|
+
const base = tidyEndpoint(endpoint);
|
|
119
|
+
/** @type {Response} */
|
|
120
|
+
let res;
|
|
121
|
+
try {
|
|
122
|
+
res = await fetch(`${base}/json/list`, {
|
|
123
|
+
signal: deadline(5000),
|
|
124
|
+
headers: { Accept: 'application/json' },
|
|
125
|
+
});
|
|
126
|
+
} catch (e) {
|
|
127
|
+
throw new StaysFixedError(`Nothing is answering at ${base}, so Stays Fixed cannot see the app's windows.`, {
|
|
128
|
+
cause: e,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
throw new StaysFixedError(
|
|
133
|
+
`Could not ask ${base} what windows it has open (it replied ${res.status}).`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const body = /** @type {any} */ (await res.json());
|
|
137
|
+
if (!Array.isArray(body)) {
|
|
138
|
+
throw new StaysFixedError(`The app on ${base} sent back a window list Stays Fixed could not read.`);
|
|
139
|
+
}
|
|
140
|
+
return body;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Turn a CDP error object into something a human can act on.
|
|
145
|
+
* @param {string} method
|
|
146
|
+
* @param {any} err
|
|
147
|
+
*/
|
|
148
|
+
function protocolError(method, err) {
|
|
149
|
+
const detail = err && typeof err === 'object' ? String(err.message ?? 'no reason given') : String(err);
|
|
150
|
+
const extra = err && typeof err === 'object' && err.data ? ` (${String(err.data)})` : '';
|
|
151
|
+
return new StaysFixedError(`The app refused the request "${method}": ${detail}${extra}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Open a connection to the app and keep it alive.
|
|
156
|
+
* @param {string} webSocketDebuggerUrl
|
|
157
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
158
|
+
* @returns {Promise<import('../types.js').CdpSession>}
|
|
159
|
+
*/
|
|
160
|
+
export async function connect(webSocketDebuggerUrl, opts = {}) {
|
|
161
|
+
const callTimeoutMs = opts.timeoutMs ?? 30_000;
|
|
162
|
+
|
|
163
|
+
/** @type {WebSocket} */
|
|
164
|
+
let ws;
|
|
165
|
+
try {
|
|
166
|
+
ws = new WebSocket(webSocketDebuggerUrl);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
throw new StaysFixedError(`Stays Fixed could not open a debug connection to the app.`, { cause: e });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** @type {Map<number, {resolve: (v: any) => void, reject: (e: unknown) => void, timer: ReturnType<typeof setTimeout>, method: string}>} */
|
|
172
|
+
const pending = new Map();
|
|
173
|
+
/** @type {Map<string, Set<(params: any, sessionId?: string) => void>>} */
|
|
174
|
+
const listeners = new Map();
|
|
175
|
+
|
|
176
|
+
let nextId = 0;
|
|
177
|
+
let shut = false;
|
|
178
|
+
let warnedAboutJunk = false;
|
|
179
|
+
|
|
180
|
+
// A socket with no error listener throws at the top level and kills the run.
|
|
181
|
+
ws.addEventListener('error', () => {});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {string} event
|
|
185
|
+
* @param {any} params
|
|
186
|
+
* @param {string|undefined} sessionId
|
|
187
|
+
*/
|
|
188
|
+
function dispatch(event, params, sessionId) {
|
|
189
|
+
const exact = listeners.get(event);
|
|
190
|
+
if (exact) {
|
|
191
|
+
for (const handler of [...exact]) {
|
|
192
|
+
try {
|
|
193
|
+
handler(params, sessionId);
|
|
194
|
+
} catch {
|
|
195
|
+
// A misbehaving listener must never take the connection down with it.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const all = listeners.get('*');
|
|
200
|
+
if (all) {
|
|
201
|
+
for (const handler of [...all]) {
|
|
202
|
+
try {
|
|
203
|
+
handler({ method: event, params, sessionId }, sessionId);
|
|
204
|
+
} catch {
|
|
205
|
+
/* same */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
ws.addEventListener('message', (/** @type {MessageEvent<any>} */ event) => {
|
|
212
|
+
// Screenshots come back as one very large text frame; parsing is the only cost.
|
|
213
|
+
const raw = typeof event.data === 'string' ? event.data : String(event.data ?? '');
|
|
214
|
+
/** @type {any} */
|
|
215
|
+
let msg;
|
|
216
|
+
try {
|
|
217
|
+
msg = JSON.parse(raw);
|
|
218
|
+
} catch {
|
|
219
|
+
if (!warnedAboutJunk) {
|
|
220
|
+
warnedAboutJunk = true;
|
|
221
|
+
warn('The app sent something Stays Fixed could not read. Ignoring it and carrying on.');
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (!msg || typeof msg !== 'object') return;
|
|
226
|
+
|
|
227
|
+
if (msg.id !== undefined && msg.id !== null) {
|
|
228
|
+
const entry = pending.get(msg.id);
|
|
229
|
+
if (!entry) return;
|
|
230
|
+
pending.delete(msg.id);
|
|
231
|
+
clearTimeout(entry.timer);
|
|
232
|
+
if (msg.error) entry.reject(protocolError(entry.method, msg.error));
|
|
233
|
+
else entry.resolve(msg.result ?? {});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (typeof msg.method === 'string') {
|
|
238
|
+
dispatch(msg.method, msg.params ?? {}, typeof msg.sessionId === 'string' ? msg.sessionId : undefined);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
ws.addEventListener('close', () => {
|
|
243
|
+
shut = true;
|
|
244
|
+
const dead = new StaysFixedError(
|
|
245
|
+
"The app's debug connection closed while Stays Fixed was still talking to it.",
|
|
246
|
+
{ hint: 'The app probably quit or crashed. Check that it stays open on its own.' },
|
|
247
|
+
);
|
|
248
|
+
for (const [id, entry] of pending) {
|
|
249
|
+
pending.delete(id);
|
|
250
|
+
clearTimeout(entry.timer);
|
|
251
|
+
entry.reject(dead);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await new Promise((resolve, reject) => {
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
done();
|
|
258
|
+
try {
|
|
259
|
+
ws.close();
|
|
260
|
+
} catch {
|
|
261
|
+
/* already gone */
|
|
262
|
+
}
|
|
263
|
+
reject(
|
|
264
|
+
new StaysFixedError('The app accepted a debug connection but never finished opening it.', {
|
|
265
|
+
hint: 'Try starting the app again, or give it longer with app.startTimeoutMs.',
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
}, callTimeoutMs);
|
|
269
|
+
|
|
270
|
+
const onOpen = () => {
|
|
271
|
+
done();
|
|
272
|
+
resolve(undefined);
|
|
273
|
+
};
|
|
274
|
+
const onFail = () => {
|
|
275
|
+
done();
|
|
276
|
+
reject(
|
|
277
|
+
new StaysFixedError('Stays Fixed could not open a debug connection to the app.', {
|
|
278
|
+
hint: 'The app may have closed the port again. Check nothing else is using it.',
|
|
279
|
+
}),
|
|
280
|
+
);
|
|
281
|
+
};
|
|
282
|
+
function done() {
|
|
283
|
+
clearTimeout(timer);
|
|
284
|
+
ws.removeEventListener('open', onOpen);
|
|
285
|
+
ws.removeEventListener('error', onFail);
|
|
286
|
+
ws.removeEventListener('close', onFail);
|
|
287
|
+
}
|
|
288
|
+
ws.addEventListener('open', onOpen);
|
|
289
|
+
ws.addEventListener('error', onFail);
|
|
290
|
+
ws.addEventListener('close', onFail);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {string} method
|
|
295
|
+
* @param {Record<string, unknown>} [params]
|
|
296
|
+
* @param {string} [sessionId]
|
|
297
|
+
* @returns {Promise<any>}
|
|
298
|
+
*/
|
|
299
|
+
function send(method, params, sessionId) {
|
|
300
|
+
if (shut || ws.readyState !== 1) {
|
|
301
|
+
return Promise.reject(
|
|
302
|
+
new StaysFixedError(
|
|
303
|
+
`Stays Fixed tried to ask the app for "${method}", but the debug connection is closed.`,
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const id = ++nextId;
|
|
308
|
+
/** @type {Record<string, unknown>} */
|
|
309
|
+
const message = { id, method, params: params ?? {} };
|
|
310
|
+
// Flat sessions: one socket, many pages, told apart only by this field.
|
|
311
|
+
if (sessionId) message.sessionId = sessionId;
|
|
312
|
+
|
|
313
|
+
return new Promise((resolve, reject) => {
|
|
314
|
+
const timer = setTimeout(() => {
|
|
315
|
+
pending.delete(id);
|
|
316
|
+
reject(
|
|
317
|
+
new StaysFixedError(`The app did not answer "${method}" within ${seconds(callTimeoutMs)}.`, {
|
|
318
|
+
hint: 'The page may be stuck on a dialog or a request that never finishes.',
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
}, callTimeoutMs);
|
|
322
|
+
pending.set(id, { resolve, reject, timer, method });
|
|
323
|
+
try {
|
|
324
|
+
ws.send(JSON.stringify(message));
|
|
325
|
+
} catch (e) {
|
|
326
|
+
pending.delete(id);
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
reject(new StaysFixedError(`Stays Fixed could not send "${method}" to the app.`, { cause: e }));
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* @param {string} event
|
|
335
|
+
* @param {(params: any, sessionId?: string) => void} handler
|
|
336
|
+
* @returns {() => void}
|
|
337
|
+
*/
|
|
338
|
+
function on(event, handler) {
|
|
339
|
+
let set = listeners.get(event);
|
|
340
|
+
if (!set) {
|
|
341
|
+
set = new Set();
|
|
342
|
+
listeners.set(event, set);
|
|
343
|
+
}
|
|
344
|
+
set.add(handler);
|
|
345
|
+
return () => {
|
|
346
|
+
const current = listeners.get(event);
|
|
347
|
+
if (!current) return;
|
|
348
|
+
current.delete(handler);
|
|
349
|
+
if (current.size === 0) listeners.delete(event);
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** @returns {Promise<void>} */
|
|
354
|
+
function close() {
|
|
355
|
+
if (shut || ws.readyState === 3) {
|
|
356
|
+
shut = true;
|
|
357
|
+
return Promise.resolve();
|
|
358
|
+
}
|
|
359
|
+
return new Promise((resolve) => {
|
|
360
|
+
const finish = () => {
|
|
361
|
+
clearTimeout(timer);
|
|
362
|
+
ws.removeEventListener('close', finish);
|
|
363
|
+
shut = true;
|
|
364
|
+
resolve();
|
|
365
|
+
};
|
|
366
|
+
// Never hang a run on a socket that refuses to say goodbye.
|
|
367
|
+
const timer = setTimeout(finish, 2000);
|
|
368
|
+
ws.addEventListener('close', finish);
|
|
369
|
+
try {
|
|
370
|
+
ws.close();
|
|
371
|
+
} catch {
|
|
372
|
+
finish();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function isOpen() {
|
|
378
|
+
return !shut && ws.readyState === 1;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return { send, on, close, isOpen };
|
|
382
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A real desktop app, opened and driven the same way a browser is.
|
|
3
|
+
*
|
|
4
|
+
* Electron is Chrome underneath, so the same debugging connection works — but
|
|
5
|
+
* two things are different and both bite. An Electron app ignores most of
|
|
6
|
+
* Chrome's command line, and an Electron app can have several windows, only one
|
|
7
|
+
* of which is the one a person means.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn, execFile } from 'node:child_process';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
import fsp from 'node:fs/promises';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { StaysFixedError, isExpected } from '../core/errors.js';
|
|
17
|
+
import { detail } from '../core/log.js';
|
|
18
|
+
import { DEFAULT_VIEWPORT } from '../core/config.js';
|
|
19
|
+
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
21
|
+
import { waitForEndpoint, listTargets, connect } from './cdp.js';
|
|
22
|
+
import { resolveElectronBinary, freePort } from './find.js';
|
|
23
|
+
import { createPage } from './page.js';
|
|
24
|
+
import { childEnv, delay, keepOutput, stopProcess } from './browser.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The flags Electron actually honours at launch. The rest of Chrome's
|
|
28
|
+
* determinism switches are set from inside the page by the freeze layer.
|
|
29
|
+
* @param {Required<import('../types.js').ViewportConfig>} viewport
|
|
30
|
+
* @returns {string[]}
|
|
31
|
+
*/
|
|
32
|
+
function electronRenderingArgs(viewport) {
|
|
33
|
+
return [
|
|
34
|
+
// Same pixel grid every run.
|
|
35
|
+
`--force-device-scale-factor=${viewport.deviceScaleFactor}`,
|
|
36
|
+
'--force-color-profile=srgb',
|
|
37
|
+
'--disable-lcd-text',
|
|
38
|
+
'--font-render-hinting=none',
|
|
39
|
+
// No animation is half-finished at the moment of the shutter.
|
|
40
|
+
'--force-prefers-reduced-motion',
|
|
41
|
+
// A window that is behind another window gets slowed down, which turns
|
|
42
|
+
// "wait for the list" into a random timeout.
|
|
43
|
+
'--disable-background-timer-throttling',
|
|
44
|
+
'--disable-renderer-backgrounding',
|
|
45
|
+
'--disable-backgrounding-occluded-windows',
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {any[]} windows
|
|
51
|
+
* @returns {string|undefined}
|
|
52
|
+
*/
|
|
53
|
+
function describeWindows(windows) {
|
|
54
|
+
if (windows.length === 0) return undefined;
|
|
55
|
+
const lines = windows.map((w) => ` ${String(w.title ?? '(no title)')} — ${String(w.url ?? '')}`);
|
|
56
|
+
return `Windows the app has open right now:\n${lines.join('\n')}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pick the window to drive.
|
|
61
|
+
* @param {string} endpoint
|
|
62
|
+
* @param {string|undefined} match
|
|
63
|
+
* @param {number} deadline epoch ms to give up at
|
|
64
|
+
* @returns {Promise<any>}
|
|
65
|
+
*/
|
|
66
|
+
async function findWindow(endpoint, match, deadline) {
|
|
67
|
+
/** @type {any[]} */
|
|
68
|
+
let pages = [];
|
|
69
|
+
for (;;) {
|
|
70
|
+
const targets = /** @type {any[]} */ (await listTargets(endpoint));
|
|
71
|
+
pages = targets.filter((t) => t && t.type === 'page');
|
|
72
|
+
if (match) {
|
|
73
|
+
const hit = pages.find(
|
|
74
|
+
(t) => String(t.title ?? '').includes(match) || String(t.url ?? '').includes(match),
|
|
75
|
+
);
|
|
76
|
+
if (hit) return hit;
|
|
77
|
+
} else {
|
|
78
|
+
const real = pages.find((t) => {
|
|
79
|
+
const url = String(t.url ?? '');
|
|
80
|
+
return !url.startsWith('devtools://') && url !== 'about:blank' && url !== '';
|
|
81
|
+
});
|
|
82
|
+
if (real) return real;
|
|
83
|
+
}
|
|
84
|
+
if (Date.now() > deadline) break;
|
|
85
|
+
// An Electron window is blank for the first moment of its life. Asking
|
|
86
|
+
// again a beat later is the difference between driving the app and
|
|
87
|
+
// photographing an empty rectangle.
|
|
88
|
+
await delay(250);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!match) {
|
|
92
|
+
// It stayed blank the whole time. Drive it anyway — an app whose window is
|
|
93
|
+
// genuinely empty is exactly the kind of thing a picture check should catch.
|
|
94
|
+
const blank = pages.find((t) => !String(t.url ?? '').startsWith('devtools://'));
|
|
95
|
+
if (blank) return blank;
|
|
96
|
+
throw new StaysFixedError('The app started but never opened a window to look at.', {
|
|
97
|
+
hint: 'If it shows a splash window first, give it longer with `app.startTimeoutMs`.',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
throw new StaysFixedError(`The app has no window matching "${match}".`, {
|
|
101
|
+
hint: describeWindows(pages) ?? 'Check `app.windowMatch` against the real window title.',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Start a desktop app and attach to its main window.
|
|
107
|
+
* @param {import('../types.js').AppConfig} app
|
|
108
|
+
* @param {import('./browser.js').LaunchOptions} [opts]
|
|
109
|
+
* @returns {Promise<import('../types.js').LaunchedApp>}
|
|
110
|
+
*/
|
|
111
|
+
export async function launchElectron(app, opts = {}) {
|
|
112
|
+
if (opts.signal?.aborted) {
|
|
113
|
+
throw new StaysFixedError('Stopped before the app could start.');
|
|
114
|
+
}
|
|
115
|
+
if (!app.binary) {
|
|
116
|
+
throw new StaysFixedError('This app has no `app.binary`, so there is nothing to open.', {
|
|
117
|
+
hint: 'On macOS point it inside the bundle: /Applications/Your App.app/Contents/MacOS/Your App',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const binary = await resolveElectronBinary(app.binary);
|
|
122
|
+
const port = await freePort(app.debugPort);
|
|
123
|
+
const viewport = { ...DEFAULT_VIEWPORT, ...(opts.viewport ?? {}) };
|
|
124
|
+
const startTimeoutMs = app.startTimeoutMs ?? 60_000;
|
|
125
|
+
const extraArgs = app.args ?? [];
|
|
126
|
+
|
|
127
|
+
// Never point a real app at a real person's data. If the caller or the config
|
|
128
|
+
// already chose a profile we respect it; otherwise we make a scratch one and
|
|
129
|
+
// delete it afterwards.
|
|
130
|
+
const configured = extraArgs.some((a) => String(a).startsWith('--user-data-dir'));
|
|
131
|
+
const ownProfile = !opts.userDataDir && !configured;
|
|
132
|
+
const profileDir = opts.userDataDir ?? (ownProfile ? await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-app-')) : null);
|
|
133
|
+
|
|
134
|
+
/** @type {string[]} */
|
|
135
|
+
const args = [`--remote-debugging-port=${port}`, '--remote-allow-origins=*'];
|
|
136
|
+
if (profileDir) args.push(`--user-data-dir=${profileDir}`);
|
|
137
|
+
args.push(...electronRenderingArgs(viewport), ...extraArgs);
|
|
138
|
+
|
|
139
|
+
detail(`app: ${binary}`);
|
|
140
|
+
detail(`debugging port: ${port}`);
|
|
141
|
+
|
|
142
|
+
// Remember who had the screen, so we can give it straight back.
|
|
143
|
+
//
|
|
144
|
+
// Opening a desktop app takes the foreground, and a check that runs every few minutes
|
|
145
|
+
// takes it every few minutes — it steals the window out from under whatever the person
|
|
146
|
+
// was actually doing. The app still has to really open (it is being photographed, not
|
|
147
|
+
// simulated), but it does not have to be in front: the flags below keep it painting
|
|
148
|
+
// while it is behind another window, so the pictures are the same either way.
|
|
149
|
+
//
|
|
150
|
+
// Set `app.foreground: true` to watch it work instead.
|
|
151
|
+
const wantsFront = /** @type {any} */ (app).foreground === true;
|
|
152
|
+
const previousApp = wantsFront ? null : await frontmostApp();
|
|
153
|
+
|
|
154
|
+
const child = spawn(binary, args, {
|
|
155
|
+
cwd: app.cwd,
|
|
156
|
+
env: childEnv(app, opts),
|
|
157
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
158
|
+
signal: opts.signal,
|
|
159
|
+
});
|
|
160
|
+
const output = keepOutput(child);
|
|
161
|
+
|
|
162
|
+
const diedEarly = /** @type {Promise<never>} */ (
|
|
163
|
+
new Promise((_resolve, reject) => {
|
|
164
|
+
child.once('error', (cause) => {
|
|
165
|
+
reject(
|
|
166
|
+
new StaysFixedError(`Could not run the app at ${binary}.`, {
|
|
167
|
+
hint: 'Check `app.binary` points at the executable, not the folder.',
|
|
168
|
+
cause,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
child.once('exit', (code, signal) => {
|
|
173
|
+
reject(
|
|
174
|
+
new StaysFixedError(
|
|
175
|
+
`The app quit before it was ready (${signal ? `signal ${signal}` : `exit code ${code}`}).`,
|
|
176
|
+
{ hint: output() ? `The last thing it said:\n${output()}` : undefined },
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
// A normal quit later on must not crash the CLI with an unhandled rejection.
|
|
183
|
+
diedEarly.catch(() => {});
|
|
184
|
+
|
|
185
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
186
|
+
/** @type {import('../types.js').CdpSession|null} */
|
|
187
|
+
let session = null;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
/** @type {any} */
|
|
191
|
+
let version;
|
|
192
|
+
try {
|
|
193
|
+
version = await Promise.race([
|
|
194
|
+
waitForEndpoint(endpoint, { timeoutMs: startTimeoutMs, intervalMs: 200 }),
|
|
195
|
+
diedEarly,
|
|
196
|
+
]);
|
|
197
|
+
} catch (cause) {
|
|
198
|
+
if (isExpected(cause)) throw cause;
|
|
199
|
+
throw new StaysFixedError('The app started but never opened its debugging connection.', {
|
|
200
|
+
hint: output()
|
|
201
|
+
? `The last thing it said:\n${output()}`
|
|
202
|
+
: 'Some apps only allow this in a development build. Check it starts with --remote-debugging-port by hand.',
|
|
203
|
+
cause,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const wsUrl = version?.webSocketDebuggerUrl;
|
|
208
|
+
if (!wsUrl) {
|
|
209
|
+
throw new StaysFixedError('The app answered but did not offer a debugging connection.');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const cdp = /** @type {import('../types.js').CdpSession} */ (await connect(wsUrl, { timeoutMs: 15_000 }));
|
|
213
|
+
session = cdp;
|
|
214
|
+
|
|
215
|
+
const chosen = await findWindow(endpoint, app.windowMatch, Date.now() + startTimeoutMs);
|
|
216
|
+
const targetId = String(chosen.id ?? chosen.targetId);
|
|
217
|
+
detail(`window: ${String(chosen.title ?? '(no title)')} — ${String(chosen.url ?? '')}`);
|
|
218
|
+
|
|
219
|
+
const attached = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
220
|
+
const sessionId = String(attached.sessionId);
|
|
221
|
+
const page = await createPage(
|
|
222
|
+
cdp,
|
|
223
|
+
/** @type {any} */ ({ sessionId, targetId, baseUrl: app.url ?? null, timeoutMs: startTimeoutMs }),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
/** @type {Promise<void>|null} */
|
|
227
|
+
let closing = null;
|
|
228
|
+
const close = () => {
|
|
229
|
+
// Safe to call twice: the second caller waits on the first close.
|
|
230
|
+
closing ??= (async () => {
|
|
231
|
+
try {
|
|
232
|
+
await cdp.send('Target.detachFromTarget', { sessionId });
|
|
233
|
+
} catch {
|
|
234
|
+
// The window may already be gone, which is where we were heading.
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
await cdp.close();
|
|
238
|
+
} catch {
|
|
239
|
+
// Hanging up cannot meaningfully fail.
|
|
240
|
+
}
|
|
241
|
+
// A desktop app can take a moment to save its state on the way out, so
|
|
242
|
+
// it gets longer than a browser does before we insist.
|
|
243
|
+
await stopProcess(child, 5000);
|
|
244
|
+
if (ownProfile && profileDir) {
|
|
245
|
+
await fsp.rm(profileDir, { recursive: true, force: true }).catch(() => {});
|
|
246
|
+
}
|
|
247
|
+
})();
|
|
248
|
+
return closing;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// The window is up and answering; hand the screen back to whoever had it.
|
|
252
|
+
if (previousApp) await giveFocusBack(previousApp);
|
|
253
|
+
|
|
254
|
+
return { cdp, page, close, endpoint, pid: child.pid ?? null, kind: 'electron' };
|
|
255
|
+
} catch (e) {
|
|
256
|
+
// Half-launched means a leaked app window sitting on somebody's screen.
|
|
257
|
+
if (session) await session.close().catch(() => {});
|
|
258
|
+
await stopProcess(child, 2000);
|
|
259
|
+
if (ownProfile && profileDir) await fsp.rm(profileDir, { recursive: true, force: true }).catch(() => {});
|
|
260
|
+
throw e;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Every window the app currently has open. `staysfixed doctor` prints this so a
|
|
266
|
+
* person can see which one a check will drive, and what to put in `windowMatch`.
|
|
267
|
+
* @param {string} endpoint
|
|
268
|
+
* @returns {Promise<{id: string, title: string, url: string, type: string}[]>}
|
|
269
|
+
*/
|
|
270
|
+
export async function listElectronWindows(endpoint) {
|
|
271
|
+
const targets = /** @type {any[]} */ (await listTargets(endpoint));
|
|
272
|
+
const windows = targets.map((t) => ({
|
|
273
|
+
id: String(t?.id ?? t?.targetId ?? ''),
|
|
274
|
+
title: String(t?.title ?? ''),
|
|
275
|
+
url: String(t?.url ?? ''),
|
|
276
|
+
type: String(t?.type ?? ''),
|
|
277
|
+
}));
|
|
278
|
+
// Real windows first. An app also reports service workers and popups, and the
|
|
279
|
+
// first row a person reads should be the one a check would actually drive.
|
|
280
|
+
return windows.sort((a, b) => Number(b.type === 'page') - Number(a.type === 'page'));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The name of the application currently in front, on macOS. Null anywhere else.
|
|
285
|
+
*
|
|
286
|
+
* @returns {Promise<string|null>}
|
|
287
|
+
*/
|
|
288
|
+
async function frontmostApp() {
|
|
289
|
+
if (process.platform !== 'darwin') return null;
|
|
290
|
+
try {
|
|
291
|
+
const { stdout } = await execFileAsync(
|
|
292
|
+
'osascript',
|
|
293
|
+
['-e', 'tell application "System Events" to get name of first application process whose frontmost is true'],
|
|
294
|
+
{ timeout: 4000 },
|
|
295
|
+
);
|
|
296
|
+
const name = stdout.trim();
|
|
297
|
+
return name.length > 0 ? name : null;
|
|
298
|
+
} catch {
|
|
299
|
+
// No Apple Events permission, or no window server at all (CI). Not worth a word.
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Put the screen back where it was before we opened anything.
|
|
306
|
+
*
|
|
307
|
+
* The app carries on running and carries on being photographed — the rendering flags it
|
|
308
|
+
* was launched with keep it painting while it sits behind another window. Bring it to the
|
|
309
|
+
* front yourself any time you want to watch it work.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} name
|
|
312
|
+
* @returns {Promise<void>}
|
|
313
|
+
*/
|
|
314
|
+
async function giveFocusBack(name) {
|
|
315
|
+
if (process.platform !== 'darwin') return;
|
|
316
|
+
try {
|
|
317
|
+
await execFileAsync(
|
|
318
|
+
'osascript',
|
|
319
|
+
['-e', `tell application "System Events" to set frontmost of first application process whose name is ${JSON.stringify(name)} to true`],
|
|
320
|
+
{ timeout: 4000 },
|
|
321
|
+
);
|
|
322
|
+
detail(`left the screen with ${name}; the app is running behind it`);
|
|
323
|
+
} catch {
|
|
324
|
+
// Worst case the app keeps the foreground. Never a reason to fail a run.
|
|
325
|
+
}
|
|
326
|
+
}
|