swagger-coverage-cli
Version:
A Node.js CLI tool to measure test coverage of Swagger/OpenAPI specs using Postman collections.
1,001 lines (891 loc) • 30.9 kB
JavaScript
// report.js
"use strict";
/**
* generateHtmlReport - enhanced version adding:
* - Coverage by Tags/Groups (an extra bar/donut chart)
* - PDF export button
* - Detailed Status Code checks in the sub-table
* - History/Trend Over Time displayed as a line chart next to the coverage pie
* - Nested expandable tables for JS test scripts with syntax highlighting
*
* coverageItems: [
* {
* method: "GET",
* path: "/v2/artist/elements",
* name: "listElements",
* tags: ["Artists", "Collections"],
* expectedStatusCodes: ["200","400"],
* statusCode: "200",
* unmatched: false,
* matchedRequests: [
* {
* name: "Get Elements (Postman)",
* rawUrl: "https://api.example.com/v2/artist/elements?foo=bar",
* method: "GET",
* testedStatusCodes: ["200","404"],
* testScripts: "pm.test('Status code is 200', function () { pm.response.to.have.status(200); });"
* },
* ...
* ]
* },
* ...
* ]
*/
function generateHtmlReport({ coverage, coverageItems, meta }) {
const { timestamp, specName, postmanCollectionName } = meta;
const covered = coverage;
const notCovered = 100 - coverage;
// Convert coverageItems to JSON for client side
const coverageDataJson = JSON.stringify(coverageItems);
const html = `
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enhanced Swagger Coverage Report</title>
<!-- Basic Material-like style + dark theme classes -->
<style>
:root {
--md-bg-color: #fafafa;
--md-text-color: #212121;
--md-surface-color: #ffffff;
--md-table-header-bg: #f5f5f5;
--md-table-hover: #eee;
--md-subtable-bg: #e0f2e9; /* soft green for sub-tables */
--md-primary-color: #6200ee; /* example, can adapt */
--md-border-color: #ccc;
}
.dark-theme {
--md-bg-color: #121212;
--md-text-color: #eeeeee;
--md-surface-color: #1e1e1e;
--md-table-header-bg: #2a2a2a;
--md-table-hover: #333333;
--md-subtable-bg: #224f3b; /* darker green for sub-table in dark theme */
--md-border-color: #666;
}
body {
margin: 0;
font-family: "Roboto", sans-serif;
background-color: var(--md-bg-color);
color: var(--md-text-color);
}
header {
padding: 16px;
background-color: var(--md-surface-color);
position: relative;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
margin: 0 0 8px 0;
font-size: 1.6rem;
}
.meta-info {
font-size: 0.9em;
opacity: 0.8;
}
.theme-toggle {
position: absolute;
top: 16px;
right: 16px;
background: none;
border: none;
cursor: pointer;
font-size: 1.4rem;
opacity: 0.7;
transition: opacity 0.3s;
}
.theme-toggle:hover {
opacity: 1;
}
.top-buttons {
padding: 8px 16px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
button {
cursor: pointer;
border: none;
border-radius: 20px;
padding: 8px 16px;
background-color: var(--md-primary-color);
color: #fff;
font-weight: 500;
font-size: 0.9rem;
letter-spacing: 0.5px;
}
button:hover {
background-color: #3700b3;
}
.coverage-section {
display: flex;
flex-wrap: wrap;
justify-content: center; /* Center containers horizontally */
gap: 20px;
padding: 16px;
}
.chart-container {
width: 300px;
max-width: 50%;
position: relative;
height: 400px; /* Fixed height for chart */
margin-bottom: 30px; /* Add bottom margin */
}
/* Special styling for tag chart container */
.chart-container:nth-child(3) {
width: 750px; /* 300px * 2.5 = 750px */
max-width: 100%; /* Allow full width */
}
/* При большом количестве тегов увеличиваем высоту */
@media (min-height: 1000px) {
.chart-container {
height: 600px; /* Increase height for many tags */
}
}
.chart-title {
text-align: center;
margin: 0.5rem 0;
font-size: 0.9rem;
opacity: 0.8;
}
.coverage-text {
min-width: 180px;
}
.trend-chart-container {
width: 300px;
max-width: 50%;
position: relative;
}
.filter-container {
padding: 0 16px 16px 16px;
display: flex;
align-items: center;
gap: 16px;
}
.search-container {
position: relative;
flex: 1;
max-width: 300px;
}
.search-input {
width: 100%;
padding: 8px 16px;
border: 1px solid var(--md-primary-color);
border-radius: 20px;
outline: none;
background: var(--md-surface-color);
color: var(--md-text-color);
font-size: 0.9rem;
transition: border-color 0.3s, box-shadow 0.3s;
}
.search-input:focus {
border-color: var(--md-primary-color);
box-shadow: 0 0 0 2px rgba(98, 0, 238, 0.2);
}
.search-input::placeholder {
color: rgba(0, 0, 0, 0.5);
}
.dark-theme .search-input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 40px;
}
th, td {
border: 1px solid var(--md-border-color);
padding: 8px;
}
th {
background-color: var(--md-table-header-bg);
text-align: left;
cursor: pointer;
}
tr:hover {
background-color: var(--md-table-hover);
}
tr.unmatched-spec > td.spec-cell {
background-color: rgba(255,229,229, 0.7); /* light red */
}
tr.spec-row.matched {
cursor: pointer;
}
.matched-requests-row {
display: none;
}
.postman-table {
border: 1px solid var(--md-border-color);
margin-top: 5px;
width: 100%;
background-color: var(--md-subtable-bg);
}
.postman-table th {
background-color: rgba(255,255,255,0.2);
}
/* Nested JS Code Table */
.js-code-row {
display: none;
}
.js-code-table {
width: 100%;
border-collapse: collapse;
margin-top: 5px;
background-color: #f0f0f0; /* Light grey for code table */
}
.js-code-table th, .js-code-table td {
border: 1px solid #ddd;
padding: 8px;
}
.js-code-table th {
background-color: #e0e0e0;
text-align: left;
}
/* Footer */
footer {
text-align: center;
font-size: 0.9em;
opacity: 0.6;
margin: 16px;
}
/* Highlight.js Styles */
/* Выберите тему по вашему усмотрению на https://highlightjs.org/static/demo/ */
.hljs {
background: none;
color: inherit;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-section,
.hljs-link {
color: #d73a49;
font-weight: bold;
}
.hljs-string,
.hljs-title,
.hljs-name,
.hljs-type,
.hljs-attr,
.hljs-number,
.hljs-selector-id,
.hljs-selector-class {
color: #005cc5;
}
.hljs-comment,
.hljs-quote {
color: #6a737d;
font-style: italic;
}
.hljs-doctag,
.hljs-meta,
.hljs-tag .hljs-attr {
color: #22863a;
font-weight: bold;
}
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_,
.hljs-title.function_.inherited__ {
color: #6f42c1;
}
.badge {
display: inline-block;
padding: 2px 6px;
margin: 1px;
border-radius: 4px;
font-size: 0.85em;
}
.badge-green { background: #c8e6c9; color: #2e7d32; }
.badge-yellow { background: #fff9c4; color: #f57f17; }
.badge-red { background: #ffcdd2; color: #c62828; }
.eye-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.2em;
opacity: 0.7;
transition: opacity 0.2s;
}
.eye-btn:hover { opacity: 1; }
/* Tooltip styles */
[data-tooltip] {
position: relative;
cursor: help;
}
[data-tooltip]:hover:after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 4px 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
z-index: 100;
margin-bottom: 4px;
}
/* Ensure tooltips are visible even in dark theme */
.dark-theme [data-tooltip]:hover:after {
background: rgba(255, 255, 255, 0.9);
color: black;
}
.recommendation-icon {
display: inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
background-color: #ffd700;
color: #000;
border-radius: 50%;
cursor: pointer;
font-weight: bold;
}
.recommendation-text {
display: none;
}
.recommendation-text.visible {
display: block;
}
/* Update the code-toggle style to remove background color */
.code-toggle {
display: inline-block;
color: #ffd700;
cursor: pointer;
font-family: monospace;
font-weight: bold;
font-size: 14px;
transition: opacity 0.2s;
}
.code-toggle:hover {
opacity: 0.8;
}
</style>
</head>
<body>
<header>
<h1>Swagger Coverage Report</h1>
<button class="theme-toggle" id="themeToggleBtn" onclick="toggleTheme()">
🔆 <!-- flashlight icon -->
</button>
<div class="meta-info">
<p><strong>Timestamp:</strong> ${timestamp}</p>
<p><strong>API Spec:</strong> ${specName}</p>
<p><strong>Postman Collection:</strong> ${postmanCollectionName}</p>
<p><strong>Coverage:</strong> ${coverage.toFixed(2)}%</p>
<p>Covered: ${covered.toFixed(2)}%<br/>
Not Covered: ${notCovered.toFixed(2)}%</p>
</div>
</header>
<div class="top-buttons">
<!-- Button to export to PDF -->
<button onclick="exportToPDF()">Export PDF</button>
</div>
<section class="coverage-section">
<!-- Coverage Pie Chart -->
<div class="chart-container">
<canvas id="coverageChart"></canvas>
<div class="chart-title">Overall Coverage</div>
</div>
<!-- Trend chart container -->
<div class="trend-chart-container">
<canvas id="trendChart"></canvas>
<div class="chart-title">Coverage Trend Over Time</div>
</div>
<!-- Tag coverage chart container -->
<div class="chart-container">
<canvas id="tagChart"></canvas>
<div class="chart-title">Coverage by Tag</div>
</div>
</section>
<div class="filter-container">
<button class="filter-button" id="filterBtn" onclick="cycleFilterMode()">
Show: All
</button>
<div class="search-container">
<input type="text"
class="search-input"
id="searchInput"
placeholder="Search..."
oninput="filterTable()">
</div>
</div>
<table id="specTable">
<thead>
<tr>
<th onclick="sortTableBy('method')">Method</th>
<th onclick="sortTableBy('path')">Path</th>
<th onclick="sortTableBy('name')">Name</th>
<th onclick="sortTableBy('statusCode')">StatusCode</th>
</tr>
</thead>
<tbody id="specTbody">
<!-- Rows rendered by JS -->
</tbody>
</table>
</div>
<footer>
<p>Generated by swagger-coverage-cli</p>
</footer>
<!-- Chart.js from a CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Highlight.js for code formatting -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script>
// Initialize Highlight.js
hljs.highlightAll();
// coverageData from server
let coverageData = ${coverageDataJson};
// Merge duplicates for display only
function unifyByMethodAndPath(items) {
const result = {};
items.forEach(item => {
const key = (item.method + item.path).toLowerCase();
if (!result[key]) {
result[key] = { ...item, matchedRequests: [...item.matchedRequests] };
} else {
// Merge matchedRequests
result[key].matchedRequests.push(...item.matchedRequests);
// Merge tags if needed
result[key].tags = Array.from(new Set([...result[key].tags, ...item.tags]));
// Merge expectedStatusCodes
result[key].expectedStatusCodes = Array.from(
new Set([...result[key].expectedStatusCodes, ...item.expectedStatusCodes])
);
// If any item is matched, set unmatched = false
if (!item.unmatched) result[key].unmatched = false;
}
});
return Object.values(result);
}
// Sort direction
let sortAsc = true;
// Filter mode: "all", "matched", "unmatched"
let filterMode = "all";
// Method priority for custom sorting
const methodPriority = ["get","post","put","patch","delete","head","options","trace"];
// The "darkTheme" toggle
let darkTheme = false;
// On load
window.onload = function() {
coverageData = unifyByMethodAndPath(coverageData);
// Save coverage in localStorage to build a trend
updateCoverageHistory(${coverage.toFixed(2)});
renderCoverageChart(${coverage.toFixed(2)});
renderTrendChart();
renderTagChart();
renderTable();
};
// Render Trend Chart from localStorage coverageHistory
function renderTrendChart() {
let hist = localStorage.getItem('coverageHistory');
let arr = hist ? JSON.parse(hist) : [];
const labels = arr.map(e => new Date(e.timestamp).toLocaleDateString() + " " + new Date(e.timestamp).toLocaleTimeString());
const dataPoints = arr.map(e => e.coverage);
const ctx = document.getElementById('trendChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Coverage Trend (%)',
data: dataPoints,
borderColor: '#6200ee',
backgroundColor: 'rgba(98, 0, 238, 0.1)',
fill: true,
tension: 0.2,
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' }
},
scales: {
y: {
beginAtZero: true,
max: 100
}
}
}
});
}
// Render Tag coverage chart
function renderTagChart() {
// Получаем все уникальные теги из спецификации
let allTags = new Set();
// Сначала собираем все теги из операций
coverageData.forEach(item => {
(item.tags || []).forEach(tag => allTags.add(tag));
});
// Инициализируем статистику для всех тегов
let tagStats = {};
allTags.forEach(tag => {
tagStats[tag] = { total: 0, matched: 0 };
});
// Теперь подсчитываем статистику
coverageData.forEach(item => {
let tags = item.tags || [];
tags.forEach(tag => {
tagStats[tag].total += 1;
if(!item.unmatched) {
tagStats[tag].matched += 1;
}
});
});
// Сортируем теги по алфавиту для лучшей читаемости
let labels = Object.keys(tagStats).sort();
let coverageVals = labels.map(t => {
// Если total === 0, возвращаем 0 чтобы избежать деления на ноль
return tagStats[t].total === 0 ? 0 : (tagStats[t].matched / tagStats[t].total) * 100;
});
const ctx = document.getElementById('tagChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [
{
label: 'Tag Coverage (%)',
data: coverageVals,
backgroundColor: '#03dac6'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' }
},
scales: {
y: {
beginAtZero: true,
max: 100
},
x: {
ticks: {
autoSkip: false, // Disable auto-skip
maxRotation: 45, // Rotate labels for better readability
minRotation: 45
}
}
}
}
});
}
// Render coverage pie chart
function renderCoverageChart(cov) {
const ctx = document.getElementById('coverageChart').getContext('2d');
new Chart(ctx, {
type: 'pie',
data: {
labels: ['Covered', 'Not Covered'],
datasets: [{
data: [cov, 100 - cov],
backgroundColor: ['#4caf50','#f44336']
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' }
}
}
});
}
// Export PDF via window.print() approach
function exportToPDF() {
window.print();
}
// Update localStorage coverage history
function updateCoverageHistory(current) {
let hist = localStorage.getItem('coverageHistory');
let arr = hist ? JSON.parse(hist) : [];
// Limit to 10 entries to avoid unbounded growth
if (arr.length >= 10) {
arr.shift();
}
arr.push({ timestamp: Date.now(), coverage: current });
localStorage.setItem('coverageHistory', JSON.stringify(arr));
}
// Toggle theme
function toggleTheme() {
darkTheme = !darkTheme;
document.body.classList.toggle('dark-theme', darkTheme);
const btn = document.getElementById('themeToggleBtn');
btn.innerHTML = darkTheme ? '☼' : '🔆';
// e.g., sun (☀) vs flashlight (🔦)
}
// Cycle the filter mode: all -> matched -> unmatched -> all ...
function cycleFilterMode() {
if (filterMode === 'all') {
filterMode = 'matched';
} else if (filterMode === 'matched') {
filterMode = 'unmatched';
} else {
filterMode = 'all';
}
const filterBtn = document.getElementById('filterBtn');
filterBtn.textContent = 'Show: ' + (filterMode.charAt(0).toUpperCase() + filterMode.slice(1));
filterTable();
}
// Add helper function for color-coded status code badges
function buildStatusBadges(specCodes, testedCodes) {
const specSet = new Set(specCodes || []);
const testSet = new Set(testedCodes || []);
let result = '';
// Check if all spec codes were found in tests
const allFound = Array.from(specSet).every(code => testSet.has(code));
// For spec codes, use green if found in tests, red if not
specSet.forEach(code => {
const badgeClass = testSet.has(code) ? 'badge-green' : 'badge-red';
const tooltip = testSet.has(code)
? 'Documented and verified'
: 'Documented but not verified';
result += '<span class="badge ' + badgeClass + '" data-tooltip="' + tooltip + '">' + code + '</span> ';
});
// For extra tested codes not in spec, use yellow
testSet.forEach(code => {
if (!specSet.has(code)) {
const tooltip = 'Not documented';
result += '<span class="badge badge-yellow" data-tooltip="' + tooltip + '">' + code + '</span> ';
}
});
return result.trim();
}
// Render the main table
function renderTable() {
const tbody = document.getElementById('specTbody');
tbody.innerHTML = '';
const filtered = coverageData.filter(item => {
if(filterMode === 'matched') return !item.unmatched;
if(filterMode === 'unmatched') return item.unmatched;
return true; // 'all'
});
filtered.forEach((item, idx) => {
const rowClass = item.unmatched ? "unmatched-spec" : "";
const hasMatches = item.matchedRequests && item.matchedRequests.length > 0;
const tr = document.createElement('tr');
tr.className = "spec-row " + rowClass;
// Only matched items get the onclick
if (hasMatches) {
tr.classList.add("matched");
const rowId = "match-row-" + idx;
tr.onclick = () => toggleMatchedRow(rowId);
}
// Columns
const tdMethod = document.createElement('td');
tdMethod.className = "spec-cell";
tdMethod.textContent = (item.method || "").toUpperCase();
const tdPath = document.createElement('td');
tdPath.className = "spec-cell";
tdPath.textContent = item.path || "";
const tdName = document.createElement('td');
tdName.className = "spec-cell";
tdName.textContent = item.name || item.summary || item.operationId || '(No operationId in spec)';
const tdStatus = document.createElement('td');
tdStatus.className = "spec-cell";
tdStatus.innerHTML = buildStatusBadges(
item.expectedStatusCodes,
item.matchedRequests.flatMap(req => req.testedStatusCodes || [])
);
tr.appendChild(tdMethod);
tr.appendChild(tdPath);
tr.appendChild(tdName);
tr.appendChild(tdStatus);
tbody.appendChild(tr);
// If matched, add a hidden sub-row with the postman requests
if(hasMatches) {
const subTr = document.createElement('tr');
subTr.id = "match-row-" + idx;
subTr.className = "matched-requests-row";
const subTd = document.createElement('td');
subTd.colSpan = 4;
const pmTable = document.createElement('table');
pmTable.className = "postman-table";
// Update column header from "Codes (Spec vs. Tested)" to "Recommendation"
const pmThead = document.createElement('thead');
pmThead.innerHTML = '<tr><th>Postman Request Name</th><th>Method</th><th>URL</th><th>Recommendation</th><th>Test Scripts</th></tr>';
pmTable.appendChild(pmThead);
const pmTbody = document.createElement('tbody');
item.matchedRequests.forEach(pmReq => {
const pmRow = document.createElement('tr');
const pmName = document.createElement('td');
pmName.textContent = pmReq.name || "";
pmRow.appendChild(pmName);
const pmMethod = document.createElement('td');
pmMethod.textContent = (pmReq.method || "").toUpperCase();
pmRow.appendChild(pmMethod);
const pmUrl = document.createElement('td');
pmUrl.textContent = pmReq.rawUrl || "";
pmRow.appendChild(pmUrl);
// Replace status code badges with recommendations
const pmCodes = document.createElement('td');
const recommendations = generateRecommendations(pmReq, item);
if (recommendations.length > 0) {
const icon = document.createElement('div');
icon.className = 'recommendation-icon';
icon.textContent = '!';
icon.onclick = function(e) {
e.stopPropagation(); // Prevent row expansion
this.style.display = 'none';
text.classList.add('visible');
};
const text = document.createElement('div');
text.className = 'recommendation-text';
text.innerHTML = '<ul style="margin: 0; padding-left: 20px; color: var(--md-text-color);">' +
recommendations.map(r => '<li>' + r + '</li>').join('') + '</ul>';
text.onclick = function(e) {
e.stopPropagation(); // Prevent row expansion
this.classList.remove('visible');
icon.style.display = 'inline-block';
};
pmCodes.appendChild(icon);
pmCodes.appendChild(text);
} else {
pmCodes.innerHTML = '<span style="color: var(--md-text-color);">All recommended tests are present</span>';
}
pmRow.appendChild(pmCodes);
// Update the Test Scripts cell generation code
const pmScripts = document.createElement('td');
if(pmReq.testScripts) {
if(pmReq.testScripts.length > 100) {
const id = 'script_' + idx + '_' + Math.random().toString(36).substr(2,9);
const codeToggle = document.createElement('div');
codeToggle.className = 'code-toggle';
codeToggle.textContent = '</>';
codeToggle.style.display = 'inline-block';
codeToggle.id = 'toggle_' + id;
codeToggle.onclick = function(e) {
e.stopPropagation();
this.style.display = 'none';
codeBlock.style.display = 'block';
};
const codeBlock = document.createElement('div');
codeBlock.id = id;
codeBlock.style.display = 'none'; // Initially hidden
codeBlock.innerHTML = '<pre><code class="javascript">' + pmReq.testScripts + '</code></pre>';
codeBlock.onclick = function(e) {
e.stopPropagation();
this.style.display = 'none';
codeToggle.style.display = 'block';
};
pmScripts.appendChild(codeToggle);
pmScripts.appendChild(codeBlock);
} else {
const pre = document.createElement('pre');
const code = document.createElement('code');
code.className = 'javascript';
code.textContent = pmReq.testScripts;
pre.appendChild(code);
pmScripts.appendChild(pre);
}
} else {
pmScripts.textContent = 'No scripts found';
}
pmRow.appendChild(pmScripts);
pmTbody.appendChild(pmRow);
});
pmTable.appendChild(pmTbody);
subTd.appendChild(pmTable);
subTr.appendChild(subTd);
tbody.appendChild(subTr);
}
});
// Re-initialize Highlight.js for new code blocks
hljs.highlightAll();
filterTable();
}
// Toggle the matched requests sub-row
function toggleMatchedRow(rowId) {
const row = document.getElementById(rowId);
if(!row) return;
row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
}
// Sort coverageData
function sortTableBy(columnKey) {
coverageData.sort((a, b) => {
let valA = a[columnKey] || "";
let valB = b[columnKey] || "";
if(columnKey === "method") {
const mA = valA.toLowerCase();
const mB = valB.toLowerCase();
const iA = methodPriority.indexOf(mA);
const iB = methodPriority.indexOf(mB);
if(iA !== -1 && iB !== -1) {
return sortAsc ? iA - iB : iB - iA;
}
if(iA !== -1 && iB === -1) {
return sortAsc ? -1 : 1;
}
if(iB !== -1 && iA === -1) {
return sortAsc ? 1 : -1;
}
}
// Fallback to standard string compare
valA = valA.toString().toLowerCase();
valB = valB.toString().toLowerCase();
if(valA < valB) return sortAsc ? -1 : 1;
if(valA > valB) return sortAsc ? 1 : -1;
return 0;
});
sortAsc = !sortAsc;
renderTable();
}
// Update the generateRecommendations function to return array instead of HTML
function generateRecommendations(pmReq, item) {
const recommendations = [];
const testScripts = pmReq.testScripts || '';
const method = (pmReq.method || '').toUpperCase();
const hasResponseTimeTest = /pm\.response\.responseTime|pm\.expect\([^)]*responseTime[^)]*\)|pm\.response\.time/i.test(testScripts);
if (!hasResponseTimeTest) {
recommendations.push('Missing response time validation');
}
const hasSchemaValidation = /pm\.response\.json\(\).*schema|tv4\.validate|ajv\.validate|expect\(.*\)\.to\.match\.schema/i.test(testScripts);
if (!hasSchemaValidation && !(method === 'DELETE' && pmReq.testedStatusCodes.includes('204'))) {
recommendations.push('Missing JSON schema validation');
}
if (method !== 'DELETE' && item.requestBodyContent) {
recommendations.push('Consider testing boundary values');
}
const hasPaginationTerms = /page|per[_-]?page|perPage|perpage|Page|limit|offset|size|from|to|&[^=]*(?:page|perPage|per_page|limit|offset|size)[^&]*/i.test(testScripts);
if (!hasPaginationTerms && method !== 'DELETE') {
recommendations.push('Consider testing pagination if applicable');
}
if (method === 'PATCH') {
recommendations.push('Verify all partial update scenarios');
}
return recommendations;
}
// Add new function for table filtering by search
function filterTable() {
const searchText = document.getElementById('searchInput').value.toLowerCase();
const rows = document.querySelectorAll('#specTable tbody tr.spec-row');
rows.forEach(row => {
const method = row.querySelector('td:nth-child(1)').textContent;
const path = row.querySelector('td:nth-child(2)').textContent;
const name = row.querySelector('td:nth-child(3)').textContent;
const text = method + ' ' + path + ' ' + name.toLowerCase();
const matchesSearch = searchText === '' || text.includes(searchText);
const matchesFilter = (filterMode === 'all') ||
(filterMode === 'matched' && !row.classList.contains('unmatched-spec')) ||
(filterMode === 'unmatched' && row.classList.contains('unmatched-spec'));
row.style.display = matchesSearch && matchesFilter ? '' : 'none';
// Hide corresponding matched-requests-row if parent is hidden const rowId = row.getAttribute('onclick')?.match(/toggleMatchedRow\('(.+?)'\)/)?.[1]; if (rowId) { const matchedRow = document.getElementById(rowId); if (matchedRow) { matchedRow.style.display = 'none'; } }
});
}
</script>
</body>
</html>
`;
return html;
}
// Export the function
module.exports = { generateHtmlReport };