@ultimat3/scraping 23.0.0 → 25.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
@@ -84,6 +84,15 @@ Both legs replay from **one** fixture directory (`fixtureBrowser(dir)`), so a hy
84
84
  login, session handoff, HTTP bulk fetch — is tested end to end. Both legs apply the same robots
85
85
  gate, offline included.
86
86
 
87
+ The robots gate follows RFC 9309 §2.3.1: a `4xx` robots.txt is **unavailable** (no rules); a `5xx`,
88
+ a network error or the deadline is **unreachable**, which refuses every path on the origin
89
+ (`X_SCRAPE_ROBOTS_DISALLOWED`) and is not cached. A `429` is a `4xx` the RFC calls unavailable; this
90
+ gate treats it as unreachable anyway — our stricter policy, since a server that is rate-limiting
91
+ the crawler has not said "no rules". A hand-written `fetchText` answers the text,
92
+ `undefined` (no file) or `{ unreachable: '<why>' }` — `RobotsAnswer`. An offline scrape declares
93
+ `robots: { ignore: 'offline fixture' }`: under the sealed test network its robots read is
94
+ unreachable.
95
+
87
96
  ## A session a service is built on
88
97
 
89
98
  One connection, one run at a time, on its own exit, in a browser rented for that run — with a
@@ -210,7 +219,7 @@ export const answer = (input: { runId: string; index: number; answer: string })
210
219
  | `onSettled` | the job's own hook (`@ultimat3/jobs`), with what a scrape adds: `completed` hands over the `ScrapeReport`; `dead-lettered` / `dropped` / `refused` carry `code` and the last attempt's `usage` (`undefined` for `refused` — the body never ran). After the row is settled, under the job's tenant, **at most once** across a crash, and a hook that throws changes nothing |
211
220
  | `run({ progress, finalAttempt })` | the job's own, passed through |
212
221
  | `prompt` | `eventPrompt({ timeout, pollMs?, bus?, keySource?, env? })` polls the event bus in process — never `step.waitForEvent`, which would close the browser. An in-memory bus (`stored: false`) is refused outside development and test (`X_DRIVER_UNAVAILABLE`): the answer comes from another process. Event `promptEventName(runId, index)`; answered by `answerPrompt({ runId, index, answer })`, sealed for that one name. Timeout is `X_SCRAPE_PROMPT_UNANSWERED` |
213
- | the bus | the **stored** one (`createPgEventBus`) wherever the worker and the answering process differ. Every `x` boot installs it |
222
+ | the bus | the **stored** one (`postgresEventBus`) wherever the worker and the answering process differ. Every `x` boot installs it |
214
223
 
215
224
  `PromptRequest` carries the run's `input` and `runId` — what ties a prompt to the app's own row
216
225
  for the run — and `index`, `clock`, `signal` and `keepAlive()` beside `label`, `scrape` and `url`:
@@ -240,7 +249,7 @@ resetScrapeClock(); // in the afterEach
240
249
  |---|---|---|
241
250
  | `systemScrapeClock` | waits | production |
242
251
  | `noWaitClock` | one turn of the event loop, whatever was asked; `now()` and deadlines are real | a test whose run waits on something that really happens in the same process — an answer the test publishes |
243
- | `testClock()` | advances virtual time and yields a microtask | a test of a timeout: thirty seconds pass in none |
252
+ | `testScrapeClock()` | advances virtual time and yields a microtask | a test of a timeout: thirty seconds pass in none |
244
253
 
245
254
  A third-party driver takes part by reading three `SessionInit` fields: dial `proxy` or throw
246
255
  `egressUnsupported()`, pass `usage` to `pageOverTarget` and its HTTP transport, and answer
@@ -261,6 +270,14 @@ the port's. A caller that had to compute its own wrote
261
270
  `display !== 'none' && visibility !== 'hidden' && opacity !== '0'` a second time, which is the
262
271
  copy axiom 1 forbids.
263
272
 
