pytest-timing 0.3.1__tar.gz → 0.3.2__tar.gz

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.
Files changed (50) hide show
  1. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/CHANGELOG.md +10 -0
  2. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/PKG-INFO +8 -2
  3. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/README.md +7 -1
  4. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/pyproject.toml +1 -1
  5. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/__init__.py +1 -1
  6. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/static/report.html +169 -25
  7. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_html.py +37 -0
  8. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/uv.lock +1 -1
  9. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/.github/workflows/ci.yml +0 -0
  10. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/.github/workflows/release.yml +0 -0
  11. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/.gitignore +0 -0
  12. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/ARCHITECTURE.md +0 -0
  13. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/LICENSE +0 -0
  14. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/benchmarks/cpu_bench.py +0 -0
  15. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/docs/report.png +0 -0
  16. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/examples/test_demo.py +0 -0
  17. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/admission.py +0 -0
  18. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/cli.py +0 -0
  19. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/collector.py +0 -0
  20. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/demand.py +0 -0
  21. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/fixtures.py +0 -0
  22. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/model.py +0 -0
  23. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/outputs.py +0 -0
  24. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/plugin.py +0 -0
  25. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/render/__init__.py +0 -0
  26. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/render/ascii.py +0 -0
  27. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/render/html.py +0 -0
  28. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/render/trace.py +0 -0
  29. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/schedule.py +0 -0
  30. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/static/__init__.py +0 -0
  31. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/telemetry.py +0 -0
  32. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/xdist_compat.py +0 -0
  33. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/xdist_scheduler.py +0 -0
  34. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/conftest.py +0 -0
  35. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/fixtures/legacy_complete_false.json +0 -0
  36. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/fixtures/legacy_complete_true.json +0 -0
  37. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/fixtures/legacy_no_flag.json +0 -0
  38. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_admission.py +0 -0
  39. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_ascii.py +0 -0
  40. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_benchmarks.py +0 -0
  41. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_cli.py +0 -0
  42. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_collector.py +0 -0
  43. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_cpu.py +0 -0
  44. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_fixtures.py +0 -0
  45. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_memory.py +0 -0
  46. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_memory_gate.py +0 -0
  47. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_plugin.py +0 -0
  48. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_runtime_admission.py +0 -0
  49. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_schedule.py +0 -0
  50. {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_trace.py +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2
4
+
5
+ - The HTML report shows CPU and memory usage. The table gains "CPU time", "CPUs"
6
+ (the CPUs a test kept busy on average, beside the slots it declared) and "Memory"
7
+ (what it needed on top of its worker's footprint) columns, the hover details say
8
+ the same with what the measurement covered, the header totals the run and names
9
+ each host's budget, and two graphs show CPUs busy and resident memory over time,
10
+ estimated from the per-test records. All of it appears only where there was a
11
+ reading.
12
+
3
13
  ## 0.3.1
4
14
 
5
15
  - Memory admission no longer charges what session- and package-scoped fixtures keep
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pytest-timing
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Record test timings under pytest-xdist and render cargo-style timing reports (ASCII Gantt and HTML).
5
5
  Project-URL: Homepage, https://github.com/messense/pytest-timing
6
6
  Author-email: messense <messense@icloud.com>
@@ -138,7 +138,13 @@ lifecycles, detected host CPU environments, and how the session ended (`finished
138
138
  the input to the CLI below and to `--timing-schedule`.
139
139
 
140
140
  **HTML** is a single file with no external dependencies, so it can be attached to a CI
141
- job as an artifact and opened anywhere.
141
+ job as an artifact and opened anywhere. Where CPU and memory were measured it shows
142
+ them: each test's CPU time, the CPUs it kept busy on average (beside the slots it
143
+ declared) and the memory it needed on top of its worker's footprint, in the table and
144
+ the hover details; the run's totals and budgets in the header; and CPU and memory
145
+ usage over time. The two graphs are drawn from the per-test records, not sampled: a
146
+ test's CPU time is spread evenly over its run, and a worker counts at its running
147
+ test's peak, so they show where the load was rather than its exact shape.
142
148
 
143
149
  **Trace** is a Chrome Trace Event file. Open it in
144
150
  [Perfetto UI](https://ui.perfetto.dev) with "Open trace file", or in `chrome://tracing`.
@@ -112,7 +112,13 @@ lifecycles, detected host CPU environments, and how the session ended (`finished
112
112
  the input to the CLI below and to `--timing-schedule`.
113
113
 
114
114
  **HTML** is a single file with no external dependencies, so it can be attached to a CI
115
- job as an artifact and opened anywhere.
115
+ job as an artifact and opened anywhere. Where CPU and memory were measured it shows
116
+ them: each test's CPU time, the CPUs it kept busy on average (beside the slots it
117
+ declared) and the memory it needed on top of its worker's footprint, in the table and
118
+ the hover details; the run's totals and budgets in the header; and CPU and memory
119
+ usage over time. The two graphs are drawn from the per-test records, not sampled: a
120
+ test's CPU time is spread evenly over its run, and a worker counts at its running
121
+ test's peak, so they show where the load was rather than its exact shape.
116
122
 
117
123
  **Trace** is a Chrome Trace Event file. Open it in
118
124
  [Perfetto UI](https://ui.perfetto.dev) with "Open trace file", or in `chrome://tracing`.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-timing"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Record test timings under pytest-xdist and render cargo-style timing reports (ASCII Gantt and HTML)."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -4,6 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  from pytest_timing.demand import cpu
6
6
 
7
- __version__ = "0.3.1"
7
+ __version__ = "0.3.2"
8
8
 
9
9
  __all__ = ["__version__", "cpu"]
@@ -30,6 +30,8 @@
30
30
  --canvas-collecting: #3a7bd5;
31
31
  --canvas-rate: rgba(250, 119, 0, 0.12);
32
32
  --canvas-rate-line: #fa7700;
33
+ --canvas-usage: #2a78d6;
34
+ --canvas-usage-fill: rgba(42, 120, 214, 0.14);
33
35
  --canvas-hl: #000;
34
36
  --tooltip-bg: #fffbe6;
35
37
  --tooltip-border: #c9b76a;
@@ -60,6 +62,8 @@
60
62
  --canvas-collecting: #6fa4ff;
61
63
  --canvas-rate: rgba(250, 119, 0, 0.16);
62
64
  --canvas-rate-line: #ff9a3c;
65
+ --canvas-usage: #3987e5;
66
+ --canvas-usage-fill: rgba(57, 135, 229, 0.2);
63
67
  --canvas-hl: #fff;
64
68
  --tooltip-bg: #2a2d33;
65
69
  --tooltip-border: #5a5f68;
@@ -74,6 +78,7 @@ body {
74
78
  h1 { font-size: 22px; margin: 0 0 12px; }
75
79
  h2 { font-size: 16px; margin: 28px 0 8px; }
76
80
  .muted { color: var(--muted); }
81
+ h2 .note { font-weight: 400; font-size: 13px; margin-left: 6px; }
77
82
  code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
78
83
  table.summary { border-collapse: collapse; margin-bottom: 8px; }
79
84
  table.summary td { padding: 3px 12px 3px 0; vertical-align: top; }
@@ -119,6 +124,7 @@ rect.ph-call { fill: var(--canvas-base); }
119
124
  .lbl { fill: var(--canvas-text); }
120
125
  .down-mark { stroke: var(--canvas-axes); stroke-width: 1.5; }
121
126
  .down-err { stroke: var(--canvas-bad); stroke-width: 2; }
127
+ .cross { stroke: var(--canvas-axes); stroke-dasharray: 3 3; pointer-events: none; }
122
128
  #tooltip {
123
129
  position: fixed; z-index: 10; pointer-events: none; display: none;
124
130
  max-width: 560px; padding: 8px 10px; font-size: 12px; line-height: 1.35;
@@ -173,6 +179,15 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
173
179
  <span><i style="background:var(--canvas-rate)"></i>Tests completed / s</span>
174
180
  </div>
175
181
 
182
+ <div id="cpu-usage" hidden>
183
+ <h2>CPU usage <span class="muted note">CPUs kept busy by the running tests, each spread evenly over its run</span></h2>
184
+ <div class="chart-wrap" id="cpu-container"></div>
185
+ </div>
186
+ <div id="mem-usage" hidden>
187
+ <h2>Memory usage <span class="muted note">resident memory of the workers' process trees, each running test at its peak</span></h2>
188
+ <div class="chart-wrap" id="mem-container"></div>
189
+ </div>
190
+
176
191
  <h2>Tests <span class="muted" id="table-note"></span></h2>
177
192
  <div style="overflow-x:auto"><table class="tests" id="tests-table"><thead></thead><tbody></tbody></table></div>
178
193
 
@@ -213,7 +228,29 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
213
228
  t.cpuWait = (t.cpu && t.cpu.wait) || 0;
214
229
  t.memWait = (t.memory && t.memory.wait) || 0;
215
230
  t.waited = t.cpuWait + t.memWait;
231
+ // Measured usage: CPU seconds and the CPUs they kept busy on average; resident
232
+ // memory needed on top of what the worker already held. null = no reading.
233
+ var c = t.cpu, m = t.memory;
234
+ t.cpuWork = c && c.coverage !== 'none' ? c.work : null;
235
+ t.cpuRate = t.cpuWork != null && c.elapsed > 0 ? c.work / c.elapsed : null;
236
+ t.memRise = m && m.peak > 0 ? Math.max(0, m.peak - m.base) : null;
216
237
  });
238
+ var ANY_CPU = TESTS.some(function (t) { return t.cpuWork != null; });
239
+ var ANY_MEM = TESTS.some(function (t) { return t.memRise != null; });
240
+ var COVERAGE = { tree: 'worker and its subprocesses', reaped: 'worker and finished subprocesses', self: 'worker only' };
241
+ function cpuText(t) {
242
+ var c = t.cpu, s = fmt(t.cpuWork) + ' of CPU time';
243
+ if (t.cpuRate != null) s += ', ' + t.cpuRate.toFixed(2) + ' CPUs busy on average';
244
+ if (c.demand > 1) s += ', ' + c.demand + ' slots declared';
245
+ if (c.pressure != null) s += ', host CPU pressure ' + Math.round(c.pressure * 100) + '%';
246
+ if (c.throttled) s += ', throttled by a cgroup quota';
247
+ return s + ' (' + (COVERAGE[c.coverage] || c.coverage) + ')';
248
+ }
249
+ function memText(t) {
250
+ var m = t.memory, kept = Math.max(0, m.after - m.base);
251
+ return 'needed +' + fmtBytes(t.memRise) + ': ' + fmtBytes(m.base) + ' before, ' + fmtBytes(m.peak) + ' at peak, ' + fmtBytes(m.after) + ' after' +
252
+ (kept ? ' (+' + fmtBytes(kept) + ' kept)' : '') + ' (' + (COVERAGE[m.coverage] || m.coverage) + ')';
253
+ }
217
254
  var ANY_WAITED = TESTS.some(function (t) { return t.waited > 0; });
218
255
  function heldText(t) {
219
256
  var parts = [];
@@ -232,12 +269,33 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
232
269
  var SUMMARY = DATA.summary || { wall: 0, busy: 0, utilisation: 0, lanes: [] };
233
270
  function events(intervals) {
234
271
  var ev = [];
235
- intervals.forEach(function (iv) { ev.push([iv[0], 1]); ev.push([iv[1], -1]); });
272
+ intervals.forEach(function (iv) { var n = iv.length > 2 ? iv[2] : 1; ev.push([iv[0], n]); ev.push([iv[1], -n]); });
236
273
  return ev.sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; });
237
274
  }
238
275
  var ACTIVE_EVENTS = events(TESTS.map(function (t) { return [t.start, t.stop]; }));
239
276
  var BOOT_EVENTS = events(WORKERS.filter(function (w) { return w.ready != null && w.start != null && w.ready > w.start; }).map(function (w) { return [w.start, w.ready]; }));
240
277
  var COLLECT_EVENTS = events(WORKERS.filter(function (w) { return w.ready != null && w.collected != null && w.collected > w.ready; }).map(function (w) { return [w.ready, w.collected]; }));
278
+ // Usage over time, from the per-test records: a test's CPU work is spread evenly over
279
+ // its span; a lane holds its running test's peak, and between tests what the last left.
280
+ var CPU_EVENTS = events(TESTS.filter(function (t) { return t.cpuRate; }).map(function (t) { return [t.start, t.stop, t.cpuRate]; }));
281
+ var MEM_EVENTS = (function () {
282
+ var last = {}, ivs = [];
283
+ TESTS_BY_START.forEach(function (t) {
284
+ var prev = last[t.worker];
285
+ if (prev && t.start > prev.stop) ivs.push([prev.stop, t.start, prev.memory.after]);
286
+ delete last[t.worker];
287
+ if (t.memRise == null) return;
288
+ ivs.push([t.start, t.stop, t.memory.peak]);
289
+ last[t.worker] = t;
290
+ });
291
+ Object.keys(last).forEach(function (id) {
292
+ var t = last[id], w = workerById[id], end = w && w.down != null ? w.down : null;
293
+ if (end != null && end > t.stop) ivs.push([t.stop, end, t.memory.after]);
294
+ });
295
+ return events(ivs);
296
+ })();
297
+ function peakOf(ev) { var cur = 0, peak = 0; ev.forEach(function (e) { cur += e[1]; if (cur > peak) peak = cur; }); return peak; }
298
+ var CPU_PEAK = peakOf(CPU_EVENTS), MEM_PEAK = peakOf(MEM_EVENTS);
241
299
  var WALL = Math.max(0, (RUN.stop || 0) - (RUN.start || 0));
242
300
  WORKERS.forEach(function (w) { ['ready', 'collected', 'down'].forEach(function (k) { if (w[k] != null) WALL = Math.max(WALL, w[k]); }); });
243
301
  TESTS.forEach(function (t) { WALL = Math.max(WALL, t.stop); });
@@ -256,6 +314,12 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
256
314
  if (s >= 0.001) return (s * 1000).toFixed(1) + 'ms';
257
315
  return (s * 1e6).toFixed(0) + 'µs';
258
316
  }
317
+ function fmtBytes(n) {
318
+ if (n == null || isNaN(n)) return '-';
319
+ var units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'], i = 0;
320
+ while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
321
+ return n.toFixed(i ? 1 : 0) + ' ' + units[i];
322
+ }
259
323
  function pct(part, total) { return total > 0 ? ' (' + Math.round(part / total * 100) + '%)' : ''; }
260
324
  function fmtDate(epoch) { try { return new Date(epoch * 1000).toISOString().replace('T', ' ').replace(/\.\d+Z$/, 'Z'); } catch (e) { return '-'; } }
261
325
  var mctx = document.getElementById('measure').getContext('2d');
@@ -413,23 +477,65 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
413
477
  '<span class="muted">hover for details, click to pin a module</span>';
414
478
  }
