| 1 | package chrome |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "html/template" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "net/url" |
| 9 | "os/exec" |
| 10 | "regexp" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "amadan.net/oficina/estilo.git/internal/tokencheck" |
| 15 | "amadan.net/oficina/estilo.git/menu" |
| 16 | "amadan.net/oficina/estilo.git/sky" |
| 17 | "github.com/carlosframework/rastrillo/ui" |
| 18 | ) |
| 19 | |
| 20 | func TestChromeUsesDefinedTokens(t *testing.T) { |
| 21 | missing, n := tokencheck.Undefined(CSS()) |
| 22 | if n == 0 { |
| 23 | t.Fatal("chrome.css reads no rastrillo token; the check would pass vacuously") |
| 24 | } |
| 25 | if len(missing) > 0 { |
| 26 | t.Fatal("chrome.css reads undefined tokens:", missing) |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | // The bar's height is public contract and must be fixed, never a minimum: |
| 31 | // nothing inside it may grow it. |
| 32 | func TestBarHeightIsFixedAndPublic(t *testing.T) { |
| 33 | src := string(CSS()) |
| 34 | for _, want := range []string{ |
| 35 | "--estilo-bar-block-size: 3.25rem;", |
| 36 | "--estilo-z-bar: 45;", |
| 37 | "block-size: var(--estilo-bar-block-size);", |
| 38 | "flex-wrap: nowrap;", |
| 39 | } { |
| 40 | if !strings.Contains(src, want) { |
| 41 | t.Errorf("chrome.css lost %q", want) |
| 42 | } |
| 43 | } |
| 44 | // Narrow: the title field may shrink to nothing and the Rename button |
| 45 | // floats under it rather than taking room from Menu and the switcher. |
| 46 | // The browser proof is polish-audit/r2-chrome-check.mjs's reach checks. |
| 47 | narrow := src[strings.LastIndex(src, "@media (max-width: 799.98px)"):] |
| 48 | for _, want := range []string{"[estilo-bar-title-input] { min-inline-size: 0; }", "[estilo-bar-title]:focus-within [estilo-bar-title-submit] {\n position: absolute;"} { |
| 49 | if !strings.Contains(narrow, want) { |
| 50 | t.Errorf("the narrow title layout lost %q", want) |
| 51 | } |
| 52 | } |
| 53 | if regexp.MustCompile(`\[estilo-bar\]\s*\{[^}]*min-block-size`).MatchString(src) { |
| 54 | t.Error("the bar declares a min-block-size: it could grow") |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | func parse(t *testing.T) *template.Template { |
| 59 | t.Helper() |
| 60 | view := template.New("").Funcs(ui.Funcs()) |
| 61 | view, err := view.ParseFS(ui.Templates(), "*.html") |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | if view, err = view.ParseFS(menu.Templates(), "templates/*.html"); err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | if view, err = view.ParseFS(Templates(), "templates/*.html"); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | return view |
| 72 | } |
| 73 | |
| 74 | func render(t *testing.T, name string, data any) string { |
| 75 | t.Helper() |
| 76 | var out bytes.Buffer |
| 77 | if err := parse(t).ExecuteTemplate(&out, name, data); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | return out.String() |
| 81 | } |
| 82 | |
| 83 | // One word per locale, used for every caller-supplied label, so a label |
| 84 | // the template dropped or hard-coded shows up as a missing string. |
| 85 | var locales = map[string]string{"en": "Account", "de": "Konto", "es": "Cuenta", "fr": "Compte", "it": "Account", "pt": "Conta", "nl": "Account", "pl": "Konto", "ar": "الحساب", "ja": "アカウント", "zh": "账户", "ko": "계정"} |
| 86 | |
| 87 | func signedIn(label string) Bar { |
| 88 | return Bar{ |
| 89 | App: App{Name: label + "-app", Slug: "docs", Home: "/", Icon: "/static/estilo/brand/docs.svg"}, |
| 90 | ContextEdit: &TitleEdit{Action: "/d/abc/title", Name: "title", Value: label + "-title", Label: label + "-title-label", Submit: label + "-rename"}, |
| 91 | Nav: []Link{{Href: "/", Label: label + "-nav", Current: true}}, |
| 92 | NavLabel: label + "-navlabel", |
| 93 | Actions: []Action{ |
| 94 | {Item: menu.Item{Href: "/d/abc/share", Label: label + "-share"}}, |
| 95 | {Item: menu.Item{Action: "/d/new", Label: label + "-create"}, Primary: true}, |
| 96 | }, |
| 97 | Switcher: &Switcher{Label: label + "-apps", AllApps: menu.Item{Href: "https://oficina.example/", Label: label + "-allapps"}}, |
| 98 | Account: &Account{ |
| 99 | TriggerLabel: label + "-trigger", Initials: "P", Address: "paul@example.test", |
| 100 | Labels: AccountLabels{SignedInAs: label + "-signedinas", Appearance: label + "-appearance", Help: label + "-help"}, |
| 101 | Items: []menu.Item{{Href: "/account/home", Label: label + "-connect"}}, |
| 102 | Appearance: &AppearanceMenu{Action: "/appearance", Return: "/d/abc", Options: AppearanceOptions(Dark, label+"-light", label+"-dark", label+"-system")}, |
| 103 | Help: []menu.Item{{Href: "/credits", Label: label + "-opensource"}}, |
| 104 | SignOut: SignOut{Action: "/signout", Label: label + "-signout"}, |
| 105 | }, |
| 106 | Labels: BarLabels{Skip: label + "-skip", Menu: label + "-menu"}, |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestBarRendersCallerLabelsInEveryLocale(t *testing.T) { |
| 111 | for locale, label := range locales { |
| 112 | t.Run(locale, func(t *testing.T) { |
| 113 | html := render(t, "estilo-bar", signedIn(label)) |
| 114 | esc := template.HTMLEscapeString |
| 115 | for _, want := range []string{ |
| 116 | `<a estilo-bar-brand href="/">`, esc(label) + "-app", |
| 117 | `<form estilo-bar-title method="post" action="/d/abc/title">`, `value="` + esc(label) + `-title"`, `aria-label="` + esc(label) + `-title-label"`, esc(label) + "-rename", |
| 118 | `aria-label="` + esc(label) + `-navlabel"`, `aria-current="page"`, |
| 119 | esc(label) + "-share", esc(label) + "-create", esc(label) + "-menu", |
| 120 | `aria-label="` + esc(label) + `-apps"`, esc(label) + "-allapps", |
| 121 | `aria-label="` + esc(label) + `-trigger"`, esc(label) + "-signedinas", "paul@example.test", |
| 122 | esc(label) + "-connect", esc(label) + "-appearance", esc(label) + "-light", esc(label) + "-dark", esc(label) + "-system", |
| 123 | esc(label) + "-help", esc(label) + "-opensource", esc(label) + "-signout", |
| 124 | } { |
| 125 | if !strings.Contains(html, want) { |
| 126 | t.Fatalf("missing %q", want) |
| 127 | } |
| 128 | } |
| 129 | if strings.Contains(html, "<script") { |
| 130 | t.Fatal("the bar requires no script") |
| 131 | } |
| 132 | }) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | // The "same corner" rule: the account is the last element in the end |
| 137 | // group, after actions, Menu and the switcher, in that order. |
| 138 | func TestEndGroupOrderIsFixed(t *testing.T) { |
| 139 | html := render(t, "estilo-bar", signedIn("x")) |
| 140 | order := []string{"<div estilo-bar-actions>", "<details estilo-bar-more", "<details estilo-switcher", "<details estilo-account"} |
| 141 | last := -1 |
| 142 | for _, marker := range order { |
| 143 | i := strings.Index(html, marker) |
| 144 | if i < 0 || i < last { |
| 145 | t.Fatalf("%q is missing or out of order", marker) |
| 146 | } |
| 147 | last = i |
| 148 | } |
| 149 | tail := html[strings.LastIndex(html, "</details>"):] |
| 150 | if strings.Count(tail, "<") != 3 { // </details> </div> </header> |
| 151 | t.Fatalf("something follows the account in the end group: %q", tail) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // Every top-level trigger shares one exclusivity group, so opening one |
| 156 | // closes the others; nothing nested inside a panel carries the name. |
| 157 | func TestEveryBarTriggerJoinsTheOneMenuGroup(t *testing.T) { |
| 158 | html := render(t, "estilo-bar", signedIn("x")) |
| 159 | details := regexp.MustCompile(`<details [^>]*>`).FindAllString(html, -1) |
| 160 | if len(details) != 3 { |
| 161 | t.Fatalf("want three bar disclosures, got %d", len(details)) |
| 162 | } |
| 163 | for _, d := range details { |
| 164 | if !strings.Contains(d, `name="estilo-menus"`) { |
| 165 | t.Errorf("%s is not in the estilo-menus group", d) |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func TestSignOutAppearsExactlyOnce(t *testing.T) { |
| 171 | html := render(t, "estilo-bar", signedIn("x")) |
| 172 | if n := strings.Count(html, "x-signout"); n != 1 { |
| 173 | t.Fatalf("Sign out rendered %d times", n) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | func TestAppearancePressedStateOnEveryOption(t *testing.T) { |
| 178 | html := render(t, "estilo-bar", signedIn("x")) |
| 179 | pressed := regexp.MustCompile(`value="(light|dark|system)" aria-pressed="(true|false)"`).FindAllStringSubmatch(html, -1) |
| 180 | if len(pressed) != 3 { |
| 181 | t.Fatalf("want three appearance options with aria-pressed, got %v", pressed) |
| 182 | } |
| 183 | for _, p := range pressed { |
| 184 | if (p[1] == "dark") != (p[2] == "true") { |
| 185 | t.Errorf("%s pressed=%s, want only dark pressed", p[1], p[2]) |
| 186 | } |
| 187 | } |
| 188 | if !strings.Contains(html, `role="group" aria-labelledby="estilo-appearance"`) { |
| 189 | t.Error("the appearance options are not a labelled group") |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | func TestSignedOutCornerAndEmptyCorner(t *testing.T) { |
| 194 | b := Bar{App: App{Name: "Docs", Home: "/", Icon: "/i.svg"}, SignIn: &Link{Href: "/signin?return_to=%2Fd%2Fabc", Label: "Sign in"}} |
| 195 | html := render(t, "estilo-bar", b) |
| 196 | if !strings.Contains(html, `<a rst-btn href="/signin?return_to=%2Fd%2Fabc" estilo-bar-signin>Sign in</a>`) { |
| 197 | t.Fatal("signed-out corner missing", html) |
| 198 | } |
| 199 | if strings.Contains(html, "estilo-account") || strings.Contains(html, "estilo-bar-more") { |
| 200 | t.Fatal("signed-out bar has an account or Menu", html) |
| 201 | } |
| 202 | b.SignIn = nil |
| 203 | if html = render(t, "estilo-bar", b); strings.Contains(html, "estilo-bar-signin") { |
| 204 | t.Fatal("sign-in page bar shows Sign in", html) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | func TestPublicBrandIsPlainText(t *testing.T) { |
| 209 | html := render(t, "estilo-bar", Bar{App: App{Name: "Docs", Icon: "/i.svg"}, Context: "A document"}) |
| 210 | if strings.Contains(html, "<a estilo-bar-brand") || !strings.Contains(html, "<span estilo-bar-brand>") { |
| 211 | t.Fatal("public brand is a link", html) |
| 212 | } |
| 213 | if !strings.Contains(html, `<span estilo-bar-context dir="auto" title="A document">A document</span>`) { |
| 214 | t.Fatal("read-only context missing", html) |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | func TestSwitcherWithoutTilesIsAllAppsOnly(t *testing.T) { |
| 219 | b := signedIn("x") |
| 220 | html := render(t, "estilo-bar", b) |
| 221 | if strings.Contains(html, "estilo-switcher-grid") || strings.Contains(html, "estilo-switcher-app") { |
| 222 | t.Fatal("a switcher with no tiles renders a grid") |
| 223 | } |
| 224 | b.Switcher.Apps = []SwitcherApp{{Name: "Docs", Href: "https://dir.example/apps/docs", Icon: "/d.svg", Current: true}, {Name: "Sheets", Href: "https://dir.example/apps/sheets", Icon: "/s.svg"}} |
| 225 | b.Switcher.CurrentLabel = "(this app)" |
| 226 | html = render(t, "estilo-bar", b) |
| 227 | if !strings.Contains(html, `aria-current="true"`) || !strings.Contains(html, "(this app)") { |
| 228 | t.Fatal("the current tile is not marked in words", html) |
| 229 | } |
| 230 | if strings.Count(html, "(this app)") != 1 { |
| 231 | t.Fatal("more than one tile is current") |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | func TestNoMenuTriggerWithoutNavOrActions(t *testing.T) { |
| 236 | b := signedIn("x") |
| 237 | b.Nav, b.Actions = nil, nil |
| 238 | if html := render(t, "estilo-bar", b); strings.Contains(html, "estilo-bar-more") || strings.Contains(html, "estilo-bar-actions") { |
| 239 | t.Fatal("an empty Menu is rendered", html) |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | // An action is not always a link. Sheets' Create is a POST, and it must |
| 244 | // be one in both places the bar draws it: a primary button at wide widths |
| 245 | // and a row in the narrow Menu sheet. Drawn as a link in the sheet, it |
| 246 | // would be a GET to the list and create nothing. |
| 247 | func TestActionsCanPostInTheBarAndTheSheet(t *testing.T) { |
| 248 | html := render(t, "estilo-bar", signedIn("x")) |
| 249 | actions := html[strings.Index(html, "<div estilo-bar-actions>"):strings.Index(html, "<details estilo-bar-more")] |
| 250 | for _, want := range []string{ |
| 251 | `<a rst-btn href="/d/abc/share">x-share</a>`, |
| 252 | `<form method="post" action="/d/new"><button rst-btn="primary" type="submit">x-create</button></form>`, |
| 253 | } { |
| 254 | if !strings.Contains(actions, want) { |
| 255 | t.Errorf("bar actions lack %q in %s", want, actions) |
| 256 | } |
| 257 | } |
| 258 | sheet := html[strings.Index(html, `<div estilo-menu="sheet">`):strings.Index(html, "<details estilo-switcher")] |
| 259 | for _, want := range []string{ |
| 260 | `<a estilo-menu-item href="/d/abc/share"><span estilo-menu-label>x-share</span></a>`, |
| 261 | `<form method="post" action="/d/new"><button estilo-menu-item type="submit"><span estilo-menu-label>x-create</span></button></form>`, |
| 262 | } { |
| 263 | if !strings.Contains(sheet, want) { |
| 264 | t.Errorf("the Menu sheet lacks %q in %s", want, sheet) |
| 265 | } |
| 266 | } |
| 267 | b := signedIn("x") |
| 268 | b.Actions = []Action{{Item: menu.Item{Dialog: "help", Label: "x-help"}}} |
| 269 | html = render(t, "estilo-bar", b) |
| 270 | if !strings.Contains(html, `<button rst-btn type="button" commandfor="help" command="show-modal">x-help</button>`) { |
| 271 | t.Error("a dialog action is not an invoker button", html) |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | // A full-height app (Sheets' grid) keys its layout on the shell. The rule |
| 276 | // is estilo's, not each app's: the working area is the viewport less the |
| 277 | // bar, and the bar must neither grow nor shrink inside it. |
| 278 | func TestTheFillShellGivesTheWorkingAreaTheRestOfTheViewport(t *testing.T) { |
| 279 | src := string(CSS()) |
| 280 | for _, want := range []string{ |
| 281 | "[estilo-shell~=\"fill\"] {\n display: flex;\n flex-direction: column;\n block-size: 100dvh;", |
| 282 | "[estilo-shell~=\"fill\"] > [estilo-bar] { flex: none; }", |
| 283 | "[estilo-shell~=\"fill\"] > main { flex: 1 1 auto; min-block-size: 0; }", |
| 284 | } { |
| 285 | if !strings.Contains(src, want) { |
| 286 | t.Errorf("chrome.css lacks the fill shell rule %q", want) |
| 287 | } |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | func TestUntrustedValuesAreEscaped(t *testing.T) { |
| 292 | b := signedIn("x") |
| 293 | b.ContextEdit.Value = `"><script>bad()</script>` |
| 294 | b.Account.Address = `<img src=x onerror=bad>` |
| 295 | b.Actions[0].Href = "javascript:bad()" |
| 296 | b.Actions[1].Action = "javascript:bad()" |
| 297 | html := render(t, "estilo-bar", b) |
| 298 | if strings.Contains(html, "<script>") || strings.Contains(html, "<img src=x") || !strings.Contains(html, "#ZgotmplZ") { |
| 299 | t.Fatal("unsafe bar markup", html) |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | func TestErrorPage(t *testing.T) { |
| 304 | for locale, label := range locales { |
| 305 | t.Run(locale, func(t *testing.T) { |
| 306 | html := render(t, "estilo-error", ErrorPage{Status: 500, Title: label + "-t", Body: label + "-b", Home: Link{Href: "/", Label: label + "-home"}, Back: &Link{Href: "/d/x", Label: label + "-back"}, Ref: "k3f9tq", RefLabel: label + "-ref"}) |
| 307 | esc := template.HTMLEscapeString |
| 308 | for _, want := range []string{"<p rst-error-status>500</p>", esc(label) + "-t", esc(label) + "-b", `<a rst-btn="primary" href="/">` + esc(label) + "-home", `<a rst-btn href="/d/x">` + esc(label) + "-back", esc(label) + "-ref k3f9tq"} { |
| 309 | if !strings.Contains(html, want) { |
| 310 | t.Fatalf("missing %q in %s", want, html) |
| 311 | } |
| 312 | } |
| 313 | }) |
| 314 | } |
| 315 | if html := render(t, "estilo-error", ErrorPage{Status: 404, Title: "t", Home: Link{Href: "/", Label: "h"}}); strings.Contains(html, "rst-error-ref") || strings.Contains(html, "rst-btn href") { |
| 316 | t.Fatal("a 404 with no ref and no checked referer shows either", html) |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | func TestSignInPages(t *testing.T) { |
| 321 | for locale, label := range locales { |
| 322 | t.Run(locale, func(t *testing.T) { |
| 323 | esc := template.HTMLEscapeString |
| 324 | html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Copy: &DoorCopy{Heading: label + "-h", AddressLabel: label + "-addr", Help: label + "-help", Submit: label + "-submit"}, Lede: label + "-lede", Error: label + "-err", Action: "/signin", Return: "/d/abc", Hidden: []Hidden{{"force", "1"}}, OpenSource: &Link{Href: "/credits", Label: label + "-os"}}) |
| 325 | for _, want := range []string{"<h1>" + esc(label) + "-h</h1>", esc(label) + "-lede", `rst-tone="negative" role="alert"`, esc(label) + "-err", `action="/signin" novalidate`, `name="return_to" value="/d/abc"`, `name="force" value="1"`, `type="email"`, `autocomplete="email"`, esc(label) + "-addr", esc(label) + "-help", esc(label) + "-submit", esc(label) + "-os"} { |
| 326 | if !strings.Contains(html, want) { |
| 327 | t.Fatalf("missing %q in %s", want, html) |
| 328 | } |
| 329 | } |
| 330 | html = render(t, "estilo-signin-sent", SignInNotice{Icon: "/i.svg", Heading: label + "-sent", Body: label + "-body", Link: &Link{Href: "/signin", Label: label + "-again"}}) |
| 331 | if !strings.Contains(html, esc(label)+"-sent") || !strings.Contains(html, `<a href="/signin">`+esc(label)+"-again") { |
| 332 | t.Fatal("sent page", html) |
| 333 | } |
| 334 | html = render(t, "estilo-signed-out", SignInNotice{Icon: "/i.svg", Heading: label + "-out", Body: label + "-body", Link: &Link{Href: "/signin", Label: label + "-in"}}) |
| 335 | if !strings.Contains(html, `<a rst-btn="primary" href="/signin">`+esc(label)+"-in") { |
| 336 | t.Fatal("signed-out page", html) |
| 337 | } |
| 338 | }) |
| 339 | } |
| 340 | // An address error belongs in the field, not a callout above it. |
| 341 | html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Action: "/signin", AddressError: "bad address"}) |
| 342 | if strings.Contains(html, "rst-callout") || !strings.Contains(html, `rst-field-error id="estilo-signin-address-error" role="alert">bad address`) || !strings.Contains(html, `aria-invalid="true"`) { |
| 343 | t.Fatal("address error not in the field slot", html) |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | func TestInitials(t *testing.T) { |
| 348 | cases := []struct{ name, address, want string }{ |
| 349 | {"Paul Campbell", "paul@tito.io", "P"}, |
| 350 | {"", "paul@tito.io", "P"}, |
| 351 | {"", " émile@example.test", "É"}, |
| 352 | {" ", "", ""}, |
| 353 | {"", "@example.test", ""}, |
| 354 | {"", "._@example.test", ""}, |
| 355 | {"李小龙", "", "李"}, |
| 356 | } |
| 357 | for _, c := range cases { |
| 358 | if got := Initials(c.name, c.address); got != c.want { |
| 359 | t.Errorf("Initials(%q, %q) = %q, want %q", c.name, c.address, got, c.want) |
| 360 | } |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | func TestAppearanceAndThemeAttr(t *testing.T) { |
| 365 | for value, want := range map[string]Mode{"light": Light, "dark": Dark, "system": System, "": System, "auto": System, "Dark": System, "<x>": System} { |
| 366 | r := httptest.NewRequest("GET", "/", nil) |
| 367 | r.AddCookie(&http.Cookie{Name: AppearanceCookie, Value: value}) |
| 368 | if got := Appearance(r); got != want { |
| 369 | t.Errorf("cookie %q: Appearance = %q, want %q", value, got, want) |
| 370 | } |
| 371 | } |
| 372 | if Appearance(httptest.NewRequest("GET", "/", nil)) != System { |
| 373 | t.Error("no cookie is not System") |
| 374 | } |
| 375 | for m, want := range map[Mode]string{Light: "light", Dark: "dark", System: "", Mode("x"): ""} { |
| 376 | if got := ThemeAttr(m); got != want { |
| 377 | t.Errorf("ThemeAttr(%q) = %q, want %q", m, got, want) |
| 378 | } |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | func post(form url.Values) *httptest.ResponseRecorder { |
| 383 | r := httptest.NewRequest("POST", "/appearance", strings.NewReader(form.Encode())) |
| 384 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 385 | w := httptest.NewRecorder() |
| 386 | HandleAppearance(w, r) |
| 387 | return w |
| 388 | } |
| 389 | |
| 390 | func TestHandleAppearance(t *testing.T) { |
| 391 | w := post(url.Values{"mode": {"dark"}, "return_to": {"/d/abc?x=1"}}) |
| 392 | if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/d/abc?x=1" { |
| 393 | t.Fatalf("got %d %q", w.Code, w.Header().Get("Location")) |
| 394 | } |
| 395 | c := w.Result().Cookies() |
| 396 | if len(c) != 1 || c[0].Name != AppearanceCookie || c[0].Value != "dark" || !c[0].Secure || !c[0].HttpOnly || c[0].SameSite != http.SameSiteLaxMode || c[0].Path != "/" || c[0].MaxAge < 364*24*3600 || c[0].Domain != "" { |
| 397 | t.Fatalf("cookie %+v", c) |
| 398 | } |
| 399 | // return_to that leaves the site falls back to the root; SafeReturn's |
| 400 | // control-character case is the one a prefix test misses. |
| 401 | for _, bad := range []string{"https://evil.example/", "//evil.example/", "/\\evil.example", "/\t/evil.example", "evil", ""} { |
| 402 | w := post(url.Values{"mode": {"light"}, "return_to": {bad}}) |
| 403 | if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/" { |
| 404 | t.Errorf("return_to %q: got %d %q", bad, w.Code, w.Header().Get("Location")) |
| 405 | } |
| 406 | } |
| 407 | for _, bad := range []string{"auto", "", "DARK", "sepia"} { |
| 408 | if w := post(url.Values{"mode": {bad}, "return_to": {"/"}}); w.Code != http.StatusBadRequest || len(w.Result().Cookies()) != 0 { |
| 409 | t.Errorf("mode %q: got %d with cookies %v", bad, w.Code, w.Result().Cookies()) |
| 410 | } |
| 411 | } |
| 412 | g := httptest.NewRecorder() |
| 413 | HandleAppearance(g, httptest.NewRequest("GET", "/appearance?mode=dark", nil)) |
| 414 | if g.Code != http.StatusMethodNotAllowed || g.Header().Get("Allow") != "POST" || len(g.Result().Cookies()) != 0 { |
| 415 | t.Fatalf("GET: %d", g.Code) |
| 416 | } |
| 417 | } |
| 418 | |
| 419 | // chrome.js must stay a classic, dependency-free script. |
| 420 | func TestScriptIsClassicAndSelfContained(t *testing.T) { |
| 421 | src := string(JS()) |
| 422 | if strings.Contains(src, "import ") || strings.Contains(src, "export ") { |
| 423 | t.Fatal("chrome.js must be a classic script") |
| 424 | } |
| 425 | // Escape and outside-click close every member of the one top-level |
| 426 | // group, the menubar's menus included: Sheets' menubar lost both when |
| 427 | // it left rastrillo's dropdown. And an invoker row closes the menu it |
| 428 | // was in, with a fallback where the platform lacks invoker commands. |
| 429 | for _, want := range []string{"Escape", "estilo-bar-title-input", "defaultValue", `details[name="estilo-menus"][open]`, "commandfor", "showModal"} { |
| 430 | if !strings.Contains(src, want) { |
| 431 | t.Errorf("chrome.js lost %q", want) |
| 432 | } |
| 433 | } |
| 434 | // Escape is heard in the capture phase: an app that stops a key from |
| 435 | // propagating out of its menus (Sheets, whose grid library listens on |
| 436 | // document) would otherwise swallow it before a bubble listener ran. |
| 437 | keydown := src[strings.Index(src, "addEventListener('keydown'"):] |
| 438 | keydown = keydown[:strings.Index(keydown[1:], "document.addEventListener(")+1] |
| 439 | if !strings.HasSuffix(strings.TrimSpace(keydown), "}, true)") { |
| 440 | t.Error("chrome.js's Escape handler is not in the capture phase") |
| 441 | } |
| 442 | } |
| 443 | |
| 444 | // chrome.js's behaviour runs under Node against a minimal DOM |
| 445 | // (chrome.test.mjs), the way savestatus's does. |
| 446 | func TestScriptBehaviour(t *testing.T) { |
| 447 | out, err := exec.Command("node", "--test", "chrome.test.mjs").CombinedOutput() |
| 448 | if err != nil { |
| 449 | t.Fatalf("node --test chrome.test.mjs: %v\n%s", err, out) |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | // The door: the sign-in pages are one card on the suite's sky, headed by |
| 454 | // the app's mark and name. Its sky markup must be exactly the sky |
| 455 | // package's own, or the two would drift. |
| 456 | func TestSignInIsTheOficinaDoor(t *testing.T) { |
| 457 | v := sky.For("calendar.oficina.works") |
| 458 | html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", App: "Calendar", Sky: v, Action: "/signin"}) |
| 459 | var want bytes.Buffer |
| 460 | if err := template.Must(template.ParseFS(sky.Templates(), "templates/*.html")).ExecuteTemplate(&want, "estilo-sky", v); err != nil { |
| 461 | t.Fatal(err) |
| 462 | } |
| 463 | if !strings.Contains(html, strings.TrimSpace(want.String())) { |
| 464 | t.Errorf("the door's sky differs from estilo-sky's:\n%s\nwant it to contain:\n%s", html, want.String()) |
| 465 | } |
| 466 | for _, s := range []string{`<div estilo-door estilo-sky-host>`, `<section estilo-signin>`, `<span>Calendar</span>`, ` autofocus`, `</section> |
| 467 | </div>`} { |
| 468 | if !strings.Contains(html, s) { |
| 469 | t.Errorf("door lacks %q", s) |
| 470 | } |
| 471 | } |
| 472 | for _, name := range []string{"estilo-signin-sent", "estilo-signed-out"} { |
| 473 | if h := render(t, name, SignInNotice{Icon: "/i.svg", App: "Calendar", Sky: v, Heading: "h", Body: "b"}); !strings.Contains(h, `<div estilo-door estilo-sky-host>`) || !strings.Contains(h, "estilo-sky-layer") { |
| 474 | t.Errorf("%s is not drawn as the door", name) |
| 475 | } |
| 476 | } |
| 477 | // No sky given, no sky drawn: the card still stands. |
| 478 | if h := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Action: "/s"}); strings.Contains(h, "estilo-sky-layer") { |
| 479 | t.Error("a sign-in with no Sky drew one") |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | // The card styles its own links: an app that styles no bare <a> would |
| 484 | // otherwise show the browser's default blue on the door. |
| 485 | func TestTheDoorStylesItsLinks(t *testing.T) { |
| 486 | src := string(CSS()) |
| 487 | for _, want := range []string{":where([estilo-signin]) a", "[estilo-signin-foot] a"} { |
| 488 | if !strings.Contains(src, want) { |
| 489 | t.Errorf("chrome.css does not style %q", want) |
| 490 | } |
| 491 | } |
| 492 | } |
| 493 | |
| 494 | // iOS zooms the page into any text control under 16px. rastrillo's |
| 495 | // tokens.css brings controls up to 16px on a coarse pointer with a bare |
| 496 | // `input` selector, and the bar's title field outweighs that with |
| 497 | // `font: inherit` at 14px, so it zoomed on every tap in every app with a |
| 498 | // title (found in Sheets, 2026-10-01: its TestNoZoomOnTap). chrome.css |
| 499 | // restates rastrillo's rule for the field, at the field's own weight, |
| 500 | // after the field's own rule. |
| 501 | func TestTitleFieldDoesNotZoomOnTap(t *testing.T) { |
| 502 | src := string(CSS()) |
| 503 | field := strings.Index(src, "[estilo-bar-title-input] {") |
| 504 | if field < 0 { |
| 505 | t.Fatal("chrome.css has no title field rule") |
| 506 | } |
| 507 | block := regexp.MustCompile(`@media \(pointer: coarse\) \{\s*\[estilo-bar-title-input\] \{\s*font-size: max\(1rem, 1em\);\s*\}\s*\}`) |
| 508 | at := block.FindStringIndex(src) |
| 509 | if at == nil { |
| 510 | t.Fatal("chrome.css does not size the title field to max(1rem, 1em) under (pointer: coarse)") |
| 511 | } |
| 512 | if at[0] < field { |
| 513 | t.Error("the coarse-pointer size comes before the title field's own rule, whose font: inherit then wins") |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | // The account at a sidebar's foot (Account.Rail), for an app whose chrome |
| 518 | // is a rail, not the bar (Fichas, Paul 2026-10-01: "put any shared stuff in |
| 519 | // the bottom left and maybe roll it into a dropup"). The same menu, the |
| 520 | // same group and the same one Sign out; only where it sits changes. |
| 521 | func TestTheAccountCanSitAtARailsFoot(t *testing.T) { |
| 522 | acc := signedIn("x").Account |
| 523 | bar := render(t, "estilo-account", acc) |
| 524 | if strings.Contains(bar, `estilo-account="rail"`) || strings.Contains(bar, "estilo-account-who") { |
| 525 | t.Error("the bar's account changed: Rail is off by default") |
| 526 | } |
| 527 | acc.Rail = true |
| 528 | html := render(t, "estilo-account", acc) |
| 529 | if !strings.Contains(html, `<details estilo-account="rail" name="estilo-menus">`) { |
| 530 | t.Errorf("a rail account is not marked, or left the one menu group:\n%s", html) |
| 531 | } |
| 532 | // In a rail the trigger is a row: who is signed in, beside the avatar. |
| 533 | summary := html[strings.Index(html, "<summary"):strings.Index(html, "</summary>")] |
| 534 | if !strings.Contains(summary, `<span estilo-account-who dir="auto">paul@example.test</span>`) { |
| 535 | t.Errorf("the rail trigger does not say who is signed in:\n%s", summary) |
| 536 | } |
| 537 | if n := strings.Count(html, "x-signout"); n != 1 { |
| 538 | t.Errorf("Sign out rendered %d times", n) |
| 539 | } |
| 540 | |
| 541 | src := string(CSS()) |
| 542 | // It opens upward where the rail is the viewport's height, and it is |
| 543 | // the foot's own width: hung from the inline end of a foot narrower |
| 544 | // than the bar's panel it ran off the window's start edge, and from the |
| 545 | // inline start it ran past the rail, which scrolls and so clips. |
| 546 | wide := regexp.MustCompile(`@media \(min-width: 800px\) \{\s*\[estilo-account~="rail"\] > \[estilo-account-panel\] \{[^}]*inset-block-start: auto;[^}]*inset-block-end: calc\(100% \+ 4px\);`) |
| 547 | if !wide.MatchString(src) { |
| 548 | t.Error("chrome.css does not open the rail account upward from 800px") |
| 549 | } |
| 550 | if !regexp.MustCompile(`\[estilo-account~="rail"\] > \[estilo-account-panel\] \{[^}]*inset-inline: 0;[^}]*inline-size: auto;[^}]*min-inline-size: 0;`).MatchString(src) { |
| 551 | t.Error("chrome.css does not size the rail account's panel to the rail's foot") |
| 552 | } |
| 553 | // The trigger is in the page flow: square, like every bar trigger. |
| 554 | if regexp.MustCompile(`\[estilo-account~="rail"\] > \[estilo-bar-trigger\] \{[^}]*border-radius: (?:[1-9]|var)`).MatchString(src) { |
| 555 | t.Error("the rail trigger is rounded; an in-flow highlight is square") |
| 556 | } |
| 557 | } |
| 558 | |