"""Browser-based E2E tests and pure-Python structural/API tests for the mobile UI.

Browser tests require Playwright and browser binaries:
    pip install playwright pytest-playwright
    playwright install chromium

Browser tests are skipped per-test when Playwright is not installed.
Pure-Python tests always run.

Automated browser coverage (Playwright, Chromium; see test functions below):
  - 320px / 375px / 430px portrait: no horizontal scroll, composer visible
  - landscape (812×375): tabs hidden, desktop layout active
  - virtual keyboard (shrink viewport height): composer / reply box stays in viewport
  - visibilitychange: immediate poll after background → foreground
  - offline → online: connection banner appears then clears
  - duplicate click: button disabled immediately, only one POST sent
  - dangerous-action confirm: overlay shown, cancel does not POST
  - tab switching portrait: list ↔ main panel toggle works
  - touch target height: all visible buttons ≥ 44px
  - PWA resources: manifest, SW, icon-192.png, icon-512.png all serve correctly

Manual verification matrix — requires real iPhone Safari / Android Chrome:
  (run on a device connected to the same LAN; tick each before a release)

  Portrait / landscape
    [ ] 320px (iPhone SE 1st gen): no overflow, all buttons reachable
    [ ] 375px (iPhone 13 mini): task list, detail, actions visible
    [ ] 430px (iPhone 14 Plus): wider layout, no wasted whitespace
    [ ] landscape 844×390: switches to two-column desktop layout, tabs hidden

  Virtual keyboard & safe area
    [ ] Tap reply textarea → keyboard raises → composer stays above keyboard
    [ ] Bottom nav / action buttons not clipped by Home Indicator (safe-area-inset-bottom)
    [ ] Dismiss keyboard → layout restores without jump

  Background & foreground
    [ ] Switch to another app → return → task status refreshes immediately (≤1 poll interval)
    [ ] Lock screen → unlock → polling resumes, no stale data shown

  Weak network & reconnect
    [ ] Toggle airplane mode mid-poll → connection banner appears
    [ ] Restore network → banner clears automatically after next successful poll
    [ ] Pending action during outage → banner, not task-failure message

  Dangerous actions (two-step confirm)
    [ ] Cancel: overlay text says task cannot be recovered, confirm sends POST, cancel does not
    [ ] Approve: overlay shown, confirm proceeds, cancel aborts
    [ ] Run-all: overlay warns about continuous execution

  Duplicate / retry safety
    [ ] Rapid double-tap advance → only one state transition, no 500 error
    [ ] Action during reload (new reqId) → state machine rejects or idempotency key matches

  PWA install & launch
    [ ] iOS Safari: Add to Home Screen → icon shows, opens in standalone mode, no URL bar
    [ ] Android Chrome: "Add to Home Screen" prompt or install banner appears
    [ ] Launched from home screen: app loads, polling starts, no auth redirect loop
    [ ] Offline launch: shell renders, shows network-error banner (not blank screen)
"""
import os
import threading
from pathlib import Path
from unittest.mock import MagicMock

import pytest


def _require_playwright():
    """Skip (or fail when enforcement is enabled) if Playwright is not installed.

    Set DUAL_AGENT_REQUIRE_BROWSER_TESTS=1 to make missing Playwright a hard
    failure rather than a skip — useful in CI/release acceptance gates.
    """
    if os.environ.get("DUAL_AGENT_REQUIRE_BROWSER_TESTS"):
        try:
            import playwright  # noqa: F401
        except ImportError:
            pytest.fail(
                "playwright not installed but DUAL_AGENT_REQUIRE_BROWSER_TESTS is set. "
                "Install it with: pip install playwright pytest-playwright && playwright install chromium"
            )
    else:
        pytest.importorskip("playwright", reason="playwright is not installed")


