shopstack / tests /test_browser_hydration.py
pranaysuyash's picture
Sync ShopStack HEAD f5f6237
e6dc493 verified
Raw
History Blame Contribute Delete
19.2 kB
"""Browser-hydration smoke test β€” verifies the app loads without console errors.
Uses Playwright to launch a headless Chromium browser, navigate to a
freshly-started Gradio server, capture every browser console message,
and assert that no errors were emitted during page load and hydration.
Why this matters:
* Gradio apps inject substantial JavaScript on every page load (JS
helpers, tab sync, autocomplete injection, i18n binding, etc.).
* A browser-side JS error (e.g. a null-reference in url_state_sync_js,
a missing DOM element the autocomplete injector expects, a Gradio
internal error during tab mount) would produce a silent hydration
failure that unit tests cannot catch.
* This test is the first line of defence against such regressions. It
runs in <5 s and requires no external services.
Architecture:
* test_browser_hydration β€” main entry point. Sets env vars, builds
the Gradio app, launches it on a random port in a background thread,
drives Playwright to collect console messages + screenshot, then
asserts zero errors.
* We intentionally avoid the session-scoped ``app`` fixture from
conftest.py (which is optimised for unit-test speed) because the
live server needs its own lifecycle and port.
"""
from __future__ import annotations
import os
import tempfile
import threading
import time
from pathlib import Path
from typing import Any
import pytest
from playwright.sync_api import ConsoleMessage, Page, sync_playwright
# Per 2026-06-14 test isolation hardening: these tests launch a real
# Gradio server and use Playwright to drive a real browser. They must
# run in their own pytest process (not in parallel with other tests
# that touch the same db singleton or Gradio state). Use
# ``pytest -m standalone`` to run in a clean invocation.
pytestmark = pytest.mark.standalone
# ── Module-level env setup (must happen before any shopstack import) ──
# Sets LOCAL_AUTO_DOWNLOAD=false so the app doesn't attempt model
# downloads during the test. SHOPSTACK_DB_PATH is set to a shared
# temp file so all test functions in this module use the SAME
# on-disk database β€” important because the ``db`` singleton in
# ``shopstack.app_context`` is created at import time and cached.
os.environ.setdefault("SHOPSTACK_LOCAL_AUTO_DOWNLOAD", "false")
_shared_db_path: str = ""
def setup_module() -> None:
"""Create a shared temp database before any test imports ``app``.
Each test in this file calls ``from app import build_app``, but
Python caches ``app`` after the first import. The ``db`` singleton
in ``shopstack.app_context`` is created during that import, so
every test must share the same database file.
``teardown_module`` cleans up the file after all tests finish.
"""
global _shared_db_path
db_fd, db_path = tempfile.mkstemp(suffix=".db", prefix="shopstack_browser_test_")
os.close(db_fd)
os.environ["SHOPSTACK_DB_PATH"] = db_path
_shared_db_path = db_path
# Redirect app_context db singleton to this unique file
from shopstack.app_context import db
db.db_path = db_path
if hasattr(db, "_local"):
db._local.conn = None
db._init_db()
def teardown_module() -> None:
"""Remove the shared temp database after all tests finish.
Uses the canonical ``_remove_db_with_sidecars`` helper from
conftest so WAL/SHM sidecars are also removed. Per motto_v3 Β§7,
this is the canonical cleanup path; do not inline
``Path(...).unlink(missing_ok=True)`` for temp DBs.
"""
# Restore app_context db singleton to the session DB
from shopstack.app_context import db
from tests.conftest import _SESSION_DB_PATH, _remove_db_with_sidecars
db.db_path = _SESSION_DB_PATH
if hasattr(db, "_local"):
db._local.conn = None
_remove_db_with_sidecars(_shared_db_path)
# ── Helpers ──────────────────────────────────────────────────────────
def _find_free_port() -> int:
"""Return a random ephemeral port via the OS bind checker."""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_for_server(url: str, timeout: float = 15.0, interval: float = 0.2) -> None:
"""Poll ``url`` until the server responds with HTTP 200."""
import httpx
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
r = httpx.get(url, timeout=5.0)
if r.status_code == 200:
return
except (httpx.ConnectError, httpx.RemoteProtocolError, httpx.ReadTimeout):
pass
time.sleep(interval)
raise TimeoutError(
f"Server at {url} did not become ready within {timeout}s"
)
# ── The test ─────────────────────────────────────────────────────────
def test_dark_mode_toggle_persistence(tmp_path: Path) -> None:
"""Verify `toggleTheme()` flips data-theme, persists to localStorage, and survives reload.
Test flow:
1. Start the app server (same infrastructure as test_browser_hydration).
2. Navigate to the app and wait for initial render.
3. Call ``toggleTheme()`` β†’ verify data-theme="dark" and
localStorage["shopstack-theme"] = "dark".
4. Call ``toggleTheme()`` again β†’ verify flip back to "light".
5. Reload the page β†’ verify "light" persists from localStorage.
6. Call ``toggleTheme()`` one final time β†’ verify "dark" again.
7. Assert no console errors during the entire sequence.
Args:
tmp_path: pytest built-in fixture for temp directory (screenshots).
"""
# Database path is set at module level via setup_module().
# All tests in this file share the same temp database because the
# ``db`` singleton is created at import-once time.
# ── Step 1: Launch the app server ────────────────────────────────
from app import build_app
app = build_app()
port = _find_free_port()
def _serve() -> None:
app.launch(
server_port=port,
prevent_thread_lock=True,
inbrowser=False,
quiet=True,
)
t = threading.Thread(target=_serve, daemon=True)
t.start()
base_url = f"http://127.0.0.1:{port}"
_wait_for_server(base_url)
# ── Step 2: Drive Playwright ─────────────────────────────────────
collected: list[dict[str, Any]] = []
js_errors: list[str] = []
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page: Page = browser.new_page()
def _on_console(msg: ConsoleMessage) -> None:
collected.append({
"type": msg.type,
"text": msg.text,
"location": msg.location,
})
def _on_pageerror(exc: str) -> None:
js_errors.append(str(exc))
page.on("console", _on_console)
page.on("pageerror", _on_pageerror)
# Navigate and wait for initial load.
page.goto(base_url, wait_until="load")
# Give deferred JS helpers time to run.
page.wait_for_timeout(1500)
# ── Step 3: Assert initial state ────────────────────────────────
initial_theme = page.evaluate(
"document.documentElement.getAttribute('data-theme')"
)
initial_ls = page.evaluate(
"localStorage.getItem('shopstack-theme')"
)
# With no localStorage value and no system preference match,
# data-theme should be None (no explicit attribute).
# localStorage may also be null on first visit.
# ── Step 4: Toggle to dark ──────────────────────────────────────
page.evaluate("toggleTheme()")
page.wait_for_timeout(100)
dark_theme = page.evaluate(
"document.documentElement.getAttribute('data-theme')"
)
dark_ls = page.evaluate(
"localStorage.getItem('shopstack-theme')"
)
assert dark_theme == "dark", (
f"Expected data-theme='dark' after toggleTheme(), got: {dark_theme!r}"
)
assert dark_ls == "dark", (
f"Expected localStorage['shopstack-theme']='dark' after toggle, got: {dark_ls!r}"
)
# ── Step 5: Toggle back to light ────────────────────────────────
page.evaluate("toggleTheme()")
page.wait_for_timeout(100)
light_theme = page.evaluate(
"document.documentElement.getAttribute('data-theme')"
)
light_ls = page.evaluate(
"localStorage.getItem('shopstack-theme')"
)
assert light_theme == "light", (
f"Expected data-theme='light' after second toggle, got: {light_theme!r}"
)
assert light_ls == "light", (
f"Expected localStorage['shopstack-theme']='light' after second toggle, got: {light_ls!r}"
)
# ── Step 6: Reload and verify persistence ───────────────────────
page.reload(wait_until="load")
page.wait_for_timeout(1500)
reload_theme = page.evaluate(
"document.documentElement.getAttribute('data-theme')"
)
reload_ls = page.evaluate(
"localStorage.getItem('shopstack-theme')"
)
assert reload_theme == "light", (
f"data-theme should persist as 'light' after reload, got: {reload_theme!r}"
)
assert reload_ls == "light", (
f"localStorage should still be 'light' after reload, got: {reload_ls!r}"
)
# ── Step 7: Toggle one more time after reload ───────────────────
page.evaluate("toggleTheme()")
page.wait_for_timeout(100)
final_theme = page.evaluate(
"document.documentElement.getAttribute('data-theme')"
)
final_ls = page.evaluate(
"localStorage.getItem('shopstack-theme')"
)
assert final_theme == "dark", (
f"Expected data-theme='dark' after post-reload toggle, got: {final_theme!r}"
)
assert final_ls == "dark", (
f"Expected localStorage='dark' after post-reload toggle, got: {final_ls!r}"
)
# Take a screenshot for evidence.
screenshot_path = str(tmp_path / "dark_mode_final.png")
page.screenshot(path=screenshot_path, full_page=True)
# Verify the page still rendered meaningful content.
body_text = page.locator("body").inner_text()
assert "ShopStack" in body_text or "shopstack" in body_text.lower(), (
f"Expected 'ShopStack' in body text after dark mode toggle, got:\n{body_text[:500]}"
)
browser.close()
finally:
try:
app.close()
except Exception:
pass
# ── Step 8: Assert no errors (include warnings for consistency with
# test_browser_hydration) ───────────────────────────────────────
# Filter out reload-induced benign errors. When the page reloads,
# Gradio's active WebSocket/streaming connections are torn down by
# the browser, surfacing as ``AbortError: signal is aborted without
# reason`` and ``TypeError: Failed to fetch``. These are mechanical
# side-effects of the test's own reload step (step 6), not real app
# bugs. motto_v3 Β§0.6: tests should catch real defects, not noise
# from the test's own actions.
_RELOAD_BENIGN_PATTERNS = (
"signal is aborted", # AbortError on WebSocket close
"Failed to fetch", # fetch() rejected during teardown
"Connection errored out", # Gradio streaming-server wording
"network error", # TypeError on WebSocket teardown during reload
)
def _is_reload_noise(text: str) -> bool:
return any(p in text for p in _RELOAD_BENIGN_PATTERNS)
error_messages = [
m for m in collected
if m["type"] in ("error", "assert", "warning")
and not _is_reload_noise(m["text"])
]
js_errors_filtered = [e for e in js_errors if not _is_reload_noise(e)]
if error_messages or js_errors_filtered:
lines: list[str] = []
lines.append("Browser errors detected during dark mode test:\n")
if js_errors_filtered:
lines.append("-- Uncaught JS exceptions --")
for exc in js_errors_filtered:
lines.append(f" {exc}")
lines.append("")
if error_messages:
lines.append("-- Console errors --")
for m in error_messages:
loc = m["location"]
loc_str = f"{loc.get('url', '?')}:{loc.get('lineNumber', '?')}"
lines.append(f" [{m['type']}] {m['text']} ({loc_str})")
lines.append("")
lines.append(f"Screenshot saved to: {screenshot_path}")
pytest.fail("\n".join(lines))
print(
f"[dark-mode] OK -- dark/light toggle + persistence verified, "
f"screenshot at {screenshot_path}"
)
def test_browser_hydration(tmp_path: Path) -> None:
"""Launch the app in headless Chromium and assert zero console errors.
Test flow:
1. Build the Gradio ``gr.Blocks`` app via ``app.build_app()``.
2. Launch it on a random port in a daemon thread.
3. Drive Playwright (headless Chromium) to navigate to the app.
4. Collect all ``console`` events (log, warn, error) and
``pageerror`` events (uncaught JS exceptions).
5. Take a screenshot saved to ``tmp_path`` for visual evidence.
6. Assert that no errors were emitted.
Args:
tmp_path: pytest built-in fixture providing a temporary directory
for the screenshot.
Raises:
AssertionError: If any browser console error or JS exception was
captured, with the full error text in the failure message.
"""
# Database path is set at module level via setup_module().
# All tests in this file share the same temp database because the
# ``db`` singleton is created at import-once time.
# ── Step 1: Launch the app server ────────────────────────────────
from app import build_app
app = build_app()
port = _find_free_port()
def _serve() -> None:
# ``prevent_thread_lock`` lets Gradio run in a background thread
# instead of blocking the main test thread.
app.launch(
server_port=port,
prevent_thread_lock=True,
inbrowser=False,
quiet=True,
)
t = threading.Thread(target=_serve, daemon=True)
t.start()
base_url = f"http://127.0.0.1:{port}"
_wait_for_server(base_url)
# ── Step 2: Drive Playwright ─────────────────────────────────────
collected: list[dict[str, Any]] = []
js_errors: list[str] = []
try:
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
page: Page = browser.new_page()
# Capture all console output (log, warn, error, info, debug).
def _on_console(msg: ConsoleMessage) -> None:
collected.append({
"type": msg.type,
"text": msg.text,
"location": msg.location,
})
# Capture uncaught JS exceptions (page-level errors).
def _on_pageerror(exc: str) -> None:
js_errors.append(str(exc))
page.on("console", _on_console)
page.on("pageerror", _on_pageerror)
# Navigate and wait for the initial page load to complete.
# Use ``wait_until="load"`` instead of ``"networkidle"`` because
# Gradio opens persistent WebSocket connections for its event
# queue β€” ``networkidle`` will never be satisfied on a page with
# an active WebSocket connection.
page.goto(base_url, wait_until="load")
# Give deferred JS helpers time to run (autocomplete_injector_js
# fires at 100 ms, url_state_sync_js at 200 ms).
page.wait_for_timeout(1000)
# Take a screenshot for visual evidence.
screenshot_path = str(tmp_path / "app_initial_load.png")
page.screenshot(path=screenshot_path, full_page=True)
# Verify the page actually rendered meaningful content.
body_text = page.locator("body").inner_text()
assert "ShopStack" in body_text or "shopstack" in body_text.lower(), (
f"Expected 'ShopStack' in page body text after load, got:\n{body_text[:500]}"
)
browser.close()
finally:
try:
app.close()
except Exception:
pass
# ── Step 3: Assert no errors ─────────────────────────────────────
error_messages = [
m for m in collected if m["type"] in ("error", "assert", "warning")
]
if error_messages or js_errors:
lines: list[str] = []
lines.append("Browser errors detected during app hydration:\n")
if js_errors:
lines.append("── Uncaught JS exceptions ──")
for exc in js_errors:
lines.append(f" {exc}")
lines.append("")
if error_messages:
lines.append("── Console errors/warnings ──")
for m in error_messages:
loc = m["location"]
loc_str = f"{loc.get('url', '?')}:{loc.get('lineNumber', '?')}"
lines.append(f" [{m['type']}] {m['text']} ({loc_str})")
lines.append("")
lines.append(f"Screenshot saved to: {screenshot_path}")
pytest.fail("\n".join(lines))
print(
f"[browser-hydration] OK β€” {len(collected)} console messages "
f"(0 errors), screenshot at {screenshot_path}"
)