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", () => {
|
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 () => {
|
it("discovers multiple iframe targets and recursively routes nested OOPIF commands", async () => {
|
||||||
const { api, onEvent } = fakeApi();
|
const { api, onEvent } = fakeApi();
|
||||||
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ async function settleBeforeDeadline(promises: Promise<void>[], deadline: number)
|
|||||||
export class ChromiumCdp {
|
export class ChromiumCdp {
|
||||||
private readonly api: CdpDebuggerApi;
|
private readonly api: CdpDebuggerApi;
|
||||||
private readonly attachedTabs = new Set<number>();
|
private readonly attachedTabs = new Set<number>();
|
||||||
|
private readonly attachmentIds = new Map<number, string>();
|
||||||
private readonly attachInFlight = new Map<number, Promise<void>>();
|
private readonly attachInFlight = new Map<number, Promise<void>>();
|
||||||
private readonly detachInFlight = new Map<number, Promise<void>>();
|
private readonly detachInFlight = new Map<number, Promise<void>>();
|
||||||
private readonly tabOwners = new Map<number, Set<string>>();
|
private readonly tabOwners = new Map<number, Set<string>>();
|
||||||
@@ -198,6 +199,11 @@ export class ChromiumCdp {
|
|||||||
this.bindFrameTargetHandler();
|
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. */
|
/** Attach to `tabId` if we haven't already in this driver. */
|
||||||
async ensureAttached(tabId: number): Promise<void> {
|
async ensureAttached(tabId: number): Promise<void> {
|
||||||
// Returning a tab clears the cache before Chrome finishes detaching.
|
// Returning a tab clears the cache before Chrome finishes detaching.
|
||||||
@@ -217,6 +223,7 @@ export class ChromiumCdp {
|
|||||||
await this.enableConsoleDomains(tabId);
|
await this.enableConsoleDomains(tabId);
|
||||||
await this.enableNetworkDomainBestEffort(tabId);
|
await this.enableNetworkDomainBestEffort(tabId);
|
||||||
this.attachedTabs.add(tabId);
|
this.attachedTabs.add(tabId);
|
||||||
|
this.attachmentIds.set(tabId, crypto.randomUUID());
|
||||||
await this.enableFrameDiscovery({ tabId }).catch((err) => {
|
await this.enableFrameDiscovery({ tabId }).catch((err) => {
|
||||||
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
|
console.debug("[bsk cdp] frame discovery unavailable", { tabId, err });
|
||||||
});
|
});
|
||||||
@@ -444,6 +451,7 @@ export class ChromiumCdp {
|
|||||||
this.attachInFlight.delete(tabId);
|
this.attachInFlight.delete(tabId);
|
||||||
if (!this.attachedTabs.has(tabId)) return;
|
if (!this.attachedTabs.has(tabId)) return;
|
||||||
this.attachedTabs.delete(tabId);
|
this.attachedTabs.delete(tabId);
|
||||||
|
this.attachmentIds.delete(tabId);
|
||||||
this.clearDialogState(tabId);
|
this.clearDialogState(tabId);
|
||||||
this.clearConsoleState(tabId);
|
this.clearConsoleState(tabId);
|
||||||
this.clearNetworkState(tabId);
|
this.clearNetworkState(tabId);
|
||||||
@@ -502,6 +510,7 @@ export class ChromiumCdp {
|
|||||||
this.attachInFlight.clear();
|
this.attachInFlight.clear();
|
||||||
this.tabOwners.clear();
|
this.tabOwners.clear();
|
||||||
this.attachedTabs.clear();
|
this.attachedTabs.clear();
|
||||||
|
this.attachmentIds.clear();
|
||||||
this.dialogBuffers.clear();
|
this.dialogBuffers.clear();
|
||||||
this.dialogSequences.clear();
|
this.dialogSequences.clear();
|
||||||
this.consoleBuffers.clear();
|
this.consoleBuffers.clear();
|
||||||
@@ -848,6 +857,7 @@ export class ChromiumCdp {
|
|||||||
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
||||||
if (typeof source.tabId === "number") {
|
if (typeof source.tabId === "number") {
|
||||||
this.attachedTabs.delete(source.tabId);
|
this.attachedTabs.delete(source.tabId);
|
||||||
|
this.attachmentIds.delete(source.tabId);
|
||||||
this.attachInFlight.delete(source.tabId);
|
this.attachInFlight.delete(source.tabId);
|
||||||
this.tabOwners.delete(source.tabId);
|
this.tabOwners.delete(source.tabId);
|
||||||
this.clearDialogState(source.tabId);
|
this.clearDialogState(source.tabId);
|
||||||
|
|||||||
@@ -436,6 +436,8 @@ describe("file transfer tools", () => {
|
|||||||
if (method === "DOM.getContentQuads") {
|
if (method === "DOM.getContentQuads") {
|
||||||
return { quads: [[10, 20, 210, 20, 210, 120, 10, 120]] };
|
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") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return { cssLayoutViewport: { clientWidth: 400, clientHeight: 300 } };
|
return { cssLayoutViewport: { clientWidth: 400, clientHeight: 300 } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,138 @@ import {
|
|||||||
import { GeometryContext } from "../geometry/frame-context";
|
import { GeometryContext } from "../geometry/frame-context";
|
||||||
import type { CdpRunner } from "../shared";
|
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", () => {
|
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", () => {
|
it("keeps region bounds separate from polygon area", () => {
|
||||||
const region = [
|
const region = [
|
||||||
rectPolygon({ x: 0, y: 0, w: 10, h: 10 }),
|
rectPolygon({ x: 0, y: 0, w: 10, h: 10 }),
|
||||||
@@ -77,6 +208,8 @@ describe("frame geometry projection", () => {
|
|||||||
const cdp: CdpRunner = {
|
const cdp: CdpRunner = {
|
||||||
send: send as CdpRunner["send"],
|
send: send as CdpRunner["send"],
|
||||||
sendToTarget: vi.fn(async (_target, method) => {
|
sendToTarget: vi.fn(async (_target, method) => {
|
||||||
|
if (method === "Runtime.evaluate")
|
||||||
|
return { result: { value: { width: 200, height: 100 } } };
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||||
}
|
}
|
||||||
@@ -135,6 +268,8 @@ describe("frame geometry projection", () => {
|
|||||||
throw new Error(`unexpected root command ${method}`);
|
throw new Error(`unexpected root command ${method}`);
|
||||||
}) as CdpRunner["send"],
|
}) as CdpRunner["send"],
|
||||||
sendToTarget: vi.fn(async (_target, method) => {
|
sendToTarget: vi.fn(async (_target, method) => {
|
||||||
|
if (method === "Runtime.evaluate")
|
||||||
|
return { result: { value: { width: 200, height: 100 } } };
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||||
}
|
}
|
||||||
@@ -196,6 +331,8 @@ describe("frame geometry projection", () => {
|
|||||||
}) as CdpRunner["send"],
|
}) as CdpRunner["send"],
|
||||||
sendToTarget: vi.fn(async (_target, method) => {
|
sendToTarget: vi.fn(async (_target, method) => {
|
||||||
if (method === "DOM.scrollIntoViewIfNeeded") return {};
|
if (method === "DOM.scrollIntoViewIfNeeded") return {};
|
||||||
|
if (method === "Runtime.evaluate")
|
||||||
|
return { result: { value: { width: 200, height: 100 } } };
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ describe("handleRequestHelp", () => {
|
|||||||
if (method === "DOM.getContentQuads") {
|
if (method === "DOM.getContentQuads") {
|
||||||
return { quads: [[10, 20, 110, 20, 110, 60, 10, 60]] };
|
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") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ describe("handleClick", () => {
|
|||||||
if (method === "DOM.getContentQuads") {
|
if (method === "DOM.getContentQuads") {
|
||||||
return { quads: [[10, 20, 110, 20, 110, 60, 10, 60]] };
|
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") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
return { cssLayoutViewport: { clientWidth: 200, clientHeight: 100 } };
|
||||||
}
|
}
|
||||||
@@ -220,6 +221,7 @@ describe("handleClick", () => {
|
|||||||
{ sessionId: "child-session", method: "DOM.scrollIntoViewIfNeeded" },
|
{ sessionId: "child-session", method: "DOM.scrollIntoViewIfNeeded" },
|
||||||
{ sessionId: "child-session", method: "DOM.getContentQuads" },
|
{ sessionId: "child-session", method: "DOM.getContentQuads" },
|
||||||
{ sessionId: "child-session", method: "Page.getLayoutMetrics" },
|
{ sessionId: "child-session", method: "Page.getLayoutMetrics" },
|
||||||
|
{ sessionId: "child-session", method: "Runtime.evaluate" },
|
||||||
]);
|
]);
|
||||||
expect(fake.sent.filter((call) => call.method === "Input.dispatchMouseEvent")).toHaveLength(3);
|
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}`);
|
throw new Error(`unexpected root CDP method ${method}`);
|
||||||
});
|
});
|
||||||
const sendToTarget = vi.fn(async (_target, method: string) => {
|
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")
|
if (method === "Page.getLayoutMetrics" && ownerGeometry !== "unavailable")
|
||||||
return {
|
return {
|
||||||
visualViewport: { clientWidth: 1000 },
|
visualViewport: { clientWidth: 1000 },
|
||||||
@@ -3695,7 +3696,8 @@ describe("handleSnapshot", () => {
|
|||||||
const result = await captureVomObservation(makeFrameAwareDeps().cdp, 4, "https://example.com", {
|
const result = await captureVomObservation(makeFrameAwareDeps().cdp, 4, "https://example.com", {
|
||||||
maxTokens,
|
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).toContain("geometry incomplete");
|
||||||
expect(result.text.startsWith("@vom 1\n")).toBe(true);
|
expect(result.text.startsWith("@vom 1\n")).toBe(true);
|
||||||
if (maxTokens === undefined) {
|
if (maxTokens === undefined) {
|
||||||
@@ -3722,7 +3724,8 @@ describe("handleSnapshot", () => {
|
|||||||
for (const maxTokens of [undefined, 100, 0]) {
|
for (const maxTokens of [undefined, 100, 0]) {
|
||||||
const result = await captureVomObservation(cdp, 4, "https://example.com", { maxTokens });
|
const result = await captureVomObservation(cdp, 4, "https://example.com", { maxTokens });
|
||||||
expect(result.text).toContain(`@warning observation incomplete: some ${stage} data`);
|
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);
|
expect(result.text.startsWith("@vom 1\n")).toBe(true);
|
||||||
if (maxTokens === undefined) {
|
if (maxTokens === undefined) {
|
||||||
expect(result.frames.map((frame) => frame.frameId)).toEqual(["main", "child"]);
|
expect(result.frames.map((frame) => frame.frameId)).toEqual(["main", "child"]);
|
||||||
@@ -3770,7 +3773,8 @@ describe("handleSnapshot", () => {
|
|||||||
4,
|
4,
|
||||||
"https://example.com",
|
"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);
|
const node = result.matchNodes.find((item) => item.backendNodeId === 22);
|
||||||
if (ownerGeometry === "clipped") expect(node?.rect).toBeNull();
|
if (ownerGeometry === "clipped") expect(node?.rect).toBeNull();
|
||||||
else expect(node?.rect).toEqual({ x: 120, y: 130, w: 120, h: 40 });
|
else expect(node?.rect).toEqual({ x: 120, y: 130, w: 120, h: 40 });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { CdpFrame, CdpFrameGraph, CdpTarget } from "@/browser-driver/frame-graph";
|
import type { CdpFrame, CdpFrameGraph, CdpTarget } from "@/browser-driver/frame-graph";
|
||||||
|
import { resolveNodeGeometry } from "../frame-geometry";
|
||||||
import type { CdpRunner } from "../shared";
|
import type { CdpRunner } from "../shared";
|
||||||
import { captureObservationFacts } from "../vom/capture-coordinator";
|
import { captureObservationFacts } from "../vom/capture-coordinator";
|
||||||
|
|
||||||
@@ -11,12 +12,28 @@ type Send = <T = Record<string, unknown>>(
|
|||||||
) => Promise<T>;
|
) => Promise<T>;
|
||||||
type Tree = { frame: { id: string; parentId?: string; name?: string }; childFrames?: Tree[] };
|
type Tree = { frame: { id: string; parentId?: string; name?: string }; childFrames?: Tree[] };
|
||||||
type Rect = { x: number; y: number; w: number; h: number };
|
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
|
// The independent oracle uses DOM border boxes and this fixture's axis-aligned
|
||||||
// iframe transforms. No production snapshot conversion/projection builds expectations.
|
// iframe transforms. No production snapshot conversion/projection builds expectations.
|
||||||
const oracleExpression = `(() => {
|
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 = {};
|
const owners = {};
|
||||||
for (const frame of document.querySelectorAll('iframe')) {
|
for (const frame of document.querySelectorAll('iframe')) {
|
||||||
const rect = frame.getBoundingClientRect();
|
const rect = frame.getBoundingClientRect();
|
||||||
@@ -28,17 +45,31 @@ const oracleExpression = `(() => {
|
|||||||
scale,
|
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", () => {
|
describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate contract", () => {
|
||||||
it.each([
|
it.each(
|
||||||
{ deviceScale: 1, zoom: 1 },
|
[
|
||||||
{ deviceScale: 0.8, zoom: 1 },
|
{ deviceScale: 1, zoom: 1 },
|
||||||
{ deviceScale: 1, zoom: 1.25 },
|
{ deviceScale: 0.8, zoom: 1 },
|
||||||
{ deviceScale: 2, zoom: 1 },
|
{ deviceScale: 1, zoom: 1.25 },
|
||||||
{ deviceScale: 2, zoom: 0.8 },
|
{ deviceScale: 2, zoom: 1 },
|
||||||
])("matches DOM geometry at device scale $deviceScale and zoom $zoom", async (configuration) => {
|
{ 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 evalRoot = new URL("../../../../../evals/browser/", import.meta.url);
|
||||||
const { createEvalServer } = await import(new URL("lib/server.mjs", evalRoot).href);
|
const { createEvalServer } = await import(new URL("lib/server.mjs", evalRoot).href);
|
||||||
const { withChrome } = await import(
|
const { withChrome } = await import(
|
||||||
@@ -59,7 +90,9 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
|
|||||||
);
|
);
|
||||||
await send(
|
await send(
|
||||||
"Page.navigate",
|
"Page.navigate",
|
||||||
{ url: `${baseUrl}/snapshot-coordinates?run=coordinates` },
|
{
|
||||||
|
url: `${baseUrl}/${configuration.fixture}?run=coordinates&scrollbars=${configuration.scrollbars}`,
|
||||||
|
},
|
||||||
rootSession,
|
rootSession,
|
||||||
);
|
);
|
||||||
await expect
|
await expect
|
||||||
@@ -86,7 +119,7 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
|
|||||||
});
|
});
|
||||||
sessions.push(sessionId);
|
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 frames: CdpFrame[] = [];
|
||||||
const names = new Map<string, string>();
|
const names = new Map<string, string>();
|
||||||
const sessionFor = (target: CdpTarget) => target.sessionId ?? rootSession;
|
const sessionFor = (target: CdpTarget) => target.sessionId ?? rootSession;
|
||||||
@@ -107,7 +140,7 @@ describe.skipIf(!process.env.BSK_GEOMETRY_CHROME)("real DOMSnapshot coordinate c
|
|||||||
};
|
};
|
||||||
visit(frameTree);
|
visit(frameTree);
|
||||||
}
|
}
|
||||||
expect(frames).toHaveLength(5);
|
expect(frames).toHaveLength(configuration.fixture === "oopif-scrollbars" ? 3 : 5);
|
||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
if (!frame.parentFrameId) continue;
|
if (!frame.parentFrameId) continue;
|
||||||
const parent = frames.find((item) => item.frameId === frame.parentFrameId)!;
|
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) => {
|
sendToTarget: (target, method, params) => {
|
||||||
calls.push(`${target.sessionId ?? target.tabId}:${method}`);
|
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));
|
return send(method, params, sessionFor(target));
|
||||||
},
|
},
|
||||||
getFrameGraph: async () => graph,
|
getFrameGraph: async () => graph,
|
||||||
|
getAttachmentId: () => rootSession,
|
||||||
};
|
};
|
||||||
const oracles = new Map<string, Oracle>();
|
const oracles = new Map<string, Oracle>();
|
||||||
for (const frame of frames) {
|
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);
|
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);
|
const facts = await captureObservationFacts(cdp, 1);
|
||||||
expect(facts.issues).toEqual([]);
|
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(
|
expect(calls.filter((call) => call.endsWith(":Page.getLayoutMetrics"))).toHaveLength(
|
||||||
sessions.length,
|
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<{
|
const metrics = await send<{
|
||||||
cssVisualViewport: { zoom: number };
|
cssVisualViewport: { zoom: number };
|
||||||
visualViewport: { clientWidth: number };
|
visualViewport: { clientWidth: number };
|
||||||
|
|||||||
@@ -161,7 +161,8 @@ export async function resolveNodeGeometry(
|
|||||||
return geometryError(`could not resolve frame geometry for ${address.frameId}`);
|
return geometryError(`could not resolve frame geometry for ${address.frameId}`);
|
||||||
topVisibleRegions = projectRegionToViewport(localRegion, projection);
|
topVisibleRegions = projectRegionToViewport(localRegion, projection);
|
||||||
if (address.target.sessionId) {
|
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
|
const localVisibleRegions = localRegion
|
||||||
.map((polygon) =>
|
.map((polygon) =>
|
||||||
clipPolygon(
|
clipPolygon(
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
type Polygon,
|
type Polygon,
|
||||||
type ProjectiveEdge,
|
type ProjectiveEdge,
|
||||||
parseCdpQuad,
|
parseCdpQuad,
|
||||||
|
projectPolygon,
|
||||||
type Quad,
|
type Quad,
|
||||||
type Size,
|
type Size,
|
||||||
|
viewportPolygon,
|
||||||
} from "../geometry";
|
} from "../geometry";
|
||||||
import { type CdpRunner, sendToCdpTarget } from "../shared";
|
import { type CdpRunner, sendToCdpTarget } from "../shared";
|
||||||
import type { CoordinateOwner, CssViewport, SnapshotProjectionResult } from "./coordinate-types";
|
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. */
|
/** One read-only measurement phase. Discard after scrolling or any later operation. */
|
||||||
export class GeometryContext {
|
export class GeometryContext {
|
||||||
private readonly metrics = new Map<string, Promise<LayoutMetrics>>();
|
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 owners = new Map<string, Promise<Quad | null>>();
|
||||||
private readonly projections = new Map<string, Promise<GeometryProjection | null>>();
|
private readonly projections = new Map<string, Promise<GeometryProjection | null>>();
|
||||||
private readonly snapshotOwners = new Map<string, Promise<{ quad: Quad; size: Size } | null>>();
|
private readonly snapshotOwners = new Map<string, Promise<{ quad: Quad; size: Size } | null>>();
|
||||||
@@ -157,6 +160,35 @@ export class GeometryContext {
|
|||||||
: null;
|
: 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> {
|
ownerContent(target: CdpTarget, backendNodeId: number): Promise<Quad | null> {
|
||||||
const key = `${cdpTargetKey(target)}:${backendNodeId}`;
|
const key = `${cdpTargetKey(target)}:${backendNodeId}`;
|
||||||
let promise = this.owners.get(key);
|
let promise = this.owners.get(key);
|
||||||
@@ -342,12 +374,19 @@ export class GeometryContext {
|
|||||||
const parent = frames.get(root.parentFrameId);
|
const parent = frames.get(root.parentFrameId);
|
||||||
if (!parent || root.ownerBackendNodeId === undefined) return null;
|
if (!parent || root.ownerBackendNodeId === undefined) return null;
|
||||||
const destinationQuad = await this.ownerContent(parent.target, root.ownerBackendNodeId);
|
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);
|
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);
|
const destinationClips = await this.clips(frames, parent, parentRoot);
|
||||||
if (!destinationClips) return null;
|
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;
|
root = parentRoot;
|
||||||
}
|
}
|
||||||
const topViewport = await this.viewport(root.target);
|
const topViewport = await this.viewport(root.target);
|
||||||
|
|||||||
@@ -938,6 +938,18 @@ export async function captureVomObservation(
|
|||||||
notices.push(
|
notices.push(
|
||||||
`@warning observation incomplete: some ${incompleteStages.join(", ")} data is unavailable or omitted.`,
|
`@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 captureNotice = notices.join("\n");
|
||||||
const rendered = renderVom(decoratedScene, {
|
const rendered = renderVom(decoratedScene, {
|
||||||
maxDepth: options.maxDepth,
|
maxDepth: options.maxDepth,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface CdpRunner {
|
|||||||
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
|
sendToTarget?<T = unknown>(target: CdpTarget, method: string, params?: object): Promise<T>;
|
||||||
detach?(tabId: number): Promise<void>;
|
detach?(tabId: number): Promise<void>;
|
||||||
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
|
||||||
|
getAttachmentId?(tabId: number): string | undefined;
|
||||||
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
|
||||||
trackSessionTab?(sessionId: string, tabId: number): void;
|
trackSessionTab?(sessionId: string, tabId: number): void;
|
||||||
releaseSessionTab?(sessionId: string, tabId: number): Promise<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 type { CdpFrame, CdpTarget } from "@/browser-driver/frame-graph";
|
||||||
import { cdpTargetKey } from "@/browser-driver/frame-graph";
|
import { cdpTargetKey } from "@/browser-driver/frame-graph";
|
||||||
import { OVERLAY_HOST_MARKER_ATTR } from "@/lib/overlay-bridge";
|
import { OVERLAY_HOST_MARKER_ATTR } from "@/lib/overlay-bridge";
|
||||||
|
import { captureVomObservation } from "../../observation";
|
||||||
import type { CdpRunner } from "../../shared";
|
import type { CdpRunner } from "../../shared";
|
||||||
import { captureObservationFacts, semanticCapture } from "../capture-coordinator";
|
import { captureObservationFacts, semanticCapture } from "../capture-coordinator";
|
||||||
import { buildSemanticGraph } from "../semantic-graph/build";
|
import { buildSemanticGraph } from "../semantic-graph/build";
|
||||||
import { REQUESTED_STYLES, type SnapshotReply } from "../snapshot";
|
import { REQUESTED_STYLES, type SnapshotReply } from "../snapshot";
|
||||||
|
|
||||||
function fixture(
|
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 ?? [
|
const frames = options.frames ?? [
|
||||||
{ frameId: "main", target: { tabId: 4 } },
|
{ frameId: "main", target: { tabId: 4 } },
|
||||||
@@ -34,7 +43,10 @@ function fixture(
|
|||||||
const elements = new Map(
|
const elements = new Map(
|
||||||
frames.map((frame, i) => [frame.frameId, frame.target.sessionId ? 1 : i * 10 + 1]),
|
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> }> = [];
|
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(
|
const send = vi.fn(
|
||||||
async <T>(target: CdpTarget, method: string, params: object = {}): Promise<T> => {
|
async <T>(target: CdpTarget, method: string, params: object = {}): Promise<T> => {
|
||||||
logs.push({ target, method, params: params as Record<string, unknown> });
|
logs.push({ target, method, params: params as Record<string, unknown> });
|
||||||
@@ -42,6 +54,27 @@ function fixture(
|
|||||||
if (options.fail === `${target.sessionId ?? "main"}:${method}`)
|
if (options.fail === `${target.sessionId ?? "main"}:${method}`)
|
||||||
throw new Error("fixture failure");
|
throw new Error("fixture failure");
|
||||||
let result: unknown = {};
|
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")
|
if (method === "Page.getLayoutMetrics")
|
||||||
result = {
|
result = {
|
||||||
visualViewport: { clientWidth: 1000 },
|
visualViewport: { clientWidth: 1000 },
|
||||||
@@ -49,6 +82,7 @@ function fixture(
|
|||||||
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
cssLayoutViewport: { clientWidth: 1000, clientHeight: 800 },
|
||||||
};
|
};
|
||||||
if (method === "DOMSnapshot.captureSnapshot") {
|
if (method === "DOMSnapshot.captureSnapshot") {
|
||||||
|
snapshots++;
|
||||||
expect((params as { computedStyles: unknown }).computedStyles).toEqual(REQUESTED_STYLES);
|
expect((params as { computedStyles: unknown }).computedStyles).toEqual(REQUESTED_STYLES);
|
||||||
result = {
|
result = {
|
||||||
strings: [
|
strings: [
|
||||||
@@ -137,6 +171,12 @@ function fixture(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const cdp: CdpRunner = {
|
const cdp: CdpRunner = {
|
||||||
|
getAttachmentId: () =>
|
||||||
|
options.missingIdentity
|
||||||
|
? undefined
|
||||||
|
: options.attachmentChanged && snapshots === targetCount
|
||||||
|
? "new-attachment"
|
||||||
|
: "attachment",
|
||||||
getFrameGraph: async () => ({ rootFrameId: frames[0].frameId, frames }),
|
getFrameGraph: async () => ({ rootFrameId: frames[0].frameId, frames }),
|
||||||
send: (tabId, method, params) =>
|
send: (tabId, method, params) =>
|
||||||
(send as NonNullable<CdpRunner["sendToTarget"]>)({ 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 === "DOMSnapshot.captureSnapshot")).toHaveLength(2);
|
||||||
expect(logs.filter((call) => call.method === "Accessibility.enable")).toHaveLength(2);
|
expect(logs.filter((call) => call.method === "Accessibility.enable")).toHaveLength(2);
|
||||||
expect(logs.filter((call) => call.method === "Accessibility.getFullAXTree")).toHaveLength(4);
|
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 main = facts.documents.find((doc) => doc.frame.frameId === "main")!;
|
||||||
const remote = facts.documents.find((doc) => doc.frame.frameId === "remote")!;
|
const remote = facts.documents.find((doc) => doc.frame.frameId === "remote")!;
|
||||||
expect(main.index.nodes.get(2)).not.toBe(remote.index.nodes.get(2));
|
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);
|
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 () => {
|
it("does not retry failed or missing documents and retains valid AX-only semantics", async () => {
|
||||||
const { cdp, logs } = fixture({
|
const { cdp, logs } = fixture({
|
||||||
fail: "remote:DOMSnapshot.captureSnapshot",
|
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 () => {
|
it("excludes overlay AX in its own document without excluding equal IDs elsewhere", async () => {
|
||||||
const facts = await captureObservationFacts(fixture({ overlay: "remote" }).cdp, 4);
|
const facts = await captureObservationFacts(fixture({ overlay: "remote" }).cdp, 4);
|
||||||
const { documents } = semanticCapture(facts);
|
const { documents } = semanticCapture(facts);
|
||||||
@@ -393,6 +655,7 @@ function childSnapshot(frameId: string, backendNodeId: number) {
|
|||||||
describe("OOPIF capture", () => {
|
describe("OOPIF capture", () => {
|
||||||
it("captures and positions multiple OOPIF documents missing from the root snapshot", async () => {
|
it("captures and positions multiple OOPIF documents missing from the root snapshot", async () => {
|
||||||
const sendToTarget = vi.fn(async (target, method) => {
|
const sendToTarget = vi.fn(async (target, method) => {
|
||||||
|
if (method === "Runtime.evaluate") return { result: { value: { width: 300, height: 200 } } };
|
||||||
if (method === "Page.getLayoutMetrics") {
|
if (method === "Page.getLayoutMetrics") {
|
||||||
return {
|
return {
|
||||||
visualViewport: { clientWidth: 1000 },
|
visualViewport: { clientWidth: 1000 },
|
||||||
@@ -906,7 +1169,7 @@ describe("sibling frame measurement scheduling", () => {
|
|||||||
return original(tabId, method, params);
|
return original(tabId, method, params);
|
||||||
};
|
};
|
||||||
const captured = await captureObservationFacts(fixture.cdp, 4);
|
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(
|
expect(captured.documents.map((doc) => doc.frame.frameId)).toEqual(
|
||||||
Array.from({ length: 8 }, (_, i) => `frame-${i}`),
|
Array.from({ length: 8 }, (_, i) => `frame-${i}`),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
isAbortError as isCaptureAbort,
|
isAbortError as isCaptureAbort,
|
||||||
throwIfAborted as throwCaptureAborted,
|
throwIfAborted as throwCaptureAborted,
|
||||||
} from "./capture-abort";
|
} 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 { buildDocumentIndex, type CaptureIssue, type ObservationFacts } from "./facts";
|
||||||
import { enrichFormControlStates } from "./form-capture";
|
import { enrichFormControlStates } from "./form-capture";
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +27,7 @@ import { describeSnapshotFrames, REQUESTED_STYLES, type SnapshotReply } from "./
|
|||||||
interface TargetBatch<T extends FrameOwnedAxNode> {
|
interface TargetBatch<T extends FrameOwnedAxNode> {
|
||||||
target: CdpTarget;
|
target: CdpTarget;
|
||||||
frames: CdpFrame[];
|
frames: CdpFrame[];
|
||||||
|
snapshotAttachmentId?: string;
|
||||||
documents: NormalizedFrameDocument[];
|
documents: NormalizedFrameDocument[];
|
||||||
ax: FrameAxBatch<T>[];
|
ax: FrameAxBatch<T>[];
|
||||||
fallbackExcluded: Set<number>;
|
fallbackExcluded: Set<number>;
|
||||||
@@ -121,6 +123,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
|||||||
throwCaptureAborted(signal);
|
throwCaptureAborted(signal);
|
||||||
await scoped.send(tabId, "DOMSnapshot.enable", {});
|
await scoped.send(tabId, "DOMSnapshot.enable", {});
|
||||||
throwCaptureAborted(signal);
|
throwCaptureAborted(signal);
|
||||||
|
batch.snapshotAttachmentId = cdp.getAttachmentId?.(tabId);
|
||||||
const snapshot = await scoped.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
|
const snapshot = await scoped.send<SnapshotReply>(tabId, "DOMSnapshot.captureSnapshot", {
|
||||||
computedStyles: REQUESTED_STYLES,
|
computedStyles: REQUESTED_STYLES,
|
||||||
includePaintOrder: true,
|
includePaintOrder: true,
|
||||||
@@ -246,6 +249,53 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
|||||||
const rootBatch = batches.find((batch) => !batch.target.sessionId);
|
const rootBatch = batches.find((batch) => !batch.target.sessionId);
|
||||||
const rootFrameId =
|
const rootFrameId =
|
||||||
rootBatch?.rootFrameId ?? graph?.rootFrameId ?? rootBatch?.frames[0]?.frameId ?? "root";
|
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[]>();
|
const children = new Map<string, string[]>();
|
||||||
for (const frame of currentFrames) {
|
for (const frame of currentFrames) {
|
||||||
if (!frame.parentFrameId) continue;
|
if (!frame.parentFrameId) continue;
|
||||||
@@ -263,7 +313,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const frame of currentFrames) {
|
for (const frame of currentFrames) {
|
||||||
if (invalid.has(frame.frameId))
|
if (ownershipInvalid.has(frame.frameId))
|
||||||
issues.push({
|
issues.push({
|
||||||
target: frame.target,
|
target: frame.target,
|
||||||
frameId: frame.frameId,
|
frameId: frame.frameId,
|
||||||
@@ -271,7 +321,12 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
|||||||
reason: "frame-ownership-unresolved",
|
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 frames = currentFrames.filter((frame) => !invalid.has(frame.frameId));
|
||||||
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
const frameById = new Map(frames.map((frame) => [frame.frameId, frame]));
|
||||||
const belongs = (frame: CdpFrame) => {
|
const belongs = (frame: CdpFrame) => {
|
||||||
@@ -319,6 +374,7 @@ export async function captureObservationFacts<T extends FrameOwnedAxNode>(
|
|||||||
} = document;
|
} = document;
|
||||||
facts.push({
|
facts.push({
|
||||||
frame,
|
frame,
|
||||||
|
identity: identities.get(document.frameId),
|
||||||
index: fallback?.size ? { ...index, excludedBackendNodeIds: fallback } : index,
|
index: fallback?.size ? { ...index, excludedBackendNodeIds: fallback } : index,
|
||||||
domNodes,
|
domNodes,
|
||||||
axNodes,
|
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 };
|
return { nodes, excludedBackendNodeIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocumentIdentity {
|
||||||
|
attachmentId: string;
|
||||||
|
target: CdpTarget;
|
||||||
|
frameId: string;
|
||||||
|
documentElementBackendNodeId: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Narrow input shared by the existing semantic scene/hover consumers. */
|
/** Narrow input shared by the existing semantic scene/hover consumers. */
|
||||||
export interface CapturedSceneInput {
|
export interface CapturedSceneInput {
|
||||||
nodes: CapturedNode[];
|
nodes: CapturedNode[];
|
||||||
@@ -110,12 +117,19 @@ export interface CaptureIssue {
|
|||||||
projectionIssue?: FrameProjectionIssue;
|
projectionIssue?: FrameProjectionIssue;
|
||||||
target: CdpTarget;
|
target: CdpTarget;
|
||||||
frameId?: string;
|
frameId?: string;
|
||||||
stage: "dom" | "ax" | "ownership" | "geometry" | "forms";
|
stage: "dom" | "ax" | "identity" | "ownership" | "geometry" | "forms";
|
||||||
reason: "capture-unavailable" | "frame-ownership-unresolved" | "geometry-unavailable";
|
reason:
|
||||||
|
| "document-changed"
|
||||||
|
| "identity-unavailable"
|
||||||
|
| "identity-unverified"
|
||||||
|
| "capture-unavailable"
|
||||||
|
| "frame-ownership-unresolved"
|
||||||
|
| "geometry-unavailable";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentFacts<T extends FrameOwnedAxNode> {
|
export interface DocumentFacts<T extends FrameOwnedAxNode> {
|
||||||
readonly frame: CdpFrame;
|
readonly frame: CdpFrame;
|
||||||
|
readonly identity?: DocumentIdentity;
|
||||||
readonly index: DocumentIndex;
|
readonly index: DocumentIndex;
|
||||||
readonly domNodes: CapturedNode[];
|
readonly domNodes: CapturedNode[];
|
||||||
readonly axNodes: T[];
|
readonly axNodes: T[];
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface FrameContext {
|
|||||||
export interface NormalizedDocument {
|
export interface NormalizedDocument {
|
||||||
nodes: NodeFacts[];
|
nodes: NodeFacts[];
|
||||||
index: DocumentIndex;
|
index: DocumentIndex;
|
||||||
|
documentElementBackendNodeId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Interpret one document using a supplied projection. No live reads or frame scheduling. */
|
/** 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),
|
(node) => !node.tag.startsWith("#") && !index.excludedBackendNodeIds.has(node.backendNodeId),
|
||||||
),
|
),
|
||||||
index,
|
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.
|
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.
|
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
|
The unit-conversion fixture keeps non-occupying OOPIF scrollbars by default. The same
|
||||||
same-process frames retain normal scrollbars. Append `&classic-scrollbars` to the fixture URL
|
browser test also runs the dedicated [OOPIF scrollbar fixture](../oopif-scrollbars/README.md),
|
||||||
to reproduce the separate existing OOPIF projection issue: `targetProjection()` maps the CSS
|
which covers occupied scrollbars, two OOPIF boundaries, clipping and real clicks.
|
||||||
layout viewport (which excludes occupying scrollbars) onto the entire owner content quad.
|
Append `&classic-scrollbars` to this fixture's URL to enable its normal OOPIF scrollbars.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -41,22 +41,23 @@ test("case manifests are discovered, ordered, and grouped into suites", () => {
|
|||||||
"diagnostics",
|
"diagnostics",
|
||||||
"mobile-emulation",
|
"mobile-emulation",
|
||||||
"generated-form",
|
"generated-form",
|
||||||
|
"oopif-scrollbars",
|
||||||
"snapshot-coordinates",
|
"snapshot-coordinates",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.deepEqual(repositorySummary(cases, fixtureRegistry).suites, {
|
assert.deepEqual(repositorySummary(cases, fixtureRegistry).suites, {
|
||||||
core: 6,
|
core: 6,
|
||||||
matrix: 1,
|
matrix: 1,
|
||||||
regression: 1,
|
regression: 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("repository validation links every case to a fixture and valid workflow evidence", () => {
|
test("repository validation links every case to a fixture and valid workflow evidence", () => {
|
||||||
assert.deepEqual(validateRepositoryCases(cases, fixtureRegistry), []);
|
assert.deepEqual(validateRepositoryCases(cases, fixtureRegistry), []);
|
||||||
const summary = repositorySummary(cases, fixtureRegistry);
|
const summary = repositorySummary(cases, fixtureRegistry);
|
||||||
assert.equal(summary.cases, 8);
|
assert.equal(summary.cases, 9);
|
||||||
assert.equal(summary.fixtureModules, 9);
|
assert.equal(summary.fixtureModules, 10);
|
||||||
assert.equal(summary.fixtureRoutes, 15);
|
assert.equal(summary.fixtureRoutes, 18);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("manifest validation rejects unknown operations and incomplete workflow steps", () => {
|
test("manifest validation rejects unknown operations and incomplete workflow steps", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user