cbrowser 7.2.0 → 7.4.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.
Files changed (84) hide show
  1. package/dist/analysis/bug-hunter.d.ts +32 -0
  2. package/dist/analysis/bug-hunter.d.ts.map +1 -0
  3. package/dist/analysis/bug-hunter.js +106 -0
  4. package/dist/analysis/bug-hunter.js.map +1 -0
  5. package/dist/analysis/chaos-testing.d.ts +41 -0
  6. package/dist/analysis/chaos-testing.d.ts.map +1 -0
  7. package/dist/analysis/chaos-testing.js +87 -0
  8. package/dist/analysis/chaos-testing.js.map +1 -0
  9. package/dist/analysis/index.d.ts +10 -0
  10. package/dist/analysis/index.d.ts.map +1 -0
  11. package/dist/analysis/index.js +26 -0
  12. package/dist/analysis/index.js.map +1 -0
  13. package/dist/analysis/natural-language.d.ts +43 -0
  14. package/dist/analysis/natural-language.d.ts.map +1 -0
  15. package/dist/analysis/natural-language.js +205 -0
  16. package/dist/analysis/natural-language.js.map +1 -0
  17. package/dist/analysis/persona-comparison.d.ts +31 -0
  18. package/dist/analysis/persona-comparison.d.ts.map +1 -0
  19. package/dist/analysis/persona-comparison.js +217 -0
  20. package/dist/analysis/persona-comparison.js.map +1 -0
  21. package/dist/browser.d.ts +1 -395
  22. package/dist/browser.d.ts.map +1 -1
  23. package/dist/browser.js +0 -4388
  24. package/dist/browser.js.map +1 -1
  25. package/dist/cli.js +198 -55
  26. package/dist/cli.js.map +1 -1
  27. package/dist/daemon.d.ts.map +1 -1
  28. package/dist/daemon.js +2 -1
  29. package/dist/daemon.js.map +1 -1
  30. package/dist/index.d.ts +4 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +9 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/performance/index.d.ts +7 -0
  35. package/dist/performance/index.d.ts.map +1 -0
  36. package/dist/performance/index.js +23 -0
  37. package/dist/performance/index.js.map +1 -0
  38. package/dist/performance/metrics.d.ts +49 -0
  39. package/dist/performance/metrics.d.ts.map +1 -0
  40. package/dist/performance/metrics.js +386 -0
  41. package/dist/performance/metrics.js.map +1 -0
  42. package/dist/testing/coverage.d.ts +39 -0
  43. package/dist/testing/coverage.d.ts.map +1 -0
  44. package/dist/testing/coverage.js +713 -0
  45. package/dist/testing/coverage.js.map +1 -0
  46. package/dist/testing/flaky-detection.d.ts +28 -0
  47. package/dist/testing/flaky-detection.d.ts.map +1 -0
  48. package/dist/testing/flaky-detection.js +332 -0
  49. package/dist/testing/flaky-detection.js.map +1 -0
  50. package/dist/testing/index.d.ts +10 -0
  51. package/dist/testing/index.d.ts.map +1 -0
  52. package/dist/testing/index.js +26 -0
  53. package/dist/testing/index.js.map +1 -0
  54. package/dist/testing/nl-test-suite.d.ts +70 -0
  55. package/dist/testing/nl-test-suite.d.ts.map +1 -0
  56. package/dist/testing/nl-test-suite.js +427 -0
  57. package/dist/testing/nl-test-suite.js.map +1 -0
  58. package/dist/testing/test-repair.d.ts +36 -0
  59. package/dist/testing/test-repair.d.ts.map +1 -0
  60. package/dist/testing/test-repair.js +528 -0
  61. package/dist/testing/test-repair.js.map +1 -0
  62. package/dist/types.d.ts +125 -0
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/visual/ab-comparison.d.ts +23 -0
  65. package/dist/visual/ab-comparison.d.ts.map +1 -0
  66. package/dist/visual/ab-comparison.js +366 -0
  67. package/dist/visual/ab-comparison.js.map +1 -0
  68. package/dist/visual/cross-browser.d.ts +41 -0
  69. package/dist/visual/cross-browser.d.ts.map +1 -0
  70. package/dist/visual/cross-browser.js +442 -0
  71. package/dist/visual/cross-browser.js.map +1 -0
  72. package/dist/visual/index.d.ts +10 -0
  73. package/dist/visual/index.d.ts.map +1 -0
  74. package/dist/visual/index.js +26 -0
  75. package/dist/visual/index.js.map +1 -0
  76. package/dist/visual/regression.d.ts +55 -0
  77. package/dist/visual/regression.d.ts.map +1 -0
  78. package/dist/visual/regression.js +616 -0
  79. package/dist/visual/regression.js.map +1 -0
  80. package/dist/visual/responsive.d.ts +27 -0
  81. package/dist/visual/responsive.d.ts.map +1 -0
  82. package/dist/visual/responsive.js +450 -0
  83. package/dist/visual/responsive.js.map +1 -0
  84. package/package.json +32 -3
package/dist/browser.js CHANGED
@@ -6,59 +6,6 @@
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.FluentCBrowser = exports.CBrowser = void 0;
9
- exports.parseNaturalLanguage = parseNaturalLanguage;
10
- exports.executeNaturalLanguage = executeNaturalLanguage;
11
- exports.executeNaturalLanguageScript = executeNaturalLanguageScript;
12
- exports.findElementByIntent = findElementByIntent;
13
- exports.huntBugs = huntBugs;
14
- exports.crossBrowserDiff = crossBrowserDiff;
15
- exports.applyChaos = applyChaos;
16
- exports.runChaosTest = runChaosTest;
17
- exports.comparePersonas = comparePersonas;
18
- exports.formatComparisonReport = formatComparisonReport;
19
- exports.parseNLInstruction = parseNLInstruction;
20
- exports.parseNLTestSuite = parseNLTestSuite;
21
- exports.runNLTestSuite = runNLTestSuite;
22
- exports.formatNLTestReport = formatNLTestReport;
23
- exports.runNLTestFile = runNLTestFile;
24
- exports.repairTest = repairTest;
25
- exports.repairTestSuite = repairTestSuite;
26
- exports.formatRepairReport = formatRepairReport;
27
- exports.exportRepairedTest = exportRepairedTest;
28
- exports.detectFlakyTests = detectFlakyTests;
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;
44
- exports.loadVisualBaselines = loadVisualBaselines;
45
- exports.captureVisualBaseline = captureVisualBaseline;
46
- exports.listVisualBaselines = listVisualBaselines;
47
- exports.getVisualBaseline = getVisualBaseline;
48
- exports.deleteVisualBaseline = deleteVisualBaseline;
49
- exports.runVisualRegression = runVisualRegression;
50
- exports.runVisualRegressionSuite = runVisualRegressionSuite;
51
- exports.formatVisualRegressionReport = formatVisualRegressionReport;
52
- exports.generateVisualRegressionHtmlReport = generateVisualRegressionHtmlReport;
53
- exports.runCrossBrowserTest = runCrossBrowserTest;
54
- exports.runCrossBrowserSuite = runCrossBrowserSuite;
55
- exports.formatCrossBrowserReport = formatCrossBrowserReport;
56
- exports.generateCrossBrowserHtmlReport = generateCrossBrowserHtmlReport;
57
- exports.runResponsiveTest = runResponsiveTest;
58
- exports.runResponsiveSuite = runResponsiveSuite;
59
- exports.formatResponsiveReport = formatResponsiveReport;
60
- exports.generateResponsiveHtmlReport = generateResponsiveHtmlReport;
61
- exports.listViewportPresets = listViewportPresets;
62
9
  const playwright_1 = require("playwright");
63
10
  const fs_1 = require("fs");
64
11
  const path_1 = require("path");
@@ -2319,4339 +2266,4 @@ class FluentCBrowser {
2319
2266
  }
2320
2267
  }
2321
2268
  exports.FluentCBrowser = FluentCBrowser;
