@glubean/browser 0.1.2
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/dist/chrome.d.ts +44 -0
- package/dist/chrome.d.ts.map +1 -0
- package/dist/chrome.js +134 -0
- package/dist/chrome.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/locator.d.ts +39 -0
- package/dist/locator.d.ts.map +1 -0
- package/dist/locator.js +116 -0
- package/dist/locator.js.map +1 -0
- package/dist/metrics.d.ts +23 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +54 -0
- package/dist/metrics.js.map +1 -0
- package/dist/network.d.ts +55 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +176 -0
- package/dist/network.js.map +1 -0
- package/dist/page.d.ts +543 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +1259 -0
- package/dist/page.js.map +1 -0
- package/dist/plugin.d.ts +21 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +120 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +33 -0
package/dist/page.js
ADDED
|
@@ -0,0 +1,1259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GlubeanBrowser and GlubeanPage — browser automation wrappers that integrate
|
|
3
|
+
* with the Glubean test context.
|
|
4
|
+
*
|
|
5
|
+
* `GlubeanBrowser` manages the Chrome connection (returned by the plugin factory).
|
|
6
|
+
* `GlubeanPage` wraps a single Puppeteer Page with auto-instrumentation:
|
|
7
|
+
* - `ctx.trace()` for every `goto()` navigation
|
|
8
|
+
* - `ctx.metric()` for page load and DOMContentLoaded timing
|
|
9
|
+
* - `ctx.log()` / `ctx.warn()` for browser console output and uncaught errors
|
|
10
|
+
* - `ctx.trace()` for in-page network requests (XHR, fetch) via CDP
|
|
11
|
+
*
|
|
12
|
+
* @module page
|
|
13
|
+
*/
|
|
14
|
+
import { attachNetworkTracer } from "./network.js";
|
|
15
|
+
import { collectNavigationMetrics } from "./metrics.js";
|
|
16
|
+
import { createWrappedLocator } from "./locator.js";
|
|
17
|
+
/**
|
|
18
|
+
* Connected browser instance returned by the plugin.
|
|
19
|
+
*
|
|
20
|
+
* Call `newPage(ctx)` to create an instrumented page wired to the test context.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const pg = await chrome.newPage(ctx);
|
|
25
|
+
* await pg.goto("/dashboard");
|
|
26
|
+
* await pg.close();
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export class GlubeanBrowser {
|
|
30
|
+
_getBrowser;
|
|
31
|
+
_baseUrl;
|
|
32
|
+
_options;
|
|
33
|
+
_openPages = 0;
|
|
34
|
+
_closeTimer = null;
|
|
35
|
+
/** @internal — created by the plugin factory. */
|
|
36
|
+
constructor(getBrowser, baseUrl, options) {
|
|
37
|
+
this._getBrowser = getBrowser;
|
|
38
|
+
this._baseUrl = baseUrl;
|
|
39
|
+
this._options = options;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Create a new instrumented page wired to the given test context.
|
|
43
|
+
*
|
|
44
|
+
* Each call creates a fresh browser page. Remember to call `page.close()`
|
|
45
|
+
* in your teardown (or use `test.extend()` with a lifecycle factory).
|
|
46
|
+
*/
|
|
47
|
+
async newPage(ctx) {
|
|
48
|
+
if (this._closeTimer) {
|
|
49
|
+
clearTimeout(this._closeTimer);
|
|
50
|
+
this._closeTimer = null;
|
|
51
|
+
}
|
|
52
|
+
this._openPages++;
|
|
53
|
+
const browser = await this._getBrowser();
|
|
54
|
+
// Reuse the default about:blank tab instead of opening a new one.
|
|
55
|
+
// When Chrome launches it always creates one blank page — reusing it
|
|
56
|
+
// avoids the extra empty tab that lingers in headless: false mode.
|
|
57
|
+
const pages = await browser.pages();
|
|
58
|
+
const blank = pages.find((p) => {
|
|
59
|
+
const url = p.url();
|
|
60
|
+
return url === "about:blank" || url === "chrome://new-tab-page/";
|
|
61
|
+
});
|
|
62
|
+
const rawPage = blank ?? await browser.newPage();
|
|
63
|
+
rawPage.once("close", () => {
|
|
64
|
+
this._openPages--;
|
|
65
|
+
if (this._openPages <= 0) {
|
|
66
|
+
this._scheduleClose();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
// Read testId lazily from the runtime global — the harness updates it
|
|
70
|
+
// before each test runs, so reading at newPage() time is always fresh.
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
72
|
+
const runtimeTestId = globalThis.__glubeanRuntime?.test?.id;
|
|
73
|
+
return GlubeanPage._create(rawPage, this._baseUrl, ctx, this._options, runtimeTestId);
|
|
74
|
+
}
|
|
75
|
+
_scheduleClose() {
|
|
76
|
+
this._closeTimer = setTimeout(async () => {
|
|
77
|
+
if (this._openPages <= 0) {
|
|
78
|
+
try {
|
|
79
|
+
await this.close();
|
|
80
|
+
}
|
|
81
|
+
catch { /* already closed */ }
|
|
82
|
+
}
|
|
83
|
+
}, 3000);
|
|
84
|
+
// Don't let the timer keep the process alive
|
|
85
|
+
if (this._closeTimer && typeof this._closeTimer === "object" && "unref" in this._closeTimer) {
|
|
86
|
+
this._closeTimer.unref();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Disconnect from the browser without closing it. Useful for remote Chrome. */
|
|
90
|
+
async disconnect() {
|
|
91
|
+
const browser = await this._getBrowser();
|
|
92
|
+
browser.disconnect();
|
|
93
|
+
}
|
|
94
|
+
/** Close the browser and terminate the Chrome process. */
|
|
95
|
+
async close() {
|
|
96
|
+
const browser = await this._getBrowser();
|
|
97
|
+
await browser.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Instrumented browser page with auto-tracing, metrics, and console forwarding.
|
|
102
|
+
*
|
|
103
|
+
* Wraps a subset of Puppeteer's Page API. For advanced operations, use `.raw`.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* await page.goto("/login");
|
|
108
|
+
* await page.type("#email", "user@test.com");
|
|
109
|
+
* await page.click('button[type="submit"]');
|
|
110
|
+
* const title = await page.title();
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
export class GlubeanPage {
|
|
114
|
+
/** The underlying Puppeteer Page for advanced use cases. */
|
|
115
|
+
raw;
|
|
116
|
+
_baseUrl;
|
|
117
|
+
_ctx;
|
|
118
|
+
_metricsEnabled;
|
|
119
|
+
_screenshotMode;
|
|
120
|
+
_screenshotDir;
|
|
121
|
+
_testId;
|
|
122
|
+
_actionTimeout;
|
|
123
|
+
_stepCounter = 0;
|
|
124
|
+
_networkCleanup = null;
|
|
125
|
+
constructor(page, baseUrl, ctx, metricsEnabled, screenshotMode, screenshotDir, testId, actionTimeout) {
|
|
126
|
+
this.raw = page;
|
|
127
|
+
this._baseUrl = baseUrl;
|
|
128
|
+
this._ctx = ctx;
|
|
129
|
+
this._metricsEnabled = metricsEnabled;
|
|
130
|
+
this._screenshotMode = screenshotMode;
|
|
131
|
+
this._screenshotDir = screenshotDir;
|
|
132
|
+
this._testId = testId;
|
|
133
|
+
this._actionTimeout = actionTimeout;
|
|
134
|
+
}
|
|
135
|
+
/** @internal */
|
|
136
|
+
static async _create(page, baseUrl, ctx, options, runtimeTestId) {
|
|
137
|
+
const consoleForward = options.consoleForward ?? true;
|
|
138
|
+
const networkTraceOpt = options.networkTrace ?? true;
|
|
139
|
+
const metricsEnabled = options.metrics ?? true;
|
|
140
|
+
const screenshotMode = options.screenshot ?? "on-failure";
|
|
141
|
+
const screenshotDir = options.screenshotDir ?? ".glubean/screenshots";
|
|
142
|
+
const testId = runtimeTestId ?? ctx.testId ?? "unknown";
|
|
143
|
+
const actionTimeout = options.actionTimeout ?? 30_000;
|
|
144
|
+
const gp = new GlubeanPage(page, baseUrl, ctx, metricsEnabled, screenshotMode, screenshotDir, testId, actionTimeout);
|
|
145
|
+
if (consoleForward) {
|
|
146
|
+
page.on("console", (msg) => {
|
|
147
|
+
const type = msg.type();
|
|
148
|
+
const text = msg.text();
|
|
149
|
+
if (type === "error") {
|
|
150
|
+
ctx.event({
|
|
151
|
+
type: "browser:console-error",
|
|
152
|
+
data: { message: text, source: msg.location()?.url },
|
|
153
|
+
});
|
|
154
|
+
ctx.warn(false, `[browser:console] ${text}`);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
ctx.log(`[browser:${type}] ${text}`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
page.on("pageerror", (err) => {
|
|
161
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
162
|
+
const stack = err instanceof Error ? err.stack : undefined;
|
|
163
|
+
ctx.event({
|
|
164
|
+
type: "browser:uncaught-error",
|
|
165
|
+
data: { message: msg, stack },
|
|
166
|
+
});
|
|
167
|
+
ctx.warn(false, `[browser:uncaught] ${msg}`);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (networkTraceOpt !== false) {
|
|
171
|
+
const filterOpts = typeof networkTraceOpt === "object" ? networkTraceOpt : undefined;
|
|
172
|
+
gp._networkCleanup = await attachNetworkTracer(page, {
|
|
173
|
+
trace: (t) => ctx.trace(t),
|
|
174
|
+
include: filterOpts?.include,
|
|
175
|
+
excludePaths: filterOpts?.excludePaths,
|
|
176
|
+
filter: filterOpts?.filter,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// Proxy: GlubeanPage methods take priority; everything else falls through
|
|
180
|
+
// to the raw Puppeteer Page so users can call page.waitForNavigation(),
|
|
181
|
+
// page.setViewport(), page.keyboard.press(), etc. directly.
|
|
182
|
+
return new Proxy(gp, {
|
|
183
|
+
get(target, prop, receiver) {
|
|
184
|
+
if (prop in target) {
|
|
185
|
+
const value = Reflect.get(target, prop, receiver);
|
|
186
|
+
if (typeof value === "function")
|
|
187
|
+
return value.bind(target);
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
const rawValue = Reflect.get(target.raw, prop);
|
|
191
|
+
if (typeof rawValue === "function")
|
|
192
|
+
return rawValue.bind(target.raw);
|
|
193
|
+
return rawValue;
|
|
194
|
+
},
|
|
195
|
+
has(target, prop) {
|
|
196
|
+
return prop in target || prop in target.raw;
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
// ── Screenshot helpers ──────────────────────────────────────────────
|
|
201
|
+
_formatTimestamp() {
|
|
202
|
+
return new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
|
|
203
|
+
}
|
|
204
|
+
_sanitizeLabel(label) {
|
|
205
|
+
return label.replace(/[^a-z0-9_-]/gi, "_").slice(0, 60);
|
|
206
|
+
}
|
|
207
|
+
async _ensureDir(dir) {
|
|
208
|
+
const { mkdir } = await import("node:fs/promises");
|
|
209
|
+
await mkdir(dir, { recursive: true });
|
|
210
|
+
}
|
|
211
|
+
async _saveScreenshot(filename, label) {
|
|
212
|
+
if (this._ctx.saveArtifact) {
|
|
213
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
214
|
+
const buffer = (await this.raw.screenshot({
|
|
215
|
+
fullPage: true,
|
|
216
|
+
encoding: "binary",
|
|
217
|
+
}));
|
|
218
|
+
const id = await this._ctx.saveArtifact(filename, buffer, {
|
|
219
|
+
type: "screenshot",
|
|
220
|
+
mimeType: "image/png",
|
|
221
|
+
});
|
|
222
|
+
this._ctx.event({
|
|
223
|
+
type: "browser:screenshot",
|
|
224
|
+
data: { artifactId: id, label, fullPage: true },
|
|
225
|
+
});
|
|
226
|
+
return id;
|
|
227
|
+
}
|
|
228
|
+
// Legacy fallback: direct file write when saveArtifact is not available
|
|
229
|
+
const dir = `${this._screenshotDir}/${this._sanitizeLabel(this._testId)}`;
|
|
230
|
+
await this._ensureDir(dir);
|
|
231
|
+
const path = `${dir}/${filename}`;
|
|
232
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
233
|
+
await this.raw.screenshot({ path, fullPage: true });
|
|
234
|
+
this._ctx.event({
|
|
235
|
+
type: "browser:screenshot",
|
|
236
|
+
data: { path, label, fullPage: true },
|
|
237
|
+
});
|
|
238
|
+
return path;
|
|
239
|
+
}
|
|
240
|
+
async _captureStep(action) {
|
|
241
|
+
if (this._screenshotMode !== "every-step")
|
|
242
|
+
return;
|
|
243
|
+
this._stepCounter++;
|
|
244
|
+
const num = String(this._stepCounter).padStart(3, "0");
|
|
245
|
+
const ts = this._formatTimestamp();
|
|
246
|
+
await this._saveScreenshot(`${num}-${this._sanitizeLabel(action)}-${ts}.png`, action);
|
|
247
|
+
}
|
|
248
|
+
async _captureFailure(action) {
|
|
249
|
+
if (this._screenshotMode === "off")
|
|
250
|
+
return;
|
|
251
|
+
this._stepCounter++;
|
|
252
|
+
const num = String(this._stepCounter).padStart(3, "0");
|
|
253
|
+
const ts = this._formatTimestamp();
|
|
254
|
+
try {
|
|
255
|
+
await this._saveScreenshot(`FAIL-${num}-${this._sanitizeLabel(action)}-${ts}.png`, `FAIL:${action}`);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// best-effort — page may be in a broken state
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Capture a screenshot for a test-level failure (e.g. assertion error).
|
|
263
|
+
*
|
|
264
|
+
* Call this in the fixture's catch block to get a final-state screenshot
|
|
265
|
+
* when the test body throws.
|
|
266
|
+
*/
|
|
267
|
+
async screenshotOnFailure() {
|
|
268
|
+
if (this._screenshotMode === "off")
|
|
269
|
+
return;
|
|
270
|
+
const ts = this._formatTimestamp();
|
|
271
|
+
try {
|
|
272
|
+
await this._saveScreenshot(`FAIL-final-${ts}.png`, "FAIL:final");
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// best-effort
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// ── Navigation & interaction ────────────────────────────────────────
|
|
279
|
+
/**
|
|
280
|
+
* Navigate to a URL. Relative paths are resolved against the configured `baseUrl`.
|
|
281
|
+
*
|
|
282
|
+
* Emits a `browser:goto` action and (if enabled) Navigation Timing metrics.
|
|
283
|
+
* Captures a screenshot on failure or after every step (depending on config).
|
|
284
|
+
*/
|
|
285
|
+
async goto(url, options) {
|
|
286
|
+
const resolvedUrl = this._resolveUrl(url);
|
|
287
|
+
const start = Date.now();
|
|
288
|
+
let response;
|
|
289
|
+
try {
|
|
290
|
+
response = await this.raw.goto(resolvedUrl, {
|
|
291
|
+
waitUntil: options?.waitUntil ?? "load",
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
const duration = Date.now() - start;
|
|
296
|
+
this._ctx.action({
|
|
297
|
+
category: "browser:goto",
|
|
298
|
+
target: url,
|
|
299
|
+
duration,
|
|
300
|
+
status: "error",
|
|
301
|
+
detail: { url: resolvedUrl, error: String(err) },
|
|
302
|
+
});
|
|
303
|
+
await this._captureFailure(`goto-${url}`);
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
const duration = Date.now() - start;
|
|
307
|
+
const httpStatus = response?.status() ?? 0;
|
|
308
|
+
this._ctx.action({
|
|
309
|
+
category: "browser:goto",
|
|
310
|
+
target: url,
|
|
311
|
+
duration,
|
|
312
|
+
status: httpStatus >= 400 ? "error" : "ok",
|
|
313
|
+
detail: { url: resolvedUrl, httpStatus },
|
|
314
|
+
});
|
|
315
|
+
if (this._metricsEnabled) {
|
|
316
|
+
await collectNavigationMetrics(this.raw, (name, value, opts) => this._ctx.metric(name, value, opts), resolvedUrl);
|
|
317
|
+
}
|
|
318
|
+
await this._captureStep(`goto-${url}`);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Create a WrappedLocator for the given selector.
|
|
322
|
+
*
|
|
323
|
+
* The returned locator supports Puppeteer's chain methods (setTimeout,
|
|
324
|
+
* setVisibility, etc.) and auto-injects trace events and screenshots
|
|
325
|
+
* for action methods (click, fill, hover, scroll, type).
|
|
326
|
+
*
|
|
327
|
+
* @example
|
|
328
|
+
* ```ts
|
|
329
|
+
* // Chain Locator options before acting
|
|
330
|
+
* await page.locator("#submit").setTimeout(5000).click();
|
|
331
|
+
*
|
|
332
|
+
* // type() extension — Locator has no native type()
|
|
333
|
+
* await page.locator("#email").type("user@test.com");
|
|
334
|
+
* ```
|
|
335
|
+
*/
|
|
336
|
+
locator(selector) {
|
|
337
|
+
const inner = this.raw.locator(selector);
|
|
338
|
+
return createWrappedLocator(inner, {
|
|
339
|
+
action: (e) => this._ctx.action(e),
|
|
340
|
+
captureStep: (label) => this._captureStep(label),
|
|
341
|
+
captureFailure: (label) => this._captureFailure(label),
|
|
342
|
+
}, selector);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Click an element matching the selector.
|
|
346
|
+
*
|
|
347
|
+
* Delegates to Puppeteer's Locator API for auto-waiting (attached, visible,
|
|
348
|
+
* enabled, stable bounding box). Emits a `browser:click` action for tracing.
|
|
349
|
+
*/
|
|
350
|
+
async click(selector, options) {
|
|
351
|
+
await this.locator(selector)
|
|
352
|
+
.setTimeout(options?.timeout ?? this._actionTimeout)
|
|
353
|
+
.click();
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Click an element and wait for a full-page navigation to complete.
|
|
357
|
+
*
|
|
358
|
+
* Convenience wrapper for `Promise.all([waitForNavigation, click])`.
|
|
359
|
+
* Emits a `browser:click-and-navigate` action with trace + metrics + screenshot.
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```ts
|
|
363
|
+
* await page.clickAndNavigate("a.external-link");
|
|
364
|
+
* await page.clickAndNavigate("a.external-link", { waitUntil: "networkidle0" });
|
|
365
|
+
* ```
|
|
366
|
+
*/
|
|
367
|
+
async clickAndNavigate(selector, options) {
|
|
368
|
+
const start = Date.now();
|
|
369
|
+
const timeout = options?.timeout ?? this._actionTimeout;
|
|
370
|
+
try {
|
|
371
|
+
await Promise.all([
|
|
372
|
+
this.raw.waitForNavigation({
|
|
373
|
+
waitUntil: options?.waitUntil ?? "load",
|
|
374
|
+
timeout,
|
|
375
|
+
}),
|
|
376
|
+
this.locator(selector).setTimeout(timeout).click(),
|
|
377
|
+
]);
|
|
378
|
+
const duration = Date.now() - start;
|
|
379
|
+
this._ctx.action({
|
|
380
|
+
category: "browser:click-and-navigate",
|
|
381
|
+
target: selector,
|
|
382
|
+
duration,
|
|
383
|
+
status: "ok",
|
|
384
|
+
detail: { url: this.raw.url() },
|
|
385
|
+
});
|
|
386
|
+
if (this._metricsEnabled) {
|
|
387
|
+
await collectNavigationMetrics(this.raw, (name, value, opts) => this._ctx.metric(name, value, opts), this.raw.url());
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
const duration = Date.now() - start;
|
|
392
|
+
this._ctx.action({
|
|
393
|
+
category: "browser:click-and-navigate",
|
|
394
|
+
target: selector,
|
|
395
|
+
duration,
|
|
396
|
+
status: "error",
|
|
397
|
+
detail: { error: String(err) },
|
|
398
|
+
});
|
|
399
|
+
await this._captureFailure(`clickAndNavigate-${selector}`);
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
await this._captureStep(`clickAndNavigate-${selector}`);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Type text into an element matching the selector (appends — does not clear).
|
|
406
|
+
*
|
|
407
|
+
* Waits for the element to be actionable via Locator, then types using
|
|
408
|
+
* the ElementHandle. Use `fill()` to clear existing text before typing.
|
|
409
|
+
*/
|
|
410
|
+
async type(selector, text, options) {
|
|
411
|
+
await this.locator(selector)
|
|
412
|
+
.setTimeout(options?.timeout ?? this._actionTimeout)
|
|
413
|
+
.type(text);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Clear an input and type a new value.
|
|
417
|
+
*
|
|
418
|
+
* Unlike `type()` which appends, `fill()` clears existing text first.
|
|
419
|
+
* Delegates to Puppeteer's Locator `fill()` for auto-waiting.
|
|
420
|
+
*/
|
|
421
|
+
async fill(selector, value, options) {
|
|
422
|
+
await this.locator(selector)
|
|
423
|
+
.setTimeout(options?.timeout ?? this._actionTimeout)
|
|
424
|
+
.fill(value);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Hover over an element matching the selector.
|
|
428
|
+
*
|
|
429
|
+
* Delegates to Puppeteer's Locator `hover()` for auto-waiting.
|
|
430
|
+
*/
|
|
431
|
+
async hover(selector, options) {
|
|
432
|
+
await this.locator(selector)
|
|
433
|
+
.setTimeout(options?.timeout ?? this._actionTimeout)
|
|
434
|
+
.hover();
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Select option(s) from a `<select>` element by value.
|
|
438
|
+
*
|
|
439
|
+
* Waits for the element to be actionable via Locator, then selects values.
|
|
440
|
+
* Returns the array of selected values.
|
|
441
|
+
*/
|
|
442
|
+
async select(selector, ...values) {
|
|
443
|
+
const start = Date.now();
|
|
444
|
+
try {
|
|
445
|
+
await this.locator(selector)
|
|
446
|
+
.setTimeout(this._actionTimeout)
|
|
447
|
+
.waitHandle();
|
|
448
|
+
const selected = await this.raw.select(selector, ...values);
|
|
449
|
+
const duration = Date.now() - start;
|
|
450
|
+
this._ctx.action({
|
|
451
|
+
category: "browser:select",
|
|
452
|
+
target: selector,
|
|
453
|
+
duration,
|
|
454
|
+
status: "ok",
|
|
455
|
+
detail: { values, selected },
|
|
456
|
+
});
|
|
457
|
+
return selected;
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
const duration = Date.now() - start;
|
|
461
|
+
this._ctx.action({
|
|
462
|
+
category: "browser:select",
|
|
463
|
+
target: selector,
|
|
464
|
+
duration,
|
|
465
|
+
status: "timeout",
|
|
466
|
+
detail: { values, error: String(err) },
|
|
467
|
+
});
|
|
468
|
+
await this._captureFailure(`select-${selector}`);
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Press a keyboard key (e.g. `"Enter"`, `"Tab"`, `"Escape"`).
|
|
474
|
+
*
|
|
475
|
+
* Operates at the keyboard level — no selector or auto-wait needed.
|
|
476
|
+
*/
|
|
477
|
+
async press(key, options) {
|
|
478
|
+
const start = Date.now();
|
|
479
|
+
try {
|
|
480
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
481
|
+
await this.raw.keyboard.press(key, options);
|
|
482
|
+
const duration = Date.now() - start;
|
|
483
|
+
this._ctx.action({
|
|
484
|
+
category: "browser:press",
|
|
485
|
+
target: key,
|
|
486
|
+
duration,
|
|
487
|
+
status: "ok",
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
const duration = Date.now() - start;
|
|
492
|
+
this._ctx.action({
|
|
493
|
+
category: "browser:press",
|
|
494
|
+
target: key,
|
|
495
|
+
duration,
|
|
496
|
+
status: "error",
|
|
497
|
+
detail: { error: String(err) },
|
|
498
|
+
});
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Upload files to a `<input type="file">` element.
|
|
504
|
+
*
|
|
505
|
+
* Waits for the element to be actionable via Locator, then attaches files
|
|
506
|
+
* via `ElementHandle.uploadFile()` and dispatches a `change` event so
|
|
507
|
+
* frameworks like React/Vue pick up the file selection.
|
|
508
|
+
*/
|
|
509
|
+
async upload(selector, ...filePaths) {
|
|
510
|
+
const start = Date.now();
|
|
511
|
+
try {
|
|
512
|
+
const handle = await this.locator(selector)
|
|
513
|
+
.setTimeout(this._actionTimeout)
|
|
514
|
+
.waitHandle();
|
|
515
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
516
|
+
await handle.uploadFile(...filePaths);
|
|
517
|
+
// Puppeteer's uploadFile sets files via CDP but does NOT fire the
|
|
518
|
+
// native change event. React/Vue rely on it to update state.
|
|
519
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
520
|
+
await handle.evaluate((el) => el.dispatchEvent(new Event("change", { bubbles: true })));
|
|
521
|
+
await handle.dispose();
|
|
522
|
+
const duration = Date.now() - start;
|
|
523
|
+
this._ctx.action({
|
|
524
|
+
category: "browser:upload",
|
|
525
|
+
target: selector,
|
|
526
|
+
duration,
|
|
527
|
+
status: "ok",
|
|
528
|
+
detail: { fileCount: filePaths.length },
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
const duration = Date.now() - start;
|
|
533
|
+
this._ctx.action({
|
|
534
|
+
category: "browser:upload",
|
|
535
|
+
target: selector,
|
|
536
|
+
duration,
|
|
537
|
+
status: "timeout",
|
|
538
|
+
detail: { fileCount: filePaths.length, error: String(err) },
|
|
539
|
+
});
|
|
540
|
+
await this._captureFailure(`upload-${selector}`);
|
|
541
|
+
throw err;
|
|
542
|
+
}
|
|
543
|
+
await this._captureStep(`upload-${selector}`);
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Click a button/element that triggers a file chooser dialog, then accept
|
|
547
|
+
* the given files. Use this when the file input is hidden and opened via
|
|
548
|
+
* a custom button — the common pattern in modern SPAs.
|
|
549
|
+
*
|
|
550
|
+
* For a visible `<input type="file">`, prefer `upload()` instead.
|
|
551
|
+
*
|
|
552
|
+
* @example
|
|
553
|
+
* ```ts
|
|
554
|
+
* await page.chooseFile("#import-csv-btn", "./fixtures/users.csv");
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
async chooseFile(triggerSelector, ...filePaths) {
|
|
558
|
+
const start = Date.now();
|
|
559
|
+
try {
|
|
560
|
+
const [fileChooser] = await Promise.all([
|
|
561
|
+
this.raw.waitForFileChooser({ timeout: this._actionTimeout }),
|
|
562
|
+
this.click(triggerSelector),
|
|
563
|
+
]);
|
|
564
|
+
await fileChooser.accept(filePaths);
|
|
565
|
+
const duration = Date.now() - start;
|
|
566
|
+
this._ctx.action({
|
|
567
|
+
category: "browser:chooseFile",
|
|
568
|
+
target: triggerSelector,
|
|
569
|
+
duration,
|
|
570
|
+
status: "ok",
|
|
571
|
+
detail: { fileCount: filePaths.length },
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
const duration = Date.now() - start;
|
|
576
|
+
this._ctx.action({
|
|
577
|
+
category: "browser:chooseFile",
|
|
578
|
+
target: triggerSelector,
|
|
579
|
+
duration,
|
|
580
|
+
status: "timeout",
|
|
581
|
+
detail: { fileCount: filePaths.length, error: String(err) },
|
|
582
|
+
});
|
|
583
|
+
await this._captureFailure(`chooseFile-${triggerSelector}`);
|
|
584
|
+
throw err;
|
|
585
|
+
}
|
|
586
|
+
await this._captureStep(`chooseFile-${triggerSelector}`);
|
|
587
|
+
}
|
|
588
|
+
/** Query a single element by selector. */
|
|
589
|
+
async $(selector) {
|
|
590
|
+
return await this.raw.$(selector);
|
|
591
|
+
}
|
|
592
|
+
/** Query all elements by selector. */
|
|
593
|
+
async $$(selector) {
|
|
594
|
+
return await this.raw.$$(selector);
|
|
595
|
+
}
|
|
596
|
+
/** Evaluate a function in the browser page context. */
|
|
597
|
+
async evaluate(fn, ...args) {
|
|
598
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
599
|
+
return await this.raw.evaluate(fn, ...args);
|
|
600
|
+
}
|
|
601
|
+
/** Take a screenshot. Returns the image as a Buffer or base64 string. */
|
|
602
|
+
async screenshot(options) {
|
|
603
|
+
return await this.raw.screenshot({
|
|
604
|
+
encoding: options?.encoding ?? "binary",
|
|
605
|
+
fullPage: options?.fullPage ?? false,
|
|
606
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
/** Current page URL. */
|
|
610
|
+
url() {
|
|
611
|
+
return this.raw.url();
|
|
612
|
+
}
|
|
613
|
+
/** Current page title. */
|
|
614
|
+
async title() {
|
|
615
|
+
return await this.raw.title();
|
|
616
|
+
}
|
|
617
|
+
// ── Retry utility ─────────────────────────────────────────────────
|
|
618
|
+
static _POLL_MS = 100;
|
|
619
|
+
/**
|
|
620
|
+
* Poll `fn` until `check(result)` returns true, or throw after `timeout` ms.
|
|
621
|
+
* Used by all `waitFor*`, `textContent`, and `expect*` methods.
|
|
622
|
+
*/
|
|
623
|
+
async _retryUntil(fn, check, errorMsg, options) {
|
|
624
|
+
const timeout = options?.timeout ?? 5_000;
|
|
625
|
+
const start = Date.now();
|
|
626
|
+
let lastVal;
|
|
627
|
+
while (Date.now() - start < timeout) {
|
|
628
|
+
try {
|
|
629
|
+
lastVal = await fn();
|
|
630
|
+
if (check(lastVal))
|
|
631
|
+
return lastVal;
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
// element may not exist yet — retry
|
|
635
|
+
}
|
|
636
|
+
await new Promise((r) => setTimeout(r, GlubeanPage._POLL_MS));
|
|
637
|
+
}
|
|
638
|
+
// One final attempt
|
|
639
|
+
try {
|
|
640
|
+
lastVal = await fn();
|
|
641
|
+
if (check(lastVal))
|
|
642
|
+
return lastVal;
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
// fall through to error
|
|
646
|
+
}
|
|
647
|
+
throw new Error(errorMsg(lastVal));
|
|
648
|
+
}
|
|
649
|
+
// ── Phase 4: Navigation Auto-Wait ────────────────────────────────
|
|
650
|
+
/**
|
|
651
|
+
* Wait until the page URL matches `pattern` (string contains or RegExp test).
|
|
652
|
+
*
|
|
653
|
+
* @example
|
|
654
|
+
* ```ts
|
|
655
|
+
* await page.click('a[href="/dashboard"]');
|
|
656
|
+
* await page.waitForURL('/dashboard');
|
|
657
|
+
* ```
|
|
658
|
+
*/
|
|
659
|
+
async waitForURL(pattern, options) {
|
|
660
|
+
const matches = (url) => typeof pattern === "string" ? url.includes(pattern) : pattern.test(url);
|
|
661
|
+
const start = Date.now();
|
|
662
|
+
try {
|
|
663
|
+
await this._retryUntil(() => Promise.resolve(this.raw.url()), matches, (lastUrl) => `waitForURL: page URL "${lastUrl}" did not match ` +
|
|
664
|
+
`"${pattern}" after ${options?.timeout ?? 5_000}ms`, { timeout: options?.timeout ?? this._actionTimeout });
|
|
665
|
+
this._ctx.action({
|
|
666
|
+
category: "browser:wait",
|
|
667
|
+
target: `URL matches ${String(pattern)}`,
|
|
668
|
+
duration: Date.now() - start,
|
|
669
|
+
status: "ok",
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
this._ctx.action({
|
|
674
|
+
category: "browser:wait",
|
|
675
|
+
target: `URL matches ${String(pattern)}`,
|
|
676
|
+
duration: Date.now() - start,
|
|
677
|
+
status: "timeout",
|
|
678
|
+
detail: { error: String(err) },
|
|
679
|
+
});
|
|
680
|
+
throw err;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Wait for an element to appear, then return its `textContent`.
|
|
685
|
+
*/
|
|
686
|
+
async textContent(selector, options) {
|
|
687
|
+
const start = Date.now();
|
|
688
|
+
const timeout = options?.timeout ?? this._actionTimeout;
|
|
689
|
+
try {
|
|
690
|
+
await this.raw.waitForSelector(selector, { timeout });
|
|
691
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
692
|
+
const result = await this.raw.$eval(selector, (el) => el.textContent);
|
|
693
|
+
this._ctx.action({
|
|
694
|
+
category: "browser:wait",
|
|
695
|
+
target: `textContent("${selector}")`,
|
|
696
|
+
duration: Date.now() - start,
|
|
697
|
+
status: "ok",
|
|
698
|
+
});
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
this._ctx.action({
|
|
703
|
+
category: "browser:wait",
|
|
704
|
+
target: `textContent("${selector}")`,
|
|
705
|
+
duration: Date.now() - start,
|
|
706
|
+
status: "timeout",
|
|
707
|
+
detail: { error: String(err) },
|
|
708
|
+
});
|
|
709
|
+
throw err;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Wait for an element to appear, then return its `innerText`.
|
|
714
|
+
*/
|
|
715
|
+
async innerText(selector, options) {
|
|
716
|
+
const start = Date.now();
|
|
717
|
+
const timeout = options?.timeout ?? this._actionTimeout;
|
|
718
|
+
try {
|
|
719
|
+
await this.raw.waitForSelector(selector, { timeout });
|
|
720
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
721
|
+
const result = await this.raw.$eval(selector, (el) => el.innerText);
|
|
722
|
+
this._ctx.action({
|
|
723
|
+
category: "browser:wait",
|
|
724
|
+
target: `innerText("${selector}")`,
|
|
725
|
+
duration: Date.now() - start,
|
|
726
|
+
status: "ok",
|
|
727
|
+
});
|
|
728
|
+
return result;
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
this._ctx.action({
|
|
732
|
+
category: "browser:wait",
|
|
733
|
+
target: `innerText("${selector}")`,
|
|
734
|
+
duration: Date.now() - start,
|
|
735
|
+
status: "timeout",
|
|
736
|
+
detail: { error: String(err) },
|
|
737
|
+
});
|
|
738
|
+
throw err;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Wait for an element to appear, then return the value of `attr`.
|
|
743
|
+
*/
|
|
744
|
+
async getAttribute(selector, attr, options) {
|
|
745
|
+
const start = Date.now();
|
|
746
|
+
const timeout = options?.timeout ?? this._actionTimeout;
|
|
747
|
+
try {
|
|
748
|
+
await this.raw.waitForSelector(selector, { timeout });
|
|
749
|
+
const result = await this.raw.$eval(selector,
|
|
750
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
751
|
+
(el, a) => el.getAttribute(a), attr);
|
|
752
|
+
this._ctx.action({
|
|
753
|
+
category: "browser:wait",
|
|
754
|
+
target: `getAttribute("${selector}", "${attr}")`,
|
|
755
|
+
duration: Date.now() - start,
|
|
756
|
+
status: "ok",
|
|
757
|
+
});
|
|
758
|
+
return result;
|
|
759
|
+
}
|
|
760
|
+
catch (err) {
|
|
761
|
+
this._ctx.action({
|
|
762
|
+
category: "browser:wait",
|
|
763
|
+
target: `getAttribute("${selector}", "${attr}")`,
|
|
764
|
+
duration: Date.now() - start,
|
|
765
|
+
status: "timeout",
|
|
766
|
+
detail: { error: String(err) },
|
|
767
|
+
});
|
|
768
|
+
throw err;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Wait for an input element to appear, then return its `.value`.
|
|
773
|
+
*/
|
|
774
|
+
async inputValue(selector, options) {
|
|
775
|
+
const start = Date.now();
|
|
776
|
+
const timeout = options?.timeout ?? this._actionTimeout;
|
|
777
|
+
try {
|
|
778
|
+
await this.raw.waitForSelector(selector, { timeout });
|
|
779
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
780
|
+
const result = await this.raw.$eval(selector, (el) => el.value ?? "");
|
|
781
|
+
this._ctx.action({
|
|
782
|
+
category: "browser:wait",
|
|
783
|
+
target: `inputValue("${selector}")`,
|
|
784
|
+
duration: Date.now() - start,
|
|
785
|
+
status: "ok",
|
|
786
|
+
});
|
|
787
|
+
return result;
|
|
788
|
+
}
|
|
789
|
+
catch (err) {
|
|
790
|
+
this._ctx.action({
|
|
791
|
+
category: "browser:wait",
|
|
792
|
+
target: `inputValue("${selector}")`,
|
|
793
|
+
duration: Date.now() - start,
|
|
794
|
+
status: "timeout",
|
|
795
|
+
detail: { error: String(err) },
|
|
796
|
+
});
|
|
797
|
+
throw err;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Check whether an element is currently visible (non-zero box, not hidden).
|
|
802
|
+
* Returns immediately — does not wait.
|
|
803
|
+
*/
|
|
804
|
+
async isVisible(selector) {
|
|
805
|
+
const handle = await this.raw.$(selector);
|
|
806
|
+
if (!handle)
|
|
807
|
+
return false;
|
|
808
|
+
try {
|
|
809
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
810
|
+
return await handle.evaluate((el) => {
|
|
811
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
812
|
+
const style = globalThis.getComputedStyle(el);
|
|
813
|
+
if (style.display === "none" || style.visibility === "hidden") {
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
const box = el.getBoundingClientRect();
|
|
817
|
+
return box.width > 0 && box.height > 0;
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
finally {
|
|
821
|
+
await handle.dispose();
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Check whether an element is currently enabled (not disabled).
|
|
826
|
+
* Returns immediately — does not wait.
|
|
827
|
+
*/
|
|
828
|
+
async isEnabled(selector) {
|
|
829
|
+
const handle = await this.raw.$(selector);
|
|
830
|
+
if (!handle)
|
|
831
|
+
return false;
|
|
832
|
+
try {
|
|
833
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
834
|
+
return await handle.evaluate((el) => {
|
|
835
|
+
if ("disabled" in el && !!el.disabled)
|
|
836
|
+
return false;
|
|
837
|
+
return el.getAttribute("aria-disabled") !== "true";
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
finally {
|
|
841
|
+
await handle.dispose();
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// ── Phase 5: Assertion Auto-Retry ────────────────────────────────
|
|
845
|
+
/**
|
|
846
|
+
* Assert that the page URL matches `pattern`. Retries until match or timeout.
|
|
847
|
+
*
|
|
848
|
+
* @example
|
|
849
|
+
* ```ts
|
|
850
|
+
* await page.click('button[type="submit"]');
|
|
851
|
+
* await page.expectURL('/dashboard');
|
|
852
|
+
* ```
|
|
853
|
+
*/
|
|
854
|
+
async expectURL(pattern, options) {
|
|
855
|
+
const matches = (url) => typeof pattern === "string" ? url.includes(pattern) : pattern.test(url);
|
|
856
|
+
const start = Date.now();
|
|
857
|
+
try {
|
|
858
|
+
await this._retryUntil(() => Promise.resolve(this.raw.url()), matches, (lastUrl) => `expectURL: page URL "${lastUrl}" did not match ` +
|
|
859
|
+
`"${pattern}" after ${options?.timeout ?? 5_000}ms`, options);
|
|
860
|
+
this._ctx.action({
|
|
861
|
+
category: "browser:assert",
|
|
862
|
+
target: `expectURL(${JSON.stringify(String(pattern))})`,
|
|
863
|
+
duration: Date.now() - start,
|
|
864
|
+
status: "ok",
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
await this._captureFailure(`expectURL-${String(pattern)}`);
|
|
869
|
+
this._ctx.action({
|
|
870
|
+
category: "browser:assert",
|
|
871
|
+
target: `expectURL(${JSON.stringify(String(pattern))})`,
|
|
872
|
+
duration: Date.now() - start,
|
|
873
|
+
status: "timeout",
|
|
874
|
+
detail: { error: String(err) },
|
|
875
|
+
});
|
|
876
|
+
throw err;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Assert that an element's text content matches `expected`. Retries until match or timeout.
|
|
881
|
+
*
|
|
882
|
+
* By default, text is normalized (trimmed + collapsed whitespace) and matched
|
|
883
|
+
* with `includes`. Use `exact: true` for strict equality or pass a `RegExp`
|
|
884
|
+
* for pattern matching.
|
|
885
|
+
*/
|
|
886
|
+
async expectText(selector, expected, options) {
|
|
887
|
+
const normalize = (s) => s.replace(/\s+/g, " ").trim();
|
|
888
|
+
const matches = (text) => {
|
|
889
|
+
if (text === null)
|
|
890
|
+
return false;
|
|
891
|
+
if (expected instanceof RegExp)
|
|
892
|
+
return expected.test(text);
|
|
893
|
+
const norm = normalize(text);
|
|
894
|
+
const exp = normalize(expected);
|
|
895
|
+
if (options?.exact) {
|
|
896
|
+
return options?.ignoreCase
|
|
897
|
+
? norm.toLowerCase() === exp.toLowerCase()
|
|
898
|
+
: norm === exp;
|
|
899
|
+
}
|
|
900
|
+
return options?.ignoreCase
|
|
901
|
+
? norm.toLowerCase().includes(exp.toLowerCase())
|
|
902
|
+
: norm.includes(exp);
|
|
903
|
+
};
|
|
904
|
+
const start = Date.now();
|
|
905
|
+
let lastVal = null;
|
|
906
|
+
try {
|
|
907
|
+
lastVal = await this._retryUntil(
|
|
908
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
909
|
+
() => this.raw.$eval(selector, (el) => el.textContent)
|
|
910
|
+
.catch(() => null), matches, (lv) => `expectText("${selector}"): expected ${JSON.stringify(expected)} ` +
|
|
911
|
+
`but received ${JSON.stringify(lv)} after ${options?.timeout ?? 5_000}ms`, options);
|
|
912
|
+
this._ctx.action({
|
|
913
|
+
category: "browser:assert",
|
|
914
|
+
target: `expectText("${selector}")`,
|
|
915
|
+
duration: Date.now() - start,
|
|
916
|
+
status: "ok",
|
|
917
|
+
detail: { expected: String(expected), actual: lastVal },
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
await this._captureFailure(`expectText-${selector}`);
|
|
922
|
+
this._ctx.action({
|
|
923
|
+
category: "browser:assert",
|
|
924
|
+
target: `expectText("${selector}")`,
|
|
925
|
+
duration: Date.now() - start,
|
|
926
|
+
status: "timeout",
|
|
927
|
+
detail: {
|
|
928
|
+
expected: String(expected),
|
|
929
|
+
actual: lastVal,
|
|
930
|
+
error: String(err),
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
throw err;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Assert that an element is visible. Retries until visible or timeout.
|
|
938
|
+
*/
|
|
939
|
+
async expectVisible(selector, options) {
|
|
940
|
+
const start = Date.now();
|
|
941
|
+
try {
|
|
942
|
+
await this._retryUntil(() => this.isVisible(selector), (visible) => visible === true, () => `expectVisible("${selector}"): element was not visible ` +
|
|
943
|
+
`after ${options?.timeout ?? 5_000}ms`, options);
|
|
944
|
+
this._ctx.action({
|
|
945
|
+
category: "browser:assert",
|
|
946
|
+
target: `expectVisible("${selector}")`,
|
|
947
|
+
duration: Date.now() - start,
|
|
948
|
+
status: "ok",
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
await this._captureFailure(`expectVisible-${selector}`);
|
|
953
|
+
this._ctx.action({
|
|
954
|
+
category: "browser:assert",
|
|
955
|
+
target: `expectVisible("${selector}")`,
|
|
956
|
+
duration: Date.now() - start,
|
|
957
|
+
status: "timeout",
|
|
958
|
+
detail: { error: String(err) },
|
|
959
|
+
});
|
|
960
|
+
throw err;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Assert that an element is hidden or absent. Retries until hidden or timeout.
|
|
965
|
+
*/
|
|
966
|
+
async expectHidden(selector, options) {
|
|
967
|
+
const start = Date.now();
|
|
968
|
+
try {
|
|
969
|
+
await this._retryUntil(() => this.isVisible(selector), (visible) => visible === false, () => `expectHidden("${selector}"): element was still visible ` +
|
|
970
|
+
`after ${options?.timeout ?? 5_000}ms`, options);
|
|
971
|
+
this._ctx.action({
|
|
972
|
+
category: "browser:assert",
|
|
973
|
+
target: `expectHidden("${selector}")`,
|
|
974
|
+
duration: Date.now() - start,
|
|
975
|
+
status: "ok",
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
await this._captureFailure(`expectHidden-${selector}`);
|
|
980
|
+
this._ctx.action({
|
|
981
|
+
category: "browser:assert",
|
|
982
|
+
target: `expectHidden("${selector}")`,
|
|
983
|
+
duration: Date.now() - start,
|
|
984
|
+
status: "timeout",
|
|
985
|
+
detail: { error: String(err) },
|
|
986
|
+
});
|
|
987
|
+
throw err;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Assert that an element has an attribute matching `expected`. Retries until match or timeout.
|
|
992
|
+
*/
|
|
993
|
+
async expectAttribute(selector, attr, expected, options) {
|
|
994
|
+
const matches = (val) => {
|
|
995
|
+
if (val === null)
|
|
996
|
+
return false;
|
|
997
|
+
return typeof expected === "string"
|
|
998
|
+
? val === expected
|
|
999
|
+
: expected.test(val);
|
|
1000
|
+
};
|
|
1001
|
+
const start = Date.now();
|
|
1002
|
+
let lastVal = null;
|
|
1003
|
+
try {
|
|
1004
|
+
lastVal = await this._retryUntil(() => this.raw.$eval(selector,
|
|
1005
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1006
|
+
(el, a) => el.getAttribute(a), attr).catch(() => null), matches, (lv) => `expectAttribute("${selector}", "${attr}"): expected ${JSON.stringify(expected)} ` +
|
|
1007
|
+
`but received ${JSON.stringify(lv)} after ${options?.timeout ?? 5_000}ms`, options);
|
|
1008
|
+
this._ctx.action({
|
|
1009
|
+
category: "browser:assert",
|
|
1010
|
+
target: `expectAttribute("${selector}", "${attr}")`,
|
|
1011
|
+
duration: Date.now() - start,
|
|
1012
|
+
status: "ok",
|
|
1013
|
+
detail: { expected: String(expected), actual: lastVal },
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
catch (err) {
|
|
1017
|
+
await this._captureFailure(`expectAttribute-${selector}-${attr}`);
|
|
1018
|
+
this._ctx.action({
|
|
1019
|
+
category: "browser:assert",
|
|
1020
|
+
target: `expectAttribute("${selector}", "${attr}")`,
|
|
1021
|
+
duration: Date.now() - start,
|
|
1022
|
+
status: "timeout",
|
|
1023
|
+
detail: {
|
|
1024
|
+
expected: String(expected),
|
|
1025
|
+
actual: lastVal,
|
|
1026
|
+
error: String(err),
|
|
1027
|
+
},
|
|
1028
|
+
});
|
|
1029
|
+
throw err;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Assert that the number of elements matching `selector` equals `expected`. Retries until match or timeout.
|
|
1034
|
+
*/
|
|
1035
|
+
async expectCount(selector, expected, options) {
|
|
1036
|
+
const start = Date.now();
|
|
1037
|
+
let lastCount = 0;
|
|
1038
|
+
try {
|
|
1039
|
+
lastCount = await this._retryUntil(async () => (await this.raw.$$(selector)).length, (count) => count === expected, (lc) => `expectCount("${selector}"): expected ${expected} elements ` +
|
|
1040
|
+
`but found ${lc} after ${options?.timeout ?? 5_000}ms`, options);
|
|
1041
|
+
this._ctx.action({
|
|
1042
|
+
category: "browser:assert",
|
|
1043
|
+
target: `expectCount("${selector}")`,
|
|
1044
|
+
duration: Date.now() - start,
|
|
1045
|
+
status: "ok",
|
|
1046
|
+
detail: { expected, actual: lastCount },
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
catch (err) {
|
|
1050
|
+
await this._captureFailure(`expectCount-${selector}`);
|
|
1051
|
+
this._ctx.action({
|
|
1052
|
+
category: "browser:assert",
|
|
1053
|
+
target: `expectCount("${selector}")`,
|
|
1054
|
+
duration: Date.now() - start,
|
|
1055
|
+
status: "timeout",
|
|
1056
|
+
detail: { expected, actual: lastCount, error: String(err) },
|
|
1057
|
+
});
|
|
1058
|
+
throw err;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
// ── Phase 8: Network Interception & Assertions ────────────────────
|
|
1062
|
+
/**
|
|
1063
|
+
* Wait for a network request matching `pattern`.
|
|
1064
|
+
*
|
|
1065
|
+
* @param pattern URL string, RegExp, or predicate function.
|
|
1066
|
+
* @returns The matched `HTTPRequest`.
|
|
1067
|
+
*/
|
|
1068
|
+
async waitForRequest(pattern, options) {
|
|
1069
|
+
const timeout = options?.timeout ?? this._actionTimeout;
|
|
1070
|
+
const label = typeof pattern === "function"
|
|
1071
|
+
? "(predicate)"
|
|
1072
|
+
: String(pattern);
|
|
1073
|
+
const start = Date.now();
|
|
1074
|
+
try {
|
|
1075
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1076
|
+
const predicate = typeof pattern === "string"
|
|
1077
|
+
? (req) => req.url().includes(pattern)
|
|
1078
|
+
: pattern instanceof RegExp
|
|
1079
|
+
? (req) => pattern.test(req.url())
|
|
1080
|
+
: pattern;
|
|
1081
|
+
const req = await this.raw.waitForRequest(predicate, { timeout });
|
|
1082
|
+
this._ctx.action({
|
|
1083
|
+
category: "browser:wait",
|
|
1084
|
+
target: `waitForRequest(${label})`,
|
|
1085
|
+
duration: Date.now() - start,
|
|
1086
|
+
status: "ok",
|
|
1087
|
+
detail: { url: req.url(), method: req.method() },
|
|
1088
|
+
});
|
|
1089
|
+
return req;
|
|
1090
|
+
}
|
|
1091
|
+
catch (err) {
|
|
1092
|
+
this._ctx.action({
|
|
1093
|
+
category: "browser:wait",
|
|
1094
|
+
target: `waitForRequest(${label})`,
|
|
1095
|
+
duration: Date.now() - start,
|
|
1096
|
+
status: "timeout",
|
|
1097
|
+
detail: { error: String(err) },
|
|
1098
|
+
});
|
|
1099
|
+
throw err;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Wait for a network response matching `pattern`.
|
|
1104
|
+
*
|
|
1105
|
+
* @param pattern URL string, RegExp, or predicate function.
|
|
1106
|
+
* @returns The matched `HTTPResponse`.
|
|
1107
|
+
*/
|
|
1108
|
+
async waitForResponse(pattern, options) {
|
|
1109
|
+
const timeout = options?.timeout ?? this._actionTimeout;
|
|
1110
|
+
const label = typeof pattern === "function"
|
|
1111
|
+
? "(predicate)"
|
|
1112
|
+
: String(pattern);
|
|
1113
|
+
const start = Date.now();
|
|
1114
|
+
try {
|
|
1115
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1116
|
+
const predicate = typeof pattern === "string"
|
|
1117
|
+
? (res) => res.url().includes(pattern)
|
|
1118
|
+
: pattern instanceof RegExp
|
|
1119
|
+
? (res) => pattern.test(res.url())
|
|
1120
|
+
: pattern;
|
|
1121
|
+
const res = await this.raw.waitForResponse(predicate, { timeout });
|
|
1122
|
+
this._ctx.action({
|
|
1123
|
+
category: "browser:wait",
|
|
1124
|
+
target: `waitForResponse(${label})`,
|
|
1125
|
+
duration: Date.now() - start,
|
|
1126
|
+
status: "ok",
|
|
1127
|
+
detail: { url: res.url(), status: res.status() },
|
|
1128
|
+
});
|
|
1129
|
+
return res;
|
|
1130
|
+
}
|
|
1131
|
+
catch (err) {
|
|
1132
|
+
this._ctx.action({
|
|
1133
|
+
category: "browser:wait",
|
|
1134
|
+
target: `waitForResponse(${label})`,
|
|
1135
|
+
duration: Date.now() - start,
|
|
1136
|
+
status: "timeout",
|
|
1137
|
+
detail: { error: String(err) },
|
|
1138
|
+
});
|
|
1139
|
+
throw err;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Assert that a network response matching `pattern` satisfies `checks`.
|
|
1144
|
+
*
|
|
1145
|
+
* Waits for the response, then validates status and/or headers.
|
|
1146
|
+
* Emits a `browser:assert` action.
|
|
1147
|
+
*
|
|
1148
|
+
* @example
|
|
1149
|
+
* ```ts
|
|
1150
|
+
* await page.click("#submit");
|
|
1151
|
+
* await page.expectResponse("/api/login", { status: 200 });
|
|
1152
|
+
* ```
|
|
1153
|
+
*/
|
|
1154
|
+
async expectResponse(pattern, checks, options) {
|
|
1155
|
+
const label = typeof pattern === "function"
|
|
1156
|
+
? "(predicate)"
|
|
1157
|
+
: String(pattern);
|
|
1158
|
+
const start = Date.now();
|
|
1159
|
+
try {
|
|
1160
|
+
const res = await this.waitForResponse(pattern, options);
|
|
1161
|
+
const failures = [];
|
|
1162
|
+
if (checks?.status !== undefined) {
|
|
1163
|
+
const s = res.status();
|
|
1164
|
+
const ok = typeof checks.status === "function"
|
|
1165
|
+
? checks.status(s)
|
|
1166
|
+
: s === checks.status;
|
|
1167
|
+
if (!ok)
|
|
1168
|
+
failures.push(`status ${s} did not match ${checks.status}`);
|
|
1169
|
+
}
|
|
1170
|
+
if (checks?.headerContains) {
|
|
1171
|
+
const headers = res.headers();
|
|
1172
|
+
for (const [key, expected] of Object.entries(checks.headerContains)) {
|
|
1173
|
+
const actual = headers[key.toLowerCase()];
|
|
1174
|
+
if (!actual || !actual.includes(expected)) {
|
|
1175
|
+
const display = actual ?? "(missing)";
|
|
1176
|
+
failures.push("header " + JSON.stringify(key) + ": expected " +
|
|
1177
|
+
JSON.stringify(expected) + ", got " + JSON.stringify(display));
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
if (failures.length > 0) {
|
|
1182
|
+
const msg = "expectResponse(" + label + "): " + failures.join("; ");
|
|
1183
|
+
this._ctx.action({
|
|
1184
|
+
category: "browser:assert",
|
|
1185
|
+
target: "expectResponse(" + label + ")",
|
|
1186
|
+
duration: Date.now() - start,
|
|
1187
|
+
status: "error",
|
|
1188
|
+
detail: { url: res.url(), httpStatus: res.status(), failures },
|
|
1189
|
+
});
|
|
1190
|
+
throw new Error(msg);
|
|
1191
|
+
}
|
|
1192
|
+
this._ctx.action({
|
|
1193
|
+
category: "browser:assert",
|
|
1194
|
+
target: `expectResponse(${label})`,
|
|
1195
|
+
duration: Date.now() - start,
|
|
1196
|
+
status: "ok",
|
|
1197
|
+
detail: { url: res.url(), httpStatus: res.status() },
|
|
1198
|
+
});
|
|
1199
|
+
return res;
|
|
1200
|
+
}
|
|
1201
|
+
catch (err) {
|
|
1202
|
+
if (!(err instanceof Error && err.message.startsWith("expectResponse("))) {
|
|
1203
|
+
this._ctx.action({
|
|
1204
|
+
category: "browser:assert",
|
|
1205
|
+
target: `expectResponse(${label})`,
|
|
1206
|
+
duration: Date.now() - start,
|
|
1207
|
+
status: "timeout",
|
|
1208
|
+
detail: { error: String(err) },
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
throw err;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
/** Clean up: remove CDP listeners and close the page. */
|
|
1215
|
+
async close() {
|
|
1216
|
+
if (this._networkCleanup) {
|
|
1217
|
+
await this._networkCleanup();
|
|
1218
|
+
this._networkCleanup = null;
|
|
1219
|
+
}
|
|
1220
|
+
try {
|
|
1221
|
+
await this.raw.close();
|
|
1222
|
+
}
|
|
1223
|
+
catch {
|
|
1224
|
+
// page may already be closed
|
|
1225
|
+
}
|
|
1226
|
+
// Close leftover blank tabs so Chrome doesn't linger with an empty window.
|
|
1227
|
+
try {
|
|
1228
|
+
const browser = this.raw.browser();
|
|
1229
|
+
const remaining = await browser.pages();
|
|
1230
|
+
const allBlank = remaining.length > 0 && remaining.every((p) => {
|
|
1231
|
+
const url = p.url();
|
|
1232
|
+
return url === "about:blank" || url === "chrome://new-tab-page/";
|
|
1233
|
+
});
|
|
1234
|
+
if (allBlank) {
|
|
1235
|
+
for (const p of remaining) {
|
|
1236
|
+
try {
|
|
1237
|
+
await p.close();
|
|
1238
|
+
}
|
|
1239
|
+
catch { /* ignore */ }
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
catch {
|
|
1244
|
+
// best-effort cleanup
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
_resolveUrl(url) {
|
|
1248
|
+
if (!this._baseUrl)
|
|
1249
|
+
return url;
|
|
1250
|
+
if (url.startsWith("http://") || url.startsWith("https://"))
|
|
1251
|
+
return url;
|
|
1252
|
+
const base = this._baseUrl.endsWith("/")
|
|
1253
|
+
? this._baseUrl.slice(0, -1)
|
|
1254
|
+
: this._baseUrl;
|
|
1255
|
+
const path = url.startsWith("/") ? url : `/${url}`;
|
|
1256
|
+
return `${base}${path}`;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
//# sourceMappingURL=page.js.map
|