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 phases: vec![],
258 runtime: vec![],
259 browser: vec![],
260 server: vec![],
261 };
262 entries.push(EvidenceArchiveEntry {
263 path: "results/command/mcdc.json".into(),
264 contents: serde_json::to_vec(&command_result).map_err(|error| error.to_string())?,
265 });
266 results.push(command_result);
267 }
268 let mut by_runner = BTreeMap::<String, Vec<&RawTestResult>>::new();
269 for result in &results {
270 by_runner
271 .entry(result.provenance.runner.clone())
272 .or_default()
273 .push(result);
274 }
275 if entries
276 .iter()
277 .any(|entry| entry.path.starts_with("server/background/") && entry.path.ends_with(".jsonl"))
278 {
279 by_runner.entry("background".into()).or_default();
280 }
281 let declaration = FrontendRunDeclaration {
282 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
283 frontend_id: "javascript".into(),
284 frontend_version: "javascript-owned-v1".into(),
285 language: "javascript".into(),
286 structural_source: StructuralSource::OwnedProbes,
287 runners: by_runner
288 .into_iter()
289 .map(|(runner, results)| javascript_runner_declaration(runner, &results))
290 .collect(),
291 structural_limitations: manifest
292 .limitations
293 .iter()
294 .map(|limitation| limitation.id.clone())
295 .collect(),
296 };
297 entries.push(EvidenceArchiveEntry {
298 path: "coverage-model.json".into(),
299 contents: serde_json::to_vec(
300 &PersistedCoverageModel::from_declaration(&javascript_coverage_model())
301 .expect("JavaScript coverage model is contract-valid"),
302 )
303 .map_err(|error| error.to_string())?,
304 });
305 entries.push(EvidenceArchiveEntry {
306 path: "frontend.json".into(),
307 contents: serde_json::to_vec(&declaration).map_err(|error| error.to_string())?,
308 });
309 Ok(entries)
310}
311
312struct RunCleanup {
313 root: PathBuf,
314 run_id: String,
315 started_at: String,
316 lock: ProjectLock,
317 workspace: Option<PathBuf>,
318 state_written: bool,
319 terminal_recorded: bool,
320}
321
322impl RunCleanup {
323 fn lock(&self) -> &ProjectLock {
324 &self.lock
325 }
326
327 fn set_workspace(&mut self, workspace: PathBuf) {
328 self.workspace = Some(workspace);
329 }
330
331 fn mark_state_written(&mut self) {
332 self.state_written = true;
333 }
334
335 fn mark_terminal(&mut self) {
336 self.terminal_recorded = true;
337 }
338}
339
340impl Drop for RunCleanup {
341 fn drop(&mut self) {
342 if self.state_written && !self.terminal_recorded {
343 let _ = update_run_state(
344 &self.root,
345 &self.run_id,
346 RunStateStatus::Failed,
347 &self.started_at,
348 Some("Rust run exited before reaching a terminal lifecycle state".into()),
349 );
350 }
351 if let Some(workspace) = &self.workspace {
352 let _ = remove_stored_tree_deferred(
353 &self.root,
354 &workspace.join(".supercov/evidence").join(&self.run_id),
355 );
356 let _ = remove_stored_tree_deferred(
357 &self.root,
358 &workspace
359 .join(".supercov/server-evidence")
360 .join(&self.run_id),
361 );
362 let keep_workspace =
363 std::env::var("SUPERCOV_KEEP_WORKSPACE").is_ok_and(|value| !value.is_empty());
364 if !keep_workspace {
365 let _ = prune_cached_workspace_sources(&self.root, &self.lock);
366 }
367 }
368 let _ = self.lock.release();
369 }
370}
371
372fn now_nonce() -> u128 {
373 SystemTime::now()
374 .duration_since(UNIX_EPOCH)
375 .unwrap_or_default()
376 .as_millis()
377}
378
379fn elapsed_ms(started: Instant) -> f64 {
380 started.elapsed().as_secs_f64() * 1000.0
381}
382
383fn rounded_millisecond(value: f64) -> f64 {
384 (value * 10.0).round() / 10.0
385}
386
387fn supervision_options() -> Result<SupervisionOptions, String> {
388 let defaults = SupervisionOptions::default();
389 Ok(SupervisionOptions {
390 diagnostic_interval: positive_milliseconds(
391 std::env::var("SUPERCOV_DIAGNOSTIC_INTERVAL_MS")
392 .ok()
393 .as_deref(),
394 "SUPERCOV_DIAGNOSTIC_INTERVAL_MS",
395 )
396 .map_err(|error| error.to_string())?
397 .unwrap_or(defaults.diagnostic_interval),
398 timeout: positive_milliseconds(
399 std::env::var("SUPERCOV_COMMAND_TIMEOUT_MS").ok().as_deref(),
400 "SUPERCOV_COMMAND_TIMEOUT_MS",
401 )
402 .map_err(|error| error.to_string())?,
403 termination_grace: defaults.termination_grace,
404 })
405}
406
407fn remove_derived_pnpm_config(environment: &mut BTreeMap<OsString, OsString>) {
408 const PNPM_ONLY_NPM_CONFIG: &[&str] = &[
414 "npm_config_auto_install_peers",
415 "npm_config_enable_pre_post_scripts",
416 "npm_config_shamefully_hoist",
417 ];
418 environment.retain(|key, _| {
419 let key = key.to_string_lossy().to_ascii_lowercase();
420 !PNPM_ONLY_NPM_CONFIG.contains(&key.as_str())
421 });
422}
423
424fn environment_with(values: BTreeMap<String, String>) -> Vec<(OsString, OsString)> {
425 let mut environment = std::env::vars_os().collect::<BTreeMap<_, _>>();
426 remove_derived_pnpm_config(&mut environment);
427 for (key, value) in values {
428 environment.retain(|existing, _| !existing.to_string_lossy().eq_ignore_ascii_case(&key));
429 environment.insert(key.into(), value.into());
430 }
431 environment.into_iter().collect()
432}
433
434fn node_options(preload: &Path) -> String {
435 let preload = std::path::absolute(preload).unwrap_or_else(|_| preload.to_path_buf());
441 [
447 std::env::var("NODE_OPTIONS").ok(),
448 Some("--enable-source-maps".into()),
449 Some(format!("--import={}", crate::workspace::file_url(&preload))),
450 ]
451 .into_iter()
452 .flatten()
453 .filter(|value| !value.is_empty())
454 .collect::<Vec<_>>()
455 .join(" ")
456}
457
458pub fn current_javascript_integrity(
462 root: &Path,
463 command: &[String],
464) -> Result<RunIntegrity, String> {
465 let environment = std::env::vars().collect::<BTreeMap<_, _>>();
466 let project = discover_coverage_project(root, &environment, command)
467 .map_err(|error| error.to_string())?;
468 javascript_integrity_for_project(root, &project)
469}
470
471fn javascript_integrity_for_project(
472 root: &Path,
473 project: &crate::project_discovery::CoverageProject,
474) -> Result<RunIntegrity, String> {
475 let frontend = FrontendIntegrityInputs::embedded_javascript();
476 create_run_integrity(root, project, &frontend).map_err(|error| error.to_string())
477}
478
479pub fn run_direct_javascript(
482 request: &DirectJavascriptRunRequest,
483 diagnostics: &mut dyn std::io::Write,
484) -> Result<DirectJavascriptRunResult, DirectJavascriptRunError> {
485 if request.command.is_empty() {
486 return Err(DirectJavascriptRunError::Failed(
487 "test command must not be empty".into(),
488 ));
489 }
490 let total_started = Instant::now();
491 let initialization_started = Instant::now();
492 let root = crate::workspace::canonicalize_simplified(&request.root)
493 .map_err(|error| format!("{}: {error}", request.root.display()))?;
494 let nonce = now_nonce();
495 let run_id = request
496 .run_id
497 .clone()
498 .unwrap_or_else(|| format!("rust-{nonce}"));
499 let started_at = request
500 .started_at
501 .clone()
502 .unwrap_or_else(|| format!("unix-ms-{nonce}"));
503 let lock =
504 ProjectLock::acquire(&root, &run_id, &started_at).map_err(|error| error.to_string())?;
505 let mut cleanup = RunCleanup {
506 root: root.clone(),
507 run_id: run_id.clone(),
508 started_at: started_at.clone(),
509 lock,
510 workspace: None,
511 state_written: false,
512 terminal_recorded: false,
513 };
514 let recovered_runs =
515 recover_abandoned_runs(&root, &started_at).map_err(|error| error.to_string())?;
516 if !recovered_runs.is_empty() {
517 writeln!(
518 diagnostics,
519 "[supercov] recovered abandoned run(s): {}",
520 recovered_runs.join(", ")
521 )
522 .map_err(|error| error.to_string())?;
523 }
524 let environment = std::env::vars().collect::<BTreeMap<_, _>>();
525 let project = discover_coverage_project(&root, &environment, &request.command)
526 .map_err(|error| error.to_string())?;
527 let integrity = javascript_integrity_for_project(&root, &project)?;
528 let assertion_inputs = crate::assertion_inputs::capture_with_expect_modules(
529 &root,
530 "javascript",
531 crate::integrity::javascript_assertion_paths(&root, &project).map_err(|e| e.to_string())?,
532 std::slice::from_ref(&project.playwright_module),
533 )?;
534 let build_cache_key = build_cache_key(&integrity, &project)?;
535 let frontend_cache_key = format!(
536 "{}:{}",
537 integrity.fingerprint.combined, integrity.fingerprint.execution
538 );
539 let prior_workspace = cached_workspace_path(&root).map_err(|error| error.to_string())?;
540 let reusable_build = if project.build_adapter == BuildAdapter::Direct {
541 None
542 } else {
543 read_build_cache(&prior_workspace, &build_cache_key)
544 };
545 let reusable_frontend = read_javascript_frontend_cache(&prior_workspace, &frontend_cache_key);
546 let mut cached_paths = reusable_build.as_ref().map(reuse_paths).unwrap_or_default();
547 if let Some(frontend) = &reusable_frontend {
548 cached_paths.extend(javascript_frontend_reuse_paths(frontend));
549 cached_paths.sort();
550 cached_paths.dedup();
551 }
552 let initialization_ms = elapsed_ms(initialization_started);
553
554 let workspace_started = Instant::now();
555 let workspace_progress = crate::progress::ProgressLine::start("preparing isolated workspace");
556 let workspace = prepare_cached_workspace(&root, cleanup.lock(), &cached_paths)
557 .map_err(|error| error.to_string())?;
558 drop(workspace_progress);
559 cleanup.set_workspace(workspace.clone());
560 let workspace_preparation_ms = elapsed_ms(workspace_started);
561 writeln!(
562 diagnostics,
563 "[supercov] instrumenting isolated workspace {}",
564 workspace.display()
565 )
566 .map_err(|error| error.to_string())?;
567 cleanup.mark_state_written();
568 write_run_state(
569 &root,
570 &RunState {
571 id: run_id.clone(),
572 pid: std::process::id(),
573 root: root.display().to_string(),
574 workspace: workspace.display().to_string(),
575 started_at: started_at.clone(),
576 updated_at: started_at.clone(),
577 status: RunStateStatus::Preparing,
578 signal: None,
579 error: None,
580 },
581 )
582 .map_err(|error| error.to_string())?;
583
584 let adapter_started = Instant::now();
585 let instrumentation_progress = crate::progress::ProgressLine::start("instrumenting sources");
586 let collector_id = format!("collector-{}", integrity.fingerprint.execution);
587 let frontend = if let Some(cache) = &reusable_frontend {
588 load_cached_javascript_frontend(&workspace, cache)
589 } else {
590 prepare_javascript_frontend(&workspace, &project, &collector_id, &frontend_cache_key)
591 }
592 .map_err(|error| error.to_string())?;
593 drop(instrumentation_progress);
594 let adapter_setup_ms = elapsed_ms(adapter_started);
595 if let Some(detail) = crate::javascript_frontend::setup_timing_detail() {
596 writeln!(diagnostics, "[supercov] {detail}").map_err(|error| error.to_string())?;
597 }
598
599 let evidence_relative = format!(".supercov/evidence/{run_id}");
600 let evidence_directory = workspace.join(&evidence_relative);
601 let server_evidence_root = workspace.join(".supercov/server-evidence");
602 let diagnostic_owner = workspace.join(format!(".supercov/diagnostic-owner-{run_id}"));
603 let mut overrides = BTreeMap::from([
604 ("NODE_OPTIONS".into(), node_options(&frontend.preload_path)),
605 ("SUPERCOV_CJS_INTERCEPT".into(), "1".into()),
606 ("SUPERCOV_DIRECT_INSTRUMENTATION".into(), "1".into()),
607 (
611 "SUPERCOV_EVIDENCE_DIR".into(),
612 evidence_directory.display().to_string(),
613 ),
614 (
615 "SUPERCOV_DIAGNOSTIC_OWNER_FILE".into(),
616 diagnostic_owner.display().to_string(),
617 ),
618 (
619 "SUPERCOV_EXECUTION_FINGERPRINT".into(),
620 integrity.fingerprint.execution.clone(),
621 ),
622 (
623 "SUPERCOV_EXECUTION_LOG".into(),
624 evidence_directory
625 .join("execution.jsonl")
626 .display()
627 .to_string(),
628 ),
629 (
630 "SUPERCOV_MANIFEST".into(),
631 frontend.manifest_path.display().to_string(),
632 ),
633 (
634 "SUPERCOV_PROJECT_ROOT".into(),
635 workspace.display().to_string(),
636 ),
637 ("SUPERCOV_RUN_ID".into(), run_id.clone()),
638 (
639 "SUPERCOV_SERVER_EVIDENCE_ROOT".into(),
640 server_evidence_root.display().to_string(),
641 ),
642 (
643 "SUPERCOV_SOURCE_PROJECT_ROOT".into(),
644 root.display().to_string(),
645 ),
646 ]);
647 overrides.insert(
651 "SUPERCOV_GENERATED_VITEST_CONFIG".into(),
652 frontend.vitest_config_path.display().to_string(),
653 );
654 overrides.insert(
655 "SUPERCOV_GENERATED_PLAYWRIGHT_CONFIG".into(),
656 frontend.playwright_config_path.display().to_string(),
657 );
658 overrides.insert(
659 "SUPERCOV_PLAYWRIGHT_MODULE".into(),
660 project.playwright_module.clone(),
661 );
662 overrides.insert(
663 "SUPERCOV_PLAYWRIGHT_TEST_EXPORT".into(),
664 project.playwright_test_export.clone(),
665 );
666 overrides.insert(
667 "SUPERCOV_PLAYWRIGHT_WRAPPER".into(),
668 "./.supercov/node_modules/playwright.mjs".into(),
669 );
670 if let Some(original) = project
671 .playwright_config
672 .as_ref()
673 .and_then(|path| path.strip_prefix(&root).ok())
674 .map(|path| workspace.join(path))
675 {
676 overrides.insert(
677 "SUPERCOV_ORIGINAL_PLAYWRIGHT_CONFIG".into(),
678 original.display().to_string(),
679 );
680 }
681 overrides.extend(project.build_environment.clone());
682 let preparation = if reusable_build.is_some() {
683 writeln!(
684 diagnostics,
685 "[supercov] reusing exact-fingerprint instrumented build {}",
686 &build_cache_key[..12]
687 )
688 .map_err(|error| error.to_string())?;
689 Vec::new()
690 } else if project.build_adapter != BuildAdapter::Direct {
691 let mut arguments = project.build_command[1..]
692 .iter()
693 .map(OsString::from)
694 .collect::<Vec<_>>();
695 if project.build_adapter == BuildAdapter::Vite {
696 arguments.extend([
697 OsString::from("--"),
698 OsString::from("--config"),
699 OsString::from(".supercov/vite.config.mjs"),
700 OsString::from("--logLevel"),
701 OsString::from("error"),
702 ]);
703 }
704 let mut build_overrides = overrides.clone();
705 build_overrides.insert("NODE_ENV".into(), "production".into());
706 build_overrides.insert("npm_config_loglevel".into(), "error".into());
707 let build_environment = environment_with(build_overrides);
708 vec![ExecutionPhase {
709 name: "build".into(),
710 kind: PhaseKind::Build,
711 command: CommandSpec {
712 program: project.build_command[0].clone().into(),
713 arguments,
714 cwd: workspace.clone(),
715 environment: Some(build_environment),
716 captured_output: Some(
717 workspace
718 .join(".supercov")
719 .join(format!("build-output-{run_id}.log")),
720 ),
721 },
722 }]
723 } else {
724 Vec::new()
725 };
726 let plan = ExecutionPlan {
727 preparation,
728 test: ExecutionPhase {
729 name: "test".into(),
730 kind: PhaseKind::Test,
731 command: CommandSpec {
732 program: request.command[0].clone().into(),
733 arguments: request.command[1..].iter().map(OsString::from).collect(),
734 cwd: workspace.clone(),
735 environment: Some(environment_with(overrides)),
736 captured_output: None,
737 },
738 },
739 };
740 let options = supervision_options()?;
741 let watchdog_program = request.watchdog_program.as_ref().ok_or_else(|| {
742 DirectJavascriptRunError::Failed(
743 "the JavaScript run is missing its crash-containment executable".into(),
744 )
745 })?;
746 let supervisor = ProcessSupervisor::new_crash_safe(watchdog_program)
747 .map_err(|error| DirectJavascriptRunError::Failed(error.to_string()))?;
748 let output_baseline = std::cell::RefCell::new(None);
749 let execution = match execute_plan_with_supervisor(
750 &supervisor,
751 &plan,
752 options,
753 diagnostics,
754 |phase, diagnostics| {
755 let status = if phase.kind == PhaseKind::Test {
756 *output_baseline.borrow_mut() =
760 Some(workspace_output_baseline(&workspace).map_err(|error| {
761 OrchestrationError::PhaseSetup {
762 phase: phase.name.clone(),
763 reason: error.to_string(),
764 }
765 })?);
766 if frontend.assertion_calls > 0 {
767 writeln!(
768 diagnostics,
769 "[supercov] attributed {} native node:assert call(s)",
770 frontend.assertion_calls
771 )
772 .map_err(|error| OrchestrationError::PhaseSetup {
773 phase: phase.name.clone(),
774 reason: error.to_string(),
775 })?;
776 }
777 writeln!(
778 diagnostics,
779 "[supercov] running in isolated workspace: {}",
780 request.command.join(" ")
781 )
782 .map_err(|error| OrchestrationError::PhaseSetup {
783 phase: phase.name.clone(),
784 reason: error.to_string(),
785 })?;
786 RunStateStatus::Testing
787 } else {
788 RunStateStatus::Building
789 };
790 update_run_state(&root, &run_id, status, &started_at, None).map_err(|error| {
791 OrchestrationError::PhaseSetup {
792 phase: phase.name.clone(),
793 reason: error.to_string(),
794 }
795 })?;
796 Ok(())
797 },
798 ) {
799 Ok(execution) => execution,
800 Err(error) => {
801 let message = error.to_string();
802 let state_updated = update_run_state(
803 &root,
804 &run_id,
805 RunStateStatus::Failed,
806 &started_at,
807 Some(message.clone()),
808 )
809 .is_ok();
810 if state_updated {
811 cleanup.mark_terminal();
812 }
813 return Err(message.into());
814 }
815 };
816 let instrumented_build_ms = execution
817 .phases
818 .iter()
819 .find(|phase| phase.kind == PhaseKind::Build)
820 .map_or(0.0, |phase| phase.duration_ms as f64);
821 let test_command_ms = execution
822 .phases
823 .iter()
824 .find(|phase| phase.kind == PhaseKind::Test)
825 .map_or(0.0, |phase| phase.duration_ms as f64);
826 let build_succeeded = execution
827 .phases
828 .iter()
829 .find(|phase| phase.kind == PhaseKind::Build)
830 .is_some_and(|phase| phase.result.exit_code() == 0);
831 if project.build_adapter != BuildAdapter::Direct && reusable_build.is_none() && build_succeeded
832 {
833 write_build_cache(&root, &workspace, &build_cache_key, &started_at)?;
834 }
835 if let Some(signal) = execution.interrupted_signal {
836 interrupt_run_state(&root, &run_id, &started_at, signal_name(signal))
837 .map_err(|error| error.to_string())?;
838 cleanup.mark_terminal();
839 return Err(DirectJavascriptRunError::Interrupted {
840 signal,
841 exit_code: execution.exit_code,
842 timings: RunTimings {
843 initialization_ms: rounded_millisecond(initialization_ms),
844 workspace_preparation_ms: rounded_millisecond(workspace_preparation_ms),
845 adapter_setup_ms: rounded_millisecond(adapter_setup_ms),
846 instrumented_build_ms: rounded_millisecond(instrumented_build_ms),
847 test_command_ms: rounded_millisecond(test_command_ms),
848 evidence_publication_ms: 0.0,
849 },
850 total_ms: rounded_millisecond(elapsed_ms(total_started)),
851 });
852 }
853 if let Some(baseline) = output_baseline.borrow().as_ref() {
858 let protected = project
859 .source_files
860 .iter()
861 .map(PathBuf::from)
862 .collect::<std::collections::BTreeSet<_>>();
863 let outputs = sync_command_outputs(&root, &workspace, baseline, &protected)
864 .map_err(|error| error.to_string())?;
865 if outputs.synced > 0 {
866 let _ = writeln!(
867 diagnostics,
868 "[supercov] synced {} file(s) the command created or changed back to the project",
869 outputs.synced
870 );
871 }
872 if !outputs.skipped_instrumented.is_empty() {
873 let _ = writeln!(
874 diagnostics,
875 "[supercov] {} file(s) stayed in the isolated workspace: they are instrumented copies, or were built from them, and must not overwrite your project: {}",
876 outputs.skipped_instrumented.len(),
877 outputs
878 .skipped_instrumented
879 .iter()
880 .take(3)
881 .map(|path| path.display().to_string())
882 .collect::<Vec<_>>()
883 .join(", ")
884 );
885 }
886 if !outputs.deleted_in_workspace.is_empty() {
887 let _ = writeln!(
888 diagnostics,
889 "[supercov] the command deleted {} file(s) in the isolated workspace; deletions are not propagated to the project",
890 outputs.deleted_in_workspace.len()
891 );
892 }
893 }
894 update_run_state(
895 &root,
896 &run_id,
897 RunStateStatus::Publishing,
898 &started_at,
899 None,
900 )
901 .map_err(|error| error.to_string())?;
902
903 let publication_started = Instant::now();
904 let archive_path = root
905 .join(".supercov/work")
906 .join(&run_id)
907 .join("evidence.raw.gz");
908 let entries = collect_sources(&[
909 EvidenceArchiveSource::File {
910 file: frontend.manifest_path,
911 path: "manifest.json".into(),
912 },
913 EvidenceArchiveSource::Directory {
914 directory: evidence_directory,
915 prefix: None,
916 },
917 EvidenceArchiveSource::Directory {
918 directory: server_evidence_root.join(&run_id),
919 prefix: Some("server".into()),
920 },
921 ])
922 .map_err(|error| error.to_string())?;
923 let entries =
924 javascript_archive_entries(entries, &frontend.manifest, &run_id, execution.exit_code)?;
925 let entries = crate::assertion_inputs::append(entries, &assertion_inputs)?;
926 let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
927 remove_stored_tree_deferred(&root, &workspace.join(".supercov/evidence"))
928 .map_err(|error| error.to_string())?;
929 remove_stored_tree_deferred(&root, &server_evidence_root).map_err(|error| error.to_string())?;
930 let evidence_publication_ms = elapsed_ms(publication_started);
931 let timings = RunTimings {
932 initialization_ms: rounded_millisecond(initialization_ms),
933 workspace_preparation_ms: rounded_millisecond(workspace_preparation_ms),
934 adapter_setup_ms: rounded_millisecond(adapter_setup_ms),
935 instrumented_build_ms: rounded_millisecond(instrumented_build_ms),
936 test_command_ms: rounded_millisecond(test_command_ms),
937 evidence_publication_ms: rounded_millisecond(evidence_publication_ms),
938 };
939 let metadata = RunMetadata {
940 id: run_id.clone(),
941 started_at: started_at.clone(),
942 duration_ms: rounded_millisecond(elapsed_ms(total_started)),
943 command: request.command.clone(),
944 test_exit_code: Some(execution.exit_code),
945 integrity,
946 raw_evidence: RawEvidenceMetadata {
947 schema_version: raw.schema_version,
948 format: raw.format.into(),
949 file: raw.file.into(),
950 files: raw.files,
951 uncompressed_bytes: raw.uncompressed_bytes,
952 compressed_bytes: raw.compressed_bytes,
953 },
954 isolated_build: Some(true),
955 instrumented_build_cache: Some(InstrumentedBuildCache {
956 key: build_cache_key,
957 reused: reusable_build.is_some(),
958 }),
959 timings: Some(timings),
960 merged: None,
961 parents: None,
962 };
963 let run_directory =
964 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
965 let terminal_status = if execution.exit_code == 0 {
966 RunStateStatus::Complete
967 } else {
968 RunStateStatus::Failed
969 };
970 update_run_state(&root, &run_id, terminal_status, &started_at, None)
971 .map_err(|error| error.to_string())?;
972 finalize_published_run(&root, &run_id).map_err(|error| error.to_string())?;
973 cleanup.mark_terminal();
974 Ok(DirectJavascriptRunResult {
975 run_id,
976 run_directory,
977 workspace,
978 exit_code: execution.exit_code,
979 assertion_calls: frontend.assertion_calls,
980 recovered_runs,
981 metadata,
982 })
983}
984
985#[cfg(test)]
986mod environment_tests {
987 use super::*;
988
989 #[test]
990 fn nested_npm_drops_only_pnpm_derived_environment_aliases() {
991 let mut environment = BTreeMap::from([
992 ("npm_config_auto_install_peers".into(), "true".into()),
993 ("NPM_CONFIG_SHAMEFULLY_HOIST".into(), "true".into()),
994 (
995 "npm_config_registry".into(),
996 "https://registry.npmjs.org".into(),
997 ),
998 ("USER_VALUE".into(), "kept".into()),
999 ]);
1000 remove_derived_pnpm_config(&mut environment);
1001 assert_eq!(
1002 environment,
1003 BTreeMap::from([
1004 ("USER_VALUE".into(), "kept".into()),
1005 (
1006 "npm_config_registry".into(),
1007 "https://registry.npmjs.org".into()
1008 ),
1009 ])
1010 );
1011 }
1012}