playwright-network-metrics 0.1.2 → 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 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 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
- 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,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('table-header');
522
- const body = document.getElementById('table-body');
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
- <th onclick="handleSort('\${k}')">
535
+ <div class="grid-cell" onclick="handleSort('\${k}')">
528
536
  \${columnMeta[k].label}
529
- <span class="tooltip">\u24D8<span class="tooltiptext">\${columnMeta[k].tooltip}</span></span>
537
+ <span class="tooltip" title="\${columnMeta[k].tooltip}">\u24D8</span>
530
538
  \${sortKey === k ? (sortOrder === 1 ? '\u25B2' : '\u25BC') : ''}
531
- </th>
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
- <tr class="main-row \${isExpanded ? 'expanded' : ''}" onclick="toggleExpand('\${item.key}', event)">
539
- <td>
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
- </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>
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
- <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>
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
- </td>
575
- </tr>
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
- * Initializes the reporter with configuration for output and format.
619
- *
620
- * @param config Configuration for output directory and HTML report generation.
624
+ * Configuration for the plugin.
621
625
  */
622
- constructor(config = {}) {
623
- this.config = {
624
- outDir: "playwright-report/network-metrics",
625
- html: false,
626
- ...config
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.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 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
- 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,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('table-header');
484
- const body = document.getElementById('table-body');
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
- <th onclick="handleSort('\${k}')">
497
+ <div class="grid-cell" onclick="handleSort('\${k}')">
490
498
  \${columnMeta[k].label}
491
- <span class="tooltip">\u24D8<span class="tooltiptext">\${columnMeta[k].tooltip}</span></span>
499
+ <span class="tooltip" title="\${columnMeta[k].tooltip}">\u24D8</span>
492
500
  \${sortKey === k ? (sortOrder === 1 ? '\u25B2' : '\u25BC') : ''}
493
- </th>
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
- <tr class="main-row \${isExpanded ? 'expanded' : ''}" onclick="toggleExpand('\${item.key}', event)">
501
- <td>
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
- </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>
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
- <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>
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
- </td>
537
- </tr>
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
- * Initializes the reporter with configuration for output and format.
581
- *
582
- * @param config Configuration for output directory and HTML report generation.
586
+ * Configuration for the plugin.
583
587
  */
584
- constructor(config = {}) {
585
- this.config = {
586
- outDir: "playwright-report/network-metrics",
587
- html: false,
588
- ...config
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.1.2",
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 --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",