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.
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/CHANGELOG.md +10 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/PKG-INFO +8 -2
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/README.md +7 -1
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/pyproject.toml +1 -1
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/__init__.py +1 -1
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/static/report.html +169 -25
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_html.py +37 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/uv.lock +1 -1
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/.github/workflows/ci.yml +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/.github/workflows/release.yml +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/.gitignore +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/ARCHITECTURE.md +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/LICENSE +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/benchmarks/cpu_bench.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/docs/report.png +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/examples/test_demo.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/admission.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/cli.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/collector.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/demand.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/fixtures.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/model.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/outputs.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/plugin.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/render/__init__.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/render/ascii.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/render/html.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/render/trace.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/schedule.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/static/__init__.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/telemetry.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/xdist_compat.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/src/pytest_timing/xdist_scheduler.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/conftest.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/fixtures/legacy_complete_false.json +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/fixtures/legacy_complete_true.json +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/fixtures/legacy_no_flag.json +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_admission.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_ascii.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_benchmarks.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_cli.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_collector.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_cpu.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_fixtures.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_memory.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_memory_gate.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_plugin.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_runtime_admission.py +0 -0
- {pytest_timing-0.3.1 → pytest_timing-0.3.2}/tests/test_schedule.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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],
|
|
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
|
-
|
|
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
|
-
|
|
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)) : '') +
|
|
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."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|