273
+ **An act scrolls its element into view; a read does not.** The `actionable` wait (every `click`,
274
+ `type`, `fill`, `select`, `focus`, and `waitFor` at its default state) calls
275
+ `ScrapeTarget.query(selector, { reveal: true })`, which scrolls the first match to the viewport's
276
+ centre before measuring — `elementFromPoint` answers nothing off-screen, so an element below the
277
+ fold read as covered. `text()`, `count()`, `values()` and `query()` never move the page. A target
278
+ with no layout engine ignores `QueryOptions` (`cdp-target-reveal.test.ts`,
279
+ `cdp-snapshot.e2e.test.ts`).
280
+
264
281
  **A frame verb reaches the FRAME.** `fill`, `type`, `select`, `clear`, `click` and `query` through
265
282
  a `ScrapeFrame` handle all address that frame's own document — never the parent's, even when the
266
283
  two carry the same ids, which is what an iframe'd SSO login looks like. `driver-parity-frames.test.ts`
@@ -313,8 +330,9 @@ bound threw away, so a count taken from one is never quietly a floor.
313
330
  All three are **redacted by value** on the way out, the same pass `page.html()` makes: a login
314
331
  endpoint fetched with the password in its query string, or a site that logs the credential it
315
332
  rejected, would otherwise put the value in `page.network()`/`page.console()` verbatim and from
316
- there into the stored failure artifact. The HTTP leg's `X_SCRAPE_HTTP_FAILED` cause is redacted
317
- too, at its throw site. Values shorter than `MIN_REDACTABLE_LENGTH` are not — a 3-character PIN is
333
+ there into the stored failure artifact — percent-encoded, form-encoded or HTML-escaped as much as
334
+ raw, since a password in a query string is never the raw string. The HTTP leg's
335
+ `X_SCRAPE_HTTP_FAILED` cause is redacted too, at its throw site. Values shorter than `MIN_REDACTABLE_LENGTH` are not — a 3-character PIN is
318
336
  a substring of ordinary prose — and pixels never can be, which is why a typed secret TAINTS the
319
337
  page and `screenshot()`/`pdf()` are refused outright.
320
338
 
@@ -362,7 +380,7 @@ on a machine with no browser — the case CI is.
362
380
  | `driver-cdp.ts` / `cdp-*.ts` | the real browser, over a structural CDP port |
