@ultimat3/scraping 20.1.6 → 20.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "20.1.6",
3
+ "version": "20.2.1",
4
4
  "description": "Browser automation as a job: scrape() returns a JobHandle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,9 +30,9 @@
30
30
  "test": "bun test"
31
31
  },
32
32
  "dependencies": {
33
- "@ultimat3/core": "20.1.6",
34
- "@ultimat3/jobs": "20.1.6",
35
- "@ultimat3/schema": "20.1.6",
36
- "@ultimat3/storage": "20.1.6"
33
+ "@ultimat3/core": "20.2.1",
34
+ "@ultimat3/jobs": "20.2.1",
35
+ "@ultimat3/schema": "20.2.1",
36
+ "@ultimat3/storage": "20.2.1"
37
37
  }
38
38
  }
@@ -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,21 @@ export interface FakeCdpBrowser extends CdpBrowserLike {
77
91
  * something sets one, which is the launcher's own default and not a value this fake invents.
78
92
  */
79
93
  readonly colorScheme: string | null;
94
+ /**
95
+ * Every keyboard event, in order — `down Meta`, `press K`, `up Meta` — so a test asserts on the
96
+ * SEQUENCE a chord became: a modifier released before the key, or never released, is the defect.
97
+ */
98
+ readonly pressed: readonly string[];
99
+ /** Every selector `page.focus()` was handed, in order. */
100
+ readonly focused: readonly string[];
101
+ /**
102
+ * Every expression `page.evaluateOnNewDocument()` was handed, in order — the scripts a real
103
+ * browser would run ahead of each document's own. Recorded, never executed: this fake has no JS
104
+ * engine, and a test asserts on WHAT was prepared and that it was prepared before `goto`.
105
+ */
106
+ readonly prepared: readonly string[];
107
+ /** How many raw CDP sessions were created and how many detached — a leak is a difference. */
108
+ readonly sessions: { readonly created: number; readonly detached: number };
80
109
  }
81
110
 
82
111
  /** A layout box every element gets, so the CDP path exercises the fields the fake target lacks. */
@@ -124,6 +153,76 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
124
153
  const covered = new Set(init.covered ?? []);
125
154
  const storage: Record<string, string> = { ...init.storage };
126
155
  let cookies: readonly ScrapeCookie[] = init.cookies ?? [];
156
+ const pressed: string[] = [];
157
+ const focused: string[] = [];
158
+ const prepared: string[] = [];
159
+ const sessions = { created: 0, detached: 0 };
160
+ const axBySelector = init.accessibility ?? {};
161
+
162
+ /**
163
+ * A raw session answering the four commands `cdp-a11y.ts` sends, in CDP's own wire shapes —
164
+ * `{ value }` wrappers, a `properties` list — so the parse in that file is what is under test.
165
+ * Node ids are minted per `querySelectorAll` and looked up on the way back, which is how the
166
+ * real protocol addresses nodes too.
167
+ */
168
+ const cdpSession = (): CdpSessionLike => {
169
+ const byNodeId = new Map<number, AxNode>();
170
+ const wrap = (value: string | boolean | undefined): { value?: string | boolean } =>
171
+ value === undefined ? {} : { value };
172
+ return {
173
+ send: (method: string, params?: Record<string, unknown>) => {
174
+ if (method === 'DOM.getDocument') return Promise.resolve({ root: { nodeId: 1 } });
175
+ if (method === 'DOM.querySelectorAll') {
176
+ const selector = typeof params?.['selector'] === 'string' ? params['selector'] : '';
177
+ const nodes = Object.hasOwn(axBySelector, selector) ? (axBySelector[selector] ?? []) : [];
178
+ const nodeIds = nodes.map((node, index) => {
179
+ const nodeId = 100 + byNodeId.size + index;
180
+ byNodeId.set(nodeId, node);
181
+ return nodeId;
182
+ });
183
+ return Promise.resolve({ nodeIds });
184
+ }
185
+ if (method === 'DOM.describeNode') {
186
+ const nodeId = typeof params?.['nodeId'] === 'number' ? params['nodeId'] : 0;
187
+ return Promise.resolve({ node: { backendNodeId: nodeId * 10 } });
188
+ }
189
+ if (method === 'Accessibility.getPartialAXTree') {
190
+ const backend =
191
+ typeof params?.['backendNodeId'] === 'number' ? params['backendNodeId'] : 0;
192
+ const node = byNodeId.get(backend / 10);
193
+ if (node === undefined) return Promise.resolve({ nodes: [] });
194
+ const properties = [
195
+ ...(node.focused === undefined ? [] : [{ name: 'focused', value: wrap(node.focused) }]),
196
+ ...(node.disabled === undefined
197
+ ? []
198
+ : [{ name: 'disabled', value: wrap(node.disabled) }]),
199
+ ];
200
+ return Promise.resolve({
201
+ nodes: [
202
+ {
203
+ nodeId: `ax-${String(backend)}`,
204
+ ignored: node.ignored,
205
+ role: { type: 'role', ...wrap(node.role) },
206
+ name: { type: 'computedString', ...wrap(node.name) },
207
+ ...(node.description === undefined
208
+ ? {}
209
+ : { description: { type: 'computedString', value: node.description } }),
210
+ ...(node.value === undefined
211
+ ? {}
212
+ : { value: { type: 'string', value: node.value } }),
213
+ properties,
214
+ },
215
+ ],
216
+ });
217
+ }
218
+ return Promise.resolve({});
219
+ },
220
+ detach: () => {
221
+ sessions.detached += 1;
222
+ return Promise.resolve();
223
+ },
224
+ };
225
+ };
127
226
 
