rgboards/rg-boards
Lanes, team task boards you run from the web or from Claude/ChatGPT (MCP). FastAPI + DataK3 + appid + Dodil Models.
A remix template · 0 remixes · Remix rgboards/rg-boards
scripts/ui_check.py
"""The web page in a real browser (Chromium via Playwright): drag and drop, the card editor,
roles, the mobile layout and the empty states, against an in-memory Lanes with the demo data.
Not part of CI (it needs a browser); run it after changing doors/static/index.html:
pip install playwright && python -m playwright install chromium
python scripts/ui_check.py [screenshot-prefix]
The page normally gets the signed-in person from the Dodil gateway; here each browser context
sends the X-Dodil-User header itself, which only works because nothing sits in front of it.
"""
import asyncio
import json
import socket
import sys
import threading
import time
from pathlib import Path
import uvicorn
from playwright.async_api import async_playwright
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from seed_demo import seed # noqa: E402
from boards.application.use_cases import Boards # noqa: E402
from boards.infrastructure.memory import MemoryBoards # noqa: E402
from boards.main import create_app # noqa: E402
class FakeAI:
"""Stands in for Dodil Models so the check needs no account and no network."""
def checklist(self, title, description=""):
return ["Outline", "Draft", "Review"]
def summary(self, board_title, facts):
return f"- {board_title}: a quiet day."
def embed(self, texts):
vocab = ["invoice", "launch", "checkout", "login", "design", "email"]
return [[1.0 if w in t.lower() else 0.0 for w in vocab] + [0.01] for t in texts]
out = sys.argv[1] if len(sys.argv) > 1 else "/tmp/lanes-ui"
errs = []
def serve() -> str:
s = Boards(MemoryBoards(), FakeAI())
seed(s, "demo.owner@lanes-demo.dev", "demo.member@lanes-demo.dev",
"demo.viewer@lanes-demo.dev")
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
server = uvicorn.Server(uvicorn.Config(create_app(s), port=port, log_level="warning"))
threading.Thread(target=server.run, daemon=True).start()
while not server.started:
time.sleep(0.05)
return f"http://127.0.0.1:{port}"
U = serve()
def hdr(who):
return {"X-Dodil-User": json.dumps({"email": f"demo.{who}@lanes-demo.dev"})}
async def main():
async with async_playwright() as p:
b = await p.chromium.launch()
async def page(who, w=1440, h=900, mobile=False):
ctx = await b.new_context(viewport={"width": w, "height": h}, device_scale_factor=2 if mobile else 1,
extra_http_headers=hdr(who) if who else {}, is_mobile=mobile, has_touch=mobile)
pg = await ctx.new_page()
pg.on("pageerror", lambda e: errs.append(f"pageerror {who}: {e}"))
pg.on("console", lambda m: errs.append(f"console {who}: {m.text}") if m.type=="error" else None)
return pg, ctx
# ---- member drags a card on Product Launch
pg, ctx = await page("member"); await pg.goto(U+"/"); await pg.wait_for_timeout(1000)
await pg.locator("nav a", has_text="Product Launch").click(); await pg.wait_for_timeout(1000)
src = pg.locator(".card", has_text="Record 60-second demo video")
dst = pg.locator('.drop[data-list="Doing"] .card').first
await src.drag_to(dst); await pg.wait_for_timeout(1200)
doing = await pg.locator('.drop[data-list="Doing"] .card .t').all_inner_texts()
print("after drag Doing:", doing)
assert doing[0].startswith("Record 60-second"), doing
# drag to empty-ish end of Done
await pg.locator('.drop[data-list="Done"]').scroll_into_view_if_needed()
await pg.locator(".card", has_text="Record 60-second demo video").drag_to(pg.locator('.drop[data-list="Done"] .card').last)
await pg.wait_for_timeout(1200)
done = await pg.locator('.drop[data-list="Done"] .card .t').all_inner_texts(); print("Done:", done)
assert any(t.startswith("Record") for t in done)
# open the card: history shows the move by member
await pg.locator(".card", has_text="Record 60-second demo video").click(); await pg.wait_for_timeout(800)
hist = await pg.locator(".ev").first.inner_text(); print("history top:", hist.replace("\n"," | "))
assert "moved from Doing to Done" in hist
await pg.screenshot(path=f"{out}-card-member.png")
# edit title + add label + save
await pg.fill("#f-title", "Record 60-second demo video (v2)"); await pg.fill("#f-labels", "content, video, launch")
await pg.click("#save"); await pg.wait_for_timeout(1000)
hist = await pg.locator(".ev").all_inner_texts(); print("history:", [h.split("\n")[0] for h in hist[:3]])
assert any("changed title" in h for h in hist)
# AI checklist (fake AI locally)
await pg.click("#ai-chk"); await pg.wait_for_timeout(1000)
assert await pg.locator(".chk").count() >= 2
await pg.locator(".chk input").first.check(); await pg.wait_for_timeout(800)
# what blocks / similar
await pg.click("#why"); await pg.wait_for_timeout(600)
await pg.click("#sim-go"); await pg.wait_for_timeout(800)
print("similar rows:", await pg.locator("#sim .rowl").count())
await pg.keyboard.press("Escape"); await pg.wait_for_timeout(300)
# add a card through the column form
await pg.locator('.addc[data-list="To do"] input').fill("Brand new card from UI"); await pg.keyboard.press("Enter"); await pg.wait_for_timeout(1000)
assert await pg.locator(".card", has_text="Brand new card from UI").count() == 1
# summary + activity + members (member: no invite form)
await pg.click('[data-act="summary"]'); await pg.wait_for_timeout(800)
print("summary:", (await pg.locator("#sum").inner_text())[:80].replace("\n"," | "))
await pg.click('[data-act="members"]'); await pg.wait_for_timeout(500)
assert await pg.locator("#inv").count() == 0
await pg.keyboard.press("Escape")
await pg.click('[data-act="activity"]'); await pg.wait_for_timeout(500)
await pg.screenshot(path=f"{out}-activity.png"); await pg.keyboard.press("Escape")
# search, words and meaning
await pg.fill("#q", "checkout"); await pg.keyboard.press("Enter"); await pg.wait_for_timeout(700)
print("search hits:", await pg.locator("#view .card").count())
await pg.click('.seg button[data-mode="meaning"]'); await pg.fill("#q", "billing a customer"); await pg.keyboard.press("Enter"); await pg.wait_for_timeout(900)
print("meaning hits:", await pg.locator("#view .card").count())
await pg.goto(U+"/#/blocked"); await pg.wait_for_timeout(900); await pg.screenshot(path=f"{out}-blocked.png")
await pg.goto(U+"/#/mine"); await pg.wait_for_timeout(900); await pg.screenshot(path=f"{out}-mine.png")
await ctx.close()
# ---- owner: members dialog + invite
pg, ctx = await page("owner"); await pg.goto(U+"/"); await pg.wait_for_timeout(1000)
await pg.locator("nav a", has_text="Product Launch").click(); await pg.wait_for_timeout(900)
await pg.click('[data-act="members"]'); await pg.wait_for_timeout(500)
await pg.fill("#m-email", "new.person@lanes-demo.dev"); await pg.select_option("#m-role", "viewer"); await pg.click("#inv button"); await pg.wait_for_timeout(900)
assert await pg.locator(".mem", has_text="new.person@lanes-demo.dev").count() == 1
await pg.screenshot(path=f"{out}-members.png")
await ctx.close()
# ---- viewer: no drag, no forms, read-only modal
pg, ctx = await page("viewer"); await pg.goto(U+"/"); await pg.wait_for_timeout(1000)
await pg.locator("nav a", has_text="Product Launch").click(); await pg.wait_for_timeout(900)
assert await pg.locator('.card[draggable="true"]').count() == 0
assert await pg.locator(".addc").count() == 0 and await pg.locator("#nl").count() == 0
before = await pg.locator('.drop[data-list="To do"] .card').count()
await pg.locator('.drop[data-list="To do"] .card').first.drag_to(pg.locator('.drop[data-list="Done"]')); await pg.wait_for_timeout(800)
assert await pg.locator('.drop[data-list="To do"] .card').count() == before
assert "ops" not in (await pg.locator("#boards").inner_text()).lower()
await pg.locator(".card").first.click(); await pg.wait_for_timeout(800)
assert await pg.locator("#save").count() == 0 and await pg.locator(".lock").count() == 1
await pg.screenshot(path=f"{out}-card-viewer.png")
await ctx.close()
# ---- mobile: drawer + modal
pg, ctx = await page("member", 390, 844, True); await pg.goto(U+"/"); await pg.wait_for_timeout(1000)
await pg.screenshot(path=f"{out}-mobile.png")
await pg.click("#menu"); await pg.wait_for_timeout(400); await pg.screenshot(path=f"{out}-mobile-nav.png")
await pg.locator("nav a", has_text="Product Launch").click(); await pg.wait_for_timeout(900)
await pg.locator(".card").first.click(); await pg.wait_for_timeout(800)
await pg.screenshot(path=f"{out}-mobile-card.png")
await ctx.close()
# ---- a brand-new user: welcome state
pg, ctx = await page("nobody"); await pg.goto(U+"/"); await pg.wait_for_timeout(1000)
await pg.screenshot(path=f"{out}-welcome.png")
await pg.fill("#first input", "My first board"); await pg.click("#first button"); await pg.wait_for_timeout(1000)
assert "My first board" in await pg.locator(".bh h1").inner_text()
await pg.screenshot(path=f"{out}-empty-board.png")
await ctx.close()
await b.close()
print("browser errors:", errs)
if __name__ == "__main__":
asyncio.run(main())
if errs:
raise SystemExit(f"browser errors: {errs}")
print("ui check passed; screenshots at", out + "-*.png")