Repository navigation
Expand file tree
/
Copy pathbrowser-runtime.mjs
More file actions
315 lines (295 loc) · 18.8 KB
/
Copy pathbrowser-runtime.mjs
File metadata and controls
315 lines (295 loc) · 18.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
import { findElement, inspectElement, readInputValue, compactAccessibilityTree } from './browser-actions.mjs';
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const bad = (message, statusCode = 400) => Object.assign(new Error(message), { statusCode });
const interactiveRoles = new Set(['button','link','textbox','searchbox','combobox','checkbox','radio','switch','slider','spinbutton','listbox','option','menuitem','menuitemcheckbox','menuitemradio','tab','treeitem']);
// Owns only sessions attached through this proxy, never every tab in the user's browser.
export class BrowserRuntime {
constructor(send) {
this.send = send;
this.records = new Map();
this.refs = new Map();
this.refKeys = new Map();
this.nextRef = 0;
this.locks = new Map();
this.dialogs = new Map();
this.downloads = new Map();
this.downloadTargets = new Map();
this.cursor = 0;
this.downloadEnabled = null;
this.defaultContexts = new Map();
this.frameCache = new Map();
}
reset() {
this.records.clear(); this.refs.clear(); this.refKeys.clear(); this.dialogs.clear();
for (const item of this.downloads.values()) if (item.state === 'inProgress') item.state = 'interrupted';
this.downloadTargets.clear(); this.downloadEnabled = null;
this.defaultContexts.clear();
this.frameCache.clear();
}
register(sessionId, targetId, rootTarget = targetId, parentSessionId = null) {
if (this.records.has(sessionId)) return this.records.get(sessionId).ready;
const record = { sessionId, targetId, rootTarget, parentSessionId, ready: null };
this.records.set(sessionId, record);
this.frameCache.delete(rootTarget);
record.ready = (async () => {
await this.send('Page.enable', {}, sessionId);
await this.send('Runtime.enable', {}, sessionId);
await this.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, filter: [{ type: 'iframe', exclude: false }, { exclude: true }] }, sessionId);
})();
// Keep rejected setup observable to callers while avoiding an event-handler rejection.
record.ready.catch(error => { record.error = error.message; });
return record.ready;
}
unregister(sessionId) {
const rootTarget = this.records.get(sessionId)?.rootTarget;
for (const child of [...this.records.values()]) if (child.parentSessionId === sessionId) this.unregister(child.sessionId);
this.records.delete(sessionId); this.dialogs.delete(sessionId);
for (const key of this.defaultContexts.keys()) if (key.startsWith(sessionId + ':')) this.defaultContexts.delete(key);
if (rootTarget) this.frameCache.delete(rootTarget);
}
forget(targetId) {
for (const [sid, record] of this.records) if (record.rootTarget === targetId) {
this.records.delete(sid); this.dialogs.delete(sid);
for (const key of this.defaultContexts.keys()) if (key.startsWith(sid + ':')) this.defaultContexts.delete(key);
}
for (const [ref, item] of this.refs) if (item.targetId === targetId) { this.refs.delete(ref); this.refKeys.delete(item.key); }
this.downloadTargets.delete(targetId);
this.frameCache.delete(targetId);
for (const [guid, item] of this.downloads) if (item.targetId === targetId) this.downloads.delete(guid);
}
event(msg) {
const p = msg.params || {};
const parent = this.records.get(msg.sessionId);
if (parent && ['Page.frameNavigated','Page.frameAttached','Page.frameDetached','Target.attachedToTarget','Target.detachedFromTarget'].includes(msg.method)) this.frameCache.delete(parent.rootTarget);
if (msg.method === 'Target.attachedToTarget' && parent && p.targetInfo?.type === 'iframe') {
this.register(p.sessionId, p.targetInfo.targetId, parent.rootTarget, msg.sessionId).catch(() => {});
} else if (msg.method === 'Target.detachedFromTarget') {
this.unregister(p.sessionId);
} else if (msg.method === 'Page.javascriptDialogOpening' && parent) {
this.dialogs.set(msg.sessionId, { targetId: parent.rootTarget, sessionId: msg.sessionId, type: p.type, message: p.message, defaultPrompt: p.defaultPrompt, url: p.url });
} else if (msg.method === 'Page.javascriptDialogClosed') {
this.dialogs.delete(msg.sessionId);
} else if (msg.method === 'Runtime.executionContextCreated' && parent && p.context?.auxData?.isDefault) {
this.defaultContexts.set(`${msg.sessionId}:${p.context.auxData.frameId}`, p.context.id);
} else if (msg.method === 'Runtime.executionContextDestroyed') {
for (const [key, value] of this.defaultContexts) if (key.startsWith(msg.sessionId + ':') && value === p.executionContextId) this.defaultContexts.delete(key);
} else if (msg.method === 'Runtime.executionContextsCleared') {
for (const key of this.defaultContexts.keys()) if (key.startsWith(msg.sessionId + ':')) this.defaultContexts.delete(key);
} else if (msg.method === 'Page.frameNavigated' && parent) {
this.downloadTargets.get(parent.rootTarget)?.add(p.frame.id);
} else if (msg.method === 'Browser.downloadWillBegin') {
const targetId = [...this.downloadTargets].find(([, frames]) => frames.has(p.frameId))?.[0];
if (targetId) {
this.downloads.set(p.guid, { targetId, cursor: ++this.cursor, guid: p.guid, url: p.url, suggestedFilename: p.suggestedFilename, state: 'inProgress', receivedBytes: 0, totalBytes: 0, path: null });
while (this.downloads.size > 200) this.downloads.delete(this.downloads.keys().next().value);
}
} else if (msg.method === 'Browser.downloadProgress') {
const item = this.downloads.get(p.guid);
if (item) Object.assign(item, { state: p.state, receivedBytes: p.receivedBytes, totalBytes: p.totalBytes, path: p.filePath || null });
}
}
async lock(targetId, action) {
const previous = this.locks.get(targetId) || Promise.resolve();
const next = previous.catch(() => {}).then(action);
this.locks.set(targetId, next);
try { return await next; }
finally { if (this.locks.get(targetId) === next) this.locks.delete(targetId); }
}
async frames(targetId) {
const cached = this.frameCache.get(targetId);
if (cached && Date.now() - cached.at < 250) return cached.frames;
// Child auto-attach events can arrive while a parent's setup is still resolving.
for (let pass = 0; pass < 3; pass++) {
const own = [...this.records.values()].filter(record => record.rootTarget === targetId);
await Promise.all(own.map(record => record.ready));
if (own.length === [...this.records.values()].filter(record => record.rootTarget === targetId).length) break;
}
const own = [...this.records.values()].filter(record => record.rootTarget === targetId);
if (!own.length) throw bad('页面 session 已失效', 409);
const byTarget = new Map(own.map(record => [record.targetId, record.sessionId]));
const result = new Map();
for (const record of own) {
let tree;
try { tree = (await this.send('Page.getFrameTree', {}, record.sessionId)).result.frameTree; }
catch (error) {
if (record.parentSessionId && /Session with given id not found|Target closed/.test(error.message)) { this.unregister(record.sessionId); continue; }
throw error;
}
const visit = (node, parentId = null, depth = 0) => {
if (!node || depth > 16) return;
const f = node.frame;
const old = result.get(f.id);
result.set(f.id, { ...f, parentId: f.parentId || parentId || old?.parentId || null, sessionId: byTarget.get(f.id) || record.sessionId });
for (const child of node.childFrames || []) visit(child, f.id, depth + 1);
};
visit(tree);
}
if (result.size > 100) throw bad('页面 frame 数量超过 100,请限定范围', 413);
this.frameCache.set(targetId, { at: Date.now(), frames: result });
return result;
}
async context(frame, mainWorld = false) {
if (mainWorld) {
const id = this.defaultContexts.get(`${frame.sessionId}:${frame.id}`);
if (!id) throw bad('frame 的页面执行环境尚未就绪', 409);
return id;
}
return (await this.send('Page.createIsolatedWorld', { frameId: frame.id, worldName: 'web-access' }, frame.sessionId)).result.executionContextId;
}
async snapshot(targetId, options = {}) {
const frames = await this.frames(targetId);
const main = [...frames.values()].find(frame => !frame.parentId);
if (options.frame === 'top') options.frame = main?.id;
if (options.frame && !frames.has(options.frame)) throw bad('未知 frame 参数');
const nodes = [], errors = [];
const selected = [...frames.values()].filter(frame => !options.frame || frame.id === options.frame);
for (const frame of selected) {
try {
const raw = (await this.send('Accessibility.getFullAXTree', { frameId: frame.id, depth: options.depth }, frame.sessionId)).result.nodes;
const lookup = new Map(raw.map(node => [node.nodeId, node]));
const depthOf = node => { let depth = 0, parent = node.parentId; const seen = new Set(); while (parent && lookup.has(parent) && !seen.has(parent)) { seen.add(parent); depth++; parent = lookup.get(parent).parentId; } return depth; };
let filtered = compactAccessibilityTree(raw, raw.length).nodes;
if (options.interactive) filtered = filtered.filter(node => interactiveRoles.has(node.role) || node.states.editable);
if (options.role) filtered = filtered.filter(node => node.role === options.role);
if (options.name) filtered = filtered.filter(node => node.name.toLowerCase().includes(options.name.toLowerCase()));
if (options.depth !== undefined) filtered = filtered.filter(node => depthOf(lookup.get(node.id)) <= options.depth);
for (const node of filtered) {
if (node.backendNodeId) {
const key = `${targetId}:${frame.id}:${frame.loaderId}:${node.backendNodeId}`;
let ref = this.refKeys.get(key);
if (!ref) { ref = `e${++this.nextRef}`; this.refKeys.set(key, ref); }
this.refs.set(ref, { key, targetId, frameId: frame.id, loaderId: frame.loaderId, backendNodeId: node.backendNodeId, sessionId: frame.sessionId });
node.ref = '@' + ref;
}
nodes.push({ ...node, id: `${frame.id}:${node.id}`, parentId: node.parentId ? `${frame.id}:${node.parentId}` : null, frameId: frame.id });
}
} catch (error) { errors.push({ frameId: frame.id, error: error.message }); }
}
// Bound memory; monotonic IDs are never recycled even after eviction.
while (this.refs.size > 10000) { const [ref, item] = this.refs.entries().next().value; this.refs.delete(ref); this.refKeys.delete(item.key); }
const limit = Math.max(1, Math.min(5000, Number(options.limit) || 1000));
return { nodes: nodes.slice(0, limit), total: nodes.length, truncated: nodes.length > limit, frames: selected.map(({ id, parentId, url }) => ({ id, parentId, url })), errors, incomplete: errors.length > 0 };
}
async withElement(targetId, options, action) {
const frames = await this.frames(targetId);
const main = [...frames.values()].find(frame => !frame.parentId);
let frame = options.frame ? frames.get(options.frame === 'top' ? main.id : options.frame) : main;
let backendNodeId = options.backendNodeId;
if (options.ref) {
const ref = this.refs.get(String(options.ref).replace(/^@/, ''));
if (!ref || ref.targetId !== targetId) throw bad('元素引用已失效或不属于此页面,请重新获取 snapshot', 409);
frame = frames.get(ref.frameId);
if (!frame || frame.loaderId !== ref.loaderId || frame.sessionId !== ref.sessionId) throw bad('引用所属文档已变化,请重新获取 snapshot', 409);
backendNodeId = ref.backendNodeId;
}
if (!frame) throw bad('未知 frame 参数');
let objectId;
try {
if (Number.isInteger(backendNodeId) && backendNodeId > 0) {
const contextId = await this.context(frame);
objectId = (await this.send('DOM.resolveNode', { backendNodeId, executionContextId: contextId }, frame.sessionId)).result.object?.objectId;
} else if (typeof options.selector === 'string' && options.selector) {
const candidates = options.frame ? [frame] : [...frames.values()];
for (const candidate of candidates) {
const contextId = await this.context(candidate);
const response = (await this.send('Runtime.evaluate', { expression: `(${findElement.toString()})(${JSON.stringify(options.selector)},false)`, contextId, returnByValue: false }, candidate.sessionId)).result;
if (response.exceptionDetails) throw bad(response.exceptionDetails.text);
if (response.result?.objectId) { objectId = response.result.objectId; frame = candidate; break; }
}
} else throw bad('需要 ref、selector 或 backendNodeId');
} catch (error) { if (options.ref) throw bad('引用对应元素已失效,请重新获取 snapshot', 409); throw error; }
if (!objectId) throw bad('未找到元素,请重新观察页面');
try { return await action({ objectId, frame, frames }); }
finally { await this.send('Runtime.releaseObject', { objectId }, frame.sessionId).catch(() => {}); }
}
async call(element, fn, args = []) {
const response = (await this.send('Runtime.callFunctionOn', { objectId: element.objectId, functionDeclaration: fn.toString(), arguments: args.map(value => ({ value })), returnByValue: true, awaitPromise: true }, element.frame.sessionId)).result;
if (response.exceptionDetails) throw bad(response.exceptionDetails.exception?.description?.split('\n')[0] || response.exceptionDetails.text);
return response.result?.value;
}
async actionable(element, kind, timeout = 5000) {
const deadline = Date.now() + Math.max(0, Math.min(30000, Number(timeout) || 0));
let previous, last;
do {
last = await this.call(element, inspectElement, [kind]);
if (last?.stale) throw bad(last.error, 409);
if (last?.terminal) throw bad(last.error);
if (last && !last.error && kind !== 'click') return last;
const stable = last && !last.error && previous && ['left','top','width','height'].every(key => Math.abs(last[key] - previous[key]) < 0.5);
if (stable) return last;
previous = last?.error ? null : last;
await delay(40);
} while (Date.now() < deadline);
const error = bad(last?.error || '元素位置尚未稳定', 408);
error.details = last; throw error;
}
async rootPoint(element, point) {
let frame = element.frame;
while (frame.parentId) {
const parent = element.frames.get(frame.parentId);
if (!parent) throw bad('父 frame 尚未就绪', 409);
const owner = (await this.send('DOM.getFrameOwner', { frameId: frame.id }, parent.sessionId)).result;
const objectId = (await this.send('DOM.resolveNode', { backendNodeId: owner.backendNodeId, executionContextId: await this.context(parent) }, parent.sessionId)).result.object.objectId;
try {
await this.call({ objectId, frame: parent }, function() { this.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); });
// DOM.getBoxModel can include page zoom; JS rects stay in CSS pixels like Input.
const bounds = await this.call({ objectId, frame: parent }, function() {
for (let node = this; node; node = node.parentElement || node.getRootNode()?.host) {
const style = getComputedStyle(node);
if (style.perspective !== 'none' || style.rotate !== 'none' && parseFloat(style.rotate) !== 0 || style.scale !== 'none' && style.scale.split(/\s+/).some(value => parseFloat(value) <= 0)) throw new Error('旋转、透视或反向缩放的 iframe 需要单独观察定位');
const transform = style.transform;
if (transform !== 'none') {
const matrix = new DOMMatrixReadOnly(transform);
if (!matrix.is2D || Math.abs(matrix.b) > 0.0001 || Math.abs(matrix.c) > 0.0001 || matrix.a <= 0 || matrix.d <= 0) throw new Error('旋转、倾斜或反向缩放的 iframe 需要单独观察定位');
}
}
const rect = this.getBoundingClientRect();
const sx = rect.width / this.offsetWidth, sy = rect.height / this.offsetHeight;
return { x: rect.left + this.clientLeft * sx, y: rect.top + this.clientTop * sy, width: this.clientWidth * sx, height: this.clientHeight * sy };
});
const contextId = await this.context(frame);
const dimensions = (await this.send('Runtime.evaluate', { expression: '[innerWidth,innerHeight]', contextId, returnByValue: true }, frame.sessionId)).result.result.value;
const x = point.x / dimensions[0], y = point.y / dimensions[1];
const mapped = { x: bounds.x + x * bounds.width, y: bounds.y + y * bounds.height };
const inspected = await this.call({ objectId, frame: parent }, inspectElement, ['click', mapped]);
if (inspected?.error) throw bad(inspected.error, 408);
point = mapped;
} finally { await this.send('Runtime.releaseObject', { objectId }, parent.sessionId).catch(() => {}); }
frame = parent;
}
return { x: point.x, y: point.y };
}
async verifyInput(element, expected) {
const actual = await this.call(element, readInputValue);
if (actual !== expected) throw bad('输入已发送,但控件内容未达到预期,请重新观察页面', 409);
return true;
}
async startDownloads(targetId) {
const frames = await this.frames(targetId);
this.downloadTargets.set(targetId, new Set(frames.keys()));
if (!this.downloadEnabled) {
this.downloadEnabled = this.send('Browser.setDownloadBehavior', { behavior: 'default', eventsEnabled: true }).catch(error => { this.downloadEnabled = null; throw error; });
}
await this.downloadEnabled;
return { cursor: this.cursor, monitoring: true };
}
async getDownloads(targetId, after = 0, wait = false, timeout = 15000) {
const deadline = Date.now() + Math.min(60000, Math.max(0, Number(timeout) || 0));
let items;
do {
items = [...this.downloads.values()].filter(item => item.targetId === targetId && item.cursor > after);
if (!wait || items.length && items.every(item => item.state !== 'inProgress')) return { state: items.some(item => ['canceled','interrupted'].includes(item.state)) ? 'failed' : items.length ? 'complete' : 'empty', downloads: items };
await delay(100);
} while (Date.now() < deadline);
return { state: 'timeout', downloads: items };
}
getDialog(targetId) { return [...this.dialogs.values()].find(item => item.targetId === targetId) || null; }
async handleDialog(targetId, options) {
const dialog = this.getDialog(targetId);
if (!dialog) throw bad('当前页面没有 JavaScript 对话框', 409);
if (typeof options.accept !== 'boolean') throw bad('accept 必须为 boolean');
await this.send('Page.handleJavaScriptDialog', { accept: options.accept, promptText: options.promptText }, dialog.sessionId);
this.dialogs.delete(dialog.sessionId);
return { handled: true, type: dialog.type };
}
}