@ultimat3/scraping 11.0.0 → 11.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/README.md CHANGED
@@ -108,6 +108,29 @@ same reason — a third-party driver that could omit it would be silent about er
108
108
  Entries are built with `pageErrorEntry()`, which truncates at `MAX_PAGE_ERROR_CHARS`: the ring
109
109
  bounds the count, and one `Maximum call stack size exceeded` is thousands of frames.
110
110
 
111
+ ### A picture of ONE component
112
+
113
+ `page.screenshot({ clip: { x, y, width, height } })` crops to a rectangle in the page's own
114
+ coordinate space — the space `getBoundingClientRect()` answers in. It exists because the reader of
115
+ a scrape's screenshot is increasingly a vision model, whose pixels are the scarce resource: a
116
+ whole-viewport picture spends them on everything that is not the component under review.
117
+
118
+ | Framing | Answer |
119
+ |---|---|
120
+ | neither | the viewport, exactly as before the clip existed — byte for byte |
121
+ | `fullPage: true` | the whole document |
122
+ | `clip` | that rectangle |
123
+ | **both** | `X_SCRAPE_CAPTURE_INVALID` — a browser honours one of the two without saying which |
124
+ | `clip` with no area, or entirely in negative coordinates | `X_SCRAPE_CAPTURE_INVALID` — a blank picture that reads as a successful capture is the failure this exists to remove |
125
+ | `clip` on `page.pdf()` | `X_SCRAPE_CAPTURE_INVALID` — a print engine paginates the document and has no crop |
126
+
127
+ A rectangle **below the fold is accepted**. It is not checked against a viewport: this package
128
+ neither sets nor reads one, and a component under the fold is the case a component crop is for.
129
+
130
+ The offline drivers honour it, so the crop is provable with no Chrome — `fakeBrowser()` answers
131
+ different deterministic bytes per rectangle, which is what lets a component screenshot be tested
132
+ on a machine with no browser — the case CI is.
133
+
111
134
  ## What it owns
112
135
 
113
136
  | Module | Owns |
