Skip to main content

supercov_engine/
javascript_frontend.rs

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