diff --git a/agent-team/tests/test_status_page.py b/agent-team/tests/test_status_page.py index 0701622..700bdaf 100644 --- a/agent-team/tests/test_status_page.py +++ b/agent-team/tests/test_status_page.py @@ -9,6 +9,7 @@ exercised against a temp SQLite ledger seeded with a couple of rows (a real from __future__ import annotations +import json import sqlite3 from contextlib import closing from pathlib import Path @@ -16,10 +17,12 @@ from pathlib import Path import pytest from agent_team.status_page import ( + STAGES, Snapshot, TaskView, build_snapshot, render_html, + snapshot_to_dict, ) # --- pure render_html tests (no DB, no socket). ------------------------------ @@ -263,3 +266,151 @@ def test_build_snapshot_never_writes(tmp_path: Path) -> None: missing = tmp_path / "nope.sqlite" build_snapshot(missing) assert not missing.exists() # mode=ro did not create it + + +# --- pipeline-map + /api/state JSON contract tests --------------------------- + + +def test_render_html_includes_svg_map_and_poller() -> None: + """The page must carry the inline SVG map and the inline JS poller.""" + html_out = render_html(_sample_snapshot()) + # Inline SVG map (hand-rolled, not fetched). + assert '" in html_out + assert "fetch('/api/state'" in html_out + assert "setInterval(poll" in html_out + # Tooltip mount + live clock the poller updates. + assert 'id="tip"' in html_out + assert 'id="clock"' in html_out + # Legend present. + assert "awaiting human" in html_out + # Still fully offline: no CDN / external references. + assert "http://" not in html_out and "https://" not in html_out + # The no-JS fallback is the meta-refresh, now inside ' in html_out + + +def test_render_html_renders_map_even_with_no_data() -> None: + """A not-ok snapshot still renders the map + poller so it goes live later.""" + snap = Snapshot(generated_at="now", db_path="/x.sqlite", ok=False, error="boom") + html_out = render_html(snap) + assert '") + + +def test_snapshot_to_dict_shape_and_grouping() -> None: + """The /api/state payload has the expected keys + correct stage grouping.""" + payload = snapshot_to_dict(_sample_snapshot()) + assert payload["ok"] is True + assert set(payload) >= { + "ok", + "generated_at", + "stages", + "tasks", + "waiting", + "question_counts", + "summary", + "budget", + } + # One stage entry per declared STAGE, in declared order. + assert [s["key"] for s in payload["stages"]] == [s.key for s in STAGES] + + by_key = {s["key"]: s for s in payload["stages"]} + # The waiting clarify task lands on the clarify node and flags awaiting_human. + clarify = by_key["clarify"] + assert clarify["count"] == 1 + assert clarify["state"] == "awaiting_human" + assert clarify["tasks"][0]["short_id"] == "abc123de" + # Each stage carries its model/agent role for the per-node label. + assert clarify["agent"] == "Claude (sub)" + assert by_key["review"]["agent"] == "GPT-4.1 (cross)" + assert by_key["build"]["agent"] == "DeepSeek (fast)" + # The active plan task lands on the plan node as 'active'. + assert by_key["plan"]["state"] == "active" + assert by_key["plan"]["count"] == 1 + # The parked task maps onto the (terminal-ish) parked side — no stage owns + # the 'parked' phase, so no pipeline node claims it. + assert all( + t["thread_id"] != "dead0000beef" for s in payload["stages"] for t in s["tasks"] + ) + # An idle stage with no tasks. + assert by_key["intake"]["state"] == "idle" + assert by_key["intake"]["count"] == 0 + + # Summary mirrors the snapshot counts. + assert payload["summary"]["active"] == 2 + assert payload["summary"]["waiting"] == 1 + assert payload["summary"]["open_questions"] == 1 + assert payload["summary"]["total"] == 3 + + +def test_snapshot_to_dict_not_ok_is_serializable() -> None: + """A not-ok snapshot still serializes to a valid, JSON-dumpable payload.""" + snap = Snapshot(generated_at="now", db_path="/x.sqlite", ok=False, error="boom") + payload = snapshot_to_dict(snap) + assert payload["ok"] is False + assert payload["error"] == "boom" + # Stages still present (all idle) so the client can render the empty map. + assert [s["key"] for s in payload["stages"]] == [s.key for s in STAGES] + assert all(s["count"] == 0 for s in payload["stages"]) + # Round-trips through json. + assert json.loads(json.dumps(payload))["ok"] is False + + +def test_descriptions_escaped_in_html_and_json_seed() -> None: + """Hostile descriptions must not break out of HTML *or* the inline JSON.""" + snap = Snapshot( + generated_at="now", + db_path="/x.sqlite", + ok=True, + tasks=[ + TaskView( + thread_id="x", + short_id="x", + task="", + current_phase="plan", + status="active", + ) + ], + ) + html_out = render_html(snap) + # No raw script tag survives anywhere in the document (table render escapes + # it; the inline JSON seed escapes < and > to \\uXXXX). + assert "" + + +def test_build_snapshot_api_payload_against_seeded_ledger(tmp_path: Path) -> None: + """End-to-end: read a seeded ledger, then assert the /api/state payload.""" + db = tmp_path / "agent_team.sqlite" + _seed_db(db) + + payload = snapshot_to_dict(build_snapshot(db)) + + assert payload["ok"] is True + by_key = {s["key"]: s for s in payload["stages"]} + # thread-waiting (clarify, open question) -> clarify node, awaiting_human. + assert by_key["clarify"]["state"] == "awaiting_human" + assert by_key["clarify"]["count"] == 1 + assert by_key["clarify"]["tasks"][0]["thread_id"] == "thread-waiting" + # thread-active (plan, active) -> plan node, active. + assert by_key["plan"]["state"] == "active" + assert by_key["plan"]["count"] == 1 + # Budget surfaced. + assert payload["budget"]["available"] is True + assert payload["budget"]["total_usd"] == pytest.approx(0.25) + # Whole payload is JSON-serializable. + assert json.loads(json.dumps(payload))["summary"]["total"] == 2