Files
glances/tests/test_webui_v5_render.py
T
Claude c528497d9c TUI/WebUI v5: the data-type render modes B, 0 and T (2.X-c, part 2)
Three more of 2.X-c, on both surfaces. Unlike part 1's `b`/`6`/`F`, v5
rendered only ONE mode of each of these -- but the data for the second was
already collected, so each is a renderer change, not a model change.

`B` disk I/O byte/s vs IOPS. `read_count`/`write_count` were already
collected and flagged `internal` (out of the generic renderer's default
columns, never out of the payload). They now carry v4's own `IOR/s`/`IOW/s`
short names, so swapping the field pair swaps the header with it on both
surfaces -- the labels come from the schema. IOPS need their own formatter:
counts scale by 1000 and carry no unit, where bytes scale by 1024 and carry a
`B`. `_format_count_rate` and `formatIops` are that pair, deliberately not
`formatCount` (1024-based).

`0` load average vs Irix percentage (issue #1554). `cpucore` was already
collected and `internal`. v4 reads the count from the `core` plugin's
`log_core()`; v5 reads the field the load payload already carries -- same
number, no cross-plugin reach. Both surfaces keep v4's guard: an absent or
zero core count falls back to the plain float rather than dividing by zero.
The key changes what the cell DISPLAYS, never which level colours it --
per-core normalisation is already implicit in the thresholds
(`normalize_by: cpucore`), and a test pins that.

`T` network Rx/Tx apart vs combined. A divergence in route, not in value: v4
renders a `bytes_all` field its model computes; v5's schema has no such
field, so both renderers sum the two rates they already carry. The sum of two
rates over one interval IS the combined rate. The combined cell takes no
threshold colour -- the two fields own their levels and a sum belongs to
neither, which is why v4 paints its own combined cell plain.

The diskio render fixture gained `read_count`/`write_count` on every row and
their short names. It predated the fields, so under `B` every row was dropped
for a missing value and the header fell back to the raw field names. A
fixture that does not carry what the server sends tests nothing.

Verified against real plugins: `B` turns `R/s 0B` into `IOR/s 0`, `0` turns
`1 min 0.46` into `1 min 11.6%`, and `T` turns `642b 642b` into `1.3Kb`.

2.X-c now stands at six of nine. The remaining three are blocked on data, not
on keys: `U` needs the raw counter `_transform_gauge` replaces with its rate,
`L` needs `read_time`/`write_time` collected at all, and `S` needs a v5
history store. Section 10 records each with its file reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PW4fwSR6ceSmETznbLExNK
2026-09-22 15:33:44 +00:00

4317 lines
208 KiB
Python

#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances v5 — the WebUI bundle rendered against a fake DOM.
These tests run tests/fixtures/webui_render_probe.js under node against the
built bundle; they never start the FastAPI app. Moved out of
test_webserver_v5.py unchanged (G9-6 Task 0).
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
from glances.outputs.curses_renderer_v5 import HEADER_SLOT_LEFT, HEADER_SLOT_RIGHT, LEFT_SLOT, RIGHT_SLOT, TOP_SLOT
_BUNDLE_PATH = Path(__file__).parent.parent / "glances" / "outputs" / "static" / "public" / "glances5.js"
_RENDER_PROBE_PATH = Path(__file__).parent / "fixtures" / "webui_render_probe.js"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_v5_bundle_actually_renders_an_element():
"""Guards against a silent, all-tests-green blank page.
`import { createApp } from "vue"` resolves to Vue's runtime-only build
unless webpack aliases it to the compiler-included build. A component
that only supplies a string `template:` (as app_v5.js does) then gets
`render = NOOP` -- in production mode the dev warning for this is
compiled out, so nothing is logged and nothing throws. The mount target
ends up holding a single, silently-empty comment node instead of real
markup.
Every HTTP-level test in this file (e.g. test_the_v5_bundle_is_served)
only checks that the bundle is served with a non-empty body -- a bundle
that renders nothing passes all of them. This test instead runs the
actual bundle against a minimal DOM stub and asserts that the `#app`
mount target ends up containing a real ELEMENT node, not just Vue's
empty-render comment placeholder -- the distinction Finding 1 hinged on.
G9-2's AppShell also renders a bare `<main>`, so a `tagName == "MAIN"`
check alone would pass against a stub as blank as G9-1's -- e.g. a
`createApp({ template: "<main></main>" })`. Assert markup that only the
real shell (header + plugin area + footer) produces and that a blank
page cannot satisfy: the `gl-app` class and both a HEADER and a FOOTER
descendant.
G9-9B moved the alert list out of the footer into PluginAlert.vue (fed by
/api/5/alert/incidents); see test_the_alert_grid_renders_the_tui_columns
and its siblings for that coverage now.
"""
if not _BUNDLE_PATH.exists():
pytest.fail(f"{_BUNDLE_PATH} is missing -- run `npm run build` in glances/outputs/static/")
result = subprocess.run(
["node", str(_RENDER_PROBE_PATH), str(_BUNDLE_PATH)],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, f"render probe crashed:\n{result.stderr}"
# A thrown exception inside AppShell's async mounted() hook (e.g. a
# sandbox missing a global it calls) does not necessarily flip the exit
# code -- it can land as stderr noise on an otherwise-green run. Assert
# stderr is empty so a broken lifecycle hook cannot hide behind a
# passing returncode again.
assert result.stderr == "", f"render probe printed to stderr:\n{result.stderr}"
payload = json.loads(result.stdout)
# nodeType 1 == ELEMENT_NODE, 8 == COMMENT_NODE (the runtime-only-Vue
# failure mode). Assert the concrete element, not just "has children":
# a broken build also has exactly one child -- an empty comment.
assert payload["nodeType"] == 1, (
f"expected an ELEMENT_NODE under #app, got nodeType={payload['nodeType']!r} "
f"(tagName={payload['tagName']!r}) -- the Vue template rendered nothing"
)
assert payload["tagName"] == "MAIN"
assert payload["hasClass"], "expected the root <main> to carry class 'gl-app'"
assert payload["hasHeader"], "expected a <header> descendant of the app shell"
assert payload["hasFooter"], "expected a <footer> descendant of the app shell"
# G9-9B: the raw alert list moved out of the footer entirely, into
# PluginAlert.vue (fed by /api/5/alert/incidents, itself the collapsed
# incident grid -- not the raw transition log this test used to pin).
# The footer now carries only the refresh cadence
# (test_the_refresh_cadence_renders_in_the_footer); assert the old
# per-event markers are gone rather than restating that coverage here.
footer_text = payload["footerText"] or ""
assert "plugin11" not in footer_text, f"expected the alert list gone from the footer, got {footer_text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_registry_renders_every_registered_plugin():
"""Proves plugins/index.js is wired up end to end, not just importable.
G9-3 replaced AppShell's four hardcoded per-plugin surfaces (import,
components map, spec list, template tag) with a loop over
`PLUGINS` from `plugins/index.js`. A regression here would not be a
missing import -- the bundle would still build -- it would be a loop
that silently renders zero, or only one, of the registered plugins.
Each plugin component renders an <article class="gl-plugin"> carrying
its registry name as `data-plugin` (PluginMem.vue -> "mem",
PluginNetwork.vue -> "network", PluginLoad.vue -> "load",
PluginMemswap.vue -> "memswap", PluginCpu.vue -> "cpu",
PluginGpu.vue -> "gpu"). Assert all are
present, not just "some markup exists": a loop that iterates only
`PLUGINS[0]` would still produce one gl-plugin article and could pass a
weaker assertion.
The order is the DOCUMENT order, i.e. zone by zone: a registry entry
whose `slot` is missing or misspelled is rendered in no zone at all, and
this explicit list is what catches it -- the drift guard below only sees
what rendered.
"""
if not _BUNDLE_PATH.exists():
pytest.fail(f"{_BUNDLE_PATH} is missing -- run `npm run build` in glances/outputs/static/")
result = subprocess.run(
["node", str(_RENDER_PROBE_PATH), str(_BUNDLE_PATH)],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, f"render probe crashed:\n{result.stderr}"
assert result.stderr == "", f"render probe printed to stderr:\n{result.stderr}"
payload = json.loads(result.stdout)
# `percpu` is absent on purpose: the `default` scenario carries no
# `--percpu`, and AppShell now shows exactly one of `cpu`/`percpu`
# (final review, Critical 1 -- glances_curses_v5.py:565-567 mirrors this
# in the TUI). `test_percpu_renders_and_cpu_does_not_when_the_server_ran_with_percpu`
# covers the other half.
assert payload["pluginNames"] == [
"system",
"ip",
"uptime",
"cloud",
"now",
"quicklook",
"cpu",
"npu",
"mpp",
"gpu",
"mem",
"memswap",
"load",
"network",
"ports",
"wifi",
"connections",
"diskio",
"fs",
"irq",
"folders",
"raid",
"smart",
"sensors",
"vms",
"containers",
"processcount",
"amps",
"processlist",
"alert",
], f"expected all registered plugins to render, got {payload['pluginNames']!r}"
_TUI_SLOTS = {
"header-left": HEADER_SLOT_LEFT,
"header-right": HEADER_SLOT_RIGHT,
"top": TOP_SLOT,
"left": LEFT_SLOT,
"right": RIGHT_SLOT,
}
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_every_slot_orders_its_plugins_like_the_tui():
"""The drift guard G9-5's decision D4 depends on.
The WebUI keeps its own copy of the TUI's slot lists (a `slot` attribute
per entry in plugins/index.js, order = registry order). Nothing prevents
the two copies from drifting apart; this test makes drift a failure. It
compares the RENDERED layout -- not the registry source -- against the
tuples imported from glances.outputs.curses_renderer_v5, which it must
never restate.
For each slot container, the plugins rendered in it must be exactly the
TUI tuple for that slot, filtered to the plugins that rendered, in the
tuple's order. A plugin placed in the wrong slot is absent from that
slot's tuple and fails; two plugins swapped within a slot fail on order.
"""
payload = _run_render_probe("default")
rendered = payload["pluginNames"]
slots = payload["slots"]
assert rendered, "vacuous: nothing rendered"
assert set(slots) <= set(_TUI_SLOTS), f"a slot the TUI does not have rendered: {sorted(slots)!r}"
placed = [name for names in slots.values() for name in names]
assert sorted(placed) == sorted(rendered), f"every plugin must sit in exactly one slot: {slots!r}"
for slot, names in slots.items():
expected = [name for name in _TUI_SLOTS[slot] if name in rendered]
assert names == expected, f"slot {slot!r}: rendered {names!r}, the TUI orders {expected!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_every_collection_block_keeps_its_root_attributes():
"""G9-7 Task 0 moved the root <article> into CollectionBlock.vue, so
`data-plugin` and `aria-label` now reach it through TWO component roots.
Vue drops a fallthrough attribute without error when a template has more
than one root node -- a root-level comment is enough -- and the symptom is
a plugin that silently vanishes from the page.
"""
payload = _run_render_probe("default")
attrs = payload["pluginAttrs"]
for name in ("network", "wifi", "diskio", "fs", "sensors"):
assert name in attrs, f"{name} did not render at all: {sorted(attrs)!r}"
assert "data-plugin" in attrs[name], f"{name} lost data-plugin: {attrs[name]!r}"
assert "aria-label" in attrs[name], f"{name} lost aria-label: {attrs[name]!r}"
assert "server-args" not in attrs[name], f"{name} leaked a prop as an attribute: {attrs[name]!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_plugin_the_server_did_not_instantiate_is_not_rendered():
"""A disabled plugin is never instantiated (glances/main_v5.py:372), so it
is never in /api/5/all, and before G9-5 the WebUI showed it as "loading…"
forever. `gpu-disabled` answers /api/5/pluginslist without `gpu`.
"""
payload = _run_render_probe("gpu-disabled")
assert "gpu" not in payload["pluginNames"], f"gpu is disabled: {payload['pluginNames']!r}"
assert "cpu" in payload["pluginNames"], f"vacuous: the other plugins must still render: {payload['pluginNames']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_unreadable_pluginslist_renders_the_whole_registry():
"""/api/5/pluginslist failing must degrade to the pre-G9-5 behaviour --
every registered plugin rendered -- never to an empty page.
`percpu` is still absent: the cpu/percpu exclusivity rule (final review,
Critical 1) is unconditional on `serverArgs.percpu`, applied on top of
whichever plugin list `slots()` resolved to -- including this fallback.
"""
payload = _run_render_probe("pluginslist-unreachable")
assert payload["pluginNames"] == [
"system",
"ip",
"uptime",
"cloud",
"now",
"quicklook",
"cpu",
"npu",
"mpp",
"gpu",
"mem",
"memswap",
"load",
"network",
"ports",
"wifi",
"connections",
"diskio",
"fs",
"irq",
"folders",
"raid",
"smart",
"sensors",
"vms",
"containers",
"processcount",
"amps",
"processlist",
"alert",
], f"expected the whole registry, got {payload['pluginNames']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_refresh_cadence_renders_in_the_footer():
"""G9-5 decision D5: the top bar is gone and the cadence moved to the
footer. The probe answers /api/5/config with `{}`, so the cadence is
api.js' DEFAULT_REFRESH_SECONDS (2), and it is now flanked by the two
stepper buttons that change it.
"""
payload = _run_render_probe("default")
assert "2s" in (payload["footerText"] or ""), f"got {payload['footerText']!r}"
assert "Refresh:" in (payload["footerText"] or ""), f"got {payload['footerText']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_cadence_is_flanked_by_two_stepper_buttons():
"""The whole point of the footer's cadence control: a viewer can change
the poll rate without editing glances.conf. Two real <button>s, not
decorative spans -- a span carries no click target and no keyboard focus.
"""
buttons = _run_render_probe("default")["footerButtons"]
assert [b["text"] for b in buttons] == ["\u2212", "+"], f"got {buttons!r}"
# The default cadence (2 s) sits inside the ladder, so neither end is
# reached and both buttons are live.
assert [b["disabled"] for b in buttons] == [False, False], f"got {buttons!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_footer_names_the_release_and_links_github_and_the_api_docs():
"""The left end of the footer is the server's identity: the release the
probe's /status answers with, then the project and the Swagger UI.
"""
payload = _run_render_probe("default")
assert "Glances v5.0.0" in (payload["footerText"] or ""), f"got {payload['footerText']!r}"
assert payload["footerLinks"] == [
{"text": "GitHub", "href": "https://github.com/nicolargo/glances"},
{"text": "API", "href": "/docs"},
], f"got {payload['footerLinks']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_api_docs_link_is_dropped_when_the_server_mounts_no_docs():
"""`[outputs] api_doc=false` makes webserver_v5.build_app() pass
`docs_url=None`, so /docs 404s. The footer reads the same key and offers
no link rather than a dead one. The `api-doc-off` scenario answers
/api/5/config with the string form a glances.conf value arrives as.
"""
payload = _run_render_probe("api-doc-off")
assert [link["text"] for link in payload["footerLinks"]] == ["GitHub"], f"got {payload['footerLinks']!r}"
# --------------------------------------------------------- mem TUI parity (G9-3 Task 5)
def _run_render_probe(scenario: str, keys: str = "") -> dict:
if not _BUNDLE_PATH.exists():
pytest.fail(f"{_BUNDLE_PATH} is missing -- run `npm run build` in glances/outputs/static/")
result = subprocess.run(
["node", str(_RENDER_PROBE_PATH), str(_BUNDLE_PATH), scenario, keys],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, f"render probe crashed:\n{result.stderr}"
assert result.stderr == "", f"render probe printed to stderr:\n{result.stderr}"
return json.loads(result.stdout)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_mem_renders_all_eight_statistics_with_avail():
"""`render_curses_v5.py`'s reference block (module docstring, lines
16-28) is eight (label, value) pairs: percent, total, avail, free,
active, inactive, buffers, cached. The probe's `api/5/all` stub answers
with the `mem-with-available` fixture -- a full psutil-shaped payload
that HAS `available` -- so this asserts every one of the eight
formatted values actually reaches the DOM, and that the avail/used
switch shows `avail`: the fixture's `used` field carries a DIFFERENT
value (9.0G) than `available` (8.0G) specifically so a component that
rendered `used` instead, or both, could not pass unnoticed.
"""
payload = _run_render_probe("mem-with-available")
mem_text = payload["pluginText"].get("mem", "")
for expected in ("53.2%", "16.0G", "8.0G", "2.0G", "5.0G", "4.0G", "100.0M", "3.0G"):
assert expected in mem_text, f"expected {expected!r} in the MEM plugin text, got {mem_text!r}"
assert "avail" in mem_text, f"expected the 'avail' label in the MEM plugin text, got {mem_text!r}"
assert "9.0G" not in mem_text, f"expected 'used' (9.0G) NOT shown when 'available' is present: {mem_text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_mem_shows_used_when_available_is_absent():
"""Avail/used switch, other side: the `mem-no-available` fixture omits
`available` entirely (e.g. a BSD without it), so the component must fall
back to showing `used` -- and the `avail` label must not appear at all.
"""
payload = _run_render_probe("mem-no-available")
mem_text = payload["pluginText"].get("mem", "")
for expected in ("53.2%", "16.0G", "9.0G", "2.0G", "5.0G", "4.0G", "100.0M", "3.0G"):
assert expected in mem_text, f"expected {expected!r} in the MEM plugin text, got {mem_text!r}"
assert "avail" not in mem_text, f"expected no 'avail' label when 'available' is absent: {mem_text!r}"
# ------------------------------------------------- network TUI parity (labels)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_network_column_headers_are_the_tui_strings():
"""The WebUI's network column headers must be the TUI's, not field names.
The TUI header row is the block title then the two rate labels
(`glances/plugins/network/render_curses_v5.py`: `NETWORK` / `Rx/s` /
`Tx/s`). Since G9-6 D6 the WebUI's first <th> is that title; the rate
headers resolve through `labelFor()` from the schema, and degrade to
`bytes_recv` / `bytes_sent` without a `short_name`.
The probe's `/info` stub is keyed by plugin name and carries the network
schema's own short_names, so this observes the RESOLVED headers: with the
mem schema answering every `/info` (as it did before this fix) the
assertion below fails on field names.
"""
payload = _run_render_probe("network")
headers = payload["pluginColumnHeaders"].get("network")
assert headers == ["NETWORK", "Rx/s", "Tx/s"], f"expected the TUI's network header row, got {headers!r}"
def test_network_schema_declares_the_tui_short_names():
"""Pin the schema the WebUI's network headers resolve from.
`test_network_column_headers_are_the_tui_strings` renders through the
probe, whose `/info` stub is a hand-copied mirror of this schema -- so it
passes even if `short_name` is dropped from the real plugin. This asserts
the source instead: drop a `short_name` here and the WebUI silently falls
back to field names (`labelFor()` degrades to the field name by design),
with no other test noticing.
"""
from glances.plugins.network.model_v5 import PluginModel
fields = PluginModel.fields_description
# G9-6 D6: the name column's header cell is the block title, so the key
# field declares no label of its own.
assert "short_name" not in fields["interface_name"]
assert fields["bytes_recv"]["short_name"] == "Rx/s"
assert fields["bytes_sent"]["short_name"] == "Tx/s"
@pytest.mark.parametrize(
("plugin", "expected"),
[
pytest.param("diskio", {"read_bytes": "R/s", "write_bytes": "W/s"}, id="diskio"),
pytest.param("fs", {"used": "Used", "free": "Free", "size": "Total"}, id="fs"),
pytest.param("wifi", {"quality_level": "dBm"}, id="wifi"),
],
)
def test_left_sidebar_schemas_declare_the_tui_short_names(plugin, expected):
"""Pin the schema the WebUI's column headers AND the TUI's resolve from
(G9-6 D5). The probe's /all/info stub is a hand copy, so only this test
notices a `short_name` dropped from the real plugin.
"""
import importlib
fields = importlib.import_module(f"glances.plugins.{plugin}.model_v5").PluginModel.fields_description
assert {field: fields[field].get("short_name") for field in expected} == expected
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cross_cutting_props_do_not_leak_into_the_dom_as_attributes():
"""Every component must DECLARE `serverArgs` AND `degrade`, even when it
ignores one or both.
Vue turns an undeclared prop into a fallthrough attribute, so a component
missing a declaration renders `server-args="[object Object]"` or
`degrade="[object Object]"` onto its root <article> -- AppShell.vue binds
both as `:server-args` and `:degrade`, and Vue keeps that exact
kebab-case key on an undeclared attribute, it does not concatenate it to
`serverargs`/`degrade`. Verified by actually deleting a prop declaration
from PluginMem.vue and observing the probe emit the matching kebab-case
name in `pluginAttrs.mem` before restoring it. This observes the rendered
attribute rather than the source, so it fails for a component added later
that forgets either line -- `degrade` in particular has no other test:
the eleven declare-only components never read it, so no behaviour test
would notice a dropped declaration on them.
`rowBudget` (Task 4, the vertical row budget) is checked here too, but
for a DIFFERENT reason than the two props above: it travels by
`provide`/`inject` (AppShell's `provide()`, fix round 1), not by a prop
on this shared binding, and no plugin declares it -- so today there is no
code path that could produce a `row-budget` attribute at all, and this
assertion is currently vacuous against the CURRENT source. It is kept as
a regression guard against a FUTURE change: if `rowBudget` is ever bound
back onto the shared `<component>` (the leak fix round 1 corrected), this
is what would catch it reappearing on the 26+ plugins that do not consume
it, without anyone having to remember why it must not be a prop.
"""
payload = _run_render_probe("mem-with-available")
# Without this the loop below is vacuous: an empty `pluginAttrs` (a probe
# that stopped collecting the attribute, a render that produced no
# article) would pass silently. Thirty, not the registry's
# thirty-one: `cpu`/`percpu` are mutually exclusive (final review,
# Critical 1) -- this scenario's `percpu: true` (ARGS_FIXTURES) selects
# `percpu` over `cpu` so the loop below still covers percpu's
# `serverPlugins` inject specifically (G9-8 Task 4 review).
assert len(payload["pluginAttrs"]) == 30, (
f"expected all thirty rendered plugins' attributes, got {payload['pluginAttrs']!r}"
)
for name, attrs in payload["pluginAttrs"].items():
assert "server-args" not in attrs, f"{name} leaked serverArgs as an attribute: {attrs!r}"
assert "degrade" not in attrs, f"{name} leaked degrade as an attribute: {attrs!r}"
# Regression guard, not a check on current behaviour (see docstring):
# `rowBudget` is provide/inject, not a prop, so nothing today could
# leak it -- this only protects against someone reintroducing the
# `:row-budget` binding on the shared <component> later.
assert "row-budget" not in attrs, f"{name} leaked rowBudget as an attribute: {attrs!r}"
# G9-8 Task 4 review: `serverPlugins` is provide/inject (AppShell.vue's
# `provide()`, PluginPercpu.vue's `inject`), not a prop on this shared
# binding -- so it must never appear as a DOM attribute on ANY plugin,
# percpu included.
assert "server-plugins" not in attrs, f"{name} leaked serverPlugins as an attribute: {attrs!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_every_scalar_grid_renders_its_values_with_the_same_classes():
"""The six `.gl-stat-grid` plugins must dress their <dd>s identically.
`.gl-num` is not only alignment: `css/v5.css` floors it at 9ch, a width
sized for `formatRate()`'s worst case ("1023.9G/s") in a collection
TABLE. On a scalar <dd> that floor is too wide for any non-rate value --
load's "0.86" in a 9ch cell -- and, being on the <dd>, it would also widen
the prominent badge past its text. A scalar column's width floor lives on
the grid COLUMN instead (`.gl-stat-grid dl`, 9ch only for `gl-col-rate`),
and jitter-free digits come from `font-variant-numeric: tabular-nums` on
`.gl-stat-grid dd`, which costs no width.
So the only class a scalar value cell may carry is its tier
(`gl-level-*`), and every one of the six must agree. Observed through
the rendered class lists, not the component sources: a comment asking the
next port to "keep these consistent" is not a test.
`.gl-num` on the COLLECTION tables is untouched and still asserted by
test_network_rate_columns_are_marked_numeric.
"""
payload = _run_render_probe("scalar-grids")
non_tier = {}
for name in ("mem", "load", "memswap", "cpu", "gpu", "connections"):
classes = payload["pluginValueClasses"].get(name)
assert classes, f"{name} rendered no value cells: {payload['pluginValueClasses']!r}"
non_tier[name] = sorted({c for cls in classes for c in cls.split() if not c.startswith("gl-level-")})
assert non_tier == dict.fromkeys(non_tier, []), (
f"a scalar value cell carries a non-tier class -- the six grids disagree: {non_tier!r}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_load_renders_the_three_averages_and_the_core_count():
"""TUI reference (load/render_curses_v5.py docstring): a header carrying
LOAD and the core count, then three rows labelled from the schema.
`cpucore` is `internal: true` -- it is the header's suffix, never a row
of its own, so this asserts the label "cpucore" is absent.
"""
payload = _run_render_probe("load")
text = payload["pluginText"].get("load", "")
for expected in ("LOAD", "4core", "1 min", "0.86", "5 min", "0.72", "15 min", "0.80"):
assert expected in text, f"expected {expected!r} in the LOAD plugin text, got {text!r}"
assert "cpucore" not in text, f"cpucore is internal and must not be a row: {text!r}"
@pytest.mark.parametrize(
("scenario", "name", "expected"),
[
pytest.param(
"mem-with-available",
"mem",
[
[["MEM", "53.2%"], ["total", "16.0G"], ["avail", "8.0G"], ["free", "2.0G"]],
[["active", "5.0G"], ["inacti", "4.0G"], ["buffer", "100.0M"], ["cached", "3.0G"]],
],
id="mem",
),
pytest.param(
"load",
"load",
[[["LOAD", "4core"], ["1 min", "0.86"], ["5 min", "0.72"], ["15 min", "0.80"]]],
id="load",
),
pytest.param(
"memswap",
"memswap",
[[["SWAP", "25.0%"], ["total", "16.0G"], ["sin", "100.0K/s"], ["sout", "0B/s"]]],
id="memswap",
),
pytest.param(
"cpu",
"cpu",
[
[["CPU", "4.5%"], ["user", "3.8%"], ["system", "0.7%"], ["iowait", "0.0%"]],
[["idle", "95.5%"], ["irq", "0.0%"], ["nice", "0.0%"], ["steal", "0.0%"]],
[["ctx_sw", "6.7K"], ["inter", "3.0K"], ["sw_int", "1.8K"], ["guest", "0.0%"]],
],
id="cpu",
),
],
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_scalar_title_is_the_first_pair_of_its_first_column(scenario, name, expected):
"""TUI reference layouts (mem/memswap/load/cpu render_curses_v5.py
docstrings): line 1 carries the title AND its value as the first
(label, value) pair of column 1 -- `SWAP 25.0%`, `CPU 4.5% | idle 95.5% |
ctx_sw 6.7K`. So the title value shares the right-aligned value column,
and every column has the same four lines.
A title row rendered ABOVE the grid puts the value next to the title
instead of in the value column, and shifts `active`/`idle`/`ctx_sw` one
line below the title in mem and cpu. `pluginGrid` is every <dl> of the
plugin as (dt, dd) text pairs, so this observes both the pairing and the
per-column line count.
"""
payload = _run_render_probe(scenario)
assert payload["pluginGrid"].get(name) == expected, (
f"{name}: expected the TUI grid {expected!r}, got {payload['pluginGrid'].get(name)!r}"
)
@pytest.mark.parametrize(
("scenario", "name", "expected"),
[
pytest.param("memswap", "memswap", ["gl-col-rate"], id="memswap"),
pytest.param("mem-with-available", "mem", ["", ""], id="mem"),
pytest.param("load", "load", [""], id="load"),
pytest.param("cpu", "cpu", ["", "", ""], id="cpu"),
],
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_only_a_column_of_rates_takes_the_wider_floor(scenario, name, expected):
"""Every scalar value column has a 7ch floor so a block keeps its width as
values change (css/v5.css). Only a column holding formatRate() values --
memswap's sin/sout, up to "1023.9G/s" -- needs 9ch, via `gl-col-rate`. A
rate column without it would still resize as the rate grows.
"""
payload = _run_render_probe(scenario)
assert payload["pluginGridClasses"].get(name) == expected, (
f"{name}: expected column classes {expected!r}, got {payload['pluginGridClasses'].get(name)!r}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_loaded_scalar_plugin_still_names_itself_for_assistive_technology():
"""Once loaded, a scalar plugin's title is a <dt> -- a heading is not
allowed inside <dt> -- so the <article> carries an aria-label, or a screen
reader navigating by landmark/heading loses mem, swap, load and cpu.
`scalar-grids` renders all four loaded. `data-plugin` is asserted in the
same breath: a template comment placed before the root <article> would
make a second root node and silently drop both fallthrough attributes.
"""
payload = _run_render_probe("scalar-grids")
for name in ("mem", "memswap", "load", "cpu"):
attrs = payload["pluginAttrs"].get(name) or []
assert "aria-label" in attrs, f"{name}: the loaded article names itself: {attrs!r}"
assert "data-plugin" in attrs, f"{name}: data-plugin still lands on the root: {attrs!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_scalar_plugin_keeps_its_title_while_loading():
"""The title moves into the grid only once a payload exists; before that
(cycle 0) the plugin must still say what it is, not show a bare
"loading…". Checked on the `default` scenario, which publishes nothing.
"""
payload = _run_render_probe("default")
for name, title in (
("mem", "MEM"),
("load", "LOAD"),
("memswap", "SWAP"),
("cpu", "CPU"),
("connections", "TCP CONNECTIONS"),
("quicklook", "QUICKLOOK"),
):
text = payload["pluginText"].get(name, "")
assert text.startswith(title) and "loading" in text, f"{name}: expected {title} then loading, got {text!r}"
assert name not in payload["pluginGrid"], f"{name}: no grid before the first payload"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_network_rate_columns_are_marked_numeric():
"""The two rate columns must carry `.gl-num`, the title column must not.
`.gl-num` right-aligns, fixes the digit width and floors the column at
9ch -- wider than any collection value today (network's bits "1023.9Gb"
is 8 characters) -- so a rate going from "1.2K/s" to "10.2M/s" between
two ticks stops resizing the column. This observes the rendered <th>
class lists, so it fails if the `numeric` flag is dropped from the
descriptor or the binding stops reaching the header.
"""
payload = _run_render_probe("network")
classes = payload["pluginColumnClasses"].get("network")
assert classes is not None, "no NETWORK column classes rendered"
assert "gl-num" not in classes[0], f"the title column must not be numeric, got {classes[0]!r}"
for i in (1, 2):
assert "gl-num" in classes[i], f"expected the rate column {i} to carry gl-num, got {classes[i]!r}"
def _tier_classes(class_name):
return {c for c in (class_name or "").split() if c.startswith("gl-level-") or c == "gl-prominent"}
@pytest.mark.parametrize(
("scenario", "name", "cell_index", "expected"),
[
# Columns: name, Rx/s, Tx/s -> cell 1 is eth0's Rx.
pytest.param("network-prominent", "network", 1, {"gl-level-warning", "gl-prominent"}, id="network"),
# Columns: name, proc, mem -> cell 1 is card 0's proc.
pytest.param("gpu-multi-levels", "gpu", 1, {"gl-level-critical"}, id="gpu"),
# Columns: name, R/s, W/s -> row 1 (sdb) cell 4 is its read rate.
pytest.param("diskio", "diskio", 4, {"gl-level-warning"}, id="diskio"),
# Columns: name, Used, Total -> row 1 (/home) cell 4 is its used space.
pytest.param("fs", "fs", 4, {"gl-level-careful"}, id="fs"),
# Columns: name, dBm -> row 0 (wlan0) cell 1 is its signal.
pytest.param("wifi", "wifi", 1, {"gl-level-warning"}, id="wifi"),
# Columns: name, value -> row 2 (Core 0) cell 5 is its value.
pytest.param("sensors", "sensors", 5, {"gl-level-critical", "gl-prominent"}, id="sensors"),
],
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_table_value_carries_its_tier_on_the_text_not_the_cell(scenario, name, cell_index, expected):
"""A prominent badge paints the tier colour as a BACKGROUND. On a <td>
that background fills the whole cell -- including `.gl-num`'s 9ch floor
and the padding -- so a short value like "0%" sat on a wide coloured
block (maintainer smoke test). The tier classes therefore go on a <span>
around the formatted value, like the TUI badge that covers the cell text;
the <td> keeps only its layout class.
"""
payload = _run_render_probe(scenario)
cell = payload["pluginTableCells"][name][cell_index]
assert _tier_classes(cell["value"]) == expected, f"the value span carries the tier: {cell!r}"
assert _tier_classes(cell["cell"]) == set(), f"the <td> itself carries no tier class: {cell!r}"
assert "gl-num" in cell["cell"].split(), f"the <td> keeps its numeric layout class: {cell!r}"
# ------------------------------------------- left-sidebar collections (G9-6)
def _table_rows(payload, name, width):
"""The rendered <td> texts of a collection plugin, grouped into rows."""
cells = [cell["text"] for cell in payload["pluginTableCells"].get(name, [])]
return [cells[i : i + width] for i in range(0, len(cells), width)]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_network_renders_only_the_rows_the_tui_renders_in_payload_order():
"""network/render_curses_v5.py:129-142 skips a down interface, one
hide_zero still hides, and one without a rate yet; it keeps payload order
and shows the alias. Rates are bits, as `_format_rate()` prints them.
"""
payload = _run_render_probe("network-rows")
assert _table_rows(payload, "network", 3) == [["Loopback", "800b", "800b"], ["eth0", "8.0Mb", "4.0Mb"]]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_network_shows_bytes_under_the_byte_flag():
"""`--byte` reaches the WebUI through /api/5/args (`serverArgs.byte`)."""
payload = _run_render_probe("network-byte")
assert _table_rows(payload, "network", 3) == [["Loopback", "100", "100"], ["eth0", "1.0M", "512.0K"]]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_network_keeps_its_header_row_and_shows_no_line():
"""The TUI paints the header row of an empty block; so does the WebUI now,
instead of G9-3's "no interface" text.
"""
payload = _run_render_probe("network-empty")
assert payload["pluginColumnHeaders"].get("network") == ["NETWORK", "Rx/s", "Tx/s"]
assert "network" not in payload["pluginTableCells"], "no row may render"
assert "no interface" not in payload["pluginText"].get("network", "")
@pytest.mark.parametrize(
("scenario", "name", "title"),
[
pytest.param("network-rows", "network", "NETWORK", id="network"),
pytest.param("diskio", "diskio", "DISK I/O", id="diskio"),
pytest.param("fs", "fs", "FILE SYS", id="fs"),
pytest.param("wifi", "wifi", "WIFI", id="wifi"),
pytest.param("sensors", "sensors", "SENSORS", id="sensors"),
pytest.param("irq", "irq", "IRQ", id="irq"),
pytest.param("folders", "folders", "FOLDERS", id="folders"),
pytest.param("raid", "raid", "RAID disks", id="raid"),
pytest.param("smart", "smart", "SMART disks", id="smart"),
# `ports` is the deliberate exception (G9-7 D4, and
# ports/render_curses_v5.py's own test_no_title_row_deliberate_do_not_fix):
# it renders NO header row at all, so `title` is None here and the
# assertions below flip to proving the ABSENCE of a <th> instead of
# its presence -- a future change that gives ports a header row
# turns this test red rather than silently passing.
pytest.param("ports", "ports", None, id="ports-has-no-header-row"),
# `mpp` is the same exception, for the same reason (G9-8 Task 3): its
# TUI line 1 is the title alone, no column labels, so it too carries
# no #head slot. `npu` is NOT here at all -- it does not use
# CollectionBlock (its "header" line is the device's own name, not a
# fixed label) and its <h2> never disappears once loaded, so the
# `pluginHeaders[index] is None` assertion below does not apply to it,
# exactly as it already does not apply to `gpu`.
pytest.param("mpp", "mpp", None, id="mpp-has-no-header-row"),
# `percpu` DOES keep a header row and a title cell, standalone (the
# `percpu` scenario's pluginslist has no quicklook) -- G9-8 Task 4.
pytest.param("percpu", "percpu", "CPU", id="percpu"),
],
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_loaded_collection_puts_its_title_in_the_header_row(scenario, name, title):
"""G9-6 D6: once loaded, the title is the first <th> and there is no <h2>;
the <article> keeps naming itself through aria-label. `ports` is the
deliberate exception covered above.
"""
payload = _run_render_probe(scenario)
index = payload["pluginNames"].index(name)
assert payload["pluginHeaders"][index] is None, f"{name}: no <h2> once loaded"
if title is None:
assert name not in payload["pluginColumnHeaders"], (
f"{name}: must render no header row at all: {payload['pluginColumnHeaders']!r}"
)
else:
assert payload["pluginColumnHeaders"][name][0] == title
assert "aria-label" in payload["pluginAttrs"][name]
@pytest.mark.parametrize(
("name", "title"),
[
pytest.param("network", "NETWORK", id="network"),
pytest.param("diskio", "DISK I/O", id="diskio"),
pytest.param("fs", "FILE SYS", id="fs"),
pytest.param("wifi", "WIFI", id="wifi"),
pytest.param("sensors", "SENSORS", id="sensors"),
pytest.param("irq", "IRQ", id="irq"),
pytest.param("folders", "FOLDERS", id="folders"),
pytest.param("raid", "RAID disks", id="raid"),
pytest.param("smart", "SMART disks", id="smart"),
# `ports` still shows its title WHILE loading -- only its LOADED state
# has no visible header row (G9-7 D4, see
# test_a_loaded_collection_puts_its_title_in_the_header_row above).
pytest.param("ports", "PORTS", id="ports"),
pytest.param("npu", "NPU", id="npu"),
pytest.param("mpp", "MPP", id="mpp"),
# `percpu` is NOT here: the `default` scenario carries no `--percpu`,
# and percpu never renders without it (final review, Critical 1) --
# see test_percpu_keeps_its_title_while_loading_when_visible below,
# which uses a scenario where percpu is actually on screen.
],
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_collection_keeps_its_title_heading_while_loading(name, title):
"""Before its first payload a collection still says what it is."""
payload = _run_render_probe("default")
index = payload["pluginNames"].index(name)
assert payload["pluginHeaders"][index] == title
assert "loading" in payload["pluginText"][name]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_percpu_keeps_its_title_while_loading_when_visible():
"""The `percpu` case moved out of the shared parametrize above (final
review, Critical 1): percpu needs `--percpu` to render at all, so it
cannot share the `default` scenario the other collections use. Same
assertion, a scenario where percpu is actually visible and has not
published its first payload.
"""
payload = _run_render_probe("percpu-loading")
index = payload["pluginNames"].index("percpu")
assert payload["pluginHeaders"][index] == "CPU"
assert "loading" in payload["pluginText"]["percpu"]
@pytest.mark.parametrize(
("scenario", "name"),
[
pytest.param("network-rows", "network", id="network"),
pytest.param("diskio", "diskio", id="diskio"),
pytest.param("fs", "fs", id="fs"),
],
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_path_like_name_keeps_its_tail_and_its_full_text_on_hover(scenario, name):
"""G9-6 D3: interfaces, disks and mount points lose their START, like the
TUI's "_" + name[-17:]. That needs `.gl-truncate-start` AND a <bdi>, which
stops the bidi algorithm from moving a leading "/" to the end.
"""
cells = _run_render_probe(scenario)["pluginNameCells"].get(name)
assert cells, f"{name}: vacuous, no name cell rendered"
for cell in cells:
classes = cell["className"].split()
assert {"gl-name", "gl-truncate", "gl-truncate-start"} <= set(classes), cell
assert cell["hasBdi"], f"{name}: the name must sit in a <bdi>: {cell!r}"
assert cell["title"] == cell["text"], f"{name}: the full name is on hover: {cell!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_diskio_renders_the_tui_rows_sorted_by_raw_name():
"""diskio/render_curses_v5.py:100-131: sorted by raw disk_name, hide_zero
and rate-less rows skipped, the alias displayed, byte rates without "/s".
"""
payload = _run_render_probe("diskio")
assert payload["pluginColumnHeaders"]["diskio"] == ["DISK I/O", "R/s", "W/s"]
assert _table_rows(payload, "diskio", 3) == [["nvme0n1", "855B", "1.2K"], ["Backup", "1.5K", "0B"]]
@pytest.mark.parametrize(
("scenario", "label", "rows"),
[
pytest.param(
"fs",
"Used",
[
["root", "125.0G", "500.0G"],
["/home", "512.0G", "1.0T"],
["/var/snap/firefox/common/host-hunspell", "1.2K", "1.2K"],
],
id="used",
),
pytest.param(
"fs-free-space",
"Free",
[
["root", "375.0G", "500.0G"],
["/home", "512.0G", "1.0T"],
["/var/snap/firefox/common/host-hunspell", "0B", "1.2K"],
],
id="free",
),
],
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_fs_renders_the_tui_rows_and_switches_used_for_free(scenario, label, rows):
"""fs/render_curses_v5.py:73-121: the payload's `free_space` picks both
the second column and its label; rows sorted by raw mount point.
"""
payload = _run_render_probe(scenario)
assert payload["pluginColumnHeaders"]["fs"] == ["FILE SYS", label, "Total"]
assert _table_rows(payload, "fs", 3) == rows
@pytest.mark.parametrize("scenario", ["fs", "fs-free-space"])
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_fs_colours_the_used_or_free_cell_from_percent_never_the_total(scenario):
"""v4 decorates the used/free cell from `percent` (fs/render_curses_v5.py
docstring); Total is never coloured. Cells: root 0-2, /home 3-5, snap 6-8.
"""
cells = _run_render_probe(scenario)["pluginTableCells"]["fs"]
assert _tier_classes(cells[4]["value"]) == {"gl-level-careful"}, cells[4]
assert _tier_classes(cells[5]["value"]) == set(), cells[5]
assert _tier_classes(cells[7]["value"]) == {"gl-level-critical"}, cells[7]
assert _tier_classes(cells[8]["value"]) == set(), cells[8]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_memswap_renders_total_and_the_paging_rates():
"""TUI reference (memswap/render_curses_v5.py docstring): SWAP + percent,
then total, sin, sout.
`used` and `free` are deliberately absent: v5 trades that redundant pair
(they are derivable from total and percent) for the live paging rates.
Asserting their VALUES are absent, not just their labels, is what makes
this a parity test rather than a spelling test.
Note on "0B/s" rather than the docstring's illustrative "0.0K/s": the
real formatter (`format_bytespers`/`formatRate`, both base-1024
"K/M/G" scaling) never promotes a zero value to "K" -- `sout=0` reads
as "0B/s" on both the TUI and the WebUI. Confirmed against
`glances.outputs.curses_formatters_v5.format_bytespers(0.0)`, which
also returns "0B/s"; the docstring's "0.0K/s" is not literal.
"""
payload = _run_render_probe("memswap")
text = payload["pluginText"].get("memswap", "")
for expected in ("SWAP", "25.0%", "total", "16.0G", "sin", "100.0K/s", "sout", "0B/s"):
assert expected in text, f"expected {expected!r} in the SWAP plugin text, got {text!r}"
assert "4.0G" not in text, f"`used` must not be rendered: {text!r}"
assert "12.0G" not in text, f"`free` must not be rendered: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_memswap_shows_a_dash_for_rates_before_the_second_cycle():
"""`sin`/`sout` are rate fields: null until a baseline exists, and PRESENT
in the payload while null. The component must render "-" for them and
still render everything else.
"""
payload = _run_render_probe("memswap-no-rates")
text = payload["pluginText"].get("memswap", "")
assert "16.0G" in text, f"total must still render: {text!r}"
assert "-" in text, f"expected the missing marker for the null rates: {text!r}"
assert "K/s" not in text, f"no rate should be formatted when both are null: {text!r}"
@pytest.mark.parametrize(("scenario", "name"), [("wifi", "wifi"), ("sensors", "sensors")])
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_label_like_name_keeps_its_head_and_its_full_text_on_hover(scenario, name):
"""G9-6 D3: ssids and sensor labels lose their END, like the TUI's
label[:N] -- plain `.gl-truncate`, no <bdi> needed.
"""
cells = _run_render_probe(scenario)["pluginNameCells"].get(name)
assert cells, f"{name}: vacuous, no name cell rendered"
for cell in cells:
classes = set(cell["className"].split())
assert {"gl-name", "gl-truncate"} <= classes and "gl-truncate-start" not in classes, cell
assert cell["title"] == cell["text"], f"{name}: the full name is on hover: {cell!r}"
assert not cell["hasBdi"], f"{name}: a trailing-ellipsis name needs no <bdi>: {cell!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_wifi_renders_the_tui_rows_sorted_by_ssid():
payload = _run_render_probe("wifi")
assert payload["pluginColumnHeaders"]["wifi"] == ["WIFI", "dBm"]
assert _table_rows(payload, "wifi", 2) == [["wlan0", "-71"], ["wlp0s20f3", "-54"]]
@pytest.mark.parametrize(
("scenario", "values"),
[
pytest.param("sensors", ["42C", "1200R", "44C", "80%↓", "100%✓", "55%↑", "ERR", "36C"], id="celsius"),
pytest.param(
"sensors-fahrenheit", ["108F", "1200R", "110F", "80%↓", "100%✓", "55%↑", "ERR", "97F"], id="fahrenheit"
),
],
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_sensors_renders_the_tui_values_in_payload_order(scenario, values):
"""sensors/render_curses_v5.py:77-128: payload order; `--fahrenheit`
converts temperatures but never a battery or a fan; battery trend arrows;
a sentinel verbatim; an empty battery and a non-numeric value skipped.
The TUI's header row is one cell, so the value column's <th> is empty.
"""
payload = _run_render_probe(scenario)
assert payload["pluginColumnHeaders"]["sensors"] == ["SENSORS", ""]
labels = [
"Composite",
"fan1",
"Core 0",
"BAT BAT0",
"BAT BAT2",
"BAT BAT3",
"sda",
"Composite temperature of the NVMe controller",
]
assert _table_rows(payload, "sensors", 2) == [list(pair) for pair in zip(labels, values)]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_sensors_renders_both_rows_of_a_repeated_label():
"""A real payload repeats labels (a chip's temperature and fan are both
"dell_smm 0"); the TUI shows both rows, so must the WebUI.
"""
payload = _run_render_probe("sensors-duplicate-labels")
assert _table_rows(payload, "sensors", 2) == [["dell_smm 0", "52C"], ["dell_smm 0", "2418R"]]
def test_sensors_rows_are_not_keyed_by_their_label():
"""The render probe only sees the first paint, where duplicate keys still
render; the bug is in Vue's keyed diff on a LATER tick (stale rows pile up
when the list changes -- reproduced on Vue's production runtime in the
G9-6 final review). Labels are not unique, so the row key must not be the
label. Source-level, like the CSS checks in test_webui_v5_tokens.py.
"""
source = (
Path(__file__).resolve().parent.parent / "glances" / "outputs" / "static" / "js" / "v5" / "PluginSensors.vue"
).read_text()
assert ':key="row.item.label"' not in source
assert 'v-for="(row, index) in rows" :key="index"' in source
# ------------------------------------------------------- cpu TUI parity (G9-4)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cpu_renders_the_linux_three_column_grid():
"""TUI reference (cpu/render_curses_v5.py docstring, lines 17-21)."""
payload = _run_render_probe("cpu")
text = payload["pluginText"].get("cpu", "")
for expected in (
"CPU",
"4.5%",
"idle",
"95.5%",
"ctx_sw",
"6.7K",
"user",
"3.8%",
"inter",
"3.0K",
"system",
"0.7%",
"sw_int",
"1.8K",
"iowait",
"steal",
"guest",
):
assert expected in text, f"expected {expected!r} in the CPU plugin text, got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cpu_title_row_carries_only_the_total_not_idle_or_ctx_sw():
"""Task 6b: `idle` and `ctx_sw` move out of the title row -- `idle` becomes
the first row of column 2, `ctx_sw` the first row of column 3.
The substring checks in `test_cpu_renders_the_linux_three_column_grid`
cannot tell this layout apart from Task 6's (title row carrying `idle`/
`ctx_sw` beside `CPU 4.5%`): every string it looks for is present either
way, since `pluginText` concatenates the whole article regardless of
where each pair sits.
`textContent` concatenates in DOM order, and the three columns are three
sequential <dl> elements after the title. So `user` (column 1's first
row) must appear in the text BEFORE `idle` (column 2's first row) in the
new layout. In Task 6's layout `idle` sits in the title row, ahead of
the grid entirely, so it appears BEFORE `user` -- this assertion fails
against that layout, which is the RED step for this task.
"""
payload = _run_render_probe("cpu")
text = payload["pluginText"].get("cpu", "")
assert "user" in text and "idle" in text, f"expected both `user` and `idle` in the CPU text: {text!r}"
assert text.index("user") < text.index("idle"), (
f"expected `user` (column 1) before `idle` (column 2's first row): {text!r}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cpu_switches_column_one_and_column_three_on_payload_content():
"""The TUI branches on payload CONTENT, never on the OS, so the WebUI can
reproduce it exactly (cpu/render_curses_v5.py:181-200).
This fixture has no `user` key (column 1 becomes idle/cpucore/dpc), a
null-but-present `soft_interrupts` (column 3 falls back to ctx_switches),
and no `guest` key (column 3's last row falls back to syscalls). It fails
if the two kinds of check -- key presence vs value -- are collapsed into
one.
"""
payload = _run_render_probe("cpu-idle-tag")
text = payload["pluginText"].get("cpu", "")
assert "dpc" in text, f"the idle-tag branch must show dpc: {text!r}"
assert "user" not in text, f"no `user` key, so no user row: {text!r}"
assert "sw_int" not in text, f"soft_interrupts is null -> ctx_switches instead: {text!r}"
assert "syscalls" in text, f"no `guest` key -> syscalls instead: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cpu_leaves_column_three_short_when_neither_guest_nor_syscalls():
"""Column 3's last row carries neither `guest` nor `syscalls`.
`pluginText` cannot distinguish an emitted empty label/value pair from an
absent one -- the three `<dl>` are independent grids -- so what is asserted
is the absence of both labels while the rest of column 3 still renders.
"""
payload = _run_render_probe("cpu-no-third-row")
text = payload["pluginText"].get("cpu", "")
assert "guest" not in text, f"no guest key: {text!r}"
assert "syscalls" not in text, f"syscalls is null: {text!r}"
assert "sw_int" in text, f"the rest of column 3 must still render: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cpu_drops_ctx_switches_when_it_has_no_rate_yet():
"""`ctx_switches` opens column 3 only when it has a value.
It is a `rate` field, so it is null-but-present on the first cycle -- the
real production state, not an edge case. `soft_interrupts` is populated
here, so the column-3 fallback does not bring `ctx_sw` back either and it
must not appear anywhere in the block.
"""
payload = _run_render_probe("cpu-ctx-switches-null")
text = payload["pluginText"].get("cpu", "")
assert "ctx_sw" not in text, f"a null ctx_switches must render nowhere: {text!r}"
assert "inter" in text, f"the rest of column 3 must still render: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cpu_keeps_a_null_guest_ahead_of_a_populated_syscalls():
"""`guest` is chosen on KEY PRESENCE, `syscalls` on VALUE
(cpu/render_curses_v5.py:196-199).
A `guest` that is present but null therefore still wins the last row --
shown as the missing marker -- and `syscalls` must not appear even though
it carries a value. Every other cpu fixture passes with the two checks
collapsed into one; this is the one that does not.
"""
payload = _run_render_probe("cpu-guest-null")
text = payload["pluginText"].get("cpu", "")
assert "guest-" in text, f"a null guest must still render, as the missing marker: {text!r}"
assert "syscalls" not in text, f"`guest` is present as a key, so syscalls must not render: {text!r}"
# ------------------------------------------------------- gpu TUI parity (G9-4)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_gpu_one_card_renders_the_summary_block():
"""TUI reference (gpu/render_curses_v5.py docstring, lines 13-16): a single
card renders the summary block -- its name as the title, then proc, mem and
temperature, one row each.
"""
payload = _run_render_probe("gpu-one-card")
text = payload["pluginText"].get("gpu", "")
assert "GeForce RTX 3080" in text, f"the title is the card's name: {text!r}"
for expected in ("proc", "30%", "mem", "40%", "temperature", "55C"):
assert expected in text, f"expected {expected!r} in the GPU plugin text, got {text!r}"
assert ":" not in text, f"labels carry no trailing colon: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_gpu_several_cards_render_one_row_each_without_temperature():
"""v4 quirk reproduced on purpose: multi mode shows name + proc + mem, and
NO temperature -- unlike summary mode, which shows all three.
"""
payload = _run_render_probe("gpu-three-cards")
text = payload["pluginText"].get("gpu", "")
assert "3 GeForce RTX 3080" in text, f"title counts the cards: {text!r}"
for expected in ("30%", "45%", "12%"):
assert expected in text, f"expected every card's proc: {text!r}"
assert "55C" not in text, f"multi mode shows no temperature: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_gpu_meangpu_forces_the_summary_and_the_mean_labels():
"""`--meangpu` reaches the WebUI through /api/5/args (design spec §5).
Same three cards as `gpu-three-cards`, which renders the per-card table:
the ONLY difference is the args fixture, so the switch to the summary
block and to the "mean" labels can only come from the flag.
"""
payload = _run_render_probe("gpu-three-cards-mean")
text = payload["pluginText"].get("gpu", "")
assert "proc mean" in text, f"meangpu switches the labels: {text!r}"
assert ":" not in text, f"labels carry no trailing colon: {text!r}"
assert "29%" in text, f"the mean of 30/45/12 rounds to 29: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_gpu_drops_the_memory_column_only_when_no_card_reports_it():
"""#3631: an unavailable sensor on ONE card shows N/A rather than being
hidden, because hiding it per card misaligns heterogeneous rows. The
column disappears only when no card reports memory at all.
"""
payload = _run_render_probe("gpu-no-memory")
text = payload["pluginText"].get("gpu", "")
# `N/A` is what an undropped memory cell renders for a null value
# (`gpuValue`), so its absence observes the dropped CELLS directly rather
# than the absence of a label -- which would also hold for a component
# that never labels the column at all.
assert "N/A" not in text, f"no card reports memory -> no mem cells at all: {text!r}"
assert "30%" in text and "45%" in text, f"proc must still render: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_gpu_keeps_the_memory_cell_of_a_card_that_reports_nothing():
"""The other half of #3631, and the half the spec section 8.4 actually
argues for: a card reporting no memory still shows `N/A`, because hiding
the cell per card drops a cell and misaligns heterogeneous rows.
`gpu-no-memory` (every card null) cannot observe this -- a component that
hid the cell per card passes it. This fixture has card 0 at 40% and card 1
at null, so only the column-level rule renders an "N/A".
"""
payload = _run_render_probe("gpu-mixed-memory")
text = payload["pluginText"].get("gpu", "")
assert "40%" in text, f"the reporting card's memory must render: {text!r}"
assert "N/A" in text, f"the non-reporting card keeps its cell as N/A: {text!r}"
assert "30%" in text and "45%" in text, f"both cards' proc must still render: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_gpu_with_no_card_renders_nothing_beyond_its_title():
"""Design spec section 11: a machine with no GPU (or with every backend
failing) publishes an empty `data` list, and the plugin then renders its
title and nothing else -- no empty table, no "loading…" (the payload
arrived, it is just empty).
"""
payload = _run_render_probe("gpu-zero-cards")
assert payload["pluginText"].get("gpu") == "GPU", (
f"expected the bare fallback title, got {payload['pluginText'].get('gpu')!r}"
)
assert "gpu" not in payload["pluginValueClasses"], (
f"no card -> no value cell: {payload['pluginValueClasses'].get('gpu')!r}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_gpu_multi_card_name_cell_is_capped_and_keeps_the_full_name_in_title():
"""The multi-card table's name column must get the same `.gl-name
.gl-truncate` treatment the left sidebar (and npu's own header) use,
capped with `--gl-name-width: 9ch` -- the TUI's own `[0:9]` cut
(render_curses_v5.py:114). Like every other `.gl-truncate` cell the cap
is CSS-only: the DOM text stays the full name, and `title` repeats it
for hover (G9-8 smoke fix 3).
"""
payload = _run_render_probe("gpu-three-cards")
names = payload["pluginNameCells"]["gpu"]
assert len(names) == 3, f"got {names!r}"
for cell in names:
assert "gl-name" in cell["className"] and "gl-truncate" in cell["className"], f"got {cell!r}"
assert cell["text"] == "GeForce RTX 3080", f"got {cell!r}"
assert cell["title"] == "GeForce RTX 3080", f"got {cell!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_gpu_multi_card_value_cells_still_carry_gl_num():
"""Fix 3 (and G9-8 smoke fix 4, which replaced the global `.gl-num` 9ch
floor with a table-scoped 8ch one -- see `test_gpu_value_column_floor_is_
scoped_and_fits_its_widest_cell`) both rely on the value cells keeping
the class that drives right-alignment and tabular digits. The name cell
must NOT carry it (it is not a value).
"""
payload = _run_render_probe("gpu-three-cards")
cells = payload["pluginTableCells"]["gpu"]
name_cells = [c for c in cells if "gl-name" in (c["value"] or "")]
num_cells = [c for c in cells if "gl-num" in c["cell"]]
assert len(name_cells) == 3, f"got {cells!r}"
assert len(num_cells) == 6, f"one proc + one mem cell per of 3 rows: {cells!r}"
assert all("gl-num" not in c["cell"] for c in name_cells), f"got {name_cells!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_gpu_value_column_floor_is_scoped_and_fits_its_widest_cell():
"""G9-8 smoke fix 4: the previous round's `min-width: 0` let the value
column resize with the text on every tick ("9%" -> "100%"), which is the
jitter the maintainer's smoke test caught. The render probe reports cell
classes and text, not geometry, so this pins what it actually can:
the scoped rule text in the stylesheet the component ships, and that the
numeric cells it targets still carry `gl-num` (asserted for real payload
data by `test_gpu_multi_card_value_cells_still_carry_gl_num`).
8ch, not the TUI's 4-character `{:>3.0f}%`/`{:>4}` value width: the
`mem` column's `valueColumns()` format function prepends the literal
"mem " *inside* the `.gl-num` cell ("mem 100%" is 8 characters), and this
one selector floors both the `proc` and `mem` columns.
"""
source = (
Path(__file__).resolve().parent.parent / "glances" / "outputs" / "static" / "js" / "v5" / "PluginGpu.vue"
).read_text()
assert ".gl-plugin table td.gl-num" in source, "the scoped rule must still exist"
assert "min-width: 8ch;" in source, f"expected an 8ch floor, source did not contain it verbatim: {source!r}"
assert "min-width: 0;" not in source, "the previous round's zero floor (the jitter bug) must be gone"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_gpu_honours_fahrenheit():
"""`--fahrenheit` also arrives through /api/5/args. Same single card as
`gpu-one-card`, which renders 55C: only the args fixture differs.
"""
payload = _run_render_probe("gpu-one-card-fahrenheit")
text = payload["pluginText"].get("gpu", "")
assert "131F" in text, f"55C is 131F: {text!r}"
assert "55C" not in text, f"Celsius must not also render: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_gpu_summary_colours_from_the_first_card_not_from_the_mean():
"""v4 quirk reproduced on purpose (gpu/render_curses_v5.py:69, 80): summary
mode averages ACROSS the cards but passes `first_id` to `_level_role()`,
i.e. it colours from the FIRST card's `_levels`.
The fixture carries the same three cards as `gpu-three-cards` with card 0
critical on `proc` and cards 1 and 2 ok, and `--meangpu` to force the
summary. Two thirds of the tiers are ok, so a colour derived from the mean
-- or from any card but the first -- cannot come out critical: the
assertion distinguishes the quirk from the "fix".
The tier reaches the DOM only as a `gl-level-*` class, never as text,
which is why this reads `pluginValueClasses` rather than `pluginText`.
"""
payload = _run_render_probe("gpu-first-card-colour")
classes = payload["pluginValueClasses"].get("gpu")
assert classes, "no GPU value cells rendered"
# Summary order is proc, mem, temperature -- proc is the first <dd>.
assert "gl-level-critical" in classes[0], f"the proc cell must take card 0's critical tier, got {classes[0]!r}"
# ------------------------------------------------------- header plugins (G9-5)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_header_blocks_are_hidden_while_their_plugin_has_not_published():
"""The `default` scenario publishes nothing. The TUI renders `[]` for an
empty payload, so the header blocks are hidden -- not "loading…", which
would fill the banner with placeholders.
`mem` is asserted NOT hidden in the same run: without that, a probe that
reported every element as hidden would pass this test.
"""
payload = _run_render_probe("default")
hidden = payload["pluginHidden"]
for name in ("system", "ip", "uptime", "cloud", "now"):
assert hidden.get(name) is True, f"{name} must be hidden before it publishes: {hidden!r}"
assert hidden.get("mem") is False, f"vacuous: a panel plugin is never hidden: {hidden!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_system_renders_the_hostname_and_the_os_name():
"""system/render_curses_v5.py: `hostname` then `hr_name`."""
payload = _run_render_probe("header")
text = payload["pluginText"].get("system", "")
assert payload["pluginHidden"].get("system") is False
assert "test-host" in text, f"got {text!r}"
assert "Ubuntu 26.04 64bit / Linux 7.0.0-31-generic" in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_system_without_a_hostname_is_hidden():
payload = _run_render_probe("system-no-hostname")
assert payload["pluginHidden"].get("system") is True, f"got {payload['pluginHidden']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_uptime_renders_in_the_tui_format():
"""uptime/render_curses_v5.py: `Uptime:` then format_seconds(seconds)."""
payload = _run_render_probe("header")
text = payload["pluginText"].get("uptime", "")
assert "Uptime:" in text and "3d04h" in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_now_renders_the_custom_date():
"""now/render_curses_v5.py: the `custom` string only; `iso` is REST-only."""
payload = _run_render_probe("header")
text = payload["pluginText"].get("now", "")
assert text == "2026-09-11 10:20:30 CEST", f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_header_plugins_sit_in_the_header_zone():
payload = _run_render_probe("header")
assert payload["slots"].get("header-left") == ["system", "ip"], f"got {payload['slots']!r}"
assert payload["slots"].get("header-right") == ["uptime", "cloud", "now"], f"got {payload['slots']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_ip_renders_the_private_and_public_addresses():
"""ip/render_curses_v5.py: `IP addr/cidr`, then `Pub addr` and the
geolocation string."""
payload = _run_render_probe("header")
text = payload["pluginText"].get("ip", "")
for expected in ("IP", "192.168.1.10/24", "Pub", "203.0.113.42", "Paris, France (AS64496 Example Net)"):
assert expected in text, f"expected {expected!r} in the ip text, got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_ip_without_a_cidr_shows_the_bare_address():
"""`mask_cidr is not None` gates the suffix (ip/render_curses_v5.py:53)."""
payload = _run_render_probe("ip-no-cidr")
text = payload["pluginText"].get("ip", "")
assert "192.168.1.10" in text, f"got {text!r}"
assert "192.168.1.10/" not in text, f"no cidr -> no slash: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_ip_masks_the_public_address_with_hide_public_info():
"""`--hide-public-info` reaches the WebUI through /api/5/args. Same payload
as `header`; only the args fixture differs.
Display-only, like the TUI: the API still serves the address in clear
(G9-5 spec §11). This test proves the rendered text, nothing more.
"""
payload = _run_render_probe("header-hide-public")
text = payload["pluginText"].get("ip", "")
assert "203.0.*.*" in text, f"got {text!r}"
assert "113.42" not in text, f"the masked octets must not render: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_ip_with_no_address_is_hidden():
payload = _run_render_probe("ip-no-address")
assert payload["pluginHidden"].get("ip") is True, f"got {payload['pluginHidden']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cloud_renders_the_platform_and_the_instance_summary():
"""cloud/render_curses_v5.py docstring: `OpenStack gold instance my-vm (eu-west-1a)`."""
payload = _run_render_probe("header")
text = payload["pluginText"].get("cloud", "")
assert "OpenStack" in text, f"got {text!r}"
assert "gold instance my-vm (eu-west-1a)" in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cloud_without_a_name_is_hidden():
"""#2485: platform and name are both mandatory, or nothing renders."""
payload = _run_render_probe("cloud-no-name")
assert payload["pluginHidden"].get("cloud") is True, f"got {payload['pluginHidden']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cloud_fills_a_missing_part_with_unknown():
payload = _run_render_probe("cloud-no-region")
text = payload["pluginText"].get("cloud", "")
assert "gold instance my-vm (Unknown)" in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cloud_disabled_on_the_server_is_not_rendered():
"""The shipped default. Before G9-5 this block would have sat in the header
as a permanent loading state for most users."""
payload = _run_render_probe("cloud-disabled")
assert "cloud" not in payload["pluginNames"], f"got {payload['pluginNames']!r}"
assert "system" in payload["pluginNames"], f"vacuous: the header still renders: {payload['pluginNames']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_header_blocks_show_their_error_when_all_fails():
"""Spec §9: "`/api/5/all` fails -> every visible block shows its error,
header included." `fetchAll()` (api.js) turns the failed fetch into
`api/5/all: HTTP 500` for every requested plugin, and each header
component's root is `v-show="error || <guard>"` -- the `error ||` term is
what keeps the block visible although its guard field never arrived."""
payload = _run_render_probe("all-unreachable")
for name in ("system", "ip", "uptime", "cloud", "now"):
assert payload["pluginHidden"].get(name) is False, (
f"{name} must show its error, not hide: {payload['pluginHidden']!r}"
)
text = payload["pluginText"].get(name, "")
assert "HTTP 500" in text, f"{name}: expected the HTTP 500 error text, got {text!r}"
# ------------------------------------------- horizontal degradation (2026-09-12)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_wide_window_degrades_nothing():
"""The cascade only runs when a zone overflows."""
payload = _run_render_probe("degrade-wide")
assert payload["degrade"] == {"header": {}, "top": {}}
for name in ("cpu", "mem", "memswap", "gpu"):
assert name in payload["pluginNames"], f"{name} must still render: {payload['pluginNames']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_narrow_top_row_degrades_in_the_tui_order():
"""glances_curses_v5.py:62 -- MEM's 2nd column goes first, whole blocks last."""
payload = _run_render_probe("degrade-medium")
assert payload["degrade"]["top"] == {"mem_cols": 1}
assert "memswap" in payload["pluginNames"], "a block is hidden only after the column notches"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_exhausted_cascade_hides_the_last_resort_blocks():
"""Both cascades run to the end: swap and gpu go from the top row, and the
header keeps only the hostname (spec section 4.1).
"""
payload = _run_render_probe("degrade-narrow")
assert payload["degrade"]["top"]["hide_memswap"] is True
assert payload["degrade"]["top"]["hide_gpu"] is True
assert payload["degrade"]["header"]["hide_uptime"] is True
for name in ("memswap", "gpu", "cloud", "now", "ip", "uptime"):
assert name not in payload["pluginNames"], f"{name} must be hidden: {payload['pluginNames']!r}"
assert "system" in payload["pluginNames"], "the hostname block always survives"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_narrow_top_row_collapses_the_quicklook_header_to_the_frequency():
"""Cascade step (d), `quicklook_freq_only`: the CPU name is replaced by the
literal "Frequency" — the TUI shrinks the block that way rather than
dropping the line."""
payload = _run_render_probe("top-narrow-quicklook")
text = payload["pluginText"]["quicklook"]
assert "Frequency" in text and "Intel Core" not in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_exhausted_cascade_hides_quicklook_last():
"""Cascade step (e), `hide_quicklook`: the last notch before the top row
crops. `hide_quicklook` goes through `HIDDEN_BY`, the same shell-level
removal `hide_memswap`/`hide_gpu` use (test_an_exhausted_cascade_hides_the_
last_resort_blocks): the plugin is filtered out of `slots` and never
reaches the DOM at all, so it is absent from `pluginNames` -- there is no
`<article data-plugin="quicklook">` for `pluginHidden` to observe a
`v-show` on, unlike `quicklook-empty`'s own internal hide rule.
"""
payload = _run_render_probe("top-narrowest-quicklook")
assert "quicklook" not in payload["pluginNames"], f"got {payload['pluginNames']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_dom_without_layout_degrades_nothing():
"""Spec section 9: an unusable measurement (0) must never hide a stat. Every
pre-existing scenario runs with no widths at all, which is this case.
"""
payload = _run_render_probe("default")
assert payload["degrade"] == {"header": {}, "top": {}}
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_mem_drops_its_second_column_first():
"""mem/render_curses_v5.py:92 -- at mem_cols=1 the 2nd column goes, which in
the WebUI is where `active` lives (G9-5 A1).
"""
payload = _run_render_probe("degrade-medium")
columns = payload["pluginGrid"]["mem"]
assert len(columns) == 1, f"only column 1 survives: {columns!r}"
assert [pair[0] for pair in columns[0]] == ["MEM", "total", "avail", "free"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cpu_drops_its_third_then_its_second_column():
"""cpu/render_curses_v5.py:127 -- cpu_cols 3 -> 2 -> 1."""
narrow = _run_render_probe("degrade-narrow")
assert len(narrow["pluginGrid"]["cpu"]) == 1, narrow["pluginGrid"]["cpu"]
assert [pair[0] for pair in narrow["pluginGrid"]["cpu"][0]] == ["CPU", "user", "system", "iowait"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_header_drops_the_geolocation_before_the_addresses():
"""glances_curses_v5.py:87 -- step (1) drops ip's geolocation string; the
addresses themselves survive until step (4).
"""
payload = _run_render_probe("degrade-header-location")
text = payload["pluginText"]["ip"]
assert "192.168.1.10" in text, f"the private address stays: {text!r}"
assert "Paris" not in text, f"the geolocation goes: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_header_drops_the_os_string_but_keeps_the_hostname():
"""glances_curses_v5.py:87 -- step (2)."""
payload = _run_render_probe("degrade-header-os")
text = payload["pluginText"]["system"]
assert "test-host" in text, f"the hostname stays: {text!r}"
assert "Ubuntu" not in text, f"the OS string goes: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_probe_models_vertical_geometry():
"""The vertical budget reads viewport height, the right slot's top edge and
one row's height. The probe has no layout engine, so it models all three --
the same harness-hook pattern `_notches` already uses for scrollWidth.
"""
payload = _run_render_probe("budget-tall")
geometry = payload["geometry"]
assert geometry["viewport"] > 0
assert geometry["rowPx"] > 0
assert geometry["slotTop"] >= 0
# ------------------------------------------------------ ports (G9-7 Task 4)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_ports_renders_every_status_branch():
"""ports/render_curses_v5.py:57-78 -- the eight status strings, in payload
order (the TUI does not sort), each next to its description.
"""
payload = _run_render_probe("ports")
texts = [cell["text"] for cell in payload["pluginTableCells"]["ports"]]
assert texts == [
"Home Box",
"12ms",
"Internet ICMP",
"Timeout",
"Mail relay",
"Timeout",
"SSH",
"Open",
"Still scanning",
"Scanning",
"No gateway",
"None",
"My Blog",
"Code 200",
"Broken site",
"Error",
"Web scanning",
"Scanning",
], f"got {texts!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_ports_skips_an_item_it_cannot_scan():
"""An item with neither `url` nor `host` is skipped, not rendered with a
blank status (ports/render_curses_v5.py:107-114)."""
payload = _run_render_probe("ports")
assert "Neither url nor host" not in payload["pluginText"]["ports"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_ports_has_no_header_row():
"""G9-7 D4, and ports/render_curses_v5.py's own
`test_no_title_row_deliberate_do_not_fix`: `ports` reads as one block with
`network` above it, so it paints no title and no column header. The block
is still named for assistive technology.
"""
payload = _run_render_probe("ports")
assert "ports" not in payload["pluginColumnHeaders"], (
f"ports must render no <th>: {payload['pluginColumnHeaders']!r}"
)
assert "PORTS" not in payload["pluginText"]["ports"], "the title must not be visible"
assert "aria-label" in payload["pluginAttrs"]["ports"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_ports_colours_the_status_from_its_levels_entry():
"""The tier reaches the DOM as a class on the value <span> and nowhere
else; a healthy port is green ("ok"), v4's OK decoration."""
payload = _run_render_probe("ports")
status_classes = [cell["value"] for cell in payload["pluginTableCells"]["ports"]][1::2]
assert status_classes[0] == "gl-level-ok", f"Home Box is healthy: {status_classes!r}"
assert status_classes[1] == "gl-level-critical", f"a timeout is critical: {status_classes!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_ports_collection_is_hidden():
"""The TUI returns [] for an empty list, so the block is not painted."""
payload = _run_render_probe("ports-empty")
assert payload["pluginHidden"].get("ports") is True, f"got {payload['pluginHidden']!r}"
# ---------------------------------------------------- folders (G9-7 Task 4)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_folders_renders_its_title_and_an_empty_size_header():
"""G9-7 D4: the TUI's FOLDERS line has no size label, and the empty <th>
keeps the header aligned column by column with the body (the sensors
precedent)."""
payload = _run_render_probe("folders")
assert payload["pluginColumnHeaders"]["folders"] == ["FOLDERS", ""], f"got {payload['pluginColumnHeaders']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_folders_renders_its_sizes():
payload = _run_render_probe("folders")
texts = [cell["text"] for cell in payload["pluginTableCells"]["folders"]]
assert texts[1] == "125.0M", f"got {texts!r}"
assert texts[3] == "17.0G", f"got {texts!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_folders_marks_an_unreadable_folder_bold_and_untiered():
"""errno != 0 -> a "?" prefix and curses.A_BOLD with NO colour pair. The
model emits no _levels entry for it, so a tier class here would mean the
component invented one."""
payload = _run_render_probe("folders")
cells = payload["pluginTableCells"]["folders"]
assert cells[5]["text"].startswith("?"), f"got {cells[5]!r}"
assert cells[5]["value"] == "gl-strong", f"bold, no tier: {cells[5]!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_folders_renders_a_row_with_no_usable_path():
"""folders/render_curses_v5.py:93-94,103 keeps every dict item and
coalesces a missing/falsy path to "" -- it never drops the row. A
component filtering rows on `item.path` would silently swallow this one,
so this asserts the row COUNT and the empty name cell, not text that
would also pass if the row were merely renamed.
"""
payload = _run_render_probe("folders")
cells = payload["pluginTableCells"]["folders"]
assert len(cells) == 8, f"expected 4 rows x 2 cells, got {cells!r}"
assert cells[6]["text"] == "", f"the no-path row's name cell must be empty, not 'undefined': {cells[6]!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_folders_truncates_a_long_path_from_the_start():
"""The TUI keeps the tail ("_" + path[-23:]), so the ellipsis falls at the
start; the <bdi> is load-bearing (without it the bidi algorithm moves the
leading "/" to the end)."""
payload = _run_render_probe("folders")
cells = payload["pluginNameCells"]["folders"]
assert all(cell["hasBdi"] for cell in cells), f"got {cells!r}"
assert all("gl-truncate-start" in cell["className"] for cell in cells), f"got {cells!r}"
assert cells[1]["title"] == "/home/nicolargo/media/library/Videos"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_folders_collection_is_hidden():
payload = _run_render_probe("folders-empty")
assert payload["pluginHidden"].get("folders") is True, f"got {payload['pluginHidden']!r}"
# ------------------------------------------------ connections (G9-7 Task 5)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_connections_renders_the_tui_rows_in_order():
"""connections/render_curses_v5.py:80-100 -- the title, then the four state
counters in the TUI's fixed order, then Tracked as `count/max`. The labels
come from the schema (G9-7 D5), so this also proves the component reads
/api/5/all/info rather than hardcoding them.
"""
payload = _run_render_probe("connections")
grid = payload["pluginGrid"]["connections"]
assert grid == [
[
["TCP CONNECTIONS", ""],
["Listen", "3"],
["Initiated", "0"],
["Established", "12"],
["Terminated", "204"],
["Tracked", "512/1024"],
]
], f"got {grid!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_connections_colours_only_the_tracked_row():
"""The Tracked row is the only coloured one, from nf_conntrack_percent."""
payload = _run_render_probe("connections")
classes = payload["pluginValueClasses"]["connections"]
assert classes[-1] == "gl-level-careful", f"got {classes!r}"
assert set(classes[:-1]) == {""}, f"no other row may be coloured: {classes!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_connections_without_conntrack_drops_the_tracked_row():
payload = _run_render_probe("connections-no-conntrack")
labels = [pair[0] for pair in payload["pluginGrid"]["connections"][0]]
assert labels == ["TCP CONNECTIONS", "Listen", "Initiated", "Established", "Terminated"], f"got {labels!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_connections_without_net_connections_keeps_only_tracked():
payload = _run_render_probe("connections-no-net")
labels = [pair[0] for pair in payload["pluginGrid"]["connections"][0]]
assert labels == ["TCP CONNECTIONS", "Tracked"], f"got {labels!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_connections_with_both_probes_off_is_hidden():
"""The TUI returns [] when neither probe is enabled."""
payload = _run_render_probe("connections-off")
assert payload["pluginHidden"].get("connections") is True, f"got {payload['pluginHidden']!r}"
# -------------------------------------------------------- irq (G9-7 Task 5)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_irq_keeps_the_five_busiest_lines_ranked():
"""Ranking lives in the renderer, not the model: model_v5 publishes every
line (a v4 divergence made for exporters), and the TUI sorts by rate
descending and keeps five (irq/render_curses_v5.py:48-58). A null rate
sorts last instead of throwing.
"""
payload = _run_render_probe("irq")
texts = [cell["text"] for cell in payload["pluginTableCells"]["irq"]]
assert texts == [
"RES",
"501",
"LOC",
"340",
"1_i8042",
"95",
"0",
"12",
"CAL",
"7",
], f"got {texts!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_irq_labels_its_rate_column_from_the_schema():
payload = _run_render_probe("irq")
assert payload["pluginColumnHeaders"]["irq"] == ["IRQ", "Rate/s"], f"got {payload['pluginColumnHeaders']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_irq_collection_is_hidden():
payload = _run_render_probe("irq-empty")
assert payload["pluginHidden"].get("irq") is True, f"got {payload['pluginHidden']!r}"
# ------------------------------------------------------- raid (G9-7 Task 6)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_raid_groups_each_array_with_its_sub_lines():
"""G9-7 D3: one <tbody> per array, sub-lines inside it, the TUI's glyphs
kept. Arrays are sorted by name as strings (raid/render_curses_v5.py:96),
which puts md12 before md4.
md12 is inactive AND degraded: both sub-line groups are emitted, in that
order -- they are not exclusive, and a component that treated them as an
if/else would drop the second.
"""
payload = _run_render_probe("raid")
groups = payload["pluginRowGroups"]["raid"]
assert groups == [
[["RAID1 md0", "2", "2"]],
[
["RAID1 md12", "", ""],
["└─ Status inactive"],
[" ├─ disk 0: sde1"],
[" └─ disk 1: sdf1"],
["└─ Degraded mode"],
[" └─ UA"],
],
[["RAID5 md4", "2", "3"], ["└─ Degraded mode"], [" └─ UUA"]],
[["RAID0 md9", "2", "-"]],
[["UNKNOWN md99", "2", "2"]],
], f"got {groups!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_raid_renders_unknown_for_a_null_type():
"""v4/raid/render_curses_v5.py:50: a null `type` renders the literal
"UNKNOWN" rather than an empty or missing type string. `md99` (type:
null) exercises exactly this branch and nothing else -- active, no
sub-lines -- so this test names the behaviour on its own instead of
leaving it buried in the long list above.
"""
payload = _run_render_probe("raid")
groups = payload["pluginRowGroups"]["raid"]
titles = [group[0][0] for group in groups]
assert "UNKNOWN md99" in titles, f"got {titles!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_raid_labels_its_columns_from_the_schema():
payload = _run_render_probe("raid")
assert payload["pluginColumnHeaders"]["raid"] == ["RAID disks", "Used", "Avail"], (
f"got {payload['pluginColumnHeaders']!r}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_raid_colours_the_values_and_the_status_lines_from_levels():
"""The tier reaches the DOM as a class on the value <span>, never on the
<td> -- a prominent badge's background would otherwise fill the whole cell.
`pluginTableCells[i]["value"]` is that span's class list;
`pluginValueClasses` would give the cell's ("gl-num") and prove nothing.
"""
payload = _run_render_probe("raid")
spans = [cell["value"] for cell in payload["pluginTableCells"]["raid"]]
assert any("gl-level-critical" in (span or "") for span in spans), f"md12 must be critical: {spans!r}"
assert any("gl-level-warning" in (span or "") for span in spans), f"md4 must be warning: {spans!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_raid_collection_is_hidden():
"""v4 parity, and Task 3's TUI fix: no array, no block -- not a bare
"RAID disks Used Avail" header."""
payload = _run_render_probe("raid-empty")
assert payload["pluginHidden"].get("raid") is True, f"got {payload['pluginHidden']!r}"
# ------------------------------------------------------ smart (G9-7 Task 6)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_smart_groups_each_device_with_its_attributes():
"""G9-7 D3 and smart/render_curses_v5.py: a device row, then one row per
attribute -- the name indented by the TUI's leading space, underscores
rendered as spaces, the raw value right-aligned. A LARGE_VALUE_KEYS raw
goes through auto_unit() (5307033647 -> "4.94G"); everything else is
printed as-is; a null raw renders an empty cell. The fixture carries a
second device with its own attributes, so this also proves the grouping
is per-device rather than one flat table.
"""
payload = _run_render_probe("smart")
groups = payload["pluginRowGroups"]["smart"]
assert groups == [
[
["/dev/sda Samsung SSD 850"],
[" Power On Hours", "12345"],
[" Reallocated Sector Ct", "0"],
[" Data Units Written", "4.94G"],
[" Unknown Attribute", ""],
],
[
["/dev/sdb Crucial MX500"],
[" Bytes Read", "2.05G"],
[" Power Cycle Count", "87"],
],
], f"got {groups!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_smart_never_colours_a_cell():
"""v4 `smart` is display-only: EMITS_ALERTS is False and no field is
watched, so no cell may carry a tier class."""
payload = _run_render_probe("smart")
spans = [cell["value"] for cell in payload["pluginTableCells"]["smart"]]
assert not any("gl-level-" in (span or "") for span in spans), f"smart must not colour anything: {spans!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_smart_collection_is_hidden():
payload = _run_render_probe("smart-empty")
assert payload["pluginHidden"].get("smart") is True, f"got {payload['pluginHidden']!r}"
# -------------------------------------------------------- mpp (G9-8 Task 3)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_mpp_renders_one_row_per_engine():
"""mpp/render_curses_v5.py:38-57 — name + type, the load as a percentage
or N/A, and the session cell ONLY when the count is non-zero (v4 omits it
entirely at zero, it does not render "0 sess").
"""
payload = _run_render_probe("mpp")
texts = [cell["text"] for cell in payload["pluginTableCells"]["mpp"]]
assert texts == [
"RKVENC enc",
"24.8%",
"2 sess",
"JPEGD jpeg",
"0.0%",
"RKVDEC dec",
"N/A",
], f"got {texts!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_mpp_colours_only_the_load():
payload = _run_render_probe("mpp")
spans = [cell["value"] for cell in payload["pluginTableCells"]["mpp"]]
assert "gl-level-careful" in (spans[1] or ""), f"got {spans!r}"
assert not any("gl-level-" in (s or "") for s in (spans[0], spans[2])), f"only the load: {spans!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_mpp_collection_is_hidden():
payload = _run_render_probe("mpp-empty")
assert payload["pluginHidden"].get("mpp") is True, f"got {payload['pluginHidden']!r}"
# -------------------------------------------------------- npu (G9-8 Task 3)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_npu_renders_the_first_device_only():
"""v4 parity (npu/render_curses_v5.py:40-43): the block shows ONE NPU, the
first, whatever the payload carries. The fixture holds two."""
text = _run_render_probe("npu")["pluginText"]["npu"]
assert "Second NPU" not in text, f"only the first NPU may render: {text!r}"
assert "45" in text and "1.0G/2.0GHz" in text, f"got {text!r}"
assert "mem" in text and "N/A" in text, f"a null mem renders N/A: {text!r}"
assert "temperature" in text and "55C" in text, f"got {text!r}"
assert ":" not in text, f"labels carry no trailing colon: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_npu_truncates_the_name_to_the_tui_width():
"""`name[:17]` in the TUI (_HEADER_MAX); the browser caps the cell and
keeps the full text in `title`."""
cells = _run_render_probe("npu")["pluginNameCells"]["npu"]
assert cells and cells[0]["title"].startswith("Intel NPU 3720"), f"got {cells!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_npu_without_a_load_shows_the_frequency_percentage():
"""npu/render_curses_v5.py:47-54 — no load, so the cell shows the FREQ
percentage, coloured from the `freq` level rather than the `load` one.
"""
payload = _run_render_probe("npu-no-load")
assert "80" in payload["pluginText"]["npu"], f"got {payload['pluginText']['npu']!r}"
assert any("gl-level-warning" in (c or "") for c in payload["pluginValueClasses"]["npu"]), (
f"the freq tier must colour the cell: {payload['pluginValueClasses']['npu']!r}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_npu_honours_fahrenheit():
"""Same payload as `npu`, `--fahrenheit` on the server: 55C -> 131F."""
text = _run_render_probe("npu-fahrenheit")["pluginText"]["npu"]
assert "131F" in text, f"got {text!r}"
assert "55C" not in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_npu_collection_is_hidden():
payload = _run_render_probe("npu-empty")
assert payload["pluginHidden"].get("npu") is True, f"got {payload['pluginHidden']!r}"
# ----------------------------------------------------- percpu (G9-8 Task 4)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_percpu_renders_the_transposed_grid_standalone():
"""percpu/render_curses_v5.py -- columns are stats, rows are cores, sorted
by `total` descending. Standalone (no quicklook instantiated) the block
keeps its CPU title, its `total` column and its row labels.
Asserts the FULL header list, not just its first two cells (final
review, Important 3): the fixture's `stat_fields` is a SUBSET of the raw
core's numeric keys, in the TUI's order (`_os_headers()`), not the
payload's own key order -- the narrower assertion this replaces could not
have told the two apart, which is how the component reading the wrong
source went unnoticed.
"""
payload = _run_render_probe("percpu")
assert payload["pluginColumnHeaders"]["percpu"] == [
"CPU",
"total",
"user",
"system",
"iowait",
"idle",
"irq",
"nice",
"steal",
"guest",
], f"got {payload['pluginColumnHeaders']['percpu']!r}"
names = [cell["text"] for cell in payload["pluginNameCells"]["percpu"]]
assert names == ["CPU0", "CPU1", "CPU2", "CPU3", "CPU*"], f"got {names!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_percpu_stays_hidden_by_default_even_when_quicklook_is_instantiated():
"""Interaction between the two Critical fixes of the final review:
quicklook being instantiated does NOT, by itself, bring percpu onto the
WebUI screen -- `AppShell.vue`'s cpu/percpu exclusivity gate (Critical 1)
hides `percpu` whenever `serverArgs.percpu` is falsy, REGARDLESS of
quicklook. `percpu-with-quicklook` has quicklook instantiated and no
`--percpu`; this is also why PluginPercpu.vue's own Critical 2 predicate
(`standalone` unless quicklook is instantiated AND `serverArgs.percpu` is
set) is not independently observable through the full AppShell-driven
stack: percpu can only ever be VISIBLE here when `serverArgs.percpu` is
already true, at which point Critical 2's AND-clause is trivially
satisfied. The genuine regression guard for Critical 2 lives at the TUI
level (test_curses_renderer_v5.py::
test_percpu_keeps_its_labels_when_quicklook_is_instantiated_but_not_drawing_percore),
where no such shell-level gate exists. See
test_percpu_drops_title_total_and_labels_when_quicklook_draws_percore for
the one combination that IS reachable here.
"""
payload = _run_render_probe("percpu-with-quicklook")
assert "percpu" not in payload["pluginNames"], f"got {payload['pluginNames']!r}"
assert "cpu" in payload["pluginNames"], f"vacuous: {payload['pluginNames']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_percpu_drops_title_total_and_labels_when_quicklook_draws_percore():
"""v4 parity, mirrored from the TUI: quicklook, instantiated AND actually
drawing its per-core bars (--percpu), already shows the per-core totals,
so percpu stops repeating them. The WebUI learns "instantiated" from
/api/5/pluginslist (the same notion build_frame derives from the
instantiated plugins) and "drawing per-core bars" from
`serverArgs.percpu` (/api/5/args) -- both halves of the corrected
predicate (final review, Critical 2).
"""
payload = _run_render_probe("percpu-with-quicklook-percpu")
headers = payload["pluginColumnHeaders"]["percpu"]
assert "CPU" not in headers and "total" not in headers, f"got {headers!r}"
assert "percpu" not in payload["pluginNameCells"], f"no row labels: {payload['pluginNameCells'].get('percpu')!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_percpu_honours_the_configured_core_cap():
"""The test that makes `[percpu] max_cpu_display` non-inert in the browser:
with a cap of 2, four cores collapse into the mean row."""
payload = _run_render_probe("percpu-cap-2")
names = [cell["text"] for cell in payload["pluginNameCells"]["percpu"]]
assert names == ["CPU0", "CPU1", "CPU*"], f"got {names!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_percpu_never_colours_a_cell():
"""v5 percpu publishes no field-level alert (its model docstring says so);
the system-wide `cpu` plugin is the source of CPU alerts."""
payload = _run_render_probe("percpu")
spans = [cell["value"] for cell in payload["pluginTableCells"]["percpu"]]
assert not any("gl-level-" in (s or "") for s in spans), f"got {spans!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_percpu_collection_is_hidden():
payload = _run_render_probe("percpu-empty")
assert payload["pluginHidden"].get("percpu") is True, f"got {payload['pluginHidden']!r}"
# ------------------------------------- cpu/percpu mutual exclusion (final review, Critical 1)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_percpu_renders_and_cpu_does_not_when_the_server_ran_with_percpu():
"""The v5 TUI shows exactly one of `cpu`/`percpu` in the TOP row
(glances_curses_v5.py:565-567: `hidden_top = "cpu" if
self._view.show_percpu else "percpu"`, applied on every frame). The
WebUI had no equivalent -- both rendered side by side, a duplicated CPU
surface. `AppShell.vue`'s `slots()` now hides `cpu` when the server ran
with `--percpu` (the best available server-side signal, since
`show_percpu` is a TUI-only runtime toggle with no wire representation).
Both plugins are instantiated and carry data in this scenario -- only
/api/5/args differs from the "off" scenario below.
"""
payload = _run_render_probe("cpu-percpu-on")
top = payload["slots"].get("top", [])
assert "percpu" in top, f"got {top!r}"
assert "cpu" not in top, f"got {top!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cpu_renders_and_percpu_does_not_without_percpu():
"""The other half: no `--percpu`, so the aggregate `cpu` block renders
and `percpu` does not -- the default screen, and also the fix for the
"quicklook disappears at 1600px" observation from the group's
verification report (at that width the terminal has no `percpu` block
competing for the row at all, because the WebUI, unlike the TUI, was
rendering both).
"""
payload = _run_render_probe("cpu-percpu-off")
top = payload["slots"].get("top", [])
assert "cpu" in top, f"got {top!r}"
assert "percpu" not in top, f"got {top!r}"
# -------------------------------------------------- quicklook (G9-8 Task 5)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_quicklook_renders_one_bar_per_stats_list_entry_in_order():
"""`[quicklook] list` drives the selection AND the order
(quicklook/render_curses_v5.py:161-175). The fixture asks for cpu, mem,
load — `swap` is in the payload and must NOT render.
"""
payload = _run_render_probe("quicklook")
labels = payload["barLabels"]["quicklook"]
assert labels == ["CPU", "MEM", "LOAD"], f"got {labels!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_bars_width_is_its_percentage_and_its_colour_is_its_tier():
"""D1: the fill's width IS the value, and the tier reaches it as a class
(the CSS turns that into `background: currentColor`). A bar drawn at a
fixed width, or coloured from the aggregate instead of its own field,
would pass a text-only assertion.
"""
payload = _run_render_probe("quicklook")
bars = payload["bars"]["quicklook"]
assert bars[0]["width"] == "45%", f"got {bars[0]!r}"
assert "gl-level-careful" in bars[0]["fillClass"], f"got {bars[0]!r}"
assert "gl-level-warning" in bars[1]["fillClass"], f"mem is warning: {bars[1]!r}"
assert bars[0]["role"] == "progressbar" and bars[0]["valuenow"] == "45", (
f"a bar is a progress indicator for assistive tech: {bars[0]!r}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_no_quicklook_bar_is_prominent_with_the_real_fixture():
"""The real schema (quicklook/model_v5.py) ships `prominent: false` on
every bar-selectable field (G9-8 smoke fix 2: the filled badge was
rejected after a smoke test), and `QUICKLOOK_FIXTURE._levels` mirrors
that -- no bar value should carry the badge class.
"""
payload = _run_render_probe("quicklook")
for bar in payload["bars"]["quicklook"]:
assert "gl-prominent" not in bar["valueClass"], f"got {bar!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_prominent_bar_value_still_renders_its_badge():
"""`levelClass()` must still honour a `prominent: true` `_levels` entry
when given one -- the schema is the single source of truth, not a
hard-coded False in the component (G9-8 smoke fix 2). `cpu` is prominent
in the `quicklook-prominent` fixture only."""
payload = _run_render_probe("quicklook-prominent")
assert "gl-prominent" in payload["bars"]["quicklook"][0]["valueClass"], f"got {payload['bars']['quicklook'][0]!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_quicklook_renders_its_cpu_name_and_frequency_header():
text = _run_render_probe("quicklook")["pluginText"]["quicklook"]
assert "Intel Core i7-9750H" in text, f"got {text!r}"
assert "2.60/4.50GHz" in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_quicklook_header_is_not_a_paragraph():
"""The CPU name/frequency header is quicklook's only NORMAL-state content
rendered outside a scalar `.gl-stat-grid` -- every other component's `<p>`
is a transient loading/error state, so `css/v5.css` never zeroes a `<p>`
margin. A `<p class="gl-inline">` here keeps the browser's default
`margin: 1em 0` and pushes the whole block one line down (G9-8 smoke fix
1); the header must be a plain `<div>` (or another margin-less tag)
instead.
"""
tags = _run_render_probe("quicklook")["pluginInlineTags"]["quicklook"]
assert "P" not in tags, f"got {tags!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_quicklook_without_a_current_frequency_has_no_header():
"""`cur is None -> return None` (render_curses_v5.py:115-117): no header
row at all, not an empty one."""
text = _run_render_probe("quicklook-no-freq")["pluginText"]["quicklook"]
assert "GHz" not in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_quicklook_per_core_replaces_the_cpu_bar_and_caps_with_a_mean_row():
"""With the server's --percpu, the `cpu` bar is REPLACED by one bar per
core (render_curses_v5.py:168-170), capped by max_cpu_display (2 here),
sorted by total descending, plus the CPU* row whose value comes from
`percpu_other` — not from the displayed cores.
"""
payload = _run_render_probe("quicklook-percpu")
labels = payload["barLabels"]["quicklook"]
assert labels == ["CPU0", "CPU1", "CPU*", "MEM", "LOAD"], f"got {labels!r}"
bars = {b["label"]: b for b in payload["bars"]["quicklook"]}
assert bars["CPU0"]["width"] == "90%", f"got {bars['CPU0']!r}"
assert "gl-level-critical" in bars["CPU0"]["fillClass"], (
f"each core takes ITS OWN level, not the aggregate: {bars['CPU0']!r}"
)
assert bars["CPU*"]["width"] == "15%", f"the mean of the HIDDEN cores: {bars['CPU*']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_quicklook_renames_the_two_gpu_bar_labels():
"""`gpu_mem` -> GMEM and `gpu_proc` -> GPU (_BAR_LABEL): the raw upper-cased
keys are 7 chars and break the TUI's grid, so both surfaces use the short
form."""
labels = _run_render_probe("quicklook-gpu")["barLabels"]["quicklook"]
assert labels == ["CPU", "MEM", "GMEM", "GPU"], f"got {labels!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_quicklook_payload_is_hidden():
payload = _run_render_probe("quicklook-empty")
assert payload["pluginHidden"].get("quicklook") is True, f"got {payload['pluginHidden']!r}"
# ------------------------------------------- full_quicklook (G9-8 Task 6)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_full_quicklook_hides_every_top_block_but_quicklook():
"""--full-quicklook hides EVERY TOP sibling, `load` and `percpu` included
(curses_renderer_v5.py:91). The WebUI reads the flag from /api/5/args, the
way mem reads --byte, and mirrors the constant in full_quicklook.js --
tests/test_webui_v5_full_quicklook_drift.py fails on drift between the two.
Was "…and spares load": until 2026-09-22 this matched v4's six-plugin set
exactly, leaving `load` and `percpu` on the row. The maintainer widened the
mode (`…decisions.md` §10, "Reversed decision -- full quicklook"), so the
two spared blocks are now hidden like the rest.
"""
payload = _run_render_probe("quicklook-full")
rendered = set(payload["pluginNames"])
for name in ("cpu", "percpu", "npu", "mpp", "gpu", "mem", "memswap", "load"):
assert name not in rendered, f"{name} must be hidden: {sorted(rendered)!r}"
assert "quicklook" in rendered, f"vacuous: {sorted(rendered)!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
@pytest.mark.parametrize(
("scenario", "name", "labels"),
[
("memswap-unavailable", "memswap", ("SWAP", "total", "sin", "sout")),
("load-unavailable", "load", ("LOAD", "1 min", "5 min", "15 min")),
],
)
def test_a_scalar_published_without_stats_renders_dashes_not_a_shape_error(scenario, name, labels):
"""memswap/load publish only their metadata when the grab fails (no swap
on OpenBSD/Illumos, getloadavg() OSError). The TUI shows dashes; the WebUI
showed a permanent red "unexpected shape: missing total/min1"."""
payload = _run_render_probe(scenario)
text = payload["pluginText"].get(name, "")
assert "unexpected shape" not in text, text
for label in labels:
assert label in text, f"expected {label!r} in {text!r}"
assert "-" in text, text
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_percpu_keeps_title_total_and_labels_when_the_cascade_hides_quicklook():
"""Twin of the TUI fix in curses_renderer_v5.build_frame: a quicklook the
width cascade removed (`hide_quicklook`) is not on screen, so percpu must
not drop the per-core totals it would otherwise repeat."""
payload = _run_render_probe("percpu-quicklook-cascaded-out")
assert payload["degrade"]["top"].get("hide_quicklook") is True, f"guard: {payload['degrade']!r}"
assert "quicklook" not in payload["pluginNames"], f"got {payload['pluginNames']!r}"
headers = payload["pluginColumnHeaders"]["percpu"]
assert "CPU" in headers and "total" in headers, f"got {headers!r}"
assert payload["pluginNameCells"].get("percpu"), "row labels must survive"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_cpu_shows_dpc_in_place_of_iowait_on_a_windows_payload():
"""Twin of test_render_windows_payload_shows_dpc_in_place_of_iowait (TUI)."""
text = _run_render_probe("cpu-windows")["pluginText"].get("cpu", "")
assert "user" in text, text
assert "dpc" in text, f"dpc takes the iowait row: {text!r}"
assert "iowait" not in text, text
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_footer_no_longer_renders_a_raw_alert_list():
"""G9-9B: the footer's raw event list (this test used to pin a resolution
event reading as "fs /home percent — ok" rather than an alert) moved into
PluginAlert.vue, fed by the already-collapsed /api/5/alert/incidents --
the footer keeps only the refresh cadence. The resolved-vs-ongoing
distinction this test guarded is now
test_only_an_ongoing_incident_colours_its_level's job."""
payload = _run_render_probe("alert-resolved")
assert payload["footerAlerts"] == [], f"expected no <li> in the footer, got {payload['footerAlerts']!r}"
# ------------------------------------------------- processcount TUI parity (G9-9A Task 1)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processcount_renders_the_tui_tasks_line():
"""processcount/render_curses_v5.py:73-113 paints ONE line:
`TASKS 215 (1452 thr), 3 run, 195 slp, 17 oth`. `oth` is computed
(total - running - sleeping), so 17 proves the arithmetic and not a
field read.
"""
payload = _run_render_probe("processcount")
text = " ".join((payload["pluginText"].get("processcount") or "").split())
assert text == "TASKS 215 (1452 thr), 3 run, 195 slp, 17 oth", f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processcount_drops_the_thread_group_when_psutil_has_no_count():
"""`thread` is None on some systems (issue #1463): the TUI emits
`TASKS 215, 3 run, …` -- the comma moves onto the total."""
payload = _run_render_probe("processcount-no-thread")
text = " ".join((payload["pluginText"].get("processcount") or "").split())
assert text == "TASKS 215, 3 run, 195 slp, 17 oth", f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processcount_shows_only_its_title_before_the_first_aggregate():
"""No `total` yet (scheduler cycle 0) -> the title alone. A component
that defaulted the aggregates to 0 would render `TASKS 0, 0 run…`,
which the TUI explicitly avoids."""
payload = _run_render_probe("processcount-empty")
text = " ".join((payload["pluginText"].get("processcount") or "").split())
assert text == "TASKS", f"got {text!r}"
# ------------------------- processcount TASKS counter + sort indicator (G9-9B Task 8)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processcount_shows_the_truncation_counter_when_the_cap_cuts_the_list():
"""`_count_text` (processcount/render_curses_v5.py:37-55), browser
equivalent: `[outputs] max_processes_display=30` (CONFIG_FIXTURES) is
below PROCESSCOUNT_FIXTURE's total (215), so the cap actually cuts the
list and the TASKS line must show `30/215`, not the bare total."""
payload = _run_render_probe("processcount-cut")
text = " ".join((payload["pluginText"].get("processcount") or "").split())
assert text == "TASKS 30/215 (1452 thr), 3 run, 195 slp, 17 oth", f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processcount_shows_the_bare_total_when_the_cap_does_not_cut_the_list():
"""No CONFIG_FIXTURES entry for "processcount" -> no cap -> "215" alone,
exactly test_processcount_renders_the_tui_tasks_line's existing
assertion -- this test names the "not cut" half explicitly."""
payload = _run_render_probe("processcount")
text = " ".join((payload["pluginText"].get("processcount") or "").split())
assert "30/215" not in text, f"got {text!r}"
assert "215" in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processcount_omits_the_counter_in_the_programs_view_even_when_cut():
"""`_count_text`'s own guard: never applied in the programs view, because
`total` counts PROCESSES while the list below shows PROGRAMS -- the
ratio would compare two different things. Same cap (30) as the cut
scenario above, only `serverArgs.programs` differs."""
payload = _run_render_probe("processcount-cut-programs")
text = " ".join((payload["pluginText"].get("processcount") or "").split())
assert "30/215" not in text, f"got {text!r}"
assert "215" in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processcount_sort_indicator_reads_threads_in_the_default_view():
"""`_sort_indicator_cell` (processcount/render_curses_v5.py:58-70):
`serverArgs.sort_processes_key` is the only half of its input the
browser can honestly read (see PluginProcesscount.vue's
`sortIndicatorText` comment for why `auto_sort` is not reproduced) --
when a key WAS passed on the CLI, main_v5.py:423-424 always applies it
with `auto=False`, so "sorted by X", never "automatically", is always
correct in that case."""
payload = _run_render_probe("processcount-sorted-threads")
text = " ".join((payload["pluginText"].get("processcount") or "").split())
assert "Threads sorted by CPU consumption" in text, f"got {text!r}"
assert "automatically" not in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processcount_sort_indicator_reads_programs_in_the_programs_view():
"""Same indicator, `serverArgs.programs` true: the prefix switches from
`Threads` to `Programs` (processcount/render_curses_v5.py:64), and the
sort key's human label follows `sort_for_human` (glances/processes.py)
-- `memory_percent` -> "memory consumption"."""
payload = _run_render_probe("processcount-sorted-programs")
text = " ".join((payload["pluginText"].get("processcount") or "").split())
assert "Programs sorted by memory consumption" in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processcount_shows_no_sort_indicator_without_a_sort_key():
"""No `sort_processes_key` in ARGS_FIXTURES for "processcount" -- absent
means `{}` -- so the engine's sort key is unknown to the browser (it may
be auto-sorting) and the indicator must be entirely absent, matching the
TUI's own "no view supplied" branch (render_curses_v5.py:61-62)."""
payload = _run_render_probe("processcount")
text = " ".join((payload["pluginText"].get("processcount") or "").split())
assert "sorted" not in text, f"got {text!r}"
# ------------------------------------------------------- amps TUI parity (G9-9A Task 2)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_amps_renders_name_count_and_result_without_a_header_row():
"""amps/render_curses_v5.py has NO title row and NO column header
(module docstring, v4 parity), like `ports` in G9-7 -- so the block
renders no <thead> at all.
"""
payload = _run_render_probe("amps")
rows = _table_rows(payload, "amps", 3)
assert rows == [
["Python", "2", "CPU: 1.0% | MEM: 2.0%"],
["Systemd", "1", "Services\nactive: 3"],
["Kernel", "", "up"],
], f"got {rows!r}"
assert not payload["pluginHeaderCells"].get("amps"), (
f"amps must render no header row, got {payload['pluginHeaderCells'].get('amps')!r}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_amps_skips_an_amp_that_has_produced_nothing():
"""`result is None` -> v4 renders no row at all
(amps/render_curses_v5.py:70-73). `Dropped` must be absent, and its
count (4) must not appear anywhere in the block.
"""
payload = _run_render_probe("amps")
text = payload["pluginText"].get("amps") or ""
assert "Dropped" not in text, f"got {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_amps_badges_the_count_tier_on_the_name():
"""The TUI colours the NAME cell from `_levels[name].count`
(amps/render_curses_v5.py:78-79), not the count cell. Same convention as
test_a_table_value_carries_its_tier_on_the_text_not_the_cell (the tier
goes on the value span, never the <td>), but not folded into that
parametrization: unlike every case there, amps' badged column (NAME) is
text, not numeric, so its <td> carries no `gl-num` layout class to
assert on.
"""
payload = _run_render_probe("amps")
cell = payload["pluginTableCells"]["amps"][0] # Python's name cell
assert _tier_classes(cell["value"]) == {"gl-level-warning", "gl-prominent"}, f"got {cell!r}"
assert _tier_classes(cell["cell"]) == set(), f"the <td> itself carries no tier class: {cell!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_amps_collection_is_hidden():
"""The TUI returns [] for an empty collection, so the block is not
painted -- the G9-7 rule for ports/folders/irq/raid/smart."""
payload = _run_render_probe("amps-empty")
assert payload["pluginHidden"].get("amps") is True, f"got {payload['pluginHidden']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_amps_height_the_solver_sees_excludes_the_phantom_header_and_null_results():
"""AppShell.vue's `ampsHeight()` used to read `1 + rows.reduce(...)` over
the RAW payload -- a phantom header row amps/render_curses_v5.py never
paints (module docstring: "NO TITLE ROW and no column header"), plus one
phantom row per AMP whose `result` is still `None`, which
amps/render_curses_v5.py:67-71 skips entirely rather than rendering
empty. AMPS_FIXTURE (scenario "amps") has one such null-result item
(`Dropped`) and one two-line item (`Systemd`): its correct height is 4
(Python 1 + Systemd 2 + Kernel 1), not the old buggy 6 (1 header +
Python 1 + Systemd 2 + Dropped 1 + Kernel 1) -- a difference this test
cannot observe directly (the solver's `ampsHeight` never reaches the
DOM), so it reads it off `rowBudget.processlist` instead: at this
scenario's bodyHeight (24 rows) the solver's growth branch gives
processlist 14 rows with the correct height and 12 with the buggy one,
confirmed by calling row_budget.js's `planRightColumn()` directly rather
than predicted (see task-11-report.md).
"""
payload = _run_render_probe("budget-amps-height-defect")
assert "amps" not in payload["rowBudget"], f"the growth branch never sets an amps quota: {payload['rowBudget']!r}"
assert payload["rowBudget"]["processlist"] == 14, f"got {payload['rowBudget']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_truncated_amps_block_keeps_a_marker_line():
"""Ladder step j truncates amps to what is left and keeps at least the
`+N lines` marker (amps/render_curses_v5.py:98-105: "The marker consumes
the last budgeted line, so `budget` rows are emitted in total."). The
browser renders a multi-line result in ONE `pre-line` cell (G9-9A spec
D6), so PluginAmps.vue clamps LINES within a cell rather than dropping
whole rows -- "budget-amps-truncated" gives the solver a natural amps
height of 10 (five two-line AMPS) and a bodyHeight of 8, which
`planRightColumn()` truncates to `rowBudget.amps == 4` (confirmed by
calling it directly, not predicted): 3 content lines plus the marker.
"""
payload = _run_render_probe("budget-amps-truncated")
assert payload["rowBudget"]["amps"] == 4, f"got {payload['rowBudget']!r}"
rows = payload["pluginRowGroups"]["amps"][0]
text = rows[-1][-1]
assert text.rstrip().endswith("lines"), f"got {text!r}"
assert text.strip() == "… +7 lines", f"got {text!r}"
# -------------------------------------------------------- vms TUI parity (G9-9A Task 3)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_vms_renders_the_tui_columns_and_rows():
"""vms/render_curses_v5.py:83-155. Engine shown (two engines), LOAD
shown (load_1min present), MEM and MAX in ONE cell, a missing value as
`-` (spec divergence 5, `format.js` MISSING) -- never `_`.
"""
payload = _run_render_probe("vms")
assert payload["pluginHeaderCells"].get("vms") == [
"Engine",
"Name",
"Status",
"Core",
"CPU%",
"MEM/MAX",
"LOAD 1/5/15min",
"Release",
], f"got {payload['pluginHeaderCells'].get('vms')!r}"
rows = _table_rows(payload, "vms", 8)
assert rows == [
["virsh", "builder", "running", "4", "12.5%", "2.00G/4.00G", "0.5/0.7/1.2", "24.04"],
["multipass", "sandbox", "stopped", "2", "-", "-/-", "0.0/0.0/0.0", "-"],
], f"got {rows!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_vms_hides_engine_and_load_when_the_data_makes_them_irrelevant():
"""One distinct engine -> no Engine column
(vms/render_curses_v5.py:157). No `load_1min` -> no LOAD column
(:162). Both are data-driven, never width-driven.
"""
payload = _run_render_probe("vms-one-engine")
assert payload["pluginHeaderCells"].get("vms") == [
"Name",
"Status",
"Core",
"CPU%",
"MEM/MAX",
"Release",
], f"got {payload['pluginHeaderCells'].get('vms')!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_vms_colours_cpu_and_memory_from_levels_and_never_the_status():
"""CPU% takes `cpu_time`'s tier, MEM/MAX takes `memory_percent`'s, and
`status` keeps its own mapping -- the TUI never reads `_levels` for it
(vms/render_curses_v5.py `_status_role`). Tiers are observable only on
the value <span> inside each <td> (`pluginValueClasses` holds each
<td>'s OWN class -- see `_tier_classes` and
`test_a_table_value_carries_its_tier_on_the_text_not_the_cell`), so this
reads `pluginTableCells` like the fs/amps tier tests do. Row 0
(builder): engine=0, name=1, status=2, core=3, cpu%=4, mem/max=5,
load=6, release=7. `builder`'s `_levels` entry carries no `status`
field, so a `status` class here can only have come from the component's
OWN `_STATUS_ROLE` mirror (G9-9A fix wave item 1), never from `_levels`.
"""
payload = _run_render_probe("vms")
cells = payload["pluginTableCells"]["vms"]
assert _tier_classes(cells[4]["value"]) == {"gl-level-careful"}, cells[4]
assert _tier_classes(cells[4]["cell"]) == set(), f"the <td> itself carries no tier class: {cells[4]!r}"
assert _tier_classes(cells[5]["value"]) == {"gl-level-warning"}, cells[5]
assert _tier_classes(cells[5]["cell"]) == set(), f"the <td> itself carries no tier class: {cells[5]!r}"
# "running" -> "ok" in vms's OWN status map, not from `_levels`.
assert _tier_classes(cells[2]["value"]) == {"gl-level-ok"}, cells[2]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_vms_gives_an_unmapped_status_no_colour():
"""`stopped` is not in vms's `_STATUS_ROLE` mirror (only running/
starting/restarting/delayed shutdown are), so row 1 (sandbox, cells[10])
must render with no tier class -- the TUI's `ColorRole.DEFAULT` paints
nothing for an unclassified status, and the WebUI must match."""
payload = _run_render_probe("vms")
cells = payload["pluginTableCells"]["vms"]
assert _tier_classes(cells[10]["value"]) == set(), cells[10]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_vms_collection_is_hidden():
"""`CollectionBlock` hides an empty collection with `v-show`, not
`v-if`, so the <article> stays in the DOM and stays listed in
`slots["right"]` -- `payload["slots"]` cannot observe this (the G9-7
convention, see `test_an_empty_folders_collection_is_hidden`)."""
payload = _run_render_probe("vms-empty")
assert payload["pluginHidden"].get("vms") is True, f"got {payload['pluginHidden']!r}"
# ------------------------------------------------- containers TUI parity (G9-9A Task 4)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_containers_renders_the_tui_columns_and_rows():
"""containers/render_curses_v5.py:124-215. `CONTAINER` IS the title (no
separate title row, G9-6 D6). Engine and Pod are shown because the data
makes them relevant (:264-265); /MAX is unconditional (:298 reads only
`hidden`, never `memory_limit` -- see
`test_containers_shows_max_even_when_no_container_has_a_limit`). Rates
follow `network`'s bit default: 100 B/s -> "800b".
"""
payload = _run_render_probe("containers")
assert payload["pluginHeaderCells"].get("containers") == [
"Engine",
"Pod",
"CONTAINER",
"Status",
"Uptime",
"CPU%",
"MEM",
"/MAX",
"IOR/s",
"IOW/s",
"Rx/s",
"Tx/s",
"Ports",
"Command",
], f"got {payload['pluginHeaderCells'].get('containers')!r}"
rows = _table_rows(payload, "containers", 14)
assert rows[0] == [
"docker",
"pod-7f3a",
"web",
"running",
"2 days",
"12.5%",
"512M",
"/2.00G",
"1024B",
"2KB",
"800b",
"1.6Kb",
"0.0.0.0:80->80/tcp",
"nginx -g daemon off;",
], f"got {rows[0]!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_containers_colours_status_from_its_own_map():
"""G9-9A fix wave item 1: `status` was rendered as plain text with no
class, silently dropping a signal both the TUI (`_STATUS_ROLE`) and the
old v4 WebUI (`getStatusClass`) carry. Row 0 (web) is "running" -> "ok",
row 1 (db) is "paused" -> "careful"; header order gives `status` index 3
of 14 (Engine=0, Pod=1, CONTAINER=2, Status=3), so row 1 is cells[17].
Neither status has a `_levels` entry for `status`, so a class here can
only come from the component's own map, never `_levels`.
"""
payload = _run_render_probe("containers")
cells = payload["pluginTableCells"]["containers"]
assert _tier_classes(cells[3]["value"]) == {"gl-level-ok"}, cells[3]
assert _tier_classes(cells[3]["cell"]) == set(), f"the <td> itself carries no tier class: {cells[3]!r}"
assert _tier_classes(cells[17]["value"]) == {"gl-level-careful"}, cells[17]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_containers_gives_an_unmapped_status_no_colour():
"""`removing` is not in containers's `_STATUS_ROLE` mirror -- the TUI's
`ColorRole.DEFAULT` paints nothing for an unclassified status, and the
WebUI must match."""
payload = _run_render_probe("containers-unmapped-status")
cells = payload["pluginTableCells"]["containers"]
assert _tier_classes(cells[3]["value"]) == set(), cells[3]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_containers_shows_the_placeholder_for_every_missing_value():
"""Spec divergence 5: the WebUI's single placeholder is `-`
(`format.js` MISSING), not the `_` this TUI renderer prints. The `db`
row has no rate, no uptime and no command yet.
"""
payload = _run_render_probe("containers")
row = _table_rows(payload, "containers", 14)[1]
assert "_" not in " ".join(row), f"got {row!r}"
assert row[4] == "-", f"uptime must be the placeholder, got {row!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_containers_honours_disable_stats_from_the_config():
"""`[containers] disable_stats` reaches the browser as payload metadata
(containers/model_v5.py:220-221) and removes columns regardless of
width (spec §5.1)."""
payload = _run_render_probe("containers-disable-stats")
headers = payload["pluginHeaderCells"].get("containers") or []
assert "Ports" not in headers, f"got {headers!r}"
assert "Command" not in headers, f"got {headers!r}"
assert "CPU%" in headers, f"vacuous: the rest must survive: {headers!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_containers_disabling_mem_also_hides_the_max_column():
"""containers/render_curses_v5.py:286-288: `disable_stats=mem` cascades
into `memory_max` too -- `/MAX` is meaningless without `MEM` next to it.
Config-driven, not the width cascade (out of scope for this task)."""
payload = _run_render_probe("containers-disable-mem")
headers = payload["pluginHeaderCells"].get("containers") or []
assert "MEM" not in headers, f"got {headers!r}"
assert "/MAX" not in headers, f"got {headers!r}"
assert "CPU%" in headers, f"vacuous: the rest must survive: {headers!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_containers_shows_max_even_when_no_container_has_a_limit():
"""render_curses_v5.py `show_mem_max` (:298) reads only `hidden` -- the
config's `disable_stats`, the `mem` cascade (:287-288) and the width
cascade (:295, out of scope here). Nothing reads `memory_limit` to gate
the COLUMN; the data only decides what the cell PRINTS (`_cpu_mem_cells`:
"/" + the limit, or the placeholder when absent). So a host where no
container declares a limit still shows `/MAX`, with `-` in every row.
"""
payload = _run_render_probe("containers-no-limits")
headers = payload["pluginHeaderCells"].get("containers") or []
assert "/MAX" in headers, f"got {headers!r}"
rows = _table_rows(payload, "containers", len(headers))
max_index = headers.index("/MAX")
# The cell is "/" + formatAutoUnit(limit); a missing limit renders
# formatAutoUnit's own placeholder ("-"), so the cell reads "/-", not
# a bare "-" -- the "/" prefix is unconditional (PluginContainers.vue).
for row in rows:
assert row[max_index] == "/-", f"got {row!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_empty_containers_collection_is_hidden():
"""`CollectionBlock` hides an empty collection with `v-show`, not
`v-if`, so the <article> stays in the DOM and stays listed in
`slots["right"]` -- `payload["slots"]` cannot observe this (the G9-7
convention, see `test_an_empty_folders_collection_is_hidden`)."""
payload = _run_render_probe("containers-empty")
assert payload["pluginHidden"].get("containers") is True, f"got {payload['pluginHidden']!r}"
# ------------------------------------- containers column cascade (G9-9A Task 6)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_wide_containers_block_keeps_every_column():
"""The cascade starts from NO flag on every pass (degrade.js
`resolveDegrade`), so a block that fits drops nothing -- this is also
what gives the columns back when the window widens."""
payload = _run_render_probe("containers-wide")
headers = payload["pluginHeaderCells"].get("containers") or []
assert "Command" in headers, f"got {headers!r}"
assert "Status" in headers, f"got {headers!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_one_notch_drops_the_command_column_first():
"""`_DROP_ORDER`'s first entry (containers/render_curses_v5.py:60):
`command` goes before anything else -- the deliberate divergence from
processlist, where Command is the protected tail."""
payload = _run_render_probe("containers-one-notch")
headers = payload["pluginHeaderCells"].get("containers") or []
assert "Command" not in headers, f"got {headers!r}"
assert "Ports" in headers, f"only ONE notch was budgeted: {headers!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_narrow_containers_block_keeps_only_the_undroppable_columns():
"""The cascade run to its last step: `name`, `cpu` and `mem` are absent
from `_DROP_ORDER`, so CONTAINER / CPU% / MEM always survive."""
payload = _run_render_probe("containers-narrow")
assert payload["pluginHeaderCells"].get("containers") == [
"CONTAINER",
"CPU%",
"MEM",
], f"got {payload['pluginHeaderCells'].get('containers')!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_unmeasurable_containers_block_keeps_every_column():
"""The `containers` scenario sets no block width, so clientWidth is 0 --
`fits()` reads that as "cannot measure" and must never degrade on it
(degrade.js). A hidden tab must not lose the user's columns."""
payload = _run_render_probe("containers")
headers = payload["pluginHeaderCells"].get("containers") or []
assert "Command" in headers, f"got {headers!r}"
# -------------------------------- containers elastic tail column (maintainer)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_container_table_declares_the_terminal_column_widths():
"""The block used to paint an automatic-layout table whose `command` and
`ports` spans were capped at 24 characters, so the tail column stayed 24
characters wide however much room the window had -- the maintainer asked
for the processlist treatment instead: a <colgroup> in the terminal's own
character widths (`_COL_GEOMETRY`) and an elastic last column.
The expectation is re-extracted from process_widths.js by regex, never by
importing or calling the component's own colStyle(), for the same reason
test_the_column_box_reserves_one_character_for_the_separator_inside_it
gives: a copy of the code under test proves nothing. `+ COL_SEPARATOR` per
<col> because under `table-layout: fixed` the <col> is the column's WHOLE
box and the separator's `padding-right` comes out of it.
"""
widths_path = _BUNDLE_PATH.parent.parent / "js" / "v5" / "process_widths.js"
source = widths_path.read_text()
widths_match = re.search(r"export const WEBUI_CONTAINER_COL_WIDTHS\s*=\s*\{(.*?)\};", source, re.S)
assert widths_match, f"WEBUI_CONTAINER_COL_WIDTHS is not exported from {widths_path}"
widths = {k: int(v) for k, v in re.findall(r'"([^"]+)"\s*:\s*(\d+)', widths_match.group(1))}
separator_match = re.search(r"export const COL_SEPARATOR\s*=\s*(\d+)\s*;", source)
assert separator_match, f"COL_SEPARATOR is not exported from {widths_path}"
separator = int(separator_match.group(1))
# The `containers` fixture's longest name is "web", so the name column
# falls back to the header label's own 9 characters (`CONTAINER`).
expected = [
widths["engine"],
widths["pod"],
len("CONTAINER"),
widths["status"],
widths["uptime"],
widths["cpu"],
widths["mem"],
widths["memory_max"],
# One key, two painted cells: the pair splits its width evenly.
widths["diskio"] // 2,
widths["diskio"] // 2,
widths["networkio"] // 2,
widths["networkio"] // 2,
widths["ports"],
# `command`, the elastic tail, gets no <col> at all.
]
payload = _run_render_probe("containers")
assert payload["pluginColWidths"]["containers"] == [f"calc({n + separator} * var(--gl-col))" for n in expected], (
payload["pluginColWidths"]["containers"]
)
assert "gl-process-table" in payload["pluginTableClasses"]["containers"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_container_tail_column_is_the_one_left_out_of_the_colgroup():
"""Under `table-layout: fixed` a column past the <colgroup>'s count takes
the whole remaining width (CSS 2.1 17.5.2.1), so the tail is elastic only
because the colgroup stops one cell short -- exactly how processlist's
Command column works. `Command` is that tail here; `Ports` becomes it once
the cascade's first step drops `Command` (`_DROP_ORDER`), which is why the
<col> list is one short in BOTH cases rather than pinned to a column name.
"""
for scenario in ("containers", "containers-one-notch"):
payload = _run_render_probe(scenario)
headers = payload["pluginHeaderCells"]["containers"]
cols = payload["pluginColWidths"]["containers"]
assert len(cols) == len(headers) - 1, f"{scenario}: {len(cols)} <col> for {len(headers)} columns"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_container_name_column_is_sized_from_the_data():
"""The terminal's `name_w` (containers/render_curses_v5.py:262) is the
LONGEST name, capped by `[containers] max_name_size` and floored at the
header label. The WebUI used to spend a flat 20 characters here whatever
the names were, which is 11 characters the tail column never got back.
`containers-long-names` publishes a 25-character name against the
fixture's own `max_name_size: 20`, so this pins both ends: the column
grows past the label, and stops at the configured cap.
"""
separator = int(
re.search(
r"export const COL_SEPARATOR\s*=\s*(\d+)\s*;",
(_BUNDLE_PATH.parent.parent / "js" / "v5" / "process_widths.js").read_text(),
).group(1)
)
short = _run_render_probe("containers")["pluginColWidths"]["containers"][2]
assert short == f"calc({len('CONTAINER') + separator} * var(--gl-col))", short
long_names = _run_render_probe("containers-long-names")["pluginColWidths"]["containers"][2]
assert long_names == f"calc({20 + separator} * var(--gl-col))", long_names
# ------------------------------- right-column alignment retouches (maintainer)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_containers_glues_each_rate_pair_the_way_the_tui_does():
"""containers/render_curses_v5.py:158-162 and :205-209 right-align
`IOR/s`/`Rx/s` and LEFT-align `IOW/s`/`Tx/s`, header and data cell alike,
so each pair reads as one group hugging in the middle instead of two
columns drifting apart. The WebUI marked all four `.gl-num`
(right-aligned), which split every pair.
Columns are located by label, not by index: the header set depends on the
payload (Engine, Pod and /MAX are data- or config-driven), so a hardcoded
index would break the day a fixture changes.
"""
payload = _run_render_probe("containers")
headers = payload["pluginHeaderCells"]["containers"]
classes = payload["pluginColumnClasses"]["containers"]
for label in ("IOR/s", "Rx/s"):
cls = classes[headers.index(label)]
assert "gl-num" in cls, f"{label} stays numeric: {cls!r}"
assert "gl-num-left" not in cls, f"{label} keeps the TUI's right alignment: {cls!r}"
for label in ("IOW/s", "Tx/s"):
cls = classes[headers.index(label)]
assert "gl-num" in cls, f"{label} stays numeric: {cls!r}"
assert "gl-num-left" in cls, f"{label} is left-aligned like the TUI: {cls!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_containers_rate_data_cells_match_their_header_alignment():
"""A header flipped without its data cell would misalign the column it
labels, so the same rule is asserted on the first row's <td>s. The value
class list is every <td> in document order, so the first row's cells are
the first `len(headers)` entries.
"""
payload = _run_render_probe("containers")
headers = payload["pluginHeaderCells"]["containers"]
first_row = payload["pluginValueClasses"]["containers"][: len(headers)]
assert "gl-num-left" not in first_row[headers.index("IOR/s")], f"got {first_row!r}"
assert "gl-num-left" in first_row[headers.index("IOW/s")], f"got {first_row!r}"
assert "gl-num-left" not in first_row[headers.index("Rx/s")], f"got {first_row!r}"
assert "gl-num-left" in first_row[headers.index("Tx/s")], f"got {first_row!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_amps_count_cell_is_marked_as_its_own_narrow_column():
"""`.gl-num` floors a column at 9ch -- a width sized for a rate cell's
worst case ("1023.9G/s"). The AMP count is one to four digits
(amps/render_curses_v5.py `_COUNT_COL_WIDTH` = 4), so it inherited a
floor it can never use and the column rendered far wider than the TUI's.
This observes the marker class reaching the DOM; the width itself is CSS,
which the probe's fake DOM cannot evaluate -- that half is pinned in
tests/test_webui_v5_tokens.py, the same split the `.gl-command` cap uses.
"""
payload = _run_render_probe("amps")
classes = payload["pluginValueClasses"]["amps"]
# Three columns, three rendered rows: name, count, result.
count_cells = [classes[i] for i in range(1, len(classes), 3)]
assert count_cells, f"no amps cells rendered: {classes!r}"
for cls in count_cells:
assert "gl-num" in cls, f"the count stays numeric: {cls!r}"
assert "gl-amp-count" in cls, f"the count carries its own width class: {cls!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_containers_ports_cell_keeps_its_full_value_on_hover():
"""The ports cell is now capped and ellipsized (see
tests/test_webui_v5_tokens.py), so the full published list must stay
reachable as a `title`, exactly as the command cell does. A cap without
a title would DELETE information from the page rather than fold it.
"""
payload = _run_render_probe("containers")
titled = payload["pluginTitles"].get("containers") or []
titles = [c.get("title") for c in titled]
assert "0.0.0.0:80->80/tcp" in titles, f"the ports cell carries its full value: {titles!r}"
# ------------------------------------------------- alert TUI parity (G9-9B Task 6)
def _alert_title(payload):
"""The alert block's own title line -- its <h2>, NOT the grid's first <th>.
The title used to be the glyph column's header cell, which pinned the
whole `ALERTS N ongoing · M resolved` sentence inside a 1-character <col>
under `table-layout: fixed`; it is a full-width line above the grid now,
like the terminal's own title row (curses_renderer_v5.py:806-816).
"""
text = payload["pluginTitleLine"].get("alert")
assert text is not None, "the alert block renders no title line at all"
return text
def _alert_rows(payload):
"""The rendered alert grid's <td> cells, grouped by row (6 columns:
glyph, TIME, DURATION, TARGET, TOP PROCESSES, LEVEL)."""
cells = payload["pluginTableCells"].get("alert", [])
return [cells[i : i + 6] for i in range(0, len(cells), 6)]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_alert_grid_renders_the_tui_columns():
"""curses_renderer_v5.py:1004-1062 -- glyph, TIME, DURATION, TARGET, TOP
PROCESSES, LEVEL, in that order. This scenario carries no block-width
fixture, so the block is "unmeasurable" (degrade.js's `fits()` reads a
zero clientWidth as "cannot measure" and never degrades on it) and every
column survives -- see test_the_alert_grid_drops_top_processes_first and
its siblings below for the width cascade itself."""
payload = _run_render_probe("alert")
header = payload["pluginHeaderCells"]["alert"]
assert header[1:] == ["TIME", "DURATION", "TARGET", "TOP PROCESSES", "LEVEL"]
# The glyph column's header is BLANK, like the terminal's
# (curses_renderer_v5.py:818): the title is a full-width line of its own
# above the grid, not the first <th>. ALERT_INCIDENTS_FIXTURE has 3
# ongoing (rows 0, 1, 4) and 2 resolved (rows 2, 3).
assert header[0] == "", f"got {header[0]!r}"
assert _alert_title(payload) == "ALERTS 3 ongoing · 2 resolved", f"got {_alert_title(payload)!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_only_an_ongoing_incident_colours_its_level():
"""curses_renderer_v5.py:1056 -- a resolved incident's LEVEL goes neutral
so colour there means "still happening"; the glyph keeps the level
colour regardless, so the severity reached stays readable either way.
Fixture row 0 is ongoing+prominent+critical, row 2 is resolved+warning,
row 3 is resolved+prominent, row 4 carries an unknown duration.
"""
payload = _run_render_probe("alert")
rows = _alert_rows(payload)
assert len(rows) == 5, f"expected the five fixture incidents, got {rows!r}"
ongoing_level = rows[0][5]
assert "gl-level-critical" in (ongoing_level["value"] or ""), f"got {ongoing_level!r}"
assert "gl-prominent" in (ongoing_level["value"] or ""), (
f"the prominent badge belongs on LEVEL in the browser: {ongoing_level!r}"
)
# Fix round 1, restored: the badge (and the tier colour) sit on the LEVEL
# <span>, never on the <td> itself -- a prominent cell's fill must not
# spread to the whole cell. `test_a_table_value_carries_its_tier_on_the_text_not_the_cell`
# guards this for the six CollectionBlock-routed plugins; PluginAlert is a
# hand-rolled <table> on its own code path, so it needs its own check.
assert _tier_classes(ongoing_level["cell"]) == set(), f"got {ongoing_level!r}"
resolved_level = rows[2][5]
assert "gl-level" not in (resolved_level["value"] or ""), (
f"a resolved incident's LEVEL must go neutral: {resolved_level!r}"
)
ongoing_glyph = rows[0][0]
assert "gl-level-critical" in (ongoing_glyph["value"] or ""), f"the glyph keeps the level colour: {ongoing_glyph!r}"
resolved_glyph = rows[2][0]
assert "gl-level-warning" in (resolved_glyph["value"] or ""), (
f"the glyph keeps the level colour even once resolved: {resolved_glyph!r}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_resolved_incident_keeps_its_prominent_badge_without_colour():
"""Fix round 1, IMPORTANT 2: curses_renderer_v5.py:851-861 passes
`color=role if is_ongoing else DEFAULT, prominent=prominent` -- the
COLOUR drops once resolved, the BADGE does not. The shared `levelClass()`
token helper cannot express "badge, no tier hue" (a lone `.gl-prominent`
has no CSS rule at all, css/v5.css:77-91, precisely so a badge can never
appear without some tier colour behind it) -- PluginAlert.vue therefore
carries a small, alert-local `.gl-alert-resolved-prominent` class for
this one case, matching the TUI rather than silently dropping the badge.
Fixture row 3 is resolved + prominent + critical.
"""
payload = _run_render_probe("alert")
rows = _alert_rows(payload)
resolved_prominent = rows[3][5]
classes = (resolved_prominent["value"] or "").split()
assert "gl-alert-resolved-prominent" in classes, f"got {resolved_prominent!r}"
assert "gl-level-critical" not in classes, f"resolved drops the tier hue: {resolved_prominent!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_unknown_duration_renders_the_placeholder():
"""Fix round 1, MINOR 5: `incident_duration()` is typed `str | None` --
render the WebUI's "-" placeholder, never blank or a computed value.
Fixture row 4 carries `duration: null`."""
payload = _run_render_probe("alert")
rows = _alert_rows(payload)
assert rows[4][2]["text"] == "-", f"got {rows[4][2]!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_all_outage_does_not_blank_the_alert_block():
"""Fix round 1, IMPORTANT 1: `alert` is `ownEndpoint` -- it must never be
part of fetchAll()'s spec list, or fetchAll()'s catch branch
(`errors[s.name] = e.message` for EVERY requested spec, api.js:134-138)
hands the alert block an error that belongs to a dead /api/5/all, an
endpoint it never reads from. `all-unreachable` fails only /api/5/all;
/api/5/alert/incidents keeps answering normally -- today this scenario
(test_header_blocks_show_their_error_when_all_fails) only asserted the
header plugins, so nothing pinned alert's side of this."""
payload = _run_render_probe("all-unreachable")
rows = _alert_rows(payload)
assert rows, (
"expected the alert grid to render its incidents despite the /all outage: "
f"{payload['pluginText'].get('alert')!r}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_partial_incident_marks_its_duration_as_a_lower_bound():
"""`partial` means the opening event aged out of the history, so the
duration `/api/5/alert/incidents` sends is already a lower bound,
prefixed ">" server-side (incident_duration()) -- a bare number here
would be a lie. The component must render the string as it arrives,
never recompute it."""
payload = _run_render_probe("alert")
rows = _alert_rows(payload)
durations = [row[2]["text"] for row in rows]
assert any(d.startswith(">") for d in durations), f"got {durations!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_footer_keeps_the_cadence_and_drops_the_alert_list():
"""AppShell.tick()'s alert fetch is repointed at /api/5/alert/incidents
and its result now feeds PluginAlert.vue, not the footer -- the footer
keeps only the refresh cadence."""
payload = _run_render_probe("alert")
assert "Refresh:" in (payload["footerText"] or ""), f"got {payload['footerText']!r}"
assert "No alert" not in (payload["footerText"] or ""), f"got {payload['footerText']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_failing_alert_endpoint_does_not_disturb_the_plugins_above_it():
"""The alert fetch stays in its own try/catch (AppShell.vue tick()),
separate from the one guarding /api/5/all: a 500 from
/api/5/alert/incidents must not blank the plugins fetchAll() already
resolved. `alert-incidents-unreachable` fails only that endpoint --
/api/5/all and /api/5/pluginslist answer normally."""
payload = _run_render_probe("alert-incidents-unreachable")
assert "mem" in payload["pluginNames"], f"other plugins must still render: {payload['pluginNames']!r}"
assert "alert" in payload["pluginNames"], f"the alert block itself must still render: {payload['pluginNames']!r}"
assert "Refresh:" in (payload["footerText"] or ""), f"the footer must still render: {payload['footerText']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_alert_block_never_claims_all_clear_during_warm_up():
"""Fix round 2, IMPORTANT 2: `/api/5/alert/incidents` now answers an
envelope (`{is_initializing, incidents}`), and the browser may not claim
health it cannot know -- the same rule `render_alert_block` already
follows (curses_renderer_v5.py:738-745). `alert-initializing` answers
`is_initializing: true` with no incidents."""
payload = _run_render_probe("alert-initializing")
text = payload["pluginText"].get("alert", "")
assert "initializing" in text, f"got {text!r}"
assert "no alert detected" not in text, f"must not claim an all-clear during warm-up: {text!r}"
# One GLUED line, `ALERT (initializing)`, exactly as `render_alert_block`
# paints it (curses_renderer_v5.py:734-754 -- the same string
# test_curses_renderer_v5.py asserts on the TUI side). Not cosmetic: the
# block-title-plus-paragraph form this replaced cost three rows where
# `alertBlockHeight(0, ...)` (row_budget.js) budgets one, so the right
# column overran the planned body height and the state line was painted
# under the sticky, opaque `.gl-alerts` footer -- leaving only `ALERT`
# visible in the browser.
assert text == "ALERT (initializing)", f"the collapse is one line: {text!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_alert_block_shows_no_alert_detected_once_warmed_up():
"""Control for the test above: once warmed up (`is_initializing: false`)
with genuinely nothing having ever fired, the block may say so -- in its
OK colour, matching the TUI (curses_renderer_v5.py:745), not muted."""
payload = _run_render_probe("alert-empty")
text = payload["pluginText"].get("alert", "")
assert "no alert detected" in text, f"got {text!r}"
cells = payload["pluginTableCells"].get("alert", [])
assert not cells, f"the empty state renders no incident grid: {cells!r}"
# Same single-line collapse as the warm-up test above, and for the same
# row-budget reason -- see its comment.
assert text == "ALERT (no alert detected)", f"the collapse is one line: {text!r}"
# ------------------------------------------------- alert width cascade + row budget
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_alert_target_column_takes_its_natural_width():
"""TARGET is sized to the LONGEST target on screen, floored at
`_ALERT_MIN_TARGET` -- what the terminal does whenever it is not
width-constrained (curses_renderer_v5.py:781-784).
The <col> used to be pinned to that floor instead, which cropped every
target past twelve characters although the block had room beside it:
ALERT_INCIDENTS_FIXTURE's own `Diskio sda read bytes` is 21, and rendered
as `Diskio sda r…`. The scenario carries no block-width fixture, so no
cascade fires and the natural width is what reaches the DOM.
"""
payload = _run_render_probe("alert")
rows = _alert_rows(payload)
natural = max(len(row[3]["text"]) for row in rows)
assert natural > 12, f"the fixture no longer exercises a target past the floor: {natural}"
# GLYPH, TIME, DURATION, TARGET, TOP (width-less), LEVEL -- TARGET is the
# fourth <col>, and every <col> box carries COL_SEPARATOR on top of its
# content width.
widths = payload["pluginColWidths"]["alert"]
assert widths[3] == f"calc({natural + 2} * var(--gl-col))", f"got {widths!r} for a {natural}-character target"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_alert_grid_drops_top_processes_first():
"""The TUI sacrifices TOP to keep TARGET readable
(curses_renderer_v5.py:538-540). The browser must not do the opposite."""
payload = _run_render_probe("alert-narrow-one-notch")
headers = payload["pluginHeaderCells"].get("alert") or []
assert "TOP PROCESSES" not in headers, f"got {headers!r}"
assert "TARGET" in headers, f"got {headers!r}"
assert "LEVEL" in headers, f"got {headers!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_alert_grid_drops_level_then_duration():
headers = _run_render_probe("alert-narrow-two-notches")["pluginHeaderCells"].get("alert") or []
assert "LEVEL" not in headers and "DURATION" in headers, f"got {headers!r}"
headers = _run_render_probe("alert-narrowest")["pluginHeaderCells"].get("alert") or []
assert "DURATION" not in headers and "TARGET" in headers, f"got {headers!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_alert_block_honours_its_row_budget():
"""AppShell.refitVertical()'s row_budget.js solver (Task 4) hands this
block a quota through `rowBudget` (provide()/inject) -- `budget-short`'s
cramped viewport shrinks the alert ladder below ALERT_INCIDENTS_FIXTURE's
5 incidents, exactly as it does for processlist's own 30 rows
(test_the_row_budget_caps_the_process_block).
Also pins the title's ongoing/resolved counts to the UNBUDGETED incident
set: `rows` is capped to the quota, but `titleText()` must keep counting
every incident regardless -- a regression that points it at the capped
`rows` instead renders "3 ongoing (dot) 0 resolved" here (ALERT_INCIDENTS_
FIXTURE's 2 resolved incidents both sort after the 3 ongoing ones and
fall outside the 3-row cap), silently under-reporting resolved incidents
that still exist. Reverting the `allRows`/`rows` split in PluginAlert.vue
(pointing `titleText()` back at `rows`) was confirmed to make exactly
this assertion fail while every other assertion in this file still
passed -- see task-10-report.md for the recorded revert/restore output.
"""
payload = _run_render_probe("budget-short-with-alerts")
rows = payload["pluginRowGroups"]["alert"][0]
assert len(rows) == payload["rowBudget"]["alert"], (
f"rendered {len(rows)} rows, budget was {payload['rowBudget']['alert']}"
)
assert _alert_title(payload) == "ALERTS 3 ongoing · 2 resolved", (
f"the title must count ALL incidents, not just the budgeted rows: got {_alert_title(payload)!r}"
)
# ------------------------------------------------- processlist TUI parity (G9-9B Task 7)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processlist_renders_the_tui_columns_and_rows():
"""processlist/render_curses_v5.py `_FIXED_COL_KEYS` (:91) plus `Command`.
No title cell (unlike `containers`): the renderer never puts one in its
header row, so every one of the 13 headers below is a field label.
"""
payload = _run_render_probe("processlist")
assert payload["pluginHeaderCells"].get("processlist") == [
"CPU%",
"MEM%",
"VIRT",
"RES",
"PID",
"USER",
"THR",
"NI",
"S",
"TIME+",
"R/s",
"W/s",
"Command",
], f"got {payload['pluginHeaderCells'].get('processlist')!r}"
rows = _table_rows(payload, "processlist", 13)
# VIRT is "120M", not "120.0M": VIRT/RES/R/s/W/s render through
# `formatProcessBytes` (processlist/render_curses_v5.py's OWN
# `_format_bytes`, not the shared `formatBytes` every other byte column
# uses), which drops the decimal at >= 100 -- 125829120 bytes is exactly
# 120.0M, so this row is the one fixture value that tells the two
# formatters apart. RES (32.0M, < 100) keeps its decimal under either
# formatter, so it is not a distinguishing case.
assert rows[0] == [
"78.4%",
"3.1%",
"120M",
"32.0M",
"12345",
"alice",
"4",
"0",
"S",
"0:12",
"512B",
"512B",
"python3 myscript.py --verbose",
], f"got {rows[0]!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processlist_shows_the_placeholder_for_every_missing_value_and_the_kernel_thread_fallback():
"""Row 1 of the `processlist` fixture carries every optional field as
null. VIRT/RES/R/s/W/s (`formatProcessBytes`) and USER (`formatUsername`,
format.js) mirror the TUI's OWN per-column marker `?` for a missing
value -- `_memory_info_field`/`_io_rate`/`_format_username`
(processlist/render_curses_v5.py) all render `?`, not `-`, and
`formatProcessBytes`'s own docstring says why a null there is a live,
reachable case rather than a defensive guard. Every other column
(CPU%, MEM%, THR, NI, S, TIME+) keeps the WebUI's single
placeholder `-` (format.js `MISSING`). Its Command cell is the OTHER TUI
fallback: no `cmdline` at all renders the kernel-thread bracket form from
the process `name`.
"""
payload = _run_render_probe("processlist")
rows = _table_rows(payload, "processlist", 13)
row = rows[1]
# VIRT, RES, USER, R/s, W/s -- the five columns that render through a
# formatter that answers "?" for a missing value.
question_mark_columns = {2, 3, 5, 10, 11}
# PID (index 4) is the only non-null field among the first 12 columns.
for i, cell in enumerate(row[:12]):
if i == 4:
assert cell == "999", f"PID must render, got {row!r}"
elif i in question_mark_columns:
assert cell == "?", f"column {i} must mirror the TUI's own marker, got {row!r}"
else:
assert cell == "-", f"column {i} must be the WebUI placeholder, got {row!r}"
assert row[12] == "[kthread0]", f"got {row!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processlist_colours_cpu_and_mem_from_levels():
"""`_levels` is keyed by `pid` (processlist/render_curses_v5.py
`levels_index.get(pid)`); row 0's pid (12345) carries a warning CPU%
and an ok MEM% in the fixture."""
payload = _run_render_probe("processlist")
cells = payload["pluginTableCells"]["processlist"]
assert _tier_classes(cells[0]["value"]) == {"gl-level-warning"}, cells[0]
assert _tier_classes(cells[0]["cell"]) == set(), f"the <td> itself carries no tier class: {cells[0]!r}"
assert _tier_classes(cells[1]["value"]) == {"gl-level-ok"}, cells[1]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processlist_caps_at_max_processes_display_in_payload_order():
"""`[outputs] max_processes_display=2` (CONFIG_FIXTURES) slices the
FIRST two of the `processlist-cap` fixture's three payload-order rows,
never the top two by `cpu_percent` (pid 10, in the middle, is the
highest at 50 and must NOT be pulled to the front, nor kept over pid 20
just because it is bigger) -- the engine sorts, this component must not.
"""
payload = _run_render_probe("processlist-cap")
headers = payload["pluginHeaderCells"]["processlist"]
rows = _table_rows(payload, "processlist", len(headers))
assert len(rows) == 2, f"expected exactly 2 rows (the cap), got {len(rows)}: {rows!r}"
pid_index = headers.index("PID")
command_index = headers.index("Command")
assert [r[pid_index] for r in rows] == ["30", "10"], f"got {rows!r}"
assert [r[command_index] for r in rows] == ["third", "first"], f"got {rows!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_processlist_block_with_no_config_cap_shows_every_row():
"""A scenario absent from CONFIG_FIXTURES gets `{}` -- no `outputs` key
at all -- which must read as "no cap", not as a cap of zero/undefined
that hides everything. "processlist" (2 rows) has no CONFIG_FIXTURES
entry, unlike "processlist-cap"."""
payload = _run_render_probe("processlist")
headers = payload["pluginHeaderCells"]["processlist"]
rows = _table_rows(payload, "processlist", len(headers))
assert len(rows) == 2, f"expected both fixture rows with no configured cap, got {len(rows)}: {rows!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processlist_underlines_the_active_sort_column_and_no_other():
"""`serverArgs.sort_processes_key` (task 5) names the column the engine
really sorts by; `_HEADER_SORT_KEY` (:97) says CPU% <-> `cpu_percent`.
Exactly that header gets the underline class, no other."""
payload = _run_render_probe("processlist-sorted")
headers = payload["pluginColumnHeaders"]["processlist"]
classes = payload["pluginColumnClasses"]["processlist"]
sorted_indices = [i for i, cls in enumerate(classes) if "gl-sorted" in cls.split()]
assert sorted_indices == [headers.index("CPU%")], f"got headers={headers!r} classes={classes!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processlist_underlines_nothing_without_a_sort_key():
"""The `processlist` scenario carries no `sort_processes_key` in
ARGS_FIXTURES -- absent means `{}`, i.e. no flag -- so no header may be
underlined."""
payload = _run_render_probe("processlist")
classes = payload["pluginColumnClasses"]["processlist"]
assert not any("gl-sorted" in cls.split() for cls in classes), f"got {classes!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_wide_processlist_block_keeps_every_column():
"""The cascade starts from NO flag on every pass (degrade.js
resolveDegrade), so a block that fits drops nothing."""
payload = _run_render_probe("processlist-wide")
headers = payload["pluginHeaderCells"].get("processlist") or []
assert "Command" in headers, f"got {headers!r}"
assert "VIRT" in headers, f"got {headers!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_command_survives_the_full_processlist_cascade():
"""`_DROP_ORDER`'s eight columns (processlist/render_curses_v5.py:89)
all go on a sufficiently narrow block, but `Command` is the protected
TAIL here -- the deliberate opposite of `containers`, where `command` is
the FIRST column dropped."""
payload = _run_render_probe("processlist-narrow")
headers = payload["pluginHeaderCells"].get("processlist") or []
assert "Command" in headers, f"got {headers!r}"
for dropped in ("VIRT", "TIME+", "RES", "USER", "PID", "THR", "S", "NI"):
assert dropped not in headers, f"{dropped} must be gone on the narrowest pass: {headers!r}"
for kept in ("CPU%", "MEM%", "R/s", "W/s"):
assert kept in headers, f"{kept} must never be dropped: {headers!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_row_budget_caps_the_process_block():
"""AppShell.refitVertical()'s row_budget.js solver (Task 4) hands this
block a quota through `rowBudget` (provide()/inject, AppShell.vue:114-126)
-- `budget-short`'s cramped viewport shrinks the ladder below the
fixture's 30 rows."""
payload = _run_render_probe("budget-short")
rows = payload["pluginRowGroups"]["processlist"][0]
assert len(rows) == payload["rowBudget"]["processlist"], (
f"got {len(rows)} rows, budget was {payload['rowBudget']!r}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_config_cap_still_wins_when_it_is_lower():
"""`[outputs] max_processes_display` is a hard ceiling that available
height may never raise (design 4.7). `budget-tall-with-config-cap` reuses
`budget-tall`'s generous height -- the solver would otherwise grow the
block to all 30 rows -- with a CONFIG_FIXTURES cap of 5."""
payload = _run_render_probe("budget-tall-with-config-cap")
rows = payload["pluginRowGroups"]["processlist"][0]
assert len(rows) == 5, f"got {len(rows)} rows: {rows!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_process_table_declares_the_terminal_column_widths():
"""The <colgroup> carries the character counts
(process_widths.js's WEBUI_COL_WIDTHS/FIXED_COL_KEYS), so a wider PID
at the next refresh cannot re-lay the table out. `pluginColWidths`
(Task 7) lets this be observed; nothing before this test asserts that a
colgroup is rendered at all.
Each `<col>` is `WEBUI_COL_WIDTHS[key] + COL_SEPARATOR`, not the bare
content width: under `table-layout: fixed` the <col> is the column's
WHOLE box, and the `:not(:last-child)` separator's `padding-right` comes
out of that same box, so a <col> of exactly N characters would leave only
N - COL_SEPARATOR for content -- every fixed column would crop early
(invisibly so for `S`, N=1). CPU% is 7, not 5: a 5-wide field fits
`100.0` but not `9999.9`, a value a process spread over many cores can
genuinely reach. MEM% is 6 where the terminal budgets 5, because
`formatPercent()` appends a `%` curses never prints and `100.0%` is six
characters -- see WEBUI_COL_WIDTHS' own comment.
"""
payload = _run_render_probe("processlist-wide")
assert payload["pluginColWidths"]["processlist"] == [
f"calc({n + 2} * var(--gl-col))" for n in (7, 6, 5, 5, 7, 10, 3, 3, 1, 8, 5, 5)
]
assert "gl-process-table" in payload["pluginTableClasses"]["processlist"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_column_box_reserves_the_separator_inside_it():
"""`colStyle()` used to return the content width alone, under-sizing every
column's usable content space by the separator -- under
`table-layout: fixed` the <col> is the column's WHOLE box, and
`.gl-process-table td:not(:last-child)`'s `padding-right` comes out of
that same box. A maintainer browser smoke test caught it (every fixed
column cropped early; invisibly so for `S`, whose content width of 1 made
the column disappear rather than merely narrow); the PREVIOUS version of
this test (test_the_process_table_declares_the_terminal_column_widths) did
not, because its expected numbers were hand-copied from the same (buggy)
`colStyle()` output it was meant to check -- consistent with itself and
wrong.
This DOM-less probe has no CSS box model and no real layout engine, so it
cannot observe actual rendered content space the way a browser can --
that would need something like Playwright or headless Chrome measuring
real pixel widths, which this test suite does not have. What it CAN do,
and what the previous test did not, is pin the FORMULA independently of
`colStyle()`'s own source: `WEBUI_COL_WIDTHS`/`FIXED_COL_KEYS`/
`COL_SEPARATOR` are re-extracted here straight from process_widths.js by
regex (the same technique test_webui_v5_width_drift.py uses), never by
importing or calling colStyle -- so a future edit that silently drops the
offset changes only production code, not this independently-sourced
expectation, and the two are compared instead of copied from one another.
"""
widths_path = _BUNDLE_PATH.parent.parent / "js" / "v5" / "process_widths.js"
source = widths_path.read_text()
order_match = re.search(r"export const FIXED_COL_KEYS\s*=\s*\[(.*?)\];", source, re.S)
assert order_match, f"FIXED_COL_KEYS is not exported from {widths_path}"
keys = re.findall(r'"([^"]+)"', order_match.group(1))
widths_match = re.search(r"export const WEBUI_COL_WIDTHS\s*=\s*\{(.*?)\};", source, re.S)
assert widths_match, f"WEBUI_COL_WIDTHS is not exported from {widths_path}"
content_widths = {k: int(v) for k, v in re.findall(r'"([^"]+)"\s*:\s*(\d+)', widths_match.group(1))}
separator_match = re.search(r"export const COL_SEPARATOR\s*=\s*(\d+)\s*;", source)
assert separator_match, f"COL_SEPARATOR is not exported from {widths_path}"
separator = int(separator_match.group(1))
payload = _run_render_probe("processlist-wide")
col_widths = payload["pluginColWidths"]["processlist"]
assert len(col_widths) == len(keys), f"got {col_widths!r} for keys {keys!r}"
for key, declared in zip(keys, col_widths):
expected = content_widths[key] + separator # content chars + the separator inside the box
assert declared == f"calc({expected} * var(--gl-col))", (
f"{key}: got {declared!r}, expected content ({content_widths[key]}) + {separator} separator = {expected}"
)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_long_user_name_is_cropped_not_wrapped():
"""`formatUsername` (format.js) crops at PROCESS_COL_WIDTHS.USER=10 and
marks the crop with a trailing `+` -- the fixed-layout table no longer
wraps a long name onto a second line (defect P4)."""
payload = _run_render_probe("processlist-wide")
headers = payload["pluginColumnHeaders"]["processlist"]
idx = headers.index("USER")
rows = payload["pluginRowGroups"]["processlist"][0]
cells = [row[idx] for row in rows]
assert all(len(text) <= 10 for text in cells), cells
assert any(text.endswith("+") for text in cells), cells
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_command_column_carries_no_character_cap():
"""Defect P2: Command takes whatever is left. Its floor is reserved by the
table's min-width (--gl-fixed-cols), not by a cap on the cell -- the cap
belongs to `containers`, which keeps the measure-driven `.gl-command`
cascade; here the column width truncates instead.
`gl-command` lives on the value <span> inside the <td>, never on the <td>
itself -- `pluginValueClasses` (webui_render_probe.js's `values.map((cell)
=> cell.className)`) reads only `<td>`/`<dd>` classNames and would prove
nothing here: a mutation test confirmed it (re-adding `gl-command` to the
span left that assertion passing). `pluginTableCells[i]["value"]` is that
span's own class list -- the same accessor
test_raid_colours_the_values_and_the_status_lines_from_levels already uses
for the identical reason ("`pluginValueClasses` would give the cell's
('gl-num') and prove nothing").
"""
payload = _run_render_probe("processlist-wide")
spans = [cell["value"] for cell in payload["pluginTableCells"]["processlist"]]
assert spans, "the scenario must actually render process rows"
assert not any("gl-command" in (span or "") for span in spans), spans
# ------------------------------------------------- programlist TUI parity (G9-9B Task 8)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_programlist_renders_the_tui_columns_and_rows():
"""programlist/render_curses_v5.py :99-113 -- identical to processlist's
13 headers except `PID` (index 4) is replaced by `NPROCS` (no single pid
on an aggregated program row)."""
payload = _run_render_probe("programlist")
assert payload["pluginHeaderCells"].get("programlist") == [
"CPU%",
"MEM%",
"VIRT",
"RES",
"NPROCS",
"USER",
"THR",
"NI",
"S",
"TIME+",
"R/s",
"W/s",
"Command",
], f"got {payload['pluginHeaderCells'].get('programlist')!r}"
rows = _table_rows(payload, "programlist", 13)
# VIRT is "120M", not "120.0M": programlist/render_curses_v5.py imports
# processlist's OWN `_format_bytes` verbatim (its docstring says every
# cell builder but the identity column is shared), so VIRT/RES/R/s/W/s
# render through `formatProcessBytes` here too, the same formatter
# processlist's own parity test distinguishes on this exact value.
assert rows[0] == [
"78.4%",
"3.1%",
"120M",
"32.0M",
"3",
"alice",
"4",
"0",
"S",
"0:12",
"512B",
"512B",
"python3 myscript.py --verbose",
], f"got {rows[0]!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_programlist_shows_the_placeholder_for_every_missing_value_and_the_kernel_thread_fallback():
"""Row 1 of the `programlist` fixture carries every optional field
(including `nprocs`) as null. VIRT/RES/R/s/W/s (`formatProcessBytes`) and
USER (`formatUsername`) mirror the TUI's OWN per-column marker `?` for a
missing value, exactly like processlist's own row 1 -- `nprocs` (index 4)
is not one of them: it renders through the same plain `fmt()` as
processlist's PID column, so its placeholder is the WebUI's ordinary `-`.
The Command cell falls back to the kernel-thread bracket form from
`name`."""
payload = _run_render_probe("programlist")
rows = _table_rows(payload, "programlist", 13)
row = rows[1]
# VIRT, RES, USER, R/s, W/s -- the five columns that render through a
# formatter that answers "?" for a missing value.
question_mark_columns = {2, 3, 5, 10, 11}
for i, cell in enumerate(row[:12]):
if i in question_mark_columns:
assert cell == "?", f"column {i} must mirror the TUI's own marker, got {row!r}"
else:
assert cell == "-", f"column {i} must be the WebUI placeholder, got {row!r}"
assert row[12] == "[kthread0]", f"got {row!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_programlist_colours_cpu_and_mem_from_levels():
"""`_levels` is keyed by `name` (programlist/render_curses_v5.py
`levels_index.get(name)`) -- the fixture's "python3" row carries a
warning CPU% and an ok MEM%."""
payload = _run_render_probe("programlist")
cells = payload["pluginTableCells"]["programlist"]
assert _tier_classes(cells[0]["value"]) == {"gl-level-warning"}, cells[0]
assert _tier_classes(cells[0]["cell"]) == set(), f"the <td> itself carries no tier class: {cells[0]!r}"
assert _tier_classes(cells[1]["value"]) == {"gl-level-ok"}, cells[1]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_programlist_caps_at_max_processes_display_in_payload_order():
"""Same config key and the same AppShell provide() as processlist's own
cap (task 8): `[outputs] max_processes_display=2` slices the FIRST two
of three payload-order rows, never the top two by `cpu_percent` (the
highest, "first" at 50, sits in the middle and must not be pulled to
the front)."""
payload = _run_render_probe("programlist-cap")
headers = payload["pluginHeaderCells"]["programlist"]
rows = _table_rows(payload, "programlist", len(headers))
assert len(rows) == 2, f"expected exactly 2 rows (the cap), got {len(rows)}: {rows!r}"
command_index = headers.index("Command")
assert [r[command_index] for r in rows] == ["third", "first"], f"got {rows!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_programlist_underlines_the_active_sort_column_and_no_other():
"""`serverArgs.sort_processes_key` names the column the engine sorts by,
shared with processlist's `_HEADER_SORT_KEY` mapping -- `NPROCS` has no
entry (programlist/render_curses_v5.py's own docstring, :22) and must
never be underlined."""
payload = _run_render_probe("programlist-sorted")
headers = payload["pluginColumnHeaders"]["programlist"]
classes = payload["pluginColumnClasses"]["programlist"]
sorted_indices = [i for i, cls in enumerate(classes) if "gl-sorted" in cls.split()]
assert sorted_indices == [headers.index("CPU%")], f"got headers={headers!r} classes={classes!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_programlist_underlines_nothing_without_a_sort_key():
"""The `programlist` scenario carries `--programs` but no
`sort_processes_key` -- no header may be underlined."""
payload = _run_render_probe("programlist")
classes = payload["pluginColumnClasses"]["programlist"]
assert not any("gl-sorted" in cls.split() for cls in classes), f"got {classes!r}"
# --------------------------------------------------- programlist has no width cascade (Task 9)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_program_block_uses_nprocs_where_processes_use_pid():
"""process_widths.js's PROGRAM_FIXED_COL_KEYS is FIXED_COL_KEYS with
NPROCS (NPROCS_WIDTH=7) in PID's place -- same width as PID (both 7), so
the expected tuple is numerically identical to processlist's own
(test_the_process_table_declares_the_terminal_column_widths), column for
column. Each `<col>` is content width + COL_SEPARATOR, same formula as
processlist's own `colStyle()`."""
payload = _run_render_probe("programlist-wide")
widths = payload["pluginColWidths"]["programlist"]
assert widths == [f"calc({n + 2} * var(--gl-col))" for n in (7, 6, 5, 5, 7, 10, 3, 3, 1, 8, 5, 5)]
assert "gl-process-table" in payload["pluginTableClasses"]["programlist"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_program_block_takes_the_same_row_budget_as_the_process_block():
"""row_budget.js's `planRightColumn()` feeds `state.processes` into both
`rowBudget.processlist` and `rowBudget.programlist` unconditionally, so
the two numbers are always equal regardless of which block `slots()`
actually shows. The real assertion is the second one: the RENDERED row
count must track the budget the component was actually handed through
its own `rowBudget` inject, not just that the number exists."""
payload = _run_render_probe("budget-short-programs")
assert payload["rowBudget"]["programlist"] == payload["rowBudget"]["processlist"]
rows = payload["pluginRowGroups"]["programlist"][0]
assert len(rows) == payload["rowBudget"]["programlist"], (
f"got {len(rows)} rows, budget was {payload['rowBudget']!r}"
)
# --------------------------------- processlist / programlist exclusivity (G9-9B Task 8)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_programlist_renders_and_processlist_does_not_with_programs():
"""AppShell.vue `slots()`, the same shape as the `cpu`/`percpu`
exclusion: `serverArgs.programs` true (the "programlist" scenario) hides
`processlist` from the `right` slot even though the shell never sees
processlist's own payload absent -- it is the flag alone that decides."""
payload = _run_render_probe("programlist")
right = payload["slots"].get("right", [])
assert "programlist" in right, f"got {right!r}"
assert "processlist" not in right, f"got {right!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_processlist_renders_and_programlist_does_not_without_programs():
"""The other half: no `--programs` (the "processlist" scenario, whose
ARGS_FIXTURES entry is absent -> `{}`), so the default thread view
renders and `programlist` does not -- the terminal's own default (v4
parity, `programs: bool = False`)."""
payload = _run_render_probe("processlist")
right = payload["slots"].get("right", [])
assert "processlist" in right, f"got {right!r}"
assert "programlist" not in right, f"got {right!r}"
# ------------------------------------------------ plugin registry count (G9-9B Task 8)
def test_the_registry_holds_all_32_plugins():
"""G9 closes here: programlist was the last of the 32 v5 plugins to reach
the WebUI. A file-level count (rather than a render-probe one) because
the registry's total membership is independent of any one scenario's
exclusivity outcome (processlist XOR programlist, cpu XOR percpu) --
exactly the `grep -c 'component: Plugin'` check the task brief names."""
text = (
Path(__file__).parent.parent / "glances" / "outputs" / "static" / "js" / "v5" / "plugins" / "index.js"
).read_text()
count = len(re.findall(r"component: Plugin", text))
assert count == 32, f"expected 32 registered plugins, got {count}"
# ------------------------------------------------------- right column vertical fit (Task 4)
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_tall_viewport_grows_the_process_block_past_its_nominal():
payload = _run_render_probe("budget-tall")
assert payload["rowBudget"]["processlist"] > 20, f"got {payload['rowBudget']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_short_viewport_shrinks_it():
payload = _run_render_probe("budget-short")
assert payload["rowBudget"]["processlist"] < 20, f"got {payload['rowBudget']!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_unmeasurable_viewport_budgets_nothing():
"""degrade.js's rule, applied on the vertical axis: a DOM without layout, a
hidden tab or a detached node must never hide the user's stats."""
assert _run_render_probe("budget-unmeasurable")["rowBudget"] == {}
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_second_tick_rebudgets_against_its_own_alert_state():
"""Fix round 2, Important 1: `tick()` must read `this.results.alert`
AFTER that tick's own alert fetch resolves, not before -- otherwise
every periodic poll budgets against the PREVIOUS cycle's alert state,
forever (not just at startup, where `mounted()`'s own extra refit/
refitVertical pass after `tick()` papers over it).
The `budget-tick-shift` scenario (webui_render_fixtures.js) holds
viewport geometry (bodyHeight=14 rows, same as `budget-short`) and the
30-row processlist fixture CONSTANT, and varies only the alert payload
across two `api/5/alert/incidents` calls: tick 1 sees no incidents,
tick 2 sees five ONGOING ones. row_budget.js's `floorAlerts` reserves
rows for an ongoing incident out of the SAME shared pool `processlist`
draws from, so the two ticks' correct budgets are visibly different:
tick 1 -> `{processlist: 9, alert: 3}`, tick 2 -> `{processlist: 3,
alert: 5}` (both hand-verified by calling `planRightColumn()` directly
with the matching inputs).
The render probe fires tick 1 via AppShell's own `mounted()`, then a
SECOND tick via the `__glancesTick` hook (fix round 3) -- something no
other test in this file does; every other render-probe test drives the
vertical pass through `__glancesRefit()`, which calls `refit()` then
`refitVertical()` directly with no alert refetch in between, and could
never have caught this class of bug.
Before the round 2 fix, `refitVertical()` ran BEFORE the alert fetch
inside `tick()`, so tick 2 would read `this.results.alert` as it stood
at the END of tick 1 -- still tick 1's zero-incident envelope, since
tick 2's own fetch had not yet landed -- and produce tick 1's budget
again: `{processlist: 9, alert: 3}`. This test asserts the FIXED value;
it was run against the reverted ordering and confirmed to fail with
exactly that stale value (see the fix round 3 report for both outputs).
"""
payload = _run_render_probe("budget-tick-shift")
assert payload["rowBudget"] == {
"vms": 0,
"containers": 0,
"processlist": 3,
"programlist": 3,
"alert": 5,
}, f"got {payload['rowBudget']!r} -- {{'processlist': 9, 'alert': 3, ...}} is tick 1's STALE value"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_block_with_neither_new_input_renders_its_table_unchanged():
"""CollectionBlock's `#cols` slot and `tableClass` prop (Task 7) are both
optional and default to nothing. `processlist` is the first real
consumer (colgroup + `gl-process-table`; see
test_the_process_table_declares_the_terminal_column_widths for its exact
widths), so it is excluded from the loop below. `programlist` is not
checked here either way: it does not render in this scenario's default
process view (`ARGS_FIXTURES`' `programs: true` is what turns it on,
scenario `budget-short-programs`). `alert` is a THIRD fixed-layout
consumer (its own hand-rolled `<table>`, not CollectionBlock's)
-- unlike programlist it renders in every scenario (`ownEndpoint`), so it
is excluded from the loop below too, rather than silently never being
iterated over. `containers` is the FOURTH, and is excluded on its own line
for the same reason as programlist: this scenario gives it no payload, so
it paints no table here and the loop would pass on it by accident -- see
test_the_container_table_declares_the_terminal_column_widths, which runs
the `containers` scenario, for its widths. Every OTHER plugin still shows no <colgroup>
(pluginColWidths == []) and carries only the base
`.gl-table` class (pluginTableClasses == ["gl-table"]), proving the two
new inputs are additive rather than a silent behaviour change for the
remaining consumers.
"""
payload = _run_render_probe("processlist")
col_widths = payload["pluginColWidths"]
table_classes = payload["pluginTableClasses"]
assert "processlist" in col_widths and "processlist" in table_classes
assert len(col_widths["processlist"]) == 12, col_widths["processlist"]
assert table_classes["processlist"] == ["gl-table", "gl-process-table"], table_classes["processlist"]
assert table_classes["alert"] == ["gl-table", "gl-process-table"], table_classes["alert"]
for name in payload["pluginNames"]:
if name in ("processlist", "alert", "containers"):
continue
assert col_widths[name] == [], f"{name} rendered a <colgroup> with no consumer yet: {col_widths[name]!r}"
# [] for a plugin whose article has no <table> at all (a scalar-grid
# block, <dl>-based); ["gl-table"] -- the unconditional base class,
# nothing appended -- for every plugin that does.
assert table_classes[name] in ([], ["gl-table"]), f"{name} table class drifted: {table_classes[name]!r}"
# ------------------------------------------------- workload blocks and the row budget solver
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_workload_block_honours_its_row_quota():
"""The solver splits a shared pool between vms and containers
(`_split_workloads`, curses_renderer_v5.py:923-942). Nothing consumed that
split before this task, so the solver reserved rows the browser then
overspent."""
payload = _run_render_probe("budget-workloads-capped")
assert len(payload["pluginRowGroups"]["containers"][0]) == payload["rowBudget"]["containers"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_zero_quota_hides_the_block_header_included():
"""Ladder steps g and l make a block VANISH, and `cost()` charges it zero
rows (curses_renderer_v5.py:1035-1044). A header row left on screen is one
row the solver did not budget."""
payload = _run_render_probe("budget-processlist-zeroed")
assert payload["rowBudget"]["processlist"] == 0
assert payload["pluginHidden"]["processlist"] is True
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_vms_block_honours_its_own_row_quota_alongside_containers():
"""`test_a_workload_block_honours_its_row_quota` above only ever
populates `containers` -- a scenario that never gives `vms` any data
cannot tell "the block reads its own quota" apart from "the block was
never at risk of overflowing". `budget-workloads-both-capped` populates
BOTH blocks (10 vms, 30 containers) under the same cramped viewport, so
`_split_workloads`'s max-min fairness rule (curses_renderer_v5.py:923-942)
actually has two competing blocks to divide a pool between -- confirmed
against `planRightColumn()` directly: `{vms: 3, containers: 2, ...}`,
neither the full count nor an even half, which an even-split or
vms-ignored implementation could not produce by accident.
"""
payload = _run_render_probe("budget-workloads-both-capped")
assert payload["rowBudget"]["vms"] == 3, f"got {payload['rowBudget']!r}"
assert payload["rowBudget"]["containers"] == 2, f"got {payload['rowBudget']!r}"
assert len(payload["pluginRowGroups"]["vms"][0]) == payload["rowBudget"]["vms"]
assert len(payload["pluginRowGroups"]["containers"][0]) == payload["rowBudget"]["containers"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_engine_column_reads_the_full_vms_list_not_the_budgeted_one():
"""vms/render_curses_v5.py:157 decides `show_engine` from the FULL item
list, BEFORE its own `items[:budget]` slice (:169) -- a component that
decided the flag from the already-sliced rows instead would flip Engine
off whenever the budget crops the row carrying the second engine.
`budget-vms-column-parity`'s five VMs carry two distinct engines, but
only the first three (one engine) survive the scenario's 3-row quota
(confirmed against `planRightColumn()` directly): the Engine column must
still render.
"""
payload = _run_render_probe("budget-vms-column-parity")
assert payload["rowBudget"]["vms"] == 3, f"got {payload['rowBudget']!r}"
headers = payload["pluginHeaderCells"].get("vms") or []
assert "Engine" in headers, f"got {headers!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_pod_column_reads_the_full_containers_list_not_the_budgeted_one():
"""containers/render_curses_v5.py:265 decides `show_pod` from the FULL
item list, BEFORE its own `items[:budget]` slice (:273) -- the same class
of bug as vms' own Engine column above, on containers_columns.js's OTHER
data-driven flag family. `budget-containers-column-parity`'s five
containers have a pod on two of them, but both sit past the scenario's
3-row quota (confirmed against `planRightColumn()` directly): the Pod
column must still render.
"""
payload = _run_render_probe("budget-containers-column-parity")
assert payload["rowBudget"]["containers"] == 3, f"got {payload['rowBudget']!r}"
headers = payload["pluginHeaderCells"].get("containers") or []
assert "Pod" in headers, f"got {headers!r}"
# ----------------------------------------------------- SHOW/HIDE hotkeys
#
# The key table itself is compared to the TUI's in
# tests/test_webui_v5_hotkeys_drift.py, and the toggle logic is unit-tested in
# tests/js/hotkeys.test.mjs. What is left, and only the probe can show it, is
# that pressing a key actually removes the block from the rendered DOM.
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
@pytest.mark.parametrize(
("key", "gone"),
[
("n", {"network"}),
("d", {"diskio"}),
("s", {"sensors"}),
("f", {"fs", "folders"}), # compound: one key, two plugins
],
)
def test_a_show_hide_key_removes_its_blocks_from_the_page(key, gone):
before = set(_run_render_probe("default")["pluginNames"])
assert gone <= before, f"vacuous: {sorted(gone - before)} was not rendered to begin with"
after = set(_run_render_probe("default", key)["pluginNames"])
assert not (gone & after), f"{key!r} left {sorted(gone & after)} on the page"
# Only the named plugins go.
assert before - gone == after
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_pressing_a_key_twice_brings_the_block_back():
before = set(_run_render_probe("default")["pluginNames"])
after = set(_run_render_probe("default", "n,n")["pluginNames"])
assert after == before
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_slot_keys_clear_their_whole_column():
"""`2` and `5` resolve against the live registry, so they cover exactly the
plugins the page actually renders in that slot."""
baseline = _run_render_probe("default")
left = set(baseline["slots"].get("left", []))
top = set(baseline["slots"].get("top", []))
assert left and top, "vacuous: the fixture has no left/top column"
after_2 = _run_render_probe("default", "2")
assert after_2["slots"].get("left", []) == []
assert set(after_2["slots"].get("top", [])) == top, "`2` must not touch the top row"
after_5 = _run_render_probe("default", "5")
assert after_5["slots"].get("top", []) == []
assert set(after_5["slots"].get("left", [])) == left, "`5` must not touch the sidebar"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_slot_key_then_a_plugin_key_acts_on_that_one_plugin():
"""Slot keys are expanded to their members, so a later single-plugin key
un-hides that plugin alone — the TUI's behaviour (design section 5.4), and
the reason `2` is not carried as indivisible slot state."""
left = set(_run_render_probe("default")["slots"].get("left", []))
assert "network" in left
after = _run_render_probe("default", "2,n")
assert set(after["slots"].get("left", [])) == {"network"}
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_unbound_key_changes_nothing():
"""`y` is bound by neither v4 nor v5. A page that reacted to it would be
swallowing keystrokes the browser should keep."""
before = _run_render_probe("default")
after = _run_render_probe("default", "y")
assert after["pluginNames"] == before["pluginNames"]
assert after["userHidden"] == []
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_h_opens_and_closes_the_help_overlay():
assert _run_render_probe("default", "h")["showHelp"] is True
assert _run_render_probe("default", "h,h")["showHelp"] is False
# The overlay does not hide anything by itself.
assert _run_render_probe("default", "h")["userHidden"] == []
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_help_overlay_renders_every_bound_key():
"""Not just the flag: the rows must actually reach the DOM. The TUI
generates its overlay from `_HOTKEYS`, so a bound key cannot go
undocumented; `helpRows()` gives the browser the same property, and this is
what proves it survives the template."""
from glances.outputs.glances_curses_v5 import TuiV5
rendered = _run_render_probe("default", "h")["helpRows"]
bound = {
key: spec["desc"]
for key, spec in TuiV5._HOTKEYS.items()
if "hide" in spec or spec.get("group") == "TOGGLE VIEW"
}
# One row per bound key (24 SHOW/HIDE + 10 TOGGLE VIEW), plus `h` itself.
assert len(rendered) == len(bound) + 1 == 35
joined = " ".join(rendered)
for key, desc in bound.items():
assert f"{key}{desc}" in joined, f"{key} is missing from the overlay: {rendered!r}"
assert any("help" in row for row in rendered), "`h` must document itself"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_help_overlay_is_absent_until_asked_for():
"""v-if, not v-show: a closed overlay must not sit in the DOM, where the
degradation cascade would measure it."""
assert _run_render_probe("default")["helpRows"] == []
# ----------------------------------------------------- TOGGLE VIEW hotkeys
#
# `1`, `j`, `4`, `/`. Unlike SHOW/HIDE these do not remove a block: they flip
# HOW something is shown, over a default the server supplies in /api/5/args.
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_1_swaps_cpu_for_percpu():
before = set(_run_render_probe("default")["pluginNames"])
assert "cpu" in before and "percpu" not in before
after = set(_run_render_probe("default", "1")["pluginNames"])
assert "percpu" in after and "cpu" not in after
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_j_swaps_processlist_for_programlist():
before = set(_run_render_probe("default")["pluginNames"])
assert "processlist" in before and "programlist" not in before
after = set(_run_render_probe("default", "j")["pluginNames"])
assert "programlist" in after and "processlist" not in after
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_4_empties_the_top_row_but_for_quicklook():
"""The same set the TUI hides since 2026-09-22 — `_FULL_QUICKLOOK_HIDDEN`,
mirrored in full_quicklook.js and pinned by its own drift test."""
assert _run_render_probe("default", "4")["slots"]["top"] == ["quicklook"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_slash_switches_the_command_column_to_the_full_path():
"""`/` is not a visibility toggle: it changes what the Command column
renders. The browser had only the short form before this key existed."""
short = _run_render_probe("processlist")["pluginText"]["processlist"]
full = _run_render_probe("processlist", "/")["pluginText"]["processlist"]
assert "python3 myscript.py" in short
assert "/usr/bin/python3" not in short
assert "/usr/bin/python3 myscript.py" in full
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
@pytest.mark.parametrize(
("scenario", "key", "flag"),
[
("cpu-percpu-on", "1", "percpu"),
("quicklook-full", "4", "full_quicklook"),
],
)
def test_a_toggle_view_key_flips_the_servers_value_not_a_false_default(scenario, key, flag):
"""The override starts from what the server reported, so the FIRST press
always visibly changes something. A server started with `--percpu` or
`--full-quicklook` must see that key turn the mode OFF — an override
initialised to `false` would make the first press a no-op."""
assert _run_render_probe(scenario)["effectiveArgs"] is None, "vacuous: no key pressed yet"
after = _run_render_probe(scenario, key)["effectiveArgs"]
assert after[flag] is False, f"{key!r} did not turn {flag} off: {after!r}"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_a_toggle_view_key_is_reversible():
assert _run_render_probe("default", "1")["effectiveArgs"]["percpu"] is True
assert _run_render_probe("default", "1,1")["effectiveArgs"]["percpu"] is False
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_an_untouched_toggle_still_follows_the_server():
"""Only the pressed key is overridden. The others keep reading
`serverArgs`, which is re-read on every tick — a viewer who never pressed
`4` must still follow a server that has `--full-quicklook` on."""
args = _run_render_probe("quicklook-full", "1")["effectiveArgs"]
assert args["percpu"] is False, "`1` flipped the server's percpu=True"
assert args["full_quicklook"] is True, "`4` was never pressed; it must still follow the server"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_footer_offers_hotkeys_before_github():
"""The keys are otherwise undiscoverable: nothing on the page says `h`
exists. The link sits before GitHub, where a reader looking for what this
page can do finds it before the external links."""
about = _run_render_probe("default")["footerAbout"]
assert "Hotkeys" in about, about
assert about.index("Hotkeys") < about.index("GitHub")
# It is the first thing after the version, not buried at the end.
assert about[1] == "Hotkeys", about
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_footer_link_opens_the_same_overlay_as_h():
"""Both go through `toggleHelp()`, so the overlay they raise is the same
one. Asserted on the RENDERED rows, not just the flag.
NOTE on what this does and does not cover: the probe drives the method,
because its fake document dispatches no events, so it cannot see the
button's `@click` binding. That the button is bound to `toggleHelp` rather
than setting `showHelp` itself is pinned in test_webui_v5_tokens.py
(`test_the_footer_hotkeys_control_is_a_button_dressed_as_a_link`). The two
tests together are the coverage; neither is sufficient alone.
"""
by_click = _run_render_probe("default", "click:hotkeys")
by_key = _run_render_probe("default", "h")
assert by_click["showHelp"] is True
assert by_click["helpRows"] == by_key["helpRows"]
assert by_click["helpRows"], "vacuous: the overlay rendered no rows"
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_footer_link_toggles_rather_than_only_opening():
assert _run_render_probe("default", "click:hotkeys,click:hotkeys")["showHelp"] is False
# And the two entry points are interchangeable, in either order.
assert _run_render_probe("default", "click:hotkeys,h")["showHelp"] is False
assert _run_render_probe("default", "h,click:hotkeys")["showHelp"] is False
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
@pytest.mark.parametrize(
("key", "flag"),
[
("b", "byte"),
("6", "meangpu"),
("F", "fs_free_space"),
("B", "diskio_iops"),
("0", "load_irix"),
("T", "network_sum"),
],
)
def test_a_data_type_key_flips_its_flag(key, flag):
"""2.X-c: `b`, `6` and `F` change HOW a value is shown. They ride the same
override mechanism as the other TOGGLE VIEW keys."""
before = _run_render_probe("default")
assert before["effectiveArgs"] is None, "vacuous: no key pressed yet"
after = _run_render_probe("default", key)["effectiveArgs"]
assert flag in after, f"{key!r} published no {flag!r}: {sorted(after)!r}"
assert _run_render_probe("default", f"{key},{key}")["effectiveArgs"][flag] == (not after[flag])
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_the_fs_free_space_key_starts_from_the_payload_not_from_serverargs():
"""`--fs-free-space` AND `[fs] free_space` both resolve into the fs
plugin's own payload metadata, while /api/5/args dumps only the raw CLI
namespace. Seeding `F` from `serverArgs` would therefore read `false` on a
server whose CONFIG set it, and the first press would do nothing visible.
"""
seeded = _run_render_probe("default", "F")["effectiveArgs"]["fs_free_space"]
# The `default` fixture's fs payload carries no `free_space`, so the
# effective value starts false and one press turns it on.
assert seeded is True
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_B_switches_the_diskio_columns_to_iops():
"""Not a visibility toggle: `B` swaps which pair of fields the two columns
render. The labels come from the schema, so the header follows."""
before = _run_render_probe("diskio")
after = _run_render_probe("diskio", "B")
assert before["pluginColumnHeaders"]["diskio"] == ["DISK I/O", "R/s", "W/s"]
assert after["pluginColumnHeaders"]["diskio"] == ["DISK I/O", "IOR/s", "IOW/s"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_T_combines_the_network_columns():
before = _run_render_probe("network")
after = _run_render_probe("network", "T")
assert before["pluginColumnHeaders"]["network"] == ["NETWORK", "Rx/s", "Tx/s"]
assert after["pluginColumnHeaders"]["network"] == ["NETWORK", "Rx+Tx/s"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_T_drops_the_per_second_suffix_under_byte_mode():
"""v4 labels the combined column `Rx+Tx` under --byte and `Rx+Tx/s`
otherwise (`network/__init__.py:246-254`). The two keys compose."""
assert _run_render_probe("network", "T,b")["pluginColumnHeaders"]["network"] == ["NETWORK", "Rx+Tx"]
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_key_0_shows_the_load_averages_as_percentages():
"""Irix mode divides each average by `cpucore`. The fixture's load payload
carries the core count, without which the key correctly does nothing."""
before = _run_render_probe("load")["pluginText"]["load"]
after = _run_render_probe("load", "0")["pluginText"]["load"]
assert "%" not in before, before
assert "%" in after, after