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