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::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 CHANGED_DIRECTORY: &str = ".supercov/node_modules/.changed";
39const RUNTIME_FILES: &[&str] = &[
40 "atomic.mjs",
41 "ava.mjs",
42 "bootstrap.cjs",
43 "capability.mjs",
44 "jest.cjs",
45 "jest.config.mjs",
46 "jestReporter.mjs",
47 "jestRuntime.cjs",
48 "launchSupervisor.mjs",
49 "mocha.mjs",
50 "nodeAssert.mjs",
51 "nodeAssertAdapter.mjs",
52 "nodeAssertStrict.mjs",
53 "nodeTest.mjs",
54 "playwright.mjs",
55 "playwrightReporter.mjs",
56 "provenance.mjs",
57 "register.mjs",
58 "resolve-loader.mjs",
59 "runnerEvidence.mjs",
60 "runtime.mjs",
61 "tap.mjs",
62 "transport.mjs",
63 "vitest.mjs",
64 "vitestBrowser.mjs",
65 "vitestReporter.mjs",
66];
67static UNIQUE: AtomicU64 = AtomicU64::new(0);
68
69struct SetupAccounting {
82 files: AtomicU64,
83 bytes: AtomicU64,
84 create_ns: AtomicU64,
85 write_ns: AtomicU64,
86 rename_ns: AtomicU64,
87 directories: AtomicU64,
88 directory_retries: AtomicU64,
89 directory_ns: AtomicU64,
90 runtime_ns: AtomicU64,
91 config_ns: AtomicU64,
92 sources_ns: AtomicU64,
93 instrument_ns: AtomicU64,
94 assertion_ns: AtomicU64,
95 cache_ns: AtomicU64,
96}
97
98impl SetupAccounting {
99 const fn new() -> Self {
100 Self {
101 files: AtomicU64::new(0),
102 bytes: AtomicU64::new(0),
103 create_ns: AtomicU64::new(0),
104 write_ns: AtomicU64::new(0),
105 rename_ns: AtomicU64::new(0),
106 directories: AtomicU64::new(0),
107 directory_retries: AtomicU64::new(0),
108 directory_ns: AtomicU64::new(0),
109 runtime_ns: AtomicU64::new(0),
110 config_ns: AtomicU64::new(0),
111 sources_ns: AtomicU64::new(0),
112 instrument_ns: AtomicU64::new(0),
113 assertion_ns: AtomicU64::new(0),
114 cache_ns: AtomicU64::new(0),
115 }
116 }
117}
118
119static SETUP: SetupAccounting = SetupAccounting::new();
120
121fn account(counter: &AtomicU64, started: Instant) {
122 counter.fetch_add(started.elapsed().as_nanos() as u64, Ordering::Relaxed);
123}
124
125fn accounted_ms(counter: &AtomicU64) -> f64 {
126 counter.load(Ordering::Relaxed) as f64 / 1_000_000.0
127}
128
129fn counted(counter: &AtomicU64) -> u64 {
130 counter.load(Ordering::Relaxed)
131}
132
133fn timed<T>(counter: &AtomicU64, operation: impl FnOnce() -> T) -> T {
134 let started = Instant::now();
135 let value = operation();
136 account(counter, started);
137 value
138}
139
140pub fn setup_timing_detail() -> Option<String> {
143 if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() != Ok("1") {
144 return None;
145 }
146 Some(format!(
147 "setup detail runtime={:.1}ms configs={:.1}ms sources={:.1}ms (instrument={:.1}ms) \
148assertions={:.1}ms cache={:.1}ms | files={} bytes={} create={:.1}ms write={:.1}ms \
149rename={:.1}ms | directories={} retries={} directory-wait={:.1}ms",
150 accounted_ms(&SETUP.runtime_ns),
151 accounted_ms(&SETUP.config_ns),
152 accounted_ms(&SETUP.sources_ns),
153 accounted_ms(&SETUP.instrument_ns),
154 accounted_ms(&SETUP.assertion_ns),
155 accounted_ms(&SETUP.cache_ns),
156 counted(&SETUP.files),
157 counted(&SETUP.bytes),
158 accounted_ms(&SETUP.create_ns),
159 accounted_ms(&SETUP.write_ns),
160 accounted_ms(&SETUP.rename_ns),
161 counted(&SETUP.directories),
162 counted(&SETUP.directory_retries),
163 accounted_ms(&SETUP.directory_ns),
164 ))
165}
166
167#[derive(Debug)]
168pub enum JavascriptFrontendError {
169 Io {
170 path: PathBuf,
171 source: io::Error,
172 },
173 Instrument {
174 file: String,
175 source: CandidateError,
176 },
177 MissingRuntimeMarker,
178 Serialize(serde_json::Error),
179 UnsafeSourcePath(String),
180}
181
182impl std::fmt::Display for JavascriptFrontendError {
183 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 match self {
185 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
186 Self::Instrument { file, source } => {
187 write!(formatter, "failed to instrument {file}: {source}")
188 }
189 Self::MissingRuntimeMarker => write!(
190 formatter,
191 "generated Supercov runtime is missing its instance marker"
192 ),
193 Self::Serialize(error) => write!(formatter, "failed to serialize manifest: {error}"),
194 Self::UnsafeSourcePath(file) => write!(formatter, "unsafe source path: {file}"),
195 }
196 }
197}
198
199impl std::error::Error for JavascriptFrontendError {}
200
201fn io_error(path: &Path, source: io::Error) -> JavascriptFrontendError {
202 JavascriptFrontendError::Io {
203 path: path.to_owned(),
204 source,
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase", deny_unknown_fields)]
210pub struct JavascriptManifest {
211 pub decisions: Vec<CandidateDecision>,
212 pub points: Vec<CandidatePoint>,
213 pub branches: Vec<CandidateBranch>,
214 pub limitations: Vec<CandidateLimitation>,
215 pub scope: SourceScope,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct PreparedJavascriptFrontend {
220 pub manifest: JavascriptManifest,
221 pub manifest_path: PathBuf,
222 pub preload_path: PathBuf,
223 pub playwright_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 vitest_config_path: generated.join("vitest.config.mjs"),
359 assertion_calls: cache.assertion_calls,
360 })
361}
362
363fn frontend_artifact_paths(workspace: &Path, project: &CoverageProject) -> Vec<String> {
364 let mut artifacts = vec![
365 ".supercov/node_modules/package.json".to_owned(),
366 ".supercov/node_modules/runtime.d.mts".to_owned(),
367 ".supercov/playwright.config.mjs".to_owned(),
368 ".supercov/vitest.config.mjs".to_owned(),
369 ".supercov/vitestCommands.mjs".to_owned(),
370 ".supercov/manifest.json".to_owned(),
371 ".supercov/statement-exclusions.json".to_owned(),
372 ".supercov/instrumentation-complete".to_owned(),
373 ];
374 artifacts.extend(
375 RUNTIME_FILES
376 .iter()
377 .map(|name| format!(".supercov/node_modules/{name}")),
378 );
379 artifacts.extend(project.source_files.iter().cloned());
384 artifacts.extend(
385 project
386 .source_scope
387 .entries
388 .iter()
389 .map(|entry| entry.file.clone()),
390 );
391 for root in &project.source_roots {
392 let host = if workspace.join(root).is_file() {
393 Path::new(root).parent().unwrap_or_else(|| Path::new(""))
394 } else {
395 Path::new(root)
396 };
397 for name in ["package.json", "runtime.mjs", "runtime.d.mts"] {
398 let path = host.join(".supercov/node_modules").join(name);
399 if let Some(path) = path.to_str() {
400 artifacts.push(path.replace('\\', "/"));
401 }
402 }
403 }
404 let rewritten = project
405 .source_files
406 .iter()
407 .chain(project.source_scope.entries.iter().map(|entry| &entry.file))
408 .map(|file| format!("{AUTHORED_DIRECTORY}/{file}"))
409 .collect::<Vec<_>>();
410 artifacts.extend(rewritten);
411 artifacts.push(AUTHORED_LIST.to_owned());
412 artifacts.sort();
413 artifacts.dedup();
414 artifacts.retain(|path| regular_file(workspace, path));
415 artifacts
416}
417
418pub fn authored_path(workspace: &Path, file: &Path) -> PathBuf {
420 workspace.join(AUTHORED_DIRECTORY).join(file)
421}
422
423pub fn changed_path(workspace: &Path, file: &Path) -> PathBuf {
426 workspace.join(CHANGED_DIRECTORY).join(file)
427}
428
429pub fn changed_directory(workspace: &Path) -> PathBuf {
431 workspace.join(CHANGED_DIRECTORY)
432}
433
434pub fn rewritten_files(workspace: &Path) -> Vec<String> {
436 fs::read(workspace.join(AUTHORED_LIST))
437 .ok()
438 .and_then(|contents| serde_json::from_slice(&contents).ok())
439 .unwrap_or_default()
440}
441
442fn keep_authored(
444 workspace: &Path,
445 authored: &mut BTreeSet<String>,
446 file: &str,
447 source: &str,
448) -> Result<(), JavascriptFrontendError> {
449 if authored.insert(file.to_owned()) {
450 let path = checked_source_path(&workspace.join(AUTHORED_DIRECTORY), file)?;
451 if let Some(parent) = path.parent() {
452 create_directory_all(parent)?;
453 }
454 atomic_write(&path, source.as_bytes())?;
455 }
456 Ok(())
457}
458
459fn write_javascript_frontend_cache(
460 workspace: &Path,
461 project: &CoverageProject,
462 key: &str,
463 assertion_calls: usize,
464) -> Result<(), JavascriptFrontendError> {
465 let cache_directory = workspace.join(FRONTEND_CACHE_DIRECTORY);
466 create_directory_all(&cache_directory)?;
467 let mut artifacts = Vec::new();
468 for path in frontend_artifact_paths(workspace, project) {
469 let contents = fs::read(workspace.join(&path))
470 .map_err(|source| io_error(&workspace.join(&path), source))?;
471 let sha256 = format!("{:x}", Sha256::digest(&contents));
472 let cache_file = format!("{FRONTEND_CACHE_DIRECTORY}/{sha256}");
473 let destination = workspace.join(&cache_file);
474 if !destination.is_file() {
475 atomic_write(&destination, &contents)?;
476 }
477 artifacts.push(JavascriptFrontendCacheArtifact {
478 path,
479 cache_file,
480 sha256,
481 });
482 }
483 let cache = JavascriptFrontendCache {
484 schema_version: FRONTEND_CACHE_SCHEMA_VERSION,
485 key: key.to_owned(),
486 assertion_calls,
487 artifacts,
488 };
489 let mut encoded =
490 serde_json::to_vec_pretty(&cache).map_err(JavascriptFrontendError::Serialize)?;
491 encoded.push(b'\n');
492 atomic_write(&workspace.join(FRONTEND_CACHE_FILE), &encoded)
493}
494
495fn embedded_runtime(name: &str) -> Option<&'static [u8]> {
496 match name {
497 "atomic.mjs" => Some(include_bytes!("../runtime-assets/javascript/atomic.mjs")),
498 "ava.mjs" => Some(include_bytes!("../runtime-assets/javascript/ava.mjs")),
499 "bootstrap.cjs" => Some(include_bytes!("../runtime-assets/javascript/bootstrap.cjs")),
500 "capability.mjs" => Some(include_bytes!(
501 "../runtime-assets/javascript/capability.mjs"
502 )),
503 "jest.cjs" => Some(include_bytes!("../runtime-assets/javascript/jest.cjs")),
504 "jest.config.mjs" => Some(include_bytes!(
505 "../runtime-assets/javascript/jest.config.mjs"
506 )),
507 "jestReporter.mjs" => Some(include_bytes!(
508 "../runtime-assets/javascript/jestReporter.mjs"
509 )),
510 "jestRuntime.cjs" => Some(include_bytes!(
511 "../runtime-assets/javascript/jestRuntime.cjs"
512 )),
513 "launchSupervisor.mjs" => Some(include_bytes!(
514 "../runtime-assets/javascript/launchSupervisor.mjs"
515 )),
516 "mocha.mjs" => Some(include_bytes!("../runtime-assets/javascript/mocha.mjs")),
517 "nodeAssert.mjs" => Some(include_bytes!(
518 "../runtime-assets/javascript/nodeAssert.mjs"
519 )),
520 "nodeAssertAdapter.mjs" => Some(include_bytes!(
521 "../runtime-assets/javascript/nodeAssertAdapter.mjs"
522 )),
523 "nodeAssertStrict.mjs" => Some(include_bytes!(
524 "../runtime-assets/javascript/nodeAssertStrict.mjs"
525 )),
526 "nodeTest.mjs" => Some(include_bytes!("../runtime-assets/javascript/nodeTest.mjs")),
527 "playwright.mjs" => Some(include_bytes!(
528 "../runtime-assets/javascript/playwright.mjs"
529 )),
530 "playwrightReporter.mjs" => Some(include_bytes!(
531 "../runtime-assets/javascript/playwrightReporter.mjs"
532 )),
533 "provenance.mjs" => Some(include_bytes!(
534 "../runtime-assets/javascript/provenance.mjs"
535 )),
536 "register.mjs" => Some(include_bytes!("../runtime-assets/javascript/register.mjs")),
537 "resolve-loader.mjs" => Some(include_bytes!(
538 "../runtime-assets/javascript/resolve-loader.mjs"
539 )),
540 "runnerEvidence.mjs" => Some(include_bytes!(
541 "../runtime-assets/javascript/runnerEvidence.mjs"
542 )),
543 "runtime.mjs" => Some(include_bytes!("../runtime-assets/javascript/runtime.mjs")),
544 "tap.mjs" => Some(include_bytes!("../runtime-assets/javascript/tap.mjs")),
545 "transport.mjs" => Some(include_bytes!("../runtime-assets/javascript/transport.mjs")),
546 "vitest.mjs" => Some(include_bytes!("../runtime-assets/javascript/vitest.mjs")),
547 "vitestBrowser.mjs" => Some(include_bytes!(
548 "../runtime-assets/javascript/vitestBrowser.mjs"
549 )),
550 "vitestReporter.mjs" => Some(include_bytes!(
551 "../runtime-assets/javascript/vitestReporter.mjs"
552 )),
553 _ => None,
554 }
555}
556
557fn unique() -> String {
558 let nanos = SystemTime::now()
559 .duration_since(UNIX_EPOCH)
560 .unwrap_or_default()
561 .as_nanos();
562 format!(
563 "{}-{nanos}-{}",
564 std::process::id(),
565 UNIQUE.fetch_add(1, Ordering::Relaxed)
566 )
567}
568
569#[cfg(not(windows))]
570fn create_directory_all(path: &Path) -> Result<(), JavascriptFrontendError> {
571 SETUP.directories.fetch_add(1, Ordering::Relaxed);
572 timed(&SETUP.directory_ns, || {
573 fs::create_dir_all(path).map_err(|source| io_error(path, source))
574 })
575}
576
577#[cfg(windows)]
578fn create_directory_all(path: &Path) -> Result<(), JavascriptFrontendError> {
579 const ATTEMPTS: usize = 16;
589 let started = std::time::Instant::now();
590 SETUP.directories.fetch_add(1, Ordering::Relaxed);
591 let mut delay = std::time::Duration::from_millis(20);
592 for attempt in 0..ATTEMPTS {
593 match fs::create_dir_all(path) {
594 Ok(()) => {
595 account(&SETUP.directory_ns, started);
596 return Ok(());
597 }
598 Err(source)
599 if source.kind() == io::ErrorKind::PermissionDenied && attempt + 1 < ATTEMPTS =>
600 {
601 SETUP.directory_retries.fetch_add(1, Ordering::Relaxed);
602 std::thread::sleep(delay);
603 delay = (delay * 2).min(std::time::Duration::from_millis(500));
604 }
605 Err(source) => {
606 account(&SETUP.directory_ns, started);
607 let detail = format!(
608 "{source} (after {} attempt(s) over {:?}; ancestors: {})",
609 attempt + 1,
610 started.elapsed(),
611 describe_ancestors(path)
612 );
613 return Err(io_error(path, io::Error::new(source.kind(), detail)));
614 }
615 }
616 }
617 unreachable!("the final directory-creation attempt always returns")
618}
619
620#[cfg_attr(not(windows), allow(dead_code))]
624fn describe_ancestors(path: &Path) -> String {
625 let mut current = PathBuf::new();
626 let mut parts = Vec::new();
627 for component in path.components() {
628 current.push(component.as_os_str());
629 let state = match fs::symlink_metadata(¤t) {
630 Ok(metadata) if metadata.file_type().is_symlink() => "link",
631 Ok(metadata) if metadata.file_type().is_dir() => "dir",
632 Ok(_) => "file",
633 Err(error)
636 if matches!(
637 error.kind(),
638 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
639 ) =>
640 {
641 "missing"
642 }
643 Err(error) => return format!("{} -> {error}", current.display()),
644 };
645 parts.push(format!("{}={state}", current.display()));
646 }
647 parts.join("; ")
648}
649
650fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), JavascriptFrontendError> {
651 let parent = path
652 .parent()
653 .ok_or_else(|| JavascriptFrontendError::UnsafeSourcePath(path.display().to_string()))?;
654 let temporary = parent.join(format!(".supercov-write-{}", unique()));
655 let result = (|| {
656 let open = || {
657 OpenOptions::new()
658 .write(true)
659 .create_new(true)
660 .open(&temporary)
661 };
662 let opened = timed(&SETUP.create_ns, open);
663 let mut output = match opened {
664 Ok(output) => output,
665 Err(source) if source.kind() == io::ErrorKind::NotFound => {
666 create_directory_all(parent)?;
667 timed(&SETUP.create_ns, open).map_err(|source| io_error(&temporary, source))?
668 }
669 Err(source) => return Err(io_error(&temporary, source)),
670 };
671 timed(&SETUP.write_ns, || output.write_all(contents))
672 .map_err(|source| io_error(&temporary, source))?;
673 timed(&SETUP.rename_ns, || fs::rename(&temporary, path))
697 .map_err(|source| io_error(path, source))?;
698 SETUP.files.fetch_add(1, Ordering::Relaxed);
699 SETUP
700 .bytes
701 .fetch_add(contents.len() as u64, Ordering::Relaxed);
702 Ok(())
703 })();
704 if result.is_err() {
705 let _ = fs::remove_file(&temporary);
706 }
707 result
708}
709
710fn checked_source_path(workspace: &Path, file: &str) -> Result<PathBuf, JavascriptFrontendError> {
711 let relative = Path::new(file);
712 if relative.is_absolute()
713 || relative
714 .components()
715 .any(|component| !matches!(component, std::path::Component::Normal(_)))
716 {
717 return Err(JavascriptFrontendError::UnsafeSourcePath(file.to_owned()));
718 }
719 Ok(workspace.join(relative))
720}
721
722fn runtime_specifier(file: &str, name: &str) -> Result<String, JavascriptFrontendError> {
723 let relative = Path::new(file);
724 if relative.is_absolute()
725 || relative
726 .components()
727 .any(|component| !matches!(component, std::path::Component::Normal(_)))
728 {
729 return Err(JavascriptFrontendError::UnsafeSourcePath(file.to_owned()));
730 }
731 let depth = relative
732 .parent()
733 .map_or(0, |parent| parent.components().count());
734 Ok(if depth == 0 {
735 format!("./.supercov/node_modules/{name}")
736 } else {
737 format!("{}.supercov/node_modules/{name}", "../".repeat(depth))
738 })
739}
740
741const GENERATED_SOURCE_BANNER: &str =
748 "/* eslint-disable */\n// @ts-nocheck -- generated coverage workspace only\n";
749
750fn generated_source_banner(code: &str) -> String {
756 let Some(rest) = code.strip_prefix("#!") else {
757 return format!("{GENERATED_SOURCE_BANNER}{code}");
758 };
759 let (line, remainder) = rest.split_once('\n').unwrap_or((rest, ""));
760 format!("#!{line}\n{GENERATED_SOURCE_BANNER}{remainder}")
761}
762
763fn isolate_runtime(source: &str, collector_id: &str) -> Result<String, JavascriptFrontendError> {
764 let source = format!("/* eslint-disable */\n{source}");
768 let source = source.as_str();
769 let double = format!("runtimeInstanceToken = \"{RUNTIME_INSTANCE_MARKER}\"");
770 let single = format!("runtimeInstanceToken = '{RUNTIME_INSTANCE_MARKER}'");
771 if let Some(index) = source.find(&double) {
772 let mut isolated = source.to_owned();
773 isolated.replace_range(
774 index..index + double.len(),
775 &format!("runtimeInstanceToken = \"{collector_id}\""),
776 );
777 return Ok(isolated);
778 }
779 if let Some(index) = source.find(&single) {
780 let mut isolated = source.to_owned();
781 isolated.replace_range(
782 index..index + single.len(),
783 &format!("runtimeInstanceToken = '{collector_id}'"),
784 );
785 return Ok(isolated);
786 }
787 Err(JavascriptFrontendError::MissingRuntimeMarker)
788}
789
790fn inline_instrumentation_map(
800 code: &str,
801 map: Option<&serde_json::Value>,
802 original_path: &Path,
803 original_source: &str,
804) -> Option<String> {
805 let map = map?.clone();
806 let mut map = map;
807 let object = map.as_object_mut()?;
808 let banner_lines = code
809 .lines()
810 .skip(usize::from(code.starts_with("#!")))
811 .take_while(|line| {
812 line.starts_with("/* eslint-disable */") || line.starts_with("// @ts-nocheck")
813 })
814 .count();
815 let mut mappings = object.get("mappings")?.as_str()?.to_owned();
816 if banner_lines > 0 {
817 mappings = format!("{}{mappings}", ";".repeat(banner_lines));
818 }
819 let mut code = std::borrow::Cow::Borrowed(code);
833 if !code.starts_with("#!") && mappings.starts_with(';') {
834 mappings.insert_str(0, "AAAA,C");
835 if code.starts_with(crate::js_instrumenter::RUNTIME_LOADER_START) {
836 code = std::borrow::Cow::Owned(format!("\n{code}"));
837 mappings.insert(6, ';');
838 }
839 }
840 let directive_line = code.matches('\n').count() + 1;
843 let mapped_lines = mappings.matches(';').count();
844 if directive_line > mapped_lines
845 && mappings.contains(|c: char| c.is_ascii_alphanumeric() || c == '+' || c == '/')
846 {
847 mappings.push_str(&";".repeat(directive_line - mapped_lines));
848 mappings.push_str("AAAA");
849 }
850 object.insert("mappings".into(), serde_json::Value::String(mappings));
851 object.insert(
852 "sources".into(),
853 serde_json::json!([original_path.display().to_string()]),
854 );
855 object.insert(
856 "sourcesContent".into(),
857 serde_json::json!([original_source]),
858 );
859 let payload = serde_json::to_string(&map).ok()?;
860 use base64::Engine as _;
861 let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
862 Some(format!(
863 "{code}\n//# sourceMappingURL=data:application/json;base64,{encoded}\n"
864 ))
865}
866
867fn strip_source_map_reference(mut bytes: Vec<u8>) -> Vec<u8> {
871 const MARKER: &[u8] = b"\n//# sourceMappingURL=";
872 if let Some(index) = bytes
873 .windows(MARKER.len())
874 .rposition(|window| window == MARKER)
875 {
876 let suffix = &bytes[index + MARKER.len()..];
877 let suffix = suffix.strip_suffix(b"\n").unwrap_or(suffix);
878 let suffix = suffix.strip_suffix(b"\r").unwrap_or(suffix);
879 if !suffix.contains(&b'\n') && !suffix.contains(&b'\r') {
880 bytes.truncate(index + 1);
881 }
882 }
883 bytes
884}
885
886fn copy_runtime(generated: &Path, collector_id: &str) -> Result<(), JavascriptFrontendError> {
887 create_directory_all(generated)?;
888 atomic_write(
889 &generated.join("package.json"),
890 b"{\"private\":true,\"type\":\"module\"}\n",
891 )?;
892 for name in RUNTIME_FILES {
893 let destination = generated.join(name);
894 let source_path = PathBuf::from(format!("embedded:{name}"));
895 let bytes = embedded_runtime(name)
896 .expect("every declared runtime file must have an embedded asset")
897 .to_vec();
898 let bytes = strip_source_map_reference(bytes);
899 if *name == "runtime.mjs" {
900 let text = String::from_utf8(bytes).map_err(|source| {
901 io_error(
902 &source_path,
903 io::Error::new(io::ErrorKind::InvalidData, source),
904 )
905 })?;
906 atomic_write(
907 &destination,
908 isolate_runtime(&text, collector_id)?.as_bytes(),
909 )?;
910 } else {
911 atomic_write(&destination, &bytes)?;
912 }
913 }
914 atomic_write(
915 &generated.join("runtime.d.mts"),
916 runtime_declarations().as_bytes(),
917 )?;
918 Ok(())
919}
920
921fn runtime_declarations() -> String {
931 const VALUES: &[(&str, usize)] = &[
933 ("selectionRight", 1),
934 ("selectionEnd", 1),
935 ("optionalSelect", 2),
936 ("optionalCallReached", 1),
937 ("optionalCallEnd", 1),
938 ("defaultSelected", 1),
939 ("tryCatch", 1),
940 ("mcdcCondition", 2),
941 ("mcdcEnd", 1),
942 ("defaultSelectedV2", 2),
943 ("optionalSelectV2", 2),
944 ("selectShortV2", 2),
945 ("selectRightV2", 2),
946 ("selectNamedRightV2", 2),
947 ("selectAssignEndV2", 2),
948 ("selectPathV2", 1),
949 ("selectEnd2V2", 2),
950 ("selectEndV2", 2),
951 ("optionalCallEndV2", 2),
952 ("renderedValueV2", 2),
953 ("parenthesizedAssignmentValue", 0),
954 ("withRequestPhase", 0),
955 ];
956 const OTHERS: &[&str] = &[
957 "coverageHit",
958 "selectionBegin",
959 "optionalCallBegin",
960 "optionalCallContinued",
961 "defaultEntered",
962 "defaultEnteredV2",
963 "tryBegin",
964 "tryEnd",
965 "loopBegin",
966 "loopEntered",
967 "loopEnd",
968 "mcdcBegin",
969 "registerProbeV2",
970 "coverageHitV2",
971 "mcdcEndV2",
972 ];
973 let mut text = String::from("/* eslint-disable */\n");
979 for (name, before) in VALUES {
980 let leading = (0..*before)
981 .map(|index| format!("a{index}: any, "))
982 .collect::<String>();
983 text.push_str(&format!(
984 "export declare function {name}<T>({leading}value: T, ...rest: any[]): T;\n"
985 ));
986 }
987 for name in OTHERS {
988 text.push_str(&format!(
989 "export declare function {name}(...args: any[]): any;\n"
990 ));
991 }
992 text
993}
994
995fn limitation_from_source(value: &SourceLimitation) -> CandidateLimitation {
996 CandidateLimitation {
997 id: value.id.clone(),
998 kind: value.kind.clone(),
999 file: value.file.clone(),
1000 line: value.line,
1001 column: value.column,
1002 source: value.source.clone(),
1003 reason: value.reason.clone(),
1004 }
1005}
1006
1007fn bootstrap_loader(
1029 output: &mut crate::js_instrumenter::CandidateOutput,
1030 file: &str,
1031) -> Result<(), JavascriptFrontendError> {
1032 let Some(binding) = output.runtime.as_ref() else {
1033 return Ok(());
1034 };
1035 let direct = format!(
1036 "{} = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin",
1037 binding.mcdc_begin
1038 );
1039 let Some(found) = output.code.find(&direct) else {
1040 return Ok(());
1041 };
1042 let loader = format!(
1043 "{}\n",
1044 crate::js_instrumenter::runtime_loader(&runtime_specifier(file, "bootstrap.cjs")?)
1045 );
1046 let offset = output.code[..found]
1047 .rfind('\n')
1048 .map_or(0, |newline| newline + 1);
1049 let line = output.code[..offset].matches('\n').count();
1050 if let Some(serde_json::Value::String(mappings)) =
1051 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1052 {
1053 let mut lines: Vec<&str> = mappings.split(';').collect();
1054 if line <= lines.len() {
1055 lines.insert(line, "");
1056 *mappings = lines.join(";");
1057 }
1058 }
1059 output.code.insert_str(offset, &loader);
1060 Ok(())
1061}
1062
1063fn keep_directives_first(source: &str, output: &mut crate::js_instrumenter::CandidateOutput) {
1071 let mut directives = Vec::new();
1072 for line in source.lines() {
1073 let trimmed = line.trim();
1074 if trimmed.starts_with("//=") {
1075 directives.push(line);
1076 } else if !(trimmed.is_empty() || trimmed.starts_with("//")) {
1077 break;
1078 }
1079 }
1080 if directives.is_empty() {
1081 return;
1082 }
1083 let lines = output.code.split('\n').collect::<Vec<_>>();
1084 let mut moved = Vec::new();
1085 let mut from = 0;
1086 for directive in &directives {
1087 let Some(index) = lines[from..].iter().position(|line| line == directive) else {
1088 return;
1089 };
1090 moved.push(from + index);
1091 from += index + 1;
1092 }
1093 if moved
1094 .iter()
1095 .enumerate()
1096 .all(|(position, index)| position == *index)
1097 {
1098 return;
1099 }
1100 let reorder = |items: Vec<&str>| -> Vec<String> {
1101 let mut first = moved
1102 .iter()
1103 .map(|index| items[*index].to_owned())
1104 .collect::<Vec<_>>();
1105 first.extend(
1106 items
1107 .iter()
1108 .enumerate()
1109 .filter(|(index, _)| !moved.contains(index))
1110 .map(|(_, item)| (*item).to_owned()),
1111 );
1112 first
1113 };
1114 let mut keep_map = true;
1118 if let Some(serde_json::Value::String(mappings)) =
1119 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1120 {
1121 let mut segments = mappings.split(';').collect::<Vec<_>>();
1122 segments.resize(segments.len().max(lines.len()), "");
1123 if moved.iter().all(|index| segments[*index].is_empty()) {
1124 *mappings = reorder(segments).join(";");
1125 } else {
1126 keep_map = false;
1127 }
1128 }
1129 if !keep_map {
1130 output.map = None;
1131 }
1132 output.code = reorder(lines).join("\n");
1133}
1134
1135fn compact_runtime(runtime_source: &str) -> String {
1140 let Some(export_start) = runtime_source.rfind("\nexport {") else {
1141 return runtime_source.to_owned();
1142 };
1143 let body = &runtime_source[..export_start];
1144 let allocator = oxc_allocator::Allocator::default();
1145 let parsed = oxc_parser::Parser::new(&allocator, body, oxc_span::SourceType::mjs()).parse();
1146 if parsed.panicked || !parsed.errors.is_empty() {
1147 return runtime_source.to_owned();
1148 }
1149 let options = oxc_codegen::CodegenOptions {
1150 minify: true,
1151 comments: oxc_codegen::CommentOptions::disabled(),
1152 ..oxc_codegen::CodegenOptions::default()
1153 };
1154 let compact = oxc_codegen::Codegen::new()
1155 .with_options(options)
1156 .build(&parsed.program)
1157 .code;
1158 format!("{compact}{}", &runtime_source[export_start..])
1159}
1160
1161fn bootstrap_runtime(
1162 output: &mut crate::js_instrumenter::CandidateOutput,
1163 runtime_source: &str,
1164 browser_suite: bool,
1165) {
1166 let Some(binding) = output.runtime.as_ref() else {
1167 return;
1168 };
1169 let script = format!(
1170 "{} = globalThis.__supercovRuntime.mcdcBegin",
1171 binding.mcdc_begin
1172 );
1173 let direct = format!(
1174 "{} = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin",
1175 binding.mcdc_begin
1176 );
1177 let Some(found) = output
1178 .code
1179 .find(&script)
1180 .or_else(|| browser_suite.then(|| output.code.find(&direct)).flatten())
1181 else {
1182 return;
1183 };
1184 let Some(export_start) = runtime_source.rfind("\nexport {") else {
1185 return;
1186 };
1187 let offset = output.code[..found]
1188 .rfind('\n')
1189 .map_or(0, |newline| newline + 1);
1190 let prelude = format!(
1191 "(() => {{ if (!globalThis.__SUPERCOV_DIRECT_RUNTIME__) {{\n{}\n}} if (globalThis.__supercovRuntime == null) globalThis.__supercovRuntime = globalThis.__SUPERCOV_DIRECT_RUNTIME__; }})();\n",
1192 &runtime_source[..export_start]
1193 );
1194 let line = output.code[..offset].matches('\n').count();
1195 let added = prelude.matches('\n').count();
1196 if let Some(serde_json::Value::String(mappings)) =
1197 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1198 {
1199 let mut lines: Vec<&str> = mappings.split(';').collect();
1200 if line <= lines.len() {
1201 lines.splice(line..line, std::iter::repeat_n("", added));
1202 *mappings = lines.join(";");
1203 }
1204 }
1205 output.code.insert_str(offset, &prelude);
1206}
1207
1208fn relocated_project_file(
1209 workspace: &Path,
1210 project: &CoverageProject,
1211 source: Option<&PathBuf>,
1212) -> Option<PathBuf> {
1213 let source = source?;
1214 let relative = source.strip_prefix(&project.root).ok()?;
1215 Some(workspace.join(relative))
1216}
1217
1218fn write_vitest_config(
1219 workspace: &Path,
1220 project: &CoverageProject,
1221 generated: &Path,
1222) -> Result<PathBuf, JavascriptFrontendError> {
1223 let path = generated.join("vitest.config.mjs");
1224 let original = relocated_project_file(workspace, project, project.vitest_config.as_ref())
1225 .map(|path| path.display().to_string());
1226 let original = serde_json::to_string(&original).map_err(JavascriptFrontendError::Serialize)?;
1227 let source = format!(
1228 "import {{ createRequire }} from 'node:module';\n\
1229 import {{ pathToFileURL }} from 'node:url';\n\
1230 // pnpm's strict layout does not hoist vite to the project root: it\n\
1231 // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
1232 // resolved from this generated file fails. Vitest depends on vite, so\n\
1233 // fall back to resolving it through vitest's own tree rather than\n\
1234 // requiring the project to hoist anything.\n\
1235 const supercovRequire = createRequire(import.meta.url);\n\
1236 const supercovLoadVite = async () => {{\n\
1237 try {{\n\
1238 return await import('vite');\n\
1239 }} catch (error) {{\n\
1240 let entry;\n\
1241 try {{\n\
1242 entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
1243 }} catch {{\n\
1244 throw error;\n\
1245 }}\n\
1246 return await import(pathToFileURL(entry).href);\n\
1247 }}\n\
1248 }};\n\
1249 const viteNamespace = await supercovLoadVite();\n\
1250 import {{ resolve }} from 'node:path';\n\
1251 import SupercovVitestReporter from './node_modules/vitestReporter.mjs';\n\
1252 import {{ supercovBrowserCommands }} from './vitestCommands.mjs';\n\
1253 const vite = viteNamespace.default ?? viteNamespace;\n\
1254 const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
1255 const discoveredConfig = {original};\n\
1256 // Browser Mode runs the test file in the browser, so the node setup --\n\
1257 // which reaches node:fs to write evidence -- cannot load there. Vite\n\
1258 // externalises it and the suite fails before collecting a test.\n\
1259 const supercovBrowserMode = (config) => Boolean(config?.test?.browser?.enabled);\n\
1260 const supercovSetupFile = (config) =>\n\
1261 supercovBrowserMode(config)\n\
1262 ? '.supercov/node_modules/vitestBrowser.mjs'\n\
1263 : '.supercov/node_modules/vitest.mjs';\n\
1264 // Evidence leaves the browser over Vitest's own command channel, so\n\
1265 // the command is registered only for the mode that uses it.\n\
1266 const supercovBrowserTest = (config) =>\n\
1267 supercovBrowserMode(config)\n\
1268 ? {{ browser: {{ commands: supercovBrowserCommands(process.cwd()) }} }}\n\
1269 : {{}};\n\
1270 // Child projects do not inherit root setupFiles. Resolve each project's\n\
1271 // environment after Vitest has loaded file/glob/inline configs and\n\
1272 // applied --project, preserving the runner's own config semantics.\n\
1273 const configureProjects = (projects) => {{\n\
1274 const nodeSetup = resolve(process.cwd(), '.supercov/node_modules/vitest.mjs');\n\
1275 const browserSetup = resolve(process.cwd(), '.supercov/node_modules/vitestBrowser.mjs');\n\
1276 for (const project of projects ?? []) {{\n\
1277 const test = project.config;\n\
1278 const browser = Boolean(test.browser?.enabled);\n\
1279 const setup = browser ? browserSetup : nodeSetup;\n\
1280 test.setupFiles = [setup, ...(test.setupFiles ?? []).filter(file => file !== nodeSetup && file !== browserSetup)];\n\
1281 test.maxConcurrency = 1;\n\
1282 if (browser) {{\n\
1283 const commands = supercovBrowserCommands(process.cwd());\n\
1284 test.browser.commands = {{ ...test.browser.commands, ...commands }};\n\
1285 // Browser instances can already exist when reporters initialize.\n\
1286 // Their parent generates the browser command exports.\n\
1287 const server = project.browser?.parent ?? project.browser;\n\
1288 if (server?.commands) Object.assign(server.commands, commands);\n\
1289 }}\n\
1290 }}\n\
1291 }};\n\
1292 // Vitest's coverage measures the instrumented copy, probes included:\n\
1293 // a suite at 100% read 87.5% of branches, and autoUpdate would write\n\
1294 // what it measured into this file. Coverage is still collected and\n\
1295 // reported (see register.mjs). Vitest 0.x kept thresholds in coverage.\n\
1296 const supercovThresholdKeys = ['thresholds', 'lines', 'functions', 'branches', 'statements', 'perFile', '100', 'thresholdAutoUpdate'];\n\
1297 const supercovSkipCoverageThresholds = (coverage) => {{\n\
1298 const configured = supercovThresholdKeys.filter((key) => coverage?.[key] !== undefined);\n\
1299 for (const key of configured) delete coverage[key];\n\
1300 const collecting = coverage?.enabled === true ||\n\
1301 process.argv.some((argument) => /^--coverage(?:\\.enabled)?(?:=true)?$/.test(argument));\n\
1302 if (configured.length && collecting) process.__SUPERCOV_SKIPPED_COVERAGE_THRESHOLDS__?.('Vitest');\n\
1303 }};\n\
1304 export default async function supercovVitestConfig(env) {{\n\
1305 const originalPath = process.env.SUPERCOV_ORIGINAL_VITEST_CONFIG || discoveredConfig;\n\
1306 const loaded = originalPath ? await loadConfigFromFile(env, originalPath, process.cwd()) : undefined;\n\
1307 const config = mergeConfig(loaded?.config ?? {{}}, {{\n\
1308 cacheDir: resolve(process.cwd(), '.supercov/vitest-cache'),\n\
1309 test: {{ setupFiles: [resolve(process.cwd(), supercovSetupFile(loaded?.config))], maxConcurrency: 1, ...supercovBrowserTest(loaded?.config) }},\n\
1310 }});\n\
1311 supercovSkipCoverageThresholds(config.test?.coverage);\n\
1312 const configuredReporters = loaded?.config?.test?.reporters;\n\
1313 config.test ??= {{}};\n\
1314 config.test.reporters = configuredReporters\n\
1315 ? [...(Array.isArray(configuredReporters) ? configuredReporters : [configuredReporters]), new SupercovVitestReporter(configureProjects)]\n\
1316 : ['default', new SupercovVitestReporter(configureProjects)];\n\
1317 return config;\n\
1318 }}\n"
1319 );
1320 atomic_write(&path, source.as_bytes())?;
1321 Ok(path)
1322}
1323
1324fn configure_playwright_runtime(
1325 generated: &Path,
1326 project: &CoverageProject,
1327) -> Result<(), JavascriptFrontendError> {
1328 let adapter_path = generated.join("playwright.mjs");
1329 let mut adapter =
1330 fs::read_to_string(&adapter_path).map_err(|source| io_error(&adapter_path, source))?;
1331 adapter = adapter
1332 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module)
1333 .replace(
1334 "__SUPERCOV_PLAYWRIGHT_TEST_EXPORT__",
1335 &project.playwright_test_export,
1336 )
1337 .replace(
1341 "__SUPERCOV_PHASE_TIMING__",
1342 if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() == Ok("1") {
1343 "1"
1344 } else {
1345 "0"
1346 },
1347 );
1348 if project.playwright_module != "@playwright/test" {
1349 let facade = serde_json::to_string(&project.playwright_module)
1356 .expect("serializing a module specifier cannot fail");
1357 adapter = adapter.replace(
1358 "export * from \"@playwright/test\";",
1359 &format!("export * from {facade};"),
1360 );
1361 }
1362 let mut exports = Vec::new();
1363 if project.playwright_test_export != "test" {
1364 exports.push(format!(
1365 "export {{ instrumentedTest as {} }};",
1366 project.playwright_test_export
1367 ));
1368 }
1369 exports.extend(
1370 project
1371 .playwright_exports
1372 .iter()
1373 .filter(|name| {
1374 name.as_str() != "test"
1375 && name.as_str() != "expect"
1376 && *name != &project.playwright_test_export
1377 })
1378 .map(|name| {
1379 let encoded = serde_json::to_string(name)
1380 .expect("serializing a JavaScript export name cannot fail");
1381 format!("export const {name} = __supercovAdapterExport(adapter[{encoded}]);")
1382 }),
1383 );
1384 adapter = adapter.replace("/*__SUPERCOV_ADAPTER_EXPORTS__*/", &exports.join("\n"));
1385 atomic_write(&adapter_path, adapter.as_bytes())?;
1386
1387 let loader_path = generated.join("resolve-loader.mjs");
1388 let loader = fs::read_to_string(&loader_path)
1389 .map_err(|source| io_error(&loader_path, source))?
1390 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module);
1391 atomic_write(&loader_path, loader.as_bytes())
1392}
1393
1394fn write_playwright_config(
1395 workspace: &Path,
1396 project: &CoverageProject,
1397 generated: &Path,
1398) -> Result<PathBuf, JavascriptFrontendError> {
1399 let path = generated.join("playwright.config.mjs");
1400 let original = relocated_project_file(workspace, project, project.playwright_config.as_ref());
1401 let original_import = if let Some(original) = &original {
1402 let relative = original
1403 .strip_prefix(workspace)
1404 .map_err(|_| JavascriptFrontendError::UnsafeSourcePath(original.display().to_string()))?
1405 .to_string_lossy()
1406 .replace('\\', "/");
1407 let specifier = serde_json::to_string(&format!("../{relative}"))
1408 .map_err(JavascriptFrontendError::Serialize)?;
1409 format!(
1416 "import {{ existsSync, readFileSync }} from 'node:fs';\n\
1417 import {{ createRequire }} from 'node:module';\n\
1418 const originalFile = fileURLToPath(new URL({specifier}, import.meta.url));\n\
1419 const originalIsModule = (() => {{\n\
1420 if (/\\.m[jt]s$/.test(originalFile)) return true;\n\
1421 if (/\\.c[jt]s$/.test(originalFile)) return false;\n\
1422 for (let folder = dirname(originalFile); ; folder = dirname(folder)) {{\n\
1423 const manifest = resolve(folder, 'package.json');\n\
1424 if (existsSync(manifest)) {{\n\
1425 try {{ return JSON.parse(readFileSync(manifest, 'utf8')).type === 'module'; }} catch {{ return false; }}\n\
1426 }}\n\
1427 if (dirname(folder) === folder) return false;\n\
1428 }}\n\
1429 }})();\n\
1430 const original = originalIsModule\n\
1431 ? (await import(pathToFileURL(originalFile).href)).default\n\
1432 : createRequire(import.meta.url)(originalFile);\n"
1433 )
1434 } else {
1435 "const original = {};\n".into()
1436 };
1437 let source = format!(
1443 "import './node_modules/register.mjs';\n\
1444 import {{ dirname, isAbsolute, relative, resolve }} from 'node:path';\n\
1445 import {{ fileURLToPath, pathToFileURL }} from 'node:url';\n\
1446 {original_import}\
1447 const configExport = original && original.__esModule === true && Object.prototype.hasOwnProperty.call(original, 'default') ? original.default : original;\n\
1448 const resolvedValue = typeof configExport === 'function' ? await configExport({{ command: 'test', mode: 'test' }}) : configExport;\n\
1449 const resolved = resolvedValue ?? {{}};\n\
1450 const runtimeProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');\n\
1451 const originalDirectory = {};
1452 const sourceProjectRoot = process.env.SUPERCOV_SOURCE_PROJECT_ROOT;\n\
1453 const runtimePath = value => {{\n\
1454 if (!value) return value;\n\
1455 const absolute = isAbsolute(value) ? value : resolve(originalDirectory, value);\n\
1456 const local = relative(runtimeProjectRoot, absolute);\n\
1457 if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1458 if (sourceProjectRoot) {{\n\
1459 const sourceLocal = relative(sourceProjectRoot, absolute);\n\
1460 if (sourceLocal === '' || (!sourceLocal.startsWith('..') && !isAbsolute(sourceLocal))) return resolve(runtimeProjectRoot, sourceLocal);\n\
1461 }}\n\
1462 throw new Error('Supercov refuses a Playwright output/cwd outside the isolated project: ' + absolute);\n\
1463 }};\n\
1464 const normalizeWebServer = server => server ? ({{ ...server, cwd: runtimePath(server.cwd ?? originalDirectory) }}) : server;\n\
1465 // How the configuration resolved, for a later merge: a run of new
1466 // tests with settings of their own is added to this one only where
1467 // nothing that decides what a test does changed. Paths inside the
1468 // copy are written relative to it; every run has a copy of its own.
1469 if (process.env.SUPERCOV_EVIDENCE_DIR && process.env.TEST_WORKER_INDEX === undefined) {{\n\
1470 try {{\n\
1471 const {{ mkdirSync, renameSync, writeFileSync }} = await import('node:fs');\n\
1472 const seen = new WeakSet();\n\
1473 let text = JSON.stringify(resolved, (key, value) => {{\n\
1474 if (typeof value === 'function' || value instanceof RegExp) return String(value);\n\
1475 if (value && typeof value === 'object') {{ if (seen.has(value)) return undefined; seen.add(value); }}\n\
1476 return value;\n\
1477 }});\n\
1478 for (const root of [runtimeProjectRoot, sourceProjectRoot].filter(Boolean)) text = text.split(JSON.stringify(root).slice(1, -1)).join('.');\n\
1479 const directory = resolve(process.cwd(), process.env.SUPERCOV_EVIDENCE_DIR);\n\
1480 mkdirSync(directory, {{ recursive: true }});\n\
1481 const file = resolve(directory, 'playwright-configuration.json');\n\
1482 writeFileSync(`${{file}}.${{process.pid}}`, text);\n\
1483 renameSync(`${{file}}.${{process.pid}}`, file);\n\
1484 }} catch {{}}\n\
1485 }}\n\
1486 const normalized = {{ ...resolved,\n\
1487 testDir: runtimePath(resolved.testDir),\n\
1488 outputDir: runtimePath(resolved.outputDir),\n\
1489 snapshotDir: runtimePath(resolved.snapshotDir),\n\
1490 projects: resolved.projects?.map(project => ({{ ...project, testDir: runtimePath(project.testDir), outputDir: runtimePath(project.outputDir), snapshotDir: runtimePath(project.snapshotDir) }})),\n\
1491 webServer: Array.isArray(resolved.webServer) ? resolved.webServer.map(normalizeWebServer) : normalizeWebServer(resolved.webServer),\n\
1492 }};\n\
1493 const configuredReporters = normalized.reporter;\n\
1494 const reporters = configuredReporters\n\
1495 ? (typeof configuredReporters === 'string' ? [[configuredReporters]] : (Array.isArray(configuredReporters[0]) ? configuredReporters : [configuredReporters]))\n\
1496 : [['list']];\n\
1497 const coverageReporter = resolve(runtimeProjectRoot, '.supercov/node_modules/playwrightReporter.mjs');\n\
1498 export default {{ ...normalized, reporter: [...reporters, [coverageReporter]] }};\n",
1499 serde_json::to_string(
1500 &original
1501 .as_ref()
1502 .and_then(|path| path.parent())
1503 .unwrap_or(workspace)
1504 .display()
1505 .to_string()
1506 )
1507 .map_err(JavascriptFrontendError::Serialize)?
1508 );
1509 atomic_write(&path, source.as_bytes())?;
1510 Ok(path)
1511}
1512
1513fn write_vitest_commands(generated: &Path) -> Result<(), JavascriptFrontendError> {
1514 let adapter = "import { mkdirSync } from 'node:fs';\n\
1515import { relative, resolve, sep } from 'node:path';\n\
1516import { atomicWriteFileSync } from './node_modules/atomic.mjs';\n\
1517import { inferTestProvenance } from './node_modules/provenance.mjs';\n\
1518// Browser Mode runs the test file in the browser, where none of this is\n\
1519// reachable: no node:fs to write evidence, no project root to relativise a\n\
1520// path against, no env to read. Vitest's own browser command channel carries\n\
1521// what the browser observed to node, which completes the same payload the node\n\
1522// setup writes -- so one contract keeps one implementation. The command is\n\
1523// awaited as part of the test, and node learns the test file from Vitest\n\
1524// rather than from the browser realm, which cannot be trusted to name it.\n\
1525const supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/;\n\
1526export function supercovBrowserCommands(root) {\n\
1527 return {\n\
1528 __supercovEvidence(context, payload, suffix) {\n\
1529 const evidenceDirectory = process.env['SUPERCOV_EVIDENCE_DIR'];\n\
1530 if (!evidenceDirectory) return;\n\
1531 // A suffix names one directory under the evidence directory. Anything\n\
1532 // else is refused rather than resolved, so a malformed one cannot send\n\
1533 // the write outside the directory it is supposed to stay in.\n\
1534 if (typeof suffix !== 'string' || !supercovEvidenceSuffix.test(suffix))\n\
1535 throw new Error('Supercov refused an unusable evidence suffix: ' + suffix);\n\
1536 const absolute = context.testPath;\n\
1537 const testFile = absolute ? relative(root, absolute).split(sep).join('/') : undefined;\n\
1538 const projectName = payload.projectName ?? context.project?.name;\n\
1539 delete payload.projectName;\n\
1540 // The browser has no environment to read, so the run identity is stamped\n\
1541 // here rather than sent from a realm that cannot know it.\n\
1542 const runId = process.env['SUPERCOV_RUN_ID'];\n\
1543 if (runId && payload.scope) payload.scope.runId = runId;\n\
1544 const complete = Object.assign({}, payload, testFile ? { testFile } : {}, {\n\
1545 provenance: inferTestProvenance({\n\
1546 runner: 'vitest',\n\
1547 file: testFile,\n\
1548 project: projectName,\n\
1549 explicitKind: process.env['SUPERCOV_TEST_KIND'],\n\
1550 }),\n\
1551 });\n\
1552 const directory = resolve(root, evidenceDirectory, suffix);\n\
1553 mkdirSync(directory, { recursive: true });\n\
1554 atomicWriteFileSync(resolve(directory, 'mcdc.json'), JSON.stringify(complete) + '\\n');\n\
1555 },\n\
1556 };\n\
1557}\n";
1558 atomic_write(&generated.join("vitestCommands.mjs"), adapter.as_bytes())
1559}
1560
1561pub fn prepare_javascript_frontend(
1564 workspace: &Path,
1565 project: &CoverageProject,
1566 collector_id: &str,
1567 cache_key: &str,
1568 command: &[String],
1569) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
1570 let generated = workspace.join(".supercov");
1571 let runtime_directory = generated.join("node_modules");
1577 timed(&SETUP.runtime_ns, || {
1578 copy_runtime(&runtime_directory, collector_id)
1579 })?;
1580 let configuration_started = Instant::now();
1581 configure_playwright_runtime(&runtime_directory, project)?;
1582 let playwright_config_path = write_playwright_config(workspace, project, &generated)?;
1583 let vitest_config_path = write_vitest_config(workspace, project, &generated)?;
1584 account(&SETUP.config_ns, configuration_started);
1585
1586 let mut exclusions = Vec::new();
1587 let mut decisions = BTreeMap::new();
1588 let mut points = BTreeMap::new();
1589 let mut branches = BTreeMap::new();
1590 let mut limitations = BTreeMap::new();
1591 for limitation in &project.source_limitations {
1592 limitations.insert(limitation.id.clone(), limitation_from_source(limitation));
1593 }
1594
1595 let runtime_path = runtime_directory.join("runtime.mjs");
1596 let standalone_runtime = compact_runtime(
1597 &fs::read_to_string(&runtime_path).map_err(|source| io_error(&runtime_path, source))?,
1598 );
1599 let browser_suite = project.playwright_config.is_some() || project.browser_runner;
1604 let mut authored = BTreeSet::new();
1605 let sources_started = Instant::now();
1606 for file in &project.source_files {
1607 let path = checked_source_path(workspace, file)?;
1608 let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
1609 let capability_wrapper = runtime_specifier(file, "capability.mjs")?;
1610 let elide = crate::typescript_imports::elides_type_imports(workspace, file, command);
1611 let instrumented = timed(&SETUP.instrument_ns, || {
1612 instrument_with_import_policy(&source, file, &capability_wrapper, elide)
1613 });
1614 let mut output = match instrumented {
1615 Ok(output) => output,
1616 Err(crate::js_instrumenter::CandidateError::Parse(messages)) => {
1620 let limitation = CandidateLimitation {
1621 id: format!("javascript-file-not-parsed#{file}"),
1622 kind: "source-scope".into(),
1623 file: file.clone(),
1624 line: 1,
1625 column: 0,
1626 source: String::new(),
1627 reason: format!(
1628 "Supercov could not parse this file ({}); it runs as written and nothing in it is measured",
1629 messages.join("; ")
1630 ),
1631 };
1632 limitations.insert(limitation.id.clone(), limitation);
1633 continue;
1634 }
1635 Err(source) => {
1636 return Err(JavascriptFrontendError::Instrument {
1637 file: file.clone(),
1638 source,
1639 });
1640 }
1641 };
1642 if output
1646 .code
1647 .contains(crate::js_instrumenter::RUNTIME_TYPES_MODULE)
1648 {
1649 output.code = output.code.replace(
1650 crate::js_instrumenter::RUNTIME_TYPES_MODULE,
1651 &runtime_specifier(file, "runtime.mjs")?,
1652 );
1653 }
1654 bootstrap_runtime(&mut output, &standalone_runtime, browser_suite);
1655 if !browser_suite {
1656 bootstrap_loader(&mut output, file)?;
1657 }
1658 keep_directives_first(&source, &mut output);
1659 if matches!(
1664 path.extension().and_then(|value| value.to_str()),
1665 Some("ts" | "tsx" | "mts" | "cts")
1666 ) {
1667 output.code = generated_source_banner(&output.code);
1668 }
1669 let code = match inline_instrumentation_map(
1676 &output.code,
1677 output.map.as_ref(),
1678 &project.root.join(file),
1679 &source,
1680 ) {
1681 Some(code) => code,
1682 None => output.code.clone(),
1683 };
1684 keep_authored(workspace, &mut authored, file, &source)?;
1685 atomic_write(&path, code.as_bytes())?;
1686 exclusions.extend(output.excluded_statements);
1687 for value in output.decisions {
1688 decisions.insert(value.id.clone(), value);
1689 }
1690 for value in output.points {
1691 points.insert(value.id.clone(), value);
1692 }
1693 for value in output.branches {
1694 branches.insert(value.id.clone(), value);
1695 }
1696 for value in output.coverage_limitations {
1697 limitations.insert(value.id.clone(), value);
1698 }
1699 }
1700 account(&SETUP.sources_ns, sources_started);
1701 write_vitest_commands(&generated)?;
1702
1703 let assertions_started = Instant::now();
1704 let mut assertion_calls = 0;
1705 for entry in &project.source_scope.entries {
1706 let path = checked_source_path(workspace, &entry.file)?;
1707 let Ok(source) = fs::read_to_string(&path) else {
1708 continue;
1709 };
1710 let capability_wrapper = (!project.source_files.contains(&entry.file))
1711 .then(|| runtime_specifier(&entry.file, "capability.mjs"))
1712 .transpose()?;
1713 let assertion_runtime = runtime_specifier(&entry.file, "bootstrap.cjs")?;
1714 let output =
1715 match crate::js_instrumenter::instrument_node_assertion_phases_with_runtime_imports(
1716 &source,
1717 &entry.file,
1718 std::slice::from_ref(&project.playwright_module),
1719 capability_wrapper.as_deref(),
1720 Some(&assertion_runtime),
1721 ) {
1722 Ok(output) => output,
1723 Err(crate::js_instrumenter::CandidateError::Parse(_)) => continue,
1726 Err(source) => {
1727 return Err(JavascriptFrontendError::Instrument {
1728 file: entry.file.clone(),
1729 source,
1730 });
1731 }
1732 };
1733 if output.assertions > 0 || output.capability_imports > 0 {
1734 keep_authored(workspace, &mut authored, &entry.file, &source)?;
1735 atomic_write(&path, output.code.as_bytes())?;
1736 assertion_calls += output.assertions;
1737 }
1738 }
1739
1740 account(&SETUP.assertion_ns, assertions_started);
1741
1742 let mut manifest = JavascriptManifest {
1743 decisions: decisions.into_values().collect(),
1744 points: points.into_values().collect(),
1745 branches: branches.into_values().collect(),
1746 limitations: limitations.into_values().collect(),
1747 scope: project.source_scope.clone(),
1748 };
1749 manifest.decisions.sort_by_key(|value| {
1750 (
1751 value.file.clone(),
1752 value.line,
1753 value.column,
1754 value.id.clone(),
1755 )
1756 });
1757 manifest.points.sort_by_key(|value| {
1758 (
1759 value.file.clone(),
1760 value.line,
1761 value.column,
1762 value.id.clone(),
1763 )
1764 });
1765 manifest.branches.sort_by_key(|value| {
1766 (
1767 value.file.clone(),
1768 value.line,
1769 value.column,
1770 value.id.clone(),
1771 )
1772 });
1773 manifest.limitations.sort_by_key(|value| {
1774 (
1775 value.file.clone(),
1776 value.line,
1777 value.column,
1778 value.id.clone(),
1779 )
1780 });
1781
1782 atomic_write(
1783 &workspace.join(AUTHORED_LIST),
1784 &serde_json::to_vec(&authored).map_err(JavascriptFrontendError::Serialize)?,
1785 )?;
1786 atomic_write(
1787 &generated.join("statement-exclusions.json"),
1788 &serde_json::to_vec(&exclusions).map_err(JavascriptFrontendError::Serialize)?,
1789 )?;
1790 let manifest_path = generated.join("manifest.json");
1791 let mut encoded =
1792 serde_json::to_vec_pretty(&manifest).map_err(JavascriptFrontendError::Serialize)?;
1793 encoded.push(b'\n');
1794 atomic_write(&manifest_path, &encoded)?;
1795 atomic_write(
1796 &generated.join("instrumentation-complete"),
1797 b"coverage-completeness-v2\n",
1798 )?;
1799 timed(&SETUP.cache_ns, || {
1800 write_javascript_frontend_cache(workspace, project, cache_key, assertion_calls)
1801 })?;
1802 Ok(PreparedJavascriptFrontend {
1803 manifest,
1804 manifest_path,
1805 preload_path: generated.join("node_modules/register.mjs"),
1806 playwright_config_path,
1807 vitest_config_path,
1808 assertion_calls,
1809 })
1810}
1811
1812#[cfg(test)]
1813mod tests {
1814
1815 #[test]
1817 fn sprockets_directives_stay_where_sprockets_reads_them() {
1818 let source = "// The manifest.\n//= link_tree ../images\n//= link_tree ../builds\n";
1819 let mut output: crate::js_instrumenter::CandidateOutput = serde_json::from_value(serde_json::json!({
1820 "engine": "", "complete": true, "supportedSurface": "",
1821 "code": "(() => { runtime(); })();\nconst __b = 1;\n// The manifest.\n//= link_tree ../images\n//= link_tree ../builds\n",
1822 "map": { "version": 3, "mappings": "AAAA;AACA;;;;" },
1823 "decisions": [], "points": [], "branches": [], "coverageLimitations": [], "limitations": []
1824 }))
1825 .unwrap();
1826 keep_directives_first(source, &mut output);
1827 assert!(
1828 output
1829 .code
1830 .starts_with("//= link_tree ../images\n//= link_tree ../builds\n(() =>"),
1831 "{}",
1832 output.code
1833 );
1834 assert_eq!(output.map.as_ref().unwrap()["mappings"], ";;AAAA;AACA;;");
1836 let mut untouched = output.clone();
1838 untouched.code = "x();\n//= not a directive\n".into();
1839 let before = untouched.code.clone();
1840 keep_directives_first("x();\n//= not a directive\n", &mut untouched);
1841 assert_eq!(untouched.code, before);
1842 }
1843
1844 #[test]
1846 fn the_copy_of_the_runtime_a_file_carries_is_compact() {
1847 let source = include_str!("../runtime-assets/javascript/runtime.mjs");
1848 let compact = compact_runtime(source);
1849 assert!(
1850 compact.len() * 3 < source.len() * 2,
1851 "{} of {}",
1852 compact.len(),
1853 source.len()
1854 );
1855 let export_start = compact.rfind("\nexport {").unwrap();
1856 assert_eq!(
1857 &compact[export_start..],
1858 &source[source.rfind("\nexport {").unwrap()..]
1859 );
1860 let allocator = oxc_allocator::Allocator::default();
1861 let parsed = oxc_parser::Parser::new(
1862 &allocator,
1863 &compact[..export_start],
1864 oxc_span::SourceType::cjs(),
1865 )
1866 .parse();
1867 assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
1868 }
1869 #[test]
1870 fn ancestor_description_names_each_component_and_its_state() {
1871 let root = std::env::temp_dir().join(format!("supercov-ancestors-{}", unique()));
1872 fs::create_dir_all(root.join("present")).unwrap();
1873 fs::write(root.join("present/file.txt"), b"x").unwrap();
1874 let described = super::describe_ancestors(&root.join("present/file.txt/child"));
1875 assert!(described.contains("present=dir"), "{described}");
1876 assert!(described.contains("file.txt=file"), "{described}");
1877 assert!(described.ends_with("child=missing"), "{described}");
1878 fs::remove_dir_all(&root).unwrap();
1879 }
1880
1881 use super::*;
1882 use crate::project_discovery::discover_coverage_project;
1883
1884 fn temporary(name: &str) -> PathBuf {
1885 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1886 .join("../../target/supercov-test-fixtures")
1887 .join(format!("javascript-frontend-{name}-{}", unique()));
1888 fs::create_dir_all(&path).unwrap();
1894 crate::workspace::canonicalize_simplified(path).unwrap()
1895 }
1896
1897 #[test]
1898 fn browser_mode_gets_a_browser_setup_and_the_command_that_carries_its_evidence() {
1899 let generated = temporary("browser-mode-config");
1900 write_vitest_commands(&generated).unwrap();
1901 let adapter = fs::read_to_string(generated.join("vitestCommands.mjs")).unwrap();
1902 assert!(adapter.contains("export function supercovBrowserCommands(root)"));
1906 assert!(adapter.contains("__supercovEvidence(context, payload, suffix)"));
1907 assert!(adapter.contains("const absolute = context.testPath;"));
1908 assert!(!adapter.contains("configureServer"), "{adapter}");
1909 assert!(!adapter.contains("__supercov/evidence"), "{adapter}");
1910 assert!(adapter.contains("supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/"));
1913 assert!(adapter.contains("Supercov refused an unusable evidence suffix"));
1914 }
1915
1916 #[test]
1917 fn a_browser_mode_project_loads_the_setup_that_can_run_in_a_browser() {
1918 let workspace = temporary("browser-mode-setup");
1921 let generated = workspace.join(".supercov");
1922 fs::create_dir_all(&generated).unwrap();
1923 fs::write(workspace.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1924 fs::create_dir_all(workspace.join("src")).unwrap();
1925 fs::write(
1926 workspace.join("src/example.mjs"),
1927 "export const one = () => 1;\n",
1928 )
1929 .unwrap();
1930 let project = discover_coverage_project(
1931 &workspace,
1932 &BTreeMap::new(),
1933 &["npx".into(), "vitest".into(), "run".into()],
1934 )
1935 .unwrap();
1936 let path = write_vitest_config(&workspace, &project, &generated).unwrap();
1937 let config = fs::read_to_string(path).unwrap();
1938 assert!(config.contains("config?.test?.browser?.enabled"));
1939 assert!(config.contains("'.supercov/node_modules/vitestBrowser.mjs'"));
1940 assert!(config.contains("'.supercov/node_modules/vitest.mjs'"));
1941 assert!(config.contains("browser: { commands: supercovBrowserCommands(process.cwd()) }"));
1943 assert!(config.contains("...supercovBrowserTest(loaded?.config)"));
1944 }
1945
1946 #[test]
1947 fn runtime_isolation_replaces_only_the_assignment_marker() {
1948 let source = concat!(
1949 "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n",
1950 "const selected = runtimeInstanceToken === \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\";\n"
1951 );
1952 let isolated = isolate_runtime(source, "collector-123").unwrap();
1953 assert!(isolated.contains("runtimeInstanceToken = \"collector-123\""));
1954 assert!(isolated.contains("=== \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\""));
1955 }
1956
1957 #[test]
1958 fn copied_runtime_does_not_reference_unshipped_source_maps() {
1959 let generated = temporary("runtime-source-maps");
1960 copy_runtime(&generated, "collector-test").unwrap();
1961 for name in ["vitest.mjs", "provenance.mjs", "atomic.mjs"] {
1962 let contents = fs::read_to_string(generated.join(name)).unwrap();
1963 assert!(
1964 !contents.contains("sourceMappingURL"),
1965 "runtime shim retained a source-map directive: {name}"
1966 );
1967 }
1968 fs::remove_dir_all(generated).unwrap();
1969 }
1970
1971 #[test]
1972 fn prepares_sorted_complete_manifest_without_touching_source_project() {
1973 let source_root = temporary("source");
1974 let workspace = temporary("workspace");
1975 fs::create_dir_all(source_root.join("src")).unwrap();
1976 fs::write(
1977 source_root.join("src/example.mjs"),
1978 "export function value(a, b) { if (a || b) return 1; return 0; }\n",
1979 )
1980 .unwrap();
1981 fs::write(source_root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1982 fs::create_dir_all(workspace.join("src")).unwrap();
1983 fs::create_dir_all(workspace.join(".supercov")).unwrap();
1984 fs::copy(
1985 source_root.join("src/example.mjs"),
1986 workspace.join("src/example.mjs"),
1987 )
1988 .unwrap();
1989 let project = discover_coverage_project(
1990 &source_root,
1991 &BTreeMap::new(),
1992 &["node".into(), "--test".into()],
1993 )
1994 .unwrap();
1995 let original = fs::read_to_string(source_root.join("src/example.mjs")).unwrap();
1996 let prepared =
1997 prepare_javascript_frontend(&workspace, &project, "collector-test", "cache-test", &[])
1998 .unwrap();
1999 assert_eq!(
2000 fs::read_to_string(source_root.join("src/example.mjs")).unwrap(),
2001 original
2002 );
2003 let transformed = fs::read_to_string(workspace.join("src/example.mjs")).unwrap();
2004 assert!(transformed.contains("__SUPERCOV_DIRECT_RUNTIME__"));
2005 assert_eq!(prepared.manifest.decisions.len(), 1);
2006 assert!(!prepared.manifest.points.is_empty());
2007 assert_eq!(prepared.manifest.scope, project.source_scope);
2008 assert!(prepared.manifest_path.is_file());
2009 assert!(prepared.preload_path.is_file());
2010 assert!(prepared.playwright_config_path.is_file());
2011 assert!(prepared.vitest_config_path.is_file());
2012 assert_eq!(prepared.assertion_calls, 0);
2013 let cache = read_javascript_frontend_cache(&workspace, "cache-test").unwrap();
2014 assert_eq!(
2015 javascript_frontend_reuse_paths(&cache),
2016 [
2017 PathBuf::from(".supercov/frontend-cache.json"),
2018 PathBuf::from(".supercov/frontend-cache-artifacts"),
2019 ]
2020 );
2021 assert!(
2022 cache
2023 .artifacts
2024 .iter()
2025 .all(|artifact| !artifact.cache_file.contains("src/")
2026 && !artifact.cache_file.contains("tests/"))
2027 );
2028 fs::write(workspace.join("src/example.mjs"), &original).unwrap();
2029 fs::remove_file(&prepared.manifest_path).unwrap();
2030 let restored = load_cached_javascript_frontend(&workspace, &cache).unwrap();
2031 assert_eq!(restored.manifest, prepared.manifest);
2032 assert_eq!(
2033 fs::read_to_string(workspace.join("src/example.mjs")).unwrap(),
2034 transformed
2035 );
2036 fs::write(workspace.join(&cache.artifacts[0].cache_file), "corrupt").unwrap();
2037 assert!(read_javascript_frontend_cache(&workspace, "cache-test").is_none());
2038 fs::remove_dir_all(source_root).unwrap();
2039 fs::remove_dir_all(workspace).unwrap();
2040 }
2041
2042 #[test]
2043 fn embedded_runtime_contains_every_declared_shim() {
2044 for name in RUNTIME_FILES {
2045 let bytes = embedded_runtime(name).unwrap();
2046 assert!(!bytes.is_empty(), "embedded runtime is empty: {name}");
2047 }
2048 assert!(
2049 std::str::from_utf8(embedded_runtime("runtime.mjs").unwrap())
2050 .unwrap()
2051 .contains(RUNTIME_INSTANCE_MARKER)
2052 );
2053 }
2054}