browsecraft 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/chunk-77HRTGXZ.js +2004 -0
- package/dist/chunk-77HRTGXZ.js.map +1 -0
- package/dist/chunk-KIPQFK3Y.cjs +2021 -0
- package/dist/chunk-KIPQFK3Y.cjs.map +1 -0
- package/dist/cli.cjs +297 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +295 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +831 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +984 -0
- package/dist/index.d.ts +984 -0
- package/dist/index.js +632 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1,2004 @@
|
|
|
1
|
+
import { cpus } from 'os';
|
|
2
|
+
import { BiDiSession } from 'browsecraft-bidi';
|
|
3
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
var DEFAULTS = {
|
|
8
|
+
browser: "chrome",
|
|
9
|
+
headless: true,
|
|
10
|
+
timeout: 3e4,
|
|
11
|
+
retries: 0,
|
|
12
|
+
screenshot: "on-failure",
|
|
13
|
+
baseURL: "",
|
|
14
|
+
viewport: { width: 1280, height: 720 },
|
|
15
|
+
maximized: false,
|
|
16
|
+
workers: Math.max(1, Math.floor((typeof cpus === "function" ? cpus().length : 4) / 2)),
|
|
17
|
+
testMatch: "**/*.test.{ts,js,mts,mjs}",
|
|
18
|
+
outputDir: ".browsecraft",
|
|
19
|
+
ai: "auto",
|
|
20
|
+
debug: false
|
|
21
|
+
};
|
|
22
|
+
function defineConfig(config) {
|
|
23
|
+
return config;
|
|
24
|
+
}
|
|
25
|
+
function resolveConfig(userConfig) {
|
|
26
|
+
if (!userConfig) return { ...DEFAULTS };
|
|
27
|
+
return {
|
|
28
|
+
...DEFAULTS,
|
|
29
|
+
...userConfig,
|
|
30
|
+
viewport: userConfig.viewport ?? DEFAULTS.viewport
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/wait.ts
|
|
35
|
+
async function waitFor(description, fn, options) {
|
|
36
|
+
const { timeout, interval = 100 } = options;
|
|
37
|
+
const startTime = Date.now();
|
|
38
|
+
let lastError = null;
|
|
39
|
+
while (Date.now() - startTime < timeout) {
|
|
40
|
+
try {
|
|
41
|
+
const result = await fn();
|
|
42
|
+
if (result !== null && result !== false) {
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
47
|
+
}
|
|
48
|
+
await sleep(interval);
|
|
49
|
+
}
|
|
50
|
+
const elapsed = Date.now() - startTime;
|
|
51
|
+
const errorMsg = lastError ? `
|
|
52
|
+
Last error: ${lastError.message}` : "";
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Timed out after ${elapsed}ms waiting for: ${description}${errorMsg}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
async function waitForLoadState(session, contextId, state = "load", timeout = 3e4) {
|
|
58
|
+
const result = await session.script.evaluate({
|
|
59
|
+
expression: "document.readyState",
|
|
60
|
+
target: { context: contextId },
|
|
61
|
+
awaitPromise: false
|
|
62
|
+
});
|
|
63
|
+
if (result.type === "success" && result.result) {
|
|
64
|
+
const readyState = result.result.value;
|
|
65
|
+
if (state === "domcontentloaded" && (readyState === "interactive" || readyState === "complete")) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (state === "load" && readyState === "complete") {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const eventName = state === "load" ? "browsingContext.load" : "browsingContext.domContentLoaded";
|
|
73
|
+
await session.subscribe([eventName], [contextId]);
|
|
74
|
+
try {
|
|
75
|
+
await session.waitForEvent(
|
|
76
|
+
eventName,
|
|
77
|
+
(event) => event.params.context === contextId,
|
|
78
|
+
timeout
|
|
79
|
+
);
|
|
80
|
+
} finally {
|
|
81
|
+
await session.unsubscribe([eventName], [contextId]).catch(() => {
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function sleep(ms) {
|
|
86
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/locator.ts
|
|
90
|
+
async function locateElement(session, contextId, target, options) {
|
|
91
|
+
const opts = normalizeTarget(target);
|
|
92
|
+
return waitFor(
|
|
93
|
+
describeTarget(target),
|
|
94
|
+
async () => {
|
|
95
|
+
const strategies = buildStrategies(opts);
|
|
96
|
+
for (const { locator, strategy, isLabelLookup } of strategies) {
|
|
97
|
+
try {
|
|
98
|
+
const result = await session.browsingContext.locateNodes({
|
|
99
|
+
context: contextId,
|
|
100
|
+
locator,
|
|
101
|
+
maxNodeCount: (opts.index ?? 0) + 10
|
|
102
|
+
// fetch extra for label resolution
|
|
103
|
+
});
|
|
104
|
+
if (result.nodes.length > 0) {
|
|
105
|
+
if (isLabelLookup) {
|
|
106
|
+
const resolved = await resolveLabelsToInputs(session, contextId, result.nodes);
|
|
107
|
+
if (resolved) {
|
|
108
|
+
return { node: resolved, strategy };
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const nodeIndex = opts.index ?? 0;
|
|
113
|
+
const node = result.nodes[nodeIndex];
|
|
114
|
+
if (node) {
|
|
115
|
+
return { node, strategy };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
},
|
|
124
|
+
options
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
async function locateAllElements(session, contextId, target) {
|
|
128
|
+
const opts = normalizeTarget(target);
|
|
129
|
+
const strategies = buildStrategies(opts);
|
|
130
|
+
for (const { locator, strategy } of strategies) {
|
|
131
|
+
try {
|
|
132
|
+
const result = await session.browsingContext.locateNodes({
|
|
133
|
+
context: contextId,
|
|
134
|
+
locator,
|
|
135
|
+
maxNodeCount: 1e3
|
|
136
|
+
});
|
|
137
|
+
if (result.nodes.length > 0) {
|
|
138
|
+
return result.nodes.map((node) => ({ node, strategy }));
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
function normalizeTarget(target) {
|
|
147
|
+
if (typeof target === "string") {
|
|
148
|
+
return { name: target };
|
|
149
|
+
}
|
|
150
|
+
return target;
|
|
151
|
+
}
|
|
152
|
+
function describeTarget(target) {
|
|
153
|
+
if (typeof target === "string") {
|
|
154
|
+
return `element "${target}"`;
|
|
155
|
+
}
|
|
156
|
+
const parts = [];
|
|
157
|
+
if (target.role) parts.push(`role="${target.role}"`);
|
|
158
|
+
if (target.name) parts.push(`name="${target.name}"`);
|
|
159
|
+
if (target.text) parts.push(`text="${target.text}"`);
|
|
160
|
+
if (target.label) parts.push(`label="${target.label}"`);
|
|
161
|
+
if (target.testId) parts.push(`testId="${target.testId}"`);
|
|
162
|
+
if (target.selector) parts.push(`selector="${target.selector}"`);
|
|
163
|
+
return `element [${parts.join(", ")}]`;
|
|
164
|
+
}
|
|
165
|
+
function buildStrategies(opts) {
|
|
166
|
+
const strategies = [];
|
|
167
|
+
const matchType = opts.exact ? "full" : "partial";
|
|
168
|
+
if (opts.selector) {
|
|
169
|
+
strategies.push({
|
|
170
|
+
locator: { type: "css", value: opts.selector },
|
|
171
|
+
strategy: "css"
|
|
172
|
+
});
|
|
173
|
+
return strategies;
|
|
174
|
+
}
|
|
175
|
+
if (opts.testId) {
|
|
176
|
+
strategies.push({
|
|
177
|
+
locator: { type: "css", value: `[data-testid="${opts.testId}"]` },
|
|
178
|
+
strategy: "testId"
|
|
179
|
+
});
|
|
180
|
+
return strategies;
|
|
181
|
+
}
|
|
182
|
+
const name = opts.name ?? opts.text;
|
|
183
|
+
if (name) {
|
|
184
|
+
if (opts.role) {
|
|
185
|
+
strategies.push({
|
|
186
|
+
locator: {
|
|
187
|
+
type: "accessibility",
|
|
188
|
+
value: { role: opts.role, name }
|
|
189
|
+
},
|
|
190
|
+
strategy: `accessibility[role="${opts.role}", name="${name}"]`
|
|
191
|
+
});
|
|
192
|
+
} else {
|
|
193
|
+
for (const role of ["button", "link", "menuitem", "tab"]) {
|
|
194
|
+
strategies.push({
|
|
195
|
+
locator: {
|
|
196
|
+
type: "accessibility",
|
|
197
|
+
value: { role, name }
|
|
198
|
+
},
|
|
199
|
+
strategy: `accessibility[role="${role}", name="${name}"]`
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
strategies.push({
|
|
204
|
+
locator: {
|
|
205
|
+
type: "innerText",
|
|
206
|
+
value: name,
|
|
207
|
+
matchType,
|
|
208
|
+
ignoreCase: !opts.exact
|
|
209
|
+
},
|
|
210
|
+
strategy: `innerText("${name}")`
|
|
211
|
+
});
|
|
212
|
+
if (name.match(/^[#.\[a-z]/i)) {
|
|
213
|
+
strategies.push({
|
|
214
|
+
locator: { type: "css", value: name },
|
|
215
|
+
strategy: `css("${name}")`
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (opts.label) {
|
|
220
|
+
strategies.push({
|
|
221
|
+
locator: {
|
|
222
|
+
type: "accessibility",
|
|
223
|
+
value: { name: opts.label }
|
|
224
|
+
},
|
|
225
|
+
strategy: `label("${opts.label}")`
|
|
226
|
+
});
|
|
227
|
+
strategies.push({
|
|
228
|
+
locator: {
|
|
229
|
+
type: "css",
|
|
230
|
+
value: `[aria-label="${opts.label}"], [placeholder="${opts.label}"]`
|
|
231
|
+
},
|
|
232
|
+
strategy: `label-css("${opts.label}")`
|
|
233
|
+
});
|
|
234
|
+
strategies.push({
|
|
235
|
+
locator: {
|
|
236
|
+
type: "innerText",
|
|
237
|
+
value: opts.label,
|
|
238
|
+
matchType,
|
|
239
|
+
ignoreCase: !opts.exact
|
|
240
|
+
},
|
|
241
|
+
strategy: `label-text("${opts.label}")`,
|
|
242
|
+
isLabelLookup: true
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (opts.role && !name) {
|
|
246
|
+
strategies.push({
|
|
247
|
+
locator: {
|
|
248
|
+
type: "accessibility",
|
|
249
|
+
value: { role: opts.role }
|
|
250
|
+
},
|
|
251
|
+
strategy: `role("${opts.role}")`
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return strategies;
|
|
255
|
+
}
|
|
256
|
+
async function resolveLabelsToInputs(session, contextId, nodes) {
|
|
257
|
+
for (const node of nodes) {
|
|
258
|
+
if (!node.sharedId) continue;
|
|
259
|
+
try {
|
|
260
|
+
const result = await session.script.callFunction({
|
|
261
|
+
functionDeclaration: `function(el) {
|
|
262
|
+
// If the element is a <label> with a 'for' attribute, find the associated input
|
|
263
|
+
if (el.tagName === 'LABEL') {
|
|
264
|
+
const forId = el.getAttribute('for');
|
|
265
|
+
if (forId) {
|
|
266
|
+
const input = document.getElementById(forId);
|
|
267
|
+
if (input) return input;
|
|
268
|
+
}
|
|
269
|
+
// Also check for implicit label association (input nested inside label)
|
|
270
|
+
const nested = el.querySelector('input, textarea, select');
|
|
271
|
+
if (nested) return nested;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}`,
|
|
275
|
+
target: { context: contextId },
|
|
276
|
+
arguments: [{ sharedId: node.sharedId, handle: node.handle }],
|
|
277
|
+
awaitPromise: false,
|
|
278
|
+
resultOwnership: "root"
|
|
279
|
+
});
|
|
280
|
+
if (result.type === "success" && result.result?.type === "node" && result.result.sharedId) {
|
|
281
|
+
return result.result;
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/page.ts
|
|
291
|
+
var Page = class {
|
|
292
|
+
/** @internal */
|
|
293
|
+
session;
|
|
294
|
+
/** @internal */
|
|
295
|
+
contextId;
|
|
296
|
+
/** @internal */
|
|
297
|
+
config;
|
|
298
|
+
/** @internal */
|
|
299
|
+
interceptIds = [];
|
|
300
|
+
/** @internal -- event listener unsubscribe functions for cleanup */
|
|
301
|
+
eventCleanups = [];
|
|
302
|
+
constructor(session, contextId, config) {
|
|
303
|
+
this.session = session;
|
|
304
|
+
this.contextId = contextId;
|
|
305
|
+
this.config = config;
|
|
306
|
+
}
|
|
307
|
+
// -----------------------------------------------------------------------
|
|
308
|
+
// Navigation
|
|
309
|
+
// -----------------------------------------------------------------------
|
|
310
|
+
/**
|
|
311
|
+
* Navigate to a URL.
|
|
312
|
+
*
|
|
313
|
+
* ```ts
|
|
314
|
+
* await page.goto('https://example.com');
|
|
315
|
+
* await page.goto('/login'); // uses baseURL from config
|
|
316
|
+
* ```
|
|
317
|
+
*/
|
|
318
|
+
async goto(url, options) {
|
|
319
|
+
const fullUrl = this.resolveURL(url);
|
|
320
|
+
const waitUntil = options?.waitUntil ?? "complete";
|
|
321
|
+
await this.session.browsingContext.navigate({
|
|
322
|
+
context: this.contextId,
|
|
323
|
+
url: fullUrl,
|
|
324
|
+
wait: waitUntil
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Reload the current page.
|
|
329
|
+
*/
|
|
330
|
+
async reload(options) {
|
|
331
|
+
await this.session.browsingContext.reload({
|
|
332
|
+
context: this.contextId,
|
|
333
|
+
wait: options?.waitUntil ?? "complete"
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Go back in browser history.
|
|
338
|
+
*/
|
|
339
|
+
async goBack() {
|
|
340
|
+
await this.session.browsingContext.traverseHistory({
|
|
341
|
+
context: this.contextId,
|
|
342
|
+
delta: -1
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Go forward in browser history.
|
|
347
|
+
*/
|
|
348
|
+
async goForward() {
|
|
349
|
+
await this.session.browsingContext.traverseHistory({
|
|
350
|
+
context: this.contextId,
|
|
351
|
+
delta: 1
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
// -----------------------------------------------------------------------
|
|
355
|
+
// Element interaction - the "stupidly simple" API
|
|
356
|
+
// -----------------------------------------------------------------------
|
|
357
|
+
/**
|
|
358
|
+
* Click an element. Finds it by text, role, or selector -- auto-waits.
|
|
359
|
+
*
|
|
360
|
+
* ```ts
|
|
361
|
+
* await page.click('Submit'); // by text
|
|
362
|
+
* await page.click({ role: 'button', name: 'Submit' }); // precise
|
|
363
|
+
* await page.click({ selector: '#my-button' }); // CSS
|
|
364
|
+
* ```
|
|
365
|
+
*/
|
|
366
|
+
async click(target, options) {
|
|
367
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
368
|
+
const located = await locateElement(this.session, this.contextId, target, { timeout });
|
|
369
|
+
await this.scrollIntoViewAndClick(located, options);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Fill a text input. First arg finds the input, second is the value.
|
|
373
|
+
*
|
|
374
|
+
* ```ts
|
|
375
|
+
* await page.fill('Email', 'user@test.com'); // by label
|
|
376
|
+
* await page.fill('Search', 'browsecraft'); // by placeholder
|
|
377
|
+
* await page.fill({ selector: '#email' }, 'test'); // by CSS
|
|
378
|
+
* ```
|
|
379
|
+
*/
|
|
380
|
+
async fill(target, value, options) {
|
|
381
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
382
|
+
const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
|
|
383
|
+
const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
|
|
384
|
+
const ref = this.getSharedRef(located.node);
|
|
385
|
+
await this.session.script.callFunction({
|
|
386
|
+
functionDeclaration: `function(element, value) {
|
|
387
|
+
element.focus();
|
|
388
|
+
// Use native setter to bypass React/Vue/Angular internal value trackers.
|
|
389
|
+
// Frameworks like React override the value property on input elements,
|
|
390
|
+
// so setting element.value directly doesn't trigger their state updates.
|
|
391
|
+
const proto = element.tagName === 'TEXTAREA'
|
|
392
|
+
? HTMLTextAreaElement.prototype
|
|
393
|
+
: HTMLInputElement.prototype;
|
|
394
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
395
|
+
if (nativeSetter) {
|
|
396
|
+
nativeSetter.call(element, '');
|
|
397
|
+
nativeSetter.call(element, value);
|
|
398
|
+
} else {
|
|
399
|
+
element.value = '';
|
|
400
|
+
element.value = value;
|
|
401
|
+
}
|
|
402
|
+
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
403
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
404
|
+
}`,
|
|
405
|
+
target: { context: this.contextId },
|
|
406
|
+
arguments: [ref, { type: "string", value }],
|
|
407
|
+
awaitPromise: false
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Type text character by character (triggers keyboard events).
|
|
412
|
+
* Use this instead of fill() when you need realistic keyboard input.
|
|
413
|
+
*
|
|
414
|
+
* ```ts
|
|
415
|
+
* await page.type('Search', 'browsecraft');
|
|
416
|
+
* ```
|
|
417
|
+
*/
|
|
418
|
+
async type(target, text, options) {
|
|
419
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
420
|
+
const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
|
|
421
|
+
const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
|
|
422
|
+
const ref = this.getSharedRef(located.node);
|
|
423
|
+
await this.session.script.callFunction({
|
|
424
|
+
functionDeclaration: "function(el) { el.focus(); }",
|
|
425
|
+
target: { context: this.contextId },
|
|
426
|
+
arguments: [ref],
|
|
427
|
+
awaitPromise: false
|
|
428
|
+
});
|
|
429
|
+
const actions = [];
|
|
430
|
+
for (const char of text) {
|
|
431
|
+
actions.push({ type: "keyDown", value: char });
|
|
432
|
+
actions.push({ type: "keyUp", value: char });
|
|
433
|
+
}
|
|
434
|
+
await this.session.input.performActions({
|
|
435
|
+
context: this.contextId,
|
|
436
|
+
actions: [{ type: "key", id: "keyboard", actions }]
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Select an option from a <select> dropdown.
|
|
441
|
+
*
|
|
442
|
+
* ```ts
|
|
443
|
+
* await page.select('Country', 'United States');
|
|
444
|
+
* ```
|
|
445
|
+
*/
|
|
446
|
+
async select(target, value, options) {
|
|
447
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
448
|
+
const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
|
|
449
|
+
const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
|
|
450
|
+
const ref = this.getSharedRef(located.node);
|
|
451
|
+
await this.session.script.callFunction({
|
|
452
|
+
functionDeclaration: `function(element, value) {
|
|
453
|
+
const options = Array.from(element.options);
|
|
454
|
+
const option = options.find(o => o.value === value || o.text === value || o.textContent.trim() === value);
|
|
455
|
+
if (option) {
|
|
456
|
+
element.value = option.value;
|
|
457
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
458
|
+
} else {
|
|
459
|
+
throw new Error('Option "' + value + '" not found in <select>');
|
|
460
|
+
}
|
|
461
|
+
}`,
|
|
462
|
+
target: { context: this.contextId },
|
|
463
|
+
arguments: [ref, { type: "string", value }],
|
|
464
|
+
awaitPromise: false
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Check a checkbox or radio button.
|
|
469
|
+
*
|
|
470
|
+
* ```ts
|
|
471
|
+
* await page.check('I agree to the terms');
|
|
472
|
+
* ```
|
|
473
|
+
*/
|
|
474
|
+
async check(target, options) {
|
|
475
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
476
|
+
const located = await locateElement(this.session, this.contextId, target, { timeout });
|
|
477
|
+
const ref = this.getSharedRef(located.node);
|
|
478
|
+
const result = await this.session.script.callFunction({
|
|
479
|
+
functionDeclaration: "function(el) { return el.checked; }",
|
|
480
|
+
target: { context: this.contextId },
|
|
481
|
+
arguments: [ref],
|
|
482
|
+
awaitPromise: false
|
|
483
|
+
});
|
|
484
|
+
const isChecked = result.type === "success" && result.result?.type === "boolean" && result.result.value === true;
|
|
485
|
+
if (!isChecked) {
|
|
486
|
+
await this.scrollIntoViewAndClick(located, options);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Uncheck a checkbox.
|
|
491
|
+
*/
|
|
492
|
+
async uncheck(target, options) {
|
|
493
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
494
|
+
const located = await locateElement(this.session, this.contextId, target, { timeout });
|
|
495
|
+
const ref = this.getSharedRef(located.node);
|
|
496
|
+
const result = await this.session.script.callFunction({
|
|
497
|
+
functionDeclaration: "function(el) { return el.checked; }",
|
|
498
|
+
target: { context: this.contextId },
|
|
499
|
+
arguments: [ref],
|
|
500
|
+
awaitPromise: false
|
|
501
|
+
});
|
|
502
|
+
const isChecked = result.type === "success" && result.result?.type === "boolean" && result.result.value === true;
|
|
503
|
+
if (isChecked) {
|
|
504
|
+
await this.scrollIntoViewAndClick(located, options);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Hover over an element.
|
|
509
|
+
*
|
|
510
|
+
* ```ts
|
|
511
|
+
* await page.hover('Profile Menu');
|
|
512
|
+
* ```
|
|
513
|
+
*/
|
|
514
|
+
async hover(target, options) {
|
|
515
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
516
|
+
const located = await locateElement(this.session, this.contextId, target, { timeout });
|
|
517
|
+
const ref = this.getSharedRef(located.node);
|
|
518
|
+
await this.session.script.callFunction({
|
|
519
|
+
functionDeclaration: 'function(el) { el.scrollIntoView({ block: "center", behavior: "instant" }); }',
|
|
520
|
+
target: { context: this.contextId },
|
|
521
|
+
arguments: [ref],
|
|
522
|
+
awaitPromise: false
|
|
523
|
+
});
|
|
524
|
+
await this.session.script.callFunction({
|
|
525
|
+
functionDeclaration: `function(el) {
|
|
526
|
+
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
|
527
|
+
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
|
528
|
+
}`,
|
|
529
|
+
target: { context: this.contextId },
|
|
530
|
+
arguments: [ref],
|
|
531
|
+
awaitPromise: false
|
|
532
|
+
});
|
|
533
|
+
const pos = await this.getElementCenter(ref);
|
|
534
|
+
await this.session.input.performActions({
|
|
535
|
+
context: this.contextId,
|
|
536
|
+
actions: [{
|
|
537
|
+
type: "pointer",
|
|
538
|
+
id: "mouse",
|
|
539
|
+
parameters: { pointerType: "mouse" },
|
|
540
|
+
actions: [
|
|
541
|
+
{ type: "pointerMove", x: pos.x, y: pos.y, origin: "viewport" }
|
|
542
|
+
]
|
|
543
|
+
}]
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
// -----------------------------------------------------------------------
|
|
547
|
+
// Finding elements (for assertions and inspection)
|
|
548
|
+
// -----------------------------------------------------------------------
|
|
549
|
+
/**
|
|
550
|
+
* Get a single element. Returns an ElementHandle for assertions.
|
|
551
|
+
*
|
|
552
|
+
* ```ts
|
|
553
|
+
* const heading = page.get('Welcome back!');
|
|
554
|
+
* await expect(heading).toBeVisible();
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
get(target) {
|
|
558
|
+
return new ElementHandle(this, target);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Get a locator by visible text. Alias for get() with text matching.
|
|
562
|
+
*
|
|
563
|
+
* ```ts
|
|
564
|
+
* const btn = page.getByText('Submit');
|
|
565
|
+
* ```
|
|
566
|
+
*/
|
|
567
|
+
getByText(text, options) {
|
|
568
|
+
return new ElementHandle(this, { text, exact: options?.exact });
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Get a locator by ARIA role and optional name.
|
|
572
|
+
*
|
|
573
|
+
* ```ts
|
|
574
|
+
* const btn = page.getByRole('button', { name: 'Submit' });
|
|
575
|
+
* ```
|
|
576
|
+
*/
|
|
577
|
+
getByRole(role, options) {
|
|
578
|
+
return new ElementHandle(this, { role, name: options?.name, exact: options?.exact });
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Get a locator by label text (for form inputs).
|
|
582
|
+
*
|
|
583
|
+
* ```ts
|
|
584
|
+
* const email = page.getByLabel('Email Address');
|
|
585
|
+
* ```
|
|
586
|
+
*/
|
|
587
|
+
getByLabel(label, options) {
|
|
588
|
+
return new ElementHandle(this, { label, exact: options?.exact });
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Get a locator by data-testid attribute.
|
|
592
|
+
*
|
|
593
|
+
* ```ts
|
|
594
|
+
* const card = page.getByTestId('user-card');
|
|
595
|
+
* ```
|
|
596
|
+
*/
|
|
597
|
+
getByTestId(testId) {
|
|
598
|
+
return new ElementHandle(this, { testId });
|
|
599
|
+
}
|
|
600
|
+
// -----------------------------------------------------------------------
|
|
601
|
+
// Page state
|
|
602
|
+
// -----------------------------------------------------------------------
|
|
603
|
+
/** Get the current page URL */
|
|
604
|
+
async url() {
|
|
605
|
+
const result = await this.session.script.evaluate({
|
|
606
|
+
expression: "window.location.href",
|
|
607
|
+
target: { context: this.contextId },
|
|
608
|
+
awaitPromise: false
|
|
609
|
+
});
|
|
610
|
+
return this.extractStringResult(result) ?? "";
|
|
611
|
+
}
|
|
612
|
+
/** Get the page title */
|
|
613
|
+
async title() {
|
|
614
|
+
const result = await this.session.script.evaluate({
|
|
615
|
+
expression: "document.title",
|
|
616
|
+
target: { context: this.contextId },
|
|
617
|
+
awaitPromise: false
|
|
618
|
+
});
|
|
619
|
+
return this.extractStringResult(result) ?? "";
|
|
620
|
+
}
|
|
621
|
+
/** Get the full page HTML content */
|
|
622
|
+
async content() {
|
|
623
|
+
const result = await this.session.script.evaluate({
|
|
624
|
+
expression: "document.documentElement.outerHTML",
|
|
625
|
+
target: { context: this.contextId },
|
|
626
|
+
awaitPromise: false
|
|
627
|
+
});
|
|
628
|
+
return this.extractStringResult(result) ?? "";
|
|
629
|
+
}
|
|
630
|
+
// -----------------------------------------------------------------------
|
|
631
|
+
// Cookies
|
|
632
|
+
// -----------------------------------------------------------------------
|
|
633
|
+
/**
|
|
634
|
+
* Get cookies for the current page's browsing context.
|
|
635
|
+
*
|
|
636
|
+
* ```ts
|
|
637
|
+
* const cookies = await page.cookies();
|
|
638
|
+
* const session = cookies.find(c => c.name === 'session_id');
|
|
639
|
+
* ```
|
|
640
|
+
*/
|
|
641
|
+
async cookies(filter) {
|
|
642
|
+
const result = await this.session.storage.getCookies({
|
|
643
|
+
filter: filter ? {
|
|
644
|
+
name: filter.name,
|
|
645
|
+
domain: filter.domain,
|
|
646
|
+
path: filter.path
|
|
647
|
+
} : void 0,
|
|
648
|
+
partition: { type: "context", context: this.contextId }
|
|
649
|
+
});
|
|
650
|
+
return result.cookies;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Set one or more cookies.
|
|
654
|
+
*
|
|
655
|
+
* ```ts
|
|
656
|
+
* await page.setCookies([
|
|
657
|
+
* { name: 'token', value: 'abc123', domain: 'example.com' },
|
|
658
|
+
* { name: 'theme', value: 'dark', domain: 'example.com' },
|
|
659
|
+
* ]);
|
|
660
|
+
* ```
|
|
661
|
+
*/
|
|
662
|
+
async setCookies(cookies) {
|
|
663
|
+
for (const cookie of cookies) {
|
|
664
|
+
const cookieHeader = {
|
|
665
|
+
name: cookie.name,
|
|
666
|
+
value: { type: "string", value: cookie.value },
|
|
667
|
+
domain: cookie.domain,
|
|
668
|
+
path: cookie.path,
|
|
669
|
+
httpOnly: cookie.httpOnly,
|
|
670
|
+
secure: cookie.secure,
|
|
671
|
+
sameSite: cookie.sameSite,
|
|
672
|
+
expiry: cookie.expiry
|
|
673
|
+
};
|
|
674
|
+
await this.session.storage.setCookie({
|
|
675
|
+
cookie: cookieHeader,
|
|
676
|
+
partition: { type: "context", context: this.contextId }
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Clear all cookies (or those matching a filter).
|
|
682
|
+
*
|
|
683
|
+
* ```ts
|
|
684
|
+
* await page.clearCookies(); // all cookies
|
|
685
|
+
* await page.clearCookies({ name: 'session_id' }); // specific cookie
|
|
686
|
+
* await page.clearCookies({ domain: 'example.com' }); // by domain
|
|
687
|
+
* ```
|
|
688
|
+
*/
|
|
689
|
+
async clearCookies(filter) {
|
|
690
|
+
await this.session.storage.deleteCookies({
|
|
691
|
+
filter: filter ? {
|
|
692
|
+
name: filter.name,
|
|
693
|
+
domain: filter.domain,
|
|
694
|
+
path: filter.path
|
|
695
|
+
} : void 0,
|
|
696
|
+
partition: { type: "context", context: this.contextId }
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
// -----------------------------------------------------------------------
|
|
700
|
+
// JavaScript execution
|
|
701
|
+
// -----------------------------------------------------------------------
|
|
702
|
+
/**
|
|
703
|
+
* Execute JavaScript in the page and return the result.
|
|
704
|
+
*
|
|
705
|
+
* ```ts
|
|
706
|
+
* const title = await page.evaluate('document.title');
|
|
707
|
+
* const count = await page.evaluate(() => document.querySelectorAll('li').length);
|
|
708
|
+
* ```
|
|
709
|
+
*/
|
|
710
|
+
async evaluate(expression) {
|
|
711
|
+
const expr = typeof expression === "function" ? `(${expression.toString()})()` : expression;
|
|
712
|
+
const result = await this.session.script.evaluate({
|
|
713
|
+
expression: expr,
|
|
714
|
+
target: { context: this.contextId },
|
|
715
|
+
awaitPromise: true
|
|
716
|
+
});
|
|
717
|
+
if (result.type === "exception") {
|
|
718
|
+
const errorText = result.exceptionDetails?.text ?? "Script evaluation failed";
|
|
719
|
+
throw new Error(errorText);
|
|
720
|
+
}
|
|
721
|
+
return this.deserializeRemoteValue(result.result);
|
|
722
|
+
}
|
|
723
|
+
// -----------------------------------------------------------------------
|
|
724
|
+
// Screenshots
|
|
725
|
+
// -----------------------------------------------------------------------
|
|
726
|
+
/**
|
|
727
|
+
* Take a screenshot of the page.
|
|
728
|
+
*
|
|
729
|
+
* ```ts
|
|
730
|
+
* const buffer = await page.screenshot();
|
|
731
|
+
* ```
|
|
732
|
+
*/
|
|
733
|
+
async screenshot() {
|
|
734
|
+
const result = await this.session.browsingContext.captureScreenshot({
|
|
735
|
+
context: this.contextId
|
|
736
|
+
});
|
|
737
|
+
return Buffer.from(result.data, "base64");
|
|
738
|
+
}
|
|
739
|
+
// -----------------------------------------------------------------------
|
|
740
|
+
// Network mocking
|
|
741
|
+
// -----------------------------------------------------------------------
|
|
742
|
+
/**
|
|
743
|
+
* Mock a network request with a fake response.
|
|
744
|
+
*
|
|
745
|
+
* ```ts
|
|
746
|
+
* await page.mock('POST /api/login', { status: 200, body: { token: 'abc' } });
|
|
747
|
+
* await page.mock('GET /api/users', { status: 500 });
|
|
748
|
+
* await page.mock('https://api.example.com/data', { body: { items: [] } });
|
|
749
|
+
* ```
|
|
750
|
+
*/
|
|
751
|
+
async mock(pattern, response) {
|
|
752
|
+
const { method, urlPattern } = parseMockPattern(pattern);
|
|
753
|
+
await this.session.subscribe(
|
|
754
|
+
["network.beforeRequestSent"],
|
|
755
|
+
[this.contextId]
|
|
756
|
+
);
|
|
757
|
+
const result = await this.session.network.addIntercept({
|
|
758
|
+
phases: ["beforeRequestSent"],
|
|
759
|
+
urlPatterns: [urlPattern],
|
|
760
|
+
contexts: [this.contextId]
|
|
761
|
+
});
|
|
762
|
+
this.interceptIds.push(result.intercept);
|
|
763
|
+
const unsubscribe = this.session.on("network.beforeRequestSent", async (event) => {
|
|
764
|
+
const params = event.params;
|
|
765
|
+
if (!params.isBlocked || params.context !== this.contextId) return;
|
|
766
|
+
if (!params.intercepts?.includes(result.intercept)) return;
|
|
767
|
+
if (method && params.request.method.toUpperCase() !== method.toUpperCase()) {
|
|
768
|
+
await this.session.network.continueRequest({ request: params.request.request });
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const headers = buildMockHeaders(response);
|
|
772
|
+
const body = buildMockBody(response);
|
|
773
|
+
await this.session.network.provideResponse({
|
|
774
|
+
request: params.request.request,
|
|
775
|
+
statusCode: response.status ?? 200,
|
|
776
|
+
headers,
|
|
777
|
+
body: body ? { type: "string", value: body } : void 0
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
this.eventCleanups.push(unsubscribe);
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Remove all network mocks and clean up event listeners.
|
|
784
|
+
*/
|
|
785
|
+
async clearMocks() {
|
|
786
|
+
for (const id of this.interceptIds) {
|
|
787
|
+
await this.session.network.removeIntercept({ intercept: id }).catch(() => {
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
this.interceptIds = [];
|
|
791
|
+
for (const cleanup of this.eventCleanups) {
|
|
792
|
+
cleanup();
|
|
793
|
+
}
|
|
794
|
+
this.eventCleanups = [];
|
|
795
|
+
}
|
|
796
|
+
// -----------------------------------------------------------------------
|
|
797
|
+
// Dialog handling
|
|
798
|
+
// -----------------------------------------------------------------------
|
|
799
|
+
/**
|
|
800
|
+
* Accept the next dialog (alert, confirm, prompt).
|
|
801
|
+
*
|
|
802
|
+
* ```ts
|
|
803
|
+
* await page.acceptDialog();
|
|
804
|
+
* page.click('Delete'); // triggers confirm dialog
|
|
805
|
+
* ```
|
|
806
|
+
*/
|
|
807
|
+
async acceptDialog(text) {
|
|
808
|
+
await this.session.subscribe(["browsingContext.userPromptOpened"], [this.contextId]);
|
|
809
|
+
await this.session.waitForEvent(
|
|
810
|
+
"browsingContext.userPromptOpened",
|
|
811
|
+
(e) => e.params.context === this.contextId
|
|
812
|
+
);
|
|
813
|
+
await this.session.browsingContext.handleUserPrompt({
|
|
814
|
+
context: this.contextId,
|
|
815
|
+
accept: true,
|
|
816
|
+
userText: text
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Dismiss the next dialog.
|
|
821
|
+
*/
|
|
822
|
+
async dismissDialog() {
|
|
823
|
+
await this.session.subscribe(["browsingContext.userPromptOpened"], [this.contextId]);
|
|
824
|
+
await this.session.waitForEvent(
|
|
825
|
+
"browsingContext.userPromptOpened",
|
|
826
|
+
(e) => e.params.context === this.contextId
|
|
827
|
+
);
|
|
828
|
+
await this.session.browsingContext.handleUserPrompt({
|
|
829
|
+
context: this.contextId,
|
|
830
|
+
accept: false
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Double-click an element.
|
|
835
|
+
*
|
|
836
|
+
* ```ts
|
|
837
|
+
* await page.dblclick('Edit');
|
|
838
|
+
* ```
|
|
839
|
+
*/
|
|
840
|
+
async dblclick(target, options) {
|
|
841
|
+
await this.click(target, { ...options, clickCount: 2 });
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Tap an element (touch gesture).
|
|
845
|
+
*
|
|
846
|
+
* ```ts
|
|
847
|
+
* await page.tap('Menu');
|
|
848
|
+
* ```
|
|
849
|
+
*/
|
|
850
|
+
async tap(target, options) {
|
|
851
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
852
|
+
const located = await locateElement(this.session, this.contextId, target, { timeout });
|
|
853
|
+
const ref = this.getSharedRef(located.node);
|
|
854
|
+
await this.session.script.callFunction({
|
|
855
|
+
functionDeclaration: 'function(el) { el.scrollIntoView({ block: "center", behavior: "instant" }); }',
|
|
856
|
+
target: { context: this.contextId },
|
|
857
|
+
arguments: [ref],
|
|
858
|
+
awaitPromise: false
|
|
859
|
+
});
|
|
860
|
+
const pos = await this.getElementCenter(ref);
|
|
861
|
+
await this.session.input.performActions({
|
|
862
|
+
context: this.contextId,
|
|
863
|
+
actions: [{
|
|
864
|
+
type: "pointer",
|
|
865
|
+
id: "touch",
|
|
866
|
+
parameters: { pointerType: "touch" },
|
|
867
|
+
actions: [
|
|
868
|
+
{ type: "pointerMove", x: pos.x, y: pos.y, origin: "viewport" },
|
|
869
|
+
{ type: "pointerDown", button: 0 },
|
|
870
|
+
{ type: "pointerUp", button: 0 }
|
|
871
|
+
]
|
|
872
|
+
}]
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Focus an element.
|
|
877
|
+
*
|
|
878
|
+
* ```ts
|
|
879
|
+
* await page.focus('Email');
|
|
880
|
+
* ```
|
|
881
|
+
*/
|
|
882
|
+
async focus(target, options) {
|
|
883
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
884
|
+
const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
|
|
885
|
+
const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
|
|
886
|
+
const ref = this.getSharedRef(located.node);
|
|
887
|
+
await this.session.script.callFunction({
|
|
888
|
+
functionDeclaration: "function(el) { el.focus(); }",
|
|
889
|
+
target: { context: this.contextId },
|
|
890
|
+
arguments: [ref],
|
|
891
|
+
awaitPromise: false
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Remove focus from an element.
|
|
896
|
+
*
|
|
897
|
+
* ```ts
|
|
898
|
+
* await page.blur('Email');
|
|
899
|
+
* ```
|
|
900
|
+
*/
|
|
901
|
+
async blur(target, options) {
|
|
902
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
903
|
+
const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
|
|
904
|
+
const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
|
|
905
|
+
const ref = this.getSharedRef(located.node);
|
|
906
|
+
await this.session.script.callFunction({
|
|
907
|
+
functionDeclaration: "function(el) { el.blur(); }",
|
|
908
|
+
target: { context: this.contextId },
|
|
909
|
+
arguments: [ref],
|
|
910
|
+
awaitPromise: false
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Get the visible inner text of an element (like element.innerText).
|
|
915
|
+
*
|
|
916
|
+
* ```ts
|
|
917
|
+
* const text = await page.innerText('h1');
|
|
918
|
+
* ```
|
|
919
|
+
*/
|
|
920
|
+
async innerText(target, options) {
|
|
921
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
922
|
+
const located = await locateElement(this.session, this.contextId, target, { timeout });
|
|
923
|
+
const ref = this.getSharedRef(located.node);
|
|
924
|
+
const result = await this.session.script.callFunction({
|
|
925
|
+
functionDeclaration: 'function(el) { return el.innerText || ""; }',
|
|
926
|
+
target: { context: this.contextId },
|
|
927
|
+
arguments: [ref],
|
|
928
|
+
awaitPromise: false
|
|
929
|
+
});
|
|
930
|
+
return this.extractStringResult(result) ?? "";
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Get the innerHTML of an element.
|
|
934
|
+
*
|
|
935
|
+
* ```ts
|
|
936
|
+
* const html = await page.innerHTML('.container');
|
|
937
|
+
* ```
|
|
938
|
+
*/
|
|
939
|
+
async innerHTML(target, options) {
|
|
940
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
941
|
+
const located = await locateElement(this.session, this.contextId, target, { timeout });
|
|
942
|
+
const ref = this.getSharedRef(located.node);
|
|
943
|
+
const result = await this.session.script.callFunction({
|
|
944
|
+
functionDeclaration: 'function(el) { return el.innerHTML || ""; }',
|
|
945
|
+
target: { context: this.contextId },
|
|
946
|
+
arguments: [ref],
|
|
947
|
+
awaitPromise: false
|
|
948
|
+
});
|
|
949
|
+
return this.extractStringResult(result) ?? "";
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Get the current value of an input/textarea/select.
|
|
953
|
+
*
|
|
954
|
+
* ```ts
|
|
955
|
+
* const email = await page.inputValue('Email');
|
|
956
|
+
* ```
|
|
957
|
+
*/
|
|
958
|
+
async inputValue(target, options) {
|
|
959
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
960
|
+
const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
|
|
961
|
+
const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
|
|
962
|
+
const ref = this.getSharedRef(located.node);
|
|
963
|
+
const result = await this.session.script.callFunction({
|
|
964
|
+
functionDeclaration: 'function(el) { return el.value ?? ""; }',
|
|
965
|
+
target: { context: this.contextId },
|
|
966
|
+
arguments: [ref],
|
|
967
|
+
awaitPromise: false
|
|
968
|
+
});
|
|
969
|
+
return this.extractStringResult(result) ?? "";
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Select multiple options from a <select multiple> dropdown.
|
|
973
|
+
*
|
|
974
|
+
* ```ts
|
|
975
|
+
* await page.selectOption('Colors', ['red', 'blue']);
|
|
976
|
+
* ```
|
|
977
|
+
*/
|
|
978
|
+
async selectOption(target, values, options) {
|
|
979
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
980
|
+
const resolvedTarget = typeof target === "string" ? { label: target, name: target } : target;
|
|
981
|
+
const located = await locateElement(this.session, this.contextId, resolvedTarget, { timeout });
|
|
982
|
+
const ref = this.getSharedRef(located.node);
|
|
983
|
+
const valuesArray = Array.isArray(values) ? values : [values];
|
|
984
|
+
await this.session.script.callFunction({
|
|
985
|
+
functionDeclaration: `function(element, values) {
|
|
986
|
+
const opts = Array.from(element.options);
|
|
987
|
+
for (const opt of opts) {
|
|
988
|
+
opt.selected = values.some(v => opt.value === v || opt.text === v || opt.textContent.trim() === v);
|
|
989
|
+
}
|
|
990
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
991
|
+
}`,
|
|
992
|
+
target: { context: this.contextId },
|
|
993
|
+
arguments: [ref, { type: "array", value: valuesArray.map((v) => ({ type: "string", value: v })) }],
|
|
994
|
+
awaitPromise: false
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Drag an element to another element or position.
|
|
999
|
+
*
|
|
1000
|
+
* ```ts
|
|
1001
|
+
* await page.dragTo('Draggable', 'Drop Zone');
|
|
1002
|
+
* ```
|
|
1003
|
+
*/
|
|
1004
|
+
async dragTo(source, dest, options) {
|
|
1005
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
1006
|
+
const sourceLoc = await locateElement(this.session, this.contextId, source, { timeout });
|
|
1007
|
+
const sourceRef = this.getSharedRef(sourceLoc.node);
|
|
1008
|
+
const sourcePos = await this.getElementCenter(sourceRef);
|
|
1009
|
+
const destLoc = await locateElement(this.session, this.contextId, dest, { timeout });
|
|
1010
|
+
const destRef = this.getSharedRef(destLoc.node);
|
|
1011
|
+
const destPos = await this.getElementCenter(destRef);
|
|
1012
|
+
await this.session.input.performActions({
|
|
1013
|
+
context: this.contextId,
|
|
1014
|
+
actions: [{
|
|
1015
|
+
type: "pointer",
|
|
1016
|
+
id: "mouse",
|
|
1017
|
+
parameters: { pointerType: "mouse" },
|
|
1018
|
+
actions: [
|
|
1019
|
+
{ type: "pointerMove", x: sourcePos.x, y: sourcePos.y, origin: "viewport" },
|
|
1020
|
+
{ type: "pointerDown", button: 0 },
|
|
1021
|
+
{ type: "pointerMove", x: destPos.x, y: destPos.y, origin: "viewport", duration: 300 },
|
|
1022
|
+
{ type: "pointerUp", button: 0 }
|
|
1023
|
+
]
|
|
1024
|
+
}]
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
// -----------------------------------------------------------------------
|
|
1028
|
+
// Waiting (explicit -- but usually you don't need these)
|
|
1029
|
+
// -----------------------------------------------------------------------
|
|
1030
|
+
/**
|
|
1031
|
+
* Wait for an element matching the target to appear in the DOM.
|
|
1032
|
+
*
|
|
1033
|
+
* ```ts
|
|
1034
|
+
* await page.waitForSelector('.loaded');
|
|
1035
|
+
* await page.waitForSelector({ role: 'dialog' });
|
|
1036
|
+
* ```
|
|
1037
|
+
*/
|
|
1038
|
+
async waitForSelector(target, options) {
|
|
1039
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
1040
|
+
const state = options?.state ?? "visible";
|
|
1041
|
+
if (state === "hidden") {
|
|
1042
|
+
await waitFor(
|
|
1043
|
+
`element to be hidden`,
|
|
1044
|
+
async () => {
|
|
1045
|
+
try {
|
|
1046
|
+
const elements = await locateAllElements(this.session, this.contextId, target);
|
|
1047
|
+
if (elements.length === 0) return true;
|
|
1048
|
+
const ref = this.getSharedRef(elements[0].node);
|
|
1049
|
+
const result = await this.session.script.callFunction({
|
|
1050
|
+
functionDeclaration: `function(el) {
|
|
1051
|
+
const style = window.getComputedStyle(el);
|
|
1052
|
+
return style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0';
|
|
1053
|
+
}`,
|
|
1054
|
+
target: { context: this.contextId },
|
|
1055
|
+
arguments: [ref],
|
|
1056
|
+
awaitPromise: false
|
|
1057
|
+
});
|
|
1058
|
+
const isHidden = result.type === "success" && result.result?.type === "boolean" && result.result.value === true;
|
|
1059
|
+
return isHidden ? true : null;
|
|
1060
|
+
} catch {
|
|
1061
|
+
return true;
|
|
1062
|
+
}
|
|
1063
|
+
},
|
|
1064
|
+
{ timeout }
|
|
1065
|
+
);
|
|
1066
|
+
return new ElementHandle(this, target);
|
|
1067
|
+
}
|
|
1068
|
+
const located = await locateElement(this.session, this.contextId, target, { timeout });
|
|
1069
|
+
if (state === "visible") {
|
|
1070
|
+
const ref = this.getSharedRef(located.node);
|
|
1071
|
+
await waitFor(
|
|
1072
|
+
`element to be visible`,
|
|
1073
|
+
async () => {
|
|
1074
|
+
const result = await this.session.script.callFunction({
|
|
1075
|
+
functionDeclaration: `function(el) {
|
|
1076
|
+
const style = window.getComputedStyle(el);
|
|
1077
|
+
const rect = el.getBoundingClientRect();
|
|
1078
|
+
return style.display !== 'none' && style.visibility !== 'hidden' &&
|
|
1079
|
+
style.opacity !== '0' && rect.width > 0 && rect.height > 0;
|
|
1080
|
+
}`,
|
|
1081
|
+
target: { context: this.contextId },
|
|
1082
|
+
arguments: [ref],
|
|
1083
|
+
awaitPromise: false
|
|
1084
|
+
});
|
|
1085
|
+
const isVisible = result.type === "success" && result.result?.type === "boolean" && result.result.value === true;
|
|
1086
|
+
return isVisible ? true : null;
|
|
1087
|
+
},
|
|
1088
|
+
{ timeout }
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
return new ElementHandle(this, target);
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Wait for a JavaScript function to return a truthy value.
|
|
1095
|
+
*
|
|
1096
|
+
* ```ts
|
|
1097
|
+
* await page.waitForFunction('document.querySelectorAll("li").length > 5');
|
|
1098
|
+
* await page.waitForFunction(() => window.appReady === true);
|
|
1099
|
+
* ```
|
|
1100
|
+
*/
|
|
1101
|
+
async waitForFunction(expression, options) {
|
|
1102
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
1103
|
+
const expr = typeof expression === "function" ? `(${expression.toString()})()` : expression;
|
|
1104
|
+
return waitFor(
|
|
1105
|
+
"function to return truthy",
|
|
1106
|
+
async () => {
|
|
1107
|
+
const result = await this.session.script.evaluate({
|
|
1108
|
+
expression: expr,
|
|
1109
|
+
target: { context: this.contextId },
|
|
1110
|
+
awaitPromise: true
|
|
1111
|
+
});
|
|
1112
|
+
if (result.type === "exception") return null;
|
|
1113
|
+
const value = this.deserializeRemoteValue(result.result);
|
|
1114
|
+
return value ? value : null;
|
|
1115
|
+
},
|
|
1116
|
+
{ timeout }
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Wait for a specific URL. Usually you use expect(page).toHaveURL() instead.
|
|
1121
|
+
*/
|
|
1122
|
+
async waitForURL(url, options) {
|
|
1123
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
1124
|
+
await waitFor(
|
|
1125
|
+
`URL to match ${url}`,
|
|
1126
|
+
async () => {
|
|
1127
|
+
const currentUrl = await this.url();
|
|
1128
|
+
if (typeof url === "string") {
|
|
1129
|
+
return currentUrl.includes(url) ? true : null;
|
|
1130
|
+
}
|
|
1131
|
+
return url.test(currentUrl) ? true : null;
|
|
1132
|
+
},
|
|
1133
|
+
{ timeout }
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Wait for the page to finish loading.
|
|
1138
|
+
*/
|
|
1139
|
+
async waitForLoadState(state) {
|
|
1140
|
+
await waitForLoadState(this.session, this.contextId, state, this.config.timeout);
|
|
1141
|
+
}
|
|
1142
|
+
// -----------------------------------------------------------------------
|
|
1143
|
+
// Lifecycle
|
|
1144
|
+
// -----------------------------------------------------------------------
|
|
1145
|
+
/**
|
|
1146
|
+
* Close this page/tab.
|
|
1147
|
+
*/
|
|
1148
|
+
async close() {
|
|
1149
|
+
await this.clearMocks();
|
|
1150
|
+
await this.session.browsingContext.close({ context: this.contextId });
|
|
1151
|
+
}
|
|
1152
|
+
// -----------------------------------------------------------------------
|
|
1153
|
+
// Keyboard shortcuts
|
|
1154
|
+
// -----------------------------------------------------------------------
|
|
1155
|
+
/**
|
|
1156
|
+
* Press a keyboard key.
|
|
1157
|
+
*
|
|
1158
|
+
* ```ts
|
|
1159
|
+
* await page.press('Enter');
|
|
1160
|
+
* await page.press('Control+a');
|
|
1161
|
+
* ```
|
|
1162
|
+
*/
|
|
1163
|
+
async press(key) {
|
|
1164
|
+
const keys = key.split("+");
|
|
1165
|
+
const actions = [];
|
|
1166
|
+
for (const k of keys) {
|
|
1167
|
+
actions.push({ type: "keyDown", value: mapKey(k) });
|
|
1168
|
+
}
|
|
1169
|
+
for (const k of keys.reverse()) {
|
|
1170
|
+
actions.push({ type: "keyUp", value: mapKey(k) });
|
|
1171
|
+
}
|
|
1172
|
+
await this.session.input.performActions({
|
|
1173
|
+
context: this.contextId,
|
|
1174
|
+
actions: [{ type: "key", id: "keyboard", actions }]
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
// -----------------------------------------------------------------------
|
|
1178
|
+
// Private helpers
|
|
1179
|
+
// -----------------------------------------------------------------------
|
|
1180
|
+
/** Resolve a relative URL against the baseURL */
|
|
1181
|
+
resolveURL(url) {
|
|
1182
|
+
if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("about:") || url.startsWith("data:")) {
|
|
1183
|
+
return url;
|
|
1184
|
+
}
|
|
1185
|
+
const base = this.config.baseURL.replace(/\/$/, "");
|
|
1186
|
+
const path = url.startsWith("/") ? url : `/${url}`;
|
|
1187
|
+
return base ? `${base}${path}` : url;
|
|
1188
|
+
}
|
|
1189
|
+
/** Get a shared reference from a located node */
|
|
1190
|
+
getSharedRef(node) {
|
|
1191
|
+
if (node.sharedId) {
|
|
1192
|
+
return { sharedId: node.sharedId, handle: node.handle };
|
|
1193
|
+
}
|
|
1194
|
+
throw new Error("Element has no shared reference. This is a bug in Browsecraft.");
|
|
1195
|
+
}
|
|
1196
|
+
/** Get the center coordinates of an element */
|
|
1197
|
+
async getElementCenter(ref) {
|
|
1198
|
+
const result = await this.session.script.callFunction({
|
|
1199
|
+
functionDeclaration: `function(el) {
|
|
1200
|
+
const rect = el.getBoundingClientRect();
|
|
1201
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
1202
|
+
throw new Error('Element has zero size -- it may be hidden or not rendered');
|
|
1203
|
+
}
|
|
1204
|
+
return { x: Math.round(rect.x + rect.width / 2), y: Math.round(rect.y + rect.height / 2) };
|
|
1205
|
+
}`,
|
|
1206
|
+
target: { context: this.contextId },
|
|
1207
|
+
arguments: [ref],
|
|
1208
|
+
awaitPromise: false
|
|
1209
|
+
});
|
|
1210
|
+
if (result.type === "exception") {
|
|
1211
|
+
const errorText = result.exceptionDetails?.text ?? "Failed to get element position";
|
|
1212
|
+
throw new Error(`Cannot interact with element: ${errorText}`);
|
|
1213
|
+
}
|
|
1214
|
+
if (result.type === "success" && result.result?.type === "object") {
|
|
1215
|
+
const val = result.result.value;
|
|
1216
|
+
if (Array.isArray(val)) {
|
|
1217
|
+
const map = new Map(val);
|
|
1218
|
+
const xVal = map.get("x");
|
|
1219
|
+
const yVal = map.get("y");
|
|
1220
|
+
const x = typeof xVal === "object" && xVal !== null && "value" in xVal ? xVal.value : null;
|
|
1221
|
+
const y = typeof yVal === "object" && yVal !== null && "value" in yVal ? yVal.value : null;
|
|
1222
|
+
if (x !== null && y !== null) {
|
|
1223
|
+
return { x, y };
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
throw new Error(
|
|
1228
|
+
"Cannot get element position: unexpected response from browser. The element may not be visible or may not have a bounding rectangle."
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Scroll element into view and click it.
|
|
1233
|
+
*
|
|
1234
|
+
* Uses JavaScript `.click()` as the primary mechanism because it is
|
|
1235
|
+
* immune to viewport coordinate mismatches, DPI scaling, layout shifts,
|
|
1236
|
+
* and Chrome BiDi's unreliable element-origin support. This works
|
|
1237
|
+
* identically in headless and headed mode, with or without slowMo delays.
|
|
1238
|
+
*
|
|
1239
|
+
* The element is scrolled into view first so it's visible in headed mode
|
|
1240
|
+
* (important when users are watching the test run).
|
|
1241
|
+
*/
|
|
1242
|
+
async scrollIntoViewAndClick(located, options) {
|
|
1243
|
+
const ref = this.getSharedRef(located.node);
|
|
1244
|
+
const clickCount = options?.clickCount ?? 1;
|
|
1245
|
+
await this.session.script.callFunction({
|
|
1246
|
+
functionDeclaration: `function(el, clickCount) {
|
|
1247
|
+
el.scrollIntoView({ block: "center", behavior: "instant" });
|
|
1248
|
+
for (let i = 0; i < clickCount; i++) {
|
|
1249
|
+
el.click();
|
|
1250
|
+
}
|
|
1251
|
+
}`,
|
|
1252
|
+
target: { context: this.contextId },
|
|
1253
|
+
arguments: [ref, { type: "number", value: clickCount }],
|
|
1254
|
+
awaitPromise: false
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
/** Extract a string from a script evaluation result */
|
|
1258
|
+
extractStringResult(result) {
|
|
1259
|
+
if (result.type === "success" && result.result?.type === "string") {
|
|
1260
|
+
return result.result.value;
|
|
1261
|
+
}
|
|
1262
|
+
return null;
|
|
1263
|
+
}
|
|
1264
|
+
/** Convert a BiDi RemoteValue back to a JS value */
|
|
1265
|
+
deserializeRemoteValue(value) {
|
|
1266
|
+
if (!value || typeof value !== "object") return value;
|
|
1267
|
+
const v = value;
|
|
1268
|
+
switch (v.type) {
|
|
1269
|
+
case "undefined":
|
|
1270
|
+
return void 0;
|
|
1271
|
+
case "null":
|
|
1272
|
+
return null;
|
|
1273
|
+
case "string":
|
|
1274
|
+
return v.value;
|
|
1275
|
+
case "number": {
|
|
1276
|
+
const n = v.value;
|
|
1277
|
+
if (n === "NaN") return Number.NaN;
|
|
1278
|
+
if (n === "-0") return -0;
|
|
1279
|
+
if (n === "Infinity") return Number.POSITIVE_INFINITY;
|
|
1280
|
+
if (n === "-Infinity") return Number.NEGATIVE_INFINITY;
|
|
1281
|
+
return n;
|
|
1282
|
+
}
|
|
1283
|
+
case "boolean":
|
|
1284
|
+
return v.value;
|
|
1285
|
+
case "bigint":
|
|
1286
|
+
return BigInt(v.value);
|
|
1287
|
+
case "array": {
|
|
1288
|
+
if (Array.isArray(v.value)) {
|
|
1289
|
+
return v.value.map((item) => this.deserializeRemoteValue(item));
|
|
1290
|
+
}
|
|
1291
|
+
return [];
|
|
1292
|
+
}
|
|
1293
|
+
case "object": {
|
|
1294
|
+
if (Array.isArray(v.value)) {
|
|
1295
|
+
const obj = {};
|
|
1296
|
+
for (const [key, val] of v.value) {
|
|
1297
|
+
obj[key] = this.deserializeRemoteValue(val);
|
|
1298
|
+
}
|
|
1299
|
+
return obj;
|
|
1300
|
+
}
|
|
1301
|
+
return {};
|
|
1302
|
+
}
|
|
1303
|
+
default:
|
|
1304
|
+
return v.value ?? null;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
var ElementHandle = class {
|
|
1309
|
+
/** @internal */
|
|
1310
|
+
page;
|
|
1311
|
+
/** @internal */
|
|
1312
|
+
target;
|
|
1313
|
+
constructor(page, target) {
|
|
1314
|
+
this.page = page;
|
|
1315
|
+
this.target = target;
|
|
1316
|
+
}
|
|
1317
|
+
/** Click this element */
|
|
1318
|
+
async click(options) {
|
|
1319
|
+
await this.page.click(this.target, options);
|
|
1320
|
+
}
|
|
1321
|
+
/** Fill this element with text */
|
|
1322
|
+
async fill(value, options) {
|
|
1323
|
+
await this.page.fill(this.target, value, options);
|
|
1324
|
+
}
|
|
1325
|
+
/** Get the visible text content of this element */
|
|
1326
|
+
async textContent() {
|
|
1327
|
+
const located = await this.locate();
|
|
1328
|
+
const ref = this.getRef(located);
|
|
1329
|
+
const result = await this.page.session.script.callFunction({
|
|
1330
|
+
functionDeclaration: 'function(el) { return el.textContent || ""; }',
|
|
1331
|
+
target: { context: this.page.contextId },
|
|
1332
|
+
arguments: [ref],
|
|
1333
|
+
awaitPromise: false
|
|
1334
|
+
});
|
|
1335
|
+
if (result.type === "success" && result.result?.type === "string") {
|
|
1336
|
+
return result.result.value;
|
|
1337
|
+
}
|
|
1338
|
+
return "";
|
|
1339
|
+
}
|
|
1340
|
+
/** Get an attribute value */
|
|
1341
|
+
async getAttribute(name) {
|
|
1342
|
+
const located = await this.locate();
|
|
1343
|
+
const ref = this.getRef(located);
|
|
1344
|
+
const result = await this.page.session.script.callFunction({
|
|
1345
|
+
functionDeclaration: `function(el, name) { return el.getAttribute(name); }`,
|
|
1346
|
+
target: { context: this.page.contextId },
|
|
1347
|
+
arguments: [ref, { type: "string", value: name }],
|
|
1348
|
+
awaitPromise: false
|
|
1349
|
+
});
|
|
1350
|
+
if (result.type === "success" && result.result?.type === "string") {
|
|
1351
|
+
return result.result.value;
|
|
1352
|
+
}
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
/** Check if the element is visible on the page */
|
|
1356
|
+
async isVisible() {
|
|
1357
|
+
try {
|
|
1358
|
+
const located = await this.locate(5e3);
|
|
1359
|
+
const ref = this.getRef(located);
|
|
1360
|
+
const result = await this.page.session.script.callFunction({
|
|
1361
|
+
functionDeclaration: `function(el) {
|
|
1362
|
+
const style = window.getComputedStyle(el);
|
|
1363
|
+
const rect = el.getBoundingClientRect();
|
|
1364
|
+
return style.display !== 'none' &&
|
|
1365
|
+
style.visibility !== 'hidden' &&
|
|
1366
|
+
style.opacity !== '0' &&
|
|
1367
|
+
rect.width > 0 &&
|
|
1368
|
+
rect.height > 0;
|
|
1369
|
+
}`,
|
|
1370
|
+
target: { context: this.page.contextId },
|
|
1371
|
+
arguments: [ref],
|
|
1372
|
+
awaitPromise: false
|
|
1373
|
+
});
|
|
1374
|
+
return result.type === "success" && result.result?.type === "boolean" && result.result.value === true;
|
|
1375
|
+
} catch {
|
|
1376
|
+
return false;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
/** Count matching elements */
|
|
1380
|
+
async count() {
|
|
1381
|
+
const elements = await locateAllElements(this.page.session, this.page.contextId, this.target);
|
|
1382
|
+
return elements.length;
|
|
1383
|
+
}
|
|
1384
|
+
/** Get the visible inner text (like element.innerText, not textContent) */
|
|
1385
|
+
async innerText() {
|
|
1386
|
+
const located = await this.locate();
|
|
1387
|
+
const ref = this.getRef(located);
|
|
1388
|
+
const result = await this.page.session.script.callFunction({
|
|
1389
|
+
functionDeclaration: 'function(el) { return el.innerText || ""; }',
|
|
1390
|
+
target: { context: this.page.contextId },
|
|
1391
|
+
arguments: [ref],
|
|
1392
|
+
awaitPromise: false
|
|
1393
|
+
});
|
|
1394
|
+
if (result.type === "success" && result.result?.type === "string") {
|
|
1395
|
+
return result.result.value;
|
|
1396
|
+
}
|
|
1397
|
+
return "";
|
|
1398
|
+
}
|
|
1399
|
+
/** Get the innerHTML of this element */
|
|
1400
|
+
async innerHTML() {
|
|
1401
|
+
const located = await this.locate();
|
|
1402
|
+
const ref = this.getRef(located);
|
|
1403
|
+
const result = await this.page.session.script.callFunction({
|
|
1404
|
+
functionDeclaration: 'function(el) { return el.innerHTML || ""; }',
|
|
1405
|
+
target: { context: this.page.contextId },
|
|
1406
|
+
arguments: [ref],
|
|
1407
|
+
awaitPromise: false
|
|
1408
|
+
});
|
|
1409
|
+
if (result.type === "success" && result.result?.type === "string") {
|
|
1410
|
+
return result.result.value;
|
|
1411
|
+
}
|
|
1412
|
+
return "";
|
|
1413
|
+
}
|
|
1414
|
+
/** Get the current value of an input/textarea/select */
|
|
1415
|
+
async inputValue() {
|
|
1416
|
+
const located = await this.locate();
|
|
1417
|
+
const ref = this.getRef(located);
|
|
1418
|
+
const result = await this.page.session.script.callFunction({
|
|
1419
|
+
functionDeclaration: 'function(el) { return el.value ?? ""; }',
|
|
1420
|
+
target: { context: this.page.contextId },
|
|
1421
|
+
arguments: [ref],
|
|
1422
|
+
awaitPromise: false
|
|
1423
|
+
});
|
|
1424
|
+
if (result.type === "success" && result.result?.type === "string") {
|
|
1425
|
+
return result.result.value;
|
|
1426
|
+
}
|
|
1427
|
+
return "";
|
|
1428
|
+
}
|
|
1429
|
+
/** Check if the element is enabled (not disabled) */
|
|
1430
|
+
async isEnabled() {
|
|
1431
|
+
try {
|
|
1432
|
+
const located = await this.locate(5e3);
|
|
1433
|
+
const ref = this.getRef(located);
|
|
1434
|
+
const result = await this.page.session.script.callFunction({
|
|
1435
|
+
functionDeclaration: "function(el) { return !el.disabled; }",
|
|
1436
|
+
target: { context: this.page.contextId },
|
|
1437
|
+
arguments: [ref],
|
|
1438
|
+
awaitPromise: false
|
|
1439
|
+
});
|
|
1440
|
+
return result.type === "success" && result.result?.type === "boolean" && result.result.value === true;
|
|
1441
|
+
} catch {
|
|
1442
|
+
return false;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
/** Check if a checkbox/radio is checked */
|
|
1446
|
+
async isChecked() {
|
|
1447
|
+
try {
|
|
1448
|
+
const located = await this.locate(5e3);
|
|
1449
|
+
const ref = this.getRef(located);
|
|
1450
|
+
const result = await this.page.session.script.callFunction({
|
|
1451
|
+
functionDeclaration: "function(el) { return !!el.checked; }",
|
|
1452
|
+
target: { context: this.page.contextId },
|
|
1453
|
+
arguments: [ref],
|
|
1454
|
+
awaitPromise: false
|
|
1455
|
+
});
|
|
1456
|
+
return result.type === "success" && result.result?.type === "boolean" && result.result.value === true;
|
|
1457
|
+
} catch {
|
|
1458
|
+
return false;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
/** Get the bounding box of the element */
|
|
1462
|
+
async boundingBox() {
|
|
1463
|
+
try {
|
|
1464
|
+
const located = await this.locate(5e3);
|
|
1465
|
+
const ref = this.getRef(located);
|
|
1466
|
+
const result = await this.page.session.script.callFunction({
|
|
1467
|
+
functionDeclaration: `function(el) {
|
|
1468
|
+
const rect = el.getBoundingClientRect();
|
|
1469
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
1470
|
+
}`,
|
|
1471
|
+
target: { context: this.page.contextId },
|
|
1472
|
+
arguments: [ref],
|
|
1473
|
+
awaitPromise: false
|
|
1474
|
+
});
|
|
1475
|
+
if (result.type === "success" && result.result?.type === "object") {
|
|
1476
|
+
const val = result.result.value;
|
|
1477
|
+
if (Array.isArray(val)) {
|
|
1478
|
+
const map = new Map(val);
|
|
1479
|
+
const extract = (key) => {
|
|
1480
|
+
const v = map.get(key);
|
|
1481
|
+
return typeof v === "object" && v !== null && "value" in v ? v.value : 0;
|
|
1482
|
+
};
|
|
1483
|
+
return { x: extract("x"), y: extract("y"), width: extract("width"), height: extract("height") };
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
return null;
|
|
1487
|
+
} catch {
|
|
1488
|
+
return null;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
/** Take a screenshot of just this element */
|
|
1492
|
+
async screenshot() {
|
|
1493
|
+
const located = await this.locate();
|
|
1494
|
+
const ref = this.getRef(located);
|
|
1495
|
+
await this.page.session.script.callFunction({
|
|
1496
|
+
functionDeclaration: 'function(el) { el.scrollIntoView({ block: "center", behavior: "instant" }); }',
|
|
1497
|
+
target: { context: this.page.contextId },
|
|
1498
|
+
arguments: [ref],
|
|
1499
|
+
awaitPromise: false
|
|
1500
|
+
});
|
|
1501
|
+
const box = await this.boundingBox();
|
|
1502
|
+
if (!box || box.width === 0 || box.height === 0) {
|
|
1503
|
+
throw new Error("Cannot screenshot element: element has no size or is not visible");
|
|
1504
|
+
}
|
|
1505
|
+
const result = await this.page.session.browsingContext.captureScreenshot({
|
|
1506
|
+
context: this.page.contextId,
|
|
1507
|
+
clip: {
|
|
1508
|
+
type: "box",
|
|
1509
|
+
x: box.x,
|
|
1510
|
+
y: box.y,
|
|
1511
|
+
width: box.width,
|
|
1512
|
+
height: box.height
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
return Buffer.from(result.data, "base64");
|
|
1516
|
+
}
|
|
1517
|
+
/** Double-click this element */
|
|
1518
|
+
async dblclick(options) {
|
|
1519
|
+
await this.page.dblclick(this.target, options);
|
|
1520
|
+
}
|
|
1521
|
+
/** Hover over this element */
|
|
1522
|
+
async hover(options) {
|
|
1523
|
+
await this.page.hover(this.target, options);
|
|
1524
|
+
}
|
|
1525
|
+
/** Type text into this element character by character */
|
|
1526
|
+
async type(text, options) {
|
|
1527
|
+
await this.page.type(this.target, text, options);
|
|
1528
|
+
}
|
|
1529
|
+
/** Focus this element */
|
|
1530
|
+
async focus(options) {
|
|
1531
|
+
await this.page.focus(this.target, options);
|
|
1532
|
+
}
|
|
1533
|
+
/** Remove focus from this element */
|
|
1534
|
+
async blur(options) {
|
|
1535
|
+
await this.page.blur(this.target, options);
|
|
1536
|
+
}
|
|
1537
|
+
/** @internal Locate the element with auto-wait */
|
|
1538
|
+
async locate(timeout) {
|
|
1539
|
+
return locateElement(
|
|
1540
|
+
this.page.session,
|
|
1541
|
+
this.page.contextId,
|
|
1542
|
+
this.target,
|
|
1543
|
+
{ timeout: timeout ?? 3e4 }
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
getRef(located) {
|
|
1547
|
+
if (located.node.sharedId) {
|
|
1548
|
+
return { sharedId: located.node.sharedId, handle: located.node.handle };
|
|
1549
|
+
}
|
|
1550
|
+
throw new Error("Element has no shared reference");
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1553
|
+
function mapKey(key) {
|
|
1554
|
+
const keyMap = {
|
|
1555
|
+
Enter: "\uE007",
|
|
1556
|
+
Tab: "\uE004",
|
|
1557
|
+
Escape: "\uE00C",
|
|
1558
|
+
Backspace: "\uE003",
|
|
1559
|
+
Delete: "\uE017",
|
|
1560
|
+
ArrowUp: "\uE013",
|
|
1561
|
+
ArrowDown: "\uE015",
|
|
1562
|
+
ArrowLeft: "\uE012",
|
|
1563
|
+
ArrowRight: "\uE014",
|
|
1564
|
+
Home: "\uE011",
|
|
1565
|
+
End: "\uE010",
|
|
1566
|
+
PageUp: "\uE00E",
|
|
1567
|
+
PageDown: "\uE00F",
|
|
1568
|
+
Control: "\uE009",
|
|
1569
|
+
Alt: "\uE00A",
|
|
1570
|
+
Shift: "\uE008",
|
|
1571
|
+
Meta: "\uE03D",
|
|
1572
|
+
Space: " "
|
|
1573
|
+
};
|
|
1574
|
+
return keyMap[key] ?? key;
|
|
1575
|
+
}
|
|
1576
|
+
function parseMockPattern(pattern) {
|
|
1577
|
+
const methodMatch = pattern.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(.+)$/i);
|
|
1578
|
+
if (methodMatch) {
|
|
1579
|
+
const method = methodMatch[1].toUpperCase();
|
|
1580
|
+
const path = methodMatch[2];
|
|
1581
|
+
if (path.startsWith("http")) {
|
|
1582
|
+
return { method, urlPattern: { type: "string", pattern: path } };
|
|
1583
|
+
}
|
|
1584
|
+
return { method, urlPattern: { type: "pattern", pathname: path } };
|
|
1585
|
+
}
|
|
1586
|
+
if (pattern.startsWith("http")) {
|
|
1587
|
+
return { method: null, urlPattern: { type: "string", pattern } };
|
|
1588
|
+
}
|
|
1589
|
+
return { method: null, urlPattern: { type: "pattern", pathname: pattern } };
|
|
1590
|
+
}
|
|
1591
|
+
function buildMockHeaders(response) {
|
|
1592
|
+
const headers = [];
|
|
1593
|
+
let contentType = response.contentType;
|
|
1594
|
+
if (!contentType) {
|
|
1595
|
+
if (typeof response.body === "object" && response.body !== null) {
|
|
1596
|
+
contentType = "application/json";
|
|
1597
|
+
} else if (typeof response.body === "string") {
|
|
1598
|
+
contentType = "text/plain";
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
if (contentType) {
|
|
1602
|
+
headers.push({ name: "Content-Type", value: { type: "string", value: contentType } });
|
|
1603
|
+
}
|
|
1604
|
+
if (response.headers) {
|
|
1605
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
1606
|
+
headers.push({ name, value: { type: "string", value } });
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
return headers;
|
|
1610
|
+
}
|
|
1611
|
+
function buildMockBody(response) {
|
|
1612
|
+
if (response.body === void 0 || response.body === null) return null;
|
|
1613
|
+
if (typeof response.body === "string") return response.body;
|
|
1614
|
+
return JSON.stringify(response.body);
|
|
1615
|
+
}
|
|
1616
|
+
async function applyViewport(session, contextId, config) {
|
|
1617
|
+
if (config.maximized && !config.headless) {
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (config.headless) {
|
|
1621
|
+
try {
|
|
1622
|
+
await session.browsingContext.setViewport({
|
|
1623
|
+
context: contextId,
|
|
1624
|
+
viewport: config.viewport
|
|
1625
|
+
});
|
|
1626
|
+
} catch {
|
|
1627
|
+
}
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
try {
|
|
1631
|
+
const { width, height } = config.viewport;
|
|
1632
|
+
await session.script.callFunction({
|
|
1633
|
+
functionDeclaration: `function(targetW, targetH) {
|
|
1634
|
+
const chromeW = window.outerWidth - window.innerWidth;
|
|
1635
|
+
const chromeH = window.outerHeight - window.innerHeight;
|
|
1636
|
+
window.resizeTo(targetW + chromeW, targetH + chromeH);
|
|
1637
|
+
}`,
|
|
1638
|
+
target: { context: contextId },
|
|
1639
|
+
arguments: [
|
|
1640
|
+
{ type: "number", value: width },
|
|
1641
|
+
{ type: "number", value: height }
|
|
1642
|
+
],
|
|
1643
|
+
awaitPromise: false
|
|
1644
|
+
});
|
|
1645
|
+
} catch {
|
|
1646
|
+
try {
|
|
1647
|
+
await session.browsingContext.setViewport({
|
|
1648
|
+
context: contextId,
|
|
1649
|
+
viewport: config.viewport
|
|
1650
|
+
});
|
|
1651
|
+
} catch {
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
var Browser = class _Browser {
|
|
1656
|
+
/** @internal */
|
|
1657
|
+
session;
|
|
1658
|
+
/** @internal */
|
|
1659
|
+
config;
|
|
1660
|
+
/** @internal */
|
|
1661
|
+
pages = [];
|
|
1662
|
+
constructor(session, config) {
|
|
1663
|
+
this.session = session;
|
|
1664
|
+
this.config = config;
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Launch a new browser instance.
|
|
1668
|
+
*
|
|
1669
|
+
* ```ts
|
|
1670
|
+
* const browser = await Browser.launch(); // Chrome, headless
|
|
1671
|
+
* const browser = await Browser.launch({ browser: 'firefox', headless: false });
|
|
1672
|
+
* ```
|
|
1673
|
+
*/
|
|
1674
|
+
static async launch(userConfig) {
|
|
1675
|
+
const config = resolveConfig(userConfig);
|
|
1676
|
+
const sessionOptions = {
|
|
1677
|
+
browser: config.browser,
|
|
1678
|
+
headless: config.headless,
|
|
1679
|
+
executablePath: config.executablePath,
|
|
1680
|
+
debug: config.debug,
|
|
1681
|
+
timeout: config.timeout,
|
|
1682
|
+
maximized: config.maximized
|
|
1683
|
+
};
|
|
1684
|
+
const session = await BiDiSession.launch(sessionOptions);
|
|
1685
|
+
return new _Browser(session, config);
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Connect to an already-running browser.
|
|
1689
|
+
*
|
|
1690
|
+
* ```ts
|
|
1691
|
+
* const browser = await Browser.connect('ws://localhost:9222/session');
|
|
1692
|
+
* ```
|
|
1693
|
+
*/
|
|
1694
|
+
static async connect(wsEndpoint, userConfig) {
|
|
1695
|
+
const config = resolveConfig(userConfig);
|
|
1696
|
+
const session = await BiDiSession.connect(wsEndpoint);
|
|
1697
|
+
return new _Browser(session, config);
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Create a new page (tab).
|
|
1701
|
+
*
|
|
1702
|
+
* ```ts
|
|
1703
|
+
* const page = await browser.newPage();
|
|
1704
|
+
* await page.goto('https://example.com');
|
|
1705
|
+
* ```
|
|
1706
|
+
*/
|
|
1707
|
+
async newPage() {
|
|
1708
|
+
const result = await this.session.browsingContext.create({ type: "tab" });
|
|
1709
|
+
const contextId = result.context;
|
|
1710
|
+
await applyViewport(this.session, contextId, this.config);
|
|
1711
|
+
const page = new Page(this.session, contextId, this.config);
|
|
1712
|
+
this.pages.push(page);
|
|
1713
|
+
this.session.on("browsingContext.closed", (event) => {
|
|
1714
|
+
const params = event.params;
|
|
1715
|
+
if (params.context === contextId) {
|
|
1716
|
+
const idx = this.pages.indexOf(page);
|
|
1717
|
+
if (idx !== -1) this.pages.splice(idx, 1);
|
|
1718
|
+
}
|
|
1719
|
+
});
|
|
1720
|
+
return page;
|
|
1721
|
+
}
|
|
1722
|
+
/** Get all open pages. */
|
|
1723
|
+
get openPages() {
|
|
1724
|
+
return [...this.pages];
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Create an isolated browser context (like incognito).
|
|
1728
|
+
* Returns a BrowserContext that can create its own pages.
|
|
1729
|
+
*/
|
|
1730
|
+
async newContext() {
|
|
1731
|
+
try {
|
|
1732
|
+
const result = await this.session.send("browser.createUserContext", {});
|
|
1733
|
+
const userContext = result.userContext;
|
|
1734
|
+
return new BrowserContext(this.session, this.config, userContext);
|
|
1735
|
+
} catch (err) {
|
|
1736
|
+
console.warn(
|
|
1737
|
+
"[browsecraft] Warning: browser.createUserContext is not supported. Pages will share cookies/storage. " + (err instanceof Error ? err.message : String(err))
|
|
1738
|
+
);
|
|
1739
|
+
return new BrowserContext(this.session, this.config, null);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
/** Close the browser and clean up all resources. */
|
|
1743
|
+
async close() {
|
|
1744
|
+
for (const page of this.pages) {
|
|
1745
|
+
await page.close().catch(() => {
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
this.pages = [];
|
|
1749
|
+
await this.session.close();
|
|
1750
|
+
}
|
|
1751
|
+
/** Whether the browser is still connected */
|
|
1752
|
+
get isConnected() {
|
|
1753
|
+
return this.session.isConnected;
|
|
1754
|
+
}
|
|
1755
|
+
/** Get the resolved config */
|
|
1756
|
+
getConfig() {
|
|
1757
|
+
return { ...this.config };
|
|
1758
|
+
}
|
|
1759
|
+
};
|
|
1760
|
+
var BrowserContext = class {
|
|
1761
|
+
/** @internal */
|
|
1762
|
+
session;
|
|
1763
|
+
/** @internal */
|
|
1764
|
+
config;
|
|
1765
|
+
/** @internal */
|
|
1766
|
+
userContext;
|
|
1767
|
+
/** @internal */
|
|
1768
|
+
pages = [];
|
|
1769
|
+
constructor(session, config, userContext) {
|
|
1770
|
+
this.session = session;
|
|
1771
|
+
this.config = config;
|
|
1772
|
+
this.userContext = userContext;
|
|
1773
|
+
}
|
|
1774
|
+
/** Create a new page in this context. */
|
|
1775
|
+
async newPage() {
|
|
1776
|
+
const params = { type: "tab" };
|
|
1777
|
+
if (this.userContext) {
|
|
1778
|
+
params.userContext = this.userContext;
|
|
1779
|
+
}
|
|
1780
|
+
const result = await this.session.browsingContext.create(params);
|
|
1781
|
+
const contextId = result.context;
|
|
1782
|
+
await applyViewport(this.session, contextId, this.config);
|
|
1783
|
+
const page = new Page(this.session, contextId, this.config);
|
|
1784
|
+
this.pages.push(page);
|
|
1785
|
+
return page;
|
|
1786
|
+
}
|
|
1787
|
+
/** Close this context and all its pages. */
|
|
1788
|
+
async close() {
|
|
1789
|
+
for (const page of this.pages) {
|
|
1790
|
+
await page.close().catch(() => {
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
this.pages = [];
|
|
1794
|
+
if (this.userContext) {
|
|
1795
|
+
await this.session.send("browser.removeUserContext", {
|
|
1796
|
+
userContext: this.userContext
|
|
1797
|
+
}).catch(() => {
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
};
|
|
1802
|
+
var testRegistry = [];
|
|
1803
|
+
var suiteStack = [];
|
|
1804
|
+
var executedBeforeAllHooks = /* @__PURE__ */ new Set();
|
|
1805
|
+
var executedAfterAllHooks = /* @__PURE__ */ new Set();
|
|
1806
|
+
function test(title, fnOrOptions, maybeFn) {
|
|
1807
|
+
const fn = typeof fnOrOptions === "function" ? fnOrOptions : maybeFn;
|
|
1808
|
+
const options = typeof fnOrOptions === "function" ? {} : fnOrOptions;
|
|
1809
|
+
testRegistry.push({
|
|
1810
|
+
title,
|
|
1811
|
+
fn,
|
|
1812
|
+
options,
|
|
1813
|
+
suitePath: [...suiteStack],
|
|
1814
|
+
skip: false,
|
|
1815
|
+
only: false
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
test.skip = function skipTest(title, fn) {
|
|
1819
|
+
testRegistry.push({
|
|
1820
|
+
title,
|
|
1821
|
+
fn,
|
|
1822
|
+
options: {},
|
|
1823
|
+
suitePath: [...suiteStack],
|
|
1824
|
+
skip: true,
|
|
1825
|
+
only: false
|
|
1826
|
+
});
|
|
1827
|
+
};
|
|
1828
|
+
test.only = function onlyTest(title, fn) {
|
|
1829
|
+
testRegistry.push({
|
|
1830
|
+
title,
|
|
1831
|
+
fn,
|
|
1832
|
+
options: {},
|
|
1833
|
+
suitePath: [...suiteStack],
|
|
1834
|
+
skip: false,
|
|
1835
|
+
only: true
|
|
1836
|
+
});
|
|
1837
|
+
};
|
|
1838
|
+
function describe(title, fn) {
|
|
1839
|
+
suiteStack.push(title);
|
|
1840
|
+
fn();
|
|
1841
|
+
suiteStack.pop();
|
|
1842
|
+
}
|
|
1843
|
+
describe.skip = function skipDescribe(title, fn) {
|
|
1844
|
+
suiteStack.push(title);
|
|
1845
|
+
const startIndex = testRegistry.length;
|
|
1846
|
+
fn();
|
|
1847
|
+
for (let i = startIndex; i < testRegistry.length; i++) {
|
|
1848
|
+
testRegistry[i].skip = true;
|
|
1849
|
+
}
|
|
1850
|
+
suiteStack.pop();
|
|
1851
|
+
};
|
|
1852
|
+
describe.only = function onlyDescribe(title, fn) {
|
|
1853
|
+
suiteStack.push(title);
|
|
1854
|
+
const startIndex = testRegistry.length;
|
|
1855
|
+
fn();
|
|
1856
|
+
for (let i = startIndex; i < testRegistry.length; i++) {
|
|
1857
|
+
testRegistry[i].only = true;
|
|
1858
|
+
}
|
|
1859
|
+
suiteStack.pop();
|
|
1860
|
+
};
|
|
1861
|
+
var hooks = {
|
|
1862
|
+
beforeAll: [],
|
|
1863
|
+
afterAll: [],
|
|
1864
|
+
beforeEach: [],
|
|
1865
|
+
afterEach: []
|
|
1866
|
+
};
|
|
1867
|
+
function beforeAll(fn) {
|
|
1868
|
+
hooks.beforeAll.push({ fn, suitePath: [...suiteStack] });
|
|
1869
|
+
}
|
|
1870
|
+
function afterAll(fn) {
|
|
1871
|
+
hooks.afterAll.push({ fn, suitePath: [...suiteStack] });
|
|
1872
|
+
}
|
|
1873
|
+
function beforeEach(fn) {
|
|
1874
|
+
hooks.beforeEach.push({ fn, suitePath: [...suiteStack] });
|
|
1875
|
+
}
|
|
1876
|
+
function afterEach(fn) {
|
|
1877
|
+
hooks.afterEach.push({ fn, suitePath: [...suiteStack] });
|
|
1878
|
+
}
|
|
1879
|
+
async function runTest(testCase, sharedBrowser, userConfig) {
|
|
1880
|
+
const startTime = Date.now();
|
|
1881
|
+
const config = resolveConfig(userConfig);
|
|
1882
|
+
if (testCase.skip) {
|
|
1883
|
+
return {
|
|
1884
|
+
title: testCase.title,
|
|
1885
|
+
suitePath: testCase.suitePath,
|
|
1886
|
+
status: "skipped",
|
|
1887
|
+
duration: 0
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
let browser;
|
|
1891
|
+
let context;
|
|
1892
|
+
let page;
|
|
1893
|
+
try {
|
|
1894
|
+
browser = sharedBrowser ?? await Browser.launch();
|
|
1895
|
+
context = await browser.newContext();
|
|
1896
|
+
page = await context.newPage();
|
|
1897
|
+
const fixtures = { page, context, browser };
|
|
1898
|
+
const applicableBeforeAll = hooks.beforeAll.filter(
|
|
1899
|
+
(h) => isHookApplicable(h.suitePath, testCase.suitePath)
|
|
1900
|
+
);
|
|
1901
|
+
for (const hook of applicableBeforeAll) {
|
|
1902
|
+
const hookKey = `${hook.suitePath.join(">")}:${hooks.beforeAll.indexOf(hook)}`;
|
|
1903
|
+
if (!executedBeforeAllHooks.has(hookKey)) {
|
|
1904
|
+
await hook.fn(fixtures);
|
|
1905
|
+
executedBeforeAllHooks.add(hookKey);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
const applicableBeforeEach = hooks.beforeEach.filter(
|
|
1909
|
+
(h) => isHookApplicable(h.suitePath, testCase.suitePath)
|
|
1910
|
+
);
|
|
1911
|
+
for (const hook of applicableBeforeEach) {
|
|
1912
|
+
await hook.fn(fixtures);
|
|
1913
|
+
}
|
|
1914
|
+
const timeout = testCase.options.timeout ?? config.timeout;
|
|
1915
|
+
await Promise.race([
|
|
1916
|
+
testCase.fn(fixtures),
|
|
1917
|
+
new Promise(
|
|
1918
|
+
(_, reject) => setTimeout(() => reject(new Error(`Test timed out after ${timeout}ms`)), timeout)
|
|
1919
|
+
)
|
|
1920
|
+
]);
|
|
1921
|
+
const applicableAfterEach = hooks.afterEach.filter(
|
|
1922
|
+
(h) => isHookApplicable(h.suitePath, testCase.suitePath)
|
|
1923
|
+
);
|
|
1924
|
+
for (const hook of applicableAfterEach) {
|
|
1925
|
+
await hook.fn(fixtures);
|
|
1926
|
+
}
|
|
1927
|
+
let screenshotPath;
|
|
1928
|
+
if (config.screenshot === "always" && page) {
|
|
1929
|
+
screenshotPath = await captureScreenshot(page, testCase, config.outputDir).catch(() => void 0);
|
|
1930
|
+
}
|
|
1931
|
+
const duration = Date.now() - startTime;
|
|
1932
|
+
return {
|
|
1933
|
+
title: testCase.title,
|
|
1934
|
+
suitePath: testCase.suitePath,
|
|
1935
|
+
status: "passed",
|
|
1936
|
+
duration,
|
|
1937
|
+
screenshotPath
|
|
1938
|
+
};
|
|
1939
|
+
} catch (error) {
|
|
1940
|
+
const duration = Date.now() - startTime;
|
|
1941
|
+
let screenshotPath;
|
|
1942
|
+
if ((config.screenshot === "on-failure" || config.screenshot === "always") && page) {
|
|
1943
|
+
screenshotPath = await captureScreenshot(page, testCase, config.outputDir).catch(() => void 0);
|
|
1944
|
+
}
|
|
1945
|
+
return {
|
|
1946
|
+
title: testCase.title,
|
|
1947
|
+
suitePath: testCase.suitePath,
|
|
1948
|
+
status: "failed",
|
|
1949
|
+
duration,
|
|
1950
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1951
|
+
screenshotPath
|
|
1952
|
+
};
|
|
1953
|
+
} finally {
|
|
1954
|
+
await context?.close().catch(() => {
|
|
1955
|
+
});
|
|
1956
|
+
if (!sharedBrowser && browser) {
|
|
1957
|
+
await browser.close().catch(() => {
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
async function runAfterAllHooks(suitePath, fixtures) {
|
|
1963
|
+
const applicableAfterAll = hooks.afterAll.filter(
|
|
1964
|
+
(h) => isHookApplicable(h.suitePath, suitePath)
|
|
1965
|
+
);
|
|
1966
|
+
for (const hook of applicableAfterAll) {
|
|
1967
|
+
const hookKey = `${hook.suitePath.join(">")}:${hooks.afterAll.indexOf(hook)}`;
|
|
1968
|
+
if (!executedAfterAllHooks.has(hookKey)) {
|
|
1969
|
+
await hook.fn(fixtures);
|
|
1970
|
+
executedAfterAllHooks.add(hookKey);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
function resetTestState() {
|
|
1975
|
+
testRegistry.length = 0;
|
|
1976
|
+
hooks.beforeAll.length = 0;
|
|
1977
|
+
hooks.afterAll.length = 0;
|
|
1978
|
+
hooks.beforeEach.length = 0;
|
|
1979
|
+
hooks.afterEach.length = 0;
|
|
1980
|
+
executedBeforeAllHooks.clear();
|
|
1981
|
+
executedAfterAllHooks.clear();
|
|
1982
|
+
suiteStack.length = 0;
|
|
1983
|
+
}
|
|
1984
|
+
function isHookApplicable(hookSuitePath, testSuitePath) {
|
|
1985
|
+
if (hookSuitePath.length === 0) return true;
|
|
1986
|
+
if (hookSuitePath.length > testSuitePath.length) return false;
|
|
1987
|
+
return hookSuitePath.every((s, i) => testSuitePath[i] === s);
|
|
1988
|
+
}
|
|
1989
|
+
async function captureScreenshot(page, testCase, outputDir) {
|
|
1990
|
+
const parts = [...testCase.suitePath, testCase.title];
|
|
1991
|
+
const safeName = parts.join("-").replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/g, "_").slice(0, 200);
|
|
1992
|
+
const timestamp = Date.now();
|
|
1993
|
+
const filename = `${safeName}-${timestamp}.png`;
|
|
1994
|
+
const screenshotDir = join(outputDir, "screenshots");
|
|
1995
|
+
await mkdir(screenshotDir, { recursive: true });
|
|
1996
|
+
const buffer = await page.screenshot();
|
|
1997
|
+
const filePath = join(screenshotDir, filename);
|
|
1998
|
+
await writeFile(filePath, buffer);
|
|
1999
|
+
return filePath;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
export { Browser, BrowserContext, ElementHandle, Page, afterAll, afterEach, beforeAll, beforeEach, defineConfig, describe, resetTestState, resolveConfig, runAfterAllHooks, runTest, test, testRegistry };
|
|
2003
|
+
//# sourceMappingURL=chunk-77HRTGXZ.js.map
|
|
2004
|
+
//# sourceMappingURL=chunk-77HRTGXZ.js.map
|