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

123 lines
4.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Stale-stream banner: kill the runner mid-stream, verify the UI reacts.
The frontend polls ``GET /health?session_id=`` every 10 s while a
response is streaming. When the runner crashes, the tunnel drops and
the health endpoint returns ``runner_online: false``. The next poll
flips ``streamStale`` in the chat store, which swaps the "Working…"
shimmer for an "Agent is unresponsive" banner.
This test exercises the full chain: SPA → SSE stream → health poll →
banner render. It kills the runner subprocess with SIGKILL while the
LLM is processing, so the tunnel drops instantly — no graceful
shutdown, no terminal SSE event.
"""
from __future__ import annotations
import os
import re
import signal
import subprocess
import time
import httpx
from playwright.sync_api import Page, expect
def _find_runner_pids() -> list[int]:
"""
Find all PIDs running the runner entry point
(``omnigent.runner._entry``).
The runner is a sibling subprocess of the server (both spawned
by the test fixture), so we search by command-line pattern
rather than by parent PID.
:returns: List of runner PIDs (may be empty).
"""
result = subprocess.run(
["pgrep", "-f", "omnigent.runner._entry"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return []
return [int(line.strip()) for line in result.stdout.strip().splitlines() if line.strip()]
def test_stale_banner_on_runner_crash(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""
Open a pre-created session, send a message, kill the runner while
the LLM is thinking, and assert the "unresponsive" banner replaces
"Working…".
Starts from ``/c/<id>`` instead of ``/`` because the home route no
longer renders a composer — see :func:`seeded_session`.
:param page: Playwright page fixture.
:param seeded_session: ``(base_url, session_id)`` of a pre-created
session bound to the running runner.
"""
live_server, session_id = seeded_session
page.goto(f"{live_server}/c/{session_id}")
composer = page.get_by_placeholder("Ask the agent anything…")
expect(composer).to_be_visible()
composer.fill("Write a 500-word essay about the history of computing.")
page.get_by_role("button", name="Send", exact=True).click()
# URL should still match /c/<session_id>.
expect(page).to_have_url(re.compile(rf"/c/{re.escape(session_id)}"), timeout=15_000)
# Wait for the "Working…" shimmer — proves the SSE stream opened
# and the agent started processing.
working = page.locator('[data-testid="working-indicator"]')
expect(working).to_be_visible(timeout=15_000)
# Verify the health endpoint reports online before the kill.
health_before = httpx.get(
f"{live_server}/health?session_id={session_id}",
timeout=5,
).json() # /health — no auth needed
assert health_before.get("session", {}).get("runner_online") is True, (
f"Health endpoint should report runner_online=true before kill, got: {health_before}"
)
# Kill the runner (sibling of the server, not a child).
runner_pids = _find_runner_pids()
assert runner_pids, (
"No runner processes found. Process tree:\n"
+ subprocess.run(
["ps", "-ef"],
capture_output=True,
text=True,
).stdout[:2000]
)
for pid in runner_pids:
os.kill(pid, signal.SIGKILL)
# Poll until the health endpoint reports offline (tunnel teardown
# is async — the server's WS route needs to notice the close and
# deregister). 10 retries × 0.5 s = 5 s budget.
health_after: dict[str, object] = {}
for _attempt in range(10):
time.sleep(0.5)
health_after = httpx.get(
f"{live_server}/health?session_id={session_id}",
timeout=5,
).json()
if health_after.get("session", {}).get("runner_online") is False:
break
assert health_after.get("session", {}).get("runner_online") is False, (
f"Health endpoint should report runner_online=false after kill, got: {health_after}"
)
# The health poller fires every 10 s. No grace period — the
# indicator flips on the next poll. Budget 20 s from the kill.
indicator = page.locator('[data-testid="disconnected-indicator"]')
expect(indicator).to_be_visible(timeout=20_000)
expect(indicator).to_contain_text("disconnected")