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