fix(vom): iframe oopif geometry

This commit is contained in:
Ljy-0827
2026-08-18 19:28:16 +08:00
committed by Zhang GH
parent 42403e55f2
commit 94ec7e73fc
8 changed files with 125 additions and 169 deletions
@@ -1,25 +1,28 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { nodeBoundingRect, quadBoundingRect } from "../element-geometry"; import { nodeContentRegion } from "../element-geometry";
import type { CdpRunner } from "../shared"; import type { CdpRunner } from "../shared";
describe("quadBoundingRect", () => { describe("nodeContentRegion", () => {
it("computes axis-aligned bounds from an 8-double quad", () => { it("rejects invalid content quads before using the box model fallback", async () => {
expect(quadBoundingRect([10, 20, 110, 20, 110, 60, 10, 60])).toEqual({ const send = async (_tabId: number, method: string) => {
x: 10, if (method === "DOM.getContentQuads") return { quads: [[0, 0, 0, 0, 0, 0, 0, 0]] };
y: 20, if (method === "DOM.getBoxModel") {
width: 100, return { model: { content: [5, 10, 25, 10, 25, 30, 5, 30] } };
height: 40, }
}); throw new Error(`unexpected CDP call ${method}`);
};
await expect(nodeContentRegion({ send: send as CdpRunner["send"] }, 7, 555)).resolves.toEqual([
[
{ x: 5, y: 10 },
{ x: 25, y: 10 },
{ x: 25, y: 30 },
{ x: 5, y: 30 },
],
]);
}); });
it("returns null for degenerate quads", () => { it("preserves every visible content fragment", async () => {
expect(quadBoundingRect([0, 0, 0, 0, 0, 0, 0, 0])).toBeNull();
expect(quadBoundingRect([1, 2, 3])).toBeNull();
});
});
describe("nodeBoundingRect", () => {
it("unions every visible content fragment", async () => {
const send = async () => ({ const send = async () => ({
quads: [ quads: [
[10, 20, 60, 20, 60, 40, 10, 40], [10, 20, 60, 20, 60, 40, 10, 40],
@@ -30,12 +33,20 @@ describe("nodeBoundingRect", () => {
send: send as CdpRunner["send"], send: send as CdpRunner["send"],
}; };
await expect(nodeBoundingRect(cdp, 7, 555)).resolves.toEqual({ await expect(nodeContentRegion(cdp, 7, 555)).resolves.toEqual([
x: 10, [
y: 20, { x: 10, y: 20 },
width: 80, { x: 60, y: 20 },
height: 60, { x: 60, y: 40 },
}); { x: 10, y: 40 },
],
[
{ x: 15, y: 50 },
{ x: 90, y: 50 },
{ x: 90, y: 80 },
{ x: 15, y: 80 },
],
]);
}); });
it("falls back to visible descendant bounds for zero-size containers", async () => { it("falls back to visible descendant bounds for zero-size containers", async () => {
@@ -57,12 +68,14 @@ describe("nodeBoundingRect", () => {
}; };
const cdp = { send: send as CdpRunner["send"] }; const cdp = { send: send as CdpRunner["send"] };
await expect(nodeBoundingRect(cdp, 7, 555)).resolves.toEqual({ await expect(nodeContentRegion(cdp, 7, 555)).resolves.toEqual([
x: 242, [
y: 468, { x: 242, y: 468 },
width: 769, { x: 1011, y: 468 },
height: 180, { x: 1011, y: 648 },
}); { x: 242, y: 648 },
],
]);
expect(calls.map((c) => c.method)).toEqual([ expect(calls.map((c) => c.method)).toEqual([
"DOM.getContentQuads", "DOM.getContentQuads",
"DOM.getBoxModel", "DOM.getBoxModel",
@@ -1,15 +1,27 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { type Quad, resolveFrameProjection, resolveNodeGeometry } from "../frame-geometry"; import { resolveFrameProjection, resolveNodeGeometry } from "../frame-geometry";
import { import {
clipPolygon, clipPolygon,
polygonArea,
polygonBounds, polygonBounds,
projectAndClipRegion, projectAndClipRegion,
projectUnitPoint, projectUnitPoint,
type Quad,
rectPolygon, rectPolygon,
regionBounds,
} from "../geometry"; } from "../geometry";
import type { CdpRunner } from "../shared"; import type { CdpRunner } from "../shared";
describe("frame geometry projection", () => { describe("frame geometry projection", () => {
it("keeps region bounds separate from polygon area", () => {
const region = [
rectPolygon({ x: 0, y: 0, w: 10, h: 10 }),
rectPolygon({ x: 20, y: 5, w: 5, h: 5 }),
];
expect(regionBounds(region)).toEqual({ x: 0, y: 0, width: 25, height: 10 });
expect(polygonArea(region[0])).toBe(100);
});
it("maps through the iframe content box instead of its border box", () => { it("maps through the iframe content box instead of its border box", () => {
const contentQuad: Quad = [ const contentQuad: Quad = [
{ x: 204, y: 306 }, { x: 204, y: 306 },
+36 -107
View File
@@ -1,9 +1,15 @@
// Shared CDP helpers for scrolling nodes into view and computing // Resolve and scroll nodes inside one CDP target. Cross-frame projection
// viewport-space bounding rectangles. Used by interaction tools (click // belongs to frame-geometry.ts.
// centre) and observation tools (element screenshot clip).
import type { RpcError } from "@/transport/types"; import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors"; import { rpcError } from "./errors";
import {
parseCdpQuad,
polygonBounds,
type Region,
rectPolygon,
type ViewportRect,
} from "./geometry";
import { type CdpRunner, isRpcError } from "./shared"; import { type CdpRunner, isRpcError } from "./shared";
const ELEMENT_NOT_VISIBLE_MESSAGE = const ELEMENT_NOT_VISIBLE_MESSAGE =
@@ -13,45 +19,6 @@ function elementNotVisibleError(): RpcError {
return rpcError("permission_denied", "element_not_visible", ELEMENT_NOT_VISIBLE_MESSAGE); return rpcError("permission_denied", "element_not_visible", ELEMENT_NOT_VISIBLE_MESSAGE);
} }
/** Viewport-space axis-aligned bounding box for CDP `Page.captureScreenshot` clip. */
export interface ViewportRect {
x: number;
y: number;
width: number;
height: number;
}
/**
* Compute the axis-aligned bounding box of an 8-double CDP content
* quad / box-model polygon. Exported for unit tests.
*/
export function quadBoundingRect(quad: number[]): ViewportRect | null {
if (quad.length !== 8) return null;
const xs = [quad[0], quad[2], quad[4], quad[6]];
const ys = [quad[1], quad[3], quad[5], quad[7]];
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const width = maxX - minX;
const height = maxY - minY;
if (width <= 0 || height <= 0) return null;
return { x: minX, y: minY, width, height };
}
function rectToQuad(rect: ViewportRect): number[] {
return [
rect.x,
rect.y,
rect.x + rect.width,
rect.y,
rect.x + rect.width,
rect.y + rect.height,
rect.x,
rect.y + rect.height,
];
}
/** /**
* Resolve `backendNodeId` → CDP `objectId` so we can invoke * Resolve `backendNodeId` → CDP `objectId` so we can invoke
* `Runtime.callFunctionOn` against the live JS object. * `Runtime.callFunctionOn` against the live JS object.
@@ -112,52 +79,24 @@ export async function scrollNodeIntoView(
} }
} }
/** function validCdpRegion(quads: number[][] | undefined): Region {
* Viewport-space bounding box for `backendNodeId`. Prefers return (quads ?? []).flatMap((raw) => {
* `DOM.getContentQuads`, then `DOM.getBoxModel`, then the union of const polygon = parseCdpQuad(raw);
* visible descendant client rects for zero-size overflow containers. return polygon && polygonBounds(polygon) ? [polygon] : [];
*/ });
export async function nodeBoundingRect( }
async function resolveVisibleContentRegion(
cdp: CdpRunner, cdp: CdpRunner,
tabId: number, tabId: number,
backendNodeId: number, backendNodeId: number,
): Promise<ViewportRect | RpcError> { ): Promise<Region | RpcError> {
const polygons = await visibleContentPolygons(cdp, tabId, backendNodeId);
if (isRpcError(polygons)) return polygons;
const bounds = polygons.flatMap((polygon) => quadBoundingRect(polygon) ?? []);
const x = Math.min(...bounds.map((rect) => rect.x));
const y = Math.min(...bounds.map((rect) => rect.y));
const right = Math.max(...bounds.map((rect) => rect.x + rect.width));
const bottom = Math.max(...bounds.map((rect) => rect.y + rect.height));
return { x, y, width: right - x, height: bottom - y };
}
/**
* Compute the centroid of a CDP content quad. Quads are reported as
* 8 doubles in clockwise order: (x1, y1, x2, y2, x3, y3, x4, y4).
*/
export function quadCentre(quad: number[]): { x: number; y: number } {
const x = (quad[0] + quad[4]) / 2;
const y = (quad[1] + quad[5]) / 2;
return { x, y };
}
/** Centre of a `DOM.getBoxModel` `content` polygon (also 8 doubles). */
export function boxCentre(box: number[]): { x: number; y: number } {
return quadCentre(box);
}
async function visibleContentPolygons(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
): Promise<number[][] | RpcError> {
try { try {
const quads = await cdp.send<{ quads?: number[][] }>(tabId, "DOM.getContentQuads", { const quads = await cdp.send<{ quads?: number[][] }>(tabId, "DOM.getContentQuads", {
backendNodeId, backendNodeId,
}); });
const visible = quads.quads?.filter((quad) => quad.length === 8 && quadBoundingRect(quad)); const visible = validCdpRegion(quads.quads);
if (visible && visible.length > 0) return visible; if (visible.length > 0) return visible;
} catch (err) { } catch (err) {
console.debug("[bsk element-geometry] getContentQuads failed", err); console.debug("[bsk element-geometry] getContentQuads failed", err);
} }
@@ -166,25 +105,32 @@ async function visibleContentPolygons(
backendNodeId, backendNodeId,
}); });
const content = box.model?.content; const content = box.model?.content;
if (content && content.length === 8 && quadBoundingRect(content)) return [content]; const fallback = validCdpRegion(content ? [content] : undefined);
if (fallback.length > 0) return fallback;
} catch (err) { } catch (err) {
console.debug("[bsk element-geometry] getBoxModel failed", err); console.debug("[bsk element-geometry] getBoxModel failed", err);
} }
const descendantRect = await descendantBoundingRect(cdp, tabId, backendNodeId); const descendantRect = await descendantBoundingRect(cdp, tabId, backendNodeId);
if (descendantRect) return [rectToQuad(descendantRect)]; if (descendantRect) {
return [
rectPolygon({
x: descendantRect.x,
y: descendantRect.y,
w: descendantRect.width,
h: descendantRect.height,
}),
];
}
return elementNotVisibleError(); return elementNotVisibleError();
} }
/** /** Resolve every visible content region in the node's target-local viewport. */
* Return a live content quad for a backend node in the target's own viewport. export async function nodeContentRegion(
* Frame-aware callers can project this quad through the owning frame chain.
*/
export async function nodeContentQuads(
cdp: CdpRunner, cdp: CdpRunner,
tabId: number, tabId: number,
backendNodeId: number, backendNodeId: number,
): Promise<number[][] | RpcError> { ): Promise<Region | RpcError> {
return visibleContentPolygons(cdp, tabId, backendNodeId); return resolveVisibleContentRegion(cdp, tabId, backendNodeId);
} }
async function descendantBoundingRect( async function descendantBoundingRect(
@@ -257,20 +203,3 @@ function parseViewportRect(value: unknown): ViewportRect | null {
if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null; if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
return { x, y, width, height }; return { x, y, width, height };
} }
/**
* Get the click point for `backendNodeId`. Prefers
* `DOM.getContentQuads` (richer for rotated / transformed elements);
* falls back to `DOM.getBoxModel`, then visible descendant bounds.
* Returns `permission_denied` with "element not visible" when every
* geometry path comes back empty / fails.
*/
export async function nodeCentre(
cdp: CdpRunner,
tabId: number,
backendNodeId: number,
): Promise<{ x: number; y: number } | RpcError> {
const polygons = await visibleContentPolygons(cdp, tabId, backendNodeId);
if (isRpcError(polygons)) return polygons;
return quadCentre(polygons[0]);
}
+11 -19
View File
@@ -1,6 +1,6 @@
import { type CdpFrame, type CdpFrameGraph, type CdpTarget } from "@/browser-driver/frame-graph"; import { type CdpFrame, type CdpFrameGraph, type CdpTarget } from "@/browser-driver/frame-graph";
import type { RpcError } from "@/transport/types"; import type { RpcError } from "@/transport/types";
import { nodeContentQuads, scrollNodeIntoView, type ViewportRect } from "./element-geometry"; import { nodeContentRegion, scrollNodeIntoView } from "./element-geometry";
import { import {
clipPolygon, clipPolygon,
type GeometryProjection, type GeometryProjection,
@@ -8,21 +8,18 @@ import {
type Polygon, type Polygon,
type ProjectiveEdge, type ProjectiveEdge,
parseCdpQuad, parseCdpQuad,
polygonBounds, polygonArea,
polygonCentroid, polygonCentroid,
projectRegionToViewport, projectRegionToViewport,
type Quad, type Quad,
type Region, type Region,
rectPolygon, rectPolygon,
regionBounds,
type Size, type Size,
type ViewportRect,
} from "./geometry"; } from "./geometry";
import { type CdpRunner, cdpRunnerForTarget, isRpcError, sendToCdpTarget } from "./shared"; import { type CdpRunner, cdpRunnerForTarget, isRpcError, sendToCdpTarget } from "./shared";
export type { Point, Quad, Region } from "./geometry";
export { parseCdpQuad } from "./geometry";
export type FrameProjection = GeometryProjection;
export interface NodeAddress { export interface NodeAddress {
target: CdpTarget; target: CdpTarget;
backendNodeId: number; backendNodeId: number;
@@ -124,7 +121,7 @@ export async function resolveFrameProjection(
cdp: CdpRunner, cdp: CdpRunner,
graph: CdpFrameGraph, graph: CdpFrameGraph,
frameId: string, frameId: string,
): Promise<FrameProjection | null> { ): Promise<GeometryProjection | null> {
const byId = frameMap(graph); const byId = frameMap(graph);
const frame = byId.get(frameId); const frame = byId.get(frameId);
if (!frame) return null; if (!frame) return null;
@@ -154,7 +151,7 @@ export async function resolveFrameProjection(
const topViewport = const topViewport =
edges.length === 0 ? sourceViewport : await targetViewport(cdp, targetRoot.target); edges.length === 0 ? sourceViewport : await targetViewport(cdp, targetRoot.target);
return topViewport ? { sourceViewport, sourceClips, edges, topViewport } : null; return topViewport ? { sourceClips, edges, topViewport } : null;
} }
async function loadFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFrameGraph | null> { async function loadFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFrameGraph | null> {
@@ -215,9 +212,8 @@ export async function scrollElementAndFramesIntoView(
function largestRegion(regions: Region): Polygon | null { function largestRegion(regions: Region): Polygon | null {
let largest: { polygon: Polygon; area: number } | null = null; let largest: { polygon: Polygon; area: number } | null = null;
for (const polygon of regions) { for (const polygon of regions) {
const bounds = polygonBounds(polygon); const area = polygonArea(polygon);
if (!bounds) continue; if (area <= 0) continue;
const area = bounds.width * bounds.height;
if (!largest || area > largest.area) largest = { polygon, area }; if (!largest || area > largest.area) largest = { polygon, area };
} }
return largest?.polygon ?? null; return largest?.polygon ?? null;
@@ -241,16 +237,12 @@ export async function resolveNodeGeometry(
if (scrollError) return scrollError; if (scrollError) return scrollError;
} }
const local = await nodeContentQuads( const localRegion = await nodeContentRegion(
cdpRunnerForTarget(cdp, address.target), cdpRunnerForTarget(cdp, address.target),
tabId, tabId,
address.backendNodeId, address.backendNodeId,
); );
if (isRpcError(local)) return local; if (isRpcError(localRegion)) return localRegion;
const localRegion = local
.map((quad) => parseCdpQuad(quad))
.filter((quad): quad is Quad => quad !== null);
if (localRegion.length === 0) return geometryError("DOM returned no valid content regions");
const graph = address.frameId ? await loadFrameGraph(cdp, tabId) : null; const graph = address.frameId ? await loadFrameGraph(cdp, tabId) : null;
if (address.target.sessionId && !address.frameId) { if (address.target.sessionId && !address.frameId) {
@@ -276,7 +268,7 @@ export async function resolveNodeGeometry(
.filter((polygon) => polygon.length >= 3); .filter((polygon) => polygon.length >= 3);
} }
const topBounds = polygonBounds(topVisibleRegions.flat()); const topBounds = regionBounds(topVisibleRegions);
const actionRegion = largestRegion(topVisibleRegions); const actionRegion = largestRegion(topVisibleRegions);
const actionPoint = actionRegion ? polygonCentroid(actionRegion) : null; const actionPoint = actionRegion ? polygonCentroid(actionRegion) : null;
if (!topBounds || !actionPoint) { if (!topBounds || !actionPoint) {
+16 -5
View File
@@ -1,5 +1,3 @@
import type { ViewportRect } from "./element-geometry";
export interface Point { export interface Point {
x: number; x: number;
y: number; y: number;
@@ -14,6 +12,13 @@ export interface Size {
height: number; height: number;
} }
export interface ViewportRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ProjectiveEdge { export interface ProjectiveEdge {
sourceViewport: Size; sourceViewport: Size;
destinationQuad: Quad; destinationQuad: Quad;
@@ -21,7 +26,6 @@ export interface ProjectiveEdge {
} }
export interface GeometryProjection { export interface GeometryProjection {
sourceViewport: Size;
sourceClips: Polygon[]; sourceClips: Polygon[];
edges: ProjectiveEdge[]; edges: ProjectiveEdge[];
topViewport: Size; topViewport: Size;
@@ -175,6 +179,14 @@ export function polygonBounds(points: Polygon): ViewportRect | null {
return { x, y, width: right - x, height: bottom - y }; return { x, y, width: right - x, height: bottom - y };
} }
export function regionBounds(region: Region): ViewportRect | null {
return polygonBounds(region.flat());
}
export function polygonArea(points: Polygon): number {
return Math.abs(polygonSignedArea(points));
}
export function polygonCentroid(points: Polygon): Point | null { export function polygonCentroid(points: Polygon): Point | null {
if (points.length < 3) return null; if (points.length < 3) return null;
let twiceArea = 0; let twiceArea = 0;
@@ -231,7 +243,7 @@ export function projectRectToViewport(
projection: GeometryProjection, projection: GeometryProjection,
): ViewportRect | null { ): ViewportRect | null {
if (!rect) return null; if (!rect) return null;
return polygonBounds(projectRegionToViewport([rectPolygon(rect)], projection).flat()); return regionBounds(projectRegionToViewport([rectPolygon(rect)], projection));
} }
export function childFrameProjection( export function childFrameProjection(
@@ -240,7 +252,6 @@ export function childFrameProjection(
): GeometryProjection { ): GeometryProjection {
const destinationQuad = rectPolygon(ownerRectInParent); const destinationQuad = rectPolygon(ownerRectInParent);
return { return {
sourceViewport: { width: ownerRectInParent.w, height: ownerRectInParent.h },
sourceClips: [], sourceClips: [],
edges: [ edges: [
{ {
+1 -4
View File
@@ -29,7 +29,7 @@ import type {
SelectResult, SelectResult,
} from "@/transport/types"; } from "@/transport/types";
import { attachDialogs, markDialogCursor } from "./dialogs"; import { attachDialogs, markDialogCursor } from "./dialogs";
import { backendNodeToObject, boxCentre, nodeCentre, quadCentre } from "./element-geometry"; import { backendNodeToObject } from "./element-geometry";
import { rpcError } from "./errors"; import { rpcError } from "./errors";
import { resolveNodeGeometry, scrollElementAndFramesIntoView } from "./frame-geometry"; import { resolveNodeGeometry, scrollElementAndFramesIntoView } from "./frame-geometry";
import { import {
@@ -1107,9 +1107,6 @@ export async function handleSelect(
export const __testing__ = { export const __testing__ = {
DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS,
quadCentre,
boxCentre,
resolveBackendNode, resolveBackendNode,
nodeCentre,
isFillable, isFillable,
}; };
-1
View File
@@ -1057,7 +1057,6 @@ export async function captureViewModel(
frameId: snapshotFrameId(doc0, strings), frameId: snapshotFrameId(doc0, strings),
ownerFrameBackendNodeId: null, ownerFrameBackendNodeId: null,
projection: { projection: {
sourceViewport: viewport,
sourceClips: [], sourceClips: [],
edges: [], edges: [],
topViewport: viewport, topViewport: viewport,
@@ -1,6 +1,6 @@
import type { CdpFrame, CdpFrameGraph } from "@/browser-driver/frame-graph"; import type { CdpFrame, CdpFrameGraph } from "@/browser-driver/frame-graph";
import { type FrameProjection, resolveFrameProjection } from "../frame-geometry"; import { resolveFrameProjection } from "../frame-geometry";
import { projectRectToViewport } from "../geometry"; import { type GeometryProjection, projectRectToViewport } from "../geometry";
import { type CdpRunner, cdpRunnerForTarget, sendToCdpTarget } from "../shared"; import { type CdpRunner, cdpRunnerForTarget, sendToCdpTarget } from "../shared";
import { type CapturedNode, type CapturedViewModel, captureViewModel } from "./capture"; import { type CapturedNode, type CapturedViewModel, captureViewModel } from "./capture";
import { import {
@@ -31,7 +31,10 @@ async function discoverFrameGraph(cdp: CdpRunner, tabId: number): Promise<CdpFra
} }
} }
function transformFrameNodes(nodes: CapturedNode[], projection: FrameProjection): CapturedNode[] { function transformFrameNodes(
nodes: CapturedNode[],
projection: GeometryProjection,
): CapturedNode[] {
return nodes.map((node) => ({ return nodes.map((node) => ({
...node, ...node,
rect: (() => { rect: (() => {