@glubean/browser 0.8.1 → 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
@@ -11,10 +11,21 @@
11
11
  *
12
12
  * @module page
13
13
  */
14
- import { attachNetworkTracer } from "./network.js";
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";
19
+ // ── Module-level helpers (used by the shoot callback in _create) ──────
20
+ /** Sanitize a string into a safe filename segment. */
21
+ function _sanitizeFilename(s) {
22
+ return s.replace(/[^a-z0-9_-]/gi, "_").slice(0, 60);
23
+ }
24
+ /** Create a directory (and any parents) if it does not already exist. */
25
+ async function _ensureDir(dir) {
26
+ const { mkdir } = await import("node:fs/promises");
27
+ await mkdir(dir, { recursive: true });
28
+ }
18
29
  /**
19
30
  * Connected browser instance returned by the plugin.
20
31
  *
@@ -50,7 +61,6 @@ export class GlubeanBrowser {
50
61
  clearTimeout(this._closeTimer);
51
62
  this._closeTimer = null;
52
63
  }
53
- this._openPages++;
54
64
  const browser = await this._getBrowser();
55
65
  // Reuse the default about:blank tab instead of opening a new one.
56
66
  // When Chrome launches it always creates one blank page — reusing it
@@ -61,16 +71,31 @@ export class GlubeanBrowser {
61
71
  return url === "about:blank" || url === "chrome://new-tab-page/";
62
72
  });
63
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++;
64
93
  rawPage.once("close", () => {
65
94
  this._openPages--;
66
95
  if (this._openPages <= 0) {
67
96
  this._scheduleClose();
68
97
  }
69
98
  });
70
- // Read testId lazily from the runtime carrier — the harness updates it
71
- // before each test runs, so reading at newPage() time is always fresh.
72
- const runtimeTestId = getRuntime()?.test?.id;
73
- return GlubeanPage._create(rawPage, this._baseUrl, ctx, this._options, runtimeTestId);
74
99
  }
75
100
  _scheduleClose() {
76
101
  this._closeTimer = setTimeout(async () => {
@@ -116,32 +141,78 @@ export class GlubeanPage {
116
141
  _baseUrl;
117
142
  _ctx;
118
143
  _metricsEnabled;
119
- _screenshotMode;
120
- _screenshotDir;
121
- _testId;
122
144
  _actionTimeout;
123
- _stepCounter = 0;
124
- _networkCleanup = null;
125
- constructor(page, baseUrl, ctx, metricsEnabled, screenshotMode, screenshotDir, testId, actionTimeout) {
145
+ _options;
146
+ _testId;
147
+ /** Owning browser used to register popups in its open-page accounting. */
148
+ _owner;
149
+ _evidence = null;
150
+ _dialogHandler = null;
151
+ constructor(page, baseUrl, ctx, metricsEnabled, actionTimeout, options, testId, owner) {
126
152
  this.raw = page;
127
153
  this._baseUrl = baseUrl;
128
154
  this._ctx = ctx;
129
155
  this._metricsEnabled = metricsEnabled;
130
- this._screenshotMode = screenshotMode;
131
- this._screenshotDir = screenshotDir;
132
- this._testId = testId;
133
156
  this._actionTimeout = actionTimeout;
157
+ this._options = options;
158
+ this._testId = testId;
159
+ this._owner = owner;
134
160
  }
135
161
  /** @internal */
