Files
glances/tests/test_webui_v5_render.py
nicolargo 3e8e4c4ee8 Glances v5 - Fix findings of the 2026-09-13 code review
High
- config: an unreadable or unparsable glances.conf stops Glances (exit 2)
  instead of being skipped (v4 parity)
- ip: public API address policy enforced on the connection itself
  (redirects, re-resolution), shared address space rejected, credentials
  dropped on cross-origin redirects

Medium
- network: per-direction capacity is the full link speed (no /2)
- quicklook: GPU means read the gpu collection envelope
- mcp: top_processes_report accepts the collection envelope
- webserver/routes: password hashing off the event loop
- actions: no relaunch while a run is in flight, dedicated pool,
  optional [alerts] action_timeout
- alerts: vanished items resolve through hysteresis and are forgotten
- processlist: NI on 3 columns, integers never truncated
- tui: full-quicklook width leaves room for LOAD, percpu keeps its labels
  when the cascade hides quicklook, control characters painted as spaces
- fs: show/hide also match the device name
- webui: memswap/load without stats render dashes, quicklook bars keep a
  width without the CPU header, percpu twin of the quicklook rule

Low
- non-ASCII Basic auth username returns 401
- DNS rebinding warning follows --bind
- CSV export realigns reordered columns instead of rotating
- a failed cycle no longer doubles the next rate
- sensors <label>_<type> alias restored
- programs view NI formatting, Windows dpc in TUI and WebUI
- network rates 1000-1023 K/M/G fit their column
- webui footer shows resolutions as resolved, header cascade runs
  before text is cropped
- webpack dev server port parsed as a number
2026-09-13 13:09:54 +02:00

2370 lines
111 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 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.
The fixture's `fetch` stub answers `api/5/alert` with twelve events in the
real `_build_event()` shape (glances/alerts_v5.py:706-716), oldest first
-- matching get_history()'s documented most-recent-LAST contract
(glances/alerts_v5.py:181). Assert the footer actually renders them --
identified by plugin AND field, not just the bare level, so a fallback to
a field that does not exist cannot pass unnoticed -- AND that it keeps the
ten MOST RECENT, newest first: a single-alert stub could not catch
AppShell using `history.slice(0, 10)` (the ten OLDEST) instead of the
correct `history.slice(-10).reverse()`, which is exactly the bug that
shipped in the previous fix round. This is exactly the shape the G9-1
blank page took: an untested corner of an otherwise-green test suite --
and the corner turned out deeper than the first probe fix realised.
"""
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"
# The stub's api/5/alert fixture carries plugin="pluginN", field="total"
# for N in 0..11, oldest (0) first -- get_history()'s documented order.
# A footer that renders only the level (e.g. a fallback to a
# non-existent `description` field) would show "critical" with no
# plugin/field at all -- assert both are present for the regression
# from fix round 1.
footer_text = payload["footerText"] or ""
assert "plugin11" in footer_text and "total" in footer_text, (
f"expected the footer to identify the alert by plugin and field, got {footer_text!r}"
)
# The two OLDEST alerts must have been dropped (only 10 of 12 shown) --
# `history.slice(0, 10)` would keep these and drop the newest two
# instead, which is the regression from fix round 2. Match "pluginN "
# (with the trailing space before " total"), not a bare substring:
# "plugin1" is also a substring of "plugin10" and "plugin11".
assert "plugin0 " not in footer_text and "plugin1 " not in footer_text, (
f"expected the two oldest alerts dropped, got {footer_text!r}"
)
# Newest first: plugin11 (most recent) must render before plugin2
# (oldest of the ten kept) -- a `slice(-10)` without `.reverse()` would
# still keep the right ten alerts but in oldest-first order.
assert footer_text.index("plugin11") < footer_text.index("plugin2"), (
f"expected newest-first order, 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",
], 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",
], 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).
"""
payload = _run_render_probe("default")
assert "refresh 2s" in (payload["footerText"] or ""), f"got {payload['footerText']!r}"
# --------------------------------------------------------- mem TUI parity (G9-3 Task 5)
def _run_render_probe(scenario: 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],
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.
"""
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. Twenty-four, not the registry's
# twenty-five: `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"]) == 24, (
f"expected all twenty-four 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}"
# 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_a_prominent_alert_badges_its_level_word_only():
"""TUI parity (curses_renderer_v5.py alert grid): a prominent incident
paints the badge on its LEVEL cell only. Now that `gl-prominent` fills
the tier colour as a background, putting it on the whole footer <li>
would turn every prominent alert into a full-width coloured band.
Every alert in the probe fixture is critical AND prominent, so the line
keeps the tier text colour without the badge, and the level word alone
carries both classes.
"""
payload = _run_render_probe("default")
alerts = payload["footerAlerts"]
assert len(alerts) == 10, f"vacuous: expected the ten most recent alerts, got {alerts!r}"
for alert in alerts:
item_classes = alert["className"].split()
assert "gl-level-critical" in item_classes and "gl-prominent" not in item_classes, (
f"the line keeps the tier colour but never the badge: {alert!r}"
)
assert alert["level"] == "critical", f"the level word renders on its own: {alert!r}"
assert set((alert["levelClass"] or "").split()) == {"gl-level-critical", "gl-prominent"}, (
f"the level word carries the badge: {alert!r}"
)
@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}"
# ------------------------------------------------------ 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_the_tui_s_six_blocks_and_spares_load():
"""--full-quicklook hides cpu/npu/mpp/gpu/mem/memswap and deliberately NOT
load or percpu (curses_renderer_v5.py:89, exact v4 parity). The WebUI reads
the flag from /api/5/args, the way mem reads --byte.
All six hidden plugins and both spared ones are covered here (final
review, Minor 2 -- the original test only checked four of the six hidden
plugins and neither spared one). The `cpu-percpu-on` scenario's fixtures
set `--percpu` too, so `percpu` is not ALSO hidden by the unrelated
cpu/percpu exclusivity rule (Critical 1) -- this test observes only what
full_quicklook itself hides.
"""
payload = _run_render_probe("quicklook-full")
rendered = set(payload["pluginNames"])
for name in ("cpu", "npu", "mpp", "gpu", "mem", "memswap"):
assert name not in rendered, f"{name} must be hidden: {sorted(rendered)!r}"
assert "load" in rendered, f"load is spared: {sorted(rendered)!r}"
assert "percpu" in rendered, f"percpu is spared: {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_a_resolution_event_reads_as_resolved_not_as_an_alert():
"""The footer lists raw history events: a return to `ok` was rendered like
an alert ("fs /home percent — ok"). It now reads as the resolution of the
level it leaves, muted, with no tier colour."""
payload = _run_render_probe("alert-resolved")
resolved, opened = payload["footerAlerts"] # newest first
assert resolved["level"] == "critical → ok", f"got {resolved!r}"
assert "gl-muted" in resolved["className"].split(), f"got {resolved!r}"
assert "gl-level-ok" not in (resolved["className"] + " " + (resolved["levelClass"] or "")), f"got {resolved!r}"
assert opened["level"] == "critical", f"a real alert is unchanged: {opened!r}"