cbrowser 7.3.0 → 7.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analysis/bug-hunter.d.ts +32 -0
- package/dist/analysis/bug-hunter.d.ts.map +1 -0
- package/dist/analysis/bug-hunter.js +106 -0
- package/dist/analysis/bug-hunter.js.map +1 -0
- package/dist/analysis/chaos-testing.d.ts +41 -0
- package/dist/analysis/chaos-testing.d.ts.map +1 -0
- package/dist/analysis/chaos-testing.js +87 -0
- package/dist/analysis/chaos-testing.js.map +1 -0
- package/dist/analysis/index.d.ts +10 -0
- package/dist/analysis/index.d.ts.map +1 -0
- package/dist/analysis/index.js +26 -0
- package/dist/analysis/index.js.map +1 -0
- package/dist/analysis/natural-language.d.ts +43 -0
- package/dist/analysis/natural-language.d.ts.map +1 -0
- package/dist/analysis/natural-language.js +205 -0
- package/dist/analysis/natural-language.js.map +1 -0
- package/dist/analysis/persona-comparison.d.ts +31 -0
- package/dist/analysis/persona-comparison.d.ts.map +1 -0
- package/dist/analysis/persona-comparison.js +217 -0
- package/dist/analysis/persona-comparison.js.map +1 -0
- package/dist/browser.d.ts +1 -411
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +0 -4745
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +64 -56
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2 -1
- package/dist/daemon.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +406 -1
- package/dist/mcp-server.js.map +1 -1
- package/dist/performance/index.d.ts +7 -0
- package/dist/performance/index.d.ts.map +1 -0
- package/dist/performance/index.js +23 -0
- package/dist/performance/index.js.map +1 -0
- package/dist/performance/metrics.d.ts +49 -0
- package/dist/performance/metrics.d.ts.map +1 -0
- package/dist/performance/metrics.js +386 -0
- package/dist/performance/metrics.js.map +1 -0
- package/dist/testing/coverage.d.ts +39 -0
- package/dist/testing/coverage.d.ts.map +1 -0
- package/dist/testing/coverage.js +713 -0
- package/dist/testing/coverage.js.map +1 -0
- package/dist/testing/flaky-detection.d.ts +28 -0
- package/dist/testing/flaky-detection.d.ts.map +1 -0
- package/dist/testing/flaky-detection.js +332 -0
- package/dist/testing/flaky-detection.js.map +1 -0
- package/dist/testing/index.d.ts +10 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +26 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/nl-test-suite.d.ts +70 -0
- package/dist/testing/nl-test-suite.d.ts.map +1 -0
- package/dist/testing/nl-test-suite.js +427 -0
- package/dist/testing/nl-test-suite.js.map +1 -0
- package/dist/testing/test-repair.d.ts +36 -0
- package/dist/testing/test-repair.d.ts.map +1 -0
- package/dist/testing/test-repair.js +528 -0
- package/dist/testing/test-repair.js.map +1 -0
- package/dist/visual/ab-comparison.d.ts +23 -0
- package/dist/visual/ab-comparison.d.ts.map +1 -0
- package/dist/visual/ab-comparison.js +366 -0
- package/dist/visual/ab-comparison.js.map +1 -0
- package/dist/visual/cross-browser.d.ts +41 -0
- package/dist/visual/cross-browser.d.ts.map +1 -0
- package/dist/visual/cross-browser.js +442 -0
- package/dist/visual/cross-browser.js.map +1 -0
- package/dist/visual/index.d.ts +10 -0
- package/dist/visual/index.d.ts.map +1 -0
- package/dist/visual/index.js +26 -0
- package/dist/visual/index.js.map +1 -0
- package/dist/visual/regression.d.ts +55 -0
- package/dist/visual/regression.d.ts.map +1 -0
- package/dist/visual/regression.js +616 -0
- package/dist/visual/regression.js.map +1 -0
- package/dist/visual/responsive.d.ts +27 -0
- package/dist/visual/responsive.d.ts.map +1 -0
- package/dist/visual/responsive.js +450 -0
- package/dist/visual/responsive.js.map +1 -0
- 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
|