@ultimat3/scraping 24.0.0 → 25.1.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/src/cdp-fake.ts DELETED
@@ -1,428 +0,0 @@
1
- // A CDP-shaped browser with no browser behind it — the seam `driver-parity.test.ts` needs to run
2
- // the SAME suite against the real driver's code path without Chrome.
3
- //
4
- // It exists for one reason, and it is the reason `mock.module('puppeteer-core')` is banned in this
5
- // package: `mock.module` replaces a module for the whole run, `bun test` does not fully serialise
6
- // test files, and a mock installed here has been observed leaking into a concurrently-running
7
- // file's assertions. An INJECTED launcher is a value; a value cannot leak.
8
- //
9
- // Precedent: `packages/storage/src/driver-s3-fixture.ts` ships the same way.
10
-
11
- import type { FakeBrowserTarget } from './cdp-fake-target';
12
- import { fakeBrowserTarget } from './cdp-fake-target';
13
- import type {
14
- CdpBrowserLike,
15
- CdpFrameLike,
16
- CdpLauncherLike,
17
- CdpPageLike,
18
- CdpSessionLike,
19
- } from './cdp-port';
20
- import { COLOR_SCHEME_FEATURE } from './color-scheme';
21
- import { queryHtml } from './html-query';
22
- import type { AxNode, ElementSnapshot, ScrapeCookie } from './target';
23
-
24
- /** The selector inside `snapshotExpression()`'s `document.querySelectorAll("…")`. */
25
- const selectorOf = (expression: string): string | undefined => {
26
- const match = /querySelectorAll\((".*?")\)/s.exec(expression);
27
- if (match?.[1] === undefined) return undefined;
28
- return JSON.parse(match[1]) as string;
29
- };
30
-
31
- /**
32
- * The selector inside `clearExpression()`'s `document.querySelector("…")`, READ rather than
33
- * imported. A clear is the one verb this port has no method for — it travels as an expression — so
34
- * a fake that recognised it by identity could not tell the page's document from a frame's, which
35
- * is precisely the divergence `driver-parity-frames.test.ts` needs it to expose.
36
- */
37
- const clearedSelectorOf = (expression: string): string | undefined => {
38
- if (!expression.includes(".value = ''")) return undefined;
39
- const match = /querySelector\((".*?")\)/s.exec(expression);
40
- if (match?.[1] === undefined) return undefined;
41
- return JSON.parse(match[1]) as string;
42
- };
43
-
44
- export interface FakeCdpFrameInit {
45
- readonly url: string;
46
- readonly html: string;
47
- }
48
-
49
- export interface FakeCdpPageInit {
50
- readonly url: string;
51
- readonly html: string;
52
- /** Selectors a click navigates from, and where to. */
53
- readonly routes?: Readonly<Record<string, { readonly url: string; readonly html: string }>>;
54
- /**
55
- * Child frames by NAME, each its own document. A frame with its own markup is what makes a
56
- * frame verb aimed at the parent document visible — with `frames(): []` the whole frame half of
57
- * `cdp-target.ts` was unreachable from every test in the package.
58
- */
59
- readonly frames?: Readonly<Record<string, FakeCdpFrameInit>>;
60
- readonly cookies?: readonly ScrapeCookie[];
61
- readonly storage?: Readonly<Record<string, string>>;
62
- readonly userAgent?: string;
63
- /** Selectors whose element is covered at its centre — what only a layout engine can see. */
64
- readonly covered?: readonly string[];
65
- /**
66
- * What the accessibility tree answers, BY SELECTOR — the nodes `Accessibility.getPartialAXTree`
67
- * would compute for each match, in document order. Canned rather than derived from the markup,
68
- * deliberately: a fake that read `role=` off the tag would be the offline driver's refusal
69
- * re-implemented as a lie, and the point of this session is to exercise `cdp-a11y.ts`'s four
70
- * protocol calls and its parse, not to compute a role.
71
- */
72
- readonly accessibility?: Readonly<Record<string, readonly AxNode[]>>;
73
- }
74
-
75
- type Handlers = Map<string, ((payload: unknown) => void)[]>;
76
-
77
- export interface FakeCdpBrowser
78
- extends CdpBrowserLike,
79
- Pick<FakeBrowserTarget, 'emitTargetRequest' | 'browserFetch'> {
80
- /** Fire a request event, as a real browser would when the page fetches a subresource. */
81
- emitRequest(url: string, resourceType: string): void;
82
- /**
83
- * Fire `pageerror` — the page threw and nothing caught it. `payload` is whatever the library
84
- * would hand a handler, `unknown` on purpose: a page can throw a string as easily as an `Error`,
85
- * and the target reads it defensively either way.
86
- */
87
- emitPageError(payload: unknown): void;
88
- readonly aborted: readonly string[];
89
- readonly closed: boolean;
90
- /** What `page.setOfflineMode()` last set, so a test asserts on the CONDITION, not on a call. */
91
- readonly offline: boolean;
92
- /**
93
- * What `page.emulateMediaFeatures()` last set `prefers-color-scheme` to, for `offline`'s reason
94
- * — the condition the page is now in, never the fact that a method was called. `null` until
95
- * something sets one, which is the launcher's own default and not a value this fake invents.
96
- */
97
- readonly colorScheme: string | null;
98
- /**
99
- * Every keyboard event, in order — `down Meta`, `press K`, `up Meta` — so a test asserts on the
100
- * SEQUENCE a chord became: a modifier released before the key, or never released, is the defect.
101
- */
102
- readonly pressed: readonly string[];
103
- /** Every selector `page.focus()` was handed, in order. */
104
- readonly focused: readonly string[];
105
- /**
106
- * Every expression `page.evaluateOnNewDocument()` was handed, in order — the scripts a real
107
- * browser would run ahead of each document's own. Recorded, never executed: this fake has no JS
108
- * engine, and a test asserts on WHAT was prepared and that it was prepared before `goto`.
109
- */
110
- readonly prepared: readonly string[];
111
- /** How many raw CDP sessions were created and how many detached — a leak is a difference. */
112
- readonly sessions: { readonly created: number; readonly detached: number };
113
- }
114
-
115
- /** A layout box every element gets, so the CDP path exercises the fields the fake target lacks. */
116
- const boxed = (snapshot: ElementSnapshot, covered: boolean): ElementSnapshot => ({
117
- ...snapshot,
118
- box: { x: 10, y: 10, width: 100, height: 20 },
119
- hitTarget: !covered,
120
- });
121
-
122
- /**
123
- * ONE document — the page's, or a frame's. A browser gives every browsing context its own DOM, so
124
- * the fake gives every one its own typed values: a verb that reaches the wrong document then shows
125
- * up as text in the wrong place rather than as nothing at all.
126
- */
127
- interface FakeDocument {
128
- html(): string;
129
- /** Typed text, keyed the way `keyOf` keys it offline: `#id` when there is one, else the selector. */
130
- readonly typed: Map<string, string>;
131
- }
132
-
133
- const keyFor = (selector: string, element: ElementSnapshot | undefined): string => {
134
- const id = element?.attrs['id'];
135
- return id === undefined ? selector : `#${id}`;
136
- };
137
-
138
- /** What the DOM would answer after a `type()`: the element, carrying whatever was typed into it. */
139
- const withTyped = (
140
- document: FakeDocument,
141
- selector: string,
142
- elements: readonly ElementSnapshot[],
143
- ): readonly ElementSnapshot[] =>
144
- elements.map((element) => {
145
- const typed = document.typed.get(keyFor(selector, element));
146
- return typed === undefined ? element : { ...element, value: typed };
147
- });
148
-
149
- export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
150
- const handlers: Handlers = new Map();
151
- const aborted: string[] = [];
152
- let url = init.url;
153
- let html = init.html;
154
- let closed = false;
155
- let offline = false;
156
- let colorScheme: string | null = null;
157
- const covered = new Set(init.covered ?? []);
158
- const storage: Record<string, string> = { ...init.storage };
159
- let cookies: readonly ScrapeCookie[] = init.cookies ?? [];
160
- const pressed: string[] = [];
161
- const focused: string[] = [];
162
- const prepared: string[] = [];
163
- const sessions = { created: 0, detached: 0 };
164
- const axBySelector = init.accessibility ?? {};
165
- const browserTarget = fakeBrowserTarget();
166
-
167
- /**
168
- * A raw session answering the four commands `cdp-a11y.ts` sends, in CDP's own wire shapes —
169
- * `{ value }` wrappers, a `properties` list — so the parse in that file is what is under test.
170
- * Node ids are minted per `querySelectorAll` and looked up on the way back, which is how the
171
- * real protocol addresses nodes too.
172
- */
173
- const cdpSession = (): CdpSessionLike => {
174
- const byNodeId = new Map<number, AxNode>();
175
- const wrap = (value: string | boolean | undefined): { value?: string | boolean } =>
176
- value === undefined ? {} : { value };
177
- return {
178
- send: (method: string, params?: Record<string, unknown>) => {
179
- if (method === 'DOM.getDocument') return Promise.resolve({ root: { nodeId: 1 } });
180
- if (method === 'DOM.querySelectorAll') {
181
- const selector = typeof params?.['selector'] === 'string' ? params['selector'] : '';
182
- const nodes = Object.hasOwn(axBySelector, selector) ? (axBySelector[selector] ?? []) : [];
183
- const nodeIds = nodes.map((node, index) => {
184
- const nodeId = 100 + byNodeId.size + index;
185
- byNodeId.set(nodeId, node);
186
- return nodeId;
187
- });
188
- return Promise.resolve({ nodeIds });
189
- }
190
- if (method === 'DOM.describeNode') {
191
- const nodeId = typeof params?.['nodeId'] === 'number' ? params['nodeId'] : 0;
192
- return Promise.resolve({ node: { backendNodeId: nodeId * 10 } });
193
- }
194
- if (method === 'Accessibility.getPartialAXTree') {
195
- const backend =
196
- typeof params?.['backendNodeId'] === 'number' ? params['backendNodeId'] : 0;
197
- const node = byNodeId.get(backend / 10);
198
- if (node === undefined) return Promise.resolve({ nodes: [] });
199
- const properties = [
200
- ...(node.focused === undefined ? [] : [{ name: 'focused', value: wrap(node.focused) }]),
201
- ...(node.disabled === undefined
202
- ? []
203
- : [{ name: 'disabled', value: wrap(node.disabled) }]),
204
- ];
205
- return Promise.resolve({
206
- nodes: [
207
- {
208
- nodeId: `ax-${String(backend)}`,
209
- ignored: node.ignored,
210
- role: { type: 'role', ...wrap(node.role) },
211
- name: { type: 'computedString', ...wrap(node.name) },
212
- ...(node.description === undefined
213
- ? {}
214
- : { description: { type: 'computedString', value: node.description } }),
215
- ...(node.value === undefined
216
- ? {}
217
- : { value: { type: 'string', value: node.value } }),
218
- properties,
219
- },
220
- ],
221
- });
222
- }
223
- return Promise.resolve({});
224
- },
225
- detach: () => {
226
- sessions.detached += 1;
227
- return Promise.resolve();
228
- },
229
- };
230
- };
231
-
232
- const documentOf = (read: () => string): FakeDocument => ({ html: read, typed: new Map() });
233
- const pageDocument = documentOf(() => html);
234
-
235
- /** Every expression this package sends, answered against the document it was sent to. */
236
- const evaluateIn = async (target: FakeDocument, expression: string): Promise<unknown> => {
237
- const selector = selectorOf(expression);
238
- if (selector !== undefined) {
239
- const found = withTyped(target, selector, await queryHtml(target.html(), selector));
240
- return JSON.stringify(found.map((element) => boxed(element, covered.has(selector))));
241
- }
242
- const cleared = clearedSelectorOf(expression);
243
- if (cleared !== undefined) {
244
- const [first] = await queryHtml(target.html(), cleared);
245
- target.typed.set(keyFor(cleared, first), '');
246
- return undefined;
247
- }
248
- return undefined;
249
- };
250
-
251
- const typeIn = async (target: FakeDocument, selector: string, text: string): Promise<void> => {
252
- const [first] = await queryHtml(target.html(), selector);
253
- const key = keyFor(selector, first);
254
- target.typed.set(key, `${target.typed.get(key) ?? first?.value ?? ''}${text}`);
255
- };
256
-
257
- const selectIn = async (target: FakeDocument, selector: string, value: string): Promise<void> => {
258
- const [first] = await queryHtml(target.html(), selector);
259
- target.typed.set(keyFor(selector, first), value);
260
- };
261
-
262
- const frames: readonly CdpFrameLike[] = Object.entries(init.frames ?? {}).map(([name, frame]) => {
263
- const frameDocument = documentOf(() => frame.html);
264
- return {
265
- name: () => name,
266
- url: () => frame.url,
267
- content: () => Promise.resolve(frame.html),
268
- evaluate: (expression: string) => evaluateIn(frameDocument, expression),
269
- click: () => Promise.resolve(),
270
- type: (selector: string, text: string) => typeIn(frameDocument, selector, text),
271
- select: async (selector: string, ...values: string[]): Promise<string[]> => {
272
- await selectIn(frameDocument, selector, values[0] ?? '');
273
- return [...values];
274
- },
275
- };
276
- });
277
-
278
- const page: CdpPageLike = {
279
- url: () => url,
280
- goto: (next: string) => {
281
- url = next;
282
- // A navigation is a new document, so what was typed into the old one is gone. Keeping it
283
- // would let a stale value answer a query on a page that never had it.
284
- pageDocument.typed.clear();
285
- return Promise.resolve(undefined);
286
- },
287
- content: () => Promise.resolve(html),
288
- async evaluate(expression: string): Promise<unknown> {
289
- if (expression.includes('localStorage')) {
290
- if (expression.includes('setItem')) return undefined;
291
- return JSON.stringify(storage);
292
- }
293
- if (expression.includes('navigator.userAgent')) return init.userAgent ?? 'fake-agent';
294
- return await evaluateIn(pageDocument, expression);
295
- },
296
- click: (selector: string) => {
297
- const route = init.routes?.[selector];
298
- if (route !== undefined) {
299
- url = route.url;
300
- html = route.html;
301
- pageDocument.typed.clear();
302
- }
303
- return Promise.resolve();
304
- },
305
- type: (selector: string, text: string) => typeIn(pageDocument, selector, text),
306
- select: async (selector: string, ...values: string[]): Promise<string[]> => {
307
- await selectIn(pageDocument, selector, values[0] ?? '');
308
- return [...values];
309
- },
310
- screenshot: () => Promise.resolve(new Uint8Array([1, 2, 3])),
311
- pdf: () => Promise.resolve(new Uint8Array([4, 5])),
312
- setRequestInterception: () => Promise.resolve(),
313
- keyboard: {
314
- down: (key: string) => {
315
- pressed.push(`down ${key}`);
316
- return Promise.resolve();
317
- },
318
- up: (key: string) => {
319
- pressed.push(`up ${key}`);
320
- return Promise.resolve();
321
- },
322
- press: (key: string) => {
323
- pressed.push(`press ${key}`);
324
- return Promise.resolve();
325
- },
326
- },
327
- focus: (selector: string) => {
328
- focused.push(selector);
329
- return Promise.resolve();
330
- },
331
- evaluateOnNewDocument: (expression: string) => {
332
- prepared.push(expression);
333
- return Promise.resolve(undefined);
334
- },
335
- createCDPSession: () => {
336
- sessions.created += 1;
337
- return Promise.resolve(cdpSession());
338
- },
339
- setOfflineMode: (enabled: boolean) => {
340
- offline = enabled;
341
- return Promise.resolve();
342
- },
343
- // CDP's own two rules, both of them. An EMPTY list is a RESET — measured on Chrome 150, after
344
- // `emulateMediaFeatures([])` the page answers exactly what an untouched one does — so it must
345
- // clear here rather than be ignored, or `'no-preference'` records the previous scheme forever.
346
- // And a non-empty list is read by NAME, never `features[0].value`: the port's shape is a list,
347
- // and a caller setting `prefers-reduced-motion` beside the scheme must not overwrite it.
348
- emulateMediaFeatures: (features: readonly { name: string; value: string }[]) => {
349
- if (features.length === 0) {
350
- colorScheme = null;
351
- return Promise.resolve();
352
- }
353
- const found = features.find((feature) => feature.name === COLOR_SCHEME_FEATURE);
354
- if (found !== undefined) colorScheme = found.value;
355
- return Promise.resolve();
356
- },
357
- on: (event: string, handler: (payload: unknown) => void) => {
358
- const listeners = handlers.get(event) ?? [];
359
- listeners.push(handler);
360
- handlers.set(event, listeners);
361
- return undefined;
362
- },
363
- frames: (): readonly CdpFrameLike[] => frames,
364
- close: () => {
365
- closed = true;
366
- return Promise.resolve();
367
- },
368
- };
369
-
370
- return {
371
- newPage: () => Promise.resolve(page),
372
- target: () => browserTarget.target,
373
- emitTargetRequest: (...args) => browserTarget.emitTargetRequest(...args),
374
- browserFetch: browserTarget.browserFetch,
375
- cookies: () => Promise.resolve(cookies),
376
- setCookie: (...next: readonly unknown[]) => {
377
- cookies = next as readonly ScrapeCookie[];
378
- return Promise.resolve();
379
- },
380
- close: () => {
381
- closed = true;
382
- return Promise.resolve();
383
- },
384
- process: () => null,
385
- emitRequest(requestUrl: string, resourceType: string): void {
386
- for (const handler of handlers.get('request') ?? []) {
387
- handler({
388
- url: () => requestUrl,
389
- resourceType: () => resourceType,
390
- abort: () => {
391
- aborted.push(requestUrl);
392
- return Promise.resolve();
393
- },
394
- continue: () => Promise.resolve(),
395
- });
396
- }
397
- },
398
- emitPageError(payload: unknown): void {
399
- for (const handler of handlers.get('pageerror') ?? []) handler(payload);
400
- },
401
- aborted,
402
- get closed(): boolean {
403
- return closed;
404
- },
405
- get offline(): boolean {
406
- return offline;
407
- },
408
- get colorScheme(): string | null {
409
- return colorScheme;
410
- },
411
- pressed,
412
- focused,
413
- prepared,
414
- sessions,
415
- };
416
- }
417
-
418
- /** A launcher over `fakeCdpBrowser`, for `localBrowser({ launcher })` in a test. */
419
- export function fakeCdpLauncher(init: FakeCdpPageInit): CdpLauncherLike & {
420
- readonly browser: FakeCdpBrowser;
421
- } {
422
- const browser = fakeCdpBrowser(init);
423
- return {
424
- browser,
425
- launch: () => Promise.resolve(browser),
426
- connect: () => Promise.resolve(browser),
427
- };
428
- }
package/src/hosts.ts DELETED
@@ -1,4 +0,0 @@
1
- // `allowHosts` matching is `@ultimat3/core`'s (`host-rules.ts`), shared with `x shot`'s browser;
2
- // re-exported here so this package's own modules and its public names are unchanged.
3
- export type { HostDecision, HostRule } from '@ultimat3/core';
4
- export { ANY_HOST, hostDecision, hostMatches } from '@ultimat3/core';