@ultimat3/scraping 22.15.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.
@@ -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
@@ -143,6 +143,16 @@ export interface CdpPageLike {
143
143
  * reason, and refused by name when absent.
144
144
  */
145
145
  createCDPSession?(): Promise<CdpSessionLike>;
146
+ /**
147
+ * `page.authenticate(credentials)`: answers the PROXY's own auth challenge. It is the only way a
148
+ * launched browser dials a credentialed exit — `--proxy-server` takes no userinfo, and one
149
+ * written there would sit in the process list. OPTIONAL for `keyboard`'s reason; a credentialed
150
+ * exit on a launcher without it is `X_SCRAPE_EGRESS_UNSUPPORTED`, never a direct connection.
151
+ */
152
+ authenticate?(credentials: {
153
+ readonly username: string;
154
+ readonly password: string;
155
+ }): Promise<void>;
146
156
  frames(): readonly CdpFrameLike[];
147
157
  close(): Promise<void>;
148
158
  }
@@ -157,8 +167,29 @@ export interface CdpFrameLike {
157
167
  select(selector: string, ...values: string[]): Promise<string[]>;
158
168
  }
159
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
+
160
184
  export interface CdpBrowserLike {
161
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;
162
193
  /** Present on a browser that owns a cookie jar. Absent ones answer `X_NOT_IMPLEMENTED`. */
163
194
  cookies?(): Promise<unknown>;
164
195
  setCookie?(...cookies: readonly unknown[]): Promise<void>;
@@ -0,0 +1,81 @@
1
+ // A browser RENTED per session: `remoteBrowser({ cdpUrl })` takes a function instead of a string,
2
+ // and what the function hands back is acquired once and released exactly once. The framework
3
+ // ships no vendor — a provider wrapper is about twenty lines of app code that returns this shape.
4
+
5
+ import { isUltimateError } from '@ultimat3/core';
6
+ import type { MoneyValue } from '@ultimat3/schema';
7
+ import { parse, t } from '@ultimat3/schema';
8
+ import { cdpResolveFailed } from './error-throws';
9
+
10
+ export interface CdpResolverRequest {
11
+ /** The scrape's name. */
12
+ readonly scrape: string;
13
+ /** The job run asking — what a provider session is tagged with. Absent outside a job. */
14
+ readonly runId: string | undefined;
15
+ /** The exit this run dials, when it declared one: rent the browser ON it. May carry credentials. */
16
+ readonly egress: string | undefined;
17
+ /** The run's cancellation. A rental still in flight when it aborts is the resolver's to cancel. */
18
+ readonly signal: AbortSignal | undefined;
19
+ }
20
+
21
+ export interface CdpResolution {
22
+ /** The connect URL. Usually carries the provider's access token: it is redacted, never printed. */
23
+ readonly cdpUrl: string;
24
+ /**
25
+ * Hand the browser back. Runs ONCE — in the session's `close()` on success, failure and abort,
26
+ * and straight away when the attach itself fails. A rejection is swallowed: it runs on the way
27
+ * out of a run whose own outcome is the one the reader needs.
28
+ */
29
+ release?(): Promise<void>;
30
+ /** What the rental cost, when the provider says so up front. `Money`, never a float. */
31
+ readonly cost?: MoneyValue | undefined;
32
+ }
33
+
34
+ export type CdpResolver = (request: CdpResolverRequest) => Promise<CdpResolution>;
35
+
36
+ export interface AcquiredCdp {
37
+ readonly cdpUrl: string;
38
+ readonly cost: MoneyValue | undefined;
39
+ /** The resolution's `release`, latched: the second call does nothing. Never throws. */
40
+ release(): Promise<void>;
41
+ }
42
+
43
+ /** A fixed URL and a resolver, as one shape — so the driver has one attach path, not two. */
44
+ export async function acquireCdp(
45
+ source: string | CdpResolver,
46
+ request: CdpResolverRequest,
47
+ ): Promise<AcquiredCdp> {
48
+ if (typeof source === 'string') {
49
+ return { cdpUrl: source, cost: undefined, release: () => Promise.resolve() };
50
+ }
51
+ let resolution: CdpResolution;
52
+ try {
53
+ resolution = await source(request);
54
+ } catch (thrown) {
55
+ // The resolver's own coded failure keeps its code and its classification; anything else is
56
+ // "no browser to attach to", which is this package's retryable attach failure.
57
+ throw isUltimateError(thrown) ? thrown : cdpResolveFailed(request.scrape, thrown);
58
+ }
59
+ let released = false;
60
+ const release = async (): Promise<void> => {
61
+ if (released) return;
62
+ released = true;
63
+ try {
64
+ await resolution.release?.();
65
+ } catch {
66
+ // See `CdpResolution.release`: a failed hand-back never replaces the run's own outcome.
67
+ }
68
+ };
69
+ try {
70
+ return {
71
+ cdpUrl: resolution.cdpUrl,
72
+ // Parsed, not trusted: the type says `minor: number` and `0.35` satisfies it. A float cost
73
+ // is refused here, before it is added to anything.
74
+ cost: resolution.cost === undefined ? undefined : parse(t.money, resolution.cost),
75
+ release,
76
+ };
77
+ } catch (thrown) {
78
+ await release();
79
+ throw thrown;
80
+ }
81
+ }
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();
package/src/clock.ts CHANGED
@@ -48,6 +48,40 @@ export const systemScrapeClock: ScrapeClock = Object.freeze({
48
48
  },
49
49
  });
50
50
 
51
+ /**
52
+ * The system clock with the waiting taken out: `now()` and `monotonic()` are real, and every
53
+ * `sleep` is ONE turn of the event loop whatever was asked for. For a test whose run waits on
54
+ * something that really happens in the same process — an answer the test publishes, a job another
55
+ * worker holds — where `testClock()`'s "sleeping is advancing" would run a five-minute budget out
56
+ * in microtasks before the test's next line. A deadline is still a real one.
57
+ */
58
+ export const noWaitClock: ScrapeClock = Object.freeze({
59
+ now: () => systemScrapeClock.now(),
60
+ monotonic: () => systemScrapeClock.monotonic(),
61
+ sleep: (_ms: number, signal?: AbortSignal) => systemScrapeClock.sleep(0, signal),
62
+ });
63
+
64
+ let ambient: ScrapeClock | undefined;
65
+
66
+ /**
67
+ * The clock every run in this process waits on, unless its definition pins one — the counterpart
68
+ * of `setScrapeDriver()`, and the seam a TEST takes: `setScrapeClock(noWaitClock)` in a
69
+ * `beforeEach`, and a declaration under test needs no `clock:` field written for the test's sake.
70
+ */
71
+ export function setScrapeClock(clock: ScrapeClock): void {
72
+ ambient = clock;
73
+ }
74
+
75
+ /** The process's scrape clock: the installed one, or the system's. */
76
+ export function scrapeClock(): ScrapeClock {
77
+ return ambient ?? systemScrapeClock;
78
+ }
79
+
80
+ /** Test seam: back to the system clock, so the next file runs on real time again. */
81
+ export function resetScrapeClock(): void {
82
+ ambient = undefined;
83
+ }
84
+
51
85
  /** The abort reason, unwrapped — a caller's `AbortSignal.reason` is whatever they put there. */
52
86
  export function throwIfAborted(signal: AbortSignal): void {
53
87
  if (signal.aborted) throw signal.reason;