Skip to main content

supercov_engine/
served_tests.rs

1//! The browser tests a measured server answered: what the browser runner
2//! reported for each attempt, which a Python or Ruby server's run takes its
3//! tests from, and the rule that gives a request naming no test to one.
4
5use std::{
6    collections::{BTreeMap, BTreeSet},
7    path::Path,
8};
9
10use crate::coverage_report::TestProvenance;
11
12/// The worker a server's runtime records a request that names no test
13/// under, numbered within its process.
14pub const REQUEST_WORKER: &str = "supercov-request";
15
16/// A browser test whose requests a measured server answered: what the
17/// browser runner reported for it, which no runner of the server's language
18/// saw.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ServedTest {
21    /// As the server's runtime names the worker: `playwright:<worker id>`.
22    pub worker: String,
23    pub test_id: String,
24    pub retry: usize,
25    /// The test's name, title and file as the browser run gives them.
26    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    /// When the attempt ran, in milliseconds since the epoch, first and
33    /// last moment: what a request that named no test is judged by.
34    pub window: Option<(i64, i64)>,
35    /// The attempt's own id, as its runner's scope names it: what a take at
36    /// the test's edge is recorded under.
37    pub attempt_id: Option<String>,
38}
39
40/// One phase starting: in which process (its transport), where in its
41/// records, and of which attempt (worker, test, retry).
42pub type PhaseStart = (String, usize, Attempt);
43
44/// A test attempt: worker, test, retry.
45pub type Attempt = (String, String, usize);
46
47/// The suite's own test attempts, in the order each process ran them: what
48/// a request to a server the suite runs in its own process -- a live server
49/// a browser or the test itself calls -- is judged by. A process writes its
50/// records in order, so the attempt whose phase began last before the
51/// request did is the one running when it came: exactly, where the attempts
52/// were run one after another, and no one's where they interleave.
53pub struct InProcess {
54    /// By transport: where each phase began and whose, and whether the
55    /// process ran attempts at once.
56    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
92/// The attempt a request that named no test belongs to: the one running when
93/// it came, if only one was, among the attempts its process answered a
94/// request of where it answered any -- with a server per worker, the
95/// worker's test, whatever other workers ran meanwhile -- and among every
96/// attempt otherwise.
97pub 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
115/// Each browser test attempt the suite reported, read from the Playwright
116/// fixture's evidence lines.
117pub fn read(evidence_directory: &Path) -> Vec<ServedTest> {
118    read_journals(evidence_directory, |name| {
119        name.starts_with("playwright-worker-")
120    })
121}
122
123/// Each test attempt any runner reported -- node:test, Vitest, Jest, Mocha,
124/// AVA, tap, Playwright -- read from the evidence journals they all write.
125pub 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        /// The attempt's first and last moment, from fixtures that record it.
152        #[serde(default)]
153        span: Option<[i64; 2]>,
154        /// `background` for a record of work between tests: no test.
155        #[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            // The whole attempt where the fixture recorded it: a test runs
189            // between its actions too, and a span of its actions alone is a
190            // few milliseconds a sweep of the server's clock can miss.
191            // Otherwise, its first and last phase.
192            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        // The attempt, pauses and all; a fixture that records no span is
268        // judged by its phases, as before.
269        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    // A request to a server the suite runs itself is the test's whose phase
277    // began last before it in that process: by record, which a millisecond
278    // clock cannot tell apart when a test ends, the next starts and the
279    // request comes all in one millisecond.
280    #[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        // Tests that ran at once in one process are no one's.
301        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}