136
- static async _create(page, baseUrl, ctx, options, runtimeTestId) {
162
+ static async _create(page, baseUrl, ctx, options, runtimeTestId, owner) {
137
163
  const consoleForward = options.consoleForward ?? true;
138
164
  const networkTraceOpt = options.networkTrace ?? true;
139
165
  const metricsEnabled = options.metrics ?? true;
140
166
  const screenshotMode = options.screenshot ?? "on-failure";
141
167
  const screenshotDir = options.screenshotDir ?? ".glubean/screenshots";
142
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";
143
175
  const actionTimeout = options.actionTimeout ?? 30_000;
144
- const gp = new GlubeanPage(page, baseUrl, ctx, metricsEnabled, screenshotMode, screenshotDir, testId, 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
+ });
145
216
  if (consoleForward) {
146
217
  page.on("console", (msg) => {
147
218
  const type = msg.type();
@@ -167,15 +238,83 @@ export class GlubeanPage {
167
238
  ctx.warn(false, `[browser:uncaught] ${msg}`);
168
239
  });
169
240
  }
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
- }
241
+ // One shared evidence CDP session hosts Network trace + Fetch mock +
242
+ // Emulation + Screenshot capture. Each capability is only wired when its
243
+ // config is present.
244
+ const filterOpts = typeof networkTraceOpt === "object" ? networkTraceOpt : undefined;
245
+ // Build the screenshot shoot delegate. It performs I/O (take screenshot,
246
+ // save artifact, emit ctx.event) and is called by EvidenceSession.captureShot
247
+ // when mode+trigger policy allows. Defined here so it closes over `page`,
248
+ // `ctx`, `screenshotDir`, and `testId` without leaking them into EvidenceSession.
249
+ // Always install the shoot delegate so captureScreenshot() (manual, trigger="manual")
250
+ // works even when screenshotMode is "off". The mode only gates automatic triggers
251
+ // (step / failure); manual captures bypass the mode filter inside EvidenceSession.
252
+ const screenshotsOpt = {
253
+ mode: screenshotMode,
254
+ shoot: async (filename, label, trigger) => {
255
+ if (ctx.saveArtifact) {
256
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
257
+ const buffer = (await page.screenshot({
258
+ fullPage: true,
259
+ encoding: "binary",
260
+ }));
261
+ const artifactId = await ctx.saveArtifact(filename, buffer, {
262
+ type: "screenshot",
263
+ mimeType: "image/png",
264
+ });
265
+ ctx.event({
266
+ type: "browser:screenshot",
267
+ data: { artifactId, label, trigger, fullPage: true },
268
+ });
269
+ return { artifactId };
270
+ }
271
+ // Legacy fallback: direct file write when saveArtifact is not available.
272
+ const subdir = `${screenshotDir}/${_sanitizeFilename(testId)}`;
273
+ await _ensureDir(subdir);
274
+ const filePath = `${subdir}/${filename}`;
275
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
+ await page.screenshot({ path: filePath, fullPage: true });
277
+ ctx.event({
278
+ type: "browser:screenshot",
279
+ data: { path: filePath, label, trigger, fullPage: true },
280
+ });
281
+ return { path: filePath };
282
+ },
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);
287
+ gp._evidence = await EvidenceSession.attach(page, {
288
+ trace: networkTraceOpt !== false ? (t) => ctx.trace(t) : undefined,
289
+ network: filterOpts && {
290
+ include: filterOpts.include,
291
+ excludePaths: filterOpts.excludePaths,
292
+ filter: filterOpts.filter,
293
+ },
294
+ mocks: options.mock,
295
+ emulate: options.emulate,
296
+ storageState: options.storageState,
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
+ },
317
+ });
179
318
  // Proxy: GlubeanPage methods take priority; everything else falls through
180
319
  // to the raw Puppeteer Page so users can call page.waitForNavigation(),
181
320
  // page.setViewport(), page.keyboard.press(), etc. directly.
@@ -198,81 +337,267 @@ export class GlubeanPage {
198
337
  });
199
338
  }
200
339
  // ── Screenshot helpers ──────────────────────────────────────────────
201
- _formatTimestamp() {
202
- return new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
340
+ /**
341
+ * Capture a screenshot for a test-level failure (e.g. assertion error).
342
+ *
343
+ * Call this in the fixture's catch block to get a final-state screenshot
344
+ * when the test body throws. Delegates to the shared {@link EvidenceSession}
345
+ * so the capture obeys the configured {@link ScreenshotMode} and the
346
+ * screenshot is recorded in the unified evidence timeline.
347
+ */
348
+ async screenshotOnFailure() {
349
+ await this._evidence?.captureShot("final", "failure");
350
+ }
351
+ /**
352
+ * Manually capture a screenshot into the evidence stream.
353
+ *
354
+ * Unlike the raw `screenshot()` method (which returns bytes and skips the
355
+ * evidence flow), this always records the capture in the timeline regardless
356
+ * of the configured auto-screenshot mode. Use it for explicit checkpoints
357
+ * inside the test body.
358
+ *
359
+ * @param label — human label for this checkpoint (default: `"manual"`)
360
+ *
361
+ * @example
362
+ * ```ts
363
+ * await page.goto("/checkout");
364
+ * await page.captureScreenshot("cart-loaded");
365
+ * ```
366
+ */
367
+ async captureScreenshot(label = "manual") {
368
+ await this._evidence?.captureShot(label, "manual");
203
369
  }
204
- _sanitizeLabel(label) {
205
- return label.replace(/[^a-z0-9_-]/gi, "_").slice(0, 60);
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);
206
387
  }
207
- async _ensureDir(dir) {
208
- const { mkdir } = await import("node:fs/promises");
209
- await mkdir(dir, { recursive: true });
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;
210
406
  }
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",
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",
221
465
  });
