browser-pilot 0.0.1
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/LICENSE +21 -0
- package/README.md +539 -0
- package/dist/actions.cjs +277 -0
- package/dist/actions.d.cts +33 -0
- package/dist/actions.d.ts +33 -0
- package/dist/actions.mjs +8 -0
- package/dist/browser.cjs +2765 -0
- package/dist/browser.d.cts +71 -0
- package/dist/browser.d.ts +71 -0
- package/dist/browser.mjs +19 -0
- package/dist/cdp.cjs +279 -0
- package/dist/cdp.d.cts +230 -0
- package/dist/cdp.d.ts +230 -0
- package/dist/cdp.mjs +10 -0
- package/dist/chunk-BCOZUKWS.mjs +251 -0
- package/dist/chunk-FI55U7JS.mjs +2108 -0
- package/dist/chunk-R3PS4PCM.mjs +207 -0
- package/dist/chunk-YEHK2XY3.mjs +250 -0
- package/dist/chunk-ZIQA4JOT.mjs +226 -0
- package/dist/cli.cjs +3587 -0
- package/dist/cli.d.cts +23 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.mjs +827 -0
- package/dist/client-7Nqka5MV.d.cts +53 -0
- package/dist/client-7Nqka5MV.d.ts +53 -0
- package/dist/index.cjs +3074 -0
- package/dist/index.d.cts +157 -0
- package/dist/index.d.ts +157 -0
- package/dist/index.mjs +64 -0
- package/dist/providers.cjs +238 -0
- package/dist/providers.d.cts +86 -0
- package/dist/providers.d.ts +86 -0
- package/dist/providers.mjs +16 -0
- package/dist/types-Cs89wle0.d.cts +925 -0
- package/dist/types-DL_-3BZk.d.ts +925 -0
- package/dist/types-D_uDqh0Z.d.cts +56 -0
- package/dist/types-D_uDqh0Z.d.ts +56 -0
- package/package.json +91 -0
|
@@ -0,0 +1,2108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCDPClient
|
|
3
|
+
} from "./chunk-BCOZUKWS.mjs";
|
|
4
|
+
import {
|
|
5
|
+
createProvider
|
|
6
|
+
} from "./chunk-R3PS4PCM.mjs";
|
|
7
|
+
import {
|
|
8
|
+
BatchExecutor
|
|
9
|
+
} from "./chunk-YEHK2XY3.mjs";
|
|
10
|
+
|
|
11
|
+
// src/network/interceptor.ts
|
|
12
|
+
var RequestInterceptor = class {
|
|
13
|
+
cdp;
|
|
14
|
+
enabled = false;
|
|
15
|
+
handlers = [];
|
|
16
|
+
pendingRequests = /* @__PURE__ */ new Map();
|
|
17
|
+
boundHandleRequestPaused;
|
|
18
|
+
boundHandleAuthRequired;
|
|
19
|
+
constructor(cdp) {
|
|
20
|
+
this.cdp = cdp;
|
|
21
|
+
this.boundHandleRequestPaused = this.handleRequestPaused.bind(this);
|
|
22
|
+
this.boundHandleAuthRequired = this.handleAuthRequired.bind(this);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Enable request interception with optional patterns
|
|
26
|
+
*/
|
|
27
|
+
async enable(patterns) {
|
|
28
|
+
if (this.enabled) return;
|
|
29
|
+
this.cdp.on("Fetch.requestPaused", this.boundHandleRequestPaused);
|
|
30
|
+
this.cdp.on("Fetch.authRequired", this.boundHandleAuthRequired);
|
|
31
|
+
await this.cdp.send("Fetch.enable", {
|
|
32
|
+
patterns: patterns?.map((p) => ({
|
|
33
|
+
urlPattern: p.urlPattern ?? "*",
|
|
34
|
+
resourceType: p.resourceType,
|
|
35
|
+
requestStage: p.requestStage ?? "Request"
|
|
36
|
+
})) ?? [{ urlPattern: "*" }],
|
|
37
|
+
handleAuthRequests: true
|
|
38
|
+
});
|
|
39
|
+
this.enabled = true;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Disable request interception
|
|
43
|
+
*/
|
|
44
|
+
async disable() {
|
|
45
|
+
if (!this.enabled) return;
|
|
46
|
+
await this.cdp.send("Fetch.disable");
|
|
47
|
+
this.cdp.off("Fetch.requestPaused", this.boundHandleRequestPaused);
|
|
48
|
+
this.cdp.off("Fetch.authRequired", this.boundHandleAuthRequired);
|
|
49
|
+
this.enabled = false;
|
|
50
|
+
this.handlers = [];
|
|
51
|
+
this.pendingRequests.clear();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Add a request handler
|
|
55
|
+
*/
|
|
56
|
+
addHandler(pattern, handler) {
|
|
57
|
+
const entry = { pattern, handler };
|
|
58
|
+
this.handlers.push(entry);
|
|
59
|
+
return () => {
|
|
60
|
+
const idx = this.handlers.indexOf(entry);
|
|
61
|
+
if (idx !== -1) this.handlers.splice(idx, 1);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Handle paused request from CDP
|
|
66
|
+
*/
|
|
67
|
+
async handleRequestPaused(params) {
|
|
68
|
+
const requestId = params["requestId"];
|
|
69
|
+
const request = params["request"];
|
|
70
|
+
const responseStatusCode = params["responseStatusCode"];
|
|
71
|
+
const responseHeaders = params["responseHeaders"];
|
|
72
|
+
const intercepted = {
|
|
73
|
+
requestId,
|
|
74
|
+
url: request["url"],
|
|
75
|
+
method: request["method"],
|
|
76
|
+
headers: request["headers"],
|
|
77
|
+
postData: request["postData"],
|
|
78
|
+
resourceType: params["resourceType"],
|
|
79
|
+
frameId: params["frameId"],
|
|
80
|
+
isNavigationRequest: params["isNavigationRequest"],
|
|
81
|
+
responseStatusCode,
|
|
82
|
+
responseHeaders: responseHeaders ? Object.fromEntries(responseHeaders.map((h) => [h.name, h.value])) : void 0
|
|
83
|
+
};
|
|
84
|
+
this.pendingRequests.set(requestId, { request: intercepted, handled: false });
|
|
85
|
+
const matchingHandler = this.handlers.find((h) => this.matchesPattern(intercepted, h.pattern));
|
|
86
|
+
if (matchingHandler) {
|
|
87
|
+
const actions = this.createActions(requestId);
|
|
88
|
+
try {
|
|
89
|
+
await matchingHandler.handler(intercepted, actions);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error("[RequestInterceptor] Handler error:", err);
|
|
92
|
+
if (!this.pendingRequests.get(requestId)?.handled) {
|
|
93
|
+
await actions.continue();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
await this.continueRequest(requestId);
|
|
98
|
+
}
|
|
99
|
+
this.pendingRequests.delete(requestId);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Handle auth challenge
|
|
103
|
+
*/
|
|
104
|
+
async handleAuthRequired(params) {
|
|
105
|
+
const requestId = params["requestId"];
|
|
106
|
+
await this.cdp.send("Fetch.continueWithAuth", {
|
|
107
|
+
requestId,
|
|
108
|
+
authChallengeResponse: { response: "CancelAuth" }
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Check if request matches pattern
|
|
113
|
+
*/
|
|
114
|
+
matchesPattern(request, pattern) {
|
|
115
|
+
if (pattern.resourceType && request.resourceType !== pattern.resourceType) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
if (pattern.urlPattern) {
|
|
119
|
+
const regex = this.globToRegex(pattern.urlPattern);
|
|
120
|
+
if (!regex.test(request.url)) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Convert glob pattern to regex
|
|
128
|
+
*/
|
|
129
|
+
globToRegex(pattern) {
|
|
130
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
131
|
+
return new RegExp(`^${escaped}$`);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Create actions object for handler
|
|
135
|
+
*/
|
|
136
|
+
createActions(requestId) {
|
|
137
|
+
const pending = this.pendingRequests.get(requestId);
|
|
138
|
+
const markHandled = () => {
|
|
139
|
+
if (pending) pending.handled = true;
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
continue: async (options) => {
|
|
143
|
+
markHandled();
|
|
144
|
+
await this.continueRequest(requestId, options);
|
|
145
|
+
},
|
|
146
|
+
fulfill: async (options) => {
|
|
147
|
+
markHandled();
|
|
148
|
+
await this.fulfillRequest(requestId, options);
|
|
149
|
+
},
|
|
150
|
+
fail: async (options) => {
|
|
151
|
+
markHandled();
|
|
152
|
+
await this.failRequest(requestId, options);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Continue a paused request
|
|
158
|
+
*/
|
|
159
|
+
async continueRequest(requestId, options) {
|
|
160
|
+
await this.cdp.send("Fetch.continueRequest", {
|
|
161
|
+
requestId,
|
|
162
|
+
url: options?.url,
|
|
163
|
+
method: options?.method,
|
|
164
|
+
headers: options?.headers ? Object.entries(options.headers).map(([name, value]) => ({ name, value })) : void 0,
|
|
165
|
+
postData: options?.postData ? btoa(options.postData) : void 0
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Fulfill a request with custom response
|
|
170
|
+
*/
|
|
171
|
+
async fulfillRequest(requestId, options) {
|
|
172
|
+
const headers = Object.entries(options.headers ?? {}).map(([name, value]) => ({
|
|
173
|
+
name,
|
|
174
|
+
value
|
|
175
|
+
}));
|
|
176
|
+
await this.cdp.send("Fetch.fulfillRequest", {
|
|
177
|
+
requestId,
|
|
178
|
+
responseCode: options.status,
|
|
179
|
+
responseHeaders: headers,
|
|
180
|
+
body: options.isBase64Encoded ? options.body : options.body ? btoa(options.body) : void 0
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Fail/abort a request
|
|
185
|
+
*/
|
|
186
|
+
async failRequest(requestId, options) {
|
|
187
|
+
await this.cdp.send("Fetch.failRequest", {
|
|
188
|
+
requestId,
|
|
189
|
+
errorReason: options?.reason ?? "BlockedByClient"
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// src/wait/strategies.ts
|
|
195
|
+
var DEEP_QUERY_SCRIPT = `
|
|
196
|
+
function deepQuery(selector, root = document) {
|
|
197
|
+
// Try direct query first (fastest path)
|
|
198
|
+
let el = root.querySelector(selector);
|
|
199
|
+
if (el) return el;
|
|
200
|
+
|
|
201
|
+
// Search in shadow roots recursively
|
|
202
|
+
const searchShadows = (node) => {
|
|
203
|
+
// Check if this node has a shadow root
|
|
204
|
+
if (node.shadowRoot) {
|
|
205
|
+
el = node.shadowRoot.querySelector(selector);
|
|
206
|
+
if (el) return el;
|
|
207
|
+
// Search children of shadow root
|
|
208
|
+
for (const child of node.shadowRoot.querySelectorAll('*')) {
|
|
209
|
+
el = searchShadows(child);
|
|
210
|
+
if (el) return el;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Search children that might have shadow roots
|
|
214
|
+
for (const child of node.querySelectorAll('*')) {
|
|
215
|
+
if (child.shadowRoot) {
|
|
216
|
+
el = searchShadows(child);
|
|
217
|
+
if (el) return el;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return searchShadows(root);
|
|
224
|
+
}
|
|
225
|
+
`;
|
|
226
|
+
async function isElementVisible(cdp, selector, contextId) {
|
|
227
|
+
const params = {
|
|
228
|
+
expression: `(() => {
|
|
229
|
+
${DEEP_QUERY_SCRIPT}
|
|
230
|
+
const el = deepQuery(${JSON.stringify(selector)});
|
|
231
|
+
if (!el) return false;
|
|
232
|
+
const style = getComputedStyle(el);
|
|
233
|
+
if (style.display === 'none') return false;
|
|
234
|
+
if (style.visibility === 'hidden') return false;
|
|
235
|
+
if (parseFloat(style.opacity) === 0) return false;
|
|
236
|
+
const rect = el.getBoundingClientRect();
|
|
237
|
+
return rect.width > 0 && rect.height > 0;
|
|
238
|
+
})()`,
|
|
239
|
+
returnByValue: true
|
|
240
|
+
};
|
|
241
|
+
if (contextId !== void 0) {
|
|
242
|
+
params["contextId"] = contextId;
|
|
243
|
+
}
|
|
244
|
+
const result = await cdp.send("Runtime.evaluate", params);
|
|
245
|
+
return result.result.value === true;
|
|
246
|
+
}
|
|
247
|
+
async function isElementAttached(cdp, selector, contextId) {
|
|
248
|
+
const params = {
|
|
249
|
+
expression: `(() => {
|
|
250
|
+
${DEEP_QUERY_SCRIPT}
|
|
251
|
+
return deepQuery(${JSON.stringify(selector)}) !== null;
|
|
252
|
+
})()`,
|
|
253
|
+
returnByValue: true
|
|
254
|
+
};
|
|
255
|
+
if (contextId !== void 0) {
|
|
256
|
+
params["contextId"] = contextId;
|
|
257
|
+
}
|
|
258
|
+
const result = await cdp.send("Runtime.evaluate", params);
|
|
259
|
+
return result.result.value === true;
|
|
260
|
+
}
|
|
261
|
+
function sleep(ms) {
|
|
262
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
263
|
+
}
|
|
264
|
+
async function waitForElement(cdp, selector, options = {}) {
|
|
265
|
+
const { state = "visible", timeout = 3e4, pollInterval = 100, contextId } = options;
|
|
266
|
+
const startTime = Date.now();
|
|
267
|
+
const deadline = startTime + timeout;
|
|
268
|
+
while (Date.now() < deadline) {
|
|
269
|
+
let conditionMet = false;
|
|
270
|
+
switch (state) {
|
|
271
|
+
case "visible":
|
|
272
|
+
conditionMet = await isElementVisible(cdp, selector, contextId);
|
|
273
|
+
break;
|
|
274
|
+
case "hidden":
|
|
275
|
+
conditionMet = !await isElementVisible(cdp, selector, contextId);
|
|
276
|
+
break;
|
|
277
|
+
case "attached":
|
|
278
|
+
conditionMet = await isElementAttached(cdp, selector, contextId);
|
|
279
|
+
break;
|
|
280
|
+
case "detached":
|
|
281
|
+
conditionMet = !await isElementAttached(cdp, selector, contextId);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
if (conditionMet) {
|
|
285
|
+
return { success: true, waitedMs: Date.now() - startTime };
|
|
286
|
+
}
|
|
287
|
+
await sleep(pollInterval);
|
|
288
|
+
}
|
|
289
|
+
return { success: false, waitedMs: Date.now() - startTime };
|
|
290
|
+
}
|
|
291
|
+
async function waitForAnyElement(cdp, selectors, options = {}) {
|
|
292
|
+
const { state = "visible", timeout = 3e4, pollInterval = 100, contextId } = options;
|
|
293
|
+
const startTime = Date.now();
|
|
294
|
+
const deadline = startTime + timeout;
|
|
295
|
+
while (Date.now() < deadline) {
|
|
296
|
+
for (const selector of selectors) {
|
|
297
|
+
let conditionMet = false;
|
|
298
|
+
switch (state) {
|
|
299
|
+
case "visible":
|
|
300
|
+
conditionMet = await isElementVisible(cdp, selector, contextId);
|
|
301
|
+
break;
|
|
302
|
+
case "hidden":
|
|
303
|
+
conditionMet = !await isElementVisible(cdp, selector, contextId);
|
|
304
|
+
break;
|
|
305
|
+
case "attached":
|
|
306
|
+
conditionMet = await isElementAttached(cdp, selector, contextId);
|
|
307
|
+
break;
|
|
308
|
+
case "detached":
|
|
309
|
+
conditionMet = !await isElementAttached(cdp, selector, contextId);
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
if (conditionMet) {
|
|
313
|
+
return { success: true, selector, waitedMs: Date.now() - startTime };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
await sleep(pollInterval);
|
|
317
|
+
}
|
|
318
|
+
return { success: false, waitedMs: Date.now() - startTime };
|
|
319
|
+
}
|
|
320
|
+
async function getCurrentUrl(cdp) {
|
|
321
|
+
const result = await cdp.send("Runtime.evaluate", {
|
|
322
|
+
expression: "location.href",
|
|
323
|
+
returnByValue: true
|
|
324
|
+
});
|
|
325
|
+
return result.result.value;
|
|
326
|
+
}
|
|
327
|
+
async function waitForNavigation(cdp, options = {}) {
|
|
328
|
+
const { timeout = 3e4, allowSameDocument = true } = options;
|
|
329
|
+
const startTime = Date.now();
|
|
330
|
+
let startUrl;
|
|
331
|
+
try {
|
|
332
|
+
startUrl = await getCurrentUrl(cdp);
|
|
333
|
+
} catch {
|
|
334
|
+
startUrl = "";
|
|
335
|
+
}
|
|
336
|
+
return new Promise((resolve) => {
|
|
337
|
+
let resolved = false;
|
|
338
|
+
const cleanup = [];
|
|
339
|
+
const done = (success) => {
|
|
340
|
+
if (resolved) return;
|
|
341
|
+
resolved = true;
|
|
342
|
+
for (const fn of cleanup) fn();
|
|
343
|
+
resolve({ success, waitedMs: Date.now() - startTime });
|
|
344
|
+
};
|
|
345
|
+
const timer = setTimeout(() => done(false), timeout);
|
|
346
|
+
cleanup.push(() => clearTimeout(timer));
|
|
347
|
+
const onLoad = () => done(true);
|
|
348
|
+
cdp.on("Page.loadEventFired", onLoad);
|
|
349
|
+
cleanup.push(() => cdp.off("Page.loadEventFired", onLoad));
|
|
350
|
+
const onFrameNavigated = (params) => {
|
|
351
|
+
const frame = params["frame"];
|
|
352
|
+
if (frame && !frame.parentId && frame.url !== startUrl) {
|
|
353
|
+
done(true);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
cdp.on("Page.frameNavigated", onFrameNavigated);
|
|
357
|
+
cleanup.push(() => cdp.off("Page.frameNavigated", onFrameNavigated));
|
|
358
|
+
if (allowSameDocument) {
|
|
359
|
+
const onSameDoc = () => done(true);
|
|
360
|
+
cdp.on("Page.navigatedWithinDocument", onSameDoc);
|
|
361
|
+
cleanup.push(() => cdp.off("Page.navigatedWithinDocument", onSameDoc));
|
|
362
|
+
}
|
|
363
|
+
const pollUrl = async () => {
|
|
364
|
+
while (!resolved && Date.now() < startTime + timeout) {
|
|
365
|
+
await sleep(100);
|
|
366
|
+
if (resolved) return;
|
|
367
|
+
try {
|
|
368
|
+
const currentUrl = await getCurrentUrl(cdp);
|
|
369
|
+
if (startUrl && currentUrl !== startUrl) {
|
|
370
|
+
done(true);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
pollUrl();
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
async function waitForNetworkIdle(cdp, options = {}) {
|
|
381
|
+
const { timeout = 3e4, idleTime = 500 } = options;
|
|
382
|
+
const startTime = Date.now();
|
|
383
|
+
await cdp.send("Network.enable");
|
|
384
|
+
return new Promise((resolve) => {
|
|
385
|
+
let inFlight = 0;
|
|
386
|
+
let idleTimer = null;
|
|
387
|
+
const timeoutTimer = setTimeout(() => {
|
|
388
|
+
cleanup();
|
|
389
|
+
resolve({ success: false, waitedMs: Date.now() - startTime });
|
|
390
|
+
}, timeout);
|
|
391
|
+
const checkIdle = () => {
|
|
392
|
+
if (inFlight === 0) {
|
|
393
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
394
|
+
idleTimer = setTimeout(() => {
|
|
395
|
+
cleanup();
|
|
396
|
+
resolve({ success: true, waitedMs: Date.now() - startTime });
|
|
397
|
+
}, idleTime);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
const onRequestStart = () => {
|
|
401
|
+
inFlight++;
|
|
402
|
+
if (idleTimer) {
|
|
403
|
+
clearTimeout(idleTimer);
|
|
404
|
+
idleTimer = null;
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
const onRequestEnd = () => {
|
|
408
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
409
|
+
checkIdle();
|
|
410
|
+
};
|
|
411
|
+
const cleanup = () => {
|
|
412
|
+
clearTimeout(timeoutTimer);
|
|
413
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
414
|
+
cdp.off("Network.requestWillBeSent", onRequestStart);
|
|
415
|
+
cdp.off("Network.loadingFinished", onRequestEnd);
|
|
416
|
+
cdp.off("Network.loadingFailed", onRequestEnd);
|
|
417
|
+
};
|
|
418
|
+
cdp.on("Network.requestWillBeSent", onRequestStart);
|
|
419
|
+
cdp.on("Network.loadingFinished", onRequestEnd);
|
|
420
|
+
cdp.on("Network.loadingFailed", onRequestEnd);
|
|
421
|
+
checkIdle();
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/browser/types.ts
|
|
426
|
+
var ElementNotFoundError = class extends Error {
|
|
427
|
+
selectors;
|
|
428
|
+
constructor(selectors) {
|
|
429
|
+
const selectorList = Array.isArray(selectors) ? selectors : [selectors];
|
|
430
|
+
super(`Element not found: ${selectorList.join(", ")}`);
|
|
431
|
+
this.name = "ElementNotFoundError";
|
|
432
|
+
this.selectors = selectorList;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
var TimeoutError = class extends Error {
|
|
436
|
+
constructor(message = "Operation timed out") {
|
|
437
|
+
super(message);
|
|
438
|
+
this.name = "TimeoutError";
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
var NavigationError = class extends Error {
|
|
442
|
+
constructor(message) {
|
|
443
|
+
super(message);
|
|
444
|
+
this.name = "NavigationError";
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// src/browser/page.ts
|
|
449
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
450
|
+
var Page = class {
|
|
451
|
+
cdp;
|
|
452
|
+
rootNodeId = null;
|
|
453
|
+
batchExecutor;
|
|
454
|
+
emulationState = {};
|
|
455
|
+
interceptor = null;
|
|
456
|
+
consoleHandlers = /* @__PURE__ */ new Set();
|
|
457
|
+
errorHandlers = /* @__PURE__ */ new Set();
|
|
458
|
+
dialogHandler = null;
|
|
459
|
+
consoleEnabled = false;
|
|
460
|
+
/** Map of ref (e.g., "e4") to backendNodeId for ref-based selectors */
|
|
461
|
+
refMap = /* @__PURE__ */ new Map();
|
|
462
|
+
/** Current frame context (null = main frame) */
|
|
463
|
+
currentFrame = null;
|
|
464
|
+
/** Stored frame document node IDs for context switching */
|
|
465
|
+
frameContexts = /* @__PURE__ */ new Map();
|
|
466
|
+
/** Map of frameId → executionContextId for JS evaluation in frames */
|
|
467
|
+
frameExecutionContexts = /* @__PURE__ */ new Map();
|
|
468
|
+
/** Current frame's execution context ID (null = main frame default) */
|
|
469
|
+
currentFrameContextId = null;
|
|
470
|
+
constructor(cdp) {
|
|
471
|
+
this.cdp = cdp;
|
|
472
|
+
this.batchExecutor = new BatchExecutor(this);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Initialize the page (enable required CDP domains)
|
|
476
|
+
*/
|
|
477
|
+
async init() {
|
|
478
|
+
this.cdp.on("Runtime.executionContextCreated", (params) => {
|
|
479
|
+
const context = params["context"];
|
|
480
|
+
if (context.auxData?.frameId && context.auxData?.isDefault) {
|
|
481
|
+
this.frameExecutionContexts.set(context.auxData.frameId, context.id);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
this.cdp.on("Runtime.executionContextDestroyed", (params) => {
|
|
485
|
+
const contextId = params["executionContextId"];
|
|
486
|
+
for (const [frameId, ctxId] of this.frameExecutionContexts.entries()) {
|
|
487
|
+
if (ctxId === contextId) {
|
|
488
|
+
this.frameExecutionContexts.delete(frameId);
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
this.cdp.on("Page.javascriptDialogOpening", this.handleDialogOpening.bind(this));
|
|
494
|
+
await Promise.all([
|
|
495
|
+
this.cdp.send("Page.enable"),
|
|
496
|
+
this.cdp.send("DOM.enable"),
|
|
497
|
+
this.cdp.send("Runtime.enable"),
|
|
498
|
+
this.cdp.send("Network.enable")
|
|
499
|
+
]);
|
|
500
|
+
}
|
|
501
|
+
// ============ Navigation ============
|
|
502
|
+
/**
|
|
503
|
+
* Navigate to a URL
|
|
504
|
+
*/
|
|
505
|
+
async goto(url, options = {}) {
|
|
506
|
+
const { timeout = DEFAULT_TIMEOUT } = options;
|
|
507
|
+
const navPromise = this.waitForNavigation({ timeout });
|
|
508
|
+
await this.cdp.send("Page.navigate", { url });
|
|
509
|
+
const result = await navPromise;
|
|
510
|
+
if (!result) {
|
|
511
|
+
throw new TimeoutError(`Navigation to ${url} timed out after ${timeout}ms`);
|
|
512
|
+
}
|
|
513
|
+
this.rootNodeId = null;
|
|
514
|
+
this.refMap.clear();
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Get the current URL
|
|
518
|
+
*/
|
|
519
|
+
async url() {
|
|
520
|
+
const result = await this.cdp.send("Runtime.evaluate", {
|
|
521
|
+
expression: "location.href",
|
|
522
|
+
returnByValue: true
|
|
523
|
+
});
|
|
524
|
+
return result.result.value;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Get the page title
|
|
528
|
+
*/
|
|
529
|
+
async title() {
|
|
530
|
+
const result = await this.cdp.send("Runtime.evaluate", {
|
|
531
|
+
expression: "document.title",
|
|
532
|
+
returnByValue: true
|
|
533
|
+
});
|
|
534
|
+
return result.result.value;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Reload the page
|
|
538
|
+
*/
|
|
539
|
+
async reload(options = {}) {
|
|
540
|
+
const { timeout = DEFAULT_TIMEOUT } = options;
|
|
541
|
+
const navPromise = this.waitForNavigation({ timeout });
|
|
542
|
+
await this.cdp.send("Page.reload");
|
|
543
|
+
await navPromise;
|
|
544
|
+
this.rootNodeId = null;
|
|
545
|
+
this.refMap.clear();
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Go back in history
|
|
549
|
+
*/
|
|
550
|
+
async goBack(options = {}) {
|
|
551
|
+
const { timeout = DEFAULT_TIMEOUT } = options;
|
|
552
|
+
const history = await this.cdp.send("Page.getNavigationHistory");
|
|
553
|
+
if (history.currentIndex <= 0) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const navPromise = this.waitForNavigation({ timeout });
|
|
557
|
+
await this.cdp.send("Page.navigateToHistoryEntry", {
|
|
558
|
+
entryId: history.entries[history.currentIndex - 1].id
|
|
559
|
+
});
|
|
560
|
+
await navPromise;
|
|
561
|
+
this.rootNodeId = null;
|
|
562
|
+
this.refMap.clear();
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Go forward in history
|
|
566
|
+
*/
|
|
567
|
+
async goForward(options = {}) {
|
|
568
|
+
const { timeout = DEFAULT_TIMEOUT } = options;
|
|
569
|
+
const history = await this.cdp.send("Page.getNavigationHistory");
|
|
570
|
+
if (history.currentIndex >= history.entries.length - 1) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const navPromise = this.waitForNavigation({ timeout });
|
|
574
|
+
await this.cdp.send("Page.navigateToHistoryEntry", {
|
|
575
|
+
entryId: history.entries[history.currentIndex + 1].id
|
|
576
|
+
});
|
|
577
|
+
await navPromise;
|
|
578
|
+
this.rootNodeId = null;
|
|
579
|
+
this.refMap.clear();
|
|
580
|
+
}
|
|
581
|
+
// ============ Core Actions ============
|
|
582
|
+
/**
|
|
583
|
+
* Click an element (supports multi-selector)
|
|
584
|
+
*
|
|
585
|
+
* Uses CDP mouse events for regular elements. For form submit buttons,
|
|
586
|
+
* uses dispatchEvent to reliably trigger form submission in headless Chrome.
|
|
587
|
+
*/
|
|
588
|
+
async click(selector, options = {}) {
|
|
589
|
+
return this.withStaleNodeRetry(async () => {
|
|
590
|
+
const element = await this.findElement(selector, options);
|
|
591
|
+
if (!element) {
|
|
592
|
+
if (options.optional) return false;
|
|
593
|
+
throw new ElementNotFoundError(selector);
|
|
594
|
+
}
|
|
595
|
+
await this.scrollIntoView(element.nodeId);
|
|
596
|
+
const submitResult = await this.evaluateInFrame(
|
|
597
|
+
`(() => {
|
|
598
|
+
const el = document.querySelector(${JSON.stringify(element.selector)});
|
|
599
|
+
if (!el) return { isSubmit: false };
|
|
600
|
+
|
|
601
|
+
// Check if this is a form submit button
|
|
602
|
+
const isSubmitButton = (el instanceof HTMLButtonElement && (el.type === 'submit' || (el.form && el.type !== 'button'))) ||
|
|
603
|
+
(el instanceof HTMLInputElement && el.type === 'submit');
|
|
604
|
+
|
|
605
|
+
if (isSubmitButton && el.form) {
|
|
606
|
+
// Dispatch submit event directly - works reliably in headless Chrome
|
|
607
|
+
el.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
608
|
+
return { isSubmit: true };
|
|
609
|
+
}
|
|
610
|
+
return { isSubmit: false };
|
|
611
|
+
})()`
|
|
612
|
+
);
|
|
613
|
+
const isSubmit = submitResult.result.value?.isSubmit;
|
|
614
|
+
if (!isSubmit) {
|
|
615
|
+
await this.clickElement(element.nodeId);
|
|
616
|
+
}
|
|
617
|
+
return true;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Fill an input field (clears first by default)
|
|
622
|
+
*/
|
|
623
|
+
async fill(selector, value, options = {}) {
|
|
624
|
+
const { clear = true } = options;
|
|
625
|
+
return this.withStaleNodeRetry(async () => {
|
|
626
|
+
const element = await this.findElement(selector, options);
|
|
627
|
+
if (!element) {
|
|
628
|
+
if (options.optional) return false;
|
|
629
|
+
throw new ElementNotFoundError(selector);
|
|
630
|
+
}
|
|
631
|
+
await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
|
|
632
|
+
if (clear) {
|
|
633
|
+
await this.evaluateInFrame(
|
|
634
|
+
`(() => {
|
|
635
|
+
const el = document.querySelector(${JSON.stringify(element.selector)});
|
|
636
|
+
if (el) {
|
|
637
|
+
el.value = '';
|
|
638
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
639
|
+
}
|
|
640
|
+
})()`
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
await this.cdp.send("Input.insertText", { text: value });
|
|
644
|
+
await this.evaluateInFrame(
|
|
645
|
+
`(() => {
|
|
646
|
+
const el = document.querySelector(${JSON.stringify(element.selector)});
|
|
647
|
+
if (el) {
|
|
648
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
649
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
650
|
+
}
|
|
651
|
+
})()`
|
|
652
|
+
);
|
|
653
|
+
return true;
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Type text character by character (for autocomplete fields, etc.)
|
|
658
|
+
*/
|
|
659
|
+
async type(selector, text, options = {}) {
|
|
660
|
+
const { delay = 50 } = options;
|
|
661
|
+
const element = await this.findElement(selector, options);
|
|
662
|
+
if (!element) {
|
|
663
|
+
if (options.optional) return false;
|
|
664
|
+
throw new ElementNotFoundError(selector);
|
|
665
|
+
}
|
|
666
|
+
await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
|
|
667
|
+
for (const char of text) {
|
|
668
|
+
await this.cdp.send("Input.dispatchKeyEvent", {
|
|
669
|
+
type: "keyDown",
|
|
670
|
+
key: char,
|
|
671
|
+
text: char
|
|
672
|
+
});
|
|
673
|
+
await this.cdp.send("Input.dispatchKeyEvent", {
|
|
674
|
+
type: "keyUp",
|
|
675
|
+
key: char
|
|
676
|
+
});
|
|
677
|
+
if (delay > 0) {
|
|
678
|
+
await sleep2(delay);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
async select(selectorOrConfig, valueOrOptions, maybeOptions) {
|
|
684
|
+
if (typeof selectorOrConfig === "object" && !Array.isArray(selectorOrConfig) && "trigger" in selectorOrConfig) {
|
|
685
|
+
return this.selectCustom(selectorOrConfig, valueOrOptions);
|
|
686
|
+
}
|
|
687
|
+
const selector = selectorOrConfig;
|
|
688
|
+
const value = valueOrOptions;
|
|
689
|
+
const options = maybeOptions ?? {};
|
|
690
|
+
const element = await this.findElement(selector, options);
|
|
691
|
+
if (!element) {
|
|
692
|
+
if (options.optional) return false;
|
|
693
|
+
throw new ElementNotFoundError(selector);
|
|
694
|
+
}
|
|
695
|
+
const values = Array.isArray(value) ? value : [value];
|
|
696
|
+
await this.cdp.send("Runtime.evaluate", {
|
|
697
|
+
expression: `(() => {
|
|
698
|
+
const el = document.querySelector(${JSON.stringify(element.selector)});
|
|
699
|
+
if (!el || el.tagName !== 'SELECT') return false;
|
|
700
|
+
const values = ${JSON.stringify(values)};
|
|
701
|
+
for (const opt of el.options) {
|
|
702
|
+
opt.selected = values.includes(opt.value) || values.includes(opt.text);
|
|
703
|
+
}
|
|
704
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
705
|
+
return true;
|
|
706
|
+
})()`,
|
|
707
|
+
returnByValue: true
|
|
708
|
+
});
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Handle custom (non-native) select/dropdown components
|
|
713
|
+
*/
|
|
714
|
+
async selectCustom(config, options = {}) {
|
|
715
|
+
const { trigger, option, value, match = "text" } = config;
|
|
716
|
+
await this.click(trigger, options);
|
|
717
|
+
await sleep2(100);
|
|
718
|
+
let optionSelector;
|
|
719
|
+
const optionSelectors = Array.isArray(option) ? option : [option];
|
|
720
|
+
if (match === "contains") {
|
|
721
|
+
optionSelector = optionSelectors.map((s) => `${s}:has-text("${value}")`).join(", ");
|
|
722
|
+
} else if (match === "value") {
|
|
723
|
+
optionSelector = optionSelectors.map((s) => `${s}[data-value="${value}"], ${s}[value="${value}"]`).join(", ");
|
|
724
|
+
} else {
|
|
725
|
+
optionSelector = optionSelectors.map((s) => `${s}`).join(", ");
|
|
726
|
+
}
|
|
727
|
+
const result = await this.cdp.send("Runtime.evaluate", {
|
|
728
|
+
expression: `(() => {
|
|
729
|
+
const options = document.querySelectorAll(${JSON.stringify(optionSelector)});
|
|
730
|
+
for (const opt of options) {
|
|
731
|
+
const text = opt.textContent?.trim();
|
|
732
|
+
if (${match === "text" ? `text === ${JSON.stringify(value)}` : match === "contains" ? `text?.includes(${JSON.stringify(value)})` : "true"}) {
|
|
733
|
+
opt.click();
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return false;
|
|
738
|
+
})()`,
|
|
739
|
+
returnByValue: true
|
|
740
|
+
});
|
|
741
|
+
if (!result.result.value) {
|
|
742
|
+
if (options.optional) return false;
|
|
743
|
+
throw new ElementNotFoundError(`Option with ${match} "${value}"`);
|
|
744
|
+
}
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Check a checkbox or radio button
|
|
749
|
+
*/
|
|
750
|
+
async check(selector, options = {}) {
|
|
751
|
+
const element = await this.findElement(selector, options);
|
|
752
|
+
if (!element) {
|
|
753
|
+
if (options.optional) return false;
|
|
754
|
+
throw new ElementNotFoundError(selector);
|
|
755
|
+
}
|
|
756
|
+
const result = await this.cdp.send("Runtime.evaluate", {
|
|
757
|
+
expression: `(() => {
|
|
758
|
+
const el = document.querySelector(${JSON.stringify(element.selector)});
|
|
759
|
+
if (!el) return false;
|
|
760
|
+
if (!el.checked) el.click();
|
|
761
|
+
return true;
|
|
762
|
+
})()`,
|
|
763
|
+
returnByValue: true
|
|
764
|
+
});
|
|
765
|
+
return result.result.value;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Uncheck a checkbox
|
|
769
|
+
*/
|
|
770
|
+
async uncheck(selector, options = {}) {
|
|
771
|
+
const element = await this.findElement(selector, options);
|
|
772
|
+
if (!element) {
|
|
773
|
+
if (options.optional) return false;
|
|
774
|
+
throw new ElementNotFoundError(selector);
|
|
775
|
+
}
|
|
776
|
+
const result = await this.cdp.send("Runtime.evaluate", {
|
|
777
|
+
expression: `(() => {
|
|
778
|
+
const el = document.querySelector(${JSON.stringify(element.selector)});
|
|
779
|
+
if (!el) return false;
|
|
780
|
+
if (el.checked) el.click();
|
|
781
|
+
return true;
|
|
782
|
+
})()`,
|
|
783
|
+
returnByValue: true
|
|
784
|
+
});
|
|
785
|
+
return result.result.value;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Submit a form (tries Enter key first, then click)
|
|
789
|
+
*
|
|
790
|
+
* Navigation waiting behavior:
|
|
791
|
+
* - 'auto' (default): Attempt to detect navigation for 1 second, then assume client-side handling
|
|
792
|
+
* - true: Wait for full navigation (traditional forms)
|
|
793
|
+
* - false: Return immediately (AJAX forms where you'll wait for something else)
|
|
794
|
+
*/
|
|
795
|
+
async submit(selector, options = {}) {
|
|
796
|
+
const { method = "enter+click", waitForNavigation: shouldWait = "auto" } = options;
|
|
797
|
+
const element = await this.findElement(selector, options);
|
|
798
|
+
if (!element) {
|
|
799
|
+
if (options.optional) return false;
|
|
800
|
+
throw new ElementNotFoundError(selector);
|
|
801
|
+
}
|
|
802
|
+
await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
|
|
803
|
+
if (method.includes("enter")) {
|
|
804
|
+
await this.press("Enter");
|
|
805
|
+
if (shouldWait === true) {
|
|
806
|
+
try {
|
|
807
|
+
await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT });
|
|
808
|
+
return true;
|
|
809
|
+
} catch {
|
|
810
|
+
}
|
|
811
|
+
} else if (shouldWait === "auto") {
|
|
812
|
+
const navigationDetected = await Promise.race([
|
|
813
|
+
this.waitForNavigation({ timeout: 1e3, optional: true }).then(
|
|
814
|
+
(success) => success ? "nav" : null
|
|
815
|
+
),
|
|
816
|
+
sleep2(500).then(() => "timeout")
|
|
817
|
+
]);
|
|
818
|
+
if (navigationDetected === "nav") {
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
} else {
|
|
822
|
+
if (method === "enter") return true;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
if (method.includes("click")) {
|
|
826
|
+
await this.click(element.selector, { ...options, optional: false });
|
|
827
|
+
if (shouldWait === true) {
|
|
828
|
+
await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT });
|
|
829
|
+
} else if (shouldWait === "auto") {
|
|
830
|
+
await sleep2(100);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Press a key
|
|
837
|
+
*/
|
|
838
|
+
async press(key) {
|
|
839
|
+
const keyMap = {
|
|
840
|
+
Enter: { key: "Enter", code: "Enter", keyCode: 13 },
|
|
841
|
+
Tab: { key: "Tab", code: "Tab", keyCode: 9 },
|
|
842
|
+
Escape: { key: "Escape", code: "Escape", keyCode: 27 },
|
|
843
|
+
Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
|
|
844
|
+
Delete: { key: "Delete", code: "Delete", keyCode: 46 },
|
|
845
|
+
ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
|
|
846
|
+
ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
|
|
847
|
+
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
|
|
848
|
+
ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 }
|
|
849
|
+
};
|
|
850
|
+
const keyInfo = keyMap[key] ?? { key, code: key, keyCode: 0 };
|
|
851
|
+
await this.cdp.send("Input.dispatchKeyEvent", {
|
|
852
|
+
type: "keyDown",
|
|
853
|
+
key: keyInfo.key,
|
|
854
|
+
code: keyInfo.code,
|
|
855
|
+
windowsVirtualKeyCode: keyInfo.keyCode
|
|
856
|
+
});
|
|
857
|
+
await this.cdp.send("Input.dispatchKeyEvent", {
|
|
858
|
+
type: "keyUp",
|
|
859
|
+
key: keyInfo.key,
|
|
860
|
+
code: keyInfo.code,
|
|
861
|
+
windowsVirtualKeyCode: keyInfo.keyCode
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Focus an element
|
|
866
|
+
*/
|
|
867
|
+
async focus(selector, options = {}) {
|
|
868
|
+
const element = await this.findElement(selector, options);
|
|
869
|
+
if (!element) {
|
|
870
|
+
if (options.optional) return false;
|
|
871
|
+
throw new ElementNotFoundError(selector);
|
|
872
|
+
}
|
|
873
|
+
await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
|
|
874
|
+
return true;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Hover over an element
|
|
878
|
+
*/
|
|
879
|
+
async hover(selector, options = {}) {
|
|
880
|
+
return this.withStaleNodeRetry(async () => {
|
|
881
|
+
const element = await this.findElement(selector, options);
|
|
882
|
+
if (!element) {
|
|
883
|
+
if (options.optional) return false;
|
|
884
|
+
throw new ElementNotFoundError(selector);
|
|
885
|
+
}
|
|
886
|
+
await this.scrollIntoView(element.nodeId);
|
|
887
|
+
const box = await this.getBoxModel(element.nodeId);
|
|
888
|
+
if (!box) {
|
|
889
|
+
if (options.optional) return false;
|
|
890
|
+
throw new Error("Could not get element box model");
|
|
891
|
+
}
|
|
892
|
+
const x = box.content[0] + box.width / 2;
|
|
893
|
+
const y = box.content[1] + box.height / 2;
|
|
894
|
+
await this.cdp.send("Input.dispatchMouseEvent", {
|
|
895
|
+
type: "mouseMoved",
|
|
896
|
+
x,
|
|
897
|
+
y
|
|
898
|
+
});
|
|
899
|
+
return true;
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Scroll an element into view (or scroll to coordinates)
|
|
904
|
+
*/
|
|
905
|
+
async scroll(selector, options = {}) {
|
|
906
|
+
const { x, y } = options;
|
|
907
|
+
if (x !== void 0 || y !== void 0) {
|
|
908
|
+
await this.cdp.send("Runtime.evaluate", {
|
|
909
|
+
expression: `window.scrollTo(${x ?? 0}, ${y ?? 0})`
|
|
910
|
+
});
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
const element = await this.findElement(selector, options);
|
|
914
|
+
if (!element) {
|
|
915
|
+
if (options.optional) return false;
|
|
916
|
+
throw new ElementNotFoundError(selector);
|
|
917
|
+
}
|
|
918
|
+
await this.scrollIntoView(element.nodeId);
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
// ============ Frame Navigation ============
|
|
922
|
+
/**
|
|
923
|
+
* Switch context to an iframe for subsequent actions
|
|
924
|
+
* @param selector - Selector for the iframe element
|
|
925
|
+
* @param options - Optional timeout and optional flags
|
|
926
|
+
* @returns true if switch succeeded
|
|
927
|
+
*/
|
|
928
|
+
async switchToFrame(selector, options = {}) {
|
|
929
|
+
const element = await this.findElement(selector, options);
|
|
930
|
+
if (!element) {
|
|
931
|
+
if (options.optional) return false;
|
|
932
|
+
throw new ElementNotFoundError(selector);
|
|
933
|
+
}
|
|
934
|
+
const descResult = await this.cdp.send("DOM.describeNode", {
|
|
935
|
+
nodeId: element.nodeId,
|
|
936
|
+
depth: 1
|
|
937
|
+
});
|
|
938
|
+
if (!descResult.node.contentDocument) {
|
|
939
|
+
if (options.optional) return false;
|
|
940
|
+
throw new Error(
|
|
941
|
+
"Cannot access iframe content. This may be a cross-origin iframe which requires different handling."
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
const frameKey = Array.isArray(selector) ? selector[0] : selector;
|
|
945
|
+
this.frameContexts.set(frameKey, descResult.node.contentDocument.nodeId);
|
|
946
|
+
this.currentFrame = frameKey;
|
|
947
|
+
this.rootNodeId = descResult.node.contentDocument.nodeId;
|
|
948
|
+
if (descResult.node.frameId) {
|
|
949
|
+
let contextId = this.frameExecutionContexts.get(descResult.node.frameId);
|
|
950
|
+
if (!contextId) {
|
|
951
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
952
|
+
contextId = this.frameExecutionContexts.get(descResult.node.frameId);
|
|
953
|
+
}
|
|
954
|
+
if (contextId) {
|
|
955
|
+
this.currentFrameContextId = contextId;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
this.refMap.clear();
|
|
959
|
+
return true;
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Switch back to the main document from an iframe
|
|
963
|
+
*/
|
|
964
|
+
async switchToMain() {
|
|
965
|
+
this.currentFrame = null;
|
|
966
|
+
this.rootNodeId = null;
|
|
967
|
+
this.currentFrameContextId = null;
|
|
968
|
+
this.refMap.clear();
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Get the current frame context (null = main frame)
|
|
972
|
+
*/
|
|
973
|
+
getCurrentFrame() {
|
|
974
|
+
return this.currentFrame;
|
|
975
|
+
}
|
|
976
|
+
// ============ Waiting ============
|
|
977
|
+
/**
|
|
978
|
+
* Wait for an element to reach a state
|
|
979
|
+
*/
|
|
980
|
+
async waitFor(selector, options = {}) {
|
|
981
|
+
const { timeout = DEFAULT_TIMEOUT, state = "visible" } = options;
|
|
982
|
+
const selectors = Array.isArray(selector) ? selector : [selector];
|
|
983
|
+
const result = await waitForAnyElement(this.cdp, selectors, {
|
|
984
|
+
state,
|
|
985
|
+
timeout,
|
|
986
|
+
contextId: this.currentFrameContextId ?? void 0
|
|
987
|
+
});
|
|
988
|
+
if (!result.success && !options.optional) {
|
|
989
|
+
throw new TimeoutError(`Timeout waiting for ${selectors.join(" or ")} to be ${state}`);
|
|
990
|
+
}
|
|
991
|
+
return result.success;
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Wait for navigation to complete
|
|
995
|
+
*/
|
|
996
|
+
async waitForNavigation(options = {}) {
|
|
997
|
+
const { timeout = DEFAULT_TIMEOUT } = options;
|
|
998
|
+
const result = await waitForNavigation(this.cdp, { timeout });
|
|
999
|
+
if (!result.success && !options.optional) {
|
|
1000
|
+
throw new TimeoutError("Navigation timeout");
|
|
1001
|
+
}
|
|
1002
|
+
this.rootNodeId = null;
|
|
1003
|
+
this.refMap.clear();
|
|
1004
|
+
return result.success;
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Wait for network to be idle
|
|
1008
|
+
*/
|
|
1009
|
+
async waitForNetworkIdle(options = {}) {
|
|
1010
|
+
const { timeout = DEFAULT_TIMEOUT, idleTime = 500 } = options;
|
|
1011
|
+
const result = await waitForNetworkIdle(this.cdp, { timeout, idleTime });
|
|
1012
|
+
if (!result.success && !options.optional) {
|
|
1013
|
+
throw new TimeoutError("Network idle timeout");
|
|
1014
|
+
}
|
|
1015
|
+
return result.success;
|
|
1016
|
+
}
|
|
1017
|
+
// ============ JavaScript Execution ============
|
|
1018
|
+
/**
|
|
1019
|
+
* Evaluate JavaScript in the page context (or current frame context if in iframe)
|
|
1020
|
+
*/
|
|
1021
|
+
async evaluate(expression, ...args) {
|
|
1022
|
+
let script;
|
|
1023
|
+
if (typeof expression === "function") {
|
|
1024
|
+
const argString = args.map((a) => JSON.stringify(a)).join(", ");
|
|
1025
|
+
script = `(${expression.toString()})(${argString})`;
|
|
1026
|
+
} else {
|
|
1027
|
+
script = expression;
|
|
1028
|
+
}
|
|
1029
|
+
const params = {
|
|
1030
|
+
expression: script,
|
|
1031
|
+
returnByValue: true,
|
|
1032
|
+
awaitPromise: true
|
|
1033
|
+
};
|
|
1034
|
+
if (this.currentFrameContextId !== null) {
|
|
1035
|
+
params["contextId"] = this.currentFrameContextId;
|
|
1036
|
+
}
|
|
1037
|
+
const result = await this.cdp.send("Runtime.evaluate", params);
|
|
1038
|
+
if (result.exceptionDetails) {
|
|
1039
|
+
throw new Error(`Evaluation failed: ${result.exceptionDetails.text}`);
|
|
1040
|
+
}
|
|
1041
|
+
return result.result.value;
|
|
1042
|
+
}
|
|
1043
|
+
// ============ Screenshots ============
|
|
1044
|
+
/**
|
|
1045
|
+
* Take a screenshot
|
|
1046
|
+
*/
|
|
1047
|
+
async screenshot(options = {}) {
|
|
1048
|
+
const { format = "png", quality, fullPage = false } = options;
|
|
1049
|
+
let clip;
|
|
1050
|
+
if (fullPage) {
|
|
1051
|
+
const metrics = await this.cdp.send("Page.getLayoutMetrics");
|
|
1052
|
+
clip = {
|
|
1053
|
+
x: 0,
|
|
1054
|
+
y: 0,
|
|
1055
|
+
width: metrics.contentSize.width,
|
|
1056
|
+
height: metrics.contentSize.height,
|
|
1057
|
+
scale: 1
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
const result = await this.cdp.send("Page.captureScreenshot", {
|
|
1061
|
+
format,
|
|
1062
|
+
quality: format === "png" ? void 0 : quality,
|
|
1063
|
+
clip,
|
|
1064
|
+
captureBeyondViewport: fullPage
|
|
1065
|
+
});
|
|
1066
|
+
return result.data;
|
|
1067
|
+
}
|
|
1068
|
+
// ============ Text Extraction ============
|
|
1069
|
+
/**
|
|
1070
|
+
* Get text content from the page or a specific element
|
|
1071
|
+
*/
|
|
1072
|
+
async text(selector) {
|
|
1073
|
+
const expression = selector ? `document.querySelector(${JSON.stringify(selector)})?.innerText ?? ''` : "document.body.innerText";
|
|
1074
|
+
const result = await this.evaluateInFrame(expression);
|
|
1075
|
+
return result.result.value ?? "";
|
|
1076
|
+
}
|
|
1077
|
+
// ============ File Handling ============
|
|
1078
|
+
/**
|
|
1079
|
+
* Set files on a file input
|
|
1080
|
+
*/
|
|
1081
|
+
async setInputFiles(selector, files, options = {}) {
|
|
1082
|
+
const element = await this.findElement(selector, options);
|
|
1083
|
+
if (!element) {
|
|
1084
|
+
if (options.optional) return false;
|
|
1085
|
+
throw new ElementNotFoundError(selector);
|
|
1086
|
+
}
|
|
1087
|
+
const fileData = await Promise.all(
|
|
1088
|
+
files.map(async (f) => {
|
|
1089
|
+
let base64;
|
|
1090
|
+
if (typeof f.buffer === "string") {
|
|
1091
|
+
base64 = f.buffer;
|
|
1092
|
+
} else {
|
|
1093
|
+
const bytes = new Uint8Array(f.buffer);
|
|
1094
|
+
base64 = btoa(String.fromCharCode(...bytes));
|
|
1095
|
+
}
|
|
1096
|
+
return { name: f.name, mimeType: f.mimeType, data: base64 };
|
|
1097
|
+
})
|
|
1098
|
+
);
|
|
1099
|
+
await this.cdp.send("Runtime.evaluate", {
|
|
1100
|
+
expression: `(() => {
|
|
1101
|
+
const input = document.querySelector(${JSON.stringify(element.selector)});
|
|
1102
|
+
if (!input) return false;
|
|
1103
|
+
|
|
1104
|
+
const files = ${JSON.stringify(fileData)};
|
|
1105
|
+
const dt = new DataTransfer();
|
|
1106
|
+
|
|
1107
|
+
for (const f of files) {
|
|
1108
|
+
const bytes = Uint8Array.from(atob(f.data), c => c.charCodeAt(0));
|
|
1109
|
+
const file = new File([bytes], f.name, { type: f.mimeType });
|
|
1110
|
+
dt.items.add(file);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
input.files = dt.files;
|
|
1114
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
1115
|
+
return true;
|
|
1116
|
+
})()`,
|
|
1117
|
+
returnByValue: true
|
|
1118
|
+
});
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Wait for a download to complete, triggered by an action
|
|
1123
|
+
*/
|
|
1124
|
+
async waitForDownload(trigger, options = {}) {
|
|
1125
|
+
const { timeout = DEFAULT_TIMEOUT } = options;
|
|
1126
|
+
await this.cdp.send("Browser.setDownloadBehavior", {
|
|
1127
|
+
behavior: "allowAndName",
|
|
1128
|
+
eventsEnabled: true
|
|
1129
|
+
});
|
|
1130
|
+
return new Promise((resolve, reject) => {
|
|
1131
|
+
let downloadGuid;
|
|
1132
|
+
let suggestedFilename;
|
|
1133
|
+
let resolved = false;
|
|
1134
|
+
const timeoutTimer = setTimeout(() => {
|
|
1135
|
+
if (!resolved) {
|
|
1136
|
+
cleanup();
|
|
1137
|
+
reject(new TimeoutError(`Download timed out after ${timeout}ms`));
|
|
1138
|
+
}
|
|
1139
|
+
}, timeout);
|
|
1140
|
+
const onDownloadWillBegin = (params) => {
|
|
1141
|
+
downloadGuid = params["guid"];
|
|
1142
|
+
suggestedFilename = params["suggestedFilename"];
|
|
1143
|
+
};
|
|
1144
|
+
const onDownloadProgress = (params) => {
|
|
1145
|
+
if (params["guid"] === downloadGuid && params["state"] === "completed") {
|
|
1146
|
+
resolved = true;
|
|
1147
|
+
cleanup();
|
|
1148
|
+
const download = {
|
|
1149
|
+
filename: suggestedFilename ?? "unknown",
|
|
1150
|
+
content: async () => {
|
|
1151
|
+
return new ArrayBuffer(0);
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
resolve(download);
|
|
1155
|
+
} else if (params["guid"] === downloadGuid && params["state"] === "canceled") {
|
|
1156
|
+
resolved = true;
|
|
1157
|
+
cleanup();
|
|
1158
|
+
reject(new Error("Download was canceled"));
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
const cleanup = () => {
|
|
1162
|
+
clearTimeout(timeoutTimer);
|
|
1163
|
+
this.cdp.off("Browser.downloadWillBegin", onDownloadWillBegin);
|
|
1164
|
+
this.cdp.off("Browser.downloadProgress", onDownloadProgress);
|
|
1165
|
+
};
|
|
1166
|
+
this.cdp.on("Browser.downloadWillBegin", onDownloadWillBegin);
|
|
1167
|
+
this.cdp.on("Browser.downloadProgress", onDownloadProgress);
|
|
1168
|
+
trigger().catch((err) => {
|
|
1169
|
+
if (!resolved) {
|
|
1170
|
+
resolved = true;
|
|
1171
|
+
cleanup();
|
|
1172
|
+
reject(err);
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
// ============ Snapshot ============
|
|
1178
|
+
/**
|
|
1179
|
+
* Get an accessibility tree snapshot of the page
|
|
1180
|
+
*/
|
|
1181
|
+
async snapshot() {
|
|
1182
|
+
const [url, title, axTree] = await Promise.all([
|
|
1183
|
+
this.url(),
|
|
1184
|
+
this.title(),
|
|
1185
|
+
this.cdp.send("Accessibility.getFullAXTree")
|
|
1186
|
+
]);
|
|
1187
|
+
const nodes = axTree.nodes.filter((n) => !n.ignored);
|
|
1188
|
+
const nodeMap = new Map(nodes.map((n) => [n.nodeId, n]));
|
|
1189
|
+
let refCounter = 0;
|
|
1190
|
+
const nodeRefs = /* @__PURE__ */ new Map();
|
|
1191
|
+
this.refMap.clear();
|
|
1192
|
+
for (const node of nodes) {
|
|
1193
|
+
const ref = `e${++refCounter}`;
|
|
1194
|
+
nodeRefs.set(node.nodeId, ref);
|
|
1195
|
+
if (node.backendDOMNodeId !== void 0) {
|
|
1196
|
+
this.refMap.set(ref, node.backendDOMNodeId);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
const buildNode = (nodeId) => {
|
|
1200
|
+
const node = nodeMap.get(nodeId);
|
|
1201
|
+
if (!node) return null;
|
|
1202
|
+
const role = node.role?.value ?? "generic";
|
|
1203
|
+
const name = node.name?.value;
|
|
1204
|
+
const value = node.value?.value;
|
|
1205
|
+
const ref = nodeRefs.get(nodeId);
|
|
1206
|
+
const children = [];
|
|
1207
|
+
if (node.childIds) {
|
|
1208
|
+
for (const childId of node.childIds) {
|
|
1209
|
+
const child = buildNode(childId);
|
|
1210
|
+
if (child) children.push(child);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
const disabled = node.properties?.find((p) => p.name === "disabled")?.value.value;
|
|
1214
|
+
const checked = node.properties?.find((p) => p.name === "checked")?.value.value;
|
|
1215
|
+
return {
|
|
1216
|
+
role,
|
|
1217
|
+
name,
|
|
1218
|
+
value,
|
|
1219
|
+
ref,
|
|
1220
|
+
children: children.length > 0 ? children : void 0,
|
|
1221
|
+
disabled,
|
|
1222
|
+
checked
|
|
1223
|
+
};
|
|
1224
|
+
};
|
|
1225
|
+
const rootNodes = nodes.filter((n) => !n.parentId || !nodeMap.has(n.parentId));
|
|
1226
|
+
const accessibilityTree = rootNodes.map((n) => buildNode(n.nodeId)).filter((n) => n !== null);
|
|
1227
|
+
const interactiveRoles = /* @__PURE__ */ new Set([
|
|
1228
|
+
"button",
|
|
1229
|
+
"link",
|
|
1230
|
+
"textbox",
|
|
1231
|
+
"checkbox",
|
|
1232
|
+
"radio",
|
|
1233
|
+
"combobox",
|
|
1234
|
+
"listbox",
|
|
1235
|
+
"menuitem",
|
|
1236
|
+
"menuitemcheckbox",
|
|
1237
|
+
"menuitemradio",
|
|
1238
|
+
"option",
|
|
1239
|
+
"searchbox",
|
|
1240
|
+
"slider",
|
|
1241
|
+
"spinbutton",
|
|
1242
|
+
"switch",
|
|
1243
|
+
"tab",
|
|
1244
|
+
"treeitem"
|
|
1245
|
+
]);
|
|
1246
|
+
const interactiveElements = [];
|
|
1247
|
+
for (const node of nodes) {
|
|
1248
|
+
const role = node.role?.value;
|
|
1249
|
+
if (role && interactiveRoles.has(role)) {
|
|
1250
|
+
const ref = nodeRefs.get(node.nodeId);
|
|
1251
|
+
const name = node.name?.value ?? "";
|
|
1252
|
+
const disabled = node.properties?.find((p) => p.name === "disabled")?.value.value;
|
|
1253
|
+
const selector = node.backendDOMNodeId ? `[data-backend-node-id="${node.backendDOMNodeId}"]` : `[aria-label="${name}"]`;
|
|
1254
|
+
interactiveElements.push({
|
|
1255
|
+
ref,
|
|
1256
|
+
role,
|
|
1257
|
+
name,
|
|
1258
|
+
selector,
|
|
1259
|
+
disabled
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
const formatTree = (nodes2, depth = 0) => {
|
|
1264
|
+
const lines = [];
|
|
1265
|
+
for (const node of nodes2) {
|
|
1266
|
+
let line = `${" ".repeat(depth)}- ${node.role}`;
|
|
1267
|
+
if (node.name) line += ` "${node.name}"`;
|
|
1268
|
+
line += ` [ref=${node.ref}]`;
|
|
1269
|
+
if (node.disabled) line += " (disabled)";
|
|
1270
|
+
if (node.checked !== void 0) line += node.checked ? " (checked)" : " (unchecked)";
|
|
1271
|
+
lines.push(line);
|
|
1272
|
+
if (node.children) {
|
|
1273
|
+
lines.push(formatTree(node.children, depth + 1));
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return lines.join("\n");
|
|
1277
|
+
};
|
|
1278
|
+
const text = formatTree(accessibilityTree);
|
|
1279
|
+
return {
|
|
1280
|
+
url,
|
|
1281
|
+
title,
|
|
1282
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1283
|
+
accessibilityTree,
|
|
1284
|
+
interactiveElements,
|
|
1285
|
+
text
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
// ============ Batch Execution ============
|
|
1289
|
+
/**
|
|
1290
|
+
* Execute a batch of steps
|
|
1291
|
+
*/
|
|
1292
|
+
async batch(steps, options) {
|
|
1293
|
+
return this.batchExecutor.execute(steps, options);
|
|
1294
|
+
}
|
|
1295
|
+
// ============ Emulation ============
|
|
1296
|
+
/**
|
|
1297
|
+
* Set the viewport size and device metrics
|
|
1298
|
+
*/
|
|
1299
|
+
async setViewport(options) {
|
|
1300
|
+
const {
|
|
1301
|
+
width,
|
|
1302
|
+
height,
|
|
1303
|
+
deviceScaleFactor = 1,
|
|
1304
|
+
isMobile = false,
|
|
1305
|
+
hasTouch = false,
|
|
1306
|
+
isLandscape = false
|
|
1307
|
+
} = options;
|
|
1308
|
+
await this.cdp.send("Emulation.setDeviceMetricsOverride", {
|
|
1309
|
+
width,
|
|
1310
|
+
height,
|
|
1311
|
+
deviceScaleFactor,
|
|
1312
|
+
mobile: isMobile,
|
|
1313
|
+
screenWidth: width,
|
|
1314
|
+
screenHeight: height,
|
|
1315
|
+
screenOrientation: {
|
|
1316
|
+
type: isLandscape ? "landscapePrimary" : "portraitPrimary",
|
|
1317
|
+
angle: isLandscape ? 90 : 0
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
if (hasTouch) {
|
|
1321
|
+
await this.cdp.send("Emulation.setTouchEmulationEnabled", {
|
|
1322
|
+
enabled: true,
|
|
1323
|
+
maxTouchPoints: 5
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
this.emulationState.viewport = options;
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Clear viewport override, return to default
|
|
1330
|
+
*/
|
|
1331
|
+
async clearViewport() {
|
|
1332
|
+
await this.cdp.send("Emulation.clearDeviceMetricsOverride");
|
|
1333
|
+
await this.cdp.send("Emulation.setTouchEmulationEnabled", { enabled: false });
|
|
1334
|
+
this.emulationState.viewport = void 0;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Set the user agent string and optional metadata
|
|
1338
|
+
*/
|
|
1339
|
+
async setUserAgent(options) {
|
|
1340
|
+
const opts = typeof options === "string" ? { userAgent: options } : options;
|
|
1341
|
+
await this.cdp.send("Emulation.setUserAgentOverride", {
|
|
1342
|
+
userAgent: opts.userAgent,
|
|
1343
|
+
acceptLanguage: opts.acceptLanguage,
|
|
1344
|
+
platform: opts.platform,
|
|
1345
|
+
userAgentMetadata: opts.userAgentMetadata
|
|
1346
|
+
});
|
|
1347
|
+
this.emulationState.userAgent = opts;
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Set geolocation coordinates
|
|
1351
|
+
*/
|
|
1352
|
+
async setGeolocation(options) {
|
|
1353
|
+
const { latitude, longitude, accuracy = 1 } = options;
|
|
1354
|
+
await this.cdp.send("Browser.grantPermissions", {
|
|
1355
|
+
permissions: ["geolocation"]
|
|
1356
|
+
});
|
|
1357
|
+
await this.cdp.send("Emulation.setGeolocationOverride", {
|
|
1358
|
+
latitude,
|
|
1359
|
+
longitude,
|
|
1360
|
+
accuracy
|
|
1361
|
+
});
|
|
1362
|
+
this.emulationState.geolocation = options;
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Clear geolocation override
|
|
1366
|
+
*/
|
|
1367
|
+
async clearGeolocation() {
|
|
1368
|
+
await this.cdp.send("Emulation.clearGeolocationOverride");
|
|
1369
|
+
this.emulationState.geolocation = void 0;
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Set timezone override
|
|
1373
|
+
*/
|
|
1374
|
+
async setTimezone(timezoneId) {
|
|
1375
|
+
await this.cdp.send("Emulation.setTimezoneOverride", { timezoneId });
|
|
1376
|
+
this.emulationState.timezone = timezoneId;
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Set locale override
|
|
1380
|
+
*/
|
|
1381
|
+
async setLocale(locale) {
|
|
1382
|
+
await this.cdp.send("Emulation.setLocaleOverride", { locale });
|
|
1383
|
+
this.emulationState.locale = locale;
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Emulate a specific device
|
|
1387
|
+
*/
|
|
1388
|
+
async emulate(device) {
|
|
1389
|
+
await this.setViewport(device.viewport);
|
|
1390
|
+
await this.setUserAgent(device.userAgent);
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Get current emulation state
|
|
1394
|
+
*/
|
|
1395
|
+
getEmulationState() {
|
|
1396
|
+
return { ...this.emulationState };
|
|
1397
|
+
}
|
|
1398
|
+
// ============ Request Interception ============
|
|
1399
|
+
/**
|
|
1400
|
+
* Add request interception handler
|
|
1401
|
+
* @param pattern URL pattern or resource type to match
|
|
1402
|
+
* @param handler Handler function for matched requests
|
|
1403
|
+
* @returns Unsubscribe function
|
|
1404
|
+
*/
|
|
1405
|
+
async intercept(pattern, handler) {
|
|
1406
|
+
if (!this.interceptor) {
|
|
1407
|
+
this.interceptor = new RequestInterceptor(this.cdp);
|
|
1408
|
+
await this.interceptor.enable();
|
|
1409
|
+
}
|
|
1410
|
+
const normalizedPattern = typeof pattern === "string" ? { urlPattern: pattern } : pattern;
|
|
1411
|
+
return this.interceptor.addHandler(normalizedPattern, handler);
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Route requests matching pattern to a mock response
|
|
1415
|
+
* Convenience wrapper around intercept()
|
|
1416
|
+
*/
|
|
1417
|
+
async route(urlPattern, options) {
|
|
1418
|
+
return this.intercept({ urlPattern }, async (_request, actions) => {
|
|
1419
|
+
let body = options.body;
|
|
1420
|
+
const headers = { ...options.headers };
|
|
1421
|
+
if (typeof body === "object") {
|
|
1422
|
+
body = JSON.stringify(body);
|
|
1423
|
+
headers["content-type"] ??= "application/json";
|
|
1424
|
+
}
|
|
1425
|
+
if (options.contentType) {
|
|
1426
|
+
headers["content-type"] = options.contentType;
|
|
1427
|
+
}
|
|
1428
|
+
await actions.fulfill({
|
|
1429
|
+
status: options.status ?? 200,
|
|
1430
|
+
headers,
|
|
1431
|
+
body
|
|
1432
|
+
});
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Block requests matching resource types
|
|
1437
|
+
*/
|
|
1438
|
+
async blockResources(types) {
|
|
1439
|
+
return this.intercept({}, async (request, actions) => {
|
|
1440
|
+
if (types.includes(request.resourceType)) {
|
|
1441
|
+
await actions.fail({ reason: "BlockedByClient" });
|
|
1442
|
+
} else {
|
|
1443
|
+
await actions.continue();
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Disable all request interception
|
|
1449
|
+
*/
|
|
1450
|
+
async disableInterception() {
|
|
1451
|
+
if (this.interceptor) {
|
|
1452
|
+
await this.interceptor.disable();
|
|
1453
|
+
this.interceptor = null;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
// ============ Cookies & Storage ============
|
|
1457
|
+
/**
|
|
1458
|
+
* Get all cookies for the current page
|
|
1459
|
+
*/
|
|
1460
|
+
async cookies(urls) {
|
|
1461
|
+
const targetUrls = urls ?? [await this.url()];
|
|
1462
|
+
const result = await this.cdp.send("Network.getCookies", {
|
|
1463
|
+
urls: targetUrls
|
|
1464
|
+
});
|
|
1465
|
+
return result.cookies;
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Set a cookie
|
|
1469
|
+
*/
|
|
1470
|
+
async setCookie(options) {
|
|
1471
|
+
const { name, value, domain, path = "/", expires, httpOnly, secure, sameSite, url } = options;
|
|
1472
|
+
let expireTime;
|
|
1473
|
+
if (expires instanceof Date) {
|
|
1474
|
+
expireTime = Math.floor(expires.getTime() / 1e3);
|
|
1475
|
+
} else if (typeof expires === "number") {
|
|
1476
|
+
expireTime = expires;
|
|
1477
|
+
}
|
|
1478
|
+
const result = await this.cdp.send("Network.setCookie", {
|
|
1479
|
+
name,
|
|
1480
|
+
value,
|
|
1481
|
+
domain,
|
|
1482
|
+
path,
|
|
1483
|
+
expires: expireTime,
|
|
1484
|
+
httpOnly,
|
|
1485
|
+
secure,
|
|
1486
|
+
sameSite,
|
|
1487
|
+
url: url ?? (domain ? void 0 : await this.url())
|
|
1488
|
+
});
|
|
1489
|
+
return result.success;
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Set multiple cookies
|
|
1493
|
+
*/
|
|
1494
|
+
async setCookies(cookies) {
|
|
1495
|
+
for (const cookie of cookies) {
|
|
1496
|
+
await this.setCookie(cookie);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Delete a specific cookie
|
|
1501
|
+
*/
|
|
1502
|
+
async deleteCookie(options) {
|
|
1503
|
+
const { name, domain, path, url } = options;
|
|
1504
|
+
await this.cdp.send("Network.deleteCookies", {
|
|
1505
|
+
name,
|
|
1506
|
+
domain,
|
|
1507
|
+
path,
|
|
1508
|
+
url: url ?? (domain ? void 0 : await this.url())
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Delete multiple cookies
|
|
1513
|
+
*/
|
|
1514
|
+
async deleteCookies(cookies) {
|
|
1515
|
+
for (const cookie of cookies) {
|
|
1516
|
+
await this.deleteCookie(cookie);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Clear all cookies
|
|
1521
|
+
*/
|
|
1522
|
+
async clearCookies(options) {
|
|
1523
|
+
if (options?.domain) {
|
|
1524
|
+
const domainCookies = await this.cookies([`https://${options.domain}`]);
|
|
1525
|
+
for (const cookie of domainCookies) {
|
|
1526
|
+
await this.deleteCookie({
|
|
1527
|
+
name: cookie.name,
|
|
1528
|
+
domain: cookie.domain,
|
|
1529
|
+
path: cookie.path
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
} else {
|
|
1533
|
+
await this.cdp.send("Storage.clearCookies", {});
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Get localStorage value
|
|
1538
|
+
*/
|
|
1539
|
+
async getLocalStorage(key) {
|
|
1540
|
+
const result = await this.cdp.send("Runtime.evaluate", {
|
|
1541
|
+
expression: `localStorage.getItem(${JSON.stringify(key)})`,
|
|
1542
|
+
returnByValue: true
|
|
1543
|
+
});
|
|
1544
|
+
return result.result.value;
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Set localStorage value
|
|
1548
|
+
*/
|
|
1549
|
+
async setLocalStorage(key, value) {
|
|
1550
|
+
await this.cdp.send("Runtime.evaluate", {
|
|
1551
|
+
expression: `localStorage.setItem(${JSON.stringify(key)}, ${JSON.stringify(value)})`
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Remove localStorage item
|
|
1556
|
+
*/
|
|
1557
|
+
async removeLocalStorage(key) {
|
|
1558
|
+
await this.cdp.send("Runtime.evaluate", {
|
|
1559
|
+
expression: `localStorage.removeItem(${JSON.stringify(key)})`
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Clear localStorage
|
|
1564
|
+
*/
|
|
1565
|
+
async clearLocalStorage() {
|
|
1566
|
+
await this.cdp.send("Runtime.evaluate", {
|
|
1567
|
+
expression: "localStorage.clear()"
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Get sessionStorage value
|
|
1572
|
+
*/
|
|
1573
|
+
async getSessionStorage(key) {
|
|
1574
|
+
const result = await this.cdp.send("Runtime.evaluate", {
|
|
1575
|
+
expression: `sessionStorage.getItem(${JSON.stringify(key)})`,
|
|
1576
|
+
returnByValue: true
|
|
1577
|
+
});
|
|
1578
|
+
return result.result.value;
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Set sessionStorage value
|
|
1582
|
+
*/
|
|
1583
|
+
async setSessionStorage(key, value) {
|
|
1584
|
+
await this.cdp.send("Runtime.evaluate", {
|
|
1585
|
+
expression: `sessionStorage.setItem(${JSON.stringify(key)}, ${JSON.stringify(value)})`
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Remove sessionStorage item
|
|
1590
|
+
*/
|
|
1591
|
+
async removeSessionStorage(key) {
|
|
1592
|
+
await this.cdp.send("Runtime.evaluate", {
|
|
1593
|
+
expression: `sessionStorage.removeItem(${JSON.stringify(key)})`
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Clear sessionStorage
|
|
1598
|
+
*/
|
|
1599
|
+
async clearSessionStorage() {
|
|
1600
|
+
await this.cdp.send("Runtime.evaluate", {
|
|
1601
|
+
expression: "sessionStorage.clear()"
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
// ============ Console & Errors ============
|
|
1605
|
+
/**
|
|
1606
|
+
* Enable console message capture
|
|
1607
|
+
*/
|
|
1608
|
+
async enableConsole() {
|
|
1609
|
+
if (this.consoleEnabled) return;
|
|
1610
|
+
this.cdp.on("Runtime.consoleAPICalled", this.handleConsoleMessage.bind(this));
|
|
1611
|
+
this.cdp.on("Runtime.exceptionThrown", this.handleException.bind(this));
|
|
1612
|
+
this.consoleEnabled = true;
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Handle console API calls
|
|
1616
|
+
*/
|
|
1617
|
+
handleConsoleMessage(params) {
|
|
1618
|
+
const args = params["args"];
|
|
1619
|
+
const stackTrace = params["stackTrace"];
|
|
1620
|
+
const message = {
|
|
1621
|
+
type: params["type"],
|
|
1622
|
+
text: this.formatConsoleArgs(args ?? []),
|
|
1623
|
+
args: args?.map((a) => a.value) ?? [],
|
|
1624
|
+
timestamp: params["timestamp"],
|
|
1625
|
+
stackTrace: stackTrace?.callFrames?.map((f) => `${f.url}:${f.lineNumber}`)
|
|
1626
|
+
};
|
|
1627
|
+
for (const handler of this.consoleHandlers) {
|
|
1628
|
+
try {
|
|
1629
|
+
handler(message);
|
|
1630
|
+
} catch (e) {
|
|
1631
|
+
console.error("[Console handler error]", e);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Handle JavaScript exceptions
|
|
1637
|
+
*/
|
|
1638
|
+
handleException(params) {
|
|
1639
|
+
const details = params["exceptionDetails"];
|
|
1640
|
+
const exception = details["exception"];
|
|
1641
|
+
const stackTrace = details["stackTrace"];
|
|
1642
|
+
const error = {
|
|
1643
|
+
message: exception?.description ?? details["text"],
|
|
1644
|
+
url: details["url"],
|
|
1645
|
+
lineNumber: details["lineNumber"],
|
|
1646
|
+
columnNumber: details["columnNumber"],
|
|
1647
|
+
timestamp: params["timestamp"],
|
|
1648
|
+
stackTrace: stackTrace?.callFrames?.map((f) => `${f.url}:${f.lineNumber}`)
|
|
1649
|
+
};
|
|
1650
|
+
for (const handler of this.errorHandlers) {
|
|
1651
|
+
try {
|
|
1652
|
+
handler(error);
|
|
1653
|
+
} catch (e) {
|
|
1654
|
+
console.error("[Error handler error]", e);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Handle dialog opening
|
|
1660
|
+
*/
|
|
1661
|
+
async handleDialogOpening(params) {
|
|
1662
|
+
const dialog = {
|
|
1663
|
+
type: params["type"],
|
|
1664
|
+
message: params["message"],
|
|
1665
|
+
defaultValue: params["defaultPrompt"],
|
|
1666
|
+
accept: async (promptText) => {
|
|
1667
|
+
await this.cdp.send("Page.handleJavaScriptDialog", {
|
|
1668
|
+
accept: true,
|
|
1669
|
+
promptText
|
|
1670
|
+
});
|
|
1671
|
+
},
|
|
1672
|
+
dismiss: async () => {
|
|
1673
|
+
await this.cdp.send("Page.handleJavaScriptDialog", {
|
|
1674
|
+
accept: false
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
if (this.dialogHandler) {
|
|
1679
|
+
try {
|
|
1680
|
+
await this.dialogHandler(dialog);
|
|
1681
|
+
} catch (e) {
|
|
1682
|
+
console.error("[Dialog handler error]", e);
|
|
1683
|
+
await dialog.dismiss();
|
|
1684
|
+
}
|
|
1685
|
+
} else {
|
|
1686
|
+
await dialog.dismiss();
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Format console arguments to string
|
|
1691
|
+
*/
|
|
1692
|
+
formatConsoleArgs(args) {
|
|
1693
|
+
return args.map((arg) => {
|
|
1694
|
+
if (arg.value !== void 0) return String(arg.value);
|
|
1695
|
+
if (arg.description) return arg.description;
|
|
1696
|
+
return "[object]";
|
|
1697
|
+
}).join(" ");
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Subscribe to console messages
|
|
1701
|
+
*/
|
|
1702
|
+
async onConsole(handler) {
|
|
1703
|
+
await this.enableConsole();
|
|
1704
|
+
this.consoleHandlers.add(handler);
|
|
1705
|
+
return () => this.consoleHandlers.delete(handler);
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Subscribe to page errors
|
|
1709
|
+
*/
|
|
1710
|
+
async onError(handler) {
|
|
1711
|
+
await this.enableConsole();
|
|
1712
|
+
this.errorHandlers.add(handler);
|
|
1713
|
+
return () => this.errorHandlers.delete(handler);
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Set dialog handler (only one at a time)
|
|
1717
|
+
*/
|
|
1718
|
+
async onDialog(handler) {
|
|
1719
|
+
await this.enableConsole();
|
|
1720
|
+
this.dialogHandler = handler;
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Collect console messages during an action
|
|
1724
|
+
*/
|
|
1725
|
+
async collectConsole(fn) {
|
|
1726
|
+
const messages = [];
|
|
1727
|
+
const unsubscribe = await this.onConsole((msg) => messages.push(msg));
|
|
1728
|
+
try {
|
|
1729
|
+
const result = await fn();
|
|
1730
|
+
return { result, messages };
|
|
1731
|
+
} finally {
|
|
1732
|
+
unsubscribe();
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Collect errors during an action
|
|
1737
|
+
*/
|
|
1738
|
+
async collectErrors(fn) {
|
|
1739
|
+
const errors = [];
|
|
1740
|
+
const unsubscribe = await this.onError((err) => errors.push(err));
|
|
1741
|
+
try {
|
|
1742
|
+
const result = await fn();
|
|
1743
|
+
return { result, errors };
|
|
1744
|
+
} finally {
|
|
1745
|
+
unsubscribe();
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
// ============ Lifecycle ============
|
|
1749
|
+
/**
|
|
1750
|
+
* Reset page state for clean test isolation
|
|
1751
|
+
* - Stops any pending operations
|
|
1752
|
+
* - Clears localStorage and sessionStorage
|
|
1753
|
+
* - Resets internal state
|
|
1754
|
+
*/
|
|
1755
|
+
async reset() {
|
|
1756
|
+
this.rootNodeId = null;
|
|
1757
|
+
this.refMap.clear();
|
|
1758
|
+
this.currentFrame = null;
|
|
1759
|
+
this.currentFrameContextId = null;
|
|
1760
|
+
this.frameContexts.clear();
|
|
1761
|
+
this.dialogHandler = null;
|
|
1762
|
+
try {
|
|
1763
|
+
await this.cdp.send("Page.stopLoading");
|
|
1764
|
+
} catch {
|
|
1765
|
+
}
|
|
1766
|
+
try {
|
|
1767
|
+
await this.cdp.send("Runtime.evaluate", {
|
|
1768
|
+
expression: `(() => {
|
|
1769
|
+
try { localStorage.clear(); } catch {}
|
|
1770
|
+
try { sessionStorage.clear(); } catch {}
|
|
1771
|
+
})()`
|
|
1772
|
+
});
|
|
1773
|
+
} catch {
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Close this page (no-op for now, managed by Browser)
|
|
1778
|
+
* This is a placeholder for API compatibility
|
|
1779
|
+
*/
|
|
1780
|
+
async close() {
|
|
1781
|
+
}
|
|
1782
|
+
// ============ Private Helpers ============
|
|
1783
|
+
/**
|
|
1784
|
+
* Retry wrapper for operations that may encounter stale nodes
|
|
1785
|
+
* Catches "Could not find node with given id" errors and retries
|
|
1786
|
+
*/
|
|
1787
|
+
async withStaleNodeRetry(fn, options = {}) {
|
|
1788
|
+
const { retries = 2, delay = 50 } = options;
|
|
1789
|
+
let lastError;
|
|
1790
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
1791
|
+
try {
|
|
1792
|
+
return await fn();
|
|
1793
|
+
} catch (e) {
|
|
1794
|
+
if (e instanceof Error && (e.message.includes("Could not find node with given id") || e.message.includes("Node with given id does not belong to the document") || e.message.includes("No node with given id found"))) {
|
|
1795
|
+
lastError = e;
|
|
1796
|
+
if (attempt < retries) {
|
|
1797
|
+
this.rootNodeId = null;
|
|
1798
|
+
await sleep2(delay);
|
|
1799
|
+
continue;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
throw e;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
throw lastError ?? new Error("Stale node retry exhausted");
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Find an element using single or multiple selectors
|
|
1809
|
+
* Supports ref: prefix for ref-based selectors (e.g., "ref:e4")
|
|
1810
|
+
*/
|
|
1811
|
+
async findElement(selectors, options = {}) {
|
|
1812
|
+
const { timeout = DEFAULT_TIMEOUT } = options;
|
|
1813
|
+
const selectorList = Array.isArray(selectors) ? selectors : [selectors];
|
|
1814
|
+
for (const selector of selectorList) {
|
|
1815
|
+
if (selector.startsWith("ref:")) {
|
|
1816
|
+
const ref = selector.slice(4);
|
|
1817
|
+
const backendNodeId = this.refMap.get(ref);
|
|
1818
|
+
if (!backendNodeId) {
|
|
1819
|
+
continue;
|
|
1820
|
+
}
|
|
1821
|
+
try {
|
|
1822
|
+
await this.ensureRootNode();
|
|
1823
|
+
const pushResult = await this.cdp.send(
|
|
1824
|
+
"DOM.pushNodesByBackendIdsToFrontend",
|
|
1825
|
+
{
|
|
1826
|
+
backendNodeIds: [backendNodeId]
|
|
1827
|
+
}
|
|
1828
|
+
);
|
|
1829
|
+
if (pushResult.nodeIds?.[0]) {
|
|
1830
|
+
return {
|
|
1831
|
+
nodeId: pushResult.nodeIds[0],
|
|
1832
|
+
backendNodeId,
|
|
1833
|
+
selector,
|
|
1834
|
+
waitedMs: 0
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
} catch {
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const cssSelectors = selectorList.filter((s) => !s.startsWith("ref:"));
|
|
1842
|
+
if (cssSelectors.length === 0) {
|
|
1843
|
+
return null;
|
|
1844
|
+
}
|
|
1845
|
+
const result = await waitForAnyElement(this.cdp, cssSelectors, {
|
|
1846
|
+
state: "visible",
|
|
1847
|
+
timeout,
|
|
1848
|
+
contextId: this.currentFrameContextId ?? void 0
|
|
1849
|
+
});
|
|
1850
|
+
if (!result.success || !result.selector) {
|
|
1851
|
+
return null;
|
|
1852
|
+
}
|
|
1853
|
+
await this.ensureRootNode();
|
|
1854
|
+
const queryResult = await this.cdp.send("DOM.querySelector", {
|
|
1855
|
+
nodeId: this.rootNodeId,
|
|
1856
|
+
selector: result.selector
|
|
1857
|
+
});
|
|
1858
|
+
if (queryResult.nodeId) {
|
|
1859
|
+
const describeResult2 = await this.cdp.send(
|
|
1860
|
+
"DOM.describeNode",
|
|
1861
|
+
{ nodeId: queryResult.nodeId }
|
|
1862
|
+
);
|
|
1863
|
+
return {
|
|
1864
|
+
nodeId: queryResult.nodeId,
|
|
1865
|
+
backendNodeId: describeResult2.node.backendNodeId,
|
|
1866
|
+
selector: result.selector,
|
|
1867
|
+
waitedMs: result.waitedMs
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
const deepQueryResult = await this.evaluateInFrame(
|
|
1871
|
+
`(() => {
|
|
1872
|
+
${DEEP_QUERY_SCRIPT}
|
|
1873
|
+
return deepQuery(${JSON.stringify(result.selector)});
|
|
1874
|
+
})()`,
|
|
1875
|
+
{ returnByValue: false }
|
|
1876
|
+
);
|
|
1877
|
+
if (!deepQueryResult.result.objectId) {
|
|
1878
|
+
return null;
|
|
1879
|
+
}
|
|
1880
|
+
const nodeResult = await this.cdp.send("DOM.requestNode", {
|
|
1881
|
+
objectId: deepQueryResult.result.objectId
|
|
1882
|
+
});
|
|
1883
|
+
if (!nodeResult.nodeId) {
|
|
1884
|
+
return null;
|
|
1885
|
+
}
|
|
1886
|
+
const describeResult = await this.cdp.send(
|
|
1887
|
+
"DOM.describeNode",
|
|
1888
|
+
{ nodeId: nodeResult.nodeId }
|
|
1889
|
+
);
|
|
1890
|
+
return {
|
|
1891
|
+
nodeId: nodeResult.nodeId,
|
|
1892
|
+
backendNodeId: describeResult.node.backendNodeId,
|
|
1893
|
+
selector: result.selector,
|
|
1894
|
+
waitedMs: result.waitedMs
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Ensure we have a valid root node ID
|
|
1899
|
+
*/
|
|
1900
|
+
async ensureRootNode() {
|
|
1901
|
+
if (this.rootNodeId) return;
|
|
1902
|
+
const doc = await this.cdp.send("DOM.getDocument", {
|
|
1903
|
+
depth: 0
|
|
1904
|
+
});
|
|
1905
|
+
this.rootNodeId = doc.root.nodeId;
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* Execute Runtime.evaluate in the current frame context
|
|
1909
|
+
* Automatically injects contextId when in an iframe
|
|
1910
|
+
*/
|
|
1911
|
+
async evaluateInFrame(expression, options = {}) {
|
|
1912
|
+
const params = {
|
|
1913
|
+
expression,
|
|
1914
|
+
returnByValue: options.returnByValue ?? true,
|
|
1915
|
+
awaitPromise: options.awaitPromise ?? false
|
|
1916
|
+
};
|
|
1917
|
+
if (this.currentFrameContextId !== null) {
|
|
1918
|
+
params["contextId"] = this.currentFrameContextId;
|
|
1919
|
+
}
|
|
1920
|
+
return this.cdp.send("Runtime.evaluate", params);
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Scroll an element into view
|
|
1924
|
+
*/
|
|
1925
|
+
async scrollIntoView(nodeId) {
|
|
1926
|
+
await this.cdp.send("DOM.scrollIntoViewIfNeeded", { nodeId });
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* Get element box model (position and dimensions)
|
|
1930
|
+
*/
|
|
1931
|
+
async getBoxModel(nodeId) {
|
|
1932
|
+
try {
|
|
1933
|
+
const result = await this.cdp.send("DOM.getBoxModel", {
|
|
1934
|
+
nodeId
|
|
1935
|
+
});
|
|
1936
|
+
return result.model;
|
|
1937
|
+
} catch {
|
|
1938
|
+
return null;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Click an element by node ID
|
|
1943
|
+
*/
|
|
1944
|
+
async clickElement(nodeId) {
|
|
1945
|
+
const box = await this.getBoxModel(nodeId);
|
|
1946
|
+
if (!box) {
|
|
1947
|
+
throw new Error("Could not get element box model for click");
|
|
1948
|
+
}
|
|
1949
|
+
const x = box.content[0] + box.width / 2;
|
|
1950
|
+
const y = box.content[1] + box.height / 2;
|
|
1951
|
+
await this.cdp.send("Input.dispatchMouseEvent", {
|
|
1952
|
+
type: "mousePressed",
|
|
1953
|
+
x,
|
|
1954
|
+
y,
|
|
1955
|
+
button: "left",
|
|
1956
|
+
clickCount: 1
|
|
1957
|
+
});
|
|
1958
|
+
await this.cdp.send("Input.dispatchMouseEvent", {
|
|
1959
|
+
type: "mouseReleased",
|
|
1960
|
+
x,
|
|
1961
|
+
y,
|
|
1962
|
+
button: "left",
|
|
1963
|
+
clickCount: 1
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
};
|
|
1967
|
+
function sleep2(ms) {
|
|
1968
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// src/browser/browser.ts
|
|
1972
|
+
var Browser = class _Browser {
|
|
1973
|
+
cdp;
|
|
1974
|
+
providerSession;
|
|
1975
|
+
pages = /* @__PURE__ */ new Map();
|
|
1976
|
+
constructor(cdp, _provider, providerSession, _options) {
|
|
1977
|
+
this.cdp = cdp;
|
|
1978
|
+
this.providerSession = providerSession;
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Connect to a browser instance
|
|
1982
|
+
*/
|
|
1983
|
+
static async connect(options) {
|
|
1984
|
+
const provider = createProvider(options);
|
|
1985
|
+
const session = await provider.createSession(options.session);
|
|
1986
|
+
const cdp = await createCDPClient(session.wsUrl, {
|
|
1987
|
+
debug: options.debug,
|
|
1988
|
+
timeout: options.timeout
|
|
1989
|
+
});
|
|
1990
|
+
return new _Browser(cdp, provider, session, options);
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* Get or create a page by name
|
|
1994
|
+
* If no name is provided, returns the first available page or creates a new one
|
|
1995
|
+
*/
|
|
1996
|
+
async page(name) {
|
|
1997
|
+
const pageName = name ?? "default";
|
|
1998
|
+
const cached = this.pages.get(pageName);
|
|
1999
|
+
if (cached) return cached;
|
|
2000
|
+
const targets = await this.cdp.send("Target.getTargets");
|
|
2001
|
+
const pageTargets = targets.targetInfos.filter((t) => t.type === "page");
|
|
2002
|
+
let targetId;
|
|
2003
|
+
if (pageTargets.length > 0) {
|
|
2004
|
+
targetId = pageTargets[0].targetId;
|
|
2005
|
+
} else {
|
|
2006
|
+
const result = await this.cdp.send("Target.createTarget", {
|
|
2007
|
+
url: "about:blank"
|
|
2008
|
+
});
|
|
2009
|
+
targetId = result.targetId;
|
|
2010
|
+
}
|
|
2011
|
+
await this.cdp.attachToTarget(targetId);
|
|
2012
|
+
const page = new Page(this.cdp);
|
|
2013
|
+
await page.init();
|
|
2014
|
+
this.pages.set(pageName, page);
|
|
2015
|
+
return page;
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Create a new page (tab)
|
|
2019
|
+
*/
|
|
2020
|
+
async newPage(url = "about:blank") {
|
|
2021
|
+
const result = await this.cdp.send("Target.createTarget", {
|
|
2022
|
+
url
|
|
2023
|
+
});
|
|
2024
|
+
await this.cdp.attachToTarget(result.targetId);
|
|
2025
|
+
const page = new Page(this.cdp);
|
|
2026
|
+
await page.init();
|
|
2027
|
+
const name = `page-${this.pages.size + 1}`;
|
|
2028
|
+
this.pages.set(name, page);
|
|
2029
|
+
return page;
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Close a page by name
|
|
2033
|
+
*/
|
|
2034
|
+
async closePage(name) {
|
|
2035
|
+
const page = this.pages.get(name);
|
|
2036
|
+
if (!page) return;
|
|
2037
|
+
const targets = await this.cdp.send("Target.getTargets");
|
|
2038
|
+
const pageTargets = targets.targetInfos.filter((t) => t.type === "page");
|
|
2039
|
+
if (pageTargets.length > 0) {
|
|
2040
|
+
await this.cdp.send("Target.closeTarget", {
|
|
2041
|
+
targetId: pageTargets[0].targetId
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
this.pages.delete(name);
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Get the WebSocket URL for this browser connection
|
|
2048
|
+
*/
|
|
2049
|
+
get wsUrl() {
|
|
2050
|
+
return this.providerSession.wsUrl;
|
|
2051
|
+
}
|
|
2052
|
+
/**
|
|
2053
|
+
* Get the provider session ID (for resumption)
|
|
2054
|
+
*/
|
|
2055
|
+
get sessionId() {
|
|
2056
|
+
return this.providerSession.sessionId;
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Get provider metadata
|
|
2060
|
+
*/
|
|
2061
|
+
get metadata() {
|
|
2062
|
+
return this.providerSession.metadata;
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Check if connected
|
|
2066
|
+
*/
|
|
2067
|
+
get isConnected() {
|
|
2068
|
+
return this.cdp.isConnected;
|
|
2069
|
+
}
|
|
2070
|
+
/**
|
|
2071
|
+
* Disconnect from the browser (keeps provider session alive for reconnection)
|
|
2072
|
+
*/
|
|
2073
|
+
async disconnect() {
|
|
2074
|
+
this.pages.clear();
|
|
2075
|
+
await this.cdp.close();
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Close the browser session completely
|
|
2079
|
+
*/
|
|
2080
|
+
async close() {
|
|
2081
|
+
this.pages.clear();
|
|
2082
|
+
await this.cdp.close();
|
|
2083
|
+
await this.providerSession.close();
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Get the underlying CDP client (for advanced usage)
|
|
2087
|
+
*/
|
|
2088
|
+
get cdpClient() {
|
|
2089
|
+
return this.cdp;
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
function connect(options) {
|
|
2093
|
+
return Browser.connect(options);
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
export {
|
|
2097
|
+
RequestInterceptor,
|
|
2098
|
+
waitForElement,
|
|
2099
|
+
waitForAnyElement,
|
|
2100
|
+
waitForNavigation,
|
|
2101
|
+
waitForNetworkIdle,
|
|
2102
|
+
ElementNotFoundError,
|
|
2103
|
+
TimeoutError,
|
|
2104
|
+
NavigationError,
|
|
2105
|
+
Page,
|
|
2106
|
+
Browser,
|
|
2107
|
+
connect
|
|
2108
|
+
};
|