feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare
This commit is contained in:
@@ -291,3 +291,159 @@ 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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user