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.
@@ -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
+ }