package chrome import ( "bytes" "html/template" "net/http" "net/http/httptest" "net/url" "os/exec" "regexp" "strings" "testing" "amadan.net/oficina/estilo.git/internal/tokencheck" "amadan.net/oficina/estilo.git/menu" "amadan.net/oficina/estilo.git/sky" "github.com/carlosframework/rastrillo/ui" ) func TestChromeUsesDefinedTokens(t *testing.T) { missing, n := tokencheck.Undefined(CSS()) if n == 0 { t.Fatal("chrome.css reads no rastrillo token; the check would pass vacuously") } if len(missing) > 0 { t.Fatal("chrome.css reads undefined tokens:", missing) } } // The bar's height is public contract and must be fixed, never a minimum: // nothing inside it may grow it. func TestBarHeightIsFixedAndPublic(t *testing.T) { src := string(CSS()) for _, want := range []string{ "--estilo-bar-block-size: 3.25rem;", "--estilo-z-bar: 45;", "block-size: var(--estilo-bar-block-size);", "flex-wrap: nowrap;", } { if !strings.Contains(src, want) { t.Errorf("chrome.css lost %q", want) } } // Narrow: the title field may shrink to nothing and the Rename button // floats under it rather than taking room from Menu and the switcher. // The browser proof is polish-audit/r2-chrome-check.mjs's reach checks. narrow := src[strings.LastIndex(src, "@media (max-width: 799.98px)"):] for _, want := range []string{"[estilo-bar-title-input] { min-inline-size: 0; }", "[estilo-bar-title]:focus-within [estilo-bar-title-submit] {\n position: absolute;"} { if !strings.Contains(narrow, want) { t.Errorf("the narrow title layout lost %q", want) } } if regexp.MustCompile(`\[estilo-bar\]\s*\{[^}]*min-block-size`).MatchString(src) { t.Error("the bar declares a min-block-size: it could grow") } } func parse(t *testing.T) *template.Template { t.Helper() view := template.New("").Funcs(ui.Funcs()) view, err := view.ParseFS(ui.Templates(), "*.html") if err != nil { t.Fatal(err) } if view, err = view.ParseFS(menu.Templates(), "templates/*.html"); err != nil { t.Fatal(err) } if view, err = view.ParseFS(Templates(), "templates/*.html"); err != nil { t.Fatal(err) } return view } func render(t *testing.T, name string, data any) string { t.Helper() var out bytes.Buffer if err := parse(t).ExecuteTemplate(&out, name, data); err != nil { t.Fatal(err) } return out.String() } // One word per locale, used for every caller-supplied label, so a label // the template dropped or hard-coded shows up as a missing string. 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": "계정"} func signedIn(label string) Bar { return Bar{ App: App{Name: label + "-app", Slug: "docs", Home: "/", Icon: "/static/estilo/brand/docs.svg"}, ContextEdit: &TitleEdit{Action: "/d/abc/title", Name: "title", Value: label + "-title", Label: label + "-title-label", Submit: label + "-rename"}, Nav: []Link{{Href: "/", Label: label + "-nav", Current: true}}, NavLabel: label + "-navlabel", Actions: []Action{ {Item: menu.Item{Href: "/d/abc/share", Label: label + "-share"}}, {Item: menu.Item{Action: "/d/new", Label: label + "-create"}, Primary: true}, }, Switcher: &Switcher{Label: label + "-apps", AllApps: menu.Item{Href: "https://oficina.example/", Label: label + "-allapps"}}, Account: &Account{ TriggerLabel: label + "-trigger", Initials: "P", Address: "paul@example.test", Labels: AccountLabels{SignedInAs: label + "-signedinas", Appearance: label + "-appearance", Help: label + "-help"}, Items: []menu.Item{{Href: "/account/home", Label: label + "-connect"}}, Appearance: &AppearanceMenu{Action: "/appearance", Return: "/d/abc", Options: AppearanceOptions(Dark, label+"-light", label+"-dark", label+"-system")}, Help: []menu.Item{{Href: "/credits", Label: label + "-opensource"}}, SignOut: SignOut{Action: "/signout", Label: label + "-signout"}, }, Labels: BarLabels{Skip: label + "-skip", Menu: label + "-menu"}, } } func TestBarRendersCallerLabelsInEveryLocale(t *testing.T) { for locale, label := range locales { t.Run(locale, func(t *testing.T) { html := render(t, "estilo-bar", signedIn(label)) esc := template.HTMLEscapeString for _, want := range []string{ ``, esc(label) + "-app", `
`, `value="` + esc(label) + `-title"`, `aria-label="` + esc(label) + `-title-label"`, esc(label) + "-rename", `aria-label="` + esc(label) + `-navlabel"`, `aria-current="page"`, esc(label) + "-share", esc(label) + "-create", esc(label) + "-menu", `aria-label="` + esc(label) + `-apps"`, esc(label) + "-allapps", `aria-label="` + esc(label) + `-trigger"`, esc(label) + "-signedinas", "paul@example.test", esc(label) + "-connect", esc(label) + "-appearance", esc(label) + "-light", esc(label) + "-dark", esc(label) + "-system", esc(label) + "-help", esc(label) + "-opensource", esc(label) + "-signout", } { if !strings.Contains(html, want) { t.Fatalf("missing %q", want) } } if strings.Contains(html, "", "
"):] if strings.Count(tail, "<") != 3 { //
t.Fatalf("something follows the account in the end group: %q", tail) } } // Every top-level trigger shares one exclusivity group, so opening one // closes the others; nothing nested inside a panel carries the name. func TestEveryBarTriggerJoinsTheOneMenuGroup(t *testing.T) { html := render(t, "estilo-bar", signedIn("x")) details := regexp.MustCompile(`
]*>`).FindAllString(html, -1) if len(details) != 3 { t.Fatalf("want three bar disclosures, got %d", len(details)) } for _, d := range details { if !strings.Contains(d, `name="estilo-menus"`) { t.Errorf("%s is not in the estilo-menus group", d) } } } func TestSignOutAppearsExactlyOnce(t *testing.T) { html := render(t, "estilo-bar", signedIn("x")) if n := strings.Count(html, "x-signout"); n != 1 { t.Fatalf("Sign out rendered %d times", n) } } func TestAppearancePressedStateOnEveryOption(t *testing.T) { html := render(t, "estilo-bar", signedIn("x")) pressed := regexp.MustCompile(`value="(light|dark|system)" aria-pressed="(true|false)"`).FindAllStringSubmatch(html, -1) if len(pressed) != 3 { t.Fatalf("want three appearance options with aria-pressed, got %v", pressed) } for _, p := range pressed { if (p[1] == "dark") != (p[2] == "true") { t.Errorf("%s pressed=%s, want only dark pressed", p[1], p[2]) } } if !strings.Contains(html, `role="group" aria-labelledby="estilo-appearance"`) { t.Error("the appearance options are not a labelled group") } } func TestSignedOutCornerAndEmptyCorner(t *testing.T) { b := Bar{App: App{Name: "Docs", Home: "/", Icon: "/i.svg"}, SignIn: &Link{Href: "/signin?return_to=%2Fd%2Fabc", Label: "Sign in"}} html := render(t, "estilo-bar", b) if !strings.Contains(html, `Sign in`) { t.Fatal("signed-out corner missing", html) } if strings.Contains(html, "estilo-account") || strings.Contains(html, "estilo-bar-more") { t.Fatal("signed-out bar has an account or Menu", html) } b.SignIn = nil if html = render(t, "estilo-bar", b); strings.Contains(html, "estilo-bar-signin") { t.Fatal("sign-in page bar shows Sign in", html) } } func TestPublicBrandIsPlainText(t *testing.T) { html := render(t, "estilo-bar", Bar{App: App{Name: "Docs", Icon: "/i.svg"}, Context: "A document"}) if strings.Contains(html, "") { t.Fatal("public brand is a link", html) } if !strings.Contains(html, `A document`) { t.Fatal("read-only context missing", html) } } func TestSwitcherWithoutTilesIsAllAppsOnly(t *testing.T) { b := signedIn("x") html := render(t, "estilo-bar", b) if strings.Contains(html, "estilo-switcher-grid") || strings.Contains(html, "estilo-switcher-app") { t.Fatal("a switcher with no tiles renders a grid") } 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"}} b.Switcher.CurrentLabel = "(this app)" html = render(t, "estilo-bar", b) if !strings.Contains(html, `aria-current="true"`) || !strings.Contains(html, "(this app)") { t.Fatal("the current tile is not marked in words", html) } if strings.Count(html, "(this app)") != 1 { t.Fatal("more than one tile is current") } } func TestNoMenuTriggerWithoutNavOrActions(t *testing.T) { b := signedIn("x") b.Nav, b.Actions = nil, nil if html := render(t, "estilo-bar", b); strings.Contains(html, "estilo-bar-more") || strings.Contains(html, "estilo-bar-actions") { t.Fatal("an empty Menu is rendered", html) } } // An action is not always a link. Sheets' Create is a POST, and it must // be one in both places the bar draws it: a primary button at wide widths // and a row in the narrow Menu sheet. Drawn as a link in the sheet, it // would be a GET to the list and create nothing. func TestActionsCanPostInTheBarAndTheSheet(t *testing.T) { html := render(t, "estilo-bar", signedIn("x")) actions := html[strings.Index(html, "
"):strings.Index(html, "
x-share`, ``, } { if !strings.Contains(actions, want) { t.Errorf("bar actions lack %q in %s", want, actions) } } sheet := html[strings.Index(html, `
`):strings.Index(html, "
x-share`, `
`, } { if !strings.Contains(sheet, want) { t.Errorf("the Menu sheet lacks %q in %s", want, sheet) } } b := signedIn("x") b.Actions = []Action{{Item: menu.Item{Dialog: "help", Label: "x-help"}}} html = render(t, "estilo-bar", b) if !strings.Contains(html, ``) { t.Error("a dialog action is not an invoker button", html) } } // A full-height app (Sheets' grid) keys its layout on the shell. The rule // is estilo's, not each app's: the working area is the viewport less the // bar, and the bar must neither grow nor shrink inside it. func TestTheFillShellGivesTheWorkingAreaTheRestOfTheViewport(t *testing.T) { src := string(CSS()) for _, want := range []string{ "[estilo-shell~=\"fill\"] {\n display: flex;\n flex-direction: column;\n block-size: 100dvh;", "[estilo-shell~=\"fill\"] > [estilo-bar] { flex: none; }", "[estilo-shell~=\"fill\"] > main { flex: 1 1 auto; min-block-size: 0; }", } { if !strings.Contains(src, want) { t.Errorf("chrome.css lacks the fill shell rule %q", want) } } } func TestUntrustedValuesAreEscaped(t *testing.T) { b := signedIn("x") b.ContextEdit.Value = `">` b.Account.Address = `` b.Actions[0].Href = "javascript:bad()" b.Actions[1].Action = "javascript:bad()" html := render(t, "estilo-bar", b) if strings.Contains(html, "