@ultimat3/scraping 12.0.0 → 14.0.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/README.md CHANGED
@@ -85,6 +85,32 @@ Both legs replay from **one** fixture directory (`fixtureBrowser(dir)`), so a hy
85
85
  login, session handoff, HTTP bulk fetch — is tested end to end. Both legs apply the same robots
86
86
  gate, offline included.
87
87
 
88
+ ## Reading elements, frames, and the network condition
89
+
90
+ | Read | Answers |
91
+ |---|---|
92
+ | `page.values(selector)` | `ElementValue[]` — tag, text, value, attrs. The projection row assembly wants |
93
+ | `page.query(selector)` | `ElementSnapshot[]` — the same matches PLUS `visible`, `enabled`, and the layout `box`/`hitTarget` a driver with a layout engine can answer. The read for a decision ABOUT an element |
94
+ | `page.frame(nameOrSelector)` | a `ScrapeFrame`, re-resolved on every call through it |
95
+ | `page.offline(enabled)` | cuts the BROWSER's network, or restores it |
96
+
97
+ `query()` exists because there is exactly one definition of "visible" in this framework and it is
98
+ the port's. A caller that had to compute its own wrote
99
+ `display !== 'none' && visibility !== 'hidden' && opacity !== '0'` a second time, which is the
100
+ copy axiom 1 forbids.
101
+
102
+ **A frame verb reaches the FRAME.** `fill`, `type`, `select`, `clear`, `click` and `query` through
103
+ a `ScrapeFrame` handle all address that frame's own document — never the parent's, even when the
104
+ two carry the same ids, which is what an iframe'd SSO login looks like. `driver-parity-frames.test.ts`
105
+ drives all six through a frame on all three drivers. The one thing an offline driver cannot do is
106
+ NAVIGATE a frame: a recorded frame is one static document, so a click inside one moves nothing
107
+ (and, in particular, never moves the parent).
108
+
109
+ **`page.offline()` is set on a browser or refused by name.** The offline drivers answer
110
+ `X_NOT_IMPLEMENTED` rather than resolving: patching `fetch` in a test process cannot reach a
111
+ browser's own requests, so a driver that quietly answered "done" would let "a like taken offline is
112
+ queued" pass against an app that was online for the whole test.
113
+
88
114
  ## What the page reports back, and the one thing a picture cannot say
89
115
 
90
116
  Three bounded rings, read off `ScrapePage`. Bounded because a ten-thousand-page run that kept
@@ -97,6 +123,14 @@ bound threw away, so a count taken from one is never quietly a floor.
97
123
  | `page.pageErrors()` / `page.pageErrorsDropped()` | `PageError[]` — what the page THREW and nobody caught, with the `stack` when the exception carried one |
98
124
  | `page.network()` / `page.networkDropped()` | `NetworkEntry[]` — every request, refusals included |
99
125
 
126
+ All three are **redacted by value** on the way out, the same pass `page.html()` makes: a login
127
+ endpoint fetched with the password in its query string, or a site that logs the credential it
128
+ rejected, would otherwise put the value in `page.network()`/`page.console()` verbatim and from
129
+ there into the stored failure artifact. The HTTP leg's `X_SCRAPE_HTTP_FAILED` cause is redacted
130
+ too, at its throw site. Values shorter than `MIN_REDACTABLE_LENGTH` are not — a 3-character PIN is
131
+ a substring of ordinary prose — and pixels never can be, which is why a typed secret TAINTS the
132
+ page and `screenshot()`/`pdf()` are refused outright.
133
+
100
134
  `console()` and `pageErrors()` are separate streams because they are separate events: an island
101
135
  that throws during hydration calls no console method, so a scrape reading the console alone sees a
102
136
  page that looks silent and is broken — and a screenshot of it is a picture of the server-rendered
@@ -141,7 +175,8 @@ on a machine with no browser — the case CI is.
141
175
  | `driver-cdp.ts` / `cdp-*.ts` | the real browser, over a structural CDP port |
