@glubean/browser 0.8.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/page.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import { EvidenceSession, } from "./evidence.js";
15
15
  import { collectNavigationMetrics } from "./metrics.js";
16
16
  import { createWrappedLocator } from "./locator.js";
17
+ import { createFrame } from "./frame.js";
17
18
  import { getRuntime } from "@glubean/sdk/internal";
18
19
  // ── Module-level helpers (used by the shoot callback in _create) ──────
19
20
  /** Sanitize a string into a safe filename segment. */
@@ -60,7 +61,6 @@ export class GlubeanBrowser {
60
61
  clearTimeout(this._closeTimer);
61
62
  this._closeTimer = null;
62
63
  }
63
- this._openPages++;
64
64
  const browser = await this._getBrowser();
65
65
  // Reuse the default about:blank tab instead of opening a new one.
66
66
  // When Chrome launches it always creates one blank page — reusing it
@@ -71,16 +71,31 @@ export class GlubeanBrowser {
71
71
  return url === "about:blank" || url === "chrome://new-tab-page/";
72
72
  });
73
73
  const rawPage = blank ?? await browser.newPage();
74
+ this._track(rawPage);
75
+ // Read testId lazily from the runtime carrier — the harness updates it
76
+ // before each test runs, so reading at newPage() time is always fresh.
77
+ const runtimeTestId = getRuntime()?.test?.id;
78
+ return GlubeanPage._create(rawPage, this._baseUrl, ctx, this._options, runtimeTestId, this);
79
+ }
80
+ /**
81
+ * @internal Register a raw page in the open-page accounting so the browser
82
+ * isn't auto-closed while it's alive. Cancels any pending close, increments
83
+ * the counter, and decrements (rescheduling close at zero) on page close.
84
+ * Used by both `newPage()` and `GlubeanPage.waitForPopup()` (popups are
85
+ * real pages that must keep the browser open too).
86
+ */
87
+ _track(rawPage) {
88
+ if (this._closeTimer) {
89
+ clearTimeout(this._closeTimer);
90
+ this._closeTimer = null;
91
+ }
92
+ this._openPages++;
74
93
  rawPage.once("close", () => {
75
94
  this._openPages--;
76
95
  if (this._openPages <= 0) {
77
96
  this._scheduleClose();
78
97
  }
79
98
  });
80
- // Read testId lazily from the runtime carrier — the harness updates it
81
- // before each test runs, so reading at newPage() time is always fresh.
82
- const runtimeTestId = getRuntime()?.test?.id;
83
- return GlubeanPage._create(rawPage, this._baseUrl, ctx, this._options, runtimeTestId);
84
99
  }
