package guards import ( "io/fs" "os" "path/filepath" "regexp" "strings" "testing" ) // classOnlyUtilities are the seven rastrillo names that are classes by // design, listed in AGENTS.md's Frontend section: "An attribute says // what a thing *is*; a class says what is being applied to it." // // They have no attribute twin in tokens.css, so writing one as an // attribute produces markup that matches nothing — and fails silently. // That is exactly what happened here: `rst-sr-only` was written as an // attribute on the formula bar's label, and the label rendered visibly // in the middle of the toolbar. Nothing errored; it just looked wrong, // and only a screenshot caught it. Hence this test. var classOnlyUtilities = []string{ "rst-grow", "rst-mono", "rst-sr-only", "rst-m-hide", "rst-danger", "rst-nm", "rst-cell-mut", } // TestUtilitiesAreNeverWrittenAsAttributes walks the templates for a // class-only name used in attribute position. // // The match is deliberately narrow: `rst-sr-only` immediately after a // `<`-tag's whitespace and followed by `>`, `=`, or more whitespace. // `class="rst-sr-only"` does not match, because there the name is // inside a quoted value. func TestUtilitiesAreNeverWrittenAsAttributes(t *testing.T) { root := repoRoot(t) files := templateFiles(t, root) if len(files) == 0 { t.Fatal("guards: found no templates; this test is not checking anything") } for _, name := range classOnlyUtilities { // The name in attribute position: preceded by whitespace, and // followed by whitespace, '>', '/' or '=' — never by a quote, // which is what a class value looks like. re := regexp.MustCompile(`\s` + regexp.QuoteMeta(name) + `[\s>/=]`) for _, rel := range files { body, err := os.ReadFile(filepath.Join(root, rel)) if err != nil { t.Fatalf("guards: read %s: %v", rel, err) } for _, line := range strings.Split(string(body), "\n") { // A class list is the correct spelling and must not trip // this: only look at the parts of the line outside quotes. if re.MatchString(stripQuoted(line)) { t.Errorf("%s: %s is written as an attribute; it is one of the seven "+ "class-only utilities (AGENTS.md Frontend) and has no attribute "+ "twin, so it matches nothing:\n %s", rel, name, strings.TrimSpace(line)) } } } } } // stripQuoted blanks the contents of double- and single-quoted runs, so // a name inside class="..." is invisible to the attribute check while // the same name written bare is not. func stripQuoted(line string) string { var b strings.Builder var quote byte for i := 0; i < len(line); i++ { c := line[i] switch { case quote != 0: if c == quote { quote = 0 b.WriteByte(c) } else { b.WriteByte(' ') } case c == '"' || c == '\'': quote = c b.WriteByte(c) default: b.WriteByte(c) } } return b.String() } // templateFiles returns every .html file under the repo. func templateFiles(t *testing.T, root string) []string { t.Helper() var out []string err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { if strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules" { return filepath.SkipDir } return nil } if strings.HasSuffix(d.Name(), ".html") { out = append(out, relPath(t, root, path)) } return nil }) if err != nil { t.Fatalf("guards: walk: %v", err) } return out } // badgesThatSwallowProse are rastrillo attributes whose rule gives the // element a FIXED width and height. Wrapped around a sentence they keep // the box and let the text spill out of it. // // rst-help is the case that produced this test. It is the little "?" // beside a label — tokens.css gives it inline-flex, 28px square, a // border and a radius. Given a paragraph it rendered one or two words // per line with the badge's border struck through the middle, and ran // off the left edge of a phone. // // It is here rather than in a review checklist because it came back. // Fixed on the sign-in screen, it reappeared within days on the share // and history screens, written by a different session reaching for the // obvious name for help text. The name is a trap and prose is the bait, // so the trap is what gets guarded. // // The prose token is rst-field-help. var badgesThatSwallowProse = []string{"rst-help"} // TestBadgeAttributesNeverWrapProse fails on a badge holding a sentence. // // The threshold is deliberately generous: a badge legitimately holds an // icon, a glyph, or a very short label, and this test must not argue // with that. What it catches is prose — anything past a handful of // characters, which no 28px box can show. func TestBadgeAttributesNeverWrapProse(t *testing.T) { root := repoRoot(t) dir := filepath.Join(root, "internal/sheets/templates") entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } for _, name := range badgesThatSwallowProse { re := regexp.MustCompile(`(?s)<(\w+)\s[^>]*\b` + regexp.QuoteMeta(name) + `\b[^>]*>(.*?) 24 { t.Errorf("%s: [%s] wraps %d characters of prose. It is a "+ "28px square badge — the box stays and the text "+ "spills out of it. Use rst-field-help.\n %.60s", e.Name(), name, len(inner), inner) } } } } }