github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/test/web/page.go (about) 1 // Copyright 2021 Daniel Erat. 2 // All rights reserved. 3 4 package web 5 6 import ( 7 "errors" 8 "fmt" 9 "math" 10 "net/url" 11 "reflect" 12 "regexp" 13 "strconv" 14 "strings" 15 "testing" 16 17 "github.com/derat/nup/server/db" 18 "github.com/derat/nup/test" 19 "github.com/tebeka/selenium" 20 ) 21 22 // testEmail is used to log in to the dev_appserver.py's fake login page: 23 // https://cloud.google.com/appengine/docs/standard/go111/users 24 const testEmail = "testuser@example.org" 25 26 // loc matches an element in the page. 27 // See selenium.WebDriver.FindElement(). 28 type loc struct { 29 by, value string 30 } 31 32 // joinLocs flattens locs, consisting of loc and []loc items, into a single slice. 33 func joinLocs(locs ...interface{}) []loc { 34 var all []loc 35 for _, l := range locs { 36 if tl, ok := l.(loc); ok { 37 all = append(all, tl) 38 } else if tl, ok := l.([]loc); ok { 39 all = append(all, tl...) 40 } else { 41 panic(fmt.Sprintf("Invalid type %T (must be loc or []loc)", l)) 42 } 43 } 44 return all 45 } 46 47 var ( 48 // Fake login page served by dev_appserver.py. 49 loginEmail = joinLocs(loc{selenium.ByID, "email"}) 50 loginButton = joinLocs(loc{selenium.ByID, "submit-login"}) 51 52 // Note that selenium.ByTagName doesn't seem to work within shadow roots. 53 // Use selenium.ByCSSSelector instead for referencing deeply-nested elements. 54 document = []loc(nil) 55 body = joinLocs(loc{selenium.ByTagName, "body"}) 56 57 optionsDialog = joinLocs(body, loc{selenium.ByCSSSelector, "dialog.options > span"}) 58 optionsOKButton = joinLocs(optionsDialog, loc{selenium.ByID, "ok-button"}) 59 themeSelect = joinLocs(optionsDialog, loc{selenium.ByID, "theme-select"}) 60 gainTypeSelect = joinLocs(optionsDialog, loc{selenium.ByID, "gain-type-select"}) 61 preAmpRange = joinLocs(optionsDialog, loc{selenium.ByID, "pre-amp-range"}) 62 63 infoDialog = joinLocs(body, loc{selenium.ByCSSSelector, "dialog.song-info > span"}) 64 infoArtist = joinLocs(infoDialog, loc{selenium.ByID, "artist"}) 65 infoTitle = joinLocs(infoDialog, loc{selenium.ByID, "title"}) 66 infoAlbum = joinLocs(infoDialog, loc{selenium.ByID, "album"}) 67 infoTrack = joinLocs(infoDialog, loc{selenium.ByID, "track"}) 68 infoDisc = joinLocs(infoDialog, loc{selenium.ByID, "disc"}) 69 infoDate = joinLocs(infoDialog, loc{selenium.ByID, "date"}) 70 infoLength = joinLocs(infoDialog, loc{selenium.ByID, "length"}) 71 infoRating = joinLocs(infoDialog, loc{selenium.ByID, "rating"}) 72 infoTags = joinLocs(infoDialog, loc{selenium.ByID, "tags"}) 73 infoDismissButton = joinLocs(infoDialog, loc{selenium.ByID, "dismiss-button"}) 74 75 statsDialog = joinLocs(body, loc{selenium.ByCSSSelector, "dialog.stats > span"}) 76 statsDecadesChart = joinLocs(statsDialog, loc{selenium.ByID, "decades-chart"}) 77 statsRatingsChart = joinLocs(statsDialog, loc{selenium.ByID, "ratings-chart"}) 78 79 menu = joinLocs(body, loc{selenium.ByCSSSelector, "dialog.menu > span"}) 80 menuFullscreen = joinLocs(menu, loc{selenium.ByID, "fullscreen"}) 81 menuOptions = joinLocs(menu, loc{selenium.ByID, "options"}) 82 menuStats = joinLocs(menu, loc{selenium.ByID, "stats"}) 83 menuInfo = joinLocs(menu, loc{selenium.ByID, "info"}) 84 menuPlay = joinLocs(menu, loc{selenium.ByID, "play"}) 85 menuRemove = joinLocs(menu, loc{selenium.ByID, "remove"}) 86 menuTruncate = joinLocs(menu, loc{selenium.ByID, "truncate"}) 87 menuUpdate = joinLocs(menu, loc{selenium.ByID, "update"}) 88 89 playView = joinLocs(loc{selenium.ByTagName, "play-view"}) 90 menuButton = joinLocs(playView, loc{selenium.ByID, "menu-button"}) 91 coverImage = joinLocs(playView, loc{selenium.ByID, "cover-img"}) 92 ratingOverlayDiv = joinLocs(playView, loc{selenium.ByID, "rating-overlay"}) 93 artistDiv = joinLocs(playView, loc{selenium.ByID, "artist"}) 94 titleDiv = joinLocs(playView, loc{selenium.ByID, "title"}) 95 albumDiv = joinLocs(playView, loc{selenium.ByID, "album"}) 96 timeDiv = joinLocs(playView, loc{selenium.ByID, "time"}) 97 prevButton = joinLocs(playView, loc{selenium.ByID, "prev"}) 98 playPauseButton = joinLocs(playView, loc{selenium.ByID, "play-pause"}) 99 nextButton = joinLocs(playView, loc{selenium.ByID, "next"}) 100 101 audioWrapper = joinLocs(playView, loc{selenium.ByCSSSelector, "audio-wrapper"}) 102 audio = joinLocs(audioWrapper, loc{selenium.ByCSSSelector, "audio"}) 103 104 playlistTable = joinLocs(playView, loc{selenium.ByID, "playlist"}, 105 loc{selenium.ByCSSSelector, "table"}) 106 107 updateDialog = joinLocs(body, loc{selenium.ByCSSSelector, "dialog.update > span"}) 108 updateArtist = joinLocs(updateDialog, loc{selenium.ByID, "artist"}) 109 updateTitle = joinLocs(updateDialog, loc{selenium.ByID, "title"}) 110 updateOneStar = joinLocs(updateDialog, loc{selenium.ByCSSSelector, "#rating a:nth-child(1)"}) 111 updateTwoStars = joinLocs(updateDialog, loc{selenium.ByCSSSelector, "#rating a:nth-child(2)"}) 112 updateThreeStars = joinLocs(updateDialog, loc{selenium.ByCSSSelector, "#rating a:nth-child(3)"}) 113 updateFourStars = joinLocs(updateDialog, loc{selenium.ByCSSSelector, "#rating a:nth-child(4)"}) 114 updateFiveStars = joinLocs(updateDialog, loc{selenium.ByCSSSelector, "#rating a:nth-child(5)"}) 115 updateTagsTextarea = joinLocs(updateDialog, loc{selenium.ByID, "tags-textarea"}) 116 updateTagSuggester = joinLocs(updateDialog, loc{selenium.ByID, "tag-suggester"}) 117 updateCloseImage = joinLocs(updateDialog, loc{selenium.ByID, "close-icon"}) 118 119 fullscreenOverlay = joinLocs(playView, loc{selenium.ByCSSSelector, "fullscreen-overlay"}) 120 currentArtistDiv = joinLocs(fullscreenOverlay, loc{selenium.ByID, "current-artist"}) 121 currentTitleDiv = joinLocs(fullscreenOverlay, loc{selenium.ByID, "current-title"}) 122 currentAlbumDiv = joinLocs(fullscreenOverlay, loc{selenium.ByID, "current-album"}) 123 nextArtistDiv = joinLocs(fullscreenOverlay, loc{selenium.ByID, "next-artist"}) 124 nextTitleDiv = joinLocs(fullscreenOverlay, loc{selenium.ByID, "next-title"}) 125 nextAlbumDiv = joinLocs(fullscreenOverlay, loc{selenium.ByID, "next-album"}) 126 127 searchView = joinLocs(loc{selenium.ByTagName, "search-view"}) 128 keywordsInput = joinLocs(searchView, loc{selenium.ByID, "keywords-input"}) 129 tagsInput = joinLocs(searchView, loc{selenium.ByID, "tags-input"}) 130 minDateInput = joinLocs(searchView, loc{selenium.ByID, "min-date-input"}) 131 maxDateInput = joinLocs(searchView, loc{selenium.ByID, "max-date-input"}) 132 firstTrackCheckbox = joinLocs(searchView, loc{selenium.ByID, "first-track-checkbox"}) 133 ratingOpSelect = joinLocs(searchView, loc{selenium.ByID, "rating-op-select"}) 134 ratingStarsSelect = joinLocs(searchView, loc{selenium.ByID, "rating-stars-select"}) 135 unratedCheckbox = joinLocs(searchView, loc{selenium.ByID, "unrated-checkbox"}) 136 orderByLastPlayedCheckbox = joinLocs(searchView, loc{selenium.ByID, "order-by-last-played-checkbox"}) 137 maxPlaysInput = joinLocs(searchView, loc{selenium.ByID, "max-plays-input"}) 138 firstPlayedSelect = joinLocs(searchView, loc{selenium.ByID, "first-played-select"}) 139 lastPlayedSelect = joinLocs(searchView, loc{selenium.ByID, "last-played-select"}) 140 presetSelect = joinLocs(searchView, loc{selenium.ByID, "preset-select"}) 141 searchButton = joinLocs(searchView, loc{selenium.ByID, "search-button"}) 142 resetButton = joinLocs(searchView, loc{selenium.ByID, "reset-button"}) 143 luckyButton = joinLocs(searchView, loc{selenium.ByID, "lucky-button"}) 144 appendButton = joinLocs(searchView, loc{selenium.ByID, "append-button"}) 145 insertButton = joinLocs(searchView, loc{selenium.ByID, "insert-button"}) 146 replaceButton = joinLocs(searchView, loc{selenium.ByID, "replace-button"}) 147 148 searchResultsCheckbox = joinLocs(searchView, loc{selenium.ByID, "results-table"}, 149 loc{selenium.ByCSSSelector, `th input[type="checkbox"]`}) 150 searchResultsTable = joinLocs(searchView, loc{selenium.ByID, "results-table"}, 151 loc{selenium.ByCSSSelector, "table"}) 152 ) 153 154 const ( 155 // Text for ratingOpSelect options. 156 atLeast = "at least" 157 atMost = "at most" 158 exactly = "exactly" 159 160 // Text for ratingStarSelect options. Note hacky U+2009 (THIN SPACE) characters. 161 oneStar = "★" 162 twoStars = "★ ★" 163 threeStars = "★ ★ ★" 164 fourStars = "★ ★ ★ ★" 165 fiveStars = "★ ★ ★ ★ ★" 166 167 // Text for firstPlayedSelect and lastPlayedSelect options. 168 unsetTime = "" 169 oneDay = "one day" 170 oneWeek = "one week" 171 oneMonth = "one month" 172 threeMonths = "three months" 173 sixMonths = "six months" 174 oneYear = "one year" 175 threeYears = "three years" 176 fiveYears = "five years" 177 178 // Text and values for themeSelect options. 179 themeAuto = "Auto" 180 themeLight = "Light" 181 themeDark = "Dark" 182 themeAutoValue = "0" 183 themeLightValue = "1" 184 themeDarkValue = "2" 185 186 // Text and values for gainTypeSelect options. 187 gainAuto = "Auto" 188 gainAlbum = "Album" 189 gainTrack = "Track" 190 gainNone = "None" 191 gainAutoValue = "3" 192 gainAlbumValue = "0" 193 gainTrackValue = "1" 194 gainNoneValue = "2" 195 196 // Text for presetSelect options. 197 // These match the presets defined in sendConfig() in web_test.go. 198 presetInstrumentalOld = "instrumental old" 199 presetMellow = "mellow" 200 presetPlayedOnce = "played once" 201 presetNewAlbums = "new albums" 202 presetUnrated = "unrated" 203 ) 204 205 // isMissingAttrError returns true if err was returned by calling 206 // GetAttribute for an attribute that doesn't exist. 207 func isMissingAttrError(err error) bool { 208 // https://github.com/tebeka/selenium/issues/143 209 return err != nil && err.Error() == "nil return value" 210 } 211 212 // isStaleElementError returns true if err was caused by using a selenium.WebElement 213 // that refers to an element that no longer exists. 214 func isStaleElementError(err error) bool { 215 return err != nil && strings.Contains(err.Error(), "stale element reference") 216 } 217 218 // page is used by tests to interact with the web interface. 219 type page struct { 220 t *testing.T 221 wd selenium.WebDriver 222 stage string 223 } 224 225 func newPage(t *testing.T, wd selenium.WebDriver, baseURL string) *page { 226 p := page{t, wd, ""} 227 if err := wd.Get(baseURL); err != nil { 228 t.Fatalf("Failed loading %v: %v", baseURL, err) 229 } 230 p.configPage() 231 return &p 232 } 233 234 // configPage configures the page for testing. This is called automatically. 235 func (p *page) configPage() { 236 // If we're at dev_appserver.py's fake login page, log in to get to the app. 237 if btn, err := p.getNoWait(loginButton); err == nil { 238 p.setText(loginEmail, testEmail) 239 if err := btn.Click(); err != nil { 240 p.t.Fatalf("Failed clicking login button at %v: %v", p.desc(), err) 241 } 242 p.getOrFail(playView) 243 } 244 if _, err := p.wd.ExecuteScript("document.test.setPlayDelayMs(10)", nil); err != nil { 245 p.t.Fatalf("Failed setting short play delay at %v: %v", p.desc(), err) 246 } 247 if _, err := p.wd.ExecuteScript("document.test.reset()", nil); err != nil { 248 p.t.Fatalf("Failed resetting page at %v: %v", p.desc(), err) 249 } 250 } 251 252 // setStage sets a short human-readable string that will be included in failure messages. 253 // This is useful for tests that iterate over multiple cases. 254 func (p *page) setStage(stage string) { 255 p.stage = stage 256 } 257 258 func (p *page) desc() string { 259 s := test.Caller() 260 if p.stage != "" { 261 s += " (" + p.stage + ")" 262 } 263 return s 264 } 265 266 // reload reloads the page. 267 func (p *page) reload() { 268 if err := p.wd.Refresh(); err != nil { 269 p.t.Fatalf("Reloading page at %v failed: %v", p.desc(), err) 270 } 271 p.configPage() 272 } 273 274 // refreshTags instructs the page to refresh the list of available tags from the server. 275 func (p *page) refreshTags() { 276 if _, err := p.wd.ExecuteScript("document.test.updateTags()", nil); err != nil { 277 p.t.Fatalf("Failed refreshing tags at %v: %v", p.desc(), err) 278 } 279 } 280 281 // getOrFail waits until getNoWait returns the first element matched by locs. 282 // If the element isn't found in a reasonable amount of time, it fails the test. 283 func (p *page) getOrFail(locs []loc) selenium.WebElement { 284 var el selenium.WebElement 285 if err := wait(func() error { 286 var err error 287 if el, err = p.getNoWait(locs); err != nil { 288 return err 289 } 290 return nil 291 }); err != nil { 292 p.t.Fatalf("Failed getting %v at %v: %v", locs, p.desc(), err) 293 } 294 return el 295 } 296 297 // getNoWait returns the first element matched by locs. 298 // 299 // If there is more than one element in locs, they will be used successively, e.g. 300 // loc[1] is used to search inside the element matched by loc[0]. 301 // 302 // If an element has a shadow root (per its 'shadowRoot' property), 303 // the shadow root will be used for the next search. 304 // 305 // If locs is empty, the document element is returned. 306 // 307 // This is based on Python code that initially used the 'return arguments[0].shadowRoot' approach 308 // described at https://stackoverflow.com/a/37253205/6882947, but that seems to have broken as a 309 // result of either upgrading to python-selenium 3.14.1 (from 3.8.0, I think) or upgrading to Chrome 310 // (and chromedriver) 96.0.4664.45 (from 94, I think). 311 // 312 // After upgrading, I would get back a dictionary like {u'shadow-6066-11e4-a52e-4f735466cecf': 313 // u'9ab4aaee-8108-45c2-9341-c232a9685355'} when evaluating shadowRoot. Trying to recursively call 314 // find_element() on it as before yielded "AttributeError: 'dict' object has no attribute 315 // 'find_element'". (For all I know, the version of Selenium that I was using was just too old for 316 // the current chromedriver, or this was a bug in python-selenium.) 317 // 318 // So, what we have instead here is an approximate JavaScript reimplementation of Selenium's 319 // element-finding code. :-/ It's possible that this could be switched back to using Selenium to 320 // find elements, but the current approach seems to work for now. 321 func (p *page) getNoWait(locs []loc) (selenium.WebElement, error) { 322 var query string 323 if len(locs) == 0 { 324 query = "document.documentElement" 325 } else { 326 for len(locs) > 0 { 327 if query != "" { 328 query = "expand(" + query + ")" 329 } else { 330 query = "document" 331 } 332 by, value := locs[0].by, locs[0].value 333 switch by { 334 case selenium.ByID: 335 query += ".getElementById('" + value + "')" 336 case selenium.ByTagName: 337 query += ".getElementsByTagName('" + value + "').item(0)" 338 case selenium.ByCSSSelector: 339 query += ".querySelector('" + value + "')" 340 default: 341 return nil, fmt.Errorf("invalid 'by' %q", by) 342 } 343 locs = locs[1:] 344 } 345 } 346 347 script := "const expand = e => e.shadowRoot || e; return " + query 348 res, err := p.wd.ExecuteScriptRaw(script, nil) 349 if err != nil { 350 if strings.Contains(err.Error(), "Cannot read properties of null (reading 'shadowRoot')") { 351 return nil, errors.New("not found") 352 } 353 return nil, err 354 } 355 return p.wd.DecodeElement(res) 356 } 357 358 // checkGone waits for the element described by locs to not be present in the document tree. 359 // It fails the test if the element remains present. 360 // Use checkDisplayed for elements that use e.g. display:none. 361 func (p *page) checkGone(locs []loc) { 362 if err := wait(func() error { 363 _, err := p.getNoWait(locs) 364 if err == nil { 365 return errors.New("still exists") 366 } 367 return nil 368 }); err != nil { 369 p.t.Fatalf("Failed waiting for element to be gone at %v: %v", p.desc(), err) 370 } 371 } 372 373 // click clicks on the element matched by locs. 374 func (p *page) click(locs []loc) { 375 if err := p.getOrFail(locs).Click(); err != nil { 376 p.t.Fatalf("Failed clicking %v at %v: %v", locs, p.desc(), err) 377 } 378 } 379 380 // clickOption clicks the <option> with the supplied text in the <select> matched by sel. 381 func (p *page) clickOption(sel []loc, option string) { 382 opts, err := p.getOrFail(sel).FindElements(selenium.ByTagName, "option") 383 if err != nil { 384 p.t.Fatalf("Failed getting %v options at %v: %v", sel, p.desc(), err) 385 } else if len(opts) == 0 { 386 p.t.Fatalf("No options for %v at %v: %v", sel, p.desc(), err) 387 } 388 names := make([]string, 0, len(opts)) 389 for _, opt := range opts { 390 name := strings.TrimSpace(p.getTextOrFail(opt, false)) 391 if name == option { 392 if err := opt.Click(); err != nil { 393 p.t.Fatalf("Failed clicking %v option %q at %v: %v", sel, option, p.desc(), err) 394 } 395 return 396 } 397 names = append(names, name) 398 } 399 p.t.Fatalf("Failed finding %v option %q among %q at %v", sel, option, names, p.desc()) 400 } 401 402 // getTextOrFail returns el's text, failing the test on error. 403 // If ignoreStale is true, errors caused by the element no longer existing are ignored. 404 // Tests should consider calling checkText instead. 405 func (p *page) getTextOrFail(el selenium.WebElement, ignoreStale bool) string { 406 text, err := el.Text() 407 if ignoreStale && isStaleElementError(err) { 408 return "" 409 } else if err != nil { 410 p.t.Fatalf("Failed getting element text at %v: %v", p.desc(), err) 411 } 412 return text 413 } 414 415 // getAttrOrFail returns the named attribute from el, failing the test on error. 416 // If ignoreStale is true, errors caused by the element no longer existing are ignored. 417 // Tests should consider calling checkAttr instead. 418 func (p *page) getAttrOrFail(el selenium.WebElement, name string, ignoreStale bool) string { 419 val, err := el.GetAttribute(name) 420 if isMissingAttrError(err) { 421 return "" 422 } else if ignoreStale && isStaleElementError(err) { 423 return "" 424 } else if err != nil { 425 p.t.Fatalf("Failed getting attribute %q at %v: %v", name, p.desc(), err) 426 } 427 return val 428 } 429 430 // getSelectedOrFail returns whether el is selected, failing the test on error. 431 // If ignoreStale is true, errors caused by the element no longer existing are ignored. 432 func (p *page) getSelectedOrFail(el selenium.WebElement, ignoreStale bool) bool { 433 sel, err := el.IsSelected() 434 if ignoreStale && isStaleElementError(err) { 435 return false 436 } else if err != nil { 437 p.t.Fatalf("Failed getting selected state at %v: %v", p.desc(), err) 438 } 439 return sel 440 } 441 442 // sendKeys sends text to the element matched by locs. 443 // 444 // Note that this doesn't work right on systems without a US Qwerty layout due to a ChromeDriver bug 445 // that will never be fixed: 446 // 447 // https://bugs.chromium.org/p/chromedriver/issues/detail?id=553 448 // https://chromedriver.chromium.org/help/keyboard-support 449 // https://github.com/SeleniumHQ/selenium/issues/4523 450 // 451 // Specifically, the requested text is sent to the element, but JavaScript key events contain 452 // incorrect values (e.g. when sending 'z' with Dvorak, the JS event will contain '/'). 453 func (p *page) sendKeys(locs []loc, text string, clearFirst bool) { 454 el := p.getOrFail(locs) 455 if clearFirst { 456 if err := el.Clear(); err != nil { 457 p.t.Fatalf("Failed clearing %v at %v: %v", locs, p.desc(), err) 458 } 459 } 460 if err := el.SendKeys(text); err != nil { 461 p.t.Fatalf("Failed sending keys to %v at %v: %v", locs, p.desc(), err) 462 } 463 } 464 465 // setText clears the element matched by locs and types text into it. 466 func (p *page) setText(locs []loc, text string) { 467 p.sendKeys(locs, text, true /* clearFirst */) 468 } 469 470 // emitKeyDown emits a 'keydown' JavaScript event with the supplied data. 471 // This avoids the ChromeDriver bug described in sendKeys. 472 func (p *page) emitKeyDown(key string, keyCode int, alt bool) { 473 s := fmt.Sprintf( 474 "document.body.dispatchEvent("+ 475 "new KeyboardEvent('keydown', { key: '%s', keyCode: %d, altKey: %v }))", 476 key, keyCode, alt) 477 if _, err := p.wd.ExecuteScript(s, nil); err != nil { 478 p.t.Fatalf("Failed emitting %q key down event at %v: %v", key, p.desc(), err) 479 } 480 } 481 482 // clickSongRowCheckbox clicks the checkbox for the song at 0-based index 483 // idx in the table matched by locs. If key (e.g. selenium.ShiftKey) is non-empty, 484 // it is held while performing the click. 485 func (p *page) clickSongRowCheckbox(locs []loc, idx int, key string) { 486 cb, err := p.getSongRow(locs, idx).FindElement(selenium.ByCSSSelector, "td:first-child input") 487 if err != nil { 488 p.t.Fatalf("Failed finding checkbox in song %d at %v: %v", idx, p.desc(), err) 489 } 490 if key != "" { 491 if err := p.wd.KeyDown(key); err != nil { 492 p.t.Fatalf("Failed pressing key before clicking checkbox %d at %v: %v", idx, p.desc(), err) 493 } 494 defer func() { 495 if err := p.wd.KeyUp(key); err != nil { 496 p.t.Fatalf("Failed releasing key after clicking checkbox %d at %v: %v", idx, p.desc(), err) 497 } 498 }() 499 } 500 if err := cb.Click(); err != nil { 501 p.t.Fatalf("Failed clicking checkbox %d at %v: %v", idx, p.desc(), err) 502 } 503 } 504 505 // clickSongRowArtist clicks the artist field on the song at the specified index in the table matched by locs. 506 func (p *page) clickSongRowArtist(locs []loc, idx int) { p.clickSongRowField(locs, idx, "artist") } 507 508 // clickSongRowAlbum clicks the album field on the song at the specified index in the table matched by locs. 509 func (p *page) clickSongRowAlbum(locs []loc, idx int) { p.clickSongRowField(locs, idx, "album") } 510 511 // clickSongRowField is a helper method for clickSongRowArtist and clickSongRowAlbum. 512 func (p *page) clickSongRowField(locs []loc, idx int, cls string) { 513 sel := "td." + cls 514 td, err := p.getSongRow(locs, idx).FindElement(selenium.ByCSSSelector, sel) 515 if err != nil { 516 p.t.Fatalf("Failed finding %q in song %d at %v: %v", sel, idx, p.desc(), err) 517 } 518 if err := td.Click(); err != nil { 519 p.t.Fatalf("Failed clicking %q in song %d at %v: %v", sel, idx, p.desc(), err) 520 } 521 } 522 523 // rightClickSongRow right-clicks on the song at the specified index in the table matched by locs. 524 func (p *page) rightClickSongRow(locs []loc, idx int) { 525 row := p.getSongRow(locs, idx) 526 // The documentation says "MoveTo moves the mouse to relative coordinates from center of 527 // element", but these coordinates seem to be relative to the element's upper-left corner. 528 if err := row.MoveTo(3, 3); err != nil { 529 p.t.Fatalf("Failed moving mouse to song at %v: %v", p.desc(), err) 530 } 531 if err := p.wd.Click(selenium.RightButton); err != nil { 532 p.t.Fatalf("Failed right-clicking on song at %v: %v", p.desc(), err) 533 } 534 } 535 536 // dragSongRow drags the song at srcIdx at table locs to dstIdx. 537 // dstOffsetY describes the Y offset from the center of dstIdx. 538 func (p *page) dragSongRow(locs []loc, srcIdx, dstIdx, dstOffsetY int) { 539 // I initially tried using MoveTo, ButtonDown, and ButtonUp to drag the row, but this seems to 540 // be broken in WebDriver: 541 // 542 // https://github.com/seleniumhq/selenium-google-code-issue-archive/issues/3604 543 // https://github.com/SeleniumHQ/selenium/issues/9878 544 // https://github.com/w3c/webdriver/issues/1488 545 // 546 // The dragstart event is emitted when I start the drag, but the page never receives dragenter, 547 // dragover, or dragend, and the button seems to remain depressed even after calling ButtonUp. 548 // Some commenters say they were able to get this to work by calling MoveTo twice, but it 549 // doesn't seem to change anything for me (regardless of whether I move relative to the source 550 // or destination row). 551 // 552 // It seems like the state of the art is to just emit fake drag events from JavaScript: 553 // 554 // https://gist.github.com/rcorreia/2362544 555 // https://stackoverflow.com/questions/61448931/selenium-drag-and-drop-issue-in-chrome 556 // 557 // Sigh. 558 src := p.getSongRow(locs, srcIdx) 559 dst := p.getSongRow(locs, dstIdx) 560 if _, err := p.wd.ExecuteScript( 561 "document.test.dragElement(arguments[0], arguments[1], 0, arguments[2])", 562 []interface{}{src, dst, dstOffsetY}); err != nil { 563 p.t.Fatalf("Failed dragging song %v to %v at %v: %v", srcIdx, dstIdx, p.desc(), err) 564 } 565 } 566 567 // getSongRow returns the row for song at the 0-based specified index in the table matched by locs. 568 func (p *page) getSongRow(locs []loc, idx int) selenium.WebElement { 569 table := p.getOrFail(locs) 570 sel := fmt.Sprintf("tbody tr:nth-child(%d)", idx+1) 571 row, err := table.FindElement(selenium.ByCSSSelector, sel) 572 if err != nil { 573 p.t.Fatalf("Failed finding song %d (%q) at %v: %v", idx, sel, p.desc(), err) 574 } 575 return row 576 } 577 578 type checkboxState uint32 579 580 const ( 581 checkboxChecked checkboxState = 1 << iota 582 checkboxTransparent // has "transparent" class 583 ) 584 585 // checkText checks that text of the element matched by locs equals want. 586 func (p *page) checkText(locs []loc, want string) { 587 p.checkTextRegexp(locs, regexp.QuoteMeta(want)) 588 } 589 590 // checkTextRegexp checks that text of the element matched by locs is matched by wantRegexp. 591 // Spacing can be weird if the text is spread across multiple child nodes. 592 func (p *page) checkTextRegexp(locs []loc, wantRegexp string) { 593 el := p.getOrFail(locs) 594 want := regexp.MustCompile(wantRegexp) 595 if err := wait(func() error { 596 if got := p.getTextOrFail(el, false); !want.MatchString(got) { 597 return fmt.Errorf("got %q; want %q (regexp)", got, want) 598 } 599 return nil 600 }); err != nil { 601 p.t.Fatalf("Bad text in element at %v: %v", p.desc(), err) 602 } 603 } 604 605 // checkAttr checks that attribute attr of the element matched by locs equals want. 606 func (p *page) checkAttr(locs []loc, attr, want string) { 607 el := p.getOrFail(locs) 608 if err := wait(func() error { 609 if got := p.getAttrOrFail(el, attr, false); got != want { 610 return fmt.Errorf("got %q; want %q", got, want) 611 } 612 return nil 613 }); err != nil { 614 p.t.Fatalf("Bad %q attribute at %v: %v", attr, p.desc(), err) 615 } 616 } 617 618 // checkDisplayed checks that the element matched by locs is or isn't displayed. 619 // Note that the element must be present in the document tree. 620 func (p *page) checkDisplayed(locs []loc, want bool) { 621 el := p.getOrFail(locs) 622 if err := wait(func() error { 623 if got, err := el.IsDisplayed(); err != nil { 624 p.t.Fatalf("Failed getting displayed state at %v: %v", p.desc(), err) 625 } else if got != want { 626 return fmt.Errorf("got %v; want %v", got, want) 627 } 628 return nil 629 }); err != nil { 630 p.t.Fatalf("Bad displayed state at %v: %v", p.desc(), err) 631 } 632 } 633 634 // checkCheckbox verifies that the checkbox element matched by locs has the specified state. 635 // TODO: The "check" in this name is ambiguous. 636 func (p *page) checkCheckbox(locs []loc, state checkboxState) { 637 el := p.getOrFail(locs) 638 if got, want := p.getSelectedOrFail(el, false), state&checkboxChecked != 0; got != want { 639 p.t.Fatalf("Checkbox %v has checked state %v at %v; want %v", locs, got, p.desc(), want) 640 } 641 class := p.getAttrOrFail(el, "class", false) 642 if got, want := strings.Contains(class, "transparent"), state&checkboxTransparent != 0; got != want { 643 p.t.Fatalf("Checkbox %v has transparent state %v at %v; want %v", locs, got, p.desc(), want) 644 } 645 } 646 647 // getSongsFromTable returns songInfos describing the supplied <table> within a <song-table>. 648 func (p *page) getSongsFromTable(table selenium.WebElement) []songInfo { 649 var songs []songInfo 650 rows, err := table.FindElements(selenium.ByTagName, "tr") 651 if err != nil { 652 p.t.Fatalf("Failed getting song rows at %v: %v", p.desc(), err) 653 } 654 if len(rows) == 0 { 655 return nil 656 } 657 for _, row := range rows[1:] { // skip header 658 cols, err := row.FindElements(selenium.ByTagName, "td") 659 if isStaleElementError(err) { 660 break // table was modified while we were reading it 661 } else if err != nil { 662 p.t.Fatalf("Failed getting song columns at %v: %v", p.desc(), err) 663 } 664 // Final column is time; first column may be checkbox. 665 song := songInfo{ 666 artist: p.getTextOrFail(cols[len(cols)-4], true), 667 title: p.getTextOrFail(cols[len(cols)-3], true), 668 album: p.getTextOrFail(cols[len(cols)-2], true), 669 } 670 671 // TODO: Copy time from last column. 672 class := p.getAttrOrFail(row, "class", true) 673 active := strings.Contains(class, "active") 674 song.active = &active 675 menu := strings.Contains(class, "menu") 676 song.menu = &menu 677 678 if len(cols) == 5 { 679 el, err := cols[0].FindElement(selenium.ByTagName, "input") 680 if err == nil { 681 checked := p.getSelectedOrFail(el, true) 682 song.checked = &checked 683 } else if !isStaleElementError(err) { 684 p.t.Fatalf("Failed getting checkbox at %v: %v", p.desc(), err) 685 } 686 } 687 songs = append(songs, song) 688 } 689 return songs 690 } 691 692 // checkSearchResults waits for the search results table to contain songs. 693 func (p *page) checkSearchResults(songs []db.Song, checks ...songListCheck) { 694 want := make([]songInfo, len(songs)) 695 for i := range songs { 696 want[i] = makeSongInfo(songs[i]) 697 } 698 for _, c := range checks { 699 c(want) 700 } 701 702 table := p.getOrFail(searchResultsTable) 703 if err := wait(func() error { 704 got := p.getSongsFromTable(table) 705 if !songInfoSlicesEqual(want, got) { 706 return errors.New("songs don't match") 707 } 708 return nil 709 }); err != nil { 710 got := p.getSongsFromTable(table) 711 msg := fmt.Sprintf("Bad search results at %v: %v\n", p.desc(), err.Error()) 712 msg += "Want:\n" 713 for _, s := range want { 714 msg += " " + s.String() + "\n" 715 } 716 msg += "Got:\n" 717 for _, s := range got { 718 msg += " " + s.String() + "\n" 719 } 720 p.t.Fatal(msg) 721 } 722 } 723 724 // checkPlaylist waits for the playlist table to contain songs. 725 func (p *page) checkPlaylist(songs []db.Song, checks ...songListCheck) { 726 want := make([]songInfo, len(songs)) 727 for i := range songs { 728 want[i] = makeSongInfo(songs[i]) 729 } 730 for _, c := range checks { 731 c(want) 732 } 733 734 table := p.getOrFail(playlistTable) 735 if err := wait(func() error { 736 got := p.getSongsFromTable(table) 737 if !songInfoSlicesEqual(want, got) { 738 return errors.New("songs don't match") 739 } 740 return nil 741 }); err != nil { 742 got := p.getSongsFromTable(table) 743 msg := fmt.Sprintf("Bad playlist at %v\n", p.desc()) 744 msg += "Want:\n" 745 for _, s := range want { 746 msg += " " + s.String() + "\n" 747 } 748 msg += "Got:\n" 749 for _, s := range got { 750 msg += " " + s.String() + "\n" 751 } 752 p.t.Fatal(msg) 753 } 754 } 755 756 // checkFullscreenOverlay waits for fullscreen-overlay to display the specified songs. 757 func (p *page) checkFullscreenOverlay(cur, next *db.Song) { 758 var curWant, nextWant *songInfo 759 if cur != nil { 760 s := makeSongInfo(*cur) 761 curWant = &s 762 } 763 if next != nil { 764 s := makeSongInfo(*next) 765 nextWant = &s 766 } 767 768 getSongs := func() (cur, next *songInfo) { 769 if d, err := p.getOrFail(currentArtistDiv).IsDisplayed(); err != nil { 770 p.t.Fatalf("Failed checking visibility of current artist at %v: %v", p.desc(), err) 771 } else if d { 772 cur = &songInfo{ 773 artist: p.getTextOrFail(p.getOrFail(currentArtistDiv), false), 774 title: p.getTextOrFail(p.getOrFail(currentTitleDiv), false), 775 album: p.getTextOrFail(p.getOrFail(currentAlbumDiv), false), 776 } 777 } 778 if d, err := p.getOrFail(nextArtistDiv).IsDisplayed(); err != nil { 779 p.t.Fatalf("Failed checking visibility of next artist at %v: %v", p.desc(), err) 780 } else if d { 781 next = &songInfo{ 782 artist: p.getTextOrFail(p.getOrFail(nextArtistDiv), false), 783 title: p.getTextOrFail(p.getOrFail(nextTitleDiv), false), 784 album: p.getTextOrFail(p.getOrFail(nextAlbumDiv), false), 785 } 786 } 787 return cur, next 788 } 789 equal := func(want, got *songInfo) bool { 790 if (want == nil) != (got == nil) { 791 return false 792 } 793 if want == nil { 794 return true 795 } 796 return songInfosEqual(*want, *got) 797 } 798 if err := wait(func() error { 799 curGot, nextGot := getSongs() 800 if !equal(curWant, curGot) || !equal(nextWant, nextGot) { 801 return errors.New("songs don't match") 802 } 803 return nil 804 }); err != nil { 805 curGot, nextGot := getSongs() 806 msg := fmt.Sprintf("Bad fullscreen-overlay songs at %v\n", p.desc()) 807 msg += "Want:\n" 808 msg += " " + curWant.String() + "\n" 809 msg += " " + nextWant.String() + "\n" 810 msg += "Got:\n" 811 msg += " " + curGot.String() + "\n" 812 msg += " " + nextGot.String() + "\n" 813 p.t.Fatal(msg) 814 } 815 } 816 817 // checkSong verifies that the current song matches s. 818 // By default, just the artist, title, and album are examined, 819 // but additional checks can be specified. 820 func (p *page) checkSong(s db.Song, checks ...songCheck) { 821 want := makeSongInfo(s) 822 for _, c := range checks { 823 c(&want) 824 } 825 826 var got songInfo 827 if err := waitFull(func() error { 828 imgTitle := p.getAttrOrFail(p.getOrFail(coverImage), "title", false) 829 time := p.getTextOrFail(p.getOrFail(timeDiv), false) 830 au := p.getOrFail(audio) 831 paused := p.getAttrOrFail(au, "paused", true) != "" 832 ended := p.getAttrOrFail(au, "ended", true) != "" 833 834 // Count the rating overlay's children to find the displayed rating. 835 var rating int 836 var err error 837 stars := p.getAttrOrFail(p.getOrFail(ratingOverlayDiv), "childElementCount", false) 838 if rating, err = strconv.Atoi(stars); err != nil { 839 return fmt.Errorf("stars: %v", err) 840 } 841 842 var filename string 843 src := p.getAttrOrFail(au, "src", true) 844 if u, err := url.Parse(src); err == nil { 845 filename = u.Query().Get("filename") 846 } 847 848 got = songInfo{ 849 artist: p.getTextOrFail(p.getOrFail(artistDiv), false), 850 title: p.getTextOrFail(p.getOrFail(titleDiv), false), 851 album: p.getTextOrFail(p.getOrFail(albumDiv), false), 852 paused: &paused, 853 ended: &ended, 854 filename: &filename, 855 rating: &rating, 856 imgTitle: &imgTitle, 857 timeStr: &time, 858 } 859 if !songInfosEqual(want, got) { 860 return errors.New("songs don't match") 861 } 862 return nil 863 }, want.getTimeout(waitTimeout), waitSleep); err != nil { 864 msg := fmt.Sprintf("Bad song at %v: %v\n", p.desc(), err) 865 msg += "Want: " + want.String() + "\n" 866 msg += "Got: " + got.String() 867 p.t.Fatal(msg) 868 } 869 } 870 871 // Describes a bar within a chart in the stats dialog. 872 type statsChartBar struct { 873 pct int // rounded within [0, 100] 874 title string 875 } 876 877 // Matches e.g. "55.3" from the stats bar style attribute "opacity: 0.55; width: 55.3%". 878 var statsPctRegexp = regexp.MustCompile(`width:\s*(?:calc\()?([^%]+)%`) 879 880 // checkStatsChart verifies that the stats dialog chart at locs contains want. 881 func (p *page) checkStatsChart(locs []loc, want []statsChartBar) { 882 chart := p.getOrFail(locs) 883 if err := wait(func() error { 884 els, err := chart.FindElements(selenium.ByTagName, "span") 885 if err != nil { 886 return err 887 } 888 got := make([]statsChartBar, len(els)) 889 for i, el := range els { 890 var bar statsChartBar 891 bar.title, _ = el.GetAttribute("title") 892 style, _ := el.GetAttribute("style") 893 if ms := statsPctRegexp.FindStringSubmatch(style); ms != nil { 894 val, _ := strconv.ParseFloat(ms[1], 64) 895 bar.pct = int(math.Round(val)) 896 } 897 got[i] = bar 898 } 899 if !reflect.DeepEqual(got, want) { 900 return fmt.Errorf("got %v; want %v", got, want) 901 } 902 return nil 903 }); err != nil { 904 p.t.Fatalf("Bad %v chart at %v: %v", locs, p.desc(), err) 905 } 906 }