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