@ultimat3/scraping 23.0.0 → 24.0.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/README.md CHANGED
@@ -313,8 +313,9 @@ bound threw away, so a count taken from one is never quietly a floor.
313
313
  All three are **redacted by value** on the way out, the same pass `page.html()` makes: a login
314
314
  endpoint fetched with the password in its query string, or a site that logs the credential it
315
315
  rejected, would otherwise put the value in `page.network()`/`page.console()` verbatim and from
316
- there into the stored failure artifact. The HTTP leg's `X_SCRAPE_HTTP_FAILED` cause is redacted
317
- too, at its throw site. Values shorter than `MIN_REDACTABLE_LENGTH` are not — a 3-character PIN is
316
+ there into the stored failure artifact — percent-encoded, form-encoded or HTML-escaped as much as
317
+ raw, since a password in a query string is never the raw string. The HTTP leg's
318
+ `X_SCRAPE_HTTP_FAILED` cause is redacted too, at its throw site. Values shorter than `MIN_REDACTABLE_LENGTH` are not — a 3-character PIN is
318
319
  a substring of ordinary prose — and pixels never can be, which is why a typed secret TAINTS the
319
320
  page and `screenshot()`/`pdf()` are refused outright.
320
321
 
@@ -362,7 +363,7 @@ on a machine with no browser — the case CI is.
362
363
  | `driver-cdp.ts` / `cdp-*.ts` | the real browser, over a structural CDP port |
