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}
37
38impl Ran {
39 pub fn of(run: &StoredRun) -> Self {
40 let merged = run.metadata.merged == Some(true);
41 match read_archive_selected(&run.evidence_path, |path| {
42 Self::wanted(path, merged).is_some()
43 }) {
44 Ok(entries) => Self::from_entries(&entries, merged),
45 Err(_) => Self::default(),
46 }
47 }
48
49 pub fn from_entries(entries: &[EvidenceArchiveEntry], merged: bool) -> Self {
51 let mut ran = Self::default();
52 for entry in entries {
53 let Some(name) = Self::wanted(&entry.path, merged) else {
54 continue;
55 };
56 let Ok(value) = serde_json::from_slice::<Value>(&entry.contents) else {
57 continue;
58 };
59 if name == PLAYWRIGHT_CONFIGURATION {
60 ran.playwright = Some(value);
61 } else if let Some(runners) = value["runners"].as_array() {
62 ran.runners.get_or_insert_with(BTreeSet::new).extend(
63 runners
64 .iter()
65 .filter_map(|runner| runner["runner"].as_str())
66 .filter(|runner| !matches!(*runner, "command" | "background"))
67 .map(str::to_owned),
68 );
69 }
70 }
71 ran
72 }
73
74 fn wanted(path: &str, merged: bool) -> Option<&'static str> {
76 let name = if merged {
77 path.rsplit('/').next()?
78 } else {
79 path
80 };
81 [PLAYWRIGHT_CONFIGURATION, "frontend.json"]
82 .into_iter()
83 .find(|wanted| *wanted == name)
84 }
85}
86
87#[derive(Debug, Clone, Default, PartialEq, Eq)]
89pub struct Judgment {
90 pub outdated: Vec<String>,
93 pub own_tests: bool,
96 pub across: Vec<String>,
99}
100
101pub struct Checkout<'a> {
103 pub root: &'a Path,
104 pub tests: &'a BTreeSet<String>,
106 imports: Imports,
107 loaded_by_tests: std::cell::OnceCell<crate::test_loads::Loads>,
111}
112
113impl<'a> Checkout<'a> {
114 pub fn new(root: &'a Path, tests: &'a BTreeSet<String>) -> Self {
115 Self {
116 root,
117 tests,
118 imports: Imports::new(root),
119 loaded_by_tests: std::cell::OnceCell::new(),
120 }
121 }
122
123 pub fn places(&self, file: &str) -> bool {
128 self.loaded_by_tests
129 .get_or_init(|| {
130 self.imports.loads(
131 self.tests
132 .iter()
133 .filter(|file| reads_imports(file))
134 .map(String::as_str),
135 )
136 })
137 .files
138 .contains(file)
139 }
140
141 pub fn loads(
145 &self,
146 own: &BTreeSet<String>,
147 runners: Option<&BTreeSet<String>>,
148 configurations: &[String],
149 ) -> Option<crate::test_loads::Loads> {
150 if own.is_empty() || !own.iter().all(|file| reads_imports(file)) {
151 return None;
152 }
153 let mut entries = own.iter().cloned().collect::<Vec<_>>();
154 for configuration in configurations {
155 let used = configuration_runner(configuration)
156 .is_some_and(|runner| runners.is_some_and(|runners| runners.contains(runner)));
157 if used {
158 entries.push(configuration.clone());
159 entries.extend(self.imports.named_by(configuration));
160 }
161 }
162 Some(self.imports.loads(entries.iter().map(String::as_str)))
163 }
164}
165
166pub fn configuration_file(file: &str) -> bool {
169 crate::integrity::configuration_file(Path::new(file))
170}
171
172pub fn beside_the_tests(file: &str) -> bool {
176 let lower = file.to_ascii_lowercase();
177 lower
178 .split('/')
179 .next()
180 .is_some_and(|top| matches!(top, "test" | "tests" | "__tests__"))
181 || lower
182 .split(['/', '_', '.', '-'])
183 .any(|part| matches!(part, "test" | "spec"))
184}
185
186pub fn dependency_file(file: &str) -> bool {
187 let name = file.rsplit('/').next().unwrap_or(file);
188 matches!(
189 name,
190 "package.json"
191 | "package-lock.json"
192 | "npm-shrinkwrap.json"
193 | "pnpm-lock.yaml"
194 | "yarn.lock"
195 | "bun.lock"
196 | "bun.lockb"
197 )
198}
199
200fn playwright_shape(configuration: &Value) -> Value {
205 const PROJECT: [&str; 5] = ["name", "use", "testDir", "dependencies", "teardown"];
206 const CONFIG: [&str; 5] = [
208 "globalSetup",
209 "globalTeardown",
210 "webServer",
211 "use",
212 "testDir",
213 ];
214 let pick = |value: &Value, keys: &[&str]| {
215 Value::Object(
216 keys.iter()
217 .filter_map(|key| value.get(*key).map(|v| ((*key).to_owned(), v.clone())))
218 .collect(),
219 )
220 };
221 let mut shape = pick(configuration, &CONFIG);
222 shape["projects"] = Value::Array(
223 configuration["projects"]
224 .as_array()
225 .into_iter()
226 .flatten()
227 .map(|project| pick(project, &PROJECT))
228 .collect(),
229 );
230 shape
231}
232
233pub fn playwright_differences(run: &Value, reference: &Value) -> Vec<String> {
238 let (run, reference) = (playwright_shape(run), playwright_shape(reference));
239 let mut differences = Vec::new();
240 for key in [
241 "globalSetup",
242 "globalTeardown",
243 "webServer",
244 "use",
245 "testDir",
246 ] {
247 if run.get(key) != reference.get(key) {
248 differences.push(key.to_owned());
249 }
250 }
251 let projects = |shape: &Value| {
252 shape["projects"]
253 .as_array()
254 .into_iter()
255 .flatten()
256 .map(|project| {
257 (
258 project["name"].as_str().unwrap_or("").to_owned(),
259 project.clone(),
260 )
261 })
262 .collect::<BTreeMap<_, _>>()
263 };
264 let now = projects(&reference);
265 for (name, project) in projects(&run) {
266 let label = if name.is_empty() {
267 String::new()
268 } else {
269 format!("project {name}: ")
270 };
271 match now.get(&name) {
272 None => differences.push(format!("{label}removed")),
273 Some(current) => {
274 for key in ["use", "testDir", "dependencies", "teardown"] {
275 if project.get(key) != current.get(key) {
276 differences.push(format!("{label}{key}"));
277 }
278 }
279 }
280 }
281 }
282 differences
283}
284
285#[allow(clippy::too_many_arguments)]
292pub fn judge(
293 checkout: &Checkout<'_>,
294 label: &str,
295 recorded: &FileManifest,
296 now: &FileManifest,
297 text: &dyn Fn(&str) -> Option<String>,
298 own: Option<&BTreeSet<String>>,
299 ran: &Ran,
300 reference: Option<&Value>,
301 allow_changed: bool,
302) -> Judgment {
303 let mut judgment = Judgment::default();
304 let configurations = recorded
305 .keys()
306 .chain(now.keys())
307 .filter(|file| configuration_file(file))
308 .cloned()
309 .collect::<BTreeSet<_>>()
310 .into_iter()
311 .collect::<Vec<_>>();
312 let loads = own.and_then(|own| checkout.loads(own, ran.runners.as_ref(), &configurations));
313 let mut changed = recorded
314 .iter()
315 .filter(|(file, was)| now.get(*file).is_none_or(|is| !is.same_bytes(was)))
316 .map(|(file, _)| file.clone())
317 .collect::<BTreeSet<_>>();
318 changed.extend(
320 now.keys()
321 .filter(|file| !recorded.contains_key(*file) && configuration_file(file))
322 .cloned(),
323 );
324 let refuse = |judgment: &mut Judgment, file: &str, what: String| {
325 if allow_changed {
326 judgment.across.push(format!("{file} (accepted)"));
327 } else {
328 judgment.outdated.push(what);
329 }
330 };
331 for file in changed {
332 let gone = !now.contains_key(&file);
333 if own.is_some_and(|own| own.contains(&file)) {
334 judgment.own_tests = true;
335 judgment.outdated.push(format!(
336 "{file}, a file of its own tests, {}",
337 if gone { "is gone" } else { "has changed" }
338 ));
339 continue;
340 }
341 if checkout.tests.contains(&file) && own.is_some() {
344 continue;
345 }
346 if dependency_file(&file) {
347 judgment.outdated.push(format!("{file} has changed"));
348 continue;
349 }
350 if configuration_file(&file) {
351 let runner = configuration_runner(&file);
352 match (runner, ran.runners.as_ref()) {
353 (Some(runner), Some(runners)) if !runners.contains(runner) => {
354 judgment.across.push(format!(
355 "{file} ({runner} settings; `{label}` ran under {})",
356 runners.iter().cloned().collect::<Vec<_>>().join(", ")
357 ));
358 }
359 (Some("playwright"), Some(_)) => match (&ran.playwright, reference) {
360 (Some(then), Some(reference)) => {
361 let differences = playwright_differences(then, reference);
362 if differences.is_empty() {
363 judgment
364 .across
365 .push(format!("{file} (only settings that schedule tests)"));
366 } else {
367 refuse(
368 &mut judgment,
369 &file,
370 format!("{file}: {} changed", differences.join(", ")),
371 );
372 }
373 }
374 _ => refuse(&mut judgment, &file, format!("{file} has changed")),
375 },
376 _ => refuse(&mut judgment, &file, format!("{file} has changed")),
377 }
378 continue;
379 }
380 let placed = checkout.places(&file);
382 if let Some(loads) = &loads
383 && loads.open.is_none()
384 && placed
385 && !loads.files.contains(&file)
386 {
387 judgment
388 .across
389 .push(format!("{file} (`{label}` does not load it)"));
390 continue;
391 }
392 if gone {
393 refuse(&mut judgment, &file, format!("{file} is gone"));
394 continue;
395 }
396 let before = recorded.get(&file).and_then(|was| was.code.as_ref());
397 let after = now.get(&file).and_then(|is| is.code.as_ref());
398 if let (Some(before), Some(source)) = (before, text(&file))
399 && let Some(added) = only_added(before, &file, &source)
400 {
401 judgment
402 .across
403 .push(format!("{file} (only added {})", added.join(", ")));
404 continue;
405 }
406 let what = match (before, after) {
407 (Some(before), Some(after)) => {
408 format!(
409 "{file} changed: {}",
410 describe(before, after, &before.diff(after))
411 )
412 }
413 _ => format!("{file} has changed"),
414 };
415 refuse(&mut judgment, &file, what);
416 }
417 judgment
418}
419
420pub fn across(items: impl IntoIterator<Item = String>) -> Vec<String> {
423 let mut files = BTreeMap::<String, Vec<String>>::new();
424 for item in items {
425 let (file, why) = match item.split_once(" (") {
426 Some((file, why)) => (file.to_owned(), why.trim_end_matches(')').to_owned()),
427 None => (item, String::new()),
428 };
429 let whys = files.entry(file).or_default();
430 if !why.is_empty() && !whys.contains(&why) {
431 whys.push(why);
432 }
433 }
434 files
435 .into_iter()
436 .map(|(file, whys)| {
437 if whys.is_empty() {
438 file
439 } else {
440 format!("{file} ({})", whys.join("; "))
441 }
442 })
443 .collect()
444}
445
446pub fn checkout_files(
449 root: &Path,
450 recorded: &FileManifest,
451 configurations: &[String],
452) -> FileManifest {
453 recorded
454 .keys()
455 .chain(configurations)
456 .filter_map(|file| {
457 let text = std::fs::read_to_string(root.join(file)).ok()?;
458 Some((file.clone(), FileFingerprint::read(file, &text)))
459 })
460 .collect()
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use serde_json::json;
467
468 #[test]
469 fn scheduling_settings_do_not_change_what_a_test_reaches() {
470 let then = json!({
471 "webServer": [{"command": "npm start", "url": "http://127.0.0.1:3000"}],
472 "projects": [{"name": "chromium", "use": {"baseURL": "http://127.0.0.1:3000"}, "timeout": 30000, "retries": 0, "testMatch": "*.spec.ts"}],
473 "workers": 1,
474 });
475 let mut later = then.clone();
476 later["workers"] = json!(4);
477 later["projects"][0]["timeout"] = json!(45000);
478 later["projects"][0]["testMatch"] = json!(["*.spec.ts", "new/*.spec.ts"]);
479 later["projects"]
480 .as_array_mut()
481 .unwrap()
482 .push(json!({"name": "firefox", "use": {"browserName": "firefox"}}));
483 assert_eq!(playwright_differences(&then, &later), Vec::<String>::new());
484
485 let mut shaped = then.clone();
486 shaped["projects"][0]["use"]["extraHTTPHeaders"] = json!({"x-suite": "e2e"});
487 shaped["webServer"][0]["command"] = json!("npm run start:mock");
488 assert_eq!(
489 playwright_differences(&then, &shaped),
490 ["webServer", "project chromium: use"]
491 );
492 let single = json!({"use": {"baseURL": "http://127.0.0.1:3000"}, "timeout": 30000});
494 let mut longer = single.clone();
495 longer["timeout"] = json!(45000);
496 assert_eq!(
497 playwright_differences(&single, &longer),
498 Vec::<String>::new()
499 );
500 longer["use"]["locale"] = json!("de-DE");
501 assert_eq!(playwright_differences(&single, &longer), ["use"]);
502 }
503}