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
5import (
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.
30func 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.
42func 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.
55func 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.
68func 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.
116func 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.
145var 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.
150func 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.
175func 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.
195type 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.
210func 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