Files
bdeb1337 bb71b49fde feat: localchat, a Go + Templ + HTMX chat app for local Gemma models
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.
2026-10-05 07:25:42 +02:00

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")