Skip to content

Commit 07e628d

Browse files
committed
fix(vom): add tests and resolve problems
1 parent 1e83cd4 commit 07e628d

6 files changed

Lines changed: 255 additions & 8 deletions

File tree

apps/extension/src/tools/__tests__/observation.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,97 @@ describe("buildVomScene", () => {
704704
expect(result.nodes).toEqual([]);
705705
});
706706

707+
it("keeps AX-only frame content attached to its iframe boundary", () => {
708+
const mainNodes: CapturedNode[] = [
709+
{
710+
backendNodeId: 1,
711+
parentBackendNodeId: null,
712+
frameId: "main",
713+
tag: "body",
714+
attrs: {},
715+
rect: { x: 0, y: 0, w: 800, h: 600 },
716+
paintOrder: 0,
717+
position: "static",
718+
pointerEvents: "auto",
719+
},
720+
{
721+
backendNodeId: 10,
722+
parentBackendNodeId: 1,
723+
frameId: "main",
724+
tag: "iframe",
725+
attrs: { title: "Remote" },
726+
rect: { x: 100, y: 100, w: 400, h: 300 },
727+
paintOrder: 1,
728+
position: "static",
729+
pointerEvents: "auto",
730+
},
731+
];
732+
const captured: CapturedViewModel = {
733+
viewport: { width: 800, height: 600 },
734+
nodes: mainNodes,
735+
iframeNodes: new Map(),
736+
frameNodes: new Map([
737+
["main", mainNodes],
738+
["child", []],
739+
]),
740+
rootFrameId: "main",
741+
excludedBackendNodeIds: new Set(),
742+
};
743+
744+
const result = buildFrameVomScene(
745+
[
746+
{
747+
frameId: "main",
748+
contextScopeId: "main",
749+
target: { tabId: 7 },
750+
domNodes: mainNodes,
751+
axNodes: [
752+
{
753+
nodeId: "main-root",
754+
backendDOMNodeId: 1,
755+
role: { type: "role", value: "RootWebArea" },
756+
},
757+
{
758+
nodeId: "frame-owner",
759+
parentId: "main-root",
760+
backendDOMNodeId: 10,
761+
role: { type: "role", value: "Iframe" },
762+
name: { type: "computedString", value: "Remote" },
763+
},
764+
],
765+
},
766+
{
767+
frameId: "child",
768+
contextScopeId: "child",
769+
parentFrameId: "main",
770+
ownerBackendNodeId: 10,
771+
target: { tabId: 7, sessionId: "child-session" },
772+
domNodes: [],
773+
axNodes: [
774+
{
775+
nodeId: "child-root",
776+
backendDOMNodeId: 100,
777+
role: { type: "role", value: "RootWebArea" },
778+
},
779+
{
780+
nodeId: "child-button",
781+
parentId: "child-root",
782+
backendDOMNodeId: 101,
783+
role: { type: "role", value: "button" },
784+
name: { type: "computedString", value: "AX fallback action" },
785+
},
786+
],
787+
},
788+
],
789+
captured,
790+
);
791+
792+
const childButton = result.nodes.find((node) => node.backendNodeId === 101);
793+
expect(childButton).toMatchObject({ frameId: "child", contextScopeId: "child" });
794+
expect(childButton?.parentId).toBe(result.nodes.find((node) => node.backendNodeId === 10)?.id);
795+
expect(renderVom(result).text).toContain('@e1 button "AX fallback action"');
796+
});
797+
707798
it("joins AX semantics with captured geometry by backendDOMNodeId", () => {
708799
const axNodes: CdpAxNode[] = [
709800
{

apps/extension/src/tools/observation.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1254,7 +1254,7 @@ export function buildFrameVomScene(
12541254
const localScenes = new Map<string, VomScene>();
12551255
for (const frameId of orderedFrameIds) {
12561256
const document = documentByFrameId.get(frameId);
1257-
if (!document || document.domNodes.length === 0) continue;
1257+
if (!document) continue;
12581258
localScenes.set(
12591259
frameId,
12601260
buildVomScene(
@@ -1345,6 +1345,7 @@ export function buildFrameVomScene(
13451345
return {
13461346
viewport: captured.viewport,
13471347
nodes,
1348+
...(rootFrameId ? { rootFrameId } : {}),
13481349
...(surfaces.length > 0 ? { surfaces } : {}),
13491350
...(activeScopeBlocks.length > 0 ? { activeScopeBlocks } : {}),
13501351
};

packages/vom/src/__tests__/render.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,102 @@ describe("renderVom single-layer page", () => {
468468
expect(out.refs).toEqual([{ ref: "e1", backendNodeId: 4 }]);
469469
});
470470

471+
it("keeps paint-order comparisons inside their frame document", () => {
472+
const out = renderVom(
473+
{
474+
...scene([
475+
node({ id: 1, role: "RootWebArea", frameId: "main" }),
476+
node({
477+
id: 2,
478+
parentId: 1,
479+
role: "button",
480+
name: "Top-level action",
481+
tag: "button",
482+
rect: { x: 120, y: 120, w: 120, h: 40 },
483+
paintOrder: 2,
484+
frameId: "main",
485+
}),
486+
node({
487+
id: 3,
488+
parentId: 1,
489+
role: "Iframe",
490+
tag: "iframe",
491+
rect: { x: 0, y: 0, w: 1000, h: 700 },
492+
paintOrder: 1,
493+
frameId: "main",
494+
}),
495+
node({
496+
id: 4,
497+
parentId: 3,
498+
role: "generic",
499+
rect: { x: 0, y: 0, w: 1000, h: 700 },
500+
paintOrder: 100,
501+
position: "fixed",
502+
frameId: "child",
503+
}),
504+
node({
505+
id: 5,
506+
parentId: 4,
507+
role: "button",
508+
name: "Child action",
509+
tag: "button",
510+
rect: { x: 120, y: 120, w: 120, h: 40 },
511+
paintOrder: 101,
512+
frameId: "child",
513+
}),
514+
]),
515+
rootFrameId: "main",
516+
},
517+
{ activeRegionPolicy: true },
518+
);
519+
520+
expect(out.text).toContain("@layers 1 focus=L1");
521+
expect(out.text).toContain('button "Top-level action"');
522+
expect(out.text).toContain('button "Child action"');
523+
});
524+
525+
it("uses the iframe owner when a parent-frame region covers child content", () => {
526+
const out = renderVom(
527+
{
528+
...scene([
529+
node({ id: 1, role: "RootWebArea", frameId: "main" }),
530+
node({
531+
id: 2,
532+
parentId: 1,
533+
role: "Iframe",
534+
tag: "iframe",
535+
rect: { x: 100, y: 100, w: 400, h: 300 },
536+
paintOrder: 1,
537+
frameId: "main",
538+
}),
539+
node({
540+
id: 3,
541+
parentId: 2,
542+
role: "button",
543+
name: "Covered child action",
544+
tag: "button",
545+
rect: { x: 150, y: 150, w: 120, h: 40 },
546+
paintOrder: 100,
547+
frameId: "child",
548+
}),
549+
node({
550+
id: 4,
551+
parentId: 1,
552+
role: "generic",
553+
rect: { x: 120, y: 120, w: 200, h: 120 },
554+
paintOrder: 2,
555+
position: "fixed",
556+
frameId: "main",
557+
}),
558+
]),
559+
rootFrameId: "main",
560+
},
561+
{ activeRegionPolicy: true },
562+
);
563+
564+
expect(out.text).not.toContain("Covered child action");
565+
});
566+
471567
it("renders conditional surface items inline on the trigger", () => {
472568
const out = renderVom({
473569
...scene([

packages/vom/src/layers.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,18 @@ function classifyLayer(
5050
return blockerCoverage >= MASK_COVERAGE_THRESHOLD ? "mask" : "modal";
5151
}
5252

53-
export function detectBlockingLayer(nodes: VomNode[], vp: Viewport): BlockingLayer | null {
53+
export function detectBlockingLayer(
54+
nodes: VomNode[],
55+
vp: Viewport,
56+
rootFrameId?: string,
57+
): BlockingLayer | null {
5458
let blocker: { node: VomNode; coverage: number } | null = null;
5559

5660
for (const node of nodes) {
61+
// A document's paint order is local to its iframe stacking context. Only
62+
// the root document can establish a page-level blocking layer; child
63+
// documents are constrained by their iframe owner in the parent document.
64+
if (rootFrameId !== undefined && node.frameId !== rootFrameId) continue;
5765
if (!isBlockingCandidate(node)) continue;
5866
const cov = coverage(node.rect, vp);
5967
const qualifies = cov >= BLOCK_COVERAGE_THRESHOLD || (hasModalFeature(node) && cov >= 0.15);
@@ -72,7 +80,9 @@ export function detectBlockingLayer(nodes: VomNode[], vp: Viewport): BlockingLay
7280
const threshold = blocker.node.paintOrder;
7381
const members = new Set<number>();
7482
for (const node of nodes) {
75-
if (node.paintOrder >= threshold) members.add(node.id);
83+
if (node.frameId === blocker.node.frameId && node.paintOrder >= threshold) {
84+
members.add(node.id);
85+
}
7686
}
7787

7888
return {

packages/vom/src/render.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -868,29 +868,75 @@ function activeRegionCandidatePriority(node: VomNode, viewportCoverage: number):
868868
return 1;
869869
}
870870

871+
function paintLineageByFrame(
872+
node: VomNode,
873+
parentMap: Map<number, number | null>,
874+
nodesById: Map<number, VomNode>,
875+
): Map<string | undefined, VomNode> {
876+
const lineage = new Map<string | undefined, VomNode>();
877+
let current: VomNode | undefined = node;
878+
let guard = 0;
879+
while (current && guard <= parentMap.size) {
880+
if (!lineage.has(current.frameId)) lineage.set(current.frameId, current);
881+
const parentId: number | null = parentMap.get(current.id) ?? null;
882+
current = parentId === null ? undefined : nodesById.get(parentId);
883+
guard += 1;
884+
}
885+
return lineage;
886+
}
887+
888+
/** Resolve both nodes to the nearest document where their paint order is comparable. */
889+
function comparablePaintNodes(
890+
target: VomNode,
891+
blocker: VomNode,
892+
parentMap: Map<number, number | null>,
893+
nodesById: Map<number, VomNode>,
894+
): { target: VomNode; blocker: VomNode } | null {
895+
if (target.frameId === blocker.frameId) return { target, blocker };
896+
const targetLineage = paintLineageByFrame(target, parentMap, nodesById);
897+
let blockerInFrame: VomNode | undefined = blocker;
898+
let guard = 0;
899+
while (blockerInFrame && guard <= parentMap.size) {
900+
const targetInFrame = targetLineage.get(blockerInFrame.frameId);
901+
if (targetInFrame) return { target: targetInFrame, blocker: blockerInFrame };
902+
const parentId: number | null = parentMap.get(blockerInFrame.id) ?? null;
903+
blockerInFrame = parentId === null ? undefined : nodesById.get(parentId);
904+
guard += 1;
905+
}
906+
return null;
907+
}
908+
871909
function isBlockedByRegion(
872910
target: VomNode,
873911
blocker: VomNode,
874912
parentMap: Map<number, number | null>,
913+
nodesById: Map<number, VomNode>,
875914
viewportCoverage: number,
876915
): boolean {
877916
if (target.id === blocker.id) return false;
878917
if (isAncestorOf(parentMap, blocker.id, target.id)) return false;
879918
if (isAncestorOf(parentMap, target.id, blocker.id)) return false;
880919
if (!target.rect || !blocker.rect) return false;
920+
const paintNodes = comparablePaintNodes(target, blocker, parentMap, nodesById);
921+
if (!paintNodes) return false;
881922

882923
const points = interactionPoints(target.rect);
883924

884925
if (
885926
viewportCoverage < 0.9 &&
886-
target.paintOrder >= blocker.paintOrder &&
927+
paintNodes.target.paintOrder >= paintNodes.blocker.paintOrder &&
887928
points.some(([x, y]) => rectContains(blocker.rect as Rect, x, y))
888929
) {
889930
return false;
890931
}
891932

892-
if (blocker.paintOrder < target.paintOrder) return false;
893-
if (blocker.paintOrder === target.paintOrder && blocker.id <= target.id) return false;
933+
if (paintNodes.blocker.paintOrder < paintNodes.target.paintOrder) return false;
934+
if (
935+
paintNodes.blocker.paintOrder === paintNodes.target.paintOrder &&
936+
paintNodes.blocker.id <= paintNodes.target.id
937+
) {
938+
return false;
939+
}
894940
if (viewportCoverage >= 0.9) return true;
895941

896942
return points.some(([x, y]) => rectContains(blocker.rect as Rect, x, y));
@@ -914,6 +960,7 @@ function collectDescendantsOfIds(nodes: VomNode[], roots: Set<number>): Set<numb
914960

915961
function applyActiveRegionPolicy(nodes: VomNode[], scene: VomScene): VomNode[] {
916962
const parentMap = buildParentMap(nodes);
963+
const nodesById = new Map(nodes.map((node) => [node.id, node]));
917964
const candidates = nodes
918965
.filter(isPositionedRegionCandidate)
919966
.map((node) => {
@@ -930,7 +977,7 @@ function applyActiveRegionPolicy(nodes: VomNode[], scene: VomScene): VomNode[] {
930977
for (const target of nodes) {
931978
if (!isVomReferenceNode(target)) continue;
932979
const blocked = candidates.some((candidate) =>
933-
isBlockedByRegion(target, candidate.node, parentMap, candidate.viewportCoverage),
980+
isBlockedByRegion(target, candidate.node, parentMap, nodesById, candidate.viewportCoverage),
934981
);
935982
if (blocked) blockedRoots.add(target.id);
936983
}
@@ -985,7 +1032,7 @@ function renderDoubleLayer(scene: VomScene, layer: BlockingLayer, options: VomOp
9851032
export function renderVom(scene: VomScene, options: VomOptions = {}): VomResult {
9861033
const nodes = applyVomInteractionRecovery(scene.nodes);
9871034
const renderScene = { ...scene, nodes };
988-
const layer = detectBlockingLayer(nodes, scene.viewport);
1035+
const layer = detectBlockingLayer(nodes, scene.viewport, scene.rootFrameId);
9891036
if (layer) return renderDoubleLayer(renderScene, layer, options);
9901037
const visibleNodes = options.activeRegionPolicy ? applyActiveRegionPolicy(nodes, scene) : nodes;
9911038

packages/vom/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export interface VomNode {
5555
export interface VomScene {
5656
viewport: Viewport;
5757
nodes: VomNode[];
58+
/** Root document whose paint order defines page-level blocking layers. */
59+
rootFrameId?: string;
5860
surfaces?: CondSurface[];
5961
activeScopeBlocks?: ActiveScopeBlock[];
6062
}

0 commit comments

Comments
 (0)