testlint_sdk/
browser_coverage.rs

1use std::fs;
2use std::path::PathBuf;
3use std::process::Command;
4
5#[derive(Debug, Clone)]
6pub enum BrowserTool {
7    Playwright,
8    Puppeteer,
9}
10
11pub struct BrowserCoverageHelper {
12    tool: BrowserTool,
13    output_dir: PathBuf,
14}
15
16impl BrowserCoverageHelper {
17    pub fn new(tool: BrowserTool, output_dir: PathBuf) -> Self {
18        BrowserCoverageHelper { tool, output_dir }
19    }
20
21    /// Generate a coverage wrapper script for the browser testing tool
22    pub fn generate_wrapper_script(&self, test_command: &str) -> Result<String, String> {
23        fs::create_dir_all(&self.output_dir)
24            .map_err(|e| format!("Failed to create output directory: {}", e))?;
25
26        match self.tool {
27            BrowserTool::Playwright => self.generate_playwright_script(test_command),
28            BrowserTool::Puppeteer => self.generate_puppeteer_script(test_command),
29        }
30    }
31
32    /// Generate Playwright coverage wrapper
33    fn generate_playwright_script(&self, _test_command: &str) -> Result<String, String> {
34        let script_path = self.output_dir.join("playwright-coverage-wrapper.js");
35
36        let script_content = format!(
37            r#"/**
38 * Playwright Coverage Wrapper
39 * Auto-generated by Testlint SDK
40 *
41 * This script wraps Playwright tests to collect JavaScript coverage
42 * from browser execution.
43 */
44
45const {{ test, chromium, firefox, webkit }} = require('@playwright/test');
46const fs = require('fs');
47const path = require('path');
48
49// Coverage output directory
50const coverageDir = '{}';
51
52// Ensure coverage directory exists
53if (!fs.existsSync(coverageDir)) {{
54  fs.mkdirSync(coverageDir, {{ recursive: true }});
55}}
56
57// Store all coverage data
58let allCoverage = [];
59
60// Hook into Playwright's test lifecycle
61test.beforeEach(async ({{ page }}) => {{
62  // Start JavaScript coverage before each test
63  await page.coverage.startJSCoverage({{
64    resetOnNavigation: false,
65    reportAnonymousScripts: true
66  }});
67
68  // Optionally start CSS coverage
69  await page.coverage.startCSSCoverage({{
70    resetOnNavigation: false
71  }});
72}});
73
74test.afterEach(async ({{ page }}) => {{
75  // Collect coverage after each test
76  const [jsCoverage, cssCoverage] = await Promise.all([
77    page.coverage.stopJSCoverage(),
78    page.coverage.stopCSSCoverage()
79  ]);
80
81  // Store coverage data
82  allCoverage.push(...jsCoverage);
83
84  console.log(`Collected coverage for ${{jsCoverage.length}} JS files`);
85}});
86
87// Save coverage on process exit
88process.on('exit', () => {{
89  saveCoverage();
90}});
91
92process.on('SIGINT', () => {{
93  saveCoverage();
94  process.exit(0);
95}});
96
97function saveCoverage() {{
98  if (allCoverage.length === 0) {{
99    console.log('No coverage data collected');
100    return;
101  }}
102
103  // Convert to Istanbul/NYC format
104  const istanbulCoverage = convertToIstanbul(allCoverage);
105
106  // Save as JSON
107  const outputFile = path.join(coverageDir, 'coverage-playwright.json');
108  fs.writeFileSync(outputFile, JSON.stringify(istanbulCoverage, null, 2));
109
110  console.log(`✓ Coverage saved to ${{outputFile}}`);
111  console.log(`  Total files: ${{Object.keys(istanbulCoverage).length}}`);
112
113  // Also save raw V8 format
114  const rawFile = path.join(coverageDir, 'coverage-raw-v8.json');
115  fs.writeFileSync(rawFile, JSON.stringify(allCoverage, null, 2));
116}}
117
118function convertToIstanbul(v8Coverage) {{
119  // Convert V8 coverage format to Istanbul format
120  // This is a simplified conversion - for production use v8-to-istanbul package
121
122  const istanbul = {{}};
123
124  for (const entry of v8Coverage) {{
125    const url = entry.url;
126
127    // Skip non-file URLs
128    if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('file://')) {{
129      continue;
130    }}
131
132    // Create Istanbul format structure
133    istanbul[url] = {{
134      path: url,
135      statementMap: {{}},
136      fnMap: {{}},
137      branchMap: {{}},
138      s: {{}},
139      f: {{}},
140      b: {{}},
141      _coverageSchema: '1a1c01bbd47fc00a2c39e90264f33305004495a9',
142      hash: 'placeholder'
143    }};
144
145    // Process ranges
146    let statementId = 0;
147    for (const func of entry.functions || []) {{
148      for (const range of func.ranges || []) {{
149        statementId++;
150        istanbul[url].statementMap[statementId] = {{
151          start: {{ line: 0, column: range.startOffset }},
152          end: {{ line: 0, column: range.endOffset }}
153        }};
154        istanbul[url].s[statementId] = range.count;
155      }}
156    }}
157  }}
158
159  return istanbul;
160}}
161
162// Export for use in tests
163module.exports = {{ saveCoverage, convertToIstanbul }};
164"#,
165            self.output_dir.display()
166        );
167
168        fs::write(&script_path, script_content)
169            .map_err(|e| format!("Failed to write Playwright wrapper: {}", e))?;
170
171        Ok(script_path.to_string_lossy().to_string())
172    }
173
174    /// Generate Puppeteer coverage wrapper
175    fn generate_puppeteer_script(&self, _test_command: &str) -> Result<String, String> {
176        let script_path = self.output_dir.join("puppeteer-coverage-wrapper.js");
177
178        let script_content = format!(
179            r#"/**
180 * Puppeteer Coverage Wrapper
181 * Auto-generated by Testlint SDK
182 *
183 * This script wraps Puppeteer tests to collect JavaScript coverage
184 * from browser execution.
185 */
186
187const puppeteer = require('puppeteer');
188const fs = require('fs');
189const path = require('path');
190
191// Coverage output directory
192const coverageDir = '{}';
193
194// Ensure coverage directory exists
195if (!fs.existsSync(coverageDir)) {{
196  fs.mkdirSync(coverageDir, {{ recursive: true }});
197}}
198
199class CoverageCollector {{
200  constructor() {{
201    this.allCoverage = [];
202    this.browser = null;
203    this.page = null;
204  }}
205
206  async launch(options = {{}}) {{
207    this.browser = await puppeteer.launch({{
208      headless: options.headless !== false,
209      ...options
210    }});
211
212    this.page = await this.browser.newPage();
213
214    // Start coverage
215    await this.page.coverage.startJSCoverage({{
216      resetOnNavigation: false,
217      reportAnonymousScripts: true
218    }});
219
220    await this.page.coverage.startCSSCoverage({{
221      resetOnNavigation: false
222    }});
223
224    return {{ browser: this.browser, page: this.page }};
225  }}
226
227  async collect() {{
228    if (!this.page) {{
229      throw new Error('No page initialized. Call launch() first.');
230    }}
231
232    const [jsCoverage, cssCoverage] = await Promise.all([
233      this.page.coverage.stopJSCoverage(),
234      this.page.coverage.stopCSSCoverage()
235    ]);
236
237    this.allCoverage.push(...jsCoverage);
238
239    console.log(`Collected coverage for ${{jsCoverage.length}} JS files`);
240
241    return {{ js: jsCoverage, css: cssCoverage }};
242  }}
243
244  async save() {{
245    if (this.allCoverage.length === 0) {{
246      console.log('No coverage data collected');
247      return;
248    }}
249
250    // Convert to Istanbul format
251    const istanbulCoverage = this.convertToIstanbul(this.allCoverage);
252
253    // Save as JSON
254    const outputFile = path.join(coverageDir, 'coverage-puppeteer.json');
255    fs.writeFileSync(outputFile, JSON.stringify(istanbulCoverage, null, 2));
256
257    console.log(`✓ Coverage saved to ${{outputFile}}`);
258    console.log(`  Total files: ${{Object.keys(istanbulCoverage).length}}`);
259
260    // Also save raw V8 format
261    const rawFile = path.join(coverageDir, 'coverage-raw-v8.json');
262    fs.writeFileSync(rawFile, JSON.stringify(this.allCoverage, null, 2));
263
264    return outputFile;
265  }}
266
267  async close() {{
268    if (this.browser) {{
269      await this.browser.close();
270    }}
271  }}
272
273  convertToIstanbul(v8Coverage) {{
274    // Convert V8 coverage format to Istanbul format
275    const istanbul = {{}};
276
277    for (const entry of v8Coverage) {{
278      const url = entry.url;
279
280      // Skip non-file URLs
281      if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('file://')) {{
282        continue;
283      }}
284
285      // Create Istanbul format structure
286      istanbul[url] = {{
287        path: url,
288        statementMap: {{}},
289        fnMap: {{}},
290        branchMap: {{}},
291        s: {{}},
292        f: {{}},
293        b: {{}},
294        _coverageSchema: '1a1c01bbd47fc00a2c39e90264f33305004495a9',
295        hash: 'placeholder'
296      }};
297
298      // Process ranges
299      let statementId = 0;
300      for (const func of entry.functions || []) {{
301        for (const range of func.ranges || []) {{
302          statementId++;
303          istanbul[url].statementMap[statementId] = {{
304            start: {{ line: 0, column: range.startOffset }},
305            end: {{ line: 0, column: range.endOffset }}
306          }};
307          istanbul[url].s[statementId] = range.count;
308        }}
309      }}
310    }}
311
312    return istanbul;
313  }}
314}}
315
316// Create global instance
317global.coverageCollector = new CoverageCollector();
318
319// Auto-save on exit
320process.on('exit', () => {{
321  if (global.coverageCollector.allCoverage.length > 0) {{
322    global.coverageCollector.save();
323  }}
324}});
325
326process.on('SIGINT', async () => {{
327  await global.coverageCollector.save();
328  await global.coverageCollector.close();
329  process.exit(0);
330}});
331
332module.exports = CoverageCollector;
333
334// Example usage:
335// const CoverageCollector = require('./puppeteer-coverage-wrapper');
336// const collector = new CoverageCollector();
337// const {{ browser, page }} = await collector.launch();
338// await page.goto('https://example.com');
339// // ... run tests ...
340// await collector.collect();
341// await collector.save();
342// await collector.close();
343"#,
344            self.output_dir.display()
345        );
346
347        fs::write(&script_path, script_content)
348            .map_err(|e| format!("Failed to write Puppeteer wrapper: {}", e))?;
349
350        Ok(script_path.to_string_lossy().to_string())
351    }
352
353    /// Install v8-to-istanbul for better coverage conversion
354    pub fn ensure_dependencies(&self) -> Result<(), String> {
355        println!("📦 Checking browser coverage dependencies...");
356
357        // Check if v8-to-istanbul is installed
358        let check = Command::new("npm")
359            .args(["list", "v8-to-istanbul"])
360            .output();
361
362        if check.is_err() || !check.unwrap().status.success() {
363            println!("📥 Installing v8-to-istanbul for coverage conversion...");
364
365            let output = Command::new("npm")
366                .args(["install", "--save-dev", "v8-to-istanbul"])
367                .output()
368                .map_err(|e| format!("Failed to install v8-to-istanbul: {}", e))?;
369
370            if !output.status.success() {
371                return Err(format!(
372                    "npm install failed: {}",
373                    String::from_utf8_lossy(&output.stderr)
374                ));
375            }
376
377            println!("✓ v8-to-istanbul installed");
378        }
379
380        Ok(())
381    }
382
383    /// Generate example test file
384    pub fn generate_example_test(&self) -> Result<String, String> {
385        let example_path = match self.tool {
386            BrowserTool::Playwright => self.output_dir.join("example.playwright.spec.js"),
387            BrowserTool::Puppeteer => self.output_dir.join("example.puppeteer.test.js"),
388        };
389
390        let example_content = match self.tool {
391            BrowserTool::Playwright => {
392                r#"// Example Playwright test with coverage
393const { test, expect } = require('@playwright/test');
394
395test.describe('Example Test Suite', () => {
396  test('should collect coverage', async ({ page }) => {
397    await page.goto('https://example.com');
398
399    const title = await page.title();
400    expect(title).toBeTruthy();
401
402    // Your test code here
403    // Coverage is automatically collected via the wrapper
404  });
405
406  test('another test with coverage', async ({ page }) => {
407    await page.goto('https://example.com');
408
409    await page.click('a');
410    // More interactions...
411  });
412});
413"#
414            }
415            BrowserTool::Puppeteer => {
416                r#"// Example Puppeteer test with coverage
417const CoverageCollector = require('./puppeteer-coverage-wrapper');
418
419describe('Example Test Suite', () => {
420  let collector;
421  let browser;
422  let page;
423
424  beforeAll(async () => {
425    collector = new CoverageCollector();
426    const launched = await collector.launch({ headless: true });
427    browser = launched.browser;
428    page = launched.page;
429  });
430
431  afterAll(async () => {
432    await collector.collect();
433    await collector.save();
434    await collector.close();
435  });
436
437  test('should collect coverage', async () => {
438    await page.goto('https://example.com');
439
440    const title = await page.title();
441    expect(title).toBeTruthy();
442
443    // Your test code here
444  });
445
446  test('another test with coverage', async () => {
447    await page.goto('https://example.com');
448
449    await page.click('a');
450    // More interactions...
451  });
452});
453"#
454            }
455        };
456
457        fs::write(&example_path, example_content)
458            .map_err(|e| format!("Failed to write example test: {}", e))?;
459
460        Ok(example_path.to_string_lossy().to_string())
461    }
462}