"""Unit: the phase-50 task-03 save-chat contract on the chat page. The browser behavior itself is E2E-gated by the story suite (task 05); like the other frontend-adjacent unit files, this module pins the JS/CSS/HTML markers the save/load contract depends on, so a silent regression is caught without a browser: * the ``currentChatId`` lifecycle (set on create/open, cleared by New chat and by the 404-PUT fallback); * the upsert branch (PUT when linked, POST when not, the 404→recreate fallback, the live-region feedback strings); * the boot-load precedence (a valid ``?chat=`` uuid + admin replaces the local restore and mirrors it to localStorage; anonymous / invalid / 404 / network → the local restore); * the ship-hidden / reveal-for-admin gate on ``#save-chat-btn``. """ 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" GIT_SOURCES_HTML = FRONTEND / "git-sources.html" DOCUMENT_HTML = FRONTEND / "document.html" LOGIN_HTML = FRONTEND / "login.html" TUNING_HTML = FRONTEND / "tuning.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 _fn(js: str, name: str) -> str: """The source of a top-level ``function (...)`` (to its close).""" start = js.find(f"function {name}(") assert start != -1, f"{name}() must exist in app.js" return js[start : js.find("\n}\n", start) + 4] # ---------- the Save button on the chat page ---------- def test_save_button_ships_hidden_beside_new_chat() -> None: """#save-chat-btn: a real type=button with the accessible name "Save chat", SHIPPED HIDDEN (app.js reveals it for admin only), beside #new-chat-btn in .chat-shell inside
, above #messages — the two chat-shell actions read as a pair. No other page carries it (chat-page only, like New chat).""" html = _index() btn = re.search(r']*id="save-chat-btn"[^>]*>', html) assert btn, "index.html must contain #save-chat-btn" tag = btn.group(0) assert 'type="button"' in tag assert 'aria-label="Save chat"' in tag assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)" # Beside New chat: after it, still inside .chat-shell, above #messages. main_idx = html.find('main id="main"') shell_idx = html.find('class="container chat-shell"') new_idx = html.find('id="new-chat-btn"') messages_idx = html.find('id="messages"') assert -1 < main_idx < shell_idx < new_idx < btn.start() < messages_idx, ( "the button must sit beside #new-chat-btn in .chat-shell, above #messages" ) for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML): assert 'id="save-chat-btn"' not in other.read_text(encoding="utf-8"), ( f"{other.name}: the Save button is chat-page only" ) def test_save_button_css_is_the_exact_new_chat_family() -> None: """styles.css: .save-chat-btn carries the EXACT visual family of .new-chat-btn — solid brand pill (--bg on --brand = 5.2:1, AA), borderless, 999px radius, ≥44px target, hover lightens the brand fill; the ≤640px block mirrors the New chat overrides (label stays visible in .chat-shell, icon hidden there; icon-only elsewhere).""" css = _css() block = re.search(r"\.save-chat-btn \{([\s\S]*?)\n\}", css) assert block, "styles.css must style .save-chat-btn" body = block.group(1) assert "min-height: 44px" in body assert "border-radius: 999px" in body assert "border: 0" in body assert "background: var(--brand)" in body, "same solid brand fill as New chat" assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)" hover = re.search(r"\.save-chat-btn:hover \{([\s\S]*?)\n\}", css) assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill" svg = re.search(r"\.save-chat-btn svg \{([\s\S]*?)\n\}", css) assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like New chat)" mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css) assert mobile, "mobile media query missing" mbody = mobile.group(1) assert ".save-chat-btn { padding: 0.4rem 0.3rem; }" in mbody, "squeezes with New chat" assert ".save-chat-label { display: none; }" in mbody assert ".save-chat-btn svg { display: block; }" in mbody assert ".chat-shell .save-chat-label { display: inline; }" in mbody, ( "in .chat-shell the label stays visible, as for New chat" ) assert ".chat-shell .save-chat-btn svg { display: none; }" in mbody # ---------- currentChatId lifecycle ---------- def test_current_chat_id_module_scope_and_lifecycle() -> None: """currentChatId: module scope, string | null — set to the created row's id on a fresh Save (201), set to the opened id on a successful boot load, cleared by "New chat" AND by the 404-PUT fallback (a stale link must never leave the conversation unsaved).""" js = _js() assert "let currentChatId = null" in js, "module-scope link, null = unlinked" # Set on create: the 201 branch links to the created row's id. save_body = _fn(js, "saveCurrentChat") assert "res.status === 201" in save_body assert "currentChatId = String(created.id)" in save_body, ( "a fresh Save links to the created row's id" ) # Set on open: the boot load links to the fetched id. load_body = _fn(js, "restoreSavedChatFromUrl") assert "currentChatId = chatId" in load_body # Cleared by New chat. new_body = _fn(js, "startNewChat") assert "currentChatId = null" in new_body, "New chat unlinks" # Cleared by the 404-PUT fallback (see the upsert test for the branch). assert "res.status === 404" in save_body assert save_body.count("currentChatId = null") >= 1 # ---------- the upsert branch ---------- def test_save_upsert_put_when_linked_post_when_not() -> None: """saveCurrentChat: linked → PUT /api/chats/ with the messages payload (re-Save updates the SAME row — no title in the body, so the row keeps its current one); unlinked → POST /api/chats (the server auto-titles). The 404 from the PUT unlinks and retries as a create. Empty conversation → no request, live-region "Nothing to save yet."; success → live-region "Conversation saved." (status text only, no banner); 403/5xx/network → the error banner.""" js = _js() body = _fn(js, "saveCurrentChat") # No-op first: nothing to save → live-region line, no fetch. noop = body.find('sendStatus.textContent = "Nothing to save yet."') first_fetch = body.find("await fetch(") assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch" assert "if (!conversation.length)" in body # The branch: PUT when linked, POST when not. assert "if (currentChatId)" in body assert '`/api/chats/${currentChatId}`' in body assert 'method: "PUT"' in body assert 'fetch("/api/chats"' in body assert 'method: "POST"' in body put_idx = body.find('method: "PUT"') post_idx = body.find('method: "POST"') assert -1 < put_idx < post_idx, "the PUT (linked) branch precedes the POST fallback" assert '{ messages: conversation }' in body, "the messages payload — no title (keep current)" # The 404→recreate fallback: unlink, then POST again. notfound_idx = body.find("res.status === 404") assert notfound_idx != -1, "the PUT 404 must be handled" fallback = body[notfound_idx:post_idx] assert "currentChatId = null" in fallback, "the stale link is dropped" # Success is status text only — the live region, never stale — and # nothing between the 201 link and the success line may raise a # banner (the !res.ok branch returns before either). assert body.count('sendStatus.textContent = "Conversation saved."') == 1 saved_line = 'sendStatus.textContent = "Conversation saved."' between = body[body.find("res.status === 201") : body.find(saved_line)] assert "showErrorBanner" not in between, "no banner on the success path" # Failures raise an actionable banner (non-ok HTTP + network). assert 'showErrorBanner("Couldn\'t save the conversation — is the app reachable?")' in body assert "check you're still signed in and try again" in body, "403/5xx: actionable line" # The double-click guard releases on EVERY outcome. finally_idx = body.rfind("finally") assert finally_idx != -1 and "saveBtn.disabled = false" in body[finally_idx:], ( "the button is re-enabled in the finally — never stale" ) # ---------- boot-load precedence ---------- def test_boot_load_precedence_saved_chat_over_local_restore() -> None: """Inside the boot IIFE: after fetchIsAdmin() + the reveal gate, restoreSavedChatFromUrl() runs; only when it returns false does the phase-14 local restore run. Header init stays first (shared-module contract).""" js = _js() boot_start = js.find("(async () => {") assert boot_start != -1, "the boot IIFE must exist" boot = js[boot_start:] init_i = boot.find("await initSharedHeader();") admin_i = boot.find("isAdmin = await fetchIsAdmin();") reveal_i = boot.find("saveBtn.hidden = !isAdmin") saved_i = boot.find("await restoreSavedChatFromUrl();") local_i = boot.find("restoreConversation();") assert -1 < init_i < admin_i < reveal_i < saved_i < local_i, ( "boot order: header init → whoami → Save reveal → ?chat= load → local fallback" ) assert "if (!openedSaved) restoreConversation();" in boot, ( "the local restore runs ONLY when the saved-chat load did not open" ) def test_boot_load_gates_valid_uuid_and_admin_only() -> None: """restoreSavedChatFromUrl: a VALID uuid + admin is the ONLY fetch path — invalid/absent ?chat= and anonymous short-circuit to false (no request: the gate would 403). On 200 the messages REPLACE the local conversation, render through the SAME renderStoredMessage loop (pixel-identical restore), link currentChatId, and mirror to localStorage. 404/network/malformed/ empty → banner + false (the local restore then runs).""" js = _js() body = _fn(js, "restoreSavedChatFromUrl") # The gates, in order: param present → valid uuid → admin. assert '.get("chat")' in body, "the ?chat= param" assert "UUID_RE.test(chatId)" in body, "a valid uuid only" assert "!isAdmin" in body, "admin only (no fetch for anonymous)" gate = body.find("!isAdmin") fetch_i = body.find('fetch(`/api/chats/${chatId}`)') assert -1 < gate < fetch_i, "the gates short-circuit BEFORE the fetch" assert "const UUID_RE" in js, "the uuid pattern is module-level" # On success: replace → render through the SAME loop → link → mirror. assert "conversation = messages" in body, "the saved messages REPLACE the local conversation" assert "renderStoredMessage(m)" in body, "the SAME renderStoredMessage path as local restore" assert "markLastRetryable()" in body, "parity with local restore: Retry on the last bubble" save_mir = body.find("saveConversation()") link_i = body.find("currentChatId = chatId") assert -1 < link_i < save_mir, "link first, then mirror to localStorage" # Failure: the exact banner line, then false (→ local restore). The # gate line returns false directly; the 404/network, malformed-body # and empty-payload paths all route through the banner helper. banner_line = 'showErrorBanner("That saved chat isn\'t available — it may have been deleted.")' assert banner_line in body assert "return false" in body, "invalid/absent param or anonymous → no fetch, local restore" assert body.count("return unavailable()") == 4, ( "network, non-ok (404/403/5xx), malformed body and empty payload all fall back" ) # The ?chat= param is a one-shot boot instruction: the success path # normalizes the URL back to / so a later refresh (or "New chat" + # refresh) restores the LOCAL session instead of re-opening the row. assert 'history.replaceState(null, "", "/")' in body, ( "a consumed ?chat= must not linger in the URL" ) # The defensive filter keeps a corrupted stored row from poisoning the # restore (same shape check as loadStoredConversation). assert 'm.who === "user" || m.who === "brain"' in body assert 'typeof m.text === "string"' in body # ---------- the reveal gate ---------- def test_save_button_revealed_only_for_admin() -> None: """The ship-hidden/reveal-for-admin contract: app.js queries #save-chat-btn, binds the click to saveCurrentChat, and the boot IIFE sets saveBtn.hidden = !isAdmin (phase 16 absent-not-hidden — hidden is display:none, no trace for anonymous).""" js = _js() assert 'document.querySelector("#save-chat-btn")' in js assert 'saveBtn?.addEventListener("click", saveCurrentChat)' in js assert "saveBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot" # The reveal happens in the boot IIFE (after whoami), not at module # evaluation (isAdmin is false there). boot_start = js.find("(async () => {") reveal = js.find("saveBtn.hidden = !isAdmin") assert boot_start < reveal, "the reveal must run at boot, after whoami resolves" def test_boot_load_adds_no_direct_storage_access() -> None: """The localStorage accesses stay EXACTLY the phase-14 three (loadStoredConversation / saveConversation / clearStoredConversation) — the saved-chat mirror goes through saveConversation(), so the house failure-safety pin (exactly 3, all try-wrapped) holds.""" 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)}" def test_no_cdn_added() -> None: """AGENTS.md rule 6: the Save button adds no external script/link.""" index = _index() assert 'src="http' not in index and 'href="http' not in index # ---------- the Share button on the chat page (phase 51, task 02) ---------- def test_share_button_ships_hidden_beside_save() -> None: """#share-chat-btn: a real type=button with the accessible name "Share chat", SHIPPED HIDDEN (app.js reveals it for admin only), BESIDE #save-chat-btn in .chat-shell inside
, above #messages — the chat-shell actions read as a pair (Save | Share). No other page carries it (chat-page only, like Save).""" html = _index() btn = re.search(r']*id="share-chat-btn"[^>]*>', html) assert btn, "index.html must contain #share-chat-btn" tag = btn.group(0) assert 'type="button"' in tag assert 'aria-label="Share chat"' in tag assert "hidden" in tag, "the button ships hidden (reveal is app.js's job)" # The label: the visible text is "Share" (the link SVG is aria-hidden # decoration; the aria-label carries the accessible name). btn_block = html[btn.start() : html.find("", btn.start())] assert '>Share' in btn_block # Beside Save: after it, still inside .chat-shell, above #messages. shell_idx = html.find('class="container chat-shell"') save_idx = html.find('id="save-chat-btn"') messages_idx = html.find('id="messages"') assert -1 < shell_idx < save_idx < btn.start() < messages_idx, ( "the button must sit beside #save-chat-btn in .chat-shell, above #messages" ) for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML, Path(FRONTEND / "history.html")): assert 'id="share-chat-btn"' not in other.read_text(encoding="utf-8"), ( f"{other.name}: the Share button is chat-page only" ) def test_share_button_css_is_the_exact_save_family() -> None: """styles.css: .share-chat-btn carries the EXACT visual family of .save-chat-btn (same solid brand pill — --bg on --brand = 5.2:1, AA; borderless; 999px radius; ≥44px target; hover lightens the brand fill); the ≤640px block mirrors the Save overrides (label stays visible in .chat-shell, icon hidden there; icon-only elsewhere).""" css = _css() block = re.search(r"\.share-chat-btn \{([\s\S]*?)\n\}", css) assert block, "styles.css must style .share-chat-btn" body = block.group(1) assert "min-height: 44px" in body assert "border-radius: 999px" in body assert "border: 0" in body assert "background: var(--brand)" in body, "same solid brand fill as Save" assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)" hover = re.search(r"\.share-chat-btn:hover \{([\s\S]*?)\n\}", css) assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill" svg = re.search(r"\.share-chat-btn svg \{([\s\S]*?)\n\}", css) assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like Save)" mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css) assert mobile, "mobile media query missing" mbody = mobile.group(1) assert ".share-chat-btn { padding: 0.4rem 0.3rem; }" in mbody, ("squeezes with Save") assert ".share-chat-label { display: none; }" in mbody assert ".share-chat-btn svg { display: block; }" in mbody assert ".chat-shell .share-chat-label { display: inline; }" in mbody, ( "in .chat-shell the label stays visible, as for Save" ) assert ".chat-shell .share-chat-btn svg { display: none; }" in mbody def test_share_current_chat_save_then_share_branch() -> None: """shareCurrentChat: the same empty-conversation no-op guard as Save (live region, no request). The save-then-share branch: linked (currentChatId set) → POST /api/chats//share (the idempotent token); unlinked → POST /api/chats with { messages: conversation, share: true } and link currentChatId to the created id — one action saves AND shares (owner-locked). Success: the ABSOLUTE URL is copied — the clipboard try succeeds → the live region reads "Share link copied."; the rejection (a non-secure http origin) renders the .share-link-fallback field + "Share link ready — copy it from the field." 403/5xx → the actionable banner (signed-out hint); network → the reachable? banner. The double-click guard releases in the finally — never stale.""" js = _js() body = _fn(js, "shareCurrentChat") # No-op first: nothing to share → live-region line, no fetch. noop = body.find('sendStatus.textContent = "Nothing to share yet."') first_fetch = body.find("await fetch(") assert 0 < noop < first_fetch, "the empty-conversation no-op precedes any fetch" assert "if (!conversation.length)" in body # The branch: POST share when linked, create-with-share when not. assert "if (currentChatId)" in body linked = '`/api/chats/${currentChatId}/share`' share_fetch = body.find(linked) assert share_fetch != -1, "the linked branch POSTs the idempotent share" assert 'fetch("/api/chats", {' in body, "the unlinked branch POSTs /api/chats" assert 'JSON.stringify({ messages: conversation, share: true })' in body, ( "the create-with-share payload — the server sets the token in the same commit" ) post_idx = body.find('fetch("/api/chats", {') assert -1 < share_fetch < post_idx, "the linked branch precedes the unlinked fallback" created_idx = body.find("currentChatId = String(created.id)", post_idx) assert created_idx != -1, "one action saved AND shared: the conversation links to the row" # The copy: the ABSOLUTE URL (share_url resolved against the page # origin) + the two live-region outcomes (success / the owner-locked # inline-field fallback). abs_fn = _fn(js, "absoluteShareUrl") assert "new URL(shareUrl, window.location.origin).toString()" in abs_fn, ( "the ABSOLUTE URL is what gets copied (the origin supplies scheme/host)" ) assert "copyShareLinkWithFallback(absoluteShareUrl(shareUrl))" in body assert ( 'sendStatus.textContent = copied\n' ' ? "Share link copied."\n' ' : "Share link ready — copy it from the field."' ) in body # Failures raise an actionable banner (non-ok HTTP + network). assert 'showErrorBanner("Couldn\'t share the conversation — is the app reachable?")' in body assert "check you're still signed in and try again" in body, "403/5xx: actionable line" assert body.count("check you're still signed in and try again") == 2, ( "both the linked and the unlinked branch carry the non-ok banner" ) # The double-click guard releases on EVERY outcome. finally_idx = body.rfind("finally") assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], ( "the button is re-enabled in the finally — never stale" ) # The clipboard + fallback helpers live in app.js (the chat page's # copy of the per-page helper). copy = _fn(js, "copyShareLinkWithFallback") assert "navigator.clipboard.writeText(absoluteUrl)" in copy assert 'field.className = "share-link-fallback"' in copy assert "field.href = absoluteUrl" in copy assert "field.textContent = absoluteUrl" in copy, "XSS contract: textContent only" assert 'field.addEventListener("focus", () => selectAllInField(field))' in copy, ( "select-on-focus — the field-like behavior" ) assert "composer.appendChild(field)" in copy, "near the status line (the composer)" sel = _fn(js, "selectAllInField") assert "document.createRange()" in sel and "selectNodeContents(el)" in sel def test_share_button_revealed_only_for_admin() -> None: """The ship-hidden/reveal-for-admin contract: app.js queries #share-chat-btn, binds the click to shareCurrentChat, and the boot IIFE sets shareBtn.hidden = !isAdmin in the SAME admin-reveal block as Save (phase 16 absent-not-hidden — no trace for anonymous).""" js = _js() assert 'document.querySelector("#share-chat-btn")' in js assert 'shareBtn?.addEventListener("click", shareCurrentChat)' in js assert "shareBtn.hidden = !isAdmin" in js, "revealed for admin only, at boot" # The reveal happens in the boot IIFE (after whoami), not at module # evaluation — and right next to Save's own reveal line. boot_start = js.find("(async () => {") reveal = js.find("shareBtn.hidden = !isAdmin") save_reveal = js.find("saveBtn.hidden = !isAdmin") assert boot_start < save_reveal < reveal, ( "the Share reveal joins the same admin-reveal block as Save" ) # ---------- stale saved chat: banner + Regenerate (phase 53, task 05) ---------- def test_stale_banner_html_after_kb_banner() -> None: """index.html: the #stale-banner section sits DIRECTLY AFTER #kb-banner (the chat-shell top-of-column position — kb-banner keeps the top slot when both are visible), role="status", shipped hidden, with the exact text and the #stale-regenerate button (type=button, visible label "Regenerate", the redo glyph — the SAME SVG paths as RETRY_ICON in app.js, the phase-49 Retry asset). No other page carries it (chat-page only).""" html = _index() kb_idx = html.find('id="kb-banner"') banner = re.search(r']*id="stale-banner"[^>]*>', html) assert banner, "index.html must contain the #stale-banner section" tag = banner.group(0) assert 'role="status"' in tag assert "hidden" in tag, "the banner ships hidden (the reveal is app.js's job)" assert -1 < kb_idx < banner.start(), "the banner sits directly after #kb-banner" # Nothing between the kb-banner close and the stale banner except # whitespace + the phase-53 comment: the top-of-column pair is kept. between = html[html.find("", kb_idx) : banner.start()] assert "id=" not in between, "no other element lands between the two banners" block = html[banner.start() : html.find("", banner.start())] assert "The sources have been updated since this chat was saved." in block btn = re.search(r']*id="stale-regenerate"[^>]*>', block) assert btn, "the banner carries the #stale-regenerate button" assert 'type="button"' in btn.group(0) btn_block = block[btn.start() : block.find("", btn.start())] assert ">Regenerate" in btn_block, "the visible label is Regenerate" # The redo glyph: the SAME paths as RETRY_ICON (the phase-49 asset). assert 'd="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"' in btn_block assert 'd="M21 3v5h-5"' in btn_block # The banner's own leading mark is the redo glyph too (distinct from # the kb-banner warning triangle) — aria-hidden decoration. lead = block[: btn.start()] assert 'aria-hidden="true"' in lead and 'd="M21 3v5h-5"' in lead for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML, TUNING_HTML, Path(FRONTEND / "history.html")): assert 'id="stale-banner"' not in other.read_text(encoding="utf-8"), ( f"{other.name}: the stale banner is chat-page only" ) def test_boot_load_reveals_stale_banner_on_payload_stale() -> None: """restoreSavedChatFromUrl: on a 200 payload with stale: true, the #stale-banner is revealed (hidden removed) — the flag is server-computed (task 03), the client never does staleness math. The reveal rides the boot SUCCESS path (after the link + the localStorage mirror), so a non-stale payload leaves the banner hidden.""" js = _js() body = _fn(js, "restoreSavedChatFromUrl") assert "data.stale === true" in body, "the reveal branches on the payload's stale flag" link_i = body.find("currentChatId = chatId") reveal_i = body.find("staleBanner.hidden = false") assert -1 < link_i < reveal_i, "the reveal runs on the success path, after the link" assert "staleBanner.hidden = false" in body, "reveal = remove `hidden`" def test_boot_load_stale_reveal_no_brain_record_guard() -> None: """The no-brain-record guard: a stale conversation with NO brain record (user-only) is revealed TEXT-ONLY — the #stale-regenerate button is removed BEFORE the reveal (retryLastTurn is never called in that state).""" js = _js() body = _fn(js, "restoreSavedChatFromUrl") guard_i = body.find('!conversation.some((m) => m.who === "brain")') remove_i = body.find("staleRegenBtn.remove()") reveal_i = body.find("staleBanner.hidden = false") assert -1 < guard_i < remove_i < reveal_i, ( "the no-brain check removes the button before the banner is revealed" ) def test_retry_last_turn_returns_the_turn_promise() -> None: """retryLastTurn RETURNS the runTurn promise (phase 53 task 05): the Regenerate path awaits the turn's completion to know when to persist. The phase-49 Retry click handler ignores the return value — behavior-neutral for it (the redo-order pins in test_frontend_feedback.py keep holding unchanged).""" js = _js() body = _fn(js, "retryLastTurn") assert "return runTurn(text, { reask: true })" in body, ( "the redo promise is returned for the Regenerate await" ) assert "void runTurn" not in body, "the fire-and-forget void is gone" # The existing Retry click handler still ignores the return value. append = _fn(js, "appendRetryButton") assert "retryLastTurn(wrap)" in append, "the Retry click is unchanged (no await)" def test_stale_regenerate_drives_retry_last_turn_and_awaits() -> None: """regenerateStaleChat: drives retryLastTurn on the LAST brain bubble's rendered wrap (phase-49 targeting — retryLastTurn's own `wrap !== lastBrainWrap` guard makes a stale click a no-op that resolves nothing), AWAITs the returned turn promise, and persists only when the turn completed WITHOUT the error banner (a mid-stream error leaves the linked row untouched — stale stays true). The double-click guard releases in the finally — never stale (PLAN §7.4).""" js = _js() body = _fn(js, "regenerateStaleChat") assert "staleRegenBtn.disabled = true" in body, "one regenerate at a time" call_i = body.find("retryLastTurn(lastBrainWrap)") await_i = body.find("await turn") assert -1 < call_i < await_i, "call the redo on the last brain wrap, then await it" err_i = body.find('banner.classList.contains("is-error")') put_i = body.find('`/api/chats/${currentChatId}`') assert -1 < await_i < err_i < put_i, ( "the error-banner check sits between the await and the persist" ) finally_idx = body.rfind("finally") assert finally_idx != -1 and "staleRegenBtn.disabled = false" in body[finally_idx:], ( "the button is re-enabled in the finally — never stale" ) def test_stale_regenerate_persists_the_linked_row() -> None: """The post-regenerate persist (the existing upsert path): linked → PUT /api/chats/ (the server re-stamps sources_version → the row is fresh); a 404 (the row was deleted from History meanwhile) follows saveCurrentChat's stale-link rule — unlink + recreate (POST), and the recreate links the new id. Success hides the banner AND announces the outcome in the #send-status live region; 403/5xx → the actionable error banner (the row stays as the turn left it); network → the reachable? banner.""" js = _js() body = _fn(js, "regenerateStaleChat") assert "if (currentChatId)" in body assert 'method: "PUT"' in body assert 'fetch("/api/chats"' in body and 'method: "POST"' in body put_idx = body.find('method: "PUT"') post_idx = body.find('method: "POST"') assert -1 < put_idx < post_idx, "the PUT (linked) branch precedes the POST fallback" assert '{ messages: conversation }' in body, "the messages payload — no title (keep current)" # The 404→recreate fallback: unlink, then POST again. notfound_idx = body.find("res.status === 404") assert notfound_idx != -1, "the PUT 404 must be handled" fallback = body[notfound_idx:post_idx] assert "currentChatId = null" in fallback, "the stale link is dropped" # The recreate links the new row. assert "res.status === 201" in body assert "currentChatId = String(created.id)" in body # Success: hide the banner, then announce in the live region. hide_i = body.find("staleBanner.hidden = true") ann_i = body.find('sendStatus.textContent = "Regenerated') assert -1 < hide_i < ann_i, "hide the banner, then announce the outcome" assert "the answer now reflects the current sources." in body, ("the live-region line") # Failures raise an actionable banner (non-ok HTTP + network). assert ( 'showErrorBanner("Couldn\'t save the regenerated answer — is the app reachable?")' in body ) assert "check you're still signed in and try again" in body, "403/5xx: actionable line" def test_stale_regenerate_binding_and_element_queries() -> None: """The wiring: app.js queries #stale-banner + #stale-regenerate at module scope and binds the click to regenerateStaleChat. The banner only ever shows on the /?chat= boot path (admin), so the binding is inert otherwise.""" js = _js() assert 'document.querySelector("#stale-banner")' in js assert 'document.querySelector("#stale-regenerate")' in js assert 'staleRegenBtn?.addEventListener("click", regenerateStaleChat)' in js def test_stale_banner_cleared_on_new_chat_and_resave() -> None: """Never-stale (PLAN §7.4): "New chat" replaces the conversation the banner described (and unlinks it) — the banner hides; a successful manual re-Save re-stamps the row to the current generation (task 03) — the banner is done the moment the save succeeds.""" js = _js() new_body = _fn(js, "startNewChat") assert "staleBanner.hidden = true" in new_body, "New chat hides the banner" save_body = _fn(js, "saveCurrentChat") saved_line = 'sendStatus.textContent = "Conversation saved."' after = save_body[save_body.find(saved_line):] assert "staleBanner.hidden = true" in after, ( "a successful re-save re-stamps the row — the banner is done" ) def test_stale_banner_css_is_the_kb_banner_family() -> None: """styles.css: the banner rides the .kb-banner family (the section carries BOTH classes — the flex row + accent tokens come from .kb-banner; .stale-banner adds the wrap so the pill can drop below the text when the row must wrap), and the .stale-regenerate pill is the EXACT brand-pill family of Save/Share (solid --brand, --bg text 5.2:1 AA, borderless, 999px, ≥44px, hover lightens the fill, 16px redo glyph). The ≤640px block makes the pill a full-width row.""" html = _index() tag = re.search(r']*id="stale-banner"[^>]*>', html) assert tag and "kb-banner" in tag.group(0) and "stale-banner" in tag.group(0), ( "the section carries both classes — the family comes from .kb-banner" ) css = _css() block = re.search(r"\.stale-regenerate \{([\s\S]*?)\n\}", css) assert block, "styles.css must style .stale-regenerate" body = block.group(1) for prop in ( "display: inline-flex", "min-height: 44px", "margin-left: auto", "border-radius: 999px", "border: 0", "background: var(--brand)", "color: var(--bg)", "font-weight: 700", "cursor: pointer", ): assert prop in body, f".stale-regenerate must keep the Save/Share family ({prop})" hover = re.search(r"\.stale-regenerate:hover \{([\s\S]*?)\n\}", css) assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill" assert re.search(r"\.stale-regenerate svg \{ width: 16px; height: 16px", css), ( "the redo glyph rides the 16px pill size" ) mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css) assert mobile, "mobile media query missing" assert ".stale-regenerate { margin-left: 0; width: 100%; }" in mobile.group(1), ( "at phone width the pill takes a full-width row" )