| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | // markup-spelling: old-spelling begin — the fixture this file drives is |
| 6 | // authored in the class spelling on purpose: it is what the attribute |
| 7 | // spelling is compared against. It ends at the file's end. |
| 8 | |
| 9 | // The evidence for stage 1 of the markup migration (design spec §6-v3), |
| 10 | // from rastrillo's ui/markup_v3_browser_test.go at the seed commit. |
| 11 | // |
| 12 | // The untagged spelling gates prove the two spellings are paired in the |
| 13 | // text of tokens.css and weigh the same on paper. That is a string |
| 14 | // check, and a string check cannot say what a browser does with the |
| 15 | // file. This one can: it renders the same fixture twice — every partial |
| 16 | // and every styleguide sample, once written in classes and once |
| 17 | // rewritten into attributes by the same translation the gate uses — and |
| 18 | // requires the computed style of every element, and of its ::before and |
| 19 | // ::after, to come back identical, property by property. |
| 20 | // |
| 21 | // A third pass renders the fixture with every rst- class and attribute |
| 22 | // stripped out. It is the check on the check: if the stylesheet were |
| 23 | // not reaching the fixture at all, the first two passes would agree |
| 24 | // perfectly and mean nothing. The bare pass has to DIFFER, on a lot of |
| 25 | // elements, or this test is measuring an unstyled page against another |
| 26 | // unstyled page and calling it a match. |
| 27 | // |
| 28 | // Both spellings are read in separate navigations rather than side by |
| 29 | // side in one document, deliberately: two copies of the same markup in |
| 30 | // one page share <details name> exclusivity groups and element ids, and |
| 31 | // a menu closing its twin would show up here as a styling difference |
| 32 | // that is nothing of the sort. |
| 33 | |
| 34 | import ( |
| 35 | "context" |
| 36 | "encoding/json" |
| 37 | "fmt" |
| 38 | "net/http" |
| 39 | "regexp" |
| 40 | "sort" |
| 41 | "strings" |
| 42 | "testing" |
| 43 | "time" |
| 44 | |
| 45 | "github.com/chromedp/chromedp" |
| 46 | |
| 47 | "amadan.net/rastrillo/fiesta" |
| 48 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 49 | "amadan.net/rastrillo/fiesta/internal/markup" |
| 50 | ) |
| 51 | |
| 52 | // ── The fixture ────────────────────────────────────────────────────── |
| 53 | |
| 54 | // classFixture is the whole fixture in the class spelling, seeded from |
| 55 | // rastrillo's classSpelling(t, classFixture(t)): the extras that the |
| 56 | // partials and the styleguide samples between them do not reach (the |
| 57 | // button and badge variants, the three tones in both their spellings, |
| 58 | // the card corner rules that are wrapped in :where(), the compact |
| 59 | // input, the large person, the menu surfaces, and the utilities that |
| 60 | // must NOT move), every styleguide sample, and every partial with its |
| 61 | // own test fixture. Every rule shape in tokens.css is represented |
| 62 | // somewhere across the three sources — the coverage check below is |
| 63 | // what says so, and fails when a new rule arrives with nothing |
| 64 | // rendering it. TestTheClassFixtureIsGenuinelyClassSpelled |
| 65 | // (spelling_fixture_test.go) holds the file to the class spelling. |
| 66 | func classFixture(t *testing.T) string { |
| 67 | t.Helper() |
| 68 | return fixture(t, "markup_v3_browser_test.go", "classFixture", "") |
| 69 | } |
| 70 | |
| 71 | // attributeSpelling is the fixture in the other spelling: the pure |
| 72 | // grammar, without the migration's renames, because the two spellings |
| 73 | // must mean the same thing rather than one being an upgrade of the |
| 74 | // other. A note is a shape the rewrite left alone, which would compare |
| 75 | // an element against itself and prove nothing about it. |
| 76 | func attributeSpelling(t *testing.T, html string) string { |
| 77 | t.Helper() |
| 78 | out, notes := markup.Respell([]byte(html)) |
| 79 | for _, n := range notes { |
| 80 | t.Errorf("respelling the fixture: %s", n) |
| 81 | } |
| 82 | return string(out) |
| 83 | } |
| 84 | |
| 85 | // bareRSTMarkup strips the vocabulary out altogether — every rst- class |
| 86 | // token and the tone attribute — leaving the same elements with none of |
| 87 | // the styling. It is the control pass. |
| 88 | var ( |
| 89 | rstClassToken = regexp.MustCompile(`\brst-[A-Za-z0-9_-]+\s*`) |
| 90 | toneAttribute = regexp.MustCompile(`\s*data-tone="[a-z]+"`) |
| 91 | ) |
| 92 | |
| 93 | func bareRSTMarkup(markup string) string { |
| 94 | out := classInMarkup.ReplaceAllStringFunc(markup, func(m string) string { |
| 95 | value := m[len(`class="`) : len(m)-1] |
| 96 | return fmt.Sprintf("class=%q", strings.TrimSpace(rstClassToken.ReplaceAllString(value, ""))) |
| 97 | }) |
| 98 | return toneAttribute.ReplaceAllString(out, "") |
| 99 | } |
| 100 | |
| 101 | // ── The drive ──────────────────────────────────────────────────────── |
| 102 | |
| 103 | // styleProperties is what each element is compared on: every property |
| 104 | // tokens.css declares anywhere, plus a fixed list of the ones a |
| 105 | // difference would show up in even if no rule names them directly. The |
| 106 | // declared set is read from the file rather than typed out, so a rule |
| 107 | // that starts painting something new is compared on it without anybody |
| 108 | // remembering to add it here. |
| 109 | func styleProperties(t testing.TB) []string { |
| 110 | t.Helper() |
| 111 | // Read as declarations: a pattern over the text found property |
| 112 | // names inside strings, and missed one written in capitals. |
| 113 | decls, err := declarationsOf("tokens.css", string(fiesta.TokensCSS())) |
| 114 | if err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | seen := map[string]bool{} |
| 118 | for _, d := range decls { |
| 119 | if !strings.HasPrefix(d.Property, "--") { |
| 120 | seen[d.Property] = true |
| 121 | } |
| 122 | } |
| 123 | for _, name := range []string{ |
| 124 | "display", "position", "visibility", "opacity", "z-index", "content", "cursor", |
| 125 | "color", "background-color", "background-image", "box-shadow", "transform", |
| 126 | "inline-size", "block-size", "font-size", "font-weight", "font-family", "line-height", |
| 127 | "letter-spacing", "text-transform", "text-decoration-line", "white-space", |
| 128 | "border-block-start-width", "border-block-start-color", "border-start-start-radius", |
| 129 | "border-end-end-radius", "outline-color", "outline-width", "outline-offset", |
| 130 | "margin-block-start", "margin-inline-start", "padding-block-start", "padding-inline-start", |
| 131 | "inset-block-start", "inset-inline-start", "flex-grow", "flex-shrink", "flex-basis", |
| 132 | "gap", "grid-template-columns", "align-items", "justify-content", "overflow-x", "overflow-y", |
| 133 | "animation-name", "transition-property", "pointer-events", "list-style-type", "appearance", |
| 134 | } { |
| 135 | seen[name] = true |
| 136 | } |
| 137 | out := make([]string, 0, len(seen)) |
| 138 | for name := range seen { |
| 139 | out = append(out, name) |
| 140 | } |
| 141 | sort.Strings(out) |
| 142 | return out |
| 143 | } |
| 144 | |
| 145 | // digestJS reads one pass: for every element under #fixture, a digest |
| 146 | // of its computed style and of its ::before and ::after, tagged with |
| 147 | // the element's tag so a structural difference reads as one. Digests |
| 148 | // rather than the values themselves because the values are ~100 |
| 149 | // properties over three origins over several hundred elements, and the |
| 150 | // detail is only wanted where two passes disagree — detailJS below |
| 151 | // fetches exactly that, for exactly those elements. |
| 152 | // |
| 153 | // Animations are frozen at their first frame before anything is read. |
| 154 | // .rst-spin really is turning while the page is open, so its transform |
| 155 | // is a different matrix every millisecond — two passes read microseconds |
| 156 | // apart would disagree about it and about nothing else, which is a flake |
| 157 | // wearing the costume of a real finding. |
| 158 | const freezeAnimations = `if (document.getAnimations) document.getAnimations().forEach(function (a) { try { a.currentTime = 0; a.pause(); } catch (e) {} });` |
| 159 | |
| 160 | // settleJS waits until a read of the page would read the same again: |
| 161 | // the document loaded, its fonts ready, every animation and transition |
| 162 | // that has an end at its end (the ones that never end, .rst-spin's, are |
| 163 | // frozen by the read itself), then two frames, so style and layout have |
| 164 | // caught up with all of it. A read taken sooner, on a busy runner, |
| 165 | // caught one element at 390px mid-change and reported a difference that |
| 166 | // a second read of the same page did not find. |
| 167 | const settleJS = `(async () => { |
| 168 | if (document.readyState !== "complete") await new Promise(r => addEventListener("load", r, {once: true})); |
| 169 | await document.fonts.ready; |
| 170 | for (;;) { |
| 171 | const running = document.getAnimations().filter(a => a.playState === "running" && a.effect && a.effect.getComputedTiming().endTime !== Infinity); |
| 172 | if (running.length === 0) break; |
| 173 | await Promise.all(running.map(a => a.finished.catch(() => null))); |
| 174 | } |
| 175 | await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); |
| 176 | return true; |
| 177 | })()` |
| 178 | |
| 179 | const digestJS = `(function (props) { |
| 180 | ` + freezeAnimations + ` |
| 181 | var out = [], els = document.querySelectorAll("#fixture *"); |
| 182 | for (var i = 0; i < els.length; i++) { |
| 183 | var el = els[i], parts = [el.tagName]; |
| 184 | var origins = [null, "::before", "::after"]; |
| 185 | for (var o = 0; o < origins.length; o++) { |
| 186 | var cs = getComputedStyle(el, origins[o]); |
| 187 | for (var p = 0; p < props.length; p++) parts.push(cs.getPropertyValue(props[p])); |
| 188 | } |
| 189 | var s = parts.join("\u0001"), h = 5381; |
| 190 | for (var c = 0; c < s.length; c++) h = ((h * 33) ^ s.charCodeAt(c)) >>> 0; |
| 191 | out.push(el.tagName + "|" + h.toString(16)); |
| 192 | } |
| 193 | return out; |
| 194 | })(%s)` |
| 195 | |
| 196 | // detailJS is the same reading, unhashed, for a handful of elements: it |
| 197 | // is what turns "element 91 differs" into "element 91's background-color |
| 198 | // differs", which is the difference between a failure someone can act on |
| 199 | // and one they have to reproduce by hand. |
| 200 | const detailJS = `(function (props, want) { |
| 201 | ` + freezeAnimations + ` |
| 202 | var out = {}, els = document.querySelectorAll("#fixture *"); |
| 203 | for (var w = 0; w < want.length; w++) { |
| 204 | var i = want[w], el = els[i]; |
| 205 | if (!el) continue; |
| 206 | var row = {tag: el.tagName, html: el.outerHTML.slice(0, 200), style: {}}; |
| 207 | var origins = [["", null], ["::before", "::before"], ["::after", "::after"]]; |
| 208 | for (var o = 0; o < origins.length; o++) { |
| 209 | var cs = getComputedStyle(el, origins[o][1]); |
| 210 | for (var p = 0; p < props.length; p++) row.style[origins[o][0] + props[p]] = cs.getPropertyValue(props[p]); |
| 211 | } |
| 212 | out[String(i)] = row; |
| 213 | } |
| 214 | return out; |
| 215 | })(%s, %s)` |
| 216 | |
| 217 | type styleDetail struct { |
| 218 | Tag string `json:"tag"` |
| 219 | HTML string `json:"html"` |
| 220 | Style map[string]string `json:"style"` |
| 221 | } |
| 222 | |
| 223 | // TestBothSpellingsComputeTheSameStyles is the only evidence that |
| 224 | // matters for stage 1: a browser, the real tokens.css, and the same |
| 225 | // fixture in both vocabularies. |
| 226 | func TestBothSpellingsComputeTheSameStyles(t *testing.T) { |
| 227 | // The seeded fixture is the whole page in the class spelling, as |
| 228 | // rastrillo translated it back from the partials and the styleguide, |
| 229 | // which write attributes since the stage-2 flip, plus a hand-written |
| 230 | // block that was always in classes; it is translated forward here. |
| 231 | // Everything that survives the round trip unchanged is markup |
| 232 | // neither spelling owns. |
| 233 | fixture := classFixture(t) |
| 234 | assertFixtureCoversTokensCSS(t, fixture) |
| 235 | |
| 236 | spellings := map[string]string{ |
| 237 | "class": fixture, |
| 238 | "attr": attributeSpelling(t, fixture), |
| 239 | "bare": bareRSTMarkup(fixture), |
| 240 | } |
| 241 | if spellings["attr"] == spellings["class"] { |
| 242 | t.Fatal("the attribute spelling of the fixture is identical to the class spelling — the translation did nothing") |
| 243 | } |
| 244 | if strings.Contains(spellings["attr"], `class="rst-b`) { |
| 245 | t.Errorf("the attribute spelling still carries component classes: %s", firstMatch(spellings["attr"], `class="rst-b`)) |
| 246 | } |
| 247 | |
| 248 | rig := harness.New(t, func(string) http.Handler { return spellingPage(t, spellings) }) |
| 249 | ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) |
| 250 | defer cancel() |
| 251 | |
| 252 | props := styleProperties(t) |
| 253 | propsJSON, err := json.Marshal(props) |
| 254 | if err != nil { |
| 255 | t.Fatalf("marshalling the property list: %v", err) |
| 256 | } |
| 257 | t.Logf("comparing %d computed properties, over the element and its ::before and ::after", len(props)) |
| 258 | |
| 259 | // The width is a parameter because the shells' phone rules (the |
| 260 | // index, the back control, the card) are scoped below 800px: read |
| 261 | // only at the default width they would be paired on paper and never |
| 262 | // computed. |
| 263 | read := func(spelling string, width int64) []string { |
| 264 | t.Helper() |
| 265 | var got []string |
| 266 | if err := chromedp.Run(ctx, |
| 267 | chromedp.EmulateViewport(width, 900), |
| 268 | chromedp.Navigate(rig.Origin+"/?spelling="+spelling), |
| 269 | chromedp.WaitVisible("#fixture", chromedp.ByQuery), |
| 270 | chromedp.Evaluate(settleJS, nil, awaitPromise), |
| 271 | chromedp.Evaluate(fmt.Sprintf(digestJS, propsJSON), &got), |
| 272 | ); err != nil { |
| 273 | t.Fatalf("reading the %s pass at %dpx: %v", spelling, width, err) |
| 274 | } |
| 275 | if len(got) == 0 { |
| 276 | t.Fatalf("the %s pass at %dpx found no elements under #fixture", spelling, width) |
| 277 | } |
| 278 | return got |
| 279 | } |
| 280 | |
| 281 | // Both passes must agree: 1280 for the wide rules, 390 for the |
| 282 | // narrow ones. |
| 283 | for _, width := range []int64{1280, 390} { |
| 284 | byClass, byAttr, bare := read("class", width), read("attr", width), read("bare", width) |
| 285 | t.Logf("at %dpx: %d elements in the fixture", width, len(byClass)) |
| 286 | |
| 287 | if len(byClass) != len(byAttr) { |
| 288 | t.Fatalf("at %dpx: the two spellings render different DOMs: %d elements in classes, %d in attributes — "+ |
| 289 | "the translation lost or gained markup, so nothing below is comparable", width, len(byClass), len(byAttr)) |
| 290 | } |
| 291 | |
| 292 | // The control first: if the stylesheet is not reaching the fixture, |
| 293 | // class == attr is worth nothing. |
| 294 | if len(bare) != len(byClass) { |
| 295 | t.Fatalf("at %dpx: the bare pass renders %d elements against the class pass's %d", width, len(bare), len(byClass)) |
| 296 | } |
| 297 | styled := 0 |
| 298 | for i := range bare { |
| 299 | if bare[i] != byClass[i] { |
| 300 | styled++ |
| 301 | } |
| 302 | } |
| 303 | if styled < len(byClass)/4 { |
| 304 | t.Fatalf("at %dpx: stripping every rst- class and attribute changed only %d of %d elements: "+ |
| 305 | "tokens.css is not reaching this fixture, so an agreement between the two spellings proves nothing", |
| 306 | width, styled, len(byClass)) |
| 307 | } |
| 308 | t.Logf("at %dpx: the control pass differs on %d of %d elements, so the stylesheet is reaching the fixture", width, styled, len(byClass)) |
| 309 | |
| 310 | differing := func() []int { |
| 311 | var out []int |
| 312 | for i := range byClass { |
| 313 | if byClass[i] != byAttr[i] { |
| 314 | out = append(out, i) |
| 315 | } |
| 316 | } |
| 317 | return out |
| 318 | } |
| 319 | differ := differing() |
| 320 | // A difference is read twice before it is reported: both |
| 321 | // spellings again, each on a freshly loaded and settled page, |
| 322 | // compared exactly as before. What still differs is reported |
| 323 | // with the properties that moved; what did not was a read of a |
| 324 | // page still changing, and the log says so. |
| 325 | if len(differ) > 0 { |
| 326 | t.Logf("at %dpx: %d elements differ on the first read; reading both spellings again", width, len(differ)) |
| 327 | byClass, byAttr = read("class", width), read("attr", width) |
| 328 | if len(byClass) != len(byAttr) { |
| 329 | t.Fatalf("at %dpx, read again: the two spellings render different DOMs: %d elements in classes, %d in attributes", width, len(byClass), len(byAttr)) |
| 330 | } |
| 331 | differ = differing() |
| 332 | if len(differ) == 0 { |
| 333 | t.Logf("at %dpx: read again, the two spellings agree on every element", width) |
| 334 | } |
| 335 | } |
| 336 | if len(differ) == 0 { |
| 337 | rig.Screen("#fixture", fmt.Sprintf("the fixture in both spellings at %dpx", width)) |
| 338 | continue |
| 339 | } |
| 340 | |
| 341 | // Something disagrees. Go back for the values, and say which |
| 342 | // property moved rather than which element did. |
| 343 | show := differ |
| 344 | if len(show) > 6 { |
| 345 | show = show[:6] |
| 346 | } |
| 347 | wantJSON, err := json.Marshal(show) |
| 348 | if err != nil { |
| 349 | t.Fatalf("marshalling the element list: %v", err) |
| 350 | } |
| 351 | detail := func(spelling string) map[string]styleDetail { |
| 352 | var got map[string]styleDetail |
| 353 | if err := chromedp.Run(ctx, |
| 354 | chromedp.EmulateViewport(width, 900), |
| 355 | chromedp.Navigate(rig.Origin+"/?spelling="+spelling), |
| 356 | chromedp.WaitVisible("#fixture", chromedp.ByQuery), |
| 357 | chromedp.Evaluate(settleJS, nil, awaitPromise), |
| 358 | chromedp.Evaluate(fmt.Sprintf(detailJS, propsJSON, wantJSON), &got), |
| 359 | ); err != nil { |
| 360 | t.Fatalf("reading the %s detail at %dpx: %v", spelling, width, err) |
| 361 | } |
| 362 | return got |
| 363 | } |
| 364 | classDetail, attrDetail := detail("class"), detail("attr") |
| 365 | for _, i := range show { |
| 366 | key := fmt.Sprint(i) |
| 367 | c, a := classDetail[key], attrDetail[key] |
| 368 | var moved []string |
| 369 | names := make([]string, 0, len(c.Style)) |
| 370 | for name := range c.Style { |
| 371 | names = append(names, name) |
| 372 | } |
| 373 | sort.Strings(names) |
| 374 | for _, name := range names { |
| 375 | if c.Style[name] != a.Style[name] { |
| 376 | moved = append(moved, fmt.Sprintf("%s: class %q, attribute %q", name, c.Style[name], a.Style[name])) |
| 377 | } |
| 378 | } |
| 379 | if len(moved) == 0 { |
| 380 | moved = append(moved, "no property differs on a third read of both, so the difference comes and goes between loads") |
| 381 | } |
| 382 | t.Errorf("at %dpx: element %d (<%s>) computes differently in the two spellings:\n\tclass markup: %s\n\tattribute markup: %s\n\t%s", |
| 383 | width, i, strings.ToLower(c.Tag), c.HTML, a.HTML, strings.Join(moved, "\n\t")) |
| 384 | } |
| 385 | t.Errorf("at %dpx: %d of %d elements compute differently in the two spellings; the first %d are above", |
| 386 | width, len(differ), len(byClass), len(show)) |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | // assertFixtureCoversTokensCSS is the coverage side of the drive: a |
| 391 | // pairing the fixture never renders is a pairing this test never |
| 392 | // checked, so every rst- class tokens.css styles has to appear in the |
| 393 | // markup above. A new rule with nothing rendering it fails here, by |
| 394 | // name, before the browser is even started. |
| 395 | func assertFixtureCoversTokensCSS(t *testing.T, fixture string) { |
| 396 | t.Helper() |
| 397 | styled := map[string]bool{} |
| 398 | for class := range styledClasses(t, string(fiesta.TokensCSS())) { |
| 399 | styled[class] = true |
| 400 | } |
| 401 | used := map[string]bool{} |
| 402 | for _, m := range classInMarkup.FindAllStringSubmatch(fixture, -1) { |
| 403 | for _, token := range strings.Fields(m[1]) { |
| 404 | used[token] = true |
| 405 | } |
| 406 | } |
| 407 | var missing []string |
| 408 | for class := range styled { |
| 409 | if !used[class] { |
| 410 | missing = append(missing, class) |
| 411 | } |
| 412 | } |
| 413 | sort.Strings(missing) |
| 414 | if len(missing) > 0 { |
| 415 | t.Errorf("tokens.css styles %d classes the fixture never renders, so their pairing is unproven: %s\n"+ |
| 416 | "\tadd them to the seeded fixture", len(missing), strings.Join(missing, " ")) |
| 417 | } |
| 418 | t.Logf("the fixture renders all %d rst- classes tokens.css styles", len(styled)) |
| 419 | } |
| 420 | |
| 421 | func firstMatch(s, needle string) string { |
| 422 | i := strings.Index(s, needle) |
| 423 | if i < 0 { |
| 424 | return "" |
| 425 | } |
| 426 | end := i + 120 |
| 427 | if end > len(s) { |
| 428 | end = len(s) |
| 429 | } |
| 430 | return s[i:end] |
| 431 | } |
| 432 | |
| 433 | // spellingPage serves the three passes off one origin: same stylesheet, |
| 434 | // same theme, same viewport, only the markup differs. |
| 435 | func spellingPage(t *testing.T, spellings map[string]string) http.Handler { |
| 436 | t.Helper() |
| 437 | mux := http.NewServeMux() |
| 438 | stylesheets(t, mux) |
| 439 | fixtureImages(mux) |
| 440 | mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { |
| 441 | body, ok := spellings[r.URL.Query().Get("spelling")] |
| 442 | if !ok { |
| 443 | http.Error(w, "unknown spelling", http.StatusNotFound) |
| 444 | return |
| 445 | } |
| 446 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 447 | fmt.Fprintf(w, `<!doctype html><html lang="en"><head><meta charset="utf-8">`+ |
| 448 | `<title>two spellings</title><link rel="stylesheet" href="/tokens.css">`+ |
| 449 | `<link rel="stylesheet" href="/theme.css"></head><body><div id="fixture">%s</div></body></html>`, body) |
| 450 | }) |
| 451 | return mux |
| 452 | } |
| 453 | |