feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare

This commit is contained in:
2026-08-30 01:34:44 -04:00
parent ece93a7c8f
commit 114b115034
28 changed files with 3442 additions and 54 deletions
+66
View File
@@ -17,6 +17,7 @@ import asyncio
import os
import re
import subprocess
import uuid
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
@@ -205,6 +206,7 @@ def test_html_pages_include_history() -> None:
"/tuning.html",
"/git-sources.html",
"/history.html",
"/shared.html", # phase 51: the shared page's static path
):
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
@@ -386,3 +388,67 @@ def test_read_body_drains_streaming_response() -> None:
resp = StreamingResponse(content=gen(), media_type="text/html")
assert asyncio.run(caching._read_body(resp)) == b"<a></a>"
# ---------------------------------------------------------------------------
# Phase 51: the dynamic share page — the ``/shared/`` prefix contract
# ---------------------------------------------------------------------------
def _shared_page_app() -> FastAPI:
"""A bare app with the phase-51 route pair + the middleware:
``/shared/<token>`` (``text/html`` — the page the route serves) and
``/api/shared/<token>`` (the JSON read), plus an unknown path."""
app = FastAPI()
@app.get("/shared/{token}", response_class=HTMLResponse)
def shared_page(token: str) -> str:
return (
"<html><head>"
'<link rel="stylesheet" href="/assets/styles.css">'
"</head><body>shared</body></html>"
)
@app.get("/api/shared/{token}")
def shared_api(token: str) -> JSONResponse:
return JSONResponse({"title": "Shared", "messages": []})
@app.get("/some/unknown/path")
def unknown() -> JSONResponse:
return JSONResponse({"ok": True})
caching.configure_caching(app)
return app
def test_middleware_treats_shared_page_path_as_known_html_page() -> None:
"""Phase 51: ``/shared/<uuid>`` joins the HTML_PAGES contract —
``no-cache`` + ``?v=`` asset rewrite on the ``text/html`` body (the
FileResponse body is drained by the existing ``_read_body`` path)."""
client = TestClient(_shared_page_app())
r = client.get(f"/shared/{uuid.uuid4()}")
assert r.status_code == 200
assert r.headers["cache-control"] == "no-cache"
token = caching.asset_version()
assert f'href="/assets/styles.css?v={token}"' in r.text
assert 'href="/assets/styles.css">' not in r.text
def test_middleware_leaves_api_shared_read_untouched() -> None:
"""``/api/shared/<uuid>`` — the JSON read — starts with ``/api/``,
not ``/shared/``: byte-identical pass-through, no injected headers."""
client = TestClient(_shared_page_app())
r = client.get(f"/api/shared/{uuid.uuid4()}")
assert r.status_code == 200
assert "cache-control" not in r.headers
assert r.json() == {"title": "Shared", "messages": []}
def test_middleware_leaves_unknown_paths_untouched() -> None:
"""A path that is neither a known page, ``/shared/*``, nor
``/assets/*`` passes through byte-identical, no headers."""
client = TestClient(_shared_page_app())
r = client.get("/some/unknown/path")
assert r.status_code == 200
assert "cache-control" not in r.headers
assert r.json() == {"ok": True}
+7 -2
View File
@@ -29,6 +29,7 @@ HTML_PAGES = (
"login.html",
"git-sources.html",
"history.html", # phase 50: the admin saved-chats page
"shared.html", # phase 51: the anonymous shared-conversation page
)
@@ -138,9 +139,13 @@ def test_every_page_loads_brand_js_before_its_module_script() -> None:
default must be set first)."""
for page in HTML_PAGES:
html = _text(FRONTEND / page)
brand_idx = html.find('<script src="assets/brand.js"></script>')
# Optional leading slash: root-level pages use "assets/brand.js",
# but the shared page is served from the NESTED /shared/<token>
# route, where a relative ref would 404 — it uses "/assets/…".
m = re.search(r'<script src="(?:/)?assets/brand\.js"></script>', html)
assert m, f"{page}: brand.js must load"
brand_idx = m.start()
module_idx = html.find('<script type="module"')
assert brand_idx != -1, f"{page}: brand.js must load"
assert module_idx != -1, f"{page}: the page module must load"
assert brand_idx < module_idx, (
f"{page}: brand.js must load BEFORE the module script"
+7 -2
View File
@@ -42,8 +42,11 @@ FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
STYLES_CSS = ASSETS / "styles.css"
#: The six pages of the app (phase 46: the shared bar contract extends to
#: the phase-35 git-sources page — the hamburger is part of that bar).
#: The app's pages (phase 46: the shared bar contract extends to the
#: phase-35 git-sources page — the hamburger is part of that bar; the
#: phase-50 History page and the phase-51 shared page carry the same
#: bar — the full seven-page set). The two pages added after phase 46
#: keep the identical header block, so they pin here too.
PAGES = (
FRONTEND / "index.html",
FRONTEND / "sources.html",
@@ -51,6 +54,8 @@ PAGES = (
FRONTEND / "git-sources.html",
FRONTEND / "login.html",
FRONTEND / "tuning.html",
FRONTEND / "history.html",
FRONTEND / "shared.html",
)
NAV_TAG = '<nav class="app-nav" id="app-nav" aria-label="Primary">'
+199 -12
View File
@@ -39,11 +39,13 @@ TUNING_HTML = FRONTEND / "tuning.html"
DOCUMENT_HTML = FRONTEND / "document.html"
LOGIN_HTML = FRONTEND / "login.html"
HISTORY_HTML = FRONTEND / "history.html"
SHARED_HTML = FRONTEND / "shared.html" # phase 51: the anonymous shared page
HISTORY_JS = ASSETS / "history.js"
HEADER_JS = ASSETS / "header.js"
STYLES_CSS = ASSETS / "styles.css"
#: The phase-34 one-bar contract + the new History page: SEVEN pages.
#: The phase-34 one-bar contract + the History page + the shared
#: page: EIGHT pages.
ALL_PAGES = (
INDEX_HTML,
SOURCES_HTML,
@@ -52,6 +54,7 @@ ALL_PAGES = (
DOCUMENT_HTML,
LOGIN_HTML,
HISTORY_HTML,
SHARED_HTML,
)
@@ -110,16 +113,17 @@ def test_nav_history_present_on_all_seven_pages() -> None:
)
def test_nav_history_count_is_exactly_seven_pages() -> None:
def test_nav_history_count_is_exactly_eight_pages() -> None:
"""The pin counting occurrences across ``frontend/*.html`` — exactly
one ``id="nav-history"`` per page, seven pages, no duplicates and no
extra page that forgot (or added twice)."""
one ``id="nav-history"`` per page, eight pages (phase 51: + the
shared page), no duplicates and no extra page that forgot (or
added twice)."""
total = 0
for html in sorted(FRONTEND.glob("*.html")):
count = html.read_text(encoding="utf-8").count('id="nav-history"')
assert count in (0, 1), f"{html.name}: #nav-history appears {count} times"
total += count
assert total == 7, f"expected #nav-history on 7 pages, found {total}"
assert total == 8, f"expected #nav-history on 8 pages, found {total}"
def test_header_js_reveals_nav_history_for_admin() -> None:
@@ -173,27 +177,35 @@ def test_history_page_scaffold_and_landmarks() -> None:
def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the four columns —
Title | Messages | Updated | Actions (the Actions header text is
visually-hidden — the row buttons carry their own aria-labels) —
and the empty-state row (ship-hidden, the exact copy)."""
"""The table skeleton: ``.history-table`` with the five columns —
Title | Messages | Updated | Share (phase 51) | Actions (the
Actions header text is visually-hidden — the row buttons carry
their own aria-labels) — and the empty-state row (ship-hidden, the
exact copy)."""
html = _text(HISTORY_HTML)
assert '<table class="history-table">' in html
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
'<th scope="col">Updated</th>'):
'<th scope="col">Updated</th>', '<th scope="col">Share</th>'):
assert col in html
# The Share column sits BETWEEN Updated and Actions.
assert (
html.find('<th scope="col">Updated</th>')
< html.find('<th scope="col">Share</th>')
< html.find('visually-hidden">Actions')
), "the Share column must sit between Updated and Actions"
actions_th = re.search(
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
html,
)
assert actions_th, "the Actions column header must be visually-hidden text"
assert actions_th.group(1) == "", "no visible text beside the hidden header"
# The empty-state row: ship-hidden, colspan 4, the exact copy.
# The empty-state row: ship-hidden, colspan 5 (the Share column
# joined the table in phase 51), the exact copy.
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
assert row, "the empty-state row must ship in the skeleton"
assert "hidden" in row.group(0)
assert 'id="history-empty-row"' in row.group(0)
assert "<td colspan=\"4\">" in html
assert "<td colspan=\"5\">" in html
assert (
"No saved chats yet — finish a conversation and press"
" <strong>Save</strong> in the chat."
@@ -426,3 +438,178 @@ def test_history_table_mobile_behavior() -> None:
mbody = mobile.group(1)
assert ".history-actions-cell { white-space: normal; }" in mbody
assert ".history-actions { flex-wrap: wrap; }" in mbody
# ---------- the Share column (phase 51, owner-locked 2026-08-29) ----------
def test_make_row_inserts_share_cell_between_updated_and_actions() -> None:
"""makeRow: the Share <td> (with the share control) lands BETWEEN
the Updated cell and the Actions cell — the column order in
history.html is Title | Messages | Updated | Share | Actions."""
js = _js()
row = _fn(js, "makeRow")
updated_i = row.find('updatedTd.className = "history-updated-cell"')
share_i = row.find('shareTd.className = "history-share-cell"')
actions_i = row.find('actionsTd.className = "history-actions-cell"')
assert -1 < updated_i < share_i < actions_i, (
"the share cell must sit between Updated and Actions"
)
assert "makeShareControl(chat)" in row
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
assert seq == ["titleTd", "countTd", "updatedTd", "shareTd", "actionsTd"], (
f"row cell order must be title/count/updated/share/actions, got {seq}"
)
def test_share_control_three_states_and_two_step_unshare() -> None:
"""The share cell's THREE states — unshared → [Create link];
shared → [Copy] [Unshare]; confirming → "Unshare? [Yes] [No]" —
plus the inline two-step unshare (the phase-50 Delete-confirm
pattern: focus moves to Yes, No restores the shared state, no
native dialog). The shipped state comes from the row's share_url
(the list endpoint populates it — no second fetch)."""
js = _js()
assert "window.confirm" not in js, "history.js must use the inline two-step only"
# makeShareControl: the shipped state branches on chat.share_url.
make = _fn(js, "makeShareControl")
assert "cell.className = \"history-share\"" in make
assert "chat.share_url" in make
assert "renderShareShared(chat, cell)" in make
assert "renderShareUnshared(chat, cell)" in make
# Unshared state: the Create link button (labeled, textContent).
unshared = _fn(js, "renderShareUnshared")
assert 'create.className = "history-share-create"' in unshared
assert 'create.textContent = "Create link"' in unshared
assert 'create.setAttribute("aria-label", `Create share link: ${chat.title}`)' in unshared
assert "innerHTML" not in unshared, "XSS contract: textContent only"
# Shared state: Copy + Unshare, then the two-step confirm.
shared = _fn(js, "renderShareShared")
assert 'copy.className = "history-share-copy"' in shared
assert 'copy.textContent = "Copy"' in shared
assert 'unshare.className = "history-unshare"' in shared
assert 'unshare.textContent = "Unshare"' in shared
assert 'label.textContent = "Unshare?"' in shared
assert 'yes.className = "history-confirm-yes"' in shared, (
"the unshare two-step reuses the phase-50 .history-confirm-* pair"
)
assert 'no.className = "history-confirm-no"' in shared
assert "cell.replaceChildren(label, yes, no)" in shared
swap_i = shared.find("cell.replaceChildren(label, yes, no)")
assert shared.find("yes.focus(", swap_i) > 0, "focus moves to Yes after the swap"
assert 'no.addEventListener("click", restoreShared)' in shared
restore_i = shared.find("function restoreShared")
assert restore_i != -1
assert "cell.replaceChildren(copy, unshare)" in shared[restore_i:restore_i + 120], (
"No (and a failed request) restore the shared state"
)
def test_share_create_and_unshare_request_outcomes() -> None:
"""createShareLink: POST /api/chats/<id>/share → the response's
share_url becomes the row's data, the cell re-renders shared, and
the ABSOLUTE link is offered for copying (clipboard → fallback);
non-2xx / network keep the unshared state (retryable) + the error
line. confirmUnshare: POST /api/chats/<id>/unshare → the cell
re-renders unshared + `Unshared "<title>".`; non-2xx / network
restore the shared state + the error line. Both double-fire
guarded."""
js = _js()
create = _fn(js, "createShareLink")
assert "createBtn.disabled = true" in create
assert 'fetch(`/api/chats/${chat.id}/share`, { method: "POST" })' in create
assert "renderShareShared(chat, cell)" in create, "success re-renders the shared state"
assert "chat.share_url = share_url" in create, "the row's data gains the link"
assert "new URL(share_url, window.location.origin).toString()" in create, (
"the ABSOLUTE link is what gets copied (the origin supplies scheme/host)"
)
assert (
'announce(copied ? "Share link copied."'
' : "Share link ready — copy it from the field.")'
) in create
assert "is the app reachable?" in create, "the network-error line"
assert "try again" in create, "the non-2xx line"
# A failed request keeps the button (re-enabled) — retryable.
assert create.count("createBtn.disabled = false") == 2, (
"both failure paths re-enable the Create link button"
)
unshare = _fn(js, "confirmUnshare")
assert "yesBtn.disabled = true" in unshare
assert 'fetch(`/api/chats/${chat.id}/unshare`, { method: "POST" })' in unshare
assert "chat.share_url = null" in unshare, "a revoked link drops the row's share_url"
assert "renderShareUnshared(chat, cell)" in unshare, "success re-renders the unshared state"
assert 'announce(`Unshared "${chat.title}".`)' in unshare
assert unshare.count("restoreShared()") == 2, (
"non-2xx and network both restore the shared state (retryable)"
)
assert "is the app reachable?" in unshare
assert "try again" in unshare
def test_share_copy_uses_own_per_page_clipboard_helper_with_fallback() -> None:
"""The per-page duplication house style: history.js keeps its OWN
~10-line copy of the clipboard + inline-link fallback helper (no
import from app.js, no new shared module). A non-secure (http)
origin rejects navigator.clipboard → a transient .share-link-fallback
<a> field lands in the row's share cell (selects its full URL on
focus — the range-based selectAllInField), one field at a time."""
js = _js()
import_lines = [line for line in js.splitlines() if line.strip().startswith("import")]
assert all("app.js" not in line for line in import_lines), (
"no cross-page import — the helper is duplicated per page"
)
copy = _fn(js, "copyShareLink")
assert "navigator.clipboard.writeText(absoluteUrl)" in copy, "the clipboard try"
assert 'cell.querySelectorAll(".share-link-fallback").forEach((el) => el.remove())' in copy, (
"one field at a time — a new offer replaces the old"
)
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
assert "field.focus({ preventScroll: true })" in copy, "selects the URL on focus"
sel = _fn(js, "selectAllInField")
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
# Copy (shared state) goes through the same helper.
rowcopy = _fn(js, "copyRowShareLink")
assert "copyShareLink(" in rowcopy
assert (
'announce(copied ? "Share link copied."'
' : "Share link ready — copy it from the field.")'
) in rowcopy
def test_share_column_css() -> None:
"""styles.css: the Share cell's ghost buttons (the Tune/Retry family
— transparent, --line border, ink-soft, ≥44px) + Unshare's
error-rose hover (it revokes — the Delete language) + the inline
fallback field (input-like: mono, surface fill, --line border,
ellipsis, 3px focus-visible). The unshare two-step reuses the
.history-confirm-* pair CSS (no new confirm styles)."""
css = _css()
for cls in (".history-share-create", ".history-share-copy", ".history-unshare"):
assert re.search(re.escape(cls), css), f"styles.css must style {cls}"
btn = re.search(
r"\.history-share-create,\n\.history-share-copy,\n\.history-unshare \{([\s\S]*?)\n\}",
css,
)
assert btn, "the share buttons share one ghost-button block"
body = btn.group(1)
assert "min-height: 44px" in body, "≥44px comfortable target"
assert "border: 1px solid var(--line)" in body
assert "background: transparent" in body
assert "var(--ink-soft)" in body
assert (
".history-unshare:hover:not(:disabled) { background: var(--err-bg);"
" color: var(--err-ink); border-color: var(--err-line); }"
) in css, "Unshare hovers the error rose (it revokes the link)"
field = re.search(r"\.share-link-fallback \{([\s\S]*?)\n\}", css)
assert field, "the inline fallback field must be styled"
fbody = field.group(1)
assert "var(--mono)" in fbody, "input-like: mono (the URL is data)"
assert "background: var(--surface)" in fbody
assert "border: 1px solid var(--line)" in fbody
assert "text-overflow: ellipsis" in fbody
assert re.search(r"\.share-link-fallback:focus-visible \{[^}]*outline[^}]*3px", css), (
"the fallback field keeps a 3px :focus-visible outline"
)
+156
View File
@@ -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"
)
+10 -2
View File
@@ -424,7 +424,12 @@ def test_init_shared_header_rewrites_sign_in_next_to_current_page() -> None:
"""Phase 34 task 02: initSharedHeader points #sign-in-link at
/login.html?next=<current pathname> (default "/") — the admin lands
back on the page they signed in from. The page markup keeps its own
static ?next= as the no-JS fallback."""
static ?next= as the no-JS fallback.
Phase 51 (owner-locked 2026-08-29, TODO.md L6): the ONE exception —
the NESTED /shared/<token> page rewrites to the APP ROOT ("/")
instead of the shared URL: a guest signing in from a shared page
returns to the app root, not to a public link."""
js = _text(HEADER_JS)
fn = js.find("function initSharedHeader")
assert fn != -1
@@ -432,7 +437,10 @@ def test_init_shared_header_rewrites_sign_in_next_to_current_page() -> None:
# raw pathname (always a query-safe "/…" string — never "//", and ?
# # / spaces stay percent-encoded inside it; login.js safeNext
# re-validates), same shape as the static markup fallbacks
assert '"/login.html?next=" + (window.location.pathname || "/")' in body
assert 'const nextPath = window.location.pathname || "/";' in body
assert 'link.href = "/login.html?next=" + signInNext;' in body
# the shared-page exception: /shared/<token> → the app root
assert 'nextPath.startsWith("/shared/") ? "/" : nextPath' in body
# ---------- phase 34 task 01: the steering controls move to the module ----------
+449
View File
@@ -0,0 +1,449 @@
"""Unit: the phase-51 task-03 shared page contract (anonymous,
read-only, zero controls).
The browser behavior itself is E2E-gated by the story suite (task 04);
like the other frontend-adjacent unit files, this module pins the
JS/CSS/HTML markers the shared-conversation contract depends on, so a
silent regression is caught without a browser:
* the page scaffold (the identical shared header — every admin-only
link ships hidden, the auth pair's static fallback is ``?next=/`` — a
guest signing in from a shared page returns to the app root — the
steering panel, the h1 fallback, the read-only note, the invalid
state, the messages section);
* the token parse (a malformed path → the invalid state, NO fetch of
any kind);
* the 404 / network / malformed read → the invalid state (no data
render, no banner);
* ZERO interactive controls: ``renderSharedMessage`` never calls the
chat page's interactive builders, and the rendered messages contain
no button/form/link — the chips are plain spans, the source chips
carry no ``href``;
* the shared shell's 46rem column mapping + the static-chip and
invalid-state CSS (the ≤640px squeeze included).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
SHARED_HTML = FRONTEND / "shared.html"
SHARED_JS = ASSETS / "shared.js"
STYLES_CSS = ASSETS / "styles.css"
def _js() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _html() -> str:
return SHARED_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 shared.js"
return js[start : js.find("\n}\n", start) + 4]
# ---------- shared.html: the page scaffold ----------
def test_shared_html_scaffold_and_landmarks() -> None:
"""The standard page scaffold (AGENTS.md rule 5): skip link, the
shared header, the steering panel + announcer (phase 34 — ships on
every page), the h1 with its static fallback, the read-only note,
the invalid state (ship-hidden), the messages section, and the
footer with the version span (the index.html shape)."""
html = _html()
assert '<a class="skip-link" href="#main">' in html
assert 'class="app-header"' in html
assert '<nav class="app-nav" id="app-nav" aria-label="Primary">' in html
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
# The steering panel (hidden) + its announcer, first children of <main>.
tag = re.search(r'<section[^>]*id="steering-panel"[^>]*>', html)
assert tag and "hidden" in tag.group(0), "the steering panel ships hidden"
assert 'id="steering-list"' in html
assert 'id="steering-empty"' in html
assert re.search(r'<p[^>]*id="steering-announcer"[^>]*role="status"[^>]*>', html)
assert html.find('id="steering-panel"') < html.find('id="steering-announcer"')
# The title: JS-filled, with the static fallback text.
assert "<h1 id=\"shared-title\">Shared conversation</h1>" in html
# The read-only note (brand resolves through window.BOR_BRAND at
# call time in shared.js; the static copy is the default name).
assert "<p class=\"shared-note\">Shared via Brain of Reese — read-only.</p>" in html
# The invalid / revoked state: ship-hidden, the exact copy.
invalid = re.search(r'<div[^>]*id="shared-invalid"[^>]*>', html)
assert invalid and "hidden" in invalid.group(0), (
"#shared-invalid must ship hidden"
)
assert (
"<div id=\"shared-invalid\" hidden>"
"This share link is invalid or was revoked.</div>"
) in html
# The messages section: the SAME structure container as the chat page.
assert (
'<section class="messages" id="messages" aria-label="Shared conversation">'
in html
)
# Footer with the version span.
assert '<span class="footer-version" id="app-version"></span>' in html
def test_shared_html_identical_header_guest_safe() -> None:
"""The IDENTICAL shared header (the phase-34 one-bar contract):
every admin-only nav link SHIPS hidden (a guest never sees one for
a frame), no nav link is "current" (a detail view — the
document.html convention), and the auth pair's static fallback is
?next=/ — a guest signing in from a shared page returns to the
app root (owner-locked; the comment notes it)."""
html = _html()
# The one-bar inventory (brand, hamburger, nav, auth pair).
assert 'class="brand"' in html
assert re.search(r'<button[^>]*id="nav-toggle"[^>]*aria-label="Menu"[^>]*>', html)
for link in (
r'<a href="/sources.html" class="nav-link" id="nav-sources" hidden>RAG</a>',
r'<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Sources</a>',
r'<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>',
r'<a href="/history.html" class="nav-link" id="nav-history" hidden>History</a>',
):
assert re.search(link, html), f"missing the ship-hidden nav link: {link}"
# No link is current — the shared page is a read-only detail view.
assert "is-active" not in html, "no nav link is current on the shared page"
# The auth pair: BOTH copies (bar + mobile dropdown) fall back to
# the app root — ?next=/ — with the owner-locked note in a comment.
assert (
'<a href="/login.html?next=/" class="auth-link sign-in-link" id="sign-in-link" hidden>'
in html
)
assert (
'<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile"'
' id="sign-in-link-mobile" hidden>'
in html
)
assert re.search(
r'<button[^>]*class="auth-link sign-out-btn" id="sign-out-btn"[^>]*>', html
)
assert (
"a guest signing in FROM a shared page returns to the APP" in html
), "the ?next=/ app-root contract must be documented in the markup"
def test_shared_html_scripts_and_no_controls() -> None:
"""Script load order (the house pattern): brand.js classic FIRST,
the classic markdown renderer second, the shared.js module last —
ALL with ABSOLUTE /assets/ paths: the page is served from the
NESTED /shared/<token> route, where a relative "assets/…" ref
would resolve to /shared/assets/… and 404. NO direct header.js
<script> tag (single-evaluation design — shared.js imports it
relatively). Zero controls (owner-locked): no document-modal, no
composer, no Send/Save/Share/New chat buttons, no form element,
and the ONLY <button>s on the page are the shared bar's own (the
hamburger + the two sign-out copies)."""
html = _html()
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
assert srcs == ["/assets/brand.js", "/assets/markdown.js", "/assets/shared.js"], (
f"shared.html must load brand.js + markdown.js (classic, absolute "
f"/assets/ paths — the nested route breaks relative refs) and the "
f"shared.js module, in that order, got {srcs}"
)
# The stylesheet too (a relative href would break the same way).
assert '<link rel="stylesheet" href="/assets/styles.css">' in html
assert 'src="header.js"' not in html, (
"no direct header.js <script> tag — the single-evaluation design"
)
assert 'type="module" src="/assets/shared.js"' in html
# No CDN (AGENTS.md rule 6): every asset is local.
assert 'src="http' not in html and 'href="http' not in html
# Zero controls: nothing the chat page's controls would look like.
for marker in (
"document-modal",
'id="composer"',
'id="send-btn"',
'id="new-chat-btn"',
'id="save-chat-btn"',
'id="share-chat-btn"',
'id="tune',
"<form",
):
assert marker not in html, f"shared.html must not carry {marker!r}"
# Exactly the shared bar's three buttons — nothing in the content.
assert html.count("<button") == 3, (
"only the hamburger + the two sign-out copies may be buttons"
)
# ---------- shared.js: the token + the no-fetch rule ----------
def test_token_parse_last_segment_uuid_only() -> None:
"""parseSharedToken: the last path segment of /shared/<token> — a
well-formed uuid only; a malformed or missing token (no final
segment, a non-uuid segment) returns null."""
js = _js()
body = _fn(js, "parseSharedToken")
assert "window.location.pathname.split(\"/\").filter(Boolean)" in body, (
"the token is the LAST path segment"
)
assert re.search(
r"TOKEN_RE = /\^\[0-9a-f\]\{8\}-\[0-9a-f\]\{4\}-\[0-9a-f\]\{4\}-"
r"\[0-9a-f\]\{4\}-\[0-9a-f\]\{12\}\$/i",
js,
), "the uuid gate is the house TOKEN_RE shape"
assert "TOKEN_RE.test(last)" in body
def test_malformed_token_shows_invalid_with_no_fetch() -> None:
"""The boot: the malformed-token branch shows #shared-invalid and
returns BEFORE any fetch of any kind — no /api/shared read, no
whoami (initSharedHeader), nothing: the page ships in its guest
state, which is already correct for a bad URL."""
js = _js()
boot_start = js.find("(async () => {")
assert boot_start != -1, "the boot IIFE must exist"
boot = js[boot_start:]
gate = boot.find("if (!token)")
assert gate != -1, "the malformed-token gate must run in boot"
ret = boot.find("return;", gate)
branch = boot[gate:ret]
assert "showInvalid()" in branch, "the invalid state shows for a bad token"
assert "fetch(" not in branch, "a malformed token must trigger NO fetch"
assert "initSharedHeader" not in branch, (
"no whoami either — the header ships in its guest state"
)
# The note is set before the gate (the brand resolves at call time).
note_i = boot.find("noteEl.textContent")
assert 0 < note_i < gate, "the brand note is set before the token gate"
assert "Shared via ${brand()} — read-only." in boot
def test_boot_order_header_then_public_read() -> None:
"""A well-formed token: initSharedHeader() FIRST (the header works
for guests — whoami anonymous, the admin links stay hidden), then
the public read GET /api/shared/<token>; a null read shows the
invalid state, a 200 renders through renderSharedChat."""
js = _js()
boot = js[js.find("(async () => {"):]
header_i = boot.find("await initSharedHeader();")
read_i = boot.find("fetchSharedChat(token)")
render_i = boot.find("renderSharedChat(data)")
assert -1 < header_i < read_i < render_i, (
"boot order: header init → public read → render"
)
invalid_i = boot.find("if (!data)")
assert -1 < invalid_i < render_i, "the null read gates the render"
branch = boot[invalid_i:render_i]
assert "showInvalid()" in branch
assert "renderSharedChat" not in branch, "no render on a null read"
# The relative import of the shared header module (no absolute
# /assets/ import — the esbuild bundle contract).
assert 'import { initSharedHeader } from "./header.js";' in js
assert '"/assets/header.js"' not in js
# No cross-page import (the per-page duplication house style).
import_lines = [
line for line in js.splitlines() if line.strip().startswith("import")
]
assert all("app.js" not in line for line in import_lines)
def test_public_read_collapses_failures_to_null() -> None:
"""fetchSharedChat: a network failure, a non-2xx (the 404 for a
wrong or revoked token), or a malformed body all return null →
the invalid state (no data render, no banner — this page has no
error banner). A 200 without a messages array is unusable too."""
js = _js()
body = _fn(js, "fetchSharedChat")
assert "fetch(`/api/shared/${token}`)" in body
# Exactly three failure collapses to null (network / non-ok /
# malformed) + the shape guard on the 200 path.
assert body.count("return null") == 3, (
"network, non-2xx and malformed body each collapse to null"
)
assert "if (!res.ok) return null" in body, "404 (wrong/revoked) → null"
assert "Array.isArray(data.messages)" in body, "the 200 shape guard"
# ---------- shared.js: the read-only render ----------
def test_render_shared_message_record_shape() -> None:
"""renderSharedMessage: the SAME record shape the chat page
restores — user → the .msg.user bubble; brain → the .msg.brain
bubble with the optional thinking block (restored COLLAPSED — the
phase-17 convention), the tool lines in saved order, the
is-deflected treatment + the plain-text "Maybe try" chips, the
plain-text source chips, and the stopped note. Markdown through
the GLOBAL escape-first renderMarkdown (no local copy)."""
js = _js()
body = _fn(js, "renderSharedMessage")
assert 'addSharedMessage("user", renderMarkdown(m.text))' in body
assert 'addSharedMessage("brain", renderMarkdown(m.text))' in body
assert "renderMarkdown" in body, "the escape-first global renderer"
assert "function renderMarkdown" not in js, (
"shared.js must NOT define its own renderer — markdown.js is the one copy"
)
# The thinking block: restored COLLAPSED.
think = _fn(js, "addThinkingBlock")
assert 'block.className = "thinking"' in think
assert "block.open = false" in think, "the phase-17 restore convention: collapsed"
assert 'summary.textContent = "Thinking"' in think
assert "renderMarkdown(thinking)" in think, "the raw reasoning is markdown-rendered"
assert 'textEl.className = "thinking-text"' in think
# The tool lines (phase 37): the exact app.js template strings (the
# frontend emoji guard strips precisely these two literals here).
tools = _fn(js, "addToolLines")
assert 'container.className = "tool-calls"' in tools
assert '"🔎 Listing documents"' in tools
assert '"📄 Reading "' in tools
assert "code.textContent = argument" in tools, "the path is data, never markup"
# Deflection: the class + the plain-text "Maybe try" chips.
assert 'wrap.classList.add("is-deflected")' in body
maybe = _fn(js, "addMaybeTry")
assert 'group.className = "maybe-try"' in maybe
assert 'chip.className = "suggestion-chip"' in maybe
assert 'chip.setAttribute("role", "listitem")' in maybe
assert "chip.textContent = text" in maybe, "XSS contract: textContent only"
# Sources: plain text spans (the label is data).
sources = _fn(js, "addSources")
assert 'meta.className = "msg-meta"' in sources
assert 'chip.className = "source-chip"' in sources
assert "chip.textContent = label" in sources
assert "chip.title = label" in sources
# The stopped note (phase 48): the local copy of the chat markup.
stopped = _fn(js, "addStoppedNote")
assert 'note.className = "stopped-note"' in stopped
assert 'label.textContent = "Stopped"' in stopped
assert "rect x=\"6.5\" y=\"6.5\" width=\"11\" height=\"11\" rx=\"2\"" in stopped
def test_zero_interactive_controls_in_the_renderer() -> None:
"""Owner-locked zero controls, pinned at source level:
renderSharedMessage (and the whole file) never calls the chat
page's interactive builders, never creates a button/form/anchor,
and binds no click handler — the chips are spans, the source chips
carry no href, and the document modal is never wired."""
js = _js()
for name in ("renderChips", "appendTuneButton", "appendRetryButton",
"openDocumentModal", "openTuneForm"):
assert name not in js, (
f"shared.js must never reference {name} (the chat page's interactive path)"
)
for marker in ('createElement("button")', 'createElement("form")',
'createElement("a")', 'addEventListener("click"'):
assert marker not in js, f"no interactive markup: {marker}"
assert "chip.href" not in js and ".href =" not in js, (
"the source chips carry no href (guests cannot open documents)"
)
assert "document-modal" not in js, "no document-modal wiring on the shared page"
# The rendered message wrapper: the .msg/.bubble structure only.
add = _fn(js, "addSharedMessage")
assert "wrap.className = `msg ${who}`" in add
assert '<div class="bubble">' in add
def test_title_and_defensive_filter_on_the_200_path() -> None:
"""renderSharedChat: the h1 gets the title only when it is a
non-blank string (the static fallback stays otherwise — and on a
failed read the function is never called, so the fallback keeps);
the same defensive record filter as the chat page's restore keeps
a corrupted row from poisoning the render."""
js = _js()
body = _fn(js, "renderSharedChat")
assert "typeof data.title === \"string\"" in body
assert "titleEl.textContent = title" in body
assert 'm.who === "user" || m.who === "brain"' in body
assert 'typeof m.text === "string"' in body
assert "renderSharedMessage(m)" in body
def test_brand_note_resolves_at_call_time() -> None:
"""Phase 39: the note reads window.BOR_BRAND through the house
brand() fallback (exactly one copy of the literal in the file —
the brand() fallback), so a label set after the /api/config fetch
lands carries the configured name."""
js = _js()
assert 'const brand = () => window.BOR_BRAND || "Brain of Reese";' in js
assert js.count('"Brain of Reese"') == 1, (
"the literal must appear only in the brand() fallback"
)
# ---------- styles.css: the shared page ----------
def test_shared_shell_maps_to_the_46rem_column() -> None:
"""The PLAN §7 column contract: .shared-shell is the centered
46rem chat column (the conversation reads exactly like the chat
page's, so the existing .msg/.bubble CSS applies unchanged)."""
css = _css()
block = re.search(r"\.shared-shell \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .shared-shell"
body = block.group(1)
assert "max-width: 46rem" in body, "the PLAN §7 centered chat column"
assert "margin-inline: auto" in body, "centered"
assert "display: flex" in body and "flex-direction: column" in body
def test_shared_page_title_note_and_invalid_css() -> None:
"""#shared-title (the page-head h1 size), .shared-note (the muted
meta line under the h1), and #shared-invalid (a centered muted
block — the not-found language: surface card, --line border,
italic ink-soft)."""
css = _css()
title = re.search(r"#shared-title \{([^}]*)\}", css)
assert title and "font-size: 1.7rem" in title.group(1)
note = re.search(r"\.shared-note \{([\s\S]*?)\n\}", css)
assert note and "var(--ink-soft)" in note.group(1), (
"the note is the muted meta line"
)
invalid = re.search(r"#shared-invalid \{([\s\S]*?)\n\}", css)
assert invalid, "the invalid state must be styled"
ibody = invalid.group(1)
for decl in (
"text-align: center",
"var(--ink-soft)",
"font-style: italic",
"background: var(--surface)",
"border: 1px solid var(--line)",
):
assert decl in ibody, f"#shared-invalid missing {decl!r}"
def test_static_chips_are_text_only_in_the_shared_scope() -> None:
"""The guest's chips are plain text (owner-locked zero controls):
the pill families' pointer treatments are scoped OFF in
.shared-shell and only there — the chat page's interactive chips
keep their styles untouched."""
css = _css()
block = re.search(
r"\.shared-shell \.suggestion-chip,\n\.shared-shell \.source-chip \{([\s\S]*?)\n\}",
css,
)
assert block, "the static-chip rule must scope both chip families"
body = block.group(1)
assert "pointer-events: none" in body, "no pointer (the hover rules die with it)"
assert "cursor: default" in body, "no cursor"
# The interactive treatments live OUTSIDE the shared scope
# (the chat page's chips are untouched).
hover = re.search(r"\.suggestion-chip:hover \{([^}]*)\}", css)
assert hover, "the chat page's chip hover must stay"
assert "pointer-events: none" not in (hover.group(1) or "")
def test_shared_page_mobile_squeeze() -> None:
"""≤640px (the phase-07 contract): the shared title + note step
down (the empty-state-title family); the shell keeps its column
and the global .msg-body 92% override applies."""
css = _css()
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the mobile media query must exist"
mbody = mobile.group(1)
assert "#shared-title { font-size: 1.35rem; }" in mbody
assert ".shared-note { font-size: 0.88rem; }" in mbody
+4 -2
View File
@@ -22,8 +22,9 @@ ASSETS = FRONTEND / "assets"
HEADER_JS = ASSETS / "header.js"
#: All seven pages carry the shared header block (phase 34's five pages
#: + phase 35's git-sources page + phase 50's History page).
#: All eight pages carry the shared header block (phase 34's five pages
#: + phase 35's git-sources page + phase 50's History page +
#: phase 51's shared page).
PAGES = (
FRONTEND / "index.html",
FRONTEND / "sources.html",
@@ -32,6 +33,7 @@ PAGES = (
FRONTEND / "login.html",
FRONTEND / "tuning.html",
FRONTEND / "history.html",
FRONTEND / "shared.html",
)