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 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 vitest_config_path: PathBuf,
224 pub assertion_calls: usize,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase", deny_unknown_fields)]
229pub struct JavascriptFrontendCache {
230 schema_version: u32,
231 key: String,
232 assertion_calls: usize,
233 artifacts: Vec<JavascriptFrontendCacheArtifact>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "camelCase", deny_unknown_fields)]
238struct JavascriptFrontendCacheArtifact {
239 path: String,
240 cache_file: String,
241 sha256: String,
242}
243
244fn safe_relative(path: &Path) -> bool {
245 path.components().next().is_some()
246 && path
247 .components()
248 .all(|component| matches!(component, std::path::Component::Normal(_)))
249}
250
251fn regular_file(workspace: &Path, relative: &str) -> bool {
252 safe_relative(Path::new(relative))
253 && fs::symlink_metadata(workspace.join(relative))
254 .is_ok_and(|metadata| metadata.file_type().is_file())
255}
256
257fn valid_cached_artifact(workspace: &Path, artifact: &JavascriptFrontendCacheArtifact) -> bool {
258 let expected_cache_file = format!("{FRONTEND_CACHE_DIRECTORY}/{}", artifact.sha256);
259 if !safe_relative(Path::new(&artifact.path))
260 || !safe_relative(Path::new(&artifact.cache_file))
261 || artifact.cache_file != expected_cache_file
262 || artifact.sha256.len() != 64
263 || !artifact
264 .sha256
265 .bytes()
266 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
267 {
268 return false;
269 }
270 let Ok(contents) = fs::read(workspace.join(&artifact.cache_file)) else {
271 return false;
272 };
273 format!("{:x}", Sha256::digest(&contents)) == artifact.sha256
274}
275
276pub fn read_javascript_frontend_cache(
277 workspace: &Path,
278 key: &str,
279) -> Option<JavascriptFrontendCache> {
280 let metadata: JavascriptFrontendCache =
281 serde_json::from_slice(&fs::read(workspace.join(FRONTEND_CACHE_FILE)).ok()?).ok()?;
282 if metadata.schema_version != FRONTEND_CACHE_SCHEMA_VERSION
283 || metadata.key != key
284 || metadata.artifacts.is_empty()
285 || metadata
286 .artifacts
287 .iter()
288 .any(|artifact| !valid_cached_artifact(workspace, artifact))
289 {
290 return None;
291 }
292 Some(metadata)
293}
294
295pub fn javascript_frontend_reuse_paths(cache: &JavascriptFrontendCache) -> Vec<PathBuf> {
296 let _ = cache;
297 vec![
298 PathBuf::from(FRONTEND_CACHE_FILE),
299 PathBuf::from(FRONTEND_CACHE_DIRECTORY),
300 ]
301}
302
303fn restore_cached_file(path: &Path, contents: &[u8]) -> Result<(), JavascriptFrontendError> {
304 let parent = path
305 .parent()
306 .ok_or_else(|| JavascriptFrontendError::UnsafeSourcePath(path.display().to_string()))?;
307 create_directory_all(parent)?;
308 let temporary = parent.join(format!(".supercov-restore-{}", unique()));
309 let result = (|| {
310 fs::write(&temporary, contents).map_err(|source| io_error(&temporary, source))?;
311 match fs::symlink_metadata(path) {
312 Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_symlink() => {
313 fs::remove_file(path).map_err(|source| io_error(path, source))?;
314 }
315 Ok(_) => {
316 return Err(JavascriptFrontendError::UnsafeSourcePath(
317 path.display().to_string(),
318 ));
319 }
320 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
321 Err(source) => return Err(io_error(path, source)),
322 }
323 fs::rename(&temporary, path).map_err(|source| io_error(path, source))
324 })();
325 if result.is_err() {
326 let _ = fs::remove_file(&temporary);
327 }
328 result
329}
330
331pub fn load_cached_javascript_frontend(
332 workspace: &Path,
333 cache: &JavascriptFrontendCache,
334) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
335 for artifact in &cache.artifacts {
336 let cache_path = workspace.join(&artifact.cache_file);
337 let contents = fs::read(&cache_path).map_err(|source| io_error(&cache_path, source))?;
338 if format!("{:x}", Sha256::digest(&contents)) != artifact.sha256 {
339 return Err(JavascriptFrontendError::UnsafeSourcePath(format!(
340 "corrupt frontend cache artifact {}",
341 artifact.cache_file
342 )));
343 }
344 restore_cached_file(&workspace.join(&artifact.path), &contents)?;
345 }
346 let generated = workspace.join(".supercov");
347 let manifest_path = generated.join("manifest.json");
348 let manifest = serde_json::from_slice(
349 &fs::read(&manifest_path).map_err(|source| io_error(&manifest_path, source))?,
350 )
351 .map_err(JavascriptFrontendError::Serialize)?;
352 Ok(PreparedJavascriptFrontend {
353 manifest,
354 manifest_path,
355 preload_path: generated.join("node_modules/register.mjs"),
356 playwright_config_path: generated.join("playwright.config.mjs"),
357 vitest_config_path: generated.join("vitest.config.mjs"),
358 assertion_calls: cache.assertion_calls,
359 })
360}
361
362fn frontend_artifact_paths(workspace: &Path, project: &CoverageProject) -> Vec<String> {
363 let mut artifacts = vec![
364 ".supercov/node_modules/package.json".to_owned(),
365 ".supercov/node_modules/runtime.d.mts".to_owned(),
366 ".supercov/playwright.config.mjs".to_owned(),
367 ".supercov/vitest.config.mjs".to_owned(),
368 ".supercov/vitestCommands.mjs".to_owned(),
369 ".supercov/manifest.json".to_owned(),
370 ".supercov/statement-exclusions.json".to_owned(),
371 ".supercov/instrumentation-complete".to_owned(),
372 ];
373 artifacts.extend(
374 RUNTIME_FILES
375 .iter()
376 .map(|name| format!(".supercov/node_modules/{name}")),
377 );
378 artifacts.extend(project.source_files.iter().cloned());
383 artifacts.extend(
384 project
385 .source_scope
386 .entries
387 .iter()
388 .map(|entry| entry.file.clone()),
389 );
390 for root in &project.source_roots {
391 let host = if workspace.join(root).is_file() {
392 Path::new(root).parent().unwrap_or_else(|| Path::new(""))
393 } else {
394 Path::new(root)
395 };
396 for name in ["package.json", "runtime.mjs", "runtime.d.mts"] {
397 let path = host.join(".supercov/node_modules").join(name);
398 if let Some(path) = path.to_str() {
399 artifacts.push(path.replace('\\', "/"));
400 }
401 }
402 }
403 let rewritten = project
404 .source_files
405 .iter()
406 .chain(project.source_scope.entries.iter().map(|entry| &entry.file))
407 .map(|file| format!("{AUTHORED_DIRECTORY}/{file}"))
408 .collect::<Vec<_>>();
409 artifacts.extend(rewritten);
410 artifacts.push(AUTHORED_LIST.to_owned());
411 artifacts.sort();
412 artifacts.dedup();
413 artifacts.retain(|path| regular_file(workspace, path));
414 artifacts
415}
416
417pub fn authored_path(workspace: &Path, file: &Path) -> PathBuf {
419 workspace.join(AUTHORED_DIRECTORY).join(file)
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
483fn embedded_runtime(name: &str) -> Option<&'static [u8]> {
484 match name {
485 "atomic.mjs" => Some(include_bytes!("../runtime-assets/javascript/atomic.mjs")),
486 "ava.mjs" => Some(include_bytes!("../runtime-assets/javascript/ava.mjs")),
487 "bootstrap.cjs" => Some(include_bytes!("../runtime-assets/javascript/bootstrap.cjs")),
488 "capability.mjs" => Some(include_bytes!(
489 "../runtime-assets/javascript/capability.mjs"
490 )),
491 "jest.cjs" => Some(include_bytes!("../runtime-assets/javascript/jest.cjs")),
492 "jest.config.mjs" => Some(include_bytes!(
493 "../runtime-assets/javascript/jest.config.mjs"
494 )),
495 "jestReporter.mjs" => Some(include_bytes!(
496 "../runtime-assets/javascript/jestReporter.mjs"
497 )),
498 "jestRuntime.cjs" => Some(include_bytes!(
499 "../runtime-assets/javascript/jestRuntime.cjs"
500 )),
501 "launchSupervisor.mjs" => Some(include_bytes!(
502 "../runtime-assets/javascript/launchSupervisor.mjs"
503 )),
504 "mocha.mjs" => Some(include_bytes!("../runtime-assets/javascript/mocha.mjs")),
505 "nodeAssert.mjs" => Some(include_bytes!(
506 "../runtime-assets/javascript/nodeAssert.mjs"
507 )),
508 "nodeAssertAdapter.mjs" => Some(include_bytes!(
509 "../runtime-assets/javascript/nodeAssertAdapter.mjs"
510 )),
511 "nodeAssertStrict.mjs" => Some(include_bytes!(
512 "../runtime-assets/javascript/nodeAssertStrict.mjs"
513 )),
514 "nodeTest.mjs" => Some(include_bytes!("../runtime-assets/javascript/nodeTest.mjs")),
515 "playwright.mjs" => Some(include_bytes!(
516 "../runtime-assets/javascript/playwright.mjs"
517 )),
518 "playwrightReporter.mjs" => Some(include_bytes!(
519 "../runtime-assets/javascript/playwrightReporter.mjs"
520 )),
521 "provenance.mjs" => Some(include_bytes!(
522 "../runtime-assets/javascript/provenance.mjs"
523 )),
524 "register.mjs" => Some(include_bytes!("../runtime-assets/javascript/register.mjs")),
525 "resolve-loader.mjs" => Some(include_bytes!(
526 "../runtime-assets/javascript/resolve-loader.mjs"
527 )),
528 "runnerEvidence.mjs" => Some(include_bytes!(
529 "../runtime-assets/javascript/runnerEvidence.mjs"
530 )),
531 "runtime.mjs" => Some(include_bytes!("../runtime-assets/javascript/runtime.mjs")),
532 "tap.mjs" => Some(include_bytes!("../runtime-assets/javascript/tap.mjs")),
533 "transport.mjs" => Some(include_bytes!("../runtime-assets/javascript/transport.mjs")),
534 "vitest.mjs" => Some(include_bytes!("../runtime-assets/javascript/vitest.mjs")),
535 "vitestBrowser.mjs" => Some(include_bytes!(
536 "../runtime-assets/javascript/vitestBrowser.mjs"
537 )),
538 "vitestReporter.mjs" => Some(include_bytes!(
539 "../runtime-assets/javascript/vitestReporter.mjs"
540 )),
541 _ => None,
542 }
543}
544
545fn unique() -> String {
546 let nanos = SystemTime::now()
547 .duration_since(UNIX_EPOCH)
548 .unwrap_or_default()
549 .as_nanos();
550 format!(
551 "{}-{nanos}-{}",
552 std::process::id(),
553 UNIQUE.fetch_add(1, Ordering::Relaxed)
554 )
555}
556
557#[cfg(not(windows))]
558fn create_directory_all(path: &Path) -> Result<(), JavascriptFrontendError> {
559 SETUP.directories.fetch_add(1, Ordering::Relaxed);
560 timed(&SETUP.directory_ns, || {
561 fs::create_dir_all(path).map_err(|source| io_error(path, source))
562 })
563}
564
565#[cfg(windows)]
566fn create_directory_all(path: &Path) -> Result<(), JavascriptFrontendError> {
567 const ATTEMPTS: usize = 16;
577 let started = std::time::Instant::now();
578 SETUP.directories.fetch_add(1, Ordering::Relaxed);
579 let mut delay = std::time::Duration::from_millis(20);
580 for attempt in 0..ATTEMPTS {
581 match fs::create_dir_all(path) {
582 Ok(()) => {
583 account(&SETUP.directory_ns, started);
584 return Ok(());
585 }
586 Err(source)
587 if source.kind() == io::ErrorKind::PermissionDenied && attempt + 1 < ATTEMPTS =>
588 {
589 SETUP.directory_retries.fetch_add(1, Ordering::Relaxed);
590 std::thread::sleep(delay);
591 delay = (delay * 2).min(std::time::Duration::from_millis(500));
592 }
593 Err(source) => {
594 account(&SETUP.directory_ns, started);
595 let detail = format!(
596 "{source} (after {} attempt(s) over {:?}; ancestors: {})",
597 attempt + 1,
598 started.elapsed(),
599 describe_ancestors(path)
600 );
601 return Err(io_error(path, io::Error::new(source.kind(), detail)));
602 }
603 }
604 }
605 unreachable!("the final directory-creation attempt always returns")
606}
607
608#[cfg_attr(not(windows), allow(dead_code))]
612fn describe_ancestors(path: &Path) -> String {
613 let mut current = PathBuf::new();
614 let mut parts = Vec::new();
615 for component in path.components() {
616 current.push(component.as_os_str());
617 let state = match fs::symlink_metadata(¤t) {
618 Ok(metadata) if metadata.file_type().is_symlink() => "link",
619 Ok(metadata) if metadata.file_type().is_dir() => "dir",
620 Ok(_) => "file",
621 Err(error)
624 if matches!(
625 error.kind(),
626 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
627 ) =>
628 {
629 "missing"
630 }
631 Err(error) => return format!("{} -> {error}", current.display()),
632 };
633 parts.push(format!("{}={state}", current.display()));
634 }
635 parts.join("; ")
636}
637
638fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), JavascriptFrontendError> {
639 let parent = path
640 .parent()
641 .ok_or_else(|| JavascriptFrontendError::UnsafeSourcePath(path.display().to_string()))?;
642 let temporary = parent.join(format!(".supercov-write-{}", unique()));
643 let result = (|| {
644 let open = || {
645 OpenOptions::new()
646 .write(true)
647 .create_new(true)
648 .open(&temporary)
649 };
650 let opened = timed(&SETUP.create_ns, open);
651 let mut output = match opened {
652 Ok(output) => output,
653 Err(source) if source.kind() == io::ErrorKind::NotFound => {
654 create_directory_all(parent)?;
655 timed(&SETUP.create_ns, open).map_err(|source| io_error(&temporary, source))?
656 }
657 Err(source) => return Err(io_error(&temporary, source)),
658 };
659 timed(&SETUP.write_ns, || output.write_all(contents))
660 .map_err(|source| io_error(&temporary, source))?;
661 timed(&SETUP.rename_ns, || fs::rename(&temporary, path))
685 .map_err(|source| io_error(path, source))?;
686 SETUP.files.fetch_add(1, Ordering::Relaxed);
687 SETUP
688 .bytes
689 .fetch_add(contents.len() as u64, Ordering::Relaxed);
690 Ok(())
691 })();
692 if result.is_err() {
693 let _ = fs::remove_file(&temporary);
694 }
695 result
696}
697
698fn checked_source_path(workspace: &Path, file: &str) -> Result<PathBuf, JavascriptFrontendError> {
699 let relative = Path::new(file);
700 if relative.is_absolute()
701 || relative
702 .components()
703 .any(|component| !matches!(component, std::path::Component::Normal(_)))
704 {
705 return Err(JavascriptFrontendError::UnsafeSourcePath(file.to_owned()));
706 }
707 Ok(workspace.join(relative))
708}
709
710fn runtime_specifier(file: &str, name: &str) -> Result<String, 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 let depth = relative
720 .parent()
721 .map_or(0, |parent| parent.components().count());
722 Ok(if depth == 0 {
723 format!("./.supercov/node_modules/{name}")
724 } else {
725 format!("{}.supercov/node_modules/{name}", "../".repeat(depth))
726 })
727}
728
729const GENERATED_SOURCE_BANNER: &str =
736 "/* eslint-disable */\n// @ts-nocheck -- generated coverage workspace only\n";
737
738fn generated_source_banner(code: &str) -> String {
744 let Some(rest) = code.strip_prefix("#!") else {
745 return format!("{GENERATED_SOURCE_BANNER}{code}");
746 };
747 let (line, remainder) = rest.split_once('\n').unwrap_or((rest, ""));
748 format!("#!{line}\n{GENERATED_SOURCE_BANNER}{remainder}")
749}
750
751fn isolate_runtime(source: &str, collector_id: &str) -> Result<String, JavascriptFrontendError> {
752 let source = format!("/* eslint-disable */\n{source}");
756 let source = source.as_str();
757 let double = format!("runtimeInstanceToken = \"{RUNTIME_INSTANCE_MARKER}\"");
758 let single = format!("runtimeInstanceToken = '{RUNTIME_INSTANCE_MARKER}'");
759 if let Some(index) = source.find(&double) {
760 let mut isolated = source.to_owned();
761 isolated.replace_range(
762 index..index + double.len(),
763 &format!("runtimeInstanceToken = \"{collector_id}\""),
764 );
765 return Ok(isolated);
766 }
767 if let Some(index) = source.find(&single) {
768 let mut isolated = source.to_owned();
769 isolated.replace_range(
770 index..index + single.len(),
771 &format!("runtimeInstanceToken = '{collector_id}'"),
772 );
773 return Ok(isolated);
774 }
775 Err(JavascriptFrontendError::MissingRuntimeMarker)
776}
777
778fn inline_instrumentation_map(
788 code: &str,
789 map: Option<&serde_json::Value>,
790 original_path: &Path,
791 original_source: &str,
792) -> Option<String> {
793 let map = map?.clone();
794 let mut map = map;
795 let object = map.as_object_mut()?;
796 let banner_lines = code
797 .lines()
798 .skip(usize::from(code.starts_with("#!")))
799 .take_while(|line| {
800 line.starts_with("/* eslint-disable */") || line.starts_with("// @ts-nocheck")
801 })
802 .count();
803 let mut mappings = object.get("mappings")?.as_str()?.to_owned();
804 if banner_lines > 0 {
805 mappings = format!("{}{mappings}", ";".repeat(banner_lines));
806 }
807 let mut code = std::borrow::Cow::Borrowed(code);
821 if !code.starts_with("#!") && mappings.starts_with(';') {
822 mappings.insert_str(0, "AAAA,C");
823 if code.starts_with(crate::js_instrumenter::RUNTIME_LOADER_START) {
824 code = std::borrow::Cow::Owned(format!("\n{code}"));
825 mappings.insert(6, ';');
826 }
827 }
828 let directive_line = code.matches('\n').count() + 1;
831 let mapped_lines = mappings.matches(';').count();
832 if directive_line > mapped_lines
833 && mappings.contains(|c: char| c.is_ascii_alphanumeric() || c == '+' || c == '/')
834 {
835 mappings.push_str(&";".repeat(directive_line - mapped_lines));
836 mappings.push_str("AAAA");
837 }
838 object.insert("mappings".into(), serde_json::Value::String(mappings));
839 object.insert(
840 "sources".into(),
841 serde_json::json!([original_path.display().to_string()]),
842 );
843 object.insert(
844 "sourcesContent".into(),
845 serde_json::json!([original_source]),
846 );
847 let payload = serde_json::to_string(&map).ok()?;
848 use base64::Engine as _;
849 let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
850 Some(format!(
851 "{code}\n//# sourceMappingURL=data:application/json;base64,{encoded}\n"
852 ))
853}
854
855fn strip_source_map_reference(mut bytes: Vec<u8>) -> Vec<u8> {
859 const MARKER: &[u8] = b"\n//# sourceMappingURL=";
860 if let Some(index) = bytes
861 .windows(MARKER.len())
862 .rposition(|window| window == MARKER)
863 {
864 let suffix = &bytes[index + MARKER.len()..];
865 let suffix = suffix.strip_suffix(b"\n").unwrap_or(suffix);
866 let suffix = suffix.strip_suffix(b"\r").unwrap_or(suffix);
867 if !suffix.contains(&b'\n') && !suffix.contains(&b'\r') {
868 bytes.truncate(index + 1);
869 }
870 }
871 bytes
872}
873
874fn copy_runtime(generated: &Path, collector_id: &str) -> Result<(), JavascriptFrontendError> {
875 create_directory_all(generated)?;
876 atomic_write(
877 &generated.join("package.json"),
878 b"{\"private\":true,\"type\":\"module\"}\n",
879 )?;
880 for name in RUNTIME_FILES {
881 let destination = generated.join(name);
882 let source_path = PathBuf::from(format!("embedded:{name}"));
883 let bytes = embedded_runtime(name)
884 .expect("every declared runtime file must have an embedded asset")
885 .to_vec();
886 let bytes = strip_source_map_reference(bytes);
887 if *name == "runtime.mjs" {
888 let text = String::from_utf8(bytes).map_err(|source| {
889 io_error(
890 &source_path,
891 io::Error::new(io::ErrorKind::InvalidData, source),
892 )
893 })?;
894 atomic_write(
895 &destination,
896 isolate_runtime(&text, collector_id)?.as_bytes(),
897 )?;
898 } else {
899 atomic_write(&destination, &bytes)?;
900 }
901 }
902 atomic_write(
903 &generated.join("runtime.d.mts"),
904 runtime_declarations().as_bytes(),
905 )?;
906 Ok(())
907}
908
909fn runtime_declarations() -> String {
919 const VALUES: &[(&str, usize)] = &[
921 ("selectionRight", 1),
922 ("selectionEnd", 1),
923 ("optionalSelect", 2),
924 ("optionalCallReached", 1),
925 ("optionalCallEnd", 1),
926 ("defaultSelected", 1),
927 ("tryCatch", 1),
928 ("mcdcCondition", 2),
929 ("mcdcEnd", 1),
930 ("defaultSelectedV2", 2),
931 ("optionalSelectV2", 2),
932 ("selectShortV2", 2),
933 ("selectRightV2", 2),
934 ("selectNamedRightV2", 2),
935 ("selectAssignEndV2", 2),
936 ("selectPathV2", 1),
937 ("selectEnd2V2", 2),
938 ("selectEndV2", 2),
939 ("optionalCallEndV2", 2),
940 ("renderedValueV2", 2),
941 ("parenthesizedAssignmentValue", 0),
942 ("withRequestPhase", 0),
943 ];
944 const OTHERS: &[&str] = &[
945 "coverageHit",
946 "selectionBegin",
947 "optionalCallBegin",
948 "optionalCallContinued",
949 "defaultEntered",
950 "defaultEnteredV2",
951 "tryBegin",
952 "tryEnd",
953 "loopBegin",
954 "loopEntered",
955 "loopEnd",
956 "mcdcBegin",
957 "registerProbeV2",
958 "coverageHitV2",
959 "mcdcEndV2",
960 ];
961 let mut text = String::from("/* eslint-disable */\n");
967 for (name, before) in VALUES {
968 let leading = (0..*before)
969 .map(|index| format!("a{index}: any, "))
970 .collect::<String>();
971 text.push_str(&format!(
972 "export declare function {name}<T>({leading}value: T, ...rest: any[]): T;\n"
973 ));
974 }
975 for name in OTHERS {
976 text.push_str(&format!(
977 "export declare function {name}(...args: any[]): any;\n"
978 ));
979 }
980 text
981}
982
983fn limitation_from_source(value: &SourceLimitation) -> CandidateLimitation {
984 CandidateLimitation {
985 id: value.id.clone(),
986 kind: value.kind.clone(),
987 file: value.file.clone(),
988 line: value.line,
989 column: value.column,
990 source: value.source.clone(),
991 reason: value.reason.clone(),
992 }
993}
994
995fn bootstrap_loader(
1017 output: &mut crate::js_instrumenter::CandidateOutput,
1018 file: &str,
1019) -> Result<(), JavascriptFrontendError> {
1020 let Some(binding) = output.runtime.as_ref() else {
1021 return Ok(());
1022 };
1023 let direct = format!(
1024 "{} = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin",
1025 binding.mcdc_begin
1026 );
1027 let Some(found) = output.code.find(&direct) else {
1028 return Ok(());
1029 };
1030 let loader = format!(
1031 "{}\n",
1032 crate::js_instrumenter::runtime_loader(&runtime_specifier(file, "bootstrap.cjs")?)
1033 );
1034 let offset = output.code[..found]
1035 .rfind('\n')
1036 .map_or(0, |newline| newline + 1);
1037 let line = output.code[..offset].matches('\n').count();
1038 if let Some(serde_json::Value::String(mappings)) =
1039 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1040 {
1041 let mut lines: Vec<&str> = mappings.split(';').collect();
1042 if line <= lines.len() {
1043 lines.insert(line, "");
1044 *mappings = lines.join(";");
1045 }
1046 }
1047 output.code.insert_str(offset, &loader);
1048 Ok(())
1049}
1050
1051fn bootstrap_runtime(
1052 output: &mut crate::js_instrumenter::CandidateOutput,
1053 runtime_source: &str,
1054 browser_suite: bool,
1055) {
1056 let Some(binding) = output.runtime.as_ref() else {
1057 return;
1058 };
1059 let script = format!(
1060 "{} = globalThis.__supercovRuntime.mcdcBegin",
1061 binding.mcdc_begin
1062 );
1063 let direct = format!(
1064 "{} = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin",
1065 binding.mcdc_begin
1066 );
1067 let Some(found) = output
1068 .code
1069 .find(&script)
1070 .or_else(|| browser_suite.then(|| output.code.find(&direct)).flatten())
1071 else {
1072 return;
1073 };
1074 let Some(export_start) = runtime_source.rfind("\nexport {") else {
1075 return;
1076 };
1077 let offset = output.code[..found]
1078 .rfind('\n')
1079 .map_or(0, |newline| newline + 1);
1080 let prelude = format!(
1081 "(() => {{ if (!globalThis.__SUPERCOV_DIRECT_RUNTIME__) {{\n{}\n}} if (globalThis.__supercovRuntime == null) globalThis.__supercovRuntime = globalThis.__SUPERCOV_DIRECT_RUNTIME__; }})();\n",
1082 &runtime_source[..export_start]
1083 );
1084 let line = output.code[..offset].matches('\n').count();
1085 let added = prelude.matches('\n').count();
1086 if let Some(serde_json::Value::String(mappings)) =
1087 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1088 {
1089 let mut lines: Vec<&str> = mappings.split(';').collect();
1090 if line <= lines.len() {
1091 lines.splice(line..line, std::iter::repeat_n("", added));
1092 *mappings = lines.join(";");
1093 }
1094 }
1095 output.code.insert_str(offset, &prelude);
1096}
1097
1098fn relocated_project_file(
1099 workspace: &Path,
1100 project: &CoverageProject,
1101 source: Option<&PathBuf>,
1102) -> Option<PathBuf> {
1103 let source = source?;
1104 let relative = source.strip_prefix(&project.root).ok()?;
1105 Some(workspace.join(relative))
1106}
1107
1108fn write_vitest_config(
1109 workspace: &Path,
1110 project: &CoverageProject,
1111 generated: &Path,
1112) -> Result<PathBuf, JavascriptFrontendError> {
1113 let path = generated.join("vitest.config.mjs");
1114 let original = relocated_project_file(workspace, project, project.vitest_config.as_ref())
1115 .map(|path| path.display().to_string());
1116 let original = serde_json::to_string(&original).map_err(JavascriptFrontendError::Serialize)?;
1117 let source = format!(
1118 "import {{ createRequire }} from 'node:module';\n\
1119 import {{ pathToFileURL }} from 'node:url';\n\
1120 // pnpm's strict layout does not hoist vite to the project root: it\n\
1121 // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
1122 // resolved from this generated file fails. Vitest depends on vite, so\n\
1123 // fall back to resolving it through vitest's own tree rather than\n\
1124 // requiring the project to hoist anything.\n\
1125 const supercovRequire = createRequire(import.meta.url);\n\
1126 const supercovLoadVite = async () => {{\n\
1127 try {{\n\
1128 return await import('vite');\n\
1129 }} catch (error) {{\n\
1130 let entry;\n\
1131 try {{\n\
1132 entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
1133 }} catch {{\n\
1134 throw error;\n\
1135 }}\n\
1136 return await import(pathToFileURL(entry).href);\n\
1137 }}\n\
1138 }};\n\
1139 const viteNamespace = await supercovLoadVite();\n\
1140 import {{ resolve }} from 'node:path';\n\
1141 import SupercovVitestReporter from './node_modules/vitestReporter.mjs';\n\
1142 import {{ supercovBrowserCommands }} from './vitestCommands.mjs';\n\
1143 const vite = viteNamespace.default ?? viteNamespace;\n\
1144 const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
1145 const discoveredConfig = {original};\n\
1146 // Browser Mode runs the test file in the browser, so the node setup --\n\
1147 // which reaches node:fs to write evidence -- cannot load there. Vite\n\
1148 // externalises it and the suite fails before collecting a test.\n\
1149 const supercovBrowserMode = (config) => Boolean(config?.test?.browser?.enabled);\n\
1150 const supercovSetupFile = (config) =>\n\
1151 supercovBrowserMode(config)\n\
1152 ? '.supercov/node_modules/vitestBrowser.mjs'\n\
1153 : '.supercov/node_modules/vitest.mjs';\n\
1154 // Evidence leaves the browser over Vitest's own command channel, so\n\
1155 // the command is registered only for the mode that uses it.\n\
1156 const supercovBrowserTest = (config) =>\n\
1157 supercovBrowserMode(config)\n\
1158 ? {{ browser: {{ commands: supercovBrowserCommands(process.cwd()) }} }}\n\
1159 : {{}};\n\
1160 // Child projects do not inherit root setupFiles. Resolve each project's\n\
1161 // environment after Vitest has loaded file/glob/inline configs and\n\
1162 // applied --project, preserving the runner's own config semantics.\n\
1163 const configureProjects = (projects) => {{\n\
1164 const nodeSetup = resolve(process.cwd(), '.supercov/node_modules/vitest.mjs');\n\
1165 const browserSetup = resolve(process.cwd(), '.supercov/node_modules/vitestBrowser.mjs');\n\
1166 for (const project of projects ?? []) {{\n\
1167 const test = project.config;\n\
1168 const browser = Boolean(test.browser?.enabled);\n\
1169 const setup = browser ? browserSetup : nodeSetup;\n\
1170 test.setupFiles = [setup, ...(test.setupFiles ?? []).filter(file => file !== nodeSetup && file !== browserSetup)];\n\
1171 test.maxConcurrency = 1;\n\
1172 if (browser) {{\n\
1173 const commands = supercovBrowserCommands(process.cwd());\n\
1174 test.browser.commands = {{ ...test.browser.commands, ...commands }};\n\
1175 // Browser instances can already exist when reporters initialize.\n\
1176 // Their parent generates the browser command exports.\n\
1177 const server = project.browser?.parent ?? project.browser;\n\
1178 if (server?.commands) Object.assign(server.commands, commands);\n\
1179 }}\n\
1180 }}\n\
1181 }};\n\
1182 // Vitest's coverage measures the instrumented copy, probes included:\n\
1183 // a suite at 100% read 87.5% of branches, and autoUpdate would write\n\
1184 // what it measured into this file. Coverage is still collected and\n\
1185 // reported (see register.mjs). Vitest 0.x kept thresholds in coverage.\n\
1186 const supercovThresholdKeys = ['thresholds', 'lines', 'functions', 'branches', 'statements', 'perFile', '100', 'thresholdAutoUpdate'];\n\
1187 const supercovSkipCoverageThresholds = (coverage) => {{\n\
1188 const configured = supercovThresholdKeys.filter((key) => coverage?.[key] !== undefined);\n\
1189 for (const key of configured) delete coverage[key];\n\
1190 const collecting = coverage?.enabled === true ||\n\
1191 process.argv.some((argument) => /^--coverage(?:\\.enabled)?(?:=true)?$/.test(argument));\n\
1192 if (configured.length && collecting) process.__SUPERCOV_SKIPPED_COVERAGE_THRESHOLDS__?.('Vitest');\n\
1193 }};\n\
1194 export default async function supercovVitestConfig(env) {{\n\
1195 const originalPath = process.env.SUPERCOV_ORIGINAL_VITEST_CONFIG || discoveredConfig;\n\
1196 const loaded = originalPath ? await loadConfigFromFile(env, originalPath, process.cwd()) : undefined;\n\
1197 const config = mergeConfig(loaded?.config ?? {{}}, {{\n\
1198 cacheDir: resolve(process.cwd(), '.supercov/vitest-cache'),\n\
1199 test: {{ setupFiles: [resolve(process.cwd(), supercovSetupFile(loaded?.config))], maxConcurrency: 1, ...supercovBrowserTest(loaded?.config) }},\n\
1200 }});\n\
1201 supercovSkipCoverageThresholds(config.test?.coverage);\n\
1202 const configuredReporters = loaded?.config?.test?.reporters;\n\
1203 config.test ??= {{}};\n\
1204 config.test.reporters = configuredReporters\n\
1205 ? [...(Array.isArray(configuredReporters) ? configuredReporters : [configuredReporters]), new SupercovVitestReporter(configureProjects)]\n\
1206 : ['default', new SupercovVitestReporter(configureProjects)];\n\
1207 return config;\n\
1208 }}\n"
1209 );
1210 atomic_write(&path, source.as_bytes())?;
1211 Ok(path)
1212}
1213
1214fn configure_playwright_runtime(
1215 generated: &Path,
1216 project: &CoverageProject,
1217) -> Result<(), JavascriptFrontendError> {
1218 let adapter_path = generated.join("playwright.mjs");
1219 let mut adapter =
1220 fs::read_to_string(&adapter_path).map_err(|source| io_error(&adapter_path, source))?;
1221 adapter = adapter
1222 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module)
1223 .replace(
1224 "__SUPERCOV_PLAYWRIGHT_TEST_EXPORT__",
1225 &project.playwright_test_export,
1226 )
1227 .replace(
1231 "__SUPERCOV_PHASE_TIMING__",
1232 if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() == Ok("1") {
1233 "1"
1234 } else {
1235 "0"
1236 },
1237 );
1238 if project.playwright_module != "@playwright/test" {
1239 let facade = serde_json::to_string(&project.playwright_module)
1246 .expect("serializing a module specifier cannot fail");
1247 adapter = adapter.replace(
1248 "export * from \"@playwright/test\";",
1249 &format!("export * from {facade};"),
1250 );
1251 }
1252 let mut exports = Vec::new();
1253 if project.playwright_test_export != "test" {
1254 exports.push(format!(
1255 "export {{ instrumentedTest as {} }};",
1256 project.playwright_test_export
1257 ));
1258 }
1259 exports.extend(
1260 project
1261 .playwright_exports
1262 .iter()
1263 .filter(|name| {
1264 name.as_str() != "test"
1265 && name.as_str() != "expect"
1266 && *name != &project.playwright_test_export
1267 })
1268 .map(|name| {
1269 let encoded = serde_json::to_string(name)
1270 .expect("serializing a JavaScript export name cannot fail");
1271 format!("export const {name} = __supercovAdapterExport(adapter[{encoded}]);")
1272 }),
1273 );
1274 adapter = adapter.replace("/*__SUPERCOV_ADAPTER_EXPORTS__*/", &exports.join("\n"));
1275 atomic_write(&adapter_path, adapter.as_bytes())?;
1276
1277 let loader_path = generated.join("resolve-loader.mjs");
1278 let loader = fs::read_to_string(&loader_path)
1279 .map_err(|source| io_error(&loader_path, source))?
1280 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module);
1281 atomic_write(&loader_path, loader.as_bytes())
1282}
1283
1284fn write_playwright_config(
1285 workspace: &Path,
1286 project: &CoverageProject,
1287 generated: &Path,
1288) -> Result<PathBuf, JavascriptFrontendError> {
1289 let path = generated.join("playwright.config.mjs");
1290 let original = relocated_project_file(workspace, project, project.playwright_config.as_ref());
1291 let original_import = if let Some(original) = &original {
1292 let relative = original
1293 .strip_prefix(workspace)
1294 .map_err(|_| JavascriptFrontendError::UnsafeSourcePath(original.display().to_string()))?
1295 .to_string_lossy()
1296 .replace('\\', "/");
1297 let specifier = serde_json::to_string(&format!("../{relative}"))
1298 .map_err(JavascriptFrontendError::Serialize)?;
1299 format!(
1306 "import {{ existsSync, readFileSync }} from 'node:fs';\n\
1307 import {{ createRequire }} from 'node:module';\n\
1308 const originalFile = fileURLToPath(new URL({specifier}, import.meta.url));\n\
1309 const originalIsModule = (() => {{\n\
1310 if (/\\.m[jt]s$/.test(originalFile)) return true;\n\
1311 if (/\\.c[jt]s$/.test(originalFile)) return false;\n\
1312 for (let folder = dirname(originalFile); ; folder = dirname(folder)) {{\n\
1313 const manifest = resolve(folder, 'package.json');\n\
1314 if (existsSync(manifest)) {{\n\
1315 try {{ return JSON.parse(readFileSync(manifest, 'utf8')).type === 'module'; }} catch {{ return false; }}\n\
1316 }}\n\
1317 if (dirname(folder) === folder) return false;\n\
1318 }}\n\
1319 }})();\n\
1320 const original = originalIsModule\n\
1321 ? (await import(pathToFileURL(originalFile).href)).default\n\
1322 : createRequire(import.meta.url)(originalFile);\n"
1323 )
1324 } else {
1325 "const original = {};\n".into()
1326 };
1327 let source = format!(
1333 "import './node_modules/register.mjs';\n\
1334 import {{ dirname, isAbsolute, relative, resolve }} from 'node:path';\n\
1335 import {{ fileURLToPath, pathToFileURL }} from 'node:url';\n\
1336 {original_import}\
1337 const configExport = original && original.__esModule === true && Object.prototype.hasOwnProperty.call(original, 'default') ? original.default : original;\n\
1338 const resolvedValue = typeof configExport === 'function' ? await configExport({{ command: 'test', mode: 'test' }}) : configExport;\n\
1339 const resolved = resolvedValue ?? {{}};\n\
1340 const runtimeProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');\n\
1341 const originalDirectory = {};
1342 const sourceProjectRoot = process.env.SUPERCOV_SOURCE_PROJECT_ROOT;\n\
1343 const runtimePath = value => {{\n\
1344 if (!value) return value;\n\
1345 const absolute = isAbsolute(value) ? value : resolve(originalDirectory, value);\n\
1346 const local = relative(runtimeProjectRoot, absolute);\n\
1347 if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1348 if (sourceProjectRoot) {{\n\
1349 const sourceLocal = relative(sourceProjectRoot, absolute);\n\
1350 if (sourceLocal === '' || (!sourceLocal.startsWith('..') && !isAbsolute(sourceLocal))) return resolve(runtimeProjectRoot, sourceLocal);\n\
1351 }}\n\
1352 throw new Error('Supercov refuses a Playwright output/cwd outside the isolated project: ' + absolute);\n\
1353 }};\n\
1354 const normalizeWebServer = server => server ? ({{ ...server, cwd: runtimePath(server.cwd ?? originalDirectory) }}) : server;\n\
1355 const normalized = {{ ...resolved,\n\
1356 testDir: runtimePath(resolved.testDir),\n\
1357 outputDir: runtimePath(resolved.outputDir),\n\
1358 snapshotDir: runtimePath(resolved.snapshotDir),\n\
1359 projects: resolved.projects?.map(project => ({{ ...project, testDir: runtimePath(project.testDir), outputDir: runtimePath(project.outputDir), snapshotDir: runtimePath(project.snapshotDir) }})),\n\
1360 webServer: Array.isArray(resolved.webServer) ? resolved.webServer.map(normalizeWebServer) : normalizeWebServer(resolved.webServer),\n\
1361 }};\n\
1362 const configuredReporters = normalized.reporter;\n\
1363 const reporters = configuredReporters\n\
1364 ? (typeof configuredReporters === 'string' ? [[configuredReporters]] : (Array.isArray(configuredReporters[0]) ? configuredReporters : [configuredReporters]))\n\
1365 : [['list']];\n\
1366 const coverageReporter = resolve(runtimeProjectRoot, '.supercov/node_modules/playwrightReporter.mjs');\n\
1367 export default {{ ...normalized, reporter: [...reporters, [coverageReporter]] }};\n",
1368 serde_json::to_string(
1369 &original
1370 .as_ref()
1371 .and_then(|path| path.parent())
1372 .unwrap_or(workspace)
1373 .display()
1374 .to_string()
1375 )
1376 .map_err(JavascriptFrontendError::Serialize)?
1377 );
1378 atomic_write(&path, source.as_bytes())?;
1379 Ok(path)
1380}
1381
1382fn write_vitest_commands(generated: &Path) -> Result<(), JavascriptFrontendError> {
1383 let adapter = "import { mkdirSync } from 'node:fs';\n\
1384import { relative, resolve, sep } from 'node:path';\n\
1385import { atomicWriteFileSync } from './node_modules/atomic.mjs';\n\
1386import { inferTestProvenance } from './node_modules/provenance.mjs';\n\
1387// Browser Mode runs the test file in the browser, where none of this is\n\
1388// reachable: no node:fs to write evidence, no project root to relativise a\n\
1389// path against, no env to read. Vitest's own browser command channel carries\n\
1390// what the browser observed to node, which completes the same payload the node\n\
1391// setup writes -- so one contract keeps one implementation. The command is\n\
1392// awaited as part of the test, and node learns the test file from Vitest\n\
1393// rather than from the browser realm, which cannot be trusted to name it.\n\
1394const supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/;\n\
1395export function supercovBrowserCommands(root) {\n\
1396 return {\n\
1397 __supercovEvidence(context, payload, suffix) {\n\
1398 const evidenceDirectory = process.env['SUPERCOV_EVIDENCE_DIR'];\n\
1399 if (!evidenceDirectory) return;\n\
1400 // A suffix names one directory under the evidence directory. Anything\n\
1401 // else is refused rather than resolved, so a malformed one cannot send\n\
1402 // the write outside the directory it is supposed to stay in.\n\
1403 if (typeof suffix !== 'string' || !supercovEvidenceSuffix.test(suffix))\n\
1404 throw new Error('Supercov refused an unusable evidence suffix: ' + suffix);\n\
1405 const absolute = context.testPath;\n\
1406 const testFile = absolute ? relative(root, absolute).split(sep).join('/') : undefined;\n\
1407 const projectName = payload.projectName ?? context.project?.name;\n\
1408 delete payload.projectName;\n\
1409 // The browser has no environment to read, so the run identity is stamped\n\
1410 // here rather than sent from a realm that cannot know it.\n\
1411 const runId = process.env['SUPERCOV_RUN_ID'];\n\
1412 if (runId && payload.scope) payload.scope.runId = runId;\n\
1413 const complete = Object.assign({}, payload, testFile ? { testFile } : {}, {\n\
1414 provenance: inferTestProvenance({\n\
1415 runner: 'vitest',\n\
1416 file: testFile,\n\
1417 project: projectName,\n\
1418 explicitKind: process.env['SUPERCOV_TEST_KIND'],\n\
1419 }),\n\
1420 });\n\
1421 const directory = resolve(root, evidenceDirectory, suffix);\n\
1422 mkdirSync(directory, { recursive: true });\n\
1423 atomicWriteFileSync(resolve(directory, 'mcdc.json'), JSON.stringify(complete) + '\\n');\n\
1424 },\n\
1425 };\n\
1426}\n";
1427 atomic_write(&generated.join("vitestCommands.mjs"), adapter.as_bytes())
1428}
1429
1430pub fn prepare_javascript_frontend(
1433 workspace: &Path,
1434 project: &CoverageProject,
1435 collector_id: &str,
1436 cache_key: &str,
1437 command: &[String],
1438) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
1439 let generated = workspace.join(".supercov");
1440 let runtime_directory = generated.join("node_modules");
1446 timed(&SETUP.runtime_ns, || {
1447 copy_runtime(&runtime_directory, collector_id)
1448 })?;
1449 let configuration_started = Instant::now();
1450 configure_playwright_runtime(&runtime_directory, project)?;
1451 let playwright_config_path = write_playwright_config(workspace, project, &generated)?;
1452 let vitest_config_path = write_vitest_config(workspace, project, &generated)?;
1453 account(&SETUP.config_ns, configuration_started);
1454
1455 let mut exclusions = Vec::new();
1456 let mut decisions = BTreeMap::new();
1457 let mut points = BTreeMap::new();
1458 let mut branches = BTreeMap::new();
1459 let mut limitations = BTreeMap::new();
1460 for limitation in &project.source_limitations {
1461 limitations.insert(limitation.id.clone(), limitation_from_source(limitation));
1462 }
1463
1464 let runtime_path = runtime_directory.join("runtime.mjs");
1465 let standalone_runtime =
1466 fs::read_to_string(&runtime_path).map_err(|source| io_error(&runtime_path, source))?;
1467 let browser_suite = project.playwright_config.is_some() || project.browser_runner;
1472 let mut authored = BTreeSet::new();
1473 let sources_started = Instant::now();
1474 for file in &project.source_files {
1475 let path = checked_source_path(workspace, file)?;
1476 let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
1477 let capability_wrapper = runtime_specifier(file, "capability.mjs")?;
1478 let elide = crate::typescript_imports::elides_type_imports(workspace, file, command);
1479 let instrumented = timed(&SETUP.instrument_ns, || {
1480 instrument_with_import_policy(&source, file, &capability_wrapper, elide)
1481 });
1482 let mut output = match instrumented {
1483 Ok(output) => output,
1484 Err(crate::js_instrumenter::CandidateError::Parse(messages)) => {
1488 let limitation = CandidateLimitation {
1489 id: format!("javascript-file-not-parsed#{file}"),
1490 kind: "source-scope".into(),
1491 file: file.clone(),
1492 line: 1,
1493 column: 0,
1494 source: String::new(),
1495 reason: format!(
1496 "Supercov could not parse this file ({}); it runs as written and nothing in it is measured",
1497 messages.join("; ")
1498 ),
1499 };
1500 limitations.insert(limitation.id.clone(), limitation);
1501 continue;
1502 }
1503 Err(source) => {
1504 return Err(JavascriptFrontendError::Instrument {
1505 file: file.clone(),
1506 source,
1507 });
1508 }
1509 };
1510 if output
1514 .code
1515 .contains(crate::js_instrumenter::RUNTIME_TYPES_MODULE)
1516 {
1517 output.code = output.code.replace(
1518 crate::js_instrumenter::RUNTIME_TYPES_MODULE,
1519 &runtime_specifier(file, "runtime.mjs")?,
1520 );
1521 }
1522 bootstrap_runtime(&mut output, &standalone_runtime, browser_suite);
1523 if !browser_suite {
1524 bootstrap_loader(&mut output, file)?;
1525 }
1526 if matches!(
1531 path.extension().and_then(|value| value.to_str()),
1532 Some("ts" | "tsx" | "mts" | "cts")
1533 ) {
1534 output.code = generated_source_banner(&output.code);
1535 }
1536 let code = match inline_instrumentation_map(
1543 &output.code,
1544 output.map.as_ref(),
1545 &project.root.join(file),
1546 &source,
1547 ) {
1548 Some(code) => code,
1549 None => output.code.clone(),
1550 };
1551 keep_authored(workspace, &mut authored, file, &source)?;
1552 atomic_write(&path, code.as_bytes())?;
1553 exclusions.extend(output.excluded_statements);
1554 for value in output.decisions {
1555 decisions.insert(value.id.clone(), value);
1556 }
1557 for value in output.points {
1558 points.insert(value.id.clone(), value);
1559 }
1560 for value in output.branches {
1561 branches.insert(value.id.clone(), value);
1562 }
1563 for value in output.coverage_limitations {
1564 limitations.insert(value.id.clone(), value);
1565 }
1566 }
1567 account(&SETUP.sources_ns, sources_started);
1568 write_vitest_commands(&generated)?;
1569
1570 let assertions_started = Instant::now();
1571 let mut assertion_calls = 0;
1572 for entry in &project.source_scope.entries {
1573 let path = checked_source_path(workspace, &entry.file)?;
1574 let Ok(source) = fs::read_to_string(&path) else {
1575 continue;
1576 };
1577 let capability_wrapper = (!project.source_files.contains(&entry.file))
1578 .then(|| runtime_specifier(&entry.file, "capability.mjs"))
1579 .transpose()?;
1580 let assertion_runtime = runtime_specifier(&entry.file, "bootstrap.cjs")?;
1581 let output =
1582 match crate::js_instrumenter::instrument_node_assertion_phases_with_runtime_imports(
1583 &source,
1584 &entry.file,
1585 std::slice::from_ref(&project.playwright_module),
1586 capability_wrapper.as_deref(),
1587 Some(&assertion_runtime),
1588 ) {
1589 Ok(output) => output,
1590 Err(crate::js_instrumenter::CandidateError::Parse(_)) => continue,
1593 Err(source) => {
1594 return Err(JavascriptFrontendError::Instrument {
1595 file: entry.file.clone(),
1596 source,
1597 });
1598 }
1599 };
1600 if output.assertions > 0 || output.capability_imports > 0 {
1601 keep_authored(workspace, &mut authored, &entry.file, &source)?;
1602 atomic_write(&path, output.code.as_bytes())?;
1603 assertion_calls += output.assertions;
1604 }
1605 }
1606
1607 account(&SETUP.assertion_ns, assertions_started);
1608
1609 let mut manifest = JavascriptManifest {
1610 decisions: decisions.into_values().collect(),
1611 points: points.into_values().collect(),
1612 branches: branches.into_values().collect(),
1613 limitations: limitations.into_values().collect(),
1614 scope: project.source_scope.clone(),
1615 };
1616 manifest.decisions.sort_by_key(|value| {
1617 (
1618 value.file.clone(),
1619 value.line,
1620 value.column,
1621 value.id.clone(),
1622 )
1623 });
1624 manifest.points.sort_by_key(|value| {
1625 (
1626 value.file.clone(),
1627 value.line,
1628 value.column,
1629 value.id.clone(),
1630 )
1631 });
1632 manifest.branches.sort_by_key(|value| {
1633 (
1634 value.file.clone(),
1635 value.line,
1636 value.column,
1637 value.id.clone(),
1638 )
1639 });
1640 manifest.limitations.sort_by_key(|value| {
1641 (
1642 value.file.clone(),
1643 value.line,
1644 value.column,
1645 value.id.clone(),
1646 )
1647 });
1648
1649 atomic_write(
1650 &workspace.join(AUTHORED_LIST),
1651 &serde_json::to_vec(&authored).map_err(JavascriptFrontendError::Serialize)?,
1652 )?;
1653 atomic_write(
1654 &generated.join("statement-exclusions.json"),
1655 &serde_json::to_vec(&exclusions).map_err(JavascriptFrontendError::Serialize)?,
1656 )?;
1657 let manifest_path = generated.join("manifest.json");
1658 let mut encoded =
1659 serde_json::to_vec_pretty(&manifest).map_err(JavascriptFrontendError::Serialize)?;
1660 encoded.push(b'\n');
1661 atomic_write(&manifest_path, &encoded)?;
1662 atomic_write(
1663 &generated.join("instrumentation-complete"),
1664 b"coverage-completeness-v2\n",
1665 )?;
1666 timed(&SETUP.cache_ns, || {
1667 write_javascript_frontend_cache(workspace, project, cache_key, assertion_calls)
1668 })?;
1669 Ok(PreparedJavascriptFrontend {
1670 manifest,
1671 manifest_path,
1672 preload_path: generated.join("node_modules/register.mjs"),
1673 playwright_config_path,
1674 vitest_config_path,
1675 assertion_calls,
1676 })
1677}
1678
1679#[cfg(test)]
1680mod tests {
1681 #[test]
1682 fn ancestor_description_names_each_component_and_its_state() {
1683 let root = std::env::temp_dir().join(format!("supercov-ancestors-{}", unique()));
1684 fs::create_dir_all(root.join("present")).unwrap();
1685 fs::write(root.join("present/file.txt"), b"x").unwrap();
1686 let described = super::describe_ancestors(&root.join("present/file.txt/child"));
1687 assert!(described.contains("present=dir"), "{described}");
1688 assert!(described.contains("file.txt=file"), "{described}");
1689 assert!(described.ends_with("child=missing"), "{described}");
1690 fs::remove_dir_all(&root).unwrap();
1691 }
1692
1693 use super::*;
1694 use crate::project_discovery::discover_coverage_project;
1695
1696 fn temporary(name: &str) -> PathBuf {
1697 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1698 .join("../../target/supercov-test-fixtures")
1699 .join(format!("javascript-frontend-{name}-{}", unique()));
1700 fs::create_dir_all(&path).unwrap();
1706 crate::workspace::canonicalize_simplified(path).unwrap()
1707 }
1708
1709 #[test]
1710 fn browser_mode_gets_a_browser_setup_and_the_command_that_carries_its_evidence() {
1711 let generated = temporary("browser-mode-config");
1712 write_vitest_commands(&generated).unwrap();
1713 let adapter = fs::read_to_string(generated.join("vitestCommands.mjs")).unwrap();
1714 assert!(adapter.contains("export function supercovBrowserCommands(root)"));
1718 assert!(adapter.contains("__supercovEvidence(context, payload, suffix)"));
1719 assert!(adapter.contains("const absolute = context.testPath;"));
1720 assert!(!adapter.contains("configureServer"), "{adapter}");
1721 assert!(!adapter.contains("__supercov/evidence"), "{adapter}");
1722 assert!(adapter.contains("supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/"));
1725 assert!(adapter.contains("Supercov refused an unusable evidence suffix"));
1726 }
1727
1728 #[test]
1729 fn a_browser_mode_project_loads_the_setup_that_can_run_in_a_browser() {
1730 let workspace = temporary("browser-mode-setup");
1733 let generated = workspace.join(".supercov");
1734 fs::create_dir_all(&generated).unwrap();
1735 fs::write(workspace.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1736 fs::create_dir_all(workspace.join("src")).unwrap();
1737 fs::write(
1738 workspace.join("src/example.mjs"),
1739 "export const one = () => 1;\n",
1740 )
1741 .unwrap();
1742 let project = discover_coverage_project(
1743 &workspace,
1744 &BTreeMap::new(),
1745 &["npx".into(), "vitest".into(), "run".into()],
1746 )
1747 .unwrap();
1748 let path = write_vitest_config(&workspace, &project, &generated).unwrap();
1749 let config = fs::read_to_string(path).unwrap();
1750 assert!(config.contains("config?.test?.browser?.enabled"));
1751 assert!(config.contains("'.supercov/node_modules/vitestBrowser.mjs'"));
1752 assert!(config.contains("'.supercov/node_modules/vitest.mjs'"));
1753 assert!(config.contains("browser: { commands: supercovBrowserCommands(process.cwd()) }"));
1755 assert!(config.contains("...supercovBrowserTest(loaded?.config)"));
1756 }
1757
1758 #[test]
1759 fn runtime_isolation_replaces_only_the_assignment_marker() {
1760 let source = concat!(
1761 "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n",
1762 "const selected = runtimeInstanceToken === \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\";\n"
1763 );
1764 let isolated = isolate_runtime(source, "collector-123").unwrap();
1765 assert!(isolated.contains("runtimeInstanceToken = \"collector-123\""));
1766 assert!(isolated.contains("=== \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\""));
1767 }
1768
1769 #[test]
1770 fn copied_runtime_does_not_reference_unshipped_source_maps() {
1771 let generated = temporary("runtime-source-maps");
1772 copy_runtime(&generated, "collector-test").unwrap();
1773 for name in ["vitest.mjs", "provenance.mjs", "atomic.mjs"] {
1774 let contents = fs::read_to_string(generated.join(name)).unwrap();
1775 assert!(
1776 !contents.contains("sourceMappingURL"),
1777 "runtime shim retained a source-map directive: {name}"
1778 );
1779 }
1780 fs::remove_dir_all(generated).unwrap();
1781 }
1782
1783 #[test]
1784 fn prepares_sorted_complete_manifest_without_touching_source_project() {
1785 let source_root = temporary("source");
1786 let workspace = temporary("workspace");
1787 fs::create_dir_all(source_root.join("src")).unwrap();
1788 fs::write(
1789 source_root.join("src/example.mjs"),
1790 "export function value(a, b) { if (a || b) return 1; return 0; }\n",
1791 )
1792 .unwrap();
1793 fs::write(source_root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
1794 fs::create_dir_all(workspace.join("src")).unwrap();
1795 fs::create_dir_all(workspace.join(".supercov")).unwrap();
1796 fs::copy(
1797 source_root.join("src/example.mjs"),
1798 workspace.join("src/example.mjs"),
1799 )
1800 .unwrap();
1801 let project = discover_coverage_project(
1802 &source_root,
1803 &BTreeMap::new(),
1804 &["node".into(), "--test".into()],
1805 )
1806 .unwrap();
1807 let original = fs::read_to_string(source_root.join("src/example.mjs")).unwrap();
1808 let prepared =
1809 prepare_javascript_frontend(&workspace, &project, "collector-test", "cache-test", &[])
1810 .unwrap();
1811 assert_eq!(
1812 fs::read_to_string(source_root.join("src/example.mjs")).unwrap(),
1813 original
1814 );
1815 let transformed = fs::read_to_string(workspace.join("src/example.mjs")).unwrap();
1816 assert!(transformed.contains("__SUPERCOV_DIRECT_RUNTIME__"));
1817 assert_eq!(prepared.manifest.decisions.len(), 1);
1818 assert!(!prepared.manifest.points.is_empty());
1819 assert_eq!(prepared.manifest.scope, project.source_scope);
1820 assert!(prepared.manifest_path.is_file());
1821 assert!(prepared.preload_path.is_file());
1822 assert!(prepared.playwright_config_path.is_file());
1823 assert!(prepared.vitest_config_path.is_file());
1824 assert_eq!(prepared.assertion_calls, 0);
1825 let cache = read_javascript_frontend_cache(&workspace, "cache-test").unwrap();
1826 assert_eq!(
1827 javascript_frontend_reuse_paths(&cache),
1828 [
1829 PathBuf::from(".supercov/frontend-cache.json"),
1830 PathBuf::from(".supercov/frontend-cache-artifacts"),
1831 ]
1832 );
1833 assert!(
1834 cache
1835 .artifacts
1836 .iter()
1837 .all(|artifact| !artifact.cache_file.contains("src/")
1838 && !artifact.cache_file.contains("tests/"))
1839 );
1840 fs::write(workspace.join("src/example.mjs"), &original).unwrap();
1841 fs::remove_file(&prepared.manifest_path).unwrap();
1842 let restored = load_cached_javascript_frontend(&workspace, &cache).unwrap();
1843 assert_eq!(restored.manifest, prepared.manifest);
1844 assert_eq!(
1845 fs::read_to_string(workspace.join("src/example.mjs")).unwrap(),
1846 transformed
1847 );
1848 fs::write(workspace.join(&cache.artifacts[0].cache_file), "corrupt").unwrap();
1849 assert!(read_javascript_frontend_cache(&workspace, "cache-test").is_none());
1850 fs::remove_dir_all(source_root).unwrap();
1851 fs::remove_dir_all(workspace).unwrap();
1852 }
1853
1854 #[test]
1855 fn embedded_runtime_contains_every_declared_shim() {
1856 for name in RUNTIME_FILES {
1857 let bytes = embedded_runtime(name).unwrap();
1858 assert!(!bytes.is_empty(), "embedded runtime is empty: {name}");
1859 }
1860 assert!(
1861 std::str::from_utf8(embedded_runtime("runtime.mjs").unwrap())
1862 .unwrap()
1863 .contains(RUNTIME_INSTANCE_MARKER)
1864 );
1865 }
1866}