@pytest.mark.browser
@pytest.mark.parametrize("engine,device", [("webkit", "iPhone 13"), ("chromium", "Pixel 5")])
def test_mobile_engine_flow(live_url, engine, device):
    _require_playwright()
    from playwright.sync_api import sync_playwright, expect
    with sync_playwright() as pw:
        browser = getattr(pw, engine).launch()
        context = browser.new_context(**pw.devices[device])
        page = context.new_page()
        page.goto(live_url, wait_until="networkidle")
        assert page.evaluate("document.documentElement.scrollWidth <= innerWidth")
        page.evaluate("showPanel('main')")
        page.locator('#reply').focus()
        page.set_viewport_size({"width": 390, "height": 400})
        expect(page.locator('#reply')).to_be_visible()
        calls = []
        page.on('request', lambda r: calls.append(r) if r.method == 'POST' else None)
        page.evaluate("void step()")
        expect(page.locator('#confirm-ok')).to_be_visible()
        page.evaluate("resolveConfirm(false)")
        page.wait_for_timeout(100)
        assert not calls, "Cancelled advance must not send POST"
        context.set_offline(True)
        page.evaluate("api('/tasks').catch(()=>null)")
        expect(page.locator('#conn-banner')).to_be_visible()
        context.set_offline(False)
        page.evaluate("api('/tasks')")
        expect(page.locator('#conn-banner')).to_be_hidden()
        page.set_viewport_size({"width": 844, "height": 390})
        assert page.evaluate("document.documentElement.scrollWidth <= innerWidth")
        browser.close()
from fastapi.testclient import TestClient

from dual_agent.api import create_app
from dual_agent.web import INDEX_HTML
from tests._fakes import fake_orchestrator, task_in_state


# ── Pure-Python structural tests ──────────────────────────────────────
# Always run; no browser or live server required.

def test_abort_timeout_covers_body_read():
    """clearTimeout must appear AFTER r.text() so body-hang is caught by abort timeout."""
    text_pos = INDEX_HTML.find("await r.text()")
    clear_pos = INDEX_HTML.find("clearTimeout(tid)")
    assert text_pos != -1, "await r.text() not found in INDEX_HTML"
    assert clear_pos != -1, "clearTimeout(tid) not found in INDEX_HTML"
    assert clear_pos > text_pos, (
        f"clearTimeout(tid) at offset {clear_pos} is before await r.text() at {text_pos}; "
        "body transfer is not covered by the abort timeout"
    )


def test_refresh_rethrows_connection_errors():
    """refresh() must propagate offline/5xx so startPolling() 5xx handler is reachable."""
    assert "connErr" in INDEX_HTML, "refresh() must use connErr sentinel"
    assert "throw connErr" in INDEX_HTML, "refresh() must rethrow connErr"


def test_load_discussions_preserves_list_on_error():
    """loadDiscussions() must not clear existing DOM list on network failure."""
    # Old broken pattern: catch{items=[];}  then always set innerHTML
    # Fixed pattern: DOM update lives inside try block
    assert "catch{items=[]" not in INDEX_HTML, (
        "loadDiscussions still uses catch{items=[];} which clears list on network error"
    )


def test_load_tasks_preserves_list_on_error():
    """loadTasks() must not clear existing DOM list on network failure."""
    assert INDEX_HTML.count("catch{items=[];") == 0, (
        "loadTasks still uses catch{items=[];} which clears list on network error"
    )


def test_polling_5xx_handler_reachable():
    """startPolling() 5xx handler is reachable only if refresh() rethrows connection errors."""
    assert "httpStatus>=500" in INDEX_HTML or "httpStatus >= 500" in INDEX_HTML
    assert "connErr" in INDEX_HTML


def test_log_pagination_offset_in_js():
    """Frontend must pass offset parameter to the logs API for paginated loading."""
    assert "offset" in INDEX_HTML
    assert "dataset.loaded" in INDEX_HTML or "data-loaded" in INDEX_HTML


def test_polling_keeps_logs_and_defers_advanced_detail():
    """Polling preserves an already loaded log and does not fetch unbounded detail."""
    assert "const oldLog=sameTask?chat.querySelector('.log-tail'):null" in INDEX_HTML
    assert "if(oldLog) chat.appendChild(oldLog)" in INDEX_HTML
    assert "async function loadAdvanced(btn)" in INDEX_HTML
    assert "appendAdvanced(chat);" in INDEX_HTML


def test_long_task_actions_keep_their_request_id():
    """Long run/advance calls use a realistic timeout and retain pending idempotency keys."""
    assert INDEX_HTML.count("15*60*1000") == 2
    assert "!pending" in INDEX_HTML


