Streams replies over SSE from any OpenAI-compatible server (oMLX, llama.cpp, Ollama, ...). Single binary that can install itself as an OS service; docker compose bundles llama.cpp + Gemma 4 E2B.
71 lines
2.7 KiB
Python
71 lines
2.7 KiB
Python
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = ["playwright"]
|
|
# ///
|
|
"""Browser end-to-end test: loads the UI, sends a message with Enter, waits
|
|
for the streamed reply to finish rendering, and saves screenshots.
|
|
|
|
uv run scripts/e2e.py [--url http://127.0.0.1:3000] [--channel msedge]
|
|
"""
|
|
import argparse, sys, time
|
|
from playwright.sync_api import sync_playwright, expect
|
|
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument("--url", default="http://127.0.0.1:3000")
|
|
p.add_argument("--channel", default="msedge", help="installed browser: chrome, msedge, ...")
|
|
p.add_argument("--out", default="docs")
|
|
p.add_argument("--dark", action="store_true")
|
|
p.add_argument("--prompt", default="Give me 3 short tips for learning Go, as a numbered list. Include one tiny code snippet.")
|
|
args = p.parse_args()
|
|
|
|
with sync_playwright() as pw:
|
|
browser = pw.chromium.launch(channel=args.channel, headless=True)
|
|
page = browser.new_page(viewport={"width": 900, "height": 760},
|
|
color_scheme="dark" if args.dark else "light")
|
|
errors = []
|
|
page.on("pageerror", lambda e: errors.append(str(e)))
|
|
page.goto(args.url)
|
|
|
|
expect(page.locator("#health .dot.ok")).to_be_visible(timeout=10_000)
|
|
expect(page.locator(".empty")).to_be_visible()
|
|
|
|
box = page.locator("#composer textarea")
|
|
box.fill(args.prompt)
|
|
t0 = time.time()
|
|
box.press("Enter")
|
|
|
|
# user bubble shows up and the input clears
|
|
expect(page.locator(".msg.user")).to_have_count(1)
|
|
expect(box).to_have_value("")
|
|
expect(page.locator(".empty")).to_be_hidden()
|
|
|
|
# tokens stream in (plain spans) ...
|
|
page.wait_for_selector(".streaming span", timeout=60_000)
|
|
first = time.time() - t0
|
|
# ... then get replaced by rendered Markdown
|
|
page.wait_for_selector(".msg.assistant .markdown", timeout=180_000)
|
|
total = time.time() - t0
|
|
page.wait_for_timeout(300)
|
|
assert page.locator(".msg.assistant [sse-connect]").count() == 0, "SSE element not cleaned up"
|
|
if "numbered list" in args.prompt:
|
|
assert page.locator(".msg.assistant ol li").count() >= 3, "expected a numbered list"
|
|
if "code" in args.prompt:
|
|
assert page.locator(".msg.assistant pre code").count() >= 1, "expected a code block"
|
|
page.screenshot(path=f"{args.out}/screenshot{'-dark' if args.dark else ''}.png")
|
|
|
|
# reload: history persists for the session
|
|
page.reload()
|
|
expect(page.locator(".msg")).to_have_count(2)
|
|
|
|
# new chat clears it
|
|
page.get_by_role("button", name="New chat").click()
|
|
expect(page.locator(".msg")).to_have_count(0)
|
|
expect(page.locator(".empty")).to_be_visible()
|
|
|
|
browser.close()
|
|
|
|
if errors:
|
|
print("JS errors:", errors)
|
|
sys.exit(1)
|
|
print(f"OK: first token {first:.1f}s, full reply {total:.1f}s")
|