Skip to main content

supercov_engine/
javascript_frontend.rs

1//! JavaScript source frontend for Rust-owned executions.
2//!
3//! The frontend mutates only an already-isolated workspace. JavaScript files
4//! are transformed by the Rust instrumenter; the small Node/browser runtime
5//! remains a language shim and is copied into the workspace under `.supercov`.
6
7use std::{
8    collections::BTreeMap,
9    fs::{self, OpenOptions},
10    io::{self, Write},
11    path::{Path, PathBuf},
12    sync::atomic::{AtomicU64, Ordering},
13    time::{Instant, SystemTime, UNIX_EPOCH},
14};
15
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::{
20    js_instrumenter::{
21        CandidateBranch, CandidateDecision, CandidateError, CandidateLimitation, CandidatePoint,
22        instrument_with_import_policy,
23    },
24    project_discovery::{BuildAdapter, CoverageProject},
25    source_discovery::{SourceLimitation, SourceScope},
26};
27
28const RUNTIME_INSTANCE_MARKER: &str = "__SUPERCOV_RUNTIME_INSTANCE__";
29const FRONTEND_CACHE_SCHEMA_VERSION: u32 = 2;
30const FRONTEND_CACHE_FILE: &str = ".supercov/frontend-cache.json";
31const FRONTEND_CACHE_DIRECTORY: &str = ".supercov/frontend-cache-artifacts";
32const RUNTIME_FILES: &[&str] = &[
33    "atomic.mjs",
34    "capability.mjs",
35    "jest.cjs",
36    "jest.config.mjs",
37    "jestReporter.mjs",
38    "jestRuntime.cjs",
39    "launchSupervisor.mjs",
40    "nodeAssert.mjs",
41    "nodeAssertAdapter.mjs",
42    "nodeAssertStrict.mjs",
43    "nodeTest.mjs",
44    "playwright.mjs",
45    "playwrightReporter.mjs",
46    "provenance.mjs",
47    "register.mjs",
48    "resolve-loader.mjs",
49    "runnerEvidence.mjs",
50    "runtime.mjs",
51    "transport.mjs",
52    "vitest.mjs",
53    "vitestBrowser.mjs",
54    "vitestReporter.mjs",
55];
56static UNIQUE: AtomicU64 = AtomicU64::new(0);
57
58/// Where the setup phase spends its time.
59///
60/// The timings line reports `setup` as one number, and one number cannot say
61/// which operation is slow: on a Windows runner it read 18.7 s for the same
62/// two-file fixture that takes 0.4-0.6 s on macOS -- per-file syncs, as it
63/// turned out. Every file operation the frontend performs adds to these
64/// counters, and `SUPERCOV_PHASE_TIMING=1`
65/// prints them beside the phase, so the next platform surprise is measured
66/// rather than guessed at. The stage counters (runtime, configs, sources,
67/// assertions, cache) partition the phase; the operation counters cut across
68/// those stages, and `instrument` is the parsing and rewriting inside
69/// `sources`, with the rest of that stage being the writes.
70struct SetupAccounting {
71    files: AtomicU64,
72    bytes: AtomicU64,
73    create_ns: AtomicU64,
74    write_ns: AtomicU64,
75    rename_ns: AtomicU64,
76    directories: AtomicU64,
77    directory_retries: AtomicU64,
78    directory_ns: AtomicU64,
79    runtime_ns: AtomicU64,
80    config_ns: AtomicU64,
81    sources_ns: AtomicU64,
82    instrument_ns: AtomicU64,
83    assertion_ns: AtomicU64,
84    cache_ns: AtomicU64,
85}
86
87impl SetupAccounting {
88    const fn new() -> Self {
89        Self {
90            files: AtomicU64::new(0),
91            bytes: AtomicU64::new(0),
92            create_ns: AtomicU64::new(0),
93            write_ns: AtomicU64::new(0),
94            rename_ns: AtomicU64::new(0),
95            directories: AtomicU64::new(0),
96            directory_retries: AtomicU64::new(0),
97            directory_ns: AtomicU64::new(0),
98            runtime_ns: AtomicU64::new(0),
99            config_ns: AtomicU64::new(0),
100            sources_ns: AtomicU64::new(0),
101            instrument_ns: AtomicU64::new(0),
102            assertion_ns: AtomicU64::new(0),
103            cache_ns: AtomicU64::new(0),
104        }
105    }
106}
107
108static SETUP: SetupAccounting = SetupAccounting::new();
109
110fn account(counter: &AtomicU64, started: Instant) {
111    counter.fetch_add(started.elapsed().as_nanos() as u64, Ordering::Relaxed);
112}
113
114fn accounted_ms(counter: &AtomicU64) -> f64 {
115    counter.load(Ordering::Relaxed) as f64 / 1_000_000.0
116}
117
118fn counted(counter: &AtomicU64) -> u64 {
119    counter.load(Ordering::Relaxed)
120}
121
122fn timed<T>(counter: &AtomicU64, operation: impl FnOnce() -> T) -> T {
123    let started = Instant::now();
124    let value = operation();
125    account(counter, started);
126    value
127}
128
129/// One line saying where the setup phase went, when `SUPERCOV_PHASE_TIMING=1`
130/// asked for it.
131pub fn setup_timing_detail() -> Option<String> {
132    if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() != Ok("1") {
133        return None;
134    }
135    Some(format!(
136        "setup detail runtime={:.1}ms configs={:.1}ms sources={:.1}ms (instrument={:.1}ms) \
137assertions={:.1}ms cache={:.1}ms | files={} bytes={} create={:.1}ms write={:.1}ms \
138rename={:.1}ms | directories={} retries={} directory-wait={:.1}ms",
139        accounted_ms(&SETUP.runtime_ns),
140        accounted_ms(&SETUP.config_ns),
141        accounted_ms(&SETUP.sources_ns),
142        accounted_ms(&SETUP.instrument_ns),
143        accounted_ms(&SETUP.assertion_ns),
144        accounted_ms(&SETUP.cache_ns),
145        counted(&SETUP.files),
146        counted(&SETUP.bytes),
147        accounted_ms(&SETUP.create_ns),
148        accounted_ms(&SETUP.write_ns),
149        accounted_ms(&SETUP.rename_ns),
150        counted(&SETUP.directories),
151        counted(&SETUP.directory_retries),
152        accounted_ms(&SETUP.directory_ns),
153    ))
154}
155
156#[derive(Debug)]
157pub enum JavascriptFrontendError {
158    Io {
159        path: PathBuf,
160        source: io::Error,
161    },
162    Instrument {
163        file: String,
164        source: CandidateError,
165    },
166    MissingRuntimeMarker,
167    Serialize(serde_json::Error),
168    UnsafeSourcePath(String),
169}
170
171impl std::fmt::Display for JavascriptFrontendError {
172    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        match self {
174            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
175            Self::Instrument { file, source } => {
176                write!(formatter, "failed to instrument {file}: {source:?}")
177            }
178            Self::MissingRuntimeMarker => write!(
179                formatter,
180                "generated Supercov runtime is missing its instance marker"
181            ),
182            Self::Serialize(error) => write!(formatter, "failed to serialize manifest: {error}"),
183            Self::UnsafeSourcePath(file) => write!(formatter, "unsafe source path: {file}"),
184        }
185    }
186}
187
188impl std::error::Error for JavascriptFrontendError {}
189
190fn io_error(path: &Path, source: io::Error) -> JavascriptFrontendError {
191    JavascriptFrontendError::Io {
192        path: path.to_owned(),
193        source,
194    }
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase", deny_unknown_fields)]
199pub struct JavascriptManifest {
200    pub decisions: Vec<CandidateDecision>,
201    pub points: Vec<CandidatePoint>,
202    pub branches: Vec<CandidateBranch>,
203    pub limitations: Vec<CandidateLimitation>,
204    pub scope: SourceScope,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct PreparedJavascriptFrontend {
209    pub manifest: JavascriptManifest,
210    pub manifest_path: PathBuf,
211    pub preload_path: PathBuf,
212    pub playwright_config_path: PathBuf,
213    pub vite_config_path: PathBuf,
214    pub vitest_config_path: PathBuf,
215    pub assertion_calls: usize,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase", deny_unknown_fields)]
220pub struct JavascriptFrontendCache {
221    schema_version: u32,
222    key: String,
223    assertion_calls: usize,
224    artifacts: Vec<JavascriptFrontendCacheArtifact>,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase", deny_unknown_fields)]
229struct JavascriptFrontendCacheArtifact {
230    path: String,
231    cache_file: String,
232    sha256: String,
233}
234
235fn safe_relative(path: &Path) -> bool {
236    path.components().next().is_some()
237        && path
238            .components()
239            .all(|component| matches!(component, std::path::Component::Normal(_)))
240}
241
242fn regular_file(workspace: &Path, relative: &str) -> bool {
243    safe_relative(Path::new(relative))
244        && fs::symlink_metadata(workspace.join(relative))
245            .is_ok_and(|metadata| metadata.file_type().is_file())
246}
247
248fn valid_cached_artifact(workspace: &Path, artifact: &JavascriptFrontendCacheArtifact) -> bool {
249    let expected_cache_file = format!("{FRONTEND_CACHE_DIRECTORY}/{}", artifact.sha256);
250    if !safe_relative(Path::new(&artifact.path))
251        || !safe_relative(Path::new(&artifact.cache_file))
252        || artifact.cache_file != expected_cache_file
253        || artifact.sha256.len() != 64
254        || !artifact
255            .sha256
256            .bytes()
257            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
258    {
259        return false;
260    }
261    let Ok(contents) = fs::read(workspace.join(&artifact.cache_file)) else {
262        return false;
263    };
264    format!("{:x}", Sha256::digest(&contents)) == artifact.sha256
265}
266
267pub fn read_javascript_frontend_cache(
268    workspace: &Path,
269    key: &str,
270) -> Option<JavascriptFrontendCache> {
271    let metadata: JavascriptFrontendCache =
272        serde_json::from_slice(&fs::read(workspace.join(FRONTEND_CACHE_FILE)).ok()?).ok()?;
273    if metadata.schema_version != FRONTEND_CACHE_SCHEMA_VERSION
274        || metadata.key != key
275        || metadata.artifacts.is_empty()
276        || metadata
277            .artifacts
278            .iter()
279            .any(|artifact| !valid_cached_artifact(workspace, artifact))
280    {
281        return None;
282    }
283    Some(metadata)
284}
285
286pub fn javascript_frontend_reuse_paths(cache: &JavascriptFrontendCache) -> Vec<PathBuf> {
287    let _ = cache;
288    vec![
289        PathBuf::from(FRONTEND_CACHE_FILE),
290        PathBuf::from(FRONTEND_CACHE_DIRECTORY),
291    ]
292}
293
294fn restore_cached_file(path: &Path, contents: &[u8]) -> Result<(), JavascriptFrontendError> {
295    let parent = path
296        .parent()
297        .ok_or_else(|| JavascriptFrontendError::UnsafeSourcePath(path.display().to_string()))?;
298    create_directory_all(parent)?;
299    let temporary = parent.join(format!(".supercov-restore-{}", unique()));
300    let result = (|| {
301        fs::write(&temporary, contents).map_err(|source| io_error(&temporary, source))?;
302        match fs::symlink_metadata(path) {
303            Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_symlink() => {
304                fs::remove_file(path).map_err(|source| io_error(path, source))?;
305            }
306            Ok(_) => {
307                return Err(JavascriptFrontendError::UnsafeSourcePath(
308                    path.display().to_string(),
309                ));
310            }
311            Err(source) if source.kind() == io::ErrorKind::NotFound => {}
312            Err(source) => return Err(io_error(path, source)),
313        }
314        fs::rename(&temporary, path).map_err(|source| io_error(path, source))
315    })();
316    if result.is_err() {
317        let _ = fs::remove_file(&temporary);
318    }
319    result
320}
321
322pub fn load_cached_javascript_frontend(
323    workspace: &Path,
324    cache: &JavascriptFrontendCache,
325) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
326    for artifact in &cache.artifacts {
327        let cache_path = workspace.join(&artifact.cache_file);
328        let contents = fs::read(&cache_path).map_err(|source| io_error(&cache_path, source))?;
329        if format!("{:x}", Sha256::digest(&contents)) != artifact.sha256 {
330            return Err(JavascriptFrontendError::UnsafeSourcePath(format!(
331                "corrupt frontend cache artifact {}",
332                artifact.cache_file
333            )));
334        }
335        restore_cached_file(&workspace.join(&artifact.path), &contents)?;
336    }
337    let generated = workspace.join(".supercov");
338    let manifest_path = generated.join("manifest.json");
339    let manifest = serde_json::from_slice(
340        &fs::read(&manifest_path).map_err(|source| io_error(&manifest_path, source))?,
341    )
342    .map_err(JavascriptFrontendError::Serialize)?;
343    Ok(PreparedJavascriptFrontend {
344        manifest,
345        manifest_path,
346        preload_path: generated.join("node_modules/register.mjs"),
347        playwright_config_path: generated.join("playwright.config.mjs"),
348        vite_config_path: generated.join("vite.config.mjs"),
349        vitest_config_path: generated.join("vitest.config.mjs"),
350        assertion_calls: cache.assertion_calls,
351    })
352}
353
354fn frontend_artifact_paths(workspace: &Path, project: &CoverageProject) -> Vec<String> {
355    let mut artifacts = vec![
356        ".supercov/node_modules/package.json".to_owned(),
357        ".supercov/node_modules/applicationRuntime.mjs".to_owned(),
358        ".supercov/node_modules/runtime.d.mts".to_owned(),
359        ".supercov/playwright.config.mjs".to_owned(),
360        ".supercov/vite.config.mjs".to_owned(),
361        ".supercov/vitest.config.mjs".to_owned(),
362        ".supercov/vite-transforms.json".to_owned(),
363        ".supercov/viteInstrumentation.mjs".to_owned(),
364        ".supercov/manifest.json".to_owned(),
365        ".supercov/statement-exclusions.json".to_owned(),
366        ".supercov/instrumentation-complete".to_owned(),
367    ];
368    artifacts.extend(
369        RUNTIME_FILES
370            .iter()
371            .map(|name| format!(".supercov/node_modules/{name}")),
372    );
373    // Scope entries outside the instrumented set may still be rewritten
374    // (assertion attribution, capability imports), so they are cached like
375    // instrumented sources. Both populations feed the cache key through the
376    // source digest -- nothing cached here escapes the fingerprint.
377    artifacts.extend(project.source_files.iter().cloned());
378    artifacts.extend(
379        project
380            .source_scope
381            .entries
382            .iter()
383            .map(|entry| entry.file.clone()),
384    );
385    for root in &project.source_roots {
386        let host = if workspace.join(root).is_file() {
387            Path::new(root).parent().unwrap_or_else(|| Path::new(""))
388        } else {
389            Path::new(root)
390        };
391        for name in ["package.json", "runtime.mjs", "runtime.d.mts"] {
392            let path = host.join(".supercov/node_modules").join(name);
393            if let Some(path) = path.to_str() {
394                artifacts.push(path.replace('\\', "/"));
395            }
396        }
397    }
398    artifacts.sort();
399    artifacts.dedup();
400    artifacts.retain(|path| regular_file(workspace, path));
401    artifacts
402}
403
404fn write_javascript_frontend_cache(
405    workspace: &Path,
406    project: &CoverageProject,
407    key: &str,
408    assertion_calls: usize,
409) -> Result<(), JavascriptFrontendError> {
410    let cache_directory = workspace.join(FRONTEND_CACHE_DIRECTORY);
411    create_directory_all(&cache_directory)?;
412    let mut artifacts = Vec::new();
413    for path in frontend_artifact_paths(workspace, project) {
414        let contents = fs::read(workspace.join(&path))
415            .map_err(|source| io_error(&workspace.join(&path), source))?;
416        let sha256 = format!("{:x}", Sha256::digest(&contents));
417        let cache_file = format!("{FRONTEND_CACHE_DIRECTORY}/{sha256}");
418        let destination = workspace.join(&cache_file);
419        if !destination.is_file() {
420            atomic_write(&destination, &contents)?;
421        }
422        artifacts.push(JavascriptFrontendCacheArtifact {
423            path,
424            cache_file,
425            sha256,
426        });
427    }
428    let cache = JavascriptFrontendCache {
429        schema_version: FRONTEND_CACHE_SCHEMA_VERSION,
430        key: key.to_owned(),
431        assertion_calls,
432        artifacts,
433    };
434    let mut encoded =
435        serde_json::to_vec_pretty(&cache).map_err(JavascriptFrontendError::Serialize)?;
436    encoded.push(b'\n');
437    atomic_write(&workspace.join(FRONTEND_CACHE_FILE), &encoded)
438}
439
440#[derive(Debug, Serialize)]
441#[serde(rename_all = "camelCase")]
442struct ViteTransform {
443    source_sha256: String,
444    code: String,
445    map: Option<serde_json::Value>,
446}
447
448fn embedded_runtime(name: &str) -> Option<&'static [u8]> {
449    match name {
450        "atomic.mjs" => Some(include_bytes!("../runtime-assets/javascript/atomic.mjs")),
451        "capability.mjs" => Some(include_bytes!(
452            "../runtime-assets/javascript/capability.mjs"
453        )),
454        "jest.cjs" => Some(include_bytes!("../runtime-assets/javascript/jest.cjs")),
455        "jest.config.mjs" => Some(include_bytes!(
456            "../runtime-assets/javascript/jest.config.mjs"
457        )),
458        "jestReporter.mjs" => Some(include_bytes!(
459            "../runtime-assets/javascript/jestReporter.mjs"
460        )),
461        "jestRuntime.cjs" => Some(include_bytes!(
462            "../runtime-assets/javascript/jestRuntime.cjs"
463        )),
464        "launchSupervisor.mjs" => Some(include_bytes!(
465            "../runtime-assets/javascript/launchSupervisor.mjs"
466        )),
467        "nodeAssert.mjs" => Some(include_bytes!(
468            "../runtime-assets/javascript/nodeAssert.mjs"
469        )),
470        "nodeAssertAdapter.mjs" => Some(include_bytes!(
471            "../runtime-assets/javascript/nodeAssertAdapter.mjs"
472        )),
473        "nodeAssertStrict.mjs" => Some(include_bytes!(
474            "../runtime-assets/javascript/nodeAssertStrict.mjs"
475        )),
476        "nodeTest.mjs" => Some(include_bytes!("../runtime-assets/javascript/nodeTest.mjs")),
477        "playwright.mjs" => Some(include_bytes!(
478            "../runtime-assets/javascript/playwright.mjs"
479        )),
480        "playwrightReporter.mjs" => Some(include_bytes!(
481            "../runtime-assets/javascript/playwrightReporter.mjs"
482        )),
483        "provenance.mjs" => Some(include_bytes!(
484            "../runtime-assets/javascript/provenance.mjs"
485        )),
486        "register.mjs" => Some(include_bytes!("../runtime-assets/javascript/register.mjs")),
487        "resolve-loader.mjs" => Some(include_bytes!(
488            "../runtime-assets/javascript/resolve-loader.mjs"
489        )),
490        "runnerEvidence.mjs" => Some(include_bytes!(
491            "../runtime-assets/javascript/runnerEvidence.mjs"
492        )),
493        "runtime.mjs" => Some(include_bytes!("../runtime-assets/javascript/runtime.mjs")),
494        "transport.mjs" => Some(include_bytes!("../runtime-assets/javascript/transport.mjs")),
495        "vitest.mjs" => Some(include_bytes!("../runtime-assets/javascript/vitest.mjs")),
496        "vitestBrowser.mjs" => Some(include_bytes!(
497            "../runtime-assets/javascript/vitestBrowser.mjs"
498        )),
499        "vitestReporter.mjs" => Some(include_bytes!(
500            "../runtime-assets/javascript/vitestReporter.mjs"
501        )),
502        _ => None,
503    }
504}
505
506fn unique() -> String {
507    let nanos = SystemTime::now()
508        .duration_since(UNIX_EPOCH)
509        .unwrap_or_default()
510        .as_nanos();
511    format!(
512        "{}-{nanos}-{}",
513        std::process::id(),
514        UNIQUE.fetch_add(1, Ordering::Relaxed)
515    )
516}
517
518#[cfg(not(windows))]
519fn create_directory_all(path: &Path) -> Result<(), JavascriptFrontendError> {
520    SETUP.directories.fetch_add(1, Ordering::Relaxed);
521    timed(&SETUP.directory_ns, || {
522        fs::create_dir_all(path).map_err(|source| io_error(path, source))
523    })
524}
525
526#[cfg(windows)]
527fn create_directory_all(path: &Path) -> Result<(), JavascriptFrontendError> {
528    // Windows scanners and just-closed directory handles reject creation of a
529    // brand-new path with ERROR_ACCESS_DENIED for as long as they hold the
530    // parent open. On a hosted runner with real-time scanning that is not
531    // milliseconds: the first Windows build exhausted eleven 20 ms retries on
532    // the generated node_modules directory right after the mirror had filled
533    // its sibling with junctions. Back off up to a few seconds against the
534    // exact owned path -- never broaden or redirect the target -- and when it
535    // still fails, say what every ancestor was, so a failure on a machine we
536    // cannot see is a diagnosis rather than a guess.
537    const ATTEMPTS: usize = 16;
538    let started = std::time::Instant::now();
539    SETUP.directories.fetch_add(1, Ordering::Relaxed);
540    let mut delay = std::time::Duration::from_millis(20);
541    for attempt in 0..ATTEMPTS {
542        match fs::create_dir_all(path) {
543            Ok(()) => {
544                account(&SETUP.directory_ns, started);
545                return Ok(());
546            }
547            Err(source)
548                if source.kind() == io::ErrorKind::PermissionDenied && attempt + 1 < ATTEMPTS =>
549            {
550                SETUP.directory_retries.fetch_add(1, Ordering::Relaxed);
551                std::thread::sleep(delay);
552                delay = (delay * 2).min(std::time::Duration::from_millis(500));
553            }
554            Err(source) => {
555                account(&SETUP.directory_ns, started);
556                let detail = format!(
557                    "{source} (after {} attempt(s) over {:?}; ancestors: {})",
558                    attempt + 1,
559                    started.elapsed(),
560                    describe_ancestors(path)
561                );
562                return Err(io_error(path, io::Error::new(source.kind(), detail)));
563            }
564        }
565    }
566    unreachable!("the final directory-creation attempt always returns")
567}
568
569/// One line per path component from the root down: whether it exists and as
570/// what. `symlink_metadata` is used so a reparse point is reported as a link
571/// rather than as whatever it points to.
572#[cfg_attr(not(windows), allow(dead_code))]
573fn describe_ancestors(path: &Path) -> String {
574    let mut current = PathBuf::new();
575    let mut parts = Vec::new();
576    for component in path.components() {
577        current.push(component.as_os_str());
578        let state = match fs::symlink_metadata(&current) {
579            Ok(metadata) if metadata.file_type().is_symlink() => "link",
580            Ok(metadata) if metadata.file_type().is_dir() => "dir",
581            Ok(_) => "file",
582            // A component below a file is "not a directory" on Unix and "path
583            // not found" on Windows; either way nothing exists there.
584            Err(error)
585                if matches!(
586                    error.kind(),
587                    io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
588                ) =>
589            {
590                "missing"
591            }
592            Err(error) => return format!("{} -> {error}", current.display()),
593        };
594        parts.push(format!("{}={state}", current.display()));
595    }
596    parts.join("; ")
597}
598
599fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), JavascriptFrontendError> {
600    let parent = path
601        .parent()
602        .ok_or_else(|| JavascriptFrontendError::UnsafeSourcePath(path.display().to_string()))?;
603    let temporary = parent.join(format!(".supercov-write-{}", unique()));
604    let result = (|| {
605        let open = || {
606            OpenOptions::new()
607                .write(true)
608                .create_new(true)
609                .open(&temporary)
610        };
611        let opened = timed(&SETUP.create_ns, open);
612        let mut output = match opened {
613            Ok(output) => output,
614            Err(source) if source.kind() == io::ErrorKind::NotFound => {
615                create_directory_all(parent)?;
616                timed(&SETUP.create_ns, open).map_err(|source| io_error(&temporary, source))?
617            }
618            Err(source) => return Err(io_error(&temporary, source)),
619        };
620        timed(&SETUP.write_ns, || output.write_all(contents))
621            .map_err(|source| io_error(&temporary, source))?;
622        // This file is not forced to disk, deliberately. Everything the
623        // frontend writes lives in the regenerable workspace cache and is
624        // listed in `frontend_artifact_paths`: a later run either rewrites it
625        // from the embedded assets and the project's sources, or restores it
626        // from the frontend cache, which verifies each artifact's sha256 when
627        // it reads the cache and again when it restores it, while mirrored
628        // sources are pruned and re-copied every run. A file left half-written
629        // by a crash therefore cannot be read back as if it were whole: it
630        // fails its digest and is regenerated. `rename` still publishes each
631        // file atomically, so no reader in this run can observe a partial one.
632        // What is given up is only surviving a power loss for files the next
633        // run rebuilds anyway.
634        //
635        // What it buys is the phase. Preparing a two-file fixture writes 61
636        // files, and the syncs were most of the wait: 240-260 ms of a 290-310
637        // ms phase on a Windows runner, and 190 ms of file sync plus 180 ms of
638        // directory sync in a 400 ms phase on macOS. Both become 14-55 ms. A
639        // Windows probe once measured this same phase at 18.7 s, which is a
640        // per-sync latency of about 300 ms; a scanner busy enough to do that
641        // no longer has anything here to block on.
642        //
643        // Durability that does matter -- evidence, run state, cache metadata
644        // -- goes through `lifecycle::atomic_write`, which still syncs.
645        timed(&SETUP.rename_ns, || fs::rename(&temporary, path))
646            .map_err(|source| io_error(path, source))?;
647        SETUP.files.fetch_add(1, Ordering::Relaxed);
648        SETUP
649            .bytes
650            .fetch_add(contents.len() as u64, Ordering::Relaxed);
651        Ok(())
652    })();
653    if result.is_err() {
654        let _ = fs::remove_file(&temporary);
655    }
656    result
657}
658
659fn checked_source_path(workspace: &Path, file: &str) -> Result<PathBuf, JavascriptFrontendError> {
660    let relative = Path::new(file);
661    if relative.is_absolute()
662        || relative
663            .components()
664            .any(|component| !matches!(component, std::path::Component::Normal(_)))
665    {
666        return Err(JavascriptFrontendError::UnsafeSourcePath(file.to_owned()));
667    }
668    Ok(workspace.join(relative))
669}
670
671fn runtime_specifier(file: &str, name: &str) -> Result<String, JavascriptFrontendError> {
672    let relative = Path::new(file);
673    if relative.is_absolute()
674        || relative
675            .components()
676            .any(|component| !matches!(component, std::path::Component::Normal(_)))
677    {
678        return Err(JavascriptFrontendError::UnsafeSourcePath(file.to_owned()));
679    }
680    let depth = relative
681        .parent()
682        .map_or(0, |parent| parent.components().count());
683    Ok(if depth == 0 {
684        format!("./.supercov/node_modules/{name}")
685    } else {
686        format!("{}.supercov/node_modules/{name}", "../".repeat(depth))
687    })
688}
689
690/// The banner that exempts an instrumented source from the host project's lint
691/// and type policy. The disable line comes FIRST so the host's own
692/// ban-ts-comment rule cannot reject the `@ts-nocheck` below it: Next.js lints
693/// instrumented sources during `next build`, and a real monorepo failed on
694/// every route file. Generated and instrumented code is immune to host lint
695/// policy as a class.
696const GENERATED_SOURCE_BANNER: &str =
697    "/* eslint-disable */\n// @ts-nocheck -- generated coverage workspace only\n";
698
699/// Prefix `code` with that banner, keeping a shebang on the first line. `#!`
700/// anywhere else is a parse error TypeScript reports as TS18026, which
701/// `@ts-nocheck` cannot suppress because it is syntax and not semantics, so a
702/// banner in front of it failed the build of every project whose entry point
703/// is executable.
704fn generated_source_banner(code: &str) -> String {
705    let Some(rest) = code.strip_prefix("#!") else {
706        return format!("{GENERATED_SOURCE_BANNER}{code}");
707    };
708    let (line, remainder) = rest.split_once('\n').unwrap_or((rest, ""));
709    format!("#!{line}\n{GENERATED_SOURCE_BANNER}{remainder}")
710}
711
712fn isolate_runtime(source: &str, collector_id: &str) -> Result<String, JavascriptFrontendError> {
713    // Generated runtime files sit inside the lint graph of bundlers that lint
714    // whatever they compile (Next.js does), so they must disarm host lint
715    // policy the same way the Rust runtime does with #[allow(warnings)].
716    let source = format!("/* eslint-disable */\n{source}");
717    let source = source.as_str();
718    let double = format!("runtimeInstanceToken = \"{RUNTIME_INSTANCE_MARKER}\"");
719    let single = format!("runtimeInstanceToken = '{RUNTIME_INSTANCE_MARKER}'");
720    if let Some(index) = source.find(&double) {
721        let mut isolated = source.to_owned();
722        isolated.replace_range(
723            index..index + double.len(),
724            &format!("runtimeInstanceToken = \"{collector_id}\""),
725        );
726        return Ok(isolated);
727    }
728    if let Some(index) = source.find(&single) {
729        let mut isolated = source.to_owned();
730        isolated.replace_range(
731            index..index + single.len(),
732            &format!("runtimeInstanceToken = '{collector_id}'"),
733        );
734        return Ok(isolated);
735    }
736    Err(JavascriptFrontendError::MissingRuntimeMarker)
737}
738
739/// Inline `map` into `code` as a data-URL source map whose single source is the
740/// ORIGINAL project file, with the original text embedded.
741///
742/// The instrumented file may have banner lines prepended AFTER the map was
743/// computed (`/* eslint-disable */` and `@ts-nocheck`); VLQ mappings are
744/// generated-line-relative with one `;` per line, so the map is shifted by
745/// prefixing one semicolon per banner line rather than re-encoding tokens. A
746/// shebang keeps the first line and maps to itself, so the banner below it
747/// shifts everything after by the same amount.
748fn inline_instrumentation_map(
749    code: &str,
750    map: Option<&serde_json::Value>,
751    original_path: &Path,
752    original_source: &str,
753) -> Option<String> {
754    let map = map?.clone();
755    let mut map = map;
756    let object = map.as_object_mut()?;
757    let banner_lines = code
758        .lines()
759        .skip(usize::from(code.starts_with("#!")))
760        .take_while(|line| {
761            line.starts_with("/* eslint-disable */") || line.starts_with("// @ts-nocheck")
762        })
763        .count();
764    if banner_lines > 0 {
765        let mappings = object.get("mappings")?.as_str()?.to_owned();
766        object.insert(
767            "mappings".into(),
768            serde_json::Value::String(format!("{}{}", ";".repeat(banner_lines), mappings)),
769        );
770    }
771    object.insert(
772        "sources".into(),
773        serde_json::json!([original_path.display().to_string()]),
774    );
775    object.insert(
776        "sourcesContent".into(),
777        serde_json::json!([original_source]),
778    );
779    let payload = serde_json::to_string(&map).ok()?;
780    use base64::Engine as _;
781    let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
782    Some(format!(
783        "{code}\n//# sourceMappingURL=data:application/json;base64,{encoded}\n"
784    ))
785}
786
787/// Target-language shims are embedded in the Rust engine. Keeping a trailing
788/// source-map directive would make Node and browser tooling look for source
789/// maps that intentionally are not part of the runtime distribution.
790fn strip_source_map_reference(mut bytes: Vec<u8>) -> Vec<u8> {
791    const MARKER: &[u8] = b"\n//# sourceMappingURL=";
792    if let Some(index) = bytes
793        .windows(MARKER.len())
794        .rposition(|window| window == MARKER)
795    {
796        let suffix = &bytes[index + MARKER.len()..];
797        let suffix = suffix.strip_suffix(b"\n").unwrap_or(suffix);
798        let suffix = suffix.strip_suffix(b"\r").unwrap_or(suffix);
799        if !suffix.contains(&b'\n') && !suffix.contains(&b'\r') {
800            bytes.truncate(index + 1);
801        }
802    }
803    bytes
804}
805
806fn copy_runtime(generated: &Path, collector_id: &str) -> Result<(), JavascriptFrontendError> {
807    create_directory_all(generated)?;
808    atomic_write(
809        &generated.join("package.json"),
810        b"{\"private\":true,\"type\":\"module\"}\n",
811    )?;
812    for name in RUNTIME_FILES {
813        let destination = generated.join(name);
814        let source_path = PathBuf::from(format!("embedded:{name}"));
815        let bytes = embedded_runtime(name)
816            .expect("every declared runtime file must have an embedded asset")
817            .to_vec();
818        let bytes = strip_source_map_reference(bytes);
819        if *name == "runtime.mjs" {
820            let text = String::from_utf8(bytes).map_err(|source| {
821                io_error(
822                    &source_path,
823                    io::Error::new(io::ErrorKind::InvalidData, source),
824                )
825            })?;
826            atomic_write(
827                &destination,
828                isolate_runtime(&text, collector_id)?.as_bytes(),
829            )?;
830            atomic_write(
831                &generated.join("applicationRuntime.mjs"),
832                isolate_runtime(&text, &format!("{collector_id}-application"))?.as_bytes(),
833            )?;
834        } else {
835            atomic_write(&destination, &bytes)?;
836        }
837    }
838    atomic_write(
839        &generated.join("runtime.d.mts"),
840        // Generated files must be immune to the HOST project's lint policy --
841        // the same rule the Rust runtime enforces with #[allow(warnings)].
842        // Next.js runs the project's eslint over the build graph, and
843        // @typescript-eslint/no-explicit-any turned every `any` below into a
844        // hard "Failed to compile" for a real monorepo.
845        b"/* eslint-disable */\n\
846export declare function coverageHit(...args: any[]): any;\n\
847export declare function selectionBegin(...args: any[]): any;\n\
848export declare function selectionRight(...args: any[]): any;\n\
849export declare function selectionEnd(...args: any[]): any;\n\
850export declare function optionalSelect(...args: any[]): any;\n\
851export declare function optionalCallBegin(...args: any[]): any;\n\
852export declare function optionalCallReached(...args: any[]): any;\n\
853export declare function optionalCallContinued(...args: any[]): any;\n\
854export declare function optionalCallEnd(...args: any[]): any;\n\
855export declare function defaultSelected(...args: any[]): any;\n\
856export declare function defaultEntered(...args: any[]): any;\n\
857export declare function tryBegin(...args: any[]): any;\n\
858export declare function tryCatch(...args: any[]): any;\n\
859export declare function tryEnd(...args: any[]): any;\n\
860export declare function loopBegin(...args: any[]): any;\n\
861export declare function loopEntered(...args: any[]): any;\n\
862export declare function loopEnd(...args: any[]): any;\n\
863export declare function mcdcBegin(...args: any[]): any;\n\
864export declare function mcdcCondition(...args: any[]): any;\n\
865export declare function mcdcEnd(...args: any[]): any;\n\
866export declare function registerProbeV2(...args: any[]): any;\n\
867export declare function coverageHitV2(...args: any[]): any;\n\
868export declare function mcdcEndV2(...args: any[]): any;\n",
869    )?;
870    Ok(())
871}
872
873fn generic_runtime_binding(
874    workspace: &Path,
875    project: &CoverageProject,
876    source_path: &Path,
877    generated: &Path,
878) -> Result<String, JavascriptFrontendError> {
879    let mut hosts = project
880        .source_roots
881        .iter()
882        .filter_map(|root| {
883            let candidate = workspace.join(root);
884            if candidate.is_dir() && source_path.strip_prefix(&candidate).is_ok() {
885                Some(candidate)
886            } else if candidate.is_file() && candidate == source_path {
887                candidate.parent().map(Path::to_owned)
888            } else {
889                None
890            }
891        })
892        .collect::<Vec<_>>();
893    hosts.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
894    let host = hosts
895        .into_iter()
896        .next()
897        .unwrap_or_else(|| workspace.to_owned());
898    let runtime_directory = host.join(".supercov/node_modules");
899    fs::create_dir_all(&runtime_directory)
900        .map_err(|source| io_error(&runtime_directory, source))?;
901    // A bundler may externalize the node_modules import instead of compiling
902    // it, leaving Node to interpret the .js file at run time.
903    atomic_write(
904        &runtime_directory.join("package.json"),
905        b"{\"private\":true,\"type\":\"module\"}\n",
906    )?;
907    for name in ["runtime.mjs", "runtime.d.mts"] {
908        let source = generated.join("node_modules").join(name);
909        let destination = runtime_directory.join(name);
910        let contents = fs::read(&source).map_err(|error| io_error(&source, error))?;
911        atomic_write(&destination, &contents)?;
912    }
913    let parent = source_path.parent().ok_or_else(|| {
914        JavascriptFrontendError::UnsafeSourcePath(source_path.display().to_string())
915    })?;
916    let local = parent.strip_prefix(&host).map_err(|_| {
917        JavascriptFrontendError::UnsafeSourcePath(source_path.display().to_string())
918    })?;
919    let depth = local.components().count();
920    Ok(if depth == 0 {
921        "./.supercov/node_modules/runtime.mjs".into()
922    } else {
923        format!("{}.supercov/node_modules/runtime.mjs", "../".repeat(depth))
924    })
925}
926
927fn limitation_from_source(value: &SourceLimitation) -> CandidateLimitation {
928    CandidateLimitation {
929        id: value.id.clone(),
930        kind: value.kind.clone(),
931        file: value.file.clone(),
932        line: value.line,
933        column: value.column,
934        source: value.source.clone(),
935        reason: value.reason.clone(),
936    }
937}
938
939fn relocated_project_file(
940    workspace: &Path,
941    project: &CoverageProject,
942    source: Option<&PathBuf>,
943) -> Option<PathBuf> {
944    let source = source?;
945    let relative = source.strip_prefix(&project.root).ok()?;
946    Some(workspace.join(relative))
947}
948
949fn write_vitest_config(
950    workspace: &Path,
951    project: &CoverageProject,
952    generated: &Path,
953) -> Result<PathBuf, JavascriptFrontendError> {
954    let path = generated.join("vitest.config.mjs");
955    let original = relocated_project_file(workspace, project, project.vitest_config.as_ref())
956        .map(|path| path.display().to_string());
957    let original = serde_json::to_string(&original).map_err(JavascriptFrontendError::Serialize)?;
958    let source = format!(
959        "import {{ createRequire }} from 'node:module';\n\
960         import {{ pathToFileURL }} from 'node:url';\n\
961         // pnpm's strict layout does not hoist vite to the project root: it\n\
962         // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
963         // resolved from this generated file fails. Vitest depends on vite, so\n\
964         // fall back to resolving it through vitest's own tree rather than\n\
965         // requiring the project to hoist anything.\n\
966         const supercovRequire = createRequire(import.meta.url);\n\
967         const supercovLoadVite = async () => {{\n\
968           try {{\n\
969             return await import('vite');\n\
970           }} catch (error) {{\n\
971             let entry;\n\
972             try {{\n\
973               entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
974             }} catch {{\n\
975               throw error;\n\
976             }}\n\
977             return await import(pathToFileURL(entry).href);\n\
978           }}\n\
979         }};\n\
980         const viteNamespace = await supercovLoadVite();\n\
981         import {{ resolve }} from 'node:path';\n\
982         import SupercovVitestReporter from './node_modules/vitestReporter.mjs';\n\
983         import {{ supercovBrowserCommands, supercovViteInstrumentation }} from './viteInstrumentation.mjs';\n\
984         const vite = viteNamespace.default ?? viteNamespace;\n\
985         const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
986         const discoveredConfig = {original};\n\
987         // Browser Mode runs the test file in the browser, so the node setup --\n\
988         // which reaches node:fs to write evidence -- cannot load there. Vite\n\
989         // externalises it and the suite fails before collecting a test.\n\
990         const supercovBrowserMode = (config) => Boolean(config?.test?.browser?.enabled);\n\
991         const supercovSetupFile = (config) =>\n\
992           supercovBrowserMode(config)\n\
993             ? '.supercov/node_modules/vitestBrowser.mjs'\n\
994             : '.supercov/node_modules/vitest.mjs';\n\
995         // Evidence leaves the browser over Vitest's own command channel, so\n\
996         // the command is registered only for the mode that uses it.\n\
997         const supercovBrowserTest = (config) =>\n\
998           supercovBrowserMode(config)\n\
999             ? {{ browser: {{ commands: supercovBrowserCommands(process.cwd()) }} }}\n\
1000             : {{}};\n\
1001         // Child projects do not inherit root setupFiles. Resolve each project's\n\
1002         // environment after Vitest has loaded file/glob/inline configs and\n\
1003         // applied --project, preserving the runner's own config semantics.\n\
1004         const configureProjects = (projects) => {{\n\
1005           const nodeSetup = resolve(process.cwd(), '.supercov/node_modules/vitest.mjs');\n\
1006           const browserSetup = resolve(process.cwd(), '.supercov/node_modules/vitestBrowser.mjs');\n\
1007           for (const project of projects ?? []) {{\n\
1008             const test = project.config;\n\
1009             const browser = Boolean(test.browser?.enabled);\n\
1010             const setup = browser ? browserSetup : nodeSetup;\n\
1011             test.setupFiles = [setup, ...(test.setupFiles ?? []).filter(file => file !== nodeSetup && file !== browserSetup)];\n\
1012             test.maxConcurrency = 1;\n\
1013             if (browser) {{\n\
1014               const commands = supercovBrowserCommands(process.cwd());\n\
1015               test.browser.commands = {{ ...test.browser.commands, ...commands }};\n\
1016               // Browser instances can already exist when reporters initialize.\n\
1017               // Their parent generates the browser command exports.\n\
1018               const server = project.browser?.parent ?? project.browser;\n\
1019               if (server?.commands) Object.assign(server.commands, commands);\n\
1020             }}\n\
1021           }}\n\
1022         }};\n\
1023         export default async function supercovVitestConfig(env) {{\n\
1024           const originalPath = process.env.SUPERCOV_ORIGINAL_VITEST_CONFIG || discoveredConfig;\n\
1025           const loaded = originalPath ? await loadConfigFromFile(env, originalPath, process.cwd()) : undefined;\n\
1026           const config = mergeConfig(loaded?.config ?? {{}}, {{\n\
1027             cacheDir: resolve(process.cwd(), '.supercov/vitest-cache'),\n\
1028             plugins: [supercovViteInstrumentation(process.cwd())],\n\
1029             test: {{ setupFiles: [resolve(process.cwd(), supercovSetupFile(loaded?.config))], maxConcurrency: 1, ...supercovBrowserTest(loaded?.config) }},\n\
1030           }});\n\
1031           const configuredReporters = loaded?.config?.test?.reporters;\n\
1032           config.test ??= {{}};\n\
1033           config.test.reporters = configuredReporters\n\
1034             ? [...(Array.isArray(configuredReporters) ? configuredReporters : [configuredReporters]), new SupercovVitestReporter(configureProjects)]\n\
1035             : ['default', new SupercovVitestReporter(configureProjects)];\n\
1036           return config;\n\
1037         }}\n"
1038    );
1039    atomic_write(&path, source.as_bytes())?;
1040    Ok(path)
1041}
1042
1043fn configure_playwright_runtime(
1044    generated: &Path,
1045    project: &CoverageProject,
1046) -> Result<(), JavascriptFrontendError> {
1047    let adapter_path = generated.join("playwright.mjs");
1048    let mut adapter =
1049        fs::read_to_string(&adapter_path).map_err(|source| io_error(&adapter_path, source))?;
1050    adapter = adapter
1051        .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module)
1052        .replace(
1053            "__SUPERCOV_PLAYWRIGHT_TEST_EXPORT__",
1054            &project.playwright_test_export,
1055        )
1056        // Baked in rather than read from the environment alone: pooled
1057        // runners execute Playwright inside VMs whose environment the host
1058        // cannot reach, while the generated file rides the workspace mount.
1059        .replace(
1060            "__SUPERCOV_PHASE_TIMING__",
1061            if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() == Ok("1") {
1062                "1"
1063            } else {
1064                "0"
1065            },
1066        );
1067    if project.playwright_module != "@playwright/test" {
1068        // A facade module exports the project's whole test API, not just the
1069        // Playwright surface: its full export set must flow through the shim,
1070        // with only the interception points (`test`, `expect`, the discovered
1071        // test export) shadowed by the shim's own declarations. The discovered
1072        // per-name re-exports below stay as a fallback for CommonJS facades,
1073        // where `export *` only forwards statically detectable names.
1074        let facade = serde_json::to_string(&project.playwright_module)
1075            .expect("serializing a module specifier cannot fail");
1076        adapter = adapter.replace(
1077            "export * from \"@playwright/test\";",
1078            &format!("export * from {facade};"),
1079        );
1080    }
1081    let mut exports = Vec::new();
1082    if project.playwright_test_export != "test" {
1083        exports.push(format!(
1084            "export {{ instrumentedTest as {} }};",
1085            project.playwright_test_export
1086        ));
1087    }
1088    exports.extend(
1089        project
1090            .playwright_exports
1091            .iter()
1092            .filter(|name| {
1093                name.as_str() != "test"
1094                    && name.as_str() != "expect"
1095                    && *name != &project.playwright_test_export
1096            })
1097            .map(|name| {
1098                let encoded = serde_json::to_string(name)
1099                    .expect("serializing a JavaScript export name cannot fail");
1100                format!("export const {name} = __supercovAdapterExport(adapter[{encoded}]);")
1101            }),
1102    );
1103    adapter = adapter.replace("/*__SUPERCOV_ADAPTER_EXPORTS__*/", &exports.join("\n"));
1104    atomic_write(&adapter_path, adapter.as_bytes())?;
1105
1106    let loader_path = generated.join("resolve-loader.mjs");
1107    let loader = fs::read_to_string(&loader_path)
1108        .map_err(|source| io_error(&loader_path, source))?
1109        .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module);
1110    atomic_write(&loader_path, loader.as_bytes())
1111}
1112
1113fn write_playwright_config(
1114    workspace: &Path,
1115    project: &CoverageProject,
1116    generated: &Path,
1117) -> Result<PathBuf, JavascriptFrontendError> {
1118    let path = generated.join("playwright.config.mjs");
1119    let original = relocated_project_file(workspace, project, project.playwright_config.as_ref());
1120    let original_import = if let Some(original) = &original {
1121        let relative = original
1122            .strip_prefix(workspace)
1123            .map_err(|_| JavascriptFrontendError::UnsafeSourcePath(original.display().to_string()))?
1124            .to_string_lossy()
1125            .replace('\\', "/");
1126        let specifier = serde_json::to_string(&format!("../{relative}"))
1127            .map_err(JavascriptFrontendError::Serialize)?;
1128        format!("import original from {specifier};\n")
1129    } else {
1130        "const original = {};\n".into()
1131    };
1132    let source = format!(
1133        "import './node_modules/register.mjs';\n\
1134         import {{ dirname, isAbsolute, relative, resolve }} from 'node:path';\n\
1135         import {{ fileURLToPath }} from 'node:url';\n\
1136         {original_import}\
1137         const resolvedValue = typeof original === 'function' ? await original({{ command: 'test', mode: 'test' }}) : original;\n\
1138         const resolved = resolvedValue ?? {{}};\n\
1139         const runtimeProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');\n\
1140         const originalDirectory = {};
1141         const sourceProjectRoot = process.env.SUPERCOV_SOURCE_PROJECT_ROOT;\n\
1142         const runtimePath = value => {{\n\
1143           if (!value) return value;\n\
1144           const absolute = isAbsolute(value) ? value : resolve(originalDirectory, value);\n\
1145           const local = relative(runtimeProjectRoot, absolute);\n\
1146           if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1147           if (sourceProjectRoot) {{\n\
1148             const sourceLocal = relative(sourceProjectRoot, absolute);\n\
1149             if (sourceLocal === '' || (!sourceLocal.startsWith('..') && !isAbsolute(sourceLocal))) return resolve(runtimeProjectRoot, sourceLocal);\n\
1150           }}\n\
1151           throw new Error('Supercov refuses a Playwright output/cwd outside the isolated project: ' + absolute);\n\
1152         }};\n\
1153         const normalizeWebServer = server => server ? ({{ ...server, cwd: runtimePath(server.cwd ?? originalDirectory) }}) : server;\n\
1154         const normalized = {{ ...resolved,\n\
1155           testDir: runtimePath(resolved.testDir),\n\
1156           outputDir: runtimePath(resolved.outputDir),\n\
1157           snapshotDir: runtimePath(resolved.snapshotDir),\n\
1158           projects: resolved.projects?.map(project => ({{ ...project, testDir: runtimePath(project.testDir), outputDir: runtimePath(project.outputDir), snapshotDir: runtimePath(project.snapshotDir) }})),\n\
1159           webServer: Array.isArray(resolved.webServer) ? resolved.webServer.map(normalizeWebServer) : normalizeWebServer(resolved.webServer),\n\
1160         }};\n\
1161         const configuredReporters = normalized.reporter;\n\
1162         const reporters = configuredReporters\n\
1163           ? (typeof configuredReporters === 'string' ? [[configuredReporters]] : (Array.isArray(configuredReporters[0]) ? configuredReporters : [configuredReporters]))\n\
1164           : [['list']];\n\
1165         const coverageReporter = resolve(runtimeProjectRoot, '.supercov/node_modules/playwrightReporter.mjs');\n\
1166         export default {{ ...normalized, reporter: [...reporters, [coverageReporter]] }};\n",
1167        serde_json::to_string(
1168            &original
1169                .as_ref()
1170                .and_then(|path| path.parent())
1171                .unwrap_or(workspace)
1172                .display()
1173                .to_string()
1174        )
1175        .map_err(JavascriptFrontendError::Serialize)?
1176    );
1177    atomic_write(&path, source.as_bytes())?;
1178    Ok(path)
1179}
1180
1181fn write_vite_config(
1182    workspace: &Path,
1183    generated: &Path,
1184) -> Result<PathBuf, JavascriptFrontendError> {
1185    let path = generated.join("vite.config.mjs");
1186    let workspace = serde_json::to_string(&workspace.display().to_string())
1187        .map_err(JavascriptFrontendError::Serialize)?;
1188    let source = format!(
1189        "import {{ createRequire }} from 'node:module';\n\
1190         import {{ pathToFileURL }} from 'node:url';\n\
1191         // pnpm's strict layout does not hoist vite to the project root: it\n\
1192         // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
1193         // resolved from this generated file fails. Vitest depends on vite, so\n\
1194         // fall back to resolving it through vitest's own tree rather than\n\
1195         // requiring the project to hoist anything.\n\
1196         const supercovRequire = createRequire(import.meta.url);\n\
1197         const supercovLoadVite = async () => {{\n\
1198           try {{\n\
1199             return await import('vite');\n\
1200           }} catch (error) {{\n\
1201             let entry;\n\
1202             try {{\n\
1203               entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
1204             }} catch {{\n\
1205               throw error;\n\
1206             }}\n\
1207             return await import(pathToFileURL(entry).href);\n\
1208           }}\n\
1209         }};\n\
1210         const viteNamespace = await supercovLoadVite();\n\
1211         import {{ isAbsolute, relative, resolve }} from 'node:path';\n\
1212         import {{ supercovViteInstrumentation }} from './viteInstrumentation.mjs';\n\
1213         const vite = viteNamespace.default ?? viteNamespace;\n\
1214         const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
1215         export default async function supercovViteConfig(env) {{\n\
1216           const isolatedRoot = {workspace};\n\
1217           const loaded = await loadConfigFromFile(env, undefined, isolatedRoot);\n\
1218           const config = loaded?.config ?? {{}};\n\
1219           const relocate = (value, label) => {{\n\
1220             const absolute = isAbsolute(value) ? value : resolve(isolatedRoot, value);\n\
1221             const local = relative(isolatedRoot, absolute);\n\
1222             if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1223             throw new Error('Supercov refuses ' + label + ' outside the isolated project: ' + absolute);\n\
1224           }};\n\
1225           const relocateOutput = output => output ? ({{ ...output, dir: output.dir ? relocate(output.dir, 'Rollup output') : output.dir, file: output.file ? relocate(output.file, 'Rollup output') : output.file }}) : output;\n\
1226           const rollupOutput = config.build?.rollupOptions?.output;\n\
1227           const safe = {{ ...config,\n\
1228             logLevel: ['1', 'true', 'yes'].includes(process.env.SUPERCOV_VERBOSE ?? process.env.SUPERCOV_DEBUG ?? '') ? config.logLevel : 'error',\n\
1229             cacheDir: resolve(isolatedRoot, '.supercov/vite-cache'),\n\
1230             build: {{ ...config.build, outDir: relocate(config.build?.outDir ?? 'dist', 'Vite build output'), rollupOptions: {{ ...config.build?.rollupOptions, output: Array.isArray(rollupOutput) ? rollupOutput.map(relocateOutput) : relocateOutput(rollupOutput) }} }},\n\
1231           }};\n\
1232           return mergeConfig(safe, {{ plugins: [supercovViteInstrumentation(isolatedRoot)] }});\n\
1233         }}\n"
1234    );
1235    atomic_write(&path, source.as_bytes())?;
1236    Ok(path)
1237}
1238
1239fn write_vite_transforms(
1240    generated: &Path,
1241    transforms: &BTreeMap<String, ViteTransform>,
1242) -> Result<(), JavascriptFrontendError> {
1243    let mut payload = serde_json::to_vec(transforms).map_err(JavascriptFrontendError::Serialize)?;
1244    payload.push(b'\n');
1245    atomic_write(&generated.join("vite-transforms.json"), &payload)?;
1246    let adapter = "import { createHash } from 'node:crypto';\n\
1247import { mkdirSync, readFileSync } from 'node:fs';\n\
1248import { relative, resolve, sep } from 'node:path';\n\
1249import { atomicWriteFileSync } from './node_modules/atomic.mjs';\n\
1250import { inferTestProvenance } from './node_modules/provenance.mjs';\n\
1251const transforms = JSON.parse(readFileSync(new URL('./vite-transforms.json', import.meta.url), 'utf8'));\n\
1252const sha256 = value => createHash('sha256').update(value).digest('hex');\n\
1253// Browser Mode runs the test file in the browser, where none of this is\n\
1254// reachable: no node:fs to write evidence, no project root to relativise a\n\
1255// path against, no env to read. Vitest's own browser command channel carries\n\
1256// what the browser observed to node, which completes the same payload the node\n\
1257// setup writes -- so one contract keeps one implementation. The command is\n\
1258// awaited as part of the test, and node learns the test file from Vitest\n\
1259// rather than from the browser realm, which cannot be trusted to name it.\n\
1260const supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/;\n\
1261export function supercovBrowserCommands(root) {\n\
1262  return {\n\
1263    __supercovEvidence(context, payload, suffix) {\n\
1264      const evidenceDirectory = process.env['SUPERCOV_EVIDENCE_DIR'];\n\
1265      if (!evidenceDirectory) return;\n\
1266      // A suffix names one directory under the evidence directory. Anything\n\
1267      // else is refused rather than resolved, so a malformed one cannot send\n\
1268      // the write outside the directory it is supposed to stay in.\n\
1269      if (typeof suffix !== 'string' || !supercovEvidenceSuffix.test(suffix))\n\
1270        throw new Error('Supercov refused an unusable evidence suffix: ' + suffix);\n\
1271      const absolute = context.testPath;\n\
1272      const testFile = absolute ? relative(root, absolute).split(sep).join('/') : undefined;\n\
1273      const projectName = payload.projectName ?? context.project?.name;\n\
1274      delete payload.projectName;\n\
1275      // The browser has no environment to read, so the run identity is stamped\n\
1276      // here rather than sent from a realm that cannot know it.\n\
1277      const runId = process.env['SUPERCOV_RUN_ID'];\n\
1278      if (runId && payload.scope) payload.scope.runId = runId;\n\
1279      const complete = Object.assign({}, payload, testFile ? { testFile } : {}, {\n\
1280        provenance: inferTestProvenance({\n\
1281          runner: 'vitest',\n\
1282          file: testFile,\n\
1283          project: projectName,\n\
1284          explicitKind: process.env['SUPERCOV_TEST_KIND'],\n\
1285        }),\n\
1286      });\n\
1287      const directory = resolve(root, evidenceDirectory, suffix);\n\
1288      mkdirSync(directory, { recursive: true });\n\
1289      atomicWriteFileSync(resolve(directory, 'mcdc.json'), JSON.stringify(complete) + '\\n');\n\
1290    },\n\
1291  };\n\
1292}\n\
1293export function supercovViteInstrumentation(root) {\n\
1294  const runtimePath = resolve(root, '.supercov/node_modules/applicationRuntime.mjs');\n\
1295  return {\n\
1296    name: 'supercov-rust-instrumentation',\n\
1297    enforce: 'pre',\n\
1298    resolveId(id) { return id === 'virtual:supercov-runtime' ? runtimePath : null; },\n\
1299    transform(code, rawId) {\n\
1300      const id = rawId.split('?')[0] ?? rawId;\n\
1301      const local = relative(root, id).split(sep).join('/');\n\
1302      const transformed = transforms[local];\n\
1303      if (!transformed) return null;\n\
1304      if (sha256(code) !== transformed.sourceSha256)\n\
1305        throw new Error('Supercov source changed before Rust instrumentation: ' + local);\n\
1306      return { code: transformed.code, map: transformed.map ?? null };\n\
1307    },\n\
1308  };\n\
1309}\n";
1310    atomic_write(
1311        &generated.join("viteInstrumentation.mjs"),
1312        adapter.as_bytes(),
1313    )
1314}
1315
1316/// Prepare the complete JavaScript frontend inside an isolated workspace.
1317/// The source project is read only through the copied workspace inventory.
1318pub fn prepare_javascript_frontend(
1319    workspace: &Path,
1320    project: &CoverageProject,
1321    collector_id: &str,
1322    cache_key: &str,
1323    command: &[String],
1324) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
1325    let generated = workspace.join(".supercov");
1326    // Runtime code files live under a node_modules segment: Node attributes
1327    // stack frames from node_modules paths to dependency infrastructure, so
1328    // deprecation warnings the user's own run would suppress (Node's
1329    // isInsideNodeModules check) stay suppressed when Supercov's module
1330    // hooks are on the call path.
1331    let runtime_directory = generated.join("node_modules");
1332    timed(&SETUP.runtime_ns, || {
1333        copy_runtime(&runtime_directory, collector_id)
1334    })?;
1335    let configuration_started = Instant::now();
1336    configure_playwright_runtime(&runtime_directory, project)?;
1337    let playwright_config_path = write_playwright_config(workspace, project, &generated)?;
1338    let vite_config_path = write_vite_config(workspace, &generated)?;
1339    let vitest_config_path = write_vitest_config(workspace, project, &generated)?;
1340    account(&SETUP.config_ns, configuration_started);
1341
1342    let mut exclusions = Vec::new();
1343    let mut decisions = BTreeMap::new();
1344    let mut points = BTreeMap::new();
1345    let mut branches = BTreeMap::new();
1346    let mut limitations = BTreeMap::new();
1347    let mut vite_transforms = BTreeMap::new();
1348    for limitation in &project.source_limitations {
1349        limitations.insert(limitation.id.clone(), limitation_from_source(limitation));
1350    }
1351
1352    let sources_started = Instant::now();
1353    for file in &project.source_files {
1354        let path = checked_source_path(workspace, file)?;
1355        let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
1356        let capability_wrapper = runtime_specifier(file, "capability.mjs")?;
1357        let elide = crate::typescript_imports::elides_type_imports(
1358            workspace,
1359            file,
1360            command,
1361            &project.build_command,
1362        );
1363        let mut output = timed(&SETUP.instrument_ns, || {
1364            instrument_with_import_policy(
1365                &source,
1366                file,
1367                &capability_wrapper,
1368                project.build_adapter == BuildAdapter::Direct,
1369                elide,
1370            )
1371        })
1372        .map_err(|source| JavascriptFrontendError::Instrument {
1373            file: file.clone(),
1374            source,
1375        })?;
1376        if project.build_adapter == BuildAdapter::Generic {
1377            let runtime = generic_runtime_binding(workspace, project, &path, &generated)?;
1378            output.code = output.code.replace("virtual:supercov-runtime", &runtime);
1379        }
1380        // Direct commands can compile TypeScript themselves (`npm test` may
1381        // begin with `tsc`), so they need the same generated-source exemption
1382        // as Supercov's separately orchestrated generic build. Instrumentation
1383        // necessarily changes control-flow expressions in ways the host type
1384        // checker cannot narrow through, while source syntax remains covered
1385        // by the parser before this banner is applied.
1386        if project.build_adapter != BuildAdapter::Vite
1387            && matches!(
1388                path.extension().and_then(|value| value.to_str()),
1389                Some("ts" | "tsx" | "mts" | "cts")
1390            )
1391        {
1392            output.code = generated_source_banner(&output.code);
1393        }
1394        if project.build_adapter == BuildAdapter::Vite {
1395            vite_transforms.insert(
1396                file.clone(),
1397                ViteTransform {
1398                    source_sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
1399                    code: output.code.clone(),
1400                    map: output.map.clone(),
1401                },
1402            );
1403        } else {
1404            // Attach the instrumentation source map inline, pointed at the
1405            // ORIGINAL project file with the original text embedded. Node runs
1406            // with --enable-source-maps, and tsx/esbuild chain input maps, so
1407            // stack traces show the user's real path and line numbers instead
1408            // of instrumented workspace positions -- Supercov stays invisible
1409            // in errors. Without this the map was generated and then dropped.
1410            let code = match inline_instrumentation_map(
1411                &output.code,
1412                output.map.as_ref(),
1413                &project.root.join(file),
1414                &source,
1415            ) {
1416                Some(code) => code,
1417                None => output.code.clone(),
1418            };
1419            atomic_write(&path, code.as_bytes())?;
1420        }
1421        exclusions.extend(output.excluded_statements);
1422        for value in output.decisions {
1423            decisions.insert(value.id.clone(), value);
1424        }
1425        for value in output.points {
1426            points.insert(value.id.clone(), value);
1427        }
1428        for value in output.branches {
1429            branches.insert(value.id.clone(), value);
1430        }
1431        for value in output.coverage_limitations {
1432            limitations.insert(value.id.clone(), value);
1433        }
1434    }
1435    account(&SETUP.sources_ns, sources_started);
1436    write_vite_transforms(&generated, &vite_transforms)?;
1437
1438    let assertions_started = Instant::now();
1439    let mut assertion_calls = 0;
1440    for entry in &project.source_scope.entries {
1441        let path = checked_source_path(workspace, &entry.file)?;
1442        let Ok(source) = fs::read_to_string(&path) else {
1443            continue;
1444        };
1445        let capability_wrapper = (!project.source_files.contains(&entry.file))
1446            .then(|| runtime_specifier(&entry.file, "capability.mjs"))
1447            .transpose()?;
1448        let assertion_runtime = runtime_specifier(&entry.file, "runtime.mjs")?;
1449        let output = crate::js_instrumenter::instrument_node_assertion_phases_with_runtime_imports(
1450            &source,
1451            &entry.file,
1452            std::slice::from_ref(&project.playwright_module),
1453            capability_wrapper.as_deref(),
1454            Some(&assertion_runtime),
1455        )
1456        .map_err(|source| JavascriptFrontendError::Instrument {
1457            file: entry.file.clone(),
1458            source,
1459        })?;
1460        let coverage_transformed_by_vite = project.build_adapter == BuildAdapter::Vite
1461            && project.source_files.contains(&entry.file);
1462        if (output.assertions > 0 || output.capability_imports > 0) && !coverage_transformed_by_vite
1463        {
1464            atomic_write(&path, output.code.as_bytes())?;
1465            assertion_calls += output.assertions;
1466        }
1467    }
1468
1469    account(&SETUP.assertion_ns, assertions_started);
1470
1471    let mut manifest = JavascriptManifest {
1472        decisions: decisions.into_values().collect(),
1473        points: points.into_values().collect(),
1474        branches: branches.into_values().collect(),
1475        limitations: limitations.into_values().collect(),
1476        scope: project.source_scope.clone(),
1477    };
1478    manifest.decisions.sort_by_key(|value| {
1479        (
1480            value.file.clone(),
1481            value.line,
1482            value.column,
1483            value.id.clone(),
1484        )
1485    });
1486    manifest.points.sort_by_key(|value| {
1487        (
1488            value.file.clone(),
1489            value.line,
1490            value.column,
1491            value.id.clone(),
1492        )
1493    });
1494    manifest.branches.sort_by_key(|value| {
1495        (
1496            value.file.clone(),
1497            value.line,
1498            value.column,
1499            value.id.clone(),
1500        )
1501    });
1502    manifest.limitations.sort_by_key(|value| {
1503        (
1504            value.file.clone(),
1505            value.line,
1506            value.column,
1507            value.id.clone(),
1508        )
1509    });
1510
1511    atomic_write(
1512        &generated.join("statement-exclusions.json"),
1513        &serde_json::to_vec(&exclusions).map_err(JavascriptFrontendError::Serialize)?,
1514    )?;
1515    let manifest_path = generated.join("manifest.json");
1516    let mut encoded =
1517        serde_json::to_vec_pretty(&manifest).map_err(JavascriptFrontendError::Serialize)?;
1518    encoded.push(b'\n');
1519    atomic_write(&manifest_path, &encoded)?;
1520    atomic_write(
1521        &generated.join("instrumentation-complete"),
1522        b"coverage-completeness-v2\n",
1523    )?;
1524    timed(&SETUP.cache_ns, || {
1525        write_javascript_frontend_cache(workspace, project, cache_key, assertion_calls)
1526    })?;
1527    Ok(PreparedJavascriptFrontend {
1528        manifest,
1529        manifest_path,
1530        preload_path: generated.join("node_modules/register.mjs"),
1531        playwright_config_path,
1532        vite_config_path,
1533        vitest_config_path,
1534        assertion_calls,
1535    })
1536}
1537
1538#[cfg(test)]
1539mod tests {
1540    #[test]
1541    fn ancestor_description_names_each_component_and_its_state() {
1542        let root = std::env::temp_dir().join(format!("supercov-ancestors-{}", unique()));
1543        fs::create_dir_all(root.join("present")).unwrap();
1544        fs::write(root.join("present/file.txt"), b"x").unwrap();
1545        let described = super::describe_ancestors(&root.join("present/file.txt/child"));
1546        assert!(described.contains("present=dir"), "{described}");
1547        assert!(described.contains("file.txt=file"), "{described}");
1548        assert!(described.ends_with("child=missing"), "{described}");
1549        fs::remove_dir_all(&root).unwrap();
1550    }
1551
1552    use super::*;
1553    use crate::project_discovery::discover_coverage_project;
1554
1555    fn temporary(name: &str) -> PathBuf {
1556        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1557            .join("../../target/supercov-test-fixtures")
1558            .join(format!("javascript-frontend-{name}-{}", unique()));
1559        // These tests validate frontend contents and manifest construction.
1560        // The dedicated workspace/platform suite owns directory-creation,
1561        // link, rename, ENOSPC, crash, and cleanup behavior on every OS. Keep
1562        // semantic fixtures in Cargo's ignored target tree so hosted-runner
1563        // policies on the system temporary directory cannot affect them.
1564        fs::create_dir_all(&path).unwrap();
1565        crate::workspace::canonicalize_simplified(path).unwrap()
1566    }
1567
1568    #[test]
1569    fn browser_mode_gets_a_browser_setup_and_the_command_that_carries_its_evidence() {
1570        let generated = temporary("browser-mode-config");
1571        write_vite_transforms(&generated, &BTreeMap::new()).unwrap();
1572        let adapter = fs::read_to_string(generated.join("viteInstrumentation.mjs")).unwrap();
1573        // Evidence leaves the browser over Vitest's own command channel. The
1574        // dev-server endpoint this replaced was reachable by anything loaded in
1575        // that realm and took the test file's identity on the browser's word.
1576        assert!(adapter.contains("export function supercovBrowserCommands(root)"));
1577        assert!(adapter.contains("__supercovEvidence(context, payload, suffix)"));
1578        assert!(adapter.contains("const absolute = context.testPath;"));
1579        assert!(!adapter.contains("configureServer"), "{adapter}");
1580        assert!(!adapter.contains("__supercov/evidence"), "{adapter}");
1581        // A suffix names one directory beneath the evidence directory; a
1582        // traversing one has to be refused rather than resolved.
1583        assert!(adapter.contains("supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/"));
1584        assert!(adapter.contains("Supercov refused an unusable evidence suffix"));
1585    }
1586
1587    #[test]
1588    fn a_browser_mode_project_loads_the_setup_that_can_run_in_a_browser() {
1589        // The node setup reaches node:fs through atomic.mjs; Vite externalises
1590        // that for the client, and the suite fails before collecting a test.
1591        let workspace = temporary("browser-mode-setup");
1592        let generated = workspace.join(".supercov");
1593        fs::create_dir_all(&generated).unwrap();
1594        fs::write(workspace.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1595        fs::create_dir_all(workspace.join("src")).unwrap();
1596        fs::write(
1597            workspace.join("src/example.mjs"),
1598            "export const one = () => 1;\n",
1599        )
1600        .unwrap();
1601        let project = discover_coverage_project(
1602            &workspace,
1603            &BTreeMap::new(),
1604            &["npx".into(), "vitest".into(), "run".into()],
1605        )
1606        .unwrap();
1607        let path = write_vitest_config(&workspace, &project, &generated).unwrap();
1608        let config = fs::read_to_string(path).unwrap();
1609        assert!(config.contains("config?.test?.browser?.enabled"));
1610        assert!(config.contains("'.supercov/node_modules/vitestBrowser.mjs'"));
1611        assert!(config.contains("'.supercov/node_modules/vitest.mjs'"));
1612        // The command exists only for the mode that uses it.
1613        assert!(config.contains("browser: { commands: supercovBrowserCommands(process.cwd()) }"));
1614        assert!(config.contains("...supercovBrowserTest(loaded?.config)"));
1615    }
1616
1617    #[test]
1618    fn runtime_isolation_replaces_only_the_assignment_marker() {
1619        let source = concat!(
1620            "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n",
1621            "const selected = runtimeInstanceToken === \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\";\n"
1622        );
1623        let isolated = isolate_runtime(source, "collector-123").unwrap();
1624        assert!(isolated.contains("runtimeInstanceToken = \"collector-123\""));
1625        assert!(isolated.contains("=== \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\""));
1626    }
1627
1628    #[test]
1629    fn copied_runtime_does_not_reference_unshipped_source_maps() {
1630        let generated = temporary("runtime-source-maps");
1631        copy_runtime(&generated, "collector-test").unwrap();
1632        for name in ["vitest.mjs", "provenance.mjs", "atomic.mjs"] {
1633            let contents = fs::read_to_string(generated.join(name)).unwrap();
1634            assert!(
1635                !contents.contains("sourceMappingURL"),
1636                "runtime shim retained a source-map directive: {name}"
1637            );
1638        }
1639        fs::remove_dir_all(generated).unwrap();
1640    }
1641
1642    #[test]
1643    fn prepares_sorted_complete_manifest_without_touching_source_project() {
1644        let source_root = temporary("source");
1645        let workspace = temporary("workspace");
1646        fs::create_dir_all(source_root.join("src")).unwrap();
1647        fs::write(
1648            source_root.join("src/example.mjs"),
1649            "export function value(a, b) { if (a || b) return 1; return 0; }\n",
1650        )
1651        .unwrap();
1652        fs::write(source_root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1653        fs::create_dir_all(workspace.join("src")).unwrap();
1654        fs::create_dir_all(workspace.join(".supercov")).unwrap();
1655        fs::copy(
1656            source_root.join("src/example.mjs"),
1657            workspace.join("src/example.mjs"),
1658        )
1659        .unwrap();
1660        let project = discover_coverage_project(
1661            &source_root,
1662            &BTreeMap::new(),
1663            &["node".into(), "--test".into()],
1664        )
1665        .unwrap();
1666        let original = fs::read_to_string(source_root.join("src/example.mjs")).unwrap();
1667        let prepared =
1668            prepare_javascript_frontend(&workspace, &project, "collector-test", "cache-test", &[])
1669                .unwrap();
1670        assert_eq!(
1671            fs::read_to_string(source_root.join("src/example.mjs")).unwrap(),
1672            original
1673        );
1674        let transformed = fs::read_to_string(workspace.join("src/example.mjs")).unwrap();
1675        assert!(transformed.contains("__SUPERCOV_DIRECT_RUNTIME__"));
1676        assert_eq!(prepared.manifest.decisions.len(), 1);
1677        assert!(!prepared.manifest.points.is_empty());
1678        assert_eq!(prepared.manifest.scope, project.source_scope);
1679        assert!(prepared.manifest_path.is_file());
1680        assert!(prepared.preload_path.is_file());
1681        assert!(prepared.playwright_config_path.is_file());
1682        assert!(prepared.vite_config_path.is_file());
1683        assert!(
1684            fs::read_to_string(&prepared.vite_config_path)
1685                .unwrap()
1686                .contains("logLevel: ['1', 'true', 'yes'].includes")
1687        );
1688        assert!(prepared.vitest_config_path.is_file());
1689        assert_eq!(prepared.assertion_calls, 0);
1690        let cache = read_javascript_frontend_cache(&workspace, "cache-test").unwrap();
1691        assert_eq!(
1692            javascript_frontend_reuse_paths(&cache),
1693            [
1694                PathBuf::from(".supercov/frontend-cache.json"),
1695                PathBuf::from(".supercov/frontend-cache-artifacts"),
1696            ]
1697        );
1698        assert!(
1699            cache
1700                .artifacts
1701                .iter()
1702                .all(|artifact| !artifact.cache_file.contains("src/")
1703                    && !artifact.cache_file.contains("tests/"))
1704        );
1705        fs::write(workspace.join("src/example.mjs"), &original).unwrap();
1706        fs::remove_file(&prepared.manifest_path).unwrap();
1707        let restored = load_cached_javascript_frontend(&workspace, &cache).unwrap();
1708        assert_eq!(restored.manifest, prepared.manifest);
1709        assert_eq!(
1710            fs::read_to_string(workspace.join("src/example.mjs")).unwrap(),
1711            transformed
1712        );
1713        fs::write(workspace.join(&cache.artifacts[0].cache_file), "corrupt").unwrap();
1714        assert!(read_javascript_frontend_cache(&workspace, "cache-test").is_none());
1715        fs::remove_dir_all(source_root).unwrap();
1716        fs::remove_dir_all(workspace).unwrap();
1717    }
1718
1719    #[test]
1720    fn embedded_runtime_contains_every_declared_shim() {
1721        for name in RUNTIME_FILES {
1722            let bytes = embedded_runtime(name).unwrap();
1723            assert!(!bytes.is_empty(), "embedded runtime is empty: {name}");
1724        }
1725        assert!(
1726            std::str::from_utf8(embedded_runtime("runtime.mjs").unwrap())
1727                .unwrap()
1728                .contains(RUNTIME_INSTANCE_MARKER)
1729        );
1730    }
1731}