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    /// Which of its tests reached which files through the processes that
41    /// served them (support_reach.rs), where it recorded that.
42    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    /// The same, from an archive already read.
57    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    /// Which of the two entries a path is, in a run or a merge of runs.
98    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/// The verdict on one run.
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct Judgment {
118    /// Files whose change makes the run a record of something else, each with
119    /// what moved in it.
120    pub outdated: Vec<String>,
121    /// Whether one of those is a file of the run's own tests: no option makes
122    /// a changed test's record its own.
123    pub own_tests: bool,
124    /// Whether one of those is one no option accepts: a file of its own
125    /// tests, or one it measured.
126    pub settled: bool,
127    /// Files that differ and do not reach the run, with why; and those the
128    /// user accepted.
129    pub across: Vec<String>,
130    /// Files that changed and that only some of its tests reached, through
131    /// a process that served them: those tests are a record of something
132    /// else, and the rest still of what they ran (support_reach.rs).
133    pub reached: BTreeMap<String, BTreeSet<String>>,
134}
135
136/// One checkout's files, read once for every run judged against it.
137pub struct Checkout<'a> {
138    pub root: &'a Path,
139    /// Every file known to hold tests, from every run there is.
140    pub tests: &'a BTreeSet<String>,
141    imports: Imports,
142    /// What all the known test files load: a helper none of them loads is one
143    /// Supercov cannot place, so it counts against every run. Read when a
144    /// helper first needs placing.
145    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    /// Whether some known test file imports `file`: one that none does is
159    /// loaded from where Supercov cannot see -- a pattern in a configuration,
160    /// a command -- and may be loaded by any test. A run whose own imports
161    /// are all followed and miss it does not load it.
162    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    /// What a run's tests load: their files, and the configuration of each
177    /// runner they ran under with what it names. `None` where a file of
178    /// theirs is not one Supercov reads imports from.
179    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
201/// A file that sets how tests or builds run, by the rule the fingerprint's
202/// configuration domain uses.
203pub fn configuration_file(file: &str) -> bool {
204    crate::integrity::configuration_file(Path::new(file))
205}
206
207/// A file that sits with the tests by its path, as the fingerprint's test
208/// domain finds them: in a `test`, `tests` or `__tests__` directory, or named
209/// for tests.
210pub 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
235/// The Playwright settings that decide what a test does, as opposed to when
236/// and how often it runs: the rest -- timeouts, retries, workers, reporters,
237/// which files and names to run -- schedules tests and changes nothing a test
238/// that ran reaches.
239fn playwright_shape(configuration: &Value) -> Value {
240    const PROJECT: [&str; 5] = ["name", "use", "testDir", "dependencies", "teardown"];
241    // At the top level `use` and `testDir` are what every project inherits.
242    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
268/// Whether the run's Playwright configuration and the reference's do the same
269/// to the run's tests: every project it had is there and set alike, and the
270/// servers and global setup are the same. A project added since runs none of
271/// its tests. Where they differ, which settings did.
272pub 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/// What the files a run was recorded with say against `now`, the files of the
321/// checkout or of the later run it is merged into.
322///
323/// `text` gives a file's current text where it is the text `now` describes;
324/// `reference` is how Playwright resolved for the later run, where one did;
325/// `label` names the run in what is said about it.
326#[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    // A configuration that did not exist then can change a runner too.
354    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        // Another run's test: this run did not run it. Where the run does not
378        // say which files are its own, every test file counts.
379        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        // What the run measured has moved on: its numbers are of other code.
417        // Where the run does not say what it measured, any file not beside
418        // the tests may be.
419        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        // A file a process that served the run's tests loaded or read -- a
432        // mock server's -- reached those tests and no others. A file no such
433        // process loaded is judged by what the tests import, as before.
434        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        // A file tests may load: a helper, a fixture, a support module, or
452        // source outside what is measured.
453        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
492/// What several judgments let through, one entry per file: `file (why;
493/// why)`.
494pub 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
518/// The checkout's own fingerprints of the files a run was recorded with, and
519/// of every configuration file there is now.
520pub 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        // A configuration without projects sets `use` for all of them.
565        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}