2322
- // ============================================================================
2323
- // Tier 3: Natural Language API (v3.0.0)
2324
- // ============================================================================
2325
- /**
2326
- * Natural language command patterns.
2327
- */
2328
- const NL_PATTERNS = [
2329
- // Navigation
2330
- { pattern: /^(?:go to|navigate to|open|visit)\s+(.+)$/i, action: "navigate", extract: (m) => ({ url: m[1] }) },
2331
- { pattern: /^(?:go\s+)?back$/i, action: "back", extract: () => ({}) },
2332
- { pattern: /^(?:go\s+)?forward$/i, action: "forward", extract: () => ({}) },
2333
- { pattern: /^refresh|reload$/i, action: "reload", extract: () => ({}) },
2334
- // Clicking
2335
- { pattern: /^click(?:\s+on)?\s+(?:the\s+)?["']?(.+?)["']?$/i, action: "click", extract: (m) => ({ selector: m[1] }) },
2336
- { pattern: /^press(?:\s+the)?\s+["']?(.+?)["']?(?:\s+button)?$/i, action: "click", extract: (m) => ({ selector: m[1] }) },
2337
- { pattern: /^tap(?:\s+on)?\s+["']?(.+?)["']?$/i, action: "click", extract: (m) => ({ selector: m[1] }) },
2338
- // Form filling
2339
- { pattern: /^(?:type|enter|input|fill(?:\s+in)?)\s+["'](.+?)["']\s+(?:in(?:to)?|on)\s+(?:the\s+)?["']?(.+?)["']?$/i, action: "fill", extract: (m) => ({ value: m[1], selector: m[2] }) },
2340
- { pattern: /^(?:fill(?:\s+in)?|set)\s+(?:the\s+)?["']?(.+?)["']?\s+(?:to|with|as)\s+["'](.+?)["']$/i, action: "fill", extract: (m) => ({ selector: m[1], value: m[2] }) },
2341
- // Selecting
2342
- { pattern: /^select\s+["'](.+?)["']\s+(?:from|in)\s+(?:the\s+)?["']?(.+?)["']?$/i, action: "select", extract: (m) => ({ value: m[1], selector: m[2] }) },
2343
- { pattern: /^choose\s+["'](.+?)["']$/i, action: "click", extract: (m) => ({ selector: m[1] }) },
2344
- // Screenshots
2345
- { pattern: /^(?:take\s+a?\s*)?screenshot(?:\s+as\s+["']?(.+?)["']?)?$/i, action: "screenshot", extract: (m) => ({ path: m[1] || "" }) },
2346
- { pattern: /^capture(?:\s+the)?\s+(?:page|screen)$/i, action: "screenshot", extract: () => ({}) },
2347
- // Waiting
2348
- { pattern: /^wait(?:\s+for)?\s+(\d+)\s*(?:ms|milliseconds?)?$/i, action: "wait", extract: (m) => ({ ms: m[1] }) },
2349
- { pattern: /^wait(?:\s+for)?\s+(\d+)\s*(?:s|seconds?)$/i, action: "waitSeconds", extract: (m) => ({ seconds: m[1] }) },
2350
- { pattern: /^wait(?:\s+for)?\s+["']?(.+?)["']?(?:\s+to\s+appear)?$/i, action: "waitFor", extract: (m) => ({ selector: m[1] }) },
2351
- // Scrolling
2352
- { pattern: /^scroll\s+(?:to\s+)?(?:the\s+)?(top|bottom)$/i, action: "scroll", extract: (m) => ({ direction: m[1] }) },
2353
- { pattern: /^scroll\s+(up|down)(?:\s+(\d+))?$/i, action: "scrollBy", extract: (m) => ({ direction: m[1], amount: m[2] || "300" }) },
2354
- // Extraction
2355
- { pattern: /^(?:get|extract|find)\s+(?:all\s+)?(?:the\s+)?(.+)$/i, action: "extract", extract: (m) => ({ what: m[1] }) },
2356
- ];
2357
- /**
2358
- * Parse natural language into browser action.
2359
- */
2360
- function parseNaturalLanguage(command) {
2361
- const trimmed = command.trim();
2362
- for (const { pattern, action, extract } of NL_PATTERNS) {
2363
- const match = trimmed.match(pattern);
2364
- if (match) {
2365
- return { action, params: extract(match) };
2366
- }
2367
- }
2368
- return null;
2369
- }
2370
- /**
2371
- * Execute a natural language command.
2372
- */
2373
- async function executeNaturalLanguage(browser, command) {
2374
- const parsed = parseNaturalLanguage(command);
2375
- if (!parsed) {
2376
- return { success: false, action: "unknown", error: `Could not parse command: "${command}"` };
2377
- }
2378
- const { action, params } = parsed;
2379
- try {
2380
- let result;
2381
- switch (action) {
2382
- case "navigate":
2383
- result = await browser.navigate(params.url);
2384
- break;
2385
- case "click":
2386
- result = await browser.click(params.selector);
2387
- break;
2388
- case "fill":
2389
- result = await browser.fill(params.selector, params.value);
2390
- break;
2391
- case "screenshot":
2392
- result = await browser.screenshot(params.path || undefined);
2393
- break;
2394
- case "wait":
2395
- await new Promise(r => setTimeout(r, parseInt(params.ms)));
2396
- result = { waited: parseInt(params.ms) };
2397
- break;
2398
- case "waitSeconds":
2399
- await new Promise(r => setTimeout(r, parseInt(params.seconds) * 1000));
2400
- result = { waited: parseInt(params.seconds) * 1000 };
2401
- break;
2402
- case "extract":
2403
- result = await browser.extract(params.what);
2404
- break;
2405
- default:
2406
- return { success: false, action, error: `Unsupported action: ${action}` };
2407
- }
2408
- return { success: true, action, result };
2409
- }
2410
- catch (e) {
2411
- return { success: false, action, error: e.message };
2412
- }
2413
- }
2414
- /**
2415
- * Execute multiple natural language commands in sequence.
2416
- */
2417
- async function executeNaturalLanguageScript(browser, commands) {
2418
- const results = [];
2419
- for (const command of commands) {
2420
- if (!command.trim() || command.startsWith("#"))
2421
- continue; // Skip empty lines and comments
2422
- const result = await executeNaturalLanguage(browser, command);
2423
- results.push({ command, ...result });
2424
- if (!result.success)
2425
- break; // Stop on first error
2426
- }
2427
- return results;
2428
- }
2429
- // ============================================================================
2430
- // Tier 4: Visual AI Understanding (v4.0.0)
2431
- // ============================================================================
2432
- /**
2433
- * AI-powered semantic element finding.
2434
- * Examples: "the cheapest product", "login form", "main navigation"
2435
- */
2436
- async function findElementByIntent(browser, intent) {
2437
- const page = await browser.getPage();
2438
- // Extract page structure for AI analysis
2439
- const pageData = await page.evaluate(() => {
2440
- const elements = [];
2441
- // Find interactive elements
2442
- const interactives = document.querySelectorAll("button, a, input, select, [role='button'], [onclick], .btn, .card, .product, [data-price], .price");
2443
- interactives.forEach((el, i) => {
2444
- const text = el.innerText?.trim().slice(0, 100) || "";
2445
- const priceMatch = text.match(/\$[\d,.]+|\d+\.\d{2}/);
2446
- elements.push({
2447
- tag: el.tagName.toLowerCase(),
2448
- text,
2449
- classes: el.className.toString().slice(0, 100),
2450
- id: el.id,
2451
- role: el.getAttribute("role") || "",
2452
- type: el.type || "",
2453
- price: priceMatch ? priceMatch[0] : undefined,
2454
- selector: el.id ? `#${el.id}` : `${el.tagName.toLowerCase()}:nth-of-type(${i + 1})`,
2455
- });
2456
- });
2457
- return elements;
2458
- });
2459
- // Intent matching logic
2460
- const intentLower = intent.toLowerCase();
2461
- // Price-based intents
2462
- if (intentLower.includes("cheapest") || intentLower.includes("lowest price")) {
2463
- const withPrices = pageData.filter(el => el.price);
2464
- if (withPrices.length > 0) {
2465
- const sorted = withPrices.sort((a, b) => {
2466
- const priceA = parseFloat(a.price.replace(/[$,]/g, ""));
2467
- const priceB = parseFloat(b.price.replace(/[$,]/g, ""));
2468
- return priceA - priceB;
2469
- });
2470
- return {
2471
- selector: sorted[0].selector,
2472
- confidence: 0.8,
2473
- description: `Cheapest item: ${sorted[0].text.slice(0, 50)} (${sorted[0].price})`,
2474
- };
2475
- }
2476
- }
2477
- if (intentLower.includes("most expensive") || intentLower.includes("highest price")) {
2478
- const withPrices = pageData.filter(el => el.price);
2479
- if (withPrices.length > 0) {
2480
- const sorted = withPrices.sort((a, b) => {
2481
- const priceA = parseFloat(a.price.replace(/[$,]/g, ""));
2482
- const priceB = parseFloat(b.price.replace(/[$,]/g, ""));
2483
- return priceB - priceA;
2484
- });
2485
- return {
2486
- selector: sorted[0].selector,
2487
- confidence: 0.8,
2488
- description: `Most expensive: ${sorted[0].text.slice(0, 50)} (${sorted[0].price})`,
2489
- };
2490
- }
2491
- }
2492
- // Form-based intents
2493
- if (intentLower.includes("login") || intentLower.includes("sign in")) {
2494
- const loginBtn = pageData.find(el => el.text.toLowerCase().includes("login") ||
2495
- el.text.toLowerCase().includes("sign in") ||
2496
- el.classes.includes("login"));
2497
- if (loginBtn) {
2498
- return { selector: loginBtn.selector, confidence: 0.9, description: "Login button/form" };
2499
- }
2500
- }
2501
- if (intentLower.includes("search")) {
2502
- const searchInput = pageData.find(el => el.type === "search" ||
2503
- el.classes.includes("search") ||
2504
- el.id.includes("search"));
2505
- if (searchInput) {
2506
- return { selector: searchInput.selector, confidence: 0.9, description: "Search input" };
2507
- }
2508
- }
2509
- // Text-based matching
2510
- const textMatch = pageData.find(el => el.text.toLowerCase().includes(intentLower) ||
2511
- el.classes.toLowerCase().includes(intentLower));
2512
- if (textMatch) {
2513
- return { selector: textMatch.selector, confidence: 0.7, description: `Matched: ${textMatch.text.slice(0, 50)}` };
2514
- }
2515
- return null;
2516
- }
2517
- /**
2518
- * Autonomously explore a page and find bugs.
2519
- */
2520
- async function huntBugs(browser, url, options = {}) {
2521
- const startTime = Date.now();
2522
- const bugs = [];
2523
- const visited = new Set();
2524
- const maxPages = options.maxPages || 10;
2525
- const timeout = options.timeout || 60000;
2526
- const page = await browser.getPage();
2527
- const consoleErrors = [];
2528
- // Capture console errors
2529
- page.on("console", (msg) => {
2530
- if (msg.type() === "error") {
2531
- consoleErrors.push(msg.text());
2532
- }
2533
- });
2534
- // Start with initial URL
2535
- await browser.navigate(url);
2536
- visited.add(url);
2537
- // Check for issues on current page
2538
- const pageIssues = await page.evaluate(() => {
2539
- const issues = [];
2540
- // Check for broken images
2541
- document.querySelectorAll("img").forEach((img, i) => {
2542
- if (!img.complete || img.naturalWidth === 0) {
2543
- issues.push({
2544
- type: "missing-image",
2545
- description: `Broken image: ${img.src || img.alt || "unknown"}`,
2546
- selector: `img:nth-of-type(${i + 1})`,
2547
- });
2548
- }
2549
- });
2550
- // Check for empty links
2551
- document.querySelectorAll("a").forEach((a, i) => {
2552
- if (!a.href || a.href === "#" || a.href === "javascript:void(0)") {
2553
- issues.push({
2554
- type: "broken-link",
2555
- description: `Empty/invalid link: ${a.textContent?.slice(0, 50) || "no text"}`,
2556
- selector: `a:nth-of-type(${i + 1})`,
2557
- });
2558
- }
2559
- });
2560
- // Check for empty buttons
2561
- document.querySelectorAll("button").forEach((btn, i) => {
2562
- if (!btn.textContent?.trim() && !btn.getAttribute("aria-label")) {
2563
- issues.push({
2564
- type: "a11y-violation",
2565
- description: "Button with no accessible text",
2566
- selector: `button:nth-of-type(${i + 1})`,
2567
- });
2568
- }
2569
- });
2570
- // Check for missing form labels
2571
- document.querySelectorAll("input:not([type='hidden'])").forEach((input, i) => {
2572
- const id = input.id;
2573
- const hasLabel = id && document.querySelector(`label[for="${id}"]`);
2574
- if (!hasLabel && !input.getAttribute("aria-label") && !input.getAttribute("placeholder")) {
2575
- issues.push({
2576
- type: "form-error",
2577
- description: "Input without label or placeholder",
2578
- selector: `input:nth-of-type(${i + 1})`,
2579
- });
2580
- }
2581
- });
2582
- return issues;
2583
- });
2584
- // Add page issues to bugs
2585
- for (const issue of pageIssues) {
2586
- bugs.push({
2587
- type: issue.type,
2588
- severity: issue.type === "a11y-violation" ? "high" : "medium",
2589
- description: issue.description,
2590
- url,
2591
- selector: issue.selector,
2592
- });
2593
- }
2594
- // Add console errors
2595
- for (const error of consoleErrors) {
2596
- bugs.push({
2597
- type: "console-error",
2598
- severity: "high",
2599
- description: error.slice(0, 200),
2600
- url,
2601
- });
2602
- }
2603
- return {
2604
- bugs,
2605
- pagesVisited: visited.size,
2606
- duration: Date.now() - startTime,
2607
- };
2608
- }
2609
- /**
2610
- * Compare page behavior across multiple browsers.
2611
- */
2612
- async function crossBrowserDiff(url, browsers = ["chromium", "firefox", "webkit"]) {
2613
- const { chromium, firefox, webkit } = await import("playwright");
2614
- const screenshots = {};
2615
- const metrics = {};
2616
- const differences = [];
2617
- const contents = {};
2618
- const browserLaunchers = { chromium, firefox, webkit };
2619
- for (const browserName of browsers) {
2620
- const launcher = browserLaunchers[browserName];
2621
- const browser = await launcher.launch({ headless: true });
2622
- const page = await browser.newPage();
2623
- const startTime = Date.now();
2624
- await page.goto(url, { waitUntil: "domcontentloaded" });
2625
- const loadTime = Date.now() - startTime;
2626
- // Capture metrics
2627
- const resourceCount = await page.evaluate(() => performance.getEntriesByType("resource").length);
2628
- metrics[browserName] = { loadTime, resourceCount };
2629
- // Capture screenshot
2630
- const screenshotPath = `/tmp/cross-browser-${browserName}-${Date.now()}.png`;
2631
- await page.screenshot({ path: screenshotPath, fullPage: true });
2632
- screenshots[browserName] = screenshotPath;
2633
- // Capture content hash
2634
- contents[browserName] = await page.evaluate(() => document.body.innerText.slice(0, 1000));
2635
- await browser.close();
2636
- }
2637
- // Compare timing
2638
- const loadTimes = Object.entries(metrics).map(([b, m]) => ({ browser: b, time: m.loadTime }));
2639
- const avgTime = loadTimes.reduce((sum, t) => sum + t.time, 0) / loadTimes.length;
2640
- const slowBrowsers = loadTimes.filter(t => t.time > avgTime * 1.5);
2641
- if (slowBrowsers.length > 0) {
2642
- differences.push({
2643
- type: "timing",
2644
- description: `Significantly slower in: ${slowBrowsers.map(b => `${b.browser} (${b.time}ms)`).join(", ")}`,
2645
- browsers: slowBrowsers.map(b => b.browser),
2646
- });
2647
- }
2648
- // Compare content
2649
- const contentValues = Object.values(contents);
2650
- const contentMismatch = contentValues.some(c => c !== contentValues[0]);
2651
- if (contentMismatch) {
2652
- differences.push({
2653
- type: "content",
2654
- description: "Page content differs between browsers",
2655
- browsers,
2656
- });
2657
- }
2658
- return { url, browsers, differences, screenshots, metrics };
2659
- }
2660
- /**
2661
- * Apply chaos engineering conditions to browser.
2662
- */
2663
- async function applyChaos(browser, config) {
2664
- const context = await browser.context;
2665
- const page = await browser.getPage();
2666
- // Network conditions
2667
- if (config.offline) {
2668
- await context.setOffline(true);
2669
- }
2670
- // Route interception for latency/failures
2671
- if (config.networkLatency || config.blockUrls || config.failApis) {
2672
- await page.route("**/*", async (route) => {
2673
- const url = route.request().url();
2674
- // Block URLs
2675
- if (config.blockUrls?.some(pattern => url.includes(pattern))) {
2676
- await route.abort();
2677
- return;
2678
- }
2679
- // Fail specific APIs
2680
- const failConfig = config.failApis?.find(f => url.includes(f.pattern));
2681
- if (failConfig) {
2682
- await route.fulfill({
2683
- status: failConfig.status,
2684
- body: failConfig.body || "Chaos: Simulated failure",
2685
- });
2686
- return;
2687
- }
2688
- // Add latency
2689
- if (config.networkLatency) {
2690
- await new Promise(r => setTimeout(r, config.networkLatency));
2691
- }
2692
- // Random delays
2693
- if (config.randomDelays && Math.random() < config.randomDelays) {
2694
- await new Promise(r => setTimeout(r, Math.random() * 3000));
2695
- }
2696
- await route.continue();
2697
- });
2698
- }
2699
- }
2700
- /**
2701
- * Run chaos test - apply conditions and verify app resilience.
2702
- */
2703
- async function runChaosTest(browser, url, chaos, actions = []) {
2704
- const startTime = Date.now();
2705
- const errors = [];
2706
- try {
2707
- await applyChaos(browser, chaos);
2708
- await browser.navigate(url);
2709
- // Execute actions
2710
- for (const action of actions) {
2711
- const result = await executeNaturalLanguage(browser, action);
2712
- if (!result.success) {
2713
- errors.push(`Action failed: ${action} - ${result.error}`);
2714
- }
2715
- }
2716
- const screenshot = await browser.screenshot();
2717
- return {
2718
- passed: errors.length === 0,
2719
- errors,
2720
- duration: Date.now() - startTime,
2721
- screenshot,
2722
- };
2723
- }
2724
- catch (e) {
2725
- return {
2726
- passed: false,
2727
- errors: [...errors, e.message],
2728
- duration: Date.now() - startTime,
2729
- screenshot: "",
2730
- };
2731
- }
2732
- }
2733
- /**
2734
- * Run the same journey with multiple personas and compare results.
2735
- * This runs personas in parallel (up to maxConcurrency) for efficiency.
2736
- */
2737
- async function comparePersonas(options) {
2738
- const { startUrl, goal, personas, maxSteps = 20, maxConcurrency = 3, headless = true, } = options;
2739
- const startTime = Date.now();
2740
- const results = [];
2741
- console.log(`\n🔄 Comparing ${personas.length} personas...`);
2742
- console.log(` URL: ${startUrl}`);
2743
- console.log(` Goal: ${goal}`);
2744
- console.log(` Concurrency: ${maxConcurrency}\n`);
2745
- // Process personas in batches
2746
- for (let i = 0; i < personas.length; i += maxConcurrency) {
2747
- const batch = personas.slice(i, i + maxConcurrency);
2748
- console.log(`📦 Batch ${Math.floor(i / maxConcurrency) + 1}: ${batch.join(", ")}`);
2749
- const batchPromises = batch.map(async (personaName) => {
2750
- const browser = new CBrowser({ headless });
2751
- try {
2752
- const persona = (0, personas_js_1.getPersona)(personaName) || personas_js_1.BUILTIN_PERSONAS["first-timer"];
2753
- const journeyStart = Date.now();
2754
- // Run the journey
2755
- const journey = await browser.journey({
2756
- persona: personaName,
2757
- startUrl,
2758
- goal,
2759
- maxSteps,
2760
- });
2761
- // Calculate average reaction time from persona config
2762
- const timing = persona.humanBehavior?.timing;
2763
- const avgReactionTime = timing
2764
- ? (timing.reactionTime.min + timing.reactionTime.max) / 2
2765
- : 500;
2766
- // Calculate error rate from persona config
2767
- const errors = persona.humanBehavior?.errors;
2768
- const errorRate = errors
2769
- ? (errors.misClickRate + errors.typoRate) / 2
2770
- : 0.05;
2771
- const result = {
2772
- persona: personaName,
2773
- description: persona.description,
2774
- techLevel: persona.demographics.tech_level || "intermediate",
2775
- device: persona.demographics.device || "desktop",
2776
- success: journey.success,
2777
- totalTime: journey.totalTime,
2778
- stepCount: journey.steps.length,
2779
- frictionCount: journey.frictionPoints.length,
2780
- frictionPoints: journey.frictionPoints,
2781
- avgReactionTime,
2782
- errorRate,
2783
- screenshots: {
2784
- start: journey.steps[0]?.screenshot || "",
2785
- end: journey.steps[journey.steps.length - 1]?.screenshot || "",
2786
- },
2787
- };
2788
- console.log(` ✓ ${personaName}: ${journey.success ? "SUCCESS" : "FAILED"} (${journey.totalTime}ms, ${journey.frictionPoints.length} friction)`);
2789
- return result;
2790
- }
2791
- catch (e) {
2792
- console.log(` ✗ ${personaName}: ERROR - ${e.message}`);
2793
- return {
2794
- persona: personaName,
2795
- description: "Unknown",
2796
- techLevel: "unknown",
2797
- device: "unknown",
2798
- success: false,
2799
- totalTime: 0,
2800
- stepCount: 0,
2801
- frictionCount: 1,
2802
- frictionPoints: [`Error: ${e.message}`],
2803
- avgReactionTime: 0,
2804
- errorRate: 0,
2805
- screenshots: { start: "", end: "" },
2806
- };
2807
- }
2808
- finally {
2809
- await browser.close();
2810
- }
2811
- });
2812
- const batchResults = await Promise.all(batchPromises);
2813
- results.push(...batchResults);
2814
- }
2815
- // Generate summary
2816
- const successfulResults = results.filter((r) => r.success);
2817
- const failedResults = results.filter((r) => !r.success);
2818
- const sortedByTime = [...successfulResults].sort((a, b) => a.totalTime - b.totalTime);
2819
- const sortedByFriction = [...results].sort((a, b) => b.frictionCount - a.frictionCount);
2820
- // Collect all friction points
2821
- const allFrictionPoints = results.flatMap((r) => r.frictionPoints);
2822
- const frictionCounts = allFrictionPoints.reduce((acc, fp) => {
2823
- acc[fp] = (acc[fp] || 0) + 1;
2824
- return acc;
2825
- }, {});
2826
- const commonFriction = Object.entries(frictionCounts)
2827
- .filter(([_, count]) => count > 1)
2828
- .sort((a, b) => b[1] - a[1])
2829
- .slice(0, 5)
2830
- .map(([fp]) => fp);
2831
- // Generate recommendations
2832
- const recommendations = [];
2833
- if (failedResults.length > 0) {
2834
- recommendations.push(`⚠️ ${failedResults.length} persona(s) failed to complete the journey: ${failedResults.map((r) => r.persona).join(", ")}`);
2835
- }
2836
- if (sortedByFriction[0]?.frictionCount > 0) {
2837
- recommendations.push(`🔧 "${sortedByFriction[0].persona}" experienced the most friction (${sortedByFriction[0].frictionCount} points) - review for accessibility improvements`);
2838
- }
2839
- const beginnerPersonas = results.filter((r) => r.techLevel === "beginner");
2840
- const expertPersonas = results.filter((r) => r.techLevel === "expert");
2841
- if (beginnerPersonas.length > 0 && expertPersonas.length > 0) {
2842
- const avgBeginnerTime = beginnerPersonas.reduce((sum, r) => sum + r.totalTime, 0) / beginnerPersonas.length;
2843
- const avgExpertTime = expertPersonas.reduce((sum, r) => sum + r.totalTime, 0) / expertPersonas.length;
2844
- if (avgBeginnerTime > avgExpertTime * 3) {
2845
- recommendations.push(`📚 Beginners take ${(avgBeginnerTime / avgExpertTime).toFixed(1)}x longer than experts - consider adding more guidance`);
2846
- }
2847
- }
2848
- const mobilePersonas = results.filter((r) => r.device === "mobile");
2849
- const desktopPersonas = results.filter((r) => r.device === "desktop");
2850
- if (mobilePersonas.length > 0 && desktopPersonas.length > 0) {
2851
- const mobileFriction = mobilePersonas.reduce((sum, r) => sum + r.frictionCount, 0) / mobilePersonas.length;
2852
- const desktopFriction = desktopPersonas.reduce((sum, r) => sum + r.frictionCount, 0) / desktopPersonas.length;
2853
- if (mobileFriction > desktopFriction * 2) {
2854
- recommendations.push(`📱 Mobile users experience ${(mobileFriction / desktopFriction).toFixed(1)}x more friction - review mobile UX`);
2855
- }
2856
- }
2857
- if (commonFriction.length > 0) {
2858
- recommendations.push(`🎯 Common friction points across personas: ${commonFriction.slice(0, 3).join("; ")}`);
2859
- }
2860
- if (recommendations.length === 0) {
2861
- recommendations.push("✅ All personas completed the journey without significant issues");
2862
- }
2863
- const avgTime = successfulResults.length > 0
2864
- ? successfulResults.reduce((sum, r) => sum + r.totalTime, 0) / successfulResults.length
2865
- : 0;
2866
- const comparison = {
2867
- url: startUrl,
2868
- goal,
2869
- timestamp: new Date().toISOString(),
2870
- duration: Date.now() - startTime,
2871
- personas: results,
2872
- summary: {
2873
- totalPersonas: personas.length,
2874
- successCount: successfulResults.length,
2875
- failureCount: failedResults.length,
2876
- fastestPersona: sortedByTime[0]?.persona || "N/A",
2877
- slowestPersona: sortedByTime[sortedByTime.length - 1]?.persona || "N/A",
2878
- mostFriction: sortedByFriction[0]?.persona || "N/A",
2879
- leastFriction: sortedByFriction[sortedByFriction.length - 1]?.persona || "N/A",
2880
- avgCompletionTime: Math.round(avgTime),
2881
- commonFrictionPoints: commonFriction,
2882
- },
2883
- recommendations,
2884
- };
2885
- return comparison;
2886
- }
2887
- /**
2888
- * Generate a formatted comparison report.
2889
- */
2890
- function formatComparisonReport(comparison) {
2891
- const lines = [];
2892
- lines.push("");
2893
- lines.push("╔══════════════════════════════════════════════════════════════════════════════╗");
2894
- lines.push("║ MULTI-PERSONA COMPARISON REPORT ║");
2895
- lines.push("╚══════════════════════════════════════════════════════════════════════════════╝");
2896
- lines.push("");
2897
- lines.push(`📍 URL: ${comparison.url}`);
2898
- lines.push(`🎯 Goal: ${comparison.goal}`);
2899
- lines.push(`⏱️ Total Duration: ${(comparison.duration / 1000).toFixed(1)}s`);
2900
- lines.push(`📅 Timestamp: ${comparison.timestamp}`);
2901
- lines.push("");
2902
- // Results table
2903
- lines.push("┌─────────────────────┬──────────┬──────────┬──────────┬──────────┬─────────────────────────────┐");
2904
- lines.push("│ Persona │ Success │ Time │ Steps │ Friction │ Key Issues │");
2905
- lines.push("├─────────────────────┼──────────┼──────────┼──────────┼──────────┼─────────────────────────────┤");
2906
- for (const result of comparison.personas) {
2907
- const name = result.persona.padEnd(19).slice(0, 19);
2908
- const success = result.success ? "✓".padEnd(8) : "✗".padEnd(8);
2909
- const time = `${(result.totalTime / 1000).toFixed(1)}s`.padEnd(8);
2910
- const steps = `${result.stepCount}`.padEnd(8);
2911
- const friction = `${result.frictionCount}`.padEnd(8);
2912
- const issues = (result.frictionPoints[0] || "-").slice(0, 27).padEnd(27);
2913
- lines.push(`│ ${name} │ ${success} │ ${time} │ ${steps} │ ${friction} │ ${issues} │`);
2914
- }
2915
- lines.push("└─────────────────────┴──────────┴──────────┴──────────┴──────────┴─────────────────────────────┘");
2916
- lines.push("");
2917
- // Summary
2918
- lines.push("📊 SUMMARY");
2919
- lines.push("─".repeat(60));
2920
- lines.push(` Total Personas: ${comparison.summary.totalPersonas}`);
2921
- lines.push(` Success Rate: ${comparison.summary.successCount}/${comparison.summary.totalPersonas} (${Math.round((comparison.summary.successCount / comparison.summary.totalPersonas) * 100)}%)`);
2922
- lines.push(` Avg Completion Time: ${(comparison.summary.avgCompletionTime / 1000).toFixed(1)}s`);
2923
- lines.push(` Fastest: ${comparison.summary.fastestPersona}`);
2924
- lines.push(` Slowest: ${comparison.summary.slowestPersona}`);
2925
- lines.push(` Most Friction: ${comparison.summary.mostFriction}`);
2926
- lines.push(` Least Friction: ${comparison.summary.leastFriction}`);
2927
- lines.push("");
2928
- // Recommendations
2929
- lines.push("💡 RECOMMENDATIONS");
2930
- lines.push("─".repeat(60));
2931
- for (const rec of comparison.recommendations) {
2932
- lines.push(` ${rec}`);
2933
- }
2934
- lines.push("");
2935
- return lines.join("\n");
2936
- }
2937
- // =========================================================================
2938
- // Natural Language Test Suites
2939
- // =========================================================================
2940
- /**
2941
- * Parse a single natural language instruction into an NLTestStep.
2942
- *
2943
- * Supported patterns:
2944
- * - "go to https://..." / "navigate to https://..." / "open https://..."
2945
- * - "click [the] <target>" / "press <target>"
2946
- * - "type '<value>' in[to] <target>" / "fill <target> with '<value>'"
2947
- * - "select '<option>' from <dropdown>"
2948
- * - "scroll down/up"
2949
- * - "wait [for] <seconds> seconds"
2950
- * - "verify <assertion>" / "assert <assertion>" / "check <assertion>"
2951
- * - "take screenshot"
2952
- */
2953
- function parseNLInstruction(instruction) {
2954
- const lower = instruction.toLowerCase().trim();
2955
- // Navigate patterns
2956
- const navigateMatch = lower.match(/^(?:go to|navigate to|open|visit)\s+(.+)$/i);
2957
- if (navigateMatch) {
2958
- return {
2959
- instruction,
2960
- action: "navigate",
2961
- target: navigateMatch[1].trim(),
2962
- };
2963
- }
2964
- // Click patterns
2965
- const clickMatch = lower.match(/^(?:click|tap|press)\s+(?:on\s+)?(?:the\s+)?(.+)$/i);
2966
- if (clickMatch) {
2967
- return {
2968
- instruction,
2969
- action: "click",
2970
- target: clickMatch[1].trim(),
2971
- };
2972
- }
2973
- // Fill patterns: "type 'value' in target" or "fill target with 'value'"
2974
- const typeMatch = lower.match(/^(?:type|enter)\s+['"](.+?)['"]\s+(?:in|into)\s+(?:the\s+)?(.+)$/i);
2975
- if (typeMatch) {
2976
- return {
2977
- instruction,
2978
- action: "fill",
2979
- value: typeMatch[1],
2980
- target: typeMatch[2].trim(),
2981
- };
2982
- }
2983
- const fillMatch = lower.match(/^fill\s+(?:the\s+)?(.+?)\s+with\s+['"](.+?)['"]$/i);
2984
- if (fillMatch) {
2985
- return {
2986
- instruction,
2987
- action: "fill",
2988
- target: fillMatch[1].trim(),
2989
- value: fillMatch[2],
2990
- };
2991
- }
2992
- // Select patterns
2993
- const selectMatch = lower.match(/^select\s+['"](.+?)['"]\s+(?:from|in)\s+(?:the\s+)?(.+)$/i);
2994
- if (selectMatch) {
2995
- return {
2996
- instruction,
2997
- action: "select",
2998
- value: selectMatch[1],
2999
- target: selectMatch[2].trim(),
3000
- };
3001
- }
3002
- // Scroll patterns
3003
- const scrollMatch = lower.match(/^scroll\s+(up|down|left|right)(?:\s+(\d+)\s+(?:times|pixels))?$/i);
3004
- if (scrollMatch) {
3005
- return {
3006
- instruction,
3007
- action: "scroll",
3008
- target: scrollMatch[1],
3009
- value: scrollMatch[2] || "3",
3010
- };
3011
- }
3012
- // Wait patterns
3013
- const waitMatch = lower.match(/^wait\s+(?:for\s+)?(\d+(?:\.\d+)?)\s*(?:seconds?|s)$/i);
3014
- if (waitMatch) {
3015
- return {
3016
- instruction,
3017
- action: "wait",
3018
- value: waitMatch[1],
3019
- };
3020
- }
3021
- // Wait for text pattern
3022
- const waitForMatch = lower.match(/^wait\s+(?:for|until)\s+['"](.+?)['"]\s+(?:appears?|is visible|shows?)$/i);
3023
- if (waitForMatch) {
3024
- return {
3025
- instruction,
3026
- action: "wait",
3027
- target: waitForMatch[1],
3028
- };
3029
- }
3030
- // Assert/verify patterns
3031
- const assertPatterns = [
3032
- // Title assertions
3033
- { pattern: /^(?:verify|assert|check)\s+(?:that\s+)?(?:page\s+)?title\s+(?:contains?|has)\s+['"](.+?)['"]$/i, type: "title", assertType: "contains" },
3034
- { pattern: /^(?:verify|assert|check)\s+(?:that\s+)?(?:page\s+)?title\s+(?:is|equals?)\s+['"](.+?)['"]$/i, type: "title", assertType: "equals" },
3035
- // URL assertions
3036
- { pattern: /^(?:verify|assert|check)\s+(?:that\s+)?url\s+(?:contains?|has)\s+['"](.+?)['"]$/i, type: "url", assertType: "contains" },
3037
- { pattern: /^(?:verify|assert|check)\s+(?:that\s+)?url\s+(?:is|equals?)\s+['"](.+?)['"]$/i, type: "url", assertType: "equals" },
3038
- // Content assertions
3039
- { pattern: /^(?:verify|assert|check)\s+(?:that\s+)?(?:page\s+)?(?:contains?|has|shows?)\s+['"](.+?)['"]$/i, type: "content", assertType: "contains" },
3040
- { pattern: /^(?:verify|assert|check)\s+(?:that\s+)?['"](.+?)['"]\s+(?:is\s+)?(?:visible|displayed|shown)$/i, type: "content", assertType: "contains" },
3041
- // Element exists
3042
- { pattern: /^(?:verify|assert|check)\s+(?:that\s+)?['"](.+?)['"]\s+exists?$/i, type: "element", assertType: "exists" },
3043
- { pattern: /^(?:verify|assert|check)\s+(?:that\s+)?(?:there\s+is\s+)?(?:a|an)\s+['"](.+?)['"]$/i, type: "element", assertType: "exists" },
3044
- // Count assertions
3045
- { pattern: /^(?:verify|assert|check)\s+(?:that\s+)?(?:there\s+are\s+)?(\d+)\s+(.+?)$/i, type: "count", assertType: "count" },
3046
- ];
3047
- for (const { pattern, type, assertType } of assertPatterns) {
3048
- const match = lower.match(pattern);
3049
- if (match) {
3050
- return {
3051
- instruction,
3052
- action: "assert",
3053
- target: type === "count" ? match[2] : match[1],
3054
- value: type === "count" ? match[1] : undefined,
3055
- assertionType: assertType,
3056
- };
3057
- }
3058
- }
3059
- // Screenshot pattern
3060
- if (/^(?:take\s+(?:a\s+)?screenshot|screenshot|capture\s+(?:the\s+)?(?:page|screen))$/i.test(lower)) {
3061
- return {
3062
- instruction,
3063
- action: "screenshot",
3064
- };
3065
- }
3066
- // Unknown - return as-is for AI-powered interpretation later
3067
- return {
3068
- instruction,
3069
- action: "unknown",
3070
- target: instruction,
3071
- };
3072
- }
3073
- /**
3074
- * Parse a natural language test suite from text.
3075
- *
3076
- * Format:
3077
- * ```
3078
- * # Test: Login Flow
3079
- * go to https://example.com
3080
- * click the login button
3081
- * type "user@example.com" in email field
3082
- * type "password123" in password field
3083
- * click submit
3084
- * verify url contains "/dashboard"
3085
- *
3086
- * # Test: Search Functionality
3087
- * go to https://example.com
3088
- * type "test query" in search box
3089
- * click search button
3090
- * verify page contains "results"
3091
- * ```
3092
- */
3093
- function parseNLTestSuite(text, suiteName = "Unnamed Suite") {
3094
- const lines = text.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("//"));
3095
- const tests = [];
3096
- let currentTest = null;
3097
- for (const line of lines) {
3098
- // Check for test header: "# Test: Name" or "## Name" or "Test: Name"
3099
- const testHeaderMatch = line.match(/^(?:#\s*)?(?:test:\s*)?(.+)$/i);
3100
- if (line.startsWith("#") || line.toLowerCase().startsWith("test:")) {
3101
- // Save previous test if exists
3102
- if (currentTest && currentTest.steps.length > 0) {
3103
- tests.push(currentTest);
3104
- }
3105
- const name = testHeaderMatch?.[1]?.replace(/^#+\s*/, "").replace(/^test:\s*/i, "").trim() || "Unnamed Test";
3106
- currentTest = {
3107
- name,
3108
- steps: [],
3109
- };
3110
- }
3111
- else if (line.length > 0) {
3112
- // Parse as instruction
3113
- if (!currentTest) {
3114
- // Create default test if no header found
3115
- currentTest = {
3116
- name: "Default Test",
3117
- steps: [],
3118
- };
3119
- }
3120
- const step = parseNLInstruction(line);
3121
- currentTest.steps.push(step);
3122
- }
3123
- }
3124
- // Save final test
3125
- if (currentTest && currentTest.steps.length > 0) {
3126
- tests.push(currentTest);
3127
- }
3128
- return { name: suiteName, tests };
3129
- }
3130
- /**
3131
- * Run a natural language test suite.
3132
- */
3133
- async function runNLTestSuite(suite, options = {}) {
3134
- const { stepTimeout = 30000, continueOnFailure = true, screenshotOnFailure = true, headless = true, } = options;
3135
- const startTime = Date.now();
3136
- const testResults = [];
3137
- console.log(`\n🧪 Running Test Suite: ${suite.name}`);
3138
- console.log(` Tests: ${suite.tests.length}`);
3139
- console.log(` Continue on failure: ${continueOnFailure}`);
3140
- console.log("");
3141
- const browser = new CBrowser({
3142
- headless,
3143
- });
3144
- try {
3145
- await browser.launch();
3146
- for (const test of suite.tests) {
3147
- console.log(`\n📋 Test: ${test.name}`);
3148
- const testStartTime = Date.now();
3149
- const stepResults = [];
3150
- let testPassed = true;
3151
- let testError;
3152
- for (const step of test.steps) {
3153
- console.log(` → ${step.instruction}`);
3154
- const stepStartTime = Date.now();
3155
- let stepPassed = true;
3156
- let stepError;
3157
- let screenshot;
3158
- let actualValue;
3159
- try {
3160
- // Execute the step based on action type
3161
- switch (step.action) {
3162
- case "navigate": {
3163
- await browser.navigate(step.target || "");
3164
- break;
3165
- }
3166
- case "click": {
3167
- const result = await browser.smartClick(step.target || "");
3168
- if (!result.success) {
3169
- throw new Error(`Failed to click: ${step.target}`);
3170
- }
3171
- break;
3172
- }
3173
- case "fill": {
3174
- await browser.fill(step.target || "", step.value || "");
3175
- break;
3176
- }
3177
- case "select": {
3178
- await browser.fill(step.target || "", step.value || "");
3179
- break;
3180
- }
3181
- case "scroll": {
3182
- const direction = step.target?.toLowerCase() === "up" ? -500 : 500;
3183
- // Use private page access through cast
3184
- const page = browser.page;
3185
- if (page) {
3186
- await page.evaluate((d) => window.scrollBy(0, d), direction);
3187
- }
3188
- break;
3189
- }
3190
- case "wait": {
3191
- if (step.target) {
3192
- // Wait for text to appear - use private page access
3193
- const page = browser.page;
3194
- if (page) {
3195
- await page.waitForSelector(`text=${step.target}`, { timeout: stepTimeout });
3196
- }
3197
- }
3198
- else {
3199
- // Wait for duration
3200
- const ms = parseFloat(step.value || "1") * 1000;
3201
- await new Promise(r => setTimeout(r, ms));
3202
- }
3203
- break;
3204
- }
3205
- case "assert": {
3206
- const assertResult = await browser.assert(step.instruction);
3207
- stepPassed = assertResult.passed;
3208
- actualValue = String(assertResult.actual);
3209
- if (!assertResult.passed) {
3210
- stepError = assertResult.message;
3211
- }
3212
- break;
3213
- }
3214
- case "screenshot": {
3215
- screenshot = await browser.screenshot();
3216
- break;
3217
- }
3218
- case "unknown": {
3219
- // Try to interpret as a click or fill
3220
- console.log(` ⚠️ Unknown instruction, attempting smart interpretation...`);
3221
- const result = await browser.smartClick(step.target || step.instruction);
3222
- if (!result.success) {
3223
- throw new Error(`Could not interpret: ${step.instruction}`);
3224
- }
3225
- break;
3226
- }
3227
- }
3228
- console.log(` ✓ Passed (${Date.now() - stepStartTime}ms)`);
3229
- }
3230
- catch (e) {
3231
- stepPassed = false;
3232
- stepError = e.message;
3233
- testPassed = false;
3234
- testError = testError || e.message;
3235
- console.log(` ✗ Failed: ${e.message}`);
3236
- if (screenshotOnFailure) {
3237
- try {
3238
- screenshot = await browser.screenshot();
3239
- }
3240
- catch { }
3241
- }
3242
- }
3243
- stepResults.push({
3244
- instruction: step.instruction,
3245
- action: step.action,
3246
- passed: stepPassed,
3247
- duration: Date.now() - stepStartTime,
3248
- error: stepError,
3249
- screenshot,
3250
- actualValue,
3251
- });
3252
- // Stop test if step failed and not continuing on failure
3253
- if (!stepPassed && !continueOnFailure) {
3254
- break;
3255
- }
3256
- }
3257
- testResults.push({
3258
- name: test.name,
3259
- passed: testPassed,
3260
- duration: Date.now() - testStartTime,
3261
- stepResults,
3262
- error: testError,
3263
- });
3264
- console.log(` ${testPassed ? "✅" : "❌"} ${test.name}: ${testPassed ? "PASSED" : "FAILED"}`);
3265
- }
3266
- }
3267
- finally {
3268
- await browser.close();
3269
- }
3270
- const passed = testResults.filter(t => t.passed).length;
3271
- const failed = testResults.filter(t => !t.passed).length;
3272
- const result = {
3273
- name: suite.name,
3274
- timestamp: new Date().toISOString(),
3275
- duration: Date.now() - startTime,
3276
- testResults,
3277
- summary: {
3278
- total: suite.tests.length,
3279
- passed,
3280
- failed,
3281
- skipped: 0,
3282
- passRate: suite.tests.length > 0 ? (passed / suite.tests.length) * 100 : 0,
3283
- },
3284
- };
3285
- return result;
3286
- }
3287
- /**
3288
- * Format a test suite result as a report.
3289
- */
3290
- function formatNLTestReport(result) {
3291
- const lines = [];
3292
- lines.push("");
3293
- lines.push("╔══════════════════════════════════════════════════════════════════════════════╗");
3294
- lines.push("║ NATURAL LANGUAGE TEST REPORT ║");
3295
- lines.push("╚══════════════════════════════════════════════════════════════════════════════╝");
3296
- lines.push("");
3297
- lines.push(`📋 Suite: ${result.name}`);
3298
- lines.push(`⏱️ Duration: ${(result.duration / 1000).toFixed(1)}s`);
3299
- lines.push(`📅 Timestamp: ${result.timestamp}`);
3300
- lines.push("");
3301
- // Summary stats
3302
- const passEmoji = result.summary.passRate === 100 ? "🎉" : result.summary.passRate >= 80 ? "✅" : "⚠️";
3303
- lines.push(`${passEmoji} Pass Rate: ${result.summary.passed}/${result.summary.total} (${result.summary.passRate.toFixed(0)}%)`);
3304
- lines.push("");
3305
- // Results table
3306
- lines.push("┌───────────────────────────────────────┬──────────┬──────────┬────────────────────┐");
3307
- lines.push("│ Test │ Status │ Duration │ Error │");
3308
- lines.push("├───────────────────────────────────────┼──────────┼──────────┼────────────────────┤");
3309
- for (const test of result.testResults) {
3310
- const name = test.name.padEnd(37).slice(0, 37);
3311
- const status = test.passed ? "✓ PASS".padEnd(8) : "✗ FAIL".padEnd(8);
3312
- const duration = `${(test.duration / 1000).toFixed(1)}s`.padEnd(8);
3313
- const error = (test.error || "-").slice(0, 18).padEnd(18);
3314
- lines.push(`│ ${name} │ ${status} │ ${duration} │ ${error} │`);
3315
- }
3316
- lines.push("└───────────────────────────────────────┴──────────┴──────────┴────────────────────┘");
3317
- lines.push("");
3318
- // Failed test details
3319
- const failedTests = result.testResults.filter(t => !t.passed);
3320
- if (failedTests.length > 0) {
3321
- lines.push("❌ FAILED TESTS");
3322
- lines.push("─".repeat(60));
3323
- for (const test of failedTests) {
3324
- lines.push(`\n 📋 ${test.name}`);
3325
- const failedSteps = test.stepResults.filter(s => !s.passed);
3326
- for (const step of failedSteps) {
3327
- lines.push(` ✗ ${step.instruction}`);
3328
- if (step.error) {
3329
- lines.push(` Error: ${step.error}`);
3330
- }
3331
- if (step.screenshot) {
3332
- lines.push(` Screenshot: ${step.screenshot}`);
3333
- }
3334
- }
3335
- }
3336
- lines.push("");
3337
- }
3338
- return lines.join("\n");
3339
- }
3340
- /**
3341
- * Run a natural language test suite from a file.
3342
- */
3343
- async function runNLTestFile(filepath, options = {}) {
3344
- if (!(0, fs_1.existsSync)(filepath)) {
3345
- throw new Error(`Test file not found: ${filepath}`);
3346
- }
3347
- const content = (0, fs_1.readFileSync)(filepath, "utf-8");
3348
- const suiteName = filepath.split("/").pop()?.replace(/\.[^.]+$/, "") || "Test Suite";
3349
- const suite = parseNLTestSuite(content, suiteName);
3350
- return runNLTestSuite(suite, options);
3351
- }
3352
- // =========================================================================
3353
- // AI Test Repair (v6.2.0)
3354
- // =========================================================================
3355
- /**
3356
- * Classify the type of failure from an error message.
3357
- */
3358
- function classifyFailure(error, step) {
3359
- const lowerError = error.toLowerCase();
3360
- if (lowerError.includes("not found") || lowerError.includes("no element") || lowerError.includes("failed to click")) {
3361
- return "selector_not_found";
3362
- }
3363
- if (lowerError.includes("assertion") || lowerError.includes("verify") || lowerError.includes("expected")) {
3364
- return "assertion_failed";
3365
- }
3366
- if (lowerError.includes("timeout") || lowerError.includes("timed out")) {
3367
- return "timeout";
3368
- }
3369
- if (lowerError.includes("navigation") || lowerError.includes("navigate") || lowerError.includes("url")) {
3370
- return "navigation_failed";
3371
- }
3372
- if (lowerError.includes("not interactable") || lowerError.includes("disabled") || lowerError.includes("hidden")) {
3373
- return "element_not_interactable";
3374
- }
3375
- return "unknown";
3376
- }
3377
- /**
3378
- * Find alternative selectors for a target on the current page.
3379
- */
3380
- async function findAlternatives(browser, originalTarget) {
3381
- const page = browser.page;
3382
- if (!page)
3383
- return [];
3384
- try {
3385
- const alternatives = await page.evaluate((target) => {
3386
- const results = [];
3387
- const lowerTarget = target.toLowerCase();
3388
- // Find buttons with similar text
3389
- document.querySelectorAll("button, [role='button'], input[type='submit'], input[type='button']").forEach((el) => {
3390
- const text = el.textContent?.trim() || el.value || "";
3391
- if (text && (text.toLowerCase().includes(lowerTarget) || lowerTarget.includes(text.toLowerCase()))) {
3392
- results.push(`button: "${text}"`);
3393
- }
3394
- });
3395
- // Find links with similar text
3396
- document.querySelectorAll("a").forEach((el) => {
3397
- const text = el.textContent?.trim() || "";
3398
- if (text && (text.toLowerCase().includes(lowerTarget) || lowerTarget.includes(text.toLowerCase()))) {
3399
- results.push(`link: "${text}"`);
3400
- }
3401
- });
3402
- // Find inputs with similar labels/placeholders
3403
- document.querySelectorAll("input, textarea, select").forEach((el) => {
3404
- const input = el;
3405
- const placeholder = input.placeholder || "";
3406
- const label = document.querySelector(`label[for="${input.id}"]`)?.textContent?.trim() || "";
3407
- const name = input.name || "";
3408
- if (placeholder.toLowerCase().includes(lowerTarget) || lowerTarget.includes(placeholder.toLowerCase())) {
3409
- results.push(`input with placeholder "${placeholder}"`);
3410
- }
3411
- if (label.toLowerCase().includes(lowerTarget) || lowerTarget.includes(label.toLowerCase())) {
3412
- results.push(`input labeled "${label}"`);
3413
- }
3414
- if (name.toLowerCase().includes(lowerTarget)) {
3415
- results.push(`input named "${name}"`);
3416
- }
3417
- });
3418
- // Find elements by aria-label
3419
- document.querySelectorAll("[aria-label]").forEach((el) => {
3420
- const label = el.getAttribute("aria-label") || "";
3421
- if (label.toLowerCase().includes(lowerTarget) || lowerTarget.includes(label.toLowerCase())) {
3422
- results.push(`aria:${el.tagName.toLowerCase()}/"${label}"`);
3423
- }
3424
- });
3425
- return [...new Set(results)].slice(0, 10);
3426
- }, originalTarget);
3427
- return alternatives;
3428
- }
3429
- catch {
3430
- return [];
3431
- }
3432
- }
3433
- /**
3434
- * Get page context for failure analysis.
3435
- */
3436
- async function getPageContext(browser) {
3437
- const page = browser.page;
3438
- if (!page)
3439
- return { url: "", title: "", visibleText: [] };
3440
- try {
3441
- const context = await page.evaluate(() => {
3442
- const visibleText = [];
3443
- // Get visible button/link text
3444
- document.querySelectorAll("button, a, [role='button']").forEach((el) => {
3445
- const text = el.textContent?.trim();
3446
- if (text && text.length < 50) {
3447
- visibleText.push(text);
3448
- }
3449
- });
3450
- return {
3451
- url: window.location.href,
3452
- title: document.title,
3453
- visibleText: [...new Set(visibleText)].slice(0, 20),
3454
- };
3455
- });
3456
- return context;
3457
- }
3458
- catch {
3459
- return { url: "", title: "", visibleText: [] };
3460
- }
3461
- }
3462
- /**
3463
- * Generate repair suggestions for a failed step.
3464
- */
3465
- async function generateRepairSuggestions(browser, step, error, failureType, alternatives, pageContext) {
3466
- const suggestions = [];
3467
- switch (failureType) {
3468
- case "selector_not_found": {
3469
- // Suggest alternative selectors
3470
- for (const alt of alternatives.slice(0, 3)) {
3471
- const newInstruction = step.instruction.replace(step.target || "", alt.replace(/^(button|link|input|aria):\s*/, "").replace(/"/g, "'"));
3472
- suggestions.push({
3473
- type: "selector_update",
3474
- confidence: 0.7,
3475
- description: `Update selector to "${alt}"`,
3476
- originalInstruction: step.instruction,
3477
- suggestedInstruction: `click ${alt.replace(/^(button|link|input|aria):\s*/, "")}`,
3478
- reasoning: `Found similar element on page: ${alt}`,
3479
- });
3480
- }
3481
- // Suggest adding a wait
3482
- if (suggestions.length === 0) {
3483
- suggestions.push({
3484
- type: "add_wait",
3485
- confidence: 0.5,
3486
- description: "Add wait before this step",
3487
- originalInstruction: step.instruction,
3488
- suggestedInstruction: `wait 2 seconds\n${step.instruction}`,
3489
- reasoning: "Element might not be loaded yet - adding wait may help",
3490
- });
3491
- }
3492
- break;
3493
- }
3494
- case "assertion_failed": {
3495
- // Suggest updating the assertion based on page content
3496
- if (step.action === "assert" && pageContext.visibleText.length > 0) {
3497
- const possibleText = pageContext.visibleText.find((t) => t.length > 3 && t.length < 30);
3498
- if (possibleText) {
3499
- suggestions.push({
3500
- type: "assertion_update",
3501
- confidence: 0.6,
3502
- description: `Update assertion to check for visible text`,
3503
- originalInstruction: step.instruction,
3504
- suggestedInstruction: `verify page contains "${possibleText}"`,
3505
- reasoning: `Page contains "${possibleText}" which might be the intended check`,
3506
- });
3507
- }
3508
- }
3509
- // Suggest checking URL instead
3510
- if (pageContext.url) {
3511
- const urlPath = new URL(pageContext.url).pathname;
3512
- suggestions.push({
3513
- type: "assertion_update",
3514
- confidence: 0.5,
3515
- description: "Assert URL instead of content",
3516
- originalInstruction: step.instruction,
3517
- suggestedInstruction: `verify url contains "${urlPath}"`,
3518
- reasoning: `Current URL is ${pageContext.url}`,
3519
- });
3520
- }
3521
- break;
3522
- }
3523
- case "timeout": {
3524
- suggestions.push({
3525
- type: "add_wait",
3526
- confidence: 0.7,
3527
- description: "Increase wait time",
3528
- originalInstruction: step.instruction,
3529
- suggestedInstruction: `wait 5 seconds\n${step.instruction}`,
3530
- reasoning: "Operation timed out - page may need more time to load",
3531
- });
3532
- break;
3533
- }
3534
- case "element_not_interactable": {
3535
- suggestions.push({
3536
- type: "add_wait",
3537
- confidence: 0.6,
3538
- description: "Wait for element to become interactive",
3539
- originalInstruction: step.instruction,
3540
- suggestedInstruction: `wait 2 seconds\n${step.instruction}`,
3541
- reasoning: "Element exists but is not interactable - may need to wait",
3542
- });
3543
- // Suggest scrolling
3544
- suggestions.push({
3545
- type: "change_action",
3546
- confidence: 0.5,
3547
- description: "Scroll element into view first",
3548
- originalInstruction: step.instruction,
3549
- suggestedInstruction: `scroll down\n${step.instruction}`,
3550
- reasoning: "Element might be outside viewport",
3551
- });
3552
- break;
3553
- }
3554
- default: {
3555
- // Generic suggestion to skip
3556
- suggestions.push({
3557
- type: "skip_step",
3558
- confidence: 0.3,
3559
- description: "Skip this step",
3560
- originalInstruction: step.instruction,
3561
- suggestedInstruction: `// SKIPPED: ${step.instruction}`,
3562
- reasoning: "Unable to determine a fix - consider removing this step",
3563
- });
3564
- }
3565
- }
3566
- // Sort by confidence
3567
- return suggestions.sort((a, b) => b.confidence - a.confidence);
3568
- }
3569
- /**
3570
- * Analyze a failed test step and suggest repairs.
3571
- */
3572
- async function analyzeFailure(browser, step, error) {
3573
- const failureType = classifyFailure(error, step);
3574
- const alternatives = step.target ? await findAlternatives(browser, step.target) : [];
3575
- const pageContext = await getPageContext(browser);
3576
- const suggestions = await generateRepairSuggestions(browser, step, error, failureType, alternatives, pageContext);
3577
- return {
3578
- step,
3579
- error,
3580
- failureType,
3581
- targetSelector: step.target,
3582
- alternativeSelectors: alternatives,
3583
- pageContext,
3584
- suggestions,
3585
- };
3586
- }
3587
- /**
3588
- * Run a test, analyze failures, and suggest/apply repairs.
3589
- */
3590
- async function repairTest(test, options = {}) {
3591
- const { headless = true, autoApply = false, verifyRepairs = true, maxRetries = 3, } = options;
3592
- const browser = new CBrowser({ headless });
3593
- const failureAnalyses = [];
3594
- const repairedSteps = [];
3595
- let failedSteps = 0;
3596
- console.log(`\n🔧 Analyzing test: ${test.name}`);
3597
- console.log(` Steps: ${test.steps.length}`);
3598
- console.log(` Auto-apply: ${autoApply}`);
3599
- console.log("");
3600
- try {
3601
- await browser.launch();
3602
- for (const step of test.steps) {
3603
- console.log(` → ${step.instruction}`);
3604
- let stepPassed = false;
3605
- let lastError = "";
3606
- let attempts = 0;
3607
- while (!stepPassed && attempts < maxRetries) {
3608
- attempts++;
3609
- try {
3610
- // Execute the step
3611
- switch (step.action) {
3612
- case "navigate":
3613
- await browser.navigate(step.target || "");
3614
- stepPassed = true;
3615
- break;
3616
- case "click":
3617
- const clickResult = await browser.smartClick(step.target || "");
3618
- stepPassed = clickResult.success;
3619
- if (!stepPassed)
3620
- lastError = `Failed to click: ${step.target}`;
3621
- break;
3622
- case "fill":
3623
- await browser.fill(step.target || "", step.value || "");
3624
- stepPassed = true;
3625
- break;
3626
- case "assert":
3627
- const assertResult = await browser.assert(step.instruction);
3628
- stepPassed = assertResult.passed;
3629
- if (!stepPassed)
3630
- lastError = assertResult.message;
3631
- break;
3632
- case "wait":
3633
- if (step.target) {
3634
- const page = browser.page;
3635
- if (page) {
3636
- await page.waitForSelector(`text=${step.target}`, { timeout: 10000 });
3637
- }
3638
- }
3639
- else {
3640
- const ms = parseFloat(step.value || "1") * 1000;
3641
- await new Promise((r) => setTimeout(r, ms));
3642
- }
3643
- stepPassed = true;
3644
- break;
3645
- case "scroll":
3646
- const direction = step.target?.toLowerCase() === "up" ? -500 : 500;
3647
- const page = browser.page;
3648
- if (page) {
3649
- await page.evaluate((d) => window.scrollBy(0, d), direction);
3650
- }
3651
- stepPassed = true;
3652
- break;
3653
- case "screenshot":
3654
- await browser.screenshot();
3655
- stepPassed = true;
3656
- break;
3657
- default:
3658
- // Try as click
3659
- const unknownResult = await browser.smartClick(step.target || step.instruction);
3660
- stepPassed = unknownResult.success;
3661
- if (!stepPassed)
3662
- lastError = `Could not interpret: ${step.instruction}`;
3663
- }
3664
- }
3665
- catch (e) {
3666
- lastError = e.message;
3667
- stepPassed = false;
3668
- }
3669
- if (!stepPassed && attempts < maxRetries) {
3670
- console.log(` ⚠️ Attempt ${attempts} failed, retrying...`);
3671
- await new Promise((r) => setTimeout(r, 500));
3672
- }
3673
- }
3674
- if (stepPassed) {
3675
- console.log(` ✓ Passed`);
3676
- repairedSteps.push(step);
3677
- }
3678
- else {
3679
- console.log(` ✗ Failed: ${lastError}`);
3680
- failedSteps++;
3681
- // Analyze the failure
3682
- const analysis = await analyzeFailure(browser, step, lastError);
3683
- failureAnalyses.push(analysis);
3684
- if (autoApply && analysis.suggestions.length > 0) {
3685
- const bestSuggestion = analysis.suggestions[0];
3686
- console.log(` 🔧 Auto-applying: ${bestSuggestion.description}`);
3687
- // Parse the suggested instruction into a step
3688
- const repairedStep = parseNLInstruction(bestSuggestion.suggestedInstruction.split("\n").pop() || "");
3689
- repairedSteps.push(repairedStep);
3690
- }
3691
- else {
3692
- // Keep original step
3693
- repairedSteps.push(step);
3694
- if (analysis.suggestions.length > 0) {
3695
- console.log(` 💡 Suggestions:`);
3696
- for (const suggestion of analysis.suggestions.slice(0, 2)) {
3697
- console.log(` - ${suggestion.description} (${Math.round(suggestion.confidence * 100)}%)`);
3698
- console.log(` → ${suggestion.suggestedInstruction}`);
3699
- }
3700
- }
3701
- }
3702
- }
3703
- }
3704
- // Create repaired test
3705
- const repairedTest = {
3706
- name: test.name,
3707
- description: test.description,
3708
- steps: repairedSteps,
3709
- };
3710
- // Optionally verify the repaired test
3711
- let repairedTestPasses;
3712
- if (verifyRepairs && autoApply && failedSteps > 0) {
3713
- console.log(`\n 🔄 Verifying repaired test...`);
3714
- await browser.close();
3715
- const verifyBrowser = new CBrowser({ headless });
3716
- try {
3717
- await verifyBrowser.launch();
3718
- let allPassed = true;
3719
- for (const step of repairedSteps) {
3720
- try {
3721
- switch (step.action) {
3722
- case "navigate":
3723
- await verifyBrowser.navigate(step.target || "");
3724
- break;
3725
- case "click":
3726
- const result = await verifyBrowser.smartClick(step.target || "");
3727
- if (!result.success)
3728
- allPassed = false;
3729
- break;
3730
- case "fill":
3731
- await verifyBrowser.fill(step.target || "", step.value || "");
3732
- break;
3733
- case "assert":
3734
- const assertResult = await verifyBrowser.assert(step.instruction);
3735
- if (!assertResult.passed)
3736
- allPassed = false;
3737
- break;
3738
- case "wait":
3739
- if (!step.target) {
3740
- const ms = parseFloat(step.value || "1") * 1000;
3741
- await new Promise((r) => setTimeout(r, ms));
3742
- }
3743
- break;
3744
- }
3745
- }
3746
- catch {
3747
- allPassed = false;
3748
- }
3749
- }
3750
- repairedTestPasses = allPassed;
3751
- console.log(` ${allPassed ? "✅" : "❌"} Repaired test ${allPassed ? "PASSES" : "still FAILS"}`);
3752
- }
3753
- finally {
3754
- await verifyBrowser.close();
3755
- }
3756
- }
3757
- return {
3758
- originalTest: test,
3759
- repairedTest: failedSteps > 0 ? repairedTest : undefined,
3760
- failedSteps,
3761
- repairedSteps: autoApply ? failedSteps : 0,
3762
- failureAnalyses,
3763
- repairedTestPasses,
3764
- };
3765
- }
3766
- finally {
3767
- await browser.close();
3768
- }
3769
- }
3770
- /**
3771
- * Run test repair on a full suite.
3772
- */
3773
- async function repairTestSuite(suite, options = {}) {
3774
- const startTime = Date.now();
3775
- const testResults = [];
3776
- console.log(`\n🔧 Repairing Test Suite: ${suite.name}`);
3777
- console.log(` Tests: ${suite.tests.length}`);
3778
- console.log("");
3779
- for (const test of suite.tests) {
3780
- const result = await repairTest(test, options);
3781
- testResults.push(result);
3782
- }
3783
- const testsWithFailures = testResults.filter((r) => r.failedSteps > 0).length;
3784
- const testsRepaired = testResults.filter((r) => r.repairedSteps > 0).length;
3785
- const totalFailedSteps = testResults.reduce((sum, r) => sum + r.failedSteps, 0);
3786
- const totalRepairedSteps = testResults.reduce((sum, r) => sum + r.repairedSteps, 0);
3787
- return {
3788
- suiteName: suite.name,
3789
- timestamp: new Date().toISOString(),
3790
- duration: Date.now() - startTime,
3791
- testResults,
3792
- summary: {
3793
- totalTests: suite.tests.length,
3794
- testsWithFailures,
3795
- testsRepaired,
3796
- totalFailedSteps,
3797
- totalRepairedSteps,
3798
- repairSuccessRate: totalFailedSteps > 0 ? (totalRepairedSteps / totalFailedSteps) * 100 : 100,
3799
- },
3800
- };
3801
- }
3802
- /**
3803
- * Format a repair result as a report.
3804
- */
3805
- function formatRepairReport(result) {
3806
- const lines = [];
3807
- lines.push("");
3808
- lines.push("╔══════════════════════════════════════════════════════════════════════════════╗");
3809
- lines.push("║ AI TEST REPAIR REPORT ║");
3810
- lines.push("╚══════════════════════════════════════════════════════════════════════════════╝");
3811
- lines.push("");
3812
- lines.push(`📋 Suite: ${result.suiteName}`);
3813
- lines.push(`⏱️ Duration: ${(result.duration / 1000).toFixed(1)}s`);
3814
- lines.push(`📅 Timestamp: ${result.timestamp}`);
3815
- lines.push("");
3816
- // Summary
3817
- lines.push("📊 SUMMARY");
3818
- lines.push("─".repeat(60));
3819
- lines.push(` Total Tests: ${result.summary.totalTests}`);
3820
- lines.push(` Tests with Failures: ${result.summary.testsWithFailures}`);
3821
- lines.push(` Tests Repaired: ${result.summary.testsRepaired}`);
3822
- lines.push(` Total Failed Steps: ${result.summary.totalFailedSteps}`);
3823
- lines.push(` Total Repaired Steps: ${result.summary.totalRepairedSteps}`);
3824
- lines.push(` Repair Success Rate: ${result.summary.repairSuccessRate.toFixed(0)}%`);
3825
- lines.push("");
3826
- // Per-test details
3827
- for (const testResult of result.testResults) {
3828
- if (testResult.failedSteps === 0)
3829
- continue;
3830
- lines.push(`\n🔧 ${testResult.originalTest.name}`);
3831
- lines.push(` Failed Steps: ${testResult.failedSteps}`);
3832
- lines.push(` Repaired: ${testResult.repairedSteps}`);
3833
- for (const analysis of testResult.failureAnalyses) {
3834
- lines.push(`\n ❌ ${analysis.step.instruction}`);
3835
- lines.push(` Error: ${analysis.error}`);
3836
- lines.push(` Type: ${analysis.failureType}`);
3837
- if (analysis.suggestions.length > 0) {
3838
- lines.push(` 💡 Suggestions:`);
3839
- for (const s of analysis.suggestions.slice(0, 2)) {
3840
- lines.push(` - ${s.description} (${Math.round(s.confidence * 100)}%)`);
3841
- lines.push(` → ${s.suggestedInstruction}`);
3842
- }
3843
- }
3844
- }
3845
- if (testResult.repairedTestPasses !== undefined) {
3846
- lines.push(`\n ${testResult.repairedTestPasses ? "✅" : "❌"} Repaired test ${testResult.repairedTestPasses ? "PASSES" : "still FAILS"}`);
3847
- }
3848
- }
3849
- lines.push("");
3850
- return lines.join("\n");
3851
- }
3852
- /**
3853
- * Export repaired test to file.
3854
- */
3855
- function exportRepairedTest(result) {
3856
- if (!result.repairedTest) {
3857
- return `# Test: ${result.originalTest.name}\n# No repairs needed\n${result.originalTest.steps.map((s) => s.instruction).join("\n")}`;
3858
- }
3859
- const lines = [];
3860
- lines.push(`# Test: ${result.repairedTest.name} (Repaired)`);
3861
- lines.push(`# Original failures: ${result.failedSteps}`);
3862
- lines.push(`# Repairs applied: ${result.repairedSteps}`);
3863
- lines.push("");
3864
- for (const step of result.repairedTest.steps) {
3865
- lines.push(step.instruction);
3866
- }
3867
- return lines.join("\n");
3868
- }
3869
- /**
3870
- * Calculate flakiness score.
3871
- * 0 = completely stable (all same result)
3872
- * 100 = maximally flaky (50% pass, 50% fail)
3873
- */
3874
- function calculateFlakinessScore(passCount, failCount) {
3875
- const total = passCount + failCount;
3876
- if (total === 0)
3877
- return 0;
3878
- const passRate = passCount / total;
3879
- // Flakiness is maximized at 50% pass rate
3880
- // Score = 1 - |passRate - 0.5| * 2, scaled to 0-100
3881
- const flakiness = (1 - Math.abs(passRate - 0.5) * 2) * 100;
3882
- return Math.round(flakiness);
3883
- }
3884
- /**
3885
- * Classify a test based on its results.
3886
- */
3887
- function classifyTest(passCount, failCount) {
3888
- const total = passCount + failCount;
3889
- if (total === 0)
3890
- return "stable_pass";
3891
- const passRate = passCount / total;
3892
- if (passRate === 1)
3893
- return "stable_pass";
3894
- if (passRate === 0)
3895
- return "stable_fail";
3896
- if (passRate >= 0.8)
3897
- return "mostly_pass";
3898
- if (passRate <= 0.2)
3899
- return "mostly_fail";
3900
- return "flaky";
3901
- }
3902
- /**
3903
- * Run a single test once and return the result.
3904
- */
3905
- async function runTestOnce(test, runNumber, headless) {
3906
- const browser = new CBrowser({ headless });
3907
- const stepResults = [];
3908
- let testPassed = true;
3909
- let testError;
3910
- const startTime = Date.now();
3911
- try {
3912
- await browser.launch();
3913
- for (const step of test.steps) {
3914
- let stepPassed = true;
3915
- let stepError;
3916
- try {
3917
- switch (step.action) {
3918
- case "navigate":
3919
- await browser.navigate(step.target || "");
3920
- break;
3921
- case "click":
3922
- const clickResult = await browser.smartClick(step.target || "");
3923
- if (!clickResult.success) {
3924
- stepPassed = false;
3925
- stepError = `Failed to click: ${step.target}`;
3926
- }
3927
- break;
3928
- case "fill":
3929
- await browser.fill(step.target || "", step.value || "");
3930
- break;
3931
- case "assert":
3932
- const assertResult = await browser.assert(step.instruction);
3933
- stepPassed = assertResult.passed;
3934
- if (!assertResult.passed) {
3935
- stepError = assertResult.message;
3936
- }
3937
- break;
3938
- case "wait":
3939
- if (!step.target) {
3940
- const ms = parseFloat(step.value || "1") * 1000;
3941
- await new Promise((r) => setTimeout(r, ms));
3942
- }
3943
- else {
3944
- const page = browser.page;
3945
- if (page) {
3946
- await page.waitForSelector(`text=${step.target}`, { timeout: 10000 });
3947
- }
3948
- }
3949
- break;
3950
- case "scroll":
3951
- const direction = step.target?.toLowerCase() === "up" ? -500 : 500;
3952
- const page = browser.page;
3953
- if (page) {
3954
- await page.evaluate((d) => window.scrollBy(0, d), direction);
3955
- }
3956
- break;
3957
- case "screenshot":
3958
- await browser.screenshot();
3959
- break;
3960
- default:
3961
- const unknownResult = await browser.smartClick(step.target || step.instruction);
3962
- if (!unknownResult.success) {
3963
- stepPassed = false;
3964
- stepError = `Could not interpret: ${step.instruction}`;
3965
- }
3966
- }
3967
- }
3968
- catch (e) {
3969
- stepPassed = false;
3970
- stepError = e.message;
3971
- }
3972
- stepResults.push({
3973
- instruction: step.instruction,
3974
- passed: stepPassed,
3975
- error: stepError,
3976
- });
3977
- if (!stepPassed) {
3978
- testPassed = false;
3979
- testError = testError || stepError;
3980
- }
3981
- }
3982
- }
3983
- catch (e) {
3984
- testPassed = false;
3985
- testError = e.message;
3986
- }
3987
- finally {
3988
- await browser.close();
3989
- }
3990
- return {
3991
- runNumber,
3992
- passed: testPassed,
3993
- duration: Date.now() - startTime,
3994
- error: testError,
3995
- stepResults,
3996
- };
3997
- }
3998
- /**
3999
- * Analyze a test for flakiness by running it multiple times.
4000
- */
4001
- async function analyzeTestFlakiness(test, options) {
4002
- const { runs = 5, headless = true, flakinessThreshold = 20, delayBetweenRuns = 500, } = options;
4003
- const testRuns = [];
4004
- console.log(`\n 🔄 Running ${runs} times...`);
4005
- for (let i = 1; i <= runs; i++) {
4006
- const result = await runTestOnce(test, i, headless);
4007
- testRuns.push(result);
4008
- const icon = result.passed ? "✓" : "✗";
4009
- console.log(` Run ${i}: ${icon} (${result.duration}ms)`);
4010
- if (i < runs && delayBetweenRuns > 0) {
4011
- await new Promise((r) => setTimeout(r, delayBetweenRuns));
4012
- }
4013
- }
4014
- // Calculate overall stats
4015
- const passCount = testRuns.filter((r) => r.passed).length;
4016
- const failCount = testRuns.filter((r) => !r.passed).length;
4017
- const flakinessScore = calculateFlakinessScore(passCount, failCount);
4018
- const classification = classifyTest(passCount, failCount);
4019
- const isFlaky = flakinessScore >= flakinessThreshold;
4020
- // Calculate duration stats
4021
- const durations = testRuns.map((r) => r.duration);
4022
- const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length;
4023
- const variance = Math.sqrt(durations.reduce((sum, d) => sum + Math.pow(d - avgDuration, 2), 0) / durations.length);
4024
- // Analyze per-step flakiness
4025
- const stepAnalysis = [];
4026
- for (let stepIdx = 0; stepIdx < test.steps.length; stepIdx++) {
4027
- const step = test.steps[stepIdx];
4028
- const stepResults = testRuns.map((r) => r.stepResults[stepIdx]).filter(Boolean);
4029
- const stepPassCount = stepResults.filter((s) => s?.passed).length;
4030
- const stepFailCount = stepResults.filter((s) => !s?.passed).length;
4031
- const stepFlakinessScore = calculateFlakinessScore(stepPassCount, stepFailCount);
4032
- const stepErrors = [...new Set(stepResults.filter((s) => s?.error).map((s) => s.error))];
4033
- stepAnalysis.push({
4034
- instruction: step.instruction,
4035
- passCount: stepPassCount,
4036
- failCount: stepFailCount,
4037
- flakinessScore: stepFlakinessScore,
4038
- isFlaky: stepFlakinessScore >= flakinessThreshold,
4039
- errors: stepErrors,
4040
- });
4041
- }
4042
- return {
4043
- testName: test.name,
4044
- totalRuns: runs,
4045
- passCount,
4046
- failCount,
4047
- flakinessScore,
4048
- isFlaky,
4049
- classification,
4050
- runs: testRuns,
4051
- stepAnalysis,
4052
- avgDuration: Math.round(avgDuration),
4053
- durationVariance: Math.round(variance),
4054
- };
4055
- }
4056
- /**
4057
- * Run flaky test detection on a test suite.
4058
- */
4059
- async function detectFlakyTests(suite, options = {}) {
4060
- const { runs = 5, flakinessThreshold = 20 } = options;
4061
- const startTime = Date.now();
4062
- const testAnalyses = [];
4063
- console.log(`\n🔍 Flaky Test Detection: ${suite.name}`);
4064
- console.log(` Tests: ${suite.tests.length}`);
4065
- console.log(` Runs per test: ${runs}`);
4066
- console.log(` Flakiness threshold: ${flakinessThreshold}%`);
4067
- for (const test of suite.tests) {
4068
- console.log(`\n📋 Test: ${test.name}`);
4069
- const analysis = await analyzeTestFlakiness(test, options);
4070
- testAnalyses.push(analysis);
4071
- const statusIcon = analysis.classification === "stable_pass" ? "✅" :
4072
- analysis.classification === "stable_fail" ? "❌" :
4073
- analysis.classification === "flaky" ? "⚠️" :
4074
- analysis.classification === "mostly_pass" ? "🟡" : "🟠";
4075
- console.log(` ${statusIcon} ${analysis.classification.toUpperCase()} (${analysis.passCount}/${analysis.totalRuns} passed, flakiness: ${analysis.flakinessScore}%)`);
4076
- }
4077
- // Calculate summary
4078
- const stablePassTests = testAnalyses.filter((t) => t.classification === "stable_pass").length;
4079
- const stableFailTests = testAnalyses.filter((t) => t.classification === "stable_fail").length;
4080
- const flakyTests = testAnalyses.filter((t) => t.isFlaky).length;
4081
- const mostFlakyTest = testAnalyses.reduce((max, t) => t.flakinessScore > (max?.flakinessScore || 0) ? t : max, testAnalyses[0])?.testName;
4082
- const allSteps = testAnalyses.flatMap((t) => t.stepAnalysis);
4083
- const mostFlakyStep = allSteps.reduce((max, s) => s.flakinessScore > (max?.flakinessScore || 0) ? s : max, allSteps[0])?.instruction;
4084
- const overallFlakinessScore = testAnalyses.length > 0
4085
- ? Math.round(testAnalyses.reduce((sum, t) => sum + t.flakinessScore, 0) / testAnalyses.length)
4086
- : 0;
4087
- return {
4088
- suiteName: suite.name,
4089
- timestamp: new Date().toISOString(),
4090
- duration: Date.now() - startTime,
4091
- runsPerTest: runs,
4092
- testAnalyses,
4093
- summary: {
4094
- totalTests: suite.tests.length,
4095
- stablePassTests,
4096
- stableFailTests,
4097
- flakyTests,
4098
- mostFlakyTest: flakyTests > 0 ? mostFlakyTest : undefined,
4099
- mostFlakyStep: allSteps.some((s) => s.isFlaky) ? mostFlakyStep : undefined,
4100
- overallFlakinessScore,
4101
- },
4102
- };
4103
- }
4104
- /**
4105
- * Format a flaky test report.
4106
- */
4107
- function formatFlakyTestReport(result) {
4108
- const lines = [];
4109
- lines.push("");
4110
- lines.push("╔══════════════════════════════════════════════════════════════════════════════╗");
4111
- lines.push("║ FLAKY TEST DETECTION REPORT ║");
4112
- lines.push("╚══════════════════════════════════════════════════════════════════════════════╝");
4113
- lines.push("");
4114
- lines.push(`📋 Suite: ${result.suiteName}`);
4115
- lines.push(`⏱️ Duration: ${(result.duration / 1000).toFixed(1)}s`);
4116
- lines.push(`🔄 Runs per test: ${result.runsPerTest}`);
4117
- lines.push(`📅 Timestamp: ${result.timestamp}`);
4118
- lines.push("");
4119
- // Summary
4120
- const flakyEmoji = result.summary.flakyTests === 0 ? "✅" : "⚠️";
4121
- lines.push(`${flakyEmoji} Overall Flakiness: ${result.summary.overallFlakinessScore}%`);
4122
- lines.push("");
4123
- lines.push("📊 SUMMARY");
4124
- lines.push("─".repeat(60));
4125
- lines.push(` Total Tests: ${result.summary.totalTests}`);
4126
- lines.push(` Stable (Pass): ${result.summary.stablePassTests}`);
4127
- lines.push(` Stable (Fail): ${result.summary.stableFailTests}`);
4128
- lines.push(` Flaky: ${result.summary.flakyTests}`);
4129
- if (result.summary.mostFlakyTest) {
4130
- lines.push(` Most Flaky Test: ${result.summary.mostFlakyTest}`);
4131
- }
4132
- if (result.summary.mostFlakyStep) {
4133
- lines.push(` Most Flaky Step: ${result.summary.mostFlakyStep.slice(0, 50)}...`);
4134
- }
4135
- lines.push("");
4136
- // Results table
4137
- lines.push("┌────────────────────────────────┬────────────┬──────────┬───────────┬────────────┐");
4138
- lines.push("│ Test │ Status │ Pass/Fail│ Flakiness │ Avg Time │");
4139
- lines.push("├────────────────────────────────┼────────────┼──────────┼───────────┼────────────┤");
4140
- for (const test of result.testAnalyses) {
4141
- const name = test.testName.padEnd(30).slice(0, 30);
4142
- const status = test.classification.replace("_", " ").toUpperCase().padEnd(10).slice(0, 10);
4143
- const passFailStr = `${test.passCount}/${test.totalRuns}`.padEnd(8);
4144
- const flakiness = `${test.flakinessScore}%`.padEnd(9);
4145
- const avgTime = `${(test.avgDuration / 1000).toFixed(1)}s`.padEnd(10);
4146
- lines.push(`│ ${name} │ ${status} │ ${passFailStr} │ ${flakiness} │ ${avgTime} │`);
4147
- }
4148
- lines.push("└────────────────────────────────┴────────────┴──────────┴───────────┴────────────┘");
4149
- lines.push("");
4150
- // Flaky tests details
4151
- const flakyTests = result.testAnalyses.filter((t) => t.isFlaky);
4152
- if (flakyTests.length > 0) {
4153
- lines.push("⚠️ FLAKY TESTS DETAILS");
4154
- lines.push("─".repeat(60));
4155
- for (const test of flakyTests) {
4156
- lines.push(`\n 📋 ${test.testName}`);
4157
- lines.push(` Flakiness: ${test.flakinessScore}%`);
4158
- lines.push(` Pass Rate: ${test.passCount}/${test.totalRuns} (${Math.round((test.passCount / test.totalRuns) * 100)}%)`);
4159
- lines.push(` Duration: ${test.avgDuration}ms ± ${test.durationVariance}ms`);
4160
- const flakySteps = test.stepAnalysis.filter((s) => s.isFlaky);
4161
- if (flakySteps.length > 0) {
4162
- lines.push(` Flaky Steps:`);
4163
- for (const step of flakySteps) {
4164
- lines.push(` - "${step.instruction.slice(0, 40)}..." (${step.flakinessScore}% flaky)`);
4165
- if (step.errors.length > 0) {
4166
- lines.push(` Errors: ${step.errors[0].slice(0, 50)}`);
4167
- }
4168
- }
4169
- }
4170
- }
4171
- lines.push("");
4172
- }
4173
- // Recommendations
4174
- lines.push("💡 RECOMMENDATIONS");
4175
- lines.push("─".repeat(60));
4176
- if (result.summary.flakyTests === 0) {
4177
- lines.push(" ✅ All tests are stable - no action needed");
4178
- }
4179
- else {
4180
- lines.push(` ⚠️ ${result.summary.flakyTests} flaky test(s) detected`);
4181
- lines.push(" Consider:");
4182
- lines.push(" - Adding explicit waits for timing-sensitive operations");
4183
- lines.push(" - Using more specific selectors");
4184
- lines.push(" - Checking for race conditions in the application");
4185
- lines.push(" - Isolating tests to avoid shared state issues");
4186
- }
4187
- lines.push("");
4188
- return lines.join("\n");
4189
- }
4190
- // ============================================================================
4191
- // Performance Regression Detection (v6.4.0)
4192
- // ============================================================================
4193
- const DEFAULT_REGRESSION_THRESHOLDS = {
4194
- lcp: 20, // 20% increase
4195
- fid: 50, // 50% increase
4196
- cls: 0.1, // Absolute increase of 0.1
4197
- fcp: 20, // 20% increase
4198
- ttfb: 30, // 30% increase
4199
- tti: 25, // 25% increase
4200
- tbt: 50, // 50% increase
4201
- transferSize: 25, // 25% increase
4202
- };
4203
- /**
4204
- * Capture a performance baseline for a URL
4205
- */
4206
- async function capturePerformanceBaseline(url, options = {}) {
4207
- const { runs = 3, name, headless = true, device, throttle } = options;
4208
- const paths = (0, config_js_1.getPaths)();
4209
- (0, config_js_1.ensureDirectories)();
4210
- const browser = new CBrowser({ headless });
4211
- const allMetrics = [];
4212
- try {
4213
- for (let i = 0; i < runs; i++) {
4214
- await browser.navigate(url);
4215
- // Wait for page to stabilize
4216
- await new Promise((r) => setTimeout(r, 2000));
4217
- const metrics = await browser.getPerformanceMetrics();
4218
- allMetrics.push(metrics);
4219
- // Brief pause between runs
4220
- if (i < runs - 1) {
4221
- await new Promise((r) => setTimeout(r, 1000));
4222
- }
4223
- }
4224
- }
4225
- finally {
4226
- await browser.close();
4227
- }
4228
- // Average the metrics
4229
- const avgMetrics = {};
4230
- const numericMetricKeys = [
4231
- "lcp", "fid", "cls", "fcp", "ttfb", "tti", "tbt",
4232
- "domContentLoaded", "load", "resourceCount", "transferSize"
4233
- ];
4234
- for (const key of numericMetricKeys) {
4235
- const values = allMetrics
4236
- .map((m) => m[key])
4237
- .filter((v) => v !== undefined && v !== null);
4238
- if (values.length > 0) {
4239
- avgMetrics[key] = values.reduce((a, b) => a + b, 0) / values.length;
4240
- }
4241
- }
4242
- // Determine ratings
4243
- if (avgMetrics.lcp !== undefined) {
4244
- avgMetrics.lcpRating = avgMetrics.lcp <= 2500 ? "good" : avgMetrics.lcp <= 4000 ? "needs-improvement" : "poor";
4245
- }
4246
- if (avgMetrics.cls !== undefined) {
4247
- avgMetrics.clsRating = avgMetrics.cls <= 0.1 ? "good" : avgMetrics.cls <= 0.25 ? "needs-improvement" : "poor";
4248
- }
4249
- const baseline = {
4250
- id: `baseline-${Date.now()}`,
4251
- url,
4252
- name: name || new URL(url).hostname,
4253
- timestamp: new Date().toISOString(),
4254
- metrics: avgMetrics,
4255
- runsAveraged: runs,
4256
- environment: {
4257
- browser: "chromium",
4258
- viewport: { width: 1280, height: 720 },
4259
- device,
4260
- connection: throttle,
4261
- },
4262
- };
4263
- // Save baseline
4264
- const baselinesDir = (0, path_1.join)(paths.dataDir, "baselines");
4265
- if (!(0, fs_1.existsSync)(baselinesDir)) {
4266
- (0, fs_1.mkdirSync)(baselinesDir, { recursive: true });
4267
- }
4268
- const baselineFile = (0, path_1.join)(baselinesDir, `${baseline.id}.json`);
4269
- (0, fs_1.writeFileSync)(baselineFile, JSON.stringify(baseline, null, 2));
4270
- return baseline;
4271
- }
4272
- /**
4273
- * List all saved performance baselines
4274
- */
4275
- function listPerformanceBaselines() {
4276
- const paths = (0, config_js_1.getPaths)();
4277
- const baselinesDir = (0, path_1.join)(paths.dataDir, "baselines");
4278
- if (!(0, fs_1.existsSync)(baselinesDir)) {
4279
- return [];
4280
- }
4281
- const files = (0, fs_1.readdirSync)(baselinesDir).filter((f) => f.endsWith(".json"));
4282
- const baselines = [];
4283
- for (const file of files) {
4284
- try {
4285
- const content = (0, fs_1.readFileSync)((0, path_1.join)(baselinesDir, file), "utf-8");
4286
- baselines.push(JSON.parse(content));
4287
- }
4288
- catch {
4289
- // Skip invalid files
4290
- }
4291
- }
4292
- return baselines.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
4293
- }
4294
- /**
4295
- * Load a specific baseline by ID or name
4296
- */
4297
- function loadPerformanceBaseline(idOrName) {
4298
- const baselines = listPerformanceBaselines();
4299
- // Try exact ID match first
4300
- let baseline = baselines.find((b) => b.id === idOrName);
4301
- if (baseline)
4302
- return baseline;
4303
- // Try name match
4304
- baseline = baselines.find((b) => b.name === idOrName);
4305
- if (baseline)
4306
- return baseline;
4307
- // Try URL match
4308
- baseline = baselines.find((b) => b.url.includes(idOrName));
4309
- return baseline || null;
4310
- }
4311
- /**
4312
- * Delete a performance baseline
4313
- */
4314
- function deletePerformanceBaseline(idOrName) {
4315
- const baseline = loadPerformanceBaseline(idOrName);
4316
- if (!baseline)
4317
- return false;
4318
- const paths = (0, config_js_1.getPaths)();
4319
- const baselineFile = (0, path_1.join)(paths.dataDir, "baselines", `${baseline.id}.json`);
4320
- if ((0, fs_1.existsSync)(baselineFile)) {
4321
- (0, fs_1.unlinkSync)(baselineFile);
4322
- return true;
4323
- }
4324
- return false;
4325
- }
4326
- /**
4327
- * Compare current performance against a baseline
4328
- */
4329
- async function detectPerformanceRegression(url, baselineIdOrName, options = {}) {
4330
- const { thresholds = DEFAULT_REGRESSION_THRESHOLDS, headless = true } = options;
4331
- const startTime = Date.now();
4332
- // Load baseline
4333
- const baseline = loadPerformanceBaseline(baselineIdOrName);
4334
- if (!baseline) {
4335
- throw new Error(`Baseline not found: ${baselineIdOrName}`);
4336
- }
4337
- // Capture current metrics
4338
- const browser = new CBrowser({ headless });
4339
- let currentMetrics;
4340
- try {
4341
- await browser.navigate(url);
4342
- await new Promise((r) => setTimeout(r, 2000));
4343
- currentMetrics = await browser.getPerformanceMetrics();
4344
- }
4345
- finally {
4346
- await browser.close();
4347
- }
4348
- // Compare metrics
4349
- const comparisons = [];
4350
- const regressions = [];
4351
- const metricsToCompare = [
4352
- "lcp", "fid", "cls", "fcp", "ttfb", "tti", "tbt", "transferSize"
4353
- ];
4354
- for (const metric of metricsToCompare) {
4355
- const baselineValue = baseline.metrics[metric];
4356
- const currentValue = currentMetrics[metric];
4357
- if (baselineValue === undefined || currentValue === undefined)
4358
- continue;
4359
- const change = currentValue - baselineValue;
4360
- const changePercent = baselineValue > 0 ? (change / baselineValue) * 100 : 0;
4361
- // Determine threshold and if it's a regression
4362
- const threshold = thresholds[metric] || 20;
4363
- const isClsMetric = metric === "cls";
4364
- // For CLS, threshold is absolute; for others, it's percentage
4365
- const exceedsThreshold = isClsMetric
4366
- ? change > threshold
4367
- : changePercent > threshold;
4368
- let status = "stable";
4369
- let severity = "warning";
4370
- if (changePercent < -10 || (isClsMetric && change < -0.05)) {
4371
- status = "improved";
4372
- }
4373
- else if (exceedsThreshold) {
4374
- if (isClsMetric ? change > threshold * 2 : changePercent > threshold * 2) {
4375
- status = "critical";
4376
- severity = "critical";
4377
- }
4378
- else {
4379
- status = "regression";
4380
- severity = "regression";
4381
- }
4382
- }
4383
- else if (changePercent > 5 || (isClsMetric && change > 0.02)) {
4384
- status = "warning";
4385
- }
4386
- const comparison = {
4387
- metric,
4388
- baseline: baselineValue,
4389
- current: currentValue,
4390
- change,
4391
- changePercent,
4392
- isRegression: status === "regression" || status === "critical",
4393
- isImprovement: status === "improved",
4394
- status,
4395
- };
4396
- comparisons.push(comparison);
4397
- if (comparison.isRegression) {
4398
- regressions.push({
4399
- metric,
4400
- baselineValue,
4401
- currentValue,
4402
- change,
4403
- changePercent,
4404
- threshold,
4405
- severity,
4406
- });
4407
- }
4408
- }
4409
- // Calculate summary
4410
- const improved = comparisons.filter((c) => c.isImprovement).length;
4411
- const regressed = comparisons.filter((c) => c.status === "regression").length;
4412
- const critical = comparisons.filter((c) => c.status === "critical").length;
4413
- const stable = comparisons.filter((c) => c.status === "stable" || c.status === "warning").length;
4414
- const overallChange = comparisons.length > 0
4415
- ? comparisons.reduce((sum, c) => sum + c.changePercent, 0) / comparisons.length
4416
- : 0;
4417
- return {
4418
- url,
4419
- baseline,
4420
- currentMetrics,
4421
- timestamp: new Date().toISOString(),
4422
- duration: Date.now() - startTime,
4423
- comparisons,
4424
- regressions,
4425
- passed: regressions.length === 0,
4426
- summary: {
4427
- totalMetrics: comparisons.length,
4428
- improved,
4429
- stable,
4430
- regressed,
4431
- critical,
4432
- overallChange,
4433
- },
4434
- };
4435
- }
4436
- /**
4437
- * Format a performance regression report
4438
- */
4439
- function formatPerformanceRegressionReport(result) {
4440
- const lines = [];
4441
- lines.push("🔍 PERFORMANCE REGRESSION REPORT");
4442
- lines.push("═".repeat(60));
4443
- lines.push("");
4444
- lines.push(`📍 URL: ${result.url}`);
4445
- lines.push(`📊 Baseline: ${result.baseline.name} (${new Date(result.baseline.timestamp).toLocaleDateString()})`);
4446
- lines.push(`⏱️ Duration: ${(result.duration / 1000).toFixed(1)}s`);
4447
- lines.push("");
4448
- // Overall result
4449
- if (result.passed) {
4450
- lines.push("✅ PASSED - No performance regressions detected");
4451
- }
4452
- else {
4453
- lines.push(`❌ FAILED - ${result.regressions.length} regression(s) detected`);
4454
- }
4455
- lines.push("");
4456
- // Detailed comparisons
4457
- lines.push("─".repeat(60));
4458
- lines.push("METRIC COMPARISON");
4459
- lines.push("─".repeat(60));
4460
- lines.push("");
4461
- const metricNames = {
4462
- lcp: "LCP (Largest Contentful Paint)",
4463
- fid: "FID (First Input Delay)",
4464
- cls: "CLS (Cumulative Layout Shift)",
4465
- fcp: "FCP (First Contentful Paint)",
4466
- ttfb: "TTFB (Time to First Byte)",
4467
- tti: "TTI (Time to Interactive)",
4468
- tbt: "TBT (Total Blocking Time)",
4469
- transferSize: "Transfer Size",
4470
- };
4471
- for (const comp of result.comparisons) {
4472
- const name = metricNames[comp.metric] || comp.metric;
4473
- const icon = comp.isImprovement ? "✅" :
4474
- comp.status === "critical" ? "🔴" :
4475
- comp.status === "regression" ? "❌" :
4476
- comp.status === "warning" ? "⚠️" : "✓";
4477
- const unit = comp.metric === "cls" ? "" :
4478
- comp.metric === "transferSize" ? " KB" : " ms";
4479
- const baseVal = comp.metric === "transferSize"
4480
- ? (comp.baseline / 1024).toFixed(1)
4481
- : comp.baseline.toFixed(1);
4482
- const currVal = comp.metric === "transferSize"
4483
- ? (comp.current / 1024).toFixed(1)
4484
- : comp.current.toFixed(1);
4485
- const changeStr = comp.changePercent >= 0
4486
- ? `+${comp.changePercent.toFixed(1)}%`
4487
- : `${comp.changePercent.toFixed(1)}%`;
4488
- lines.push(`${icon} ${name}`);
4489
- lines.push(` Baseline: ${baseVal}${unit} → Current: ${currVal}${unit} (${changeStr})`);
4490
- lines.push("");
4491
- }
4492
- // Summary
4493
- lines.push("─".repeat(60));
4494
- lines.push("SUMMARY");
4495
- lines.push("─".repeat(60));
4496
- lines.push("");
4497
- lines.push(` Total Metrics: ${result.summary.totalMetrics}`);
4498
- lines.push(` ✅ Improved: ${result.summary.improved}`);
4499
- lines.push(` ✓ Stable: ${result.summary.stable}`);
4500
- lines.push(` ❌ Regressed: ${result.summary.regressed}`);
4501
- lines.push(` 🔴 Critical: ${result.summary.critical}`);
4502
- lines.push(` 📊 Overall Change: ${result.summary.overallChange >= 0 ? "+" : ""}${result.summary.overallChange.toFixed(1)}%`);
4503
- lines.push("");
4504
- // Recommendations if regressions found
4505
- if (result.regressions.length > 0) {
4506
- lines.push("─".repeat(60));
4507
- lines.push("💡 RECOMMENDATIONS");
4508
- lines.push("─".repeat(60));
4509
- lines.push("");
4510
- for (const reg of result.regressions) {
4511
- const name = metricNames[reg.metric] || reg.metric;
4512
- lines.push(`⚠️ ${name}:`);
4513
- switch (reg.metric) {
4514
- case "lcp":
4515
- lines.push(" - Optimize largest content element (images, videos)");
4516
- lines.push(" - Consider lazy loading below-fold content");
4517
- lines.push(" - Improve server response times");
4518
- break;
4519
- case "cls":
4520
- lines.push(" - Set explicit dimensions on images/embeds");
4521
- lines.push(" - Avoid inserting content above existing content");
4522
- lines.push(" - Reserve space for dynamic content");
4523
- break;
4524
- case "fcp":
4525
- case "ttfb":
4526
- lines.push(" - Optimize server response time");
4527
- lines.push(" - Use CDN for static assets");
4528
- lines.push(" - Enable compression (gzip/brotli)");
4529
- break;
4530
- case "tbt":
4531
- case "tti":
4532
- lines.push(" - Split long JavaScript tasks");
4533
- lines.push(" - Defer non-critical JavaScript");
4534
- lines.push(" - Remove unused code");
4535
- break;
4536
- case "transferSize":
4537
- lines.push(" - Compress and optimize assets");
4538
- lines.push(" - Remove unused CSS/JavaScript");
4539
- lines.push(" - Optimize images (WebP, proper sizing)");
4540
- break;
4541
- default:
4542
- lines.push(" - Review recent changes for performance impact");
4543
- }
4544
- lines.push("");
4545
- }
4546
- }
4547
- return lines.join("\n");
4548
- }
4549
- // ============================================================================
4550
- // Test Coverage Map (v6.5.0)
4551
- // ============================================================================
4552
- /**
4553
- * Parse test files to extract tested URLs and actions
4554
- */
4555
- function parseTestFilesForCoverage(testFiles) {
4556
- const pageMap = new Map();
4557
- for (const testFile of testFiles) {
4558
- if (!(0, fs_1.existsSync)(testFile))
4559
- continue;
4560
- const content = (0, fs_1.readFileSync)(testFile, "utf-8");
4561
- const lines = content.split("\n");
4562
- let currentUrl = null;
4563
- let lineNumber = 0;
4564
- for (const line of lines) {
4565
- lineNumber++;
4566
- const trimmed = line.trim().toLowerCase();
4567
- // Skip comments and empty lines
4568
- if (trimmed.startsWith("#") || !trimmed)
4569
- continue;
4570
- // Detect navigation
4571
- const navMatch = line.match(/(?:go to|navigate to|open|visit)\s+["']?([^"'\s]+)["']?/i);
4572
- if (navMatch) {
4573
- currentUrl = navMatch[1];
4574
- const path = normalizeUrlToPath(currentUrl);
4575
- if (!pageMap.has(path)) {
4576
- pageMap.set(path, {
4577
- url: currentUrl,
4578
- path,
4579
- testFiles: [],
4580
- actions: [],
4581
- testCount: 0,
4582
- coverageScore: 0,
4583
- });
4584
- }
4585
- const page = pageMap.get(path);
4586
- if (!page.testFiles.includes(testFile)) {
4587
- page.testFiles.push(testFile);
4588
- page.testCount++;
4589
- }
4590
- page.actions.push({
4591
- type: "navigate",
4592
- target: currentUrl,
4593
- testFile,
4594
- lineNumber,
4595
- });
4596
- }
4597
- // Detect click actions
4598
- const clickMatch = line.match(/click\s+(?:on\s+)?(?:the\s+)?["']?([^"'\n]+)["']?/i);
4599
- if (clickMatch && currentUrl) {
4600
- const path = normalizeUrlToPath(currentUrl);
4601
- const page = pageMap.get(path);
4602
- if (page) {
4603
- page.actions.push({
4604
- type: "click",
4605
- target: clickMatch[1].trim(),
4606
- testFile,
4607
- lineNumber,
4608
- });
4609
- }
4610
- }
4611
- // Detect fill/type actions
4612
- const fillMatch = line.match(/(?:type|fill|enter)\s+["']([^"']+)["']\s+(?:in|into)\s+(?:the\s+)?["']?([^"'\n]+)["']?/i);
4613
- if (fillMatch && currentUrl) {
4614
- const path = normalizeUrlToPath(currentUrl);
4615
- const page = pageMap.get(path);
4616
- if (page) {
4617
- page.actions.push({
4618
- type: "fill",
4619
- target: fillMatch[2].trim(),
4620
- value: fillMatch[1],
4621
- testFile,
4622
- lineNumber,
4623
- });
4624
- }
4625
- }
4626
- // Detect verify actions
4627
- const verifyMatch = line.match(/(?:verify|assert|check|expect|should)\s+(.+)/i);
4628
- if (verifyMatch && currentUrl) {
4629
- const path = normalizeUrlToPath(currentUrl);
4630
- const page = pageMap.get(path);
4631
- if (page) {
4632
- page.actions.push({
4633
- type: "verify",
4634
- target: verifyMatch[1].trim(),
4635
- testFile,
4636
- lineNumber,
4637
- });
4638
- }
4639
- }
4640
- // Detect wait actions
4641
- const waitMatch = line.match(/wait\s+(?:for\s+)?(.+)/i);
4642
- if (waitMatch && currentUrl) {
4643
- const path = normalizeUrlToPath(currentUrl);
4644
- const page = pageMap.get(path);
4645
- if (page) {
4646
- page.actions.push({
4647
- type: "wait",
4648
- target: waitMatch[1].trim(),
4649
- testFile,
4650
- lineNumber,
4651
- });
4652
- }
4653
- }
4654
- }
4655
- }
4656
- // Calculate coverage scores
4657
- for (const page of pageMap.values()) {
4658
- const hasClicks = page.actions.some(a => a.type === "click");
4659
- const hasFills = page.actions.some(a => a.type === "fill");
4660
- const hasVerifies = page.actions.some(a => a.type === "verify");
4661
- let score = 20; // Base score for visiting
4662
- if (hasClicks)
4663
- score += 25;
4664
- if (hasFills)
4665
- score += 25;
4666
- if (hasVerifies)
4667
- score += 30;
4668
- page.coverageScore = Math.min(100, score);
4669
- }
4670
- return Array.from(pageMap.values());
4671
- }
4672
- /**
4673
- * Normalize URL to a path for comparison
4674
- */
4675
- function normalizeUrlToPath(url) {
4676
- try {
4677
- const parsed = new URL(url);
4678
- return parsed.pathname.replace(/\/$/, "") || "/";
4679
- }
4680
- catch {
4681
- // Not a full URL, treat as path
4682
- return url.replace(/\/$/, "") || "/";
4683
- }
4684
- }
4685
- /**
4686
- * Fetch and parse sitemap.xml
4687
- */
4688
- async function parseSitemap(sitemapUrl) {
4689
- const pages = [];
4690
- try {
4691
- const response = await fetch(sitemapUrl);
4692
- const xml = await response.text();
4693
- // Simple XML parsing for sitemap
4694
- const locMatches = xml.matchAll(/<loc>([^<]+)<\/loc>/g);
4695
- for (const match of locMatches) {
4696
- const url = match[1].trim();
4697
- pages.push({
4698
- url,
4699
- path: normalizeUrlToPath(url),
4700
- source: "sitemap",
4701
- });
4702
- }
4703
- }
4704
- catch (err) {
4705
- console.error(`Failed to fetch sitemap: ${err}`);
4706
- }
4707
- return pages;
4708
- }
4709
- /**
4710
- * Crawl a site to discover pages
4711
- */
4712
- async function crawlSiteForCoverage(startUrl, maxPages = 100, includePattern, excludePattern) {
4713
- const pages = [];
4714
- const visited = new Set();
4715
- const queue = [startUrl];
4716
- const browser = new CBrowser({
4717
- headless: true,
4718
- browser: "chromium",
4719
- });
4720
- const baseUrl = new URL(startUrl);
4721
- const includeRegex = includePattern ? new RegExp(includePattern) : null;
4722
- const excludeRegex = excludePattern ? new RegExp(excludePattern) : null;
4723
- try {
4724
- while (queue.length > 0 && pages.length < maxPages) {
4725
- const url = queue.shift();
4726
- const path = normalizeUrlToPath(url);
4727
- if (visited.has(path))
4728
- continue;
4729
- visited.add(path);
4730
- // Check patterns
4731
- if (includeRegex && !includeRegex.test(path))
4732
- continue;
4733
- if (excludeRegex && excludeRegex.test(path))
4734
- continue;
4735
- try {
4736
- const result = await browser.navigate(url);
4737
- // Count interactive elements
4738
- const page = await browser.getPage();
4739
- const interactiveElements = await page.locator("button, a, input, select, textarea, [onclick], [role='button']").count();
4740
- const formCount = await page.locator("form").count();
4741
- // Get outbound links
4742
- const links = await page.locator("a[href]").evaluateAll((els) => els.map(el => el.href).filter(href => href && !href.startsWith("javascript:")));
4743
- const sitePage = {
4744
- url,
4745
- path,
4746
- title: result.title,
4747
- source: pages.length === 0 ? "crawl" : "link",
4748
- status: 200,
4749
- outboundLinks: links,
4750
- interactiveElements,
4751
- formCount,
4752
- };
4753
- pages.push(sitePage);
4754
- // Add internal links to queue
4755
- for (const link of links) {
4756
- try {
4757
- const linkUrl = new URL(link);
4758
- if (linkUrl.hostname === baseUrl.hostname && !visited.has(normalizeUrlToPath(link))) {
4759
- queue.push(link);
4760
- }
4761
- }
4762
- catch {
4763
- // Invalid URL, skip
4764
- }
4765
- }
4766
- }
4767
- catch (err) {
4768
- // Page failed to load
4769
- pages.push({
4770
- url,
4771
- path,
4772
- source: "link",
4773
- status: 0,
4774
- });
4775
- }
4776
- }
4777
- }
4778
- finally {
4779
- await browser.close();
4780
- }
4781
- return pages;
4782
- }
4783
- /**
4784
- * Identify coverage gaps
4785
- */
4786
- function identifyCoverageGaps(sitePages, testedPages, minCoverage = 50) {
4787
- const gaps = [];
4788
- const testedPaths = new Set(testedPages.map(p => p.path));
4789
- for (const sitePage of sitePages) {
4790
- const testedPage = testedPages.find(p => p.path === sitePage.path);
4791
- // Completely untested
4792
- if (!testedPage) {
4793
- const priority = determinePriority(sitePage);
4794
- gaps.push({
4795
- page: sitePage,
4796
- reason: "untested",
4797
- priority,
4798
- suggestedTests: generateSuggestedTests(sitePage),
4799
- similarTestedPages: findSimilarTestedPages(sitePage.path, testedPages),
4800
- });
4801
- continue;
4802
- }
4803
- // Low coverage
4804
- if (testedPage.coverageScore < minCoverage) {
4805
- gaps.push({
4806
- page: sitePage,
4807
- reason: "low-coverage",
4808
- priority: "medium",
4809
- suggestedTests: generateSuggestedTests(sitePage, testedPage),
4810
- });
4811
- continue;
4812
- }
4813
- // No interactions tested
4814
- const hasInteractions = testedPage.actions.some(a => a.type === "click" || a.type === "fill");
4815
- if (!hasInteractions && sitePage.interactiveElements && sitePage.interactiveElements > 5) {
4816
- gaps.push({
4817
- page: sitePage,
4818
- reason: "no-interactions",
4819
- priority: "low",
4820
- suggestedTests: [`Test interactive elements on ${sitePage.path}`],
4821
- });
4822
- }
4823
- // No verifications
4824
- const hasVerifications = testedPage.actions.some(a => a.type === "verify");
4825
- if (!hasVerifications) {
4826
- gaps.push({
4827
- page: sitePage,
4828
- reason: "no-verifications",
4829
- priority: "low",
4830
- suggestedTests: [`Add assertions to verify ${sitePage.path} content`],
4831
- });
4832
- }
4833
- }
4834
- // Sort by priority
4835
- const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
4836
- gaps.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
4837
- return gaps;
4838
- }
4839
- /**
4840
- * Determine priority of an untested page
4841
- */
4842
- function determinePriority(page) {
4843
- const path = page.path.toLowerCase();
4844
- // Critical paths
4845
- if (path.includes("checkout") || path.includes("payment") || path.includes("login") ||
4846
- path.includes("register") || path.includes("signup") || path.includes("auth")) {
4847
- return "critical";
4848
- }
4849
- // High priority - user account, settings
4850
- if (path.includes("account") || path.includes("profile") || path.includes("settings") ||
4851
- path.includes("dashboard") || path.includes("admin")) {
4852
- return "high";
4853
- }
4854
- // Medium - has forms or many interactive elements
4855
- if (page.formCount && page.formCount > 0)
4856
- return "medium";
4857
- if (page.interactiveElements && page.interactiveElements > 10)
4858
- return "medium";
4859
- return "low";
4860
- }
4861
- /**
4862
- * Generate suggested test steps for a page
4863
- */
4864
- function generateSuggestedTests(sitePage, existingTests) {
4865
- const suggestions = [];
4866
- suggestions.push(`go to ${sitePage.url}`);
4867
- if (sitePage.formCount && sitePage.formCount > 0) {
4868
- suggestions.push(`fill form fields with test data`);
4869
- suggestions.push(`submit form and verify success`);
4870
- }
4871
- if (sitePage.interactiveElements && sitePage.interactiveElements > 0) {
4872
- suggestions.push(`click primary call-to-action`);
4873
- }
4874
- suggestions.push(`verify page contains expected content`);
4875
- suggestions.push(`verify no console errors`);
4876
- if (existingTests) {
4877
- // Add specific suggestions based on what's missing
4878
- const hasClicks = existingTests.actions.some(a => a.type === "click");
4879
- const hasFills = existingTests.actions.some(a => a.type === "fill");
4880
- const hasVerifies = existingTests.actions.some(a => a.type === "verify");
4881
- if (!hasClicks)
4882
- suggestions.unshift(`# Add click interactions`);
4883
- if (!hasFills && sitePage.formCount)
4884
- suggestions.unshift(`# Add form fill tests`);
4885
- if (!hasVerifies)
4886
- suggestions.unshift(`# Add verification assertions`);
4887
- }
4888
- return suggestions;
4889
- }
4890
- /**
4891
- * Find similar tested pages for reference
4892
- */
4893
- function findSimilarTestedPages(path, testedPages) {
4894
- const segments = path.split("/").filter(Boolean);
4895
- if (segments.length === 0)
4896
- return [];
4897
- const similar = [];
4898
- const prefix = "/" + segments[0];
4899
- for (const tested of testedPages) {
4900
- if (tested.path.startsWith(prefix) && tested.path !== path) {
4901
- similar.push(tested.path);
4902
- if (similar.length >= 3)
4903
- break;
4904
- }
4905
- }
4906
- return similar;
4907
- }
4908
- /**
4909
- * Calculate overall coverage analysis
4910
- */
4911
- function calculateCoverageAnalysis(sitePages, testedPages) {
4912
- const testedPaths = new Set(testedPages.map(p => p.path));
4913
- // Section coverage
4914
- const sections = {};
4915
- for (const page of sitePages) {
4916
- const segments = page.path.split("/").filter(Boolean);
4917
- const section = segments.length > 0 ? "/" + segments[0] : "/";
4918
- if (!sections[section]) {
4919
- sections[section] = { total: 0, tested: 0 };
4920
- }
4921
- sections[section].total++;
4922
- if (testedPaths.has(page.path)) {
4923
- sections[section].tested++;
4924
- }
4925
- }
4926
- const sectionCoverage = {};
4927
- for (const [section, data] of Object.entries(sections)) {
4928
- sectionCoverage[section] = {
4929
- ...data,
4930
- percent: data.total > 0 ? Math.round((data.tested / data.total) * 100) : 0,
4931
- };
4932
- }
4933
- const totalPages = sitePages.length;
4934
- const testedCount = sitePages.filter(p => testedPaths.has(p.path)).length;
4935
- return {
4936
- totalPages,
4937
- testedPages: testedCount,
4938
- untestedPages: totalPages - testedCount,
4939
- coveragePercent: totalPages > 0 ? Math.round((testedCount / totalPages) * 100) : 0,
4940
- sectionCoverage,
4941
- };
4942
- }
4943
- /**
4944
- * Generate complete coverage map
4945
- */
4946
- async function generateCoverageMap(baseUrl, testFiles, options = {}) {
4947
- const startTime = Date.now();
4948
- // Parse test files
4949
- const testedPages = parseTestFilesForCoverage(testFiles);
4950
- // Get site pages
4951
- let sitePages;
4952
- if (options.sitemapUrl) {
4953
- sitePages = await parseSitemap(options.sitemapUrl);
4954
- }
4955
- else {
4956
- sitePages = await crawlSiteForCoverage(baseUrl, options.maxPages || 100, options.includePattern, options.excludePattern);
4957
- }
4958
- // Identify gaps
4959
- const gaps = identifyCoverageGaps(sitePages, testedPages, options.minCoverage || 50);
4960
- // Calculate analysis
4961
- const analysis = calculateCoverageAnalysis(sitePages, testedPages);
4962
- // Generate recommendations
4963
- const recommendations = [];
4964
- if (analysis.coveragePercent < 50) {
4965
- recommendations.push("Coverage is below 50% - prioritize testing critical paths");
4966
- }
4967
- const criticalGaps = gaps.filter(g => g.priority === "critical");
4968
- if (criticalGaps.length > 0) {
4969
- recommendations.push(`${criticalGaps.length} critical pages have no tests (checkout, auth, etc.)`);
4970
- }
4971
- const lowCoverageSections = Object.entries(analysis.sectionCoverage)
4972
- .filter(([_, data]) => data.percent < 30 && data.total > 2)
4973
- .map(([section]) => section);
4974
- if (lowCoverageSections.length > 0) {
4975
- recommendations.push(`Sections with low coverage: ${lowCoverageSections.join(", ")}`);
4976
- }
4977
- if (gaps.filter(g => g.reason === "no-verifications").length > 3) {
4978
- recommendations.push("Many tests lack assertions - add verification steps");
4979
- }
4980
- return {
4981
- baseUrl,
4982
- timestamp: new Date().toISOString(),
4983
- duration: Date.now() - startTime,
4984
- testFiles,
4985
- sitePages,
4986
- testedPages,
4987
- gaps,
4988
- analysis,
4989
- recommendations,
4990
- };
4991
- }
4992
- /**
4993
- * Format coverage map as text report
4994
- */
4995
- function formatCoverageReport(result) {
4996
- const lines = [];
4997
- lines.push("╔══════════════════════════════════════════════════════════════════════════════╗");
4998
- lines.push("║ TEST COVERAGE MAP REPORT ║");
4999
- lines.push("╚══════════════════════════════════════════════════════════════════════════════╝");
5000
- lines.push("");
5001
- lines.push(`📊 Site: ${result.baseUrl}`);
5002
- lines.push(`📅 Generated: ${result.timestamp}`);
5003
- lines.push(`⏱️ Analysis time: ${(result.duration / 1000).toFixed(1)}s`);
5004
- lines.push(`📝 Test files analyzed: ${result.testFiles.length}`);
5005
- lines.push("");
5006
- // Overall coverage
5007
- lines.push("═══════════════════════════════════════════════════════════════════════════════");
5008
- lines.push("📈 OVERALL COVERAGE");
5009
- lines.push("═══════════════════════════════════════════════════════════════════════════════");
5010
- lines.push("");
5011
- const { analysis } = result;
5012
- const coverageBar = generateCoverageProgressBar(analysis.coveragePercent);
5013
- lines.push(` Coverage: ${coverageBar} ${analysis.coveragePercent}%`);
5014
- lines.push("");
5015
- lines.push(` Total pages: ${analysis.totalPages}`);
5016
- lines.push(` Tested pages: ${analysis.testedPages}`);
5017
- lines.push(` Untested pages: ${analysis.untestedPages}`);
5018
- lines.push("");
5019
- // Section coverage
5020
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5021
- lines.push("📁 COVERAGE BY SECTION");
5022
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5023
- lines.push("");
5024
- const sections = Object.entries(analysis.sectionCoverage)
5025
- .sort((a, b) => b[1].total - a[1].total);
5026
- for (const [section, data] of sections) {
5027
- const bar = generateCoverageProgressBar(data.percent, 20);
5028
- const status = data.percent >= 70 ? "✅" : data.percent >= 40 ? "⚠️" : "❌";
5029
- lines.push(` ${status} ${section.padEnd(20)} ${bar} ${data.tested}/${data.total} (${data.percent}%)`);
5030
- }
5031
- lines.push("");
5032
- // Coverage gaps
5033
- if (result.gaps.length > 0) {
5034
- lines.push("═══════════════════════════════════════════════════════════════════════════════");
5035
- lines.push("🕳️ COVERAGE GAPS");
5036
- lines.push("═══════════════════════════════════════════════════════════════════════════════");
5037
- lines.push("");
5038
- const priorityEmoji = { critical: "🚨", high: "🔴", medium: "🟡", low: "🟢" };
5039
- for (const gap of result.gaps.slice(0, 15)) {
5040
- const emoji = priorityEmoji[gap.priority];
5041
- lines.push(` ${emoji} ${gap.page.path}`);
5042
- lines.push(` Reason: ${gap.reason} | Priority: ${gap.priority}`);
5043
- if (gap.suggestedTests.length > 0) {
5044
- lines.push(` Suggested: ${gap.suggestedTests[0]}`);
5045
- }
5046
- lines.push("");
5047
- }
5048
- if (result.gaps.length > 15) {
5049
- lines.push(` ... and ${result.gaps.length - 15} more gaps`);
5050
- lines.push("");
5051
- }
5052
- }
5053
- // Recommendations
5054
- if (result.recommendations.length > 0) {
5055
- lines.push("═══════════════════════════════════════════════════════════════════════════════");
5056
- lines.push("💡 RECOMMENDATIONS");
5057
- lines.push("═══════════════════════════════════════════════════════════════════════════════");
5058
- lines.push("");
5059
- for (const rec of result.recommendations) {
5060
- lines.push(` ${rec}`);
5061
- }
5062
- lines.push("");
5063
- }
5064
- // Tested pages summary
5065
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5066
- lines.push("✅ TESTED PAGES (Top 10 by coverage)");
5067
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5068
- lines.push("");
5069
- const topTested = [...result.testedPages]
5070
- .sort((a, b) => b.coverageScore - a.coverageScore)
5071
- .slice(0, 10);
5072
- for (const page of topTested) {
5073
- const bar = generateCoverageProgressBar(page.coverageScore, 15);
5074
- lines.push(` ${bar} ${page.coverageScore}% ${page.path}`);
5075
- lines.push(` Actions: ${page.actions.length} | Tests: ${page.testCount}`);
5076
- }
5077
- return lines.join("\n");
5078
- }
5079
- /**
5080
- * Generate HTML coverage report
5081
- */
5082
- function generateCoverageHtmlReport(result) {
5083
- const { analysis, gaps, testedPages } = result;
5084
- const coverageColor = analysis.coveragePercent >= 70 ? "#22c55e" :
5085
- analysis.coveragePercent >= 40 ? "#eab308" : "#ef4444";
5086
- return `<!DOCTYPE html>
5087
- <html lang="en">
5088
- <head>
5089
- <meta charset="UTF-8">
5090
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
5091
- <title>Test Coverage Map - ${result.baseUrl}</title>
5092
- <style>
5093
- * { box-sizing: border-box; margin: 0; padding: 0; }
5094
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; padding: 2rem; }
5095
- .container { max-width: 1200px; margin: 0 auto; }
5096
- h1 { color: #fff; margin-bottom: 0.5rem; }
5097
- .subtitle { color: #888; margin-bottom: 2rem; }
5098
- .card { background: #252540; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; }
5099
- .card h2 { color: #fff; font-size: 1.1rem; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem; }
5100
- .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; }
5101
- .stat { text-align: center; }
5102
- .stat-value { font-size: 2rem; font-weight: bold; color: ${coverageColor}; }
5103
- .stat-label { color: #888; font-size: 0.875rem; }
5104
- .progress-bar { height: 8px; background: #333; border-radius: 4px; overflow: hidden; margin: 1rem 0; }
5105
- .progress-fill { height: 100%; background: ${coverageColor}; transition: width 0.5s; }
5106
- .section-list { list-style: none; }
5107
- .section-item { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid #333; }
5108
- .section-name { flex: 1; }
5109
- .section-bar { width: 150px; height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
5110
- .section-bar-fill { height: 100%; border-radius: 3px; }
5111
- .section-percent { width: 60px; text-align: right; font-weight: 500; }
5112
- .gap-list { list-style: none; }
5113
- .gap-item { padding: 1rem; margin-bottom: 0.75rem; background: #1a1a2e; border-radius: 8px; border-left: 4px solid; }
5114
- .gap-critical { border-color: #ef4444; }
5115
- .gap-high { border-color: #f97316; }
5116
- .gap-medium { border-color: #eab308; }
5117
- .gap-low { border-color: #22c55e; }
5118
- .gap-path { font-weight: 600; color: #fff; }
5119
- .gap-reason { color: #888; font-size: 0.875rem; margin-top: 0.25rem; }
5120
- .badge { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
5121
- .badge-critical { background: #ef4444; color: #fff; }
5122
- .badge-high { background: #f97316; color: #fff; }
5123
- .badge-medium { background: #eab308; color: #000; }
5124
- .badge-low { background: #22c55e; color: #fff; }
5125
- .recommendations { list-style: none; }
5126
- .recommendations li { padding: 0.75rem; background: #1a1a2e; border-radius: 6px; margin-bottom: 0.5rem; }
5127
- .page-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1rem; }
5128
- .page-card { background: #1a1a2e; border-radius: 8px; padding: 1rem; }
5129
- .page-score { font-size: 1.5rem; font-weight: bold; }
5130
- .page-path { color: #888; font-size: 0.875rem; word-break: break-all; }
5131
- </style>
5132
- </head>
5133
- <body>
5134
- <div class="container">
5135
- <h1>Test Coverage Map</h1>
5136
- <p class="subtitle">${result.baseUrl} | Generated ${new Date(result.timestamp).toLocaleString()}</p>
5137
-
5138
- <div class="card">
5139
- <h2>📊 Overall Coverage</h2>
5140
- <div class="stats">
5141
- <div class="stat">
5142
- <div class="stat-value">${analysis.coveragePercent}%</div>
5143
- <div class="stat-label">Coverage</div>
5144
- </div>
5145
- <div class="stat">
5146
- <div class="stat-value">${analysis.totalPages}</div>
5147
- <div class="stat-label">Total Pages</div>
5148
- </div>
5149
- <div class="stat">
5150
- <div class="stat-value">${analysis.testedPages}</div>
5151
- <div class="stat-label">Tested</div>
5152
- </div>
5153
- <div class="stat">
5154
- <div class="stat-value">${analysis.untestedPages}</div>
5155
- <div class="stat-label">Untested</div>
5156
- </div>
5157
- </div>
5158
- <div class="progress-bar">
5159
- <div class="progress-fill" style="width: ${analysis.coveragePercent}%"></div>
5160
- </div>
5161
- </div>
5162
-
5163
- <div class="card">
5164
- <h2>📁 Coverage by Section</h2>
5165
- <ul class="section-list">
5166
- ${Object.entries(analysis.sectionCoverage)
5167
- .sort((a, b) => b[1].total - a[1].total)
5168
- .map(([section, data]) => {
5169
- const color = data.percent >= 70 ? "#22c55e" : data.percent >= 40 ? "#eab308" : "#ef4444";
5170
- return `
5171
- <li class="section-item">
5172
- <span class="section-name">${section}</span>
5173
- <div class="section-bar">
5174
- <div class="section-bar-fill" style="width: ${data.percent}%; background: ${color}"></div>
5175
- </div>
5176
- <span class="section-percent" style="color: ${color}">${data.percent}%</span>
5177
- <span style="color: #666; font-size: 0.875rem">${data.tested}/${data.total}</span>
5178
- </li>
5179
- `;
5180
- }).join("")}
5181
- </ul>
5182
- </div>
5183
-
5184
- ${gaps.length > 0 ? `
5185
- <div class="card">
5186
- <h2>🕳️ Coverage Gaps (${gaps.length})</h2>
5187
- <ul class="gap-list">
5188
- ${gaps.slice(0, 20).map(gap => `
5189
- <li class="gap-item gap-${gap.priority}">
5190
- <div style="display: flex; justify-content: space-between; align-items: center;">
5191
- <span class="gap-path">${gap.page.path}</span>
5192
- <span class="badge badge-${gap.priority}">${gap.priority}</span>
5193
- </div>
5194
- <div class="gap-reason">Reason: ${gap.reason}</div>
5195
- </li>
5196
- `).join("")}
5197
- </ul>
5198
- ${gaps.length > 20 ? `<p style="color: #666; text-align: center;">...and ${gaps.length - 20} more gaps</p>` : ""}
5199
- </div>
5200
- ` : ""}
5201
-
5202
- ${result.recommendations.length > 0 ? `
5203
- <div class="card">
5204
- <h2>💡 Recommendations</h2>
5205
- <ul class="recommendations">
5206
- ${result.recommendations.map(rec => `<li>${rec}</li>`).join("")}
5207
- </ul>
5208
- </div>
5209
- ` : ""}
5210
-
5211
- <div class="card">
5212
- <h2>✅ Tested Pages (Top 12)</h2>
5213
- <div class="page-grid">
5214
- ${testedPages
5215
- .sort((a, b) => b.coverageScore - a.coverageScore)
5216
- .slice(0, 12)
5217
- .map(page => {
5218
- const color = page.coverageScore >= 70 ? "#22c55e" : page.coverageScore >= 40 ? "#eab308" : "#ef4444";
5219
- return `
5220
- <div class="page-card">
5221
- <div class="page-score" style="color: ${color}">${page.coverageScore}%</div>
5222
- <div class="page-path">${page.path}</div>
5223
- <div style="color: #666; font-size: 0.75rem; margin-top: 0.5rem;">
5224
- ${page.actions.length} actions | ${page.testCount} test(s)
5225
- </div>
5226
- </div>
5227
- `;
5228
- }).join("")}
5229
- </div>
5230
- </div>
5231
-
5232
- <footer style="text-align: center; color: #666; margin-top: 2rem; font-size: 0.875rem;">
5233
- Generated by CBrowser v6.5.0 | Analysis took ${(result.duration / 1000).toFixed(1)}s
5234
- </footer>
5235
- </div>
5236
- </body>
5237
- </html>`;
5238
- }
5239
- /**
5240
- * Generate a text progress bar for coverage
5241
- */
5242
- function generateCoverageProgressBar(percent, width = 30) {
5243
- const filled = Math.round((percent / 100) * width);
5244
- const empty = width - filled;
5245
- return "█".repeat(filled) + "░".repeat(empty);
5246
- }
5247
- // =========================================================================
5248
- // Tier 7: AI Visual Regression (v7.0.0)
5249
- // =========================================================================
5250
- /**
5251
- * Get the path to visual baselines storage
5252
- */
5253
- function getVisualBaselinesPath() {
5254
- const baseDir = process.env.CBROWSER_DATA_DIR || (0, path_1.join)(process.cwd(), ".cbrowser");
5255
- const baselinesDir = (0, path_1.join)(baseDir, "visual-baselines");
5256
- if (!(0, fs_1.existsSync)(baselinesDir)) {
5257
- (0, fs_1.mkdirSync)(baselinesDir, { recursive: true });
5258
- }
5259
- return baselinesDir;
5260
- }
5261
- /**
5262
- * Get the path to visual baseline screenshots
5263
- */
5264
- function getVisualScreenshotsPath() {
5265
- const baselinesDir = getVisualBaselinesPath();
5266
- const screenshotsDir = (0, path_1.join)(baselinesDir, "screenshots");
5267
- if (!(0, fs_1.existsSync)(screenshotsDir)) {
5268
- (0, fs_1.mkdirSync)(screenshotsDir, { recursive: true });
5269
- }
5270
- return screenshotsDir;
5271
- }
5272
- /**
5273
- * Load all visual baselines from storage
5274
- */
5275
- function loadVisualBaselines() {
5276
- const baselinesPath = getVisualBaselinesPath();
5277
- const indexPath = (0, path_1.join)(baselinesPath, "baselines.json");
5278
- if (!(0, fs_1.existsSync)(indexPath)) {
5279
- return [];
5280
- }
5281
- try {
5282
- const data = JSON.parse((0, fs_1.readFileSync)(indexPath, "utf-8"));
5283
- return data.baselines || [];
5284
- }
5285
- catch {
5286
- return [];
5287
- }
5288
- }
5289
- /**
5290
- * Save visual baselines to storage
5291
- */
5292
- function saveVisualBaselines(baselines) {
5293
- const baselinesPath = getVisualBaselinesPath();
5294
- const indexPath = (0, path_1.join)(baselinesPath, "baselines.json");
5295
- (0, fs_1.writeFileSync)(indexPath, JSON.stringify({ baselines, updated: new Date().toISOString() }, null, 2));
5296
- }
5297
- /**
5298
- * Capture a visual baseline screenshot
5299
- */
5300
- async function captureVisualBaseline(url, name, options = {}) {
5301
- const browser = new CBrowser({
5302
- device: options.device,
5303
- viewportWidth: options.viewport?.width || 1920,
5304
- viewportHeight: options.viewport?.height || 1080,
5305
- });
5306
- try {
5307
- await browser.launch();
5308
- await browser.navigate(url);
5309
- // Wait if specified
5310
- if (options.waitFor) {
5311
- if (typeof options.waitFor === "number") {
5312
- await new Promise(resolve => setTimeout(resolve, options.waitFor));
5313
- }
5314
- else {
5315
- const page = await browser.getPage();
5316
- await page.waitForSelector(options.waitFor, { timeout: 10000 }).catch(() => { });
5317
- }
5318
- }
5319
- // Take screenshot
5320
- const screenshotsPath = getVisualScreenshotsPath();
5321
- const id = `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${Date.now()}`;
5322
- const screenshotPath = (0, path_1.join)(screenshotsPath, `${id}.png`);
5323
- const page = await browser.getPage();
5324
- if (options.selector) {
5325
- const element = page.locator(options.selector).first();
5326
- await element.screenshot({ path: screenshotPath });
5327
- }
5328
- else {
5329
- await page.screenshot({ path: screenshotPath, fullPage: false });
5330
- }
5331
- // Get dimensions
5332
- const viewport = page.viewportSize() || { width: 1920, height: 1080 };
5333
- const baseline = {
5334
- id,
5335
- name,
5336
- url,
5337
- screenshotPath,
5338
- dimensions: viewport,
5339
- viewport,
5340
- device: options.device,
5341
- timestamp: new Date().toISOString(),
5342
- selector: options.selector,
5343
- };
5344
- // Save to index
5345
- const baselines = loadVisualBaselines();
5346
- // Remove existing baseline with same name (update)
5347
- const filtered = baselines.filter(b => b.name !== name);
5348
- filtered.push(baseline);
5349
- saveVisualBaselines(filtered);
5350
- return baseline;
5351
- }
5352
- finally {
5353
- await browser.close();
5354
- }
5355
- }
5356
- /**
5357
- * List all visual baselines
5358
- */
5359
- function listVisualBaselines() {
5360
- return loadVisualBaselines();
5361
- }
5362
- /**
5363
- * Get a visual baseline by name
5364
- */
5365
- function getVisualBaseline(name) {
5366
- const baselines = loadVisualBaselines();
5367
- return baselines.find(b => b.name === name);
5368
- }
5369
- /**
5370
- * Delete a visual baseline
5371
- */
5372
- function deleteVisualBaseline(name) {
5373
- const baselines = loadVisualBaselines();
5374
- const baseline = baselines.find(b => b.name === name);
5375
- if (!baseline) {
5376
- return false;
5377
- }
5378
- // Delete screenshot file
5379
- if ((0, fs_1.existsSync)(baseline.screenshotPath)) {
5380
- (0, fs_1.unlinkSync)(baseline.screenshotPath);
5381
- }
5382
- // Update index
5383
- const filtered = baselines.filter(b => b.name !== name);
5384
- saveVisualBaselines(filtered);
5385
- return true;
5386
- }
5387
- /**
5388
- * Analyze visual differences using AI
5389
- */
5390
- async function analyzeVisualDifferences(baselinePath, currentPath, options = {}) {
5391
- // Read both images as base64
5392
- const baselineImage = (0, fs_1.readFileSync)(baselinePath).toString("base64");
5393
- const currentImage = (0, fs_1.readFileSync)(currentPath).toString("base64");
5394
- // Build the AI prompt for analysis
5395
- const sensitivityDesc = {
5396
- low: "Only flag significant, obvious changes that would clearly impact users",
5397
- medium: "Flag notable changes in layout, content, or style",
5398
- high: "Flag any visible differences, including subtle spacing or color changes",
5399
- };
5400
- const prompt = `You are a visual regression testing AI. Compare these two screenshots and identify any differences.
5401
-
5402
- BASELINE IMAGE: The first/reference screenshot
5403
- CURRENT IMAGE: The second/new screenshot
5404
-
5405
- Sensitivity level: ${options.sensitivity || "medium"} - ${sensitivityDesc[options.sensitivity || "medium"]}
5406
-
5407
- ${options.ignoreRegions?.length ? `Ignore changes in these regions: ${JSON.stringify(options.ignoreRegions)}` : ""}
5408
-
5409
- Analyze the visual differences and respond in this exact JSON format:
5410
- {
5411
- "overallStatus": "pass" | "warning" | "fail",
5412
- "summary": "Brief 1-2 sentence summary of changes found",
5413
- "changes": [
5414
- {
5415
- "type": "layout" | "content" | "style" | "missing" | "added" | "moved",
5416
- "severity": "breaking" | "warning" | "info" | "acceptable",
5417
- "region": { "x": 0, "y": 0, "width": 100, "height": 100 },
5418
- "description": "What changed",
5419
- "reasoning": "Why this matters",
5420
- "confidence": 0.95,
5421
- "suggestion": "Optional suggestion to fix or accept"
5422
- }
5423
- ],
5424
- "similarityScore": 0.85,
5425
- "productionReady": true | false,
5426
- "confidence": 0.9
5427
- }
5428
-
5429
- Change severity guidelines:
5430
- - "breaking": Layout shifts, missing critical elements, broken functionality indicators
5431
- - "warning": Noticeable content changes, significant style differences
5432
- - "info": Minor spacing changes, subtle color adjustments
5433
- - "acceptable": Expected dynamic content (timestamps, ads), minor rendering differences
5434
-
5435
- For overallStatus:
5436
- - "pass": No changes or only acceptable/info-level changes
5437
- - "warning": Some warning-level changes that should be reviewed
5438
- - "fail": Any breaking changes detected
5439
-
5440
- Respond ONLY with the JSON, no other text.`;
5441
- // Use Claude to analyze the images
5442
- // For now, we'll use a simulated response since we don't have direct API access
5443
- // In production, this would call the Anthropic API with vision
5444
- try {
5445
- // Try to use the inference tool if available
5446
- const { execSync } = await import("child_process");
5447
- const inferenceScript = (0, path_1.join)(process.env.HOME || "", ".claude/skills/Tools/Inference.ts");
5448
- if ((0, fs_1.existsSync)(inferenceScript)) {
5449
- // Create a temporary file with the images and prompt
5450
- const tempDir = (0, path_1.join)(getVisualBaselinesPath(), "temp");
5451
- if (!(0, fs_1.existsSync)(tempDir)) {
5452
- (0, fs_1.mkdirSync)(tempDir, { recursive: true });
5453
- }
5454
- const requestPath = (0, path_1.join)(tempDir, `analysis-${Date.now()}.json`);
5455
- (0, fs_1.writeFileSync)(requestPath, JSON.stringify({
5456
- prompt,
5457
- images: [
5458
- { type: "base64", media_type: "image/png", data: baselineImage },
5459
- { type: "base64", media_type: "image/png", data: currentImage },
5460
- ],
5461
- }));
5462
- // For now, perform a heuristic comparison since we can't easily call Claude with images
5463
- // This can be enhanced when proper API integration is available
5464
- const analysis = performHeuristicAnalysis(baselinePath, currentPath, options);
5465
- // Clean up
5466
- if ((0, fs_1.existsSync)(requestPath)) {
5467
- (0, fs_1.unlinkSync)(requestPath);
5468
- }
5469
- return analysis;
5470
- }
5471
- }
5472
- catch {
5473
- // Fall back to heuristic analysis
5474
- }
5475
- // Fallback: Heuristic analysis based on file comparison
5476
- return performHeuristicAnalysis(baselinePath, currentPath, options);
5477
- }
5478
- /**
5479
- * Perform heuristic visual analysis when AI is not available
5480
- */
5481
- function performHeuristicAnalysis(baselinePath, currentPath, options = {}) {
5482
- const baselineStats = (0, fs_1.statSync)(baselinePath);
5483
- const currentStats = (0, fs_1.statSync)(currentPath);
5484
- // Simple heuristic: compare file sizes
5485
- const sizeDiff = Math.abs(baselineStats.size - currentStats.size);
5486
- const sizeRatio = sizeDiff / baselineStats.size;
5487
- const changes = [];
5488
- let overallStatus = "pass";
5489
- let similarityScore = 1.0;
5490
- // Size-based heuristics
5491
- if (sizeRatio > 0.3) {
5492
- changes.push({
5493
- type: "layout",
5494
- severity: "breaking",
5495
- region: { x: 0, y: 0, width: 1920, height: 1080 },
5496
- description: "Significant visual change detected (>30% size difference)",
5497
- reasoning: "Large file size difference indicates substantial visual changes",
5498
- confidence: 0.7,
5499
- suggestion: "Review the visual changes manually",
5500
- });
5501
- overallStatus = "fail";
5502
- similarityScore = 0.5;
5503
- }
5504
- else if (sizeRatio > 0.1) {
5505
- changes.push({
5506
- type: "content",
5507
- severity: "warning",
5508
- region: { x: 0, y: 0, width: 1920, height: 1080 },
5509
- description: "Moderate visual change detected (10-30% size difference)",
5510
- reasoning: "Moderate file size difference suggests some visual changes",
5511
- confidence: 0.6,
5512
- suggestion: "Review to confirm changes are expected",
5513
- });
5514
- overallStatus = "warning";
5515
- similarityScore = 0.75;
5516
- }
5517
- else if (sizeRatio > 0.02) {
5518
- changes.push({
5519
- type: "style",
5520
- severity: "info",
5521
- region: { x: 0, y: 0, width: 1920, height: 1080 },
5522
- description: "Minor visual change detected (2-10% size difference)",
5523
- reasoning: "Small file size difference indicates minor rendering differences",
5524
- confidence: 0.5,
5525
- });
5526
- similarityScore = 0.9;
5527
- }
5528
- // Apply sensitivity adjustments
5529
- if (options.sensitivity === "low" && overallStatus === "warning") {
5530
- overallStatus = "pass";
5531
- }
5532
- else if (options.sensitivity === "high" && changes.length === 0 && sizeRatio > 0.005) {
5533
- changes.push({
5534
- type: "style",
5535
- severity: "info",
5536
- region: { x: 0, y: 0, width: 1920, height: 1080 },
5537
- description: "Very minor visual change detected",
5538
- reasoning: "Slight file size difference at high sensitivity",
5539
- confidence: 0.4,
5540
- });
5541
- similarityScore = 0.95;
5542
- }
5543
- return {
5544
- overallStatus,
5545
- summary: changes.length === 0
5546
- ? "No significant visual changes detected"
5547
- : `Found ${changes.length} visual change(s) with ${overallStatus} status`,
5548
- changes,
5549
- similarityScore,
5550
- productionReady: overallStatus !== "fail",
5551
- confidence: 0.6, // Lower confidence for heuristic analysis
5552
- rawAnalysis: "Heuristic analysis based on file comparison (AI analysis not available)",
5553
- };
5554
- }
5555
- /**
5556
- * Run visual regression test against a baseline
5557
- */
5558
- async function runVisualRegression(url, baselineName, options = {}) {
5559
- const startTime = Date.now();
5560
- const baseline = getVisualBaseline(baselineName);
5561
- if (!baseline) {
5562
- return {
5563
- passed: false,
5564
- baseline: null,
5565
- currentScreenshotPath: "",
5566
- analysis: {
5567
- overallStatus: "fail",
5568
- summary: `Baseline "${baselineName}" not found`,
5569
- changes: [],
5570
- similarityScore: 0,
5571
- productionReady: false,
5572
- confidence: 1.0,
5573
- },
5574
- duration: Date.now() - startTime,
5575
- };
5576
- }
5577
- // Capture current screenshot with same settings as baseline
5578
- const browser = new CBrowser({
5579
- device: baseline.device,
5580
- viewportWidth: baseline.viewport.width,
5581
- viewportHeight: baseline.viewport.height,
5582
- });
5583
- try {
5584
- await browser.launch();
5585
- await browser.navigate(url);
5586
- // Wait if specified in options
5587
- if (options.waitBeforeCapture) {
5588
- await new Promise(resolve => setTimeout(resolve, options.waitBeforeCapture));
5589
- }
5590
- // Take screenshot
5591
- const screenshotsPath = getVisualScreenshotsPath();
5592
- const currentScreenshotPath = (0, path_1.join)(screenshotsPath, `current-${baseline.id}-${Date.now()}.png`);
5593
- const page = await browser.getPage();
5594
- if (baseline.selector) {
5595
- const element = page.locator(baseline.selector).first();
5596
- await element.screenshot({ path: currentScreenshotPath });
5597
- }
5598
- else {
5599
- await page.screenshot({ path: currentScreenshotPath, fullPage: false });
5600
- }
5601
- // Analyze differences
5602
- const analysis = await analyzeVisualDifferences(baseline.screenshotPath, currentScreenshotPath, options);
5603
- // Determine pass/fail based on threshold
5604
- const threshold = options.threshold ?? 0.9;
5605
- const passed = analysis.similarityScore >= threshold && analysis.overallStatus !== "fail";
5606
- // Generate diff image path (if we had pixel-diff capability)
5607
- const diffImagePath = options.generateDiff
5608
- ? (0, path_1.join)(screenshotsPath, `diff-${baseline.id}-${Date.now()}.png`)
5609
- : undefined;
5610
- return {
5611
- passed,
5612
- baseline,
5613
- currentScreenshotPath,
5614
- diffImagePath,
5615
- analysis,
5616
- duration: Date.now() - startTime,
5617
- };
5618
- }
5619
- finally {
5620
- await browser.close();
5621
- }
5622
- }
5623
- /**
5624
- * Run visual regression on multiple pages
5625
- */
5626
- async function runVisualRegressionSuite(suite, options = {}) {
5627
- const startTime = Date.now();
5628
- const results = [];
5629
- let passed = 0;
5630
- let failed = 0;
5631
- let warnings = 0;
5632
- console.log(`\n🔍 Running visual regression suite: ${suite.name}`);
5633
- console.log(` Testing ${suite.pages.length} page(s)...\n`);
5634
- for (const page of suite.pages) {
5635
- console.log(` 📸 Testing: ${page.name}...`);
5636
- const result = await runVisualRegression(page.url, page.baselineName, { ...options, ...page.options });
5637
- results.push(result);
5638
- if (result.passed) {
5639
- if (result.analysis.overallStatus === "warning") {
5640
- warnings++;
5641
- console.log(` ⚠️ Warning (similarity: ${(result.analysis.similarityScore * 100).toFixed(1)}%)`);
5642
- }
5643
- else {
5644
- passed++;
5645
- console.log(` ✅ Passed (similarity: ${(result.analysis.similarityScore * 100).toFixed(1)}%)`);
5646
- }
5647
- }
5648
- else {
5649
- failed++;
5650
- console.log(` ❌ Failed: ${result.analysis.summary}`);
5651
- }
5652
- }
5653
- const duration = Date.now() - startTime;
5654
- console.log(`\n${"─".repeat(60)}`);
5655
- console.log(` Results: ${passed} passed, ${failed} failed, ${warnings} warnings`);
5656
- console.log(` Duration: ${(duration / 1000).toFixed(1)}s`);
5657
- console.log(`${"─".repeat(60)}\n`);
5658
- return {
5659
- suite,
5660
- results,
5661
- summary: {
5662
- total: suite.pages.length,
5663
- passed,
5664
- failed,
5665
- warnings,
5666
- },
5667
- duration,
5668
- timestamp: new Date().toISOString(),
5669
- };
5670
- }
5671
- /**
5672
- * Format visual regression result as text report
5673
- */
5674
- function formatVisualRegressionReport(result) {
5675
- const lines = [];
5676
- lines.push("╔══════════════════════════════════════════════════════════════════════════════╗");
5677
- lines.push("║ AI VISUAL REGRESSION REPORT ║");
5678
- lines.push("╚══════════════════════════════════════════════════════════════════════════════╝");
5679
- lines.push("");
5680
- const statusIcon = result.passed ? "✅" : "❌";
5681
- const statusText = result.passed ? "PASSED" : "FAILED";
5682
- lines.push(`${statusIcon} Status: ${statusText}`);
5683
- lines.push(`📊 Similarity: ${(result.analysis.similarityScore * 100).toFixed(1)}%`);
5684
- lines.push(`🎯 Confidence: ${(result.analysis.confidence * 100).toFixed(0)}%`);
5685
- lines.push(`⏱️ Duration: ${(result.duration / 1000).toFixed(2)}s`);
5686
- lines.push("");
5687
- if (result.baseline) {
5688
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5689
- lines.push("📸 BASELINE INFO");
5690
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5691
- lines.push(` Name: ${result.baseline.name}`);
5692
- lines.push(` URL: ${result.baseline.url}`);
5693
- lines.push(` Captured: ${result.baseline.timestamp}`);
5694
- lines.push(` Viewport: ${result.baseline.viewport.width}x${result.baseline.viewport.height}`);
5695
- if (result.baseline.device) {
5696
- lines.push(` Device: ${result.baseline.device}`);
5697
- }
5698
- lines.push("");
5699
- }
5700
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5701
- lines.push("📝 ANALYSIS SUMMARY");
5702
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5703
- lines.push(` ${result.analysis.summary}`);
5704
- lines.push("");
5705
- if (result.analysis.changes.length > 0) {
5706
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5707
- lines.push("🔄 DETECTED CHANGES");
5708
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5709
- for (const change of result.analysis.changes) {
5710
- const severityIcon = {
5711
- breaking: "🚨",
5712
- warning: "⚠️",
5713
- info: "ℹ️",
5714
- acceptable: "✓",
5715
- }[change.severity];
5716
- lines.push("");
5717
- lines.push(` ${severityIcon} [${change.severity.toUpperCase()}] ${change.type}`);
5718
- lines.push(` ${change.description}`);
5719
- lines.push(` Reasoning: ${change.reasoning}`);
5720
- if (change.suggestion) {
5721
- lines.push(` Suggestion: ${change.suggestion}`);
5722
- }
5723
- }
5724
- lines.push("");
5725
- }
5726
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5727
- lines.push(`🚀 Production Ready: ${result.analysis.productionReady ? "YES" : "NO"}`);
5728
- lines.push("───────────────────────────────────────────────────────────────────────────────");
5729
- return lines.join("\n");
5730
- }
5731
- /**
5732
- * Generate HTML report for visual regression suite
5733
- */
5734
- function generateVisualRegressionHtmlReport(suiteResult) {
5735
- const { suite, results, summary, duration, timestamp } = suiteResult;
5736
- return `<!DOCTYPE html>
5737
- <html lang="en">
5738
- <head>
5739
- <meta charset="UTF-8">
5740
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
5741
- <title>Visual Regression Report - ${suite.name}</title>
5742
- <style>
5743
- * { box-sizing: border-box; margin: 0; padding: 0; }
5744
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; line-height: 1.6; }
5745
- .container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
5746
- h1 { font-size: 2rem; margin-bottom: 0.5rem; }
5747
- h2 { font-size: 1.25rem; margin-bottom: 1rem; color: #94a3b8; }
5748
- .header { text-align: center; margin-bottom: 2rem; }
5749
- .summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
5750
- .stat { background: #1e293b; padding: 1.5rem; border-radius: 0.5rem; text-align: center; }
5751
- .stat-value { font-size: 2rem; font-weight: bold; }
5752
- .stat-label { color: #94a3b8; font-size: 0.875rem; }
5753
- .passed { color: #22c55e; }
5754
- .failed { color: #ef4444; }
5755
- .warning { color: #eab308; }
5756
- .results { display: flex; flex-direction: column; gap: 1rem; }
5757
- .result-card { background: #1e293b; border-radius: 0.5rem; overflow: hidden; }
5758
- .result-header { padding: 1rem; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #334155; }
5759
- .result-body { padding: 1rem; }
5760
- .badge { padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
5761
- .badge-pass { background: #166534; color: #22c55e; }
5762
- .badge-fail { background: #7f1d1d; color: #ef4444; }
5763
- .badge-warning { background: #713f12; color: #eab308; }
5764
- .similarity { font-size: 1.5rem; font-weight: bold; }
5765
- .changes { margin-top: 1rem; }
5766
- .change { padding: 0.75rem; background: #0f172a; border-radius: 0.25rem; margin-bottom: 0.5rem; }
5767
- .change-breaking { border-left: 3px solid #ef4444; }
5768
- .change-warning { border-left: 3px solid #eab308; }
5769
- .change-info { border-left: 3px solid #3b82f6; }
5770
- .change-acceptable { border-left: 3px solid #22c55e; }
5771
- footer { text-align: center; color: #64748b; margin-top: 2rem; padding-top: 2rem; border-top: 1px solid #334155; }
5772
- </style>
5773
- </head>
5774
- <body>
5775
- <div class="container">
5776
- <div class="header">
5777
- <h1>🔍 Visual Regression Report</h1>
5778
- <h2>${suite.name}</h2>
5779
- <p style="color: #64748b;">Generated: ${new Date(timestamp).toLocaleString()}</p>
5780
- </div>
5781
-
5782
- <div class="summary">
5783
- <div class="stat">
5784
- <div class="stat-value">${summary.total}</div>
5785
- <div class="stat-label">Total Tests</div>
5786
- </div>
5787
- <div class="stat">
5788
- <div class="stat-value passed">${summary.passed}</div>
5789
- <div class="stat-label">Passed</div>
5790
- </div>
5791
- <div class="stat">
5792
- <div class="stat-value failed">${summary.failed}</div>
5793
- <div class="stat-label">Failed</div>
5794
- </div>
5795
- <div class="stat">
5796
- <div class="stat-value warning">${summary.warnings}</div>
5797
- <div class="stat-label">Warnings</div>
5798
- </div>
5799
- </div>
5800
-
5801
- <div class="results">
5802
- ${results.map((result, i) => {
5803
- const page = suite.pages[i];
5804
- const statusClass = result.passed ? (result.analysis.overallStatus === "warning" ? "warning" : "passed") : "failed";
5805
- const badgeClass = result.passed ? (result.analysis.overallStatus === "warning" ? "badge-warning" : "badge-pass") : "badge-fail";
5806
- const statusText = result.passed ? (result.analysis.overallStatus === "warning" ? "WARNING" : "PASSED") : "FAILED";
5807
- return `
5808
- <div class="result-card">
5809
- <div class="result-header">
5810
- <div>
5811
- <strong>${page.name}</strong>
5812
- <div style="color: #64748b; font-size: 0.875rem;">${page.url}</div>
5813
- </div>
5814
- <div style="display: flex; align-items: center; gap: 1rem;">
5815
- <div class="similarity ${statusClass}">${(result.analysis.similarityScore * 100).toFixed(1)}%</div>
5816
- <span class="badge ${badgeClass}">${statusText}</span>
5817
- </div>
5818
- </div>
5819
- <div class="result-body">
5820
- <p>${result.analysis.summary}</p>
5821
- ${result.analysis.changes.length > 0 ? `
5822
- <div class="changes">
5823
- ${result.analysis.changes.map(change => `
5824
- <div class="change change-${change.severity}">
5825
- <strong>[${change.severity.toUpperCase()}] ${change.type}</strong>
5826
- <p>${change.description}</p>
5827
- ${change.suggestion ? `<p style="color: #94a3b8;"><em>Suggestion: ${change.suggestion}</em></p>` : ""}
5828
- </div>
5829
- `).join("")}
5830
- </div>
5831
- ` : ""}
5832
- </div>
5833
- </div>
5834
- `;
5835
- }).join("")}
5836
- </div>
5837
-
5838
- <footer>
5839
- Generated by CBrowser v7.1.0 | Suite completed in ${(duration / 1000).toFixed(1)}s
5840
- </footer>
5841
- </div>
5842
- </body>
5843
- </html>`;
5844
- }
5845
- // =========================================================================
5846
- // Tier 7.1: Cross-Browser Visual Testing (v7.1.0)
5847
- // =========================================================================
5848
- /**
5849
- * Get the path for cross-browser screenshots
5850
- */
5851
- function getCrossBrowserScreenshotsPath() {
5852
- const baseDir = process.env.CBROWSER_DATA_DIR || (0, path_1.join)(process.cwd(), ".cbrowser");
5853
- const dir = (0, path_1.join)(baseDir, "cross-browser");
5854
- if (!(0, fs_1.existsSync)(dir)) {
5855
- (0, fs_1.mkdirSync)(dir, { recursive: true });
5856
- }
5857
- return dir;
5858
- }
5859
- /**
5860
- * Capture screenshot with a specific browser
5861
- */
5862
- async function captureWithBrowser(url, browserType, options = {}) {
5863
- const startTime = Date.now();
5864
- const browser = new CBrowser({
5865
- browser: browserType,
5866
- viewportWidth: options.viewport?.width || 1920,
5867
- viewportHeight: options.viewport?.height || 1080,
5868
- });
5869
- try {
5870
- await browser.launch();
5871
- await browser.navigate(url);
5872
- // Wait if specified
5873
- if (options.waitForSelector) {
5874
- const page = await browser.getPage();
5875
- await page.waitForSelector(options.waitForSelector, { timeout: 10000 }).catch(() => { });
5876
- }
5877
- if (options.waitBeforeCapture) {
5878
- await new Promise(resolve => setTimeout(resolve, options.waitBeforeCapture));
5879
- }
5880
- // Take screenshot
5881
- const screenshotsPath = getCrossBrowserScreenshotsPath();
5882
- const filename = `${browserType}-${Date.now()}.png`;
5883
- const screenshotPath = (0, path_1.join)(screenshotsPath, filename);
5884
- const page = await browser.getPage();
5885
- await page.screenshot({ path: screenshotPath, fullPage: false });
5886
- // Get user agent
5887
- const userAgent = await page.evaluate(() => navigator.userAgent);
5888
- const viewport = page.viewportSize() || { width: 1920, height: 1080 };
5889
- return {
5890
- browser: browserType,
5891
- screenshotPath,
5892
- viewport,
5893
- userAgent,
5894
- timestamp: new Date().toISOString(),
5895
- captureTime: Date.now() - startTime,
5896
- };
5897
- }
5898
- finally {
5899
- await browser.close();
5900
- }
5901
- }
5902
- /**
5903
- * Run cross-browser visual test for a single URL
5904
- */
5905
- async function runCrossBrowserTest(url, options = {}) {
5906
- const startTime = Date.now();
5907
- const browsers = options.browsers || ["chromium", "firefox", "webkit"];
5908
- console.log(`\n🌐 Cross-Browser Visual Test`);
5909
- console.log(` URL: ${url}`);
5910
- console.log(` Browsers: ${browsers.join(", ")}\n`);
5911
- // Capture screenshots from each browser
5912
- const screenshots = [];
5913
- for (const browserType of browsers) {
5914
- console.log(` 📸 Capturing ${browserType}...`);
5915
- try {
5916
- const screenshot = await captureWithBrowser(url, browserType, options);
5917
- screenshots.push(screenshot);
5918
- console.log(` ✅ Captured in ${screenshot.captureTime}ms`);
5919
- }
5920
- catch (error) {
5921
- console.log(` ❌ Failed: ${error instanceof Error ? error.message : "Unknown error"}`);
5922
- }
5923
- }
5924
- if (screenshots.length < 2) {
5925
- return {
5926
- url,
5927
- screenshots,
5928
- comparisons: [],
5929
- overallStatus: "major_differences",
5930
- summary: "Could not capture enough screenshots for comparison",
5931
- problematicBrowsers: [],
5932
- duration: Date.now() - startTime,
5933
- timestamp: new Date().toISOString(),
5934
- };
5935
- }
5936
- // Compare all pairs of browsers
5937
- const comparisons = [];
5938
- let hasMinorDifferences = false;
5939
- let hasMajorDifferences = false;
5940
- const problematicBrowsers = new Set();
5941
- console.log(`\n 🔍 Comparing browsers...`);
5942
- for (let i = 0; i < screenshots.length; i++) {
5943
- for (let j = i + 1; j < screenshots.length; j++) {
5944
- const a = screenshots[i];
5945
- const b = screenshots[j];
5946
- console.log(` ${a.browser} vs ${b.browser}...`);
5947
- // Use the existing AI visual analysis
5948
- const analysis = await analyzeVisualDifferences(a.screenshotPath, b.screenshotPath, { sensitivity: options.sensitivity || "medium" });
5949
- comparisons.push({
5950
- browserA: a.browser,
5951
- browserB: b.browser,
5952
- analysis,
5953
- screenshots: {
5954
- a: a.screenshotPath,
5955
- b: b.screenshotPath,
5956
- },
5957
- });
5958
- if (analysis.overallStatus === "fail") {
5959
- hasMajorDifferences = true;
5960
- problematicBrowsers.add(a.browser);
5961
- problematicBrowsers.add(b.browser);
5962
- console.log(` ❌ Major differences (${(analysis.similarityScore * 100).toFixed(1)}%)`);
5963
- }
5964
- else if (analysis.overallStatus === "warning") {
5965
- hasMinorDifferences = true;
5966
- console.log(` ⚠️ Minor differences (${(analysis.similarityScore * 100).toFixed(1)}%)`);
5967
- }
5968
- else {
5969
- console.log(` ✅ Consistent (${(analysis.similarityScore * 100).toFixed(1)}%)`);
5970
- }
5971
- }
5972
- }
5973
- const overallStatus = hasMajorDifferences
5974
- ? "major_differences"
5975
- : hasMinorDifferences
5976
- ? "minor_differences"
5977
- : "consistent";
5978
- const summary = overallStatus === "consistent"
5979
- ? "Page renders consistently across all tested browsers"
5980
- : overallStatus === "minor_differences"
5981
- ? "Minor rendering differences detected between browsers"
5982
- : "Significant rendering differences detected between browsers";
5983
- return {
5984
- url,
5985
- screenshots,
5986
- comparisons,
5987
- overallStatus,
5988
- summary,
5989
- problematicBrowsers: Array.from(problematicBrowsers),
5990
- duration: Date.now() - startTime,
5991
- timestamp: new Date().toISOString(),
5992
- };
5993
- }
5994
- /**
5995
- * Run cross-browser test suite
5996
- */
5997
- async function runCrossBrowserSuite(suite) {
5998
- const startTime = Date.now();
5999
- const results = [];
6000
- let consistent = 0;
6001
- let minorDifferences = 0;
6002
- let majorDifferences = 0;
6003
- console.log(`\n🌐 Cross-Browser Visual Test Suite: ${suite.name}`);
6004
- console.log(` Testing ${suite.urls.length} URL(s)...\n`);
6005
- for (const url of suite.urls) {
6006
- const result = await runCrossBrowserTest(url, suite.options);
6007
- results.push(result);
6008
- switch (result.overallStatus) {
6009
- case "consistent":
6010
- consistent++;
6011
- break;
6012
- case "minor_differences":
6013
- minorDifferences++;
6014
- break;
6015
- case "major_differences":
6016
- majorDifferences++;
6017
- break;
6018
- }
6019
- }
6020
- return {
6021
- suite,
6022
- results,
6023
- summary: {
6024
- total: suite.urls.length,
6025
- consistent,
6026
- minorDifferences,
6027
- majorDifferences,
6028
- },
6029
- duration: Date.now() - startTime,
6030
- timestamp: new Date().toISOString(),
6031
- };
6032
- }
6033
- /**
6034
- * Format cross-browser result as text report
6035
- */
6036
- function formatCrossBrowserReport(result) {
6037
- const lines = [];
6038
- lines.push("╔══════════════════════════════════════════════════════════════════════════════╗");
6039
- lines.push("║ CROSS-BROWSER VISUAL TEST REPORT ║");
6040
- lines.push("╚══════════════════════════════════════════════════════════════════════════════╝");
6041
- lines.push("");
6042
- const statusIcon = {
6043
- consistent: "✅",
6044
- minor_differences: "⚠️",
6045
- major_differences: "❌",
6046
- }[result.overallStatus];
6047
- const statusText = {
6048
- consistent: "CONSISTENT",
6049
- minor_differences: "MINOR DIFFERENCES",
6050
- major_differences: "MAJOR DIFFERENCES",
6051
- }[result.overallStatus];
6052
- lines.push(`${statusIcon} Status: ${statusText}`);
6053
- lines.push(`🔗 URL: ${result.url}`);
6054
- lines.push(`⏱️ Duration: ${(result.duration / 1000).toFixed(2)}s`);
6055
- lines.push("");
6056
- lines.push("───────────────────────────────────────────────────────────────────────────────");
6057
- lines.push("📸 BROWSER SCREENSHOTS");
6058
- lines.push("───────────────────────────────────────────────────────────────────────────────");
6059
- for (const screenshot of result.screenshots) {
6060
- lines.push(` ${screenshot.browser.toUpperCase()}`);
6061
- lines.push(` Viewport: ${screenshot.viewport.width}x${screenshot.viewport.height}`);
6062
- lines.push(` Capture time: ${screenshot.captureTime}ms`);
6063
- lines.push(` Path: ${screenshot.screenshotPath}`);
6064
- lines.push("");
6065
- }
6066
- lines.push("───────────────────────────────────────────────────────────────────────────────");
6067
- lines.push("🔍 BROWSER COMPARISONS");
6068
- lines.push("───────────────────────────────────────────────────────────────────────────────");
6069
- for (const comparison of result.comparisons) {
6070
- const compIcon = {
6071
- pass: "✅",
6072
- warning: "⚠️",
6073
- fail: "❌",
6074
- }[comparison.analysis.overallStatus];
6075
- lines.push(` ${comparison.browserA.toUpperCase()} vs ${comparison.browserB.toUpperCase()}: ${compIcon}`);
6076
- lines.push(` Similarity: ${(comparison.analysis.similarityScore * 100).toFixed(1)}%`);
6077
- lines.push(` ${comparison.analysis.summary}`);
6078
- if (comparison.analysis.changes.length > 0) {
6079
- for (const change of comparison.analysis.changes) {
6080
- lines.push(` - [${change.severity.toUpperCase()}] ${change.description}`);
6081
- }
6082
- }
6083
- lines.push("");
6084
- }
6085
- if (result.problematicBrowsers.length > 0) {
6086
- lines.push("───────────────────────────────────────────────────────────────────────────────");
6087
- lines.push("⚠️ BROWSERS WITH ISSUES");
6088
- lines.push("───────────────────────────────────────────────────────────────────────────────");
6089
- for (const browser of result.problematicBrowsers) {
6090
- lines.push(` • ${browser}`);
6091
- }
6092
- lines.push("");
6093
- }
6094
- lines.push("───────────────────────────────────────────────────────────────────────────────");
6095
- lines.push(`📝 SUMMARY: ${result.summary}`);
6096
- lines.push("───────────────────────────────────────────────────────────────────────────────");
6097
- return lines.join("\n");
6098
- }
6099
- /**
6100
- * Generate HTML report for cross-browser test suite
6101
- */
6102
- function generateCrossBrowserHtmlReport(suiteResult) {
6103
- const { suite, results, summary, duration, timestamp } = suiteResult;
6104
- return `<!DOCTYPE html>
6105
- <html lang="en">
6106
- <head>
6107
- <meta charset="UTF-8">
6108
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6109
- <title>Cross-Browser Visual Report - ${suite.name}</title>
6110
- <style>
6111
- * { box-sizing: border-box; margin: 0; padding: 0; }
6112
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; line-height: 1.6; }
6113
- .container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
6114
- h1 { font-size: 2rem; margin-bottom: 0.5rem; }
6115
- h2 { font-size: 1.25rem; margin-bottom: 1rem; color: #94a3b8; }
6116
- .header { text-align: center; margin-bottom: 2rem; }
6117
- .summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
6118
- .stat { background: #1e293b; padding: 1.5rem; border-radius: 0.5rem; text-align: center; }
6119
- .stat-value { font-size: 2rem; font-weight: bold; }
6120
- .stat-label { color: #94a3b8; font-size: 0.875rem; }
6121
- .consistent { color: #22c55e; }
6122
- .minor { color: #eab308; }
6123
- .major { color: #ef4444; }
6124
- .results { display: flex; flex-direction: column; gap: 2rem; }
6125
- .result-card { background: #1e293b; border-radius: 0.5rem; overflow: hidden; }
6126
- .result-header { padding: 1rem; border-bottom: 1px solid #334155; display: flex; justify-content: space-between; align-items: center; }
6127
- .result-body { padding: 1rem; }
6128
- .screenshots { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
6129
- .screenshot-card { background: #0f172a; border-radius: 0.25rem; padding: 1rem; text-align: center; }
6130
- .screenshot-card img { max-width: 100%; border-radius: 0.25rem; margin-top: 0.5rem; }
6131
- .browser-name { font-weight: bold; text-transform: capitalize; }
6132
- .comparisons { margin-top: 1rem; }
6133
- .comparison { padding: 0.75rem; background: #0f172a; border-radius: 0.25rem; margin-bottom: 0.5rem; }
6134
- .comparison-header { display: flex; justify-content: space-between; align-items: center; }
6135
- .badge { padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
6136
- .badge-consistent { background: #166534; color: #22c55e; }
6137
- .badge-minor { background: #713f12; color: #eab308; }
6138
- .badge-major { background: #7f1d1d; color: #ef4444; }
6139
- footer { text-align: center; color: #64748b; margin-top: 2rem; padding-top: 2rem; border-top: 1px solid #334155; }
6140
- </style>
6141
- </head>
6142
- <body>
6143
- <div class="container">
6144
- <div class="header">
6145
- <h1>🌐 Cross-Browser Visual Report</h1>
6146
- <h2>${suite.name}</h2>
6147
- <p style="color: #64748b;">Generated: ${new Date(timestamp).toLocaleString()}</p>
6148
- </div>
6149
-
6150
- <div class="summary">
6151
- <div class="stat">
6152
- <div class="stat-value">${summary.total}</div>
6153
- <div class="stat-label">URLs Tested</div>
6154
- </div>
6155
- <div class="stat">
6156
- <div class="stat-value consistent">${summary.consistent}</div>
6157
- <div class="stat-label">Consistent</div>
6158
- </div>
6159
- <div class="stat">
6160
- <div class="stat-value minor">${summary.minorDifferences}</div>
6161
- <div class="stat-label">Minor Differences</div>
6162
- </div>
6163
- <div class="stat">
6164
- <div class="stat-value major">${summary.majorDifferences}</div>
6165
- <div class="stat-label">Major Differences</div>
6166
- </div>
6167
- </div>
6168
-
6169
- <div class="results">
6170
- ${results.map(result => {
6171
- const statusClass = result.overallStatus === "consistent" ? "consistent" : result.overallStatus === "minor_differences" ? "minor" : "major";
6172
- const badgeClass = result.overallStatus === "consistent" ? "badge-consistent" : result.overallStatus === "minor_differences" ? "badge-minor" : "badge-major";
6173
- const statusText = result.overallStatus === "consistent" ? "Consistent" : result.overallStatus === "minor_differences" ? "Minor Differences" : "Major Differences";
6174
- return `
6175
- <div class="result-card">
6176
- <div class="result-header">
6177
- <div>
6178
- <strong>${result.url}</strong>
6179
- <div style="color: #64748b; font-size: 0.875rem;">${result.summary}</div>
6180
- </div>
6181
- <span class="badge ${badgeClass}">${statusText}</span>
6182
- </div>
6183
- <div class="result-body">
6184
- <h3 style="margin-bottom: 1rem; color: #94a3b8;">Screenshots</h3>
6185
- <div class="screenshots">
6186
- ${result.screenshots.map(s => `
6187
- <div class="screenshot-card">
6188
- <div class="browser-name">${s.browser}</div>
6189
- <div style="color: #64748b; font-size: 0.75rem;">${s.viewport.width}x${s.viewport.height} • ${s.captureTime}ms</div>
6190
- </div>
6191
- `).join("")}
6192
- </div>
6193
-
6194
- <h3 style="margin: 1rem 0; color: #94a3b8;">Comparisons</h3>
6195
- <div class="comparisons">
6196
- ${result.comparisons.map(c => {
6197
- const cBadgeClass = c.analysis.overallStatus === "pass" ? "badge-consistent" : c.analysis.overallStatus === "warning" ? "badge-minor" : "badge-major";
6198
- return `
6199
- <div class="comparison">
6200
- <div class="comparison-header">
6201
- <span>${c.browserA} vs ${c.browserB}</span>
6202
- <span class="badge ${cBadgeClass}">${(c.analysis.similarityScore * 100).toFixed(1)}%</span>
6203
- </div>
6204
- <p style="color: #94a3b8; font-size: 0.875rem; margin-top: 0.5rem;">${c.analysis.summary}</p>
6205
- </div>
6206
- `;
6207
- }).join("")}
6208
- </div>
6209
- </div>
6210
- </div>
6211
- `;
6212
- }).join("")}
6213
- </div>
6214
-
6215
- <footer>
6216
- Generated by CBrowser v7.2.0 | Test completed in ${(duration / 1000).toFixed(1)}s
6217
- </footer>
6218
- </div>
6219
- </body>
6220
- </html>`;
6221
- }
6222
- // ============================================================================
6223
- // Responsive Visual Testing (v7.2.0)
6224
- // ============================================================================
6225
- /**
6226
- * Get viewport presets by name or return custom preset
6227
- */
6228
- function resolveViewports(viewports) {
6229
- if (!viewports || viewports.length === 0) {
6230
- // Default: mobile, tablet, desktop
6231
- return types_js_1.VIEWPORT_PRESETS.filter(v => v.name === "mobile" || v.name === "tablet" || v.name === "desktop");
6232
- }
6233
- return viewports.map(v => {
6234
- if (typeof v === "string") {
6235
- const preset = types_js_1.VIEWPORT_PRESETS.find(p => p.name === v);
6236
- if (!preset) {
6237
- throw new Error(`Unknown viewport preset: ${v}. Available: ${types_js_1.VIEWPORT_PRESETS.map(p => p.name).join(", ")}`);
6238
- }
6239
- return preset;
6240
- }
6241
- return v;
6242
- });
6243
- }
6244
- /**
6245
- * Get the path for responsive testing screenshots
6246
- */
6247
- function getResponsiveScreenshotsPath() {
6248
- const basePath = process.cwd();
6249
- const screenshotsPath = (0, path_1.join)(basePath, ".cbrowser", "responsive");
6250
- if (!(0, fs_1.existsSync)(screenshotsPath)) {
6251
- (0, fs_1.mkdirSync)(screenshotsPath, { recursive: true });
6252
- }
6253
- return screenshotsPath;
6254
- }
6255
- /**
6256
- * Capture screenshot at a specific viewport
6257
- */
6258
- async function captureAtViewport(url, viewport, options = {}) {
6259
- const startTime = Date.now();
6260
- const browser = new CBrowser({
6261
- viewportWidth: viewport.width,
6262
- viewportHeight: viewport.height,
6263
- });
6264
- try {
6265
- await browser.launch();
6266
- const page = await browser.getPage();
6267
- // Set mobile emulation if needed
6268
- if (viewport.isMobile || viewport.hasTouch) {
6269
- await page.emulateMedia({ reducedMotion: "reduce" });
6270
- }
6271
- await browser.navigate(url);
6272
- // Wait if specified
6273
- if (options.waitForSelector) {
6274
- await page.waitForSelector(options.waitForSelector, { timeout: 10000 }).catch(() => { });
6275
- }
6276
- if (options.waitBeforeCapture) {
6277
- await new Promise(resolve => setTimeout(resolve, options.waitBeforeCapture));
6278
- }
6279
- // Take screenshot
6280
- const screenshotsPath = getResponsiveScreenshotsPath();
6281
- const filename = `${viewport.name}-${Date.now()}.png`;
6282
- const screenshotPath = (0, path_1.join)(screenshotsPath, filename);
6283
- await page.screenshot({ path: screenshotPath, fullPage: false });
6284
- return {
6285
- viewport,
6286
- screenshotPath,
6287
- timestamp: new Date().toISOString(),
6288
- captureTime: Date.now() - startTime,
6289
- };
6290
- }
6291
- finally {
6292
- await browser.close();
6293
- }
6294
- }
6295
- /**
6296
- * Analyze responsive issues from comparisons
6297
- */
6298
- function analyzeResponsiveIssues(comparisons, screenshots) {
6299
- const issues = [];
6300
- for (const comparison of comparisons) {
6301
- if (comparison.analysis.overallStatus !== "pass") {
6302
- const changes = comparison.analysis.changes || [];
6303
- for (const change of changes) {
6304
- let issueType = "other";
6305
- const desc = change.description.toLowerCase();
6306
- if (desc.includes("overflow") || desc.includes("scroll")) {
6307
- issueType = "overflow";
6308
- }
6309
- else if (desc.includes("truncat") || desc.includes("cut off")) {
6310
- issueType = "truncation";
6311
- }
6312
- else if (desc.includes("overlap")) {
6313
- issueType = "overlap";
6314
- }
6315
- else if (desc.includes("hidden") || desc.includes("disappear")) {
6316
- issueType = "hidden_content";
6317
- }
6318
- else if (desc.includes("text") && (desc.includes("small") || desc.includes("read"))) {
6319
- issueType = "unreadable_text";
6320
- }
6321
- else if (desc.includes("layout") || desc.includes("break") || desc.includes("shift")) {
6322
- issueType = "layout_break";
6323
- }
6324
- // Map VisualChange severity to ResponsiveIssue severity
6325
- const severityMap = {
6326
- breaking: "critical",
6327
- warning: "major",
6328
- info: "minor",
6329
- acceptable: "minor",
6330
- };
6331
- issues.push({
6332
- type: issueType,
6333
- severity: severityMap[change.severity] || "minor",
6334
- description: change.description,
6335
- affectedViewports: [comparison.viewportA.name, comparison.viewportB.name],
6336
- breakpointRange: {
6337
- min: Math.min(comparison.viewportA.width, comparison.viewportB.width),
6338
- max: Math.max(comparison.viewportA.width, comparison.viewportB.width),
6339
- },
6340
- });
6341
- }
6342
- }
6343
- }
6344
- return issues;
6345
- }
6346
- /**
6347
- * Run responsive visual test for a single URL
6348
- */
6349
- async function runResponsiveTest(url, options = {}) {
6350
- const startTime = Date.now();
6351
- const viewports = resolveViewports(options.viewports);
6352
- console.log(`\n📱 Responsive Visual Test`);
6353
- console.log(` URL: ${url}`);
6354
- console.log(` Viewports: ${viewports.map(v => v.name).join(", ")}\n`);
6355
- // Capture screenshots at each viewport
6356
- const screenshots = [];
6357
- for (const viewport of viewports) {
6358
- console.log(` 📸 Capturing ${viewport.name} (${viewport.width}x${viewport.height})...`);
6359
- try {
6360
- const screenshot = await captureAtViewport(url, viewport, options);
6361
- screenshots.push(screenshot);
6362
- console.log(` ✅ Captured in ${screenshot.captureTime}ms`);
6363
- }
6364
- catch (error) {
6365
- console.log(` ❌ Failed: ${error instanceof Error ? error.message : "Unknown error"}`);
6366
- }
6367
- }
6368
- if (screenshots.length < 2) {
6369
- return {
6370
- url,
6371
- screenshots,
6372
- comparisons: [],
6373
- issues: [],
6374
- overallStatus: "major_issues",
6375
- summary: "Could not capture enough screenshots for comparison",
6376
- problematicViewports: [],
6377
- duration: Date.now() - startTime,
6378
- timestamp: new Date().toISOString(),
6379
- };
6380
- }
6381
- // Compare adjacent viewport sizes (small to large)
6382
- const sortedScreenshots = [...screenshots].sort((a, b) => a.viewport.width - b.viewport.width);
6383
- const comparisons = [];
6384
- let hasMinorIssues = false;
6385
- let hasMajorIssues = false;
6386
- const problematicViewports = new Set();
6387
- console.log(`\n 🔍 Comparing viewports...`);
6388
- for (let i = 0; i < sortedScreenshots.length - 1; i++) {
6389
- const a = sortedScreenshots[i];
6390
- const b = sortedScreenshots[i + 1];
6391
- console.log(` ${a.viewport.name} → ${b.viewport.name}...`);
6392
- const analysis = await analyzeVisualDifferences(a.screenshotPath, b.screenshotPath, { sensitivity: options.sensitivity || "medium" });
6393
- comparisons.push({
6394
- viewportA: a.viewport,
6395
- viewportB: b.viewport,
6396
- analysis,
6397
- screenshots: {
6398
- a: a.screenshotPath,
6399
- b: b.screenshotPath,
6400
- },
6401
- });
6402
- if (analysis.overallStatus === "fail") {
6403
- hasMajorIssues = true;
6404
- problematicViewports.add(a.viewport.name);
6405
- problematicViewports.add(b.viewport.name);
6406
- console.log(` ❌ Major issues (${(analysis.similarityScore * 100).toFixed(1)}%)`);
6407
- }
6408
- else if (analysis.overallStatus === "warning") {
6409
- hasMinorIssues = true;
6410
- console.log(` ⚠️ Minor issues (${(analysis.similarityScore * 100).toFixed(1)}%)`);
6411
- }
6412
- else {
6413
- console.log(` ✅ Responsive (${(analysis.similarityScore * 100).toFixed(1)}%)`);
6414
- }
6415
- }
6416
- // Analyze issues
6417
- const issues = analyzeResponsiveIssues(comparisons, screenshots);
6418
- const overallStatus = hasMajorIssues
6419
- ? "major_issues"
6420
- : hasMinorIssues
6421
- ? "minor_issues"
6422
- : "responsive";
6423
- const summary = overallStatus === "responsive"
6424
- ? "Page is fully responsive across all tested viewports"
6425
- : overallStatus === "minor_issues"
6426
- ? "Minor responsive issues detected"
6427
- : "Significant responsive issues detected";
6428
- return {
6429
- url,
6430
- screenshots,
6431
- comparisons,
6432
- issues,
6433
- overallStatus,
6434
- summary,
6435
- problematicViewports: Array.from(problematicViewports),
6436
- duration: Date.now() - startTime,
6437
- timestamp: new Date().toISOString(),
6438
- };
6439
- }
6440
- /**
6441
- * Run responsive test suite for multiple URLs
6442
- */
6443
- async function runResponsiveSuite(suite) {
6444
- const startTime = Date.now();
6445
- const results = [];
6446
- console.log(`\n📱 Responsive Test Suite: ${suite.name}`);
6447
- console.log(` Testing ${suite.urls.length} URLs\n`);
6448
- for (const url of suite.urls) {
6449
- const result = await runResponsiveTest(url, suite.options);
6450
- results.push(result);
6451
- }
6452
- // Aggregate common issues
6453
- const issueMap = new Map();
6454
- for (const result of results) {
6455
- for (const issue of result.issues) {
6456
- const key = `${issue.type}-${issue.description}`;
6457
- if (issueMap.has(key)) {
6458
- const existing = issueMap.get(key);
6459
- existing.affectedViewports = [...new Set([...existing.affectedViewports, ...issue.affectedViewports])];
6460
- }
6461
- else {
6462
- issueMap.set(key, { ...issue });
6463
- }
6464
- }
6465
- }
6466
- return {
6467
- suite,
6468
- results,
6469
- summary: {
6470
- total: results.length,
6471
- responsive: results.filter(r => r.overallStatus === "responsive").length,
6472
- minorIssues: results.filter(r => r.overallStatus === "minor_issues").length,
6473
- majorIssues: results.filter(r => r.overallStatus === "major_issues").length,
6474
- totalIssues: results.reduce((sum, r) => sum + r.issues.length, 0),
6475
- },
6476
- commonIssues: Array.from(issueMap.values()),
6477
- duration: Date.now() - startTime,
6478
- timestamp: new Date().toISOString(),
6479
- };
6480
- }
6481
- /**
6482
- * Format responsive test result as console report
6483
- */
6484
- function formatResponsiveReport(result) {
6485
- const lines = [];
6486
- const duration = (result.duration / 1000).toFixed(2);
6487
- lines.push(`╔${"═".repeat(78)}╗`);
6488
- lines.push(`║${" ".repeat(20)}RESPONSIVE VISUAL TEST REPORT${" ".repeat(29)}║`);
6489
- lines.push(`╚${"═".repeat(78)}╝`);
6490
- lines.push("");
6491
- const statusIcon = result.overallStatus === "responsive" ? "✅" : result.overallStatus === "minor_issues" ? "⚠️" : "❌";
6492
- const statusText = result.overallStatus.toUpperCase().replace("_", " ");
6493
- lines.push(`${statusIcon} Status: ${statusText}`);
6494
- lines.push(`🔗 URL: ${result.url}`);
6495
- lines.push(`⏱️ Duration: ${duration}s`);
6496
- lines.push("");
6497
- lines.push("─".repeat(79));
6498
- lines.push("📸 VIEWPORT SCREENSHOTS");
6499
- lines.push("─".repeat(79));
6500
- for (const screenshot of result.screenshots) {
6501
- const v = screenshot.viewport;
6502
- lines.push(` ${v.name.toUpperCase()} (${v.deviceType})`);
6503
- lines.push(` Dimensions: ${v.width}x${v.height}`);
6504
- if (v.deviceName)
6505
- lines.push(` Device: ${v.deviceName}`);
6506
- lines.push(` Capture time: ${screenshot.captureTime}ms`);
6507
- lines.push(` Path: ${screenshot.screenshotPath}`);
6508
- lines.push("");
6509
- }
6510
- lines.push("─".repeat(79));
6511
- lines.push("🔍 VIEWPORT COMPARISONS");
6512
- lines.push("─".repeat(79));
6513
- for (const comparison of result.comparisons) {
6514
- const icon = comparison.analysis.overallStatus === "pass" ? "✅" : comparison.analysis.overallStatus === "warning" ? "⚠️" : "❌";
6515
- lines.push(` ${comparison.viewportA.name} → ${comparison.viewportB.name}: ${icon}`);
6516
- lines.push(` Similarity: ${(comparison.analysis.similarityScore * 100).toFixed(1)}%`);
6517
- lines.push(` ${comparison.analysis.summary}`);
6518
- lines.push("");
6519
- }
6520
- if (result.issues.length > 0) {
6521
- lines.push("─".repeat(79));
6522
- lines.push("⚠️ RESPONSIVE ISSUES DETECTED");
6523
- lines.push("─".repeat(79));
6524
- for (const issue of result.issues) {
6525
- const severityIcon = issue.severity === "critical" ? "🔴" : issue.severity === "major" ? "🟠" : "🟡";
6526
- lines.push(` ${severityIcon} [${issue.type.toUpperCase()}] ${issue.description}`);
6527
- lines.push(` Affected: ${issue.affectedViewports.join(", ")}`);
6528
- if (issue.breakpointRange) {
6529
- lines.push(` Breakpoint range: ${issue.breakpointRange.min}px - ${issue.breakpointRange.max}px`);
6530
- }
6531
- lines.push("");
6532
- }
6533
- }
6534
- lines.push("─".repeat(79));
6535
- lines.push(`📝 SUMMARY: ${result.summary}`);
6536
- lines.push("─".repeat(79));
6537
- return lines.join("\n");
6538
- }
6539
- /**
6540
- * Generate HTML report for responsive test suite
6541
- */
6542
- function generateResponsiveHtmlReport(suiteResult) {
6543
- const { suite, results, summary, duration } = suiteResult;
6544
- return `<!DOCTYPE html>
6545
- <html>
6546
- <head>
6547
- <title>Responsive Test Report - ${suite.name}</title>
6548
- <style>
6549
- * { margin: 0; padding: 0; box-sizing: border-box; }
6550
- body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; line-height: 1.6; }
6551
- .container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
6552
- header { text-align: center; margin-bottom: 2rem; }
6553
- h1 { font-size: 2rem; margin-bottom: 0.5rem; background: linear-gradient(135deg, #8b5cf6, #06b6d4); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
6554
- .subtitle { color: #94a3b8; }
6555
- .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
6556
- .summary-card { background: #1e293b; border-radius: 0.5rem; padding: 1.5rem; text-align: center; }
6557
- .summary-value { font-size: 2rem; font-weight: bold; }
6558
- .summary-label { color: #94a3b8; font-size: 0.875rem; }
6559
- .responsive { color: #22c55e; }
6560
- .minor { color: #f59e0b; }
6561
- .major { color: #ef4444; }
6562
- .result-card { background: #1e293b; border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 1rem; }
6563
- .result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
6564
- .badge { padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
6565
- .badge-responsive { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
6566
- .badge-minor { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
6567
- .badge-major { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
6568
- .viewport-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem; margin-top: 1rem; }
6569
- .viewport-item { background: #0f172a; padding: 0.75rem; border-radius: 0.375rem; font-size: 0.875rem; }
6570
- .viewport-name { font-weight: 600; color: #8b5cf6; }
6571
- .issue { background: #0f172a; padding: 0.75rem; border-radius: 0.375rem; margin-top: 0.5rem; border-left: 3px solid; }
6572
- .issue-critical { border-color: #ef4444; }
6573
- .issue-major { border-color: #f59e0b; }
6574
- .issue-minor { border-color: #22c55e; }
6575
- footer { text-align: center; color: #64748b; padding: 2rem 0; font-size: 0.875rem; }
6576
- </style>
6577
- </head>
6578
- <body>
6579
- <div class="container">
6580
- <header>
6581
- <h1>📱 Responsive Test Report</h1>
6582
- <p class="subtitle">${suite.name}</p>
6583
- </header>
6584
-
6585
- <div class="summary-grid">
6586
- <div class="summary-card">
6587
- <div class="summary-value">${summary.total}</div>
6588
- <div class="summary-label">Total URLs</div>
6589
- </div>
6590
- <div class="summary-card">
6591
- <div class="summary-value responsive">${summary.responsive}</div>
6592
- <div class="summary-label">Fully Responsive</div>
6593
- </div>
6594
- <div class="summary-card">
6595
- <div class="summary-value minor">${summary.minorIssues}</div>
6596
- <div class="summary-label">Minor Issues</div>
6597
- </div>
6598
- <div class="summary-card">
6599
- <div class="summary-value major">${summary.majorIssues}</div>
6600
- <div class="summary-label">Major Issues</div>
6601
- </div>
6602
- <div class="summary-card">
6603
- <div class="summary-value">${summary.totalIssues}</div>
6604
- <div class="summary-label">Total Issues</div>
6605
- </div>
6606
- </div>
6607
-
6608
- <div class="results">
6609
- ${results.map(result => {
6610
- const badgeClass = result.overallStatus === "responsive" ? "badge-responsive" : result.overallStatus === "minor_issues" ? "badge-minor" : "badge-major";
6611
- return `
6612
- <div class="result-card">
6613
- <div class="result-header">
6614
- <div>
6615
- <strong>${result.url}</strong>
6616
- <p style="color: #94a3b8; font-size: 0.875rem;">${result.summary}</p>
6617
- </div>
6618
- <span class="badge ${badgeClass}">${result.overallStatus.replace("_", " ").toUpperCase()}</span>
6619
- </div>
6620
- <div class="viewport-grid">
6621
- ${result.screenshots.map(s => `
6622
- <div class="viewport-item">
6623
- <span class="viewport-name">${s.viewport.name}</span>
6624
- <span style="color: #94a3b8;"> ${s.viewport.width}×${s.viewport.height}</span>
6625
- </div>
6626
- `).join("")}
6627
- </div>
6628
- ${result.issues.length > 0 ? `
6629
- <div style="margin-top: 1rem;">
6630
- <strong style="color: #f59e0b;">Issues:</strong>
6631
- ${result.issues.map(issue => `
6632
- <div class="issue issue-${issue.severity}">
6633
- <strong>[${issue.type.toUpperCase()}]</strong> ${issue.description}
6634
- <br><span style="color: #94a3b8; font-size: 0.75rem;">Affected: ${issue.affectedViewports.join(", ")}</span>
6635
- </div>
6636
- `).join("")}
6637
- </div>
6638
- ` : ""}
6639
- </div>
6640
- `;
6641
- }).join("")}
6642
- </div>
6643
-
6644
- <footer>
6645
- Generated by CBrowser v7.2.0 | Test completed in ${(duration / 1000).toFixed(1)}s
6646
- </footer>
6647
- </div>
6648
- </body>
6649
- </html>`;
6650
- }
6651
- /**
6652
- * List available viewport presets
6653
- */
6654
- function listViewportPresets() {
6655
- return types_js_1.VIEWPORT_PRESETS;
6656
- }
6657
2269
  //# sourceMappingURL=browser.js.map