@alternative-path/testlens-playwright-reporter 0.3.7
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 +21 -0
- package/README.md +163 -0
- package/cross-env.js +24 -0
- package/index.d.ts +192 -0
- package/index.js +1126 -0
- package/index.ts +1407 -0
- package/package.json +82 -0
- package/postinstall.js +39 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1407 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import * as https from 'https';
|
|
6
|
+
import axios, { AxiosInstance } from 'axios';
|
|
7
|
+
import type { Reporter, TestCase, TestResult, FullConfig, Suite } from '@playwright/test/reporter';
|
|
8
|
+
import { execSync } from 'child_process';
|
|
9
|
+
import FormData from 'form-data';
|
|
10
|
+
|
|
11
|
+
// Lazy-load mime module to support ESM
|
|
12
|
+
let mimeModule: any = null;
|
|
13
|
+
async function getMime() {
|
|
14
|
+
if (!mimeModule) {
|
|
15
|
+
const imported = await import('mime');
|
|
16
|
+
// Handle both default export and named exports
|
|
17
|
+
mimeModule = imported.default || imported;
|
|
18
|
+
}
|
|
19
|
+
return mimeModule;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TestLensReporterConfig {
|
|
23
|
+
/** TestLens API endpoint URL */
|
|
24
|
+
apiEndpoint?: string;
|
|
25
|
+
/** API key for authentication - can be provided in config or via TESTLENS_API_KEY environment variable */
|
|
26
|
+
apiKey?: string;
|
|
27
|
+
/** Enable real-time streaming of test events */
|
|
28
|
+
enableRealTimeStream?: boolean;
|
|
29
|
+
/** Enable Git information collection */
|
|
30
|
+
enableGitInfo?: boolean;
|
|
31
|
+
/** Enable artifact processing */
|
|
32
|
+
enableArtifacts?: boolean;
|
|
33
|
+
/** Enable video capture - defaults to true */
|
|
34
|
+
enableVideo?: boolean;
|
|
35
|
+
/** Enable screenshot capture - defaults to true */
|
|
36
|
+
enableScreenshot?: boolean;
|
|
37
|
+
/** Batch size for API requests */
|
|
38
|
+
batchSize?: number;
|
|
39
|
+
/** Flush interval in milliseconds */
|
|
40
|
+
flushInterval?: number;
|
|
41
|
+
/** Number of retry attempts for failed API calls */
|
|
42
|
+
retryAttempts?: number;
|
|
43
|
+
/** Request timeout in milliseconds */
|
|
44
|
+
timeout?: number;
|
|
45
|
+
/** SSL certificate validation - set to false to disable SSL verification */
|
|
46
|
+
rejectUnauthorized?: boolean;
|
|
47
|
+
/** Alternative SSL option - set to true to ignore SSL certificate errors */
|
|
48
|
+
ignoreSslErrors?: boolean;
|
|
49
|
+
/** Custom metadata from CLI arguments (automatically parsed from --key=value arguments) */
|
|
50
|
+
customMetadata?: Record<string, string | string[]>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TestLensReporterOptions {
|
|
54
|
+
/** TestLens API endpoint URL */
|
|
55
|
+
apiEndpoint?: string;
|
|
56
|
+
/** API key for authentication - can be provided in config or via TESTLENS_API_KEY environment variable */
|
|
57
|
+
apiKey?: string;
|
|
58
|
+
/** Enable real-time streaming of test events */
|
|
59
|
+
enableRealTimeStream?: boolean;
|
|
60
|
+
/** Enable Git information collection */
|
|
61
|
+
enableGitInfo?: boolean;
|
|
62
|
+
/** Enable artifact processing */
|
|
63
|
+
enableArtifacts?: boolean;
|
|
64
|
+
/** Enable video capture - defaults to true */
|
|
65
|
+
enableVideo?: boolean;
|
|
66
|
+
/** Enable screenshot capture - defaults to true */
|
|
67
|
+
enableScreenshot?: boolean;
|
|
68
|
+
/** Batch size for API requests */
|
|
69
|
+
batchSize?: number;
|
|
70
|
+
/** Flush interval in milliseconds */
|
|
71
|
+
flushInterval?: number;
|
|
72
|
+
/** Number of retry attempts for failed API calls */
|
|
73
|
+
retryAttempts?: number;
|
|
74
|
+
/** Request timeout in milliseconds */
|
|
75
|
+
timeout?: number;
|
|
76
|
+
/** SSL certificate validation - set to false to disable SSL verification */
|
|
77
|
+
rejectUnauthorized?: boolean;
|
|
78
|
+
/** Alternative SSL option - set to true to ignore SSL certificate errors */
|
|
79
|
+
ignoreSslErrors?: boolean;
|
|
80
|
+
/** Custom metadata from CLI arguments (automatically parsed from --key=value arguments) */
|
|
81
|
+
customMetadata?: Record<string, string | string[]>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface GitInfo {
|
|
85
|
+
branch: string;
|
|
86
|
+
commit: string;
|
|
87
|
+
shortCommit: string;
|
|
88
|
+
author: string;
|
|
89
|
+
message: string;
|
|
90
|
+
timestamp: string;
|
|
91
|
+
isDirty: boolean;
|
|
92
|
+
remoteName: string;
|
|
93
|
+
remoteUrl: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CodeBlock {
|
|
97
|
+
type: 'test' | 'describe';
|
|
98
|
+
name: string;
|
|
99
|
+
content: string;
|
|
100
|
+
summary?: string;
|
|
101
|
+
describe?: string;
|
|
102
|
+
startLine?: number;
|
|
103
|
+
endLine?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface RunMetadata {
|
|
107
|
+
id: string;
|
|
108
|
+
startTime: string;
|
|
109
|
+
endTime?: string;
|
|
110
|
+
duration?: number;
|
|
111
|
+
environment: string;
|
|
112
|
+
browser: string;
|
|
113
|
+
os: string;
|
|
114
|
+
playwrightVersion: string;
|
|
115
|
+
nodeVersion: string;
|
|
116
|
+
gitInfo?: GitInfo | null;
|
|
117
|
+
shardInfo?: {
|
|
118
|
+
current: number;
|
|
119
|
+
total: number;
|
|
120
|
+
};
|
|
121
|
+
totalTests?: number;
|
|
122
|
+
passedTests?: number;
|
|
123
|
+
failedTests?: number;
|
|
124
|
+
skippedTests?: number;
|
|
125
|
+
status?: string;
|
|
126
|
+
testlensBuildName?: string;
|
|
127
|
+
customMetadata?: Record<string, string | string[]>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface TestError {
|
|
131
|
+
message: string;
|
|
132
|
+
stack?: string;
|
|
133
|
+
location?: {
|
|
134
|
+
file: string;
|
|
135
|
+
line: number;
|
|
136
|
+
column: number;
|
|
137
|
+
};
|
|
138
|
+
snippet?: string;
|
|
139
|
+
expected?: string;
|
|
140
|
+
actual?: string;
|
|
141
|
+
diff?: string;
|
|
142
|
+
matcherName?: string;
|
|
143
|
+
timeout?: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface TestData {
|
|
147
|
+
id: string;
|
|
148
|
+
name: string;
|
|
149
|
+
status: string;
|
|
150
|
+
originalStatus?: string; // Preserve original Playwright status
|
|
151
|
+
duration: number;
|
|
152
|
+
startTime: string;
|
|
153
|
+
endTime: string;
|
|
154
|
+
errorMessages: string[];
|
|
155
|
+
errors?: TestError[]; // Rich error details
|
|
156
|
+
retryAttempts: number;
|
|
157
|
+
currentRetry: number;
|
|
158
|
+
annotations: Array<{ type: string; description?: string }>;
|
|
159
|
+
projectName: string;
|
|
160
|
+
workerIndex?: number;
|
|
161
|
+
parallelIndex?: number;
|
|
162
|
+
location?: {
|
|
163
|
+
file: string;
|
|
164
|
+
line: number;
|
|
165
|
+
column: number;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface SpecData {
|
|
170
|
+
filePath: string;
|
|
171
|
+
testSuiteName: string;
|
|
172
|
+
tags?: string[];
|
|
173
|
+
startTime: string;
|
|
174
|
+
endTime?: string;
|
|
175
|
+
status: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export class TestLensReporter implements Reporter {
|
|
179
|
+
private config: Required<TestLensReporterConfig>;
|
|
180
|
+
private axiosInstance: AxiosInstance;
|
|
181
|
+
private runId: string;
|
|
182
|
+
private runMetadata: RunMetadata;
|
|
183
|
+
private specMap: Map<string, SpecData>;
|
|
184
|
+
private testMap: Map<string, TestData>;
|
|
185
|
+
private runCreationFailed: boolean = false; // Track if run creation failed due to limits
|
|
186
|
+
private cliArgs: Record<string, any> = {}; // Store CLI args separately
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Parse custom metadata from environment variables
|
|
190
|
+
* Checks for common metadata environment variables
|
|
191
|
+
*/
|
|
192
|
+
private static parseCustomArgs(): Record<string, any> {
|
|
193
|
+
const customArgs: Record<string, any> = {};
|
|
194
|
+
|
|
195
|
+
// Common environment variable names for build metadata
|
|
196
|
+
const envVarMappings: Record<string, string[]> = {
|
|
197
|
+
// Support both TestLens-specific names (recommended) and common CI names
|
|
198
|
+
'testlensBuildTag': ['testlensBuildTag', 'TESTLENS_BUILD_TAG', 'TESTLENS_BUILDTAG', 'BUILDTAG', 'BUILD_TAG', 'TestlensBuildTag', 'TestLensBuildTag'],
|
|
199
|
+
'testlensBuildName': ['testlensBuildName', 'TESTLENS_BUILD_NAME', 'TESTLENS_BUILDNAME', 'BUILDNAME', 'BUILD_NAME', 'TestlensBuildName', 'TestLensBuildName'],
|
|
200
|
+
'environment': ['ENVIRONMENT', 'ENV', 'NODE_ENV', 'DEPLOYMENT_ENV'],
|
|
201
|
+
'branch': ['BRANCH', 'GIT_BRANCH', 'CI_COMMIT_BRANCH', 'GITHUB_REF_NAME'],
|
|
202
|
+
'team': ['TEAM', 'TEAM_NAME'],
|
|
203
|
+
'project': ['PROJECT', 'PROJECT_NAME'],
|
|
204
|
+
'customvalue': ['CUSTOMVALUE', 'CUSTOM_VALUE']
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Check for each metadata key
|
|
208
|
+
Object.entries(envVarMappings).forEach(([key, envVars]) => {
|
|
209
|
+
for (const envVar of envVars) {
|
|
210
|
+
const value = process.env[envVar];
|
|
211
|
+
if (value) {
|
|
212
|
+
// For testlensBuildTag, support comma-separated values
|
|
213
|
+
if (key === 'testlensBuildTag' && value.includes(',')) {
|
|
214
|
+
customArgs[key] = value.split(',').map(tag => tag.trim()).filter(tag => tag);
|
|
215
|
+
console.log(`✓ Found ${envVar}=${value} (mapped to '${key}' as array of ${customArgs[key].length} tags)`);
|
|
216
|
+
} else {
|
|
217
|
+
customArgs[key] = value;
|
|
218
|
+
console.log(`✓ Found ${envVar}=${value} (mapped to '${key}')`);
|
|
219
|
+
}
|
|
220
|
+
break; // Use first match
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return customArgs;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
constructor(options: TestLensReporterOptions) {
|
|
229
|
+
// Parse custom CLI arguments
|
|
230
|
+
const customArgs = TestLensReporter.parseCustomArgs();
|
|
231
|
+
this.cliArgs = customArgs; // Store CLI args separately for later use
|
|
232
|
+
|
|
233
|
+
// Allow API key from environment variable if not provided in config
|
|
234
|
+
// Check multiple environment variable names in priority order (uppercase and lowercase)
|
|
235
|
+
const apiKey = options.apiKey
|
|
236
|
+
|| process.env.TESTLENS_API_KEY
|
|
237
|
+
|| process.env.testlens_api_key
|
|
238
|
+
|| process.env.TESTLENS_KEY
|
|
239
|
+
|| process.env.testlens_key
|
|
240
|
+
|| process.env.testlensApiKey
|
|
241
|
+
|| process.env.PLAYWRIGHT_API_KEY
|
|
242
|
+
|| process.env.playwright_api_key
|
|
243
|
+
|| process.env.PW_API_KEY
|
|
244
|
+
|| process.env.pw_api_key;
|
|
245
|
+
|
|
246
|
+
this.config = {
|
|
247
|
+
apiEndpoint: options.apiEndpoint || 'https://testlens.qa-path.com/api/v1/webhook/playwright',
|
|
248
|
+
apiKey: apiKey, // API key from config or environment variable
|
|
249
|
+
enableRealTimeStream: options.enableRealTimeStream !== undefined ? options.enableRealTimeStream : true,
|
|
250
|
+
enableGitInfo: options.enableGitInfo !== undefined ? options.enableGitInfo : true,
|
|
251
|
+
enableArtifacts: options.enableArtifacts !== undefined ? options.enableArtifacts : true,
|
|
252
|
+
enableVideo: options.enableVideo !== undefined ? options.enableVideo : true, // Default to true, override from config
|
|
253
|
+
enableScreenshot: options.enableScreenshot !== undefined ? options.enableScreenshot : true, // Default to true, override from config
|
|
254
|
+
batchSize: options.batchSize || 10,
|
|
255
|
+
flushInterval: options.flushInterval || 5000,
|
|
256
|
+
retryAttempts: options.retryAttempts !== undefined ? options.retryAttempts : 0,
|
|
257
|
+
timeout: options.timeout || 60000,
|
|
258
|
+
customMetadata: { ...options.customMetadata, ...customArgs } // Config metadata first, then CLI args override
|
|
259
|
+
} as Required<TestLensReporterConfig>;
|
|
260
|
+
|
|
261
|
+
if (!this.config.apiKey) {
|
|
262
|
+
throw new Error('API_KEY is required for TestLensReporter. Pass it as apiKey option in your playwright config or set one of these environment variables: TESTLENS_API_KEY, TESTLENS_KEY, PLAYWRIGHT_API_KEY, PW_API_KEY, API_KEY, or APIKEY.');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (apiKey !== options.apiKey) {
|
|
266
|
+
console.log('✓ Using API key from environment variable');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Determine SSL validation behavior
|
|
270
|
+
let rejectUnauthorized = true; // Default to secure
|
|
271
|
+
|
|
272
|
+
// Check various ways SSL validation can be disabled (in order of precedence)
|
|
273
|
+
if (this.config.ignoreSslErrors) {
|
|
274
|
+
// Explicit configuration option
|
|
275
|
+
rejectUnauthorized = false;
|
|
276
|
+
console.log('⚠️ SSL certificate validation disabled via ignoreSslErrors option');
|
|
277
|
+
} else if (this.config.rejectUnauthorized === false) {
|
|
278
|
+
// Explicit configuration option
|
|
279
|
+
rejectUnauthorized = false;
|
|
280
|
+
console.log('⚠️ SSL certificate validation disabled via rejectUnauthorized option');
|
|
281
|
+
} else if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
|
|
282
|
+
// Environment variable override
|
|
283
|
+
rejectUnauthorized = false;
|
|
284
|
+
console.log('⚠️ SSL certificate validation disabled via NODE_TLS_REJECT_UNAUTHORIZED environment variable');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Set up axios instance with retry logic and enhanced SSL handling
|
|
288
|
+
this.axiosInstance = axios.create({
|
|
289
|
+
baseURL: this.config.apiEndpoint,
|
|
290
|
+
timeout: this.config.timeout,
|
|
291
|
+
headers: {
|
|
292
|
+
'Content-Type': 'application/json',
|
|
293
|
+
...(this.config.apiKey && { 'X-API-Key': this.config.apiKey }),
|
|
294
|
+
},
|
|
295
|
+
// Enhanced SSL handling with flexible TLS configuration
|
|
296
|
+
httpsAgent: new https.Agent({
|
|
297
|
+
rejectUnauthorized: rejectUnauthorized,
|
|
298
|
+
// Allow any TLS version for better compatibility
|
|
299
|
+
minVersion: 'TLSv1.2',
|
|
300
|
+
maxVersion: 'TLSv1.3'
|
|
301
|
+
})
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Add retry interceptor
|
|
305
|
+
this.axiosInstance.interceptors.response.use(
|
|
306
|
+
(response) => response,
|
|
307
|
+
async (error: any) => {
|
|
308
|
+
const originalRequest = error.config;
|
|
309
|
+
|
|
310
|
+
if (!originalRequest._retry && error.response?.status >= 500) {
|
|
311
|
+
originalRequest._retry = true;
|
|
312
|
+
originalRequest._retryCount = (originalRequest._retryCount || 0) + 1;
|
|
313
|
+
|
|
314
|
+
if (originalRequest._retryCount <= this.config.retryAttempts) {
|
|
315
|
+
// Exponential backoff
|
|
316
|
+
const delay = Math.pow(2, originalRequest._retryCount) * 1000;
|
|
317
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
318
|
+
return this.axiosInstance(originalRequest);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return Promise.reject(error);
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
this.runId = randomUUID();
|
|
327
|
+
this.runMetadata = this.initializeRunMetadata();
|
|
328
|
+
this.specMap = new Map<string, SpecData>();
|
|
329
|
+
this.testMap = new Map<string, TestData>();
|
|
330
|
+
this.runCreationFailed = false;
|
|
331
|
+
|
|
332
|
+
// Log custom metadata if any
|
|
333
|
+
if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
|
|
334
|
+
console.log('\n📋 Custom Metadata Detected:');
|
|
335
|
+
Object.entries(this.config.customMetadata).forEach(([key, value]) => {
|
|
336
|
+
console.log(` ${key}: ${value}`);
|
|
337
|
+
});
|
|
338
|
+
console.log('');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private initializeRunMetadata(): RunMetadata {
|
|
343
|
+
const metadata: RunMetadata = {
|
|
344
|
+
id: this.runId,
|
|
345
|
+
startTime: new Date().toISOString(),
|
|
346
|
+
environment: 'production',
|
|
347
|
+
browser: 'multiple',
|
|
348
|
+
os: `${os.type()} ${os.release()}`,
|
|
349
|
+
playwrightVersion: this.getPlaywrightVersion(),
|
|
350
|
+
nodeVersion: process.version
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// Add custom metadata if provided
|
|
354
|
+
if (this.config.customMetadata && Object.keys(this.config.customMetadata).length > 0) {
|
|
355
|
+
metadata.customMetadata = this.config.customMetadata;
|
|
356
|
+
|
|
357
|
+
// Extract testlensBuildName as a dedicated field for dashboard display
|
|
358
|
+
if (this.config.customMetadata.testlensBuildName) {
|
|
359
|
+
const buildName = this.config.customMetadata.testlensBuildName;
|
|
360
|
+
// Handle both string and array (take first element if array)
|
|
361
|
+
metadata.testlensBuildName = Array.isArray(buildName) ? buildName[0] : buildName;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return metadata;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private getPlaywrightVersion(): string {
|
|
369
|
+
try {
|
|
370
|
+
const playwrightPackage = require('@playwright/test/package.json');
|
|
371
|
+
return playwrightPackage.version;
|
|
372
|
+
} catch (error) {
|
|
373
|
+
return 'unknown';
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private normalizeTestStatus(status: string): string {
|
|
378
|
+
// Treat timeout as failed for consistency with analytics
|
|
379
|
+
if (status === 'timedOut') {
|
|
380
|
+
return 'failed';
|
|
381
|
+
}
|
|
382
|
+
return status;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private normalizeRunStatus(status: string, hasTimeouts: boolean): string {
|
|
386
|
+
// If run has timeouts, treat as failed
|
|
387
|
+
if (hasTimeouts && status === 'passed') {
|
|
388
|
+
return 'failed';
|
|
389
|
+
}
|
|
390
|
+
// Treat timeout status as failed
|
|
391
|
+
if (status === 'timedOut') {
|
|
392
|
+
return 'failed';
|
|
393
|
+
}
|
|
394
|
+
return status;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async onBegin(config: FullConfig, suite: Suite): Promise<void> {
|
|
398
|
+
// Show Build Name if provided, otherwise show Run ID
|
|
399
|
+
if (this.runMetadata.testlensBuildName) {
|
|
400
|
+
console.log(`🚀 TestLens Reporter starting - Build: ${this.runMetadata.testlensBuildName}`);
|
|
401
|
+
console.log(` Run ID: ${this.runId}`);
|
|
402
|
+
} else {
|
|
403
|
+
console.log(`🚀 TestLens Reporter starting - Run ID: ${this.runId}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Collect Git information if enabled
|
|
407
|
+
if (this.config.enableGitInfo) {
|
|
408
|
+
this.runMetadata.gitInfo = await this.collectGitInfo();
|
|
409
|
+
if (this.runMetadata.gitInfo) {
|
|
410
|
+
console.log(`📦 Git info collected: branch=${this.runMetadata.gitInfo.branch}, commit=${this.runMetadata.gitInfo.shortCommit}, author=${this.runMetadata.gitInfo.author}`);
|
|
411
|
+
} else {
|
|
412
|
+
console.log(`⚠️ Git info collection returned null - not in a git repository or git not available`);
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
console.log(`ℹ️ Git info collection disabled (enableGitInfo: false)`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Add shard information if available
|
|
419
|
+
if (config.shard) {
|
|
420
|
+
this.runMetadata.shardInfo = {
|
|
421
|
+
current: config.shard.current,
|
|
422
|
+
total: config.shard.total
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Send run start event to API
|
|
427
|
+
await this.sendToApi({
|
|
428
|
+
type: 'runStart',
|
|
429
|
+
runId: this.runId,
|
|
430
|
+
timestamp: new Date().toISOString(),
|
|
431
|
+
metadata: this.runMetadata
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async onTestBegin(test: TestCase, result: TestResult): Promise<void> {
|
|
436
|
+
// Log which test is starting
|
|
437
|
+
console.log(`\n▶️ Running test: ${test.title}`);
|
|
438
|
+
|
|
439
|
+
const specPath = test.location.file;
|
|
440
|
+
const specKey = `${specPath}-${test.parent.title}`;
|
|
441
|
+
|
|
442
|
+
// Create or update spec data
|
|
443
|
+
if (!this.specMap.has(specKey)) {
|
|
444
|
+
const extractedTags = this.extractTags(test);
|
|
445
|
+
const specData: SpecData = {
|
|
446
|
+
filePath: path.relative(process.cwd(), specPath),
|
|
447
|
+
testSuiteName: test.parent.title,
|
|
448
|
+
startTime: new Date().toISOString(),
|
|
449
|
+
status: 'running'
|
|
450
|
+
};
|
|
451
|
+
if (extractedTags.length > 0) {
|
|
452
|
+
specData.tags = extractedTags;
|
|
453
|
+
}
|
|
454
|
+
this.specMap.set(specKey, specData);
|
|
455
|
+
|
|
456
|
+
// Send spec start event to API
|
|
457
|
+
await this.sendToApi({
|
|
458
|
+
type: 'specStart',
|
|
459
|
+
runId: this.runId,
|
|
460
|
+
timestamp: new Date().toISOString(),
|
|
461
|
+
spec: specData
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const testId = this.getTestId(test);
|
|
466
|
+
|
|
467
|
+
// Only send testStart event on first attempt (retry 0)
|
|
468
|
+
if (result.retry === 0) {
|
|
469
|
+
// Create test data
|
|
470
|
+
const testData: TestData = {
|
|
471
|
+
id: testId,
|
|
472
|
+
name: test.title,
|
|
473
|
+
status: 'running',
|
|
474
|
+
originalStatus: 'running',
|
|
475
|
+
duration: 0,
|
|
476
|
+
startTime: new Date().toISOString(),
|
|
477
|
+
endTime: '',
|
|
478
|
+
errorMessages: [],
|
|
479
|
+
errors: [],
|
|
480
|
+
retryAttempts: test.retries,
|
|
481
|
+
currentRetry: result.retry,
|
|
482
|
+
annotations: test.annotations.map((ann: any) => ({
|
|
483
|
+
type: ann.type,
|
|
484
|
+
description: ann.description
|
|
485
|
+
})),
|
|
486
|
+
projectName: test.parent.project()?.name || 'default',
|
|
487
|
+
workerIndex: result.workerIndex,
|
|
488
|
+
parallelIndex: result.parallelIndex,
|
|
489
|
+
location: {
|
|
490
|
+
file: path.relative(process.cwd(), test.location.file),
|
|
491
|
+
line: test.location.line,
|
|
492
|
+
column: test.location.column
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
this.testMap.set(testData.id, testData);
|
|
497
|
+
|
|
498
|
+
// Send test start event to API
|
|
499
|
+
await this.sendToApi({
|
|
500
|
+
type: 'testStart',
|
|
501
|
+
runId: this.runId,
|
|
502
|
+
timestamp: new Date().toISOString(),
|
|
503
|
+
test: testData
|
|
504
|
+
});
|
|
505
|
+
} else {
|
|
506
|
+
// For retries, just update the existing test data
|
|
507
|
+
const existingTestData = this.testMap.get(testId);
|
|
508
|
+
if (existingTestData) {
|
|
509
|
+
existingTestData.currentRetry = result.retry;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async onTestEnd(test: TestCase, result: TestResult): Promise<void> {
|
|
515
|
+
const testId = this.getTestId(test);
|
|
516
|
+
let testData = this.testMap.get(testId);
|
|
517
|
+
|
|
518
|
+
console.log(`[TestLens] onTestEnd called for test: ${test.title}, status: ${result.status}, testData exists: ${!!testData}`);
|
|
519
|
+
|
|
520
|
+
// For skipped tests, onTestBegin might not be called, so we need to create the test data here
|
|
521
|
+
if (!testData) {
|
|
522
|
+
console.log(`[TestLens] Creating test data for skipped/uncreated test: ${test.title}`);
|
|
523
|
+
// Create spec data if not exists (skipped tests might not have spec data either)
|
|
524
|
+
const specPath = test.location.file;
|
|
525
|
+
const specKey = `${specPath}-${test.parent.title}`;
|
|
526
|
+
|
|
527
|
+
if (!this.specMap.has(specKey)) {
|
|
528
|
+
const extractedTags = this.extractTags(test);
|
|
529
|
+
const specData: SpecData = {
|
|
530
|
+
filePath: path.relative(process.cwd(), specPath),
|
|
531
|
+
testSuiteName: test.parent.title,
|
|
532
|
+
startTime: new Date().toISOString(),
|
|
533
|
+
status: 'skipped'
|
|
534
|
+
};
|
|
535
|
+
if (extractedTags.length > 0) {
|
|
536
|
+
specData.tags = extractedTags;
|
|
537
|
+
}
|
|
538
|
+
this.specMap.set(specKey, specData);
|
|
539
|
+
|
|
540
|
+
// Send spec start event to API
|
|
541
|
+
await this.sendToApi({
|
|
542
|
+
type: 'specStart',
|
|
543
|
+
runId: this.runId,
|
|
544
|
+
timestamp: new Date().toISOString(),
|
|
545
|
+
spec: specData
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Create test data for skipped test
|
|
550
|
+
testData = {
|
|
551
|
+
id: testId,
|
|
552
|
+
name: test.title,
|
|
553
|
+
status: 'skipped',
|
|
554
|
+
originalStatus: 'skipped',
|
|
555
|
+
duration: 0,
|
|
556
|
+
startTime: new Date().toISOString(),
|
|
557
|
+
endTime: new Date().toISOString(),
|
|
558
|
+
errorMessages: [],
|
|
559
|
+
errors: [],
|
|
560
|
+
retryAttempts: test.retries,
|
|
561
|
+
currentRetry: 0,
|
|
562
|
+
annotations: test.annotations.map((ann: any) => ({
|
|
563
|
+
type: ann.type,
|
|
564
|
+
description: ann.description
|
|
565
|
+
})),
|
|
566
|
+
projectName: test.parent.project()?.name || 'default',
|
|
567
|
+
workerIndex: result.workerIndex,
|
|
568
|
+
parallelIndex: result.parallelIndex,
|
|
569
|
+
location: {
|
|
570
|
+
file: path.relative(process.cwd(), test.location.file),
|
|
571
|
+
line: test.location.line,
|
|
572
|
+
column: test.location.column
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
this.testMap.set(testId, testData);
|
|
577
|
+
|
|
578
|
+
// Send test start event first (so the test gets created in DB)
|
|
579
|
+
await this.sendToApi({
|
|
580
|
+
type: 'testStart',
|
|
581
|
+
runId: this.runId,
|
|
582
|
+
timestamp: new Date().toISOString(),
|
|
583
|
+
test: testData
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (testData) {
|
|
588
|
+
// Update test data with latest result
|
|
589
|
+
testData.originalStatus = result.status;
|
|
590
|
+
testData.status = this.normalizeTestStatus(result.status);
|
|
591
|
+
testData.duration = result.duration;
|
|
592
|
+
testData.endTime = new Date().toISOString();
|
|
593
|
+
testData.errorMessages = result.errors.map((error: any) => error.message || error.toString());
|
|
594
|
+
testData.currentRetry = result.retry;
|
|
595
|
+
|
|
596
|
+
// Capture test location
|
|
597
|
+
testData.location = {
|
|
598
|
+
file: path.relative(process.cwd(), test.location.file),
|
|
599
|
+
line: test.location.line,
|
|
600
|
+
column: test.location.column
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// Capture rich error details like Playwright's HTML report
|
|
604
|
+
testData.errors = result.errors.map((error: any) => {
|
|
605
|
+
const testError: TestError = {
|
|
606
|
+
message: error.message || error.toString()
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
// Capture stack trace
|
|
610
|
+
if (error.stack) {
|
|
611
|
+
testError.stack = error.stack;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Capture error location
|
|
615
|
+
if (error.location) {
|
|
616
|
+
testError.location = {
|
|
617
|
+
file: path.relative(process.cwd(), error.location.file),
|
|
618
|
+
line: error.location.line,
|
|
619
|
+
column: error.location.column
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Capture code snippet around error - from Playwright error object
|
|
624
|
+
if (error.snippet) {
|
|
625
|
+
testError.snippet = error.snippet;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Capture expected/actual values for assertion failures
|
|
629
|
+
// Playwright stores these as specially formatted strings in the message
|
|
630
|
+
const message = error.message || '';
|
|
631
|
+
|
|
632
|
+
// Try to parse expected pattern from toHaveURL and similar assertions
|
|
633
|
+
const expectedPatternMatch = message.match(/Expected pattern:\s*(.+?)(?:\n|$)/);
|
|
634
|
+
if (expectedPatternMatch) {
|
|
635
|
+
testError.expected = expectedPatternMatch[1].trim();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Also try "Expected string:" format
|
|
639
|
+
if (!testError.expected) {
|
|
640
|
+
const expectedStringMatch = message.match(/Expected string:\s*["']?(.+?)["']?(?:\n|$)/);
|
|
641
|
+
if (expectedStringMatch) {
|
|
642
|
+
testError.expected = expectedStringMatch[1].trim();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Try to parse received/actual value
|
|
647
|
+
const receivedMatch = message.match(/Received (?:string|value):\s*["']?(.+?)["']?(?:\n|$)/);
|
|
648
|
+
if (receivedMatch) {
|
|
649
|
+
testError.actual = receivedMatch[1].trim();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Parse call log entries for debugging info (timeouts, retries, etc.)
|
|
653
|
+
const callLogMatch = message.match(/Call log:([\s\S]*?)(?=\n\n|\n\s*\d+\s*\||$)/);
|
|
654
|
+
if (callLogMatch) {
|
|
655
|
+
// Store call log separately for display
|
|
656
|
+
const callLog = callLogMatch[1].trim();
|
|
657
|
+
if (callLog) {
|
|
658
|
+
testError.diff = callLog; // Reuse diff field for call log
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Parse timeout information - multiple formats
|
|
663
|
+
const timeoutMatch = message.match(/(?:with timeout|Timeout:?)\s*(\d+)ms/i);
|
|
664
|
+
if (timeoutMatch) {
|
|
665
|
+
testError.timeout = parseInt(timeoutMatch[1], 10);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Parse matcher name (e.g., toHaveURL, toBeVisible)
|
|
669
|
+
const matcherMatch = message.match(/expect\([^)]+\)\.(\w+)/);
|
|
670
|
+
if (matcherMatch) {
|
|
671
|
+
testError.matcherName = matcherMatch[1];
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Extract code snippet from message if not already captured
|
|
675
|
+
// Look for lines like " 9 | await page.click..." or "> 11 | await expect..."
|
|
676
|
+
if (!testError.snippet) {
|
|
677
|
+
const codeSnippetMatch = message.match(/((?:\s*>?\s*\d+\s*\|.*\n?)+)/);
|
|
678
|
+
if (codeSnippetMatch) {
|
|
679
|
+
testError.snippet = codeSnippetMatch[1].trim();
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return testError;
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// Only send testEnd event after final retry attempt
|
|
687
|
+
// If test passed or this is the last retry, send the event
|
|
688
|
+
const isFinalAttempt = result.status === 'passed' || result.status === 'skipped' || result.retry >= test.retries;
|
|
689
|
+
|
|
690
|
+
if (isFinalAttempt) {
|
|
691
|
+
console.log(`[TestLens] Sending testEnd - testId: ${testData.id}, status: ${testData.status}, originalStatus: ${testData.originalStatus}`);
|
|
692
|
+
// Send test end event to API and get response
|
|
693
|
+
const testEndResponse = await this.sendToApi({
|
|
694
|
+
type: 'testEnd',
|
|
695
|
+
runId: this.runId,
|
|
696
|
+
timestamp: new Date().toISOString(),
|
|
697
|
+
test: testData
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
// Handle artifacts (test case is now guaranteed to be in database)
|
|
701
|
+
if (this.config.enableArtifacts) {
|
|
702
|
+
// Pass test case DB ID if available for faster lookups
|
|
703
|
+
await this.processArtifacts(testId, result, testEndResponse?.testCaseId);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Update spec status
|
|
709
|
+
const specPath = test.location.file;
|
|
710
|
+
const specKey = `${specPath}-${test.parent.title}`;
|
|
711
|
+
const specData = this.specMap.get(specKey);
|
|
712
|
+
if (specData) {
|
|
713
|
+
const normalizedStatus = this.normalizeTestStatus(result.status);
|
|
714
|
+
if (normalizedStatus === 'failed' && specData.status !== 'failed') {
|
|
715
|
+
specData.status = 'failed';
|
|
716
|
+
} else if (result.status === 'skipped' && specData.status === 'passed') {
|
|
717
|
+
specData.status = 'skipped';
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Check if all tests in spec are complete
|
|
721
|
+
const remainingTests = test.parent.tests.filter((t: any) => {
|
|
722
|
+
const tId = this.getTestId(t);
|
|
723
|
+
const tData = this.testMap.get(tId);
|
|
724
|
+
return !tData || !tData.endTime;
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
if (remainingTests.length === 0) {
|
|
728
|
+
// Aggregate tags from all tests in this spec
|
|
729
|
+
const allTags = new Set<string>();
|
|
730
|
+
test.parent.tests.forEach((t: any) => {
|
|
731
|
+
const tags = this.extractTags(t);
|
|
732
|
+
tags.forEach(tag => allTags.add(tag));
|
|
733
|
+
});
|
|
734
|
+
const aggregatedTags = Array.from(allTags);
|
|
735
|
+
// Only update tags if we have any
|
|
736
|
+
if (aggregatedTags.length > 0) {
|
|
737
|
+
specData.tags = aggregatedTags;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
specData.endTime = new Date().toISOString();
|
|
741
|
+
|
|
742
|
+
// Send spec end event to API
|
|
743
|
+
await this.sendToApi({
|
|
744
|
+
type: 'specEnd',
|
|
745
|
+
runId: this.runId,
|
|
746
|
+
timestamp: new Date().toISOString(),
|
|
747
|
+
spec: specData
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// Send spec code blocks to API
|
|
751
|
+
await this.sendSpecCodeBlocks(specPath);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async onEnd(result: { status: string }): Promise<void> {
|
|
757
|
+
this.runMetadata.endTime = new Date().toISOString();
|
|
758
|
+
this.runMetadata.duration = Date.now() - new Date(this.runMetadata.startTime).getTime();
|
|
759
|
+
|
|
760
|
+
// Calculate final stats
|
|
761
|
+
const totalTests = Array.from(this.testMap.values()).length;
|
|
762
|
+
const passedTests = Array.from(this.testMap.values()).filter(t => t.status === 'passed').length;
|
|
763
|
+
// failedTests already includes timedOut tests since normalizeTestStatus converts 'timedOut' to 'failed'
|
|
764
|
+
const failedTests = Array.from(this.testMap.values()).filter(t => t.status === 'failed').length;
|
|
765
|
+
const skippedTests = Array.from(this.testMap.values()).filter(t => t.status === 'skipped').length;
|
|
766
|
+
// Track timedOut separately for reporting purposes only (not for count)
|
|
767
|
+
const timedOutTests = Array.from(this.testMap.values()).filter(t => t.originalStatus === 'timedOut').length;
|
|
768
|
+
|
|
769
|
+
// Normalize run status - if there are timeouts, treat run as failed
|
|
770
|
+
const hasTimeouts = timedOutTests > 0;
|
|
771
|
+
const normalizedRunStatus = this.normalizeRunStatus(result.status, hasTimeouts);
|
|
772
|
+
|
|
773
|
+
// Send run end event to API
|
|
774
|
+
await this.sendToApi({
|
|
775
|
+
type: 'runEnd',
|
|
776
|
+
runId: this.runId,
|
|
777
|
+
timestamp: new Date().toISOString(),
|
|
778
|
+
metadata: {
|
|
779
|
+
...this.runMetadata,
|
|
780
|
+
totalTests,
|
|
781
|
+
passedTests,
|
|
782
|
+
failedTests, // Already includes timedOut tests (normalized to 'failed')
|
|
783
|
+
skippedTests,
|
|
784
|
+
timedOutTests, // For informational purposes
|
|
785
|
+
status: normalizedRunStatus
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// Show Build Name if provided, otherwise show Run ID
|
|
790
|
+
if (this.runMetadata.testlensBuildName) {
|
|
791
|
+
console.log(`📊 TestLens Report completed - Build: ${this.runMetadata.testlensBuildName}`);
|
|
792
|
+
console.log(` Run ID: ${this.runId}`);
|
|
793
|
+
} else {
|
|
794
|
+
console.log(`📊 TestLens Report completed - Run ID: ${this.runId}`);
|
|
795
|
+
}
|
|
796
|
+
console.log(`🎯 Results: ${passedTests} passed, ${failedTests} failed (${timedOutTests} timeouts), ${skippedTests} skipped`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private async sendToApi(payload: any): Promise<any> {
|
|
800
|
+
// Skip sending if run creation already failed
|
|
801
|
+
if (this.runCreationFailed && payload.type !== 'runStart') {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
const response = await this.axiosInstance.post('', payload, {
|
|
807
|
+
headers: {
|
|
808
|
+
'X-API-Key': this.config.apiKey
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
if (this.config.enableRealTimeStream) {
|
|
812
|
+
console.log(`✅ Sent ${payload.type} event to TestLens (HTTP ${response.status})`);
|
|
813
|
+
}
|
|
814
|
+
// Return response data for caller to use
|
|
815
|
+
return response.data;
|
|
816
|
+
} catch (error: any) {
|
|
817
|
+
const errorData = error?.response?.data;
|
|
818
|
+
const status = error?.response?.status;
|
|
819
|
+
|
|
820
|
+
// Check for limit exceeded (403)
|
|
821
|
+
if (status === 403 && errorData?.error === 'limit_exceeded') {
|
|
822
|
+
// Set flag to skip subsequent events
|
|
823
|
+
if (payload.type === 'runStart' && errorData?.limit_type === 'test_runs') {
|
|
824
|
+
this.runCreationFailed = true;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
console.error('\n' + '='.repeat(80));
|
|
828
|
+
if (errorData?.limit_type === 'test_cases') {
|
|
829
|
+
console.error('❌ TESTLENS ERROR: Test Cases Limit Reached');
|
|
830
|
+
} else if (errorData?.limit_type === 'test_runs') {
|
|
831
|
+
console.error('❌ TESTLENS ERROR: Test Runs Limit Reached');
|
|
832
|
+
} else {
|
|
833
|
+
console.error('❌ TESTLENS ERROR: Plan Limit Reached');
|
|
834
|
+
}
|
|
835
|
+
console.error('='.repeat(80));
|
|
836
|
+
console.error('');
|
|
837
|
+
console.error(errorData?.message || 'You have reached your plan limit.');
|
|
838
|
+
console.error('');
|
|
839
|
+
console.error(`Current usage: ${errorData?.current_usage || 'N/A'} / ${errorData?.limit || 'N/A'}`);
|
|
840
|
+
console.error('');
|
|
841
|
+
console.error('To continue, please upgrade your plan.');
|
|
842
|
+
console.error('Contact: support@alternative-path.com');
|
|
843
|
+
console.error('');
|
|
844
|
+
console.error('='.repeat(80));
|
|
845
|
+
console.error('');
|
|
846
|
+
return; // Don't log the full error object for limit errors
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Check for trial expiration, subscription errors, or limit errors (401)
|
|
850
|
+
if (status === 401) {
|
|
851
|
+
if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
|
|
852
|
+
errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
|
|
853
|
+
console.error('\n' + '='.repeat(80));
|
|
854
|
+
|
|
855
|
+
if (errorData?.error === 'test_cases_limit_reached') {
|
|
856
|
+
console.error('❌ TESTLENS ERROR: Test Cases Limit Reached');
|
|
857
|
+
} else if (errorData?.error === 'test_runs_limit_reached') {
|
|
858
|
+
console.error('❌ TESTLENS ERROR: Test Runs Limit Reached');
|
|
859
|
+
} else {
|
|
860
|
+
console.error('❌ TESTLENS ERROR: Your trial plan has ended');
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
console.error('='.repeat(80));
|
|
864
|
+
console.error('');
|
|
865
|
+
console.error(errorData?.message || 'Your trial period has expired.');
|
|
866
|
+
console.error('');
|
|
867
|
+
console.error('To continue using TestLens, please upgrade to Enterprise plan.');
|
|
868
|
+
console.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
|
|
869
|
+
console.error('');
|
|
870
|
+
if (errorData?.trial_end_date) {
|
|
871
|
+
console.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
|
|
872
|
+
console.error('');
|
|
873
|
+
}
|
|
874
|
+
console.error('='.repeat(80));
|
|
875
|
+
console.error('');
|
|
876
|
+
} else {
|
|
877
|
+
console.error(`❌ Authentication failed: ${errorData?.error || 'Invalid API key'}`);
|
|
878
|
+
}
|
|
879
|
+
} else if (status !== 403) {
|
|
880
|
+
// Log other errors (but not 403 which we handled above)
|
|
881
|
+
console.error(`❌ Failed to send ${payload.type} event to TestLens:`, {
|
|
882
|
+
message: error?.message || 'Unknown error',
|
|
883
|
+
status: status,
|
|
884
|
+
statusText: error?.response?.statusText,
|
|
885
|
+
data: errorData,
|
|
886
|
+
code: error?.code,
|
|
887
|
+
url: error?.config?.url,
|
|
888
|
+
method: error?.config?.method
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Don't throw error to avoid breaking test execution
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private async processArtifacts(testId: string, result: TestResult, testCaseDbId?: string): Promise<void> {
|
|
897
|
+
// Skip artifact processing if run creation failed
|
|
898
|
+
if (this.runCreationFailed) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const attachments = result.attachments;
|
|
903
|
+
|
|
904
|
+
for (const attachment of attachments) {
|
|
905
|
+
if (attachment.path) {
|
|
906
|
+
// Check if attachment should be processed based on config
|
|
907
|
+
const artifactType = this.getArtifactType(attachment.name);
|
|
908
|
+
const isVideo = artifactType === 'video' || attachment.contentType?.startsWith('video/');
|
|
909
|
+
const isScreenshot = artifactType === 'screenshot' || attachment.contentType?.startsWith('image/');
|
|
910
|
+
|
|
911
|
+
// Skip video if disabled in config
|
|
912
|
+
if (isVideo && !this.config.enableVideo) {
|
|
913
|
+
console.log(`⏭️ Skipping video artifact ${attachment.name} - video capture disabled in config`);
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Skip screenshot if disabled in config
|
|
918
|
+
if (isScreenshot && !this.config.enableScreenshot) {
|
|
919
|
+
console.log(`⏭️ Skipping screenshot artifact ${attachment.name} - screenshot capture disabled in config`);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
try {
|
|
924
|
+
// Determine proper filename with extension
|
|
925
|
+
// Playwright attachment.name often doesn't have extension, so we need to derive it
|
|
926
|
+
let fileName = attachment.name;
|
|
927
|
+
const existingExt = path.extname(fileName);
|
|
928
|
+
|
|
929
|
+
if (!existingExt) {
|
|
930
|
+
// Get extension from the actual file path
|
|
931
|
+
const pathExt = path.extname(attachment.path);
|
|
932
|
+
if (pathExt) {
|
|
933
|
+
fileName = `${fileName}${pathExt}`;
|
|
934
|
+
} else if (attachment.contentType) {
|
|
935
|
+
// Fallback: derive extension from contentType
|
|
936
|
+
const mimeToExt: Record<string, string> = {
|
|
937
|
+
'image/png': '.png',
|
|
938
|
+
'image/jpeg': '.jpg',
|
|
939
|
+
'image/gif': '.gif',
|
|
940
|
+
'image/webp': '.webp',
|
|
941
|
+
'video/webm': '.webm',
|
|
942
|
+
'video/mp4': '.mp4',
|
|
943
|
+
'application/zip': '.zip',
|
|
944
|
+
'application/json': '.json',
|
|
945
|
+
'text/plain': '.txt'
|
|
946
|
+
};
|
|
947
|
+
const ext = mimeToExt[attachment.contentType];
|
|
948
|
+
if (ext) {
|
|
949
|
+
fileName = `${fileName}${ext}`;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Upload to S3 first (pass DB ID if available for faster lookup)
|
|
955
|
+
const s3Data = await this.uploadArtifactToS3(attachment.path, testId, fileName, testCaseDbId);
|
|
956
|
+
|
|
957
|
+
// Skip if upload failed or file was too large
|
|
958
|
+
if (!s3Data) {
|
|
959
|
+
console.log(`⏭️ [Test: ${testId.substring(0, 8)}...] Skipping artifact ${attachment.name} - upload failed or file too large`);
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const artifactData = {
|
|
964
|
+
testId,
|
|
965
|
+
type: this.getArtifactType(attachment.name),
|
|
966
|
+
path: attachment.path,
|
|
967
|
+
name: fileName,
|
|
968
|
+
contentType: attachment.contentType,
|
|
969
|
+
fileSize: this.getFileSize(attachment.path),
|
|
970
|
+
storageType: 's3',
|
|
971
|
+
s3Key: s3Data.key,
|
|
972
|
+
s3Url: s3Data.url
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
// Send artifact data to API
|
|
976
|
+
await this.sendToApi({
|
|
977
|
+
type: 'artifact',
|
|
978
|
+
runId: this.runId,
|
|
979
|
+
timestamp: new Date().toISOString(),
|
|
980
|
+
artifact: artifactData
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
console.log(`📎 [Test: ${testId.substring(0, 8)}...] Processed artifact: ${fileName}`);
|
|
984
|
+
} catch (error) {
|
|
985
|
+
console.error(`❌ [Test: ${testId.substring(0, 8)}...] Failed to process artifact ${attachment.name}:`, (error as Error).message);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private async sendSpecCodeBlocks(specPath: string): Promise<void> {
|
|
992
|
+
try {
|
|
993
|
+
// Extract code blocks using built-in parser
|
|
994
|
+
const testBlocks = this.extractTestBlocks(specPath);
|
|
995
|
+
|
|
996
|
+
// Transform blocks to match backend API expectations
|
|
997
|
+
const codeBlocks = testBlocks.map(block => ({
|
|
998
|
+
type: block.type, // 'test' or 'describe'
|
|
999
|
+
name: block.name, // test/describe name
|
|
1000
|
+
content: block.content, // full code content
|
|
1001
|
+
summary: null, // optional
|
|
1002
|
+
describe: block.describe // parent describe block name
|
|
1003
|
+
}));
|
|
1004
|
+
|
|
1005
|
+
// Send to dedicated spec code blocks API endpoint
|
|
1006
|
+
const baseUrl = this.config.apiEndpoint.replace('/webhook/playwright', '');
|
|
1007
|
+
const specEndpoint = `${baseUrl}/webhook/playwright/spec-code-blocks`;
|
|
1008
|
+
|
|
1009
|
+
await this.axiosInstance.post(specEndpoint, {
|
|
1010
|
+
filePath: path.relative(process.cwd(), specPath),
|
|
1011
|
+
codeBlocks,
|
|
1012
|
+
testSuiteName: path.basename(specPath).replace(/\.(spec|test)\.(js|ts)$/, '')
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
console.log(`📝 Sent ${codeBlocks.length} code blocks for: ${path.basename(specPath)}`);
|
|
1016
|
+
} catch (error: any) {
|
|
1017
|
+
const errorData = error?.response?.data;
|
|
1018
|
+
|
|
1019
|
+
// Handle duplicate spec code blocks gracefully (when re-running tests)
|
|
1020
|
+
if (errorData?.error && errorData.error.includes('duplicate key value violates unique constraint')) {
|
|
1021
|
+
console.log(`ℹ️ Spec code blocks already exist for: ${path.basename(specPath)} (skipped)`);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
console.error('Failed to send spec code blocks:', errorData || error?.message || 'Unknown error');
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
private extractTestBlocks(filePath: string): CodeBlock[] {
|
|
1030
|
+
try {
|
|
1031
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1032
|
+
const blocks: CodeBlock[] = [];
|
|
1033
|
+
const lines = content.split('\n');
|
|
1034
|
+
|
|
1035
|
+
let currentDescribe: string | null = null;
|
|
1036
|
+
let braceCount = 0;
|
|
1037
|
+
let inBlock = false;
|
|
1038
|
+
let blockStart = -1;
|
|
1039
|
+
let blockType: 'test' | 'describe' = 'test';
|
|
1040
|
+
let blockName = '';
|
|
1041
|
+
|
|
1042
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1043
|
+
const line = lines[i];
|
|
1044
|
+
const trimmedLine = line.trim();
|
|
1045
|
+
|
|
1046
|
+
// Check for describe blocks
|
|
1047
|
+
const describeMatch = trimmedLine.match(/describe\s*\(\s*['"`]([^'"`]+)['"`]/);
|
|
1048
|
+
if (describeMatch) {
|
|
1049
|
+
currentDescribe = describeMatch[1];
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Check for test blocks
|
|
1053
|
+
const testMatch = trimmedLine.match(/test\s*\(\s*['"`]([^'"`]+)['"`]/);
|
|
1054
|
+
if (testMatch && !inBlock) {
|
|
1055
|
+
blockType = 'test';
|
|
1056
|
+
blockName = testMatch[1];
|
|
1057
|
+
blockStart = i;
|
|
1058
|
+
braceCount = 0;
|
|
1059
|
+
inBlock = true;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Count braces when in a block
|
|
1063
|
+
if (inBlock) {
|
|
1064
|
+
for (const char of line) {
|
|
1065
|
+
if (char === '{') braceCount++;
|
|
1066
|
+
if (char === '}') braceCount--;
|
|
1067
|
+
|
|
1068
|
+
if (braceCount === 0 && blockStart !== -1 && i > blockStart) {
|
|
1069
|
+
// End of block found
|
|
1070
|
+
const blockContent = lines.slice(blockStart, i + 1).join('\n');
|
|
1071
|
+
|
|
1072
|
+
blocks.push({
|
|
1073
|
+
type: blockType,
|
|
1074
|
+
name: blockName,
|
|
1075
|
+
content: blockContent,
|
|
1076
|
+
describe: currentDescribe || undefined,
|
|
1077
|
+
startLine: blockStart + 1,
|
|
1078
|
+
endLine: i + 1
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
inBlock = false;
|
|
1082
|
+
blockStart = -1;
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return blocks;
|
|
1090
|
+
} catch (error: any) {
|
|
1091
|
+
console.error(`Failed to extract test blocks from ${filePath}:`, error.message);
|
|
1092
|
+
return [];
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
private async collectGitInfo(): Promise<GitInfo | null> {
|
|
1097
|
+
try {
|
|
1098
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
|
|
1099
|
+
const commit = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
|
|
1100
|
+
const shortCommit = commit.substring(0, 7);
|
|
1101
|
+
const author = execSync('git log -1 --pretty=format:"%an"', { encoding: 'utf-8' }).trim();
|
|
1102
|
+
const commitMessage = execSync('git log -1 --pretty=format:"%s"', { encoding: 'utf-8' }).trim();
|
|
1103
|
+
const commitTimestamp = execSync('git log -1 --pretty=format:"%ci"', { encoding: 'utf-8' }).trim();
|
|
1104
|
+
|
|
1105
|
+
let remoteName = 'origin';
|
|
1106
|
+
let remoteUrl = '';
|
|
1107
|
+
try {
|
|
1108
|
+
const remotes = execSync('git remote', { encoding: 'utf-8' }).trim();
|
|
1109
|
+
if (remotes) {
|
|
1110
|
+
remoteName = remotes.split('\n')[0] || 'origin';
|
|
1111
|
+
remoteUrl = execSync(`git remote get-url ${remoteName}`, { encoding: 'utf-8' }).trim();
|
|
1112
|
+
}
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
// Remote info is optional - handle gracefully
|
|
1115
|
+
console.log('ℹ️ No git remote configured, skipping remote info');
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const isDirty = execSync('git status --porcelain', { encoding: 'utf-8' }).trim().length > 0;
|
|
1119
|
+
|
|
1120
|
+
return {
|
|
1121
|
+
branch,
|
|
1122
|
+
commit,
|
|
1123
|
+
shortCommit,
|
|
1124
|
+
author,
|
|
1125
|
+
message: commitMessage,
|
|
1126
|
+
timestamp: commitTimestamp,
|
|
1127
|
+
isDirty,
|
|
1128
|
+
remoteName,
|
|
1129
|
+
remoteUrl
|
|
1130
|
+
};
|
|
1131
|
+
} catch (error: any) {
|
|
1132
|
+
// Silently skip git information if not in a git repository
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
private getArtifactType(name: string): string {
|
|
1138
|
+
if (name.includes('screenshot')) return 'screenshot';
|
|
1139
|
+
if (name.includes('video')) return 'video';
|
|
1140
|
+
if (name.includes('trace')) return 'trace';
|
|
1141
|
+
return 'attachment';
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
private extractTags(test: TestCase): string[] {
|
|
1145
|
+
const tags: string[] = [];
|
|
1146
|
+
|
|
1147
|
+
// Playwright stores tags in the _tags property
|
|
1148
|
+
const testTags = (test as any)._tags;
|
|
1149
|
+
if (testTags && Array.isArray(testTags)) {
|
|
1150
|
+
tags.push(...testTags);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Also get tags from parent suites by walking up the tree
|
|
1154
|
+
let currentSuite: Suite | undefined = test.parent;
|
|
1155
|
+
while (currentSuite) {
|
|
1156
|
+
const suiteTags = (currentSuite as any)._tags;
|
|
1157
|
+
if (suiteTags && Array.isArray(suiteTags)) {
|
|
1158
|
+
tags.push(...suiteTags);
|
|
1159
|
+
}
|
|
1160
|
+
currentSuite = currentSuite.parent;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Also extract @tags from test title for backward compatibility
|
|
1164
|
+
const tagMatches = test.title.match(/@[\w-]+/g);
|
|
1165
|
+
if (tagMatches) {
|
|
1166
|
+
tags.push(...tagMatches);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Add testlensBuildTag: CLI args take precedence over config
|
|
1170
|
+
const buildTagSource = this.cliArgs.testlensBuildTag || this.config.customMetadata?.testlensBuildTag;
|
|
1171
|
+
if (buildTagSource) {
|
|
1172
|
+
const buildTags = Array.isArray(buildTagSource)
|
|
1173
|
+
? buildTagSource
|
|
1174
|
+
: [buildTagSource];
|
|
1175
|
+
buildTags.forEach(tag => tags.push(`@${tag}`));
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Remove duplicates and return
|
|
1179
|
+
return [...new Set(tags)];
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
private getTestId(test: TestCase): string {
|
|
1183
|
+
const cleanTitle = test.title.replace(/@[\w-]+/g, '').trim();
|
|
1184
|
+
// Normalize path separators to forward slashes for cross-platform consistency
|
|
1185
|
+
const normalizedFile = test.location.file.replace(/\\/g, '/');
|
|
1186
|
+
return `${normalizedFile}:${test.location.line}:${cleanTitle}`;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
private async uploadArtifactToS3(filePath: string, testId: string, fileName: string, testCaseDbId?: string): Promise<{ key: string; url: string; presignedUrl: string; fileSize: number; contentType: string } | null> {
|
|
1191
|
+
try {
|
|
1192
|
+
// Check file size first
|
|
1193
|
+
const fileSize = this.getFileSize(filePath);
|
|
1194
|
+
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
|
|
1195
|
+
|
|
1196
|
+
console.log(`📤 Uploading ${fileName} (${fileSizeMB}MB) directly to S3...`);
|
|
1197
|
+
|
|
1198
|
+
const baseUrl = this.config.apiEndpoint.replace('/api/v1/webhook/playwright', '');
|
|
1199
|
+
|
|
1200
|
+
// Step 1: Request pre-signed URL from server
|
|
1201
|
+
const presignedUrlEndpoint = `${baseUrl}/api/v1/artifacts/public/presigned-url`;
|
|
1202
|
+
const requestBody: any = {
|
|
1203
|
+
apiKey: this.config.apiKey,
|
|
1204
|
+
testRunId: this.runId,
|
|
1205
|
+
testId: testId,
|
|
1206
|
+
fileName: fileName,
|
|
1207
|
+
fileType: await this.getContentType(fileName),
|
|
1208
|
+
fileSize: fileSize,
|
|
1209
|
+
artifactType: this.getArtifactType(fileName)
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
// Include DB ID if available for faster lookup (avoids query)
|
|
1213
|
+
if (testCaseDbId) {
|
|
1214
|
+
requestBody.testCaseDbId = testCaseDbId;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const presignedResponse = await this.axiosInstance.post(presignedUrlEndpoint, requestBody, {
|
|
1218
|
+
timeout: 10000 // Quick timeout for metadata request
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
if (!presignedResponse.data.success) {
|
|
1222
|
+
throw new Error(`Failed to get presigned URL: ${presignedResponse.data.error || 'Unknown error'}`);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const { uploadUrl, s3Key, metadata } = presignedResponse.data;
|
|
1226
|
+
|
|
1227
|
+
// Step 2: Upload directly to S3 using presigned URL
|
|
1228
|
+
console.log(`⬆️ [Test: ${testId.substring(0, 8)}...] Uploading ${fileName} directly to S3...`);
|
|
1229
|
+
|
|
1230
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
1231
|
+
|
|
1232
|
+
// IMPORTANT: When using presigned URLs, we MUST include exactly the headers that were signed
|
|
1233
|
+
// The backend signs with ServerSideEncryption:'AES256', so we must send that header
|
|
1234
|
+
// AWS presigned URLs are very strict about header matching
|
|
1235
|
+
const uploadResponse = await axios.put(uploadUrl, fileBuffer, {
|
|
1236
|
+
headers: {
|
|
1237
|
+
'x-amz-server-side-encryption': 'AES256'
|
|
1238
|
+
},
|
|
1239
|
+
maxContentLength: Infinity,
|
|
1240
|
+
maxBodyLength: Infinity,
|
|
1241
|
+
timeout: Math.max(600000, Math.ceil(fileSize / (1024 * 1024)) * 10000), // 10s per MB, min 10 minutes - increased for large trace files
|
|
1242
|
+
// Don't use custom HTTPS agent for S3 uploads
|
|
1243
|
+
httpsAgent: undefined
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
if (uploadResponse.status !== 200) {
|
|
1247
|
+
throw new Error(`S3 upload failed with status ${uploadResponse.status}`);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
console.log(`✅ [Test: ${testId.substring(0, 8)}...] S3 upload completed for ${fileName}`);
|
|
1251
|
+
|
|
1252
|
+
// Step 3: Confirm upload with server to save metadata
|
|
1253
|
+
const confirmEndpoint = `${baseUrl}/api/v1/artifacts/public/confirm-upload`;
|
|
1254
|
+
const confirmBody: any = {
|
|
1255
|
+
apiKey: this.config.apiKey,
|
|
1256
|
+
testRunId: this.runId,
|
|
1257
|
+
testId: testId,
|
|
1258
|
+
s3Key: s3Key,
|
|
1259
|
+
fileName: fileName,
|
|
1260
|
+
fileType: await this.getContentType(fileName),
|
|
1261
|
+
fileSize: fileSize,
|
|
1262
|
+
artifactType: this.getArtifactType(fileName)
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
// Include DB ID if available for direct insert (avoids query and race condition)
|
|
1266
|
+
if (testCaseDbId) {
|
|
1267
|
+
confirmBody.testCaseDbId = testCaseDbId;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const confirmResponse = await this.axiosInstance.post(confirmEndpoint, confirmBody, {
|
|
1271
|
+
timeout: 10000
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
if (confirmResponse.status === 201 && confirmResponse.data.success) {
|
|
1275
|
+
const artifact = confirmResponse.data.artifact;
|
|
1276
|
+
console.log(`✅ [Test: ${testId.substring(0, 8)}...] Upload confirmed${testCaseDbId ? ' (fast path)' : ' (fallback)'}`);
|
|
1277
|
+
return {
|
|
1278
|
+
key: s3Key,
|
|
1279
|
+
url: artifact.s3Url,
|
|
1280
|
+
presignedUrl: artifact.presignedUrl,
|
|
1281
|
+
fileSize: artifact.fileSize,
|
|
1282
|
+
contentType: artifact.contentType
|
|
1283
|
+
};
|
|
1284
|
+
} else {
|
|
1285
|
+
throw new Error(`Upload confirmation failed: ${confirmResponse.data.error || 'Unknown error'}`);
|
|
1286
|
+
}
|
|
1287
|
+
} catch (error: any) {
|
|
1288
|
+
// Check for trial expiration, subscription errors, or limit errors
|
|
1289
|
+
if (error?.response?.status === 401) {
|
|
1290
|
+
const errorData = error?.response?.data;
|
|
1291
|
+
|
|
1292
|
+
if (errorData?.error === 'trial_expired' || errorData?.error === 'subscription_inactive' ||
|
|
1293
|
+
errorData?.error === 'test_cases_limit_reached' || errorData?.error === 'test_runs_limit_reached') {
|
|
1294
|
+
console.error('\\n' + '='.repeat(80));
|
|
1295
|
+
|
|
1296
|
+
if (errorData?.error === 'test_cases_limit_reached') {
|
|
1297
|
+
console.error('❌ TESTLENS ERROR: Test Cases Limit Reached');
|
|
1298
|
+
} else if (errorData?.error === 'test_runs_limit_reached') {
|
|
1299
|
+
console.error('❌ TESTLENS ERROR: Test Runs Limit Reached');
|
|
1300
|
+
} else {
|
|
1301
|
+
console.error('❌ TESTLENS ERROR: Your trial plan has ended');
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
console.error('='.repeat(80));
|
|
1305
|
+
console.error('');
|
|
1306
|
+
console.error(errorData?.message || 'Your trial period has expired.');
|
|
1307
|
+
console.error('');
|
|
1308
|
+
console.error('To continue using TestLens, please upgrade to Enterprise plan.');
|
|
1309
|
+
console.error('Contact: ' + (errorData?.contactEmail || 'support@alternative-path.com'));
|
|
1310
|
+
console.error('');
|
|
1311
|
+
if (errorData?.trial_end_date) {
|
|
1312
|
+
console.error(`Trial ended: ${new Date(errorData.trial_end_date).toLocaleDateString()}`);
|
|
1313
|
+
console.error('');
|
|
1314
|
+
}
|
|
1315
|
+
console.error('='.repeat(80));
|
|
1316
|
+
console.error('');
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Better error messages for common issues
|
|
1322
|
+
let errorMsg = error.message;
|
|
1323
|
+
|
|
1324
|
+
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
|
1325
|
+
errorMsg = `Upload timeout - file may be too large or connection is slow`;
|
|
1326
|
+
} else if (error.response?.status === 413) {
|
|
1327
|
+
errorMsg = `File too large (413) - server rejected the upload`;
|
|
1328
|
+
} else if (error.response?.status === 400) {
|
|
1329
|
+
errorMsg = `Bad request (400) - ${error.response.data?.error || 'check file format'}`;
|
|
1330
|
+
} else if (error.response?.status === 403) {
|
|
1331
|
+
errorMsg = `Access denied (403) - presigned URL may have expired`;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
console.error(`❌ Failed to upload ${fileName} to S3:`, errorMsg);
|
|
1335
|
+
if (error.response?.data) {
|
|
1336
|
+
console.error('Error details:', error.response.data);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Don't throw, just return null to continue with other artifacts
|
|
1340
|
+
return null;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private async getContentType(fileName: string): Promise<string> {
|
|
1345
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
1346
|
+
try {
|
|
1347
|
+
const mime = await getMime();
|
|
1348
|
+
// Try different ways to access getType method
|
|
1349
|
+
const getType = mime.getType || mime.default?.getType;
|
|
1350
|
+
if (typeof getType === 'function') {
|
|
1351
|
+
const mimeType = getType.call(mime, ext) || getType.call(mime.default, ext);
|
|
1352
|
+
return mimeType || 'application/octet-stream';
|
|
1353
|
+
}
|
|
1354
|
+
} catch (error: any) {
|
|
1355
|
+
console.warn(`Failed to get MIME type for ${fileName}:`, error.message);
|
|
1356
|
+
}
|
|
1357
|
+
// Fallback to basic content type mapping
|
|
1358
|
+
const contentTypes: Record<string, string> = {
|
|
1359
|
+
'.mp4': 'video/mp4',
|
|
1360
|
+
'.webm': 'video/webm',
|
|
1361
|
+
'.png': 'image/png',
|
|
1362
|
+
'.jpg': 'image/jpeg',
|
|
1363
|
+
'.jpeg': 'image/jpeg',
|
|
1364
|
+
'.gif': 'image/gif',
|
|
1365
|
+
'.json': 'application/json',
|
|
1366
|
+
'.txt': 'text/plain',
|
|
1367
|
+
'.html': 'text/html',
|
|
1368
|
+
'.xml': 'application/xml',
|
|
1369
|
+
'.zip': 'application/zip',
|
|
1370
|
+
'.pdf': 'application/pdf'
|
|
1371
|
+
};
|
|
1372
|
+
return contentTypes[ext] || 'application/octet-stream';
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
private generateS3Key(runId: string, testId: string, fileName: string): string {
|
|
1376
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
1377
|
+
const safeTestId = this.sanitizeForS3(testId);
|
|
1378
|
+
const safeFileName = this.sanitizeForS3(fileName);
|
|
1379
|
+
const ext = path.extname(fileName);
|
|
1380
|
+
const baseName = path.basename(fileName, ext);
|
|
1381
|
+
|
|
1382
|
+
return `test-artifacts/${date}/${runId}/${safeTestId}/${safeFileName}${ext}`;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
private sanitizeForS3(value: string): string {
|
|
1386
|
+
return value
|
|
1387
|
+
.replace(/[\/:*?"<>|]/g, '-')
|
|
1388
|
+
.replace(/[-\u001f\u007f]/g, '-')
|
|
1389
|
+
.replace(/[^-~]/g, '-')
|
|
1390
|
+
.replace(/\s+/g, '-')
|
|
1391
|
+
.replace(/[_]/g, '-')
|
|
1392
|
+
.replace(/-+/g, '-')
|
|
1393
|
+
.replace(/^-|-$/g, '');
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
private getFileSize(filePath: string): number {
|
|
1397
|
+
try {
|
|
1398
|
+
const stats = fs.statSync(filePath);
|
|
1399
|
+
return stats.size;
|
|
1400
|
+
} catch (error) {
|
|
1401
|
+
console.warn(`Could not get file size for ${filePath}:`, (error as Error).message);
|
|
1402
|
+
return 0;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
export default TestLensReporter;
|