cbrowser 6.3.2 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.js CHANGED
@@ -27,6 +27,20 @@ exports.formatRepairReport = formatRepairReport;
27
27
  exports.exportRepairedTest = exportRepairedTest;
28
28
  exports.detectFlakyTests = detectFlakyTests;
29
29
  exports.formatFlakyTestReport = formatFlakyTestReport;
30
+ exports.capturePerformanceBaseline = capturePerformanceBaseline;
31
+ exports.listPerformanceBaselines = listPerformanceBaselines;
32
+ exports.loadPerformanceBaseline = loadPerformanceBaseline;
33
+ exports.deletePerformanceBaseline = deletePerformanceBaseline;
34
+ exports.detectPerformanceRegression = detectPerformanceRegression;
35
+ exports.formatPerformanceRegressionReport = formatPerformanceRegressionReport;
36
+ exports.parseTestFilesForCoverage = parseTestFilesForCoverage;
37
+ exports.parseSitemap = parseSitemap;
38
+ exports.crawlSiteForCoverage = crawlSiteForCoverage;
39
+ exports.identifyCoverageGaps = identifyCoverageGaps;
40
+ exports.calculateCoverageAnalysis = calculateCoverageAnalysis;
41
+ exports.generateCoverageMap = generateCoverageMap;
42
+ exports.formatCoverageReport = formatCoverageReport;
43
+ exports.generateCoverageHtmlReport = generateCoverageHtmlReport;
30
44
  const playwright_1 = require("playwright");
31
45
  const fs_1 = require("fs");
32
46
  const path_1 = require("path");
@@ -4141,4 +4155,1061 @@ function formatFlakyTestReport(result) {
4141
4155
  lines.push("");
4142
4156
  return lines.join("\n");
4143
4157
  }
