@checkly/playwright-reporter 0.1.5 → 0.1.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/dist/index.d.ts +213 -0
- package/dist/index.js +1036 -1
- package/package.json +15 -30
- package/README.md +0 -256
- package/dist/asset-collector.js +0 -1
- package/dist/clients/checkly-client.js +0 -1
- package/dist/clients/errors.js +0 -1
- package/dist/clients/index.js +0 -1
- package/dist/clients/test-results.js +0 -1
- package/dist/clients/types.js +0 -1
- package/dist/reporter.js +0 -1
- package/dist/types.js +0 -1
- package/dist/zipper.js +0 -1
package/dist/index.js
CHANGED
|
@@ -1 +1,1036 @@
|
|
|
1
|
-
|
|
1
|
+
// ../utils/src/asset-collector.ts
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
var AssetCollector = class {
|
|
5
|
+
constructor(testResultsDir) {
|
|
6
|
+
this.testResultsDir = testResultsDir;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Collects assets from test results directory
|
|
10
|
+
* Uses a two-phase approach to support both normal test runs and blob merge scenarios:
|
|
11
|
+
* 1. First, extract attachment paths from the JSON report (works for merge scenarios)
|
|
12
|
+
* 2. Fall back to directory scanning for any additional assets not in the report
|
|
13
|
+
*
|
|
14
|
+
* @param report Optional Playwright JSONReport with attachment paths
|
|
15
|
+
* @returns Array of Asset objects with source and archive paths
|
|
16
|
+
*/
|
|
17
|
+
async collectAssets(report) {
|
|
18
|
+
const assets = [];
|
|
19
|
+
const addedPaths = /* @__PURE__ */ new Set();
|
|
20
|
+
if (report) {
|
|
21
|
+
this.collectAssetsFromReport(report, assets, addedPaths);
|
|
22
|
+
}
|
|
23
|
+
if (fs.existsSync(this.testResultsDir)) {
|
|
24
|
+
await this.collectAssetsRecursive(this.testResultsDir, assets, addedPaths);
|
|
25
|
+
}
|
|
26
|
+
return assets;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Extracts assets from JSON report attachment paths
|
|
30
|
+
* Essential for blob merge scenarios where attachments are extracted to temporary locations
|
|
31
|
+
*/
|
|
32
|
+
collectAssetsFromReport(report, assets, addedPaths) {
|
|
33
|
+
const processResults = (results) => {
|
|
34
|
+
for (const result of results) {
|
|
35
|
+
if (result.attachments && Array.isArray(result.attachments)) {
|
|
36
|
+
for (const attachment of result.attachments) {
|
|
37
|
+
if (attachment.path && typeof attachment.path === "string") {
|
|
38
|
+
const sourcePath = attachment.path;
|
|
39
|
+
if (addedPaths.has(sourcePath)) continue;
|
|
40
|
+
if (!fs.existsSync(sourcePath)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const archivePath = this.determineArchivePath(sourcePath);
|
|
44
|
+
const asset = this.createAsset(sourcePath, archivePath);
|
|
45
|
+
assets.push(asset);
|
|
46
|
+
addedPaths.add(sourcePath);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const processSuite = (suite) => {
|
|
53
|
+
if (suite.specs) {
|
|
54
|
+
for (const spec of suite.specs) {
|
|
55
|
+
if (spec.tests) {
|
|
56
|
+
for (const test of spec.tests) {
|
|
57
|
+
if (test.results) {
|
|
58
|
+
processResults(test.results);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (suite.suites) {
|
|
65
|
+
for (const nestedSuite of suite.suites) {
|
|
66
|
+
processSuite(nestedSuite);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
if (report.suites) {
|
|
71
|
+
for (const suite of report.suites) {
|
|
72
|
+
processSuite(suite);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Determines the archive path for an asset based on its source path
|
|
78
|
+
* Handles both test-results paths and blob merge extraction paths
|
|
79
|
+
*/
|
|
80
|
+
determineArchivePath(sourcePath) {
|
|
81
|
+
const normalizedPath = sourcePath.replace(/\\/g, "/");
|
|
82
|
+
const testResultsIndex = normalizedPath.indexOf("test-results/");
|
|
83
|
+
if (testResultsIndex !== -1) {
|
|
84
|
+
return normalizedPath.substring(testResultsIndex);
|
|
85
|
+
}
|
|
86
|
+
const parts = normalizedPath.split("/");
|
|
87
|
+
if (parts.length >= 2) {
|
|
88
|
+
return `test-results/${parts.slice(-2).join("/")}`;
|
|
89
|
+
}
|
|
90
|
+
return `test-results/${path.basename(sourcePath)}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Recursively traverses directories to collect assets
|
|
94
|
+
*/
|
|
95
|
+
async collectAssetsRecursive(currentDir, assets, addedPaths) {
|
|
96
|
+
let entries;
|
|
97
|
+
try {
|
|
98
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const baseDirName = path.basename(path.resolve(this.testResultsDir));
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
105
|
+
const relativeFromBase = path.relative(this.testResultsDir, fullPath);
|
|
106
|
+
const archivePath = path.join(baseDirName, relativeFromBase);
|
|
107
|
+
if (this.shouldSkipFile(entry.name)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (entry.isDirectory()) {
|
|
111
|
+
await this.collectAssetsRecursive(fullPath, assets, addedPaths);
|
|
112
|
+
} else if (entry.isFile()) {
|
|
113
|
+
if (!addedPaths.has(fullPath)) {
|
|
114
|
+
const asset = this.createAsset(fullPath, archivePath);
|
|
115
|
+
assets.push(asset);
|
|
116
|
+
addedPaths.add(fullPath);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Determines if a file should be skipped
|
|
123
|
+
*/
|
|
124
|
+
shouldSkipFile(filename) {
|
|
125
|
+
const skipPatterns = [
|
|
126
|
+
/^\./,
|
|
127
|
+
// Hidden files (.DS_Store, .gitkeep, etc.)
|
|
128
|
+
/\.tmp$/i,
|
|
129
|
+
// Temporary files
|
|
130
|
+
/~$/,
|
|
131
|
+
// Backup files
|
|
132
|
+
/\.swp$/i,
|
|
133
|
+
// Vim swap files
|
|
134
|
+
/\.lock$/i,
|
|
135
|
+
// Lock files
|
|
136
|
+
/^playwright-test-report\.json$/i
|
|
137
|
+
// Skip the JSON report itself
|
|
138
|
+
];
|
|
139
|
+
return skipPatterns.some((pattern) => pattern.test(filename));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Creates an Asset object from file path
|
|
143
|
+
*/
|
|
144
|
+
createAsset(sourcePath, archivePath) {
|
|
145
|
+
const ext = path.extname(sourcePath).toLowerCase();
|
|
146
|
+
const { type, contentType } = this.determineAssetType(ext);
|
|
147
|
+
const normalizedArchivePath = archivePath.split(path.sep).join("/");
|
|
148
|
+
return {
|
|
149
|
+
sourcePath,
|
|
150
|
+
archivePath: normalizedArchivePath,
|
|
151
|
+
type,
|
|
152
|
+
contentType
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Determines asset type and content type from file extension
|
|
157
|
+
*/
|
|
158
|
+
determineAssetType(ext) {
|
|
159
|
+
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg"].includes(ext)) {
|
|
160
|
+
return {
|
|
161
|
+
type: "screenshot",
|
|
162
|
+
contentType: this.getImageContentType(ext)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if ([".webm", ".mp4", ".avi", ".mov"].includes(ext)) {
|
|
166
|
+
return {
|
|
167
|
+
type: "video",
|
|
168
|
+
contentType: this.getVideoContentType(ext)
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (ext === ".zip") {
|
|
172
|
+
return {
|
|
173
|
+
type: "trace",
|
|
174
|
+
contentType: "application/zip"
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (ext === ".html" || ext === ".htm") {
|
|
178
|
+
return {
|
|
179
|
+
type: "attachment",
|
|
180
|
+
contentType: "text/html"
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (ext === ".json") {
|
|
184
|
+
return {
|
|
185
|
+
type: "attachment",
|
|
186
|
+
contentType: "application/json"
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (ext === ".txt" || ext === ".log") {
|
|
190
|
+
return {
|
|
191
|
+
type: "attachment",
|
|
192
|
+
contentType: "text/plain"
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
type: "other",
|
|
197
|
+
contentType: "application/octet-stream"
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Gets content type for image files
|
|
202
|
+
*/
|
|
203
|
+
getImageContentType(ext) {
|
|
204
|
+
const contentTypes = {
|
|
205
|
+
".png": "image/png",
|
|
206
|
+
".jpg": "image/jpeg",
|
|
207
|
+
".jpeg": "image/jpeg",
|
|
208
|
+
".gif": "image/gif",
|
|
209
|
+
".bmp": "image/bmp",
|
|
210
|
+
".svg": "image/svg+xml"
|
|
211
|
+
};
|
|
212
|
+
return contentTypes[ext] || "image/png";
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Gets content type for video files
|
|
216
|
+
*/
|
|
217
|
+
getVideoContentType(ext) {
|
|
218
|
+
const contentTypes = {
|
|
219
|
+
".webm": "video/webm",
|
|
220
|
+
".mp4": "video/mp4",
|
|
221
|
+
".avi": "video/x-msvideo",
|
|
222
|
+
".mov": "video/quicktime"
|
|
223
|
+
};
|
|
224
|
+
return contentTypes[ext] || "video/webm";
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// ../utils/src/zipper.ts
|
|
229
|
+
import * as fs2 from "fs";
|
|
230
|
+
import * as os from "os";
|
|
231
|
+
import * as path2 from "path";
|
|
232
|
+
import { ZipArchive } from "archiver";
|
|
233
|
+
var Zipper = class {
|
|
234
|
+
outputPath;
|
|
235
|
+
constructor(options) {
|
|
236
|
+
this.outputPath = options.outputPath;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Creates a ZIP archive containing the JSON report and assets
|
|
240
|
+
* @param reportPath - Path to the JSON report file
|
|
241
|
+
* @param assets - Array of assets to include in the ZIP
|
|
242
|
+
* @returns ZIP creation result with metadata
|
|
243
|
+
*/
|
|
244
|
+
async createZip(reportPath, assets) {
|
|
245
|
+
const entries = [];
|
|
246
|
+
return new Promise((resolve2, reject) => {
|
|
247
|
+
try {
|
|
248
|
+
const output = fs2.createWriteStream(this.outputPath);
|
|
249
|
+
const archive = new ZipArchive({
|
|
250
|
+
zlib: { level: 0 }
|
|
251
|
+
});
|
|
252
|
+
archive.on("entry", (entryData) => {
|
|
253
|
+
const entryName = entryData.name.replace(/\\/g, "/");
|
|
254
|
+
const start = entryData._offsets?.contents ?? 0;
|
|
255
|
+
const end = entryData._offsets?.contents + (entryData.csize ?? 0) - 1;
|
|
256
|
+
entries.push({
|
|
257
|
+
name: entryName,
|
|
258
|
+
start,
|
|
259
|
+
end
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
output.on("close", () => {
|
|
263
|
+
const zipSize = archive.pointer();
|
|
264
|
+
resolve2({
|
|
265
|
+
zipPath: this.outputPath,
|
|
266
|
+
size: zipSize,
|
|
267
|
+
entryCount: entries.length,
|
|
268
|
+
entries
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
archive.on("error", (err) => {
|
|
272
|
+
reject(err);
|
|
273
|
+
});
|
|
274
|
+
output.on("error", (err) => {
|
|
275
|
+
reject(err);
|
|
276
|
+
});
|
|
277
|
+
archive.pipe(output);
|
|
278
|
+
if (!fs2.existsSync(reportPath)) {
|
|
279
|
+
reject(new Error(`Report file not found: ${reportPath}`));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const transformedReportPath = this.transformJsonReport(reportPath);
|
|
283
|
+
archive.file(transformedReportPath, { name: "output/playwright-test-report.json" });
|
|
284
|
+
for (const asset of assets) {
|
|
285
|
+
if (!fs2.existsSync(asset.sourcePath)) {
|
|
286
|
+
console.warn(`[Checkly Reporter] Skipping missing asset: ${asset.sourcePath}`);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
archive.file(asset.sourcePath, { name: asset.archivePath });
|
|
290
|
+
}
|
|
291
|
+
archive.finalize();
|
|
292
|
+
} catch (error) {
|
|
293
|
+
reject(error);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Transforms the JSON report to use relative paths for attachments
|
|
299
|
+
* This ensures the UI can map attachment paths to ZIP entries
|
|
300
|
+
* @param reportPath - Path to the original JSON report
|
|
301
|
+
* @returns Path to the transformed JSON report (in temp directory)
|
|
302
|
+
*/
|
|
303
|
+
transformJsonReport(reportPath) {
|
|
304
|
+
const reportContent = fs2.readFileSync(reportPath, "utf-8");
|
|
305
|
+
const report = JSON.parse(reportContent);
|
|
306
|
+
this.transformAttachmentPaths(report);
|
|
307
|
+
const tempReportPath = path2.join(os.tmpdir(), `playwright-test-report-${Date.now()}.json`);
|
|
308
|
+
fs2.writeFileSync(tempReportPath, JSON.stringify(report, null, 2));
|
|
309
|
+
return tempReportPath;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Recursively transforms attachment paths in the report structure
|
|
313
|
+
* Converts absolute paths to relative paths matching ZIP structure
|
|
314
|
+
* @param obj - Object to transform (mutated in place)
|
|
315
|
+
*/
|
|
316
|
+
transformAttachmentPaths(obj) {
|
|
317
|
+
if (typeof obj !== "object" || obj === null) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (Array.isArray(obj)) {
|
|
321
|
+
obj.forEach((item) => this.transformAttachmentPaths(item));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (obj.attachments && Array.isArray(obj.attachments)) {
|
|
325
|
+
obj.attachments.forEach((attachment) => {
|
|
326
|
+
if (attachment.path && typeof attachment.path === "string") {
|
|
327
|
+
attachment.path = this.normalizeAttachmentPath(attachment.path);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
Object.values(obj).forEach((value) => this.transformAttachmentPaths(value));
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Normalizes attachment paths by extracting the relevant snapshot directory portion.
|
|
335
|
+
* Supports Playwright's default and common custom snapshot directory patterns.
|
|
336
|
+
*
|
|
337
|
+
* Priority order (first match wins):
|
|
338
|
+
* 1. test-results/ (highest priority, existing behavior)
|
|
339
|
+
* 2. *-snapshots/ (Playwright default pattern)
|
|
340
|
+
* 3. __screenshots__/ (common custom pattern)
|
|
341
|
+
* 4. __snapshots__/ (common custom pattern)
|
|
342
|
+
* 5. screenshots/ (simple custom pattern)
|
|
343
|
+
* 6. snapshots/ (simple custom pattern)
|
|
344
|
+
*
|
|
345
|
+
* @param attachmentPath - Absolute or relative path to attachment
|
|
346
|
+
* @returns Normalized path starting from the matched directory, or original path if no match
|
|
347
|
+
*/
|
|
348
|
+
normalizeAttachmentPath(attachmentPath) {
|
|
349
|
+
const normalizedPath = attachmentPath.replace(/\\/g, "/");
|
|
350
|
+
const testResultsIndex = normalizedPath.indexOf("test-results/");
|
|
351
|
+
if (testResultsIndex !== -1) {
|
|
352
|
+
return normalizedPath.substring(testResultsIndex);
|
|
353
|
+
}
|
|
354
|
+
const snapshotsIndex = normalizedPath.indexOf("-snapshots/");
|
|
355
|
+
if (snapshotsIndex !== -1) {
|
|
356
|
+
const pathBeforeSnapshots = normalizedPath.substring(0, snapshotsIndex);
|
|
357
|
+
const lastSlashIndex = pathBeforeSnapshots.lastIndexOf("/");
|
|
358
|
+
const startIndex = lastSlashIndex !== -1 ? lastSlashIndex + 1 : 0;
|
|
359
|
+
return normalizedPath.substring(startIndex);
|
|
360
|
+
}
|
|
361
|
+
const patterns = ["__screenshots__/", "__snapshots__/", "screenshots/", "snapshots/"];
|
|
362
|
+
for (const pattern of patterns) {
|
|
363
|
+
const matchIndex = normalizedPath.indexOf(pattern);
|
|
364
|
+
if (matchIndex !== -1) {
|
|
365
|
+
return normalizedPath.substring(matchIndex);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
console.warn(`[Checkly Reporter] Could not normalize attachment path: ${attachmentPath}`);
|
|
369
|
+
return attachmentPath;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// src/reporter.ts
|
|
374
|
+
import * as fs3 from "fs";
|
|
375
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
376
|
+
import * as path3 from "path";
|
|
377
|
+
import { dirname, join as join3 } from "path";
|
|
378
|
+
import { fileURLToPath } from "url";
|
|
379
|
+
|
|
380
|
+
// ../clients/src/checkly-client.ts
|
|
381
|
+
import axios from "axios";
|
|
382
|
+
|
|
383
|
+
// ../clients/src/errors.ts
|
|
384
|
+
var ApiError = class extends Error {
|
|
385
|
+
data;
|
|
386
|
+
constructor(data, options) {
|
|
387
|
+
super(data.message, options);
|
|
388
|
+
this.name = this.constructor.name;
|
|
389
|
+
this.data = data;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
var ValidationError = class extends ApiError {
|
|
393
|
+
constructor(data, options) {
|
|
394
|
+
super(data, options);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
var UnauthorizedError = class extends ApiError {
|
|
398
|
+
constructor(data, options) {
|
|
399
|
+
super(data, options);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
var ForbiddenError = class extends ApiError {
|
|
403
|
+
constructor(data, options) {
|
|
404
|
+
super(data, options);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
var NotFoundError = class extends ApiError {
|
|
408
|
+
constructor(data, options) {
|
|
409
|
+
super(data, options);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
var RequestTimeoutError = class extends ApiError {
|
|
413
|
+
constructor(data, options) {
|
|
414
|
+
super(data, options);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
var ConflictError = class extends ApiError {
|
|
418
|
+
constructor(data, options) {
|
|
419
|
+
super(data, options);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
var ServerError = class extends ApiError {
|
|
423
|
+
constructor(data, options) {
|
|
424
|
+
super(data, options);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
var MiscellaneousError = class extends ApiError {
|
|
428
|
+
constructor(data, options) {
|
|
429
|
+
super(data, options);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
var MissingResponseError = class extends Error {
|
|
433
|
+
constructor(message, options) {
|
|
434
|
+
super(message, options);
|
|
435
|
+
this.name = "MissingResponseError";
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
function parseErrorData(data, options) {
|
|
439
|
+
if (!data) {
|
|
440
|
+
return void 0;
|
|
441
|
+
}
|
|
442
|
+
if (typeof data === "object" && data.statusCode && data.error && data.message) {
|
|
443
|
+
return {
|
|
444
|
+
statusCode: data.statusCode,
|
|
445
|
+
error: data.error,
|
|
446
|
+
message: data.message,
|
|
447
|
+
errorCode: data.errorCode
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (typeof data === "object" && data.error && !data.message) {
|
|
451
|
+
return {
|
|
452
|
+
statusCode: options.statusCode,
|
|
453
|
+
error: data.error,
|
|
454
|
+
message: data.error
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
if (typeof data === "object" && data.error && data.message) {
|
|
458
|
+
return {
|
|
459
|
+
statusCode: options.statusCode,
|
|
460
|
+
error: data.error,
|
|
461
|
+
message: data.message,
|
|
462
|
+
errorCode: data.errorCode
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
if (typeof data === "object" && data.message) {
|
|
466
|
+
return {
|
|
467
|
+
statusCode: options.statusCode,
|
|
468
|
+
error: data.message,
|
|
469
|
+
message: data.message,
|
|
470
|
+
errorCode: data.errorCode
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (typeof data === "string") {
|
|
474
|
+
return {
|
|
475
|
+
statusCode: options.statusCode,
|
|
476
|
+
error: data,
|
|
477
|
+
message: data
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
return void 0;
|
|
481
|
+
}
|
|
482
|
+
function handleErrorResponse(err) {
|
|
483
|
+
if (!err.response) {
|
|
484
|
+
throw new MissingResponseError(err.message || "Network error");
|
|
485
|
+
}
|
|
486
|
+
const { status, data } = err.response;
|
|
487
|
+
const errorData = parseErrorData(data, { statusCode: status });
|
|
488
|
+
if (!errorData) {
|
|
489
|
+
throw new MiscellaneousError({
|
|
490
|
+
statusCode: status,
|
|
491
|
+
error: "Unknown error",
|
|
492
|
+
message: err.message || "An error occurred"
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
switch (status) {
|
|
496
|
+
case 400:
|
|
497
|
+
throw new ValidationError(errorData);
|
|
498
|
+
case 401:
|
|
499
|
+
throw new UnauthorizedError(errorData);
|
|
500
|
+
case 403:
|
|
501
|
+
throw new ForbiddenError(errorData);
|
|
502
|
+
case 404:
|
|
503
|
+
throw new NotFoundError(errorData);
|
|
504
|
+
case 408:
|
|
505
|
+
throw new RequestTimeoutError(errorData);
|
|
506
|
+
case 409:
|
|
507
|
+
throw new ConflictError(errorData);
|
|
508
|
+
default:
|
|
509
|
+
if (status >= 500) {
|
|
510
|
+
throw new ServerError(errorData);
|
|
511
|
+
}
|
|
512
|
+
throw new MiscellaneousError(errorData);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ../clients/src/checkly-client.ts
|
|
517
|
+
function getVersion() {
|
|
518
|
+
return "0.1.0";
|
|
519
|
+
}
|
|
520
|
+
function createRequestInterceptor(apiKey, accountId) {
|
|
521
|
+
return (config) => {
|
|
522
|
+
if (config.headers) {
|
|
523
|
+
config.headers.Authorization = `Bearer ${apiKey}`;
|
|
524
|
+
config.headers["x-checkly-account"] = accountId;
|
|
525
|
+
config.headers["User-Agent"] = `@checkly/playwright-reporter/${getVersion()}`;
|
|
526
|
+
}
|
|
527
|
+
return config;
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function createResponseErrorInterceptor() {
|
|
531
|
+
return (error) => {
|
|
532
|
+
handleErrorResponse(error);
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
var ChecklyClient = class {
|
|
536
|
+
apiKey;
|
|
537
|
+
baseUrl;
|
|
538
|
+
accountId;
|
|
539
|
+
api;
|
|
540
|
+
constructor(options) {
|
|
541
|
+
this.accountId = options.accountId;
|
|
542
|
+
this.apiKey = options.apiKey;
|
|
543
|
+
this.baseUrl = options.baseUrl;
|
|
544
|
+
this.api = axios.create({
|
|
545
|
+
baseURL: this.baseUrl,
|
|
546
|
+
timeout: 12e4,
|
|
547
|
+
// 120 second timeout for large uploads
|
|
548
|
+
maxContentLength: Number.POSITIVE_INFINITY,
|
|
549
|
+
// Allow large payloads
|
|
550
|
+
maxBodyLength: Number.POSITIVE_INFINITY
|
|
551
|
+
// Allow large request bodies
|
|
552
|
+
});
|
|
553
|
+
this.api.interceptors.request.use(createRequestInterceptor(this.apiKey, this.accountId));
|
|
554
|
+
this.api.interceptors.response.use((response) => response, createResponseErrorInterceptor());
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Gets the underlying axios instance
|
|
558
|
+
* Useful for creating resource-specific clients (e.g., TestResults)
|
|
559
|
+
*/
|
|
560
|
+
getAxiosInstance() {
|
|
561
|
+
return this.api;
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
// ../clients/src/test-results.ts
|
|
566
|
+
import FormData from "form-data";
|
|
567
|
+
var TestResults = class {
|
|
568
|
+
constructor(api) {
|
|
569
|
+
this.api = api;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Creates a new test session in Checkly
|
|
573
|
+
*
|
|
574
|
+
* @param request Test session creation request
|
|
575
|
+
* @returns Test session response with session ID and test result IDs
|
|
576
|
+
* @throws {ValidationError} If request data is invalid
|
|
577
|
+
* @throws {UnauthorizedError} If authentication fails
|
|
578
|
+
* @throws {ServerError} If server error occurs
|
|
579
|
+
*/
|
|
580
|
+
async createTestSession(request) {
|
|
581
|
+
const response = await this.api.post("/next/test-sessions/create", request);
|
|
582
|
+
return response.data;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Step 1: Upload test result assets to S3
|
|
586
|
+
* Streams a ZIP file containing test assets (traces, videos, screenshots)
|
|
587
|
+
*
|
|
588
|
+
* @param testSessionId ID of the test session
|
|
589
|
+
* @param testResultId ID of the test result
|
|
590
|
+
* @param assets Buffer or ReadableStream of the ZIP file
|
|
591
|
+
* @returns Upload response with assetId, region, key, and url
|
|
592
|
+
* @throws {ValidationError} If assets are invalid
|
|
593
|
+
* @throws {UnauthorizedError} If authentication fails
|
|
594
|
+
* @throws {NotFoundError} If test session or result not found
|
|
595
|
+
* @throws {PayloadTooLargeError} If assets exceed 500MB
|
|
596
|
+
* @throws {ServerError} If S3 upload fails
|
|
597
|
+
*/
|
|
598
|
+
async uploadTestResultAsset(testSessionId, testResultId, assets) {
|
|
599
|
+
const form = new FormData();
|
|
600
|
+
form.append("assets", assets, {
|
|
601
|
+
filename: "assets.zip",
|
|
602
|
+
contentType: "application/zip"
|
|
603
|
+
});
|
|
604
|
+
const response = await this.api.post(
|
|
605
|
+
`/next/test-sessions/${testSessionId}/results/${testResultId}/assets`,
|
|
606
|
+
form,
|
|
607
|
+
{
|
|
608
|
+
headers: {
|
|
609
|
+
...form.getHeaders()
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
);
|
|
613
|
+
return response.data;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Step 2: Update test result with status and optional asset reference
|
|
617
|
+
* Uses JSON payload for clean, easy-to-validate updates
|
|
618
|
+
*
|
|
619
|
+
* @param testSessionId ID of the test session
|
|
620
|
+
* @param testResultId ID of the test result to update
|
|
621
|
+
* @param request Test result update request (JSON)
|
|
622
|
+
* @returns Test result update response
|
|
623
|
+
* @throws {ValidationError} If request data is invalid
|
|
624
|
+
* @throws {UnauthorizedError} If authentication fails
|
|
625
|
+
* @throws {NotFoundError} If test session or result not found
|
|
626
|
+
* @throws {ServerError} If server error occurs
|
|
627
|
+
*/
|
|
628
|
+
async updateTestResult(testSessionId, testResultId, request) {
|
|
629
|
+
const response = await this.api.post(
|
|
630
|
+
`/next/test-sessions/${testSessionId}/results/${testResultId}`,
|
|
631
|
+
request
|
|
632
|
+
);
|
|
633
|
+
return response.data;
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
// src/reporter.ts
|
|
638
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
639
|
+
var __dirname = dirname(__filename);
|
|
640
|
+
var packageJson = JSON.parse(readFileSync3(join3(__dirname, "..", "package.json"), "utf-8"));
|
|
641
|
+
var pkgVersion = packageJson.version;
|
|
642
|
+
var pluralRules = new Intl.PluralRules("en-US");
|
|
643
|
+
var projectForms = {
|
|
644
|
+
zero: "Project",
|
|
645
|
+
one: "Project",
|
|
646
|
+
two: "Projects",
|
|
647
|
+
few: "Projects",
|
|
648
|
+
many: "Projects",
|
|
649
|
+
other: "Projects"
|
|
650
|
+
};
|
|
651
|
+
function getApiUrl(environment) {
|
|
652
|
+
const environments = {
|
|
653
|
+
local: "http://127.0.0.1:3000",
|
|
654
|
+
development: "https://api-dev.checklyhq.com",
|
|
655
|
+
staging: "https://api-test.checklyhq.com",
|
|
656
|
+
production: "https://api.checklyhq.com"
|
|
657
|
+
};
|
|
658
|
+
return environments[environment];
|
|
659
|
+
}
|
|
660
|
+
function getEnvironment(options) {
|
|
661
|
+
const envFromOptions = options?.environment;
|
|
662
|
+
const envFromEnvVar = process.env.CHECKLY_ENV;
|
|
663
|
+
const env = envFromOptions || envFromEnvVar || "production";
|
|
664
|
+
const validEnvironments = ["local", "development", "staging", "production"];
|
|
665
|
+
if (!validEnvironments.includes(env)) {
|
|
666
|
+
console.warn(`[Checkly Reporter] Invalid environment "${env}", using "production"`);
|
|
667
|
+
return "production";
|
|
668
|
+
}
|
|
669
|
+
return env;
|
|
670
|
+
}
|
|
671
|
+
function convertStepToJSON(step) {
|
|
672
|
+
return {
|
|
673
|
+
title: step.title,
|
|
674
|
+
duration: step.duration,
|
|
675
|
+
error: step.error,
|
|
676
|
+
steps: step.steps.length > 0 ? step.steps.map(convertStepToJSON) : void 0
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
function getDirectoryName() {
|
|
680
|
+
const cwd = process.cwd();
|
|
681
|
+
let dirName = path3.basename(cwd);
|
|
682
|
+
if (!dirName || dirName === "/" || dirName === ".") {
|
|
683
|
+
dirName = "playwright-tests";
|
|
684
|
+
}
|
|
685
|
+
dirName = dirName.replace(/[<>:"|?*]/g, "-");
|
|
686
|
+
if (dirName.length > 255) {
|
|
687
|
+
dirName = dirName.substring(0, 255);
|
|
688
|
+
}
|
|
689
|
+
return dirName;
|
|
690
|
+
}
|
|
691
|
+
var ChecklyReporter = class {
|
|
692
|
+
options;
|
|
693
|
+
assetCollector;
|
|
694
|
+
zipper;
|
|
695
|
+
testResults;
|
|
696
|
+
testSession;
|
|
697
|
+
startTime;
|
|
698
|
+
testCounts = {
|
|
699
|
+
passed: 0,
|
|
700
|
+
failed: 0,
|
|
701
|
+
flaky: 0
|
|
702
|
+
};
|
|
703
|
+
// Store steps per test result, keyed by "testId:retry"
|
|
704
|
+
stepsMap = /* @__PURE__ */ new Map();
|
|
705
|
+
// Store warnings per test result, keyed by "testId:retry"
|
|
706
|
+
warningsMap = /* @__PURE__ */ new Map();
|
|
707
|
+
constructor(options = {}) {
|
|
708
|
+
const environment = getEnvironment(options);
|
|
709
|
+
const baseUrl = getApiUrl(environment);
|
|
710
|
+
const apiKey = process.env.CHECKLY_API_KEY || options.apiKey;
|
|
711
|
+
const accountId = process.env.CHECKLY_ACCOUNT_ID || options.accountId;
|
|
712
|
+
this.options = {
|
|
713
|
+
accountId,
|
|
714
|
+
apiKey,
|
|
715
|
+
outputPath: options.outputPath ?? "checkly-report.zip",
|
|
716
|
+
jsonReportPath: options.jsonReportPath ?? "test-results/playwright-test-report.json",
|
|
717
|
+
testResultsDir: options.testResultsDir ?? "test-results",
|
|
718
|
+
dryRun: options.dryRun ?? false,
|
|
719
|
+
sessionName: options.sessionName
|
|
720
|
+
};
|
|
721
|
+
this.assetCollector = new AssetCollector(this.options.testResultsDir);
|
|
722
|
+
this.zipper = new Zipper({
|
|
723
|
+
outputPath: this.options.outputPath
|
|
724
|
+
});
|
|
725
|
+
if (!this.options.dryRun && this.options.apiKey && this.options.accountId) {
|
|
726
|
+
const client = new ChecklyClient({
|
|
727
|
+
apiKey: this.options.apiKey,
|
|
728
|
+
accountId: this.options.accountId,
|
|
729
|
+
baseUrl
|
|
730
|
+
});
|
|
731
|
+
this.testResults = new TestResults(client.getAxiosInstance());
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Resolves the session name from options
|
|
736
|
+
* Supports string, callback function, or falls back to default
|
|
737
|
+
*/
|
|
738
|
+
resolveSessionName(context) {
|
|
739
|
+
const { sessionName } = this.options;
|
|
740
|
+
if (typeof sessionName === "function") {
|
|
741
|
+
return sessionName(context);
|
|
742
|
+
}
|
|
743
|
+
if (typeof sessionName === "string") {
|
|
744
|
+
return sessionName;
|
|
745
|
+
}
|
|
746
|
+
return `Playwright Test Session: ${context.directoryName}`;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Checks if test result has a trace attachment and adds context-aware warning if missing
|
|
750
|
+
* The warning type depends on the trace configuration and test result state
|
|
751
|
+
*/
|
|
752
|
+
checkTraceAttachment(test, result) {
|
|
753
|
+
const warningsKey = `${test.id}:${result.retry}`;
|
|
754
|
+
const hasTrace = result.attachments?.some(
|
|
755
|
+
(attachment) => attachment.name === "trace" || attachment.contentType === "application/zip"
|
|
756
|
+
);
|
|
757
|
+
if (hasTrace) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const traceConfig = test.parent?.project()?.use?.trace;
|
|
761
|
+
const traceMode = typeof traceConfig === "object" ? traceConfig.mode : traceConfig;
|
|
762
|
+
const isRetry = result.retry > 0;
|
|
763
|
+
const testPassed = result.status === "passed";
|
|
764
|
+
let warningType;
|
|
765
|
+
let message;
|
|
766
|
+
switch (traceMode) {
|
|
767
|
+
case void 0:
|
|
768
|
+
return;
|
|
769
|
+
case "off":
|
|
770
|
+
warningType = "trace-off";
|
|
771
|
+
message = 'Traces are disabled. Set trace: "on" in playwright.config.ts to capture traces.';
|
|
772
|
+
break;
|
|
773
|
+
case "retain-on-failure":
|
|
774
|
+
if (testPassed) {
|
|
775
|
+
warningType = "trace-retained-on-failure";
|
|
776
|
+
message = 'No trace retained because test passed. Trace mode is "retain-on-failure" which discards traces for passing tests.';
|
|
777
|
+
} else {
|
|
778
|
+
warningType = "trace-missing";
|
|
779
|
+
message = 'Trace should exist but was not found. The test failed with trace: "retain-on-failure".';
|
|
780
|
+
}
|
|
781
|
+
break;
|
|
782
|
+
case "on-first-retry":
|
|
783
|
+
if (!isRetry) {
|
|
784
|
+
warningType = "trace-first-retry-only";
|
|
785
|
+
message = 'No trace for initial attempt. Trace mode is "on-first-retry" which only records traces on the first retry.';
|
|
786
|
+
} else if (result.retry === 1) {
|
|
787
|
+
warningType = "trace-missing";
|
|
788
|
+
message = 'Trace should exist but was not found. This is the first retry with trace: "on-first-retry".';
|
|
789
|
+
} else {
|
|
790
|
+
warningType = "trace-first-retry-only";
|
|
791
|
+
message = `No trace for retry #${result.retry}. Trace mode is "on-first-retry" which only records the first retry.`;
|
|
792
|
+
}
|
|
793
|
+
break;
|
|
794
|
+
case "on-all-retries":
|
|
795
|
+
if (!isRetry) {
|
|
796
|
+
warningType = "trace-retries-only";
|
|
797
|
+
message = 'No trace for initial attempt. Trace mode is "on-all-retries" which only records traces on retries.';
|
|
798
|
+
} else {
|
|
799
|
+
warningType = "trace-missing";
|
|
800
|
+
message = `Trace should exist but was not found. This is retry #${result.retry} with trace: "on-all-retries".`;
|
|
801
|
+
}
|
|
802
|
+
break;
|
|
803
|
+
case "retain-on-first-failure":
|
|
804
|
+
if (testPassed) {
|
|
805
|
+
warningType = "trace-retained-on-first-failure";
|
|
806
|
+
message = 'No trace retained because test passed. Trace mode is "retain-on-first-failure" which discards traces for passing tests.';
|
|
807
|
+
} else if (isRetry) {
|
|
808
|
+
warningType = "trace-retained-on-first-failure";
|
|
809
|
+
message = 'No trace for retries. Trace mode is "retain-on-first-failure" which only records the first run.';
|
|
810
|
+
} else {
|
|
811
|
+
warningType = "trace-missing";
|
|
812
|
+
message = 'Trace should exist but was not found. The test failed on first run with trace: "retain-on-first-failure".';
|
|
813
|
+
}
|
|
814
|
+
break;
|
|
815
|
+
case "on":
|
|
816
|
+
warningType = "trace-missing";
|
|
817
|
+
message = 'Trace should exist but was not found. Trace mode is "on" which should always record traces.';
|
|
818
|
+
break;
|
|
819
|
+
default:
|
|
820
|
+
warningType = "trace-missing";
|
|
821
|
+
message = `No trace found. Trace mode "${traceMode}" may not be generating traces for this result.`;
|
|
822
|
+
}
|
|
823
|
+
const warnings = this.warningsMap.get(warningsKey) || [];
|
|
824
|
+
warnings.push({ type: warningType, message });
|
|
825
|
+
this.warningsMap.set(warningsKey, warnings);
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Called once before running tests
|
|
829
|
+
* Creates test session in Checkly if credentials provided
|
|
830
|
+
*/
|
|
831
|
+
onBegin(config, suite) {
|
|
832
|
+
this.startTime = /* @__PURE__ */ new Date();
|
|
833
|
+
if (!this.testResults) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
const directoryName = getDirectoryName();
|
|
838
|
+
const sessionName = this.resolveSessionName({ directoryName, config, suite });
|
|
839
|
+
const testResults = [{ name: directoryName }];
|
|
840
|
+
const repoUrl = process.env.GITHUB_REPOSITORY ? `https://github.com/${process.env.GITHUB_REPOSITORY}` : void 0;
|
|
841
|
+
const repoInfo = repoUrl ? {
|
|
842
|
+
repoUrl,
|
|
843
|
+
commitId: process.env.GITHUB_SHA,
|
|
844
|
+
branchName: process.env.GITHUB_REF_NAME,
|
|
845
|
+
commitOwner: process.env.GITHUB_ACTOR,
|
|
846
|
+
commitMessage: process.env.GITHUB_EVENT_NAME
|
|
847
|
+
} : void 0;
|
|
848
|
+
this.testResults.createTestSession({
|
|
849
|
+
name: sessionName,
|
|
850
|
+
environment: process.env.NODE_ENV || "test",
|
|
851
|
+
repoInfo,
|
|
852
|
+
startedAt: this.startTime.getTime(),
|
|
853
|
+
// Required timestamp in milliseconds
|
|
854
|
+
testResults,
|
|
855
|
+
provider: "PW_REPORTER"
|
|
856
|
+
}).then((response) => {
|
|
857
|
+
this.testSession = response;
|
|
858
|
+
}).catch((error) => {
|
|
859
|
+
console.error("[Checkly Reporter] Failed to create test session:", error.message);
|
|
860
|
+
});
|
|
861
|
+
} catch (error) {
|
|
862
|
+
console.error("[Checkly Reporter] Error in onBegin:", error);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Called for each test when it completes
|
|
867
|
+
* Captures steps and warnings, tracks test results for final status calculation
|
|
868
|
+
*/
|
|
869
|
+
onTestEnd(test, result) {
|
|
870
|
+
try {
|
|
871
|
+
this.checkTraceAttachment(test, result);
|
|
872
|
+
const stepsKey = `${test.id}:${result.retry}`;
|
|
873
|
+
if (result.steps && result.steps.length > 0) {
|
|
874
|
+
this.stepsMap.set(stepsKey, result.steps.map(convertStepToJSON));
|
|
875
|
+
}
|
|
876
|
+
const outcome = test.outcome();
|
|
877
|
+
const testIsComplete = result.retry === test.retries || outcome !== "unexpected";
|
|
878
|
+
if (!testIsComplete) {
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const isFlaky = outcome === "flaky";
|
|
882
|
+
if (isFlaky) {
|
|
883
|
+
this.testCounts.flaky++;
|
|
884
|
+
this.testCounts.passed++;
|
|
885
|
+
} else {
|
|
886
|
+
if (result.status === "passed") {
|
|
887
|
+
this.testCounts.passed++;
|
|
888
|
+
} else if (result.status === "failed" || result.status === "timedOut") {
|
|
889
|
+
this.testCounts.failed++;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
} catch (error) {
|
|
893
|
+
console.error("[Checkly Reporter] Error in onTestEnd:", error);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Called after all tests have completed
|
|
898
|
+
* This is where we create the ZIP archive and upload results
|
|
899
|
+
*/
|
|
900
|
+
async onEnd() {
|
|
901
|
+
try {
|
|
902
|
+
const jsonReportPath = this.options.jsonReportPath;
|
|
903
|
+
if (!fs3.existsSync(jsonReportPath)) {
|
|
904
|
+
console.error(`[Checkly Reporter] ERROR: JSON report not found at: ${jsonReportPath}`);
|
|
905
|
+
console.error("[Checkly Reporter] Make sure to configure the json reporter before the checkly reporter:");
|
|
906
|
+
console.error(
|
|
907
|
+
" reporter: [\n ['json', { outputFile: 'test-results/playwright-test-report.json' }],\n ['@checkly/playwright-reporter']\n ]"
|
|
908
|
+
);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const reportContent = fs3.readFileSync(jsonReportPath, "utf-8");
|
|
912
|
+
const report = JSON.parse(reportContent);
|
|
913
|
+
this.injectDataIntoReport(report);
|
|
914
|
+
fs3.writeFileSync(jsonReportPath, JSON.stringify(report, null, 2), "utf-8");
|
|
915
|
+
const assets = await this.assetCollector.collectAssets(report);
|
|
916
|
+
const result = await this.zipper.createZip(jsonReportPath, assets);
|
|
917
|
+
if (this.testResults && this.testSession) {
|
|
918
|
+
await this.uploadResults(report, result.zipPath, result.entries);
|
|
919
|
+
if (!this.options.dryRun) {
|
|
920
|
+
try {
|
|
921
|
+
fs3.unlinkSync(result.zipPath);
|
|
922
|
+
} catch (cleanupError) {
|
|
923
|
+
console.warn(`[Checkly Reporter] Warning: Could not delete ZIP file: ${cleanupError}`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (this.testResults && this.testSession?.link) {
|
|
928
|
+
this.printSummary(report, this.testSession);
|
|
929
|
+
}
|
|
930
|
+
} catch (error) {
|
|
931
|
+
console.error("[Checkly Reporter] ERROR creating report:", error);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
printSummary(report, testSession) {
|
|
935
|
+
const rule = pluralRules.select(report.config.projects.length);
|
|
936
|
+
console.log("\n======================================================\n");
|
|
937
|
+
console.log(`\u{1F99D} Checkly reporter: ${pkgVersion}`);
|
|
938
|
+
console.log(`\u{1F3AD} Playwright: ${report.config.version}`);
|
|
939
|
+
console.log(`\u{1F4D4} ${projectForms[rule]}: ${report.config.projects.map(({ name }) => name).join(",")}`);
|
|
940
|
+
console.log(`\u{1F517} Test session URL: ${testSession.link}`);
|
|
941
|
+
console.log("\n======================================================");
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Injects captured steps and warnings into the JSON report
|
|
945
|
+
* Traverses the report structure and matches by test ID + retry
|
|
946
|
+
*/
|
|
947
|
+
injectDataIntoReport(report) {
|
|
948
|
+
const processSuite = (suite) => {
|
|
949
|
+
for (const spec of suite.specs) {
|
|
950
|
+
for (const test of spec.tests) {
|
|
951
|
+
for (const result of test.results) {
|
|
952
|
+
const key = `${spec.id}:${result.retry}`;
|
|
953
|
+
const steps = this.stepsMap.get(key);
|
|
954
|
+
if (steps) {
|
|
955
|
+
result.steps = steps;
|
|
956
|
+
}
|
|
957
|
+
const warnings = this.warningsMap.get(key);
|
|
958
|
+
if (warnings && warnings.length > 0) {
|
|
959
|
+
result._checkly = { warnings };
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (suite.suites) {
|
|
965
|
+
for (const nestedSuite of suite.suites) {
|
|
966
|
+
processSuite(nestedSuite);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
for (const suite of report.suites) {
|
|
971
|
+
processSuite(suite);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Uploads test results to Checkly API
|
|
976
|
+
*/
|
|
977
|
+
async uploadResults(report, zipPath, entries) {
|
|
978
|
+
if (!this.testResults || !this.testSession) {
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
try {
|
|
982
|
+
const { failed: failedCount, flaky: flakyCount } = this.testCounts;
|
|
983
|
+
const overallStatus = failedCount > 0 ? "FAILED" : "PASSED";
|
|
984
|
+
const isDegraded = failedCount === 0 && flakyCount > 0;
|
|
985
|
+
const endTime = /* @__PURE__ */ new Date();
|
|
986
|
+
const responseTime = this.startTime ? Math.max(0, endTime.getTime() - this.startTime.getTime()) : 0;
|
|
987
|
+
const zipSizeBytes = (await fs3.promises.stat(zipPath)).size;
|
|
988
|
+
if (this.testSession.testResults.length > 0) {
|
|
989
|
+
const firstResult = this.testSession.testResults[0];
|
|
990
|
+
let assetId;
|
|
991
|
+
if (zipSizeBytes > 0) {
|
|
992
|
+
try {
|
|
993
|
+
const assets = fs3.createReadStream(zipPath);
|
|
994
|
+
const uploadResponse = await this.testResults.uploadTestResultAsset(
|
|
995
|
+
this.testSession.testSessionId,
|
|
996
|
+
firstResult.testResultId,
|
|
997
|
+
assets
|
|
998
|
+
);
|
|
999
|
+
assetId = uploadResponse.assetId;
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1002
|
+
console.error("[Checkly Reporter] Asset upload failed:", errorMessage);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
await this.testResults.updateTestResult(this.testSession.testSessionId, firstResult.testResultId, {
|
|
1006
|
+
status: overallStatus,
|
|
1007
|
+
assetEntries: assetId ? entries : void 0,
|
|
1008
|
+
isDegraded,
|
|
1009
|
+
startedAt: this.startTime?.toISOString(),
|
|
1010
|
+
stoppedAt: endTime.toISOString(),
|
|
1011
|
+
responseTime,
|
|
1012
|
+
metadata: {
|
|
1013
|
+
usageData: {
|
|
1014
|
+
s3PostTotalBytes: zipSizeBytes
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1021
|
+
console.error("[Checkly Reporter] Failed to upload results:", errorMessage);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Called when a global error occurs
|
|
1026
|
+
*/
|
|
1027
|
+
onError(error) {
|
|
1028
|
+
console.error("[Checkly Reporter] Global error:", error);
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
export {
|
|
1032
|
+
AssetCollector,
|
|
1033
|
+
ChecklyReporter,
|
|
1034
|
+
Zipper,
|
|
1035
|
+
ChecklyReporter as default
|
|
1036
|
+
};
|