1use base64::engine::general_purpose::STANDARD as B64;
4use base64::Engine as _;
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7use sha2::{Digest, Sha256};
8
9use crate::cdp::Browser;
10use crate::determinism::Determinism;
11use crate::device::Device;
12use crate::error::{Error, Result};
13use crate::png;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Stability {
18 pub fonts: bool,
22 pub images: bool,
24 pub quiet_frames: u32,
26 pub timeout_ms: u32,
28}
29
30impl Default for Stability {
31 fn default() -> Self {
32 Stability {
33 fonts: true,
34 images: true,
35 quiet_frames: 2,
36 timeout_ms: 10_000,
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct Environment {
55 pub inner_width: u32,
57 pub inner_height: u32,
59 pub device_pixel_ratio: u32,
61 pub touch_points: u32,
63 pub viewport_honoured: bool,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Capture {
72 pub device_id: String,
73 pub expected: (u32, u32),
75 pub actual: (u32, u32),
77 pub sha256: String,
79 pub bytes: usize,
80 pub exact: bool,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub environment: Option<Environment>,
86}
87
88#[derive(Debug, Clone)]
90pub struct CaptureRequest<'a> {
91 pub url: &'a str,
92 pub device: &'a Device,
93 pub determinism: &'a Determinism,
94 pub stability: &'a Stability,
95}
96
97fn stability_script(s: &Stability) -> String {
102 format!(
103 r#"new Promise((resolve) => {{
104 const deadline = Date.now() + {timeout};
105 const done = (why) => resolve(why);
106 const waits = [];
107 if ({fonts} && document.fonts && document.fonts.ready) {{
108 waits.push(document.fonts.ready.catch(() => {{}}));
109 }}
110 if ({images}) {{
111 const imgs = Array.from(document.images || []);
112 waits.push(Promise.all(imgs.map((i) =>
113 (i.decode ? i.decode().catch(() => {{}}) : Promise.resolve())
114 )));
115 }}
116 Promise.all(waits).then(() => {{
117 let quiet = 0;
118 let dirty = false;
119 const obs = new MutationObserver(() => {{ dirty = true; }});
120 obs.observe(document.documentElement, {{
121 subtree: true, childList: true, attributes: true, characterData: true
122 }});
123 const tick = () => {{
124 if (Date.now() > deadline) {{ obs.disconnect(); return done('timeout'); }}
125 if (dirty) {{ dirty = false; quiet = 0; }} else {{ quiet++; }}
126 if (quiet >= {frames}) {{ obs.disconnect(); return done('quiet'); }}
127 setTimeout(tick, 16);
128 }};
129 tick();
130 }});
131}})"#,
132 timeout = s.timeout_ms,
133 fonts = s.fonts,
134 images = s.images,
135 frames = s.quiet_frames.max(1),
136 )
137}
138
139pub fn capture(browser: &mut Browser, req: &CaptureRequest<'_>) -> Result<(Capture, Vec<u8>)> {
146 let (vw, vh) = req.device.viewport();
147
148 browser.call("Page.enable", json!({}))?;
149 browser.call("Runtime.enable", json!({}))?;
150
151 browser.call(
152 "Emulation.setDeviceMetricsOverride",
153 json!({
154 "width": vw,
155 "height": vh,
156 "deviceScaleFactor": req.device.scale,
157 "mobile": req.device.mobile,
158 }),
159 )?;
160
161 let platform = req.device.platform;
174 if let Some(ua) = platform.user_agent() {
175 browser
176 .call(
177 "Emulation.setUserAgentOverride",
178 json!({
179 "userAgent": ua,
180 "platform": platform.ch_platform(),
181 "userAgentMetadata": {
185 "platform": platform.ch_platform(),
186 "platformVersion": "",
187 "architecture": "",
188 "model": "",
189 "mobile": platform.ch_mobile(),
190 "brands": [],
191 },
192 }),
193 )
194 .or_else(swallow_unsupported)?;
195 }
196 let touch = platform.touch_points();
197 browser
198 .call(
199 "Emulation.setTouchEmulationEnabled",
200 json!({ "enabled": touch > 0, "maxTouchPoints": touch.max(1) }),
201 )
202 .or_else(swallow_unsupported)?;
203
204 browser
208 .call(
209 "Emulation.setTimezoneOverride",
210 json!({ "timezoneId": req.determinism.timezone }),
211 )
212 .or_else(swallow_unsupported)?;
213 browser
214 .call(
215 "Emulation.setLocaleOverride",
216 json!({ "locale": req.determinism.locale }),
217 )
218 .or_else(swallow_unsupported)?;
219
220 browser.call(
221 "Page.addScriptToEvaluateOnNewDocument",
222 json!({ "source": req.determinism.preamble() }),
223 )?;
224
225 browser.call("Page.navigate", json!({ "url": req.url }))?;
226
227 browser.call(
228 "Runtime.evaluate",
229 json!({
230 "expression": stability_script(req.stability),
231 "awaitPromise": true,
232 "returnByValue": true,
233 }),
234 )?;
235
236 let environment = probe_environment(browser, vw, vh).ok();
239
240 let shot = browser.call(
241 "Page.captureScreenshot",
242 json!({ "format": "png", "captureBeyondViewport": false }),
243 )?;
244 let b64 = shot
245 .get("data")
246 .and_then(|v| v.as_str())
247 .ok_or_else(|| Error::Shape("captureScreenshot returned no data".into()))?;
248 let bytes = B64
249 .decode(b64)
250 .map_err(|e| Error::Shape(format!("screenshot was not valid base64: {e}")))?;
251
252 let actual = png::dimensions(&bytes)?;
253 let expected = req.device.output_size();
254
255 let mut h = Sha256::new();
256 h.update(&bytes);
257 let sha256 = format!("{:x}", h.finalize());
258
259 Ok((
260 Capture {
261 device_id: req.device.id.clone(),
262 expected,
263 actual,
264 sha256,
265 bytes: bytes.len(),
266 exact: actual == expected,
267 environment,
268 },
269 bytes,
270 ))
271}
272
273fn probe_environment(browser: &mut Browser, want_w: u32, want_h: u32) -> Result<Environment> {
275 let v = browser.call(
276 "Runtime.evaluate",
277 json!({
278 "expression": "({w:innerWidth,h:innerHeight,d:devicePixelRatio,\
279 t:(navigator.maxTouchPoints||0)})",
280 "returnByValue": true,
281 }),
282 )?;
283 let obj = v
284 .get("result")
285 .and_then(|r| r.get("value"))
286 .ok_or_else(|| Error::Shape("environment probe returned nothing".into()))?;
287 let num = |k: &str| obj.get(k).and_then(|x| x.as_f64()).unwrap_or(0.0) as u32;
288 let inner_width = num("w");
289 let inner_height = num("h");
290 Ok(Environment {
291 inner_width,
292 inner_height,
293 device_pixel_ratio: num("d"),
294 touch_points: num("t"),
295 viewport_honoured: inner_width == want_w && inner_height == want_h,
298 })
299}
300
301fn swallow_unsupported(e: Error) -> Result<serde_json::Value> {
305 match &e {
306 Error::Cdp { message, .. }
307 if message.contains("wasn't found") || message.contains("not supported") =>
308 {
309 Ok(serde_json::Value::Null)
310 }
311 _ => Err(e),
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn stability_script_honours_its_flags() {
321 let s = Stability {
322 fonts: false,
323 images: true,
324 quiet_frames: 3,
325 timeout_ms: 500,
326 };
327 let js = stability_script(&s);
328 assert!(js.contains("if (false && document.fonts"));
329 assert!(js.contains("if (true)"));
330 assert!(js.contains("quiet >= 3"));
331 assert!(js.contains("Date.now() + 500"));
332 }
333
334 #[test]
335 fn quiet_frames_never_degenerates_to_zero() {
336 let s = Stability {
338 quiet_frames: 0,
339 ..Default::default()
340 };
341 assert!(stability_script(&s).contains("quiet >= 1"));
342 }
343
344 #[test]
345 fn unsupported_domain_errors_are_swallowed_but_others_are_not() {
346 let ok = swallow_unsupported(Error::Cdp {
347 method: "Emulation.setLocaleOverride".into(),
348 message: "'Emulation.setLocaleOverride' wasn't found".into(),
349 });
350 assert!(ok.is_ok());
351
352 let bad = swallow_unsupported(Error::Cdp {
353 method: "Page.navigate".into(),
354 message: "Cannot navigate to invalid URL".into(),
355 });
356 assert!(bad.is_err(), "real errors must not be swallowed");
357 }
358}