Newer
Older
navi-1 / tests / clients / test_ws_bridge.py
"""Tests for WsBridge reconnect behaviour.

Uses a fake WebSocket client (no network) to drive the supervisor: a socket
delivers a few frames then drops, and the bridge must back off and reconnect,
continuing to forward events and not lose the user's queued input.
"""

from __future__ import annotations

import asyncio

import pytest

from clients.terminal.tui.events import ConnectionStatusChanged, WsEvent
from clients.terminal.tui.ws_bridge import WsBridge


class _FakeApp:
    """Records every posted message — WsBridge only needs ``post_message``."""

    def __init__(self) -> None:
        self.messages: list = []

    def post_message(self, message) -> None:
        self.messages.append(message)


class _FakeWs:
    """Async iterator standing in for a real socket.

    ``frames`` are delivered one per ``__anext__``; when they run out the
    socket ``close``s (the supervisor treats that as a drop and reconnects).
    ``idle=True`` makes the socket stay open with no incoming data — it blocks
    until ``close()`` — so a connect can hold ``connected`` True long enough
    for the input loop to send.
    """

    def __init__(self, frames: list[str] | None = None, *, idle: bool = False, send_fails: bool = False) -> None:
        self._frames = list(frames or [])
        self.idle = idle
        self.send_fails = send_fails
        self.sent: list[str] = []
        self._closed = asyncio.Event()

    def __aiter__(self):
        return self

    async def __anext__(self):
        if self.idle:
            # Open socket, no incoming data: block until closed.
            await self._closed.wait()
            raise StopAsyncIteration
        if not self._frames:
            raise StopAsyncIteration
        return self._frames.pop(0)

    async def close(self) -> None:
        self._closed.set()


class _FakeClient:
    """Stand-in for NaviWebSocketClient: scripted connect() results, records
    sends, exposes the same _input_queue / stop_input surface the bridge uses."""

    def __init__(self) -> None:
        self._ws: _FakeWs | None = None
        self._input_queue: asyncio.Queue = asyncio.Queue()
        self._ws_sequence: list[_FakeWs] = []
        self.connect_calls = 0
        self.sent: list[str] = []

    def push_ws(self, ws: _FakeWs) -> None:
        self._ws_sequence.append(ws)

    async def connect(self) -> None:
        self.connect_calls += 1
        self._ws = self._ws_sequence.pop(0) if self._ws_sequence else _FakeWs([])

    async def close(self) -> None:
        if self._ws is not None:
            await self._ws.close()
        self._ws = None

    async def send(self, content: str) -> None:
        if self._ws is None:
            raise RuntimeError("not connected")
        if self._ws.send_fails:
            raise RuntimeError("socket dead")
        self._ws.sent.append(content)
        self.sent.append(content)

    def stop_input(self) -> None:
        self._input_queue.put_nowait(None)

    def enqueue(self, content: str) -> None:
        self._input_queue.put_nowait(content)


def _bridge(client: _FakeClient) -> WsBridge:
    """A bridge with a tiny backoff so reconnects happen within the test."""
    bridge = WsBridge(_FakeApp(), "sess", client=client)
    bridge._backoff_min = 0.01
    bridge._backoff_max = 0.05
    return bridge


def _ws_payloads(bridge: WsBridge) -> list[dict]:
    return [m.payload for m in bridge.app.messages if isinstance(m, WsEvent)]


def _status_details(bridge: WsBridge) -> list[str]:
    return [m.detail for m in bridge.app.messages if isinstance(m, ConnectionStatusChanged)]


@pytest.mark.anyio
async def test_reconnects_after_drop_and_continues_forwarding_events() -> None:
    """A socket delivers two frames then closes; the bridge backs off,
    reconnects, and frames from the second socket still reach the app."""
    client = _FakeClient()
    client.push_ws(_FakeWs(['{"type":"status","content":"a"}', '{"type":"status","content":"b"}']))
    client.push_ws(_FakeWs(['{"type":"status","content":"c"}']))
    bridge = _bridge(client)

    await bridge.start()
    # connect1 → a, b → drop → backoff → connect2 → c → drop. 50ms is enough
    # for two tiny backoffs.
    await asyncio.sleep(0.05)
    await bridge.stop()

    assert client.connect_calls >= 2, "supervisor must reconnect after a drop"
    payloads = [p.get("content") for p in _ws_payloads(bridge)]
    assert "a" in payloads and "b" in payloads and "c" in payloads


