Files
wehub-resource-sync 4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

362 lines
14 KiB
Python

"""Pure rendering tests for the ``/fleet`` dashboard table (issue #1488).
These cover ``render_agents_table`` in isolation — no slash-command
dispatch, no real registry I/O. The integration tests in
``test_agents_commands.py`` cover the dispatch path that consumes
this function.
"""
from __future__ import annotations
import io
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from rich.console import Console
from rich.table import Table
from surfaces.interactive_shell.ui.agents import agents_view as agents_view_mod
from surfaces.interactive_shell.ui.agents.agents_view import _build_agents_table
from tools.system.fleet_monitoring import config as config_mod
from tools.system.fleet_monitoring.probe import ProcessSnapshot
from tools.system.fleet_monitoring.registry import AgentRecord
from tools.system.fleet_monitoring.token_rate import TOKEN_RATE_TRACKER
@pytest.fixture(autouse=True)
def isolated_agents_yaml(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Autouse: redirect ``agents_config_path`` to a per-test tmp file
so the rendering tests don't read the developer's real
``~/.opensre/agents.yaml`` (which would let real budgets
leak into the placeholder assertions and create cross-machine
flakes).
"""
target = tmp_path / "agents.yaml"
monkeypatch.setattr(config_mod, "agents_config_path", lambda: target)
return target
@pytest.fixture(autouse=True)
def _clear_sampler_module_state() -> None:
"""Reset module-level state in the sampler before each test.
Three globals can leak across test files:
- :data:`tools.system.fleet_monitoring.sampler._latest` (probe snapshots dict)
- :data:`tools.system.fleet_monitoring.token_rate.TOKEN_RATE_TRACKER` (per-PID deque)
- :data:`tools.system.fleet_monitoring.sampler._TickCache.registry_snapshot` and
:data:`_TickCache.agents_config` (tick caches added in #2023 to
avoid re-reading the disk on every render)
Sampler tests populate all of them; view tests must start clean
so placeholder assertions are stable under ``pytest-xdist`` and
in arbitrary alphabetical order.
"""
from tools.system.fleet_monitoring import sampler as sampler_mod
sampler_mod._latest.clear()
sampler_mod._TickCache.registry_snapshot = {}
sampler_mod._TickCache.agents_config = None
for pid in list(TOKEN_RATE_TRACKER.known_pids()):
TOKEN_RATE_TRACKER.forget(pid)
# The columns this PR ships are the contract for #1490 and later
# tickets that thread snapshot data into the rendering layer; pin
# them here so a downstream reorder doesn't silently break the
# dashboard preview.
_DASHBOARD_COLUMNS: tuple[str, ...] = (
"agent",
"pid",
"uptime",
"cpu%",
"tokens/min",
"$/hr",
"status",
)
def _render(records: list[AgentRecord]) -> tuple[Table, str]:
"""Build the table and capture the printed form for substring assertions."""
table = _build_agents_table(records)
buf = io.StringIO()
Console(file=buf, force_terminal=False, highlight=False, width=120).print(table)
return table, buf.getvalue()
# ---------------------------------------------------------------------------
# Column structure — the contract downstream tickets lean on
# ---------------------------------------------------------------------------
def test_table_has_full_dashboard_column_set_in_documented_order() -> None:
table, _ = _render([])
headers = tuple(str(col.header) for col in table.columns)
assert headers == _DASHBOARD_COLUMNS
def test_pid_column_is_right_justified_to_match_numeric_dashboard_preview() -> None:
"""Numeric columns (pid, uptime, cpu%, tokens/min, $/hr) are
right-justified; agent name and status are left-justified. This
matches the spacing in the issue's mock and keeps later
snapshot-injected cells aligned without re-styling."""
table, _ = _render([])
by_header = {str(col.header): col for col in table.columns}
assert by_header["pid"].justify == "right"
assert by_header["uptime"].justify == "right"
assert by_header["cpu%"].justify == "right"
assert by_header["tokens/min"].justify == "right"
assert by_header["$/hr"].justify == "right"
assert by_header["agent"].justify == "left"
assert by_header["status"].justify == "left"
# ---------------------------------------------------------------------------
# Empty state
# ---------------------------------------------------------------------------
def test_empty_records_renders_table_with_zero_rows() -> None:
table, _ = _render([])
assert table.row_count == 0
def test_empty_records_caption_announces_empty_state() -> None:
"""Empty-state UX: the table caption tells the user the fleet
is empty rather than leaving a blank table that looks like a bug.
"""
_, out = _render([])
assert "no agents discovered or registered yet" in out
def test_non_empty_records_have_no_caption() -> None:
"""When the registry has rows, the caption is suppressed —
the table content speaks for itself and a caption would be noise."""
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
assert table.caption is None
# ---------------------------------------------------------------------------
# Row content
# ---------------------------------------------------------------------------
def test_row_contains_agent_name_and_pid() -> None:
_, out = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
assert "claude-code" in out
assert "8421" in out
def test_metric_cells_are_placeholders_when_no_sampler_data() -> None:
"""All five metric cells render ``-`` when the sampler has no
data for the PID (REPL not running, non-interactive ``opensre
agents list``, or fresh registration that has not yet been
sampled). #2023 split ``tokens/min`` and ``$/hr`` from the
yaml budget; both still fall back to ``-`` here for the same
"no data" reason.
"""
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
# row_count == 1, so iterate directly to inspect the rendered cells
assert table.row_count == 1
rendered_cells = [list(col.cells)[0] for col in table.columns]
# cells[0] = agent, cells[1] = pid, then metric cells and status.
assert rendered_cells[2:] == ["-", "-", "-", "-", "-"]
def test_table_shows_live_probe_data_when_snapshot_exists(monkeypatch: pytest.MonkeyPatch) -> None:
fixed_now = datetime(2026, 5, 10, 14, 0, 0, tzinfo=UTC)
started_at = datetime(2026, 5, 10, 12, 0, 0, tzinfo=UTC) # exactly 2h before
class FrozenDatetime(datetime):
@classmethod
def now(cls, _tz=None): # type: ignore[override]
return fixed_now
monkeypatch.setattr(agents_view_mod, "datetime", FrozenDatetime)
fake_snapshot = ProcessSnapshot(
pid=8421,
cpu_percent=23.5,
rss_mb=128.0,
num_fds=42,
num_connections=3,
status="running",
started_at=started_at,
)
monkeypatch.setattr(agents_view_mod, "get_snapshot", lambda _pid: fake_snapshot)
table, _ = _render([AgentRecord(name="cursor", pid=8444, command="cursor")])
rendered_cells = [list(col.cells)[0] for col in table.columns]
# ``tokens/min`` and ``$/hr`` are still ``-`` because no token
# tracker entry exists for this PID — the snapshot fixture covers
# only the resource side. The full live-data case is covered by
# ``test_table_shows_tokens_and_cost_when_tracker_has_data``.
# Without recent output activity, the status heuristic falls back
# to the process start time and renders STUCK with a progress-time annotation.
assert rendered_cells[2:] == ["2h0m", "23.5", "-", "-", "[red]stuck (2h0m no progress)[/red]"]
def test_recent_agent_output_keeps_status_active(monkeypatch: pytest.MonkeyPatch) -> None:
fixed_now = datetime(2026, 5, 10, 14, 0, 0, tzinfo=UTC)
class FrozenDatetime(datetime):
@classmethod
def now(cls, _tz=None): # type: ignore[override]
return fixed_now
monkeypatch.setattr(agents_view_mod, "datetime", FrozenDatetime)
fake_snapshot = ProcessSnapshot(
pid=8421,
cpu_percent=23.5,
rss_mb=128.0,
num_fds=42,
num_connections=3,
status="running",
started_at=fixed_now - timedelta(hours=2),
last_output_at=fixed_now - timedelta(seconds=30),
)
monkeypatch.setattr(agents_view_mod, "get_snapshot", lambda _pid: fake_snapshot)
table, _ = _render([AgentRecord(name="cursor", pid=8444, command="cursor")])
rendered_cells = [list(col.cells)[0] for col in table.columns]
assert rendered_cells[6] == "[green]active[/green]"
def test_stuck_message_anchors_to_last_output_at_not_started_at(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fixed_now = datetime(2026, 5, 10, 14, 0, 0, tzinfo=UTC)
class FrozenDatetime(datetime):
@classmethod
def now(cls, _tz=None): # type: ignore[override]
return fixed_now
monkeypatch.setattr(agents_view_mod, "datetime", FrozenDatetime)
fake_snapshot = ProcessSnapshot(
pid=8421,
cpu_percent=5.0,
rss_mb=64.0,
num_fds=10,
num_connections=1,
status="running",
started_at=fixed_now - timedelta(hours=3),
last_output_at=fixed_now - timedelta(minutes=10),
)
monkeypatch.setattr(agents_view_mod, "get_snapshot", lambda _pid: fake_snapshot)
table, _ = _render([AgentRecord(name="cursor", pid=8444, command="cursor")])
rendered_cells = [list(col.cells)[0] for col in table.columns]
assert rendered_cells[6] == "[red]stuck (10m no progress)[/red]"
def test_table_shows_tokens_and_cost_when_tracker_has_data(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""End-to-end of the #2023 wiring on the view side: when the
sampler's accessors return live numbers, the view formats them
into the ``tokens/min`` and ``$/hr`` columns.
"""
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 120.0)
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: 0.27)
table, _ = _render([AgentRecord(name="claude-code-8421", pid=8421, command="claude")])
rendered_cells = [list(col.cells)[0] for col in table.columns]
# cells[4] = tokens/min, cells[5] = $/hr
assert rendered_cells[4] == "120"
assert rendered_cells[5] == "$0.27"
def test_tokens_per_min_formatter_uses_k_suffix_above_1000(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A busy session emitting > 1k tokens/min must not blow out the
column width; the formatter falls back to ``1.2k`` shorthand.
"""
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 1234.0)
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: None)
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
rendered_cells = [list(col.cells)[0] for col in table.columns]
assert rendered_cells[4] == "1.2k"
def test_idle_observed_agent_renders_zero_tokens_per_min_not_dash(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The ``0`` vs ``-`` distinction the tracker enforces must
propagate to the cell: a known-but-idle agent renders ``0``,
a never-observed agent renders ``-``.
"""
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 0.0)
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: 0.0)
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
rendered_cells = [list(col.cells)[0] for col in table.columns]
assert rendered_cells[4] == "0"
assert rendered_cells[5] == "$0.00"
def test_usd_per_hour_renders_dash_when_model_unknown(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``get_usd_per_hour`` returns ``None`` when the model cannot
be resolved (and therefore pricing cannot be applied). The cell
must render ``-`` rather than ``$0.00`` so the user does not
misread "unknown" as "free".
"""
monkeypatch.setattr(agents_view_mod, "get_tokens_per_min", lambda _pid: 500.0)
monkeypatch.setattr(agents_view_mod, "get_usd_per_hour", lambda _pid: None)
table, _ = _render([AgentRecord(name="claude-code", pid=8421, command="claude")])
rendered_cells = [list(col.cells)[0] for col in table.columns]
# tokens/min still shows the live figure …
assert rendered_cells[4] == "500"
# … but $/hr is honest about the missing rate.
assert rendered_cells[5] == "-"
def test_multiple_records_are_each_rendered_in_order() -> None:
records = [
AgentRecord(name="claude-code", pid=8421, command="claude"),
AgentRecord(name="cursor-tab", pid=9133, command="cursor"),
AgentRecord(name="aider", pid=7702, command="aider"),
]
table, out = _render(records)
assert table.row_count == 3
# Substring order in the rendered output preserves input order:
pos_claude = out.index("claude-code")
pos_cursor = out.index("cursor-tab")
pos_aider = out.index("aider")
assert pos_claude < pos_cursor < pos_aider
# ---------------------------------------------------------------------------
# Defense against Rich-markup injection
# ---------------------------------------------------------------------------
def test_record_name_is_rich_escaped_so_markup_does_not_render() -> None:
"""An adversarial agent name containing Rich markup tags must
render literally, not interpreted. Without ``escape()``, a name
like ``[bold red]ghost[/]`` would visually mimic a styled cell
and could mask other dashboard content."""
records = [
AgentRecord(name="[bold red]ghost[/]", pid=1, command="bin"),
]
_, out = _render(records)
# Literal brackets survive in the rendered output:
assert "[bold red]ghost[/]" in out
# Resilience to a schema-invalid ``agents.yaml`` is now enforced by
# the sampler's catch-all around ``load_agents_config`` (see
# ``test_sampler.py``); the view no longer touches the config.