Test: Home's pages on the shared chrome (red)
shared_chrome_test.go states round 2's Home row (estilo spec shared-chrome §12.1 row 8) and the r3 audit's Home findings, and fails on this tree: - TestHomePagesWearTheSharedBar: every page but the front page sits under estilo's bar, branded Oficina. On the directory a signed-in person has one account menu (Passkeys and Recovery codes on id.*, Appearance, Help > Open source, exactly one Sign out); signed out, the corner is Sign in. id.* pages are the door with a brand-only bar. The sky only where Calma puts it: the door, the team home header, the front page. - TestFrontPageKeepsItsOwnDesign: no app bar; the title is "Oficina". - TestAppearanceSetsTheTheme: /appearance sets the theme. - TestErrorPagesSplitByAccept: estilo's 404 page on both origins for a browser, plain text otherwise (it was text/plain for everyone). - TestStartingAgainKeepsTheDestination: r3 C1. - TestHopKeepsTheSkyPlaceholder: r3 C2. - TestColdLoadPreloadsTheFontAndMarks: r3 C4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+302
−0
internal/home/shared_chrome_test.go+302 −0
diff --git a/internal/home/shared_chrome_test.go b/internal/home/shared_chrome_test.go| new file mode 100644 |
| index 0000000..644f54f |
| --- /dev/null |
| +++ b/internal/home/shared_chrome_test.go |
| @@ -0,0 +1,302 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/internal/home/sealtest" |
| +) |
| + |
| +// The shared chrome (estilo spec shared-chrome, §12.1 row 8, Home): every |
| +// page Home draws for a person, other than the public front page, sits |
| +// under estilo's 52px bar with the brand "Oficina". On the directory, a |
| +// signed-in person has one account menu (who, Passkeys and Recovery codes |
| +// on id.*, Appearance, Help → Open source, one Sign out); signed out, the |
| +// corner is "Sign in". id.* pages are the door: the bar carries the brand |
| +// only, linking back to the directory. |
| + |
| +// chromeGet is a GET with an Accept header (empty sends none). |
| +func chromeGet(h http.Handler, target, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest(http.MethodGet, target, nil) |
| + if accept != "" { |
| + r.Header.Set("Accept", accept) |
| + } |
| + for _, c := range cookies { |
| + if c != nil { |
| + r.AddCookie(c) |
| + } |
| + } |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, r) |
| + return rec |
| +} |
| + |
| +var pageTitle = regexp.MustCompile(`<title>([^<]*)</title>`) |
| + |
| +func titleOf(body string) string { |
| + m := pageTitle.FindStringSubmatch(body) |
| + if m == nil { |
| + return "" |
| + } |
| + return m[1] |
| +} |
| + |
| +const acceptHTML = "text/html,application/xhtml+xml" |
| + |
| +func TestHomePagesWearTheSharedBar(t *testing.T) { |
| + f := newMountedChooser(t) |
| + directory := f.p.directory.Router() |
| + credential := f.p.credential.Router() |
| + person := f.session(t, chooserPerson) |
| + idSession := signInFully(t, f.p.credential, sealtest.CanaryAddress) |
| + |
| + type want struct { |
| + title string |
| + account bool // the account menu, and with it exactly one Sign out |
| + signIn bool // the signed-out corner's "Sign in" |
| + sky bool |
| + brand string // the brand link's href |
| + } |
| + cases := []struct { |
| + name string |
| + h http.Handler |
| + target string |
| + cookie *http.Cookie |
| + want want |
| + }{ |
| + {"directory", directory, directoryOrigin + "/", person, want{"Oficina", true, false, true, "/"}}, |
| + {"team chooser", directory, directoryOrigin + docsEntryPath, person, want{"No teams available · Oficina", true, false, false, "/"}}, |
| + {"open source, signed in", directory, directoryOrigin + "/help/open-source", person, want{"Open source · Oficina", true, false, false, "/"}}, |
| + {"open source, signed out", directory, directoryOrigin + "/help/open-source", nil, want{"Open source · Oficina", false, true, false, "/"}}, |
| + {"signed out", directory, directoryOrigin + "/signed-out", nil, want{"Signed out · Oficina", false, false, true, "/"}}, |
| + {"id.* sign in", credential, Origin + "/signin", nil, want{"Sign in · Oficina", false, false, true, directoryOrigin + "/"}}, |
| + {"id.* check your email", credential, Origin + "/signin/sent", nil, want{"Check your email · Oficina", false, false, true, directoryOrigin + "/"}}, |
| + {"id.* recovery codes", credential, Origin + "/passkey/codes", idSession, want{"Recovery codes · Oficina", false, false, true, directoryOrigin + "/"}}, |
| + {"id.* add a passkey", credential, Origin + "/passkey/enrol", idSession, want{"Add a passkey · Oficina", false, false, true, directoryOrigin + "/"}}, |
| + {"id.* open source", credential, Origin + "/help/open-source", nil, want{"Open source · Oficina", false, false, false, directoryOrigin + "/"}}, |
| + {"id.* sign out", credential, Origin + "/signout", nil, want{"Finish signing out · Oficina", false, false, true, directoryOrigin + "/"}}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + rec := chromeGet(c.h, c.target, acceptHTML, c.cookie) |
| + body := rec.Body.String() |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("status %d", rec.Code) |
| + } |
| + if got := titleOf(body); got != c.want.title { |
| + t.Errorf("title %q, want %q", got, c.want.title) |
| + } |
| + if strings.Count(body, "<header estilo-bar") != 1 { |
| + t.Errorf("want exactly one estilo-bar") |
| + } |
| + if !strings.Contains(body, `<a estilo-bar-brand href="`+c.want.brand+`">`) || !strings.Contains(body, `<span estilo-bar-name>Oficina</span>`) { |
| + t.Errorf("the brand is not \"Oficina\" linking to %s", c.want.brand) |
| + } |
| + if strings.Contains(body, "rst-dropdown") || strings.Contains(body, "home-hub-head><h1 estilo-brand") { |
| + t.Errorf("the old lone Help menu or hub header is still drawn") |
| + } |
| + if got := strings.Contains(body, "<details estilo-account"); got != c.want.account { |
| + t.Errorf("account menu: %v, want %v", got, c.want.account) |
| + } |
| + signOuts := strings.Count(body, ">Sign out<") |
| + if c.want.account { |
| + if signOuts != 1 { |
| + t.Errorf("%d Sign out buttons, want exactly one", signOuts) |
| + } |
| + for _, item := range []string{ |
| + `href="` + Origin + `/passkey/enrol"`, |
| + `href="` + Origin + `/passkey/codes"`, |
| + `action="/appearance"`, |
| + `href="/help/open-source"`, |
| + `<form method="post" action="/signout">`, |
| + } { |
| + if !strings.Contains(body, item) { |
| + t.Errorf("the account menu lacks %s", item) |
| + } |
| + } |
| + } else if c.name != "id.* sign out" && signOuts != 0 { |
| + t.Errorf("%d Sign out buttons with no account menu", signOuts) |
| + } |
| + if got := strings.Contains(body, "estilo-bar-signin"); got != c.want.signIn { |
| + t.Errorf("Sign in corner: %v, want %v", got, c.want.signIn) |
| + } |
| + if got := strings.Contains(body, "<div estilo-sky "); got != c.want.sky { |
| + t.Errorf("sky: %v, want %v (sign-in, team home header and front page only)", got, c.want.sky) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The front page keeps its own design: no app bar. Its title is the hub's |
| +// name alone (spec §10), not "Oficina — open your new office suite". |
| +func TestFrontPageKeepsItsOwnDesign(t *testing.T) { |
| + f := newMountedChooser(t) |
| + body := chromeGet(f.p.directory.Router(), directoryOrigin+"/", acceptHTML).Body.String() |
| + if strings.Contains(body, "estilo-bar") { |
| + t.Error("the public front page has the app bar") |
| + } |
| + if got := titleOf(body); got != "Oficina" { |
| + t.Errorf("front page title %q, want \"Oficina\"", got) |
| + } |
| +} |
| + |
| +// Appearance: a signed-in person's choice, kept per host (spec §8.3). |
| +func TestAppearanceSetsTheTheme(t *testing.T) { |
| + f := newMountedChooser(t) |
| + person := f.session(t, chooserPerson) |
| + rec := f.request("POST", "/appearance", directoryOrigin, url.Values{"mode": {"dark"}, "return_to": {"/"}}.Encode(), person) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { |
| + t.Fatalf("POST /appearance: %d %q", rec.Code, rec.Header().Get("Location")) |
| + } |
| + var mode *http.Cookie |
| + for _, c := range rec.Result().Cookies() { |
| + if c.Name == "estilo_appearance" { |
| + mode = c |
| + } |
| + } |
| + if mode == nil || mode.Value != "dark" { |
| + t.Fatalf("no estilo_appearance=dark cookie: %v", rec.Result().Cookies()) |
| + } |
| + body := chromeGet(f.p.directory.Router(), directoryOrigin+"/", acceptHTML, person, mode).Body.String() |
| + if !strings.Contains(body, `<html lang="en" data-theme="dark">`) { |
| + t.Errorf("the directory does not draw the chosen theme: %.120s", body) |
| + } |
| + if !strings.Contains(body, `value="dark" aria-pressed="true"`) { |
| + t.Error("Dark is not shown as the current appearance") |
| + } |
| +} |
| + |
| +// estilo's error page on both origins, for a browser; everything else |
| +// keeps a plain-text answer (spec §3.3). The 404 used to be chi's bare |
| +// "404 page not found" whatever was asking. |
| +func TestErrorPagesSplitByAccept(t *testing.T) { |
| + f := newMountedChooser(t) |
| + person := f.session(t, chooserPerson) |
| + cases := []struct { |
| + name string |
| + h http.Handler |
| + target string |
| + cookie *http.Cookie |
| + account bool |
| + }{ |
| + {"directory, signed in", f.p.directory.Router(), directoryOrigin + "/no/such/page", person, true}, |
| + {"directory, signed out", f.p.directory.Router(), directoryOrigin + "/no/such/page", nil, false}, |
| + {"directory, GET to a POST-only path", f.p.directory.Router(), directoryOrigin + "/appearance", person, true}, |
| + {"id.*", f.p.credential.Router(), Origin + "/no/such/page", nil, false}, |
| + {"id.*, GET to a POST-only path", f.p.credential.Router(), Origin + "/passkey/signin/begin", nil, false}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + rec := chromeGet(c.h, c.target, acceptHTML, c.cookie) |
| + body := rec.Body.String() |
| + if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { |
| + t.Fatalf("browser: %d %q, want a 404 page", rec.Code, rec.Header().Get("Content-Type")) |
| + } |
| + if titleOf(body) != "Page not found · Oficina" || !strings.Contains(body, "<div rst-error>") || !strings.Contains(body, "We can’t find that page") || !strings.Contains(body, "<header estilo-bar") { |
| + t.Errorf("not estilo's error page under the bar: %q", titleOf(body)) |
| + } |
| + if got := strings.Contains(body, "<details estilo-account"); got != c.account { |
| + t.Errorf("account menu %v, want %v", got, c.account) |
| + } |
| + if rec.Header().Get("Cache-Control") != "no-store" { |
| + t.Errorf("Cache-Control %q", rec.Header().Get("Cache-Control")) |
| + } |
| + for _, accept := range []string{"", "*/*", "application/json"} { |
| + rec := chromeGet(c.h, c.target, accept, c.cookie) |
| + if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") { |
| + t.Errorf("Accept %q: %d %q, want a plain 404", accept, rec.Code, rec.Header().Get("Content-Type")) |
| + } |
| + } |
| + }) |
| + } |
| + // An unknown host is not a page anyone navigates to: still empty. |
| + rec := chromeGet(f.p.directory.Router(), "https://elsewhere.example/no/such/page", acceptHTML) |
| + if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 { |
| + t.Errorf("unknown host: %d %q", rec.Code, rec.Body.String()) |
| + } |
| +} |
| + |
| +// "Use a different address" and "Start again" keep where you were going |
| +// (r3 C1). Both were a bare /signin, which forgets return_to: the magic |
| +// link then ended at id.* "/", which sends you to the signed-out front |
| +// page, and a sign-in from an app lost the app. |
| +func TestStartingAgainKeepsTheDestination(t *testing.T) { |
| + app := newTestApp(t) |
| + h := app.Router() |
| + form := chromeGet(h, Origin+"/signin?return_to=%2Fauthorize%2Fresume", acceptHTML) |
| + var keep *http.Cookie |
| + for _, c := range form.Result().Cookies() { |
| + if c.Name == returnCookie && c.Value != "" { |
| + keep = c |
| + } |
| + } |
| + if keep == nil { |
| + t.Fatal("the form did not remember return_to") |
| + } |
| + const again = `href="/signin?return_to=%2Fauthorize%2Fresume"` |
| + sent := chromeGet(h, Origin+"/signin/sent", acceptHTML, keep).Body.String() |
| + if !strings.Contains(sent, again+">Use a different address") { |
| + t.Errorf("Use a different address forgets the destination:\n%s", sent) |
| + } |
| + r := httptest.NewRequest(http.MethodPost, Origin+linkPath, strings.NewReader("token=nope")) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + r.Header.Set("Origin", Origin) |
| + r.AddCookie(keep) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, r) |
| + if !strings.Contains(rec.Body.String(), again+">Start again") { |
| + t.Errorf("Start again forgets the destination:\n%s", rec.Body.String()) |
| + } |
| + // With nowhere remembered, both stay a plain /signin. |
| + if body := chromeGet(h, Origin+"/signin/sent", acceptHTML).Body.String(); !strings.Contains(body, `href="/signin">Use a different address`) { |
| + t.Errorf("with no destination: %s", body) |
| + } |
| +} |
| + |
| +// The hand-off hop keeps the sky's inline placeholder (r3 C2): it used to |
| +// draw neither sky nor card, so every sign-in flashed a blank page between |
| +// two skies. Only the placeholder: it is a data: URI, so the hop still |
| +// fetches nothing it will not show. |
| +func TestHopKeepsTheSkyPlaceholder(t *testing.T) { |
| + p := newExchangePair(t) |
| + credential := signInFully(t, p.credential, sealtest.CanaryAddress) |
| + _, intent := p.start(t) |
| + rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, credential) |
| + body := rec.Body.String() |
| + if rec.Code != http.StatusOK || !strings.Contains(body, `name="assertion"`) { |
| + t.Fatalf("resume: %d", rec.Code) |
| + } |
| + if !strings.Contains(body, `estilo-sky-layer="placeholder" estilo-sky-tone="light" src="data:`) { |
| + t.Error("the hop draws no sky") |
| + } |
| + if strings.Contains(body, "grain.svg") || strings.Contains(body, "filaments.svg") || strings.Contains(body, "busy.js") { |
| + t.Error("the hop fetches sky layers or scripts it will not show") |
| + } |
| +} |
| + |
| +// A cold first load paints the mark and Onest at once (r3 C4): the font |
| +// and the brand pictures are preloaded, on the front page and every page |
| +// in the layout. |
| +func TestColdLoadPreloadsTheFontAndMarks(t *testing.T) { |
| + f := newMountedChooser(t) |
| + font := `<link rel="preload" href="/static/estilo/fonts/v1/onest-latin.woff2" as="font" type="font/woff2" crossorigin>` |
| + front := chromeGet(f.p.directory.Router(), directoryOrigin+"/", acceptHTML).Body.String() |
| + signin := chromeGet(f.p.credential.Router(), Origin+"/signin", acceptHTML).Body.String() |
| + for name, body := range map[string]string{"front page": front, "id.* sign in": signin} { |
| + if !strings.Contains(body, font) { |
| + t.Errorf("%s: Onest is not preloaded", name) |
| + } |
| + if !strings.Contains(body, `<link rel="preload" href="/static/brand/icon.svg?v=`) { |
| + t.Errorf("%s: the Oficina mark is not preloaded", name) |
| + } |
| + } |
| + for _, app := range []string{"docs", "sheets", "slides", "calendar", "calls", "memoria"} { |
| + if !strings.Contains(front, `<link rel="preload" href="/static/brand/`+app+`.svg?v=`) { |
| + t.Errorf("front page: %s's mark is not preloaded", app) |
| + } |
| + } |
| +} |