222
- this._ctx.event({
223
- type: "browser:screenshot",
224
- data: { artifactId: id, label, fullPage: true },
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) },
225
476
  });
226
- return id;
477
+ throw err;
227
478
  }
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
479
  }
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();
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);
254
504
  try {
255
- await this._saveScreenshot(`FAIL-${num}-${this._sanitizeLabel(action)}-${ts}.png`, `FAIL:${action}`);
505
+ await action();
256
506
  }
257
- catch {
258
- // best-effort — page may be in a broken state
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;
259
539
  }
260
540
  }
541
+ // ── iframe ───────────────────────────────────────────────────────────
261
542
  /**
262
- * Capture a screenshot for a test-level failure (e.g. assertion error).
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`).
263
547
  *
264
- * Call this in the fixture's catch block to get a final-state screenshot
265
- * when the test body throws.
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
+ * ```
266
558
  */
267
- async screenshotOnFailure() {
268
- if (this._screenshotMode === "off")
269
- return;
270
- const ts = this._formatTimestamp();
559
+ async frame(selector, options) {
560
+ const timeout = options?.timeout ?? this._actionTimeout;
561
+ const start = Date.now();
271
562
  try {
272
- await this._saveScreenshot(`FAIL-final-${ts}.png`, "FAIL:final");
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);
273
590
  }
274
- catch {
275
- // best-effort
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;
276
601
  }
277
602
  }
278
603
  // ── Navigation & interaction ────────────────────────────────────────
@@ -300,7 +625,7 @@ export class GlubeanPage {
300
625
  status: "error",
301
626
  detail: { url: resolvedUrl, error: String(err) },
302
627
  });
303
- await this._captureFailure(`goto-${url}`);
628
+ await this._evidence?.captureShot(`goto-${url}`, "failure");
304
629
  throw err;
305
630
  }
306
631
  const duration = Date.now() - start;
@@ -315,7 +640,7 @@ export class GlubeanPage {
315
640
  if (this._metricsEnabled) {
316
641
  await collectNavigationMetrics(this.raw, (name, value, opts) => this._ctx.metric(name, value, opts), resolvedUrl);
317
642
  }
318
- await this._captureStep(`goto-${url}`);
643
+ await this._evidence?.captureShot(`goto-${url}`, "step");
319
644
  }
320
645
  /**
321
646
  * Create a WrappedLocator for the given selector.
@@ -337,9 +662,9 @@ export class GlubeanPage {
337
662
  const inner = this.raw.locator(selector);
338
663
  return createWrappedLocator(inner, {
339
664
  action: (e) => this._ctx.action(e),
340
- captureStep: (label) => this._captureStep(label),
341
- captureFailure: (label) => this._captureFailure(label),
342
- }, selector);
665
+ captureStep: (label) => this._evidence?.captureShot(label, "step") ?? Promise.resolve(),
666
+ captureFailure: (label) => this._evidence?.captureShot(label, "failure") ?? Promise.resolve(),
667
+ }, selector, this.raw);
343
668
  }
344
669
  /**
345
670
  * Locate an element by its `data-testid` attribute.
@@ -451,10 +776,10 @@ export class GlubeanPage {
451
776
  status: "error",
452
777
  detail: { error: String(err) },
453
778
  });
454
- await this._captureFailure(`clickAndNavigate-${selector}`);
779
+ await this._evidence?.captureShot(`clickAndNavigate-${selector}`, "failure");
455
780
  throw err;
456
781
  }
457
- await this._captureStep(`clickAndNavigate-${selector}`);
782
+ await this._evidence?.captureShot(`clickAndNavigate-${selector}`, "step");
458
783
  }
459
784
  /**
460
785
  * Type text into an element matching the selector (appends — does not clear).
@@ -530,7 +855,7 @@ export class GlubeanPage {
530
855
  status: "timeout",
531
856
  detail: { values, error: String(err) },
532
857
  });
533
- await this._captureFailure(`select-${selector}`);
858
+ await this._evidence?.captureShot(`select-${selector}`, "failure");
534
859
  throw err;
535
860
  }
536
861
  }
@@ -602,10 +927,10 @@ export class GlubeanPage {
602
927
  status: "timeout",
603
928
  detail: { fileCount: filePaths.length, error: String(err) },
604
929
  });
605
- await this._captureFailure(`upload-${selector}`);
930
+ await this._evidence?.captureShot(`upload-${selector}`, "failure");
606
931
  throw err;
607
932
  }
608
- await this._captureStep(`upload-${selector}`);
933
+ await this._evidence?.captureShot(`upload-${selector}`, "step");
609
934
  }
610
935
  /**
611
936
  * Click a button/element that triggers a file chooser dialog, then accept
@@ -645,10 +970,10 @@ export class GlubeanPage {
645
970
  status: "timeout",
646
971
  detail: { fileCount: filePaths.length, error: String(err) },
647
972
  });
648
- await this._captureFailure(`chooseFile-${triggerSelector}`);
973
+ await this._evidence?.captureShot(`chooseFile-${triggerSelector}`, "failure");
649
974
  throw err;
650
975
  }
651
- await this._captureStep(`chooseFile-${triggerSelector}`);
976
+ await this._evidence?.captureShot(`chooseFile-${triggerSelector}`, "step");
652
977
  }
653
978
  /** Query a single element by selector. */
