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