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 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 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 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 pub fn ensure_dependencies(&self) -> Result<(), String> {
355 println!("📦 Checking browser coverage dependencies...");
356
357 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 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}