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