@ultimat3/scraping 20.1.6 → 20.2.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/package.json +5 -5
- package/src/cdp-a11y.ts +125 -0
- package/src/cdp-arm.ts +173 -0
- package/src/cdp-fake.ts +119 -2
- package/src/cdp-port.ts +38 -0
- package/src/cdp-snapshot.ts +11 -0
- package/src/cdp-target.ts +87 -166
- package/src/error-throws.ts +14 -0
- package/src/errors.ts +6 -0
- package/src/html-target.ts +37 -0
- package/src/index.ts +15 -1
- package/src/key-chord.ts +60 -0
- package/src/page-over-target.ts +26 -1
- package/src/page.ts +30 -1
- package/src/target.ts +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/scraping",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.2.0",
|
|
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.
|
|
34
|
-
"@ultimat3/jobs": "20.
|
|
35
|
-
"@ultimat3/schema": "20.
|
|
36
|
-
"@ultimat3/storage": "20.
|
|
33
|
+
"@ultimat3/core": "20.2.0",
|
|
34
|
+
"@ultimat3/jobs": "20.2.0",
|
|
35
|
+
"@ultimat3/schema": "20.2.0",
|
|
36
|
+
"@ultimat3/storage": "20.2.0"
|
|
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,15 @@ 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
|
+
/** How many raw CDP sessions were created and how many detached — a leak is a difference. */
|
|
102
|
+
readonly sessions: { readonly created: number; readonly detached: number };
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
/** A layout box every element gets, so the CDP path exercises the fields the fake target lacks. */
|
|
@@ -124,6 +147,75 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
124
147
|
const covered = new Set(init.covered ?? []);
|
|
125
148
|
const storage: Record<string, string> = { ...init.storage };
|
|
126
149
|
let cookies: readonly ScrapeCookie[] = init.cookies ?? [];
|
|
150
|
+
const pressed: string[] = [];
|
|
151
|
+
const focused: string[] = [];
|
|
152
|
+
const sessions = { created: 0, detached: 0 };
|
|
153
|
+
const axBySelector = init.accessibility ?? {};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A raw session answering the four commands `cdp-a11y.ts` sends, in CDP's own wire shapes —
|
|
157
|
+
* `{ value }` wrappers, a `properties` list — so the parse in that file is what is under test.
|
|
158
|
+
* Node ids are minted per `querySelectorAll` and looked up on the way back, which is how the
|
|
159
|
+
* real protocol addresses nodes too.
|
|
160
|
+
*/
|
|
161
|
+
const cdpSession = (): CdpSessionLike => {
|
|
162
|
+
const byNodeId = new Map<number, AxNode>();
|
|
163
|
+
const wrap = (value: string | boolean | undefined): { value?: string | boolean } =>
|
|
164
|
+
value === undefined ? {} : { value };
|
|
165
|
+
return {
|
|
166
|
+
send: (method: string, params?: Record<string, unknown>) => {
|
|
167
|
+
if (method === 'DOM.getDocument') return Promise.resolve({ root: { nodeId: 1 } });
|
|
168
|
+
if (method === 'DOM.querySelectorAll') {
|
|
169
|
+
const selector = typeof params?.['selector'] === 'string' ? params['selector'] : '';
|
|
170
|
+
const nodes = Object.hasOwn(axBySelector, selector) ? (axBySelector[selector] ?? []) : [];
|
|
171
|
+
const nodeIds = nodes.map((node, index) => {
|
|
172
|
+
const nodeId = 100 + byNodeId.size + index;
|
|
173
|
+
byNodeId.set(nodeId, node);
|
|
174
|
+
return nodeId;
|
|
175
|
+
});
|
|
176
|
+
return Promise.resolve({ nodeIds });
|
|
177
|
+
}
|
|
178
|
+
if (method === 'DOM.describeNode') {
|
|
179
|
+
const nodeId = typeof params?.['nodeId'] === 'number' ? params['nodeId'] : 0;
|
|
180
|
+
return Promise.resolve({ node: { backendNodeId: nodeId * 10 } });
|
|
181
|
+
}
|
|
182
|
+
if (method === 'Accessibility.getPartialAXTree') {
|
|
183
|
+
const backend =
|
|
184
|
+
typeof params?.['backendNodeId'] === 'number' ? params['backendNodeId'] : 0;
|
|
185
|
+
const node = byNodeId.get(backend / 10);
|
|
186
|
+
if (node === undefined) return Promise.resolve({ nodes: [] });
|
|
187
|
+
const properties = [
|
|
188
|
+
...(node.focused === undefined ? [] : [{ name: 'focused', value: wrap(node.focused) }]),
|
|
189
|
+
...(node.disabled === undefined
|
|
190
|
+
? []
|
|
191
|
+
: [{ name: 'disabled', value: wrap(node.disabled) }]),
|
|
192
|
+
];
|
|
193
|
+
return Promise.resolve({
|
|
194
|
+
nodes: [
|
|
195
|
+
{
|
|
196
|
+
nodeId: `ax-${String(backend)}`,
|
|
197
|
+
ignored: node.ignored,
|
|
198
|
+
role: { type: 'role', ...wrap(node.role) },
|
|
199
|
+
name: { type: 'computedString', ...wrap(node.name) },
|
|
200
|
+
...(node.description === undefined
|
|
201
|
+
? {}
|
|
202
|
+
: { description: { type: 'computedString', value: node.description } }),
|
|
203
|
+
...(node.value === undefined
|
|
204
|
+
? {}
|
|
205
|
+
: { value: { type: 'string', value: node.value } }),
|
|
206
|
+
properties,
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return Promise.resolve({});
|
|
212
|
+
},
|
|
213
|
+
detach: () => {
|
|
214
|
+
sessions.detached += 1;
|
|
215
|
+
return Promise.resolve();
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
};
|
|
127
219
|
|
|
128
220
|
const documentOf = (read: () => string): FakeDocument => ({ html: read, typed: new Map() });
|
|
129
221
|
const pageDocument = documentOf(() => html);
|
|
@@ -206,6 +298,28 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
206
298
|
screenshot: () => Promise.resolve(new Uint8Array([1, 2, 3])),
|
|
207
299
|
pdf: () => Promise.resolve(new Uint8Array([4, 5])),
|
|
208
300
|
setRequestInterception: () => Promise.resolve(),
|
|
301
|
+
keyboard: {
|
|
302
|
+
down: (key: string) => {
|
|
303
|
+
pressed.push(`down ${key}`);
|
|
304
|
+
return Promise.resolve();
|
|
305
|
+
},
|
|
306
|
+
up: (key: string) => {
|
|
307
|
+
pressed.push(`up ${key}`);
|
|
308
|
+
return Promise.resolve();
|
|
309
|
+
},
|
|
310
|
+
press: (key: string) => {
|
|
311
|
+
pressed.push(`press ${key}`);
|
|
312
|
+
return Promise.resolve();
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
focus: (selector: string) => {
|
|
316
|
+
focused.push(selector);
|
|
317
|
+
return Promise.resolve();
|
|
318
|
+
},
|
|
319
|
+
createCDPSession: () => {
|
|
320
|
+
sessions.created += 1;
|
|
321
|
+
return Promise.resolve(cdpSession());
|
|
322
|
+
},
|
|
209
323
|
setOfflineMode: (enabled: boolean) => {
|
|
210
324
|
offline = enabled;
|
|
211
325
|
return Promise.resolve();
|
|
@@ -275,6 +389,9 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
275
389
|
get colorScheme(): string | null {
|
|
276
390
|
return colorScheme;
|
|
277
391
|
},
|
|
392
|
+
pressed,
|
|
393
|
+
focused,
|
|
394
|
+
sessions,
|
|
278
395
|
};
|
|
279
396
|
}
|
|
280
397
|
|
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,21 @@ 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
|
+
* A raw protocol session, for the one read this package makes that the library has no method
|
|
134
|
+
* for: the accessibility tree (`Accessibility.getPartialAXTree`). OPTIONAL for `keyboard`'s
|
|
135
|
+
* reason, and refused by name when absent.
|
|
136
|
+
*/
|
|
137
|
+
createCDPSession?(): Promise<CdpSessionLike>;
|
|
100
138
|
frames(): readonly CdpFrameLike[];
|
|
101
139
|
close(): Promise<void>;
|
|
102
140
|
}
|
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(); })()`;
|
package/src/cdp-target.ts
CHANGED
|
@@ -5,25 +5,24 @@ import { isUltimateError } 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
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
8
|
+
import { axNodesFor } from './cdp-a11y';
|
|
9
|
+
import type { CdpArmInit } from './cdp-arm';
|
|
10
|
+
import { arm } from './cdp-arm';
|
|
11
|
+
import type { CdpBrowserLike, CdpFrameLike } from './cdp-port';
|
|
12
|
+
import {
|
|
13
|
+
clearExpression,
|
|
14
|
+
focusExpression,
|
|
15
|
+
parseSnapshots,
|
|
16
|
+
snapshotExpression,
|
|
17
|
+
} from './cdp-snapshot';
|
|
11
18
|
import { type ColorScheme, colorSchemeFeatures } from './color-scheme';
|
|
12
19
|
import { browserUnreachable, pageCrashed, scrapeNotImplemented } from './error-throws';
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
16
|
-
ConsoleLine,
|
|
17
|
-
ConsoleRing,
|
|
18
|
-
NetworkEntry,
|
|
19
|
-
NetworkRing,
|
|
20
|
-
PageError,
|
|
21
|
-
PageErrorRing,
|
|
22
|
-
ResourceType,
|
|
23
|
-
} from './rings';
|
|
24
|
-
import { createRing, pageErrorEntry, RESOURCE_TYPES } from './rings';
|
|
20
|
+
import { parseKeyChord } from './key-chord';
|
|
21
|
+
import type { ConsoleLine, NetworkEntry, PageError } from './rings';
|
|
22
|
+
import { createRing } from './rings';
|
|
25
23
|
import type { SessionSnapshot } from './session-state';
|
|
26
24
|
import type {
|
|
25
|
+
AxNode,
|
|
27
26
|
CaptureOptions,
|
|
28
27
|
FrameRef,
|
|
29
28
|
GotoOptions,
|
|
@@ -49,83 +48,6 @@ const cookieSchema = t.array(
|
|
|
49
48
|
}),
|
|
50
49
|
) as unknown as StandardSchemaV1<unknown, ScrapeCookie[]>;
|
|
51
50
|
|
|
52
|
-
const asResourceType = (raw: string): ResourceType =>
|
|
53
|
-
(RESOURCE_TYPES as readonly string[]).includes(raw) ? (raw as ResourceType) : 'other';
|
|
54
|
-
|
|
55
|
-
/** The library's event payloads are `unknown` here — read structurally, never cast. */
|
|
56
|
-
const asRequest = (payload: unknown): CdpRequestLike | undefined => {
|
|
57
|
-
if (typeof payload !== 'object' || payload === null) return undefined;
|
|
58
|
-
const candidate = payload as Partial<CdpRequestLike>;
|
|
59
|
-
return typeof candidate.url === 'function' && typeof candidate.abort === 'function'
|
|
60
|
-
? (candidate as CdpRequestLike)
|
|
61
|
-
: undefined;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* CDP's console levels, mapped onto this package's five. `warning` is the library's spelling of
|
|
66
|
-
* `warn`, `verbose` of `debug`, and everything structural (`table`, `startGroup`, `dir`) is a log
|
|
67
|
-
* line with a shape — never its own level, because `ConsoleLine.level` is what an author filters on.
|
|
68
|
-
*/
|
|
69
|
-
const CONSOLE_LEVELS: Readonly<Record<string, ConsoleLine['level']>> = {
|
|
70
|
-
error: 'error',
|
|
71
|
-
assert: 'error',
|
|
72
|
-
warning: 'warn',
|
|
73
|
-
warn: 'warn',
|
|
74
|
-
info: 'info',
|
|
75
|
-
debug: 'debug',
|
|
76
|
-
verbose: 'debug',
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* `Object.hasOwn`, never the read alone: the type word arrives off the WIRE, so
|
|
81
|
-
* `CONSOLE_LEVELS['__proto__']` answered `Object.prototype` and `['constructor']` the `Object`
|
|
82
|
-
* function — neither of which a `?? 'log'` fallback can rescue, because neither is `undefined`.
|
|
83
|
-
* `ConsoleLine.level` would then hold a value its own type says is one of five words, so the
|
|
84
|
-
* `level === 'error'` filter this ring exists for matched nothing and `JSON.stringify` dropped
|
|
85
|
-
* the field from a snapshot outright. Lowercasing is not the guard: `__proto__` and `constructor`
|
|
86
|
-
* are already lowercase. Same discriminator as `packages/flags/src/subject.ts`.
|
|
87
|
-
*/
|
|
88
|
-
const consoleLevel = (type: string): ConsoleLine['level'] => {
|
|
89
|
-
const word = type.toLowerCase();
|
|
90
|
-
return Object.hasOwn(CONSOLE_LEVELS, word) ? (CONSOLE_LEVELS[word] ?? 'log') : 'log';
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Reads a string out of somebody else's event payload, calling an accessor THROUGH ITS OWNER.
|
|
95
|
-
*
|
|
96
|
-
* `HTTPRequest.method()` and `ConsoleMessage.type()`/`.text()` read `this` — they are methods on
|
|
97
|
-
* the library's own objects, not closures over a value. Handing the bare function to a helper
|
|
98
|
-
* (`readString(request.method)`) drops the receiver, so the accessor answers against `undefined`:
|
|
99
|
-
* on one build that throws inside the interception handler, on another it answers wrong.
|
|
100
|
-
*/
|
|
101
|
-
const readStringFrom = (owner: unknown, key: string): string | undefined => {
|
|
102
|
-
if (typeof owner !== 'object' || owner === null) return undefined;
|
|
103
|
-
const value = (owner as Record<string, unknown>)[key];
|
|
104
|
-
if (typeof value === 'string') return value;
|
|
105
|
-
if (typeof value !== 'function') return undefined;
|
|
106
|
-
const answer = (value as (this: unknown) => unknown).call(owner);
|
|
107
|
-
return typeof answer === 'string' ? answer : undefined;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* A `pageerror` payload, read defensively — never cast, and never assumed to be an `Error`.
|
|
112
|
-
*
|
|
113
|
-
* `readStringFrom`, the same reader the console handler uses, because the payload has the same
|
|
114
|
-
* problem: `message` and `stack` are an own property on one build and an accessor on another, and
|
|
115
|
-
* a schema parse cannot call an accessor. A page can also `throw 'a string'` or throw a frozen
|
|
116
|
-
* object with no `message` at all — both reach here, and both are recorded as SOMETHING having
|
|
117
|
-
* thrown, because an entry with a poor message is still the difference between "the island threw"
|
|
118
|
-
* and silence.
|
|
119
|
-
*/
|
|
120
|
-
const readPageError = (payload: unknown, at: number): PageError => {
|
|
121
|
-
if (typeof payload === 'string') return pageErrorEntry({ message: payload, at });
|
|
122
|
-
return pageErrorEntry({
|
|
123
|
-
message: readStringFrom(payload, 'message') ?? '',
|
|
124
|
-
stack: readStringFrom(payload, 'stack'),
|
|
125
|
-
at,
|
|
126
|
-
});
|
|
127
|
-
};
|
|
128
|
-
|
|
129
51
|
/**
|
|
130
52
|
* The failures `guard()` must NOT re-label. Two codes, each because a SECOND attempt reaches the
|
|
131
53
|
* identical answer — never because the error looked coded.
|
|
@@ -167,81 +89,8 @@ const isStructuralRefusal = (thrown: unknown): boolean =>
|
|
|
167
89
|
* of three drivers a bound the other two ignore, which is precisely the divergence
|
|
168
90
|
* `driver-parity.test.ts` exists to refuse. `DEFAULT_RING_CAPACITY` is the one bound.
|
|
169
91
|
*/
|
|
170
|
-
export interface CdpTargetInit {
|
|
171
|
-
readonly page: CdpPageLike;
|
|
92
|
+
export interface CdpTargetInit extends CdpArmInit {
|
|
172
93
|
readonly browser: CdpBrowserLike;
|
|
173
|
-
readonly rules: InterceptRules;
|
|
174
|
-
readonly clock: ScrapeClock;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Everything `arm()` writes into. Named rather than positional: three rings of near-identical
|
|
179
|
-
* type plus a latch is a call site nobody can read, and swapping two of them is a mistake the
|
|
180
|
-
* compiler cannot catch.
|
|
181
|
-
*/
|
|
182
|
-
interface CdpSinks {
|
|
183
|
-
readonly network: NetworkRing;
|
|
184
|
-
readonly console: ConsoleRing;
|
|
185
|
-
readonly pageErrors: PageErrorRing;
|
|
186
|
-
readonly crashed: { value: string | undefined };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Interception is armed BEFORE the first navigation and refuses at the request, not after the
|
|
191
|
-
* response — an `allowHosts` that reported afterwards would be a log line about bytes that
|
|
192
|
-
* already left the container.
|
|
193
|
-
*/
|
|
194
|
-
async function arm(init: CdpTargetInit, sinks: CdpSinks): Promise<void> {
|
|
195
|
-
const { network, console: console_, pageErrors, crashed } = sinks;
|
|
196
|
-
await init.page.setRequestInterception(true);
|
|
197
|
-
init.page.on('request', (payload) => {
|
|
198
|
-
const request = asRequest(payload);
|
|
199
|
-
if (request === undefined) return;
|
|
200
|
-
const url = request.url();
|
|
201
|
-
const type = asResourceType(request.resourceType());
|
|
202
|
-
// The METHOD the browser is actually sending. Recording every request as a GET made
|
|
203
|
-
// `page.network()` — which `X_SCRAPE_HTTP_FAILED`'s own fix line tells the reader to open —
|
|
204
|
-
// misreport every POST and PUT the page made.
|
|
205
|
-
const method = readStringFrom(request, 'method') ?? 'GET';
|
|
206
|
-
const verdict = interceptVerdict(url, type, init.rules);
|
|
207
|
-
const at = init.clock.now().getTime();
|
|
208
|
-
if (verdict === 'allow') {
|
|
209
|
-
network.push({ method, url, resourceType: type, at });
|
|
210
|
-
void request.continue();
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
network.push(refusalEntry(url, type, verdict, at, method));
|
|
214
|
-
void request.abort();
|
|
215
|
-
});
|
|
216
|
-
init.page.on('console', (payload) => {
|
|
217
|
-
console_.push({
|
|
218
|
-
level: consoleLevel(readStringFrom(payload, 'type') ?? ''),
|
|
219
|
-
text: readStringFrom(payload, 'text') ?? '',
|
|
220
|
-
at: init.clock.now().getTime(),
|
|
221
|
-
});
|
|
222
|
-
});
|
|
223
|
-
/**
|
|
224
|
-
* The page threw and nothing caught it. THE gap this ring closes: a screenshot of an island
|
|
225
|
-
* that threw during hydration is a picture of the server-rendered markup, indistinguishable
|
|
226
|
-
* from a page that worked — and `console` does not carry it, because throwing calls no console
|
|
227
|
-
* method. Subscribed here, beside the others, so a target is observing before its first
|
|
228
|
-
* navigation: an exception raised during load has no second chance to be recorded.
|
|
229
|
-
*
|
|
230
|
-
* NOT the same event as `error` below, and the difference is the whole reason this is a
|
|
231
|
-
* separate handler: puppeteer's `pageerror` is "an uncaught exception happens within the page"
|
|
232
|
-
* and its `error` is "the page crashes" (`PageEvent.PageError` / `PageEvent.Error`). One is the
|
|
233
|
-
* app being broken and the session is fine; the other is the tab being gone. Recording a
|
|
234
|
-
* `pageerror` into `crashed` would make every scrape of a page with one bad island answer
|
|
235
|
-
* X_SCRAPE_PAGE_CRASHED — a code registered `terminal` — for a page still perfectly usable.
|
|
236
|
-
*/
|
|
237
|
-
init.page.on('pageerror', (payload) => {
|
|
238
|
-
pageErrors.push(readPageError(payload, init.clock.now().getTime()));
|
|
239
|
-
});
|
|
240
|
-
// A renderer that dies must be a CODE, not a hang: every later call answers X_SCRAPE_PAGE_CRASHED
|
|
241
|
-
// instead of waiting out its own timeout against a tab that is gone.
|
|
242
|
-
init.page.on('error', (payload) => {
|
|
243
|
-
crashed.value = readStringFrom(payload, 'message') ?? 'renderer crashed';
|
|
244
|
-
});
|
|
245
94
|
}
|
|
246
95
|
|
|
247
96
|
export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
@@ -319,7 +168,26 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
319
168
|
await frame.select(selector, ...values);
|
|
320
169
|
}),
|
|
321
170
|
evaluate: (expression) => guard('evaluate', () => frame.evaluate(expression)),
|
|
171
|
+
// The PAGE's, listed rather than inherited so the next reader sees it was decided: a browser
|
|
172
|
+
// has one keyboard, and the focused element — set with the frame's own `focus` below — is what
|
|
173
|
+
// routes the chord into the frame's document.
|
|
174
|
+
press: (chord) => parent.press(chord),
|
|
322
175
|
frames: () => Promise.resolve([]),
|
|
176
|
+
focus: (selector) =>
|
|
177
|
+
guard('focus', async () => {
|
|
178
|
+
await frame.evaluate(focusExpression(selector));
|
|
179
|
+
}),
|
|
180
|
+
// `Accessibility.getPartialAXTree` is addressed by backend node id and `DOM.querySelectorAll`
|
|
181
|
+
// by a document's node id, so a frame read needs the frame's own document — a second
|
|
182
|
+
// `DOM.getDocument` scoped by frame id that `cdp-a11y.ts` does not perform. Refused by name
|
|
183
|
+
// rather than answered from the PARENT document, which is what the spread alone would do.
|
|
184
|
+
accessibility: (_selector, _max): Promise<readonly AxNode[]> =>
|
|
185
|
+
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
|
+
),
|
|
190
|
+
),
|
|
323
191
|
});
|
|
324
192
|
|
|
325
193
|
const target: ScrapeTarget = {
|
|
@@ -351,6 +219,59 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
351
219
|
await init.page.select(selector, ...values);
|
|
352
220
|
}),
|
|
353
221
|
evaluate: (expression) => guard('evaluate', () => init.page.evaluate(expression)),
|
|
222
|
+
// `async`, and the parse OUTSIDE `guard()`: the chord is the caller's literal, not the
|
|
223
|
+
// browser's answer, so a bad one is `X_SCRAPE_KEY_INVALID` and never re-labelled "the browser
|
|
224
|
+
// went away" — and it is parsed before the keyboard is touched, because a refusal halfway
|
|
225
|
+
// through the sequence below would leave a modifier held for every later verb on this page.
|
|
226
|
+
press: async (chord: string): Promise<void> => {
|
|
227
|
+
const { modifiers, key } = parseKeyChord(chord);
|
|
228
|
+
await guard('press', async () => {
|
|
229
|
+
const keyboard = init.page.keyboard;
|
|
230
|
+
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
|
+
);
|
|
235
|
+
}
|
|
236
|
+
// Down in the order written, the key, then up in REVERSE — the order a hand releases
|
|
237
|
+
// them, and the order the browser's own `press` with modifiers performs. `.call`-free:
|
|
238
|
+
// `keyboard` is read as an object and its methods are called through it.
|
|
239
|
+
for (const modifier of modifiers) await keyboard.down(modifier);
|
|
240
|
+
try {
|
|
241
|
+
await keyboard.press(key);
|
|
242
|
+
} finally {
|
|
243
|
+
for (const modifier of [...modifiers].reverse()) await keyboard.up(modifier);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
focus: (selector: string): Promise<void> =>
|
|
248
|
+
guard('focus', async () => {
|
|
249
|
+
const focus = init.page.focus;
|
|
250
|
+
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
|
+
);
|
|
255
|
+
}
|
|
256
|
+
// `.call`, for `setColorScheme`'s reason: the member is read off the object.
|
|
257
|
+
await focus.call(init.page, selector);
|
|
258
|
+
}),
|
|
259
|
+
accessibility: (selector: string, max: number): Promise<readonly AxNode[]> =>
|
|
260
|
+
guard('accessibility', async () => {
|
|
261
|
+
const createSession = init.page.createCDPSession;
|
|
262
|
+
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
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const session = await createSession.call(init.page);
|
|
269
|
+
try {
|
|
270
|
+
return await axNodesFor(session, selector, max);
|
|
271
|
+
} finally {
|
|
272
|
+
await session.detach();
|
|
273
|
+
}
|
|
274
|
+
}),
|
|
354
275
|
setOfflineMode: (enabled: boolean): Promise<void> =>
|
|
355
276
|
guard('setOfflineMode', async () => {
|
|
356
277
|
const source = init.page as { setOfflineMode?: (value: boolean) => Promise<void> };
|
package/src/error-throws.ts
CHANGED
|
@@ -437,3 +437,17 @@ export const captureClipOnPdf = (clip: CaptureClip): ScrapeError =>
|
|
|
437
437
|
fix: 'call page.screenshot({ clip }) for one component, or page.pdf() with no clip for the document',
|
|
438
438
|
meta: { clip: { ...clip } },
|
|
439
439
|
});
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* A chord that cannot be pressed. Refused before ANY key goes down, which is the reason it is a
|
|
443
|
+
* parse and not a try: `press()` holds every modifier, presses the key, then releases — and a
|
|
444
|
+
* chord refused halfway through would leave `Control` held on the page for every later verb.
|
|
445
|
+
* Terminal for `X_SCRAPE_CAPTURE_INVALID`'s reason: the chord is the caller's own literal.
|
|
446
|
+
*/
|
|
447
|
+
export const keyInvalid = (chord: string, reason: string): ScrapeError =>
|
|
448
|
+
new ScrapeError({
|
|
449
|
+
code: 'X_SCRAPE_KEY_INVALID',
|
|
450
|
+
cause: `the key chord ${JSON.stringify(chord)} ${reason}`,
|
|
451
|
+
fix: "spell the chord as zero or more of Meta, Control, Alt, Shift joined by '+' and then the key — 'Meta+K', 'Escape', 'Shift+Tab' — using the browser's own key names",
|
|
452
|
+
meta: { chord },
|
|
453
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -38,6 +38,7 @@ export const SCRAPE_OWNED_ERROR_CODES = [
|
|
|
38
38
|
'X_SCRAPE_SESSION_EXPIRED',
|
|
39
39
|
'X_SCRAPE_PROMPT_UNANSWERED',
|
|
40
40
|
'X_SCRAPE_BLOCKED',
|
|
41
|
+
'X_SCRAPE_KEY_INVALID',
|
|
41
42
|
] as const;
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -89,6 +90,7 @@ export const SCRAPE_ERROR_TITLES: Readonly<Record<ScrapeOwnedErrorCode, string>>
|
|
|
89
90
|
X_SCRAPE_SESSION_EXPIRED: 'the restored session is no longer valid and nothing can renew it',
|
|
90
91
|
X_SCRAPE_PROMPT_UNANSWERED: 'a login step asked for a code and nothing answered',
|
|
91
92
|
X_SCRAPE_BLOCKED: 'the site refused this client — the identity is spent',
|
|
93
|
+
X_SCRAPE_KEY_INVALID: 'the key chord names no key a browser can press',
|
|
92
94
|
};
|
|
93
95
|
|
|
94
96
|
// One unconditional call, so a second package claiming one of these codes throws
|
|
@@ -186,6 +188,10 @@ export const SCRAPE_ERROR_RETRY = {
|
|
|
186
188
|
X_SCRAPE_AUTH_FAILED: 'terminal',
|
|
187
189
|
X_SCRAPE_SESSION_EXPIRED: 'terminal',
|
|
188
190
|
X_SCRAPE_PROMPT_UNANSWERED: 'terminal',
|
|
191
|
+
// A declaration error, for `X_SCRAPE_CAPTURE_INVALID`'s reason: the chord is the caller's own
|
|
192
|
+
// literal, attempt 2 passes the identical string, and a retry is a browser launch for no chance
|
|
193
|
+
// of a different parse. Refused before any key goes down, so no modifier is left held.
|
|
194
|
+
X_SCRAPE_KEY_INVALID: 'terminal',
|
|
189
195
|
} as const satisfies Readonly<Record<ScrapeOwnedErrorCode, 'retryable' | 'terminal'>>;
|
|
190
196
|
|
|
191
197
|
registerErrorRetry(SCRAPE_ERROR_RETRY);
|
package/src/html-target.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { queryHtml } from './html-query';
|
|
|
19
19
|
import { markupRequests } from './html-requests';
|
|
20
20
|
import type { InterceptRules } from './intercept';
|
|
21
21
|
import { interceptVerdict, refusalEntry } from './intercept';
|
|
22
|
+
import { parseKeyChord } from './key-chord';
|
|
22
23
|
import type { PageRecording } from './recording';
|
|
23
24
|
import { splitDownload } from './recording';
|
|
24
25
|
import type { ConsoleLine, NetworkEntry, PageError } from './rings';
|
|
@@ -26,6 +27,7 @@ import { createRing } from './rings';
|
|
|
26
27
|
import type { SessionSnapshot } from './session-state';
|
|
27
28
|
import { EMPTY_SESSION } from './session-state';
|
|
28
29
|
import type {
|
|
30
|
+
AxNode,
|
|
29
31
|
CaptureOptions,
|
|
30
32
|
ElementSnapshot,
|
|
31
33
|
FrameRef,
|
|
@@ -296,6 +298,12 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
|
|
|
296
298
|
type: (selector, text) => typeIn(document, selector, text),
|
|
297
299
|
clear: (selector) => setIn(document, selector, ''),
|
|
298
300
|
select: (selector, values) => setIn(document, selector, values[0] ?? ''),
|
|
301
|
+
// The frame's OWN document, for `click`'s reason: inheriting `base.focus` would refuse a
|
|
302
|
+
// selector that exists only in the frame, and accept one that exists only in the parent.
|
|
303
|
+
async focus(selector: string): Promise<void> {
|
|
304
|
+
const element = await atIn(document, selector, 0);
|
|
305
|
+
if (element === undefined) throw fixtureMissing(`${url} ${selector}`, init.source);
|
|
306
|
+
},
|
|
299
307
|
frames: () => Promise.resolve([]),
|
|
300
308
|
};
|
|
301
309
|
};
|
|
@@ -326,6 +334,35 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
|
|
|
326
334
|
clear: (selector: string): Promise<void> => setIn(pageDocument, selector, ''),
|
|
327
335
|
select: (selector: string, values: readonly string[]): Promise<void> =>
|
|
328
336
|
setIn(pageDocument, selector, values[0] ?? ''),
|
|
337
|
+
/**
|
|
338
|
+
* PARSED and then resolved, for `setColorScheme`'s reason and not `setOfflineMode`'s. There is
|
|
339
|
+
* no JS engine here, so no `keydown` listener can hear the chord — but the one thing this
|
|
340
|
+
* driver CAN be wrong about, the chord's spelling, is checked by the same `parseKeyChord` the
|
|
341
|
+
* real driver runs, so `'Ctrl+K'` is `X_SCRAPE_KEY_INVALID` offline exactly as it is live. What
|
|
342
|
+
* it cannot prove is that the page reacted; a test that needs that runs on `localBrowser()`.
|
|
343
|
+
*/
|
|
344
|
+
press(chord: string): Promise<void> {
|
|
345
|
+
live();
|
|
346
|
+
parseKeyChord(chord);
|
|
347
|
+
return Promise.resolve();
|
|
348
|
+
},
|
|
349
|
+
/** The selector must exist, as `click` requires — a focus on nothing is a fixture gap. */
|
|
350
|
+
async focus(selector: string): Promise<void> {
|
|
351
|
+
const element = await at(selector, 0);
|
|
352
|
+
if (element === undefined) throw fixtureMissing(`${page.url} ${selector}`, init.source);
|
|
353
|
+
},
|
|
354
|
+
/**
|
|
355
|
+
* REFUSED, and `async` so it REJECTS. No accessibility engine runs here, and the honest
|
|
356
|
+
* alternative — reading `role=` and `aria-label=` off the markup — is the defect the read
|
|
357
|
+
* exists to catch: a `<div onclick>` has no computed role, and a fake that answered the
|
|
358
|
+
* attribute would pass every test against the element a screen reader cannot reach.
|
|
359
|
+
*/
|
|
360
|
+
async accessibility(_selector: string, _max: number): Promise<readonly AxNode[]> {
|
|
361
|
+
throw scrapeNotImplemented(
|
|
362
|
+
`accessibility() on the ${init.driver} driver`,
|
|
363
|
+
'run this assertion on localBrowser()/remoteBrowser(), whose accessibility() reads the tree the browser computed — an offline driver parses markup and computes no role',
|
|
364
|
+
);
|
|
365
|
+
},
|
|
329
366
|
evaluate(expression: string): Promise<unknown> {
|
|
330
367
|
live();
|
|
331
368
|
const answer = recorded(page.evaluate, expression);
|
package/src/index.ts
CHANGED
|
@@ -21,15 +21,23 @@ export { burnSession, createPrompt, ensureAuthenticated, restorableSession } fro
|
|
|
21
21
|
export { browserRecord } from './browser-record';
|
|
22
22
|
export type { CaptureClip, CaptureFraming } from './capture-clip';
|
|
23
23
|
export { assertCaptureFraming } from './capture-clip';
|
|
24
|
+
export { axNodesFor } from './cdp-a11y';
|
|
24
25
|
export type {
|
|
25
26
|
CdpBrowserLike,
|
|
26
27
|
CdpFrameLike,
|
|
28
|
+
CdpKeyboardLike,
|
|
27
29
|
CdpLauncherLike,
|
|
28
30
|
CdpPageLike,
|
|
29
31
|
CdpRequestLike,
|
|
30
32
|
CdpScreenshotOptions,
|
|
33
|
+
CdpSessionLike,
|
|
31
34
|
} from './cdp-port';
|
|
32
|
-
export {
|
|
35
|
+
export {
|
|
36
|
+
clearExpression,
|
|
37
|
+
focusExpression,
|
|
38
|
+
parseSnapshots,
|
|
39
|
+
snapshotExpression,
|
|
40
|
+
} from './cdp-snapshot';
|
|
33
41
|
export type { CdpTargetInit } from './cdp-target';
|
|
34
42
|
export { CDP_DRIVER, cdpTarget } from './cdp-target';
|
|
35
43
|
export type { Deadline, ScrapeClock, TestScrapeClock } from './clock';
|
|
@@ -62,6 +70,7 @@ export {
|
|
|
62
70
|
fixtureStale,
|
|
63
71
|
hostBlocked,
|
|
64
72
|
httpFailed,
|
|
73
|
+
keyInvalid,
|
|
65
74
|
notActionable,
|
|
66
75
|
outputInvalid,
|
|
67
76
|
pageCrashed,
|
|
@@ -117,9 +126,12 @@ export type { RedirectHop } from './http-redirect';
|
|
|
117
126
|
export { MAX_REDIRECT_HOPS, redirectHop } from './http-redirect';
|
|
118
127
|
export type { InterceptRules, InterceptVerdict } from './intercept';
|
|
119
128
|
export { interceptVerdict, refusalEntry } from './intercept';
|
|
129
|
+
export type { KeyChord, KeyModifier } from './key-chord';
|
|
130
|
+
export { KEY_MODIFIERS, parseKeyChord } from './key-chord';
|
|
120
131
|
export type { OfflineSessionInit } from './offline-session';
|
|
121
132
|
export { openOfflineSession } from './offline-session';
|
|
122
133
|
export type {
|
|
134
|
+
AccessibilityOptions,
|
|
123
135
|
CaptureRequest,
|
|
124
136
|
DownloadRequest,
|
|
125
137
|
ElementValue,
|
|
@@ -127,6 +139,7 @@ export type {
|
|
|
127
139
|
ScrapePage,
|
|
128
140
|
WaitOptions,
|
|
129
141
|
} from './page';
|
|
142
|
+
export { DEFAULT_ACCESSIBILITY_MAX } from './page';
|
|
130
143
|
export type { PageContext } from './page-over-target';
|
|
131
144
|
export { pageOverTarget } from './page-over-target';
|
|
132
145
|
export type { Pacer } from './rate';
|
|
@@ -197,6 +210,7 @@ export {
|
|
|
197
210
|
storageSessionStore,
|
|
198
211
|
} from './session-state';
|
|
199
212
|
export type {
|
|
213
|
+
AxNode,
|
|
200
214
|
CaptureOptions,
|
|
201
215
|
ElementBox,
|
|
202
216
|
ElementSnapshot,
|
package/src/key-chord.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// A key chord, parsed ONCE and refused before any key goes down.
|
|
2
|
+
//
|
|
3
|
+
// `'Meta+K'` is the spelling every scraper in the audit wrote by hand, and every one of them split
|
|
4
|
+
// on `+` and handed the pieces to the browser unchecked — so `'Ctrl+K'` held nothing (the browser
|
|
5
|
+
// knows `Control`, not `Ctrl`), pressed `K`, and the test went green with the palette closed. The
|
|
6
|
+
// grammar is small enough to state in full: zero or more modifiers, joined by `+`, then one key,
|
|
7
|
+
// spelled the way the browser spells it. The parse is the whole check, and it runs on every driver
|
|
8
|
+
// — an offline driver has no keyboard, but it can still tell a caller the chord is wrong.
|
|
9
|
+
|
|
10
|
+
import { keyInvalid } from './error-throws';
|
|
11
|
+
|
|
12
|
+
/** The four modifiers a browser holds, under the names the browser's own keyboard uses. */
|
|
13
|
+
export const KEY_MODIFIERS = ['Meta', 'Control', 'Alt', 'Shift'] as const;
|
|
14
|
+
|
|
15
|
+
export type KeyModifier = (typeof KEY_MODIFIERS)[number];
|
|
16
|
+
|
|
17
|
+
export interface KeyChord {
|
|
18
|
+
/** In the order written, which is the order they go down; they come up in reverse. */
|
|
19
|
+
readonly modifiers: readonly KeyModifier[];
|
|
20
|
+
/** The browser's own key name — `K`, `Enter`, `Escape`, `ArrowDown`. Never empty. */
|
|
21
|
+
readonly key: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isModifier = (word: string): word is KeyModifier =>
|
|
25
|
+
(KEY_MODIFIERS as readonly string[]).includes(word);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `'Meta+K'` → `{ modifiers: ['Meta'], key: 'K' }`. Refuses, with `X_SCRAPE_KEY_INVALID`, an
|
|
29
|
+
* unknown modifier, an empty key, a trailing `+`, and a modifier written twice — each is a chord
|
|
30
|
+
* the browser would hold and release differently from what the caller meant, and a chord is the
|
|
31
|
+
* caller's own literal, so the refusal is terminal.
|
|
32
|
+
*
|
|
33
|
+
* The literal `+` key has no spelling in this grammar: `'+'` splits to nothing. Type it with
|
|
34
|
+
* `page.type()`, which is what a character is for; a chord is for a key the page listens to.
|
|
35
|
+
*/
|
|
36
|
+
export function parseKeyChord(chord: string): KeyChord {
|
|
37
|
+
if (chord === '') throw keyInvalid(chord, 'is empty, so there is no key to press');
|
|
38
|
+
const parts = chord.split('+');
|
|
39
|
+
const key = parts.at(-1) ?? '';
|
|
40
|
+
if (key === '') {
|
|
41
|
+
throw keyInvalid(
|
|
42
|
+
chord,
|
|
43
|
+
"ends in '+', so it names modifiers and no key — the last segment is the key to press",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const modifiers: KeyModifier[] = [];
|
|
47
|
+
for (const word of parts.slice(0, -1)) {
|
|
48
|
+
if (!isModifier(word)) {
|
|
49
|
+
throw keyInvalid(
|
|
50
|
+
chord,
|
|
51
|
+
`names ${JSON.stringify(word)} as a modifier, and the browser holds only ${KEY_MODIFIERS.join(', ')}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (modifiers.includes(word)) {
|
|
55
|
+
throw keyInvalid(chord, `holds ${word} twice, and a key held twice comes up once`);
|
|
56
|
+
}
|
|
57
|
+
modifiers.push(word);
|
|
58
|
+
}
|
|
59
|
+
return { modifiers, key };
|
|
60
|
+
}
|
package/src/page-over-target.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// driver a test uses and the driver production uses.
|
|
5
5
|
|
|
6
6
|
import type { Secret } from '@ultimat3/core';
|
|
7
|
-
import { isSecret, revealSecret } from '@ultimat3/core';
|
|
7
|
+
import { finiteCount, isSecret, revealSecret } from '@ultimat3/core';
|
|
8
8
|
import type { ActionabilityState } from './actionability';
|
|
9
9
|
import { awaitActionable } from './actionability';
|
|
10
10
|
import { assertCaptureFraming } from './capture-clip';
|
|
@@ -14,6 +14,7 @@ import type { ColorScheme } from './color-scheme';
|
|
|
14
14
|
import { hostBlocked, secretExposed, selectorMissing } from './error-throws';
|
|
15
15
|
import { hostDecision } from './hosts';
|
|
16
16
|
import type {
|
|
17
|
+
AccessibilityOptions,
|
|
17
18
|
CaptureRequest,
|
|
18
19
|
DownloadRequest,
|
|
19
20
|
ElementValue,
|
|
@@ -21,10 +22,12 @@ import type {
|
|
|
21
22
|
ScrapePage,
|
|
22
23
|
WaitOptions,
|
|
23
24
|
} from './page';
|
|
25
|
+
import { DEFAULT_ACCESSIBILITY_MAX } from './page';
|
|
24
26
|
import type { RobotsGate } from './robots';
|
|
25
27
|
import type { ScrapeSecrets } from './secrets';
|
|
26
28
|
import { safeConsole, safeHtml, safeNetwork, safePageErrors } from './secrets';
|
|
27
29
|
import type {
|
|
30
|
+
AxNode,
|
|
28
31
|
CaptureOptions,
|
|
29
32
|
ElementSnapshot,
|
|
30
33
|
ScrapeCookie,
|
|
@@ -131,6 +134,28 @@ function frameOver(
|
|
|
131
134
|
await wait(selector, options, 'actionable');
|
|
132
135
|
await (await resolve()).select(selector, values);
|
|
133
136
|
},
|
|
137
|
+
// `actionable`, like `click`: focus moved to a hidden or disabled control is focus the page
|
|
138
|
+
// will not keep, and the chord that follows lands on the body.
|
|
139
|
+
async focus(selector, options): Promise<void> {
|
|
140
|
+
await wait(selector, options, 'actionable');
|
|
141
|
+
await (await resolve()).focus(selector);
|
|
142
|
+
},
|
|
143
|
+
// `async`, for `download()`'s reason: a third-party target that throws synchronously from a
|
|
144
|
+
// promise-typed method must still reach the caller's `.catch()`.
|
|
145
|
+
async press(chord): Promise<void> {
|
|
146
|
+
await (await resolve()).press(chord);
|
|
147
|
+
},
|
|
148
|
+
async accessibility(selector, options?: AccessibilityOptions): Promise<readonly AxNode[]> {
|
|
149
|
+
// Screened, never `??`-defaulted alone: `NaN` is not nullish, and `slice(0, NaN)` is
|
|
150
|
+
// `slice(0, 0)` — an empty answer that reads as "nothing matched" (`finiteCount`, core).
|
|
151
|
+
const max = finiteCount(
|
|
152
|
+
'page.accessibility()',
|
|
153
|
+
'max',
|
|
154
|
+
options?.max ?? DEFAULT_ACCESSIBILITY_MAX,
|
|
155
|
+
1,
|
|
156
|
+
);
|
|
157
|
+
return await (await resolve()).accessibility(selector, max);
|
|
158
|
+
},
|
|
134
159
|
// Resolved through `resolve()` like every other verb, which is what makes a FRAME's `query`
|
|
135
160
|
// read the frame's document: `resolve` is `resolveChild` there, and both drivers override
|
|
136
161
|
// `query` on the frame target. A forward that reached for a captured page target instead
|
package/src/page.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { CaptureFraming } from './capture-clip';
|
|
|
13
13
|
import type { ColorScheme } from './color-scheme';
|
|
14
14
|
import type { ConsoleLine, NetworkEntry, PageError } from './rings';
|
|
15
15
|
import type { SessionSnapshot } from './session-state';
|
|
16
|
-
import type { ElementSnapshot, ScrapeCookie, ScrapeDownloadFile } from './target';
|
|
16
|
+
import type { AxNode, ElementSnapshot, ScrapeCookie, ScrapeDownloadFile } from './target';
|
|
17
17
|
|
|
18
18
|
export interface WaitOptions {
|
|
19
19
|
readonly state?: ActionabilityState | undefined;
|
|
@@ -21,6 +21,13 @@ export interface WaitOptions {
|
|
|
21
21
|
readonly timeout?: number | undefined;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** How many matches `accessibility()` describes. A bound, because each match is a round trip. */
|
|
25
|
+
export interface AccessibilityOptions {
|
|
26
|
+
readonly max?: number | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_ACCESSIBILITY_MAX = 25;
|
|
30
|
+
|
|
24
31
|
export interface ElementValue {
|
|
25
32
|
readonly tag: string;
|
|
26
33
|
readonly text: string;
|
|
@@ -49,6 +56,28 @@ export interface ScrapeFrame {
|
|
|
49
56
|
/** Clears first, then types — the spelling a login form wants. */
|
|
50
57
|
fill(selector: string, text: string | Secret, options?: WaitOptions): Promise<void>;
|
|
51
58
|
select(selector: string, values: readonly string[], options?: WaitOptions): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Waits for the element to be actionable, then moves focus to it — the setup for a `press()`,
|
|
61
|
+
* and the half of keyboard navigation a click cannot stand in for.
|
|
62
|
+
*/
|
|
63
|
+
focus(selector: string, options?: WaitOptions): Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* A key chord on whatever holds focus: `'Meta+K'`, `'Escape'`, `'Shift+Tab'`, `'Enter'`.
|
|
66
|
+
* Modifiers are `Meta`, `Control`, `Alt`, `Shift`, in the browser's own spelling — `'Ctrl+K'`
|
|
67
|
+
* is `X_SCRAPE_KEY_INVALID` on every driver, offline included, because the parse is the one
|
|
68
|
+
* thing an offline driver can be wrong about. The browser has ONE keyboard, so a chord pressed
|
|
69
|
+
* through a frame handle reaches whichever element that frame's `focus()` put focus on.
|
|
70
|
+
*/
|
|
71
|
+
press(chord: string): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* What a screen reader is told about each match — the browser's computed role and name, after
|
|
74
|
+
* ARIA and label association, which `query()` cannot answer from attributes. At most
|
|
75
|
+
* `options.max` (default `DEFAULT_ACCESSIBILITY_MAX`) nodes, in document order. Refused with
|
|
76
|
+
* `X_NOT_IMPLEMENTED` on a driver with no accessibility engine — an offline driver, or a frame
|
|
77
|
+
* of the real one — never answered from the markup: a `<div onclick>` computing no role IS the
|
|
78
|
+
* finding, and a fake reading `role=` off the tag would hide it.
|
|
79
|
+
*/
|
|
80
|
+
accessibility(selector: string, options?: AccessibilityOptions): Promise<readonly AxNode[]>;
|
|
52
81
|
/**
|
|
53
82
|
* Every match, as SNAPSHOTS — `visible`, `enabled` and (on a driver with a layout engine) the
|
|
54
83
|
* box and hit-target, which `values()` projects away.
|
package/src/target.ts
CHANGED
|
@@ -47,6 +47,24 @@ export interface ElementSnapshot {
|
|
|
47
47
|
readonly hitTarget?: boolean | undefined;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* One node of the browser's accessibility tree, as of one observation — what a screen reader is
|
|
52
|
+
* told about an element, which is a different fact from what `ElementSnapshot` says about its
|
|
53
|
+
* markup. `role` and `name` are always answered, `''` when the browser computed nothing: absent is
|
|
54
|
+
* a real answer for a `<div>`, and a fabricated `generic` would hide the elements a reader cannot
|
|
55
|
+
* name. A VALUE, like `ElementSnapshot`: it cannot go stale behind the caller's back, only old.
|
|
56
|
+
*/
|
|
57
|
+
export interface AxNode {
|
|
58
|
+
readonly role: string;
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly description?: string | undefined;
|
|
61
|
+
readonly value?: string | undefined;
|
|
62
|
+
readonly focused?: boolean | undefined;
|
|
63
|
+
readonly disabled?: boolean | undefined;
|
|
64
|
+
/** Pruned from the tree a reader walks — `aria-hidden`, or a wrapper with nothing to say. */
|
|
65
|
+
readonly ignored: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
50
68
|
export interface ScrapeCookie {
|
|
51
69
|
readonly name: string;
|
|
52
70
|
readonly value: string;
|
|
@@ -133,6 +151,25 @@ export interface ScrapeTarget {
|
|
|
133
151
|
select(selector: string, values: readonly string[]): Promise<void>;
|
|
134
152
|
/** The expression runs in the page. The result is `unknown` and is parsed by the caller. */
|
|
135
153
|
evaluate(expression: string): Promise<unknown>;
|
|
154
|
+
/**
|
|
155
|
+
* A key chord — `'Meta+K'`, `'Escape'`, `'Shift+Tab'` — pressed on whatever holds focus. The
|
|
156
|
+
* PAGE's keyboard, on a frame target too: a browser has one keyboard, and the focused element
|
|
157
|
+
* is what decides which document hears it. REQUIRED here where `CdpPageLike.keyboard` is
|
|
158
|
+
* optional, for `setOfflineMode`'s reason: the asymmetry is the enforcement. A driver with no
|
|
159
|
+
* keyboard still PARSES the chord (`key-chord.ts`) and then resolves — the refusal an offline
|
|
160
|
+
* driver can give is the one it does give.
|
|
161
|
+
*/
|
|
162
|
+
press(chord: string): Promise<void>;
|
|
163
|
+
/** Moves focus to the first match. Refused when nothing matches. */
|
|
164
|
+
focus(selector: string): Promise<void>;
|
|
165
|
+
/**
|
|
166
|
+
* The accessibility node of every match, bounded by `max` — the browser's own computed role and
|
|
167
|
+
* name, which no HTML parse can reproduce. REQUIRED for `press`'s reason, and a driver with no
|
|
168
|
+
* accessibility engine answers `X_NOT_IMPLEMENTED`, never an invented role: a fake that read
|
|
169
|
+
* `role="button"` off the markup would pass a test against a `<div onclick>` a reader cannot
|
|
170
|
+
* reach, which is the exact defect this read exists to catch.
|
|
171
|
+
*/
|
|
172
|
+
accessibility(selector: string, max: number): Promise<readonly AxNode[]>;
|
|
136
173
|
/**
|
|
137
174
|
* The browser goes offline, or comes back. REQUIRED on this port where it is optional on
|
|
138
175
|
* `CdpPageLike`, and the asymmetry is the enforcement: a driver author gets a type error naming
|