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