package web
import (
"html/template"
"net/http/httptest"
"strings"
"golang.org/x/net/html"
"testing"
)
// chatRun renders a list the way the page does — grouping first, then
// one row at a time through the single renderer.
func chatRun(msgs []ChatMessage, readSeq uint64, viewer string) string {
return strings.Join(chatRows(msgs, readSeq, viewer), "")
}
// chatRows is the same, one string per row, for the assertions that
// care which row drew what.
func chatRows(msgs []ChatMessage, readSeq uint64, viewer string) []string {
chatGroupRuns(msgs, readSeq)
chatRefQuotes(msgs)
out := make([]string, len(msgs))
for i, m := range msgs {
out[i] = string(chatMsgHTML(m, "alpha", viewer, false))
}
return out
}
// One header per run of an author's messages inside one minute, and one
// action row per message however it was drawn.
func TestChatGroupsARunUnderOneHeader(t *testing.T) {
got := chatRun([]ChatMessage{
{Seq: 1, Kind: "msg", User: "dan", Text: "one", At: "2026-08-29T14:05:01Z"},
{Seq: 2, Kind: "msg", User: "dan", Text: "two", At: "2026-08-29T14:05:40Z"},
{Seq: 3, Kind: "msg", User: "dan", Text: "three", At: "2026-08-29T14:05:59Z"},
}, 0, "dan")
if n := strings.Count(got, `class="chat-head"`); n != 1 {
t.Errorf("a three-message run drew %d headers, want 1", n)
}
if n := strings.Count(got, `class="chat-toolbar"`); n != 3 {
t.Errorf("%d action rows for three messages, want 3", n)
}
if n := strings.Count(got, "data-grouped"); n != 2 {
t.Errorf("%d rows marked as continuing the run, want 2", n)
}
}
// A different author, a different minute, and a reply each open a new
// group — the reply because its quote has to sit under a header.
func TestChatBreaksGroups(t *testing.T) {
for _, tc := range []struct {
name string
next ChatMessage
}{
{"another author", ChatMessage{Seq: 2, Kind: "msg", User: "eve", Text: "b", At: "2026-08-29T14:05:10Z"}},
{"another minute", ChatMessage{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:06:00Z"}},
{"a reply", ChatMessage{Seq: 2, Kind: "msg", User: "dan", Ref: 1, RefUser: "eve", RefLine: "x",
Text: "b", At: "2026-08-29T14:05:10Z"}},
{"a robot line", ChatMessage{Seq: 2, Kind: "robot", User: "robot", Text: "b", At: "2026-08-29T14:05:10Z"}},
} {
got := chatRun([]ChatMessage{
{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
tc.next,
}, 0, "dan")
if n := strings.Count(got, `class="chat-head"`); n != 2 {
t.Errorf("%s drew %d headers, want 2", tc.name, n)
}
}
}
// The "new" divider is drawn between the read and the unread, so the
// first unread row keeps its own header rather than losing it to a run
// on the other side of the line.
func TestChatFirstUnreadStartsAGroup(t *testing.T) {
got := chatRun([]ChatMessage{
{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:05:10Z"},
}, 1, "dan")
if n := strings.Count(got, `class="chat-head"`); n != 2 {
t.Errorf("the first unread row drew %d headers in the run, want 2", n)
}
}
// The row's controls: reply and edit as their own icons, everything
// rare or dangerous behind an overflow — and every one of them a link
// or a <details>, so a tap does the same thing a click does with no
// script anywhere in the path.
func TestChatActionsAreOneQuietRow(t *testing.T) {
m := ChatMessage{Seq: 7, Kind: "msg", User: "dan", Text: "mine", At: "2026-08-29T14:05:00Z"}
got := string(chatMsgHTML(m, "alpha", "dan", false))
for _, want := range []string{
`class="chat-toolbar"`,
`class="chat-icon chat-act chatreplyto" href="/r/alpha/chat?reply=7"`,
`class="chatmenu-item chat-act chatpermalink" href="/r/alpha/chat/7">Permalink #7<`,
`<details class="chatedit"><summary class="chat-icon chat-act"`,
`<textarea name="text"`,
`action="/r/alpha/chat/7/delete"`,
`data-on:submit=`,
} {
if !strings.Contains(got, want) {
t.Errorf("own message missing %q:\n%s", want, got)
}
}
// Somebody else's message offers no edit and no delete.
other := string(chatMsgHTML(m, "alpha", "eve", false))
for _, unwanted := range []string{"chatedit", "chatdelete"} {
if strings.Contains(other, unwanted) {
t.Errorf("another account's message offered %q", unwanted)
}
}
if !strings.Contains(other, `class="chat-toolbar"`) {
t.Error("a message the viewer cannot edit still needs its permalink row")
}
// A deleted message loses edit, delete and reply — and its
// permalink, which would resolve to a message that is not there.
// What it gains is the one thing still worth doing to it.
del := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
Deleted: true, At: "2026-08-29T14:05:00Z"}, "alpha", "dan", false))
if strings.Contains(del, "chatedit") || strings.Contains(del, "chatdelete") {
t.Error("a deleted message still offered edit or delete")
}
if strings.Contains(del, "chatpermalink") {
t.Error("a deleted message still offers a permalink to nothing")
}
if !strings.Contains(del, "chatpurge") || !strings.Contains(del, "Remove from timeline") {
t.Errorf("a deleted message offers no way off the timeline:\n%s", del)
}
// Purge is the SECOND step and keeps its own confirmation: the
// tombstone is the last trace, and erasing it is final.
if !strings.Contains(del, "/chat/7/purge") || !strings.Contains(del, "Remove for everyone") {
t.Error("removal is not two deliberate steps")
}
// Somebody else's deleted message offers nothing to remove.
if other := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
Deleted: true, At: "2026-08-29T14:05:00Z"}, "alpha", "eve", false)); strings.Contains(other, "chatpurge") {
t.Error("a reader who is neither the author nor an admin was offered a purge")
}
// An admin may.
if adm := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
Deleted: true, At: "2026-08-29T14:05:00Z"}, "alpha", "eve", true)); !strings.Contains(adm, "chatpurge") {
t.Error("an admin cannot clear a tombstone")
}
}
// A reply's quote is compact: who, which message, and a bounded first
// line — a reminder, not a second copy.
func TestChatQuoteIsBounded(t *testing.T) {
long := strings.Repeat("x", 400)
got := string(chatMsgHTML(ChatMessage{Seq: 9, Kind: "msg", User: "dan", Ref: 4,
RefUser: "eve", RefLine: long, Text: "answer", At: "2026-08-29T14:05:00Z"},
"alpha", "dan", false))
if !strings.Contains(got, `class="chat-quote"`) || !strings.Contains(got, "· #4 ·") {
t.Errorf("the quote does not name the message it answers:\n%s", got)
}
if strings.Contains(got, strings.Repeat("x", 200)) {
t.Error("the quote carried the whole quoted message")
}
if !strings.Contains(got, "…") {
t.Error("the truncated quote does not say it was cut")
}
}
// The chat page carries the chip rule the mention markup renders into,
// and the mention itself is visual only — no preview hook, because the
// person card is capability-gated.
func TestChatPageStylesTheMentionChip(t *testing.T) {
// Asserted against the SERVED sheet, not the page. These rules used
// to ride in an inline <style> on the chat page, and this test
// passed for as long as they did — while the browser applied none
// of them, because a strict style-src drops a <style> element.
// Rendering the rule into the HTML proves nothing; being in the
// stylesheet the page links is the only thing that reaches anyone.
if !strings.Contains(sheet, ".at{") {
t.Error("the stylesheet has no rule for the mention chip")
}
}
// The rail and the identicon are the forum's own vocabulary at chat
// density: a group block says whose column it is before a word is read.
func TestChatGroupBlockCarriesTheAuthorsHue(t *testing.T) {
got := chatRun([]ChatMessage{
{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
}, 0, "dan")
if !strings.Contains(got, `style="--uh:`) {
t.Errorf("the group block carries no author hue:\n%s", got)
}
if !strings.Contains(got, `class="idn"`) {
t.Errorf("the group header carries no identicon:\n%s", got)
}
// A robot line is the channel talking, not a person: a rail, but no
// hue to claim it belongs to somebody.
bot := chatRun([]ChatMessage{
{Seq: 1, Kind: "robot", User: "robot", Text: "check-in", At: "2026-08-29T14:05:00Z"},
}, 0, "dan")
if strings.Contains(bot, `style="--uh:`) {
t.Errorf("a robot line took an author hue:\n%s", bot)
}
}
// quotesIn counts the diminished quote blocks in one rendered row.
func quotesIn(s string) int { return strings.Count(s, `class="chat-quote"`) }
// An inline #seq is a reference like any other, and gets the same
// diminished quote a formal reply does.
func TestInlineRefRendersItsQuote(t *testing.T) {
rows := chatRows([]ChatMessage{
{Seq: 1, Kind: "msg", User: "eve", Text: "the original", At: "2026-08-29T14:05:00Z"},
{Seq: 2, Kind: "msg", User: "dan", Text: "see #1", Refs: []string{"#1"},
At: "2026-08-29T14:06:00Z"},
}, 0, "dan")
if n := quotesIn(rows[1]); n != 1 {
t.Errorf("an inline #1 drew %d quotes, want 1:\n%s", n, rows[1])
}
if !strings.Contains(rows[1], `href="/r/alpha/chat/1"`) {
t.Errorf("the inline quote does not link the permalink:\n%s", rows[1])
}
if !strings.Contains(rows[1], "the original") {
t.Errorf("the inline quote carries no first line:\n%s", rows[1])
}
}
// A reply that also names the message it answers is still one quote:
// the formal reply already drew it.
func TestInlineRefIsNotQuotedTwice(t *testing.T) {
rows := chatRows([]ChatMessage{
{Seq: 1, Kind: "msg", User: "eve", Text: "the original", At: "2026-08-29T14:05:00Z"},
{Seq: 2, Kind: "msg", User: "dan", Text: "yes, #1", Ref: 1, RefUser: "eve",
RefLine: "the original", Refs: []string{"#1"}, At: "2026-08-29T14:06:00Z"},
}, 0, "dan")
if n := quotesIn(rows[1]); n != 1 {
t.Errorf("a reply that also names its target drew %d quotes, want 1:\n%s", n, rows[1])
}
}
// Two is the cap: a quote is a reminder, and a message that names five
// things must not become a wall of other people's words.
func TestInlineRefQuotesAreCappedAtTwo(t *testing.T) {
rows := chatRows([]ChatMessage{
{Seq: 1, Kind: "msg", User: "eve", Text: "one", At: "2026-08-29T14:05:00Z"},
{Seq: 2, Kind: "msg", User: "eve", Text: "two", At: "2026-08-29T14:05:10Z"},
{Seq: 3, Kind: "msg", User: "eve", Text: "three", At: "2026-08-29T14:05:20Z"},
{Seq: 4, Kind: "msg", User: "dan", Text: "#1 #2 #3",
Refs: []string{"#1", "#2", "#3"}, At: "2026-08-29T14:06:00Z"},
}, 0, "dan")
if n := quotesIn(rows[3]); n != 2 {
t.Errorf("three inline refs drew %d quotes, want 2:\n%s", n, rows[3])
}
}
// A seq the page cannot source is quoted not at all — an aged-out
// reference keeps its link and loses only the words.
func TestUnknownInlineRefRendersNoQuote(t *testing.T) {
rows := chatRows([]ChatMessage{
{Seq: 2, Kind: "msg", User: "dan", Text: "see #99", Refs: []string{"#99"},
At: "2026-08-29T14:06:00Z"},
}, 0, "dan")
if n := quotesIn(rows[0]); n != 0 {
t.Errorf("an unknown seq drew %d quotes, want 0:\n%s", n, rows[0])
}
}
// fakeLive replays a fixed script down the tail, then closes it, which
// is how liveChatAs is made to return.
type fakeLive struct{ msgs []ChatMessage }
func (f fakeLive) Tail(string, uint64) (<-chan ChatMessage, func()) {
ch := make(chan ChatMessage, len(f.msgs))
for _, m := range f.msgs {
ch <- m
}
close(ch)
return ch, func() {}
}
// The live path groups against every row it appended, not only against
// the messages: a robot line between two of an author's messages breaks
// the run there exactly as it does on the reloaded page.
func TestLiveGroupingBreaksOnARobotLine(t *testing.T) {
live := fakeLive{msgs: []ChatMessage{
{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
{Seq: 2, Kind: "robot", User: "robot", Text: "check-in", At: "2026-08-29T14:05:10Z"},
{Seq: 3, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:05:20Z"},
}}
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/r/alpha/chat.sse", nil)
liveChatAs(live, "alpha", "dan", false, nil, w, req)
got := w.Body.String()
if n := strings.Count(got, `class="chat-head"`); n != 3 {
t.Errorf("the live run drew %d headers for three rows, want 3:\n%s", n, got)
}
if strings.Contains(got, "data-grouped") {
t.Errorf("a robot line did not break the live run:\n%s", got)
}
}
// A live row quotes an inline reference to a message the same
// connection already appended, through the same renderer the page uses.
func TestLiveRowQuotesAnInlineRef(t *testing.T) {
live := fakeLive{msgs: []ChatMessage{
{Seq: 1, Kind: "msg", User: "eve", Text: "the original", At: "2026-08-29T14:05:00Z"},
{Seq: 2, Kind: "msg", User: "dan", Text: "see #1", Refs: []string{"#1"},
At: "2026-08-29T14:06:00Z"},
}}
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/r/alpha/chat.sse", nil)
liveChatAs(live, "alpha", "dan", false, nil, w, req)
if n := quotesIn(w.Body.String()); n != 1 {
t.Errorf("the live row drew %d quotes, want 1:\n%s", n, w.Body.String())
}
}
// --- visual-polish hooks -------------------------------------------------
// chatContent renders the chat page's content block for the assertions
// that are about the page rather than one row.
func chatContent(t *testing.T, d pageData) string {
t.Helper()
var b strings.Builder
if err := chatPage.ExecuteTemplate(&b, "content", d); err != nil {
t.Fatal(err)
}
return b.String()
}
// Every row reserves the same gutter: a group header fills it with the
// author's mark, a continuation row with the absolute clock, so the
// column reads as one column whichever kind of row is in it.
func TestChatRowsShareAnAvatarGutter(t *testing.T) {
rows := chatRows([]ChatMessage{
{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-29T14:05:00Z"},
{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-29T14:05:20Z"},
}, 0, "dan")
for i, r := range rows {
if !strings.Contains(r, `class="chat-gut"`) {
t.Errorf("row %d has no gutter:\n%s", i, r)
}
}
if !strings.Contains(rows[0], `class="idn"`) {
t.Errorf("the group header's gutter has no mark:\n%s", rows[0])
}
if !strings.Contains(rows[1], `class="chat-clock"`) {
t.Errorf("a continuation row carries no absolute clock:\n%s", rows[1])
}
if !strings.Contains(rows[1], `>14:05<`) {
t.Errorf("the continuation clock is not the message's own time:\n%s", rows[1])
}
}
// The row's controls are a toolbar, not a line of prose under the text.
func TestChatToolbarIsItsOwnBox(t *testing.T) {
got := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
Text: "mine", At: "2026-08-29T14:05:00Z"}, "alpha", "dan", false))
if !strings.Contains(got, `class="chat-toolbar"`) {
t.Errorf("no toolbar on the row:\n%s", got)
}
if !strings.Contains(sheet, ".chat-toolbar{") {
t.Error("the stylesheet has no toolbar rule")
}
if !strings.Contains(sheet, "var(--shadow-1)") {
t.Error("the floating toolbar has no shadow token")
}
}
// The quote is a card: a mark, a name, the number, one line — one row.
func TestChatQuoteCarriesAMark(t *testing.T) {
got := string(chatQuoteBlock(4, "eve", "the original", "alpha"))
if !strings.Contains(got, `class="idn"`) {
t.Errorf("the quote card has no identicon:\n%s", got)
}
if !strings.Contains(got, "· #4 ·") {
t.Errorf("the quote card does not name the message:\n%s", got)
}
}
// A day boundary gets its own separator, so a log read the next morning
// says which morning.
func TestChatLogSeparatesDays(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatMsgs: []ChatMessage{
{Seq: 1, Kind: "msg", User: "dan", Text: "a", At: "2026-08-28T14:05:00Z"},
{Seq: 2, Kind: "msg", User: "dan", Text: "b", At: "2026-08-28T14:06:00Z"},
{Seq: 3, Kind: "msg", User: "dan", Text: "c", At: "2026-08-29T09:00:00Z"},
}})
if n := strings.Count(got, `class="chat-date"`); n != 2 {
t.Errorf("a two-day log drew %d date separators, want 2:\n%s", n, got)
}
}
// The composer is one rounded field with its own toolbar strip: the
// note, the key hint, mark-read and Send, inside the border.
func TestChatComposerIsARoundedField(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
for _, want := range []string{
`class="composer"`,
`class="composer-bar"`,
`class="cbtn cbtn-send"`,
`placeholder="Message #alpha"`,
} {
if !strings.Contains(got, want) {
t.Errorf("the composer is missing %q", want)
}
}
// Its rules come from the served sheet, which is the only place a
// browser reads them from.
for _, want := range []string{".composer{", "field-sizing:content"} {
if !strings.Contains(sheet, want) {
t.Errorf("the stylesheet is missing %q", want)
}
}
// Signed out there is no composer at all, and nothing dangling.
out := chatContent(t, pageData{Repo: "alpha"})
if strings.Contains(out, `class="composer"`) {
t.Error("a signed-out reader was given a composer")
}
}
// The chat rules are written in the shared scale, not in loose pixels —
// and they are in the SHEET, which is what a browser applies.
func TestChatStyleUsesTheScale(t *testing.T) {
for _, want := range []string{"var(--sp-", "var(--r-", "var(--t-fast)", ".chat-date{"} {
if !strings.Contains(sheet, want) {
t.Errorf("the stylesheet does not use %q", want)
}
}
}
// The scale itself is declared once, in the base stylesheet.
func TestBaseStylesheetDeclaresTheScale(t *testing.T) {
for _, want := range []string{
"--sp-1:", "--sp-2:", "--sp-3:", "--sp-4:", "--sp-5:", "--sp-6:",
"--r-1:", "--r-2:", "--r-3:", "--r-4:", "--shadow-1:", "--t-fast:",
} {
if !strings.Contains(styleSource, want) {
t.Errorf("the base stylesheet declares no %s", want)
}
}
}
// The log has an end that can be linked to, and the page offers the
// link. Without script this anchor IS jump-to-latest, which is why it
// is a real <a href> and not a scripted button.
func TestChatOffersJumpToLatestWithoutScript(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan",
ChatMsgs: []ChatMessage{{Seq: 1, Kind: "msg", User: "dan", Text: "hi", HTML: "hi"}}})
if !strings.Contains(got, `id="chat-latest"`) {
t.Error("the log has no end sentinel to jump to")
}
if !strings.Contains(got, `href="#chat-latest"`) {
t.Error("no jump-to-latest anchor on the live view")
}
// The sentinel must be the LAST thing in the log, or it stops
// naming the latest message. Live rows are inserted before it.
if strings.Index(got, `id="chat-latest"`) < strings.Index(got, ">hi<") {
t.Error("the end sentinel is not after the messages")
}
}
// Following the log is CONDITIONAL. A chat that always scrolls to the
// newest message pulls the page out from under somebody reading back.
func TestChatFollowsOnlyWhenAtTheEnd(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
if !strings.Contains(got, "$chatEnd") {
t.Error("nothing tracks whether the reader is at the end")
}
if !strings.Contains(got, "data-effect") || !strings.Contains(got, "$chatTick") {
t.Error("no effect watching for an arrival")
}
// Away from the end the arrival is counted and offered, not forced.
if !strings.Contains(got, "$chatNew") {
t.Error("an arrival away from the end is not counted")
}
// And the page opens where the reading is.
if !strings.Contains(got, "chat-divider") || !strings.Contains(got, "scrollIntoView") {
t.Error("the log does not open at the divider or the end")
}
}
// Delete takes two deliberate actions WITH OR WITHOUT script. The
// confirm() used to be the only guard, and it is Datastar-only — so the
// scriptless path, the one where a slip cannot be undone, had none.
func TestChatDeleteIsGuardedWithoutScript(t *testing.T) {
got := string(chatMsgHTML(ChatMessage{Seq: 4, Kind: "msg", User: "dan",
Text: "mine", HTML: "mine"}, "alpha", "dan", false))
if !strings.Contains(got, `<details class="chatdelete">`) {
t.Error("delete is not behind a native disclosure")
}
if !strings.Contains(got, "<summary") {
t.Error("the disclosure has no summary to open it")
}
// The form is inside the disclosure, so it cannot be submitted
// until the reader has opened it.
d := strings.Index(got, `<details class="chatdelete">`)
f := strings.Index(got[d:], "<form")
if f < 0 {
t.Fatal("no delete form inside the disclosure")
}
if !strings.Contains(got, "confirm(") {
t.Error("the pointer-user confirm was dropped")
}
}
// TestEveryDatastarAttributeSurvivesTheParser is a guard against a bug
// with no visible symptom: a template comment written {{- ... -}}
// between two attributes eats the whitespace BETWEEN them, so
//
// data-indicator="chatSending"data-on:submit__prevent="…"
//
// reaches the browser as ONE attribute whose name happens to end in a
// quote. The page renders, the styles apply, nothing throws — the
// behaviour is simply absent. It cost a live regression in the chat
// composer, where sending silently reverted to a full page reload.
//
// Parsing is what catches it: a string search for the attribute finds
// the glued text just as happily as the correct markup.
func TestEveryDatastarAttributeSurvivesTheParser(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan", Live: true,
ChatMsgs: []ChatMessage{{Seq: 1, Kind: "msg", User: "dan", Text: "hi", HTML: "hi"}}})
doc, err := html.Parse(strings.NewReader(got))
if err != nil {
t.Fatal(err)
}
var walk func(*html.Node)
seen := map[string]bool{}
walk = func(n *html.Node) {
for _, a := range n.Attr {
// A well-formed name is letters, digits and the few
// punctuation marks Datastar uses. A quote in it means two
// attributes were run together.
if strings.ContainsAny(a.Key, `"'<>=`) {
t.Errorf("attribute name %q on <%s> — two attributes ran together, "+
"almost certainly a {{- comment between them", a.Key, n.Data)
}
seen[a.Key] = true
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
// The composer's submit handler is the one this was found by, so it
// is named outright rather than left to the general scan.
for _, want := range []string{"data-on:submit__prevent", "data-bind", "data-indicator"} {
if !seen[want] {
t.Errorf("the chat page carries no %s attribute the parser can see", want)
}
}
}
// The unread-count badge must ship HIDDEN. It reports a live signal, so
// the only honest state for a page whose script has not run — or whose
// script failed — is "nothing arrived"; an empty white pill is a wrong
// answer stated confidently. It showed exactly that on a phone when an
// unrelated expression threw and aborted the attribute walk before
// reaching it.
func TestChatCountBadgeShipsHidden(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan", Live: true})
i := strings.Index(got, `class="chat-jump-n"`)
if i < 0 {
t.Fatal("no count badge on the jump control")
}
tag := got[i:]
if end := strings.Index(tag, ">"); end >= 0 {
tag = tag[:end]
}
if !strings.Contains(tag, " hidden") {
t.Errorf("the badge is not hidden in the markup: <span %s>", tag)
}
}
// The permalink's job moved into the channel. What these pin is the
// fold-in itself: replying is the composer's band, arriving is ?at=,
// and only "gone" is still a page — because it has nowhere to send you.
// ?reply=N arms the composer with the message the next post answers,
// drawn as who-and-what — identicon, name, seq, one truncated line —
// the same shape the log gives a quote, because it produces one.
func TestReplyBandArmsTheComposer(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan",
ChatReplyTo: ChatMessage{Seq: 44, Kind: "msg", User: "eve", Text: "ok, **cool**",
HTML: template.HTML("<p>ok, <strong>cool</strong></p>")}})
for _, want := range []string{
`class="reply-band"`,
`class="rb-name">eve<`,
`class="rb-seq">#44<`,
`class="rb-txt"><p>ok, <strong>cool</strong></p><`,
`name="ref" value="44"`,
} {
if !strings.Contains(got, want) {
t.Errorf("the band is missing %q", want)
}
}
// Who is an identicon, not a coloured name: the mark reads at a
// glance and the band keeps no colour of its own.
if !strings.Contains(got, `class="rb-idn"`) {
t.Error("the band carries no identicon")
}
// It is a MODE, so it has an exit that works without script: the X
// is a link back to the channel without ?reply.
if !strings.Contains(got, `class="rb-x" href="/r/alpha/chat"`) {
t.Error("the band has no scriptless way out")
}
// Without ?reply there is no band and no stray ref.
plain := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
if strings.Contains(plain, "reply-band") || strings.Contains(plain, `name="ref"`) {
t.Error("an unarmed composer still carries reply machinery")
}
}
// The reply icon on a message arms the channel's own composer rather
// than leaving for a page: you answer while still looking at the room.
func TestReplyIconArmsTheChannel(t *testing.T) {
got := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
Text: "hi", At: "2026-08-29T14:05:00Z"}, "alpha", "dan", false))
if !strings.Contains(got, `chatreplyto" href="/r/alpha/chat?reply=7"`) {
t.Errorf("the reply icon does not arm the composer:\n%s", got)
}
}
// An older page continues in both directions. Later is a plain link —
// no script — and the newest page draws nothing, because the stream
// appends there on its own.
func TestOlderPagesOfferLaterMessages(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan",
ChatBefore: 40, ChatLaterCur: 39,
ChatMsgs: []ChatMessage{{Seq: 39, Kind: "msg", User: "dan", Text: "x", HTML: template.HTML("x")}}})
if !strings.Contains(got, `href="/r/alpha/chat?after=39"`) {
t.Error("an older page offers no way forward")
}
latest := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
if strings.Contains(latest, "?after=") {
t.Error("the newest page draws a later control that leads nowhere")
}
}
// Gone renders AT the permalink's URL — swept or purged, the same fact
// from outside — with the room's door one tap away. It is the only
// state that stays a page, because it has nothing to redirect to.
func TestGoneStateRendersInTheChannelShell(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatSeq: 19, ChatExpired: true})
if !strings.Contains(got, `data-hook="chat-expired"`) {
t.Fatal("no gone state")
}
if !strings.Contains(got, "Linked message <b>#19</b>") {
t.Error("the gone state does not say which message was linked")
}
if !strings.Contains(got, `href="/r/alpha/chat"`) {
t.Error("the gone state is a dead end")
}
if strings.Contains(got, `id="chatlog"`) {
t.Error("the gone state draws a log it does not have")
}
}
// "Open in the channel at this message" cannot be a fragment: the live
// view loads the newest page, so an older message's id is usually not
// on the page to scroll to. The server positions and marks it.
func TestChatOpensAtAMessage(t *testing.T) {
d := pageData{Repo: "alpha", ChatViewer: "dan", ChatAt: 8,
ChatMsgs: []ChatMessage{
{Seq: 7, Kind: "msg", User: "dan", Text: "seven", HTML: template.HTML("seven")},
{Seq: 8, Kind: "msg", User: "dan", Text: "eight", HTML: template.HTML("eight")}}}
got := chatContent(t, d)
if !strings.Contains(got, `data-seq="8"`) {
t.Errorf("the log does not open at the named message:\n%s", got[:min(len(got), 900)])
}
if !strings.Contains(got, `<li class="chat-anchor">`) {
t.Error("the message opened at is not marked")
}
// Without ?at the log still opens at the unread divider.
d.ChatAt = 0
if got := chatContent(t, d); !strings.Contains(got, ".chat-divider") ||
strings.Contains(got, `<li class="chat-anchor">`) {
t.Error("an ordinary visit no longer opens at the divider")
}
}
// One control, one shape. The older-page jump is a plain link and the
// live one is signal-driven, but both are the same caret in the same
// 2rem circle — drawing the older one as a sentence left the words
// spilling out of the circle and across the composer.
func TestJumpToLatestIsTheSameShapeOnBothPaths(t *testing.T) {
older := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan", ChatBefore: 40})
if !strings.Contains(older, `class="chat-jump"`) {
t.Fatal("an older page offers no way back to the latest")
}
if strings.Contains(older, "jump to latest ↓") || strings.Contains(older, ">jump to latest<") {
t.Error("the older-page jump is still drawn as words inside the circle")
}
if !strings.Contains(older, "#i-caret-down") {
t.Error("the older-page jump carries no caret")
}
// The words survive as the accessible name, which is where a glyph
// control's label belongs.
if !strings.Contains(older, `aria-label="jump to the latest message"`) {
t.Error("the older-page jump has no accessible name")
}
}
// Reply, edit and delete must be findable with a mouse. They used to be
// a permanent row on a touch device and invisible until hover on a
// desktop — the same message offering different products depending on
// what you were holding.
func TestMessageActionsAreVisibleWithoutHovering(t *testing.T) {
i := strings.Index(sheet, "@media (hover: hover) and (pointer: fine){")
if i < 0 {
t.Fatal("no pointer query in the sheet")
}
block := sheet[i : i+400]
if strings.Contains(block, "opacity:0;") || strings.Contains(block, "opacity:0}") {
t.Error("the actions are still fully hidden at rest where there is a pointer")
}
if strings.Contains(block, "pointer-events:none") {
t.Error("the actions are still unclickable at rest where there is a pointer")
}
// Nothing that MATTERS may live behind a pointer query. A rule that
// only a mouse can satisfy is a feature a phone does not have.
for _, forbidden := range []string{"display:none", "visibility:hidden"} {
if strings.Contains(block, forbidden) {
t.Errorf("the pointer query hides actions with %q", forbidden)
}
}
}
// Every action has to work on a touch screen, where there is no hover
// and no right-click. That rules out both of the mechanisms every other
// chat client uses, so each control is a link or a <details>: a tap
// does exactly what a click does, with no script in the path.
func TestMessageActionsWorkWithoutHoverOrScript(t *testing.T) {
got := string(chatMsgHTML(ChatMessage{Seq: 7, Kind: "msg", User: "dan",
Text: "mine", At: "2026-08-29T14:05:00Z"}, "alpha", "dan", true))
// The overflow opens natively.
if !strings.Contains(got, `<details class="chatmenu"`) {
t.Error("the overflow is not a native disclosure")
}
// name= makes the menus one exclusive group, so a second tap
// elsewhere closes the first — the "click outside to dismiss" that
// otherwise costs a document listener.
if !strings.Contains(got, `name="chatmenu"`) {
t.Error("the menus are not an exclusive group, so taps leave a trail of open ones")
}
// Nothing in the row depends on script to be reachable: no button
// whose only handler is a Datastar expression, no href="#".
for _, dead := range []string{`href="#"`, `href="javascript:`} {
if strings.Contains(got, dead) {
t.Errorf("a control is inert without script: %q", dead)
}
}
// In flow the panel cannot be clipped sideways — but on the LAST
// message it grew past the log's visible bottom and nothing
// scrolled to it, so it opened half off the edge. block:"nearest"
// moves the log the least amount that shows the whole disclosure,
// and by zero when it is already visible. With no script the
// reader scrolls those few pixels themselves; the panel is still
// in the log and still reachable, which is the difference between
// a degradation and a break.
if !strings.Contains(got, "data-on:toggle=") {
t.Error("an opened menu does not scroll itself into view")
}
if !strings.Contains(got, "block: 'nearest'") {
t.Error("the menu scrolls by more than it needs to")
}
// Delete stays two deliberate steps INSIDE the menu — three taps
// for the one action nobody can undo.
menu := got[strings.Index(got, `class="chatmenu-panel"`):]
if !strings.Contains(menu, `<details class="chatdelete">`) {
t.Error("delete is not behind the overflow")
}
if !strings.Contains(menu, "Delete for everyone") {
t.Error("delete lost its confirmation step")
}
// Reply and edit are top-level icons: the common actions cost one
// tap, not two.
head := got[strings.Index(got, "chat-toolbar"):strings.Index(got, `class="chatmenu-panel"`)]
for _, want := range []string{"#i-reply", "#i-pencil", "#i-more"} {
if !strings.Contains(head, want) {
t.Errorf("the toolbar is missing the %s control", want)
}
}
// A glyph with no accessible name is a control a screen reader
// cannot announce.
if strings.Count(head, "aria-label=") < 3 {
t.Errorf("an icon control has no accessible name:\n%s", head)
}
}
// A tap target has to be a target. The glyph is 16px because that reads
// right beside the text; the box around it has to be a thumb's worth.
func TestActionIconsAreThumbSized(t *testing.T) {
i := strings.Index(sheet, ".chat-icon-box{")
if i < 0 {
t.Fatal("no icon box rule in the sheet")
}
rule := sheet[i : i+strings.Index(sheet[i:], "}")]
// 32, not 36: the box used to be 36 pulled back 9px at each end,
// and that overhang painted outside the message's own padding and
// over the rounded edge of the card below it. A box that sits in
// its row cannot overhang anything.
for _, want := range []string{"width:32px", "height:32px"} {
if !strings.Contains(rule, want) {
t.Errorf("the icon hit box is not thumb-sized, missing %q: %s", want, rule)
}
}
}
// Changing a <summary>'s display is a documented way to lose the
// disclosure's own behaviour in some engines. What sits behind these
// particular ones is an edit form and a delete confirmation — the
// two-step guard on the only irreversible action in the app — and the
// failure would be silent: the page would look right and the guard
// would be gone. This is a hazard guard, not a regression test; the
// rule costs nothing, because the box it would style can live on an
// inner span instead.
func TestNoRuleSetsDisplayOnASummary(t *testing.T) {
// Every class the markup puts on a <summary>.
onSummaries := []string{".chat-icon", ".chatmenu-item", ".chat-act"}
for _, sel := range onSummaries {
for i := 0; ; {
j := strings.Index(sheet[i:], sel+"{")
if j < 0 {
break
}
i += j
end := strings.Index(sheet[i:], "}")
if end < 0 {
break
}
rule := sheet[i : i+end]
// Only the BARE class matters. "button.chatmenu-item" is a
// selector no <summary> can match, so a display there is
// both intentional and harmless.
bare := i == 0 || !isSelectorChar(sheet[i-1])
// A marker reset is the one display nobody minds.
if bare && strings.Contains(rule, "display:") && !strings.Contains(rule, "::-webkit-details-marker") {
t.Errorf("%s sets display, which disables every disclosure wearing it: %s", sel, rule)
}
i += end
}
}
}
// Two modes, and the DEFAULT must be the safe one. In flow the panel
// can never be clipped by the log's scroll box, which is what a page
// with no script gets. Floating is the enhancement — no layout shift —
// and it is gated on the log having said script is running.
func TestMenuPanelFloatsOnlyAsAnEnhancement(t *testing.T) {
i := strings.Index(sheet, ".chatmenu-panel{")
if i < 0 {
t.Fatal("no menu panel rule in the sheet")
}
rule := sheet[i : i+strings.Index(sheet[i:], "}")]
if strings.Contains(rule, "position:absolute") || strings.Contains(rule, "position:fixed") {
t.Errorf("the panel floats by default, so a scriptless page can clip it: %s", rule)
}
// The enhanced rule exists, and is gated.
j := strings.Index(sheet, ".chatlog.js .chatmenu-panel{")
if j < 0 {
t.Fatal("the floating panel is not offered where script is running")
}
if !strings.Contains(sheet[j:j+160], "position:absolute") {
t.Error("the enhanced panel does not actually float")
}
}
// The flag that gates it is a CLASS, never a data-* attribute: Datastar
// watches data-* and reads one back as a plugin of its own. A
// data-enhanced would become a plugin called "enhanced", which does not
// exist, and throw on every toggle — which is exactly how an earlier
// data-attr:data-show broke this page.
func TestScriptFlagIsAClassNotADataAttribute(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
if !strings.Contains(got, "el.classList.add('js')") {
t.Error("the log does not flag that script is running")
}
for _, trap := range []string{"data-attr:data-", "dataset.enhanced", `setAttribute('data-`} {
if strings.Contains(got, trap) {
t.Errorf("the page writes a data-* attribute Datastar will try to interpret: %q", trap)
}
}
}
func isSelectorChar(c byte) bool {
return c == '.' || c == '#' || c == '-' || c == '_' ||
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}
// Pressing a control must not move the control. The icons used to be
// centred in the toolbar, so an opening panel — which makes the row
// taller — slid them downward; switching the alignment on open was a
// second shift stacked on the first. Top alignment is unconditional
// now: closed, every item is the same height and it looks identical;
// open, the icons cannot move, because nothing about their position
// depends on the row's height.
func TestOpeningAMenuDoesNotMoveTheIcons(t *testing.T) {
i := strings.Index(sheet, ".chat-toolbar{")
if i < 0 {
t.Fatal("no toolbar rule in the sheet")
}
rule := sheet[i : i+strings.Index(sheet[i:], "}")]
if !strings.Contains(rule, "align-items:flex-start") {
t.Errorf("the toolbar does not pin its icons to the top: %s", rule)
}
// No rule may re-align the toolbar based on what is open — that is
// the layout shift, not the cure for it.
if strings.Contains(sheet, ".chat-toolbar:has(details[open]){align-items") {
t.Error("the toolbar still changes its alignment when something opens")
}
}
// Focusing the composer used to draw two rings at once: the sheet's
// global textarea:focus-visible outline inside — which a plain
// outline:none on the class loses to on specificity — and the panel's
// border turning accent outside. A box inside a glowing box, every
// time you tapped to type.
//
// A text field states its focus with a caret. What this pins is that
// the suppression is SCOPED: every other input in the forge keeps the
// ring it needs to say which of several it is.
func TestComposerHasNoFocusRing(t *testing.T) {
if !strings.Contains(sheet, ".composer-in:focus,.composer-in:focus-visible{outline:none}") {
t.Error("the composer still takes the global focus outline")
}
if strings.Contains(sheet, ".composer:focus-within{border-color") {
t.Error("the composer panel still lights up on focus")
}
// The global ring survives for everything else.
if !strings.Contains(sheet, "textarea:focus-visible { outline: 2px solid var(--muted)") {
t.Error("the forge-wide focus ring was removed along with the composer's")
}
}
// The linked message is ONE card. It used to draw an accent stripe down
// its left edge — beside the author rail every message already has —
// inside a tinted band, around a row that kept its own rounded corners
// and hover wash. Four edges and three surfaces for one message, which
// is what read as striated.
func TestLinkedMessageIsOneCardNotAStackOfEdges(t *testing.T) {
i := strings.Index(sheet, ".chat-anchor{")
if i < 0 {
t.Fatal("no anchor rule in the sheet")
}
rule := sheet[i : i+strings.Index(sheet[i:], "}")]
if strings.Contains(rule, "box-shadow:inset") {
t.Errorf("the card still draws a stripe beside the author rail: %s", rule)
}
if !strings.Contains(rule, "border:1px solid") {
t.Errorf("the card has no single border of its own: %s", rule)
}
// The message inside gives up the edges the card now carries.
if !strings.Contains(sheet, ".chat-anchor>.chatmsg{border-left-color:transparent") {
t.Error("the message inside the card keeps its own rail")
}
if !strings.Contains(sheet, ".chat-anchor>.chatmsg:hover,.chat-anchor>.chatmsg:focus-within{background:none}") {
t.Error("the hover wash still fights the card's surface")
}
}
// The channel and a permalink name the same room the same way. "Chat"
// was the tab's word, not the room's.
func TestChannelAndPermalinkAgreeOnTheName(t *testing.T) {
if got := repoChatTitle("alpha"); got != "#alpha" {
t.Errorf("repoChatTitle = %q, want #alpha", got)
}
}
// ONE line, four things: attach at the left, a spring, preview, Send.
// The script-only preview SHIPS HIDDEN — drawn on a page with no
// script it is a button that does nothing, which teaches the reader
// not to press buttons. The attach button is disabled and says why:
// the row's shape is part of what a composer teaches, and attachments
// land with the zit forum verbs.
func TestComposerStripIsOneLine(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
for _, want := range []string{
"#i-plus", "#i-eye", "#i-up",
`class="composer-spring"`,
"chat/preview.frag",
`id="chat-preview" class="chat-preview" hidden`,
`disabled title="Attachments land with durable archival"`,
} {
if !strings.Contains(got, want) {
t.Errorf("the strip is missing %q", want)
}
}
// Everything the strip shed stays shed: the hint, the note, the
// info circle, the mark-read control and its form.
for _, gone := range []string{"composer-hint", "composer-note", "c-info",
"chat-mark", `id="chat-read"`, "newline"} {
if strings.Contains(got, gone) {
t.Errorf("the strip still carries %q", gone)
}
}
// One line means one line: nothing may wrap the bar.
i := strings.Index(sheet, ".composer-bar{")
rule := sheet[i : i+strings.Index(sheet[i:], "}")]
if strings.Contains(rule, "flex-wrap") {
t.Errorf("the bar can wrap onto a second line: %s", rule)
}
}
// Where script runs, reaching the end of the log marks the channel
// read; the strip's button is the same verb for a page with no script.
// One data-effect carries both behaviours, because the effect plugin
// denies keyed attributes — a second data-effect:x would throw and
// abort the whole attribute walk.
func TestReadingToTheEndMarksRead(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
if strings.Count(got, "data-effect=") != 1 {
t.Fatalf("want exactly one data-effect, got %d", strings.Count(got, "data-effect="))
}
if strings.Contains(got, "data-effect:") {
t.Error("a keyed data-effect will throw: the plugin denies keys")
}
if !strings.Contains(got, "$chatMarked != $chatTick") ||
!strings.Contains(got, "/r/alpha/chat/read") {
t.Error("reaching the end does not mark the channel read")
}
// Signed out, no auto-read: the POST would only bounce off the
// capability gate, once per scroll.
anon := chatContent(t, pageData{Repo: "alpha"})
if strings.Contains(anon, "chat/read") {
t.Error("an anonymous reader fires read posts it cannot make")
}
}
// With no script the NEW divider would be a flag nobody could ever
// lower: reading-to-the-end needs script to observe. The check in the
// strip is the same verb by hand — shipped visible, hidden by CSS
// exactly where the automatic path exists, and drawn only while
// something is actually unread.
func TestNoScriptReadersCanLowerTheNewFlag(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan", ChatHasNew: true})
if !strings.Contains(got, `class="cbtn chat-read-btn" form="chat-read"`) ||
!strings.Contains(got, "#i-check") {
t.Error("no mark-read check in the strip")
}
if !strings.Contains(got, `id="chat-read" method="post" action="/r/alpha/chat/read"`) {
t.Error("the check has no form to submit")
}
if !strings.Contains(sheet, "body:has(.chatlog.js) .chat-read-btn{display:none}") {
t.Error("the check is not hidden where the automatic path exists")
}
// Nothing unread, nothing to lower.
read := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
if strings.Contains(read, "chat-read-btn") || strings.Contains(read, `id="chat-read"`) {
t.Error("a fully-read channel still offers mark-read")
}
}
// iOS zooms the whole page when a focused field computes under 16px,
// and the zoom sticks — the shell comes back letterboxed inside dark
// margins. The message editor sits inside .chatbody at 0.95rem, which
// is 15.2px: under the threshold. Every text field a thumb can focus
// must clear it.
func TestNoTextFieldTriggersIOSZoom(t *testing.T) {
i := strings.Index(sheet, ".chatedit textarea{")
if i < 0 {
t.Fatal("no editor rule")
}
rule := sheet[i : i+strings.Index(sheet[i:], "}")]
if !strings.Contains(rule, "font-size:max(16px, 1em)") {
t.Errorf("the editor can compute under 16px and zoom the page: %s", rule)
}
// The composer inherits body's 16px; pin the floor so a future
// body-size change cannot quietly re-arm the zoom.
if !strings.Contains(sheet, "font: 16px/1.5 system-ui") {
t.Error("body no longer anchors 16px — recheck every field against the iOS zoom threshold")
}
}
// A reference to a message already on the page must not reload the
// page: the script scrolls to it and flashes it, and the conversation
// stays exactly where it was. Without the script the link still works,
// opening the channel around the message.
func TestReferenceOnPageJumpsWithoutReload(t *testing.T) {
got := chatContent(t, pageData{Repo: "alpha", ChatViewer: "dan"})
_ = got
if !strings.Contains(string(chatJS), `log.querySelector('.chatmsg[data-seq="' + seq + '"]')`) {
t.Error("the jump does not look the message up on the page")
}
if !strings.Contains(string(chatJS), "ev.preventDefault()") ||
!strings.Contains(string(chatJS), "if (!row) return;") {
t.Error("the link is not left to navigate when the message is off the page")
}
// Only this channel's own permalinks: a cross-repository reference
// carries another repo in its path and must navigate.
if !strings.Contains(string(chatJS), `href.indexOf(here) !== 0`) {
t.Error("a reference into another channel could be mistaken for one here")
}
if !strings.Contains(sheet, ".chatmsg.chat-found{animation:chat-found") {
t.Error("the found message does not flash")
}
}
// Preview REPLACES the field. The field's own display:block outranks
// the UA's [hidden] rule, so without an explicit rule the attribute was
// set and the raw text stayed on screen above the rendered preview —
// the one state that shows the draft twice.
func TestPreviewHidesTheField(t *testing.T) {
if !strings.Contains(sheet, ".composer-in[hidden]{display:none}") {
t.Error("a hidden composer field still renders")
}
}