4158
+ // ============================================================================
4159
+ // Performance Regression Detection (v6.4.0)
4160
+ // ============================================================================
4161
+ const DEFAULT_REGRESSION_THRESHOLDS = {
4162
+ lcp: 20, // 20% increase
4163
+ fid: 50, // 50% increase
4164
+ cls: 0.1, // Absolute increase of 0.1
4165
+ fcp: 20, // 20% increase
4166
+ ttfb: 30, // 30% increase
4167
+ tti: 25, // 25% increase
4168
+ tbt: 50, // 50% increase
4169
+ transferSize: 25, // 25% increase
4170
+ };
4171
+ /**
4172
+ * Capture a performance baseline for a URL
4173
+ */
4174
+ async function capturePerformanceBaseline(url, options = {}) {
4175
+ const { runs = 3, name, headless = true, device, throttle } = options;
4176
+ const paths = (0, config_js_1.getPaths)();
4177
+ (0, config_js_1.ensureDirectories)();
4178
+ const browser = new CBrowser({ headless });
4179
+ const allMetrics = [];
4180
+ try {
4181
+ for (let i = 0; i < runs; i++) {
4182
+ await browser.navigate(url);
4183
+ // Wait for page to stabilize
4184
+ await new Promise((r) => setTimeout(r, 2000));
4185
+ const metrics = await browser.getPerformanceMetrics();
4186
+ allMetrics.push(metrics);
4187
+ // Brief pause between runs
4188
+ if (i < runs - 1) {
4189
+ await new Promise((r) => setTimeout(r, 1000));
4190
+ }
4191
+ }
4192
+ }
4193
+ finally {
4194
+ await browser.close();
4195
+ }
4196
+ // Average the metrics
4197
+ const avgMetrics = {};
4198
+ const numericMetricKeys = [
4199
+ "lcp", "fid", "cls", "fcp", "ttfb", "tti", "tbt",
4200
+ "domContentLoaded", "load", "resourceCount", "transferSize"
4201
+ ];
4202
+ for (const key of numericMetricKeys) {
4203
+ const values = allMetrics
4204
+ .map((m) => m[key])
4205
+ .filter((v) => v !== undefined && v !== null);
4206
+ if (values.length > 0) {
4207
+ avgMetrics[key] = values.reduce((a, b) => a + b, 0) / values.length;
4208
+ }
4209
+ }
4210
+ // Determine ratings
4211
+ if (avgMetrics.lcp !== undefined) {
4212
+ avgMetrics.lcpRating = avgMetrics.lcp <= 2500 ? "good" : avgMetrics.lcp <= 4000 ? "needs-improvement" : "poor";
4213
+ }
4214
+ if (avgMetrics.cls !== undefined) {
4215
+ avgMetrics.clsRating = avgMetrics.cls <= 0.1 ? "good" : avgMetrics.cls <= 0.25 ? "needs-improvement" : "poor";
4216
+ }
4217
+ const baseline = {
4218
+ id: `baseline-${Date.now()}`,
4219
+ url,
4220
+ name: name || new URL(url).hostname,
4221
+ timestamp: new Date().toISOString(),
4222
+ metrics: avgMetrics,
4223
+ runsAveraged: runs,
4224
+ environment: {
4225
+ browser: "chromium",
4226
+ viewport: { width: 1280, height: 720 },
4227
+ device,
4228
+ connection: throttle,
4229
+ },
4230
+ };
4231
+ // Save baseline
4232
+ const baselinesDir = (0, path_1.join)(paths.dataDir, "baselines");
4233
+ if (!(0, fs_1.existsSync)(baselinesDir)) {
4234
+ (0, fs_1.mkdirSync)(baselinesDir, { recursive: true });
4235
+ }
4236
+ const baselineFile = (0, path_1.join)(baselinesDir, `${baseline.id}.json`);
4237
+ (0, fs_1.writeFileSync)(baselineFile, JSON.stringify(baseline, null, 2));
4238
+ return baseline;
4239
+ }
4240
+ /**
4241
+ * List all saved performance baselines
4242
+ */
4243
+ function listPerformanceBaselines() {
4244
+ const paths = (0, config_js_1.getPaths)();
4245
+ const baselinesDir = (0, path_1.join)(paths.dataDir, "baselines");
4246
+ if (!(0, fs_1.existsSync)(baselinesDir)) {
4247
+ return [];
4248
+ }
4249
+ const files = (0, fs_1.readdirSync)(baselinesDir).filter((f) => f.endsWith(".json"));
4250
+ const baselines = [];
4251
+ for (const file of files) {
4252
+ try {
4253
+ const content = (0, fs_1.readFileSync)((0, path_1.join)(baselinesDir, file), "utf-8");
4254
+ baselines.push(JSON.parse(content));
4255
+ }
4256
+ catch {
4257
+ // Skip invalid files
4258
+ }
4259
+ }
4260
+ return baselines.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
4261
+ }
4262
+ /**
4263
+ * Load a specific baseline by ID or name
4264
+ */
4265
+ function loadPerformanceBaseline(idOrName) {
4266
+ const baselines = listPerformanceBaselines();
4267
+ // Try exact ID match first
4268
+ let baseline = baselines.find((b) => b.id === idOrName);
4269
+ if (baseline)
4270
+ return baseline;
4271
+ // Try name match
4272
+ baseline = baselines.find((b) => b.name === idOrName);
4273
+ if (baseline)
4274
+ return baseline;
4275
+ // Try URL match
4276
+ baseline = baselines.find((b) => b.url.includes(idOrName));
4277
+ return baseline || null;
4278
+ }
4279
+ /**
4280
+ * Delete a performance baseline
4281
+ */
4282
+ function deletePerformanceBaseline(idOrName) {
4283
+ const baseline = loadPerformanceBaseline(idOrName);
4284
+ if (!baseline)
4285
+ return false;
4286
+ const paths = (0, config_js_1.getPaths)();
4287
+ const baselineFile = (0, path_1.join)(paths.dataDir, "baselines", `${baseline.id}.json`);
4288
+ if ((0, fs_1.existsSync)(baselineFile)) {
4289
+ (0, fs_1.unlinkSync)(baselineFile);
4290
+ return true;
4291
+ }
4292
+ return false;
4293
+ }
4294
+ /**
4295
+ * Compare current performance against a baseline
4296
+ */
4297
+ async function detectPerformanceRegression(url, baselineIdOrName, options = {}) {
4298
+ const { thresholds = DEFAULT_REGRESSION_THRESHOLDS, headless = true } = options;
4299
+ const startTime = Date.now();
4300
+ // Load baseline
4301
+ const baseline = loadPerformanceBaseline(baselineIdOrName);
4302
+ if (!baseline) {
4303
+ throw new Error(`Baseline not found: ${baselineIdOrName}`);
4304
+ }
4305
+ // Capture current metrics
4306
+ const browser = new CBrowser({ headless });
4307
+ let currentMetrics;
4308
+ try {
4309
+ await browser.navigate(url);
4310
+ await new Promise((r) => setTimeout(r, 2000));
4311
+ currentMetrics = await browser.getPerformanceMetrics();
4312
+ }
4313
+ finally {
4314
+ await browser.close();
4315
+ }
4316
+ // Compare metrics
4317
+ const comparisons = [];
4318
+ const regressions = [];
4319
+ const metricsToCompare = [
4320
+ "lcp", "fid", "cls", "fcp", "ttfb", "tti", "tbt", "transferSize"
4321
+ ];
4322
+ for (const metric of metricsToCompare) {
4323
+ const baselineValue = baseline.metrics[metric];
4324
+ const currentValue = currentMetrics[metric];
4325
+ if (baselineValue === undefined || currentValue === undefined)
4326
+ continue;
4327
+ const change = currentValue - baselineValue;
4328
+ const changePercent = baselineValue > 0 ? (change / baselineValue) * 100 : 0;
4329
+ // Determine threshold and if it's a regression
4330
+ const threshold = thresholds[metric] || 20;
4331
+ const isClsMetric = metric === "cls";
4332
+ // For CLS, threshold is absolute; for others, it's percentage
4333
+ const exceedsThreshold = isClsMetric
4334
+ ? change > threshold
4335
+ : changePercent > threshold;
4336
+ let status = "stable";
4337
+ let severity = "warning";
4338
+ if (changePercent < -10 || (isClsMetric && change < -0.05)) {
4339
+ status = "improved";
4340
+ }
4341
+ else if (exceedsThreshold) {
4342
+ if (isClsMetric ? change > threshold * 2 : changePercent > threshold * 2) {
4343
+ status = "critical";
4344
+ severity = "critical";
4345
+ }
4346
+ else {
4347
+ status = "regression";
4348
+ severity = "regression";
4349
+ }
4350
+ }
4351
+ else if (changePercent > 5 || (isClsMetric && change > 0.02)) {
4352
+ status = "warning";
4353
+ }
4354
+ const comparison = {
4355
+ metric,
4356
+ baseline: baselineValue,
4357
+ current: currentValue,
4358
+ change,
4359
+ changePercent,
4360
+ isRegression: status === "regression" || status === "critical",
4361
+ isImprovement: status === "improved",
4362
+ status,
4363
+ };
4364
+ comparisons.push(comparison);
4365
+ if (comparison.isRegression) {
4366
+ regressions.push({
4367
+ metric,
4368
+ baselineValue,
4369
+ currentValue,
4370
+ change,
4371
+ changePercent,
4372
+ threshold,
4373
+ severity,
4374
+ });
4375
+ }
4376
+ }
4377
+ // Calculate summary
4378
+ const improved = comparisons.filter((c) => c.isImprovement).length;
4379
+ const regressed = comparisons.filter((c) => c.status === "regression").length;
4380
+ const critical = comparisons.filter((c) => c.status === "critical").length;
4381
+ const stable = comparisons.filter((c) => c.status === "stable" || c.status === "warning").length;
4382
+ const overallChange = comparisons.length > 0
4383
+ ? comparisons.reduce((sum, c) => sum + c.changePercent, 0) / comparisons.length
4384
+ : 0;
4385
+ return {
4386
+ url,
4387
+ baseline,
4388
+ currentMetrics,
4389
+ timestamp: new Date().toISOString(),
4390
+ duration: Date.now() - startTime,
4391
+ comparisons,
4392
+ regressions,
4393
+ passed: regressions.length === 0,
4394
+ summary: {
4395
+ totalMetrics: comparisons.length,
4396
+ improved,
4397
+ stable,
4398
+ regressed,
4399
+ critical,
4400
+ overallChange,
4401
+ },
4402
+ };
4403
+ }
4404
+ /**
4405
+ * Format a performance regression report
4406
+ */
4407
+ function formatPerformanceRegressionReport(result) {
4408
+ const lines = [];
4409
+ lines.push("🔍 PERFORMANCE REGRESSION REPORT");
4410
+ lines.push("═".repeat(60));
4411
+ lines.push("");
4412
+ lines.push(`📍 URL: ${result.url}`);
4413
+ lines.push(`📊 Baseline: ${result.baseline.name} (${new Date(result.baseline.timestamp).toLocaleDateString()})`);
4414
+ lines.push(`⏱️ Duration: ${(result.duration / 1000).toFixed(1)}s`);
4415
+ lines.push("");
4416
+ // Overall result
4417
+ if (result.passed) {
4418
+ lines.push("✅ PASSED - No performance regressions detected");
4419
+ }
4420
+ else {
4421
+ lines.push(`❌ FAILED - ${result.regressions.length} regression(s) detected`);
4422
+ }
4423
+ lines.push("");
4424
+ // Detailed comparisons
4425
+ lines.push("─".repeat(60));
4426
+ lines.push("METRIC COMPARISON");
4427
+ lines.push("─".repeat(60));
4428
+ lines.push("");
4429
+ const metricNames = {
4430
+ lcp: "LCP (Largest Contentful Paint)",
4431
+ fid: "FID (First Input Delay)",
4432
+ cls: "CLS (Cumulative Layout Shift)",
4433
+ fcp: "FCP (First Contentful Paint)",
4434
+ ttfb: "TTFB (Time to First Byte)",
4435
+ tti: "TTI (Time to Interactive)",
4436
+ tbt: "TBT (Total Blocking Time)",
4437
+ transferSize: "Transfer Size",
4438
+ };
4439
+ for (const comp of result.comparisons) {
4440
+ const name = metricNames[comp.metric] || comp.metric;
4441
+ const icon = comp.isImprovement ? "✅" :
4442
+ comp.status === "critical" ? "🔴" :
4443
+ comp.status === "regression" ? "❌" :
4444
+ comp.status === "warning" ? "⚠️" : "✓";
4445
+ const unit = comp.metric === "cls" ? "" :
4446
+ comp.metric === "transferSize" ? " KB" : " ms";
4447
+ const baseVal = comp.metric === "transferSize"
4448
+ ? (comp.baseline / 1024).toFixed(1)
4449
+ : comp.baseline.toFixed(1);
4450
+ const currVal = comp.metric === "transferSize"
4451
+ ? (comp.current / 1024).toFixed(1)
4452
+ : comp.current.toFixed(1);
4453
+ const changeStr = comp.changePercent >= 0
4454
+ ? `+${comp.changePercent.toFixed(1)}%`
4455
+ : `${comp.changePercent.toFixed(1)}%`;
4456
+ lines.push(`${icon} ${name}`);
4457
+ lines.push(` Baseline: ${baseVal}${unit} → Current: ${currVal}${unit} (${changeStr})`);
4458
+ lines.push("");
4459
+ }
4460
+ // Summary
4461
+ lines.push("─".repeat(60));
4462
+ lines.push("SUMMARY");
4463
+ lines.push("─".repeat(60));
4464
+ lines.push("");
4465
+ lines.push(` Total Metrics: ${result.summary.totalMetrics}`);
4466
+ lines.push(` ✅ Improved: ${result.summary.improved}`);
4467
+ lines.push(` ✓ Stable: ${result.summary.stable}`);
4468
+ lines.push(` ❌ Regressed: ${result.summary.regressed}`);
4469
+ lines.push(` 🔴 Critical: ${result.summary.critical}`);
4470
+ lines.push(` 📊 Overall Change: ${result.summary.overallChange >= 0 ? "+" : ""}${result.summary.overallChange.toFixed(1)}%`);
4471
+ lines.push("");
4472
+ // Recommendations if regressions found
4473
+ if (result.regressions.length > 0) {
4474
+ lines.push("─".repeat(60));
4475
+ lines.push("💡 RECOMMENDATIONS");
4476
+ lines.push("─".repeat(60));
4477
+ lines.push("");
4478
+ for (const reg of result.regressions) {
4479
+ const name = metricNames[reg.metric] || reg.metric;
4480
+ lines.push(`⚠️ ${name}:`);
4481
+ switch (reg.metric) {
4482
+ case "lcp":
4483
+ lines.push(" - Optimize largest content element (images, videos)");
4484
+ lines.push(" - Consider lazy loading below-fold content");
4485
+ lines.push(" - Improve server response times");
4486
+ break;
4487
+ case "cls":
4488
+ lines.push(" - Set explicit dimensions on images/embeds");
4489
+ lines.push(" - Avoid inserting content above existing content");
4490
+ lines.push(" - Reserve space for dynamic content");
4491
+ break;
4492
+ case "fcp":
4493
+ case "ttfb":
4494
+ lines.push(" - Optimize server response time");
4495
+ lines.push(" - Use CDN for static assets");
4496
+ lines.push(" - Enable compression (gzip/brotli)");
4497
+ break;
4498
+ case "tbt":
4499
+ case "tti":
4500
+ lines.push(" - Split long JavaScript tasks");
4501
+ lines.push(" - Defer non-critical JavaScript");
4502
+ lines.push(" - Remove unused code");
4503
+ break;
4504
+ case "transferSize":
4505
+ lines.push(" - Compress and optimize assets");
4506
+ lines.push(" - Remove unused CSS/JavaScript");
4507
+ lines.push(" - Optimize images (WebP, proper sizing)");
4508
+ break;
4509
+ default:
4510
+ lines.push(" - Review recent changes for performance impact");
4511
+ }
4512
+ lines.push("");
4513
+ }
4514
+ }
4515
+ return lines.join("\n");
4516
+ }
4517
+ // ============================================================================
4518
+ // Test Coverage Map (v6.5.0)
4519
+ // ============================================================================
4520
+ /**
4521
+ * Parse test files to extract tested URLs and actions
4522
+ */
4523
+ function parseTestFilesForCoverage(testFiles) {
4524
+ const pageMap = new Map();
4525
+ for (const testFile of testFiles) {
4526
+ if (!(0, fs_1.existsSync)(testFile))
4527
+ continue;
4528
+ const content = (0, fs_1.readFileSync)(testFile, "utf-8");
4529
+ const lines = content.split("\n");
4530
+ let currentUrl = null;
4531
+ let lineNumber = 0;
4532
+ for (const line of lines) {
4533
+ lineNumber++;
4534
+ const trimmed = line.trim().toLowerCase();
4535
+ // Skip comments and empty lines
4536
+ if (trimmed.startsWith("#") || !trimmed)
4537
+ continue;
4538
+ // Detect navigation
4539
+ const navMatch = line.match(/(?:go to|navigate to|open|visit)\s+["']?([^"'\s]+)["']?/i);
4540
+ if (navMatch) {
4541
+ currentUrl = navMatch[1];
4542
+ const path = normalizeUrlToPath(currentUrl);
4543
+ if (!pageMap.has(path)) {
4544
+ pageMap.set(path, {
4545
+ url: currentUrl,
4546
+ path,
4547
+ testFiles: [],
4548
+ actions: [],
4549
+ testCount: 0,
4550
+ coverageScore: 0,
4551
+ });
4552
+ }
4553
+ const page = pageMap.get(path);
4554
+ if (!page.testFiles.includes(testFile)) {
4555
+ page.testFiles.push(testFile);
4556
+ page.testCount++;
4557
+ }
4558
+ page.actions.push({
4559
+ type: "navigate",
4560
+ target: currentUrl,
4561
+ testFile,
4562
+ lineNumber,
4563
+ });
4564
+ }
4565
+ // Detect click actions
4566
+ const clickMatch = line.match(/click\s+(?:on\s+)?(?:the\s+)?["']?([^"'\n]+)["']?/i);
4567
+ if (clickMatch && currentUrl) {
4568
+ const path = normalizeUrlToPath(currentUrl);
4569
+ const page = pageMap.get(path);
4570
+ if (page) {
4571
+ page.actions.push({
4572
+ type: "click",
4573
+ target: clickMatch[1].trim(),
4574
+ testFile,
4575
+ lineNumber,
4576
+ });
4577
+ }
4578
+ }
4579
+ // Detect fill/type actions
4580
+ const fillMatch = line.match(/(?:type|fill|enter)\s+["']([^"']+)["']\s+(?:in|into)\s+(?:the\s+)?["']?([^"'\n]+)["']?/i);
4581
+ if (fillMatch && currentUrl) {
4582
+ const path = normalizeUrlToPath(currentUrl);
4583
+ const page = pageMap.get(path);
4584
+ if (page) {
4585
+ page.actions.push({
4586
+ type: "fill",
4587
+ target: fillMatch[2].trim(),
4588
+ value: fillMatch[1],
4589
+ testFile,
4590
+ lineNumber,
4591
+ });
4592
+ }
4593
+ }
4594
+ // Detect verify actions
4595
+ const verifyMatch = line.match(/(?:verify|assert|check|expect|should)\s+(.+)/i);
4596
+ if (verifyMatch && currentUrl) {
4597
+ const path = normalizeUrlToPath(currentUrl);
4598
+ const page = pageMap.get(path);
4599
+ if (page) {
4600
+ page.actions.push({
4601
+ type: "verify",
4602
+ target: verifyMatch[1].trim(),
4603
+ testFile,
4604
+ lineNumber,
4605
+ });
4606
+ }
4607
+ }
4608
+ // Detect wait actions
4609
+ const waitMatch = line.match(/wait\s+(?:for\s+)?(.+)/i);
4610
+ if (waitMatch && currentUrl) {
4611
+ const path = normalizeUrlToPath(currentUrl);
4612
+ const page = pageMap.get(path);
4613
+ if (page) {
4614
+ page.actions.push({
4615
+ type: "wait",
4616
+ target: waitMatch[1].trim(),
4617
+ testFile,
4618
+ lineNumber,
4619
+ });
4620
+ }
4621
+ }
4622
+ }
4623
+ }
4624
+ // Calculate coverage scores
4625
+ for (const page of pageMap.values()) {
4626
+ const hasClicks = page.actions.some(a => a.type === "click");
4627
+ const hasFills = page.actions.some(a => a.type === "fill");
4628
+ const hasVerifies = page.actions.some(a => a.type === "verify");
4629
+ let score = 20; // Base score for visiting
4630
+ if (hasClicks)
4631
+ score += 25;
4632
+ if (hasFills)
4633
+ score += 25;
4634
+ if (hasVerifies)
4635
+ score += 30;
4636
+ page.coverageScore = Math.min(100, score);
4637
+ }
4638
+ return Array.from(pageMap.values());
4639
+ }
4640
+ /**
4641
+ * Normalize URL to a path for comparison
4642
+ */
4643
+ function normalizeUrlToPath(url) {
4644
+ try {
4645
+ const parsed = new URL(url);
4646
+ return parsed.pathname.replace(/\/$/, "") || "/";
4647
+ }
4648
+ catch {
4649
+ // Not a full URL, treat as path
4650
+ return url.replace(/\/$/, "") || "/";
4651
+ }
4652
+ }
4653
+ /**
4654
+ * Fetch and parse sitemap.xml
4655
+ */
4656
+ async function parseSitemap(sitemapUrl) {
4657
+ const pages = [];
4658
+ try {
4659
+ const response = await fetch(sitemapUrl);
4660
+ const xml = await response.text();
4661
+ // Simple XML parsing for sitemap
4662
+ const locMatches = xml.matchAll(/<loc>([^<]+)<\/loc>/g);
4663
+ for (const match of locMatches) {
4664
+ const url = match[1].trim();
4665
+ pages.push({
4666
+ url,
4667
+ path: normalizeUrlToPath(url),
4668
+ source: "sitemap",
4669
+ });
4670
+ }
4671
+ }
4672
+ catch (err) {
4673
+ console.error(`Failed to fetch sitemap: ${err}`);
4674
+ }
4675
+ return pages;
4676
+ }
4677
+ /**
4678
+ * Crawl a site to discover pages
4679
+ */
4680
+ async function crawlSiteForCoverage(startUrl, maxPages = 100, includePattern, excludePattern) {
4681
+ const pages = [];
4682
+ const visited = new Set();
4683
+ const queue = [startUrl];
4684
+ const browser = new CBrowser({
4685
+ headless: true,
4686
+ browser: "chromium",
4687
+ });
4688
+ const baseUrl = new URL(startUrl);
4689
+ const includeRegex = includePattern ? new RegExp(includePattern) : null;
4690
+ const excludeRegex = excludePattern ? new RegExp(excludePattern) : null;
4691
+ try {
4692
+ while (queue.length > 0 && pages.length < maxPages) {
4693
+ const url = queue.shift();
4694
+ const path = normalizeUrlToPath(url);
4695
+ if (visited.has(path))
4696
+ continue;
4697
+ visited.add(path);
4698
+ // Check patterns
4699
+ if (includeRegex && !includeRegex.test(path))
4700
+ continue;
4701
+ if (excludeRegex && excludeRegex.test(path))
4702
+ continue;
4703
+ try {
4704
+ const result = await browser.navigate(url);
4705
+ // Count interactive elements
4706
+ const page = await browser.getPage();
4707
+ const interactiveElements = await page.locator("button, a, input, select, textarea, [onclick], [role='button']").count();
4708
+ const formCount = await page.locator("form").count();
4709
+ // Get outbound links
4710
+ const links = await page.locator("a[href]").evaluateAll((els) => els.map(el => el.href).filter(href => href && !href.startsWith("javascript:")));
4711
+ const sitePage = {
4712
+ url,
4713
+ path,
4714
+ title: result.title,
4715
+ source: pages.length === 0 ? "crawl" : "link",
4716
+ status: 200,
4717
+ outboundLinks: links,
4718
+ interactiveElements,
4719
+ formCount,
4720
+ };
4721
+ pages.push(sitePage);
4722
+ // Add internal links to queue
4723
+ for (const link of links) {
4724
+ try {
4725
+ const linkUrl = new URL(link);
4726
+ if (linkUrl.hostname === baseUrl.hostname && !visited.has(normalizeUrlToPath(link))) {
4727
+ queue.push(link);
4728
+ }
4729
+ }
4730
+ catch {
4731
+ // Invalid URL, skip
4732
+ }
4733
+ }
4734
+ }
4735
+ catch (err) {
4736
+ // Page failed to load
4737
+ pages.push({
4738
+ url,
4739
+ path,
4740
+ source: "link",
4741
+ status: 0,
4742
+ });
4743
+ }
4744
+ }
4745
+ }
4746
+ finally {
4747
+ await browser.close();
4748
+ }
4749
+ return pages;
4750
+ }
4751
+ /**
4752
+ * Identify coverage gaps
4753
+ */
4754
+ function identifyCoverageGaps(sitePages, testedPages, minCoverage = 50) {
4755
+ const gaps = [];
4756
+ const testedPaths = new Set(testedPages.map(p => p.path));
4757
+ for (const sitePage of sitePages) {
4758
+ const testedPage = testedPages.find(p => p.path === sitePage.path);
4759
+ // Completely untested
4760
+ if (!testedPage) {
4761
+ const priority = determinePriority(sitePage);
4762
+ gaps.push({
4763
+ page: sitePage,
4764
+ reason: "untested",
4765
+ priority,
4766
+ suggestedTests: generateSuggestedTests(sitePage),
4767
+ similarTestedPages: findSimilarTestedPages(sitePage.path, testedPages),
4768
+ });
4769
+ continue;
4770
+ }
4771
+ // Low coverage
4772
+ if (testedPage.coverageScore < minCoverage) {
4773
+ gaps.push({
4774
+ page: sitePage,
4775
+ reason: "low-coverage",
4776
+ priority: "medium",
4777
+ suggestedTests: generateSuggestedTests(sitePage, testedPage),
4778
+ });
4779
+ continue;
4780
+ }
4781
+ // No interactions tested
4782
+ const hasInteractions = testedPage.actions.some(a => a.type === "click" || a.type === "fill");
4783
+ if (!hasInteractions && sitePage.interactiveElements && sitePage.interactiveElements > 5) {
4784
+ gaps.push({
4785
+ page: sitePage,
4786
+ reason: "no-interactions",
4787
+ priority: "low",
4788
+ suggestedTests: [`Test interactive elements on ${sitePage.path}`],
4789
+ });
4790
+ }
4791
+ // No verifications
4792
+ const hasVerifications = testedPage.actions.some(a => a.type === "verify");
4793
+ if (!hasVerifications) {
4794
+ gaps.push({
4795
+ page: sitePage,
4796
+ reason: "no-verifications",
4797
+ priority: "low",
4798
+ suggestedTests: [`Add assertions to verify ${sitePage.path} content`],
4799
+ });
4800
+ }
4801
+ }
4802
+ // Sort by priority
4803
+ const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
4804
+ gaps.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
4805
+ return gaps;
4806
+ }
4807
+ /**
4808
+ * Determine priority of an untested page
4809
+ */
4810
+ function determinePriority(page) {
4811
+ const path = page.path.toLowerCase();
4812
+ // Critical paths
4813
+ if (path.includes("checkout") || path.includes("payment") || path.includes("login") ||
4814
+ path.includes("register") || path.includes("signup") || path.includes("auth")) {
4815
+ return "critical";
4816
+ }
4817
+ // High priority - user account, settings
4818
+ if (path.includes("account") || path.includes("profile") || path.includes("settings") ||
4819
+ path.includes("dashboard") || path.includes("admin")) {
4820
+ return "high";
4821
+ }
4822
+ // Medium - has forms or many interactive elements
4823
+ if (page.formCount && page.formCount > 0)
4824
+ return "medium";
4825
+ if (page.interactiveElements && page.interactiveElements > 10)
4826
+ return "medium";
4827
+ return "low";
4828
+ }
4829
+ /**
4830
+ * Generate suggested test steps for a page
4831
+ */
4832
+ function generateSuggestedTests(sitePage, existingTests) {
4833
+ const suggestions = [];
4834
+ suggestions.push(`go to ${sitePage.url}`);
4835
+ if (sitePage.formCount && sitePage.formCount > 0) {
4836
+ suggestions.push(`fill form fields with test data`);
4837
+ suggestions.push(`submit form and verify success`);
4838
+ }
4839
+ if (sitePage.interactiveElements && sitePage.interactiveElements > 0) {
4840
+ suggestions.push(`click primary call-to-action`);
4841
+ }
4842
+ suggestions.push(`verify page contains expected content`);
4843
+ suggestions.push(`verify no console errors`);
4844
+ if (existingTests) {
4845
+ // Add specific suggestions based on what's missing
4846
+ const hasClicks = existingTests.actions.some(a => a.type === "click");
4847
+ const hasFills = existingTests.actions.some(a => a.type === "fill");
4848
+ const hasVerifies = existingTests.actions.some(a => a.type === "verify");
4849
+ if (!hasClicks)
4850
+ suggestions.unshift(`# Add click interactions`);
4851
+ if (!hasFills && sitePage.formCount)
4852
+ suggestions.unshift(`# Add form fill tests`);
4853
+ if (!hasVerifies)
4854
+ suggestions.unshift(`# Add verification assertions`);
4855
+ }
4856
+ return suggestions;
4857
+ }
4858
+ /**
4859
+ * Find similar tested pages for reference
4860
+ */
4861
+ function findSimilarTestedPages(path, testedPages) {
4862
+ const segments = path.split("/").filter(Boolean);
4863
+ if (segments.length === 0)
4864
+ return [];
4865
+ const similar = [];
4866
+ const prefix = "/" + segments[0];
4867
+ for (const tested of testedPages) {
4868
+ if (tested.path.startsWith(prefix) && tested.path !== path) {
4869
+ similar.push(tested.path);
4870
+ if (similar.length >= 3)
4871
+ break;
4872
+ }
4873
+ }
4874
+ return similar;
4875
+ }
4876
+ /**
4877
+ * Calculate overall coverage analysis
4878
+ */
4879
+ function calculateCoverageAnalysis(sitePages, testedPages) {
4880
+ const testedPaths = new Set(testedPages.map(p => p.path));
4881
+ // Section coverage
4882
+ const sections = {};
4883
+ for (const page of sitePages) {
4884
+ const segments = page.path.split("/").filter(Boolean);
4885
+ const section = segments.length > 0 ? "/" + segments[0] : "/";
4886
+ if (!sections[section]) {
4887
+ sections[section] = { total: 0, tested: 0 };
4888
+ }
4889
+ sections[section].total++;
4890
+ if (testedPaths.has(page.path)) {
4891
+ sections[section].tested++;
4892
+ }
4893
+ }
4894
+ const sectionCoverage = {};
4895
+ for (const [section, data] of Object.entries(sections)) {
4896
+ sectionCoverage[section] = {
4897
+ ...data,
4898
+ percent: data.total > 0 ? Math.round((data.tested / data.total) * 100) : 0,
4899
+ };
4900
+ }
4901
+ const totalPages = sitePages.length;
4902
+ const testedCount = sitePages.filter(p => testedPaths.has(p.path)).length;
4903
+ return {
4904
+ totalPages,
4905
+ testedPages: testedCount,
4906
+ untestedPages: totalPages - testedCount,
4907
+ coveragePercent: totalPages > 0 ? Math.round((testedCount / totalPages) * 100) : 0,
4908
+ sectionCoverage,
4909
+ };
4910
+ }
4911
+ /**
4912
+ * Generate complete coverage map
4913
+ */
4914
+ async function generateCoverageMap(baseUrl, testFiles, options = {}) {
4915
+ const startTime = Date.now();
4916
+ // Parse test files
4917
+ const testedPages = parseTestFilesForCoverage(testFiles);
4918
+ // Get site pages
4919
+ let sitePages;
4920
+ if (options.sitemapUrl) {
4921
+ sitePages = await parseSitemap(options.sitemapUrl);
4922
+ }
4923
+ else {
4924
+ sitePages = await crawlSiteForCoverage(baseUrl, options.maxPages || 100, options.includePattern, options.excludePattern);
4925
+ }
4926
+ // Identify gaps
4927
+ const gaps = identifyCoverageGaps(sitePages, testedPages, options.minCoverage || 50);
4928
+ // Calculate analysis
4929
+ const analysis = calculateCoverageAnalysis(sitePages, testedPages);
4930
+ // Generate recommendations
4931
+ const recommendations = [];
4932
+ if (analysis.coveragePercent < 50) {
4933
+ recommendations.push("Coverage is below 50% - prioritize testing critical paths");
4934
+ }
4935
+ const criticalGaps = gaps.filter(g => g.priority === "critical");
4936
+ if (criticalGaps.length > 0) {
4937
+ recommendations.push(`${criticalGaps.length} critical pages have no tests (checkout, auth, etc.)`);
4938
+ }
4939
+ const lowCoverageSections = Object.entries(analysis.sectionCoverage)
4940
+ .filter(([_, data]) => data.percent < 30 && data.total > 2)
4941
+ .map(([section]) => section);
4942
+ if (lowCoverageSections.length > 0) {
4943
+ recommendations.push(`Sections with low coverage: ${lowCoverageSections.join(", ")}`);
4944
+ }
4945
+ if (gaps.filter(g => g.reason === "no-verifications").length > 3) {
4946
+ recommendations.push("Many tests lack assertions - add verification steps");
4947
+ }
4948
+ return {
4949
+ baseUrl,
4950
+ timestamp: new Date().toISOString(),
4951
+ duration: Date.now() - startTime,
4952
+ testFiles,
4953
+ sitePages,
4954
+ testedPages,
4955
+ gaps,
4956
+ analysis,
4957
+ recommendations,
4958
+ };
4959
+ }
4960
+ /**
4961
+ * Format coverage map as text report
4962
+ */
4963
+ function formatCoverageReport(result) {
4964
+ const lines = [];
4965
+ lines.push("╔══════════════════════════════════════════════════════════════════════════════╗");
4966
+ lines.push("║ TEST COVERAGE MAP REPORT ║");
4967
+ lines.push("╚══════════════════════════════════════════════════════════════════════════════╝");
4968
+ lines.push("");
4969
+ lines.push(`📊 Site: ${result.baseUrl}`);
4970
+ lines.push(`📅 Generated: ${result.timestamp}`);
4971
+ lines.push(`⏱️ Analysis time: ${(result.duration / 1000).toFixed(1)}s`);
4972
+ lines.push(`📝 Test files analyzed: ${result.testFiles.length}`);
4973
+ lines.push("");
4974
+ // Overall coverage
4975
+ lines.push("═══════════════════════════════════════════════════════════════════════════════");
4976
+ lines.push("📈 OVERALL COVERAGE");
4977
+ lines.push("═══════════════════════════════════════════════════════════════════════════════");
4978
+ lines.push("");
4979
+ const { analysis } = result;
4980
+ const coverageBar = generateCoverageProgressBar(analysis.coveragePercent);
4981
+ lines.push(` Coverage: ${coverageBar} ${analysis.coveragePercent}%`);
4982
+ lines.push("");
4983
+ lines.push(` Total pages: ${analysis.totalPages}`);
4984
+ lines.push(` Tested pages: ${analysis.testedPages}`);
4985
+ lines.push(` Untested pages: ${analysis.untestedPages}`);
4986
+ lines.push("");
4987
+ // Section coverage
4988
+ lines.push("───────────────────────────────────────────────────────────────────────────────");
4989
+ lines.push("📁 COVERAGE BY SECTION");
4990
+ lines.push("───────────────────────────────────────────────────────────────────────────────");
4991
+ lines.push("");
4992
+ const sections = Object.entries(analysis.sectionCoverage)
4993
+ .sort((a, b) => b[1].total - a[1].total);
4994
+ for (const [section, data] of sections) {
4995
+ const bar = generateCoverageProgressBar(data.percent, 20);
4996
+ const status = data.percent >= 70 ? "✅" : data.percent >= 40 ? "⚠️" : "❌";
4997
+ lines.push(` ${status} ${section.padEnd(20)} ${bar} ${data.tested}/${data.total} (${data.percent}%)`);
4998
+ }
4999
+ lines.push("");
5000
+ // Coverage gaps
5001
+ if (result.gaps.length > 0) {
5002
+ lines.push("═══════════════════════════════════════════════════════════════════════════════");
5003
+ lines.push("🕳️ COVERAGE GAPS");
5004
+ lines.push("═══════════════════════════════════════════════════════════════════════════════");
5005
+ lines.push("");
5006
+ const priorityEmoji = { critical: "🚨", high: "🔴", medium: "🟡", low: "🟢" };
5007
+ for (const gap of result.gaps.slice(0, 15)) {
5008
+ const emoji = priorityEmoji[gap.priority];
5009
+ lines.push(` ${emoji} ${gap.page.path}`);
5010
+ lines.push(` Reason: ${gap.reason} | Priority: ${gap.priority}`);
5011
+ if (gap.suggestedTests.length > 0) {
5012
+ lines.push(` Suggested: ${gap.suggestedTests[0]}`);
5013
+ }
5014
+ lines.push("");
5015
+ }
5016
+ if (result.gaps.length > 15) {
5017
+ lines.push(` ... and ${result.gaps.length - 15} more gaps`);
5018
+ lines.push("");
5019
+ }
5020
+ }
5021
+ // Recommendations
5022
+ if (result.recommendations.length > 0) {
5023
+ lines.push("═══════════════════════════════════════════════════════════════════════════════");
5024
+ lines.push("💡 RECOMMENDATIONS");
5025
+ lines.push("═══════════════════════════════════════════════════════════════════════════════");
5026
+ lines.push("");
5027
+ for (const rec of result.recommendations) {
5028
+ lines.push(` ${rec}`);
5029
+ }
5030
+ lines.push("");
5031
+ }
5032
+ // Tested pages summary
5033
+ lines.push("───────────────────────────────────────────────────────────────────────────────");
5034
+ lines.push("✅ TESTED PAGES (Top 10 by coverage)");
5035
+ lines.push("───────────────────────────────────────────────────────────────────────────────");
5036
+ lines.push("");
5037
+ const topTested = [...result.testedPages]
5038
+ .sort((a, b) => b.coverageScore - a.coverageScore)
5039
+ .slice(0, 10);
5040
+ for (const page of topTested) {
5041
+ const bar = generateCoverageProgressBar(page.coverageScore, 15);
5042
+ lines.push(` ${bar} ${page.coverageScore}% ${page.path}`);
5043
+ lines.push(` Actions: ${page.actions.length} | Tests: ${page.testCount}`);
5044
+ }
5045
+ return lines.join("\n");
5046
+ }
5047
+ /**
5048
+ * Generate HTML coverage report
5049
+ */
5050
+ function generateCoverageHtmlReport(result) {
5051
+ const { analysis, gaps, testedPages } = result;
5052
+ const coverageColor = analysis.coveragePercent >= 70 ? "#22c55e" :
5053
+ analysis.coveragePercent >= 40 ? "#eab308" : "#ef4444";
5054
+ return `<!DOCTYPE html>
5055
+ <html lang="en">
5056
+ <head>
5057
+ <meta charset="UTF-8">
5058
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
5059
+ <title>Test Coverage Map - ${result.baseUrl}</title>
5060
+ <style>
5061
+ * { box-sizing: border-box; margin: 0; padding: 0; }
5062
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; padding: 2rem; }
5063
+ .container { max-width: 1200px; margin: 0 auto; }
5064
+ h1 { color: #fff; margin-bottom: 0.5rem; }
5065
+ .subtitle { color: #888; margin-bottom: 2rem; }
5066
+ .card { background: #252540; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; }
5067
+ .card h2 { color: #fff; font-size: 1.1rem; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem; }
5068
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; }
5069
+ .stat { text-align: center; }
5070
+ .stat-value { font-size: 2rem; font-weight: bold; color: ${coverageColor}; }
5071
+ .stat-label { color: #888; font-size: 0.875rem; }
5072
+ .progress-bar { height: 8px; background: #333; border-radius: 4px; overflow: hidden; margin: 1rem 0; }
5073
+ .progress-fill { height: 100%; background: ${coverageColor}; transition: width 0.5s; }
5074
+ .section-list { list-style: none; }
5075
+ .section-item { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid #333; }
5076
+ .section-name { flex: 1; }
5077
+ .section-bar { width: 150px; height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
5078
+ .section-bar-fill { height: 100%; border-radius: 3px; }
5079
+ .section-percent { width: 60px; text-align: right; font-weight: 500; }
5080
+ .gap-list { list-style: none; }
5081
+ .gap-item { padding: 1rem; margin-bottom: 0.75rem; background: #1a1a2e; border-radius: 8px; border-left: 4px solid; }
5082
+ .gap-critical { border-color: #ef4444; }
5083
+ .gap-high { border-color: #f97316; }
5084
+ .gap-medium { border-color: #eab308; }
5085
+ .gap-low { border-color: #22c55e; }
5086
+ .gap-path { font-weight: 600; color: #fff; }
5087
+ .gap-reason { color: #888; font-size: 0.875rem; margin-top: 0.25rem; }
5088
+ .badge { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
5089
+ .badge-critical { background: #ef4444; color: #fff; }
5090
+ .badge-high { background: #f97316; color: #fff; }
5091
+ .badge-medium { background: #eab308; color: #000; }
5092
+ .badge-low { background: #22c55e; color: #fff; }
5093
+ .recommendations { list-style: none; }
5094
+ .recommendations li { padding: 0.75rem; background: #1a1a2e; border-radius: 6px; margin-bottom: 0.5rem; }
5095
+ .page-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1rem; }
5096
+ .page-card { background: #1a1a2e; border-radius: 8px; padding: 1rem; }
5097
+ .page-score { font-size: 1.5rem; font-weight: bold; }
5098
+ .page-path { color: #888; font-size: 0.875rem; word-break: break-all; }
5099
+ </style>
5100
+ </head>
5101
+ <body>
5102
+ <div class="container">
5103
+ <h1>Test Coverage Map</h1>
5104
+ <p class="subtitle">${result.baseUrl} | Generated ${new Date(result.timestamp).toLocaleString()}</p>
5105
+
5106
+ <div class="card">
5107
+ <h2>📊 Overall Coverage</h2>
5108
+ <div class="stats">
5109
+ <div class="stat">
5110
+ <div class="stat-value">${analysis.coveragePercent}%</div>
5111
+ <div class="stat-label">Coverage</div>
5112
+ </div>
5113
+ <div class="stat">
5114
+ <div class="stat-value">${analysis.totalPages}</div>
5115
+ <div class="stat-label">Total Pages</div>
5116
+ </div>
5117
+ <div class="stat">
5118
+ <div class="stat-value">${analysis.testedPages}</div>
5119
+ <div class="stat-label">Tested</div>
5120
+ </div>
5121
+ <div class="stat">
5122
+ <div class="stat-value">${analysis.untestedPages}</div>
5123
+ <div class="stat-label">Untested</div>
5124
+ </div>
5125
+ </div>
5126
+ <div class="progress-bar">
5127
+ <div class="progress-fill" style="width: ${analysis.coveragePercent}%"></div>
5128
+ </div>
5129
+ </div>
5130
+
5131
+ <div class="card">
5132
+ <h2>📁 Coverage by Section</h2>
5133
+ <ul class="section-list">
5134
+ ${Object.entries(analysis.sectionCoverage)
5135
+ .sort((a, b) => b[1].total - a[1].total)
5136
+ .map(([section, data]) => {
5137
+ const color = data.percent >= 70 ? "#22c55e" : data.percent >= 40 ? "#eab308" : "#ef4444";
5138
+ return `
5139
+ <li class="section-item">
5140
+ <span class="section-name">${section}</span>
5141
+ <div class="section-bar">
5142
+ <div class="section-bar-fill" style="width: ${data.percent}%; background: ${color}"></div>
5143
+ </div>
5144
+ <span class="section-percent" style="color: ${color}">${data.percent}%</span>
5145
+ <span style="color: #666; font-size: 0.875rem">${data.tested}/${data.total}</span>
5146
+ </li>
5147
+ `;
5148
+ }).join("")}
5149
+ </ul>
5150
+ </div>
5151
+
5152
+ ${gaps.length > 0 ? `
5153
+ <div class="card">
5154
+ <h2>🕳️ Coverage Gaps (${gaps.length})</h2>
5155
+ <ul class="gap-list">
5156
+ ${gaps.slice(0, 20).map(gap => `
5157
+ <li class="gap-item gap-${gap.priority}">
5158
+ <div style="display: flex; justify-content: space-between; align-items: center;">
5159
+ <span class="gap-path">${gap.page.path}</span>
5160
+ <span class="badge badge-${gap.priority}">${gap.priority}</span>
5161
+ </div>
5162
+ <div class="gap-reason">Reason: ${gap.reason}</div>
5163
+ </li>
5164
+ `).join("")}
5165
+ </ul>
5166
+ ${gaps.length > 20 ? `<p style="color: #666; text-align: center;">...and ${gaps.length - 20} more gaps</p>` : ""}
5167
+ </div>
5168
+ ` : ""}
5169
+
5170
+ ${result.recommendations.length > 0 ? `
5171
+ <div class="card">
5172
+ <h2>💡 Recommendations</h2>
5173
+ <ul class="recommendations">
5174
+ ${result.recommendations.map(rec => `<li>${rec}</li>`).join("")}
5175
+ </ul>
5176
+ </div>
5177
+ ` : ""}
5178
+
5179
+ <div class="card">
5180
+ <h2>✅ Tested Pages (Top 12)</h2>
5181
+ <div class="page-grid">
5182
+ ${testedPages
5183
+ .sort((a, b) => b.coverageScore - a.coverageScore)
5184
+ .slice(0, 12)
5185
+ .map(page => {
5186
+ const color = page.coverageScore >= 70 ? "#22c55e" : page.coverageScore >= 40 ? "#eab308" : "#ef4444";
5187
+ return `
5188
+ <div class="page-card">
5189
+ <div class="page-score" style="color: ${color}">${page.coverageScore}%</div>
5190
+ <div class="page-path">${page.path}</div>
5191
+ <div style="color: #666; font-size: 0.75rem; margin-top: 0.5rem;">
5192
+ ${page.actions.length} actions | ${page.testCount} test(s)
5193
+ </div>
5194
+ </div>
5195
+ `;
5196
+ }).join("")}
5197
+ </div>
5198
+ </div>
5199
+
5200
+ <footer style="text-align: center; color: #666; margin-top: 2rem; font-size: 0.875rem;">
5201
+ Generated by CBrowser v6.5.0 | Analysis took ${(result.duration / 1000).toFixed(1)}s
5202
+ </footer>
5203
+ </div>
5204
+ </body>
5205
+ </html>`;
5206
+ }
5207
+ /**
5208
+ * Generate a text progress bar for coverage
5209
+ */
5210
+ function generateCoverageProgressBar(percent, width = 30) {
5211
+ const filled = Math.round((percent / 100) * width);
5212
+ const empty = width - filled;
5213
+ return "█".repeat(filled) + "░".repeat(empty);
5214
+ }
4144
5215
  //# sourceMappingURL=browser.js.map