@ultimat3/scraping 20.1.6 → 20.2.1
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/package.json +5 -5
- package/src/cdp-a11y.ts +125 -0
- package/src/cdp-arm.ts +173 -0
- package/src/cdp-fake.ts +131 -2
- package/src/cdp-port.ts +46 -0
- package/src/cdp-snapshot.ts +11 -0
- package/src/cdp-target.ts +99 -166
- package/src/error-throws.ts +14 -0
- package/src/errors.ts +6 -0
- package/src/html-target.ts +49 -0
- package/src/index.ts +15 -1
- package/src/key-chord.ts +60 -0
- package/src/page-over-target.ts +30 -1
- package/src/page.ts +42 -1
- package/src/target.ts +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/scraping",
|
|
3
|
-
"version": "20.1
|
|
3
|
+
"version": "20.2.1",
|
|
4
4
|
"description": "Browser automation as a job: scrape() returns a JobHandle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"test": "bun test"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "20.1
|
|
34
|
-
"@ultimat3/jobs": "20.1
|
|
35
|
-
"@ultimat3/schema": "20.1
|
|
36
|
-
"@ultimat3/storage": "20.1
|
|
33
|
+
"@ultimat3/core": "20.2.1",
|
|
34
|
+
"@ultimat3/jobs": "20.2.1",
|
|
35
|
+
"@ultimat3/schema": "20.2.1",
|
|
36
|
+
"@ultimat3/storage": "20.2.1"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/cdp-a11y.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The accessibility tree, read over a raw CDP session — the one read in this package the browser
|
|
2
|
+
// library has no page method for, and the one no HTML parse can reproduce: `role` and `name` here
|
|
3
|
+
// are what the BROWSER computed for a screen reader, after ARIA, after the label association,
|
|
4
|
+
// after `aria-hidden` pruned the subtree. A `<div onclick>` answers no role and no name, which is
|
|
5
|
+
// the finding; a fake reading `role=` off the markup would answer the attribute and miss it.
|
|
6
|
+
//
|
|
7
|
+
// Four protocol calls per read, each parsed with a schema and never cast: the answers are
|
|
8
|
+
// somebody else's wire shapes, and a build that renames a field must refuse loudly
|
|
9
|
+
// (`X_VALIDATION_FAILED`, which `cdp-target.ts`'s `guard()` passes through unlabelled).
|
|
10
|
+
|
|
11
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
12
|
+
import { parse, t } from '@ultimat3/schema';
|
|
13
|
+
import type { CdpSessionLike } from './cdp-port';
|
|
14
|
+
import type { AxNode } from './target';
|
|
15
|
+
|
|
16
|
+
const anyString = t.string.min(0);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* CDP's `AXValue`: `{ type, value, … }`, where `value` is typed `any` on the wire. A role and a
|
|
20
|
+
* name are strings; a `value` may be a number (a slider) or a boolean (a checkbox), so every
|
|
21
|
+
* scalar is accepted and rendered to text — `AxNode.value` is what a reader is TOLD, and a reader
|
|
22
|
+
* is told text.
|
|
23
|
+
*/
|
|
24
|
+
const axValueSchema = t.object({
|
|
25
|
+
value: t.optional(t.union(anyString, t.number, t.boolean)),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const axPropertySchema = t.object({
|
|
29
|
+
name: t.string,
|
|
30
|
+
value: axValueSchema,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const axNodeSchema = t.object({
|
|
34
|
+
ignored: t.boolean,
|
|
35
|
+
role: t.optional(axValueSchema),
|
|
36
|
+
name: t.optional(axValueSchema),
|
|
37
|
+
description: t.optional(axValueSchema),
|
|
38
|
+
value: t.optional(axValueSchema),
|
|
39
|
+
properties: t.optional(t.array(axPropertySchema)),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const documentSchema = t.object({ root: t.object({ nodeId: t.number }) });
|
|
43
|
+
const nodeIdsSchema = t.object({ nodeIds: t.array(t.number) });
|
|
44
|
+
const describedSchema = t.object({ node: t.object({ backendNodeId: t.number }) });
|
|
45
|
+
const partialTreeSchema = t.object({ nodes: t.array(axNodeSchema) });
|
|
46
|
+
|
|
47
|
+
type ParsedAxValue = { readonly value?: string | number | boolean | undefined };
|
|
48
|
+
type ParsedAxNode = {
|
|
49
|
+
readonly ignored: boolean;
|
|
50
|
+
readonly role?: ParsedAxValue | undefined;
|
|
51
|
+
readonly name?: ParsedAxValue | undefined;
|
|
52
|
+
readonly description?: ParsedAxValue | undefined;
|
|
53
|
+
readonly value?: ParsedAxValue | undefined;
|
|
54
|
+
readonly properties?: readonly { readonly name: string; readonly value: ParsedAxValue }[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const textOf = (wrapped: ParsedAxValue | undefined): string | undefined =>
|
|
58
|
+
wrapped?.value === undefined ? undefined : String(wrapped.value);
|
|
59
|
+
|
|
60
|
+
const flagOf = (node: ParsedAxNode, name: string): boolean | undefined => {
|
|
61
|
+
const found = node.properties?.find((property) => property.name === name);
|
|
62
|
+
return found === undefined ? undefined : found.value.value === true;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `nodes[0]` is the node for the element itself when `fetchRelatives` is false — CDP answers a
|
|
67
|
+
* list because the same command can walk ancestors. An EMPTY list is a real answer too: an element
|
|
68
|
+
* that never entered the tree at all (a `<template>`, a detached subtree), which is recorded as
|
|
69
|
+
* ignored with nothing computed, rather than dropped, so the count the caller gets is the count of
|
|
70
|
+
* matches and not the count of nodes the browser deigned to describe.
|
|
71
|
+
*/
|
|
72
|
+
const toAxNode = (raw: unknown): AxNode => {
|
|
73
|
+
const nodes = parse(
|
|
74
|
+
partialTreeSchema as unknown as StandardSchemaV1<unknown, { nodes: ParsedAxNode[] }>,
|
|
75
|
+
raw,
|
|
76
|
+
).nodes;
|
|
77
|
+
const node = nodes[0];
|
|
78
|
+
if (node === undefined) return { role: '', name: '', ignored: true };
|
|
79
|
+
const description = textOf(node.description);
|
|
80
|
+
const value = textOf(node.value);
|
|
81
|
+
const focused = flagOf(node, 'focused');
|
|
82
|
+
const disabled = flagOf(node, 'disabled');
|
|
83
|
+
return {
|
|
84
|
+
role: textOf(node.role) ?? '',
|
|
85
|
+
name: textOf(node.name) ?? '',
|
|
86
|
+
...(description === undefined ? {} : { description }),
|
|
87
|
+
...(value === undefined ? {} : { value }),
|
|
88
|
+
...(focused === undefined ? {} : { focused }),
|
|
89
|
+
...(disabled === undefined ? {} : { disabled }),
|
|
90
|
+
ignored: node.ignored,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The accessibility node of every element `selector` matches, in document order, at most `max`.
|
|
96
|
+
*
|
|
97
|
+
* The session is the CALLER's: `cdp-target.ts` creates it and detaches it in a `finally`, so a
|
|
98
|
+
* throw from any of the four calls — a selector the browser refuses, a build whose answer does
|
|
99
|
+
* not parse — never leaves a session attached behind the page. `max` is screened by the caller
|
|
100
|
+
* too (`finiteCount`); here it is only a bound on the per-node round trips.
|
|
101
|
+
*/
|
|
102
|
+
export async function axNodesFor(
|
|
103
|
+
session: CdpSessionLike,
|
|
104
|
+
selector: string,
|
|
105
|
+
max: number,
|
|
106
|
+
): Promise<readonly AxNode[]> {
|
|
107
|
+
const document = parse(documentSchema, await session.send('DOM.getDocument', { depth: 0 }));
|
|
108
|
+
const { nodeIds } = parse(
|
|
109
|
+
nodeIdsSchema,
|
|
110
|
+
await session.send('DOM.querySelectorAll', { nodeId: document.root.nodeId, selector }),
|
|
111
|
+
);
|
|
112
|
+
const out: AxNode[] = [];
|
|
113
|
+
for (const nodeId of nodeIds.slice(0, max)) {
|
|
114
|
+
const { node } = parse(describedSchema, await session.send('DOM.describeNode', { nodeId }));
|
|
115
|
+
out.push(
|
|
116
|
+
toAxNode(
|
|
117
|
+
await session.send('Accessibility.getPartialAXTree', {
|
|
118
|
+
backendNodeId: node.backendNodeId,
|
|
119
|
+
fetchRelatives: false,
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
package/src/cdp-arm.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Everything `cdpTarget()` arms on the page BEFORE its first navigation, plus the readers that
|
|
2
|
+
// turn the library's `unknown` event payloads into ring entries. Split out of `cdp-target.ts` on
|
|
3
|
+
// 2026-09-19 for one reason: that file stood at 478 lines against the 500-line ceiling, and the
|
|
4
|
+
// three verbs `press`/`focus`/`accessibility` did not fit under it. The extraction is VERBATIM —
|
|
5
|
+
// the request, console, `pageerror` and `error` handlers are the same code, one file over.
|
|
6
|
+
|
|
7
|
+
import type { CdpPageLike, CdpRequestLike } from './cdp-port';
|
|
8
|
+
import type { ScrapeClock } from './clock';
|
|
9
|
+
import type { InterceptRules } from './intercept';
|
|
10
|
+
import { interceptVerdict, refusalEntry } from './intercept';
|
|
11
|
+
import type {
|
|
12
|
+
ConsoleLine,
|
|
13
|
+
ConsoleRing,
|
|
14
|
+
NetworkRing,
|
|
15
|
+
PageError,
|
|
16
|
+
PageErrorRing,
|
|
17
|
+
ResourceType,
|
|
18
|
+
} from './rings';
|
|
19
|
+
import { pageErrorEntry, RESOURCE_TYPES } from './rings';
|
|
20
|
+
|
|
21
|
+
/** What `arm()` reads: the page it subscribes to, the rules it judges by, the clock it stamps with. */
|
|
22
|
+
export interface CdpArmInit {
|
|
23
|
+
readonly page: CdpPageLike;
|
|
24
|
+
readonly rules: InterceptRules;
|
|
25
|
+
readonly clock: ScrapeClock;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const asResourceType = (raw: string): ResourceType =>
|
|
29
|
+
(RESOURCE_TYPES as readonly string[]).includes(raw) ? (raw as ResourceType) : 'other';
|
|
30
|
+
|
|
31
|
+
/** The library's event payloads are `unknown` here — read structurally, never cast. */
|
|
32
|
+
const asRequest = (payload: unknown): CdpRequestLike | undefined => {
|
|
33
|
+
if (typeof payload !== 'object' || payload === null) return undefined;
|
|
34
|
+
const candidate = payload as Partial<CdpRequestLike>;
|
|
35
|
+
return typeof candidate.url === 'function' && typeof candidate.abort === 'function'
|
|
36
|
+
? (candidate as CdpRequestLike)
|
|
37
|
+
: undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* CDP's console levels, mapped onto this package's five. `warning` is the library's spelling of
|
|
42
|
+
* `warn`, `verbose` of `debug`, and everything structural (`table`, `startGroup`, `dir`) is a log
|
|
43
|
+
* line with a shape — never its own level, because `ConsoleLine.level` is what an author filters on.
|
|
44
|
+
*/
|
|
45
|
+
const CONSOLE_LEVELS: Readonly<Record<string, ConsoleLine['level']>> = {
|
|
46
|
+
error: 'error',
|
|
47
|
+
assert: 'error',
|
|
48
|
+
warning: 'warn',
|
|
49
|
+
warn: 'warn',
|
|
50
|
+
info: 'info',
|
|
51
|
+
debug: 'debug',
|
|
52
|
+
verbose: 'debug',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `Object.hasOwn`, never the read alone: the type word arrives off the WIRE, so
|
|
57
|
+
* `CONSOLE_LEVELS['__proto__']` answered `Object.prototype` and `['constructor']` the `Object`
|
|
58
|
+
* function — neither of which a `?? 'log'` fallback can rescue, because neither is `undefined`.
|
|
59
|
+
* `ConsoleLine.level` would then hold a value its own type says is one of five words, so the
|
|
60
|
+
* `level === 'error'` filter this ring exists for matched nothing and `JSON.stringify` dropped
|
|
61
|
+
* the field from a snapshot outright. Lowercasing is not the guard: `__proto__` and `constructor`
|
|
62
|
+
* are already lowercase. Same discriminator as `packages/flags/src/subject.ts`.
|
|
63
|
+
*/
|
|
64
|
+
const consoleLevel = (type: string): ConsoleLine['level'] => {
|
|
65
|
+
const word = type.toLowerCase();
|
|
66
|
+
return Object.hasOwn(CONSOLE_LEVELS, word) ? (CONSOLE_LEVELS[word] ?? 'log') : 'log';
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reads a string out of somebody else's event payload, calling an accessor THROUGH ITS OWNER.
|
|
71
|
+
*
|
|
72
|
+
* `HTTPRequest.method()` and `ConsoleMessage.type()`/`.text()` read `this` — they are methods on
|
|
73
|
+
* the library's own objects, not closures over a value. Handing the bare function to a helper
|
|
74
|
+
* (`readString(request.method)`) drops the receiver, so the accessor answers against `undefined`:
|
|
75
|
+
* on one build that throws inside the interception handler, on another it answers wrong.
|
|
76
|
+
*/
|
|
77
|
+
const readStringFrom = (owner: unknown, key: string): string | undefined => {
|
|
78
|
+
if (typeof owner !== 'object' || owner === null) return undefined;
|
|
79
|
+
const value = (owner as Record<string, unknown>)[key];
|
|
80
|
+
if (typeof value === 'string') return value;
|
|
81
|
+
if (typeof value !== 'function') return undefined;
|
|
82
|
+
const answer = (value as (this: unknown) => unknown).call(owner);
|
|
83
|
+
return typeof answer === 'string' ? answer : undefined;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A `pageerror` payload, read defensively — never cast, and never assumed to be an `Error`.
|
|
88
|
+
*
|
|
89
|
+
* `readStringFrom`, the same reader the console handler uses, because the payload has the same
|
|
90
|
+
* problem: `message` and `stack` are an own property on one build and an accessor on another, and
|
|
91
|
+
* a schema parse cannot call an accessor. A page can also `throw 'a string'` or throw a frozen
|
|
92
|
+
* object with no `message` at all — both reach here, and both are recorded as SOMETHING having
|
|
93
|
+
* thrown, because an entry with a poor message is still the difference between "the island threw"
|
|
94
|
+
* and silence.
|
|
95
|
+
*/
|
|
96
|
+
const readPageError = (payload: unknown, at: number): PageError => {
|
|
97
|
+
if (typeof payload === 'string') return pageErrorEntry({ message: payload, at });
|
|
98
|
+
return pageErrorEntry({
|
|
99
|
+
message: readStringFrom(payload, 'message') ?? '',
|
|
100
|
+
stack: readStringFrom(payload, 'stack'),
|
|
101
|
+
at,
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Everything `arm()` writes into. Named rather than positional: three rings of near-identical
|
|
107
|
+
* type plus a latch is a call site nobody can read, and swapping two of them is a mistake the
|
|
108
|
+
* compiler cannot catch.
|
|
109
|
+
*/
|
|
110
|
+
export interface CdpSinks {
|
|
111
|
+
readonly network: NetworkRing;
|
|
112
|
+
readonly console: ConsoleRing;
|
|
113
|
+
readonly pageErrors: PageErrorRing;
|
|
114
|
+
readonly crashed: { value: string | undefined };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Interception is armed BEFORE the first navigation and refuses at the request, not after the
|
|
119
|
+
* response — an `allowHosts` that reported afterwards would be a log line about bytes that
|
|
120
|
+
* already left the container.
|
|
121
|
+
*/
|
|
122
|
+
export async function arm(init: CdpArmInit, sinks: CdpSinks): Promise<void> {
|
|
123
|
+
const { network, console: console_, pageErrors, crashed } = sinks;
|
|
124
|
+
await init.page.setRequestInterception(true);
|
|
125
|
+
init.page.on('request', (payload) => {
|
|
126
|
+
const request = asRequest(payload);
|
|
127
|
+
if (request === undefined) return;
|
|
128
|
+
const url = request.url();
|
|
129
|
+
const type = asResourceType(request.resourceType());
|
|
130
|
+
// The METHOD the browser is actually sending. Recording every request as a GET made
|
|
131
|
+
// `page.network()` — which `X_SCRAPE_HTTP_FAILED`'s own fix line tells the reader to open —
|
|
132
|
+
// misreport every POST and PUT the page made.
|
|
133
|
+
const method = readStringFrom(request, 'method') ?? 'GET';
|
|
134
|
+
const verdict = interceptVerdict(url, type, init.rules);
|
|
135
|
+
const at = init.clock.now().getTime();
|
|
136
|
+
if (verdict === 'allow') {
|
|
137
|
+
network.push({ method, url, resourceType: type, at });
|
|
138
|
+
void request.continue();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
network.push(refusalEntry(url, type, verdict, at, method));
|
|
142
|
+
void request.abort();
|
|
143
|
+
});
|
|
144
|
+
init.page.on('console', (payload) => {
|
|
145
|
+
console_.push({
|
|
146
|
+
level: consoleLevel(readStringFrom(payload, 'type') ?? ''),
|
|
147
|
+
text: readStringFrom(payload, 'text') ?? '',
|
|
148
|
+
at: init.clock.now().getTime(),
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
/**
|
|
152
|
+
* The page threw and nothing caught it. THE gap this ring closes: a screenshot of an island
|
|
153
|
+
* that threw during hydration is a picture of the server-rendered markup, indistinguishable
|
|
154
|
+
* from a page that worked — and `console` does not carry it, because throwing calls no console
|
|
155
|
+
* method. Subscribed here, beside the others, so a target is observing before its first
|
|
156
|
+
* navigation: an exception raised during load has no second chance to be recorded.
|
|
157
|
+
*
|
|
158
|
+
* NOT the same event as `error` below, and the difference is the whole reason this is a
|
|
159
|
+
* separate handler: puppeteer's `pageerror` is "an uncaught exception happens within the page"
|
|
160
|
+
* and its `error` is "the page crashes" (`PageEvent.PageError` / `PageEvent.Error`). One is the
|
|
161
|
+
* app being broken and the session is fine; the other is the tab being gone. Recording a
|
|
162
|
+
* `pageerror` into `crashed` would make every scrape of a page with one bad island answer
|
|
163
|
+
* X_SCRAPE_PAGE_CRASHED — a code registered `terminal` — for a page still perfectly usable.
|
|
164
|
+
*/
|
|
165
|
+
init.page.on('pageerror', (payload) => {
|
|
166
|
+
pageErrors.push(readPageError(payload, init.clock.now().getTime()));
|
|
167
|
+
});
|
|
168
|
+
// A renderer that dies must be a CODE, not a hang: every later call answers X_SCRAPE_PAGE_CRASHED
|
|
169
|
+
// instead of waiting out its own timeout against a tab that is gone.
|
|
170
|
+
init.page.on('error', (payload) => {
|
|
171
|
+
crashed.value = readStringFrom(payload, 'message') ?? 'renderer crashed';
|
|
172
|
+
});
|
|
173
|
+
}
|
package/src/cdp-fake.ts
CHANGED
|
@@ -8,10 +8,16 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Precedent: `packages/storage/src/driver-s3-fixture.ts` ships the same way.
|
|
10
10
|
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
CdpBrowserLike,
|
|
13
|
+
CdpFrameLike,
|
|
14
|
+
CdpLauncherLike,
|
|
15
|
+
CdpPageLike,
|
|
16
|
+
CdpSessionLike,
|
|
17
|
+
} from './cdp-port';
|
|
12
18
|
import { COLOR_SCHEME_FEATURE } from './color-scheme';
|
|
13
19
|
import { queryHtml } from './html-query';
|
|
14
|
-
import type { ElementSnapshot, ScrapeCookie } from './target';
|
|
20
|
+
import type { AxNode, ElementSnapshot, ScrapeCookie } from './target';
|
|
15
21
|
|
|
16
22
|
/** The selector inside `snapshotExpression()`'s `document.querySelectorAll("…")`. */
|
|
17
23
|
const selectorOf = (expression: string): string | undefined => {
|
|
@@ -54,6 +60,14 @@ export interface FakeCdpPageInit {
|
|
|
54
60
|
readonly userAgent?: string;
|
|
55
61
|
/** Selectors whose element is covered at its centre — what only a layout engine can see. */
|
|
56
62
|
readonly covered?: readonly string[];
|
|
63
|
+
/**
|
|
64
|
+
* What the accessibility tree answers, BY SELECTOR — the nodes `Accessibility.getPartialAXTree`
|
|
65
|
+
* would compute for each match, in document order. Canned rather than derived from the markup,
|
|
66
|
+
* deliberately: a fake that read `role=` off the tag would be the offline driver's refusal
|
|
67
|
+
* re-implemented as a lie, and the point of this session is to exercise `cdp-a11y.ts`'s four
|
|
68
|
+
* protocol calls and its parse, not to compute a role.
|
|
69
|
+
*/
|
|
70
|
+
readonly accessibility?: Readonly<Record<string, readonly AxNode[]>>;
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
type Handlers = Map<string, ((payload: unknown) => void)[]>;
|
|
@@ -77,6 +91,21 @@ export interface FakeCdpBrowser extends CdpBrowserLike {
|
|
|
77
91
|
* something sets one, which is the launcher's own default and not a value this fake invents.
|
|
78
92
|
*/
|
|
79
93
|
readonly colorScheme: string | null;
|
|
94
|
+
/**
|
|
95
|
+
* Every keyboard event, in order — `down Meta`, `press K`, `up Meta` — so a test asserts on the
|
|
96
|
+
* SEQUENCE a chord became: a modifier released before the key, or never released, is the defect.
|
|
97
|
+
*/
|
|
98
|
+
readonly pressed: readonly string[];
|
|
99
|
+
/** Every selector `page.focus()` was handed, in order. */
|
|
100
|
+
readonly focused: readonly string[];
|
|
101
|
+
/**
|
|
102
|
+
* Every expression `page.evaluateOnNewDocument()` was handed, in order — the scripts a real
|
|
103
|
+
* browser would run ahead of each document's own. Recorded, never executed: this fake has no JS
|
|
104
|
+
* engine, and a test asserts on WHAT was prepared and that it was prepared before `goto`.
|
|
105
|
+
*/
|
|
106
|
+
readonly prepared: readonly string[];
|
|
107
|
+
/** How many raw CDP sessions were created and how many detached — a leak is a difference. */
|
|
108
|
+
readonly sessions: { readonly created: number; readonly detached: number };
|
|
80
109
|
}
|
|
81
110
|
|
|
82
111
|
/** A layout box every element gets, so the CDP path exercises the fields the fake target lacks. */
|
|
@@ -124,6 +153,76 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
124
153
|
const covered = new Set(init.covered ?? []);
|
|
125
154
|
const storage: Record<string, string> = { ...init.storage };
|
|
126
155
|
let cookies: readonly ScrapeCookie[] = init.cookies ?? [];
|
|
156
|
+
const pressed: string[] = [];
|
|
157
|
+
const focused: string[] = [];
|
|
158
|
+
const prepared: string[] = [];
|
|
159
|
+
const sessions = { created: 0, detached: 0 };
|
|
160
|
+
const axBySelector = init.accessibility ?? {};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A raw session answering the four commands `cdp-a11y.ts` sends, in CDP's own wire shapes —
|
|
164
|
+
* `{ value }` wrappers, a `properties` list — so the parse in that file is what is under test.
|
|
165
|
+
* Node ids are minted per `querySelectorAll` and looked up on the way back, which is how the
|
|
166
|
+
* real protocol addresses nodes too.
|
|
167
|
+
*/
|
|
168
|
+
const cdpSession = (): CdpSessionLike => {
|
|
169
|
+
const byNodeId = new Map<number, AxNode>();
|
|
170
|
+
const wrap = (value: string | boolean | undefined): { value?: string | boolean } =>
|
|
171
|
+
value === undefined ? {} : { value };
|
|
172
|
+
return {
|
|
173
|
+
send: (method: string, params?: Record<string, unknown>) => {
|
|
174
|
+
if (method === 'DOM.getDocument') return Promise.resolve({ root: { nodeId: 1 } });
|
|
175
|
+
if (method === 'DOM.querySelectorAll') {
|
|
176
|
+
const selector = typeof params?.['selector'] === 'string' ? params['selector'] : '';
|
|
177
|
+
const nodes = Object.hasOwn(axBySelector, selector) ? (axBySelector[selector] ?? []) : [];
|
|
178
|
+
const nodeIds = nodes.map((node, index) => {
|
|
179
|
+
const nodeId = 100 + byNodeId.size + index;
|
|
180
|
+
byNodeId.set(nodeId, node);
|
|
181
|
+
return nodeId;
|
|
182
|
+
});
|
|
183
|
+
return Promise.resolve({ nodeIds });
|
|
184
|
+
}
|
|
185
|
+
if (method === 'DOM.describeNode') {
|
|
186
|
+
const nodeId = typeof params?.['nodeId'] === 'number' ? params['nodeId'] : 0;
|
|
187
|
+
return Promise.resolve({ node: { backendNodeId: nodeId * 10 } });
|
|
188
|
+
}
|
|
189
|
+
if (method === 'Accessibility.getPartialAXTree') {
|
|
190
|
+
const backend =
|
|
191
|
+
typeof params?.['backendNodeId'] === 'number' ? params['backendNodeId'] : 0;
|
|
192
|
+
const node = byNodeId.get(backend / 10);
|
|
193
|
+
if (node === undefined) return Promise.resolve({ nodes: [] });
|
|
194
|
+
const properties = [
|
|
195
|
+
...(node.focused === undefined ? [] : [{ name: 'focused', value: wrap(node.focused) }]),
|
|
196
|
+
...(node.disabled === undefined
|
|
197
|
+
? []
|
|
198
|
+
: [{ name: 'disabled', value: wrap(node.disabled) }]),
|
|
199
|
+
];
|
|
200
|
+
return Promise.resolve({
|
|
201
|
+
nodes: [
|
|
202
|
+
{
|
|
203
|
+
nodeId: `ax-${String(backend)}`,
|
|
204
|
+
ignored: node.ignored,
|
|
205
|
+
role: { type: 'role', ...wrap(node.role) },
|
|
206
|
+
name: { type: 'computedString', ...wrap(node.name) },
|
|
207
|
+
...(node.description === undefined
|
|
208
|
+
? {}
|
|
209
|
+
: { description: { type: 'computedString', value: node.description } }),
|
|
210
|
+
...(node.value === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: { value: { type: 'string', value: node.value } }),
|
|
213
|
+
properties,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return Promise.resolve({});
|
|
219
|
+
},
|
|
220
|
+
detach: () => {
|
|
221
|
+
sessions.detached += 1;
|
|
222
|
+
return Promise.resolve();
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
};
|
|
127
226
|
|
|
128
227
|
const documentOf = (read: () => string): FakeDocument => ({ html: read, typed: new Map() });
|
|
129
228
|
const pageDocument = documentOf(() => html);
|
|
@@ -206,6 +305,32 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
206
305
|
screenshot: () => Promise.resolve(new Uint8Array([1, 2, 3])),
|
|
207
306
|
pdf: () => Promise.resolve(new Uint8Array([4, 5])),
|
|
208
307
|
setRequestInterception: () => Promise.resolve(),
|
|
308
|
+
keyboard: {
|
|
309
|
+
down: (key: string) => {
|
|
310
|
+
pressed.push(`down ${key}`);
|
|
311
|
+
return Promise.resolve();
|
|
312
|
+
},
|
|
313
|
+
up: (key: string) => {
|
|
314
|
+
pressed.push(`up ${key}`);
|
|
315
|
+
return Promise.resolve();
|
|
316
|
+
},
|
|
317
|
+
press: (key: string) => {
|
|
318
|
+
pressed.push(`press ${key}`);
|
|
319
|
+
return Promise.resolve();
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
focus: (selector: string) => {
|
|
323
|
+
focused.push(selector);
|
|
324
|
+
return Promise.resolve();
|
|
325
|
+
},
|
|
326
|
+
evaluateOnNewDocument: (expression: string) => {
|
|
327
|
+
prepared.push(expression);
|
|
328
|
+
return Promise.resolve(undefined);
|
|
329
|
+
},
|
|
330
|
+
createCDPSession: () => {
|
|
331
|
+
sessions.created += 1;
|
|
332
|
+
return Promise.resolve(cdpSession());
|
|
333
|
+
},
|
|
209
334
|
setOfflineMode: (enabled: boolean) => {
|
|
210
335
|
offline = enabled;
|
|
211
336
|
return Promise.resolve();
|
|
@@ -275,6 +400,10 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
275
400
|
get colorScheme(): string | null {
|
|
276
401
|
return colorScheme;
|
|
277
402
|
},
|
|
403
|
+
pressed,
|
|
404
|
+
focused,
|
|
405
|
+
prepared,
|
|
406
|
+
sessions,
|
|
278
407
|
};
|
|
279
408
|
}
|
|
280
409
|
|
package/src/cdp-port.ts
CHANGED
|
@@ -44,6 +44,29 @@ export interface CdpRequestLike {
|
|
|
44
44
|
continue(): Promise<void>;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The page's keyboard — `page.keyboard` under puppeteer, a PROPERTY and not a method, which is why
|
|
49
|
+
* it is declared as one. `key` stays a bare `string`: the library's `KeyInput` union is its own
|
|
50
|
+
* vocabulary, and a launcher whose union is wider or narrower must still satisfy the port. The
|
|
51
|
+
* chord grammar is checked in `key-chord.ts` before any of these is called.
|
|
52
|
+
*/
|
|
53
|
+
export interface CdpKeyboardLike {
|
|
54
|
+
down(key: string): Promise<void>;
|
|
55
|
+
up(key: string): Promise<void>;
|
|
56
|
+
press(key: string): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A raw protocol session — `page.createCDPSession()` under puppeteer. `method` and `params` are
|
|
61
|
+
* the wire's own shapes, restated as `string` and a plain record: the library's typed command map
|
|
62
|
+
* is the library's, and naming it here would make this file depend on somebody else's protocol
|
|
63
|
+
* tables. Every answer is `unknown` and is parsed by the caller (`cdp-a11y.ts`), never cast.
|
|
64
|
+
*/
|
|
65
|
+
export interface CdpSessionLike {
|
|
66
|
+
send(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
67
|
+
detach(): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
47
70
|
export interface CdpPageLike {
|
|
48
71
|
url(): string;
|
|
49
72
|
goto(url: string, options?: { readonly timeout?: number }): Promise<unknown>;
|
|
@@ -97,6 +120,29 @@ export interface CdpPageLike {
|
|
|
97
120
|
* the library's own types for them.
|
|
98
121
|
*/
|
|
99
122
|
on(event: string, handler: (payload: unknown) => void): unknown;
|
|
123
|
+
/**
|
|
124
|
+
* OPTIONAL, read defensively, for `setOfflineMode`'s reason: a provider SDK or a launcher that
|
|
125
|
+
* predates the member must still satisfy the port. `cdp-target.ts` refuses BY NAME with
|
|
126
|
+
* `X_NOT_IMPLEMENTED` when a launcher lacks it — a chord on a page with no keyboard is a coded
|
|
127
|
+
* refusal, never a silent no-op that leaves a command palette closed and a test green.
|
|
128
|
+
*/
|
|
129
|
+
readonly keyboard?: CdpKeyboardLike | undefined;
|
|
130
|
+
/** `page.focus(selector)`. OPTIONAL for `keyboard`'s reason, and refused by name when absent. */
|
|
131
|
+
focus?(selector: string): Promise<void>;
|
|
132
|
+
/**
|
|
133
|
+
* `page.evaluateOnNewDocument(expression)`: a script the browser runs in EVERY document this
|
|
134
|
+
* page navigates to, before any of the document's own scripts — `Page.addScriptToEvaluateOnNewDocument`
|
|
135
|
+
* under CDP. It is the only moment that beats an inlined boot script, which is what a
|
|
136
|
+
* storage-seeded theme choice needs (`ScrapePage.prepare`). The string form, for `evaluate`'s
|
|
137
|
+
* reason. OPTIONAL for `keyboard`'s reason, and refused by name when absent.
|
|
138
|
+
*/
|
|
139
|
+
evaluateOnNewDocument?(expression: string): Promise<unknown>;
|
|
140
|
+
/**
|
|
141
|
+
* A raw protocol session, for the one read this package makes that the library has no method
|
|
142
|
+
* for: the accessibility tree (`Accessibility.getPartialAXTree`). OPTIONAL for `keyboard`'s
|
|
143
|
+
* reason, and refused by name when absent.
|
|
144
|
+
*/
|
|
145
|
+
createCDPSession?(): Promise<CdpSessionLike>;
|
|
100
146
|
frames(): readonly CdpFrameLike[];
|
|
101
147
|
close(): Promise<void>;
|
|
102
148
|
}
|
package/src/cdp-snapshot.ts
CHANGED
|
@@ -92,3 +92,14 @@ export function parseSnapshots(raw: unknown): readonly ElementSnapshot[] {
|
|
|
92
92
|
attrs: browserRecord(attrsOf(rows[index])),
|
|
93
93
|
}));
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Moving focus, as an expression — for a FRAME, where the port has no `focus` method. The page
|
|
98
|
+
* target calls `page.focus()`, which the library resolves against the top-level document, so a
|
|
99
|
+
* frame's focus travels as text into the frame's own `evaluate`, exactly as `clearExpression`
|
|
100
|
+
* does. A selector matching nothing REJECTS, as `page.focus()` does — the `.focus()` call on a
|
|
101
|
+
* null match is the throw, deliberately unguarded — and `cdp-target.ts`'s `guard()` re-labels the
|
|
102
|
+
* frame's rejection the same way it re-labels any other page throw.
|
|
103
|
+
*/
|
|
104
|
+
export const focusExpression = (selector: string): string =>
|
|
105
|
+
`(() => { document.querySelector(${JSON.stringify(selector)}).focus(); })()`;
|