@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 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
- <a href="https://postimg.cc/180cym6c" target="_blank"><img src="https://i.postimg.cc/180cym6c/Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html.png" alt="Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html"/></a><br/><br/>
10
- <a href="https://postimg.cc/V5TFRHmM" target="_blank"><img src="https://i.postimg.cc/V5TFRHmM/Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html-1.png" alt="Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html-1"/></a><br/><br/>
11
- <a href="https://postimg.cc/XXTwFGkk" target="_blank"><img src="https://i.postimg.cc/XXTwFGkk/Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html-2.png" alt="Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html-2"/></a><br/><br/>
9
+
10
+ [![Screenshot 1](https://i.postimg.cc/180cym6c/Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html.png)](https://postimg.cc/180cym6c)
11
+
12
+ [![Screenshot 2](https://i.postimg.cc/V5TFRHmM/Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html-1.png)](https://postimg.cc/V5TFRHmM)
13
+
14
+ [![Screenshot 3](https://i.postimg.cc/XXTwFGkk/Users-arghajitsingha-Downloads-pulse-report-1-playwright-pulse-static-report-html-2.png)](https://postimg.cc/XXTwFGkk)
15
+
12
16
 
13
17
  ### Mobile View [Click on Images to View full Image]
14
- <a href="https://postimg.cc/CzJBLR5N" target="_blank"><img src="https://i.postimg.cc/CzJBLR5N/127-0-0-1-5500-pulse-report-output-playwright-pulse-static-report-html-i-Phone-14-Pro-Max.png" alt="127-0-0-1-5500-pulse-report-output-playwright-pulse-static-report-html-i-Phone-14-Pro-Max"/></a><br/><br/>
15
- <a href="https://postimg.cc/G8YTczT8" target="_blank"><img src="https://i.postimg.cc/G8YTczT8/127-0-0-1-5500-pulse-report-output-playwright-pulse-static-report-html-i-Phone-14-Pro-Max-1.png" alt="127-0-0-1-5500-pulse-report-output-playwright-pulse-static-report-html-i-Phone-14-Pro-Max-1"/></a><br/><br/>
18
+
19
+ [![iPhone Preview 1](https://i.postimg.cc/CzJBLR5N/127-0-0-1-5500-pulse-report-output-playwright-pulse-static-report-html-i-Phone-14-Pro-Max.png)](https://postimg.cc/CzJBLR5N)
20
+
21
+ [![iPhone Preview 2](https://i.postimg.cc/G8YTczT8/127-0-0-1-5500-pulse-report-output-playwright-pulse-static-report-html-i-Phone-14-Pro-Max-1.png)](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
+ [![Screenshot-2025-05-09-at-2-31-15-AM.png](https://i.postimg.cc/X7W1VWqr/Screenshot-2025-05-09-at-2-31-15-AM.png)](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
- 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(
53
- attachmentsBaseDir,
54
- attachmentsSubFolder
55
- ); // e.g., pulse-report/attachments/test_id_abc
56
- try {
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
- // Determine filename
80
- const safeName = name.replace(/[^a-zA-Z0-9_.-]/g, "_"); // Sanitize original name
81
- const extension = attachmentPath
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
- id: `${testId}_step_${startTime.toISOString()}-${duration}-${(0, crypto_1.randomUUID)()}`,
154
- title: step.title,
155
- status: stepStatus,
156
- duration: duration,
157
- startTime: startTime,
158
- endTime: endTime,
159
- errorMessage: errorMessage,
160
- stackTrace: ((_d = step.error) === null || _d === void 0 ? void 0 : _d.stack) || undefined,
161
- codeLocation: codeLocation || undefined,
162
- isHook: step.category === "hook",
163
- hookType: step.category === "hook"
164
- ? step.title.toLowerCase().includes("before")
165
- ? "before"
166
- : "after"
167
- : undefined,
168
- steps: [], // Will be populated recursively
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 (((_a = test.location) === null || _a === void 0 ? void 0 : _a.file) && ((_b = test.location) === null || _b === void 0 ? void 0 : _b.line) && ((_c = test.location) === null || _c === void 0 ? void 0 : _c.column)) {
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: ((_d = this.config.projects[0]) === null || _d === void 0 ? void 0 : _d.name) || "Default Suite",
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: ((_e = result.steps) === null || _e === void 0 ? void 0 : _e.length)
219
+ steps: ((_g = result.steps) === null || _g === void 0 ? void 0 : _g.length)
219
220
  ? await processAllSteps(result.steps, testStatus)
220
221
  : [],
221
- errorMessage: (_f = result.error) === null || _f === void 0 ? void 0 : _f.message,
222
- stackTrace: (_g = result.error) === null || _g === void 0 ? void 0 : _g.stack,
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: [],
@@ -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.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.name.split(" > ")[1]; // Extract browser (chromium/firefox/webkit)
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 browserMatch = test.name.match(/ > (\w+) > /);
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();