1use std::{
12 collections::{BTreeMap, BTreeSet},
13 path::Path,
14};
15
16use serde_json::Value;
17
18use crate::{
19 evidence_archive::{EvidenceArchiveEntry, read_archive_selected},
20 run_store::StoredRun,
21 source_manifest::{FileFingerprint, FileManifest, describe},
22 source_units::only_added,
23 test_loads::{Imports, configuration_runner, reads_imports},
24};
25
26pub const PLAYWRIGHT_CONFIGURATION: &str = "playwright-configuration.json";
28
29#[derive(Debug, Clone, Default)]
31pub struct Ran {
32 pub runners: Option<BTreeSet<String>>,
34 pub playwright: Option<Value>,
36 pub measured: Option<BTreeSet<String>>,
40}
41
42impl Ran {
43 pub fn of(run: &StoredRun) -> Self {
44 let merged = run.metadata.merged == Some(true);
45 match read_archive_selected(&run.evidence_path, |path| {
46 Self::wanted(path, merged).is_some()
47 }) {
48 Ok(entries) => Self::from_entries(&entries, merged),
49 Err(_) => Self::default(),
50 }
51 }
52
53 pub fn from_entries(entries: &[EvidenceArchiveEntry], merged: bool) -> Self {
55 let mut ran = Self::default();
56 for entry in entries {
57 let Some(name) = Self::wanted(&entry.path, merged) else {
58 continue;
59 };
60 let Ok(value) = serde_json::from_slice::<Value>(&entry.contents) else {
61 continue;
62 };
63 if name == PLAYWRIGHT_CONFIGURATION {
64 ran.playwright = Some(value);
65 } else if name == "manifest.json" {
66 ran.measured = value["scope"]["entries"].as_array().map(|entries| {
67 entries
68 .iter()
69 .filter(|entry| entry["status"] == "included")
70 .filter_map(|entry| entry["file"].as_str().map(str::to_owned))
71 .collect()
72 });
73 } else if let Some(runners) = value["runners"].as_array() {
74 ran.runners.get_or_insert_with(BTreeSet::new).extend(
75 runners
76 .iter()
77 .filter_map(|runner| runner["runner"].as_str())
78 .filter(|runner| !matches!(*runner, "command" | "background"))
79 .map(str::to_owned),
80 );
81 }
82 }
83 ran
84 }
85
86 fn wanted(path: &str, merged: bool) -> Option<&'static str> {
88 let name = if merged {
89 path.rsplit('/').next()?
90 } else {
91 path
92 };
93 [PLAYWRIGHT_CONFIGURATION, "frontend.json", "manifest.json"]
94 .into_iter()
95 .find(|wanted| *wanted == name)
96 }
97}
98
99#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct Judgment {
102 pub outdated: Vec<String>,
105 pub own_tests: bool,
108 pub settled: bool,
111 pub across: Vec<String>,
114}
115
116pub struct Checkout<'a> {
118 pub root: &'a Path,
119 pub tests: &'a BTreeSet<String>,
121 imports: Imports,
122 loaded_by_tests: std::cell::OnceCell<crate::test_loads::Loads>,
126}
127
128impl<'a> Checkout<'a> {
129 pub fn new(root: &'a Path, tests: &'a BTreeSet<String>) -> Self {
130 Self {
131 root,
132 tests,
133 imports: Imports::new(root),
134 loaded_by_tests: std::cell::OnceCell::new(),
135 }
136 }
137
138 pub fn places(&self, file: &str) -> bool {
143 self.loaded_by_tests
144 .get_or_init(|| {
145 self.imports.loads(
146 self.tests
147 .iter()
148 .filter(|file| reads_imports(file))
149 .map(String::as_str),
150 )
151 })
152 .files
153 .contains(file)
154 }
155
156 pub fn loads(
160 &self,
161 own: &BTreeSet<String>,
162 runners: Option<&BTreeSet<String>>,
163 configurations: &[String],
164 ) -> Option<crate::test_loads::Loads> {
165 if own.is_empty() || !own.iter().all(|file| reads_imports(file)) {
166 return None;
167 }
168 let mut entries = own.iter().cloned().collect::<Vec<_>>();
169 for configuration in configurations {
170 let used = configuration_runner(configuration)
171 .is_some_and(|runner| runners.is_some_and(|runners| runners.contains(runner)));
172 if used {
173 entries.push(configuration.clone());
174 entries.extend(self.imports.named_by(configuration));
175 }
176 }
177 Some(self.imports.loads(entries.iter().map(String::as_str)))
178 }
179}
180
181pub fn configuration_file(file: &str) -> bool {
184 crate::integrity::configuration_file(Path::new(file))
185}
186
187pub fn beside_the_tests(file: &str) -> bool {
191 let lower = file.to_ascii_lowercase();
192 lower
193 .split('/')
194 .next()
195 .is_some_and(|top| matches!(top, "test" | "tests" | "__tests__"))
196 || lower
197 .split(['/', '_', '.', '-'])
198 .any(|part| matches!(part, "test" | "spec"))
199}
200
201pub fn dependency_file(file: &str) -> bool {
202 let name = file.rsplit('/').next().unwrap_or(file);
203 matches!(
204 name,
205 "package.json"
206 | "package-lock.json"
207 | "npm-shrinkwrap.json"
208 | "pnpm-lock.yaml"
209 | "yarn.lock"
210 | "bun.lock"
211 | "bun.lockb"
212 )
213}
214
215fn playwright_shape(configuration: &Value) -> Value {
220 const PROJECT: [&str; 5] = ["name", "use", "testDir", "dependencies", "teardown"];
221 const CONFIG: [&str; 5] = [
223 "globalSetup",
224 "globalTeardown",
225 "webServer",
226 "use",
227 "testDir",
228 ];
229 let pick = |value: &Value, keys: &[&str]| {
230 Value::Object(
231 keys.iter()
232 .filter_map(|key| value.get(*key).map(|v| ((*key).to_owned(), v.clone())))
233 .collect(),
234 )
235 };
236 let mut shape = pick(configuration, &CONFIG);
237 shape["projects"] = Value::Array(
238 configuration["projects"]
239 .as_array()
240 .into_iter()
241 .flatten()
242 .map(|project| pick(project, &PROJECT))
243 .collect(),
244 );
245 shape
246}
247
248pub fn playwright_differences(run: &Value, reference: &Value) -> Vec<String> {
253 let (run, reference) = (playwright_shape(run), playwright_shape(reference));
254 let mut differences = Vec::new();
255 for key in [
256 "globalSetup",
257 "globalTeardown",
258 "webServer",
259 "use",
260 "testDir",
261 ] {
262 if run.get(key) != reference.get(key) {
263 differences.push(key.to_owned());
264 }
265 }
266 let projects = |shape: &Value| {
267 shape["projects"]
268 .as_array()
269 .into_iter()
270 .flatten()
271 .map(|project| {
272 (
273 project["name"].as_str().unwrap_or("").to_owned(),
274 project.clone(),
275 )
276 })
277 .collect::<BTreeMap<_, _>>()
278 };
279 let now = projects(&reference);
280 for (name, project) in projects(&run) {
281 let label = if name.is_empty() {
282 String::new()
283 } else {
284 format!("project {name}: ")
285 };
286 match now.get(&name) {
287 None => differences.push(format!("{label}removed")),
288 Some(current) => {
289 for key in ["use", "testDir", "dependencies", "teardown"] {
290 if project.get(key) != current.get(key) {
291 differences.push(format!("{label}{key}"));
292 }
293 }
294 }
295 }
296 }
297 differences
298}
299
300#[allow(clippy::too_many_arguments)]
307pub fn judge(
308 checkout: &Checkout<'_>,
309 label: &str,
310 recorded: &FileManifest,
311 now: &FileManifest,
312 text: &dyn Fn(&str) -> Option<String>,
313 own: Option<&BTreeSet<String>>,
314 ran: &Ran,
315 reference: Option<&Value>,
316 allow_changed: bool,
317) -> Judgment {
318 let mut judgment = Judgment::default();
319 let configurations = recorded
320 .keys()
321 .chain(now.keys())
322 .filter(|file| configuration_file(file))
323 .cloned()
324 .collect::<BTreeSet<_>>()
325 .into_iter()
326 .collect::<Vec<_>>();
327 let loads = own.and_then(|own| checkout.loads(own, ran.runners.as_ref(), &configurations));
328 let mut changed = recorded
329 .iter()
330 .filter(|(file, was)| now.get(*file).is_none_or(|is| !is.same_bytes(was)))
331 .map(|(file, _)| file.clone())
332 .collect::<BTreeSet<_>>();
333 changed.extend(
335 now.keys()
336 .filter(|file| !recorded.contains_key(*file) && configuration_file(file))
337 .cloned(),
338 );
339 let refuse = |judgment: &mut Judgment, file: &str, what: String| {
340 if allow_changed {
341 judgment.across.push(format!("{file} (accepted)"));
342 } else {
343 judgment.outdated.push(what);
344 }
345 };
346 for file in changed {
347 let gone = !now.contains_key(&file);
348 if own.is_some_and(|own| own.contains(&file)) {
349 judgment.own_tests = true;
350 judgment.settled = true;
351 judgment.outdated.push(format!(
352 "{file}, a file of its own tests, {}",
353 if gone { "is gone" } else { "has changed" }
354 ));
355 continue;
356 }
357 if checkout.tests.contains(&file) && own.is_some() {
360 continue;
361 }
362 if dependency_file(&file) {
363 judgment.outdated.push(format!("{file} has changed"));
364 continue;
365 }
366 if configuration_file(&file) {
367 let runner = configuration_runner(&file);
368 match (runner, ran.runners.as_ref()) {
369 (Some(runner), Some(runners)) if !runners.contains(runner) => {
370 judgment.across.push(format!(
371 "{file} ({runner} settings; `{label}` ran under {})",
372 runners.iter().cloned().collect::<Vec<_>>().join(", ")
373 ));
374 }
375 (Some("playwright"), Some(_)) => match (&ran.playwright, reference) {
376 (Some(then), Some(reference)) => {
377 let differences = playwright_differences(then, reference);
378 if differences.is_empty() {
379 judgment
380 .across
381 .push(format!("{file} (only settings that schedule tests)"));
382 } else {
383 refuse(
384 &mut judgment,
385 &file,
386 format!("{file}: {} changed", differences.join(", ")),
387 );
388 }
389 }
390 _ => refuse(&mut judgment, &file, format!("{file} has changed")),
391 },
392 _ => refuse(&mut judgment, &file, format!("{file} has changed")),
393 }
394 continue;
395 }
396 let measured = match &ran.measured {
400 Some(measured) => measured.contains(&file),
401 None => !beside_the_tests(&file),
402 };
403 if measured {
404 judgment.settled = true;
405 judgment.outdated.push(format!(
406 "{file}, which it measured, {}",
407 if gone { "is gone" } else { "has changed" }
408 ));
409 continue;
410 }
411 let placed = checkout.places(&file);
414 if let Some(loads) = &loads
415 && loads.open.is_none()
416 && placed
417 && !loads.files.contains(&file)
418 {
419 judgment
420 .across
421 .push(format!("{file} (`{label}` does not load it)"));
422 continue;
423 }
424 if gone {
425 refuse(&mut judgment, &file, format!("{file} is gone"));
426 continue;
427 }
428 let before = recorded.get(&file).and_then(|was| was.code.as_ref());
429 let after = now.get(&file).and_then(|is| is.code.as_ref());
430 if let (Some(before), Some(source)) = (before, text(&file))
431 && let Some(added) = only_added(before, &file, &source)
432 {
433 judgment
434 .across
435 .push(format!("{file} (only added {})", added.join(", ")));
436 continue;
437 }
438 let what = match (before, after) {
439 (Some(before), Some(after)) => {
440 format!(
441 "{file} changed: {}",
442 describe(before, after, &before.diff(after))
443 )
444 }
445 _ => format!("{file} has changed"),
446 };
447 refuse(&mut judgment, &file, what);
448 }
449 judgment
450}
451
452pub fn across(items: impl IntoIterator<Item = String>) -> Vec<String> {
455 let mut files = BTreeMap::<String, Vec<String>>::new();
456 for item in items {
457 let (file, why) = match item.split_once(" (") {
458 Some((file, why)) => (file.to_owned(), why.trim_end_matches(')').to_owned()),
459 None => (item, String::new()),
460 };
461 let whys = files.entry(file).or_default();
462 if !why.is_empty() && !whys.contains(&why) {
463 whys.push(why);
464 }
465 }
466 files
467 .into_iter()
468 .map(|(file, whys)| {
469 if whys.is_empty() {
470 file
471 } else {
472 format!("{file} ({})", whys.join("; "))
473 }
474 })
475 .collect()
476}
477
478pub fn checkout_files(
481 root: &Path,
482 recorded: &FileManifest,
483 configurations: &[String],
484) -> FileManifest {
485 recorded
486 .keys()
487 .chain(configurations)
488 .filter_map(|file| {
489 let text = std::fs::read_to_string(root.join(file)).ok()?;
490 Some((file.clone(), FileFingerprint::read(file, &text)))
491 })
492 .collect()
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use serde_json::json;
499
500 #[test]
501 fn scheduling_settings_do_not_change_what_a_test_reaches() {
502 let then = json!({
503 "webServer": [{"command": "npm start", "url": "http://127.0.0.1:3000"}],
504 "projects": [{"name": "chromium", "use": {"baseURL": "http://127.0.0.1:3000"}, "timeout": 30000, "retries": 0, "testMatch": "*.spec.ts"}],
505 "workers": 1,
506 });
507 let mut later = then.clone();
508 later["workers"] = json!(4);
509 later["projects"][0]["timeout"] = json!(45000);
510 later["projects"][0]["testMatch"] = json!(["*.spec.ts", "new/*.spec.ts"]);
511 later["projects"]
512 .as_array_mut()
513 .unwrap()
514 .push(json!({"name": "firefox", "use": {"browserName": "firefox"}}));
515 assert_eq!(playwright_differences(&then, &later), Vec::<String>::new());
516
517 let mut shaped = then.clone();
518 shaped["projects"][0]["use"]["extraHTTPHeaders"] = json!({"x-suite": "e2e"});
519 shaped["webServer"][0]["command"] = json!("npm run start:mock");
520 assert_eq!(
521 playwright_differences(&then, &shaped),
522 ["webServer", "project chromium: use"]
523 );
524 let single = json!({"use": {"baseURL": "http://127.0.0.1:3000"}, "timeout": 30000});
526 let mut longer = single.clone();
527 longer["timeout"] = json!(45000);
528 assert_eq!(
529 playwright_differences(&single, &longer),
530 Vec::<String>::new()
531 );
532 longer["use"]["locale"] = json!("de-DE");
533 assert_eq!(playwright_differences(&single, &longer), ["use"]);
534 }
535}