Bracket paired screenshots at capture state

This commit is contained in:
SGavrl committed 2026-07-31 08:59:53 +00:00
1 parent c69fafae21
commit 42d3c1191b
4 files changed
+510 -111

No files matched your search

+116 -52
View File
@@ -636,6 +636,33 @@ async fn run_fetch(
eprintln!("Fetching {}...", url_str); eprintln!("Fetching {}...", url_str);
} }
// The paired corpus opts into a truthful capture boundary: its read-only
// evaluation runs after all settle passes and the final scroll reassert,
// immediately before screenshot paint. Ordinary CLI evaluation retains
// its existing evaluate-then-settle behavior when this private variable is
// absent.
let eval_at_capture_boundary = screenshot.is_some()
&& eval.is_some()
&& std::env::var("OBSCURA_SHOT_EVAL_AT_CAPTURE")
.is_ok_and(|value| value == "1");
let controlled_scroll_request = screenshot.as_ref().and_then(|_| {
let raw_y = std::env::var("OBSCURA_SHOT_SCROLL_Y").ok()?;
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((x, requested_y))
});
// Process-level hard deadline. A synchronous hang inside a Rust op invoked // Process-level hard deadline. A synchronous hang inside a Rust op invoked
// from page JS cannot be cancelled by tokio (there is no await to interrupt) // from page JS cannot be cancelled by tokio (there is no await to interrupt)
// nor by the V8 watchdog (terminate_execution only unwinds JS bytecode, not // nor by the V8 watchdog (terminate_execution only unwinds JS bytecode, not
@@ -644,7 +671,9 @@ async fn run_fetch(
// operation overruns navigation + every configured settle pass + grace. A // operation overruns navigation + every configured settle pass + grace. A
// normal fetch returns first and the process exits before this fires. // normal fetch returns first and the process exits before this fires.
{ {
let settle_passes = if eval.is_some() let settle_passes = if eval_at_capture_boundary {
1 + u64::from(controlled_scroll_request.is_some())
} else if eval.is_some()
&& (screenshot.is_some() || selector.is_some() || dump_specified) && (screenshot.is_some() || selector.is_some() || dump_specified)
{ {
2 2
@@ -683,38 +712,62 @@ async fn run_fetch(
page.settle(wait_secs.saturating_mul(1000)).await; page.settle(wait_secs.saturating_mul(1000)).await;
let mut deferred_eval_output = None; let mut deferred_eval_output = None;
if let Some(ref expr) = eval { let initial_controlled_scroll = if eval_at_capture_boundary {
// Bound the eval by the same budget as navigation so a runaway controlled_scroll_request.as_ref().map(|(x, requested_y)| {
// expression (infinite loop, never-settling sync work) cannot hang. page.evaluate(&format!(
let result = page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs)); "(()=>{{\
const requestedX={x},requestedY={requested_y};\
// A bare --eval (no --selector, --dump, or --screenshot) returns the const preInitial={{x:window.scrollX,y:window.scrollY}};\
// eval value directly, so synchronous expressions window.scrollTo(requestedX,requestedY);\
// (JSON.stringify, ...) are unchanged. Screenshot captures continue return {{requested:{{x:requestedX,y:requestedY}},\
// below so an evaluation such as scrollTo() affects the painted preInitialActual:preInitial,\
// viewport instead of being silently ignored. postInitialActual:{{x:window.scrollX,y:window.scrollY}},\
if !dump_specified && selector.is_none() && screenshot.is_none() { initialBehavior:'authored',\
let rendered = match result { initialPhase:'before-controlled-scroll-settle'}}\
serde_json::Value::String(s) => s, }})()"
serde_json::Value::Null => "null".to_string(), ))
other => other.to_string(), })
}; } else {
write_or_print(rendered, output.as_ref()).await?; None
context.save_cookies(); };
return Ok(()); if initial_controlled_scroll.is_some() {
}
if screenshot.is_some() {
deferred_eval_output = Some(result);
}
// --eval combined with --selector, --dump, and/or --screenshot
// typically kicks off async work (a fetch promise, a timer, a scroll
// listener) that writes the DOM. Drive the event loop again so that
// work completes, then fall through to selector/capture/dump instead
// of returning the still-pending eval value (issue #248).
page.settle(wait_secs.saturating_mul(1000)).await; page.settle(wait_secs.saturating_mul(1000)).await;
} }
if !eval_at_capture_boundary {
if let Some(ref expr) = eval {
// Bound the eval by the same budget as navigation so a runaway
// expression (infinite loop, never-settling sync work) cannot hang.
let result = page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs));
// A bare --eval (no --selector, --dump, or --screenshot) returns the
// eval value directly, so synchronous expressions
// (JSON.stringify, ...) are unchanged. Screenshot captures continue
// below so an evaluation such as scrollTo() affects the painted
// viewport instead of being silently ignored.
if !dump_specified && selector.is_none() && screenshot.is_none() {
let rendered = match result {
serde_json::Value::String(s) => s,
serde_json::Value::Null => "null".to_string(),
other => other.to_string(),
};
write_or_print(rendered, output.as_ref()).await?;
context.save_cookies();
return Ok(());
}
if screenshot.is_some() {
deferred_eval_output = Some(result);
}
// --eval combined with --selector, --dump, and/or --screenshot
// typically kicks off async work (a fetch promise, a timer, a scroll
// listener) that writes the DOM. Drive the event loop again so that
// work completes, then fall through to selector/capture/dump instead
// of returning the still-pending eval value (issue #248).
page.settle(wait_secs.saturating_mul(1000)).await;
}
}
if let Some(ref sel) = selector { if let Some(ref sel) = selector {
let found = wait_for_selector(&mut page, sel, wait_secs).await; let found = wait_for_selector(&mut page, sel, wait_secs).await;
if !found { if !found {
@@ -735,26 +788,10 @@ async fn run_fetch(
// into one instant reassertion at the actual capture boundary. // into one instant reassertion at the actual capture boundary.
// Ordinary CLI screenshots are unchanged when these private // Ordinary CLI screenshots are unchanged when these private
// capture-environment variables are absent. // capture-environment variables are absent.
let controlled_scroll = std::env::var("OBSCURA_SHOT_SCROLL_Y") let controlled_scroll = controlled_scroll_request
.ok() .as_ref()
.and_then(|raw_y| { .map(|(x, requested_y)| {
let x = match std::env::var("OBSCURA_SHOT_SCROLL_X") { page.evaluate(&format!(
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 requestedX={x},requestedY={requested_y};\
const preReassert={{x:window.scrollX,y:window.scrollY}};\ const preReassert={{x:window.scrollX,y:window.scrollY}};\
@@ -771,8 +808,15 @@ async fn run_fetch(
behavior:'instant',\ behavior:'instant',\
phase:'immediately-before-capture-state-and-screenshot'}}\ phase:'immediately-before-capture-state-and-screenshot'}}\
}})()" }})()"
))) ))
}); });
if eval_at_capture_boundary {
if let Some(ref expr) = eval {
deferred_eval_output = Some(
page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs))
);
}
}
let capture_state = deferred_eval_output.as_ref().map(|_| { let capture_state = deferred_eval_output.as_ref().map(|_| {
page.evaluate( page.evaluate(
"(()=>({\ "(()=>({\
@@ -796,11 +840,31 @@ async fn run_fetch(
// after the post-eval settle so automation can record the exact // after the post-eval settle so automation can record the exact
// live viewport that was painted. // live viewport that was painted.
if let Some(result) = deferred_eval_output { if let Some(result) = deferred_eval_output {
let mut controlled_scroll_report = controlled_scroll;
if let (Some(report), Some(initial)) = (
controlled_scroll_report.as_mut(),
initial_controlled_scroll.as_ref(),
) {
if let (Some(report), Some(initial)) =
(report.as_object_mut(), initial.as_object())
{
for key in [
"preInitialActual",
"postInitialActual",
"initialBehavior",
"initialPhase",
] {
if let Some(value) = initial.get(key) {
report.insert(key.to_string(), value.clone());
}
}
}
}
println!( println!(
"{}", "{}",
serde_json::json!({ serde_json::json!({
"evaluation": result, "evaluation": result,
"controlledScroll": controlled_scroll, "controlledScroll": controlled_scroll_report,
"captureState": capture_state.unwrap_or(serde_json::Value::Null), "captureState": capture_state.unwrap_or(serde_json::Value::Null),
}) })
); );
@@ -126,3 +126,68 @@ fn paired_capture_reasserts_scroll_after_the_post_eval_settle() {
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_evaluates_state_after_final_scroll_reassert() {
let directory = output_directory();
std::fs::create_dir_all(&directory).expect("temporary output directory");
let screenshot = directory.join("capture-boundary.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",
"JSON.stringify({phase:'capture-boundary-before-screenshot',y:scrollY})",
"--wait",
"0",
"--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")
.env("OBSCURA_SHOT_EVAL_AT_CAPTURE", "1")
.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");
let evaluation: serde_json::Value = serde_json::from_str(
report["evaluation"]
.as_str()
.expect("stringified boundary state"),
)
.expect("boundary state JSON");
assert_eq!(
evaluation["phase"].as_str(),
Some("capture-boundary-before-screenshot")
);
assert_eq!(
evaluation["y"].as_f64(),
report["captureState"]["scrollY"].as_f64(),
"state and screenshot captureState must observe the same final reassert"
);
assert_eq!(
report["controlledScroll"]["initialPhase"].as_str(),
Some("before-controlled-scroll-settle")
);
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");
}
+205 -52
View File
@@ -63,6 +63,7 @@ BRAND_PERMUTATIONS = [
[2, 1, 0], [2, 1, 0],
] ]
GEOMETRY_PROBE_RECT_LIMIT = 200 GEOMETRY_PROBE_RECT_LIMIT = 200
CAPTURE_BOUNDARY_PHASE = "capture-boundary-before-screenshot"
def geometry_probe_javascript(selectors_expression): def geometry_probe_javascript(selectors_expression):
@@ -216,8 +217,18 @@ def reassert_chromium_controlled_scroll(page, scroll):
) )
def obscura_state_eval_expression(scroll, geometry_selectors=None): def obscura_state_eval_expression(
"""Sample the live page after an optional scroll and immediately before paint.""" scroll,
geometry_selectors=None,
sampled_phase="before-cli-post-eval-settle",
):
"""Build a page-state sample with an explicit phase label.
`scroll` remains for compatibility with focused tests and ordinary callers.
Paired captures perform their authored scroll before the second settle in
the CLI, then invoke this expression without scrolling at the final capture
boundary.
"""
scroll_script = "" scroll_script = ""
requested = "null" requested = "null"
controlled_scroll_result = "null" controlled_scroll_result = "null"
@@ -269,7 +280,7 @@ 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:'before-cli-post-eval-settle'," f"sampled_phase:{json.dumps(sampled_phase)},"
+ geometry_result + geometry_result
+ f"requested:{requested}," + f"requested:{requested},"
+ f"controlled_scroll:{controlled_scroll_result}," + f"controlled_scroll:{controlled_scroll_result},"
@@ -347,7 +358,10 @@ def parse_obscura_capture_report(stdout):
if isinstance(report.get("controlledScroll"), dict): if isinstance(report.get("controlledScroll"), dict):
state["controlled_scroll_capture"] = report["controlledScroll"] state["controlled_scroll_capture"] = report["controlledScroll"]
state["evaluation_sampled_phase"] = state.get("sampled_phase") state["evaluation_sampled_phase"] = state.get("sampled_phase")
state["sampled_phase"] = "immediately-before-screenshot" state["screenshot_sampled_phase"] = CAPTURE_BOUNDARY_PHASE
state["state_and_screenshot_share_capture_boundary"] = (
state.get("sampled_phase") == CAPTURE_BOUNDARY_PHASE
)
return state return state
return None return None
@@ -366,8 +380,14 @@ def parse_obscura_scroll_report(stdout):
or state.get("requested") or state.get("requested")
), ),
"pre_reassert_actual": capture_controlled.get("preReassertActual"), "pre_reassert_actual": capture_controlled.get("preReassertActual"),
"pre_initial_actual": controlled.get("pre_initial_actual"), "pre_initial_actual": (
"post_initial_actual": controlled.get("post_initial_actual"), capture_controlled.get("preInitialActual")
or controlled.get("pre_initial_actual")
),
"post_initial_actual": (
capture_controlled.get("postInitialActual")
or controlled.get("post_initial_actual")
),
"final_reassert_actual": capture_controlled.get( "final_reassert_actual": capture_controlled.get(
"finalReassertActual" "finalReassertActual"
), ),
@@ -407,6 +427,10 @@ def obscura_environment(width, height):
# randomized platform changes responsive content and font selection, # randomized platform changes responsive content and font selection,
# making a renderer comparison answer the wrong question. # making a renderer comparison answer the wrong question.
OBSCURA_PROFILE=str(CANONICAL_OBSCURA_PROFILE), OBSCURA_PROFILE=str(CANONICAL_OBSCURA_PROFILE),
# Run the paired corpus's read-only state/selector evaluation only after
# all settle phases and the final scroll reassertion. No event-loop
# pumping occurs between that evaluation and screenshot paint.
OBSCURA_SHOT_EVAL_AT_CAPTURE="1",
) )
return env return env
@@ -557,7 +581,11 @@ def capture_obscura(
"--wait", "--wait",
f"{settle_ms / 1000:g}", f"{settle_ms / 1000:g}",
] ]
state_expression = obscura_state_eval_expression(scroll, geometry_selectors) state_expression = obscura_state_eval_expression(
None,
geometry_selectors,
sampled_phase=CAPTURE_BOUNDARY_PHASE,
)
command.extend(["--eval", state_expression]) command.extend(["--eval", state_expression])
started = time.time() started = time.time()
try: try:
@@ -650,15 +678,15 @@ def chromium_identity_override(session):
def capture_chromium_state(page, geometry_selectors=None): def capture_chromium_state(page, geometry_selectors=None):
"""Sample page provenance immediately before the screenshot.""" """Synchronously sample page provenance immediately before screenshot.
expression = """async () => {
async function sha256(value) { Keep the in-page evaluation free of promises and event-loop yields. Hashing
if (!globalThis.crypto || !crypto.subtle) return null; is intentionally performed on the host after the synchronous snapshot
const bytes = new TextEncoder().encode(value); returns; awaiting WebCrypto here would let page tasks mutate layout between
const digest = await crypto.subtle.digest("SHA-256", bytes); the geometry sample and screenshot paint while falsely labeling both as one
return Array.from(new Uint8Array(digest), byte => capture boundary.
byte.toString(16).padStart(2, "0")).join(""); """
} expression = """() => {
function fnv1a32(value) { function fnv1a32(value) {
let hash = 2166136261; let hash = 2166136261;
for (let index = 0; index < value.length; index++) { for (let index = 0; index < value.length; index++) {
@@ -695,7 +723,12 @@ def capture_chromium_state(page, geometry_selectors=None):
matchMedia("(prefers-reduced-motion: reduce)").matches matchMedia("(prefers-reduced-motion: reduce)").matches
}; };
return { return {
sampled_phase: "immediately-before-screenshot", _hash_sources: {
dom,
normalized_dom: normalizedDom,
visible_text: normalizedText
},
sampled_phase: __CAPTURE_BOUNDARY_PHASE__,
url: location.href, url: location.href,
identity: { identity: {
user_agent: navigator.userAgent, user_agent: navigator.userAgent,
@@ -715,16 +748,13 @@ def capture_chromium_state(page, geometry_selectors=None):
outer_html_utf16: dom.length, outer_html_utf16: dom.length,
outer_html_fnv1a32: fnv1a32(dom), outer_html_fnv1a32: fnv1a32(dom),
outer_html_bytes: new TextEncoder().encode(dom).length, outer_html_bytes: new TextEncoder().encode(dom).length,
outer_html_sha256: await sha256(dom),
normalized_outer_html_utf16: normalizedDom.length, normalized_outer_html_utf16: normalizedDom.length,
normalized_outer_html_fnv1a32: fnv1a32(normalizedDom), normalized_outer_html_fnv1a32: fnv1a32(normalizedDom),
normalized_outer_html_bytes: normalized_outer_html_bytes:
new TextEncoder().encode(normalizedDom).length, new TextEncoder().encode(normalizedDom).length,
normalized_outer_html_sha256: await sha256(normalizedDom),
visible_text_utf16: normalizedText.length, visible_text_utf16: normalizedText.length,
visible_text_fnv1a32: fnv1a32(normalizedText), visible_text_fnv1a32: fnv1a32(normalizedText),
visible_text_bytes: new TextEncoder().encode(normalizedText).length, visible_text_bytes: new TextEncoder().encode(normalizedText).length,
visible_text_sha256: await sha256(normalizedText),
}, },
geometry: { geometry: {
inner_width: innerWidth, inner_width: innerWidth,
@@ -776,9 +806,12 @@ def capture_chromium_state(page, geometry_selectors=None):
} }
}; };
}""" }"""
expression = expression.replace(
"__CAPTURE_BOUNDARY_PHASE__", json.dumps(CAPTURE_BOUNDARY_PHASE), 1
)
if geometry_selectors: if geometry_selectors:
expression = expression.replace( expression = expression.replace(
"async () => {", "async geometrySelectors => {", 1 "() => {", "geometrySelectors => {", 1
) )
expression = expression.replace( expression = expression.replace(
" const root = document.documentElement;", " const root = document.documentElement;",
@@ -787,41 +820,135 @@ def capture_chromium_state(page, geometry_selectors=None):
1, 1,
) )
expression = expression.replace( expression = expression.replace(
' return {\n sampled_phase: "immediately-before-screenshot",', " return {\n _hash_sources:",
' return {\n geometry_probes: geometryProbes,\n' ' return {\n geometry_probes: geometryProbes,\n'
' sampled_phase: "immediately-before-screenshot",', " _hash_sources:",
1, 1,
) )
state = page.evaluate(expression, list(geometry_selectors)) state = page.evaluate(expression, list(geometry_selectors))
else: else:
state = page.evaluate(expression) state = page.evaluate(expression)
return state
def finalize_chromium_state_hashes(state):
"""Finalize hashes after paint without touching the live Chromium page."""
document_state = state["document"] document_state = state["document"]
if ( hash_sources = state.pop("_hash_sources", None)
document_state["outer_html_sha256"] is None if hash_sources is not None:
or document_state["visible_text_sha256"] is None
):
# crypto.subtle is unavailable on some non-secure/file origins. Only
# transfer the potentially large strings on that fallback path.
fallback = page.evaluate(
"""() => {
const dom = document.documentElement
? document.documentElement.outerHTML
: "";
const text = document.body
? document.body.innerText.replace(/\\s+/g, " ").trim()
: "";
return {dom, text};
}"""
)
document_state["outer_html_sha256"] = hashlib.sha256( document_state["outer_html_sha256"] = hashlib.sha256(
fallback["dom"].encode() hash_sources["dom"].encode()
).hexdigest()
document_state["normalized_outer_html_sha256"] = hashlib.sha256(
hash_sources["normalized_dom"].encode()
).hexdigest() ).hexdigest()
document_state["visible_text_sha256"] = hashlib.sha256( document_state["visible_text_sha256"] = hashlib.sha256(
fallback["text"].encode() hash_sources["visible_text"].encode()
).hexdigest() ).hexdigest()
return state return state
def chromium_capture_signature(state):
"""Return the capture-critical state used to bracket a Chromium PNG.
Full DOM hashes remain useful provenance but are intentionally excluded:
analytics nodes can churn without affecting paint. The signature focuses
on viewport/content geometry, resource readiness, and every requested
geometry probe.
"""
document = state.get("document") or {}
geometry = state.get("geometry") or {}
fonts = state.get("fonts") or {}
images = state.get("images") or {}
probes = []
for probe in state.get("geometry_probes") or []:
probes.append(
{
"selector": probe.get("selector"),
"valid": probe.get("valid"),
"count": probe.get("count"),
"rects_truncated": probe.get("rects_truncated"),
"rects": [
{
key: rect.get(key)
for key in (
"x",
"y",
"width",
"height",
"visible",
"client_rect_count",
)
}
for rect in probe.get("rects") or []
],
}
)
return {
"document": {
key: document.get(key)
for key in ("ready_state", "element_count")
},
"geometry": {
key: geometry.get(key)
for key in (
"inner_width",
"inner_height",
"scroll_x",
"scroll_y",
"device_pixel_ratio",
"document_client_width",
"document_client_height",
"document_scroll_width",
"document_scroll_height",
"body_client_width",
"body_client_height",
"body_scroll_width",
"body_scroll_height",
"visual_viewport",
)
},
"fonts": {
key: fonts.get(key)
for key in ("supported", "status", "face_count", "ready_at_sample")
},
"images": {
key: images.get(key)
for key in (
"total",
"complete",
"complete_with_pixels",
"complete_without_pixels",
"pending",
"lazy",
)
},
"geometry_probes": probes,
}
def capture_chromium_image(page, screenshot, geometry_selectors=None):
"""Bracket one Chromium PNG with synchronous capture-critical samples."""
state = capture_chromium_state(page, geometry_selectors)
page.screenshot(
path=str(screenshot),
full_page=False,
timeout=50000,
)
post_capture_state = capture_chromium_state(page, geometry_selectors)
before_signature = chromium_capture_signature(state)
after_signature = chromium_capture_signature(post_capture_state)
post_capture_state.pop("_hash_sources", None)
boundary = {
"bracketed": True,
"stable": before_signature == after_signature,
"before": before_signature,
"after": after_signature,
}
finalize_chromium_state_hashes(state)
return state, boundary
def freeze_chromium_animations(page, sample_ms): def freeze_chromium_animations(page, sample_ms):
"""Pause currently exposed Web Animations at one explicit local time. """Pause currently exposed Web Animations at one explicit local time.
@@ -1104,7 +1231,8 @@ def main():
"after load and once after the initial scroll. Both engines record " "after load and once after the initial scroll. Both engines record "
"the resulting anchored offset and then reassert the requested " "the resulting anchored offset and then reassert the requested "
"offset with instant behavior immediately before capture state " "offset with instant behavior immediately before capture state "
"and paint" "and paint. Neither engine performs another settle between state/"
"selector sampling and screenshot paint"
), ),
"animation_sampling": ( "animation_sampling": (
{ {
@@ -1146,11 +1274,19 @@ def main():
), ),
}, },
"state_observability": { "state_observability": {
"chromium": "same page, sampled immediately before screenshot", "capture_boundary_phase": CAPTURE_BOUNDARY_PHASE,
"chromium": (
"same page, synchronously sampled after all settle phases and "
"immediately before screenshot. A second synchronous sample "
"after paint brackets the PNG and reports whether capture-"
"critical geometry/readiness remained stable. DOM SHA-256 "
"finalization runs on the host only after paint"
),
"obscura": ( "obscura": (
"DOM/resource probes are sampled by the eval that begins the " "the paired-capture CLI defers DOM/resource/selector evaluation "
"post-eval settle; the CLI captureState is sampled afterward, " "until after all settle phases and the final instant scroll "
"following the final instant scroll reassert, and records the " "reassert. Evaluation, captureState, and screenshot then run "
"consecutively without another settle; captureState records the "
"exact shared PreparedRender viewport, scroll offset, and " "exact shared PreparedRender viewport, scroll offset, and "
"content size used by paint" "content size used by paint"
), ),
@@ -1312,6 +1448,7 @@ def main():
chrome_started = time.time() chrome_started = time.time()
chromium_scroll_state = None chromium_scroll_state = None
chromium_capture_boundary = 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)
@@ -1340,9 +1477,19 @@ def main():
) )
chromium_state_ok = False chromium_state_ok = False
try: try:
chromium_state = capture_chromium_state( (
page, args.geometry_selector chromium_state,
chromium_capture_boundary,
) = capture_chromium_image(
page,
chrome_path,
args.geometry_selector,
) )
if not chromium_capture_boundary["stable"]:
chrome_messages.append(
"capture boundary warning: capture-critical "
"state changed while taking the Chromium PNG"
)
if chromium_scroll_state is not None: if chromium_scroll_state is not None:
chromium_state["controlled_scroll"] = ( chromium_state["controlled_scroll"] = (
chromium_scroll_state chromium_scroll_state
@@ -1362,9 +1509,12 @@ def main():
except Exception as error: except Exception as error:
chromium_state = None chromium_state = None
chrome_messages.append(f"state capture error: {error}") chrome_messages.append(f"state capture error: {error}")
page.screenshot( if not chrome_path.is_file():
path=str(chrome_path), full_page=False, timeout=50000 page.screenshot(
) path=str(chrome_path),
full_page=False,
timeout=50000,
)
chrome_ok = ( chrome_ok = (
chromium_state_ok chromium_state_ok
and chrome_path.is_file() and chrome_path.is_file()
@@ -1382,6 +1532,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,
"capture_boundary_validation": (
chromium_capture_boundary if chrome_ok else None
),
"scroll_state": ( "scroll_state": (
chromium_scroll_state if chrome_ok else None chromium_scroll_state if chrome_ok else None
), ),
+124 -7
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import hashlib
import importlib.util import importlib.util
import json import json
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).with_name("paired-corpus.py") SCRIPT = Path(__file__).with_name("paired-corpus.py")
@@ -75,12 +77,14 @@ class ControlledScrollTests(unittest.TestCase):
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":"{\\"sampled_phase\\":'
'\\"controlled_scroll\\":{\\"requested\\":{\\"x\\":0,\\"y\\":2702},' '\\"capture-boundary-before-screenshot\\",'
'\\"pre_initial_actual\\":{\\"x\\":0,\\"y\\":0},' '\\"requested\\":null,\\"controlled_scroll\\":null}",'
'\\"post_initial_actual\\":{\\"x\\":0,\\"y\\":12},'
'\\"initial_behavior\\":\\"authored\\"}}",'
'"controlledScroll":{"requested":{"x":0,"y":2702},' '"controlledScroll":{"requested":{"x":0,"y":2702},'
'"preInitialActual":{"x":0,"y":0},'
'"postInitialActual":{"x":0,"y":12},'
'"initialBehavior":"authored",'
'"initialPhase":"before-controlled-scroll-settle",'
'"preReassertActual":{"x":0,"y":1791},' '"preReassertActual":{"x":0,"y":1791},'
'"finalReassertActual":{"x":0,"y":1802},' '"finalReassertActual":{"x":0,"y":1802},'
'"behavior":"instant",' '"behavior":"instant",'
@@ -102,7 +106,9 @@ class ControlledScrollTests(unittest.TestCase):
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["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"], paired_corpus.CAPTURE_BOUNDARY_PHASE
)
def test_obscura_capture_exports_final_scroll_request_to_cli(self): def test_obscura_capture_exports_final_scroll_request_to_cli(self):
environment = paired_corpus.obscura_environment(1280, 720) environment = paired_corpus.obscura_environment(1280, 720)
@@ -111,6 +117,20 @@ class ControlledScrollTests(unittest.TestCase):
) )
self.assertEqual(environment["OBSCURA_SHOT_SCROLL_X"], "12") self.assertEqual(environment["OBSCURA_SHOT_SCROLL_X"], "12")
self.assertEqual(environment["OBSCURA_SHOT_SCROLL_Y"], "bottom") self.assertEqual(environment["OBSCURA_SHOT_SCROLL_Y"], "bottom")
self.assertEqual(environment["OBSCURA_SHOT_EVAL_AT_CAPTURE"], "1")
def test_paired_state_expression_is_read_only_at_capture_boundary(self):
expression = paired_corpus.obscura_state_eval_expression(
None,
[".card"],
sampled_phase=paired_corpus.CAPTURE_BOUNDARY_PHASE,
)
self.assertIn(
f'sampled_phase:"{paired_corpus.CAPTURE_BOUNDARY_PHASE}"',
expression,
)
self.assertIn("geometry_probes:geometryProbes", expression)
self.assertNotIn("window.scrollTo", expression)
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)
@@ -124,7 +144,9 @@ class ControlledScrollTests(unittest.TestCase):
def test_capture_report_keeps_dom_state_and_authoritative_geometry(self): def test_capture_report_keeps_dom_state_and_authoritative_geometry(self):
stdout = ( stdout = (
'{"evaluation":"{\\"document\\":{\\"ready_state\\":\\"complete\\",' '{"evaluation":"{\\"sampled_phase\\":'
'\\"capture-boundary-before-screenshot\\",'
'\\"document\\":{\\"ready_state\\":\\"complete\\",'
'\\"element_count\\":7,\\"outer_html_fnv1a32\\":\\"12345678\\"},' '\\"element_count\\":7,\\"outer_html_fnv1a32\\":\\"12345678\\"},'
'\\"geometry\\":{\\"document_scroll_height\\":999}}",' '\\"geometry\\":{\\"document_scroll_height\\":999}}",'
'"captureState":{"scrollX":3,"scrollY":40,' '"captureState":{"scrollX":3,"scrollY":40,'
@@ -135,6 +157,26 @@ class ControlledScrollTests(unittest.TestCase):
self.assertEqual(state["document"]["element_count"], 7) self.assertEqual(state["document"]["element_count"], 7)
self.assertEqual(state["geometry"]["scroll_y"], 40) self.assertEqual(state["geometry"]["scroll_y"], 40)
self.assertEqual(state["geometry"]["document_scroll_height"], 1200) self.assertEqual(state["geometry"]["document_scroll_height"], 1200)
self.assertTrue(state["state_and_screenshot_share_capture_boundary"])
def test_legacy_pre_settle_report_is_not_relabelled_as_capture_state(self):
stdout = (
'{"evaluation":"{\\"sampled_phase\\":'
'\\"before-cli-post-eval-settle\\",'
'\\"document\\":{},\\"geometry\\":{}}",'
'"captureState":{"scrollX":0,"scrollY":0,'
'"innerWidth":640,"innerHeight":480,'
'"scrollWidth":640,"scrollHeight":480}}\n'
)
state = paired_corpus.parse_obscura_capture_report(stdout)
self.assertEqual(
state["sampled_phase"], "before-cli-post-eval-settle"
)
self.assertEqual(
state["screenshot_sampled_phase"],
paired_corpus.CAPTURE_BOUNDARY_PHASE,
)
self.assertFalse(state["state_and_screenshot_share_capture_boundary"])
def test_page_state_comparison_reports_provenance_and_geometry_deltas(self): def test_page_state_comparison_reports_provenance_and_geometry_deltas(self):
obscura = { obscura = {
@@ -212,6 +254,77 @@ class GeometryProbeTests(unittest.TestCase):
self.assertEqual(args, ()) self.assertEqual(args, ())
self.assertNotIn("sampleGeometrySelector", expression) self.assertNotIn("sampleGeometrySelector", expression)
self.assertNotIn("geometry_probes", expression) self.assertNotIn("geometry_probes", expression)
self.assertNotIn("async ", expression)
self.assertNotIn("await ", expression)
self.assertNotIn("crypto.subtle", expression)
self.assertIn(
f'sampled_phase: "{paired_corpus.CAPTURE_BOUNDARY_PHASE}"',
expression,
)
def test_chromium_snapshot_paints_before_host_hashing(self):
events = []
first_state = {
"_hash_sources": {
"dom": "<html></html>",
"normalized_dom": "<html></html>",
"visible_text": "hello",
},
"document": {},
}
second_state = {
"_hash_sources": {
"dom": "<html></html>",
"normalized_dom": "<html></html>",
"visible_text": "hello",
},
"document": {},
}
class OrderedPage:
def __init__(self):
self.states = [first_state, second_state]
def evaluate(self, expression, *args):
events.append("evaluate")
return self.states.pop(0)
def screenshot(self, **kwargs):
events.append("screenshot")
real_sha256 = hashlib.sha256
def ordered_sha256(value):
events.append("sha256")
return real_sha256(value)
with mock.patch.object(
paired_corpus.hashlib,
"sha256",
side_effect=ordered_sha256,
):
captured, boundary = paired_corpus.capture_chromium_image(
OrderedPage(), Path("/tmp/not-written.png")
)
self.assertEqual(
events,
["evaluate", "screenshot", "evaluate", "sha256", "sha256", "sha256"],
)
self.assertTrue(boundary["stable"])
self.assertNotIn("_hash_sources", captured)
self.assertEqual(
captured["document"]["outer_html_sha256"],
hashlib.sha256(b"<html></html>").hexdigest(),
)
self.assertEqual(
captured["document"]["normalized_outer_html_sha256"],
hashlib.sha256(b"<html></html>").hexdigest(),
)
self.assertEqual(
captured["document"]["visible_text_sha256"],
hashlib.sha256(b"hello").hexdigest(),
)
def test_repeatable_selectors_are_passed_safely_in_one_state_expression(self): def test_repeatable_selectors_are_passed_safely_in_one_state_expression(self):
selectors = ["header nav a", '[data-label="a\\\"b"]', "["] selectors = ["header nav a", '[data-label="a\\\"b"]', "["]
@@ -232,6 +345,10 @@ class GeometryProbeTests(unittest.TestCase):
self.assertIn("querySelectorAll(selector)", expression) self.assertIn("querySelectorAll(selector)", expression)
self.assertIn("catch(error)", expression) self.assertIn("catch(error)", expression)
self.assertIn("geometry_probes: geometryProbes", expression) self.assertIn("geometry_probes: geometryProbes", expression)
self.assertIn(
f'sampled_phase: "{paired_corpus.CAPTURE_BOUNDARY_PHASE}"',
expression,
)
def test_probe_comparison_reports_raw_deltas_and_invalid_errors(self): def test_probe_comparison_reports_raw_deltas_and_invalid_errors(self):
obscura = { obscura = {