@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/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