1use std::{
7 collections::BTreeMap,
8 ffi::OsString,
9 fs,
10 path::{Path, PathBuf},
11 time::{Instant, SystemTime, UNIX_EPOCH},
12};
13
14use serde::{Deserialize, Serialize};
15
16use crate::{
17 evidence_archive::{EvidenceArchiveSource, collect_sources, write_archive},
18 integrity::{FrontendIntegrityInputs, create_run_integrity},
19 javascript_frontend::{javascript_runtime_files, prepare_javascript_frontend},
20 lifecycle::{
21 ProjectLock, RunState, RunStateStatus, finalize_published_run, interrupt_run_state,
22 publish_run, recover_abandoned_runs, remove_stored_tree_deferred, update_run_state,
23 write_run_state,
24 },
25 orchestration::{ExecutionPhase, ExecutionPlan, OrchestrationError, PhaseKind, execute_plan},
26 process_supervision::{
27 CommandSpec, ForwardedSignal, SupervisionOptions, positive_milliseconds,
28 },
29 project_discovery::{BuildAdapter, discover_coverage_project},
30 run_store::{RawEvidenceMetadata, RunIntegrity, RunMetadata, RunTimings},
31 workspace::{prepare_cached_workspace, prune_cached_workspace_sources},
32};
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct DirectJavascriptRunRequest {
37 pub root: PathBuf,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub runtime_root: Option<PathBuf>,
40 pub command: Vec<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub run_id: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub started_at: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase", deny_unknown_fields)]
49pub struct DirectJavascriptRunResult {
50 pub run_id: String,
51 pub run_directory: PathBuf,
52 pub workspace: PathBuf,
53 pub exit_code: i32,
54 pub assertion_calls: usize,
55 pub recovered_runs: Vec<String>,
56 pub metadata: RunMetadata,
57}
58
59#[derive(Debug)]
60pub enum DirectJavascriptRunError {
61 Interrupted {
62 signal: ForwardedSignal,
63 exit_code: i32,
64 timings: RunTimings,
65 total_ms: f64,
66 },
67 Failed(String),
68}
69
70impl std::fmt::Display for DirectJavascriptRunError {
71 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Self::Interrupted { signal, .. } => {
74 write!(formatter, "interrupted by {}", signal_name(*signal))
75 }
76 Self::Failed(message) => formatter.write_str(message),
77 }
78 }
79}
80
81impl std::error::Error for DirectJavascriptRunError {}
82
83impl From<String> for DirectJavascriptRunError {
84 fn from(value: String) -> Self {
85 Self::Failed(value)
86 }
87}
88
89fn signal_name(signal: ForwardedSignal) -> &'static str {
90 match signal {
91 ForwardedSignal::Sighup => "SIGHUP",
92 ForwardedSignal::Sigint => "SIGINT",
93 ForwardedSignal::Sigterm => "SIGTERM",
94 }
95}
96
97struct RunCleanup {
98 root: PathBuf,
99 run_id: String,
100 started_at: String,
101 lock: ProjectLock,
102 workspace: Option<PathBuf>,
103 state_written: bool,
104 terminal_recorded: bool,
105}
106
107impl RunCleanup {
108 fn lock(&self) -> &ProjectLock {
109 &self.lock
110 }
111
112 fn set_workspace(&mut self, workspace: PathBuf) {
113 self.workspace = Some(workspace);
114 }
115
116 fn mark_state_written(&mut self) {
117 self.state_written = true;
118 }
119
120 fn mark_terminal(&mut self) {
121 self.terminal_recorded = true;
122 }
123}
124
125impl Drop for RunCleanup {
126 fn drop(&mut self) {
127 if self.state_written && !self.terminal_recorded {
128 let _ = update_run_state(
129 &self.root,
130 &self.run_id,
131 RunStateStatus::Failed,
132 &self.started_at,
133 Some("Rust run exited before reaching a terminal lifecycle state".into()),
134 );
135 }
136 if let Some(workspace) = &self.workspace {
137 let _ = remove_stored_tree_deferred(
138 &self.root,
139 &workspace.join(".supercov/evidence").join(&self.run_id),
140 );
141 let _ = remove_stored_tree_deferred(
142 &self.root,
143 &workspace
144 .join(".supercov/server-evidence")
145 .join(&self.run_id),
146 );
147 let keep_workspace =
148 std::env::var("SUPERCOV_KEEP_WORKSPACE").is_ok_and(|value| !value.is_empty());
149 if !keep_workspace {
150 let _ = prune_cached_workspace_sources(&self.root, &self.lock);
151 }
152 }
153 let _ = self.lock.release();
154 }
155}
156
157fn now_nonce() -> u128 {
158 SystemTime::now()
159 .duration_since(UNIX_EPOCH)
160 .unwrap_or_default()
161 .as_millis()
162}
163
164fn elapsed_ms(started: Instant) -> f64 {
165 started.elapsed().as_secs_f64() * 1000.0
166}
167
168fn rounded_millisecond(value: f64) -> f64 {
169 (value * 10.0).round() / 10.0
170}
171
172fn supervision_options() -> Result<SupervisionOptions, String> {
173 let defaults = SupervisionOptions::default();
174 Ok(SupervisionOptions {
175 diagnostic_interval: positive_milliseconds(
176 std::env::var("SUPERCOV_DIAGNOSTIC_INTERVAL_MS")
177 .ok()
178 .as_deref(),
179 "SUPERCOV_DIAGNOSTIC_INTERVAL_MS",
180 )
181 .map_err(|error| error.to_string())?
182 .unwrap_or(defaults.diagnostic_interval),
183 timeout: positive_milliseconds(
184 std::env::var("SUPERCOV_COMMAND_TIMEOUT_MS").ok().as_deref(),
185 "SUPERCOV_COMMAND_TIMEOUT_MS",
186 )
187 .map_err(|error| error.to_string())?,
188 termination_grace: defaults.termination_grace,
189 })
190}
191
192fn environment_with(values: BTreeMap<String, String>) -> Vec<(OsString, OsString)> {
193 let mut environment = std::env::vars_os().collect::<BTreeMap<_, _>>();
194 for (key, value) in values {
195 environment.insert(key.into(), value.into());
196 }
197 environment.into_iter().collect()
198}
199
200fn node_options(preload: &Path) -> String {
201 [
202 std::env::var("NODE_OPTIONS").ok(),
203 Some("--enable-source-maps".into()),
204 Some(format!("--import={}", preload.display())),
205 ]
206 .into_iter()
207 .flatten()
208 .filter(|value| !value.is_empty())
209 .collect::<Vec<_>>()
210 .join(" ")
211}
212
213pub fn current_javascript_integrity(
217 root: &Path,
218 runtime_root: Option<&Path>,
219 command: &[String],
220) -> Result<RunIntegrity, String> {
221 let environment = std::env::vars().collect::<BTreeMap<_, _>>();
222 let project = discover_coverage_project(root, &environment, command)
223 .map_err(|error| error.to_string())?;
224 javascript_integrity_for_project(root, runtime_root, &project)
225}
226
227fn javascript_integrity_for_project(
228 root: &Path,
229 runtime_root: Option<&Path>,
230 project: &crate::project_discovery::CoverageProject,
231) -> Result<RunIntegrity, String> {
232 let frontend = runtime_root.map_or_else(FrontendIntegrityInputs::embedded_javascript, |root| {
233 FrontendIntegrityInputs::javascript(root.to_owned(), javascript_runtime_files(root))
234 });
235 create_run_integrity(root, project, &frontend).map_err(|error| error.to_string())
236}
237
238pub fn run_direct_javascript(
241 request: &DirectJavascriptRunRequest,
242 diagnostics: &mut dyn std::io::Write,
243) -> Result<DirectJavascriptRunResult, DirectJavascriptRunError> {
244 if request.command.is_empty() {
245 return Err(DirectJavascriptRunError::Failed(
246 "test command must not be empty".into(),
247 ));
248 }
249 let total_started = Instant::now();
250 let initialization_started = Instant::now();
251 let root = fs::canonicalize(&request.root)
252 .map_err(|error| format!("{}: {error}", request.root.display()))?;
253 let runtime_root = request
254 .runtime_root
255 .as_ref()
256 .map(|path| fs::canonicalize(path).map_err(|error| format!("{}: {error}", path.display())))
257 .transpose()?;
258 let nonce = now_nonce();
259 let run_id = request
260 .run_id
261 .clone()
262 .unwrap_or_else(|| format!("rust-{nonce}"));
263 let started_at = request
264 .started_at
265 .clone()
266 .unwrap_or_else(|| format!("unix-ms-{nonce}"));
267 let lock =
268 ProjectLock::acquire(&root, &run_id, &started_at).map_err(|error| error.to_string())?;
269 let mut cleanup = RunCleanup {
270 root: root.clone(),
271 run_id: run_id.clone(),
272 started_at: started_at.clone(),
273 lock,
274 workspace: None,
275 state_written: false,
276 terminal_recorded: false,
277 };
278 let recovered_runs =
279 recover_abandoned_runs(&root, &started_at).map_err(|error| error.to_string())?;
280 if !recovered_runs.is_empty() {
281 writeln!(
282 diagnostics,
283 "[supercov] recovered abandoned run(s): {}",
284 recovered_runs.join(", ")
285 )
286 .map_err(|error| error.to_string())?;
287 }
288 let environment = std::env::vars().collect::<BTreeMap<_, _>>();
289 let project = discover_coverage_project(&root, &environment, &request.command)
290 .map_err(|error| error.to_string())?;
291 let integrity = javascript_integrity_for_project(&root, runtime_root.as_deref(), &project)?;
292 let initialization_ms = elapsed_ms(initialization_started);
293
294 let workspace_started = Instant::now();
295 let workspace =
296 prepare_cached_workspace(&root, cleanup.lock(), &[]).map_err(|error| error.to_string())?;
297 cleanup.set_workspace(workspace.clone());
298 let workspace_preparation_ms = elapsed_ms(workspace_started);
299 writeln!(
300 diagnostics,
301 "[supercov] instrumenting isolated workspace {}",
302 workspace.display()
303 )
304 .map_err(|error| error.to_string())?;
305 cleanup.mark_state_written();
306 write_run_state(
307 &root,
308 &RunState {
309 id: run_id.clone(),
310 pid: std::process::id(),
311 root: root.display().to_string(),
312 workspace: workspace.display().to_string(),
313 started_at: started_at.clone(),
314 updated_at: started_at.clone(),
315 status: RunStateStatus::Preparing,
316 signal: None,
317 error: None,
318 },
319 )
320 .map_err(|error| error.to_string())?;
321
322 let adapter_started = Instant::now();
323 let collector_id = format!("collector-{}", integrity.fingerprint.execution);
324 let frontend =
325 prepare_javascript_frontend(&workspace, &project, runtime_root.as_deref(), &collector_id)
326 .map_err(|error| error.to_string())?;
327 let adapter_setup_ms = elapsed_ms(adapter_started);
328
329 let evidence_relative = format!(".supercov/evidence/{run_id}");
330 let evidence_directory = workspace.join(&evidence_relative);
331 let server_evidence_root = workspace.join(".supercov/server-evidence");
332 let diagnostic_owner = workspace.join(format!(".supercov/diagnostic-owner-{run_id}"));
333 let mut overrides = BTreeMap::from([
334 ("NODE_OPTIONS".into(), node_options(&frontend.preload_path)),
335 ("SUPERCOV_CJS_INTERCEPT".into(), "1".into()),
336 ("SUPERCOV_DIRECT_INSTRUMENTATION".into(), "1".into()),
337 ("SUPERCOV_EVIDENCE_DIR".into(), evidence_relative.clone()),
338 (
339 "SUPERCOV_DIAGNOSTIC_OWNER_FILE".into(),
340 diagnostic_owner.display().to_string(),
341 ),
342 (
343 "SUPERCOV_EXECUTION_FINGERPRINT".into(),
344 integrity.fingerprint.execution.clone(),
345 ),
346 (
347 "SUPERCOV_EXECUTION_LOG".into(),
348 evidence_directory
349 .join("execution.jsonl")
350 .display()
351 .to_string(),
352 ),
353 (
354 "SUPERCOV_MANIFEST".into(),
355 frontend.manifest_path.display().to_string(),
356 ),
357 (
358 "SUPERCOV_PROJECT_ROOT".into(),
359 workspace.display().to_string(),
360 ),
361 ("SUPERCOV_RUN_ID".into(), run_id.clone()),
362 (
363 "SUPERCOV_SERVER_EVIDENCE_ROOT".into(),
364 server_evidence_root.display().to_string(),
365 ),
366 (
367 "SUPERCOV_SOURCE_PROJECT_ROOT".into(),
368 root.display().to_string(),
369 ),
370 ]);
371 overrides.insert(
375 "SUPERCOV_GENERATED_VITEST_CONFIG".into(),
376 frontend.vitest_config_path.display().to_string(),
377 );
378 overrides.insert(
379 "SUPERCOV_GENERATED_PLAYWRIGHT_CONFIG".into(),
380 frontend.playwright_config_path.display().to_string(),
381 );
382 overrides.insert(
383 "SUPERCOV_PLAYWRIGHT_MODULE".into(),
384 project.playwright_module.clone(),
385 );
386 overrides.insert(
387 "SUPERCOV_PLAYWRIGHT_TEST_EXPORT".into(),
388 project.playwright_test_export.clone(),
389 );
390 overrides.insert(
391 "SUPERCOV_PLAYWRIGHT_WRAPPER".into(),
392 "./.supercov/playwright.js".into(),
393 );
394 if let Some(original) = project
395 .playwright_config
396 .as_ref()
397 .and_then(|path| path.strip_prefix(&root).ok())
398 .map(|path| workspace.join(path))
399 {
400 overrides.insert(
401 "SUPERCOV_ORIGINAL_PLAYWRIGHT_CONFIG".into(),
402 original.display().to_string(),
403 );
404 }
405 overrides.extend(project.build_environment.clone());
406 let preparation = if project.build_adapter != BuildAdapter::Direct {
407 let mut arguments = project.build_command[1..]
408 .iter()
409 .map(OsString::from)
410 .collect::<Vec<_>>();
411 if project.build_adapter == BuildAdapter::Vite {
412 arguments.extend([
413 OsString::from("--"),
414 OsString::from("--config"),
415 OsString::from(".supercov/vite.config.mjs"),
416 ]);
417 }
418 let mut build_overrides = overrides.clone();
419 build_overrides.insert("NODE_ENV".into(), "production".into());
420 let build_environment = environment_with(build_overrides);
421 vec![ExecutionPhase {
422 name: "build".into(),
423 kind: PhaseKind::Build,
424 command: CommandSpec {
425 program: project.build_command[0].clone().into(),
426 arguments,
427 cwd: workspace.clone(),
428 environment: Some(build_environment),
429 },
430 }]
431 } else {
432 Vec::new()
433 };
434 let plan = ExecutionPlan {
435 preparation,
436 test: ExecutionPhase {
437 name: "test".into(),
438 kind: PhaseKind::Test,
439 command: CommandSpec {
440 program: request.command[0].clone().into(),
441 arguments: request.command[1..].iter().map(OsString::from).collect(),
442 cwd: workspace.clone(),
443 environment: Some(environment_with(overrides)),
444 },
445 },
446 };
447 let options = supervision_options()?;
448 let execution = match execute_plan(&plan, options, diagnostics, |phase, diagnostics| {
449 let status = if phase.kind == PhaseKind::Test {
450 if frontend.assertion_calls > 0 {
451 writeln!(
452 diagnostics,
453 "[supercov] attributed {} native node:assert call(s)",
454 frontend.assertion_calls
455 )
456 .map_err(|error| OrchestrationError::PhaseSetup {
457 phase: phase.name.clone(),
458 reason: error.to_string(),
459 })?;
460 }
461 writeln!(
462 diagnostics,
463 "[supercov] running in isolated workspace: {}",
464 request.command.join(" ")
465 )
466 .map_err(|error| OrchestrationError::PhaseSetup {
467 phase: phase.name.clone(),
468 reason: error.to_string(),
469 })?;
470 RunStateStatus::Testing
471 } else {
472 RunStateStatus::Building
473 };
474 update_run_state(&root, &run_id, status, &started_at, None).map_err(|error| {
475 OrchestrationError::PhaseSetup {
476 phase: phase.name.clone(),
477 reason: error.to_string(),
478 }
479 })?;
480 Ok(())
481 }) {
482 Ok(execution) => execution,
483 Err(error) => {
484 let message = error.to_string();
485 let state_updated = update_run_state(
486 &root,
487 &run_id,
488 RunStateStatus::Failed,
489 &started_at,
490 Some(message.clone()),
491 )
492 .is_ok();
493 if state_updated {
494 cleanup.mark_terminal();
495 }
496 return Err(message.into());
497 }
498 };
499 let instrumented_build_ms = execution
500 .phases
501 .iter()
502 .find(|phase| phase.kind == PhaseKind::Build)
503 .map_or(0.0, |phase| phase.duration_ms as f64);
504 let test_command_ms = execution
505 .phases
506 .iter()
507 .find(|phase| phase.kind == PhaseKind::Test)
508 .map_or(0.0, |phase| phase.duration_ms as f64);
509 if let Some(signal) = execution.interrupted_signal {
510 interrupt_run_state(&root, &run_id, &started_at, signal_name(signal))
511 .map_err(|error| error.to_string())?;
512 cleanup.mark_terminal();
513 return Err(DirectJavascriptRunError::Interrupted {
514 signal,
515 exit_code: execution.exit_code,
516 timings: RunTimings {
517 initialization_ms: rounded_millisecond(initialization_ms),
518 workspace_preparation_ms: rounded_millisecond(workspace_preparation_ms),
519 adapter_setup_ms: rounded_millisecond(adapter_setup_ms),
520 instrumented_build_ms: rounded_millisecond(instrumented_build_ms),
521 test_command_ms: rounded_millisecond(test_command_ms),
522 evidence_publication_ms: 0.0,
523 },
524 total_ms: rounded_millisecond(elapsed_ms(total_started)),
525 });
526 }
527 update_run_state(
528 &root,
529 &run_id,
530 RunStateStatus::Publishing,
531 &started_at,
532 None,
533 )
534 .map_err(|error| error.to_string())?;
535
536 let publication_started = Instant::now();
537 let archive_path = root
538 .join(".supercov/work")
539 .join(&run_id)
540 .join("evidence.raw.gz");
541 let entries = collect_sources(&[
542 EvidenceArchiveSource::File {
543 file: frontend.manifest_path,
544 path: "manifest.json".into(),
545 },
546 EvidenceArchiveSource::Directory {
547 directory: evidence_directory,
548 prefix: None,
549 },
550 EvidenceArchiveSource::Directory {
551 directory: server_evidence_root.join(&run_id),
552 prefix: Some("server".into()),
553 },
554 ])
555 .map_err(|error| error.to_string())?;
556 let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
557 remove_stored_tree_deferred(&root, &workspace.join(".supercov/evidence"))
558 .map_err(|error| error.to_string())?;
559 remove_stored_tree_deferred(&root, &server_evidence_root).map_err(|error| error.to_string())?;
560 let evidence_publication_ms = elapsed_ms(publication_started);
561 let timings = RunTimings {
562 initialization_ms: rounded_millisecond(initialization_ms),
563 workspace_preparation_ms: rounded_millisecond(workspace_preparation_ms),
564 adapter_setup_ms: rounded_millisecond(adapter_setup_ms),
565 instrumented_build_ms: rounded_millisecond(instrumented_build_ms),
566 test_command_ms: rounded_millisecond(test_command_ms),
567 evidence_publication_ms: rounded_millisecond(evidence_publication_ms),
568 };
569 let metadata = RunMetadata {
570 id: run_id.clone(),
571 started_at: started_at.clone(),
572 duration_ms: rounded_millisecond(elapsed_ms(total_started)),
573 command: request.command.clone(),
574 test_exit_code: Some(execution.exit_code),
575 integrity,
576 raw_evidence: RawEvidenceMetadata {
577 schema_version: raw.schema_version,
578 format: raw.format.into(),
579 file: raw.file.into(),
580 files: raw.files,
581 uncompressed_bytes: raw.uncompressed_bytes,
582 compressed_bytes: raw.compressed_bytes,
583 },
584 isolated_build: Some(true),
585 instrumented_build_cache: None,
586 timings: Some(timings),
587 merged: None,
588 parents: None,
589 };
590 let run_directory =
591 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
592 let terminal_status = if execution.exit_code == 0 {
593 RunStateStatus::Complete
594 } else {
595 RunStateStatus::Failed
596 };
597 update_run_state(&root, &run_id, terminal_status, &started_at, None)
598 .map_err(|error| error.to_string())?;
599 finalize_published_run(&root, &run_id).map_err(|error| error.to_string())?;
600 cleanup.mark_terminal();
601 Ok(DirectJavascriptRunResult {
602 run_id,
603 run_directory,
604 workspace,
605 exit_code: execution.exit_code,
606 assertion_calls: frontend.assertion_calls,
607 recovered_runs,
608 metadata,
609 })
610}