testlens-playwright-reporter 0.3.2 → 0.3.4

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/lib/index.js DELETED
@@ -1,908 +0,0 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
- var __importDefault = (this && this.__importDefault) || function (mod) {
36
- return (mod && mod.__esModule) ? mod : { "default": mod };
37
- };
38
- Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.TestLensReporter = void 0;
40
- const crypto_1 = require("crypto");
41
- const os = __importStar(require("os"));
42
- const path = __importStar(require("path"));
43
- const fs = __importStar(require("fs"));
44
- const https = __importStar(require("https"));
45
- const axios_1 = __importDefault(require("axios"));
46
- const child_process_1 = require("child_process");
47
- // Lazy-load mime module to support ESM
48
- let mimeModule = null;
49
- async function getMime() {
50
- if (!mimeModule) {
51
- const imported = await Promise.resolve().then(() => __importStar(require('mime')));
52
- // Handle both default export and named exports
53
- mimeModule = imported.default || imported;
54
- }
55
- return mimeModule;
56
- }
57
- class TestLensReporter {
58
- constructor(options) {
59
- this.config = {
60
- apiEndpoint: options.apiEndpoint || 'https://testlens.qa-path.com/api/v1/webhook/playwright',
61
- apiKey: options.apiKey, // API key must come from config file
62
- enableRealTimeStream: options.enableRealTimeStream !== undefined ? options.enableRealTimeStream : true,
63
- enableGitInfo: options.enableGitInfo !== undefined ? options.enableGitInfo : true,
64
- enableArtifacts: options.enableArtifacts !== undefined ? options.enableArtifacts : true,
65
- enableVideo: options.enableVideo !== undefined ? options.enableVideo : true, // Default to true, override from config
66
- enableScreenshot: options.enableScreenshot !== undefined ? options.enableScreenshot : true, // Default to true, override from config
67
- batchSize: options.batchSize || 10,
68
- flushInterval: options.flushInterval || 5000,
69
- retryAttempts: options.retryAttempts !== undefined ? options.retryAttempts : 0,
70
- timeout: options.timeout || 60000
71
- };
72
- if (!this.config.apiKey) {
73
- throw new Error('API_KEY is required for TestLensReporter. Pass it as apiKey option in your playwright config.');
74
- }
75
- // Determine SSL validation behavior
76
- let rejectUnauthorized = true; // Default to secure
77
- // Check various ways SSL validation can be disabled (in order of precedence)
78
- if (this.config.ignoreSslErrors) {
79
- // Explicit configuration option
80
- rejectUnauthorized = false;
81
- console.log('⚠️ SSL certificate validation disabled via ignoreSslErrors option');
82
- }
83
- else if (this.config.rejectUnauthorized === false) {
84
- // Explicit configuration option
85
- rejectUnauthorized = false;
86
- console.log('⚠️ SSL certificate validation disabled via rejectUnauthorized option');
87
- }
88
- else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
89
- // Environment variable override
90
- rejectUnauthorized = false;
91
- console.log('⚠️ SSL certificate validation disabled via NODE_TLS_REJECT_UNAUTHORIZED environment variable');
92
- }
93
- // Set up axios instance with retry logic and enhanced SSL handling
94
- this.axiosInstance = axios_1.default.create({
95
- baseURL: this.config.apiEndpoint,
96
- timeout: this.config.timeout,
97
- headers: {
98
- 'Content-Type': 'application/json',
99
- ...(this.config.apiKey && { 'X-API-Key': this.config.apiKey }),
100
- },
101
- // Enhanced SSL handling with flexible TLS configuration
102
- httpsAgent: new https.Agent({
103
- rejectUnauthorized: rejectUnauthorized,
104
- // Allow any TLS version for better compatibility
105
- minVersion: 'TLSv1.2',
106
- maxVersion: 'TLSv1.3'
107
- })
108
- });
109
- // Add retry interceptor
110
- this.axiosInstance.interceptors.response.use((response) => response, async (error) => {
111
- const originalRequest = error.config;
112
- if (!originalRequest._retry && error.response?.status >= 500) {
113
- originalRequest._retry = true;
114
- originalRequest._retryCount = (originalRequest._retryCount || 0) + 1;
115
- if (originalRequest._retryCount <= this.config.retryAttempts) {
116
- // Exponential backoff
117
- const delay = Math.pow(2, originalRequest._retryCount) * 1000;
118
- await new Promise(resolve => setTimeout(resolve, delay));
119
- return this.axiosInstance(originalRequest);
120
- }
121
- }
122
- return Promise.reject(error);
123
- });
124
- this.runId = (0, crypto_1.randomUUID)();
125
- this.runMetadata = this.initializeRunMetadata();
126
- this.specMap = new Map();
127
- this.testMap = new Map();
128
- }
129
- initializeRunMetadata() {
130
- return {
131
- id: this.runId,
132
- startTime: new Date().toISOString(),
133
- environment: 'production',
134
- browser: 'multiple',
135
- os: `${os.type()} ${os.release()}`,
136
- playwrightVersion: this.getPlaywrightVersion(),
137
- nodeVersion: process.version
138
- };
139
- }
140
- getPlaywrightVersion() {
141
- try {
142
- const playwrightPackage = require('@playwright/test/package.json');
143
- return playwrightPackage.version;
144
- }
145
- catch (error) {
146
- return 'unknown';
147
- }
148
- }
149
- normalizeTestStatus(status) {
150
- // Treat timeout as failed for consistency with analytics
151
- if (status === 'timedOut') {
152
- return 'failed';
153
- }
154
- return status;
155
- }
156
- normalizeRunStatus(status, hasTimeouts) {
157
- // If run has timeouts, treat as failed
158
- if (hasTimeouts && status === 'passed') {
159
- return 'failed';
160
- }
161
- // Treat timeout status as failed
162
- if (status === 'timedOut') {
163
- return 'failed';
164
- }
165
- return status;
166
- }
167
- async onBegin(config, suite) {
168
- console.log(`🚀 TestLens Reporter starting - Run ID: ${this.runId}`);
169
- // Collect Git information if enabled
170
- if (this.config.enableGitInfo) {
171
- this.runMetadata.gitInfo = await this.collectGitInfo();
172
- if (this.runMetadata.gitInfo) {
173
- console.log(`📦 Git info collected: branch=${this.runMetadata.gitInfo.branch}, commit=${this.runMetadata.gitInfo.shortCommit}, author=${this.runMetadata.gitInfo.author}`);
174
- }
175
- else {
176
- console.log(`⚠️ Git info collection returned null - not in a git repository or git not available`);
177
- }
178
- }
179
- else {
180
- console.log(`ℹ️ Git info collection disabled (enableGitInfo: false)`);
181
- }
182
- // Add shard information if available
183
- if (config.shard) {
184
- this.runMetadata.shardInfo = {
185
- current: config.shard.current,
186
- total: config.shard.total
187
- };
188
- }
189
- // Send run start event to API
190
- await this.sendToApi({
191
- type: 'runStart',
192
- runId: this.runId,
193
- timestamp: new Date().toISOString(),
194
- metadata: this.runMetadata
195
- });
196
- }
197
- async onTestBegin(test, result) {
198
- const specPath = test.location.file;
199
- const specKey = `${specPath}-${test.parent.title}`;
200
- // Create or update spec data
201
- if (!this.specMap.has(specKey)) {
202
- const specData = {
203
- filePath: path.relative(process.cwd(), specPath),
204
- testSuiteName: test.parent.title,
205
- tags: this.extractTags(test),
206
- startTime: new Date().toISOString(),
207
- status: 'running'
208
- };
209
- this.specMap.set(specKey, specData);
210
- // Send spec start event to API
211
- await this.sendToApi({
212
- type: 'specStart',
213
- runId: this.runId,
214
- timestamp: new Date().toISOString(),
215
- spec: specData
216
- });
217
- }
218
- const testId = this.getTestId(test);
219
- // Only send testStart event on first attempt (retry 0)
220
- if (result.retry === 0) {
221
- // Create test data
222
- const testData = {
223
- id: testId,
224
- name: test.title,
225
- status: 'running',
226
- originalStatus: 'running',
227
- duration: 0,
228
- startTime: new Date().toISOString(),
229
- endTime: '',
230
- errorMessages: [],
231
- errors: [],
232
- retryAttempts: test.retries,
233
- currentRetry: result.retry,
234
- annotations: test.annotations.map((ann) => ({
235
- type: ann.type,
236
- description: ann.description
237
- })),
238
- projectName: test.parent.project()?.name || 'default',
239
- workerIndex: result.workerIndex,
240
- parallelIndex: result.parallelIndex,
241
- location: {
242
- file: path.relative(process.cwd(), test.location.file),
243
- line: test.location.line,
244
- column: test.location.column
245
- }
246
- };
247
- this.testMap.set(testData.id, testData);
248
- // Send test start event to API
249
- await this.sendToApi({
250
- type: 'testStart',
251
- runId: this.runId,
252
- timestamp: new Date().toISOString(),
253
- test: testData
254
- });
255
- }
256
- else {
257
- // For retries, just update the existing test data
258
- const existingTestData = this.testMap.get(testId);
259
- if (existingTestData) {
260
- existingTestData.currentRetry = result.retry;
261
- }
262
- }
263
- }
264
- async onTestEnd(test, result) {
265
- const testId = this.getTestId(test);
266
- let testData = this.testMap.get(testId);
267
- console.log(`[TestLens] onTestEnd called for test: ${test.title}, status: ${result.status}, testData exists: ${!!testData}`);
268
- // For skipped tests, onTestBegin might not be called, so we need to create the test data here
269
- if (!testData) {
270
- console.log(`[TestLens] Creating test data for skipped/uncreated test: ${test.title}`);
271
- // Create spec data if not exists (skipped tests might not have spec data either)
272
- const specPath = test.location.file;
273
- const specKey = `${specPath}-${test.parent.title}`;
274
- if (!this.specMap.has(specKey)) {
275
- const specData = {
276
- filePath: path.relative(process.cwd(), specPath),
277
- testSuiteName: test.parent.title,
278
- tags: this.extractTags(test),
279
- startTime: new Date().toISOString(),
280
- status: 'skipped'
281
- };
282
- this.specMap.set(specKey, specData);
283
- // Send spec start event to API
284
- await this.sendToApi({
285
- type: 'specStart',
286
- runId: this.runId,
287
- timestamp: new Date().toISOString(),
288
- spec: specData
289
- });
290
- }
291
- // Create test data for skipped test
292
- testData = {
293
- id: testId,
294
- name: test.title,
295
- status: 'skipped',
296
- originalStatus: 'skipped',
297
- duration: 0,
298
- startTime: new Date().toISOString(),
299
- endTime: new Date().toISOString(),
300
- errorMessages: [],
301
- errors: [],
302
- retryAttempts: test.retries,
303
- currentRetry: 0,
304
- annotations: test.annotations.map((ann) => ({
305
- type: ann.type,
306
- description: ann.description
307
- })),
308
- projectName: test.parent.project()?.name || 'default',
309
- workerIndex: result.workerIndex,
310
- parallelIndex: result.parallelIndex,
311
- location: {
312
- file: path.relative(process.cwd(), test.location.file),
313
- line: test.location.line,
314
- column: test.location.column
315
- }
316
- };
317
- this.testMap.set(testId, testData);
318
- // Send test start event first (so the test gets created in DB)
319
- await this.sendToApi({
320
- type: 'testStart',
321
- runId: this.runId,
322
- timestamp: new Date().toISOString(),
323
- test: testData
324
- });
325
- }
326
- if (testData) {
327
- // Update test data with latest result
328
- testData.originalStatus = result.status;
329
- testData.status = this.normalizeTestStatus(result.status);
330
- testData.duration = result.duration;
331
- testData.endTime = new Date().toISOString();
332
- testData.errorMessages = result.errors.map((error) => error.message || error.toString());
333
- testData.currentRetry = result.retry;
334
- // Capture test location
335
- testData.location = {
336
- file: path.relative(process.cwd(), test.location.file),
337
- line: test.location.line,
338
- column: test.location.column
339
- };
340
- // Capture rich error details like Playwright's HTML report
341
- testData.errors = result.errors.map((error) => {
342
- const testError = {
343
- message: error.message || error.toString()
344
- };
345
- // Capture stack trace
346
- if (error.stack) {
347
- testError.stack = error.stack;
348
- }
349
- // Capture error location
350
- if (error.location) {
351
- testError.location = {
352
- file: path.relative(process.cwd(), error.location.file),
353
- line: error.location.line,
354
- column: error.location.column
355
- };
356
- }
357
- // Capture code snippet around error - from Playwright error object
358
- if (error.snippet) {
359
- testError.snippet = error.snippet;
360
- }
361
- // Capture expected/actual values for assertion failures
362
- // Playwright stores these as specially formatted strings in the message
363
- const message = error.message || '';
364
- // Try to parse expected pattern from toHaveURL and similar assertions
365
- const expectedPatternMatch = message.match(/Expected pattern:\s*(.+?)(?:\n|$)/);
366
- if (expectedPatternMatch) {
367
- testError.expected = expectedPatternMatch[1].trim();
368
- }
369
- // Also try "Expected string:" format
370
- if (!testError.expected) {
371
- const expectedStringMatch = message.match(/Expected string:\s*["']?(.+?)["']?(?:\n|$)/);
372
- if (expectedStringMatch) {
373
- testError.expected = expectedStringMatch[1].trim();
374
- }
375
- }
376
- // Try to parse received/actual value
377
- const receivedMatch = message.match(/Received (?:string|value):\s*["']?(.+?)["']?(?:\n|$)/);
378
- if (receivedMatch) {
379
- testError.actual = receivedMatch[1].trim();
380
- }
381
- // Parse call log entries for debugging info (timeouts, retries, etc.)
382
- const callLogMatch = message.match(/Call log:([\s\S]*?)(?=\n\n|\n\s*\d+\s*\||$)/);
383
- if (callLogMatch) {
384
- // Store call log separately for display
385
- const callLog = callLogMatch[1].trim();
386
- if (callLog) {
387
- testError.diff = callLog; // Reuse diff field for call log
388
- }
389
- }
390
- // Parse timeout information - multiple formats
391
- const timeoutMatch = message.match(/(?:with timeout|Timeout:?)\s*(\d+)ms/i);
392
- if (timeoutMatch) {
393
- testError.timeout = parseInt(timeoutMatch[1], 10);
394
- }
395
- // Parse matcher name (e.g., toHaveURL, toBeVisible)
396
- const matcherMatch = message.match(/expect\([^)]+\)\.(\w+)/);
397
- if (matcherMatch) {
398
- testError.matcherName = matcherMatch[1];
399
- }
400
- // Extract code snippet from message if not already captured
401
- // Look for lines like " 9 | await page.click..." or "> 11 | await expect..."
402
- if (!testError.snippet) {
403
- const codeSnippetMatch = message.match(/((?:\s*>?\s*\d+\s*\|.*\n?)+)/);
404
- if (codeSnippetMatch) {
405
- testError.snippet = codeSnippetMatch[1].trim();
406
- }
407
- }
408
- return testError;
409
- });
410
- // Only send testEnd event after final retry attempt
411
- // If test passed or this is the last retry, send the event
412
- const isFinalAttempt = result.status === 'passed' || result.status === 'skipped' || result.retry >= test.retries;
413
- if (isFinalAttempt) {
414
- console.log(`[TestLens] Sending testEnd - testId: ${testData.id}, status: ${testData.status}, originalStatus: ${testData.originalStatus}`);
415
- // Send test end event to API
416
- await this.sendToApi({
417
- type: 'testEnd',
418
- runId: this.runId,
419
- timestamp: new Date().toISOString(),
420
- test: testData
421
- });
422
- // Handle artifacts
423
- if (this.config.enableArtifacts) {
424
- await this.processArtifacts(testId, result);
425
- }
426
- }
427
- }
428
- // Update spec status
429
- const specPath = test.location.file;
430
- const specKey = `${specPath}-${test.parent.title}`;
431
- const specData = this.specMap.get(specKey);
432
- if (specData) {
433
- const normalizedStatus = this.normalizeTestStatus(result.status);
434
- if (normalizedStatus === 'failed' && specData.status !== 'failed') {
435
- specData.status = 'failed';
436
- }
437
- else if (result.status === 'skipped' && specData.status === 'passed') {
438
- specData.status = 'skipped';
439
- }
440
- // Check if all tests in spec are complete
441
- const remainingTests = test.parent.tests.filter((t) => {
442
- const tId = this.getTestId(t);
443
- const tData = this.testMap.get(tId);
444
- return !tData || !tData.endTime;
445
- });
446
- if (remainingTests.length === 0) {
447
- specData.endTime = new Date().toISOString();
448
- // Send spec end event to API
449
- await this.sendToApi({
450
- type: 'specEnd',
451
- runId: this.runId,
452
- timestamp: new Date().toISOString(),
453
- spec: specData
454
- });
455
- // Send spec code blocks to API
456
- await this.sendSpecCodeBlocks(specPath);
457
- }
458
- }
459
- }
460
- async onEnd(result) {
461
- this.runMetadata.endTime = new Date().toISOString();
462
- this.runMetadata.duration = Date.now() - new Date(this.runMetadata.startTime).getTime();
463
- // Calculate final stats
464
- const totalTests = Array.from(this.testMap.values()).length;
465
- const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
466
- // failedTests already includes timedOut tests since normalizeTestStatus converts 'timedOut' to 'failed'
467
- const failedTests = Array.from(this.testMap.values()).filter(t => t.status === 'failed').length;
468
- const skippedTests = Array.from(this.testMap.values()).filter(t => t.status === 'skipped').length;
469
- // Track timedOut separately for reporting purposes only (not for count)
470
- const timedOutTests = Array.from(this.testMap.values()).filter(t => t.originalStatus === 'timedOut').length;
471
- // Normalize run status - if there are timeouts, treat run as failed
472
- const hasTimeouts = timedOutTests > 0;
473
- const normalizedRunStatus = this.normalizeRunStatus(result.status, hasTimeouts);
474
- // Send run end event to API
475
- await this.sendToApi({
476
- type: 'runEnd',
477
- runId: this.runId,
478
- timestamp: new Date().toISOString(),
479
- metadata: {
480
- ...this.runMetadata,
481
- totalTests,
482
- passedTests,
483
- failedTests, // Already includes timedOut tests (normalized to 'failed')
484
- skippedTests,
485
- timedOutTests, // For informational purposes
486
- status: normalizedRunStatus
487
- }
488
- });
489
- console.log(`📊 TestLens Report completed - Run ID: ${this.runId}`);
490
- console.log(`🎯 Results: ${passedTests} passed, ${failedTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
491
- }
492
- async sendToApi(payload) {
493
- try {
494
- console.log(`📤 Sending ${payload.type} event to ${this.config.apiEndpoint}`);
495
- const response = await this.axiosInstance.post('', payload, {
496
- headers: {
497
- 'X-API-Key': this.config.apiKey
498
- }
499
- });
500
- if (this.config.enableRealTimeStream) {
501
- console.log(`✅ Sent ${payload.type} event to TestLens (HTTP ${response.status})`);
502
- }
503
- }
504
- catch (error) {
505
- console.error(`❌ Failed to send ${payload.type} event to TestLens:`, {
506
- message: error?.message || 'Unknown error',
507
- status: error?.response?.status,
508
- statusText: error?.response?.statusText,
509
- data: error?.response?.data,
510
- code: error?.code,
511
- url: error?.config?.url,
512
- method: error?.config?.method
513
- });
514
- // Don't throw error to avoid breaking test execution
515
- }
516
- }
517
- async processArtifacts(testId, result) {
518
- const attachments = result.attachments;
519
- for (const attachment of attachments) {
520
- if (attachment.path) {
521
- // Check if attachment should be processed based on config
522
- const artifactType = this.getArtifactType(attachment.name);
523
- const isVideo = artifactType === 'video' || attachment.contentType?.startsWith('video/');
524
- const isScreenshot = artifactType === 'screenshot' || attachment.contentType?.startsWith('image/');
525
- // Skip video if disabled in config
526
- if (isVideo && !this.config.enableVideo) {
527
- console.log(`⏭️ Skipping video artifact ${attachment.name} - video capture disabled in config`);
528
- continue;
529
- }
530
- // Skip screenshot if disabled in config
531
- if (isScreenshot && !this.config.enableScreenshot) {
532
- console.log(`⏭️ Skipping screenshot artifact ${attachment.name} - screenshot capture disabled in config`);
533
- continue;
534
- }
535
- try {
536
- // Determine proper filename with extension
537
- // Playwright attachment.name often doesn't have extension, so we need to derive it
538
- let fileName = attachment.name;
539
- const existingExt = path.extname(fileName);
540
- if (!existingExt) {
541
- // Get extension from the actual file path
542
- const pathExt = path.extname(attachment.path);
543
- if (pathExt) {
544
- fileName = `${fileName}${pathExt}`;
545
- }
546
- else if (attachment.contentType) {
547
- // Fallback: derive extension from contentType
548
- const mimeToExt = {
549
- 'image/png': '.png',
550
- 'image/jpeg': '.jpg',
551
- 'image/gif': '.gif',
552
- 'image/webp': '.webp',
553
- 'video/webm': '.webm',
554
- 'video/mp4': '.mp4',
555
- 'application/zip': '.zip',
556
- 'application/json': '.json',
557
- 'text/plain': '.txt'
558
- };
559
- const ext = mimeToExt[attachment.contentType];
560
- if (ext) {
561
- fileName = `${fileName}${ext}`;
562
- }
563
- }
564
- }
565
- // Upload to S3 first
566
- const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName);
567
- // Skip if upload failed or file was too large
568
- if (!s3Data) {
569
- console.log(`⏭️ Skipping artifact ${attachment.name} - upload failed or file too large`);
570
- continue;
571
- }
572
- const artifactData = {
573
- testId,
574
- type: this.getArtifactType(attachment.name),
575
- path: attachment.path,
576
- name: fileName,
577
- contentType: attachment.contentType,
578
- fileSize: this.getFileSize(attachment.path),
579
- storageType: 's3',
580
- s3Key: s3Data.key,
581
- s3Url: s3Data.url
582
- };
583
- // Send artifact data to API
584
- await this.sendToApi({
585
- type: 'artifact',
586
- runId: this.runId,
587
- timestamp: new Date().toISOString(),
588
- artifact: artifactData
589
- });
590
- console.log(`📎 Processed artifact: ${fileName} (uploaded to S3)`);
591
- }
592
- catch (error) {
593
- console.error(`❌ Failed to process artifact ${attachment.name}:`, error.message);
594
- }
595
- }
596
- }
597
- }
598
- async sendSpecCodeBlocks(specPath) {
599
- try {
600
- // Extract code blocks using built-in parser
601
- const testBlocks = this.extractTestBlocks(specPath);
602
- // Transform blocks to match backend API expectations
603
- const codeBlocks = testBlocks.map(block => ({
604
- type: block.type, // 'test' or 'describe'
605
- name: block.name, // test/describe name
606
- content: block.content, // full code content
607
- summary: null, // optional
608
- describe: block.describe // parent describe block name
609
- }));
610
- // Send to dedicated spec code blocks API endpoint
611
- const baseUrl = this.config.apiEndpoint.replace('/webhook/playwright', '');
612
- const specEndpoint = `${baseUrl}/webhook/playwright/spec-code-blocks`;
613
- await this.axiosInstance.post(specEndpoint, {
614
- filePath: path.relative(process.cwd(), specPath),
615
- codeBlocks,
616
- testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, '')
617
- });
618
- console.log(`📝 Sent ${codeBlocks.length} code blocks for: ${path.basename(specPath)}`);
619
- }
620
- catch (error) {
621
- console.error('Failed to send spec code blocks:', error?.response?.data || error?.message || 'Unknown error');
622
- }
623
- }
624
- extractTestBlocks(filePath) {
625
- try {
626
- const content = fs.readFileSync(filePath, 'utf-8');
627
- const blocks = [];
628
- const lines = content.split('\n');
629
- let currentDescribe = null;
630
- let braceCount = 0;
631
- let inBlock = false;
632
- let blockStart = -1;
633
- let blockType = 'test';
634
- let blockName = '';
635
- for (let i = 0; i < lines.length; i++) {
636
- const line = lines[i];
637
- const trimmedLine = line.trim();
638
- // Check for describe blocks
639
- const describeMatch = trimmedLine.match(/describe\s*\(\s*['"`]([^'"`]+)['"`]/);
640
- if (describeMatch) {
641
- currentDescribe = describeMatch[1];
642
- }
643
- // Check for test blocks
644
- const testMatch = trimmedLine.match(/test\s*\(\s*['"`]([^'"`]+)['"`]/);
645
- if (testMatch && !inBlock) {
646
- blockType = 'test';
647
- blockName = testMatch[1];
648
- blockStart = i;
649
- braceCount = 0;
650
- inBlock = true;
651
- }
652
- // Count braces when in a block
653
- if (inBlock) {
654
- for (const char of line) {
655
- if (char === '{')
656
- braceCount++;
657
- if (char === '}')
658
- braceCount--;
659
- if (braceCount === 0 && blockStart !== -1 && i > blockStart) {
660
- // End of block found
661
- const blockContent = lines.slice(blockStart, i + 1).join('\n');
662
- blocks.push({
663
- type: blockType,
664
- name: blockName,
665
- content: blockContent,
666
- describe: currentDescribe || undefined,
667
- startLine: blockStart + 1,
668
- endLine: i + 1
669
- });
670
- inBlock = false;
671
- blockStart = -1;
672
- break;
673
- }
674
- }
675
- }
676
- }
677
- return blocks;
678
- }
679
- catch (error) {
680
- console.error(`Failed to extract test blocks from ${filePath}:`, error.message);
681
- return [];
682
- }
683
- }
684
- async collectGitInfo() {
685
- try {
686
- const branch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
687
- const commit = (0, child_process_1.execSync)('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
688
- const shortCommit = commit.substring(0, 7);
689
- const author = (0, child_process_1.execSync)('git log -1 --pretty=format:"%an"', { encoding: 'utf-8' }).trim();
690
- const commitMessage = (0, child_process_1.execSync)('git log -1 --pretty=format:"%s"', { encoding: 'utf-8' }).trim();
691
- const commitTimestamp = (0, child_process_1.execSync)('git log -1 --pretty=format:"%ci"', { encoding: 'utf-8' }).trim();
692
- let remoteName = 'origin';
693
- let remoteUrl = '';
694
- try {
695
- const remotes = (0, child_process_1.execSync)('git remote', { encoding: 'utf-8' }).trim();
696
- if (remotes) {
697
- remoteName = remotes.split('\n')[0] || 'origin';
698
- remoteUrl = (0, child_process_1.execSync)(`git remote get-url ${remoteName}`, { encoding: 'utf-8' }).trim();
699
- }
700
- }
701
- catch (e) {
702
- // Remote info is optional - handle gracefully
703
- console.log('ℹ️ No git remote configured, skipping remote info');
704
- }
705
- const isDirty = (0, child_process_1.execSync)('git status --porcelain', { encoding: 'utf-8' }).trim().length > 0;
706
- return {
707
- branch,
708
- commit,
709
- shortCommit,
710
- author,
711
- message: commitMessage,
712
- timestamp: commitTimestamp,
713
- isDirty,
714
- remoteName,
715
- remoteUrl
716
- };
717
- }
718
- catch (error) {
719
- console.warn('Could not collect Git information:', error.message);
720
- return null;
721
- }
722
- }
723
- getArtifactType(name) {
724
- if (name.includes('screenshot'))
725
- return 'screenshot';
726
- if (name.includes('video'))
727
- return 'video';
728
- if (name.includes('trace'))
729
- return 'trace';
730
- return 'attachment';
731
- }
732
- extractTags(test) {
733
- const tags = [];
734
- test.annotations.forEach((annotation) => {
735
- if (annotation.type === 'tag' && annotation.description) {
736
- tags.push(annotation.description);
737
- }
738
- });
739
- const tagMatches = test.title.match(/@[\w-]+/g);
740
- if (tagMatches) {
741
- tags.push(...tagMatches);
742
- }
743
- return tags;
744
- }
745
- getTestId(test) {
746
- const cleanTitle = test.title.replace(/@[\w-]+/g, '').trim();
747
- // Normalize path separators to forward slashes for cross-platform consistency
748
- const normalizedFile = test.location.file.replace(/\\/g, '/');
749
- return `${normalizedFile}:${test.location.line}:${cleanTitle}`;
750
- }
751
- async uploadArtifactToS3(filePath, testId, fileName) {
752
- try {
753
- // Check file size first
754
- const fileSize = this.getFileSize(filePath);
755
- const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
756
- console.log(`📤 Uploading ${fileName} (${fileSizeMB}MB) directly to S3...`);
757
- const baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
758
- // Step 1: Request pre-signed URL from server
759
- const presignedUrlEndpoint = `${baseUrl}/api/v1/artifacts/public/presigned-url`;
760
- const presignedResponse = await this.axiosInstance.post(presignedUrlEndpoint, {
761
- apiKey: this.config.apiKey,
762
- testRunId: this.runId,
763
- testId: testId,
764
- fileName: fileName,
765
- fileType: await this.getContentType(fileName),
766
- fileSize: fileSize,
767
- artifactType: this.getArtifactType(fileName)
768
- }, {
769
- timeout: 10000 // Quick timeout for metadata request
770
- });
771
- if (!presignedResponse.data.success) {
772
- throw new Error(`Failed to get presigned URL: ${presignedResponse.data.error || 'Unknown error'}`);
773
- }
774
- const { uploadUrl, s3Key, metadata } = presignedResponse.data;
775
- // Step 2: Upload directly to S3 using presigned URL
776
- console.log(`⬆️ Uploading ${fileName} directly to S3 (bypass server)...`);
777
- const fileBuffer = fs.readFileSync(filePath);
778
- // IMPORTANT: When using presigned URLs, we MUST include exactly the headers that were signed
779
- // The backend signs with ServerSideEncryption:'AES256', so we must send that header
780
- // AWS presigned URLs are very strict about header matching
781
- const uploadResponse = await axios_1.default.put(uploadUrl, fileBuffer, {
782
- headers: {
783
- 'x-amz-server-side-encryption': 'AES256'
784
- },
785
- maxContentLength: Infinity,
786
- maxBodyLength: Infinity,
787
- timeout: Math.max(600000, Math.ceil(fileSize / (1024 * 1024)) * 10000), // 10s per MB, min 10 minutes - increased for large trace files
788
- // Don't use custom HTTPS agent for S3 uploads
789
- httpsAgent: undefined
790
- });
791
- if (uploadResponse.status !== 200) {
792
- throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
793
- }
794
- console.log(`✅ S3 direct upload completed for ${fileName}`);
795
- // Step 3: Confirm upload with server to save metadata
796
- const confirmEndpoint = `${baseUrl}/api/v1/artifacts/public/confirm-upload`;
797
- const confirmResponse = await this.axiosInstance.post(confirmEndpoint, {
798
- apiKey: this.config.apiKey,
799
- testRunId: this.runId,
800
- testId: testId,
801
- s3Key: s3Key,
802
- fileName: fileName,
803
- fileType: await this.getContentType(fileName),
804
- fileSize: fileSize,
805
- artifactType: this.getArtifactType(fileName)
806
- }, {
807
- timeout: 10000
808
- });
809
- if (confirmResponse.status === 201 && confirmResponse.data.success) {
810
- const artifact = confirmResponse.data.artifact;
811
- console.log(`✅ Upload confirmed and saved to database`);
812
- return {
813
- key: s3Key,
814
- url: artifact.s3Url,
815
- presignedUrl: artifact.presignedUrl,
816
- fileSize: artifact.fileSize,
817
- contentType: artifact.contentType
818
- };
819
- }
820
- else {
821
- throw new Error(`Upload confirmation failed: ${confirmResponse.data.error || 'Unknown error'}`);
822
- }
823
- }
824
- catch (error) {
825
- // Better error messages for common issues
826
- let errorMsg = error.message;
827
- if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
828
- errorMsg = `Upload timeout - file may be too large or connection is slow`;
829
- }
830
- else if (error.response?.status === 413) {
831
- errorMsg = `File too large (413) - server rejected the upload`;
832
- }
833
- else if (error.response?.status === 400) {
834
- errorMsg = `Bad request (400) - ${error.response.data?.error || 'check file format'}`;
835
- }
836
- else if (error.response?.status === 403) {
837
- errorMsg = `Access denied (403) - presigned URL may have expired`;
838
- }
839
- console.error(`❌ Failed to upload ${fileName} to S3:`, errorMsg);
840
- if (error.response?.data) {
841
- console.error('Error details:', error.response.data);
842
- }
843
- // Don't throw, just return null to continue with other artifacts
844
- return null;
845
- }
846
- }
847
- async getContentType(fileName) {
848
- const ext = path.extname(fileName).toLowerCase();
849
- try {
850
- const mime = await getMime();
851
- // Try different ways to access getType method
852
- const getType = mime.getType || mime.default?.getType;
853
- if (typeof getType === 'function') {
854
- const mimeType = getType.call(mime, ext) || getType.call(mime.default, ext);
855
- return mimeType || 'application/octet-stream';
856
- }
857
- }
858
- catch (error) {
859
- console.warn(`Failed to get MIME type for ${fileName}:`, error.message);
860
- }
861
- // Fallback to basic content type mapping
862
- const contentTypes = {
863
- '.mp4': 'video/mp4',
864
- '.webm': 'video/webm',
865
- '.png': 'image/png',
866
- '.jpg': 'image/jpeg',
867
- '.jpeg': 'image/jpeg',
868
- '.gif': 'image/gif',
869
- '.json': 'application/json',
870
- '.txt': 'text/plain',
871
- '.html': 'text/html',
872
- '.xml': 'application/xml',
873
- '.zip': 'application/zip',
874
- '.pdf': 'application/pdf'
875
- };
876
- return contentTypes[ext] || 'application/octet-stream';
877
- }
878
- generateS3Key(runId, testId, fileName) {
879
- const date = new Date().toISOString().slice(0, 10);
880
- const safeTestId = this.sanitizeForS3(testId);
881
- const safeFileName = this.sanitizeForS3(fileName);
882
- const ext = path.extname(fileName);
883
- const baseName = path.basename(fileName, ext);
884
- return `test-artifacts/${date}/${runId}/${safeTestId}/${safeFileName}${ext}`;
885
- }
886
- sanitizeForS3(value) {
887
- return value
888
- .replace(/[\/:*?"<>|]/g, '-')
889
- .replace(/[-\u001f\u007f]/g, '-')
890
- .replace(/[^-~]/g, '-')
891
- .replace(/\s+/g, '-')
892
- .replace(/[_]/g, '-')
893
- .replace(/-+/g, '-')
894
- .replace(/^-|-$/g, '');
895
- }
896
- getFileSize(filePath) {
897
- try {
898
- const stats = fs.statSync(filePath);
899
- return stats.size;
900
- }
901
- catch (error) {
902
- console.warn(`Could not get file size for ${filePath}:`, error.message);
903
- return 0;
904
- }
905
- }
906
- }
907
- exports.TestLensReporter = TestLensReporter;
908
- exports.default = TestLensReporter;