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