Bracket paired screenshots at capture state
This commit is contained in:
1 parent
c69fafae21
commit
42d3c1191b
4 files changed
+479
-80
No files matched your search
@@ -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,6 +712,29 @@ 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;
|
||||||
|
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 {
|
if let Some(ref expr) = eval {
|
||||||
// Bound the eval by the same budget as navigation so a runaway
|
// Bound the eval by the same budget as navigation so a runaway
|
||||||
// expression (infinite loop, never-settling sync work) cannot hang.
|
// expression (infinite loop, never-settling sync work) cannot hang.
|
||||||
@@ -714,6 +766,7 @@ async fn run_fetch(
|
|||||||
// of returning the still-pending eval value (issue #248).
|
// 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 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;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
+203
-50
@@ -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,8 +1477,18 @@ 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"] = (
|
||||||
@@ -1362,8 +1509,11 @@ 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}")
|
||||||
|
if not chrome_path.is_file():
|
||||||
page.screenshot(
|
page.screenshot(
|
||||||
path=str(chrome_path), full_page=False, timeout=50000
|
path=str(chrome_path),
|
||||||
|
full_page=False,
|
||||||
|
timeout=50000,
|
||||||
)
|
)
|
||||||
chrome_ok = (
|
chrome_ok = (
|
||||||
chromium_state_ok
|
chromium_state_ok
|
||||||
@@ -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
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in new issue
Block a user