@ultimat3/scraping 20.1.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "20.1.5",
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.1.5",
34
- "@ultimat3/jobs": "20.1.5",
35
- "@ultimat3/schema": "20.1.5",
36
- "@ultimat3/storage": "20.1.5"
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
  }
@@ -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 { CdpBrowserLike, CdpFrameLike, CdpLauncherLike, CdpPageLike } from './cdp-port';
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
  }
@@ -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 type { CdpBrowserLike, CdpFrameLike, CdpPageLike, CdpRequestLike } from './cdp-port';
9
- import { clearExpression, parseSnapshots, snapshotExpression } from './cdp-snapshot';
10
- import type { ScrapeClock } from './clock';
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 type { InterceptRules } from './intercept';
14
- import { interceptVerdict, refusalEntry } from './intercept';
15
- import type {
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> };
@@ -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);
@@ -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 { clearExpression, parseSnapshots, snapshotExpression } from './cdp-snapshot';
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,
@@ -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
+ }
@@ -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