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