415
479
 
480
+ // Event sweep sampled per pixel column over a pre-sorted event list.
481
+ function sample(ev, cols, pxPerSec) {
482
+ var res = new Array(cols), cur = 0, k = 0, peak = 0;
483
+ for (var col = 0; col < cols; col++) {
484
+ var t = (col + 1) / pxPerSec, mx = cur;
485
+ while (k < ev.length && ev[k][0] <= t) { cur += ev[k][1]; if (cur > mx) mx = cur; k++; }
486
+ res[col] = mx; if (mx > peak) peak = mx;
487
+ }
488
+ return { v: res, peak: peak };
489
+ }
490
+ // Step line through a sampled series, scaled so `peak` reaches the top of the plot.
491
+ function stepPath(series, peak, x0, plotH) {
492
+ var d = '', prev = -1, cols = series.v.length;
493
+ for (var col = 0; col < cols; col++) {
494
+ var v = series.v[col], y = TOP_PAD + plotH - v / peak * plotH, x = x0 + col;
495
+ if (col === 0) d += 'M' + x + ' ' + y.toFixed(1);
496
+ else if (v !== prev) d += ' L' + x + ' ' + (TOP_PAD + plotH - prev / peak * plotH).toFixed(1) + ' L' + x + ' ' + y.toFixed(1);
497
+ prev = v;
498
+ }
499
+ return d + ' L' + (x0 + cols) + ' ' + (TOP_PAD + plotH - prev / peak * plotH).toFixed(1);
500
+ }
501
+
502
+ // One usage series over time: a step line over a soft fill, with its own y axis.
503
+ var usageCharts = {};
504
+ function usageChart(id, ev, bytes) {
505
+ var pxPerSec = state.pxPerSec, H = 160, plotH = H - TOP_PAD - BOTTOM_PAD;
506
+ var x0 = LEFT_PAD + textWidth('0000.0 MiB') + 8, cols = Math.max(1, Math.ceil(WALL * pxPerSec));
507
+ var series = sample(ev, cols, pxPerSec);
508
+ // Ticks at 1/2/5 steps of the unit the peak is read in (binary units for bytes).
509
+ var unit = 1; while (bytes && series.peak / unit >= 1024) unit *= 1024;
510
+ var raw = (series.peak / unit || 1) / 4, mag = Math.pow(10, Math.floor(Math.log(raw) / Math.LN10)), f = raw / mag;
511
+ var step = (f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10) * mag * unit, top = step * Math.max(1, Math.ceil(series.peak / step - 1e-9));
512
+ var svg = ['<svg width="' + (x0 + cols + RIGHT_PAD) + '" height="' + H + '">', axisSvg(x0, cols, H, pxPerSec)];
513
+ for (var yv = 0; yv <= top + step / 2; yv += step) {
514
+ var yy = (TOP_PAD + plotH - yv / top * plotH).toFixed(1);
515
+ svg.push('<line class="grid" x1="' + x0 + '" y1="' + yy + '" x2="' + (x0 + cols) + '" y2="' + yy + '"/>');
516
+ svg.push('<text class="lbl" x="' + (x0 - 4) + '" y="' + (+yy + 4) + '" text-anchor="end">' + (bytes ? fmtBytes(yv) : +yv.toFixed(2)) + '</text>');
517
+ }
518
+ var d = stepPath(series, top, x0, plotH), floor = TOP_PAD + plotH;
519
+ svg.push('<path d="' + d + ' L' + (x0 + cols) + ' ' + floor + ' L' + x0 + ' ' + floor + ' Z" fill="var(--canvas-usage-fill)"/>');
520
+ svg.push('<path d="' + d + '" fill="none" stroke="var(--canvas-usage)" stroke-width="2"/>');
521
+ svg.push('<line class="cross" y1="' + TOP_PAD + '" y2="' + floor + '" visibility="hidden"/></svg>');
522
+ document.getElementById(id).innerHTML = svg.join('');
523
+ usageCharts[id] = { x0: x0, pxPerSec: pxPerSec, v: series.v, bytes: bytes };
524
+ }
525
+ function renderUsage() {
526
+ document.getElementById('cpu-usage').hidden = !CPU_PEAK;
527
+ document.getElementById('mem-usage').hidden = !MEM_PEAK;
528
+ if (CPU_PEAK) usageChart('cpu-container', CPU_EVENTS, false);
529
+ if (MEM_PEAK) usageChart('mem-container', MEM_EVENTS, true);
530
+ }
531
+
416
532
  function renderConcurrency() {
417
533
  var c = document.getElementById('timing-container');
418
534
  if (!TESTS.length && !WORKERS.length) { c.innerHTML = '<div class="empty">No data.</div>'; return; }
419
535
  var pxPerSec = state.pxPerSec, H = 220, plotH = H - TOP_PAD - BOTTOM_PAD;
420
536
  var labelW = textWidth('000') + 8, x0 = LEFT_PAD + labelW, width = Math.ceil(WALL * pxPerSec);
421
537
  var cols = Math.max(1, width);
422
- // Event sweep sampled per pixel column over a pre-sorted event list.
423
- function sample(ev) {
424
- var res = new Array(cols), cur = 0, k = 0, peak = 0;
425
- for (var col = 0; col < cols; col++) {
426
- var t = (col + 1) / pxPerSec, mx = cur;
427
- while (k < ev.length && ev[k][0] <= t) { cur += ev[k][1]; if (cur > mx) mx = cur; k++; }
428
- res[col] = mx; if (mx > peak) peak = mx;
429
- }
430
- return { v: res, peak: peak };
431
- }
432
- var active = sample(ACTIVE_EVENTS), booting = sample(BOOT_EVENTS), collecting = sample(COLLECT_EVENTS);
538
+ var active = sample(ACTIVE_EVENTS, cols, pxPerSec), booting = sample(BOOT_EVENTS, cols, pxPerSec), collecting = sample(COLLECT_EVENTS, cols, pxPerSec);
433
539
  var peak = Math.max(1, active.peak, booting.peak, collecting.peak, laneIds.length);
434
540
  // Tests completed per second: buckets follow the scale, at least 8px wide.
435
541
  var bucket = niceStep(pxPerSec, 8), nb = Math.ceil(WALL / bucket) + 1, rate = new Array(nb), ratePeak = 0;
@@ -459,16 +565,7 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
459
565
  svg.push('<path d="' + area + '" fill="var(--canvas-rate)" stroke="var(--canvas-rate-line)" stroke-width="0.75" stroke-opacity="0.6"/>');
460
566
  }
