Skip to main content

supercov_engine/
run_currency.rs

1//! Whether a run is still a record of a checkout whose test-side files have
2//! moved on, judged file by file.
3//!
4//! A run is fingerprinted by domain -- source, tests, dependencies,
5//! configuration -- and any change in a domain made it out of date. For the
6//! source and the dependencies that is right. For the rest it was too blunt:
7//! the end-to-end suite's Playwright configuration or helpers changing made
8//! every unit run out of date, and a helper that only gained a function for a
9//! new test made every run that loads it one. Each file that differs is
10//! judged here by what it is and what the run's tests did with it.
11use 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
26/// The archive entry holding the Playwright configuration a run resolved.
27pub const PLAYWRIGHT_CONFIGURATION: &str = "playwright-configuration.json";
28
29/// What a run's archive says about how it ran, beyond its files.
30#[derive(Debug, Clone, Default)]
31pub struct Ran {
32    /// The runners its tests ran under, where its archive says.
33    pub runners: Option<BTreeSet<String>>,
34    /// The Playwright configuration as it resolved, where it ran Playwright.
35    pub playwright: Option<Value>,
36    /// The files it measured, from its coverage manifest's scope. A file of
37    /// the source outside them -- a simulator, a script -- is judged as a
38    /// helper is.
39    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    /// The same, from an archive already read.
54    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    /// Which of the two entries a path is, in a run or a merge of runs.
87    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/// The verdict on one run.
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct Judgment {
102    /// Files whose change makes the run a record of something else, each with
103    /// what moved in it.
104    pub outdated: Vec<String>,
105    /// Whether one of those is a file of the run's own tests: no option makes
106    /// a changed test's record its own.
107    pub own_tests: bool,
108    /// Whether one of those is one no option accepts: a file of its own
109    /// tests, or one it measured.
110    pub settled: bool,
111    /// Files that differ and do not reach the run, with why; and those the
112    /// user accepted.
113    pub across: Vec<String>,
114}
115
116/// One checkout's files, read once for every run judged against it.
117pub struct Checkout<'a> {
118    pub root: &'a Path,
119    /// Every file known to hold tests, from every run there is.
120    pub tests: &'a BTreeSet<String>,
121    imports: Imports,
122    /// What all the known test files load: a helper none of them loads is one
123    /// Supercov cannot place, so it counts against every run. Read when a
124    /// helper first needs placing.
125    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    /// Whether some known test file imports `file`: one that none does is
139    /// loaded from where Supercov cannot see -- a pattern in a configuration,
140    /// a command -- and may be loaded by any test. A run whose own imports
141    /// are all followed and miss it does not load it.
142    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    /// What a run's tests load: their files, and the configuration of each
157    /// runner they ran under with what it names. `None` where a file of
158    /// theirs is not one Supercov reads imports from.
159    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
181/// A file that sets how tests or builds run, by the rule the fingerprint's
182/// configuration domain uses.
183pub fn configuration_file(file: &str) -> bool {
184    crate::integrity::configuration_file(Path::new(file))
185}
186
187/// A file that sits with the tests by its path, as the fingerprint's test
188/// domain finds them: in a `test`, `tests` or `__tests__` directory, or named
189/// for tests.
190pub 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
215/// The Playwright settings that decide what a test does, as opposed to when
216/// and how often it runs: the rest -- timeouts, retries, workers, reporters,
217/// which files and names to run -- schedules tests and changes nothing a test
218/// that ran reaches.
219fn playwright_shape(configuration: &Value) -> Value {
220    const PROJECT: [&str; 5] = ["name", "use", "testDir", "dependencies", "teardown"];
221    // At the top level `use` and `testDir` are what every project inherits.
222    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
248/// Whether the run's Playwright configuration and the reference's do the same
249/// to the run's tests: every project it had is there and set alike, and the
250/// servers and global setup are the same. A project added since runs none of
251/// its tests. Where they differ, which settings did.
252pub 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/// What the files a run was recorded with say against `now`, the files of the
301/// checkout or of the later run it is merged into.
302///
303/// `text` gives a file's current text where it is the text `now` describes;
304/// `reference` is how Playwright resolved for the later run, where one did;
305/// `label` names the run in what is said about it.
306#[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    // A configuration that did not exist then can change a runner too.
334    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        // Another run's test: this run did not run it. Where the run does not
358        // say which files are its own, every test file counts.
359        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        // What the run measured has moved on: its numbers are of other code.
397        // Where the run does not say what it measured, any file not beside
398        // the tests may be.
399        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        // A file tests may load: a helper, a fixture, a support module, or
412        // source outside what is measured.
413        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
452/// What several judgments let through, one entry per file: `file (why;
453/// why)`.
454pub 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
478/// The checkout's own fingerprints of the files a run was recorded with, and
479/// of every configuration file there is now.
480pub 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        // A configuration without projects sets `use` for all of them.
525        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}