@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.
- package/README.md +187 -14
- package/package.json +6 -5
- package/src/artifacts.ts +3 -2
- package/src/auth.ts +156 -22
- package/src/cdp-arm.ts +65 -1
- package/src/cdp-fake-target.ts +67 -0
- package/src/cdp-fake.ts +9 -1
- package/src/cdp-port.ts +31 -0
- package/src/cdp-resolver.ts +81 -0
- package/src/cdp-target.ts +70 -48
- package/src/clock.ts +34 -0
- package/src/driver-cdp.ts +175 -20
- package/src/driver.ts +26 -2
- package/src/error-throws-session.ts +111 -0
- package/src/error-throws.ts +40 -17
- package/src/errors.ts +18 -2
- package/src/event-prompt.ts +133 -0
- package/src/events.ts +6 -0
- package/src/expect.ts +21 -4
- package/src/html-target.ts +10 -15
- package/src/http-recorded.ts +7 -1
- package/src/http.ts +56 -11
- package/src/index.ts +43 -5
- package/src/launch-args.ts +51 -0
- package/src/offline-session.ts +6 -0
- package/src/page-over-target.ts +4 -0
- package/src/pinned-host.ts +65 -0
- package/src/recover.ts +3 -2
- package/src/robots-fetch.ts +79 -11
- package/src/scrape-run.ts +109 -21
- package/src/scrape.ts +77 -8
- package/src/secret-scan.ts +17 -0
- package/src/secrets.ts +86 -9
- package/src/session-state.ts +84 -10
- package/src/url-secrets.ts +139 -0
- package/src/usage.ts +101 -0
- /package/src/{driver-fixture.ts → driver-recorded.ts} +0 -0
|
@@ -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
|
|
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
|
|
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 `
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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;
|