"""Unit: the chat-persistence contract in the static frontend (phase 14). The browser behavior itself is E2E-covered (tests/e2e/test_chat_persistence.py); here we pin the localStorage persistence markers in app.js/index.html/ styles.css so a silent regression (key rename, dropped try/catch, missing restore, New chat control lost) is caught without a browser. Pinned design (PLAN §7.4 note / phase 14): * versioned key ``bor.chat.v1`` → ``{v: 1, messages: [...]}``, raw text only; * save points: user message on send, brain message on ``done``; * every ``localStorage`` access wrapped in try/catch (failure-safe); * size budget ~700k chars, oldest dropped first; * ``#new-chat-btn`` in the chat header (chat page only), ≥44px, ghost pill. """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" APP_JS = FRONTEND / "assets" / "app.js" INDEX_HTML = FRONTEND / "index.html" SOURCES_HTML = FRONTEND / "sources.html" DOCUMENT_HTML = FRONTEND / "document.html" STYLES_CSS = FRONTEND / "assets" / "styles.css" def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _css() -> str: return STYLES_CSS.read_text(encoding="utf-8") def _index() -> str: return INDEX_HTML.read_text(encoding="utf-8") def test_versioned_storage_key_and_v1_payload() -> None: """`bor.chat.v1` (versioned — a format bump is a clean start) with the {v, messages} payload shape (A11: raw localStorage JSON, no library).""" js = _js() assert 'const STORAGE_KEY = "bor.chat.v1"' in js assert "export const STORAGE_VERSION = 1" in js # The payload written to the key is always {v: STORAGE_VERSION, messages} # (two write paths: saveConversation and the trimToBudget size probe). assert js.count("v: STORAGE_VERSION, messages") >= 2 # Restore validates the version before trusting anything. assert "data.v !== STORAGE_VERSION" in js def test_storage_size_budget_drops_oldest_first() -> None: """~700k-char serialized budget (far under the ~5MB quota); the loop drops messages from the FRONT (oldest) until the state fits.""" js = _js() assert "export const STORAGE_BUDGET_CHARS = 700_000" in js assert "out.length <= 1" in js, "never drop the last remaining message" assert "out = out.slice(1)" in js, "oldest-first drop (slice(1), not pop)" assert "STORAGE_BUDGET_CHARS" in js def test_every_storage_access_is_failure_safe() -> None: """AC4: every localStorage access (getItem/setItem/removeItem) must be inside a try/ that is closer than the enclosing function boundary — private mode or quota exhaustion must never throw into the UI.""" js = _js() accesses = list(re.finditer(r"localStorage\.(?:getItem|setItem|removeItem)", js)) assert len(accesses) == 3, f"expected exactly 3 localStorage accesses, got {len(accesses)}" for m in accesses: try_idx = js.rfind("try {", 0, m.start()) fn_idx = js.rfind("function ", 0, m.start()) assert try_idx != -1, f"no try before {m.group(0)!r}" assert try_idx > fn_idx, ( f"{m.group(0)!r} is not inside its function's try block " f"(function boundary at {fn_idx} is after try at {try_idx})" ) # Each access has its own catch that degrades silently. assert js.count("} catch {") >= len(accesses) def test_raw_text_only_stored_and_re_rendered_on_restore() -> None: """The value is raw text (re-rendered through the escape-first markdown on restore) — no HTML is ever stored. Restore re-applies the full brain-message chrome: is-deflected styling, maybe-try chips, sources.""" js = _js() # Phase 18: restore landings are forced ("auto" + force) one-shot # scrollReveal calls — the only forced scrolls in the app. assert 'addMessage("user", renderMarkdown(m.text), "auto", true)' in js assert 'addMessage("brain", renderMarkdown(m.text), "auto", true)' in js assert "wrap.classList.add(\"is-deflected\")" in js assert "appendMaybeTry(wrap, m.suggestions)" in js assert "appendSources(wrap, m.sources)" in js # Restore runs on load (module scope, after the handlers are wired). assert "restoreConversation();" in js # Corrupt/legacy payloads degrade to a clean start, never a crash. assert "Array.isArray(data.messages)" in js def test_save_points_user_on_send_and_brain_on_done() -> None: """Save points: the user message is stored the moment it is sent (BEFORE the fetch — a failed turn keeps the question); the brain message is stored on `done` with the done metadata (sources/deflected/suggestions).""" js = _js() user_push = js.find('conversation.push({ who: "user", text })') assert user_push != -1 assert user_push < js.find('fetch("/api/chat"'), ( "the user message must be saved before the turn starts" ) # Brain save point is wired into the done handler with full metadata # (phase 17: the persisted text is finalText — the empty-answer # fallback substitution — and the optional thinking field rides along # in the same meta object). done_idx = js.find('ev.type === "done"') assert done_idx != -1 done_block = js[done_idx : done_idx + 1300] assert "rememberBrainTurn(finalText || acc" in done_block assert "thinking: thinkingAcc || undefined" in done_block assert "deflected: !!ev.deflected" in done_block assert "sources: ev.sources" in done_block assert "suggestions: ev.suggestions" in done_block # rememberBrainTurn stores raw text and saves immediately. assert "text: rawText ||" in js body = js[js.find("function rememberBrainTurn") :] assert "saveConversation()" in body[: body.find("\n}\n") + 3] def test_new_chat_clears_key_and_ui() -> None: """New chat: clears the stored key, the rendered list, restores the empty state, and reuses the #send-status live region for the confirmation. A live turn is never hijacked.""" js = _js() fn_start = js.find("function startNewChat") assert fn_start != -1 body = js[fn_start : js.find("\n}\n", fn_start)] assert "clearStoredConversation()" in body assert 'querySelectorAll(".msg")' in body assert "emptyState.hidden = false" in body assert "setUiState(UI_STATE.idle)" in body assert "sendStatus.textContent" in body, "confirmation via the live region" assert "UI_STATE.thinking" in body and "UI_STATE.streaming" in body, ( "new chat must be ignored while a turn is in flight" ) assert "removeItem(STORAGE_KEY)" in js def test_new_chat_button_in_chat_header_only() -> None: """#new-chat-btn lives in the chat header (index.html) as a real type=button with an accessible name — and nowhere else (A10: chat-page only control).""" html = _index() btn = re.search(r']*id="new-chat-btn"[^>]*>', html) assert btn, "index.html must contain #new-chat-btn" tag = btn.group(0) assert 'type="button"' in tag assert 'aria-label="New chat"' in tag nav_idx = html.find('