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