@pytest.mark.anyio
async def test_initial_connect_failure_keeps_retrying_until_success() -> None:
    """If the server is down at startup, the supervisor keeps retrying and
    eventually connects when the server comes back."""
    client = _FakeClient()
    bridge = _bridge(client)

    # No ws pushed yet → connect() succeeds but yields an empty (immediately
    # closing) socket, which also drops. To simulate a refused connection,
    # make connect() fail for the first two attempts.

    original_connect = client.connect
    calls = {"n": 0}

    async def flaky_connect():
        calls["n"] += 1
        if calls["n"] <= 2:
            raise ConnectionRefusedError("server down")
        await original_connect()

    client.connect = flaky_connect
    client.push_ws(_FakeWs(['{"type":"status","content":"recovered"}']))

    await bridge.start()
    await asyncio.sleep(0.08)
    await bridge.stop()

    assert calls["n"] >= 3, "must keep retrying past the initial refusals"
    assert "recovered" in [p.get("content") for p in _ws_payloads(bridge)]
    # The status panel saw the offline→online transition.
    details = _status_details(bridge)
    assert any("reconnecting" in d for d in details)
    assert any(d == "online" for d in details)


@pytest.mark.anyio
async def test_queued_input_is_held_across_disconnect_then_sent_on_reconnect() -> None:
    """A message enqueued while disconnected is not lost — it's sent once the
    next socket is live."""
    client = _FakeClient()
    # ws1 closes immediately (no frames) → drop; ws2 stays open and accepts sends.
    client.push_ws(_FakeWs([]))
    client.push_ws(_FakeWs(idle=True))
    bridge = _bridge(client)
    # Long first backoff so the disconnected window is reliably hit.
    bridge._backoff_min = 0.2
    bridge._backoff_max = 0.2

    await bridge.start()
    await asyncio.sleep(0.02)  # ws1 connected then dropped; now in the 200ms backoff
    assert bridge.connected is False  # genuinely disconnected
    client.enqueue("hello from the dark")
    await asyncio.sleep(0.25)  # backoff done → ws2 connects → input loop sends
    await bridge.stop()

    assert "hello from the dark" in client.sent


@pytest.mark.anyio
async def test_send_failure_requeues_message_and_resent_after_reconnect() -> None:
    """If send() fails mid-flight (socket died right after connect), the
    message is requeued and delivered after the next reconnect."""
    client = _FakeClient()
    # ws1: open but send always fails; ws2: open and accepts sends.
    client.push_ws(_FakeWs(idle=True, send_fails=True))
    client.push_ws(_FakeWs(idle=True))
    bridge = _bridge(client)

    await bridge.start()
    await asyncio.sleep(0.02)  # ws1 connected (send will fail)
    client.enqueue("survive the blip")
    await asyncio.sleep(0.06)  # send fails → requeue → close → reconnect → resend
    await bridge.stop()

    assert "survive the blip" in client.sent


@pytest.mark.anyio
async def test_stop_cancels_backoff_promptly() -> None:
    """Stopping during a backoff sleep ends the supervisor quickly — no hanging
    on a long reconnect delay."""
    client = _FakeClient()
    bridge = _bridge(client)
    bridge._backoff_min = 10.0  # long backoff
    bridge._backoff_max = 10.0

    await bridge.start()
    await asyncio.sleep(0.01)  # first connect attempt (no ws → empty socket → drop → long backoff)
    loop = asyncio.get_event_loop()
    t0 = loop.time()
    await asyncio.wait_for(bridge.stop(), timeout=2.0)
    elapsed = loop.time() - t0
    assert elapsed < 2.0, "stop() must cancel the backoff, not wait for it"


@pytest.mark.anyio
async def test_stop_is_idempotent() -> None:
    client = _FakeClient()
    bridge = _bridge(client)
    await bridge.start()
    await asyncio.sleep(0.01)
    await bridge.stop()
    await bridge.stop()  # must not raise
    assert bridge.connected is False


@pytest.mark.anyio
async def test_supervisor_does_not_reconnect_after_stop() -> None:
    """Once stopped, a dropped socket must not trigger further connects."""
    client = _FakeClient()
    client.push_ws(_FakeWs([]))  # drops immediately
    bridge = _bridge(client)

    await bridge.start()
    await asyncio.sleep(0.02)
    await bridge.stop()
    calls_after_stop = client.connect_calls
    await asyncio.sleep(0.05)
    assert client.connect_calls == calls_after_stop, "stop() must end reconnect attempts"