playwright-ai-auto-debug
Version:
Automatic Playwright test debugging with AI assistance + UI Test Coverage Analysis
854 lines (736 loc) • 32.3 kB
JavaScript
// new-pw-ai/src/main/lib/testElementTracker.js
import fs from 'fs';
import path from 'path';
/**
* Система отслеживания элементов используемых в тестах
* Анализирует какие селекторы используются и сопоставляет с реальными элементами страницы
*/
export class TestElementTracker {
constructor(config = {}) {
this.config = {
outputDir: config.outputDir || 'test-coverage-reports',
trackingEnabled: config.trackingEnabled ?? true,
...config
};
// Хранилище данных о покрытии
this.testCoverageData = {
tests: new Map(), // testName -> { pages: [], elements: [], selectors: [] }
pages: new Map(), // pageUrl -> { elements: [], visitedBy: [] }
globalStats: {
totalTests: 0,
totalPages: 0,
totalElements: 0,
coveredElements: 0,
uncoveredElements: []
}
};
// Список отслеживаемых методов Playwright
this.trackedMethods = [
'click', 'fill', 'type', 'check', 'uncheck', 'selectOption',
'hover', 'focus', 'blur', 'dblclick', 'dragTo',
'waitForSelector', 'locator', 'getByRole', 'getByText',
'getByLabel', 'getByTestId', 'getByPlaceholder'
];
}
/**
* 🎯 Инициализация трекинга для теста
*/
async startTestTracking(testName, page) {
if (!this.config.trackingEnabled) return;
console.log(`🔍 Начинаю отслеживание элементов для теста: ${testName}`);
// Инициализируем данные теста
this.testCoverageData.tests.set(testName, {
pages: [],
elements: [],
selectors: [],
interactions: [],
startTime: Date.now()
});
// Подключаем перехватчики для отслеживания взаимодействий
await this.attachPageTrackers(testName, page);
}
/**
* 📡 Подключение трекеров к странице
*/
async attachPageTrackers(testName, page) {
const testData = this.testCoverageData.tests.get(testName);
// Отслеживание будет происходить через перехват page.goto в fixture
// Перехват методов локаторов
const originalLocator = page.locator;
page.locator = (selector, options) => {
this.trackSelectorUsage(testName, selector, 'locator');
return originalLocator.call(page, selector, options);
};
// Перехват методов getBy*
const originalGetByRole = page.getByRole;
page.getByRole = (role, options) => {
this.trackSelectorUsage(testName, `role=${role}`, 'getByRole', options);
return originalGetByRole.call(page, role, options);
};
const originalGetByText = page.getByText;
page.getByText = (text, options) => {
this.trackSelectorUsage(testName, `text=${text}`, 'getByText', options);
return originalGetByText.call(page, text, options);
};
}
/**
* 🌐 Отслеживание посещения страницы
*/
async trackPageVisit(testName, url, page) {
try {
console.log(`📄 Анализирую страницу: ${url} для теста: ${testName}`);
// Ждем загрузки страницы
await page.waitForLoadState('networkidle', { timeout: 5000 });
// Получаем все элементы страницы
const pageElements = await this.extractPageElements(page, url);
// Сохраняем данные о странице
if (!this.testCoverageData.pages.has(url)) {
this.testCoverageData.pages.set(url, {
elements: pageElements,
visitedBy: [],
lastAnalyzed: Date.now()
});
}
// Добавляем тест к посетителям страницы
const pageData = this.testCoverageData.pages.get(url);
if (!pageData.visitedBy.includes(testName)) {
pageData.visitedBy.push(testName);
}
// Добавляем страницу к тесту
const testData = this.testCoverageData.tests.get(testName);
if (!testData.pages.includes(url)) {
testData.pages.push(url);
}
} catch (error) {
console.warn(`⚠️ Ошибка анализа страницы ${url}:`, error.message);
}
}
/**
* 🔍 Извлечение всех элементов со страницы
*/
async extractPageElements(page, url) {
try {
// Проверяем что это HTML страница
const contentType = await page.evaluate(() => document.contentType || 'text/html');
if (!contentType.includes('html')) {
return [];
}
const elements = await page.evaluate(() => {
const allElements = [];
// Получаем все интерактивные элементы
const interactiveSelectors = [
'button', 'a', 'input', 'select', 'textarea',
'[role="button"]', '[role="link"]', '[role="textbox"]',
'[onclick]', '[onsubmit]', '[tabindex]'
];
interactiveSelectors.forEach(selector => {
try {
document.querySelectorAll(selector).forEach((element, index) => {
// Фильтруем только видимые и значимые элементы
if (element.offsetParent !== null || element.tagName === 'A') {
const elementData = {
tagName: element.tagName.toLowerCase(),
type: element.type || 'unknown',
id: element.id || '',
className: element.className || '',
text: (element.textContent || element.innerText || '').trim().substring(0, 100),
href: element.href || '',
role: element.getAttribute('role') || '',
ariaLabel: element.getAttribute('aria-label') || '',
placeholder: element.getAttribute('placeholder') || '',
selector: selector,
xpath: '',
position: { x: element.offsetLeft || 0, y: element.offsetTop || 0 },
visible: element.offsetParent !== null,
index: index
};
// Добавляем только если есть текст или важные атрибуты
if (elementData.text || elementData.id || elementData.ariaLabel || elementData.href) {
allElements.push(elementData);
}
}
});
} catch (err) {
console.warn('Ошибка обработки селектора:', selector, err.message);
}
});
return allElements;
});
console.log(`📊 Извлечено ${elements.length} элементов со страницы ${url}`);
return elements;
} catch (error) {
console.warn(`⚠️ Ошибка извлечения элементов со страницы ${url}:`, error.message);
return [];
}
}
/**
* 📝 Отслеживание использования селекторов
*/
trackSelectorUsage(testName, selector, method, options = {}) {
const testData = this.testCoverageData.tests.get(testName);
if (!testData) return;
const interaction = {
selector,
method,
options,
timestamp: Date.now()
};
testData.selectors.push(selector);
testData.interactions.push(interaction);
console.log(`🎯 Тест ${testName} использует: ${method}("${selector}")`);
}
/**
* 📊 Анализ покрытия после завершения всех тестов
*/
async analyzeCoverage() {
console.log('📊 Анализирую покрытие элементов тестами...');
const coverageReport = {
summary: {
totalTests: this.testCoverageData.tests.size,
totalPages: this.testCoverageData.pages.size,
totalElements: 0,
coveredElements: 0,
coveragePercentage: 0
},
pageAnalysis: [],
testAnalysis: [],
uncoveredElements: [],
recommendations: []
};
// Анализ по страницам
for (const [pageUrl, pageData] of this.testCoverageData.pages) {
const pageAnalysis = await this.analyzePageCoverage(pageUrl, pageData);
coverageReport.pageAnalysis.push(pageAnalysis);
coverageReport.summary.totalElements += pageData.elements.length;
}
// Анализ по тестам
for (const [testName, testData] of this.testCoverageData.tests) {
const testAnalysis = this.analyzeTestCoverage(testName, testData);
coverageReport.testAnalysis.push(testAnalysis);
}
// Поиск непокрытых элементов
coverageReport.uncoveredElements = this.findUncoveredElements();
// Расчет общего покрытия
const totalElements = coverageReport.summary.totalElements;
const coveredElements = this.calculateCoveredElements();
coverageReport.summary.coveredElements = coveredElements;
coverageReport.summary.coveragePercentage = totalElements > 0
? Math.round((coveredElements / totalElements) * 100)
: 0;
// Генерация рекомендаций
coverageReport.recommendations = this.generateRecommendations(coverageReport);
return coverageReport;
}
/**
* 📄 Анализ покрытия конкретной страницы
*/
async analyzePageCoverage(pageUrl, pageData) {
const usedSelectors = new Set();
// Собираем все селекторы используемые на этой странице
for (const testName of pageData.visitedBy) {
const testData = this.testCoverageData.tests.get(testName);
if (testData) {
testData.selectors.forEach(selector => usedSelectors.add(selector));
}
}
// Сопоставляем элементы страницы с используемыми селекторами
const coveredElements = [];
const uncoveredElements = [];
for (const element of pageData.elements) {
const isCovered = this.isElementCovered(element, usedSelectors);
if (isCovered.covered) {
coveredElements.push({
...element,
coveredBy: isCovered.matchedSelectors,
usedInTests: pageData.visitedBy
});
} else {
uncoveredElements.push(element);
}
}
return {
url: pageUrl,
visitedBy: pageData.visitedBy,
totalElements: pageData.elements.length,
coveredElements: coveredElements.length,
uncoveredElements: uncoveredElements.length,
coveragePercentage: Math.round((coveredElements.length / pageData.elements.length) * 100),
elementDetails: {
covered: coveredElements,
uncovered: uncoveredElements
}
};
}
/**
* 🧪 Анализ покрытия конкретного теста
*/
analyzeTestCoverage(testName, testData) {
return {
testName,
pagesVisited: testData.pages.length,
selectorsUsed: testData.selectors.length,
uniqueSelectors: [...new Set(testData.selectors)].length,
interactions: testData.interactions.length,
duration: Date.now() - testData.startTime,
pages: testData.pages,
mostUsedSelectors: this.getMostUsedSelectors(testData.selectors)
};
}
/**
* 🔍 Проверка покрытия элемента селекторами
*/
isElementCovered(element, usedSelectors) {
const matchedSelectors = [];
for (const selector of usedSelectors) {
if (this.matchesSelector(element, selector)) {
matchedSelectors.push(selector);
}
}
return {
covered: matchedSelectors.length > 0,
matchedSelectors
};
}
/**
* 🎯 Проверка соответствия элемента селектору
*/
matchesSelector(element, selector) {
// Простое сопоставление по тексту
if (selector.includes('text=') && element.text.includes(selector.replace('text=', ''))) {
return true;
}
// Сопоставление по роли
if (selector.includes('role=') && element.role === selector.replace('role=', '')) {
return true;
}
// Сопоставление по ID
if (selector.startsWith('#') && element.id === selector.substring(1)) {
return true;
}
// Сопоставление по классу
if (selector.startsWith('.') && element.className.includes(selector.substring(1))) {
return true;
}
// Сопоставление по тегу
if (element.tagName === selector.toLowerCase()) {
return true;
}
return false;
}
/**
* 🔍 Поиск непокрытых элементов
*/
findUncoveredElements() {
const uncovered = [];
for (const [pageUrl, pageData] of this.testCoverageData.pages) {
const usedSelectors = new Set();
// Собираем все селекторы для этой страницы
for (const testName of pageData.visitedBy) {
const testData = this.testCoverageData.tests.get(testName);
if (testData) {
testData.selectors.forEach(selector => usedSelectors.add(selector));
}
}
// Ищем непокрытые элементы
for (const element of pageData.elements) {
const coverage = this.isElementCovered(element, usedSelectors);
if (!coverage.covered) {
uncovered.push({
...element,
pageUrl,
suggestedSelectors: this.generateSuggestedSelectors(element),
priority: this.calculateElementPriority(element)
});
}
}
}
return uncovered.sort((a, b) => b.priority - a.priority);
}
/**
* 🎯 Генерация предлагаемых селекторов для элемента
*/
generateSuggestedSelectors(element) {
const selectors = [];
if (element.id) {
selectors.push(`#${element.id}`);
}
if (element.text && element.text.length < 50) {
selectors.push(`text=${element.text}`);
}
if (element.role) {
selectors.push(`[role="${element.role}"]`);
}
if (element.ariaLabel) {
selectors.push(`[aria-label="${element.ariaLabel}"]`);
}
if (element.className) {
const classes = element.className.split(' ').filter(c => c.length > 2);
if (classes.length > 0) {
selectors.push(`.${classes[0]}`);
}
}
selectors.push(element.tagName);
return selectors;
}
/**
* ⭐ Расчет приоритета элемента
*/
calculateElementPriority(element) {
let priority = 1;
// Высокий приоритет для интерактивных элементов
if (['button', 'a', 'input'].includes(element.tagName)) {
priority += 5;
}
// Высокий приоритет для элементов с текстом
if (element.text && element.text.length > 0) {
priority += 3;
}
// Высокий приоритет для видимых элементов
if (element.visible) {
priority += 2;
}
// Высокий приоритет для элементов с ID
if (element.id) {
priority += 2;
}
return priority;
}
/**
* 📊 Расчет количества покрытых элементов
*/
calculateCoveredElements() {
let covered = 0;
for (const [pageUrl, pageData] of this.testCoverageData.pages) {
const usedSelectors = new Set();
for (const testName of pageData.visitedBy) {
const testData = this.testCoverageData.tests.get(testName);
if (testData) {
testData.selectors.forEach(selector => usedSelectors.add(selector));
}
}
for (const element of pageData.elements) {
if (this.isElementCovered(element, usedSelectors).covered) {
covered++;
}
}
}
return covered;
}
/**
* 📈 Получение самых используемых селекторов
*/
getMostUsedSelectors(selectors) {
const selectorCount = {};
selectors.forEach(selector => {
selectorCount[selector] = (selectorCount[selector] || 0) + 1;
});
return Object.entries(selectorCount)
.sort(([,a], [,b]) => b - a)
.slice(0, 5)
.map(([selector, count]) => ({ selector, count }));
}
/**
* 💡 Генерация рекомендаций
*/
generateRecommendations(coverageReport) {
const recommendations = [];
// Рекомендации по покрытию
if (coverageReport.summary.coveragePercentage < 50) {
recommendations.push({
type: 'coverage',
priority: 'high',
message: `🔴 Низкое покрытие элементов (${coverageReport.summary.coveragePercentage}%) - добавьте тесты для важных элементов`
});
}
// Рекомендации по непокрытым элементам
const highPriorityUncovered = coverageReport.uncoveredElements.filter(el => el.priority > 5);
if (highPriorityUncovered.length > 0) {
recommendations.push({
type: 'elements',
priority: 'high',
message: `🎯 Найдено ${highPriorityUncovered.length} важных непокрытых элементов`
});
}
// Рекомендации по конкретным тестам
const testsWithLowCoverage = coverageReport.testAnalysis.filter(test => test.selectorsUsed < 3);
if (testsWithLowCoverage.length > 0) {
recommendations.push({
type: 'tests',
priority: 'medium',
message: `📝 ${testsWithLowCoverage.length} тестов используют мало селекторов - расширьте проверки`
});
}
return recommendations;
}
/**
* 💾 Сохранение отчета покрытия
*/
async saveCoverageReport(coverageReport) {
const timestamp = Date.now();
const reportDir = this.config.outputDir;
// Создаем директорию если не существует
if (!fs.existsSync(reportDir)) {
fs.mkdirSync(reportDir, { recursive: true });
}
// Сохраняем JSON отчет
const jsonPath = path.join(reportDir, `test-coverage-${timestamp}.json`);
fs.writeFileSync(jsonPath, JSON.stringify(coverageReport, null, 2));
// Генерируем и сохраняем HTML отчет
const htmlReport = this.generateHTMLReport(coverageReport);
const htmlPath = path.join(reportDir, `test-coverage-${timestamp}.html`);
fs.writeFileSync(htmlPath, htmlReport);
// Генерируем и сохраняем Markdown отчет
const mdReport = this.generateMarkdownReport(coverageReport);
const mdPath = path.join(reportDir, `test-coverage-${timestamp}.md`);
fs.writeFileSync(mdPath, mdReport);
console.log(`📊 Отчеты покрытия сохранены:`);
console.log(` 📝 JSON: ${jsonPath}`);
console.log(` 🌐 HTML: ${htmlPath}`);
console.log(` 📄 MD: ${mdPath}`);
return { jsonPath, htmlPath, mdPath };
}
/**
* 🌐 Генерация HTML отчета
*/
generateHTMLReport(coverageReport) {
return `<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Coverage Report</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
.container { max-width: 1400px; margin: 0 auto; background: white; border-radius: 8px; padding: 30px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.header { text-align: center; margin-bottom: 30px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }
.stat-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px; text-align: center; }
.stat-card.success { background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%); }
.stat-card.warning { background: linear-gradient(135deg, #ff9800 0%, #ffc107 100%); }
.stat-card.danger { background: linear-gradient(135deg, #f44336 0%, #e91e63 100%); }
.stat-value { font-size: 2em; font-weight: bold; margin-bottom: 5px; }
.stat-label { opacity: 0.9; }
.section { margin: 30px 0; }
.section-title { font-size: 1.5em; margin-bottom: 15px; border-bottom: 2px solid #eee; padding-bottom: 5px; }
.table { width: 100%; border-collapse: collapse; margin: 20px 0; }
.table th, .table td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
.table th { background: #f5f5f5; font-weight: bold; }
.covered { background: #e8f5e8; }
.uncovered { background: #ffebee; }
.high-priority { border-left: 4px solid #f44336; }
.tabs { display: flex; border-bottom: 2px solid #eee; margin-bottom: 20px; }
.tab { padding: 10px 20px; cursor: pointer; border-bottom: 2px solid transparent; }
.tab.active { border-bottom-color: #667eea; color: #667eea; font-weight: bold; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.progress-bar { width: 100%; height: 20px; background: #eee; border-radius: 10px; overflow: hidden; margin: 10px 0; }
.progress-fill { height: 100%; transition: width 0.3s; }
.progress-high { background: linear-gradient(90deg, #4caf50, #8bc34a); }
.progress-medium { background: linear-gradient(90deg, #ff9800, #ffc107); }
.progress-low { background: linear-gradient(90deg, #f44336, #e91e63); }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎯 Test Coverage Report</h1>
<p><strong>Дата анализа:</strong> ${new Date().toLocaleString('ru')}</p>
</div>
<div class="stats-grid">
<div class="stat-card ${coverageReport.summary.coveragePercentage > 70 ? 'success' : coverageReport.summary.coveragePercentage > 40 ? 'warning' : 'danger'}">
<div class="stat-value">${coverageReport.summary.coveragePercentage}%</div>
<div class="stat-label">Покрытие элементов</div>
</div>
<div class="stat-card">
<div class="stat-value">${coverageReport.summary.totalTests}</div>
<div class="stat-label">Всего тестов</div>
</div>
<div class="stat-card">
<div class="stat-value">${coverageReport.summary.totalPages}</div>
<div class="stat-label">Страниц</div>
</div>
<div class="stat-card">
<div class="stat-value">${coverageReport.summary.totalElements}</div>
<div class="stat-label">Всего элементов</div>
</div>
</div>
<div class="tabs">
<div class="tab active" onclick="showTab('summary')">Сводка</div>
<div class="tab" onclick="showTab('pages')">По страницам</div>
<div class="tab" onclick="showTab('tests')">По тестам</div>
<div class="tab" onclick="showTab('uncovered')">Непокрытые</div>
</div>
<div id="summary" class="tab-content active">
<div class="section">
<h2 class="section-title">📊 Общая статистика</h2>
<p><strong>Покрыто элементов:</strong> ${coverageReport.summary.coveredElements} из ${coverageReport.summary.totalElements}</p>
<div class="progress-bar">
<div class="progress-fill ${coverageReport.summary.coveragePercentage > 70 ? 'progress-high' : coverageReport.summary.coveragePercentage > 40 ? 'progress-medium' : 'progress-low'}" style="width: ${coverageReport.summary.coveragePercentage}%"></div>
</div>
</div>
</div>
<div id="pages" class="tab-content">
<div class="section">
<h2 class="section-title">🌐 Анализ по страницам</h2>
<table class="table">
<thead>
<tr>
<th>Страница</th>
<th>Тестов</th>
<th>Элементов</th>
<th>Покрыто</th>
<th>Покрытие</th>
</tr>
</thead>
<tbody>
${coverageReport.pageAnalysis.map(page => `
<tr>
<td>${page.url}</td>
<td>${page.visitedBy.length}</td>
<td>${page.totalElements}</td>
<td>${page.coveredElements}</td>
<td>
<div class="progress-bar">
<div class="progress-fill ${page.coveragePercentage > 70 ? 'progress-high' : page.coveragePercentage > 40 ? 'progress-medium' : 'progress-low'}" style="width: ${page.coveragePercentage}%"></div>
</div>
${page.coveragePercentage}%
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
<div id="tests" class="tab-content">
<div class="section">
<h2 class="section-title">🧪 Анализ по тестам</h2>
<table class="table">
<thead>
<tr>
<th>Тест</th>
<th>Страниц</th>
<th>Селекторов</th>
<th>Взаимодействий</th>
<th>Время (мс)</th>
</tr>
</thead>
<tbody>
${coverageReport.testAnalysis.map(test => `
<tr>
<td>${test.testName}</td>
<td>${test.pagesVisited}</td>
<td>${test.uniqueSelectors}</td>
<td>${test.interactions}</td>
<td>${test.duration}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
<div id="uncovered" class="tab-content">
<div class="section">
<h2 class="section-title">🎯 Непокрытые элементы</h2>
<table class="table">
<thead>
<tr>
<th>Элемент</th>
<th>Тип</th>
<th>Страница</th>
<th>Предлагаемые селекторы</th>
<th>Приоритет</th>
</tr>
</thead>
<tbody>
${coverageReport.uncoveredElements.slice(0, 20).map(element => `
<tr class="${element.priority > 5 ? 'high-priority' : ''}">
<td>${element.text || element.tagName}</td>
<td>${element.tagName}</td>
<td>${element.pageUrl}</td>
<td><code>${element.suggestedSelectors.slice(0, 2).join(', ')}</code></td>
<td>${element.priority}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
</div>
<script>
function showTab(tabName) {
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
});
document.getElementById(tabName).classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>`;
}
/**
* 📄 Генерация Markdown отчета
*/
generateMarkdownReport(coverageReport) {
return `# 🎯 Test Coverage Report
## 📊 Сводка
- **Всего тестов:** ${coverageReport.summary.totalTests}
- **Страниц проанализировано:** ${coverageReport.summary.totalPages}
- **Всего элементов:** ${coverageReport.summary.totalElements}
- **Покрыто элементов:** ${coverageReport.summary.coveredElements}
- **Покрытие:** ${coverageReport.summary.coveragePercentage}%
- **Дата анализа:** ${new Date().toLocaleString('ru')}
## 🌐 Анализ по страницам
${coverageReport.pageAnalysis.map(page => `
### ${page.url}
- **Посещено тестами:** ${page.visitedBy.join(', ')}
- **Элементов:** ${page.totalElements}
- **Покрыто:** ${page.coveredElements}
- **Покрытие:** ${page.coveragePercentage}%
`).join('\n')}
## 🧪 Анализ по тестам
${coverageReport.testAnalysis.map(test => `
### ${test.testName}
- **Страниц:** ${test.pagesVisited}
- **Селекторов:** ${test.uniqueSelectors}
- **Взаимодействий:** ${test.interactions}
- **Время выполнения:** ${test.duration}ms
`).join('\n')}
## 🎯 Топ-10 непокрытых элементов
${coverageReport.uncoveredElements.slice(0, 10).map((element, index) => `
${index + 1}. **${element.text || element.tagName}** (${element.tagName})
- Страница: ${element.pageUrl}
- Приоритет: ${element.priority}
- Предлагаемые селекторы: \`${element.suggestedSelectors.slice(0, 3).join('`, `')}\`
`).join('\n')}
## 💡 Рекомендации
${coverageReport.recommendations.map(rec => `- ${rec.message}`).join('\n')}
---
*Отчет создан автоматически с помощью TestElementTracker*
`;
}
/**
* 🔄 Завершение отслеживания теста
*/
async finishTestTracking(testName) {
const testData = this.testCoverageData.tests.get(testName);
if (testData) {
testData.endTime = Date.now();
console.log(`✅ Завершено отслеживание теста: ${testName}`);
}
}
/**
* 📈 Получение статистики покрытия
*/
getCoverageStats() {
return {
tests: this.testCoverageData.tests.size,
pages: this.testCoverageData.pages.size,
totalElements: Array.from(this.testCoverageData.pages.values())
.reduce((sum, page) => sum + page.elements.length, 0)
};
}
}