363
381
  | `driver-fake.ts` / `driver-recorded.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
364
382
  | `http.ts` / `http-recorded.ts` | the second transport, live and replayed |
365
- | `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. A stored session is sealed |
383
+ | `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. A stored session is sealed. `burnSession(plan, seen)` takes `recordVersion(record)` of the record the run used, so a record another run saved since is not burned |
366
384
  | `event-prompt.ts` | `eventPrompt()` and `answerPrompt()`: a prompt answered from another process, over the job event bus, with the browser open |
367
385
  | `cdp-resolver.ts` | a browser rented per session: acquired once, released once |
368
386
  | `usage.ts` | what one run used, counted where the work happens |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "23.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "Browser automation as a job: scrape() returns a JobHandle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,16 +24,16 @@
24
24
  "LICENSE"
25
25
  ],
26
26
  "engines": {
27
- "bun": ">=1.4.0"
27
+ "bun": ">=1.4.2"
28
28
  },
29
29
  "scripts": {
30
30
  "typecheck": "tsc --noEmit -p tsconfig.json",
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "23.0.0",
35
- "@ultimat3/jobs": "23.0.0",
36
- "@ultimat3/schema": "23.0.0",
37
- "@ultimat3/storage": "23.0.0"
34
+ "@ultimat3/core": "25.0.0",
35
+ "@ultimat3/jobs": "25.0.0",
36
+ "@ultimat3/schema": "25.0.0",
37
+ "@ultimat3/storage": "25.0.0"
38
38
  }
39
39
  }
package/src/artifacts.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // Written through `@ultimat3/storage`'s driver, so the same call lands on a local disk in
6
6
  // development and on S3 in production, and this package owns no upload path of its own.
7
7
 
8
- import type { StorageDriver } from '@ultimat3/storage';
8
+ import { DEFAULT_CONTENT_TYPE, type StorageDriver } from '@ultimat3/storage';
9
9
 
10
10
  export interface ArtifactRef {
11
11
  readonly key: string;
@@ -31,8 +31,6 @@ export interface ArtifactWriterInit {
31
31
 
32
32
  export const DEFAULT_ARTIFACT_PREFIX = 'scrape';
33
33
 
34
- export const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
35
-
36
34
  /**
37
35
  * A `Map`, not an object literal — the same choice `failures.ts` makes, for the same reason. The
38
36
  * extension comes off a caller-supplied filename, and on a download that filename came off the
@@ -57,7 +55,7 @@ export const contentTypeFor = (name: string): string =>
57
55
  * that has not configured storage should still be able to run a scrape; losing the artifact is a
58
56
  * cost, and refusing the run over it is a bigger one.
59
57
  */
60
- export function createArtifactWriter(init: ArtifactWriterInit): ArtifactWriter {
58
+ export function artifactWriter(init: ArtifactWriterInit): ArtifactWriter {
61
59
  const saved: ArtifactRef[] = [];
62
60
  const prefix = `${init.prefix ?? DEFAULT_ARTIFACT_PREFIX}/${init.scrape}/${init.runId}`;
63
61
  return {
package/src/auth.ts CHANGED
@@ -20,10 +20,15 @@
20
20
  import type { Logger } from '@ultimat3/core';
21
21
  import { finiteCount } from '@ultimat3/core';
22
22
  import type { ScrapeClock } from './clock';
23
- import { authFailed, promptUnanswered, sessionExpired } from './error-throws';
23
+ import { authFailed, promptUnanswered, scrapeSessionExpired } from './error-throws';
24
24
  import type { ScrapePage } from './page';
25
25
  import type { ScrapeSecrets } from './secrets';
26
- import { type ScrapeSessionStore, type SessionState, sessionDigest } from './session-state';
26
+ import {
27
+ recordVersion,
28
+ type ScrapeSessionStore,
29
+ type SessionState,
30
+ sessionDigest,
31
+ } from './session-state';
27
32
 
28
33
  export interface PromptRequest<I = unknown> {
29
34
  /**
@@ -119,7 +124,7 @@ export interface PromptInit<I = unknown> {
119
124
  */
120
125
  const KEEPALIVE_SELECTOR = 'ultimate-keepalive';
121
126
 
122
- export function createPrompt<I>(init: PromptInit<I>): (label: string) => Promise<string> {
127
+ export function scrapePrompt<I>(init: PromptInit<I>): (label: string) => Promise<string> {
123
128
  let asked = 0;
124
129
  return async (label: string): Promise<string> => {
125
130
  const { scrape, handler, page } = init;
@@ -167,10 +172,30 @@ export interface AuthPlanInput<I> {
167
172
  export async function restorableSession<I>(
168
173
  plan: AuthPlanInput<I>,
169
174
  ): Promise<SessionState | undefined> {
175
+ return (await observeSession(plan)).restorable;
176
+ }
177
+
178
+ /** What `observeSession` answers: the session to restore, and the version of what was found. */
179
+ export interface ObservedSession {
180
+ readonly restorable: SessionState | undefined;
181
+ /**
182
+ * `recordVersion` of the record found — restorable or NOT. A `reuse: false` run, or one whose
183
+ * record is past `maxAge`, still saw that record: a refused login must tombstone over it, and a
184
+ * run that compared against "nothing" let the rejected credential through again.
185
+ */
186
+ readonly seen: string | undefined;
187
+ }
188
+
189
+ /** `restorableSession`, plus the version of the record found, for the run's later writes. */
190
+ export async function observeSession<I>(plan: AuthPlanInput<I>): Promise<ObservedSession> {
170
191
  const store = plan.auth?.store;
171
- if (store === undefined) return undefined;
192
+ if (store === undefined) return { restorable: undefined, seen: undefined };
172
193
  const found = await store.load(plan.key);
173
- if (found === undefined) return undefined;
194
+ if (found === undefined) return { restorable: undefined, seen: undefined };
195
+ return { restorable: restorableFrom(plan, found), seen: recordVersion(found) };
196
+ }
197
+
198
+ function restorableFrom<I>(plan: AuthPlanInput<I>, found: SessionState): SessionState | undefined {
174
199
  // The tombstone is read BEFORE `reuse` is honoured. `reuse: false` says "do not restore this
175
200
  // session"; it does not say "present the rejected credential again", and reading it first meant
176
201
  // a `reuse: false` scrape walked a refused password back to the login form on every requeue.
@@ -194,13 +219,42 @@ export async function restorableSession<I>(
194
219
  return found;
195
220
  }
196
221
 
197
- /** Written down so the next attempt cannot reach the login form with the same credentials. */
198
- export async function markRefused<I>(plan: AuthPlanInput<I>): Promise<void> {
222
+ /**
223
+ * True when the stored record is still the one this run last saw — `seen` is the `recordVersion`
224
+ * it found or persisted, `undefined` when it found none. The store has no compare-and-set (nor has
225
+ * `StorageDriver` a conditional write to build one from), so a
226
+ * burn or a tombstone is a blind write: two runs on one key, and the one that failed destroyed the
227
+ * session the other had just persisted. Compared before the write, which narrows the race to the
228
+ * gap between this read and that write rather than the whole run. A record moved on is left alone.
229
+ */
230
+ async function stillSeen<I>(
231
+ plan: AuthPlanInput<I>,
232
+ store: ScrapeSessionStore,
233
+ seen: string | undefined,
234
+ step: 'session.burn' | 'session.refuse',
235
+ ): Promise<boolean> {
236
+ const current = await store.load(plan.key);
237
+ if (current === undefined || recordVersion(current) === seen) return true;
238
+ plan.logger.warn('scrape.session.superseded', { step });
239
+ return false;
240
+ }
241
+
242
+ /**
243
+ * Written down so the next attempt cannot reach the login form with the same credentials — unless
244
+ * another run saved a session after this one read the record: that login SUCCEEDED, so this
245
+ * refusal is older news than the record it would overwrite.
246
+ */
247
+ export async function markRefused<I>(
248
+ plan: AuthPlanInput<I>,
249
+ seen: string | undefined,
250
+ ): Promise<void> {
199
251
  const store = plan.auth?.store;
200
252
  if (store === undefined) return;
253
+ if (!(await stillSeen(plan, store, seen, 'session.refuse'))) return;
201
254
  await store.save({
202
255
  key: plan.key,
203
256
  savedAt: plan.clock.now().toISOString(),
257
+ version: crypto.randomUUID(),
204
258
  refusedAt: plan.clock.now().toISOString(),
205
259
  cookies: [],
206
260
  headers: {},
@@ -210,10 +264,19 @@ export async function markRefused<I>(plan: AuthPlanInput<I>): Promise<void> {
210
264
  });
211
265
  }
212
266
 
213
- /** New identity, from scratch. One call, because a flagged profile is unusable and must go. */
214
- export async function burnSession<I>(plan: AuthPlanInput<I>): Promise<void> {
215
- if (plan.auth?.store === undefined) return;
216
- await plan.auth.store.burn(plan.key);
267
+ /**
268
+ * New identity, from scratch. One call, because a flagged profile is unusable and must go — the
269
+ * profile THIS run used: `seen` is that record's `recordVersion` (`observeSession`'s, or the one
270
+ * `persistSession` answered), and a record another run saved since is not burned.
271
+ */
272
+ export async function burnSession<I>(
273
+ plan: AuthPlanInput<I>,
274
+ seen: string | undefined,
275
+ ): Promise<void> {
276
+ const store = plan.auth?.store;
277
+ if (store === undefined) return;
278
+ if (!(await stillSeen(plan, store, seen, 'session.burn'))) return;
279
+ await store.burn(plan.key);
217
280
  plan.logger.warn('scrape.session.burned', { burned: true });
218
281
  }
219
282
 
@@ -251,18 +314,31 @@ export async function ensureAuthenticated<I>(args: EnsureAuthInput<I>): Promise<
251
314
  return false;
252
315
  }
253
316
  args.logger.info('scrape.session.expired', { reused: false });
254
- await burnSession(args);
317
+ await burnSession(args, recordVersion(args.restored));
255
318
  }
256
- if (auth.login === undefined) throw sessionExpired(args.scrape, args.key);
319
+ if (auth.login === undefined) throw scrapeSessionExpired(args.scrape, args.key);
257
320
  await auth.login(context);
258
321
  return true;
259
322
  }
260
323
 
261
- /** After a login: keep what it produced. A 2FA code somebody typed is worth exactly this. */
262
- export async function persistSession<I>(plan: AuthPlanInput<I>, page: ScrapePage): Promise<void> {
324
+ /**
325
+ * After a login: keep what it produced. A 2FA code somebody typed is worth exactly this. Answers
326
+ * the `version` it wrote — the record this run now owns, for a later burn to compare against.
327
+ */
328
+ export async function persistSession<I>(
329
+ plan: AuthPlanInput<I>,
330
+ page: ScrapePage,
331
+ ): Promise<string | undefined> {
263
332
  const store = plan.auth?.store;
264
- if (store === undefined) return;
333
+ if (store === undefined) return undefined;
265
334
  const snapshot = await page.session();
266
- await store.save({ ...snapshot, key: plan.key, savedAt: plan.clock.now().toISOString() });
335
+ const version = crypto.randomUUID();
336
+ await store.save({
337
+ ...snapshot,
338
+ key: plan.key,
339
+ savedAt: plan.clock.now().toISOString(),
340
+ version,
341
+ });
267
342
  plan.logger.info('scrape.session.saved', sessionDigest(snapshot));
343
+ return version;
268
344
  }
package/src/cdp-arm.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // three verbs `press`/`focus`/`accessibility` did not fit under it. The extraction is VERBATIM —
5
5
  // the request, console, `pageerror` and `error` handlers are the same code, one file over.
6
6
 
7
- import type { CdpPageLike, CdpRequestLike } from './cdp-port';
7
+ import type { CdpBrowserLike, CdpPageLike, CdpRequestLike } from './cdp-port';
8
8
  import type { ScrapeClock } from './clock';
9
9
  import type { InterceptRules } from './intercept';
10
10
  import { interceptVerdict, refusalEntry } from './intercept';
@@ -174,3 +174,67 @@ export async function arm(init: CdpArmInit, sinks: CdpSinks): Promise<void> {
174
174
  crashed.value = readStringFrom(payload, 'message') ?? 'renderer crashed';
175
175
  });
176
176
  }
177
+
178
+ /** A `Fetch.requestPaused` payload, read structurally — the fields the verdict needs, or nothing. */
179
+ interface PausedRequest {
180
+ readonly requestId: string;
181
+ readonly resourceType: string;
182
+ readonly url: string;
183
+ readonly method: string;
184
+ }
185
+
186
+ const asPaused = (payload: unknown): PausedRequest | undefined => {
187
+ if (typeof payload !== 'object' || payload === null) return undefined;
188
+ const event = payload as Record<string, unknown>;
189
+ const request = event['request'];
190
+ if (typeof request !== 'object' || request === null) return undefined;
191
+ const { url, method } = request as Record<string, unknown>;
192
+ const { requestId, resourceType } = event;
193
+ if (typeof requestId !== 'string' || typeof url !== 'string') return undefined;
194
+ return {
195
+ requestId,
196
+ url,
197
+ resourceType: typeof resourceType === 'string' ? resourceType : 'Other',
198
+ method: typeof method === 'string' ? method : 'GET',
199
+ };
200
+ };
201
+
202
+ /**
203
+ * `allowHosts` and `block` on EVERY target the browser opens. Page-level interception (`arm`)
204
+ * covers the one page this package created; a popup — `window.open`, a `target=_blank` link — is
205
+ * a new target it never sees, and on Chrome 150 a scraped page's `window.open` to an off-list host
206
+ * reached that host. Browser-level `Fetch` pauses those requests too, and the same
207
+ * `interceptVerdict` decides them. The page's own requests are decided at the page first, so an
208
+ * allowed one is continued here as well and a refused one never arrives: one ring entry each.
209
+ *
210
+ * Enabled BEFORE the first navigation; a browser that refuses `Fetch.enable` rejects here, and
211
+ * `driver-cdp.ts`'s `opened()` closes it — a session whose rule would hold on one tab is refused.
212
+ */
213
+ export async function armBrowser(
214
+ init: {
215
+ readonly browser: CdpBrowserLike;
216
+ readonly rules: InterceptRules;
217
+ readonly clock: ScrapeClock;
218
+ },
219
+ network: NetworkRing,
220
+ ): Promise<void> {
221
+ const session = await init.browser.target().createCDPSession();
222
+ session.on('Fetch.requestPaused', (payload) => {
223
+ const paused = asPaused(payload);
224
+ if (paused === undefined) return;
225
+ const type = asResourceType(paused.resourceType.toLowerCase());
226
+ const verdict = interceptVerdict(paused.url, type, init.rules);
227
+ const requestId = { requestId: paused.requestId };
228
+ // Caught, never floated, for `arm`'s reason: the target may be gone by the time this answers.
229
+ if (verdict === 'allow') {
230
+ session.send('Fetch.continueRequest', requestId).catch(() => undefined);
231
+ return;
232
+ }
233
+ const at = init.clock.now().getTime();
234
+ network.push(refusalEntry(paused.url, type, verdict, at, paused.method));
235
+ session
236
+ .send('Fetch.failRequest', { ...requestId, errorReason: 'BlockedByClient' })
237
+ .catch(() => undefined);
238
+ });
239
+ await session.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
240
+ }
package/src/cdp-port.ts CHANGED
@@ -167,8 +167,29 @@ export interface CdpFrameLike {
167
167
  select(selector: string, ...values: string[]): Promise<string[]>;
168
168
  }
169
169
 
170
+ /**
171
+ * A raw session on the BROWSER target — `browser.target().createCDPSession()` under puppeteer —
172
+ * with the event half `CdpSessionLike` does not need: `Fetch.requestPaused` arrives as an event.
173
+ * `event` stays a bare `string` for `CdpPageLike.on`'s reason.
174
+ */
175
+ export interface CdpBrowserSessionLike extends CdpSessionLike {
176
+ on(event: string, handler: (payload: unknown) => void): unknown;
177
+ }
178
+
179
+ /** The browser's own target — `browser.target()` under puppeteer. */
180
+ export interface CdpBrowserTargetLike {
181
+ createCDPSession(): Promise<CdpBrowserSessionLike>;
182
+ }
183
+
170
184
  export interface CdpBrowserLike {
171
185
  newPage(): Promise<CdpPageLike>;
186
+ /**
187
+ * REQUIRED, unlike the page's optional verbs: it is how `allowHosts` reaches every target the
188
+ * browser opens — a popup, a `target=_blank` link — and not only the one page this package
189
+ * created. Page-level interception never sees those, and a rule that holds on one tab of a
190
+ * browser a scraped site controls is advisory. A launcher without it does not compile.
191
+ */
192
+ target(): CdpBrowserTargetLike;
172
193
  /** Present on a browser that owns a cookie jar. Absent ones answer `X_NOT_IMPLEMENTED`. */
173
194
  cookies?(): Promise<unknown>;
174
195
  setCookie?(...cookies: readonly unknown[]): Promise<void>;
@@ -8,16 +8,24 @@
8
8
  import type { StandardSchemaV1 } from '@ultimat3/schema';
9
9
  import { parse, t } from '@ultimat3/schema';
10
10
  import { browserRecord } from './browser-record';
11
- import type { ElementSnapshot } from './target';
11
+ import type { ElementSnapshot, QueryOptions } from './target';
12
12
 
13
13
  /**
14
14
  * Returns JSON TEXT, not an object: a CDP round trip serialises the result anyway, and a string
15
15
  * has one deserialiser here — `parse` against the schema below — instead of an implicit one in
16
16
  * the library plus a cast.
17
+ *
18
+ * `reveal` scrolls BEFORE the loop, so every box below is measured where the page now is — the
19
+ * centre a click must land on, and the point `elementFromPoint` is asked about (`QueryOptions`).
17
20
  */
18
- export const snapshotExpression = (selector: string): string => `(() => {
21
+ export const snapshotExpression = (
22
+ selector: string,
23
+ options: QueryOptions = {},
24
+ ): string => `(() => {
19
25
  const out = [];
20
- for (const el of document.querySelectorAll(${JSON.stringify(selector)})) {
26
+ const matches = document.querySelectorAll(${JSON.stringify(selector)});
27
+ ${options.reveal === true ? "matches[0]?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });" : ''}
28
+ for (const el of matches) {
21
29
  const style = getComputedStyle(el);
22
30
  const rect = el.getBoundingClientRect();
23
31
  const attrs = {};