mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-10-29 00:49:42 +08:00
ci(cypress): create cypress dashboard (#8401)
Co-authored-by: PiX <69745008+pixincreate@users.noreply.github.com> Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
3
cypress-tests/.gitignore
vendored
3
cypress-tests/.gitignore
vendored
@ -8,3 +8,6 @@ videos/
|
||||
|
||||
creds.json
|
||||
run_all.sh
|
||||
|
||||
# Cypress Dashboard build
|
||||
**/dist
|
||||
1006
cypress-tests/dashboard/dashboard.js
Normal file
1006
cypress-tests/dashboard/dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
164
cypress-tests/dashboard/index.html
Normal file
164
cypress-tests/dashboard/index.html
Normal file
@ -0,0 +1,164 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hyperswitch Cypress Test Dashboard</title>
|
||||
<link rel="stylesheet" href="styles-minimal.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🚀 Hyperswitch Cypress Test Dashboard</h1>
|
||||
<div class="header-info">
|
||||
<span id="lastUpdated"></span>
|
||||
<!-- Report loader for hosted environment -->
|
||||
<div
|
||||
id="reportLoader"
|
||||
style="display: none; gap: 8px; align-items: center"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="reportNameInput"
|
||||
placeholder="Enter report name (e.g., report_2024_01_15)"
|
||||
style="
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
min-width: 250px;
|
||||
"
|
||||
/>
|
||||
<button
|
||||
id="loadReportBtn"
|
||||
class="btn btn-secondary"
|
||||
title="Load Specific Report"
|
||||
>
|
||||
📂 Load
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
id="loadLatestBtn"
|
||||
class="btn btn-primary"
|
||||
title="Load Latest Report"
|
||||
>
|
||||
📊 Latest Report
|
||||
</button>
|
||||
<button
|
||||
id="themeToggle"
|
||||
class="btn btn-secondary"
|
||||
title="Toggle theme"
|
||||
>
|
||||
🌓
|
||||
</button>
|
||||
<button id="refreshBtn" class="btn btn-primary">
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<section class="summary-cards">
|
||||
<div class="card">
|
||||
<h3>Total Connectors</h3>
|
||||
<div class="metric" id="totalConnectors">0</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Total Tests</h3>
|
||||
<div class="metric" id="totalTests">0</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Passed</h3>
|
||||
<div class="metric success" id="totalPassed">0</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Failed</h3>
|
||||
<div class="metric error" id="totalFailed">0</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Skipped</h3>
|
||||
<div class="metric warning" id="totalSkipped">0</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Success Rate</h3>
|
||||
<div class="metric success" id="successRate">0%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Failure Rate</h3>
|
||||
<div class="metric error" id="failureRate">0%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Execution Time</h3>
|
||||
<div class="metric" id="executionTime">0s</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<section class="charts-section">
|
||||
<div class="chart-container">
|
||||
<h3>Test Results & Execution Time by Connector</h3>
|
||||
<canvas id="connectorChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h3>Overall Test Distribution</h3>
|
||||
<canvas id="distributionChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h3>Average Test Duration by Connector</h3>
|
||||
<canvas id="avgDurationChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Connector Details -->
|
||||
<section class="connector-details">
|
||||
<h2>Connector Details</h2>
|
||||
<div class="filters">
|
||||
<select id="connectorFilter" class="filter-select">
|
||||
<option value="">All Connectors</option>
|
||||
</select>
|
||||
<select id="statusFilter" class="filter-select">
|
||||
<option value="">All Status</option>
|
||||
<option value="passed">Passed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
<option value="pending">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="connectorTables"></div>
|
||||
</section>
|
||||
|
||||
<!-- Failed Tests Details -->
|
||||
<section class="failed-tests collapsed" id="failedTestsSection">
|
||||
<h2 class="collapsible-header">
|
||||
Failed Tests Details
|
||||
<span class="failed-count" id="failedCount"></span>
|
||||
</h2>
|
||||
<div id="failedTestsList" class="collapsible-content"></div>
|
||||
</section>
|
||||
|
||||
<!-- Test Runner Modal -->
|
||||
<div id="testRunnerModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close">×</span>
|
||||
<h2>Run Individual Test</h2>
|
||||
<div class="test-runner-form">
|
||||
<label for="testConnector">Connector:</label>
|
||||
<select id="testConnector"></select>
|
||||
|
||||
<label for="testFile">Test File:</label>
|
||||
<select id="testFile"></select>
|
||||
|
||||
<label for="testCase">Test Case:</label>
|
||||
<select id="testCase"></select>
|
||||
|
||||
<button id="runTestBtn" class="btn btn-primary">Run Test</button>
|
||||
|
||||
<div id="testOutput" class="test-output"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
604
cypress-tests/dashboard/styles-minimal.css
Normal file
604
cypress-tests/dashboard/styles-minimal.css
Normal file
@ -0,0 +1,604 @@
|
||||
/* Reset and Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Light theme colors - Minimalistic */
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8f9fa;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6c757d;
|
||||
--text-tertiary: #000000;
|
||||
--border-color: #e9ecef;
|
||||
--shadow-color: rgba(0, 0, 0, 0.05);
|
||||
--hover-bg: #f1f3f5;
|
||||
|
||||
/* Status colors */
|
||||
--success-color: #04c38d;
|
||||
--success-bg: #e6fffa;
|
||||
--success-text: #065f46;
|
||||
--error-color: #dc2626;
|
||||
--error-bg: #fee2e2;
|
||||
--error-text: #991b1b;
|
||||
--warning-color: #2563eb;
|
||||
--warning-bg: #dbeafe;
|
||||
--warning-text: #1e3a8a;
|
||||
--info-color: #2563eb;
|
||||
--info-bg: #dbeafe;
|
||||
--info-text: #1e3a8a;
|
||||
--cypress-green: #04c38d;
|
||||
--cypress-dark: #171717;
|
||||
}
|
||||
|
||||
/* Dark theme - Better readability */
|
||||
body.dark-theme {
|
||||
--bg-primary: #0f0f0f;
|
||||
--bg-secondary: #1a1a1a;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-tertiary: #f3f4f6;
|
||||
--border-color: #2d2d2d;
|
||||
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||
--hover-bg: #262626;
|
||||
|
||||
/* Status colors for dark theme */
|
||||
--success-bg: rgba(4, 195, 141, 0.15);
|
||||
--success-text: #04c38d;
|
||||
--error-bg: rgba(220, 38, 38, 0.15);
|
||||
--error-text: #ef4444;
|
||||
--warning-bg: rgba(37, 99, 235, 0.15);
|
||||
--warning-text: #60a5fa;
|
||||
--info-bg: rgba(37, 99, 235, 0.15);
|
||||
--info-text: #60a5fa;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Header Styles - Minimal */
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
color: var(--text-primary);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#lastUpdated {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Button Styles - Minimal */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: var(--hover-bg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--cypress-green);
|
||||
color: white;
|
||||
border-color: var(--cypress-green);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #03a479;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Summary Cards - Minimal */
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--cypress-green);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.metric.success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.metric.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.metric.warning {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
/* Charts Section - Minimal */
|
||||
.charts-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 40px; /* Add padding to the bottom of the section */
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: var(--bg-secondary);
|
||||
padding: 20px;
|
||||
padding-bottom: 60px; /* Increased bottom padding for rotated labels */
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
height: 350px; /* Keep original height for the graph area */
|
||||
}
|
||||
|
||||
.chart-container h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Connector Details - Minimal */
|
||||
.connector-details {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.connector-details h2 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Tables - Single column layout */
|
||||
#connectorTables {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.connector-table {
|
||||
margin-bottom: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.connector-table h3 {
|
||||
color: var(--text-primary);
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.connector-table h3:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.connector-table h3::before {
|
||||
content: "▼";
|
||||
margin-right: 8px;
|
||||
transition: transform 0.3s ease;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.connector-table.collapsed h3::before {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.connector-table.collapsed table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.run-test-btn {
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--bg-secondary);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: var(--hover-bg);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status.passed {
|
||||
background-color: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.status.failed {
|
||||
background-color: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.status.skipped {
|
||||
background-color: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.status.pending {
|
||||
background-color: var(--info-bg);
|
||||
color: var(--info-text);
|
||||
}
|
||||
|
||||
/* Failed Tests Section - Minimal */
|
||||
.failed-tests {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.failed-tests h2 {
|
||||
color: var(--error-color);
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
padding: 16px 20px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.failed-tests h2:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.failed-tests h2::before {
|
||||
content: "▼";
|
||||
margin-right: 8px;
|
||||
transition: transform 0.3s ease;
|
||||
color: var(--error-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.failed-tests.collapsed h2::before {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.failed-tests .collapsible-content {
|
||||
padding: 0 20px 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.failed-tests.collapsed .collapsible-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.failed-count {
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.failed-test-item {
|
||||
border: 1px solid var(--error-color);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
background-color: var(--error-bg);
|
||||
}
|
||||
|
||||
.failed-test-item h4 {
|
||||
color: var(--error-text);
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.failed-test-details {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.failed-test-details span {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
color: var(--error-text);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.media-links {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.media-links a {
|
||||
color: var(--info-color);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.media-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Modal Styles - Minimal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--bg-secondary);
|
||||
margin: 5% auto;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border-color);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: var(--text-secondary);
|
||||
float: right;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.close:hover,
|
||||
.close:focus {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Test Runner Form - Minimal */
|
||||
.test-runner-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.test-runner-form label {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-runner-form select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.test-output {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--hover-bg);
|
||||
border-top: 2px solid var(--cypress-green);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.charts-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mb-20 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mt-20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Improved dark theme Chart.js support */
|
||||
body.dark-theme canvas {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
@ -10,6 +10,9 @@ export default [
|
||||
pluginCypress.configs.recommended,
|
||||
eslintPluginPrettierRecommended,
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
ignores: ["dist/**", "build/**", "node_modules/**"],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
|
||||
@ -13,6 +13,12 @@
|
||||
"cypress:payments": "cypress run --headless --spec 'cypress/e2e/spec/Payment/**/*'",
|
||||
"cypress:payouts": "cypress run --headless --spec 'cypress/e2e/spec/Payout/**/*'",
|
||||
"cypress:routing": "cypress run --headless --spec 'cypress/e2e/spec/Routing/**/*'",
|
||||
"report:generate": "node scripts/report-generator.js",
|
||||
"report:open": "open dashboard/index.html || start dashboard/index.html || xdg-open dashboard/index.html",
|
||||
"dashboard:serve": "node scripts/serve-dashboard.js",
|
||||
"dashboard": "npm run dashboard:serve",
|
||||
"cypress:ci:report": "npm run cypress:ci && npm run report:generate",
|
||||
"build": "node scripts/build.js",
|
||||
"format": "prettier --config .prettierrc . --write",
|
||||
"format:check": "prettier --config .prettierrc . --check",
|
||||
"lint": "eslint ."
|
||||
|
||||
104
cypress-tests/scripts/build.js
Normal file
104
cypress-tests/scripts/build.js
Normal file
@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import fs from "fs";
|
||||
|
||||
const DASHBOARD_DIR = "dashboard";
|
||||
const DIST_DIR = "dist";
|
||||
|
||||
// Create dist directory
|
||||
function createDirs() {
|
||||
if (!fs.existsSync(DIST_DIR)) {
|
||||
fs.mkdirSync(DIST_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Minify JavaScript
|
||||
function minifyJS(inputPath, outputPath) {
|
||||
let content = fs.readFileSync(inputPath, "utf8");
|
||||
|
||||
// Preserve eslint disable comments
|
||||
const eslintComments = [];
|
||||
content = content.replace(/\/\*\s*eslint-disable[\s\S]*?\*\//g, (match) => {
|
||||
eslintComments.push(match);
|
||||
return `/*ESLINT_PRESERVE_${eslintComments.length - 1}*/`;
|
||||
});
|
||||
content = content.replace(/\/\*\s*global[\s\S]*?\*\//g, (match) => {
|
||||
eslintComments.push(match);
|
||||
return `/*ESLINT_PRESERVE_${eslintComments.length - 1}*/`;
|
||||
});
|
||||
|
||||
// Basic minification
|
||||
content = content
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "") // Remove other block comments
|
||||
.replace(/\/\/.*$/gm, "") // Remove line comments
|
||||
.replace(/\s+/g, " ") // Replace multiple spaces with single space
|
||||
.replace(/;\s*}/g, ";}") // Remove space before closing brace
|
||||
.replace(/{\s*/g, "{") // Remove space after opening brace
|
||||
.replace(/}\s*/g, "}") // Remove space after closing brace
|
||||
.trim();
|
||||
|
||||
// Restore eslint comments
|
||||
eslintComments.forEach((comment, index) => {
|
||||
content = content.replace(`/*ESLINT_PRESERVE_${index}*/`, comment);
|
||||
});
|
||||
|
||||
fs.writeFileSync(outputPath, content);
|
||||
}
|
||||
|
||||
// Minify CSS
|
||||
function minifyCSS(inputPath, outputPath) {
|
||||
let content = fs.readFileSync(inputPath, "utf8");
|
||||
|
||||
// Basic CSS minification
|
||||
content = content
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "") // Remove comments
|
||||
.replace(/\s+/g, " ") // Replace multiple spaces
|
||||
.replace(/;\s*}/g, ";}") // Remove space before closing brace
|
||||
.replace(/{\s*/g, "{") // Remove space after opening brace
|
||||
.replace(/}\s*/g, "}") // Remove space after closing brace
|
||||
.replace(/:\s*/g, ":") // Remove space after colon
|
||||
.replace(/;\s*/g, ";") // Remove space after semicolon
|
||||
.trim();
|
||||
|
||||
fs.writeFileSync(outputPath, content);
|
||||
}
|
||||
|
||||
// Process HTML with minified references
|
||||
function processHTML() {
|
||||
let content = fs.readFileSync(`${DASHBOARD_DIR}/index.html`, "utf8");
|
||||
|
||||
// Update references to minified files
|
||||
content = content
|
||||
.replace("dashboard.js", "dashboard.min.js")
|
||||
.replace("styles-minimal.css", "styles-minimal.min.css");
|
||||
|
||||
fs.writeFileSync(`${DIST_DIR}/index.html`, content);
|
||||
}
|
||||
|
||||
// Main build function
|
||||
function build() {
|
||||
console.log("Building dashboard...");
|
||||
|
||||
createDirs();
|
||||
|
||||
// Minify files
|
||||
minifyJS(`${DASHBOARD_DIR}/dashboard.js`, `${DIST_DIR}/dashboard.min.js`);
|
||||
minifyCSS(
|
||||
`${DASHBOARD_DIR}/styles-minimal.css`,
|
||||
`${DIST_DIR}/styles-minimal.min.css`
|
||||
);
|
||||
|
||||
// Process HTML
|
||||
processHTML();
|
||||
|
||||
// Copy vercel.json if it exists
|
||||
if (fs.existsSync(`${DASHBOARD_DIR}/vercel.json`)) {
|
||||
fs.copyFileSync(`${DASHBOARD_DIR}/vercel.json`, `${DIST_DIR}/vercel.json`);
|
||||
}
|
||||
|
||||
console.log("✅ Build completed! Files ready in ./dist directory");
|
||||
}
|
||||
|
||||
// Main script logic
|
||||
build();
|
||||
41
cypress-tests/scripts/post-test-hook.js
Normal file
41
cypress-tests/scripts/post-test-hook.js
Normal file
@ -0,0 +1,41 @@
|
||||
/* eslint-disable no-console */
|
||||
import { exec } from "child_process";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// This script runs after Cypress tests complete
|
||||
async function runPostTestTasks() {
|
||||
console.log("🔄 Running post-test tasks...");
|
||||
|
||||
try {
|
||||
// Generate the report
|
||||
console.log("📊 Generating test report...");
|
||||
const reportGeneratorPath = path.join(__dirname, "report-generator.js");
|
||||
await execAsync(`node ${reportGeneratorPath}`);
|
||||
|
||||
// Open the dashboard in the default browser (optional)
|
||||
const dashboardPath = path.join(__dirname, "../dashboard/index.html");
|
||||
const openCommand =
|
||||
process.platform === "darwin"
|
||||
? `open ${dashboardPath}`
|
||||
: process.platform === "win32"
|
||||
? `start ${dashboardPath}`
|
||||
: `xdg-open ${dashboardPath}`;
|
||||
|
||||
console.log("🌐 Opening dashboard...");
|
||||
await execAsync(openCommand);
|
||||
|
||||
console.log("✅ Post-test tasks completed successfully!");
|
||||
} catch (error) {
|
||||
console.error("❌ Error in post-test tasks:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the tasks
|
||||
runPostTestTasks();
|
||||
327
cypress-tests/scripts/report-generator.js
Normal file
327
cypress-tests/scripts/report-generator.js
Normal file
@ -0,0 +1,327 @@
|
||||
/* eslint-disable no-console */
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
class CypressReportGenerator {
|
||||
constructor(reportsDir = path.join(__dirname, "../cypress/reports")) {
|
||||
this.reportsDir = reportsDir;
|
||||
this.summaryData = {
|
||||
connectors: {},
|
||||
totalTests: 0,
|
||||
totalPassed: 0,
|
||||
totalFailed: 0,
|
||||
totalSkipped: 0,
|
||||
totalPending: 0,
|
||||
executionTime: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
failedTests: [],
|
||||
};
|
||||
}
|
||||
|
||||
async generateReport() {
|
||||
try {
|
||||
await this.collectTestResults();
|
||||
await this.calculateMetrics();
|
||||
await this.generateSummaryReport();
|
||||
await this.generateDashboardData();
|
||||
console.log("✅ Report generation completed successfully!");
|
||||
} catch (error) {
|
||||
console.error("❌ Error generating report:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async collectTestResults() {
|
||||
const connectorDirs = fs
|
||||
.readdirSync(this.reportsDir)
|
||||
.filter((item) =>
|
||||
fs.statSync(path.join(this.reportsDir, item)).isDirectory()
|
||||
);
|
||||
|
||||
for (const connector of connectorDirs) {
|
||||
const connectorPath = path.join(this.reportsDir, connector);
|
||||
const jsonFiles = fs
|
||||
.readdirSync(connectorPath)
|
||||
.filter(
|
||||
(file) => file.endsWith(".json") && file !== "mochawesome.json"
|
||||
);
|
||||
|
||||
this.summaryData.connectors[connector] = {
|
||||
tests: [],
|
||||
totalTests: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
pending: 0,
|
||||
executionTime: 0,
|
||||
testsByFile: {},
|
||||
};
|
||||
|
||||
for (const jsonFile of jsonFiles) {
|
||||
const reportPath = path.join(connectorPath, jsonFile);
|
||||
const reportData = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
||||
|
||||
this.processReportData(connector, reportData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processReportData(connector, reportData) {
|
||||
const connectorData = this.summaryData.connectors[connector];
|
||||
|
||||
// Process stats
|
||||
if (reportData.stats) {
|
||||
connectorData.totalTests += reportData.stats.tests || 0;
|
||||
connectorData.passed += reportData.stats.passes || 0;
|
||||
connectorData.failed += reportData.stats.failures || 0;
|
||||
connectorData.skipped += reportData.stats.skipped || 0;
|
||||
connectorData.pending += reportData.stats.pending || 0;
|
||||
connectorData.executionTime += reportData.stats.duration || 0;
|
||||
}
|
||||
|
||||
// Process individual test results
|
||||
if (reportData.results && reportData.results.length > 0) {
|
||||
reportData.results.forEach((result) => {
|
||||
if (result.suites) {
|
||||
this.processSuites(connector, result.suites, result.file);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
processSuites(connector, suites, file) {
|
||||
const connectorData = this.summaryData.connectors[connector];
|
||||
|
||||
suites.forEach((suite) => {
|
||||
if (suite.tests) {
|
||||
suite.tests.forEach((test) => {
|
||||
const testInfo = {
|
||||
title: test.title,
|
||||
fullTitle: test.fullTitle || `${suite.title} - ${test.title}`,
|
||||
state: test.state,
|
||||
duration: test.duration || 0,
|
||||
file: file,
|
||||
error: test.err
|
||||
? {
|
||||
message: test.err.message,
|
||||
stack: test.err.stack,
|
||||
diff: test.err.diff,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
connectorData.tests.push(testInfo);
|
||||
|
||||
// Track by file
|
||||
if (!connectorData.testsByFile[file]) {
|
||||
connectorData.testsByFile[file] = {
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
pending: 0,
|
||||
tests: [],
|
||||
};
|
||||
}
|
||||
|
||||
connectorData.testsByFile[file].tests.push(testInfo);
|
||||
connectorData.testsByFile[file][test.state || "pending"]++;
|
||||
|
||||
// Track failed tests globally
|
||||
if (test.state === "failed") {
|
||||
this.summaryData.failedTests.push({
|
||||
connector,
|
||||
...testInfo,
|
||||
screenshot: this.findScreenshot(connector, test.title),
|
||||
video: this.findVideo(connector, file),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process nested suites
|
||||
if (suite.suites && suite.suites.length > 0) {
|
||||
this.processSuites(connector, suite.suites, file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
findScreenshot(connector, testTitle) {
|
||||
const screenshotDir = path.join(__dirname, "../screenshots", connector);
|
||||
if (!fs.existsSync(screenshotDir)) return null;
|
||||
|
||||
const screenshots = fs.readdirSync(screenshotDir);
|
||||
const sanitizedTitle = testTitle.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
|
||||
const screenshot = screenshots.find((file) =>
|
||||
file.toLowerCase().includes(sanitizedTitle.toLowerCase())
|
||||
);
|
||||
|
||||
return screenshot ? `/screenshots/${connector}/${screenshot}` : null;
|
||||
}
|
||||
|
||||
findVideo(connector, testFile) {
|
||||
const videoDir = path.join(__dirname, "../cypress/videos", connector);
|
||||
if (!fs.existsSync(videoDir)) return null;
|
||||
|
||||
const videos = fs.readdirSync(videoDir);
|
||||
const testFileName = path.basename(testFile, ".cy.js");
|
||||
|
||||
const video = videos.find((file) => file.includes(testFileName));
|
||||
|
||||
return video ? `/videos/${connector}/${video}` : null;
|
||||
}
|
||||
|
||||
calculateMetrics() {
|
||||
// Calculate totals
|
||||
Object.values(this.summaryData.connectors).forEach((connector) => {
|
||||
this.summaryData.totalTests += connector.totalTests;
|
||||
this.summaryData.totalPassed += connector.passed;
|
||||
this.summaryData.totalFailed += connector.failed;
|
||||
this.summaryData.totalSkipped += connector.skipped;
|
||||
this.summaryData.totalPending += connector.pending;
|
||||
this.summaryData.executionTime += connector.executionTime;
|
||||
|
||||
// Calculate rates
|
||||
connector.successRate =
|
||||
connector.totalTests > 0
|
||||
? ((connector.passed / connector.totalTests) * 100).toFixed(2)
|
||||
: 0;
|
||||
connector.failureRate =
|
||||
connector.totalTests > 0
|
||||
? ((connector.failed / connector.totalTests) * 100).toFixed(2)
|
||||
: 0;
|
||||
});
|
||||
|
||||
// Calculate overall rates
|
||||
this.summaryData.overallSuccessRate =
|
||||
this.summaryData.totalTests > 0
|
||||
? (
|
||||
(this.summaryData.totalPassed / this.summaryData.totalTests) *
|
||||
100
|
||||
).toFixed(2)
|
||||
: 0;
|
||||
this.summaryData.overallFailureRate =
|
||||
this.summaryData.totalTests > 0
|
||||
? (
|
||||
(this.summaryData.totalFailed / this.summaryData.totalTests) *
|
||||
100
|
||||
).toFixed(2)
|
||||
: 0;
|
||||
}
|
||||
|
||||
async generateSummaryReport() {
|
||||
const reportContent = this.formatSummaryReport();
|
||||
const outputPath = path.join(this.reportsDir, "summary-report.md");
|
||||
|
||||
fs.writeFileSync(outputPath, reportContent);
|
||||
console.log(`📄 Summary report generated: ${outputPath}`);
|
||||
}
|
||||
|
||||
formatSummaryReport() {
|
||||
let report = `# Cypress Test Summary Report
|
||||
|
||||
**Generated:** ${new Date(this.summaryData.timestamp).toLocaleString()}
|
||||
**Total Execution Time:** ${this.formatDuration(this.summaryData.executionTime)}
|
||||
|
||||
## Overall Summary
|
||||
|
||||
- **Total Connectors Tested:** ${Object.keys(this.summaryData.connectors).length}
|
||||
- **Total Tests:** ${this.summaryData.totalTests}
|
||||
- **Passed:** ${this.summaryData.totalPassed} ✅
|
||||
- **Failed:** ${this.summaryData.totalFailed} ❌
|
||||
- **Skipped:** ${this.summaryData.totalSkipped} ⏭️
|
||||
- **Pending:** ${this.summaryData.totalPending} ⏸️
|
||||
- **Overall Success Rate:** ${this.summaryData.overallSuccessRate}%
|
||||
- **Overall Failure Rate:** ${this.summaryData.overallFailureRate}%
|
||||
|
||||
## Connector Details
|
||||
|
||||
`;
|
||||
|
||||
// Add connector details
|
||||
Object.entries(this.summaryData.connectors).forEach(([connector, data]) => {
|
||||
report += `### ${connector.toUpperCase()}
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Tests | ${data.totalTests} |
|
||||
| Passed | ${data.passed} ✅ |
|
||||
| Failed | ${data.failed} ❌ |
|
||||
| Skipped | ${data.skipped} ⏭️ |
|
||||
| Pending | ${data.pending} ⏸️ |
|
||||
| Success Rate | ${data.successRate}% |
|
||||
| Failure Rate | ${data.failureRate}% |
|
||||
| Execution Time | ${this.formatDuration(data.executionTime)} |
|
||||
|
||||
`;
|
||||
|
||||
// Add test details by file
|
||||
if (Object.keys(data.testsByFile).length > 0) {
|
||||
report += `#### Test Files\n\n`;
|
||||
Object.entries(data.testsByFile).forEach(([file, fileData]) => {
|
||||
const fileName = path.basename(file);
|
||||
report += `- **${fileName}**: ${fileData.passed}✅ ${fileData.failed}❌ ${fileData.skipped}⏭️ ${fileData.pending}⏸️\n`;
|
||||
});
|
||||
report += "\n";
|
||||
}
|
||||
});
|
||||
|
||||
// Add failed tests section
|
||||
if (this.summaryData.failedTests.length > 0) {
|
||||
report += `## Failed Tests Details\n\n`;
|
||||
|
||||
this.summaryData.failedTests.forEach((test, index) => {
|
||||
report += `### ${index + 1}. ${test.fullTitle}
|
||||
|
||||
- **Connector:** ${test.connector}
|
||||
- **File:** ${path.basename(test.file)}
|
||||
- **Duration:** ${test.duration}ms
|
||||
- **Error:** ${test.error?.message || "No error message available"}
|
||||
`;
|
||||
|
||||
if (test.screenshot) {
|
||||
report += `- **Screenshot:** [View Screenshot](${test.screenshot})\n`;
|
||||
}
|
||||
|
||||
if (test.video) {
|
||||
report += `- **Video:** [View Recording](${test.video})\n`;
|
||||
}
|
||||
|
||||
report += "\n";
|
||||
});
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
formatDuration(ms) {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
async generateDashboardData() {
|
||||
const dashboardDataPath = path.join(this.reportsDir, "dashboard-data.json");
|
||||
fs.writeFileSync(
|
||||
dashboardDataPath,
|
||||
JSON.stringify(this.summaryData, null, 2)
|
||||
);
|
||||
console.log(`📊 Dashboard data generated: ${dashboardDataPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the report generator
|
||||
const generator = new CypressReportGenerator();
|
||||
generator.generateReport();
|
||||
81
cypress-tests/scripts/serve-dashboard.js
Normal file
81
cypress-tests/scripts/serve-dashboard.js
Normal file
@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import fs from "fs";
|
||||
import http from "http";
|
||||
import path, { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// Get the directory of the current module
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Define the port
|
||||
const PORT = process.env.PORT || 3333;
|
||||
|
||||
// MIME types
|
||||
const mimeTypes = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
// Create server
|
||||
const server = http.createServer((req, res) => {
|
||||
// Parse URL
|
||||
let filePath = path.join(__dirname, "..", req.url);
|
||||
|
||||
// Default to index.html for root
|
||||
if (req.url === "/") {
|
||||
filePath = path.join(__dirname, "..", "dashboard", "index.html");
|
||||
}
|
||||
|
||||
// Check if path is a directory and append index.html
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(filePath, "index.html");
|
||||
}
|
||||
|
||||
// Get file extension
|
||||
const extname = String(path.extname(filePath)).toLowerCase();
|
||||
const contentType = mimeTypes[extname] || "application/octet-stream";
|
||||
|
||||
// Read and serve the file
|
||||
fs.readFile(filePath, (error, content) => {
|
||||
if (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
res.writeHead(404, { "Content-Type": "text/html" });
|
||||
res.end("<h1>404 - File Not Found</h1>", "utf-8");
|
||||
} else {
|
||||
res.writeHead(500);
|
||||
res.end(`Server Error: ${error.code}`, "utf-8");
|
||||
}
|
||||
} else {
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
res.end(content, "utf-8");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
server.listen(PORT, () => {
|
||||
console.log(`\n🚀 Dashboard server running at http://localhost:${PORT}/`);
|
||||
console.log(
|
||||
`📊 Open http://localhost:${PORT}/dashboard/ to view the dashboard`
|
||||
);
|
||||
console.log("\nPress Ctrl+C to stop the server\n");
|
||||
});
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on("SIGINT", () => {
|
||||
console.log("\n\nShutting down server...");
|
||||
server.close(() => {
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user