Stabilize controlled scroll screenshot comparisons

This commit is contained in:
SGavrl committed 2026-07-31 06:48:05 +00:00
1 parent 9bacacc60d
commit 1a0c7096fc
4 files changed
+296 -12

No files matched your search

+46
View File
@@ -728,6 +728,51 @@ async fn run_fetch(
if let Some(ref path) = screenshot {
#[cfg(feature = "render")]
{
// Paired renderer captures need a stable final coordinate after
// the post-eval settle. Authored smooth scrolling and scroll
// anchoring may legitimately move an earlier scrollTo while the
// page changes above the viewport, so the comparison harness opts
// into one instant reassertion at the actual capture boundary.
// Ordinary CLI screenshots are unchanged when these private
// capture-environment variables are absent.
let controlled_scroll = std::env::var("OBSCURA_SHOT_SCROLL_Y")
.ok()
.and_then(|raw_y| {
let x = match std::env::var("OBSCURA_SHOT_SCROLL_X") {
Ok(raw) => raw
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())?,
Err(_) => 0.0,
};
let requested_y = if raw_y.eq_ignore_ascii_case("bottom") {
"document.documentElement.scrollHeight".to_string()
} else {
raw_y
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
.map(|value| value.to_string())?
};
Some(page.evaluate(&format!(
"(()=>{{\
const requestedX={x},requestedY={requested_y};\
const preReassert={{x:window.scrollX,y:window.scrollY}};\
const root=document.documentElement;\
const previous=root?root.style.getPropertyValue('scroll-behavior'):'';\
const priority=root?root.style.getPropertyPriority('scroll-behavior'):'';\
if(root)root.style.setProperty('scroll-behavior','auto','important');\
window.scrollTo(requestedX,requestedY);\
if(root){{if(previous)root.style.setProperty('scroll-behavior',previous,priority);\
else root.style.removeProperty('scroll-behavior')}}\
return {{requested:{{x:requestedX,y:requestedY}},\
preReassertActual:preReassert,\
finalReassertActual:{{x:window.scrollX,y:window.scrollY}},\
behavior:'instant',\
phase:'immediately-before-capture-state-and-screenshot'}}\
}})()"
)))
});
let capture_state = deferred_eval_output.as_ref().map(|_| {
page.evaluate(
"(()=>({\
@@ -755,6 +800,7 @@ async fn run_fetch(
"{}",
serde_json::json!({
"evaluation": result,
"controlledScroll": controlled_scroll,
"captureState": capture_state.unwrap_or(serde_json::Value::Null),
})
);
@@ -71,3 +71,58 @@ fn screenshot_is_captured_after_eval_scrolls_the_live_page() {
std::fs::remove_dir_all(directory).expect("remove temporary output directory");
}
#[test]
fn paired_capture_reasserts_scroll_after_the_post_eval_settle() {
let directory = output_directory();
std::fs::create_dir_all(&directory).expect("temporary output directory");
let screenshot = directory.join("reasserted.png");
let url = concat!(
"data:text/html,<html style=\"margin:0;scroll-behavior:smooth\">",
"<body style=\"margin:0\"><div style=\"height:400px\"></div>",
"</body></html>"
);
let output = Command::new(env!("CARGO_BIN_EXE_obscura"))
.args(["fetch", url, "--screenshot"])
.arg(&screenshot)
.args([
"--eval",
"(()=>{scrollTo(0,40);setTimeout(()=>scrollTo(0,10),10);return 'started'})()",
"--wait",
"1",
"--timeout",
"5",
"--quiet",
])
.env("OBSCURA_SHOT_W", "100")
.env("OBSCURA_SHOT_H", "80")
.env("OBSCURA_SHOT_SCROLL_X", "0")
.env("OBSCURA_SHOT_SCROLL_Y", "80")
.output()
.expect("run obscura fetch");
assert!(
output.status.success(),
"capture failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let report: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("capture report JSON");
assert_eq!(
report["controlledScroll"]["preReassertActual"]["y"].as_f64(),
Some(10.0),
"the diagnostic offset must be sampled after the post-eval settle"
);
assert_eq!(
report["controlledScroll"]["finalReassertActual"]["y"].as_f64(),
Some(80.0)
);
assert_eq!(report["captureState"]["scrollY"].as_f64(), Some(80.0));
assert_eq!(
report["controlledScroll"]["phase"].as_str(),
Some("immediately-before-capture-state-and-screenshot")
);
std::fs::remove_dir_all(directory).expect("remove temporary output directory");
}
+137 -11
View File
@@ -161,10 +161,66 @@ def scroll_eval_expression(scroll):
return obscura_state_eval_expression(scroll)
def reassert_chromium_controlled_scroll(page, scroll):
"""Reassert an exact capture offset after the page's settle interval.
The first controlled scroll intentionally obeys the page's authored
`scroll-behavior` and participates in ordinary scroll anchoring while the
page settles. That is useful runtime evidence, but it is not a stable
screenshot coordinate: a shrinking region above the viewport can move the
final offset even when both engines laid it out correctly. Preserve that
settled offset, then issue one explicit instant scroll immediately before
state sampling and paint.
"""
scroll_x, scroll_y = scroll
return page.evaluate(
"""([x, y]) => {
const requestedY = y === "bottom"
? document.documentElement.scrollHeight
: y;
const beforeReassert = {x: window.scrollX, y: window.scrollY};
try {
window.scrollTo({
left: x,
top: requestedY,
behavior: "instant"
});
} catch (_) {
const root = document.documentElement;
const previous = root ? root.style.getPropertyValue(
"scroll-behavior") : "";
const priority = root ? root.style.getPropertyPriority(
"scroll-behavior") : "";
if (root) {
root.style.setProperty("scroll-behavior", "auto", "important");
}
window.scrollTo(x, requestedY);
if (root) {
if (previous) {
root.style.setProperty("scroll-behavior", previous, priority);
} else {
root.style.removeProperty("scroll-behavior");
}
}
}
return {
requested: {x, y: requestedY},
pre_reassert_actual: beforeReassert,
final_actual: {x: window.scrollX, y: window.scrollY},
reassert_behavior: "instant",
pre_reassert_phase: "after-controlled-scroll-settle",
final_phase: "immediately-before-state-and-screenshot"
};
}""",
[scroll_x, scroll_y],
)
def obscura_state_eval_expression(scroll, geometry_selectors=None):
"""Sample the live page after an optional scroll and immediately before paint."""
scroll_script = ""
requested = "null"
controlled_scroll_result = "null"
if scroll is not None:
scroll_x, scroll_y = scroll
requested_y = (
@@ -174,9 +230,19 @@ def obscura_state_eval_expression(scroll, geometry_selectors=None):
)
scroll_script = (
f"const requestedX={scroll_x},requestedY={requested_y};"
"const preInitialActual={x:window.scrollX,y:window.scrollY};"
"window.scrollTo(requestedX,requestedY);"
"const postInitialActual={x:window.scrollX,y:window.scrollY};"
)
requested = "{x:requestedX,y:requestedY}"
controlled_scroll_result = (
"{requested:{x:requestedX,y:requestedY},"
"pre_initial_actual:preInitialActual,"
"post_initial_actual:postInitialActual,"
"initial_behavior:'authored',"
"initial_phase:'before-cli-post-eval-settle',"
"final_phase:'cli-capture-state-after-post-eval-settle'}"
)
geometry_setup = ""
geometry_result = ""
if geometry_selectors:
@@ -203,9 +269,10 @@ def obscura_state_eval_expression(scroll, geometry_selectors=None):
"return ('00000000'+(h>>>0).toString(16)).slice(-8)};"
+ geometry_setup
+ "return JSON.stringify({"
"sampled_phase:'immediately-before-screenshot',"
"sampled_phase:'before-cli-post-eval-settle',"
+ geometry_result
+ f"requested:{requested},"
+ f"controlled_scroll:{controlled_scroll_result},"
"url:location.href,"
"document:{ready_state:document.readyState,"
"element_count:document.getElementsByTagName('*').length,"
@@ -277,6 +344,9 @@ def parse_obscura_capture_report(stdout):
}
)
state["geometry"] = geometry
if isinstance(report.get("controlledScroll"), dict):
state["controlled_scroll_capture"] = report["controlledScroll"]
state["evaluation_sampled_phase"] = state.get("sampled_phase")
state["sampled_phase"] = "immediately-before-screenshot"
return state
return None
@@ -287,8 +357,20 @@ def parse_obscura_scroll_report(stdout):
if state is None:
return None
geometry = state.get("geometry") or {}
controlled = state.get("controlled_scroll") or {}
capture_controlled = state.get("controlled_scroll_capture") or {}
return {
"requested": state.get("requested"),
"requested": (
capture_controlled.get("requested")
or controlled.get("requested")
or state.get("requested")
),
"pre_reassert_actual": capture_controlled.get("preReassertActual"),
"pre_initial_actual": controlled.get("pre_initial_actual"),
"post_initial_actual": controlled.get("post_initial_actual"),
"final_reassert_actual": capture_controlled.get(
"finalReassertActual"
),
"actual": {"x": geometry.get("scroll_x"), "y": geometry.get("scroll_y")},
"viewport": {
"width": geometry.get("inner_width"),
@@ -298,6 +380,12 @@ def parse_obscura_scroll_report(stdout):
"width": geometry.get("document_scroll_width"),
"height": geometry.get("document_scroll_height"),
},
"reassert_behavior": capture_controlled.get("behavior"),
"pre_reassert_phase": capture_controlled.get("phase"),
"final_phase": (
capture_controlled.get("phase")
or controlled.get("final_phase")
),
"sampled_phase": state["sampled_phase"],
}
@@ -323,6 +411,14 @@ def obscura_environment(width, height):
return env
def with_controlled_scroll_environment(env, scroll):
"""Copy capture-only final scroll coordinates into the CLI environment."""
if scroll is not None:
env["OBSCURA_SHOT_SCROLL_X"] = str(scroll[0])
env["OBSCURA_SHOT_SCROLL_Y"] = str(scroll[1])
return env
def probe_obscura_identity(binary):
expression = (
"JSON.stringify({userAgent:navigator.userAgent,"
@@ -445,7 +541,9 @@ def capture_obscura(
scroll=None,
geometry_selectors=None,
):
env = obscura_environment(width, height)
env = with_controlled_scroll_environment(
obscura_environment(width, height), scroll
)
command = [
binary,
"fetch",
@@ -1003,7 +1101,10 @@ def main():
"settle_semantics": (
"full wall-clock interval while pumping each engine; when a "
"controlled scroll is requested, the same interval runs once "
"after load and once after scrolling"
"after load and once after the initial scroll. Both engines record "
"the resulting anchored offset and then reassert the requested "
"offset with instant behavior immediately before capture state "
"and paint"
),
"animation_sampling": (
{
@@ -1047,9 +1148,11 @@ def main():
"state_observability": {
"chromium": "same page, sampled immediately before screenshot",
"obscura": (
"same live page sampled immediately before screenshot; the CLI "
"captureState records the exact shared PreparedRender viewport, "
"scroll offset, and content size used by paint"
"DOM/resource probes are sampled by the eval that begins the "
"post-eval settle; the CLI captureState is sampled afterward, "
"following the final instant scroll reassert, and records the "
"exact shared PreparedRender viewport, scroll offset, and "
"content size used by paint"
),
},
"methodology_limits": {
@@ -1058,10 +1161,13 @@ def main():
"fidelity verdict"
),
"controlled_scroll": (
"CSSOM and screenshot paint share one resource-aware "
"PreparedRender. Content-size and scroll deltas are exact for "
"each captured engine, but different DOM/resource states can "
"still make semantic bottom landmarks differ."
"The settled pre-reassert offset is diagnostic evidence, not "
"the comparison coordinate: authored smooth scrolling and "
"scroll anchoring can legitimately move it while layout above "
"the viewport changes. The requested offset is reasserted "
"instantly for final state and paint. CSSOM and screenshot "
"paint share one resource-aware PreparedRender, but different "
"content sizes can still clamp the same request differently."
),
"page_state": (
"DOM/text fingerprints and length deltas expose different live "
@@ -1205,6 +1311,7 @@ def main():
)
chrome_started = time.time()
chromium_scroll_state = None
try:
page.goto(url, wait_until="load", timeout=50000)
page.wait_for_timeout(args.settle_ms)
@@ -1225,11 +1332,21 @@ def main():
animation_sampling = freeze_chromium_animations(
page, args.animation_time_ms
)
if controlled_scroll is not None:
chromium_scroll_state = (
reassert_chromium_controlled_scroll(
page, controlled_scroll
)
)
chromium_state_ok = False
try:
chromium_state = capture_chromium_state(
page, args.geometry_selector
)
if chromium_scroll_state is not None:
chromium_state["controlled_scroll"] = (
chromium_scroll_state
)
if animation_sampling is not None:
chromium_state["animation_sampling"] = (
animation_sampling
@@ -1265,6 +1382,9 @@ def main():
"elapsed_s": round(time.time() - chrome_started, 3),
"title": page.title() if chrome_ok else None,
"state": chromium_state if chrome_ok else None,
"scroll_state": (
chromium_scroll_state if chrome_ok else None
),
}
context.close()
page_result["obscura"] = ours_future.result()
@@ -1322,6 +1442,12 @@ def main():
)
page_result["controlled_scroll_comparison"] = {
"comparable": comparable,
"obscura_pre_reassert_actual": ours_scroll.get(
"pre_reassert_actual"
),
"chromium_pre_reassert_actual": (
chromium_scroll_state or {}
).get("pre_reassert_actual"),
"obscura_actual": ours_actual,
"chromium_actual": {
"x": chrome_geometry.get("scroll_x"),
+58 -1
View File
@@ -40,21 +40,78 @@ class ControlledScrollTests(unittest.TestCase):
"requestedY=document.documentElement.scrollHeight", expression
)
self.assertIn("window.scrollTo(requestedX,requestedY)", expression)
self.assertIn("preInitialActual", expression)
self.assertIn("postInitialActual", expression)
self.assertNotIn("behavior:'instant'", expression)
def test_chromium_reassert_records_settled_and_final_offsets(self):
class FakePage:
def __init__(self):
self.calls = []
def evaluate(self, expression, argument):
self.calls.append((expression, argument))
return {
"requested": {"x": 4, "y": 300},
"pre_reassert_actual": {"x": 4, "y": 287},
"final_actual": {"x": 4, "y": 300},
"reassert_behavior": "instant",
}
page = FakePage()
result = paired_corpus.reassert_chromium_controlled_scroll(
page, (4, 300)
)
self.assertEqual(result["pre_reassert_actual"]["y"], 287)
self.assertEqual(result["final_actual"]["y"], 300)
expression, argument = page.calls[0]
self.assertEqual(argument, [4, 300])
self.assertIn('behavior: "instant"', expression)
self.assertLess(
expression.index("beforeReassert"),
expression.index("window.scrollTo({"),
)
def test_capture_report_uses_post_settle_state(self):
stdout = (
"diagnostic\n"
'{"evaluation":"{\\"requested\\":{\\"x\\":0,\\"y\\":2702}}",'
'{"evaluation":"{\\"requested\\":{\\"x\\":0,\\"y\\":2702},'
'\\"controlled_scroll\\":{\\"requested\\":{\\"x\\":0,\\"y\\":2702},'
'\\"pre_initial_actual\\":{\\"x\\":0,\\"y\\":0},'
'\\"post_initial_actual\\":{\\"x\\":0,\\"y\\":12},'
'\\"initial_behavior\\":\\"authored\\"}}",'
'"controlledScroll":{"requested":{"x":0,"y":2702},'
'"preReassertActual":{"x":0,"y":1791},'
'"finalReassertActual":{"x":0,"y":1802},'
'"behavior":"instant",'
'"phase":"immediately-before-capture-state-and-screenshot"},'
'"captureState":{"scrollX":0,"scrollY":1802,'
'"innerWidth":1280,"innerHeight":900,'
'"scrollWidth":1291,"scrollHeight":2702}}\n'
)
state = paired_corpus.parse_obscura_scroll_report(stdout)
self.assertEqual(state["requested"], {"x": 0, "y": 2702})
self.assertEqual(
state["pre_reassert_actual"], {"x": 0, "y": 1791}
)
self.assertEqual(state["pre_initial_actual"], {"x": 0, "y": 0})
self.assertEqual(state["post_initial_actual"], {"x": 0, "y": 12})
self.assertEqual(
state["final_reassert_actual"], {"x": 0, "y": 1802}
)
self.assertEqual(state["actual"], {"x": 0, "y": 1802})
self.assertEqual(state["reassert_behavior"], "instant")
self.assertEqual(state["content"]["height"], 2702)
self.assertEqual(state["sampled_phase"], "immediately-before-screenshot")
def test_obscura_capture_exports_final_scroll_request_to_cli(self):
environment = paired_corpus.obscura_environment(1280, 720)
paired_corpus.with_controlled_scroll_environment(
environment, (12, "bottom")
)
self.assertEqual(environment["OBSCURA_SHOT_SCROLL_X"], "12")
self.assertEqual(environment["OBSCURA_SHOT_SCROLL_Y"], "bottom")
def test_every_capture_expression_samples_page_state_without_scrolling(self):
expression = paired_corpus.obscura_state_eval_expression(None)
self.assertIn("outer_html_fnv1a32", expression)