461
567
  function line(series, color) {
462
- if (!series.peak) return '';
463
- var d = '', prev = -1;
464
- for (var col = 0; col < cols; col++) {
465
- var v = series.v[col], y = TOP_PAD + plotH - v / peak * plotH, x = x0 + col;
466
- if (col === 0) d += 'M' + x + ' ' + y.toFixed(1);
467
- else if (v !== prev) d += ' L' + x + ' ' + (TOP_PAD + plotH - prev / peak * plotH).toFixed(1) + ' L' + x + ' ' + y.toFixed(1);
468
- prev = v;
469
- }
470
- d += ' L' + (x0 + cols) + ' ' + (TOP_PAD + plotH - prev / peak * plotH).toFixed(1);
471
- return '<path d="' + d + '" fill="none" stroke="' + color + '" stroke-width="1.5"/>';
568
+ return series.peak ? '<path d="' + stepPath(series, peak, x0, plotH) + '" fill="none" stroke="' + color + '" stroke-width="1.5"/>' : '';
472
569
  }
473
570
  svg.push(line(booting, 'var(--canvas-booting)'));
474
571
  svg.push(line(collecting, 'var(--canvas-collecting)'));
@@ -481,11 +578,13 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
481
578
  ['idx', '#'], ['nodeid', 'Test'], ['worker', 'Worker'], ['dur', 'Total'],
