Skip to main content

supercov_engine/
support_reach.rs

1//! Which tests reached which project files through the processes that served
2//! them. A suite's mock servers are processes of their own, started by
3//! Playwright's `webServer` and outside what is measured; nothing ties their
4//! files to tests, so a change to one counted for every test, and a suite
5//! measured again in full after any mock changed. Each Node process under
6//! the run notes the project files it loaded and read, and the tests it
7//! served (`register.mjs`, `resolve-loader.mjs`, `runtime.mjs`). A process
8//! that served requests reaches every file it loaded for each of those
9//! tests: a mock keeps state between tests, so its files are judged whole,
10//! never by route.
11
12use std::{
13    collections::{BTreeMap, BTreeSet},
14    fs,
15    path::Path,
16};
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::served_tests::ServedTest;
22
23/// The archive entry the reach is kept in.
24pub const ARCHIVE_PATH: &str = "support-reach.json";
25/// Where each process notes what it loaded, read and served.
26pub const DIRECTORY_VARIABLE: &str = "SUPERCOV_SUPPORT_DIR";
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
29pub struct SupportReach {
30    /// Each project file a serving process loaded or read, relative to the
31    /// project, and the tests (by id) those processes served.
32    pub files: BTreeMap<String, BTreeSet<String>>,
33    /// Each of those tests' name, as the run names it.
34    #[serde(default)]
35    pub names: BTreeMap<String, String>,
36}
37
38impl SupportReach {
39    /// One reach from several (a merge's runs).
40    pub fn extend(&mut self, other: SupportReach) {
41        for (file, tests) in other.files {
42            self.files.entry(file).or_default().extend(tests);
43        }
44        self.names.extend(other.names);
45    }
46}
47
48/// What one process noted.
49#[derive(Default)]
50struct Process {
51    files: BTreeSet<String>,
52    served: BTreeSet<String>,
53    unscoped: Vec<i64>,
54}
55
56/// The reach from the processes' notes in `directory`. `workspace` is the
57/// copy the processes ran in; `served` the browser run's tests, with when
58/// each ran. None when no process served a request.
59pub fn read(directory: &Path, workspace: &Path, served: &[ServedTest]) -> Option<SupportReach> {
60    let roots = [
61        Some(workspace.to_path_buf()),
62        fs::canonicalize(workspace).ok(),
63    ]
64    .into_iter()
65    .flatten()
66    .collect::<Vec<_>>();
67    let relative = |noted: &str| -> Option<String> {
68        let path = match noted.strip_prefix("file://") {
69            Some(rest) => url_path(rest),
70            None => noted.to_owned(),
71        };
72        let path = Path::new(&path);
73        roots.iter().find_map(|root| {
74            let relative = path.strip_prefix(root).ok()?;
75            let relative = relative.to_string_lossy().replace('\\', "/");
76            // Supercov's own files, under the copy's `.supercov/`, and
77            // dependencies are no project file.
78            (!relative.is_empty()
79                && !relative.starts_with(".supercov/")
80                && !relative.split('/').any(|part| part == "node_modules"))
81            .then_some(relative)
82        })
83    };
84    let mut processes = Vec::new();
85    for entry in fs::read_dir(directory).ok()?.flatten() {
86        let path = entry.path();
87        if path
88            .extension()
89            .is_none_or(|extension| extension != "jsonl")
90        {
91            continue;
92        }
93        let Ok(text) = fs::read_to_string(&path) else {
94            continue;
95        };
96        let mut process = Process::default();
97        for line in text.lines() {
98            let Ok(record) = serde_json::from_str::<Value>(line) else {
99                continue;
100            };
101            if let Some(noted) = record["loaded"].as_str().or(record["read"].as_str())
102                && let Some(file) = relative(noted)
103            {
104                process.files.insert(file);
105            } else if let Some(test) = record["served"].as_str() {
106                process.served.insert(test.to_owned());
107            } else if let Some(at) = record["unscoped"].as_i64() {
108                process.unscoped.push(at);
109            }
110        }
111        processes.push(process);
112    }
113    let mut reach = SupportReach::default();
114    let mut any = false;
115    for process in processes {
116        // A process no request reached is not a server: a test worker loads
117        // the tests' helpers, which their imports already place.
118        if process.served.is_empty() && process.unscoped.is_empty() {
119            continue;
120        }
121        any = true;
122        let mut tests = process.served;
123        // A request that named no test is the test's that was running then,
124        // if only one was.
125        for at in process.unscoped {
126            let mut running = served.iter().filter(|test| {
127                test.window
128                    .is_some_and(|(start, end)| start <= at && at <= end)
129            });
130            if let (Some(test), None) = (running.next(), running.next()) {
131                tests.insert(test.test_id.clone());
132            }
133        }
134        for test in &tests {
135            if let Some(served) = served.iter().find(|served| served.test_id == *test) {
136                reach.names.insert(test.clone(), served.name.clone());
137            }
138        }
139        for file in process.files {
140            reach
141                .files
142                .entry(file)
143                .or_default()
144                .extend(tests.iter().cloned());
145        }
146    }
147    any.then_some(reach)
148}
149
150/// A file URL's path, its percent escapes decoded.
151fn url_path(rest: &str) -> String {
152    let rest = rest.strip_prefix("localhost").unwrap_or(rest);
153    let bytes = rest.as_bytes();
154    let mut decoded = Vec::with_capacity(bytes.len());
155    let mut index = 0;
156    while index < bytes.len() {
157        if bytes[index] == b'%'
158            && index + 2 < bytes.len()
159            && let Ok(byte) = u8::from_str_radix(&rest[index + 1..index + 3], 16)
160        {
161            decoded.push(byte);
162            index += 3;
163            continue;
164        }
165        decoded.push(bytes[index]);
166        index += 1;
167    }
168    let path = String::from_utf8_lossy(&decoded).into_owned();
169    // `file:///C:/x` on Windows.
170    if path.len() > 2 && path.as_bytes()[0] == b'/' && path.as_bytes()[2] == b':' {
171        return path[1..].to_owned();
172    }
173    path
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn served(id: &str, window: (i64, i64)) -> ServedTest {
181        ServedTest {
182            worker: "playwright:0".into(),
183            test_id: id.into(),
184            retry: 0,
185            name: id.into(),
186            title: id.into(),
187            file: "tests/a.spec.ts".into(),
188            status: "passed".into(),
189            expected_status: "passed".into(),
190            provenance: Default::default(),
191            window: Some(window),
192        }
193    }
194
195    #[test]
196    fn a_serving_process_reaches_its_files_for_the_tests_it_served() {
197        let root = std::env::temp_dir().join(format!(
198            "supercov-support-{}-{}",
199            std::process::id(),
200            line!()
201        ));
202        let workspace = root.join("copy");
203        let directory = root.join("support");
204        fs::create_dir_all(&directory).unwrap();
205        let mock = format!(
206            "file://{}/tests/mocks/stripe%20server.mjs",
207            workspace.display()
208        );
209        let data = workspace.join("tests/mocks/stripe-api/requests.json");
210        fs::write(
211            directory.join("1-a.jsonl"),
212            format!(
213                "{}\n{}\n{}\n{}\n",
214                serde_json::json!({ "loaded": mock }),
215                serde_json::json!({ "read": data.display().to_string() }),
216                serde_json::json!({ "served": "t-pay" }),
217                // A webhook while only t-refund ran.
218                serde_json::json!({ "unscoped": 150 }),
219            ),
220        )
221        .unwrap();
222        // A test worker: loads helpers, serves nothing.
223        fs::write(
224            directory.join("2-b.jsonl"),
225            format!("{}\n", serde_json::json!({ "loaded": format!("file://{}/tests/helpers.ts", workspace.display()) })),
226        )
227        .unwrap();
228        let reach = read(
229            &directory,
230            &workspace,
231            &[served("t-pay", (0, 100)), served("t-refund", (120, 200))],
232        )
233        .unwrap();
234        let both = BTreeSet::from(["t-pay".to_owned(), "t-refund".to_owned()]);
235        assert_eq!(
236            reach.files.get("tests/mocks/stripe server.mjs"),
237            Some(&both)
238        );
239        assert_eq!(
240            reach.files.get("tests/mocks/stripe-api/requests.json"),
241            Some(&both)
242        );
243        assert!(!reach.files.contains_key("tests/helpers.ts"));
244        assert_eq!(
245            reach.names.get("t-refund").map(String::as_str),
246            Some("t-refund")
247        );
248        fs::remove_dir_all(root).unwrap();
249    }
250}