1use std::{
8 collections::BTreeMap,
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::{BuildAdapter, 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 RUNTIME_FILES: &[&str] = &[
33 "atomic.mjs",
34 "capability.mjs",
35 "jest.cjs",
36 "jest.config.mjs",
37 "jestReporter.mjs",
38 "launchSupervisor.mjs",
39 "nodeAssert.mjs",
40 "nodeAssertAdapter.mjs",
41 "nodeAssertStrict.mjs",
42 "nodeTest.mjs",
43 "playwright.mjs",
44 "playwrightReporter.mjs",
45 "provenance.mjs",
46 "register.mjs",
47 "resolve-loader.mjs",
48 "runnerEvidence.mjs",
49 "runtime.mjs",
50 "transport.mjs",
51 "vitest.mjs",
52 "vitestBrowser.mjs",
53 "vitestReporter.mjs",
54];
55static UNIQUE: AtomicU64 = AtomicU64::new(0);
56
57struct SetupAccounting {
70 files: AtomicU64,
71 bytes: AtomicU64,
72 create_ns: AtomicU64,
73 write_ns: AtomicU64,
74 rename_ns: AtomicU64,
75 directories: AtomicU64,
76 directory_retries: AtomicU64,
77 directory_ns: AtomicU64,
78 runtime_ns: AtomicU64,
79 config_ns: AtomicU64,
80 sources_ns: AtomicU64,
81 instrument_ns: AtomicU64,
82 assertion_ns: AtomicU64,
83 cache_ns: AtomicU64,
84}
85
86impl SetupAccounting {
87 const fn new() -> Self {
88 Self {
89 files: AtomicU64::new(0),
90 bytes: AtomicU64::new(0),
91 create_ns: AtomicU64::new(0),
92 write_ns: AtomicU64::new(0),
93 rename_ns: AtomicU64::new(0),
94 directories: AtomicU64::new(0),
95 directory_retries: AtomicU64::new(0),
96 directory_ns: AtomicU64::new(0),
97 runtime_ns: AtomicU64::new(0),
98 config_ns: AtomicU64::new(0),
99 sources_ns: AtomicU64::new(0),
100 instrument_ns: AtomicU64::new(0),
101 assertion_ns: AtomicU64::new(0),
102 cache_ns: AtomicU64::new(0),
103 }
104 }
105}
106
107static SETUP: SetupAccounting = SetupAccounting::new();
108
109fn account(counter: &AtomicU64, started: Instant) {
110 counter.fetch_add(started.elapsed().as_nanos() as u64, Ordering::Relaxed);
111}
112
113fn accounted_ms(counter: &AtomicU64) -> f64 {
114 counter.load(Ordering::Relaxed) as f64 / 1_000_000.0
115}
116
117fn counted(counter: &AtomicU64) -> u64 {
118 counter.load(Ordering::Relaxed)
119}
120
121fn timed<T>(counter: &AtomicU64, operation: impl FnOnce() -> T) -> T {
122 let started = Instant::now();
123 let value = operation();
124 account(counter, started);
125 value
126}
127
128pub fn setup_timing_detail() -> Option<String> {
131 if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() != Ok("1") {
132 return None;
133 }
134 Some(format!(
135 "setup detail runtime={:.1}ms configs={:.1}ms sources={:.1}ms (instrument={:.1}ms) \
136assertions={:.1}ms cache={:.1}ms | files={} bytes={} create={:.1}ms write={:.1}ms \
137rename={:.1}ms | directories={} retries={} directory-wait={:.1}ms",
138 accounted_ms(&SETUP.runtime_ns),
139 accounted_ms(&SETUP.config_ns),
140 accounted_ms(&SETUP.sources_ns),
141 accounted_ms(&SETUP.instrument_ns),
142 accounted_ms(&SETUP.assertion_ns),
143 accounted_ms(&SETUP.cache_ns),
144 counted(&SETUP.files),
145 counted(&SETUP.bytes),
146 accounted_ms(&SETUP.create_ns),
147 accounted_ms(&SETUP.write_ns),
148 accounted_ms(&SETUP.rename_ns),
149 counted(&SETUP.directories),
150 counted(&SETUP.directory_retries),
151 accounted_ms(&SETUP.directory_ns),
152 ))
153}
154
155#[derive(Debug)]
156pub enum JavascriptFrontendError {
157 Io {
158 path: PathBuf,
159 source: io::Error,
160 },
161 Instrument {
162 file: String,
163 source: CandidateError,
164 },
165 MissingRuntimeMarker,
166 Serialize(serde_json::Error),
167 UnsafeSourcePath(String),
168}
169
170impl std::fmt::Display for JavascriptFrontendError {
171 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 match self {
173 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
174 Self::Instrument { file, source } => {
175 write!(formatter, "failed to instrument {file}: {source:?}")
176 }
177 Self::MissingRuntimeMarker => write!(
178 formatter,
179 "generated Supercov runtime is missing its instance marker"
180 ),
181 Self::Serialize(error) => write!(formatter, "failed to serialize manifest: {error}"),
182 Self::UnsafeSourcePath(file) => write!(formatter, "unsafe source path: {file}"),
183 }
184 }
185}
186
187impl std::error::Error for JavascriptFrontendError {}
188
189fn io_error(path: &Path, source: io::Error) -> JavascriptFrontendError {
190 JavascriptFrontendError::Io {
191 path: path.to_owned(),
192 source,
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase", deny_unknown_fields)]
198pub struct JavascriptManifest {
199 pub decisions: Vec<CandidateDecision>,
200 pub points: Vec<CandidatePoint>,
201 pub branches: Vec<CandidateBranch>,
202 pub limitations: Vec<CandidateLimitation>,
203 pub scope: SourceScope,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct PreparedJavascriptFrontend {
208 pub manifest: JavascriptManifest,
209 pub manifest_path: PathBuf,
210 pub preload_path: PathBuf,
211 pub playwright_config_path: PathBuf,
212 pub vite_config_path: PathBuf,
213 pub vitest_config_path: PathBuf,
214 pub assertion_calls: usize,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase", deny_unknown_fields)]
219pub struct JavascriptFrontendCache {
220 schema_version: u32,
221 key: String,
222 assertion_calls: usize,
223 artifacts: Vec<JavascriptFrontendCacheArtifact>,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase", deny_unknown_fields)]
228struct JavascriptFrontendCacheArtifact {
229 path: String,
230 cache_file: String,
231 sha256: String,
232}
233
234fn safe_relative(path: &Path) -> bool {
235 path.components().next().is_some()
236 && path
237 .components()
238 .all(|component| matches!(component, std::path::Component::Normal(_)))
239}
240
241fn regular_file(workspace: &Path, relative: &str) -> bool {
242 safe_relative(Path::new(relative))
243 && fs::symlink_metadata(workspace.join(relative))
244 .is_ok_and(|metadata| metadata.file_type().is_file())
245}
246
247fn valid_cached_artifact(workspace: &Path, artifact: &JavascriptFrontendCacheArtifact) -> bool {
248 let expected_cache_file = format!("{FRONTEND_CACHE_DIRECTORY}/{}", artifact.sha256);
249 if !safe_relative(Path::new(&artifact.path))
250 || !safe_relative(Path::new(&artifact.cache_file))
251 || artifact.cache_file != expected_cache_file
252 || artifact.sha256.len() != 64
253 || !artifact
254 .sha256
255 .bytes()
256 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
257 {
258 return false;
259 }
260 let Ok(contents) = fs::read(workspace.join(&artifact.cache_file)) else {
261 return false;
262 };
263 format!("{:x}", Sha256::digest(&contents)) == artifact.sha256
264}
265
266pub fn read_javascript_frontend_cache(
267 workspace: &Path,
268 key: &str,
269) -> Option<JavascriptFrontendCache> {
270 let metadata: JavascriptFrontendCache =
271 serde_json::from_slice(&fs::read(workspace.join(FRONTEND_CACHE_FILE)).ok()?).ok()?;
272 if metadata.schema_version != FRONTEND_CACHE_SCHEMA_VERSION
273 || metadata.key != key
274 || metadata.artifacts.is_empty()
275 || metadata
276 .artifacts
277 .iter()
278 .any(|artifact| !valid_cached_artifact(workspace, artifact))
279 {
280 return None;
281 }
282 Some(metadata)
283}
284
285pub fn javascript_frontend_reuse_paths(cache: &JavascriptFrontendCache) -> Vec<PathBuf> {
286 let _ = cache;
287 vec![
288 PathBuf::from(FRONTEND_CACHE_FILE),
289 PathBuf::from(FRONTEND_CACHE_DIRECTORY),
290 ]
291}
292
293fn restore_cached_file(path: &Path, contents: &[u8]) -> Result<(), JavascriptFrontendError> {
294 let parent = path
295 .parent()
296 .ok_or_else(|| JavascriptFrontendError::UnsafeSourcePath(path.display().to_string()))?;
297 create_directory_all(parent)?;
298 let temporary = parent.join(format!(".supercov-restore-{}", unique()));
299 let result = (|| {
300 fs::write(&temporary, contents).map_err(|source| io_error(&temporary, source))?;
301 match fs::symlink_metadata(path) {
302 Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_symlink() => {
303 fs::remove_file(path).map_err(|source| io_error(path, source))?;
304 }
305 Ok(_) => {
306 return Err(JavascriptFrontendError::UnsafeSourcePath(
307 path.display().to_string(),
308 ));
309 }
310 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
311 Err(source) => return Err(io_error(path, source)),
312 }
313 fs::rename(&temporary, path).map_err(|source| io_error(path, source))
314 })();
315 if result.is_err() {
316 let _ = fs::remove_file(&temporary);
317 }
318 result
319}
320
321pub fn load_cached_javascript_frontend(
322 workspace: &Path,
323 cache: &JavascriptFrontendCache,
324) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
325 for artifact in &cache.artifacts {
326 let cache_path = workspace.join(&artifact.cache_file);
327 let contents = fs::read(&cache_path).map_err(|source| io_error(&cache_path, source))?;
328 if format!("{:x}", Sha256::digest(&contents)) != artifact.sha256 {
329 return Err(JavascriptFrontendError::UnsafeSourcePath(format!(
330 "corrupt frontend cache artifact {}",
331 artifact.cache_file
332 )));
333 }
334 restore_cached_file(&workspace.join(&artifact.path), &contents)?;
335 }
336 let generated = workspace.join(".supercov");
337 let manifest_path = generated.join("manifest.json");
338 let manifest = serde_json::from_slice(
339 &fs::read(&manifest_path).map_err(|source| io_error(&manifest_path, source))?,
340 )
341 .map_err(JavascriptFrontendError::Serialize)?;
342 Ok(PreparedJavascriptFrontend {
343 manifest,
344 manifest_path,
345 preload_path: generated.join("node_modules/register.mjs"),
346 playwright_config_path: generated.join("playwright.config.mjs"),
347 vite_config_path: generated.join("vite.config.mjs"),
348 vitest_config_path: generated.join("vitest.config.mjs"),
349 assertion_calls: cache.assertion_calls,
350 })
351}
352
353fn frontend_artifact_paths(workspace: &Path, project: &CoverageProject) -> Vec<String> {
354 let mut artifacts = vec![
355 ".supercov/node_modules/package.json".to_owned(),
356 ".supercov/node_modules/applicationRuntime.mjs".to_owned(),
357 ".supercov/node_modules/runtime.d.mts".to_owned(),
358 ".supercov/playwright.config.mjs".to_owned(),
359 ".supercov/vite.config.mjs".to_owned(),
360 ".supercov/vitest.config.mjs".to_owned(),
361 ".supercov/vite-transforms.json".to_owned(),
362 ".supercov/viteInstrumentation.mjs".to_owned(),
363 ".supercov/manifest.json".to_owned(),
364 ".supercov/statement-exclusions.json".to_owned(),
365 ".supercov/instrumentation-complete".to_owned(),
366 ];
367 artifacts.extend(
368 RUNTIME_FILES
369 .iter()
370 .map(|name| format!(".supercov/node_modules/{name}")),
371 );
372 artifacts.extend(project.source_files.iter().cloned());
377 artifacts.extend(
378 project
379 .source_scope
380 .entries
381 .iter()
382 .map(|entry| entry.file.clone()),
383 );
384 for root in &project.source_roots {
385 let host = if workspace.join(root).is_file() {
386 Path::new(root).parent().unwrap_or_else(|| Path::new(""))
387 } else {
388 Path::new(root)
389 };
390 for name in ["package.json", "runtime.mjs", "runtime.d.mts"] {
391 let path = host.join(".supercov/node_modules").join(name);
392 if let Some(path) = path.to_str() {
393 artifacts.push(path.replace('\\', "/"));
394 }
395 }
396 }
397 artifacts.sort();
398 artifacts.dedup();
399 artifacts.retain(|path| regular_file(workspace, path));
400 artifacts
401}
402
403fn write_javascript_frontend_cache(
404 workspace: &Path,
405 project: &CoverageProject,
406 key: &str,
407 assertion_calls: usize,
408) -> Result<(), JavascriptFrontendError> {
409 let cache_directory = workspace.join(FRONTEND_CACHE_DIRECTORY);
410 create_directory_all(&cache_directory)?;
411 let mut artifacts = Vec::new();
412 for path in frontend_artifact_paths(workspace, project) {
413 let contents = fs::read(workspace.join(&path))
414 .map_err(|source| io_error(&workspace.join(&path), source))?;
415 let sha256 = format!("{:x}", Sha256::digest(&contents));
416 let cache_file = format!("{FRONTEND_CACHE_DIRECTORY}/{sha256}");
417 let destination = workspace.join(&cache_file);
418 if !destination.is_file() {
419 atomic_write(&destination, &contents)?;
420 }
421 artifacts.push(JavascriptFrontendCacheArtifact {
422 path,
423 cache_file,
424 sha256,
425 });
426 }
427 let cache = JavascriptFrontendCache {
428 schema_version: FRONTEND_CACHE_SCHEMA_VERSION,
429 key: key.to_owned(),
430 assertion_calls,
431 artifacts,
432 };
433 let mut encoded =
434 serde_json::to_vec_pretty(&cache).map_err(JavascriptFrontendError::Serialize)?;
435 encoded.push(b'\n');
436 atomic_write(&workspace.join(FRONTEND_CACHE_FILE), &encoded)
437}
438
439#[derive(Debug, Serialize)]
440#[serde(rename_all = "camelCase")]
441struct ViteTransform {
442 source_sha256: String,
443 code: String,
444 map: Option<serde_json::Value>,
445}
446
447fn embedded_runtime(name: &str) -> Option<&'static [u8]> {
448 match name {
449 "atomic.mjs" => Some(include_bytes!("../runtime-assets/javascript/atomic.mjs")),
450 "capability.mjs" => Some(include_bytes!(
451 "../runtime-assets/javascript/capability.mjs"
452 )),
453 "jest.cjs" => Some(include_bytes!("../runtime-assets/javascript/jest.cjs")),
454 "jest.config.mjs" => Some(include_bytes!(
455 "../runtime-assets/javascript/jest.config.mjs"
456 )),
457 "jestReporter.mjs" => Some(include_bytes!(
458 "../runtime-assets/javascript/jestReporter.mjs"
459 )),
460 "launchSupervisor.mjs" => Some(include_bytes!(
461 "../runtime-assets/javascript/launchSupervisor.mjs"
462 )),
463 "nodeAssert.mjs" => Some(include_bytes!(
464 "../runtime-assets/javascript/nodeAssert.mjs"
465 )),
466 "nodeAssertAdapter.mjs" => Some(include_bytes!(
467 "../runtime-assets/javascript/nodeAssertAdapter.mjs"
468 )),
469 "nodeAssertStrict.mjs" => Some(include_bytes!(
470 "../runtime-assets/javascript/nodeAssertStrict.mjs"
471 )),
472 "nodeTest.mjs" => Some(include_bytes!("../runtime-assets/javascript/nodeTest.mjs")),
473 "playwright.mjs" => Some(include_bytes!(
474 "../runtime-assets/javascript/playwright.mjs"
475 )),
476 "playwrightReporter.mjs" => Some(include_bytes!(
477 "../runtime-assets/javascript/playwrightReporter.mjs"
478 )),
479 "provenance.mjs" => Some(include_bytes!(
480 "../runtime-assets/javascript/provenance.mjs"
481 )),
482 "register.mjs" => Some(include_bytes!("../runtime-assets/javascript/register.mjs")),
483 "resolve-loader.mjs" => Some(include_bytes!(
484 "../runtime-assets/javascript/resolve-loader.mjs"
485 )),
486 "runnerEvidence.mjs" => Some(include_bytes!(
487 "../runtime-assets/javascript/runnerEvidence.mjs"
488 )),
489 "runtime.mjs" => Some(include_bytes!("../runtime-assets/javascript/runtime.mjs")),
490 "transport.mjs" => Some(include_bytes!("../runtime-assets/javascript/transport.mjs")),
491 "vitest.mjs" => Some(include_bytes!("../runtime-assets/javascript/vitest.mjs")),
492 "vitestBrowser.mjs" => Some(include_bytes!(
493 "../runtime-assets/javascript/vitestBrowser.mjs"
494 )),
495 "vitestReporter.mjs" => Some(include_bytes!(
496 "../runtime-assets/javascript/vitestReporter.mjs"
497 )),
498 _ => None,
499 }
500}
501
502fn unique() -> String {
503 let nanos = SystemTime::now()
504 .duration_since(UNIX_EPOCH)
505 .unwrap_or_default()
506 .as_nanos();
507 format!(
508 "{}-{nanos}-{}",
509 std::process::id(),
510 UNIQUE.fetch_add(1, Ordering::Relaxed)
511 )
512}
513
514#[cfg(not(windows))]
515fn create_directory_all(path: &Path) -> Result<(), JavascriptFrontendError> {
516 SETUP.directories.fetch_add(1, Ordering::Relaxed);
517 timed(&SETUP.directory_ns, || {
518 fs::create_dir_all(path).map_err(|source| io_error(path, source))
519 })
520}
521
522#[cfg(windows)]
523fn create_directory_all(path: &Path) -> Result<(), JavascriptFrontendError> {
524 const ATTEMPTS: usize = 16;
534 let started = std::time::Instant::now();
535 SETUP.directories.fetch_add(1, Ordering::Relaxed);
536 let mut delay = std::time::Duration::from_millis(20);
537 for attempt in 0..ATTEMPTS {
538 match fs::create_dir_all(path) {
539 Ok(()) => {
540 account(&SETUP.directory_ns, started);
541 return Ok(());
542 }
543 Err(source)
544 if source.kind() == io::ErrorKind::PermissionDenied && attempt + 1 < ATTEMPTS =>
545 {
546 SETUP.directory_retries.fetch_add(1, Ordering::Relaxed);
547 std::thread::sleep(delay);
548 delay = (delay * 2).min(std::time::Duration::from_millis(500));
549 }
550 Err(source) => {
551 account(&SETUP.directory_ns, started);
552 let detail = format!(
553 "{source} (after {} attempt(s) over {:?}; ancestors: {})",
554 attempt + 1,
555 started.elapsed(),
556 describe_ancestors(path)
557 );
558 return Err(io_error(path, io::Error::new(source.kind(), detail)));
559 }
560 }
561 }
562 unreachable!("the final directory-creation attempt always returns")
563}
564
565#[cfg_attr(not(windows), allow(dead_code))]
569fn describe_ancestors(path: &Path) -> String {
570 let mut current = PathBuf::new();
571 let mut parts = Vec::new();
572 for component in path.components() {
573 current.push(component.as_os_str());
574 let state = match fs::symlink_metadata(¤t) {
575 Ok(metadata) if metadata.file_type().is_symlink() => "link",
576 Ok(metadata) if metadata.file_type().is_dir() => "dir",
577 Ok(_) => "file",
578 Err(error)
581 if matches!(
582 error.kind(),
583 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
584 ) =>
585 {
586 "missing"
587 }
588 Err(error) => return format!("{} -> {error}", current.display()),
589 };
590 parts.push(format!("{}={state}", current.display()));
591 }
592 parts.join("; ")
593}
594
595fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), JavascriptFrontendError> {
596 let parent = path
597 .parent()
598 .ok_or_else(|| JavascriptFrontendError::UnsafeSourcePath(path.display().to_string()))?;
599 let temporary = parent.join(format!(".supercov-write-{}", unique()));
600 let result = (|| {
601 let open = || {
602 OpenOptions::new()
603 .write(true)
604 .create_new(true)
605 .open(&temporary)
606 };
607 let opened = timed(&SETUP.create_ns, open);
608 let mut output = match opened {
609 Ok(output) => output,
610 Err(source) if source.kind() == io::ErrorKind::NotFound => {
611 create_directory_all(parent)?;
612 timed(&SETUP.create_ns, open).map_err(|source| io_error(&temporary, source))?
613 }
614 Err(source) => return Err(io_error(&temporary, source)),
615 };
616 timed(&SETUP.write_ns, || output.write_all(contents))
617 .map_err(|source| io_error(&temporary, source))?;
618 timed(&SETUP.rename_ns, || fs::rename(&temporary, path))
642 .map_err(|source| io_error(path, source))?;
643 SETUP.files.fetch_add(1, Ordering::Relaxed);
644 SETUP
645 .bytes
646 .fetch_add(contents.len() as u64, Ordering::Relaxed);
647 Ok(())
648 })();
649 if result.is_err() {
650 let _ = fs::remove_file(&temporary);
651 }
652 result
653}
654
655fn checked_source_path(workspace: &Path, file: &str) -> Result<PathBuf, JavascriptFrontendError> {
656 let relative = Path::new(file);
657 if relative.is_absolute()
658 || relative
659 .components()
660 .any(|component| !matches!(component, std::path::Component::Normal(_)))
661 {
662 return Err(JavascriptFrontendError::UnsafeSourcePath(file.to_owned()));
663 }
664 Ok(workspace.join(relative))
665}
666
667fn runtime_specifier(file: &str, name: &str) -> Result<String, JavascriptFrontendError> {
668 let relative = Path::new(file);
669 if relative.is_absolute()
670 || relative
671 .components()
672 .any(|component| !matches!(component, std::path::Component::Normal(_)))
673 {
674 return Err(JavascriptFrontendError::UnsafeSourcePath(file.to_owned()));
675 }
676 let depth = relative
677 .parent()
678 .map_or(0, |parent| parent.components().count());
679 Ok(if depth == 0 {
680 format!("./.supercov/node_modules/{name}")
681 } else {
682 format!("{}.supercov/node_modules/{name}", "../".repeat(depth))
683 })
684}
685
686const GENERATED_SOURCE_BANNER: &str =
693 "/* eslint-disable */\n// @ts-nocheck -- generated coverage workspace only\n";
694
695fn generated_source_banner(code: &str) -> String {
701 let Some(rest) = code.strip_prefix("#!") else {
702 return format!("{GENERATED_SOURCE_BANNER}{code}");
703 };
704 let (line, remainder) = rest.split_once('\n').unwrap_or((rest, ""));
705 format!("#!{line}\n{GENERATED_SOURCE_BANNER}{remainder}")
706}
707
708fn isolate_runtime(source: &str, collector_id: &str) -> Result<String, JavascriptFrontendError> {
709 let source = format!("/* eslint-disable */\n{source}");
713 let source = source.as_str();
714 let double = format!("runtimeInstanceToken = \"{RUNTIME_INSTANCE_MARKER}\"");
715 let single = format!("runtimeInstanceToken = '{RUNTIME_INSTANCE_MARKER}'");
716 if let Some(index) = source.find(&double) {
717 let mut isolated = source.to_owned();
718 isolated.replace_range(
719 index..index + double.len(),
720 &format!("runtimeInstanceToken = \"{collector_id}\""),
721 );
722 return Ok(isolated);
723 }
724 if let Some(index) = source.find(&single) {
725 let mut isolated = source.to_owned();
726 isolated.replace_range(
727 index..index + single.len(),
728 &format!("runtimeInstanceToken = '{collector_id}'"),
729 );
730 return Ok(isolated);
731 }
732 Err(JavascriptFrontendError::MissingRuntimeMarker)
733}
734
735fn inline_instrumentation_map(
745 code: &str,
746 map: Option<&serde_json::Value>,
747 original_path: &Path,
748 original_source: &str,
749) -> Option<String> {
750 let map = map?.clone();
751 let mut map = map;
752 let object = map.as_object_mut()?;
753 let banner_lines = code
754 .lines()
755 .skip(usize::from(code.starts_with("#!")))
756 .take_while(|line| {
757 line.starts_with("/* eslint-disable */") || line.starts_with("// @ts-nocheck")
758 })
759 .count();
760 if banner_lines > 0 {
761 let mappings = object.get("mappings")?.as_str()?.to_owned();
762 object.insert(
763 "mappings".into(),
764 serde_json::Value::String(format!("{}{}", ";".repeat(banner_lines), mappings)),
765 );
766 }
767 object.insert(
768 "sources".into(),
769 serde_json::json!([original_path.display().to_string()]),
770 );
771 object.insert(
772 "sourcesContent".into(),
773 serde_json::json!([original_source]),
774 );
775 let payload = serde_json::to_string(&map).ok()?;
776 use base64::Engine as _;
777 let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
778 Some(format!(
779 "{code}\n//# sourceMappingURL=data:application/json;base64,{encoded}\n"
780 ))
781}
782
783fn strip_source_map_reference(mut bytes: Vec<u8>) -> Vec<u8> {
787 const MARKER: &[u8] = b"\n//# sourceMappingURL=";
788 if let Some(index) = bytes
789 .windows(MARKER.len())
790 .rposition(|window| window == MARKER)
791 {
792 let suffix = &bytes[index + MARKER.len()..];
793 let suffix = suffix.strip_suffix(b"\n").unwrap_or(suffix);
794 let suffix = suffix.strip_suffix(b"\r").unwrap_or(suffix);
795 if !suffix.contains(&b'\n') && !suffix.contains(&b'\r') {
796 bytes.truncate(index + 1);
797 }
798 }
799 bytes
800}
801
802fn copy_runtime(generated: &Path, collector_id: &str) -> Result<(), JavascriptFrontendError> {
803 create_directory_all(generated)?;
804 atomic_write(
805 &generated.join("package.json"),
806 b"{\"private\":true,\"type\":\"module\"}\n",
807 )?;
808 for name in RUNTIME_FILES {
809 let destination = generated.join(name);
810 let source_path = PathBuf::from(format!("embedded:{name}"));
811 let bytes = embedded_runtime(name)
812 .expect("every declared runtime file must have an embedded asset")
813 .to_vec();
814 let bytes = strip_source_map_reference(bytes);
815 if *name == "runtime.mjs" {
816 let text = String::from_utf8(bytes).map_err(|source| {
817 io_error(
818 &source_path,
819 io::Error::new(io::ErrorKind::InvalidData, source),
820 )
821 })?;
822 atomic_write(
823 &destination,
824 isolate_runtime(&text, collector_id)?.as_bytes(),
825 )?;
826 atomic_write(
827 &generated.join("applicationRuntime.mjs"),
828 isolate_runtime(&text, &format!("{collector_id}-application"))?.as_bytes(),
829 )?;
830 } else {
831 atomic_write(&destination, &bytes)?;
832 }
833 }
834 atomic_write(
835 &generated.join("runtime.d.mts"),
836 b"/* eslint-disable */\n\
842export declare function coverageHit(...args: any[]): any;\n\
843export declare function selectionBegin(...args: any[]): any;\n\
844export declare function selectionRight(...args: any[]): any;\n\
845export declare function selectionEnd(...args: any[]): any;\n\
846export declare function optionalSelect(...args: any[]): any;\n\
847export declare function optionalCallBegin(...args: any[]): any;\n\
848export declare function optionalCallReached(...args: any[]): any;\n\
849export declare function optionalCallContinued(...args: any[]): any;\n\
850export declare function optionalCallEnd(...args: any[]): any;\n\
851export declare function defaultSelected(...args: any[]): any;\n\
852export declare function defaultEntered(...args: any[]): any;\n\
853export declare function tryBegin(...args: any[]): any;\n\
854export declare function tryCatch(...args: any[]): any;\n\
855export declare function tryEnd(...args: any[]): any;\n\
856export declare function loopBegin(...args: any[]): any;\n\
857export declare function loopEntered(...args: any[]): any;\n\
858export declare function loopEnd(...args: any[]): any;\n\
859export declare function mcdcBegin(...args: any[]): any;\n\
860export declare function mcdcCondition(...args: any[]): any;\n\
861export declare function mcdcEnd(...args: any[]): any;\n\
862export declare function registerProbeV2(...args: any[]): any;\n\
863export declare function coverageHitV2(...args: any[]): any;\n\
864export declare function mcdcEndV2(...args: any[]): any;\n",
865 )?;
866 Ok(())
867}
868
869fn generic_runtime_binding(
870 workspace: &Path,
871 project: &CoverageProject,
872 source_path: &Path,
873 generated: &Path,
874) -> Result<String, JavascriptFrontendError> {
875 let mut hosts = project
876 .source_roots
877 .iter()
878 .filter_map(|root| {
879 let candidate = workspace.join(root);
880 if candidate.is_dir() && source_path.strip_prefix(&candidate).is_ok() {
881 Some(candidate)
882 } else if candidate.is_file() && candidate == source_path {
883 candidate.parent().map(Path::to_owned)
884 } else {
885 None
886 }
887 })
888 .collect::<Vec<_>>();
889 hosts.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
890 let host = hosts
891 .into_iter()
892 .next()
893 .unwrap_or_else(|| workspace.to_owned());
894 let runtime_directory = host.join(".supercov/node_modules");
895 fs::create_dir_all(&runtime_directory)
896 .map_err(|source| io_error(&runtime_directory, source))?;
897 atomic_write(
900 &runtime_directory.join("package.json"),
901 b"{\"private\":true,\"type\":\"module\"}\n",
902 )?;
903 for name in ["runtime.mjs", "runtime.d.mts"] {
904 let source = generated.join("node_modules").join(name);
905 let destination = runtime_directory.join(name);
906 let contents = fs::read(&source).map_err(|error| io_error(&source, error))?;
907 atomic_write(&destination, &contents)?;
908 }
909 let parent = source_path.parent().ok_or_else(|| {
910 JavascriptFrontendError::UnsafeSourcePath(source_path.display().to_string())
911 })?;
912 let local = parent.strip_prefix(&host).map_err(|_| {
913 JavascriptFrontendError::UnsafeSourcePath(source_path.display().to_string())
914 })?;
915 let depth = local.components().count();
916 Ok(if depth == 0 {
917 "./.supercov/node_modules/runtime.mjs".into()
918 } else {
919 format!("{}.supercov/node_modules/runtime.mjs", "../".repeat(depth))
920 })
921}
922
923fn limitation_from_source(value: &SourceLimitation) -> CandidateLimitation {
924 CandidateLimitation {
925 id: value.id.clone(),
926 kind: value.kind.clone(),
927 file: value.file.clone(),
928 line: value.line,
929 column: value.column,
930 source: value.source.clone(),
931 reason: value.reason.clone(),
932 }
933}
934
935fn relocated_project_file(
936 workspace: &Path,
937 project: &CoverageProject,
938 source: Option<&PathBuf>,
939) -> Option<PathBuf> {
940 let source = source?;
941 let relative = source.strip_prefix(&project.root).ok()?;
942 Some(workspace.join(relative))
943}
944
945fn write_vitest_config(
946 workspace: &Path,
947 project: &CoverageProject,
948 generated: &Path,
949) -> Result<PathBuf, JavascriptFrontendError> {
950 let path = generated.join("vitest.config.mjs");
951 let original = relocated_project_file(workspace, project, project.vitest_config.as_ref())
952 .map(|path| path.display().to_string());
953 let original = serde_json::to_string(&original).map_err(JavascriptFrontendError::Serialize)?;
954 let source = format!(
955 "import {{ createRequire }} from 'node:module';\n\
956 import {{ pathToFileURL }} from 'node:url';\n\
957 // pnpm's strict layout does not hoist vite to the project root: it\n\
958 // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
959 // resolved from this generated file fails. Vitest depends on vite, so\n\
960 // fall back to resolving it through vitest's own tree rather than\n\
961 // requiring the project to hoist anything.\n\
962 const supercovRequire = createRequire(import.meta.url);\n\
963 const supercovLoadVite = async () => {{\n\
964 try {{\n\
965 return await import('vite');\n\
966 }} catch (error) {{\n\
967 let entry;\n\
968 try {{\n\
969 entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
970 }} catch {{\n\
971 throw error;\n\
972 }}\n\
973 return await import(pathToFileURL(entry).href);\n\
974 }}\n\
975 }};\n\
976 const viteNamespace = await supercovLoadVite();\n\
977 import {{ resolve }} from 'node:path';\n\
978 import SupercovVitestReporter from './node_modules/vitestReporter.mjs';\n\
979 import {{ supercovBrowserCommands, supercovViteInstrumentation }} from './viteInstrumentation.mjs';\n\
980 const vite = viteNamespace.default ?? viteNamespace;\n\
981 const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
982 const discoveredConfig = {original};\n\
983 // Browser Mode runs the test file in the browser, so the node setup --\n\
984 // which reaches node:fs to write evidence -- cannot load there. Vite\n\
985 // externalises it and the suite fails before collecting a test.\n\
986 const supercovBrowserMode = (config) => Boolean(config?.test?.browser?.enabled);\n\
987 const supercovSetupFile = (config) =>\n\
988 supercovBrowserMode(config)\n\
989 ? '.supercov/node_modules/vitestBrowser.mjs'\n\
990 : '.supercov/node_modules/vitest.mjs';\n\
991 // Evidence leaves the browser over Vitest's own command channel, so\n\
992 // the command is registered only for the mode that uses it.\n\
993 const supercovBrowserTest = (config) =>\n\
994 supercovBrowserMode(config)\n\
995 ? {{ browser: {{ commands: supercovBrowserCommands(process.cwd()) }} }}\n\
996 : {{}};\n\
997 export default async function supercovVitestConfig(env) {{\n\
998 const originalPath = process.env.SUPERCOV_ORIGINAL_VITEST_CONFIG || discoveredConfig;\n\
999 const loaded = originalPath ? await loadConfigFromFile(env, originalPath, process.cwd()) : undefined;\n\
1000 const config = mergeConfig(loaded?.config ?? {{}}, {{\n\
1001 cacheDir: resolve(process.cwd(), '.supercov/vitest-cache'),\n\
1002 plugins: [supercovViteInstrumentation(process.cwd())],\n\
1003 test: {{ setupFiles: [resolve(process.cwd(), supercovSetupFile(loaded?.config))], maxConcurrency: 1, ...supercovBrowserTest(loaded?.config) }},\n\
1004 }});\n\
1005 const configuredReporters = loaded?.config?.test?.reporters;\n\
1006 config.test ??= {{}};\n\
1007 config.test.reporters = configuredReporters\n\
1008 ? [...(Array.isArray(configuredReporters) ? configuredReporters : [configuredReporters]), new SupercovVitestReporter()]\n\
1009 : ['default', new SupercovVitestReporter()];\n\
1010 return config;\n\
1011 }}\n"
1012 );
1013 atomic_write(&path, source.as_bytes())?;
1014 Ok(path)
1015}
1016
1017fn configure_playwright_runtime(
1018 generated: &Path,
1019 project: &CoverageProject,
1020) -> Result<(), JavascriptFrontendError> {
1021 let adapter_path = generated.join("playwright.mjs");
1022 let mut adapter =
1023 fs::read_to_string(&adapter_path).map_err(|source| io_error(&adapter_path, source))?;
1024 adapter = adapter
1025 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module)
1026 .replace(
1027 "__SUPERCOV_PLAYWRIGHT_TEST_EXPORT__",
1028 &project.playwright_test_export,
1029 )
1030 .replace(
1034 "__SUPERCOV_PHASE_TIMING__",
1035 if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() == Ok("1") {
1036 "1"
1037 } else {
1038 "0"
1039 },
1040 );
1041 if project.playwright_module != "@playwright/test" {
1042 let facade = serde_json::to_string(&project.playwright_module)
1049 .expect("serializing a module specifier cannot fail");
1050 adapter = adapter.replace(
1051 "export * from \"@playwright/test\";",
1052 &format!("export * from {facade};"),
1053 );
1054 }
1055 let mut exports = Vec::new();
1056 if project.playwright_test_export != "test" {
1057 exports.push(format!(
1058 "export {{ instrumentedTest as {} }};",
1059 project.playwright_test_export
1060 ));
1061 }
1062 exports.extend(
1063 project
1064 .playwright_exports
1065 .iter()
1066 .filter(|name| {
1067 name.as_str() != "test"
1068 && name.as_str() != "expect"
1069 && *name != &project.playwright_test_export
1070 })
1071 .map(|name| {
1072 let encoded = serde_json::to_string(name)
1073 .expect("serializing a JavaScript export name cannot fail");
1074 format!("export const {name} = __supercovAdapterExport(adapter[{encoded}]);")
1075 }),
1076 );
1077 adapter = adapter.replace("/*__SUPERCOV_ADAPTER_EXPORTS__*/", &exports.join("\n"));
1078 atomic_write(&adapter_path, adapter.as_bytes())?;
1079
1080 let loader_path = generated.join("resolve-loader.mjs");
1081 let loader = fs::read_to_string(&loader_path)
1082 .map_err(|source| io_error(&loader_path, source))?
1083 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module);
1084 atomic_write(&loader_path, loader.as_bytes())
1085}
1086
1087fn write_playwright_config(
1088 workspace: &Path,
1089 project: &CoverageProject,
1090 generated: &Path,
1091) -> Result<PathBuf, JavascriptFrontendError> {
1092 let path = generated.join("playwright.config.mjs");
1093 let original = relocated_project_file(workspace, project, project.playwright_config.as_ref());
1094 let original_import = if let Some(original) = &original {
1095 let relative = original
1096 .strip_prefix(workspace)
1097 .map_err(|_| JavascriptFrontendError::UnsafeSourcePath(original.display().to_string()))?
1098 .to_string_lossy()
1099 .replace('\\', "/");
1100 let specifier = serde_json::to_string(&format!("../{relative}"))
1101 .map_err(JavascriptFrontendError::Serialize)?;
1102 format!("import original from {specifier};\n")
1103 } else {
1104 "const original = {};\n".into()
1105 };
1106 let source = format!(
1107 "import './node_modules/register.mjs';\n\
1108 import {{ dirname, isAbsolute, relative, resolve }} from 'node:path';\n\
1109 import {{ fileURLToPath }} from 'node:url';\n\
1110 {original_import}\
1111 const resolvedValue = typeof original === 'function' ? await original({{ command: 'test', mode: 'test' }}) : original;\n\
1112 const resolved = resolvedValue ?? {{}};\n\
1113 const runtimeProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');\n\
1114 const originalDirectory = {};
1115 const sourceProjectRoot = process.env.SUPERCOV_SOURCE_PROJECT_ROOT;\n\
1116 const runtimePath = value => {{\n\
1117 if (!value) return value;\n\
1118 const absolute = isAbsolute(value) ? value : resolve(originalDirectory, value);\n\
1119 const local = relative(runtimeProjectRoot, absolute);\n\
1120 if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1121 if (sourceProjectRoot) {{\n\
1122 const sourceLocal = relative(sourceProjectRoot, absolute);\n\
1123 if (sourceLocal === '' || (!sourceLocal.startsWith('..') && !isAbsolute(sourceLocal))) return resolve(runtimeProjectRoot, sourceLocal);\n\
1124 }}\n\
1125 throw new Error('Supercov refuses a Playwright output/cwd outside the isolated project: ' + absolute);\n\
1126 }};\n\
1127 const normalizeWebServer = server => server ? ({{ ...server, cwd: runtimePath(server.cwd ?? originalDirectory) }}) : server;\n\
1128 const normalized = {{ ...resolved,\n\
1129 testDir: runtimePath(resolved.testDir),\n\
1130 outputDir: runtimePath(resolved.outputDir),\n\
1131 snapshotDir: runtimePath(resolved.snapshotDir),\n\
1132 projects: resolved.projects?.map(project => ({{ ...project, testDir: runtimePath(project.testDir), outputDir: runtimePath(project.outputDir), snapshotDir: runtimePath(project.snapshotDir) }})),\n\
1133 webServer: Array.isArray(resolved.webServer) ? resolved.webServer.map(normalizeWebServer) : normalizeWebServer(resolved.webServer),\n\
1134 }};\n\
1135 const configuredReporters = normalized.reporter;\n\
1136 const reporters = configuredReporters\n\
1137 ? (typeof configuredReporters === 'string' ? [[configuredReporters]] : (Array.isArray(configuredReporters[0]) ? configuredReporters : [configuredReporters]))\n\
1138 : [['list']];\n\
1139 const coverageReporter = resolve(runtimeProjectRoot, '.supercov/node_modules/playwrightReporter.mjs');\n\
1140 export default {{ ...normalized, reporter: [...reporters, [coverageReporter]] }};\n",
1141 serde_json::to_string(
1142 &original
1143 .as_ref()
1144 .and_then(|path| path.parent())
1145 .unwrap_or(workspace)
1146 .display()
1147 .to_string()
1148 )
1149 .map_err(JavascriptFrontendError::Serialize)?
1150 );
1151 atomic_write(&path, source.as_bytes())?;
1152 Ok(path)
1153}
1154
1155fn write_vite_config(
1156 workspace: &Path,
1157 generated: &Path,
1158) -> Result<PathBuf, JavascriptFrontendError> {
1159 let path = generated.join("vite.config.mjs");
1160 let workspace = serde_json::to_string(&workspace.display().to_string())
1161 .map_err(JavascriptFrontendError::Serialize)?;
1162 let source = format!(
1163 "import {{ createRequire }} from 'node:module';\n\
1164 import {{ pathToFileURL }} from 'node:url';\n\
1165 // pnpm's strict layout does not hoist vite to the project root: it\n\
1166 // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
1167 // resolved from this generated file fails. Vitest depends on vite, so\n\
1168 // fall back to resolving it through vitest's own tree rather than\n\
1169 // requiring the project to hoist anything.\n\
1170 const supercovRequire = createRequire(import.meta.url);\n\
1171 const supercovLoadVite = async () => {{\n\
1172 try {{\n\
1173 return await import('vite');\n\
1174 }} catch (error) {{\n\
1175 let entry;\n\
1176 try {{\n\
1177 entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
1178 }} catch {{\n\
1179 throw error;\n\
1180 }}\n\
1181 return await import(pathToFileURL(entry).href);\n\
1182 }}\n\
1183 }};\n\
1184 const viteNamespace = await supercovLoadVite();\n\
1185 import {{ isAbsolute, relative, resolve }} from 'node:path';\n\
1186 import {{ supercovViteInstrumentation }} from './viteInstrumentation.mjs';\n\
1187 const vite = viteNamespace.default ?? viteNamespace;\n\
1188 const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
1189 export default async function supercovViteConfig(env) {{\n\
1190 const isolatedRoot = {workspace};\n\
1191 const loaded = await loadConfigFromFile(env, undefined, isolatedRoot);\n\
1192 const config = loaded?.config ?? {{}};\n\
1193 const relocate = (value, label) => {{\n\
1194 const absolute = isAbsolute(value) ? value : resolve(isolatedRoot, value);\n\
1195 const local = relative(isolatedRoot, absolute);\n\
1196 if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1197 throw new Error('Supercov refuses ' + label + ' outside the isolated project: ' + absolute);\n\
1198 }};\n\
1199 const relocateOutput = output => output ? ({{ ...output, dir: output.dir ? relocate(output.dir, 'Rollup output') : output.dir, file: output.file ? relocate(output.file, 'Rollup output') : output.file }}) : output;\n\
1200 const rollupOutput = config.build?.rollupOptions?.output;\n\
1201 const safe = {{ ...config,\n\
1202 logLevel: ['1', 'true', 'yes'].includes(process.env.SUPERCOV_VERBOSE ?? process.env.SUPERCOV_DEBUG ?? '') ? config.logLevel : 'error',\n\
1203 cacheDir: resolve(isolatedRoot, '.supercov/vite-cache'),\n\
1204 build: {{ ...config.build, outDir: relocate(config.build?.outDir ?? 'dist', 'Vite build output'), rollupOptions: {{ ...config.build?.rollupOptions, output: Array.isArray(rollupOutput) ? rollupOutput.map(relocateOutput) : relocateOutput(rollupOutput) }} }},\n\
1205 }};\n\
1206 return mergeConfig(safe, {{ plugins: [supercovViteInstrumentation(isolatedRoot)] }});\n\
1207 }}\n"
1208 );
1209 atomic_write(&path, source.as_bytes())?;
1210 Ok(path)
1211}
1212
1213fn write_vite_transforms(
1214 generated: &Path,
1215 transforms: &BTreeMap<String, ViteTransform>,
1216) -> Result<(), JavascriptFrontendError> {
1217 let mut payload = serde_json::to_vec(transforms).map_err(JavascriptFrontendError::Serialize)?;
1218 payload.push(b'\n');
1219 atomic_write(&generated.join("vite-transforms.json"), &payload)?;
1220 let adapter = "import { createHash } from 'node:crypto';\n\
1221import { mkdirSync, readFileSync } from 'node:fs';\n\
1222import { relative, resolve, sep } from 'node:path';\n\
1223import { atomicWriteFileSync } from './node_modules/atomic.mjs';\n\
1224import { inferTestProvenance } from './node_modules/provenance.mjs';\n\
1225const transforms = JSON.parse(readFileSync(new URL('./vite-transforms.json', import.meta.url), 'utf8'));\n\
1226const sha256 = value => createHash('sha256').update(value).digest('hex');\n\
1227// Browser Mode runs the test file in the browser, where none of this is\n\
1228// reachable: no node:fs to write evidence, no project root to relativise a\n\
1229// path against, no env to read. Vitest's own browser command channel carries\n\
1230// what the browser observed to node, which completes the same payload the node\n\
1231// setup writes -- so one contract keeps one implementation. The command is\n\
1232// awaited as part of the test, and node learns the test file from Vitest\n\
1233// rather than from the browser realm, which cannot be trusted to name it.\n\
1234const supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/;\n\
1235export function supercovBrowserCommands(root) {\n\
1236 return {\n\
1237 __supercovEvidence(context, payload, suffix) {\n\
1238 const evidenceDirectory = process.env['SUPERCOV_EVIDENCE_DIR'];\n\
1239 if (!evidenceDirectory) return;\n\
1240 // A suffix names one directory under the evidence directory. Anything\n\
1241 // else is refused rather than resolved, so a malformed one cannot send\n\
1242 // the write outside the directory it is supposed to stay in.\n\
1243 if (typeof suffix !== 'string' || !supercovEvidenceSuffix.test(suffix))\n\
1244 throw new Error('Supercov refused an unusable evidence suffix: ' + suffix);\n\
1245 const absolute = context.testPath;\n\
1246 const testFile = absolute ? relative(root, absolute).split(sep).join('/') : undefined;\n\
1247 const projectName = payload.projectName ?? context.project?.name;\n\
1248 delete payload.projectName;\n\
1249 // The browser has no environment to read, so the run identity is stamped\n\
1250 // here rather than sent from a realm that cannot know it.\n\
1251 const runId = process.env['SUPERCOV_RUN_ID'];\n\
1252 if (runId && payload.scope) payload.scope.runId = runId;\n\
1253 const complete = Object.assign({}, payload, testFile ? { testFile } : {}, {\n\
1254 provenance: inferTestProvenance({\n\
1255 runner: 'vitest',\n\
1256 file: testFile,\n\
1257 project: projectName,\n\
1258 explicitKind: process.env['SUPERCOV_TEST_KIND'],\n\
1259 }),\n\
1260 });\n\
1261 const directory = resolve(root, evidenceDirectory, suffix);\n\
1262 mkdirSync(directory, { recursive: true });\n\
1263 atomicWriteFileSync(resolve(directory, 'mcdc.json'), JSON.stringify(complete) + '\\n');\n\
1264 },\n\
1265 };\n\
1266}\n\
1267export function supercovViteInstrumentation(root) {\n\
1268 const runtimePath = resolve(root, '.supercov/node_modules/applicationRuntime.mjs');\n\
1269 return {\n\
1270 name: 'supercov-rust-instrumentation',\n\
1271 enforce: 'pre',\n\
1272 resolveId(id) { return id === 'virtual:supercov-runtime' ? runtimePath : null; },\n\
1273 transform(code, rawId) {\n\
1274 const id = rawId.split('?')[0] ?? rawId;\n\
1275 const local = relative(root, id).split(sep).join('/');\n\
1276 const transformed = transforms[local];\n\
1277 if (!transformed) return null;\n\
1278 if (sha256(code) !== transformed.sourceSha256)\n\
1279 throw new Error('Supercov source changed before Rust instrumentation: ' + local);\n\
1280 return { code: transformed.code, map: transformed.map ?? null };\n\
1281 },\n\
1282 };\n\
1283}\n";
1284 atomic_write(
1285 &generated.join("viteInstrumentation.mjs"),
1286 adapter.as_bytes(),
1287 )
1288}
1289
1290pub fn prepare_javascript_frontend(
1293 workspace: &Path,
1294 project: &CoverageProject,
1295 collector_id: &str,
1296 cache_key: &str,
1297 command: &[String],
1298) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
1299 let generated = workspace.join(".supercov");
1300 let runtime_directory = generated.join("node_modules");
1306 timed(&SETUP.runtime_ns, || {
1307 copy_runtime(&runtime_directory, collector_id)
1308 })?;
1309 let configuration_started = Instant::now();
1310 configure_playwright_runtime(&runtime_directory, project)?;
1311 let playwright_config_path = write_playwright_config(workspace, project, &generated)?;
1312 let vite_config_path = write_vite_config(workspace, &generated)?;
1313 let vitest_config_path = write_vitest_config(workspace, project, &generated)?;
1314 account(&SETUP.config_ns, configuration_started);
1315
1316 let mut exclusions = Vec::new();
1317 let mut decisions = BTreeMap::new();
1318 let mut points = BTreeMap::new();
1319 let mut branches = BTreeMap::new();
1320 let mut limitations = BTreeMap::new();
1321 let mut vite_transforms = BTreeMap::new();
1322 for limitation in &project.source_limitations {
1323 limitations.insert(limitation.id.clone(), limitation_from_source(limitation));
1324 }
1325
1326 let sources_started = Instant::now();
1327 for file in &project.source_files {
1328 let path = checked_source_path(workspace, file)?;
1329 let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
1330 let capability_wrapper = runtime_specifier(file, "capability.mjs")?;
1331 let elide = crate::typescript_imports::elides_type_imports(
1332 workspace,
1333 file,
1334 command,
1335 &project.build_command,
1336 );
1337 let mut output = timed(&SETUP.instrument_ns, || {
1338 instrument_with_import_policy(
1339 &source,
1340 file,
1341 &capability_wrapper,
1342 project.build_adapter == BuildAdapter::Direct,
1343 elide,
1344 )
1345 })
1346 .map_err(|source| JavascriptFrontendError::Instrument {
1347 file: file.clone(),
1348 source,
1349 })?;
1350 if project.build_adapter == BuildAdapter::Generic {
1351 let runtime = generic_runtime_binding(workspace, project, &path, &generated)?;
1352 output.code = output.code.replace("virtual:supercov-runtime", &runtime);
1353 }
1354 if project.build_adapter != BuildAdapter::Vite
1361 && matches!(
1362 path.extension().and_then(|value| value.to_str()),
1363 Some("ts" | "tsx" | "mts" | "cts")
1364 )
1365 {
1366 output.code = generated_source_banner(&output.code);
1367 }
1368 if project.build_adapter == BuildAdapter::Vite {
1369 vite_transforms.insert(
1370 file.clone(),
1371 ViteTransform {
1372 source_sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
1373 code: output.code.clone(),
1374 map: output.map.clone(),
1375 },
1376 );
1377 } else {
1378 let code = match inline_instrumentation_map(
1385 &output.code,
1386 output.map.as_ref(),
1387 &project.root.join(file),
1388 &source,
1389 ) {
1390 Some(code) => code,
1391 None => output.code.clone(),
1392 };
1393 atomic_write(&path, code.as_bytes())?;
1394 }
1395 exclusions.extend(output.excluded_statements);
1396 for value in output.decisions {
1397 decisions.insert(value.id.clone(), value);
1398 }
1399 for value in output.points {
1400 points.insert(value.id.clone(), value);
1401 }
1402 for value in output.branches {
1403 branches.insert(value.id.clone(), value);
1404 }
1405 for value in output.coverage_limitations {
1406 limitations.insert(value.id.clone(), value);
1407 }
1408 }
1409 account(&SETUP.sources_ns, sources_started);
1410 write_vite_transforms(&generated, &vite_transforms)?;
1411
1412 let assertions_started = Instant::now();
1413 let mut assertion_calls = 0;
1414 for entry in &project.source_scope.entries {
1415 let path = checked_source_path(workspace, &entry.file)?;
1416 let Ok(source) = fs::read_to_string(&path) else {
1417 continue;
1418 };
1419 let capability_wrapper = (!project.source_files.contains(&entry.file))
1420 .then(|| runtime_specifier(&entry.file, "capability.mjs"))
1421 .transpose()?;
1422 let assertion_runtime = runtime_specifier(&entry.file, "runtime.mjs")?;
1423 let output = crate::js_instrumenter::instrument_node_assertion_phases_with_runtime_imports(
1424 &source,
1425 &entry.file,
1426 std::slice::from_ref(&project.playwright_module),
1427 capability_wrapper.as_deref(),
1428 Some(&assertion_runtime),
1429 )
1430 .map_err(|source| JavascriptFrontendError::Instrument {
1431 file: entry.file.clone(),
1432 source,
1433 })?;
1434 let coverage_transformed_by_vite = project.build_adapter == BuildAdapter::Vite
1435 && project.source_files.contains(&entry.file);
1436 if (output.assertions > 0 || output.capability_imports > 0) && !coverage_transformed_by_vite
1437 {
1438 atomic_write(&path, output.code.as_bytes())?;
1439 assertion_calls += output.assertions;
1440 }
1441 }
1442
1443 account(&SETUP.assertion_ns, assertions_started);
1444
1445 let mut manifest = JavascriptManifest {
1446 decisions: decisions.into_values().collect(),
1447 points: points.into_values().collect(),
1448 branches: branches.into_values().collect(),
1449 limitations: limitations.into_values().collect(),
1450 scope: project.source_scope.clone(),
1451 };
1452 manifest.decisions.sort_by_key(|value| {
1453 (
1454 value.file.clone(),
1455 value.line,
1456 value.column,
1457 value.id.clone(),
1458 )
1459 });
1460 manifest.points.sort_by_key(|value| {
1461 (
1462 value.file.clone(),
1463 value.line,
1464 value.column,
1465 value.id.clone(),
1466 )
1467 });
1468 manifest.branches.sort_by_key(|value| {
1469 (
1470 value.file.clone(),
1471 value.line,
1472 value.column,
1473 value.id.clone(),
1474 )
1475 });
1476 manifest.limitations.sort_by_key(|value| {
1477 (
1478 value.file.clone(),
1479 value.line,
1480 value.column,
1481 value.id.clone(),
1482 )
1483 });
1484
1485 atomic_write(
1486 &generated.join("statement-exclusions.json"),
1487 &serde_json::to_vec(&exclusions).map_err(JavascriptFrontendError::Serialize)?,
1488 )?;
1489 let manifest_path = generated.join("manifest.json");
1490 let mut encoded =
1491 serde_json::to_vec_pretty(&manifest).map_err(JavascriptFrontendError::Serialize)?;
1492 encoded.push(b'\n');
1493 atomic_write(&manifest_path, &encoded)?;
1494 atomic_write(
1495 &generated.join("instrumentation-complete"),
1496 b"coverage-completeness-v2\n",
1497 )?;
1498 timed(&SETUP.cache_ns, || {
1499 write_javascript_frontend_cache(workspace, project, cache_key, assertion_calls)
1500 })?;
1501 Ok(PreparedJavascriptFrontend {
1502 manifest,
1503 manifest_path,
1504 preload_path: generated.join("node_modules/register.mjs"),
1505 playwright_config_path,
1506 vite_config_path,
1507 vitest_config_path,
1508 assertion_calls,
1509 })
1510}
1511
1512#[cfg(test)]
1513mod tests {
1514 #[test]
1515 fn ancestor_description_names_each_component_and_its_state() {
1516 let root = std::env::temp_dir().join(format!("supercov-ancestors-{}", unique()));
1517 fs::create_dir_all(root.join("present")).unwrap();
1518 fs::write(root.join("present/file.txt"), b"x").unwrap();
1519 let described = super::describe_ancestors(&root.join("present/file.txt/child"));
1520 assert!(described.contains("present=dir"), "{described}");
1521 assert!(described.contains("file.txt=file"), "{described}");
1522 assert!(described.ends_with("child=missing"), "{described}");
1523 fs::remove_dir_all(&root).unwrap();
1524 }
1525
1526 use super::*;
1527 use crate::project_discovery::discover_coverage_project;
1528
1529 fn temporary(name: &str) -> PathBuf {
1530 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1531 .join("../../target/supercov-test-fixtures")
1532 .join(format!("javascript-frontend-{name}-{}", unique()));
1533 fs::create_dir_all(&path).unwrap();
1539 crate::workspace::canonicalize_simplified(path).unwrap()
1540 }
1541
1542 #[test]
1543 fn browser_mode_gets_a_browser_setup_and_the_command_that_carries_its_evidence() {
1544 let generated = temporary("browser-mode-config");
1545 write_vite_transforms(&generated, &BTreeMap::new()).unwrap();
1546 let adapter = fs::read_to_string(generated.join("viteInstrumentation.mjs")).unwrap();
1547 assert!(adapter.contains("export function supercovBrowserCommands(root)"));
1551 assert!(adapter.contains("__supercovEvidence(context, payload, suffix)"));
1552 assert!(adapter.contains("const absolute = context.testPath;"));
1553 assert!(!adapter.contains("configureServer"), "{adapter}");
1554 assert!(!adapter.contains("__supercov/evidence"), "{adapter}");
1555 assert!(adapter.contains("supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/"));
1558 assert!(adapter.contains("Supercov refused an unusable evidence suffix"));
1559 }
1560
1561 #[test]
1562 fn a_browser_mode_project_loads_the_setup_that_can_run_in_a_browser() {
1563 let workspace = temporary("browser-mode-setup");
1566 let generated = workspace.join(".supercov");
1567 fs::create_dir_all(&generated).unwrap();
1568 fs::write(workspace.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1569 fs::create_dir_all(workspace.join("src")).unwrap();
1570 fs::write(
1571 workspace.join("src/example.mjs"),
1572 "export const one = () => 1;\n",
1573 )
1574 .unwrap();
1575 let project = discover_coverage_project(
1576 &workspace,
1577 &BTreeMap::new(),
1578 &["npx".into(), "vitest".into(), "run".into()],
1579 )
1580 .unwrap();
1581 let path = write_vitest_config(&workspace, &project, &generated).unwrap();
1582 let config = fs::read_to_string(path).unwrap();
1583 assert!(config.contains("config?.test?.browser?.enabled"));
1584 assert!(config.contains("'.supercov/node_modules/vitestBrowser.mjs'"));
1585 assert!(config.contains("'.supercov/node_modules/vitest.mjs'"));
1586 assert!(config.contains("browser: { commands: supercovBrowserCommands(process.cwd()) }"));
1588 assert!(config.contains("...supercovBrowserTest(loaded?.config)"));
1589 }
1590
1591 #[test]
1592 fn runtime_isolation_replaces_only_the_assignment_marker() {
1593 let source = concat!(
1594 "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n",
1595 "const selected = runtimeInstanceToken === \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\";\n"
1596 );
1597 let isolated = isolate_runtime(source, "collector-123").unwrap();
1598 assert!(isolated.contains("runtimeInstanceToken = \"collector-123\""));
1599 assert!(isolated.contains("=== \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\""));
1600 }
1601
1602 #[test]
1603 fn copied_runtime_does_not_reference_unshipped_source_maps() {
1604 let generated = temporary("runtime-source-maps");
1605 copy_runtime(&generated, "collector-test").unwrap();
1606 for name in ["vitest.mjs", "provenance.mjs", "atomic.mjs"] {
1607 let contents = fs::read_to_string(generated.join(name)).unwrap();
1608 assert!(
1609 !contents.contains("sourceMappingURL"),
1610 "runtime shim retained a source-map directive: {name}"
1611 );
1612 }
1613 fs::remove_dir_all(generated).unwrap();
1614 }
1615
1616 #[test]
1617 fn prepares_sorted_complete_manifest_without_touching_source_project() {
1618 let source_root = temporary("source");
1619 let workspace = temporary("workspace");
1620 fs::create_dir_all(source_root.join("src")).unwrap();
1621 fs::write(
1622 source_root.join("src/example.mjs"),
1623 "export function value(a, b) { if (a || b) return 1; return 0; }\n",
1624 )
1625 .unwrap();
1626 fs::write(source_root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1627 fs::create_dir_all(workspace.join("src")).unwrap();
1628 fs::create_dir_all(workspace.join(".supercov")).unwrap();
1629 fs::copy(
1630 source_root.join("src/example.mjs"),
1631 workspace.join("src/example.mjs"),
1632 )
1633 .unwrap();
1634 let project = discover_coverage_project(
1635 &source_root,
1636 &BTreeMap::new(),
1637 &["node".into(), "--test".into()],
1638 )
1639 .unwrap();
1640 let original = fs::read_to_string(source_root.join("src/example.mjs")).unwrap();
1641 let prepared =
1642 prepare_javascript_frontend(&workspace, &project, "collector-test", "cache-test", &[])
1643 .unwrap();
1644 assert_eq!(
1645 fs::read_to_string(source_root.join("src/example.mjs")).unwrap(),
1646 original
1647 );
1648 let transformed = fs::read_to_string(workspace.join("src/example.mjs")).unwrap();
1649 assert!(transformed.contains("__SUPERCOV_DIRECT_RUNTIME__"));
1650 assert_eq!(prepared.manifest.decisions.len(), 1);
1651 assert!(!prepared.manifest.points.is_empty());
1652 assert_eq!(prepared.manifest.scope, project.source_scope);
1653 assert!(prepared.manifest_path.is_file());
1654 assert!(prepared.preload_path.is_file());
1655 assert!(prepared.playwright_config_path.is_file());
1656 assert!(prepared.vite_config_path.is_file());
1657 assert!(
1658 fs::read_to_string(&prepared.vite_config_path)
1659 .unwrap()
1660 .contains("logLevel: ['1', 'true', 'yes'].includes")
1661 );
1662 assert!(prepared.vitest_config_path.is_file());
1663 assert_eq!(prepared.assertion_calls, 0);
1664 let cache = read_javascript_frontend_cache(&workspace, "cache-test").unwrap();
1665 assert_eq!(
1666 javascript_frontend_reuse_paths(&cache),
1667 [
1668 PathBuf::from(".supercov/frontend-cache.json"),
1669 PathBuf::from(".supercov/frontend-cache-artifacts"),
1670 ]
1671 );
1672 assert!(
1673 cache
1674 .artifacts
1675 .iter()
1676 .all(|artifact| !artifact.cache_file.contains("src/")
1677 && !artifact.cache_file.contains("tests/"))
1678 );
1679 fs::write(workspace.join("src/example.mjs"), &original).unwrap();
1680 fs::remove_file(&prepared.manifest_path).unwrap();
1681 let restored = load_cached_javascript_frontend(&workspace, &cache).unwrap();
1682 assert_eq!(restored.manifest, prepared.manifest);
1683 assert_eq!(
1684 fs::read_to_string(workspace.join("src/example.mjs")).unwrap(),
1685 transformed
1686 );
1687 fs::write(workspace.join(&cache.artifacts[0].cache_file), "corrupt").unwrap();
1688 assert!(read_javascript_frontend_cache(&workspace, "cache-test").is_none());
1689 fs::remove_dir_all(source_root).unwrap();
1690 fs::remove_dir_all(workspace).unwrap();
1691 }
1692
1693 #[test]
1694 fn embedded_runtime_contains_every_declared_shim() {
1695 for name in RUNTIME_FILES {
1696 let bytes = embedded_runtime(name).unwrap();
1697 assert!(!bytes.is_empty(), "embedded runtime is empty: {name}");
1698 }
1699 assert!(
1700 std::str::from_utf8(embedded_runtime("runtime.mjs").unwrap())
1701 .unwrap()
1702 .contains(RUNTIME_INSTANCE_MARKER)
1703 );
1704 }
1705}