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