@arghajit/dummy 0.3.38 โ†’ 0.3.39

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.
@@ -10,6 +10,7 @@ export declare class PlaywrightPulseReporter implements Reporter {
10
10
  private outputDir;
11
11
  private attachmentsDir;
12
12
  private baseOutputFile;
13
+ private individualReportsSubDir;
13
14
  private isSharded;
14
15
  private shardIndex;
15
16
  private resetOnEachRun;
@@ -46,6 +47,5 @@ export declare class PlaywrightPulseReporter implements Reporter {
46
47
  private _cleanupStaleRunReports;
47
48
  private _ensureDirExists;
48
49
  onEnd(result: FullResult): Promise<void>;
49
- private _mergeAllRunReports;
50
50
  }
51
51
  export default PlaywrightPulseReporter;
@@ -64,20 +64,21 @@ const convertStatus = (status, testCase) => {
64
64
  };
65
65
  const TEMP_SHARD_FILE_PREFIX = ".pulse-shard-results-";
66
66
  const ATTACHMENTS_SUBDIR = "attachments";
67
- const INDIVIDUAL_REPORTS_SUBDIR = "pulse-results";
68
67
  class PlaywrightPulseReporter {
69
68
  constructor(options = {}) {
70
- var _a, _b, _c;
69
+ var _a, _b, _c, _d;
71
70
  this.results = [];
72
71
  this._pendingTestEnds = [];
73
72
  this.baseOutputFile = "playwright-pulse-report.json";
73
+ this.individualReportsSubDir = "pulse-results";
74
74
  this.isSharded = false;
75
75
  this.shardIndex = undefined;
76
76
  this.options = options;
77
77
  this.baseOutputFile = (_a = options.outputFile) !== null && _a !== void 0 ? _a : this.baseOutputFile;
78
78
  this.outputDir = (_b = options.outputDir) !== null && _b !== void 0 ? _b : "pulse-report";
79
+ this.individualReportsSubDir = (_c = options.individualReportsSubDir) !== null && _c !== void 0 ? _c : "pulse-results";
79
80
  this.attachmentsDir = path.join(this.outputDir, ATTACHMENTS_SUBDIR);
80
- this.resetOnEachRun = (_c = options.resetOnEachRun) !== null && _c !== void 0 ? _c : true;
81
+ this.resetOnEachRun = (_d = options.resetOnEachRun) !== null && _d !== void 0 ? _d : true;
81
82
  }
82
83
  printsToStdio() {
83
84
  return this.shardIndex === undefined || this.shardIndex === 0;
@@ -524,7 +525,7 @@ class PlaywrightPulseReporter {
524
525
  * Cleaning up at `onBegin` time guarantees each run starts with a fresh slate.
525
526
  */
526
527
  async _cleanupStaleRunReports() {
527
- const pulseResultsDir = path.join(this.outputDir, INDIVIDUAL_REPORTS_SUBDIR);
528
+ const pulseResultsDir = path.join(this.outputDir, this.individualReportsSubDir);
528
529
  try {
529
530
  const files = await fs.readdir(pulseResultsDir);
530
531
  const staleFiles = files.filter((f) => f.startsWith("playwright-pulse-report-") && f.endsWith(".json"));
@@ -625,7 +626,7 @@ class PlaywrightPulseReporter {
625
626
  }
626
627
  else {
627
628
  // Logic for appending/merging reports
628
- const pulseResultsDir = path.join(this.outputDir, INDIVIDUAL_REPORTS_SUBDIR);
629
+ const pulseResultsDir = path.join(this.outputDir, this.individualReportsSubDir);
629
630
  const individualReportPath = path.join(pulseResultsDir, `playwright-pulse-report-${Date.now()}.json`);
630
631
  try {
631
632
  await this._ensureDirExists(pulseResultsDir);
@@ -633,111 +634,19 @@ class PlaywrightPulseReporter {
633
634
  if (this.printsToStdio()) {
634
635
  console.log(`PlaywrightPulseReporter: Individual run report for merging written to ${individualReportPath}`);
635
636
  }
636
- await this._mergeAllRunReports();
637
+ // DEFERRED MERGING:
638
+ // We do not call _mergeAllRunReports() here anymore when resetOnEachRun is false.
639
+ // The individual JSON files in pulse-results/ will be collected and merged
640
+ // into the main JSON when the user next runs one of the report generator commands.
637
641
  }
638
642
  catch (error) {
639
- console.error(`Pulse Reporter: Failed to write or merge report. Error: ${error.message}`);
643
+ console.error(`Pulse Reporter: Failed to write report. Error: ${error.message}`);
640
644
  }
641
645
  }
642
646
  if (this.isSharded) {
643
647
  await this._cleanupTemporaryFiles();
644
648
  }
645
649
  }
646
- async _mergeAllRunReports() {
647
- const pulseResultsDir = path.join(this.outputDir, INDIVIDUAL_REPORTS_SUBDIR);
648
- const finalOutputPath = path.join(this.outputDir, this.baseOutputFile);
649
- let reportFiles;
650
- try {
651
- const allFiles = await fs.readdir(pulseResultsDir);
652
- reportFiles = allFiles.filter((file) => file.startsWith("playwright-pulse-report-") && file.endsWith(".json"));
653
- }
654
- catch (error) {
655
- if (error.code === "ENOENT") {
656
- if (this.printsToStdio()) {
657
- console.log(`Pulse Reporter: No individual reports directory found at ${pulseResultsDir}. Skipping merge.`);
658
- }
659
- return;
660
- }
661
- console.error(`Pulse Reporter: Error reading report directory ${pulseResultsDir}:`, error);
662
- return;
663
- }
664
- if (reportFiles.length === 0) {
665
- if (this.printsToStdio()) {
666
- console.log("Pulse Reporter: No matching JSON report files found to merge.");
667
- }
668
- return;
669
- }
670
- const allResultsFromAllFiles = [];
671
- let latestTimestamp = new Date(0);
672
- let lastRunEnvironment = undefined;
673
- let totalDuration = 0;
674
- for (const file of reportFiles) {
675
- const filePath = path.join(pulseResultsDir, file);
676
- try {
677
- const content = await fs.readFile(filePath, "utf-8");
678
- const json = JSON.parse(content);
679
- if (json.run) {
680
- const runTimestamp = new Date(json.run.timestamp);
681
- if (runTimestamp > latestTimestamp) {
682
- latestTimestamp = runTimestamp;
683
- lastRunEnvironment = json.run.environment || undefined;
684
- }
685
- }
686
- if (json.results) {
687
- allResultsFromAllFiles.push(...json.results);
688
- }
689
- }
690
- catch (err) {
691
- console.warn(`Pulse Reporter: Could not parse report file ${filePath}. Skipping. Error: ${err.message}`);
692
- }
693
- }
694
- // De-duplicate the results from ALL merged files using the helper function
695
- const finalMergedResults = this._getFinalizedResults(allResultsFromAllFiles);
696
- // Sum the duration from the final, de-duplicated list of tests
697
- totalDuration = finalMergedResults.reduce((acc, r) => acc + (r.duration || 0), 0);
698
- const combinedRun = {
699
- id: `merged-${Date.now()}`,
700
- timestamp: latestTimestamp,
701
- environment: lastRunEnvironment,
702
- // Recalculate counts based on the truly final, de-duplicated list
703
- totalTests: finalMergedResults.length,
704
- passed: finalMergedResults.filter((r) => (r.final_status || r.status) === "passed").length,
705
- failed: finalMergedResults.filter((r) => (r.final_status || r.status) === "failed").length,
706
- skipped: finalMergedResults.filter((r) => (r.final_status || r.status) === "skipped").length,
707
- flaky: finalMergedResults.filter((r) => (r.final_status || r.status) === "flaky").length,
708
- duration: totalDuration,
709
- };
710
- const finalReport = {
711
- run: combinedRun,
712
- results: finalMergedResults, // Use the de-duplicated list
713
- metadata: {
714
- generatedAt: new Date().toISOString(),
715
- },
716
- };
717
- try {
718
- await fs.writeFile(finalOutputPath, JSON.stringify(finalReport, (key, value) => {
719
- if (value instanceof Date)
720
- return value.toISOString();
721
- return value;
722
- }, 2));
723
- if (this.printsToStdio()) {
724
- console.log(`PlaywrightPulseReporter: โœ… Merged report with ${finalMergedResults.length} total results saved to ${finalOutputPath}`);
725
- }
726
- // Clean up the pulse-results directory after a successful merge
727
- try {
728
- await fs.rm(pulseResultsDir, { recursive: true, force: true });
729
- if (this.printsToStdio()) {
730
- console.log(`PlaywrightPulseReporter: Cleaned up individual reports directory at ${pulseResultsDir}`);
731
- }
732
- }
733
- catch (cleanupErr) {
734
- console.warn(`Pulse Reporter: Could not clean up individual reports directory. Error: ${cleanupErr.message}`);
735
- }
736
- }
737
- catch (err) {
738
- console.error(`Pulse Reporter: Failed to write final merged report to ${finalOutputPath}. Error: ${err.message}`);
739
- }
740
- }
741
650
  }
742
651
  exports.PlaywrightPulseReporter = PlaywrightPulseReporter;
743
652
  exports.default = PlaywrightPulseReporter;
@@ -81,12 +81,55 @@ export interface TrendDataPoint {
81
81
  flaky?: number;
82
82
  }
83
83
  export interface PlaywrightPulseReporterOptions {
84
+ /**
85
+ * The name of the output JSON file. Kindly do not change.
86
+ * @default "playwright-pulse-report.json"
87
+ */
84
88
  outputFile?: string;
89
+ /**
90
+ * The directory where the report files will be generated.
91
+ *
92
+ * Mostly useful while using sharding
93
+ *
94
+ * @default "pulse-report"
95
+ */
85
96
  outputDir?: string;
97
+ /**
98
+ * Whether to embed images directly as base64 strings in the report.
99
+ * @default false
100
+ */
86
101
  base64Images?: boolean;
102
+ /**
103
+ * Whether to reset the output directory before each run.
104
+ *
105
+ * Mostly useful while running multiple test suites in a single run with `&&` operator.
106
+ *
107
+ * example: `npx playwright test test1.spec.ts && npx playwright test test2.spec.ts`
108
+ *
109
+ * If `resetOnEachRun` is set to `false`, then the report of `test2.spec.ts` will be merged with `test1.spec.ts` report.
110
+ *
111
+ * @default true
112
+ */
87
113
  resetOnEachRun?: boolean;
114
+ /**
115
+ * A custom description to embed or display in the report.
116
+ *
117
+ * If not added, the component will not appear in the html reports
118
+ */
88
119
  reportDescription?: string;
120
+ /**
121
+ * Path to a custom logo image file to use in the report, which will be displayed in the header of the html report's logo and favicon.
122
+ *
123
+ * If not added, the default logo will be used.
124
+ */
89
125
  logo?: string;
126
+ /**
127
+ * The subdirectory within `outputDir` where individual run reports are stored.
128
+ * Only used when `resetOnEachRun` is `false`.
129
+ *
130
+ * @default "pulse-results"
131
+ */
132
+ individualReportsSubDir?: string;
90
133
  }
91
134
  export interface EnvDetails {
92
135
  host: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arghajit/dummy",
3
3
  "author": "Arghajit Singha",
4
- "version": "0.3.38",
4
+ "version": "0.3.39",
5
5
  "description": "A Playwright reporter and dashboard for visualizing test results.",
6
6
  "homepage": "https://arghajit47.github.io/playwright-pulse/",
7
7
  "repository": {
@@ -40,8 +40,8 @@
40
40
  "generate-report": "scripts/generate-report.mjs",
41
41
  "merge-pulse-report": "scripts/merge-pulse-report.js",
42
42
  "send-email": "scripts/sendReport.mjs",
43
- "generate-trend": "scripts/generate-trend.mjs",
44
43
  "generate-email-report": "scripts/generate-email-report.mjs",
44
+ "generate-trend": "scripts/generate-trend.mjs",
45
45
  "pulse-logo": "scripts/terminal-logo.mjs"
46
46
  },
47
47
  "exports": {
@@ -22,159 +22,131 @@ async function findPlaywrightConfig() {
22
22
  return { path: null, exists: false };
23
23
  }
24
24
 
25
- async function extractOutputDirFromConfig(configPath) {
25
+ async function extractReporterOptionsFromConfig(configPath) {
26
26
  let fileContent = "";
27
27
  try {
28
28
  fileContent = fs.readFileSync(configPath, "utf-8");
29
29
  } catch (e) {
30
- // If we can't read the file, we can't parse or import it.
31
- return null;
30
+ return {};
32
31
  }
33
32
 
33
+ const options = {};
34
+
34
35
  // 1. Strategy: Text Parsing (Safe & Fast)
35
- // We try to read the file as text first. This finds the outputDir without
36
- // triggering any Node.js warnings or errors.
37
36
  try {
38
- // Regex matches: outputDir: "value" or outputDir: 'value'
39
- const match = fileContent.match(/outputDir:\s*["']([^"']+)["']/);
40
-
41
- if (match && match[1]) {
42
- return path.resolve(process.cwd(), match[1]);
43
- }
44
- } catch (e) {
45
- // Ignore text reading errors
46
- }
37
+ const outputDirMatch = fileContent.match(/outputDir:\s*["']([^"']+)["']/);
38
+ if (outputDirMatch && outputDirMatch[1]) options.outputDir = outputDirMatch[1];
39
+
40
+ const outputFileMatch = fileContent.match(/outputFile:\s*["']([^"']+)["']/);
41
+ if (outputFileMatch && outputFileMatch[1]) options.outputFile = outputFileMatch[1];
42
+
43
+ const resetOnEachRunMatch = fileContent.match(/resetOnEachRun:\s*(true|false)/);
44
+ if (resetOnEachRunMatch) options.resetOnEachRun = resetOnEachRunMatch[1] === "true";
45
+
46
+ const individualReportsSubDirMatch = fileContent.match(/individualReportsSubDir:\s*["']([^"']+)["']/);
47
+ if (individualReportsSubDirMatch && individualReportsSubDirMatch[1]) options.individualReportsSubDir = individualReportsSubDirMatch[1];
48
+ } catch (e) { }
49
+
50
+ // 2. Safety Check and Dynamic Import
51
+ if (Object.keys(options).length < 3) {
52
+ // Check if we can safely import()
53
+ let canImport = true;
54
+ if (configPath.endsWith(".js")) {
55
+ let isModulePackage = false;
56
+ try {
57
+ const pkgPath = path.resolve(process.cwd(), "package.json");
58
+ if (fs.existsSync(pkgPath)) {
59
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
60
+ isModulePackage = pkg.type === "module";
61
+ }
62
+ } catch (e) {}
47
63
 
48
- // 2. Safety Check: Detect ESM in CJS to Prevent Node Warnings
49
- // The warning "To load an ES module..." happens when we try to import()
50
- // a .js file containing ESM syntax (import/export) in a CJS package.
51
- // We explicitly check for this and ABORT the import if found.
52
- if (configPath.endsWith(".js")) {
53
- let isModulePackage = false;
54
- try {
55
- const pkgPath = path.resolve(process.cwd(), "package.json");
56
- if (fs.existsSync(pkgPath)) {
57
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
58
- isModulePackage = pkg.type === "module";
59
- }
60
- } catch (e) {}
61
-
62
- if (!isModulePackage) {
63
- // Heuristic: Check for ESM syntax (import/export at start of lines)
64
- const hasEsmSyntax =
65
- /^\s*import\s+/m.test(fileContent) ||
66
- /^\s*export\s+/m.test(fileContent);
67
-
68
- if (hasEsmSyntax) {
69
- // We found ESM syntax in a .js file within a CJS project.
70
- // Attempting to import this WILL trigger the Node.js warning.
71
- // Since regex failed to find outputDir, and we can't import safely, we abort now.
72
- return null;
64
+ if (!isModulePackage) {
65
+ const hasEsmSyntax = /^\s*import\s+/m.test(fileContent) || /^\s*export\s+/m.test(fileContent);
66
+ if (hasEsmSyntax) canImport = false;
73
67
  }
74
68
  }
75
- }
76
-
77
- // 3. Strategy: Dynamic Import
78
- // If we passed the safety check, we try to import the config.
79
- try {
80
- let config;
81
- const configDir = dirname(configPath);
82
- const originalDirname = global.__dirname;
83
- const originalFilename = global.__filename;
84
69
 
85
- try {
86
- global.__dirname = configDir;
87
- global.__filename = configPath;
70
+ if (canImport) {
71
+ try {
72
+ let config;
73
+ const configDir = dirname(configPath);
74
+ const originalDirname = global.__dirname;
75
+ const originalFilename = global.__filename;
88
76
 
89
- if (configPath.endsWith(".ts")) {
90
77
  try {
91
- const { register } = await import("node:module");
92
- const { pathToFileURL } = await import("node:url");
93
- register("ts-node/esm", pathToFileURL("./"));
94
- config = await import(pathToFileURL(configPath).href);
95
- } catch (tsError) {
96
- const tsNode = await import("ts-node");
97
- tsNode.register({
98
- transpileOnly: true,
99
- compilerOptions: { module: "commonjs" },
100
- });
101
- config = require(configPath);
102
- }
103
- } else {
104
- // Try dynamic import for JS/MJS
105
- config = await import(pathToFileURL(configPath).href);
106
- }
107
-
108
- // Handle Default Export
109
- if (config && config.default) {
110
- config = config.default;
111
- }
78
+ global.__dirname = configDir;
79
+ global.__filename = configPath;
80
+
81
+ if (configPath.endsWith(".ts")) {
82
+ try {
83
+ const { register } = await import("node:module");
84
+ const { pathToFileURL } = await import("node:url");
85
+ register("ts-node/esm", pathToFileURL("./"));
86
+ config = await import(pathToFileURL(configPath).href);
87
+ } catch (tsError) {
88
+ const tsNode = await import("ts-node");
89
+ tsNode.register({ transpileOnly: true, compilerOptions: { module: "commonjs" } });
90
+ config = require(configPath);
91
+ }
92
+ } else {
93
+ config = await import(pathToFileURL(configPath).href);
94
+ }
112
95
 
113
- if (config) {
114
- // Check for Reporter Config
115
- if (config.reporter) {
116
- const reporters = Array.isArray(config.reporter)
117
- ? config.reporter
118
- : [config.reporter];
119
-
120
- for (const reporter of reporters) {
121
- const reporterName = Array.isArray(reporter)
122
- ? reporter[0]
123
- : reporter;
124
- const reporterOptions = Array.isArray(reporter)
125
- ? reporter[1]
126
- : null;
127
-
128
- if (
129
- typeof reporterName === "string" &&
130
- (reporterName.includes("playwright-pulse-report") ||
131
- reporterName.includes("@arghajit/playwright-pulse-report") ||
132
- reporterName.includes("@arghajit/dummy"))
133
- ) {
134
- if (reporterOptions && reporterOptions.outputDir) {
135
- return path.resolve(process.cwd(), reporterOptions.outputDir);
96
+ if (config && config.default) config = config.default;
97
+
98
+ if (config) {
99
+ if (config.reporter) {
100
+ const reporters = Array.isArray(config.reporter) ? config.reporter : [config.reporter];
101
+ for (const reporter of reporters) {
102
+ const reporterName = Array.isArray(reporter) ? reporter[0] : reporter;
103
+ const reporterOptions = Array.isArray(reporter) ? reporter[1] : null;
104
+
105
+ if (typeof reporterName === "string" &&
106
+ (reporterName.includes("playwright-pulse-report") ||
107
+ reporterName.includes("@arghajit/playwright-pulse-report"))) {
108
+ if (reporterOptions) {
109
+ if (reporterOptions.outputDir) options.outputDir = reporterOptions.outputDir;
110
+ if (reporterOptions.outputFile) options.outputFile = reporterOptions.outputFile;
111
+ if (reporterOptions.resetOnEachRun !== undefined) options.resetOnEachRun = reporterOptions.resetOnEachRun;
112
+ if (reporterOptions.individualReportsSubDir) options.individualReportsSubDir = reporterOptions.individualReportsSubDir;
113
+ }
114
+ }
136
115
  }
137
116
  }
117
+ if (config.outputDir && !options.outputDir) options.outputDir = config.outputDir;
138
118
  }
119
+ } finally {
120
+ global.__dirname = originalDirname;
121
+ global.__filename = originalFilename;
139
122
  }
140
-
141
- // Check for Global outputDir
142
- if (config.outputDir) {
143
- return path.resolve(process.cwd(), config.outputDir);
144
- }
145
- }
146
- } finally {
147
- // Clean up globals
148
- global.__dirname = originalDirname;
149
- global.__filename = originalFilename;
123
+ } catch (error) {}
150
124
  }
151
- } catch (error) {
152
- // SILENT CATCH: Do NOT log anything here.
153
- return null;
154
125
  }
155
126
 
156
- return null;
127
+ return options;
157
128
  }
158
129
 
159
- export async function getOutputDir(customOutputDirFromArgs = null) {
160
- if (customOutputDirFromArgs) {
161
- console.log(`Using custom outputDir from CLI: ${customOutputDirFromArgs}`);
162
- return path.resolve(process.cwd(), customOutputDirFromArgs);
163
- }
164
-
130
+ export async function getReporterConfig(customOutputDirFromArgs = null) {
165
131
  const { path: configPath, exists } = await findPlaywrightConfig();
166
- console.log(
167
- `Config file search result: ${exists ? configPath : "not found"}`
168
- );
132
+ let options = {};
169
133
 
170
134
  if (exists) {
171
- const outputDirFromConfig = await extractOutputDirFromConfig(configPath);
172
- if (outputDirFromConfig) {
173
- console.log(`Using outputDir from config: ${outputDirFromConfig}`);
174
- return outputDirFromConfig;
175
- }
135
+ options = await extractReporterOptionsFromConfig(configPath);
176
136
  }
177
137
 
178
- console.log(`Using default outputDir: ${DEFAULT_OUTPUT_DIR}`);
179
- return path.resolve(process.cwd(), DEFAULT_OUTPUT_DIR);
138
+ const outputDir = customOutputDirFromArgs
139
+ ? path.resolve(process.cwd(), customOutputDirFromArgs)
140
+ : path.resolve(process.cwd(), options.outputDir || DEFAULT_OUTPUT_DIR);
141
+
142
+ const outputFile = options.outputFile || "playwright-pulse-report.json";
143
+ const resetOnEachRun = options.resetOnEachRun !== undefined ? options.resetOnEachRun : true;
144
+ const individualReportsSubDir = options.individualReportsSubDir || "pulse-results";
145
+
146
+ return { outputDir, outputFile, resetOnEachRun, individualReportsSubDir };
147
+ }
148
+
149
+ export async function getOutputDir(customOutputDirFromArgs = null) {
150
+ const config = await getReporterConfig(customOutputDirFromArgs);
151
+ return config.outputDir;
180
152
  }
@@ -4,6 +4,7 @@ import * as fs from "fs/promises";
4
4
  import path from "path";
5
5
  import { getOutputDir } from "./config-reader.mjs";
6
6
  import { animate } from "./terminal-logo.mjs";
7
+ import { mergeSequentialReportsIfNeeded } from "./merge-sequential-reports.mjs";
7
8
 
8
9
  // Use dynamic import for chalk as it's ESM only
9
10
  let chalk;
@@ -758,6 +759,7 @@ async function main() {
758
759
  }
759
760
 
760
761
  const outputDir = await getOutputDir(customOutputDir);
762
+ await mergeSequentialReportsIfNeeded(outputDir);
761
763
  const reportJsonPath = path.resolve(outputDir, DEFAULT_JSON_FILE);
762
764
  const minifiedReportHtmlPath = path.resolve(outputDir, MINIFIED_HTML_FILE); // Path for the new minified HTML
763
765
 
@@ -7,6 +7,7 @@ import { fork } from "child_process";
7
7
  import { fileURLToPath } from "url";
8
8
  import { getOutputDir } from "./config-reader.mjs";
9
9
  import { animate } from "./terminal-logo.mjs";
10
+ import { mergeSequentialReportsIfNeeded } from "./merge-sequential-reports.mjs";
10
11
 
11
12
  const __filename = fileURLToPath(import.meta.url);
12
13
  const __dirname = path.dirname(__filename);
@@ -5298,6 +5299,7 @@ async function main() {
5298
5299
  );
5299
5300
 
5300
5301
  const outputDir = await getOutputDir(customOutputDir);
5302
+ await mergeSequentialReportsIfNeeded(outputDir);
5301
5303
  const reportJsonPath = path.resolve(outputDir, DEFAULT_JSON_FILE); // Current run's main JSON
5302
5304
  const reportHtmlPath = path.resolve(outputDir, DEFAULT_HTML_FILE);
5303
5305
 
@@ -7,6 +7,7 @@ import { fork } from "child_process";
7
7
  import { fileURLToPath } from "url";
8
8
  import { getOutputDir } from "./config-reader.mjs";
9
9
  import { animate } from "./terminal-logo.mjs";
10
+ import { mergeSequentialReportsIfNeeded } from "./merge-sequential-reports.mjs";
10
11
 
11
12
  const __filename = fileURLToPath(import.meta.url);
12
13
  const __dirname = path.dirname(__filename);
@@ -6462,11 +6463,13 @@ function generateHTML(reportData, trendData = null) {
6462
6463
  function switchRetryTab(event, tabId) {
6463
6464
  // Find container
6464
6465
  const container = event.target.closest('.retry-tabs-container');
6466
+ if (!container) return;
6465
6467
 
6466
6468
  // Update tab buttons
6467
6469
  const buttons = container.querySelectorAll('.retry-tab');
6468
6470
  buttons.forEach(btn => btn.classList.remove('active'));
6469
- event.target.classList.add('active');
6471
+ const activeBtn = event.target.closest('.retry-tab') || event.target;
6472
+ activeBtn.classList.add('active');
6470
6473
 
6471
6474
  // Update content
6472
6475
  const contents = container.querySelectorAll('.retry-tab-content');
@@ -6475,10 +6478,12 @@ function generateHTML(reportData, trendData = null) {
6475
6478
  content.classList.remove('active');
6476
6479
  });
6477
6480
 
6478
- const activeContent = container.querySelector('#' + tabId);
6481
+ const activeContent = document.getElementById(tabId);
6479
6482
  if (activeContent) {
6480
6483
  activeContent.style.display = 'block';
6481
6484
  activeContent.classList.add('active');
6485
+ } else {
6486
+ console.error('Failed to find retry tab content for id:', tabId);
6482
6487
  }
6483
6488
  }
6484
6489
 
@@ -7159,6 +7164,7 @@ async function main() {
7159
7164
  );
7160
7165
 
7161
7166
  const outputDir = await getOutputDir(customOutputDir);
7167
+ await mergeSequentialReportsIfNeeded(outputDir);
7162
7168
  const reportJsonPath = path.resolve(outputDir, DEFAULT_JSON_FILE); // Current run's main JSON
7163
7169
  const reportHtmlPath = path.resolve(outputDir, DEFAULT_HTML_FILE);
7164
7170
 
@@ -2,6 +2,7 @@
2
2
  import * as fs from "fs/promises";
3
3
  import path from "path";
4
4
  import { getOutputDir } from "./config-reader.mjs";
5
+ import { mergeSequentialReportsIfNeeded } from "./merge-sequential-reports.mjs";
5
6
 
6
7
  // Use dynamic import for chalk as it's ESM only for prettier console logs
7
8
  let chalk;
@@ -34,6 +35,7 @@ for (let i = 0; i < args.length; i++) {
34
35
 
35
36
  async function archiveCurrentRunData() {
36
37
  const outputDir = await getOutputDir(customOutputDir);
38
+ await mergeSequentialReportsIfNeeded(outputDir);
37
39
  const currentRunJsonPath = path.join(outputDir, CURRENT_RUN_JSON_FILE);
38
40
  const historyDir = path.join(outputDir, HISTORY_SUBDIR);
39
41
 
@@ -182,9 +182,11 @@ function cleanupShardDirectories(shardDirs) {
182
182
  // Main execution
183
183
  (async () => {
184
184
  const { animate } = await import("./terminal-logo.mjs");
185
+ const { mergeSequentialReportsIfNeeded } = await import("./merge-sequential-reports.mjs");
185
186
  await animate();
186
187
 
187
188
  const REPORT_DIR = await getReportDir();
189
+ await mergeSequentialReportsIfNeeded(REPORT_DIR);
188
190
 
189
191
  console.log(`\n๐Ÿ”„ Playwright Pulse - Merge Reports (Sharding Mode)\n`);
190
192
  console.log(` Report directory: ${REPORT_DIR}`);
@@ -0,0 +1,205 @@
1
+ import * as fs from "fs/promises";
2
+ import * as path from "path";
3
+
4
+ import { getReporterConfig } from "./config-reader.mjs";
5
+
6
+ /**
7
+ * Reads all `playwright-pulse-report-*.json` files in the `pulse-results` directory
8
+ * and merges them into a single `playwright-pulse-report.json`.
9
+ * It resolves duplicate tests using exactly the same logic as the reporter.
10
+ *
11
+ * @param {string} customOutputDir The base report directory override (from CLI).
12
+ */
13
+ export async function mergeSequentialReportsIfNeeded(customOutputDir) {
14
+ const config = await getReporterConfig(customOutputDir);
15
+
16
+ // This logic should ONLY run if resetOnEachRun is disabled.
17
+ if (config.resetOnEachRun) {
18
+ return;
19
+ }
20
+
21
+ const individualReportsSubDir = config.individualReportsSubDir;
22
+ const baseOutputFile = config.outputFile;
23
+ const outputDir = config.outputDir;
24
+
25
+ const pulseResultsDir = path.join(outputDir, individualReportsSubDir);
26
+ const finalOutputPath = path.join(outputDir, baseOutputFile);
27
+
28
+ let reportFiles;
29
+ try {
30
+ const allFiles = await fs.readdir(pulseResultsDir);
31
+ reportFiles = allFiles.filter(
32
+ (file) =>
33
+ file.startsWith("playwright-pulse-report-") && file.endsWith(".json"),
34
+ );
35
+ } catch (error) {
36
+ if (error.code === "ENOENT") {
37
+ // No individual reports directory found, which is completely fine/normal
38
+ return;
39
+ }
40
+ console.error(
41
+ `Pulse Reporter: Error reading directory ${pulseResultsDir}:`,
42
+ error,
43
+ );
44
+ return;
45
+ }
46
+
47
+ if (reportFiles.length === 0) {
48
+ // No matching JSON report files found to merge
49
+ return;
50
+ }
51
+
52
+ console.log(
53
+ `\n๐Ÿ”„ Merging ${reportFiles.length} sequential test run(s) from '${individualReportsSubDir}'...`,
54
+ );
55
+
56
+ const allResultsFromAllFiles = [];
57
+ let latestTimestamp = new Date(0);
58
+ let lastRunEnvironment = undefined;
59
+ let totalDuration = 0;
60
+
61
+ for (const file of reportFiles) {
62
+ const filePath = path.join(pulseResultsDir, file);
63
+ try {
64
+ const content = await fs.readFile(filePath, "utf-8");
65
+ const json = JSON.parse(content);
66
+
67
+ if (json.run) {
68
+ const runTimestamp = new Date(json.run.timestamp);
69
+ if (runTimestamp > latestTimestamp) {
70
+ latestTimestamp = runTimestamp;
71
+ lastRunEnvironment = json.run.environment || undefined;
72
+ }
73
+ }
74
+ if (json.results) {
75
+ allResultsFromAllFiles.push(...json.results);
76
+ }
77
+ } catch (err) {
78
+ console.warn(
79
+ `Pulse Reporter: Could not parse report file ${filePath}. Skipping. Error: ${err.message}`,
80
+ );
81
+ }
82
+ }
83
+
84
+ // De-duplicate the results from ALL merged files using the same logic as the reporter
85
+ const finalMergedResults = getFinalizedResults(allResultsFromAllFiles);
86
+
87
+ totalDuration = finalMergedResults.reduce(
88
+ (acc, r) => acc + (r.duration || 0),
89
+ 0,
90
+ );
91
+
92
+ const combinedRun = {
93
+ id: `run-${Date.now()}-581d5ad8-ce75-4ca5-94a6-ed29c466c815`,
94
+ timestamp: latestTimestamp.toISOString(),
95
+ environment: lastRunEnvironment,
96
+ totalTests: finalMergedResults.length,
97
+ passed: finalMergedResults.filter(
98
+ (r) => (r.final_status || r.status) === "passed",
99
+ ).length,
100
+ failed: finalMergedResults.filter(
101
+ (r) => (r.final_status || r.status) === "failed",
102
+ ).length,
103
+ skipped: finalMergedResults.filter(
104
+ (r) => (r.final_status || r.status) === "skipped",
105
+ ).length,
106
+ flaky: finalMergedResults.filter(
107
+ (r) => (r.final_status || r.status) === "flaky",
108
+ ).length,
109
+ duration: totalDuration,
110
+ };
111
+
112
+ const finalReport = {
113
+ run: combinedRun,
114
+ results: finalMergedResults,
115
+ metadata: {
116
+ generatedAt: new Date().toISOString(),
117
+ },
118
+ };
119
+
120
+ try {
121
+ await fs.writeFile(
122
+ finalOutputPath,
123
+ JSON.stringify(
124
+ finalReport,
125
+ (key, value) => {
126
+ if (value instanceof Date) return value.toISOString();
127
+ return value;
128
+ },
129
+ 2,
130
+ ),
131
+ );
132
+ console.log(
133
+ `โœ… Merged report with ${finalMergedResults.length} total results saved to ${finalOutputPath}`,
134
+ );
135
+
136
+ // Clean up the pulse-results directory after a successful merge
137
+ try {
138
+ await fs.rm(pulseResultsDir, { recursive: true, force: true });
139
+ console.log(
140
+ `๐Ÿงน Cleaned up temporary reports directory at ${pulseResultsDir}`,
141
+ );
142
+ } catch (cleanupErr) {
143
+ console.warn(
144
+ `Pulse Reporter: Could not clean up individual reports directory. Error: ${cleanupErr.message}`,
145
+ );
146
+ }
147
+ } catch (err) {
148
+ console.error(
149
+ `Pulse Reporter: Failed to write final merged report to ${finalOutputPath}. Error: ${err.message}`,
150
+ );
151
+ }
152
+ }
153
+
154
+ function getFinalizedResults(allResults) {
155
+ const resultsMap = new Map();
156
+
157
+ for (const result of allResults) {
158
+ if (!resultsMap.has(result.id)) {
159
+ resultsMap.set(result.id, []);
160
+ }
161
+ resultsMap.get(result.id).push(result);
162
+ }
163
+
164
+ const finalResults = [];
165
+
166
+ for (const [testId, attempts] of resultsMap.entries()) {
167
+ // Sort by retry count (ASC) then timestamp (DESC) to ensure stable resolution
168
+ attempts.sort((a, b) => {
169
+ if (a.retries !== b.retries) return a.retries - b.retries;
170
+ return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
171
+ });
172
+
173
+ const firstAttempt = attempts[0];
174
+ const retryAttempts = attempts.slice(1);
175
+
176
+ const hasActualRetries =
177
+ retryAttempts.length > 0 &&
178
+ retryAttempts.some(
179
+ (attempt) =>
180
+ attempt.status === "failed" ||
181
+ attempt.status === "flaky" ||
182
+ firstAttempt.status === "failed" ||
183
+ firstAttempt.status === "flaky"
184
+ );
185
+
186
+ if (hasActualRetries) {
187
+ firstAttempt.retryHistory = retryAttempts;
188
+
189
+ const lastAttempt = attempts[attempts.length - 1];
190
+ firstAttempt.final_status = lastAttempt.status;
191
+
192
+ if (lastAttempt.outcome === "flaky" || lastAttempt.status === "flaky") {
193
+ firstAttempt.outcome = "flaky";
194
+ firstAttempt.status = "flaky";
195
+ }
196
+ } else {
197
+ delete firstAttempt.final_status;
198
+ delete firstAttempt.retryHistory;
199
+ }
200
+
201
+ finalResults.push(firstAttempt);
202
+ }
203
+
204
+ return finalResults;
205
+ }
@@ -12,6 +12,7 @@ import { animate } from "./terminal-logo.mjs";
12
12
  import { fork } from "child_process";
13
13
  import "dotenv/config";
14
14
  import { getOutputDir } from "./config-reader.mjs";
15
+ import { mergeSequentialReportsIfNeeded } from "./merge-sequential-reports.mjs";
15
16
 
16
17
  let chalk;
17
18
  let logo =
@@ -460,6 +461,7 @@ const main = async () => {
460
461
  }
461
462
 
462
463
  const reportDir = await getOutputDir(customOutputDir);
464
+ await mergeSequentialReportsIfNeeded(reportDir);
463
465
  console.log(chalk.blue(`Preparing to send email report...`));
464
466
  console.log(chalk.blue(`Report directory set to: ${reportDir}`));
465
467