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            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    // `npx supercov` is itself launched by npm, which exports project `.npmrc`
409    // keys as `npm_config_*`. pnpm-only keys then make every nested npm process
410    // print a second set of "Unknown env config" warnings. They have no npm
411    // semantics (npm 11 reports them as unknown), so do not leak those derived
412    // environment aliases into the user's already-configured test command.
413    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    // The register import must be ABSOLUTE: node resolves a relative
436    // `--import` against each child process's OWN working directory, and
437    // monorepo runners spawn tasks inside package directories. turbo running
438    // `packages/react#build` resolved `.supercov/register.mjs` against
439    // `packages/react/` and aborted every task with ERR_MODULE_NOT_FOUND.
440    let preload = std::path::absolute(preload).unwrap_or_else(|_| preload.to_path_buf());
441    // And it must be a URL, not a path: Node reads a bare `C:\...` as a URL
442    // with scheme `c:` and refuses it, so on Windows the first Node child died
443    // at startup and the suite reported only that its exit code was 1. The
444    // JavaScript runtime already passes this import as a file URL; so does
445    // this.
446    [
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
458/// Fingerprint the current JavaScript project using the same discovery and
459/// runtime-shim inputs as a Rust-owned execution. Query callers deliberately
460/// treat failure as "staleness unavailable", matching the frozen CLI contract.
461pub 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
479/// Execute one JavaScript suite with every language-neutral stage owned by
480/// Rust. Target-language runtime and runner adapters remain generated shims.
481pub 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,
538        integrity.fingerprint.execution,
539        crate::assertion_map::digest(&request.command)
540    );
541    let prior_workspace = cached_workspace_path(&root).map_err(|error| error.to_string())?;
542    let reusable_build = if project.build_adapter == BuildAdapter::Direct {
543        None
544    } else {
545        read_build_cache(&prior_workspace, &build_cache_key)
546    };
547    let reusable_frontend = read_javascript_frontend_cache(&prior_workspace, &frontend_cache_key);
548    let mut cached_paths = reusable_build.as_ref().map(reuse_paths).unwrap_or_default();
549    if let Some(frontend) = &reusable_frontend {
550        cached_paths.extend(javascript_frontend_reuse_paths(frontend));
551        cached_paths.sort();
552        cached_paths.dedup();
553    }
554    let initialization_ms = elapsed_ms(initialization_started);
555
556    let workspace_started = Instant::now();
557    let workspace_progress = crate::progress::ProgressLine::start("preparing isolated workspace");
558    let workspace = prepare_cached_workspace(&root, cleanup.lock(), &cached_paths)
559        .map_err(|error| error.to_string())?;
560    drop(workspace_progress);
561    cleanup.set_workspace(workspace.clone());
562    let workspace_preparation_ms = elapsed_ms(workspace_started);
563    writeln!(
564        diagnostics,
565        "[supercov] instrumenting isolated workspace {}",
566        workspace.display()
567    )
568    .map_err(|error| error.to_string())?;
569    cleanup.mark_state_written();
570    write_run_state(
571        &root,
572        &RunState {
573            id: run_id.clone(),
574            pid: std::process::id(),
575            root: root.display().to_string(),
576            workspace: workspace.display().to_string(),
577            started_at: started_at.clone(),
578            updated_at: started_at.clone(),
579            status: RunStateStatus::Preparing,
580            signal: None,
581            error: None,
582        },
583    )
584    .map_err(|error| error.to_string())?;
585
586    let adapter_started = Instant::now();
587    let instrumentation_progress = crate::progress::ProgressLine::start("instrumenting sources");
588    let collector_id = format!("collector-{}", integrity.fingerprint.execution);
589    let frontend = if let Some(cache) = &reusable_frontend {
590        load_cached_javascript_frontend(&workspace, cache)
591    } else {
592        prepare_javascript_frontend(
593            &workspace,
594            &project,
595            &collector_id,
596            &frontend_cache_key,
597            &request.command,
598        )
599    }
600    .map_err(|error| error.to_string())?;
601    drop(instrumentation_progress);
602    let adapter_setup_ms = elapsed_ms(adapter_started);
603    if let Some(detail) = crate::javascript_frontend::setup_timing_detail() {
604        writeln!(diagnostics, "[supercov] {detail}").map_err(|error| error.to_string())?;
605    }
606
607    let evidence_relative = format!(".supercov/evidence/{run_id}");
608    let evidence_directory = workspace.join(&evidence_relative);
609    let server_evidence_root = workspace.join(".supercov/server-evidence");
610    let diagnostic_owner = workspace.join(format!(".supercov/diagnostic-owner-{run_id}"));
611    let mut overrides = BTreeMap::from([
612        ("NODE_OPTIONS".into(), node_options(&frontend.preload_path)),
613        ("SUPERCOV_CJS_INTERCEPT".into(), "1".into()),
614        ("SUPERCOV_DIRECT_INSTRUMENTATION".into(), "1".into()),
615        // Must be absolute: monorepo runners spawn test processes with a
616        // package directory as cwd, and a relative evidence directory made
617        // every per-test record land beside the package, never collected.
618        (
619            "SUPERCOV_EVIDENCE_DIR".into(),
620            evidence_directory.display().to_string(),
621        ),
622        (
623            "SUPERCOV_DIAGNOSTIC_OWNER_FILE".into(),
624            diagnostic_owner.display().to_string(),
625        ),
626        (
627            "SUPERCOV_EXECUTION_FINGERPRINT".into(),
628            integrity.fingerprint.execution.clone(),
629        ),
630        (
631            "SUPERCOV_EXECUTION_LOG".into(),
632            evidence_directory
633                .join("execution.jsonl")
634                .display()
635                .to_string(),
636        ),
637        (
638            "SUPERCOV_MANIFEST".into(),
639            frontend.manifest_path.display().to_string(),
640        ),
641        (
642            "SUPERCOV_PROJECT_ROOT".into(),
643            workspace.display().to_string(),
644        ),
645        ("SUPERCOV_RUN_ID".into(), run_id.clone()),
646        (
647            "SUPERCOV_SERVER_EVIDENCE_ROOT".into(),
648            server_evidence_root.display().to_string(),
649        ),
650        (
651            "SUPERCOV_SOURCE_PROJECT_ROOT".into(),
652            root.display().to_string(),
653        ),
654    ]);
655    // A wrapped npm/pnpm/yarn script can launch either runner several process
656    // generations later. The preload is the discovery boundary, so always
657    // provide both generated configs; each runner ignores the unrelated one.
658    overrides.insert(
659        "SUPERCOV_GENERATED_VITEST_CONFIG".into(),
660        frontend.vitest_config_path.display().to_string(),
661    );
662    overrides.insert(
663        "SUPERCOV_GENERATED_PLAYWRIGHT_CONFIG".into(),
664        frontend.playwright_config_path.display().to_string(),
665    );
666    overrides.insert(
667        "SUPERCOV_PLAYWRIGHT_MODULE".into(),
668        project.playwright_module.clone(),
669    );
670    overrides.insert(
671        "SUPERCOV_PLAYWRIGHT_TEST_EXPORT".into(),
672        project.playwright_test_export.clone(),
673    );
674    overrides.insert(
675        "SUPERCOV_PLAYWRIGHT_WRAPPER".into(),
676        "./.supercov/node_modules/playwright.mjs".into(),
677    );
678    if let Some(original) = project
679        .playwright_config
680        .as_ref()
681        .and_then(|path| path.strip_prefix(&root).ok())
682        .map(|path| workspace.join(path))
683    {
684        overrides.insert(
685            "SUPERCOV_ORIGINAL_PLAYWRIGHT_CONFIG".into(),
686            original.display().to_string(),
687        );
688    }
689    overrides.extend(project.build_environment.clone());
690    let preparation = if reusable_build.is_some() {
691        writeln!(
692            diagnostics,
693            "[supercov] reusing exact-fingerprint instrumented build {}",
694            &build_cache_key[..12]
695        )
696        .map_err(|error| error.to_string())?;
697        Vec::new()
698    } else if project.build_adapter != BuildAdapter::Direct {
699        let mut arguments = project.build_command[1..]
700            .iter()
701            .map(OsString::from)
702            .collect::<Vec<_>>();
703        if project.build_adapter == BuildAdapter::Vite {
704            arguments.extend([
705                OsString::from("--"),
706                OsString::from("--config"),
707                OsString::from(".supercov/vite.config.mjs"),
708                OsString::from("--logLevel"),
709                OsString::from("error"),
710            ]);
711        }
712        let mut build_overrides = overrides.clone();
713        build_overrides.insert("NODE_ENV".into(), "production".into());
714        build_overrides.insert("npm_config_loglevel".into(), "error".into());
715        let build_environment = environment_with(build_overrides);
716        vec![ExecutionPhase {
717            name: "build".into(),
718            kind: PhaseKind::Build,
719            command: CommandSpec {
720                program: project.build_command[0].clone().into(),
721                arguments,
722                cwd: workspace.clone(),
723                environment: Some(build_environment),
724                captured_output: Some(
725                    workspace
726                        .join(".supercov")
727                        .join(format!("build-output-{run_id}.log")),
728                ),
729            },
730        }]
731    } else {
732        Vec::new()
733    };
734    let plan = ExecutionPlan {
735        preparation,
736        test: ExecutionPhase {
737            name: "test".into(),
738            kind: PhaseKind::Test,
739            command: CommandSpec {
740                program: request.command[0].clone().into(),
741                arguments: request.command[1..].iter().map(OsString::from).collect(),
742                cwd: workspace.clone(),
743                environment: Some(environment_with(overrides)),
744                captured_output: None,
745            },
746        },
747    };
748    let options = supervision_options()?;
749    let watchdog_program = request.watchdog_program.as_ref().ok_or_else(|| {
750        DirectJavascriptRunError::Failed(
751            "the JavaScript run is missing its crash-containment executable".into(),
752        )
753    })?;
754    let supervisor = ProcessSupervisor::new_crash_safe(watchdog_program)
755        .map_err(|error| DirectJavascriptRunError::Failed(error.to_string()))?;
756    let output_baseline = std::cell::RefCell::new(None);
757    let execution = match execute_plan_with_supervisor(
758        &supervisor,
759        &plan,
760        options,
761        diagnostics,
762        |phase, diagnostics| {
763            let status = if phase.kind == PhaseKind::Test {
764                // The snapshot boundary sits after Supercov's own build phase
765                // and before the user's command, so only the command's own
766                // effects flow back to the real project afterwards.
767                *output_baseline.borrow_mut() =
768                    Some(workspace_output_baseline(&workspace).map_err(|error| {
769                        OrchestrationError::PhaseSetup {
770                            phase: phase.name.clone(),
771                            reason: error.to_string(),
772                        }
773                    })?);
774                if frontend.assertion_calls > 0 {
775                    writeln!(
776                        diagnostics,
777                        "[supercov] attributed {} native node:assert call(s)",
778                        frontend.assertion_calls
779                    )
780                    .map_err(|error| OrchestrationError::PhaseSetup {
781                        phase: phase.name.clone(),
782                        reason: error.to_string(),
783                    })?;
784                }
785                writeln!(
786                    diagnostics,
787                    "[supercov] running in isolated workspace: {}",
788                    request.command.join(" ")
789                )
790                .map_err(|error| OrchestrationError::PhaseSetup {
791                    phase: phase.name.clone(),
792                    reason: error.to_string(),
793                })?;
794                RunStateStatus::Testing
795            } else {
796                RunStateStatus::Building
797            };
798            update_run_state(&root, &run_id, status, &started_at, None).map_err(|error| {
799                OrchestrationError::PhaseSetup {
800                    phase: phase.name.clone(),
801                    reason: error.to_string(),
802                }
803            })?;
804            Ok(())
805        },
806    ) {
807        Ok(execution) => execution,
808        Err(error) => {
809            let message = error.to_string();
810            let state_updated = update_run_state(
811                &root,
812                &run_id,
813                RunStateStatus::Failed,
814                &started_at,
815                Some(message.clone()),
816            )
817            .is_ok();
818            if state_updated {
819                cleanup.mark_terminal();
820            }
821            return Err(message.into());
822        }
823    };
824    let instrumented_build_ms = execution
825        .phases
826        .iter()
827        .find(|phase| phase.kind == PhaseKind::Build)
828        .map_or(0.0, |phase| phase.duration_ms as f64);
829    let test_command_ms = execution
830        .phases
831        .iter()
832        .find(|phase| phase.kind == PhaseKind::Test)
833        .map_or(0.0, |phase| phase.duration_ms as f64);
834    let build_succeeded = execution
835        .phases
836        .iter()
837        .find(|phase| phase.kind == PhaseKind::Build)
838        .is_some_and(|phase| phase.result.exit_code() == 0);
839    if project.build_adapter != BuildAdapter::Direct && reusable_build.is_none() && build_succeeded
840    {
841        write_build_cache(&root, &workspace, &build_cache_key, &started_at)?;
842    }
843    if let Some(signal) = execution.interrupted_signal {
844        interrupt_run_state(&root, &run_id, &started_at, signal_name(signal))
845            .map_err(|error| error.to_string())?;
846        cleanup.mark_terminal();
847        return Err(DirectJavascriptRunError::Interrupted {
848            signal,
849            exit_code: execution.exit_code,
850            timings: RunTimings {
851                initialization_ms: rounded_millisecond(initialization_ms),
852                workspace_preparation_ms: rounded_millisecond(workspace_preparation_ms),
853                adapter_setup_ms: rounded_millisecond(adapter_setup_ms),
854                instrumented_build_ms: rounded_millisecond(instrumented_build_ms),
855                test_command_ms: rounded_millisecond(test_command_ms),
856                evidence_publication_ms: 0.0,
857            },
858            total_ms: rounded_millisecond(elapsed_ms(total_started)),
859        });
860    }
861    // Leave the real working tree as the command alone would have: updated
862    // snapshots, generated fixtures, and reports belong in the repository,
863    // not in the cache. Deletions and instrumented-source rewrites are
864    // reported, never propagated.
865    if let Some(baseline) = output_baseline.borrow().as_ref() {
866        let protected = project
867            .source_files
868            .iter()
869            .map(PathBuf::from)
870            .collect::<std::collections::BTreeSet<_>>();
871        let outputs = sync_command_outputs(&root, &workspace, baseline, &protected)
872            .map_err(|error| error.to_string())?;
873        if outputs.synced > 0 {
874            let _ = writeln!(
875                diagnostics,
876                "[supercov] synced {} file(s) the command created or changed back to the project",
877                outputs.synced
878            );
879        }
880        if !outputs.skipped_instrumented.is_empty() {
881            let _ = writeln!(
882                diagnostics,
883                "[supercov] {} file(s) stayed in the isolated workspace: they are instrumented copies, or were built from them, and must not overwrite your project: {}",
884                outputs.skipped_instrumented.len(),
885                outputs
886                    .skipped_instrumented
887                    .iter()
888                    .take(3)
889                    .map(|path| path.display().to_string())
890                    .collect::<Vec<_>>()
891                    .join(", ")
892            );
893        }
894        if !outputs.deleted_in_workspace.is_empty() {
895            let _ = writeln!(
896                diagnostics,
897                "[supercov] the command deleted {} file(s) in the isolated workspace; deletions are not propagated to the project",
898                outputs.deleted_in_workspace.len()
899            );
900        }
901    }
902    update_run_state(
903        &root,
904        &run_id,
905        RunStateStatus::Publishing,
906        &started_at,
907        None,
908    )
909    .map_err(|error| error.to_string())?;
910
911    let publication_started = Instant::now();
912    let archive_path = root
913        .join(".supercov/work")
914        .join(&run_id)
915        .join("evidence.raw.gz");
916    let entries = collect_sources(&[
917        EvidenceArchiveSource::File {
918            file: frontend.manifest_path,
919            path: "manifest.json".into(),
920        },
921        EvidenceArchiveSource::File {
922            file: workspace.join(".supercov/statement-exclusions.json"),
923            path: "statement-exclusions.json".into(),
924        },
925        EvidenceArchiveSource::Directory {
926            directory: evidence_directory,
927            prefix: None,
928        },
929        EvidenceArchiveSource::Directory {
930            directory: server_evidence_root.join(&run_id),
931            prefix: Some("server".into()),
932        },
933    ])
934    .map_err(|error| error.to_string())?;
935    let entries =
936        javascript_archive_entries(entries, &frontend.manifest, &run_id, execution.exit_code)?;
937    let entries = crate::assertion_inputs::append(entries, &assertion_inputs)?;
938    let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
939    remove_stored_tree_deferred(&root, &workspace.join(".supercov/evidence"))
940        .map_err(|error| error.to_string())?;
941    remove_stored_tree_deferred(&root, &server_evidence_root).map_err(|error| error.to_string())?;
942    let evidence_publication_ms = elapsed_ms(publication_started);
943    let timings = RunTimings {
944        initialization_ms: rounded_millisecond(initialization_ms),
945        workspace_preparation_ms: rounded_millisecond(workspace_preparation_ms),
946        adapter_setup_ms: rounded_millisecond(adapter_setup_ms),
947        instrumented_build_ms: rounded_millisecond(instrumented_build_ms),
948        test_command_ms: rounded_millisecond(test_command_ms),
949        evidence_publication_ms: rounded_millisecond(evidence_publication_ms),
950    };
951    let metadata = RunMetadata {
952        id: run_id.clone(),
953        started_at: started_at.clone(),
954        duration_ms: rounded_millisecond(elapsed_ms(total_started)),
955        command: request.command.clone(),
956        test_exit_code: Some(execution.exit_code),
957        integrity,
958        raw_evidence: RawEvidenceMetadata {
959            schema_version: raw.schema_version,
960            format: raw.format.into(),
961            file: raw.file.into(),
962            files: raw.files,
963            uncompressed_bytes: raw.uncompressed_bytes,
964            compressed_bytes: raw.compressed_bytes,
965        },
966        isolated_build: Some(true),
967        instrumented_build_cache: Some(InstrumentedBuildCache {
968            key: build_cache_key,
969            reused: reusable_build.is_some(),
970        }),
971        timings: Some(timings),
972        merged: None,
973        parents: None,
974    };
975    let run_directory =
976        publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
977    let terminal_status = if execution.exit_code == 0 {
978        RunStateStatus::Complete
979    } else {
980        RunStateStatus::Failed
981    };
982    update_run_state(&root, &run_id, terminal_status, &started_at, None)
983        .map_err(|error| error.to_string())?;
984    finalize_published_run(&root, &run_id).map_err(|error| error.to_string())?;
985    cleanup.mark_terminal();
986    Ok(DirectJavascriptRunResult {
987        run_id,
988        run_directory,
989        workspace,
990        exit_code: execution.exit_code,
991        assertion_calls: frontend.assertion_calls,
992        recovered_runs,
993        metadata,
994    })
995}
996
997#[cfg(test)]
998mod environment_tests {
999    use super::*;
1000
1001    #[test]
1002    fn nested_npm_drops_only_pnpm_derived_environment_aliases() {
1003        let mut environment = BTreeMap::from([
1004            ("npm_config_auto_install_peers".into(), "true".into()),
1005            ("NPM_CONFIG_SHAMEFULLY_HOIST".into(), "true".into()),
1006            (
1007                "npm_config_registry".into(),
1008                "https://registry.npmjs.org".into(),
1009            ),
1010            ("USER_VALUE".into(), "kept".into()),
1011        ]);
1012        remove_derived_pnpm_config(&mut environment);
1013        assert_eq!(
1014            environment,
1015            BTreeMap::from([
1016                ("USER_VALUE".into(), "kept".into()),
1017                (
1018                    "npm_config_registry".into(),
1019                    "https://registry.npmjs.org".into()
1020                ),
1021            ])
1022        );
1023    }
1024}