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 { if let Some(ref path) = screenshot {
#[cfg(feature = "render")] #[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(|_| { let capture_state = deferred_eval_output.as_ref().map(|_| {
page.evaluate( page.evaluate(
"(()=>({\ "(()=>({\
@@ -755,6 +800,7 @@ async fn run_fetch(
"{}", "{}",
serde_json::json!({ serde_json::json!({
"evaluation": result, "evaluation": result,
"controlledScroll": controlled_scroll,
"captureState": capture_state.unwrap_or(serde_json::Value::Null), "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"); 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) 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): def obscura_state_eval_expression(scroll, geometry_selectors=None):
"""Sample the live page after an optional scroll and immediately before paint.""" """Sample the live page after an optional scroll and immediately before paint."""
scroll_script = "" scroll_script = ""
requested = "null" requested = "null"
controlled_scroll_result = "null"
if scroll is not None: if scroll is not None:
scroll_x, scroll_y = scroll scroll_x, scroll_y = scroll
requested_y = ( requested_y = (
@@ -174,9 +230,19 @@ def obscura_state_eval_expression(scroll, geometry_selectors=None):
) )
scroll_script = ( scroll_script = (
f"const requestedX={scroll_x},requestedY={requested_y};" f"const requestedX={scroll_x},requestedY={requested_y};"
"const preInitialActual={x:window.scrollX,y:window.scrollY};"
"window.scrollTo(requestedX,requestedY);" "window.scrollTo(requestedX,requestedY);"
"const postInitialActual={x:window.scrollX,y:window.scrollY};"
) )
requested = "{x:requestedX,y:requestedY}" 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_setup = ""
geometry_result = "" geometry_result = ""
if geometry_selectors: if geometry_selectors:
@@ -203,9 +269,10 @@ def obscura_state_eval_expression(scroll, geometry_selectors=None):
"return ('00000000'+(h>>>0).toString(16)).slice(-8)};" "return ('00000000'+(h>>>0).toString(16)).slice(-8)};"
+ geometry_setup + geometry_setup
+ "return JSON.stringify({" + "return JSON.stringify({"
"sampled_phase:'immediately-before-screenshot'," "sampled_phase:'before-cli-post-eval-settle',"
+ geometry_result + geometry_result
+ f"requested:{requested}," + f"requested:{requested},"
+ f"controlled_scroll:{controlled_scroll_result},"
"url:location.href," "url:location.href,"
"document:{ready_state:document.readyState," "document:{ready_state:document.readyState,"
"element_count:document.getElementsByTagName('*').length," "element_count:document.getElementsByTagName('*').length,"
@@ -277,6 +344,9 @@ def parse_obscura_capture_report(stdout):
} }
) )
state["geometry"] = geometry 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" state["sampled_phase"] = "immediately-before-screenshot"
return state return state
return None return None
@@ -287,8 +357,20 @@ def parse_obscura_scroll_report(stdout):
if state is None: if state is None:
return None return None
geometry = state.get("geometry") or {} geometry = state.get("geometry") or {}
controlled = state.get("controlled_scroll") or {}
capture_controlled = state.get("controlled_scroll_capture") or {}
return { 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")}, "actual": {"x": geometry.get("scroll_x"), "y": geometry.get("scroll_y")},
"viewport": { "viewport": {
"width": geometry.get("inner_width"), "width": geometry.get("inner_width"),
@@ -298,6 +380,12 @@ def parse_obscura_scroll_report(stdout):
"width": geometry.get("document_scroll_width"), "width": geometry.get("document_scroll_width"),
"height": geometry.get("document_scroll_height"), "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"], "sampled_phase": state["sampled_phase"],
} }
@@ -323,6 +411,14 @@ def obscura_environment(width, height):
return env 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): def probe_obscura_identity(binary):
expression = ( expression = (
"JSON.stringify({userAgent:navigator.userAgent," "JSON.stringify({userAgent:navigator.userAgent,"
@@ -445,7 +541,9 @@ def capture_obscura(
scroll=None, scroll=None,
geometry_selectors=None, geometry_selectors=None,
): ):
env = obscura_environment(width, height) env = with_controlled_scroll_environment(
obscura_environment(width, height), scroll
)
command = [ command = [
binary, binary,
"fetch", "fetch",
@@ -1003,7 +1101,10 @@ def main():
"settle_semantics": ( "settle_semantics": (
"full wall-clock interval while pumping each engine; when a " "full wall-clock interval while pumping each engine; when a "
"controlled scroll is requested, the same interval runs once " "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": ( "animation_sampling": (
{ {
@@ -1047,9 +1148,11 @@ def main():
"state_observability": { "state_observability": {
"chromium": "same page, sampled immediately before screenshot", "chromium": "same page, sampled immediately before screenshot",
"obscura": ( "obscura": (
"same live page sampled immediately before screenshot; the CLI " "DOM/resource probes are sampled by the eval that begins the "
"captureState records the exact shared PreparedRender viewport, " "post-eval settle; the CLI captureState is sampled afterward, "
"scroll offset, and content size used by paint" "following the final instant scroll reassert, and records the "
"exact shared PreparedRender viewport, scroll offset, and "
"content size used by paint"
), ),
}, },
"methodology_limits": { "methodology_limits": {
@@ -1058,10 +1161,13 @@ def main():
"fidelity verdict" "fidelity verdict"
), ),
"controlled_scroll": ( "controlled_scroll": (
"CSSOM and screenshot paint share one resource-aware " "The settled pre-reassert offset is diagnostic evidence, not "
"PreparedRender. Content-size and scroll deltas are exact for " "the comparison coordinate: authored smooth scrolling and "
"each captured engine, but different DOM/resource states can " "scroll anchoring can legitimately move it while layout above "
"still make semantic bottom landmarks differ." "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": ( "page_state": (
"DOM/text fingerprints and length deltas expose different live " "DOM/text fingerprints and length deltas expose different live "
@@ -1205,6 +1311,7 @@ def main():
) )
chrome_started = time.time() chrome_started = time.time()
chromium_scroll_state = None
try: try:
page.goto(url, wait_until="load", timeout=50000) page.goto(url, wait_until="load", timeout=50000)
page.wait_for_timeout(args.settle_ms) page.wait_for_timeout(args.settle_ms)
@@ -1225,11 +1332,21 @@ def main():
animation_sampling = freeze_chromium_animations( animation_sampling = freeze_chromium_animations(
page, args.animation_time_ms 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 chromium_state_ok = False
try: try:
chromium_state = capture_chromium_state( chromium_state = capture_chromium_state(
page, args.geometry_selector page, args.geometry_selector
) )
if chromium_scroll_state is not None:
chromium_state["controlled_scroll"] = (
chromium_scroll_state
)
if animation_sampling is not None: if animation_sampling is not None:
chromium_state["animation_sampling"] = ( chromium_state["animation_sampling"] = (
animation_sampling animation_sampling
@@ -1265,6 +1382,9 @@ def main():
"elapsed_s": round(time.time() - chrome_started, 3), "elapsed_s": round(time.time() - chrome_started, 3),
"title": page.title() if chrome_ok else None, "title": page.title() if chrome_ok else None,
"state": chromium_state 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() context.close()
page_result["obscura"] = ours_future.result() page_result["obscura"] = ours_future.result()
@@ -1322,6 +1442,12 @@ def main():
) )
page_result["controlled_scroll_comparison"] = { page_result["controlled_scroll_comparison"] = {
"comparable": comparable, "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, "obscura_actual": ours_actual,
"chromium_actual": { "chromium_actual": {
"x": chrome_geometry.get("scroll_x"), "x": chrome_geometry.get("scroll_x"),
+58 -1
View File
@@ -40,21 +40,78 @@ class ControlledScrollTests(unittest.TestCase):
"requestedY=document.documentElement.scrollHeight", expression "requestedY=document.documentElement.scrollHeight", expression
) )
self.assertIn("window.scrollTo(requestedX,requestedY)", 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): def test_capture_report_uses_post_settle_state(self):
stdout = ( stdout = (
"diagnostic\n" "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,' '"captureState":{"scrollX":0,"scrollY":1802,'
'"innerWidth":1280,"innerHeight":900,' '"innerWidth":1280,"innerHeight":900,'
'"scrollWidth":1291,"scrollHeight":2702}}\n' '"scrollWidth":1291,"scrollHeight":2702}}\n'
) )
state = paired_corpus.parse_obscura_scroll_report(stdout) state = paired_corpus.parse_obscura_scroll_report(stdout)
self.assertEqual(state["requested"], {"x": 0, "y": 2702}) 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["actual"], {"x": 0, "y": 1802})
self.assertEqual(state["reassert_behavior"], "instant")
self.assertEqual(state["content"]["height"], 2702) self.assertEqual(state["content"]["height"], 2702)
self.assertEqual(state["sampled_phase"], "immediately-before-screenshot") 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): def test_every_capture_expression_samples_page_state_without_scrolling(self):
expression = paired_corpus.obscura_state_eval_expression(None) expression = paired_corpus.obscura_state_eval_expression(None)
self.assertIn("outer_html_fnv1a32", expression) self.assertIn("outer_html_fnv1a32", expression)