phase: 21_page_cleanups
**Phase 21 final verification pass** (all 3 task files already in `complete/`, working tree carries the implementation)
- **Verified** task 01: `#pause`/`Space`/`setPaused`/`_paused` gone — `grep -ri pause src/dashboards/web/` empty, 0 pause hits in all 8 served pages + 10 static assets; re-ran `data/logs/21-task01-nopause.cjs` → 30/30 PASS (hidden tab: 0 data fetches in 22 s, one poll 2 ms after return).
- **Verified** task 02: re-ran `21-task02-gpu-values.cjs` on the live fleet → all PASS (deskwork/driveripper show Model/VRAM/Pool/Temp at 16.3 px; gateway = 5 heroes, slot `hidden`, no `n/a`; hero order unchanged; GPU history chart still builds per host).
- **Verified** task 03: re-ran `21-task03-scroll.cjs` → 14/14 PASS (`class="data scroll"`, scrollWidth 1021 vs clientWidth 292 at 360 px, Rotate/Revoke/bundle reachable and hit-testable; unchanged at 1280 px). Screenshots refreshed in `data/logs/`.
- **Gate**: `uv run pytest` → **1471 passed, 15 skipped**; `--cov --cov-fail-under=94` → **98.94%**; `ruff check src tests agent` clean; `pyright` **0 errors**; `bash .agents/validate.sh` → **EXIT 0** (incl. `node --check`); `uv run pytest -m live --live` → **11 passed**; `/api/health` all 9 sources `ok`.
- **Docs fixed** (pre-existing + this phase): "all **eight** signed-in pages" was a miscount of `MARKUP_PAGES` (7 = 5 dashboards + `/settings` + `/certs`; `/login` is the 8th, unsigned) → corrected in `README.md`, `AGENTS.md` (×2) and the `tests/test_api.py` docstring/comment; `AGENTS.md` Commands hermetic tally 1470 → **1471**.
- **Docs reviewed, consistent**: Quickstart/commands/flags (`install-agent.sh`, `capture-fixtures.sh`, `dev-server.sh`), the env table vs `config.py`, every documented route vs the served route table, masthead/`/system` GPU/`/certs` scroll descriptions vs code, and all `data/logs/*` + `tests/fixtures/*` references.
- **Deviation**: `validate.sh`'s advisory `fastapi 0.153.0 → 0.156.4` left alone — no dependency was touched this phase, so AGENTS rule 8 ("check after touching dependencies") is not triggered; upgrading here would be an unrelated behaviour change.
- No live-infra changes made (repo-side + browser verification only).
- **Next pending phase**: `22_disks_hide_unconfigured`.
This commit is contained in:
+292
-17
@@ -26,14 +26,17 @@ from fastapi.testclient import TestClient
|
||||
import store_perf
|
||||
from auth_client import sign_in
|
||||
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
||||
from card_contract import HOST_SHAPES, assert_card_contract
|
||||
from dashboards import alerts, auth, certs
|
||||
from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, parse_range
|
||||
from dashboards.app import create_app
|
||||
from dashboards.collectors import UpstreamError
|
||||
from dashboards.config import Settings, Target
|
||||
from dashboards.rates import RateEstimator
|
||||
from dashboards.severity import classify
|
||||
from dashboards.sources import nut
|
||||
from dashboards.sources.disks import SMART_SERIES, DisksCollector, parse_disks_snapshot
|
||||
from dashboards.sources.host import HostCollector, parse_host_snapshot
|
||||
from dashboards.sources.llm import (
|
||||
HISTORY_METRICS,
|
||||
parse_gufo_metrics,
|
||||
@@ -999,7 +1002,8 @@ def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> N
|
||||
def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None:
|
||||
"""`⚙ Account` and `⚷ Certs` are the only way to the two admin pages, so they are everywhere.
|
||||
|
||||
One loop over all eight signed-in pages, because the controls are now the *same* on all of
|
||||
One loop over all seven signed-in pages (`MARKUP_PAGES`), because the controls are now the *same*
|
||||
on all of
|
||||
them: clock → `⚷ Certs` → `⚙ Account` → theme toggle, in `.masthead-right` and **outside**
|
||||
`.tabs` (the tab strip's structure — five dashboards, one of them current — is pinned by
|
||||
`test_the_disks_tab_is_in_the_masthead_of_every_page`, and a sixth entry with no
|
||||
@@ -1015,7 +1019,7 @@ def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None:
|
||||
certs_open = '<a class="ghost account" href="/certs"'
|
||||
# the opening tag **and** its label: the claim is the cluster does not change shape between
|
||||
# pages, and `/certs` shipped a theme toggle missing its `aria-pressed` for exactly the
|
||||
# reason nothing ever compared the eight markups
|
||||
# reason nothing ever compared the seven markups
|
||||
account = f'{account_open} title="Session and password">⚙ Account</a>'
|
||||
certs_link = f'{certs_open} title="Agent certificates">⚷ Certs</a>'
|
||||
theme = '<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">'
|
||||
@@ -1099,6 +1103,25 @@ def test_the_certs_page_carries_the_authority_and_the_agents_table(client) -> No
|
||||
assert "/api/certs/root/rotate" in js
|
||||
|
||||
|
||||
def test_the_certs_agents_table_scrolls_to_its_last_column(client) -> None:
|
||||
"""`#agents-table` carries the `.scroll` modifier, and the stylesheet still styles it.
|
||||
|
||||
`TODO.md:9`: at phone width the serial, expiry, `san` and controls columns were clipped by
|
||||
`.card { overflow: hidden }` with no way to reach them. `dash.css` turns `table.data` into
|
||||
a scroll container **only** through the `scroll` class, so the class and the rule are one
|
||||
mechanism and are pinned together — the phase-18 idiom of comparing the two places a
|
||||
contract lives, because a rename on either side silently un-scrolls the table.
|
||||
"""
|
||||
html = client.get(CERTS_PAGE).text
|
||||
assert '<table class="data scroll" id="agents-table">' in html, \
|
||||
"the agents table lost its .scroll modifier — the controls column is unreachable at 360px"
|
||||
|
||||
css = client.get("/static/dash.css").text
|
||||
start = css.index("table.data.scroll {")
|
||||
rule = css[start:css.index("}", start)]
|
||||
assert "overflow: auto" in rule, "table.data.scroll no longer scrolls — the class names nothing"
|
||||
|
||||
|
||||
def test_the_certs_page_prints_the_names_the_authority_mints(client) -> None:
|
||||
"""The CN of each certificate is markup, so the literal is pinned to `certs.py`.
|
||||
|
||||
@@ -1427,42 +1450,212 @@ def test_system_page_colours_from_the_payload_not_from_js_thresholds(client) ->
|
||||
assert "hero-bar" not in gpu_slot, "the GPU hero rebuilt the bar variant of .hero"
|
||||
|
||||
|
||||
def test_gpu_hero_footnote_prints_the_temperature_the_agent_read(client) -> None:
|
||||
"""TODO.md L9: the GPU temperature is glanceable on the hero row, not only in the table.
|
||||
#: Every cell of the `/system` GPU card's value grid, in render order, with the `gpus[]`
|
||||
#: key(s) it prints. This is the `SLOT_FIELDS` device applied to the four values phase 21
|
||||
#: task 02 created: the table is read out of the served `GPU_VALUE_FIELDS` and resolved
|
||||
#: against every captured host shape *and* against `/api/system/snapshot`, so a rename on
|
||||
#: either side fails a test instead of printing `n/a` beside a healthy fleet — and so the
|
||||
#: dense ` · `-joined sub-line the TODO complained about cannot come back.
|
||||
GPU_VALUE_CELLS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("Model", ("model",)),
|
||||
("VRAM", ("vram_used_bytes", "vram_total_bytes")),
|
||||
("Pool", ("mem_used_pct",)),
|
||||
("Temp", ("temp_c",)),
|
||||
)
|
||||
|
||||
`gpus[].temp_c` is the agent's max over that card's hwmon labels (`sources/host.py`,
|
||||
pinned by `tests/test_system_contract.py`), so the hero needed no new field and the
|
||||
`gpu.temp_c@N` history series kept its name. The null guard *is* the behaviour, so that
|
||||
is what the assertion pins (there is no JS test runner — a locked anchor): a card whose
|
||||
driver exposes no temperature prints none, because `n/a` is for numbers the card is
|
||||
supposed to have and a `0 °C` would be a cool GPU that does not exist.
|
||||
|
||||
def _gpu_value_fields(js: str) -> list[tuple[str, tuple[str, ...]]]:
|
||||
"""The served `GPU_VALUE_FIELDS` table as `(label, payload keys)` rows."""
|
||||
table = re.search(r"const GPU_VALUE_FIELDS = \[(.*?)\n\];", js, re.S)
|
||||
assert table, "page-system.js no longer has a GPU_VALUE_FIELDS table"
|
||||
rows: list[tuple[str, tuple[str, ...]]] = []
|
||||
for entry in re.findall(r"\{[^{}]*\}", table.group(1)):
|
||||
label = re.search(r"label: '(\w+)'", entry)
|
||||
field = re.search(r"field: '(\w+)'", entry)
|
||||
assert label and field, f"a GPU value cell names no label or field: {entry!r}"
|
||||
total = re.search(r"total: '(\w+)'", entry)
|
||||
rows.append((label.group(1),
|
||||
(field.group(1), total.group(1)) if total else (field.group(1),)))
|
||||
return rows
|
||||
|
||||
|
||||
def test_gpu_values_are_four_labelled_cells_not_a_dense_sub_line(client) -> None:
|
||||
"""`TODO.md:12` — "… is too much info for such small text and should be its own values."
|
||||
|
||||
The GPU slot used to print `Radeon 8060S Graphics · VRAM 180.2 MiB / 512.0 MiB · pool
|
||||
79% · 69 °C` as one `.sub` line in 11px mono. It is now four labelled cells, and the
|
||||
pin is bidirectional: the table has the four rows in order, and the joined-sentence
|
||||
builder that produced the TODO's quoted string is gone (`gpuSub` and its
|
||||
`bits.join(' · ')` are the thing that made the card unreadable).
|
||||
|
||||
Two invariants ride on the same slice. The model string is upstream text (a PCI device
|
||||
name), so it leaves through `DashCharts.escapeHtml`; and the temperature keeps its
|
||||
null guard — `0 °C` would be a cool GPU that does not exist (phase 06).
|
||||
"""
|
||||
js = client.get("/static/page-system.js").text
|
||||
sub = js[js.index("function gpuSub("):js.index("function renderGpuSlot(")]
|
||||
assert "gpu.temp_c" in sub, "the GPU hero stopped printing the temperature"
|
||||
assert "if (gpu.temp_c != null)" in sub, "the temperature lost its null guard"
|
||||
assert sub.count("°C") == 1, (
|
||||
"a second °C in gpuSub is a branch printing a temperature nobody measured"
|
||||
)
|
||||
assert _gpu_value_fields(js) == list(GPU_VALUE_CELLS), \
|
||||
"the GPU value grid is no longer Model / VRAM / Pool / Temp over those payload keys"
|
||||
# the °C rides in the value (the label is the word, as on the /disks card and the GPU
|
||||
# detail table), and it appears exactly once in the table: one cell, one unit.
|
||||
assert js.count("unit: ' °C'") == 1, "the Temp cell's unit is not the one °C on the card"
|
||||
|
||||
body = js[js.index("function gpuValues("):js.index("function renderGpuSlot(")]
|
||||
assert "GPU_VALUE_FIELDS.map(" in body, "gpuValues() stopped rendering the table"
|
||||
assert "escapeHtml" in body, "the Model cell prints an upstream string unescaped"
|
||||
assert "== null" in body, "a cell lost its null guard (missing must print n/a, not 0)"
|
||||
|
||||
# the dense sub-line the TODO quoted is gone, container and all
|
||||
assert "function gpuSub(" not in js, "gpuSub() came back"
|
||||
assert "join(' · ')" not in js, "the ` · `-joined GPU footnote came back"
|
||||
|
||||
# "More detail" keeps the per-label breakdown: a surface change, not a de-duplication
|
||||
extra = js[js.index("function renderGpuExtra("):js.index("function renderHostCard(")]
|
||||
assert "Temp" in extra and "gpu.temp_c" in extra, "GPU detail lost its temperature column"
|
||||
|
||||
|
||||
def test_gpu_value_cells_resolve_against_every_captured_host(
|
||||
agent_snapshots: dict[str, dict[str, Any]]) -> None:
|
||||
"""Every key the four cells print exists on every captured card — discrete GPU and APU.
|
||||
|
||||
`tests/card_contract.py` owns the payload table; this resolves the *page's* table
|
||||
against the same three captures, which is where a rename would otherwise be caught by
|
||||
an operator looking at `n/a` next to a healthy machine. `temp_c` may legitimately be
|
||||
`None` (a driver with no hwmon input) — present is the contract, and the cell prints
|
||||
`n/a` for it, never `0 °C`.
|
||||
"""
|
||||
for name in HOST_SHAPES:
|
||||
payload = parse_host_snapshot(name, agent_snapshots[name], HostCollector(
|
||||
RateEstimator(span=60.0)), now=1_000.0).payload
|
||||
assert_card_contract(payload)
|
||||
for gpu in payload["gpus"]:
|
||||
for label, keys in GPU_VALUE_CELLS:
|
||||
for key in keys:
|
||||
assert key in gpu, f"{name}: the {label!r} cell has no {key!r} key"
|
||||
if not payload["gpus"]:
|
||||
# gateway: the omission's input. An empty list is a *fact* about the box, and
|
||||
# `renderGpuSlot()` turns that fact into an absent slot rather than an `n/a`.
|
||||
assert name == "gateway", "a capture lost its GPU row"
|
||||
|
||||
|
||||
def test_the_gpu_value_cells_are_served_for_the_hosts_that_have_a_card(
|
||||
tmp_path: Path, agent_snapshots: dict[str, dict[str, Any]]) -> None:
|
||||
"""The same four keys over HTTP, on the body `/system` actually renders.
|
||||
|
||||
Parsing in-process proves the source; this proves the endpoint. One app with all three
|
||||
hosts injected (dead ports, so no poll can overwrite what the test put there) answers
|
||||
with the discrete-GPU card, the APU card, and gateway's empty list — the exact union
|
||||
`renderGpuSlot()` branches on: cards present means four labelled values per card, `[]`
|
||||
means no slot at all.
|
||||
"""
|
||||
db = str(tmp_path / "gpu-cells.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
host_targets=tuple(
|
||||
Target(name, DEAD_URL) for name in HOST_SHAPES)), store)
|
||||
test_client = TestClient(app)
|
||||
test_client.__enter__()
|
||||
try:
|
||||
sign_in(test_client)
|
||||
hub = app.state.ctx.hub
|
||||
for name in HOST_SHAPES:
|
||||
hub.jobs[f"host:{name}"].state.payload = parse_host_snapshot(
|
||||
name, agent_snapshots[name], HostCollector(RateEstimator(span=60.0)),
|
||||
now=1_000.0).payload
|
||||
hosts = {row["name"]: row for row in test_client.get("/api/system/snapshot").json()["hosts"]}
|
||||
assert set(hosts) == set(HOST_SHAPES)
|
||||
for name, row in hosts.items():
|
||||
for gpu in row["gpus"]:
|
||||
for label, keys in GPU_VALUE_CELLS:
|
||||
for key in keys:
|
||||
assert key in gpu, f"{name}: /api/system/snapshot has no {key!r} " \
|
||||
f"for the {label!r} cell"
|
||||
assert hosts["gateway"]["gpus"] == [], \
|
||||
"gateway must answer [] — that is what makes the page omit the slot"
|
||||
assert hosts["driveripper"]["gpus"] and hosts["deskwork"]["gpus"]
|
||||
finally:
|
||||
test_client.__exit__(None, None, None)
|
||||
store.close()
|
||||
|
||||
|
||||
def test_a_host_with_no_gpu_omits_the_gpu_slot_instead_of_printing_n_a(client) -> None:
|
||||
"""`TODO.md:12` — "Boxes without a GPU should not report N/A but instead should omit
|
||||
these values."
|
||||
|
||||
gateway has no card, and the sixth hero slot used to say so with a big faint `n/a` plus
|
||||
the sentence "No GPU reported by this agent" — invariant 3's complaint in the one place
|
||||
the page had not learned it. `renderGpuSlot()` now empties the slot and sets `hidden`,
|
||||
and `[hidden] { display: none !important }` at the end of `dash.css` is what takes the
|
||||
wrapper out of the `.heroes` grid so the row reflows to five. Pinned on the served
|
||||
bytes (no JS runner — a locked anchor) and measured in a browser by
|
||||
`data/logs/21-task02-gpu-values.cjs`.
|
||||
|
||||
The wrapper element itself stays in `hostCard()`: the hero row remains one static list
|
||||
of six `data-k` slots (pinned by the test below), a host whose agent starts reporting a
|
||||
card has something to grow into, and an empty hidden element is zero footprint.
|
||||
"""
|
||||
js = client.get("/static/page-system.js").text
|
||||
slot = js[js.index("function renderGpuSlot("):js.index("function renderGpuExtra(")]
|
||||
assert "cell.hidden = gpus.length === 0" in slot, \
|
||||
"the GPU slot no longer hides itself when the host has no card"
|
||||
assert "No GPU reported" not in js, "the no-GPU n/a sentence came back"
|
||||
assert "n/a" not in slot, "the GPU slot grew its own n/a fallback again"
|
||||
# and the row itself keeps the six static slots, so a GPU host still has somewhere to go
|
||||
assert "display: none !important" in client.get("/static/dash.css").text, \
|
||||
"[hidden] stopped beating .hero-slot's display, so a hidden slot would still " \
|
||||
"occupy a hero track"
|
||||
|
||||
|
||||
def test_gpu_value_grid_ships_no_verdict_and_wraps(client) -> None:
|
||||
"""The four new cells colour nothing and let a long model string wrap.
|
||||
|
||||
Phase 06's rule extends to them: `severity.THRESHOLDS` has no `gpu.*` row, the payload
|
||||
carries no `gpus[].severity`, so neither the markup nor the grid may reach for
|
||||
`--ok`/`--warn`/`--fail` or a `severity` option (a busy GPU on an LLM server is the job).
|
||||
`overflow-wrap` is the other half of `TODO.md:12` — the readability complaint *is* the
|
||||
20-character model string, and truncating it would keep the complaint.
|
||||
"""
|
||||
js = client.get("/static/page-system.js").text
|
||||
# word-boundary on purpose: the docstring in this slice is allowed to *say* "severity",
|
||||
# the code is not allowed to pass one (or a bar, or a verdict class) to anything
|
||||
markup = js[js.index("const GPU_VALUE_FIELDS"):js.index("function renderGpuSlot(")]
|
||||
verdicts = re.findall(r"\b(ok|warn|fail|severity:|bar:|hero-bar)\b", markup)
|
||||
assert not verdicts, f"the GPU value grid grew a verdict: {verdicts}"
|
||||
|
||||
css = client.get("/static/dash.css").text
|
||||
rule = re.search(r"\.gpu-values \{([^}]*)\}", css)
|
||||
assert rule, "/system lost its .gpu-values grid"
|
||||
assert "auto-fit" in rule.group(1), ".gpu-values stopped adapting to the hero track"
|
||||
wrap = re.search(r"\.gpu-values \.value \{([^}]*)\}", css)
|
||||
assert wrap and "overflow-wrap" in wrap.group(1), \
|
||||
"a long GPU model string truncates instead of wrapping"
|
||||
for token in ("--ok", "--warn", "--fail"):
|
||||
assert token not in rule.group(1) + (wrap.group(1) if wrap else ""), \
|
||||
f"the GPU value grid coloured itself with {token} (severity.py rates no GPU)"
|
||||
|
||||
|
||||
def test_system_host_card_is_six_heroes_without_per_core(client) -> None:
|
||||
"""Each host card leads with exactly six heroes: CPU, MEM, TEMP, DISK, GPU, NET.
|
||||
"""Each host card ships six hero slots in order: CPU, MEM, TEMP, DISK, GPU, NET.
|
||||
|
||||
Asserted on the served script because there is no JS test runner (a locked anchor).
|
||||
The two dense `.metrics` rows and the per-core heat bars are exactly what made the
|
||||
card unreadable, and "no per-core anywhere on /system" is a promise a stray
|
||||
`renderCores` call would silently break — the series stays stored, the page just
|
||||
stopped drawing it (PLAN.md *Dropped visuals*).
|
||||
|
||||
Six **slots**, not six visible heroes: the GPU slot ships `hidden` and
|
||||
`renderGpuSlot()` un-hides it only for a host whose payload has a card, so gateway's
|
||||
card renders five (pinned by the test above and measured in the browser by
|
||||
`data/logs/21-task02-gpu-values.cjs`). The static order below is what makes that a
|
||||
one-line rule instead of a re-flowing template.
|
||||
"""
|
||||
js = client.get("/static/page-system.js").text
|
||||
card = js[js.index("function hostCard("):js.index("function renderHostCard(")]
|
||||
slots = re.findall(r'<div class="(?:hero|hero-slot)[^"]*" data-k="([^"]+)"', card)
|
||||
assert slots == ["cpu", "mem", "temp", "disk", "gpus", "net"], \
|
||||
"the hero row is no longer the six numbers in the agreed order"
|
||||
assert re.search(r'data-k="gpus"[^>]*hidden', card), \
|
||||
"the GPU slot is visible before the payload says this host has a card"
|
||||
# per-core visualisation is gone from the card code entirely
|
||||
assert "cores" not in card, "a per-core visual came back to the card"
|
||||
assert "renderCores" not in js and "core_usage" not in js
|
||||
@@ -4459,6 +4652,88 @@ def test_the_menu_snaps_in_both_directions_under_reduced_motion(client) -> None:
|
||||
"the named open rule stopped stilling the transition"
|
||||
|
||||
|
||||
# ---- the masthead with no polling control (phase 21 task 01) -------------- #
|
||||
#
|
||||
# `TODO.md:7` — "Completely remove the pause button". The control was four things: a masthead
|
||||
# button on the four dashboards, a `Space` binding, the `setPaused`/`_paused` plumbing inside
|
||||
# `Dash.poll()`, and a stylesheet comment naming it as a row-3 tenant. A browser is the only
|
||||
# thing that can prove the operator no longer sees a button, but the served bytes are what ship
|
||||
# it, so those are what fail a test when someone puts it back.
|
||||
#
|
||||
# The **hidden-tab** skip is deliberately *not* part of the removal and is pinned on the same
|
||||
# page for that reason: a backgrounded tab asking for nothing is idle hygiene rather than a
|
||||
# decision the operator made, and a poll loop "simplified" back to an unconditional `setTimeout`
|
||||
# would be invisible until a laptop battery complained.
|
||||
|
||||
#: Every spelling the deleted control ever had, in markup, script or stylesheet.
|
||||
PAUSE_ARTIFACTS = ('id="pause"', 'name="pause"', "⏸", "▶ Paused",
|
||||
"bindPause", "setPaused", "_paused")
|
||||
|
||||
|
||||
def test_no_page_serves_a_button_that_stops_the_polls(client, tmp_path: Path) -> None:
|
||||
"""Eight pages, eleven assets, zero pause control.
|
||||
|
||||
`/login` needs its own anonymous `TestClient` for the reason every other login-page audit
|
||||
gives: this `client` is signed in, and a signed-in `GET /login` is a `303` to the dashboard,
|
||||
which would audit `/`'s bytes and call them `/login`. The other seven are read as themselves.
|
||||
"""
|
||||
bodies = {path: client.get(path).text for path in MARKUP_PAGES}
|
||||
|
||||
db = str(tmp_path / "no-pause-login.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2), store)
|
||||
try:
|
||||
with TestClient(app) as anon:
|
||||
response = anon.get("/login")
|
||||
assert response.status_code == 200, \
|
||||
"a signed-out /login must be the form, not a bounce"
|
||||
bodies["/login"] = response.text
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
assert len(bodies) == 8, "the audit lost a page; every served page owes this check"
|
||||
for path, html in bodies.items():
|
||||
for artifact in PAUSE_ARTIFACTS:
|
||||
assert artifact not in html, f"{path} serves the removed control again: {artifact!r}"
|
||||
|
||||
for asset in ASSETS:
|
||||
body = client.get(asset).text
|
||||
for artifact in PAUSE_ARTIFACTS:
|
||||
assert artifact not in body, \
|
||||
f"{asset} still carries the removed control: {artifact!r}"
|
||||
|
||||
|
||||
def test_the_poll_loop_still_idles_on_a_hidden_tab_and_binds_no_key_for_the_old_control(
|
||||
client,
|
||||
) -> None:
|
||||
"""What survives the deletion, pinned beside what must not come back.
|
||||
|
||||
The first block is the hidden-tab behaviour the phase keeps (`document.hidden` skips the run
|
||||
and reschedules; `visibilitychange` fires one poll on return). The second is the `Space`
|
||||
binding: no `event.code` check may live in the shared script, because a key that stops the
|
||||
numbers with no control on screen to say so is the failure mode this task exists to retire.
|
||||
The third is the half of the old listener that was never about pausing — `T` still toggles
|
||||
the theme, on the same four pages it always did.
|
||||
"""
|
||||
app_js = client.get("/static/app.js").text
|
||||
loop = app_js[app_js.index("function poll(fn, ms) {"):app_js.index(" // ---- chrome")]
|
||||
assert "if (stopped || inFlight || document.hidden) { schedule(); return; }" in loop, \
|
||||
"a hidden tab no longer skips its poll — the idle-hygiene guard is not the button"
|
||||
assert "document.addEventListener('visibilitychange', () => { if (!document.hidden) tick(); })" in loop, \
|
||||
"returning to a backgrounded tab no longer fires a poll — the page would sit on old numbers"
|
||||
|
||||
assert "event.code" not in app_js, \
|
||||
"a key-code binding is back in the shared script — the only one it carried was the deleted control's"
|
||||
assert "'Space'" not in app_js, "the deleted control's keyboard shortcut came back"
|
||||
|
||||
assert "event.key === 't' || event.key === 'T'" in app_js, \
|
||||
"`T` stopped toggling the theme: the shortcut that shared the deleted binding survives"
|
||||
for name in ("page-system.js", "page-disks.js", "page-llm.js", "page-ups.js"):
|
||||
assert "Dash.bindShortcuts();" in client.get(f"/static/{name}").text, \
|
||||
f"{name} no longer opts in to the theme shortcut it always had"
|
||||
|
||||
|
||||
def test_the_header_status_dot_picks_up_the_disks_job(client) -> None:
|
||||
"""`disks` gets its own dot instead of hiding inside `hosts`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user