playwright-network-metrics 0.1.2 → 0.2.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/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
- * Initializes the reporter with configuration for output and format.
113
- *
114
- * @param config Configuration for output directory and HTML report generation.
96
+ * Configuration for the plugin.
115
97
  */
116
- constructor(config?: NetworkMetricsReporterConfig);
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
- * Initializes the reporter with configuration for output and format.
113
- *
114
- * @param config Configuration for output directory and HTML report generation.
96
+ * Configuration for the plugin.
115
97
  */
116
- constructor(config?: NetworkMetricsReporterConfig);
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.responseEnd >= 0 && timing.responseStart >= 0 ? timing.responseEnd - timing.responseStart : -1;
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,
@@ -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 { max-width: 1200px; margin: 0 auto; }
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
- .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; }
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 160px 160px 160px 160px 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; min-width: 100%; }
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; min-width: 100%; }
384
391
 
385
- tr.main-row { cursor: pointer; transition: background 0.1s; }
386
- tr.main-row:hover { background: #fcfdfe; }
387
- tr.expanded { background: #f8fafc; }
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
- .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; }
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
- /* Tooltips */
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
- tr.expanded .chevron { transform: rotate(90deg); fill: var(--primary); }
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="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>
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,11 +477,10 @@ 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.' },
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.' },
480
+ avgLoadTimeMs: { label: 'Avg Load', tooltip: 'Resource Load Time: The time taken to download the resource body (responseEnd - responseStart).' },
481
+ avgDurationMs: { label: 'Avg Duration', tooltip: 'The average response time in milliseconds. This represents the total time from request start to the end of the response body.' },
482
+ totalLoadTimeMs: { label: 'Total Load', tooltip: 'Sum of all load times for this group.' },
483
+ totalDurationMs: { label: 'Total Duration', tooltip: 'Sum of all response times for this group.' },
478
484
  errorCount: { label: 'Errors', tooltip: 'Number of failed requests (non-2xx response or network error).' }
479
485
  };
480
486
 
@@ -518,61 +524,58 @@ function generateHtmlReport(report) {
518
524
  return (valA - valB) * sortOrder;
519
525
  });
520
526
 
521
- const header = document.getElementById('table-header');
522
- const body = document.getElementById('table-body');
527
+ const header = document.getElementById('grid-header');
528
+ const body = document.getElementById('grid-body');
523
529
 
524
- const columns = ['key', 'count', 'avgDurationMs', 'p50', 'p95', 'p99', 'totalDurationMs', 'errorCount'];
530
+ const columns = ['key', 'count', 'avgLoadTimeMs', 'totalLoadTimeMs', 'avgDurationMs', 'totalDurationMs', 'errorCount'];
525
531
 
