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
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page control — the surface every screen recipe and every guard is handed.
|
|
3
|
+
*
|
|
4
|
+
* It sits on one attached target (a browser tab or an Electron window) and turns
|
|
5
|
+
* "click the save button" into real input events, real waits and real pictures.
|
|
6
|
+
*
|
|
7
|
+
* Two habits run through the whole file, and both exist for determinism:
|
|
8
|
+
* - every wait computes its deadline once, then polls; a naive loop that adds
|
|
9
|
+
* a timeout per iteration drifts and makes the same check take a different
|
|
10
|
+
* amount of time on a slow machine.
|
|
11
|
+
* - input is dispatched as real browser events, never as `element.click()`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Buffer } from 'node:buffer';
|
|
15
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
16
|
+
import { StaysFixedError } from '../core/errors.js';
|
|
17
|
+
import { detail } from '../core/log.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Keys `press()` understands by name. Anything else is sent as literal text.
|
|
21
|
+
* @type {Record<string, {key: string, code: string, keyCode: number, text?: string}>}
|
|
22
|
+
*/
|
|
23
|
+
export const KEY_CODES = {
|
|
24
|
+
Enter: { key: 'Enter', code: 'Enter', keyCode: 13, text: '\r' },
|
|
25
|
+
Escape: { key: 'Escape', code: 'Escape', keyCode: 27 },
|
|
26
|
+
Tab: { key: 'Tab', code: 'Tab', keyCode: 9, text: '\t' },
|
|
27
|
+
Backspace: { key: 'Backspace', code: 'Backspace', keyCode: 8 },
|
|
28
|
+
Delete: { key: 'Delete', code: 'Delete', keyCode: 46 },
|
|
29
|
+
ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
|
|
30
|
+
ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
|
|
31
|
+
ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
|
|
32
|
+
ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
|
|
33
|
+
Home: { key: 'Home', code: 'Home', keyCode: 36 },
|
|
34
|
+
End: { key: 'End', code: 'End', keyCode: 35 },
|
|
35
|
+
PageUp: { key: 'PageUp', code: 'PageUp', keyCode: 33 },
|
|
36
|
+
PageDown: { key: 'PageDown', code: 'PageDown', keyCode: 34 },
|
|
37
|
+
Space: { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
|
|
38
|
+
Shift: { key: 'Shift', code: 'ShiftLeft', keyCode: 16 },
|
|
39
|
+
Control: { key: 'Control', code: 'ControlLeft', keyCode: 17 },
|
|
40
|
+
Alt: { key: 'Alt', code: 'AltLeft', keyCode: 18 },
|
|
41
|
+
Meta: { key: 'Meta', code: 'MetaLeft', keyCode: 91 },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Domains we try to turn on. A window that refuses one still works for the rest. */
|
|
45
|
+
const DOMAINS = ['Page', 'Runtime', 'DOM', 'CSS', 'Log', 'Console'];
|
|
46
|
+
|
|
47
|
+
/** Most recorded errors worth reading. Past this the page is broken, not subtly wrong. */
|
|
48
|
+
const MAX_CONSOLE_ERRORS = 50;
|
|
49
|
+
|
|
50
|
+
/** Chrome cannot paint a picture wider or taller than this. */
|
|
51
|
+
const MAX_CAPTURE_SIDE = 16384;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Seconds, written the way a person says them: 15, 1.5, 0.5.
|
|
55
|
+
* @param {number} ms
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
function secs(ms) {
|
|
59
|
+
return String(Math.round(ms / 100) / 10);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} selector
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function q(selector) {
|
|
67
|
+
return JSON.stringify(selector);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} selector
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
function visibleSource(selector) {
|
|
75
|
+
return (
|
|
76
|
+
'(function(){var el=document.querySelector(' +
|
|
77
|
+
q(selector) +
|
|
78
|
+
');if(!el)return false;' +
|
|
79
|
+
'var r=el.getBoundingClientRect();if(r.width<=0||r.height<=0)return false;' +
|
|
80
|
+
'var node=el;' +
|
|
81
|
+
'while(node&&node.nodeType===1){' +
|
|
82
|
+
'var s=window.getComputedStyle(node);' +
|
|
83
|
+
'if(s.display==="none"||s.visibility==="hidden"||s.visibility==="collapse")return false;' +
|
|
84
|
+
'if(parseFloat(s.opacity||"1")===0)return false;' +
|
|
85
|
+
'node=node.parentElement;}' +
|
|
86
|
+
'return true;})()'
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} selector
|
|
92
|
+
* @param {boolean} scroll Scroll it to the middle of the window first.
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
function boxSource(selector, scroll) {
|
|
96
|
+
return (
|
|
97
|
+
'(function(){var el=document.querySelector(' +
|
|
98
|
+
q(selector) +
|
|
99
|
+
');if(!el)return null;' +
|
|
100
|
+
(scroll ? 'el.scrollIntoView({block:"center",inline:"center",behavior:"instant"});' : '') +
|
|
101
|
+
'var r=el.getBoundingClientRect();' +
|
|
102
|
+
'return {x:r.x,y:r.y,width:r.width,height:r.height};})()'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A `<style>` tag that adds itself once, whenever the document is ready enough
|
|
108
|
+
* to hold it. Used both for the immediate injection and as the init script that
|
|
109
|
+
* re-applies it after a navigation.
|
|
110
|
+
* @param {string} css
|
|
111
|
+
* @param {string} token The element id, so the script is safe to run twice.
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function styleTagSource(css, token) {
|
|
115
|
+
return (
|
|
116
|
+
'(function(){var css=' +
|
|
117
|
+
JSON.stringify(css) +
|
|
118
|
+
';var id=' +
|
|
119
|
+
JSON.stringify(token) +
|
|
120
|
+
';function add(){' +
|
|
121
|
+
'if(document.getElementById(id))return;' +
|
|
122
|
+
'var root=document.head||document.documentElement;if(!root)return;' +
|
|
123
|
+
'var el=document.createElement("style");el.id=id;el.textContent=css;root.appendChild(el);}' +
|
|
124
|
+
'add();document.addEventListener("DOMContentLoaded",add);})();'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {string} token
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
function removeStyleTagSource(token) {
|
|
133
|
+
return (
|
|
134
|
+
'(function(){var el=document.getElementById(' +
|
|
135
|
+
JSON.stringify(token) +
|
|
136
|
+
');if(el&&el.parentNode)el.parentNode.removeChild(el);return true;})()'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* One line describing whatever the page threw.
|
|
142
|
+
* @param {any} details Runtime.ExceptionDetails
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
function describeException(details) {
|
|
146
|
+
if (!details) return 'Unknown error';
|
|
147
|
+
const ex = details.exception;
|
|
148
|
+
let text = ex && (ex.description ?? ex.value);
|
|
149
|
+
if (text === undefined || text === null) text = details.text;
|
|
150
|
+
if (text === undefined || text === null) text = 'Unknown error';
|
|
151
|
+
return String(text).split('\n')[0].trim() || 'Unknown error';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One console argument, readable.
|
|
156
|
+
* @param {any} arg Runtime.RemoteObject
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
function describeArg(arg) {
|
|
160
|
+
if (!arg) return '';
|
|
161
|
+
if (arg.value !== undefined) {
|
|
162
|
+
if (typeof arg.value === 'string') return arg.value;
|
|
163
|
+
const json = JSON.stringify(arg.value);
|
|
164
|
+
return typeof json === 'string' ? json : String(arg.value);
|
|
165
|
+
}
|
|
166
|
+
if (arg.unserializableValue !== undefined) return String(arg.unserializableValue);
|
|
167
|
+
if (arg.description !== undefined) return String(arg.description).split('\n')[0];
|
|
168
|
+
return String(arg.type ?? '');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build the page surface for one already-attached target.
|
|
173
|
+
*
|
|
174
|
+
* @param {import('../types.js').CdpSession} cdp
|
|
175
|
+
* @param {{sessionId: string, targetId: string, baseUrl?: string|null, timeoutMs?: number}} opts
|
|
176
|
+
* @returns {Promise<import('../types.js').PageHandle>}
|
|
177
|
+
*/
|
|
178
|
+
export async function createPage(cdp, opts) {
|
|
179
|
+
const sessionId = opts.sessionId;
|
|
180
|
+
const targetId = opts.targetId;
|
|
181
|
+
const baseUrl = opts.baseUrl ?? null;
|
|
182
|
+
const defaultTimeout = opts.timeoutMs ?? 15000;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {string} method
|
|
186
|
+
* @param {Record<string, unknown>} [params]
|
|
187
|
+
* @returns {Promise<any>}
|
|
188
|
+
*/
|
|
189
|
+
function send(method, params) {
|
|
190
|
+
return cdp.send(method, params, sessionId);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Events arrive for every attached target on one connection, so filter by
|
|
195
|
+
* session. A connection opened straight at a page's own websocket sends no
|
|
196
|
+
* session id at all — those events are ours too.
|
|
197
|
+
* @param {string} event
|
|
198
|
+
* @param {(params: any) => void} handler
|
|
199
|
+
* @returns {() => void}
|
|
200
|
+
*/
|
|
201
|
+
function on(event, handler) {
|
|
202
|
+
return cdp.on(event, (params, sid) => {
|
|
203
|
+
if (sid === undefined || sid === sessionId) handler(params);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const domain of DOMAINS) {
|
|
208
|
+
try {
|
|
209
|
+
await send(`${domain}.enable`);
|
|
210
|
+
} catch {
|
|
211
|
+
// Some Electron windows and service-worker-ish targets simply do not have
|
|
212
|
+
// every domain. Losing CSS or Log costs a nicety, not the run.
|
|
213
|
+
detail(`This window does not support ${domain}; carrying on without it.`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Console capture — a screen can look perfect and still be on fire.
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/** @type {string[]} */
|
|
222
|
+
const errors = [];
|
|
223
|
+
|
|
224
|
+
/** @param {string} text */
|
|
225
|
+
function record(text) {
|
|
226
|
+
const line = String(text).trim();
|
|
227
|
+
if (!line) return;
|
|
228
|
+
if (errors.includes(line)) return;
|
|
229
|
+
// Keep the first fifty: the earliest error is usually the cause of the rest.
|
|
230
|
+
if (errors.length >= MAX_CONSOLE_ERRORS) return;
|
|
231
|
+
errors.push(line);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
on('Runtime.consoleAPICalled', (params) => {
|
|
235
|
+
const type = String(params?.type ?? '');
|
|
236
|
+
if (type !== 'error' && type !== 'assert' && type !== 'warning') return;
|
|
237
|
+
const args = Array.isArray(params?.args) ? params.args : [];
|
|
238
|
+
const text = args.map(describeArg).filter(Boolean).join(' ');
|
|
239
|
+
if (type === 'warning') {
|
|
240
|
+
detail(`The page warned: ${text}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
record(text);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
on('Log.entryAdded', (params) => {
|
|
247
|
+
const entry = params?.entry;
|
|
248
|
+
if (!entry) return;
|
|
249
|
+
if (entry.level !== 'error') {
|
|
250
|
+
if (entry.level === 'warning') detail(`The page warned: ${entry.text}`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
record(entry.url ? `${entry.text} (${entry.url})` : String(entry.text ?? ''));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
on('Runtime.exceptionThrown', (params) => {
|
|
257
|
+
record(describeException(params?.exceptionDetails));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
function consoleErrors() {
|
|
261
|
+
return errors.slice();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function clearConsole() {
|
|
265
|
+
errors.length = 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Running JavaScript in the page
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {string} js
|
|
274
|
+
* @returns {Promise<any>}
|
|
275
|
+
*/
|
|
276
|
+
async function evaluate(js) {
|
|
277
|
+
const res = await send('Runtime.evaluate', {
|
|
278
|
+
expression: js,
|
|
279
|
+
awaitPromise: true,
|
|
280
|
+
returnByValue: true,
|
|
281
|
+
userGesture: true,
|
|
282
|
+
});
|
|
283
|
+
if (res?.exceptionDetails) {
|
|
284
|
+
throw new StaysFixedError(`The page threw an error: ${describeException(res.exceptionDetails)}`, {
|
|
285
|
+
hint: 'This came from the app itself, not from Stays Fixed. Open the same screen by hand and look at the browser console.',
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return res?.result?.value;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* document.readyState, tolerant of being asked mid-navigation (the old
|
|
293
|
+
* execution context is thrown away and the call simply fails).
|
|
294
|
+
* @returns {Promise<string>}
|
|
295
|
+
*/
|
|
296
|
+
async function readyState() {
|
|
297
|
+
try {
|
|
298
|
+
return String(await evaluate('document.readyState'));
|
|
299
|
+
} catch {
|
|
300
|
+
return '';
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Looking at the page
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* @param {string} selector
|
|
310
|
+
* @returns {Promise<boolean>}
|
|
311
|
+
*/
|
|
312
|
+
async function visible(selector) {
|
|
313
|
+
return Boolean(await evaluate(visibleSource(selector)));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {string} selector
|
|
318
|
+
* @returns {Promise<boolean>}
|
|
319
|
+
*/
|
|
320
|
+
async function exists(selector) {
|
|
321
|
+
return Boolean(await evaluate(`Boolean(document.querySelector(${q(selector)}))`));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param {string} selector
|
|
326
|
+
* @returns {Promise<string>}
|
|
327
|
+
*/
|
|
328
|
+
async function textOf(selector) {
|
|
329
|
+
const value = await evaluate(
|
|
330
|
+
'(function(){var el=document.querySelector(' +
|
|
331
|
+
q(selector) +
|
|
332
|
+
');if(!el)return "";' +
|
|
333
|
+
'var t=el.innerText;if(typeof t!=="string")t=el.textContent||"";return t.trim();})()',
|
|
334
|
+
);
|
|
335
|
+
return typeof value === 'string' ? value : '';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @param {string} selector
|
|
340
|
+
* @returns {Promise<number>}
|
|
341
|
+
*/
|
|
342
|
+
async function count(selector) {
|
|
343
|
+
const n = await evaluate(`document.querySelectorAll(${q(selector)}).length`);
|
|
344
|
+
return typeof n === 'number' ? n : 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* @param {string} selector
|
|
349
|
+
* @param {boolean} [scroll]
|
|
350
|
+
* @returns {Promise<import('../types.js').MaskRect|null>}
|
|
351
|
+
*/
|
|
352
|
+
async function readBox(selector, scroll = false) {
|
|
353
|
+
const box = await evaluate(boxSource(selector, scroll));
|
|
354
|
+
if (!box || typeof box !== 'object') return null;
|
|
355
|
+
return /** @type {import('../types.js').MaskRect} */ ({
|
|
356
|
+
x: Number(box.x),
|
|
357
|
+
y: Number(box.y),
|
|
358
|
+
width: Number(box.width),
|
|
359
|
+
height: Number(box.height),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* @param {string} selector
|
|
365
|
+
* @returns {Promise<import('../types.js').MaskRect|null>}
|
|
366
|
+
*/
|
|
367
|
+
function boxOf(selector) {
|
|
368
|
+
return readBox(selector, false);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** @returns {Promise<string>} */
|
|
372
|
+
async function url() {
|
|
373
|
+
const href = await evaluate('String(window.location.href)');
|
|
374
|
+
return typeof href === 'string' ? href : '';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** @returns {Promise<string>} */
|
|
378
|
+
async function title() {
|
|
379
|
+
const t = await evaluate('String(document.title)');
|
|
380
|
+
return typeof t === 'string' ? t : '';
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Waiting
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* @param {string} selector
|
|
389
|
+
* @param {{timeoutMs?: number}} [o]
|
|
390
|
+
* @returns {Promise<void>}
|
|
391
|
+
*/
|
|
392
|
+
async function waitFor(selector, o) {
|
|
393
|
+
const timeoutMs = o?.timeoutMs ?? defaultTimeout;
|
|
394
|
+
const deadline = Date.now() + timeoutMs;
|
|
395
|
+
for (;;) {
|
|
396
|
+
if (await visible(selector)) return;
|
|
397
|
+
if (Date.now() >= deadline) {
|
|
398
|
+
throw new StaysFixedError(`Waited ${secs(timeoutMs)}s for "${selector}" and it never appeared.`, {
|
|
399
|
+
hint: 'Either the app did not get that far, or the selector no longer matches anything on the screen.',
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
await sleep(50);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @param {string} selector
|
|
408
|
+
* @param {{timeoutMs?: number}} [o]
|
|
409
|
+
* @returns {Promise<void>}
|
|
410
|
+
*/
|
|
411
|
+
async function waitForGone(selector, o) {
|
|
412
|
+
const timeoutMs = o?.timeoutMs ?? defaultTimeout;
|
|
413
|
+
const deadline = Date.now() + timeoutMs;
|
|
414
|
+
for (;;) {
|
|
415
|
+
if (!(await visible(selector))) return;
|
|
416
|
+
if (Date.now() >= deadline) {
|
|
417
|
+
throw new StaysFixedError(`Waited ${secs(timeoutMs)}s for "${selector}" to go away and it is still on screen.`, {
|
|
418
|
+
hint: 'Whatever was meant to close it — a dialog closing, a spinner finishing — did not happen.',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
await sleep(50);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @param {number} ms
|
|
427
|
+
* @returns {Promise<void>}
|
|
428
|
+
*/
|
|
429
|
+
async function wait(ms) {
|
|
430
|
+
await sleep(Math.max(0, Number(ms) || 0));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Going places
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* @param {string} target
|
|
439
|
+
* @returns {Promise<void>}
|
|
440
|
+
*/
|
|
441
|
+
async function goto(target) {
|
|
442
|
+
let resolved;
|
|
443
|
+
try {
|
|
444
|
+
resolved = baseUrl ? new URL(target, baseUrl).href : new URL(target).href;
|
|
445
|
+
} catch {
|
|
446
|
+
throw new StaysFixedError(`I cannot open "${target}" because it is not a full address.`, {
|
|
447
|
+
hint: 'Either write the whole address, or set the app address in your config so short paths like "/settings" have something to hang off.',
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let loaded = false;
|
|
452
|
+
// Listen before navigating: the load event can beat the navigate reply back.
|
|
453
|
+
const offLoad = on('Page.loadEventFired', () => {
|
|
454
|
+
loaded = true;
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const res = await send('Page.navigate', { url: resolved });
|
|
459
|
+
if (res?.errorText) {
|
|
460
|
+
throw new StaysFixedError(`The app could not open ${resolved}: ${res.errorText}.`, {
|
|
461
|
+
hint: 'Check the app is actually running and serving that address.',
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// No loaderId means the page moved inside itself — a hash change or a
|
|
466
|
+
// single-page route. No load event will ever fire for that.
|
|
467
|
+
const sameDocument = !res?.loaderId;
|
|
468
|
+
const deadline = Date.now() + defaultTimeout;
|
|
469
|
+
const patienceForMissedLoad = Date.now() + 1000;
|
|
470
|
+
|
|
471
|
+
for (;;) {
|
|
472
|
+
if (loaded) break;
|
|
473
|
+
const state = await readyState();
|
|
474
|
+
if (state === 'complete' && (sameDocument || Date.now() >= patienceForMissedLoad)) break;
|
|
475
|
+
if (Date.now() >= deadline) {
|
|
476
|
+
throw new StaysFixedError(`Waited ${secs(defaultTimeout)}s for ${resolved} to finish loading and it never did.`, {
|
|
477
|
+
hint: 'Something on the page is still working — a request that never answers, or a script that never finishes.',
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
await sleep(50);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// The load event can fire while the document is still settling; the real
|
|
484
|
+
// finish line is readyState.
|
|
485
|
+
while ((await readyState()) !== 'complete') {
|
|
486
|
+
if (Date.now() >= deadline) {
|
|
487
|
+
throw new StaysFixedError(`Waited ${secs(defaultTimeout)}s for ${resolved} to finish loading and it never did.`, {
|
|
488
|
+
hint: 'Something on the page is still working — a request that never answers, or a script that never finishes.',
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
await sleep(50);
|
|
492
|
+
}
|
|
493
|
+
} finally {
|
|
494
|
+
offLoad();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// Input — real events, because synthetic ones miss half the handlers
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* @param {string} selector
|
|
504
|
+
* @param {boolean} scroll
|
|
505
|
+
* @returns {Promise<{x: number, y: number}>}
|
|
506
|
+
*/
|
|
507
|
+
async function pointFor(selector, scroll) {
|
|
508
|
+
const box = await readBox(selector, scroll);
|
|
509
|
+
if (!box || !(box.width > 0) || !(box.height > 0)) {
|
|
510
|
+
throw new StaysFixedError(`I found "${selector}" but it takes up no space on screen, so there is nothing to click.`, {
|
|
511
|
+
hint: 'It may be collapsed, sized to nothing, or covered by something else.',
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
x: Math.round(Math.max(0, box.x + box.width / 2)),
|
|
516
|
+
y: Math.round(Math.max(0, box.y + box.height / 2)),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @param {string} selector
|
|
522
|
+
* @param {{timeoutMs?: number}} [o]
|
|
523
|
+
* @returns {Promise<void>}
|
|
524
|
+
*/
|
|
525
|
+
async function click(selector, o) {
|
|
526
|
+
await waitFor(selector, o);
|
|
527
|
+
|
|
528
|
+
// A click that was never delivered is the worst kind of failure, because nothing
|
|
529
|
+
// reports it: the coordinates were right, the element was there, the protocol call
|
|
530
|
+
// succeeded, and the app simply never heard about it. On a desktop app it happened
|
|
531
|
+
// about two times in five — the top strip of an Electron window is a drag region the
|
|
532
|
+
// window manager consumes mouse events for, and a control sitting in it gets its
|
|
533
|
+
// clicks eaten. The symptom was a guard failing every other run with no error.
|
|
534
|
+
//
|
|
535
|
+
// So the click is confirmed rather than assumed: arm a one-shot listener, dispatch,
|
|
536
|
+
// and check whether the element actually heard it. If it did not, try again; if a
|
|
537
|
+
// real pointer can never reach it, click the element directly and say so.
|
|
538
|
+
const attempts = 3;
|
|
539
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
540
|
+
const point = await pointFor(selector, true);
|
|
541
|
+
await arm(selector);
|
|
542
|
+
const base = { x: point.x, y: point.y, button: 'left', clickCount: 1, modifiers: 0 };
|
|
543
|
+
await send('Input.dispatchMouseEvent', { ...base, type: 'mouseMoved', buttons: 0 });
|
|
544
|
+
await send('Input.dispatchMouseEvent', { ...base, type: 'mousePressed', buttons: 1 });
|
|
545
|
+
await send('Input.dispatchMouseEvent', { ...base, type: 'mouseReleased', buttons: 0 });
|
|
546
|
+
if (await heard()) return;
|
|
547
|
+
detail(`click on ${selector} was not delivered (try ${attempt} of ${attempts})`);
|
|
548
|
+
await sleep(120);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const blocker = await topAt(selector);
|
|
552
|
+
detail(`clicking ${selector} directly; a real pointer could not reach it`, blocker ? `(${blocker} is on top)` : '');
|
|
553
|
+
const done = await evaluate(
|
|
554
|
+
`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`
|
|
555
|
+
);
|
|
556
|
+
if (!done) {
|
|
557
|
+
throw new StaysFixedError(`Could not click "${selector}" — it disappeared while I was trying.`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Arm a one-shot capture listener so we can tell whether a click was really delivered.
|
|
563
|
+
* @param {string} selector
|
|
564
|
+
*/
|
|
565
|
+
async function arm(selector) {
|
|
566
|
+
await evaluate(`(() => {
|
|
567
|
+
window.__staysfixed_heard = false;
|
|
568
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
569
|
+
if (!el) return false;
|
|
570
|
+
el.addEventListener('click', function h() { window.__staysfixed_heard = true; }, { once: true, capture: true });
|
|
571
|
+
return true;
|
|
572
|
+
})()`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** @returns {Promise<boolean>} whether the armed element heard the click */
|
|
576
|
+
async function heard() {
|
|
577
|
+
try {
|
|
578
|
+
return Boolean(await evaluate('window.__staysfixed_heard === true'));
|
|
579
|
+
} catch {
|
|
580
|
+
// The page navigated because of the click. That counts as delivered.
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* What is actually on top at the middle of an element — the thing swallowing the click.
|
|
587
|
+
* @param {string} selector
|
|
588
|
+
* @returns {Promise<string|null>}
|
|
589
|
+
*/
|
|
590
|
+
async function topAt(selector) {
|
|
591
|
+
try {
|
|
592
|
+
return /** @type {string|null} */ (
|
|
593
|
+
await evaluate(`(() => {
|
|
594
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
595
|
+
if (!el) return null;
|
|
596
|
+
const r = el.getBoundingClientRect();
|
|
597
|
+
const top = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
|
598
|
+
if (!top || top === el || el.contains(top)) return null;
|
|
599
|
+
return top.tagName.toLowerCase() + (top.className ? '.' + String(top.className).trim().split(/\s+/)[0] : '');
|
|
600
|
+
})()`)
|
|
601
|
+
);
|
|
602
|
+
} catch {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* @param {string} selector
|
|
609
|
+
* @returns {Promise<void>}
|
|
610
|
+
*/
|
|
611
|
+
async function hover(selector) {
|
|
612
|
+
await waitFor(selector);
|
|
613
|
+
const point = await pointFor(selector, true);
|
|
614
|
+
await send('Input.dispatchMouseEvent', {
|
|
615
|
+
type: 'mouseMoved',
|
|
616
|
+
x: point.x,
|
|
617
|
+
y: point.y,
|
|
618
|
+
button: 'none',
|
|
619
|
+
buttons: 0,
|
|
620
|
+
modifiers: 0,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Park the pointer where nothing lives.
|
|
626
|
+
*
|
|
627
|
+
* A click leaves the mouse sitting on whatever it just pressed, and that thing stays
|
|
628
|
+
* hovered: a highlighted row, a tooltip, a button in its hover colour. The first real
|
|
629
|
+
* app this tool was pointed at photographed a tooltip nobody meant to capture, and a
|
|
630
|
+
* sidebar that refused to collapse because the pointer was still resting on the arrow
|
|
631
|
+
* that collapses it. Guards hit the same thing, so this is on the page, not buried in
|
|
632
|
+
* the capture path.
|
|
633
|
+
*
|
|
634
|
+
* Park it OUTSIDE the window, not in a corner. The corner was the first attempt and it
|
|
635
|
+
* was wrong in a way worth remembering: (1,1) is the top-left, which is exactly where
|
|
636
|
+
* sidebars, their reveal hot-zones and window controls live. Parking there hovered the
|
|
637
|
+
* very sidebar the picture was meant to show collapsed, and held it open.
|
|
638
|
+
*
|
|
639
|
+
* Past the bottom-right edge there is nothing to hit, so every hovered element gets its
|
|
640
|
+
* mouseout and the page settles into the state a person would see with their hand off
|
|
641
|
+
* the mouse.
|
|
642
|
+
* @returns {Promise<void>}
|
|
643
|
+
*/
|
|
644
|
+
async function moveMouseAway() {
|
|
645
|
+
let x = 5000;
|
|
646
|
+
let y = 5000;
|
|
647
|
+
try {
|
|
648
|
+
const size = /** @type {{w: number, h: number}} */ (
|
|
649
|
+
await evaluate('({ w: window.innerWidth || 0, h: window.innerHeight || 0 })')
|
|
650
|
+
);
|
|
651
|
+
if (size && size.w > 0) x = size.w + 50;
|
|
652
|
+
if (size && size.h > 0) y = size.h + 50;
|
|
653
|
+
} catch {
|
|
654
|
+
// No document to ask. The default is far enough outside any real window.
|
|
655
|
+
}
|
|
656
|
+
try {
|
|
657
|
+
await send('Input.dispatchMouseEvent', {
|
|
658
|
+
type: 'mouseMoved',
|
|
659
|
+
x,
|
|
660
|
+
y,
|
|
661
|
+
button: 'none',
|
|
662
|
+
buttons: 0,
|
|
663
|
+
modifiers: 0,
|
|
664
|
+
});
|
|
665
|
+
} catch {
|
|
666
|
+
// A target with no input domain has no hover to clear.
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* @param {string} key
|
|
672
|
+
* @returns {Promise<void>}
|
|
673
|
+
*/
|
|
674
|
+
async function press(key) {
|
|
675
|
+
// "Meta+b", "Control+Shift+P", "Alt+ArrowLeft" — the shortcut a person would type.
|
|
676
|
+
// Shortcuts are how a lot of real apps are actually driven, and a check that can only
|
|
677
|
+
// click cannot reach half of what a keyboard user does.
|
|
678
|
+
const parts = String(key).split('+').filter(Boolean);
|
|
679
|
+
const name = parts.length > 1 ? /** @type {string} */ (parts.pop()) : String(key);
|
|
680
|
+
let modifiers = 0;
|
|
681
|
+
for (const part of parts) {
|
|
682
|
+
const m = part.toLowerCase();
|
|
683
|
+
if (m === 'alt' || m === 'option') modifiers |= 1;
|
|
684
|
+
else if (m === 'ctrl' || m === 'control') modifiers |= 2;
|
|
685
|
+
else if (m === 'meta' || m === 'cmd' || m === 'command') modifiers |= 4;
|
|
686
|
+
else if (m === 'shift') modifiers |= 8;
|
|
687
|
+
else {
|
|
688
|
+
throw new StaysFixedError(`I do not know the key "${part}" in "${key}".`, {
|
|
689
|
+
hint: 'Modifiers are Meta, Control, Alt and Shift, joined with +, e.g. "Meta+b".',
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const entry = KEY_CODES[name];
|
|
695
|
+
if (!entry && modifiers === 0) {
|
|
696
|
+
// Unknown name with no modifiers: treat it as literal text, so press('a') still works.
|
|
697
|
+
await sendChar(name);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const single = name.length === 1;
|
|
702
|
+
const upper = single ? name.toUpperCase() : name;
|
|
703
|
+
/** @type {Record<string, unknown>} */
|
|
704
|
+
const common = entry
|
|
705
|
+
? {
|
|
706
|
+
key: entry.key,
|
|
707
|
+
code: entry.code,
|
|
708
|
+
windowsVirtualKeyCode: entry.keyCode,
|
|
709
|
+
nativeVirtualKeyCode: entry.keyCode,
|
|
710
|
+
modifiers,
|
|
711
|
+
}
|
|
712
|
+
: {
|
|
713
|
+
key: name,
|
|
714
|
+
code: single ? (/[a-z]/i.test(name) ? `Key${upper}` : `Digit${name}`) : name,
|
|
715
|
+
windowsVirtualKeyCode: upper.charCodeAt(0),
|
|
716
|
+
nativeVirtualKeyCode: upper.charCodeAt(0),
|
|
717
|
+
modifiers,
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
// A modified key must NOT carry text: sending "b" alongside Meta types a letter into
|
|
721
|
+
// whatever has focus as well as firing the shortcut.
|
|
722
|
+
const withText = Boolean(entry && entry.text) && modifiers === 0;
|
|
723
|
+
await send('Input.dispatchKeyEvent', {
|
|
724
|
+
...common,
|
|
725
|
+
type: withText ? 'keyDown' : 'rawKeyDown',
|
|
726
|
+
...(withText && entry && entry.text ? { text: entry.text, unmodifiedText: entry.text } : {}),
|
|
727
|
+
});
|
|
728
|
+
await send('Input.dispatchKeyEvent', { ...common, type: 'keyUp' });
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* One character, the long way round: a key press the app can listen to, then
|
|
734
|
+
* the character insertion itself.
|
|
735
|
+
* @param {string} ch
|
|
736
|
+
* @returns {Promise<void>}
|
|
737
|
+
*/
|
|
738
|
+
async function sendChar(ch) {
|
|
739
|
+
if (ch === '\n' || ch === '\r') {
|
|
740
|
+
await press('Enter');
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const code = ch.length === 1 ? ch.charCodeAt(0) : 0;
|
|
744
|
+
const virtual = ch.length === 1 ? ch.toUpperCase().charCodeAt(0) : 0;
|
|
745
|
+
/** @type {Record<string, unknown>} */
|
|
746
|
+
const common = {
|
|
747
|
+
key: ch,
|
|
748
|
+
windowsVirtualKeyCode: virtual,
|
|
749
|
+
nativeVirtualKeyCode: virtual,
|
|
750
|
+
modifiers: 0,
|
|
751
|
+
};
|
|
752
|
+
// rawKeyDown, then char: keyDown carrying text would insert the character a
|
|
753
|
+
// second time on top of the char event.
|
|
754
|
+
if (code >= 0x20) {
|
|
755
|
+
await send('Input.dispatchKeyEvent', { ...common, type: 'rawKeyDown' });
|
|
756
|
+
await send('Input.dispatchKeyEvent', { type: 'char', text: ch, key: ch, unmodifiedText: ch, modifiers: 0 });
|
|
757
|
+
await send('Input.dispatchKeyEvent', { ...common, type: 'keyUp' });
|
|
758
|
+
} else {
|
|
759
|
+
await send('Input.dispatchKeyEvent', { type: 'char', text: ch, key: ch, unmodifiedText: ch, modifiers: 0 });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* @param {string} selector
|
|
765
|
+
* @param {string} text
|
|
766
|
+
* @returns {Promise<void>}
|
|
767
|
+
*/
|
|
768
|
+
async function typeText(selector, text) {
|
|
769
|
+
await click(selector);
|
|
770
|
+
const before = await evaluate(
|
|
771
|
+
'(function(){var el=document.querySelector(' +
|
|
772
|
+
q(selector) +
|
|
773
|
+
');if(!el)return null;return typeof el.value==="string"?el.value:null;})()',
|
|
774
|
+
);
|
|
775
|
+
for (const ch of String(text)) await sendChar(ch);
|
|
776
|
+
|
|
777
|
+
// Some frameworks only listen for their own synthetic input events and never
|
|
778
|
+
// see raw key events at all. If nothing landed, put the value in by hand.
|
|
779
|
+
if (typeof before === 'string') {
|
|
780
|
+
const after = await evaluate(
|
|
781
|
+
'(function(){var el=document.querySelector(' +
|
|
782
|
+
q(selector) +
|
|
783
|
+
');if(!el)return null;return typeof el.value==="string"?el.value:null;})()',
|
|
784
|
+
);
|
|
785
|
+
if (after === before) {
|
|
786
|
+
await evaluate(
|
|
787
|
+
'(function(){var el=document.querySelector(' +
|
|
788
|
+
q(selector) +
|
|
789
|
+
');if(!el)return false;var v=' +
|
|
790
|
+
JSON.stringify(before + String(text)) +
|
|
791
|
+
';' +
|
|
792
|
+
// React replaces the value setter on the element itself, so the only
|
|
793
|
+
// way it notices a change is through the prototype's original setter.
|
|
794
|
+
'var proto=(window.HTMLTextAreaElement&&el instanceof window.HTMLTextAreaElement)?window.HTMLTextAreaElement.prototype:window.HTMLInputElement.prototype;' +
|
|
795
|
+
'var d=Object.getOwnPropertyDescriptor(proto,"value");' +
|
|
796
|
+
'if(d&&d.set){d.set.call(el,v);}else{el.value=v;}' +
|
|
797
|
+
'el.dispatchEvent(new Event("input",{bubbles:true}));' +
|
|
798
|
+
'el.dispatchEvent(new Event("change",{bubbles:true}));' +
|
|
799
|
+
'return true;})()',
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* @param {string} selector
|
|
807
|
+
* @returns {Promise<void>}
|
|
808
|
+
*/
|
|
809
|
+
async function scrollTo(selector) {
|
|
810
|
+
await waitFor(selector);
|
|
811
|
+
await evaluate(boxSource(selector, true));
|
|
812
|
+
// Smooth scrolling may be off, but the app may still be animating something
|
|
813
|
+
// of its own. Wait until the page stops moving before anyone takes a picture.
|
|
814
|
+
const deadline = Date.now() + defaultTimeout;
|
|
815
|
+
let previous = await evaluate('window.scrollY');
|
|
816
|
+
for (;;) {
|
|
817
|
+
await sleep(100);
|
|
818
|
+
const now = await evaluate('window.scrollY');
|
|
819
|
+
if (now === previous) return;
|
|
820
|
+
previous = now;
|
|
821
|
+
if (Date.now() >= deadline) return;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
// Window and pictures
|
|
827
|
+
// ---------------------------------------------------------------------------
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* @param {import('../types.js').ViewportConfig} v
|
|
831
|
+
* @returns {Promise<void>}
|
|
832
|
+
*/
|
|
833
|
+
async function setViewport(v) {
|
|
834
|
+
await send('Emulation.setDeviceMetricsOverride', {
|
|
835
|
+
width: Math.round(v.width),
|
|
836
|
+
height: Math.round(v.height),
|
|
837
|
+
deviceScaleFactor: v.deviceScaleFactor ?? 1,
|
|
838
|
+
mobile: Boolean(v.mobile),
|
|
839
|
+
});
|
|
840
|
+
try {
|
|
841
|
+
await send('Emulation.setVisibleSize', { width: Math.round(v.width), height: Math.round(v.height) });
|
|
842
|
+
} catch {
|
|
843
|
+
// Gone from newer Chrome. The metrics override above already did the work.
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* @param {import('../types.js').CaptureOptions} [captureOpts]
|
|
849
|
+
* @returns {Promise<Buffer>}
|
|
850
|
+
*/
|
|
851
|
+
async function shoot(captureOpts) {
|
|
852
|
+
const o = captureOpts ?? {};
|
|
853
|
+
/** @type {{x: number, y: number, width: number, height: number, scale: number}|null} */
|
|
854
|
+
let clip = null;
|
|
855
|
+
|
|
856
|
+
if (o.fullPage) {
|
|
857
|
+
const metrics = await send('Page.getLayoutMetrics');
|
|
858
|
+
const content = metrics?.cssContentSize ?? metrics?.contentSize ?? null;
|
|
859
|
+
if (content) {
|
|
860
|
+
clip = {
|
|
861
|
+
x: 0,
|
|
862
|
+
y: 0,
|
|
863
|
+
// Whole pixels only: a layout that lands on a half pixel would
|
|
864
|
+
// otherwise change the picture's size between runs.
|
|
865
|
+
width: Math.min(MAX_CAPTURE_SIDE, Math.ceil(Number(content.width))),
|
|
866
|
+
height: Math.min(MAX_CAPTURE_SIDE, Math.ceil(Number(content.height))),
|
|
867
|
+
scale: 1,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
} else if (o.rect) {
|
|
871
|
+
clip = {
|
|
872
|
+
x: Math.round(o.rect.x),
|
|
873
|
+
y: Math.round(o.rect.y),
|
|
874
|
+
width: Math.round(o.rect.width),
|
|
875
|
+
height: Math.round(o.rect.height),
|
|
876
|
+
scale: 1,
|
|
877
|
+
};
|
|
878
|
+
} else if (o.clip) {
|
|
879
|
+
const box = await readBox(o.clip, false);
|
|
880
|
+
if (!box || !(box.width > 0) || !(box.height > 0)) {
|
|
881
|
+
throw new StaysFixedError(`I cannot photograph "${o.clip}" because it takes up no space on screen.`, {
|
|
882
|
+
hint: 'Check the screen really shows that element by the time the picture is taken.',
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
clip = {
|
|
886
|
+
x: Math.round(box.x),
|
|
887
|
+
y: Math.round(box.y),
|
|
888
|
+
width: Math.round(box.width),
|
|
889
|
+
height: Math.round(box.height),
|
|
890
|
+
scale: 1,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** @type {Record<string, unknown>} */
|
|
895
|
+
const params = {
|
|
896
|
+
format: 'png',
|
|
897
|
+
captureBeyondViewport: Boolean(o.fullPage),
|
|
898
|
+
fromSurface: true,
|
|
899
|
+
};
|
|
900
|
+
if (clip) params.clip = clip;
|
|
901
|
+
|
|
902
|
+
const res = await send('Page.captureScreenshot', params);
|
|
903
|
+
if (!res?.data) {
|
|
904
|
+
throw new StaysFixedError('The window gave back an empty picture.', {
|
|
905
|
+
hint: 'This usually means the window was hidden or had just closed. Try again with the app in the foreground.',
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return Buffer.from(String(res.data), 'base64');
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ---------------------------------------------------------------------------
|
|
912
|
+
// Scripts and styles we push into the page
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* @param {string} source
|
|
917
|
+
* @returns {Promise<string>}
|
|
918
|
+
*/
|
|
919
|
+
async function addInitScript(source) {
|
|
920
|
+
const res = await send('Page.addScriptToEvaluateOnNewDocument', { source });
|
|
921
|
+
return String(res?.identifier ?? '');
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* @param {string} id
|
|
926
|
+
* @returns {Promise<void>}
|
|
927
|
+
*/
|
|
928
|
+
async function removeInitScript(id) {
|
|
929
|
+
if (!id) return;
|
|
930
|
+
try {
|
|
931
|
+
await send('Page.removeScriptToEvaluateOnNewDocument', { identifier: id });
|
|
932
|
+
} catch {
|
|
933
|
+
// Already gone, or the page went away first. Nothing to undo.
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** @type {Map<string, {styleSheetId?: string, initId?: string, token: string}>} */
|
|
938
|
+
const styles = new Map();
|
|
939
|
+
let styleCounter = 0;
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* @param {string} css
|
|
943
|
+
* @returns {Promise<string>}
|
|
944
|
+
*/
|
|
945
|
+
async function insertCss(css) {
|
|
946
|
+
styleCounter += 1;
|
|
947
|
+
const token = `staysfixed-style-${styleCounter}`;
|
|
948
|
+
const source = styleTagSource(css, token);
|
|
949
|
+
|
|
950
|
+
// The stylesheet dies on navigation, so the same CSS also goes in as an init
|
|
951
|
+
// script. On this document only the stylesheet applies; after a navigation
|
|
952
|
+
// only the tag does — never both, so nothing is applied twice.
|
|
953
|
+
let initId = '';
|
|
954
|
+
try {
|
|
955
|
+
initId = await addInitScript(source);
|
|
956
|
+
} catch {
|
|
957
|
+
detail('This window will not keep styles across navigation; applying them to the current page only.');
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** @type {string|undefined} */
|
|
961
|
+
let styleSheetId;
|
|
962
|
+
try {
|
|
963
|
+
const tree = await send('Page.getFrameTree');
|
|
964
|
+
const frameId = tree?.frameTree?.frame?.id;
|
|
965
|
+
if (!frameId) throw new Error('no frame');
|
|
966
|
+
const sheet = await send('CSS.createStyleSheet', { frameId });
|
|
967
|
+
styleSheetId = String(sheet?.styleSheetId ?? '');
|
|
968
|
+
if (!styleSheetId) throw new Error('no stylesheet');
|
|
969
|
+
await send('CSS.setStyleSheetText', { styleSheetId, text: css });
|
|
970
|
+
} catch {
|
|
971
|
+
// No CSS domain on this window (common in Electron). Push a plain <style>
|
|
972
|
+
// tag in instead — same result, one more element in the DOM.
|
|
973
|
+
styleSheetId = undefined;
|
|
974
|
+
await evaluate(source);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const id = `${styleSheetId ? 'sheet' : 'style'}:${styleCounter}`;
|
|
978
|
+
styles.set(id, { styleSheetId, initId, token });
|
|
979
|
+
return id;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* @param {string} id
|
|
984
|
+
* @returns {Promise<void>}
|
|
985
|
+
*/
|
|
986
|
+
async function removeCss(id) {
|
|
987
|
+
const entry = styles.get(id);
|
|
988
|
+
if (!entry) return;
|
|
989
|
+
styles.delete(id);
|
|
990
|
+
|
|
991
|
+
if (entry.styleSheetId) {
|
|
992
|
+
try {
|
|
993
|
+
// CDP has no "delete stylesheet"; emptying it is the removal.
|
|
994
|
+
await send('CSS.setStyleSheetText', { styleSheetId: entry.styleSheetId, text: '' });
|
|
995
|
+
} catch {
|
|
996
|
+
// The page navigated away and took the stylesheet with it.
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (entry.initId) await removeInitScript(entry.initId);
|
|
1000
|
+
try {
|
|
1001
|
+
await evaluate(removeStyleTagSource(entry.token));
|
|
1002
|
+
} catch {
|
|
1003
|
+
// Nothing to take out.
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** @type {import('../types.js').PageHandle} */
|
|
1008
|
+
const page = {
|
|
1009
|
+
goto,
|
|
1010
|
+
click,
|
|
1011
|
+
type: typeText,
|
|
1012
|
+
press,
|
|
1013
|
+
hover,
|
|
1014
|
+
moveMouseAway,
|
|
1015
|
+
waitFor,
|
|
1016
|
+
waitForGone,
|
|
1017
|
+
scrollTo,
|
|
1018
|
+
wait,
|
|
1019
|
+
evaluate,
|
|
1020
|
+
visible,
|
|
1021
|
+
exists,
|
|
1022
|
+
textOf,
|
|
1023
|
+
count,
|
|
1024
|
+
boxOf,
|
|
1025
|
+
url,
|
|
1026
|
+
title,
|
|
1027
|
+
shoot,
|
|
1028
|
+
setViewport,
|
|
1029
|
+
consoleErrors,
|
|
1030
|
+
send,
|
|
1031
|
+
on,
|
|
1032
|
+
sessionId,
|
|
1033
|
+
targetId,
|
|
1034
|
+
addInitScript,
|
|
1035
|
+
removeInitScript,
|
|
1036
|
+
insertCss,
|
|
1037
|
+
removeCss,
|
|
1038
|
+
baseUrl,
|
|
1039
|
+
clearConsole,
|
|
1040
|
+
};
|
|
1041
|
+
return page;
|
|
1042
|
+
}
|