450 lines
20 KiB
Python
450 lines
20 KiB
Python
"""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
|