//go:build browser package check import ( "bytes" "context" "fmt" "net/http" "net/http/httptest" "sort" "strings" "testing" "time" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/internal/build" "amadan.net/rastrillo/fiesta/internal/harness" ) // The release is held to the tree it was built from, in a browser: // the one-file themes paint the same pixels as the source stylesheets, // the stripped scripts drive the same journeys, and a page on another // origin can use all of it from one directory. // releaseDist is build.Dist over the embedded tree at the shipped // version, once per test. func releaseDist(t *testing.T) map[string][]byte { t.Helper() d, err := build.Dist(fiesta.FS(), fiesta.Version) if err != nil { t.Fatalf("building the release: %v", err) } return d } // distScripts serves the release's scripts and i18n/*.js by the name // the fixture mux asks for, in place of the tree's, so a drive written // against the source runs against what ships. func distScripts(t *testing.T) func(name string) ([]byte, bool) { t.Helper() d := releaseDist(t) return func(name string) ([]byte, bool) { if !strings.HasSuffix(name, ".js") { return nil, false } b, ok := d[name] return b, ok } } // oneFile is the release's one-file theme for a theme. func oneFile(theme string) string { if theme == "day" { return "fiesta.css" } return "fiesta-" + theme + ".css" } // TestTheReleaseRendersAsTheSource: for every theme, the matrix page // under the source tokens.css and theme paints exactly the pixels it // paints under the release's one-file theme served alone, and again // under the release's separate tokens.css and theme. Exactly: the // strip removes comments and nothing else, so there is no corner // case to allow for, and samePixelsWithin's cap is 0. func TestTheReleaseRendersAsTheSource(t *testing.T) { d := releaseDist(t) for _, theme := range fiesta.ThemeNames() { oldTheme, ok := fiesta.ThemeCSS(theme) if !ok { t.Fatalf("no theme %s", theme) } for _, c := range []struct { name string tokens, theme []byte }{ {"one file", d[oneFile(theme)], []byte{}}, {"separate files", d["tokens.css"], d[theme+".css"]}, } { t.Run(theme+", "+c.name, func(t *testing.T) { if len(c.tokens) == 0 { t.Fatalf("the release has no stylesheet for %s", c.name) } rig := legacyRig(t, fiesta.TokensCSS(), oldTheme, c.tokens, c.theme, false) ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) defer cancel() for _, scheme := range []string{"light", "dark"} { old := matrixShots(t, ctx, rig.Origin, "/old/", scheme) now := matrixShots(t, ctx, rig.Origin, "/new/", scheme) if len(old) == 0 { t.Fatal("the matrix photographed nothing") } keys := make([]string, 0, len(old)) for key := range old { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { if ok, why := samePixelsWithin(t, old[key], now[key], 0); !ok { t.Errorf("%s renders differently from the release: %s", key, why) } } } }) } } } // TestTheReleaseScriptsDriveTheJourneys runs five drives, each on a // rig whose scripts are the release's: the switch drag, the // remembered scheme, the select filter, the calendar and the // snippet's copy. Each drive's pages put their own document at /, so // each has a rig of its own. func TestTheReleaseScriptsDriveTheJourneys(t *testing.T) { scripts := distScripts(t) if b, ok := scripts("fiesta.js"); !ok || !bytes.HasPrefix(b, []byte(build.Banner(fiesta.Version))) { t.Fatal("distScripts does not serve the release's fiesta.js") } for _, drive := range []struct { name string pages map[string]string run func(*testing.T, *harness.Rig) }{ {"switch drag", switchDragPages, driveSwitchDrag}, {"scheme remember", schemePages(), driveSchemeRemember}, {"select filter", selectFilterPages(t), driveSelectFilter}, {"calendar open", calendarPages(t), driveCalendarOpen}, {"snippet copy", copyPages(), func(t *testing.T, rig *harness.Rig) { ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() driveCopy(t, ctx, rig.Origin, "/") }}, } { t.Run(drive.name, func(t *testing.T) { pages := drive.pages rig := harness.New(t, func(string) http.Handler { return sizingMuxWith(t, pages, scripts) }) drive.run(t, rig) }) } } // contentTypes is what the release server says each file is. var contentTypes = map[string]string{".css": "text/css", ".js": "text/javascript", ".svg": "image/svg+xml", ".json": "application/json"} // releaseServer serves the dist on 127.0.0.1, which is a different // origin from the rig's localhost, as a CDN or a site's static host // would be. func releaseServer(t *testing.T, d map[string][]byte) string { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, ok := d[strings.TrimPrefix(r.URL.Path, "/")] if !ok { http.NotFound(w, r) return } for ext, ct := range contentTypes { if strings.HasSuffix(r.URL.Path, ext) { w.Header().Set("Content-Type", ct) } } w.Write(b) })) t.Cleanup(srv.Close) if !strings.HasPrefix(srv.URL, "http://127.0.0.1:") { t.Fatalf("the release server is at %s, not on 127.0.0.1", srv.URL) } return srv.URL } // anotherOriginDoc is a Spanish page on the rig's origin that takes // everything from the release server: fiesta.css, the scripts the // date field needs, es.js, and every icon as an image. func anotherOriginDoc(files string, slugs []string) string { var icons strings.Builder for _, slug := range slugs { fmt.Fprintf(&icons, ``, slug, files, slug) } return `release` + `` + `` + `` + `
` + `