654
979
  async $(selector) {
@@ -930,7 +1255,7 @@ export class GlubeanPage {
930
1255
  });
931
1256
  }
932
1257
  catch (err) {
933
- await this._captureFailure(`expectURL-${String(pattern)}`);
1258
+ await this._evidence?.captureShot(`expectURL-${String(pattern)}`, "failure");
934
1259
  this._ctx.action({
935
1260
  category: "browser:assert",
936
1261
  target: `expectURL(${JSON.stringify(String(pattern))})`,
@@ -983,7 +1308,7 @@ export class GlubeanPage {
983
1308
  });
984
1309
  }
985
1310
  catch (err) {
986
- await this._captureFailure(`expectText-${selector}`);
1311
+ await this._evidence?.captureShot(`expectText-${selector}`, "failure");
987
1312
  this._ctx.action({
988
1313
  category: "browser:assert",
989
1314
  target: `expectText("${selector}")`,
@@ -1014,7 +1339,7 @@ export class GlubeanPage {
1014
1339
  });
1015
1340
  }
1016
1341
  catch (err) {
1017
- await this._captureFailure(`expectVisible-${selector}`);
1342
+ await this._evidence?.captureShot(`expectVisible-${selector}`, "failure");
1018
1343
  this._ctx.action({
1019
1344
  category: "browser:assert",
1020
1345
  target: `expectVisible("${selector}")`,
@@ -1041,7 +1366,7 @@ export class GlubeanPage {
1041
1366
  });
1042
1367
  }
1043
1368
  catch (err) {
1044
- await this._captureFailure(`expectHidden-${selector}`);
1369
+ await this._evidence?.captureShot(`expectHidden-${selector}`, "failure");
1045
1370
  this._ctx.action({
1046
1371
  category: "browser:assert",
1047
1372
  target: `expectHidden("${selector}")`,
@@ -1079,7 +1404,7 @@ export class GlubeanPage {
1079
1404
  });
1080
1405
  }
1081
1406
  catch (err) {
1082
- await this._captureFailure(`expectAttribute-${selector}-${attr}`);
1407
+ await this._evidence?.captureShot(`expectAttribute-${selector}-${attr}`, "failure");
1083
1408
  this._ctx.action({
1084
1409
  category: "browser:assert",
1085
1410
  target: `expectAttribute("${selector}", "${attr}")`,
@@ -1112,7 +1437,7 @@ export class GlubeanPage {
1112
1437
  });
1113
1438
  }
1114
1439
  catch (err) {
1115
- await this._captureFailure(`expectCount-${selector}`);
1440
+ await this._evidence?.captureShot(`expectCount-${selector}`, "failure");
1116
1441
  this._ctx.action({
1117
1442
  category: "browser:assert",
1118
1443
  target: `expectCount("${selector}")`,
@@ -1123,6 +1448,82 @@ export class GlubeanPage {
1123
1448
  throw err;
1124
1449
  }
1125
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
+ }
1126
1527
  // ── Phase 8: Network Interception & Assertions ────────────────────
1127
1528
  /**
1128
1529
  * Wait for a network request matching `pattern`.
@@ -1276,11 +1677,11 @@ export class GlubeanPage {
1276
1677
  throw err;
1277
1678
  }
1278
1679
  }
1279
- /** Clean up: remove CDP listeners and close the page. */
1680
+ /** Clean up: detach the shared evidence CDP session and close the page. */
1280
1681
  async close() {
1281
- if (this._networkCleanup) {
1282
- await this._networkCleanup();
1283
- this._networkCleanup = null;
1682
+ if (this._evidence) {
1683
+ await this._evidence.detach();
1684
+ this._evidence = null;
1284
1685
  }
1285
1686
  try {
1286
1687
  await this.raw.close();