testlint_sdk/runtime_coverage/
javascript.rs

1use crate::platform::{process_exists, signal_process, ProcessSignal};
2use crate::runtime_coverage::{CoverageSummary, RuntimeCoverageResult};
3use std::fs;
4use std::path::Path;
5use std::process::Command;
6use std::thread;
7use std::time::Duration;
8
9pub struct JavaScriptRuntimeCoverage;
10
11impl Default for JavaScriptRuntimeCoverage {
12    fn default() -> Self {
13        Self::new()
14    }
15}
16
17impl JavaScriptRuntimeCoverage {
18    pub fn new() -> Self {
19        JavaScriptRuntimeCoverage
20    }
21
22    /// Attach to a running Node.js process and collect coverage continuously
23    pub fn attach_and_collect(&self, pid: u32) -> Result<RuntimeCoverageResult, String> {
24        println!("šŸ“— Attaching coverage to Node.js process PID: {}", pid);
25        println!("šŸ“Š Collecting coverage continuously (press Ctrl+C to stop)...");
26
27        // Check if process exists and is Node.js
28        self.check_nodejs_process(pid)?;
29
30        // Get the inspector port for this process
31        let inspector_port = self.find_or_enable_inspector(pid)?;
32
33        println!(
34            "šŸ”Œ Connected to Node.js inspector on port {}",
35            inspector_port
36        );
37
38        // Enable coverage via Inspector Protocol
39        println!("šŸ’‰ Enabling coverage collection...");
40        self.enable_coverage(inspector_port)?;
41
42        // Wait for Ctrl+C
43        use std::sync::atomic::{AtomicBool, Ordering};
44        use std::sync::Arc;
45
46        let running = Arc::new(AtomicBool::new(true));
47        let r = running.clone();
48
49        ctrlc::set_handler(move || {
50            r.store(false, Ordering::SeqCst);
51        })
52        .expect("Error setting Ctrl-C handler");
53
54        let start_time = std::time::Instant::now();
55
56        println!("ā³ Collecting coverage... Press Ctrl+C to stop");
57
58        while running.load(Ordering::SeqCst) {
59            thread::sleep(Duration::from_millis(100));
60        }
61
62        let duration_secs = start_time.elapsed().as_secs();
63
64        // Collect coverage data
65        println!("\nšŸ“ Collecting coverage data...");
66        let coverage_file = self.collect_coverage(pid, inspector_port)?;
67
68        // Parse coverage summary
69        let summary = self.parse_coverage_summary(&coverage_file)?;
70
71        Ok(RuntimeCoverageResult {
72            language: "JavaScript".to_string(),
73            pid,
74            duration_secs,
75            coverage_file,
76            summary,
77        })
78    }
79
80    /// Check if process is a Node.js process
81    fn check_nodejs_process(&self, pid: u32) -> Result<(), String> {
82        use crate::platform::get_process_name;
83
84        if !process_exists(pid)? {
85            return Err(format!("Process {} not found", pid));
86        }
87
88        let process_name = get_process_name(pid)?;
89        if !process_name.to_lowercase().contains("node") {
90            return Err(format!("Process {} is not a Node.js process", pid));
91        }
92
93        Ok(())
94    }
95
96    /// Find existing inspector port or signal process to enable inspector
97    fn find_or_enable_inspector(&self, pid: u32) -> Result<u16, String> {
98        // First, try to find if inspector is already enabled
99        if let Ok(port) = self.find_existing_inspector(pid) {
100            return Ok(port);
101        }
102
103        // If not, send SIGUSR1 to enable inspector (Unix) or use file trigger (Windows)
104        println!("šŸ”§ Enabling Node.js inspector...");
105
106        #[cfg(unix)]
107        {
108            signal_process(pid, ProcessSignal::User1)?;
109
110            // Wait for inspector to start
111            thread::sleep(Duration::from_secs(1));
112
113            // Try to find the inspector port
114            self.find_existing_inspector(pid)
115        }
116
117        #[cfg(windows)]
118        {
119            // On Windows, create a trigger file that Node.js process can watch for
120            let trigger_file = std::env::temp_dir().join(format!("inspector_trigger_{}.txt", pid));
121            fs::write(&trigger_file, "enable")?;
122            println!(
123                "ā„¹ļø  Created trigger file (Windows alternative to SIGUSR1): {}",
124                trigger_file.display()
125            );
126
127            thread::sleep(Duration::from_secs(1));
128            self.find_existing_inspector(pid)
129        }
130
131        #[cfg(not(any(unix, windows)))]
132        {
133            Err("Inspector detection not supported on this platform".to_string())
134        }
135    }
136
137    /// Find the inspector port for a running Node.js process
138    fn find_existing_inspector(&self, pid: u32) -> Result<u16, String> {
139        // Check common inspector ports first (9229 is default)
140        for port in [9229, 9230, 9231, 9232, 9233] {
141            if self.test_inspector_port(port) {
142                return Ok(port);
143            }
144        }
145
146        // Try to find from process info
147        #[cfg(target_os = "linux")]
148        {
149            // On Linux, check /proc/<pid>/cmdline and /proc/<pid>/fd for websocket
150            let cmdline_path = format!("/proc/{}/cmdline", pid);
151            if let Ok(cmdline) = fs::read_to_string(&cmdline_path) {
152                if let Some(port) = self.extract_inspector_port(&cmdline) {
153                    return Ok(port);
154                }
155            }
156        }
157
158        Err(format!(
159            "Could not find Node.js inspector port for PID {}. \
160            Process may not have inspector enabled. \
161            Start your Node.js app with --inspect flag.",
162            pid
163        ))
164    }
165
166    /// Test if an inspector port is active
167    fn test_inspector_port(&self, port: u16) -> bool {
168        // Try to connect to the inspector WebSocket
169        use std::net::TcpStream;
170        TcpStream::connect(format!("127.0.0.1:{}", port))
171            .map(|_| true)
172            .unwrap_or(false)
173    }
174
175    /// Extract inspector port from command line
176    #[allow(dead_code)]
177    fn extract_inspector_port(&self, cmdline: &str) -> Option<u16> {
178        // Look for --inspect=<port> or --inspect-brk=<port>
179        for arg in cmdline.split('\0') {
180            if arg.starts_with("--inspect=") {
181                if let Some(port_str) = arg.strip_prefix("--inspect=") {
182                    if let Ok(port) = port_str.parse() {
183                        return Some(port);
184                    }
185                }
186            }
187        }
188        None
189    }
190
191    /// Enable coverage via Inspector Protocol
192    fn enable_coverage(&self, inspector_port: u16) -> Result<(), String> {
193        // Create a Node.js script to enable coverage via inspector protocol
194        let script = r#"
195const inspector = require('inspector');
196const session = new inspector.Session();
197
198session.connect();
199
200// Enable Profiler domain
201session.post('Profiler.enable');
202
203// Enable precise coverage
204session.post('Profiler.startPreciseCoverage', {
205  callCount: true,
206  detailed: true
207});
208
209console.log('[Coverage] Precise coverage enabled');
210
211// Keep the connection alive
212setTimeout(() => {}, 1000000);
213"#
214        .to_string();
215
216        let temp_dir = std::env::temp_dir();
217        let script_path = temp_dir.join(format!("enable_coverage_{}.js", inspector_port));
218        fs::write(&script_path, script).map_err(|e| format!("Failed to write script: {}", e))?;
219
220        let output = Command::new("node")
221            .arg(&script_path)
222            .output()
223            .map_err(|e| format!("Failed to enable coverage: {}", e))?;
224
225        let _ = fs::remove_file(&script_path);
226
227        if !output.status.success() {
228            return Err(format!(
229                "Failed to enable coverage: {}",
230                String::from_utf8_lossy(&output.stderr)
231            ));
232        }
233
234        Ok(())
235    }
236
237    /// Collect coverage data from the process
238    fn collect_coverage(&self, pid: u32, _inspector_port: u16) -> Result<String, String> {
239        // Create a Node.js script to collect coverage
240        let coverage_file = format!("coverage-{}.json", pid);
241
242        let script = format!(
243            r#"
244const inspector = require('inspector');
245const fs = require('fs');
246const session = new inspector.Session();
247
248session.connect();
249
250// Take precise coverage
251session.post('Profiler.takePreciseCoverage', (err, {{ result }}) => {{
252  if (err) {{
253    console.error('Failed to take coverage:', err);
254    process.exit(1);
255  }}
256
257  // Save coverage to file
258  fs.writeFileSync('{}', JSON.stringify(result, null, 2));
259  console.log('[Coverage] Saved to {}');
260
261  // Stop coverage
262  session.post('Profiler.stopPreciseCoverage');
263  session.post('Profiler.disable');
264
265  process.exit(0);
266}});
267"#,
268            coverage_file, coverage_file
269        );
270
271        let temp_dir = std::env::temp_dir();
272        let script_path = temp_dir.join(format!("collect_coverage_{}.js", pid));
273        fs::write(&script_path, script)
274            .map_err(|e| format!("Failed to write collection script: {}", e))?;
275
276        let output = Command::new("node")
277            .arg(&script_path)
278            .output()
279            .map_err(|e| format!("Failed to collect coverage: {}", e))?;
280
281        let _ = fs::remove_file(&script_path);
282
283        if !output.status.success() {
284            return Err(format!(
285                "Failed to collect coverage: {}",
286                String::from_utf8_lossy(&output.stderr)
287            ));
288        }
289
290        if !Path::new(&coverage_file).exists() {
291            return Err(format!("Coverage file {} not found", coverage_file));
292        }
293
294        Ok(coverage_file)
295    }
296
297    /// Parse coverage summary from V8 coverage format
298    fn parse_coverage_summary(&self, coverage_file: &str) -> Result<CoverageSummary, String> {
299        println!("šŸ“Š Parsing coverage report...");
300
301        let content = fs::read_to_string(coverage_file)
302            .map_err(|e| format!("Failed to read coverage file: {}", e))?;
303
304        let data: serde_json::Value =
305            serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
306
307        let mut total_lines = 0;
308        let mut covered_lines = 0;
309
310        // V8 coverage format: array of script coverage objects
311        if let Some(scripts) = data.as_array() {
312            for script in scripts {
313                if let Some(functions) = script["functions"].as_array() {
314                    for function in functions {
315                        if let Some(ranges) = function["ranges"].as_array() {
316                            for range in ranges {
317                                if let (Some(count), Some(_start), Some(_end)) = (
318                                    range["count"].as_u64(),
319                                    range["startOffset"].as_u64(),
320                                    range["endOffset"].as_u64(),
321                                ) {
322                                    total_lines += 1;
323                                    if count > 0 {
324                                        covered_lines += 1;
325                                    }
326                                }
327                            }
328                        }
329                    }
330                }
331            }
332        }
333
334        if total_lines == 0 {
335            return Err("No coverage data found".to_string());
336        }
337
338        let coverage_percentage = (covered_lines as f64 / total_lines as f64) * 100.0;
339
340        Ok(CoverageSummary {
341            total_lines,
342            covered_lines,
343            coverage_percentage,
344            total_branches: None,
345            covered_branches: None,
346            branch_percentage: None,
347        })
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn test_runtime_coverage_new() {
357        let coverage = JavaScriptRuntimeCoverage::new();
358        assert_eq!(std::mem::size_of_val(&coverage), 0);
359    }
360
361    #[test]
362    fn test_runtime_coverage_default() {
363        let coverage = JavaScriptRuntimeCoverage;
364        assert_eq!(std::mem::size_of_val(&coverage), 0);
365    }
366}