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 pub server_runs: Vec<(String, PathBuf)>,
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
438const PLAYWRIGHT_CACHE_VARIABLE: &str = "PWTEST_CACHE_DIR";
440
441pub fn playwright_transform_cache(root: &Path) -> PathBuf {
443 crate::workspace::workspace_container(root).join("caches/playwright-transform")
444}
445
446fn node_options(preload: &Path) -> String {
447 let preload = std::path::absolute(preload).unwrap_or_else(|_| preload.to_path_buf());
453 [
459 std::env::var("NODE_OPTIONS").ok(),
460 Some("--enable-source-maps".into()),
461 Some(format!("--import={}", crate::workspace::file_url(&preload))),
462 ]
463 .into_iter()
464 .flatten()
465 .filter(|value| !value.is_empty())
466 .collect::<Vec<_>>()
467 .join(" ")
468}
469
470pub fn current_javascript_integrity(
474 root: &Path,
475 command: &[String],
476 source_roots: Option<&[String]>,
477) -> Result<RunIntegrity, String> {
478 let mut environment = std::env::vars().collect::<BTreeMap<_, _>>();
479 match source_roots.filter(|roots| !roots.is_empty()) {
484 Some(roots) => {
485 environment.insert(
486 crate::source_discovery::SOURCE_ROOTS_VARIABLE.into(),
487 roots.join(","),
488 );
489 }
490 None => {
491 environment.remove(crate::source_discovery::SOURCE_ROOTS_VARIABLE);
492 }
493 }
494 let project = discover_coverage_project(root, &environment, command)
495 .map_err(|error| error.to_string())?;
496 javascript_integrity_for_project(root, &project)
497}
498
499fn javascript_integrity_for_project(
500 root: &Path,
501 project: &crate::project_discovery::CoverageProject,
502) -> Result<RunIntegrity, String> {
503 let frontend = FrontendIntegrityInputs::embedded_javascript();
504 create_run_integrity(root, project, &frontend).map_err(|error| error.to_string())
505}
506
507pub fn run_direct_javascript(
510 request: &DirectJavascriptRunRequest,
511 diagnostics: &mut dyn std::io::Write,
512) -> Result<DirectJavascriptRunResult, DirectJavascriptRunError> {
513 if request.command.is_empty() {
514 return Err(DirectJavascriptRunError::Failed(
515 "test command must not be empty".into(),
516 ));
517 }
518 let total_started = Instant::now();
519 let initialization_started = Instant::now();
520 let root = crate::workspace::canonicalize_simplified(&request.root)
521 .map_err(|error| format!("{}: {error}", request.root.display()))?;
522 let nonce = now_nonce();
523 let run_id = request
524 .run_id
525 .clone()
526 .unwrap_or_else(|| format!("rust-{nonce}"));
527 let started_at = request
528 .started_at
529 .clone()
530 .unwrap_or_else(|| format!("unix-ms-{nonce}"));
531 let lock =
532 ProjectLock::acquire(&root, &run_id, &started_at).map_err(|error| error.to_string())?;
533 let mut cleanup = RunCleanup {
534 root: root.clone(),
535 run_id: run_id.clone(),
536 started_at: started_at.clone(),
537 lock,
538 workspace: None,
539 state_written: false,
540 terminal_recorded: false,
541 keep_workspace: false,
542 };
543 let recovered_runs =
544 recover_abandoned_runs(&root, &started_at).map_err(|error| error.to_string())?;
545 if !recovered_runs.is_empty() {
546 writeln!(
547 diagnostics,
548 "[supercov] recovered abandoned run(s): {}",
549 recovered_runs.join(", ")
550 )
551 .map_err(|error| error.to_string())?;
552 }
553 let environment = std::env::vars().collect::<BTreeMap<_, _>>();
554 let source_roots = crate::source_discovery::configured_source_roots(&environment);
555 let project = crate::project_discovery::discover_coverage_project_of_any_size(
556 &root,
557 &environment,
558 &request.command,
559 )
560 .map_err(|error| error.to_string())?;
561 let browser_suite = project.playwright_config.is_some();
565 let serves_python = browser_suite
566 && crate::python_project::discover_python_files(&root)
567 .is_ok_and(|files| !files.sources.is_empty());
568 let serves_ruby = browser_suite
569 && crate::ruby_project::discover_ruby_files(&root)
570 .is_ok_and(|files| !files.sources.is_empty());
571 let serves_go = browser_suite
572 && (root.join("go.mod").is_file()
573 || !crate::go_project::workspace_modules(&root).is_empty());
574 if project.source_files.is_empty() && !serves_python && !serves_ruby && !serves_go {
575 return Err(
576 crate::project_discovery::ProjectDiscoveryError::NoSourceFiles
577 .to_string()
578 .into(),
579 );
580 }
581 let integrity = javascript_integrity_for_project(&root, &project)?;
582 let source_inputs = crate::source_capture::capture(
583 &root,
584 "javascript",
585 crate::integrity::javascript_assertion_paths(&root, &project).map_err(|e| e.to_string())?,
586 )?;
587 let frontend_cache_key = format!(
593 "{}:{}:{}:{}:{}",
594 integrity.fingerprint.combined,
595 integrity.fingerprint.execution,
596 crate::source_manifest::digest(&request.command),
597 crate::source_manifest::digest(&project.root.to_string_lossy()),
598 crate::source_manifest::digest(&(&project.source_files, &source_roots))
599 );
600 let prior_workspace =
601 cached_workspace_path(&root, cleanup.lock()).map_err(|error| error.to_string())?;
602 let reusable_frontend = read_javascript_frontend_cache(&prior_workspace, &frontend_cache_key);
603 let cached_paths = reusable_frontend
604 .as_ref()
605 .map(javascript_frontend_reuse_paths)
606 .unwrap_or_default();
607 let initialization_ms = elapsed_ms(initialization_started);
608 let prepare_servers = |workspace: PathBuf| {
612 let (root, run_id, command) = (root.clone(), run_id.clone(), request.command.clone());
613 move || -> Result<Vec<Server>, String> {
614 let mut servers = Vec::new();
615 if serves_python
616 && let Some(server) =
617 crate::python_run::PythonServer::prepare(&root, &workspace, &run_id, &command)?
618 {
619 servers.push(Server::Python(server));
620 }
621 if serves_ruby
622 && let Some(server) =
623 crate::ruby_run::RubyServer::prepare(&root, &workspace, &run_id, &command)?
624 {
625 servers.push(Server::Ruby(server));
626 }
627 Ok(servers)
628 }
629 };
630 let preparing_servers = std::thread::spawn(prepare_servers(prior_workspace.clone()));
631
632 let workspace_started = Instant::now();
633 let workspace_progress = crate::progress::ProgressLine::start("preparing isolated workspace");
634 let workspace = prepare_cached_workspace(&root, cleanup.lock(), &cached_paths)
635 .map_err(|error| error.to_string())?;
636 drop(workspace_progress);
637 cleanup.set_workspace(workspace.clone());
638 crate::lifecycle::sweep_trash_in_background(&root);
640 if let Err(error) = crate::workspace::mirror_git_repository(&root, &workspace)
641 && std::env::var_os("SUPERCOV_DEBUG").is_some()
642 {
643 eprintln!("[supercov] the workspace has no git repository of its own: {error}");
644 }
645 let workspace_preparation_ms = elapsed_ms(workspace_started);
646 writeln!(
647 diagnostics,
648 "[supercov] instrumenting isolated workspace {}",
649 workspace.display()
650 )
651 .map_err(|error| error.to_string())?;
652 cleanup.mark_state_written();
653 write_run_state(
654 &root,
655 &RunState {
656 id: run_id.clone(),
657 pid: std::process::id(),
658 root: root.display().to_string(),
659 workspace: workspace.display().to_string(),
660 started_at: started_at.clone(),
661 updated_at: started_at.clone(),
662 status: RunStateStatus::Preparing,
663 signal: None,
664 error: None,
665 },
666 )
667 .map_err(|error| error.to_string())?;
668
669 let adapter_started = Instant::now();
670 let instrumentation_progress = crate::progress::ProgressLine::start("instrumenting sources");
671 let collector_id = format!("collector-{}", integrity.fingerprint.execution);
672 let frontend = if let Some(cache) = &reusable_frontend {
673 load_cached_javascript_frontend(&workspace, cache)
674 } else {
675 prepare_javascript_frontend(
676 &workspace,
677 &project,
678 &collector_id,
679 &frontend_cache_key,
680 &request.command,
681 )
682 }
683 .map_err(|error| error.to_string())?;
684 crate::workspace::hide_rewritten_files(
685 &workspace,
686 &crate::javascript_frontend::rewritten_files(&workspace),
687 );
688 drop(instrumentation_progress);
689 let adapter_setup_ms = elapsed_ms(adapter_started);
690 if let Some(detail) = crate::javascript_frontend::setup_timing_detail() {
691 writeln!(diagnostics, "[supercov] {detail}").map_err(|error| error.to_string())?;
692 }
693
694 let mut servers = preparing_servers
695 .join()
696 .unwrap_or_else(|panic| std::panic::resume_unwind(panic))?;
697 if workspace != prior_workspace && !servers.is_empty() {
700 servers = prepare_servers(workspace.clone())()?;
701 }
702 if serves_go
704 && let Some(server) =
705 crate::go_run::GoServer::prepare(&root, &workspace, &run_id, &request.command)?
706 {
707 servers.push(Server::Go(server));
708 }
709 if project.source_files.is_empty() && servers.is_empty() {
710 return Err(
711 crate::project_discovery::ProjectDiscoveryError::NoSourceFiles
712 .to_string()
713 .into(),
714 );
715 }
716 for server in &servers {
717 writeln!(
718 diagnostics,
719 "[supercov] measuring {} {language} source file(s) in any {language} server the tests start",
720 server.source_files(),
721 language = server.language(),
722 )
723 .map_err(|error| error.to_string())?;
724 }
725
726 let evidence_relative = format!(".supercov/evidence/{run_id}");
727 let evidence_directory = workspace.join(&evidence_relative);
728 let server_evidence_root = workspace.join(".supercov/server-evidence");
729 let diagnostic_owner = workspace.join(format!(".supercov/diagnostic-owner-{run_id}"));
730 let mut overrides: BTreeMap<String, String> = BTreeMap::from([
731 ("NODE_OPTIONS".into(), node_options(&frontend.preload_path)),
732 ("SUPERCOV_CJS_INTERCEPT".into(), "1".into()),
733 ("SUPERCOV_DIRECT_INSTRUMENTATION".into(), "1".into()),
734 ("TAP_ALLOW_INCOMPLETE_COVERAGE".into(), "1".into()),
739 (
743 "SUPERCOV_EVIDENCE_DIR".into(),
744 evidence_directory.display().to_string(),
745 ),
746 (
747 "SUPERCOV_DIAGNOSTIC_OWNER_FILE".into(),
748 diagnostic_owner.display().to_string(),
749 ),
750 (
751 "SUPERCOV_EXECUTION_FINGERPRINT".into(),
752 integrity.fingerprint.execution.clone(),
753 ),
754 (
755 "SUPERCOV_EXECUTION_LOG".into(),
756 evidence_directory
757 .join("execution.jsonl")
758 .display()
759 .to_string(),
760 ),
761 (
762 "SUPERCOV_MANIFEST".into(),
763 frontend.manifest_path.display().to_string(),
764 ),
765 (
766 "SUPERCOV_PROJECT_ROOT".into(),
767 workspace.display().to_string(),
768 ),
769 ("SUPERCOV_RUN_ID".into(), run_id.clone()),
770 (
771 "SUPERCOV_SERVER_EVIDENCE_ROOT".into(),
772 server_evidence_root.display().to_string(),
773 ),
774 (
775 "SUPERCOV_SOURCE_PROJECT_ROOT".into(),
776 root.display().to_string(),
777 ),
778 ]);
779 if std::env::var_os(PLAYWRIGHT_CACHE_VARIABLE).is_none_or(|value| value.is_empty()) {
789 overrides.insert(
790 PLAYWRIGHT_CACHE_VARIABLE.into(),
791 playwright_transform_cache(&root).display().to_string(),
792 );
793 }
794 overrides.insert(
798 "SUPERCOV_GENERATED_VITEST_CONFIG".into(),
799 frontend.vitest_config_path.display().to_string(),
800 );
801 overrides.insert(
802 "SUPERCOV_GENERATED_PLAYWRIGHT_CONFIG".into(),
803 frontend.playwright_config_path.display().to_string(),
804 );
805 overrides.insert(
806 "SUPERCOV_PLAYWRIGHT_MODULE".into(),
807 project.playwright_module.clone(),
808 );
809 overrides.insert(
810 "SUPERCOV_PLAYWRIGHT_TEST_EXPORT".into(),
811 project.playwright_test_export.clone(),
812 );
813 overrides.insert(
814 "SUPERCOV_PLAYWRIGHT_WRAPPER".into(),
815 "./.supercov/node_modules/playwright.mjs".into(),
816 );
817 if let Some(original) = project
818 .playwright_config
819 .as_ref()
820 .and_then(|path| path.strip_prefix(&root).ok())
821 .map(|path| workspace.join(path))
822 {
823 overrides.insert(
824 "SUPERCOV_ORIGINAL_PLAYWRIGHT_CONFIG".into(),
825 original.display().to_string(),
826 );
827 }
828 for server in &servers {
829 overrides.extend(server.environment());
830 }
831 if let Some(path) = crate::source_tools::path_with_launchers(&workspace)
834 && let Some(path) = path.to_str()
835 {
836 overrides.insert("PATH".into(), path.to_owned());
837 }
838 let settings = serde_json::json!({
842 "root": workspace.display().to_string(),
843 "environment": overrides
844 .iter()
845 .filter(|(name, _)| name.starts_with("SUPERCOV_"))
846 .collect::<BTreeMap<_, _>>(),
847 });
848 let settings_path = frontend.preload_path.with_file_name("run.json");
849 std::fs::write(
850 &settings_path,
851 serde_json::to_vec(&settings).map_err(|error| error.to_string())?,
852 )
853 .map_err(|error| format!("{}: {error}", settings_path.display()))?;
854 let _ = crate::source_tools::clear_source_changes(&workspace);
857 let plan = ExecutionPlan {
859 preparation: Vec::new(),
860 test: ExecutionPhase {
861 name: "test".into(),
862 kind: PhaseKind::Test,
863 command: CommandSpec {
864 program: request.command[0].clone().into(),
865 arguments: request.command[1..].iter().map(OsString::from).collect(),
866 cwd: workspace.clone(),
867 environment: Some(environment_with(overrides)),
868 captured_output: None,
869 console: None,
870 },
871 },
872 };
873 let options = supervision_options()?;
874 let watchdog_program = request.watchdog_program.as_ref().ok_or_else(|| {
875 DirectJavascriptRunError::Failed(
876 "the JavaScript run is missing its crash-containment executable".into(),
877 )
878 })?;
879 let supervisor = ProcessSupervisor::new_crash_safe(watchdog_program)
880 .map_err(|error| DirectJavascriptRunError::Failed(error.to_string()))?;
881 let output_baseline = std::cell::RefCell::new(None);
882 let execution = match execute_plan_with_supervisor(
883 &supervisor,
884 &plan,
885 options,
886 diagnostics,
887 |phase, diagnostics| {
888 {
889 *output_baseline.borrow_mut() =
893 Some(workspace_output_baseline(&workspace).map_err(|error| {
894 OrchestrationError::PhaseSetup {
895 phase: phase.name.clone(),
896 reason: error.to_string(),
897 }
898 })?);
899 if frontend.assertion_calls > 0 {
900 writeln!(
901 diagnostics,
902 "[supercov] attributed {} native node:assert call(s)",
903 frontend.assertion_calls
904 )
905 .map_err(|error| OrchestrationError::PhaseSetup {
906 phase: phase.name.clone(),
907 reason: error.to_string(),
908 })?;
909 }
910 writeln!(
911 diagnostics,
912 "[supercov] running in isolated workspace: {}",
913 request.command.join(" ")
914 )
915 .map_err(|error| OrchestrationError::PhaseSetup {
916 phase: phase.name.clone(),
917 reason: error.to_string(),
918 })?;
919 }
920 update_run_state(&root, &run_id, RunStateStatus::Testing, &started_at, None).map_err(
921 |error| OrchestrationError::PhaseSetup {
922 phase: phase.name.clone(),
923 reason: error.to_string(),
924 },
925 )?;
926 Ok(())
927 },
928 ) {
929 Ok(execution) => execution,
930 Err(error) => {
931 let message = error.to_string();
932 let state_updated = update_run_state(
933 &root,
934 &run_id,
935 RunStateStatus::Failed,
936 &started_at,
937 Some(message.clone()),
938 )
939 .is_ok();
940 if state_updated {
941 cleanup.mark_terminal();
942 }
943 return Err(message.into());
944 }
945 };
946 let test_command_ms = execution
947 .phases
948 .iter()
949 .find(|phase| phase.kind == PhaseKind::Test)
950 .map_or(0.0, |phase| phase.duration_ms as f64);
951 if execution.exit_code != 0
956 && execution.interrupted_signal.is_none()
957 && !project.unrun_build.is_empty()
958 {
959 let build = project.unrun_build.join(" ");
960 let _ = writeln!(
961 diagnostics,
962 "[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} && {}\"`",
963 request.command.join(" ")
964 );
965 }
966 if let Some(signal) = execution.interrupted_signal {
967 interrupt_run_state(&root, &run_id, &started_at, signal_name(signal))
968 .map_err(|error| error.to_string())?;
969 cleanup.mark_terminal();
970 return Err(DirectJavascriptRunError::Interrupted {
971 signal,
972 exit_code: execution.exit_code,
973 timings: RunTimings {
974 initialization_ms: rounded_millisecond(initialization_ms),
975 workspace_preparation_ms: rounded_millisecond(workspace_preparation_ms),
976 adapter_setup_ms: rounded_millisecond(adapter_setup_ms),
977 instrumented_build_ms: 0.0,
978 test_command_ms: rounded_millisecond(test_command_ms),
979 evidence_publication_ms: 0.0,
980 },
981 total_ms: rounded_millisecond(elapsed_ms(total_started)),
982 });
983 }
984 if let Some(baseline) = output_baseline.borrow().as_ref() {
989 let protected = project
990 .source_files
991 .iter()
992 .map(PathBuf::from)
993 .collect::<std::collections::BTreeSet<_>>();
994 let outputs = sync_command_outputs(&root, &workspace, baseline, &protected)
995 .map_err(|error| error.to_string())?;
996 if outputs.synced > 0 {
997 let _ = writeln!(
998 diagnostics,
999 "[supercov] synced {} file(s) the command created or changed back to the project: {}",
1000 outputs.synced,
1001 crate::workspace::summarize_paths(&outputs.synced_paths)
1002 );
1003 }
1004 if !outputs.skipped_instrumented.is_empty() {
1005 let _ = writeln!(
1006 diagnostics,
1007 "[supercov] {} file(s) stayed in the isolated workspace: they are instrumented copies, or were built from them, and must not overwrite your project: {}",
1008 outputs.skipped_instrumented.len(),
1009 crate::workspace::summarize_paths(&outputs.skipped_instrumented)
1010 );
1011 }
1012 match crate::source_tools::apply_source_changes(&root, &workspace) {
1017 Ok(changes) => {
1018 if !changes.applied.is_empty() {
1019 let _ = writeln!(
1020 diagnostics,
1021 "[supercov] the command's tools changed {} source file(s), now changed in the project too: {}; this run measured them as they were before",
1022 changes.applied.len(),
1023 crate::workspace::summarize_paths(&changes.applied)
1024 );
1025 }
1026 if !changes.kept_back.is_empty() {
1027 let _ = writeln!(
1028 diagnostics,
1029 "[supercov] the command's tools changed {} source file(s) that were edited in the project while it ran; those changes were not applied: {}",
1030 changes.kept_back.len(),
1031 crate::workspace::summarize_paths(&changes.kept_back)
1032 );
1033 }
1034 }
1035 Err(error) => {
1036 let _ = writeln!(
1037 diagnostics,
1038 "[supercov] the source changes the command's tools made could not be applied to the project: {error}"
1039 );
1040 }
1041 }
1042 if !outputs.deleted_in_workspace.is_empty() {
1043 let _ = writeln!(
1044 diagnostics,
1045 "[supercov] the command deleted {} file(s) in the isolated workspace; deletions are not propagated to the project: {}",
1046 outputs.deleted_in_workspace.len(),
1047 crate::workspace::summarize_paths(&outputs.deleted_in_workspace)
1048 );
1049 }
1050 }
1051 update_run_state(
1052 &root,
1053 &run_id,
1054 RunStateStatus::Publishing,
1055 &started_at,
1056 None,
1057 )
1058 .map_err(|error| error.to_string())?;
1059
1060 let publication_started = Instant::now();
1061 let served = if servers.is_empty() {
1063 Vec::new()
1064 } else {
1065 crate::served_tests::read(&evidence_directory)
1066 };
1067 if project.source_files.is_empty() {
1068 let metadata = RunMetadata {
1071 id: run_id.clone(),
1072 started_at: started_at.clone(),
1073 duration_ms: rounded_millisecond(elapsed_ms(total_started)),
1074 command: request.command.clone(),
1075 test_exit_code: Some(execution.exit_code),
1076 integrity: integrity.clone(),
1077 raw_evidence: RawEvidenceMetadata::default(),
1078 isolated_build: Some(true),
1079 instrumented_build_cache: None,
1080 timings: Some(RunTimings {
1081 initialization_ms: rounded_millisecond(initialization_ms),
1082 workspace_preparation_ms: rounded_millisecond(workspace_preparation_ms),
1083 adapter_setup_ms: rounded_millisecond(adapter_setup_ms),
1084 instrumented_build_ms: 0.0,
1085 test_command_ms: rounded_millisecond(test_command_ms),
1086 evidence_publication_ms: 0.0,
1087 }),
1088 merged: None,
1089 parents: None,
1090 source_roots: source_roots.clone(),
1091 copy: Some(cleanup.lock().copy()),
1092 merged_across: None,
1093 recorded_by: None,
1094 companions: None,
1095 };
1096 let mut published = None;
1097 let mut server_runs = Vec::new();
1098 let mut failure = None;
1099 for server in servers {
1100 let id = if published.is_none() {
1101 run_id.clone()
1102 } else {
1103 companion_run_id(&run_id, server_runs.len() + 1)
1104 };
1105 let language = server.language();
1106 match server.publish(
1107 &root,
1108 &run_id,
1109 RunMetadata {
1110 id: id.clone(),
1111 ..metadata.clone()
1112 },
1113 &served,
1114 diagnostics,
1115 ) {
1116 Ok(Some(run)) if published.is_none() => published = Some(run),
1117 Ok(Some((directory, _))) => {
1118 finalize_published_run(&root, &id).map_err(|error| error.to_string())?;
1119 server_runs.push((language.to_owned(), directory));
1120 }
1121 Ok(None) => {}
1122 Err(error) => {
1123 let _ = writeln!(
1124 diagnostics,
1125 "[supercov] the {language} server's run could not be published: {error}"
1126 );
1127 failure.get_or_insert(error);
1128 }
1129 }
1130 }
1131 remove_stored_tree_deferred(&root, &workspace.join(".supercov/evidence"))
1132 .map_err(|error| error.to_string())?;
1133 remove_stored_tree_deferred(&root, &server_evidence_root)
1134 .map_err(|error| error.to_string())?;
1135 if published.is_none()
1136 && let Some(error) = failure
1137 {
1138 return Err(error.into());
1139 }
1140 let Some((run_directory, metadata)) = published else {
1141 let message = if execution.exit_code != 0 && served.is_empty() {
1142 format!(
1143 "the test command exited {} before any test reported an outcome; its own output above says why, and no run was published",
1144 execution.exit_code
1145 )
1146 } else {
1147 "the project has no JavaScript source, and no test's request reached measured server code: there was nothing to measure, and no run was published".to_owned()
1148 };
1149 let _ = update_run_state(
1150 &root,
1151 &run_id,
1152 RunStateStatus::Failed,
1153 &started_at,
1154 Some(message.clone()),
1155 );
1156 cleanup.mark_terminal();
1157 return Err(message.into());
1158 };
1159 update_run_state(
1160 &root,
1161 &run_id,
1162 if execution.exit_code == 0 {
1163 RunStateStatus::Complete
1164 } else {
1165 RunStateStatus::Failed
1166 },
1167 &started_at,
1168 None,
1169 )
1170 .map_err(|error| error.to_string())?;
1171 finalize_published_run(&root, &run_id).map_err(|error| error.to_string())?;
1172 cleanup.mark_terminal();
1173 return Ok(DirectJavascriptRunResult {
1174 run_id,
1175 run_directory,
1176 workspace,
1177 exit_code: execution.exit_code,
1178 assertion_calls: frontend.assertion_calls,
1179 recovered_runs,
1180 metadata,
1181 server_runs,
1182 });
1183 }
1184 let archive_path = root
1185 .join(".supercov/work")
1186 .join(&run_id)
1187 .join("evidence.raw.gz");
1188 let entries = collect_sources(&[
1189 EvidenceArchiveSource::File {
1190 file: frontend.manifest_path,
1191 path: "manifest.json".into(),
1192 },
1193 EvidenceArchiveSource::File {
1194 file: workspace.join(".supercov/statement-exclusions.json"),
1195 path: "statement-exclusions.json".into(),
1196 },
1197 EvidenceArchiveSource::Directory {
1198 directory: evidence_directory,
1199 prefix: None,
1200 },
1201 EvidenceArchiveSource::Directory {
1202 directory: server_evidence_root.join(&run_id),
1203 prefix: Some("server".into()),
1204 },
1205 ])
1206 .map_err(|error| error.to_string())?;
1207 let entries =
1208 javascript_archive_entries(entries, &frontend.manifest, &run_id, execution.exit_code)?;
1209 let entries = crate::source_capture::append(entries, &source_inputs)?;
1210 let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
1211 remove_stored_tree_deferred(&root, &workspace.join(".supercov/evidence"))
1212 .map_err(|error| error.to_string())?;
1213 remove_stored_tree_deferred(&root, &server_evidence_root).map_err(|error| error.to_string())?;
1214 let evidence_publication_ms = elapsed_ms(publication_started);
1215 let timings = RunTimings {
1216 initialization_ms: rounded_millisecond(initialization_ms),
1217 workspace_preparation_ms: rounded_millisecond(workspace_preparation_ms),
1218 adapter_setup_ms: rounded_millisecond(adapter_setup_ms),
1219 instrumented_build_ms: 0.0,
1220 test_command_ms: rounded_millisecond(test_command_ms),
1221 evidence_publication_ms: rounded_millisecond(evidence_publication_ms),
1222 };
1223 let metadata = RunMetadata {
1224 id: run_id.clone(),
1225 started_at: started_at.clone(),
1226 duration_ms: rounded_millisecond(elapsed_ms(total_started)),
1227 command: request.command.clone(),
1228 test_exit_code: Some(execution.exit_code),
1229 integrity,
1230 raw_evidence: RawEvidenceMetadata {
1231 schema_version: raw.schema_version,
1232 format: raw.format.into(),
1233 file: raw.file.into(),
1234 files: raw.files,
1235 uncompressed_bytes: raw.uncompressed_bytes,
1236 compressed_bytes: raw.compressed_bytes,
1237 },
1238 isolated_build: Some(true),
1239 instrumented_build_cache: None,
1240 timings: Some(timings),
1241 merged: None,
1242 parents: None,
1243 source_roots: source_roots.clone(),
1244 copy: Some(cleanup.lock().copy()),
1245 merged_across: None,
1246 recorded_by: None,
1247 companions: None,
1248 };
1249 let mut server_runs = Vec::new();
1253 let mut companions = Vec::new();
1254 for server in servers {
1255 let id = companion_run_id(&run_id, server_runs.len() + 1);
1256 let language = server.language();
1257 let server_metadata = RunMetadata {
1258 id: id.clone(),
1259 companions: Some(vec![crate::run_store::CompanionRun {
1260 id: run_id.clone(),
1261 language: "JavaScript".into(),
1262 role: "browser".into(),
1263 }]),
1264 ..metadata.clone()
1265 };
1266 match server.publish(&root, &run_id, server_metadata, &served, diagnostics) {
1267 Ok(Some((directory, _))) => {
1268 finalize_published_run(&root, &id).map_err(|error| error.to_string())?;
1269 server_runs.push((language.to_owned(), directory));
1270 companions.push(crate::run_store::CompanionRun {
1271 id,
1272 language: language.into(),
1273 role: "server".into(),
1274 });
1275 }
1276 Ok(None) => {}
1277 Err(error) => {
1279 let _ = writeln!(
1280 diagnostics,
1281 "[supercov] the {language} server's run could not be published: {error}"
1282 );
1283 }
1284 }
1285 }
1286 let metadata = RunMetadata {
1287 companions: (!companions.is_empty()).then_some(companions),
1288 ..metadata
1289 };
1290 let run_directory =
1291 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1292 let terminal_status = if execution.exit_code == 0 {
1293 RunStateStatus::Complete
1294 } else {
1295 RunStateStatus::Failed
1296 };
1297 update_run_state(&root, &run_id, terminal_status, &started_at, None)
1298 .map_err(|error| error.to_string())?;
1299 finalize_published_run(&root, &run_id).map_err(|error| error.to_string())?;
1300 cleanup.mark_terminal();
1301 Ok(DirectJavascriptRunResult {
1302 run_id,
1303 run_directory,
1304 workspace,
1305 exit_code: execution.exit_code,
1306 assertion_calls: frontend.assertion_calls,
1307 recovered_runs,
1308 metadata,
1309 server_runs,
1310 })
1311}
1312
1313fn companion_run_id(run_id: &str, nth: usize) -> String {
1317 match run_id
1318 .strip_prefix("run_")
1319 .filter(|hex| hex.len() == 16)
1320 .and_then(|hex| u64::from_str_radix(hex, 16).ok())
1321 {
1322 Some(number) => format!("run_{:016x}", number.wrapping_sub(nth as u64)),
1323 None => format!("{run_id}-server-{nth}"),
1324 }
1325}
1326
1327enum Server {
1329 Python(crate::python_run::PythonServer),
1330 Ruby(crate::ruby_run::RubyServer),
1331 Go(crate::go_run::GoServer),
1332}
1333
1334impl Server {
1335 fn language(&self) -> &'static str {
1336 match self {
1337 Self::Python(_) => "Python",
1338 Self::Ruby(_) => "Ruby",
1339 Self::Go(_) => "Go",
1340 }
1341 }
1342
1343 fn source_files(&self) -> usize {
1344 match self {
1345 Self::Python(server) => server.source_files(),
1346 Self::Ruby(server) => server.source_files(),
1347 Self::Go(server) => server.source_files(),
1348 }
1349 }
1350
1351 fn environment(&self) -> BTreeMap<String, String> {
1352 match self {
1353 Self::Python(server) => server.environment(),
1354 Self::Ruby(server) => server.environment(),
1355 Self::Go(server) => server.environment(),
1356 }
1357 }
1358
1359 fn publish(
1360 self,
1361 root: &Path,
1362 evidence_run_id: &str,
1363 metadata: RunMetadata,
1364 served: &[crate::served_tests::ServedTest],
1365 diagnostics: &mut dyn std::io::Write,
1366 ) -> Result<Option<(PathBuf, RunMetadata)>, String> {
1367 match self {
1368 Self::Python(server) => {
1369 server.publish(root, evidence_run_id, metadata, served, diagnostics)
1370 }
1371 Self::Ruby(server) => {
1372 server.publish(root, evidence_run_id, metadata, served, diagnostics)
1373 }
1374 Self::Go(server) => {
1375 server.publish(root, evidence_run_id, metadata, served, diagnostics)
1376 }
1377 }
1378 }
1379}
1380
1381#[cfg(test)]
1382mod environment_tests {
1383 use super::*;
1384
1385 #[test]
1388 fn a_companion_run_id_is_public_and_sorts_after_its_run() {
1389 for run in [
1390 "run_0123456789abcdef",
1391 "run_ffffffffffffffff",
1392 "run_8000000000000000",
1393 ] {
1394 let companion = companion_run_id(run, 1);
1395 assert_eq!(companion.len(), 20);
1396 assert!(companion.starts_with("run_"));
1397 assert!(companion.as_str() < run, "{companion} >= {run}");
1398 assert_eq!(companion, companion_run_id(run, 1));
1399 assert!(companion_run_id(run, 2) < companion);
1400 }
1401 assert_eq!(companion_run_id("rust-1", 1), "rust-1-server-1");
1402 }
1403
1404 #[test]
1405 fn nested_npm_drops_only_pnpm_derived_environment_aliases() {
1406 let mut environment = BTreeMap::from([
1407 ("npm_config_auto_install_peers".into(), "true".into()),
1408 ("NPM_CONFIG_SHAMEFULLY_HOIST".into(), "true".into()),
1409 (
1410 "npm_config_registry".into(),
1411 "https://registry.npmjs.org".into(),
1412 ),
1413 ("USER_VALUE".into(), "kept".into()),
1414 ]);
1415 remove_derived_pnpm_config(&mut environment);
1416 assert_eq!(
1417 environment,
1418 BTreeMap::from([
1419 ("USER_VALUE".into(), "kept".into()),
1420 (
1421 "npm_config_registry".into(),
1422 "https://registry.npmjs.org".into()
1423 ),
1424 ])
1425 );
1426 }
1427}