Warm renderer resources before parity sampling
This commit is contained in:
1 parent
4d459c2125
commit
65ed38a71d
4 files changed
+95
-11
No files matched your search
@@ -781,6 +781,25 @@ async fn run_fetch(
|
|||||||
if let Some(ref path) = screenshot {
|
if let Some(ref path) = screenshot {
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
{
|
{
|
||||||
|
// Default CSS-pixel viewport, matching the engine's innerWidth/Height.
|
||||||
|
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
|
||||||
|
// capture below-the-fold content in one shot).
|
||||||
|
let viewport = screenshot_viewport.unwrap_or((1280.0, 720.0));
|
||||||
|
// The parity harness performs one throwaway paint in both engines
|
||||||
|
// before observing image/font readiness. Obscura resolves retained
|
||||||
|
// render resources during prepare/paint, so sampling first would
|
||||||
|
// compare pre-paint Obscura state with post-load Chromium state.
|
||||||
|
// Keep this private opt-in out of ordinary CLI screenshots.
|
||||||
|
let warmup_capture = std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP")
|
||||||
|
.is_ok_and(|value| value == "1");
|
||||||
|
if warmup_capture {
|
||||||
|
if page.screenshot(viewport).is_none() {
|
||||||
|
anyhow::bail!("resource warm-up screenshot failed: page has no DOM to render");
|
||||||
|
}
|
||||||
|
// Give completion callbacks one bounded task turn before the
|
||||||
|
// capture-boundary evaluation reads resource state.
|
||||||
|
page.settle(1).await;
|
||||||
|
}
|
||||||
// Paired renderer captures need a stable final coordinate after
|
// Paired renderer captures need a stable final coordinate after
|
||||||
// the post-eval settle. Authored smooth scrolling and scroll
|
// the post-eval settle. Authored smooth scrolling and scroll
|
||||||
// anchoring may legitimately move an earlier scrollTo while the
|
// anchoring may legitimately move an earlier scrollTo while the
|
||||||
@@ -827,10 +846,6 @@ async fn run_fetch(
|
|||||||
}))()",
|
}))()",
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
// Default CSS-pixel viewport, matching the engine's innerWidth/Height.
|
|
||||||
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
|
|
||||||
// capture below-the-fold content in one shot).
|
|
||||||
let viewport = screenshot_viewport.unwrap_or((1280.0, 720.0));
|
|
||||||
match page.screenshot(viewport) {
|
match page.screenshot(viewport) {
|
||||||
Some(bytes) => std::fs::write(path, &bytes)?,
|
Some(bytes) => std::fs::write(path, &bytes)?,
|
||||||
None => anyhow::bail!("screenshot failed: page has no DOM to render"),
|
None => anyhow::bail!("screenshot failed: page has no DOM to render"),
|
||||||
@@ -865,6 +880,12 @@ async fn run_fetch(
|
|||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"evaluation": result,
|
"evaluation": result,
|
||||||
"controlledScroll": controlled_scroll_report,
|
"controlledScroll": controlled_scroll_report,
|
||||||
|
"resourceWarmup": {
|
||||||
|
"performed": warmup_capture,
|
||||||
|
"discardedShots": if warmup_capture { 1 } else { 0 },
|
||||||
|
"taskTurnMs": if warmup_capture { 1 } else { 0 },
|
||||||
|
"phase": "before-final-scroll-reassert-and-state-sample",
|
||||||
|
},
|
||||||
"captureState": capture_state.unwrap_or(serde_json::Value::Null),
|
"captureState": capture_state.unwrap_or(serde_json::Value::Null),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ fn paired_capture_evaluates_state_after_final_scroll_reassert() {
|
|||||||
.env("OBSCURA_SHOT_SCROLL_X", "0")
|
.env("OBSCURA_SHOT_SCROLL_X", "0")
|
||||||
.env("OBSCURA_SHOT_SCROLL_Y", "80")
|
.env("OBSCURA_SHOT_SCROLL_Y", "80")
|
||||||
.env("OBSCURA_SHOT_EVAL_AT_CAPTURE", "1")
|
.env("OBSCURA_SHOT_EVAL_AT_CAPTURE", "1")
|
||||||
|
.env("OBSCURA_SHOT_RESOURCE_WARMUP", "1")
|
||||||
.output()
|
.output()
|
||||||
.expect("run obscura fetch");
|
.expect("run obscura fetch");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -188,6 +189,15 @@ fn paired_capture_evaluates_state_after_final_scroll_reassert() {
|
|||||||
report["controlledScroll"]["phase"].as_str(),
|
report["controlledScroll"]["phase"].as_str(),
|
||||||
Some("immediately-before-capture-state-and-screenshot")
|
Some("immediately-before-capture-state-and-screenshot")
|
||||||
);
|
);
|
||||||
|
assert_eq!(report["resourceWarmup"]["performed"].as_bool(), Some(true));
|
||||||
|
assert_eq!(
|
||||||
|
report["resourceWarmup"]["discardedShots"].as_u64(),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
report["resourceWarmup"]["phase"].as_str(),
|
||||||
|
Some("before-final-scroll-reassert-and-state-sample")
|
||||||
|
);
|
||||||
|
|
||||||
std::fs::remove_dir_all(directory).expect("remove temporary output directory");
|
std::fs::remove_dir_all(directory).expect("remove temporary output directory");
|
||||||
}
|
}
|
||||||
@@ -64,6 +64,7 @@ BRAND_PERMUTATIONS = [
|
|||||||
]
|
]
|
||||||
GEOMETRY_PROBE_RECT_LIMIT = 200
|
GEOMETRY_PROBE_RECT_LIMIT = 200
|
||||||
CAPTURE_BOUNDARY_PHASE = "capture-boundary-before-screenshot"
|
CAPTURE_BOUNDARY_PHASE = "capture-boundary-before-screenshot"
|
||||||
|
RESOURCE_WARMUP_PHASE = "before-final-scroll-reassert-and-state-sample"
|
||||||
|
|
||||||
|
|
||||||
def geometry_probe_javascript(selectors_expression):
|
def geometry_probe_javascript(selectors_expression):
|
||||||
@@ -360,6 +361,8 @@ def parse_obscura_capture_report(stdout):
|
|||||||
state["geometry"] = geometry
|
state["geometry"] = geometry
|
||||||
if isinstance(report.get("controlledScroll"), dict):
|
if isinstance(report.get("controlledScroll"), dict):
|
||||||
state["controlled_scroll_capture"] = report["controlledScroll"]
|
state["controlled_scroll_capture"] = report["controlledScroll"]
|
||||||
|
if isinstance(report.get("resourceWarmup"), dict):
|
||||||
|
state["resource_warmup"] = report["resourceWarmup"]
|
||||||
state["evaluation_sampled_phase"] = state.get("sampled_phase")
|
state["evaluation_sampled_phase"] = state.get("sampled_phase")
|
||||||
state["screenshot_sampled_phase"] = CAPTURE_BOUNDARY_PHASE
|
state["screenshot_sampled_phase"] = CAPTURE_BOUNDARY_PHASE
|
||||||
state["state_and_screenshot_share_capture_boundary"] = (
|
state["state_and_screenshot_share_capture_boundary"] = (
|
||||||
@@ -434,6 +437,10 @@ def obscura_environment(width, height):
|
|||||||
# all settle phases and the final scroll reassertion. No event-loop
|
# all settle phases and the final scroll reassertion. No event-loop
|
||||||
# pumping occurs between that evaluation and screenshot paint.
|
# pumping occurs between that evaluation and screenshot paint.
|
||||||
OBSCURA_SHOT_EVAL_AT_CAPTURE="1",
|
OBSCURA_SHOT_EVAL_AT_CAPTURE="1",
|
||||||
|
# Resolve the exact retained image/font graph with one throwaway paint
|
||||||
|
# before resource-readiness state is sampled. Chromium performs the
|
||||||
|
# same warm-up shot in the paired process.
|
||||||
|
OBSCURA_SHOT_RESOURCE_WARMUP="1",
|
||||||
)
|
)
|
||||||
return env
|
return env
|
||||||
|
|
||||||
@@ -952,6 +959,19 @@ def capture_chromium_image(page, screenshot, geometry_selectors=None):
|
|||||||
return state, boundary
|
return state, boundary
|
||||||
|
|
||||||
|
|
||||||
|
def warm_chromium_capture(page):
|
||||||
|
"""Resolve paint-time resources before capture-boundary state sampling."""
|
||||||
|
page.screenshot(full_page=False, timeout=50000)
|
||||||
|
# Yield one bounded browser task turn, matching the Obscura CLI warm-up.
|
||||||
|
page.wait_for_timeout(1)
|
||||||
|
return {
|
||||||
|
"performed": True,
|
||||||
|
"discardedShots": 1,
|
||||||
|
"taskTurnMs": 1,
|
||||||
|
"phase": RESOURCE_WARMUP_PHASE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -1278,17 +1298,21 @@ def main():
|
|||||||
},
|
},
|
||||||
"state_observability": {
|
"state_observability": {
|
||||||
"capture_boundary_phase": CAPTURE_BOUNDARY_PHASE,
|
"capture_boundary_phase": CAPTURE_BOUNDARY_PHASE,
|
||||||
|
"resource_warmup_phase": RESOURCE_WARMUP_PHASE,
|
||||||
"chromium": (
|
"chromium": (
|
||||||
"same page, synchronously sampled after all settle phases and "
|
"same page, one discard paint resolves the retained resource "
|
||||||
|
"graph, then one bounded task turn and the final scroll "
|
||||||
|
"reassert run before synchronous state sampling. State is "
|
||||||
"immediately before screenshot. A second synchronous sample "
|
"immediately before screenshot. A second synchronous sample "
|
||||||
"after paint brackets the PNG and reports whether capture-"
|
"after paint brackets the PNG and reports whether capture-"
|
||||||
"critical geometry/readiness remained stable. DOM SHA-256 "
|
"critical geometry/readiness remained stable. DOM SHA-256 "
|
||||||
"finalization runs on the host only after paint"
|
"finalization runs on the host only after paint"
|
||||||
),
|
),
|
||||||
"obscura": (
|
"obscura": (
|
||||||
"the paired-capture CLI defers DOM/resource/selector evaluation "
|
"the paired-capture CLI takes one discard paint over the same "
|
||||||
"until after all settle phases and the final instant scroll "
|
"live page, pumps one bounded task turn, then performs the "
|
||||||
"reassert. Evaluation, captureState, and screenshot then run "
|
"final instant scroll reassert before deferred DOM/resource/"
|
||||||
|
"selector evaluation. Evaluation, captureState, and screenshot run "
|
||||||
"consecutively without another settle; captureState records the "
|
"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"
|
||||||
@@ -1318,9 +1342,12 @@ def main():
|
|||||||
"serialize fetched stylesheet text into outerHTML."
|
"serialize fetched stylesheet text into outerHTML."
|
||||||
),
|
),
|
||||||
"resource_readiness": (
|
"resource_readiness": (
|
||||||
"Obscura's DOM image/font readiness is sampled before paint; "
|
"Both engines take and discard one screenshot before sampling "
|
||||||
"paint may then fetch retained renderer resources that are not "
|
"resource readiness, then yield one bounded task turn. This "
|
||||||
"reflected back into HTMLImageElement or FontFaceSet state."
|
"warms the exact retained render graph instead of comparing "
|
||||||
|
"Obscura's pre-paint state with Chromium's post-load state. "
|
||||||
|
"A successful fetch is still not proof of equal decode, selected "
|
||||||
|
"source, or paint output."
|
||||||
),
|
),
|
||||||
"animation_sampling": (
|
"animation_sampling": (
|
||||||
"The deterministic option controls only animations still "
|
"The deterministic option controls only animations still "
|
||||||
@@ -1452,6 +1479,7 @@ def main():
|
|||||||
chrome_started = time.time()
|
chrome_started = time.time()
|
||||||
chromium_scroll_state = None
|
chromium_scroll_state = None
|
||||||
chromium_capture_boundary = None
|
chromium_capture_boundary = None
|
||||||
|
chromium_resource_warmup = 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)
|
||||||
@@ -1472,6 +1500,7 @@ def main():
|
|||||||
animation_sampling = freeze_chromium_animations(
|
animation_sampling = freeze_chromium_animations(
|
||||||
page, args.animation_time_ms
|
page, args.animation_time_ms
|
||||||
)
|
)
|
||||||
|
chromium_resource_warmup = warm_chromium_capture(page)
|
||||||
if controlled_scroll is not None:
|
if controlled_scroll is not None:
|
||||||
chromium_scroll_state = (
|
chromium_scroll_state = (
|
||||||
reassert_chromium_controlled_scroll(
|
reassert_chromium_controlled_scroll(
|
||||||
@@ -1538,6 +1567,9 @@ def main():
|
|||||||
"capture_boundary_validation": (
|
"capture_boundary_validation": (
|
||||||
chromium_capture_boundary if chrome_ok else None
|
chromium_capture_boundary if chrome_ok else None
|
||||||
),
|
),
|
||||||
|
"resource_warmup": (
|
||||||
|
chromium_resource_warmup 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
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ 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")
|
self.assertEqual(environment["OBSCURA_SHOT_EVAL_AT_CAPTURE"], "1")
|
||||||
|
self.assertEqual(environment["OBSCURA_SHOT_RESOURCE_WARMUP"], "1")
|
||||||
|
|
||||||
def test_paired_state_expression_is_read_only_at_capture_boundary(self):
|
def test_paired_state_expression_is_read_only_at_capture_boundary(self):
|
||||||
expression = paired_corpus.obscura_state_eval_expression(
|
expression = paired_corpus.obscura_state_eval_expression(
|
||||||
@@ -326,6 +327,26 @@ class GeometryProbeTests(unittest.TestCase):
|
|||||||
hashlib.sha256(b"hello").hexdigest(),
|
hashlib.sha256(b"hello").hexdigest(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_chromium_resource_warmup_discards_one_shot_then_yields(self):
|
||||||
|
events = []
|
||||||
|
|
||||||
|
class WarmupPage:
|
||||||
|
def screenshot(self, **kwargs):
|
||||||
|
events.append(("screenshot", kwargs))
|
||||||
|
return b"discarded"
|
||||||
|
|
||||||
|
def wait_for_timeout(self, timeout):
|
||||||
|
events.append(("wait", timeout))
|
||||||
|
|
||||||
|
report = paired_corpus.warm_chromium_capture(WarmupPage())
|
||||||
|
self.assertEqual([event[0] for event in events], ["screenshot", "wait"])
|
||||||
|
self.assertEqual(events[1], ("wait", 1))
|
||||||
|
self.assertNotIn("path", events[0][1])
|
||||||
|
self.assertEqual(report["discardedShots"], 1)
|
||||||
|
self.assertEqual(
|
||||||
|
report["phase"], paired_corpus.RESOURCE_WARMUP_PHASE
|
||||||
|
)
|
||||||
|
|
||||||
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"]', "["]
|
||||||
obscura_expression = paired_corpus.obscura_state_eval_expression(
|
obscura_expression = paired_corpus.obscura_state_eval_expression(
|
||||||
|
|||||||
Reference in new issue
Block a user