Files
2026-07-13 13:12:00 +08:00

227 lines
9.0 KiB
Python

"""Browser-level e2e coverage of the installable-PWA behavior.
Companion to the build-output guard in ``conftest._assert_pwa_build`` and
``test_pwa_build.py`` (which inspect the emitted files): this drives the SPA in a
real browser and asserts the runtime PWA contract end to end —
1. the service worker registers and reaches an active state,
2. the app is installable (manifest linked, served with the manifest MIME,
with the required name / display / icons),
3. the worker caches ONLY ``version.json`` — never the app shell, and
4. navigations always hit the network (the worker does not intercept them,
even once it controls the page).
(3) and (4) are the load-bearing invariants for a cloud app: a cached/served app
shell would white-screen users behind a stale deploy. Asserting them in a real
browser is the only place that catches a service worker that *looks* fine in the
source guard but actually serves the shell at runtime.
Unlike the rest of this suite, the PWA contract is independent of the agent /
LLM, so this spawns a plain ``omnigent server`` (no ``--agent``, no runner, no
Databricks credentials) — keeping the test fast and runnable anywhere. It still
serves the production static mount + cache/MIME headers from
``omnigent/server/app.py``, which is exactly what the browser checks rely on.
Part of the gated e2e_ui suite (excluded from the default ``pytest`` run via
``--ignore=tests/e2e_ui``); see this package's ``conftest`` module docstring.
"""
from __future__ import annotations
import os
import socket
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path
import httpx
import pytest
from playwright.sync_api import Page
_REPO_ROOT = Path(__file__).resolve().parents[2]
_HEALTH_TIMEOUT_S = 30.0
_HEALTH_POLL_INTERVAL_S = 0.5
def _free_port() -> int:
"""Return a free localhost TCP port (bind-to-0, then release)."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _wait_healthy(proc: subprocess.Popen[bytes], base_url: str, log_path: Path) -> None:
"""Poll ``/health`` until 200, or raise with the server log on failure."""
deadline = time.monotonic() + _HEALTH_TIMEOUT_S
last_error = "not polled yet"
while time.monotonic() < deadline:
if proc.poll() is not None:
log = log_path.read_text() if log_path.exists() else ""
raise RuntimeError(
f"omnigent server exited early (code {proc.returncode}).\n{log[:2000]}"
)
try:
resp = httpx.get(f"{base_url}/health", timeout=2)
if resp.status_code == 200:
return
last_error = f"health HTTP {resp.status_code}"
except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException) as exc:
last_error = f"{type(exc).__name__}: {exc}"
time.sleep(_HEALTH_POLL_INTERVAL_S)
log = log_path.read_text() if log_path.exists() else ""
raise RuntimeError(
f"omnigent server not healthy within {_HEALTH_TIMEOUT_S:.0f}s on "
f"{base_url} (last_error={last_error}).\n{log[:2000]}"
)
@pytest.fixture(scope="session")
def pwa_server(
built_spa: None,
request: pytest.FixtureRequest,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterator[str]:
"""Serve the built SPA via a no-agent ``omnigent server`` and yield its URL.
No ``--agent`` (and therefore no runner / LLM credentials) — the PWA
contract this test checks is agent-independent, so this stays fast and
creds-free where ``live_server`` cannot. ``--database-uri`` /
``--artifact-location`` point at the pytest tmp dir so the user's default
``omnigent.db`` / ``artifacts`` are never touched.
:param built_spa: Ensures the SPA bundle (incl. the PWA assets) is on disk
before the server mounts it.
:param request: Reads ``--ui-base-url`` to reuse an already-running server.
:param tmp_path_factory: Per-session DB / artifact / log location.
:returns: The server base URL, e.g. ``"http://127.0.0.1:51234"``.
"""
override = request.config.getoption("--ui-base-url")
if override:
yield override
return
port = _free_port()
server_tmp = tmp_path_factory.mktemp("pwa_e2e_server")
log_path = server_tmp / "server.log"
# PYTHONPATH forces the subprocess to import omnigent from the worktree, not
# whatever is pip-installed in .venv — otherwise a branch's code changes
# would silently run against stale code (same trick as ``live_server``).
env = {
**os.environ,
"PYTHONPATH": f"{_REPO_ROOT}{os.pathsep}{os.environ.get('PYTHONPATH', '')}",
}
base_url = f"http://127.0.0.1:{port}"
# The log handle must outlive the Popen, so the context manager spans the
# whole fixture (Popen → yield → teardown), closing on fixture exit.
with open(log_path, "w", encoding="utf-8") as log_handle:
proc = subprocess.Popen(
[
sys.executable,
"-c",
"from omnigent.cli import main; main()",
"server",
"--host",
"127.0.0.1",
"--port",
str(port),
"--database-uri",
f"sqlite:///{server_tmp / 'test.db'}",
"--artifact-location",
str(server_tmp / "artifacts"),
],
env=env,
stdout=log_handle,
stderr=subprocess.STDOUT,
)
try:
_wait_healthy(proc, base_url, log_path)
yield base_url
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
def test_pwa_installable_sw_registers_and_never_serves_the_shell(
page: Page,
pwa_server: str,
) -> None:
"""The installed PWA registers its worker, is installable, and the worker
serves only ``version.json`` — never the app shell or navigations.
:param page: Playwright page fixture (fresh context per test).
:param pwa_server: Base URL of the no-agent server serving the built SPA.
:returns: None.
"""
base_url = pwa_server
page.goto(base_url, wait_until="load")
# 1. The service worker registers and activates.
sw = page.evaluate(
"""async () => {
const reg = await navigator.serviceWorker.ready;
return { scriptURL: reg.active && reg.active.scriptURL, scope: reg.scope };
}"""
)
assert sw["scriptURL"], "service worker never reached an active state"
assert sw["scriptURL"].endswith("/sw.js")
assert sw["scope"] == f"{base_url}/"
# 2. Installable: manifest linked, served with the manifest MIME, and valid.
manifest = page.evaluate(
"""async () => {
const link = document.querySelector('link[rel="manifest"]');
if (!link) return { linked: false };
const res = await fetch(link.href);
let body = null;
try { body = await res.json(); } catch (e) { /* non-JSON */ }
return {
linked: true,
status: res.status,
contentType: res.headers.get("content-type") || "",
name: body && body.name,
display: body && body.display,
icons: body && body.icons ? body.icons.length : 0,
};
}"""
)
assert manifest["linked"], "index.html has no <link rel=manifest>"
assert manifest["status"] == 200
assert manifest["contentType"].startswith("application/manifest+json")
assert manifest["name"] == "Omnigent"
assert manifest["display"] == "standalone"
assert manifest["icons"] >= 2 # 192 + 512 is the installability minimum
# 3. The worker caches ONLY version.json — never the app shell. A cached
# shell would white-screen users behind a stale deploy.
cached_paths = page.evaluate(
"""async () => {
const paths = [];
for (const name of await caches.keys()) {
const cache = await caches.open(name);
for (const req of await cache.keys()) paths.push(new URL(req.url).pathname);
}
return paths.sort();
}"""
)
assert cached_paths == ["/version.json"], f"worker cached unexpected entries: {cached_paths}"
# 4. Navigations always hit the network — the worker must not intercept them
# even once it controls the page. Reload so the worker is controlling,
# then assert the navigation response came from the network, not the SW.
resp = page.goto(base_url, wait_until="load")
assert page.evaluate("() => !!navigator.serviceWorker.controller"), (
"service worker did not take control of the page after reload"
)
assert resp is not None
assert resp.from_service_worker is False, (
"the service worker intercepted a navigation — navigations must hit the "
"network so a deploy is never masked by a stale shell"
)