# ── Pure-Python API tests: discussion mutation idempotency ────────────

def _mock_advisor():
    mock = MagicMock()
    msg = MagicMock()
    msg.model_dump.return_value = {"role": "assistant", "text": "hi"}
    mock.say.return_value = [msg]
    mock.reply.return_value = [msg]
    brief = MagicMock()
    brief.model_dump.return_value = {"goal": "g", "steps": []}
    mock.draft_brief.return_value = brief
    return mock


def test_say_idempotent_same_body():
    """/say with same X-Request-Id and body must call say() exactly once."""
    mock = _mock_advisor()
    c = TestClient(create_app(fake_orchestrator(), advisor=mock), raise_server_exceptions=False)
    rid = "say-idem-01"
    body = {"text": "hello"}
    r1 = c.post("/discussions/disc-x/say", json=body, headers={"X-Request-Id": rid})
    r2 = c.post("/discussions/disc-x/say", json=body, headers={"X-Request-Id": rid})
    assert r1.status_code == 200
    assert r2.status_code == 200
    assert mock.say.call_count == 1, "say() called more than once for duplicate reqId+body"


def test_say_not_idempotent_different_body():
    """/say with same reqId but different body must bypass cache and call say() again."""
    mock = _mock_advisor()
    c = TestClient(create_app(fake_orchestrator(), advisor=mock), raise_server_exceptions=False)
    rid = "say-idem-02"
    r1 = c.post("/discussions/disc-x/say", json={"text": "hello"}, headers={"X-Request-Id": rid})
    r2 = c.post("/discussions/disc-x/say", json={"text": "different"}, headers={"X-Request-Id": rid})
    assert r1.status_code == 200
    assert r2.status_code == 200
    assert mock.say.call_count == 2, "Different body must not hit cache even with same reqId"


def test_reply_idempotent_same_request_id():
    """/reply with same X-Request-Id must call reply() exactly once."""
    mock = _mock_advisor()
    c = TestClient(create_app(fake_orchestrator(), advisor=mock), raise_server_exceptions=False)
    rid = "reply-idem-01"
    r1 = c.post("/discussions/disc-x/reply", headers={"X-Request-Id": rid})
    r2 = c.post("/discussions/disc-x/reply", headers={"X-Request-Id": rid})
    assert r1.status_code == 200
    assert r2.status_code == 200
    assert mock.reply.call_count == 1


def test_brief_idempotent_same_request_id():
    """/brief with same X-Request-Id must call draft_brief() exactly once."""
    mock = _mock_advisor()
    c = TestClient(create_app(fake_orchestrator(), advisor=mock), raise_server_exceptions=False)
    rid = "brief-idem-01"
    r1 = c.post("/discussions/disc-x/brief", headers={"X-Request-Id": rid})
    r2 = c.post("/discussions/disc-x/brief", headers={"X-Request-Id": rid})
    assert r1.status_code == 200
    assert r2.status_code == 200
    assert mock.draft_brief.call_count == 1


def test_say_without_request_id_always_executes():
    """/say without X-Request-Id must always invoke say() (no caching)."""
    mock = _mock_advisor()
    c = TestClient(create_app(fake_orchestrator(), advisor=mock), raise_server_exceptions=False)
    body = {"text": "hello"}
    c.post("/discussions/disc-x/say", json=body)
    c.post("/discussions/disc-x/say", json=body)
    assert mock.say.call_count == 2


# ── Viewport helpers ──────────────────────────────────────────────────

IPHONE_320 = {"width": 320, "height": 568}   # iPhone SE 1st gen
IPHONE_375 = {"width": 375, "height": 812}
IPHONE_LAND = {"width": 812, "height": 375}
ANDROID_430 = {"width": 430, "height": 932}


# ── Pure-Python PWA resource tests (always run, no browser needed) ─────

def test_pwa_manifest_served():
    """Manifest endpoint returns 200 with display=standalone and start_url='/'."""
    c = TestClient(create_app(fake_orchestrator()))
    r = c.get("/manifest.webmanifest")
    assert r.status_code == 200
    data = r.json()
    assert data["display"] == "standalone"
    assert data["start_url"] == "/"