128
227
  const documentOf = (read: () => string): FakeDocument => ({ html: read, typed: new Map() });
129
228
  const pageDocument = documentOf(() => html);
@@ -206,6 +305,32 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
206
305
  screenshot: () => Promise.resolve(new Uint8Array([1, 2, 3])),
207
306
  pdf: () => Promise.resolve(new Uint8Array([4, 5])),
208
307
  setRequestInterception: () => Promise.resolve(),
308
+ keyboard: {
309
+ down: (key: string) => {
310
+ pressed.push(`down ${key}`);
311
+ return Promise.resolve();
312
+ },
313
+ up: (key: string) => {
314
+ pressed.push(`up ${key}`);
315
+ return Promise.resolve();
316
+ },
317
+ press: (key: string) => {
318
+ pressed.push(`press ${key}`);
319
+ return Promise.resolve();
320
+ },
321
+ },
322
+ focus: (selector: string) => {
323
+ focused.push(selector);
324
+ return Promise.resolve();
325
+ },
326
+ evaluateOnNewDocument: (expression: string) => {
327
+ prepared.push(expression);
328
+ return Promise.resolve(undefined);
329
+ },
330
+ createCDPSession: () => {
331
+ sessions.created += 1;
332
+ return Promise.resolve(cdpSession());
333
+ },
209
334
  setOfflineMode: (enabled: boolean) => {
210
335
  offline = enabled;
211
336
  return Promise.resolve();
@@ -275,6 +400,10 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
275
400
  get colorScheme(): string | null {
276
401
  return colorScheme;
277
402
  },
403
+ pressed,
404
+ focused,
405
+ prepared,
406
+ sessions,
278
407
  };
279
408
  }
280
409
 
package/src/cdp-port.ts CHANGED
@@ -44,6 +44,29 @@ export interface CdpRequestLike {
44
44
  continue(): Promise<void>;
45
45
  }
46
46
 
47
+ /**
48
+ * The page's keyboard — `page.keyboard` under puppeteer, a PROPERTY and not a method, which is why
49
+ * it is declared as one. `key` stays a bare `string`: the library's `KeyInput` union is its own
50
+ * vocabulary, and a launcher whose union is wider or narrower must still satisfy the port. The
51
+ * chord grammar is checked in `key-chord.ts` before any of these is called.
52
+ */
53
+ export interface CdpKeyboardLike {
54
+ down(key: string): Promise<void>;
55
+ up(key: string): Promise<void>;
56
+ press(key: string): Promise<void>;
57
+ }
58
+
59
+ /**
60
+ * A raw protocol session — `page.createCDPSession()` under puppeteer. `method` and `params` are
61
+ * the wire's own shapes, restated as `string` and a plain record: the library's typed command map
62
+ * is the library's, and naming it here would make this file depend on somebody else's protocol
63
+ * tables. Every answer is `unknown` and is parsed by the caller (`cdp-a11y.ts`), never cast.
64
+ */
65
+ export interface CdpSessionLike {
66
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
67
+ detach(): Promise<void>;
68
+ }
69
+
47
70
  export interface CdpPageLike {
48
71
  url(): string;
49
72
  goto(url: string, options?: { readonly timeout?: number }): Promise<unknown>;
@@ -97,6 +120,29 @@ export interface CdpPageLike {
97
120
  * the library's own types for them.
98
121
  */
99
122
  on(event: string, handler: (payload: unknown) => void): unknown;
123
+ /**
124
+ * OPTIONAL, read defensively, for `setOfflineMode`'s reason: a provider SDK or a launcher that
125
+ * predates the member must still satisfy the port. `cdp-target.ts` refuses BY NAME with
126
+ * `X_NOT_IMPLEMENTED` when a launcher lacks it — a chord on a page with no keyboard is a coded
127
+ * refusal, never a silent no-op that leaves a command palette closed and a test green.
128
+ */
129
+ readonly keyboard?: CdpKeyboardLike | undefined;
130
+ /** `page.focus(selector)`. OPTIONAL for `keyboard`'s reason, and refused by name when absent. */
131
+ focus?(selector: string): Promise<void>;
132
+ /**
133
+ * `page.evaluateOnNewDocument(expression)`: a script the browser runs in EVERY document this
134
+ * page navigates to, before any of the document's own scripts — `Page.addScriptToEvaluateOnNewDocument`
135
+ * under CDP. It is the only moment that beats an inlined boot script, which is what a
136
+ * storage-seeded theme choice needs (`ScrapePage.prepare`). The string form, for `evaluate`'s
137
+ * reason. OPTIONAL for `keyboard`'s reason, and refused by name when absent.
138
+ */
139
+ evaluateOnNewDocument?(expression: string): Promise<unknown>;
140
+ /**
141
+ * A raw protocol session, for the one read this package makes that the library has no method
142
+ * for: the accessibility tree (`Accessibility.getPartialAXTree`). OPTIONAL for `keyboard`'s
143
+ * reason, and refused by name when absent.
144
+ */
145
+ createCDPSession?(): Promise<CdpSessionLike>;
100
146
  frames(): readonly CdpFrameLike[];
101
147
  close(): Promise<void>;
102
148
  }
@@ -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(); })()`;