Newer
Older
navi-1 / tests / unit / mcp / test_navi_web_view.py
"""web_view must open up to three URLs at once and never lose the pages it got.

Pages take a second to several, so a batch is worth it only if it actually runs
concurrently — and only if the shared text budget and the per-URL deadlines keep
one slow or broken page from costing the others. The loader lives in
``conftest.py`` next to this file.
"""

import asyncio
import time


def _page(text: str = "hello") -> dict:
    return {"success": True, "output": f"Title: t\n\n{text}", "error": None,
            "metadata": {}}


def _failed(error: str = "invalid_url") -> dict:
    return {"success": False, "output": "URL must start with http:// or https://",
            "error": error, "metadata": {}}


class TestSingleUrl:
    async def test_one_url_keeps_the_plain_output(
        self, navi_web_browse, monkeypatch
    ):
        seen: dict = {}

        async def view_one(url, screenshot=False, wait_until="networkidle"):
            seen["url"] = url
            seen["screenshot"] = screenshot
            return _page()

        monkeypatch.setattr(navi_web_browse, "_view_one", view_one)

        result = await navi_web_browse.web_view("https://example.com", screenshot=True)

        assert result == _page()
        assert seen == {"url": "https://example.com", "screenshot": True}

    async def test_an_invalid_url_is_reported_without_launching(
        self, navi_web_browse
    ):
        result = await navi_web_browse.web_view("ftp://example.com")

        assert result["success"] is False
        assert result["error"] == "invalid_url"


class TestBatch:
    async def test_urls_open_concurrently(self, navi_web_browse, monkeypatch):
        opened: list[str] = []

        async def view_one(url, screenshot=False, wait_until="networkidle"):
            opened.append(url)
            await asyncio.sleep(0.1)
            return _page(url)

        monkeypatch.setattr(navi_web_browse, "_view_one", view_one)

        began = time.monotonic()
        result = await navi_web_browse.web_view(["https://a", "https://b", "https://c"])
        elapsed = time.monotonic() - began

        assert result["success"] is True
        assert opened == ["https://a", "https://b", "https://c"]
        assert elapsed < 0.25, f"three 0.1s pages took {elapsed:.2f}s — not concurrent"
        assert result["output"].count("### ") == 3

    async def test_blocks_follow_the_request_order(self, navi_web_browse, monkeypatch):
        async def view_one(url, screenshot=False, wait_until="networkidle"):
            await asyncio.sleep(0.1 if url == "https://a" else 0)
            return _page(url)

        monkeypatch.setattr(navi_web_browse, "_view_one", view_one)

        result = await navi_web_browse.web_view(["https://a", "https://b"])

        assert result["output"].index("### https://a") < result["output"].index(
            "### https://b"
        )

    async def test_more_than_three_are_capped_with_a_note(
        self, navi_web_browse, monkeypatch
    ):
        opened: list[str] = []

        async def view_one(url, screenshot=False, wait_until="networkidle"):
            opened.append(url)
            return _page()

        monkeypatch.setattr(navi_web_browse, "_view_one", view_one)

        result = await navi_web_browse.web_view(
            [f"https://{i}" for i in range(5)]
        )

        assert len(opened) == 3
        assert "Only the first 3 of 5 URLs were opened" in result["output"]

    async def test_duplicates_collapse(self, navi_web_browse, monkeypatch):
        opened: list[str] = []

        async def view_one(url, screenshot=False, wait_until="networkidle"):
            opened.append(url)
            return _page()

        monkeypatch.setattr(navi_web_browse, "_view_one", view_one)

        await navi_web_browse.web_view(["https://a", " https://a ", "https://b"])

        assert opened == ["https://a", "https://b"]


