Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c | """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()) | |