supercov_engine/
support_reach.rs1use 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
23pub const ARCHIVE_PATH: &str = "support-reach.json";
25pub const DIRECTORY_VARIABLE: &str = "SUPERCOV_SUPPORT_DIR";
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
29pub struct SupportReach {
30 pub files: BTreeMap<String, BTreeSet<String>>,
33 #[serde(default)]
35 pub names: BTreeMap<String, String>,
36}
37
38impl SupportReach {
39 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#[derive(Default)]
50struct Process {
51 files: BTreeSet<String>,
52 served: BTreeSet<String>,
53 unscoped: Vec<i64>,
54}
55
56pub 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 (!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 if process.served.is_empty() && process.unscoped.is_empty() {
119 continue;
120 }
121 any = true;
122 let mut tests = process.served;
123 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
150fn 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 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 serde_json::json!({ "unscoped": 150 }),
219 ),
220 )
221 .unwrap();
222 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}