Skip to main content

supercov_engine/
javascript_run.rs

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