rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package 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
34import (
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.
66func 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.
76func 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.
88var (
89 rstClassToken = regexp.MustCompile(`\brst-[A-Za-z0-9_-]+\s*`)
90 toneAttribute = regexp.MustCompile(`\s*data-tone="[a-z]+"`)
91)
92
93func 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.
109func 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.
158const 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.
167const 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
179const 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.
200const 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
217type 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.
226func 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.
395func 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
421func 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.
435func 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