mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(geometry): normalize snapshot layout bounds and scroll together
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# DOMSnapshot layout-unit regression (#195)
|
||||
|
||||
Source: https://github.com/Tencent/BrowserSkill/pull/195
|
||||
|
||||
DOMSnapshot bounds and document scroll offsets retain Blink layout units. Treating them as CSS
|
||||
pixels changes both positions and sizes under device scaling/browser zoom, before iframe
|
||||
projection or viewport clipping even starts.
|
||||
|
||||
The fixture contains a scrolled root, a scrolled same-process iframe, an OOPIF served from the
|
||||
other loopback hostname, and a nested iframe in each. Owners have borders, padding and CSS
|
||||
transforms. The geometry test compares the production capture's local and top-level rectangles
|
||||
against independent DOM border boxes, with at most 2 CSS pixels of layout rounding. It also
|
||||
checks that each target reads layout metrics only once. No credentials or personal tabs are used.
|
||||
|
||||
Run the geometric assertions 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 test launches and cleans up its own headless Chrome profiles. It explicitly verifies the
|
||||
requested browser zoom and OOPIF topology, so unsupported setups fail rather than silently
|
||||
testing a different configuration. It is skipped in ordinary unit runs unless the executable
|
||||
is specified. No browser download or new package dependency is required.
|
||||
|
||||
The corpus smoke case checks fixture readiness and observable semantics through the real CLI:
|
||||
|
||||
```sh
|
||||
BSK_AUTO_UPDATE=off pnpm eval:browser smoke --case snapshot-coordinates --bsk ./target/debug/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
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,99 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// This regression owns its browser/profile. It never attaches to a user's Chrome.
|
||||
export async function withChrome({ executable, deviceScale, zoom }, run) {
|
||||
const profile = await mkdtemp(join(tmpdir(), "bsk-snapshot-coordinates-"));
|
||||
let chrome;
|
||||
let socket;
|
||||
const pending = new Map();
|
||||
try {
|
||||
await mkdir(join(profile, "Default"));
|
||||
await writeFile(
|
||||
join(profile, "Default", "Preferences"),
|
||||
JSON.stringify({
|
||||
partition: { default_zoom_level: { x: Math.log(zoom) / Math.log(1.2) } },
|
||||
}),
|
||||
);
|
||||
chrome = spawn(
|
||||
executable,
|
||||
[
|
||||
"--headless=new",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-extensions",
|
||||
"--site-per-process",
|
||||
"--remote-debugging-port=0",
|
||||
"--window-size=1600,1200",
|
||||
`--force-device-scale-factor=${deviceScale}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
"about:blank",
|
||||
],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
);
|
||||
const endpoint = await new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`Chrome startup timed out: ${output}`)),
|
||||
15_000,
|
||||
);
|
||||
const fail = (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
};
|
||||
chrome.once("error", fail);
|
||||
chrome.once("exit", (code) => fail(new Error(`Chrome exited (${code}): ${output}`)));
|
||||
chrome.stderr.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
const match = output.match(/DevTools listening on (ws:\/\/\S+)/);
|
||||
if (match) {
|
||||
clearTimeout(timeout);
|
||||
resolve(match[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
socket = new WebSocket(endpoint);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener("open", resolve, { once: true });
|
||||
socket.addEventListener("error", reject, { once: true });
|
||||
});
|
||||
socket.addEventListener("message", ({ data }) => {
|
||||
const reply = JSON.parse(data);
|
||||
const request = pending.get(reply.id);
|
||||
if (!request) return;
|
||||
clearTimeout(request.timeout);
|
||||
pending.delete(reply.id);
|
||||
if (reply.error) request.reject(new Error(JSON.stringify(reply.error)));
|
||||
else request.resolve(reply.result);
|
||||
});
|
||||
let sequence = 0;
|
||||
const send = (method, params = {}, sessionId) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = ++sequence;
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`CDP timed out: ${method}`));
|
||||
}, 10_000);
|
||||
pending.set(id, { resolve, reject, timeout });
|
||||
socket.send(JSON.stringify({ id, method, params, sessionId }));
|
||||
});
|
||||
await run(send);
|
||||
} finally {
|
||||
for (const request of pending.values()) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error("regression browser closed"));
|
||||
}
|
||||
socket?.close();
|
||||
if (chrome && chrome.exitCode === null && chrome.signalCode === null) {
|
||||
const exited = once(chrome, "exit");
|
||||
const timeout = setTimeout(() => chrome.kill("SIGKILL"), 5_000);
|
||||
chrome.kill();
|
||||
await exited;
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
await rm(profile, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "../../../schemas/case.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "snapshot-coordinates",
|
||||
"title": "Scrolled and nested frame coordinate fixture",
|
||||
"suite": "regression",
|
||||
"tags": ["geometry", "iframe", "scroll"],
|
||||
"fixture": { "startPath": "/snapshot-coordinates" },
|
||||
"prompts": {
|
||||
"en": "Open {url}, observe the page and its frames, report the GEOMETRY marker, then close the session.",
|
||||
"zh-CN": "打开 {url},观察页面及其 iframe,报告 GEOMETRY 标记,然后关闭会话。"
|
||||
},
|
||||
"coverage": ["session.start", "session.stop", "page.navigate", "inspect.observe"],
|
||||
"assertions": {
|
||||
"site": [
|
||||
{
|
||||
"label": "root fixture scrolled and settled",
|
||||
"type": "geometry.ready",
|
||||
"where": { "data.root": true },
|
||||
"minCount": 1
|
||||
}
|
||||
],
|
||||
"response": [{ "label": "observed the fixture marker", "includes": "GEOMETRY-195" }],
|
||||
"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,52 @@
|
||||
import { page, withRun } from "../../../lib/fixtures.mjs";
|
||||
|
||||
export default {
|
||||
id: "snapshot-coordinates",
|
||||
routes: ["/snapshot-coordinates", "/snapshot-coordinates/frame", "/snapshot-coordinates/nested"],
|
||||
render({ pathname, runId, query }) {
|
||||
const root = pathname === "/snapshot-coordinates";
|
||||
const nested = pathname.endsWith("/nested");
|
||||
return page({
|
||||
title: "Snapshot coordinate regression",
|
||||
body: `
|
||||
<style>
|
||||
main { margin: 0; width: auto; }
|
||||
body { width: 2000px; height: 2400px; }
|
||||
html { overflow-anchor: none; }
|
||||
${query.get("scrollbars") === "none" ? "html { scrollbar-width: none; }" : ""}
|
||||
button { position: absolute; box-sizing: border-box; padding: 0; border: 2px solid black; border-radius: 0; }
|
||||
#probe { left: ${root ? 180 : 100}px; top: ${root ? 360 : 160}px; width: 120px; height: 40px; }
|
||||
iframe { position: absolute; box-sizing: content-box; width: 320px; height: 220px;
|
||||
border: 6px solid black; padding: 8px; transform: scale(1.25); transform-origin: 0 0; }
|
||||
#same { left: 340px; top: 400px; }
|
||||
#cross { left: 820px; top: 400px; }
|
||||
#nested { left: 150px; top: 210px; width: 130px; height: 70px; border-width: 2px; padding: 4px; transform: scale(0.8); }
|
||||
${nested ? "#probe { left: 30px; top: 40px; width: 60px; height: 20px; }" : ""}
|
||||
</style>
|
||||
<button id="probe" data-geometry-probe>GEOMETRY-195</button>
|
||||
${root ? `<iframe id="same" name="same" title="same-process frame" src="${withRun("/snapshot-coordinates/frame", runId)}"></iframe><iframe id="cross" name="cross" title="cross-site frame"></iframe>` : ""}
|
||||
${!root && !nested ? `<iframe id="nested" name="nested" title="nested frame" src="${withRun("/snapshot-coordinates/nested", runId)}"></iframe>` : ""}
|
||||
`,
|
||||
script: `
|
||||
history.scrollRestoration = "manual";
|
||||
const cross = document.querySelector("#cross");
|
||||
if (cross) {
|
||||
const url = new URL(${JSON.stringify(withRun("/snapshot-coordinates/frame", runId))}, location.href);
|
||||
url.hostname = location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1";
|
||||
// Isolate unit conversion from the pre-existing OOPIF scrollbar-width
|
||||
// projection issue. ?classic-scrollbars retains that separate reproduction.
|
||||
if (!new URLSearchParams(location.search).has("classic-scrollbars")) url.searchParams.set("scrollbars", "none");
|
||||
cross.src = url.href;
|
||||
}
|
||||
window.addEventListener("load", () => {
|
||||
scrollTo(${root ? "80, 240" : nested ? "10, 20" : "40, 100"});
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
window.geometryReady = true;
|
||||
document.documentElement.dataset.geometryReady = "true";
|
||||
browserEval.send("geometry.ready", { root: ${root} });
|
||||
}));
|
||||
});
|
||||
`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -41,17 +41,22 @@ test("case manifests are discovered, ordered, and grouped into suites", () => {
|
||||
"diagnostics",
|
||||
"mobile-emulation",
|
||||
"generated-form",
|
||||
"snapshot-coordinates",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(repositorySummary(cases, fixtureRegistry).suites, { core: 6, matrix: 1 });
|
||||
assert.deepEqual(repositorySummary(cases, fixtureRegistry).suites, {
|
||||
core: 6,
|
||||
matrix: 1,
|
||||
regression: 1,
|
||||
});
|
||||
});
|
||||
|
||||
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, 7);
|
||||
assert.equal(summary.fixtureModules, 8);
|
||||
assert.equal(summary.fixtureRoutes, 12);
|
||||
assert.equal(summary.cases, 8);
|
||||
assert.equal(summary.fixtureModules, 9);
|
||||
assert.equal(summary.fixtureRoutes, 15);
|
||||
});
|
||||
|
||||
test("manifest validation rejects unknown operations and incomplete workflow steps", () => {
|
||||
|
||||
Reference in New Issue
Block a user