Skip to main content

proofsheet_core/
capture.rs

1//! Capturing one image, at exactly the size a store demands.
2
3use 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/// How long to let the page settle before capturing.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Stability {
18    /// Wait for `document.fonts.ready`. A capture taken mid font-swap shows
19    /// fallback metrics and is the single most common source of screenshot
20    /// churn.
21    pub fonts: bool,
22    /// Wait for every `<img>` to finish decoding, not merely to load.
23    pub images: bool,
24    /// Require this many consecutive animation frames with no DOM mutation.
25    pub quiet_frames: u32,
26    /// Give up waiting after this many milliseconds and capture anyway.
27    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/// What the page actually saw, read back from the page itself.
42///
43/// # Why this is recorded
44///
45/// Dimensions alone cannot tell you whether a screenshot is right. A capture
46/// can be exactly 1320x2868 and still show a desktop layout scaled into a
47/// phone frame, because a page served to a desktop User-Agent may declare
48/// `<meta name="viewport" content="width=1120">` and Chrome honours it.
49/// Every size assertion passes; the image is useless.
50///
51/// So the run records the viewport and touch points the page reported, and
52/// callers can assert on the environment rather than only on the output.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct Environment {
55    /// `window.innerWidth` as the page saw it.
56    pub inner_width: u32,
57    /// `window.innerHeight` as the page saw it.
58    pub inner_height: u32,
59    /// `devicePixelRatio` as the page saw it.
60    pub device_pixel_ratio: u32,
61    /// `navigator.maxTouchPoints`.
62    pub touch_points: u32,
63    /// Whether the layout viewport matched what was requested. False means
64    /// the page overrode it, which usually means content negotiation served
65    /// the wrong layout.
66    pub viewport_honoured: bool,
67}
68
69/// The result of one capture.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Capture {
72    pub device_id: String,
73    /// What the store requires.
74    pub expected: (u32, u32),
75    /// What the browser actually produced.
76    pub actual: (u32, u32),
77    /// Content address of the image bytes.
78    pub sha256: String,
79    pub bytes: usize,
80    /// Whether `actual == expected`. A false here is a hard failure, not a
81    /// warning: an off-size asset is rejected at upload.
82    pub exact: bool,
83    /// What the page reported about itself. `None` if the probe failed.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub environment: Option<Environment>,
86}
87
88/// Everything needed to drive one capture.
89#[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
97/// Build the JS that resolves once the page has stopped moving.
98///
99/// Note the deliberate use of real timers here rather than the virtualised
100/// clock: this runs in the harness's frame of reference, not the page's.
101fn 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
139/// Capture a single image.
140///
141/// Order matters and is the whole trick: metrics are applied **before**
142/// navigation so the first layout already happens at the target size. Setting
143/// them afterwards produces a reflow, and anything that measured the viewport
144/// during initial layout is now wrong.
145pub 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    // Metrics alone are NOT device emulation.
162    //
163    // A page served to a desktop User-Agent may declare
164    // `<meta name="viewport" content="width=1120">`, and Chrome honours that
165    // whenever `mobile` is set -- so the layout viewport becomes 1120 CSS px
166    // and the desktop layout is merely scaled into a phone-sized frame. The
167    // pixel count is right and the content is wrong, which is the single most
168    // dangerous failure this tool can have: it looks like a success.
169    //
170    // Measured on a real site, identical metrics, changing only these two
171    // calls: innerWidth 1120 -> 440, maxTouchPoints 0 -> 5, and the server
172    // returned different HTML.
173    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                    // Client Hints as well as the UA string: modern sites
182                    // branch on Sec-CH-UA-Mobile, and overriding only one
183                    // leaves the page half-convinced it is on a phone.
184                    "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    // Locale and timezone are set through CDP rather than script because the
205    // script-level overrides are trivially detectable and do not affect
206    // Intl's internal data.
207    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    // Read back what the page actually saw, before capturing. This is the
237    // check that catches "right pixels, wrong layout".
238    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
273/// Ask the page what viewport and input capabilities it believes it has.
274fn 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        // A page that overrides the layout viewport via meta-viewport is
296        // telling us it did not accept the device we claimed to be.
297        viewport_honoured: inner_width == want_w && inner_height == want_h,
298    })
299}
300
301/// Some `Emulation.*` overrides are unavailable in older or reduced builds
302/// (`chrome-headless-shell` in particular). A missing locale override should
303/// degrade the run, not end it — but anything else must still propagate.
304fn 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        // Zero would make the loop resolve before observing anything.
337        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}