@testsmith/perfornium 0.1.0

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.
Files changed (164) hide show
  1. package/README.md +360 -0
  2. package/dist/cli/cli.d.ts +2 -0
  3. package/dist/cli/cli.js +192 -0
  4. package/dist/cli/commands/distributed.d.ts +11 -0
  5. package/dist/cli/commands/distributed.js +179 -0
  6. package/dist/cli/commands/import.d.ts +23 -0
  7. package/dist/cli/commands/import.js +461 -0
  8. package/dist/cli/commands/init.d.ts +7 -0
  9. package/dist/cli/commands/init.js +923 -0
  10. package/dist/cli/commands/mock.d.ts +7 -0
  11. package/dist/cli/commands/mock.js +281 -0
  12. package/dist/cli/commands/report.d.ts +5 -0
  13. package/dist/cli/commands/report.js +70 -0
  14. package/dist/cli/commands/run.d.ts +12 -0
  15. package/dist/cli/commands/run.js +260 -0
  16. package/dist/cli/commands/validate.d.ts +3 -0
  17. package/dist/cli/commands/validate.js +35 -0
  18. package/dist/cli/commands/worker.d.ts +27 -0
  19. package/dist/cli/commands/worker.js +320 -0
  20. package/dist/config/index.d.ts +2 -0
  21. package/dist/config/index.js +20 -0
  22. package/dist/config/parser.d.ts +19 -0
  23. package/dist/config/parser.js +330 -0
  24. package/dist/config/types/global-config.d.ts +74 -0
  25. package/dist/config/types/global-config.js +2 -0
  26. package/dist/config/types/hooks.d.ts +58 -0
  27. package/dist/config/types/hooks.js +3 -0
  28. package/dist/config/types/import-types.d.ts +33 -0
  29. package/dist/config/types/import-types.js +2 -0
  30. package/dist/config/types/index.d.ts +11 -0
  31. package/dist/config/types/index.js +27 -0
  32. package/dist/config/types/load-config.d.ts +32 -0
  33. package/dist/config/types/load-config.js +9 -0
  34. package/dist/config/types/output-config.d.ts +10 -0
  35. package/dist/config/types/output-config.js +2 -0
  36. package/dist/config/types/report-config.d.ts +10 -0
  37. package/dist/config/types/report-config.js +2 -0
  38. package/dist/config/types/runtime-types.d.ts +6 -0
  39. package/dist/config/types/runtime-types.js +2 -0
  40. package/dist/config/types/scenario-config.d.ts +30 -0
  41. package/dist/config/types/scenario-config.js +2 -0
  42. package/dist/config/types/step-types.d.ts +139 -0
  43. package/dist/config/types/step-types.js +2 -0
  44. package/dist/config/types/test-configuration.d.ts +18 -0
  45. package/dist/config/types/test-configuration.js +2 -0
  46. package/dist/config/types/worker-config.d.ts +12 -0
  47. package/dist/config/types/worker-config.js +2 -0
  48. package/dist/config/validator.d.ts +19 -0
  49. package/dist/config/validator.js +198 -0
  50. package/dist/core/csv-data-provider.d.ts +47 -0
  51. package/dist/core/csv-data-provider.js +265 -0
  52. package/dist/core/hooks-manager.d.ts +33 -0
  53. package/dist/core/hooks-manager.js +129 -0
  54. package/dist/core/index.d.ts +5 -0
  55. package/dist/core/index.js +11 -0
  56. package/dist/core/script-executor.d.ts +14 -0
  57. package/dist/core/script-executor.js +290 -0
  58. package/dist/core/step-executor.d.ts +41 -0
  59. package/dist/core/step-executor.js +680 -0
  60. package/dist/core/test-runner.d.ts +34 -0
  61. package/dist/core/test-runner.js +465 -0
  62. package/dist/core/threshold-evaluator.d.ts +43 -0
  63. package/dist/core/threshold-evaluator.js +170 -0
  64. package/dist/core/virtual-user-pool.d.ts +42 -0
  65. package/dist/core/virtual-user-pool.js +136 -0
  66. package/dist/core/virtual-user.d.ts +51 -0
  67. package/dist/core/virtual-user.js +488 -0
  68. package/dist/distributed/coordinator.d.ts +34 -0
  69. package/dist/distributed/coordinator.js +158 -0
  70. package/dist/distributed/health-monitor.d.ts +18 -0
  71. package/dist/distributed/health-monitor.js +72 -0
  72. package/dist/distributed/load-distributor.d.ts +17 -0
  73. package/dist/distributed/load-distributor.js +106 -0
  74. package/dist/distributed/remote-worker.d.ts +37 -0
  75. package/dist/distributed/remote-worker.js +241 -0
  76. package/dist/distributed/result-aggregator.d.ts +43 -0
  77. package/dist/distributed/result-aggregator.js +146 -0
  78. package/dist/dsl/index.d.ts +3 -0
  79. package/dist/dsl/index.js +11 -0
  80. package/dist/dsl/test-builder.d.ts +111 -0
  81. package/dist/dsl/test-builder.js +514 -0
  82. package/dist/importers/har-importer.d.ts +17 -0
  83. package/dist/importers/har-importer.js +172 -0
  84. package/dist/importers/open-api-importer.d.ts +23 -0
  85. package/dist/importers/open-api-importer.js +181 -0
  86. package/dist/importers/wsdl-importer.d.ts +42 -0
  87. package/dist/importers/wsdl-importer.js +440 -0
  88. package/dist/index.d.ts +5 -0
  89. package/dist/index.js +17 -0
  90. package/dist/load-patterns/arrivals.d.ts +7 -0
  91. package/dist/load-patterns/arrivals.js +118 -0
  92. package/dist/load-patterns/base.d.ts +9 -0
  93. package/dist/load-patterns/base.js +2 -0
  94. package/dist/load-patterns/basic.d.ts +7 -0
  95. package/dist/load-patterns/basic.js +117 -0
  96. package/dist/load-patterns/stepping.d.ts +6 -0
  97. package/dist/load-patterns/stepping.js +122 -0
  98. package/dist/metrics/collector.d.ts +72 -0
  99. package/dist/metrics/collector.js +662 -0
  100. package/dist/metrics/types.d.ts +135 -0
  101. package/dist/metrics/types.js +2 -0
  102. package/dist/outputs/base.d.ts +7 -0
  103. package/dist/outputs/base.js +2 -0
  104. package/dist/outputs/csv.d.ts +13 -0
  105. package/dist/outputs/csv.js +163 -0
  106. package/dist/outputs/graphite.d.ts +13 -0
  107. package/dist/outputs/graphite.js +126 -0
  108. package/dist/outputs/influxdb.d.ts +12 -0
  109. package/dist/outputs/influxdb.js +82 -0
  110. package/dist/outputs/json.d.ts +14 -0
  111. package/dist/outputs/json.js +107 -0
  112. package/dist/outputs/streaming-csv.d.ts +37 -0
  113. package/dist/outputs/streaming-csv.js +254 -0
  114. package/dist/outputs/streaming-json.d.ts +43 -0
  115. package/dist/outputs/streaming-json.js +353 -0
  116. package/dist/outputs/webhook.d.ts +16 -0
  117. package/dist/outputs/webhook.js +96 -0
  118. package/dist/protocols/base.d.ts +33 -0
  119. package/dist/protocols/base.js +2 -0
  120. package/dist/protocols/rest/handler.d.ts +67 -0
  121. package/dist/protocols/rest/handler.js +776 -0
  122. package/dist/protocols/soap/handler.d.ts +12 -0
  123. package/dist/protocols/soap/handler.js +165 -0
  124. package/dist/protocols/web/core-web-vitals.d.ts +121 -0
  125. package/dist/protocols/web/core-web-vitals.js +373 -0
  126. package/dist/protocols/web/handler.d.ts +50 -0
  127. package/dist/protocols/web/handler.js +706 -0
  128. package/dist/recorder/native-recorder.d.ts +14 -0
  129. package/dist/recorder/native-recorder.js +533 -0
  130. package/dist/recorder/scenario-recorder.d.ts +55 -0
  131. package/dist/recorder/scenario-recorder.js +296 -0
  132. package/dist/reporting/constants.d.ts +94 -0
  133. package/dist/reporting/constants.js +82 -0
  134. package/dist/reporting/enhanced-html-generator.d.ts +55 -0
  135. package/dist/reporting/enhanced-html-generator.js +965 -0
  136. package/dist/reporting/generator.d.ts +42 -0
  137. package/dist/reporting/generator.js +1217 -0
  138. package/dist/reporting/statistics.d.ts +144 -0
  139. package/dist/reporting/statistics.js +742 -0
  140. package/dist/reporting/templates/enhanced-report.hbs +2812 -0
  141. package/dist/reporting/templates/html.hbs +2453 -0
  142. package/dist/utils/faker-manager.d.ts +55 -0
  143. package/dist/utils/faker-manager.js +166 -0
  144. package/dist/utils/file-manager.d.ts +33 -0
  145. package/dist/utils/file-manager.js +154 -0
  146. package/dist/utils/handlebars-manager.d.ts +42 -0
  147. package/dist/utils/handlebars-manager.js +172 -0
  148. package/dist/utils/logger.d.ts +16 -0
  149. package/dist/utils/logger.js +46 -0
  150. package/dist/utils/template.d.ts +80 -0
  151. package/dist/utils/template.js +513 -0
  152. package/dist/utils/test-output-writer.d.ts +56 -0
  153. package/dist/utils/test-output-writer.js +643 -0
  154. package/dist/utils/time.d.ts +3 -0
  155. package/dist/utils/time.js +23 -0
  156. package/dist/utils/timestamp-helper.d.ts +17 -0
  157. package/dist/utils/timestamp-helper.js +53 -0
  158. package/dist/workers/manager.d.ts +18 -0
  159. package/dist/workers/manager.js +95 -0
  160. package/dist/workers/server.d.ts +21 -0
  161. package/dist/workers/server.js +205 -0
  162. package/dist/workers/worker.d.ts +19 -0
  163. package/dist/workers/worker.js +147 -0
  164. package/package.json +102 -0
