//go:build browser package gallery import ( "context" "encoding/json" "fmt" "math" "net/http" "slices" "strings" "testing" "time" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta" "amadan.net/rastrillo/fiesta/example" "amadan.net/rastrillo/fiesta/internal/axe" "amadan.net/rastrillo/fiesta/internal/codeview" "amadan.net/rastrillo/fiesta/internal/harness" ) // The code blocks in a real engine: the Copy button, the soft wrap on // a phone, axe over the open panels, left-to-right code on an Arabic // page, and the HTML tab laying out exactly like the preview. // clipboardStub replaces the clipboard with one the leg controls: // every write is recorded, and window.__clipMode decides whether it // resolves or rejects. const clipboardStub = `window.__clipCalls = []; window.__clipMode = "resolve"; Object.defineProperty(Navigator.prototype, "clipboard", {configurable: true, get() { return { writeText: s => { window.__clipCalls.push(s); return window.__clipMode === "resolve" ? Promise.resolve() : Promise.reject(new DOMException("denied", "NotAllowedError")); } }; }});` // noClipboard is a plain-HTTP origin's clipboard: not there at all. const noClipboard = `Object.defineProperty(Navigator.prototype, "clipboard", {configurable: true, get() { return undefined; }});` // requireCopyButtons fails a leg by name when the page has no Copy // button once its scripts have run. func requireCopyButtons(t *testing.T, ctx context.Context, where string) { t.Helper() until(t, ctx, where+", scripts run", `document.readyState !== "loading"`) var n int mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".ds-copy").length`, &n)) if n == 0 { t.Fatalf("%s: no Copy button on the page", where) } } // TestTheCopyButtonCopiesAnnouncesAndFailsSafely: on the field page, // every copyable block has a button named uniquely from text already // on screen; a resolved write puts exactly the block's text on the // clipboard, says Copied in the live region and on the button, and // reverts; a refused write announces the failure and selects the // block; a grouped page's rows are named apart; and with no clipboard // API no button is drawn. func TestTheCopyButtonCopiesAnnouncesAndFailsSafely(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) defer cancel() field := pageURL(rig, "en", "field.html") mustRun(t, ctx, addInit(clipboardStub)) openPage(t, ctx, "field with a stubbed clipboard", field, 1280, 900) requireCopyButtons(t, ctx, "field") mustRun(t, ctx, chromedp.Evaluate(`document.querySelector(".ds-view__tab--h input").click(); true`, nil)) assertNames(t, ctx, "field") const first = `document.querySelector(".ds-view__code--h .ds-copy")` mustRun(t, ctx, chromedp.Evaluate(first+`.click(); true`, nil)) until(t, ctx, "a resolved copy", `document.querySelector("[data-ds-copy-status]").textContent === document.querySelector("[data-ds-copy-status]").dataset.copied`) var got struct { Calls []string Text string Label string } at(t, ctx, `JSON.stringify({Calls: window.__clipCalls, Text: `+first+`.nextElementSibling.querySelector("code").textContent, Label: `+first+`.firstChild.nodeValue})`, &got) if len(got.Calls) != 1 || got.Calls[0] != got.Text { t.Errorf("the clipboard got %q, want exactly the block's text %q", got.Calls, got.Text) } if !strings.HasPrefix(got.Text, "
") { t.Errorf("the first block on the field page copied %q; it should be the field without its wrapper", got.Text) } if got.Label != say("gallery.copied") { t.Errorf("the button reads %q after a copy, want %q", got.Label, say("gallery.copied")) } until(t, ctx, "the label returns", first+`.firstChild.nodeValue === document.querySelector("[data-ds-copy-status]").dataset.copy`) mustRun(t, ctx, chromedp.Evaluate(`window.__clipMode = "reject"; window.__errors = 0; addEventListener("error", () => window.__errors++); addEventListener("unhandledrejection", () => window.__errors++); `+first+`.click(); true`, nil)) until(t, ctx, "a refused copy", `document.querySelector("[data-ds-copy-status]").textContent === document.querySelector("[data-ds-copy-status]").dataset.failed`) until(t, ctx, "the block selected", `getSelection().toString() === `+first+`.nextElementSibling.querySelector("code").textContent && window.__errors === 0`) // A grouped page: four blocks under one heading, named apart by // their row labels. openPage(t, ctx, "status-pill", pageURL(rig, "en", "status-pill.html"), 1280, 900) requireCopyButtons(t, ctx, "status-pill") assertNames(t, ctx, "status-pill") // No clipboard API, no button: a fresh tab with the init script // standing in for a missing clipboard, scripts on, since the page's // own script is what decides. tab2, cancel2 := chromedp.NewContext(rig.Context()) defer cancel2() var buttons, blocks int mustRun(t, tab2, addInit(noClipboard), chromedp.EmulateViewport(1280, 900), chromedp.Navigate(field), chromedp.WaitReady(`[data-ds-copy-status]`, chromedp.ByQuery)) until(t, tab2, "field with no clipboard, scripts run", `document.readyState === "complete"`) mustRun(t, tab2, chromedp.Evaluate(`document.querySelectorAll(".ds-copy").length`, &buttons), chromedp.Evaluate(`document.querySelectorAll("pre.ds-src").length`, &blocks)) if buttons != 0 || blocks == 0 { t.Errorf("with no clipboard API: %d buttons over %d blocks, want none over some", buttons, blocks) } } // assertNames holds one page's copy buttons to the two promises: one // per copyable block, and names unique, each starting with the visible // label so a voice user saying "Copy" reaches them. func assertNames(t *testing.T, ctx context.Context, where string) { t.Helper() var got struct { Names []string Blocks int Label string } at(t, ctx, `JSON.stringify({ Names: [...document.querySelectorAll(".ds-copy")].map(b => b.textContent), Blocks: document.querySelectorAll("pre.ds-src:not([data-ds-nocopy])").length, Label: document.querySelector("[data-ds-copy-status]").dataset.copy})`, &got) if len(got.Names) == 0 || len(got.Names) != got.Blocks { t.Fatalf("%s: %d copy buttons for %d copyable blocks", where, len(got.Names), got.Blocks) } seen := map[string]bool{} for _, n := range got.Names { if seen[n] { t.Errorf("%s: two copy buttons are both named %q", where, n) } seen[n] = true if !strings.HasPrefix(n, got.Label+" ") { t.Errorf("%s: %q does not start with the visible label %q", where, n, got.Label) } } t.Logf("%s: %d copy buttons, for instance %q", where, len(got.Names), got.Names[len(got.Names)-1]) } // TestNoCodeBlockScrollsSidewaysOnAPhone: with every widget's HTML tab // chosen, no source block on the heaviest pages scrolls sideways at // 320px or 390px, in English and in Arabic: the soft wrap holds a // select's option list, a list bar and the stage layout's whole // document with its one-line SVG. Each pass first requires that what // it chose is on screen. func TestNoCodeBlockScrollsSidewaysOnAPhone(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) defer cancel() for _, locale := range []string{"en", "ar"} { for _, file := range []string{"field-select.html", "list-bar.html", "layout-stage.html", "status-pill.html"} { for _, w := range []int64{320, 390} { where := fmt.Sprintf("day/%s %s at %dpx, every HTML tab chosen", locale, file, w) openPage(t, ctx, where, pageURL(rig, locale, file), w, 844) mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".ds-view__tab--h input").forEach(i => i.checked = true); true`, nil)) var got struct { Views, Blocks int Blind, Wide []string Page float64 } at(t, ctx, `(() => { const shown = p => p.checkVisibility() && p.getBoundingClientRect().height > 0; const views = [...document.querySelectorAll(".ds-view")]; const blind = []; let blocks = 0; views.forEach(v => { const n = [...v.querySelectorAll(".ds-view__code--h .ds-src")].filter(shown).length; if (n === 0) blind.push(v.querySelector("iframe").title); blocks += n; }); const wide = [...document.querySelectorAll(".ds-src")].filter(p => shown(p) && p.scrollWidth > p.clientWidth).map(p => p.textContent.slice(0, 50)); return JSON.stringify({Views: views.length, Blocks: blocks, Blind: blind, Wide: wide, Page: document.documentElement.scrollWidth - document.documentElement.clientWidth}); })()`, &got) if got.Views == 0 || len(got.Blind) > 0 { t.Fatalf("%s: %d widgets, and %d show no source block: %q", where, got.Views, len(got.Blind), got.Blind) } if len(got.Wide) > 0 { t.Errorf("%s: %d of %d source blocks scroll sideways: %q", where, len(got.Wide), got.Blocks, got.Wide) } if got.Page > 1 { t.Errorf("%s: the page scrolls %.0fpx sideways with the code open", where, got.Page) } } } } } // TestA11yScansTheCodePanels: axe with the HTML panels open, which the // page scans never see because the panels are display: none until a // tab is chosen. Each leg first asserts which highlight elements are // on the page, so the coverage cannot quietly shrink. func TestA11yScansTheCodePanels(t *testing.T) { rig := harness.New(t, func(string) http.Handler { return treeHandler(t) }) ctx, cancel := context.WithTimeout(rig.Context(), 600*time.Second) defer cancel() total := 0 for _, file := range []string{"field.html", "badge.html"} { for _, theme := range fiesta.ThemeNames() { for _, scheme := range []string{"light", "dark"} { where := fmt.Sprintf("%s/en %s, every HTML tab chosen (%s)", theme, file, scheme) mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+href(theme+"/en/"+file)), chromedp.WaitReady("body")) requireWidget(t, ctx, where) var shown []string mustRun(t, ctx, chromedp.Evaluate(`document.querySelectorAll(".ds-view__tab--h input").forEach(i => i.checked = true); ["ds-t", "ds-a", "ds-v", "ds-x", "ds-s"].filter(n => [...document.querySelectorAll(n)].some(e => e.getBoundingClientRect().width > 0))`, &shown)) for _, w := range []string{"ds-t", "ds-a", "ds-v"} { if !slices.Contains(shown, w) { t.Fatalf("%s: no visible %s; this scan would not be checking that colour", where, w) } } var bg string mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`document.documentElement.setAttribute("data-theme", %q); getComputedStyle(document.body).backgroundColor`, scheme), &bg)) res, err := axe.Scan(ctx, "", axe.DefaultTags()) if err != nil { t.Fatalf("%s: %v", where, err) } total += report(t, where, res.Violations) t.Logf("%s on %s: needs review, axe could not decide: %v", where, bg, res.Incomplete) } } } if total == 0 { t.Log("clean: the HTML panels on the field and badge pages in every theme and scheme") } } // TestCodeIsLeftToRightOnAnArabicPage: a
 inherits the page's
