Skip to main content

supercov_engine/
javascript_run.rs

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