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 Some(found) = helper_declaration(
1037 &output.code,
1038 &binding.mcdc_begin,
1039 "globalThis.__SUPERCOV_DIRECT_RUNTIME__",
1040 true,
1041 ) else {
1042 return Ok(());
1043 };
1044 let loader = format!(
1045 "{}\n",
1046 crate::js_instrumenter::runtime_loader(&runtime_specifier(file, "bootstrap.cjs")?)
1047 );
1048 let offset = output.code[..found]
1049 .rfind('\n')
1050 .map_or(0, |newline| newline + 1);
1051 let line = output.code[..offset].matches('\n').count();
1052 if let Some(serde_json::Value::String(mappings)) =
1053 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1054 {
1055 let mut lines: Vec<&str> = mappings.split(';').collect();
1056 if line <= lines.len() {
1057 lines.insert(line, "");
1058 *mappings = lines.join(";");
1059 }
1060 }
1061 output.code.insert_str(offset, &loader);
1062 Ok(())
1063}
1064
1065fn keep_directives_first(source: &str, output: &mut crate::js_instrumenter::CandidateOutput) {
1073 let mut directives = Vec::new();
1074 for line in source.lines() {
1075 let trimmed = line.trim();
1076 if trimmed.starts_with("//=") {
1077 directives.push(line);
1078 } else if !(trimmed.is_empty() || trimmed.starts_with("//")) {
1079 break;
1080 }
1081 }
1082 if directives.is_empty() {
1083 return;
1084 }
1085 let lines = output.code.split('\n').collect::<Vec<_>>();
1086 let mut moved = Vec::new();
1087 let mut from = 0;
1088 for directive in &directives {
1089 let Some(index) = lines[from..].iter().position(|line| line == directive) else {
1090 return;
1091 };
1092 moved.push(from + index);
1093 from += index + 1;
1094 }
1095 if moved
1096 .iter()
1097 .enumerate()
1098 .all(|(position, index)| position == *index)
1099 {
1100 return;
1101 }
1102 let reorder = |items: Vec<&str>| -> Vec<String> {
1103 let mut first = moved
1104 .iter()
1105 .map(|index| items[*index].to_owned())
1106 .collect::<Vec<_>>();
1107 first.extend(
1108 items
1109 .iter()
1110 .enumerate()
1111 .filter(|(index, _)| !moved.contains(index))
1112 .map(|(_, item)| (*item).to_owned()),
1113 );
1114 first
1115 };
1116 let mut keep_map = true;
1120 if let Some(serde_json::Value::String(mappings)) =
1121 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1122 {
1123 let mut segments = mappings.split(';').collect::<Vec<_>>();
1124 segments.resize(segments.len().max(lines.len()), "");
1125 if moved.iter().all(|index| segments[*index].is_empty()) {
1126 *mappings = reorder(segments).join(";");
1127 } else {
1128 keep_map = false;
1129 }
1130 }
1131 if !keep_map {
1132 output.map = None;
1133 }
1134 output.code = reorder(lines).join("\n");
1135}
1136
1137fn compact_runtime(runtime_source: &str) -> String {
1142 let Some(export_start) = runtime_source.rfind("\nexport {") else {
1143 return runtime_source.to_owned();
1144 };
1145 let body = &runtime_source[..export_start];
1146 let allocator = oxc_allocator::Allocator::default();
1147 let parsed = oxc_parser::Parser::new(&allocator, body, oxc_span::SourceType::mjs()).parse();
1148 if parsed.panicked || !parsed.errors.is_empty() {
1149 return runtime_source.to_owned();
1150 }
1151 let options = oxc_codegen::CodegenOptions {
1152 minify: true,
1153 comments: oxc_codegen::CommentOptions::disabled(),
1154 ..oxc_codegen::CodegenOptions::default()
1155 };
1156 let compact = oxc_codegen::Codegen::new()
1157 .with_options(options)
1158 .build(&parsed.program)
1159 .code;
1160 format!("{compact}{}", &runtime_source[export_start..])
1161}
1162
1163fn helper_declaration(code: &str, name: &str, global: &str, typed: bool) -> Option<usize> {
1167 let value = format!("= {global}.mcdcBegin");
1168 let mut from = 0;
1169 while let Some(found) = code[from..].find(name) {
1170 let at = from + found;
1171 let rest = &code[at + name.len()..];
1172 let declared = rest.split([',', ';', '\n']).next().unwrap_or_default();
1173 if declared.trim() == value
1174 || (typed && declared.starts_with(": ") && declared.trim_end().ends_with(&value))
1175 {
1176 return Some(at);
1177 }
1178 from = at + name.len();
1179 }
1180 None
1181}
1182
1183pub fn builds_images(root: &Path) -> bool {
1186 let named = |directory: &Path| {
1187 fs::read_dir(directory)
1188 .into_iter()
1189 .flatten()
1190 .flatten()
1191 .any(|entry| {
1192 let name = entry.file_name().to_string_lossy().to_lowercase();
1193 name == "dockerfile"
1194 || name.ends_with(".dockerfile")
1195 || name.starts_with("dockerfile.")
1196 || matches!(
1197 name.as_str(),
1198 "compose.yaml"
1199 | "compose.yml"
1200 | "docker-compose.yaml"
1201 | "docker-compose.yml"
1202 )
1203 })
1204 };
1205 named(root)
1206 || fs::read_dir(root)
1207 .into_iter()
1208 .flatten()
1209 .flatten()
1210 .any(|entry| {
1211 entry.file_type().is_ok_and(|kind| kind.is_dir())
1212 && !entry.file_name().to_string_lossy().starts_with('.')
1213 && entry.file_name() != "node_modules"
1214 && named(&entry.path())
1215 })
1216}
1217
1218fn bootstrap_runtime(
1219 output: &mut crate::js_instrumenter::CandidateOutput,
1220 runtime_source: &str,
1221 browser_suite: bool,
1222 images: bool,
1223) {
1224 let Some(binding) = output.runtime.as_ref() else {
1225 return;
1226 };
1227 let Some(found) = helper_declaration(
1234 &output.code,
1235 &binding.mcdc_begin,
1236 "globalThis.__supercovRuntime",
1237 images,
1238 )
1239 .or_else(|| {
1240 browser_suite
1241 .then(|| {
1242 helper_declaration(
1243 &output.code,
1244 &binding.mcdc_begin,
1245 "globalThis.__SUPERCOV_DIRECT_RUNTIME__",
1246 images,
1247 )
1248 })
1249 .flatten()
1250 }) else {
1251 return;
1252 };
1253 let Some(export_start) = runtime_source.rfind("\nexport {") else {
1254 return;
1255 };
1256 let offset = output.code[..found]
1257 .rfind('\n')
1258 .map_or(0, |newline| newline + 1);
1259 let prelude = format!(
1260 "(() => {{ if (!globalThis.__SUPERCOV_DIRECT_RUNTIME__) {{\n{}\n}} if (globalThis.__supercovRuntime == null) globalThis.__supercovRuntime = globalThis.__SUPERCOV_DIRECT_RUNTIME__; }})();\n",
1261 &runtime_source[..export_start]
1262 );
1263 let line = output.code[..offset].matches('\n').count();
1264 let added = prelude.matches('\n').count();
1265 if let Some(serde_json::Value::String(mappings)) =
1266 output.map.as_mut().and_then(|map| map.get_mut("mappings"))
1267 {
1268 let mut lines: Vec<&str> = mappings.split(';').collect();
1269 if line <= lines.len() {
1270 lines.splice(line..line, std::iter::repeat_n("", added));
1271 *mappings = lines.join(";");
1272 }
1273 }
1274 output.code.insert_str(offset, &prelude);
1275}
1276
1277fn relocated_project_file(
1278 workspace: &Path,
1279 project: &CoverageProject,
1280 source: Option<&PathBuf>,
1281) -> Option<PathBuf> {
1282 let source = source?;
1283 let relative = source.strip_prefix(&project.root).ok()?;
1284 Some(workspace.join(relative))
1285}
1286
1287fn write_vitest_config(
1288 workspace: &Path,
1289 project: &CoverageProject,
1290 generated: &Path,
1291) -> Result<PathBuf, JavascriptFrontendError> {
1292 let path = generated.join("vitest.config.mjs");
1293 let original = relocated_project_file(workspace, project, project.vitest_config.as_ref())
1294 .map(|path| path.display().to_string());
1295 let original = serde_json::to_string(&original).map_err(JavascriptFrontendError::Serialize)?;
1296 let source = format!(
1297 "import {{ createRequire }} from 'node:module';\n\
1298 import {{ pathToFileURL }} from 'node:url';\n\
1299 // pnpm's strict layout does not hoist vite to the project root: it\n\
1300 // lives inside vitest's virtual store, so a bare 'vite' specifier\n\
1301 // resolved from this generated file fails. Vitest depends on vite, so\n\
1302 // fall back to resolving it through vitest's own tree rather than\n\
1303 // requiring the project to hoist anything.\n\
1304 const supercovRequire = createRequire(import.meta.url);\n\
1305 const supercovLoadVite = async () => {{\n\
1306 try {{\n\
1307 return await import('vite');\n\
1308 }} catch (error) {{\n\
1309 let entry;\n\
1310 try {{\n\
1311 entry = createRequire(supercovRequire.resolve('vitest')).resolve('vite');\n\
1312 }} catch {{\n\
1313 throw error;\n\
1314 }}\n\
1315 return await import(pathToFileURL(entry).href);\n\
1316 }}\n\
1317 }};\n\
1318 const viteNamespace = await supercovLoadVite();\n\
1319 import {{ resolve }} from 'node:path';\n\
1320 import SupercovVitestReporter from './node_modules/vitestReporter.mjs';\n\
1321 import {{ supercovBrowserCommands }} from './vitestCommands.mjs';\n\
1322 const vite = viteNamespace.default ?? viteNamespace;\n\
1323 const {{ loadConfigFromFile, mergeConfig }} = vite;\n\
1324 const discoveredConfig = {original};\n\
1325 // Browser Mode runs the test file in the browser, so the node setup --\n\
1326 // which reaches node:fs to write evidence -- cannot load there. Vite\n\
1327 // externalises it and the suite fails before collecting a test.\n\
1328 const supercovBrowserMode = (config) => Boolean(config?.test?.browser?.enabled);\n\
1329 const supercovSetupFile = (config) =>\n\
1330 supercovBrowserMode(config)\n\
1331 ? '.supercov/node_modules/vitestBrowser.mjs'\n\
1332 : '.supercov/node_modules/vitest.mjs';\n\
1333 // Evidence leaves the browser over Vitest's own command channel, so\n\
1334 // the command is registered only for the mode that uses it.\n\
1335 const supercovBrowserTest = (config) =>\n\
1336 supercovBrowserMode(config)\n\
1337 ? {{ browser: {{ commands: supercovBrowserCommands(process.cwd()) }} }}\n\
1338 : {{}};\n\
1339 // Child projects do not inherit root setupFiles. Resolve each project's\n\
1340 // environment after Vitest has loaded file/glob/inline configs and\n\
1341 // applied --project, preserving the runner's own config semantics.\n\
1342 const configureProjects = (projects) => {{\n\
1343 const nodeSetup = resolve(process.cwd(), '.supercov/node_modules/vitest.mjs');\n\
1344 const browserSetup = resolve(process.cwd(), '.supercov/node_modules/vitestBrowser.mjs');\n\
1345 for (const project of projects ?? []) {{\n\
1346 const test = project.config;\n\
1347 const browser = Boolean(test.browser?.enabled);\n\
1348 const setup = browser ? browserSetup : nodeSetup;\n\
1349 test.setupFiles = [setup, ...(test.setupFiles ?? []).filter(file => file !== nodeSetup && file !== browserSetup)];\n\
1350 test.maxConcurrency = 1;\n\
1351 if (browser) {{\n\
1352 const commands = supercovBrowserCommands(process.cwd());\n\
1353 test.browser.commands = {{ ...test.browser.commands, ...commands }};\n\
1354 // Browser instances can already exist when reporters initialize.\n\
1355 // Their parent generates the browser command exports.\n\
1356 const server = project.browser?.parent ?? project.browser;\n\
1357 if (server?.commands) Object.assign(server.commands, commands);\n\
1358 }}\n\
1359 }}\n\
1360 }};\n\
1361 // Vitest's coverage measures the instrumented copy, probes included:\n\
1362 // a suite at 100% read 87.5% of branches, and autoUpdate would write\n\
1363 // what it measured into this file. Coverage is still collected and\n\
1364 // reported (see register.mjs). Vitest 0.x kept thresholds in coverage.\n\
1365 const supercovThresholdKeys = ['thresholds', 'lines', 'functions', 'branches', 'statements', 'perFile', '100', 'thresholdAutoUpdate'];\n\
1366 const supercovSkipCoverageThresholds = (coverage) => {{\n\
1367 const configured = supercovThresholdKeys.filter((key) => coverage?.[key] !== undefined);\n\
1368 for (const key of configured) delete coverage[key];\n\
1369 const collecting = coverage?.enabled === true ||\n\
1370 process.argv.some((argument) => /^--coverage(?:\\.enabled)?(?:=true)?$/.test(argument));\n\
1371 if (configured.length && collecting) process.__SUPERCOV_SKIPPED_COVERAGE_THRESHOLDS__?.('Vitest');\n\
1372 }};\n\
1373 export default async function supercovVitestConfig(env) {{\n\
1374 const originalPath = process.env.SUPERCOV_ORIGINAL_VITEST_CONFIG || discoveredConfig;\n\
1375 const loaded = originalPath ? await loadConfigFromFile(env, originalPath, process.cwd()) : undefined;\n\
1376 const config = mergeConfig(loaded?.config ?? {{}}, {{\n\
1377 cacheDir: resolve(process.cwd(), '.supercov/vitest-cache'),\n\
1378 test: {{ setupFiles: [resolve(process.cwd(), supercovSetupFile(loaded?.config))], maxConcurrency: 1, ...supercovBrowserTest(loaded?.config) }},\n\
1379 }});\n\
1380 supercovSkipCoverageThresholds(config.test?.coverage);\n\
1381 const configuredReporters = loaded?.config?.test?.reporters;\n\
1382 config.test ??= {{}};\n\
1383 config.test.reporters = configuredReporters\n\
1384 ? [...(Array.isArray(configuredReporters) ? configuredReporters : [configuredReporters]), new SupercovVitestReporter(configureProjects)]\n\
1385 : ['default', new SupercovVitestReporter(configureProjects)];\n\
1386 return config;\n\
1387 }}\n"
1388 );
1389 atomic_write(&path, source.as_bytes())?;
1390 Ok(path)
1391}
1392
1393fn link_playwright_package(workspace: &Path, project: &CoverageProject, runtime_directory: &Path) {
1402 if project.playwright_module != "@playwright/test" {
1403 return;
1404 }
1405 let package = |directory: &Path| directory.join("node_modules/@playwright/test");
1406 if workspace
1407 .ancestors()
1408 .any(|directory| package(directory).is_dir())
1409 {
1410 return;
1411 }
1412 let Some(config) = project.playwright_config.as_ref() else {
1413 return;
1414 };
1415 let Some(directory) = config
1416 .strip_prefix(&project.root)
1417 .ok()
1418 .and_then(|relative| workspace.join(relative).parent().map(Path::to_path_buf))
1419 else {
1420 return;
1421 };
1422 let Some(found) = directory
1423 .ancestors()
1424 .take_while(|candidate| candidate.starts_with(workspace))
1425 .map(package)
1426 .find(|candidate| candidate.is_dir())
1427 else {
1428 return;
1429 };
1430 let scope = runtime_directory.join("@playwright");
1431 if fs::create_dir_all(&scope).is_ok() {
1432 let _ = crate::workspace::create_link(&found, &scope.join("test"), true);
1433 }
1434}
1435
1436fn configure_playwright_runtime(
1437 generated: &Path,
1438 project: &CoverageProject,
1439) -> Result<(), JavascriptFrontendError> {
1440 let adapter_path = generated.join("playwright.mjs");
1441 let mut adapter =
1442 fs::read_to_string(&adapter_path).map_err(|source| io_error(&adapter_path, source))?;
1443 adapter = adapter
1444 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module)
1445 .replace(
1446 "__SUPERCOV_PLAYWRIGHT_TEST_EXPORT__",
1447 &project.playwright_test_export,
1448 )
1449 .replace(
1453 "__SUPERCOV_PHASE_TIMING__",
1454 if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() == Ok("1") {
1455 "1"
1456 } else {
1457 "0"
1458 },
1459 );
1460 if project.playwright_module != "@playwright/test" {
1461 let facade = serde_json::to_string(&project.playwright_module)
1468 .expect("serializing a module specifier cannot fail");
1469 adapter = adapter.replace(
1470 "export * from \"@playwright/test\";",
1471 &format!("export * from {facade};"),
1472 );
1473 }
1474 let mut exports = Vec::new();
1475 if project.playwright_test_export != "test" {
1476 exports.push(format!(
1477 "export {{ instrumentedTest as {} }};",
1478 project.playwright_test_export
1479 ));
1480 }
1481 exports.extend(
1482 project
1483 .playwright_exports
1484 .iter()
1485 .filter(|name| {
1486 name.as_str() != "test"
1487 && name.as_str() != "expect"
1488 && *name != &project.playwright_test_export
1489 })
1490 .map(|name| {
1491 let encoded = serde_json::to_string(name)
1492 .expect("serializing a JavaScript export name cannot fail");
1493 format!("export const {name} = __supercovAdapterExport(adapter[{encoded}]);")
1494 }),
1495 );
1496 adapter = adapter.replace("/*__SUPERCOV_ADAPTER_EXPORTS__*/", &exports.join("\n"));
1497 atomic_write(&adapter_path, adapter.as_bytes())?;
1498
1499 let loader_path = generated.join("resolve-loader.mjs");
1500 let loader = fs::read_to_string(&loader_path)
1501 .map_err(|source| io_error(&loader_path, source))?
1502 .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module);
1503 atomic_write(&loader_path, loader.as_bytes())
1504}
1505
1506fn write_playwright_config(
1507 workspace: &Path,
1508 project: &CoverageProject,
1509 generated: &Path,
1510) -> Result<PathBuf, JavascriptFrontendError> {
1511 let path = generated.join("playwright.config.mjs");
1512 let original = relocated_project_file(workspace, project, project.playwright_config.as_ref());
1513 let original_import = if let Some(original) = &original {
1514 let relative = original
1515 .strip_prefix(workspace)
1516 .map_err(|_| JavascriptFrontendError::UnsafeSourcePath(original.display().to_string()))?
1517 .to_string_lossy()
1518 .replace('\\', "/");
1519 let specifier = serde_json::to_string(&format!("../{relative}"))
1520 .map_err(JavascriptFrontendError::Serialize)?;
1521 format!(
1528 "import {{ existsSync, readFileSync }} from 'node:fs';\n\
1529 import {{ createRequire }} from 'node:module';\n\
1530 const originalFile = fileURLToPath(new URL({specifier}, import.meta.url));\n\
1531 const originalIsModule = (() => {{\n\
1532 if (/\\.m[jt]s$/.test(originalFile)) return true;\n\
1533 if (/\\.c[jt]s$/.test(originalFile)) return false;\n\
1534 for (let folder = dirname(originalFile); ; folder = dirname(folder)) {{\n\
1535 const manifest = resolve(folder, 'package.json');\n\
1536 if (existsSync(manifest)) {{\n\
1537 try {{ return JSON.parse(readFileSync(manifest, 'utf8')).type === 'module'; }} catch {{ return false; }}\n\
1538 }}\n\
1539 if (dirname(folder) === folder) return false;\n\
1540 }}\n\
1541 }})();\n\
1542 const original = originalIsModule\n\
1543 ? (await import(pathToFileURL(originalFile).href)).default\n\
1544 : createRequire(import.meta.url)(originalFile);\n"
1545 )
1546 } else {
1547 "const original = {};\n".into()
1548 };
1549 let source = format!(
1555 "import './node_modules/register.mjs';\n\
1556 import {{ dirname, isAbsolute, relative, resolve }} from 'node:path';\n\
1557 import {{ fileURLToPath, pathToFileURL }} from 'node:url';\n\
1558 {original_import}\
1559 const configExport = original && original.__esModule === true && Object.prototype.hasOwnProperty.call(original, 'default') ? original.default : original;\n\
1560 const resolvedValue = typeof configExport === 'function' ? await configExport({{ command: 'test', mode: 'test' }}) : configExport;\n\
1561 const resolved = resolvedValue ?? {{}};\n\
1562 const runtimeProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');\n\
1563 const originalDirectory = {};
1564 const sourceProjectRoot = process.env.SUPERCOV_SOURCE_PROJECT_ROOT;\n\
1565 const runtimePath = value => {{\n\
1566 if (!value) return value;\n\
1567 const absolute = isAbsolute(value) ? value : resolve(originalDirectory, value);\n\
1568 const local = relative(runtimeProjectRoot, absolute);\n\
1569 if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
1570 if (sourceProjectRoot) {{\n\
1571 const sourceLocal = relative(sourceProjectRoot, absolute);\n\
1572 if (sourceLocal === '' || (!sourceLocal.startsWith('..') && !isAbsolute(sourceLocal))) return resolve(runtimeProjectRoot, sourceLocal);\n\
1573 }}\n\
1574 throw new Error('Supercov refuses a Playwright output/cwd outside the isolated project: ' + absolute);\n\
1575 }};\n\
1576 const normalizeWebServer = server => server ? ({{ ...server, cwd: runtimePath(server.cwd ?? originalDirectory) }}) : server;\n\
1577 // How the configuration resolved, for a later merge: a run of new
1578 // tests with settings of their own is added to this one only where
1579 // nothing that decides what a test does changed. Paths inside the
1580 // copy are written relative to it; every run has a copy of its own.
1581 if (process.env.SUPERCOV_EVIDENCE_DIR && process.env.TEST_WORKER_INDEX === undefined) {{\n\
1582 try {{\n\
1583 const {{ mkdirSync, renameSync, writeFileSync }} = await import('node:fs');\n\
1584 const seen = new WeakSet();\n\
1585 let text = JSON.stringify(resolved, (key, value) => {{\n\
1586 if (typeof value === 'function' || value instanceof RegExp) return String(value);\n\
1587 if (value && typeof value === 'object') {{ if (seen.has(value)) return undefined; seen.add(value); }}\n\
1588 return value;\n\
1589 }});\n\
1590 for (const root of [runtimeProjectRoot, sourceProjectRoot].filter(Boolean)) text = text.split(JSON.stringify(root).slice(1, -1)).join('.');\n\
1591 const directory = resolve(process.cwd(), process.env.SUPERCOV_EVIDENCE_DIR);\n\
1592 mkdirSync(directory, {{ recursive: true }});\n\
1593 const file = resolve(directory, 'playwright-configuration.json');\n\
1594 writeFileSync(`${{file}}.${{process.pid}}`, text);\n\
1595 renameSync(`${{file}}.${{process.pid}}`, file);\n\
1596 }} catch {{}}\n\
1597 }}\n\
1598 const normalized = {{ ...resolved,\n\
1599 testDir: runtimePath(resolved.testDir),\n\
1600 outputDir: runtimePath(resolved.outputDir),\n\
1601 snapshotDir: runtimePath(resolved.snapshotDir),\n\
1602 projects: resolved.projects?.map(project => ({{ ...project, testDir: runtimePath(project.testDir), outputDir: runtimePath(project.outputDir), snapshotDir: runtimePath(project.snapshotDir) }})),\n\
1603 webServer: Array.isArray(resolved.webServer) ? resolved.webServer.map(normalizeWebServer) : normalizeWebServer(resolved.webServer),\n\
1604 }};\n\
1605 const configuredReporters = normalized.reporter;\n\
1606 const reporters = configuredReporters\n\
1607 ? (typeof configuredReporters === 'string' ? [[configuredReporters]] : (Array.isArray(configuredReporters[0]) ? configuredReporters : [configuredReporters]))\n\
1608 : [['list']];\n\
1609 const coverageReporter = resolve(runtimeProjectRoot, '.supercov/node_modules/playwrightReporter.mjs');\n\
1610 export default {{ ...normalized, reporter: [...reporters, [coverageReporter]] }};\n",
1611 serde_json::to_string(
1612 &original
1613 .as_ref()
1614 .and_then(|path| path.parent())
1615 .unwrap_or(workspace)
1616 .display()
1617 .to_string()
1618 )
1619 .map_err(JavascriptFrontendError::Serialize)?
1620 );
1621 atomic_write(&path, source.as_bytes())?;
1622 Ok(path)
1623}
1624
1625fn write_vitest_commands(generated: &Path) -> Result<(), JavascriptFrontendError> {
1626 let adapter = "import { mkdirSync } from 'node:fs';\n\
1627import { relative, resolve, sep } from 'node:path';\n\
1628import { atomicWriteFileSync } from './node_modules/atomic.mjs';\n\
1629import { inferTestProvenance } from './node_modules/provenance.mjs';\n\
1630// Browser Mode runs the test file in the browser, where none of this is\n\
1631// reachable: no node:fs to write evidence, no project root to relativise a\n\
1632// path against, no env to read. Vitest's own browser command channel carries\n\
1633// what the browser observed to node, which completes the same payload the node\n\
1634// setup writes -- so one contract keeps one implementation. The command is\n\
1635// awaited as part of the test, and node learns the test file from Vitest\n\
1636// rather than from the browser realm, which cannot be trusted to name it.\n\
1637const supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/;\n\
1638export function supercovBrowserCommands(root) {\n\
1639 return {\n\
1640 __supercovEvidence(context, payload, suffix) {\n\
1641 const evidenceDirectory = process.env['SUPERCOV_EVIDENCE_DIR'];\n\
1642 if (!evidenceDirectory) return;\n\
1643 // A suffix names one directory under the evidence directory. Anything\n\
1644 // else is refused rather than resolved, so a malformed one cannot send\n\
1645 // the write outside the directory it is supposed to stay in.\n\
1646 if (typeof suffix !== 'string' || !supercovEvidenceSuffix.test(suffix))\n\
1647 throw new Error('Supercov refused an unusable evidence suffix: ' + suffix);\n\
1648 const absolute = context.testPath;\n\
1649 const testFile = absolute ? relative(root, absolute).split(sep).join('/') : undefined;\n\
1650 const projectName = payload.projectName ?? context.project?.name;\n\
1651 delete payload.projectName;\n\
1652 // The browser has no environment to read, so the run identity is stamped\n\
1653 // here rather than sent from a realm that cannot know it.\n\
1654 const runId = process.env['SUPERCOV_RUN_ID'];\n\
1655 if (runId && payload.scope) payload.scope.runId = runId;\n\
1656 const complete = Object.assign({}, payload, testFile ? { testFile } : {}, {\n\
1657 provenance: inferTestProvenance({\n\
1658 runner: 'vitest',\n\
1659 file: testFile,\n\
1660 project: projectName,\n\
1661 explicitKind: process.env['SUPERCOV_TEST_KIND'],\n\
1662 }),\n\
1663 });\n\
1664 const directory = resolve(root, evidenceDirectory, suffix);\n\
1665 mkdirSync(directory, { recursive: true });\n\
1666 atomicWriteFileSync(resolve(directory, 'mcdc.json'), JSON.stringify(complete) + '\\n');\n\
1667 },\n\
1668 };\n\
1669}\n";
1670 atomic_write(&generated.join("vitestCommands.mjs"), adapter.as_bytes())
1671}
1672
1673pub fn prepare_javascript_frontend(
1676 workspace: &Path,
1677 project: &CoverageProject,
1678 collector_id: &str,
1679 cache_key: &str,
1680 command: &[String],
1681) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
1682 let generated = workspace.join(".supercov");
1683 let runtime_directory = generated.join("node_modules");
1689 timed(&SETUP.runtime_ns, || {
1690 copy_runtime(&runtime_directory, collector_id)
1691 })?;
1692 let configuration_started = Instant::now();
1693 configure_playwright_runtime(&runtime_directory, project)?;
1694 link_playwright_package(workspace, project, &runtime_directory);
1695 let playwright_config_path = write_playwright_config(workspace, project, &generated)?;
1696 let vitest_config_path = write_vitest_config(workspace, project, &generated)?;
1697 account(&SETUP.config_ns, configuration_started);
1698
1699 let mut exclusions = Vec::new();
1700 let mut decisions = BTreeMap::new();
1701 let mut points = BTreeMap::new();
1702 let mut branches = BTreeMap::new();
1703 let mut limitations = BTreeMap::new();
1704 for limitation in &project.source_limitations {
1705 limitations.insert(limitation.id.clone(), limitation_from_source(limitation));
1706 }
1707
1708 let runtime_path = runtime_directory.join("runtime.mjs");
1709 let standalone_runtime = compact_runtime(
1710 &fs::read_to_string(&runtime_path).map_err(|source| io_error(&runtime_path, source))?,
1711 );
1712 let browser_suite = project.playwright_config.is_some() || project.browser_runner;
1717 let images = builds_images(workspace);
1718 let mut authored = BTreeSet::new();
1719 let sources_started = Instant::now();
1720 for file in &project.source_files {
1721 let path = checked_source_path(workspace, file)?;
1722 let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
1723 let capability_wrapper = runtime_specifier(file, "capability.mjs")?;
1724 let elide = crate::typescript_imports::elides_type_imports(workspace, file, command);
1725 let instrumented = timed(&SETUP.instrument_ns, || {
1726 instrument_with_import_policy(&source, file, &capability_wrapper, elide)
1727 });
1728 let mut output = match instrumented {
1729 Ok(output) => output,
1730 Err(crate::js_instrumenter::CandidateError::Parse(messages)) => {
1734 let limitation = CandidateLimitation {
1735 id: format!("javascript-file-not-parsed#{file}"),
1736 kind: "source-scope".into(),
1737 file: file.clone(),
1738 line: 1,
1739 column: 0,
1740 source: String::new(),
1741 reason: format!(
1742 "Supercov could not parse this file ({}); it runs as written and nothing in it is measured",
1743 messages.join("; ")
1744 ),
1745 };
1746 limitations.insert(limitation.id.clone(), limitation);
1747 continue;
1748 }
1749 Err(source) => {
1750 return Err(JavascriptFrontendError::Instrument {
1751 file: file.clone(),
1752 source,
1753 });
1754 }
1755 };
1756 if output
1760 .code
1761 .contains(crate::js_instrumenter::RUNTIME_TYPES_MODULE)
1762 {
1763 output.code = output.code.replace(
1764 crate::js_instrumenter::RUNTIME_TYPES_MODULE,
1765 &runtime_specifier(file, "runtime.mjs")?,
1766 );
1767 }
1768 bootstrap_runtime(&mut output, &standalone_runtime, browser_suite, images);
1769 if !browser_suite {
1770 bootstrap_loader(&mut output, file)?;
1771 }
1772 keep_directives_first(&source, &mut output);
1773 if matches!(
1778 path.extension().and_then(|value| value.to_str()),
1779 Some("ts" | "tsx" | "mts" | "cts")
1780 ) {
1781 output.code = generated_source_banner(&output.code);
1782 }
1783 let code = match inline_instrumentation_map(
1790 &output.code,
1791 output.map.as_ref(),
1792 &project.root.join(file),
1793 &source,
1794 ) {
1795 Some(code) => code,
1796 None => output.code.clone(),
1797 };
1798 keep_authored(workspace, &mut authored, file, &source)?;
1799 atomic_write(&path, code.as_bytes())?;
1800 exclusions.extend(output.excluded_statements);
1801 for value in output.decisions {
1802 decisions.insert(value.id.clone(), value);
1803 }
1804 for value in output.points {
1805 points.insert(value.id.clone(), value);
1806 }
1807 for value in output.branches {
1808 branches.insert(value.id.clone(), value);
1809 }
1810 for value in output.coverage_limitations {
1811 limitations.insert(value.id.clone(), value);
1812 }
1813 }
1814 account(&SETUP.sources_ns, sources_started);
1815 write_vitest_commands(&generated)?;
1816
1817 let assertions_started = Instant::now();
1818 let mut assertion_calls = 0;
1819 for entry in &project.source_scope.entries {
1820 let path = checked_source_path(workspace, &entry.file)?;
1821 let Ok(source) = fs::read_to_string(&path) else {
1822 continue;
1823 };
1824 let capability_wrapper = (!project.source_files.contains(&entry.file))
1825 .then(|| runtime_specifier(&entry.file, "capability.mjs"))
1826 .transpose()?;
1827 let assertion_runtime = runtime_specifier(&entry.file, "bootstrap.cjs")?;
1828 let output =
1829 match crate::js_instrumenter::instrument_node_assertion_phases_with_runtime_imports(
1830 &source,
1831 &entry.file,
1832 std::slice::from_ref(&project.playwright_module),
1833 capability_wrapper.as_deref(),
1834 Some(&assertion_runtime),
1835 ) {
1836 Ok(output) => output,
1837 Err(crate::js_instrumenter::CandidateError::Parse(_)) => continue,
1840 Err(source) => {
1841 return Err(JavascriptFrontendError::Instrument {
1842 file: entry.file.clone(),
1843 source,
1844 });
1845 }
1846 };
1847 if output.assertions > 0 || output.capability_imports > 0 {
1848 keep_authored(workspace, &mut authored, &entry.file, &source)?;
1849 atomic_write(&path, output.code.as_bytes())?;
1850 assertion_calls += output.assertions;
1851 }
1852 }
1853
1854 account(&SETUP.assertion_ns, assertions_started);
1855
1856 let mut manifest = JavascriptManifest {
1857 decisions: decisions.into_values().collect(),
1858 points: points.into_values().collect(),
1859 branches: branches.into_values().collect(),
1860 limitations: limitations.into_values().collect(),
1861 scope: project.source_scope.clone(),
1862 };
1863 manifest.decisions.sort_by_key(|value| {
1864 (
1865 value.file.clone(),
1866 value.line,
1867 value.column,
1868 value.id.clone(),
1869 )
1870 });
1871 manifest.points.sort_by_key(|value| {
1872 (
1873 value.file.clone(),
1874 value.line,
1875 value.column,
1876 value.id.clone(),
1877 )
1878 });
1879 manifest.branches.sort_by_key(|value| {
1880 (
1881 value.file.clone(),
1882 value.line,
1883 value.column,
1884 value.id.clone(),
1885 )
1886 });
1887 manifest.limitations.sort_by_key(|value| {
1888 (
1889 value.file.clone(),
1890 value.line,
1891 value.column,
1892 value.id.clone(),
1893 )
1894 });
1895
1896 atomic_write(
1897 &workspace.join(AUTHORED_LIST),
1898 &serde_json::to_vec(&authored).map_err(JavascriptFrontendError::Serialize)?,
1899 )?;
1900 atomic_write(
1901 &generated.join("statement-exclusions.json"),
1902 &serde_json::to_vec(&exclusions).map_err(JavascriptFrontendError::Serialize)?,
1903 )?;
1904 let manifest_path = generated.join("manifest.json");
1905 let mut encoded =
1906 serde_json::to_vec_pretty(&manifest).map_err(JavascriptFrontendError::Serialize)?;
1907 encoded.push(b'\n');
1908 atomic_write(&manifest_path, &encoded)?;
1909 atomic_write(
1910 &generated.join("instrumentation-complete"),
1911 b"coverage-completeness-v2\n",
1912 )?;
1913 timed(&SETUP.cache_ns, || {
1914 write_javascript_frontend_cache(workspace, project, cache_key, assertion_calls)
1915 })?;
1916 Ok(PreparedJavascriptFrontend {
1917 manifest,
1918 manifest_path,
1919 preload_path: generated.join("node_modules/register.mjs"),
1920 playwright_config_path,
1921 vitest_config_path,
1922 assertion_calls,
1923 })
1924}
1925
1926#[cfg(test)]
1927mod tests {
1928
1929 #[test]
1931 fn sprockets_directives_stay_where_sprockets_reads_them() {
1932 let source = "// The manifest.\n//= link_tree ../images\n//= link_tree ../builds\n";
1933 let mut output: crate::js_instrumenter::CandidateOutput = serde_json::from_value(serde_json::json!({
1934 "engine": "", "complete": true, "supportedSurface": "",
1935 "code": "(() => { runtime(); })();\nconst __b = 1;\n// The manifest.\n//= link_tree ../images\n//= link_tree ../builds\n",
1936 "map": { "version": 3, "mappings": "AAAA;AACA;;;;" },
1937 "decisions": [], "points": [], "branches": [], "coverageLimitations": [], "limitations": []
1938 }))
1939 .unwrap();
1940 keep_directives_first(source, &mut output);
1941 assert!(
1942 output
1943 .code
1944 .starts_with("//= link_tree ../images\n//= link_tree ../builds\n(() =>"),
1945 "{}",
1946 output.code
1947 );
1948 assert_eq!(output.map.as_ref().unwrap()["mappings"], ";;AAAA;AACA;;");
1950 let mut untouched = output.clone();
1952 untouched.code = "x();\n//= not a directive\n".into();
1953 let before = untouched.code.clone();
1954 keep_directives_first("x();\n//= not a directive\n", &mut untouched);
1955 assert_eq!(untouched.code, before);
1956 }
1957
1958 #[test]
1960 fn a_helper_declaration_is_found_with_or_without_its_type() {
1961 let global = "globalThis.__SUPERCOV_DIRECT_RUNTIME__";
1962 let untyped =
1963 "var x = 1;\nvar h_1 = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin, c_1 = 2;";
1964 assert_eq!(helper_declaration(untyped, "h_1", global, false), Some(15));
1965 let typed = "var h_1: typeof import(\"./r.mjs\").mcdcBegin = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin, c_1 = 2;";
1966 assert_eq!(helper_declaration(typed, "h_1", global, true), Some(4));
1967 assert_eq!(helper_declaration(typed, "h_1", global, false), None);
1968 assert_eq!(
1970 helper_declaration(
1971 "h_1(); var h_1 = globalThis.__SUPERCOV_DIRECT_RUNTIME__.mcdcBegin;",
1972 "h_1",
1973 global,
1974 true
1975 ),
1976 Some(11)
1977 );
1978 }
1979
1980 #[test]
1982 fn the_copy_of_the_runtime_a_file_carries_is_compact() {
1983 let source = include_str!("../runtime-assets/javascript/runtime.mjs");
1984 let compact = compact_runtime(source);
1985 assert!(
1986 compact.len() * 3 < source.len() * 2,
1987 "{} of {}",
1988 compact.len(),
1989 source.len()
1990 );
1991 let export_start = compact.rfind("\nexport {").unwrap();
1992 assert_eq!(
1993 &compact[export_start..],
1994 &source[source.rfind("\nexport {").unwrap()..]
1995 );
1996 let allocator = oxc_allocator::Allocator::default();
1997 let parsed = oxc_parser::Parser::new(
1998 &allocator,
1999 &compact[..export_start],
2000 oxc_span::SourceType::cjs(),
2001 )
2002 .parse();
2003 assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
2004 }
2005 #[test]
2006 fn ancestor_description_names_each_component_and_its_state() {
2007 let root = std::env::temp_dir().join(format!("supercov-ancestors-{}", unique()));
2008 fs::create_dir_all(root.join("present")).unwrap();
2009 fs::write(root.join("present/file.txt"), b"x").unwrap();
2010 let described = super::describe_ancestors(&root.join("present/file.txt/child"));
2011 assert!(described.contains("present=dir"), "{described}");
2012 assert!(described.contains("file.txt=file"), "{described}");
2013 assert!(described.ends_with("child=missing"), "{described}");
2014 fs::remove_dir_all(&root).unwrap();
2015 }
2016
2017 use super::*;
2018 use crate::project_discovery::discover_coverage_project;
2019
2020 fn temporary(name: &str) -> PathBuf {
2021 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2022 .join("../../target/supercov-test-fixtures")
2023 .join(format!("javascript-frontend-{name}-{}", unique()));
2024 fs::create_dir_all(&path).unwrap();
2030 crate::workspace::canonicalize_simplified(path).unwrap()
2031 }
2032
2033 #[test]
2034 fn browser_mode_gets_a_browser_setup_and_the_command_that_carries_its_evidence() {
2035 let generated = temporary("browser-mode-config");
2036 write_vitest_commands(&generated).unwrap();
2037 let adapter = fs::read_to_string(generated.join("vitestCommands.mjs")).unwrap();
2038 assert!(adapter.contains("export function supercovBrowserCommands(root)"));
2042 assert!(adapter.contains("__supercovEvidence(context, payload, suffix)"));
2043 assert!(adapter.contains("const absolute = context.testPath;"));
2044 assert!(!adapter.contains("configureServer"), "{adapter}");
2045 assert!(!adapter.contains("__supercov/evidence"), "{adapter}");
2046 assert!(adapter.contains("supercovEvidenceSuffix = /^[A-Za-z0-9._-]+$/"));
2049 assert!(adapter.contains("Supercov refused an unusable evidence suffix"));
2050 }
2051
2052 #[test]
2053 fn a_browser_mode_project_loads_the_setup_that_can_run_in_a_browser() {
2054 let workspace = temporary("browser-mode-setup");
2057 let generated = workspace.join(".supercov");
2058 fs::create_dir_all(&generated).unwrap();
2059 fs::write(workspace.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
2060 fs::create_dir_all(workspace.join("src")).unwrap();
2061 fs::write(
2062 workspace.join("src/example.mjs"),
2063 "export const one = () => 1;\n",
2064 )
2065 .unwrap();
2066 let project = discover_coverage_project(
2067 &workspace,
2068 &BTreeMap::new(),
2069 &["npx".into(), "vitest".into(), "run".into()],
2070 )
2071 .unwrap();
2072 let path = write_vitest_config(&workspace, &project, &generated).unwrap();
2073 let config = fs::read_to_string(path).unwrap();
2074 assert!(config.contains("config?.test?.browser?.enabled"));
2075 assert!(config.contains("'.supercov/node_modules/vitestBrowser.mjs'"));
2076 assert!(config.contains("'.supercov/node_modules/vitest.mjs'"));
2077 assert!(config.contains("browser: { commands: supercovBrowserCommands(process.cwd()) }"));
2079 assert!(config.contains("...supercovBrowserTest(loaded?.config)"));
2080 }
2081
2082 #[test]
2083 fn runtime_isolation_replaces_only_the_assignment_marker() {
2084 let source = concat!(
2085 "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n",
2086 "const selected = runtimeInstanceToken === \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\";\n"
2087 );
2088 let isolated = isolate_runtime(source, "collector-123").unwrap();
2089 assert!(isolated.contains("runtimeInstanceToken = \"collector-123\""));
2090 assert!(isolated.contains("=== \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\""));
2091 }
2092
2093 #[test]
2094 fn copied_runtime_does_not_reference_unshipped_source_maps() {
2095 let generated = temporary("runtime-source-maps");
2096 copy_runtime(&generated, "collector-test").unwrap();
2097 for name in ["vitest.mjs", "provenance.mjs", "atomic.mjs"] {
2098 let contents = fs::read_to_string(generated.join(name)).unwrap();
2099 assert!(
2100 !contents.contains("sourceMappingURL"),
2101 "runtime shim retained a source-map directive: {name}"
2102 );
2103 }
2104 fs::remove_dir_all(generated).unwrap();
2105 }
2106
2107 #[test]
2108 fn prepares_sorted_complete_manifest_without_touching_source_project() {
2109 let source_root = temporary("source");
2110 let workspace = temporary("workspace");
2111 fs::create_dir_all(source_root.join("src")).unwrap();
2112 fs::write(
2113 source_root.join("src/example.mjs"),
2114 "export function value(a, b) { if (a || b) return 1; return 0; }\n",
2115 )
2116 .unwrap();
2117 fs::write(source_root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
2118 fs::create_dir_all(workspace.join("src")).unwrap();
2119 fs::create_dir_all(workspace.join(".supercov")).unwrap();
2120 fs::copy(
2121 source_root.join("src/example.mjs"),
2122 workspace.join("src/example.mjs"),
2123 )
2124 .unwrap();
2125 let project = discover_coverage_project(
2126 &source_root,
2127 &BTreeMap::new(),
2128 &["node".into(), "--test".into()],
2129 )
2130 .unwrap();
2131 let original = fs::read_to_string(source_root.join("src/example.mjs")).unwrap();
2132 let prepared =
2133 prepare_javascript_frontend(&workspace, &project, "collector-test", "cache-test", &[])
2134 .unwrap();
2135 assert_eq!(
2136 fs::read_to_string(source_root.join("src/example.mjs")).unwrap(),
2137 original
2138 );
2139 let transformed = fs::read_to_string(workspace.join("src/example.mjs")).unwrap();
2140 assert!(transformed.contains("__SUPERCOV_DIRECT_RUNTIME__"));
2141 assert_eq!(prepared.manifest.decisions.len(), 1);
2142 assert!(!prepared.manifest.points.is_empty());
2143 assert_eq!(prepared.manifest.scope, project.source_scope);
2144 assert!(prepared.manifest_path.is_file());
2145 assert!(prepared.preload_path.is_file());
2146 assert!(prepared.playwright_config_path.is_file());
2147 assert!(prepared.vitest_config_path.is_file());
2148 assert_eq!(prepared.assertion_calls, 0);
2149 let cache = read_javascript_frontend_cache(&workspace, "cache-test").unwrap();
2150 assert_eq!(
2151 javascript_frontend_reuse_paths(&cache),
2152 [
2153 PathBuf::from(".supercov/frontend-cache.json"),
2154 PathBuf::from(".supercov/frontend-cache-artifacts"),
2155 ]
2156 );
2157 assert!(
2158 cache
2159 .artifacts
2160 .iter()
2161 .all(|artifact| !artifact.cache_file.contains("src/")
2162 && !artifact.cache_file.contains("tests/"))
2163 );
2164 fs::write(workspace.join("src/example.mjs"), &original).unwrap();
2165 fs::remove_file(&prepared.manifest_path).unwrap();
2166 let restored = load_cached_javascript_frontend(&workspace, &cache).unwrap();
2167 assert_eq!(restored.manifest, prepared.manifest);
2168 assert_eq!(
2169 fs::read_to_string(workspace.join("src/example.mjs")).unwrap(),
2170 transformed
2171 );
2172 fs::write(workspace.join(&cache.artifacts[0].cache_file), "corrupt").unwrap();
2173 assert!(read_javascript_frontend_cache(&workspace, "cache-test").is_none());
2174 fs::remove_dir_all(source_root).unwrap();
2175 fs::remove_dir_all(workspace).unwrap();
2176 }
2177
2178 #[test]
2179 fn embedded_runtime_contains_every_declared_shim() {
2180 for name in RUNTIME_FILES {
2181 let bytes = embedded_runtime(name).unwrap();
2182 assert!(!bytes.is_empty(), "embedded runtime is empty: {name}");
2183 }
2184 assert!(
2185 std::str::from_utf8(embedded_runtime("runtime.mjs").unwrap())
2186 .unwrap()
2187 .contains(RUNTIME_INSTANCE_MARKER)
2188 );
2189 }
2190}