@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 +100 -22
- package/package.json +1 -1
- package/src/cdp/args.ts +4 -1
- package/src/cdp/commands/tab/list.ts +1 -1
- package/src/interact.ts +110 -17
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
17429
|
-
const
|
|
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
|
-
|
|
17435
|
-
|
|
17436
|
-
|
|
17437
|
-
|
|
17438
|
-
|
|
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.
|
|
267599
|
+
const declared = "1.4.26";
|
|
267522
267600
|
if (declared) {
|
|
267523
267601
|
console.log(declared);
|
|
267524
267602
|
return;
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
394
|
-
|
|
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
|
|
409
|
-
const
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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',
|