Skip to main content

supercov_engine/
javascript_frontend.rs

1//! JavaScript source frontend for Rust-owned executions.
2//!
3//! The frontend mutates only an already-isolated workspace. JavaScript files
4//! are transformed by the Rust instrumenter; the small Node/browser runtime
5//! remains a language shim and is copied into the workspace under `.supercov`.
6
7use std::{
8    collections::BTreeMap,
9    fs::{self, OpenOptions},
10    io::{self, Write},
11    path::{Path, PathBuf},
12    sync::atomic::{AtomicU64, Ordering},
13    time::{SystemTime, UNIX_EPOCH},
14};
15
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::{
20    js_instrumenter::{
21        CandidateBranch, CandidateDecision, CandidateError, CandidateLimitation, CandidatePoint,
22        instrument_candidate, instrument_direct_candidate,
23        instrument_node_assertion_phases_with_expect_modules,
24    },
25    project_discovery::{BuildAdapter, CoverageProject},
26    source_discovery::{SourceLimitation, SourceScope},
27};
28
29const RUNTIME_INSTANCE_MARKER: &str = "__SUPERCOV_RUNTIME_INSTANCE__";
30const RUNTIME_FILES: &[&str] = &[
31    "atomic.js",
32    "launchSupervisor.js",
33    "nodeAssert.js",
34    "nodeAssertAdapter.js",
35    "nodeAssertStrict.js",
36    "nodeTest.js",
37    "playwright.js",
38    "playwrightReporter.js",
39    "provenance.js",
40    "register.mjs",
41    "resolve-loader.mjs",
42    "runnerEvidence.js",
43    "runtime.js",
44    "transport.js",
45    "types.js",
46    "vitest.js",
47    "vitestReporter.js",
48];
49static UNIQUE: AtomicU64 = AtomicU64::new(0);
50
51#[derive(Debug)]
52pub enum JavascriptFrontendError {
53    Io {
54        path: PathBuf,
55        source: io::Error,
56    },
57    Instrument {
58        file: String,
59        source: CandidateError,
60    },
61    MissingRuntimeMarker,
62    Serialize(serde_json::Error),
63    UnsafeSourcePath(String),
64}
65
66impl std::fmt::Display for JavascriptFrontendError {
67    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
70            Self::Instrument { file, source } => {
71                write!(formatter, "failed to instrument {file}: {source:?}")
72            }
73            Self::MissingRuntimeMarker => write!(
74                formatter,
75                "generated Supercov runtime is missing its instance marker"
76            ),
77            Self::Serialize(error) => write!(formatter, "failed to serialize manifest: {error}"),
78            Self::UnsafeSourcePath(file) => write!(formatter, "unsafe source path: {file}"),
79        }
80    }
81}
82
83impl std::error::Error for JavascriptFrontendError {}
84
85fn io_error(path: &Path, source: io::Error) -> JavascriptFrontendError {
86    JavascriptFrontendError::Io {
87        path: path.to_owned(),
88        source,
89    }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94pub struct JavascriptManifest {
95    pub decisions: Vec<CandidateDecision>,
96    pub points: Vec<CandidatePoint>,
97    pub branches: Vec<CandidateBranch>,
98    pub limitations: Vec<CandidateLimitation>,
99    pub scope: SourceScope,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct PreparedJavascriptFrontend {
104    pub manifest: JavascriptManifest,
105    pub manifest_path: PathBuf,
106    pub preload_path: PathBuf,
107    pub playwright_config_path: PathBuf,
108    pub vite_config_path: PathBuf,
109    pub vitest_config_path: PathBuf,
110    pub assertion_calls: usize,
111}
112
113#[derive(Debug, Serialize)]
114#[serde(rename_all = "camelCase")]
115struct ViteTransform {
116    source_sha256: String,
117    code: String,
118    map: Option<serde_json::Value>,
119}
120
121pub fn javascript_runtime_files(runtime_root: &Path) -> Vec<PathBuf> {
122    RUNTIME_FILES
123        .iter()
124        .map(|name| runtime_root.join(name))
125        .collect()
126}
127
128fn embedded_runtime(name: &str) -> Option<&'static [u8]> {
129    match name {
130        "atomic.js" => Some(include_bytes!("../runtime/atomic.js")),
131        "launchSupervisor.js" => Some(include_bytes!("../runtime/launchSupervisor.js")),
132        "nodeAssert.js" => Some(include_bytes!("../runtime/nodeAssert.js")),
133        "nodeAssertAdapter.js" => Some(include_bytes!("../runtime/nodeAssertAdapter.js")),
134        "nodeAssertStrict.js" => Some(include_bytes!("../runtime/nodeAssertStrict.js")),
135        "nodeTest.js" => Some(include_bytes!("../runtime/nodeTest.js")),
136        "playwright.js" => Some(include_bytes!("../runtime/playwright.js")),
137        "playwrightReporter.js" => Some(include_bytes!("../runtime/playwrightReporter.js")),
138        "provenance.js" => Some(include_bytes!("../runtime/provenance.js")),
139        "register.mjs" => Some(include_bytes!("../runtime/register.mjs")),
140        "resolve-loader.mjs" => Some(include_bytes!("../runtime/resolve-loader.mjs")),
141        "runnerEvidence.js" => Some(include_bytes!("../runtime/runnerEvidence.js")),
142        "runtime.js" => Some(include_bytes!("../runtime/runtime.js")),
143        "transport.js" => Some(include_bytes!("../runtime/transport.js")),
144        "types.js" => Some(include_bytes!("../runtime/types.js")),
145        "vitest.js" => Some(include_bytes!("../runtime/vitest.js")),
146        "vitestReporter.js" => Some(include_bytes!("../runtime/vitestReporter.js")),
147        _ => None,
148    }
149}
150
151fn unique() -> String {
152    let nanos = SystemTime::now()
153        .duration_since(UNIX_EPOCH)
154        .unwrap_or_default()
155        .as_nanos();
156    format!(
157        "{}-{nanos}-{}",
158        std::process::id(),
159        UNIQUE.fetch_add(1, Ordering::Relaxed)
160    )
161}
162
163fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), JavascriptFrontendError> {
164    let parent = path
165        .parent()
166        .ok_or_else(|| JavascriptFrontendError::UnsafeSourcePath(path.display().to_string()))?;
167    fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
168    let temporary = parent.join(format!(".supercov-write-{}", unique()));
169    let result = (|| {
170        let mut output = OpenOptions::new()
171            .write(true)
172            .create_new(true)
173            .open(&temporary)
174            .map_err(|source| io_error(&temporary, source))?;
175        output
176            .write_all(contents)
177            .and_then(|_| output.sync_all())
178            .map_err(|source| io_error(&temporary, source))?;
179        fs::rename(&temporary, path).map_err(|source| io_error(path, source))?;
180        OpenOptions::new()
181            .read(true)
182            .open(parent)
183            .and_then(|directory| directory.sync_all())
184            .map_err(|source| io_error(parent, source))
185    })();
186    if result.is_err() {
187        let _ = fs::remove_file(&temporary);
188    }
189    result
190}
191
192fn checked_source_path(workspace: &Path, file: &str) -> Result<PathBuf, JavascriptFrontendError> {
193    let relative = Path::new(file);
194    if relative.is_absolute()
195        || relative
196            .components()
197            .any(|component| !matches!(component, std::path::Component::Normal(_)))
198    {
199        return Err(JavascriptFrontendError::UnsafeSourcePath(file.to_owned()));
200    }
201    Ok(workspace.join(relative))
202}
203
204fn isolate_runtime(source: &str, collector_id: &str) -> Result<String, JavascriptFrontendError> {
205    let double = format!("runtimeInstanceToken = \"{RUNTIME_INSTANCE_MARKER}\"");
206    let single = format!("runtimeInstanceToken = '{RUNTIME_INSTANCE_MARKER}'");
207    if let Some(index) = source.find(&double) {
208        let mut isolated = source.to_owned();
209        isolated.replace_range(
210            index..index + double.len(),
211            &format!("runtimeInstanceToken = \"{collector_id}\""),
212        );
213        return Ok(isolated);
214    }
215    if let Some(index) = source.find(&single) {
216        let mut isolated = source.to_owned();
217        isolated.replace_range(
218            index..index + single.len(),
219            &format!("runtimeInstanceToken = '{collector_id}'"),
220        );
221        return Ok(isolated);
222    }
223    Err(JavascriptFrontendError::MissingRuntimeMarker)
224}
225
226fn copy_runtime(
227    runtime_root: Option<&Path>,
228    generated: &Path,
229    collector_id: &str,
230) -> Result<(), JavascriptFrontendError> {
231    fs::create_dir_all(generated).map_err(|source| io_error(generated, source))?;
232    atomic_write(
233        &generated.join("package.json"),
234        b"{\"private\":true,\"type\":\"module\"}\n",
235    )?;
236    for name in RUNTIME_FILES {
237        let destination = generated.join(name);
238        let (bytes, source_path) = if let Some(runtime_root) = runtime_root {
239            let source_path = runtime_root.join(name);
240            let bytes = fs::read(&source_path).map_err(|source| io_error(&source_path, source))?;
241            (bytes, source_path)
242        } else {
243            let bytes = embedded_runtime(name)
244                .expect("every declared runtime file must have an embedded asset")
245                .to_vec();
246            (bytes, PathBuf::from(format!("embedded:{name}")))
247        };
248        if *name == "runtime.js" {
249            let text = String::from_utf8(bytes).map_err(|source| {
250                io_error(
251                    &source_path,
252                    io::Error::new(io::ErrorKind::InvalidData, source),
253                )
254            })?;
255            atomic_write(
256                &destination,
257                isolate_runtime(&text, collector_id)?.as_bytes(),
258            )?;
259            atomic_write(
260                &generated.join("applicationRuntime.js"),
261                isolate_runtime(&text, &format!("{collector_id}-application"))?.as_bytes(),
262            )?;
263        } else {
264            atomic_write(&destination, &bytes)?;
265        }
266    }
267    atomic_write(
268        &generated.join("runtime.d.ts"),
269        b"export declare function coverageHit(...args: any[]): any;\n\
270export declare function selectionBegin(...args: any[]): any;\n\
271export declare function selectionRight(...args: any[]): any;\n\
272export declare function selectionEnd(...args: any[]): any;\n\
273export declare function optionalSelect(...args: any[]): any;\n\
274export declare function optionalCallBegin(...args: any[]): any;\n\
275export declare function optionalCallReached(...args: any[]): any;\n\
276export declare function optionalCallContinued(...args: any[]): any;\n\
277export declare function optionalCallEnd(...args: any[]): any;\n\
278export declare function defaultSelected(...args: any[]): any;\n\
279export declare function defaultEntered(...args: any[]): any;\n\
280export declare function tryBegin(...args: any[]): any;\n\
281export declare function tryCatch(...args: any[]): any;\n\
282export declare function tryEnd(...args: any[]): any;\n\
283export declare function loopBegin(...args: any[]): any;\n\
284export declare function loopEntered(...args: any[]): any;\n\
285export declare function loopEnd(...args: any[]): any;\n\
286export declare function mcdcBegin(...args: any[]): any;\n\
287export declare function mcdcCondition(...args: any[]): any;\n\
288export declare function mcdcEnd(...args: any[]): any;\n\
289export declare function registerProbeV2(...args: any[]): any;\n\
290export declare function coverageHitV2(...args: any[]): any;\n\
291export declare function mcdcEndV2(...args: any[]): any;\n",
292    )?;
293    Ok(())
294}
295
296fn generic_runtime_binding(
297    workspace: &Path,
298    project: &CoverageProject,
299    source_path: &Path,
300    generated: &Path,
301) -> Result<String, JavascriptFrontendError> {
302    let mut hosts = project
303        .source_roots
304        .iter()
305        .filter_map(|root| {
306            let candidate = workspace.join(root);
307            if candidate.is_dir() && source_path.strip_prefix(&candidate).is_ok() {
308                Some(candidate)
309            } else if candidate.is_file() && candidate == source_path {
310                candidate.parent().map(Path::to_owned)
311            } else {
312                None
313            }
314        })
315        .collect::<Vec<_>>();
316    hosts.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
317    let host = hosts
318        .into_iter()
319        .next()
320        .unwrap_or_else(|| workspace.to_owned());
321    let runtime_directory = host.join(".supercov");
322    fs::create_dir_all(&runtime_directory)
323        .map_err(|source| io_error(&runtime_directory, source))?;
324    for name in ["runtime.js", "runtime.d.ts"] {
325        let source = generated.join(name);
326        let destination = runtime_directory.join(name);
327        let contents = fs::read(&source).map_err(|error| io_error(&source, error))?;
328        atomic_write(&destination, &contents)?;
329    }
330    let parent = source_path.parent().ok_or_else(|| {
331        JavascriptFrontendError::UnsafeSourcePath(source_path.display().to_string())
332    })?;
333    let local = parent.strip_prefix(&host).map_err(|_| {
334        JavascriptFrontendError::UnsafeSourcePath(source_path.display().to_string())
335    })?;
336    let depth = local.components().count();
337    Ok(if depth == 0 {
338        "./.supercov/runtime.js".into()
339    } else {
340        format!("{}.supercov/runtime.js", "../".repeat(depth))
341    })
342}
343
344fn limitation_from_source(value: &SourceLimitation) -> CandidateLimitation {
345    CandidateLimitation {
346        id: value.id.clone(),
347        kind: value.kind.clone(),
348        file: value.file.clone(),
349        line: value.line,
350        column: value.column,
351        source: value.source.clone(),
352        reason: value.reason.clone(),
353    }
354}
355
356fn relocated_project_file(
357    workspace: &Path,
358    project: &CoverageProject,
359    source: Option<&PathBuf>,
360) -> Option<PathBuf> {
361    let source = source?;
362    let relative = source.strip_prefix(&project.root).ok()?;
363    Some(workspace.join(relative))
364}
365
366fn write_vitest_config(
367    workspace: &Path,
368    project: &CoverageProject,
369    generated: &Path,
370) -> Result<PathBuf, JavascriptFrontendError> {
371    let path = generated.join("vitest.config.mjs");
372    let original = relocated_project_file(workspace, project, project.vitest_config.as_ref())
373        .map(|path| path.display().to_string());
374    let original = serde_json::to_string(&original).map_err(JavascriptFrontendError::Serialize)?;
375    let source = format!(
376        "import {{ loadConfigFromFile, mergeConfig }} from 'vite';\n\
377         import {{ resolve }} from 'node:path';\n\
378         import SupercovVitestReporter from './vitestReporter.js';\n\
379         import {{ supercovViteInstrumentation }} from './viteInstrumentation.mjs';\n\
380         const discoveredConfig = {original};\n\
381         export default async function supercovVitestConfig(env) {{\n\
382           const originalPath = process.env.SUPERCOV_ORIGINAL_VITEST_CONFIG || discoveredConfig;\n\
383           const loaded = originalPath ? await loadConfigFromFile(env, originalPath, process.cwd()) : undefined;\n\
384           const config = mergeConfig(loaded?.config ?? {{}}, {{\n\
385             cacheDir: resolve(process.cwd(), '.supercov/vitest-cache'),\n\
386             plugins: [supercovViteInstrumentation(process.cwd())],\n\
387             test: {{ setupFiles: [resolve(process.cwd(), '.supercov/vitest.js')], maxConcurrency: 1 }},\n\
388           }});\n\
389           const configuredReporters = loaded?.config?.test?.reporters;\n\
390           config.test ??= {{}};\n\
391           config.test.reporters = configuredReporters\n\
392             ? [...(Array.isArray(configuredReporters) ? configuredReporters : [configuredReporters]), new SupercovVitestReporter()]\n\
393             : ['default', new SupercovVitestReporter()];\n\
394           return config;\n\
395         }}\n"
396    );
397    atomic_write(&path, source.as_bytes())?;
398    Ok(path)
399}
400
401fn configure_playwright_runtime(
402    generated: &Path,
403    project: &CoverageProject,
404) -> Result<(), JavascriptFrontendError> {
405    let adapter_path = generated.join("playwright.js");
406    let mut adapter =
407        fs::read_to_string(&adapter_path).map_err(|source| io_error(&adapter_path, source))?;
408    adapter = adapter
409        .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module)
410        .replace(
411            "__SUPERCOV_PLAYWRIGHT_TEST_EXPORT__",
412            &project.playwright_test_export,
413        );
414    let mut exports = Vec::new();
415    if project.playwright_test_export != "test" {
416        exports.push(format!(
417            "export {{ instrumentedTest as {} }};",
418            project.playwright_test_export
419        ));
420    }
421    exports.extend(
422        project
423            .playwright_exports
424            .iter()
425            .filter(|name| {
426                name.as_str() != "test"
427                    && name.as_str() != "expect"
428                    && *name != &project.playwright_test_export
429            })
430            .map(|name| {
431                let encoded = serde_json::to_string(name)
432                    .expect("serializing a JavaScript export name cannot fail");
433                format!("export const {name} = adapter[{encoded}];")
434            }),
435    );
436    adapter = adapter.replace("/*__SUPERCOV_ADAPTER_EXPORTS__*/", &exports.join("\n"));
437    atomic_write(&adapter_path, adapter.as_bytes())?;
438
439    let loader_path = generated.join("resolve-loader.mjs");
440    let loader = fs::read_to_string(&loader_path)
441        .map_err(|source| io_error(&loader_path, source))?
442        .replace("__SUPERCOV_PLAYWRIGHT_MODULE__", &project.playwright_module);
443    atomic_write(&loader_path, loader.as_bytes())
444}
445
446fn write_playwright_config(
447    workspace: &Path,
448    project: &CoverageProject,
449    generated: &Path,
450) -> Result<PathBuf, JavascriptFrontendError> {
451    let path = generated.join("playwright.config.mjs");
452    let original = relocated_project_file(workspace, project, project.playwright_config.as_ref());
453    let original_import = if let Some(original) = &original {
454        let relative = original
455            .strip_prefix(workspace)
456            .map_err(|_| JavascriptFrontendError::UnsafeSourcePath(original.display().to_string()))?
457            .to_string_lossy()
458            .replace('\\', "/");
459        let specifier = serde_json::to_string(&format!("../{relative}"))
460            .map_err(JavascriptFrontendError::Serialize)?;
461        format!("import original from {specifier};\n")
462    } else {
463        "const original = {};\n".into()
464    };
465    let source = format!(
466        "import './register.mjs';\n\
467         import {{ dirname, isAbsolute, relative, resolve }} from 'node:path';\n\
468         import {{ fileURLToPath }} from 'node:url';\n\
469         {original_import}\
470         const resolvedValue = typeof original === 'function' ? await original({{ command: 'test', mode: 'test' }}) : original;\n\
471         const resolved = resolvedValue ?? {{}};\n\
472         const runtimeProjectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');\n\
473         const originalDirectory = {};
474         const sourceProjectRoot = process.env.SUPERCOV_SOURCE_PROJECT_ROOT;\n\
475         const runtimePath = value => {{\n\
476           if (!value) return value;\n\
477           const absolute = isAbsolute(value) ? value : resolve(originalDirectory, value);\n\
478           const local = relative(runtimeProjectRoot, absolute);\n\
479           if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
480           if (sourceProjectRoot) {{\n\
481             const sourceLocal = relative(sourceProjectRoot, absolute);\n\
482             if (sourceLocal === '' || (!sourceLocal.startsWith('..') && !isAbsolute(sourceLocal))) return resolve(runtimeProjectRoot, sourceLocal);\n\
483           }}\n\
484           throw new Error('Supercov refuses a Playwright output/cwd outside the isolated project: ' + absolute);\n\
485         }};\n\
486         const normalizeWebServer = server => server ? ({{ ...server, cwd: runtimePath(server.cwd ?? originalDirectory) }}) : server;\n\
487         const normalized = {{ ...resolved,\n\
488           testDir: runtimePath(resolved.testDir),\n\
489           outputDir: runtimePath(resolved.outputDir),\n\
490           snapshotDir: runtimePath(resolved.snapshotDir),\n\
491           projects: resolved.projects?.map(project => ({{ ...project, testDir: runtimePath(project.testDir), outputDir: runtimePath(project.outputDir), snapshotDir: runtimePath(project.snapshotDir) }})),\n\
492           webServer: Array.isArray(resolved.webServer) ? resolved.webServer.map(normalizeWebServer) : normalizeWebServer(resolved.webServer),\n\
493         }};\n\
494         const configuredReporters = normalized.reporter;\n\
495         const reporters = configuredReporters\n\
496           ? (typeof configuredReporters === 'string' ? [[configuredReporters]] : (Array.isArray(configuredReporters[0]) ? configuredReporters : [configuredReporters]))\n\
497           : [['list']];\n\
498         const coverageReporter = resolve(runtimeProjectRoot, '.supercov/playwrightReporter.js');\n\
499         export default {{ ...normalized, reporter: [...reporters, [coverageReporter]] }};\n",
500        serde_json::to_string(
501            &original
502                .as_ref()
503                .and_then(|path| path.parent())
504                .unwrap_or(workspace)
505                .display()
506                .to_string()
507        )
508        .map_err(JavascriptFrontendError::Serialize)?
509    );
510    atomic_write(&path, source.as_bytes())?;
511    Ok(path)
512}
513
514fn write_vite_config(
515    workspace: &Path,
516    generated: &Path,
517) -> Result<PathBuf, JavascriptFrontendError> {
518    let path = generated.join("vite.config.mjs");
519    let workspace = serde_json::to_string(&workspace.display().to_string())
520        .map_err(JavascriptFrontendError::Serialize)?;
521    let source = format!(
522        "import {{ loadConfigFromFile, mergeConfig }} from 'vite';\n\
523         import {{ isAbsolute, relative, resolve }} from 'node:path';\n\
524         import {{ supercovViteInstrumentation }} from './viteInstrumentation.mjs';\n\
525         export default async function supercovViteConfig(env) {{\n\
526           const isolatedRoot = {workspace};\n\
527           const loaded = await loadConfigFromFile(env, undefined, isolatedRoot);\n\
528           const config = loaded?.config ?? {{}};\n\
529           const relocate = (value, label) => {{\n\
530             const absolute = isAbsolute(value) ? value : resolve(isolatedRoot, value);\n\
531             const local = relative(isolatedRoot, absolute);\n\
532             if (local === '' || (!local.startsWith('..') && !isAbsolute(local))) return absolute;\n\
533             throw new Error('Supercov refuses ' + label + ' outside the isolated project: ' + absolute);\n\
534           }};\n\
535           const relocateOutput = output => output ? ({{ ...output, dir: output.dir ? relocate(output.dir, 'Rollup output') : output.dir, file: output.file ? relocate(output.file, 'Rollup output') : output.file }}) : output;\n\
536           const rollupOutput = config.build?.rollupOptions?.output;\n\
537           const safe = {{ ...config,\n\
538             cacheDir: resolve(isolatedRoot, '.supercov/vite-cache'),\n\
539             build: {{ ...config.build, outDir: relocate(config.build?.outDir ?? 'dist', 'Vite build output'), rollupOptions: {{ ...config.build?.rollupOptions, output: Array.isArray(rollupOutput) ? rollupOutput.map(relocateOutput) : relocateOutput(rollupOutput) }} }},\n\
540           }};\n\
541           return mergeConfig(safe, {{ plugins: [supercovViteInstrumentation(isolatedRoot)] }});\n\
542         }}\n"
543    );
544    atomic_write(&path, source.as_bytes())?;
545    Ok(path)
546}
547
548fn write_vite_transforms(
549    generated: &Path,
550    transforms: &BTreeMap<String, ViteTransform>,
551) -> Result<(), JavascriptFrontendError> {
552    let mut payload = serde_json::to_vec(transforms).map_err(JavascriptFrontendError::Serialize)?;
553    payload.push(b'\n');
554    atomic_write(&generated.join("vite-transforms.json"), &payload)?;
555    let adapter = "import { createHash } from 'node:crypto';\n\
556import { readFileSync } from 'node:fs';\n\
557import { relative, resolve, sep } from 'node:path';\n\
558const transforms = JSON.parse(readFileSync(new URL('./vite-transforms.json', import.meta.url), 'utf8'));\n\
559const sha256 = value => createHash('sha256').update(value).digest('hex');\n\
560export function supercovViteInstrumentation(root) {\n\
561  const runtimePath = resolve(root, '.supercov/applicationRuntime.js');\n\
562  return {\n\
563    name: 'supercov-rust-instrumentation',\n\
564    enforce: 'pre',\n\
565    resolveId(id) { return id === 'virtual:supercov-runtime' ? runtimePath : null; },\n\
566    transform(code, rawId) {\n\
567      const id = rawId.split('?')[0] ?? rawId;\n\
568      const local = relative(root, id).split(sep).join('/');\n\
569      const transformed = transforms[local];\n\
570      if (!transformed) return null;\n\
571      if (sha256(code) !== transformed.sourceSha256)\n\
572        throw new Error('Supercov source changed before Rust instrumentation: ' + local);\n\
573      return { code: transformed.code, map: transformed.map ?? null };\n\
574    },\n\
575  };\n\
576}\n";
577    atomic_write(
578        &generated.join("viteInstrumentation.mjs"),
579        adapter.as_bytes(),
580    )
581}
582
583/// Prepare the complete JavaScript frontend inside an isolated workspace.
584/// The source project is read only through the copied workspace inventory.
585pub fn prepare_javascript_frontend(
586    workspace: &Path,
587    project: &CoverageProject,
588    runtime_root: Option<&Path>,
589    collector_id: &str,
590) -> Result<PreparedJavascriptFrontend, JavascriptFrontendError> {
591    let generated = workspace.join(".supercov");
592    copy_runtime(runtime_root, &generated, collector_id)?;
593    configure_playwright_runtime(&generated, project)?;
594    let playwright_config_path = write_playwright_config(workspace, project, &generated)?;
595    let vite_config_path = write_vite_config(workspace, &generated)?;
596    let vitest_config_path = write_vitest_config(workspace, project, &generated)?;
597
598    let mut decisions = BTreeMap::new();
599    let mut points = BTreeMap::new();
600    let mut branches = BTreeMap::new();
601    let mut limitations = BTreeMap::new();
602    let mut vite_transforms = BTreeMap::new();
603    for limitation in &project.source_limitations {
604        limitations.insert(limitation.id.clone(), limitation_from_source(limitation));
605    }
606
607    for file in &project.source_files {
608        let path = checked_source_path(workspace, file)?;
609        let source = fs::read_to_string(&path).map_err(|source| io_error(&path, source))?;
610        let mut output = match project.build_adapter {
611            BuildAdapter::Vite => instrument_candidate(&source, file),
612            BuildAdapter::Generic => instrument_candidate(&source, file),
613            BuildAdapter::Direct => instrument_direct_candidate(&source, file),
614        }
615        .map_err(|source| JavascriptFrontendError::Instrument {
616            file: file.clone(),
617            source,
618        })?;
619        if project.build_adapter == BuildAdapter::Generic {
620            let runtime = generic_runtime_binding(workspace, project, &path, &generated)?;
621            output.code = output.code.replace("virtual:supercov-runtime", &runtime);
622            if matches!(
623                path.extension().and_then(|value| value.to_str()),
624                Some("ts" | "tsx" | "mts" | "cts")
625            ) {
626                output.code = format!(
627                    "// @ts-nocheck -- generated coverage workspace only\n{}",
628                    output.code
629                );
630            }
631        }
632        if project.build_adapter == BuildAdapter::Vite {
633            vite_transforms.insert(
634                file.clone(),
635                ViteTransform {
636                    source_sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
637                    code: output.code.clone(),
638                    map: output.map.clone(),
639                },
640            );
641        } else {
642            atomic_write(&path, output.code.as_bytes())?;
643        }
644        for value in output.decisions {
645            decisions.insert(value.id.clone(), value);
646        }
647        for value in output.points {
648            points.insert(value.id.clone(), value);
649        }
650        for value in output.branches {
651            branches.insert(value.id.clone(), value);
652        }
653        for value in output.coverage_limitations {
654            limitations.insert(value.id.clone(), value);
655        }
656    }
657    write_vite_transforms(&generated, &vite_transforms)?;
658
659    let mut assertion_calls = 0;
660    for entry in &project.source_scope.entries {
661        let path = checked_source_path(workspace, &entry.file)?;
662        let Ok(source) = fs::read_to_string(&path) else {
663            continue;
664        };
665        let output = instrument_node_assertion_phases_with_expect_modules(
666            &source,
667            &entry.file,
668            std::slice::from_ref(&project.playwright_module),
669        )
670        .map_err(|source| JavascriptFrontendError::Instrument {
671            file: entry.file.clone(),
672            source,
673        })?;
674        let coverage_transformed_by_vite = project.build_adapter == BuildAdapter::Vite
675            && project.source_files.contains(&entry.file);
676        if output.assertions > 0 && !coverage_transformed_by_vite {
677            atomic_write(&path, output.code.as_bytes())?;
678            assertion_calls += output.assertions;
679        }
680    }
681
682    let mut manifest = JavascriptManifest {
683        decisions: decisions.into_values().collect(),
684        points: points.into_values().collect(),
685        branches: branches.into_values().collect(),
686        limitations: limitations.into_values().collect(),
687        scope: project.source_scope.clone(),
688    };
689    manifest.decisions.sort_by_key(|value| {
690        (
691            value.file.clone(),
692            value.line,
693            value.column,
694            value.id.clone(),
695        )
696    });
697    manifest.points.sort_by_key(|value| {
698        (
699            value.file.clone(),
700            value.line,
701            value.column,
702            value.id.clone(),
703        )
704    });
705    manifest.branches.sort_by_key(|value| {
706        (
707            value.file.clone(),
708            value.line,
709            value.column,
710            value.id.clone(),
711        )
712    });
713    manifest.limitations.sort_by_key(|value| {
714        (
715            value.file.clone(),
716            value.line,
717            value.column,
718            value.id.clone(),
719        )
720    });
721
722    let manifest_path = generated.join("manifest.json");
723    let mut encoded =
724        serde_json::to_vec_pretty(&manifest).map_err(JavascriptFrontendError::Serialize)?;
725    encoded.push(b'\n');
726    atomic_write(&manifest_path, &encoded)?;
727    atomic_write(
728        &generated.join("instrumentation-complete"),
729        b"coverage-completeness-v2\n",
730    )?;
731    Ok(PreparedJavascriptFrontend {
732        manifest,
733        manifest_path,
734        preload_path: generated.join("register.mjs"),
735        playwright_config_path,
736        vite_config_path,
737        vitest_config_path,
738        assertion_calls,
739    })
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745    use crate::project_discovery::discover_coverage_project;
746
747    fn temporary(name: &str) -> PathBuf {
748        std::env::temp_dir().join(format!("supercov-js-frontend-{name}-{}", unique()))
749    }
750
751    #[test]
752    fn runtime_isolation_replaces_only_the_assignment_marker() {
753        let source = concat!(
754            "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n",
755            "const selected = runtimeInstanceToken === \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\";\n"
756        );
757        let isolated = isolate_runtime(source, "collector-123").unwrap();
758        assert!(isolated.contains("runtimeInstanceToken = \"collector-123\""));
759        assert!(isolated.contains("=== \"__SUPERCOV_\" + \"RUNTIME_INSTANCE__\""));
760    }
761
762    #[test]
763    fn prepares_sorted_complete_manifest_without_touching_source_project() {
764        let source_root = temporary("source");
765        let workspace = temporary("workspace");
766        let runtime = temporary("runtime");
767        fs::create_dir_all(source_root.join("src")).unwrap();
768        fs::write(
769            source_root.join("src/example.mjs"),
770            "export function value(a, b) { if (a || b) return 1; return 0; }\n",
771        )
772        .unwrap();
773        fs::write(source_root.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
774        fs::create_dir_all(workspace.join("src")).unwrap();
775        fs::copy(
776            source_root.join("src/example.mjs"),
777            workspace.join("src/example.mjs"),
778        )
779        .unwrap();
780        for name in RUNTIME_FILES {
781            fs::create_dir_all(&runtime).unwrap();
782            let contents = if *name == "runtime.js" {
783                "const runtimeInstanceToken = \"__SUPERCOV_RUNTIME_INSTANCE__\";\n"
784            } else {
785                "export {};\n"
786            };
787            fs::write(runtime.join(name), contents).unwrap();
788        }
789        let project = discover_coverage_project(
790            &source_root,
791            &BTreeMap::new(),
792            &["node".into(), "--test".into()],
793        )
794        .unwrap();
795        let original = fs::read_to_string(source_root.join("src/example.mjs")).unwrap();
796        let prepared =
797            prepare_javascript_frontend(&workspace, &project, Some(&runtime), "collector-test")
798                .unwrap();
799        assert_eq!(
800            fs::read_to_string(source_root.join("src/example.mjs")).unwrap(),
801            original
802        );
803        let transformed = fs::read_to_string(workspace.join("src/example.mjs")).unwrap();
804        assert!(transformed.contains("__SUPERCOV_DIRECT_RUNTIME__"));
805        assert_eq!(prepared.manifest.decisions.len(), 1);
806        assert!(!prepared.manifest.points.is_empty());
807        assert_eq!(prepared.manifest.scope, project.source_scope);
808        assert!(prepared.manifest_path.is_file());
809        assert!(prepared.preload_path.is_file());
810        assert!(prepared.playwright_config_path.is_file());
811        assert!(prepared.vite_config_path.is_file());
812        assert!(prepared.vitest_config_path.is_file());
813        assert_eq!(prepared.assertion_calls, 0);
814        fs::remove_dir_all(source_root).unwrap();
815        fs::remove_dir_all(workspace).unwrap();
816        fs::remove_dir_all(runtime).unwrap();
817    }
818
819    #[test]
820    fn embedded_runtime_contains_every_declared_shim() {
821        for name in RUNTIME_FILES {
822            let bytes = embedded_runtime(name).unwrap();
823            assert!(!bytes.is_empty(), "embedded runtime is empty: {name}");
824        }
825        assert!(
826            std::str::from_utf8(embedded_runtime("runtime.js").unwrap())
827                .unwrap()
828                .contains(RUNTIME_INSTANCE_MARKER)
829        );
830    }
831}