482
579
  ['setup', 'Setup'], ['call', 'Call'], ['teardown', 'Teardown'], ['outcome', 'Outcome'], ['start', 'Start']
483
580
  ];
581
+ if (ANY_CPU) COLS.push(['cpuWork', 'CPU time'], ['cpuRate', 'CPUs']);
582
+ if (ANY_MEM) COLS.push(['memRise', 'Memory']);
484
583
  if (ANY_WAITED) COLS.push(['waited', 'Held']);
485
584
  function sortVal(t, key) {
486
585
  if (key === 'idx') return t.i;
487
586
  if (PHASES.indexOf(key) >= 0) return t.ph[key] ? t.ph[key].dur : 0;
488
- return t[key];
587
+ return t[key] == null ? -1 : t[key];
489
588
  }
490
589
  function renderTable() {
491
590
  var tests = filteredTests(), key = state.sortKey, dir = state.sortDir;
@@ -498,6 +597,8 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
498
597
  '<td class="num">' + fmt(t.dur) + '</td>' +
499
598
  PHASES.map(function (p) { var ph = t.ph[p]; return '<td class="num">' + (ph ? fmt(ph.dur) + pct(ph.dur, t.dur) : '-') + '</td>'; }).join('') +
500
599
  '<td class="o-' + t.outcome + '">' + esc(t.outcome) + '</td><td class="num">' + fmt(t.start) + '</td>' +
600
+ (ANY_CPU ? '<td class="num"' + (t.cpuWork != null ? ' title="' + esc(cpuText(t)) + '">' + fmt(t.cpuWork) : '>-') + '</td><td class="num">' + (t.cpuRate != null ? t.cpuRate.toFixed(2) + (t.cpu.demand > 1 ? ' <span class="muted">/ ' + t.cpu.demand + '</span>' : '') : '-') + '</td>' : '') +
601
+ (ANY_MEM ? '<td class="num"' + (t.memRise != null ? ' title="' + esc(memText(t)) + '">' + (t.memRise ? '+' : '') + fmtBytes(t.memRise) : '>-') + '</td>' : '') +
501
602
  (ANY_WAITED ? '<td class="num" title="' + esc(heldText(t)) + '">' + (t.waited ? fmt(t.waited) : '-') + '</td>' : '') + '</tr>');
