dsh-moli 1.1.12 → 1.1.13
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/README.md +46 -125
- package/README.zh.md +41 -111
- package/index.js +5 -299
- package/lib/moli.js +62 -1
- package/package.json +1 -1
- package/lib/actions.js +0 -460
- package/lib/session.js +0 -632
package/lib/actions.js
DELETED
|
@@ -1,460 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The actions a session can perform.
|
|
3
|
-
*
|
|
4
|
-
* Kept apart from {@link ./session.js} so the transport (server lifecycle, CDP
|
|
5
|
-
* plumbing) and the behaviour (what `click` means) can be read and tested
|
|
6
|
-
* separately. Every action takes the CDP connection and returns a plain value;
|
|
7
|
-
* none of them throws for a page-level problem, because "that selector matched
|
|
8
|
-
* nothing" is a result the caller has to see, not a transport failure.
|
|
9
|
-
*
|
|
10
|
-
* Selector handling is deliberately uniform: an element is located by CSS
|
|
11
|
-
* selector in the page, measured, and then driven through real input events. The
|
|
12
|
-
* alternative -- taking viewport coordinates from the caller -- makes the model
|
|
13
|
-
* guess pixels, and the coordinates go stale the moment the page reflows.
|
|
14
|
-
*
|
|
15
|
-
* @module dsh-moli/lib/actions
|
|
16
|
-
*/
|
|
17
|
-
import { existsSync, writeFileSync } from 'node:fs';
|
|
18
|
-
import { dirname } from 'node:path';
|
|
19
|
-
|
|
20
|
-
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
21
|
-
|
|
22
|
-
/** CSS-selector quoting, so a selector can be embedded in an expression safely. */
|
|
23
|
-
const quote = value => JSON.stringify(String(value));
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Resolve a selector to a click point, honouring the backend's geometry frame.
|
|
27
|
-
*
|
|
28
|
-
* Two frames exist and they are not interchangeable. Moli's rectangle is
|
|
29
|
-
* document-absolute and does not follow the scroll, and its hit test stops
|
|
30
|
-
* answering past the viewport height; a real Chromium reports a viewport-relative
|
|
31
|
-
* rectangle that a scroll changes. The same measurement code cannot serve both --
|
|
32
|
-
* a bounds test that is right for one silently rejects the other -- so the frame
|
|
33
|
-
* arrives from the session and the two branches below implement it directly.
|
|
34
|
-
*
|
|
35
|
-
* @param geometry - `viewport` (standard) or `absolute` (moli).
|
|
36
|
-
* @returns `{ box, point, tag, text, covered }` on success, `{ error }` otherwise.
|
|
37
|
-
*/
|
|
38
|
-
async function boxOf(connection, selector, geometry) {
|
|
39
|
-
if (geometry === 'absolute') return boxOfAbsolute(connection, selector);
|
|
40
|
-
return boxOfViewport(connection, selector);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Moli's frame: the coordinate the engine's input accepts is the one its own
|
|
45
|
-
* rectangle reports, and scrolling changes neither. Measured on moli 1.1.16.
|
|
46
|
-
*/
|
|
47
|
-
async function boxOfAbsolute(connection, selector) {
|
|
48
|
-
const expression = `(() => {
|
|
49
|
-
const el = document.querySelector(${quote(selector)});
|
|
50
|
-
if (el === null) return { missing: true };
|
|
51
|
-
const rect = el.getBoundingClientRect();
|
|
52
|
-
const style = getComputedStyle(el);
|
|
53
|
-
const cx = rect.x + rect.width / 2;
|
|
54
|
-
const cy = rect.y + rect.height / 2;
|
|
55
|
-
const found = document.elementFromPoint(cx, cy);
|
|
56
|
-
const hitSelf = found !== null && (found === el || el.contains(found) || found.contains(el));
|
|
57
|
-
return {
|
|
58
|
-
box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
59
|
-
point: hitSelf ? { x: cx, y: cy } : null,
|
|
60
|
-
covered: hitSelf || found === null ? null : found.tagName.toLowerCase() + (found.id ? '#' + found.id : ''),
|
|
61
|
-
tag: el.tagName.toLowerCase(),
|
|
62
|
-
text: (el.innerText || el.value || el.getAttribute('aria-label') || '').trim().slice(0, 80),
|
|
63
|
-
visible: style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0,
|
|
64
|
-
viewportHeight: innerHeight,
|
|
65
|
-
};
|
|
66
|
-
})()`;
|
|
67
|
-
const result = await connection.evaluate(expression);
|
|
68
|
-
if (result.throws !== undefined) return { error: result.throws };
|
|
69
|
-
const value = result.value;
|
|
70
|
-
if (value === undefined) return { error: 'the page returned nothing for that selector' };
|
|
71
|
-
if (value.missing === true) return { error: `no element matches ${JSON.stringify(selector)}` };
|
|
72
|
-
return value;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* The standards-compliant frame: scroll the element into view, then read the
|
|
77
|
-
* rectangle, which is now viewport-relative. The scroll has to happen *inside* the
|
|
78
|
-
* same evaluation as the measurement, or the rectangle read afterwards describes
|
|
79
|
-
* the pre-scroll layout.
|
|
80
|
-
*/
|
|
81
|
-
async function boxOfViewport(connection, selector) {
|
|
82
|
-
const expression = `(() => {
|
|
83
|
-
const el = document.querySelector(${quote(selector)});
|
|
84
|
-
if (el === null) return { missing: true };
|
|
85
|
-
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
86
|
-
const rect = el.getBoundingClientRect();
|
|
87
|
-
const style = getComputedStyle(el);
|
|
88
|
-
const cx = rect.x + rect.width / 2;
|
|
89
|
-
const cy = rect.y + rect.height / 2;
|
|
90
|
-
const inside = cx >= 0 && cx <= innerWidth && cy >= 0 && cy <= innerHeight;
|
|
91
|
-
let found = null;
|
|
92
|
-
if (inside) found = document.elementFromPoint(cx, cy);
|
|
93
|
-
const hitSelf = found !== null && (found === el || el.contains(found) || found.contains(el));
|
|
94
|
-
return {
|
|
95
|
-
box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
96
|
-
point: hitSelf && inside ? { x: cx, y: cy } : null,
|
|
97
|
-
covered: hitSelf ? null : (found === null ? null : found.tagName.toLowerCase() + (found.id ? '#' + found.id : '')),
|
|
98
|
-
tag: el.tagName.toLowerCase(),
|
|
99
|
-
text: (el.innerText || el.value || el.getAttribute('aria-label') || '').trim().slice(0, 80),
|
|
100
|
-
visible: style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0,
|
|
101
|
-
inViewport: inside,
|
|
102
|
-
viewportHeight: innerHeight,
|
|
103
|
-
};
|
|
104
|
-
})()`;
|
|
105
|
-
const result = await connection.evaluate(expression);
|
|
106
|
-
if (result.throws !== undefined) return { error: result.throws };
|
|
107
|
-
const value = result.value;
|
|
108
|
-
if (value === undefined) return { error: 'the page returned nothing for that selector' };
|
|
109
|
-
if (value.missing === true) return { error: `no element matches ${JSON.stringify(selector)}` };
|
|
110
|
-
return value;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Measure a selector, in whichever frame the backend uses.
|
|
115
|
-
*
|
|
116
|
-
* The scroll for the standard frame happens inside the measurement expression, so
|
|
117
|
-
* there is nothing to wait for here. A timer would be wrong anyway:
|
|
118
|
-
* `requestAnimationFrame` does not fire while the page is not painting, so a
|
|
119
|
-
* promise waiting on one never settles.
|
|
120
|
-
*/
|
|
121
|
-
async function ensureVisible(connection, selector, geometry) {
|
|
122
|
-
return boxOf(connection, selector, geometry);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Turn a measurement into a click that is known to land on the element.
|
|
127
|
-
*
|
|
128
|
-
* A click outside the viewport, or one whose point is covered by another element,
|
|
129
|
-
* is reported rather than dispatched: dispatching anyway produces a silent no-op
|
|
130
|
-
* that looks like success to the caller.
|
|
131
|
-
*/
|
|
132
|
-
async function clickSelector(connection, selector, geometry) {
|
|
133
|
-
const found = await ensureVisible(connection, selector, geometry);
|
|
134
|
-
if (found.error !== undefined) return { ok: false, error: found.error };
|
|
135
|
-
if (found.visible !== true) return { ok: false, error: `${JSON.stringify(selector)} is not visible (display:none or visibility:hidden)` };
|
|
136
|
-
if (found.point === null) {
|
|
137
|
-
// Only the moli frame has the viewport-height wall. In the standard frame a
|
|
138
|
-
// null point means the element could not be brought on screen, or something
|
|
139
|
-
// is on top of it.
|
|
140
|
-
const beyondViewport = geometry === 'absolute'
|
|
141
|
-
&& found.box.y + found.box.height / 2 > (found.viewportHeight ?? 0);
|
|
142
|
-
return {
|
|
143
|
-
ok: false,
|
|
144
|
-
selector,
|
|
145
|
-
error: found.covered !== null
|
|
146
|
-
? `${JSON.stringify(selector)} is covered by another element (${found.covered})`
|
|
147
|
-
: beyondViewport
|
|
148
|
-
? `${JSON.stringify(selector)} is below the viewport (y=${String(Math.round(found.box.y))}) and this engine cannot send input there`
|
|
149
|
-
: `${JSON.stringify(selector)} could not be brought into the viewport`,
|
|
150
|
-
hint: found.covered !== null
|
|
151
|
-
? 'Something is on top of it -- commonly a consent banner or a modal. Dismiss that first, or press Escape.'
|
|
152
|
-
: beyondViewport
|
|
153
|
-
? 'Moli\'s hit test is bounded by the viewport height and its element geometry does not follow scrolling, so synthetic clicks cannot reach elements further down the document. Use eval to click it in the page instead -- for example `document.querySelector("SEL").click()` -- which bypasses the input layer entirely. Switching the plugin config to `browser: "chromium"` also removes the limit, since a real browser reports standard geometry.'
|
|
154
|
-
: 'The element may be inside a scrollable container, or hidden behind a fixed overlay.',
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
await clickAt(connection, found.point.x, found.point.y);
|
|
158
|
-
return { ok: true, found, x: found.point.x, y: found.point.y };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Dispatch a full click: move, press, release. Real events, so page handlers run. */
|
|
162
|
-
async function clickAt(connection, x, y, { clickCount = 1 } = {}) {
|
|
163
|
-
await connection.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
|
164
|
-
await connection.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount });
|
|
165
|
-
await connection.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Wait until the page has no pending load and the document is unchanged for a
|
|
170
|
-
* short settle window. Used after navigation and after clicks that may navigate.
|
|
171
|
-
*/
|
|
172
|
-
async function settle(connection, { quietMs = 250, timeoutMs = 20000 } = {}) {
|
|
173
|
-
const started = Date.now();
|
|
174
|
-
let lastSignature;
|
|
175
|
-
let stableSince = Date.now();
|
|
176
|
-
while (Date.now() - started < timeoutMs) {
|
|
177
|
-
const result = await connection.evaluate('document.readyState + "|" + document.title + "|" + location.href');
|
|
178
|
-
const signature = String(result.value);
|
|
179
|
-
if (signature !== lastSignature) {
|
|
180
|
-
lastSignature = signature;
|
|
181
|
-
stableSince = Date.now();
|
|
182
|
-
} else if (Date.now() - stableSince >= quietMs) {
|
|
183
|
-
return { settled: true, ms: Date.now() - started };
|
|
184
|
-
}
|
|
185
|
-
await sleep(100);
|
|
186
|
-
}
|
|
187
|
-
return { settled: false, ms: Date.now() - started };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** A readable, size-bounded view of the rendered page. */
|
|
191
|
-
async function snapshot(connection, { maxChars, includeHtml }) {
|
|
192
|
-
const expression = `(() => {
|
|
193
|
-
const text = document.body === null ? '' : document.body.innerText.trim();
|
|
194
|
-
const links = [...document.querySelectorAll('a[href]')].slice(0, 200).map(a => ({ text: (a.innerText || '').trim().slice(0, 80), href: a.href }));
|
|
195
|
-
return JSON.stringify({
|
|
196
|
-
url: location.href,
|
|
197
|
-
title: document.title,
|
|
198
|
-
text,
|
|
199
|
-
links,
|
|
200
|
-
forms: [...document.querySelectorAll('input,textarea,select')].slice(0, 50).map(el => ({
|
|
201
|
-
tag: el.tagName.toLowerCase(), type: el.type || '', name: el.name || '', id: el.id || '', placeholder: el.placeholder || '',
|
|
202
|
-
})),
|
|
203
|
-
html: ${includeHtml === true ? 'document.documentElement.outerHTML' : "''"},
|
|
204
|
-
});
|
|
205
|
-
})()`;
|
|
206
|
-
const result = await connection.evaluate(expression);
|
|
207
|
-
if (result.throws !== undefined) return { ok: false, error: result.throws };
|
|
208
|
-
const data = JSON.parse(String(result.value));
|
|
209
|
-
const limit = typeof maxChars === 'number' && maxChars > 0 ? maxChars : 200000;
|
|
210
|
-
const truncated = data.text.length > limit;
|
|
211
|
-
return {
|
|
212
|
-
ok: true,
|
|
213
|
-
url: data.url,
|
|
214
|
-
title: data.title,
|
|
215
|
-
text: truncated ? `${data.text.slice(0, limit)}\n\n… [dsh-moli] ${String(data.text.length - limit)} characters omitted; pass a larger maxChars to keep them.` : data.text,
|
|
216
|
-
chars: data.text.length,
|
|
217
|
-
truncated,
|
|
218
|
-
links: data.links,
|
|
219
|
-
fields: data.forms,
|
|
220
|
-
...(includeHtml === true ? { html: data.html.slice(0, limit) } : {}),
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// ---------------------------------------------------------------------------
|
|
225
|
-
// the action table
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Run one action against a live connection.
|
|
230
|
-
*
|
|
231
|
-
* @param connection - the CDP connection from {@link SessionManager#ensure}.
|
|
232
|
-
* @param args - the tool arguments.
|
|
233
|
-
* @param options - output limits, the backend's geometry frame, and the browser.
|
|
234
|
-
* @returns the action's value.
|
|
235
|
-
*/
|
|
236
|
-
export async function performAction(connection, args, options) {
|
|
237
|
-
const action = args.action;
|
|
238
|
-
const timeoutMs = args.timeoutMs ?? 30000;
|
|
239
|
-
// Which frame element geometry lives in. Always supplied by the session; the
|
|
240
|
-
// default keeps a direct call from behaving surprisingly.
|
|
241
|
-
const geometry = options.geometry === 'viewport' ? 'viewport' : 'absolute';
|
|
242
|
-
|
|
243
|
-
switch (action) {
|
|
244
|
-
case 'open': {
|
|
245
|
-
if (typeof args.url !== 'string' || args.url === '') return { ok: false, error: 'open needs a url' };
|
|
246
|
-
await connection.send('Page.navigate', { url: args.url }, timeoutMs);
|
|
247
|
-
const result = await settle(connection, { timeoutMs });
|
|
248
|
-
const url = await connection.evaluate('location.href');
|
|
249
|
-
const title = await connection.evaluate('document.title');
|
|
250
|
-
return { ok: true, action, url: url.value, title: title.value, settled: result.settled, settledMs: result.ms };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
case 'snapshot':
|
|
254
|
-
return snapshot(connection, { maxChars: args.maxChars ?? options.maxChars, includeHtml: args.includeHtml === true });
|
|
255
|
-
|
|
256
|
-
case 'click': {
|
|
257
|
-
if (typeof args.selector !== 'string' || args.selector === '') return { ok: false, error: 'click needs a selector' };
|
|
258
|
-
const before = await connection.evaluate('location.href');
|
|
259
|
-
const outcome = await clickSelector(connection, args.selector, geometry);
|
|
260
|
-
if (outcome.ok !== true) return { ok: false, action, selector: args.selector, ...outcome };
|
|
261
|
-
// A click may navigate, open a dialog, or only run a handler; settling
|
|
262
|
-
// covers the first, and reporting the URL change covers all three.
|
|
263
|
-
const settled = await settle(connection, { timeoutMs: Math.min(timeoutMs, 10000) });
|
|
264
|
-
const after = await connection.evaluate('location.href');
|
|
265
|
-
return {
|
|
266
|
-
ok: true,
|
|
267
|
-
action,
|
|
268
|
-
selector: args.selector,
|
|
269
|
-
clicked: { tag: outcome.found.tag, text: outcome.found.text, x: Math.round(outcome.x), y: Math.round(outcome.y) },
|
|
270
|
-
navigated: before.value !== after.value,
|
|
271
|
-
url: after.value,
|
|
272
|
-
settled: settled.settled,
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
case 'type': {
|
|
277
|
-
if (typeof args.selector !== 'string' || args.selector === '') return { ok: false, error: 'type needs a selector' };
|
|
278
|
-
if (typeof args.text !== 'string') return { ok: false, error: 'type needs text' };
|
|
279
|
-
// Measure first so a field that cannot be reached is reported rather than
|
|
280
|
-
// silently typed into nowhere. Focusing also scrolls, which is what the
|
|
281
|
-
// standard frame needs.
|
|
282
|
-
const found = await ensureVisible(connection, args.selector, geometry);
|
|
283
|
-
if (found.error !== undefined) return { ok: false, error: found.error, selector: args.selector };
|
|
284
|
-
const focused = await connection.evaluate(
|
|
285
|
-
`(() => { const el = document.querySelector(${quote(args.selector)}); if (el === null) return 'missing'; el.focus(); ${args.clear === true ? 'if ("value" in el) { el.value = ""; }' : ''} return document.activeElement === el ? 'focused' : 'not-focused'; })()`);
|
|
286
|
-
if (focused.value !== 'focused') return { ok: false, error: `could not focus ${JSON.stringify(args.selector)} (${String(focused.value ?? focused.throws)})` };
|
|
287
|
-
await connection.send('Input.insertText', { text: args.text });
|
|
288
|
-
let submitted = false;
|
|
289
|
-
if (args.submit === true) {
|
|
290
|
-
await keyPress(connection, 'Enter');
|
|
291
|
-
submitted = true;
|
|
292
|
-
await settle(connection, { timeoutMs: Math.min(timeoutMs, 10000) });
|
|
293
|
-
}
|
|
294
|
-
const value = await connection.evaluate(`(() => { const el = document.querySelector(${quote(args.selector)}); return el === null ? null : (el.value ?? el.innerText ?? null); })()`);
|
|
295
|
-
return { ok: true, action, selector: args.selector, submitted, value: value.value };
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
case 'cdp': {
|
|
299
|
-
// The escape hatch. Every action above is an abstraction chosen in advance;
|
|
300
|
-
// this one is not, and it reaches what they do not: attaching to another
|
|
301
|
-
// target, cross-origin frames, file inputs (`DOM.setFileInputFiles`), extra
|
|
302
|
-
// headers (`Network.setExtraHTTPHeaders`), and anything else the protocol
|
|
303
|
-
// offers. Agents have seen far more CDP than any wrapper can anticipate.
|
|
304
|
-
if (typeof args.method !== 'string' || args.method === '') return { ok: false, error: 'cdp needs method, e.g. "Network.enable"' };
|
|
305
|
-
let params = {};
|
|
306
|
-
if (args.params !== undefined && args.params !== null) {
|
|
307
|
-
if (typeof args.params === 'string') {
|
|
308
|
-
try { params = JSON.parse(args.params); }
|
|
309
|
-
catch (error) { return { ok: false, action, error: `params is not valid JSON: ${String(error.message)}` }; }
|
|
310
|
-
} else if (typeof args.params === 'object') {
|
|
311
|
-
params = args.params;
|
|
312
|
-
} else {
|
|
313
|
-
return { ok: false, action, error: 'params must be an object or a JSON string' };
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
try {
|
|
317
|
-
const result = await connection.send(args.method, params, timeoutMs);
|
|
318
|
-
const serialized = JSON.stringify(result);
|
|
319
|
-
const limit = args.maxChars ?? options.maxChars ?? 200000;
|
|
320
|
-
const trimmed = typeof serialized === 'string' && serialized.length > limit;
|
|
321
|
-
return {
|
|
322
|
-
ok: true,
|
|
323
|
-
action,
|
|
324
|
-
method: args.method,
|
|
325
|
-
result: trimmed ? undefined : result,
|
|
326
|
-
serialized: trimmed ? `${serialized.slice(0, limit)}\n… [dsh-moli] truncated; pass a larger maxChars` : serialized,
|
|
327
|
-
bytes: typeof serialized === 'string' ? serialized.length : 0,
|
|
328
|
-
};
|
|
329
|
-
} catch (error) {
|
|
330
|
-
return {
|
|
331
|
-
ok: false,
|
|
332
|
-
action,
|
|
333
|
-
method: args.method,
|
|
334
|
-
error: error instanceof Error ? error.message : String(error),
|
|
335
|
-
hint: 'The protocol rejected the command. Check the method name and its parameters against the CDP reference; `Runtime.evaluate` with a script is often easier than several low-level calls.',
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
case 'press':
|
|
341
|
-
if (typeof args.key !== 'string' || args.key === '') return { ok: false, error: 'press needs key' };
|
|
342
|
-
await keyPress(connection, args.key);
|
|
343
|
-
return { ok: true, action, key: args.key };
|
|
344
|
-
|
|
345
|
-
case 'scroll': {
|
|
346
|
-
let expression;
|
|
347
|
-
if (typeof args.selector === 'string' && args.selector !== '') {
|
|
348
|
-
expression = `(() => { const el = document.querySelector(${quote(args.selector)}); if (el === null) return null; el.scrollIntoView({ block: '${String(args.block ?? 'center')}' }); return window.scrollY; })()`;
|
|
349
|
-
} else {
|
|
350
|
-
const dy = typeof args.dy === 'number' ? args.dy : 800;
|
|
351
|
-
expression = `(() => { window.scrollBy(0, ${String(dy)}); return window.scrollY; })()`;
|
|
352
|
-
}
|
|
353
|
-
const result = await connection.evaluate(expression);
|
|
354
|
-
if (result.value === null) return { ok: false, error: `no element matches ${JSON.stringify(String(args.selector))}` };
|
|
355
|
-
// In the standard frame the scroll has to take effect before the position is
|
|
356
|
-
// reported, or the caller sees the old value and thinks nothing moved.
|
|
357
|
-
if (geometry === 'viewport') await sleep(60);
|
|
358
|
-
const metrics = await connection.evaluate('JSON.stringify({ y: window.scrollY, height: document.body === null ? 0 : document.body.scrollHeight, viewport: innerHeight })');
|
|
359
|
-
return { ok: true, action, ...JSON.parse(String(metrics.value)) };
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
case 'waitFor': {
|
|
363
|
-
if (typeof args.selector !== 'string' || args.selector === '') return { ok: false, error: 'waitFor needs a selector' };
|
|
364
|
-
const deadline = Date.now() + timeoutMs;
|
|
365
|
-
const start = Date.now();
|
|
366
|
-
// `detached` means the selector matches nothing. It is deliberately not
|
|
367
|
-
// "hidden": a `display:none` element still matches, and conflating the two
|
|
368
|
-
// would make this wait forever on something the caller can already see is
|
|
369
|
-
// absent from the DOM.
|
|
370
|
-
const wantPresent = args.state !== 'detached';
|
|
371
|
-
for (;;) {
|
|
372
|
-
const present = (await connection.evaluate(`document.querySelector(${quote(args.selector)}) !== null`)).value === true;
|
|
373
|
-
if (present === wantPresent) {
|
|
374
|
-
return {
|
|
375
|
-
ok: true, action, selector: args.selector,
|
|
376
|
-
state: present ? 'attached' : 'detached',
|
|
377
|
-
waitedMs: Date.now() - start,
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
if (Date.now() > deadline) {
|
|
381
|
-
return {
|
|
382
|
-
ok: false, action, selector: args.selector,
|
|
383
|
-
error: `timed out after ${String(timeoutMs)}ms waiting for ${JSON.stringify(args.selector)} to be ${wantPresent ? 'attached' : 'detached'}`,
|
|
384
|
-
// A hidden element still matches the selector, which is the usual
|
|
385
|
-
// reason someone reaches for `detached` and waits for ever.
|
|
386
|
-
hint: '`detached` means the element is absent from the DOM; a hidden element still matches the selector. Use eval to check getComputedStyle, or wait for the attribute or class that hides it.',
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
await sleep(150);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
case 'eval': {
|
|
394
|
-
if (typeof args.expression !== 'string' || args.expression === '') return { ok: false, error: 'eval needs expression' };
|
|
395
|
-
const result = await connection.evaluate(args.expression, { awaitPromise: true, timeoutMs });
|
|
396
|
-
if (result.throws !== undefined) return { ok: false, action, error: result.throws, hint: 'The expression threw. Check that the page is in the state you expect and that the selector matches an element.' };
|
|
397
|
-
const value = result.value;
|
|
398
|
-
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
|
|
399
|
-
return { ok: true, action, value, serialized: serialized ?? 'undefined' };
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
case 'screenshot': {
|
|
403
|
-
const result = await connection.send('Page.captureScreenshot', { format: args.format ?? 'png' }, timeoutMs);
|
|
404
|
-
const data = result.data;
|
|
405
|
-
if (typeof data !== 'string') return { ok: false, error: 'the engine returned no image data' };
|
|
406
|
-
if (typeof args.writeTo !== 'string' || args.writeTo === '') {
|
|
407
|
-
return { ok: false, error: 'screenshot needs writeTo (an absolute path): the image cannot be returned inline', bytes: Buffer.from(data, 'base64').length };
|
|
408
|
-
}
|
|
409
|
-
const directory = dirname(args.writeTo);
|
|
410
|
-
if (!existsSync(directory)) return { ok: false, error: `the destination directory does not exist: ${directory}` };
|
|
411
|
-
const buffer = Buffer.from(data, 'base64');
|
|
412
|
-
writeFileSync(args.writeTo, buffer);
|
|
413
|
-
return { ok: true, action, path: args.writeTo, bytes: buffer.length, format: args.format ?? 'png' };
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
case 'status':
|
|
417
|
-
return { ok: true, action };
|
|
418
|
-
|
|
419
|
-
case 'close':
|
|
420
|
-
return { ok: true, action };
|
|
421
|
-
|
|
422
|
-
default:
|
|
423
|
-
return { ok: false, error: `unknown action ${JSON.stringify(String(action))}` };
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* Press one key.
|
|
429
|
-
*
|
|
430
|
-
* The engine needs the key, the code and the legacy virtual key code to produce a
|
|
431
|
-
* complete event; `Enter` also needs the character fields, which is why the table
|
|
432
|
-
* carries more than the key name.
|
|
433
|
-
*/
|
|
434
|
-
const KEYS = {
|
|
435
|
-
Enter: { code: 'Enter', keyCode: 13, text: '\r' },
|
|
436
|
-
Tab: { code: 'Tab', keyCode: 9 },
|
|
437
|
-
Escape: { code: 'Escape', keyCode: 27 },
|
|
438
|
-
Backspace: { code: 'Backspace', keyCode: 8 },
|
|
439
|
-
ArrowUp: { code: 'ArrowUp', keyCode: 38 },
|
|
440
|
-
ArrowDown: { code: 'ArrowDown', keyCode: 40 },
|
|
441
|
-
ArrowLeft: { code: 'ArrowLeft', keyCode: 37 },
|
|
442
|
-
ArrowRight: { code: 'ArrowRight', keyCode: 39 },
|
|
443
|
-
PageUp: { code: 'PageUp', keyCode: 33 },
|
|
444
|
-
PageDown: { code: 'PageDown', keyCode: 34 },
|
|
445
|
-
Home: { code: 'Home', keyCode: 36 },
|
|
446
|
-
End: { code: 'End', keyCode: 35 },
|
|
447
|
-
Delete: { code: 'Delete', keyCode: 46 },
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
async function keyPress(connection, name) {
|
|
451
|
-
const spec = KEYS[name] ?? { code: name, keyCode: 0 };
|
|
452
|
-
const base = { key: name, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode };
|
|
453
|
-
const down = { ...base, type: spec.text === undefined ? 'rawKeyDown' : 'keyDown' };
|
|
454
|
-
if (spec.text !== undefined) down.text = spec.text;
|
|
455
|
-
await connection.send('Input.dispatchKeyEvent', down);
|
|
456
|
-
await connection.send('Input.dispatchKeyEvent', { ...base, type: 'keyUp' });
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/** Exported for tests: the selector-and-box step, without an input dispatch. */
|
|
460
|
-
export const __internal = { boxOf, ensureVisible, settle, snapshot, KEYS };
|