mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a62a28d8cd | ||
|
|
896c7944d8 | ||
|
|
7e5f3bae26 | ||
|
|
af4c2fa229 | ||
|
|
ee10da6fe9 | ||
|
|
4834d8b5fa | ||
|
|
b4006d5b85 | ||
|
|
2d1e5c6d9d |
@@ -29,6 +29,24 @@ function fakeApi() {
|
||||
}
|
||||
|
||||
describe("ChromiumCdp", () => {
|
||||
it("changes attachment identity only across real debugger attachments", async () => {
|
||||
const { api, onEvent, onDetach } = fakeApi();
|
||||
const cdp = new ChromiumCdp(api);
|
||||
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||
await cdp.ensureAttached(4);
|
||||
const first = cdp.getAttachmentId(4);
|
||||
expect(first).toBeTruthy();
|
||||
await cdp.ensureAttached(4);
|
||||
onEvent.fire({ tabId: 4 }, "Target.attachedToTarget", { sessionId: "child" });
|
||||
expect(cdp.getAttachmentId(4)).toBe(first);
|
||||
onDetach.fire({ tabId: 4 }, "target_closed");
|
||||
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||
await cdp.ensureAttached(4);
|
||||
expect(cdp.getAttachmentId(4)).not.toBe(first);
|
||||
await cdp.detach(4);
|
||||
expect(cdp.getAttachmentId(4)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => {
|
||||
const { api, onEvent } = fakeApi();
|
||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
|
||||
@@ -164,6 +164,7 @@ async function settleBeforeDeadline(promises: Promise<void>[], deadline: number)
|
||||
export class ChromiumCdp {
|
||||
private readonly api: CdpDebuggerApi;
|
||||
private readonly attachedTabs = new Set<number>();
|
||||
private readonly attachmentIds = new Map<number, string>();
|
||||
private readonly attachInFlight = new Map<number, Promise<void>>();
|
||||
private readonly detachInFlight = new Map<number, Promise<void>>();
|
||||
private readonly tabOwners = new Map<number, Set<string>>();
|
||||
@@ -198,6 +199,11 @@ export class ChromiumCdp {
|
||||
this.bindFrameTargetHandler();
|
||||
}
|
||||
|
||||
/** Identity of this actual debugger attachment, independent of frame topology. */
|
||||
getAttachmentId(tabId: number): string | undefined {
|
||||
return this.attachmentIds.get(tabId);
|
||||
}
|
||||
|
||||
/** Attach to `tabId` if we haven't already in this driver. */
|
||||
async ensureAttached(tabId: number): Promise<void> {
|
||||
// Returning a tab clears the cache before Chrome finishes detaching.
|
||||
@@ -217,6 +223,7 @@ export class ChromiumCdp {
|
||||
await this.enableConsoleDomains(tabId);
|
||||
await this.enableNetworkDomainBestEffort(tabId);
|
||||
this.attachedTabs.add(tabId);
|
||||
this.attachmentIds.set(tabId, crypto.randomUUID());
|
||||
await this.enableFrameDiscovery({ tabId }).catch((err) => {
|
||||
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
|
||||
});
|
||||
@@ -444,6 +451,7 @@ export class ChromiumCdp {
|
||||
this.attachInFlight.delete(tabId);
|
||||
if (!this.attachedTabs.has(tabId)) return;
|
||||
this.attachedTabs.delete(tabId);
|
||||
this.attachmentIds.delete(tabId);
|
||||
this.clearDialogState(tabId);
|
||||
this.clearConsoleState(tabId);
|
||||
this.clearNetworkState(tabId);
|
||||
@@ -502,6 +510,7 @@ export class ChromiumCdp {
|
||||
this.attachInFlight.clear();
|
||||
this.tabOwners.clear();
|
||||
this.attachedTabs.clear();
|
||||
this.attachmentIds.clear();
|
||||
this.dialogBuffers.clear();
|
||||
this.dialogSequences.clear();
|
||||
this.consoleBuffers.clear();
|
||||
@@ -848,6 +857,7 @@ export class ChromiumCdp {
|
||||
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
||||
if (typeof source.tabId === "number") {
|
||||
this.attachedTabs.delete(source.tabId);
|
||||
this.attachmentIds.delete(source.tabId);
|
||||
this.attachInFlight.delete(source.tabId);
|
||||
this.tabOwners.delete(source.tabId);
|
||||
this.clearDialogState(source.tabId);
|
||||
|
||||
@@ -436,6 +436,8 @@ describe("file transfer tools", () => {
|
||||
if (method === "DOM.getContentQuads") {
|
||||
return { quads: [[10, 20, 210, 20, 210, 120, 10, 120]] };
|
||||
}
|
||||
if (method === "Runtime.evaluate")
|
||||
return { result: { value: { width: 400, height: 300 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 400, clientHeight: 300 } };
|
||||
}
|
||||
|
||||
@@ -13,7 +13,138 @@ import {
|
||||
import { GeometryContext } from "../geometry/frame-context";
|
||||
import type { CdpRunner } from "../shared";
|
||||
|
||||
function scrollbarDriver(
|
||||
visible = { width: 185, height: 89 },
|
||||
frameSize: unknown = { width: 200, height: 100 },
|
||||
) {
|
||||
const send = vi.fn(async (target: { sessionId?: string }, method: string) => {
|
||||
if (method === "Runtime.evaluate") return { result: { value: frameSize } };
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return {
|
||||
cssLayoutViewport: {
|
||||
clientWidth: target.sessionId ? visible.width : 800,
|
||||
clientHeight: target.sessionId ? visible.height : 600,
|
||||
},
|
||||
};
|
||||
if (method === "DOM.getBoxModel")
|
||||
return {
|
||||
model: {
|
||||
content: target.sessionId
|
||||
? [0, 0, 200, 0, 200, 100, 0, 100]
|
||||
: [100, 100, 500, 100, 500, 300, 100, 300],
|
||||
},
|
||||
};
|
||||
if (method === "DOM.getContentQuads")
|
||||
return { quads: [[180, 80, 230, 80, 230, 120, 180, 120]] };
|
||||
throw new Error(`unexpected ${method}`);
|
||||
});
|
||||
const cdp: CdpRunner = {
|
||||
send: (tabId, method) => send({ tabId } as never, method) as never,
|
||||
sendToTarget: send as CdpRunner["sendToTarget"],
|
||||
getFrameGraph: async () => ({
|
||||
rootFrameId: "main",
|
||||
frames: [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
{
|
||||
frameId: "child",
|
||||
parentFrameId: "main",
|
||||
ownerBackendNodeId: 10,
|
||||
target: { tabId: 4, sessionId: "child" },
|
||||
},
|
||||
{
|
||||
frameId: "same-child",
|
||||
parentFrameId: "child",
|
||||
ownerBackendNodeId: 20,
|
||||
target: { tabId: 4, sessionId: "child" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
return { cdp, send };
|
||||
}
|
||||
|
||||
describe("frame geometry projection", () => {
|
||||
it.each([
|
||||
{ width: 200, height: 100 },
|
||||
{ width: 185, height: 100 },
|
||||
{ width: 200, height: 89 },
|
||||
{ width: 185, height: 89 },
|
||||
])("preserves scale and clips scrollbar strips for visible viewport $width × $height", async (visible) => {
|
||||
const { cdp, send } = scrollbarDriver(visible);
|
||||
const geometry = await resolveNodeGeometry(cdp, 4, {
|
||||
target: { tabId: 4, sessionId: "child" },
|
||||
frameId: "child",
|
||||
backendNodeId: 101,
|
||||
});
|
||||
expect(geometry).toMatchObject({
|
||||
topBounds: {
|
||||
x: 460,
|
||||
y: 260,
|
||||
width: (visible.width - 180) * 2,
|
||||
height: (visible.height - 80) * 2,
|
||||
},
|
||||
actionPoint: { x: 460 + visible.width - 180, y: 260 + visible.height - 80 },
|
||||
targetActionPoint: { x: (180 + visible.width) / 2, y: (80 + visible.height) / 2 },
|
||||
});
|
||||
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
|
||||
expect(send.mock.calls.filter(([, method]) => method === "Page.getLayoutMetrics")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("shares the full viewport read across same-target frames, and refreshes it next operation", async () => {
|
||||
const { cdp, send } = scrollbarDriver();
|
||||
const context = new GeometryContext(cdp, 4);
|
||||
await Promise.all([context.targetProjection("child"), context.targetProjection("same-child")]);
|
||||
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
|
||||
await new GeometryContext(cdp, 4).targetProjection("child");
|
||||
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
{ width: 0, height: 100 },
|
||||
{ width: 200, height: NaN },
|
||||
])("rejects unavailable full viewport dimensions: %j", async (size) => {
|
||||
const { cdp } = scrollbarDriver(undefined, size);
|
||||
expect(await new GeometryContext(cdp, 4).targetProjection("child")).toBeNull();
|
||||
});
|
||||
|
||||
it("clips an inner OOPIF against an intermediate OOPIF's occupied scrollbar strips", async () => {
|
||||
const { cdp } = scrollbarDriver();
|
||||
const outer = cdp.sendToTarget!;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
if (method === "DOM.getBoxModel")
|
||||
return { model: { content: [150, 60, 250, 60, 250, 120, 150, 120] } } as never;
|
||||
if (target.sessionId !== "inner") return outer(target, method, params);
|
||||
if (method === "Runtime.evaluate")
|
||||
return { result: { value: { width: 100, height: 60 } } } as never;
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
return { cssLayoutViewport: { clientWidth: 90, clientHeight: 50 } } as never;
|
||||
if (method === "DOM.getContentQuads")
|
||||
return { quads: [[0, 0, 100, 0, 100, 60, 0, 60]] } as never;
|
||||
throw new Error(`unexpected ${method}`);
|
||||
};
|
||||
const graph = await cdp.getFrameGraph!(4);
|
||||
graph.frames[2] = {
|
||||
frameId: "inner",
|
||||
parentFrameId: "child",
|
||||
ownerBackendNodeId: 20,
|
||||
target: { tabId: 4, sessionId: "inner" },
|
||||
};
|
||||
cdp.getFrameGraph = async () => graph;
|
||||
expect(
|
||||
await resolveNodeGeometry(cdp, 4, {
|
||||
target: { tabId: 4, sessionId: "inner" },
|
||||
frameId: "inner",
|
||||
backendNodeId: 101,
|
||||
}),
|
||||
).toMatchObject({
|
||||
topBounds: { x: 400, y: 220, width: 70, height: 58 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps region bounds separate from polygon area", () => {
|
||||
const region = [
|
||||
rectPolygon({ x: 0, y: 0, w: 10, h: 10 }),
|
||||
@@ -77,6 +208,8 @@ describe("frame geometry projection", () => {
|
||||
const cdp: CdpRunner = {
|
||||
send: send as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "Runtime.evaluate")
|
||||
return { result: { value: { width: 200, height: 100 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
@@ -135,6 +268,8 @@ describe("frame geometry projection", () => {
|
||||
throw new Error(`unexpected root command ${method}`);
|
||||
}) as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "Runtime.evaluate")
|
||||
return { result: { value: { width: 200, height: 100 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
@@ -196,6 +331,8 @@ describe("frame geometry projection", () => {
|
||||
}) as CdpRunner["send"],
|
||||
sendToTarget: vi.fn(async (_target, method) => {
|
||||
if (method === "DOM.scrollIntoViewIfNeeded") return {};
|
||||
if (method === "Runtime.evaluate")
|
||||
return { result: { value: { width: 200, height: 100 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
|
||||
@@ -433,6 +433,7 @@ describe("handleRequestHelp", () => {
|
||||
if (method === "DOM.getContentQuads") {
|
||||
return { quads: [[10, 20, 110, 20, 110, 60, 10, 60]] };
|
||||
}
|
||||
if (method === "Runtime.evaluate") return { result: { value: { width: 200, height: 100 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
|
||||
@@ -201,6 +201,7 @@ describe("handleClick", () => {
|
||||
if (method === "DOM.getContentQuads") {
|
||||
return { quads: [[10, 20, 110, 20, 110, 60, 10, 60]] };
|
||||
}
|
||||
if (method === "Runtime.evaluate") return { result: { value: { width: 200, height: 100 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||
}
|
||||
@@ -220,6 +221,7 @@ describe("handleClick", () => {
|
||||
{ sessionId: "child-session", method: "DOM.scrollIntoViewIfNeeded" },
|
||||
{ sessionId: "child-session", method: "DOM.getContentQuads" },
|
||||
{ sessionId: "child-session", method: "Page.getLayoutMetrics" },
|
||||
{ sessionId: "child-session", method: "Runtime.evaluate" },
|
||||
]);
|
||||
expect(fake.sent.filter((call) => call.method === "Input.dispatchMouseEvent")).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -3380,6 +3380,7 @@ describe("handleSnapshot", () => {
|
||||
throw new Error(`unexpected root CDP method ${method}`);
|
||||
});
|
||||
const sendToTarget = vi.fn(async (_target, method: string) => {
|
||||
if (method === "Runtime.evaluate") return { result: { value: { width: 400, height: 300 } } };
|
||||
if (method === "Page.getLayoutMetrics" && ownerGeometry !== "unavailable")
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
@@ -3695,7 +3696,8 @@ describe("handleSnapshot", () => {
|
||||
const result = await captureVomObservation(makeFrameAwareDeps().cdp, 4, "https://example.com", {
|
||||
maxTokens,
|
||||
});
|
||||
expect(result.text.match(/@warning/g)).toHaveLength(1);
|
||||
expect(result.text.match(/@warning/g)).toHaveLength(2);
|
||||
expect(result.text).toContain("document identity unverified");
|
||||
expect(result.text).toContain("geometry incomplete");
|
||||
expect(result.text.startsWith("@vom 1\n")).toBe(true);
|
||||
if (maxTokens === undefined) {
|
||||
@@ -3722,7 +3724,8 @@ describe("handleSnapshot", () => {
|
||||
for (const maxTokens of [undefined, 100, 0]) {
|
||||
const result = await captureVomObservation(cdp, 4, "https://example.com", { maxTokens });
|
||||
expect(result.text).toContain(`@warning observation incomplete: some ${stage} data`);
|
||||
expect(result.text.match(/@warning/g)).toHaveLength(1);
|
||||
expect(result.text.match(/@warning/g)).toHaveLength(2);
|
||||
expect(result.text).toContain("document identity unverified");
|
||||
expect(result.text.startsWith("@vom 1\n")).toBe(true);
|
||||
if (maxTokens === undefined) {
|
||||
expect(result.frames.map((frame) => frame.frameId)).toEqual(["main", "child"]);
|
||||
@@ -3770,7 +3773,8 @@ describe("handleSnapshot", () => {
|
||||
4,
|
||||
"https://example.com",
|
||||
);
|
||||
expect(result.text).not.toContain("@warning");
|
||||
expect(result.text).not.toContain("iframe geometry incomplete");
|
||||
expect(result.text).toContain("document identity unverified");
|
||||
const node = result.matchNodes.find((item) => item.backendNodeId === 22);
|
||||
if (ownerGeometry === "clipped") expect(node?.rect).toBeNull();
|
||||
else expect(node?.rect).toEqual({ x: 120, y: 130, w: 120, h: 40 });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CdpFrame, CdpFrameGraph, CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import { resolveNodeGeometry } from "../frame-geometry";
|
||||
import type { CdpRunner } from "../shared";
|
||||
import { captureObservationFacts } from "../vom/capture-coordinator";
|
||||
|
||||
@@ -11,12 +12,28 @@ type Send = <T = Record<string, unknown>>(
|
||||
) => Promise<T>;
|
||||
type Tree = { frame: { id: string; parentId?: string; name?: string }; childFrames?: Tree[] };
|
||||
type Rect = { x: number; y: number; w: number; h: number };
|
||||
type Oracle = { probe: Rect; owners: Record<string, { x: number; y: number; scale: number }> };
|
||||
type Oracle = {
|
||||
probes: Record<string, Rect>;
|
||||
viewport: { width: number; height: number };
|
||||
owners: Record<string, { x: number; y: number; scale: number }>;
|
||||
};
|
||||
|
||||
function clip(rect: Rect, viewport: Oracle["viewport"]): Rect | null {
|
||||
const x = Math.max(0, rect.x),
|
||||
y = Math.max(0, rect.y);
|
||||
const right = Math.min(viewport.width, rect.x + rect.w);
|
||||
const bottom = Math.min(viewport.height, rect.y + rect.h);
|
||||
return right > x && bottom > y ? { x, y, w: right - x, h: bottom - y } : null;
|
||||
}
|
||||
|
||||
// The independent oracle uses DOM border boxes and this fixture's axis-aligned
|
||||
// iframe transforms. No production snapshot conversion/projection builds expectations.
|
||||
const oracleExpression = `(() => {
|
||||
const box = document.querySelector('#probe').getBoundingClientRect();
|
||||
const probes = {};
|
||||
for (const node of document.querySelectorAll('[data-geometry-probe]')) {
|
||||
const box = node.getBoundingClientRect();
|
||||
probes[node.id] = { x: box.x, y: box.y, w: box.width, h: box.height };
|
||||
}
|
||||
const owners = {};
|
||||
for (const frame of document.querySelectorAll('iframe')) {
|
||||
const rect = frame.getBoundingClientRect();
|
||||
@@ -28,17 +45,31 @@ const oracleExpression = `(() => {
|
||||
scale,
|
||||
};
|
||||
}
|
||||
return { probe: { x: box.x, y: box.y, w: box.width, h: box.height }, owners };
|
||||
return { probes, viewport: { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight }, owners };
|
||||
})()`;
|
||||
|
||||
describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate contract", () => {
|
||||
it.each([
|
||||
{ deviceScale: 1, zoom: 1 },
|
||||
{ deviceScale: 0.8, zoom: 1 },
|
||||
{ deviceScale: 1, zoom: 1.25 },
|
||||
{ deviceScale: 2, zoom: 1 },
|
||||
{ deviceScale: 2, zoom: 0.8 },
|
||||
])("matches DOM geometry at device scale $deviceScale and zoom $zoom", async (configuration) => {
|
||||
it.each(
|
||||
[
|
||||
{ deviceScale: 1, zoom: 1 },
|
||||
{ deviceScale: 0.8, zoom: 1 },
|
||||
{ deviceScale: 1, zoom: 1.25 },
|
||||
{ deviceScale: 2, zoom: 1 },
|
||||
{ deviceScale: 2, zoom: 0.8 },
|
||||
]
|
||||
.flatMap((configuration) => [
|
||||
{ ...configuration, fixture: "snapshot-coordinates", scrollbars: "none" },
|
||||
{ ...configuration, fixture: "oopif-scrollbars", scrollbars: "both" },
|
||||
])
|
||||
.concat(
|
||||
["vertical", "horizontal", "none"].map((scrollbars) => ({
|
||||
deviceScale: 1,
|
||||
zoom: 1,
|
||||
fixture: "oopif-scrollbars",
|
||||
scrollbars,
|
||||
})),
|
||||
),
|
||||
)("$fixture: device scale $deviceScale, zoom $zoom, scrollbars $scrollbars", async (configuration) => {
|
||||
const evalRoot = new URL("../../../../../evals/browser/", import.meta.url);
|
||||
const { createEvalServer } = await import(new URL("lib/server.mjs", evalRoot).href);
|
||||
const { withChrome } = await import(
|
||||
@@ -59,7 +90,9 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
|
||||
);
|
||||
await send(
|
||||
"Page.navigate",
|
||||
{ url: `${baseUrl}/snapshot-coordinates?run=coordinates` },
|
||||
{
|
||||
url: `${baseUrl}/${configuration.fixture}?run=coordinates&scrollbars=${configuration.scrollbars}`,
|
||||
},
|
||||
rootSession,
|
||||
);
|
||||
await expect
|
||||
@@ -86,7 +119,7 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
|
||||
});
|
||||
sessions.push(sessionId);
|
||||
}
|
||||
expect(sessions.length).toBe(2); // The cross-site fixture must actually be an OOPIF.
|
||||
expect(sessions.length).toBe(configuration.fixture === "oopif-scrollbars" ? 3 : 2);
|
||||
const frames: CdpFrame[] = [];
|
||||
const names = new Map<string, string>();
|
||||
const sessionFor = (target: CdpTarget) => target.sessionId ?? rootSession;
|
||||
@@ -107,7 +140,7 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
|
||||
};
|
||||
visit(frameTree);
|
||||
}
|
||||
expect(frames).toHaveLength(5);
|
||||
expect(frames).toHaveLength(configuration.fixture === "oopif-scrollbars" ? 3 : 5);
|
||||
for (const frame of frames) {
|
||||
if (!frame.parentFrameId) continue;
|
||||
const parent = frames.find((item) => item.frameId === frame.parentFrameId)!;
|
||||
@@ -127,9 +160,15 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
|
||||
},
|
||||
sendToTarget: (target, method, params) => {
|
||||
calls.push(`${target.sessionId ?? target.tabId}:${method}`);
|
||||
if (
|
||||
method === "Runtime.evaluate" &&
|
||||
(params as { expression?: string })?.expression?.includes("window.innerWidth")
|
||||
)
|
||||
calls.push(`${target.sessionId ?? target.tabId}:viewport-size`);
|
||||
return send(method, params, sessionFor(target));
|
||||
},
|
||||
getFrameGraph: async () => graph,
|
||||
getAttachmentId: () => rootSession,
|
||||
};
|
||||
const oracles = new Map<string, Oracle>();
|
||||
for (const frame of frames) {
|
||||
@@ -178,47 +217,130 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
|
||||
);
|
||||
oracles.set(frame.frameId, reply.result.value);
|
||||
}
|
||||
const childViewport = await send<{
|
||||
result: {
|
||||
value: { width: number; height: number; clientWidth: number; clientHeight: number };
|
||||
};
|
||||
}>(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression:
|
||||
"({ width: innerWidth, height: innerHeight, clientWidth: document.documentElement.clientWidth, clientHeight: document.documentElement.clientHeight })",
|
||||
returnByValue: true,
|
||||
},
|
||||
sessions[1],
|
||||
);
|
||||
for (const dimension of ["Width", "Height"] as const) {
|
||||
const occupied =
|
||||
childViewport.result.value[dimension === "Width" ? "width" : "height"] -
|
||||
childViewport.result.value[`client${dimension}`];
|
||||
if (
|
||||
configuration.scrollbars === "both" ||
|
||||
configuration.scrollbars === (dimension === "Width" ? "vertical" : "horizontal")
|
||||
)
|
||||
expect(occupied).toBeGreaterThan(0);
|
||||
else expect(occupied).toBe(0);
|
||||
}
|
||||
const facts = await captureObservationFacts(cdp, 1);
|
||||
expect(facts.issues).toEqual([]);
|
||||
for (const frame of frames) {
|
||||
const node = facts.documents
|
||||
.find((doc) => doc.frame.frameId === frame.frameId)
|
||||
?.domNodes.find((node) => node.attrs.id === "probe");
|
||||
expect(node, `missing probe in ${names.get(frame.frameId) || "root"}`).toBeDefined();
|
||||
const local = oracles.get(frame.frameId)!.probe;
|
||||
const top = { ...local };
|
||||
let current = frame;
|
||||
while (current.parentFrameId) {
|
||||
const owner = oracles.get(current.parentFrameId)!.owners[names.get(current.frameId)!];
|
||||
top.x = owner.x + top.x * owner.scale;
|
||||
top.y = owner.y + top.y * owner.scale;
|
||||
top.w *= owner.scale;
|
||||
top.h *= owner.scale;
|
||||
current = frames.find((item) => item.frameId === current.parentFrameId)!;
|
||||
}
|
||||
expect(
|
||||
node!.rect,
|
||||
JSON.stringify({
|
||||
frame: names.get(frame.frameId),
|
||||
local,
|
||||
top,
|
||||
viewport: facts.viewport,
|
||||
}),
|
||||
).not.toBeNull();
|
||||
for (const key of ["x", "y", "w", "h"] as const) {
|
||||
expect(
|
||||
Math.abs(node!.localRect![key] - local[key]),
|
||||
`${names.get(frame.frameId)} local ${key}`,
|
||||
).toBeLessThan(2);
|
||||
expect(
|
||||
Math.abs(node!.rect![key] - top[key]),
|
||||
`${names.get(frame.frameId)} top ${key}`,
|
||||
).toBeLessThan(2);
|
||||
}
|
||||
}
|
||||
expect(calls.filter((call) => call.endsWith(":Page.getLayoutMetrics"))).toHaveLength(
|
||||
sessions.length,
|
||||
);
|
||||
// Identity verification also uses Runtime.evaluate; count the full viewport reads separately.
|
||||
const viewportReadsBeforeLive = calls.filter((call) => call.endsWith(":viewport-size"));
|
||||
expect(new Set(viewportReadsBeforeLive).size).toBe(viewportReadsBeforeLive.length);
|
||||
expect(viewportReadsBeforeLive.length).toBeLessThanOrEqual(sessions.length - 1);
|
||||
for (const frame of frames) {
|
||||
for (const [id, local] of Object.entries(oracles.get(frame.frameId)!.probes)) {
|
||||
const node = facts.documents
|
||||
.find((doc) => doc.frame.frameId === frame.frameId)
|
||||
?.domNodes.find((node) => node.attrs.id === id);
|
||||
expect(node, `missing ${id} in ${names.get(frame.frameId) || "root"}`).toBeDefined();
|
||||
let top = clip(local, oracles.get(frame.frameId)!.viewport);
|
||||
let current = frame;
|
||||
while (top && current.parentFrameId) {
|
||||
const parentOracle = oracles.get(current.parentFrameId)!;
|
||||
const owner = parentOracle.owners[names.get(current.frameId)!];
|
||||
top = clip(
|
||||
{
|
||||
x: owner.x + top.x * owner.scale,
|
||||
y: owner.y + top.y * owner.scale,
|
||||
w: top.w * owner.scale,
|
||||
h: top.h * owner.scale,
|
||||
},
|
||||
parentOracle.viewport,
|
||||
);
|
||||
current = frames.find((item) => item.frameId === current.parentFrameId)!;
|
||||
}
|
||||
for (const key of ["x", "y", "w", "h"] as const)
|
||||
expect(
|
||||
Math.abs(node!.localRect![key] - local[key]),
|
||||
`${names.get(frame.frameId)} ${id} local ${key}`,
|
||||
).toBeLessThan(2);
|
||||
if (!top) {
|
||||
expect(node!.rect, `${id} must be clipped out`).toBeNull();
|
||||
if (configuration.fixture === "oopif-scrollbars")
|
||||
expect(
|
||||
await resolveNodeGeometry(cdp, 1, {
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
backendNodeId: node!.backendNodeId,
|
||||
}),
|
||||
).toMatchObject({ code: "permission_denied" });
|
||||
continue;
|
||||
}
|
||||
expect(node!.rect).not.toBeNull();
|
||||
for (const key of ["x", "y", "w", "h"] as const)
|
||||
expect(
|
||||
Math.abs(node!.rect![key] - top[key]),
|
||||
`${names.get(frame.frameId)} ${id} top ${key}`,
|
||||
).toBeLessThan(2);
|
||||
if (configuration.fixture !== "oopif-scrollbars") continue;
|
||||
const live = await resolveNodeGeometry(cdp, 1, {
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
backendNodeId: node!.backendNodeId,
|
||||
});
|
||||
if ("code" in live) throw new Error(live.message);
|
||||
for (const [key, liveKey] of [
|
||||
["x", "x"],
|
||||
["y", "y"],
|
||||
["w", "width"],
|
||||
["h", "height"],
|
||||
] as const)
|
||||
expect(
|
||||
Math.abs(live.topBounds[liveKey] - top[key]),
|
||||
`${id} live ${key}`,
|
||||
).toBeLessThan(2);
|
||||
const before = server.snapshot("coordinates").events.length;
|
||||
for (const type of ["mousePressed", "mouseReleased"])
|
||||
await send(
|
||||
"Input.dispatchMouseEvent",
|
||||
{ type, ...live.actionPoint, button: "left", clickCount: 1 },
|
||||
rootSession,
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
server
|
||||
.snapshot("coordinates")
|
||||
.events.slice(before)
|
||||
.some(
|
||||
(event: { type: string; path: string; data: { probe?: string } }) =>
|
||||
event.type === "geometry.clicked" &&
|
||||
event.data.probe === id &&
|
||||
event.path ===
|
||||
(frame.parentFrameId
|
||||
? names.get(frame.frameId) === "nested"
|
||||
? "/oopif-scrollbars/nested"
|
||||
: "/oopif-scrollbars/frame"
|
||||
: "/oopif-scrollbars"),
|
||||
),
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
}
|
||||
const metrics = await send<{
|
||||
cssVisualViewport: { zoom: number };
|
||||
visualViewport: { clientWidth: number };
|
||||
|
||||
@@ -161,7 +161,8 @@ export async function resolveNodeGeometry(
|
||||
return geometryError(`could not resolve frame geometry for ${address.frameId}`);
|
||||
topVisibleRegions = projectRegionToViewport(localRegion, projection);
|
||||
if (address.target.sessionId) {
|
||||
const localViewport = projection.edges[0]?.sourceViewport ?? projection.topViewport;
|
||||
const localViewport = await context.viewport(address.target);
|
||||
if (!localViewport) return geometryError("could not resolve target viewport geometry");
|
||||
const localVisibleRegions = localRegion
|
||||
.map((polygon) =>
|
||||
clipPolygon(
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
type Polygon,
|
||||
type ProjectiveEdge,
|
||||
parseCdpQuad,
|
||||
projectPolygon,
|
||||
type Quad,
|
||||
type Size,
|
||||
viewportPolygon,
|
||||
} from "../geometry";
|
||||
import { type CdpRunner, sendToCdpTarget } from "../shared";
|
||||
import type { CoordinateOwner, CssViewport, SnapshotProjectionResult } from "./coordinate-types";
|
||||
@@ -67,6 +69,7 @@ export function cssViewport(metrics: LayoutMetrics): CssViewport {
|
||||
/** One read-only measurement phase. Discard after scrolling or any later operation. */
|
||||
export class GeometryContext {
|
||||
private readonly metrics = new Map<string, Promise<LayoutMetrics>>();
|
||||
private readonly frameViewports = new Map<string, Promise<Size | null>>();
|
||||
private readonly owners = new Map<string, Promise<Quad | null>>();
|
||||
private readonly projections = new Map<string, Promise<GeometryProjection | null>>();
|
||||
private readonly snapshotOwners = new Map<string, Promise<{ quad: Quad; size: Size } | null>>();
|
||||
@@ -157,6 +160,35 @@ export class GeometryContext {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The full OOPIF viewport maps to its owner's content quad. Layout metrics
|
||||
* exclude occupied scrollbars and remain the clipping boundary, not the scale. */
|
||||
private frameViewport(target: CdpTarget): Promise<Size | null> {
|
||||
const key = cdpTargetKey(target);
|
||||
let promise = this.frameViewports.get(key);
|
||||
if (!promise) {
|
||||
promise = this.request<{ result?: { value?: Size }; exceptionDetails?: unknown }>(
|
||||
target,
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression: "({ width: window.innerWidth, height: window.innerHeight })",
|
||||
returnByValue: true,
|
||||
},
|
||||
).then(({ result, exceptionDetails }) => {
|
||||
const size = result?.value;
|
||||
return !exceptionDetails &&
|
||||
size &&
|
||||
Number.isFinite(size.width) &&
|
||||
Number.isFinite(size.height) &&
|
||||
size.width > 0 &&
|
||||
size.height > 0
|
||||
? size
|
||||
: null;
|
||||
});
|
||||
this.frameViewports.set(key, promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
ownerContent(target: CdpTarget, backendNodeId: number): Promise<Quad | null> {
|
||||
const key = `${cdpTargetKey(target)}:${backendNodeId}`;
|
||||
let promise = this.owners.get(key);
|
||||
@@ -342,12 +374,19 @@ export class GeometryContext {
|
||||
const parent = frames.get(root.parentFrameId);
|
||||
if (!parent || root.ownerBackendNodeId === undefined) return null;
|
||||
const destinationQuad = await this.ownerContent(parent.target, root.ownerBackendNodeId);
|
||||
const source = await this.viewport(root.target);
|
||||
if (!destinationQuad) return null;
|
||||
const source = await this.frameViewport(root.target);
|
||||
const visible = await this.viewport(root.target);
|
||||
const parentRoot = this.targetRoot(frames, parent);
|
||||
if (!destinationQuad || !source || !parentRoot) return null;
|
||||
if (!source || !visible || !parentRoot) return null;
|
||||
const destinationClips = await this.clips(frames, parent, parentRoot);
|
||||
if (!destinationClips) return null;
|
||||
edges.push({ sourceViewport: source, destinationQuad, destinationClips });
|
||||
const edge = { sourceViewport: source, destinationQuad, destinationClips };
|
||||
// Keep scrollbar strips clipped at every target boundary, including
|
||||
// intermediate OOPIFs. Project the clip with the same full-viewport scale.
|
||||
if (source.width !== visible.width || source.height !== visible.height)
|
||||
destinationClips.push(projectPolygon(viewportPolygon(visible), edge));
|
||||
edges.push(edge);
|
||||
root = parentRoot;
|
||||
}
|
||||
const topViewport = await this.viewport(root.target);
|
||||
|
||||
@@ -938,6 +938,18 @@ export async function captureVomObservation(
|
||||
notices.push(
|
||||
`@warning observation incomplete: some ${incompleteStages.join(", ")} data is unavailable or omitted.`,
|
||||
);
|
||||
if (
|
||||
facts.issues.some(
|
||||
(issue) => issue.stage === "identity" && issue.reason !== "identity-unverified",
|
||||
)
|
||||
)
|
||||
notices.push(
|
||||
"@warning observation incomplete: some documents were omitted because their identity changed or could not be revalidated.",
|
||||
);
|
||||
if (facts.issues.some((issue) => issue.reason === "identity-unverified"))
|
||||
notices.push(
|
||||
"@warning document identity unverified: some retained documents could not be checked for changes during capture.",
|
||||
);
|
||||
const captureNotice = notices.join("\n");
|
||||
const rendered = renderVom(decoratedScene, {
|
||||
maxDepth: options.maxDepth,
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface CdpRunner {
|
||||
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
|
||||
detach?(tabId: number): Promise<void>;
|
||||
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
||||
getAttachmentId?(tabId: number): string | undefined;
|
||||
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
||||
trackSessionTab?(sessionId: string, tabId: number): void;
|
||||
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
|
||||
|
||||
@@ -2,13 +2,22 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { CdpFrame, CdpTarget } from "@/browser-driver/frame-graph";
|
||||
import { cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||
import { OVERLAY_HOST_MARKER_ATTR } from "@/lib/overlay-bridge";
|
||||
import { captureVomObservation } from "../../observation";
|
||||
import type { CdpRunner } from "../../shared";
|
||||
import { captureObservationFacts, semanticCapture } from "../capture-coordinator";
|
||||
import { buildSemanticGraph } from "../semantic-graph/build";
|
||||
import { REQUESTED_STYLES, type SnapshotReply } from "../snapshot";
|
||||
|
||||
function fixture(
|
||||
options: { frames?: CdpFrame[]; fail?: string; omitDocument?: string; overlay?: string } = {},
|
||||
options: {
|
||||
frames?: CdpFrame[];
|
||||
after?: Record<string, { element?: number; missing?: boolean; unreadable?: boolean }>;
|
||||
fail?: string;
|
||||
missingIdentity?: boolean;
|
||||
omitDocument?: string;
|
||||
attachmentChanged?: boolean;
|
||||
overlay?: string;
|
||||
} = {},
|
||||
) {
|
||||
const frames = options.frames ?? [
|
||||
{ frameId: "main", target: { tabId: 4 } },
|
||||
@@ -34,7 +43,10 @@ function fixture(
|
||||
const elements = new Map(
|
||||
frames.map((frame, i) => [frame.frameId, frame.target.sessionId ? 1 : i * 10 + 1]),
|
||||
);
|
||||
const contexts = new Map(frames.map((frame, i) => [i + 1, frame.frameId]));
|
||||
const logs: Array<{ target: CdpTarget; method: string; params: Record<string, unknown> }> = [];
|
||||
let snapshots = 0;
|
||||
const targetCount = new Set(frames.map((frame) => cdpTargetKey(frame.target))).size;
|
||||
const send = vi.fn(
|
||||
async <T>(target: CdpTarget, method: string, params: object = {}): Promise<T> => {
|
||||
logs.push({ target, method, params: params as Record<string, unknown> });
|
||||
@@ -42,6 +54,27 @@ function fixture(
|
||||
if (options.fail === `${target.sessionId ?? "main"}:${method}`)
|
||||
throw new Error("fixture failure");
|
||||
let result: unknown = {};
|
||||
if (method === "Page.createIsolatedWorld")
|
||||
result = {
|
||||
executionContextId: frames.findIndex((frame) => frame.frameId === args.frameId) + 1,
|
||||
};
|
||||
if (method === "Runtime.evaluate" && args.contextId) {
|
||||
const id = contexts.get(Number(args.contextId))!;
|
||||
const change = options.after?.[id];
|
||||
result =
|
||||
options.missingIdentity || change?.unreadable
|
||||
? {}
|
||||
: {
|
||||
result: {
|
||||
deepSerializedValue: change?.missing
|
||||
? { type: "null" }
|
||||
: {
|
||||
type: "node",
|
||||
value: { backendNodeId: change?.element ?? elements.get(id) },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "Page.getLayoutMetrics")
|
||||
result = {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
@@ -49,6 +82,7 @@ function fixture(
|
||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
||||
};
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
snapshots++;
|
||||
expect((params as { computedStyles: unknown }).computedStyles).toEqual(REQUESTED_STYLES);
|
||||
result = {
|
||||
strings: [
|
||||
@@ -137,6 +171,12 @@ function fixture(
|
||||
},
|
||||
);
|
||||
const cdp: CdpRunner = {
|
||||
getAttachmentId: () =>
|
||||
options.missingIdentity
|
||||
? undefined
|
||||
: options.attachmentChanged && snapshots === targetCount
|
||||
? "new-attachment"
|
||||
: "attachment",
|
||||
getFrameGraph: async () => ({ rootFrameId: frames[0].frameId, frames }),
|
||||
send: (tabId, method, params) =>
|
||||
(send as NonNullable<CdpRunner["sendToTarget"]>)({ tabId }, method, params),
|
||||
@@ -181,6 +221,9 @@ describe("captureObservationFacts", () => {
|
||||
expect(logs.filter((call) => call.method === "DOMSnapshot.captureSnapshot")).toHaveLength(2);
|
||||
expect(logs.filter((call) => call.method === "Accessibility.enable")).toHaveLength(2);
|
||||
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(4);
|
||||
expect(facts.documents.every((doc) => doc.identity)).toBe(true);
|
||||
expect(logs.filter((call) => call.method === "DOM.describeNode")).toHaveLength(0);
|
||||
expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(4);
|
||||
const main = facts.documents.find((doc) => doc.frame.frameId === "main")!;
|
||||
const remote = facts.documents.find((doc) => doc.frame.frameId === "remote")!;
|
||||
expect(main.index.nodes.get(2)).not.toBe(remote.index.nodes.get(2));
|
||||
@@ -191,6 +234,219 @@ describe("captureObservationFacts", () => {
|
||||
expect(facts.finishedAt).toBeGreaterThanOrEqual(facts.startedAt);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ element: 99 },
|
||||
{ missing: true },
|
||||
])("isolates changed child identity %j and dependent descendants", async (change) => {
|
||||
const { cdp } = fixture({ after: { same: change } });
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "remote"]);
|
||||
expect(facts.issues).toContainEqual(
|
||||
expect.objectContaining({ frameId: "same", reason: "document-changed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ after: { main: { element: 99 } } },
|
||||
{ attachmentChanged: true },
|
||||
])("rejects a root replacement or reattachment %j", async (options) => {
|
||||
await expect(captureObservationFacts(fixture(options).cdp, 4)).rejects.toThrow(
|
||||
"document identity",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"main",
|
||||
"same",
|
||||
])("does not publish a previously identified %s document after identity reads fail", async (frameId) => {
|
||||
const { cdp } = fixture({ after: { [frameId]: { unreadable: true } } });
|
||||
if (frameId === "main")
|
||||
await expect(captureObservationFacts(cdp, 4)).rejects.toThrow("document identity");
|
||||
else {
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "remote"]);
|
||||
expect(facts.issues).toContainEqual(
|
||||
expect.objectContaining({ frameId, reason: "identity-unavailable" }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("validates newly discovered snapshot documents without a pre-snapshot identity read", async () => {
|
||||
const { cdp, logs } = fixture();
|
||||
const graph = await cdp.getFrameGraph!(4);
|
||||
cdp.getFrameGraph = async () => ({ ...graph, frames: graph.frames.slice(0, 1) });
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
expect(facts.documents.map((doc) => doc.frame.frameId)).toEqual(["main", "same", "nested"]);
|
||||
expect(facts.documents.every((doc) => doc.identity)).toBe(true);
|
||||
expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(3);
|
||||
expect(logs.findIndex((call) => call.method === "Page.createIsolatedWorld")).toBeGreaterThan(
|
||||
logs.findIndex((call) => call.method === "Accessibility.getFullAXTree"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a snapshot root that no longer matches the current frame root", async () => {
|
||||
const { cdp } = fixture();
|
||||
const original = cdp.sendToTarget!;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
const result = await original(target, method, params);
|
||||
if (method === "DOMSnapshot.captureSnapshot" && !target.sessionId) {
|
||||
const snapshot = result as {
|
||||
documents: { frameId: string; nodes: { backendNodeId: number[] } }[];
|
||||
};
|
||||
snapshot.documents[0].nodes.backendNodeId[2] = 999;
|
||||
}
|
||||
return result as never;
|
||||
};
|
||||
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
|
||||
await expect(captureObservationFacts(cdp, 4)).rejects.toThrow("document identity");
|
||||
});
|
||||
|
||||
it("starts after-identity reads only after every target finishes and propagates cancellation during identity cleanup", async () => {
|
||||
const { cdp, logs } = fixture();
|
||||
const controller = new AbortController();
|
||||
const original = cdp.sendToTarget!;
|
||||
let identityCleanup = 0;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
if (method === "Page.createIsolatedWorld") {
|
||||
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(
|
||||
4,
|
||||
);
|
||||
}
|
||||
const result = await original(target, method, params);
|
||||
if (
|
||||
method === "Runtime.releaseObjectGroup" &&
|
||||
String((params as { objectGroup?: string })?.objectGroup).startsWith(
|
||||
"bsk-document-identity-",
|
||||
) &&
|
||||
target.sessionId === "remote"
|
||||
) {
|
||||
if (++identityCleanup === 1) controller.abort();
|
||||
}
|
||||
return result as never;
|
||||
};
|
||||
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
|
||||
await expect(captureObservationFacts(cdp, 4, controller.signal)).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
expect(identityCleanup).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
1, 10, 100,
|
||||
])("bounds identity reads for %i same-target documents after snapshot and AX collection", async (count) => {
|
||||
const frames: CdpFrame[] = Array.from({ length: count }, (_, i) => ({
|
||||
frameId: `frame-${i}`,
|
||||
target: { tabId: 4 },
|
||||
...(i ? { parentFrameId: "frame-0", ownerBackendNodeId: 90000 + i } : {}),
|
||||
}));
|
||||
const { cdp, logs } = fixture({ frames });
|
||||
const original = cdp.sendToTarget!;
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
let released = 0;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
if (method === "Page.createIsolatedWorld") {
|
||||
peak = Math.max(peak, ++active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
if (method === "DOMSnapshot.captureSnapshot") {
|
||||
expect(active).toBe(0);
|
||||
expect(released).toBe(0);
|
||||
}
|
||||
if (method === "Page.createIsolatedWorld") {
|
||||
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(
|
||||
count,
|
||||
);
|
||||
}
|
||||
const result = await original(target, method, params);
|
||||
if (
|
||||
method === "Runtime.releaseObjectGroup" &&
|
||||
String((params as { objectGroup?: string })?.objectGroup).startsWith(
|
||||
"bsk-document-identity-",
|
||||
)
|
||||
) {
|
||||
active--;
|
||||
released++;
|
||||
}
|
||||
return result as never;
|
||||
};
|
||||
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
|
||||
const facts = await captureObservationFacts(cdp, 4);
|
||||
expect(facts.documents.every((doc) => doc.identity)).toBe(true);
|
||||
expect(peak).toBe(Math.min(count, 4));
|
||||
expect(active).toBe(0);
|
||||
expect(released).toBe(count);
|
||||
for (const method of [
|
||||
"Page.createIsolatedWorld",
|
||||
"Runtime.evaluate",
|
||||
"Runtime.releaseObjectGroup",
|
||||
])
|
||||
expect(logs.filter((call) => call.method === method)).toHaveLength(count);
|
||||
expect(logs.filter((call) => call.method === "DOMSnapshot.captureSnapshot")).toHaveLength(1);
|
||||
expect(logs.filter((call) => call.method === "Page.getFrameTree")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("joins identity cleanup on cancellation without starting queued frames", async () => {
|
||||
const frames: CdpFrame[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
frameId: `frame-${i}`,
|
||||
target: { tabId: 4 },
|
||||
...(i ? { parentFrameId: "frame-0", ownerBackendNodeId: 90000 + i } : {}),
|
||||
}));
|
||||
const { cdp, logs } = fixture({ frames });
|
||||
const original = cdp.sendToTarget!;
|
||||
const controller = new AbortController();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let ready!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
ready = resolve;
|
||||
});
|
||||
let held = 0;
|
||||
cdp.sendToTarget = async (target, method, params) => {
|
||||
const result = await original(target, method, params);
|
||||
if (
|
||||
method === "Runtime.releaseObjectGroup" &&
|
||||
String((params as { objectGroup?: string })?.objectGroup).startsWith(
|
||||
"bsk-document-identity-",
|
||||
)
|
||||
) {
|
||||
if (++held === 4) ready();
|
||||
await gate;
|
||||
}
|
||||
return result as never;
|
||||
};
|
||||
cdp.send = (tabId, method, params) => cdp.sendToTarget!({ tabId }, method, params);
|
||||
let settled = false;
|
||||
const pending = captureObservationFacts(cdp, 4, controller.signal).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
await blocked;
|
||||
controller.abort();
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
release();
|
||||
await rejected;
|
||||
expect(held).toBe(4);
|
||||
expect(logs.filter((call) => call.method === "Page.createIsolatedWorld")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ after: { same: { element: 99 } } }, "omitted", false],
|
||||
[{ after: { same: { unreadable: true } } }, "omitted", false],
|
||||
[{ missingIdentity: true }, "document identity unverified", true],
|
||||
[{}, "", true],
|
||||
] as const)("publishes identity integrity without silently losing content %j", async (options, notice, retained) => {
|
||||
const { cdp } = fixture(options);
|
||||
const result = await captureVomObservation(cdp, 4, "https://example.com");
|
||||
expect(result.frames.some((frame) => frame.frameId === "same")).toBe(retained);
|
||||
if (notice) expect(result.text).toContain(notice);
|
||||
else expect(result.text).not.toContain("identity");
|
||||
if (!retained) expect(result.text).not.toContain("document identity unverified");
|
||||
});
|
||||
|
||||
it("does not retry failed or missing documents and retains valid AX-only semantics", async () => {
|
||||
const { cdp, logs } = fixture({
|
||||
fail: "remote:DOMSnapshot.captureSnapshot",
|
||||
@@ -209,6 +465,12 @@ describe("captureObservationFacts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps missing identity explicit without manufacturing a verified document", async () => {
|
||||
const facts = await captureObservationFacts(fixture({ missingIdentity: true }).cdp, 4);
|
||||
expect(facts.documents.every((doc) => !doc.identity)).toBe(true);
|
||||
expect(facts.issues.filter((issue) => issue.reason === "identity-unverified")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("excludes overlay AX in its own document without excluding equal IDs elsewhere", async () => {
|
||||
const facts = await captureObservationFacts(fixture({ overlay: "remote" }).cdp, 4);
|
||||
const { documents } = semanticCapture(facts);
|
||||
@@ -393,6 +655,7 @@ function childSnapshot(frameId: string, backendNodeId: number) {
|
||||
describe("OOPIF capture", () => {
|
||||
it("captures and positions multiple OOPIF documents missing from the root snapshot", async () => {
|
||||
const sendToTarget = vi.fn(async (target, method) => {
|
||||
if (method === "Runtime.evaluate") return { result: { value: { width: 300, height: 200 } } };
|
||||
if (method === "Page.getLayoutMetrics") {
|
||||
return {
|
||||
visualViewport: { clientWidth: 1000 },
|
||||
@@ -906,7 +1169,7 @@ describe("sibling frame measurement scheduling", () => {
|
||||
return original(tabId, method, params);
|
||||
};
|
||||
const captured = await captureObservationFacts(fixture.cdp, 4);
|
||||
expect(captured.issues).toEqual([]);
|
||||
expect(captured.issues.filter((issue) => issue.stage === "geometry")).toEqual([]);
|
||||
expect(captured.documents.map((doc) => doc.frame.frameId)).toEqual(
|
||||
Array.from({ length: 8 }, (_, i) => `frame-${i}`),
|
||||
);
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
isAbortError as isCaptureAbort,
|
||||
throwIfAborted as throwCaptureAborted,
|
||||
} from "./capture-abort";
|
||||
import type { CapturedSceneInput } from "./facts";
|
||||
import { verifyDocumentIdentity } from "./document-identity";
|
||||
import type { CapturedSceneInput, DocumentIdentity } from "./facts";
|
||||
import { buildDocumentIndex, type CaptureIssue, type ObservationFacts } from "./facts";
|
||||
import { enrichFormControlStates } from "./form-capture";
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import { describeSnapshotFrames, REQUESTED_STYLES, type SnapshotReply } from "./
|
||||
interface TargetBatch<T extends FrameOwnedAxNode> {
|
||||
target: CdpTarget;
|
||||
frames: CdpFrame[];
|
||||
snapshotAttachmentId?: string;
|
||||
documents: NormalizedFrameDocument[];
|
||||
ax: FrameAxBatch<T>[];
|
||||
fallbackExcluded: Set<number>;
|
||||
@@ -121,6 +123,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
throwCaptureAborted(signal);
|
||||
await scoped.send(tabId, "DOMSnapshot.enable", {});
|
||||
throwCaptureAborted(signal);
|
||||
batch.snapshotAttachmentId = cdp.getAttachmentId?.(tabId);
|
||||
const snapshot = await scoped.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
|
||||
computedStyles: REQUESTED_STYLES,
|
||||
includePaintOrder: true,
|
||||
@@ -246,6 +249,53 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
const rootBatch = batches.find((batch) => !batch.target.sessionId);
|
||||
const rootFrameId =
|
||||
rootBatch?.rootFrameId ?? graph?.rootFrameId ?? rootBatch?.frames[0]?.frameId ?? "root";
|
||||
const ownershipInvalid = new Set(invalid);
|
||||
const identities = new Map<string, DocumentIdentity>();
|
||||
const selected = new Map(currentFrames.map((frame) => [frame.frameId, frame]));
|
||||
const checks = batches.flatMap((batch) => {
|
||||
const docs = new Map(batch.documents.map((doc) => [doc.frame.frameId, doc]));
|
||||
return batch.frames
|
||||
.filter(
|
||||
(frame) =>
|
||||
!invalid.has(frame.frameId) &&
|
||||
cdpTargetKey(selected.get(frame.frameId)!.target) === cdpTargetKey(batch.target),
|
||||
)
|
||||
.map((frame) => ({ batch, frame, doc: docs.get(frame.frameId) }));
|
||||
});
|
||||
await collectTasks(
|
||||
checks,
|
||||
async ({ batch, frame, doc }) => {
|
||||
throwCaptureAborted(signal);
|
||||
if (!batch.snapshotAttachmentId || doc?.documentElementBackendNodeId === undefined) {
|
||||
issues.push({
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
stage: "identity",
|
||||
reason: "identity-unverified",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const identity: DocumentIdentity = {
|
||||
attachmentId: batch.snapshotAttachmentId,
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
documentElementBackendNodeId: doc.documentElementBackendNodeId,
|
||||
};
|
||||
const verified = await verifyDocumentIdentity(cdp, identity, signal);
|
||||
const status = cdp.getAttachmentId?.(tabId) === identity.attachmentId ? verified : "changed";
|
||||
if (status === "current") identities.set(frame.frameId, identity);
|
||||
else {
|
||||
invalid.add(frame.frameId);
|
||||
issues.push({
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
stage: "identity",
|
||||
reason: status === "changed" ? "document-changed" : "identity-unavailable",
|
||||
});
|
||||
}
|
||||
},
|
||||
signal,
|
||||
);
|
||||
const children = new Map<string, string[]>();
|
||||
for (const frame of currentFrames) {
|
||||
if (!frame.parentFrameId) continue;
|
||||
@@ -263,7 +313,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
}
|
||||
}
|
||||
for (const frame of currentFrames) {
|
||||
if (invalid.has(frame.frameId))
|
||||
if (ownershipInvalid.has(frame.frameId))
|
||||
issues.push({
|
||||
target: frame.target,
|
||||
frameId: frame.frameId,
|
||||
@@ -271,7 +321,12 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
reason: "frame-ownership-unresolved",
|
||||
});
|
||||
}
|
||||
if (invalid.has(rootFrameId)) throw new Error("observation root document ownership is ambiguous");
|
||||
if (invalid.has(rootFrameId))
|
||||
throw new Error(
|
||||
ownershipInvalid.has(rootFrameId)
|
||||
? "observation root document ownership is ambiguous"
|
||||
: "observation document identity changed or could not be verified; observe again",
|
||||
);
|
||||
const frames = currentFrames.filter((frame) => !invalid.has(frame.frameId));
|
||||
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
||||
const belongs = (frame: CdpFrame) => {
|
||||
@@ -319,6 +374,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
||||
} = document;
|
||||
facts.push({
|
||||
frame,
|
||||
identity: identities.get(document.frameId),
|
||||
index: fallback?.size ? { ...index, excludedBackendNodeIds: fallback } : index,
|
||||
domNodes,
|
||||
axNodes,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CdpRunner } from "../shared";
|
||||
import { sendToCdpTarget } from "../shared";
|
||||
import { isAbortError, throwIfAborted } from "./capture-abort";
|
||||
import type { DocumentIdentity } from "./facts";
|
||||
|
||||
/** Compare the snapshot root with the current root in that exact frame.
|
||||
* Deep serialization supplies the backend ID without a separate describeNode. */
|
||||
export async function verifyDocumentIdentity(
|
||||
cdp: CdpRunner,
|
||||
identity: DocumentIdentity,
|
||||
signal?: AbortSignal,
|
||||
): Promise<"current" | "changed" | "unavailable"> {
|
||||
const attached = () => cdp.getAttachmentId?.(identity.target.tabId) === identity.attachmentId;
|
||||
throwIfAborted(signal);
|
||||
if (!attached()) return "changed";
|
||||
const objectGroup = `bsk-document-identity-${crypto.randomUUID()}`;
|
||||
const send = <T>(method: string, params: object) => {
|
||||
throwIfAborted(signal);
|
||||
return sendToCdpTarget<T>(cdp, identity.target, method, params);
|
||||
};
|
||||
try {
|
||||
const world = await send<{ executionContextId: number }>("Page.createIsolatedWorld", {
|
||||
frameId: identity.frameId,
|
||||
worldName: "bsk-document-identity",
|
||||
});
|
||||
const reply = await send<{
|
||||
result?: { deepSerializedValue?: { type: string; value?: { backendNodeId?: number } } };
|
||||
}>("Runtime.evaluate", {
|
||||
expression: "document.documentElement",
|
||||
contextId: world.executionContextId,
|
||||
objectGroup,
|
||||
serializationOptions: {
|
||||
serialization: "deep",
|
||||
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
|
||||
},
|
||||
});
|
||||
if (!attached()) return "changed";
|
||||
const root = reply.result?.deepSerializedValue;
|
||||
if (root?.type === "null") return "changed";
|
||||
const id = root?.type === "node" ? root.value?.backendNodeId : undefined;
|
||||
if (id === undefined) return "unavailable";
|
||||
return id === identity.documentElementBackendNodeId ? "current" : "changed";
|
||||
} catch (error) {
|
||||
throwIfAborted(signal);
|
||||
if (isAbortError(error)) throw error;
|
||||
return attached() ? "unavailable" : "changed";
|
||||
} finally {
|
||||
await sendToCdpTarget(cdp, identity.target, "Runtime.releaseObjectGroup", {
|
||||
objectGroup,
|
||||
}).catch(() => {});
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,13 @@ export async function buildDocumentIndex<T extends DecodedNode>(
|
||||
return { nodes, excludedBackendNodeIds };
|
||||
}
|
||||
|
||||
export interface DocumentIdentity {
|
||||
attachmentId: string;
|
||||
target: CdpTarget;
|
||||
frameId: string;
|
||||
documentElementBackendNodeId: number;
|
||||
}
|
||||
|
||||
/** Narrow input shared by the existing semantic scene/hover consumers. */
|
||||
export interface CapturedSceneInput {
|
||||
nodes: CapturedNode[];
|
||||
@@ -110,12 +117,19 @@ export interface CaptureIssue {
|
||||
projectionIssue?: FrameProjectionIssue;
|
||||
target: CdpTarget;
|
||||
frameId?: string;
|
||||
stage: "dom" | "ax" | "ownership" | "geometry" | "forms";
|
||||
reason: "capture-unavailable" | "frame-ownership-unresolved" | "geometry-unavailable";
|
||||
stage: "dom" | "ax" | "identity" | "ownership" | "geometry" | "forms";
|
||||
reason:
|
||||
| "document-changed"
|
||||
| "identity-unavailable"
|
||||
| "identity-unverified"
|
||||
| "capture-unavailable"
|
||||
| "frame-ownership-unresolved"
|
||||
| "geometry-unavailable";
|
||||
}
|
||||
|
||||
export interface DocumentFacts<T extends FrameOwnedAxNode> {
|
||||
readonly frame: CdpFrame;
|
||||
readonly identity?: DocumentIdentity;
|
||||
readonly index: DocumentIndex;
|
||||
readonly domNodes: CapturedNode[];
|
||||
readonly axNodes: T[];
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface FrameContext {
|
||||
export interface NormalizedDocument {
|
||||
nodes: NodeFacts[];
|
||||
index: DocumentIndex;
|
||||
documentElementBackendNodeId?: number;
|
||||
}
|
||||
|
||||
/** Interpret one document using a supplied projection. No live reads or frame scheduling. */
|
||||
@@ -98,6 +99,12 @@ export async function normalizeDocument(
|
||||
(node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId),
|
||||
),
|
||||
index,
|
||||
documentElementBackendNodeId: nodes.find(
|
||||
(node) =>
|
||||
node.nodeType === 1 &&
|
||||
node.parentBackendNodeId !== null &&
|
||||
index.nodes.get(node.parentBackendNodeId)?.nodeType === 9,
|
||||
)?.backendNodeId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# OOPIF occupied-scrollbar regression
|
||||
|
||||
An iframe's content quad includes its child viewport's scrollbar space, while
|
||||
`Page.getLayoutMetrics().cssLayoutViewport` excludes that space. Mapping the latter
|
||||
onto the whole quad stretches positions and sizes. The full target-local viewport
|
||||
must determine scale; the visible viewport must still clip content at every OOPIF
|
||||
boundary and constrain target-local action points.
|
||||
|
||||
This fixture alternates loopback hostnames across two nested OOPIFs. It includes
|
||||
scrolling, borders, padding, scaled iframe owners, a partially clipped button and
|
||||
a fully clipped button. Custom scrollbars occupy different widths and heights.
|
||||
The `scrollbars` query parameter accepts `both` (default), `vertical`, `horizontal`
|
||||
or `none`.
|
||||
|
||||
Run the numerical regression with Node 22+ and a local Chrome executable:
|
||||
|
||||
```sh
|
||||
BSK_GEOMETRY_CHROME=/path/to/chrome pnpm --filter @browser-skill/extension exec vitest run \
|
||||
src/tools/__tests__/snapshot-coordinates.browser.test.ts
|
||||
```
|
||||
|
||||
The existing browser runner is shared with the snapshot-unit regression. The test:
|
||||
|
||||
- Verifies actual OOPIF targets and occupied scrollbar dimensions.
|
||||
- Compares snapshot and live geometry with independent DOM rectangles and clipping.
|
||||
- Dispatches real root-target clicks and verifies the intended frame/button received them.
|
||||
- Rejects fully clipped controls and checks snapshot measurement reuse per target.
|
||||
- Covers five device-scale/browser-zoom combinations plus single-axis and no-scrollbar cases.
|
||||
|
||||
The runner creates and removes isolated browser profiles. It is opt-in and skipped
|
||||
in normal unit runs when `BSK_GEOMETRY_CHROME` is unset.
|
||||
|
||||
Run the CLI fixture smoke with a connected test extension:
|
||||
|
||||
```sh
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --case oopif-scrollbars --bsk ./target/debug/bsk
|
||||
```
|
||||
|
||||
The smoke assertions verify both nested frames have occupied scrollbars, that the
|
||||
marker is observed and that the session is closed. Numeric geometry and actual
|
||||
click assertions are exercised by the browser test above, not by smoke alone.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "../../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "oopif-scrollbars",
|
||||
"title": "Nested OOPIFs with occupied scrollbars and clipped controls",
|
||||
"suite": "regression",
|
||||
"tags": ["geometry", "iframe", "scroll"],
|
||||
"fixture": { "startPath": "/oopif-scrollbars" },
|
||||
"prompts": {
|
||||
"en": "Open {url}, observe the page and its frames, report the OOPIF-SCROLLBARS marker, then close the session.",
|
||||
"zh-CN": "打开 {url},观察页面及其 iframe,报告 OOPIF-SCROLLBARS 标记,然后关闭会话。"
|
||||
},
|
||||
"coverage": ["session.start", "session.stop", "page.navigate", "inspect.observe"],
|
||||
"assertions": {
|
||||
"site": [
|
||||
{
|
||||
"label": "nested frame fixture settled",
|
||||
"type": "geometry.ready",
|
||||
"where": { "data.root": true },
|
||||
"minCount": 1
|
||||
},
|
||||
{
|
||||
"label": "both child frames have occupied scrollbars",
|
||||
"type": "geometry.scrollbars",
|
||||
"where": { "data.vertical": true, "data.horizontal": true },
|
||||
"minCount": 2
|
||||
}
|
||||
],
|
||||
"response": [{ "label": "observed the fixture marker", "includes": "OOPIF-SCROLLBARS" }],
|
||||
"adapter": [{ "label": "browser session was closed", "key": "sessionStopped" }]
|
||||
},
|
||||
"smoke": {
|
||||
"steps": [
|
||||
{ "action": "navigate" },
|
||||
{ "action": "wait-site-event", "type": "geometry.ready", "where": { "root": true } },
|
||||
{ "action": "observe" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { page, withRun } from "../../../lib/fixtures.mjs";
|
||||
|
||||
export default {
|
||||
id: "oopif-scrollbars",
|
||||
routes: ["/oopif-scrollbars", "/oopif-scrollbars/frame", "/oopif-scrollbars/nested"],
|
||||
render({ pathname, runId, query }) {
|
||||
const root = pathname === "/oopif-scrollbars";
|
||||
const nested = pathname.endsWith("/nested");
|
||||
const mode = query.get("scrollbars") ?? "both";
|
||||
return page({
|
||||
title: "OOPIF scrollbar geometry regression",
|
||||
body: `
|
||||
<style>
|
||||
main { margin: 0; width: auto; }
|
||||
body { width: 2000px; height: 2400px; }
|
||||
html { overflow-anchor: none; }
|
||||
${!root ? `html { overflow-x: ${mode === "vertical" || mode === "none" ? "hidden" : "scroll"}; overflow-y: ${mode === "horizontal" || mode === "none" ? "hidden" : "scroll"}; } html::-webkit-scrollbar { width: 17px; height: 11px; }` : ""}
|
||||
button { position: absolute; padding: 0; border: 0; border-radius: 0; }
|
||||
#probe { left: ${root ? 180 : nested ? 30 : 100}px; top: ${root ? 360 : nested ? 40 : 160}px; width: 60px; height: 20px; }
|
||||
#edge { position: fixed; right: -20px; bottom: -15px; width: 80px; height: 60px; }
|
||||
#outside { position: fixed; left: calc(100% + 2px); top: calc(100% + 2px); width: 10px; height: 10px; }
|
||||
iframe { position: absolute; box-sizing: content-box; width: 420px; height: 320px;
|
||||
left: 340px; top: 400px; border: 6px solid black; padding: 8px; transform: scale(1.2); transform-origin: 0 0; }
|
||||
#nested { left: 150px; top: 210px; width: 200px; height: 120px; border-width: 4px; padding: 6px; transform: scale(0.8); }
|
||||
</style>
|
||||
<button id="probe" data-geometry-probe>OOPIF-SCROLLBARS</button>
|
||||
${!root ? '<button id="edge" data-geometry-probe>EDGE</button><button id="outside" data-geometry-probe>OUTSIDE</button>' : ""}
|
||||
${!nested ? `<iframe id="${root ? "cross" : "nested"}" name="${root ? "cross" : "nested"}" title="${root ? "outer" : "nested"} cross-site frame"></iframe>` : ""}
|
||||
`,
|
||||
script: `
|
||||
history.scrollRestoration = "manual";
|
||||
const child = document.querySelector("iframe");
|
||||
if (child) {
|
||||
const url = new URL(${JSON.stringify(withRun(root ? "/oopif-scrollbars/frame" : "/oopif-scrollbars/nested", runId, { scrollbars: mode }))}, location.href);
|
||||
url.hostname = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1";
|
||||
child.src = url.href;
|
||||
}
|
||||
document.addEventListener("click", event => {
|
||||
const probe = event.target.closest("[data-geometry-probe]");
|
||||
if (probe) browserEval.send("geometry.clicked", { probe: probe.id });
|
||||
});
|
||||
window.addEventListener("load", () => {
|
||||
scrollTo(${root ? "80, 240" : nested ? "10, 20" : "40, 100"});
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
document.documentElement.dataset.geometryReady = "true";
|
||||
if (!${root}) browserEval.send("geometry.scrollbars", {
|
||||
vertical: innerWidth > document.documentElement.clientWidth,
|
||||
horizontal: innerHeight > document.documentElement.clientHeight,
|
||||
});
|
||||
browserEval.send("geometry.ready", { root: ${root} });
|
||||
}));
|
||||
});
|
||||
`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -33,12 +33,9 @@ BSK_AUTO_UPDATE=off pnpm eval:browser smoke --case snapshot-coordinates --bsk ./
|
||||
Smoke alone does **not** certify coordinate accuracy; CLI observations do not expose raw boxes.
|
||||
Use the geometric test above for the regression's numeric assertions.
|
||||
|
||||
## Separate existing boundary
|
||||
## Related scrollbar regression
|
||||
|
||||
The OOPIF root uses non-occupying scrollbars in the unit-conversion regression. Root and
|
||||
same-process frames retain normal scrollbars. Append `&classic-scrollbars` to the fixture URL
|
||||
to reproduce the separate existing OOPIF projection issue: `targetProjection()` maps the CSS
|
||||
layout viewport (which excludes occupying scrollbars) onto the entire owner content quad.
|
||||
This inflates the projected coordinates when scrollbars occupy space. Main already has that
|
||||
mapping; fixing it also changes shared live geometry and is outside this snapshot-unit fix.
|
||||
The test does not relax its numeric tolerance to absorb that error.
|
||||
The unit-conversion fixture keeps non-occupying OOPIF scrollbars by default. The same
|
||||
browser test also runs the dedicated [OOPIF scrollbar fixture](../oopif-scrollbars/README.md),
|
||||
which covers occupied scrollbars, two OOPIF boundaries, clipping and real clicks.
|
||||
Append `&classic-scrollbars` to this fixture's URL to enable its normal OOPIF scrollbars.
|
||||
|
||||
@@ -41,22 +41,23 @@ test("case manifests are discovered, ordered, and grouped into suites", () => {
|
||||
"diagnostics",
|
||||
"mobile-emulation",
|
||||
"generated-form",
|
||||
"oopif-scrollbars",
|
||||
"snapshot-coordinates",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(repositorySummary(cases, fixtureRegistry).suites, {
|
||||
core: 6,
|
||||
matrix: 1,
|
||||
regression: 1,
|
||||
regression: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("repository validation links every case to a fixture and valid workflow evidence", () => {
|
||||
assert.deepEqual(validateRepositoryCases(cases, fixtureRegistry), []);
|
||||
const summary = repositorySummary(cases, fixtureRegistry);
|
||||
assert.equal(summary.cases, 8);
|
||||
assert.equal(summary.fixtureModules, 9);
|
||||
assert.equal(summary.fixtureRoutes, 15);
|
||||
assert.equal(summary.cases, 9);
|
||||
assert.equal(summary.fixtureModules, 10);
|
||||
assert.equal(summary.fixtureRoutes, 18);
|
||||
});
|
||||
|
||||
test("manifest validation rejects unknown operations and incomplete workflow steps", () => {
|
||||
|
||||
Reference in New Issue
Block a user