@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/evidence.js
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared evidence CDP session — the foundation for browser evidence capture.
|
|
3
|
+
*
|
|
4
|
+
* A single self-opened `createCDPSession` hosts **all** CDP-backed capabilities
|
|
5
|
+
* at once, instead of opening one session per capability:
|
|
6
|
+
*
|
|
7
|
+
* - **Network trace** (`Network` domain) — every in-page request as a Glubean
|
|
8
|
+
* trace event (see {@link ./network.ts}).
|
|
9
|
+
* - **Request mock** (`Fetch` domain) — fulfill matching requests with a canned
|
|
10
|
+
* response; non-matching requests are continued untouched.
|
|
11
|
+
* - **Emulation** (`Emulation` domain) — timezone / geolocation / viewport / user agent.
|
|
12
|
+
* - **Storage state** — restore cookies / localStorage before the page's
|
|
13
|
+
* first script runs, and capture them back out on demand (a thin "login
|
|
14
|
+
* once, reuse the session" primitive). Cookies go through this session's
|
|
15
|
+
* `Network.setCookies` (browser-context scoped, session-agnostic in CDP).
|
|
16
|
+
* localStorage does **not** — see guardrail ③.
|
|
17
|
+
*
|
|
18
|
+
* The keystone spike disproved the "Fetch is near-exclusive" hypothesis: a
|
|
19
|
+
* self-opened session running `Fetch.enable`/`fulfillRequest` is stable **as
|
|
20
|
+
* long as the user has not enabled Puppeteer's own request interception**.
|
|
21
|
+
* Three coexistence hazards exist, each handled below:
|
|
22
|
+
*
|
|
23
|
+
* **Guardrail ① — viewport ownership.** Our `Emulation.setDeviceMetricsOverride`
|
|
24
|
+
* and Puppeteer's `page.setViewport()` both drive device metrics and clobber
|
|
25
|
+
* each other last-wins. When `emulate.viewport` is configured, Glubean is the
|
|
26
|
+
* sole owner: it applies the viewport last and blocks `page.setViewport()` with
|
|
27
|
+
* a clear error so the two never race.
|
|
28
|
+
*
|
|
29
|
+
* **Guardrail ② — Fetch double-open.** If the user turns on Puppeteer request
|
|
30
|
+
* interception (`page.setRequestInterception(true)`) while our `Fetch` mock is
|
|
31
|
+
* active, both stacks try to handle the same paused request and fulfillment
|
|
32
|
+
* conflicts. When mocks are enabled, Glubean owns `Fetch` and blocks
|
|
33
|
+
* `page.setRequestInterception(true)` with a clear error. When no mocks are
|
|
34
|
+
* configured we never enable `Fetch`, so the user's own interception is free.
|
|
35
|
+
*
|
|
36
|
+
* **Guardrail ③ — new-document scripts need Puppeteer's OWN session.**
|
|
37
|
+
* Verified empirically: sending `Page.addScriptToEvaluateOnNewDocument` on
|
|
38
|
+
* our *auxiliary* `page.createCDPSession()` session registers without error
|
|
39
|
+
* (a real `identifier` comes back) but the script never actually fires on
|
|
40
|
+
* subsequent navigations — only the session Puppeteer itself uses for the
|
|
41
|
+
* page's frame-lifecycle bookkeeping gets its new-document scripts run.
|
|
42
|
+
* `storageState.localStorage` therefore seeds via Puppeteer's own
|
|
43
|
+
* `page.evaluateOnNewDocument()`, not a raw CDP call on this session.
|
|
44
|
+
*
|
|
45
|
+
* **Downloads** (`Browser` domain, BT1-M5) — `Browser.setDownloadBehavior`
|
|
46
|
+
* with `eventsEnabled: true`, called on this same auxiliary session, both
|
|
47
|
+
* configures the save directory AND (unlike guardrail ③'s Page-domain
|
|
48
|
+
* finding) reliably delivers `Browser.downloadWillBegin` /
|
|
49
|
+
* `Browser.downloadProgress` back on THIS session — verified empirically
|
|
50
|
+
* (spike script) against puppeteer-core ^24; `Browser.*` is genuinely
|
|
51
|
+
* session-agnostic where `Page.addScriptToEvaluateOnNewDocument` was not.
|
|
52
|
+
* The deprecated `Page.setDownloadBehavior` / `Page.downloadWillBegin` also
|
|
53
|
+
* work on the same spike but `Browser.*` is the protocol's forward path, so
|
|
54
|
+
* that's what's wired here. One caveat verified in the same spike: the file
|
|
55
|
+
* on disk is saved as `suggestedFilename`, not `guid` (despite some CDP docs
|
|
56
|
+
* implying the latter) — `DownloadEntry.path` is built from that name, which
|
|
57
|
+
* is only exact when Chrome didn't have to de-duplicate a collision.
|
|
58
|
+
* Always wired when a page is created (like dialog/popup) — a download IS
|
|
59
|
+
* evidence the moment it happens, not an opt-in capability.
|
|
60
|
+
*
|
|
61
|
+
* **Guardrail ④ — download events are browser-context-global.** Unlike the
|
|
62
|
+
* page-scoped `Network`/`Fetch` domains, `Browser.setDownloadBehavior` sets a
|
|
63
|
+
* single context-wide save path (last caller wins) and its `downloadWillBegin`/
|
|
64
|
+
* `downloadProgress` events fire on *every* session that turned events on, for
|
|
65
|
+
* *every* download in the context. So two instrumented pages in one Chrome
|
|
66
|
+
* would otherwise cross-observe and cross-repoint each other's downloads. Two
|
|
67
|
+
* mitigations: (a) the page layer uses ONE browser-wide download dir (not
|
|
68
|
+
* per-test) so the last-wins path is a no-op; (b) this session enables the
|
|
69
|
+
* `Page` domain, tracks its target's frame ids (`Page.getFrameTree` +
|
|
70
|
+
* `frameAttached`/`frameDetached`), and only records downloads whose
|
|
71
|
+
* originating `frameId` it owns — so a page never resolves another page's
|
|
72
|
+
* download. Both degrade safely: an unreadable frame tree falls back to
|
|
73
|
+
* accepting all events (single-page runs are unaffected).
|
|
74
|
+
*
|
|
75
|
+
* @module evidence
|
|
76
|
+
*/
|
|
77
|
+
import { attachNetworkTracer, } from "./network.js";
|
|
78
|
+
// ── Pure helpers (exported for testing) ───────────────────────────────
|
|
79
|
+
/** @internal Does `rule` match the given request? */
|
|
80
|
+
export function matchMock(rule, req) {
|
|
81
|
+
if (rule.method && rule.method.toUpperCase() !== req.method.toUpperCase()) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (typeof rule.url === "string")
|
|
85
|
+
return req.url.includes(rule.url);
|
|
86
|
+
// Test against a freshly-constructed clone that carries the SAME flags as
|
|
87
|
+
// `rule.url`, including `g`/`y`: a global/sticky regex carries `lastIndex`
|
|
88
|
+
// state, so `.test()` on the caller's own object would flip-flop matches
|
|
89
|
+
// for the same URL across repeated requests (and would mutate the caller's
|
|
90
|
+
// regex). A same-flags clone fixes that statelessness — it always starts
|
|
91
|
+
// at `lastIndex: 0` and is discarded after this call, so no state ever
|
|
92
|
+
// crosses requests or leaks back into `rule.url`.
|
|
93
|
+
//
|
|
94
|
+
// Earlier this also *stripped* `g`/`y` on the clone, which went further
|
|
95
|
+
// than the statelessness fix required: dropping `y` silently discards a
|
|
96
|
+
// sticky regex's anchoring semantics — `.test()` on a `/y` regex only
|
|
97
|
+
// matches when the pattern occurs starting exactly at `lastIndex` (0 here,
|
|
98
|
+
// i.e. the start of the string), whereas a plain (non-sticky) regex
|
|
99
|
+
// matches anywhere in the string. Stripping `y` therefore turned an
|
|
100
|
+
// anchored mock pattern into an unanchored substring search, which could
|
|
101
|
+
// make a sticky mock rule match URLs it was never meant to (GLU-73).
|
|
102
|
+
// Preserving the original flags on the clone keeps `.test()` behaving
|
|
103
|
+
// exactly as it would on a fresh copy of the caller's own regex.
|
|
104
|
+
const clone = new RegExp(rule.url.source, rule.url.flags);
|
|
105
|
+
return clone.test(req.url);
|
|
106
|
+
}
|
|
107
|
+
/** @internal Find the first matching mock rule, or `undefined`. */
|
|
108
|
+
export function findMock(mocks, req) {
|
|
109
|
+
return mocks.find((m) => matchMock(m, req));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* @internal Build `Fetch.fulfillRequest` params from a rule.
|
|
113
|
+
*
|
|
114
|
+
* The body is always base64-encoded (as CDP requires). Content-type is inferred
|
|
115
|
+
* from the body kind unless `rule.contentType` is set. User headers win over the
|
|
116
|
+
* inferred content-type.
|
|
117
|
+
*/
|
|
118
|
+
export function buildFulfillParams(rule, requestId) {
|
|
119
|
+
let bodyStr = "";
|
|
120
|
+
let inferredType;
|
|
121
|
+
if (rule.body !== undefined) {
|
|
122
|
+
if (typeof rule.body === "string") {
|
|
123
|
+
bodyStr = rule.body;
|
|
124
|
+
inferredType = "text/plain; charset=utf-8";
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
bodyStr = JSON.stringify(rule.body);
|
|
128
|
+
inferredType = "application/json; charset=utf-8";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const headerMap = new Map();
|
|
132
|
+
const contentType = rule.contentType ?? inferredType;
|
|
133
|
+
if (contentType !== undefined)
|
|
134
|
+
headerMap.set("content-type", contentType);
|
|
135
|
+
// User headers win over the inferred content-type (case-insensitive keys).
|
|
136
|
+
for (const [name, value] of Object.entries(rule.headers ?? {})) {
|
|
137
|
+
headerMap.set(name.toLowerCase(), value);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
requestId,
|
|
141
|
+
responseCode: rule.status ?? 200,
|
|
142
|
+
responseHeaders: [...headerMap].map(([name, value]) => ({ name, value })),
|
|
143
|
+
body: Buffer.from(bodyStr, "utf8").toString("base64"),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* @internal Build the ordered list of `Emulation.*` CDP calls.
|
|
148
|
+
*
|
|
149
|
+
* Timezone, geolocation, and user agent come first (order-independent among
|
|
150
|
+
* themselves); the viewport override is applied **last** (guardrail ①) so it
|
|
151
|
+
* is the final word on device metrics at setup time.
|
|
152
|
+
*/
|
|
153
|
+
export function buildEmulationCalls(emulate) {
|
|
154
|
+
const calls = [];
|
|
155
|
+
if (emulate.timezone !== undefined) {
|
|
156
|
+
calls.push({
|
|
157
|
+
method: "Emulation.setTimezoneOverride",
|
|
158
|
+
params: { timezoneId: emulate.timezone },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (emulate.geolocation) {
|
|
162
|
+
const { latitude, longitude, accuracy } = emulate.geolocation;
|
|
163
|
+
calls.push({
|
|
164
|
+
method: "Emulation.setGeolocationOverride",
|
|
165
|
+
params: { latitude, longitude, accuracy: accuracy ?? 0 },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (emulate.userAgent !== undefined) {
|
|
169
|
+
calls.push({
|
|
170
|
+
method: "Emulation.setUserAgentOverride",
|
|
171
|
+
params: { userAgent: emulate.userAgent },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (emulate.viewport) {
|
|
175
|
+
const { width, height, deviceScaleFactor, mobile } = emulate.viewport;
|
|
176
|
+
calls.push({
|
|
177
|
+
method: "Emulation.setDeviceMetricsOverride",
|
|
178
|
+
params: {
|
|
179
|
+
width,
|
|
180
|
+
height,
|
|
181
|
+
deviceScaleFactor: deviceScaleFactor ?? 1,
|
|
182
|
+
mobile: mobile ?? false,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return calls;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* @internal Build `Network.setCookies` params from `storageState.cookies`.
|
|
190
|
+
* Returns `undefined` when there is nothing to set (caller skips the call).
|
|
191
|
+
*/
|
|
192
|
+
export function buildSetCookiesParams(cookies) {
|
|
193
|
+
if (!cookies || cookies.length === 0)
|
|
194
|
+
return undefined;
|
|
195
|
+
return { cookies };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* @internal Replace `page[method]` with a guard, returning a restore function.
|
|
199
|
+
*
|
|
200
|
+
* `shouldBlock` decides, per call arguments, whether to throw the guard error.
|
|
201
|
+
* Blocked calls throw synchronously; allowed calls fall through to the original
|
|
202
|
+
* method (or resolve to `undefined` if there was none). Used to enforce
|
|
203
|
+
* guardrails ① (viewport) and ② (Fetch interception).
|
|
204
|
+
*/
|
|
205
|
+
export function guardPageMethod(
|
|
206
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
207
|
+
page, method, message, shouldBlock) {
|
|
208
|
+
const hadOwn = Object.prototype.hasOwnProperty.call(page, method);
|
|
209
|
+
const previous = page[method];
|
|
210
|
+
const original = typeof previous === "function"
|
|
211
|
+
? previous.bind(page)
|
|
212
|
+
: undefined;
|
|
213
|
+
page[method] = (...args) => {
|
|
214
|
+
if (shouldBlock(args))
|
|
215
|
+
throw new Error(message);
|
|
216
|
+
return original ? original(...args) : Promise.resolve(undefined);
|
|
217
|
+
};
|
|
218
|
+
return () => {
|
|
219
|
+
if (hadOwn)
|
|
220
|
+
page[method] = previous;
|
|
221
|
+
else
|
|
222
|
+
delete page[method];
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// ── EvidenceSession ───────────────────────────────────────────────────
|
|
226
|
+
/**
|
|
227
|
+
* A single self-opened CDP session that hosts every CDP-backed evidence
|
|
228
|
+
* capability for one page. Create with {@link EvidenceSession.attach}; release
|
|
229
|
+
* with {@link EvidenceSession.detach}.
|
|
230
|
+
*/
|
|
231
|
+
export class EvidenceSession {
|
|
232
|
+
_cdp;
|
|
233
|
+
_cleanups = [];
|
|
234
|
+
_detached = false;
|
|
235
|
+
// ── Screenshot state ──────────────────────────────────────────────
|
|
236
|
+
_screenshotOpts;
|
|
237
|
+
_screenshotSeq = 0;
|
|
238
|
+
_screenshotLog = [];
|
|
239
|
+
// ── Download state ───────────────────────────────────────────────
|
|
240
|
+
_downloadsEnabled = false;
|
|
241
|
+
_downloadDir;
|
|
242
|
+
_onDownload;
|
|
243
|
+
/**
|
|
244
|
+
* Frame ids owned by THIS page's target. `Browser.setDownloadBehavior`'s
|
|
245
|
+
* events are browser-context-global — every session that turned events on
|
|
246
|
+
* receives every download in the context. We only record downloads whose
|
|
247
|
+
* originating `frameId` belongs to this page, so two instrumented pages
|
|
248
|
+
* sharing one Chrome never cross-observe each other's downloads.
|
|
249
|
+
*/
|
|
250
|
+
_ownedFrames = new Set();
|
|
251
|
+
/**
|
|
252
|
+
* True only once the initial `Page.getFrameTree` seeded {@link _ownedFrames}.
|
|
253
|
+
* The frame filter engages ONLY when this is set — otherwise we accept all
|
|
254
|
+
* download events (fallback mode). Without this flag, a later
|
|
255
|
+
* `Page.frameAttached` would grow `_ownedFrames` from empty to non-empty and
|
|
256
|
+
* silently flip fallback mode into a PARTIAL filter that drops the page's own
|
|
257
|
+
* top-frame downloads (codex R2 P2).
|
|
258
|
+
*/
|
|
259
|
+
_frameTreeSeeded = false;
|
|
260
|
+
_pendingDownloads = new Map();
|
|
261
|
+
_downloadWaiters = [];
|
|
262
|
+
constructor(cdp) {
|
|
263
|
+
this._cdp = cdp;
|
|
264
|
+
}
|
|
265
|
+
/** The underlying CDP session, for advanced/raw use. */
|
|
266
|
+
get cdp() {
|
|
267
|
+
return this._cdp;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* All screenshots captured into this evidence session, in order.
|
|
271
|
+
*
|
|
272
|
+
* Each entry carries `seq`, `ts`, `label`, `trigger`, and an artifact
|
|
273
|
+
* reference (`artifactId` or `path`). The list is empty when no screenshot
|
|
274
|
+
* has been captured yet or when `screenshots.mode` is `"off"`.
|
|
275
|
+
*/
|
|
276
|
+
get screenshots() {
|
|
277
|
+
return this._screenshotLog;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Capture a screenshot into the evidence stream, subject to the configured
|
|
281
|
+
* {@link ScreenshotMode} and the `trigger` argument.
|
|
282
|
+
*
|
|
283
|
+
* | mode | "step" trigger | "failure" trigger | "manual" trigger |
|
|
284
|
+
* |---------------|----------------|-------------------|------------------|
|
|
285
|
+
* | "off" | skip | skip | skip |
|
|
286
|
+
* | "on-failure" | skip | capture | capture |
|
|
287
|
+
* | "every-step" | capture | capture | capture |
|
|
288
|
+
*
|
|
289
|
+
* Always best-effort: errors from the I/O delegate are silently swallowed
|
|
290
|
+
* so a broken page state never masks the real test error.
|
|
291
|
+
*
|
|
292
|
+
* Does nothing when no `screenshots` option was passed to {@link attach}.
|
|
293
|
+
*/
|
|
294
|
+
async captureShot(label, trigger) {
|
|
295
|
+
const opts = this._screenshotOpts;
|
|
296
|
+
if (!opts)
|
|
297
|
+
return;
|
|
298
|
+
// "off" blocks automatic triggers but manual checkpoints always fire.
|
|
299
|
+
if (opts.mode === "off" && trigger !== "manual")
|
|
300
|
+
return;
|
|
301
|
+
if (opts.mode === "on-failure" && trigger === "step")
|
|
302
|
+
return;
|
|
303
|
+
this._screenshotSeq++;
|
|
304
|
+
const seq = this._screenshotSeq;
|
|
305
|
+
const ts = new Date().toISOString();
|
|
306
|
+
const sanitized = label.replace(/[^a-z0-9_-]/gi, "_").slice(0, 60);
|
|
307
|
+
const tsCompact = ts.replace(/[:.]/g, "").slice(0, 15);
|
|
308
|
+
const prefix = trigger === "failure" ? "FAIL-" : "";
|
|
309
|
+
const filename = `${prefix}${String(seq).padStart(3, "0")}-${sanitized}-${tsCompact}.png`;
|
|
310
|
+
try {
|
|
311
|
+
const ref = await opts.shoot(filename, label, trigger);
|
|
312
|
+
this._screenshotLog.push({ seq, ts, label, trigger, ...ref });
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// best-effort: page may be in a broken state
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Open one CDP session on `page` and wire up the requested capabilities:
|
|
320
|
+
* Network trace, Fetch mock (guardrail ②), Emulation (guardrail ① for
|
|
321
|
+
* viewport), and storage state (cookies + localStorage). Capabilities are
|
|
322
|
+
* only enabled when their config is present, so a page with no mocks never
|
|
323
|
+
* enables `Fetch` and leaves the user's own request interception untouched.
|
|
324
|
+
*/
|
|
325
|
+
static async attach(page, options) {
|
|
326
|
+
const cdp = await page.createCDPSession();
|
|
327
|
+
const session = new EvidenceSession(cdp);
|
|
328
|
+
// Wire screenshot options (no CDP domain — just stores the delegate).
|
|
329
|
+
// Store opts even when mode is "off" so that captureShot can still
|
|
330
|
+
// honour explicit manual checkpoints (captureShot itself gates auto
|
|
331
|
+
// triggers via `if (mode === "off" && trigger !== "manual") return`).
|
|
332
|
+
if (options.screenshots) {
|
|
333
|
+
session._screenshotOpts = options.screenshots;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
// 1. Network trace.
|
|
337
|
+
if (options.trace) {
|
|
338
|
+
const removeNetwork = await attachNetworkTracer(cdp, {
|
|
339
|
+
trace: options.trace,
|
|
340
|
+
include: options.network?.include,
|
|
341
|
+
excludePaths: options.network?.excludePaths,
|
|
342
|
+
filter: options.network?.filter,
|
|
343
|
+
});
|
|
344
|
+
session._cleanups.push(removeNetwork);
|
|
345
|
+
}
|
|
346
|
+
// 2. Fetch mock — guardrail ②: Glubean owns Fetch, block user interception.
|
|
347
|
+
const mocks = options.mocks;
|
|
348
|
+
if (mocks && mocks.length > 0) {
|
|
349
|
+
await session._installMocks(page, mocks);
|
|
350
|
+
}
|
|
351
|
+
// 3. Emulation (timezone / geolocation / user agent) + viewport last (guardrail ①).
|
|
352
|
+
if (options.emulate) {
|
|
353
|
+
for (const call of buildEmulationCalls(options.emulate)) {
|
|
354
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
355
|
+
await cdp.send(call.method, call.params);
|
|
356
|
+
}
|
|
357
|
+
if (options.emulate.viewport) {
|
|
358
|
+
session._guardViewport(page);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// 4. Storage state — cookies + localStorage restored before the page's
|
|
362
|
+
// first script runs. Order-independent from the other capabilities.
|
|
363
|
+
if (options.storageState) {
|
|
364
|
+
await session._applyStorageState(page, options.storageState);
|
|
365
|
+
}
|
|
366
|
+
// 5. Downloads — see the module doc's Downloads note. Order-independent.
|
|
367
|
+
if (options.downloads) {
|
|
368
|
+
await session._enableDownloads(options.downloads);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
// Roll back any partial wiring so we never leak a half-open session.
|
|
373
|
+
await session.detach();
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
return session;
|
|
377
|
+
}
|
|
378
|
+
async _installMocks(page, mocks) {
|
|
379
|
+
// Guardrail ②: block the user from enabling Puppeteer request interception
|
|
380
|
+
// while our Fetch mock is active (double-fulfill conflict). Disabling
|
|
381
|
+
// (`false`) is allowed through to the original.
|
|
382
|
+
const restoreInterception = guardPageMethod(page, "setRequestInterception", "Glubean request mocking is active on this page: do not call " +
|
|
383
|
+
"page.setRequestInterception(true) — it conflicts with the mock's " +
|
|
384
|
+
"Fetch handler. Remove the `mock` option to manage interception yourself.", (args) => args[0] === true);
|
|
385
|
+
this._cleanups.push(restoreInterception);
|
|
386
|
+
// Intercept every request at the Request stage; match in JS. Mocked
|
|
387
|
+
// requests are fulfilled, everything else is continued untouched — so the
|
|
388
|
+
// Network tracer still observes real responses.
|
|
389
|
+
await this._cdp.send("Fetch.enable", {
|
|
390
|
+
patterns: [{ urlPattern: "*", requestStage: "Request" }],
|
|
391
|
+
});
|
|
392
|
+
const onPaused = (event) => {
|
|
393
|
+
const rule = findMock(mocks, {
|
|
394
|
+
url: event.request.url,
|
|
395
|
+
method: event.request.method,
|
|
396
|
+
});
|
|
397
|
+
const done = rule
|
|
398
|
+
? this._cdp.send("Fetch.fulfillRequest",
|
|
399
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
400
|
+
buildFulfillParams(rule, event.requestId))
|
|
401
|
+
: this._cdp.send("Fetch.continueRequest", {
|
|
402
|
+
requestId: event.requestId,
|
|
403
|
+
});
|
|
404
|
+
// The request is gone if the page navigated away mid-flight — ignore.
|
|
405
|
+
done.catch(() => { });
|
|
406
|
+
};
|
|
407
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
408
|
+
this._cdp.on("Fetch.requestPaused", onPaused);
|
|
409
|
+
this._cleanups.push(() => {
|
|
410
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
411
|
+
this._cdp.off("Fetch.requestPaused", onPaused);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
async _applyStorageState(page, state) {
|
|
415
|
+
// Cookies are browser-context scoped in CDP, so our auxiliary session
|
|
416
|
+
// sets them fine (`Network.setCookies` isn't tied to a particular
|
|
417
|
+
// DevTools session/target the way frame-lifecycle hooks are).
|
|
418
|
+
const cookieParams = buildSetCookiesParams(state.cookies);
|
|
419
|
+
if (cookieParams) {
|
|
420
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
421
|
+
await this._cdp.send("Network.setCookies", cookieParams);
|
|
422
|
+
}
|
|
423
|
+
// localStorage seeding, in contrast, MUST go through Puppeteer's own
|
|
424
|
+
// page.evaluateOnNewDocument() — sending the equivalent raw
|
|
425
|
+
// `Page.addScriptToEvaluateOnNewDocument` on our *auxiliary*
|
|
426
|
+
// `page.createCDPSession()` session silently registers (no error, a
|
|
427
|
+
// real `identifier` comes back) but never actually fires on subsequent
|
|
428
|
+
// navigations. Verified empirically (spike script) against puppeteer-core
|
|
429
|
+
// ^24/^25: only the session Puppeteer itself uses for the page's own
|
|
430
|
+
// frame-lifecycle bookkeeping gets its new-document scripts run — a
|
|
431
|
+
// second, independently-attached session's registration is a no-op.
|
|
432
|
+
// Puppeteer's own API owns that session, so route through it here.
|
|
433
|
+
if (state.localStorage && Object.keys(state.localStorage).length > 0) {
|
|
434
|
+
const entries = state.localStorage;
|
|
435
|
+
await page.evaluateOnNewDocument((seed) => {
|
|
436
|
+
for (const [k, v] of Object.entries(seed)) {
|
|
437
|
+
try {
|
|
438
|
+
localStorage.setItem(k, v);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// best-effort — e.g. storage quota exceeded
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}, entries);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* @internal Wire `Browser.setDownloadBehavior` (`eventsEnabled: true`) plus
|
|
449
|
+
* the `downloadWillBegin` / `downloadProgress` listeners on this session.
|
|
450
|
+
* See the module doc's Downloads note for why `Browser.*` (not the
|
|
451
|
+
* deprecated `Page.*` pair) and why it works on an auxiliary session.
|
|
452
|
+
*
|
|
453
|
+
* `Browser.*` download events are browser-context-global, so we also enable
|
|
454
|
+
* the `Page` domain and track this target's frame ids, then only record
|
|
455
|
+
* downloads originating from a frame we own — two instrumented pages in one
|
|
456
|
+
* Chrome must not cross-observe each other's downloads.
|
|
457
|
+
*/
|
|
458
|
+
async _enableDownloads(opts) {
|
|
459
|
+
this._downloadsEnabled = true;
|
|
460
|
+
this._downloadDir = opts.dir;
|
|
461
|
+
this._onDownload = opts.onDownload;
|
|
462
|
+
// Seed + track the frame ids owned by this page's target so download-event
|
|
463
|
+
// attribution is page-scoped (Browser.* events are context-global). Page
|
|
464
|
+
// domain on our auxiliary session is independent of Puppeteer's own session.
|
|
465
|
+
await this._cdp.send("Page.enable");
|
|
466
|
+
try {
|
|
467
|
+
const { frameTree } = (await this._cdp.send("Page.getFrameTree"));
|
|
468
|
+
const walk = (node) => {
|
|
469
|
+
this._ownedFrames.add(node.frame.id);
|
|
470
|
+
for (const child of node.childFrames ?? []) {
|
|
471
|
+
walk(child);
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
walk(frameTree);
|
|
475
|
+
this._frameTreeSeeded = true;
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// best-effort: if the frame tree can't be read, fall back to accepting
|
|
479
|
+
// all download events (single-page runs are unaffected; the cross-page
|
|
480
|
+
// guard just degrades to off rather than erroring).
|
|
481
|
+
}
|
|
482
|
+
const onFrameAttached = (e) => {
|
|
483
|
+
this._ownedFrames.add(e.frameId);
|
|
484
|
+
};
|
|
485
|
+
const onFrameDetached = (e) => {
|
|
486
|
+
this._ownedFrames.delete(e.frameId);
|
|
487
|
+
};
|
|
488
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
489
|
+
this._cdp.on("Page.frameAttached", onFrameAttached);
|
|
490
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
491
|
+
this._cdp.on("Page.frameDetached", onFrameDetached);
|
|
492
|
+
await this._cdp.send("Browser.setDownloadBehavior", {
|
|
493
|
+
behavior: "allow",
|
|
494
|
+
downloadPath: opts.dir,
|
|
495
|
+
eventsEnabled: true,
|
|
496
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
497
|
+
});
|
|
498
|
+
const onWillBegin = (event) => {
|
|
499
|
+
// Only record downloads from a frame this page owns. The filter engages
|
|
500
|
+
// ONLY when the initial frame tree seeded successfully (_frameTreeSeeded)
|
|
501
|
+
// — otherwise we accept all events (fallback), and later frameAttached
|
|
502
|
+
// ids must NOT flip that into a partial filter (codex R2 P2). An event
|
|
503
|
+
// with no frameId is also accepted rather than silently dropped.
|
|
504
|
+
if (this._frameTreeSeeded &&
|
|
505
|
+
event.frameId !== undefined &&
|
|
506
|
+
!this._ownedFrames.has(event.frameId)) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
this._pendingDownloads.set(event.guid, {
|
|
510
|
+
url: event.url,
|
|
511
|
+
suggestedFilename: event.suggestedFilename,
|
|
512
|
+
});
|
|
513
|
+
};
|
|
514
|
+
const onProgress = (event) => {
|
|
515
|
+
// Unknown guid = a download we didn't record in onWillBegin (either it
|
|
516
|
+
// began before attach, or it belongs to another page — see the frame
|
|
517
|
+
// filter above). Ignore it.
|
|
518
|
+
const pending = this._pendingDownloads.get(event.guid);
|
|
519
|
+
if (!pending)
|
|
520
|
+
return;
|
|
521
|
+
const entry = {
|
|
522
|
+
guid: event.guid,
|
|
523
|
+
url: pending.url,
|
|
524
|
+
suggestedFilename: pending.suggestedFilename,
|
|
525
|
+
path: `${this._downloadDir}/${pending.suggestedFilename}`,
|
|
526
|
+
};
|
|
527
|
+
if (event.state === "inProgress") {
|
|
528
|
+
this._notifyDownload(entry, "inProgress");
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this._pendingDownloads.delete(event.guid);
|
|
532
|
+
// Emit evidence for EVERY terminal download (best-effort, isolated from
|
|
533
|
+
// the waiter state machine — a throwing callback must not wedge it).
|
|
534
|
+
this._notifyDownload(entry, event.state);
|
|
535
|
+
// Hand the terminal result to the next waiter (FIFO). A terminal
|
|
536
|
+
// download with no waiter is untargeted — its evidence is already
|
|
537
|
+
// emitted, so we simply drop it (never buffer it to satisfy a LATER,
|
|
538
|
+
// unrelated waitForDownload — that would silently mis-scope the wait).
|
|
539
|
+
const waiter = this._downloadWaiters.shift();
|
|
540
|
+
if (!waiter)
|
|
541
|
+
return;
|
|
542
|
+
if (event.state === "completed") {
|
|
543
|
+
waiter.resolve(entry);
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
waiter.reject(new Error(`waitForDownload: download "${entry.suggestedFilename}" (${entry.guid}) was canceled`));
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
550
|
+
this._cdp.on("Browser.downloadWillBegin", onWillBegin);
|
|
551
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
552
|
+
this._cdp.on("Browser.downloadProgress", onProgress);
|
|
553
|
+
this._cleanups.push(() => {
|
|
554
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
555
|
+
this._cdp.off("Browser.downloadWillBegin", onWillBegin);
|
|
556
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
557
|
+
this._cdp.off("Browser.downloadProgress", onProgress);
|
|
558
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
559
|
+
this._cdp.off("Page.frameAttached", onFrameAttached);
|
|
560
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
561
|
+
this._cdp.off("Page.frameDetached", onFrameDetached);
|
|
562
|
+
// Mark disabled + reject any still-pending waiters so nothing hangs
|
|
563
|
+
// until its own timeout past detach, and a post-detach call fails fast.
|
|
564
|
+
this._downloadsEnabled = false;
|
|
565
|
+
this._frameTreeSeeded = false;
|
|
566
|
+
this._pendingDownloads.clear();
|
|
567
|
+
this._ownedFrames.clear();
|
|
568
|
+
const waiting = this._downloadWaiters.splice(0, this._downloadWaiters.length);
|
|
569
|
+
for (const w of waiting) {
|
|
570
|
+
w.reject(new Error("waitForDownload: evidence session detached while waiting"));
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
/** @internal Best-effort onDownload callback — never let it wedge the waiter machine. */
|
|
575
|
+
_notifyDownload(entry, state) {
|
|
576
|
+
try {
|
|
577
|
+
this._onDownload?.(entry, state);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// best-effort — a throwing evidence callback must not break download waits
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Wait for the NEXT download (one that completes after this call) to finish,
|
|
585
|
+
* and resolve with its metadata. Register the wait *before* triggering the
|
|
586
|
+
* action that starts the download — `GlubeanPage.waitForDownload(action)` in
|
|
587
|
+
* `page.ts` does exactly this and is the ergonomic wrapper most callers
|
|
588
|
+
* should use instead of this lower-level method.
|
|
589
|
+
*
|
|
590
|
+
* Rejects if the next download is canceled, after `timeoutMs`, if `signal`
|
|
591
|
+
* aborts (e.g. the triggering action threw), or if the session has detached.
|
|
592
|
+
* Does **not** resolve from downloads that already completed before the call —
|
|
593
|
+
* each wait is scoped to a fresh, future download.
|
|
594
|
+
*/
|
|
595
|
+
waitForDownload(timeoutMs, signal) {
|
|
596
|
+
if (!this._downloadsEnabled) {
|
|
597
|
+
return Promise.reject(new Error("waitForDownload: downloads are not enabled on this page"));
|
|
598
|
+
}
|
|
599
|
+
if (signal?.aborted) {
|
|
600
|
+
return Promise.reject(new Error("waitForDownload: aborted before it started"));
|
|
601
|
+
}
|
|
602
|
+
return new Promise((resolve, reject) => {
|
|
603
|
+
// Single teardown for EVERY exit path (timeout, abort, resolve, reject)
|
|
604
|
+
// so the timer, the waiter registration, and the abort listener are all
|
|
605
|
+
// always released — no listener leak on the timeout path (codex R2 P3).
|
|
606
|
+
const cleanup = () => {
|
|
607
|
+
clearTimeout(timer);
|
|
608
|
+
signal?.removeEventListener("abort", onAbort);
|
|
609
|
+
const idx = this._downloadWaiters.indexOf(waiter);
|
|
610
|
+
if (idx >= 0)
|
|
611
|
+
this._downloadWaiters.splice(idx, 1);
|
|
612
|
+
};
|
|
613
|
+
const timer = setTimeout(() => {
|
|
614
|
+
cleanup();
|
|
615
|
+
reject(new Error(`waitForDownload: no download completed after ${timeoutMs}ms`));
|
|
616
|
+
}, timeoutMs);
|
|
617
|
+
const onAbort = () => {
|
|
618
|
+
cleanup();
|
|
619
|
+
reject(new Error("waitForDownload: aborted (triggering action failed)"));
|
|
620
|
+
};
|
|
621
|
+
if (signal)
|
|
622
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
623
|
+
const waiter = {
|
|
624
|
+
resolve: (entry) => {
|
|
625
|
+
cleanup();
|
|
626
|
+
resolve(entry);
|
|
627
|
+
},
|
|
628
|
+
reject: (err) => {
|
|
629
|
+
cleanup();
|
|
630
|
+
reject(err);
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
this._downloadWaiters.push(waiter);
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Capture the current page's cookies + localStorage as a {@link StorageState}
|
|
638
|
+
* snapshot — the counterpart to {@link EvidenceSessionOptions.storageState}.
|
|
639
|
+
* Cookies are scoped to the page's current URL (`Network.getCookies`), not
|
|
640
|
+
* every cookie the browser holds, so unrelated tabs/origins never leak in.
|
|
641
|
+
*
|
|
642
|
+
* @example
|
|
643
|
+
* ```ts
|
|
644
|
+
* await page.goto("/login");
|
|
645
|
+
* // ... perform login ...
|
|
646
|
+
* const state = await page.getStorageState();
|
|
647
|
+
* // Reuse on a fresh page to skip the login flow:
|
|
648
|
+
* const chrome2 = browser({ launch: true, storageState: state });
|
|
649
|
+
* ```
|
|
650
|
+
*/
|
|
651
|
+
async captureStorageState(page) {
|
|
652
|
+
const { cookies } = (await this._cdp.send("Network.getCookies", {
|
|
653
|
+
urls: [page.url()],
|
|
654
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
655
|
+
}));
|
|
656
|
+
const localStorage = await page.evaluate(() => {
|
|
657
|
+
const out = {};
|
|
658
|
+
try {
|
|
659
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
660
|
+
const ls = globalThis.localStorage;
|
|
661
|
+
for (let i = 0; i < ls.length; i++) {
|
|
662
|
+
const key = ls.key(i);
|
|
663
|
+
if (key !== null)
|
|
664
|
+
out[key] = ls.getItem(key) ?? "";
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
// localStorage may be inaccessible (e.g. sandboxed/about:blank) — best-effort.
|
|
669
|
+
}
|
|
670
|
+
return out;
|
|
671
|
+
});
|
|
672
|
+
return {
|
|
673
|
+
cookies: (cookies ?? []).map((c) => ({
|
|
674
|
+
name: c.name,
|
|
675
|
+
value: c.value,
|
|
676
|
+
domain: c.domain,
|
|
677
|
+
path: c.path,
|
|
678
|
+
expires: c.expires,
|
|
679
|
+
httpOnly: c.httpOnly,
|
|
680
|
+
secure: c.secure,
|
|
681
|
+
sameSite: c.sameSite,
|
|
682
|
+
})),
|
|
683
|
+
localStorage,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
_guardViewport(page) {
|
|
687
|
+
// Guardrail ①: Glubean owns the viewport via Emulation on this session.
|
|
688
|
+
// Block page.setViewport() so it can't clobber the override last-wins.
|
|
689
|
+
const restore = guardPageMethod(page, "setViewport", "Glubean owns the viewport on this page (via the `emulate.viewport` " +
|
|
690
|
+
"option): do not call page.setViewport() — the two overrides race " +
|
|
691
|
+
"last-wins. Configure the viewport through browser({ emulate }) instead.", () => true);
|
|
692
|
+
this._cleanups.push(restore);
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Remove all listeners/overrides, restore guarded page methods, and detach
|
|
696
|
+
* the CDP session. Idempotent and best-effort — safe to call after the page
|
|
697
|
+
* has closed.
|
|
698
|
+
*/
|
|
699
|
+
async detach() {
|
|
700
|
+
if (this._detached)
|
|
701
|
+
return;
|
|
702
|
+
this._detached = true;
|
|
703
|
+
// Run cleanups in reverse (LIFO) so guards/overrides unwind in order.
|
|
704
|
+
for (const cleanup of this._cleanups.reverse()) {
|
|
705
|
+
try {
|
|
706
|
+
await cleanup();
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
// best-effort — page/session may already be gone
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
this._cleanups.length = 0;
|
|
713
|
+
try {
|
|
714
|
+
await this._cdp.detach();
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// session may already be closed
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
//# sourceMappingURL=evidence.js.map
|