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  }