playwright-ai-auto-debug
Version:
Automatic Playwright test debugging with AI assistance + UI Test Coverage Analysis
675 lines (604 loc) • 24.2 kB
JavaScript
// lib/updateHtml.js
import fs from 'fs';
import path from 'path';
/**
* Обновляет HTML отчет с добавлением AI блока
* @param {string} htmlPath - путь к HTML файлу
* @param {string} errorContent - содержимое ошибки
* @param {string} aiResponse - ответ AI
* @param {string} testName - имя теста (опционально)
*/
export async function updateHtmlReport(htmlPath, errorContent, aiResponse, testName = null) {
try {
if (!fs.existsSync(htmlPath)) {
console.warn(`⚠️ HTML file not found: ${htmlPath}`);
return;
}
let htmlContent = fs.readFileSync(htmlPath, 'utf-8');
// Проверяем, является ли это основным Playwright отчетом
if (isPlaywrightMainReport(htmlContent)) {
// Используем переданное имя теста или извлекаем из errorContent
const finalTestName = testName || extractTestName(errorContent) || 'Unknown Test';
await integrateIntoPlaywrightReport(htmlPath, htmlContent, errorContent, aiResponse, finalTestName);
} else {
// Обычная интеграция для других HTML файлов
const aiBlock = createAiBlock(errorContent, aiResponse);
const insertionPoint = findInsertionPoint(htmlContent);
if (insertionPoint !== -1) {
htmlContent = htmlContent.slice(0, insertionPoint) +
aiBlock +
htmlContent.slice(insertionPoint);
fs.writeFileSync(htmlPath, htmlContent, 'utf-8');
console.log(`✅ Updated HTML report: ${path.basename(htmlPath)}`);
} else {
console.warn(`⚠️ Could not find insertion point in: ${path.basename(htmlPath)}`);
}
}
} catch (error) {
console.error(`❌ Failed to update HTML report: ${error.message}`);
}
}
/**
* Проверяет, является ли HTML файл основным отчетом Playwright
*/
function isPlaywrightMainReport(htmlContent) {
return htmlContent.includes('playwrightReportBase64') &&
htmlContent.includes('<div id=\'root\'></div>');
}
/**
* Извлекает имя теста из содержимого ошибки или пути к файлу
*/
export function extractTestName(errorContent, filePath = '') {
if (!errorContent && !filePath) return null;
// Пробуем различные паттерны для извлечения имени теста из контента
if (errorContent) {
const patterns = [
/› ([^›]+) › ([^›]+) › ([^›\n]+)/, // Playwright test hierarchy
/Test: (.+)/, // Простой формат "Test: ..."
/describe\(['"]([^'"]+)/, // describe('test name')
/test\(['"]([^'"]+)/, // test('test name')
/it\(['"]([^'"]+)/ // it('test name')
];
for (const pattern of patterns) {
const match = errorContent.match(pattern);
if (match) {
// Для Playwright иерархии берем последний элемент (имя теста)
return match[match.length - 1].trim();
}
}
}
// Извлекаем из пути к файлу ошибки
const fullPath = filePath || errorContent || '';
// Создаем карту для восстановления полных имен из сокращенных путей
const testNameMap = {
// Полные пути
'Memory-leak-simulation': 'Memory leak simulation',
'Form-validation-error': 'Form validation error',
'Login-timeout-simulation': 'Login timeout simulation',
'Infinite-scroll-timeout': 'Infinite scroll timeout',
'API-response-timeout': 'API response timeout',
'Wrong-title-assertion': 'Wrong title assertion',
'Missing-checkout-button': 'Missing checkout button',
'Network-error-simulation': 'Network error simulation',
'JavaScript-console-errors': 'JavaScript console errors',
'Mobile-viewport-issues': 'Mobile viewport issues',
'File-upload-failure': 'File upload failure',
'Drag-and-drop-failure': 'Drag and drop failure',
'Database-connection-simulation': 'Database connection simulation',
'WebSocket-connection-failure': 'WebSocket connection failure',
'Geolocation-permission-denied': 'Geolocation permission denied',
'PDF-download-timeout': 'PDF download timeout',
'Service-Worker-registration-failure': 'Service Worker registration failure',
'CSS-animation-timing-issues': 'CSS animation timing issues',
// Сокращенные суффиксы
'ebSocket-connection-failure': 'WebSocket connection failure',
'abase-connection-simulation': 'Database connection simulation',
'olocation-permission-denied': 'Geolocation permission denied',
'Worker-registration-failure': 'Service Worker registration failure'
};
// Пробуем различные паттерны извлечения
const patterns = [
// Полные пути с ❌- префиксом
/demo-🎯-AI-Debug-Integration-Demo-❌-([^/]+)-chromium/,
// Сокращенные пути
/demo-🎯-AI-Debug-Integrati-[a-z0-9]+-([^/]+)-chromium/,
// Обычные пути
/([^/\\]+)-chromium\/error-context\.md/
];
for (const pattern of patterns) {
const match = fullPath.match(pattern);
if (match) {
const rawName = match[1];
// Проверяем карту имен
if (testNameMap[rawName]) {
return testNameMap[rawName];
}
// Если нет в карте, обрабатываем самостоятельно
let testName = rawName
.replace(/^❌-/, '') // Убираем префикс ❌-
.replace(/^✅-/, '') // Убираем префикс ✅-
.replace(/-/g, ' ') // Заменяем дефисы на пробелы
.replace(/([a-z])([A-Z])/g, '$1 $2') // Добавляем пробелы перед заглавными буквами
.replace(/\s+/g, ' ') // Убираем лишние пробелы
.trim();
if (testName && testName.length > 3) {
return testName;
}
}
}
return null;
}
/**
* Интегрирует AI анализ в основной HTML отчет Playwright
*/
async function integrateIntoPlaywrightReport(htmlPath, htmlContent, errorContent, aiResponse, testName = 'Unknown Test') {
try {
// Проверяем, уже ли добавлены AI блоки
if (htmlContent.includes('ai-analysis-overlay')) {
// Если уже есть AI блоки, добавляем новый анализ к существующим
await addToExistingAiAnalysis(htmlPath, htmlContent, errorContent, aiResponse, testName);
} else {
// Создаем первый AI блок
await createFirstAiAnalysis(htmlPath, htmlContent, errorContent, aiResponse, testName);
}
} catch (error) {
console.error(`❌ Failed to integrate into Playwright report: ${error.message}`);
}
}
/**
* Создает первый AI анализ в отчете
*/
async function createFirstAiAnalysis(htmlPath, htmlContent, errorContent, aiResponse, testName = 'Unknown Test') {
const aiBlock = createPlaywrightStyleAiBlock(errorContent, aiResponse, testName);
// Ищем место для вставки - перед закрывающим тегом body
const bodyCloseIndex = htmlContent.lastIndexOf('</body>');
if (bodyCloseIndex === -1) {
console.warn('⚠️ Could not find </body> tag in Playwright report');
return;
}
// Вставляем AI блок как отдельную секцию
const updatedContent = htmlContent.slice(0, bodyCloseIndex) +
aiBlock +
htmlContent.slice(bodyCloseIndex);
fs.writeFileSync(htmlPath, updatedContent, 'utf-8');
console.log(`✅ Integrated AI analysis into Playwright report: ${path.basename(htmlPath)}`);
}
/**
* Добавляет новый анализ к существующим AI блокам
*/
async function addToExistingAiAnalysis(htmlPath, htmlContent, errorContent, aiResponse, testName = 'Unknown Test') {
// Создаем новый элемент анализа для добавления к существующему списку
const newAnalysisItem = createAnalysisItem(errorContent, aiResponse, testName);
// Ищем место для вставки нового элемента - в конец контейнера анализов
const containerEndPattern = /<\/div>\s*<\/div>\s*<\/div>\s*<div id="ai-analysis-trigger"/;
const match = htmlContent.match(containerEndPattern);
if (match) {
const insertionPoint = match.index;
const updatedContent = htmlContent.slice(0, insertionPoint) +
newAnalysisItem +
htmlContent.slice(insertionPoint);
// Обновляем счетчик анализов в кнопке
const countPattern = /<span id="ai-analysis-count">AI Analysis(\s*\(\d+\))?<\/span>/;
const analysisCount = (updatedContent.match(/class="ai-analysis-item"/g) || []).length;
const finalContent = updatedContent.replace(
countPattern,
`<span id="ai-analysis-count">AI Analysis (${analysisCount})</span>`
);
fs.writeFileSync(htmlPath, finalContent, 'utf-8');
console.log(`✅ Added new AI analysis to existing report: ${path.basename(htmlPath)}`);
} else {
console.warn('⚠️ Could not find insertion point for additional AI analysis');
}
}
/**
* Создает AI блок в стиле Playwright для интеграции в основной отчет
*/
function createPlaywrightStyleAiBlock(errorContent, aiResponse, testName = 'Unknown Test') {
const analysisItem = createAnalysisItem(errorContent, aiResponse, testName);
return `
<div id="ai-analysis-overlay" style="
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
">
<div style="
background: var(--color-canvas-default, white);
border-radius: 12px;
max-width: 1000px;
max-height: 85vh;
overflow-y: auto;
margin: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
border: 1px solid var(--color-border-default, #d0d7de);
">
<div style="
padding: 24px;
border-bottom: 1px solid var(--color-border-muted, #d8dee4);
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
background: var(--color-canvas-default, white);
z-index: 1;
">
<div style="display: flex; align-items: center;">
<span style="font-size: 24px; margin-right: 12px;">🤖</span>
<h2 style="
margin: 0;
color: var(--color-fg-default, #24292f);
font-size: 18px;
font-weight: 600;
">AI Analysis & Solutions</h2>
</div>
<button onclick="document.getElementById('ai-analysis-overlay').style.display='none'" style="
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--color-fg-muted, #656d76);
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s;
" onmouseover="this.style.backgroundColor='var(--color-neutral-muted, #f6f8fa)'"
onmouseout="this.style.backgroundColor='transparent'">×</button>
</div>
<div id="ai-analyses-container" style="padding: 0;">
${analysisItem}
</div>
</div>
</div>
<div id="ai-analysis-trigger" style="
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
background: linear-gradient(135deg, #2da44e 0%, #1a7f37 100%);
color: white;
border: none;
border-radius: 50px;
padding: 12px 20px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 24px rgba(45, 164, 78, 0.3);
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
" onclick="document.getElementById('ai-analysis-overlay').style.display='flex'"
onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 12px 32px rgba(45, 164, 78, 0.4)'; this.style.background='linear-gradient(135deg, #1a7f37 0%, #2da44e 100%)'"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 8px 24px rgba(45, 164, 78, 0.3)'; this.style.background='linear-gradient(135deg, #2da44e 0%, #1a7f37 100%)'">
<span style="font-size: 16px;">🤖</span>
<span id="ai-analysis-count">AI Analysis (1)</span>
</div>
<style>
/* Playwright-совместимые стили для AI анализа */
@media (prefers-color-scheme: dark) {
#ai-analysis-overlay {
background: rgba(0, 0, 0, 0.9) ;
}
#ai-analysis-overlay > div {
background: var(--color-canvas-default, #0d1117) ;
border-color: var(--color-border-default, #30363d) ;
}
.test-header {
background: var(--color-canvas-subtle, #161b22) ;
}
.ai-analysis-item pre {
background: var(--color-neutral-subtle, #21262d) ;
border-color: var(--color-attention-muted, #9e6a03) ;
color: var(--color-fg-default, #f0f6fc) ;
}
.analysis-content > div:last-child > div {
background: var(--color-success-subtle, #0f2419) ;
border-color: var(--color-success-muted, #2ea043) ;
color: var(--color-fg-default, #f0f6fc) ;
}
}
/* Анимации для улучшения UX */
#ai-analysis-overlay {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
backdrop-filter: blur(0px);
}
to {
opacity: 1;
backdrop-filter: blur(4px);
}
}
.ai-analysis-item {
animation: slideInUp 0.4s ease-out;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Анимация аккордиона */
.analysis-content {
transition: all 0.3s ease-out;
overflow: hidden;
}
.analysis-content.expanding {
animation: expandContent 0.3s ease-out;
}
.analysis-content.collapsing {
animation: collapseContent 0.3s ease-out;
}
@keyframes expandContent {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 1000px;
}
}
@keyframes collapseContent {
from {
opacity: 1;
max-height: 1000px;
}
to {
opacity: 0;
max-height: 0;
}
}
</style>
<script>
// JavaScript для управления аккордионом
function toggleAnalysis(itemId) {
const content = document.getElementById(itemId);
const chevron = document.getElementById(itemId + '-chevron');
if (content.style.display === 'none' || content.style.display === '') {
// Открываем
content.style.display = 'block';
content.classList.add('expanding');
chevron.style.transform = 'rotate(180deg)';
setTimeout(() => {
content.classList.remove('expanding');
}, 300);
} else {
// Закрываем
content.classList.add('collapsing');
chevron.style.transform = 'rotate(0deg)';
setTimeout(() => {
content.style.display = 'none';
content.classList.remove('collapsing');
}, 300);
}
}
// Функция для открытия первого анализа по умолчанию
function openFirstAnalysis() {
const firstItem = document.querySelector('.ai-analysis-item');
if (firstItem) {
const firstContent = firstItem.querySelector('.analysis-content');
const firstChevron = firstItem.querySelector('[id$="-chevron"]');
if (firstContent && firstChevron) {
firstContent.style.display = 'block';
firstChevron.style.transform = 'rotate(180deg)';
}
}
}
// Открываем первый анализ при загрузке
document.addEventListener('DOMContentLoaded', openFirstAnalysis);
</script>
`;
}
/**
* Создает отдельный элемент анализа
*/
function createAnalysisItem(errorContent, aiResponse, testName = 'Unknown Test') {
const escapedError = escapeHtml(errorContent);
const escapedResponse = escapeHtml(aiResponse);
const itemId = `analysis-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
return `
<div class="ai-analysis-item" style="
border-bottom: 1px solid var(--color-border-muted, #d8dee4);
">
<!-- Заголовок теста с аккордионом -->
<div class="test-header" onclick="toggleAnalysis('${itemId}')" style="
padding: 20px 24px;
cursor: pointer;
background: var(--color-canvas-subtle, #f6f8fa);
border-bottom: 1px solid var(--color-border-muted, #d8dee4);
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.2s;
" onmouseover="this.style.backgroundColor='var(--color-neutral-muted, #afb8c1)'"
onmouseout="this.style.backgroundColor='var(--color-canvas-subtle, #f6f8fa)'">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-size: 18px;">🧪</span>
<div>
<h3 style="
margin: 0;
color: var(--color-fg-default, #24292f);
font-size: 16px;
font-weight: 600;
">${escapeHtml(testName)}</h3>
<p style="
margin: 4px 0 0 0;
color: var(--color-fg-muted, #656d76);
font-size: 14px;
">Нажмите для просмотра анализа</p>
</div>
</div>
<span id="${itemId}-chevron" style="
font-size: 20px;
color: var(--color-fg-muted, #656d76);
transition: transform 0.2s;
">▼</span>
</div>
<!-- Содержимое анализа (скрыто по умолчанию) -->
<div id="${itemId}" class="analysis-content" style="
display: none;
padding: 24px;
">
<div style="margin-bottom: 24px;">
<h4 style="
color: var(--color-danger-fg, #d1242f);
margin: 0 0 12px 0;
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
">
<span style="margin-right: 8px;">🔍</span>
Detected Error
</h4>
<pre style="
background: var(--color-attention-subtle, #fff8c5);
border: 1px solid var(--color-attention-muted, #d4a72c);
padding: 16px;
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
margin: 0;
line-height: 1.45;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
">${escapedError}</pre>
</div>
<div>
<h4 style="
color: var(--color-success-fg, #1a7f37);
margin: 0 0 12px 0;
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
">
<span style="margin-right: 8px;">💡</span>
Recommended Solution
</h4>
<div style="
background: var(--color-success-subtle, #dafbe1);
border: 1px solid var(--color-success-muted, #4ac26b);
padding: 16px;
border-radius: 6px;
font-size: 14px;
line-height: 1.6;
margin: 0;
color: var(--color-fg-default, #24292f);
">${formatMarkdown(escapedResponse)}</div>
</div>
</div>
</div>
`;
}
/**
* Создает AI блок для вставки в HTML
*/
function createAiBlock(errorContent, aiResponse) {
const escapedError = escapeHtml(errorContent);
const escapedResponse = escapeHtml(aiResponse);
return `
<div class="ai-debug-block" style="
margin: 20px 0;
padding: 20px;
border: 2px solid #4CAF50;
border-radius: 8px;
background: linear-gradient(135deg, #f0f8ff 0%, #e8f5e8 100%);
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
">
<div class="ai-header" style="
display: flex;
align-items: center;
margin-bottom: 15px;
font-weight: bold;
color: #2c3e50;
">
<span style="font-size: 24px; margin-right: 10px;">🤖</span>
<h3 style="margin: 0; color: #27ae60;">AI Analysis & Solution</h3>
</div>
<div class="error-section" style="margin-bottom: 15px;">
<h4 style="color: #e74c3c; margin: 0 0 8px 0;">🔍 Detected Error:</h4>
<pre style="
background: #fff3cd;
border: 1px solid #ffeaa7;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
margin: 0;
">${escapedError}</pre>
</div>
<div class="solution-section">
<h4 style="color: #27ae60; margin: 0 0 8px 0;">💡 Recommended Solution:</h4>
<div style="
background: #d4edda;
border: 1px solid #c3e6cb;
padding: 10px;
border-radius: 4px;
font-size: 14px;
line-height: 1.5;
margin: 0;
">${formatMarkdown(escapedResponse)}</div>
</div>
</div>
`;
}
/**
* Находит точку вставки в HTML
*/
function findInsertionPoint(htmlContent) {
// Ищем после первого test-result блока
const patterns = [
/<div[^>]*class="[^"]*test-result[^"]*"[^>]*>.*?<\/div>/s,
/<div[^>]*class="[^"]*result[^"]*"[^>]*>.*?<\/div>/s,
/<body[^>]*>/,
/<div[^>]*class="[^"]*content[^"]*"[^>]*>/
];
for (const pattern of patterns) {
const match = htmlContent.match(pattern);
if (match) {
return match.index + match[0].length;
}
}
return -1;
}
/**
* Экранирует HTML символы
*/
function escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* Простое форматирование markdown
*/
function formatMarkdown(text) {
if (!text) return '';
return text
.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre style="background: #f8f9fa; padding: 10px; border-radius: 4px; overflow-x: auto;"><code>$2</code></pre>')
.replace(/`([^`]+)`/g, '<code style="background: #f1f2f6; padding: 2px 4px; border-radius: 3px;">$1</code>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(.+)/, '<p>$1')
.replace(/(.+)$/, '$1</p>');
}