1use std::{
8 collections::{BTreeMap, BTreeSet},
9 fs::{self, OpenOptions},
10 io::{self, Write},
11 path::{Path, PathBuf},
12 sync::atomic::{AtomicU64, Ordering},
13 time::{Instant, SystemTime, UNIX_EPOCH},
14};
15
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::{
20 js_instrumenter::{
21 CandidateBranch, CandidateDecision, CandidateError, CandidateLimitation, CandidatePoint,
22 instrument_with_import_policy,
23 },
24 project_discovery::{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 AUTHORED_DIRECTORY: &str = ".supercov/node_modules/.authored";
37const AUTHORED_LIST: &str = ".supercov/node_modules/authored-sources.json";
38const RUNTIME_FILES: &[&str] = &[
39 "atomic.mjs",
40 "ava.mjs",
41 "bootstrap.cjs",
42 "capability.mjs",
43 "jest.cjs",
44 "jest.config.mjs",
45 "jestReporter.mjs",
46 "jestRuntime.cjs",
47 "launchSupervisor.mjs",
48 "mocha.mjs",
49 "nodeAssert.mjs",
50 "nodeAssertAdapter.mjs",
51 "nodeAssertStrict.mjs",
52 "nodeTest.mjs",
53 "playwright.mjs",
54 "playwrightReporter.mjs",
55 "provenance.mjs",
56 "register.mjs",
57 "resolve-loader.mjs",
58 "runnerEvidence.mjs",
59 "runtime.mjs",
60 "tap.mjs",
61 "transport.mjs",
62 "vitest.mjs",
63 "vitestBrowser.mjs",
64 "vitestReporter.mjs",
65];
66static UNIQUE: AtomicU64 = AtomicU64::new(0);
67
68struct SetupAccounting {
81 files: AtomicU64,
82 bytes: AtomicU64,
83 create_ns: AtomicU64,
84 write_ns: AtomicU64,
85 rename_ns: AtomicU64,
86 directories: AtomicU64,
87 directory_retries: AtomicU64,
88 directory_ns: AtomicU64,
89 runtime_ns: AtomicU64,
90 config_ns: AtomicU64,
91 sources_ns: AtomicU64,
92 instrument_ns: AtomicU64,
93 assertion_ns: AtomicU64,
94 cache_ns: AtomicU64,
95}
96
97impl SetupAccounting {
98 const fn new() -> Self {
99 Self {
100 files: AtomicU64::new(0),
101 bytes: AtomicU64::new(0),
102 create_ns: AtomicU64::new(0),
103 write_ns: AtomicU64::new(0),
104 rename_ns: AtomicU64::new(0),
105 directories: AtomicU64::new(0),
106 directory_retries: AtomicU64::new(0),
107 directory_ns: AtomicU64::new(0),
108 runtime_ns: AtomicU64::new(0),
109 config_ns: AtomicU64::new(0),
110 sources_ns: AtomicU64::new(0),
111 instrument_ns: AtomicU64::new(0),
112 assertion_ns: AtomicU64::new(0),
113 cache_ns: AtomicU64::new(0),
114 }
115 }
116}
117
118static SETUP: SetupAccounting = SetupAccounting::new();
119
120fn account(counter: &AtomicU64, started: Instant) {
121 counter.fetch_add(started.elapsed().as_nanos() as u64, Ordering::Relaxed);
122}
123
124fn accounted_ms(counter: &AtomicU64) -> f64 {
125 counter.load(Ordering::Relaxed) as f64 / 1_000_000.0
126}
127
128fn counted(counter: &AtomicU64) -> u64 {
129 counter.load(Ordering::Relaxed)
130}
131
132fn timed<T>(counter: &AtomicU64, operation: impl FnOnce() -> T) -> T {
133 let started = Instant::now();
134 let value = operation();
135 account(counter, started);
136 value
137}
138
139pub fn setup_timing_detail() -> Option<String> {
142 if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() != Ok("1") {
143 return None;
144 }
145 Some(format!(
146 "setup detail runtime={:.1}ms configs={:.1}ms sources={:.1}ms (instrument={:.1}ms) \
147assertions={:.1}ms cache={:.1}ms | files={} bytes={} create={:.1}ms write={:.1}ms \
148rename={:.1}ms | directories={} retries={} directory-wait={:.1}ms",
149 accounted_ms(&SETUP.runtime_ns),
150 accounted_ms(&SETUP.config_ns),
151 accounted_ms(&SETUP.sources_ns),
152 accounted_ms(&SETUP.instrument_ns),
153 accounted_ms(&SETUP.assertion_ns),
154 accounted_ms(&SETUP.cache_ns),
155 counted(&SETUP.files),
156 counted(&SETUP.bytes),
157 accounted_ms(&SETUP.create_ns),
158 accounted_ms(&SETUP.write_ns),
159 accounted_ms(&SETUP.rename_ns),
160 counted(&SETUP.directories),
161 counted(&SETUP.directory_retries),
162 accounted_ms(&SETUP.directory_ns),
163 ))
164}
165
166#[derive(Debug)]
167pub enum JavascriptFrontendError {
168 Io {
169 path: PathBuf,
170 source: io::Error,
171 },
172 Instrument {
173 file: String,
174 source: CandidateError,
175 },
176 MissingRuntimeMarker,
177 Serialize(serde_json::Error),
178 UnsafeSourcePath(String),
179}
180
181impl std::fmt::Display for JavascriptFrontendError {
182 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 match self {
184 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
185 Self::Instrument { file, source } => {
186 write!(formatter, "failed to instrument {file}: {source}")
187 }
188 Self::MissingRuntimeMarker => write!(
189 formatter,
190 "generated Supercov runtime is missing its instance marker"
191 ),
192 Self::Serialize(error) => write!(formatter, "failed to serialize manifest: {error}"),
193 Self::UnsafeSourcePath(file) => write!(formatter, "unsafe source path: {file}"),
194 }
195 }
196}
197
198impl std::error::Error for JavascriptFrontendError {}
199
200fn io_error(path: &Path, source: io::Error) -> JavascriptFrontendError {
201 JavascriptFrontendError::Io {
202 path: path.to_owned(),
203 source,
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "camelCase", deny_unknown_fields)]
209pub struct JavascriptManifest {
210 pub decisions: Vec<CandidateDecision>,
211 pub points: Vec<CandidatePoint>,
212 pub branches: Vec<CandidateBranch>,
213 pub limitations: Vec<CandidateLimitation>,
214 pub scope: SourceScope,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct PreparedJavascriptFrontend {
219 pub manifest: JavascriptManifest,
220 pub manifest_path: PathBuf,
221 pub preload_path: PathBuf,
222 pub playwright_config_path: PathBuf,
223 pub vite_config_path: PathBuf,
224 pub vitest_config_path: PathBuf,
225 pub assertion_calls: usize,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "camelCase", deny_unknown_fields)]
230pub struct JavascriptFrontendCache {
231 schema_version: u32,
232 key: String,
233 assertion_calls: usize,
234 artifacts: Vec<JavascriptFrontendCacheArtifact>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "camelCase", deny_unknown_fields)]
239struct JavascriptFrontendCacheArtifact {
240 path: String,
241 cache_file: String,
242 sha256: String,
243}
244
245fn safe_relative(path: &Path) -> bool {
246 path.components().next().is_some()
247 && path
248 .components()
249 .all(|component| matches!(component, std::path::Component::Normal(_)))
250}
251
252fn regular_file(workspace: &Path, relative: &str) -> bool {
253 safe_relative(Path::new(relative))
254 && fs::symlink_metadata(workspace.join(relative))
255 .is_ok_and(|metadata| metadata.file_type().is_file())
256}
257
258fn valid_cached_artifact(workspace: &Path, artifact: &JavascriptFrontendCacheArtifact) -> bool {
259 let expected_cache_file = format!("{FRONTEND_CACHE_DIRECTORY}/{}", artifact.sha256);
260 if !safe_relative(Path::new(&artifact.path))
261 || !safe_relative(Path::new(&artifact.cache_file))
262 || artifact.cache_file != expected_cache_file
263 || artifact.sha256.len() != 64
264 || !artifact
265 .sha256
266 .bytes()
267 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
268 {
269 return false;
270 }
271 let Ok(contents) = fs::read(workspace.join(&artifact.cache_file)) else {
272 return false;
273 };
274 format!("{:x}", Sha256::digest(&contents)) == artifact.sha256
275}
276
277pub fn read_javascript_frontend_cache(
278 workspace: &Path,
279 key: &str,
280) -> Option<JavascriptFrontendCache> {
281 let metadata: JavascriptFrontendCache =
282 serde_json::from_slice(&fs::read(workspace.join(FRONTEND_CACHE_FILE)).ok()?).ok()?;
283 if metadata.schema_version != FRONTEND_CACHE_SCHEMA_VERSION
284 || metadata.key != key
285 || metadata.artifacts.is_empty()
286 || metadata
287 .artifacts
288 .iter()
289 .any(|artifact| !valid_cached_artifact(workspace, artifact))
290 {
291 return None;
292 }
293 Some(metadata)
294}
295
296pub fn javascript_frontend_reuse_paths(cache: &JavascriptFrontendCache) -> Vec<PathBuf> {
297 let _ = cache;
298 vec![
299 PathBuf::from(FRONTEND_CACHE_FILE),
300 PathBuf::from(FRONTEND_CACHE_DIRECTORY),
301 ]
302}
303
304fn restore_cached_file(path: &Path, contents: &[u8]) -> Result<(), JavascriptFrontendError> {
305 let parent = path
306 .parent()
307 .ok_or_else(|| JavascriptFrontendError::UnsafeSourcePath(path.display().to_string()))?;
308 create_directory_all(parent)?;
309 let temporary = parent.join(format!(".supercov-restore-{}", unique()));
310 let result = (|| {
311 fs::write(&temporary, contents).map_err(|source| io_error(&temporary, source))?;
312 match fs::symlink_metadata(path) {
313 Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_symlink() => {
314 fs::remove_file(path).map_err(|source| io_error(path, source))?;
315 }
316 Ok(_) => {
317 return Err(JavascriptFrontendError::UnsafeSourcePath(
318 path.display().to_string(),
319 ));
320 }
321 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
322 Err(source) => return Err(io_error(path, source)),
323 }
324 fs::rename(&temporary, path).map_err(|source| io_error(path, source))
325 })();
326 if result.is_err() {
327 let _ = fs::remove_file(&temporary);
328 }
329 result
330}
331
332pub fn load_cached_javascript_frontend(
333 workspace: &Path,
334 cache: &JavascriptFrontendCache,
335) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
336 for artifact in &cache.artifacts {
337 let cache_path = workspace.join(&artifact.cache_file);
338 let contents = fs::read(&cache_path).map_err(|source| io_error(&cache_path, source))?;
339 if format!("{:x}", Sha256::digest(&contents)) != artifact.sha256 {
340 return Err(JavascriptFrontendError::UnsafeSourcePath(format!(
341 "corrupt frontend cache artifact {}",
342 artifact.cache_file
343 )));
344 }
345 restore_cached_file(&workspace.join(&artifact.path), &contents)?;
346 }
347 let generated = workspace.join(".supercov");
348 let manifest_path = generated.join("manifest.json");
349 let manifest = serde_json::from_slice(
350 &fs::read(&manifest_path).map_err(|source| io_error(&manifest_path, source))?,
351 )
352 .map_err(JavascriptFrontendError::Serialize)?;
353 Ok(PreparedJavascriptFrontend {
354 manifest,
355 manifest_path,
356 preload_path: generated.join("node_modules/register.mjs"),
357 playwright_config_path: generated.join("playwright.config.mjs"),
358 vite_config_path: generated.join("vite.config.mjs"),
359 vitest_config_path: generated.join("vitest.config.mjs"),
360 assertion_calls: cache.assertion_calls,
361 })
362}
363
364fn frontend_artifact_paths(workspace: &Path, project: &CoverageProject) -> Vec<String> {
365 let mut artifacts = vec![
366 ".supercov/node_modules/package.json".to_owned(),
367 ".supercov/node_modules/applicationRuntime.mjs".to_owned(),
368 ".supercov/node_modules/runtime.d.mts".to_owned(),
369 ".supercov/playwright.config.mjs".to_owned(),
370 ".supercov/vite.config.mjs".to_owned(),
371 ".supercov/vitest.config.mjs".to_owned(),
372 ".supercov/vite-transforms.json".to_owned(),
373 ".supercov/viteInstrumentation.mjs".to_owned(),
374 ".supercov/manifest.json".to_owned(),
375 ".supercov/statement-exclusions.json".to_owned(),
376 ".supercov/instrumentation-complete".to_owned(),
377 ];
378 artifacts.extend(
379 RUNTIME_FILES
380 .iter()
381 .map(|name| format!(".supercov/node_modules/{name}")),
382 );
383 artifacts.extend(project.source_files.iter().cloned());
388 artifacts.extend(
389 project
390 .source_scope
391 .entries
392 .iter()
393 .map(|entry| entry.file.clone()),
394 );
395 for root in &project.source_roots {
396 let host = if workspace.join(root).is_file() {
397 Path::new(root).parent().unwrap_or_else(|| Path::new(""))
398 } else {
399 Path::new(root)
400 };
401 for name in ["package.json", "runtime.mjs", "runtime.d.mts"] {
402 let path = host.join(".supercov/node_modules").join(name);
403 if let Some(path) = path.to_str() {
404 artifacts.push(path.replace('\\', "/"));
405 }
406 }
407 }
408 let rewritten = project
409 .source_files
410 .iter()
411 .chain(project.source_scope.entries.iter().map(|entry| &entry.file))
412 .map(|file| format!("{AUTHORED_DIRECTORY}/{file}"))
413 .collect::<Vec<_>>();
414 artifacts.extend(rewritten);
415 artifacts.push(AUTHORED_LIST.to_owned());
416 artifacts.sort();
417 artifacts.dedup();
418 artifacts.retain(|path| regular_file(workspace, path));
419 artifacts
420}
421
422pub fn rewritten_files(workspace: &Path) -> Vec<String> {
424 fs::read(workspace.join(AUTHORED_LIST))
425 .ok()
426 .and_then(|contents| serde_json::from_slice(&contents).ok())
427 .unwrap_or_default()
428}
429
430fn keep_authored(
432 workspace: &Path,
433 authored: &mut BTreeSet<String>,
434 file: &str,
435 source: &str,
436) -> Result<(), JavascriptFrontendError> {
437 if authored.insert(file.to_owned()) {
438 let path = checked_source_path(&workspace.join(AUTHORED_DIRECTORY), file)?;
439 if let Some(parent) = path.parent() {
440 create_directory_all(parent)?;
441 }
442 atomic_write(&path, source.as_bytes())?;
443 }
444 Ok(())
445}
446
447fn write_javascript_frontend_cache(
448 workspace: &Path,
449 project: &CoverageProject,
450 key: &str,
451 assertion_calls: usize,
452) -> Result<(), JavascriptFrontendError> {
453 let cache_directory = workspace.join(FRONTEND_CACHE_DIRECTORY);
454 create_directory_all(&cache_directory)?;
455 let mut artifacts = Vec::new();
456 for path in frontend_artifact_paths(workspace, project) {
457 let contents = fs::read(workspace.join(&path))
458 .map_err(|source| io_error(&workspace.join(&path), source))?;
459 let sha256 = format!("{:x}", Sha256::digest(&contents));
460 let cache_file = format!("{FRONTEND_CACHE_DIRECTORY}/{sha256}");
461 let destination = workspace.join(&cache_file);
462 if !destination.is_file() {
463 atomic_write(&destination, &contents)?;
464 }
465 artifacts.push(JavascriptFrontendCacheArtifact {
466 path,
467 cache_file,
468 sha256,
469 });
470 }
471 let cache = JavascriptFrontendCache {
472 schema_version: FRONTEND_CACHE_SCHEMA_VERSION,
473 key: key.to_owned(),
474 assertion_calls,
475 artifacts,
476 };
477 let mut encoded =
478 serde_json::to_vec_pretty(&cache).map_err(JavascriptFrontendError::Serialize)?;
479 encoded.push(b'\n');
480 atomic_write(&workspace.join(FRONTEND_CACHE_FILE), &encoded)
481}
482
483#[derive(Debug, Serialize)]
484#[serde(rename_all = "camelCase")]
485struct ViteTransform {
486 source_sha256: String,
487 code: String,
488 map: Option<serde_json::Value>,
489}
490
491fn embedded_runtime(name: &str) -> Option<&'static [u8]> {
492 match name {
493 "atomic.mjs" => Some(include_bytes!("../runtime-assets/javascript/atomic.mjs")),
494 "ava.mjs" => Some(include_bytes!("../runtime-assets/javascript/ava.mjs")),
495 "bootstrap.cjs" => Some(include_bytes!("../runtime-assets/javascript/bootstrap.cjs")),
496 "capability.mjs" => Some(include_bytes!(
497 "../runtime-assets/javascript/capability.mjs"
498 )),
499 "jest.cjs" => Some(include_bytes!("../runtime-assets/javascript/jest.cjs")),
500 "jest.config.mjs" => Some(include_bytes!(
501 "../runtime-assets/javascript/jest.config.mjs"
502 )),
503 "jestReporter.mjs" => Some(include_bytes!(
504 "../runtime-assets/javascript/jestReporter.mjs"
505 )),
506 "jestRuntime.cjs" => Some(include_bytes!(
507 "../runtime-assets/javascript/jestRuntime.cjs"
508 )),
509 "launchSupervisor.mjs" => Some(include_bytes!(
510 "../runtime-assets/javascript/launchSupervisor.mjs"
511 )),
512 "mocha.mjs" => Some(include_bytes!("../runtime-assets/javascript/mocha.mjs")),
513 "nodeAssert.mjs" => Some(include_bytes!(
514 "../runtime-assets/javascript/nodeAssert.mjs"
515 )),
516 "nodeAssertAdapter.mjs" => Some(include_bytes!(
517 "../runtime-assets/javascript/nodeAssertAdapter.mjs"
518 )),
519 "nodeAssertStrict.mjs" => Some(include_bytes!(
520 "../runtime-assets/javascript/nodeAssertStrict.mjs"
521 )),
522 "nodeTest.mjs" => Some(include_bytes!("../runtime-assets/javascript/nodeTest.mjs")),
523 "playwright.mjs" => Some(include_bytes!(
524 "../runtime-assets/javascript/playwright.mjs"
525 )),
526 "playwrightReporter.mjs" => Some(include_bytes!(
527 "../runtime-assets/javascript/playwrightReporter.mjs"
528 )),
529 "provenance.mjs" => Some(include_bytes!(
530 "../runtime-assets/javascript/provenance.mjs"
531 )),
532 "register.mjs" => Some(include_bytes!("../runtime-assets/javascript/register.mjs")),
533 "resolve-loader.mjs" => Some(include_bytes!(
534 "../runtime-assets/javascript/resolve-loader.mjs"
535 )),
536 "runnerEvidence.mjs" => Some(include_bytes!(
537 "../runtime-assets/javascript/runnerEvidence.mjs"
538 )),
539 "runtime.mjs" => Some(include_bytes!("../runtime-assets/javascript/runtime.mjs")),
540 "tap.mjs" => Some(include_bytes!("../runtime-assets/javascript/tap.mjs")),
541 "transport.mjs" => Some(include_bytes!("../runtime-assets/javascript/transport.mjs")),
542 "vitest.mjs" => Some(include_bytes!("../runtime-assets/javascript/vitest.mjs")),
543 "vitestBrowser.mjs" => Some(include_bytes!(
544 "../runtime-assets/javascript/vitestBrowser.mjs"
545 )),
546 "vitestReporter.mjs" => Some(include_bytes!(
547 "../runtime-assets/javascript/vitestReporter.mjs"
548 )),
549 _ => None,
550 }
551}
552
553fn unique() -> String {
554 let nanos = SystemTime::now()
555 .duration_since(UNIX_EPOCH)
556 .unwrap_or_default()
557 .as_nanos();
558 format!(
559 "{}-{nanos}-{}",
560 std::process::id(),
561 UNIQUE.fetch_add(1, Ordering::Relaxed)
562 )
563}
564
565#[cfg(not(windows))]
566fn create_directory_all(path: &Path) -> Result<(), JavascriptFrontendError> {
567 SETUP.directories.fetch_add(1, Ordering::Relaxed);
568 timed(&SETUP.directory_ns, || {
569 fs::create_dir_all(path).map_err(|source| io_error(path, source))
570 })
571}
572
573#[cfg(windows)]
574fn create_directory_all(path: &Path) -> Result<(), JavascriptFrontendError> {
575 const ATTEMPTS: usize = 16;
585 let started = std::time::Instant::now();
586 SETUP.directories.fetch_add(1, Ordering::Relaxed);
587 let mut delay = std::time::Duration::from_millis(20);
588 for attempt in 0..ATTEMPTS {
589 match fs::create_dir_all(path) {
590 Ok(()) => {
591 account(&SETUP.directory_ns, started);
592 return Ok(());
593 }
594 Err(source)
595 if source.kind() == io::ErrorKind::PermissionDenied && attempt + 1 < ATTEMPTS =>
596 {
597 SETUP.directory_retries.fetch_add(1, Ordering::Relaxed);
598 std::thread::sleep(delay);
599 delay = (delay * 2).min(std::time::Duration::from_millis(500));
600 }
601 Err(source) => {
602 account(&SETUP.directory_ns, started);
603 let detail = format!(
604 "{source} (after {} attempt(s) over {:?}; ancestors: {})",
605 attempt + 1,
606 started.elapsed(),
607 describe_ancestors(path)
608 );
609 return Err(io_error(path, io::Error::new(source.kind(), detail)));
610 }
611 }
612 }
613 unreachable!("the final directory-creation attempt always returns")
614}
615
616#[cfg_attr(not(windows), allow(dead_code))]
620fn describe_ancestors(path: &Path) -> String {
621 let mut current = PathBuf::new();
622 let mut parts = Vec::new();
623 for component in path.components() {
624 current.push(component.as_os_str());
625 let state = match fs::symlink_metadata(¤t) {
626 Ok(metadata) if metadata.file_type().is_symlink() => "link",
627 Ok(metadata) if metadata.file_type().is_dir() => "dir",
628 Ok(_) => "file",
629 Err(error)
632 if matches!(
633 error.kind(),
634 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
635 ) =>
636 {
637 "missing"
638 }
639 Err(error) => return format!("{} -> {error}", current.display()),
640 };
641 parts.push(format!("{}={state}", current.display()));
642 }
643 parts.join("; ")
644}
645
646fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), JavascriptFrontendError> {
647 let parent = path
648 .parent()
649 .ok_or_else(|| JavascriptFrontendError::UnsafeSourcePath(path.display().to_string()))?;
650 let temporary = parent.join(format!(".supercov-write-{}", unique()));
651 let result = (|| {
652 let open = || {
653 OpenOptions::new()
654 .write(true)
655 .create_new(true)
656 .open(&temporary)
657 };
658 let opened = timed(&SETUP.create_ns, open);
659 let mut output = match opened {
660 Ok(output) => output,
661 Err(source) if source.kind() == io::ErrorKind::NotFound => {
662 create_directory_all(parent)?;
663 timed(&SETUP.create_ns, open).map_err(|source| io_error(&temporary, source))?
664 }
665 Err(source) => return Err(io_error(&temporary, source)),
666 };
667 timed(&SETUP.write_ns, || output.write_all(contents))
668 .map_err(|source| io_error(&temporary, source))?;
669 timed(&SETUP.rename_ns, || fs::rename(&temporary, path))
693 .map_err(|source| io_error(path, source))?;
694 SETUP.files.fetch_add(1, Ordering::Relaxed);
695 SETUP
696 .bytes
697 .fetch_add(contents.len() as u64, Ordering::Relaxed);
698 Ok(())
699 })();
700 if result.is_err() {
701 let _ = fs::remove_file(&temporary);
702 }
703 result
704}
705
706fn checked_source_path(workspace: &Path, file: &str) -> Result<PathBuf, JavascriptFrontendError> {
707 let relative = Path::new(file);
708 if relative.is_absolute()
709 || relative
710 .components()
711 .any(|component| !matches!(component, std::path::Component::Normal(_)))
712 {
713 return Err(JavascriptFrontendError::UnsafeSourcePath(file.to_owned()));
714 }
715 Ok(workspace.join(relative))
716}
717
718fn runtime_specifier(file: &str, name: &str) -> Result<String, JavascriptFrontendError> {
719 let relative = Path::new(file);
720 if relative.is_absolute()
721 || relative
722 .components()
723 .any(|component| !matches!(component, std::path::Component::Normal(_)))
724 {
725 return Err(JavascriptFrontendError::UnsafeSourcePath(file.to_owned()));
726 }
727 let depth = relative
728 .parent()
729 .map_or(0, |parent| parent.components().count());
730 Ok(if depth == 0 {
731 format!("./.supercov/node_modules/{name}")
732 } else {
733 format!("{}.supercov/node_modules/{name}", "../".repeat(depth))
734 })
735}
736
737const GENERATED_SOURCE_BANNER: &str =
744 "/* eslint-disable */\n// @ts-nocheck -- generated coverage workspace only\n";
745
746fn generated_source_banner(code: &str) -> String {
752 let Some(rest) = code.strip_prefix("#!") else {
753 return format!("{GENERATED_SOURCE_BANNER}{code}");
754 };
755 let (line, remainder) = rest.split_once('\n').unwrap_or((rest, ""));
756 format!("#!{line}\n{GENERATED_SOURCE_BANNER}{remainder}")
757}
758
759fn isolate_runtime(source: &str, collector_id: &str) -> Result<String, JavascriptFrontendError> {
760 let source = format!("/* eslint-disable */\n{source}");
764 let source = source.as_str();
765 let double = format!("runtimeInstanceToken = \"{RUNTIME_INSTANCE_MARKER}\"");
766 let single = format!("runtimeInstanceToken = '{RUNTIME_INSTANCE_MARKER}'");
767 if let Some(index) = source.find(&double) {
768 let mut isolated = source.to_owned();
769 isolated.replace_range(
770 index..index + double.len(),
771 &format!("runtimeInstanceToken = \"{collector_id}\""),
772 );
773 return Ok(isolated);
774 }
775 if let Some(index) = source.find(&single) {
776 let mut isolated = source.to_owned();
777 isolated.replace_range(
778 index..index + single.len(),
779 &format!("runtimeInstanceToken = '{collector_id}'"),
780 );
781 return Ok(isolated);
782 }
783 Err(JavascriptFrontendError::MissingRuntimeMarker)
784}
785
786fn inline_instrumentation_map(
796 code: &str,
797 map: Option<&serde_json::Value>,
798 original_path: &Path,
799 original_source: &str,
800) -> Option<String> {
801 let map = map?.clone();
802 let mut map = map;
803 let object = map.as_object_mut()?;
804 let banner_lines = code
805 .lines()
806 .skip(usize::from(code.starts_with("#!")))
807 .take_while(|line| {
808 line.starts_with("/* eslint-disable */") || line.starts_with("// @ts-nocheck")
809 })
810 .count();
811 let mut mappings = object.get("mappings")?.as_str()?.to_owned();
812 if banner_lines > 0 {
813 mappings = format!("{}{mappings}", ";".repeat(banner_lines));
814 }
815 if !code.starts_with("#!") && mappings.starts_with(';') {
821 mappings.insert_str(0, "AAAA,C");
822 }
823 let directive_line = code.matches('\n').count() + 1;
826 let mapped_lines = mappings.matches(';').count();
827 if directive_line > mapped_lines
828 && mappings.contains(|c: char| c.is_ascii_alphanumeric() || c == '+' || c == '/')
829 {
830 mappings.push_str(&";".repeat(directive_line - mapped_lines));
831 mappings.push_str("AAAA");
832 }
833 object.insert("mappings".into(), serde_json::Value::String(mappings));
834 object.insert(
835 "sources".into(),
836 serde_json::json!([original_path.display().to_string()]),
837 );
838 object.insert(
839 "sourcesContent".into(),
840 serde_json::json!([original_source]),
841 );
842 let payload = serde_json::to_string(&map).ok()?;
843 use base64::Engine as _;
844 let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
845 Some(format!(
846 "{code}\n//# sourceMappingURL=data:application/json;base64,{encoded}\n"
847 ))
848}
849
850fn strip_source_map_reference(mut bytes: Vec<u8>) -> Vec<u8> {
854 const MARKER: &[u8] = b"\n//# sourceMappingURL=";
855 if let Some(index) = bytes
856 .windows(MARKER.len())
857 .rposition(|window| window == MARKER)
858 {
859 let suffix = &bytes[index + MARKER.len()..];
860 let suffix = suffix.strip_suffix(b"\n").unwrap_or(suffix);
861 let suffix = suffix.strip_suffix(b"\r").unwrap_or(suffix);
862 if !suffix.contains(&b'\n') && !suffix.contains(&b'\r') {
863 bytes.truncate(index + 1);
864 }
865 }
866 bytes
867}
868
869fn copy_runtime(generated: &Path, collector_id: &str) -> Result<(), JavascriptFrontendError> {
870 create_directory_all(generated)?;
871 atomic_write(
872 &generated.join("package.json"),
873 b"{\"private\":true,\"type\":\"module\"}\n",
874 )?;
875 for name in RUNTIME_FILES {
876 let destination = generated.join(name);
877 let source_path = PathBuf::from(format!("embedded:{name}"));
878 let bytes = embedded_runtime(name)
879 .expect("every declared runtime file must have an embedded asset")
880 .to_vec();
881 let bytes = strip_source_map_reference(bytes);
882 if *name == "runtime.mjs" {
883 let text = String::from_utf8(bytes).map_err(|source| {
884 io_error(
885 &source_path,
886 io::Error::new(io::ErrorKind::InvalidData, source),
887 )
888 })?;
889 atomic_write(
890 &destination,
891 isolate_runtime(&text, collector_id)?.as_bytes(),
892 )?;
893 atomic_write(
894 &generated.join("applicationRuntime.mjs"),
895 isolate_runtime(&text, &format!("{collector_id}-application"))?.as_bytes(),
896 )?;
897 } else {
898 atomic_write(&destination, &bytes)?;
899 }
900 }
901 atomic_write(
902 &generated.join("runtime.d.mts"),
903 b"/* eslint-disable */\n\
909export declare function coverageHit(...args: any[]): any;\n\
910export declare function selectionBegin(...args: any[]): any;\n\
911export declare function selectionRight(...args: any[]): any;\n\
912export declare function selectionEnd(...args: any[]): any;\n\
913export declare function optionalSelect(...args: any[]): any;\n\
914export declare function optionalCallBegin(...args: any[]): any;\n\
915export declare function optionalCallReached(...args: any[]): any;\n\
916export declare function optionalCallContinued(...args: any[]): any;\n\
917export declare function optionalCallEnd(...args: any[]): any;\n\
918export declare function defaultSelected(...args: any[]): any;\n\
919export declare function defaultEntered(...args: any[]): any;\n\
920export declare function defaultSelectedV2(...args: any[]): any;\n\
921export declare function defaultEnteredV2(...args: any[]): any;\n\
922export declare function optionalSelectV2(...args: any[]): any;\n\
923export declare function selectShortV2(...args: any[]): any;\n\
924export declare function selectRightV2(...args: any[]): any;\n\
925export declare function selectNamedRightV2(...args: any[]): any;\n\
926export declare function selectAssignEndV2(...args: any[]): any;\n\
927export declare function selectPathV2(...args: any[]): any;\n\
928export declare function optionalCallEndV2(...args: any[]): any;\n\
929export declare function tryBegin(...args: any[]): any;\n\
930export declare function tryCatch(...args: any[]): any;\n\
931export declare function tryEnd(...args: any[]): any;\n\
932export declare function loopBegin(...args: any[]): any;\n\
933export declare function loopEntered(...args: any[]): any;\n\
934export declare function loopEnd(...args: any[]): any;\n\
935export declare function mcdcBegin(...args: any[]): any;\n\
936export declare function mcdcCondition(...args: any[]): any;\n\
937export declare function mcdcEnd(...args: any[]): any;\n\
938export declare function registerProbeV2(...args: any[]): any;\n\
939export declare function coverageHitV2(...args: any[]): any;\n\
940export declare function mcdcEndV2(...args: any[]): any;\n",
941 )?;
942 Ok(())
943}
944
945fn generic_runtime_binding(
946 workspace: &Path,
947 project: &CoverageProject,
948 source_path: &Path,
949 generated: &Path,
950) -> Result<String, JavascriptFrontendError> {
951 let mut hosts = project
952 .source_roots
953 .iter()
954 .filter_map(|root| {
955 let candidate = workspace.join(root);
956 if candidate.is_dir() && source_path.strip_prefix(&candidate).is_ok() {
957 Some(candidate)
958 } else if candidate.is_file() && candidate == source_path {
959 candidate.parent().map(Path::to_owned)
960 } else {
961 None
962 }
963 })
964 .collect::<Vec<_>>();
965 hosts.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
966 let host = hosts
967 .into_iter()
968 .next()
969 .unwrap_or_else(|| workspace.to_owned());
970 let runtime_directory = host.join(".supercov/node_modules");
971 fs::create_dir_all(&runtime_directory)
972 .map_err(|source| io_error(&runtime_directory, source))?;
973 atomic_write(
976 &runtime_directory.join("package.json"),
977 b"{\"private\":true,\"type\":\"module\"}\n",
978 )?;
979 for name in ["runtime.mjs", "runtime.d.mts"] {
980 let source = generated.join("node_modules").join(name);
981 let destination = runtime_directory.join(name);
982 let contents = fs::read(&source).map_err(|error| io_error(&source, error))?;
983 atomic_write(&destination, &contents)?;
984 }
985 if project.relocating_build {
994 return Ok(runtime_directory
995 .join("runtime.mjs")
996 .to_string_lossy()
997 .replace('\\', "/"));
998 }
999 let parent = source_path.parent().ok_or_else(|| {
1000 JavascriptFrontendError::UnsafeSourcePath(source_path.display().to_string())
1001 })?;
1002 let local = parent.strip_prefix(&host).map_err(|_| {
1003 JavascriptFrontendError::UnsafeSourcePath(source_path.display().to_string())
1004 })?;
1005 let depth = local.components().count();
1006 Ok(if depth == 0 {
1007 "./.supercov/node_modules/runtime.mjs".into()
1008 } else {
1009 format!("{}.supercov/node_modules/runtime.mjs", "../".repeat(depth))
1010 })
1011}
1012
1013fn limitation_from_source(value: &SourceLimitation) -> CandidateLimitation {
1014 CandidateLimitation {
1015 id: value.id.clone(),
1016 kind: value.kind.clone(),
1017 file: value.file.clone(),
1018 line: value.line,
1019 column: value.column,
1020 source: value.source.clone(),
1021 reason: value.reason.clone(),
1022 }
1023}
1024
1025fn bootstrap_loader(
1047 output: &mut crate::js_instrumenter::CandidateOutput,
1048 file: &str,
1049) -> Result<(), JavascriptFrontendError> {
1050 let Some(binding) = output.runtime.as_ref() else {
1051 return Ok(());
1052 };
1053 let direct = format!(
1054 "{} = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin",
1055 binding.mcdc_begin
1056 );
1057 let Some(found) = output.code.find(&direct) else {
1058 return Ok(());
1059 };
1060 let loader = format!(
1061 "{}\n",
1062 crate::js_instrumenter::runtime_loader(&runtime_specifier(file, "bootstrap.cjs")?)
1063 );
1064 let offset = output.code[..found]
1065 .rfind('\n')
1066 .map_or(0, |newline| newline + 1);
1067 let line = output.code[..offset].matches('\n').count();
1068 if let Some(serde_json::Value::String(mappings)) =
1069 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1070 {
1071 let mut lines: Vec<&str> = mappings.split(';').collect();
1072 if line <= lines.len() {
1073 lines.insert(line, "");
1074 *mappings = lines.join(";");
1075 }
1076 }
1077 output.code.insert_str(offset, &loader);
1078 Ok(())
1079}
1080
1081fn bootstrap_runtime(
1082 output: &mut crate::js_instrumenter::CandidateOutput,
1083 runtime_source: &str,
1084 browser_suite: bool,
1085) {
1086 let Some(binding) = output.runtime.as_ref() else {
1087 return;
1088 };
1089 let script = format!(
1090 "{} = globalThis.__supercovRuntime.mcdcBegin",
1091 binding.mcdc_begin
1092 );
1093 let direct = format!(
1094 "{} = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin",
1095 binding.mcdc_begin
1096 );
1097 let Some(found) = output
1098 .code
1099 .find(&script)
1100 .or_else(|| browser_suite.then(|| output.code.find(&direct)).flatten())
1101 else {
1102 return;
1103 };
1104 let Some(export_start) = runtime_source.rfind("\nexport {") else {
1105 return;
1106 };
1107 let offset = output.code[..found]
1108 .rfind('\n')
1109 .map_or(0, |newline| newline + 1);
1110 let prelude = format!(
1111 "(() => {{ if (!globalThis.__SUPERCOV_DIRECT_RUNTIME__) {{\n{}\n}} if (globalThis.__supercovRuntime == null) globalThis.__supercovRuntime = globalThis.__SUPERCOV_DIRECT_RUNTIME__; }})();\n",
1112 &runtime_source[..export_start]
1113 );
1114 let line = output.code[..offset].matches('\n').count();
1115 let added = prelude.matches('\n').count();
1116 if let Some(serde_json::Value::String(mappings)) =
1117 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1118 {
1119 let mut lines: Vec<&str> = mappings.split(';').collect();
1120 if line <= lines.len() {
1121 lines.splice(line..line, std::iter::repeat_n("", added));
1122 *mappings = lines.join(";");
1123 }
1124 }
1125 output.code.insert_str(offset, &prelude);
1126}
1127
1128fn relocated_project_file(
1129 workspace: &Path,
1130 project: &CoverageProject,
1131 source: Option<&PathBuf>,
1132) -> Option<PathBuf> {
1133 let source = source?;
1134 let relative = source.strip_prefix(&project.root).ok()?;
1135 Some(workspace.join(relative))
1136}
1137
1138fn write_vitest_config(
1139 workspace: &Path,
1140 project: &CoverageProject,
1141 generated: &Path,
1142) -> Result<PathBuf, JavascriptFrontendError> {
1143 let path = generated.join("vitest.config.mjs");
1144 let original = relocated_project_file(workspace, project, project.vitest_config.as_ref())
1145 .map(|path| path.display().to_string());
1146 let original = serde_json::to_string(&original).map_err(JavascriptFrontendError::Serialize)?;
1147 let source = format!(
1148 "import {{ createRequire }} from 'node:module';\n\
1149 import {{ pathToFileURL }} from 'node:url';\n\
1150 // pnpm's strict layout does not hoist vite to the project root: it\n\
1151 // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
1152 // resolved from this generated file fails. Vitest depends on vite, so\n\
1153 // fall back to resolving it through vitest's own tree rather than\n\
1154 // requiring the project to hoist anything.\n\
1155 const supercovRequire = createRequire(import.meta.url);\n\
1156 const supercovLoadVite = async () => {{\n\
1157 try {{\n\
1158 return await import('vite');\n\
1159 }} catch (error) {{\n\
1160 let entry;\n\
1161 try {{\n\
1162 entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
1163 }} catch {{\n\
1164 throw error;\n\
1165 }}\n\
1166 return await import(pathToFileURL(entry).href);\n\
1167 }}\n\
1168 }};\n\
1169 const viteNamespace = await supercovLoadVite();\n\
1170 import {{ resolve }} from 'node:path';\n\
1171 import SupercovVitestReporter from './node_modules/vitestReporter.mjs';\n\
1172 import {{ supercovBrowserCommands, supercovViteInstrumentation }} from './viteInstrumentation.mjs';\n\
1173 const vite = viteNamespace.default ?? viteNamespace;\n\
1174 const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
1175 const discoveredConfig = {original};\n\
1176 // Browser Mode runs the test file in the browser, so the node setup --\n\
1177 // which reaches node:fs to write evidence -- cannot load there. Vite\n\
1178 // externalises it and the suite fails before collecting a test.\n\
1179 const supercovBrowserMode = (config) => Boolean(config?.test?.browser?.enabled);\n\
1180 const supercovSetupFile = (config) =>\n\
1181 supercovBrowserMode(config)\n\
1182 ? '.supercov/node_modules/vitestBrowser.mjs'\n\
1183 : '.supercov/node_modules/vitest.mjs';\n\
1184 // Evidence leaves the browser over Vitest's own command channel, so\n\
1185 // the command is registered only for the mode that uses it.\n\
1186 const supercovBrowserTest = (config) =>\n\
1187 supercovBrowserMode(config)\n\
1188 ? {{ browser: {{ commands: supercovBrowserCommands(process.cwd()) }} }}\n\
1189 : {{}};\n\
1190 // Child projects do not inherit root setupFiles. Resolve each project's\n\
1191 // environment after Vitest has loaded file/glob/inline configs and\n\
1192 // applied --project, preserving the runner's own config semantics.\n\
1193 const configureProjects = (projects) => {{\n\
1194 const nodeSetup = resolve(process.cwd(), '.supercov/node_modules/vitest.mjs');\n\
1195 const browserSetup = resolve(process.cwd(), '.supercov/node_modules/vitestBrowser.mjs');\n\
1196 for (const project of projects ?? []) {{\n\
1197 const test = project.config;\n\
1198 const browser = Boolean(test.browser?.enabled);\n\
1199 const setup = browser ? browserSetup : nodeSetup;\n\
1200 test.setupFiles = [setup, ...(test.setupFiles ?? []).filter(file => file !== nodeSetup && file !== browserSetup)];\n\
1201 test.maxConcurrency = 1;\n\
1202 if (browser) {{\n\
1203 const commands = supercovBrowserCommands(process.cwd());\n\
1204 test.browser.commands = {{ ...test.browser.commands, ...commands }};\n\
1205 // Browser instances can already exist when reporters initialize.\n\
1206 // Their parent generates the browser command exports.\n\
1207 const server = project.browser?.parent ?? project.browser;\n\
1208 if (server?.commands) Object.assign(server.commands, commands);\n\
1209 }}\n\
1210 }}\n\
1211 }};\n\
1212 // Vitest's coverage measures the instrumented copy, probes included:\n\
1213 // a suite at 100% read 87.5% of branches, and autoUpdate would write\n\
1214 // what it measured into this file. Coverage is still collected and\n\
1215 // reported (see register.mjs). Vitest 0.x kept thresholds in coverage.\n\
1216 const supercovThresholdKeys = ['thresholds', 'lines', 'functions', 'branches', 'statements', 'perFile', '100', 'thresholdAutoUpdate'];\n\
1217 const supercovSkipCoverageThresholds = (coverage) => {{\n\
1218 const configured = supercovThresholdKeys.filter((key) => coverage?.[key] !== undefined);\n\
1219 for (const key of configured) delete coverage[key];\n\
1220 const collecting = coverage?.enabled === true ||\n\
1221 process.argv.some((argument) => /^--coverage(?:\\.enabled)?(?:=true)?$/.test(argument));\n\
1222 if (configured.length && collecting) process.__SUPERCOV_SKIPPED_COVERAGE_THRESHOLDS__?.('Vitest');\n\
1223 }};\n\
1224 export default async function supercovVitestConfig(env) {{\n\
1225 const originalPath = process.env.SUPERCOV_ORIGINAL_VITEST_CONFIG || discoveredConfig;\n\
1226 const loaded = originalPath ? await loadConfigFromFile(env, originalPath, process.cwd()) : undefined;\n\
1227 const config = mergeConfig(loaded?.config ?? {{}}, {{\n\
1228 cacheDir: resolve(process.cwd(), '.supercov/vitest-cache'),\n\
1229 plugins: [supercovViteInstrumentation(process.cwd())],\n\
1230 test: {{ setupFiles: [resolve(process.cwd(), supercovSetupFile(loaded?.config))], maxConcurrency: 1, ...supercovBrowserTest(loaded?.config) }},\n\
1231 }});\n\
1232 supercovSkipCoverageThresholds(config.test?.coverage);\n\
1233 const configuredReporters = loaded?.config?.test?.reporters;\n\
1234 config.test ??= {{}};\n\
1235 config.test.reporters = configuredReporters\n\
1236 ? [...(Array.isArray(configuredReporters) ? configuredReporters : [configuredReporters]), new SupercovVitestReporter(configureProjects)]\n\
1237 : ['default', new SupercovVitestReporter(configureProjects)];\n\
1238 return config;\n\
1239 }}\n"
1240 );
1241 atomic_write(&path, source.as_bytes())?;
1242 Ok(path)
1243}
1244
1245fn configure_playwright_runtime(
1246 generated: &Path,
1247 project: &CoverageProject,
1248) -> Result<(), JavascriptFrontendError> {
1249 let adapter_path = generated.join("playwright.mjs");
1250 let mut adapter =
1251 fs::read_to_string(&adapter_path).map_err(|source| io_error(&adapter_path, source))?;
1252 adapter = adapter
1253 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module)
1254 .replace(
1255 "__SUPERCOV_PLAYWRIGHT_TEST_EXPORT__",
1256 &project.playwright_test_export,
1257 )
1258 .replace(
1262 "__SUPERCOV_PHASE_TIMING__",
1263 if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() == Ok("1") {
1264 "1"
1265 } else {
1266 "0"
1267 },
1268 );
1269 if project.playwright_module != "@playwright/test" {
1270 let facade = serde_json::to_string(&project.playwright_module)
1277 .expect("serializing a module specifier cannot fail");
1278 adapter = adapter.replace(
1279 "export * from \"@playwright/test\";",
1280 &format!("export * from {facade};"),
1281 );
1282 }
1283 let mut exports = Vec::new();
1284 if project.playwright_test_export != "test" {
1285 exports.push(format!(
1286 "export {{ instrumentedTest as {} }};",
1287 project.playwright_test_export
1288 ));
1289 }
1290 exports.extend(
1291 project
1292 .playwright_exports
1293 .iter()
1294 .filter(|name| {
1295 name.as_str() != "test"
1296 && name.as_str() != "expect"
1297 && *name != &project.playwright_test_export
1298 })
1299 .map(|name| {
1300 let encoded = serde_json::to_string(name)
1301 .expect("serializing a JavaScript export name cannot fail");
1302 format!("export const {name} = __supercovAdapterExport(adapter[{encoded}]);")
1303 }),
1304 );
1305 adapter = adapter.replace("/*__SUPERCOV_ADAPTER_EXPORTS__*/", &exports.join("\n"));
1306 atomic_write(&adapter_path, adapter.as_bytes())?;
1307
1308 let loader_path = generated.join("resolve-loader.mjs");
1309 let loader = fs::read_to_string(&loader_path)
1310 .map_err(|source| io_error(&loader_path, source))?
1311 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module);
1312 atomic_write(&loader_path, loader.as_bytes())
1313}
1314
1315fn write_playwright_config(
1316 workspace: &Path,
1317 project: &CoverageProject,
1318 generated: &Path,
1319) -> Result<PathBuf, JavascriptFrontendError> {
1320 let path = generated.join("playwright.config.mjs");
1321 let original = relocated_project_file(workspace, project, project.playwright_config.as_ref());
1322 let original_import = if let Some(original) = &original {
1323 let relative = original
1324 .strip_prefix(workspace)
1325 .map_err(|_| JavascriptFrontendError::UnsafeSourcePath(original.display().to_string()))?
1326 .to_string_lossy()
1327 .replace('\\', "/");
1328 let specifier = serde_json::to_string(&format!("../{relative}"))
1329 .map_err(JavascriptFrontendError::Serialize)?;
1330 format!("import original from {specifier};\n")
1331 } else {
1332 "const original = {};\n".into()
1333 };
1334 let source = format!(
1340 "import './node_modules/register.mjs';\n\
1341 import {{ dirname, isAbsolute, relative, resolve }} from 'node:path';\n\
1342 import {{ fileURLToPath }} from 'node:url';\n\
1343 {original_import}\
1344 const configExport = original && original.__esModule === true && Object.prototype.hasOwnProperty.call(original, 'default') ? original.default : original;\n\
1345 const resolvedValue = typeof configExport === 'function' ? await configExport({{ command: 'test', mode: 'test' }}) : configExport;\n\
1346 const resolved = resolvedValue ?? {{}};\n\
1347 const runtimeProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');\n\
1348 const originalDirectory = {};
1349 const sourceProjectRoot = process.env.SUPERCOV_SOURCE_PROJECT_ROOT;\n\
1350 const runtimePath = value => {{\n\
1351 if (!value) return value;\n\
1352 const absolute = isAbsolute(value) ? value : resolve(originalDirectory, value);\n\
1353 const local = relative(runtimeProjectRoot, absolute);\n\
1354 if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1355 if (sourceProjectRoot) {{\n\
1356 const sourceLocal = relative(sourceProjectRoot, absolute);\n\
1357 if (sourceLocal === '' || (!sourceLocal.startsWith('..') && !isAbsolute(sourceLocal))) return resolve(runtimeProjectRoot, sourceLocal);\n\
1358 }}\n\
1359 throw new Error('Supercov refuses a Playwright output/cwd outside the isolated project: ' + absolute);\n\
1360 }};\n\
1361 const normalizeWebServer = server => server ? ({{ ...server, cwd: runtimePath(server.cwd ?? originalDirectory) }}) : server;\n\
1362 const normalized = {{ ...resolved,\n\
1363 testDir: runtimePath(resolved.testDir),\n\
1364 outputDir: runtimePath(resolved.outputDir),\n\
1365 snapshotDir: runtimePath(resolved.snapshotDir),\n\
1366 projects: resolved.projects?.map(project => ({{ ...project, testDir: runtimePath(project.testDir), outputDir: runtimePath(project.outputDir), snapshotDir: runtimePath(project.snapshotDir) }})),\n\
1367 webServer: Array.isArray(resolved.webServer) ? resolved.webServer.map(normalizeWebServer) : normalizeWebServer(resolved.webServer),\n\
1368 }};\n\
1369 const configuredReporters = normalized.reporter;\n\
1370 const reporters = configuredReporters\n\
1371 ? (typeof configuredReporters === 'string' ? [[configuredReporters]] : (Array.isArray(configuredReporters[0]) ? configuredReporters : [configuredReporters]))\n\
1372 : [['list']];\n\
1373 const coverageReporter = resolve(runtimeProjectRoot, '.supercov/node_modules/playwrightReporter.mjs');\n\
1374 export default {{ ...normalized, reporter: [...reporters, [coverageReporter]] }};\n",
1375 serde_json::to_string(
1376 &original
1377 .as_ref()
1378 .and_then(|path| path.parent())
1379 .unwrap_or(workspace)
1380 .display()
1381 .to_string()
1382 )
1383 .map_err(JavascriptFrontendError::Serialize)?
1384 );
1385 atomic_write(&path, source.as_bytes())?;
1386 Ok(path)
1387}
1388
1389fn write_vite_config(
1390 workspace: &Path,
1391 generated: &Path,
1392) -> Result<PathBuf, JavascriptFrontendError> {
1393 let path = generated.join("vite.config.mjs");
1394 let workspace = serde_json::to_string(&workspace.display().to_string())
1395 .map_err(JavascriptFrontendError::Serialize)?;
1396 let source = format!(
1397 "import {{ createRequire }} from 'node:module';\n\
1398 import {{ pathToFileURL }} from 'node:url';\n\
1399 // pnpm's strict layout does not hoist vite to the project root: it\n\
1400 // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
1401 // resolved from this generated file fails. Vitest depends on vite, so\n\
1402 // fall back to resolving it through vitest's own tree rather than\n\
1403 // requiring the project to hoist anything.\n\
1404 const supercovRequire = createRequire(import.meta.url);\n\
1405 const supercovLoadVite = async () => {{\n\
1406 try {{\n\
1407 return await import('vite');\n\
1408 }} catch (error) {{\n\
1409 let entry;\n\
1410 try {{\n\
1411 entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
1412 }} catch {{\n\
1413 throw error;\n\
1414 }}\n\
1415 return await import(pathToFileURL(entry).href);\n\
1416 }}\n\
1417 }};\n\
1418 const viteNamespace = await supercovLoadVite();\n\
1419 import {{ isAbsolute, relative, resolve }} from 'node:path';\n\
1420 import {{ supercovViteInstrumentation }} from './viteInstrumentation.mjs';\n\
1421 const vite = viteNamespace.default ?? viteNamespace;\n\
1422 const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
1423 export default async function supercovViteConfig(env) {{\n\
1424 const isolatedRoot = {workspace};\n\
1425 const loaded = await loadConfigFromFile(env, undefined, isolatedRoot);\n\
1426 const config = loaded?.config ?? {{}};\n\
1427 const relocate = (value, label) => {{\n\
1428 const absolute = isAbsolute(value) ? value : resolve(isolatedRoot, value);\n\
1429 const local = relative(isolatedRoot, absolute);\n\
1430 if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1431 throw new Error('Supercov refuses ' + label + ' outside the isolated project: ' + absolute);\n\
1432 }};\n\
1433 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\
1434 const rollupOutput = config.build?.rollupOptions?.output;\n\
1435 const safe = {{ ...config,\n\
1436 logLevel: ['1', 'true', 'yes'].includes(process.env.SUPERCOV_VERBOSE ?? process.env.SUPERCOV_DEBUG ?? '') ? config.logLevel : 'error',\n\
1437 cacheDir: resolve(isolatedRoot, '.supercov/vite-cache'),\n\
1438 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\
1439 }};\n\
1440 return mergeConfig(safe, {{ plugins: [supercovViteInstrumentation(isolatedRoot)] }});\n\
1441 }}\n"
1442 );
1443 atomic_write(&path, source.as_bytes())?;
1444 Ok(path)
1445}
1446
1447fn write_vite_transforms(
1448 generated: &Path,
1449 transforms: &BTreeMap<String, ViteTransform>,
1450) -> Result<(), JavascriptFrontendError> {
1451 let mut payload = serde_json::to_vec(transforms).map_err(JavascriptFrontendError::Serialize)?;
1452 payload.push(b'\n');
1453 atomic_write(&generated.join("vite-transforms.json"), &payload)?;
1454 let adapter = "import { createHash } from 'node:crypto';\n\
1455import { mkdirSync, readFileSync } from 'node:fs';\n\
1456import { relative, resolve, sep } from 'node:path';\n\
1457import { atomicWriteFileSync } from './node_modules/atomic.mjs';\n\
1458import { inferTestProvenance } from './node_modules/provenance.mjs';\n\
1459const transforms = JSON.parse(readFileSync(new URL('./vite-transforms.json', import.meta.url), 'utf8'));\n\
1460const sha256 = value => createHash('sha256').update(value).digest('hex');\n\
1461// Browser Mode runs the test file in the browser, where none of this is\n\
1462// reachable: no node:fs to write evidence, no project root to relativise a\n\
1463// path against, no env to read. Vitest's own browser command channel carries\n\
1464// what the browser observed to node, which completes the same payload the node\n\
1465// setup writes -- so one contract keeps one implementation. The command is\n\
1466// awaited as part of the test, and node learns the test file from Vitest\n\
1467// rather than from the browser realm, which cannot be trusted to name it.\n\
1468const supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/;\n\
1469export function supercovBrowserCommands(root) {\n\
1470 return {\n\
1471 __supercovEvidence(context, payload, suffix) {\n\
1472 const evidenceDirectory = process.env['SUPERCOV_EVIDENCE_DIR'];\n\
1473 if (!evidenceDirectory) return;\n\
1474 // A suffix names one directory under the evidence directory. Anything\n\
1475 // else is refused rather than resolved, so a malformed one cannot send\n\
1476 // the write outside the directory it is supposed to stay in.\n\
1477 if (typeof suffix !== 'string' || !supercovEvidenceSuffix.test(suffix))\n\
1478 throw new Error('Supercov refused an unusable evidence suffix: ' + suffix);\n\
1479 const absolute = context.testPath;\n\
1480 const testFile = absolute ? relative(root, absolute).split(sep).join('/') : undefined;\n\
1481 const projectName = payload.projectName ?? context.project?.name;\n\
1482 delete payload.projectName;\n\
1483 // The browser has no environment to read, so the run identity is stamped\n\
1484 // here rather than sent from a realm that cannot know it.\n\
1485 const runId = process.env['SUPERCOV_RUN_ID'];\n\
1486 if (runId && payload.scope) payload.scope.runId = runId;\n\
1487 const complete = Object.assign({}, payload, testFile ? { testFile } : {}, {\n\
1488 provenance: inferTestProvenance({\n\
1489 runner: 'vitest',\n\
1490 file: testFile,\n\
1491 project: projectName,\n\
1492 explicitKind: process.env['SUPERCOV_TEST_KIND'],\n\
1493 }),\n\
1494 });\n\
1495 const directory = resolve(root, evidenceDirectory, suffix);\n\
1496 mkdirSync(directory, { recursive: true });\n\
1497 atomicWriteFileSync(resolve(directory, 'mcdc.json'), JSON.stringify(complete) + '\\n');\n\
1498 },\n\
1499 };\n\
1500}\n\
1501export function supercovViteInstrumentation(root) {\n\
1502 const runtimePath = resolve(root, '.supercov/node_modules/applicationRuntime.mjs');\n\
1503 return {\n\
1504 name: 'supercov-rust-instrumentation',\n\
1505 enforce: 'pre',\n\
1506 resolveId(id) { return id === 'virtual:supercov-runtime' ? runtimePath : null; },\n\
1507 transform(code, rawId) {\n\
1508 const id = rawId.split('?')[0] ?? rawId;\n\
1509 const local = relative(root, id).split(sep).join('/');\n\
1510 const transformed = transforms[local];\n\
1511 if (!transformed) return null;\n\
1512 if (sha256(code) !== transformed.sourceSha256)\n\
1513 throw new Error('Supercov source changed before Rust instrumentation: ' + local);\n\
1514 return { code: transformed.code, map: transformed.map ?? null };\n\
1515 },\n\
1516 };\n\
1517}\n";
1518 atomic_write(
1519 &generated.join("viteInstrumentation.mjs"),
1520 adapter.as_bytes(),
1521 )
1522}
1523
1524pub fn prepare_javascript_frontend(
1527 workspace: &Path,
1528 project: &CoverageProject,
1529 collector_id: &str,
1530 cache_key: &str,
1531 command: &[String],
1532) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
1533 let generated = workspace.join(".supercov");
1534 let runtime_directory = generated.join("node_modules");
1540 timed(&SETUP.runtime_ns, || {
1541 copy_runtime(&runtime_directory, collector_id)
1542 })?;
1543 let configuration_started = Instant::now();
1544 configure_playwright_runtime(&runtime_directory, project)?;
1545 let playwright_config_path = write_playwright_config(workspace, project, &generated)?;
1546 let vite_config_path = write_vite_config(workspace, &generated)?;
1547 let vitest_config_path = write_vitest_config(workspace, project, &generated)?;
1548 account(&SETUP.config_ns, configuration_started);
1549
1550 let mut exclusions = Vec::new();
1551 let mut decisions = BTreeMap::new();
1552 let mut points = BTreeMap::new();
1553 let mut branches = BTreeMap::new();
1554 let mut limitations = BTreeMap::new();
1555 let mut vite_transforms = BTreeMap::new();
1556 for limitation in &project.source_limitations {
1557 limitations.insert(limitation.id.clone(), limitation_from_source(limitation));
1558 }
1559
1560 let runtime_path = runtime_directory.join("runtime.mjs");
1561 let standalone_runtime =
1562 fs::read_to_string(&runtime_path).map_err(|source| io_error(&runtime_path, source))?;
1563 let browser_suite = project.playwright_config.is_some() || project.browser_runner;
1568 let mut authored = BTreeSet::new();
1569 let sources_started = Instant::now();
1570 for file in &project.source_files {
1571 let path = checked_source_path(workspace, file)?;
1572 let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
1573 let capability_wrapper = runtime_specifier(file, "capability.mjs")?;
1574 let elide = crate::typescript_imports::elides_type_imports(
1575 workspace,
1576 file,
1577 command,
1578 &project.build_command,
1579 );
1580 let instrumented = timed(&SETUP.instrument_ns, || {
1581 instrument_with_import_policy(
1582 &source,
1583 file,
1584 &capability_wrapper,
1585 project.build_adapter == BuildAdapter::Direct,
1586 elide,
1587 )
1588 });
1589 let mut output = match instrumented {
1590 Ok(output) => output,
1591 Err(crate::js_instrumenter::CandidateError::Parse(messages)) => {
1595 let limitation = CandidateLimitation {
1596 id: format!("javascript-file-not-parsed#{file}"),
1597 kind: "source-scope".into(),
1598 file: file.clone(),
1599 line: 1,
1600 column: 0,
1601 source: String::new(),
1602 reason: format!(
1603 "Supercov could not parse this file ({}); it runs as written and nothing in it is measured",
1604 messages.join("; ")
1605 ),
1606 };
1607 limitations.insert(limitation.id.clone(), limitation);
1608 continue;
1609 }
1610 Err(source) => {
1611 return Err(JavascriptFrontendError::Instrument {
1612 file: file.clone(),
1613 source,
1614 });
1615 }
1616 };
1617 if project.build_adapter == BuildAdapter::Generic {
1618 let runtime = generic_runtime_binding(workspace, project, &path, &generated)?;
1619 output.code = output.code.replace("virtual:supercov-runtime", &runtime);
1620 }
1621 bootstrap_runtime(&mut output, &standalone_runtime, browser_suite);
1622 if project.build_adapter == BuildAdapter::Direct && !browser_suite {
1623 bootstrap_loader(&mut output, file)?;
1624 }
1625 if project.build_adapter != BuildAdapter::Vite
1632 && matches!(
1633 path.extension().and_then(|value| value.to_str()),
1634 Some("ts" | "tsx" | "mts" | "cts")
1635 )
1636 {
1637 output.code = generated_source_banner(&output.code);
1638 }
1639 if project.build_adapter == BuildAdapter::Vite {
1640 vite_transforms.insert(
1641 file.clone(),
1642 ViteTransform {
1643 source_sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
1644 code: output.code.clone(),
1645 map: output.map.clone(),
1646 },
1647 );
1648 } else {
1649 let code = match inline_instrumentation_map(
1656 &output.code,
1657 output.map.as_ref(),
1658 &project.root.join(file),
1659 &source,
1660 ) {
1661 Some(code) => code,
1662 None => output.code.clone(),
1663 };
1664 keep_authored(workspace, &mut authored, file, &source)?;
1665 atomic_write(&path, code.as_bytes())?;
1666 }
1667 exclusions.extend(output.excluded_statements);
1668 for value in output.decisions {
1669 decisions.insert(value.id.clone(), value);
1670 }
1671 for value in output.points {
1672 points.insert(value.id.clone(), value);
1673 }
1674 for value in output.branches {
1675 branches.insert(value.id.clone(), value);
1676 }
1677 for value in output.coverage_limitations {
1678 limitations.insert(value.id.clone(), value);
1679 }
1680 }
1681 account(&SETUP.sources_ns, sources_started);
1682 write_vite_transforms(&generated, &vite_transforms)?;
1683
1684 let assertions_started = Instant::now();
1685 let mut assertion_calls = 0;
1686 for entry in &project.source_scope.entries {
1687 let path = checked_source_path(workspace, &entry.file)?;
1688 let Ok(source) = fs::read_to_string(&path) else {
1689 continue;
1690 };
1691 let capability_wrapper = (!project.source_files.contains(&entry.file))
1692 .then(|| runtime_specifier(&entry.file, "capability.mjs"))
1693 .transpose()?;
1694 let assertion_runtime = runtime_specifier(&entry.file, "bootstrap.cjs")?;
1695 let output =
1696 match crate::js_instrumenter::instrument_node_assertion_phases_with_runtime_imports(
1697 &source,
1698 &entry.file,
1699 std::slice::from_ref(&project.playwright_module),
1700 capability_wrapper.as_deref(),
1701 Some(&assertion_runtime),
1702 ) {
1703 Ok(output) => output,
1704 Err(crate::js_instrumenter::CandidateError::Parse(_)) => continue,
1707 Err(source) => {
1708 return Err(JavascriptFrontendError::Instrument {
1709 file: entry.file.clone(),
1710 source,
1711 });
1712 }
1713 };
1714 let coverage_transformed_by_vite = project.build_adapter == BuildAdapter::Vite
1715 && project.source_files.contains(&entry.file);
1716 if (output.assertions > 0 || output.capability_imports > 0) && !coverage_transformed_by_vite
1717 {
1718 keep_authored(workspace, &mut authored, &entry.file, &source)?;
1719 atomic_write(&path, output.code.as_bytes())?;
1720 assertion_calls += output.assertions;
1721 }
1722 }
1723
1724 account(&SETUP.assertion_ns, assertions_started);
1725
1726 let mut manifest = JavascriptManifest {
1727 decisions: decisions.into_values().collect(),
1728 points: points.into_values().collect(),
1729 branches: branches.into_values().collect(),
1730 limitations: limitations.into_values().collect(),
1731 scope: project.source_scope.clone(),
1732 };
1733 manifest.decisions.sort_by_key(|value| {
1734 (
1735 value.file.clone(),
1736 value.line,
1737 value.column,
1738 value.id.clone(),
1739 )
1740 });
1741 manifest.points.sort_by_key(|value| {
1742 (
1743 value.file.clone(),
1744 value.line,
1745 value.column,
1746 value.id.clone(),
1747 )
1748 });
1749 manifest.branches.sort_by_key(|value| {
1750 (
1751 value.file.clone(),
1752 value.line,
1753 value.column,
1754 value.id.clone(),
1755 )
1756 });
1757 manifest.limitations.sort_by_key(|value| {
1758 (
1759 value.file.clone(),
1760 value.line,
1761 value.column,
1762 value.id.clone(),
1763 )
1764 });
1765
1766 atomic_write(
1767 &workspace.join(AUTHORED_LIST),
1768 &serde_json::to_vec(&authored).map_err(JavascriptFrontendError::Serialize)?,
1769 )?;
1770 atomic_write(
1771 &generated.join("statement-exclusions.json"),
1772 &serde_json::to_vec(&exclusions).map_err(JavascriptFrontendError::Serialize)?,
1773 )?;
1774 let manifest_path = generated.join("manifest.json");
1775 let mut encoded =
1776 serde_json::to_vec_pretty(&manifest).map_err(JavascriptFrontendError::Serialize)?;
1777 encoded.push(b'\n');
1778 atomic_write(&manifest_path, &encoded)?;
1779 atomic_write(
1780 &generated.join("instrumentation-complete"),
1781 b"coverage-completeness-v2\n",
1782 )?;
1783 timed(&SETUP.cache_ns, || {
1784 write_javascript_frontend_cache(workspace, project, cache_key, assertion_calls)
1785 })?;
1786 Ok(PreparedJavascriptFrontend {
1787 manifest,
1788 manifest_path,
1789 preload_path: generated.join("node_modules/register.mjs"),
1790 playwright_config_path,
1791 vite_config_path,
1792 vitest_config_path,
1793 assertion_calls,
1794 })
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799 #[test]
1800 fn ancestor_description_names_each_component_and_its_state() {
1801 let root = std::env::temp_dir().join(format!("supercov-ancestors-{}", unique()));
1802 fs::create_dir_all(root.join("present")).unwrap();
1803 fs::write(root.join("present/file.txt"), b"x").unwrap();
1804 let described = super::describe_ancestors(&root.join("present/file.txt/child"));
1805 assert!(described.contains("present=dir"), "{described}");
1806 assert!(described.contains("file.txt=file"), "{described}");
1807 assert!(described.ends_with("child=missing"), "{described}");
1808 fs::remove_dir_all(&root).unwrap();
1809 }
1810
1811 use super::*;
1812 use crate::project_discovery::discover_coverage_project;
1813
1814 fn temporary(name: &str) -> PathBuf {
1815 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1816 .join("../../target/supercov-test-fixtures")
1817 .join(format!("javascript-frontend-{name}-{}", unique()));
1818 fs::create_dir_all(&path).unwrap();
1824 crate::workspace::canonicalize_simplified(path).unwrap()
1825 }
1826
1827 #[test]
1828 fn browser_mode_gets_a_browser_setup_and_the_command_that_carries_its_evidence() {
1829 let generated = temporary("browser-mode-config");
1830 write_vite_transforms(&generated, &BTreeMap::new()).unwrap();
1831 let adapter = fs::read_to_string(generated.join("viteInstrumentation.mjs")).unwrap();
1832 assert!(adapter.contains("export function supercovBrowserCommands(root)"));
1836 assert!(adapter.contains("__supercovEvidence(context, payload, suffix)"));
1837 assert!(adapter.contains("const absolute = context.testPath;"));
1838 assert!(!adapter.contains("configureServer"), "{adapter}");
1839 assert!(!adapter.contains("__supercov/evidence"), "{adapter}");
1840 assert!(adapter.contains("supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/"));
1843 assert!(adapter.contains("Supercov refused an unusable evidence suffix"));
1844 }
1845
1846 #[test]
1847 fn a_browser_mode_project_loads_the_setup_that_can_run_in_a_browser() {
1848 let workspace = temporary("browser-mode-setup");
1851 let generated = workspace.join(".supercov");
1852 fs::create_dir_all(&generated).unwrap();
1853 fs::write(workspace.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1854 fs::create_dir_all(workspace.join("src")).unwrap();
1855 fs::write(
1856 workspace.join("src/example.mjs"),
1857 "export const one = () => 1;\n",
1858 )
1859 .unwrap();
1860 let project = discover_coverage_project(
1861 &workspace,
1862 &BTreeMap::new(),
1863 &["npx".into(), "vitest".into(), "run".into()],
1864 )
1865 .unwrap();
1866 let path = write_vitest_config(&workspace, &project, &generated).unwrap();
1867 let config = fs::read_to_string(path).unwrap();
1868 assert!(config.contains("config?.test?.browser?.enabled"));
1869 assert!(config.contains("'.supercov/node_modules/vitestBrowser.mjs'"));
1870 assert!(config.contains("'.supercov/node_modules/vitest.mjs'"));
1871 assert!(config.contains("browser: { commands: supercovBrowserCommands(process.cwd()) }"));
1873 assert!(config.contains("...supercovBrowserTest(loaded?.config)"));
1874 }
1875
1876 #[test]
1877 fn runtime_isolation_replaces_only_the_assignment_marker() {
1878 let source = concat!(
1879 "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n",
1880 "const selected = runtimeInstanceToken === \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\";\n"
1881 );
1882 let isolated = isolate_runtime(source, "collector-123").unwrap();
1883 assert!(isolated.contains("runtimeInstanceToken = \"collector-123\""));
1884 assert!(isolated.contains("=== \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\""));
1885 }
1886
1887 #[test]
1888 fn copied_runtime_does_not_reference_unshipped_source_maps() {
1889 let generated = temporary("runtime-source-maps");
1890 copy_runtime(&generated, "collector-test").unwrap();
1891 for name in ["vitest.mjs", "provenance.mjs", "atomic.mjs"] {
1892 let contents = fs::read_to_string(generated.join(name)).unwrap();
1893 assert!(
1894 !contents.contains("sourceMappingURL"),
1895 "runtime shim retained a source-map directive: {name}"
1896 );
1897 }
1898 fs::remove_dir_all(generated).unwrap();
1899 }
1900
1901 #[test]
1902 fn prepares_sorted_complete_manifest_without_touching_source_project() {
1903 let source_root = temporary("source");
1904 let workspace = temporary("workspace");
1905 fs::create_dir_all(source_root.join("src")).unwrap();
1906 fs::write(
1907 source_root.join("src/example.mjs"),
1908 "export function value(a, b) { if (a || b) return 1; return 0; }\n",
1909 )
1910 .unwrap();
1911 fs::write(source_root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1912 fs::create_dir_all(workspace.join("src")).unwrap();
1913 fs::create_dir_all(workspace.join(".supercov")).unwrap();
1914 fs::copy(
1915 source_root.join("src/example.mjs"),
1916 workspace.join("src/example.mjs"),
1917 )
1918 .unwrap();
1919 let project = discover_coverage_project(
1920 &source_root,
1921 &BTreeMap::new(),
1922 &["node".into(), "--test".into()],
1923 )
1924 .unwrap();
1925 let original = fs::read_to_string(source_root.join("src/example.mjs")).unwrap();
1926 let prepared =
1927 prepare_javascript_frontend(&workspace, &project, "collector-test", "cache-test", &[])
1928 .unwrap();
1929 assert_eq!(
1930 fs::read_to_string(source_root.join("src/example.mjs")).unwrap(),
1931 original
1932 );
1933 let transformed = fs::read_to_string(workspace.join("src/example.mjs")).unwrap();
1934 assert!(transformed.contains("__SUPERCOV_DIRECT_RUNTIME__"));
1935 assert_eq!(prepared.manifest.decisions.len(), 1);
1936 assert!(!prepared.manifest.points.is_empty());
1937 assert_eq!(prepared.manifest.scope, project.source_scope);
1938 assert!(prepared.manifest_path.is_file());
1939 assert!(prepared.preload_path.is_file());
1940 assert!(prepared.playwright_config_path.is_file());
1941 assert!(prepared.vite_config_path.is_file());
1942 assert!(
1943 fs::read_to_string(&prepared.vite_config_path)
1944 .unwrap()
1945 .contains("logLevel: ['1', 'true', 'yes'].includes")
1946 );
1947 assert!(prepared.vitest_config_path.is_file());
1948 assert_eq!(prepared.assertion_calls, 0);
1949 let cache = read_javascript_frontend_cache(&workspace, "cache-test").unwrap();
1950 assert_eq!(
1951 javascript_frontend_reuse_paths(&cache),
1952 [
1953 PathBuf::from(".supercov/frontend-cache.json"),
1954 PathBuf::from(".supercov/frontend-cache-artifacts"),
1955 ]
1956 );
1957 assert!(
1958 cache
1959 .artifacts
1960 .iter()
1961 .all(|artifact| !artifact.cache_file.contains("src/")
1962 && !artifact.cache_file.contains("tests/"))
1963 );
1964 fs::write(workspace.join("src/example.mjs"), &original).unwrap();
1965 fs::remove_file(&prepared.manifest_path).unwrap();
1966 let restored = load_cached_javascript_frontend(&workspace, &cache).unwrap();
1967 assert_eq!(restored.manifest, prepared.manifest);
1968 assert_eq!(
1969 fs::read_to_string(workspace.join("src/example.mjs")).unwrap(),
1970 transformed
1971 );
1972 fs::write(workspace.join(&cache.artifacts[0].cache_file), "corrupt").unwrap();
1973 assert!(read_javascript_frontend_cache(&workspace, "cache-test").is_none());
1974 fs::remove_dir_all(source_root).unwrap();
1975 fs::remove_dir_all(workspace).unwrap();
1976 }
1977
1978 #[test]
1979 fn embedded_runtime_contains_every_declared_shim() {
1980 for name in RUNTIME_FILES {
1981 let bytes = embedded_runtime(name).unwrap();
1982 assert!(!bytes.is_empty(), "embedded runtime is empty: {name}");
1983 }
1984 assert!(
1985 std::str::from_utf8(embedded_runtime("runtime.mjs").unwrap())
1986 .unwrap()
1987 .contains(RUNTIME_INSTANCE_MARKER)
1988 );
1989 }
1990}