| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "regexp" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/chromedp/cdproto/emulation" |
| 13 | "github.com/chromedp/chromedp" |
| 14 | |
| 15 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 16 | ) |
| 17 | |
| 18 | // noShadow: none, or a shadow list whose every colour is fully |
| 19 | // transparent, which is what a token set flat computes to; an element |
| 20 | // with such a list paints nothing. |
| 21 | func noShadow(v string) bool { |
| 22 | if v == "none" { |
| 23 | return true |
| 24 | } |
| 25 | for _, c := range regexp.MustCompile(`rgba?\([^)]*\)|color\([^)]*\)`).FindAllString(v, -1) { |
| 26 | if !strings.HasSuffix(c, ", 0)") && !strings.HasSuffix(c, "/ 0)") { |
| 27 | return false |
| 28 | } |
| 29 | } |
| 30 | return true |
| 31 | } |
| 32 | |
| 33 | // noMove: none, or a translate whose every length is 0px (a 0px drop |
| 34 | // computes to "0px", not to none). |
| 35 | func noMove(v string) bool { |
| 36 | if v == "none" { |
| 37 | return true |
| 38 | } |
| 39 | for _, f := range strings.Fields(v) { |
| 40 | if f != "0px" { |
| 41 | return false |
| 42 | } |
| 43 | } |
| 44 | return true |
| 45 | } |
| 46 | |
| 47 | // knobStyle reads one computed property of the first switch's knob, |
| 48 | // the ::after getComputedStyle takes as its second argument. |
| 49 | func knobStyle(t *testing.T, ctx context.Context, prop string) string { |
| 50 | t.Helper() |
| 51 | return knobStyleOf(t, ctx, "#sw [rst-switch-track]", prop) |
| 52 | } |
| 53 | |
| 54 | // knobStyleOf is knobStyle for the track the selector names. |
| 55 | func knobStyleOf(t *testing.T, ctx context.Context, track, prop string) string { |
| 56 | t.Helper() |
| 57 | var out string |
| 58 | mustRun(t, ctx, chromedp.Evaluate(`getComputedStyle(document.querySelector(`+jsStr(track)+`), "::after").getPropertyValue(`+jsStr(prop)+`)`, &out)) |
| 59 | return strings.TrimSpace(out) |
| 60 | } |
| 61 | |
| 62 | // systemColour reads what the page resolves one CSS system colour to, |
| 63 | // so a drive can hold an element to Highlight or HighlightText by |
| 64 | // value rather than by a guess at the emulated palette. |
| 65 | func systemColour(t *testing.T, ctx context.Context, name string) string { |
| 66 | t.Helper() |
| 67 | var out string |
| 68 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const e = document.createElement("span"); e.style.backgroundColor = `+jsStr(name)+`; document.body.append(e); const v = getComputedStyle(e).backgroundColor; e.remove(); return v; })()`, &out)) |
| 69 | return strings.TrimSpace(out) |
| 70 | } |
| 71 | |
| 72 | // emulatedRig opens the depth page under an emulated medium, at a width |
| 73 | // set before the navigation. A viewport change after the page has |
| 74 | // loaded turns (hover: hover) off in this Chromium until the next |
| 75 | // navigation, so a hover cell read after a resize sees no rule inside |
| 76 | // a hover query and tests nothing; the query is checked here so that |
| 77 | // cannot happen quietly. |
| 78 | func emulatedRig(t *testing.T, media *emulation.SetEmulatedMediaParams, width, height int64) (*harness.Rig, context.Context) { |
| 79 | t.Helper() |
| 80 | rig := sizingRig(t, false, depthDoc(t), harness.WithHover()) |
| 81 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 82 | t.Cleanup(cancel) |
| 83 | mustRun(t, ctx, media, chromedp.EmulateViewport(width, height), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#field", chromedp.ByQuery)) |
| 84 | still(t, ctx) |
| 85 | var hov bool |
| 86 | mustRun(t, ctx, chromedp.Evaluate(`matchMedia("(hover: hover)").matches`, &hov)) |
| 87 | if !hov { |
| 88 | t.Fatalf("(hover: hover) is false at %dx%d; the hover cells would read nothing", width, height) |
| 89 | } |
| 90 | mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("dd").open = true, true`, nil)) |
| 91 | return rig, ctx |
| 92 | } |
| 93 | |
| 94 | var forcedMedia = emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{{Name: "forced-colors", Value: "active"}, {Name: "prefers-color-scheme", Value: "light"}}) |
| 95 | |
| 96 | // TestForcedColoursKeepBordersAndSelection emulates forced colours: |
| 97 | // cards, buttons, fields and summaries have a visible border and |
| 98 | // no painted shadow in every state; the knob is painted and its track |
| 99 | // bordered; the current item in each of the three shells, the current |
| 100 | // menu item, tab and chip use the highlight pair and paint no shadow |
| 101 | // (the tab's lift is a theme shadow the axis never flattened); a |
| 102 | // pressed button does not move; the focus outline is still there. At |
| 103 | // the phone width, on a rig born there, the topbar card's current item |
| 104 | // takes the pair, and the index's current link keeps it hovered and |
| 105 | // pressed: the index ladder painted its surface over the highlight |
| 106 | // and left the text HighlightText, white on white. |
| 107 | func TestForcedColoursKeepBordersAndSelection(t *testing.T) { |
| 108 | _, ctx := emulatedRig(t, forcedMedia, 1280, 900) |
| 109 | var active bool |
| 110 | mustRun(t, ctx, chromedp.Evaluate(`matchMedia("(forced-colors: active)").matches`, &active)) |
| 111 | if !active { |
| 112 | t.Fatal("forced colours are not emulated; the leg would prove nothing") |
| 113 | } |
| 114 | for _, sel := range []string{"#box", "#card", "#list", "#stats", "#b-plain", "#b-primary", "#b-ghost", "#field", "#dd-sum"} { |
| 115 | for _, states := range [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}} { |
| 116 | forceState(t, ctx, sel, states...) |
| 117 | if w, s := computed(t, ctx, sel, "border-top-width"), computed(t, ctx, sel, "border-top-style"); w == "0px" || s == "none" { |
| 118 | t.Errorf("%s %v under forced colours has border %s %s; want a real border", sel, states, w, s) |
| 119 | } |
| 120 | if sh := computed(t, ctx, sel, "box-shadow"); !noShadow(sh) { |
| 121 | t.Errorf("%s %v under forced colours paints box-shadow %q", sel, states, sh) |
| 122 | } |
| 123 | forceState(t, ctx, sel) |
| 124 | } |
| 125 | } |
| 126 | // The mode paints Canvas under everything, so the knob is held |
| 127 | // against its track, not against transparent: ButtonText on Canvas. |
| 128 | if knob, track := knobStyle(t, ctx, "background-color"), computed(t, ctx, "#sw [rst-switch-track]", "background-color"); knob == "rgba(0, 0, 0, 0)" || knob == track { |
| 129 | t.Errorf("the switch knob under forced colours is %s on a %s track; want ButtonText on Canvas", knob, track) |
| 130 | } |
| 131 | // Checked, the track is Highlight, and the knob on it must be |
| 132 | // HighlightText: the pair a palette guarantees contrasts. ButtonText |
| 133 | // on Highlight need not (a white ButtonText on a cyan Highlight is |
| 134 | // about 1.25:1), for the ordinary switch and the toggle block's head |
| 135 | // alike. Highlight and HighlightText are held apart from |
| 136 | // ButtonText first, or the knob could pass by coincidence. |
| 137 | hl, hlText, btnText := systemColour(t, ctx, "Highlight"), systemColour(t, ctx, "HighlightText"), systemColour(t, ctx, "ButtonText") |
| 138 | if hlText == btnText || hl == hlText { |
| 139 | t.Fatalf("the emulated palette has HighlightText %s, ButtonText %s and Highlight %s; the checked knob could not be told from an unchecked one", hlText, btnText, hl) |
| 140 | } |
| 141 | for _, track := range []string{"#sw-on [rst-switch-track]", "#tb-head [rst-switch-track]"} { |
| 142 | if knob, bg := knobStyleOf(t, ctx, track, "background-color"), computed(t, ctx, track, "background-color"); bg != hl || knob != hlText { |
| 143 | t.Errorf("the checked switch %s under forced colours paints its knob %s on a %s track; want HighlightText %s on Highlight %s", track, knob, bg, hlText, hl) |
| 144 | } |
| 145 | } |
| 146 | // The hovered knob's dome is a gradient of the theme's knob colours, |
| 147 | // not of depth tokens; the mode drops every gradient layer (one none |
| 148 | // per layer), and that is the measurement the block relies on when |
| 149 | // it sets only the knob's colour. |
| 150 | forceState(t, ctx, "#sw", "hover") |
| 151 | if img := knobStyle(t, ctx, "background-image"); strings.Trim(strings.ReplaceAll(img, "none", ""), ", ") != "" { |
| 152 | t.Errorf("a hovered knob under forced colours still paints background-image %q", img) |
| 153 | } |
| 154 | forceState(t, ctx, "#sw") |
| 155 | // Canvas on CanvasText is what the mode gives every item, so the |
| 156 | // current item is held against its sibling: the pair must differ. |
| 157 | highlighted := func(ctx context.Context, cur, other string) { |
| 158 | t.Helper() |
| 159 | bg, fg := computed(t, ctx, cur, "background-color"), computed(t, ctx, cur, "color") |
| 160 | if bg == "rgba(0, 0, 0, 0)" || bg == computed(t, ctx, other, "background-color") || fg == computed(t, ctx, other, "color") { |
| 161 | t.Errorf("%s under forced colours is %s on %s, the same as %s; want Highlight on HighlightText", cur, fg, bg, other) |
| 162 | } |
| 163 | if sh := computed(t, ctx, cur, "box-shadow"); !noShadow(sh) { |
| 164 | t.Errorf("%s under forced colours paints box-shadow %q", cur, sh) |
| 165 | } |
| 166 | } |
| 167 | for _, pair := range [][2]string{{"#nav-y", "#nav-x"}, {"#cnav-y", "#cnav-x"}, {"#menu-cur", "#menu-a"}, {"#tab-2", "#tab-1"}, {"#page-2", "#page-1"}} { |
| 168 | highlighted(ctx, pair[0], pair[1]) |
| 169 | } |
| 170 | forceState(t, ctx, "#b-primary", "hover", "active") |
| 171 | if got := computed(t, ctx, "#b-primary", "translate"); !noMove(got) { |
| 172 | t.Errorf("a pressed button moved (%s) under forced colours", got) |
| 173 | } |
| 174 | forceState(t, ctx, "#b-primary", "focus", "focus-visible") |
| 175 | if st := computed(t, ctx, "#b-primary", "outline-style"); st == "none" { |
| 176 | t.Error("the focus outline is gone under forced colours") |
| 177 | } |
| 178 | forceState(t, ctx, "#b-primary") |
| 179 | |
| 180 | _, nctx := emulatedRig(t, forcedMedia, 390, 844) |
| 181 | highlighted(nctx, "#tnav-y", "#tnav-x") |
| 182 | rest := computed(t, nctx, "#inav-y", "background-color") |
| 183 | for _, states := range [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}} { |
| 184 | forceState(t, nctx, "#inav-y", states...) |
| 185 | if bg, fg := computed(t, nctx, "#inav-y", "background-color"), computed(t, nctx, "#inav-y", "color"); bg != rest || bg == fg { |
| 186 | t.Errorf("the index's current link %v under forced colours is %s on %s; want the rest highlight %s under HighlightText", states, fg, bg, rest) |
| 187 | } |
| 188 | forceState(t, nctx, "#inav-y") |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // TestPrintDrawsBordersAndNoDepth emulates print media (C17): no |
| 193 | // painted shadow, no gradient, no tint, no movement in any state; |
| 194 | // cards, fields, buttons and summaries have a --rst-line-strong |
| 195 | // border; a filled button is outlined text in every state (the fills |
| 196 | // are tokens, and print sets them transparent); the knob has a |
| 197 | // printable fill and a ring, and the checked track a fill the off |
| 198 | // track lacks; current items are flat. The check is for borders |
| 199 | // because "backgrounds disabled" is a print setting that drops |
| 200 | // backgrounds and leaves borders and outlines, so a boundary drawn as |
| 201 | // one of those is the one that survives it. The phone width is walked |
| 202 | // on a rig born there, because the topbar's card and its hover are |
| 203 | // phone-only rules inside a hover query; and a dark-scheme press on a |
| 204 | // page opened again, because day's dark press numbers sit in |
| 205 | // :root[data-theme] blocks that outrank html:root. |
| 206 | func TestPrintDrawsBordersAndNoDepth(t *testing.T) { |
| 207 | printMedia := emulation.SetEmulatedMedia().WithMedia("print") |
| 208 | rig, ctx := emulatedRig(t, printMedia, 1280, 900) |
| 209 | flat := func(ctx context.Context, sel string, states ...string) { |
| 210 | t.Helper() |
| 211 | forceState(t, ctx, sel, states...) |
| 212 | if sh, img, tr := computed(t, ctx, sel, "box-shadow"), computed(t, ctx, sel, "background-image"), computed(t, ctx, sel, "translate"); !noShadow(sh) || img != "none" || !noMove(tr) { |
| 213 | t.Errorf("%s %v in print: box-shadow %q background-image %q translate %s; want nothing painted and nothing moved", sel, states, sh, img, tr) |
| 214 | } |
| 215 | forceState(t, ctx, sel) |
| 216 | } |
| 217 | for _, sel := range []string{"#box", "#card", "#list", "#stats", "#b-plain", "#b-primary", "#b-danger", "#b-ghost", "#field", "#dd-sum", "#row-a", "#lra", "#tile", "#nav-x", "#nav-y", "#menu-a", "#opt-active", "#tab-1", "#tab-2", "#page-1", "#page-2"} { |
| 218 | for _, states := range [][]string{{}, {"hover"}, {"hover", "active"}, {"active"}} { |
| 219 | flat(ctx, sel, states...) |
| 220 | } |
| 221 | } |
| 222 | // day's light --rst-line-strong is #868691 (its comment says why it |
| 223 | // is darker than the mockup's). |
| 224 | const line = "rgb(134, 134, 145)" |
| 225 | for _, sel := range []string{"#box", "#card", "#list", "#stats", "#b-plain", "#b-primary", "#b-ghost", "#field", "#dd-sum", "#page-2"} { |
| 226 | if c, w := computed(t, ctx, sel, "border-top-color"), computed(t, ctx, sel, "border-top-width"); c != line || w != "1px" { |
| 227 | t.Errorf("%s in print has border %s %s, want 1px --rst-line-strong %s", sel, c, w, line) |
| 228 | } |
| 229 | } |
| 230 | for _, sel := range []string{"#b-primary", "#b-danger", "#b-plain"} { |
| 231 | for _, states := range [][]string{{}, {"hover"}, {"hover", "active"}} { |
| 232 | forceState(t, ctx, sel, states...) |
| 233 | if bg, fg := computed(t, ctx, sel, "background-color"), computed(t, ctx, sel, "color"); bg != "rgba(0, 0, 0, 0)" || fg != "rgb(24, 24, 27)" { |
| 234 | t.Errorf("%s %v in print is %s on %s; want outlined text", sel, states, fg, bg) |
| 235 | } |
| 236 | forceState(t, ctx, sel) |
| 237 | } |
| 238 | } |
| 239 | if knob, ring := knobStyle(t, ctx, "background-color"), knobStyle(t, ctx, "outline-style"); knob != "rgb(24, 24, 27)" || ring != "solid" { |
| 240 | t.Errorf("the switch knob prints as %s with outline %s; want the text colour inside a solid ring", knob, ring) |
| 241 | } |
| 242 | // On and off must differ by more than the knob's end: the checked |
| 243 | // track (a switch's and a toggle block's) keeps the strong line. |
| 244 | if off, on, head := computed(t, ctx, "#sw [rst-switch-track]", "background-color"), computed(t, ctx, "#sw-on [rst-switch-track]", "background-color"), computed(t, ctx, "#tb [rst-switch-track]", "background-color"); off != "rgba(0, 0, 0, 0)" || on != line || head != line { |
| 245 | t.Errorf("tracks print off %s, on %s, toggle-block on %s; want an empty ring and two filled with %s", off, on, head, line) |
| 246 | } |
| 247 | // Hover tints: the row's, the tile's and the menu item's are tokens |
| 248 | // of their own, and print sets each to the surface or to nothing. |
| 249 | for _, sel := range []string{"#row-a", "#tile", "#menu-a"} { |
| 250 | forceState(t, ctx, sel, "hover") |
| 251 | if got := computed(t, ctx, sel, "background-color"); got != "rgba(0, 0, 0, 0)" && got != "rgb(255, 255, 255)" { |
| 252 | t.Errorf("hovered %s in print is tinted %s", sel, got) |
| 253 | } |
| 254 | forceState(t, ctx, sel) |
| 255 | } |
| 256 | |
| 257 | _, nctx := emulatedRig(t, printMedia, 390, 844) |
| 258 | for _, sel := range []string{"#tnav-x", "#tnav-y", "#nav-y", "#inav-y", "#b-primary", "#card"} { |
| 259 | for _, states := range [][]string{{}, {"hover"}, {"hover", "active"}} { |
| 260 | flat(nctx, sel, states...) |
| 261 | } |
| 262 | } |
| 263 | // The card's hover is its own token, read only inside a phone-and- |
| 264 | // hover query; print sets it to nothing. |
| 265 | forceState(t, nctx, "#tnav-x", "hover") |
| 266 | if got := computed(t, nctx, "#tnav-x", "background-color"); got != "rgba(0, 0, 0, 0)" { |
| 267 | t.Errorf("a hovered topbar phone-card link in print is tinted %s", got) |
| 268 | } |
| 269 | forceState(t, nctx, "#tnav-x") |
| 270 | if got := computed(t, nctx, "#tnav-y", "background-color"); got != "rgba(0, 0, 0, 0)" { |
| 271 | t.Errorf("topbar phone-card current item in print is filled %s; want no fill", got) |
| 272 | } |
| 273 | |
| 274 | // A fresh navigation on the first rig: open() sets the viewport |
| 275 | // before it navigates, so the hover query is on again. |
| 276 | open(t, ctx, rig, "/", false) |
| 277 | still(t, ctx) |
| 278 | var hov bool |
| 279 | mustRun(t, ctx, chromedp.Evaluate(`matchMedia("(hover: hover)").matches`, &hov)) |
| 280 | if !hov { |
| 281 | t.Fatal("(hover: hover) is false after the second navigation; the dark press would be the tap rule alone") |
| 282 | } |
| 283 | mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.dataset.theme = "dark", true`, nil)) |
| 284 | flat(ctx, "#b-primary", "hover", "active") |
| 285 | flat(ctx, "#b-plain", "active") |
| 286 | } |
| 287 | |