1use std::{
4 collections::BTreeMap,
5 ffi::OsString,
6 path::{Path, PathBuf},
7 time::{Instant, SystemTime, UNIX_EPOCH},
8};
9
10use serde::{Deserialize, Serialize};
11use supercov_contracts::{
12 AttributionPrecision, ExecutionModel, FrontendAttribution, FrontendLimitation,
13 FrontendLimitationScope, FrontendRunDeclaration, FrontendRunnerDeclaration,
14 LANGUAGE_FRONTEND_PROTOCOL_VERSION, StructuralSource,
15};
16
17use crate::{
18 build_cache::{build_cache_key, read_build_cache, reuse_paths, write_build_cache},
19 coverage_report::{PersistedCoverageModel, RawTestResult, javascript_coverage_model},
20 evidence_archive::{
21 EvidenceArchiveEntry, EvidenceArchiveSource, collect_sources, write_archive,
22 },
23 integrity::{FrontendIntegrityInputs, create_run_integrity},
24 javascript_frontend::{
25 javascript_frontend_reuse_paths, load_cached_javascript_frontend,
26 prepare_javascript_frontend, read_javascript_frontend_cache,
27 },
28 lifecycle::{
29 ProjectLock, RunState, RunStateStatus, finalize_published_run, interrupt_run_state,
30 publish_run, recover_abandoned_runs, remove_stored_tree_deferred, update_run_state,
31 write_run_state,
32 },
33 orchestration::{
34 ExecutionPhase, ExecutionPlan, OrchestrationError, PhaseKind, execute_plan_with_supervisor,
35 },
36 process_supervision::{
37 CommandSpec, ForwardedSignal, ProcessSupervisor, SupervisionOptions, positive_milliseconds,
38 },
39 project_discovery::{BuildAdapter, discover_coverage_project},
40 run_store::{
41 InstrumentedBuildCache, RawEvidenceMetadata, RunIntegrity, RunMetadata, RunTimings,
42 },
43 workspace::{
44 cached_workspace_path, prepare_cached_workspace, prune_cached_workspace_sources,
45 sync_command_outputs, workspace_output_baseline,
46 },
47};
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase", deny_unknown_fields)]
51pub struct DirectJavascriptRunRequest {
52 pub root: PathBuf,
53 pub command: Vec<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub run_id: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub started_at: Option<String>,
58 #[serde(skip)]
59 pub watchdog_program: Option<PathBuf>,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase", deny_unknown_fields)]
64pub struct DirectJavascriptRunResult {
65 pub run_id: String,
66 pub run_directory: PathBuf,
67 pub workspace: PathBuf,
68 pub exit_code: i32,
69 pub assertion_calls: usize,
70 pub recovered_runs: Vec<String>,
71 pub metadata: RunMetadata,
72}
73
74#[derive(Debug)]
75pub enum DirectJavascriptRunError {
76 Interrupted {
77 signal: ForwardedSignal,
78 exit_code: i32,
79 timings: RunTimings,
80 total_ms: f64,
81 },
82 Failed(String),
83}
84
85impl std::fmt::Display for DirectJavascriptRunError {
86 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 match self {
88 Self::Interrupted { signal, .. } => {
89 write!(formatter, "interrupted by {}", signal_name(*signal))
90 }
91 Self::Failed(message) => formatter.write_str(message),
92 }
93 }
94}
95
96impl std::error::Error for DirectJavascriptRunError {}
97
98impl From<String> for DirectJavascriptRunError {
99 fn from(value: String) -> Self {
100 Self::Failed(value)
101 }
102}
103
104fn signal_name(signal: ForwardedSignal) -> &'static str {
105 match signal {
106 ForwardedSignal::Sighup => "SIGHUP",
107 ForwardedSignal::Sigint => "SIGINT",
108 ForwardedSignal::Sigterm => "SIGTERM",
109 }
110}
111
112fn javascript_runner_declaration(
113 runner: String,
114 results: &[&RawTestResult],
115) -> FrontendRunnerDeclaration {
116 let has_observations = |raw: &&RawTestResult| {
117 !raw.phases.is_empty()
118 || !raw.server.is_empty()
119 || raw.runtime.iter().chain(&raw.browser).any(|snapshot| {
120 !snapshot.decisions.is_empty()
121 || !snapshot.hits.is_empty()
122 || !snapshot.events.is_empty()
123 })
124 };
125 let exact_test = results.iter().all(|raw| {
126 raw.test_id
127 .as_deref()
128 .is_some_and(|value| !value.is_empty())
129 && raw
130 .scope
131 .as_ref()
132 .is_none_or(|scope| raw.test_id.as_deref() == Some(scope.test_id.as_str()))
133 });
134 let exact_worker = results.iter().all(|raw| {
135 !has_observations(raw)
136 || raw
137 .scope
138 .as_ref()
139 .is_some_and(|scope| !scope.worker_id.is_empty())
140 });
141 let exact_retry = results.iter().all(|raw| {
142 raw.retry.is_some()
143 && raw
144 .scope
145 .as_ref()
146 .is_none_or(|scope| raw.retry == Some(scope.retry))
147 });
148 let contextual = !results.is_empty() && exact_test && exact_worker && exact_retry;
149 let precision = |exact| {
150 if exact {
151 AttributionPrecision::Exact
152 } else {
153 AttributionPrecision::Unavailable
154 }
155 };
156 let attribution = FrontendAttribution {
157 run: AttributionPrecision::Exact,
158 worker: precision(contextual),
159 test: precision(contextual),
160 retry: precision(contextual),
161 phase: precision(contextual),
162 action: precision(contextual),
163 assertion: precision(contextual),
164 };
165 let mut limitations = Vec::new();
166 if !contextual {
167 for (suffix, scope) in [
168 ("worker", FrontendLimitationScope::Worker),
169 ("test", FrontendLimitationScope::Test),
170 ("retry", FrontendLimitationScope::Retry),
171 ("phase", FrontendLimitationScope::Phase),
172 ("action", FrontendLimitationScope::Action),
173 ("assertion", FrontendLimitationScope::Assertion),
174 ] {
175 limitations.push(FrontendLimitation {
176 id: format!("{runner}-no-{suffix}").replace(':', "-"),
177 scopes: vec![scope],
178 reason: format!(
179 "Runner {runner} did not expose exact {suffix} identity for every result"
180 ),
181 });
182 }
183 }
184 FrontendRunnerDeclaration {
185 runner,
186 execution_model: if contextual {
187 ExecutionModel::ParallelContextPropagated
188 } else {
189 ExecutionModel::ParallelUnattributed
190 },
191 attribution,
192 limitations,
193 }
194}
195
196fn javascript_archive_entries(
197 mut entries: Vec<EvidenceArchiveEntry>,
198 manifest: &crate::javascript_frontend::JavascriptManifest,
199 run_id: &str,
200 exit_code: i32,
201) -> Result<Vec<EvidenceArchiveEntry>, String> {
202 let mut results = Vec::new();
203 for entry in &entries {
204 if entry.path == "mcdc.json" || entry.path.ends_with("/mcdc.json") {
205 results.push(
206 serde_json::from_slice::<RawTestResult>(&entry.contents)
207 .map_err(|error| format!("invalid {}: {error}", entry.path))?,
208 );
209 } else if entry.path == "mcdc.jsonl" || entry.path.ends_with(".mcdc.jsonl") {
210 let contents = entry
211 .contents
212 .strip_suffix(b"\n")
213 .ok_or_else(|| format!("{} does not end with a newline", entry.path))?;
214 for (index, line) in contents.split(|byte| *byte == b'\n').enumerate() {
215 if line.is_empty() {
216 return Err(format!(
217 "{} contains a blank record at line {}",
218 entry.path,
219 index + 1
220 ));
221 }
222 results.push(
223 serde_json::from_slice::<RawTestResult>(line).map_err(|error| {
224 format!(
225 "invalid {} record at line {}: {error}",
226 entry.path,
227 index + 1
228 )
229 })?,
230 );
231 }
232 }
233 }
234 if results.is_empty() {
235 let status = match exit_code {
236 0 => "passed",
237 supercov_contracts::COMMAND_TIMEOUT_EXIT_CODE => "timedOut",
238 _ => "failed",
239 };
240 let command_result = RawTestResult {
241 test_id: Some(format!("command:{run_id}")),
242 scope: None,
243 test: "Test command".into(),
244 test_file: None,
245 title: Some("Test command".into()),
246 retry: Some(0),
247 status: Some(status.into()),
248 expected_status: None,
249 flaky: false,
250 provenance: crate::coverage_report::TestProvenance {
251 runner: "command".into(),
252 kind: "setup".into(),
253 project: None,
254 source: "engine".into(),
255 },
256 role: "setup".into(),
257 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
258 phases: vec![],
259 runtime: vec![],
260 browser: vec![],
261 server: vec![],
262 };
263 entries.push(EvidenceArchiveEntry {
264 path: "results/command/mcdc.json".into(),
265 contents: serde_json::to_vec(&command_result).map_err(|error| error.to_string())?,
266 });
267 results.push(command_result);
268 }
269 let mut by_runner = BTreeMap::<String, Vec<&RawTestResult>>::new();
270 for result in &results {
271 by_runner
272 .entry(result.provenance.runner.clone())
273 .or_default()
274 .push(result);
275 }
276 if entries
277 .iter()
278 .any(|entry| entry.path.starts_with("server/background/") && entry.path.ends_with(".jsonl"))
279 {
280 by_runner.entry("background".into()).or_default();
281 }
282 let declaration = FrontendRunDeclaration {
283 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
284 frontend_id: "javascript".into(),
285 frontend_version: "javascript-owned-v1".into(),
286 language: "javascript".into(),
287 structural_source: StructuralSource::OwnedProbes,
288 runners: by_runner
289 .into_iter()
290 .map(|(runner, results)| javascript_runner_declaration(runner, &results))
291 .collect(),
292 structural_limitations: manifest
293 .limitations
294 .iter()
295 .map(|limitation| limitation.id.clone())
296 .collect(),
297 };
298 entries.push(EvidenceArchiveEntry {
299 path: "coverage-model.json".into(),
300 contents: serde_json::to_vec(
301 &PersistedCoverageModel::from_declaration(&javascript_coverage_model())
302 .expect("JavaScript coverage model is contract-valid"),
303 )
304 .map_err(|error| error.to_string())?,
305 });
306 entries.push(EvidenceArchiveEntry {
307 path: "frontend.json".into(),
308 contents: serde_json::to_vec(&declaration).map_err(|error| error.to_string())?,
309 });
310 Ok(entries)
311}
312
313struct RunCleanup {
314 root: PathBuf,
315 run_id: String,
316 started_at: String,
317 lock: ProjectLock,
318 workspace: Option<PathBuf>,
319 state_written: bool,
320 terminal_recorded: bool,
321 keep_workspace: bool,
324}
325
326impl RunCleanup {
327 fn lock(&self) -> &ProjectLock {
328 &self.lock
329 }
330
331 fn set_workspace(&mut self, workspace: PathBuf) {
332 self.workspace = Some(workspace);
333 }
334
335 fn mark_state_written(&mut self) {
336 self.state_written = true;
337 }
338
339 fn mark_terminal(&mut self) {
340 self.terminal_recorded = true;
341 }
342}
343
344impl Drop for RunCleanup {
345 fn drop(&mut self) {
346 if self.state_written && !self.terminal_recorded {
347 let _ = update_run_state(
348 &self.root,
349 &self.run_id,
350 RunStateStatus::Failed,
351 &self.started_at,
352 Some("Rust run exited before reaching a terminal lifecycle state".into()),
353 );
354 }
355 if let Some(workspace) = &self.workspace {
356 let _ = remove_stored_tree_deferred(
357 &self.root,
358 &workspace.join(".supercov/evidence").join(&self.run_id),
359 );
360 let _ = remove_stored_tree_deferred(
361 &self.root,
362 &workspace
363 .join(".supercov/server-evidence")
364 .join(&self.run_id),
365 );
366 let keep_workspace = self.keep_workspace
367 || std::env::var("SUPERCOV_KEEP_WORKSPACE").is_ok_and(|value| !value.is_empty());
368 if !keep_workspace {
369 let _ = prune_cached_workspace_sources(&self.root, &self.lock);
370 }
371 }
372 let _ = self.lock.release();
373 }
374}
375
376fn now_nonce() -> u128 {
377 SystemTime::now()
378 .duration_since(UNIX_EPOCH)
379 .unwrap_or_default()
380 .as_millis()
381}
382
383fn elapsed_ms(started: Instant) -> f64 {
384 started.elapsed().as_secs_f64() * 1000.0
385}
386
387fn rounded_millisecond(value: f64) -> f64 {
388 (value * 10.0).round() / 10.0
389}
390
391fn supervision_options() -> Result<SupervisionOptions, String> {
392 let defaults = SupervisionOptions::default();
393 Ok(SupervisionOptions {
394 diagnostic_interval: positive_milliseconds(
395 std::env::var("SUPERCOV_DIAGNOSTIC_INTERVAL_MS")
396 .ok()
397 .as_deref(),
398 "SUPERCOV_DIAGNOSTIC_INTERVAL_MS",
399 )
400 .map_err(|error| error.to_string())?
401 .unwrap_or(defaults.diagnostic_interval),
402 timeout: positive_milliseconds(
403 std::env::var("SUPERCOV_COMMAND_TIMEOUT_MS").ok().as_deref(),
404 "SUPERCOV_COMMAND_TIMEOUT_MS",
405 )
406 .map_err(|error| error.to_string())?,
407 termination_grace: defaults.termination_grace,
408 })
409}
410
411fn remove_derived_pnpm_config(environment: &mut BTreeMap<OsString, OsString>) {
412 const PNPM_ONLY_NPM_CONFIG: &[&str] = &[
418 "npm_config_auto_install_peers",
419 "npm_config_enable_pre_post_scripts",
420 "npm_config_shamefully_hoist",
421 ];
422 environment.retain(|key, _| {
423 let key = key.to_string_lossy().to_ascii_lowercase();
424 !PNPM_ONLY_NPM_CONFIG.contains(&key.as_str())
425 });
426}
427
428fn environment_with(values: BTreeMap<String, String>) -> Vec<(OsString, OsString)> {
429 let mut environment = std::env::vars_os().collect::<BTreeMap<_, _>>();
430 remove_derived_pnpm_config(&mut environment);
431 for (key, value) in values {
432 environment.retain(|existing, _| !existing.to_string_lossy().eq_ignore_ascii_case(&key));
433 environment.insert(key.into(), value.into());
434 }
435 environment.into_iter().collect()
436}
437
438fn node_options(preload: &Path) -> String {
439 let preload = std::path::absolute(preload).unwrap_or_else(|_| preload.to_path_buf());
445 [
451 std::env::var("NODE_OPTIONS").ok(),
452 Some("--enable-source-maps".into()),
453 Some(format!("--import={}", crate::workspace::file_url(&preload))),
454 ]
455 .into_iter()
456 .flatten()
457 .filter(|value| !value.is_empty())
458 .collect::<Vec<_>>()
459 .join(" ")
460}
461
462pub fn current_javascript_integrity(
466 root: &Path,
467 command: &[String],
468 source_roots: Option<&[String]>,
469) -> Result<RunIntegrity, String> {
470 let mut environment = std::env::vars().collect::<BTreeMap<_, _>>();
471 match source_roots.filter(|roots| !roots.is_empty()) {
476 Some(roots) => {
477 environment.insert(
478 crate::source_discovery::SOURCE_ROOTS_VARIABLE.into(),
479 roots.join(","),
480 );
481 }
482 None => {
483 environment.remove(crate::source_discovery::SOURCE_ROOTS_VARIABLE);
484 }
485 }
486 let project = discover_coverage_project(root, &environment, command)
487 .map_err(|error| error.to_string())?;
488 javascript_integrity_for_project(root, &project)
489}
490
491fn javascript_integrity_for_project(
492 root: &Path,
493 project: &crate::project_discovery::CoverageProject,
494) -> Result<RunIntegrity, String> {
495 let frontend = FrontendIntegrityInputs::embedded_javascript();
496 create_run_integrity(root, project, &frontend).map_err(|error| error.to_string())
497}
498
499pub fn run_direct_javascript(
502 request: &DirectJavascriptRunRequest,
503 diagnostics: &mut dyn std::io::Write,
504) -> Result<DirectJavascriptRunResult, DirectJavascriptRunError> {
505 if request.command.is_empty() {
506 return Err(DirectJavascriptRunError::Failed(
507 "test command must not be empty".into(),
508 ));
509 }
510 let total_started = Instant::now();
511 let initialization_started = Instant::now();
512 let root = crate::workspace::canonicalize_simplified(&request.root)
513 .map_err(|error| format!("{}: {error}", request.root.display()))?;
514 let nonce = now_nonce();
515 let run_id = request
516 .run_id
517 .clone()
518 .unwrap_or_else(|| format!("rust-{nonce}"));
519 let started_at = request
520 .started_at
521 .clone()
522 .unwrap_or_else(|| format!("unix-ms-{nonce}"));
523 let lock =
524 ProjectLock::acquire(&root, &run_id, &started_at).map_err(|error| error.to_string())?;
525 let mut cleanup = RunCleanup {
526 root: root.clone(),
527 run_id: run_id.clone(),
528 started_at: started_at.clone(),
529 lock,
530 workspace: None,
531 state_written: false,
532 terminal_recorded: false,
533 keep_workspace: false,
534 };
535 let recovered_runs =
536 recover_abandoned_runs(&root, &started_at).map_err(|error| error.to_string())?;
537 if !recovered_runs.is_empty() {
538 writeln!(
539 diagnostics,
540 "[supercov] recovered abandoned run(s): {}",
541 recovered_runs.join(", ")
542 )
543 .map_err(|error| error.to_string())?;
544 }
545 let environment = std::env::vars().collect::<BTreeMap<_, _>>();
546 let source_roots = crate::source_discovery::configured_source_roots(&environment);
547 let project = discover_coverage_project(&root, &environment, &request.command)
548 .map_err(|error| error.to_string())?;
549 let integrity = javascript_integrity_for_project(&root, &project)?;
550 let source_inputs = crate::source_capture::capture(
551 &root,
552 "javascript",
553 crate::integrity::javascript_assertion_paths(&root, &project).map_err(|e| e.to_string())?,
554 )?;
555 let build_cache_key = build_cache_key(&integrity, &project)?;
556 let frontend_cache_key = format!(
563 "{}:{}:{}:{}:{}",
564 integrity.fingerprint.combined,
565 integrity.fingerprint.execution,
566 crate::source_manifest::digest(&request.command),
567 crate::source_manifest::digest(&project.root.to_string_lossy()),
568 crate::source_manifest::digest(&(&project.source_files, &source_roots))
569 );
570 let prior_workspace = cached_workspace_path(&root).map_err(|error| error.to_string())?;
571 let reusable_build = if project.build_adapter == BuildAdapter::Direct {
572 None
573 } else {
574 read_build_cache(&prior_workspace, &build_cache_key)
575 };
576 let reusable_frontend = read_javascript_frontend_cache(&prior_workspace, &frontend_cache_key);
577 let mut cached_paths = reusable_build.as_ref().map(reuse_paths).unwrap_or_default();
578 if let Some(frontend) = &reusable_frontend {
579 cached_paths.extend(javascript_frontend_reuse_paths(frontend));
580 cached_paths.sort();
581 cached_paths.dedup();
582 }
583 let initialization_ms = elapsed_ms(initialization_started);
584
585 let workspace_started = Instant::now();
586 let workspace_progress = crate::progress::ProgressLine::start("preparing isolated workspace");
587 let workspace = prepare_cached_workspace(&root, cleanup.lock(), &cached_paths)
588 .map_err(|error| error.to_string())?;
589 drop(workspace_progress);
590 cleanup.set_workspace(workspace.clone());
591 if let Err(error) = crate::workspace::mirror_git_repository(&root, &workspace)
592 && std::env::var_os("SUPERCOV_DEBUG").is_some()
593 {
594 eprintln!("[supercov] the workspace has no git repository of its own: {error}");
595 }
596 let workspace_preparation_ms = elapsed_ms(workspace_started);
597 writeln!(
598 diagnostics,
599 "[supercov] instrumenting isolated workspace {}",
600 workspace.display()
601 )
602 .map_err(|error| error.to_string())?;
603 cleanup.mark_state_written();
604 write_run_state(
605 &root,
606 &RunState {
607 id: run_id.clone(),
608 pid: std::process::id(),
609 root: root.display().to_string(),
610 workspace: workspace.display().to_string(),
611 started_at: started_at.clone(),
612 updated_at: started_at.clone(),
613 status: RunStateStatus::Preparing,
614 signal: None,
615 error: None,
616 },
617 )
618 .map_err(|error| error.to_string())?;
619
620 let adapter_started = Instant::now();
621 let instrumentation_progress = crate::progress::ProgressLine::start("instrumenting sources");
622 let collector_id = format!("collector-{}", integrity.fingerprint.execution);
623 let frontend = if let Some(cache) = &reusable_frontend {
624 load_cached_javascript_frontend(&workspace, cache)
625 } else {
626 prepare_javascript_frontend(
627 &workspace,
628 &project,
629 &collector_id,
630 &frontend_cache_key,
631 &request.command,
632 )
633 }
634 .map_err(|error| error.to_string())?;
635 crate::workspace::hide_rewritten_files(
636 &workspace,
637 &crate::javascript_frontend::rewritten_files(&workspace),
638 );
639 drop(instrumentation_progress);
640 let adapter_setup_ms = elapsed_ms(adapter_started);
641 if let Some(detail) = crate::javascript_frontend::setup_timing_detail() {
642 writeln!(diagnostics, "[supercov] {detail}").map_err(|error| error.to_string())?;
643 }
644
645 let evidence_relative = format!(".supercov/evidence/{run_id}");
646 let evidence_directory = workspace.join(&evidence_relative);
647 let server_evidence_root = workspace.join(".supercov/server-evidence");
648 let diagnostic_owner = workspace.join(format!(".supercov/diagnostic-owner-{run_id}"));
649 let mut overrides = BTreeMap::from([
650 ("NODE_OPTIONS".into(), node_options(&frontend.preload_path)),
651 ("SUPERCOV_CJS_INTERCEPT".into(), "1".into()),
652 ("SUPERCOV_DIRECT_INSTRUMENTATION".into(), "1".into()),
653 ("TAP_ALLOW_INCOMPLETE_COVERAGE".into(), "1".into()),
658 (
662 "SUPERCOV_EVIDENCE_DIR".into(),
663 evidence_directory.display().to_string(),
664 ),
665 (
666 "SUPERCOV_DIAGNOSTIC_OWNER_FILE".into(),
667 diagnostic_owner.display().to_string(),
668 ),
669 (
670 "SUPERCOV_EXECUTION_FINGERPRINT".into(),
671 integrity.fingerprint.execution.clone(),
672 ),
673 (
674 "SUPERCOV_EXECUTION_LOG".into(),
675 evidence_directory
676 .join("execution.jsonl")
677 .display()
678 .to_string(),
679 ),
680 (
681 "SUPERCOV_MANIFEST".into(),
682 frontend.manifest_path.display().to_string(),
683 ),
684 (
685 "SUPERCOV_PROJECT_ROOT".into(),
686 workspace.display().to_string(),
687 ),
688 ("SUPERCOV_RUN_ID".into(), run_id.clone()),
689 (
690 "SUPERCOV_SERVER_EVIDENCE_ROOT".into(),
691 server_evidence_root.display().to_string(),
692 ),
693 (
694 "SUPERCOV_SOURCE_PROJECT_ROOT".into(),
695 root.display().to_string(),
696 ),
697 ]);
698 overrides.insert(
702 "SUPERCOV_GENERATED_VITEST_CONFIG".into(),
703 frontend.vitest_config_path.display().to_string(),
704 );
705 overrides.insert(
706 "SUPERCOV_GENERATED_PLAYWRIGHT_CONFIG".into(),
707 frontend.playwright_config_path.display().to_string(),
708 );
709 overrides.insert(
710 "SUPERCOV_PLAYWRIGHT_MODULE".into(),
711 project.playwright_module.clone(),
712 );
713 overrides.insert(
714 "SUPERCOV_PLAYWRIGHT_TEST_EXPORT".into(),
715 project.playwright_test_export.clone(),
716 );
717 overrides.insert(
718 "SUPERCOV_PLAYWRIGHT_WRAPPER".into(),
719 "./.supercov/node_modules/playwright.mjs".into(),
720 );
721 if let Some(original) = project
722 .playwright_config
723 .as_ref()
724 .and_then(|path| path.strip_prefix(&root).ok())
725 .map(|path| workspace.join(path))
726 {
727 overrides.insert(
728 "SUPERCOV_ORIGINAL_PLAYWRIGHT_CONFIG".into(),
729 original.display().to_string(),
730 );
731 }
732 overrides.extend(project.build_environment.clone());
733 let settings = serde_json::json!({
737 "root": workspace.display().to_string(),
738 "environment": overrides
739 .iter()
740 .filter(|(name, _)| name.starts_with("SUPERCOV_"))
741 .collect::<BTreeMap<_, _>>(),
742 });
743 let settings_path = frontend.preload_path.with_file_name("run.json");
744 std::fs::write(
745 &settings_path,
746 serde_json::to_vec(&settings).map_err(|error| error.to_string())?,
747 )
748 .map_err(|error| format!("{}: {error}", settings_path.display()))?;
749 let preparation = if reusable_build.is_some() {
750 writeln!(
751 diagnostics,
752 "[supercov] reusing exact-fingerprint instrumented build {}",
753 &build_cache_key[..12]
754 )
755 .map_err(|error| error.to_string())?;
756 Vec::new()
757 } else if project.build_adapter != BuildAdapter::Direct {
758 let mut arguments = project.build_command[1..]
759 .iter()
760 .map(OsString::from)
761 .collect::<Vec<_>>();
762 if project.build_adapter == BuildAdapter::Vite {
763 let npm = Path::new(&project.build_command[0])
766 .file_stem()
767 .is_some_and(|name| name.eq_ignore_ascii_case("npm"));
768 if npm {
769 arguments.push(OsString::from("--"));
770 }
771 arguments.extend([
772 OsString::from("--config"),
773 OsString::from(".supercov/vite.config.mjs"),
774 OsString::from("--logLevel"),
775 OsString::from("error"),
776 ]);
777 }
778 let mut build_overrides = overrides.clone();
779 build_overrides.insert("NODE_ENV".into(), "production".into());
780 build_overrides.insert("npm_config_loglevel".into(), "error".into());
781 let build_environment = environment_with(build_overrides);
782 vec![ExecutionPhase {
783 name: "build".into(),
784 kind: PhaseKind::Build,
785 command: CommandSpec {
786 program: project.build_command[0].clone().into(),
787 arguments,
788 cwd: workspace.clone(),
789 environment: Some(build_environment),
790 captured_output: Some(
791 workspace
792 .join(".supercov")
793 .join(format!("build-output-{run_id}.log")),
794 ),
795 },
796 }]
797 } else {
798 Vec::new()
799 };
800 let plan = ExecutionPlan {
801 preparation,
802 test: ExecutionPhase {
803 name: "test".into(),
804 kind: PhaseKind::Test,
805 command: CommandSpec {
806 program: request.command[0].clone().into(),
807 arguments: request.command[1..].iter().map(OsString::from).collect(),
808 cwd: workspace.clone(),
809 environment: Some(environment_with(overrides)),
810 captured_output: None,
811 },
812 },
813 };
814 let options = supervision_options()?;
815 let watchdog_program = request.watchdog_program.as_ref().ok_or_else(|| {
816 DirectJavascriptRunError::Failed(
817 "the JavaScript run is missing its crash-containment executable".into(),
818 )
819 })?;
820 let supervisor = ProcessSupervisor::new_crash_safe(watchdog_program)
821 .map_err(|error| DirectJavascriptRunError::Failed(error.to_string()))?;
822 let output_baseline = std::cell::RefCell::new(None);
823 let execution = match execute_plan_with_supervisor(
824 &supervisor,
825 &plan,
826 options,
827 diagnostics,
828 |phase, diagnostics| {
829 let status = if phase.kind == PhaseKind::Test {
830 *output_baseline.borrow_mut() =
834 Some(workspace_output_baseline(&workspace).map_err(|error| {
835 OrchestrationError::PhaseSetup {
836 phase: phase.name.clone(),
837 reason: error.to_string(),
838 }
839 })?);
840 if frontend.assertion_calls > 0 {
841 writeln!(
842 diagnostics,
843 "[supercov] attributed {} native node:assert call(s)",
844 frontend.assertion_calls
845 )
846 .map_err(|error| OrchestrationError::PhaseSetup {
847 phase: phase.name.clone(),
848 reason: error.to_string(),
849 })?;
850 }
851 writeln!(
852 diagnostics,
853 "[supercov] running in isolated workspace: {}",
854 request.command.join(" ")
855 )
856 .map_err(|error| OrchestrationError::PhaseSetup {
857 phase: phase.name.clone(),
858 reason: error.to_string(),
859 })?;
860 RunStateStatus::Testing
861 } else {
862 writeln!(
866 diagnostics,
867 "[supercov] building the instrumented copy first: {} ({})",
868 project.build_command.join(" "),
869 if crate::project_discovery::declared_build_command(&environment).is_some() {
870 format!("named by {}", crate::project_discovery::BUILD_COMMAND_VARIABLE)
871 } else {
872 format!(
873 "the project has a build script and the command may need its output; {}=<command> names another",
874 crate::project_discovery::BUILD_COMMAND_VARIABLE
875 )
876 }
877 )
878 .map_err(|error| OrchestrationError::PhaseSetup {
879 phase: phase.name.clone(),
880 reason: error.to_string(),
881 })?;
882 RunStateStatus::Building
883 };
884 update_run_state(&root, &run_id, status, &started_at, None).map_err(|error| {
885 OrchestrationError::PhaseSetup {
886 phase: phase.name.clone(),
887 reason: error.to_string(),
888 }
889 })?;
890 Ok(())
891 },
892 ) {
893 Ok(execution) => execution,
894 Err(error) => {
895 let message = error.to_string();
896 let state_updated = update_run_state(
897 &root,
898 &run_id,
899 RunStateStatus::Failed,
900 &started_at,
901 Some(message.clone()),
902 )
903 .is_ok();
904 if state_updated {
905 cleanup.mark_terminal();
906 }
907 return Err(message.into());
908 }
909 };
910 let instrumented_build_ms = execution
911 .phases
912 .iter()
913 .find(|phase| phase.kind == PhaseKind::Build)
914 .map_or(0.0, |phase| phase.duration_ms as f64);
915 let test_command_ms = execution
916 .phases
917 .iter()
918 .find(|phase| phase.kind == PhaseKind::Test)
919 .map_or(0.0, |phase| phase.duration_ms as f64);
920 let build_succeeded = execution
921 .phases
922 .iter()
923 .find(|phase| phase.kind == PhaseKind::Build)
924 .is_some_and(|phase| phase.result.exit_code() == 0);
925 if project.build_adapter != BuildAdapter::Direct && reusable_build.is_none() && build_succeeded
926 {
927 write_build_cache(&root, &workspace, &build_cache_key, &started_at)?;
928 }
929 if execution
934 .phases
935 .iter()
936 .any(|phase| phase.kind == PhaseKind::Build && phase.result.exit_code() != 0)
937 {
938 let _ = writeln!(
939 diagnostics,
940 "[supercov] the build failed, so the tests did not run. Supercov ran `{}` on the instrumented copy of the project before the tests; if it passes in the project itself, the error comes from the copy. {}=<command> names the build to run instead.",
941 project.build_command.join(" "),
942 crate::project_discovery::BUILD_COMMAND_VARIABLE
943 );
944 cleanup.keep_workspace = true;
947 let _ = writeln!(
948 diagnostics,
949 "[supercov] the instrumented copy is kept until the next run, to inspect: {}",
950 workspace.display()
951 );
952 }
953 if let Some(signal) = execution.interrupted_signal {
954 interrupt_run_state(&root, &run_id, &started_at, signal_name(signal))
955 .map_err(|error| error.to_string())?;
956 cleanup.mark_terminal();
957 return Err(DirectJavascriptRunError::Interrupted {
958 signal,
959 exit_code: execution.exit_code,
960 timings: RunTimings {
961 initialization_ms: rounded_millisecond(initialization_ms),
962 workspace_preparation_ms: rounded_millisecond(workspace_preparation_ms),
963 adapter_setup_ms: rounded_millisecond(adapter_setup_ms),
964 instrumented_build_ms: rounded_millisecond(instrumented_build_ms),
965 test_command_ms: rounded_millisecond(test_command_ms),
966 evidence_publication_ms: 0.0,
967 },
968 total_ms: rounded_millisecond(elapsed_ms(total_started)),
969 });
970 }
971 if let Some(baseline) = output_baseline.borrow().as_ref() {
976 let protected = project
977 .source_files
978 .iter()
979 .map(PathBuf::from)
980 .collect::<std::collections::BTreeSet<_>>();
981 let outputs = sync_command_outputs(&root, &workspace, baseline, &protected)
982 .map_err(|error| error.to_string())?;
983 if outputs.synced > 0 {
984 let _ = writeln!(
985 diagnostics,
986 "[supercov] synced {} file(s) the command created or changed back to the project: {}",
987 outputs.synced,
988 crate::workspace::summarize_paths(&outputs.synced_paths)
989 );
990 }
991 if !outputs.skipped_instrumented.is_empty() {
992 let _ = writeln!(
993 diagnostics,
994 "[supercov] {} file(s) stayed in the isolated workspace: they are instrumented copies, or were built from them, and must not overwrite your project: {}",
995 outputs.skipped_instrumented.len(),
996 crate::workspace::summarize_paths(&outputs.skipped_instrumented)
997 );
998 }
999 if !outputs.deleted_in_workspace.is_empty() {
1000 let _ = writeln!(
1001 diagnostics,
1002 "[supercov] the command deleted {} file(s) in the isolated workspace; deletions are not propagated to the project: {}",
1003 outputs.deleted_in_workspace.len(),
1004 crate::workspace::summarize_paths(&outputs.deleted_in_workspace)
1005 );
1006 }
1007 }
1008 update_run_state(
1009 &root,
1010 &run_id,
1011 RunStateStatus::Publishing,
1012 &started_at,
1013 None,
1014 )
1015 .map_err(|error| error.to_string())?;
1016
1017 let publication_started = Instant::now();
1018 let archive_path = root
1019 .join(".supercov/work")
1020 .join(&run_id)
1021 .join("evidence.raw.gz");
1022 let entries = collect_sources(&[
1023 EvidenceArchiveSource::File {
1024 file: frontend.manifest_path,
1025 path: "manifest.json".into(),
1026 },
1027 EvidenceArchiveSource::File {
1028 file: workspace.join(".supercov/statement-exclusions.json"),
1029 path: "statement-exclusions.json".into(),
1030 },
1031 EvidenceArchiveSource::Directory {
1032 directory: evidence_directory,
1033 prefix: None,
1034 },
1035 EvidenceArchiveSource::Directory {
1036 directory: server_evidence_root.join(&run_id),
1037 prefix: Some("server".into()),
1038 },
1039 ])
1040 .map_err(|error| error.to_string())?;
1041 let entries =
1042 javascript_archive_entries(entries, &frontend.manifest, &run_id, execution.exit_code)?;
1043 let entries = crate::source_capture::append(entries, &source_inputs)?;
1044 let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
1045 remove_stored_tree_deferred(&root, &workspace.join(".supercov/evidence"))
1046 .map_err(|error| error.to_string())?;
1047 remove_stored_tree_deferred(&root, &server_evidence_root).map_err(|error| error.to_string())?;
1048 let evidence_publication_ms = elapsed_ms(publication_started);
1049 let timings = RunTimings {
1050 initialization_ms: rounded_millisecond(initialization_ms),
1051 workspace_preparation_ms: rounded_millisecond(workspace_preparation_ms),
1052 adapter_setup_ms: rounded_millisecond(adapter_setup_ms),
1053 instrumented_build_ms: rounded_millisecond(instrumented_build_ms),
1054 test_command_ms: rounded_millisecond(test_command_ms),
1055 evidence_publication_ms: rounded_millisecond(evidence_publication_ms),
1056 };
1057 let metadata = RunMetadata {
1058 id: run_id.clone(),
1059 started_at: started_at.clone(),
1060 duration_ms: rounded_millisecond(elapsed_ms(total_started)),
1061 command: request.command.clone(),
1062 test_exit_code: Some(execution.exit_code),
1063 integrity,
1064 raw_evidence: RawEvidenceMetadata {
1065 schema_version: raw.schema_version,
1066 format: raw.format.into(),
1067 file: raw.file.into(),
1068 files: raw.files,
1069 uncompressed_bytes: raw.uncompressed_bytes,
1070 compressed_bytes: raw.compressed_bytes,
1071 },
1072 isolated_build: Some(true),
1073 instrumented_build_cache: Some(InstrumentedBuildCache {
1074 key: build_cache_key,
1075 reused: reusable_build.is_some(),
1076 }),
1077 timings: Some(timings),
1078 merged: None,
1079 parents: None,
1080 source_roots: source_roots.clone(),
1081 };
1082 let run_directory =
1083 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1084 let terminal_status = if execution.exit_code == 0 {
1085 RunStateStatus::Complete
1086 } else {
1087 RunStateStatus::Failed
1088 };
1089 update_run_state(&root, &run_id, terminal_status, &started_at, None)
1090 .map_err(|error| error.to_string())?;
1091 finalize_published_run(&root, &run_id).map_err(|error| error.to_string())?;
1092 cleanup.mark_terminal();
1093 Ok(DirectJavascriptRunResult {
1094 run_id,
1095 run_directory,
1096 workspace,
1097 exit_code: execution.exit_code,
1098 assertion_calls: frontend.assertion_calls,
1099 recovered_runs,
1100 metadata,
1101 })
1102}
1103
1104#[cfg(test)]
1105mod environment_tests {
1106 use super::*;
1107
1108 #[test]
1109 fn nested_npm_drops_only_pnpm_derived_environment_aliases() {
1110 let mut environment = BTreeMap::from([
1111 ("npm_config_auto_install_peers".into(), "true".into()),
1112 ("NPM_CONFIG_SHAMEFULLY_HOIST".into(), "true".into()),
1113 (
1114 "npm_config_registry".into(),
1115 "https://registry.npmjs.org".into(),
1116 ),
1117 ("USER_VALUE".into(), "kept".into()),
1118 ]);
1119 remove_derived_pnpm_config(&mut environment);
1120 assert_eq!(
1121 environment,
1122 BTreeMap::from([
1123 ("USER_VALUE".into(), "kept".into()),
1124 (
1125 "npm_config_registry".into(),
1126 "https://registry.npmjs.org".into()
1127 ),
1128 ])
1129 );
1130 }
1131}