Skip to main content

supercov_engine/
rust_wasm.rs

1//! A Rust crate compiled to wasm that JavaScript tests load: a library
2//! wasm-bindgen exposes to a Node suite (Vitest, Jest, node:test), or the
3//! wasm of a web app a Playwright suite drives.
4//!
5//! The crate is instrumented in the suite's copy, and the suite's own command
6//! builds it there (`wasm-pack build`, `cargo build` and `wasm-bindgen`), so
7//! the module the tests load carries the probes. Its regions live in the
8//! module's memory; the JavaScript runtime remembers each instance and takes
9//! the regions at every test's edges, in the test process and, through the
10//! Playwright fixture, in each page. Each take belongs to the test that just
11//! ended, exactly, and the run publishes them as a Rust run of its own.
12
13use std::{
14    collections::BTreeMap,
15    fs,
16    io::Write,
17    path::{Path, PathBuf},
18};
19
20use base64::Engine as _;
21use serde_json::Value;
22
23use crate::{
24    integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
25    lifecycle::{note_kept_evidence, remove_stored_tree_deferred},
26    run_store::{RunIntegrity, RunMetadata},
27    rust_project::PreparedRustProject,
28    rust_wasm_evidence::{Owner, WasmRecord, WasmRun},
29    served_tests::ServedTest,
30};
31
32/// The crates under `root` that compile to a wasm library: a `cdylib` that
33/// depends on `wasm-bindgen`. A Worker's crate, which `worker-build` builds,
34/// is measured by `rust_worker` instead.
35fn wasm_crates(root: &Path) -> Vec<PathBuf> {
36    const SKIPPED: &[&str] = &[
37        "node_modules",
38        "target",
39        "build",
40        "dist",
41        "pkg",
42        ".git",
43        ".supercov",
44        ".wrangler",
45    ];
46    let mut found = Vec::new();
47    let mut directories = vec![(root.to_path_buf(), 0)];
48    while let Some((directory, depth)) = directories.pop() {
49        let Ok(entries) = fs::read_dir(&directory) else {
50            continue;
51        };
52        for entry in entries.flatten() {
53            let path = entry.path();
54            let name = entry.file_name();
55            let Some(name) = name.to_str() else {
56                continue;
57            };
58            let Ok(kind) = entry.file_type() else {
59                continue;
60            };
61            if kind.is_dir() {
62                if depth < 6 && !SKIPPED.contains(&name) {
63                    directories.push((path, depth + 1));
64                }
65                continue;
66            }
67            if kind.is_file() && name == "Cargo.toml" && is_wasm_library(&path) {
68                found.push(path);
69            }
70        }
71    }
72    found.sort();
73    found
74}
75
76fn is_wasm_library(manifest: &Path) -> bool {
77    let Ok(text) = fs::read_to_string(manifest) else {
78        return false;
79    };
80    let Ok(document) = text.parse::<toml::Table>() else {
81        return false;
82    };
83    let cdylib = document
84        .get("lib")
85        .and_then(|lib| lib.get("crate-type"))
86        .and_then(toml::Value::as_array)
87        .is_some_and(|types| types.iter().any(|kind| kind.as_str() == Some("cdylib")));
88    let depends = |table: Option<&toml::Value>| {
89        table
90            .and_then(|dependencies| dependencies.get("wasm-bindgen"))
91            .is_some()
92    };
93    // `[dependencies]`, or `[target.'cfg(..)'.dependencies]`.
94    let bindgen = depends(document.get("dependencies"))
95        || document
96            .get("target")
97            .and_then(toml::Value::as_table)
98            .is_some_and(|targets| {
99                targets
100                    .values()
101                    .any(|target| depends(target.get("dependencies")))
102            });
103    let worker = ["wrangler.toml", "wrangler.json", "wrangler.jsonc"]
104        .iter()
105        .any(|name| manifest.with_file_name(name).is_file());
106    cdylib && bindgen && !worker
107}
108
109/// The wasm crates of one Cargo workspace JavaScript tests may load.
110pub struct RustWasmServer {
111    project: PreparedRustProject,
112    integrity: RunIntegrity,
113    source_inputs: crate::source_manifest::Inputs,
114    source_roots: Option<Vec<String>>,
115    /// The copy, whose `.supercov/evidence/<run>` holds the tests' records.
116    workspace: PathBuf,
117    /// Where the JavaScript runtime writes its takes.
118    records: PathBuf,
119    /// `.supercov/work/<run>/rust-wasm-<n>`.
120    directory: PathBuf,
121}
122
123impl RustWasmServer {
124    /// Whether `root` holds a wasm library JavaScript tests could load.
125    pub fn present(root: &Path) -> bool {
126        !wasm_crates(root).is_empty()
127    }
128
129    /// Instrument the copy's wasm crates, one server per Cargo workspace.
130    pub fn prepare(
131        root: &Path,
132        workspace: &Path,
133        server_evidence_root: &Path,
134        run_id: &str,
135        command: &[String],
136    ) -> Result<Vec<Self>, String> {
137        let mut by_workspace = BTreeMap::<PathBuf, ()>::new();
138        for manifest in wasm_crates(workspace) {
139            let crate_directory = manifest.parent().unwrap_or(workspace).to_owned();
140            let cargo_root = crate::rust_worker::cargo_workspace_root(&crate_directory)
141                .unwrap_or(crate_directory);
142            by_workspace.insert(cargo_root, ());
143        }
144        let mut servers = Vec::new();
145        for (index, cargo_root) in by_workspace.into_keys().enumerate() {
146            let Ok(relative) = cargo_root.strip_prefix(workspace) else {
147                continue;
148            };
149            let source_roots = crate::source_discovery::configured_source_roots(
150                &std::env::vars().collect::<BTreeMap<_, _>>(),
151            );
152            // The project's inputs, as a later query recomputes them to judge
153            // whether the run is current.
154            let mut integrity_inputs = crate::rust_run::collect_integrity_inputs(root, command)?;
155            crate::source_discovery::fold_roots_into_configuration(
156                &mut integrity_inputs.execution_configuration,
157                source_roots.as_deref(),
158            );
159            let source_inputs =
160                crate::source_capture::capture(root, "rust", integrity_inputs.assertion_paths())?;
161            let integrity = create_explicit_run_integrity(
162                root,
163                &integrity_inputs,
164                &FrontendIntegrityInputs::embedded_rust(),
165            )
166            .map_err(|error| error.to_string())?;
167            let cfg = crate::rust_worker::wasm_target_cfg(&cargo_root);
168            let mut project =
169                crate::rust_project::prepare_rust_project_for(&cargo_root, None, cfg.as_ref())
170                    .map_err(|error| error.to_string())?;
171            crate::rust_wasm_evidence::root_paths(&mut project, relative);
172            let directory = root
173                .join(".supercov/work")
174                .join(run_id)
175                .join(format!("rust-wasm-{index}"));
176            fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
177            servers.push(Self {
178                project,
179                integrity,
180                source_inputs,
181                source_roots,
182                workspace: workspace.to_owned(),
183                records: server_evidence_root.join("wasm"),
184                directory,
185            });
186        }
187        Ok(servers)
188    }
189
190    pub fn source_files(&self) -> usize {
191        self.project.source_files.len()
192    }
193
194    /// The source this crate was instrumented from.
195    pub fn source_fingerprint(&self) -> String {
196        self.integrity.fingerprint.source.clone()
197    }
198
199    /// The test processes remember wasm instances and take their regions;
200    /// the build caps lints at `warn` (Cargo reads wasm32's dropped crate
201    /// types from rustc's warnings) and allows the probes' parentheses.
202    pub fn environment(&self) -> BTreeMap<String, String> {
203        let mut rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
204        if !rustflags.is_empty() {
205            rustflags.push(' ');
206        }
207        rustflags.push_str("--cap-lints=warn -A unused_parens");
208        BTreeMap::from([
209            ("SUPERCOV_WASM_REGIONS".into(), "1".into()),
210            ("RUSTFLAGS".into(), rustflags),
211        ])
212    }
213
214    /// Publish what the tests' wasm ran as a Rust run: each take credited to
215    /// the attempt it was taken at the end of. None when no wasm ran.
216    pub fn publish(
217        self,
218        root: &Path,
219        evidence_run_id: &str,
220        metadata: RunMetadata,
221        _served: &[ServedTest],
222        diagnostics: &mut dyn Write,
223    ) -> Result<Option<(PathBuf, RunMetadata)>, String> {
224        // Every runner's attempts: a Node suite reports none of Playwright's.
225        let tests = crate::served_tests::read_attempts(
226            &self
227                .workspace
228                .join(".supercov/evidence")
229                .join(evidence_run_id),
230        );
231        let result = read_records(&self.records).and_then(|records| {
232            crate::rust_wasm_evidence::publish(
233                root,
234                WasmRun {
235                    language: "Rust wasm",
236                    archive: "rust-wasm-evidence.raw.gz",
237                    project: &self.project,
238                    integrity: &self.integrity,
239                    source_inputs: &self.source_inputs,
240                    source_roots: self.source_roots.clone(),
241                },
242                records,
243                metadata,
244                &tests,
245                diagnostics,
246            )
247        });
248        let result = result.map_err(|error| {
249            note_kept_evidence(
250                root,
251                &self.records,
252                &format!("{evidence_run_id}-rust-wasm"),
253                error,
254            )
255        });
256        let _ = remove_stored_tree_deferred(root, &self.directory);
257        result
258    }
259}
260
261/// The takes the JavaScript runtime wrote, one JSON line each.
262fn read_records(directory: &Path) -> Result<Vec<WasmRecord>, String> {
263    let mut files = fs::read_dir(directory)
264        .map(|entries| {
265            entries
266                .filter_map(Result::ok)
267                .map(|entry| entry.path())
268                .filter(|path| {
269                    path.extension()
270                        .is_some_and(|extension| extension == "jsonl")
271                })
272                .collect::<Vec<_>>()
273        })
274        .unwrap_or_default();
275    files.sort();
276    let mut records = Vec::new();
277    for file in &files {
278        let text =
279            fs::read_to_string(file).map_err(|error| format!("{}: {error}", file.display()))?;
280        // A line cut short by a process killed mid-write is skipped.
281        for line in text.lines() {
282            let Ok(value) = serde_json::from_str::<Value>(line) else {
283                continue;
284            };
285            let owner = match value.get("owner").and_then(Value::as_str) {
286                Some(attempt) => Owner::Attempt(attempt.to_owned()),
287                None => Owner::Nobody,
288            };
289            if let Some(regions) = regions_from(&value) {
290                records.push(WasmRecord { owner, regions });
291            }
292        }
293    }
294    Ok(records)
295}
296
297/// Each region of a take, rebuilt from its header and touched pages.
298pub(crate) fn regions_from(value: &Value) -> Option<Vec<(String, Vec<u8>)>> {
299    let mut regions = Vec::new();
300    for region in value.get("regions")?.as_array()? {
301        let crate_key = region.get(0)?.as_str()?.to_owned();
302        let length = usize::try_from(region.get(1)?.as_u64()?).ok()?;
303        let mut bytes = vec![0_u8; length];
304        for segment in region.get(2)?.as_array()? {
305            let offset = usize::try_from(segment.get(0)?.as_u64()?).ok()?;
306            let data = base64::engine::general_purpose::STANDARD
307                .decode(segment.get(1)?.as_str()?)
308                .ok()?;
309            bytes
310                .get_mut(offset..offset + data.len())?
311                .copy_from_slice(&data);
312        }
313        regions.push((crate_key, bytes));
314    }
315    Some(regions)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn a_wasm_library_is_a_cdylib_that_depends_on_wasm_bindgen() {
324        let root = std::env::temp_dir().join(format!(
325            "supercov-rust-wasm-crates-{}-{}",
326            std::process::id(),
327            std::time::SystemTime::now()
328                .duration_since(std::time::UNIX_EPOCH)
329                .unwrap()
330                .as_nanos()
331        ));
332        let write = |relative: &str, text: &str| {
333            let path = root.join(relative);
334            fs::create_dir_all(path.parent().unwrap()).unwrap();
335            fs::write(path, text).unwrap();
336        };
337        write(
338            "rust/Cargo.toml",
339            "[package]\nname = \"a\"\n[lib]\ncrate-type = [\"cdylib\", \"rlib\"]\n[dependencies]\nwasm-bindgen = \"0.2\"\n",
340        );
341        // A native crate, a cdylib that is no wasm library, a Worker, and a
342        // crate in a dependency are not.
343        write(
344            "native/Cargo.toml",
345            "[package]\nname = \"b\"\n[dependencies]\nwasm-bindgen = \"0.2\"\n",
346        );
347        write(
348            "plugin/Cargo.toml",
349            "[package]\nname = \"c\"\n[lib]\ncrate-type = [\"cdylib\"]\n",
350        );
351        write(
352            "worker/Cargo.toml",
353            "[package]\nname = \"d\"\n[lib]\ncrate-type = [\"cdylib\"]\n[dependencies]\nwasm-bindgen = \"0.2\"\n",
354        );
355        write("worker/wrangler.toml", "main = \"x\"\n");
356        write(
357            "node_modules/e/Cargo.toml",
358            "[package]\nname = \"e\"\n[lib]\ncrate-type = [\"cdylib\"]\n[dependencies]\nwasm-bindgen = \"0.2\"\n",
359        );
360        let found = wasm_crates(&root)
361            .into_iter()
362            .map(|path| path.strip_prefix(&root).unwrap().to_owned())
363            .collect::<Vec<_>>();
364        assert_eq!(found, [PathBuf::from("rust/Cargo.toml")]);
365        fs::remove_dir_all(root).unwrap();
366    }
367}