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