@crouton-kit/capture 1.4.24 → 1.4.26

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/bin/capture CHANGED
@@ -10174,7 +10174,8 @@ function resolveCliContext(parsed) {
10174
10174
  parsed.target = session.targetId;
10175
10175
  parsed.targetSource = "session";
10176
10176
  }
10177
- if (parsed.port === void 0 && session.port !== void 0 && session.port !== null) {
10177
+ const discoveringEndpoints = parsed.command === "tab" && parsed.positional[0] === "list";
10178
+ if (parsed.port === void 0 && !discoveringEndpoints && session.port !== void 0 && session.port !== null) {
10178
10179
  parsed.port = session.port;
10179
10180
  parsed.portSource = "session";
10180
10181
  }
@@ -10944,7 +10945,7 @@ function validateHarAppendBatch(value, source) {
10944
10945
  return { entries, incompleteLifecycles };
10945
10946
  }
10946
10947
  function captureVersion() {
10947
- const version = "1.4.24";
10948
+ const version = "1.4.26";
10948
10949
  return typeof version === "string" && version.length > 0 ? version : "development";
10949
10950
  }
10950
10951
  function token() {
@@ -13046,7 +13047,7 @@ function wallTime(value) {
13046
13047
  }
13047
13048
  }
13048
13049
  function captureVersion2() {
13049
- const version = "1.4.24";
13050
+ const version = "1.4.26";
13050
13051
  return typeof version === "string" && version.length > 0 ? version : "development";
13051
13052
  }
13052
13053
  function deepFreeze(value) {
@@ -17321,7 +17322,8 @@ function settleAxMatches(kind, input, rawMatches) {
17321
17322
  }
17322
17323
  async function resolveBareTarget(client, input, selector) {
17323
17324
  const css = await resolveLiveCss(client, input, selector);
17324
- if (css.ok || css.code === "ambiguous") return css;
17325
+ if (css.ok && !(CSS_TYPE_SELECTOR.test(selector) && !await hasLayoutBox(client, css.backendNodeId))) return css;
17326
+ if (!css.ok && css.code === "ambiguous") return css;
17325
17327
  const normalized2 = normalizeAccessibleName(input);
17326
17328
  if (!normalized2) return css;
17327
17329
  const matches2 = (await readFullAXTree(client)).filter(
@@ -17330,6 +17332,52 @@ async function resolveBareTarget(client, input, selector) {
17330
17332
  const ax = settleAxMatches("ax", input, matches2);
17331
17333
  return ax.ok || ax.code === "ambiguous" ? ax : css;
17332
17334
  }
17335
+ async function hasLayoutBox(client, backendNodeId2) {
17336
+ try {
17337
+ const response = await client.send("DOM.getBoxModel", { backendNodeId: backendNodeId2 });
17338
+ return isContentQuad(response?.model?.content);
17339
+ } catch (error4) {
17340
+ if (isNoLayoutError(error4)) return false;
17341
+ throw error4;
17342
+ }
17343
+ }
17344
+ function isNoLayoutError(error4) {
17345
+ return error4 instanceof Error && /Node does not have a layout object|Could not compute box model/.test(error4.message);
17346
+ }
17347
+ function notClickable(backendNodeId2, detail, cause) {
17348
+ throw captureError("world", "target_not_clickable", `Resolved target backend:${backendNodeId2} ${detail}`, cause);
17349
+ }
17350
+ function viewportClickPoint(quad, width, height) {
17351
+ const points = [[quad[0], quad[1]], [quad[2], quad[3]], [quad[4], quad[5]], [quad[6], quad[7]]];
17352
+ const cx = points.reduce((sum, p) => sum + p[0], 0) / 4;
17353
+ const cy = points.reduce((sum, p) => sum + p[1], 0) / 4;
17354
+ if (cx >= 0 && cx < width && cy >= 0 && cy < height) return { x: cx, y: cy };
17355
+ const edges = [
17356
+ { inside: (p) => p[0] >= 0, cut: (a, b) => [0, a[1] + (b[1] - a[1]) * (0 - a[0]) / (b[0] - a[0])] },
17357
+ { inside: (p) => p[0] <= width, cut: (a, b) => [width, a[1] + (b[1] - a[1]) * (width - a[0]) / (b[0] - a[0])] },
17358
+ { inside: (p) => p[1] >= 0, cut: (a, b) => [a[0] + (b[0] - a[0]) * (0 - a[1]) / (b[1] - a[1]), 0] },
17359
+ { inside: (p) => p[1] <= height, cut: (a, b) => [a[0] + (b[0] - a[0]) * (height - a[1]) / (b[1] - a[1]), height] }
17360
+ ];
17361
+ let polygon = points;
17362
+ for (const edge of edges) {
17363
+ const input = polygon;
17364
+ polygon = [];
17365
+ input.forEach((current, i) => {
17366
+ const previous = input[(i + input.length - 1) % input.length];
17367
+ if (edge.inside(current)) {
17368
+ if (!edge.inside(previous)) polygon.push(edge.cut(previous, current));
17369
+ polygon.push(current);
17370
+ } else if (edge.inside(previous)) {
17371
+ polygon.push(edge.cut(previous, current));
17372
+ }
17373
+ });
17374
+ if (polygon.length === 0) return null;
17375
+ }
17376
+ if (polygon.length < 3) return null;
17377
+ const x = polygon.reduce((sum, p) => sum + p[0], 0) / polygon.length;
17378
+ const y = polygon.reduce((sum, p) => sum + p[1], 0) / polygon.length;
17379
+ return x >= 0 && x < width && y >= 0 && y < height ? { x, y } : null;
17380
+ }
17333
17381
  async function resolveLiveCss(client, input, selector) {
17334
17382
  await client.send("DOM.enable");
17335
17383
  const { root } = await client.send("DOM.getDocument", { depth: 0 });
@@ -17404,18 +17452,32 @@ async function axIdentityFor(client, backendNodeId2) {
17404
17452
  async function clickLiveTarget(client, input, opts = {}) {
17405
17453
  const initial = await resolveLiveTarget(client, input);
17406
17454
  if (!initial.ok) return initial;
17407
- await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: initial.backendNodeId });
17455
+ await scrollIntoView(client, initial.backendNodeId);
17408
17456
  const current = await resolveLiveTarget(client, input);
17409
17457
  if (!current.ok) return current;
17410
17458
  return dispatchResolvedClick(client, current, opts);
17411
17459
  }
17412
17460
  async function clickResolved(client, resolved, opts = {}) {
17413
- await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: resolved.backendNodeId });
17461
+ await scrollIntoView(client, resolved.backendNodeId);
17414
17462
  return dispatchResolvedClick(client, resolved, opts);
17415
17463
  }
17464
+ async function scrollIntoView(client, backendNodeId2) {
17465
+ try {
17466
+ await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: backendNodeId2 });
17467
+ } catch (error4) {
17468
+ if (isNoLayoutError(error4)) notClickable(backendNodeId2, "has no layout box to scroll to (hidden, detached, or not rendered).", error4);
17469
+ throw error4;
17470
+ }
17471
+ }
17416
17472
  async function dispatchResolvedClick(client, resolved, opts) {
17417
17473
  const { backendNodeId: backendNodeId2 } = resolved;
17418
- const boxResponse = await client.send("DOM.getBoxModel", { backendNodeId: backendNodeId2 });
17474
+ let boxResponse;
17475
+ try {
17476
+ boxResponse = await client.send("DOM.getBoxModel", { backendNodeId: backendNodeId2 });
17477
+ } catch (error4) {
17478
+ if (isNoLayoutError(error4)) notClickable(backendNodeId2, "has no layout box to click (hidden, detached, or not rendered).", error4);
17479
+ throw error4;
17480
+ }
17419
17481
  const quad = boxResponse?.model?.content;
17420
17482
  if (!isContentQuad(quad)) {
17421
17483
  throw captureError(
@@ -17425,24 +17487,39 @@ async function dispatchResolvedClick(client, resolved, opts) {
17425
17487
  { method: "DOM.getBoxModel", response: boxResponse }
17426
17488
  );
17427
17489
  }
17428
- const x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
17429
- const y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
17490
+ const metrics = await client.send("Page.getLayoutMetrics");
17491
+ const viewport2 = metrics.cssVisualViewport;
17492
+ if (!viewport2 || ![viewport2.pageX, viewport2.pageY, viewport2.clientWidth, viewport2.clientHeight].every(Number.isFinite)) {
17493
+ throw captureError("world", "malformed_protocol", "Page.getLayoutMetrics returned no valid CSS visual viewport.", {
17494
+ method: "Page.getLayoutMetrics",
17495
+ response: metrics
17496
+ });
17497
+ }
17498
+ const point = viewportClickPoint(quad, viewport2.clientWidth, viewport2.clientHeight);
17499
+ if (point === null) {
17500
+ notClickable(
17501
+ backendNodeId2,
17502
+ `is outside the ${viewport2.clientWidth}x${viewport2.clientHeight} viewport after scrolling it into view (a fixed or clipped element the page cannot scroll to).`,
17503
+ { method: "DOM.getBoxModel", response: boxResponse }
17504
+ );
17505
+ }
17506
+ const { x, y } = point;
17430
17507
  const dispatchX = Math.round(x);
17431
17508
  const dispatchY = Math.round(y);
17432
17509
  let hitTestReceiverBackendNodeId;
17433
17510
  if (opts.inspectHitTest) {
17434
- const metrics = await client.send("Page.getLayoutMetrics");
17435
- const viewport2 = metrics.cssVisualViewport;
17436
- if (!viewport2 || !Number.isFinite(viewport2.pageX) || !Number.isFinite(viewport2.pageY)) {
17437
- throw captureError("world", "malformed_protocol", "Page.getLayoutMetrics returned no valid CSS visual viewport.", {
17438
- method: "Page.getLayoutMetrics",
17439
- response: metrics
17511
+ let hitTest;
17512
+ try {
17513
+ hitTest = await client.send("DOM.getNodeForLocation", {
17514
+ x: Math.round(x + viewport2.pageX),
17515
+ y: Math.round(y + viewport2.pageY)
17440
17516
  });
17517
+ } catch (error4) {
17518
+ if (error4 instanceof Error && error4.message.includes("No node found at given location")) {
17519
+ notClickable(backendNodeId2, `has no element at its click point x=${dispatchX} y=${dispatchY}; nothing there can receive the click.`, error4);
17520
+ }
17521
+ throw error4;
17441
17522
  }
17442
- const hitTest = await client.send("DOM.getNodeForLocation", {
17443
- x: Math.round(x + viewport2.pageX),
17444
- y: Math.round(y + viewport2.pageY)
17445
- });
17446
17523
  if (typeof hitTest.backendNodeId !== "number" || !Number.isSafeInteger(hitTest.backendNodeId) || hitTest.backendNodeId <= 0) {
17447
17524
  throw captureError(
17448
17525
  "world",
@@ -17555,7 +17632,7 @@ async function scrollResolved(client, resolved, to, opts = {}) {
17555
17632
  }
17556
17633
  }
17557
17634
  }
17558
- var ACCEPTED_LIVE_PREFIXES, CANDIDATE_LIMIT;
17635
+ var ACCEPTED_LIVE_PREFIXES, CANDIDATE_LIMIT, CSS_TYPE_SELECTOR;
17559
17636
  var init_interact = __esm({
17560
17637
  "src/interact.ts"() {
17561
17638
  init_a11y();
@@ -17564,6 +17641,7 @@ var init_interact = __esm({
17564
17641
  init_selector();
17565
17642
  ACCEPTED_LIVE_PREFIXES = ["css", "ax:", "axid:", "backend:"];
17566
17643
  CANDIDATE_LIMIT = 10;
17644
+ CSS_TYPE_SELECTOR = /^[A-Za-z][A-Za-z0-9-]*$/;
17567
17645
  }
17568
17646
  });
17569
17647
 
@@ -230506,7 +230584,7 @@ var init_list = __esm({
230506
230584
  USAGE11 = `capture tab list \u2014 discover CDP endpoints and list the page tabs on each.
230507
230585
 
230508
230586
  input:
230509
- --port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint
230587
+ --port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint; an active session's endpoint does not narrow it (CDP_PORT still pins one endpoint)
230510
230588
 
230511
230589
  output: <tabs endpoints=\u2026 tabs=\u2026> \u2014 one section per reachable endpoint (port, app, page-tab count, preferred marker, and a capture-owned pid marker on any browser \`tab launch\` started) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
230512
230590
  effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, stopped answering CDP, or gone idle \u2014 browsers capture did not start are never signalled.`;
@@ -267518,7 +267596,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
267518
267596
  A CDP-enabled browser must be running; \`capture tab list\` is the probe, and \`capture tab launch\` starts one capture owns and reaps when nothing is (never hand-roll a detached browser \u2014 nothing reaps that). Capture never signals a browser it did not start, so attaching to your own with --port is unchanged. CDP_PORT / CDP_TARGET pin the browser + tab for target-selecting commands \u2014 precedence: explicit flag > active session > env. \`session start\` adopts only an explicit --target. When CRTR_NODE_ID is set (a crtr agent node), a browser command with no --port, no --target and no active session of that node is refused instead of falling back to CDP_PORT, CDP_TARGET or localhost discovery, because the browser found could belong to another node: run \`capture session start --url <url>\`, or pass --port or --target. \`capture tab list\` and commands that only read a recorded artifact are not refused.`;
267519
267597
  }
267520
267598
  function printVersion() {
267521
- const declared = "1.4.24";
267599
+ const declared = "1.4.26";
267522
267600
  if (declared) {
267523
267601
  console.log(declared);
267524
267602
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.4.24",
3
+ "version": "1.4.26",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
package/src/cdp/args.ts CHANGED
@@ -625,7 +625,10 @@ export function resolveCliContext(parsed: ParsedArgs): ParsedArgs {
625
625
  // A session target is inseparable from the endpoint that created it.
626
626
  // Prefer that endpoint over ambient CDP_PORT, but retain an explicit
627
627
  // --port override for deliberate multi-browser work.
628
- if (parsed.port === undefined && session.port !== undefined && session.port !== null) { parsed.port = session.port; parsed.portSource = 'session'; }
628
+ // `tab list` is the discovery probe: without --port it lists every
629
+ // localhost endpoint, so the session's endpoint must not narrow it.
630
+ const discoveringEndpoints = parsed.command === 'tab' && parsed.positional[0] === 'list';
631
+ if (parsed.port === undefined && !discoveringEndpoints && session.port !== undefined && session.port !== null) { parsed.port = session.port; parsed.portSource = 'session'; }
629
632
  }
630
633
  refuseUnownedEndpoint(parsed, sessionStart);
631
634
  if (parsed.port === undefined && process.env.CDP_PORT !== undefined) {
@@ -30,7 +30,7 @@ import {
30
30
  const USAGE = `capture tab list — discover CDP endpoints and list the page tabs on each.
31
31
 
32
32
  input:
33
- --port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint
33
+ --port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint; an active session's endpoint does not narrow it (CDP_PORT still pins one endpoint)
34
34
 
35
35
  output: <tabs endpoints=… tabs=…> — one section per reachable endpoint (port, app, page-tab count, preferred marker, and a capture-owned pid marker on any browser \`tab launch\` started) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
36
36
  effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, stopped answering CDP, or gone idle — browsers capture did not start are never signalled.`;
package/src/interact.ts CHANGED
@@ -238,7 +238,11 @@ async function resolveBareTarget(
238
238
  selector: string,
239
239
  ): Promise<ResolvedTarget | ResolutionFailure> {
240
240
  const css = await resolveLiveCss(client, input, selector);
241
- if (css.ok || css.code === 'ambiguous') return css;
241
+ // A one-word accessible name is also a valid CSS type selector: `Title` is
242
+ // the <title> element in <head>, which has no layout box. That match cannot
243
+ // be driven, so it must not shadow the accessible name.
244
+ if (css.ok && !(CSS_TYPE_SELECTOR.test(selector) && !(await hasLayoutBox(client, css.backendNodeId)))) return css;
245
+ if (!css.ok && css.code === 'ambiguous') return css;
242
246
 
243
247
  const normalized = normalizeAccessibleName(input);
244
248
  if (!normalized) return css;
@@ -252,6 +256,67 @@ async function resolveBareTarget(
252
256
  return ax.ok || ax.code === 'ambiguous' ? ax : css;
253
257
  }
254
258
 
259
+ /** A selector that is a single tag name — the only CSS shape an accessible name can collide with. */
260
+ const CSS_TYPE_SELECTOR = /^[A-Za-z][A-Za-z0-9-]*$/;
261
+
262
+ async function hasLayoutBox(client: LiveClient, backendNodeId: number): Promise<boolean> {
263
+ try {
264
+ const response = (await client.send('DOM.getBoxModel', { backendNodeId })) as { model?: { content?: unknown } } | undefined;
265
+ return isContentQuad(response?.model?.content);
266
+ } catch (error) {
267
+ if (isNoLayoutError(error)) return false;
268
+ throw error;
269
+ }
270
+ }
271
+
272
+ /** The renderer's messages for a node with no layout box (`<title>`, `display:none`, detached). */
273
+ function isNoLayoutError(error: unknown): boolean {
274
+ return error instanceof Error && /Node does not have a layout object|Could not compute box model/.test(error.message);
275
+ }
276
+
277
+ function notClickable(backendNodeId: number, detail: string, cause?: unknown): never {
278
+ throw captureError('world', 'target_not_clickable', `Resolved target backend:${backendNodeId} ${detail}`, cause);
279
+ }
280
+
281
+ /**
282
+ * Where to click a content quad: its center when that is inside the viewport,
283
+ * otherwise the center of the part of the quad that is. A tall or wide element
284
+ * (or a fixed one larger than the window) can have its center outside the
285
+ * viewport after scrolling; a mouse event there reaches no element.
286
+ */
287
+ function viewportClickPoint(quad: number[], width: number, height: number): { x: number; y: number } | null {
288
+ const points: Array<[number, number]> = [[quad[0], quad[1]], [quad[2], quad[3]], [quad[4], quad[5]], [quad[6], quad[7]]];
289
+ const cx = points.reduce((sum, p) => sum + p[0], 0) / 4;
290
+ const cy = points.reduce((sum, p) => sum + p[1], 0) / 4;
291
+ if (cx >= 0 && cx < width && cy >= 0 && cy < height) return { x: cx, y: cy };
292
+ // Sutherland–Hodgman clip of the (convex) quad against the viewport rectangle.
293
+ const edges: Array<{ inside: (p: [number, number]) => boolean; cut: (a: [number, number], b: [number, number]) => [number, number] }> = [
294
+ { inside: (p) => p[0] >= 0, cut: (a, b) => [0, a[1] + ((b[1] - a[1]) * (0 - a[0])) / (b[0] - a[0])] },
295
+ { inside: (p) => p[0] <= width, cut: (a, b) => [width, a[1] + ((b[1] - a[1]) * (width - a[0])) / (b[0] - a[0])] },
296
+ { inside: (p) => p[1] >= 0, cut: (a, b) => [a[0] + ((b[0] - a[0]) * (0 - a[1])) / (b[1] - a[1]), 0] },
297
+ { inside: (p) => p[1] <= height, cut: (a, b) => [a[0] + ((b[0] - a[0]) * (height - a[1])) / (b[1] - a[1]), height] },
298
+ ];
299
+ let polygon = points;
300
+ for (const edge of edges) {
301
+ const input = polygon;
302
+ polygon = [];
303
+ input.forEach((current, i) => {
304
+ const previous = input[(i + input.length - 1) % input.length];
305
+ if (edge.inside(current)) {
306
+ if (!edge.inside(previous)) polygon.push(edge.cut(previous, current));
307
+ polygon.push(current);
308
+ } else if (edge.inside(previous)) {
309
+ polygon.push(edge.cut(previous, current));
310
+ }
311
+ });
312
+ if (polygon.length === 0) return null;
313
+ }
314
+ if (polygon.length < 3) return null;
315
+ const x = polygon.reduce((sum, p) => sum + p[0], 0) / polygon.length;
316
+ const y = polygon.reduce((sum, p) => sum + p[1], 0) / polygon.length;
317
+ return x >= 0 && x < width && y >= 0 && y < height ? { x, y } : null;
318
+ }
319
+
255
320
  async function resolveLiveCss(
256
321
  client: LiveClient,
257
322
  input: string,
@@ -368,7 +433,7 @@ export async function clickLiveTarget(
368
433
  const initial = await resolveLiveTarget(client, input);
369
434
  if (!initial.ok) return initial;
370
435
 
371
- await client.send('DOM.scrollIntoViewIfNeeded', { backendNodeId: initial.backendNodeId });
436
+ await scrollIntoView(client, initial.backendNodeId);
372
437
 
373
438
  const current = await resolveLiveTarget(client, input);
374
439
  if (!current.ok) return current;
@@ -380,19 +445,32 @@ export async function clickResolved(
380
445
  resolved: ResolvedTarget,
381
446
  opts: { mark?: string; inspectHitTest?: boolean; includeHitTestReceiver?: boolean } = {},
382
447
  ): Promise<ClickDispatch> {
383
- await client.send('DOM.scrollIntoViewIfNeeded', { backendNodeId: resolved.backendNodeId });
448
+ await scrollIntoView(client, resolved.backendNodeId);
384
449
  return dispatchResolvedClick(client, resolved, opts);
385
450
  }
386
451
 
452
+ async function scrollIntoView(client: LiveClient, backendNodeId: number): Promise<void> {
453
+ try {
454
+ await client.send('DOM.scrollIntoViewIfNeeded', { backendNodeId });
455
+ } catch (error) {
456
+ if (isNoLayoutError(error)) notClickable(backendNodeId, 'has no layout box to scroll to (hidden, detached, or not rendered).', error);
457
+ throw error;
458
+ }
459
+ }
460
+
387
461
  async function dispatchResolvedClick(
388
462
  client: LiveClient,
389
463
  resolved: ResolvedTarget,
390
464
  opts: { mark?: string; inspectHitTest?: boolean; includeHitTestReceiver?: boolean },
391
465
  ): Promise<ClickDispatch> {
392
466
  const { backendNodeId } = resolved;
393
- const boxResponse = (await client.send('DOM.getBoxModel', { backendNodeId })) as
394
- | { model?: { content?: unknown } }
395
- | undefined;
467
+ let boxResponse: { model?: { content?: unknown } } | undefined;
468
+ try {
469
+ boxResponse = (await client.send('DOM.getBoxModel', { backendNodeId })) as { model?: { content?: unknown } } | undefined;
470
+ } catch (error) {
471
+ if (isNoLayoutError(error)) notClickable(backendNodeId, 'has no layout box to click (hidden, detached, or not rendered).', error);
472
+ throw error;
473
+ }
396
474
 
397
475
  // Content quad is [x1,y1, x2,y2, x3,y3, x4,y4] — validated here, once, for
398
476
  // every click consumer (page click and motion rec's one-shot alike).
@@ -405,24 +483,39 @@ async function dispatchResolvedClick(
405
483
  { method: 'DOM.getBoxModel', response: boxResponse },
406
484
  );
407
485
  }
408
- const x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
409
- const y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
486
+ const metrics = (await client.send('Page.getLayoutMetrics')) as { cssVisualViewport?: { pageX: number; pageY: number; clientWidth: number; clientHeight: number } };
487
+ const viewport = metrics.cssVisualViewport;
488
+ if (!viewport || ![viewport.pageX, viewport.pageY, viewport.clientWidth, viewport.clientHeight].every(Number.isFinite)) {
489
+ throw captureError('world', 'malformed_protocol', 'Page.getLayoutMetrics returned no valid CSS visual viewport.', {
490
+ method: 'Page.getLayoutMetrics', response: metrics,
491
+ });
492
+ }
493
+ const point = viewportClickPoint(quad, viewport.clientWidth, viewport.clientHeight);
494
+ if (point === null) {
495
+ notClickable(
496
+ backendNodeId,
497
+ `is outside the ${viewport.clientWidth}x${viewport.clientHeight} viewport after scrolling it into view (a fixed or clipped element the page cannot scroll to).`,
498
+ { method: 'DOM.getBoxModel', response: boxResponse },
499
+ );
500
+ }
501
+ const { x, y } = point;
410
502
 
411
503
  const dispatchX = Math.round(x);
412
504
  const dispatchY = Math.round(y);
413
505
  let hitTestReceiverBackendNodeId: number | undefined;
414
506
  if (opts.inspectHitTest) {
415
507
  // The box quad and mouse dispatch use viewport coordinates; DOM.getNodeForLocation uses page coordinates.
416
- const metrics = (await client.send('Page.getLayoutMetrics')) as { cssVisualViewport?: { pageX: number; pageY: number } };
417
- const viewport = metrics.cssVisualViewport;
418
- if (!viewport || !Number.isFinite(viewport.pageX) || !Number.isFinite(viewport.pageY)) {
419
- throw captureError('world', 'malformed_protocol', 'Page.getLayoutMetrics returned no valid CSS visual viewport.', {
420
- method: 'Page.getLayoutMetrics', response: metrics,
421
- });
508
+ let hitTest: { backendNodeId?: unknown };
509
+ try {
510
+ hitTest = (await client.send('DOM.getNodeForLocation', {
511
+ x: Math.round(x + viewport.pageX), y: Math.round(y + viewport.pageY),
512
+ })) as { backendNodeId?: unknown };
513
+ } catch (error) {
514
+ if (error instanceof Error && error.message.includes('No node found at given location')) {
515
+ notClickable(backendNodeId, `has no element at its click point x=${dispatchX} y=${dispatchY}; nothing there can receive the click.`, error);
516
+ }
517
+ throw error;
422
518
  }
423
- const hitTest = (await client.send('DOM.getNodeForLocation', {
424
- x: Math.round(x + viewport.pageX), y: Math.round(y + viewport.pageY),
425
- })) as { backendNodeId?: unknown };
426
519
  if (typeof hitTest.backendNodeId !== 'number' || !Number.isSafeInteger(hitTest.backendNodeId) || hitTest.backendNodeId <= 0) {
427
520
  throw captureError(
428
521
  'world',