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 pub support: Option<crate::support_reach::SupportReach>,
43}
44
45impl Ran {
46 pub fn of(run: &StoredRun) -> Self {
47 let merged = run.metadata.merged == Some(true);
48 match read_archive_selected(&run.evidence_path, |path| {
49 Self::wanted(path, merged).is_some()
50 }) {
51 Ok(entries) => Self::from_entries(&entries, merged),
52 Err(_) => Self::default(),
53 }
54 }
55
56 pub fn from_entries(entries: &[EvidenceArchiveEntry], merged: bool) -> Self {
58 let mut ran = Self::default();
59 for entry in entries {
60 let Some(name) = Self::wanted(&entry.path, merged) else {
61 continue;
62 };
63 let Ok(value) = serde_json::from_slice::<Value>(&entry.contents) else {
64 continue;
65 };
66 if name == crate::support_reach::ARCHIVE_PATH {
67 if let Ok(reach) =
68 serde_json::from_value::<crate::support_reach::SupportReach>(value)
69 {
70 ran.support
71 .get_or_insert_with(Default::default)
72 .extend(reach);
73 }
74 } else if name == PLAYWRIGHT_CONFIGURATION {
75 ran.playwright = Some(value);
76 } else if name == "manifest.json" {
77 ran.measured = value["scope"]["entries"].as_array().map(|entries| {
78 entries
79 .iter()
80 .filter(|entry| entry["status"] == "included")
81 .filter_map(|entry| entry["file"].as_str().map(str::to_owned))
82 .collect()
83 });
84 } else if let Some(runners) = value["runners"].as_array() {
85 ran.runners.get_or_insert_with(BTreeSet::new).extend(
86 runners
87 .iter()
88 .filter_map(|runner| runner["runner"].as_str())
89 .filter(|runner| !matches!(*runner, "command" | "background"))
90 .map(str::to_owned),
91 );
92 }
93 }
94 ran
95 }
96
97 fn wanted(path: &str, merged: bool) -> Option<&'static str> {
99 let name = if merged {
100 path.rsplit('/').next()?
101 } else {
102 path
103 };
104 [
105 PLAYWRIGHT_CONFIGURATION,
106 "frontend.json",
107 "manifest.json",
108 crate::support_reach::ARCHIVE_PATH,
109 ]
110 .into_iter()
111 .find(|wanted| *wanted == name)
112 }
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct Judgment {
118 pub outdated: Vec<String>,
121 pub own_tests: bool,
124 pub settled: bool,
127 pub across: Vec<String>,
130 pub reached: BTreeMap<String, BTreeSet<String>>,
134}
135
136pub struct Checkout<'a> {
138 pub root: &'a Path,
139 pub tests: &'a BTreeSet<String>,
141 imports: Imports,
142 loaded_by_tests: std::cell::OnceCell<crate::test_loads::Loads>,
146}
147
148impl<'a> Checkout<'a> {
149 pub fn new(root: &'a Path, tests: &'a BTreeSet<String>) -> Self {
150 Self {
151 root,
152 tests,
153 imports: Imports::new(root),
154 loaded_by_tests: std::cell::OnceCell::new(),
155 }
156 }
157
158 pub fn places(&self, file: &str) -> bool {
163 self.loaded_by_tests
164 .get_or_init(|| {
165 self.imports.loads(
166 self.tests
167 .iter()
168 .filter(|file| reads_imports(file))
169 .map(String::as_str),
170 )
171 })
172 .files
173 .contains(file)
174 }
175
176 pub fn loads(
180 &self,
181 own: &BTreeSet<String>,
182 runners: Option<&BTreeSet<String>>,
183 configurations: &[String],
184 ) -> Option<crate::test_loads::Loads> {
185 if own.is_empty() || !own.iter().all(|file| reads_imports(file)) {
186 return None;
187 }
188 let mut entries = own.iter().cloned().collect::<Vec<_>>();
189 for configuration in configurations {
190 let used = configuration_runner(configuration)
191 .is_some_and(|runner| runners.is_some_and(|runners| runners.contains(runner)));
192 if used {
193 entries.push(configuration.clone());
194 entries.extend(self.imports.named_by(configuration));
195 }
196 }
197 Some(self.imports.loads(entries.iter().map(String::as_str)))
198 }
199}
200
201pub fn configuration_file(file: &str) -> bool {
204 crate::integrity::configuration_file(Path::new(file))
205}
206
207pub fn beside_the_tests(file: &str) -> bool {
211 let lower = file.to_ascii_lowercase();
212 lower
213 .split('/')
214 .next()
215 .is_some_and(|top| matches!(top, "test" | "tests" | "__tests__"))
216 || lower
217 .split(['/', '_', '.', '-'])
218 .any(|part| matches!(part, "test" | "spec"))
219}
220
221pub fn dependency_file(file: &str) -> bool {
222 let name = file.rsplit('/').next().unwrap_or(file);
223 matches!(
224 name,
225 "package.json"
226 | "package-lock.json"
227 | "npm-shrinkwrap.json"
228 | "pnpm-lock.yaml"
229 | "yarn.lock"
230 | "bun.lock"
231 | "bun.lockb"
232 )
233}
234
235fn playwright_shape(configuration: &Value) -> Value {
240 const PROJECT: [&str; 5] = ["name", "use", "testDir", "dependencies", "teardown"];
241 const CONFIG: [&str; 5] = [
243 "globalSetup",
244 "globalTeardown",
245 "webServer",
246 "use",
247 "testDir",
248 ];
249 let pick = |value: &Value, keys: &[&str]| {
250 Value::Object(
251 keys.iter()
252 .filter_map(|key| value.get(*key).map(|v| ((*key).to_owned(), v.clone())))
253 .collect(),
254 )
255 };
256 let mut shape = pick(configuration, &CONFIG);
257 shape["projects"] = Value::Array(
258 configuration["projects"]
259 .as_array()
260 .into_iter()
261 .flatten()
262 .map(|project| pick(project, &PROJECT))
263 .collect(),
264 );
265 shape
266}
267
268pub fn playwright_differences(run: &Value, reference: &Value) -> Vec<String> {
273 let (run, reference) = (playwright_shape(run), playwright_shape(reference));
274 let mut differences = Vec::new();
275 for key in [
276 "globalSetup",
277 "globalTeardown",
278 "webServer",
279 "use",
280 "testDir",
281 ] {
282 if run.get(key) != reference.get(key) {
283 differences.push(key.to_owned());
284 }
285 }
286 let projects = |shape: &Value| {
287 shape["projects"]
288 .as_array()
289 .into_iter()
290 .flatten()
291 .map(|project| {
292 (
293 project["name"].as_str().unwrap_or("").to_owned(),
294 project.clone(),
295 )
296 })
297 .collect::<BTreeMap<_, _>>()
298 };
299 let now = projects(&reference);
300 for (name, project) in projects(&run) {
301 let label = if name.is_empty() {
302 String::new()
303 } else {
304 format!("project {name}: ")
305 };
306 match now.get(&name) {
307 None => differences.push(format!("{label}removed")),
308 Some(current) => {
309 for key in ["use", "testDir", "dependencies", "teardown"] {
310 if project.get(key) != current.get(key) {
311 differences.push(format!("{label}{key}"));
312 }
313 }
314 }
315 }
316 }
317 differences
318}
319
320#[allow(clippy::too_many_arguments)]
327pub fn judge(
328 checkout: &Checkout<'_>,
329 label: &str,
330 recorded: &FileManifest,
331 now: &FileManifest,
332 text: &dyn Fn(&str) -> Option<String>,
333 own: Option<&BTreeSet<String>>,
334 ran: &Ran,
335 reference: Option<&Value>,
336 allow_changed: bool,
337) -> Judgment {
338 let mut judgment = Judgment::default();
339 let configurations = recorded
340 .keys()
341 .chain(now.keys())
342 .filter(|file| configuration_file(file))
343 .cloned()
344 .collect::<BTreeSet<_>>()
345 .into_iter()
346 .collect::<Vec<_>>();
347 let loads = own.and_then(|own| checkout.loads(own, ran.runners.as_ref(), &configurations));
348 let mut changed = recorded
349 .iter()
350 .filter(|(file, was)| now.get(*file).is_none_or(|is| !is.same_bytes(was)))
351 .map(|(file, _)| file.clone())
352 .collect::<BTreeSet<_>>();
353 changed.extend(
355 now.keys()
356 .filter(|file| !recorded.contains_key(*file) && configuration_file(file))
357 .cloned(),
358 );
359 let refuse = |judgment: &mut Judgment, file: &str, what: String| {
360 if allow_changed {
361 judgment.across.push(format!("{file} (accepted)"));
362 } else {
363 judgment.outdated.push(what);
364 }
365 };
366 for file in changed {
367 let gone = !now.contains_key(&file);
368 if own.is_some_and(|own| own.contains(&file)) {
369 judgment.own_tests = true;
370 judgment.settled = true;
371 judgment.outdated.push(format!(
372 "{file}, a file of its own tests, {}",
373 if gone { "is gone" } else { "has changed" }
374 ));
375 continue;
376 }
377 if checkout.tests.contains(&file) && own.is_some() {
380 continue;
381 }
382 if dependency_file(&file) {
383 judgment.outdated.push(format!("{file} has changed"));
384 continue;
385 }
386 if configuration_file(&file) {
387 let runner = configuration_runner(&file);
388 match (runner, ran.runners.as_ref()) {
389 (Some(runner), Some(runners)) if !runners.contains(runner) => {
390 judgment.across.push(format!(
391 "{file} ({runner} settings; `{label}` ran under {})",
392 runners.iter().cloned().collect::<Vec<_>>().join(", ")
393 ));
394 }
395 (Some("playwright"), Some(_)) => match (&ran.playwright, reference) {
396 (Some(then), Some(reference)) => {
397 let differences = playwright_differences(then, reference);
398 if differences.is_empty() {
399 judgment
400 .across
401 .push(format!("{file} (only settings that schedule tests)"));
402 } else {
403 refuse(
404 &mut judgment,
405 &file,
406 format!("{file}: {} changed", differences.join(", ")),
407 );
408 }
409 }
410 _ => refuse(&mut judgment, &file, format!("{file} has changed")),
411 },
412 _ => refuse(&mut judgment, &file, format!("{file} has changed")),
413 }
414 continue;
415 }
416 let measured = match &ran.measured {
420 Some(measured) => measured.contains(&file),
421 None => !beside_the_tests(&file),
422 };
423 if measured {
424 judgment.settled = true;
425 judgment.outdated.push(format!(
426 "{file}, which it measured, {}",
427 if gone { "is gone" } else { "has changed" }
428 ));
429 continue;
430 }
431 if let Some(tests) = ran
435 .support
436 .as_ref()
437 .and_then(|support| support.files.get(&file))
438 && !loads
439 .as_ref()
440 .is_some_and(|loads| loads.files.contains(&file))
441 {
442 if tests.is_empty() {
443 judgment.across.push(format!(
444 "{file} (no test of `{label}` was served by what loaded it)"
445 ));
446 } else {
447 judgment.reached.insert(file, tests.clone());
448 }
449 continue;
450 }
451 let placed = checkout.places(&file);
454 if let Some(loads) = &loads
455 && loads.open.is_none()
456 && placed
457 && !loads.files.contains(&file)
458 {
459 judgment
460 .across
461 .push(format!("{file} (`{label}` does not load it)"));
462 continue;
463 }
464 if gone {
465 refuse(&mut judgment, &file, format!("{file} is gone"));
466 continue;
467 }
468 let before = recorded.get(&file).and_then(|was| was.code.as_ref());
469 let after = now.get(&file).and_then(|is| is.code.as_ref());
470 if let (Some(before), Some(source)) = (before, text(&file))
471 && let Some(added) = only_added(before, &file, &source)
472 {
473 judgment
474 .across
475 .push(format!("{file} (only added {})", added.join(", ")));
476 continue;
477 }
478 let what = match (before, after) {
479 (Some(before), Some(after)) => {
480 format!(
481 "{file} changed: {}",
482 describe(before, after, &before.diff(after))
483 )
484 }
485 _ => format!("{file} has changed"),
486 };
487 refuse(&mut judgment, &file, what);
488 }
489 judgment
490}
491
492pub fn across(items: impl IntoIterator<Item = String>) -> Vec<String> {
495 let mut files = BTreeMap::<String, Vec<String>>::new();
496 for item in items {
497 let (file, why) = match item.split_once(" (") {
498 Some((file, why)) => (file.to_owned(), why.trim_end_matches(')').to_owned()),
499 None => (item, String::new()),
500 };
501 let whys = files.entry(file).or_default();
502 if !why.is_empty() && !whys.contains(&why) {
503 whys.push(why);
504 }
505 }
506 files
507 .into_iter()
508 .map(|(file, whys)| {
509 if whys.is_empty() {
510 file
511 } else {
512 format!("{file} ({})", whys.join("; "))
513 }
514 })
515 .collect()
516}
517
518pub fn checkout_files(
521 root: &Path,
522 recorded: &FileManifest,
523 configurations: &[String],
524) -> FileManifest {
525 recorded
526 .keys()
527 .chain(configurations)
528 .filter_map(|file| {
529 let text = std::fs::read_to_string(root.join(file)).ok()?;
530 Some((file.clone(), FileFingerprint::read(file, &text)))
531 })
532 .collect()
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use serde_json::json;
539
540 #[test]
541 fn scheduling_settings_do_not_change_what_a_test_reaches() {
542 let then = json!({
543 "webServer": [{"command": "npm start", "url": "http://127.0.0.1:3000"}],
544 "projects": [{"name": "chromium", "use": {"baseURL": "http://127.0.0.1:3000"}, "timeout": 30000, "retries": 0, "testMatch": "*.spec.ts"}],
545 "workers": 1,
546 });
547 let mut later = then.clone();
548 later["workers"] = json!(4);
549 later["projects"][0]["timeout"] = json!(45000);
550 later["projects"][0]["testMatch"] = json!(["*.spec.ts", "new/*.spec.ts"]);
551 later["projects"]
552 .as_array_mut()
553 .unwrap()
554 .push(json!({"name": "firefox", "use": {"browserName": "firefox"}}));
555 assert_eq!(playwright_differences(&then, &later), Vec::<String>::new());
556
557 let mut shaped = then.clone();
558 shaped["projects"][0]["use"]["extraHTTPHeaders"] = json!({"x-suite": "e2e"});
559 shaped["webServer"][0]["command"] = json!("npm run start:mock");
560 assert_eq!(
561 playwright_differences(&then, &shaped),
562 ["webServer", "project chromium: use"]
563 );
564 let single = json!({"use": {"baseURL": "http://127.0.0.1:3000"}, "timeout": 30000});
566 let mut longer = single.clone();
567 longer["timeout"] = json!(45000);
568 assert_eq!(
569 playwright_differences(&single, &longer),
570 Vec::<String>::new()
571 );
572 longer["use"]["locale"] = json!("de-DE");
573 assert_eq!(playwright_differences(&single, &longer), ["use"]);
574 }
575}