class TestFailures:
    async def test_one_failed_page_does_not_erase_the_others(
        self, navi_web_browse, monkeypatch
    ):
        async def view_one(url, screenshot=False, wait_until="networkidle"):
            return _failed() if url == "https://b" else _page()

        monkeypatch.setattr(navi_web_browse, "_view_one", view_one)

        result = await navi_web_browse.web_view(["https://a", "https://b", "https://c"])

        assert result["success"] is True
        assert "### https://b -> failed:" in result["output"]
        assert [item["success"] for item in result["metadata"]["urls"]] == [
            True, False, True,
        ]

    async def test_a_page_timing_out_does_not_erase_the_others(
        self, navi_web_browse, monkeypatch
    ):
        async def view_one(url, screenshot=False, wait_until="networkidle"):
            if url == "https://b":
                await asyncio.sleep(10)
            return _page()

        monkeypatch.setattr(navi_web_browse, "_view_one", view_one)
        monkeypatch.setattr(navi_web_browse, "_BATCH_DEADLINE", 0.05)

        result = await navi_web_browse.web_view(["https://a", "https://b"])

        assert result["success"] is True
        assert "### https://b -> failed:" in result["output"]

    async def test_a_batch_with_no_pages_fails(self, navi_web_browse, monkeypatch):
        async def view_one(url, screenshot=False, wait_until="networkidle"):
            return _failed()

        monkeypatch.setattr(navi_web_browse, "_view_one", view_one)

        result = await navi_web_browse.web_view(["https://a", "https://b"])

        assert result["success"] is False
        assert result["error"] == "all_urls_failed"

    async def test_no_url_is_an_error(self, navi_web_browse):
        result = await navi_web_browse.web_view([" ", ""])

        assert result["success"] is False
        assert result["error"] == "empty_url"

    async def test_a_screenshot_batch_is_refused_up_front(
        self, navi_web_browse, monkeypatch
    ):
        """`screenshot` returns image metadata for one page; with three there is
        nowhere to put them, so say so instead of silently dropping two."""
        opened: list[str] = []

        async def view_one(url, screenshot=False, wait_until="networkidle"):
            opened.append(url)
            return _page()

        monkeypatch.setattr(navi_web_browse, "_view_one", view_one)

        result = await navi_web_browse.web_view(
            ["https://a", "https://b"], screenshot=True
        )

        assert result["success"] is False
        assert result["error"] == "screenshot_batch"
        assert opened == []


class TestTextBudget:
    async def test_the_batch_stops_at_its_shared_text_budget(
        self, navi_web_browse, monkeypatch
    ):
        """Three pages at the single-page cap would be 60k chars of context; the
        batch stops at 40k and says where it stopped."""
        big = "x" * (navi_web_browse._MAX_TEXT)

        async def view_one(url, screenshot=False, wait_until="networkidle"):
            return _page(big)

        monkeypatch.setattr(navi_web_browse, "_view_one", view_one)

        result = await navi_web_browse.web_view(["https://a", "https://b", "https://c"])

        assert len(result["output"]) < navi_web_browse._BATCH_TEXT + 2_000
        assert "40k-char" in result["output"]
        assert result["metadata"]["urls"][-1]["truncated"] is True

    async def test_a_single_page_keeps_its_own_cap(self, navi_web_browse, monkeypatch):
        async def view_one(url, screenshot=False, wait_until="networkidle"):
            return _page("x" * navi_web_browse._MAX_TEXT)

        monkeypatch.setattr(navi_web_browse, "_view_one", view_one)

        result = await navi_web_browse.web_view("https://a")

        assert "40k-char" not in result["output"]


class TestToolLayer:
    """A call that opened nothing must reach the journal as an error.

    ``view_tool`` used to return the JSON with ``"success": false`` and no
    ``isError`` — which is how a server whose browser could not launch at all
    looked like a working one, with a 1400-char payload I mistook for page text.
    """

    async def test_no_page_opened_raises(self, navi_web_server, monkeypatch):
        async def failed(url, screenshot=False, wait_until="networkidle"):
            return {"success": False, "output": "Browser error: no browser",
                    "error": "all_urls_failed", "metadata": {}}

        monkeypatch.setattr(navi_web_server, "web_view", failed)

        try:
            await navi_web_server.view_tool(["https://a", "https://b"])
        except RuntimeError as exc:
            assert "Browser error" in str(exc)
        else:
            raise AssertionError("a failed web_view did not raise")

    async def test_a_partial_batch_still_returns(
        self, navi_web_server, monkeypatch
    ):
        async def partly(url, screenshot=False, wait_until="networkidle"):
            return {"success": True, "output": "### https://a\nok", "error": None,
                    "metadata": {"urls": [{"url": "https://a", "success": True}]}}

        monkeypatch.setattr(navi_web_server, "web_view", partly)

        body = await navi_web_server.view_tool(["https://a", "https://b"])

        assert '"success": true' in body