@@ -121,6 +144,7 @@ bounds the count, and one `Maximum call stack size exceeded` is thousands of fra
121
144
  | `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn |
122
145
  | `expect.ts` | the silent-green alarm |
123
146
  | `watchdog.ts` | the wedge and zombie discipline |
147
+ | `capture-clip.ts` | the one framing rule — crop, whole page, or a refusal — checked before any driver sees it |
124
148
  | `errors.ts` / `error-throws.ts` | this package's `X_*` codes and their retry classification |
125
149
 
126
150
  ## Extending it — there is no plugin API, and none is needed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "11.0.0",
3
+ "version": "11.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": "11.0.0",
34
- "@ultimat3/jobs": "11.0.0",
35
- "@ultimat3/schema": "11.0.0",
36
- "@ultimat3/storage": "11.0.0"
33
+ "@ultimat3/core": "11.2.0",
34
+ "@ultimat3/jobs": "11.2.0",
35
+ "@ultimat3/schema": "11.2.0",
36
+ "@ultimat3/storage": "11.2.0"
37
37
  }
38
38
  }
@@ -0,0 +1,57 @@
1
+ // The crop rectangle a capture may name, and the four shapes of one that would answer a blank
2
+ // picture instead of a refusal. Its own module because the rule belongs to EVERY driver
3
+ // identically — a check per driver is three chances to disagree — and because `target.ts` is a
4
+ // port declaration, which is not a place decisions live.
5
+
6
+ import { captureClipEmpty, captureClipOnPdf, captureFramingConflict } from './error-throws';
7
+
8
+ /**
9
+ * CSS pixels in the page's own coordinate space, top-left origin — the space
10
+ * `getBoundingClientRect()` answers in, which is where a caller's rectangle comes from.
11
+ *
12
+ * No `scale`. CDP's clip has one and nothing here would read it, which is this repo's most
13
+ * repeated defect (`scripts/config-readers.ts`); a device pixel ratio belongs to the launch, not
14
+ * to one capture.
15
+ */
16
+ export interface CaptureClip {
17
+ readonly x: number;
18
+ readonly y: number;
19
+ readonly width: number;
20
+ readonly height: number;
21
+ }
22
+
23
+ /** What both capture verbs are framed by. `CaptureOptions` and `CaptureRequest` are this, named. */
24
+ export interface CaptureFraming {
25
+ readonly fullPage?: boolean | undefined;
26
+ readonly clip?: CaptureClip | undefined;
27
+ }
28
+
29
+ const finite = (value: number): boolean => Number.isFinite(value);
30
+
31
+ /**
32
+ * Refuse a framing no driver can honour, BEFORE any driver sees it.
33
+ *
34
+ * What is deliberately NOT checked: whether the rectangle is inside the viewport. This package
35
+ * neither sets nor reads a viewport — nothing in `src/` mentions one — so the answer would cost a
36
+ * round trip into the page, and it would be the WRONG answer: a component below the fold is
37
+ * exactly what a component crop is for, and every current build captures beyond the viewport. A
38
+ * rectangle entirely in negative space needs no browser to refuse and is the half that is real.
39
+ */
40
+ export function assertCaptureFraming(kind: 'screenshot' | 'pdf', framing: CaptureFraming): void {
41
+ const clip = framing.clip;
42
+ if (clip === undefined) return;
43
+ // A PDF is paginated by the print engine; CDP's `Page.printToPDF` has no clip to forward one to.
44
+ // Refused rather than dropped: a whole-page PDF returned to a caller who asked for one component
45
+ // is the same silent wrong answer the fullPage/clip pair produces.
46
+ if (kind === 'pdf') throw captureClipOnPdf(clip);
47
+ // `=== true` and not truthiness: `fullPage: false` beside a clip is a caller spelling out the
48
+ // default, which is not a conflict. Only the pair that CDP resolves silently is refused.
49
+ if (framing.fullPage === true) throw captureFramingConflict(clip);
50
+ if (!finite(clip.x) || !finite(clip.y) || !finite(clip.width) || !finite(clip.height)) {
51
+ throw captureClipEmpty(clip, 'is not four finite numbers');
52
+ }
53
+ if (clip.width <= 0 || clip.height <= 0) throw captureClipEmpty(clip, 'has no area');
54
+ if (clip.x + clip.width <= 0 || clip.y + clip.height <= 0) {
55
+ throw captureClipEmpty(clip, 'lies entirely in negative coordinates, where no page content is');
56
+ }
57
+ }
package/src/cdp-port.ts CHANGED
@@ -12,6 +12,25 @@
12
12
  // `connect({ browserWSEndpoint })`, headless Chrome 150): the WebSocket upgrade Playwright's
13
13
  // `connectOverCDP` cannot do under Bun works here, which is why this is the intended library.
14
14
 
15
+ /**
16
+ * The library's screenshot options, restated here rather than shared with `CaptureClip`.
17
+ *
18
+ * This file is the shape of somebody ELSE's object, and importing this package's own vocabulary
19
+ * into it would make a puppeteer call signature depend on a scraping type — the direction this
20
+ * seam exists to forbid. The two shapes are structurally identical, so a `CaptureClip` passes
21
+ * where this is expected and no cast is needed anywhere.
22
+ */
23
+ export interface CdpScreenshotOptions {
24
+ readonly fullPage?: boolean;
25
+ /** CSS pixels, page coordinates. Set INSTEAD of `fullPage`, never beside it. */
26
+ readonly clip?: {
27
+ readonly x: number;
28
+ readonly y: number;
29
+ readonly width: number;
30
+ readonly height: number;
31
+ };
32
+ }
33
+
15
34
  export interface CdpRequestLike {
16
35
  url(): string;
17
36
  resourceType(): string;
@@ -34,7 +53,7 @@ export interface CdpPageLike {
34
53
  click(selector: string): Promise<void>;
35
54
  type(selector: string, text: string): Promise<void>;
36
55
  select(selector: string, ...values: string[]): Promise<string[]>;
37
- screenshot(options: { readonly fullPage?: boolean }): Promise<Uint8Array | string>;
56
+ screenshot(options: CdpScreenshotOptions): Promise<Uint8Array | string>;
38
57
  pdf(options?: Record<string, unknown>): Promise<Uint8Array>;
39
58
  setRequestInterception(enabled: boolean): Promise<void>;
40
59
  /**
package/src/cdp-target.ts CHANGED
@@ -329,7 +329,14 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
329
329
  evaluate: (expression) => guard('evaluate', () => init.page.evaluate(expression)),
330
330
  screenshot: (options: CaptureOptions) =>
331
331
  guard('screenshot', async () => {
332
- const shot = await init.page.screenshot({ fullPage: options.fullPage === true });
332
+ // `fullPage` is OMITTED when a clip is given rather than sent as `false`: the two are
333
+ // exclusive at the library too, and some builds refuse the pair on truthiness while others
334
+ // resolve it silently. Sending only what was asked for is the request neither can misread.
335
+ const shot = await init.page.screenshot(
336
+ options.clip === undefined
337
+ ? { fullPage: options.fullPage === true }
338
+ : { clip: options.clip },
339
+ );
333
340
  // Some builds answer base64 text, some answer bytes. `atob` is the one decoder both a
334
341
  // browser and Bun agree on, and it keeps this file free of a Buffer import.
335
342
  return typeof shot === 'string'
@@ -3,7 +3,8 @@
3
3
  // a browser is rendered through core's `renderThrowable`, which is the rule `bun run error-render`
4
4
  // enforces.
5
5
 
6
- import { renderThrowable, UltimateError } from '@ultimat3/core';
6
+ import { isRetryableStatus, renderThrowable, UltimateError } from '@ultimat3/core';
7
+ import type { CaptureClip } from './capture-clip';
7
8
  import { ScrapeError } from './errors';
8
9
 
9
10
  /**
@@ -230,14 +231,29 @@ export const secretExposed = (artifact: string, url: string): ScrapeError =>
230
231
  meta: { artifact, url },
231
232
  });
232
233
 
233
- /** A non-2xx from the HTTP leg. 4xx below 429 is terminal; everything else may be tried again. */
234
+ /**
235
+ * A non-2xx from the HTTP leg. The per-instance override that `errors.ts` reserves for this code:
236
+ * the code is registered `retryable`, and only a PERMANENT status downgrades an instance.
237
+ *
238
+ * Which 4xx are permanent is `@ultimat3/core`'s `isRetryableStatus` and not this package's opinion.
239
+ * Scraping had the framework's fifth copy of that table and it had drifted: `status !== 429` alone
240
+ * made a 408, a 409 and a 425 terminal here while `cache`, `mail` and `ai` all called them
241
+ * retryable — and terminal is not documentation, it dead-letters the run on the attempt that threw
242
+ * it (`packages/jobs/src/retry-classification.ts`), so a scrape burned a five-attempt policy on one
243
+ * request timeout. Nothing about a SCRAPE argues for a different answer: the statuses that could,
244
+ * 401 and 403, are permanent in core's table too and stay terminal here.
245
+ *
246
+ * The 4xx window stays because it is the range this override speaks for. A sub-400 non-ok — a 304
247
+ * is the reachable one — keeps the code's registered `retryable`: an early dead-letter on a status
248
+ * no table in the framework classifies is the expensive way to guess.
249
+ */
234
250
  export const httpFailed = (url: string, status: number, body: string): ScrapeError =>
235
251
  new ScrapeError({
236
252
  code: 'X_SCRAPE_HTTP_FAILED',
237
253
  cause: `${url} answered ${String(status)}: ${body}`,
238
254
  fix: 'confirm the endpoint the browser leg calls is the one this request names — page.network() lists every URL the page actually fetched',
239
255
  meta: { url, status },
240
- retry: status >= 400 && status < 500 && status !== 429 ? 'terminal' : 'retryable',
256
+ retry: status >= 400 && status < 500 && !isRetryableStatus(status) ? 'terminal' : 'retryable',
241
257
  });
242
258
 
243
259
  /**
@@ -306,3 +322,35 @@ export const scrapeNotImplemented = (feature: string, fix: string): UltimateErro
306
322
  fix,
307
323
  meta: { feature },
308
324
  });
325
+
326
+ /**
327
+ * The crop rectangle's three refusals, one code. Each is a caller's DECLARATION being impossible
328
+ * rather than a site behaving badly, so each names the edit that fixes it — and the alternative in
329
+ * every case is a picture that looks like a successful capture and is not of what was asked for.
330
+ */
331
+ const clipText = (clip: CaptureClip): string =>
332
+ `${String(clip.width)}x${String(clip.height)} at ${String(clip.x)},${String(clip.y)}`;
333
+
334
+ export const captureFramingConflict = (clip: CaptureClip): ScrapeError =>
335
+ new ScrapeError({
336
+ code: 'X_SCRAPE_CAPTURE_INVALID',
337
+ cause: `this capture names both fullPage: true and clip ${clipText(clip)}, and a browser silently honours one of them without saying which`,
338
+ fix: 'drop fullPage — a clip is already the whole request — or drop clip to photograph the whole page',
339
+ meta: { clip: { ...clip }, fullPage: true },
340
+ });
341
+
342
+ export const captureClipEmpty = (clip: CaptureClip, reason: string): ScrapeError =>
343
+ new ScrapeError({
344
+ code: 'X_SCRAPE_CAPTURE_INVALID',
345
+ cause: `the clip ${clipText(clip)} ${reason}, so the capture would answer a blank picture that reads as a success`,
346
+ fix: "measure the element first — page.query('<selector>') answers a box with a positive width and height — and pass that rectangle as clip",
347
+ meta: { clip: { ...clip } },
348
+ });
349
+
350
+ export const captureClipOnPdf = (clip: CaptureClip): ScrapeError =>
351
+ new ScrapeError({
352
+ code: 'X_SCRAPE_CAPTURE_INVALID',
353
+ cause: `page.pdf() was given the clip ${clipText(clip)} and a PDF has no crop rectangle — the print engine paginates the whole document`,
354
+ fix: 'call page.screenshot({ clip }) for one component, or page.pdf() with no clip for the document',
355
+ meta: { clip: { ...clip } },
356
+ });
package/src/errors.ts CHANGED
@@ -29,6 +29,7 @@ export const SCRAPE_OWNED_ERROR_CODES = [
29
29
  'X_SCRAPE_REMOTE_REQUIRED',
30
30
  'X_SCRAPE_RECOVER_REFUSED',
31
31
  'X_SCRAPE_SECRET_EXPOSED',
32
+ 'X_SCRAPE_CAPTURE_INVALID',
32
33
  'X_SCRAPE_HTTP_FAILED',
33
34
  'X_SCRAPE_BODY_TOO_LARGE',
34
35
  'X_SCRAPE_AUTH_FAILED',
@@ -77,6 +78,7 @@ export const SCRAPE_ERROR_TITLES: Readonly<Record<ScrapeOwnedErrorCode, string>>
77
78
  X_SCRAPE_REMOTE_REQUIRED: 'this driver needs a cdpUrl and was given none',
78
79
  X_SCRAPE_RECOVER_REFUSED: 'the recovery hook declined to recover this failure',
79
80
  X_SCRAPE_SECRET_EXPOSED: 'an artifact would have carried a secret this run typed',
81
+ X_SCRAPE_CAPTURE_INVALID: 'the capture names a framing no picture can be taken with',
80
82
  X_SCRAPE_HTTP_FAILED: 'the site answered the HTTP leg with a non-2xx status',
81
83
  X_SCRAPE_BODY_TOO_LARGE: 'the HTTP response body passed its byte cap',
82
84
  X_SCRAPE_AUTH_FAILED: 'the credentials were rejected',
@@ -112,9 +114,12 @@ export const SCRAPE_ERROR_RETRY = {
112
114
  // persisted identity re-trips it every time: the flagged cookies are the thing being refused,
113
115
  // so the retry has to arrive as somebody else or it is arithmetic, not a retry.
114
116
  X_SCRAPE_BLOCKED: 'retryable',
115
- // Non-2xx is transient far more often than not (429, 502, a deploy). A 4xx that is genuinely
116
- // permanent is thrown with a per-instance `terminal` override, which `UltimateError` supports —
117
- // one code, and the throw site decides, because the same status is both at different sites.
117
+ // Non-2xx is transient far more often than not (408, 429, 502, a deploy). A 4xx that is
118
+ // genuinely permanent is thrown with a per-instance `terminal` override, which `UltimateError`
119
+ // supports — one code, and the throw site decides, because the same status is both at different
120
+ // sites. WHICH 4xx are permanent is not decided here: `httpFailed` reads core's
121
+ // `isRetryableStatus`, the framework's one table, so this leg cannot drift from `cache`, `mail`
122
+ // and `ai` again — it had, on 408, 409 and 425.
118
123
  X_SCRAPE_HTTP_FAILED: 'retryable',
119
124
  // Everything below is terminal, and each one is listed rather than left to the default so that
120
125
  // deleting a line is a visible decision.
@@ -135,6 +140,11 @@ export const SCRAPE_ERROR_RETRY = {
135
140
  // A declaration error, raised by `scrape()` before any attempt exists — there is no run to
136
141
  // retry, and the same definition would refuse identically forever.
137
142
  X_SCRAPE_YIELD_HISTORY_MISSING: 'terminal',
143
+ // A declaration error too, and the early dead letter is the CORRECT answer here: the rectangle
144
+ // is the caller's own literal, attempt 2 passes the identical one, and a retry is a browser
145
+ // launch and a login for no chance of a different verdict. Refused before any driver runs, so
146
+ // there is no half-done capture to resume.
147
+ X_SCRAPE_CAPTURE_INVALID: 'terminal',
138
148
  // TERMINAL where its sibling `X_SCRAPE_WEDGED` is retryable, and the difference IS the reason
139
149
  // the two codes are separate. A wedge is the site or the browser being slow — the definition of
140
150
  // "run it again and it may go differently". This is the guard's own loop dying on code the
@@ -5,6 +5,7 @@
5
5
  // through to the network would make a green offline suite that is secretly hitting production —
6
6
  // the exact failure an offline driver exists to prevent.
7
7
 
8
+ import type { CaptureClip } from './capture-clip';
8
9
  import type { ScrapeClock } from './clock';
9
10
  import { browserUnreachable, downloadTimeout, fixtureMissing, fixtureStale } from './error-throws';
10
11
  import { queryHtml } from './html-query';
@@ -29,6 +30,22 @@ import type {
29
30
 
30
31
  /** Deterministic bytes, so an artifact test asserts on a stable digest. Not a real PNG render. */
31
32
  const FAKE_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
33
+
34
+ /**
35
+ * A clipped capture answers DIFFERENT deterministic bytes, one picture per rectangle. CI has no
36
+ * Chrome, so the offline drivers are the only place a crop can be proved at all: a fake that
37
+ * answered the same eight bytes whatever the rectangle would let a driver that silently drops the
38
+ * clip pass every test there is. Unclipped bytes are unchanged, byte for byte.
39
+ */
40
+ const clippedPng = (clip: CaptureClip): Uint8Array => {
41
+ const suffix = new TextEncoder().encode(
42
+ ` clip ${String(clip.x)},${String(clip.y)},${String(clip.width)},${String(clip.height)}`,
43
+ );
44
+ const out = new Uint8Array(FAKE_PNG.length + suffix.length);
45
+ out.set(FAKE_PNG);
46
+ out.set(suffix, FAKE_PNG.length);
47
+ return out;
48
+ };
32
49
  const FAKE_PDF = new TextEncoder().encode('%PDF-1.4 offline');
33
50
 
34
51
  export type RecordingLookup = (url: string) => Promise<PageRecording | undefined>;
@@ -208,7 +225,8 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
208
225
  throw fixtureMissing(`${page.url} evaluate(${expression})`, init.source);
209
226
  return Promise.resolve(JSON.parse(answer) as unknown);
210
227
  },
211
- screenshot: (_options: CaptureOptions): Promise<Uint8Array> => Promise.resolve(FAKE_PNG),
228
+ screenshot: (options: CaptureOptions): Promise<Uint8Array> =>
229
+ Promise.resolve(options.clip === undefined ? FAKE_PNG : clippedPng(options.clip)),
212
230
  pdf: (_options: CaptureOptions): Promise<Uint8Array> => Promise.resolve(FAKE_PDF),
213
231
  cookies: (): Promise<readonly ScrapeCookie[]> => Promise.resolve(session.cookies),
214
232
  session: (): Promise<SessionSnapshot> => Promise.resolve(session),
package/src/index.ts CHANGED
@@ -18,12 +18,15 @@ export type {
18
18
  ScrapeAuth,
19
19
  } from './auth';
20
20
  export { burnSession, createPrompt, ensureAuthenticated, restorableSession } from './auth';
21
+ export type { CaptureClip, CaptureFraming } from './capture-clip';
22
+ export { assertCaptureFraming } from './capture-clip';
21
23
  export type {
22
24
  CdpBrowserLike,
23
25
  CdpFrameLike,
24
26
  CdpLauncherLike,
25
27
  CdpPageLike,
26
28
  CdpRequestLike,
29
+ CdpScreenshotOptions,
27
30
  } from './cdp-port';
28
31
  export { parseSnapshots, snapshotExpression } from './cdp-snapshot';
29
32
  export type { CdpTargetInit } from './cdp-target';
@@ -7,6 +7,7 @@ import type { Secret } from '@ultimat3/core';
7
7
  import { isSecret, revealSecret } from '@ultimat3/core';
8
8
  import type { ActionabilityState } from './actionability';
9
9
  import { awaitActionable } from './actionability';
10
+ import { assertCaptureFraming } from './capture-clip';
10
11
  import type { ScrapeClock } from './clock';
11
12
  import { deadline } from './clock';
12
13
  import { hostBlocked, secretExposed, selectorMissing } from './error-throws';
@@ -22,7 +23,13 @@ import type {
22
23
  import type { RobotsGate } from './robots';
23
24
  import type { ScrapeSecrets } from './secrets';
24
25
  import { safeHtml } from './secrets';
25
- import type { ElementSnapshot, ScrapeCookie, ScrapeDownloadFile, ScrapeTarget } from './target';
26
+ import type {
27
+ CaptureOptions,
28
+ ElementSnapshot,
29
+ ScrapeCookie,
30
+ ScrapeDownloadFile,
31
+ ScrapeTarget,
32
+ } from './target';
26
33
  import { ROOT_SELECTOR } from './target';
27
34
 
28
35
  export interface PageContext {
@@ -187,7 +194,14 @@ export function pageOverTarget(target: ScrapeTarget, ctx: PageContext): ScrapePa
187
194
  // in object storage, forever. Refused rather than masked — a mask over pixels is a guess
188
195
  // about layout, and `page.html()` already gives a redacted artifact that is exact.
189
196
  if (state.tainted) throw secretExposed(kind, target.url());
190
- const request = { fullPage: options?.fullPage };
197
+ // The ONE place a capture request is built, so the framing rule is checked once for every
198
+ // driver rather than three times with three chances to disagree. After the secret guard, which
199
+ // is the stronger refusal: a tainted page must not be told its rectangle is fine.
200
+ assertCaptureFraming(kind, options ?? {});
201
+ const request: CaptureOptions = {
202
+ fullPage: options?.fullPage,
203
+ ...(options?.clip === undefined ? {} : { clip: options.clip }),
204
+ };
191
205
  return kind === 'screenshot' ? target.screenshot(request) : target.pdf(request);
192
206
  };
193
207
  return {
package/src/page.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { Secret } from '@ultimat3/core';
10
10
  import type { ActionabilityState } from './actionability';
11
+ import type { CaptureFraming } from './capture-clip';
11
12
  import type { ConsoleLine, NetworkEntry, PageError } from './rings';
12
13
  import type { SessionSnapshot } from './session-state';
13
14
  import type { ElementSnapshot, ScrapeCookie, ScrapeDownloadFile } from './target';
@@ -64,10 +65,13 @@ export interface ScrapeFrame {
64
65
  frame(nameOrSelector: string): ScrapeFrame;
65
66
  }
66
67
 
67
- /** `fullPage` only. `timeout` is gone with the port's — see `CaptureOptions` for why. */
68
- export interface CaptureRequest {
69
- readonly fullPage?: boolean | undefined;
70
- }
68
+ /**
69
+ * `fullPage`, or a `clip` — never both. `timeout` is gone with the port's; see `CaptureOptions`.
70
+ *
71
+ * A clip is what makes a capture reviewable by a vision model: the whole viewport spends the
72
+ * reader's scarce pixels on everything that is not the component under review.
73
+ */
74
+ export type CaptureRequest = CaptureFraming;
71
75
 
72
76
  export interface DownloadRequest {
73
77
  readonly timeout?: number | undefined;
package/src/target.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // lets `page-over-target.ts` be the ONE implementation of `ScrapePage` for the real browser, the
4
4
  // fixture replayer and the fake alike.
5
5
 
6
+ import type { CaptureFraming } from './capture-clip';
6
7
  import type { ConsoleRing, NetworkRing, PageErrorRing } from './rings';
7
8
  import type { SessionSnapshot } from './session-state';
8
9
 
@@ -75,7 +76,12 @@ export interface GotoOptions {
75
76
  }
76
77
 
77
78
  /**
78
- * `fullPage` and nothing else. It carried a required `timeoutMs` until 2026-08 that NO driver
79
+ * `CaptureFraming`, under the port's own name: `fullPage` for the whole document, or a `clip` to
80
+ * crop to one rectangle, never both. ONE declaration for the port and the vocabulary — the two
81
+ * were separate copies of `{ fullPage }` and a field added to one would have reached neither the
82
+ * drivers nor the callers.
83
+ *
84
+ * It carried a required `timeoutMs` until 2026-08 that NO driver
79
85
  * honoured — `cdp-target.ts` read only `fullPage`, `html-target.ts` ignored the whole object —
80
86
  * so `page.screenshot({ timeout })` was a documented deadline that bounded nothing. Deleted
81
87
  * rather than implemented: the CDP port's own `screenshot({ fullPage })` has no timeout slot to
@@ -83,9 +89,7 @@ export interface GotoOptions {
83
89
  * `ScrapeClock.sleep`, which under `testClock` resolves on the first microtask and would time
84
90
  * out every capture in every test. A driver's own default is the honest bound.
85
91
  */
86
- export interface CaptureOptions {
87
- readonly fullPage?: boolean | undefined;
88
- }
92
+ export type CaptureOptions = CaptureFraming;
89
93
 
90
94
  /**
91
95
  * The port. Twelve methods, every one of them something a browser genuinely does and nothing a