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
1063/// Sprockets reads a file's `//= require` and `//= link` directives only from
1064/// the comments it opens with, and stops at the first line of code. The
1065/// runtime and its bindings went above them, so a Rails app's
1066/// `app/assets/config/manifest.js` linked nothing and every page raised
1067/// "Asset `tailwind.css` was not declared to be precompiled". The directive
1068/// lines the source opens with go back to the top, and the map moves with
1069/// them so every other line stays where it was mapped.
1070fn keep_directives_first(source: &str, output: &mut crate::js_instrumenter::CandidateOutput) {
1071    let mut directives = Vec::new();
1072    for line in source.lines() {
1073        let trimmed = line.trim();
1074        if trimmed.starts_with("//=") {
1075            directives.push(line);
1076        } else if !(trimmed.is_empty() || trimmed.starts_with("//")) {
1077            break;
1078        }
1079    }
1080    if directives.is_empty() {
1081        return;
1082    }
1083    let lines = output.code.split('\n').collect::<Vec<_>>();
1084    let mut moved = Vec::new();
1085    let mut from = 0;
1086    for directive in &directives {
1087        let Some(index) = lines[from..].iter().position(|line| line == directive) else {
1088            return;
1089        };
1090        moved.push(from + index);
1091        from += index + 1;
1092    }
1093    if moved
1094        .iter()
1095        .enumerate()
1096        .all(|(position, index)| position == *index)
1097    {
1098        return;
1099    }
1100    let reorder = |items: Vec<&str>| -> Vec<String> {
1101        let mut first = moved
1102            .iter()
1103            .map(|index| items[*index].to_owned())
1104            .collect::<Vec<_>>();
1105        first.extend(
1106            items
1107                .iter()
1108                .enumerate()
1109                .filter(|(index, _)| !moved.contains(index))
1110                .map(|(_, item)| (*item).to_owned()),
1111        );
1112        first
1113    };
1114    // Segments are encoded relative to the one before, so a line that carries
1115    // any cannot move without re-encoding the map. A directive is a comment
1116    // and carries none; a map where one does is dropped, not left wrong.
1117    let mut keep_map = true;
1118    if let Some(serde_json::Value::String(mappings)) =
1119        output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1120    {
1121        let mut segments = mappings.split(';').collect::<Vec<_>>();
1122        segments.resize(segments.len().max(lines.len()), "");
1123        if moved.iter().all(|index| segments[*index].is_empty()) {
1124            *mappings = reorder(segments).join(";");
1125        } else {
1126            keep_map = false;
1127        }
1128    }
1129    if !keep_map {
1130        output.map = None;
1131    }
1132    output.code = reorder(lines).join("\n");
1133}
1134
1135/// The runtime's body as each file that needs its own copy carries it:
1136/// without its comments and spacing. A browser loads every directly served
1137/// file, so a page of 47 files fetched 47 copies, 3.9 MB, at each test's fresh
1138/// context. The source as written when it cannot be parsed.
1139fn compact_runtime(runtime_source: &str) -> String {
1140    let Some(export_start) = runtime_source.rfind("\nexport {") else {
1141        return runtime_source.to_owned();
1142    };
1143    let body = &runtime_source[..export_start];
1144    let allocator = oxc_allocator::Allocator::default();
1145    let parsed = oxc_parser::Parser::new(&allocator, body, oxc_span::SourceType::mjs()).parse();
1146    if parsed.panicked || !parsed.errors.is_empty() {
1147        return runtime_source.to_owned();
1148    }
1149    let options = oxc_codegen::CodegenOptions {
1150        minify: true,
1151        comments: oxc_codegen::CommentOptions::disabled(),
1152        ..oxc_codegen::CodegenOptions::default()
1153    };
1154    let compact = oxc_codegen::Codegen::new()
1155        .with_options(options)
1156        .build(&parsed.program)
1157        .code;
1158    format!("{compact}{}", &runtime_source[export_start..])
1159}
1160
1161fn bootstrap_runtime(
1162    output: &mut crate::js_instrumenter::CandidateOutput,
1163    runtime_source: &str,
1164    browser_suite: bool,
1165) {
1166    let Some(binding) = output.runtime.as_ref() else {
1167        return;
1168    };
1169    let script = format!(
1170        "{} = globalThis.__supercovRuntime.mcdcBegin",
1171        binding.mcdc_begin
1172    );
1173    let direct = format!(
1174        "{} = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin",
1175        binding.mcdc_begin
1176    );
1177    let Some(found) = output
1178        .code
1179        .find(&script)
1180        .or_else(|| browser_suite.then(|| output.code.find(&direct)).flatten())
1181    else {
1182        return;
1183    };
1184    let Some(export_start) = runtime_source.rfind("\nexport {") else {
1185        return;
1186    };
1187    let offset = output.code[..found]
1188        .rfind('\n')
1189        .map_or(0, |newline| newline + 1);
1190    let prelude = format!(
1191        "(() => {{ if (!globalThis.__SUPERCOV_DIRECT_RUNTIME__) {{\n{}\n}} if (globalThis.__supercovRuntime == null) globalThis.__supercovRuntime = globalThis.__SUPERCOV_DIRECT_RUNTIME__; }})();\n",
1192        &runtime_source[..export_start]
1193    );
1194    let line = output.code[..offset].matches('\n').count();
1195    let added = prelude.matches('\n').count();
1196    if let Some(serde_json::Value::String(mappings)) =
1197        output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1198    {
1199        let mut lines: Vec<&str> = mappings.split(';').collect();
1200        if line <= lines.len() {
1201            lines.splice(line..line, std::iter::repeat_n("", added));
1202            *mappings = lines.join(";");
1203        }
1204    }
1205    output.code.insert_str(offset, &prelude);
1206}
1207
1208fn relocated_project_file(
1209    workspace: &Path,
1210    project: &CoverageProject,
1211    source: Option<&PathBuf>,
1212) -> Option<PathBuf> {
1213    let source = source?;
1214    let relative = source.strip_prefix(&project.root).ok()?;
1215    Some(workspace.join(relative))
1216}
1217
1218fn write_vitest_config(
1219    workspace: &Path,
1220    project: &CoverageProject,
1221    generated: &Path,
1222) -> Result<PathBuf, JavascriptFrontendError> {
1223    let path = generated.join("vitest.config.mjs");
1224    let original = relocated_project_file(workspace, project, project.vitest_config.as_ref())
1225        .map(|path| path.display().to_string());
1226    let original = serde_json::to_string(&original).map_err(JavascriptFrontendError::Serialize)?;
1227    let source = format!(
1228        "import {{ createRequire }} from 'node:module';\n\
1229         import {{ pathToFileURL }} from 'node:url';\n\
1230         // pnpm's strict layout does not hoist vite to the project root: it\n\
1231         // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
1232         // resolved from this generated file fails. Vitest depends on vite, so\n\
1233         // fall back to resolving it through vitest's own tree rather than\n\
1234         // requiring the project to hoist anything.\n\
1235         const supercovRequire = createRequire(import.meta.url);\n\
1236         const supercovLoadVite = async () => {{\n\
1237           try {{\n\
1238             return await import('vite');\n\
1239           }} catch (error) {{\n\
1240             let entry;\n\
1241             try {{\n\
1242               entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
1243             }} catch {{\n\
1244               throw error;\n\
1245             }}\n\
1246             return await import(pathToFileURL(entry).href);\n\
1247           }}\n\
1248         }};\n\
1249         const viteNamespace = await supercovLoadVite();\n\
1250         import {{ resolve }} from 'node:path';\n\
1251         import SupercovVitestReporter from './node_modules/vitestReporter.mjs';\n\
1252         import {{ supercovBrowserCommands }} from './vitestCommands.mjs';\n\
1253         const vite = viteNamespace.default ?? viteNamespace;\n\
1254         const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
1255         const discoveredConfig = {original};\n\
1256         // Browser Mode runs the test file in the browser, so the node setup --\n\
1257         // which reaches node:fs to write evidence -- cannot load there. Vite\n\
1258         // externalises it and the suite fails before collecting a test.\n\
1259         const supercovBrowserMode = (config) => Boolean(config?.test?.browser?.enabled);\n\
1260         const supercovSetupFile = (config) =>\n\
1261           supercovBrowserMode(config)\n\
1262             ? '.supercov/node_modules/vitestBrowser.mjs'\n\
1263             : '.supercov/node_modules/vitest.mjs';\n\
1264         // Evidence leaves the browser over Vitest's own command channel, so\n\
1265         // the command is registered only for the mode that uses it.\n\
1266         const supercovBrowserTest = (config) =>\n\
1267           supercovBrowserMode(config)\n\
1268             ? {{ browser: {{ commands: supercovBrowserCommands(process.cwd()) }} }}\n\
1269             : {{}};\n\
1270         // Child projects do not inherit root setupFiles. Resolve each project's\n\
1271         // environment after Vitest has loaded file/glob/inline configs and\n\
1272         // applied --project, preserving the runner's own config semantics.\n\
1273         const configureProjects = (projects) => {{\n\
1274           const nodeSetup = resolve(process.cwd(), '.supercov/node_modules/vitest.mjs');\n\
1275           const browserSetup = resolve(process.cwd(), '.supercov/node_modules/vitestBrowser.mjs');\n\
1276           for (const project of projects ?? []) {{\n\
1277             const test = project.config;\n\
1278             const browser = Boolean(test.browser?.enabled);\n\
1279             const setup = browser ? browserSetup : nodeSetup;\n\
1280             test.setupFiles = [setup, ...(test.setupFiles ?? []).filter(file => file !== nodeSetup && file !== browserSetup)];\n\
1281             test.maxConcurrency = 1;\n\
1282             if (browser) {{\n\
1283               const commands = supercovBrowserCommands(process.cwd());\n\
1284               test.browser.commands = {{ ...test.browser.commands, ...commands }};\n\
1285               // Browser instances can already exist when reporters initialize.\n\
1286               // Their parent generates the browser command exports.\n\
1287               const server = project.browser?.parent ?? project.browser;\n\
1288               if (server?.commands) Object.assign(server.commands, commands);\n\
1289             }}\n\
1290           }}\n\
1291         }};\n\
1292         // Vitest's coverage measures the instrumented copy, probes included:\n\
1293         // a suite at 100% read 87.5% of branches, and autoUpdate would write\n\
1294         // what it measured into this file. Coverage is still collected and\n\
1295         // reported (see register.mjs). Vitest 0.x kept thresholds in coverage.\n\
1296         const supercovThresholdKeys = ['thresholds', 'lines', 'functions', 'branches', 'statements', 'perFile', '100', 'thresholdAutoUpdate'];\n\
1297         const supercovSkipCoverageThresholds = (coverage) => {{\n\
1298           const configured = supercovThresholdKeys.filter((key) => coverage?.[key] !== undefined);\n\
1299           for (const key of configured) delete coverage[key];\n\
1300           const collecting = coverage?.enabled === true ||\n\
1301             process.argv.some((argument) => /^--coverage(?:\\.enabled)?(?:=true)?$/.test(argument));\n\
1302           if (configured.length && collecting) process.__SUPERCOV_SKIPPED_COVERAGE_THRESHOLDS__?.('Vitest');\n\
1303         }};\n\
1304         export default async function supercovVitestConfig(env) {{\n\
1305           const originalPath = process.env.SUPERCOV_ORIGINAL_VITEST_CONFIG || discoveredConfig;\n\
1306           const loaded = originalPath ? await loadConfigFromFile(env, originalPath, process.cwd()) : undefined;\n\
1307           const config = mergeConfig(loaded?.config ?? {{}}, {{\n\
1308             cacheDir: resolve(process.cwd(), '.supercov/vitest-cache'),\n\
1309             test: {{ setupFiles: [resolve(process.cwd(), supercovSetupFile(loaded?.config))], maxConcurrency: 1, ...supercovBrowserTest(loaded?.config) }},\n\
1310           }});\n\
1311           supercovSkipCoverageThresholds(config.test?.coverage);\n\
1312           const configuredReporters = loaded?.config?.test?.reporters;\n\
1313           config.test ??= {{}};\n\
1314           config.test.reporters = configuredReporters\n\
1315             ? [...(Array.isArray(configuredReporters) ? configuredReporters : [configuredReporters]), new SupercovVitestReporter(configureProjects)]\n\
1316             : ['default', new SupercovVitestReporter(configureProjects)];\n\
1317           return config;\n\
1318         }}\n"
1319    );
1320    atomic_write(&path, source.as_bytes())?;
1321    Ok(path)
1322}
1323
1324fn configure_playwright_runtime(
1325    generated: &Path,
1326    project: &CoverageProject,
1327) -> Result<(), JavascriptFrontendError> {
1328    let adapter_path = generated.join("playwright.mjs");
1329    let mut adapter =
1330        fs::read_to_string(&adapter_path).map_err(|source| io_error(&adapter_path, source))?;
1331    adapter = adapter
1332        .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module)
1333        .replace(
1334            "__SUPERCOV_PLAYWRIGHT_TEST_EXPORT__",
1335            &project.playwright_test_export,
1336        )
1337        // Baked in rather than read from the environment alone: pooled
1338        // runners execute Playwright inside VMs whose environment the host
1339        // cannot reach, while the generated file rides the workspace mount.
1340        .replace(
1341            "__SUPERCOV_PHASE_TIMING__",
1342            if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() == Ok("1") {
1343                "1"
1344            } else {
1345                "0"
1346            },
1347        );
1348    if project.playwright_module != "@playwright/test" {
1349        // A facade module exports the project's whole test API, not just the
1350        // Playwright surface: its full export set must flow through the shim,
1351        // with only the interception points (`test`, `expect`, the discovered
1352        // test export) shadowed by the shim's own declarations. The discovered
1353        // per-name re-exports below stay as a fallback for CommonJS facades,
1354        // where `export *` only forwards statically detectable names.
1355        let facade = serde_json::to_string(&project.playwright_module)
1356            .expect("serializing a module specifier cannot fail");
1357        adapter = adapter.replace(
1358            "export * from \"@playwright/test\";",
1359            &format!("export * from {facade};"),
1360        );
1361    }
1362    let mut exports = Vec::new();
1363    if project.playwright_test_export != "test" {
1364        exports.push(format!(
1365            "export {{ instrumentedTest as {} }};",
1366            project.playwright_test_export
1367        ));
1368    }
1369    exports.extend(
1370        project
1371            .playwright_exports
1372            .iter()
1373            .filter(|name| {
1374                name.as_str() != "test"
1375                    && name.as_str() != "expect"
1376                    && *name != &project.playwright_test_export
1377            })
1378            .map(|name| {
1379                let encoded = serde_json::to_string(name)
1380                    .expect("serializing a JavaScript export name cannot fail");
1381                format!("export const {name} = __supercovAdapterExport(adapter[{encoded}]);")
1382            }),
1383    );
1384    adapter = adapter.replace("/*__SUPERCOV_ADAPTER_EXPORTS__*/", &exports.join("\n"));
1385    atomic_write(&adapter_path, adapter.as_bytes())?;
1386
1387    let loader_path = generated.join("resolve-loader.mjs");
1388    let loader = fs::read_to_string(&loader_path)
1389        .map_err(|source| io_error(&loader_path, source))?
1390        .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module);
1391    atomic_write(&loader_path, loader.as_bytes())
1392}
1393
1394fn write_playwright_config(
1395    workspace: &Path,
1396    project: &CoverageProject,
1397    generated: &Path,
1398) -> Result<PathBuf, JavascriptFrontendError> {
1399    let path = generated.join("playwright.config.mjs");
1400    let original = relocated_project_file(workspace, project, project.playwright_config.as_ref());
1401    let original_import = if let Some(original) = &original {
1402        let relative = original
1403            .strip_prefix(workspace)
1404            .map_err(|_| JavascriptFrontendError::UnsafeSourcePath(original.display().to_string()))?
1405            .to_string_lossy()
1406            .replace('\\', "/");
1407        let specifier = serde_json::to_string(&format!("../{relative}"))
1408            .map_err(JavascriptFrontendError::Serialize)?;
1409        // Playwright requires a config that is CommonJS and imports one that
1410        // is a module. Importing a TypeScript config of a package without
1411        // `"type": "module"` left its `import` statements in a module Node
1412        // then ran as CommonJS ("Cannot use import statement outside a
1413        // module", Playwright 1.60 on Node 24), so the wrapper loads the
1414        // config the way Playwright itself would.
1415        format!(
1416            "import {{ existsSync, readFileSync }} from 'node:fs';\n\
1417             import {{ createRequire }} from 'node:module';\n\
1418             const originalFile = fileURLToPath(new URL({specifier}, import.meta.url));\n\
1419             const originalIsModule = (() => {{\n\
1420               if (/\\.m[jt]s$/.test(originalFile)) return true;\n\
1421               if (/\\.c[jt]s$/.test(originalFile)) return false;\n\
1422               for (let folder = dirname(originalFile); ; folder = dirname(folder)) {{\n\
1423                 const manifest = resolve(folder, 'package.json');\n\
1424                 if (existsSync(manifest)) {{\n\
1425                   try {{ return JSON.parse(readFileSync(manifest, 'utf8')).type === 'module'; }} catch {{ return false; }}\n\
1426                 }}\n\
1427                 if (dirname(folder) === folder) return false;\n\
1428               }}\n\
1429             }})();\n\
1430             const original = originalIsModule\n\
1431               ? (await import(pathToFileURL(originalFile).href)).default\n\
1432               : createRequire(import.meta.url)(originalFile);\n"
1433        )
1434    } else {
1435        "const original = {};\n".into()
1436    };
1437    // A TypeScript config in a CommonJS package is transpiled to CommonJS and
1438    // imported here as its module namespace, `{ __esModule: true, default }`,
1439    // not as the config. Spreading the namespace lost `testDir`, `use` and
1440    // `webServer`, and Playwright found no tests. Only an explicitly marked
1441    // namespace is unwrapped; a config that merely has a `default` key is not.
1442    let source = format!(
1443        "import './node_modules/register.mjs';\n\
1444         import {{ dirname, isAbsolute, relative, resolve }} from 'node:path';\n\
1445         import {{ fileURLToPath, pathToFileURL }} from 'node:url';\n\
1446         {original_import}\
1447         const configExport = original && original.__esModule === true && Object.prototype.hasOwnProperty.call(original, 'default') ? original.default : original;\n\
1448         const resolvedValue = typeof configExport === 'function' ? await configExport({{ command: 'test', mode: 'test' }}) : configExport;\n\
1449         const resolved = resolvedValue ?? {{}};\n\
1450         const runtimeProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');\n\
1451         const originalDirectory = {};
1452         const sourceProjectRoot = process.env.SUPERCOV_SOURCE_PROJECT_ROOT;\n\
1453         const runtimePath = value => {{\n\
1454           if (!value) return value;\n\
1455           const absolute = isAbsolute(value) ? value : resolve(originalDirectory, value);\n\
1456           const local = relative(runtimeProjectRoot, absolute);\n\
1457           if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1458           if (sourceProjectRoot) {{\n\
1459             const sourceLocal = relative(sourceProjectRoot, absolute);\n\
1460             if (sourceLocal === '' || (!sourceLocal.startsWith('..') && !isAbsolute(sourceLocal))) return resolve(runtimeProjectRoot, sourceLocal);\n\
1461           }}\n\
1462           throw new Error('Supercov refuses a Playwright output/cwd outside the isolated project: ' + absolute);\n\
1463         }};\n\
1464         const normalizeWebServer = server => server ? ({{ ...server, cwd: runtimePath(server.cwd ?? originalDirectory) }}) : server;\n\
1465         // How the configuration resolved, for a later merge: a run of new
1466         // tests with settings of their own is added to this one only where
1467         // nothing that decides what a test does changed. Paths inside the
1468         // copy are written relative to it; every run has a copy of its own.
1469         if (process.env.SUPERCOV_EVIDENCE_DIR && process.env.TEST_WORKER_INDEX === undefined) {{\n\
1470           try {{\n\
1471             const {{ mkdirSync, renameSync, writeFileSync }} = await import('node:fs');\n\
1472             const seen = new WeakSet();\n\
1473             let text = JSON.stringify(resolved, (key, value) => {{\n\
1474               if (typeof value === 'function' || value instanceof RegExp) return String(value);\n\
1475               if (value && typeof value === 'object') {{ if (seen.has(value)) return undefined; seen.add(value); }}\n\
1476               return value;\n\
1477             }});\n\
1478             for (const root of [runtimeProjectRoot, sourceProjectRoot].filter(Boolean)) text = text.split(JSON.stringify(root).slice(1, -1)).join('.');\n\
1479             const directory = resolve(process.cwd(), process.env.SUPERCOV_EVIDENCE_DIR);\n\
1480             mkdirSync(directory, {{ recursive: true }});\n\
1481             const file = resolve(directory, 'playwright-configuration.json');\n\
1482             writeFileSync(`${{file}}.${{process.pid}}`, text);\n\
1483             renameSync(`${{file}}.${{process.pid}}`, file);\n\
1484           }} catch {{}}\n\
1485         }}\n\
1486         const normalized = {{ ...resolved,\n\
1487           testDir: runtimePath(resolved.testDir),\n\
1488           outputDir: runtimePath(resolved.outputDir),\n\
1489           snapshotDir: runtimePath(resolved.snapshotDir),\n\
1490           projects: resolved.projects?.map(project => ({{ ...project, testDir: runtimePath(project.testDir), outputDir: runtimePath(project.outputDir), snapshotDir: runtimePath(project.snapshotDir) }})),\n\
1491           webServer: Array.isArray(resolved.webServer) ? resolved.webServer.map(normalizeWebServer) : normalizeWebServer(resolved.webServer),\n\
1492         }};\n\
1493         const configuredReporters = normalized.reporter;\n\
1494         const reporters = configuredReporters\n\
1495           ? (typeof configuredReporters === 'string' ? [[configuredReporters]] : (Array.isArray(configuredReporters[0]) ? configuredReporters : [configuredReporters]))\n\
1496           : [['list']];\n\
1497         const coverageReporter = resolve(runtimeProjectRoot, '.supercov/node_modules/playwrightReporter.mjs');\n\
1498         export default {{ ...normalized, reporter: [...reporters, [coverageReporter]] }};\n",
1499        serde_json::to_string(
1500            &original
1501                .as_ref()
1502                .and_then(|path| path.parent())
1503                .unwrap_or(workspace)
1504                .display()
1505                .to_string()
1506        )
1507        .map_err(JavascriptFrontendError::Serialize)?
1508    );
1509    atomic_write(&path, source.as_bytes())?;
1510    Ok(path)
1511}
1512
1513fn write_vitest_commands(generated: &Path) -> Result<(), JavascriptFrontendError> {
1514    let adapter = "import { mkdirSync } from 'node:fs';\n\
1515import { relative, resolve, sep } from 'node:path';\n\
1516import { atomicWriteFileSync } from './node_modules/atomic.mjs';\n\
1517import { inferTestProvenance } from './node_modules/provenance.mjs';\n\
1518// Browser Mode runs the test file in the browser, where none of this is\n\
1519// reachable: no node:fs to write evidence, no project root to relativise a\n\
1520// path against, no env to read. Vitest's own browser command channel carries\n\
1521// what the browser observed to node, which completes the same payload the node\n\
1522// setup writes -- so one contract keeps one implementation. The command is\n\
1523// awaited as part of the test, and node learns the test file from Vitest\n\
1524// rather than from the browser realm, which cannot be trusted to name it.\n\
1525const supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/;\n\
1526export function supercovBrowserCommands(root) {\n\
1527  return {\n\
1528    __supercovEvidence(context, payload, suffix) {\n\
1529      const evidenceDirectory = process.env['SUPERCOV_EVIDENCE_DIR'];\n\
1530      if (!evidenceDirectory) return;\n\
1531      // A suffix names one directory under the evidence directory. Anything\n\
1532      // else is refused rather than resolved, so a malformed one cannot send\n\
1533      // the write outside the directory it is supposed to stay in.\n\
1534      if (typeof suffix !== 'string' || !supercovEvidenceSuffix.test(suffix))\n\
1535        throw new Error('Supercov refused an unusable evidence suffix: ' + suffix);\n\
1536      const absolute = context.testPath;\n\
1537      const testFile = absolute ? relative(root, absolute).split(sep).join('/') : undefined;\n\
1538      const projectName = payload.projectName ?? context.project?.name;\n\
1539      delete payload.projectName;\n\
1540      // The browser has no environment to read, so the run identity is stamped\n\
1541      // here rather than sent from a realm that cannot know it.\n\
1542      const runId = process.env['SUPERCOV_RUN_ID'];\n\
1543      if (runId && payload.scope) payload.scope.runId = runId;\n\
1544      const complete = Object.assign({}, payload, testFile ? { testFile } : {}, {\n\
1545        provenance: inferTestProvenance({\n\
1546          runner: 'vitest',\n\
1547          file: testFile,\n\
1548          project: projectName,\n\
1549          explicitKind: process.env['SUPERCOV_TEST_KIND'],\n\
1550        }),\n\
1551      });\n\
1552      const directory = resolve(root, evidenceDirectory, suffix);\n\
1553      mkdirSync(directory, { recursive: true });\n\
1554      atomicWriteFileSync(resolve(directory, 'mcdc.json'), JSON.stringify(complete) + '\\n');\n\
1555    },\n\
1556  };\n\
1557}\n";
1558    atomic_write(&generated.join("vitestCommands.mjs"), adapter.as_bytes())
1559}
1560
1561/// Prepare the complete JavaScript frontend inside an isolated workspace.
1562/// The source project is read only through the copied workspace inventory.
1563pub fn prepare_javascript_frontend(
1564    workspace: &Path,
1565    project: &CoverageProject,
1566    collector_id: &str,
1567    cache_key: &str,
1568    command: &[String],
1569) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
1570    let generated = workspace.join(".supercov");
1571    // Runtime code files live under a node_modules segment: Node attributes
1572    // stack frames from node_modules paths to dependency infrastructure, so
1573    // deprecation warnings the user's own run would suppress (Node's
1574    // isInsideNodeModules check) stay suppressed when Supercov's module
1575    // hooks are on the call path.
1576    let runtime_directory = generated.join("node_modules");
1577    timed(&SETUP.runtime_ns, || {
1578        copy_runtime(&runtime_directory, collector_id)
1579    })?;
1580    let configuration_started = Instant::now();
1581    configure_playwright_runtime(&runtime_directory, project)?;
1582    let playwright_config_path = write_playwright_config(workspace, project, &generated)?;
1583    let vitest_config_path = write_vitest_config(workspace, project, &generated)?;
1584    account(&SETUP.config_ns, configuration_started);
1585
1586    let mut exclusions = Vec::new();
1587    let mut decisions = BTreeMap::new();
1588    let mut points = BTreeMap::new();
1589    let mut branches = BTreeMap::new();
1590    let mut limitations = BTreeMap::new();
1591    for limitation in &project.source_limitations {
1592        limitations.insert(limitation.id.clone(), limitation_from_source(limitation));
1593    }
1594
1595    let runtime_path = runtime_directory.join("runtime.mjs");
1596    let standalone_runtime = compact_runtime(
1597        &fs::read_to_string(&runtime_path).map_err(|source| io_error(&runtime_path, source))?,
1598    );
1599    // debug's karma bundles its sources with browserify and runs them in
1600    // Chrome, where nothing had installed the runtime: every file failed on
1601    // its first line ("reading 'mcdcBegin'"). The browser's evidence has no
1602    // adapter to reach the run, but its tests pass as they do without it.
1603    let browser_suite = project.playwright_config.is_some() || project.browser_runner;
1604    let mut authored = BTreeSet::new();
1605    let sources_started = Instant::now();
1606    for file in &project.source_files {
1607        let path = checked_source_path(workspace, file)?;
1608        let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
1609        let capability_wrapper = runtime_specifier(file, "capability.mjs")?;
1610        let elide = crate::typescript_imports::elides_type_imports(workspace, file, command);
1611        let instrumented = timed(&SETUP.instrument_ns, || {
1612            instrument_with_import_policy(&source, file, &capability_wrapper, elide)
1613        });
1614        let mut output = match instrumented {
1615            Ok(output) => output,
1616            // A file the parser rejects runs as written: the suite may never
1617            // load it, and one that does fails there as it would without
1618            // Supercov. Nothing in it is measured, and the run says so.
1619            Err(crate::js_instrumenter::CandidateError::Parse(messages)) => {
1620                let limitation = CandidateLimitation {
1621                    id: format!("javascript-file-not-parsed#{file}"),
1622                    kind: "source-scope".into(),
1623                    file: file.clone(),
1624                    line: 1,
1625                    column: 0,
1626                    source: String::new(),
1627                    reason: format!(
1628                        "Supercov could not parse this file ({}); it runs as written and nothing in it is measured",
1629                        messages.join("; ")
1630                    ),
1631                };
1632                limitations.insert(limitation.id.clone(), limitation);
1633                continue;
1634            }
1635            Err(source) => {
1636                return Err(JavascriptFrontendError::Instrument {
1637                    file: file.clone(),
1638                    source,
1639                });
1640            }
1641        };
1642        // A directly run TypeScript file names its helpers' types from the
1643        // runtime's declarations, which sit beside the runtime in the
1644        // workspace.
1645        if output
1646            .code
1647            .contains(crate::js_instrumenter::RUNTIME_TYPES_MODULE)
1648        {
1649            output.code = output.code.replace(
1650                crate::js_instrumenter::RUNTIME_TYPES_MODULE,
1651                &runtime_specifier(file, "runtime.mjs")?,
1652            );
1653        }
1654        bootstrap_runtime(&mut output, &standalone_runtime, browser_suite);
1655        if !browser_suite {
1656            bootstrap_loader(&mut output, file)?;
1657        }
1658        keep_directives_first(&source, &mut output);
1659        // The command can compile TypeScript itself (`npm test` may begin
1660        // with `tsc`), and a probe's own code is not the project's to have
1661        // type-checked: the banner exempts the file, while what it exports
1662        // keeps the types it has in the source.
1663        if matches!(
1664            path.extension().and_then(|value| value.to_str()),
1665            Some("ts" | "tsx" | "mts" | "cts")
1666        ) {
1667            output.code = generated_source_banner(&output.code);
1668        }
1669        // Attach the instrumentation source map inline, pointed at the
1670        // ORIGINAL project file with the original text embedded. Node runs
1671        // with --enable-source-maps, and tsx/esbuild chain input maps, so
1672        // stack traces show the user's real path and line numbers instead
1673        // of instrumented workspace positions -- Supercov stays invisible
1674        // in errors. Without this the map was generated and then dropped.
1675        let code = match inline_instrumentation_map(
1676            &output.code,
1677            output.map.as_ref(),
1678            &project.root.join(file),
1679            &source,
1680        ) {
1681            Some(code) => code,
1682            None => output.code.clone(),
1683        };
1684        keep_authored(workspace, &mut authored, file, &source)?;
1685        atomic_write(&path, code.as_bytes())?;
1686        exclusions.extend(output.excluded_statements);
1687        for value in output.decisions {
1688            decisions.insert(value.id.clone(), value);
1689        }
1690        for value in output.points {
1691            points.insert(value.id.clone(), value);
1692        }
1693        for value in output.branches {
1694            branches.insert(value.id.clone(), value);
1695        }
1696        for value in output.coverage_limitations {
1697            limitations.insert(value.id.clone(), value);
1698        }
1699    }
1700    account(&SETUP.sources_ns, sources_started);
1701    write_vitest_commands(&generated)?;
1702
1703    let assertions_started = Instant::now();
1704    let mut assertion_calls = 0;
1705    for entry in &project.source_scope.entries {
1706        let path = checked_source_path(workspace, &entry.file)?;
1707        let Ok(source) = fs::read_to_string(&path) else {
1708            continue;
1709        };
1710        let capability_wrapper = (!project.source_files.contains(&entry.file))
1711            .then(|| runtime_specifier(&entry.file, "capability.mjs"))
1712            .transpose()?;
1713        let assertion_runtime = runtime_specifier(&entry.file, "bootstrap.cjs")?;
1714        let output =
1715            match crate::js_instrumenter::instrument_node_assertion_phases_with_runtime_imports(
1716                &source,
1717                &entry.file,
1718                std::slice::from_ref(&project.playwright_module),
1719                capability_wrapper.as_deref(),
1720                Some(&assertion_runtime),
1721            ) {
1722                Ok(output) => output,
1723                // A test file the parser rejects is left as written; its runner
1724                // reports it, and it has no assertions to track.
1725                Err(crate::js_instrumenter::CandidateError::Parse(_)) => continue,
1726                Err(source) => {
1727                    return Err(JavascriptFrontendError::Instrument {
1728                        file: entry.file.clone(),
1729                        source,
1730                    });
1731                }
1732            };
1733        if output.assertions > 0 || output.capability_imports > 0 {
1734            keep_authored(workspace, &mut authored, &entry.file, &source)?;
1735            atomic_write(&path, output.code.as_bytes())?;
1736            assertion_calls += output.assertions;
1737        }
1738    }
1739
1740    account(&SETUP.assertion_ns, assertions_started);
1741
1742    let mut manifest = JavascriptManifest {
1743        decisions: decisions.into_values().collect(),
1744        points: points.into_values().collect(),
1745        branches: branches.into_values().collect(),
1746        limitations: limitations.into_values().collect(),
1747        scope: project.source_scope.clone(),
1748    };
1749    manifest.decisions.sort_by_key(|value| {
1750        (
1751            value.file.clone(),
1752            value.line,
1753            value.column,
1754            value.id.clone(),
1755        )
1756    });
1757    manifest.points.sort_by_key(|value| {
1758        (
1759            value.file.clone(),
1760            value.line,
1761            value.column,
1762            value.id.clone(),
1763        )
1764    });
1765    manifest.branches.sort_by_key(|value| {
1766        (
1767            value.file.clone(),
1768            value.line,
1769            value.column,
1770            value.id.clone(),
1771        )
1772    });
1773    manifest.limitations.sort_by_key(|value| {
1774        (
1775            value.file.clone(),
1776            value.line,
1777            value.column,
1778            value.id.clone(),
1779        )
1780    });
1781
1782    atomic_write(
1783        &workspace.join(AUTHORED_LIST),
1784        &serde_json::to_vec(&authored).map_err(JavascriptFrontendError::Serialize)?,
1785    )?;
1786    atomic_write(
1787        &generated.join("statement-exclusions.json"),
1788        &serde_json::to_vec(&exclusions).map_err(JavascriptFrontendError::Serialize)?,
1789    )?;
1790    let manifest_path = generated.join("manifest.json");
1791    let mut encoded =
1792        serde_json::to_vec_pretty(&manifest).map_err(JavascriptFrontendError::Serialize)?;
1793    encoded.push(b'\n');
1794    atomic_write(&manifest_path, &encoded)?;
1795    atomic_write(
1796        &generated.join("instrumentation-complete"),
1797        b"coverage-completeness-v2\n",
1798    )?;
1799    timed(&SETUP.cache_ns, || {
1800        write_javascript_frontend_cache(workspace, project, cache_key, assertion_calls)
1801    })?;
1802    Ok(PreparedJavascriptFrontend {
1803        manifest,
1804        manifest_path,
1805        preload_path: generated.join("node_modules/register.mjs"),
1806        playwright_config_path,
1807        vitest_config_path,
1808        assertion_calls,
1809    })
1810}
1811
1812#[cfg(test)]
1813mod tests {
1814
1815    // Sprockets reads directives only from the comments a file opens with.
1816    #[test]
1817    fn sprockets_directives_stay_where_sprockets_reads_them() {
1818        let source = "// The manifest.\n//= link_tree ../images\n//= link_tree ../builds\n";
1819        let mut output: crate::js_instrumenter::CandidateOutput = serde_json::from_value(serde_json::json!({
1820            "engine": "", "complete": true, "supportedSurface": "",
1821            "code": "(() => { runtime(); })();\nconst __b = 1;\n// The manifest.\n//= link_tree ../images\n//= link_tree ../builds\n",
1822            "map": { "version": 3, "mappings": "AAAA;AACA;;;;" },
1823            "decisions": [], "points": [], "branches": [], "coverageLimitations": [], "limitations": []
1824        }))
1825        .unwrap();
1826        keep_directives_first(source, &mut output);
1827        assert!(
1828            output
1829                .code
1830                .starts_with("//= link_tree ../images\n//= link_tree ../builds\n(() =>"),
1831            "{}",
1832            output.code
1833        );
1834        // The code's own lines keep their segments, behind the moved lines.
1835        assert_eq!(output.map.as_ref().unwrap()["mappings"], ";;AAAA;AACA;;");
1836        // A file that opens with code is left alone.
1837        let mut untouched = output.clone();
1838        untouched.code = "x();\n//= not a directive\n".into();
1839        let before = untouched.code.clone();
1840        keep_directives_first("x();\n//= not a directive\n", &mut untouched);
1841        assert_eq!(untouched.code, before);
1842    }
1843
1844    // Each directly served file carries a copy, so it carries no comments.
1845    #[test]
1846    fn the_copy_of_the_runtime_a_file_carries_is_compact() {
1847        let source = include_str!("../runtime-assets/javascript/runtime.mjs");
1848        let compact = compact_runtime(source);
1849        assert!(
1850            compact.len() * 3 < source.len() * 2,
1851            "{} of {}",
1852            compact.len(),
1853            source.len()
1854        );
1855        let export_start = compact.rfind("\nexport {").unwrap();
1856        assert_eq!(
1857            &compact[export_start..],
1858            &source[source.rfind("\nexport {").unwrap()..]
1859        );
1860        let allocator = oxc_allocator::Allocator::default();
1861        let parsed = oxc_parser::Parser::new(
1862            &allocator,
1863            &compact[..export_start],
1864            oxc_span::SourceType::cjs(),
1865        )
1866        .parse();
1867        assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
1868    }
1869    #[test]
1870    fn ancestor_description_names_each_component_and_its_state() {
1871        let root = std::env::temp_dir().join(format!("supercov-ancestors-{}", unique()));
1872        fs::create_dir_all(root.join("present")).unwrap();
1873        fs::write(root.join("present/file.txt"), b"x").unwrap();
1874        let described = super::describe_ancestors(&root.join("present/file.txt/child"));
1875        assert!(described.contains("present=dir"), "{described}");
1876        assert!(described.contains("file.txt=file"), "{described}");
1877        assert!(described.ends_with("child=missing"), "{described}");
1878        fs::remove_dir_all(&root).unwrap();
1879    }
1880
1881    use super::*;
1882    use crate::project_discovery::discover_coverage_project;
1883
1884    fn temporary(name: &str) -> PathBuf {
1885        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1886            .join("../../target/supercov-test-fixtures")
1887            .join(format!("javascript-frontend-{name}-{}", unique()));
1888        // These tests validate frontend contents and manifest construction.
1889        // The dedicated workspace/platform suite owns directory-creation,
1890        // link, rename, ENOSPC, crash, and cleanup behavior on every OS. Keep
1891        // semantic fixtures in Cargo's ignored target tree so hosted-runner
1892        // policies on the system temporary directory cannot affect them.
1893        fs::create_dir_all(&path).unwrap();
1894        crate::workspace::canonicalize_simplified(path).unwrap()
1895    }
1896
1897    #[test]
1898    fn browser_mode_gets_a_browser_setup_and_the_command_that_carries_its_evidence() {
1899        let generated = temporary("browser-mode-config");
1900        write_vitest_commands(&generated).unwrap();
1901        let adapter = fs::read_to_string(generated.join("vitestCommands.mjs")).unwrap();
1902        // Evidence leaves the browser over Vitest's own command channel. The
1903        // dev-server endpoint this replaced was reachable by anything loaded in
1904        // that realm and took the test file's identity on the browser's word.
1905        assert!(adapter.contains("export function supercovBrowserCommands(root)"));
1906        assert!(adapter.contains("__supercovEvidence(context, payload, suffix)"));
1907        assert!(adapter.contains("const absolute = context.testPath;"));
1908        assert!(!adapter.contains("configureServer"), "{adapter}");
1909        assert!(!adapter.contains("__supercov/evidence"), "{adapter}");
1910        // A suffix names one directory beneath the evidence directory; a
1911        // traversing one has to be refused rather than resolved.
1912        assert!(adapter.contains("supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/"));
1913        assert!(adapter.contains("Supercov refused an unusable evidence suffix"));
1914    }
1915
1916    #[test]
1917    fn a_browser_mode_project_loads_the_setup_that_can_run_in_a_browser() {
1918        // The node setup reaches node:fs through atomic.mjs; Vite externalises
1919        // that for the client, and the suite fails before collecting a test.
1920        let workspace = temporary("browser-mode-setup");
1921        let generated = workspace.join(".supercov");
1922        fs::create_dir_all(&generated).unwrap();
1923        fs::write(workspace.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1924        fs::create_dir_all(workspace.join("src")).unwrap();
1925        fs::write(
1926            workspace.join("src/example.mjs"),
1927            "export const one = () => 1;\n",
1928        )
1929        .unwrap();
1930        let project = discover_coverage_project(
1931            &workspace,
1932            &BTreeMap::new(),
1933            &["npx".into(), "vitest".into(), "run".into()],
1934        )
1935        .unwrap();
1936        let path = write_vitest_config(&workspace, &project, &generated).unwrap();
1937        let config = fs::read_to_string(path).unwrap();
1938        assert!(config.contains("config?.test?.browser?.enabled"));
1939        assert!(config.contains("'.supercov/node_modules/vitestBrowser.mjs'"));
1940        assert!(config.contains("'.supercov/node_modules/vitest.mjs'"));
1941        // The command exists only for the mode that uses it.
1942        assert!(config.contains("browser: { commands: supercovBrowserCommands(process.cwd()) }"));
1943        assert!(config.contains("...supercovBrowserTest(loaded?.config)"));
1944    }
1945
1946    #[test]
1947    fn runtime_isolation_replaces_only_the_assignment_marker() {
1948        let source = concat!(
1949            "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n",
1950            "const selected = runtimeInstanceToken === \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\";\n"
1951        );
1952        let isolated = isolate_runtime(source, "collector-123").unwrap();
1953        assert!(isolated.contains("runtimeInstanceToken = \"collector-123\""));
1954        assert!(isolated.contains("=== \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\""));
1955    }
1956
1957    #[test]
1958    fn copied_runtime_does_not_reference_unshipped_source_maps() {
1959        let generated = temporary("runtime-source-maps");
1960        copy_runtime(&generated, "collector-test").unwrap();
1961        for name in ["vitest.mjs", "provenance.mjs", "atomic.mjs"] {
1962            let contents = fs::read_to_string(generated.join(name)).unwrap();
1963            assert!(
1964                !contents.contains("sourceMappingURL"),
1965                "runtime shim retained a source-map directive: {name}"
1966            );
1967        }
1968        fs::remove_dir_all(generated).unwrap();
1969    }
1970
1971    #[test]
1972    fn prepares_sorted_complete_manifest_without_touching_source_project() {
1973        let source_root = temporary("source");
1974        let workspace = temporary("workspace");
1975        fs::create_dir_all(source_root.join("src")).unwrap();
1976        fs::write(
1977            source_root.join("src/example.mjs"),
1978            "export function value(a, b) { if (a || b) return 1; return 0; }\n",
1979        )
1980        .unwrap();
1981        fs::write(source_root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1982        fs::create_dir_all(workspace.join("src")).unwrap();
1983        fs::create_dir_all(workspace.join(".supercov")).unwrap();
1984        fs::copy(
1985            source_root.join("src/example.mjs"),
1986            workspace.join("src/example.mjs"),
1987        )
1988        .unwrap();
1989        let project = discover_coverage_project(
1990            &source_root,
1991            &BTreeMap::new(),
1992            &["node".into(), "--test".into()],
1993        )
1994        .unwrap();
1995        let original = fs::read_to_string(source_root.join("src/example.mjs")).unwrap();
1996        let prepared =
1997            prepare_javascript_frontend(&workspace, &project, "collector-test", "cache-test", &[])
1998                .unwrap();
1999        assert_eq!(
2000            fs::read_to_string(source_root.join("src/example.mjs")).unwrap(),
2001            original
2002        );
2003        let transformed = fs::read_to_string(workspace.join("src/example.mjs")).unwrap();
2004        assert!(transformed.contains("__SUPERCOV_DIRECT_RUNTIME__"));
2005        assert_eq!(prepared.manifest.decisions.len(), 1);
2006        assert!(!prepared.manifest.points.is_empty());
2007        assert_eq!(prepared.manifest.scope, project.source_scope);
2008        assert!(prepared.manifest_path.is_file());
2009        assert!(prepared.preload_path.is_file());
2010        assert!(prepared.playwright_config_path.is_file());
2011        assert!(prepared.vitest_config_path.is_file());
2012        assert_eq!(prepared.assertion_calls, 0);
2013        let cache = read_javascript_frontend_cache(&workspace, "cache-test").unwrap();
2014        assert_eq!(
2015            javascript_frontend_reuse_paths(&cache),
2016            [
2017                PathBuf::from(".supercov/frontend-cache.json"),
2018                PathBuf::from(".supercov/frontend-cache-artifacts"),
2019            ]
2020        );
2021        assert!(
2022            cache
2023                .artifacts
2024                .iter()
2025                .all(|artifact| !artifact.cache_file.contains("src/")
2026                    && !artifact.cache_file.contains("tests/"))
2027        );
2028        fs::write(workspace.join("src/example.mjs"), &original).unwrap();
2029        fs::remove_file(&prepared.manifest_path).unwrap();
2030        let restored = load_cached_javascript_frontend(&workspace, &cache).unwrap();
2031        assert_eq!(restored.manifest, prepared.manifest);
2032        assert_eq!(
2033            fs::read_to_string(workspace.join("src/example.mjs")).unwrap(),
2034            transformed
2035        );
2036        fs::write(workspace.join(&cache.artifacts[0].cache_file), "corrupt").unwrap();
2037        assert!(read_javascript_frontend_cache(&workspace, "cache-test").is_none());
2038        fs::remove_dir_all(source_root).unwrap();
2039        fs::remove_dir_all(workspace).unwrap();
2040    }
2041
2042    #[test]
2043    fn embedded_runtime_contains_every_declared_shim() {
2044        for name in RUNTIME_FILES {
2045            let bytes = embedded_runtime(name).unwrap();
2046            assert!(!bytes.is_empty(), "embedded runtime is empty: {name}");
2047        }
2048        assert!(
2049            std::str::from_utf8(embedded_runtime("runtime.mjs").unwrap())
2050                .unwrap()
2051                .contains(RUNTIME_INSTANCE_MARKER)
2052        );
2053    }
2054}