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