| 1 | //go:build browser |
| 2 | |
| 3 | // The media grid's frames, its tile as the target and its no-script |
| 4 | // selection, from rastrillo's ui/mediagrid_browser_test.go at the |
| 5 | // seed commit, its page read through fixture. fixtureImages, the |
| 6 | // catch-all /images/ handler, lives in rig_browser_test.go. |
| 7 | package check |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "encoding/json" |
| 12 | "fmt" |
| 13 | "math" |
| 14 | "net/http" |
| 15 | "net/url" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "github.com/chromedp/cdproto/emulation" |
| 21 | "github.com/chromedp/chromedp" |
| 22 | |
| 23 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 24 | ) |
| 25 | |
| 26 | // Two pictures the grid has to hold without stretching a tile: a page |
| 27 | // screenshot three times taller than it is wide, and a banner six times |
| 28 | // wider than it is tall. SVG so the test owns the ratio, served by the |
| 29 | // drive's own mux at the routes the seeded tiles name. Not data: URLs: |
| 30 | // html/template's URL filter writes #ZgotmplZ for one, and a drive that |
| 31 | // measured the broken-image boxes those leave would pass with the |
| 32 | // cover and contain rules deleted. |
| 33 | const ( |
| 34 | mediaTallSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 900" width="300" height="900"><rect width="300" height="900" fill="#cfd8e3"/><rect width="300" height="60" fill="#2464e0"/></svg>` |
| 35 | mediaWideSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 200" width="1200" height="200"><rect width="1200" height="200" fill="#e5e7eb"/><circle cx="100" cy="100" r="60" fill="#2464e0"/></svg>` |
| 36 | mediaTallSrc = "/images/tall.svg" |
| 37 | mediaWideSrc = "/images/wide.svg" |
| 38 | ) |
| 39 | |
| 40 | // mediaPictures serves the two pictures at their routes, ahead of the |
| 41 | // fixtures' catch-all /images/ handler. |
| 42 | func mediaPictures(mux *http.ServeMux) { |
| 43 | for route, body := range map[string]string{mediaTallSrc: mediaTallSVG, mediaWideSrc: mediaWideSVG} { |
| 44 | body := body |
| 45 | mux.HandleFunc("GET "+route, func(w http.ResponseWriter, _ *http.Request) { |
| 46 | w.Header().Set("Content-Type", "image/svg+xml") |
| 47 | fmt.Fprint(w, body) |
| 48 | }) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // mediaTiles is how many tiles the seeded grid holds: the two shapes, |
| 53 | // each at the default cover and at contain, one tile selected (the |
| 54 | // wide contained one, value wide-contain), every tile linked to |
| 55 | // /go/<id> and with a kebab, so one grid exercises every control the |
| 56 | // partial draws. The first tile (tall, value tall) carries a long |
| 57 | // description, so the clamp has something to clamp. |
| 58 | const mediaTiles = 4 |
| 59 | |
| 60 | // mediaPage is the grid in a list card under a bulk bar, in one |
| 61 | // direction, the way an Images page in select mode is laid out. The |
| 62 | // ids are for the probes. |
| 63 | func mediaPage(t *testing.T, dir string) string { |
| 64 | t.Helper() |
| 65 | return strings.Replace(sizingDoc("media-grid", fixture(t, "mediagrid_browser_test.go", "mediaPage", "")), `dir="ltr"`, `dir="`+dir+`"`, 1) |
| 66 | } |
| 67 | |
| 68 | // mediaMux serves the page in both directions, a landing page for every |
| 69 | // /go/ href, and the form's action, which hands what it was posted to |
| 70 | // the drive. |
| 71 | func mediaMux(t *testing.T, posted chan<- url.Values) *http.ServeMux { |
| 72 | t.Helper() |
| 73 | mux := sizingMux(t, map[string]string{ |
| 74 | "/": mediaPage(t, "ltr"), |
| 75 | "/rtl": mediaPage(t, "rtl"), |
| 76 | "/go/": sizingDoc("landed", `<p id="landed">landed</p>`), |
| 77 | }) |
| 78 | mediaPictures(mux) |
| 79 | mux.HandleFunc("POST /select", func(w http.ResponseWriter, r *http.Request) { |
| 80 | if err := r.ParseForm(); err != nil { |
| 81 | http.Error(w, err.Error(), http.StatusBadRequest) |
| 82 | return |
| 83 | } |
| 84 | posted <- r.PostForm |
| 85 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 86 | fmt.Fprint(w, sizingDoc("posted", `<p id="posted">posted</p>`)) |
| 87 | }) |
| 88 | return mux |
| 89 | } |
| 90 | |
| 91 | // mediaReading is one tile measured: its boxes, the image's, the |
| 92 | // caption's last line's, the two controls', and whether anything in the |
| 93 | // tile is cut off by it. |
| 94 | type mediaReading struct { |
| 95 | Name string |
| 96 | Tile, Frame, Img, Caption, Last, Link box |
| 97 | Check, Kebab *box |
| 98 | Overflow float64 // scrollHeight - clientHeight of the tile |
| 99 | Contain bool |
| 100 | Selected bool |
| 101 | Outline string |
| 102 | // The picture as decoded, and how the frame fits it: naturalWidth |
| 103 | // is 0 for a picture that did not decode, so a 404 or a filtered |
| 104 | // src reads as the zero it is. |
| 105 | Decoded bool |
| 106 | NaturalW, NaturalH int |
| 107 | ObjectFit, ObjectPos string |
| 108 | Src string |
| 109 | } |
| 110 | |
| 111 | const mediaJS = `(() => { |
| 112 | const r = el => { if (!el) return null; const b = el.getBoundingClientRect(); return {L: b.left, T: b.top, R: b.right, B: b.bottom}; }; |
| 113 | const out = []; |
| 114 | document.querySelectorAll("[rst-media-tile]").forEach(tile => { |
| 115 | const frame = tile.querySelector("[rst-media-frame]"), cap = tile.querySelector("[rst-media-caption]"); |
| 116 | const last = cap.querySelector("[rst-media-meta]") || cap.querySelector("[rst-media-desc]"); |
| 117 | const img = frame.querySelector("img"), is = getComputedStyle(img); |
| 118 | out.push({Name: tile.querySelector("[rst-media-name] > :first-child").textContent, |
| 119 | Tile: r(tile), Frame: r(frame), Img: r(img), Caption: r(cap), Last: r(last), |
| 120 | Decoded: img.complete && img.naturalWidth > 0, NaturalW: img.naturalWidth, NaturalH: img.naturalHeight, |
| 121 | ObjectFit: is.objectFit, ObjectPos: is.objectPosition, Src: img.getAttribute("src"), |
| 122 | Link: r(tile.querySelector("[rst-media-name] > a")), |
| 123 | Check: r(tile.querySelector("[rst-selbox]")), Kebab: r(tile.querySelector("[rst-row-menu] > summary")), |
| 124 | Overflow: tile.scrollHeight - tile.clientHeight, |
| 125 | Contain: frame.getAttribute("rst-media-frame") === "contain", |
| 126 | Selected: !!tile.querySelector("[rst-selbox] input:checked"), |
| 127 | Outline: getComputedStyle(tile).outlineStyle + " " + getComputedStyle(tile).outlineWidth}); |
| 128 | }); |
| 129 | const gridEl = document.querySelector("[rst-media]"), gs = getComputedStyle(gridEl); |
| 130 | const grid = r(gridEl), inner = gridEl.clientWidth - parseFloat(gs.paddingLeft) - parseFloat(gs.paddingRight); |
| 131 | const floor = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--rst-target")) * parseFloat(getComputedStyle(document.documentElement).fontSize); |
| 132 | return JSON.stringify({Tiles: out, Grid: grid, GridInner: inner, Floor: floor, Width: innerWidth, RTL: document.documentElement.dir === "rtl", |
| 133 | Scripted: document.documentElement.dataset.scripted === "1"}); |
| 134 | })()` |
| 135 | |
| 136 | type mediaPageReading struct { |
| 137 | Tiles []mediaReading |
| 138 | Grid box |
| 139 | GridInner float64 |
| 140 | Floor float64 |
| 141 | Width int |
| 142 | RTL bool |
| 143 | Scripted bool |
| 144 | } |
| 145 | |
| 146 | func readMedia(t *testing.T, ctx context.Context) mediaPageReading { |
| 147 | t.Helper() |
| 148 | // Every picture has finished loading, well or badly, before the |
| 149 | // reading: complete is true for a decode and for a failure alike, |
| 150 | // and naturalWidth tells them apart. |
| 151 | settleUntil(t, ctx, `[...document.images].every(i => i.complete)`) |
| 152 | var raw string |
| 153 | if err := chromedp.Run(ctx, chromedp.Evaluate(mediaJS, &raw)); err != nil { |
| 154 | t.Fatalf("reading the grid: %v", err) |
| 155 | } |
| 156 | var got mediaPageReading |
| 157 | if err := json.Unmarshal([]byte(raw), &got); err != nil { |
| 158 | t.Fatalf("decoding %q: %v", raw, err) |
| 159 | } |
| 160 | if len(got.Tiles) != mediaTiles { |
| 161 | t.Fatalf("read %d tiles, want %d; the selectors no longer match the partial", len(got.Tiles), mediaTiles) |
| 162 | } |
| 163 | return got |
| 164 | } |
| 165 | |
| 166 | // insideBox says whether inner is inside outer, to a pixel. |
| 167 | func insideBox(inner, outer box) bool { |
| 168 | return inner.L >= outer.L-1 && inner.T >= outer.T-1 && inner.R <= outer.R+1 && inner.B <= outer.B+1 |
| 169 | } |
| 170 | |
| 171 | // TestMediaGridFramesAreEvenAndCaptionsStayInTheTile is the partial's |
| 172 | // claim, measured: whatever the picture's shape, a 1:3 screenshot or a |
| 173 | // 6:1 banner, its frame is 4:3 and the image is held inside it; the |
| 174 | // name, the description and the meta line are inside the tile with |
| 175 | // nothing cut off; the checkbox and the kebab are at the floor, in the |
| 176 | // frame's and the caption's start corners, in both directions; one |
| 177 | // column at a phone's width and several at a desktop's. |
| 178 | func TestMediaGridFramesAreEvenAndCaptionsStayInTheTile(t *testing.T) { |
| 179 | for _, leg := range []struct { |
| 180 | name string |
| 181 | w, h int64 |
| 182 | coarse bool |
| 183 | dir string |
| 184 | floor float64 |
| 185 | cols int // the least number of columns |
| 186 | }{ |
| 187 | {"1024 mouse", 1024, 768, false, "ltr", 32, 3}, |
| 188 | {"1024 mouse rtl", 1024, 768, false, "rtl", 32, 3}, |
| 189 | {"390 touch", 390, 844, true, "ltr", 44, 1}, |
| 190 | {"390 touch rtl", 390, 844, true, "rtl", 44, 1}, |
| 191 | {"320 touch", 320, 568, true, "ltr", 44, 1}, |
| 192 | } { |
| 193 | t.Run(leg.name, func(t *testing.T) { |
| 194 | rig := mediaRig(t, leg.coarse, mediaMux(t, make(chan url.Values, 4))) |
| 195 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 196 | defer cancel() |
| 197 | path := "/" |
| 198 | if leg.dir == "rtl" { |
| 199 | path = "/rtl" |
| 200 | } |
| 201 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("[rst-media]", chromedp.ByQuery)); err != nil { |
| 202 | t.Fatal(err) |
| 203 | } |
| 204 | requirePointer(t, ctx, leg.coarse) |
| 205 | got := readMedia(t, ctx) |
| 206 | if got.RTL != (leg.dir == "rtl") { |
| 207 | t.Fatalf("the page is dir=%v, this leg wants %s", got.RTL, leg.dir) |
| 208 | } |
| 209 | if got.Floor != leg.floor { |
| 210 | t.Errorf("--rst-target is %vpx, want %v", got.Floor, leg.floor) |
| 211 | } |
| 212 | lefts := map[int]bool{} |
| 213 | for _, tile := range got.Tiles { |
| 214 | if tile.Tile.B > got.Tiles[0].Tile.B+1 { |
| 215 | continue // a later row |
| 216 | } |
| 217 | lefts[int(math.Round(tile.Tile.L))] = true |
| 218 | } |
| 219 | if len(lefts) < leg.cols { |
| 220 | t.Errorf("%d column(s) in the first row, want at least %d", len(lefts), leg.cols) |
| 221 | } |
| 222 | if leg.cols == 1 && len(lefts) != 1 { |
| 223 | t.Errorf("a phone shows %d columns, want one", len(lefts)) |
| 224 | } |
| 225 | if leg.cols == 1 { |
| 226 | // One column, and the column is the grid: a tile narrower |
| 227 | // than the grid would be a grid that reserved room for a |
| 228 | // second tile it cannot fit. |
| 229 | if tw, gw := got.Tiles[0].Tile.w(), got.GridInner; math.Abs(tw-gw) > 1 { |
| 230 | t.Errorf("the one column is %.0fpx wide in a grid whose content box is %.0fpx", tw, gw) |
| 231 | } |
| 232 | } |
| 233 | var selected int |
| 234 | for _, tile := range got.Tiles { |
| 235 | where := leg.name + ": " + tile.Name |
| 236 | if ratio := tile.Frame.w() / (tile.Frame.B - tile.Frame.T); math.Abs(ratio-4.0/3) > 0.02 { |
| 237 | t.Errorf("%s: the frame is %.0f×%.0f, ratio %.3f, want 4:3", where, tile.Frame.w(), tile.Frame.B-tile.Frame.T, ratio) |
| 238 | } |
| 239 | // The picture decoded at the shape the drive drew, so what |
| 240 | // is measured below is the real 1:3 or 6:1 image in its |
| 241 | // frame and not a broken-image box. |
| 242 | wantW, wantH := 300, 900 |
| 243 | if tile.Src == mediaWideSrc { |
| 244 | wantW, wantH = 1200, 200 |
| 245 | } else if tile.Src != mediaTallSrc { |
| 246 | t.Fatalf("%s: the image's src is %q, not a route this drive serves", where, tile.Src) |
| 247 | } |
| 248 | if !tile.Decoded || tile.NaturalW != wantW || tile.NaturalH != wantH { |
| 249 | t.Errorf("%s: the picture decoded %v at %d×%d, want %d×%d", where, tile.Decoded, tile.NaturalW, tile.NaturalH, wantW, wantH) |
| 250 | } |
| 251 | // cover from the top for the default, contain for Fit: the |
| 252 | // two rules that make a tall screenshot show its top and a |
| 253 | // logo show whole, read off the engine rather than inferred |
| 254 | // from a box the frame's own size would give either way. |
| 255 | if tile.Contain { |
| 256 | if tile.ObjectFit != "contain" { |
| 257 | t.Errorf("%s: Fit contain computes object-fit %q, want contain", where, tile.ObjectFit) |
| 258 | } |
| 259 | } else if tile.ObjectFit != "cover" || tile.ObjectPos != "50% 0%" { |
| 260 | t.Errorf("%s: the default computes object-fit %q at %q, want cover at 50%% 0%% (top centre)", where, tile.ObjectFit, tile.ObjectPos) |
| 261 | } |
| 262 | if !insideBox(tile.Img, tile.Frame) { |
| 263 | t.Errorf("%s: the image %+v is not held inside its frame %+v", where, tile.Img, tile.Frame) |
| 264 | } |
| 265 | if tile.Img.w() < tile.Frame.w()-1 || (tile.Img.B-tile.Img.T) < (tile.Frame.B-tile.Frame.T)-1 { |
| 266 | t.Errorf("%s: the image box %+v does not fill its frame %+v; the frame's shape would show around it", where, tile.Img, tile.Frame) |
| 267 | } |
| 268 | if !insideBox(tile.Caption, tile.Tile) || !insideBox(tile.Last, tile.Tile) { |
| 269 | t.Errorf("%s: the caption %+v or its last line %+v leaves the tile %+v", where, tile.Caption, tile.Last, tile.Tile) |
| 270 | } |
| 271 | if tile.Caption.T < tile.Frame.B-1 { |
| 272 | t.Errorf("%s: the caption starts at %.0f, over the frame that ends at %.0f", where, tile.Caption.T, tile.Frame.B) |
| 273 | } |
| 274 | if tile.Overflow > 0.5 { |
| 275 | t.Errorf("%s: %.0fpx of the tile is cut off; a caption must be visible without scrolling the tile", where, tile.Overflow) |
| 276 | } |
| 277 | if tile.Check == nil || tile.Kebab == nil { |
| 278 | t.Fatalf("%s: no checkbox or no kebab was rendered", where) |
| 279 | } |
| 280 | for name, b := range map[string]box{"checkbox": *tile.Check, "kebab": *tile.Kebab} { |
| 281 | if b.w() < got.Floor-0.5 || (b.B-b.T) < got.Floor-0.5 { |
| 282 | t.Errorf("%s: the %s is %.0f×%.0f, under the %.0fpx floor", where, name, b.w(), b.B-b.T, got.Floor) |
| 283 | } |
| 284 | } |
| 285 | if !insideBox(*tile.Check, tile.Frame) { |
| 286 | t.Errorf("%s: the checkbox %+v is not inside the frame %+v", where, *tile.Check, tile.Frame) |
| 287 | } |
| 288 | // The start corners: the checkbox in the frame's, the |
| 289 | // kebab at the caption's end, mirrored under dir=rtl. |
| 290 | if got.RTL { |
| 291 | if tile.Frame.R-tile.Check.R > 8 || tile.Check.T-tile.Frame.T > 8 { |
| 292 | t.Errorf("%s: in RTL the checkbox %+v is not in the frame's top right corner (frame %+v)", where, *tile.Check, tile.Frame) |
| 293 | } |
| 294 | if tile.Kebab.L > tile.Link.L { |
| 295 | t.Errorf("%s: in RTL the kebab (%.0f) is not at the left of the name (%.0f)", where, tile.Kebab.L, tile.Link.L) |
| 296 | } |
| 297 | } else { |
| 298 | if tile.Check.L-tile.Frame.L > 8 || tile.Check.T-tile.Frame.T > 8 { |
| 299 | t.Errorf("%s: the checkbox %+v is not in the frame's top left corner (frame %+v)", where, *tile.Check, tile.Frame) |
| 300 | } |
| 301 | if tile.Kebab.R < tile.Link.R { |
| 302 | t.Errorf("%s: the kebab (%.0f) is not at the right of the name (%.0f)", where, tile.Kebab.R, tile.Link.R) |
| 303 | } |
| 304 | } |
| 305 | if tile.Selected { |
| 306 | selected++ |
| 307 | if !strings.HasPrefix(tile.Outline, "solid 2px") { |
| 308 | t.Errorf("%s: the selected tile's outline is %q, want a solid 2px accent outline", where, tile.Outline) |
| 309 | } |
| 310 | } else if !strings.HasPrefix(tile.Outline, "none") { |
| 311 | t.Errorf("%s: an unselected tile has an outline (%q)", where, tile.Outline) |
| 312 | } |
| 313 | } |
| 314 | if selected != 1 { |
| 315 | t.Errorf("%d tiles read as selected, want the one the server checked", selected) |
| 316 | } |
| 317 | }) |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | // mediaRig is sizingRig with a mux of the drive's own: the form's |
| 322 | // action is a handler, not a page. |
| 323 | func mediaRig(t *testing.T, coarse bool, mux *http.ServeMux) *harness.Rig { |
| 324 | t.Helper() |
| 325 | var opts []harness.Option |
| 326 | if coarse { |
| 327 | opts = append(opts, harness.WithCoarsePointer()) |
| 328 | } |
| 329 | return harness.New(t, func(string) http.Handler { return mux }, opts...) |
| 330 | } |
| 331 | |
| 332 | // TestMediaGridTileIsTheTarget is the tile's half of the whole-row |
| 333 | // rule: a click on the picture, on the description and on the tile's |
| 334 | // empty corner all open the tile's Href, and the checkbox and the kebab |
| 335 | // each do their own thing without navigating, with a mouse and with a |
| 336 | // touch screen. |
| 337 | func TestMediaGridTileIsTheTarget(t *testing.T) { |
| 338 | for _, leg := range []struct { |
| 339 | name string |
| 340 | w, h int64 |
| 341 | coarse bool |
| 342 | }{{"1280 mouse", 1280, 900, false}, {"390 touch", 390, 844, true}} { |
| 343 | t.Run(leg.name, func(t *testing.T) { |
| 344 | rig := mediaRig(t, leg.coarse, mediaMux(t, make(chan url.Values, 4))) |
| 345 | ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) |
| 346 | defer cancel() |
| 347 | if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h)); err != nil { |
| 348 | t.Fatal(err) |
| 349 | } |
| 350 | load := func() { |
| 351 | t.Helper() |
| 352 | if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-media]", chromedp.ByQuery)); err != nil { |
| 353 | t.Fatal(err) |
| 354 | } |
| 355 | } |
| 356 | load() |
| 357 | requirePointer(t, ctx, leg.coarse) |
| 358 | // The ids the probes read back: the first tile's link, box |
| 359 | // and kebab, by position in the DOM. |
| 360 | if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { const tile = document.querySelector("[rst-media-tile]"); |
| 361 | tile.id = "tile"; tile.querySelector("[rst-media-frame]").id = "frame"; tile.querySelector("img").id = "img"; |
| 362 | tile.querySelector("[rst-media-name] > a").id = "link"; tile.querySelector("[rst-selbox] input").id = "check"; |
| 363 | tile.querySelector("[rst-row-menu]").id = "menu"; tile.querySelector("[rst-row-menu] > summary").id = "kebab"; |
| 364 | tile.querySelector("[rst-media-desc]").id = "desc"; return true; })()`, nil)); err != nil { |
| 365 | t.Fatal(err) |
| 366 | } |
| 367 | for _, c := range []struct { |
| 368 | where, sel string |
| 369 | fx, fy float64 |
| 370 | }{ |
| 371 | {"the middle of the picture", "#frame", 0.5, 0.5}, |
| 372 | {"the description", "#desc", 0.5, 0.5}, |
| 373 | {"the tile's bottom end corner", "#tile", 0.97, 0.97}, |
| 374 | } { |
| 375 | p := probe(t, ctx, c.sel, c.fx, c.fy, 0, 0) |
| 376 | if p.Hit != "link" { |
| 377 | t.Errorf("%s: at %s the element under the pointer is %s, want the tile's name link", leg.name, c.where, p.Hit) |
| 378 | } |
| 379 | clickAndLand(t, ctx, p, "/go/tall") |
| 380 | load() |
| 381 | if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { const tile = document.querySelector("[rst-media-tile]"); |
| 382 | tile.id = "tile"; tile.querySelector("[rst-media-frame]").id = "frame"; tile.querySelector("[rst-media-name] > a").id = "link"; |
| 383 | tile.querySelector("[rst-selbox] input").id = "check"; tile.querySelector("[rst-row-menu]").id = "menu"; |
| 384 | tile.querySelector("[rst-row-menu] > summary").id = "kebab"; tile.querySelector("[rst-media-desc]").id = "desc"; return true; })()`, nil)); err != nil { |
| 385 | t.Fatal(err) |
| 386 | } |
| 387 | } |
| 388 | box := probe(t, ctx, "#check", 0.5, 0.5, 0, 0) |
| 389 | if box.Hit != "check" { |
| 390 | t.Errorf("%s: over the checkbox the element under the pointer is %s", leg.name, box.Hit) |
| 391 | } |
| 392 | clickAndStay(t, ctx, box, "", `document.getElementById("check").checked`) |
| 393 | settleUntil(t, ctx, `getComputedStyle(document.getElementById("tile")).outlineStyle === "solid"`) |
| 394 | // The pointer lands on the icon inside the summary, so the hit |
| 395 | // is read as the control it is inside rather than by its id. |
| 396 | kebab := probe(t, ctx, "#kebab", 0.5, 0.5, 0, 0) |
| 397 | var onKebab bool |
| 398 | if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`!!document.elementFromPoint(%v, %v).closest("#kebab")`, kebab.X, kebab.Y), &onKebab)); err != nil { |
| 399 | t.Fatal(err) |
| 400 | } |
| 401 | if !onKebab { |
| 402 | t.Errorf("%s: over the kebab the element under the pointer is %s, outside the summary", leg.name, kebab.Hit) |
| 403 | } |
| 404 | clickAndStay(t, ctx, kebab, "", `document.getElementById("menu").open`) |
| 405 | }) |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | // TestMediaGridSelectionReachesTheBulkBarsForm is the no-JavaScript |
| 410 | // round trip: with script execution disabled in the engine, a reader |
| 411 | // ticks a tile, opens the bulk bar's menu and presses an action, and |
| 412 | // the form posts every checked tile's value under one name beside the |
| 413 | // action, which is all a handler needs to act on the selection. |
| 414 | func TestMediaGridSelectionReachesTheBulkBarsForm(t *testing.T) { |
| 415 | posted := make(chan url.Values, 4) |
| 416 | rig := mediaRig(t, true, mediaMux(t, posted)) |
| 417 | ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second) |
| 418 | defer cancel() |
| 419 | if err := chromedp.Run(ctx, emulation.SetScriptExecutionDisabled(true), chromedp.EmulateViewport(390, 844), |
| 420 | chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-media]", chromedp.ByQuery)); err != nil { |
| 421 | t.Fatal(err) |
| 422 | } |
| 423 | // The page's own inline script marks the document when it runs; |
| 424 | // CDP's evaluate runs regardless, so the marker is the proof. |
| 425 | if readMedia(t, ctx).Scripted { |
| 426 | t.Fatal("the page's script ran; this drive is the no-JavaScript round trip") |
| 427 | } |
| 428 | // The first tile's box, then the bulk bar's menu and its one action. |
| 429 | // Scripts are off, so each is a real click at the control's box. |
| 430 | click := func(sel string) { |
| 431 | t.Helper() |
| 432 | p := probeScripted(t, ctx, false, sel, 0.5, 0.5, 0, 0) |
| 433 | if err := chromedp.Run(ctx, chromedp.MouseClickXY(p.X, p.Y)); err != nil { |
| 434 | t.Fatal(err) |
| 435 | } |
| 436 | } |
| 437 | click(`[rst-media-tile]:first-child [rst-selbox] input`) |
| 438 | click(`[rst-bulkbar] [rst-dropdown] > summary`) |
| 439 | click(`[rst-bulkbar] [rst-dropdown-menu] button[value="export"]`) |
| 440 | var form url.Values |
| 441 | select { |
| 442 | case form = <-posted: |
| 443 | case <-time.After(10 * time.Second): |
| 444 | t.Fatal("the form never posted: the action button did not submit it with scripts off") |
| 445 | } |
| 446 | select { |
| 447 | case again := <-posted: |
| 448 | t.Fatalf("the form posted twice; the second time with %v", again) |
| 449 | case <-time.After(500 * time.Millisecond): |
| 450 | } |
| 451 | if got, want := form["id"], []string{"tall", "wide-contain"}; strings.Join(got, ",") != strings.Join(want, ",") { |
| 452 | t.Errorf("the selection posted id=%v, want %v: the tile ticked by hand and the one the server checked", got, want) |
| 453 | } |
| 454 | if got := form.Get("action"); got != "export" { |
| 455 | t.Errorf("the action posted is %q, want export", got) |
| 456 | } |
| 457 | } |
| 458 | |