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 // How the configuration resolved, for a later merge: a run of new
1368 // tests with settings of their own is added to this one only where
1369 // nothing that decides what a test does changed. Paths inside the
1370 // copy are written relative to it; every run has a copy of its own.
1371 if (process.env.SUPERCOV_EVIDENCE_DIR && process.env.TEST_WORKER_INDEX === undefined) {{\n\
1372 try {{\n\
1373 const {{ mkdirSync, renameSync, writeFileSync }} = await import('node:fs');\n\
1374 const seen = new WeakSet();\n\
1375 let text = JSON.stringify(resolved, (key, value) => {{\n\
1376 if (typeof value === 'function' || value instanceof RegExp) return String(value);\n\
1377 if (value && typeof value === 'object') {{ if (seen.has(value)) return undefined; seen.add(value); }}\n\
1378 return value;\n\
1379 }});\n\
1380 for (const root of [runtimeProjectRoot, sourceProjectRoot].filter(Boolean)) text = text.split(JSON.stringify(root).slice(1, -1)).join('.');\n\
1381 const directory = resolve(process.cwd(), process.env.SUPERCOV_EVIDENCE_DIR);\n\
1382 mkdirSync(directory, {{ recursive: true }});\n\
1383 const file = resolve(directory, 'playwright-configuration.json');\n\
1384 writeFileSync(`${{file}}.${{process.pid}}`, text);\n\
1385 renameSync(`${{file}}.${{process.pid}}`, file);\n\
1386 }} catch {{}}\n\
1387 }}\n\
1388 const normalized = {{ ...resolved,\n\
1389 testDir: runtimePath(resolved.testDir),\n\
1390 outputDir: runtimePath(resolved.outputDir),\n\
1391 snapshotDir: runtimePath(resolved.snapshotDir),\n\
1392 projects: resolved.projects?.map(project => ({{ ...project, testDir: runtimePath(project.testDir), outputDir: runtimePath(project.outputDir), snapshotDir: runtimePath(project.snapshotDir) }})),\n\
1393 webServer: Array.isArray(resolved.webServer) ? resolved.webServer.map(normalizeWebServer) : normalizeWebServer(resolved.webServer),\n\
1394 }};\n\
1395 const configuredReporters = normalized.reporter;\n\
1396 const reporters = configuredReporters\n\
1397 ? (typeof configuredReporters === 'string' ? [[configuredReporters]] : (Array.isArray(configuredReporters[0]) ? configuredReporters : [configuredReporters]))\n\
1398 : [['list']];\n\
1399 const coverageReporter = resolve(runtimeProjectRoot, '.supercov/node_modules/playwrightReporter.mjs');\n\
1400 export default {{ ...normalized, reporter: [...reporters, [coverageReporter]] }};\n",
1401 serde_json::to_string(
1402 &original
1403 .as_ref()
1404 .and_then(|path| path.parent())
1405 .unwrap_or(workspace)
1406 .display()
1407 .to_string()
1408 )
1409 .map_err(JavascriptFrontendError::Serialize)?
1410 );
1411 atomic_write(&path, source.as_bytes())?;
1412 Ok(path)
1413}
1414
1415fn write_vitest_commands(generated: &Path) -> Result<(), JavascriptFrontendError> {
1416 let adapter = "import { mkdirSync } from 'node:fs';\n\
1417import { relative, resolve, sep } from 'node:path';\n\
1418import { atomicWriteFileSync } from './node_modules/atomic.mjs';\n\
1419import { inferTestProvenance } from './node_modules/provenance.mjs';\n\
1420// Browser Mode runs the test file in the browser, where none of this is\n\
1421// reachable: no node:fs to write evidence, no project root to relativise a\n\
1422// path against, no env to read. Vitest's own browser command channel carries\n\
1423// what the browser observed to node, which completes the same payload the node\n\
1424// setup writes -- so one contract keeps one implementation. The command is\n\
1425// awaited as part of the test, and node learns the test file from Vitest\n\
1426// rather than from the browser realm, which cannot be trusted to name it.\n\
1427const supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/;\n\
1428export function supercovBrowserCommands(root) {\n\
1429 return {\n\
1430 __supercovEvidence(context, payload, suffix) {\n\
1431 const evidenceDirectory = process.env['SUPERCOV_EVIDENCE_DIR'];\n\
1432 if (!evidenceDirectory) return;\n\
1433 // A suffix names one directory under the evidence directory. Anything\n\
1434 // else is refused rather than resolved, so a malformed one cannot send\n\
1435 // the write outside the directory it is supposed to stay in.\n\
1436 if (typeof suffix !== 'string' || !supercovEvidenceSuffix.test(suffix))\n\
1437 throw new Error('Supercov refused an unusable evidence suffix: ' + suffix);\n\
1438 const absolute = context.testPath;\n\
1439 const testFile = absolute ? relative(root, absolute).split(sep).join('/') : undefined;\n\
1440 const projectName = payload.projectName ?? context.project?.name;\n\
1441 delete payload.projectName;\n\
1442 // The browser has no environment to read, so the run identity is stamped\n\
1443 // here rather than sent from a realm that cannot know it.\n\
1444 const runId = process.env['SUPERCOV_RUN_ID'];\n\
1445 if (runId && payload.scope) payload.scope.runId = runId;\n\
1446 const complete = Object.assign({}, payload, testFile ? { testFile } : {}, {\n\
1447 provenance: inferTestProvenance({\n\
1448 runner: 'vitest',\n\
1449 file: testFile,\n\
1450 project: projectName,\n\
1451 explicitKind: process.env['SUPERCOV_TEST_KIND'],\n\
1452 }),\n\
1453 });\n\
1454 const directory = resolve(root, evidenceDirectory, suffix);\n\
1455 mkdirSync(directory, { recursive: true });\n\
1456 atomicWriteFileSync(resolve(directory, 'mcdc.json'), JSON.stringify(complete) + '\\n');\n\
1457 },\n\
1458 };\n\
1459}\n";
1460 atomic_write(&generated.join("vitestCommands.mjs"), adapter.as_bytes())
1461}
1462
1463pub fn prepare_javascript_frontend(
1466 workspace: &Path,
1467 project: &CoverageProject,
1468 collector_id: &str,
1469 cache_key: &str,
1470 command: &[String],
1471) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
1472 let generated = workspace.join(".supercov");
1473 let runtime_directory = generated.join("node_modules");
1479 timed(&SETUP.runtime_ns, || {
1480 copy_runtime(&runtime_directory, collector_id)
1481 })?;
1482 let configuration_started = Instant::now();
1483 configure_playwright_runtime(&runtime_directory, project)?;
1484 let playwright_config_path = write_playwright_config(workspace, project, &generated)?;
1485 let vitest_config_path = write_vitest_config(workspace, project, &generated)?;
1486 account(&SETUP.config_ns, configuration_started);
1487
1488 let mut exclusions = Vec::new();
1489 let mut decisions = BTreeMap::new();
1490 let mut points = BTreeMap::new();
1491 let mut branches = BTreeMap::new();
1492 let mut limitations = BTreeMap::new();
1493 for limitation in &project.source_limitations {
1494 limitations.insert(limitation.id.clone(), limitation_from_source(limitation));
1495 }
1496
1497 let runtime_path = runtime_directory.join("runtime.mjs");
1498 let standalone_runtime =
1499 fs::read_to_string(&runtime_path).map_err(|source| io_error(&runtime_path, source))?;
1500 let browser_suite = project.playwright_config.is_some() || project.browser_runner;
1505 let mut authored = BTreeSet::new();
1506 let sources_started = Instant::now();
1507 for file in &project.source_files {
1508 let path = checked_source_path(workspace, file)?;
1509 let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
1510 let capability_wrapper = runtime_specifier(file, "capability.mjs")?;
1511 let elide = crate::typescript_imports::elides_type_imports(workspace, file, command);
1512 let instrumented = timed(&SETUP.instrument_ns, || {
1513 instrument_with_import_policy(&source, file, &capability_wrapper, elide)
1514 });
1515 let mut output = match instrumented {
1516 Ok(output) => output,
1517 Err(crate::js_instrumenter::CandidateError::Parse(messages)) => {
1521 let limitation = CandidateLimitation {
1522 id: format!("javascript-file-not-parsed#{file}"),
1523 kind: "source-scope".into(),
1524 file: file.clone(),
1525 line: 1,
1526 column: 0,
1527 source: String::new(),
1528 reason: format!(
1529 "Supercov could not parse this file ({}); it runs as written and nothing in it is measured",
1530 messages.join("; ")
1531 ),
1532 };
1533 limitations.insert(limitation.id.clone(), limitation);
1534 continue;
1535 }
1536 Err(source) => {
1537 return Err(JavascriptFrontendError::Instrument {
1538 file: file.clone(),
1539 source,
1540 });
1541 }
1542 };
1543 if output
1547 .code
1548 .contains(crate::js_instrumenter::RUNTIME_TYPES_MODULE)
1549 {
1550 output.code = output.code.replace(
1551 crate::js_instrumenter::RUNTIME_TYPES_MODULE,
1552 &runtime_specifier(file, "runtime.mjs")?,
1553 );
1554 }
1555 bootstrap_runtime(&mut output, &standalone_runtime, browser_suite);
1556 if !browser_suite {
1557 bootstrap_loader(&mut output, file)?;
1558 }
1559 if matches!(
1564 path.extension().and_then(|value| value.to_str()),
1565 Some("ts" | "tsx" | "mts" | "cts")
1566 ) {
1567 output.code = generated_source_banner(&output.code);
1568 }
1569 let code = match inline_instrumentation_map(
1576 &output.code,
1577 output.map.as_ref(),
1578 &project.root.join(file),
1579 &source,
1580 ) {
1581 Some(code) => code,
1582 None => output.code.clone(),
1583 };
1584 keep_authored(workspace, &mut authored, file, &source)?;
1585 atomic_write(&path, code.as_bytes())?;
1586 exclusions.extend(output.excluded_statements);
1587 for value in output.decisions {
1588 decisions.insert(value.id.clone(), value);
1589 }
1590 for value in output.points {
1591 points.insert(value.id.clone(), value);
1592 }
1593 for value in output.branches {
1594 branches.insert(value.id.clone(), value);
1595 }
1596 for value in output.coverage_limitations {
1597 limitations.insert(value.id.clone(), value);
1598 }
1599 }
1600 account(&SETUP.sources_ns, sources_started);
1601 write_vitest_commands(&generated)?;
1602
1603 let assertions_started = Instant::now();
1604 let mut assertion_calls = 0;
1605 for entry in &project.source_scope.entries {
1606 let path = checked_source_path(workspace, &entry.file)?;
1607 let Ok(source) = fs::read_to_string(&path) else {
1608 continue;
1609 };
1610 let capability_wrapper = (!project.source_files.contains(&entry.file))
1611 .then(|| runtime_specifier(&entry.file, "capability.mjs"))
1612 .transpose()?;
1613 let assertion_runtime = runtime_specifier(&entry.file, "bootstrap.cjs")?;
1614 let output =
1615 match crate::js_instrumenter::instrument_node_assertion_phases_with_runtime_imports(
1616 &source,
1617 &entry.file,
1618 std::slice::from_ref(&project.playwright_module),
1619 capability_wrapper.as_deref(),
1620 Some(&assertion_runtime),
1621 ) {
1622 Ok(output) => output,
1623 Err(crate::js_instrumenter::CandidateError::Parse(_)) => continue,
1626 Err(source) => {
1627 return Err(JavascriptFrontendError::Instrument {
1628 file: entry.file.clone(),
1629 source,
1630 });
1631 }
1632 };
1633 if output.assertions > 0 || output.capability_imports > 0 {
1634 keep_authored(workspace, &mut authored, &entry.file, &source)?;
1635 atomic_write(&path, output.code.as_bytes())?;
1636 assertion_calls += output.assertions;
1637 }
1638 }
1639
1640 account(&SETUP.assertion_ns, assertions_started);
1641
1642 let mut manifest = JavascriptManifest {
1643 decisions: decisions.into_values().collect(),
1644 points: points.into_values().collect(),
1645 branches: branches.into_values().collect(),
1646 limitations: limitations.into_values().collect(),
1647 scope: project.source_scope.clone(),
1648 };
1649 manifest.decisions.sort_by_key(|value| {
1650 (
1651 value.file.clone(),
1652 value.line,
1653 value.column,
1654 value.id.clone(),
1655 )
1656 });
1657 manifest.points.sort_by_key(|value| {
1658 (
1659 value.file.clone(),
1660 value.line,
1661 value.column,
1662 value.id.clone(),
1663 )
1664 });
1665 manifest.branches.sort_by_key(|value| {
1666 (
1667 value.file.clone(),
1668 value.line,
1669 value.column,
1670 value.id.clone(),
1671 )
1672 });
1673 manifest.limitations.sort_by_key(|value| {
1674 (
1675 value.file.clone(),
1676 value.line,
1677 value.column,
1678 value.id.clone(),
1679 )
1680 });
1681
1682 atomic_write(
1683 &workspace.join(AUTHORED_LIST),
1684 &serde_json::to_vec(&authored).map_err(JavascriptFrontendError::Serialize)?,
1685 )?;
1686 atomic_write(
1687 &generated.join("statement-exclusions.json"),
1688 &serde_json::to_vec(&exclusions).map_err(JavascriptFrontendError::Serialize)?,
1689 )?;
1690 let manifest_path = generated.join("manifest.json");
1691 let mut encoded =
1692 serde_json::to_vec_pretty(&manifest).map_err(JavascriptFrontendError::Serialize)?;
1693 encoded.push(b'\n');
1694 atomic_write(&manifest_path, &encoded)?;
1695 atomic_write(
1696 &generated.join("instrumentation-complete"),
1697 b"coverage-completeness-v2\n",
1698 )?;
1699 timed(&SETUP.cache_ns, || {
1700 write_javascript_frontend_cache(workspace, project, cache_key, assertion_calls)
1701 })?;
1702 Ok(PreparedJavascriptFrontend {
1703 manifest,
1704 manifest_path,
1705 preload_path: generated.join("node_modules/register.mjs"),
1706 playwright_config_path,
1707 vitest_config_path,
1708 assertion_calls,
1709 })
1710}
1711
1712#[cfg(test)]
1713mod tests {
1714 #[test]
1715 fn ancestor_description_names_each_component_and_its_state() {
1716 let root = std::env::temp_dir().join(format!("supercov-ancestors-{}", unique()));
1717 fs::create_dir_all(root.join("present")).unwrap();
1718 fs::write(root.join("present/file.txt"), b"x").unwrap();
1719 let described = super::describe_ancestors(&root.join("present/file.txt/child"));
1720 assert!(described.contains("present=dir"), "{described}");
1721 assert!(described.contains("file.txt=file"), "{described}");
1722 assert!(described.ends_with("child=missing"), "{described}");
1723 fs::remove_dir_all(&root).unwrap();
1724 }
1725
1726 use super::*;
1727 use crate::project_discovery::discover_coverage_project;
1728
1729 fn temporary(name: &str) -> PathBuf {
1730 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1731 .join("../../target/supercov-test-fixtures")
1732 .join(format!("javascript-frontend-{name}-{}", unique()));
1733 fs::create_dir_all(&path).unwrap();
1739 crate::workspace::canonicalize_simplified(path).unwrap()
1740 }
1741
1742 #[test]
1743 fn browser_mode_gets_a_browser_setup_and_the_command_that_carries_its_evidence() {
1744 let generated = temporary("browser-mode-config");
1745 write_vitest_commands(&generated).unwrap();
1746 let adapter = fs::read_to_string(generated.join("vitestCommands.mjs")).unwrap();
1747 assert!(adapter.contains("export function supercovBrowserCommands(root)"));
1751 assert!(adapter.contains("__supercovEvidence(context, payload, suffix)"));
1752 assert!(adapter.contains("const absolute = context.testPath;"));
1753 assert!(!adapter.contains("configureServer"), "{adapter}");
1754 assert!(!adapter.contains("__supercov/evidence"), "{adapter}");
1755 assert!(adapter.contains("supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/"));
1758 assert!(adapter.contains("Supercov refused an unusable evidence suffix"));
1759 }
1760
1761 #[test]
1762 fn a_browser_mode_project_loads_the_setup_that_can_run_in_a_browser() {
1763 let workspace = temporary("browser-mode-setup");
1766 let generated = workspace.join(".supercov");
1767 fs::create_dir_all(&generated).unwrap();
1768 fs::write(workspace.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1769 fs::create_dir_all(workspace.join("src")).unwrap();
1770 fs::write(
1771 workspace.join("src/example.mjs"),
1772 "export const one = () => 1;\n",
1773 )
1774 .unwrap();
1775 let project = discover_coverage_project(
1776 &workspace,
1777 &BTreeMap::new(),
1778 &["npx".into(), "vitest".into(), "run".into()],
1779 )
1780 .unwrap();
1781 let path = write_vitest_config(&workspace, &project, &generated).unwrap();
1782 let config = fs::read_to_string(path).unwrap();
1783 assert!(config.contains("config?.test?.browser?.enabled"));
1784 assert!(config.contains("'.supercov/node_modules/vitestBrowser.mjs'"));
1785 assert!(config.contains("'.supercov/node_modules/vitest.mjs'"));
1786 assert!(config.contains("browser: { commands: supercovBrowserCommands(process.cwd()) }"));
1788 assert!(config.contains("...supercovBrowserTest(loaded?.config)"));
1789 }
1790
1791 #[test]
1792 fn runtime_isolation_replaces_only_the_assignment_marker() {
1793 let source = concat!(
1794 "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n",
1795 "const selected = runtimeInstanceToken === \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\";\n"
1796 );
1797 let isolated = isolate_runtime(source, "collector-123").unwrap();
1798 assert!(isolated.contains("runtimeInstanceToken = \"collector-123\""));
1799 assert!(isolated.contains("=== \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\""));
1800 }
1801
1802 #[test]
1803 fn copied_runtime_does_not_reference_unshipped_source_maps() {
1804 let generated = temporary("runtime-source-maps");
1805 copy_runtime(&generated, "collector-test").unwrap();
1806 for name in ["vitest.mjs", "provenance.mjs", "atomic.mjs"] {
1807 let contents = fs::read_to_string(generated.join(name)).unwrap();
1808 assert!(
1809 !contents.contains("sourceMappingURL"),
1810 "runtime shim retained a source-map directive: {name}"
1811 );
1812 }
1813 fs::remove_dir_all(generated).unwrap();
1814 }
1815
1816 #[test]
1817 fn prepares_sorted_complete_manifest_without_touching_source_project() {
1818 let source_root = temporary("source");
1819 let workspace = temporary("workspace");
1820 fs::create_dir_all(source_root.join("src")).unwrap();
1821 fs::write(
1822 source_root.join("src/example.mjs"),
1823 "export function value(a, b) { if (a || b) return 1; return 0; }\n",
1824 )
1825 .unwrap();
1826 fs::write(source_root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1827 fs::create_dir_all(workspace.join("src")).unwrap();
1828 fs::create_dir_all(workspace.join(".supercov")).unwrap();
1829 fs::copy(
1830 source_root.join("src/example.mjs"),
1831 workspace.join("src/example.mjs"),
1832 )
1833 .unwrap();
1834 let project = discover_coverage_project(
1835 &source_root,
1836 &BTreeMap::new(),
1837 &["node".into(), "--test".into()],
1838 )
1839 .unwrap();
1840 let original = fs::read_to_string(source_root.join("src/example.mjs")).unwrap();
1841 let prepared =
1842 prepare_javascript_frontend(&workspace, &project, "collector-test", "cache-test", &[])
1843 .unwrap();
1844 assert_eq!(
1845 fs::read_to_string(source_root.join("src/example.mjs")).unwrap(),
1846 original
1847 );
1848 let transformed = fs::read_to_string(workspace.join("src/example.mjs")).unwrap();
1849 assert!(transformed.contains("__SUPERCOV_DIRECT_RUNTIME__"));
1850 assert_eq!(prepared.manifest.decisions.len(), 1);
1851 assert!(!prepared.manifest.points.is_empty());
1852 assert_eq!(prepared.manifest.scope, project.source_scope);
1853 assert!(prepared.manifest_path.is_file());
1854 assert!(prepared.preload_path.is_file());
1855 assert!(prepared.playwright_config_path.is_file());
1856 assert!(prepared.vitest_config_path.is_file());
1857 assert_eq!(prepared.assertion_calls, 0);
1858 let cache = read_javascript_frontend_cache(&workspace, "cache-test").unwrap();
1859 assert_eq!(
1860 javascript_frontend_reuse_paths(&cache),
1861 [
1862 PathBuf::from(".supercov/frontend-cache.json"),
1863 PathBuf::from(".supercov/frontend-cache-artifacts"),
1864 ]
1865 );
1866 assert!(
1867 cache
1868 .artifacts
1869 .iter()
1870 .all(|artifact| !artifact.cache_file.contains("src/")
1871 && !artifact.cache_file.contains("tests/"))
1872 );
1873 fs::write(workspace.join("src/example.mjs"), &original).unwrap();
1874 fs::remove_file(&prepared.manifest_path).unwrap();
1875 let restored = load_cached_javascript_frontend(&workspace, &cache).unwrap();
1876 assert_eq!(restored.manifest, prepared.manifest);
1877 assert_eq!(
1878 fs::read_to_string(workspace.join("src/example.mjs")).unwrap(),
1879 transformed
1880 );
1881 fs::write(workspace.join(&cache.artifacts[0].cache_file), "corrupt").unwrap();
1882 assert!(read_javascript_frontend_cache(&workspace, "cache-test").is_none());
1883 fs::remove_dir_all(source_root).unwrap();
1884 fs::remove_dir_all(workspace).unwrap();
1885 }
1886
1887 #[test]
1888 fn embedded_runtime_contains_every_declared_shim() {
1889 for name in RUNTIME_FILES {
1890 let bytes = embedded_runtime(name).unwrap();
1891 assert!(!bytes.is_empty(), "embedded runtime is empty: {name}");
1892 }
1893 assert!(
1894 std::str::from_utf8(embedded_runtime("runtime.mjs").unwrap())
1895 .unwrap()
1896 .contains(RUNTIME_INSTANCE_MARKER)
1897 );
1898 }
1899}