// dir="rtl", and in a right-to-left run the bidi algorithm mirrors
// brackets and moves punctuation, so what a reader sees is not what
// Copy puts on the clipboard. Computed style, every block and every
// wrapper-line name, on four Arabic pages; the counts are the control.
func TestCodeIsLeftToRightOnAnArabicPage(t *testing.T) {
	rig := harness.New(t, func(string) http.Handler { return treeHandler(t) })
	ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
	defer cancel()
	var blocks, wrapped int
	for _, file := range []string{"field.html", "status-pill.html", "layout-column.html", "format-money.html"} {
		where := "day/ar " + file
		mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(pageURL(rig, "ar", file)), chromedp.WaitReady("body"))
		var got struct {
			Page      string
			Src, Wrap int
			Bad       []string
		}
		at(t, ctx, `(() => {
		  if (document.documentElement.dir !== "rtl") return JSON.stringify({Page: document.documentElement.dir});
		  const bad = [];
		  const read = sel => { const es = [...document.querySelectorAll(sel)]; es.forEach(e => {
		    const s = getComputedStyle(e);
		    if (s.direction !== "ltr" || !["start", "left"].includes(s.textAlign)) bad.push(sel + " " + s.direction + "/" + s.textAlign + ": " + e.textContent.slice(0, 40));
		  }); return es.length; };
		  return JSON.stringify({Page: "rtl", Src: read(".ds-src"), Wrap: read(".ds-wrap code"), Bad: bad.slice(0, 6)});
		})()`, &got)
		if got.Page != "rtl" {
			t.Fatalf("%s: the page is dir=%q, so this leg reads nothing a right-to-left page does to code", where, got.Page)
		}
		blocks += got.Src
		wrapped += got.Wrap
		if len(got.Bad) > 0 {
			t.Errorf("%s: code laid out right to left: %q", where, got.Bad)
		}
	}
	if blocks == 0 || wrapped == 0 {
		t.Fatalf("read %d source blocks and %d wrapper-line names over the ar pages; want both above zero", blocks, wrapped)
	}
	t.Logf("%d source blocks and %d wrapper-line names, all left to right", blocks, wrapped)
}

