@crouton-kit/capture 1.4.24 → 1.4.25

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.
Files changed (3) hide show
  1. package/bin/capture +97 -20
  2. package/package.json +1 -1
  3. package/src/interact.ts +110 -17
package/bin/capture CHANGED
@@ -10944,7 +10944,7 @@ function validateHarAppendBatch(value, source) {
10944
10944
  return { entries, incompleteLifecycles };
10945
10945
  }
10946
10946
  function captureVersion() {
10947
- const version = "1.4.24";
10947
+ const version = "1.4.25";
10948
10948
  return typeof version === "string" && version.length > 0 ? version : "development";
10949
10949
  }
10950
10950
  function token() {
@@ -13046,7 +13046,7 @@ function wallTime(value) {
13046
13046
  }
13047
13047
  }
13048
13048
  function captureVersion2() {
13049
- const version = "1.4.24";
13049
+ const version = "1.4.25";
13050
13050
  return typeof version === "string" && version.length > 0 ? version : "development";
13051
13051
  }
13052
13052
  function deepFreeze(value) {
@@ -17321,7 +17321,8 @@ function settleAxMatches(kind, input, rawMatches) {
17321
17321
  }
17322
17322
  async function resolveBareTarget(client, input, selector) {
17323
17323
  const css = await resolveLiveCss(client, input, selector);
17324
- if (css.ok || css.code === "ambiguous") return css;
17324
+ if (css.ok && !(CSS_TYPE_SELECTOR.test(selector) && !await hasLayoutBox(client, css.backendNodeId))) return css;
17325
+ if (!css.ok && css.code === "ambiguous") return css;
17325
17326
  const normalized2 = normalizeAccessibleName(input);
17326
17327
  if (!normalized2) return css;
17327
17328
  const matches2 = (await readFullAXTree(client)).filter(
@@ -17330,6 +17331,52 @@ async function resolveBareTarget(client, input, selector) {
17330
17331
  const ax = settleAxMatches("ax", input, matches2);
17331
17332
  return ax.ok || ax.code === "ambiguous" ? ax : css;
17332
17333
  }
17334
+ async function hasLayoutBox(client, backendNodeId2) {
17335
+ try {
17336
+ const response = await client.send("DOM.getBoxModel", { backendNodeId: backendNodeId2 });
17337
+ return isContentQuad(response?.model?.content);
17338
+ } catch (error4) {
17339
+ if (isNoLayoutError(error4)) return false;
17340
+ throw error4;
17341
+ }
17342
+ }
17343
+ function isNoLayoutError(error4) {
17344
+ return error4 instanceof Error && /Node does not have a layout object|Could not compute box model/.test(error4.message);
17345
+ }
17346
+ function notClickable(backendNodeId2, detail, cause) {
17347
+ throw captureError("world", "target_not_clickable", `Resolved target backend:${backendNodeId2} ${detail}`, cause);
17348
+ }
17349
+ function viewportClickPoint(quad, width, height) {
17350
+ const points = [[quad[0], quad[1]], [quad[2], quad[3]], [quad[4], quad[5]], [quad[6], quad[7]]];
17351
+ const cx = points.reduce((sum, p) => sum + p[0], 0) / 4;
17352
+ const cy = points.reduce((sum, p) => sum + p[1], 0) / 4;
17353
+ if (cx >= 0 && cx < width && cy >= 0 && cy < height) return { x: cx, y: cy };
17354
+ const edges = [
17355
+ { inside: (p) => p[0] >= 0, cut: (a, b) => [0, a[1] + (b[1] - a[1]) * (0 - a[0]) / (b[0] - a[0])] },
17356
+ { inside: (p) => p[0] <= width, cut: (a, b) => [width, a[1] + (b[1] - a[1]) * (width - a[0]) / (b[0] - a[0])] },
17357
+ { inside: (p) => p[1] >= 0, cut: (a, b) => [a[0] + (b[0] - a[0]) * (0 - a[1]) / (b[1] - a[1]), 0] },
17358
+ { inside: (p) => p[1] <= height, cut: (a, b) => [a[0] + (b[0] - a[0]) * (height - a[1]) / (b[1] - a[1]), height] }
17359
+ ];
17360
+ let polygon = points;
17361
+ for (const edge of edges) {
17362
+ const input = polygon;
17363
+ polygon = [];
17364
+ input.forEach((current, i) => {
17365
+ const previous = input[(i + input.length - 1) % input.length];
17366
+ if (edge.inside(current)) {
17367
+ if (!edge.inside(previous)) polygon.push(edge.cut(previous, current));
17368
+ polygon.push(current);
17369
+ } else if (edge.inside(previous)) {
17370
+ polygon.push(edge.cut(previous, current));
17371
+ }
17372
+ });
17373
+ if (polygon.length === 0) return null;
17374
+ }
17375
+ if (polygon.length < 3) return null;
17376
+ const x = polygon.reduce((sum, p) => sum + p[0], 0) / polygon.length;
17377
+ const y = polygon.reduce((sum, p) => sum + p[1], 0) / polygon.length;
17378
+ return x >= 0 && x < width && y >= 0 && y < height ? { x, y } : null;
17379
+ }
17333
17380
  async function resolveLiveCss(client, input, selector) {
17334
17381
  await client.send("DOM.enable");
17335
17382
  const { root } = await client.send("DOM.getDocument", { depth: 0 });
@@ -17404,18 +17451,32 @@ async function axIdentityFor(client, backendNodeId2) {
17404
17451
  async function clickLiveTarget(client, input, opts = {}) {
17405
17452
  const initial = await resolveLiveTarget(client, input);
17406
17453
  if (!initial.ok) return initial;
17407
- await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: initial.backendNodeId });
17454
+ await scrollIntoView(client, initial.backendNodeId);
17408
17455
  const current = await resolveLiveTarget(client, input);
17409
17456
  if (!current.ok) return current;
17410
17457
  return dispatchResolvedClick(client, current, opts);
17411
17458
  }
17412
17459
  async function clickResolved(client, resolved, opts = {}) {
17413
- await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: resolved.backendNodeId });
17460
+ await scrollIntoView(client, resolved.backendNodeId);
17414
17461
  return dispatchResolvedClick(client, resolved, opts);
17415
17462
  }
17463
+ async function scrollIntoView(client, backendNodeId2) {
17464
+ try {
17465
+ await client.send("DOM.scrollIntoViewIfNeeded", { backendNodeId: backendNodeId2 });
17466
+ } catch (error4) {
17467
+ if (isNoLayoutError(error4)) notClickable(backendNodeId2, "has no layout box to scroll to (hidden, detached, or not rendered).", error4);
17468
+ throw error4;
17469
+ }
17470
+ }
17416
17471
  async function dispatchResolvedClick(client, resolved, opts) {
17417
17472
  const { backendNodeId: backendNodeId2 } = resolved;
17418
- const boxResponse = await client.send("DOM.getBoxModel", { backendNodeId: backendNodeId2 });
17473
+ let boxResponse;
17474
+ try {
17475
+ boxResponse = await client.send("DOM.getBoxModel", { backendNodeId: backendNodeId2 });
17476
+ } catch (error4) {
17477
+ if (isNoLayoutError(error4)) notClickable(backendNodeId2, "has no layout box to click (hidden, detached, or not rendered).", error4);
17478
+ throw error4;
17479
+ }
17419
17480
  const quad = boxResponse?.model?.content;
17420
17481
  if (!isContentQuad(quad)) {
17421
17482
  throw captureError(
@@ -17425,24 +17486,39 @@ async function dispatchResolvedClick(client, resolved, opts) {
17425
17486
  { method: "DOM.getBoxModel", response: boxResponse }
17426
17487
  );
17427
17488
  }
17428
- const x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
17429
- const y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
17489
+ const metrics = await client.send("Page.getLayoutMetrics");
17490
+ const viewport2 = metrics.cssVisualViewport;
17491
+ if (!viewport2 || ![viewport2.pageX, viewport2.pageY, viewport2.clientWidth, viewport2.clientHeight].every(Number.isFinite)) {
17492
+ throw captureError("world", "malformed_protocol", "Page.getLayoutMetrics returned no valid CSS visual viewport.", {
17493
+ method: "Page.getLayoutMetrics",
17494
+ response: metrics
17495
+ });
17496
+ }
17497
+ const point = viewportClickPoint(quad, viewport2.clientWidth, viewport2.clientHeight);
17498
+ if (point === null) {
17499
+ notClickable(
17500
+ backendNodeId2,
17501
+ `is outside the ${viewport2.clientWidth}x${viewport2.clientHeight} viewport after scrolling it into view (a fixed or clipped element the page cannot scroll to).`,
17502
+ { method: "DOM.getBoxModel", response: boxResponse }
17503
+ );
17504
+ }
17505
+ const { x, y } = point;
17430
17506
  const dispatchX = Math.round(x);
17431
17507
  const dispatchY = Math.round(y);
17432
17508
  let hitTestReceiverBackendNodeId;
17433
17509
  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
17510
+ let hitTest;
17511
+ try {
17512
+ hitTest = await client.send("DOM.getNodeForLocation", {
17513
+ x: Math.round(x + viewport2.pageX),
17514
+ y: Math.round(y + viewport2.pageY)
17440
17515
  });
17516
+ } catch (error4) {
17517
+ if (error4 instanceof Error && error4.message.includes("No node found at given location")) {
17518
+ notClickable(backendNodeId2, `has no element at its click point x=${dispatchX} y=${dispatchY}; nothing there can receive the click.`, error4);
17519
+ }
17520
+ throw error4;
17441
17521
  }
17442
- const hitTest = await client.send("DOM.getNodeForLocation", {
17443
- x: Math.round(x + viewport2.pageX),
17444
- y: Math.round(y + viewport2.pageY)
17445
- });
17446
17522
  if (typeof hitTest.backendNodeId !== "number" || !Number.isSafeInteger(hitTest.backendNodeId) || hitTest.backendNodeId <= 0) {
17447
17523
  throw captureError(
17448
17524
  "world",
@@ -17555,7 +17631,7 @@ async function scrollResolved(client, resolved, to, opts = {}) {
17555
17631
  }
17556
17632
  }
17557
17633
  }
17558
- var ACCEPTED_LIVE_PREFIXES, CANDIDATE_LIMIT;
17634
+ var ACCEPTED_LIVE_PREFIXES, CANDIDATE_LIMIT, CSS_TYPE_SELECTOR;
17559
17635
  var init_interact = __esm({
17560
17636
  "src/interact.ts"() {
17561
17637
  init_a11y();
@@ -17564,6 +17640,7 @@ var init_interact = __esm({
17564
17640
  init_selector();
17565
17641
  ACCEPTED_LIVE_PREFIXES = ["css", "ax:", "axid:", "backend:"];
17566
17642
  CANDIDATE_LIMIT = 10;
17643
+ CSS_TYPE_SELECTOR = /^[A-Za-z][A-Za-z0-9-]*$/;
17567
17644
  }
17568
17645
  });
17569
17646
 
@@ -267518,7 +267595,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
267518
267595
  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
267596
  }
267520
267597
  function printVersion() {
267521
- const declared = "1.4.24";
267598
+ const declared = "1.4.25";
267522
267599
  if (declared) {
267523
267600
  console.log(declared);
267524
267601
  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.25",
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/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',