loosecanvas / scripts /playwright_composer.py
Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c
Raw
History Blame Contribute Delete
6.27 kB
"""Browser validation for P2-05 chat composer ergonomics (A1-A4).
No LLM needed: loads a deterministic fixture, and a real turn fails fast (llama.cpp
port refuses the connection) so the error path is exercised quickly. Asserts:
- A1: the chat box is a multi-line textarea (max_lines) and is autofocused on load.
- A3 (no-session): submitting with no graph preserves the typed draft and shows a
"load a graph first" status — the message is NOT committed to the transcript.
- A2 + A3 (error): after loading a fixture, submitting a turn keeps the typed text and
leaves the box re-enabled once the LLM-down turn errors (proves the disable→re-enable
and keep-text-on-error paths, and that the new 6-tuple yields don't crash gradio).
- on_build_text empty guard yields cleanly (6-tuple) — clicking Build with no text warns.
.venv/Scripts/python.exe scripts/playwright_composer.py
Requires `uvicorn loosecanvas.main:app` on APP_PORT (default 8000).
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
BASE = f"http://127.0.0.1:{os.environ.get('APP_PORT', '8000')}/"
OUT = Path(__file__).resolve().parent.parent / ".playwright-out"
OUT.mkdir(exist_ok=True)
PLACEHOLDER = "Ask about the graph"
def main() -> int: # noqa: C901 - a linear validation script
console: list[str] = []
report: dict[str, object] = {"base": BASE}
failures: list[str] = []
warnings: list[str] = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.on("console", lambda m: console.append(f"{m.type}: {m.text}"))
page.on("pageerror", lambda e: console.append(f"pageerror: {e}"))
page.add_init_script("window.__PLAYWRIGHT_TEST = true;")
page.goto(BASE, wait_until="domcontentloaded")
page.wait_for_selector("#lc-canvas", timeout=30_000)
box = page.get_by_placeholder(PLACEHOLDER)
box.wait_for(timeout=15_000)
# A1: multi-line textarea + autofocus.
tag = box.evaluate("el => el.tagName.toLowerCase()")
report["composer_tag"] = tag
if tag != "textarea":
warnings.append(f"composer is <{tag}>, expected <textarea> (multi-line)")
focused = box.evaluate("el => el === document.activeElement")
report["autofocused"] = focused
if not focused:
warnings.append("composer not autofocused on load")
# A3 no-session: type a draft, submit with NO graph loaded.
draft = "what connects these ideas?"
box.fill(draft)
page.get_by_role("button", name="Send").click()
page.wait_for_timeout(1200)
kept = box.input_value()
report["no_session_draft_kept"] = kept == draft
if kept != draft:
failures.append(
f"A3 no-session: draft not preserved (box={kept!r}, expected {draft!r})"
)
status_text = (page.locator(".lc-turn-status").first.inner_text() or "").strip()
report["no_session_status"] = status_text
if "graph" not in status_text.lower():
warnings.append(
f"A3 no-session: expected a 'load a graph' status, got {status_text!r}"
)
# A2 + A3 error path: load a fixture, then submit a turn (LLM is down).
page.get_by_role("button", name="Load graph").click()
page.wait_for_function(
"() => window.__cy && window.__cy.nodes().length > 0", timeout=20_000
)
box.fill(draft)
page.get_by_role("button", name="Send").click()
# Try to observe the transient disabled state (best-effort; fast on conn-refused).
try:
page.wait_for_function(
"(ph) => { const t=[...document.querySelectorAll('textarea')]"
".find(e=>e.placeholder && e.placeholder.includes(ph));"
" return t && t.disabled; }",
arg=PLACEHOLDER,
timeout=4000,
)
report["observed_disabled_during_turn"] = True
except Exception: # noqa: BLE001 - transient; record as not-observed
report["observed_disabled_during_turn"] = False
warnings.append("did not catch the transient disabled state (fast error)")
# End state: the turn errored (LLM down); box must be re-enabled and text kept.
page.wait_for_function(
"(ph) => { const t=[...document.querySelectorAll('textarea')]"
".find(e=>e.placeholder && e.placeholder.includes(ph));"
" return t && !t.disabled; }",
arg=PLACEHOLDER,
timeout=20_000,
)
page.wait_for_timeout(500)
after_err = box.input_value()
report["error_draft_kept"] = after_err == draft
report["error_box_enabled"] = box.is_enabled()
if after_err != draft:
failures.append(
f"A3 error: typed text not kept after errored turn (box={after_err!r})"
)
if not box.is_enabled():
failures.append("A2: composer left disabled after errored turn")
page.screenshot(path=str(OUT / "composer_after_error.png"))
# on_build_text empty-guard arity: expand accordion, Build with empty text.
try:
page.get_by_text("Build from text", exact=False).first.click()
page.wait_for_timeout(300)
page.get_by_role("button", name="Build graph from text").click()
page.wait_for_timeout(1000)
report["build_empty_guard_ran"] = True
except Exception as exc: # noqa: BLE001
warnings.append(f"could not exercise build empty-guard: {exc}")
report["build_empty_guard_ran"] = False
browser.close()
report["console_errors"] = [c for c in console if "error" in c.lower()][-15:]
report["warnings"] = warnings
report["failures"] = failures
report["ok"] = not failures
(OUT / "composer_report.json").write_text(
json.dumps(report, indent=2), encoding="utf-8"
)
print(json.dumps(report, indent=2))
return 0 if not failures else 1
if __name__ == "__main__":
sys.exit(main())