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