1use 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#[derive(Debug, Clone)]
22pub struct RunOptions {
23 pub url: String,
25 pub devices: Vec<Device>,
27 pub out_dir: PathBuf,
29 pub determinism: Determinism,
30 pub stability: Stability,
31 pub browser: PathBuf,
33 pub fail_fast: bool,
35}
36
37#[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#[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 pub ok: bool,
64 pub results: Vec<DeviceResult>,
65}
66
67pub 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
148fn 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 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 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 #[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}