| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "fmt" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "sort" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/chromedp/chromedp" |
| 17 | |
| 18 | "amadan.net/rastrillo/fiesta" |
| 19 | "amadan.net/rastrillo/fiesta/internal/build" |
| 20 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 21 | ) |
| 22 | |
| 23 | // The release is held to the tree it was built from, in a browser: |
| 24 | // the one-file themes paint the same pixels as the source stylesheets, |
| 25 | // the stripped scripts drive the same journeys, and a page on another |
| 26 | // origin can use all of it from one directory. |
| 27 | |
| 28 | // releaseDist is build.Dist over the embedded tree at the shipped |
| 29 | // version, once per test. |
| 30 | func releaseDist(t *testing.T) map[string][]byte { |
| 31 | t.Helper() |
| 32 | d, err := build.Dist(fiesta.FS(), fiesta.Version) |
| 33 | if err != nil { |
| 34 | t.Fatalf("building the release: %v", err) |
| 35 | } |
| 36 | return d |
| 37 | } |
| 38 | |
| 39 | // distScripts serves the release's scripts and i18n/*.js by the name |
| 40 | // the fixture mux asks for, in place of the tree's, so a drive written |
| 41 | // against the source runs against what ships. |
| 42 | func distScripts(t *testing.T) func(name string) ([]byte, bool) { |
| 43 | t.Helper() |
| 44 | d := releaseDist(t) |
| 45 | return func(name string) ([]byte, bool) { |
| 46 | if !strings.HasSuffix(name, ".js") { |
| 47 | return nil, false |
| 48 | } |
| 49 | b, ok := d[name] |
| 50 | return b, ok |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | // oneFile is the release's one-file theme for a theme. |
| 55 | func oneFile(theme string) string { |
| 56 | if theme == "day" { |
| 57 | return "fiesta.css" |
| 58 | } |
| 59 | return "fiesta-" + theme + ".css" |
| 60 | } |
| 61 | |
| 62 | // TestTheReleaseRendersAsTheSource: for every theme, the matrix page |
| 63 | // under the source tokens.css and theme paints exactly the pixels it |
| 64 | // paints under the release's one-file theme served alone, and again |
| 65 | // under the release's separate tokens.css and theme. Exactly: the |
| 66 | // strip removes comments and nothing else, so there is no corner |
| 67 | // case to allow for, and samePixelsWithin's cap is 0. |
| 68 | func TestTheReleaseRendersAsTheSource(t *testing.T) { |
| 69 | d := releaseDist(t) |
| 70 | for _, theme := range fiesta.ThemeNames() { |
| 71 | oldTheme, ok := fiesta.ThemeCSS(theme) |
| 72 | if !ok { |
| 73 | t.Fatalf("no theme %s", theme) |
| 74 | } |
| 75 | for _, c := range []struct { |
| 76 | name string |
| 77 | tokens, theme []byte |
| 78 | }{ |
| 79 | {"one file", d[oneFile(theme)], []byte{}}, |
| 80 | {"separate files", d["tokens.css"], d[theme+".css"]}, |
| 81 | } { |
| 82 | t.Run(theme+", "+c.name, func(t *testing.T) { |
| 83 | if len(c.tokens) == 0 { |
| 84 | t.Fatalf("the release has no stylesheet for %s", c.name) |
| 85 | } |
| 86 | rig := legacyRig(t, fiesta.TokensCSS(), oldTheme, c.tokens, c.theme, false) |
| 87 | ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| 88 | defer cancel() |
| 89 | for _, scheme := range []string{"light", "dark"} { |
| 90 | old := matrixShots(t, ctx, rig.Origin, "/old/", scheme) |
| 91 | now := matrixShots(t, ctx, rig.Origin, "/new/", scheme) |
| 92 | if len(old) == 0 { |
| 93 | t.Fatal("the matrix photographed nothing") |
| 94 | } |
| 95 | keys := make([]string, 0, len(old)) |
| 96 | for key := range old { |
| 97 | keys = append(keys, key) |
| 98 | } |
| 99 | sort.Strings(keys) |
| 100 | for _, key := range keys { |
| 101 | if ok, why := samePixelsWithin(t, old[key], now[key], 0); !ok { |
| 102 | t.Errorf("%s renders differently from the release: %s", key, why) |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | }) |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // TestTheReleaseScriptsDriveTheJourneys runs five drives, each on a |
| 112 | // rig whose scripts are the release's: the switch drag, the |
| 113 | // remembered scheme, the select filter, the calendar and the |
| 114 | // snippet's copy. Each drive's pages put their own document at /, so |
| 115 | // each has a rig of its own. |
| 116 | func TestTheReleaseScriptsDriveTheJourneys(t *testing.T) { |
| 117 | scripts := distScripts(t) |
| 118 | if b, ok := scripts("fiesta.js"); !ok || !bytes.HasPrefix(b, []byte(build.Banner(fiesta.Version))) { |
| 119 | t.Fatal("distScripts does not serve the release's fiesta.js") |
| 120 | } |
| 121 | for _, drive := range []struct { |
| 122 | name string |
| 123 | pages map[string]string |
| 124 | run func(*testing.T, *harness.Rig) |
| 125 | }{ |
| 126 | {"switch drag", switchDragPages, driveSwitchDrag}, |
| 127 | {"scheme remember", schemePages(), driveSchemeRemember}, |
| 128 | {"select filter", selectFilterPages(t), driveSelectFilter}, |
| 129 | {"calendar open", calendarPages(t), driveCalendarOpen}, |
| 130 | {"snippet copy", copyPages(), func(t *testing.T, rig *harness.Rig) { |
| 131 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 132 | defer cancel() |
| 133 | driveCopy(t, ctx, rig.Origin, "/") |
| 134 | }}, |
| 135 | } { |
| 136 | t.Run(drive.name, func(t *testing.T) { |
| 137 | pages := drive.pages |
| 138 | rig := harness.New(t, func(string) http.Handler { return sizingMuxWith(t, pages, scripts) }) |
| 139 | drive.run(t, rig) |
| 140 | }) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // contentTypes is what the release server says each file is. |
| 145 | var contentTypes = map[string]string{".css": "text/css", ".js": "text/javascript", ".svg": "image/svg+xml", ".json": "application/json"} |
| 146 | |
| 147 | // releaseServer serves the dist on 127.0.0.1, which is a different |
| 148 | // origin from the rig's localhost, as a CDN or a site's static host |
| 149 | // would be. |
| 150 | func releaseServer(t *testing.T, d map[string][]byte) string { |
| 151 | t.Helper() |
| 152 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 153 | b, ok := d[strings.TrimPrefix(r.URL.Path, "/")] |
| 154 | if !ok { |
| 155 | http.NotFound(w, r) |
| 156 | return |
| 157 | } |
| 158 | for ext, ct := range contentTypes { |
| 159 | if strings.HasSuffix(r.URL.Path, ext) { |
| 160 | w.Header().Set("Content-Type", ct) |
| 161 | } |
| 162 | } |
| 163 | w.Write(b) |
| 164 | })) |
| 165 | t.Cleanup(srv.Close) |
| 166 | if !strings.HasPrefix(srv.URL, "http://127.0.0.1:") { |
| 167 | t.Fatalf("the release server is at %s, not on 127.0.0.1", srv.URL) |
| 168 | } |
| 169 | return srv.URL |
| 170 | } |
| 171 | |
| 172 | // anotherOriginDoc is a Spanish page on the rig's origin that takes |
| 173 | // everything from the release server: fiesta.css, the scripts the |
| 174 | // date field needs, es.js, and every icon as an image. |
| 175 | func anotherOriginDoc(files string, slugs []string) string { |
| 176 | var icons strings.Builder |
| 177 | for _, slug := range slugs { |
| 178 | fmt.Fprintf(&icons, `<img id="icon-%s" src="%s/icons/%s.svg" alt="">`, slug, files, slug) |
| 179 | } |
| 180 | return `<!doctype html><html lang="es"><head><meta charset="utf-8"><title>release</title>` + |
| 181 | `<link rel="stylesheet" href="` + files + `/fiesta.css">` + |
| 182 | `<script defer src="` + files + `/fiesta.js"></script><script defer src="` + files + `/i18n/es.js"></script>` + |
| 183 | `<script defer src="` + files + `/calendar.js"></script><script defer src="` + files + `/datetime.js"></script>` + |
| 184 | `</head><body><div rst-page>` + |
| 185 | `<p><button rst-btn id="btn">Guardar</button></p>` + |
| 186 | `<div rst-field><label rst-field-label for="when">Fecha</label><input rst-input id="when" name="when" type="date" data-rst-date></div>` + |
| 187 | `<p id="icons">` + icons.String() + `</p>` + |
| 188 | `</div></body></html>` |
| 189 | } |
| 190 | |
| 191 | // anotherOrigin is what the page reports: the button's computed |
| 192 | // alignment and the root's accent token (the stylesheet, structure |
| 193 | // and theme), the armed reading's set label (the strings), and each |
| 194 | // icon's load. |
| 195 | type anotherOrigin struct { |
| 196 | AlignItems, Accent, Set string |
| 197 | Icons []struct { |
| 198 | Slug string |
| 199 | Complete bool |
| 200 | NaturalWidth, NaturalHeight int |
| 201 | Decoded string |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // TestTheReleaseWorksFromAnotherOrigin: a page on localhost links |
| 206 | // fiesta.css and loads fiesta.js, datetime.js, calendar.js and |
| 207 | // i18n/es.js from the release on 127.0.0.1. The button is styled, the |
| 208 | // date field's set label is es.json's, every standalone icon loads as |
| 209 | // an image, and the page reports no error. |
| 210 | func TestTheReleaseWorksFromAnotherOrigin(t *testing.T) { |
| 211 | d := releaseDist(t) |
| 212 | files := releaseServer(t, d) |
| 213 | slugs := fiesta.IconSlugs() |
| 214 | if len(slugs) == 0 { |
| 215 | t.Fatal("no icons") |
| 216 | } |
| 217 | es, want, tomorrow := loadES(t) |
| 218 | if !bytes.Contains(d["i18n/es.js"], es) { |
| 219 | t.Fatal("the release's es.js does not carry es.json") |
| 220 | } |
| 221 | page := anotherOriginDoc(files, slugs) |
| 222 | rig := harness.New(t, func(string) http.Handler { |
| 223 | mux := http.NewServeMux() |
| 224 | mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) { |
| 225 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 226 | fmt.Fprint(w, page) |
| 227 | }) |
| 228 | return mux |
| 229 | }) |
| 230 | if !strings.HasPrefix(rig.Origin, "http://localhost:") { |
| 231 | t.Fatalf("the page is at %s, not on localhost", rig.Origin) |
| 232 | } |
| 233 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 234 | defer cancel() |
| 235 | |
| 236 | mustRun(t, ctx, |
| 237 | chromedp.Navigate(rig.Origin+"/"), |
| 238 | chromedp.WaitVisible(`[rst-dtp] input[role="combobox"]`, chromedp.ByQuery), |
| 239 | chromedp.Click(`[rst-dtp] input[role="combobox"]`, chromedp.ByQuery), |
| 240 | chromedp.SendKeys(`[rst-dtp] input[role="combobox"]`, tomorrow, chromedp.ByQuery)) |
| 241 | settleUntil(t, ctx, `!!document.querySelector('[rst-dtp] [role="option"].is-active [rst-dtp-set]')`) |
| 242 | |
| 243 | var got anotherOrigin |
| 244 | at(t, ctx, `(async () => { |
| 245 | const icons = []; |
| 246 | for (const img of document.querySelectorAll("#icons img")) { |
| 247 | let decoded = ""; |
| 248 | try { await img.decode(); } catch (e) { decoded = e.name + ": " + e.message; } |
| 249 | icons.push({Slug: img.id.replace(/^icon-/, ""), Complete: img.complete, NaturalWidth: img.naturalWidth, NaturalHeight: img.naturalHeight, Decoded: decoded}); |
| 250 | } |
| 251 | return JSON.stringify({ |
| 252 | AlignItems: getComputedStyle(document.getElementById("btn")).alignItems, |
| 253 | Accent: getComputedStyle(document.documentElement).getPropertyValue("--rst-accent").trim(), |
| 254 | Set: document.querySelector('[rst-dtp] [role="option"].is-active [rst-dtp-set]')?.textContent ?? "", |
| 255 | Icons: icons}); |
| 256 | })()`, &got, awaitPromise) |
| 257 | |
| 258 | if got.AlignItems != "center" { |
| 259 | t.Errorf("[rst-btn]'s computed align-items is %q, want center: fiesta.css did not apply across origins", got.AlignItems) |
| 260 | } |
| 261 | if got.Accent == "" { |
| 262 | t.Error("--rst-accent is unset on the root: the theme half of fiesta.css did not apply") |
| 263 | } |
| 264 | if got.Set != want.Set { |
| 265 | t.Errorf("the set label is %q, want es.json's %q", got.Set, want.Set) |
| 266 | } |
| 267 | if len(got.Icons) != len(slugs) { |
| 268 | t.Fatalf("the page has %d icons, want %d", len(got.Icons), len(slugs)) |
| 269 | } |
| 270 | for _, icon := range got.Icons { |
| 271 | t.Logf("icons/%s.svg: complete %v, natural %dx%d, decode %q", icon.Slug, icon.Complete, icon.NaturalWidth, icon.NaturalHeight, icon.Decoded) |
| 272 | if !icon.Complete || icon.Decoded != "" { |
| 273 | t.Errorf("icons/%s.svg did not load as an image: complete %v, decode %q", icon.Slug, icon.Complete, icon.Decoded) |
| 274 | } |
| 275 | if icon.NaturalWidth <= 0 { |
| 276 | t.Errorf("icons/%s.svg has naturalWidth %d, want above 0", icon.Slug, icon.NaturalWidth) |
| 277 | } |
| 278 | } |
| 279 | rig.Screen("body", "the release used from another origin") |
| 280 | } |
| 281 | |