//go:build browser package check // markup-spelling: old-spelling begin — the fixture this file drives is // authored in the class spelling on purpose: it is what the attribute // spelling is compared against. It ends at the file's end. // The evidence for stage 1 of the markup migration (design spec §6-v3), // from rastrillo's ui/markup_v3_browser_test.go at the seed commit. // // The untagged spelling gates prove the two spellings are paired in the // text of tokens.css and weigh the same on paper. That is a string // check, and a string check cannot say what a browser does with the // file. This one can: it renders the same fixture twice — every partial // and every styleguide sample, once written in classes and once // rewritten into attributes by the same translation the gate uses — and // requires the computed style of every element, and of its ::before and // ::after, to come back identical, property by property. // // A third pass renders the fixture with every rst- class and attribute // stripped out. It is the check on the check: if the stylesheet were // not reaching the fixture at all, the first two passes would agree // perfectly and mean nothing. The bare pass has to DIFFER, on a lot of // elements, or this test is measuring an unstyled page against another // unstyled page and calling it a match. // // Both spellings are read in separate navigations rather than side by // side in one document, deliberately: two copies of the same markup in // one page share
exclusivity groups and element ids, and // a menu closing its twin would show up here as a styling difference // that is nothing of the sort. import ( "context" "encoding/json" "fmt" "net/http" "regexp" "sort" "strings" "testing" "time" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/harness" "amadan.net/rastrillo/fiesta/internal/markup" ) // ── The fixture ────────────────────────────────────────────────────── // classFixture is the whole fixture in the class spelling, seeded from // rastrillo's classSpelling(t, classFixture(t)): the extras that the // partials and the styleguide samples between them do not reach (the // button and badge variants, the three tones in both their spellings, // the card corner rules that are wrapped in :where(), the compact // input, the large person, the menu surfaces, and the utilities that // must NOT move), every styleguide sample, and every partial with its // own test fixture. Every rule shape in tokens.css is represented // somewhere across the three sources — the coverage check below is // what says so, and fails when a new rule arrives with nothing // rendering it. TestTheClassFixtureIsGenuinelyClassSpelled // (spelling_fixture_test.go) holds the file to the class spelling. func classFixture(t *testing.T) string { t.Helper() return fixture(t, "markup_v3_browser_test.go", "classFixture", "") } // attributeSpelling is the fixture in the other spelling: the pure // grammar, without the migration's renames, because the two spellings // must mean the same thing rather than one being an upgrade of the // other. A note is a shape the rewrite left alone, which would compare // an element against itself and prove nothing about it. func attributeSpelling(t *testing.T, html string) string { t.Helper() out, notes := markup.Respell([]byte(html)) for _, n := range notes { t.Errorf("respelling the fixture: %s", n) } return string(out) } // bareRSTMarkup strips the vocabulary out altogether — every rst- class // token and the tone attribute — leaving the same elements with none of // the styling. It is the control pass. var ( rstClassToken = regexp.MustCompile(`\brst-[A-Za-z0-9_-]+\s*`) toneAttribute = regexp.MustCompile(`\s*data-tone="[a-z]+"`) ) func bareRSTMarkup(markup string) string { out := classInMarkup.ReplaceAllStringFunc(markup, func(m string) string { value := m[len(`class="`) : len(m)-1] return fmt.Sprintf("class=%q", strings.TrimSpace(rstClassToken.ReplaceAllString(value, ""))) }) return toneAttribute.ReplaceAllString(out, "") } // ── The drive ──────────────────────────────────────────────────────── // styleProperties is what each element is compared on: every property // tokens.css declares anywhere, plus a fixed list of the ones a // difference would show up in even if no rule names them directly. The // declared set is read from the file rather than typed out, so a rule // that starts painting something new is compared on it without anybody // remembering to add it here. func styleProperties(t testing.TB) []string { t.Helper() // Read as declarations: a pattern over the text found property // names inside strings, and missed one written in capitals. decls, err := declarationsOf("tokens.css", string(fiesta.TokensCSS())) if err != nil { t.Fatal(err) } seen := map[string]bool{} for _, d := range decls { if !strings.HasPrefix(d.Property, "--") { seen[d.Property] = true } } for _, name := range []string{ "display", "position", "visibility", "opacity", "z-index", "content", "cursor", "color", "background-color", "background-image", "box-shadow", "transform", "inline-size", "block-size", "font-size", "font-weight", "font-family", "line-height", "letter-spacing", "text-transform", "text-decoration-line", "white-space", "border-block-start-width", "border-block-start-color", "border-start-start-radius", "border-end-end-radius", "outline-color", "outline-width", "outline-offset", "margin-block-start", "margin-inline-start", "padding-block-start", "padding-inline-start", "inset-block-start", "inset-inline-start", "flex-grow", "flex-shrink", "flex-basis", "gap", "grid-template-columns", "align-items", "justify-content", "overflow-x", "overflow-y", "animation-name", "transition-property", "pointer-events", "list-style-type", "appearance", } { seen[name] = true } out := make([]string, 0, len(seen)) for name := range seen { out = append(out, name) } sort.Strings(out) return out } // digestJS reads one pass: for every element under #fixture, a digest // of its computed style and of its ::before and ::after, tagged with // the element's tag so a structural difference reads as one. Digests // rather than the values themselves because the values are ~100 // properties over three origins over several hundred elements, and the // detail is only wanted where two passes disagree — detailJS below // fetches exactly that, for exactly those elements. // // Animations are frozen at their first frame before anything is read. // .rst-spin really is turning while the page is open, so its transform // is a different matrix every millisecond — two passes read microseconds // apart would disagree about it and about nothing else, which is a flake // wearing the costume of a real finding. const freezeAnimations = `if (document.getAnimations) document.getAnimations().forEach(function (a) { try { a.currentTime = 0; a.pause(); } catch (e) {} });` // settleJS waits until a read of the page would read the same again: // the document loaded, its fonts ready, every animation and transition // that has an end at its end (the ones that never end, .rst-spin's, are // frozen by the read itself), then two frames, so style and layout have // caught up with all of it. A read taken sooner, on a busy runner, // caught one element at 390px mid-change and reported a difference that // a second read of the same page did not find. const settleJS = `(async () => { if (document.readyState !== "complete") await new Promise(r => addEventListener("load", r, {once: true})); await document.fonts.ready; for (;;) { const running = document.getAnimations().filter(a => a.playState === "running" && a.effect && a.effect.getComputedTiming().endTime !== Infinity); if (running.length === 0) break; await Promise.all(running.map(a => a.finished.catch(() => null))); } await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); return true; })()` const digestJS = `(function (props) { ` + freezeAnimations + ` var out = [], els = document.querySelectorAll("#fixture *"); for (var i = 0; i < els.length; i++) { var el = els[i], parts = [el.tagName]; var origins = [null, "::before", "::after"]; for (var o = 0; o < origins.length; o++) { var cs = getComputedStyle(el, origins[o]); for (var p = 0; p < props.length; p++) parts.push(cs.getPropertyValue(props[p])); } var s = parts.join("\u0001"), h = 5381; for (var c = 0; c < s.length; c++) h = ((h * 33) ^ s.charCodeAt(c)) >>> 0; out.push(el.tagName + "|" + h.toString(16)); } return out; })(%s)` // detailJS is the same reading, unhashed, for a handful of elements: it // is what turns "element 91 differs" into "element 91's background-color // differs", which is the difference between a failure someone can act on // and one they have to reproduce by hand. const detailJS = `(function (props, want) { ` + freezeAnimations + ` var out = {}, els = document.querySelectorAll("#fixture *"); for (var w = 0; w < want.length; w++) { var i = want[w], el = els[i]; if (!el) continue; var row = {tag: el.tagName, html: el.outerHTML.slice(0, 200), style: {}}; var origins = [["", null], ["::before", "::before"], ["::after", "::after"]]; for (var o = 0; o < origins.length; o++) { var cs = getComputedStyle(el, origins[o][1]); for (var p = 0; p < props.length; p++) row.style[origins[o][0] + props[p]] = cs.getPropertyValue(props[p]); } out[String(i)] = row; } return out; })(%s, %s)` type styleDetail struct { Tag string `json:"tag"` HTML string `json:"html"` Style map[string]string `json:"style"` } // TestBothSpellingsComputeTheSameStyles is the only evidence that // matters for stage 1: a browser, the real tokens.css, and the same // fixture in both vocabularies. func TestBothSpellingsComputeTheSameStyles(t *testing.T) { // The seeded fixture is the whole page in the class spelling, as // rastrillo translated it back from the partials and the styleguide, // which write attributes since the stage-2 flip, plus a hand-written // block that was always in classes; it is translated forward here. // Everything that survives the round trip unchanged is markup // neither spelling owns. fixture := classFixture(t) assertFixtureCoversTokensCSS(t, fixture) spellings := map[string]string{ "class": fixture, "attr": attributeSpelling(t, fixture), "bare": bareRSTMarkup(fixture), } if spellings["attr"] == spellings["class"] { t.Fatal("the attribute spelling of the fixture is identical to the class spelling — the translation did nothing") } if strings.Contains(spellings["attr"], `class="rst-b`) { t.Errorf("the attribute spelling still carries component classes: %s", firstMatch(spellings["attr"], `class="rst-b`)) } rig := harness.New(t, func(string) http.Handler { return spellingPage(t, spellings) }) ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) defer cancel() props := styleProperties(t) propsJSON, err := json.Marshal(props) if err != nil { t.Fatalf("marshalling the property list: %v", err) } t.Logf("comparing %d computed properties, over the element and its ::before and ::after", len(props)) // The width is a parameter because the shells' phone rules (the // index, the back control, the card) are scoped below 800px: read // only at the default width they would be paired on paper and never // computed. read := func(spelling string, width int64) []string { t.Helper() var got []string if err := chromedp.Run(ctx, chromedp.EmulateViewport(width, 900), chromedp.Navigate(rig.Origin+"/?spelling="+spelling), chromedp.WaitVisible("#fixture", chromedp.ByQuery), chromedp.Evaluate(settleJS, nil, awaitPromise), chromedp.Evaluate(fmt.Sprintf(digestJS, propsJSON), &got), ); err != nil { t.Fatalf("reading the %s pass at %dpx: %v", spelling, width, err) } if len(got) == 0 { t.Fatalf("the %s pass at %dpx found no elements under #fixture", spelling, width) } return got } // Both passes must agree: 1280 for the wide rules, 390 for the // narrow ones. for _, width := range []int64{1280, 390} { byClass, byAttr, bare := read("class", width), read("attr", width), read("bare", width) t.Logf("at %dpx: %d elements in the fixture", width, len(byClass)) if len(byClass) != len(byAttr) { t.Fatalf("at %dpx: the two spellings render different DOMs: %d elements in classes, %d in attributes — "+ "the translation lost or gained markup, so nothing below is comparable", width, len(byClass), len(byAttr)) } // The control first: if the stylesheet is not reaching the fixture, // class == attr is worth nothing. if len(bare) != len(byClass) { t.Fatalf("at %dpx: the bare pass renders %d elements against the class pass's %d", width, len(bare), len(byClass)) } styled := 0 for i := range bare { if bare[i] != byClass[i] { styled++ } } if styled < len(byClass)/4 { t.Fatalf("at %dpx: stripping every rst- class and attribute changed only %d of %d elements: "+ "tokens.css is not reaching this fixture, so an agreement between the two spellings proves nothing", width, styled, len(byClass)) } t.Logf("at %dpx: the control pass differs on %d of %d elements, so the stylesheet is reaching the fixture", width, styled, len(byClass)) differing := func() []int { var out []int for i := range byClass { if byClass[i] != byAttr[i] { out = append(out, i) } } return out } differ := differing() // A difference is read twice before it is reported: both // spellings again, each on a freshly loaded and settled page, // compared exactly as before. What still differs is reported // with the properties that moved; what did not was a read of a // page still changing, and the log says so. if len(differ) > 0 { t.Logf("at %dpx: %d elements differ on the first read; reading both spellings again", width, len(differ)) byClass, byAttr = read("class", width), read("attr", width) if len(byClass) != len(byAttr) { t.Fatalf("at %dpx, read again: the two spellings render different DOMs: %d elements in classes, %d in attributes", width, len(byClass), len(byAttr)) } differ = differing() if len(differ) == 0 { t.Logf("at %dpx: read again, the two spellings agree on every element", width) } } if len(differ) == 0 { rig.Screen("#fixture", fmt.Sprintf("the fixture in both spellings at %dpx", width)) continue } // Something disagrees. Go back for the values, and say which // property moved rather than which element did. show := differ if len(show) > 6 { show = show[:6] } wantJSON, err := json.Marshal(show) if err != nil { t.Fatalf("marshalling the element list: %v", err) } detail := func(spelling string) map[string]styleDetail { var got map[string]styleDetail if err := chromedp.Run(ctx, chromedp.EmulateViewport(width, 900), chromedp.Navigate(rig.Origin+"/?spelling="+spelling), chromedp.WaitVisible("#fixture", chromedp.ByQuery), chromedp.Evaluate(settleJS, nil, awaitPromise), chromedp.Evaluate(fmt.Sprintf(detailJS, propsJSON, wantJSON), &got), ); err != nil { t.Fatalf("reading the %s detail at %dpx: %v", spelling, width, err) } return got } classDetail, attrDetail := detail("class"), detail("attr") for _, i := range show { key := fmt.Sprint(i) c, a := classDetail[key], attrDetail[key] var moved []string names := make([]string, 0, len(c.Style)) for name := range c.Style { names = append(names, name) } sort.Strings(names) for _, name := range names { if c.Style[name] != a.Style[name] { moved = append(moved, fmt.Sprintf("%s: class %q, attribute %q", name, c.Style[name], a.Style[name])) } } if len(moved) == 0 { moved = append(moved, "no property differs on a third read of both, so the difference comes and goes between loads") } t.Errorf("at %dpx: element %d (<%s>) computes differently in the two spellings:\n\tclass markup: %s\n\tattribute markup: %s\n\t%s", width, i, strings.ToLower(c.Tag), c.HTML, a.HTML, strings.Join(moved, "\n\t")) } t.Errorf("at %dpx: %d of %d elements compute differently in the two spellings; the first %d are above", width, len(differ), len(byClass), len(show)) } } // assertFixtureCoversTokensCSS is the coverage side of the drive: a // pairing the fixture never renders is a pairing this test never // checked, so every rst- class tokens.css styles has to appear in the // markup above. A new rule with nothing rendering it fails here, by // name, before the browser is even started. func assertFixtureCoversTokensCSS(t *testing.T, fixture string) { t.Helper() styled := map[string]bool{} for class := range styledClasses(t, string(fiesta.TokensCSS())) { styled[class] = true } used := map[string]bool{} for _, m := range classInMarkup.FindAllStringSubmatch(fixture, -1) { for _, token := range strings.Fields(m[1]) { used[token] = true } } var missing []string for class := range styled { if !used[class] { missing = append(missing, class) } } sort.Strings(missing) if len(missing) > 0 { t.Errorf("tokens.css styles %d classes the fixture never renders, so their pairing is unproven: %s\n"+ "\tadd them to the seeded fixture", len(missing), strings.Join(missing, " ")) } t.Logf("the fixture renders all %d rst- classes tokens.css styles", len(styled)) } func firstMatch(s, needle string) string { i := strings.Index(s, needle) if i < 0 { return "" } end := i + 120 if end > len(s) { end = len(s) } return s[i:end] } // spellingPage serves the three passes off one origin: same stylesheet, // same theme, same viewport, only the markup differs. func spellingPage(t *testing.T, spellings map[string]string) http.Handler { t.Helper() mux := http.NewServeMux() stylesheets(t, mux) fixtureImages(mux) mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { body, ok := spellings[r.URL.Query().Get("spelling")] if !ok { http.Error(w, "unknown spelling", http.StatusNotFound) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, ``+ `two spellings`+ `
%s
`, body) }) return mux }