502
603
  });
503
604
  document.querySelector('#tests-table tbody').innerHTML = rows.join('') || '<tr><td colspan="' + COLS.length + '" class="empty">No tests match the current filters.</td></tr>';
@@ -524,6 +625,30 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
524
625
  row('Tests:', TESTS.length + OUTCOMES.filter(function (o) { return counts[o]; }).map(function (o) { return ', ' + counts[o] + ' ' + o; }).join(''));
525
626
  row('Busy time:', fmt(busy) + (avail > 0 ? ' of ' + fmt(avail) + ' available (' + Math.round(busy / avail * 100) + '% utilisation)' : ''));
526
627
  if (longest && laneIds.length > 1) row('Critical lane:', esc(longest.id) + ', last test finished at ' + fmt(longest.last));
628
+ // A gate's budget per host, and how long tests waited at it (RunInfo.cpu / RunInfo.memory).
629
+ function gateText(info, what, describe) {
630
+ var names = Object.keys((info && info.domains) || {}), parts = [];
631
+ names.forEach(function (name) { var d = describe(info.domains[name]); if (d) parts.push((names.length > 1 ? esc(name) + ': ' : '') + d); });
632
+ if (info && info.waited_tests) parts.push(info.waited_tests + ' test' + (info.waited_tests !== 1 ? 's' : '') + ' waited ' + fmt(info.waited) + ' for ' + what);
633
+ return parts.length ? '<br><span class="muted">' + parts.join('; ') + '</span>' : '';
634
+ }
635
+ if (ANY_CPU) {
636
+ var work = 0; TESTS.forEach(function (t) { work += t.cpuWork || 0; });
637
+ row('CPU:', fmt(work) + ' of CPU time, ' + (work / WALL).toFixed(2) + ' CPUs busy on average, up to ' + CPU_PEAK.toFixed(2) +
638
+ gateText(RUN.cpu, 'slots', function (d) {
639
+ var host = d.host || {}, out = [];
640
+ if (host.cpus) out.push(host.cpus + ' cpus');
641
+ if (d.budget != null) out.push('budget ' + d.budget + ' slot' + (d.budget !== 1 ? 's' : '') + (d.lowest < d.budget ? ', lowered to ' + d.lowest + ' under pressure' : ''));
642
+ return out.join(', ');
643
+ }));
644
+ }
645
+ if (ANY_MEM) {
646
+ var big = null; TESTS.forEach(function (t) { if (t.memRise != null && (!big || t.memRise > big.memRise)) big = t; });
647
+ row('Memory:', 'up to ' + fmtBytes(MEM_PEAK) + ' resident across workers; the hungriest test needed +' + fmtBytes(big.memRise) + ' <code>' + esc(big.nodeid) + '</code>' +
648
+ gateText(RUN.memory, 'memory', function (d) {
649
+ return d.budget != null ? 'budget ' + fmtBytes(d.budget) + (d.host ? ' of ' + fmtBytes(d.host) : '') : '';
650
+ }));
651
+ }
527
652
  var collect = WORKERS.filter(function (w) { return w.ready != null && w.collected != null; });