85
100
  _scheduleClose() {
86
101
  this._closeTimer = setTimeout(async () => {
@@ -127,24 +142,77 @@ export class GlubeanPage {
127
142
  _ctx;
128
143
  _metricsEnabled;
129
144
  _actionTimeout;
145
+ _options;
146
+ _testId;
147
+ /** Owning browser — used to register popups in its open-page accounting. */
148
+ _owner;
130
149
  _evidence = null;
131
- constructor(page, baseUrl, ctx, metricsEnabled, actionTimeout) {
150
+ _dialogHandler = null;
151
+ constructor(page, baseUrl, ctx, metricsEnabled, actionTimeout, options, testId, owner) {
132
152
  this.raw = page;
133
153
  this._baseUrl = baseUrl;
134
154
  this._ctx = ctx;
135
155
  this._metricsEnabled = metricsEnabled;
136
156
  this._actionTimeout = actionTimeout;
157
+ this._options = options;
158
+ this._testId = testId;
159
+ this._owner = owner;
137
160
  }
138
161
  /** @internal */
139
- static async _create(page, baseUrl, ctx, options, runtimeTestId) {
162
+ static async _create(page, baseUrl, ctx, options, runtimeTestId, owner) {
140
163
  const consoleForward = options.consoleForward ?? true;
141
164
  const networkTraceOpt = options.networkTrace ?? true;
142
165
  const metricsEnabled = options.metrics ?? true;
143
166
  const screenshotMode = options.screenshot ?? "on-failure";
144
167
  const screenshotDir = options.screenshotDir ?? ".glubean/screenshots";
145
168
  const testId = runtimeTestId ?? ctx.testId ?? "unknown";
169
+ // ONE download dir for the whole browser — NOT per-test. `Browser.setDownloadBehavior`
170
+ // is browser-context-global (last call wins the path), so two concurrent instrumented
171
+ // pages sharing one Chrome (e.g. concurrent tests off one `browser()` fixture) must
172
+ // agree on the directory or one would silently repoint the other's downloads. The
173
+ // per-download `browser:download` evidence carries the testId for attribution instead.
174
+ const downloadDir = options.downloadDir ?? ".glubean/downloads";
146
175
  const actionTimeout = options.actionTimeout ?? 30_000;
147
- const gp = new GlubeanPage(page, baseUrl, ctx, metricsEnabled, actionTimeout);
176
+ const dialogMode = options.dialogMode ?? "dismiss";
177
+ const gp = new GlubeanPage(page, baseUrl, ctx, metricsEnabled, actionTimeout, options, testId, owner);
178
+ // Native dialogs (alert/confirm/prompt/beforeunload) always emit evidence.
179
+ // Puppeteer auto-dismisses unhandled dialogs UNLESS a 'dialog' listener is
180
+ // registered — once we register one, WE are responsible for resolving
181
+ // every dialog (custom handler via onDialog(), else `dialogMode`).
182
+ page.on("dialog", (dialog) => {
183
+ ctx.event({
184
+ type: "browser:dialog",
185
+ data: {
186
+ dialogType: dialog.type(),
187
+ message: dialog.message(),
188
+ defaultValue: dialog.defaultValue(),
189
+ },
190
+ });
191
+ const handle = async () => {
192
+ if (gp._dialogHandler) {
193
+ await gp._dialogHandler(dialog);
194
+ return;
195
+ }
196
+ if (dialogMode === "accept")
197
+ await dialog.accept();
198
+ else
199
+ await dialog.dismiss();
200
+ };
201
+ handle().catch((err) => {
202
+ ctx.warn(false, `[browser:dialog] failed to resolve dialog: ${String(err)}`);
203
+ });
204
+ });
205
+ // Popups (window.open / target=_blank) always emit evidence. Use
206
+ // page.waitForPopup() to also get a fully-instrumented handle to the
207
+ // popup itself (own evidence session: network/console/screenshots).
208
+ page.on("popup", (popup) => {
209
+ if (!popup)
210
+ return;
211
+ ctx.event({
212
+ type: "browser:popup",
213
+ data: { url: popup.url() },
214
+ });
215
+ });
148
216
  if (consoleForward) {
149
217
  page.on("console", (msg) => {
150
218
  const type = msg.type();
@@ -213,6 +281,9 @@ export class GlubeanPage {
213
281
  return { path: filePath };
214
282
  },
215
283
  };
284
+ // Downloads are always captured as evidence (like dialog/popup) — the
285
+ // save directory must exist before Chrome is told to use it.
286
+ await _ensureDir(downloadDir);
216
287
  gp._evidence = await EvidenceSession.attach(page, {
217
288
  trace: networkTraceOpt !== false ? (t) => ctx.trace(t) : undefined,
218
289
  network: filterOpts && {
@@ -222,7 +293,27 @@ export class GlubeanPage {
222
293
  },
223
294
  mocks: options.mock,
224
295
  emulate: options.emulate,
296
+ storageState: options.storageState,
225
297
  screenshots: screenshotsOpt,
298
+ downloads: {
299
+ dir: downloadDir,
300
+ onDownload: (entry, state) => {
301
+ // Only terminal states are evidence — "inProgress" fires repeatedly
302
+ // per download and would flood the timeline with no new information.
303
+ if (state === "inProgress")
304
+ return;
305
+ ctx.event({
306
+ type: "browser:download",
307
+ data: {
308
+ guid: entry.guid,
309
+ url: entry.url,
310
+ filename: entry.suggestedFilename,
311
+ path: entry.path,
312
+ state,
313
+ },
314
+ });
315
+ },
316
+ },
226
317
  });
227
318
  // Proxy: GlubeanPage methods take priority; everything else falls through
228
319
  // to the raw Puppeteer Page so users can call page.waitForNavigation(),
@@ -276,6 +367,239 @@ export class GlubeanPage {
276
367
  async captureScreenshot(label = "manual") {
277
368
  await this._evidence?.captureShot(label, "manual");
278
369
  }
370
+ // ── Storage state (cookies + localStorage) ──────────────────────────
371
+ /**
372
+ * Capture the current page's cookies + localStorage — a thin "login once,
373
+ * reuse the session" primitive. Pass the result to `browser({ storageState })`
374
+ * on a fresh page to skip a UI login flow.
375
+ *
376
+ * @example
377
+ * ```ts
378
+ * await page.fill('[data-testid="username"]', "ada");
379
+ * await page.click('[data-testid="login-btn"]');
380
+ * const state = await page.getStorageState();
381
+ * ```
382
+ */
383
+ async getStorageState() {
384
+ if (!this._evidence)
385
+ return {};
386
+ return await this._evidence.captureStorageState(this.raw);
387
+ }
388
+ // ── Dialog & popup ───────────────────────────────────────────────────
389
+ /**
390
+ * Register a handler for the next native dialogs (`alert`/`confirm`/`prompt`/
391
+ * `beforeunload`) on this page, overriding the plugin's `dialogMode` default.
392
+ * The handler MUST resolve the dialog itself (`dialog.accept()` /
393
+ * `dialog.dismiss()`) — Glubean will not also auto-resolve it.
394
+ *
395
+ * A `browser:dialog` evidence event is emitted for every dialog regardless
396
+ * of whether a handler is registered.
397
+ *
398
+ * @example
399
+ * ```ts
400
+ * page.onDialog((dialog) => dialog.accept());
401
+ * await page.click('[data-testid="clear-cart-btn"]'); // triggers confirm()
402
+ * ```
403
+ */
404
+ onDialog(handler) {
405
+ this._dialogHandler = handler;
406
+ }
407
+ /**
408
+ * Run `action`, wait for it to open a popup (`window.open()` / `target="_blank"`),
409
+ * and return the popup as a fully-instrumented page (its own evidence session —
410
+ * network trace, console forwarding, screenshots — just like the parent).
411
+ *
412
+ * A `browser:popup` evidence event is emitted for every popup regardless of
413
+ * whether `waitForPopup()` is used to capture it.
414
+ *
415
+ * @example
416
+ * ```ts
417
+ * const popup = await page.waitForPopup(() => page.click('[data-testid="policy-link"]'));
418
+ * await popup.expectText("body", "Return Policy");
419
+ * await popup.close();
420
+ * ```
421
+ */
422
+ async waitForPopup(action, options) {
423
+ const timeout = options?.timeout ?? this._actionTimeout;
424
+ const start = Date.now();
425
+ // Named listener + timer so BOTH are always cleaned up — a failed wait
426
+ // must not leave a stale `popup` listener behind (it would swallow a later
427
+ // popup and accumulate across repeated failed waits).
428
+ let timer;
429
+ let onPopup;
430
+ const popupPromise = new Promise((resolve, reject) => {
431
+ timer = setTimeout(() => {
432
+ reject(new Error(`waitForPopup: no popup opened after ${timeout}ms`));
433
+ }, timeout);
434
+ onPopup = (popup) => {
435
+ if (popup) {
436
+ // Register the popup in the owning browser's open-page accounting
437
+ // SYNCHRONOUSLY on the open event — before awaiting `action()` to
438
+ // settle. Otherwise a popup that opens and closes within `action()`
439
+ // would fire its `close` before `_track()` wired the decrementing
440
+ // listener, permanently pinning `_openPages` above zero and
441
+ // suppressing browser auto-close.
442
+ this._owner?._track(popup);
443
+ resolve(popup);
444
+ }
445
+ else {
446
+ reject(new Error("waitForPopup: popup event fired with no page"));
447
+ }
448
+ };
449
+ this.raw.once("popup", onPopup);
450
+ });
451
+ const cleanup = () => {
452
+ if (timer)
453
+ clearTimeout(timer);
454
+ if (onPopup)
455
+ this.raw.off("popup", onPopup);
456
+ };
457
+ try {
458
+ const [rawPopup] = await Promise.all([popupPromise, action()]);
459
+ cleanup();
460
+ this._ctx.action({
461
+ category: "browser:waitForPopup",
462
+ target: rawPopup.url(),
463
+ duration: Date.now() - start,
464
+ status: "ok",
465
+ });
466
+ return await GlubeanPage._create(rawPopup, this._baseUrl, this._ctx, this._options, this._testId, this._owner);
467
+ }
468
+ catch (err) {
469
+ cleanup();
470
+ this._ctx.action({
471
+ category: "browser:waitForPopup",
472
+ target: "(popup)",
473
+ duration: Date.now() - start,
474
+ status: "timeout",
475
+ detail: { error: String(err) },
476
+ });
477
+ throw err;
478
+ }
479
+ }
480
+ // ── Downloads ────────────────────────────────────────────────────────
481
+ /**
482
+ * Run `action`, wait for it to produce a completed download, and return
483
+ * the download's metadata. A `browser:download` evidence event is emitted
484
+ * for every download (`completed` or `canceled`) regardless of whether
485
+ * `waitForDownload()` is used to capture it — like `waitForPopup()`.
486
+ *
487
+ * @example
488
+ * ```ts
489
+ * const dl = await page.waitForDownload(() => page.click('[data-testid="download-receipt"]'));
490
+ * ctx.assert(dl.suggestedFilename.endsWith(".txt"), "expected a .txt receipt");
491
+ * ```
492
+ */
493
+ async waitForDownload(action, options) {
494
+ const timeout = options?.timeout ?? this._actionTimeout;
495
+ const start = Date.now();
496
+ if (!this._evidence) {
497
+ throw new Error("waitForDownload: evidence session not attached");
498
+ }
499
+ // Register the wait BEFORE the action so a fast download can't slip past,
500
+ // and scope it to THIS action: if `action()` throws, abort the wait so it
501
+ // neither leaks a waiter nor gets satisfied by a later, unrelated download.
502
+ const ac = new AbortController();
503
+ const waitPromise = this._evidence.waitForDownload(timeout, ac.signal);
504
+ try {
505
+ await action();
506
+ }
507
+ catch (err) {
508
+ ac.abort();
509
+ await waitPromise.catch(() => { }); // settle the aborted wait; swallow its rejection
510
+ this._ctx.action({
511
+ category: "browser:waitForDownload",
512
+ target: "(download)",
513
+ duration: Date.now() - start,
514
+ status: "timeout",
515
+ detail: { error: String(err) },
516
+ });
517
+ throw err;
518
+ }
519
+ try {
520
+ const entry = await waitPromise;
521
+ this._ctx.action({
522
+ category: "browser:waitForDownload",
523
+ target: entry.suggestedFilename,
524
+ duration: Date.now() - start,
525
+ status: "ok",
526
+ detail: { url: entry.url, path: entry.path },
527
+ });
528
+ return entry;
529
+ }
530
+ catch (err) {
531
+ this._ctx.action({
532
+ category: "browser:waitForDownload",
533
+ target: "(download)",
534
+ duration: Date.now() - start,
535
+ status: "timeout",
536
+ detail: { error: String(err) },
537
+ });
538
+ throw err;
539
+ }
540
+ }
541
+ // ── iframe ───────────────────────────────────────────────────────────
542
+ /**
543
+ * Locate an `<iframe>` matching `selector` and return its content frame,
544
+ * wrapped with the same locator API as `GlubeanPage` (`byTestId`/`byText`/
545
+ * `byRole`/`byLabel`, `click`/`fill`/`type`/`hover`, `expectVisible`/
546
+ * `expectText`/`expectHidden`).
547
+ *
548
+ * CDP already handles the execution-context switch into the frame (that's
549
+ * Puppeteer's own `Frame` machinery) — this method is purely the
550
+ * encapsulation-layer half: locator ergonomics scoped to the frame instead
551
+ * of the top-level page.
552
+ *
553
+ * @example
554
+ * ```ts
555
+ * const policy = await page.frame('[data-testid="policy-frame"]');
556
+ * await policy.expectText("body", "Return Policy");
557
+ * ```
558
+ */
559
+ async frame(selector, options) {
560
+ const timeout = options?.timeout ?? this._actionTimeout;
561
+ const start = Date.now();
562
+ try {
563
+ const handle = await this.raw.waitForSelector(selector, { timeout });
564
+ if (!handle) {
565
+ throw new Error(`frame("${selector}"): element not found`);
566
+ }
567
+ // try/finally so the handle is disposed even if contentFrame() throws.
568
+ let contentFrame;
569
+ try {
570
+ contentFrame = await handle.contentFrame();
571
+ }
572
+ finally {
573
+ await handle.dispose();
574
+ }
575
+ if (!contentFrame) {
576
+ throw new Error(`frame("${selector}"): element matched but is not an <iframe> (no content frame)`);
577
+ }
578
+ this._ctx.action({
579
+ category: "browser:frame",
580
+ target: selector,
581
+ duration: Date.now() - start,
582
+ status: "ok",
583
+ detail: { url: contentFrame.url() },
584
+ });
585
+ return createFrame(contentFrame, {
586
+ action: (e) => this._ctx.action(e),
587
+ captureStep: (label) => this._evidence?.captureShot(label, "step") ?? Promise.resolve(),
588
+ captureFailure: (label) => this._evidence?.captureShot(label, "failure") ?? Promise.resolve(),
589
+ }, this._actionTimeout);
590
+ }
591
+ catch (err) {
592
+ this._ctx.action({
593
+ category: "browser:frame",
594
+ target: selector,
595
+ duration: Date.now() - start,
596
+ status: "timeout",
597
+ detail: { error: String(err) },
598
+ });
599
+ await this._evidence?.captureShot(`frame-${selector}`, "failure");
600
+ throw err;
601
+ }
602
+ }
279
603
  // ── Navigation & interaction ────────────────────────────────────────
280
604
  /**
281
605
  * Navigate to a URL. Relative paths are resolved against the configured `baseUrl`.
@@ -340,7 +664,7 @@ export class GlubeanPage {
340
664
  action: (e) => this._ctx.action(e),
341
665
  captureStep: (label) => this._evidence?.captureShot(label, "step") ?? Promise.resolve(),
342
666
  captureFailure: (label) => this._evidence?.captureShot(label, "failure") ?? Promise.resolve(),
343
- }, selector);
667
+ }, selector, this.raw);
344
668
  }
345
669
  /**
346
670
  * Locate an element by its `data-testid` attribute.
@@ -1124,6 +1448,82 @@ export class GlubeanPage {
1124
1448
  throw err;
1125
1449
  }
1126
1450
  }
1451
+ /**
1452
+ * Assert that a checkbox/radio's `.checked` state matches `expected`.
1453
+ * Retries until match or timeout.
1454
+ *
1455
+ * @param expected — desired `.checked` value. @default true
1456
+ */
1457
+ async expectChecked(selector, expected = true, options) {
1458
+ const start = Date.now();
1459
+ let lastVal = null;
1460
+ try {
1461
+ lastVal = await this._retryUntil(() => this.raw.$eval(selector,
1462
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1463
+ (el) => Boolean(el.checked)).catch(() => null), (val) => val === expected, (lv) => `expectChecked("${selector}"): expected checked=${expected} ` +
1464
+ `but received checked=${JSON.stringify(lv)} after ${options?.timeout ?? 5_000}ms`, options);
1465
+ this._ctx.action({
1466
+ category: "browser:assert",
1467
+ target: `expectChecked("${selector}")`,
1468
+ duration: Date.now() - start,
1469
+ status: "ok",
1470
+ detail: { expected, actual: lastVal },
1471
+ });
1472
+ }
1473
+ catch (err) {
1474
+ await this._evidence?.captureShot(`expectChecked-${selector}`, "failure");
1475
+ this._ctx.action({
1476
+ category: "browser:assert",
1477
+ target: `expectChecked("${selector}")`,
1478
+ duration: Date.now() - start,
1479
+ status: "timeout",
1480
+ detail: { expected, actual: lastVal, error: String(err) },
1481
+ });
1482
+ throw err;
1483
+ }
1484
+ }
1485
+ /**
1486
+ * Assert that an input/textarea/select's `.value` matches `expected`.
1487
+ * Retries until match or timeout. For text content of non-form elements,
1488
+ * use `expectText()` instead.
1489
+ */
1490
+ async expectValue(selector, expected, options) {
1491
+ const matches = (val) => {
1492
+ if (val === null)
1493
+ return false;
1494
+ return expected instanceof RegExp ? expected.test(val) : val === expected;
1495
+ };
1496
+ const start = Date.now();
1497
+ let lastVal = null;
1498
+ try {
1499
+ lastVal = await this._retryUntil(() => this.raw.$eval(selector,
1500
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1501
+ (el) => (el.value ?? "")).catch(() => null), matches, (lv) => `expectValue("${selector}"): expected ${JSON.stringify(expected)} ` +
1502
+ `but received ${JSON.stringify(lv)} after ${options?.timeout ?? 5_000}ms`, options);
1503
+ this._ctx.action({
1504
+ category: "browser:assert",
1505
+ target: `expectValue("${selector}")`,
1506
+ duration: Date.now() - start,
1507
+ status: "ok",
1508
+ detail: { expected: String(expected), actual: lastVal },
1509
+ });
1510
+ }
1511
+ catch (err) {
1512
+ await this._evidence?.captureShot(`expectValue-${selector}`, "failure");
1513
+ this._ctx.action({
1514
+ category: "browser:assert",
1515
+ target: `expectValue("${selector}")`,
1516
+ duration: Date.now() - start,
1517
+ status: "timeout",
1518
+ detail: {
1519
+ expected: String(expected),
1520
+ actual: lastVal,
1521
+ error: String(err),
1522
+ },
1523
+ });
1524
+ throw err;
1525
+ }
1526
+ }
1127
1527
  // ── Phase 8: Network Interception & Assertions ────────────────────
1128
1528
  /**
1129
1529
  * Wait for a network request matching `pattern`.