// ── The HTML tab lays out like the preview ───────────────────────────

// layoutProbe reads what a reader sees: every element's boxes, every
// run of text's boxes, in document order, and the page's visible text,
// with every animation stopped at its start.
const layoutProbe = `(() => {
  for (const a of document.getAnimations()) { a.pause(); a.currentTime = 0; }
  const round = r => [r.x, r.y, r.width, r.height];
  const boxes = [];
  for (const el of document.body.querySelectorAll("*")) {
    boxes.push(el.tagName + " " + JSON.stringify([...el.getClientRects()].map(round)));
  }
  const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
  for (let n = walk.nextNode(); n; n = walk.nextNode()) {
    if (!n.data.trim()) continue;
    const range = document.createRange();
    range.selectNodeContents(n);
    boxes.push("#text " + JSON.stringify(n.data.trim()) + " " + JSON.stringify([...range.getClientRects()].map(round)));
  }
  return JSON.stringify({boxes, text: document.body.innerText});
})()`

type layoutReading struct {
	Boxes []string
	Text  string
}

// disclosureState puts a document's disclosures into state k and
// resolves once the page has drawn it: -1 closes every 
, -2 // opens every one, and k >= 0 opens the k-th one, the disclosures it // sits in, and whatever else it takes for it to be seen, with every // other closed. const disclosureState = `k => new Promise(done => { const all = [...document.querySelectorAll("details")]; const chain = d => { const c = []; for (; d; d = d.parentElement && d.parentElement.closest("details")) c.unshift(d); return c; }; const shown = d => (d.querySelector(":scope > summary") || d).getClientRects().length > 0; for (const d of all) d.open = false; if (k === -2) for (const d of all) d.open = true; if (k >= 0) { const target = all[k], open = chain(target); for (const d of open) d.open = true; const names = new Set(open.map(d => d.name).filter(Boolean)); for (const d of all) { if (shown(target)) break; const more = chain(d).filter(x => !open.includes(x)); if (!more.length || target.contains(d) || more.some(x => x.name && names.has(x.name))) continue; for (const x of more) x.open = true; if (shown(target)) { open.push(...more); for (const x of more) if (x.name) names.add(x.name); } else { for (const x of more) x.open = false; } } } requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(done, 0))); })` // TestTheHTMLTabRendersExactlyLikeThePreview: codeview.Format puts a // child on a line of its own only where whitespace cannot change what // renders. Every component state whose formatted markup differs from // the seeded body is rendered twice, as the frame holds it and as the // HTML tab lays it out, each inside the body's own wrapper, and every // element and run of text has to land in the same place within half a // pixel, at a desktop frame's width and a phone's, as loaded and in // every disclosure state. The Copy button copies the formatted text, // so this is what makes pasting it safe. It runs in English and again // in Japanese, on the samples as day/ja shows and copies them: the // words change, and with them where a line may break. // // It is one test per language and width, so the suite's slices can run // them on shards of their own: the En900 test and the three after it. // Each builds every state's two copies in both languages, so the count // of states the formatter changes is checked whole as before, then // renders its own language at its own width; and each runs both // controls first, which the one test ran once. func TestTheHTMLTabRendersExactlyLikeThePreviewEn900(t *testing.T) { htmlTabRendersLikeThePreview(t, "en", 900) } func TestTheHTMLTabRendersExactlyLikeThePreviewEn390(t *testing.T) { htmlTabRendersLikeThePreview(t, "en", 390) } func TestTheHTMLTabRendersExactlyLikeThePreviewJa900(t *testing.T) { htmlTabRendersLikeThePreview(t, "ja", 900) } func TestTheHTMLTabRendersExactlyLikeThePreviewJa390(t *testing.T) { htmlTabRendersLikeThePreview(t, "ja", 390) } // htmlTabRendersLikeThePreview is TestTheHTMLTabRendersExactlyLikeThePreview // in one language at one width. func htmlTabRendersLikeThePreview(t *testing.T, only string, onlyWidth int64) { s := newTestSite(t, fiesta.FS(), mountPath) theme := fiesta.ThemeNames()[0] docs := map[string]string{} var serveIn func(locale, name string, e example.Entry, body string) string type job struct{ locale, name, raw, fmt string } var jobs []job serve := func(name string, e example.Entry, body string) string { return serveIn("en", name, e, body) } serveIn = func(locale, name string, e example.Entry, body string) string { t.Helper() doc, err := s.frame(exampleDocument(name, e.Scripts, "", body), theme, locale, e, e.Default) if err != nil { t.Fatalf("%s: %v", name, err) } return doc } var abbr example.Entry var abbrBody string for _, e := range s.m.Examples { if e.Document { continue } for _, st := range e.States { english, err := example.Markup(fiesta.FS(), e.Name, st.ID) if err != nil { t.Fatal(err) } for _, locale := range []string{"en", "ja"} { raw := localMarkup(locale, e.Name, english) if e.Name == moneyExample && st.ID == e.Default { raw, _ = s.moneyBody(locale) } inner, err := unwrap(raw, e.Wrapper) if err != nil { t.Fatalf("%s/%s: %v", e.Name, st.ID, err) } i := strings.Index(raw, inner) if i < 0 { t.Fatalf("%s/%s: the unwrapped body is not a slice of the body", e.Name, st.ID) } formatted := raw[:i] + codeview.Format(inner) + raw[i+len(inner):] if e.Name == "format-abbr" && locale == "en" { abbr, abbrBody = e, raw } if formatted == raw { continue } name := locale + "/" + e.Name + "/" + st.ID base := "/fmt/" + name docs[base+"-raw.html"] = serveIn(locale, name, e, raw) docs[base+"-fmt.html"] = serveIn(locale, name, e, formatted) jobs = append(jobs, job{locale, name, base + "-raw.html", base + "-fmt.html"}) } } } // The control: the short-forms sample with a line break put between // "(" and its , mid-sentence, where a space shows. A probe // that cannot tell that copy from the preview is measuring nothing. broken := strings.Replace(abbrBody, "(first", "\nfirst", 1)) if len(jobs) < 100 { t.Fatalf("only %d states change when formatted; the sample walk or the formatter has stopped", len(jobs)) } tree := treeHandler(t) rig := harness.New(t, func(string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if doc, ok := docs[r.URL.Path]; ok { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(doc)) return } tree.ServeHTTP(w, r) }) }) ctx, cancel := context.WithTimeout(rig.Context(), 15*time.Minute) defer cancel() read := func(url string) []layoutReading { probe := func() layoutReading { var out layoutReading at(t, ctx, layoutProbe, &out) return out } var n int mustRun(t, ctx, chromedp.Navigate(rig.Origin+url), chromedp.Evaluate(`new Promise(r => document.fonts.ready.then(() => requestAnimationFrame(() => requestAnimationFrame(r))))`, nil, awaitPromise), chromedp.Evaluate(`document.querySelectorAll("details").length`, &n)) out := []layoutReading{probe()} for k := -2; k < n; k++ { mustRun(t, ctx, chromedp.Evaluate(fmt.Sprintf(`(%s)(%d)`, disclosureState, k), nil, awaitPromise)) out = append(out, probe()) } return out } mustRun(t, ctx, chromedp.EmulateViewport(900, 900)) if want, got := read("/fmt/control-raw.html"), read("/fmt/control.html"); sameReadings(want, got) == "" { t.Fatal("a space inside a sentence moved no box; this drive cannot see a formatter that changes rendering") } if want, got := read("/fmt/hidden-raw.html"), read("/fmt/hidden.html"); sameReadings(want, got) == "" { t.Fatal("a space inside a closed disclosure moved no box once opened; this drive cannot see a formatter that changes what a menu shows") } var compared, states int for _, width := range []int64{900, 390} { if width != onlyWidth { continue } mustRun(t, ctx, chromedp.EmulateViewport(width, 900)) for _, j := range jobs { if j.locale != only { continue } want, got := read(j.raw), read(j.fmt) compared++ states += len(want) if diff := sameReadings(want, got); diff != "" { t.Errorf("%s at %dpx: %s", j.name, width, diff) } } } if compared == 0 { t.Fatalf("no %s sample was compared at %dpx", only, onlyWidth) } t.Logf("compared %d %s samples at %dpx in %d states, each rendered as written and as formatted", compared, only, onlyWidth, states) } // sameReadings compares a document's readings state by state, and says // how the first difference shows; "" when there is none. func sameReadings(want, got []layoutReading) string { if len(want) != len(got) { return fmt.Sprintf("%d disclosure states in the preview, %d in the formatted copy", len(want), len(got)) } for s := range want { state := "as loaded" switch { case s == 1: state = "with every disclosure open" case s == 2: state = "with every disclosure closed" case s > 2: state = fmt.Sprintf("with disclosure %d open, and what shows it", s-3) } if got[s].Text != want[s].Text { return fmt.Sprintf("%s, the visible text differs\npreview: %q\n tab: %q", state, want[s].Text, got[s].Text) } if len(got[s].Boxes) != len(want[s].Boxes) { return fmt.Sprintf("%s, %d boxes in the preview, %d in the formatted copy", state, len(want[s].Boxes), len(got[s].Boxes)) } for k := range want[s].Boxes { if !sameBoxes(want[s].Boxes[k], got[s].Boxes[k]) { return fmt.Sprintf("%s, a box moved\npreview: %s\n tab: %s", state, want[s].Boxes[k], got[s].Boxes[k]) } } } return "" } // sameBoxes compares two probe lines: the same element or text, and // every rectangle within half a pixel. func sameBoxes(a, b string) bool { if a == b { return true } ia, ib := strings.LastIndex(a, " ["), strings.LastIndex(b, " [") if ia < 0 || ib < 0 || a[:ia] != b[:ib] { return false } var ra, rb [][]float64 if json.Unmarshal([]byte(a[ia+1:]), &ra) != nil || json.Unmarshal([]byte(b[ib+1:]), &rb) != nil || len(ra) != len(rb) { return false } for i := range ra { for k := range ra[i] { if math.Abs(ra[i][k]-rb[i][k]) > 0.5 { return false } } } return true }