//go:build browser
// The scheme switch in a browser: a choice that a standalone page
// remembers across a reload, a restore that lands before the body's
// own script runs, and a server-owned page that reads nothing a
// standalone page on the same origin stored.
package check
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/chromedp/cdproto/emulation"
"github.com/chromedp/cdproto/input"
"github.com/chromedp/chromedp"
"amadan.net/rastrillo/fiesta"
"amadan.net/rastrillo/fiesta/example"
"amadan.net/rastrillo/fiesta/internal/axe"
"amadan.net/rastrillo/fiesta/internal/harness"
)
// schemeMenu is the scheme-menu partial's markup at the seed commit,
// by hand: a dropdown of three one-field forms, System current, no
// script. The scheme-menu example's standalone state carries the same
// shape. The forms name no action, so a submit fiesta.js failed to
// take would POST to the page itself and the fixture server would
// answer 405, which the harness reports.
const schemeMenu = `Colour scheme
`
// schemeDoc is a page with the stylesheet and a theme, the menu, and
// the scripts head asks for. store puts data-rst-scheme-store="local"
// on , where fiesta.js reads it before anything else runs. The
// body's own script also counts the rst-scheme events a listener on
// the document hears, which is how an app's cookie writer listens.
func schemeDoc(store bool, head string) string {
attr := ""
if store {
attr = ` data-rst-scheme-store="local"`
}
return `
` +
`scheme` +
`` + head +
`
` + schemeMenu + `
` +
``
}
// schemePages is what driveSchemeRemember and the drives beside it
// navigate: / is a standalone page, /server the same page without the
// attribute, and /early a standalone page with fiesta.js in
// without defer. Every page records data-theme as the body's own
// script sees it, in window.__early.
func schemePages() map[string]string {
deferred := ``
return map[string]string{
"/": schemeDoc(true, deferred),
"/server": schemeDoc(false, deferred),
"/early": schemeDoc(true, ``),
}
}
// schemeState is what the page says about itself after each step.
type schemeState struct {
Theme string // data-theme on , "" for none
Stored string // localStorage["fiesta-scheme"], "" for none
Cookie string // document.cookie
Current string // the scheme whose button is aria-current
Open bool // the menu is still open
Mark bool // window.__mark survived: no navigation happened
Early string // data-theme as the body's inline script saw it
Heard int // rst-scheme events a document listener heard
}
const schemeStateJS = `(() => {
const m = document.querySelector("[rst-scheme]");
const cur = m.querySelector("button[aria-current]");
let stored = "";
try { stored = localStorage.getItem("fiesta-scheme") || ""; } catch (e) {}
return JSON.stringify({
Theme: document.documentElement.getAttribute("data-theme") || "",
Stored: stored,
Cookie: document.cookie,
Current: cur ? cur.form.elements.scheme.value : "",
Open: m.open,
Mark: window.__mark === 1,
Early: window.__early,
Heard: window.__heard || 0
});
})()`
// chooseScheme opens the menu and presses one scheme's button, then
// waits for the menu to close, which the submit handler does in place.
// The mark it leaves on the window says afterwards whether the page
// navigated.
func chooseScheme(t *testing.T, ctx context.Context, scheme string) {
t.Helper()
btn := fmt.Sprintf(`[rst-scheme] input[value=%q] ~ button`, scheme)
mustRun(t, ctx,
chromedp.Evaluate(`window.__mark = 1; true`, nil),
chromedp.Click(`[rst-scheme] > summary`, chromedp.ByQuery),
chromedp.WaitVisible(btn, chromedp.ByQuery),
chromedp.Click(btn, chromedp.ByQuery),
)
settleUntil(t, ctx, `!document.querySelector("[rst-scheme]").open`)
}
// driveSchemeRemember is the body of TestAStandaloneSchemeSurvivesAReload,
// against any rig that serves schemePages (internal/build's drives run it
// with the release's scripts). Dark is chosen in place: the attribute
// changes, the key is stored, no cookie is written, the menu closes and
// nothing navigates. A reload paints Dark again and marks its button,
// which can only have come from storage. System undoes all of it, reload
// included.
func driveSchemeRemember(t *testing.T, rig *harness.Rig) {
t.Helper()
ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
defer cancel()
var got schemeState
mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
at(t, ctx, schemeStateJS, &got)
if got.Theme != "" || got.Current != "system" || got.Stored != "" {
t.Fatalf("first visit: %+v", got)
}
chooseScheme(t, ctx, "dark")
at(t, ctx, schemeStateJS, &got)
if got.Theme != "dark" || got.Current != "dark" || got.Stored != "dark" || got.Cookie != "" || got.Open || !got.Mark {
t.Fatalf("after choosing Dark in place: %+v", got)
}
if got.Heard != 1 {
t.Fatalf("a listener on the document heard %d rst-scheme events after Dark, want 1; the event is dispatched on the form and must bubble", got.Heard)
}
mustRun(t, ctx, chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
at(t, ctx, schemeStateJS, &got)
if got.Mark {
t.Fatal("the reload did not happen: the mark survived")
}
if got.Theme != "dark" || got.Current != "dark" {
t.Fatalf("after a reload the stored scheme was not restored: %+v", got)
}
chooseScheme(t, ctx, "system")
at(t, ctx, schemeStateJS, &got)
if got.Theme != "" || got.Current != "system" || got.Stored != "" || !got.Mark {
t.Fatalf("after choosing System in place: %+v", got)
}
mustRun(t, ctx, chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
at(t, ctx, schemeStateJS, &got)
if got.Theme != "" || got.Current != "system" || got.Stored != "" {
t.Fatalf("after a reload on System: %+v", got)
}
rig.Screen("body", "the scheme menu after Dark, System and two reloads")
}
func TestAStandaloneSchemeSurvivesAReload(t *testing.T) {
driveSchemeRemember(t, sizingRig(t, false, schemePages()))
}
// With fiesta.js in without defer, the restore runs before the
// body: the inline script at the end of the body already sees
// data-theme. Before anything is stored the same page records
// nothing, which is the control.
func TestTheStoredSchemeIsSetBeforeTheBodyRuns(t *testing.T) {
rig := sizingRig(t, false, schemePages())
ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
defer cancel()
var got schemeState
mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/early"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
at(t, ctx, schemeStateJS, &got)
if got.Early != "" || got.Theme != "" {
t.Fatalf("with nothing stored: %+v", got)
}
mustRun(t, ctx, chromedp.Evaluate(`localStorage.setItem("fiesta-scheme", "dark"); true`, nil),
chromedp.Reload(), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
at(t, ctx, schemeStateJS, &got)
if got.Early != "dark" {
t.Errorf("the body's script saw data-theme %q; the restore did not run before the body", got.Early)
}
if got.Theme != "dark" || got.Current != "dark" {
t.Errorf("after the reload: %+v", got)
}
rig.Screen("body", "the early page with Dark stored")
}
// A standalone page and a server-owned page on one origin share a
// localStorage. The server-owned page, which carries no attribute,
// neither reads what the other stored (it loads without data-theme and
// stays light) nor writes over it when its own switch is used.
func TestAServerOwnedSchemeSurvivesAStandalonePage(t *testing.T) {
rig := sizingRig(t, false, schemePages())
ctx, cancel := context.WithTimeout(rig.Context(), 90*time.Second)
defer cancel()
var got schemeState
mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
chooseScheme(t, ctx, "dark")
at(t, ctx, schemeStateJS, &got)
if got.Stored != "dark" {
t.Fatalf("the standalone page did not store Dark: %+v", got)
}
mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/server"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
at(t, ctx, schemeStateJS, &got)
if got.Theme != "" || got.Early != "" || got.Current != "system" {
t.Fatalf("the server-owned page took the stored scheme: %+v", got)
}
if got.Stored != "dark" {
t.Fatalf("the server-owned page touched storage on load: %+v", got)
}
chooseScheme(t, ctx, "light")
at(t, ctx, schemeStateJS, &got)
if got.Theme != "light" || got.Current != "light" || !got.Mark {
t.Fatalf("after choosing Light on the server-owned page: %+v", got)
}
if got.Stored != "dark" {
t.Errorf("the server-owned page wrote storage: fiesta-scheme is %q, want the standalone page's dark", got.Stored)
}
rig.Screen("body", "the server-owned page after Light")
}
// ── The scheme menu under axe ────────────────────────────────────────
// schemeMenuPages serves the scheme-menu example's standalone state
// whole, through example.Document, so the that carries
// data-rst-scheme-store="local" comes along with the menu. Its
// relative asset links are rewritten to the flat names the fixture
// server answers (parityLinks), and nothing else in the document is
// touched. It takes over from rastrillo's server-backed schemeApp,
// which stays in rastrillo with the tests that need a server.
func schemeMenuPages(t *testing.T) map[string]string {
t.Helper()
state, ok := manifest(t).Default("scheme-menu")
if !ok || state == "" {
t.Fatal("examples.json names no default state for scheme-menu")
}
doc, err := example.Document(fiesta.FS(), "scheme-menu", state)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(doc, `data-rst-scheme-store="local"`) {
t.Fatalf("scheme-menu/%s is not the standalone state: its root carries no data-rst-scheme-store", state)
}
return map[string]string{"/": parityLinks(doc)}
}
// axeClean scans the whole page and reports every violation, each
// with the nodes it names; what axe could not decide is logged, so
// the seam between the scanned half of the standard and the reviewed
// half is never silent.
func axeClean(t *testing.T, ctx context.Context, where string) {
t.Helper()
res, err := axe.Scan(ctx, "", axe.DefaultTags())
if err != nil {
t.Fatalf("%s: axe: %v", where, err)
}
if len(res.Incomplete) > 0 {
t.Logf("%s: needs review, axe could not decide: %v", where, res.Incomplete)
}
for _, v := range res.Violations {
t.Errorf("%s: %s [%s] %s: %s", where, v.ID, v.Impact, v.Help, strings.Join(v.Nodes, "; "))
}
}
// TestTheSchemeMenuPassesAxeInBothSchemes: the standalone scheme menu,
// served with fiesta.js as the example links it, passes axe with the
// menu open before a choice and after Dark is chosen in place. The
// choice sets data-theme="dark" on the root and navigates nowhere (the
// mark chooseScheme leaves on the window survives it). The root is
// read first: without data-rst-scheme-store="local" this would be a
// server-owned page, and the drive could not tell the two apart. The
// first scan is light because a light preference is emulated and
// checked, not because the browser happens to default to one.
func TestTheSchemeMenuPassesAxeInBothSchemes(t *testing.T) {
rig := sizingRig(t, false, schemeMenuPages(t))
ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
defer cancel()
light := emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "prefers-color-scheme", Value: "light"}})
mustRun(t, ctx, light, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`[rst-scheme]`, chromedp.ByQuery))
var dark bool
mustRun(t, ctx, chromedp.Evaluate(`matchMedia("(prefers-color-scheme: dark)").matches`, &dark))
if dark {
t.Fatal("the page prefers dark, so the light scan would scan dark")
}
var store string
mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.getAttribute("data-rst-scheme-store") || ""`, &store))
if store != "local" {
t.Fatalf("the served document's root carries data-rst-scheme-store=%q, want local: this is not the standalone page", store)
}
var got schemeState
at(t, ctx, schemeStateJS, &got)
if got.Theme != "" || got.Stored != "" {
t.Fatalf("first visit: %+v", got)
}
mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = true`, nil), chromedp.WaitVisible(`[rst-scheme] button`, chromedp.ByQuery))
axeClean(t, ctx, "light, menu open")
mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = false`, nil))
chooseScheme(t, ctx, "dark")
at(t, ctx, schemeStateJS, &got)
if got.Theme != "dark" || got.Current != "dark" || got.Open || !got.Mark {
t.Fatalf("after choosing Dark in place: %+v", got)
}
mustRun(t, ctx, chromedp.Evaluate(`document.querySelector("[rst-scheme]").open = true`, nil), chromedp.WaitVisible(`[rst-scheme] button`, chromedp.ByQuery))
axeClean(t, ctx, "dark, menu open")
rig.Screen("body", "the standalone scheme menu open in dark")
}
// ── A scheme flip lands (ui/scheme_browser_test.go) ──────────────────
// schemeFlipBody is the page a scheme flip is read on: a sidebar's
// current nav item and a linked media tile, the two things whose
// background fades between schemes (0.2s and 0.25s, for the press
// and the hover) while their text changes at once, and the switch's
// own forms as an app renders them.
const schemeFlipBody = `