142
176
  | `driver-fake.ts` / `driver-fixture.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
143
177
  | `http.ts` / `http-recorded.ts` | the second transport, live and replayed |
144
- | `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn |
178
+ | `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions |
179
+ | `secrets.ts` / `browser-record.ts` | what may leave this package, and how a browser's own string map is read |
145
180
  | `expect.ts` | the silent-green alarm |
146
181
  | `watchdog.ts` | the wedge and zombie discipline |
147
182
  | `capture-clip.ts` | the one framing rule — crop, whole page, or a refusal — checked before any driver sees it |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "12.0.0",
3
+ "version": "14.0.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": "12.0.0",
34
- "@ultimat3/jobs": "12.0.0",
35
- "@ultimat3/schema": "12.0.0",
36
- "@ultimat3/storage": "12.0.0"
33
+ "@ultimat3/core": "14.0.0",
34
+ "@ultimat3/jobs": "14.0.0",
35
+ "@ultimat3/schema": "14.0.0",
36
+ "@ultimat3/storage": "14.0.0"
37
37
  }
38
38
  }
@@ -0,0 +1,35 @@
1
+ // A string-to-string map that came out of the BROWSER — an element's attributes, `localStorage` —
2
+ // read permissively and onto a null prototype. One reader, because the two call sites had the same
3
+ // bug and would have been fixed twice.
4
+
5
+ /**
6
+ * The browser's own keys, READ rather than refused by name.
7
+ *
8
+ * `t.record()` refuses `__proto__`, `constructor` and `prototype` (`packages/schema/src/
9
+ * validators.ts`), which is exactly right where a record's keys are a caller's — a request body —
10
+ * and exactly wrong here. `<div constructor="Foo">` is legal HTML that real build tooling emits,
11
+ * and `localStorage.setItem('constructor', …)` is legal storage; both threw `X_VALIDATION_FAILED`
12
+ * out of a page read with nothing wrong with it, and `cdp-target.ts`'s `guard()` then re-labelled
13
+ * that `X_SCRAPE_BROWSER_UNREACHABLE`, which is registered RETRYABLE — five browser launches and
14
+ * five arrivals at a login over one benign attribute.
15
+ *
16
+ * The null prototype is the other half, and it is why this is not merely a looser schema: on a
17
+ * `{}` literal `attrs['toString']` answers an `Object.prototype` function the page never sent, and
18
+ * `attrs['__proto__'] = value` files no own key at all. `Readonly<Record<string, string>>` would
19
+ * be a lie the caller cannot see through either way. Same construction and the same argument as
20
+ * `http.ts`'s `headerRecord` makes for response headers, one leg over.
21
+ *
22
+ * TOTAL, deliberately: a non-object answers an empty map and a non-string value is skipped. The
23
+ * caller is a driver reading a live browser, and a refusal from here is read by everything above
24
+ * it as a browser that went away.
25
+ */
26
+ export function browserRecord(value: unknown): Record<string, string> {
27
+ const out = Object.create(null) as Record<string, string>;
28
+ if (typeof value !== 'object' || value === null) return out;
29
+ // Assignment, not `defineProperty`: with no prototype there is no inherited `__proto__` setter
30
+ // to swallow the write, so a key spelled `__proto__` files as an ordinary own key.
31
+ for (const [key, entry] of Object.entries(value)) {
32
+ if (typeof entry === 'string') out[key] = entry;
33
+ }
34
+ return out;
35
+ }
package/src/cdp-fake.ts CHANGED
@@ -19,11 +19,35 @@ const selectorOf = (expression: string): string | undefined => {
19
19
  return JSON.parse(match[1]) as string;
20
20
  };
21
21
 
22
+ /**
23
+ * The selector inside `clearExpression()`'s `document.querySelector("…")`, READ rather than
24
+ * imported. A clear is the one verb this port has no method for — it travels as an expression — so
25
+ * a fake that recognised it by identity could not tell the page's document from a frame's, which
26
+ * is precisely the divergence `driver-parity-frames.test.ts` needs it to expose.
27
+ */
28
+ const clearedSelectorOf = (expression: string): string | undefined => {
29
+ if (!expression.includes(".value = ''")) return undefined;
30
+ const match = /querySelector\((".*?")\)/s.exec(expression);
31
+ if (match?.[1] === undefined) return undefined;
32
+ return JSON.parse(match[1]) as string;
33
+ };
34
+
35
+ export interface FakeCdpFrameInit {
36
+ readonly url: string;
37
+ readonly html: string;
38
+ }
39
+
22
40
  export interface FakeCdpPageInit {
23
41
  readonly url: string;
24
42
  readonly html: string;
25
43
  /** Selectors a click navigates from, and where to. */
26
44
  readonly routes?: Readonly<Record<string, { readonly url: string; readonly html: string }>>;
45
+ /**
46
+ * Child frames by NAME, each its own document. A frame with its own markup is what makes a
47
+ * frame verb aimed at the parent document visible — with `frames(): []` the whole frame half of
48
+ * `cdp-target.ts` was unreachable from every test in the package.
49
+ */
50
+ readonly frames?: Readonly<Record<string, FakeCdpFrameInit>>;
27
51
  readonly cookies?: readonly ScrapeCookie[];
28
52
  readonly storage?: Readonly<Record<string, string>>;
29
53
  readonly userAgent?: string;
@@ -44,6 +68,8 @@ export interface FakeCdpBrowser extends CdpBrowserLike {
44
68
  emitPageError(payload: unknown): void;
45
69
  readonly aborted: readonly string[];
46
70
  readonly closed: boolean;
71
+ /** What `page.setOfflineMode()` last set, so a test asserts on the CONDITION, not on a call. */
72
+ readonly offline: boolean;
47
73
  }
48
74
 
49
75
  /** A layout box every element gets, so the CDP path exercises the fields the fake target lacks. */
@@ -53,58 +79,136 @@ const boxed = (snapshot: ElementSnapshot, covered: boolean): ElementSnapshot =>
53
79
  hitTarget: !covered,
54
80
  });
55
81
 
82
+ /**
83
+ * ONE document — the page's, or a frame's. A browser gives every browsing context its own DOM, so
84
+ * the fake gives every one its own typed values: a verb that reaches the wrong document then shows
85
+ * up as text in the wrong place rather than as nothing at all.
86
+ */
87
+ interface FakeDocument {
88
+ html(): string;
89
+ /** Typed text, keyed the way `keyOf` keys it offline: `#id` when there is one, else the selector. */
90
+ readonly typed: Map<string, string>;
91
+ }
92
+
93
+ const keyFor = (selector: string, element: ElementSnapshot | undefined): string => {
94
+ const id = element?.attrs['id'];
95
+ return id === undefined ? selector : `#${id}`;
96
+ };
97
+
98
+ /** What the DOM would answer after a `type()`: the element, carrying whatever was typed into it. */
99
+ const withTyped = (
100
+ document: FakeDocument,
101
+ selector: string,
102
+ elements: readonly ElementSnapshot[],
103
+ ): readonly ElementSnapshot[] =>
104
+ elements.map((element) => {
105
+ const typed = document.typed.get(keyFor(selector, element));
106
+ return typed === undefined ? element : { ...element, value: typed };
107
+ });
108
+
56
109
  export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