528
653
  if (collect.length) {
529
654
  var cmax = maxOf(collect, function (w) { return w.collected - w.ready; });
@@ -541,7 +666,8 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
541
666
  }
542
667
  function tipForTest(t) {
543
668
  return '<b>' + esc(t.nodeid) + '</b><br>' + esc(t.outcome) + ' on ' + esc(t.worker) + (t.attempt ? ' (attempt ' + (t.attempt + 1) + ')' : '') +
544
- '<br>total ' + fmt(t.dur) + ', started at ' + fmt(t.start) + (t.waited ? '<br>held ' + esc(heldText(t)) : '') + phaseRows(t);
669
+ '<br>total ' + fmt(t.dur) + ', started at ' + fmt(t.start) + (t.waited ? '<br>held ' + esc(heldText(t)) : '') +
670
+ (t.cpuWork != null ? '<br>cpu: ' + esc(cpuText(t)) : '') + (t.memRise != null ? '<br>memory: ' + esc(memText(t)) : '') + phaseRows(t);
545
671
  }
546
672
  function tipForCluster(c) {
547
673
  var top = c.tests.slice().sort(function (a, b) { return b.dur - a.dur; }).slice(0, 10);
@@ -570,6 +696,23 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
570
696
  state.pinned = state.pinned === m ? null : m;
571
697
  applyPin();
572
698
  });