def test_pwa_service_worker_served():
    """SW endpoint returns 200 with no-cache header."""
    c = TestClient(create_app(fake_orchestrator()))
    r = c.get("/sw.js")
    assert r.status_code == 200
    assert "no-cache" in r.headers.get("Cache-Control", "").lower()


def test_pwa_icon_192_served():
    """icon-192.png returns 200 with correct PNG content-type."""
    c = TestClient(create_app(fake_orchestrator()))
    r = c.get("/icon-192.png")
    assert r.status_code == 200
    assert r.headers["content-type"] == "image/png"
    assert r.content[:4] == b"\x89PNG"


def test_pwa_icon_512_served():
    """icon-512.png returns 200 with correct PNG content-type."""
    c = TestClient(create_app(fake_orchestrator()))
    r = c.get("/icon-512.png")
    assert r.status_code == 200
    assert r.headers["content-type"] == "image/png"
    assert r.content[:4] == b"\x89PNG"


def test_pwa_icon_svg_served():
    """icon.svg returns 200 with SVG content-type."""
    c = TestClient(create_app(fake_orchestrator()))
    r = c.get("/icon.svg")
    assert r.status_code == 200
    assert "svg" in r.headers["content-type"]


# ── Live server fixture (browser tests only) ──────────────────────────

@pytest.fixture(scope="module")
def live_url():
    """Spin up FastAPI on a free port; skip if uvicorn/httpx unavailable."""
    import socket
    import time

    try:
        import uvicorn
        import httpx
    except ImportError:
        pytest.skip("uvicorn or httpx not installed")

    orc = fake_orchestrator()
    app = create_app(orc)

    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        port = s.getsockname()[1]

    config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
    server = uvicorn.Server(config)
    t = threading.Thread(target=server.run, daemon=True)
    t.start()

    import httpx as _httpx
    for _ in range(30):
        try:
            _httpx.get(f"http://127.0.0.1:{port}/health", timeout=1)
            break
        except Exception:
            time.sleep(0.1)
    else:
        pytest.skip("live server did not start in time")

    yield f"http://127.0.0.1:{port}"
    server.should_exit = True


# ── Browser tests (skipped per-test when Playwright not installed) ────

@pytest.mark.browser
def test_index_loads_portrait(live_url):
    """Index page loads without JS errors in portrait phone viewport."""
    _require_playwright()
    from playwright.sync_api import sync_playwright

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        page = browser.new_page(viewport=IPHONE_375)
        errors = []
        page.on("pageerror", lambda e: errors.append(str(e)))
        page.goto(live_url, wait_until="networkidle")
        assert "Dual Agent" in page.title()
        assert errors == [], f"JS errors: {errors}"
        browser.close()


@pytest.mark.browser
@pytest.mark.parametrize("viewport", [IPHONE_320, IPHONE_375, ANDROID_430])
def test_no_horizontal_scroll_portrait(live_url, viewport):
    """Page must not overflow horizontally at 320, 375, or 430px portrait widths."""
    _require_playwright()
    from playwright.sync_api import sync_playwright

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        page = browser.new_page(viewport=viewport)
        page.goto(live_url, wait_until="networkidle")
        overflow = page.evaluate(
            "document.documentElement.scrollWidth > document.documentElement.clientWidth"
        )
        assert not overflow, (
            f"Horizontal scroll detected at {viewport['width']}px — "
            "content overflows the viewport"
        )
        browser.close()


@pytest.mark.browser
def test_pwa_resources_load_correctly(live_url):
    """manifest.webmanifest, sw.js, icon-192.png, and icon-512.png all return 200."""
    _require_playwright()
    from playwright.sync_api import sync_playwright

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        page = browser.new_page(viewport=IPHONE_375)
        page.goto(live_url, wait_until="networkidle")

        for path in ["/manifest.webmanifest", "/sw.js", "/icon-192.png", "/icon-512.png", "/icon.svg"]:
            response = page.request.get(f"{live_url}{path}")
            assert response.status == 200, f"PWA resource {path} returned {response.status}"

        browser.close()


