@arghajit/playwright-pulse-report 0.2.5 → 0.2.8

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.
@@ -6,7 +6,11 @@ import path from "path";
6
6
  import { fork } from "child_process";
7
7
  import { fileURLToPath } from "url";
8
8
 
9
- // Use dynamic import for chalk as it's ESM only
9
+ /**
10
+ * Dynamically imports the 'chalk' library for terminal string styling.
11
+ * This is necessary because chalk is an ESM-only module.
12
+ * If the import fails, a fallback object with plain console log functions is used.
13
+ */
10
14
  let chalk;
11
15
  try {
12
16
  chalk = (await import("chalk")).default;
@@ -22,12 +26,30 @@ try {
22
26
  };
23
27
  }
24
28
 
25
- // Default configuration
29
+ /**
30
+ * @constant {string} DEFAULT_OUTPUT_DIR
31
+ * The default directory where the report will be generated.
32
+ */
26
33
  const DEFAULT_OUTPUT_DIR = "pulse-report";
34
+
35
+ /**
36
+ * @constant {string} DEFAULT_JSON_FILE
37
+ * The default name for the JSON file containing the test data.
38
+ */
27
39
  const DEFAULT_JSON_FILE = "playwright-pulse-report.json";
40
+
41
+ /**
42
+ * @constant {string} DEFAULT_HTML_FILE
43
+ * The default name for the generated HTML report file.
44
+ */
28
45
  const DEFAULT_HTML_FILE = "playwright-pulse-static-report.html";
29
46
 
30
47
  // Helper functions