@@ -0,0 +1,965 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.EnhancedHTMLReportGenerator = void 0;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const Handlebars = __importStar(require("handlebars"));
40
+ const logger_1 = require("../utils/logger");
41
+ const file_manager_1 = require("../utils/file-manager");
42
+ const statistics_1 = require("./statistics");
43
+ const constants_1 = require("./constants");
44
+ class EnhancedHTMLReportGenerator {
45
+ constructor(config) {
46
+ this.templateCache = new Map();
47
+ this.DEFAULT_CONFIG = {
48
+ title: 'Perfornium Load Test Report',
49
+ description: 'Performance test results and analysis',
50
+ includeCharts: true,
51
+ includeTimeline: true,
52
+ includeErrorDetails: true,
53
+ includeResponseTimes: true,
54
+ assetsInline: true,
55
+ darkMode: false
56
+ };
57
+ this.config = { ...this.DEFAULT_CONFIG, ...config };
58
+ this.setupHandlebarsHelpers();
59
+ }
60
+ setupHandlebarsHelpers() {
61
+ // Number formatting helpers
62
+ Handlebars.registerHelper('round', (num, decimals = 2) => {
63
+ return typeof num === 'number' ? num.toFixed(decimals) : '0';
64
+ });
65
+ Handlebars.registerHelper('formatNumber', (num) => {
66
+ return typeof num === 'number' ? num.toLocaleString() : '0';
67
+ });
68
+ // Percentage helper
69
+ Handlebars.registerHelper('percent', (num, total) => {
70
+ if (!total || total === 0)
71
+ return '0%';
72
+ return ((num / total) * 100).toFixed(1) + '%';
73
+ });
74
+ // Status class helper
75
+ Handlebars.registerHelper('statusClass', (success) => {
76
+ return success ? 'success' : 'error';
77
+ });
78
+ // Duration formatting
79
+ Handlebars.registerHelper('formatDuration', (ms) => {
80
+ if (ms < 1000)
81
+ return `${ms}ms`;
82
+ const seconds = Math.floor(ms / 1000);
83
+ if (seconds < 60)
84
+ return `${seconds}s`;
85
+ const minutes = Math.floor(seconds / 60);
86
+ return `${minutes}m ${seconds % 60}s`;
87
+ });
88
+ // Date formatting
89
+ Handlebars.registerHelper('formatDate', (timestamp) => {
90
+ return new Date(timestamp).toLocaleString();
91
+ });
92
+ // JSON helper
93
+ Handlebars.registerHelper('json', (obj) => {
94
+ return JSON.stringify(obj, null, 2);
95
+ });
96
+ // Chart data helper
97
+ Handlebars.registerHelper('chartData', (data) => {
98
+ return JSON.stringify(data);
99
+ });
100
+ // Core Web Vitals score class helper
101
+ Handlebars.registerHelper('vitalsScoreClass', (score) => {
102
+ switch (score) {
103
+ case 'good': return 'vitals-good';
104
+ case 'needs-improvement': return 'vitals-warning';
105
+ case 'poor': return 'vitals-poor';
106
+ default: return 'vitals-unknown';
107
+ }
108
+ });
109
+ // Web Vitals metric formatting
110
+ Handlebars.registerHelper('formatVitalsMetric', (value, metric) => {
111
+ if (typeof value !== 'number')
112
+ return 'N/A';
113
+ switch (metric) {
114
+ case 'cls':
115
+ return value.toFixed(3);
116
+ case 'lcp':
117
+ case 'inp':
118
+ case 'fid':
119
+ case 'fcp':
120
+ case 'ttfb':
121
+ case 'tti':
122
+ case 'tbt':
123
+ case 'speedIndex':
124
+ return value < 1000 ? `${Math.round(value)}ms` : `${(value / 1000).toFixed(2)}s`;
125
+ default:
126
+ return value.toFixed(2);
127
+ }
128
+ });
129
+ // Verification metrics helper
130
+ Handlebars.registerHelper('formatVerificationDuration', (duration) => {
131
+ if (typeof duration !== 'number')
132
+ return 'N/A';
133
+ return duration < 1000 ? `${Math.round(duration)}ms` : `${(duration / 1000).toFixed(2)}s`;
134
+ });
135
+ // String comparison helper
136
+ Handlebars.registerHelper('ifEquals', function (arg1, arg2, options) {
137
+ return (String(arg1) === String(arg2)) ? options.fn(this) : options.inverse(this);
138
+ });
139
+ // Greater than or equal comparison helper
140
+ Handlebars.registerHelper('gte', function (a, b) {
141
+ return a >= b;
142
+ });
143
+ // Greater than comparison helper
144
+ Handlebars.registerHelper('gt', function (a, b) {
145
+ return a > b;
146
+ });
147
+ // Less than comparison helper
148
+ Handlebars.registerHelper('lt', function (a, b) {
149
+ return a < b;
150
+ });
151
+ // Additional helpers needed by the template
152
+ Handlebars.registerHelper('toFixed', function (num, digits) {
153
+ return typeof num === 'number' ? num.toFixed(digits) : '0';
154
+ });
155
+ Handlebars.registerHelper('percent', function (num, digits) {
156
+ return typeof num === 'number' ? num.toFixed(digits) : '0';
157
+ });
158
+ Handlebars.registerHelper('lookup', function (obj, key) {
159
+ return obj && obj[key] !== undefined ? obj[key] : 0;
160
+ });
161
+ }
162
+ async generate(data, filePath) {
163
+ try {
164
+ // Process file path template
165
+ const processedPath = file_manager_1.FileManager.processFilePath(filePath, {
166
+ vu_id: 0,
167
+ iteration: 0,
168
+ variables: { test_name: data.testName },
169
+ extracted_data: {}
170
+ });
171
+ // Prepare enhanced data for template
172
+ const enhancedData = this.prepareReportData(data);
173
+ // Load and compile template
174
+ const template = await this.loadTemplate();
175
+ // Generate HTML
176
+ const html = template(enhancedData);
177
+ // Write to file
178
+ file_manager_1.FileManager.createTimestampedFile(processedPath, html);
179
+ logger_1.logger.success(`📋 HTML report generated: ${processedPath}`);
180
+ return processedPath;
181
+ }
182
+ catch (error) {
183
+ const errorMessage = error instanceof Error ? error.message : String(error);
184
+ const errorStack = error instanceof Error ? error.stack : '';
185
+ logger_1.logger.error(`❌ Failed to generate HTML report for "${data.testName}":`, {
186
+ error: errorMessage,
187
+ stack: errorStack,
188
+ outputPath: filePath,
189
+ resultsCount: data.results?.length || 0,
190
+ });
191
+ throw new Error(`Report generation failed: ${errorMessage}`);
192
+ }
193
+ }
194
+ prepareReportData(data) {
195
+ const now = Date.now();
196
+ const startTime = data.metadata?.test_start ? new Date(data.metadata.test_start).getTime() : now - 60000;
197
+ // Calculate enhanced statistics if results are available
198
+ const enhancedSummary = { ...data.summary };
199
+ // Comprehensive analysis objects
200
+ let apdexScore = null;
201
+ let slaCompliance = null;
202
+ let outlierAnalysis = null;
203
+ let confidenceInterval = null;
204
+ let heatmapData = null;
205
+ let performanceTrends = null;
206
+ if (data.results && data.results.length > 0) {
207
+ try {
208
+ // Add Web Vitals statistics
209
+ const webVitalsStats = statistics_1.StatisticsCalculator.calculateWebVitalsStatistics(data.results);
210
+ Object.assign(enhancedSummary, webVitalsStats);
211
+ // Calculate step statistics using centralized method (deduplicated)
212
+ if (!enhancedSummary.step_statistics || enhancedSummary.step_statistics.length === 0) {
213
+ enhancedSummary.step_statistics = statistics_1.StatisticsCalculator.calculateDetailedStepStatistics(data.results);
214
+ }
215
+ // Calculate Apdex score
216
+ apdexScore = statistics_1.StatisticsCalculator.calculateApdexScore(data.results, this.config.apdexThreshold);
217
+ // Check SLA compliance
218
+ const slaConfig = this.config.sla ? {
219
+ SUCCESS_RATE: this.config.sla.successRate ?? constants_1.SLA_DEFAULTS.SUCCESS_RATE,
220
+ AVG_RESPONSE_TIME: this.config.sla.avgResponseTime ?? constants_1.SLA_DEFAULTS.AVG_RESPONSE_TIME,
221
+ P95_RESPONSE_TIME: this.config.sla.p95ResponseTime ?? constants_1.SLA_DEFAULTS.P95_RESPONSE_TIME,
222
+ P99_RESPONSE_TIME: this.config.sla.p99ResponseTime ?? constants_1.SLA_DEFAULTS.P99_RESPONSE_TIME,
223
+ MIN_REQUESTS_PER_SECOND: this.config.sla.minThroughput ?? constants_1.SLA_DEFAULTS.MIN_REQUESTS_PER_SECOND,
224
+ } : undefined;
225
+ slaCompliance = statistics_1.StatisticsCalculator.checkSLACompliance(data.results, slaConfig);
226
+ // Detect outliers
227
+ outlierAnalysis = statistics_1.StatisticsCalculator.detectOutliers(data.results);
228
+ // Calculate confidence interval for response times
229
+ const responseTimes = data.results.filter(r => r.success).map(r => (0, constants_1.getResponseTime)(r));
230
+ if (responseTimes.length > 0) {
231
+ confidenceInterval = statistics_1.StatisticsCalculator.calculateConfidenceInterval(responseTimes);
232
+ }
233
+ // Generate heatmap data
234
+ heatmapData = statistics_1.StatisticsCalculator.generateHeatmapData(data.results);
235
+ // Calculate performance trends
236
+ performanceTrends = statistics_1.StatisticsCalculator.calculatePerformanceTrends(data.results);
237
+ }
238
+ catch (error) {
239
+ logger_1.logger.warn('Warning: Error calculating enhanced statistics:', error);
240
+ }
241
+ }
242
+ // Check if this is a Playwright-based test
243
+ const hasWebVitals = data.results?.some(r => r.custom_metrics?.web_vitals) || enhancedSummary.web_vitals_data;
244
+ const isPlaywrightTest = data.results?.some(r => r.scenario?.includes('web') || r.action?.includes('goto') || r.action?.includes('verify')) || hasWebVitals;
245
+ // Prepare data in the format expected by the template
246
+ const charts = this.config.includeCharts ? this.prepareChartData(data) : null;
247
+ const timeline = this.config.includeTimeline ? this.prepareTimelineData(data) : null;
248
+ const responseTimeAnalysis = this.config.includeResponseTimes ? this.prepareResponseTimeAnalysis(data) : null;
249
+ const generatedAt = new Date().toLocaleString('en-US', {
250
+ year: 'numeric',
251
+ month: 'long',
252
+ day: 'numeric',
253
+ hour: '2-digit',
254
+ minute: '2-digit',
255
+ second: '2-digit',
256
+ hour12: false
257
+ });
258
+ return {
259
+ ...data,
260
+ summary: enhancedSummary,
261
+ config: this.config,
262
+ metadata: {
263
+ generated_at: new Date().toISOString(),
264
+ generated_by: 'Perfornium Enhanced HTML Generator',
265
+ test_duration: this.formatDuration(now - startTime),
266
+ ...data.metadata
267
+ },
268
+ charts,
269
+ timeline,
270
+ errorAnalysis: this.config.includeErrorDetails ? this.prepareErrorAnalysis(data) : null,
271
+ responseTimeAnalysis,
272
+ webVitalsCharts: isPlaywrightTest ? this.prepareWebVitalsCharts(data) : null,
273
+ isPlaywrightTest,
274
+ // NEW: Comprehensive analysis data
275
+ apdexScore,
276
+ slaCompliance,
277
+ outlierAnalysis,
278
+ confidenceInterval,
279
+ heatmapData,
280
+ performanceTrends,
281
+ // JSON-serialized versions for charts
282
+ apdexScoreData: JSON.stringify(apdexScore),
283
+ slaComplianceData: JSON.stringify(slaCompliance),
284
+ outlierAnalysisData: JSON.stringify(outlierAnalysis),
285
+ confidenceIntervalData: JSON.stringify(confidenceInterval),
286
+ heatmapDataJson: JSON.stringify(heatmapData),
287
+ performanceTrendsData: JSON.stringify(performanceTrends),
288
+ // Template-specific data variables (for compatibility with enhanced-report.hbs)
289
+ generatedAt, // Human-readable date for template header
290
+ summaryData: JSON.stringify(enhancedSummary),
291
+ stepStatistics: enhancedSummary.step_statistics || [], // Direct array for {{#each}} iteration
292
+ stepStatisticsData: JSON.stringify(enhancedSummary.step_statistics || []),
293
+ vuRampupData: JSON.stringify(timeline?.vuRampup || []),
294
+ timelineData: JSON.stringify(timeline?.data || []),
295
+ responseTimeDistributionData: JSON.stringify(charts?.responseTimeHistogram?.data || []),
296
+ requestsPerSecondData: JSON.stringify(timeline?.requestsPerSecond || []),
297
+ responsesPerSecondData: JSON.stringify(timeline?.responsesPerSecond || []),
298
+ stepResponseTimesData: JSON.stringify(responseTimeAnalysis?.stepResponseTimes || []),
299
+ stepResponseTimes: responseTimeAnalysis?.stepResponseTimes || [], // Direct access for iteration in template
300
+ connectTimeData: JSON.stringify(timeline?.connectTimeData || []),
301
+ latencyData: JSON.stringify(timeline?.latencyData || [])
302
+ };
303
+ }
304
+ prepareChartData(data) {
305
+ const summary = data.summary;
306
+ return {
307
+ // Success/Failure pie chart
308
+ successFailure: {
309
+ labels: ['Successful', 'Failed'],
310
+ data: [summary.successful_requests, summary.failed_requests],
311
+ colors: ['#4CAF50', '#F44336']
312
+ },
313
+ // Response time distribution
314
+ responseTimeHistogram: this.createResponseTimeHistogram(data.results || []),
315
+ // Status codes distribution
316
+ statusCodes: {
317
+ labels: Object.keys(summary.status_distribution || {}),
318
+ data: Object.values(summary.status_distribution || {}),
319
+ colors: this.generateColors(Object.keys(summary.status_distribution || {}).length)
320
+ },
321
+ // Percentiles
322
+ percentiles: {
323
+ labels: Object.keys(summary.percentiles || {}),
324
+ data: Object.values(summary.percentiles || {}),
325
+ colors: ['#2196F3']
326
+ }
327
+ };
328
+ }
329
+ prepareTimelineData(data) {
330
+ if (!data.results || data.results.length === 0)
331
+ return null;
332
+ const results = data.results.sort((a, b) => a.timestamp - b.timestamp);
333
+ const startTime = results[0].timestamp;
334
+ // Get VU ramp-up events from summary (real VU tracking data)
335
+ const vuRampUpEvents = data.summary.vu_ramp_up || [];
336
+ // Use TIME_BUCKETS.FINE (1 second) for granular timeline
337
+ const bucketSize = constants_1.TIME_BUCKETS.FINE;
338
+ const buckets = new Map();
339
+ // O(n) bucketing - single pass
340
+ for (const result of results) {
341
+ const bucketKey = Math.floor((result.timestamp - startTime) / bucketSize) * bucketSize;
342
+ if (!buckets.has(bucketKey)) {
343
+ buckets.set(bucketKey, []);
344
+ }
345
+ buckets.get(bucketKey).push(result);
346
+ }
347
+ // Create timeline data with active VUs
348
+ const timelineData = Array.from(buckets.entries()).map(([time, bucketResults]) => {
349
+ const successfulResults = bucketResults.filter(r => r.success);
350
+ const successful = successfulResults.length;
351
+ const failed = bucketResults.filter(r => !r.success).length;
352
+ // Calculate average response time using standardized getResponseTime
353
+ const avgResponseTime = successfulResults.length > 0
354
+ ? successfulResults.reduce((sum, r) => sum + (0, constants_1.getResponseTime)(r), 0) / successfulResults.length
355
+ : 0;
356
+ // Calculate average connect time and latency (from successful requests only)
357
+ const connectTimes = successfulResults.map(r => r.connect_time || 0).filter(ct => ct > 0);
358
+ const avgConnectTime = connectTimes.length > 0
359
+ ? connectTimes.reduce((sum, ct) => sum + ct, 0) / connectTimes.length
360
+ : 0;
361
+ const latencies = successfulResults.map(r => r.latency || 0).filter(l => l > 0);
362
+ const avgLatency = latencies.length > 0
363
+ ? latencies.reduce((sum, l) => sum + l, 0) / latencies.length
364
+ : 0;
365
+ // Calculate active VUs at this time point - ONLY use real tracked data
366
+ const currentTime = startTime + time;
367
+ let activeVUs = 0;
368
+ if (vuRampUpEvents.length > 0) {
369
+ // Count VUs that started before or at this time point (real data from workers)
370
+ activeVUs = vuRampUpEvents.filter(vu => vu.start_time <= currentTime).length;
371
+ }
372
+ // No fallback - if no VU tracking data, activeVUs stays 0
373
+ // Calculate success rate
374
+ const successRate = bucketResults.length > 0
375
+ ? (successful / bucketResults.length) * 100
376
+ : 0;
377
+ return {
378
+ time: time / 1000, // Convert to seconds
379
+ timestamp: startTime + time,
380
+ successful,
381
+ failed,
382
+ total: bucketResults.length,
383
+ avg_response_time: Math.round(avgResponseTime), // snake_case for template
384
+ avgResponseTime: Math.round(avgResponseTime), // camelCase for backwards compatibility
385
+ avg_connect_time: Math.round(avgConnectTime),
386
+ avgConnectTime: Math.round(avgConnectTime),
387
+ avg_latency: Math.round(avgLatency),
388
+ avgLatency: Math.round(avgLatency),
389
+ throughput: bucketResults.length / (bucketSize / 1000), // req/s
390
+ active_vus: activeVUs,
391
+ success_rate: successRate
392
+ };
393
+ }).sort((a, b) => a.timestamp - b.timestamp); // Sort by timestamp to ensure chronological order
394
+ // Aggregate VU ramp-up by time buckets
395
+ const vuRampupBuckets = new Map();
396
+ vuRampUpEvents.forEach(vu => {
397
+ const bucketKey = Math.floor((vu.start_time - startTime) / bucketSize) * bucketSize;
398
+ vuRampupBuckets.set(bucketKey, (vuRampupBuckets.get(bucketKey) || 0) + 1);
399
+ });
400
+ // Create cumulative VU count for chart as a time series
401
+ // ONLY use real tracked VU data - no estimation
402
+ const summaryAny = data.summary;
403
+ const totalVUs = summaryAny.total_virtual_users
404
+ || summaryAny.peak_virtual_users
405
+ || vuRampUpEvents.length; // Only count if we have real VU events
406
+ const vuRampupCumulative = [];
407
+ // Only generate VU ramp-up data if we have real VU tracking events
408
+ if (vuRampUpEvents.length > 0) {
409
+ const testStartTime = Math.min(...vuRampUpEvents.map(vu => vu.start_time));
410
+ const testEndTime = results.length > 0
411
+ ? Math.max(...results.map(r => r.timestamp))
412
+ : testStartTime + 60000;
413
+ const timeInterval = 1000; // 1 second
414
+ const sortedVUEvents = [...vuRampUpEvents].sort((a, b) => a.start_time - b.start_time);
415
+ for (let t = testStartTime; t <= testEndTime; t += timeInterval) {
416
+ // Count VUs that have started by this time (real data from workers)
417
+ const activeVUs = sortedVUEvents.filter(vu => vu.start_time <= t).length;
418
+ vuRampupCumulative.push({
419
+ time: (t - testStartTime) / 1000,
420
+ timestamp: t,
421
+ count: Math.min(activeVUs, totalVUs)
422
+ });
423
+ }
424
+ }
425
+ // If no VU events, vuRampupCumulative stays empty - chart will be hidden
426
+ // Prepare requests per second data with all required fields
427
+ const requestsPerSecondData = timelineData.map(d => ({
428
+ timestamp: d.timestamp,
429
+ requests_per_second: d.total / (bucketSize / 1000),
430
+ successful_requests_per_second: d.successful / (bucketSize / 1000),
431
+ error_requests_per_second: d.failed / (bucketSize / 1000)
432
+ }));
433
+ // Prepare responses per second data with all required fields
434
+ const responsesPerSecondData = timelineData.map(d => ({
435
+ timestamp: d.timestamp,
436
+ responses_per_second: d.successful / (bucketSize / 1000),
437
+ error_responses_per_second: d.failed / (bucketSize / 1000)
438
+ }));
439
+ // Prepare connect time data
440
+ const connectTimeData = timelineData.map(d => ({
441
+ timestamp: d.timestamp,
442
+ time: d.time,
443
+ avg_connect_time: d.avg_connect_time
444
+ }));
445
+ // Prepare latency data
446
+ const latencyData = timelineData.map(d => ({
447
+ timestamp: d.timestamp,
448
+ time: d.time,
449
+ avg_latency: d.avg_latency
450
+ }));
451
+ return {
452
+ data: timelineData,
453
+ labels: timelineData.map(d => `${d.time}s`),
454
+ successful: timelineData.map(d => d.successful),
455
+ failed: timelineData.map(d => d.failed),
456
+ responseTime: timelineData.map(d => d.avgResponseTime),
457
+ throughput: timelineData.map(d => d.throughput),
458
+ activeVUs: timelineData.map(d => d.active_vus),
459
+ connectTime: timelineData.map(d => d.avgConnectTime),
460
+ latency: timelineData.map(d => d.avgLatency),
461
+ vuRampup: vuRampupCumulative,
462
+ requestsPerSecond: requestsPerSecondData,
463
+ responsesPerSecond: responsesPerSecondData,
464
+ connectTimeData,
465
+ latencyData
466
+ };
467
+ }
468
+ prepareErrorAnalysis(data) {
469
+ if (!data.results)
470
+ return null;
471
+ const errors = data.results.filter(r => !r.success);
472
+ const errorGroups = new Map();
473
+ errors.forEach(error => {
474
+ const key = `${error.status || 'Unknown'}:${error.error || 'Unknown error'}`;
475
+ if (!errorGroups.has(key)) {
476
+ errorGroups.set(key, []);
477
+ }
478
+ errorGroups.get(key).push(error);
479
+ });
480
+ const topErrors = Array.from(errorGroups.entries())
481
+ .map(([key, errors]) => {
482
+ const [status, message] = key.split(':', 2);
483
+ return {
484
+ status: status === 'Unknown' ? null : parseInt(status),
485
+ message,
486
+ count: errors.length,
487
+ percentage: (errors.length / data.results.length * 100).toFixed(1),
488
+ examples: errors.slice(0, 3).map(e => ({
489
+ url: e.request_url,
490
+ timestamp: e.timestamp,
491
+ vu_id: e.vu_id,
492
+ response_body: e.response_body?.substring(0, 200)
493
+ }))
494
+ };
495
+ })
496
+ .sort((a, b) => b.count - a.count)
497
+ .slice(0, 10);
498
+ // Group errors by sample/request (step_name) and error type
499
+ const sampleErrorGroups = new Map();
500
+ errors.forEach(error => {
501
+ const sampleName = error.step_name || error.action || 'Unknown Sample';
502
+ const errorType = `${error.status || 'N/A'}:${error.error || 'Unknown error'}`;
503
+ if (!sampleErrorGroups.has(sampleName)) {
504
+ sampleErrorGroups.set(sampleName, new Map());
505
+ }
506
+ const errorTypeMap = sampleErrorGroups.get(sampleName);
507
+ if (!errorTypeMap.has(errorType)) {
508
+ errorTypeMap.set(errorType, []);
509
+ }
510
+ errorTypeMap.get(errorType).push(error);
511
+ });
512
+ // Prepare error details grouped by sample and error type
513
+ const errorDetails = [];
514
+ sampleErrorGroups.forEach((errorTypeMap, sampleName) => {
515
+ // Count total requests for this sample
516
+ const totalSampleRequests = data.results.filter(r => (r.step_name || r.action || 'Unknown Sample') === sampleName).length;
517
+ errorTypeMap.forEach((errorList, errorType) => {
518
+ const [status, errorMessage] = errorType.split(':', 2);
519
+ const errorCount = errorList.length;
520
+ const percentageOfSample = totalSampleRequests > 0
521
+ ? ((errorCount / totalSampleRequests) * 100).toFixed(1)
522
+ : '0.0';
523
+ // Get example request details
524
+ const example = errorList[0];
525
+ errorDetails.push({
526
+ sample_name: sampleName,
527
+ request_method: example.request_method || 'N/A',
528
+ request_url: example.request_url || 'N/A',
529
+ status: status === 'N/A' ? 'N/A' : status,
530
+ status_text: example.status_text || 'N/A',
531
+ error_type: errorMessage,
532
+ error_code: example.error_code || 'N/A',
533
+ error_count: errorCount,
534
+ total_sample_requests: totalSampleRequests,
535
+ percentage: percentageOfSample
536
+ });
537
+ });
538
+ });
539
+ // Sort by sample name, then by error count
540
+ errorDetails.sort((a, b) => {
541
+ if (a.sample_name !== b.sample_name) {
542
+ return a.sample_name.localeCompare(b.sample_name);
543
+ }
544
+ return b.error_count - a.error_count;
545
+ });
546
+ return {
547
+ totalErrors: errors.length,
548
+ errorRate: (errors.length / data.results.length * 100).toFixed(2),
549
+ topErrors,
550
+ errorDetails
551
+ };
552
+ }
553
+ prepareResponseTimeAnalysis(data) {
554
+ if (!data.results)
555
+ return null;
556
+ const responseTimes = data.results
557
+ .map(r => r.response_time || r.duration || 0)
558
+ .filter(rt => rt > 0)
559
+ .sort((a, b) => a - b);
560
+ if (responseTimes.length === 0)
561
+ return null;
562
+ // Group by step for step response times chart
563
+ const stepGroups = new Map();
564
+ data.results.forEach(result => {
565
+ const stepName = result.step_name || result.action || 'Unknown Step';
566
+ if (!stepGroups.has(stepName)) {
567
+ stepGroups.set(stepName, []);
568
+ }
569
+ stepGroups.get(stepName).push(result);
570
+ });
571
+ const stepResponseTimes = Array.from(stepGroups.entries()).map(([stepName, results]) => {
572
+ const stepResponseTimes = results.map(r => r.response_time || r.duration || 0).filter(rt => rt > 0);
573
+ return {
574
+ step_name: stepName,
575
+ count: results.length,
576
+ avg: stepResponseTimes.length > 0 ? Math.round(stepResponseTimes.reduce((a, b) => a + b, 0) / stepResponseTimes.length) : 0,
577
+ min: Math.min(...stepResponseTimes) || 0,
578
+ max: Math.max(...stepResponseTimes) || 0,
579
+ ...(() => {
580
+ const pcts = statistics_1.StatisticsCalculator.calculatePercentiles(stepResponseTimes, [50, 90, 95, 99]);
581
+ return { p50: pcts[50] || 0, p90: pcts[90] || 0, p95: pcts[95] || 0, p99: pcts[99] || 0 };
582
+ })(),
583
+ response_times: stepResponseTimes,
584
+ timeline_data: results.map(r => ({
585
+ duration: r.response_time || r.duration || 0,
586
+ timestamp: r.timestamp,
587
+ vu_id: r.vu_id || 1,
588
+ iteration: r.iteration || 0
589
+ }))
590
+ };
591
+ });
592
+ return {
593
+ histogram: this.createResponseTimeHistogram(data.results),
594
+ distribution: {
595
+ '< 100ms': responseTimes.filter(rt => rt < 100).length,
596
+ '100ms - 500ms': responseTimes.filter(rt => rt >= 100 && rt < 500).length,
597
+ '500ms - 1s': responseTimes.filter(rt => rt >= 500 && rt < 1000).length,
598
+ '1s - 5s': responseTimes.filter(rt => rt >= 1000 && rt < 5000).length,
599
+ '> 5s': responseTimes.filter(rt => rt >= 5000).length
600
+ },
601
+ stepResponseTimes
602
+ };
603
+ }
604
+ createResponseTimeHistogram(results) {
605
+ const responseTimes = results
606
+ .map(r => r.response_time || r.duration || 0)
607
+ .filter(rt => rt > 0)
608
+ .sort((a, b) => a - b);
609
+ if (responseTimes.length === 0)
610
+ return { data: [], labels: [], colors: [] };
611
+ const min = Math.floor(responseTimes[0]);
612
+ const max = Math.ceil(responseTimes[responseTimes.length - 1]);
613
+ const range = max - min;
614
+ // Use adaptive bucket sizing based on range
615
+ let bucketSize;
616
+ let numBuckets;
617
+ if (range <= 10) {
618
+ // Small range: 1ms buckets
619
+ bucketSize = 1;
620
+ numBuckets = Math.max(range, 1);
621
+ }
622
+ else if (range <= 100) {
623
+ // Medium range: 5ms buckets
624
+ bucketSize = 5;
625
+ numBuckets = Math.ceil(range / bucketSize);
626
+ }
627
+ else if (range <= 1000) {
628
+ // Large range: 50ms buckets
629
+ bucketSize = 50;
630
+ numBuckets = Math.ceil(range / bucketSize);
631
+ }
632
+ else {
633
+ // Very large range: adaptive buckets (max 20)
634
+ numBuckets = 20;
635
+ bucketSize = Math.ceil(range / numBuckets);
636
+ }
637
+ const histogram = new Array(numBuckets).fill(0);
638
+ const labels = [];
639
+ const distributionData = [];
640
+ for (let i = 0; i < numBuckets; i++) {
641
+ const start = min + (i * bucketSize);
642
+ const end = min + ((i + 1) * bucketSize);
643
+ labels.push(`${start}-${end}ms`);
644
+ }
645
+ responseTimes.forEach(rt => {
646
+ const bucket = Math.min(Math.floor((rt - min) / bucketSize), numBuckets - 1);
647
+ histogram[bucket]++;
648
+ });
649
+ // Create distribution data array with bucket, count, and percentage
650
+ for (let i = 0; i < numBuckets; i++) {
651
+ distributionData.push({
652
+ bucket: labels[i],
653
+ count: histogram[i],
654
+ percentage: (histogram[i] / responseTimes.length) * 100
655
+ });
656
+ }
657
+ return {
658
+ labels,
659
+ data: distributionData, // Array of objects for template compatibility
660
+ colors: ['#2196F3']
661
+ };
662
+ }
663
+ prepareWebVitalsCharts(data) {
664
+ if (!data.results || data.results.length === 0)
665
+ return null;
666
+ // Collect all Web Vitals data points
667
+ const webVitalsData = data.results
668
+ .filter(r => r.custom_metrics?.web_vitals)
669
+ .map(r => ({
670
+ timestamp: r.timestamp,
671
+ vitals: r.custom_metrics.web_vitals,
672
+ score: r.custom_metrics.vitals_score,
673
+ url: r.custom_metrics.page_url
674
+ }));
675
+ if (webVitalsData.length === 0)
676
+ return null;
677
+ // Prepare time series data for each metric (prioritized order)
678
+ const metrics = ['lcp', 'cls', 'inp', 'ttfb', 'fcp', 'fid', 'tti', 'tbt', 'speedIndex'];
679
+ const timeSeries = {};
680
+ metrics.forEach(metric => {
681
+ timeSeries[metric] = {
682
+ labels: [],
683
+ data: [],
684
+ backgroundColor: this.getMetricColor(metric),
685
+ borderColor: this.getMetricColor(metric)
686
+ };
687
+ });
688
+ // Sort data by timestamp for proper timeline
689
+ webVitalsData.sort((a, b) => a.timestamp - b.timestamp);
690
+ // Create unified labels and populate time series data
691
+ const unifiedLabels = [];
692
+ const unifiedData = {};
693
+ metrics.forEach(metric => {
694
+ unifiedData[metric] = [];
695
+ });
696
+ webVitalsData.forEach((data) => {
697
+ const timeLabel = new Date(data.timestamp).toLocaleTimeString();
698
+ const urlLabel = data.url ? ` (${data.url.split('/').pop() || data.url})` : '';
699
+ unifiedLabels.push(`${timeLabel}${urlLabel}`);
700
+ metrics.forEach(metric => {
701
+ const value = data.vitals[metric] !== undefined ? data.vitals[metric] : null;
702
+ unifiedData[metric].push(value);
703
+ // Also maintain individual metric arrays for backward compatibility
704
+ if (value !== null) {
705
+ timeSeries[metric].labels.push(`${timeLabel}${urlLabel}`);
706
+ timeSeries[metric].data.push(value);
707
+ }
708
+ });
709
+ });
710
+ // Add unified data for the combined timeline chart
711
+ timeSeries.unified = {
712
+ labels: unifiedLabels,
713
+ data: unifiedData
714
+ };
715
+ // Calculate distributions
716
+ const distributions = {};
717
+ metrics.forEach(metric => {
718
+ const values = webVitalsData
719
+ .map(d => d.vitals[metric])
720
+ .filter(v => v !== undefined && v !== null);
721
+ if (values.length > 0) {
722
+ const stats = statistics_1.StatisticsCalculator.calculateEnhancedStatistics(values);
723
+ const pcts = statistics_1.StatisticsCalculator.calculatePercentiles(values, [75, 90, 95, 99]);
724
+ distributions[metric] = {
725
+ min: stats.min,
726
+ max: stats.max,
727
+ avg: stats.mean,
728
+ median: stats.median,
729
+ p75: pcts[75] || 0,
730
+ p90: pcts[90] || 0,
731
+ p95: pcts[95] || 0,
732
+ p99: pcts[99] || 0
733
+ };
734
+ }
735
+ });
736
+ // Score distribution
737
+ const scoreDistribution = {
738
+ good: webVitalsData.filter(d => d.score === 'good').length,
739
+ needsImprovement: webVitalsData.filter(d => d.score === 'needs-improvement').length,
740
+ poor: webVitalsData.filter(d => d.score === 'poor').length
741
+ };
742
+ // Page-by-page analysis if multiple pages
743
+ const pageAnalysis = this.analyzeByPage(webVitalsData);
744
+ return {
745
+ timeSeries,
746
+ distributions,
747
+ scoreDistribution,
748
+ pageAnalysis,
749
+ totalMeasurements: webVitalsData.length,
750
+ metrics: metrics.filter(m => timeSeries[m].data.length > 0)
751
+ };
752
+ }
753
+ getMetricColor(metric) {
754
+ const colors = {
755
+ lcp: '#FF6B6B', // Red - Loading performance
756
+ cls: '#45B7D1', // Blue - Visual stability
757
+ inp: '#4ECDC4', // Teal - Responsiveness
758
+ ttfb: '#FECA57', // Yellow - Server response
759
+ fcp: '#96CEB4', // Green - Loading performance
760
+ fid: '#9370DB', // Medium Purple (deprecated)
761
+ tti: '#DDA0DD', // Plum
762
+ tbt: '#FFA07A', // Light Salmon
763
+ speedIndex: '#98D8C8' // Mint
764
+ };
765
+ return colors[metric] || '#999999';
766
+ }
767
+ analyzeByPage(webVitalsData) {
768
+ const pageGroups = new Map();
769
+ webVitalsData.forEach(data => {
770
+ const url = data.url || 'Unknown';
771
+ if (!pageGroups.has(url)) {
772
+ pageGroups.set(url, []);
773
+ }
774
+ pageGroups.get(url).push(data);
775
+ });
776
+ const analysis = [];
777
+ pageGroups.forEach((pageData, url) => {
778
+ const metrics = {};
779
+ ['lcp', 'cls', 'inp', 'ttfb', 'fcp'].forEach(metric => {
780
+ const values = pageData
781
+ .map(d => d.vitals[metric])
782
+ .filter(v => v !== undefined && v !== null);
783
+ if (values.length > 0) {
784
+ metrics[metric] = {
785
+ avg: values.reduce((a, b) => a + b, 0) / values.length,
786
+ min: Math.min(...values),
787
+ max: Math.max(...values)
788
+ };
789
+ }
790
+ });
791
+ analysis.push({
792
+ url,
793
+ measurements: pageData.length,
794
+ metrics,
795
+ avgScore: this.calculateAverageScore(pageData.map(d => d.score))
796
+ });
797
+ });
798
+ return analysis;
799
+ }
800
+ calculateAverageScore(scores) {
801
+ const scoreValues = scores.map(s => {
802
+ switch (s) {
803
+ case 'good': return 3;
804
+ case 'needs-improvement': return 2;
805
+ case 'poor': return 1;
806
+ default: return 0;
807
+ }
808
+ });
809
+ const avg = scoreValues.reduce((a, b) => a + b, 0) / scoreValues.length;
810
+ if (avg >= 2.5)
811
+ return 'good';
812
+ if (avg >= 1.5)
813
+ return 'needs-improvement';
814
+ return 'poor';
815
+ }
816
+ generateColors(count) {
817
+ const colors = [
818
+ '#4CAF50', '#2196F3', '#FF9800', '#F44336', '#9C27B0',
819
+ '#00BCD4', '#CDDC39', '#FF5722', '#607D8B', '#795548'
820
+ ];
821
+ const result = [];
822
+ for (let i = 0; i < count; i++) {
823
+ result.push(colors[i % colors.length]);
824
+ }
825
+ return result;
826
+ }
827
+ async loadTemplate() {
828
+ const templatePath = this.config.templatePath || this.getDefaultTemplatePath();
829
+ if (this.templateCache.has(templatePath)) {
830
+ return this.templateCache.get(templatePath);
831
+ }
832
+ const templateContent = this.config.templatePath
833
+ ? fs.readFileSync(templatePath, 'utf8')
834
+ : (fs.existsSync(templatePath) ? fs.readFileSync(templatePath, 'utf8') : this.getDefaultTemplate());
835
+ const template = Handlebars.compile(templateContent);
836
+ this.templateCache.set(templatePath, template);
837
+ return template;
838
+ }
839
+ getDefaultTemplatePath() {
840
+ return path.join(__dirname, '../reporting/templates/enhanced-report.hbs');
841
+ }
842
+ // NOTE: calculateStepStatistics has been removed - using StatisticsCalculator.calculateDetailedStepStatistics instead
843
+ formatDuration(ms) {
844
+ if (ms < 1000)
845
+ return `${ms}ms`;
846
+ const seconds = Math.floor(ms / 1000);
847
+ if (seconds < 60)
848
+ return `${seconds}s`;
849
+ const minutes = Math.floor(seconds / 60);
850
+ return `${minutes}m ${seconds % 60}s`;
851
+ }
852
+ getDefaultTemplate() {
853
+ return `<!DOCTYPE html>
854
+ <html lang="en">
855
+ <head>
856
+ <meta charset="UTF-8">
857
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
858
+ <title>{{config.title}}</title>
859
+ <style>
860
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
861
+ .container { max-width: 1200px; margin: 0 auto; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
862
+ .header { padding: 30px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 8px 8px 0 0; }
863
+ .content { padding: 30px; }
864
+ .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; margin-bottom: 40px; }
865
+ .metric { background: #f8f9fa; padding: 20px; border-radius: 8px; text-align: center; border-left: 4px solid #667eea; }
866
+ .metric-value { font-size: 2em; font-weight: bold; color: #333; }
867
+ .metric-label { color: #666; margin-top: 5px; }
868
+ .chart-container { margin: 40px 0; padding: 20px; background: #f8f9fa; border-radius: 8px; }
869
+ .success { color: #4CAF50; }
870
+ .error { color: #F44336; }
871
+ .table { width: 100%; border-collapse: collapse; margin: 20px 0; }
872
+ .table th, .table td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
873
+ .table th { background: #f8f9fa; font-weight: bold; }
874
+ .footer { margin-top: 40px; padding: 20px; text-align: center; color: #666; border-top: 1px solid #eee; }
875
+ </style>
876
+ </head>
877
+ <body>
878
+ <div class="container">
879
+ <div class="header">
880
+ <h1>{{config.title}}</h1>
881
+ <p>{{testName}} - {{config.description}}</p>
882
+ <p>Generated: {{formatDate metadata.generated_at}}</p>
883
+ </div>
884
+
885
+ <div class="content">
886
+ <div class="summary">
887
+ <div class="metric">
888
+ <div class="metric-value">{{formatNumber summary.total_requests}}</div>
889
+ <div class="metric-label">Total Requests</div>
890
+ </div>
891
+ <div class="metric">
892
+ <div class="metric-value {{#if (gt summary.success_rate 90)}}success{{else}}error{{/if}}">{{round summary.success_rate}}%</div>
893
+ <div class="metric-label">Success Rate</div>
894
+ </div>
895
+ <div class="metric">
896
+ <div class="metric-value">{{round summary.avg_response_time}}ms</div>
897
+ <div class="metric-label">Avg Response Time</div>
898
+ </div>
899
+ <div class="metric">
900
+ <div class="metric-value">{{round summary.requests_per_second}}</div>
901
+ <div class="metric-label">Requests/Second</div>
902
+ </div>
903
+ </div>
904
+
905
+ {{#if errorAnalysis}}
906
+ <div class="chart-container">
907
+ <h3>Error Analysis</h3>
908
+ <p>Total Errors: <span class="error">{{formatNumber errorAnalysis.totalErrors}}</span> ({{errorAnalysis.errorRate}}%)</p>
909
+ {{#if errorAnalysis.topErrors}}
910
+ <table class="table">
911
+ <thead>
912
+ <tr>
913
+ <th>Status</th>
914
+ <th>Error Message</th>
915
+ <th>Count</th>
916
+ <th>Percentage</th>
917
+ </tr>
918
+ </thead>
919
+ <tbody>
920
+ {{#each errorAnalysis.topErrors}}
921
+ <tr>
922
+ <td>{{status}}</td>
923
+ <td>{{message}}</td>
924
+ <td>{{count}}</td>
925
+ <td>{{percentage}}%</td>
926
+ </tr>
927
+ {{/each}}
928
+ </tbody>
929
+ </table>
930
+ {{/if}}
931
+ </div>
932
+ {{/if}}
933
+
934
+ {{#if summary.percentiles}}
935
+ <div class="chart-container">
936
+ <h3>Response Time Percentiles</h3>
937
+ <table class="table">
938
+ <thead>
939
+ <tr>
940
+ <th>Percentile</th>
941
+ <th>Response Time (ms)</th>
942
+ </tr>
943
+ </thead>
944
+ <tbody>
945
+ {{#each summary.percentiles}}
946
+ <tr>
947
+ <td>P{{@key}}</td>
948
+ <td>{{round this}}</td>
949
+ </tr>
950
+ {{/each}}
951
+ </tbody>
952
+ </table>
953
+ </div>
954
+ {{/if}}
955
+ </div>
956
+
957
+ <div class="footer">
958
+ <p>{{metadata.generated_by}} - Test Duration: {{metadata.test_duration}}</p>
959
+ </div>
960
+ </div>
961
+ </body>
962
+ </html>`;
963
+ }
964
+ }
965
+ exports.EnhancedHTMLReportGenerator = EnhancedHTMLReportGenerator;