699
+ ['cpu-container', 'mem-container'].forEach(function (id) {
700
+ var el = document.getElementById(id);
701
+ el.addEventListener('mousemove', function (ev) {
702
+ var ch = usageCharts[id], svg = el.querySelector('svg'), cross = el.querySelector('.cross');
703
+ if (!ch || !svg) return;
704
+ var col = Math.floor(ev.clientX - svg.getBoundingClientRect().left - ch.x0);
705
+ if (col < 0 || col >= ch.v.length) { tip.style.display = 'none'; cross.setAttribute('visibility', 'hidden'); return; }
706
+ cross.setAttribute('x1', ch.x0 + col + 0.5); cross.setAttribute('x2', ch.x0 + col + 0.5); cross.setAttribute('visibility', 'visible');
707
+ tip.innerHTML = '<b>' + (ch.bytes ? fmtBytes(ch.v[col]) : ch.v[col].toFixed(2) + ' CPUs') + '</b> at ' + fmt(col / ch.pxPerSec);
708
+ tip.style.display = 'block';
709
+ tip.style.left = Math.min(ev.clientX + 14, window.innerWidth - tip.offsetWidth - 8) + 'px'; tip.style.top = (ev.clientY + 14) + 'px';
710
+ });
711
+ el.addEventListener('mouseleave', function () {
712
+ tip.style.display = 'none';
713
+ var cross = el.querySelector('.cross'); if (cross) cross.setAttribute('visibility', 'hidden');
714
+ });
715
+ });
573
716
  function applyPin() {
574
717
  var bars = pc.querySelectorAll('.bar');
575
718
  for (var i = 0; i < bars.length; i++) {
@@ -602,7 +745,7 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
602
745
  var scaleTimer = null;
603
746
  scaleInput.addEventListener('input', function () {
604
747
  state.pxPerSec = scaleFromSlider(+scaleInput.value); showScale();
605
- clearTimeout(scaleTimer); scaleTimer = setTimeout(function () { renderPipeline(); renderConcurrency(); }, 40);
748
+ clearTimeout(scaleTimer); scaleTimer = setTimeout(function () { renderPipeline(); renderConcurrency(); renderUsage(); }, 40);
606
749
  });
607
750
 
608
751
  document.getElementById('view-lane').addEventListener('click', function () { setView('lane'); });
@@ -640,6 +783,7 @@ button.on { background: var(--link); color: #fff; border-color: var(--link); }
640
783
  renderSummary();
641
784
  renderPipeline();
642
785
  renderConcurrency();
786
+ renderUsage();
643
787
  renderTable();
644
788
  } catch (e) {
645
789
  // Surface failures in the document so automated checks can see them.
@@ -78,6 +78,43 @@ def test_report_shows_how_long_admission_held_a_test(sample_run: Run, tmp_path:
78
78
  assert dom.count('title="">-</td>') == len(sample_run.tests) - 1
79
79
 
80
80
 
81
+ @needs_chrome
82
+ def test_report_shows_cpu_and_memory_usage(sample_run: Run, tmp_path: Path) -> None:
83
+ from pytest_timing.model import CpuRecord, MemoryRecord
84
+
85
+ dom = _chrome_dom(sample_run, tmp_path)
86
+ assert "CPU time</th>" not in dom and "Memory</th>" not in dom # nothing measured
87
+ assert '<div id="cpu-usage" hidden' in dom and '<div id="mem-usage" hidden' in dom
88
+
89
+ mib = 1024 * 1024
90
+ two = next(t for t in sample_run.tests if t.nodeid.endswith("test_two"))
91
+ two.cpu = CpuRecord(elapsed=2.0, work=3.0, demand=4, coverage="tree")
92
+ two.memory = MemoryRecord(base=100 * mib, peak=612 * mib, after=110 * mib, coverage="tree")
93
+ four = next(t for t in sample_run.tests if t.nodeid.endswith("test_four"))
94
+ four.cpu = CpuRecord(elapsed=four.duration, wait=0.5) # held back, but nothing measured
95
+ sample_run.run.cpu = {
96
+ "gated": True,
97
+ "domains": {"local": {"budget": 4, "lowest": 4, "host": {"cpus": 8}}},
98
+ "waited": 0.5,
99
+ "waited_tests": 1,
100
+ }
101
+ dom = _chrome_dom(sample_run, tmp_path)
102
+ for heading in ("CPU time</th>", "CPUs</th>", "Memory</th>"):
103
+ assert heading in dom
104
+ assert '>3.00s</td><td class="num">1.50 <span class="muted">/ 4</span></td>' in dom
105
+ assert "100.0 MiB before, 612.0 MiB at peak, 110.0 MiB after (+10.0 MiB kept)" in dom
106
+ assert ">+512.0 MiB</td>" in dom
107
+ unmeasured = len(sample_run.tests) - 1
108
+ assert dom.count('<td class="num">-</td><td class="num">-</td><td class="num">-</td>') == (
109
+ unmeasured
110
+ )
111
+ assert "3.00s of CPU time, 1.00 CPUs busy on average, up to 1.50" in dom
112
+ assert "8 cpus, budget 4 slots; 1 test waited 500.0ms for slots" in dom
113
+ assert "up to 612.0 MiB resident across workers" in dom
114
+ assert '<div id="cpu-usage">' in dom and '<div id="mem-usage">' in dom
115
+ assert 'id="cpu-container"><svg' in dom and 'id="mem-container"><svg' in dom
116
+
117
+
81
118
  @needs_chrome
82
119
  def test_large_report_executes_in_a_browser(tmp_path: Path) -> None:
83
120
  """150k tests on one lane: no argument-limit errors, merged bars, capped table."""
@@ -476,7 +476,7 @@ wheels = [
476
476
 
477
477
  [[package]]
478
478
  name = "pytest-timing"
479
- version = "0.3.1"
479
+ version = "0.3.2"
480
480
  source = { editable = "." }
481
481
  dependencies = [
482
482
  { name = "pytest" },
File without changes
File without changes