playwright-network-metrics 0.1.1 → 0.2.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/dist/index.d.mts +4 -21
- package/dist/index.d.ts +4 -21
- package/dist/index.js +96 -84
- package/dist/index.mjs +96 -84
- package/package.json +3 -2
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Request, Fixtures } from '@playwright/test';
|
|
2
|
-
import { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
|
|
2
|
+
import { Reporter, FullConfig, TestCase, TestResult } from '@playwright/test/reporter';
|
|
3
3
|
|
|
4
4
|
declare const RESOURCE_TYPES: string[];
|
|
5
5
|
|
|
@@ -56,21 +56,6 @@ interface NetworkMetricsConfig {
|
|
|
56
56
|
*/
|
|
57
57
|
routeGroupFn?: (url: string) => string | undefined;
|
|
58
58
|
}
|
|
59
|
-
/**
|
|
60
|
-
* Configuration for the Playwright reporter plugin.
|
|
61
|
-
*/
|
|
62
|
-
interface NetworkMetricsReporterConfig {
|
|
63
|
-
/**
|
|
64
|
-
* Directory where the metrics reports (JSON/HTML) will be saved.
|
|
65
|
-
* @default "playwright-report/network-metrics"
|
|
66
|
-
*/
|
|
67
|
-
outDir?: string;
|
|
68
|
-
/**
|
|
69
|
-
* Whether to generate an interactive HTML report.
|
|
70
|
-
* @default false
|
|
71
|
-
*/
|
|
72
|
-
html?: boolean;
|
|
73
|
-
}
|
|
74
59
|
|
|
75
60
|
/**
|
|
76
61
|
* Returns a Playwright fixture tuple with automatic instrumentation.
|
|
@@ -107,13 +92,11 @@ declare class NetworkMetricsReporter implements Reporter {
|
|
|
107
92
|
* Internal store for all collected request metrics from all tests.
|
|
108
93
|
*/
|
|
109
94
|
private allMetrics;
|
|
110
|
-
private config;
|
|
111
95
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* @param config Configuration for output directory and HTML report generation.
|
|
96
|
+
* Configuration for the plugin.
|
|
115
97
|
*/
|
|
116
|
-
|
|
98
|
+
private config;
|
|
99
|
+
onBegin(config: FullConfig): void;
|
|
117
100
|
/**
|
|
118
101
|
* Playwright lifecycle hook called after each test finishes.
|
|
119
102
|
* Extracts "network-metrics" attachments and stores them for end-of-run aggregation.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Request, Fixtures } from '@playwright/test';
|
|
2
|
-
import { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
|
|
2
|
+
import { Reporter, FullConfig, TestCase, TestResult } from '@playwright/test/reporter';
|
|
3
3
|
|
|
4
4
|
declare const RESOURCE_TYPES: string[];
|
|
5
5
|
|
|
@@ -56,21 +56,6 @@ interface NetworkMetricsConfig {
|
|
|
56
56
|
*/
|
|
57
57
|
routeGroupFn?: (url: string) => string | undefined;
|
|
58
58
|
}
|
|
59
|
-
/**
|
|
60
|
-
* Configuration for the Playwright reporter plugin.
|
|
61
|
-
*/
|
|
62
|
-
interface NetworkMetricsReporterConfig {
|
|
63
|
-
/**
|
|
64
|
-
* Directory where the metrics reports (JSON/HTML) will be saved.
|
|
65
|
-
* @default "playwright-report/network-metrics"
|
|
66
|
-
*/
|
|
67
|
-
outDir?: string;
|
|
68
|
-
/**
|
|
69
|
-
* Whether to generate an interactive HTML report.
|
|
70
|
-
* @default false
|
|
71
|
-
*/
|
|
72
|
-
html?: boolean;
|
|
73
|
-
}
|
|
74
59
|
|
|
75
60
|
/**
|
|
76
61
|
* Returns a Playwright fixture tuple with automatic instrumentation.
|
|
@@ -107,13 +92,11 @@ declare class NetworkMetricsReporter implements Reporter {
|
|
|
107
92
|
* Internal store for all collected request metrics from all tests.
|
|
108
93
|
*/
|
|
109
94
|
private allMetrics;
|
|
110
|
-
private config;
|
|
111
95
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* @param config Configuration for output directory and HTML report generation.
|
|
96
|
+
* Configuration for the plugin.
|
|
115
97
|
*/
|
|
116
|
-
|
|
98
|
+
private config;
|
|
99
|
+
onBegin(config: FullConfig): void;
|
|
117
100
|
/**
|
|
118
101
|
* Playwright lifecycle hook called after each test finishes.
|
|
119
102
|
* Extracts "network-metrics" attachments and stores them for end-of-run aggregation.
|
package/dist/index.js
CHANGED
|
@@ -140,9 +140,9 @@ var NetworkMetricsCollector = class {
|
|
|
140
140
|
const urlWithQuery = this.redactUrl(request.url());
|
|
141
141
|
const urlParts = new URL(urlWithQuery);
|
|
142
142
|
const url = `${urlParts.protocol}//${urlParts.host}${urlParts.pathname}`;
|
|
143
|
-
const duration = timing.
|
|
143
|
+
const duration = timing.requestStart >= 0 && timing.responseStart >= 0 ? timing.responseStart - timing.requestStart : -1;
|
|
144
|
+
const loadTime = timing.responseEnd >= 0 && timing.responseStart >= 0 ? timing.responseEnd - timing.responseStart : -1;
|
|
144
145
|
if (duration < 0) {
|
|
145
|
-
console.warn(`Invalid duration for request ${url}: ${duration}`);
|
|
146
146
|
return;
|
|
147
147
|
}
|
|
148
148
|
const metric = {
|
|
@@ -151,6 +151,7 @@ var NetworkMetricsCollector = class {
|
|
|
151
151
|
method: request.method(),
|
|
152
152
|
status: response?.status() ?? 0,
|
|
153
153
|
duration,
|
|
154
|
+
loadTime,
|
|
154
155
|
resourceType: request.resourceType(),
|
|
155
156
|
failed: !response || !response.ok(),
|
|
156
157
|
errorText,
|
|
@@ -193,7 +194,7 @@ var NetworkMetricsCollector = class {
|
|
|
193
194
|
};
|
|
194
195
|
|
|
195
196
|
// src/fixture.ts
|
|
196
|
-
var defineNetworkMetricsFixture =
|
|
197
|
+
var defineNetworkMetricsFixture = (config) => {
|
|
197
198
|
return [
|
|
198
199
|
async ({ page }, use, testInfo) => {
|
|
199
200
|
const collector = new NetworkMetricsCollector(config);
|
|
@@ -271,7 +272,9 @@ var NetworkMetricsAggregator = class {
|
|
|
271
272
|
method: m.method,
|
|
272
273
|
count: 0,
|
|
273
274
|
totalDurationMs: 0,
|
|
275
|
+
totalLoadTimeMs: 0,
|
|
274
276
|
avgDurationMs: 0,
|
|
277
|
+
avgLoadTimeMs: 0,
|
|
275
278
|
p50: 0,
|
|
276
279
|
p95: 0,
|
|
277
280
|
p99: 0,
|
|
@@ -283,6 +286,9 @@ var NetworkMetricsAggregator = class {
|
|
|
283
286
|
}
|
|
284
287
|
am.count++;
|
|
285
288
|
am.totalDurationMs += m.duration;
|
|
289
|
+
if (m.loadTime >= 0) {
|
|
290
|
+
am.totalLoadTimeMs += m.loadTime;
|
|
291
|
+
}
|
|
286
292
|
if (m.failed) am.errorCount++;
|
|
287
293
|
const samples = this.samplesPerKey.get(key) ?? [];
|
|
288
294
|
samples.push(m.duration);
|
|
@@ -311,6 +317,7 @@ var NetworkMetricsAggregator = class {
|
|
|
311
317
|
*/
|
|
312
318
|
finalizeAggregatedMetric(am) {
|
|
313
319
|
am.avgDurationMs = am.count > 0 ? am.totalDurationMs / am.count : 0;
|
|
320
|
+
am.avgLoadTimeMs = am.count > 0 ? am.totalLoadTimeMs / am.count : 0;
|
|
314
321
|
const samples = this.samplesPerKey.get(am.key) ?? [];
|
|
315
322
|
if (samples.length > 0) {
|
|
316
323
|
samples.sort((a, b) => a - b);
|
|
@@ -352,8 +359,8 @@ function generateHtmlReport(report) {
|
|
|
352
359
|
--text-muted: #7f8c8d;
|
|
353
360
|
--border: #e1e8ed;
|
|
354
361
|
}
|
|
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 {
|
|
362
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: var(--text); margin: 0; padding: 20px 80px; background: var(--bg); }
|
|
363
|
+
.container { margin: 0 auto; }
|
|
357
364
|
h1 { color: var(--secondary); margin-bottom: 30px; font-weight: 700; }
|
|
358
365
|
|
|
359
366
|
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }
|
|
@@ -372,32 +379,24 @@ function generateHtmlReport(report) {
|
|
|
372
379
|
.tab:hover { background: #f8f9fa; }
|
|
373
380
|
.tab.active { background: var(--primary); color: white; border-color: var(--primary); }
|
|
374
381
|
|
|
375
|
-
.
|
|
376
|
-
table {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
382
|
+
.grid-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: hidden; }
|
|
383
|
+
.grid-table { overflow-x: auto; }
|
|
384
|
+
.grid-header, .grid-row { display: grid; grid-template-columns: minmax(300px, 4fr) 110px 100px 120px 100px 100px 100px 155px 120px; align-items: center; border-bottom: 1px solid var(--border); }
|
|
385
|
+
.grid-header { background: #f8fafc; font-weight: 700; color: var(--secondary); font-size: 0.85rem; text-transform: uppercase; width: fit-content; }
|
|
386
|
+
.grid-cell { padding: 14px 18px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
387
|
+
.grid-header .grid-cell { cursor: pointer; position: relative; display: flex; align-items: center; gap: 4px; }
|
|
388
|
+
.grid-header .grid-cell:hover { background: #f1f5f9; }
|
|
389
|
+
|
|
390
|
+
#grid-body { width: fit-content; }
|
|
384
391
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
392
|
+
.grid-row { cursor: pointer; transition: background 0.1s; background: white; }
|
|
393
|
+
.grid-row:hover { background: #fcfdfe; }
|
|
394
|
+
.grid-row.expanded { background: #f8fafc; border-bottom: none; }
|
|
388
395
|
|
|
389
|
-
.
|
|
390
|
-
.
|
|
391
|
-
.
|
|
392
|
-
.PUT { background: #fff3e0; color: #f57c00; }
|
|
393
|
-
.DELETE { background: #ffebee; color: #d32f2f; }
|
|
396
|
+
.details-pane { display: none; background: #f8fafc; border-bottom: 1px solid var(--border); }
|
|
397
|
+
.details-pane.show { display: block; }
|
|
398
|
+
.details-content { padding: 20px; }
|
|
394
399
|
|
|
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
400
|
.details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
|
402
401
|
.details-section h4 { margin: 0 0 10px 0; font-size: 0.9rem; color: var(--secondary); border-bottom: 1px solid #dee5ed; padding-bottom: 5px; }
|
|
403
402
|
.details-list { margin: 0; padding: 0; list-style: none; font-size: 0.85rem; }
|
|
@@ -405,14 +404,24 @@ function generateHtmlReport(report) {
|
|
|
405
404
|
.details-list li:last-child { border-bottom: none; }
|
|
406
405
|
.count-badge { background: #edf2f7; padding: 2px 8px; border-radius: 10px; font-weight: 600; color: var(--secondary); }
|
|
407
406
|
|
|
408
|
-
|
|
407
|
+
.endpoint-cell { display: flex; align-items: center; }
|
|
408
|
+
.endpoint-key { overflow: hidden; text-overflow: ellipsis; }
|
|
409
|
+
|
|
410
|
+
.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; vertical-align: middle; }
|
|
411
|
+
.GET { background: #e3f2fd; color: #1976d2; }
|
|
412
|
+
.POST { background: #e8f5e9; color: #388e3c; }
|
|
413
|
+
.PUT { background: #fff3e0; color: #f57c00; }
|
|
414
|
+
.DELETE { background: #ffebee; color: #d32f2f; }
|
|
415
|
+
|
|
416
|
+
.error-tag { color: var(--error); font-weight: 700; }
|
|
417
|
+
|
|
409
418
|
.tooltip { position: relative; display: inline-block; margin-left: 4px; cursor: help; color: #cbd5e0; }
|
|
410
419
|
.tooltip:hover { color: var(--primary); }
|
|
411
420
|
.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
421
|
.tooltip:hover .tooltiptext { visibility: visible; opacity: 1; }
|
|
413
422
|
|
|
414
423
|
.chevron { display: inline-block; transition: transform 0.2s; margin-right: 8px; width: 12px; height: 12px; fill: #cbd5e0; }
|
|
415
|
-
|
|
424
|
+
.grid-row.expanded .chevron { transform: rotate(90deg); fill: var(--primary); }
|
|
416
425
|
</style>
|
|
417
426
|
</head>
|
|
418
427
|
<body>
|
|
@@ -449,13 +458,11 @@ function generateHtmlReport(report) {
|
|
|
449
458
|
<div class="tab" data-target="resourceTypes">Resource Types</div>
|
|
450
459
|
</div>
|
|
451
460
|
|
|
452
|
-
<div class="
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
<tbody id="table-body"></tbody>
|
|
458
|
-
</table>
|
|
461
|
+
<div class="grid-wrapper">
|
|
462
|
+
<div class="grid-table" id="metrics-grid">
|
|
463
|
+
<div class="grid-header" id="grid-header"></div>
|
|
464
|
+
<div id="grid-body"></div>
|
|
465
|
+
</div>
|
|
459
466
|
</div>
|
|
460
467
|
</div>
|
|
461
468
|
|
|
@@ -470,7 +477,8 @@ function generateHtmlReport(report) {
|
|
|
470
477
|
const columnMeta = {
|
|
471
478
|
key: { label: 'Endpoint / Key', tooltip: 'The unique identifier for this aggregation group.' },
|
|
472
479
|
count: { label: 'Count', tooltip: 'Total number of requests made.' },
|
|
473
|
-
avgDurationMs: { label: 'Avg', tooltip: 'The average response time in milliseconds.' },
|
|
480
|
+
avgDurationMs: { label: 'Avg', tooltip: 'The average response time in milliseconds. This represents the total time from request start to the end of the response body.' },
|
|
481
|
+
avgLoadTimeMs: { label: 'Load', tooltip: 'Resource Load Time: The time taken to download the resource body (responseEnd - responseStart).' },
|
|
474
482
|
p50: { label: 'P50', tooltip: 'Median: 50% of requests were faster than this value.' },
|
|
475
483
|
p95: { label: 'P95', tooltip: '95th Percentile: 95% of requests were faster than this value. Useful for identifying high-latency outliers.' },
|
|
476
484
|
p99: { label: 'P99', tooltip: '99th Percentile: Only 1% of requests were slower than this value. Critical for tail latency optimization.' },
|
|
@@ -518,61 +526,60 @@ function generateHtmlReport(report) {
|
|
|
518
526
|
return (valA - valB) * sortOrder;
|
|
519
527
|
});
|
|
520
528
|
|
|
521
|
-
const header = document.getElementById('
|
|
522
|
-
const body = document.getElementById('
|
|
529
|
+
const header = document.getElementById('grid-header');
|
|
530
|
+
const body = document.getElementById('grid-body');
|
|
523
531
|
|
|
524
|
-
const columns = ['key', 'count', 'avgDurationMs', 'p50', 'p95', 'p99', 'totalDurationMs', 'errorCount'];
|
|
532
|
+
const columns = ['key', 'count', 'avgDurationMs', 'avgLoadTimeMs', 'p50', 'p95', 'p99', 'totalDurationMs', 'errorCount'];
|
|
525
533
|
|
|
526
534
|
header.innerHTML = columns.map(k => \`
|
|
527
|
-
<
|
|
535
|
+
<div class="grid-cell" onclick="handleSort('\${k}')">
|
|
528
536
|
\${columnMeta[k].label}
|
|
529
|
-
<span class="tooltip"
|
|
537
|
+
<span class="tooltip" title="\${columnMeta[k].tooltip}">\u24D8</span>
|
|
530
538
|
\${sortKey === k ? (sortOrder === 1 ? '\u25B2' : '\u25BC') : ''}
|
|
531
|
-
</
|
|
539
|
+
</div>
|
|
532
540
|
\`).join('');
|
|
533
541
|
|
|
534
542
|
let html = '';
|
|
535
543
|
data.forEach(item => {
|
|
536
544
|
const isExpanded = expandedKeys.has(item.key);
|
|
537
545
|
html += \`
|
|
538
|
-
<
|
|
539
|
-
<
|
|
546
|
+
<div class="grid-row \${isExpanded ? 'expanded' : ''}" onclick="toggleExpand('\${item.key}', event)">
|
|
547
|
+
<div class="grid-cell endpoint-cell">
|
|
540
548
|
<svg class="chevron" viewBox="0 0 20 20"><path d="M7 1L16 10L7 19" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
541
549
|
\${item.method ? \`<span class="method \${item.method}">\${item.method}</span>\` : ''}
|
|
542
|
-
\${item.key}
|
|
543
|
-
</
|
|
544
|
-
<
|
|
545
|
-
<
|
|
546
|
-
<
|
|
547
|
-
<
|
|
548
|
-
<
|
|
549
|
-
<
|
|
550
|
-
<
|
|
551
|
-
|
|
550
|
+
<span class="endpoint-key" title="\${item.key}">\${item.method ? item.key.replace(new RegExp(\`^\${item.method}\\\\s+\`), '') : item.key}</span>
|
|
551
|
+
</div>
|
|
552
|
+
<div class="grid-cell">\${item.count}</div>
|
|
553
|
+
<div class="grid-cell">\${formatMs(item.avgDurationMs)}</div>
|
|
554
|
+
<div class="grid-cell">\${formatMs(item.avgLoadTimeMs)}</div>
|
|
555
|
+
<div class="grid-cell">\${formatMs(item.p50)}</div>
|
|
556
|
+
<div class="grid-cell">\${formatMs(item.p95)}</div>
|
|
557
|
+
<div class="grid-cell">\${formatMs(item.p99)}</div>
|
|
558
|
+
<div class="grid-cell">\${formatMs(item.totalDurationMs)}</div>
|
|
559
|
+
<div class="grid-cell \${item.errorCount > 0 ? 'error-tag' : ''}">\${item.errorCount}</div>
|
|
560
|
+
</div>
|
|
552
561
|
\`;
|
|
553
562
|
|
|
554
563
|
if (isExpanded) {
|
|
555
564
|
html += \`
|
|
556
|
-
<
|
|
557
|
-
<
|
|
558
|
-
<div class="details-
|
|
559
|
-
<div class="details-
|
|
560
|
-
<
|
|
561
|
-
|
|
562
|
-
<
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
<
|
|
567
|
-
|
|
568
|
-
<
|
|
569
|
-
|
|
570
|
-
</ul>
|
|
571
|
-
</div>
|
|
565
|
+
<div class="details-pane show">
|
|
566
|
+
<div class="details-content">
|
|
567
|
+
<div class="details-grid">
|
|
568
|
+
<div class="details-section">
|
|
569
|
+
<h4>Contributing Spec Files</h4>
|
|
570
|
+
<ul class="details-list">
|
|
571
|
+
\${item.specs.map(s => \`<li><span>\${s.name}</span> <span class="count-badge">\${s.count}</span></li>\`).join('')}
|
|
572
|
+
</ul>
|
|
573
|
+
</div>
|
|
574
|
+
<div class="details-section">
|
|
575
|
+
<h4>Contributing Tests</h4>
|
|
576
|
+
<ul class="details-list">
|
|
577
|
+
\${item.tests.map(t => \`<li><span>\${t.name}</span> <span class="count-badge">\${t.count}</span></li>\`).join('')}
|
|
578
|
+
</ul>
|
|
572
579
|
</div>
|
|
573
580
|
</div>
|
|
574
|
-
</
|
|
575
|
-
</
|
|
581
|
+
</div>
|
|
582
|
+
</div>
|
|
576
583
|
\`;
|
|
577
584
|
}
|
|
578
585
|
});
|
|
@@ -613,18 +620,23 @@ var NetworkMetricsReporter = class {
|
|
|
613
620
|
* Internal store for all collected request metrics from all tests.
|
|
614
621
|
*/
|
|
615
622
|
allMetrics = [];
|
|
616
|
-
config;
|
|
617
623
|
/**
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
* @param config Configuration for output directory and HTML report generation.
|
|
624
|
+
* Configuration for the plugin.
|
|
621
625
|
*/
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
626
|
+
config = {
|
|
627
|
+
outDir: "playwright-report/network-metrics",
|
|
628
|
+
html: true
|
|
629
|
+
};
|
|
630
|
+
onBegin(config) {
|
|
631
|
+
const reporterEntry = config.reporter.find(
|
|
632
|
+
([name]) => name === "playwright-network-metrics"
|
|
633
|
+
);
|
|
634
|
+
if (reporterEntry && typeof reporterEntry[1] === "object") {
|
|
635
|
+
this.config = {
|
|
636
|
+
...this.config,
|
|
637
|
+
...reporterEntry[1]
|
|
638
|
+
};
|
|
639
|
+
}
|
|
628
640
|
}
|
|
629
641
|
/**
|
|
630
642
|
* Playwright lifecycle hook called after each test finishes.
|
package/dist/index.mjs
CHANGED
|
@@ -102,9 +102,9 @@ var NetworkMetricsCollector = class {
|
|
|
102
102
|
const urlWithQuery = this.redactUrl(request.url());
|
|
103
103
|
const urlParts = new URL(urlWithQuery);
|
|
104
104
|
const url = `${urlParts.protocol}//${urlParts.host}${urlParts.pathname}`;
|
|
105
|
-
const duration = timing.
|
|
105
|
+
const duration = timing.requestStart >= 0 && timing.responseStart >= 0 ? timing.responseStart - timing.requestStart : -1;
|
|
106
|
+
const loadTime = timing.responseEnd >= 0 && timing.responseStart >= 0 ? timing.responseEnd - timing.responseStart : -1;
|
|
106
107
|
if (duration < 0) {
|
|
107
|
-
console.warn(`Invalid duration for request ${url}: ${duration}`);
|
|
108
108
|
return;
|
|
109
109
|
}
|
|
110
110
|
const metric = {
|
|
@@ -113,6 +113,7 @@ var NetworkMetricsCollector = class {
|
|
|
113
113
|
method: request.method(),
|
|
114
114
|
status: response?.status() ?? 0,
|
|
115
115
|
duration,
|
|
116
|
+
loadTime,
|
|
116
117
|
resourceType: request.resourceType(),
|
|
117
118
|
failed: !response || !response.ok(),
|
|
118
119
|
errorText,
|
|
@@ -155,7 +156,7 @@ var NetworkMetricsCollector = class {
|
|
|
155
156
|
};
|
|
156
157
|
|
|
157
158
|
// src/fixture.ts
|
|
158
|
-
var defineNetworkMetricsFixture =
|
|
159
|
+
var defineNetworkMetricsFixture = (config) => {
|
|
159
160
|
return [
|
|
160
161
|
async ({ page }, use, testInfo) => {
|
|
161
162
|
const collector = new NetworkMetricsCollector(config);
|
|
@@ -233,7 +234,9 @@ var NetworkMetricsAggregator = class {
|
|
|
233
234
|
method: m.method,
|
|
234
235
|
count: 0,
|
|
235
236
|
totalDurationMs: 0,
|
|
237
|
+
totalLoadTimeMs: 0,
|
|
236
238
|
avgDurationMs: 0,
|
|
239
|
+
avgLoadTimeMs: 0,
|
|
237
240
|
p50: 0,
|
|
238
241
|
p95: 0,
|
|
239
242
|
p99: 0,
|
|
@@ -245,6 +248,9 @@ var NetworkMetricsAggregator = class {
|
|
|
245
248
|
}
|
|
246
249
|
am.count++;
|
|
247
250
|
am.totalDurationMs += m.duration;
|
|
251
|
+
if (m.loadTime >= 0) {
|
|
252
|
+
am.totalLoadTimeMs += m.loadTime;
|
|
253
|
+
}
|
|
248
254
|
if (m.failed) am.errorCount++;
|
|
249
255
|
const samples = this.samplesPerKey.get(key) ?? [];
|
|
250
256
|
samples.push(m.duration);
|
|
@@ -273,6 +279,7 @@ var NetworkMetricsAggregator = class {
|
|
|
273
279
|
*/
|
|
274
280
|
finalizeAggregatedMetric(am) {
|
|
275
281
|
am.avgDurationMs = am.count > 0 ? am.totalDurationMs / am.count : 0;
|
|
282
|
+
am.avgLoadTimeMs = am.count > 0 ? am.totalLoadTimeMs / am.count : 0;
|
|
276
283
|
const samples = this.samplesPerKey.get(am.key) ?? [];
|
|
277
284
|
if (samples.length > 0) {
|
|
278
285
|
samples.sort((a, b) => a - b);
|
|
@@ -314,8 +321,8 @@ function generateHtmlReport(report) {
|
|
|
314
321
|
--text-muted: #7f8c8d;
|
|
315
322
|
--border: #e1e8ed;
|
|
316
323
|
}
|
|
317
|
-
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); }
|
|
318
|
-
.container {
|
|
324
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: var(--text); margin: 0; padding: 20px 80px; background: var(--bg); }
|
|
325
|
+
.container { margin: 0 auto; }
|
|
319
326
|
h1 { color: var(--secondary); margin-bottom: 30px; font-weight: 700; }
|
|
320
327
|
|
|
321
328
|
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }
|
|
@@ -334,32 +341,24 @@ function generateHtmlReport(report) {
|
|
|
334
341
|
.tab:hover { background: #f8f9fa; }
|
|
335
342
|
.tab.active { background: var(--primary); color: white; border-color: var(--primary); }
|
|
336
343
|
|
|
337
|
-
.
|
|
338
|
-
table {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
.grid-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: hidden; }
|
|
345
|
+
.grid-table { overflow-x: auto; }
|
|
346
|
+
.grid-header, .grid-row { display: grid; grid-template-columns: minmax(300px, 4fr) 110px 100px 120px 100px 100px 100px 155px 120px; align-items: center; border-bottom: 1px solid var(--border); }
|
|
347
|
+
.grid-header { background: #f8fafc; font-weight: 700; color: var(--secondary); font-size: 0.85rem; text-transform: uppercase; width: fit-content; }
|
|
348
|
+
.grid-cell { padding: 14px 18px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
349
|
+
.grid-header .grid-cell { cursor: pointer; position: relative; display: flex; align-items: center; gap: 4px; }
|
|
350
|
+
.grid-header .grid-cell:hover { background: #f1f5f9; }
|
|
351
|
+
|
|
352
|
+
#grid-body { width: fit-content; }
|
|
346
353
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
354
|
+
.grid-row { cursor: pointer; transition: background 0.1s; background: white; }
|
|
355
|
+
.grid-row:hover { background: #fcfdfe; }
|
|
356
|
+
.grid-row.expanded { background: #f8fafc; border-bottom: none; }
|
|
350
357
|
|
|
351
|
-
.
|
|
352
|
-
.
|
|
353
|
-
.
|
|
354
|
-
.PUT { background: #fff3e0; color: #f57c00; }
|
|
355
|
-
.DELETE { background: #ffebee; color: #d32f2f; }
|
|
358
|
+
.details-pane { display: none; background: #f8fafc; border-bottom: 1px solid var(--border); }
|
|
359
|
+
.details-pane.show { display: block; }
|
|
360
|
+
.details-content { padding: 20px; }
|
|
356
361
|
|
|
357
|
-
.error-tag { color: var(--error); font-weight: 700; }
|
|
358
|
-
|
|
359
|
-
/* Details row */
|
|
360
|
-
.details-row { display: none; background: #f8fafc; }
|
|
361
|
-
.details-row.show { display: table-row; }
|
|
362
|
-
.details-content { padding: 20px; border-bottom: 1px solid var(--border); }
|
|
363
362
|
.details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
|
364
363
|
.details-section h4 { margin: 0 0 10px 0; font-size: 0.9rem; color: var(--secondary); border-bottom: 1px solid #dee5ed; padding-bottom: 5px; }
|
|
365
364
|
.details-list { margin: 0; padding: 0; list-style: none; font-size: 0.85rem; }
|
|
@@ -367,14 +366,24 @@ function generateHtmlReport(report) {
|
|
|
367
366
|
.details-list li:last-child { border-bottom: none; }
|
|
368
367
|
.count-badge { background: #edf2f7; padding: 2px 8px; border-radius: 10px; font-weight: 600; color: var(--secondary); }
|
|
369
368
|
|
|
370
|
-
|
|
369
|
+
.endpoint-cell { display: flex; align-items: center; }
|
|
370
|
+
.endpoint-key { overflow: hidden; text-overflow: ellipsis; }
|
|
371
|
+
|
|
372
|
+
.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; vertical-align: middle; }
|
|
373
|
+
.GET { background: #e3f2fd; color: #1976d2; }
|
|
374
|
+
.POST { background: #e8f5e9; color: #388e3c; }
|
|
375
|
+
.PUT { background: #fff3e0; color: #f57c00; }
|
|
376
|
+
.DELETE { background: #ffebee; color: #d32f2f; }
|
|
377
|
+
|
|
378
|
+
.error-tag { color: var(--error); font-weight: 700; }
|
|
379
|
+
|
|
371
380
|
.tooltip { position: relative; display: inline-block; margin-left: 4px; cursor: help; color: #cbd5e0; }
|
|
372
381
|
.tooltip:hover { color: var(--primary); }
|
|
373
382
|
.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); }
|
|
374
383
|
.tooltip:hover .tooltiptext { visibility: visible; opacity: 1; }
|
|
375
384
|
|
|
376
385
|
.chevron { display: inline-block; transition: transform 0.2s; margin-right: 8px; width: 12px; height: 12px; fill: #cbd5e0; }
|
|
377
|
-
|
|
386
|
+
.grid-row.expanded .chevron { transform: rotate(90deg); fill: var(--primary); }
|
|
378
387
|
</style>
|
|
379
388
|
</head>
|
|
380
389
|
<body>
|
|
@@ -411,13 +420,11 @@ function generateHtmlReport(report) {
|
|
|
411
420
|
<div class="tab" data-target="resourceTypes">Resource Types</div>
|
|
412
421
|
</div>
|
|
413
422
|
|
|
414
|
-
<div class="
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
<tbody id="table-body"></tbody>
|
|
420
|
-
</table>
|
|
423
|
+
<div class="grid-wrapper">
|
|
424
|
+
<div class="grid-table" id="metrics-grid">
|
|
425
|
+
<div class="grid-header" id="grid-header"></div>
|
|
426
|
+
<div id="grid-body"></div>
|
|
427
|
+
</div>
|
|
421
428
|
</div>
|
|
422
429
|
</div>
|
|
423
430
|
|
|
@@ -432,7 +439,8 @@ function generateHtmlReport(report) {
|
|
|
432
439
|
const columnMeta = {
|
|
433
440
|
key: { label: 'Endpoint / Key', tooltip: 'The unique identifier for this aggregation group.' },
|
|
434
441
|
count: { label: 'Count', tooltip: 'Total number of requests made.' },
|
|
435
|
-
avgDurationMs: { label: 'Avg', tooltip: 'The average response time in milliseconds.' },
|
|
442
|
+
avgDurationMs: { label: 'Avg', tooltip: 'The average response time in milliseconds. This represents the total time from request start to the end of the response body.' },
|
|
443
|
+
avgLoadTimeMs: { label: 'Load', tooltip: 'Resource Load Time: The time taken to download the resource body (responseEnd - responseStart).' },
|
|
436
444
|
p50: { label: 'P50', tooltip: 'Median: 50% of requests were faster than this value.' },
|
|
437
445
|
p95: { label: 'P95', tooltip: '95th Percentile: 95% of requests were faster than this value. Useful for identifying high-latency outliers.' },
|
|
438
446
|
p99: { label: 'P99', tooltip: '99th Percentile: Only 1% of requests were slower than this value. Critical for tail latency optimization.' },
|
|
@@ -480,61 +488,60 @@ function generateHtmlReport(report) {
|
|
|
480
488
|
return (valA - valB) * sortOrder;
|
|
481
489
|
});
|
|
482
490
|
|
|
483
|
-
const header = document.getElementById('
|
|
484
|
-
const body = document.getElementById('
|
|
491
|
+
const header = document.getElementById('grid-header');
|
|
492
|
+
const body = document.getElementById('grid-body');
|
|
485
493
|
|
|
486
|
-
const columns = ['key', 'count', 'avgDurationMs', 'p50', 'p95', 'p99', 'totalDurationMs', 'errorCount'];
|
|
494
|
+
const columns = ['key', 'count', 'avgDurationMs', 'avgLoadTimeMs', 'p50', 'p95', 'p99', 'totalDurationMs', 'errorCount'];
|
|
487
495
|
|
|
488
496
|
header.innerHTML = columns.map(k => \`
|
|
489
|
-
<
|
|
497
|
+
<div class="grid-cell" onclick="handleSort('\${k}')">
|
|
490
498
|
\${columnMeta[k].label}
|
|
491
|
-
<span class="tooltip"
|
|
499
|
+
<span class="tooltip" title="\${columnMeta[k].tooltip}">\u24D8</span>
|
|
492
500
|
\${sortKey === k ? (sortOrder === 1 ? '\u25B2' : '\u25BC') : ''}
|
|
493
|
-
</
|
|
501
|
+
</div>
|
|
494
502
|
\`).join('');
|
|
495
503
|
|
|
496
504
|
let html = '';
|
|
497
505
|
data.forEach(item => {
|
|
498
506
|
const isExpanded = expandedKeys.has(item.key);
|
|
499
507
|
html += \`
|
|
500
|
-
<
|
|
501
|
-
<
|
|
508
|
+
<div class="grid-row \${isExpanded ? 'expanded' : ''}" onclick="toggleExpand('\${item.key}', event)">
|
|
509
|
+
<div class="grid-cell endpoint-cell">
|
|
502
510
|
<svg class="chevron" viewBox="0 0 20 20"><path d="M7 1L16 10L7 19" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
503
511
|
\${item.method ? \`<span class="method \${item.method}">\${item.method}</span>\` : ''}
|
|
504
|
-
\${item.key}
|
|
505
|
-
</
|
|
506
|
-
<
|
|
507
|
-
<
|
|
508
|
-
<
|
|
509
|
-
<
|
|
510
|
-
<
|
|
511
|
-
<
|
|
512
|
-
<
|
|
513
|
-
|
|
512
|
+
<span class="endpoint-key" title="\${item.key}">\${item.method ? item.key.replace(new RegExp(\`^\${item.method}\\\\s+\`), '') : item.key}</span>
|
|
513
|
+
</div>
|
|
514
|
+
<div class="grid-cell">\${item.count}</div>
|
|
515
|
+
<div class="grid-cell">\${formatMs(item.avgDurationMs)}</div>
|
|
516
|
+
<div class="grid-cell">\${formatMs(item.avgLoadTimeMs)}</div>
|
|
517
|
+
<div class="grid-cell">\${formatMs(item.p50)}</div>
|
|
518
|
+
<div class="grid-cell">\${formatMs(item.p95)}</div>
|
|
519
|
+
<div class="grid-cell">\${formatMs(item.p99)}</div>
|
|
520
|
+
<div class="grid-cell">\${formatMs(item.totalDurationMs)}</div>
|
|
521
|
+
<div class="grid-cell \${item.errorCount > 0 ? 'error-tag' : ''}">\${item.errorCount}</div>
|
|
522
|
+
</div>
|
|
514
523
|
\`;
|
|
515
524
|
|
|
516
525
|
if (isExpanded) {
|
|
517
526
|
html += \`
|
|
518
|
-
<
|
|
519
|
-
<
|
|
520
|
-
<div class="details-
|
|
521
|
-
<div class="details-
|
|
522
|
-
<
|
|
523
|
-
|
|
524
|
-
<
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
<
|
|
529
|
-
|
|
530
|
-
<
|
|
531
|
-
|
|
532
|
-
</ul>
|
|
533
|
-
</div>
|
|
527
|
+
<div class="details-pane show">
|
|
528
|
+
<div class="details-content">
|
|
529
|
+
<div class="details-grid">
|
|
530
|
+
<div class="details-section">
|
|
531
|
+
<h4>Contributing Spec Files</h4>
|
|
532
|
+
<ul class="details-list">
|
|
533
|
+
\${item.specs.map(s => \`<li><span>\${s.name}</span> <span class="count-badge">\${s.count}</span></li>\`).join('')}
|
|
534
|
+
</ul>
|
|
535
|
+
</div>
|
|
536
|
+
<div class="details-section">
|
|
537
|
+
<h4>Contributing Tests</h4>
|
|
538
|
+
<ul class="details-list">
|
|
539
|
+
\${item.tests.map(t => \`<li><span>\${t.name}</span> <span class="count-badge">\${t.count}</span></li>\`).join('')}
|
|
540
|
+
</ul>
|
|
534
541
|
</div>
|
|
535
542
|
</div>
|
|
536
|
-
</
|
|
537
|
-
</
|
|
543
|
+
</div>
|
|
544
|
+
</div>
|
|
538
545
|
\`;
|
|
539
546
|
}
|
|
540
547
|
});
|
|
@@ -575,18 +582,23 @@ var NetworkMetricsReporter = class {
|
|
|
575
582
|
* Internal store for all collected request metrics from all tests.
|
|
576
583
|
*/
|
|
577
584
|
allMetrics = [];
|
|
578
|
-
config;
|
|
579
585
|
/**
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
* @param config Configuration for output directory and HTML report generation.
|
|
586
|
+
* Configuration for the plugin.
|
|
583
587
|
*/
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
588
|
+
config = {
|
|
589
|
+
outDir: "playwright-report/network-metrics",
|
|
590
|
+
html: true
|
|
591
|
+
};
|
|
592
|
+
onBegin(config) {
|
|
593
|
+
const reporterEntry = config.reporter.find(
|
|
594
|
+
([name]) => name === "playwright-network-metrics"
|
|
595
|
+
);
|
|
596
|
+
if (reporterEntry && typeof reporterEntry[1] === "object") {
|
|
597
|
+
this.config = {
|
|
598
|
+
...this.config,
|
|
599
|
+
...reporterEntry[1]
|
|
600
|
+
};
|
|
601
|
+
}
|
|
590
602
|
}
|
|
591
603
|
/**
|
|
592
604
|
* Playwright lifecycle hook called after each test finishes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "playwright-network-metrics",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Capture and aggregate network performance metrics in Playwright tests with interactive HTML reports.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsup src/index.ts --format cjs,esm --dts --clean --external @playwright/test",
|
|
21
21
|
"lint": "biome check .",
|
|
22
|
-
"lint:fix": "biome check --
|
|
22
|
+
"lint:fix": "biome check --write .",
|
|
23
23
|
"test": "jest",
|
|
24
24
|
"test:pw": "npx playwright test --config playwright-tests/playwright.config.ts",
|
|
25
25
|
"test:watch": "jest --watch"
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@biomejs/biome": "^2.3.10",
|
|
43
43
|
"@playwright/test": "^1.57.0",
|
|
44
|
+
"@types/bun": "^1.3.5",
|
|
44
45
|
"@types/jest": "^30.0.0",
|
|
45
46
|
"@types/micromatch": "^4.0.10",
|
|
46
47
|
"jest": "^30.2.0",
|