qase-report 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +282 -0
- package/dist/cli/commands/generate.js +69 -0
- package/dist/cli/commands/open.js +95 -0
- package/dist/cli/generators/html-generator.js +117 -0
- package/dist/cli/history.js +150 -0
- package/dist/cli/index.js +25 -0
- package/dist/cli/qase-api.js +275 -0
- package/dist/cli/qase-transform.js +119 -0
- package/dist/cli/server.js +459 -0
- package/dist/index.html +431 -0
- package/dist/schemas/Attachment.schema.js +43 -0
- package/dist/schemas/QaseHistory.schema.js +147 -0
- package/dist/schemas/QaseRun.schema.js +146 -0
- package/dist/schemas/QaseTestResult.schema.js +122 -0
- package/dist/schemas/Step.schema.js +87 -0
- package/package.json +92 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, join, resolve } from 'path';
|
|
4
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
import archiver from 'archiver';
|
|
7
|
+
import { generateHtmlReport } from './generators/html-generator.js';
|
|
8
|
+
import { createQaseRun, sendQaseResults, completeQaseRun, buildRunUrl, uploadAttachments, QaseApiError } from './qase-api.js';
|
|
9
|
+
import { transformResults } from './qase-transform.js';
|
|
10
|
+
import { TestResultSchema } from '../schemas/QaseTestResult.schema.js';
|
|
11
|
+
// Strip UTF-8 BOM that some tools (e.g. .NET reporters) prepend to JSON files
|
|
12
|
+
const stripBom = (s) => s.replace(/^\uFEFF/, '');
|
|
13
|
+
// Get package root directory
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = dirname(__filename);
|
|
16
|
+
const packageRoot = resolve(__dirname, '..', '..');
|
|
17
|
+
/**
|
|
18
|
+
* Create Express application configured to serve the React app and report API
|
|
19
|
+
*/
|
|
20
|
+
export function createServer(options) {
|
|
21
|
+
const { reportPath, port = 3000, historyPath } = options;
|
|
22
|
+
const resolvedReportPath = resolve(reportPath);
|
|
23
|
+
const distPath = join(packageRoot, 'dist');
|
|
24
|
+
const app = express();
|
|
25
|
+
// Store port for later use
|
|
26
|
+
app.set('port', port);
|
|
27
|
+
// Store history path for API endpoint
|
|
28
|
+
app.set('historyPath', historyPath);
|
|
29
|
+
// Enable JSON body parsing for POST endpoints
|
|
30
|
+
app.use(express.json());
|
|
31
|
+
// API endpoint: GET /api/report
|
|
32
|
+
app.get('/api/report', (req, res, next) => {
|
|
33
|
+
try {
|
|
34
|
+
// Read run.json
|
|
35
|
+
const runJsonPath = join(resolvedReportPath, 'run.json');
|
|
36
|
+
if (!existsSync(runJsonPath)) {
|
|
37
|
+
res.status(404).json({
|
|
38
|
+
error: 'Report not found',
|
|
39
|
+
message: `run.json not found at ${runJsonPath}`,
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const runData = JSON.parse(stripBom(readFileSync(runJsonPath, 'utf-8')));
|
|
44
|
+
// Read all results from results/ directory
|
|
45
|
+
const resultsDir = join(resolvedReportPath, 'results');
|
|
46
|
+
const results = [];
|
|
47
|
+
if (existsSync(resultsDir)) {
|
|
48
|
+
const resultFiles = readdirSync(resultsDir).filter((f) => f.endsWith('.json'));
|
|
49
|
+
for (const file of resultFiles) {
|
|
50
|
+
try {
|
|
51
|
+
const filePath = join(resultsDir, file);
|
|
52
|
+
const resultData = JSON.parse(stripBom(readFileSync(filePath, 'utf-8')));
|
|
53
|
+
results.push(resultData);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.warn(`Warning: Failed to parse ${file}:`, err);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Return combined report data
|
|
61
|
+
const response = {
|
|
62
|
+
run: runData,
|
|
63
|
+
results,
|
|
64
|
+
attachmentsPath: '/api/attachments',
|
|
65
|
+
};
|
|
66
|
+
res.json(response);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error('Error reading report:', err);
|
|
70
|
+
res.status(500).json({
|
|
71
|
+
error: 'Internal server error',
|
|
72
|
+
message: err instanceof Error ? err.message : 'Unknown error',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
// API endpoint: GET /api/history
|
|
77
|
+
app.get('/api/history', (req, res, next) => {
|
|
78
|
+
try {
|
|
79
|
+
const historyPath = app.get('historyPath');
|
|
80
|
+
if (!historyPath || !existsSync(historyPath)) {
|
|
81
|
+
// Return empty history structure if no history file
|
|
82
|
+
res.json({
|
|
83
|
+
schema_version: '1.0.0',
|
|
84
|
+
runs: [],
|
|
85
|
+
tests: [],
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const historyData = JSON.parse(stripBom(readFileSync(historyPath, 'utf-8')));
|
|
90
|
+
res.json(historyData);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.error('Error reading history:', err);
|
|
94
|
+
// Return empty history on error (non-critical)
|
|
95
|
+
res.json({
|
|
96
|
+
schema_version: '1.0.0',
|
|
97
|
+
runs: [],
|
|
98
|
+
tests: [],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
// Attachments endpoint: GET /api/attachments/:filename
|
|
103
|
+
app.get('/api/attachments/:filename', (req, res, next) => {
|
|
104
|
+
const { filename } = req.params;
|
|
105
|
+
const attachmentsDir = join(resolvedReportPath, 'attachments');
|
|
106
|
+
const filePath = join(attachmentsDir, filename);
|
|
107
|
+
// Security check: ensure resolved path is within attachments directory
|
|
108
|
+
const resolvedFilePath = resolve(filePath);
|
|
109
|
+
const resolvedAttachmentsDir = resolve(attachmentsDir);
|
|
110
|
+
if (!resolvedFilePath.startsWith(resolvedAttachmentsDir)) {
|
|
111
|
+
res.status(400).json({ error: 'Invalid filename' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!existsSync(resolvedFilePath)) {
|
|
115
|
+
res.status(404).json({ error: 'Attachment not found' });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
res.sendFile(resolvedFilePath);
|
|
119
|
+
});
|
|
120
|
+
// Trace viewer: serve playwright-core trace viewer static files
|
|
121
|
+
// This enables viewing Playwright traces in an iframe
|
|
122
|
+
const require = createRequire(import.meta.url);
|
|
123
|
+
try {
|
|
124
|
+
const playwrightCorePath = dirname(require.resolve('playwright-core/package.json'));
|
|
125
|
+
const traceViewerPath = join(playwrightCorePath, 'lib', 'vite', 'traceViewer');
|
|
126
|
+
if (existsSync(traceViewerPath)) {
|
|
127
|
+
app.use('/trace-viewer', express.static(traceViewerPath));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// playwright-core not installed - trace viewer endpoint won't be available
|
|
132
|
+
// This is expected when installed without optional dependencies
|
|
133
|
+
console.warn('playwright-core not found - trace viewer will not be available');
|
|
134
|
+
}
|
|
135
|
+
// API endpoint: check if trace viewer is available
|
|
136
|
+
app.get('/api/trace-viewer-available', (req, res) => {
|
|
137
|
+
try {
|
|
138
|
+
const playwrightCorePath = dirname(require.resolve('playwright-core/package.json'));
|
|
139
|
+
const traceViewerPath = join(playwrightCorePath, 'lib', 'vite', 'traceViewer');
|
|
140
|
+
res.json({ available: existsSync(traceViewerPath) });
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
res.json({ available: false });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
// ─── Download API Endpoints ───────────────────────────────────────────
|
|
147
|
+
// Download endpoint: GET /api/download/html
|
|
148
|
+
// Returns self-contained HTML report as file download
|
|
149
|
+
app.get('/api/download/html', (req, res, next) => {
|
|
150
|
+
try {
|
|
151
|
+
const historyPath = app.get('historyPath');
|
|
152
|
+
const html = generateHtmlReport({
|
|
153
|
+
reportPath: resolvedReportPath,
|
|
154
|
+
historyPath,
|
|
155
|
+
});
|
|
156
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
157
|
+
res.setHeader('Content-Disposition', 'attachment; filename="qase-report.html"');
|
|
158
|
+
res.send(html);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
console.error('Error generating HTML report:', err);
|
|
162
|
+
res.status(500).json({
|
|
163
|
+
error: 'Failed to generate HTML report',
|
|
164
|
+
message: err instanceof Error ? err.message : 'Unknown error',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
// Download endpoint: GET /api/download/history
|
|
169
|
+
// Returns history JSON as file download, or 404 if missing
|
|
170
|
+
app.get('/api/download/history', (req, res, next) => {
|
|
171
|
+
try {
|
|
172
|
+
const storedHistoryPath = app.get('historyPath');
|
|
173
|
+
const resolvedHistoryPath = storedHistoryPath || join(resolvedReportPath, 'qase-report-history.json');
|
|
174
|
+
if (!existsSync(resolvedHistoryPath)) {
|
|
175
|
+
res.status(404).json({ error: 'History file not found' });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const historyData = readFileSync(resolvedHistoryPath, 'utf-8');
|
|
179
|
+
res.setHeader('Content-Type', 'application/json');
|
|
180
|
+
res.setHeader('Content-Disposition', 'attachment; filename="qase-report-history.json"');
|
|
181
|
+
res.send(historyData);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
console.error('Error reading history file:', err);
|
|
185
|
+
res.status(500).json({
|
|
186
|
+
error: 'Failed to read history file',
|
|
187
|
+
message: err instanceof Error ? err.message : 'Unknown error',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
// Download endpoint: GET /api/download/zip
|
|
192
|
+
// Returns ZIP archive of the full report directory
|
|
193
|
+
app.get('/api/download/zip', (req, res, next) => {
|
|
194
|
+
try {
|
|
195
|
+
// Check run.json exists (required for a valid report)
|
|
196
|
+
const runJsonPath = join(resolvedReportPath, 'run.json');
|
|
197
|
+
if (!existsSync(runJsonPath)) {
|
|
198
|
+
res.status(404).json({
|
|
199
|
+
error: 'Report not found',
|
|
200
|
+
message: `run.json not found at ${runJsonPath}`,
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
205
|
+
res.setHeader('Content-Disposition', 'attachment; filename="qase-report.zip"');
|
|
206
|
+
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
207
|
+
archive.on('error', (err) => {
|
|
208
|
+
console.error('Archive error:', err);
|
|
209
|
+
if (!res.headersSent) {
|
|
210
|
+
res.status(500).json({
|
|
211
|
+
error: 'Failed to create ZIP archive',
|
|
212
|
+
message: err.message,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
res.destroy();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
archive.on('warning', (err) => {
|
|
220
|
+
console.warn('Archive warning:', err.message);
|
|
221
|
+
});
|
|
222
|
+
archive.pipe(res);
|
|
223
|
+
// Add run.json
|
|
224
|
+
archive.file(runJsonPath, { name: 'run.json' });
|
|
225
|
+
// Add results/ directory if it exists
|
|
226
|
+
const resultsDir = join(resolvedReportPath, 'results');
|
|
227
|
+
if (existsSync(resultsDir)) {
|
|
228
|
+
archive.directory(resultsDir, 'results');
|
|
229
|
+
}
|
|
230
|
+
// Add attachments/ directory if it exists
|
|
231
|
+
const attachmentsDir = join(resolvedReportPath, 'attachments');
|
|
232
|
+
if (existsSync(attachmentsDir)) {
|
|
233
|
+
archive.directory(attachmentsDir, 'attachments');
|
|
234
|
+
}
|
|
235
|
+
archive.finalize();
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
console.error('Error creating ZIP archive:', err);
|
|
239
|
+
if (!res.headersSent) {
|
|
240
|
+
res.status(500).json({
|
|
241
|
+
error: 'Failed to create ZIP archive',
|
|
242
|
+
message: err instanceof Error ? err.message : 'Unknown error',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
// ─── Send to Qase API Endpoint ────────────────────────────────────
|
|
248
|
+
// POST /api/send-to-qase - Send test results to Qase TMS
|
|
249
|
+
app.post('/api/send-to-qase', async (req, res) => {
|
|
250
|
+
try {
|
|
251
|
+
// 1. Validate request body
|
|
252
|
+
const { project_code, token, title } = req.body;
|
|
253
|
+
if (!project_code || !token || !title) {
|
|
254
|
+
res.status(400).json({
|
|
255
|
+
error: 'Missing required fields',
|
|
256
|
+
message: 'Request must include project_code, token, and title',
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
// 2. Read results from report directory
|
|
261
|
+
const resultsDir = join(resolvedReportPath, 'results');
|
|
262
|
+
const results = [];
|
|
263
|
+
if (existsSync(resultsDir)) {
|
|
264
|
+
const resultFiles = readdirSync(resultsDir).filter(f => f.endsWith('.json'));
|
|
265
|
+
for (const file of resultFiles) {
|
|
266
|
+
try {
|
|
267
|
+
const filePath = join(resultsDir, file);
|
|
268
|
+
const rawData = JSON.parse(stripBom(readFileSync(filePath, 'utf-8')));
|
|
269
|
+
const parsed = TestResultSchema.safeParse(rawData);
|
|
270
|
+
if (parsed.success) {
|
|
271
|
+
results.push(parsed.data);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
console.warn(`Warning: Skipping invalid result ${file}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
console.warn(`Warning: Failed to parse ${file}:`, err);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (results.length === 0) {
|
|
283
|
+
res.status(400).json({
|
|
284
|
+
error: 'No test results',
|
|
285
|
+
message: 'No valid test results found in the report directory',
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// 3. Collect unique attachments from all results (test-level + step-level)
|
|
290
|
+
const typedResults = results;
|
|
291
|
+
const uniqueAttachments = new Map();
|
|
292
|
+
function collectAttachmentsFromSteps(steps) {
|
|
293
|
+
for (const step of steps) {
|
|
294
|
+
for (const att of step.execution.attachments) {
|
|
295
|
+
if (!uniqueAttachments.has(att.id)) {
|
|
296
|
+
const filePath = join(resolvedReportPath, 'attachments', `${att.id}-${att.file_name}`);
|
|
297
|
+
uniqueAttachments.set(att.id, {
|
|
298
|
+
id: att.id,
|
|
299
|
+
name: att.file_name,
|
|
300
|
+
path: filePath,
|
|
301
|
+
mimeType: att.mime_type ?? 'application/octet-stream',
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (step.steps && step.steps.length > 0) {
|
|
306
|
+
collectAttachmentsFromSteps(step.steps);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (const result of typedResults) {
|
|
311
|
+
for (const att of result.attachments) {
|
|
312
|
+
if (!uniqueAttachments.has(att.id)) {
|
|
313
|
+
const filePath = join(resolvedReportPath, 'attachments', `${att.id}-${att.file_name}`);
|
|
314
|
+
uniqueAttachments.set(att.id, {
|
|
315
|
+
id: att.id,
|
|
316
|
+
name: att.file_name,
|
|
317
|
+
path: filePath,
|
|
318
|
+
mimeType: att.mime_type ?? 'application/octet-stream',
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
collectAttachmentsFromSteps(result.steps);
|
|
323
|
+
}
|
|
324
|
+
// 4. Filter only existing files and upload
|
|
325
|
+
const filesToUpload = [...uniqueAttachments.values()].filter(f => existsSync(f.path));
|
|
326
|
+
let attachmentMap;
|
|
327
|
+
if (filesToUpload.length > 0) {
|
|
328
|
+
attachmentMap = await uploadAttachments({
|
|
329
|
+
apiToken: token,
|
|
330
|
+
projectCode: project_code,
|
|
331
|
+
files: filesToUpload,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// 5. Transform results to Qase API format (with attachment hashes)
|
|
335
|
+
const apiResults = transformResults(typedResults, attachmentMap);
|
|
336
|
+
// 6. Read run start_time from run.json
|
|
337
|
+
const runJsonPath = join(resolvedReportPath, 'run.json');
|
|
338
|
+
const runData = JSON.parse(stripBom(readFileSync(runJsonPath, 'utf-8')));
|
|
339
|
+
const runStartTimeMs = runData.execution?.start_time;
|
|
340
|
+
// 7. Create run with start_time from report data
|
|
341
|
+
const runId = await createQaseRun({
|
|
342
|
+
apiToken: token,
|
|
343
|
+
projectCode: project_code,
|
|
344
|
+
title,
|
|
345
|
+
startTime: runStartTimeMs ? runStartTimeMs / 1000 : undefined,
|
|
346
|
+
});
|
|
347
|
+
// 8. Send results
|
|
348
|
+
await sendQaseResults({
|
|
349
|
+
apiToken: token,
|
|
350
|
+
projectCode: project_code,
|
|
351
|
+
runId,
|
|
352
|
+
results: apiResults,
|
|
353
|
+
});
|
|
354
|
+
// 9. Complete run
|
|
355
|
+
await completeQaseRun({
|
|
356
|
+
apiToken: token,
|
|
357
|
+
projectCode: project_code,
|
|
358
|
+
runId,
|
|
359
|
+
});
|
|
360
|
+
// 10. Return success with run URL
|
|
361
|
+
const runUrl = buildRunUrl(project_code, runId);
|
|
362
|
+
res.json({
|
|
363
|
+
success: true,
|
|
364
|
+
run_id: runId,
|
|
365
|
+
run_url: runUrl,
|
|
366
|
+
results_count: apiResults.length,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
if (err instanceof QaseApiError) {
|
|
371
|
+
// Map QaseApiError to appropriate HTTP status
|
|
372
|
+
const httpStatus = err.statusCode === 0 ? 502 : err.statusCode;
|
|
373
|
+
res.status(httpStatus).json({
|
|
374
|
+
error: 'Qase API error',
|
|
375
|
+
message: err.qaseMessage,
|
|
376
|
+
status_code: err.statusCode,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
console.error('Error in send-to-qase:', err);
|
|
381
|
+
res.status(500).json({
|
|
382
|
+
error: 'Internal server error',
|
|
383
|
+
message: err instanceof Error ? err.message : 'Unknown error',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
// Static file serving for React app (exclude index.html - handled separately)
|
|
389
|
+
app.use(express.static(distPath, {
|
|
390
|
+
index: false, // Don't serve index.html automatically
|
|
391
|
+
}));
|
|
392
|
+
// Serve index.html with server mode flag injection
|
|
393
|
+
// Handles both root path and SPA fallback for client-side routing
|
|
394
|
+
const serveIndex = (req, res) => {
|
|
395
|
+
const indexPath = join(distPath, 'index.html');
|
|
396
|
+
if (existsSync(indexPath)) {
|
|
397
|
+
// Read index.html and inject server mode flag
|
|
398
|
+
let html = readFileSync(indexPath, 'utf-8');
|
|
399
|
+
const serverModeScript = '<script>window.__QASE_SERVER_MODE__=true;</script>';
|
|
400
|
+
// Inject before closing head tag
|
|
401
|
+
html = html.replace('</head>', `${serverModeScript}</head>`);
|
|
402
|
+
res.type('html').send(html);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
res.status(404).json({
|
|
406
|
+
error: 'React app not found',
|
|
407
|
+
message: 'Run `npm run build` first to build the React application',
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
// Root path
|
|
412
|
+
app.get('/', serveIndex);
|
|
413
|
+
// SPA fallback: serve index.html for all non-API routes
|
|
414
|
+
// Express 5 requires named catch-all parameter instead of '*'
|
|
415
|
+
app.get('/{*splat}', serveIndex);
|
|
416
|
+
return app;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Start the Express server on the specified port
|
|
420
|
+
* @returns Promise that resolves to the HTTP server instance when listening
|
|
421
|
+
*/
|
|
422
|
+
export function startServer(app, port) {
|
|
423
|
+
const serverPort = port ?? app.get('port') ?? 3000;
|
|
424
|
+
return new Promise((resolve, reject) => {
|
|
425
|
+
const server = app.listen(serverPort, () => {
|
|
426
|
+
console.log(`Server running at http://localhost:${serverPort}`);
|
|
427
|
+
resolve({ server, port: serverPort });
|
|
428
|
+
});
|
|
429
|
+
server.on('error', (err) => {
|
|
430
|
+
if (err.code === 'EADDRINUSE') {
|
|
431
|
+
console.error(`Error: Port ${serverPort} is already in use`);
|
|
432
|
+
}
|
|
433
|
+
reject(err);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Setup graceful shutdown handlers for the server
|
|
439
|
+
*/
|
|
440
|
+
export function setupGracefulShutdown(server) {
|
|
441
|
+
const shutdown = (signal) => {
|
|
442
|
+
console.log(`\nReceived ${signal}, shutting down gracefully...`);
|
|
443
|
+
server.close((err) => {
|
|
444
|
+
if (err) {
|
|
445
|
+
console.error('Error during shutdown:', err);
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
console.log('Server stopped');
|
|
449
|
+
process.exit(0);
|
|
450
|
+
});
|
|
451
|
+
// Force shutdown after 10 seconds
|
|
452
|
+
setTimeout(() => {
|
|
453
|
+
console.error('Forced shutdown after timeout');
|
|
454
|
+
process.exit(1);
|
|
455
|
+
}, 10000);
|
|
456
|
+
};
|
|
457
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
458
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
459
|
+
}
|