//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, "
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, "(details:not([open]) + div { display: none; }More
Read the FAQfirst.
`
docs["/fmt/hidden-raw.html"] = serve("hidden control", abbr, hidden)
docs["/fmt/hidden.html"] = serve("hidden control", abbr, strings.Replace(hidden, "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
}