450 lines
22 KiB
Python
450 lines
22 KiB
Python
"""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 <name>(...)`` (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 <main>, 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'<button[^>]*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/<id> 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 <main>, 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'<button[^>]*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("</button>", btn.start())]
|
|
assert '>Share</span>' 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/<id>/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"
|
|
)
|