playwright-network-metrics 0.1.1
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/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/index.d.mts +133 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.js +692 -0
- package/dist/index.mjs +654 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
RESOURCE_TYPES: () => RESOURCE_TYPES,
|
|
34
|
+
default: () => index_default,
|
|
35
|
+
defineNetworkMetricsFixture: () => defineNetworkMetricsFixture
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(index_exports);
|
|
38
|
+
|
|
39
|
+
// src/constants.ts
|
|
40
|
+
var RESOURCE_TYPES = [
|
|
41
|
+
"fetch",
|
|
42
|
+
"xhr",
|
|
43
|
+
"document",
|
|
44
|
+
"script",
|
|
45
|
+
"style",
|
|
46
|
+
"image",
|
|
47
|
+
"font",
|
|
48
|
+
"other"
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// src/collector.ts
|
|
52
|
+
var import_micromatch = __toESM(require("micromatch"));
|
|
53
|
+
var NetworkMetricsCollector = class {
|
|
54
|
+
metrics = [];
|
|
55
|
+
config;
|
|
56
|
+
/**
|
|
57
|
+
* Initializes the collector with optional configuration.
|
|
58
|
+
* Merges user config with default values.
|
|
59
|
+
*
|
|
60
|
+
* @param config Partial configuration for filtering and redaction.
|
|
61
|
+
*/
|
|
62
|
+
constructor(config = {}) {
|
|
63
|
+
this.config = {
|
|
64
|
+
urlMatch: "**",
|
|
65
|
+
resourceTypes: RESOURCE_TYPES,
|
|
66
|
+
shouldTrackRequest: () => true,
|
|
67
|
+
redactQueryParams: [],
|
|
68
|
+
redactUrl: (url) => url,
|
|
69
|
+
routeGroupRules: [],
|
|
70
|
+
routeGroupFn: () => void 0,
|
|
71
|
+
...config
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Attaches the collector to a Playwright Page or BrowserContext.
|
|
76
|
+
* Begins listening for 'requestfinished' and 'requestfailed' events.
|
|
77
|
+
*
|
|
78
|
+
* @param target The Page or BrowserContext to instrument.
|
|
79
|
+
*/
|
|
80
|
+
async attach(target) {
|
|
81
|
+
target.on(
|
|
82
|
+
"requestfinished",
|
|
83
|
+
(request) => this.handleRequestFinished(request)
|
|
84
|
+
);
|
|
85
|
+
target.on("requestfailed", (request) => this.handleRequestFailed(request));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Internal handler for successful requests.
|
|
89
|
+
*/
|
|
90
|
+
async handleRequestFinished(request) {
|
|
91
|
+
if (!this.shouldTrack(request)) return;
|
|
92
|
+
const response = await request.response();
|
|
93
|
+
if (!response) return;
|
|
94
|
+
this.addMetric(request, response);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Internal handler for failed requests.
|
|
98
|
+
*/
|
|
99
|
+
async handleRequestFailed(request) {
|
|
100
|
+
if (!this.shouldTrack(request)) return;
|
|
101
|
+
this.addMetric(request, null, request.failure()?.errorText);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Determines if a request should be recorded based on urlMatch, resourceTypes, and custom hooks.
|
|
105
|
+
*/
|
|
106
|
+
shouldTrack(request) {
|
|
107
|
+
const url = request.url();
|
|
108
|
+
const resourceType = request.resourceType();
|
|
109
|
+
if (this.config.urlMatch && !import_micromatch.default.isMatch(url, this.config.urlMatch)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
if (!this.config.resourceTypes.includes(resourceType)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (!this.config.shouldTrackRequest(request)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Resolves the logical route group for a given URL based on configured rules.
|
|
122
|
+
*/
|
|
123
|
+
getRouteGroup(url) {
|
|
124
|
+
if (this.config.routeGroupFn) {
|
|
125
|
+
const group = this.config.routeGroupFn(url);
|
|
126
|
+
if (group) return group;
|
|
127
|
+
}
|
|
128
|
+
for (const rule of this.config.routeGroupRules) {
|
|
129
|
+
if (import_micromatch.default.isMatch(url, rule.match)) {
|
|
130
|
+
return rule.group;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return void 0;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Creates and stores a RequestMetric from a Playwright Request/Response.
|
|
137
|
+
*/
|
|
138
|
+
addMetric(request, response, errorText) {
|
|
139
|
+
const timing = request.timing();
|
|
140
|
+
const urlWithQuery = this.redactUrl(request.url());
|
|
141
|
+
const urlParts = new URL(urlWithQuery);
|
|
142
|
+
const url = `${urlParts.protocol}//${urlParts.host}${urlParts.pathname}`;
|
|
143
|
+
const duration = timing.responseEnd >= 0 && timing.responseStart >= 0 ? timing.responseEnd - timing.responseStart : -1;
|
|
144
|
+
if (duration < 0) {
|
|
145
|
+
console.warn(`Invalid duration for request ${url}: ${duration}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const metric = {
|
|
149
|
+
url,
|
|
150
|
+
urlWithQuery,
|
|
151
|
+
method: request.method(),
|
|
152
|
+
status: response?.status() ?? 0,
|
|
153
|
+
duration,
|
|
154
|
+
resourceType: request.resourceType(),
|
|
155
|
+
failed: !response || !response.ok(),
|
|
156
|
+
errorText,
|
|
157
|
+
timestamp: timing.startTime,
|
|
158
|
+
group: this.getRouteGroup(url)
|
|
159
|
+
};
|
|
160
|
+
this.metrics.push(metric);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Applies URL redaction based on config.redactUrl and config.redactQueryParams.
|
|
164
|
+
*/
|
|
165
|
+
redactUrl(url) {
|
|
166
|
+
let modifiedUrl = this.config.redactUrl(url);
|
|
167
|
+
if (this.config.redactQueryParams.length > 0) {
|
|
168
|
+
try {
|
|
169
|
+
const urlObj = new URL(modifiedUrl);
|
|
170
|
+
for (const param of this.config.redactQueryParams) {
|
|
171
|
+
if (urlObj.searchParams.has(param)) {
|
|
172
|
+
urlObj.searchParams.set(param, "[REDACTED]");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
modifiedUrl = urlObj.toString();
|
|
176
|
+
} catch (_e) {
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return modifiedUrl;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Returns the list of captured metrics.
|
|
183
|
+
*/
|
|
184
|
+
getMetrics() {
|
|
185
|
+
return this.metrics;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Clears all captured metrics from the internal store.
|
|
189
|
+
*/
|
|
190
|
+
clearMetrics() {
|
|
191
|
+
this.metrics = [];
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// src/fixture.ts
|
|
196
|
+
var defineNetworkMetricsFixture = async (config) => {
|
|
197
|
+
return [
|
|
198
|
+
async ({ page }, use, testInfo) => {
|
|
199
|
+
const collector = new NetworkMetricsCollector(config);
|
|
200
|
+
await collector.attach(page);
|
|
201
|
+
await use(collector);
|
|
202
|
+
const metrics = collector.getMetrics();
|
|
203
|
+
await testInfo.attach("network-metrics", {
|
|
204
|
+
body: JSON.stringify(metrics),
|
|
205
|
+
contentType: "application/json"
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
{ title: "networkMetrics", auto: true, box: true }
|
|
209
|
+
];
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// src/reporter.ts
|
|
213
|
+
var import_node_fs = __toESM(require("fs"));
|
|
214
|
+
var import_node_path = __toESM(require("path"));
|
|
215
|
+
|
|
216
|
+
// src/aggregator.ts
|
|
217
|
+
var NetworkMetricsAggregator = class {
|
|
218
|
+
/**
|
|
219
|
+
* Internal store for duration samples to calculate percentiles.
|
|
220
|
+
*/
|
|
221
|
+
samplesPerKey = /* @__PURE__ */ new Map();
|
|
222
|
+
/**
|
|
223
|
+
* Aggregates an array of individual request metrics into a comprehensive report.
|
|
224
|
+
*
|
|
225
|
+
* @param metrics Array of captured request metrics.
|
|
226
|
+
* @returns A structured report with global totals and categorized aggregations.
|
|
227
|
+
*/
|
|
228
|
+
aggregate(metrics) {
|
|
229
|
+
const endpointsNormalized = /* @__PURE__ */ new Map();
|
|
230
|
+
const endpointsExactWithQuery = /* @__PURE__ */ new Map();
|
|
231
|
+
const routeGroups = /* @__PURE__ */ new Map();
|
|
232
|
+
const resourceTypes = /* @__PURE__ */ new Map();
|
|
233
|
+
let totalDurationMs = 0;
|
|
234
|
+
let totalFailedRequests = 0;
|
|
235
|
+
for (const m of metrics) {
|
|
236
|
+
totalDurationMs += m.duration;
|
|
237
|
+
if (m.failed) totalFailedRequests++;
|
|
238
|
+
const normKey = `${m.method} ${m.url}`;
|
|
239
|
+
this.updateAggregate(endpointsNormalized, normKey, m);
|
|
240
|
+
const exactKey = `${m.method} ${m.urlWithQuery}`;
|
|
241
|
+
this.updateAggregate(endpointsExactWithQuery, exactKey, m);
|
|
242
|
+
const routeGroup = m.group || "Other";
|
|
243
|
+
const routeKey = `${m.method} ${routeGroup}`;
|
|
244
|
+
this.updateAggregate(routeGroups, routeKey, m);
|
|
245
|
+
this.updateAggregate(resourceTypes, m.resourceType, m);
|
|
246
|
+
}
|
|
247
|
+
const finalize = (map) => {
|
|
248
|
+
return Array.from(map.values()).map((am) => this.finalizeAggregatedMetric(am)).sort((a, b) => b.totalDurationMs - a.totalDurationMs);
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
totals: {
|
|
252
|
+
totalRequests: metrics.length,
|
|
253
|
+
totalFailedRequests,
|
|
254
|
+
totalDurationMs,
|
|
255
|
+
avgRequestDurationMs: metrics.length > 0 ? totalDurationMs / metrics.length : 0
|
|
256
|
+
},
|
|
257
|
+
endpointsNormalized: finalize(endpointsNormalized),
|
|
258
|
+
endpointsExactWithQuery: finalize(endpointsExactWithQuery),
|
|
259
|
+
routeGroups: finalize(routeGroups),
|
|
260
|
+
resourceTypes: finalize(resourceTypes)
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Updates a single aggregation entry with new request data.
|
|
265
|
+
*/
|
|
266
|
+
updateAggregate(map, key, m) {
|
|
267
|
+
let am = map.get(key);
|
|
268
|
+
if (!am) {
|
|
269
|
+
am = {
|
|
270
|
+
key,
|
|
271
|
+
method: m.method,
|
|
272
|
+
count: 0,
|
|
273
|
+
totalDurationMs: 0,
|
|
274
|
+
avgDurationMs: 0,
|
|
275
|
+
p50: 0,
|
|
276
|
+
p95: 0,
|
|
277
|
+
p99: 0,
|
|
278
|
+
errorCount: 0,
|
|
279
|
+
specs: [],
|
|
280
|
+
tests: []
|
|
281
|
+
};
|
|
282
|
+
map.set(key, am);
|
|
283
|
+
}
|
|
284
|
+
am.count++;
|
|
285
|
+
am.totalDurationMs += m.duration;
|
|
286
|
+
if (m.failed) am.errorCount++;
|
|
287
|
+
const samples = this.samplesPerKey.get(key) ?? [];
|
|
288
|
+
samples.push(m.duration);
|
|
289
|
+
this.samplesPerKey.set(key, samples);
|
|
290
|
+
if (m.specFile) {
|
|
291
|
+
this.updateList(am.specs, m.specFile);
|
|
292
|
+
}
|
|
293
|
+
if (m.testName) {
|
|
294
|
+
this.updateList(am.tests, m.testName);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Updates a frequency list for specs or tests.
|
|
299
|
+
*/
|
|
300
|
+
updateList(list, value) {
|
|
301
|
+
let entry = list.find((item) => item.name === value);
|
|
302
|
+
if (!entry) {
|
|
303
|
+
entry = { name: value, count: 1 };
|
|
304
|
+
list.push(entry);
|
|
305
|
+
} else {
|
|
306
|
+
entry.count++;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Finalizes the calculation of an aggregated metric (averages, percentiles, sorting).
|
|
311
|
+
*/
|
|
312
|
+
finalizeAggregatedMetric(am) {
|
|
313
|
+
am.avgDurationMs = am.count > 0 ? am.totalDurationMs / am.count : 0;
|
|
314
|
+
const samples = this.samplesPerKey.get(am.key) ?? [];
|
|
315
|
+
if (samples.length > 0) {
|
|
316
|
+
samples.sort((a, b) => a - b);
|
|
317
|
+
am.p50 = this.getPercentile(samples, 50);
|
|
318
|
+
am.p95 = this.getPercentile(samples, 95);
|
|
319
|
+
am.p99 = this.getPercentile(samples, 99);
|
|
320
|
+
}
|
|
321
|
+
am.specs.sort((a, b) => b.count - a.count);
|
|
322
|
+
am.tests.sort((a, b) => b.count - a.count);
|
|
323
|
+
return am;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Calculates a percentile value from a sorted array of samples.
|
|
327
|
+
*/
|
|
328
|
+
getPercentile(sortedSamples, percentile) {
|
|
329
|
+
const index = Math.ceil(percentile / 100 * sortedSamples.length) - 1;
|
|
330
|
+
return sortedSamples[Math.max(0, index)];
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// src/html-report.ts
|
|
335
|
+
function generateHtmlReport(report) {
|
|
336
|
+
const jsonReport = JSON.stringify(report);
|
|
337
|
+
return `<!DOCTYPE html>
|
|
338
|
+
<html lang="en">
|
|
339
|
+
<head>
|
|
340
|
+
<meta charset="UTF-8">
|
|
341
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
342
|
+
<title>Network Performance Metrics</title>
|
|
343
|
+
<style>
|
|
344
|
+
:root {
|
|
345
|
+
--primary: #2980b9;
|
|
346
|
+
--secondary: #34495e;
|
|
347
|
+
--success: #27ae60;
|
|
348
|
+
--error: #e74c3c;
|
|
349
|
+
--bg: #f4f7f9;
|
|
350
|
+
--card-bg: #ffffff;
|
|
351
|
+
--text: #2c3e50;
|
|
352
|
+
--text-muted: #7f8c8d;
|
|
353
|
+
--border: #e1e8ed;
|
|
354
|
+
}
|
|
355
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: var(--text); margin: 0; padding: 20px; background: var(--bg); }
|
|
356
|
+
.container { max-width: 1200px; margin: 0 auto; }
|
|
357
|
+
h1 { color: var(--secondary); margin-bottom: 30px; font-weight: 700; }
|
|
358
|
+
|
|
359
|
+
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }
|
|
360
|
+
.card { background: var(--card-bg); padding: 20px; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); border: 1px solid var(--border); transition: transform 0.2s; }
|
|
361
|
+
.card:hover { transform: translateY(-2px); }
|
|
362
|
+
.card h3 { margin: 0 0 10px 0; font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; }
|
|
363
|
+
.card .value { font-size: 1.8rem; font-weight: 800; color: var(--primary); }
|
|
364
|
+
.card .value.error { color: var(--error); }
|
|
365
|
+
|
|
366
|
+
.search-container { margin-bottom: 25px; position: relative; }
|
|
367
|
+
#search { width: 100%; padding: 12px 20px; border: 1px solid var(--border); border-radius: 8px; box-sizing: border-box; font-size: 1rem; box-shadow: 0 2px 4px rgba(0,0,0,0.02); }
|
|
368
|
+
#search:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(41, 128, 185, 0.1); }
|
|
369
|
+
|
|
370
|
+
.tabs { display: flex; gap: 8px; margin-bottom: 20px; overflow-x: auto; padding-bottom: 5px; }
|
|
371
|
+
.tab { cursor: pointer; padding: 10px 20px; border-radius: 8px; border: 1px solid var(--border); background: var(--card-bg); font-weight: 600; color: var(--text); transition: all 0.2s; white-space: nowrap; }
|
|
372
|
+
.tab:hover { background: #f8f9fa; }
|
|
373
|
+
.tab.active { background: var(--primary); color: white; border-color: var(--primary); }
|
|
374
|
+
|
|
375
|
+
.table-wrapper { background: var(--card-bg); border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.05); border: 1px solid var(--border); overflow: visible; }
|
|
376
|
+
table { width: 100%; border-collapse: separate; border-spacing: 0; text-align: left; }
|
|
377
|
+
th, td { padding: 14px 18px; border-bottom: 1px solid var(--border); }
|
|
378
|
+
th:first-child { border-top-left-radius: 12px; }
|
|
379
|
+
th:last-child { border-top-right-radius: 12px; }
|
|
380
|
+
tr:last-child td:first-child { border-bottom-left-radius: 12px; }
|
|
381
|
+
tr:last-child td:last-child { border-bottom-right-radius: 12px; }
|
|
382
|
+
th { background: #f8fafc; font-weight: 700; color: var(--secondary); font-size: 0.85rem; text-transform: uppercase; cursor: pointer; position: relative; }
|
|
383
|
+
th:hover { background: #f1f5f9; }
|
|
384
|
+
|
|
385
|
+
tr.main-row { cursor: pointer; transition: background 0.1s; }
|
|
386
|
+
tr.main-row:hover { background: #fcfdfe; }
|
|
387
|
+
tr.expanded { background: #f8fafc; }
|
|
388
|
+
|
|
389
|
+
.method { font-weight: 800; font-size: 0.7rem; padding: 3px 6px; border-radius: 4px; margin-right: 10px; display: inline-block; min-width: 45px; text-align: center; }
|
|
390
|
+
.GET { background: #e3f2fd; color: #1976d2; }
|
|
391
|
+
.POST { background: #e8f5e9; color: #388e3c; }
|
|
392
|
+
.PUT { background: #fff3e0; color: #f57c00; }
|
|
393
|
+
.DELETE { background: #ffebee; color: #d32f2f; }
|
|
394
|
+
|
|
395
|
+
.error-tag { color: var(--error); font-weight: 700; }
|
|
396
|
+
|
|
397
|
+
/* Details row */
|
|
398
|
+
.details-row { display: none; background: #f8fafc; }
|
|
399
|
+
.details-row.show { display: table-row; }
|
|
400
|
+
.details-content { padding: 20px; border-bottom: 1px solid var(--border); }
|
|
401
|
+
.details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
|
402
|
+
.details-section h4 { margin: 0 0 10px 0; font-size: 0.9rem; color: var(--secondary); border-bottom: 1px solid #dee5ed; padding-bottom: 5px; }
|
|
403
|
+
.details-list { margin: 0; padding: 0; list-style: none; font-size: 0.85rem; }
|
|
404
|
+
.details-list li { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dotted #e1e8ed; }
|
|
405
|
+
.details-list li:last-child { border-bottom: none; }
|
|
406
|
+
.count-badge { background: #edf2f7; padding: 2px 8px; border-radius: 10px; font-weight: 600; color: var(--secondary); }
|
|
407
|
+
|
|
408
|
+
/* Tooltips */
|
|
409
|
+
.tooltip { position: relative; display: inline-block; margin-left: 4px; cursor: help; color: #cbd5e0; }
|
|
410
|
+
.tooltip:hover { color: var(--primary); }
|
|
411
|
+
.tooltip .tooltiptext { visibility: hidden; width: 220px; background-color: #334155; color: #fff; text-align: center; border-radius: 6px; padding: 8px; position: absolute; z-index: 100; bottom: 125%; left: 50%; margin-left: -110px; opacity: 0; transition: opacity 0.2s; font-size: 0.75rem; text-transform: none; font-weight: 400; line-height: 1.4; pointer-events: none; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
|
412
|
+
.tooltip:hover .tooltiptext { visibility: visible; opacity: 1; }
|
|
413
|
+
|
|
414
|
+
.chevron { display: inline-block; transition: transform 0.2s; margin-right: 8px; width: 12px; height: 12px; fill: #cbd5e0; }
|
|
415
|
+
tr.expanded .chevron { transform: rotate(90deg); fill: var(--primary); }
|
|
416
|
+
</style>
|
|
417
|
+
</head>
|
|
418
|
+
<body>
|
|
419
|
+
<div class="container">
|
|
420
|
+
<h1>Network Performance Metrics</h1>
|
|
421
|
+
|
|
422
|
+
<div class="summary">
|
|
423
|
+
<div class="card">
|
|
424
|
+
<h3>Total Requests</h3>
|
|
425
|
+
<div class="value" id="stat-total-requests">-</div>
|
|
426
|
+
</div>
|
|
427
|
+
<div class="card">
|
|
428
|
+
<h3>Avg Duration</h3>
|
|
429
|
+
<div class="value" id="stat-avg-duration">-</div>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="card">
|
|
432
|
+
<h3>Total Duration</h3>
|
|
433
|
+
<div class="value" id="stat-total-duration">-</div>
|
|
434
|
+
</div>
|
|
435
|
+
<div class="card">
|
|
436
|
+
<h3>Failed Requests</h3>
|
|
437
|
+
<div class="value error" id="stat-failed">-</div>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
<div class="search-container">
|
|
442
|
+
<input type="text" id="search" placeholder="Filter by URL or method...">
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
<div class="tabs">
|
|
446
|
+
<div class="tab active" data-target="endpointsNormalized">Endpoints (Normalized)</div>
|
|
447
|
+
<div class="tab" data-target="endpointsExactWithQuery">Exact URLs</div>
|
|
448
|
+
<div class="tab" data-target="routeGroups">Route Groups</div>
|
|
449
|
+
<div class="tab" data-target="resourceTypes">Resource Types</div>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
<div class="table-wrapper">
|
|
453
|
+
<table id="metrics-table">
|
|
454
|
+
<thead>
|
|
455
|
+
<tr id="table-header"></tr>
|
|
456
|
+
</thead>
|
|
457
|
+
<tbody id="table-body"></tbody>
|
|
458
|
+
</table>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
<script>
|
|
463
|
+
const report = ${jsonReport};
|
|
464
|
+
let currentTab = 'endpointsNormalized';
|
|
465
|
+
let sortKey = 'totalDurationMs';
|
|
466
|
+
let sortOrder = -1;
|
|
467
|
+
let filterText = '';
|
|
468
|
+
let expandedKeys = new Set();
|
|
469
|
+
|
|
470
|
+
const columnMeta = {
|
|
471
|
+
key: { label: 'Endpoint / Key', tooltip: 'The unique identifier for this aggregation group.' },
|
|
472
|
+
count: { label: 'Count', tooltip: 'Total number of requests made.' },
|
|
473
|
+
avgDurationMs: { label: 'Avg', tooltip: 'The average response time in milliseconds.' },
|
|
474
|
+
p50: { label: 'P50', tooltip: 'Median: 50% of requests were faster than this value.' },
|
|
475
|
+
p95: { label: 'P95', tooltip: '95th Percentile: 95% of requests were faster than this value. Useful for identifying high-latency outliers.' },
|
|
476
|
+
p99: { label: 'P99', tooltip: '99th Percentile: Only 1% of requests were slower than this value. Critical for tail latency optimization.' },
|
|
477
|
+
totalDurationMs: { label: 'Total Time', tooltip: 'Sum of all response times for this group.' },
|
|
478
|
+
errorCount: { label: 'Errors', tooltip: 'Number of failed requests (non-2xx response or network error).' }
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
function formatMs(ms) {
|
|
482
|
+
return ms.toFixed(1) + 'ms';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function updateStats() {
|
|
486
|
+
document.getElementById('stat-total-requests').textContent = report.totals.totalRequests;
|
|
487
|
+
document.getElementById('stat-avg-duration').textContent = formatMs(report.totals.avgRequestDurationMs);
|
|
488
|
+
document.getElementById('stat-total-duration').textContent = (report.totals.totalDurationMs / 1000).toFixed(2) + 's';
|
|
489
|
+
document.getElementById('stat-failed').textContent = report.totals.totalFailedRequests;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function toggleExpand(key, event) {
|
|
493
|
+
event.stopPropagation();
|
|
494
|
+
if (expandedKeys.has(key)) {
|
|
495
|
+
expandedKeys.delete(key);
|
|
496
|
+
} else {
|
|
497
|
+
expandedKeys.add(key);
|
|
498
|
+
}
|
|
499
|
+
renderTable();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function renderTable() {
|
|
503
|
+
const data = report[currentTab].filter(item => {
|
|
504
|
+
const searchStr = (item.method ? item.method + ' ' : '') + item.key;
|
|
505
|
+
return searchStr.toLowerCase().includes(filterText.toLowerCase());
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
data.sort((a, b) => {
|
|
509
|
+
if (currentTab === 'routeGroups') {
|
|
510
|
+
const aIsOther = a.key.endsWith('Other');
|
|
511
|
+
const bIsOther = b.key.endsWith('Other');
|
|
512
|
+
if (aIsOther && !bIsOther) return 1;
|
|
513
|
+
if (!aIsOther && bIsOther) return -1;
|
|
514
|
+
}
|
|
515
|
+
const valA = a[sortKey];
|
|
516
|
+
const valB = b[sortKey];
|
|
517
|
+
if (typeof valA === 'string') return valA.localeCompare(valB) * sortOrder;
|
|
518
|
+
return (valA - valB) * sortOrder;
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const header = document.getElementById('table-header');
|
|
522
|
+
const body = document.getElementById('table-body');
|
|
523
|
+
|
|
524
|
+
const columns = ['key', 'count', 'avgDurationMs', 'p50', 'p95', 'p99', 'totalDurationMs', 'errorCount'];
|
|
525
|
+
|
|
526
|
+
header.innerHTML = columns.map(k => \`
|
|
527
|
+
<th onclick="handleSort('\${k}')">
|
|
528
|
+
\${columnMeta[k].label}
|
|
529
|
+
<span class="tooltip">\u24D8<span class="tooltiptext">\${columnMeta[k].tooltip}</span></span>
|
|
530
|
+
\${sortKey === k ? (sortOrder === 1 ? '\u25B2' : '\u25BC') : ''}
|
|
531
|
+
</th>
|
|
532
|
+
\`).join('');
|
|
533
|
+
|
|
534
|
+
let html = '';
|
|
535
|
+
data.forEach(item => {
|
|
536
|
+
const isExpanded = expandedKeys.has(item.key);
|
|
537
|
+
html += \`
|
|
538
|
+
<tr class="main-row \${isExpanded ? 'expanded' : ''}" onclick="toggleExpand('\${item.key}', event)">
|
|
539
|
+
<td>
|
|
540
|
+
<svg class="chevron" viewBox="0 0 20 20"><path d="M7 1L16 10L7 19" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
541
|
+
\${item.method ? \`<span class="method \${item.method}">\${item.method}</span>\` : ''}
|
|
542
|
+
\${item.key}
|
|
543
|
+
</td>
|
|
544
|
+
<td>\${item.count}</td>
|
|
545
|
+
<td>\${formatMs(item.avgDurationMs)}</td>
|
|
546
|
+
<td>\${formatMs(item.p50)}</td>
|
|
547
|
+
<td>\${formatMs(item.p95)}</td>
|
|
548
|
+
<td>\${formatMs(item.p99)}</td>
|
|
549
|
+
<td>\${formatMs(item.totalDurationMs)}</td>
|
|
550
|
+
<td class="\${item.errorCount > 0 ? 'error-tag' : ''}">\${item.errorCount}</td>
|
|
551
|
+
</tr>
|
|
552
|
+
\`;
|
|
553
|
+
|
|
554
|
+
if (isExpanded) {
|
|
555
|
+
html += \`
|
|
556
|
+
<tr class="details-row show">
|
|
557
|
+
<td colspan="8">
|
|
558
|
+
<div class="details-content">
|
|
559
|
+
<div class="details-grid">
|
|
560
|
+
<div class="details-section">
|
|
561
|
+
<h4>Contributing Spec Files</h4>
|
|
562
|
+
<ul class="details-list">
|
|
563
|
+
\${item.specs.map(s => \`<li><span>\${s.name}</span> <span class="count-badge">\${s.count}</span></li>\`).join('')}
|
|
564
|
+
</ul>
|
|
565
|
+
</div>
|
|
566
|
+
<div class="details-section">
|
|
567
|
+
<h4>Contributing Tests</h4>
|
|
568
|
+
<ul class="details-list">
|
|
569
|
+
\${item.tests.map(t => \`<li><span>\${t.name}</span> <span class="count-badge">\${t.count}</span></li>\`).join('')}
|
|
570
|
+
</ul>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
</td>
|
|
575
|
+
</tr>
|
|
576
|
+
\`;
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
body.innerHTML = html;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function handleSort(key) {
|
|
583
|
+
if (sortKey === key) sortOrder *= -1;
|
|
584
|
+
else { sortKey = key; sortOrder = -1; }
|
|
585
|
+
renderTable();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
document.querySelectorAll('.tab').forEach(tab => {
|
|
589
|
+
tab.addEventListener('click', () => {
|
|
590
|
+
document.querySelector('.tab.active').classList.remove('active');
|
|
591
|
+
tab.classList.add('active');
|
|
592
|
+
currentTab = tab.dataset.target;
|
|
593
|
+
expandedKeys.clear();
|
|
594
|
+
renderTable();
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
document.getElementById('search').addEventListener('input', (e) => {
|
|
599
|
+
filterText = e.target.value;
|
|
600
|
+
renderTable();
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
updateStats();
|
|
604
|
+
renderTable();
|
|
605
|
+
</script>
|
|
606
|
+
</body>
|
|
607
|
+
</html>`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/reporter.ts
|
|
611
|
+
var NetworkMetricsReporter = class {
|
|
612
|
+
/**
|
|
613
|
+
* Internal store for all collected request metrics from all tests.
|
|
614
|
+
*/
|
|
615
|
+
allMetrics = [];
|
|
616
|
+
config;
|
|
617
|
+
/**
|
|
618
|
+
* Initializes the reporter with configuration for output and format.
|
|
619
|
+
*
|
|
620
|
+
* @param config Configuration for output directory and HTML report generation.
|
|
621
|
+
*/
|
|
622
|
+
constructor(config = {}) {
|
|
623
|
+
this.config = {
|
|
624
|
+
outDir: "playwright-report/network-metrics",
|
|
625
|
+
html: false,
|
|
626
|
+
...config
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Playwright lifecycle hook called after each test finishes.
|
|
631
|
+
* Extracts "network-metrics" attachments and stores them for end-of-run aggregation.
|
|
632
|
+
*/
|
|
633
|
+
onTestEnd(test, result) {
|
|
634
|
+
const attachment = result.attachments.find(
|
|
635
|
+
(a) => a.name === "network-metrics"
|
|
636
|
+
);
|
|
637
|
+
if (attachment?.body) {
|
|
638
|
+
try {
|
|
639
|
+
const metrics = JSON.parse(attachment.body.toString());
|
|
640
|
+
if (Array.isArray(metrics)) {
|
|
641
|
+
this.allMetrics.push(
|
|
642
|
+
...metrics.map((item) => ({
|
|
643
|
+
...item,
|
|
644
|
+
specFile: test.location.file,
|
|
645
|
+
testName: test.title
|
|
646
|
+
}))
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
} catch (_e) {
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Playwright lifecycle hook called after all tests have finished.
|
|
655
|
+
* Aggregates all collected metrics and writes the final reports to disk.
|
|
656
|
+
*/
|
|
657
|
+
async onEnd() {
|
|
658
|
+
if (this.allMetrics.length === 0) {
|
|
659
|
+
console.log("No network metrics collected.");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const aggregator = new NetworkMetricsAggregator();
|
|
663
|
+
const report = aggregator.aggregate(this.allMetrics);
|
|
664
|
+
const outputDir = import_node_path.default.resolve(this.config.outDir);
|
|
665
|
+
if (!import_node_fs.default.existsSync(outputDir)) {
|
|
666
|
+
import_node_fs.default.mkdirSync(outputDir, { recursive: true });
|
|
667
|
+
}
|
|
668
|
+
const jsonPath = import_node_path.default.join(outputDir, "network-metrics.json");
|
|
669
|
+
import_node_fs.default.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
|
|
670
|
+
console.log(`Network metrics JSON report: ${jsonPath}`);
|
|
671
|
+
if (this.config.html) {
|
|
672
|
+
const htmlPath = import_node_path.default.join(outputDir, "network-metrics.html");
|
|
673
|
+
const htmlContent = this.generateHtml(report);
|
|
674
|
+
import_node_fs.default.writeFileSync(htmlPath, htmlContent);
|
|
675
|
+
console.log(`Network metrics HTML report: ${htmlPath}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Internal helper to trigger HTML report generation.
|
|
680
|
+
*/
|
|
681
|
+
generateHtml(report) {
|
|
682
|
+
return generateHtmlReport(report);
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// src/index.ts
|
|
687
|
+
var index_default = NetworkMetricsReporter;
|
|
688
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
689
|
+
0 && (module.exports = {
|
|
690
|
+
RESOURCE_TYPES,
|
|
691
|
+
defineNetworkMetricsFixture
|
|
692
|
+
});
|