57
110
  const handlers: Handlers = new Map();
58
111
  const aborted: string[] = [];
59
112
  let url = init.url;
60
113
  let html = init.html;
61
114
  let closed = false;
115
+ let offline = false;
62
116
  const covered = new Set(init.covered ?? []);
63
117
  const storage: Record<string, string> = { ...init.storage };
64
118
  let cookies: readonly ScrapeCookie[] = init.cookies ?? [];
65
119
 
66
- const evaluate = async (expression: string): Promise<unknown> => {
120
+ const documentOf = (read: () => string): FakeDocument => ({ html: read, typed: new Map() });
121
+ const pageDocument = documentOf(() => html);
122
+
123
+ /** Every expression this package sends, answered against the document it was sent to. */
124
+ const evaluateIn = async (target: FakeDocument, expression: string): Promise<unknown> => {
67
125
  const selector = selectorOf(expression);
68
126
  if (selector !== undefined) {
69
- const found = await queryHtml(html, selector);
127
+ const found = withTyped(target, selector, await queryHtml(target.html(), selector));
70
128
  return JSON.stringify(found.map((element) => boxed(element, covered.has(selector))));
71
129
  }
72
- if (expression.includes('localStorage')) {
73
- if (expression.includes('setItem')) return undefined;
74
- return JSON.stringify(storage);
130
+ const cleared = clearedSelectorOf(expression);
131
+ if (cleared !== undefined) {
132
+ const [first] = await queryHtml(target.html(), cleared);
133
+ target.typed.set(keyFor(cleared, first), '');
134
+ return undefined;
75
135
  }
76
- if (expression.includes('navigator.userAgent')) return init.userAgent ?? 'fake-agent';
77
136
  return undefined;
78
137
  };
79
138
 
139
+ const typeIn = async (target: FakeDocument, selector: string, text: string): Promise<void> => {
140
+ const [first] = await queryHtml(target.html(), selector);
141
+ const key = keyFor(selector, first);
142
+ target.typed.set(key, `${target.typed.get(key) ?? first?.value ?? ''}${text}`);
143
+ };
144
+
145
+ const selectIn = async (target: FakeDocument, selector: string, value: string): Promise<void> => {
146
+ const [first] = await queryHtml(target.html(), selector);
147
+ target.typed.set(keyFor(selector, first), value);
148
+ };
149
+
150
+ const frames: readonly CdpFrameLike[] = Object.entries(init.frames ?? {}).map(([name, frame]) => {
151
+ const frameDocument = documentOf(() => frame.html);
152
+ return {
153
+ name: () => name,
154
+ url: () => frame.url,
155
+ content: () => Promise.resolve(frame.html),
156
+ evaluate: (expression: string) => evaluateIn(frameDocument, expression),
157
+ click: () => Promise.resolve(),
158
+ type: (selector: string, text: string) => typeIn(frameDocument, selector, text),
159
+ select: async (selector: string, ...values: string[]): Promise<string[]> => {
160
+ await selectIn(frameDocument, selector, values[0] ?? '');
161
+ return [...values];
162
+ },
163
+ };
164
+ });
165
+
80
166
  const page: CdpPageLike = {
81
167
  url: () => url,
82
168
  goto: (next: string) => {
83
169
  url = next;
170
+ // A navigation is a new document, so what was typed into the old one is gone. Keeping it
171
+ // would let a stale value answer a query on a page that never had it.
172
+ pageDocument.typed.clear();
84
173
  return Promise.resolve(undefined);
85
174
  },
86
175
  content: () => Promise.resolve(html),
87
- evaluate,
176
+ async evaluate(expression: string): Promise<unknown> {
177
+ if (expression.includes('localStorage')) {
178
+ if (expression.includes('setItem')) return undefined;
179
+ return JSON.stringify(storage);
180
+ }
181
+ if (expression.includes('navigator.userAgent')) return init.userAgent ?? 'fake-agent';
182
+ return await evaluateIn(pageDocument, expression);
183
+ },
88
184
  click: (selector: string) => {
89
185
  const route = init.routes?.[selector];
90
186
  if (route !== undefined) {
91
187
  url = route.url;
92
188
  html = route.html;
189
+ pageDocument.typed.clear();
93
190
  }
94
191
  return Promise.resolve();
95
192
  },
96
- type: () => Promise.resolve(),
97
- select: () => Promise.resolve([]),
193
+ type: (selector: string, text: string) => typeIn(pageDocument, selector, text),
194
+ select: async (selector: string, ...values: string[]): Promise<string[]> => {
195
+ await selectIn(pageDocument, selector, values[0] ?? '');
196
+ return [...values];
197
+ },
98
198
  screenshot: () => Promise.resolve(new Uint8Array([1, 2, 3])),
99
199
  pdf: () => Promise.resolve(new Uint8Array([4, 5])),
100
200
  setRequestInterception: () => Promise.resolve(),
201
+ setOfflineMode: (enabled: boolean) => {
202
+ offline = enabled;
203
+ return Promise.resolve();
204
+ },
101
205
  on: (event: string, handler: (payload: unknown) => void) => {
102
206
  const listeners = handlers.get(event) ?? [];
103
207
  listeners.push(handler);
104
208
  handlers.set(event, listeners);
105
209
  return undefined;
106
210
  },
107
- frames: (): readonly CdpFrameLike[] => [],
211
+ frames: (): readonly CdpFrameLike[] => frames,
108
212
  close: () => {
109
213
  closed = true;
110
214
  return Promise.resolve();
@@ -143,6 +247,9 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
143
247
  get closed(): boolean {
144
248
  return closed;
145
249
  },
250
+ get offline(): boolean {
251
+ return offline;
252
+ },
146
253
  };
147
254
  }
148
255
 
package/src/cdp-port.ts CHANGED
@@ -56,6 +56,19 @@ export interface CdpPageLike {
56
56
  screenshot(options: CdpScreenshotOptions): Promise<Uint8Array | string>;
57
57
  pdf(options?: Record<string, unknown>): Promise<Uint8Array>;
58
58
  setRequestInterception(enabled: boolean): Promise<void>;
59
+ /**
60
+ * The browser's own network condition — `Network.emulateNetworkConditions` under CDP.
61
+ *
62
+ * OPTIONAL, read defensively, for `CdpRequestLike.method` and `CdpBrowserLike.cookies`' reason:
63
+ * this file is the shape of somebody ELSE's object, and a provider SDK or a launcher that
64
+ * predates the method must still satisfy the port. It does not go unwired by being optional —
65
+ * `cdp-target.ts` refuses BY NAME with `X_NOT_IMPLEMENTED` and a fix when a launcher lacks it,
66
+ * exactly as `cookies()` does, so the gap is a coded refusal rather than a silent no-op.
67
+ *
68
+ * Required instead would cost every existing launcher, every provider SDK and every test double
69
+ * a type error for a capability the port cannot make them have.
70
+ */
71
+ setOfflineMode?(enabled: boolean): Promise<void>;
59
72
  /**
60
73
  * `event` stays a bare `string` — a union of the four names this package subscribes to would be
61
74
  * this file naming somebody else's event vocabulary, which is the thing it exists not to do, and
@@ -1,4 +1,4 @@
1
- // The one in-page expression this package runs, and the schema that reads its answer back.
1
+ // The in-page expressions this package runs, and the schema that reads their answer back.
2
2
  //
3
3
  // It computes exactly `ElementSnapshot` — including the two fields the offline drivers cannot
4
4
  // have: the layout box, and whether the element is what a click at its own centre would hit. A
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { StandardSchemaV1 } from '@ultimat3/schema';
9
9
  import { parse, t } from '@ultimat3/schema';
10
+ import { browserRecord } from './browser-record';
10
11
  import type { ElementSnapshot } from './target';
11
12
 
12
13
  /**
@@ -36,6 +37,18 @@ export const snapshotExpression = (selector: string): string => `(() => {
36
37
  return JSON.stringify(out);
37
38
  })()`;
38
39
 
40
+ /**
41
+ * Emptying a control, as an expression — the one verb `CdpPageLike`/`CdpFrameLike` has no method
42
+ * for, so it travels as text and is `evaluate`d against whichever document it is handed to.
43
+ *
44
+ * ONE declaration, and that is the whole point of it living here: the page target and the frame
45
+ * target each `evaluate` it, and a second copy is how the frame half came to send the page's
46
+ * expression to the page's own document — emptying the PARENT's same-named field while the
47
+ * frame's kept its value and a `fill` appended to it.
48
+ */
49
+ export const clearExpression = (selector: string): string =>
50
+ `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (el) { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); } })()`;
51
+
39
52
  /**
40
53
  * `t.string` refuses an empty string, and an element's `text` and `value` are legitimately empty
41
54
  * far more often than not — a `<div>` wrapper, an unfilled input. `.min(0)` says "a string, and
@@ -43,10 +56,16 @@ export const snapshotExpression = (selector: string): string => `(() => {
43
56
  */
44
57
  const anyString = t.string.min(0);
45
58
 
59
+ /**
60
+ * `attrs` is absent from the shape and read by `browserRecord` instead — the one field whose KEYS
61
+ * are the page's rather than a schema's. `t.record()` refuses `__proto__`, `constructor` and
62
+ * `prototype` by name, so `<div constructor="Foo">` on any queried element refused the whole read;
63
+ * see `browser-record.ts` for why the refusal is right for a request body and wrong for a DOM.
64
+ * Every other field is still parsed, and a `tag` that is not a string still refuses.
65
+ */
46
66
  const snapshotSchema = t.array(
47
67
  t.object({
48
68
  tag: t.string,
49
- attrs: t.record(anyString),
50
69
  text: anyString,
51
70
  value: anyString,
52
71
  visible: t.boolean,
@@ -54,10 +73,22 @@ const snapshotSchema = t.array(
54
73
  box: t.object({ x: t.number, y: t.number, width: t.number, height: t.number }),
55
74
  hitTarget: t.boolean,
56
75
  }),
57
- ) as unknown as StandardSchemaV1<unknown, ElementSnapshot[]>;
76
+ ) as unknown as StandardSchemaV1<unknown, Omit<ElementSnapshot, 'attrs'>[]>;
77
+
78
+ const attrsOf = (row: unknown): unknown =>
79
+ typeof row === 'object' && row !== null ? (row as { readonly attrs?: unknown }).attrs : undefined;
58
80
 
59
- /** The browser's answer is `unknown` and stays `unknown` until this parses it. Never a cast. */
81
+ /**
82
+ * The browser's answer is `unknown` and stays `unknown` until this reads it. Never a cast.
83
+ *
84
+ * The parsed rows and the raw rows are zipped by INDEX, which is sound because the array schema
85
+ * preserves order and length: a row that failed makes the whole parse throw before this runs.
86
+ */
60
87
  export function parseSnapshots(raw: unknown): readonly ElementSnapshot[] {
61
- if (typeof raw !== 'string') return parse(snapshotSchema, raw);
62
- return parse(snapshotSchema, JSON.parse(raw) as unknown);
88
+ const value = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
89
+ const rows: readonly unknown[] = Array.isArray(value) ? (value as readonly unknown[]) : [];
90
+ return parse(snapshotSchema, value).map((element, index) => ({
91
+ ...element,
92
+ attrs: browserRecord(attrsOf(rows[index])),
93
+ }));
63
94
  }
package/src/cdp-target.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  import { isUltimateError } from '@ultimat3/core';
5
5
  import type { StandardSchemaV1 } from '@ultimat3/schema';
6
6
  import { parse, t } from '@ultimat3/schema';
7
+ import { browserRecord } from './browser-record';
7
8
  import type { CdpBrowserLike, CdpFrameLike, CdpPageLike, CdpRequestLike } from './cdp-port';
8
- import { parseSnapshots, snapshotExpression } from './cdp-snapshot';
9
+ import { clearExpression, parseSnapshots, snapshotExpression } from './cdp-snapshot';
9
10
  import type { ScrapeClock } from './clock';
10
11
  import { browserUnreachable, pageCrashed, scrapeNotImplemented } from './error-throws';
11
12
  import type { InterceptRules } from './intercept';
@@ -47,11 +48,6 @@ const cookieSchema = t.array(
47
48
  }),
48
49
  ) as unknown as StandardSchemaV1<unknown, ScrapeCookie[]>;
49
50
 
50
- const storageSchema = t.record(anyString) as unknown as StandardSchemaV1<
51
- unknown,
52
- Record<string, string>
53
- >;
54
-
55
51
  const asResourceType = (raw: string): ResourceType =>
56
52
  (RESOURCE_TYPES as readonly string[]).includes(raw) ? (raw as ResourceType) : 'other';
57
53
 
@@ -130,31 +126,51 @@ const readPageError = (payload: unknown, at: number): PageError => {
130
126
  };
131
127
 
132
128
  /**
133
- * The one failure `guard()` must NOT re-label, and the line is drawn at exactly one code.
129
+ * The failures `guard()` must NOT re-label. Two codes, each because a SECOND attempt reaches the
130
+ * identical answer — never because the error looked coded.
131
+ *
132
+ * | Code | Why a retry cannot change it |
133
+ * |---|---|
134
+ * | `X_NOT_IMPLEMENTED` | a fact about the launcher's own shape. A browser cannot produce it; only this file's `scrapeNotImplemented()` can, and the method is still missing on attempt five |
135
+ * | `X_VALIDATION_FAILED` | the browser ANSWERED, and the answer did not match the shape this driver reads it with. The page is what it is; attempt five reads the same DOM |
134
136
  *
135
- * `X_NOT_IMPLEMENTED` is the only code that says "this build does not have the feature" — a fact
136
- * about the launcher's own shape, never about the connection. A browser cannot produce it; only
137
- * this file's own `scrapeNotImplemented()` can, from inside a guarded closure. Re-labelled as
138
- * `X_SCRAPE_BROWSER_UNREACHABLE` (registered `retryable` in `errors.ts`) it spends every attempt
139
- * in the scrape's retry policy on a method that is still missing on attempt five, and tells the
140
- * operator the browser went away while the browser is answering fine.
137
+ * Both were re-labelled `X_SCRAPE_BROWSER_UNREACHABLE`, which `errors.ts` registers `retryable`,
138
+ * so each spent the whole retry policy — five browser launches, five arrivals at a login — while
139
+ * telling the operator the browser went away about a browser that was answering perfectly.
141
140
  *
142
141
  * Every OTHER coded error stays wrapped, deliberately. `thrown instanceof UltimateError` is the
143
142
  * naive version of this check and it is wrong: an `X_SCRAPE_TIMEOUT` raised while the socket was
144
143
  * already dead would then arrive unwrapped, and "the browser went away mid-run" is the frame that
145
144
  * makes a disconnect legible — which is the whole reason this wrapper exists.
146
145
  *
146
+ * The half this package cannot close: both codes belong to `@ultimat3/core`/`@ultimat3/schema` and
147
+ * NEITHER is classified, so `classifyThrown` reads them as unclassified and the job's attempt
148
+ * count still governs. Passing them through stops the wrong TITLE and the false `retryable`
149
+ * claim; making them terminal is a `registerErrorRetry` beside the code that declares it.
150
+ *
147
151
  * `isUltimateError`, not `instanceof`: the brand survives a duplicated module instance.
148
152
  */
153
+ const PASSED_THROUGH_CODES: ReadonlySet<string> = new Set([
154
+ 'X_NOT_IMPLEMENTED',
155
+ 'X_VALIDATION_FAILED',
156
+ ]);
157
+
149
158
  const isStructuralRefusal = (thrown: unknown): boolean =>
150
- isUltimateError(thrown) && thrown.code === 'X_NOT_IMPLEMENTED';
159
+ isUltimateError(thrown) && PASSED_THROUGH_CODES.has(thrown.code);
151
160
 
161
+ /**
162
+ * `ringCapacity` was declared here, exported, read three lines into `cdpTarget` — and passed by
163
+ * NOBODY: `driver-cdp.ts` constructs without it and no `BrowserOptions` or `ScrapeDefinition`
164
+ * field reaches it, so an app could not set it at any distance. Deleted rather than threaded, the
165
+ * same call `CaptureOptions.timeoutMs` got: wiring it through only the CDP driver would give one
166
+ * of three drivers a bound the other two ignore, which is precisely the divergence
167
+ * `driver-parity.test.ts` exists to refuse. `DEFAULT_RING_CAPACITY` is the one bound.
168
+ */
152
169
  export interface CdpTargetInit {
153
170
  readonly page: CdpPageLike;
154
171
  readonly browser: CdpBrowserLike;
155
172
  readonly rules: InterceptRules;
156
173
  readonly clock: ScrapeClock;
157
- readonly ringCapacity?: number | undefined;
158
174
  }
159
175
 
160
176
  /**
@@ -228,9 +244,9 @@ async function arm(init: CdpTargetInit, sinks: CdpSinks): Promise<void> {
228
244
  }
229
245
 
230
246
  export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
231
- const console_ = createRing<ConsoleLine>(init.ringCapacity);
232
- const network = createRing<NetworkEntry>(init.ringCapacity);
233
- const pageErrors = createRing<PageError>(init.ringCapacity);
247
+ const console_ = createRing<ConsoleLine>();
248
+ const network = createRing<NetworkEntry>();
249
+ const pageErrors = createRing<PageError>();
234
250
  const crashed: { value: string | undefined } = { value: undefined };
235
251
  await arm(init, { network, console: console_, pageErrors, crashed });
236
252
  let pendingStorage: SessionSnapshot | undefined;
@@ -288,6 +304,15 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
288
304
  ),
289
305
  click: (selector) => guard('click', () => frame.click(selector)),
290
306
  type: (selector, text) => guard('type', () => frame.type(selector, text)),
307
+ // Listed even though `type` above already covers the pair a `fill` performs: `clear` is the
308
+ // one verb with no port method, so it is the one an author of a new frame verb forgets. It was
309
+ // forgotten — the spread handed the frame the PAGE's closure, so `frame.fill()` emptied the
310
+ // parent document's same-named field and merely APPENDED to the frame's, which is how an
311
+ // iframe'd login submits `oldUserNEWUSER` and passes its own offline test doing it.
312
+ clear: (selector) =>
313
+ guard('clear', async () => {
314
+ await frame.evaluate(clearExpression(selector));
315
+ }),
291
316
  select: (selector, values) =>
292
317
  guard('select', async () => {
293
318
  await frame.select(selector, ...values);
@@ -318,15 +343,24 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
318
343
  type: (selector, text) => guard('type', () => init.page.type(selector, text)),
319
344
  clear: (selector) =>
320
345
  guard('clear', async () => {
321
- await init.page.evaluate(
322
- `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (el) { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); } })()`,
323
- );
346
+ await init.page.evaluate(clearExpression(selector));
324
347
  }),
325
348
  select: (selector, values) =>
326
349
  guard('select', async () => {
327
350
  await init.page.select(selector, ...values);
328
351
  }),
329
352
  evaluate: (expression) => guard('evaluate', () => init.page.evaluate(expression)),
353
+ setOfflineMode: (enabled: boolean): Promise<void> =>
354
+ guard('setOfflineMode', async () => {
355
+ const source = init.page as { setOfflineMode?: (value: boolean) => Promise<void> };
356
+ if (typeof source.setOfflineMode !== 'function') {
357
+ throw scrapeNotImplemented(
358
+ 'setOfflineMode() on a CDP page with no setOfflineMode() method',
359
+ 'upgrade the launcher to a puppeteer-core that exposes page.setOfflineMode(), or drive the condition from the app under test instead of the browser',
360
+ );
361
+ }
362
+ await source.setOfflineMode(enabled);
363
+ }),
330
364
  screenshot: (options: CaptureOptions) =>
331
365
  guard('screenshot', async () => {
332
366
  // `fullPage` is OMITTED when a clip is given rather than sent as `false`: the two are
@@ -398,7 +432,9 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
398
432
  cookies:
399
433
  typeof source.cookies === 'function' ? parse(cookieSchema, await source.cookies()) : [],
400
434
  headers: {},
401
- storage: parse(storageSchema, JSON.parse(typeof storage === 'string' ? storage : '{}')),
435
+ // `browserRecord` and not `t.record()`: a page may legitimately hold a key called
436
+ // `constructor`, and the schema refuses that name outright — see `browser-record.ts`.
437
+ storage: browserRecord(JSON.parse(typeof storage === 'string' ? storage : '{}')),
402
438
  userAgent: typeof agent === 'string' ? agent : '',
403
439
  origin,
404
440
  };
package/src/driver-cdp.ts CHANGED
@@ -143,6 +143,10 @@ async function sessionOver(
143
143
  signal,
144
144
  onActivity,
145
145
  proxy: options.proxy,
146
+ // The run's secrets, so the ONE thing this leg quotes from the site — the first 200 bytes
147
+ // of a non-2xx body, in `X_SCRAPE_HTTP_FAILED`'s cause — cannot carry the password the
148
+ // login endpoint echoed back. The bag was in scope here and unread.
149
+ secrets: init.secrets,
146
150
  }),
147
151
  close: () => guard.shutdown(),
148
152
  };