@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.
- package/README.md +360 -0
- package/dist/cli/cli.d.ts +2 -0
- package/dist/cli/cli.js +192 -0
- package/dist/cli/commands/distributed.d.ts +11 -0
- package/dist/cli/commands/distributed.js +179 -0
- package/dist/cli/commands/import.d.ts +23 -0
- package/dist/cli/commands/import.js +461 -0
- package/dist/cli/commands/init.d.ts +7 -0
- package/dist/cli/commands/init.js +923 -0
- package/dist/cli/commands/mock.d.ts +7 -0
- package/dist/cli/commands/mock.js +281 -0
- package/dist/cli/commands/report.d.ts +5 -0
- package/dist/cli/commands/report.js +70 -0
- package/dist/cli/commands/run.d.ts +12 -0
- package/dist/cli/commands/run.js +260 -0
- package/dist/cli/commands/validate.d.ts +3 -0
- package/dist/cli/commands/validate.js +35 -0
- package/dist/cli/commands/worker.d.ts +27 -0
- package/dist/cli/commands/worker.js +320 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.js +20 -0
- package/dist/config/parser.d.ts +19 -0
- package/dist/config/parser.js +330 -0
- package/dist/config/types/global-config.d.ts +74 -0
- package/dist/config/types/global-config.js +2 -0
- package/dist/config/types/hooks.d.ts +58 -0
- package/dist/config/types/hooks.js +3 -0
- package/dist/config/types/import-types.d.ts +33 -0
- package/dist/config/types/import-types.js +2 -0
- package/dist/config/types/index.d.ts +11 -0
- package/dist/config/types/index.js +27 -0
- package/dist/config/types/load-config.d.ts +32 -0
- package/dist/config/types/load-config.js +9 -0
- package/dist/config/types/output-config.d.ts +10 -0
- package/dist/config/types/output-config.js +2 -0
- package/dist/config/types/report-config.d.ts +10 -0
- package/dist/config/types/report-config.js +2 -0
- package/dist/config/types/runtime-types.d.ts +6 -0
- package/dist/config/types/runtime-types.js +2 -0
- package/dist/config/types/scenario-config.d.ts +30 -0
- package/dist/config/types/scenario-config.js +2 -0
- package/dist/config/types/step-types.d.ts +139 -0
- package/dist/config/types/step-types.js +2 -0
- package/dist/config/types/test-configuration.d.ts +18 -0
- package/dist/config/types/test-configuration.js +2 -0
- package/dist/config/types/worker-config.d.ts +12 -0
- package/dist/config/types/worker-config.js +2 -0
- package/dist/config/validator.d.ts +19 -0
- package/dist/config/validator.js +198 -0
- package/dist/core/csv-data-provider.d.ts +47 -0
- package/dist/core/csv-data-provider.js +265 -0
- package/dist/core/hooks-manager.d.ts +33 -0
- package/dist/core/hooks-manager.js +129 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +11 -0
- package/dist/core/script-executor.d.ts +14 -0
- package/dist/core/script-executor.js +290 -0
- package/dist/core/step-executor.d.ts +41 -0
- package/dist/core/step-executor.js +680 -0
- package/dist/core/test-runner.d.ts +34 -0
- package/dist/core/test-runner.js +465 -0
- package/dist/core/threshold-evaluator.d.ts +43 -0
- package/dist/core/threshold-evaluator.js +170 -0
- package/dist/core/virtual-user-pool.d.ts +42 -0
- package/dist/core/virtual-user-pool.js +136 -0
- package/dist/core/virtual-user.d.ts +51 -0
- package/dist/core/virtual-user.js +488 -0
- package/dist/distributed/coordinator.d.ts +34 -0
- package/dist/distributed/coordinator.js +158 -0
- package/dist/distributed/health-monitor.d.ts +18 -0
- package/dist/distributed/health-monitor.js +72 -0
- package/dist/distributed/load-distributor.d.ts +17 -0
- package/dist/distributed/load-distributor.js +106 -0
- package/dist/distributed/remote-worker.d.ts +37 -0
- package/dist/distributed/remote-worker.js +241 -0
- package/dist/distributed/result-aggregator.d.ts +43 -0
- package/dist/distributed/result-aggregator.js +146 -0
- package/dist/dsl/index.d.ts +3 -0
- package/dist/dsl/index.js +11 -0
- package/dist/dsl/test-builder.d.ts +111 -0
- package/dist/dsl/test-builder.js +514 -0
- package/dist/importers/har-importer.d.ts +17 -0
- package/dist/importers/har-importer.js +172 -0
- package/dist/importers/open-api-importer.d.ts +23 -0
- package/dist/importers/open-api-importer.js +181 -0
- package/dist/importers/wsdl-importer.d.ts +42 -0
- package/dist/importers/wsdl-importer.js +440 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +17 -0
- package/dist/load-patterns/arrivals.d.ts +7 -0
- package/dist/load-patterns/arrivals.js +118 -0
- package/dist/load-patterns/base.d.ts +9 -0
- package/dist/load-patterns/base.js +2 -0
- package/dist/load-patterns/basic.d.ts +7 -0
- package/dist/load-patterns/basic.js +117 -0
- package/dist/load-patterns/stepping.d.ts +6 -0
- package/dist/load-patterns/stepping.js +122 -0
- package/dist/metrics/collector.d.ts +72 -0
- package/dist/metrics/collector.js +662 -0
- package/dist/metrics/types.d.ts +135 -0
- package/dist/metrics/types.js +2 -0
- package/dist/outputs/base.d.ts +7 -0
- package/dist/outputs/base.js +2 -0
- package/dist/outputs/csv.d.ts +13 -0
- package/dist/outputs/csv.js +163 -0
- package/dist/outputs/graphite.d.ts +13 -0
- package/dist/outputs/graphite.js +126 -0
- package/dist/outputs/influxdb.d.ts +12 -0
- package/dist/outputs/influxdb.js +82 -0
- package/dist/outputs/json.d.ts +14 -0
- package/dist/outputs/json.js +107 -0
- package/dist/outputs/streaming-csv.d.ts +37 -0
- package/dist/outputs/streaming-csv.js +254 -0
- package/dist/outputs/streaming-json.d.ts +43 -0
- package/dist/outputs/streaming-json.js +353 -0
- package/dist/outputs/webhook.d.ts +16 -0
- package/dist/outputs/webhook.js +96 -0
- package/dist/protocols/base.d.ts +33 -0
- package/dist/protocols/base.js +2 -0
- package/dist/protocols/rest/handler.d.ts +67 -0
- package/dist/protocols/rest/handler.js +776 -0
- package/dist/protocols/soap/handler.d.ts +12 -0
- package/dist/protocols/soap/handler.js +165 -0
- package/dist/protocols/web/core-web-vitals.d.ts +121 -0
- package/dist/protocols/web/core-web-vitals.js +373 -0
- package/dist/protocols/web/handler.d.ts +50 -0
- package/dist/protocols/web/handler.js +706 -0
- package/dist/recorder/native-recorder.d.ts +14 -0
- package/dist/recorder/native-recorder.js +533 -0
- package/dist/recorder/scenario-recorder.d.ts +55 -0
- package/dist/recorder/scenario-recorder.js +296 -0
- package/dist/reporting/constants.d.ts +94 -0
- package/dist/reporting/constants.js +82 -0
- package/dist/reporting/enhanced-html-generator.d.ts +55 -0
- package/dist/reporting/enhanced-html-generator.js +965 -0
- package/dist/reporting/generator.d.ts +42 -0
- package/dist/reporting/generator.js +1217 -0
- package/dist/reporting/statistics.d.ts +144 -0
- package/dist/reporting/statistics.js +742 -0
- package/dist/reporting/templates/enhanced-report.hbs +2812 -0
- package/dist/reporting/templates/html.hbs +2453 -0
- package/dist/utils/faker-manager.d.ts +55 -0
- package/dist/utils/faker-manager.js +166 -0
- package/dist/utils/file-manager.d.ts +33 -0
- package/dist/utils/file-manager.js +154 -0
- package/dist/utils/handlebars-manager.d.ts +42 -0
- package/dist/utils/handlebars-manager.js +172 -0
- package/dist/utils/logger.d.ts +16 -0
- package/dist/utils/logger.js +46 -0
- package/dist/utils/template.d.ts +80 -0
- package/dist/utils/template.js +513 -0
- package/dist/utils/test-output-writer.d.ts +56 -0
- package/dist/utils/test-output-writer.js +643 -0
- package/dist/utils/time.d.ts +3 -0
- package/dist/utils/time.js +23 -0
- package/dist/utils/timestamp-helper.d.ts +17 -0
- package/dist/utils/timestamp-helper.js +53 -0
- package/dist/workers/manager.d.ts +18 -0
- package/dist/workers/manager.js +95 -0
- package/dist/workers/server.d.ts +21 -0
- package/dist/workers/server.js +205 -0
- package/dist/workers/worker.d.ts +19 -0
- package/dist/workers/worker.js +147 -0
- 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;
|