@pytest.mark.browser
def test_index_loads_landscape(live_url):
    """Index page loads and tabs are hidden in landscape (desktop layout)."""
    _require_playwright()
    from playwright.sync_api import sync_playwright

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        page = browser.new_page(viewport=IPHONE_LAND)
        page.goto(live_url, wait_until="networkidle")
        tabs = page.locator("#tabs")
        assert tabs.evaluate("el => getComputedStyle(el).display") == "none"
        browser.close()


@pytest.mark.browser
def test_connection_banner_hidden_on_load(live_url):
    """Connection banner is hidden when server is reachable."""
    _require_playwright()
    from playwright.sync_api import sync_playwright

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        page = browser.new_page(viewport=IPHONE_375)
        page.goto(live_url, wait_until="networkidle")
        banner = page.locator("#conn-banner")
        assert banner.get_attribute("hidden") is not None
        browser.close()


@pytest.mark.browser
def test_confirm_dialog_appears_for_cancel(live_url):
    """Cancel button shows the confirm dialog before submitting."""
    _require_playwright()
    from playwright.sync_api import sync_playwright

    import socket
    import time
    import httpx

    try:
        import uvicorn
    except ImportError:
        pytest.skip("uvicorn not installed")

    orc2 = fake_orchestrator()
    task = task_in_state(orc2, "INIT")
    app2 = create_app(orc2)

    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        port2 = s.getsockname()[1]

    config2 = uvicorn.Config(app2, host="127.0.0.1", port=port2, log_level="error")
    server2 = uvicorn.Server(config2)
    t2 = threading.Thread(target=server2.run, daemon=True)
    t2.start()
    for _ in range(30):
        try:
            httpx.get(f"http://127.0.0.1:{port2}/health", timeout=1)
            break
        except Exception:
            time.sleep(0.1)

    base = f"http://127.0.0.1:{port2}"

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        page = browser.new_page(viewport=IPHONE_375)
        page.goto(base, wait_until="networkidle")
        page.evaluate(f"openTask('{task.task_id}')")
        page.wait_for_timeout(500)
        cancel_btn = page.locator("#actions button.danger")
        if cancel_btn.count() > 0:
            cancel_btn.first.click()
            overlay = page.locator("#confirm-overlay")
            assert overlay.get_attribute("hidden") is None, "Confirm overlay should be visible"
            page.locator("#confirm-overlay button").first.click()
        browser.close()

    server2.should_exit = True


@pytest.mark.browser
def test_duplicate_click_blocked(live_url):
    """Clicking an action button twice does not send duplicate requests."""
    _require_playwright()
    from playwright.sync_api import sync_playwright

    import socket
    import time
    import httpx

    try:
        import uvicorn
    except ImportError:
        pytest.skip("uvicorn not installed")

    orc3 = fake_orchestrator()
    task3 = task_in_state(orc3, "INIT")
    app3 = create_app(orc3)

    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        port3 = s.getsockname()[1]

    config3 = uvicorn.Config(app3, host="127.0.0.1", port=port3, log_level="error")
    server3 = uvicorn.Server(config3)
    t3 = threading.Thread(target=server3.run, daemon=True)
    t3.start()
    for _ in range(30):
        try:
            httpx.get(f"http://127.0.0.1:{port3}/health", timeout=1)
            break
        except Exception:
            time.sleep(0.1)

    base3 = f"http://127.0.0.1:{port3}"

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        page = browser.new_page(viewport=IPHONE_375)
        page.goto(base3, wait_until="networkidle")
        page.evaluate(f"openTask('{task3.task_id}')")
        page.wait_for_timeout(500)

        step_btn = page.locator("#actions button", has_text="推进一步")
        if step_btn.count() > 0:
            page.evaluate("window.advanceCalls=0; api=()=>{window.advanceCalls++;return new Promise(()=>{});}; null")
            step_btn.first.click()
            page.locator('#confirm-ok').click()
            is_disabled = step_btn.first.is_disabled()
            assert is_disabled, "Button should be disabled immediately after first click"
            page.evaluate("void step()")
            assert page.evaluate("window.advanceCalls") == 1

        browser.close()

    server3.should_exit = True