48
+ /**
49
+ * Converts a string with ANSI escape codes to an HTML string with inline styles.
50
+ * @param {string} text The text with ANSI codes.
51
+ * @returns {string} The converted HTML string.
52
+ */
31
53
  export function ansiToHtml(text) {
32
54
  if (!text) {
33
55
  return "";
@@ -141,6 +163,11 @@ export function ansiToHtml(text) {
141
163
  }
142
164
  return html;
143
165
  }
166
+ /**
167
+ * Sanitizes an HTML string by replacing special characters with their corresponding HTML entities.
168
+ * @param {string} str The HTML string to sanitize.
169
+ * @returns {string} The sanitized HTML string.
170
+ */
144
171
  function sanitizeHTML(str) {
145
172
  if (str === null || str === undefined) return "";
146
173
  return String(str).replace(
@@ -149,32 +176,52 @@ function sanitizeHTML(str) {
149
176
  ({ "&": "&", "<": "<", ">": ">", '"': '"', "'": "'" }[match] || match)
150
177
  );
151
178
  }
179
+ /**
180
+ * Capitalizes the first letter of a string and converts the rest to lowercase.
181
+ * @param {string} str The string to capitalize.
182
+ * @returns {string} The capitalized string.
183
+ */
152
184
  function capitalize(str) {
153
185
  if (!str) return "";
154
186
  return str[0].toUpperCase() + str.slice(1).toLowerCase();
155
187
  }
188
+ /**
189
+ * Formats a Playwright error object or message into an HTML string.
190
+ * @param {Error|string} error The error object or message string.
191
+ * @returns {string} The formatted HTML error string.
192
+ */
156
193
  function formatPlaywrightError(error) {
157
194
  const commandOutput = ansiToHtml(error || error.message);
158
195
  return convertPlaywrightErrorToHTML(commandOutput);
159
196
  }
197
+ /**
198
+ * Converts a string containing Playwright-style error formatting to HTML.
199
+ * @param {string} str The error string.
200
+ * @returns {string} The HTML-formatted error string.
201
+ */
160
202
  function convertPlaywrightErrorToHTML(str) {
161
- return (
162
- str
163
- // Convert leading spaces to &nbsp; and tabs to &nbsp;&nbsp;&nbsp;&nbsp;
164
- .replace(/^(\s+)/gm, (match) =>
165
- match.replace(/ /g, "&nbsp;").replace(/\t/g, "&nbsp;&nbsp;")
166
- )
167
- // Color and style replacements
168
- .replace(/<red>/g, '<span style="color: red;">')
169
- .replace(/<green>/g, '<span style="color: green;">')
170
- .replace(/<dim>/g, '<span style="opacity: 0.6;">')
171
- .replace(/<intensity>/g, '<span style="font-weight: bold;">') // Changed to apply bold
172
- .replace(/<\/color>/g, "</span>")
173
- .replace(/<\/intensity>/g, "</span>")
174
- // Convert newlines to <br> after processing other replacements
175
- .replace(/\n/g, "<br>")
176
- );
203
+ if (!str) return "";
204
+ return str
205
+ .replace(/^(\s+)/gm, (match) =>
206
+ match.replace(/ /g, " ").replace(/\t/g, " ")
207
+ )
208
+ .replace(/<red>/g, '<span style="color: red;">')
209
+ .replace(/<green>/g, '<span style="color: green;">')
210
+ .replace(/<dim>/g, '<span style="opacity: 0.6;">')
211
+ .replace(/<intensity>/g, '<span style="font-weight: bold;">')
212
+ .replace(/<\/color>/g, "</span>")
213
+ .replace(/<\/intensity>/g, "</span>")
214
+ .replace(/\n/g, "<br>");
177
215
  }
216
+ /**
217
+ * Formats a duration in milliseconds into a human-readable string (e.g., '1h 2m 3s', '4.5s').
218
+ * @param {number} ms The duration in milliseconds.
219
+ * @param {object} [options={}] Formatting options.
220
+ * @param {number} [options.precision=1] The number of decimal places for seconds.
221
+ * @param {string} [options.invalidInputReturn="N/A"] The string to return for invalid input.
222
+ * @param {string|null} [options.defaultForNullUndefinedNegative=null] The value for null, undefined, or negative inputs.
223
+ * @returns {string} The formatted duration string.
224
+ */
178
225
  function formatDuration(ms, options = {}) {
179
226
  const {
180
227
  precision = 1,
@@ -214,19 +261,12 @@ function formatDuration(ms, options = {}) {
214
261
 
215
262
  const totalRawSeconds = numMs / MS_PER_SECOND;
216
263
 
217
- // Decision: Are we going to display hours or minutes?
218
- // This happens if the duration is inherently >= 1 minute OR
219
- // if it's < 1 minute but ceiling the seconds makes it >= 1 minute.
220
264
  if (
221
265
  totalRawSeconds < SECONDS_PER_MINUTE &&
222
266
  Math.ceil(totalRawSeconds) < SECONDS_PER_MINUTE
223
267
  ) {
224
- // Strictly seconds-only display, use precision.
225
268
  return `${totalRawSeconds.toFixed(validPrecision)}s`;
226
269
  } else {
227
- // Display will include minutes and/or hours, or seconds round up to a minute.
228
- // Seconds part should be an integer (ceiling).
229
- // Round the total milliseconds UP to the nearest full second.
230
270
  const totalMsRoundedUpToSecond =
231
271
  Math.ceil(numMs / MS_PER_SECOND) * MS_PER_SECOND;
232
272
 
@@ -238,26 +278,26 @@ function formatDuration(ms, options = {}) {
238
278
  const m = Math.floor(remainingMs / (MS_PER_SECOND * SECONDS_PER_MINUTE));
239
279
  remainingMs %= MS_PER_SECOND * SECONDS_PER_MINUTE;
240
280
 
241
- const s = Math.floor(remainingMs / MS_PER_SECOND); // This will be an integer
281
+ const s = Math.floor(remainingMs / MS_PER_SECOND);
242
282
 
243
283
  const parts = [];
244
284
  if (h > 0) {
245
285
  parts.push(`${h}h`);
246
286
  }
247
-
248
- // Show minutes if:
249
- // - hours are present (e.g., "1h 0m 5s")
250
- // - OR minutes themselves are > 0 (e.g., "5m 10s")
251
- // - OR the original duration was >= 1 minute (ensures "1m 0s" for 60000ms)
252
287
  if (h > 0 || m > 0 || numMs >= MS_PER_SECOND * SECONDS_PER_MINUTE) {
253
288
  parts.push(`${m}m`);
254
289
  }
255
-
256
290
  parts.push(`${s}s`);
257
291
 
258
292
  return parts.join(" ");
259
293
  }
260
294
  }
295
+ /**
296
+ * Generates HTML and JavaScript for a Highcharts line chart to display test result trends over multiple runs.
297
+ * @param {object} trendData The trend data.
298
+ * @param {Array<object>} trendData.overall An array of run objects with test statistics.
299
+ * @returns {string} The HTML string for the test trends chart.
300
+ */
261
301
  function generateTestTrendsChart(trendData) {
262
302
  if (!trendData || !trendData.overall || trendData.overall.length === 0) {
263
303
  return '<div class="no-data">No overall trend data available for test counts.</div>';
@@ -353,6 +393,12 @@ function generateTestTrendsChart(trendData) {
353
393
  </script>
354
394
  `;
355
395
  }
396
+ /**
397
+ * Generates HTML and JavaScript for a Highcharts area chart to display test duration trends.
398
+ * @param {object} trendData Data for duration trends.
399
+ * @param {Array<object>} trendData.overall Array of objects, each representing a test run with a duration.
400
+ * @returns {string} The HTML string for the duration trend chart.
401
+ */
356
402
  function generateDurationTrendChart(trendData) {
357
403
  if (!trendData || !trendData.overall || trendData.overall.length === 0) {
358
404
  return '<div class="no-data">No overall trend data available for durations.</div>';
@@ -437,6 +483,11 @@ function generateDurationTrendChart(trendData) {
437
483
  </script>
438
484
  `;
439
485
  }
486
+ /**
487
+ * Formats a date string or Date object into a more readable format (e.g., "MM/DD/YY HH:MM").
488
+ * @param {string|Date} dateStrOrDate The date string or Date object to format.
489
+ * @returns {string} The formatted date string, or "N/A" for invalid dates.
490
+ */
440
491
  function formatDate(dateStrOrDate) {
441
492
  if (!dateStrOrDate) return "N/A";
442
493
  try {
@@ -455,6 +506,12 @@ function formatDate(dateStrOrDate) {
455
506
  return "Invalid Date Format";
456
507
  }
457
508
  }
509
+ /**
510
+ * Generates a small area chart showing the duration history of a single test across multiple runs.
511
+ * The status of each run is indicated by the color of the marker.
512
+ * @param {Array<object>} history An array of run objects, each with status and duration.
513
+ * @returns {string} The HTML string for the test history chart.
514
+ */
458
515
  function generateTestHistoryChart(history) {
459
516
  if (!history || history.length === 0)
460
517
  return '<div class="no-data-chart">No data for chart</div>';
@@ -565,6 +622,13 @@ function generateTestHistoryChart(history) {
565
622
  </script>
566
623
  `;
567
624
  }
625
+ /**
626
+ * Generates a Highcharts pie chart to visualize the distribution of test statuses.
627
+ * @param {Array<object>} data The data for the pie chart, with each object having a 'label' and 'value'.
628
+ * @param {number} [chartWidth=300] The width of the chart.
629
+ * @param {number} [chartHeight=300] The height of the chart.
630
+ * @returns {string} The HTML string for the pie chart.
631
+ */
568
632
  function generatePieChart(data, chartWidth = 300, chartHeight = 300) {
569
633
  const total = data.reduce((sum, d) => sum + d.value, 0);
570
634
  if (total === 0) {
@@ -694,6 +758,12 @@ function generatePieChart(data, chartWidth = 300, chartHeight = 300) {
694
758
  </div>
695
759
  `;
696
760
  }
761
+ /**
762
+ * Generates an HTML dashboard to display environment details.
763
+ * @param {object} environment The environment information.
764
+ * @param {number} [dashboardHeight=600] The height of the dashboard.
765
+ * @returns {string} The HTML string for the environment dashboard.
766
+ */
697
767
  function generateEnvironmentDashboard(environment, dashboardHeight = 600) {
698
768
  // Format memory for display
699
769
  const formattedMemory = environment.memory.replace(/(\d+\.\d{2})GB/, "$1 GB");
@@ -709,176 +779,176 @@ function generateEnvironmentDashboard(environment, dashboardHeight = 600) {
709
779
  return `
710
780
  <div class="environment-dashboard-wrapper" id="${dashboardId}">
711
781
  <style>
712
- .environment-dashboard-wrapper *,
713
- .environment-dashboard-wrapper *::before,
714
- .environment-dashboard-wrapper *::after {
715
- box-sizing: border-box;
716
- }
782
+ .environment-dashboard-wrapper *,
783
+ .environment-dashboard-wrapper *::before,
784
+ .environment-dashboard-wrapper *::after {
785
+ box-sizing: border-box;
786
+ }
717
787
 
718
- .environment-dashboard-wrapper {
719
- --primary-color: #007bff;
720
- --primary-light-color: #e6f2ff;
721
- --secondary-color: #6c757d;
722
- --success-color: #28a745;
723
- --success-light-color: #eaf6ec;
724
- --warning-color: #ffc107;
725
- --warning-light-color: #fff9e6;
726
- --danger-color: #dc3545;
727
-
728
- --background-color: #ffffff;
729
- --card-background-color: #ffffff;
730
- --text-color: #212529;
731
- --text-color-secondary: #6c757d;
732
- --border-color: #dee2e6;
733
- --border-light-color: #f1f3f5;
734
- --icon-color: #495057;
735
- --chip-background: #e9ecef;
736
- --chip-text: #495057;
737
- --shadow-color: rgba(0, 0, 0, 0.075);
738
-
739
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
740
- background-color: var(--background-color);
741
- border-radius: 12px;
742
- box-shadow: 0 6px 12px var(--shadow-color);
743
- padding: 24px;
744
- color: var(--text-color);
745
- display: grid;
746
- grid-template-columns: 1fr 1fr;
747
- grid-template-rows: auto 1fr;
748
- gap: 20px;
749
- font-size: 14px;
750
- }
751
-
752
- .env-dashboard-header {
753
- grid-column: 1 / -1;
754
- display: flex;
755
- justify-content: space-between;
756
- align-items: center;
757
- border-bottom: 1px solid var(--border-color);
758
- padding-bottom: 16px;
759
- margin-bottom: 8px;
760
- }
761
-
762
- .env-dashboard-title {
763
- font-size: 1.5rem;
764
- font-weight: 600;
765
- color: var(--text-color);
766
- margin: 0;
767
- }
768
-
769
- .env-dashboard-subtitle {
770
- font-size: 0.875rem;
771
- color: var(--text-color-secondary);
772
- margin-top: 4px;
773
- }
774
-
775
- .env-card {
776
- background-color: var(--card-background-color);
777
- border-radius: 8px;
778
- padding: ${cardContentPadding}px;
779
- box-shadow: 0 3px 6px var(--shadow-color);
780
- height: ${cardHeight}px;
781
- display: flex;
782
- flex-direction: column;
783
- overflow: hidden;
784
- }
785
-
786
- .env-card-header {
787
- font-weight: 600;
788
- font-size: 1rem;
789
- margin-bottom: 12px;
790
- color: var(--text-color);
791
- display: flex;
792
- align-items: center;
793
- padding-bottom: 8px;
794
- border-bottom: 1px solid var(--border-light-color);
795
- }
796
-
797
- .env-card-header svg {
798
- margin-right: 10px;
799
- width: 18px;
800
- height: 18px;
801
- fill: var(--icon-color);
802
- }
788
+ .environment-dashboard-wrapper {
789
+ --primary-color: #4a9eff;
790
+ --primary-light-color: #1a2332;
791
+ --secondary-color: #9ca3af;
792
+ --success-color: #34d399;
793
+ --success-light-color: #1a2e23;
794
+ --warning-color: #fbbf24;
795
+ --warning-light-color: #2d2a1a;
796
+ --danger-color: #f87171;
797
+
798
+ --background-color: #1f2937;
799
+ --card-background-color: #374151;
800
+ --text-color: #f9fafb;
801
+ --text-color-secondary: #d1d5db;
802
+ --border-color: #4b5563;
803
+ --border-light-color: #374151;
804
+ --icon-color: #d1d5db;
805
+ --chip-background: #4b5563;
806
+ --chip-text: #f9fafb;
807
+ --shadow-color: rgba(0, 0, 0, 0.3);
808
+
809
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
810
+ background-color: var(--background-color);
811
+ border-radius: 12px;
812
+ box-shadow: 0 6px 12px var(--shadow-color);
813
+ padding: 24px;
814
+ color: var(--text-color);
815
+ display: grid;
816
+ grid-template-columns: 1fr 1fr;
817
+ grid-template-rows: auto 1fr;
818
+ gap: 20px;
819
+ font-size: 14px;
820
+ }
803
821
 
804
- .env-card-content {
805
- flex-grow: 1;
806
- overflow-y: auto;
807
- padding-right: 5px;
808
- }
809
-
810
- .env-detail-row {
811
- display: flex;
812
- justify-content: space-between;
813
- align-items: center;
814
- padding: 10px 0;
815
- border-bottom: 1px solid var(--border-light-color);
816
- font-size: 0.875rem;
817
- }
818
-
819
- .env-detail-row:last-child {
820
- border-bottom: none;
821
- }
822
-
823
- .env-detail-label {
824
- color: var(--text-color-secondary);
825
- font-weight: 500;
826
- margin-right: 10px;
827
- }
828
-
829
- .env-detail-value {
830
- color: var(--text-color);
831
- font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
832
- text-align: right;
833
- word-break: break-all;
834
- }
835
-
836
- .env-chip {
837
- display: inline-block;
838
- padding: 4px 10px;
839
- border-radius: 16px;
840
- font-size: 0.75rem;
841
- font-weight: 500;
842
- line-height: 1.2;
843
- background-color: var(--chip-background);
844
- color: var(--chip-text);
845
- }
846
-
847
- .env-chip-primary {
848
- background-color: var(--primary-light-color);
849
- color: var(--primary-color);
850
- }
851
-
852
- .env-chip-success {
853
- background-color: var(--success-light-color);
854
- color: var(--success-color);
855
- }
856
-
857
- .env-chip-warning {
858
- background-color: var(--warning-light-color);
859
- color: var(--warning-color);
860
- }
861
-
862
- .env-cpu-cores {
863
- display: flex;
864
- align-items: center;
865
- gap: 6px;
866
- }
867
-
868
- .env-core-indicator {
869
- width: 12px;
870
- height: 12px;
871
- border-radius: 50%;
872
- background-color: var(--success-color);
873
- border: 1px solid rgba(0,0,0,0.1);
874
- }
875
-
876
- .env-core-indicator.inactive {
877
- background-color: var(--border-light-color);
878
- opacity: 0.7;
879
- border-color: var(--border-color);
880
- }
881
- </style>
822
+ .env-dashboard-header {
823
+ grid-column: 1 / -1;
824
+ display: flex;
825
+ justify-content: space-between;
826
+ align-items: center;
827
+ border-bottom: 1px solid var(--border-color);
828
+ padding-bottom: 16px;
829
+ margin-bottom: 8px;
830
+ }
831
+
832
+ .env-dashboard-title {
833
+ font-size: 1.5rem;
834
+ font-weight: 600;
835
+ color: var(--text-color);
836
+ margin: 0;
837
+ }
838
+
839
+ .env-dashboard-subtitle {
840
+ font-size: 0.875rem;
841
+ color: var(--text-color-secondary);
842
+ margin-top: 4px;
843
+ }
844
+
845
+ .env-card {
846
+ background-color: var(--card-background-color);
847
+ border-radius: 8px;
848
+ padding: ${cardContentPadding}px;
849
+ box-shadow: 0 3px 6px var(--shadow-color);
850
+ height: ${cardHeight}px;
851
+ display: flex;
852
+ flex-direction: column;
853
+ overflow: hidden;
854
+ }
855
+
856
+ .env-card-header {
857
+ font-weight: 600;
858
+ font-size: 1rem;
859
+ margin-bottom: 12px;
860
+ color: var(--text-color);
861
+ display: flex;
862
+ align-items: center;
863
+ padding-bottom: 8px;
864
+ border-bottom: 1px solid var(--border-light-color);
865
+ }
866
+
867
+ .env-card-header svg {
868
+ margin-right: 10px;
869
+ width: 18px;
870
+ height: 18px;
871
+ fill: var(--icon-color);
872
+ }
873
+
874
+ .env-card-content {
875
+ flex-grow: 1;
876
+ overflow-y: auto;
877
+ padding-right: 5px;
878
+ }
879
+
880
+ .env-detail-row {
881
+ display: flex;
882
+ justify-content: space-between;
883
+ align-items: center;
884
+ padding: 10px 0;
885
+ border-bottom: 1px solid var(--border-light-color);
886
+ font-size: 0.875rem;
887
+ }
888
+
889
+ .env-detail-row:last-child {
890
+ border-bottom: none;
891
+ }
892
+
893
+ .env-detail-label {
894
+ color: var(--text-color-secondary);
895
+ font-weight: 500;
896
+ margin-right: 10px;
897
+ }
898
+
899
+ .env-detail-value {
900
+ color: var(--text-color);
901
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
902
+ text-align: right;
903
+ word-break: break-all;
904
+ }
905
+
906
+ .env-chip {
907
+ display: inline-block;
908
+ padding: 4px 10px;
909
+ border-radius: 16px;
910
+ font-size: 0.75rem;
911
+ font-weight: 500;
912
+ line-height: 1.2;
913
+ background-color: var(--chip-background);
914
+ color: var(--chip-text);
915
+ }
916
+
917
+ .env-chip-primary {
918
+ background-color: var(--primary-light-color);
919
+ color: var(--primary-color);
920
+ }
921
+
922
+ .env-chip-success {
923
+ background-color: var(--success-light-color);
924
+ color: var(--success-color);
925
+ }
926
+
927
+ .env-chip-warning {
928
+ background-color: var(--warning-light-color);
929
+ color: var(--warning-color);
930
+ }
931
+
932
+ .env-cpu-cores {
933
+ display: flex;
934
+ align-items: center;
935
+ gap: 6px;
936
+ }
937
+
938
+ .env-core-indicator {
939
+ width: 12px;
940
+ height: 12px;
941
+ border-radius: 50%;
942
+ background-color: var(--success-color);
943
+ border: 1px solid rgba(255,255,255,0.2);
944
+ }
945
+
946
+ .env-core-indicator.inactive {
947
+ background-color: var(--border-light-color);
948
+ opacity: 0.7;
949
+ border-color: var(--border-color);
950
+ }
951
+ </style>
882
952
 
883
953
  <div class="env-dashboard-header">
884
954
  <div>
@@ -1023,125 +1093,420 @@ function generateEnvironmentDashboard(environment, dashboardHeight = 600) {
1023
1093
  </div>
1024
1094
  `;
1025
1095
  }
1026
- function generateTestHistoryContent(trendData) {
1027
- if (
1028
- !trendData ||
1029
- !trendData.testRuns ||
1030
- Object.keys(trendData.testRuns).length === 0
1031
- ) {
1032
- return '<div class="no-data">No historical test data available.</div>';
1096
+ /**
1097
+ * Generates a Highcharts bar chart to visualize the distribution of test results across different workers.
1098
+ * @param {Array<object>} results The test results data.
1099
+ * @returns {string} The HTML string for the worker distribution chart and its associated modal.
1100
+ */
1101
+ function generateWorkerDistributionChart(results) {
1102
+ if (!results || results.length === 0) {
1103
+ return '<div class="no-data">No test results data available to display worker distribution.</div>';
1033
1104
  }
1034
1105
 
1035
- const allTestNamesAndPaths = new Map();
1036
- Object.values(trendData.testRuns).forEach((run) => {
1037
- if (Array.isArray(run)) {
1038
- run.forEach((test) => {
1039
- if (test && test.testName && !allTestNamesAndPaths.has(test.testName)) {
1040
- const parts = test.testName.split(" > ");
1041
- const title = parts[parts.length - 1];
1042
- allTestNamesAndPaths.set(test.testName, title);
1043
- }
1044
- });
1106
+ // 1. Sort results by startTime to ensure chronological order
1107
+ const sortedResults = [...results].sort((a, b) => {
1108
+ const timeA = a.startTime ? new Date(a.startTime).getTime() : 0;
1109
+ const timeB = b.startTime ? new Date(b.startTime).getTime() : 0;
1110
+ return timeA - timeB;
1111
+ });
1112
+
1113
+ const workerData = sortedResults.reduce((acc, test) => {
1114
+ const workerId =
1115
+ typeof test.workerId !== "undefined" ? test.workerId : "N/A";
1116
+ if (!acc[workerId]) {
1117
+ acc[workerId] = { passed: 0, failed: 0, skipped: 0, tests: [] };
1118
+ }
1119
+
1120
+ const status = String(test.status).toLowerCase();
1121
+ if (status === "passed" || status === "failed" || status === "skipped") {
1122
+ acc[workerId][status]++;
1045
1123
  }
1124
+
1125
+ const testTitleParts = test.name.split(" > ");
1126
+ const testTitle =
1127
+ testTitleParts[testTitleParts.length - 1] || "Unnamed Test";
1128
+ // Store both name and status for each test
1129
+ acc[workerId].tests.push({ name: testTitle, status: status });
1130
+
1131
+ return acc;
1132
+ }, {});
1133
+
1134
+ const workerIds = Object.keys(workerData).sort((a, b) => {
1135
+ if (a === "N/A") return 1;
1136
+ if (b === "N/A") return -1;
1137
+ return parseInt(a, 10) - parseInt(b, 10);
1046
1138
  });
1047
1139
 
1048
- if (allTestNamesAndPaths.size === 0) {
1049
- return '<div class="no-data">No historical test data found after processing.</div>';
1140
+ if (workerIds.length === 0) {
1141
+ return '<div class="no-data">Could not determine worker distribution from test data.</div>';
1050
1142
  }
1051
1143
 
1052
- const testHistory = Array.from(allTestNamesAndPaths.entries())
1053
- .map(([fullTestName, testTitle]) => {
1054
- const history = [];
1055
- (trendData.overall || []).forEach((overallRun, index) => {
1056
- const runKey = overallRun.runId
1057
- ? `test run ${overallRun.runId}`
1058
- : `test run ${index + 1}`;
1059
- const testRunForThisOverallRun = trendData.testRuns[runKey]?.find(
1060
- (t) => t && t.testName === fullTestName
1061
- );
1062
- if (testRunForThisOverallRun) {
1063
- history.push({
1064
- runId: overallRun.runId || index + 1,
1065
- status: testRunForThisOverallRun.status || "unknown",
1066
- duration: testRunForThisOverallRun.duration || 0,
1067
- timestamp:
1068
- testRunForThisOverallRun.timestamp ||
1069
- overallRun.timestamp ||
1070
- new Date(),
1071
- });
1072
- }
1073
- });
1074
- return { fullTestName, testTitle, history };
1075
- })
1076
- .filter((item) => item.history.length > 0);
1144
+ const chartId = `workerDistChart-${Date.now()}-${Math.random()
1145
+ .toString(36)
1146
+ .substring(2, 7)}`;
1147
+ const renderFunctionName = `renderWorkerDistChart_${chartId.replace(
1148
+ /-/g,
1149
+ "_"
1150
+ )}`;
1151
+ const modalJsNamespace = `modal_funcs_${chartId.replace(/-/g, "_")}`;
1152
+
1153
+ // The categories now just need the name for the axis labels
1154
+ const categories = workerIds.map((id) => `Worker ${id}`);
1155
+
1156
+ // We pass the full data separately to the script
1157
+ const fullWorkerData = workerIds.map((id) => ({
1158
+ id: id,
1159
+ name: `Worker ${id}`,
1160
+ tests: workerData[id].tests,
1161
+ }));
1162
+
1163
+ const passedData = workerIds.map((id) => workerData[id].passed);
1164
+ const failedData = workerIds.map((id) => workerData[id].failed);
1165
+ const skippedData = workerIds.map((id) => workerData[id].skipped);
1166
+
1167
+ const categoriesString = JSON.stringify(categories);
1168
+ const fullDataString = JSON.stringify(fullWorkerData);
1169
+ const seriesString = JSON.stringify([
1170
+ { name: "Passed", data: passedData, color: "var(--success-color)" },
1171
+ { name: "Failed", data: failedData, color: "var(--danger-color)" },
1172
+ { name: "Skipped", data: skippedData, color: "var(--warning-color)" },
1173
+ ]);
1077
1174
 
1175
+ // The HTML now includes the chart container, the modal, and styles for the modal
1078
1176
  return `
1079
- <div class="test-history-container">
1080
- <div class="filters" style="border-color: black; border-style: groove;">
1081
- <input type="text" id="history-filter-name" placeholder="Search by test title..." style="border-color: black; border-style: outset;">
1082
- <select id="history-filter-status">
1083
- <option value="">All Statuses</option>
1084
- <option value="passed">Passed</option>
1085
- <option value="failed">Failed</option>
1086
- <option value="skipped">Skipped</option>
1087
- </select>
1088
- <button id="clear-history-filters" class="clear-filters-btn">Clear Filters</button>
1089
- </div>
1090
-
1091
- <div class="test-history-grid">
1092
- ${testHistory
1093
- .map((test) => {
1094
- const latestRun =
1095
- test.history.length > 0
1096
- ? test.history[test.history.length - 1]
1097
- : { status: "unknown" };
1098
- return `
1099
- <div class="test-history-card" data-test-name="${sanitizeHTML(
1100
- test.testTitle.toLowerCase()
1101
- )}" data-latest-status="${latestRun.status}">
1102
- <div class="test-history-header">
1103
- <p title="${sanitizeHTML(test.testTitle)}">${capitalize(
1104
- sanitizeHTML(test.testTitle)
1105
- )}</p>
1106
- <span class="status-badge ${getStatusClass(latestRun.status)}">
1107
- ${String(latestRun.status).toUpperCase()}
1108
- </span>
1109
- </div>
1110
- <div class="test-history-trend">
1111
- ${generateTestHistoryChart(test.history)}
1112
- </div>
1113
- <details class="test-history-details-collapsible">
1114
- <summary>Show Run Details (${test.history.length})</summary>
1115
- <div class="test-history-details">
1116
- <table>
1117
- <thead><tr><th>Run</th><th>Status</th><th>Duration</th><th>Date</th></tr></thead>
1118
- <tbody>
1119
- ${test.history
1120
- .slice()
1121
- .reverse()
1122
- .map(
1123
- (run) => `
1124
- <tr>
1125
- <td>${run.runId}</td>
1126
- <td><span class="status-badge-small ${getStatusClass(
1127
- run.status
1128
- )}">${String(run.status).toUpperCase()}</span></td>
1129
- <td>${formatDuration(run.duration)}</td>
1130
- <td>${formatDate(run.timestamp)}</td>
1131
- </tr>`
1132
- )
1133
- .join("")}
1134
- </tbody>
1135
- </table>
1136
- </div>
1137
- </details>
1138
- </div>`;
1139
- })
1140
- .join("")}
1141
- </div>
1142
- </div>
1177
+ <style>
1178
+ .worker-modal-overlay {
1179
+ position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%;
1180
+ overflow: auto; background-color: rgba(0,0,0,0.8);
1181
+ display: none; align-items: center; justify-content: center;
1182
+ }
1183
+ .worker-modal-content {
1184
+ background-color: #1f2937;
1185
+ color: #f9fafb;
1186
+ margin: auto; padding: 20px; border: 1px solid #4b5563;
1187
+ width: 80%; max-width: 700px; border-radius: 8px;
1188
+ position: relative; box-shadow: 0 5px 15px rgba(0,0,0,0.7);
1189
+ }
1190
+ .worker-modal-close {
1191
+ position: absolute; top: 10px; right: 20px;
1192
+ font-size: 28px; font-weight: bold; cursor: pointer;
1193
+ line-height: 1; color: #d1d5db;
1194
+ }
1195
+ .worker-modal-close:hover, .worker-modal-close:focus {
1196
+ color: #f9fafb;
1197
+ }
1198
+ #worker-modal-body-${chartId} ul {
1199
+ list-style-type: none; padding-left: 0; margin-top: 15px; max-height: 45vh; overflow-y: auto;
1200
+ }
1201
+ #worker-modal-body-${chartId} li {
1202
+ padding: 8px 5px; border-bottom: 1px solid #4b5563;
1203
+ font-size: 0.9em; color: #f9fafb;
1204
+ }
1205
+ #worker-modal-body-${chartId} li:last-child {
1206
+ border-bottom: none;
1207
+ }
1208
+ #worker-modal-body-${chartId} li > span {
1209
+ display: inline-block;
1210
+ width: 70px;
1211
+ font-weight: bold;
1212
+ text-align: right;
1213
+ margin-right: 10px;
1214
+ color: #d1d5db;
1215
+ }
1216
+ </style>
1217
+
1218
+ <div id="${chartId}" class="trend-chart-container lazy-load-chart" data-render-function-name="${renderFunctionName}" style="min-height: 350px;">
1219
+ <div class="no-data">Loading Worker Distribution Chart...</div>
1220
+ </div>
1221
+
1222
+ <div id="worker-modal-${chartId}" class="worker-modal-overlay">
1223
+ <div class="worker-modal-content">
1224
+ <span class="worker-modal-close">×</span>
1225
+ <h3 id="worker-modal-title-${chartId}" style="text-align: center; margin-top: 0; margin-bottom: 25px; font-size: 1.25em; font-weight: 600; color: #fff"></h3>
1226
+ <div id="worker-modal-body-${chartId}"></div>
1227
+ </div>
1228
+ </div>
1229
+
1230
+ <script>
1231
+ // Namespace for modal functions to avoid global scope pollution
1232
+ window.${modalJsNamespace} = {};
1233
+
1234
+ window.${renderFunctionName} = function() {
1235
+ const chartContainer = document.getElementById('${chartId}');
1236
+ if (!chartContainer) { console.error("Chart container ${chartId} not found."); return; }
1237
+
1238
+ // --- Modal Setup ---
1239
+ const modal = document.getElementById('worker-modal-${chartId}');
1240
+ const modalTitle = document.getElementById('worker-modal-title-${chartId}');
1241
+ const modalBody = document.getElementById('worker-modal-body-${chartId}');
1242
+ const closeModalBtn = modal.querySelector('.worker-modal-close');
1243
+ if (modal && modal.parentElement !== document.body) {
1244
+ document.body.appendChild(modal);
1245
+ }
1246
+
1247
+ // Lightweight HTML escaper for client-side use
1248
+ function __escHtml(s){return String(s==null?'':s).replace(/[&<>\"]/g,function(ch){return ch==='&'?'&amp;':ch==='<'?'&lt;':ch==='>'?'&gt;':'&quot;';});}
1249
+
1250
+ window.${modalJsNamespace}.open = function(worker) {
1251
+ if (!worker) return;
1252
+ try {
1253
+ modalTitle.textContent = 'Test Details for ' + worker.name;
1254
+
1255
+ let testListHtml = '<ul>';
1256
+ if (worker.tests && worker.tests.length > 0) {
1257
+ worker.tests.forEach(test => {
1258
+ let color = 'inherit';
1259
+ if (test.status === 'passed') color = 'var(--success-color)';
1260
+ else if (test.status === 'failed') color = 'var(--danger-color)';
1261
+ else if (test.status === 'skipped') color = 'var(--warning-color)';
1262
+
1263
+ const safeName = __escHtml(test.name);
1264
+ testListHtml += '<li style="color: ' + color + ';"><span style="color: ' + color + '">[' + String(test.status).toUpperCase() + ']</span> ' + safeName + '</li>';
1265
+ });
1266
+ } else {
1267
+ testListHtml += '<li>No detailed test data available for this worker.</li>';
1268
+ }
1269
+ testListHtml += '</ul>';
1270
+
1271
+ modalBody.innerHTML = testListHtml;
1272
+ if (typeof openModal === 'function') openModal(); else modal.style.display = 'flex';
1273
+ } catch (err) {
1274
+ console.error('Failed to open worker modal:', err);
1275
+ }
1276
+ };
1277
+
1278
+ const closeModal = function() {
1279
+ modal.style.display = 'none';
1280
+ try { document.body.style.overflow = ''; } catch (_) {}
1281
+ };
1282
+
1283
+ const openModal = function() {
1284
+ modal.style.display = 'flex';
1285
+ try { document.body.style.overflow = 'hidden'; } catch (_) {}
1286
+ };
1287
+
1288
+ if (closeModalBtn) closeModalBtn.onclick = closeModal;
1289
+ modal.addEventListener('click', function(event) {
1290
+ if (event.target === modal) {
1291
+ closeModal();
1292
+ }
1293
+ });
1294
+
1295
+ document.addEventListener('keydown', function escHandler(e) {
1296
+ if (modal.style.display === 'flex' && (e.key === 'Escape' || e.key === 'Esc')) {
1297
+ closeModal();
1298
+ }
1299
+ });
1300
+
1301
+
1302
+ // --- Highcharts Setup ---
1303
+ if (typeof Highcharts !== 'undefined') {
1304
+ try {
1305
+ chartContainer.innerHTML = '';
1306
+ const fullData = ${fullDataString};
1307
+
1308
+ const chartOptions = {
1309
+ chart: { type: 'bar', height: 350, backgroundColor: 'transparent' },
1310
+ title: { text: null },
1311
+ xAxis: {
1312
+ categories: ${categoriesString},
1313
+ title: { text: 'Worker ID' },
1314
+ labels: { style: { color: 'var(--text-color-secondary)' }}
1315
+ },
1316
+ yAxis: {
1317
+ min: 0,
1318
+ title: { text: 'Number of Tests' },
1319
+ labels: { style: { color: 'var(--text-color-secondary)' }},
1320
+ stackLabels: { enabled: true, style: { fontWeight: 'bold', color: 'var(--text-color)' } }
1321
+ },
1322
+ legend: { reversed: true, itemStyle: { fontSize: "12px", color: 'var(--text-color)' } },
1323
+ plotOptions: {
1324
+ series: {
1325
+ stacking: 'normal',
1326
+ cursor: 'pointer',
1327
+ point: {
1328
+ events: {
1329
+ click: function () {
1330
+ // 'this.x' is the index of the category
1331
+ const workerData = fullData[this.x];
1332
+ window.${modalJsNamespace}.open(workerData);
1333
+ }
1334
+ }
1335
+ }
1336
+ }
1337
+ },
1338
+ tooltip: {
1339
+ shared: true,
1340
+ headerFormat: '<b>{point.key}</b> (Click for details)<br/>',
1341
+ pointFormat: '<span style="color:{series.color}">●</span> {series.name}: <b>{point.y}</b><br/>',
1342
+ footerFormat: 'Total: <b>{point.total}</b>'
1343
+ },
1344
+ series: ${seriesString},
1345
+ credits: { enabled: false }
1346
+ };
1347
+ Highcharts.chart('${chartId}', chartOptions);
1348
+ } catch (e) {
1349
+ console.error("Error rendering chart ${chartId}:", e);
1350
+ chartContainer.innerHTML = '<div class="no-data">Error rendering worker distribution chart.</div>';
1351
+ }
1352
+ } else {
1353
+ chartContainer.innerHTML = '<div class="no-data">Charting library not available for worker distribution.</div>';
1354
+ }
1355
+ };
1356
+ </script>
1357
+ `;
1358
+ }
1359
+ /**
1360
+ * A tooltip providing information about why worker -1 is special in Playwright.
1361
+ * @type {string}
1362
+ */
1363
+ const infoTooltip = `
1364
+ <span class="info-tooltip" style="display: inline-block; margin-left: 8px;">
1365
+ <span class="info-icon"
1366
+ style="cursor: pointer; font-size: 1.25rem;"
1367
+ onclick="window.workerInfoPrompt()">ℹ️</span>
1368
+ </span>
1369
+ <script>
1370
+ window.workerInfoPrompt = function() {
1371
+ const message = 'Why is worker -1 special?\\n\\n' +
1372
+ 'Playwright assigns skipped tests to worker -1 because:\\n' +
1373
+ '1. They don\\'t require browser execution\\n' +
1374
+ '2. This keeps real workers focused on actual tests\\n' +
1375
+ '3. Maintains clean reporting\\n\\n' +
1376
+ 'This is an intentional optimization by Playwright.';
1377
+ alert(message);
1378
+ }
1379
+ </script>
1380
+ `;
1381
+ /**
1382
+ * Generates the HTML content for the test history section.
1383
+ * @param {object} trendData - The historical trend data.
1384
+ * @returns {string} The HTML string for the test history content.
1385
+ */
1386
+ function generateTestHistoryContent(trendData) {
1387
+ if (
1388
+ !trendData ||
1389
+ !trendData.testRuns ||
1390
+ Object.keys(trendData.testRuns).length === 0
1391
+ ) {
1392
+ return '<div class="no-data">No historical test data available.</div>';
1393
+ }
1394
+
1395
+ const allTestNamesAndPaths = new Map();
1396
+ Object.values(trendData.testRuns).forEach((run) => {
1397
+ if (Array.isArray(run)) {
1398
+ run.forEach((test) => {
1399
+ if (test && test.testName && !allTestNamesAndPaths.has(test.testName)) {
1400
+ const parts = test.testName.split(" > ");
1401
+ const title = parts[parts.length - 1];
1402
+ allTestNamesAndPaths.set(test.testName, title);
1403
+ }
1404
+ });
1405
+ }
1406
+ });
1407
+
1408
+ if (allTestNamesAndPaths.size === 0) {
1409
+ return '<div class="no-data">No historical test data found after processing.</div>';
1410
+ }
1411
+
1412
+ const testHistory = Array.from(allTestNamesAndPaths.entries())
1413
+ .map(([fullTestName, testTitle]) => {
1414
+ const history = [];
1415
+ (trendData.overall || []).forEach((overallRun, index) => {
1416
+ const runKey = overallRun.runId
1417
+ ? `test run ${overallRun.runId}`
1418
+ : `test run ${index + 1}`;
1419
+ const testRunForThisOverallRun = trendData.testRuns[runKey]?.find(
1420
+ (t) => t && t.testName === fullTestName
1421
+ );
1422
+ if (testRunForThisOverallRun) {
1423
+ history.push({
1424
+ runId: overallRun.runId || index + 1,
1425
+ status: testRunForThisOverallRun.status || "unknown",
1426
+ duration: testRunForThisOverallRun.duration || 0,
1427
+ timestamp:
1428
+ testRunForThisOverallRun.timestamp ||
1429
+ overallRun.timestamp ||
1430
+ new Date(),
1431
+ });
1432
+ }
1433
+ });
1434
+ return { fullTestName, testTitle, history };
1435
+ })
1436
+ .filter((item) => item.history.length > 0);
1437
+
1438
+ return `
1439
+ <div class="test-history-container">
1440
+ <div class="filters" style="border-color: black; border-style: groove;">
1441
+ <input type="text" id="history-filter-name" placeholder="Search by test title..." style="border-color: black; border-style: outset;">
1442
+ <select id="history-filter-status">
1443
+ <option value="">All Statuses</option>
1444
+ <option value="passed">Passed</option>
1445
+ <option value="failed">Failed</option>
1446
+ <option value="skipped">Skipped</option>
1447
+ </select>
1448
+ <button id="clear-history-filters" class="clear-filters-btn">Clear Filters</button>
1449
+ </div>
1450
+
1451
+ <div class="test-history-grid">
1452
+ ${testHistory
1453
+ .map((test) => {
1454
+ const latestRun =
1455
+ test.history.length > 0
1456
+ ? test.history[test.history.length - 1]
1457
+ : { status: "unknown" };
1458
+ return `
1459
+ <div class="test-history-card" data-test-name="${sanitizeHTML(
1460
+ test.testTitle.toLowerCase()
1461
+ )}" data-latest-status="${latestRun.status}">
1462
+ <div class="test-history-header">
1463
+ <p title="${sanitizeHTML(test.testTitle)}">${capitalize(
1464
+ sanitizeHTML(test.testTitle)
1465
+ )}</p>
1466
+ <span class="status-badge ${getStatusClass(latestRun.status)}">
1467
+ ${String(latestRun.status).toUpperCase()}
1468
+ </span>
1469
+ </div>
1470
+ <div class="test-history-trend">
1471
+ ${generateTestHistoryChart(test.history)}
1472
+ </div>
1473
+ <details class="test-history-details-collapsible">
1474
+ <summary>Show Run Details (${test.history.length})</summary>
1475
+ <div class="test-history-details">
1476
+ <table>
1477
+ <thead><tr><th>Run</th><th>Status</th><th>Duration</th><th>Date</th></tr></thead>
1478
+ <tbody>
1479
+ ${test.history
1480
+ .slice()
1481
+ .reverse()
1482
+ .map(
1483
+ (run) => `
1484
+ <tr>
1485
+ <td>${run.runId}</td>
1486
+ <td><span class="status-badge-small ${getStatusClass(
1487
+ run.status
1488
+ )}">${String(run.status).toUpperCase()}</span></td>
1489
+ <td>${formatDuration(run.duration)}</td>
1490
+ <td>${formatDate(run.timestamp)}</td>
1491
+ </tr>`
1492
+ )
1493
+ .join("")}
1494
+ </tbody>
1495
+ </table>
1496
+ </div>
1497
+ </details>
1498
+ </div>`;
1499
+ })
1500
+ .join("")}
1501
+ </div>
1502
+ </div>
1143
1503
  `;
1144
1504
  }
1505
+ /**
1506
+ * Gets the CSS class for a given test status.
1507
+ * @param {string} status - The test status.
1508
+ * @returns {string} The CSS class for the status.
1509
+ */
1145
1510
  function getStatusClass(status) {
1146
1511
  switch (String(status).toLowerCase()) {
1147
1512
  case "passed":
@@ -1154,6 +1519,11 @@ function getStatusClass(status) {
1154
1519
  return "status-unknown";
1155
1520
  }
1156
1521
  }
1522
+ /**
1523
+ * Gets the icon for a given test status.
1524
+ * @param {string} status - The test status.
1525
+ * @returns {string} The icon for the status.
1526
+ */
1157
1527
  function getStatusIcon(status) {
1158
1528
  switch (String(status).toLowerCase()) {
1159
1529
  case "passed":
@@ -1166,6 +1536,11 @@ function getStatusIcon(status) {
1166
1536
  return "❓";
1167
1537
  }
1168
1538
  }
1539
+ /**
1540
+ * Processes test results to extract suite data.
1541
+ * @param {Array<object>} results - The test results.
1542
+ * @returns {Array<object>} An array of suite data objects.
1543
+ */
1169
1544
  function getSuitesData(results) {
1170
1545
  const suitesMap = new Map();
1171
1546
  if (!results || results.length === 0) return [];
@@ -1214,6 +1589,16 @@ function getSuitesData(results) {
1214
1589
  });
1215
1590
  return Array.from(suitesMap.values());
1216
1591
  }
1592
+ /**
1593
+ * Returns an icon for a given content type.
1594
+ * @param {string} contentType - The content type of the file.
1595
+ * @returns {string} The icon for the content type.
1596
+ */
1597
+ /**
1598
+ * Returns an icon for a given content type.
1599
+ * @param {string} contentType - The content type of the file.
1600
+ * @returns {string} The icon for the content type.
1601
+ */
1217
1602
  function getAttachmentIcon(contentType) {
1218
1603
  if (!contentType) return "📎"; // Handle undefined/null
1219
1604
 
@@ -1227,6 +1612,16 @@ function getAttachmentIcon(contentType) {
1227
1612
  if (normalizedType.startsWith("text/")) return "📝";
1228
1613
  return "📎";
1229
1614
  }
1615
+ /**
1616
+ * Generates the HTML for the suites widget.
1617
+ * @param {Array} suitesData - The data for the suites.
1618
+ * @returns {string} The HTML for the suites widget.
1619
+ */
1620
+ /**
1621
+ * Generates the HTML for the suites widget.
1622
+ * @param {Array} suitesData - The data for the suites.
1623
+ * @returns {string} The HTML for the suites widget.
1624
+ */
1230
1625
  function generateSuitesWidget(suitesData) {
1231
1626
  if (!suitesData || suitesData.length === 0) {
1232
1627
  return `<div class="suites-widget"><div class="suites-header"><h2>Test Suites</h2></div><div class="no-data">No suite data available.</div></div>`;
@@ -1270,273 +1665,122 @@ function generateSuitesWidget(suitesData) {
1270
1665
  ? `<span class="stat-failed" title="Failed"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-x-circle-fill" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/></svg> ${suite.failed}</span>`
1271
1666
  : ""
1272
1667
  }
1273
- ${
1274
- suite.skipped > 0
1275
- ? `<span class="stat-skipped" title="Skipped"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-exclamation-triangle-fill" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg> ${suite.skipped}</span>`
1276
- : ""
1277
- }
1278
- </div>
1279
- </div>
1280
- </div>`
1281
- )
1282
- .join("")}
1283
- </div>
1284
- </div>`;
1285
- }
1286
- function generateWorkerDistributionChart(results) {
1287
- if (!results || results.length === 0) {
1288
- return '<div class="no-data">No test results data available to display worker distribution.</div>';
1289
- }
1290
-
1291
- // 1. Sort results by startTime to ensure chronological order
1292
- const sortedResults = [...results].sort((a, b) => {
1293
- const timeA = a.startTime ? new Date(a.startTime).getTime() : 0;
1294
- const timeB = b.startTime ? new Date(b.startTime).getTime() : 0;
1295
- return timeA - timeB;
1296
- });
1297
-
1298
- const workerData = sortedResults.reduce((acc, test) => {
1299
- const workerId =
1300
- typeof test.workerId !== "undefined" ? test.workerId : "N/A";
1301
- if (!acc[workerId]) {
1302
- acc[workerId] = { passed: 0, failed: 0, skipped: 0, tests: [] };
1303
- }
1304
-
1305
- const status = String(test.status).toLowerCase();
1306
- if (status === "passed" || status === "failed" || status === "skipped") {
1307
- acc[workerId][status]++;
1308
- }
1309
-
1310
- const testTitleParts = test.name.split(" > ");
1311
- const testTitle =
1312
- testTitleParts[testTitleParts.length - 1] || "Unnamed Test";
1313
- // Store both name and status for each test
1314
- acc[workerId].tests.push({ name: testTitle, status: status });
1315
-
1316
- return acc;
1317
- }, {});
1318
-
1319
- const workerIds = Object.keys(workerData).sort((a, b) => {
1320
- if (a === "N/A") return 1;
1321
- if (b === "N/A") return -1;
1322
- return parseInt(a, 10) - parseInt(b, 10);
1323
- });
1324
-
1325
- if (workerIds.length === 0) {
1326
- return '<div class="no-data">Could not determine worker distribution from test data.</div>';
1327
- }
1328
-
1329
- const chartId = `workerDistChart-${Date.now()}-${Math.random()
1330
- .toString(36)
1331
- .substring(2, 7)}`;
1332
- const renderFunctionName = `renderWorkerDistChart_${chartId.replace(
1333
- /-/g,
1334
- "_"
1335
- )}`;
1336
- const modalJsNamespace = `modal_funcs_${chartId.replace(/-/g, "_")}`;
1337
-
1338
- // The categories now just need the name for the axis labels
1339
- const categories = workerIds.map((id) => `Worker ${id}`);
1340
-
1341
- // We pass the full data separately to the script
1342
- const fullWorkerData = workerIds.map((id) => ({
1343
- id: id,
1344
- name: `Worker ${id}`,
1345
- tests: workerData[id].tests,
1346
- }));
1668
+ ${
1669
+ suite.skipped > 0
1670
+ ? `<span class="stat-skipped" title="Skipped"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-exclamation-triangle-fill" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg> ${suite.skipped}</span>`
1671
+ : ""
1672
+ }
1673
+ </div>
1674
+ </div>
1675
+ </div>`
1676
+ )
1677
+ .join("")}
1678
+ </div>
1679
+ </div>`;
1680
+ }
1681
+ /**
1682
+ * Generates the HTML for the AI failure analyzer tab.
1683
+ * @param {Array} results - The results of the test run.
1684
+ * @returns {string} The HTML for the AI failure analyzer tab.
1685
+ */
1686
+ function generateAIFailureAnalyzerTab(results) {
1687
+ const failedTests = (results || []).filter(
1688
+ (test) => test.status === "failed"
1689
+ );
1347
1690
 
1348
- const passedData = workerIds.map((id) => workerData[id].passed);
1349
- const failedData = workerIds.map((id) => workerData[id].failed);
1350
- const skippedData = workerIds.map((id) => workerData[id].skipped);
1691
+ if (failedTests.length === 0) {
1692
+ return `
1693
+ <h2 class="tab-main-title">AI Failure Analysis</h2>
1694
+ <div class="no-data">Congratulations! No failed tests in this run.</div>
1695
+ `;
1696
+ }
1351
1697
 
1352
- const categoriesString = JSON.stringify(categories);
1353
- const fullDataString = JSON.stringify(fullWorkerData);
1354
- const seriesString = JSON.stringify([
1355
- { name: "Passed", data: passedData, color: "var(--success-color)" },
1356
- { name: "Failed", data: failedData, color: "var(--danger-color)" },
1357
- { name: "Skipped", data: skippedData, color: "var(--warning-color)" },
1358
- ]);
1698
+ // btoa is not available in Node.js environment, so we define a simple polyfill for it.
1699
+ const btoa = (str) => Buffer.from(str).toString("base64");
1359
1700
 
1360
- // The HTML now includes the chart container, the modal, and styles for the modal
1361
1701
  return `
1362
- <style>
1363
- .worker-modal-overlay {
1364
- position: fixed; z-index: 1050; left: 0; top: 0; width: 100%; height: 100%;
1365
- overflow: auto; background-color: rgba(0,0,0,0.6);
1366
- display: none; align-items: center; justify-content: center;
1367
- }
1368
- .worker-modal-content {
1369
- background-color: #3d4043;
1370
- color: var(--card-background-color);
1371
- margin: auto; padding: 20px; border: 1px solid var(--border-color, #888);
1372
- width: 80%; max-width: 700px; border-radius: 8px;
1373
- position: relative; box-shadow: 0 5px 15px rgba(0,0,0,0.5);
1374
- }
1375
- .worker-modal-close {
1376
- position: absolute; top: 10px; right: 20px;
1377
- font-size: 28px; font-weight: bold; cursor: pointer;
1378
- line-height: 1;
1379
- }
1380
- .worker-modal-close:hover, .worker-modal-close:focus {
1381
- color: var(--text-color, #000);
1382
- }
1383
- #worker-modal-body-${chartId} ul {
1384
- list-style-type: none; padding-left: 0; margin-top: 15px; max-height: 45vh; overflow-y: auto;
1385
- }
1386
- #worker-modal-body-${chartId} li {
1387
- padding: 8px 5px; border-bottom: 1px solid var(--border-color, #eee);
1388
- font-size: 0.9em;
1389
- }
1390
- #worker-modal-body-${chartId} li:last-child {
1391
- border-bottom: none;
1392
- }
1393
- #worker-modal-body-${chartId} li > span {
1394
- display: inline-block;
1395
- width: 70px;
1396
- font-weight: bold;
1397
- text-align: right;
1398
- margin-right: 10px;
1399
- }
1400
- </style>
1401
-
1402
- <div id="${chartId}" class="trend-chart-container lazy-load-chart" data-render-function-name="${renderFunctionName}" style="min-height: 350px;">
1403
- <div class="no-data">Loading Worker Distribution Chart...</div>
1702
+ <h2 class="tab-main-title">AI Failure Analysis</h2>
1703
+ <div class="ai-analyzer-stats">
1704
+ <div class="stat-item">
1705
+ <span class="stat-number">${failedTests.length}</span>
1706
+ <span class="stat-label">Failed Tests</span>
1707
+ </div>
1708
+ <div class="stat-item">
1709
+ <span class="stat-number">${
1710
+ new Set(failedTests.map((t) => t.browser)).size
1711
+ }</span>
1712
+ <span class="stat-label">Browsers</span>
1713
+ </div>
1714
+ <div class="stat-item">
1715
+ <span class="stat-number">${Math.round(
1716
+ failedTests.reduce((sum, test) => sum + (test.duration || 0), 0) /
1717
+ 1000
1718
+ )}s</span>
1719
+ <span class="stat-label">Total Duration</span>
1720
+ </div>
1404
1721
  </div>
1405
-
1406
- <div id="worker-modal-${chartId}" class="worker-modal-overlay">
1407
- <div class="worker-modal-content">
1408
- <span class="worker-modal-close">×</span>
1409
- <h3 id="worker-modal-title-${chartId}" style="text-align: center; margin-top: 0; margin-bottom: 25px; font-size: 1.25em; font-weight: 600; color: #fff"></h3>
1410
- <div id="worker-modal-body-${chartId}"></div>
1411
- </div>
1722
+ <p class="ai-analyzer-description">
1723
+ Analyze failed tests using AI to get suggestions and potential fixes. Click the AI Fix button for specific failed test.
1724
+ </p>
1725
+
1726
+ <div class="compact-failure-list">
1727
+ ${failedTests
1728
+ .map((test) => {
1729
+ const testTitle = test.name.split(" > ").pop() || "Unnamed Test";
1730
+ const testJson = btoa(JSON.stringify(test)); // Base64 encode the test object
1731
+ const truncatedError =
1732
+ (test.errorMessage || "No error message").slice(0, 150) +
1733
+ (test.errorMessage && test.errorMessage.length > 150 ? "..." : "");
1734
+
1735
+ return `
1736
+ <div class="compact-failure-item">
1737
+ <div class="failure-header">
1738
+ <div class="failure-main-info">
1739
+ <h3 class="failure-title" title="${sanitizeHTML(
1740
+ test.name
1741
+ )}">${sanitizeHTML(testTitle)}</h3>
1742
+ <div class="failure-meta">
1743
+ <span class="browser-indicator">${sanitizeHTML(
1744
+ test.browser || "unknown"
1745
+ )}</span>
1746
+ <span class="duration-indicator">${formatDuration(
1747
+ test.duration
1748
+ )}</span>
1749
+ </div>
1750
+ </div>
1751
+ <button class="compact-ai-btn" onclick="getAIFix(this)" data-test-json="${testJson}">
1752
+ <span class="ai-text">AI Fix</span>
1753
+ </button>
1754
+ </div>
1755
+ <div class="failure-error-preview">
1756
+ <div class="error-snippet">${formatPlaywrightError(
1757
+ truncatedError
1758
+ )}</div>
1759
+ <button class="expand-error-btn" onclick="toggleErrorDetails(this)">
1760
+ <span class="expand-text">Show Full Error</span>
1761
+ <span class="expand-icon">▼</span>
1762
+ </button>
1763
+ </div>
1764
+ <div class="full-error-details" style="display: none;">
1765
+ <div class="full-error-content">
1766
+ ${formatPlaywrightError(
1767
+ test.errorMessage || "No detailed error message available"
1768
+ )}
1769
+ </div>
1770
+ </div>
1771
+ </div>
1772
+ `;
1773
+ })
1774
+ .join("")}
1412
1775
  </div>
1413
-
1414
- <script>
1415
- // Namespace for modal functions to avoid global scope pollution
1416
- window.${modalJsNamespace} = {};
1417
-
1418
- window.${renderFunctionName} = function() {
1419
- const chartContainer = document.getElementById('${chartId}');
1420
- if (!chartContainer) { console.error("Chart container ${chartId} not found."); return; }
1421
-
1422
- // --- Modal Setup ---
1423
- const modal = document.getElementById('worker-modal-${chartId}');
1424
- const modalTitle = document.getElementById('worker-modal-title-${chartId}');
1425
- const modalBody = document.getElementById('worker-modal-body-${chartId}');
1426
- const closeModalBtn = modal.querySelector('.worker-modal-close');
1427
-
1428
- window.${modalJsNamespace}.open = function(worker) {
1429
- if (!worker) return;
1430
- modalTitle.textContent = 'Test Details for ' + worker.name;
1431
-
1432
- let testListHtml = '<ul>';
1433
- if (worker.tests && worker.tests.length > 0) {
1434
- worker.tests.forEach(test => {
1435
- let color = 'inherit';
1436
- if (test.status === 'passed') color = 'var(--success-color)';
1437
- else if (test.status === 'failed') color = 'var(--danger-color)';
1438
- else if (test.status === 'skipped') color = 'var(--warning-color)';
1439
-
1440
- const escapedName = test.name.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
1441
- testListHtml += \`<li style="color: \${color};"><span style="color: \${color}">[\${test.status.toUpperCase()}]</span> \${escapedName}</li>\`;
1442
- });
1443
- } else {
1444
- testListHtml += '<li>No detailed test data available for this worker.</li>';
1445
- }
1446
- testListHtml += '</ul>';
1447
-
1448
- modalBody.innerHTML = testListHtml;
1449
- modal.style.display = 'flex';
1450
- };
1451
-
1452
- const closeModal = function() {
1453
- modal.style.display = 'none';
1454
- };
1455
-
1456
- closeModalBtn.onclick = closeModal;
1457
- modal.onclick = function(event) {
1458
- // Close if clicked on the dark overlay background
1459
- if (event.target == modal) {
1460
- closeModal();
1461
- }
1462
- };
1463
-
1464
-
1465
- // --- Highcharts Setup ---
1466
- if (typeof Highcharts !== 'undefined') {
1467
- try {
1468
- chartContainer.innerHTML = '';
1469
- const fullData = ${fullDataString};
1470
-
1471
- const chartOptions = {
1472
- chart: { type: 'bar', height: 350, backgroundColor: 'transparent' },
1473
- title: { text: null },
1474
- xAxis: {
1475
- categories: ${categoriesString},
1476
- title: { text: 'Worker ID' },
1477
- labels: { style: { color: 'var(--text-color-secondary)' }}
1478
- },
1479
- yAxis: {
1480
- min: 0,
1481
- title: { text: 'Number of Tests' },
1482
- labels: { style: { color: 'var(--text-color-secondary)' }},
1483
- stackLabels: { enabled: true, style: { fontWeight: 'bold', color: 'var(--text-color)' } }
1484
- },
1485
- legend: { reversed: true, itemStyle: { fontSize: "12px", color: 'var(--text-color)' } },
1486
- plotOptions: {
1487
- series: {
1488
- stacking: 'normal',
1489
- cursor: 'pointer',
1490
- point: {
1491
- events: {
1492
- click: function () {
1493
- // 'this.x' is the index of the category
1494
- const workerData = fullData[this.x];
1495
- window.${modalJsNamespace}.open(workerData);
1496
- }
1497
- }
1498
- }
1499
- }
1500
- },
1501
- tooltip: {
1502
- shared: true,
1503
- headerFormat: '<b>{point.key}</b> (Click for details)<br/>',
1504
- pointFormat: '<span style="color:{series.color}">●</span> {series.name}: <b>{point.y}</b><br/>',
1505
- footerFormat: 'Total: <b>{point.total}</b>'
1506
- },
1507
- series: ${seriesString},
1508
- credits: { enabled: false }
1509
- };
1510
- Highcharts.chart('${chartId}', chartOptions);
1511
- } catch (e) {
1512
- console.error("Error rendering chart ${chartId}:", e);
1513
- chartContainer.innerHTML = '<div class="no-data">Error rendering worker distribution chart.</div>';
1514
- }
1515
- } else {
1516
- chartContainer.innerHTML = '<div class="no-data">Charting library not available for worker distribution.</div>';
1517
- }
1518
- };
1519
- </script>
1520
1776
  `;
1521
1777
  }
1522
- const infoTooltip = `
1523
- <span class="info-tooltip" style="display: inline-block; margin-left: 8px;">
1524
- <span class="info-icon"
1525
- style="cursor: pointer; font-size: 1.25rem;"
1526
- onclick="window.workerInfoPrompt()">ℹ️</span>
1527
- </span>
1528
- <script>
1529
- window.workerInfoPrompt = function() {
1530
- const message = 'Why is worker -1 special?\\n\\n' +
1531
- 'Playwright assigns skipped tests to worker -1 because:\\n' +
1532
- '1. They don\\'t require browser execution\\n' +
1533
- '2. This keeps real workers focused on actual tests\\n' +
1534
- '3. Maintains clean reporting\\n\\n' +
1535
- 'This is an intentional optimization by Playwright.';
1536
- alert(message);
1537
- }
1538
- </script>
1539
- `;
1778
+ /**
1779
+ * Generates the HTML report.
1780
+ * @param {object} reportData - The data for the report.
1781
+ * @param {object} trendData - The data for the trend chart.
1782
+ * @returns {string} The HTML report.
1783
+ */
1540
1784
  function generateHTML(reportData, trendData = null) {
1541
1785
  const { run, results } = reportData;
1542
1786
  const suitesData = getSuitesData(reportData.results || []);
@@ -1559,11 +1803,16 @@ function generateHTML(reportData, trendData = null) {
1559
1803
  ? formatDuration(runSummary.duration / runSummary.totalTests)
1560
1804
  : "0.0s";
1561
1805
 
1562
- function generateTestCasesHTML() {
1806
+ /**
1807
+ * Generates the HTML for the test cases.
1808
+ * @returns {string} The HTML for the test cases.
1809
+ */
1810
+ function generateTestCasesHTML(subset = results, baseIndex = 0) {
1563
1811
  if (!results || results.length === 0)
1564
1812
  return '<div class="no-tests">No test results found in this run.</div>';
1565
- return results
1566
- .map((test) => {
1813
+ return subset
1814
+ .map((test, i) => {
1815
+ const testIndex = baseIndex + i;
1567
1816
  const browser = test.browser || "unknown";
1568
1817
  const testFileParts = test.name.split(" > ");
1569
1818
  const testTitle =
@@ -1601,6 +1850,42 @@ function generateHTML(reportData, trendData = null) {
1601
1850
  : ""
1602
1851
  }<button class="copy-error-btn" onclick="copyErrorToClipboard(this)">Copy Error Prompt</button></div>`
1603
1852
  : ""
1853
+ }${
1854
+ (() => {
1855
+ if (!step.attachments || step.attachments.length === 0) return "";
1856
+ return `<div class="attachments-section"><h4>Step Attachments</h4><div class="attachments-grid">${step.attachments
1857
+ .map((attachment) => {
1858
+ try {
1859
+ const attachmentPath = path.resolve(
1860
+ DEFAULT_OUTPUT_DIR,
1861
+ attachment.path
1862
+ );
1863
+ if (!fsExistsSync(attachmentPath)) {
1864
+ return `<div class="attachment-item error">Attachment not found: ${sanitizeHTML(
1865
+ attachment.name
1866
+ )}</div>`;
1867
+ }
1868
+ const attachmentBase64 = readFileSync(attachmentPath).toString("base64");
1869
+ const attachmentDataUri = `data:${attachment.contentType};base64,${attachmentBase64}`;
1870
+ return `<div class="attachment-item generic-attachment">
1871
+ <div class="attachment-icon">${getAttachmentIcon(attachment.contentType)}</div>
1872
+ <div class="attachment-caption">
1873
+ <span class="attachment-name" title="${sanitizeHTML(attachment.name)}">${sanitizeHTML(attachment.name)}</span>
1874
+ <span class="attachment-type">${sanitizeHTML(attachment.contentType)}</span>
1875
+ </div>
1876
+ <div class="attachment-info">
1877
+ <div class="trace-actions">
1878
+ <a href="#" data-href="${attachmentDataUri}" class="view-full lazy-load-attachment" target="_blank">View</a>
1879
+ <a href="#" data-href="${attachmentDataUri}" class="lazy-load-attachment" download="${sanitizeHTML(attachment.name)}">Download</a>
1880
+ </div>
1881
+ </div>
1882
+ </div>`;
1883
+ } catch (e) {
1884
+ return `<div class="attachment-item error">Failed to load attachment: ${sanitizeHTML(attachment.name)}</div>`;
1885
+ }
1886
+ })
1887
+ .join("")}</div></div>`;
1888
+ })()
1604
1889
  }${
1605
1890
  hasNestedSteps
1606
1891
  ? `<div class="nested-steps">${generateStepsHTML(
@@ -1618,7 +1903,7 @@ function generateHTML(reportData, trendData = null) {
1618
1903
  test.tags || []
1619
1904
  )
1620
1905
  .join(",")
1621
- .toLowerCase()}">
1906
+ .toLowerCase()}" data-test-id="${sanitizeHTML(String(test.id || testIndex))}">
1622
1907
  <div class="test-case-header" role="button" aria-expanded="false"><div class="test-case-summary"><span class="status-badge ${getStatusClass(
1623
1908
  test.status
1624
1909
  )}">${String(
@@ -1654,6 +1939,13 @@ function generateHTML(reportData, trendData = null) {
1654
1939
  )}<button class="copy-error-btn" onclick="copyErrorToClipboard(this)">Copy Error Prompt</button></div>`
1655
1940
  : ""
1656
1941
  }
1942
+ ${
1943
+ test.snippet
1944
+ ? `<div class="code-section"><h4>Error Snippet</h4><pre><code>${formatPlaywrightError(
1945
+ test.snippet
1946
+ )}</code></pre></div>`
1947
+ : ""
1948
+ }
1657
1949
  <h4>Steps</h4><div class="steps-list">${generateStepsHTML(
1658
1950
  test.steps
1659
1951
  )}</div>
@@ -1677,9 +1969,12 @@ function generateHTML(reportData, trendData = null) {
1677
1969
  })()}
1678
1970
  ${
1679
1971
  test.stderr && test.stderr.length > 0
1680
- ? `<div class="console-output-section"><h4>Console Output (stderr)</h4><pre class="console-log stderr-log">${test.stderr
1681
- .map((line) => sanitizeHTML(line))
1682
- .join("\\n")}</pre></div>`
1972
+ ? (() => {
1973
+ const logId = `stderr-log-${test.id || testIndex}`;
1974
+ return `<div class="console-output-section"><h4>Console Output (stderr)</h4><pre id="${logId}" class="console-log stderr-log">${test.stderr
1975
+ .map((line) => sanitizeHTML(line))
1976
+ .join("\\n")}</pre></div>`;
1977
+ })()
1683
1978
  : ""
1684
1979
  }
1685
1980
 
@@ -1704,7 +1999,7 @@ function generateHTML(reportData, trendData = null) {
1704
1999
  readFileSync(imagePath).toString("base64");
1705
2000
  return `<div class="attachment-item"><img src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" data-src="data:image/png;base64,${base64ImageData}" alt="Screenshot ${
1706
2001
  index + 1
1707
- }" class="lazy-load-image"><div class="attachment-info"><div class="trace-actions"><a href="data:image/png;base64,${base64ImageData}" target="_blank" download="screenshot-${index}.png">Download</a></div></div></div>`;
2002
+ }" class="lazy-load-image"><div class="attachment-info"><div class="trace-actions"><a href="#" data-href="data:image/png;base64,${base64ImageData}" class="lazy-load-attachment" target="_blank" download="screenshot-${index}.png">Download</a></div></div></div>`;
1708
2003
  } catch (e) {
1709
2004
  return `<div class="attachment-item error">Failed to load screenshot: ${sanitizeHTML(
1710
2005
  screenshotPath
@@ -1745,7 +2040,7 @@ function generateHTML(reportData, trendData = null) {
1745
2040
  avi: "video/x-msvideo",
1746
2041
  }[fileExtension] || "video/mp4";
1747
2042
  const videoDataUri = `data:${mimeType};base64,${videoBase64}`;
1748
- return `<div class="attachment-item video-item"><video controls preload="none" class="lazy-load-video"><source data-src="${videoDataUri}" type="${mimeType}"></video><div class="attachment-info"><div class="trace-actions"><a href="${videoDataUri}" target="_blank" download="video-${index}.${fileExtension}">Download</a></div></div></div>`;
2043
+ return `<div class="attachment-item video-item"><video controls preload="none" class="lazy-load-video"><source data-src="${videoDataUri}" type="${mimeType}"></video><div class="attachment-info"><div class="trace-actions"><a href="#" data-href="${videoDataUri}" class="lazy-load-attachment" target="_blank" download="video-${index}.${fileExtension}">Download</a></div></div></div>`;
1749
2044
  } catch (e) {
1750
2045
  return `<div class="attachment-item error">Failed to load video: ${sanitizeHTML(
1751
2046
  videoPath
@@ -1821,8 +2116,8 @@ function generateHTML(reportData, trendData = null) {
1821
2116
  </div>
1822
2117
  <div class="attachment-info">
1823
2118
  <div class="trace-actions">
1824
- <a href="${attachmentDataUri}" target="_blank" class="view-full">View</a>
1825
- <a href="${attachmentDataUri}" download="${sanitizeHTML(
2119
+ <a href="#" data-href="${attachmentDataUri}" class="view-full lazy-load-attachment" target="_blank">View</a>
2120
+ <a href="#" data-href="${attachmentDataUri}" class="lazy-load-attachment" download="${sanitizeHTML(
1826
2121
  attachment.name
1827
2122
  )}">Download</a>
1828
2123
  </div>
@@ -1862,172 +2157,241 @@ function generateHTML(reportData, trendData = null) {
1862
2157
  <link rel="icon" type="image/png" href="https://i.postimg.cc/v817w4sg/logo.png">
1863
2158
  <link rel="apple-touch-icon" href="https://i.postimg.cc/v817w4sg/logo.png">
1864
2159
  <script src="https://code.highcharts.com/highcharts.js" defer></script>
1865
- <title>Playwright Pulse Report</title>
1866
- <style>
1867
- :root {
1868
- --primary-color: #3f51b5; --secondary-color: #ff4081; --accent-color: #673ab7; --accent-color-alt: #FF9800;
1869
- --success-color: #4CAF50; --danger-color: #F44336; --warning-color: #FFC107; --info-color: #2196F3;
1870
- --light-gray-color: #f5f5f5; --medium-gray-color: #e0e0e0; --dark-gray-color: #757575;
1871
- --text-color: #333; --text-color-secondary: #555; --border-color: #ddd; --background-color: #f8f9fa;
1872
- --card-background-color: #fff; --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
1873
- --border-radius: 8px; --box-shadow: 0 5px 15px rgba(0,0,0,0.08); --box-shadow-light: 0 3px 8px rgba(0,0,0,0.05); --box-shadow-inset: inset 0 1px 3px rgba(0,0,0,0.07);
1874
- }
1875
- .trend-chart-container, .test-history-trend div[id^="testHistoryChart-"] { min-height: 100px; }
1876
- .lazy-load-chart .no-data, .lazy-load-chart .no-data-chart { display: flex; align-items: center; justify-content: center; height: 100%; font-style: italic; color: var(--dark-gray-color); }
1877
-
1878
- /* General Highcharts styling */
1879
- .highcharts-background { fill: transparent; }
1880
- .highcharts-title, .highcharts-subtitle { font-family: var(--font-family); }
1881
- .highcharts-axis-labels text, .highcharts-legend-item text { fill: var(--text-color-secondary) !important; font-size: 12px !important; }
1882
- .highcharts-axis-title { fill: var(--text-color) !important; }
1883
- .highcharts-tooltip > span { background-color: rgba(10,10,10,0.92) !important; border-color: rgba(10,10,10,0.92) !important; color: #f5f5f5 !important; padding: 10px !important; border-radius: 6px !important; }
1884
- body { font-family: var(--font-family); margin: 0; background-color: var(--background-color); color: var(--text-color); line-height: 1.65; font-size: 16px; }
1885
- .container { padding: 30px; border-radius: var(--border-radius); box-shadow: var(--box-shadow); background: repeating-linear-gradient(#f1f8e9, #f9fbe7, #fce4ec); }
1886
- .header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; padding-bottom: 25px; border-bottom: 1px solid var(--border-color); margin-bottom: 25px; }
1887
- .header-title { display: flex; align-items: center; gap: 15px; }
1888
- .header h1 { margin: 0; font-size: 1.85em; font-weight: 600; color: var(--primary-color); }
1889
- #report-logo { height: 40px; width: 55px; }
1890
- .run-info { font-size: 0.9em; text-align: right; color: var(--text-color-secondary); line-height:1.5;}
1891
- .run-info strong { color: var(--text-color); }
1892
- .tabs { display: flex; border-bottom: 2px solid var(--border-color); margin-bottom: 30px; overflow-x: auto; }
1893
- .tab-button { padding: 15px 25px; background: none; border: none; border-bottom: 3px solid transparent; cursor: pointer; font-size: 1.1em; font-weight: 600; color: black; transition: color 0.2s ease, border-color 0.2s ease; white-space: nowrap; }
1894
- .tab-button:hover { color: var(--accent-color); }
1895
- .tab-button.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
1896
- .tab-content { display: none; animation: fadeIn 0.4s ease-out; }
1897
- .tab-content.active { display: block; }
1898
- @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
1899
- .dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 22px; margin-bottom: 35px; }
1900
- .summary-card { background-color: var(--card-background-color); border: 1px solid var(--border-color); border-radius: var(--border-radius); padding: 22px; text-align: center; box-shadow: var(--box-shadow-light); transition: transform 0.2s ease, box-shadow 0.2s ease; }
1901
- .summary-card:hover { transform: translateY(-5px); box-shadow: var(--box-shadow); }
1902
- .summary-card h3 { margin: 0 0 10px; font-size: 1.05em; font-weight: 500; color: var(--text-color-secondary); }
1903
- .summary-card .value { font-size: 2.4em; font-weight: 600; margin-bottom: 8px; }
1904
- .summary-card .trend-percentage { font-size: 1em; color: var(--dark-gray-color); }
1905
- .status-passed .value, .stat-passed svg { color: var(--success-color); }
1906
- .status-failed .value, .stat-failed svg { color: var(--danger-color); }
1907
- .status-skipped .value, .stat-skipped svg { color: var(--warning-color); }
1908
- .dashboard-bottom-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 28px; align-items: stretch; }
1909
- .pie-chart-wrapper, .suites-widget, .trend-chart { background-color: var(--card-background-color); padding: 28px; border-radius: var(--border-radius); box-shadow: var(--box-shadow-light); display: flex; flex-direction: column; }
1910
- .pie-chart-wrapper h3, .suites-header h2, .trend-chart h3 { text-align: center; margin-top: 0; margin-bottom: 25px; font-size: 1.25em; font-weight: 600; color: var(--text-color); }
1911
- .trend-chart-container, .pie-chart-wrapper div[id^="pieChart-"] { flex-grow: 1; min-height: 250px; }
1912
- .status-badge-small-tooltip { padding: 2px 5px; border-radius: 3px; font-size: 0.9em; font-weight: 600; color: white; text-transform: uppercase; }
1913
- .status-badge-small-tooltip.status-passed { background-color: var(--success-color); }
1914
- .status-badge-small-tooltip.status-failed { background-color: var(--danger-color); }
1915
- .status-badge-small-tooltip.status-skipped { background-color: var(--warning-color); }
1916
- .status-badge-small-tooltip.status-unknown { background-color: var(--dark-gray-color); }
1917
- .suites-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
1918
- .summary-badge { background-color: var(--light-gray-color); color: var(--text-color-secondary); padding: 7px 14px; border-radius: 16px; font-size: 0.9em; }
1919
- .suites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; }
1920
- .suite-card { border: 1px solid var(--border-color); border-left-width: 5px; border-radius: calc(var(--border-radius) / 1.5); padding: 20px; background-color: var(--card-background-color); transition: box-shadow 0.2s ease, border-left-color 0.2s ease; }
1921
- .suite-card:hover { box-shadow: var(--box-shadow); }
1922
- .suite-card.status-passed { border-left-color: var(--success-color); }
1923
- .suite-card.status-failed { border-left-color: var(--danger-color); }
1924
- .suite-card.status-skipped { border-left-color: var(--warning-color); }
1925
- .suite-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
1926
- .suite-name { font-weight: 600; font-size: 1.05em; color: var(--text-color); margin-right: 10px; word-break: break-word;}
1927
- .browser-tag { font-size: 0.8em; background-color: var(--medium-gray-color); color: var(--text-color-secondary); padding: 3px 8px; border-radius: 4px; white-space: nowrap;}
1928
- .suite-card-body .test-count { font-size: 0.95em; color: var(--text-color-secondary); display: block; margin-bottom: 10px; }
1929
- .suite-stats { display: flex; gap: 14px; font-size: 0.95em; align-items: center; }
1930
- .suite-stats span { display: flex; align-items: center; gap: 6px; }
1931
- .suite-stats svg { vertical-align: middle; font-size: 1.15em; }
1932
- .filters { display: flex; flex-wrap: wrap; gap: 18px; margin-bottom: 28px; padding: 20px; background-color: var(--light-gray-color); border-radius: var(--border-radius); box-shadow: var(--box-shadow-inset); border-color: black; border-style: groove; }
1933
- .filters input, .filters select, .filters button { padding: 11px 15px; border: 1px solid var(--border-color); border-radius: 6px; font-size: 1em; }
1934
- .filters input { flex-grow: 1; min-width: 240px;}
1935
- .filters select {min-width: 180px;}
1936
- .filters button { background-color: var(--primary-color); color: white; cursor: pointer; transition: background-color 0.2s ease, box-shadow 0.2s ease; border: none; }
1937
- .filters button:hover { background-color: var(--accent-color); box-shadow: 0 2px 5px rgba(0,0,0,0.15);}
1938
- .test-case { margin-bottom: 15px; border: 1px solid var(--border-color); border-radius: var(--border-radius); background-color: var(--card-background-color); box-shadow: var(--box-shadow-light); overflow: hidden; }
1939
- .test-case-header { padding: 10px 15px; background-color: #fff; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid transparent; transition: background-color 0.2s ease; }
1940
- .test-case-header:hover { background-color: #f4f6f8; }
1941
- .test-case-header[aria-expanded="true"] { border-bottom-color: var(--border-color); background-color: #f9fafb; }
1942
- .test-case-summary { display: flex; align-items: center; gap: 14px; flex-grow: 1; flex-wrap: wrap;}
1943
- .test-case-title { font-weight: 600; color: var(--text-color); font-size: 1em; }
1944
- .test-case-browser { font-size: 0.9em; color: var(--text-color-secondary); }
1945
- .test-case-meta { display: flex; align-items: center; gap: 12px; font-size: 0.9em; color: var(--text-color-secondary); flex-shrink: 0; }
1946
- .test-duration { background-color: var(--light-gray-color); padding: 4px 10px; border-radius: 12px; font-size: 0.9em;}
1947
- .status-badge { padding: 5px; border-radius: 6px; font-size: 0.8em; font-weight: 600; color: white; text-transform: uppercase; min-width: 70px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
1948
- .status-badge.status-passed { background-color: var(--success-color); }
1949
- .status-badge.status-failed { background-color: var(--danger-color); }
1950
- .status-badge.status-skipped { background-color: var(--warning-color); }
1951
- .status-badge.status-unknown { background-color: var(--dark-gray-color); }
1952
- .tag { display: inline-block; background: linear-gradient( #fff, #333, #000); color: #fff; padding: 3px 10px; border-radius: 12px; font-size: 0.85em; margin-right: 6px; font-weight: 400; }
1953
- .test-case-content { display: none; padding: 20px; border-top: 1px solid var(--border-color); background-color: #fcfdff; }
1954
- .test-case-content h4 { margin-top: 22px; margin-bottom: 14px; font-size: 1.15em; color: var(--primary-color); }
1955
- .test-case-content p { margin-bottom: 10px; font-size: 1em; }
1956
- .test-error-summary { margin-bottom: 20px; padding: 14px; background-color: rgba(244,67,54,0.05); border: 1px solid rgba(244,67,54,0.2); border-left: 4px solid var(--danger-color); border-radius: 4px; }
1957
- .test-error-summary h4 { color: var(--danger-color); margin-top:0;}
1958
- .test-error-summary pre { white-space: pre-wrap; word-break: break-all; color: var(--danger-color); font-size: 0.95em;}
1959
- .steps-list { margin: 18px 0; }
1960
- .step-item { margin-bottom: 8px; padding-left: calc(var(--depth, 0) * 28px); }
1961
- .step-header { display: flex; align-items: center; cursor: pointer; padding: 10px 14px; border-radius: 6px; background-color: #fff; border: 1px solid var(--light-gray-color); transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; }
1962
- .step-header:hover { background-color: #f0f2f5; border-color: var(--medium-gray-color); box-shadow: var(--box-shadow-inset); }
1963
- .step-icon { margin-right: 12px; width: 20px; text-align: center; font-size: 1.1em; }
1964
- .step-title { flex: 1; font-size: 1em; }
1965
- .step-duration { color: var(--dark-gray-color); font-size: 0.9em; }
1966
- .step-details { display: none; padding: 14px; margin-top: 8px; background: #fdfdfd; border-radius: 6px; font-size: 0.95em; border: 1px solid var(--light-gray-color); }
1967
- .step-info { margin-bottom: 8px; }
1968
- .test-error-summary { color: var(--danger-color); margin-top: 12px; padding: 14px; background: rgba(244,67,54,0.05); border-radius: 4px; font-size: 0.95em; border-left: 3px solid var(--danger-color); }
1969
- .test-error-summary pre.stack-trace { margin-top: 10px; padding: 12px; background-color: rgba(0,0,0,0.03); border-radius: 4px; font-size:0.9em; max-height: 280px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }
1970
- .step-hook { background-color: rgba(33,150,243,0.04); border-left: 3px solid var(--info-color) !important; }
1971
- .step-hook .step-title { font-style: italic; color: var(--info-color)}
1972
- .nested-steps { margin-top: 12px; }
1973
- .attachments-section { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--light-gray-color); }
1974
- .attachments-section h4 { margin-top: 0; margin-bottom: 20px; font-size: 1.1em; color: var(--text-color); }
1975
- .attachments-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 22px; }
1976
- .attachment-item { border: 1px solid var(--border-color); border-radius: var(--border-radius); background-color: #fff; box-shadow: var(--box-shadow-light); overflow: hidden; display: flex; flex-direction: column; transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; }
1977
- .attachment-item:hover { transform: translateY(-4px); box-shadow: var(--box-shadow); }
1978
- .attachment-item img, .attachment-item video { width: 100%; height: 180px; object-fit: cover; display: block; background-color: #eee; border-bottom: 1px solid var(--border-color); transition: opacity 0.3s ease; }
1979
- .attachment-info { padding: 12px; margin-top: auto; background-color: #fafafa;}
1980
- .attachment-item a:hover img { opacity: 0.85; }
1981
- .attachment-caption { padding: 12px 15px; font-size: 0.9em; text-align: center; color: var(--text-color-secondary); word-break: break-word; background-color: var(--light-gray-color); }
1982
- .video-item a, .trace-item a { display: block; margin-bottom: 8px; color: var(--primary-color); text-decoration: none; font-weight: 500; }
1983
- .video-item a:hover, .trace-item a:hover { text-decoration: underline; }
1984
- .code-section pre { background-color: #2d2d2d; color: #f0f0f0; padding: 20px; border-radius: 6px; overflow-x: auto; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; font-size: 0.95em; line-height:1.6;}
1985
- .trend-charts-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(480px, 1fr)); gap: 28px; margin-bottom: 35px; }
1986
- .test-history-container h2.tab-main-title { font-size: 1.6em; margin-bottom: 18px; color: var(--primary-color); border-bottom: 1px solid var(--border-color); padding-bottom: 12px;}
1987
- .test-history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 22px; margin-top: 22px; }
1988
- .test-history-card { background: var(--card-background-color); border: 1px solid var(--border-color); border-radius: var(--border-radius); padding: 22px; box-shadow: var(--box-shadow-light); display: flex; flex-direction: column; }
1989
- .test-history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 14px; border-bottom: 1px solid var(--light-gray-color); }
1990
- .test-history-header h3 { margin: 0; font-size: 1.15em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* This was h3, changed to p for consistency with user file */
1991
- .test-history-header p { font-weight: 500 } /* Added this */
1992
- .test-history-trend { margin-bottom: 20px; min-height: 110px; }
1993
- .test-history-trend div[id^="testHistoryChart-"] { display: block; margin: 0 auto; max-width:100%; height: 100px; width: 320px; }
1994
- .test-history-details-collapsible summary { cursor: pointer; font-size: 1em; color: var(--primary-color); margin-bottom: 10px; font-weight:500; }
1995
- .test-history-details-collapsible summary:hover {text-decoration: underline;}
1996
- .test-history-details table { width: 100%; border-collapse: collapse; font-size: 0.95em; }
1997
- .test-history-details th, .test-history-details td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--light-gray-color); }
1998
- .test-history-details th { background-color: var(--light-gray-color); font-weight: 600; }
1999
- .status-badge-small { padding: 3px 7px; border-radius: 4px; font-size: 0.8em; font-weight: 600; color: white; text-transform: uppercase; display: inline-block; }
2000
- .status-badge-small.status-passed { background-color: var(--success-color); }
2001
- .status-badge-small.status-failed { background-color: var(--danger-color); }
2002
- .status-badge-small.status-skipped { background-color: var(--warning-color); }
2003
- .status-badge-small.status-unknown { background-color: var(--dark-gray-color); }
2004
- .no-data, .no-tests, .no-steps, .no-data-chart { padding: 28px; text-align: center; color: var(--dark-gray-color); font-style: italic; font-size:1.1em; background-color: var(--light-gray-color); border-radius: var(--border-radius); margin: 18px 0; border: 1px dashed var(--medium-gray-color); }
2005
- .no-data-chart {font-size: 0.95em; padding: 18px;}
2006
- #test-ai iframe { border: 1px solid var(--border-color); width: 100%; height: 85vh; border-radius: var(--border-radius); box-shadow: var(--box-shadow-light); }
2007
- #test-ai p {margin-bottom: 18px; font-size: 1em; color: var(--text-color-secondary);}
2008
- .trace-preview { padding: 1rem; text-align: center; background: #f5f5f5; border-bottom: 1px solid #e1e1e1; }
2009
- .trace-icon { font-size: 2rem; display: block; margin-bottom: 0.5rem; }
2010
- .trace-name { word-break: break-word; font-size: 0.9rem; }
2011
- .trace-actions { display: flex; gap: 0.5rem; }
2012
- .trace-actions a { flex: 1; text-align: center; padding: 0.25rem 0.5rem; font-size: 0.85rem; border-radius: 4px; text-decoration: none; background: cornflowerblue; color: aliceblue; }
2013
- .view-trace { background: #3182ce; color: white; }
2014
- .view-trace:hover { background: #2c5282; }
2015
- .download-trace { background: #e2e8f0; color: #2d3748; }
2016
- .download-trace:hover { background: #cbd5e0; }
2017
- .filters button.clear-filters-btn { background-color: var(--medium-gray-color); color: var(--text-color); }
2018
- .filters button.clear-filters-btn:hover { background-color: var(--dark-gray-color); color: #fff; }
2019
- .copy-btn {color: var(--primary-color); background: #fefefe; border-radius: 8px; cursor: pointer; border-color: var(--primary-color); font-size: 1em; margin-left: 93%; font-weight: 600;}
2020
- @media (max-width: 1200px) { .trend-charts-row { grid-template-columns: 1fr; } }
2021
- @media (max-width: 992px) { .dashboard-bottom-row { grid-template-columns: 1fr; } .pie-chart-wrapper div[id^="pieChart-"] { max-width: 350px; margin: 0 auto; } .filters input { min-width: 180px; } .filters select { min-width: 150px; } }
2022
- @media (max-width: 768px) { body { font-size: 15px; } .container { margin: 10px; padding: 20px; } .header { flex-direction: column; align-items: flex-start; gap: 15px; } .header h1 { font-size: 1.6em; } .run-info { text-align: left; font-size:0.9em; } .tabs { margin-bottom: 25px;} .tab-button { padding: 12px 20px; font-size: 1.05em;} .dashboard-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 18px;} .summary-card .value {font-size: 2em;} .summary-card h3 {font-size: 0.95em;} .filters { flex-direction: column; padding: 18px; gap: 12px;} .filters input, .filters select, .filters button {width: 100%; box-sizing: border-box;} .test-case-header { flex-direction: column; align-items: flex-start; gap: 10px; padding: 14px; } .test-case-summary {gap: 10px;} .test-case-title {font-size: 1.05em;} .test-case-meta { flex-direction: row; flex-wrap: wrap; gap: 8px; margin-top: 8px;} .attachments-grid {grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 18px;} .test-history-grid {grid-template-columns: 1fr;} .pie-chart-wrapper {min-height: auto;} }
2023
- @media (max-width: 480px) { body {font-size: 14px;} .container {padding: 15px;} .header h1 {font-size: 1.4em;} #report-logo { height: 35px; width: 45px; } .tab-button {padding: 10px 15px; font-size: 1em;} .summary-card .value {font-size: 1.8em;} .attachments-grid {grid-template-columns: 1fr;} .step-item {padding-left: calc(var(--depth, 0) * 18px);} .test-case-content, .step-details {padding: 15px;} .trend-charts-row {gap: 20px;} .trend-chart {padding: 20px;} }
2024
- .trace-actions a { text-decoration: none; color: var(--primary-color); font-weight: 500; font-size: 0.9em; }
2025
- .generic-attachment { text-align: center; padding: 1rem; justify-content: center; }
2026
- .attachment-icon { font-size: 2.5rem; display: block; margin-bottom: 0.75rem; }
2027
- .attachment-caption { display: flex; flex-direction: column; align-items: center; justify-content: center; flex-grow: 1; }
2028
- .attachment-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
2029
- .attachment-type { font-size: 0.8rem; color: var(--text-color-secondary); }
2030
- </style>
2160
+ <title>Playwright Pulse Report (Static Report)</title>
2161
+
2162
+ <style>
2163
+ :root {
2164
+ --primary-color: #60a5fa; --secondary-color: #f472b6; --accent-color: #a78bfa; --accent-color-alt: #fb923c;
2165
+ --success-color: #34d399; --danger-color: #f87171; --warning-color: #fbbf24; --info-color: #60a5fa;
2166
+ --light-gray-color: #374151; --medium-gray-color: #4b5563; --dark-gray-color: #9ca3af;
2167
+ --text-color: #f9fafb; --text-color-secondary: #d1d5db; --border-color: #4b5563; --background-color: #111827;
2168
+ --card-background-color: #1f2937; --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
2169
+ --border-radius: 8px; --box-shadow: 0 5px 15px rgba(0,0,0,0.3); --box-shadow-light: 0 3px 8px rgba(0,0,0,0.2); --box-shadow-inset: inset 0 1px 3px rgba(0,0,0,0.3);
2170
+ }
2171
+ .trend-chart-container, .test-history-trend div[id^="testHistoryChart-"] { min-height: 100px; }
2172
+ .lazy-load-chart .no-data, .lazy-load-chart .no-data-chart { display: flex; align-items: center; justify-content: center; height: 100%; font-style: italic; color: var(--dark-gray-color); }
2173
+ .highcharts-background { fill: transparent; }
2174
+ .highcharts-title, .highcharts-subtitle { font-family: var(--font-family); }
2175
+ .highcharts-axis-labels text, .highcharts-legend-item text { fill: var(--text-color-secondary) !important; font-size: 12px !important; }
2176
+ .highcharts-axis-title { fill: var(--text-color) !important; }
2177
+ .highcharts-tooltip > span { background-color: rgba(31,41,55,0.95) !important; border-color: rgba(31,41,55,0.95) !important; color: #f9fafb !important; padding: 10px !important; border-radius: 6px !important; }
2178
+ body { font-family: var(--font-family); margin: 0; background-color: var(--background-color); color: var(--text-color); line-height: 1.65; font-size: 16px; }
2179
+ .container { padding: 30px; border-radius: var(--border-radius); box-shadow: var(--box-shadow); background: repeating-linear-gradient(#1f2937, #374151, #1f2937); }
2180
+ .header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; padding-bottom: 25px; border-bottom: 1px solid var(--border-color); margin-bottom: 25px; }
2181
+ .header-title { display: flex; align-items: center; gap: 15px; }
2182
+ .header h1 { margin: 0; font-size: 1.85em; font-weight: 600; color: var(--primary-color); }
2183
+ #report-logo { height: 40px; width: 55px; }
2184
+ .run-info { font-size: 0.9em; text-align: right; color: var(--text-color-secondary); line-height:1.5;}
2185
+ .run-info strong { color: var(--text-color); }
2186
+ .tabs { display: flex; border-bottom: 2px solid var(--border-color); margin-bottom: 30px; overflow-x: auto; }
2187
+ .tab-button { padding: 15px 25px; background: none; border: none; border-bottom: 3px solid transparent; cursor: pointer; font-size: 1.1em; font-weight: 600; color: var(--text-color); transition: color 0.2s ease, border-color 0.2s ease; white-space: nowrap; }
2188
+ .tab-button:hover { color: var(--accent-color); }
2189
+ .tab-button.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
2190
+ .tab-content { display: none; animation: fadeIn 0.4s ease-out; }
2191
+ .tab-content.active { display: block; }
2192
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
2193
+ .dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 22px; margin-bottom: 35px; }
2194
+ .summary-card { background-color: var(--card-background-color); border: 1px solid var(--border-color); border-radius: var(--border-radius); padding: 22px; text-align: center; box-shadow: var(--box-shadow-light); transition: transform 0.2s ease, box-shadow 0.2s ease; }
2195
+ .summary-card:hover { transform: translateY(-5px); box-shadow: var(--box-shadow); }
2196
+ .summary-card h3 { margin: 0 0 10px; font-size: 1.05em; font-weight: 500; color: var(--text-color-secondary); }
2197
+ .summary-card .value { font-size: 2.4em; font-weight: 600; margin-bottom: 8px; }
2198
+ .summary-card .trend-percentage { font-size: 1em; color: var(--dark-gray-color); }
2199
+ .status-passed .value, .stat-passed svg { color: var(--success-color); }
2200
+ .status-failed .value, .stat-failed svg { color: var(--danger-color); }
2201
+ .status-skipped .value, .stat-skipped svg { color: var(--warning-color); }
2202
+ .dashboard-bottom-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 28px; align-items: stretch; }
2203
+ .pie-chart-wrapper, .suites-widget, .trend-chart { background-color: var(--card-background-color); padding: 28px; border-radius: var(--border-radius); box-shadow: var(--box-shadow-light); display: flex; flex-direction: column; }
2204
+ .pie-chart-wrapper h3, .suites-header h2, .trend-chart h3 { text-align: center; margin-top: 0; margin-bottom: 25px; font-size: 1.25em; font-weight: 600; color: var(--text-color); }
2205
+ .trend-chart-container, .pie-chart-wrapper div[id^="pieChart-"] { flex-grow: 1; min-height: 250px; }
2206
+ .status-badge-small-tooltip { padding: 2px 5px; border-radius: 3px; font-size: 0.9em; font-weight: 600; color: white; text-transform: uppercase; }
2207
+ .status-badge-small-tooltip.status-passed { background-color: var(--success-color); }
2208
+ .status-badge-small-tooltip.status-failed { background-color: var(--danger-color); }
2209
+ .status-badge-small-tooltip.status-skipped { background-color: var(--warning-color); }
2210
+ .status-badge-small-tooltip.status-unknown { background-color: var(--dark-gray-color); }
2211
+ .suites-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
2212
+ .summary-badge { background-color: var(--light-gray-color); color: var(--text-color-secondary); padding: 7px 14px; border-radius: 16px; font-size: 0.9em; }
2213
+ .suites-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; }
2214
+ .suite-card { border: 1px solid var(--border-color); border-left-width: 5px; border-radius: calc(var(--border-radius) / 1.5); padding: 20px; background-color: var(--card-background-color); transition: box-shadow 0.2s ease, border-left-color 0.2s ease; }
2215
+ .suite-card:hover { box-shadow: var(--box-shadow); }
2216
+ .suite-card.status-passed { border-left-color: var(--success-color); }
2217
+ .suite-card.status-failed { border-left-color: var(--danger-color); }
2218
+ .suite-card.status-skipped { border-left-color: var(--warning-color); }
2219
+ .suite-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
2220
+ .suite-name { font-weight: 600; font-size: 1.05em; color: var(--text-color); margin-right: 10px; word-break: break-word;}
2221
+ .browser-tag { font-size: 0.8em; background-color: var(--medium-gray-color); color: var(--text-color-secondary); padding: 3px 8px; border-radius: 4px; white-space: nowrap;}
2222
+ .suite-card-body .test-count { font-size: 0.95em; color: var(--text-color-secondary); display: block; margin-bottom: 10px; }
2223
+ .suite-stats { display: flex; gap: 14px; font-size: 0.95em; align-items: center; }
2224
+ .suite-stats span { display: flex; align-items: center; gap: 6px; }
2225
+ .suite-stats svg { vertical-align: middle; font-size: 1.15em; }
2226
+ .filters { display: flex; flex-wrap: wrap; gap: 18px; margin-bottom: 28px; padding: 20px; background-color: var(--light-gray-color); border-radius: var(--border-radius); box-shadow: var(--box-shadow-inset); border: 1px solid var(--border-color); }
2227
+ .filters input, .filters select, .filters button { padding: 11px 15px; border: 1px solid var(--border-color); border-radius: 6px; font-size: 1em; background-color: var(--card-background-color); color: var(--text-color); }
2228
+ .filters input { flex-grow: 1; min-width: 240px;}
2229
+ .filters select {min-width: 180px;}
2230
+ .filters button { background-color: var(--primary-color); color: white; cursor: pointer; transition: background-color 0.2s ease, box-shadow 0.2s ease; border: none; }
2231
+ .filters button:hover { background-color: var(--accent-color); box-shadow: 0 2px 5px rgba(0,0,0,0.3);}
2232
+ .test-case { margin-bottom: 15px; border: 1px solid var(--border-color); border-radius: var(--border-radius); background-color: var(--card-background-color); box-shadow: var(--box-shadow-light); overflow: hidden; }
2233
+ .test-case-header { padding: 10px 15px; background-color: var(--card-background-color); cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid transparent; transition: background-color 0.2s ease; }
2234
+ .test-case-header:hover { background-color: var(--light-gray-color); }
2235
+ .test-case-header[aria-expanded="true"] { border-bottom-color: var(--border-color); background-color: var(--light-gray-color); }
2236
+ .test-case-summary { display: flex; align-items: center; gap: 14px; flex-grow: 1; flex-wrap: wrap;}
2237
+ .test-case-title { font-weight: 600; color: var(--text-color); font-size: 1em; }
2238
+ .test-case-browser { font-size: 0.9em; color: var(--text-color-secondary); }
2239
+ .test-case-meta { display: flex; align-items: center; gap: 12px; font-size: 0.9em; color: var(--text-color-secondary); flex-shrink: 0; }
2240
+ .test-duration { background-color: var(--light-gray-color); padding: 4px 10px; border-radius: 12px; font-size: 0.9em;}
2241
+ .status-badge { padding: 5px; border-radius: 6px; font-size: 0.8em; font-weight: 600; color: white; text-transform: uppercase; min-width: 70px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
2242
+ .status-badge.status-passed { background-color: var(--success-color); }
2243
+ .status-badge.status-failed { background-color: var(--danger-color); }
2244
+ .status-badge.status-skipped { background-color: var(--warning-color); }
2245
+ .status-badge.status-unknown { background-color: var(--dark-gray-color); }
2246
+ .tag { display: inline-block; background: linear-gradient(#4b5563, #1f2937, #111827); color: #f9fafb; padding: 3px 10px; border-radius: 12px; font-size: 0.85em; margin-right: 6px; font-weight: 400; }
2247
+ .test-case-content { display: none; padding: 20px; border-top: 1px solid var(--border-color); background-color: var(--light-gray-color); }
2248
+ .test-case-content h4 { margin-top: 22px; margin-bottom: 14px; font-size: 1.15em; color: var(--primary-color); }
2249
+ .test-case-content p { margin-bottom: 10px; font-size: 1em; }
2250
+ .test-error-summary { margin-bottom: 20px; padding: 14px; background-color: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.3); border-left: 4px solid var(--danger-color); border-radius: 4px; }
2251
+ .test-error-summary h4 { color: var(--danger-color); margin-top:0;}
2252
+ .test-error-summary pre { white-space: pre-wrap; word-break: break-all; color: var(--danger-color); font-size: 0.95em;}
2253
+ .steps-list { margin: 18px 0; }
2254
+ @supports (content-visibility: auto) {
2255
+ .tab-content,
2256
+ #test-runs .test-case,
2257
+ .attachments-section,
2258
+ .test-history-card,
2259
+ .trend-chart,
2260
+ .suite-card {
2261
+ content-visibility: auto;
2262
+ contain-intrinsic-size: 1px 600px;
2263
+ }
2264
+ }
2265
+ .test-case,
2266
+ .test-history-card,
2267
+ .suite-card,
2268
+ .attachments-section {
2269
+ contain: content;
2270
+ }
2271
+ .attachments-grid .attachment-item img.lazy-load-image {
2272
+ width: 100%;
2273
+ aspect-ratio: 4 / 3;
2274
+ object-fit: cover;
2275
+ }
2276
+ .attachments-grid .attachment-item.video-item {
2277
+ aspect-ratio: 16 / 9;
2278
+ }
2279
+
2280
+ .step-item { margin-bottom: 8px; padding-left: calc(var(--depth, 0) * 28px); }
2281
+ .step-header { display: flex; align-items: center; cursor: pointer; padding: 10px 14px; border-radius: 6px; background-color: var(--card-background-color); border: 1px solid var(--light-gray-color); transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; }
2282
+ .step-header:hover { background-color: var(--light-gray-color); border-color: var(--medium-gray-color); box-shadow: var(--box-shadow-inset); }
2283
+ .step-icon { margin-right: 12px; width: 20px; text-align: center; font-size: 1.1em; }
2284
+ .step-title { flex: 1; font-size: 1em; }
2285
+ .step-duration { color: var(--dark-gray-color); font-size: 0.9em; }
2286
+ .step-details { display: none; padding: 14px; margin-top: 8px; background: var(--light-gray-color); border-radius: 6px; font-size: 0.95em; border: 1px solid var(--light-gray-color); }
2287
+ .step-info { margin-bottom: 8px; }
2288
+ .test-error-summary { color: var(--danger-color); margin-top: 12px; padding: 14px; background: rgba(248,113,113,0.1); border-radius: 4px; font-size: 0.95em; border-left: 3px solid var(--danger-color); }
2289
+ .test-error-summary pre.stack-trace { margin-top: 10px; padding: 12px; background-color: rgba(0,0,0,0.2); border-radius: 4px; font-size:0.9em; max-height: 280px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }
2290
+ .step-hook { background-color: rgba(96,165,250,0.1); border-left: 3px solid var(--info-color) !important; }
2291
+ .step-hook .step-title { font-style: italic; color: var(--info-color)}
2292
+ .nested-steps { margin-top: 12px; }
2293
+ .attachments-section { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--light-gray-color); }
2294
+ .attachments-section h4 { margin-top: 0; margin-bottom: 20px; font-size: 1.1em; color: var(--text-color); }
2295
+ .attachments-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 22px; }
2296
+ .attachment-item { border: 1px solid var(--border-color); border-radius: var(--border-radius); background-color: var(--card-background-color); box-shadow: var(--box-shadow-light); display: flex; flex-direction: column; transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; }
2297
+ .attachment-item:hover { transform: translateY(-4px); box-shadow: var(--box-shadow); }
2298
+ .attachment-item img, .attachment-item video { width: 100%; height: 180px; object-fit: cover; display: block; background-color: var(--medium-gray-color); border-bottom: 1px solid var(--border-color); transition: opacity 0.3s ease; }
2299
+ .attachment-info { padding: 12px; margin-top: auto; background-color: var(--light-gray-color);}
2300
+ .attachment-item a:hover img { opacity: 0.85; }
2301
+ .attachment-caption { padding: 12px 15px; font-size: 0.9em; text-align: center; color: var(--text-color-secondary); word-break: break-word; background-color: var(--light-gray-color); }
2302
+ .video-item a, .trace-item a { display: block; margin-bottom: 8px; color: var(--primary-color); text-decoration: none; font-weight: 500; }
2303
+ .video-item a:hover, .trace-item a:hover { text-decoration: underline; }
2304
+ .code-section pre { background-color: #111827; color: #f9fafb; padding: 20px; border-radius: 6px; overflow-x: auto; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; font-size: 0.95em; line-height:1.6;}
2305
+ .trend-charts-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(480px, 1fr)); gap: 28px; margin-bottom: 35px; }
2306
+ .test-history-container h2.tab-main-title { font-size: 1.6em; margin-bottom: 18px; color: var(--primary-color); border-bottom: 1px solid var(--border-color); padding-bottom: 12px;}
2307
+ .test-history-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 22px; margin-top: 22px; }
2308
+ .test-history-card { background: var(--card-background-color); border: 1px solid var(--border-color); border-radius: var(--border-radius); padding: 22px; box-shadow: var(--box-shadow-light); display: flex; flex-direction: column; }
2309
+ .test-history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 14px; border-bottom: 1px solid var(--light-gray-color); }
2310
+ .test-history-header h3 { margin: 0; font-size: 1.15em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2311
+ .test-history-header p { font-weight: 500 }
2312
+ .test-history-trend { margin-bottom: 20px; min-height: 110px; }
2313
+ .test-history-trend div[id^="testHistoryChart-"] { display: block; margin: 0 auto; max-width:100%; height: 100px; width: 320px; }
2314
+ .test-history-details-collapsible summary { cursor: pointer; font-size: 1em; color: var(--primary-color); margin-bottom: 10px; font-weight:500; }
2315
+ .test-history-details-collapsible summary:hover {text-decoration: underline;}
2316
+ .test-history-details table { width: 100%; border-collapse: collapse; font-size: 0.95em; }
2317
+ .test-history-details th, .test-history-details td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--light-gray-color); }
2318
+ .test-history-details th { background-color: var(--light-gray-color); font-weight: 600; }
2319
+ .status-badge-small { padding: 3px 7px; border-radius: 4px; font-size: 0.8em; font-weight: 600; color: white; text-transform: uppercase; display: inline-block; }
2320
+ .status-badge-small.status-passed { background-color: var(--success-color); }
2321
+ .status-badge-small.status-failed { background-color: var(--danger-color); }
2322
+ .status-badge-small.status-skipped { background-color: var(--warning-color); }
2323
+ .status-badge-small.status-unknown { background-color: var(--dark-gray-color); }
2324
+ .no-data, .no-tests, .no-steps, .no-data-chart { padding: 28px; text-align: center; color: var(--dark-gray-color); font-style: italic; font-size:1.1em; background-color: var(--light-gray-color); border-radius: var(--border-radius); margin: 18px 0; border: 1px dashed var(--medium-gray-color); }
2325
+ .no-data-chart {font-size: 0.95em; padding: 18px;}
2326
+ .ai-failure-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 22px; }
2327
+ .ai-failure-card { background: var(--card-background-color); border: 1px solid var(--border-color); border-left: 5px solid var(--danger-color); border-radius: var(--border-radius); box-shadow: var(--box-shadow-light); display: flex; flex-direction: column; }
2328
+ .ai-failure-card-header { padding: 15px 20px; border-bottom: 1px solid var(--light-gray-color); display: flex; align-items: center; justify-content: space-between; gap: 15px; }
2329
+ .ai-failure-card-header h3 { margin: 0; font-size: 1.1em; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2330
+ .ai-failure-card-body { padding: 20px; }
2331
+ .ai-fix-btn { background-color: var(--primary-color); color: white; border: none; padding: 10px 18px; font-size: 1em; font-weight: 600; border-radius: 6px; cursor: pointer; transition: background-color 0.2s ease, transform 0.2s ease; display: inline-flex; align-items: center; gap: 8px; }
2332
+ .ai-fix-btn:hover { background-color: var(--accent-color); transform: translateY(-2px); }
2333
+ .ai-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); display: none; align-items: center; justify-content: center; z-index: 1050; animation: fadeIn 0.3s; }
2334
+ .ai-modal-content { background-color: var(--card-background-color); color: var(--text-color); border-radius: var(--border-radius); width: 90%; max-width: 800px; max-height: 90vh; box-shadow: 0 10px 30px rgba(0,0,0,0.5); display: flex; flex-direction: column; overflow: hidden; }
2335
+ .ai-modal-header { padding: 18px 25px; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
2336
+ .ai-modal-header h3 { margin: 0; font-size: 1.25em; }
2337
+ .ai-modal-close { font-size: 2rem; font-weight: 300; cursor: pointer; color: var(--dark-gray-color); line-height: 1; transition: color 0.2s; }
2338
+ .ai-modal-close:hover { color: var(--danger-color); }
2339
+ .ai-modal-body { padding: 25px; overflow-y: auto; }
2340
+ .ai-modal-body h4 { margin-top: 18px; margin-bottom: 10px; font-size: 1.1em; color: var(--primary-color); }
2341
+ .ai-modal-body p { margin-bottom: 15px; }
2342
+ .ai-loader { margin: 40px auto; border: 5px solid var(--medium-gray-color); border-top: 5px solid var(--primary-color); border-radius: 50%; width: 50px; height: 50px; animation: spin 1s linear infinite; }
2343
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
2344
+ .trace-preview { padding: 1rem; text-align: center; background: var(--light-gray-color); border-bottom: 1px solid var(--border-color); }
2345
+ .trace-icon { font-size: 2rem; display: block; margin-bottom: 0.5rem; }
2346
+ .trace-name { word-break: break-word; font-size: 0.9rem; }
2347
+ .trace-actions { display: flex; gap: 0.5rem; }
2348
+ .trace-actions a { flex: 1; text-align: center; padding: 0.25rem 0.5rem; font-size: 0.85rem; border-radius: 4px; text-decoration: none; background: var(--primary-color); color: white; }
2349
+ .view-trace { background: var(--primary-color); color: white; }
2350
+ .view-trace:hover { background: var(--accent-color); }
2351
+ .download-trace { background: var(--medium-gray-color); color: var(--text-color); }
2352
+ .download-trace:hover { background: var(--dark-gray-color); }
2353
+ .filters button.clear-filters-btn { background-color: var(--medium-gray-color); color: var(--text-color); }
2354
+ .filters button.clear-filters-btn:hover { background-color: var(--dark-gray-color); color: #fff; }
2355
+ .copy-btn {color: var(--primary-color); background: var(--card-background-color); border-radius: 8px; cursor: pointer; border-color: var(--primary-color); font-size: 1em; margin-left: 93%; font-weight: 600;}
2356
+ .ai-analyzer-stats { display: flex; gap: 20px; margin-bottom: 25px; padding: 20px; background: linear-gradient(135deg, #374151 0%, #1f2937 100%); border-radius: var(--border-radius); justify-content: center; }
2357
+ .stat-item { text-align: center; color: white; }
2358
+ .stat-number { display: block; font-size: 2em; font-weight: 700; line-height: 1;}
2359
+ .stat-label { font-size: 0.9em; opacity: 0.9; font-weight: 500;}
2360
+ .ai-analyzer-description { margin-bottom: 25px; font-size: 1em; color: var(--text-color-secondary); text-align: center; max-width: 600px; margin-left: auto; margin-right: auto;}
2361
+ .compact-failure-list { display: flex; flex-direction: column; gap: 15px; }
2362
+ .compact-failure-item { background: var(--card-background-color); border: 1px solid var(--border-color); border-left: 4px solid var(--danger-color); border-radius: var(--border-radius); box-shadow: var(--box-shadow-light); transition: transform 0.2s ease, box-shadow 0.2s ease;}
2363
+ .compact-failure-item:hover { transform: translateY(-2px); box-shadow: var(--box-shadow); }
2364
+ .failure-header { display: flex; justify-content: space-between; align-items: center; padding: 18px 20px; gap: 15px;}
2365
+ .failure-main-info { flex: 1; min-width: 0; }
2366
+ .failure-title { margin: 0 0 8px 0; font-size: 1.1em; font-weight: 600; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}
2367
+ .failure-meta { display: flex; gap: 12px; align-items: center;}
2368
+ .browser-indicator, .duration-indicator { font-size: 0.85em; padding: 3px 8px; border-radius: 12px; font-weight: 500;}
2369
+ .browser-indicator { background: var(--info-color); color: white; }
2370
+ #load-more-tests { font-size: 16px; padding: 4px; background-color: var(--light-gray-color); border-radius: 4px; color: var(--text-color); }
2371
+ .duration-indicator { background: var(--medium-gray-color); color: var(--text-color); }
2372
+ .compact-ai-btn { background: linear-gradient(135deg, #374151 0%, #1f2937 100%); color: white; border: none; padding: 12px 18px; border-radius: 6px; cursor: pointer; font-weight: 600; display: flex; align-items: center; gap: 8px; transition: all 0.3s ease; white-space: nowrap;}
2373
+ .compact-ai-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(55, 65, 81, 0.4); }
2374
+ .ai-text { font-size: 0.95em; }
2375
+ .failure-error-preview { padding: 0 20px 18px 20px; border-top: 1px solid var(--light-gray-color);}
2376
+ .error-snippet { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.3); border-radius: 6px; padding: 12px; margin-bottom: 12px; font-family: monospace; font-size: 0.9em; color: var(--danger-color); line-height: 1.4;}
2377
+ .expand-error-btn { background: none; border: 1px solid var(--border-color); color: var(--text-color-secondary); padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 0.85em; display: flex; align-items: center; gap: 6px; transition: all 0.2s ease;}
2378
+ .expand-error-btn:hover { background: var(--light-gray-color); border-color: var(--medium-gray-color); }
2379
+ .expand-icon { transition: transform 0.2s ease; font-size: 0.8em;}
2380
+ .expand-error-btn.expanded .expand-icon { transform: rotate(180deg); }
2381
+ .full-error-details { padding: 0 20px 20px 20px; border-top: 1px solid var(--light-gray-color); margin-top: 0;}
2382
+ .full-error-content { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.3); border-radius: 6px; padding: 15px; font-family: monospace; font-size: 0.9em; color: var(--danger-color); line-height: 1.4; max-height: 300px; overflow-y: auto;}
2383
+ @media (max-width: 1200px) { .trend-charts-row { grid-template-columns: 1fr; } }
2384
+ @media (max-width: 992px) { .dashboard-bottom-row { grid-template-columns: 1fr; } .pie-chart-wrapper div[id^="pieChart-"] { max-width: 350px; margin: 0 auto; } .filters input { min-width: 180px; } .filters select { min-width: 150px; } }
2385
+ @media (max-width: 768px) { body { font-size: 15px; } .container { margin: 10px; padding: 20px; } .header { flex-direction: column; align-items: flex-start; gap: 15px; } .header h1 { font-size: 1.6em; } .run-info { text-align: left; font-size:0.9em; } .tabs { margin-bottom: 25px;} .tab-button { padding: 12px 20px; font-size: 1.05em;} .dashboard-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 18px;} .summary-card .value {font-size: 2em;} .summary-card h3 {font-size: 0.95em;} .filters { flex-direction: column; padding: 18px; gap: 12px;} .filters input, .filters select, .filters button {width: 100%; box-sizing: border-box;} .test-case-header { flex-direction: column; align-items: flex-start; gap: 10px; padding: 14px; } .test-case-summary {gap: 10px;} .test-case-title {font-size: 1.05em;} .test-case-meta { flex-direction: row; flex-wrap: wrap; gap: 8px; margin-top: 8px;} .attachments-grid {grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 18px;} .test-history-grid {grid-template-columns: 1fr;} .pie-chart-wrapper {min-height: auto;} .ai-failure-cards-grid { grid-template-columns: 1fr; } .ai-analyzer-stats { flex-direction: column; gap: 15px; text-align: center; } .failure-header { flex-direction: column; align-items: stretch; gap: 15px; } .failure-main-info { text-align: center; } .failure-meta { justify-content: center; } .compact-ai-btn { justify-content: center; padding: 12px 20px; } }
2386
+ @media (max-width: 480px) { body {font-size: 14px;} .container {padding: 15px;} .header h1 {font-size: 1.4em;} #report-logo { height: 35px; width: 45px; } .tab-button {padding: 10px 15px; font-size: 1em;} .summary-card .value {font-size: 1.8em;} .attachments-grid {grid-template-columns: 1fr;} .step-item {padding-left: calc(var(--depth, 0) * 18px);} .test-case-content, .step-details {padding: 15px;} .trend-charts-row {gap: 20px;} .trend-chart {padding: 20px;} .stat-item .stat-number { font-size: 1.5em; } .failure-header { padding: 15px; } .failure-error-preview, .full-error-details { padding-left: 15px; padding-right: 15px; } }
2387
+ .trace-actions a { text-decoration: none; color: var(--primary-color); font-weight: 500; font-size: 0.9em; }
2388
+ .generic-attachment { text-align: center; padding: 1rem; justify-content: center; }
2389
+ .attachment-icon { font-size: 2.5rem; display: block; margin-bottom: 0.75rem; }
2390
+ .attachment-caption { display: flex; flex-direction: column; align-items: center; justify-content: center; flex-grow: 1; }
2391
+ .attachment-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
2392
+ .attachment-type { font-size: 0.8rem; color: var(--text-color-secondary); }
2393
+ .footer-text { color: white }
2394
+ </style>
2031
2395
  </head>
2032
2396
  <body>
2033
2397
  <div class="container">
@@ -2046,7 +2410,7 @@ function generateHTML(reportData, trendData = null) {
2046
2410
  <button class="tab-button active" data-tab="dashboard">Dashboard</button>
2047
2411
  <button class="tab-button" data-tab="test-runs">Test Run Summary</button>
2048
2412
  <button class="tab-button" data-tab="test-history">Test History</button>
2049
- <button class="tab-button" data-tab="test-ai">AI Analysis</button>
2413
+ <button class="tab-button" data-tab="ai-failure-analyzer">AI Failure Analyzer</button>
2050
2414
  </div>
2051
2415
  <div id="dashboard" class="tab-content active">
2052
2416
  <div class="dashboard-grid">
@@ -2106,7 +2470,18 @@ function generateHTML(reportData, trendData = null) {
2106
2470
  .join("")}</select>
2107
2471
  <button id="expand-all-tests">Expand All</button> <button id="collapse-all-tests">Collapse All</button> <button id="clear-run-summary-filters" class="clear-filters-btn">Clear Filters</button>
2108
2472
  </div>
2109
- <div class="test-cases-list">${generateTestCasesHTML()}</div>
2473
+ <div class="test-cases-list">${generateTestCasesHTML(
2474
+ results.slice(0, 50),
2475
+ 0
2476
+ )}</div>
2477
+ ${
2478
+ results.length > 50
2479
+ ? `<div class="load-more-wrapper"><button id="load-more-tests">Load more</button></div><script type="application/json" id="remaining-tests-b64">${Buffer.from(
2480
+ generateTestCasesHTML(results.slice(50), 50),
2481
+ "utf8"
2482
+ ).toString("base64")}</script>`
2483
+ : ``
2484
+ }
2110
2485
  </div>
2111
2486
  <div id="test-history" class="tab-content">
2112
2487
  <h2 class="tab-main-title">Execution Trends</h2>
@@ -2141,12 +2516,12 @@ function generateHTML(reportData, trendData = null) {
2141
2516
  : '<div class="no-data">Individual test history data not available.</div>'
2142
2517
  }
2143
2518
  </div>
2144
- <div id="test-ai" class="tab-content">
2145
- <iframe data-src="https://ai-test-analyser.netlify.app/" width="100%" height="100%" frameborder="0" allowfullscreen class="lazy-load-iframe" title="AI Test Analyser" style="border: none; height: 100vh;"></iframe>
2519
+ <div id="ai-failure-analyzer" class="tab-content">
2520
+ ${generateAIFailureAnalyzerTab(results)}
2146
2521
  </div>
2147
2522
  <footer style="padding: 0.5rem; box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05); text-align: center; font-family: 'Segoe UI', system-ui, sans-serif;">
2148
2523
  <div style="display: inline-flex; align-items: center; gap: 0.5rem; color: #333; font-size: 0.9rem; font-weight: 600; letter-spacing: 0.5px;">
2149
- <span>Created by</span>
2524
+ <span class="footer-text">Created by</span>
2150
2525
  <a href="https://github.com/Arghajit47" target="_blank" rel="noopener noreferrer" style="color: #7737BF; font-weight: 700; font-style: italic; text-decoration: none; transition: all 0.2s ease;" onmouseover="this.style.color='#BF5C37'" onmouseout="this.style.color='#7737BF'">Arghajit Singha</a>
2151
2526
  </div>
2152
2527
  <div style="margin-top: 0.5rem; font-size: 0.75rem; color: #666;">Crafted with precision</div>
@@ -2174,7 +2549,129 @@ function generateHTML(reportData, trendData = null) {
2174
2549
  button.textContent = 'Failed';
2175
2550
  setTimeout(() => { button.textContent = 'Copy'; }, 2000);
2176
2551
  });
2177
- }
2552
+ }
2553
+
2554
+ function getAIFix(button) {
2555
+ const modal = document.getElementById('ai-fix-modal');
2556
+ const modalContent = document.getElementById('ai-fix-modal-content');
2557
+ const modalTitle = document.getElementById('ai-fix-modal-title');
2558
+
2559
+ modal.style.display = 'flex';
2560
+ document.body.style.setProperty('overflow', 'hidden', 'important');
2561
+ modalTitle.textContent = 'Analyzing...';
2562
+ modalContent.innerHTML = '<div class="ai-loader"></div>';
2563
+
2564
+ try {
2565
+ const testJson = button.dataset.testJson;
2566
+ const test = JSON.parse(atob(testJson));
2567
+
2568
+ const testName = test.name || 'Unknown Test';
2569
+ const failureLogsAndErrors = [
2570
+ 'Error Message:',
2571
+ test.errorMessage || 'Not available.',
2572
+ '\\n\\n--- stdout ---',
2573
+ (test.stdout && test.stdout.length > 0) ? test.stdout.join('\\n') : 'Not available.',
2574
+ '\\n\\n--- stderr ---',
2575
+ (test.stderr && test.stderr.length > 0) ? test.stderr.join('\\n') : 'Not available.'
2576
+ ].join('\\n');
2577
+ const codeSnippet = test.snippet || '';
2578
+
2579
+ const shortTestName = testName.split(' > ').pop();
2580
+ modalTitle.textContent = \`Analysis for: \${shortTestName}\`;
2581
+
2582
+ const apiUrl = 'https://ai-test-analyser.netlify.app/api/analyze';
2583
+ fetch(apiUrl, {
2584
+ method: 'POST',
2585
+ headers: { 'Content-Type': 'application/json' },
2586
+ body: JSON.stringify({
2587
+ testName: testName,
2588
+ failureLogsAndErrors: failureLogsAndErrors,
2589
+ codeSnippet: codeSnippet,
2590
+ }),
2591
+ })
2592
+ .then(response => {
2593
+ if (!response.ok) {
2594
+ return response.text().then(text => {
2595
+ throw new Error(\`API request failed with status \${response.status}: \${text || response.statusText}\`);
2596
+ });
2597
+ }
2598
+ return response.text();
2599
+ })
2600
+ .then(text => {
2601
+ if (!text) {
2602
+ throw new Error("The AI analyzer returned an empty response. This might happen during high load or if the request was blocked. Please try again in a moment.");
2603
+ }
2604
+ try {
2605
+ return JSON.parse(text);
2606
+ } catch (e) {
2607
+ console.error("Failed to parse JSON:", text);
2608
+ throw new Error(\`The AI analyzer returned an invalid response. \${e.message}\`);
2609
+ }
2610
+ })
2611
+ .then(data => {
2612
+ const escapeHtml = (unsafe) => {
2613
+ if (typeof unsafe !== 'string') return '';
2614
+ return unsafe
2615
+ .replace(/&/g, "&amp;")
2616
+ .replace(/</g, "&lt;")
2617
+ .replace(/>/g, "&gt;")
2618
+ .replace(/"/g, "&quot;")
2619
+ .replace(/'/g, "&#039;");
2620
+ };
2621
+
2622
+ const analysisHtml = \`<h4>Analysis</h4><p>\${escapeHtml(data.rootCause) || 'No analysis provided.'}</p>\`;
2623
+
2624
+ let suggestionsHtml = '<h4>Suggestions</h4>';
2625
+ if (data.suggestedFixes && data.suggestedFixes.length > 0) {
2626
+ suggestionsHtml += '<div class="suggestions-list" style="margin-top: 15px;">';
2627
+ data.suggestedFixes.forEach(fix => {
2628
+ suggestionsHtml += \`
2629
+ <div class="suggestion-item" style="margin-bottom: 22px; border-left: 3px solid var(--accent-color-alt); padding-left: 15px;">
2630
+ <p style="margin: 0 0 8px 0; font-weight: 500;">\${escapeHtml(fix.description)}</p>
2631
+ \${fix.codeSnippet ? \`<div class="code-section"><pre><code>\${escapeHtml(fix.codeSnippet)}</code></pre></div>\` : ''}
2632
+ </div>
2633
+ \`;
2634
+ });
2635
+ suggestionsHtml += '</div>';
2636
+ } else {
2637
+ suggestionsHtml += \`<div class="code-section"><pre><code>No suggestion provided.</code></pre></div>\`;
2638
+ }
2639
+
2640
+ modalContent.innerHTML = analysisHtml + suggestionsHtml;
2641
+ })
2642
+ .catch(err => {
2643
+ console.error('AI Fix Error:', err);
2644
+ modalContent.innerHTML = \`<div class="test-error-summary"><strong>Error:</strong> Failed to get AI analysis. Please check the console for details. <br><br> \${err.message}</div>\`;
2645
+ });
2646
+
2647
+ } catch (e) {
2648
+ console.error('Error processing test data for AI Fix:', e);
2649
+ modalTitle.textContent = 'Error';
2650
+ modalContent.innerHTML = \`<div class="test-error-summary">Could not process test data. Is it formatted correctly?</div>\`;
2651
+ }
2652
+ }
2653
+
2654
+ function closeAiModal() {
2655
+ const modal = document.getElementById('ai-fix-modal');
2656
+ if(modal) modal.style.display = 'none';
2657
+ document.body.style.setProperty('overflow', '', 'important');
2658
+ }
2659
+
2660
+ function toggleErrorDetails(button) {
2661
+ const errorDetails = button.closest('.compact-failure-item').querySelector('.full-error-details');
2662
+ const expandText = button.querySelector('.expand-text');
2663
+
2664
+ if (errorDetails.style.display === 'none' || !errorDetails.style.display) {
2665
+ errorDetails.style.display = 'block';
2666
+ expandText.textContent = 'Hide Full Error';
2667
+ button.classList.add('expanded');
2668
+ } else {
2669
+ errorDetails.style.display = 'none';
2670
+ expandText.textContent = 'Show Full Error';
2671
+ button.classList.remove('expanded');
2672
+ }
2673
+ }
2674
+
2178
2675
  function initializeReportInteractivity() {
2179
2676
  const tabButtons = document.querySelectorAll('.tab-button');
2180
2677
  const tabContents = document.querySelectorAll('.tab-content');
@@ -2187,18 +2684,51 @@ function generateHTML(reportData, trendData = null) {
2187
2684
  const activeContent = document.getElementById(tabId);
2188
2685
  if (activeContent) {
2189
2686
  activeContent.classList.add('active');
2190
- // Check if IntersectionObserver is already handling elements in this tab
2191
- // For simplicity, we assume if an element is observed, it will be handled when it becomes visible.
2192
- // If IntersectionObserver is not supported, already-visible elements would have been loaded by fallback.
2193
2687
  }
2194
2688
  });
2195
2689
  });
2196
2690
  // --- Test Run Summary Filters ---
2197
2691
  const nameFilter = document.getElementById('filter-name');
2692
+ function ensureAllTestsAppended() {
2693
+ const node = document.getElementById('remaining-tests-b64');
2694
+ const loadMoreBtn = document.getElementById('load-more-tests');
2695
+ if (!node) return;
2696
+ const b64 = (node.textContent || '').trim();
2697
+ function b64ToUtf8(b64Str) {
2698
+ try { return decodeURIComponent(escape(window.atob(b64Str))); }
2699
+ catch (e) { return window.atob(b64Str); }
2700
+ }
2701
+ const html = b64ToUtf8(b64);
2702
+ const container = document.querySelector('#test-runs .test-cases-list');
2703
+ if (container) container.insertAdjacentHTML('beforeend', html);
2704
+ if (loadMoreBtn) loadMoreBtn.remove();
2705
+ node.remove();
2706
+ }
2707
+ const loadMoreBtn = document.getElementById('load-more-tests');
2708
+ if (loadMoreBtn) {
2709
+ loadMoreBtn.addEventListener('click', () => {
2710
+ const node = document.getElementById('remaining-tests-b64');
2711
+ if (!node) return;
2712
+ const b64 = (node.textContent || '').trim();
2713
+ function b64ToUtf8(b64Str) {
2714
+ try { return decodeURIComponent(escape(window.atob(b64Str))); }
2715
+ catch (e) { return window.atob(b64Str); }
2716
+ }
2717
+ const html = b64ToUtf8(b64);
2718
+ const container = document.querySelector('#test-runs .test-cases-list');
2719
+ if (container) container.insertAdjacentHTML('beforeend', html);
2720
+ loadMoreBtn.remove();
2721
+ node.remove();
2722
+ });
2723
+ }
2724
+
2725
+
2726
+
2198
2727
  const statusFilter = document.getElementById('filter-status');
2199
2728
  const browserFilter = document.getElementById('filter-browser');
2200
2729
  const clearRunSummaryFiltersBtn = document.getElementById('clear-run-summary-filters');
2201
2730
  function filterTestCases() {
2731
+ ensureAllTestsAppended();
2202
2732
  const nameValue = nameFilter ? nameFilter.value.toLowerCase() : "";
2203
2733
  const statusValue = statusFilter ? statusFilter.value : "";
2204
2734
  const browserValue = browserFilter ? browserFilter.value : "";
@@ -2217,7 +2747,10 @@ function generateHTML(reportData, trendData = null) {
2217
2747
  if(statusFilter) statusFilter.addEventListener('change', filterTestCases);
2218
2748
  if(browserFilter) browserFilter.addEventListener('change', filterTestCases);
2219
2749
  if(clearRunSummaryFiltersBtn) clearRunSummaryFiltersBtn.addEventListener('click', () => {
2220
- if(nameFilter) nameFilter.value = ''; if(statusFilter) statusFilter.value = ''; if(browserFilter) browserFilter.value = '';
2750
+ ensureAllTestsAppended();
2751
+ if(nameFilter) nameFilter.value = '';
2752
+ if(statusFilter) statusFilter.value = '';
2753
+ if(browserFilter) browserFilter.value = '';
2221
2754
  filterTestCases();
2222
2755
  });
2223
2756
  // --- Test History Filters ---
@@ -2258,12 +2791,6 @@ function generateHTML(reportData, trendData = null) {
2258
2791
  headerElement.setAttribute('aria-expanded', String(!isExpanded));
2259
2792
  }
2260
2793
  }
2261
- document.querySelectorAll('#test-runs .test-case-header').forEach(header => {
2262
- header.addEventListener('click', () => toggleElementDetails(header));
2263
- });
2264
- document.querySelectorAll('#test-runs .step-header').forEach(header => {
2265
- header.addEventListener('click', () => toggleElementDetails(header, '.step-details'));
2266
- });
2267
2794
  const expandAllBtn = document.getElementById('expand-all-tests');
2268
2795
  const collapseAllBtn = document.getElementById('collapse-all-tests');
2269
2796
  function setAllTestRunDetailsVisibility(displayMode, ariaState) {
@@ -2274,31 +2801,89 @@ function generateHTML(reportData, trendData = null) {
2274
2801
  }
2275
2802
  if (expandAllBtn) expandAllBtn.addEventListener('click', () => setAllTestRunDetailsVisibility('block', 'true'));
2276
2803
  if (collapseAllBtn) collapseAllBtn.addEventListener('click', () => setAllTestRunDetailsVisibility('none', 'false'));
2277
- // --- Intersection Observer for Lazy Loading ---
2278
- const lazyLoadElements = document.querySelectorAll('.lazy-load-chart, .lazy-load-iframe, .lazy-load-image, .lazy-load-video, .lazy-load-attachment');
2804
+ document.addEventListener('click', (e) => {
2805
+ const inHighcharts = e.target && e.target.closest && e.target.closest('.highcharts-container');
2806
+ if (inHighcharts) {
2807
+ return;
2808
+ }
2809
+ const header = e.target.closest('#test-runs .test-case-header');
2810
+ if (header) {
2811
+ let contentElement = header.parentElement.querySelector('.test-case-content');
2812
+ if (contentElement) {
2813
+ const isExpanded = contentElement.style.display === 'block';
2814
+ contentElement.style.display = isExpanded ? 'none' : 'block';
2815
+ header.setAttribute('aria-expanded', String(!isExpanded));
2816
+ }
2817
+ return;
2818
+ }
2819
+ const stepHeader = e.target.closest('#test-runs .step-header');
2820
+ if (stepHeader) {
2821
+ let details = stepHeader.nextElementSibling;
2822
+ if (details && details.matches('.step-details')) {
2823
+ const isExpanded = details.style.display === 'block';
2824
+ details.style.display = isExpanded ? 'none' : 'block';
2825
+ stepHeader.setAttribute('aria-expanded', String(!isExpanded));
2826
+ }
2827
+ return;
2828
+ }
2829
+ const img = e.target.closest('img.lazy-load-image');
2830
+ if (img && img.dataset && img.dataset.src) {
2831
+ if (e.preventDefault) e.preventDefault();
2832
+ img.src = img.dataset.src;
2833
+ img.removeAttribute('data-src');
2834
+ const parentLink = img.closest('a.lazy-load-attachment');
2835
+ if (parentLink && parentLink.dataset && parentLink.dataset.href) {
2836
+ parentLink.href = parentLink.dataset.href;
2837
+ parentLink.removeAttribute('data-href');
2838
+ }
2839
+ return;
2840
+ }
2841
+ const video = e.target.closest('video.lazy-load-video');
2842
+ if (video) {
2843
+ if (e.preventDefault) e.preventDefault();
2844
+ const s = video.querySelector('source');
2845
+ if (s && s.dataset && s.dataset.src && !s.src) {
2846
+ s.src = s.dataset.src;
2847
+ s.removeAttribute('data-src');
2848
+ video.load();
2849
+ } else if (video.dataset && video.dataset.src && !video.src) {
2850
+ video.src = video.dataset.src;
2851
+ video.removeAttribute('data-src');
2852
+ video.load();
2853
+ }
2854
+ return;
2855
+ }
2856
+ const a = e.target.closest('a.lazy-load-attachment');
2857
+ if (a && a.dataset && a.dataset.href) {
2858
+ e.preventDefault();
2859
+ a.href = a.dataset.href;
2860
+ a.removeAttribute('data-href');
2861
+ a.click();
2862
+ return;
2863
+ }
2864
+ });
2865
+ document.addEventListener('play', (e) => {
2866
+ const video = e.target && e.target.closest ? e.target.closest('video.lazy-load-video') : null;
2867
+ if (video) {
2868
+ const s = video.querySelector('source');
2869
+ if (s && s.dataset && s.dataset.src && !s.src) {
2870
+ s.src = s.dataset.src;
2871
+ s.removeAttribute('data-src');
2872
+ video.load();
2873
+ } else if (video.dataset && video.dataset.src && !video.src) {
2874
+ video.src = video.dataset.src;
2875
+ video.removeAttribute('data-src');
2876
+ video.load();
2877
+ }
2878
+ }
2879
+ }, true);
2880
+ const lazyLoadElements = document.querySelectorAll('.lazy-load-chart, .lazy-load-iframe');
2279
2881
  if ('IntersectionObserver' in window) {
2280
2882
  let lazyObserver = new IntersectionObserver((entries, observer) => {
2281
2883
  entries.forEach(entry => {
2282
2884
  if (entry.isIntersecting) {
2283
2885
  const element = entry.target;
2284
- if (element.classList.contains('lazy-load-image')) {
2285
- if (element.dataset.src) {
2286
- element.src = element.dataset.src;
2287
- element.removeAttribute('data-src');
2288
- }
2289
- } else if (element.classList.contains('lazy-load-video')) {
2290
- const source = element.querySelector('source');
2291
- if (source && source.dataset.src) {
2292
- source.src = source.dataset.src;
2293
- source.removeAttribute('data-src');
2294
- element.load();
2295
- }
2296
- } else if (element.classList.contains('lazy-load-attachment')) {
2297
- if (element.dataset.href) {
2298
- element.href = element.dataset.href;
2299
- element.removeAttribute('data-href');
2300
- }
2301
- } else if (element.classList.contains('lazy-load-iframe')) {
2886
+ if (element.classList.contains('lazy-load-iframe')) {
2302
2887
  if (element.dataset.src) {
2303
2888
  element.src = element.dataset.src;
2304
2889
  element.removeAttribute('data-src');
@@ -2314,79 +2899,61 @@ function generateHTML(reportData, trendData = null) {
2314
2899
  });
2315
2900
  }, { rootMargin: "0px 0px 200px 0px" });
2316
2901
  lazyLoadElements.forEach(el => lazyObserver.observe(el));
2317
- } else { // Fallback for browsers without IntersectionObserver
2902
+ } else {
2318
2903
  lazyLoadElements.forEach(element => {
2319
- if (element.classList.contains('lazy-load-image') && element.dataset.src) element.src = element.dataset.src;
2320
- else if (element.classList.contains('lazy-load-video')) { const source = element.querySelector('source'); if (source && source.dataset.src) { source.src = source.dataset.src; element.load(); } }
2321
- else if (element.classList.contains('lazy-load-attachment') && element.dataset.href) element.href = element.dataset.href;
2322
- else if (element.classList.contains('lazy-load-iframe') && element.dataset.src) element.src = element.dataset.src;
2323
- else if (element.classList.contains('lazy-load-chart')) { const renderFn = element.dataset.renderFunctionName; if(renderFn && window[renderFn]) window[renderFn](); }
2904
+ if (element.classList.contains('lazy-load-iframe') && element.dataset.src) element.src = element.dataset.src;
2905
+ else if (element.classList.contains('lazy-load-chart')) { const renderFn = element.dataset.renderFunctionName; if (renderFn && window[renderFn]) window[renderFn](); }
2324
2906
  });
2325
2907
  }
2326
2908
  }
2327
2909
  document.addEventListener('DOMContentLoaded', initializeReportInteractivity);
2328
2910
 
2329
2911
  function copyErrorToClipboard(button) {
2330
- // 1. Find the main error container, which should always be present.
2331
- const errorContainer = button.closest('.test-error-summary');
2332
- if (!errorContainer) {
2333
- console.error("Could not find '.test-error-summary' container. The report's HTML structure might have changed.");
2334
- return;
2335
- }
2336
-
2337
- let errorText;
2338
-
2339
- // 2. First, try to find the preferred .stack-trace element (the "happy path").
2340
- const stackTraceElement = errorContainer.querySelector('.stack-trace');
2912
+ const errorContainer = button.closest('.test-error-summary');
2913
+ if (!errorContainer) {
2914
+ console.error("Could not find '.test-error-summary' container.");
2915
+ return;
2916
+ }
2917
+ let errorText;
2918
+ const stackTraceElement = errorContainer.querySelector('.stack-trace');
2919
+ if (stackTraceElement) {
2920
+ errorText = stackTraceElement.textContent;
2921
+ } else {
2922
+ const clonedContainer = errorContainer.cloneNode(true);
2923
+ const buttonInClone = clonedContainer.querySelector('button');
2924
+ if (buttonInClone) buttonInClone.remove();
2925
+ errorText = clonedContainer.textContent;
2926
+ }
2341
2927
 
2342
- if (stackTraceElement) {
2343
- // If it exists, use its text content. This handles standard assertion errors.
2344
- errorText = stackTraceElement.textContent;
2345
- } else {
2346
- // 3. FALLBACK: If .stack-trace doesn't exist, this is likely an unstructured error.
2347
- // We clone the container to avoid manipulating the live DOM or copying the button's own text.
2348
- const clonedContainer = errorContainer.cloneNode(true);
2349
-
2350
- // Remove the button from our clone before extracting the text.
2351
- const buttonInClone = clonedContainer.querySelector('button');
2352
- if (buttonInClone) {
2353
- buttonInClone.remove();
2928
+ if (!errorText) {
2929
+ button.textContent = 'Nothing to copy';
2930
+ setTimeout(() => { button.textContent = 'Copy Error'; }, 2000);
2931
+ return;
2932
+ }
2933
+ navigator.clipboard.writeText(errorText.trim()).then(() => {
2934
+ const originalText = button.textContent;
2935
+ button.textContent = 'Copied!';
2936
+ setTimeout(() => { button.textContent = originalText; }, 2000);
2937
+ }).catch(err => {
2938
+ console.error('Failed to copy: ', err);
2939
+ button.textContent = 'Failed';
2940
+ });
2354
2941
  }
2355
-
2356
- // Use the text content of the cleaned container as the fallback.
2357
- errorText = clonedContainer.textContent;
2358
- }
2359
-
2360
- // 4. Proceed with the clipboard logic, ensuring text is not null and is trimmed.
2361
- if (!errorText) {
2362
- console.error('Could not extract error text.');
2363
- button.textContent = 'Nothing to copy';
2364
- setTimeout(() => { button.textContent = 'Copy Error'; }, 2000);
2365
- return;
2366
- }
2942
+ </script>
2367
2943
 
2368
- const textarea = document.createElement('textarea');
2369
- textarea.value = errorText.trim(); // Trim whitespace for a cleaner copy.
2370
- textarea.style.position = 'fixed'; // Prevent screen scroll
2371
- textarea.style.top = '-9999px';
2372
- document.body.appendChild(textarea);
2373
- textarea.select();
2944
+ <!-- AI Fix Modal -->
2945
+ <div id="ai-fix-modal" class="ai-modal-overlay" onclick="closeAiModal()">
2946
+ <div class="ai-modal-content" onclick="event.stopPropagation()">
2947
+ <div class="ai-modal-header">
2948
+ <h3 id="ai-fix-modal-title">AI Analysis</h3>
2949
+ <span class="ai-modal-close" onclick="closeAiModal()">×</span>
2950
+ </div>
2951
+ <div class="ai-modal-body" id="ai-fix-modal-content">
2952
+ <!-- Content will be injected by JavaScript -->
2953
+ </div>
2954
+ </div>
2955
+ </div>
2374
2956
 
2375
- try {
2376
- const successful = document.execCommand('copy');
2377
- const originalText = button.textContent;
2378
- button.textContent = successful ? 'Copied!' : 'Failed';
2379
- setTimeout(() => {
2380
- button.textContent = originalText;
2381
- }, 2000);
2382
- } catch (err) {
2383
- console.error('Failed to copy: ', err);
2384
- button.textContent = 'Failed';
2385
- }
2386
-
2387
- document.body.removeChild(textarea);
2388
- }
2389
- </script>
2390
2957
  </body>
2391
2958
  </html>
2392
2959
  `;
@@ -2415,6 +2982,11 @@ async function runScript(scriptPath) {
2415
2982
  });
2416
2983
  });
2417
2984
  }
2985
+ /**
2986
+ * The main function that orchestrates the generation of the static HTML report.
2987
+ * It reads the latest test run data, loads historical data for trend analysis,
2988
+ * prepares the data, and then generates and writes the final HTML report file.
2989
+ */
2418
2990
  async function main() {
2419
2991
  const __filename = fileURLToPath(import.meta.url);
2420
2992
  const __dirname = path.dirname(__filename);
@@ -2605,4 +3177,4 @@ main().catch((err) => {
2605
3177
  );
2606
3178
  console.error(err.stack);
2607
3179
  process.exit(1);
2608
- });
3180
+ });