Skip to main content

proofsheet_core/
run.rs

1//! Driving a whole capture matrix.
2//!
3//! This exists so the CLI, the Node binding and the Python binding all walk
4//! the same code path. A binding that reimplements the loop is a binding that
5//! drifts from the CLI, and then "it works from the command line" becomes a
6//! support burden.
7
8use std::path::{Path, PathBuf};
9use std::time::Instant;
10
11use serde::{Deserialize, Serialize};
12
13use crate::capture::{capture, Capture, CaptureRequest, Stability};
14use crate::cdp::{Browser, LaunchOptions};
15use crate::determinism::Determinism;
16use crate::device::Device;
17use crate::error::Result;
18use crate::progress::{DeviceEvent, Outcome, Progress, Summary};
19
20/// Everything a run needs.
21#[derive(Debug, Clone)]
22pub struct RunOptions {
23    /// Page to capture.
24    pub url: String,
25    /// Devices to capture it on.
26    pub devices: Vec<Device>,
27    /// Where PNGs are written.
28    pub out_dir: PathBuf,
29    pub determinism: Determinism,
30    pub stability: Stability,
31    /// Browser binary.
32    pub browser: PathBuf,
33    /// Stop at the first failure rather than completing the matrix.
34    pub fail_fast: bool,
35}
36
37/// One device's result, including the failure case.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct DeviceResult {
40    pub device_id: String,
41    pub outcome: String,
42    pub elapsed_ms: u128,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub capture: Option<Capture>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub error: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub path: Option<String>,
49}
50
51/// The manifest a run produces.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct RunReport {
54    pub proofsheet: String,
55    pub url: String,
56    pub seed: u64,
57    pub locale: String,
58    pub exact: usize,
59    pub off_size: usize,
60    pub failed: usize,
61    pub elapsed_ms: u128,
62    /// True only when at least one capture happened and none went wrong.
63    pub ok: bool,
64    pub results: Vec<DeviceResult>,
65}
66
67/// Capture every device in `opts`, reporting progress as it goes.
68///
69/// A device that fails does not abort the matrix unless `fail_fast` is set:
70/// when eight of nine sizes are fine, you want the eight and a clear note
71/// about the ninth, not an empty output directory.
72pub fn run<P: Progress>(opts: &RunOptions, progress: &mut P) -> Result<RunReport> {
73    let started = Instant::now();
74    let total = opts.devices.len();
75    progress.run_started(total, &opts.url);
76    std::fs::create_dir_all(&opts.out_dir)?;
77
78    let mut summary = Summary::default();
79    let mut results = Vec::with_capacity(total);
80
81    for (i, device) in opts.devices.iter().enumerate() {
82        let index = i + 1;
83        progress.device_started(index, total, device);
84        let t0 = Instant::now();
85
86        let attempt = capture_one(opts, device, &opts.out_dir);
87        let elapsed = t0.elapsed();
88
89        let (outcome, cap, err, path) = match attempt {
90            Ok((c, p)) => {
91                let o = if c.exact {
92                    Outcome::Exact
93                } else {
94                    Outcome::OffSize
95                };
96                (o, Some(c), None, Some(p))
97            }
98            Err(e) => (Outcome::Failed, None, Some(e.to_string()), None),
99        };
100
101        match outcome {
102            Outcome::Exact => summary.exact += 1,
103            Outcome::OffSize => summary.off_size += 1,
104            Outcome::Failed => summary.failed += 1,
105        }
106
107        progress.device_finished(&DeviceEvent {
108            index,
109            total,
110            device,
111            outcome,
112            capture: cap.as_ref(),
113            elapsed,
114            error: err.as_deref(),
115        });
116
117        results.push(DeviceResult {
118            device_id: device.id.clone(),
119            outcome: outcome.as_str().to_string(),
120            elapsed_ms: elapsed.as_millis(),
121            capture: cap,
122            error: err,
123            path: path.map(|p| p.display().to_string()),
124        });
125
126        if opts.fail_fast && outcome.is_bad() {
127            break;
128        }
129    }
130
131    let elapsed = started.elapsed();
132    progress.run_finished(summary, elapsed);
133
134    Ok(RunReport {
135        proofsheet: crate::VERSION.to_string(),
136        url: opts.url.clone(),
137        seed: opts.determinism.seed,
138        locale: opts.determinism.locale.clone(),
139        exact: summary.exact,
140        off_size: summary.off_size,
141        failed: summary.failed,
142        elapsed_ms: elapsed.as_millis(),
143        ok: summary.ok(),
144        results,
145    })
146}
147
148/// One device, in its own browser.
149///
150/// A fresh process per device is deliberate. Emulation overrides accumulate
151/// on a session, and a leaked override from the previous device produces a
152/// plausible-looking image at the wrong metrics — the exact failure mode this
153/// tool exists to prevent. Process startup is cheap next to being wrong.
154fn capture_one(opts: &RunOptions, device: &Device, out_dir: &Path) -> Result<(Capture, PathBuf)> {
155    let launch = LaunchOptions::new(&opts.browser);
156    let mut browser = Browser::launch(&launch)?;
157    let req = CaptureRequest {
158        url: &opts.url,
159        device,
160        determinism: &opts.determinism,
161        stability: &opts.stability,
162    };
163    let (cap, bytes) = capture(&mut browser, &req)?;
164    let path = out_dir.join(format!("{}.png", device.id));
165    std::fs::write(&path, &bytes)?;
166    Ok((cap, path))
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::progress::Collector;
173
174    fn opts(devices: Vec<Device>) -> RunOptions {
175        RunOptions {
176            url: "about:blank".into(),
177            devices,
178            out_dir: std::env::temp_dir().join("proofsheet-run-test"),
179            determinism: Determinism::default(),
180            stability: Stability::default(),
181            // Deliberately not a browser. These tests exercise the loop's
182            // bookkeeping and its failure path, not the browser.
183            browser: PathBuf::from("/nonexistent/proofsheet/no-browser-here"),
184            fail_fast: false,
185        }
186    }
187
188    #[test]
189    fn every_device_is_reported_even_when_all_fail() {
190        let devices: Vec<Device> = crate::device::builtin().into_iter().take(3).collect();
191        let mut c = Collector::default();
192        let report = run(&opts(devices), &mut c).unwrap();
193
194        assert_eq!(report.results.len(), 3);
195        assert_eq!(report.failed, 3);
196        assert_eq!(c.summary.total(), 3);
197        // A run where nothing captured must never claim success.
198        assert!(!report.ok);
199        for r in &report.results {
200            assert_eq!(r.outcome, "failed");
201            assert!(r.error.is_some(), "a failure must carry its reason");
202        }
203    }
204
205    #[test]
206    fn fail_fast_stops_at_the_first_problem() {
207        let devices: Vec<Device> = crate::device::builtin().into_iter().take(4).collect();
208        let mut o = opts(devices);
209        o.fail_fast = true;
210        let mut c = Collector::default();
211        let report = run(&o, &mut c).unwrap();
212        assert_eq!(report.results.len(), 1, "should have stopped after one");
213    }
214
215    #[test]
216    fn an_empty_device_list_is_not_a_success() {
217        let mut c = Collector::default();
218        let report = run(&opts(vec![]), &mut c).unwrap();
219        assert_eq!(report.results.len(), 0);
220        assert!(
221            !report.ok,
222            "a run that captured nothing reported ok; that is the empty-green bug"
223        );
224    }
225
226    /// The JSON manifest is a public contract: CI parses it, and so will
227    /// anyone scripting around `--json`.
228    ///
229    /// This test exists because renaming `captures` to `results` silently
230    /// broke the CI consumer, and that only surfaced on the live browser
231    /// job minutes later. A field rename should fail here, in milliseconds.
232    #[test]
233    fn manifest_field_names_are_stable() {
234        let devices: Vec<Device> = crate::device::builtin().into_iter().take(1).collect();
235        let mut c = Collector::default();
236        let report = run(&opts(devices), &mut c).unwrap();
237        let v = serde_json::to_value(&report).unwrap();
238
239        for key in [
240            "proofsheet",
241            "url",
242            "seed",
243            "locale",
244            "exact",
245            "off_size",
246            "failed",
247            "elapsed_ms",
248            "ok",
249            "results",
250        ] {
251            assert!(
252                v.get(key).is_some(),
253                "manifest lost top-level field `{key}`; update its consumers \
254                 (.github/workflows/ci.yml) in the same commit"
255            );
256        }
257        for key in ["device_id", "outcome", "elapsed_ms"] {
258            assert!(
259                v["results"][0].get(key).is_some(),
260                "manifest result lost field `{key}`"
261            );
262        }
263    }
264}