@pytest.mark.browser
def test_tab_switching_portrait(live_url):
    """Mobile opens chat first; menu switches to the list and back."""
    _require_playwright()
    from playwright.sync_api import sync_playwright

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        page = browser.new_page(viewport=IPHONE_375)
        page.goto(live_url, wait_until="networkidle")

        left = page.locator("#left")
        assert not left.is_visible()
        assert page.locator("#main").is_visible()

        page.locator("#mobile-menu").click()
        page.wait_for_timeout(100)
        assert left.is_visible()

        page.locator(".menu-heading .icon-btn").click()
        page.wait_for_timeout(100)
        assert not left.is_visible()
        assert page.locator("#main").is_visible()

        browser.close()


@pytest.mark.browser
def test_touch_targets_min_height(live_url):
    """All buttons must have at least 44px touch height for mobile usability."""
    _require_playwright()
    from playwright.sync_api import sync_playwright

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        page = browser.new_page(viewport=IPHONE_375)
        page.goto(live_url, wait_until="networkidle")

        result = page.evaluate("""
                Array.from(document.querySelectorAll('button')).filter(btn => btn.getClientRects().length > 0).every(btn => {
                const h = btn.getBoundingClientRect().height;
                return h >= 44;
            })
        """)
        assert result, "Some buttons are shorter than 44px touch target"
        browser.close()


@pytest.mark.browser
def test_visibility_change_triggers_immediate_poll(live_url):
    """Returning from background (visibilitychange) triggers an immediate poll, not a full interval wait."""
    _require_playwright()
    from playwright.sync_api import sync_playwright

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        page = browser.new_page(viewport=IPHONE_375)
        page.goto(live_url, wait_until="networkidle")

        # Simulate background → foreground transition by hiding then showing document.
        # Patch Date.now so we can confirm a fetch is fired promptly after becoming visible.
        requests_fired = []
        page.on("request", lambda r: requests_fired.append(r.url) if "/tasks" in r.url else None)

        request_count_before = len(requests_fired)
        # Dispatch a synthetic visibilitychange that reports visible.
        page.evaluate("""
            Object.defineProperty(document, 'hidden', {get: () => false, configurable: true});
            document.dispatchEvent(new Event('visibilitychange'));
        """)
        # The handler should fire a poll immediately (within one rAF / microtask).
        page.wait_for_timeout(300)
        request_count_after = len(requests_fired)
        assert request_count_after > request_count_before, (
            "visibilitychange did not trigger an immediate /tasks poll"
        )
        browser.close()


@pytest.mark.browser
def test_keyboard_viewport_shrink_composer_visible(live_url):
    """When the viewport shrinks (soft keyboard appears), the composer remains visible."""
    _require_playwright()
    from playwright.sync_api import sync_playwright, expect

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        # Start with normal portrait viewport then shrink height to simulate keyboard.
        page = browser.new_page(viewport=IPHONE_375)
        page.goto(live_url, wait_until="networkidle")
        page.evaluate("showPanel('main')")

        page.locator("#reply").focus()
        # Simulate keyboard by halving available height.
        page.set_viewport_size({"width": 375, "height": 400})

        expect(page.locator("#reply")).to_be_visible()
        expect(page.locator("#reply")).to_be_in_viewport()
        browser.close()


@pytest.mark.browser
def test_5xx_recovery_clears_connection_banner(live_url):
    """After a 5xx/offline error, the connection banner clears once polling recovers."""
    _require_playwright()
    from playwright.sync_api import sync_playwright, expect

    with sync_playwright() as pw:
        browser = pw.chromium.launch()
        context = browser.new_context(viewport=IPHONE_375)
        page = context.new_page()
        page.goto(live_url, wait_until="networkidle")

        # Simulate network offline, verify banner appears.
        context.set_offline(True)
        page.evaluate("api('/tasks').catch(()=>null)")
        page.wait_for_timeout(200)
        expect(page.locator("#conn-banner")).to_be_visible()

        # Restore network, verify banner clears after successful poll.
        context.set_offline(False)
        page.evaluate("api('/tasks')")
        page.wait_for_timeout(300)
        expect(page.locator("#conn-banner")).to_be_hidden()
        browser.close()
