Newer
Older
navi-1 / e2e / support.py
"""Helpers shared by the end-to-end tests: HTTP, sessions, UI waiters.

The suite drives the real server and the built webclient, so the helpers stay
deliberately small: create a session over REST, open it in the browser, type a
prompt, and wait for the tool cards the agent is supposed to produce.
"""

from __future__ import annotations

import json
import os
from urllib import request

# How long one agent turn may take. A real cloud model plans, thinks and calls
# tools, so this is minutes rather than seconds by design.
TURN_TIMEOUT_MS = int(os.environ.get("E2E_TURN_TIMEOUT_MS", "300000"))
# How long the fresh backend may take to answer /health.
SERVER_TIMEOUT = float(os.environ.get("E2E_SERVER_TIMEOUT", "120"))


def post_json(base_url: str, path: str, payload: dict) -> dict:
    data = json.dumps(payload).encode()
    req = request.Request(
        base_url + path,
        data=data,
        method="POST",
        headers={"Content-Type": "application/json"},
    )
    with request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read().decode())


def create_session(base_url: str, profile: str = "assistant") -> str:
    """Create a session as the anonymous admin (auth is off in the harness)."""
    return post_json(base_url, "/sessions", {"profile_id": profile})["session_id"]


def open_session(page, base_url: str, session_id: str) -> None:
    page.goto(f"{base_url}/#{session_id}", wait_until="domcontentloaded")
    page.wait_for_selector(".input-textarea", timeout=30_000)


def send_message(page, text: str) -> None:
    page.fill(".input-textarea", text)
    page.press(".input-textarea", "Enter")


def tool_card(page, name: str):
    return page.locator(f'.tool-card:has(.tool-name:text-is("{name}"))').first


def wait_for_tools(page, names: list[str], timeout_ms: int) -> None:
    """Wait until every named tool card exists and the turn has finished.

    ``.msg-meta-row`` is rendered only for a completed assistant message (the
    stats/timestamp row), so it is the end-of-turn marker the UI already owns.
    """
    for name in names:
        page.wait_for_selector(
            f'.tool-card:has(.tool-name:text-is("{name}"))',
            timeout=timeout_ms,
        )
    page.wait_for_selector(".msg-meta-row", timeout=timeout_ms)


def assert_ok(card) -> None:
    classes = card.get_attribute("class") or ""
    assert "is-success" in classes, f"tool card did not succeed: {classes}"


def open_card(card) -> None:
    """Expand a tool card.

    The card body is mounted lazily (a loaded session is mostly closed cards,
    and their result HTML is the bulk of the DOM), so it exists only after the
    first open — reading ``.tool-result`` without this sees nothing.
    """
    if card.get_attribute("open") is None:
        card.locator("summary").first.click()
    card.locator(".tool-card-body").first.wait_for(state="attached", timeout=10_000)


def result_text(card) -> str:
    open_card(card)
    return card.locator(".tool-result").text_content() or ""