Stabilize controlled scroll screenshot comparisons
This commit is contained in:
1 parent
9bacacc60d
commit
1a0c7096fc
4 files changed
+296
-12
No files matched your search
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user