@glubean/browser 0.8.4 → 0.9.1
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/evidence.d.ts +207 -11
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +393 -15
- package/dist/evidence.js.map +1 -1
- package/dist/frame.d.ts +83 -0
- package/dist/frame.d.ts.map +1 -0
- package/dist/frame.js +183 -0
- package/dist/frame.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/locator.d.ts +73 -2
- package/dist/locator.d.ts.map +1 -1
- package/dist/locator.js +127 -2
- package/dist/locator.js.map +1 -1
- package/dist/page.d.ts +147 -3
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +409 -9
- package/dist/page.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
|
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`.
|