` + `
` + `

` + icons.String() + `

` + `
` } // anotherOrigin is what the page reports: the button's computed // alignment and the root's accent token (the stylesheet, structure // and theme), the armed reading's set label (the strings), and each // icon's load. type anotherOrigin struct { AlignItems, Accent, Set string Icons []struct { Slug string Complete bool NaturalWidth, NaturalHeight int Decoded string } } // TestTheReleaseWorksFromAnotherOrigin: a page on localhost links // fiesta.css and loads fiesta.js, datetime.js, calendar.js and // i18n/es.js from the release on 127.0.0.1. The button is styled, the // date field's set label is es.json's, every standalone icon loads as // an image, and the page reports no error. func TestTheReleaseWorksFromAnotherOrigin(t *testing.T) { d := releaseDist(t) files := releaseServer(t, d) slugs := fiesta.IconSlugs() if len(slugs) == 0 { t.Fatal("no icons") } es, want, tomorrow := loadES(t) if !bytes.Contains(d["i18n/es.js"], es) { t.Fatal("the release's es.js does not carry es.json") } page := anotherOriginDoc(files, slugs) rig := harness.New(t, func(string) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, page) }) return mux }) if !strings.HasPrefix(rig.Origin, "http://localhost:") { t.Fatalf("the page is at %s, not on localhost", rig.Origin) } ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-dtp] input[role="combobox"]`, chromedp.ByQuery), chromedp.Click(`[rst-dtp] input[role="combobox"]`, chromedp.ByQuery), chromedp.SendKeys(`[rst-dtp] input[role="combobox"]`, tomorrow, chromedp.ByQuery)) settleUntil(t, ctx, `!!document.querySelector('[rst-dtp] [role="option"].is-active [rst-dtp-set]')`) var got anotherOrigin at(t, ctx, `(async () => { const icons = []; for (const img of document.querySelectorAll("#icons img")) { let decoded = ""; try { await img.decode(); } catch (e) { decoded = e.name + ": " + e.message; } icons.push({Slug: img.id.replace(/^icon-/, ""), Complete: img.complete, NaturalWidth: img.naturalWidth, NaturalHeight: img.naturalHeight, Decoded: decoded}); } return JSON.stringify({ AlignItems: getComputedStyle(document.getElementById("btn")).alignItems, Accent: getComputedStyle(document.documentElement).getPropertyValue("--rst-accent").trim(), Set: document.querySelector('[rst-dtp] [role="option"].is-active [rst-dtp-set]')?.textContent ?? "", Icons: icons}); })()`, &got, awaitPromise) if got.AlignItems != "center" { t.Errorf("[rst-btn]'s computed align-items is %q, want center: fiesta.css did not apply across origins", got.AlignItems) } if got.Accent == "" { t.Error("--rst-accent is unset on the root: the theme half of fiesta.css did not apply") } if got.Set != want.Set { t.Errorf("the set label is %q, want es.json's %q", got.Set, want.Set) } if len(got.Icons) != len(slugs) { t.Fatalf("the page has %d icons, want %d", len(got.Icons), len(slugs)) } for _, icon := range got.Icons { t.Logf("icons/%s.svg: complete %v, natural %dx%d, decode %q", icon.Slug, icon.Complete, icon.NaturalWidth, icon.NaturalHeight, icon.Decoded) if !icon.Complete || icon.Decoded != "" { t.Errorf("icons/%s.svg did not load as an image: complete %v, decode %q", icon.Slug, icon.Complete, icon.Decoded) } if icon.NaturalWidth <= 0 { t.Errorf("icons/%s.svg has naturalWidth %d, want above 0", icon.Slug, icon.NaturalWidth) } } rig.Screen("body", "the release used from another origin") }