1use std::{
6 collections::{BTreeMap, BTreeSet},
7 path::Path,
8};
9
10use crate::coverage_report::TestProvenance;
11
12pub const REQUEST_WORKER: &str = "supercov-request";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ServedTest {
21 pub worker: String,
23 pub test_id: String,
24 pub retry: usize,
25 pub name: String,
27 pub title: String,
28 pub file: String,
29 pub status: String,
30 pub expected_status: String,
31 pub provenance: TestProvenance,
32 pub window: Option<(i64, i64)>,
35 pub attempt_id: Option<String>,
38}
39
40pub type PhaseStart = (String, usize, Attempt);
43
44pub type Attempt = (String, String, usize);
46
47pub struct InProcess {
54 by_transport: BTreeMap<String, (Vec<(usize, Attempt)>, bool)>,
57}
58
59impl InProcess {
60 pub fn new(phases: &[PhaseStart]) -> Self {
61 let mut by_transport = BTreeMap::<String, (Vec<_>, bool)>::new();
62 for (transport, seq, attempt) in phases {
63 by_transport
64 .entry(transport.clone())
65 .or_default()
66 .0
67 .push((*seq, attempt.clone()));
68 }
69 for (starts, interleaved) in by_transport.values_mut() {
70 starts.sort();
71 let mut left = BTreeSet::new();
72 for pair in starts.windows(2) {
73 if pair[0].1 != pair[1].1 {
74 left.insert(pair[0].1.clone());
75 *interleaved |= left.contains(&pair[1].1);
76 }
77 }
78 }
79 Self { by_transport }
80 }
81
82 pub fn owner(&self, transport: &str, seq: usize) -> Option<&Attempt> {
83 let (starts, interleaved) = self.by_transport.get(transport)?;
84 if *interleaved {
85 return None;
86 }
87 let before = starts.partition_point(|(start, _)| *start < seq);
88 before.checked_sub(1).map(|index| &starts[index].1)
89 }
90}
91
92pub fn owner<'a>(
98 served: &'a [ServedTest],
99 answered: Option<&BTreeSet<(String, String, usize)>>,
100 at: i64,
101) -> Option<&'a ServedTest> {
102 let mut running = served.iter().filter(|test| {
103 answered.is_none_or(|attempts| {
104 attempts.contains(&(test.worker.clone(), test.test_id.clone(), test.retry))
105 }) && test
106 .window
107 .is_some_and(|(start, end)| start <= at && at <= end)
108 });
109 match (running.next(), running.next()) {
110 (Some(only), None) => Some(only),
111 _ => None,
112 }
113}
114
115pub fn read(evidence_directory: &Path) -> Vec<ServedTest> {
118 read_journals(evidence_directory, |name| {
119 name.starts_with("playwright-worker-")
120 })
121}
122
123pub fn read_attempts(evidence_directory: &Path) -> Vec<ServedTest> {
126 read_journals(evidence_directory, |_| true)
127}
128
129fn read_journals(evidence_directory: &Path, wanted: impl Fn(&str) -> bool) -> Vec<ServedTest> {
130 #[derive(serde::Deserialize)]
131 #[serde(rename_all = "camelCase")]
132 struct Scope {
133 worker_id: String,
134 #[serde(default)]
135 attempt_id: Option<String>,
136 }
137 #[derive(serde::Deserialize)]
138 #[serde(rename_all = "camelCase")]
139 struct Attempt {
140 test_id: String,
141 scope: Scope,
142 test: String,
143 test_file: String,
144 title: String,
145 retry: usize,
146 status: String,
147 expected_status: Option<String>,
148 provenance: crate::coverage_report::TestProvenance,
149 #[serde(default)]
150 phases: Vec<Phase>,
151 #[serde(default)]
153 span: Option<[i64; 2]>,
154 #[serde(default)]
156 role: Option<String>,
157 }
158 #[derive(serde::Deserialize)]
159 #[serde(rename_all = "camelCase")]
160 struct Phase {
161 started_at_ms: i64,
162 ended_at_ms: Option<i64>,
163 }
164 let mut served = BTreeMap::new();
165 let Ok(entries) = std::fs::read_dir(evidence_directory) else {
166 return Vec::new();
167 };
168 let mut files = entries
169 .filter_map(Result::ok)
170 .map(|entry| entry.path())
171 .filter(|path| {
172 path.file_name()
173 .and_then(|name| name.to_str())
174 .is_some_and(|name| wanted(name) && name.ends_with(".mcdc.jsonl"))
175 })
176 .collect::<Vec<_>>();
177 files.sort();
178 for file in files {
179 let Ok(text) = std::fs::read_to_string(&file) else {
180 continue;
181 };
182 for attempt in text
183 .lines()
184 .filter_map(|line| serde_json::from_str::<Attempt>(line).ok())
185 .filter(|attempt| attempt.role.as_deref().is_none_or(|role| role == "test"))
186 {
187 let worker = format!("playwright:{}", attempt.scope.worker_id);
188 let (start, end) = match attempt.span {
193 Some([start, end]) => (Some(start), Some(end)),
194 None => (
195 attempt.phases.iter().map(|phase| phase.started_at_ms).min(),
196 attempt
197 .phases
198 .iter()
199 .map(|phase| phase.ended_at_ms.unwrap_or(phase.started_at_ms))
200 .max(),
201 ),
202 };
203 served
204 .entry((worker.clone(), attempt.test_id.clone(), attempt.retry))
205 .or_insert(ServedTest {
206 worker,
207 test_id: attempt.test_id,
208 retry: attempt.retry,
209 name: attempt.test,
210 title: attempt.title,
211 file: attempt.test_file,
212 status: attempt.status,
213 expected_status: attempt.expected_status.unwrap_or_else(|| "passed".into()),
214 provenance: attempt.provenance,
215 window: start.zip(end),
216 attempt_id: attempt.scope.attempt_id,
217 });
218 }
219 }
220 served.into_values().collect()
221}
222
223#[cfg(test)]
224mod tests {
225
226 #[test]
227 fn a_test_is_running_for_its_whole_attempt_where_the_fixture_says_so() {
228 let directory = std::env::temp_dir().join(format!(
229 "supercov-served-span-{}-{}",
230 std::process::id(),
231 std::time::SystemTime::now()
232 .duration_since(std::time::UNIX_EPOCH)
233 .unwrap()
234 .as_nanos()
235 ));
236 std::fs::create_dir_all(&directory).unwrap();
237 let attempt = |id: &str, span: Option<[i64; 2]>| {
238 let mut line = serde_json::json!({
239 "testId": id,
240 "scope": {"workerId": "w0"},
241 "test": id,
242 "testFile": "a.spec.js",
243 "title": id,
244 "retry": 0,
245 "status": "passed",
246 "provenance": {"runner": "playwright", "kind": "e2e", "source": "explicit"},
247 "phases": [{"id": "p", "kind": "action", "operation": "get", "startedAtMs": 1050, "endedAtMs": 1053}],
248 });
249 if let Some(span) = span {
250 line["span"] = serde_json::json!(span);
251 }
252 line.to_string()
253 };
254 std::fs::write(
255 directory.join("playwright-worker-0.mcdc.jsonl"),
256 format!(
257 "{}\n{}\n",
258 attempt("spanned", Some([1000, 1110])),
259 attempt("older", None)
260 ),
261 )
262 .unwrap();
263 let served = read(&directory)
264 .into_iter()
265 .map(|test| (test.test_id, test.window))
266 .collect::<std::collections::BTreeMap<_, _>>();
267 assert_eq!(served["spanned"], Some((1000, 1110)));
270 assert_eq!(served["older"], Some((1050, 1053)));
271 std::fs::remove_dir_all(directory).unwrap();
272 }
273
274 use super::*;
275
276 #[test]
281 fn a_request_in_process_is_the_test_running_before_it() {
282 let attempt = |test: &str| ("main".to_owned(), test.to_owned(), 0);
283 let phases = [
284 ("a".to_owned(), 1, attempt("one")),
285 ("a".to_owned(), 2, attempt("one")),
286 ("a".to_owned(), 5, attempt("one")),
287 ("a".to_owned(), 6, attempt("two")),
288 ("a".to_owned(), 7, attempt("two")),
289 ];
290 let order = InProcess::new(&phases);
291 assert_eq!(order.owner("a", 0), None, "before any test");
292 assert_eq!(order.owner("a", 3), Some(&attempt("one")));
293 assert_eq!(
294 order.owner("a", 6),
295 Some(&attempt("one")),
296 "as the next test begins"
297 );
298 assert_eq!(order.owner("a", 8), Some(&attempt("two")));
299 assert_eq!(order.owner("b", 3), None, "another process ran no test");
300 let interleaved = InProcess::new(&[
302 ("a".to_owned(), 1, attempt("one")),
303 ("a".to_owned(), 2, attempt("two")),
304 ("a".to_owned(), 3, attempt("one")),
305 ]);
306 assert_eq!(interleaved.owner("a", 4), None);
307 }
308}