363
364
  | `driver-fake.ts` / `driver-recorded.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
364
365
  | `http.ts` / `http-recorded.ts` | the second transport, live and replayed |
365
- | `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions. A stored session is sealed |
366
+ | `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions. A stored session is sealed. `burnSession(plan, seen)` takes `recordVersion(record)` of the record the run used, so a record another run saved since is not burned |
366
367
  | `event-prompt.ts` | `eventPrompt()` and `answerPrompt()`: a prompt answered from another process, over the job event bus, with the browser open |
367
368
  | `cdp-resolver.ts` | a browser rented per session: acquired once, released once |
368
369
  | `usage.ts` | what one run used, counted where the work happens |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "23.0.0",
3
+ "version": "24.0.0",
4
4
  "description": "Browser automation as a job: scrape() returns a JobHandle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "23.0.0",
35
- "@ultimat3/jobs": "23.0.0",
36
- "@ultimat3/schema": "23.0.0",
37
- "@ultimat3/storage": "23.0.0"
34
+ "@ultimat3/core": "24.0.0",
35
+ "@ultimat3/jobs": "24.0.0",
36
+ "@ultimat3/schema": "24.0.0",
37
+ "@ultimat3/storage": "24.0.0"
38
38
  }
39
39
  }
package/src/auth.ts CHANGED
@@ -23,7 +23,12 @@ import type { ScrapeClock } from './clock';
23
23
  import { authFailed, promptUnanswered, sessionExpired } from './error-throws';
24
24
  import type { ScrapePage } from './page';
25
25
  import type { ScrapeSecrets } from './secrets';
26
- import { type ScrapeSessionStore, type SessionState, sessionDigest } from './session-state';
26
+ import {
27
+ recordVersion,
28
+ type ScrapeSessionStore,
29
+ type SessionState,
30
+ sessionDigest,
31
+ } from './session-state';
27
32
 
28
33
  export interface PromptRequest<I = unknown> {
29
34
  /**
@@ -167,10 +172,30 @@ export interface AuthPlanInput<I> {
167
172
  export async function restorableSession<I>(
168
173
  plan: AuthPlanInput<I>,
169
174
  ): Promise<SessionState | undefined> {
175
+ return (await observeSession(plan)).restorable;
176
+ }
177
+
178
+ /** What `observeSession` answers: the session to restore, and the version of what was found. */
179
+ export interface ObservedSession {
180
+ readonly restorable: SessionState | undefined;
181
+ /**
182
+ * `recordVersion` of the record found — restorable or NOT. A `reuse: false` run, or one whose
183
+ * record is past `maxAge`, still saw that record: a refused login must tombstone over it, and a
184
+ * run that compared against "nothing" let the rejected credential through again.
185
+ */
186
+ readonly seen: string | undefined;
187
+ }
188
+
189
+ /** `restorableSession`, plus the version of the record found, for the run's later writes. */
190
+ export async function observeSession<I>(plan: AuthPlanInput<I>): Promise<ObservedSession> {
170
191
  const store = plan.auth?.store;
171
- if (store === undefined) return undefined;
192
+ if (store === undefined) return { restorable: undefined, seen: undefined };
172
193
  const found = await store.load(plan.key);
173
- if (found === undefined) return undefined;
194
+ if (found === undefined) return { restorable: undefined, seen: undefined };
195
+ return { restorable: restorableFrom(plan, found), seen: recordVersion(found) };
196
+ }
197
+
198
+ function restorableFrom<I>(plan: AuthPlanInput<I>, found: SessionState): SessionState | undefined {
174
199
  // The tombstone is read BEFORE `reuse` is honoured. `reuse: false` says "do not restore this
175
200
  // session"; it does not say "present the rejected credential again", and reading it first meant
176
201
  // a `reuse: false` scrape walked a refused password back to the login form on every requeue.
@@ -194,13 +219,42 @@ export async function restorableSession<I>(
194
219
  return found;
195
220
  }
196
221
 
197
- /** Written down so the next attempt cannot reach the login form with the same credentials. */
198
- export async function markRefused<I>(plan: AuthPlanInput<I>): Promise<void> {
222
+ /**
223
+ * True when the stored record is still the one this run last saw — `seen` is the `recordVersion`
224
+ * it found or persisted, `undefined` when it found none. The store has no compare-and-set (nor has
225
+ * `StorageDriver` a conditional write to build one from), so a
226
+ * burn or a tombstone is a blind write: two runs on one key, and the one that failed destroyed the
227
+ * session the other had just persisted. Compared before the write, which narrows the race to the
228
+ * gap between this read and that write rather than the whole run. A record moved on is left alone.
229
+ */
230
+ async function stillSeen<I>(
231
+ plan: AuthPlanInput<I>,
232
+ store: ScrapeSessionStore,
233
+ seen: string | undefined,
234
+ step: 'session.burn' | 'session.refuse',
235
+ ): Promise<boolean> {
236
+ const current = await store.load(plan.key);
237
+ if (current === undefined || recordVersion(current) === seen) return true;
238
+ plan.logger.warn('scrape.session.superseded', { step });
239
+ return false;
240
+ }
241
+
242
+ /**
243
+ * Written down so the next attempt cannot reach the login form with the same credentials — unless
244
+ * another run saved a session after this one read the record: that login SUCCEEDED, so this
245
+ * refusal is older news than the record it would overwrite.
246
+ */
247
+ export async function markRefused<I>(
248
+ plan: AuthPlanInput<I>,
249
+ seen: string | undefined,
250
+ ): Promise<void> {
199
251
  const store = plan.auth?.store;
200
252
  if (store === undefined) return;
253
+ if (!(await stillSeen(plan, store, seen, 'session.refuse'))) return;
201
254
  await store.save({
202
255
  key: plan.key,
203
256
  savedAt: plan.clock.now().toISOString(),
257
+ version: crypto.randomUUID(),
204
258
  refusedAt: plan.clock.now().toISOString(),
205
259
  cookies: [],
206
260
  headers: {},
@@ -210,10 +264,19 @@ export async function markRefused<I>(plan: AuthPlanInput<I>): Promise<void> {
210
264
  });
211
265
  }
212
266
 
213
- /** New identity, from scratch. One call, because a flagged profile is unusable and must go. */
214
- export async function burnSession<I>(plan: AuthPlanInput<I>): Promise<void> {
215
- if (plan.auth?.store === undefined) return;
216
- await plan.auth.store.burn(plan.key);
267
+ /**
268
+ * New identity, from scratch. One call, because a flagged profile is unusable and must go — the
269
+ * profile THIS run used: `seen` is that record's `recordVersion` (`observeSession`'s, or the one
270
+ * `persistSession` answered), and a record another run saved since is not burned.
271
+ */
272
+ export async function burnSession<I>(
273
+ plan: AuthPlanInput<I>,
274
+ seen: string | undefined,
275
+ ): Promise<void> {
276
+ const store = plan.auth?.store;
277
+ if (store === undefined) return;
278
+ if (!(await stillSeen(plan, store, seen, 'session.burn'))) return;
279
+ await store.burn(plan.key);
217
280
  plan.logger.warn('scrape.session.burned', { burned: true });
218
281
  }
219
282
 
@@ -251,18 +314,31 @@ export async function ensureAuthenticated<I>(args: EnsureAuthInput<I>): Promise<
251
314
  return false;
252
315
  }
253
316
  args.logger.info('scrape.session.expired', { reused: false });
254
- await burnSession(args);
317
+ await burnSession(args, recordVersion(args.restored));
255
318
  }
256
319
  if (auth.login === undefined) throw sessionExpired(args.scrape, args.key);
257
320
  await auth.login(context);
258
321
  return true;
259
322
  }
260
323
 
261
- /** After a login: keep what it produced. A 2FA code somebody typed is worth exactly this. */
262
- export async function persistSession<I>(plan: AuthPlanInput<I>, page: ScrapePage): Promise<void> {
324
+ /**
325
+ * After a login: keep what it produced. A 2FA code somebody typed is worth exactly this. Answers
326
+ * the `version` it wrote — the record this run now owns, for a later burn to compare against.
327
+ */
328
+ export async function persistSession<I>(
329
+ plan: AuthPlanInput<I>,
330
+ page: ScrapePage,
331
+ ): Promise<string | undefined> {
263
332
  const store = plan.auth?.store;
264
- if (store === undefined) return;
333
+ if (store === undefined) return undefined;
265
334
  const snapshot = await page.session();
266
- await store.save({ ...snapshot, key: plan.key, savedAt: plan.clock.now().toISOString() });
335
+ const version = crypto.randomUUID();
336
+ await store.save({
337
+ ...snapshot,
338
+ key: plan.key,
339
+ savedAt: plan.clock.now().toISOString(),
340
+ version,
341
+ });
267
342
  plan.logger.info('scrape.session.saved', sessionDigest(snapshot));
343
+ return version;
268
344
  }
package/src/cdp-arm.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // three verbs `press`/`focus`/`accessibility` did not fit under it. The extraction is VERBATIM —
5
5
  // the request, console, `pageerror` and `error` handlers are the same code, one file over.
6
6
 
7
- import type { CdpPageLike, CdpRequestLike } from './cdp-port';
7
+ import type { CdpBrowserLike, CdpPageLike, CdpRequestLike } from './cdp-port';
8
8
  import type { ScrapeClock } from './clock';
9
9
  import type { InterceptRules } from './intercept';
10
10
  import { interceptVerdict, refusalEntry } from './intercept';
@@ -174,3 +174,67 @@ export async function arm(init: CdpArmInit, sinks: CdpSinks): Promise<void> {
174
174
  crashed.value = readStringFrom(payload, 'message') ?? 'renderer crashed';
175
175
  });
176
176
  }
177
+
178
+ /** A `Fetch.requestPaused` payload, read structurally — the fields the verdict needs, or nothing. */
179
+ interface PausedRequest {
180
+ readonly requestId: string;
181
+ readonly resourceType: string;
182
+ readonly url: string;
183
+ readonly method: string;
184
+ }
185
+
186
+ const asPaused = (payload: unknown): PausedRequest | undefined => {
187
+ if (typeof payload !== 'object' || payload === null) return undefined;
188
+ const event = payload as Record<string, unknown>;
189
+ const request = event['request'];
190
+ if (typeof request !== 'object' || request === null) return undefined;
191
+ const { url, method } = request as Record<string, unknown>;
192
+ const { requestId, resourceType } = event;
193
+ if (typeof requestId !== 'string' || typeof url !== 'string') return undefined;
194
+ return {
195
+ requestId,
196
+ url,
197
+ resourceType: typeof resourceType === 'string' ? resourceType : 'Other',
198
+ method: typeof method === 'string' ? method : 'GET',
199
+ };
200
+ };
201
+
202
+ /**
203
+ * `allowHosts` and `block` on EVERY target the browser opens. Page-level interception (`arm`)
204
+ * covers the one page this package created; a popup — `window.open`, a `target=_blank` link — is
205
+ * a new target it never sees, and on Chrome 150 a scraped page's `window.open` to an off-list host
206
+ * reached that host. Browser-level `Fetch` pauses those requests too, and the same
207
+ * `interceptVerdict` decides them. The page's own requests are decided at the page first, so an
208
+ * allowed one is continued here as well and a refused one never arrives: one ring entry each.
209
+ *
210
+ * Enabled BEFORE the first navigation; a browser that refuses `Fetch.enable` rejects here, and
211
+ * `driver-cdp.ts`'s `opened()` closes it — a session whose rule would hold on one tab is refused.
212
+ */
213
+ export async function armBrowser(
214
+ init: {
215
+ readonly browser: CdpBrowserLike;
216
+ readonly rules: InterceptRules;
217
+ readonly clock: ScrapeClock;
218
+ },
219
+ network: NetworkRing,
220
+ ): Promise<void> {
221
+ const session = await init.browser.target().createCDPSession();
222
+ session.on('Fetch.requestPaused', (payload) => {
223
+ const paused = asPaused(payload);
224
+ if (paused === undefined) return;
225
+ const type = asResourceType(paused.resourceType.toLowerCase());
226
+ const verdict = interceptVerdict(paused.url, type, init.rules);
227
+ const requestId = { requestId: paused.requestId };
228
+ // Caught, never floated, for `arm`'s reason: the target may be gone by the time this answers.
229
+ if (verdict === 'allow') {
230
+ session.send('Fetch.continueRequest', requestId).catch(() => undefined);
231
+ return;
232
+ }
233
+ const at = init.clock.now().getTime();
234
+ network.push(refusalEntry(paused.url, type, verdict, at, paused.method));
235
+ session
236
+ .send('Fetch.failRequest', { ...requestId, errorReason: 'BlockedByClient' })
237
+ .catch(() => undefined);
238
+ });
239
+ await session.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
240
+ }
@@ -0,0 +1,67 @@
1
+ // The browser TARGET of `cdp-fake.ts`'s browser: one raw session that records what the driver
2
+ // enabled and answers `Fetch.requestPaused` the way Chrome does — a request from a target this
3
+ // package never created (a popup) pauses here and nowhere else. Split out because `cdp-fake.ts`
4
+ // stands near its size ceiling.
5
+
6
+ import type { CdpBrowserSessionLike, CdpBrowserTargetLike } from './cdp-port';
7
+
8
+ export interface FakeBrowserTarget {
9
+ readonly target: CdpBrowserTargetLike;
10
+ /** A request from another target — a popup — pausing at the browser-level `Fetch` domain. */
11
+ emitTargetRequest(url: string, resourceType: string, method?: string): void;
12
+ /** What browser-level interception decided, and whether it was enabled at all. */
13
+ readonly browserFetch: {
14
+ readonly enabled: boolean;
15
+ readonly continued: readonly string[];
16
+ readonly failed: readonly string[];
17
+ };
18
+ }
19
+
20
+ /** `refuseEnable`: the browser answers `Fetch.enable` with a protocol error, as a locked-down provider might. */
21
+ export function fakeBrowserTarget(
22
+ options: { readonly refuseEnable?: boolean } = {},
23
+ ): FakeBrowserTarget {
24
+ const listeners: ((payload: unknown) => void)[] = [];
25
+ const byId = new Map<string, string>();
26
+ const continued: string[] = [];
27
+ const failed: string[] = [];
28
+ let enabled = false;
29
+ const session: CdpBrowserSessionLike = {
30
+ send: (method, params) => {
31
+ if (method === 'Fetch.enable') {
32
+ if (options.refuseEnable === true) {
33
+ return Promise.reject(new Error("'Fetch.enable' wasn't found"));
34
+ }
35
+ enabled = true;
36
+ }
37
+ const id = typeof params?.['requestId'] === 'string' ? params['requestId'] : '';
38
+ if (method === 'Fetch.continueRequest') continued.push(byId.get(id) ?? id);
39
+ if (method === 'Fetch.failRequest') failed.push(byId.get(id) ?? id);
40
+ return Promise.resolve({});
41
+ },
42
+ detach: () => Promise.resolve(),
43
+ on: (event, handler) => {
44
+ if (event === 'Fetch.requestPaused') listeners.push(handler);
45
+ return undefined;
46
+ },
47
+ };
48
+ return {
49
+ target: { createCDPSession: () => Promise.resolve(session) },
50
+ emitTargetRequest(url, resourceType, method = 'GET'): void {
51
+ // Paused only while enabled, as in Chrome: before `Fetch.enable` the request just leaves.
52
+ if (!enabled) return;
53
+ const requestId = `interception-${String(byId.size + 1)}`;
54
+ byId.set(requestId, url);
55
+ for (const listener of listeners) {
56
+ listener({ requestId, resourceType, request: { url, method } });
57
+ }
58
+ },
59
+ browserFetch: {
60
+ get enabled(): boolean {
61
+ return enabled;
62
+ },
63
+ continued,
64
+ failed,
65
+ },
66
+ };
67
+ }
package/src/cdp-fake.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  //
9
9
  // Precedent: `packages/storage/src/driver-s3-fixture.ts` ships the same way.
10
10
 
11
+ import type { FakeBrowserTarget } from './cdp-fake-target';
12
+ import { fakeBrowserTarget } from './cdp-fake-target';
11
13
  import type {
12
14
  CdpBrowserLike,
13
15
  CdpFrameLike,
@@ -72,7 +74,9 @@ export interface FakeCdpPageInit {
72
74
 
73
75
  type Handlers = Map<string, ((payload: unknown) => void)[]>;
74
76
 
75
- export interface FakeCdpBrowser extends CdpBrowserLike {
77
+ export interface FakeCdpBrowser
78
+ extends CdpBrowserLike,
79
+ Pick<FakeBrowserTarget, 'emitTargetRequest' | 'browserFetch'> {
76
80
  /** Fire a request event, as a real browser would when the page fetches a subresource. */
77
81
  emitRequest(url: string, resourceType: string): void;
78
82
  /**
@@ -158,6 +162,7 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
158
162
  const prepared: string[] = [];
159
163
  const sessions = { created: 0, detached: 0 };
160
164
  const axBySelector = init.accessibility ?? {};
165
+ const browserTarget = fakeBrowserTarget();
161
166
 
162
167
  /**
163
168
  * A raw session answering the four commands `cdp-a11y.ts` sends, in CDP's own wire shapes —
@@ -364,6 +369,9 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
364
369
 
365
370
  return {
366
371
  newPage: () => Promise.resolve(page),
372
+ target: () => browserTarget.target,
373
+ emitTargetRequest: (...args) => browserTarget.emitTargetRequest(...args),
374
+ browserFetch: browserTarget.browserFetch,
367
375
  cookies: () => Promise.resolve(cookies),
368
376
  setCookie: (...next: readonly unknown[]) => {
369
377
  cookies = next as readonly ScrapeCookie[];
package/src/cdp-port.ts CHANGED
@@ -167,8 +167,29 @@ export interface CdpFrameLike {
167
167
  select(selector: string, ...values: string[]): Promise<string[]>;
168
168
  }
169
169
 
170
+ /**
171
+ * A raw session on the BROWSER target — `browser.target().createCDPSession()` under puppeteer —
172
+ * with the event half `CdpSessionLike` does not need: `Fetch.requestPaused` arrives as an event.
173
+ * `event` stays a bare `string` for `CdpPageLike.on`'s reason.
174
+ */
175
+ export interface CdpBrowserSessionLike extends CdpSessionLike {
176
+ on(event: string, handler: (payload: unknown) => void): unknown;
177
+ }
178
+
179
+ /** The browser's own target — `browser.target()` under puppeteer. */
180
+ export interface CdpBrowserTargetLike {
181
+ createCDPSession(): Promise<CdpBrowserSessionLike>;
182
+ }
183
+
170
184
  export interface CdpBrowserLike {
171
185
  newPage(): Promise<CdpPageLike>;
186
+ /**
187
+ * REQUIRED, unlike the page's optional verbs: it is how `allowHosts` reaches every target the
188
+ * browser opens — a popup, a `target=_blank` link — and not only the one page this package
189
+ * created. Page-level interception never sees those, and a rule that holds on one tab of a
190
+ * browser a scraped site controls is advisory. A launcher without it does not compile.
191
+ */
192
+ target(): CdpBrowserTargetLike;
172
193
  /** Present on a browser that owns a cookie jar. Absent ones answer `X_NOT_IMPLEMENTED`. */
173
194
  cookies?(): Promise<unknown>;
174
195
  setCookie?(...cookies: readonly unknown[]): Promise<void>;
package/src/cdp-target.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  // `ScrapeTarget` over a real browser, through the structural CDP port. Everything driver-specific
2
2
  // in this package lives here and in `driver-cdp.ts`; the vocabulary above it does not change.
3
3
 
4
- import { isUltimateError } from '@ultimat3/core';
4
+ import { isUltimateError, NotImplementedError } from '@ultimat3/core';
5
5
  import type { StandardSchemaV1 } from '@ultimat3/schema';
6
6
  import { parse, t } from '@ultimat3/schema';
7
7
  import { browserRecord } from './browser-record';
8
8
  import { axNodesFor } from './cdp-a11y';
9
9
  import type { CdpArmInit } from './cdp-arm';
10
- import { arm } from './cdp-arm';
10
+ import { arm, armBrowser } from './cdp-arm';
11
11
  import type { CdpBrowserLike, CdpFrameLike } from './cdp-port';
12
12
  import {
13
13
  clearExpression,
@@ -16,7 +16,7 @@ import {
16
16
  snapshotExpression,
17
17
  } from './cdp-snapshot';
18
18
  import { type ColorScheme, colorSchemeFeatures } from './color-scheme';
19
- import { browserUnreachable, pageCrashed, scrapeNotImplemented } from './error-throws';
19
+ import { browserUnreachable, pageCrashed } from './error-throws';
20
20
  import { parseKeyChord } from './key-chord';
21
21
  import type { ConsoleLine, NetworkEntry, PageError } from './rings';
22
22
  import { createRing } from './rings';
@@ -54,7 +54,7 @@ const cookieSchema = t.array(
54
54
  *
55
55
  * | Code | Why a retry cannot change it |
56
56
  * |---|---|
57
- * | `X_NOT_IMPLEMENTED` | a fact about the launcher's own shape. A browser cannot produce it; only this file's `scrapeNotImplemented()` can, and the method is still missing on attempt five |
57
+ * | `X_NOT_IMPLEMENTED` | a fact about the launcher's own shape. A browser cannot produce it; only this file's own `NotImplementedError` refusals can, and the method is still missing on attempt five |
58
58
  * | `X_VALIDATION_FAILED` | the browser ANSWERED, and the answer did not match the shape this driver reads it with. The page is what it is; attempt five reads the same DOM |
59
59
  *
60
60
  * Both were re-labelled `X_SCRAPE_BROWSER_UNREACHABLE`, which `errors.ts` registers `retryable`,
@@ -99,6 +99,7 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
99
99
  const pageErrors = createRing<PageError>();
100
100
  const crashed: { value: string | undefined } = { value: undefined };
101
101
  await arm(init, { network, console: console_, pageErrors, crashed });
102
+ await armBrowser(init, network);
102
103
  let pendingStorage: SessionSnapshot | undefined;
103
104
 
104
105
  const originOf = (url: string): string => {
@@ -183,10 +184,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
183
184
  // rather than answered from the PARENT document, which is what the spread alone would do.
184
185
  accessibility: (_selector, _max): Promise<readonly AxNode[]> =>
185
186
  Promise.reject(
186
- scrapeNotImplemented(
187
- 'accessibility() on a frame of the puppeteer driver',
188
- 'read the accessibility tree through the page — page.accessibility(selector) — which covers the top-level document only',
189
- ),
187
+ new NotImplementedError({
188
+ cause:
189
+ 'accessibility() on a frame of the puppeteer driver is not implemented by this driver',
190
+ fix: 'read the accessibility tree through the page — page.accessibility(selector) — which covers the top-level document only',
191
+ }),
190
192
  ),
191
193
  });
192
194
 
@@ -228,10 +230,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
228
230
  await guard('press', async () => {
229
231
  const keyboard = init.page.keyboard;
230
232
  if (keyboard === undefined) {
231
- throw scrapeNotImplemented(
232
- 'press() on a CDP page with no keyboard',
233
- 'upgrade the launcher to a puppeteer-core that exposes page.keyboard, or dispatch the key from the app under test instead of the browser',
234
- );
233
+ throw new NotImplementedError({
234
+ cause: 'press() on a CDP page with no keyboard is not implemented by this driver',
235
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.keyboard, or dispatch the key from the app under test instead of the browser',
236
+ });
235
237
  }
236
238
  // Down in the order written, the key, then up in REVERSE — the order a hand releases
237
239
  // them, and the order the browser's own `press` with modifiers performs. `.call`-free:
@@ -248,10 +250,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
248
250
  guard('focus', async () => {
249
251
  const focus = init.page.focus;
250
252
  if (typeof focus !== 'function') {
251
- throw scrapeNotImplemented(
252
- 'focus() on a CDP page with no focus() method',
253
- 'upgrade the launcher to a puppeteer-core that exposes page.focus(), or click the element instead',
254
- );
253
+ throw new NotImplementedError({
254
+ cause: 'focus() on a CDP page with no focus() method is not implemented by this driver',
255
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.focus(), or click the element instead',
256
+ });
255
257
  }
256
258
  // `.call`, for `setColorScheme`'s reason: the member is read off the object.
257
259
  await focus.call(init.page, selector);
@@ -260,10 +262,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
260
262
  guard('accessibility', async () => {
261
263
  const createSession = init.page.createCDPSession;
262
264
  if (typeof createSession !== 'function') {
263
- throw scrapeNotImplemented(
264
- 'accessibility() on a CDP page with no createCDPSession() method',
265
- 'upgrade the launcher to a puppeteer-core that exposes page.createCDPSession(), or assert on the markup with page.query() — which reads attributes, not what the browser computed',
266
- );
265
+ throw new NotImplementedError({
266
+ cause:
267
+ 'accessibility() on a CDP page with no createCDPSession() method is not implemented by this driver',
268
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.createCDPSession(), or assert on the markup with page.query() — which reads attributes, not what the browser computed',
269
+ });
267
270
  }
268
271
  const session = await createSession.call(init.page);
269
272
  try {
@@ -276,10 +279,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
276
279
  guard('setOfflineMode', async () => {
277
280
  const source = init.page as { setOfflineMode?: (value: boolean) => Promise<void> };
278
281
  if (typeof source.setOfflineMode !== 'function') {
279
- throw scrapeNotImplemented(
280
- 'setOfflineMode() on a CDP page with no setOfflineMode() method',
281
- 'upgrade the launcher to a puppeteer-core that exposes page.setOfflineMode(), or drive the condition from the app under test instead of the browser',
282
- );
282
+ throw new NotImplementedError({
283
+ cause:
284
+ 'setOfflineMode() on a CDP page with no setOfflineMode() method is not implemented by this driver',
285
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.setOfflineMode(), or drive the condition from the app under test instead of the browser',
286
+ });
283
287
  }
284
288
  await source.setOfflineMode(enabled);
285
289
  }),
@@ -291,10 +295,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
291
295
  // older spelling.
292
296
  const emulate = init.page.emulateMediaFeatures;
293
297
  if (typeof emulate !== 'function') {
294
- throw scrapeNotImplemented(
295
- 'setColorScheme() on a CDP page with no emulateMediaFeatures() method',
296
- 'upgrade the launcher to a puppeteer-core that exposes page.emulateMediaFeatures(), or have the page under test set its own theme',
297
- );
298
+ throw new NotImplementedError({
299
+ cause:
300
+ 'setColorScheme() on a CDP page with no emulateMediaFeatures() method is not implemented by this driver',
301
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.emulateMediaFeatures(), or have the page under test set its own theme',
302
+ });
298
303
  }
299
304
  // `.call`, because the member is read off the object: an unbound method loses `this` and
300
305
  // puppeteer's own `Page` needs it.
@@ -305,10 +310,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
305
310
  // Read off the object and called through it, for `setColorScheme`'s reason.
306
311
  const evaluateOnNewDocument = init.page.evaluateOnNewDocument;
307
312
  if (typeof evaluateOnNewDocument !== 'function') {
308
- throw scrapeNotImplemented(
309
- 'prepare() on a CDP page with no evaluateOnNewDocument() method',
310
- 'upgrade the launcher to a puppeteer-core that exposes page.evaluateOnNewDocument(), or seed the state from the app under test instead of the browser',
311
- );
313
+ throw new NotImplementedError({
314
+ cause:
315
+ 'prepare() on a CDP page with no evaluateOnNewDocument() method is not implemented by this driver',
316
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.evaluateOnNewDocument(), or seed the state from the app under test instead of the browser',
317
+ });
312
318
  }
313
319
  await evaluateOnNewDocument.call(init.page, expression);
314
320
  }),
@@ -333,10 +339,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
333
339
  guard('cookies', async () => {
334
340
  const source = init.browser as { cookies?: () => Promise<unknown> };
335
341
  if (typeof source.cookies !== 'function') {
336
- throw scrapeNotImplemented(
337
- 'cookies() on a CDP browser with no cookies() method',
338
- 'upgrade the launcher to a puppeteer-core that exposes browser.cookies(), or read them with page.evaluate("document.cookie")',
339
- );
342
+ throw new NotImplementedError({
343
+ cause:
344
+ 'cookies() on a CDP browser with no cookies() method is not implemented by this driver',
345
+ fix: 'upgrade the launcher to a puppeteer-core that exposes browser.cookies(), or read them with page.evaluate("document.cookie")',
346
+ });
340
347
  }
341
348
  return parse(cookieSchema, await source.cookies());
342
349
  }),
@@ -349,10 +356,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
349
356
  // `try` — a synchronous `throw` jumps over the first of those entirely.
350
357
  download: (_options): Promise<ScrapeDownloadFile> =>
351
358
  Promise.reject(
352
- scrapeNotImplemented(
353
- 'download() on the puppeteer driver',
354
- 'fetch the file inside the page — page.evaluate("fetch(url).then(r => r.text())") — or run this scrape on fixtureBrowser(), whose download() is complete',
355
- ),
359
+ new NotImplementedError({
360
+ cause: 'download() on the puppeteer driver is not implemented by this driver',
361
+ fix: 'fetch the file inside the page — page.evaluate("fetch(url).then(r => r.text())") — or run this scrape on fixtureBrowser(), whose download() is complete',
362
+ }),
356
363
  ),
357
364
  frames: () =>
358
365
  guard('frames', () =>
@@ -378,10 +385,19 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
378
385
  } catch {
379
386
  origin = '';
380
387
  }
381
- const source = init.browser as { cookies?: () => Promise<unknown> };
388
+ // Refused, never `[]`: the HTTP leg reads its cookies from here on every request and
389
+ // `persistSession` stores what this answers, so an empty jar went out logged-out and was
390
+ // saved as the session — both silently, while every other missing port method refuses.
391
+ if (typeof init.browser.cookies !== 'function') {
392
+ throw new NotImplementedError({
393
+ cause:
394
+ 'session() on a CDP browser with no cookies() method is not implemented by this driver',
395
+ fix: 'upgrade the launcher to a puppeteer-core that exposes browser.cookies() — the HTTP leg and a persisted session both read the jar through it',
396
+ });
397
+ }
398
+ const jar = parse(cookieSchema, await init.browser.cookies());
382
399
  return {
383
- cookies:
384
- typeof source.cookies === 'function' ? parse(cookieSchema, await source.cookies()) : [],
400
+ cookies: jar,
385
401
  headers: {},
386
402
  // `browserRecord` and not `t.record()`: a page may legitimately hold a key called
387
403
  // `constructor`, and the schema refuses that name outright — see `browser-record.ts`.
@@ -394,11 +410,17 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
394
410
  guard('restore', async () => {
395
411
  // Two halves, because they belong to two different moments: cookies are the browser's and
396
412
  // can be put back now, storage is an ORIGIN's and cannot exist until one is loaded.
397
- const source = init.browser as {
398
- setCookie?: (...cookies: readonly unknown[]) => Promise<void>;
399
- };
400
- if (typeof source.setCookie === 'function' && session.cookies.length > 0) {
401
- await source.setCookie(...session.cookies);
413
+ if (session.cookies.length > 0) {
414
+ // Refused rather than skipped: a restore that dropped the cookies opened the session
415
+ // logged-out while the run believed it restored one.
416
+ if (typeof init.browser.setCookie !== 'function') {
417
+ throw new NotImplementedError({
418
+ cause:
419
+ 'restore() of cookies on a CDP browser with no setCookie() method is not implemented by this driver',
420
+ fix: 'upgrade the launcher to a puppeteer-core that exposes browser.setCookie(), or set auth: { reuse: false } on the scrape() definition so no session is restored',
421
+ });
422
+ }
423
+ await init.browser.setCookie(...session.cookies);
402
424
  }
403
425
  pendingStorage = Object.keys(session.storage).length > 0 ? session : undefined;
404
426
  await applyPendingStorage();