| new file mode 100644 |
| index 0000000..144976e |
| --- /dev/null |
| +++ b/internal/callstest/chrome_test.go |
| @@ -0,0 +1,420 @@ |
| +package callstest |
| + |
| +// The shared chrome (estilo spec/shared-chrome, round 2), black-box: |
| +// every page wears the 52px estilo bar with one account menu and one |
| +// Sign out, is titled "<Page> · Calls", answers errors as pages for a |
| +// browser and as text for a fetch, and sends a signed-out person back to |
| +// the page they asked for once they have signed in. |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/estilo.git/base" |
| + "amadan.net/oficina/estilo.git/chrome" |
| + estilomenu "amadan.net/oficina/estilo.git/menu" |
| +) |
| + |
| +var titleRe = regexp.MustCompile(`<title>([^<]*)</title>`) |
| + |
| +func pageTitle(t *testing.T, body string) string { |
| + t.Helper() |
| + m := titleRe.FindStringSubmatch(body) |
| + if m == nil { |
| + t.Fatalf("no <title>:\n%s", body) |
| + } |
| + return m[1] |
| +} |
| + |
| +// Spec §10: every page's <title> is "<Page> · … · Calls", the page's h1 |
| +// first, and the app root is "Calls" alone. |
| +func TestEveryPageIsTitledPageDotCalls(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + for path, want := range map[string]string{ |
| + "/": "Calls", |
| + "/settings": "Settings · Calls", |
| + "/m/" + sqid + "/join": "Board meeting · Calls", |
| + "/m/" + sqid: "Board meeting · Calls", |
| + "/meetings/" + sqid + "/settings": "Meeting settings · Board meeting · Calls", |
| + "/meetings/" + sqid + "/end": "End meeting for everyone? · Board meeting · Calls", |
| + "/help/open-source": "Open source · Calls", |
| + "/no/such/page": "Page not found · Calls", |
| + "/m/zzzzzzzzzz/join": "Meeting not available · Calls", |
| + } { |
| + if got := pageTitle(t, c.getNav(path).Body.String()); got != want { |
| + t.Errorf("GET %s: title %q, want %q", path, got, want) |
| + } |
| + } |
| + out := x.browser(t) |
| + for path, want := range map[string]string{ |
| + "/signin": "Sign in · Calls", |
| + "/signin?sent=1": "Check your email · Calls", |
| + "/signed-out": "Signed out · Calls", |
| + "/no/such/page": "Page not found · Calls", |
| + } { |
| + if got := pageTitle(t, out.getNav(path).Body.String()); got != want { |
| + t.Errorf("signed out GET %s: title %q, want %q", path, got, want) |
| + } |
| + } |
| +} |
| + |
| +// Spec §5 and §8: the estilo bar, not rastrillo's topbar; signed in, one |
| +// account menu holding the only Sign out; signed out, a "Sign in" that |
| +// comes back to this page; on the sign-in pages themselves, nothing. |
| +func TestPagesWearTheEstiloBarWithOneAccountMenu(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + for _, path := range []string{"/", "/settings", "/m/" + sqid + "/join", "/meetings/" + sqid + "/settings", "/help/open-source", "/no/such/page"} { |
| + body := c.getNav(path).Body.String() |
| + if !strings.Contains(body, "<header estilo-bar>") || strings.Contains(body, "rst-shell-bar") { |
| + t.Errorf("GET %s does not wear the estilo bar", path) |
| + } |
| + if n := strings.Count(body, ">Sign out<"); n != 1 { |
| + t.Errorf("GET %s has %d Sign out, want exactly 1", path, n) |
| + } |
| + if !strings.Contains(body, `<summary estilo-bar-trigger aria-label="Account: paul@example.com">`) { |
| + t.Errorf("GET %s has no account menu", path) |
| + } |
| + for _, want := range []string{`<span estilo-menu-identity-label>Signed in as</span>`, `<p estilo-menu-heading id="estilo-appearance">Appearance</p>`, `<a estilo-menu-item href="/help/open-source"><span estilo-menu-label>Open source</span></a>`} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("GET %s: the account menu lacks %q", path, want) |
| + } |
| + } |
| + for _, gone := range []string{"Help: open source", "rst-shell-account", ">Help</summary>"} { |
| + if strings.Contains(body, gone) { |
| + t.Errorf("GET %s still carries %q", path, gone) |
| + } |
| + } |
| + } |
| + if body := c.getNav("/settings").Body.String(); !strings.Contains(body, `<a href="/settings" aria-current="page">Settings</a>`) { |
| + t.Error("the bar's nav does not mark Settings as the current page") |
| + } |
| + out := x.browser(t) |
| + body := out.getNav("/no/such/page").Body.String() |
| + if !strings.Contains(body, `<a rst-btn href="/signin?return_to=%2Fno%2Fsuch%2Fpage" estilo-bar-signin>Sign in</a>`) || strings.Contains(body, "estilo-account") { |
| + t.Errorf("a signed-out page's corner is not Sign in, back to it:\n%s", body) |
| + } |
| + for _, path := range []string{"/signin", "/signin?sent=1", "/signed-out"} { |
| + body := out.getNav(path).Body.String() |
| + if !strings.Contains(body, "<header estilo-bar>") || strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") { |
| + t.Errorf("GET %s: the sign-in pages' corner must be empty", path) |
| + } |
| + } |
| +} |
| + |
| +// Spec §8.4: a guest gets the same "Sign in" corner, never an account. |
| +func TestAGuestGetsTheSignInCorner(t *testing.T) { |
| + x, sqid, token := memberWithMeeting(t) |
| + g := x.browser(t) |
| + invite := g.getNav("/j/" + token).Body.String() |
| + if !strings.Contains(invite, `<a rst-btn href="/signin" estilo-bar-signin>Sign in</a>`) { |
| + t.Errorf("the invitation page's corner must be a bare Sign in (its path is the capability):\n%s", invite) |
| + } |
| + if rec := g.post("/j/"+token+"/guest", url.Values{"name": {"Visitor"}}); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("guest join: %d", rec.Code) |
| + } |
| + body := g.getNav("/m/" + sqid + "/join").Body.String() |
| + if !strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") || strings.Contains(body, ">Sign out<") { |
| + t.Errorf("a guest's holding page corner is not Sign in:\n%s", body) |
| + } |
| + if !strings.Contains(body, "You’ll join as Visitor, a guest.") { |
| + t.Errorf("the holding page does not say who the guest joins as:\n%s", body) |
| + } |
| +} |
| + |
| +// Spec §3: an error is a page for a browser and plain text for anything |
| +// else, so the room's own fetches keep the answers room.js reads. |
| +func TestErrorPagesAreSplitByAccept(t *testing.T) { |
| + x, _, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + page := c.getNav("/no/such/page") |
| + if page.Code != http.StatusNotFound || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") || |
| + !strings.Contains(page.Body.String(), "We can’t find that page") || !strings.Contains(page.Body.String(), `<a rst-btn="primary" href="/">Go to Calls</a>`) { |
| + t.Errorf("unknown route for a browser: %d %q\n%s", page.Code, page.Header().Get("Content-Type"), page.Body.String()) |
| + } |
| + plain := c.get("/no/such/page") |
| + if plain.Code != http.StatusNotFound || !strings.HasPrefix(plain.Header().Get("Content-Type"), "text/plain") { |
| + t.Errorf("unknown route for a fetch: %d %q", plain.Code, plain.Header().Get("Content-Type")) |
| + } |
| + meeting := c.getNav("/m/zzzzzzzzzz/join") |
| + if meeting.Code != http.StatusNotFound || !strings.Contains(meeting.Body.String(), "This meeting isn’t available") { |
| + t.Errorf("missing meeting for a browser: %d\n%s", meeting.Code, meeting.Body.String()) |
| + } |
| + if fetch := c.get("/m/zzzzzzzzzz/info"); fetch.Code != http.StatusNotFound || !strings.HasPrefix(fetch.Header().Get("Content-Type"), "text/plain") { |
| + t.Errorf("missing meeting for a fetch: %d %q", fetch.Code, fetch.Header().Get("Content-Type")) |
| + } |
| + // A POST-only path opened from history is no page, not a 405. |
| + if rec := c.getNav("/signout"); rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "We can’t find that page") { |
| + t.Errorf("GET /signout: %d", rec.Code) |
| + } |
| + del := c.do(httptest.NewRequest(http.MethodDelete, "/signin", nil)) |
| + if del.Code != http.StatusMethodNotAllowed || !strings.Contains(del.Header().Get("Allow"), "GET") { |
| + t.Errorf("DELETE /signin: %d Allow %q", del.Code, del.Header().Get("Allow")) |
| + } |
| +} |
| + |
| +// Spec §9.4: a signed-out page request carries the page to sign-in, and |
| +// the emailed link lands back on it. The room's fetches keep today's |
| +// bare redirect, which room.js reads as "signed out". |
| +func TestSignInComesBackToTheDeepLink(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + rec := c.getNav("/meetings/" + sqid + "/settings") |
| + want := "/signin?return_to=" + url.QueryEscape("/meetings/"+sqid+"/settings") |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != want { |
| + t.Fatalf("signed-out deep link: %d %q, want %q", rec.Code, rec.Header().Get("Location"), want) |
| + } |
| + if fetch := c.getStream("/m/" + sqid + "/stream"); fetch.Code != http.StatusSeeOther || fetch.Header().Get("Location") != "/signin" { |
| + t.Errorf("the stream's signed-out answer changed: %d %q", fetch.Code, fetch.Header().Get("Location")) |
| + } |
| + door := c.getNav(want).Body.String() |
| + if !strings.Contains(door, `name="return_to" value="/meetings/`+sqid+`/settings"`) { |
| + t.Errorf("the door does not carry return_to:\n%s", door) |
| + } |
| + if rec := c.post("/signin", url.Values{"address": {"paul@example.com"}}); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /signin: %d", rec.Code) |
| + } |
| + // "Use a different address" keeps the destination (Docs #65, 2). |
| + sent := c.getNav("/signin?sent=1").Body.String() |
| + if !strings.Contains(sent, `href="/signin?return_to=`+url.QueryEscape("/meetings/"+sqid+"/settings")+`">Use a different address</a>`) { |
| + t.Errorf("Use a different address loses the destination:\n%s", sent) |
| + } |
| + _, link := x.mail.last() |
| + if dest := c.follow(link); dest != "/meetings/"+sqid+"/settings" { |
| + t.Fatalf("after the link: %q, want the deep link", dest) |
| + } |
| + // Signed in, /signin skips the form and goes where it was asked to. |
| + if rec := c.getNav("/signin?return_to=%2Fsettings"); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings" { |
| + t.Errorf("signed in, GET /signin: %d %q", rec.Code, rec.Header().Get("Location")) |
| + } |
| + // And a destination off this origin is never followed. |
| + if rec := c.getNav("/signin?return_to=%2F%2Fevil.example"); rec.Header().Get("Location") != "/" { |
| + t.Errorf("signed in, GET /signin?return_to=//evil.example: %q", rec.Header().Get("Location")) |
| + } |
| +} |
| + |
| +// The remembered page is sealed: a hand-written cookie is ignored. |
| +func TestAForgedReturnCookieIsIgnored(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.cookies["calls_return"] = &http.Cookie{Name: "calls_return", Value: "%2F%2Fevil.example"} |
| + if dest := c.signIn("paul@example.com"); dest != "/" { |
| + t.Fatalf("a forged return cookie was followed: %q", dest) |
| + } |
| +} |
| + |
| +// r3 audit C6: a malformed address is the field's own error, not |
| +// "Check your email" — and nothing is sent. |
| +func TestAMalformedAddressIsTheFieldsError(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + rec := c.post("/signin", url.Values{"address": {"not-an-email@"}}) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?err=address" { |
| + t.Fatalf("malformed address: %d %q", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if x.mail.count() != 0 { |
| + t.Fatal("a malformed address was mailed") |
| + } |
| +} |
| + |
| +// Paul, 2026-10-01: the door's button says "Sign in"; the sent page |
| +// says what happened in the suite's words (spec §9.2). |
| +func TestTheDoorSpeaksTheSuitesWords(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + door := c.getNav("/signin").Body.String() |
| + for _, want := range []string{`type="submit">Sign in</button>`, "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", `<a href="/help/open-source">Open source</a>`} { |
| + if !strings.Contains(door, want) { |
| + t.Errorf("the door lacks %q", want) |
| + } |
| + } |
| + if strings.Contains(door, "Email me a link") { |
| + t.Error("the door still says Email me a link") |
| + } |
| + sent := c.getNav("/signin?sent=1").Body.String() |
| + if !strings.Contains(sent, "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.") || strings.Contains(sent, "should be on its way") { |
| + t.Errorf("the sent page's copy:\n%s", sent) |
| + } |
| +} |
| + |
| +// Sign out lands on a page that says so (spec §9.2), not on a bare form. |
| +func TestSignOutLandsOnTheSignedOutPage(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + rec := c.post("/signout", nil) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signed-out" { |
| + t.Fatalf("POST /signout: %d %q", rec.Code, rec.Header().Get("Location")) |
| + } |
| + page := c.getNav("/signed-out") |
| + if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "You’re signed out") || !strings.Contains(page.Body.String(), `<a rst-btn="primary" href="/signin">Sign in</a>`) { |
| + t.Errorf("GET /signed-out: %d\n%s", page.Code, page.Body.String()) |
| + } |
| +} |
| + |
| +// Spec §8.3: Appearance is a cookie the layout writes into <html>. |
| +func TestAppearanceIsRememberedPerApp(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + rec := c.post("/appearance", url.Values{"mode": {"dark"}, "return_to": {"/settings"}}) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings" { |
| + t.Fatalf("POST /appearance: %d %q", rec.Code, rec.Header().Get("Location")) |
| + } |
| + body := c.getNav("/settings").Body.String() |
| + if !strings.Contains(body, `<html lang="en" dir="ltr" data-theme="dark">`) || !strings.Contains(body, `value="dark" aria-pressed="true"`) { |
| + t.Errorf("dark was not remembered:\n%s", body[:min(len(body), 600)]) |
| + } |
| +} |
| + |
| +// Real list rows (spec §2 of the audit): each meeting is an rst-row whose |
| +// actions are one ⋯ menu in estilo's floating menu, with one label for |
| +// ending a meeting. |
| +func TestMeetingsAreListRowsWithOneMenu(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + body := c.getNav("/").Body.String() |
| + for _, want := range []string{ |
| + `<span rst-row-main><a href="/m/` + sqid + `/join">Board meeting</a>`, |
| + `<details rst-row-menu name="estilo-menus">`, |
| + `<summary aria-label="Actions for Board meeting">`, |
| + `<a estilo-menu-item class="calls-menu-item-danger" href="/meetings/` + sqid + `/end"><span estilo-menu-label>End meeting for everyone</span></a>`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("the meeting list lacks %q", want) |
| + } |
| + } |
| + if strings.Contains(body, "<article>") { |
| + t.Error("the meeting list still renders bare articles") |
| + } |
| +} |
| + |
| +// r3 audit C4 / spec S10: Start a call names the call in the starter's |
| +// own time, and without script names the zone it used. |
| +func TestStartACallTitlesInTheStartersZone(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + tokyo, _ := time.LoadLocation("Asia/Tokyo") |
| + rec := c.post("/meetings/start", url.Values{"zone": {"Asia/Tokyo"}}) |
| + if want := "Call, " + x.now.In(tokyo).Format("2 Jan 15:04"); !strings.Contains(rec.Body.String(), want) { |
| + t.Errorf("with the browser's zone the title is not %q:\n%s", want, rec.Body.String()) |
| + } |
| + rec = c.post("/meetings/start", nil) |
| + if want := "Call, " + x.now.UTC().Format("2 Jan 15:04") + " UTC"; !strings.Contains(rec.Body.String(), want) { |
| + t.Errorf("without one the title is not %q", want) |
| + } |
| +} |
| + |
| +// Spec §4's grep gate: one notice vocabulary, rastrillo's callout in a |
| +// tone. rst-notice is always success green, and carried the relay |
| +// warning and "open in another tab". |
| +func TestTemplatesUseCalloutTonesOnly(t *testing.T) { |
| + bad := regexp.MustCompile(`rst-notice|rst-callout="|rst-tone="warn"`) |
| + n := 0 |
| + err := filepath.WalkDir(filepath.Join("..", "calls", "templates"), func(path string, d os.DirEntry, err error) error { |
| + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") { |
| + return err |
| + } |
| + n++ |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + return err |
| + } |
| + if m := bad.Find(b); m != nil { |
| + t.Errorf("%s uses %q", path, m) |
| + } |
| + return nil |
| + }) |
| + if err != nil || n < 10 { |
| + t.Fatalf("walked %d templates: %v", n, err) |
| + } |
| +} |
| + |
| +// Spec §3.3's second gate: every remaining plain-text http.Error is |
| +// listed here with why it is not a page. A new one fails until it is |
| +// either a page (errorPage) or classified. |
| +func TestEveryPlainTextErrorIsClassified(t *testing.T) { |
| + classified := map[string]int{ |
| + // The native app's JSON endpoints (/native/*): a Swift client |
| + // reads the status, and a page is the wrong answer to it. |
| + "native_auth.go": 12, |
| + // POST /native/devices: JSON from the native app. |
| + "knockpush.go": 7, |
| + // methodNotAllowed's 405 (no person navigates with DELETE) and |
| + // plainError, the answer to a request that did not ask for a page. |
| + "errors.go": 2, |
| + // render's last resort when a template itself fails. |
| + "render.go": 1, |
| + } |
| + files, err := filepath.Glob(filepath.Join("..", "calls", "*.go")) |
| + if err != nil || len(files) < 20 { |
| + t.Fatalf("glob: %d files, %v", len(files), err) |
| + } |
| + got := map[string]int{} |
| + for _, f := range files { |
| + if strings.HasSuffix(f, "_test.go") { |
| + continue |
| + } |
| + b, err := os.ReadFile(f) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := strings.Count(string(b), "http.Error("); n > 0 { |
| + got[filepath.Base(f)] = n |
| + } |
| + } |
| + for f, n := range got { |
| + if classified[f] != n { |
| + t.Errorf("%s has %d http.Error calls, %d classified: make it a page, or classify it here with the reason", f, n, classified[f]) |
| + } |
| + } |
| + for f, n := range classified { |
| + if got[f] != n { |
| + t.Errorf("%s: %d classified, %d found", f, n, got[f]) |
| + } |
| + } |
| +} |
| + |
| +// The two-pin check (estilo AGENTS.md; spec §12.1 item 1): Calls pins |
| +// rastrillo v0.26, estilo's own token tests run against v0.23, so every |
| +// var(--rst-*) estilo's base, menu and chrome sheets use must be defined |
| +// in the tokens.css and theme.css Calls actually serves. |
| +func TestEstilosChromeUsesTokensCallsDefines(t *testing.T) { |
| + var defined strings.Builder |
| + for _, f := range []string{"tokens.css", "theme.css"} { |
| + b, err := os.ReadFile(filepath.Join("..", "calls", "static", f)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defined.Write(b) |
| + } |
| + defs := map[string]bool{} |
| + for _, m := range regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:`).FindAllStringSubmatch(defined.String(), -1) { |
| + defs[m[1]] = true |
| + } |
| + use := regexp.MustCompile(`var\((--rst-[a-z0-9-]+)`) |
| + for name, css := range map[string][]byte{"base.css": base.CSS(), "menu.css": estilomenu.CSS(), "chrome.css": chrome.CSS()} { |
| + uses := use.FindAllSubmatch(css, -1) |
| + if len(uses) == 0 && name != "base.css" { |
| + t.Errorf("%s uses no tokens: this check proves nothing", name) |
| + } |
| + for _, m := range uses { |
| + if !defs[string(m[1])] { |
| + t.Errorf("estilo %s uses %s, which Calls' tokens.css and theme.css do not define", name, m[1]) |
| + } |
| + } |
| + } |
| +} |