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        format!("import original from {specifier};\n")
1331    } else {
1332        "const original = {};\n".into()
1333    };
1334    // A TypeScript config in a CommonJS package is transpiled to CommonJS and
1335    // imported here as its module namespace, `{ __esModule: true, default }`,
1336    // not as the config. Spreading the namespace lost `testDir`, `use` and
1337    // `webServer`, and Playwright found no tests. Only an explicitly marked
1338    // namespace is unwrapped; a config that merely has a `default` key is not.
1339    let source = format!(
1340        "import './node_modules/register.mjs';\n\
1341         import {{ dirname, isAbsolute, relative, resolve }} from 'node:path';\n\
1342         import {{ fileURLToPath }} from 'node:url';\n\
1343         {original_import}\
1344         const configExport = original && original.__esModule === true && Object.prototype.hasOwnProperty.call(original, 'default') ? original.default : original;\n\
1345         const resolvedValue = typeof configExport === 'function' ? await configExport({{ command: 'test', mode: 'test' }}) : configExport;\n\
1346         const resolved = resolvedValue ?? {{}};\n\
1347         const runtimeProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');\n\
1348         const originalDirectory = {};
1349         const sourceProjectRoot = process.env.SUPERCOV_SOURCE_PROJECT_ROOT;\n\
1350         const runtimePath = value => {{\n\
1351           if (!value) return value;\n\
1352           const absolute = isAbsolute(value) ? value : resolve(originalDirectory, value);\n\
1353           const local = relative(runtimeProjectRoot, absolute);\n\
1354           if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1355           if (sourceProjectRoot) {{\n\
1356             const sourceLocal = relative(sourceProjectRoot, absolute);\n\
1357             if (sourceLocal === '' || (!sourceLocal.startsWith('..') && !isAbsolute(sourceLocal))) return resolve(runtimeProjectRoot, sourceLocal);\n\
1358           }}\n\
1359           throw new Error('Supercov refuses a Playwright output/cwd outside the isolated project: ' + absolute);\n\
1360         }};\n\
1361         const normalizeWebServer = server => server ? ({{ ...server, cwd: runtimePath(server.cwd ?? originalDirectory) }}) : server;\n\
1362         const normalized = {{ ...resolved,\n\
1363           testDir: runtimePath(resolved.testDir),\n\
1364           outputDir: runtimePath(resolved.outputDir),\n\
1365           snapshotDir: runtimePath(resolved.snapshotDir),\n\
1366           projects: resolved.projects?.map(project => ({{ ...project, testDir: runtimePath(project.testDir), outputDir: runtimePath(project.outputDir), snapshotDir: runtimePath(project.snapshotDir) }})),\n\
1367           webServer: Array.isArray(resolved.webServer) ? resolved.webServer.map(normalizeWebServer) : normalizeWebServer(resolved.webServer),\n\
1368         }};\n\
1369         const configuredReporters = normalized.reporter;\n\
1370         const reporters = configuredReporters\n\
1371           ? (typeof configuredReporters === 'string' ? [[configuredReporters]] : (Array.isArray(configuredReporters[0]) ? configuredReporters : [configuredReporters]))\n\
1372           : [['list']];\n\
1373         const coverageReporter = resolve(runtimeProjectRoot, '.supercov/node_modules/playwrightReporter.mjs');\n\
1374         export default {{ ...normalized, reporter: [...reporters, [coverageReporter]] }};\n",
1375        serde_json::to_string(
1376            &original
1377                .as_ref()
1378                .and_then(|path| path.parent())
1379                .unwrap_or(workspace)
1380                .display()
1381                .to_string()
1382        )
1383        .map_err(JavascriptFrontendError::Serialize)?
1384    );
1385    atomic_write(&path, source.as_bytes())?;
1386    Ok(path)
1387}
1388
1389fn write_vite_config(
1390    workspace: &Path,
1391    generated: &Path,
1392) -> Result<PathBuf, JavascriptFrontendError> {
1393    let path = generated.join("vite.config.mjs");
1394    let workspace = serde_json::to_string(&workspace.display().to_string())
1395        .map_err(JavascriptFrontendError::Serialize)?;
1396    let source = format!(
1397        "import {{ createRequire }} from 'node:module';\n\
1398         import {{ pathToFileURL }} from 'node:url';\n\
1399         // pnpm's strict layout does not hoist vite to the project root: it\n\
1400         // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
1401         // resolved from this generated file fails. Vitest depends on vite, so\n\
1402         // fall back to resolving it through vitest's own tree rather than\n\
1403         // requiring the project to hoist anything.\n\
1404         const supercovRequire = createRequire(import.meta.url);\n\
1405         const supercovLoadVite = async () => {{\n\
1406           try {{\n\
1407             return await import('vite');\n\
1408           }} catch (error) {{\n\
1409             let entry;\n\
1410             try {{\n\
1411               entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
1412             }} catch {{\n\
1413               throw error;\n\
1414             }}\n\
1415             return await import(pathToFileURL(entry).href);\n\
1416           }}\n\
1417         }};\n\
1418         const viteNamespace = await supercovLoadVite();\n\
1419         import {{ isAbsolute, relative, resolve }} from 'node:path';\n\
1420         import {{ supercovViteInstrumentation }} from './viteInstrumentation.mjs';\n\
1421         const vite = viteNamespace.default ?? viteNamespace;\n\
1422         const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
1423         export default async function supercovViteConfig(env) {{\n\
1424           const isolatedRoot = {workspace};\n\
1425           const loaded = await loadConfigFromFile(env, undefined, isolatedRoot);\n\
1426           const config = loaded?.config ?? {{}};\n\
1427           const relocate = (value, label) => {{\n\
1428             const absolute = isAbsolute(value) ? value : resolve(isolatedRoot, value);\n\
1429             const local = relative(isolatedRoot, absolute);\n\
1430             if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1431             throw new Error('Supercov refuses ' + label + ' outside the isolated project: ' + absolute);\n\
1432           }};\n\
1433           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\
1434           const rollupOutput = config.build?.rollupOptions?.output;\n\
1435           const safe = {{ ...config,\n\
1436             logLevel: ['1', 'true', 'yes'].includes(process.env.SUPERCOV_VERBOSE ?? process.env.SUPERCOV_DEBUG ?? '') ? config.logLevel : 'error',\n\
1437             cacheDir: resolve(isolatedRoot, '.supercov/vite-cache'),\n\
1438             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\
1439           }};\n\
1440           return mergeConfig(safe, {{ plugins: [supercovViteInstrumentation(isolatedRoot)] }});\n\
1441         }}\n"
1442    );
1443    atomic_write(&path, source.as_bytes())?;
1444    Ok(path)
1445}
1446
1447fn write_vite_transforms(
1448    generated: &Path,
1449    transforms: &BTreeMap<String, ViteTransform>,
1450) -> Result<(), JavascriptFrontendError> {
1451    let mut payload = serde_json::to_vec(transforms).map_err(JavascriptFrontendError::Serialize)?;
1452    payload.push(b'\n');
1453    atomic_write(&generated.join("vite-transforms.json"), &payload)?;
1454    let adapter = "import { createHash } from 'node:crypto';\n\
1455import { mkdirSync, readFileSync } from 'node:fs';\n\
1456import { relative, resolve, sep } from 'node:path';\n\
1457import { atomicWriteFileSync } from './node_modules/atomic.mjs';\n\
1458import { inferTestProvenance } from './node_modules/provenance.mjs';\n\
1459const transforms = JSON.parse(readFileSync(new URL('./vite-transforms.json', import.meta.url), 'utf8'));\n\
1460const sha256 = value => createHash('sha256').update(value).digest('hex');\n\
1461// Browser Mode runs the test file in the browser, where none of this is\n\
1462// reachable: no node:fs to write evidence, no project root to relativise a\n\
1463// path against, no env to read. Vitest's own browser command channel carries\n\
1464// what the browser observed to node, which completes the same payload the node\n\
1465// setup writes -- so one contract keeps one implementation. The command is\n\
1466// awaited as part of the test, and node learns the test file from Vitest\n\
1467// rather than from the browser realm, which cannot be trusted to name it.\n\
1468const supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/;\n\
1469export function supercovBrowserCommands(root) {\n\
1470  return {\n\
1471    __supercovEvidence(context, payload, suffix) {\n\
1472      const evidenceDirectory = process.env['SUPERCOV_EVIDENCE_DIR'];\n\
1473      if (!evidenceDirectory) return;\n\
1474      // A suffix names one directory under the evidence directory. Anything\n\
1475      // else is refused rather than resolved, so a malformed one cannot send\n\
1476      // the write outside the directory it is supposed to stay in.\n\
1477      if (typeof suffix !== 'string' || !supercovEvidenceSuffix.test(suffix))\n\
1478        throw new Error('Supercov refused an unusable evidence suffix: ' + suffix);\n\
1479      const absolute = context.testPath;\n\
1480      const testFile = absolute ? relative(root, absolute).split(sep).join('/') : undefined;\n\
1481      const projectName = payload.projectName ?? context.project?.name;\n\
1482      delete payload.projectName;\n\
1483      // The browser has no environment to read, so the run identity is stamped\n\
1484      // here rather than sent from a realm that cannot know it.\n\
1485      const runId = process.env['SUPERCOV_RUN_ID'];\n\
1486      if (runId && payload.scope) payload.scope.runId = runId;\n\
1487      const complete = Object.assign({}, payload, testFile ? { testFile } : {}, {\n\
1488        provenance: inferTestProvenance({\n\
1489          runner: 'vitest',\n\
1490          file: testFile,\n\
1491          project: projectName,\n\
1492          explicitKind: process.env['SUPERCOV_TEST_KIND'],\n\
1493        }),\n\
1494      });\n\
1495      const directory = resolve(root, evidenceDirectory, suffix);\n\
1496      mkdirSync(directory, { recursive: true });\n\
1497      atomicWriteFileSync(resolve(directory, 'mcdc.json'), JSON.stringify(complete) + '\\n');\n\
1498    },\n\
1499  };\n\
1500}\n\
1501export function supercovViteInstrumentation(root) {\n\
1502  const runtimePath = resolve(root, '.supercov/node_modules/applicationRuntime.mjs');\n\
1503  return {\n\
1504    name: 'supercov-rust-instrumentation',\n\
1505    enforce: 'pre',\n\
1506    resolveId(id) { return id === 'virtual:supercov-runtime' ? runtimePath : null; },\n\
1507    transform(code, rawId) {\n\
1508      const id = rawId.split('?')[0] ?? rawId;\n\
1509      const local = relative(root, id).split(sep).join('/');\n\
1510      const transformed = transforms[local];\n\
1511      if (!transformed) return null;\n\
1512      if (sha256(code) !== transformed.sourceSha256)\n\
1513        throw new Error('Supercov source changed before Rust instrumentation: ' + local);\n\
1514      return { code: transformed.code, map: transformed.map ?? null };\n\
1515    },\n\
1516  };\n\
1517}\n";
1518    atomic_write(
1519        &generated.join("viteInstrumentation.mjs"),
1520        adapter.as_bytes(),
1521    )
1522}
1523
1524/// Prepare the complete JavaScript frontend inside an isolated workspace.
1525/// The source project is read only through the copied workspace inventory.
1526pub fn prepare_javascript_frontend(
1527    workspace: &Path,
1528    project: &CoverageProject,
1529    collector_id: &str,
1530    cache_key: &str,
1531    command: &[String],
1532) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
1533    let generated = workspace.join(".supercov");
1534    // Runtime code files live under a node_modules segment: Node attributes
1535    // stack frames from node_modules paths to dependency infrastructure, so
1536    // deprecation warnings the user's own run would suppress (Node's
1537    // isInsideNodeModules check) stay suppressed when Supercov's module
1538    // hooks are on the call path.
1539    let runtime_directory = generated.join("node_modules");
1540    timed(&SETUP.runtime_ns, || {
1541        copy_runtime(&runtime_directory, collector_id)
1542    })?;
1543    let configuration_started = Instant::now();
1544    configure_playwright_runtime(&runtime_directory, project)?;
1545    let playwright_config_path = write_playwright_config(workspace, project, &generated)?;
1546    let vite_config_path = write_vite_config(workspace, &generated)?;
1547    let vitest_config_path = write_vitest_config(workspace, project, &generated)?;
1548    account(&SETUP.config_ns, configuration_started);
1549
1550    let mut exclusions = Vec::new();
1551    let mut decisions = BTreeMap::new();
1552    let mut points = BTreeMap::new();
1553    let mut branches = BTreeMap::new();
1554    let mut limitations = BTreeMap::new();
1555    let mut vite_transforms = BTreeMap::new();
1556    for limitation in &project.source_limitations {
1557        limitations.insert(limitation.id.clone(), limitation_from_source(limitation));
1558    }
1559
1560    let runtime_path = runtime_directory.join("runtime.mjs");
1561    let standalone_runtime =
1562        fs::read_to_string(&runtime_path).map_err(|source| io_error(&runtime_path, source))?;
1563    // debug's karma bundles its sources with browserify and runs them in
1564    // Chrome, where nothing had installed the runtime: every file failed on
1565    // its first line ("reading 'mcdcBegin'"). The browser's evidence has no
1566    // adapter to reach the run, but its tests pass as they do without it.
1567    let browser_suite = project.playwright_config.is_some() || project.browser_runner;
1568    let mut authored = BTreeSet::new();
1569    let sources_started = Instant::now();
1570    for file in &project.source_files {
1571        let path = checked_source_path(workspace, file)?;
1572        let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
1573        let capability_wrapper = runtime_specifier(file, "capability.mjs")?;
1574        let elide = crate::typescript_imports::elides_type_imports(
1575            workspace,
1576            file,
1577            command,
1578            &project.build_command,
1579        );
1580        let instrumented = timed(&SETUP.instrument_ns, || {
1581            instrument_with_import_policy(
1582                &source,
1583                file,
1584                &capability_wrapper,
1585                project.build_adapter == BuildAdapter::Direct,
1586                elide,
1587            )
1588        });
1589        let mut output = match instrumented {
1590            Ok(output) => output,
1591            // A file the parser rejects runs as written: the suite may never
1592            // load it, and one that does fails there as it would without
1593            // Supercov. Nothing in it is measured, and the run says so.
1594            Err(crate::js_instrumenter::CandidateError::Parse(messages)) => {
1595                let limitation = CandidateLimitation {
1596                    id: format!("javascript-file-not-parsed#{file}"),
1597                    kind: "source-scope".into(),
1598                    file: file.clone(),
1599                    line: 1,
1600                    column: 0,
1601                    source: String::new(),
1602                    reason: format!(
1603                        "Supercov could not parse this file ({}); it runs as written and nothing in it is measured",
1604                        messages.join("; ")
1605                    ),
1606                };
1607                limitations.insert(limitation.id.clone(), limitation);
1608                continue;
1609            }
1610            Err(source) => {
1611                return Err(JavascriptFrontendError::Instrument {
1612                    file: file.clone(),
1613                    source,
1614                });
1615            }
1616        };
1617        if project.build_adapter == BuildAdapter::Generic {
1618            let runtime = generic_runtime_binding(workspace, project, &path, &generated)?;
1619            output.code = output.code.replace("virtual:supercov-runtime", &runtime);
1620        }
1621        bootstrap_runtime(&mut output, &standalone_runtime, browser_suite);
1622        if project.build_adapter == BuildAdapter::Direct && !browser_suite {
1623            bootstrap_loader(&mut output, file)?;
1624        }
1625        // Direct commands can compile TypeScript themselves (`npm test` may
1626        // begin with `tsc`), so they need the same generated-source exemption
1627        // as Supercov's separately orchestrated generic build. Instrumentation
1628        // necessarily changes control-flow expressions in ways the host type
1629        // checker cannot narrow through, while source syntax remains covered
1630        // by the parser before this banner is applied.
1631        if project.build_adapter != BuildAdapter::Vite
1632            && matches!(
1633                path.extension().and_then(|value| value.to_str()),
1634                Some("ts" | "tsx" | "mts" | "cts")
1635            )
1636        {
1637            output.code = generated_source_banner(&output.code);
1638        }
1639        if project.build_adapter == BuildAdapter::Vite {
1640            vite_transforms.insert(
1641                file.clone(),
1642                ViteTransform {
1643                    source_sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
1644                    code: output.code.clone(),
1645                    map: output.map.clone(),
1646                },
1647            );
1648        } else {
1649            // Attach the instrumentation source map inline, pointed at the
1650            // ORIGINAL project file with the original text embedded. Node runs
1651            // with --enable-source-maps, and tsx/esbuild chain input maps, so
1652            // stack traces show the user's real path and line numbers instead
1653            // of instrumented workspace positions -- Supercov stays invisible
1654            // in errors. Without this the map was generated and then dropped.
1655            let code = match inline_instrumentation_map(
1656                &output.code,
1657                output.map.as_ref(),
1658                &project.root.join(file),
1659                &source,
1660            ) {
1661                Some(code) => code,
1662                None => output.code.clone(),
1663            };
1664            keep_authored(workspace, &mut authored, file, &source)?;
1665            atomic_write(&path, code.as_bytes())?;
1666        }
1667        exclusions.extend(output.excluded_statements);
1668        for value in output.decisions {
1669            decisions.insert(value.id.clone(), value);
1670        }
1671        for value in output.points {
1672            points.insert(value.id.clone(), value);
1673        }
1674        for value in output.branches {
1675            branches.insert(value.id.clone(), value);
1676        }
1677        for value in output.coverage_limitations {
1678            limitations.insert(value.id.clone(), value);
1679        }
1680    }
1681    account(&SETUP.sources_ns, sources_started);
1682    write_vite_transforms(&generated, &vite_transforms)?;
1683
1684    let assertions_started = Instant::now();
1685    let mut assertion_calls = 0;
1686    for entry in &project.source_scope.entries {
1687        let path = checked_source_path(workspace, &entry.file)?;
1688        let Ok(source) = fs::read_to_string(&path) else {
1689            continue;
1690        };
1691        let capability_wrapper = (!project.source_files.contains(&entry.file))
1692            .then(|| runtime_specifier(&entry.file, "capability.mjs"))
1693            .transpose()?;
1694        let assertion_runtime = runtime_specifier(&entry.file, "bootstrap.cjs")?;
1695        let output =
1696            match crate::js_instrumenter::instrument_node_assertion_phases_with_runtime_imports(
1697                &source,
1698                &entry.file,
1699                std::slice::from_ref(&project.playwright_module),
1700                capability_wrapper.as_deref(),
1701                Some(&assertion_runtime),
1702            ) {
1703                Ok(output) => output,
1704                // A test file the parser rejects is left as written; its runner
1705                // reports it, and it has no assertions to track.
1706                Err(crate::js_instrumenter::CandidateError::Parse(_)) => continue,
1707                Err(source) => {
1708                    return Err(JavascriptFrontendError::Instrument {
1709                        file: entry.file.clone(),
1710                        source,
1711                    });
1712                }
1713            };
1714        let coverage_transformed_by_vite = project.build_adapter == BuildAdapter::Vite
1715            && project.source_files.contains(&entry.file);
1716        if (output.assertions > 0 || output.capability_imports > 0) && !coverage_transformed_by_vite
1717        {
1718            keep_authored(workspace, &mut authored, &entry.file, &source)?;
1719            atomic_write(&path, output.code.as_bytes())?;
1720            assertion_calls += output.assertions;
1721        }
1722    }
1723
1724    account(&SETUP.assertion_ns, assertions_started);
1725
1726    let mut manifest = JavascriptManifest {
1727        decisions: decisions.into_values().collect(),
1728        points: points.into_values().collect(),
1729        branches: branches.into_values().collect(),
1730        limitations: limitations.into_values().collect(),
1731        scope: project.source_scope.clone(),
1732    };
1733    manifest.decisions.sort_by_key(|value| {
1734        (
1735            value.file.clone(),
1736            value.line,
1737            value.column,
1738            value.id.clone(),
1739        )
1740    });
1741    manifest.points.sort_by_key(|value| {
1742        (
1743            value.file.clone(),
1744            value.line,
1745            value.column,
1746            value.id.clone(),
1747        )
1748    });
1749    manifest.branches.sort_by_key(|value| {
1750        (
1751            value.file.clone(),
1752            value.line,
1753            value.column,
1754            value.id.clone(),
1755        )
1756    });
1757    manifest.limitations.sort_by_key(|value| {
1758        (
1759            value.file.clone(),
1760            value.line,
1761            value.column,
1762            value.id.clone(),
1763        )
1764    });
1765
1766    atomic_write(
1767        &workspace.join(AUTHORED_LIST),
1768        &serde_json::to_vec(&authored).map_err(JavascriptFrontendError::Serialize)?,
1769    )?;
1770    atomic_write(
1771        &generated.join("statement-exclusions.json"),
1772        &serde_json::to_vec(&exclusions).map_err(JavascriptFrontendError::Serialize)?,
1773    )?;
1774    let manifest_path = generated.join("manifest.json");
1775    let mut encoded =
1776        serde_json::to_vec_pretty(&manifest).map_err(JavascriptFrontendError::Serialize)?;
1777    encoded.push(b'\n');
1778    atomic_write(&manifest_path, &encoded)?;
1779    atomic_write(
1780        &generated.join("instrumentation-complete"),
1781        b"coverage-completeness-v2\n",
1782    )?;
1783    timed(&SETUP.cache_ns, || {
1784        write_javascript_frontend_cache(workspace, project, cache_key, assertion_calls)
1785    })?;
1786    Ok(PreparedJavascriptFrontend {
1787        manifest,
1788        manifest_path,
1789        preload_path: generated.join("node_modules/register.mjs"),
1790        playwright_config_path,
1791        vite_config_path,
1792        vitest_config_path,
1793        assertion_calls,
1794    })
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799    #[test]
1800    fn ancestor_description_names_each_component_and_its_state() {
1801        let root = std::env::temp_dir().join(format!("supercov-ancestors-{}", unique()));
1802        fs::create_dir_all(root.join("present")).unwrap();
1803        fs::write(root.join("present/file.txt"), b"x").unwrap();
1804        let described = super::describe_ancestors(&root.join("present/file.txt/child"));
1805        assert!(described.contains("present=dir"), "{described}");
1806        assert!(described.contains("file.txt=file"), "{described}");
1807        assert!(described.ends_with("child=missing"), "{described}");
1808        fs::remove_dir_all(&root).unwrap();
1809    }
1810
1811    use super::*;
1812    use crate::project_discovery::discover_coverage_project;
1813
1814    fn temporary(name: &str) -> PathBuf {
1815        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1816            .join("../../target/supercov-test-fixtures")
1817            .join(format!("javascript-frontend-{name}-{}", unique()));
1818        // These tests validate frontend contents and manifest construction.
1819        // The dedicated workspace/platform suite owns directory-creation,
1820        // link, rename, ENOSPC, crash, and cleanup behavior on every OS. Keep
1821        // semantic fixtures in Cargo's ignored target tree so hosted-runner
1822        // policies on the system temporary directory cannot affect them.
1823        fs::create_dir_all(&path).unwrap();
1824        crate::workspace::canonicalize_simplified(path).unwrap()
1825    }
1826
1827    #[test]
1828    fn browser_mode_gets_a_browser_setup_and_the_command_that_carries_its_evidence() {
1829        let generated = temporary("browser-mode-config");
1830        write_vite_transforms(&generated, &BTreeMap::new()).unwrap();
1831        let adapter = fs::read_to_string(generated.join("viteInstrumentation.mjs")).unwrap();
1832        // Evidence leaves the browser over Vitest's own command channel. The
1833        // dev-server endpoint this replaced was reachable by anything loaded in
1834        // that realm and took the test file's identity on the browser's word.
1835        assert!(adapter.contains("export function supercovBrowserCommands(root)"));
1836        assert!(adapter.contains("__supercovEvidence(context, payload, suffix)"));
1837        assert!(adapter.contains("const absolute = context.testPath;"));
1838        assert!(!adapter.contains("configureServer"), "{adapter}");
1839        assert!(!adapter.contains("__supercov/evidence"), "{adapter}");
1840        // A suffix names one directory beneath the evidence directory; a
1841        // traversing one has to be refused rather than resolved.
1842        assert!(adapter.contains("supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/"));
1843        assert!(adapter.contains("Supercov refused an unusable evidence suffix"));
1844    }
1845
1846    #[test]
1847    fn a_browser_mode_project_loads_the_setup_that_can_run_in_a_browser() {
1848        // The node setup reaches node:fs through atomic.mjs; Vite externalises
1849        // that for the client, and the suite fails before collecting a test.
1850        let workspace = temporary("browser-mode-setup");
1851        let generated = workspace.join(".supercov");
1852        fs::create_dir_all(&generated).unwrap();
1853        fs::write(workspace.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1854        fs::create_dir_all(workspace.join("src")).unwrap();
1855        fs::write(
1856            workspace.join("src/example.mjs"),
1857            "export const one = () => 1;\n",
1858        )
1859        .unwrap();
1860        let project = discover_coverage_project(
1861            &workspace,
1862            &BTreeMap::new(),
1863            &["npx".into(), "vitest".into(), "run".into()],
1864        )
1865        .unwrap();
1866        let path = write_vitest_config(&workspace, &project, &generated).unwrap();
1867        let config = fs::read_to_string(path).unwrap();
1868        assert!(config.contains("config?.test?.browser?.enabled"));
1869        assert!(config.contains("'.supercov/node_modules/vitestBrowser.mjs'"));
1870        assert!(config.contains("'.supercov/node_modules/vitest.mjs'"));
1871        // The command exists only for the mode that uses it.
1872        assert!(config.contains("browser: { commands: supercovBrowserCommands(process.cwd()) }"));
1873        assert!(config.contains("...supercovBrowserTest(loaded?.config)"));
1874    }
1875
1876    #[test]
1877    fn runtime_isolation_replaces_only_the_assignment_marker() {
1878        let source = concat!(
1879            "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n",
1880            "const selected = runtimeInstanceToken === \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\";\n"
1881        );
1882        let isolated = isolate_runtime(source, "collector-123").unwrap();
1883        assert!(isolated.contains("runtimeInstanceToken = \"collector-123\""));
1884        assert!(isolated.contains("=== \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\""));
1885    }
1886
1887    #[test]
1888    fn copied_runtime_does_not_reference_unshipped_source_maps() {
1889        let generated = temporary("runtime-source-maps");
1890        copy_runtime(&generated, "collector-test").unwrap();
1891        for name in ["vitest.mjs", "provenance.mjs", "atomic.mjs"] {
1892            let contents = fs::read_to_string(generated.join(name)).unwrap();
1893            assert!(
1894                !contents.contains("sourceMappingURL"),
1895                "runtime shim retained a source-map directive: {name}"
1896            );
1897        }
1898        fs::remove_dir_all(generated).unwrap();
1899    }
1900
1901    #[test]
1902    fn prepares_sorted_complete_manifest_without_touching_source_project() {
1903        let source_root = temporary("source");
1904        let workspace = temporary("workspace");
1905        fs::create_dir_all(source_root.join("src")).unwrap();
1906        fs::write(
1907            source_root.join("src/example.mjs"),
1908            "export function value(a, b) { if (a || b) return 1; return 0; }\n",
1909        )
1910        .unwrap();
1911        fs::write(source_root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1912        fs::create_dir_all(workspace.join("src")).unwrap();
1913        fs::create_dir_all(workspace.join(".supercov")).unwrap();
1914        fs::copy(
1915            source_root.join("src/example.mjs"),
1916            workspace.join("src/example.mjs"),
1917        )
1918        .unwrap();
1919        let project = discover_coverage_project(
1920            &source_root,
1921            &BTreeMap::new(),
1922            &["node".into(), "--test".into()],
1923        )
1924        .unwrap();
1925        let original = fs::read_to_string(source_root.join("src/example.mjs")).unwrap();
1926        let prepared =
1927            prepare_javascript_frontend(&workspace, &project, "collector-test", "cache-test", &[])
1928                .unwrap();
1929        assert_eq!(
1930            fs::read_to_string(source_root.join("src/example.mjs")).unwrap(),
1931            original
1932        );
1933        let transformed = fs::read_to_string(workspace.join("src/example.mjs")).unwrap();
1934        assert!(transformed.contains("__SUPERCOV_DIRECT_RUNTIME__"));
1935        assert_eq!(prepared.manifest.decisions.len(), 1);
1936        assert!(!prepared.manifest.points.is_empty());
1937        assert_eq!(prepared.manifest.scope, project.source_scope);
1938        assert!(prepared.manifest_path.is_file());
1939        assert!(prepared.preload_path.is_file());
1940        assert!(prepared.playwright_config_path.is_file());
1941        assert!(prepared.vite_config_path.is_file());
1942        assert!(
1943            fs::read_to_string(&prepared.vite_config_path)
1944                .unwrap()
1945                .contains("logLevel: ['1', 'true', 'yes'].includes")
1946        );
1947        assert!(prepared.vitest_config_path.is_file());
1948        assert_eq!(prepared.assertion_calls, 0);
1949        let cache = read_javascript_frontend_cache(&workspace, "cache-test").unwrap();
1950        assert_eq!(
1951            javascript_frontend_reuse_paths(&cache),
1952            [
1953                PathBuf::from(".supercov/frontend-cache.json"),
1954                PathBuf::from(".supercov/frontend-cache-artifacts"),
1955            ]
1956        );
1957        assert!(
1958            cache
1959                .artifacts
1960                .iter()
1961                .all(|artifact| !artifact.cache_file.contains("src/")
1962                    && !artifact.cache_file.contains("tests/"))
1963        );
1964        fs::write(workspace.join("src/example.mjs"), &original).unwrap();
1965        fs::remove_file(&prepared.manifest_path).unwrap();
1966        let restored = load_cached_javascript_frontend(&workspace, &cache).unwrap();
1967        assert_eq!(restored.manifest, prepared.manifest);
1968        assert_eq!(
1969            fs::read_to_string(workspace.join("src/example.mjs")).unwrap(),
1970            transformed
1971        );
1972        fs::write(workspace.join(&cache.artifacts[0].cache_file), "corrupt").unwrap();
1973        assert!(read_javascript_frontend_cache(&workspace, "cache-test").is_none());
1974        fs::remove_dir_all(source_root).unwrap();
1975        fs::remove_dir_all(workspace).unwrap();
1976    }
1977
1978    #[test]
1979    fn embedded_runtime_contains_every_declared_shim() {
1980        for name in RUNTIME_FILES {
1981            let bytes = embedded_runtime(name).unwrap();
1982            assert!(!bytes.is_empty(), "embedded runtime is empty: {name}");
1983        }
1984        assert!(
1985            std::str::from_utf8(embedded_runtime("runtime.mjs").unwrap())
1986                .unwrap()
1987                .contains(RUNTIME_INSTANCE_MARKER)
1988        );
1989    }
1990}