Bracket paired screenshots at capture state
This commit is contained in:
1 parent
c69fafae21
commit
42d3c1191b
4 files changed
+510
-111
No files matched your search
+116
-52
@@ -636,6 +636,33 @@ async fn run_fetch(
|
||||
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
|
||||
// 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
|
||||
@@ -644,7 +671,9 @@ async fn run_fetch(
|
||||
// operation overruns navigation + every configured settle pass + grace. A
|
||||
// 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)
|
||||
{
|
||||
2
|
||||
@@ -683,38 +712,62 @@ async fn run_fetch(
|
||||
page.settle(wait_secs.saturating_mul(1000)).await;
|
||||
|
||||
let mut deferred_eval_output = None;
|
||||
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).
|
||||
let initial_controlled_scroll = if eval_at_capture_boundary {
|
||||
controlled_scroll_request.as_ref().map(|(x, requested_y)| {
|
||||
page.evaluate(&format!(
|
||||
"(()=>{{\
|
||||
const requestedX={x},requestedY={requested_y};\
|
||||
const preInitial={{x:window.scrollX,y:window.scrollY}};\
|
||||
window.scrollTo(requestedX,requestedY);\
|
||||
return {{requested:{{x:requestedX,y:requestedY}},\
|
||||
preInitialActual:preInitial,\
|
||||
postInitialActual:{{x:window.scrollX,y:window.scrollY}},\
|
||||
initialBehavior:'authored',\
|
||||
initialPhase:'before-controlled-scroll-settle'}}\
|
||||
}})()"
|
||||
))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if initial_controlled_scroll.is_some() {
|
||||
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 {
|
||||
let found = wait_for_selector(&mut page, sel, wait_secs).await;
|
||||
if !found {
|
||||
@@ -735,26 +788,10 @@ async fn run_fetch(
|
||||
// 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!(
|
||||
let controlled_scroll = controlled_scroll_request
|
||||
.as_ref()
|
||||
.map(|(x, requested_y)| {
|
||||
page.evaluate(&format!(
|
||||
"(()=>{{\
|
||||
const requestedX={x},requestedY={requested_y};\
|
||||
const preReassert={{x:window.scrollX,y:window.scrollY}};\
|
||||
@@ -771,8 +808,15 @@ async fn run_fetch(
|
||||
behavior:'instant',\
|
||||
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(|_| {
|
||||
page.evaluate(
|
||||
"(()=>({\
|
||||
@@ -796,11 +840,31 @@ async fn run_fetch(
|
||||
// after the post-eval settle so automation can record the exact
|
||||
// live viewport that was painted.
|
||||
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!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"evaluation": result,
|
||||
"controlledScroll": controlled_scroll,
|
||||
"controlledScroll": controlled_scroll_report,
|
||||
"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");
|
||||
}
|
||||
|
||||
#[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
@@ -63,6 +63,7 @@ BRAND_PERMUTATIONS = [
|
||||
[2, 1, 0],
|
||||
]
|
||||
GEOMETRY_PROBE_RECT_LIMIT = 200
|
||||
CAPTURE_BOUNDARY_PHASE = "capture-boundary-before-screenshot"
|
||||
|
||||
|
||||
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):
|
||||
"""Sample the live page after an optional scroll and immediately before paint."""
|
||||
def obscura_state_eval_expression(
|
||||
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 = ""
|
||||
requested = "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)};"
|
||||
+ geometry_setup
|
||||
+ "return JSON.stringify({"
|
||||
"sampled_phase:'before-cli-post-eval-settle',"
|
||||
f"sampled_phase:{json.dumps(sampled_phase)},"
|
||||
+ geometry_result
|
||||
+ f"requested:{requested},"
|
||||
+ f"controlled_scroll:{controlled_scroll_result},"
|
||||
@@ -347,7 +358,10 @@ def parse_obscura_capture_report(stdout):
|
||||
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["screenshot_sampled_phase"] = CAPTURE_BOUNDARY_PHASE
|
||||
state["state_and_screenshot_share_capture_boundary"] = (
|
||||
state.get("sampled_phase") == CAPTURE_BOUNDARY_PHASE
|
||||
)
|
||||
return state
|
||||
return None
|
||||
|
||||
@@ -366,8 +380,14 @@ def parse_obscura_scroll_report(stdout):
|
||||
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"),
|
||||
"pre_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(
|
||||
"finalReassertActual"
|
||||
),
|
||||
@@ -407,6 +427,10 @@ def obscura_environment(width, height):
|
||||
# randomized platform changes responsive content and font selection,
|
||||
# making a renderer comparison answer the wrong question.
|
||||
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
|
||||
|
||||
@@ -557,7 +581,11 @@ def capture_obscura(
|
||||
"--wait",
|
||||
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])
|
||||
started = time.time()
|
||||
try:
|
||||
@@ -650,15 +678,15 @@ def chromium_identity_override(session):
|
||||
|
||||
|
||||
def capture_chromium_state(page, geometry_selectors=None):
|
||||
"""Sample page provenance immediately before the screenshot."""
|
||||
expression = """async () => {
|
||||
async function sha256(value) {
|
||||
if (!globalThis.crypto || !crypto.subtle) return null;
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return Array.from(new Uint8Array(digest), byte =>
|
||||
byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
"""Synchronously sample page provenance immediately before screenshot.
|
||||
|
||||
Keep the in-page evaluation free of promises and event-loop yields. Hashing
|
||||
is intentionally performed on the host after the synchronous snapshot
|
||||
returns; awaiting WebCrypto here would let page tasks mutate layout between
|
||||
the geometry sample and screenshot paint while falsely labeling both as one
|
||||
capture boundary.
|
||||
"""
|
||||
expression = """() => {
|
||||
function fnv1a32(value) {
|
||||
let hash = 2166136261;
|
||||
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
|
||||
};
|
||||
return {
|
||||
sampled_phase: "immediately-before-screenshot",
|
||||
_hash_sources: {
|
||||
dom,
|
||||
normalized_dom: normalizedDom,
|
||||
visible_text: normalizedText
|
||||
},
|
||||
sampled_phase: __CAPTURE_BOUNDARY_PHASE__,
|
||||
url: location.href,
|
||||
identity: {
|
||||
user_agent: navigator.userAgent,
|
||||
@@ -715,16 +748,13 @@ def capture_chromium_state(page, geometry_selectors=None):
|
||||
outer_html_utf16: dom.length,
|
||||
outer_html_fnv1a32: fnv1a32(dom),
|
||||
outer_html_bytes: new TextEncoder().encode(dom).length,
|
||||
outer_html_sha256: await sha256(dom),
|
||||
normalized_outer_html_utf16: normalizedDom.length,
|
||||
normalized_outer_html_fnv1a32: fnv1a32(normalizedDom),
|
||||
normalized_outer_html_bytes:
|
||||
new TextEncoder().encode(normalizedDom).length,
|
||||
normalized_outer_html_sha256: await sha256(normalizedDom),
|
||||
visible_text_utf16: normalizedText.length,
|
||||
visible_text_fnv1a32: fnv1a32(normalizedText),
|
||||
visible_text_bytes: new TextEncoder().encode(normalizedText).length,
|
||||
visible_text_sha256: await sha256(normalizedText),
|
||||
},
|
||||
geometry: {
|
||||
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:
|
||||
expression = expression.replace(
|
||||
"async () => {", "async geometrySelectors => {", 1
|
||||
"() => {", "geometrySelectors => {", 1
|
||||
)
|
||||
expression = expression.replace(
|
||||
" const root = document.documentElement;",
|
||||
@@ -787,41 +820,135 @@ def capture_chromium_state(page, geometry_selectors=None):
|
||||
1,
|
||||
)
|
||||
expression = expression.replace(
|
||||
' return {\n sampled_phase: "immediately-before-screenshot",',
|
||||
" return {\n _hash_sources:",
|
||||
' return {\n geometry_probes: geometryProbes,\n'
|
||||
' sampled_phase: "immediately-before-screenshot",',
|
||||
" _hash_sources:",
|
||||
1,
|
||||
)
|
||||
state = page.evaluate(expression, list(geometry_selectors))
|
||||
else:
|
||||
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"]
|
||||
if (
|
||||
document_state["outer_html_sha256"] is 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};
|
||||
}"""
|
||||
)
|
||||
hash_sources = state.pop("_hash_sources", None)
|
||||
if hash_sources is not None:
|
||||
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()
|
||||
document_state["visible_text_sha256"] = hashlib.sha256(
|
||||
fallback["text"].encode()
|
||||
hash_sources["visible_text"].encode()
|
||||
).hexdigest()
|
||||
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):
|
||||
"""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 "
|
||||
"the resulting anchored offset and then reassert the requested "
|
||||
"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": (
|
||||
{
|
||||
@@ -1146,11 +1274,19 @@ def main():
|
||||
),
|
||||
},
|
||||
"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": (
|
||||
"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 "
|
||||
"the paired-capture CLI defers DOM/resource/selector evaluation "
|
||||
"until after all settle phases and the final instant scroll "
|
||||
"reassert. Evaluation, captureState, and screenshot then run "
|
||||
"consecutively without another settle; captureState records the "
|
||||
"exact shared PreparedRender viewport, scroll offset, and "
|
||||
"content size used by paint"
|
||||
),
|
||||
@@ -1312,6 +1448,7 @@ def main():
|
||||
|
||||
chrome_started = time.time()
|
||||
chromium_scroll_state = None
|
||||
chromium_capture_boundary = None
|
||||
try:
|
||||
page.goto(url, wait_until="load", timeout=50000)
|
||||
page.wait_for_timeout(args.settle_ms)
|
||||
@@ -1340,9 +1477,19 @@ def main():
|
||||
)
|
||||
chromium_state_ok = False
|
||||
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:
|
||||
chromium_state["controlled_scroll"] = (
|
||||
chromium_scroll_state
|
||||
@@ -1362,9 +1509,12 @@ def main():
|
||||
except Exception as error:
|
||||
chromium_state = None
|
||||
chrome_messages.append(f"state capture error: {error}")
|
||||
page.screenshot(
|
||||
path=str(chrome_path), full_page=False, timeout=50000
|
||||
)
|
||||
if not chrome_path.is_file():
|
||||
page.screenshot(
|
||||
path=str(chrome_path),
|
||||
full_page=False,
|
||||
timeout=50000,
|
||||
)
|
||||
chrome_ok = (
|
||||
chromium_state_ok
|
||||
and chrome_path.is_file()
|
||||
@@ -1382,6 +1532,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,
|
||||
"capture_boundary_validation": (
|
||||
chromium_capture_boundary if chrome_ok else None
|
||||
),
|
||||
"scroll_state": (
|
||||
chromium_scroll_state if chrome_ok else None
|
||||
),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
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):
|
||||
stdout = (
|
||||
"diagnostic\n"
|
||||
'{"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\\"}}",'
|
||||
'{"evaluation":"{\\"sampled_phase\\":'
|
||||
'\\"capture-boundary-before-screenshot\\",'
|
||||
'\\"requested\\":null,\\"controlled_scroll\\":null}",'
|
||||
'"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},'
|
||||
'"finalReassertActual":{"x":0,"y":1802},'
|
||||
'"behavior":"instant",'
|
||||
@@ -102,7 +106,9 @@ class ControlledScrollTests(unittest.TestCase):
|
||||
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")
|
||||
self.assertEqual(
|
||||
state["sampled_phase"], paired_corpus.CAPTURE_BOUNDARY_PHASE
|
||||
)
|
||||
|
||||
def test_obscura_capture_exports_final_scroll_request_to_cli(self):
|
||||
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_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):
|
||||
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):
|
||||
stdout = (
|
||||
'{"evaluation":"{\\"document\\":{\\"ready_state\\":\\"complete\\",'
|
||||
'{"evaluation":"{\\"sampled_phase\\":'
|
||||
'\\"capture-boundary-before-screenshot\\",'
|
||||
'\\"document\\":{\\"ready_state\\":\\"complete\\",'
|
||||
'\\"element_count\\":7,\\"outer_html_fnv1a32\\":\\"12345678\\"},'
|
||||
'\\"geometry\\":{\\"document_scroll_height\\":999}}",'
|
||||
'"captureState":{"scrollX":3,"scrollY":40,'
|
||||
@@ -135,6 +157,26 @@ class ControlledScrollTests(unittest.TestCase):
|
||||
self.assertEqual(state["document"]["element_count"], 7)
|
||||
self.assertEqual(state["geometry"]["scroll_y"], 40)
|
||||
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):
|
||||
obscura = {
|
||||
@@ -212,6 +254,77 @@ class GeometryProbeTests(unittest.TestCase):
|
||||
self.assertEqual(args, ())
|
||||
self.assertNotIn("sampleGeometrySelector", 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):
|
||||
selectors = ["header nav a", '[data-label="a\\\"b"]', "["]
|
||||
@@ -232,6 +345,10 @@ class GeometryProbeTests(unittest.TestCase):
|
||||
self.assertIn("querySelectorAll(selector)", expression)
|
||||
self.assertIn("catch(error)", 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):
|
||||
obscura = {
|
||||
|
||||
Reference in new issue
Block a user