use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::served_tests::ServedTest;
pub const ARCHIVE_PATH: &str = "support-reach.json";
pub const DIRECTORY_VARIABLE: &str = "SUPERCOV_SUPPORT_DIR";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SupportReach {
pub files: BTreeMap<String, BTreeSet<String>>,
#[serde(default)]
pub names: BTreeMap<String, String>,
}
impl SupportReach {
pub fn extend(&mut self, other: SupportReach) {
for (file, tests) in other.files {
self.files.entry(file).or_default().extend(tests);
}
self.names.extend(other.names);
}
}
#[derive(Default)]
struct Process {
files: BTreeSet<String>,
served: BTreeSet<String>,
unscoped: Vec<i64>,
}
pub fn read(directory: &Path, workspace: &Path, served: &[ServedTest]) -> Option<SupportReach> {
let roots = [
Some(workspace.to_path_buf()),
fs::canonicalize(workspace).ok(),
]
.into_iter()
.flatten()
.collect::<Vec<_>>();
let relative = |noted: &str| -> Option<String> {
let path = match noted.strip_prefix("file://") {
Some(rest) => url_path(rest),
None => noted.to_owned(),
};
let path = Path::new(&path);
roots.iter().find_map(|root| {
let relative = path.strip_prefix(root).ok()?;
let relative = relative.to_string_lossy().replace('\\', "/");
(!relative.is_empty()
&& !relative.starts_with(".supercov/")
&& !relative.split('/').any(|part| part == "node_modules"))
.then_some(relative)
})
};
let mut processes = Vec::new();
for entry in fs::read_dir(directory).ok()?.flatten() {
let path = entry.path();
if path
.extension()
.is_none_or(|extension| extension != "jsonl")
{
continue;
}
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
let mut process = Process::default();
for line in text.lines() {
let Ok(record) = serde_json::from_str::<Value>(line) else {
continue;
};
if let Some(noted) = record["loaded"].as_str().or(record["read"].as_str())
&& let Some(file) = relative(noted)
{
process.files.insert(file);
} else if let Some(test) = record["served"].as_str() {
process.served.insert(test.to_owned());
} else if let Some(at) = record["unscoped"].as_i64() {
process.unscoped.push(at);
}
}
processes.push(process);
}
let mut reach = SupportReach::default();
let mut any = false;
for process in processes {
if process.served.is_empty() && process.unscoped.is_empty() {
continue;
}
any = true;
let mut tests = process.served;
for at in process.unscoped {
let mut running = served.iter().filter(|test| {
test.window
.is_some_and(|(start, end)| start <= at && at <= end)
});
if let (Some(test), None) = (running.next(), running.next()) {
tests.insert(test.test_id.clone());
}
}
for test in &tests {
if let Some(served) = served.iter().find(|served| served.test_id == *test) {
reach.names.insert(test.clone(), served.name.clone());
}
}
for file in process.files {
reach
.files
.entry(file)
.or_default()
.extend(tests.iter().cloned());
}
}
any.then_some(reach)
}
fn url_path(rest: &str) -> String {
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
let bytes = rest.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%'
&& index + 2 < bytes.len()
&& let Ok(byte) = u8::from_str_radix(&rest[index + 1..index + 3], 16)
{
decoded.push(byte);
index += 3;
continue;
}
decoded.push(bytes[index]);
index += 1;
}
let path = String::from_utf8_lossy(&decoded).into_owned();
if path.len() > 2 && path.as_bytes()[0] == b'/' && path.as_bytes()[2] == b':' {
return path[1..].to_owned();
}
path
}
#[cfg(test)]
mod tests {
use super::*;
fn served(id: &str, window: (i64, i64)) -> ServedTest {
ServedTest {
worker: "playwright:0".into(),
test_id: id.into(),
retry: 0,
name: id.into(),
title: id.into(),
file: "tests/a.spec.ts".into(),
status: "passed".into(),
expected_status: "passed".into(),
provenance: Default::default(),
window: Some(window),
}
}
#[test]
fn a_serving_process_reaches_its_files_for_the_tests_it_served() {
let root = std::env::temp_dir().join(format!(
"supercov-support-{}-{}",
std::process::id(),
line!()
));
let workspace = root.join("copy");
let directory = root.join("support");
fs::create_dir_all(&directory).unwrap();
let mock = format!(
"file://{}/tests/mocks/stripe%20server.mjs",
workspace.display()
);
let data = workspace.join("tests/mocks/stripe-api/requests.json");
fs::write(
directory.join("1-a.jsonl"),
format!(
"{}\n{}\n{}\n{}\n",
serde_json::json!({ "loaded": mock }),
serde_json::json!({ "read": data.display().to_string() }),
serde_json::json!({ "served": "t-pay" }),
serde_json::json!({ "unscoped": 150 }),
),
)
.unwrap();
fs::write(
directory.join("2-b.jsonl"),
format!("{}\n", serde_json::json!({ "loaded": format!("file://{}/tests/helpers.ts", workspace.display()) })),
)
.unwrap();
let reach = read(
&directory,
&workspace,
&[served("t-pay", (0, 100)), served("t-refund", (120, 200))],
)
.unwrap();
let both = BTreeSet::from(["t-pay".to_owned(), "t-refund".to_owned()]);
assert_eq!(
reach.files.get("tests/mocks/stripe server.mjs"),
Some(&both)
);
assert_eq!(
reach.files.get("tests/mocks/stripe-api/requests.json"),
Some(&both)
);
assert!(!reach.files.contains_key("tests/helpers.ts"));
assert_eq!(
reach.names.get("t-refund").map(String::as_str),
Some("t-refund")
);
fs::remove_dir_all(root).unwrap();
}
}