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