| 1 | package guards |
| 2 | |
| 3 | import ( |
| 4 | "io/fs" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "regexp" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // classOnlyUtilities are the seven rastrillo names that are classes by |
| 13 | // design, listed in AGENTS.md's Frontend section: "An attribute says |
| 14 | // what a thing *is*; a class says what is being applied to it." |
| 15 | // |
| 16 | // They have no attribute twin in tokens.css, so writing one as an |
| 17 | // attribute produces markup that matches nothing — and fails silently. |
| 18 | // That is exactly what happened here: `rst-sr-only` was written as an |
| 19 | // attribute on the formula bar's label, and the label rendered visibly |
| 20 | // in the middle of the toolbar. Nothing errored; it just looked wrong, |
| 21 | // and only a screenshot caught it. Hence this test. |
| 22 | var classOnlyUtilities = []string{ |
| 23 | "rst-grow", "rst-mono", "rst-sr-only", "rst-m-hide", |
| 24 | "rst-danger", "rst-nm", "rst-cell-mut", |
| 25 | } |
| 26 | |
| 27 | // TestUtilitiesAreNeverWrittenAsAttributes walks the templates for a |
| 28 | // class-only name used in attribute position. |
| 29 | // |
| 30 | // The match is deliberately narrow: `rst-sr-only` immediately after a |
| 31 | // `<`-tag's whitespace and followed by `>`, `=`, or more whitespace. |
| 32 | // `class="rst-sr-only"` does not match, because there the name is |
| 33 | // inside a quoted value. |
| 34 | func TestUtilitiesAreNeverWrittenAsAttributes(t *testing.T) { |
| 35 | root := repoRoot(t) |
| 36 | files := templateFiles(t, root) |
| 37 | if len(files) == 0 { |
| 38 | t.Fatal("guards: found no templates; this test is not checking anything") |
| 39 | } |
| 40 | |
| 41 | for _, name := range classOnlyUtilities { |
| 42 | // The name in attribute position: preceded by whitespace, and |
| 43 | // followed by whitespace, '>', '/' or '=' — never by a quote, |
| 44 | // which is what a class value looks like. |
| 45 | re := regexp.MustCompile(`\s` + regexp.QuoteMeta(name) + `[\s>/=]`) |
| 46 | for _, rel := range files { |
| 47 | body, err := os.ReadFile(filepath.Join(root, rel)) |
| 48 | if err != nil { |
| 49 | t.Fatalf("guards: read %s: %v", rel, err) |
| 50 | } |
| 51 | for _, line := range strings.Split(string(body), "\n") { |
| 52 | // A class list is the correct spelling and must not trip |
| 53 | // this: only look at the parts of the line outside quotes. |
| 54 | if re.MatchString(stripQuoted(line)) { |
| 55 | t.Errorf("%s: %s is written as an attribute; it is one of the seven "+ |
| 56 | "class-only utilities (AGENTS.md Frontend) and has no attribute "+ |
| 57 | "twin, so it matches nothing:\n %s", |
| 58 | rel, name, strings.TrimSpace(line)) |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // stripQuoted blanks the contents of double- and single-quoted runs, so |
| 66 | // a name inside class="..." is invisible to the attribute check while |
| 67 | // the same name written bare is not. |
| 68 | func stripQuoted(line string) string { |
| 69 | var b strings.Builder |
| 70 | var quote byte |
| 71 | for i := 0; i < len(line); i++ { |
| 72 | c := line[i] |
| 73 | switch { |
| 74 | case quote != 0: |
| 75 | if c == quote { |
| 76 | quote = 0 |
| 77 | b.WriteByte(c) |
| 78 | } else { |
| 79 | b.WriteByte(' ') |
| 80 | } |
| 81 | case c == '"' || c == '\'': |
| 82 | quote = c |
| 83 | b.WriteByte(c) |
| 84 | default: |
| 85 | b.WriteByte(c) |
| 86 | } |
| 87 | } |
| 88 | return b.String() |
| 89 | } |
| 90 | |
| 91 | // templateFiles returns every .html file under the repo. |
| 92 | func templateFiles(t *testing.T, root string) []string { |
| 93 | t.Helper() |
| 94 | var out []string |
| 95 | err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { |
| 96 | if err != nil { |
| 97 | return err |
| 98 | } |
| 99 | if d.IsDir() { |
| 100 | if strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules" { |
| 101 | return filepath.SkipDir |
| 102 | } |
| 103 | return nil |
| 104 | } |
| 105 | if strings.HasSuffix(d.Name(), ".html") { |
| 106 | out = append(out, relPath(t, root, path)) |
| 107 | } |
| 108 | return nil |
| 109 | }) |
| 110 | if err != nil { |
| 111 | t.Fatalf("guards: walk: %v", err) |
| 112 | } |
| 113 | return out |
| 114 | } |
| 115 | |
| 116 | // badgesThatSwallowProse are rastrillo attributes whose rule gives the |
| 117 | // element a FIXED width and height. Wrapped around a sentence they keep |
| 118 | // the box and let the text spill out of it. |
| 119 | // |
| 120 | // rst-help is the case that produced this test. It is the little "?" |
| 121 | // beside a label — tokens.css gives it inline-flex, 28px square, a |
| 122 | // border and a radius. Given a paragraph it rendered one or two words |
| 123 | // per line with the badge's border struck through the middle, and ran |
| 124 | // off the left edge of a phone. |
| 125 | // |
| 126 | // It is here rather than in a review checklist because it came back. |
| 127 | // Fixed on the sign-in screen, it reappeared within days on the share |
| 128 | // and history screens, written by a different session reaching for the |
| 129 | // obvious name for help text. The name is a trap and prose is the bait, |
| 130 | // so the trap is what gets guarded. |
| 131 | // |
| 132 | // The prose token is rst-field-help. |
| 133 | var badgesThatSwallowProse = []string{"rst-help"} |
| 134 | |
| 135 | // TestBadgeAttributesNeverWrapProse fails on a badge holding a sentence. |
| 136 | // |
| 137 | // The threshold is deliberately generous: a badge legitimately holds an |
| 138 | // icon, a glyph, or a very short label, and this test must not argue |
| 139 | // with that. What it catches is prose — anything past a handful of |
| 140 | // characters, which no 28px box can show. |
| 141 | func TestBadgeAttributesNeverWrapProse(t *testing.T) { |
| 142 | root := repoRoot(t) |
| 143 | dir := filepath.Join(root, "internal/sheets/templates") |
| 144 | entries, err := os.ReadDir(dir) |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | for _, name := range badgesThatSwallowProse { |
| 149 | re := regexp.MustCompile(`(?s)<(\w+)\s[^>]*\b` + |
| 150 | regexp.QuoteMeta(name) + `\b[^>]*>(.*?)</`) |
| 151 | for _, e := range entries { |
| 152 | if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") { |
| 153 | continue |
| 154 | } |
| 155 | body, err := os.ReadFile(filepath.Join(dir, e.Name())) |
| 156 | if err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | // Template comments first: they discuss this attribute by |
| 160 | // name, and matching inside one would fail on the very |
| 161 | // prose that explains the rule. |
| 162 | clean := regexp.MustCompile(`(?s)\{\{/\*.*?\*/\}\}`). |
| 163 | ReplaceAllString(string(body), "") |
| 164 | for _, m := range re.FindAllStringSubmatch(clean, -1) { |
| 165 | inner := strings.TrimSpace(m[2]) |
| 166 | if len(inner) > 24 { |
| 167 | t.Errorf("%s: [%s] wraps %d characters of prose. It is a "+ |
| 168 | "28px square badge — the box stays and the text "+ |
| 169 | "spills out of it. Use rst-field-help.\n %.60s", |
| 170 | e.Name(), name, len(inner), inner) |
| 171 | } |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | |