@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/evidence.d.ts +470 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +721 -0
- package/dist/evidence.js.map +1 -0
- 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 -2
- 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/network.d.ts +15 -5
- package/dist/network.d.ts.map +1 -1
- package/dist/network.js +16 -12
- package/dist/network.js.map +1 -1
- package/dist/page.d.ts +213 -17
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +508 -107
- package/dist/page.js.map +1 -1
- package/package.json +2 -2
package/dist/page.js
CHANGED
|
@@ -11,10 +11,21 @@
|
|
|
11
11
|
*
|
|
12
12
|
* @module page
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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.
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
505
|
+
await action();
|
|
256
506
|
}
|
|
257
|
-
catch {
|
|
258
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
265
|
-
*
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
341
|
-
captureFailure: (label) => this.
|
|
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.
|
|
779
|
+
await this._evidence?.captureShot(`clickAndNavigate-${selector}`, "failure");
|
|
455
780
|
throw err;
|
|
456
781
|
}
|
|
457
|
-
await this.
|
|
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.
|
|
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.
|
|
930
|
+
await this._evidence?.captureShot(`upload-${selector}`, "failure");
|
|
606
931
|
throw err;
|
|
607
932
|
}
|
|
608
|
-
await this.
|
|
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.
|
|
973
|
+
await this._evidence?.captureShot(`chooseFile-${triggerSelector}`, "failure");
|
|
649
974
|
throw err;
|
|
650
975
|
}
|
|
651
|
-
await this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
1680
|
+
/** Clean up: detach the shared evidence CDP session and close the page. */
|
|
1280
1681
|
async close() {
|
|
1281
|
-
if (this.
|
|
1282
|
-
await this.
|
|
1283
|
-
this.
|
|
1682
|
+
if (this._evidence) {
|
|
1683
|
+
await this._evidence.detach();
|
|
1684
|
+
this._evidence = null;
|
|
1284
1685
|
}
|
|
1285
1686
|
try {
|
|
1286
1687
|
await this.raw.close();
|