@arghajit/playwright-pulse-report 0.1.1 → 0.1.3
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/README.md +37 -7
- package/dist/reporter/attachment-utils.js +54 -92
- package/dist/reporter/playwright-pulse-reporter.js +35 -34
- package/dist/types/index.d.ts +2 -0
- package/package.json +14 -5
- package/scripts/generate-static-report.mjs +6 -10
- package/scripts/sendReport.js +335 -0
package/README.md
CHANGED
|
@@ -6,13 +6,20 @@ This project provides both a custom Playwright reporter and a Next.js web dashbo
|
|
|
6
6
|
## Screenshots
|
|
7
7
|
|
|
8
8
|
### Desktop View [Click on Images to View full Image]
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
|
|
10
|
+
[](https://postimg.cc/180cym6c)
|
|
11
|
+
|
|
12
|
+
[](https://postimg.cc/V5TFRHmM)
|
|
13
|
+
|
|
14
|
+
[](https://postimg.cc/XXTwFGkk)
|
|
15
|
+
|
|
12
16
|
|
|
13
17
|
### Mobile View [Click on Images to View full Image]
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
|
|
19
|
+
[](https://postimg.cc/CzJBLR5N)
|
|
20
|
+
|
|
21
|
+
[](https://postimg.cc/G8YTczT8)
|
|
22
|
+
|
|
16
23
|
|
|
17
24
|
## How it Works
|
|
18
25
|
|
|
@@ -207,7 +214,7 @@ To work on the reporter or the dashboard itself:
|
|
|
207
214
|
|
|
208
215
|
* This project supports Playwright test execution with Pulse Reporting in GitHub Actions. Here's how Pulse reports are managed:
|
|
209
216
|
|
|
210
|
-
```
|
|
217
|
+
```bash
|
|
211
218
|
# Upload Pulse report from each shard (per matrix.config.type)
|
|
212
219
|
- name: Upload Pulse Report results
|
|
213
220
|
if: success() || failure()
|
|
@@ -241,7 +248,7 @@ To work on the reporter or the dashboard itself:
|
|
|
241
248
|
|
|
242
249
|
* This project supports sharded Playwright test execution with Pulse Reporting in GitHub Actions. Here's how Pulse reports are managed across shards:
|
|
243
250
|
|
|
244
|
-
```
|
|
251
|
+
```bash
|
|
245
252
|
# Upload Pulse report from each shard (per matrix.config.type)
|
|
246
253
|
- name: Upload Pulse Report results
|
|
247
254
|
if: success() || failure()
|
|
@@ -280,6 +287,7 @@ To work on the reporter or the dashboard itself:
|
|
|
280
287
|
name: pulse-report
|
|
281
288
|
path: pulse-report/
|
|
282
289
|
```
|
|
290
|
+
|
|
283
291
|
## 🧠 Notes:
|
|
284
292
|
|
|
285
293
|
* Each shard generates its own playwright-pulse-report.json inside pulse-report/.
|
|
@@ -287,3 +295,25 @@ To work on the reporter or the dashboard itself:
|
|
|
287
295
|
* After the test matrix completes, reports are downloaded, renamed, and merged.
|
|
288
296
|
* merge-report is a custom Node.js script that combines all JSON files into one.
|
|
289
297
|
* generate-report can build a static HTML dashboard if needed.
|
|
298
|
+
|
|
299
|
+
## Email Report:
|
|
300
|
+
|
|
301
|
+
- To use the Emailable report option, user should use .env file by installing "dotenv" package into their repository:
|
|
302
|
+
|
|
303
|
+
✅ Create a .env file in the root of your project:
|
|
304
|
+
```bash
|
|
305
|
+
SENDER_EMAIL_1=recipient1@example.com
|
|
306
|
+
SENDER_EMAIL_2=recipient2@example.com
|
|
307
|
+
SENDER_EMAIL_3=recipient3@example.com
|
|
308
|
+
SENDER_EMAIL_4=recipient4@example.com
|
|
309
|
+
SENDER_EMAIL_5=recipient5@example.com
|
|
310
|
+
```
|
|
311
|
+
Pulse Report by default supports 5 mail recipients, and by running the command `npx send-email` user can send an overall test report with the actual test report html file attached to it. The Final email report will look something like below screenshot:
|
|
312
|
+
|
|
313
|
+
[](https://postimg.cc/DmCPgtqh)
|
|
314
|
+
|
|
315
|
+
## Fixes:
|
|
316
|
+
|
|
317
|
+
### - "0.1.1" : Added Sharding Support
|
|
318
|
+
### - "0.1.2" : Fixed browser filter and Added Browser Tag in Test Suite Card
|
|
319
|
+
### - "0.1.3" : Added Emailable report option
|
|
@@ -45,100 +45,62 @@ const ATTACHMENTS_SUBDIR = "attachments"; // Consistent subdirectory name
|
|
|
45
45
|
* @param config The reporter configuration options.
|
|
46
46
|
*/
|
|
47
47
|
function attachFiles(testId, pwResult, pulseResult, config) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!fs.existsSync(testAttachmentsDir)) {
|
|
58
|
-
fs.mkdirSync(testAttachmentsDir, { recursive: true });
|
|
59
|
-
}
|
|
60
|
-
} catch (error) {
|
|
61
|
-
console.error(
|
|
62
|
-
`Pulse Reporter: Failed to create attachments directory: ${testAttachmentsDir}`,
|
|
63
|
-
error
|
|
64
|
-
);
|
|
65
|
-
return; // Stop processing if directory creation fails
|
|
66
|
-
}
|
|
67
|
-
if (!pwResult.attachments) return;
|
|
68
|
-
const { base64Images } = config; // Get base64 embedding option
|
|
69
|
-
pulseResult.screenshots = []; // Initialize screenshots array
|
|
70
|
-
pwResult.attachments.forEach((attachment) => {
|
|
71
|
-
const { contentType, name, path: attachmentPath, body } = attachment;
|
|
72
|
-
// Skip attachments without path or body
|
|
73
|
-
if (!attachmentPath && !body) {
|
|
74
|
-
console.warn(
|
|
75
|
-
`Pulse Reporter: Attachment "${name}" for test ${testId} has no path or body. Skipping.`
|
|
76
|
-
);
|
|
77
|
-
return;
|
|
48
|
+
const baseReportDir = config.outputDir || "pulse-report"; // Base output directory
|
|
49
|
+
// Ensure attachments are relative to the main outputDir
|
|
50
|
+
const attachmentsBaseDir = path.resolve(baseReportDir, ATTACHMENTS_SUBDIR); // Absolute path for FS operations
|
|
51
|
+
const attachmentsSubFolder = testId.replace(/[^a-zA-Z0-9_-]/g, "_"); // Sanitize testId for folder name
|
|
52
|
+
const testAttachmentsDir = path.join(attachmentsBaseDir, attachmentsSubFolder); // e.g., pulse-report/attachments/test_id_abc
|
|
53
|
+
try {
|
|
54
|
+
if (!fs.existsSync(testAttachmentsDir)) {
|
|
55
|
+
fs.mkdirSync(testAttachmentsDir, { recursive: true });
|
|
56
|
+
}
|
|
78
57
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
? path.extname(attachmentPath)
|
|
83
|
-
: `.${getFileExtension(contentType)}`;
|
|
84
|
-
const baseFilename = attachmentPath
|
|
85
|
-
? path.basename(attachmentPath, extension)
|
|
86
|
-
: safeName;
|
|
87
|
-
// Ensure unique filename within the test's attachment folder
|
|
88
|
-
const fileName = `${baseFilename}_${Date.now()}${extension}`;
|
|
89
|
-
// Relative path for storing in JSON (relative to baseReportDir)
|
|
90
|
-
const relativePath = path.join(
|
|
91
|
-
ATTACHMENTS_SUBDIR,
|
|
92
|
-
attachmentsSubFolder,
|
|
93
|
-
fileName
|
|
94
|
-
);
|
|
95
|
-
// Full path for file system operations
|
|
96
|
-
const fullPath = path.join(testAttachmentsDir, fileName);
|
|
97
|
-
if (
|
|
98
|
-
contentType === null || contentType === void 0
|
|
99
|
-
? void 0
|
|
100
|
-
: contentType.startsWith("image/")
|
|
101
|
-
) {
|
|
102
|
-
// Handle all image types consistently
|
|
103
|
-
handleImage(
|
|
104
|
-
attachmentPath,
|
|
105
|
-
body,
|
|
106
|
-
base64Images,
|
|
107
|
-
fullPath,
|
|
108
|
-
relativePath,
|
|
109
|
-
pulseResult,
|
|
110
|
-
name
|
|
111
|
-
);
|
|
112
|
-
} else if (
|
|
113
|
-
name === "video" ||
|
|
114
|
-
(contentType === null || contentType === void 0
|
|
115
|
-
? void 0
|
|
116
|
-
: contentType.startsWith("video/"))
|
|
117
|
-
) {
|
|
118
|
-
handleAttachment(
|
|
119
|
-
attachmentPath,
|
|
120
|
-
body,
|
|
121
|
-
fullPath,
|
|
122
|
-
relativePath,
|
|
123
|
-
"videoPath",
|
|
124
|
-
pulseResult
|
|
125
|
-
);
|
|
126
|
-
} else if (name === "trace" || contentType === "application/zip") {
|
|
127
|
-
// Trace files are zips
|
|
128
|
-
handleAttachment(
|
|
129
|
-
attachmentPath,
|
|
130
|
-
body,
|
|
131
|
-
fullPath,
|
|
132
|
-
relativePath,
|
|
133
|
-
"tracePath",
|
|
134
|
-
pulseResult
|
|
135
|
-
);
|
|
136
|
-
} else {
|
|
137
|
-
// Handle other generic attachments if needed (e.g., log files)
|
|
138
|
-
// console.log(`Pulse Reporter: Processing generic attachment "${name}" (Type: ${contentType}) for test ${testId}`);
|
|
139
|
-
// handleAttachment(attachmentPath, body, fullPath, relativePath, 'otherAttachments', pulseResult); // Example for storing other types
|
|
58
|
+
catch (error) {
|
|
59
|
+
console.error(`Pulse Reporter: Failed to create attachments directory: ${testAttachmentsDir}`, error);
|
|
60
|
+
return; // Stop processing if directory creation fails
|
|
140
61
|
}
|
|
141
|
-
|
|
62
|
+
if (!pwResult.attachments)
|
|
63
|
+
return;
|
|
64
|
+
const { base64Images } = config; // Get base64 embedding option
|
|
65
|
+
pulseResult.screenshots = []; // Initialize screenshots array
|
|
66
|
+
pwResult.attachments.forEach((attachment) => {
|
|
67
|
+
const { contentType, name, path: attachmentPath, body } = attachment;
|
|
68
|
+
// Skip attachments without path or body
|
|
69
|
+
if (!attachmentPath && !body) {
|
|
70
|
+
console.warn(`Pulse Reporter: Attachment "${name}" for test ${testId} has no path or body. Skipping.`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Determine filename
|
|
74
|
+
const safeName = name.replace(/[^a-zA-Z0-9_.-]/g, "_"); // Sanitize original name
|
|
75
|
+
const extension = attachmentPath
|
|
76
|
+
? path.extname(attachmentPath)
|
|
77
|
+
: `.${getFileExtension(contentType)}`;
|
|
78
|
+
const baseFilename = attachmentPath
|
|
79
|
+
? path.basename(attachmentPath, extension)
|
|
80
|
+
: safeName;
|
|
81
|
+
// Ensure unique filename within the test's attachment folder
|
|
82
|
+
const fileName = `${baseFilename}_${Date.now()}${extension}`;
|
|
83
|
+
// Relative path for storing in JSON (relative to baseReportDir)
|
|
84
|
+
const relativePath = path.join(ATTACHMENTS_SUBDIR, attachmentsSubFolder, fileName);
|
|
85
|
+
// Full path for file system operations
|
|
86
|
+
const fullPath = path.join(testAttachmentsDir, fileName);
|
|
87
|
+
if (contentType === null || contentType === void 0 ? void 0 : contentType.startsWith("image/")) {
|
|
88
|
+
// Handle all image types consistently
|
|
89
|
+
handleImage(attachmentPath, body, base64Images, fullPath, relativePath, pulseResult, name);
|
|
90
|
+
}
|
|
91
|
+
else if (name === "video" || (contentType === null || contentType === void 0 ? void 0 : contentType.startsWith("video/"))) {
|
|
92
|
+
handleAttachment(attachmentPath, body, fullPath, relativePath, "videoPath", pulseResult);
|
|
93
|
+
}
|
|
94
|
+
else if (name === "trace" || contentType === "application/zip") {
|
|
95
|
+
// Trace files are zips
|
|
96
|
+
handleAttachment(attachmentPath, body, fullPath, relativePath, "tracePath", pulseResult);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
// Handle other generic attachments if needed (e.g., log files)
|
|
100
|
+
// console.log(`Pulse Reporter: Processing generic attachment "${name}" (Type: ${contentType}) for test ${testId}`);
|
|
101
|
+
// handleAttachment(attachmentPath, body, fullPath, relativePath, 'otherAttachments', pulseResult); // Example for storing other types
|
|
102
|
+
}
|
|
103
|
+
});
|
|
142
104
|
}
|
|
143
105
|
/**
|
|
144
106
|
* Handles image attachments, either embedding as base64 or copying the file.
|
|
@@ -72,10 +72,7 @@ class PlaywrightPulseReporter {
|
|
|
72
72
|
this.baseOutputFile = (_a = options.outputFile) !== null && _a !== void 0 ? _a : this.baseOutputFile;
|
|
73
73
|
// Determine outputDir relative to config file or rootDir
|
|
74
74
|
// The actual resolution happens in onBegin where config is available
|
|
75
|
-
this.outputDir =
|
|
76
|
-
(_b = options.outputDir) !== null && _b !== void 0
|
|
77
|
-
? _b
|
|
78
|
-
: "pulse-report";
|
|
75
|
+
this.outputDir = (_b = options.outputDir) !== null && _b !== void 0 ? _b : "pulse-report";
|
|
79
76
|
this.attachmentsDir = path.join(this.outputDir, ATTACHMENTS_SUBDIR); // Initial path, resolved fully in onBegin
|
|
80
77
|
// console.log(`Pulse Reporter Init: Configured outputDir option: ${options.outputDir}, Base file: ${this.baseOutputFile}`);
|
|
81
78
|
}
|
|
@@ -93,12 +90,7 @@ class PlaywrightPulseReporter {
|
|
|
93
90
|
const configFileDir = this.config.configFile
|
|
94
91
|
? path.dirname(this.config.configFile)
|
|
95
92
|
: configDir;
|
|
96
|
-
this.outputDir = path.resolve(
|
|
97
|
-
configFileDir,
|
|
98
|
-
(_a = this.options.outputDir) !== null && _a !== void 0
|
|
99
|
-
? _a
|
|
100
|
-
: "pulse-report"
|
|
101
|
-
);
|
|
93
|
+
this.outputDir = path.resolve(configFileDir, (_a = this.options.outputDir) !== null && _a !== void 0 ? _a : "pulse-report");
|
|
102
94
|
// Resolve attachmentsDir relative to the final outputDir
|
|
103
95
|
this.attachmentsDir = path.resolve(this.outputDir, ATTACHMENTS_SUBDIR);
|
|
104
96
|
// Update options with the resolved absolute path for internal use
|
|
@@ -129,7 +121,7 @@ class PlaywrightPulseReporter {
|
|
|
129
121
|
// Optional: Log test start if needed
|
|
130
122
|
// console.log(`Starting test: ${test.title}`);
|
|
131
123
|
}
|
|
132
|
-
async processStep(step, testId) {
|
|
124
|
+
async processStep(step, testId, browserName) {
|
|
133
125
|
var _a, _b, _c, _d;
|
|
134
126
|
// Determine actual step status (don't inherit from parent)
|
|
135
127
|
let stepStatus = "passed";
|
|
@@ -150,26 +142,34 @@ class PlaywrightPulseReporter {
|
|
|
150
142
|
codeLocation = `${path.relative(this.config.rootDir, step.location.file)}:${step.location.line}:${step.location.column}`;
|
|
151
143
|
}
|
|
152
144
|
return {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
145
|
+
id: `${testId}_step_${startTime.toISOString()}-${duration}-${(0,
|
|
146
|
+
crypto_1.randomUUID)()}--581d5ad8-ce75-4ca5-94a6-ed29c466c815`,
|
|
147
|
+
title: step.title,
|
|
148
|
+
status: stepStatus,
|
|
149
|
+
duration: duration,
|
|
150
|
+
startTime: startTime,
|
|
151
|
+
endTime: endTime,
|
|
152
|
+
browser: browserName,
|
|
153
|
+
errorMessage: errorMessage,
|
|
154
|
+
stackTrace:
|
|
155
|
+
((_d = step.error) === null || _d === void 0 ? void 0 : _d.stack) ||
|
|
156
|
+
undefined,
|
|
157
|
+
codeLocation: codeLocation || undefined,
|
|
158
|
+
isHook: step.category === "hook",
|
|
159
|
+
hookType:
|
|
160
|
+
step.category === "hook"
|
|
161
|
+
? step.title.toLowerCase().includes("before")
|
|
162
|
+
? "before"
|
|
163
|
+
: "after"
|
|
164
|
+
: undefined,
|
|
165
|
+
steps: [], // Will be populated recursively
|
|
169
166
|
};
|
|
170
167
|
}
|
|
171
168
|
async onTestEnd(test, result) {
|
|
172
|
-
var _a, _b, _c, _d, _e, _f, _g;
|
|
169
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
170
|
+
// Get the most accurate browser name
|
|
171
|
+
const project = (_a = test.parent) === null || _a === void 0 ? void 0 : _a.project();
|
|
172
|
+
const browserName = ((_b = project === null || project === void 0 ? void 0 : project.use) === null || _b === void 0 ? void 0 : _b.defaultBrowserType) || "unknown";
|
|
173
173
|
const testStatus = convertStatus(result.status, test);
|
|
174
174
|
const startTime = new Date(result.startTime);
|
|
175
175
|
const endTime = new Date(startTime.getTime() + result.duration);
|
|
@@ -183,7 +183,7 @@ class PlaywrightPulseReporter {
|
|
|
183
183
|
const processAllSteps = async (steps, parentTestStatus) => {
|
|
184
184
|
let processed = [];
|
|
185
185
|
for (const step of steps) {
|
|
186
|
-
const processedStep = await this.processStep(step, testIdForFiles);
|
|
186
|
+
const processedStep = await this.processStep(step, testIdForFiles, browserName);
|
|
187
187
|
processed.push(processedStep);
|
|
188
188
|
if (step.steps && step.steps.length > 0) {
|
|
189
189
|
const nestedSteps = await processAllSteps(step.steps, processedStep.status);
|
|
@@ -196,7 +196,7 @@ class PlaywrightPulseReporter {
|
|
|
196
196
|
// --- Extract Code Snippet ---
|
|
197
197
|
let codeSnippet = undefined;
|
|
198
198
|
try {
|
|
199
|
-
if (((
|
|
199
|
+
if (((_c = test.location) === null || _c === void 0 ? void 0 : _c.file) && ((_d = test.location) === null || _d === void 0 ? void 0 : _d.line) && ((_e = test.location) === null || _e === void 0 ? void 0 : _e.column)) {
|
|
200
200
|
const relativePath = path.relative(this.config.rootDir, test.location.file);
|
|
201
201
|
codeSnippet = `Test defined at: ${relativePath}:${test.location.line}:${test.location.column}`;
|
|
202
202
|
}
|
|
@@ -209,17 +209,18 @@ class PlaywrightPulseReporter {
|
|
|
209
209
|
id: test.id || `${test.title}-${startTime.toISOString()}-${(0, crypto_1.randomUUID)()}`, // Use the original ID logic here
|
|
210
210
|
runId: "TBD", // Will be set later
|
|
211
211
|
name: test.titlePath().join(" > "),
|
|
212
|
-
suiteName: ((
|
|
212
|
+
suiteName: ((_f = this.config.projects[0]) === null || _f === void 0 ? void 0 : _f.name) || "Default Suite",
|
|
213
213
|
status: testStatus,
|
|
214
214
|
duration: result.duration,
|
|
215
215
|
startTime: startTime,
|
|
216
216
|
endTime: endTime,
|
|
217
|
+
browser: browserName,
|
|
217
218
|
retries: result.retry,
|
|
218
|
-
steps: ((
|
|
219
|
+
steps: ((_g = result.steps) === null || _g === void 0 ? void 0 : _g.length)
|
|
219
220
|
? await processAllSteps(result.steps, testStatus)
|
|
220
221
|
: [],
|
|
221
|
-
errorMessage: (
|
|
222
|
-
stackTrace: (
|
|
222
|
+
errorMessage: (_h = result.error) === null || _h === void 0 ? void 0 : _h.message,
|
|
223
|
+
stackTrace: (_j = result.error) === null || _j === void 0 ? void 0 : _j.stack,
|
|
223
224
|
codeSnippet: codeSnippet,
|
|
224
225
|
tags: test.tags.map((tag) => tag.startsWith("@") ? tag.substring(1) : tag),
|
|
225
226
|
screenshots: [],
|
package/dist/types/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface TestStep {
|
|
|
7
7
|
duration: number;
|
|
8
8
|
startTime: Date;
|
|
9
9
|
endTime: Date;
|
|
10
|
+
browser: string;
|
|
10
11
|
errorMessage?: string;
|
|
11
12
|
stackTrace?: string;
|
|
12
13
|
codeLocation?: string;
|
|
@@ -29,6 +30,7 @@ export interface TestResult {
|
|
|
29
30
|
tags?: string[];
|
|
30
31
|
suiteName?: string;
|
|
31
32
|
runId: string;
|
|
33
|
+
browser: string;
|
|
32
34
|
screenshots?: string[];
|
|
33
35
|
videoPath?: string;
|
|
34
36
|
tracePath?: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arghajit/playwright-pulse-report",
|
|
3
3
|
"author": "Arghajit Singha",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.3",
|
|
5
5
|
"description": "A Playwright reporter and dashboard for visualizing test results.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"playwright",
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
"reporting",
|
|
12
12
|
"nextjs",
|
|
13
13
|
"playwright-pulse",
|
|
14
|
-
"report"
|
|
14
|
+
"report",
|
|
15
|
+
"email-report",
|
|
16
|
+
"send-report",
|
|
17
|
+
"email"
|
|
15
18
|
],
|
|
16
19
|
"main": "dist/reporter/index.js",
|
|
17
20
|
"types": "dist/reporter/index.d.ts",
|
|
@@ -22,7 +25,8 @@
|
|
|
22
25
|
"license": "MIT",
|
|
23
26
|
"bin": {
|
|
24
27
|
"generate-pulse-report": "./scripts/generate-static-report.mjs",
|
|
25
|
-
"merge-pulse-report": "./scripts/merge-pulse-report.js"
|
|
28
|
+
"merge-pulse-report": "./scripts/merge-pulse-report.js",
|
|
29
|
+
"send-email": "./scripts/sendReport.js"
|
|
26
30
|
},
|
|
27
31
|
"exports": {
|
|
28
32
|
".": {
|
|
@@ -41,7 +45,9 @@
|
|
|
41
45
|
"lint": "next lint",
|
|
42
46
|
"typecheck": "tsc --noEmit",
|
|
43
47
|
"prepublishOnly": "npm run build:reporter",
|
|
44
|
-
"report:static": "node ./scripts/generate-static-report.mjs"
|
|
48
|
+
"report:static": "node ./scripts/generate-static-report.mjs",
|
|
49
|
+
"report:merge": "node ./scripts/merge-pulse-report.js",
|
|
50
|
+
"report:email": "node ./scripts/sendReport.js"
|
|
45
51
|
},
|
|
46
52
|
"dependencies": {
|
|
47
53
|
"@genkit-ai/googleai": "^1.6.2",
|
|
@@ -86,7 +92,10 @@
|
|
|
86
92
|
"recharts": "^2.15.1",
|
|
87
93
|
"tailwind-merge": "^3.0.1",
|
|
88
94
|
"tailwindcss-animate": "^1.0.7",
|
|
89
|
-
"zod": "^3.24.2"
|
|
95
|
+
"zod": "^3.24.2",
|
|
96
|
+
"archiver": "^7.0.1",
|
|
97
|
+
"dotenv": "^16.5.0",
|
|
98
|
+
"nodemailer": "^7.0.3"
|
|
90
99
|
},
|
|
91
100
|
"devDependencies": {
|
|
92
101
|
"@types/node": "^20",
|
|
@@ -221,7 +221,7 @@ function getSuitesData(results) {
|
|
|
221
221
|
const suitesMap = new Map();
|
|
222
222
|
|
|
223
223
|
results.forEach((test) => {
|
|
224
|
-
const browser = test.
|
|
224
|
+
const browser = test.browser; // Extract browser (chromium/firefox/webkit)
|
|
225
225
|
const suiteName = test.suiteName;
|
|
226
226
|
const key = `${suiteName}|${browser}`;
|
|
227
227
|
|
|
@@ -231,6 +231,7 @@ function getSuitesData(results) {
|
|
|
231
231
|
name: `${suiteName} (${browser})`,
|
|
232
232
|
status: test.status,
|
|
233
233
|
count: 0,
|
|
234
|
+
browser: browser,
|
|
234
235
|
});
|
|
235
236
|
}
|
|
236
237
|
suitesMap.get(key).count++;
|
|
@@ -267,6 +268,7 @@ function generateSuitesWidget(suitesData) {
|
|
|
267
268
|
suite.count !== 1 ? "s" : ""
|
|
268
269
|
}</span>
|
|
269
270
|
</div>
|
|
271
|
+
<span class="browser-name">${suite.browser}</span>
|
|
270
272
|
</div>
|
|
271
273
|
`
|
|
272
274
|
)
|
|
@@ -469,8 +471,7 @@ function generateHTML(reportData) {
|
|
|
469
471
|
|
|
470
472
|
return results
|
|
471
473
|
.map((test, index) => {
|
|
472
|
-
const
|
|
473
|
-
const browser = browserMatch ? browserMatch[1] : "unknown";
|
|
474
|
+
const browser = test.browser || "unknown";
|
|
474
475
|
const testName = test.name.split(" > ").pop() || test.name;
|
|
475
476
|
|
|
476
477
|
// Generate steps HTML recursively
|
|
@@ -1272,12 +1273,7 @@ function generateHTML(reportData) {
|
|
|
1272
1273
|
<select id="filter-browser">
|
|
1273
1274
|
<option value="">All Browsers</option>
|
|
1274
1275
|
${Array.from(
|
|
1275
|
-
new Set(
|
|
1276
|
-
results.map((test) => {
|
|
1277
|
-
const match = test.name.match(/ > (\w+) > /);
|
|
1278
|
-
return match ? match[1] : "unknown";
|
|
1279
|
-
})
|
|
1280
|
-
)
|
|
1276
|
+
new Set(results.map((test) => test.browser || "unknown"))
|
|
1281
1277
|
)
|
|
1282
1278
|
.map(
|
|
1283
1279
|
(browser) => `
|
|
@@ -1533,4 +1529,4 @@ async function main() {
|
|
|
1533
1529
|
}
|
|
1534
1530
|
|
|
1535
1531
|
// Run the main function
|
|
1536
|
-
main();
|
|
1532
|
+
main();
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const nodemailer = require("nodemailer");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const archiver = require("archiver");
|
|
5
|
+
const fileSystem = require("fs");
|
|
6
|
+
const reportDir = "./pulse-report";
|
|
7
|
+
|
|
8
|
+
require("dotenv").config();
|
|
9
|
+
|
|
10
|
+
let fetch;
|
|
11
|
+
import("node-fetch")
|
|
12
|
+
.then((module) => {
|
|
13
|
+
fetch = module.default;
|
|
14
|
+
})
|
|
15
|
+
.catch((err) => {
|
|
16
|
+
console.error("Failed to import node-fetch:", err);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
let projectName;
|
|
21
|
+
|
|
22
|
+
function getUUID() {
|
|
23
|
+
const reportPath = path.join(
|
|
24
|
+
process.cwd(),
|
|
25
|
+
`${reportDir}/playwright-pulse-report.json`
|
|
26
|
+
);
|
|
27
|
+
console.log("Report path:", reportPath);
|
|
28
|
+
|
|
29
|
+
if (!fileSystem.existsSync(reportPath)) {
|
|
30
|
+
throw new Error("Pulse report file not found.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const content = JSON.parse(fileSystem.readFileSync(reportPath, "utf-8"));
|
|
34
|
+
const idString = content.run.id;
|
|
35
|
+
const parts = idString.split("-");
|
|
36
|
+
const uuid = parts.slice(-5).join("-");
|
|
37
|
+
return uuid;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatDuration(ms) {
|
|
41
|
+
const seconds = (ms / 1000).toFixed(2);
|
|
42
|
+
if (ms < 1000) return `${ms}ms`;
|
|
43
|
+
if (ms < 60000) return `${seconds}s`;
|
|
44
|
+
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}min`;
|
|
45
|
+
return `${(ms / 3600000).toFixed(1)}h`;
|
|
46
|
+
}
|
|
47
|
+
const formatStartTime = (isoString) => {
|
|
48
|
+
const date = new Date(isoString);
|
|
49
|
+
return date.toLocaleString(); // Default locale
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Generate test-data from allure report
|
|
53
|
+
const getPulseReportSummary = () => {
|
|
54
|
+
const reportPath = path.join(
|
|
55
|
+
process.cwd(),
|
|
56
|
+
`${reportDir}/playwright-pulse-report.json`
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (!fileSystem.existsSync(reportPath)) {
|
|
60
|
+
throw new Error("Pulse report file not found.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const content = JSON.parse(fileSystem.readFileSync(reportPath, "utf-8"));
|
|
64
|
+
const run = content.run;
|
|
65
|
+
|
|
66
|
+
const total = run.totalTests || 0;
|
|
67
|
+
const passed = run.passed || 0;
|
|
68
|
+
const failed = run.failed || 0;
|
|
69
|
+
const skipped = run.skipped || 0;
|
|
70
|
+
const duration = (run.duration || 0) / 1000; // Convert ms to seconds
|
|
71
|
+
|
|
72
|
+
const readableStartTime = new Date(run.timestamp).toLocaleString();
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
total,
|
|
76
|
+
passed,
|
|
77
|
+
failed,
|
|
78
|
+
skipped,
|
|
79
|
+
passedPercentage: total ? ((passed / total) * 100).toFixed(2) : "0.00",
|
|
80
|
+
failedPercentage: total ? ((failed / total) * 100).toFixed(2) : "0.00",
|
|
81
|
+
skippedPercentage: total ? ((skipped / total) * 100).toFixed(2) : "0.00",
|
|
82
|
+
startTime: readableStartTime,
|
|
83
|
+
duration: formatDuration(duration),
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// sleep function for javascript file
|
|
88
|
+
const delay = (time) => new Promise((resolve) => setTimeout(resolve, time));
|
|
89
|
+
// Function to zip the folder asynchronously using async/await
|
|
90
|
+
const zipFolder = async (folderPath, zipPath) => {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const output = fileSystem.createWriteStream(zipPath); // Must use require("fs") directly here
|
|
93
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
94
|
+
|
|
95
|
+
output.on("close", () => {
|
|
96
|
+
console.log(`${archive.pointer()} total bytes`);
|
|
97
|
+
console.log("Folder has been zipped successfully.");
|
|
98
|
+
resolve(); // Resolve the promise after zipping is complete
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
archive.on("error", (err) => {
|
|
102
|
+
reject(err); // Reject the promise in case of an error
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
archive.pipe(output);
|
|
106
|
+
archive.directory(folderPath, false); // Zip the folder without the parent folder
|
|
107
|
+
archive.finalize(); // Finalize the archive
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Function to convert JSON data to HTML table format
|
|
112
|
+
const generateHtmlTable = (data) => {
|
|
113
|
+
projectName = "Pulse Emailable Report";
|
|
114
|
+
const stats = data;
|
|
115
|
+
const total = stats.passed + stats.failed + stats.skipped;
|
|
116
|
+
const passedTests = stats.passed;
|
|
117
|
+
const passedPercentage = stats.passedPercentage;
|
|
118
|
+
const failedTests = stats.failed;
|
|
119
|
+
const failedPercentage = stats.failedPercentage;
|
|
120
|
+
const skippedTests = stats.skipped;
|
|
121
|
+
const skippedPercentage = stats.skippedPercentage;
|
|
122
|
+
const startTime = stats.startTime;
|
|
123
|
+
const durationSeconds = stats.duration;
|
|
124
|
+
|
|
125
|
+
return `
|
|
126
|
+
<!DOCTYPE html>
|
|
127
|
+
<html lang="en">
|
|
128
|
+
<head>
|
|
129
|
+
<meta charset="UTF-8">
|
|
130
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
131
|
+
<title>Test Stats Report</title>
|
|
132
|
+
<style>
|
|
133
|
+
table {
|
|
134
|
+
width: 100%;
|
|
135
|
+
border-collapse: collapse;
|
|
136
|
+
}
|
|
137
|
+
table, th, td {
|
|
138
|
+
border: 1px solid black;
|
|
139
|
+
}
|
|
140
|
+
th, td {
|
|
141
|
+
padding: 8px;
|
|
142
|
+
text-align: left;
|
|
143
|
+
}
|
|
144
|
+
th {
|
|
145
|
+
background-color: #f2f2f2;
|
|
146
|
+
}
|
|
147
|
+
</style>
|
|
148
|
+
</head>
|
|
149
|
+
<body>
|
|
150
|
+
<h1>${projectName} Statistics</h1>
|
|
151
|
+
<table>
|
|
152
|
+
<thead>
|
|
153
|
+
<tr>
|
|
154
|
+
<th>Metric</th>
|
|
155
|
+
<th>Value</th>
|
|
156
|
+
</tr>
|
|
157
|
+
</thead>
|
|
158
|
+
<tbody>
|
|
159
|
+
<tr>
|
|
160
|
+
<td>Test Start Time</td>
|
|
161
|
+
<td>${startTime}</td>
|
|
162
|
+
</tr>
|
|
163
|
+
<tr>
|
|
164
|
+
<td>Test Run Duration (Seconds)</td>
|
|
165
|
+
<td>${durationSeconds}</td>
|
|
166
|
+
</tr>
|
|
167
|
+
<tr>
|
|
168
|
+
<td>Total Tests Count</td>
|
|
169
|
+
<td>${total}</td>
|
|
170
|
+
</tr>
|
|
171
|
+
<tr>
|
|
172
|
+
<td>Tests Passed</td>
|
|
173
|
+
<td>${passedTests} (${passedPercentage}%)</td>
|
|
174
|
+
</tr>
|
|
175
|
+
<tr>
|
|
176
|
+
<td>Skipped Tests</td>
|
|
177
|
+
<td>${skippedTests} (${skippedPercentage}%)</td>
|
|
178
|
+
</tr>
|
|
179
|
+
<tr>
|
|
180
|
+
<td>Test Failed</td>
|
|
181
|
+
<td>${failedTests} (${failedPercentage}%)</td>
|
|
182
|
+
</tr>
|
|
183
|
+
</tbody>
|
|
184
|
+
</table>
|
|
185
|
+
<p>With regards,</p>
|
|
186
|
+
<p>Networks QA Team</p>
|
|
187
|
+
</body>
|
|
188
|
+
</html>
|
|
189
|
+
`;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Async function to send an email
|
|
193
|
+
const sendEmail = async (credentials) => {
|
|
194
|
+
try {
|
|
195
|
+
console.log("Starting the sendEmail function...");
|
|
196
|
+
|
|
197
|
+
// Configure nodemailer transporter
|
|
198
|
+
const secureTransporter = nodemailer.createTransport({
|
|
199
|
+
host: "smtp.gmail.com",
|
|
200
|
+
port: 465,
|
|
201
|
+
secure: true, // Use SSL/TLS
|
|
202
|
+
auth: {
|
|
203
|
+
user: credentials.username,
|
|
204
|
+
pass: credentials.password, // Ensure you use app password or secured token
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
// Generate HTML content for email
|
|
208
|
+
const reportData = getPulseReportSummary();
|
|
209
|
+
const htmlContent = generateHtmlTable(reportData);
|
|
210
|
+
|
|
211
|
+
// Configure mail options
|
|
212
|
+
const mailOptions = {
|
|
213
|
+
from: credentials.username,
|
|
214
|
+
to: [
|
|
215
|
+
process.env.SENDER_EMAIL_1 || "",
|
|
216
|
+
process.env.SENDER_EMAIL_2 || "",
|
|
217
|
+
process.env.SENDER_EMAIL_3 || "",
|
|
218
|
+
process.env.SENDER_EMAIL_4 || "",
|
|
219
|
+
process.env.SENDER_EMAIL_5 || "",
|
|
220
|
+
],
|
|
221
|
+
subject: "Pulse Report " + new Date().toLocaleString(),
|
|
222
|
+
html: htmlContent,
|
|
223
|
+
attachments: [
|
|
224
|
+
{
|
|
225
|
+
filename: `report.html`,
|
|
226
|
+
path: `${reportDir}/playwright-pulse-static-report.html`, // Attach the zipped folder
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Send email
|
|
232
|
+
const info = await secureTransporter.sendMail(mailOptions);
|
|
233
|
+
console.log("Email sent: ", info.response);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
console.error("Error sending email: ", error);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
async function fetchCredentials(retries = 6) {
|
|
240
|
+
const timeout = 10000; // 10 seconds timeout
|
|
241
|
+
const key = getUUID();
|
|
242
|
+
// Validate API key exists before making any requests
|
|
243
|
+
if (!key) {
|
|
244
|
+
console.error(
|
|
245
|
+
"🔴 Critical: API key not provided - please set EMAIL_KEY in your environment variables"
|
|
246
|
+
);
|
|
247
|
+
console.warn("🟠 Falling back to default credentials (if any)");
|
|
248
|
+
return null; // Return null instead of throwing
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
252
|
+
try {
|
|
253
|
+
console.log(`🟡 Attempt ${attempt} of ${retries}`);
|
|
254
|
+
|
|
255
|
+
// Create a timeout promise
|
|
256
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
257
|
+
setTimeout(() => {
|
|
258
|
+
reject(new Error(`Request timed out after ${timeout}ms`));
|
|
259
|
+
}, timeout);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Create the fetch promise
|
|
263
|
+
const fetchPromise = fetch(
|
|
264
|
+
"https://test-dashboard-66zd.onrender.com/api/getcredentials",
|
|
265
|
+
{
|
|
266
|
+
method: "GET",
|
|
267
|
+
headers: {
|
|
268
|
+
"x-api-key": `${key}`,
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// Race between fetch and timeout
|
|
274
|
+
const response = await Promise.race([fetchPromise, timeoutPromise]);
|
|
275
|
+
|
|
276
|
+
if (!response.ok) {
|
|
277
|
+
// Handle specific HTTP errors with console messages only
|
|
278
|
+
if (response.status === 401) {
|
|
279
|
+
console.error("🔴 Invalid API key - authentication failed");
|
|
280
|
+
} else if (response.status === 404) {
|
|
281
|
+
console.error("🔴 Endpoint not found - check the API URL");
|
|
282
|
+
} else {
|
|
283
|
+
console.error(`🔴 Fetch failed with status: ${response.status}`);
|
|
284
|
+
}
|
|
285
|
+
continue; // Skip to next attempt instead of throwing
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const data = await response.json();
|
|
289
|
+
|
|
290
|
+
// Validate the response structure
|
|
291
|
+
if (!data.username || !data.password) {
|
|
292
|
+
console.error("🔴 Invalid credentials format received from API");
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
console.log("🟢 Fetched credentials successfully");
|
|
297
|
+
return data;
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error(`🔴 Attempt ${attempt} failed: ${err.message}`);
|
|
300
|
+
|
|
301
|
+
if (attempt === retries) {
|
|
302
|
+
console.error(
|
|
303
|
+
`🔴 All ${retries} attempts failed. Last error: ${err.message}`
|
|
304
|
+
);
|
|
305
|
+
console.warn(
|
|
306
|
+
"🟠 Proceeding without credentials - email sending will be skipped"
|
|
307
|
+
);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Main function to zip the folder and send the email
|
|
317
|
+
const main = async () => {
|
|
318
|
+
await import("node-fetch").then((module) => {
|
|
319
|
+
fetch = module.default;
|
|
320
|
+
});
|
|
321
|
+
const credentials = await fetchCredentials();
|
|
322
|
+
if (!credentials) {
|
|
323
|
+
console.warn("Skipping email sending due to missing credentials");
|
|
324
|
+
// Continue with pipeline without failing
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
await delay(10000);
|
|
328
|
+
try {
|
|
329
|
+
await sendEmail(credentials);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
console.error("Error in main function: ", error);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
main();
|