526
532
  header.innerHTML = columns.map(k => \`
527
- <th onclick="handleSort('\${k}')">
533
+ <div class="grid-cell" onclick="handleSort('\${k}')">
528
534
  \${columnMeta[k].label}
529
- <span class="tooltip">\u24D8<span class="tooltiptext">\${columnMeta[k].tooltip}</span></span>
535
+ <span class="tooltip" title="\${columnMeta[k].tooltip}">\u24D8</span>
530
536
  \${sortKey === k ? (sortOrder === 1 ? '\u25B2' : '\u25BC') : ''}
531
- </th>
537
+ </div>
532
538
  \`).join('');
533
539
 
534
540
  let html = '';
535
541
  data.forEach(item => {
536
542
  const isExpanded = expandedKeys.has(item.key);
537
543
  html += \`
538
- <tr class="main-row \${isExpanded ? 'expanded' : ''}" onclick="toggleExpand('\${item.key}', event)">
539
- <td>
544
+ <div class="grid-row \${isExpanded ? 'expanded' : ''}" onclick="toggleExpand('\${item.key}', event)">
545
+ <div class="grid-cell endpoint-cell">
540
546
  <svg class="chevron" viewBox="0 0 20 20"><path d="M7 1L16 10L7 19" stroke="currentColor" stroke-width="2" fill="none"/></svg>
541
547
  \${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>
548
+ <span class="endpoint-key" title="\${item.key}">\${item.method ? item.key.replace(new RegExp(\`^\${item.method}\\\\s+\`), '') : item.key}</span>
549
+ </div>
550
+ <div class="grid-cell">\${item.count}</div>
551
+ <div class="grid-cell">\${formatMs(item.avgLoadTimeMs)}</div>
552
+ <div class="grid-cell">\${formatMs(item.totalLoadTimeMs)}</div>
553
+ <div class="grid-cell">\${formatMs(item.avgDurationMs)}</div>
554
+ <div class="grid-cell">\${formatMs(item.totalDurationMs)}</div>
555
+ <div class="grid-cell \${item.errorCount > 0 ? 'error-tag' : ''}">\${item.errorCount}</div>
556
+ </div>
552
557
  \`;
553
558
 
554
559
  if (isExpanded) {
555
560
  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>
561
+ <div class="details-pane show">
562
+ <div class="details-content">
563
+ <div class="details-grid">
564
+ <div class="details-section">
565
+ <h4>Contributing Spec Files</h4>
566
+ <ul class="details-list">
567
+ \${item.specs.map(s => \`<li><span>\${s.name}</span> <span class="count-badge">\${s.count}</span></li>\`).join('')}
568
+ </ul>
569
+ </div>
570
+ <div class="details-section">
571
+ <h4>Contributing Tests</h4>
572
+ <ul class="details-list">
573
+ \${item.tests.map(t => \`<li><span>\${t.name}</span> <span class="count-badge">\${t.count}</span></li>\`).join('')}
574
+ </ul>
572
575
  </div>
573
576
  </div>
574
- </td>
575
- </tr>
577
+ </div>
578
+ </div>
576
579
  \`;
577
580
  }
578
581
  });
@@ -613,18 +616,23 @@ var NetworkMetricsReporter = class {
613
616
  * Internal store for all collected request metrics from all tests.
614
617
  */
615
618
  allMetrics = [];
616
- config;
617
619
  /**
618
- * Initializes the reporter with configuration for output and format.
619
- *
620
- * @param config Configuration for output directory and HTML report generation.
620
+ * Configuration for the plugin.
621
621
  */
622
- constructor(config = {}) {
623
- this.config = {
624
- outDir: "playwright-report/network-metrics",
625
- html: false,
626
- ...config
627
- };
622
+ config = {
623
+ outDir: "playwright-report/network-metrics",
624
+ html: true
625
+ };
626
+ onBegin(config) {
627
+ const reporterEntry = config.reporter.find(
628
+ ([name]) => name.includes("playwright-network-metrics")
629
+ );
630
+ if (reporterEntry && typeof reporterEntry[1] === "object") {
631
+ this.config = {
632
+ ...this.config,
633
+ ...reporterEntry[1]
634
+ };
635
+ }
628
636
  }
629
637
  /**
630
638
  * 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.responseEnd >= 0 && timing.responseStart >= 0 ? timing.responseEnd - timing.responseStart : -1;
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,
@@ -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 { max-width: 1200px; margin: 0 auto; }
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
- .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; }
338
- table { width: 100%; border-collapse: separate; border-spacing: 0; text-align: left; }
339
- th, td { padding: 14px 18px; border-bottom: 1px solid var(--border); }
340
- th:first-child { border-top-left-radius: 12px; }
341
- th:last-child { border-top-right-radius: 12px; }
342
- tr:last-child td:first-child { border-bottom-left-radius: 12px; }
343
- tr:last-child td:last-child { border-bottom-right-radius: 12px; }
344
- th { background: #f8fafc; font-weight: 700; color: var(--secondary); font-size: 0.85rem; text-transform: uppercase; cursor: pointer; position: relative; }
345
- th:hover { background: #f1f5f9; }
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 160px 160px 160px 160px 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; min-width: 100%; }
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; min-width: 100%; }
346
353
 
347
- tr.main-row { cursor: pointer; transition: background 0.1s; }
348
- tr.main-row:hover { background: #fcfdfe; }
349
- tr.expanded { background: #f8fafc; }
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
- .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; }
352
- .GET { background: #e3f2fd; color: #1976d2; }
353
- .POST { background: #e8f5e9; color: #388e3c; }
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
- /* Tooltips */
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
- tr.expanded .chevron { transform: rotate(90deg); fill: var(--primary); }
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="table-wrapper">
415
- <table id="metrics-table">
416
- <thead>
417
- <tr id="table-header"></tr>
418
- </thead>
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,11 +439,10 @@ 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.' },
436
- p50: { label: 'P50', tooltip: 'Median: 50% of requests were faster than this value.' },
437
- p95: { label: 'P95', tooltip: '95th Percentile: 95% of requests were faster than this value. Useful for identifying high-latency outliers.' },
438
- p99: { label: 'P99', tooltip: '99th Percentile: Only 1% of requests were slower than this value. Critical for tail latency optimization.' },
439
- totalDurationMs: { label: 'Total Time', tooltip: 'Sum of all response times for this group.' },
442
+ avgLoadTimeMs: { label: 'Avg Load', tooltip: 'Resource Load Time: The time taken to download the resource body (responseEnd - responseStart).' },
443
+ avgDurationMs: { label: 'Avg Duration', tooltip: 'The average response time in milliseconds. This represents the total time from request start to the end of the response body.' },
444
+ totalLoadTimeMs: { label: 'Total Load', tooltip: 'Sum of all load times for this group.' },
445
+ totalDurationMs: { label: 'Total Duration', tooltip: 'Sum of all response times for this group.' },
440
446
  errorCount: { label: 'Errors', tooltip: 'Number of failed requests (non-2xx response or network error).' }
441
447
  };
442
448
 
@@ -480,61 +486,58 @@ function generateHtmlReport(report) {
480
486
  return (valA - valB) * sortOrder;
481
487
  });
482
488
 
483
- const header = document.getElementById('table-header');
484
- const body = document.getElementById('table-body');
489
+ const header = document.getElementById('grid-header');
490
+ const body = document.getElementById('grid-body');
485
491
 
486
- const columns = ['key', 'count', 'avgDurationMs', 'p50', 'p95', 'p99', 'totalDurationMs', 'errorCount'];
492
+ const columns = ['key', 'count', 'avgLoadTimeMs', 'totalLoadTimeMs', 'avgDurationMs', 'totalDurationMs', 'errorCount'];
487
493
 
488
494
  header.innerHTML = columns.map(k => \`
489
- <th onclick="handleSort('\${k}')">
495
+ <div class="grid-cell" onclick="handleSort('\${k}')">
490
496
  \${columnMeta[k].label}
491
- <span class="tooltip">\u24D8<span class="tooltiptext">\${columnMeta[k].tooltip}</span></span>
497
+ <span class="tooltip" title="\${columnMeta[k].tooltip}">\u24D8</span>
492
498
  \${sortKey === k ? (sortOrder === 1 ? '\u25B2' : '\u25BC') : ''}
493
- </th>
499
+ </div>
494
500
  \`).join('');
495
501
 
496
502
  let html = '';
497
503
  data.forEach(item => {
498
504
  const isExpanded = expandedKeys.has(item.key);
499
505
  html += \`
500
- <tr class="main-row \${isExpanded ? 'expanded' : ''}" onclick="toggleExpand('\${item.key}', event)">
501
- <td>
506
+ <div class="grid-row \${isExpanded ? 'expanded' : ''}" onclick="toggleExpand('\${item.key}', event)">
507
+ <div class="grid-cell endpoint-cell">
502
508
  <svg class="chevron" viewBox="0 0 20 20"><path d="M7 1L16 10L7 19" stroke="currentColor" stroke-width="2" fill="none"/></svg>
503
509
  \${item.method ? \`<span class="method \${item.method}">\${item.method}</span>\` : ''}
504
- \${item.key}
505
- </td>
506
- <td>\${item.count}</td>
507
- <td>\${formatMs(item.avgDurationMs)}</td>
508
- <td>\${formatMs(item.p50)}</td>
509
- <td>\${formatMs(item.p95)}</td>
510
- <td>\${formatMs(item.p99)}</td>
511
- <td>\${formatMs(item.totalDurationMs)}</td>
512
- <td class="\${item.errorCount > 0 ? 'error-tag' : ''}">\${item.errorCount}</td>
513
- </tr>
510
+ <span class="endpoint-key" title="\${item.key}">\${item.method ? item.key.replace(new RegExp(\`^\${item.method}\\\\s+\`), '') : item.key}</span>
511
+ </div>
512
+ <div class="grid-cell">\${item.count}</div>
513
+ <div class="grid-cell">\${formatMs(item.avgLoadTimeMs)}</div>
514
+ <div class="grid-cell">\${formatMs(item.totalLoadTimeMs)}</div>
515
+ <div class="grid-cell">\${formatMs(item.avgDurationMs)}</div>
516
+ <div class="grid-cell">\${formatMs(item.totalDurationMs)}</div>
517
+ <div class="grid-cell \${item.errorCount > 0 ? 'error-tag' : ''}">\${item.errorCount}</div>
518
+ </div>
514
519
  \`;
515
520
 
516
521
  if (isExpanded) {
517
522
  html += \`
518
- <tr class="details-row show">
519
- <td colspan="8">
520
- <div class="details-content">
521
- <div class="details-grid">
522
- <div class="details-section">
523
- <h4>Contributing Spec Files</h4>
524
- <ul class="details-list">
525
- \${item.specs.map(s => \`<li><span>\${s.name}</span> <span class="count-badge">\${s.count}</span></li>\`).join('')}
526
- </ul>
527
- </div>
528
- <div class="details-section">
529
- <h4>Contributing Tests</h4>
530
- <ul class="details-list">
531
- \${item.tests.map(t => \`<li><span>\${t.name}</span> <span class="count-badge">\${t.count}</span></li>\`).join('')}
532
- </ul>
533
- </div>
523
+ <div class="details-pane show">
524
+ <div class="details-content">
525
+ <div class="details-grid">
526
+ <div class="details-section">
527
+ <h4>Contributing Spec Files</h4>
528
+ <ul class="details-list">
529
+ \${item.specs.map(s => \`<li><span>\${s.name}</span> <span class="count-badge">\${s.count}</span></li>\`).join('')}
530
+ </ul>
531
+ </div>
532
+ <div class="details-section">
533
+ <h4>Contributing Tests</h4>
534
+ <ul class="details-list">
535
+ \${item.tests.map(t => \`<li><span>\${t.name}</span> <span class="count-badge">\${t.count}</span></li>\`).join('')}
536
+ </ul>
534
537
  </div>
535
538
  </div>
536
- </td>
537
- </tr>
539
+ </div>
540
+ </div>
538
541
  \`;
539
542
  }
540
543
  });
@@ -575,18 +578,23 @@ var NetworkMetricsReporter = class {
575
578
  * Internal store for all collected request metrics from all tests.
576
579
  */
577
580
  allMetrics = [];
578
- config;
579
581
  /**
580
- * Initializes the reporter with configuration for output and format.
581
- *
582
- * @param config Configuration for output directory and HTML report generation.
582
+ * Configuration for the plugin.
583
583
  */
584
- constructor(config = {}) {
585
- this.config = {
586
- outDir: "playwright-report/network-metrics",
587
- html: false,
588
- ...config
589
- };
584
+ config = {
585
+ outDir: "playwright-report/network-metrics",
586
+ html: true
587
+ };
588
+ onBegin(config) {
589
+ const reporterEntry = config.reporter.find(
590
+ ([name]) => name.includes("playwright-network-metrics")
591
+ );
592
+ if (reporterEntry && typeof reporterEntry[1] === "object") {
593
+ this.config = {
594
+ ...this.config,
595
+ ...reporterEntry[1]
596
+ };
597
+ }
590
598
  }
591
599
  /**
592
600
  * 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.1.2",
3
+ "version": "0.2.1",
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 --apply .",
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",