1use 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";
32const 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
69struct 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
140pub 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 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
418pub fn authored_path(workspace: &Path, file: &Path) -> PathBuf {
420 workspace.join(AUTHORED_DIRECTORY).join(file)
421}
422
423pub fn changed_path(workspace: &Path, file: &Path) -> PathBuf {
426 workspace.join(CHANGED_DIRECTORY).join(file)
427}
428
429pub fn changed_directory(workspace: &Path) -> PathBuf {
431 workspace.join(CHANGED_DIRECTORY)
432}
433
434pub 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
442fn 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 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#[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(¤t) {
630 Ok(metadata) if metadata.file_type().is_symlink() => "link",
631 Ok(metadata) if metadata.file_type().is_dir() => "dir",
632 Ok(_) => "file",
633 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 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
741const GENERATED_SOURCE_BANNER: &str =
748 "/* eslint-disable */\n// @ts-nocheck -- generated coverage workspace only\n";
749
750fn 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 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
790fn 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 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 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
867fn 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
921fn runtime_declarations() -> String {
931 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 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
1007fn bootstrap_loader(
1029 output: &mut crate::js_instrumenter::CandidateOutput,
1030 file: &str,
1031) -> Result<(), JavascriptFrontendError> {
1032 let Some(binding) = output.runtime.as_ref() else {
1033 return Ok(());
1034 };
1035 let direct = format!(
1036 "{} = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin",
1037 binding.mcdc_begin
1038 );
1039 let Some(found) = output.code.find(&direct) else {
1040 return Ok(());
1041 };
1042 let loader = format!(
1043 "{}\n",
1044 crate::js_instrumenter::runtime_loader(&runtime_specifier(file, "bootstrap.cjs")?)
1045 );
1046 let offset = output.code[..found]
1047 .rfind('\n')
1048 .map_or(0, |newline| newline + 1);
1049 let line = output.code[..offset].matches('\n').count();
1050 if let Some(serde_json::Value::String(mappings)) =
1051 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1052 {
1053 let mut lines: Vec<&str> = mappings.split(';').collect();
1054 if line <= lines.len() {
1055 lines.insert(line, "");
1056 *mappings = lines.join(";");
1057 }
1058 }
1059 output.code.insert_str(offset, &loader);
1060 Ok(())
1061}
1062
1063fn bootstrap_runtime(
1064 output: &mut crate::js_instrumenter::CandidateOutput,
1065 runtime_source: &str,
1066 browser_suite: bool,
1067) {
1068 let Some(binding) = output.runtime.as_ref() else {
1069 return;
1070 };
1071 let script = format!(
1072 "{} = globalThis.__supercovRuntime.mcdcBegin",
1073 binding.mcdc_begin
1074 );
1075 let direct = format!(
1076 "{} = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin",
1077 binding.mcdc_begin
1078 );
1079 let Some(found) = output
1080 .code
1081 .find(&script)
1082 .or_else(|| browser_suite.then(|| output.code.find(&direct)).flatten())
1083 else {
1084 return;
1085 };
1086 let Some(export_start) = runtime_source.rfind("\nexport {") else {
1087 return;
1088 };
1089 let offset = output.code[..found]
1090 .rfind('\n')
1091 .map_or(0, |newline| newline + 1);
1092 let prelude = format!(
1093 "(() => {{ if (!globalThis.__SUPERCOV_DIRECT_RUNTIME__) {{\n{}\n}} if (globalThis.__supercovRuntime == null) globalThis.__supercovRuntime = globalThis.__SUPERCOV_DIRECT_RUNTIME__; }})();\n",
1094 &runtime_source[..export_start]
1095 );
1096 let line = output.code[..offset].matches('\n').count();
1097 let added = prelude.matches('\n').count();
1098 if let Some(serde_json::Value::String(mappings)) =
1099 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1100 {
1101 let mut lines: Vec<&str> = mappings.split(';').collect();
1102 if line <= lines.len() {
1103 lines.splice(line..line, std::iter::repeat_n("", added));
1104 *mappings = lines.join(";");
1105 }
1106 }
1107 output.code.insert_str(offset, &prelude);
1108}
1109
1110fn relocated_project_file(
1111 workspace: &Path,
1112 project: &CoverageProject,
1113 source: Option<&PathBuf>,
1114) -> Option<PathBuf> {
1115 let source = source?;
1116 let relative = source.strip_prefix(&project.root).ok()?;
1117 Some(workspace.join(relative))
1118}
1119
1120fn write_vitest_config(
1121 workspace: &Path,
1122 project: &CoverageProject,
1123 generated: &Path,
1124) -> Result<PathBuf, JavascriptFrontendError> {
1125 let path = generated.join("vitest.config.mjs");
1126 let original = relocated_project_file(workspace, project, project.vitest_config.as_ref())
1127 .map(|path| path.display().to_string());
1128 let original = serde_json::to_string(&original).map_err(JavascriptFrontendError::Serialize)?;
1129 let source = format!(
1130 "import {{ createRequire }} from 'node:module';\n\
1131 import {{ pathToFileURL }} from 'node:url';\n\
1132 // pnpm's strict layout does not hoist vite to the project root: it\n\
1133 // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
1134 // resolved from this generated file fails. Vitest depends on vite, so\n\
1135 // fall back to resolving it through vitest's own tree rather than\n\
1136 // requiring the project to hoist anything.\n\
1137 const supercovRequire = createRequire(import.meta.url);\n\
1138 const supercovLoadVite = async () => {{\n\
1139 try {{\n\
1140 return await import('vite');\n\
1141 }} catch (error) {{\n\
1142 let entry;\n\
1143 try {{\n\
1144 entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
1145 }} catch {{\n\
1146 throw error;\n\
1147 }}\n\
1148 return await import(pathToFileURL(entry).href);\n\
1149 }}\n\
1150 }};\n\
1151 const viteNamespace = await supercovLoadVite();\n\
1152 import {{ resolve }} from 'node:path';\n\
1153 import SupercovVitestReporter, {{ supercovSourceMaps }} from './node_modules/vitestReporter.mjs';\n\
1154 import {{ supercovBrowserCommands }} from './vitestCommands.mjs';\n\
1155 const vite = viteNamespace.default ?? viteNamespace;\n\
1156 const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
1157 const discoveredConfig = {original};\n\
1158 // Browser Mode runs the test file in the browser, so the node setup --\n\
1159 // which reaches node:fs to write evidence -- cannot load there. Vite\n\
1160 // externalises it and the suite fails before collecting a test.\n\
1161 const supercovBrowserMode = (config) => Boolean(config?.test?.browser?.enabled);\n\
1162 const supercovSetupFile = (config) =>\n\
1163 supercovBrowserMode(config)\n\
1164 ? '.supercov/node_modules/vitestBrowser.mjs'\n\
1165 : '.supercov/node_modules/vitest.mjs';\n\
1166 // Evidence leaves the browser over Vitest's own command channel, so\n\
1167 // the command is registered only for the mode that uses it.\n\
1168 const supercovBrowserTest = (config) =>\n\
1169 supercovBrowserMode(config)\n\
1170 ? {{ browser: {{ commands: supercovBrowserCommands(process.cwd()) }} }}\n\
1171 : {{}};\n\
1172 // Child projects do not inherit root setupFiles. Resolve each project's\n\
1173 // environment after Vitest has loaded file/glob/inline configs and\n\
1174 // applied --project, preserving the runner's own config semantics.\n\
1175 const configureProjects = (projects) => {{\n\
1176 const nodeSetup = resolve(process.cwd(), '.supercov/node_modules/vitest.mjs');\n\
1177 const browserSetup = resolve(process.cwd(), '.supercov/node_modules/vitestBrowser.mjs');\n\
1178 for (const project of projects ?? []) {{\n\
1179 const test = project.config;\n\
1180 const browser = Boolean(test.browser?.enabled);\n\
1181 const setup = browser ? browserSetup : nodeSetup;\n\
1182 test.setupFiles = [setup, ...(test.setupFiles ?? []).filter(file => file !== nodeSetup && file !== browserSetup)];\n\
1183 test.maxConcurrency = 1;\n\
1184 if (browser) {{\n\
1185 const commands = supercovBrowserCommands(process.cwd());\n\
1186 test.browser.commands = {{ ...test.browser.commands, ...commands }};\n\
1187 // Browser instances can already exist when reporters initialize.\n\
1188 // Their parent generates the browser command exports.\n\
1189 const server = project.browser?.parent ?? project.browser;\n\
1190 if (server?.commands) Object.assign(server.commands, commands);\n\
1191 }}\n\
1192 }}\n\
1193 }};\n\
1194 // Vitest's coverage measures the instrumented copy, probes included:\n\
1195 // a suite at 100% read 87.5% of branches, and autoUpdate would write\n\
1196 // what it measured into this file. Coverage is still collected and\n\
1197 // reported (see register.mjs). Vitest 0.x kept thresholds in coverage.\n\
1198 const supercovThresholdKeys = ['thresholds', 'lines', 'functions', 'branches', 'statements', 'perFile', '100', 'thresholdAutoUpdate'];\n\
1199 const supercovSkipCoverageThresholds = (coverage) => {{\n\
1200 const configured = supercovThresholdKeys.filter((key) => coverage?.[key] !== undefined);\n\
1201 for (const key of configured) delete coverage[key];\n\
1202 const collecting = coverage?.enabled === true ||\n\
1203 process.argv.some((argument) => /^--coverage(?:\\.enabled)?(?:=true)?$/.test(argument));\n\
1204 if (configured.length && collecting) process.__SUPERCOV_SKIPPED_COVERAGE_THRESHOLDS__?.('Vitest');\n\
1205 }};\n\
1206 export default async function supercovVitestConfig(env) {{\n\
1207 const originalPath = process.env.SUPERCOV_ORIGINAL_VITEST_CONFIG || discoveredConfig;\n\
1208 const loaded = originalPath ? await loadConfigFromFile(env, originalPath, process.cwd()) : undefined;\n\
1209 const config = mergeConfig(loaded?.config ?? {{}}, {{\n\
1210 cacheDir: resolve(process.cwd(), '.supercov/vitest-cache'),\n\
1211 plugins: [supercovSourceMaps()],\n\
1212 test: {{ setupFiles: [resolve(process.cwd(), supercovSetupFile(loaded?.config))], maxConcurrency: 1, ...supercovBrowserTest(loaded?.config) }},\n\
1213 }});\n\
1214 supercovSkipCoverageThresholds(config.test?.coverage);\n\
1215 const configuredReporters = loaded?.config?.test?.reporters;\n\
1216 config.test ??= {{}};\n\
1217 config.test.reporters = configuredReporters\n\
1218 ? [...(Array.isArray(configuredReporters) ? configuredReporters : [configuredReporters]), new SupercovVitestReporter(configureProjects)]\n\
1219 : ['default', new SupercovVitestReporter(configureProjects)];\n\
1220 return config;\n\
1221 }}\n"
1222 );
1223 atomic_write(&path, source.as_bytes())?;
1224 Ok(path)
1225}
1226
1227fn configure_playwright_runtime(
1228 generated: &Path,
1229 project: &CoverageProject,
1230) -> Result<(), JavascriptFrontendError> {
1231 let adapter_path = generated.join("playwright.mjs");
1232 let mut adapter =
1233 fs::read_to_string(&adapter_path).map_err(|source| io_error(&adapter_path, source))?;
1234 adapter = adapter
1235 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module)
1236 .replace(
1237 "__SUPERCOV_PLAYWRIGHT_TEST_EXPORT__",
1238 &project.playwright_test_export,
1239 )
1240 .replace(
1244 "__SUPERCOV_PHASE_TIMING__",
1245 if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() == Ok("1") {
1246 "1"
1247 } else {
1248 "0"
1249 },
1250 );
1251 if project.playwright_module != "@playwright/test" {
1252 let facade = serde_json::to_string(&project.playwright_module)
1259 .expect("serializing a module specifier cannot fail");
1260 adapter = adapter.replace(
1261 "export * from \"@playwright/test\";",
1262 &format!("export * from {facade};"),
1263 );
1264 }
1265 let mut exports = Vec::new();
1266 if project.playwright_test_export != "test" {
1267 exports.push(format!(
1268 "export {{ instrumentedTest as {} }};",
1269 project.playwright_test_export
1270 ));
1271 }
1272 exports.extend(
1273 project
1274 .playwright_exports
1275 .iter()
1276 .filter(|name| {
1277 name.as_str() != "test"
1278 && name.as_str() != "expect"
1279 && *name != &project.playwright_test_export
1280 })
1281 .map(|name| {
1282 let encoded = serde_json::to_string(name)
1283 .expect("serializing a JavaScript export name cannot fail");
1284 format!("export const {name} = __supercovAdapterExport(adapter[{encoded}]);")
1285 }),
1286 );
1287 adapter = adapter.replace("/*__SUPERCOV_ADAPTER_EXPORTS__*/", &exports.join("\n"));
1288 atomic_write(&adapter_path, adapter.as_bytes())?;
1289
1290 let loader_path = generated.join("resolve-loader.mjs");
1291 let loader = fs::read_to_string(&loader_path)
1292 .map_err(|source| io_error(&loader_path, source))?
1293 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module);
1294 atomic_write(&loader_path, loader.as_bytes())
1295}
1296
1297fn write_playwright_config(
1298 workspace: &Path,
1299 project: &CoverageProject,
1300 generated: &Path,
1301) -> Result<PathBuf, JavascriptFrontendError> {
1302 let path = generated.join("playwright.config.mjs");
1303 let original = relocated_project_file(workspace, project, project.playwright_config.as_ref());
1304 let original_import = if let Some(original) = &original {
1305 let relative = original
1306 .strip_prefix(workspace)
1307 .map_err(|_| JavascriptFrontendError::UnsafeSourcePath(original.display().to_string()))?
1308 .to_string_lossy()
1309 .replace('\\', "/");
1310 let specifier = serde_json::to_string(&format!("../{relative}"))
1311 .map_err(JavascriptFrontendError::Serialize)?;
1312 format!(
1319 "import {{ existsSync, readFileSync }} from 'node:fs';\n\
1320 import {{ createRequire }} from 'node:module';\n\
1321 const originalFile = fileURLToPath(new URL({specifier}, import.meta.url));\n\
1322 const originalIsModule = (() => {{\n\
1323 if (/\\.m[jt]s$/.test(originalFile)) return true;\n\
1324 if (/\\.c[jt]s$/.test(originalFile)) return false;\n\
1325 for (let folder = dirname(originalFile); ; folder = dirname(folder)) {{\n\
1326 const manifest = resolve(folder, 'package.json');\n\
1327 if (existsSync(manifest)) {{\n\
1328 try {{ return JSON.parse(readFileSync(manifest, 'utf8')).type === 'module'; }} catch {{ return false; }}\n\
1329 }}\n\
1330 if (dirname(folder) === folder) return false;\n\
1331 }}\n\
1332 }})();\n\
1333 const original = originalIsModule\n\
1334 ? (await import(pathToFileURL(originalFile).href)).default\n\
1335 : createRequire(import.meta.url)(originalFile);\n"
1336 )
1337 } else {
1338 "const original = {};\n".into()
1339 };
1340 let source = format!(
1346 "import './node_modules/register.mjs';\n\
1347 import {{ dirname, isAbsolute, relative, resolve }} from 'node:path';\n\
1348 import {{ fileURLToPath, pathToFileURL }} from 'node:url';\n\
1349 {original_import}\
1350 const configExport = original && original.__esModule === true && Object.prototype.hasOwnProperty.call(original, 'default') ? original.default : original;\n\
1351 const resolvedValue = typeof configExport === 'function' ? await configExport({{ command: 'test', mode: 'test' }}) : configExport;\n\
1352 const resolved = resolvedValue ?? {{}};\n\
1353 const runtimeProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');\n\
1354 const originalDirectory = {};
1355 const sourceProjectRoot = process.env.SUPERCOV_SOURCE_PROJECT_ROOT;\n\
1356 const runtimePath = value => {{\n\
1357 if (!value) return value;\n\
1358 const absolute = isAbsolute(value) ? value : resolve(originalDirectory, value);\n\
1359 const local = relative(runtimeProjectRoot, absolute);\n\
1360 if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1361 if (sourceProjectRoot) {{\n\
1362 const sourceLocal = relative(sourceProjectRoot, absolute);\n\
1363 if (sourceLocal === '' || (!sourceLocal.startsWith('..') && !isAbsolute(sourceLocal))) return resolve(runtimeProjectRoot, sourceLocal);\n\
1364 }}\n\
1365 throw new Error('Supercov refuses a Playwright output/cwd outside the isolated project: ' + absolute);\n\
1366 }};\n\
1367 const normalizeWebServer = server => server ? ({{ ...server, cwd: runtimePath(server.cwd ?? originalDirectory) }}) : server;\n\
1368 const normalized = {{ ...resolved,\n\
1369 testDir: runtimePath(resolved.testDir),\n\
1370 outputDir: runtimePath(resolved.outputDir),\n\
1371 snapshotDir: runtimePath(resolved.snapshotDir),\n\
1372 projects: resolved.projects?.map(project => ({{ ...project, testDir: runtimePath(project.testDir), outputDir: runtimePath(project.outputDir), snapshotDir: runtimePath(project.snapshotDir) }})),\n\
1373 webServer: Array.isArray(resolved.webServer) ? resolved.webServer.map(normalizeWebServer) : normalizeWebServer(resolved.webServer),\n\
1374 }};\n\
1375 const configuredReporters = normalized.reporter;\n\
1376 const reporters = configuredReporters\n\
1377 ? (typeof configuredReporters === 'string' ? [[configuredReporters]] : (Array.isArray(configuredReporters[0]) ? configuredReporters : [configuredReporters]))\n\
1378 : [['list']];\n\
1379 const coverageReporter = resolve(runtimeProjectRoot, '.supercov/node_modules/playwrightReporter.mjs');\n\
1380 export default {{ ...normalized, reporter: [...reporters, [coverageReporter]] }};\n",
1381 serde_json::to_string(
1382 &original
1383 .as_ref()
1384 .and_then(|path| path.parent())
1385 .unwrap_or(workspace)
1386 .display()
1387 .to_string()
1388 )
1389 .map_err(JavascriptFrontendError::Serialize)?
1390 );
1391 atomic_write(&path, source.as_bytes())?;
1392 Ok(path)
1393}
1394
1395fn write_vitest_commands(generated: &Path) -> Result<(), JavascriptFrontendError> {
1396 let adapter = "import { mkdirSync } from 'node:fs';\n\
1397import { relative, resolve, sep } from 'node:path';\n\
1398import { atomicWriteFileSync } from './node_modules/atomic.mjs';\n\
1399import { inferTestProvenance } from './node_modules/provenance.mjs';\n\
1400// Browser Mode runs the test file in the browser, where none of this is\n\
1401// reachable: no node:fs to write evidence, no project root to relativise a\n\
1402// path against, no env to read. Vitest's own browser command channel carries\n\
1403// what the browser observed to node, which completes the same payload the node\n\
1404// setup writes -- so one contract keeps one implementation. The command is\n\
1405// awaited as part of the test, and node learns the test file from Vitest\n\
1406// rather than from the browser realm, which cannot be trusted to name it.\n\
1407const supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/;\n\
1408export function supercovBrowserCommands(root) {\n\
1409 return {\n\
1410 __supercovEvidence(context, payload, suffix) {\n\
1411 const evidenceDirectory = process.env['SUPERCOV_EVIDENCE_DIR'];\n\
1412 if (!evidenceDirectory) return;\n\
1413 // A suffix names one directory under the evidence directory. Anything\n\
1414 // else is refused rather than resolved, so a malformed one cannot send\n\
1415 // the write outside the directory it is supposed to stay in.\n\
1416 if (typeof suffix !== 'string' || !supercovEvidenceSuffix.test(suffix))\n\
1417 throw new Error('Supercov refused an unusable evidence suffix: ' + suffix);\n\
1418 const absolute = context.testPath;\n\
1419 const testFile = absolute ? relative(root, absolute).split(sep).join('/') : undefined;\n\
1420 const projectName = payload.projectName ?? context.project?.name;\n\
1421 delete payload.projectName;\n\
1422 // The browser has no environment to read, so the run identity is stamped\n\
1423 // here rather than sent from a realm that cannot know it.\n\
1424 const runId = process.env['SUPERCOV_RUN_ID'];\n\
1425 if (runId && payload.scope) payload.scope.runId = runId;\n\
1426 const complete = Object.assign({}, payload, testFile ? { testFile } : {}, {\n\
1427 provenance: inferTestProvenance({\n\
1428 runner: 'vitest',\n\
1429 file: testFile,\n\
1430 project: projectName,\n\
1431 explicitKind: process.env['SUPERCOV_TEST_KIND'],\n\
1432 }),\n\
1433 });\n\
1434 const directory = resolve(root, evidenceDirectory, suffix);\n\
1435 mkdirSync(directory, { recursive: true });\n\
1436 atomicWriteFileSync(resolve(directory, 'mcdc.json'), JSON.stringify(complete) + '\\n');\n\
1437 },\n\
1438 };\n\
1439}\n";
1440 atomic_write(&generated.join("vitestCommands.mjs"), adapter.as_bytes())
1441}
1442
1443pub fn prepare_javascript_frontend(
1446 workspace: &Path,
1447 project: &CoverageProject,
1448 collector_id: &str,
1449 cache_key: &str,
1450 command: &[String],
1451) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
1452 let generated = workspace.join(".supercov");
1453 let runtime_directory = generated.join("node_modules");
1459 timed(&SETUP.runtime_ns, || {
1460 copy_runtime(&runtime_directory, collector_id)
1461 })?;
1462 let configuration_started = Instant::now();
1463 configure_playwright_runtime(&runtime_directory, project)?;
1464 let playwright_config_path = write_playwright_config(workspace, project, &generated)?;
1465 let vitest_config_path = write_vitest_config(workspace, project, &generated)?;
1466 account(&SETUP.config_ns, configuration_started);
1467
1468 let mut exclusions = Vec::new();
1469 let mut decisions = BTreeMap::new();
1470 let mut points = BTreeMap::new();
1471 let mut branches = BTreeMap::new();
1472 let mut limitations = BTreeMap::new();
1473 for limitation in &project.source_limitations {
1474 limitations.insert(limitation.id.clone(), limitation_from_source(limitation));
1475 }
1476
1477 let runtime_path = runtime_directory.join("runtime.mjs");
1478 let standalone_runtime =
1479 fs::read_to_string(&runtime_path).map_err(|source| io_error(&runtime_path, source))?;
1480 let browser_suite = project.playwright_config.is_some() || project.browser_runner;
1485 let mut authored = BTreeSet::new();
1486 let sources_started = Instant::now();
1487 for file in &project.source_files {
1488 let path = checked_source_path(workspace, file)?;
1489 let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
1490 let capability_wrapper = runtime_specifier(file, "capability.mjs")?;
1491 let elide = crate::typescript_imports::elides_type_imports(workspace, file, command);
1492 let instrumented = timed(&SETUP.instrument_ns, || {
1493 instrument_with_import_policy(&source, file, &capability_wrapper, elide)
1494 });
1495 let mut output = match instrumented {
1496 Ok(output) => output,
1497 Err(crate::js_instrumenter::CandidateError::Parse(messages)) => {
1501 let limitation = CandidateLimitation {
1502 id: format!("javascript-file-not-parsed#{file}"),
1503 kind: "source-scope".into(),
1504 file: file.clone(),
1505 line: 1,
1506 column: 0,
1507 source: String::new(),
1508 reason: format!(
1509 "Supercov could not parse this file ({}); it runs as written and nothing in it is measured",
1510 messages.join("; ")
1511 ),
1512 };
1513 limitations.insert(limitation.id.clone(), limitation);
1514 continue;
1515 }
1516 Err(source) => {
1517 return Err(JavascriptFrontendError::Instrument {
1518 file: file.clone(),
1519 source,
1520 });
1521 }
1522 };
1523 if output
1527 .code
1528 .contains(crate::js_instrumenter::RUNTIME_TYPES_MODULE)
1529 {
1530 output.code = output.code.replace(
1531 crate::js_instrumenter::RUNTIME_TYPES_MODULE,
1532 &runtime_specifier(file, "runtime.mjs")?,
1533 );
1534 }
1535 bootstrap_runtime(&mut output, &standalone_runtime, browser_suite);
1536 if !browser_suite {
1537 bootstrap_loader(&mut output, file)?;
1538 }
1539 if matches!(
1544 path.extension().and_then(|value| value.to_str()),
1545 Some("ts" | "tsx" | "mts" | "cts")
1546 ) {
1547 output.code = generated_source_banner(&output.code);
1548 }
1549 let code = match inline_instrumentation_map(
1556 &output.code,
1557 output.map.as_ref(),
1558 &project.root.join(file),
1559 &source,
1560 ) {
1561 Some(code) => code,
1562 None => output.code.clone(),
1563 };
1564 keep_authored(workspace, &mut authored, file, &source)?;
1565 atomic_write(&path, code.as_bytes())?;
1566 exclusions.extend(output.excluded_statements);
1567 for value in output.decisions {
1568 decisions.insert(value.id.clone(), value);
1569 }
1570 for value in output.points {
1571 points.insert(value.id.clone(), value);
1572 }
1573 for value in output.branches {
1574 branches.insert(value.id.clone(), value);
1575 }
1576 for value in output.coverage_limitations {
1577 limitations.insert(value.id.clone(), value);
1578 }
1579 }
1580 account(&SETUP.sources_ns, sources_started);
1581 write_vitest_commands(&generated)?;
1582
1583 let assertions_started = Instant::now();
1584 let mut assertion_calls = 0;
1585 for entry in &project.source_scope.entries {
1586 let path = checked_source_path(workspace, &entry.file)?;
1587 let Ok(source) = fs::read_to_string(&path) else {
1588 continue;
1589 };
1590 let capability_wrapper = (!project.source_files.contains(&entry.file))
1591 .then(|| runtime_specifier(&entry.file, "capability.mjs"))
1592 .transpose()?;
1593 let assertion_runtime = runtime_specifier(&entry.file, "bootstrap.cjs")?;
1594 let output =
1595 match crate::js_instrumenter::instrument_node_assertion_phases_with_runtime_imports(
1596 &source,
1597 &entry.file,
1598 std::slice::from_ref(&project.playwright_module),
1599 capability_wrapper.as_deref(),
1600 Some(&assertion_runtime),
1601 ) {
1602 Ok(output) => output,
1603 Err(crate::js_instrumenter::CandidateError::Parse(_)) => continue,
1606 Err(source) => {
1607 return Err(JavascriptFrontendError::Instrument {
1608 file: entry.file.clone(),
1609 source,
1610 });
1611 }
1612 };
1613 if output.assertions > 0 || output.capability_imports > 0 {
1614 keep_authored(workspace, &mut authored, &entry.file, &source)?;
1615 atomic_write(&path, output.code.as_bytes())?;
1616 assertion_calls += output.assertions;
1617 }
1618 }
1619
1620 account(&SETUP.assertion_ns, assertions_started);
1621
1622 let mut manifest = JavascriptManifest {
1623 decisions: decisions.into_values().collect(),
1624 points: points.into_values().collect(),
1625 branches: branches.into_values().collect(),
1626 limitations: limitations.into_values().collect(),
1627 scope: project.source_scope.clone(),
1628 };
1629 manifest.decisions.sort_by_key(|value| {
1630 (
1631 value.file.clone(),
1632 value.line,
1633 value.column,
1634 value.id.clone(),
1635 )
1636 });
1637 manifest.points.sort_by_key(|value| {
1638 (
1639 value.file.clone(),
1640 value.line,
1641 value.column,
1642 value.id.clone(),
1643 )
1644 });
1645 manifest.branches.sort_by_key(|value| {
1646 (
1647 value.file.clone(),
1648 value.line,
1649 value.column,
1650 value.id.clone(),
1651 )
1652 });
1653 manifest.limitations.sort_by_key(|value| {
1654 (
1655 value.file.clone(),
1656 value.line,
1657 value.column,
1658 value.id.clone(),
1659 )
1660 });
1661
1662 atomic_write(
1663 &workspace.join(AUTHORED_LIST),
1664 &serde_json::to_vec(&authored).map_err(JavascriptFrontendError::Serialize)?,
1665 )?;
1666 atomic_write(
1667 &generated.join("statement-exclusions.json"),
1668 &serde_json::to_vec(&exclusions).map_err(JavascriptFrontendError::Serialize)?,
1669 )?;
1670 let manifest_path = generated.join("manifest.json");
1671 let mut encoded =
1672 serde_json::to_vec_pretty(&manifest).map_err(JavascriptFrontendError::Serialize)?;
1673 encoded.push(b'\n');
1674 atomic_write(&manifest_path, &encoded)?;
1675 atomic_write(
1676 &generated.join("instrumentation-complete"),
1677 b"coverage-completeness-v2\n",
1678 )?;
1679 timed(&SETUP.cache_ns, || {
1680 write_javascript_frontend_cache(workspace, project, cache_key, assertion_calls)
1681 })?;
1682 Ok(PreparedJavascriptFrontend {
1683 manifest,
1684 manifest_path,
1685 preload_path: generated.join("node_modules/register.mjs"),
1686 playwright_config_path,
1687 vitest_config_path,
1688 assertion_calls,
1689 })
1690}
1691
1692#[cfg(test)]
1693mod tests {
1694 #[test]
1695 fn ancestor_description_names_each_component_and_its_state() {
1696 let root = std::env::temp_dir().join(format!("supercov-ancestors-{}", unique()));
1697 fs::create_dir_all(root.join("present")).unwrap();
1698 fs::write(root.join("present/file.txt"), b"x").unwrap();
1699 let described = super::describe_ancestors(&root.join("present/file.txt/child"));
1700 assert!(described.contains("present=dir"), "{described}");
1701 assert!(described.contains("file.txt=file"), "{described}");
1702 assert!(described.ends_with("child=missing"), "{described}");
1703 fs::remove_dir_all(&root).unwrap();
1704 }
1705
1706 use super::*;
1707 use crate::project_discovery::discover_coverage_project;
1708
1709 fn temporary(name: &str) -> PathBuf {
1710 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1711 .join("../../target/supercov-test-fixtures")
1712 .join(format!("javascript-frontend-{name}-{}", unique()));
1713 fs::create_dir_all(&path).unwrap();
1719 crate::workspace::canonicalize_simplified(path).unwrap()
1720 }
1721
1722 #[test]
1723 fn browser_mode_gets_a_browser_setup_and_the_command_that_carries_its_evidence() {
1724 let generated = temporary("browser-mode-config");
1725 write_vitest_commands(&generated).unwrap();
1726 let adapter = fs::read_to_string(generated.join("vitestCommands.mjs")).unwrap();
1727 assert!(adapter.contains("export function supercovBrowserCommands(root)"));
1731 assert!(adapter.contains("__supercovEvidence(context, payload, suffix)"));
1732 assert!(adapter.contains("const absolute = context.testPath;"));
1733 assert!(!adapter.contains("configureServer"), "{adapter}");
1734 assert!(!adapter.contains("__supercov/evidence"), "{adapter}");
1735 assert!(adapter.contains("supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/"));
1738 assert!(adapter.contains("Supercov refused an unusable evidence suffix"));
1739 }
1740
1741 #[test]
1742 fn a_browser_mode_project_loads_the_setup_that_can_run_in_a_browser() {
1743 let workspace = temporary("browser-mode-setup");
1746 let generated = workspace.join(".supercov");
1747 fs::create_dir_all(&generated).unwrap();
1748 fs::write(workspace.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1749 fs::create_dir_all(workspace.join("src")).unwrap();
1750 fs::write(
1751 workspace.join("src/example.mjs"),
1752 "export const one = () => 1;\n",
1753 )
1754 .unwrap();
1755 let project = discover_coverage_project(
1756 &workspace,
1757 &BTreeMap::new(),
1758 &["npx".into(), "vitest".into(), "run".into()],
1759 )
1760 .unwrap();
1761 let path = write_vitest_config(&workspace, &project, &generated).unwrap();
1762 let config = fs::read_to_string(path).unwrap();
1763 assert!(config.contains("config?.test?.browser?.enabled"));
1764 assert!(config.contains("'.supercov/node_modules/vitestBrowser.mjs'"));
1765 assert!(config.contains("'.supercov/node_modules/vitest.mjs'"));
1766 assert!(config.contains("browser: { commands: supercovBrowserCommands(process.cwd()) }"));
1768 assert!(config.contains("...supercovBrowserTest(loaded?.config)"));
1769 }
1770
1771 #[test]
1772 fn runtime_isolation_replaces_only_the_assignment_marker() {
1773 let source = concat!(
1774 "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n",
1775 "const selected = runtimeInstanceToken === \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\";\n"
1776 );
1777 let isolated = isolate_runtime(source, "collector-123").unwrap();
1778 assert!(isolated.contains("runtimeInstanceToken = \"collector-123\""));
1779 assert!(isolated.contains("=== \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\""));
1780 }
1781
1782 #[test]
1783 fn copied_runtime_does_not_reference_unshipped_source_maps() {
1784 let generated = temporary("runtime-source-maps");
1785 copy_runtime(&generated, "collector-test").unwrap();
1786 for name in ["vitest.mjs", "provenance.mjs", "atomic.mjs"] {
1787 let contents = fs::read_to_string(generated.join(name)).unwrap();
1788 assert!(
1789 !contents.contains("sourceMappingURL"),
1790 "runtime shim retained a source-map directive: {name}"
1791 );
1792 }
1793 fs::remove_dir_all(generated).unwrap();
1794 }
1795
1796 #[test]
1797 fn prepares_sorted_complete_manifest_without_touching_source_project() {
1798 let source_root = temporary("source");
1799 let workspace = temporary("workspace");
1800 fs::create_dir_all(source_root.join("src")).unwrap();
1801 fs::write(
1802 source_root.join("src/example.mjs"),
1803 "export function value(a, b) { if (a || b) return 1; return 0; }\n",
1804 )
1805 .unwrap();
1806 fs::write(source_root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1807 fs::create_dir_all(workspace.join("src")).unwrap();
1808 fs::create_dir_all(workspace.join(".supercov")).unwrap();
1809 fs::copy(
1810 source_root.join("src/example.mjs"),
1811 workspace.join("src/example.mjs"),
1812 )
1813 .unwrap();
1814 let project = discover_coverage_project(
1815 &source_root,
1816 &BTreeMap::new(),
1817 &["node".into(), "--test".into()],
1818 )
1819 .unwrap();
1820 let original = fs::read_to_string(source_root.join("src/example.mjs")).unwrap();
1821 let prepared =
1822 prepare_javascript_frontend(&workspace, &project, "collector-test", "cache-test", &[])
1823 .unwrap();
1824 assert_eq!(
1825 fs::read_to_string(source_root.join("src/example.mjs")).unwrap(),
1826 original
1827 );
1828 let transformed = fs::read_to_string(workspace.join("src/example.mjs")).unwrap();
1829 assert!(transformed.contains("__SUPERCOV_DIRECT_RUNTIME__"));
1830 assert_eq!(prepared.manifest.decisions.len(), 1);
1831 assert!(!prepared.manifest.points.is_empty());
1832 assert_eq!(prepared.manifest.scope, project.source_scope);
1833 assert!(prepared.manifest_path.is_file());
1834 assert!(prepared.preload_path.is_file());
1835 assert!(prepared.playwright_config_path.is_file());
1836 assert!(prepared.vitest_config_path.is_file());
1837 assert_eq!(prepared.assertion_calls, 0);
1838 let cache = read_javascript_frontend_cache(&workspace, "cache-test").unwrap();
1839 assert_eq!(
1840 javascript_frontend_reuse_paths(&cache),
1841 [
1842 PathBuf::from(".supercov/frontend-cache.json"),
1843 PathBuf::from(".supercov/frontend-cache-artifacts"),
1844 ]
1845 );
1846 assert!(
1847 cache
1848 .artifacts
1849 .iter()
1850 .all(|artifact| !artifact.cache_file.contains("src/")
1851 && !artifact.cache_file.contains("tests/"))
1852 );
1853 fs::write(workspace.join("src/example.mjs"), &original).unwrap();
1854 fs::remove_file(&prepared.manifest_path).unwrap();
1855 let restored = load_cached_javascript_frontend(&workspace, &cache).unwrap();
1856 assert_eq!(restored.manifest, prepared.manifest);
1857 assert_eq!(
1858 fs::read_to_string(workspace.join("src/example.mjs")).unwrap(),
1859 transformed
1860 );
1861 fs::write(workspace.join(&cache.artifacts[0].cache_file), "corrupt").unwrap();
1862 assert!(read_javascript_frontend_cache(&workspace, "cache-test").is_none());
1863 fs::remove_dir_all(source_root).unwrap();
1864 fs::remove_dir_all(workspace).unwrap();
1865 }
1866
1867 #[test]
1868 fn embedded_runtime_contains_every_declared_shim() {
1869 for name in RUNTIME_FILES {
1870 let bytes = embedded_runtime(name).unwrap();
1871 assert!(!bytes.is_empty(), "embedded runtime is empty: {name}");
1872 }
1873 assert!(
1874 std::str::from_utf8(embedded_runtime("runtime.mjs").unwrap())
1875 .unwrap()
1876 .contains(RUNTIME_INSTANCE_MARKER)
1877 );
1878 }
1879}