github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/test/web/web_test.go (about)

     1  // Copyright 2021 Daniel Erat.
     2  // All rights reserved.
     3  
     4  // Package web contains Selenium-based tests of the web interface.
     5  package web
     6  
     7  import (
     8  	"encoding/json"
     9  	"flag"
    10  	"fmt"
    11  	"io"
    12  	"io/ioutil"
    13  	"log"
    14  	"net/http"
    15  	"net/http/httptest"
    16  	"os"
    17  	"path/filepath"
    18  	"reflect"
    19  	"regexp"
    20  	"strconv"
    21  	"strings"
    22  	"testing"
    23  	"time"
    24  
    25  	"github.com/derat/nup/server/config"
    26  	"github.com/derat/nup/server/db"
    27  	"github.com/derat/nup/server/esbuild"
    28  	"github.com/derat/nup/test"
    29  
    30  	"github.com/evanw/esbuild/pkg/api"
    31  
    32  	"github.com/tebeka/selenium"
    33  	"github.com/tebeka/selenium/chrome"
    34  	slog "github.com/tebeka/selenium/log"
    35  
    36  	"golang.org/x/sys/unix"
    37  )
    38  
    39  var (
    40  	// Globals shared across all tests.
    41  	webDrv         selenium.WebDriver // talks to browser using ChromeDriver
    42  	appURL         string             // slash-terminated URL of App Engine server (if running app)
    43  	tester         *test.Tester       // interacts with App Engine server (if running app)
    44  	browserLog     io.Writer          // receives log messages from browser
    45  	unitTestRegexp string             // regexp matching unit tests to run
    46  
    47  	// Pull some stuff into our namespace for convenience.
    48  	file0s  = test.Song0s.Filename
    49  	file1s  = test.Song1s.Filename
    50  	file5s  = test.Song5s.Filename
    51  	file10s = test.Song10s.Filename
    52  )
    53  
    54  func TestMain(m *testing.M) {
    55  	// Do everything in a function so that deferred calls run on failure.
    56  	code, err := runTests(m)
    57  	if err != nil {
    58  		log.Print("Failed running tests: ", err)
    59  	}
    60  	os.Exit(code)
    61  }
    62  
    63  func runTests(m *testing.M) (res int, err error) {
    64  	browserStderr := flag.Bool("browser-stderr", false, "Write browser log to stderr (default is -out-dir)")
    65  	chromedriverPath := flag.String("chromedriver", "chromedriver", "Chromedriver executable ($PATH searched by default)")
    66  	debugSelenium := flag.Bool("debug-selenium", false, "Write Selenium debug logs to stderr")
    67  	headless := flag.Bool("headless", true, "Run Chrome headlessly using Xvfb")
    68  	minify := flag.Bool("minify", true, "Minify HTML, JavaScript, and CSS")
    69  	flag.StringVar(&unitTestRegexp, "unit-test-regexp", "", "Regexp matching unit tests to run (all other tests skipped)")
    70  	flag.Parse()
    71  
    72  	test.HandleSignals([]os.Signal{unix.SIGINT, unix.SIGTERM}, nil)
    73  
    74  	// TODO: Find a better way to do this. There doesn't seem to be any way to use testing.M to
    75  	// determine which tests are being run (probably by design), so we use -unit-test-regexp to
    76  	// determine that we don't need to start the app for other tests. This is way faster when just
    77  	// running unit tests.
    78  	runApp := unitTestRegexp == ""
    79  
    80  	outDir, keepOutDir, err := test.OutputDir("web_test")
    81  	if err != nil {
    82  		return -1, err
    83  	}
    84  	defer func() {
    85  		// Also delete the dir if the browser logs are going to stderr and we're not running the
    86  		// app, as everything interesting should be in the browser log in that case.
    87  		if (res == 0 || (*browserStderr && !runApp)) && !keepOutDir {
    88  			log.Print("Removing ", outDir)
    89  			os.RemoveAll(outDir)
    90  		}
    91  	}()
    92  	log.Print("Writing files to ", outDir)
    93  
    94  	var musicDir string
    95  	if runApp {
    96  		// Serve music files in the background.
    97  		musicDir = filepath.Join(outDir, "music")
    98  		if err := os.MkdirAll(musicDir, 0755); err != nil {
    99  			return -1, err
   100  		}
   101  		defer os.RemoveAll(musicDir)
   102  		if err := test.CopySongs(musicDir, file0s, file1s, file5s, file10s); err != nil {
   103  			return -1, fmt.Errorf("copying songs: %v", err)
   104  		}
   105  		musicSrv := test.ServeFiles(musicDir)
   106  		defer musicSrv.Close()
   107  
   108  		appLog, err := os.Create(filepath.Join(outDir, "app.log"))
   109  		if err != nil {
   110  			return -1, err
   111  		}
   112  		defer appLog.Close()
   113  
   114  		cfg := &config.Config{
   115  			Users: []config.User{
   116  				{Email: testEmail},
   117  				{Username: test.Username, Password: test.Password, Admin: true},
   118  			},
   119  			SongBaseURL:  musicSrv.URL + "/",
   120  			CoverBaseURL: musicSrv.URL + "/.covers/", // bogus but required
   121  			Presets:      presets,
   122  			Minify:       minify,
   123  		}
   124  		storageDir := filepath.Join(outDir, "app_storage")
   125  		appSrv, err := test.NewDevAppserver(cfg, storageDir, appLog)
   126  		if err != nil {
   127  			return -1, fmt.Errorf("dev_appserver: %v", err)
   128  		}
   129  		defer os.RemoveAll(storageDir)
   130  		defer appSrv.Close()
   131  		appURL = appSrv.URL()
   132  		log.Print("dev_appserver is listening at ", appURL)
   133  	}
   134  
   135  	opts := []selenium.ServiceOption{}
   136  	if *debugSelenium {
   137  		selenium.SetDebug(true)
   138  		opts = append(opts, selenium.Output(os.Stderr))
   139  	}
   140  	if *headless {
   141  		opts = append(opts, selenium.StartFrameBuffer())
   142  	}
   143  
   144  	ports, err := test.FindUnusedPorts(1)
   145  	if err != nil {
   146  		return -1, fmt.Errorf("finding ports: %v", err)
   147  	}
   148  	chromeDrvPort := ports[0]
   149  	svc, err := selenium.NewChromeDriverService(*chromedriverPath, chromeDrvPort, opts...)
   150  	if err != nil {
   151  		return -1, fmt.Errorf("ChromeDriver: %v", err)
   152  	}
   153  	defer svc.Stop()
   154  
   155  	chromeArgs := []string{"--autoplay-policy=no-user-gesture-required"}
   156  	if test.CloudBuild() {
   157  		chromeArgs = append(chromeArgs,
   158  			"--no-sandbox",            // actually get Chrome to run
   159  			"--disable-dev-shm-usage", // prevent random crashes: https://stackoverflow.com/a/53970825/6882947
   160  		)
   161  	}
   162  	caps := selenium.Capabilities{}
   163  	caps.AddChrome(chrome.Capabilities{Args: chromeArgs})
   164  	caps.SetLogLevel(slog.Browser, slog.All)
   165  	webDrv, err = selenium.NewRemote(caps, fmt.Sprintf("http://localhost:%d/wd/hub", chromeDrvPort))
   166  	if err != nil {
   167  		return -1, fmt.Errorf("Selenium: %v", err)
   168  	}
   169  	defer webDrv.Quit()
   170  
   171  	if *browserStderr {
   172  		browserLog = os.Stderr
   173  	} else {
   174  		// Create a file containing messages logged by the web interface.
   175  		f, err := os.Create(filepath.Join(outDir, "browser.log"))
   176  		if err != nil {
   177  			return -1, err
   178  		}
   179  		defer f.Close()
   180  		browserLog = f
   181  	}
   182  	defer copyBrowserLogs()
   183  
   184  	if runApp {
   185  		writeLogHeader("Running web tests against " + appURL)
   186  		testerDir := filepath.Join(outDir, "tester")
   187  		tester = test.NewTester(nil, appURL, testerDir, test.TesterConfig{MusicDir: musicDir})
   188  		defer os.RemoveAll(testerDir)
   189  	}
   190  
   191  	res = m.Run()
   192  	return res, nil
   193  }
   194  
   195  // writeLogHeader writes s and a line of dashes to browserLog.
   196  func writeLogHeader(s string) {
   197  	fmt.Fprintf(browserLog, "%s\n%s\n", s, strings.Repeat("-", 80))
   198  }
   199  
   200  // Log messages usually look like this:
   201  //  http://localhost:8080/search-view.js 478:18 "Got response with 1 song(s)"
   202  // This regexp matches the filename, line number, and message.
   203  var logRegexp = regexp.MustCompile(`(?s)^https?://[^ ]+/([^ /]+\.[jt]s) (\d+):\d+ (.*)$`)
   204  
   205  // copyBrowserLogs gets new log messages from the browser and writes them to browserLog.
   206  func copyBrowserLogs() {
   207  	msgs, err := webDrv.Log(slog.Browser)
   208  	if err != nil {
   209  		fmt.Fprintf(browserLog, "Failed getting browser logs: %v\n", err)
   210  		return
   211  	}
   212  	for _, msg := range msgs {
   213  		// Try to make logs more readable by dropping the server URL from the
   214  		// beginning of the filename and lining up the actual messages.
   215  		text := msg.Message
   216  		if ms := logRegexp.FindStringSubmatch(text); ms != nil {
   217  			if u, err := strconv.Unquote(ms[3]); err == nil {
   218  				ms[3] = u
   219  			}
   220  			text = fmt.Sprintf("%-24s %s", ms[1]+":"+ms[2], ms[3])
   221  		}
   222  		ts := msg.Timestamp.Format("15:04:05.000")
   223  		fmt.Fprintf(browserLog, "%s %-7s %s\n", ts, msg.Level, text)
   224  	}
   225  }
   226  
   227  // initWebTest should be called at the beginning of each test.
   228  // The returned page object is used to interact with the web interface via Selenium,
   229  // and the returned server object is used to interact with the server.
   230  // The test should defer a call to the returned done function.
   231  func initWebTest(t *testing.T) (p *page, s *server, done func()) {
   232  	if tester != nil && tester.T != nil {
   233  		t.Fatalf("%v didn't call done", tester.T.Name())
   234  	}
   235  
   236  	// Huge hack: skip the test if we're only running unit tests.
   237  	if unitTestRegexp != "" && t.Name() != "TestUnit" {
   238  		t.SkipNow() // calls runtime.Goexit
   239  	}
   240  
   241  	// Copy any browser logs from the previous test and write a header.
   242  	copyBrowserLogs()
   243  	io.WriteString(browserLog, "\n")
   244  	writeLogHeader(t.Name())
   245  
   246  	// Bail out if we don't need the app.
   247  	if appURL == "" {
   248  		return nil, nil, func() {}
   249  	}
   250  
   251  	tester.T = t
   252  	tester.PingServer()
   253  	tester.ClearData()
   254  	tester.ForceUpdateFailures(false)
   255  	return newPage(t, webDrv, appURL), &server{t, tester}, func() { tester.T = nil }
   256  }
   257  
   258  // MaxPlays needs to be explicitly set to -1 here since these structs are
   259  // declared literally rather than being unmarshaled from JSON by config.Parse
   260  // (which assigns a default of -1 if the field isn't specified).
   261  var presets = []config.SearchPreset{
   262  	{
   263  		Name:       "instrumental old",
   264  		Tags:       "instrumental",
   265  		MinRating:  4,
   266  		LastPlayed: 6,
   267  		MaxPlays:   -1,
   268  		Shuffle:    true,
   269  		Play:       true,
   270  	},
   271  	{
   272  		Name:      "mellow",
   273  		Tags:      "mellow",
   274  		MinRating: 4,
   275  		MaxPlays:  -1,
   276  		Shuffle:   true,
   277  		Play:      true,
   278  	},
   279  	{
   280  		Name:      "played once",
   281  		MinRating: 4,
   282  		MaxPlays:  1,
   283  		Shuffle:   true,
   284  		Play:      true,
   285  	},
   286  	{
   287  		Name:        "new albums",
   288  		FirstPlayed: 3,
   289  		MaxPlays:    -1,
   290  		FirstTrack:  true,
   291  	},
   292  	{
   293  		Name:     "unrated",
   294  		Unrated:  true,
   295  		MaxPlays: -1,
   296  		Play:     true,
   297  	},
   298  }
   299  
   300  // importSongs posts the supplied db.Song or []db.Song args to the server.
   301  func importSongs(songs ...interface{}) {
   302  	tester.PostSongs(joinSongs(songs...), true, 0)
   303  }
   304  
   305  func TestKeywordQuery(t *testing.T) {
   306  	page, _, done := initWebTest(t)
   307  	defer done()
   308  	album1 := joinSongs(
   309  		newSong("ar1", "ti1", "al1", withTrack(1)),
   310  		newSong("ar1", "ti2", "al1", withTrack(2)),
   311  		newSong("ar1", "ti3", "al1", withTrack(3)),
   312  	)
   313  	album2 := joinSongs(
   314  		newSong("ar2", "ti1", "al2", withTrack(1)),
   315  		newSong("ar2", "ti2", "al2", withTrack(2)),
   316  	)
   317  	album3 := joinSongs(
   318  		newSong("artist with space", "ti1", "al3", withTrack(1)),
   319  	)
   320  	importSongs(album1, album2, album3)
   321  
   322  	for _, tc := range []struct {
   323  		kw   string
   324  		want []db.Song
   325  	}{
   326  		{"album:al1", album1},
   327  		{"album:al2", album2},
   328  		{"artist:ar1", album1},
   329  		{"artist:\"artist with space\"", album3},
   330  		{"ti2", joinSongs(album1[1], album2[1])},
   331  		{"AR2 ti1", joinSongs(album2[0])},
   332  		{"ar1 bogus", nil},
   333  	} {
   334  		page.setStage(tc.kw)
   335  		page.setText(keywordsInput, tc.kw)
   336  		page.click(searchButton)
   337  		page.checkSearchResults(tc.want)
   338  	}
   339  }
   340  
   341  func TestTagQuery(t *testing.T) {
   342  	page, _, done := initWebTest(t)
   343  	defer done()
   344  	song1 := newSong("ar1", "ti1", "al1", withTags("electronic", "instrumental"))
   345  	song2 := newSong("ar2", "ti2", "al2", withTags("rock", "guitar"))
   346  	song3 := newSong("ar3", "ti3", "al3", withTags("instrumental", "rock"))
   347  	importSongs(song1, song2, song3)
   348  
   349  	for _, tc := range []struct {
   350  		tags string
   351  		want []db.Song
   352  	}{
   353  		{"electronic", joinSongs(song1)},
   354  		{"guitar rock", joinSongs(song2)},
   355  		{"instrumental", joinSongs(song1, song3)},
   356  		{"instrumental -electronic", joinSongs(song3)},
   357  	} {
   358  		page.setStage(tc.tags)
   359  		page.setText(tagsInput, tc.tags)
   360  		page.click(searchButton)
   361  		page.checkSearchResults(tc.want)
   362  	}
   363  }
   364  
   365  func TestDateQuery(t *testing.T) {
   366  	page, _, done := initWebTest(t)
   367  	defer done()
   368  	song1 := newSong("a", "1985-01-01", "al", withTrack(1), withDate(test.Date(1985, 1, 1)))
   369  	song2 := newSong("a", "1991-12-31", "al", withTrack(2), withDate(test.Date(1991, 12, 31)))
   370  	song3 := newSong("a", "2005-07-08", "al", withTrack(3), withDate(test.Date(2005, 7, 8)))
   371  	song4 := newSong("a", "unset", "al", withTrack(4))
   372  	importSongs(joinSongs(song1, song2, song3, song4))
   373  
   374  	for _, tc := range []struct {
   375  		min, max string
   376  		want     []db.Song
   377  	}{
   378  		{"1970", "1979", joinSongs()},
   379  		{"2010", "2019", joinSongs()},
   380  		{"1988", "1989", joinSongs()},
   381  		{"1980", "1989", joinSongs(song1)},
   382  		{"1985", "1985", joinSongs(song1)},
   383  		{"1991", "1991", joinSongs(song2)},
   384  		{"2005-07-07", "2005-07-09", joinSongs(song3)},
   385  		{"2005-07-09", "2005-07-10", joinSongs()},
   386  		{"1985", "1991", joinSongs(song1, song2)},
   387  		{"1985", "2005", joinSongs(song1, song2, song3)},
   388  		{"1990", "", joinSongs(song2, song3)},
   389  		{"", "2000", joinSongs(song1, song2)},
   390  	} {
   391  		page.setStage(tc.min + "/" + tc.max)
   392  		// TODO: I saw this fail once:
   393  		//  page.go:451: Failed sending keys to [{tag name search-view} {id min-date-input}] at
   394  		//  web_test.go:389 (1980/1989): unknown error - 60: element not interactable
   395  		// That's in the middle of the test cases, so I have no idea what's going on.
   396  		page.setText(minDateInput, tc.min)
   397  		page.setText(maxDateInput, tc.max)
   398  		page.click(searchButton)
   399  		page.checkSearchResults(tc.want)
   400  	}
   401  }
   402  
   403  func TestRatingQuery(t *testing.T) {
   404  	page, _, done := initWebTest(t)
   405  	defer done()
   406  	song1 := newSong("a", "t", "al1", withRating(1))
   407  	song2 := newSong("a", "t", "al2", withRating(2))
   408  	song3 := newSong("a", "t", "al3", withRating(3))
   409  	song4 := newSong("a", "t", "al4", withRating(4))
   410  	song5 := newSong("a", "t", "al5", withRating(5))
   411  	song6 := newSong("a", "t", "al6")
   412  	allSongs := joinSongs(song1, song2, song3, song4, song5, song6)
   413  	importSongs(allSongs)
   414  
   415  	page.setStage("unset")
   416  	page.click(searchButton)
   417  	page.checkSearchResults(allSongs)
   418  
   419  	page.click(resetButton)
   420  	for _, tc := range []struct {
   421  		op, stars string
   422  		want      []db.Song
   423  	}{
   424  		{atLeast, oneStar, joinSongs(song1, song2, song3, song4, song5)},
   425  		{atLeast, twoStars, joinSongs(song2, song3, song4, song5)},
   426  		{atLeast, threeStars, joinSongs(song3, song4, song5)},
   427  		{atLeast, fourStars, joinSongs(song4, song5)},
   428  		{atLeast, fiveStars, joinSongs(song5)},
   429  		{atMost, oneStar, joinSongs(song1)},
   430  		{atMost, twoStars, joinSongs(song1, song2)},
   431  		{atMost, threeStars, joinSongs(song1, song2, song3)},
   432  		{atMost, fourStars, joinSongs(song1, song2, song3, song4)},
   433  		{atMost, fiveStars, joinSongs(song1, song2, song3, song4, song5)},
   434  		{exactly, oneStar, joinSongs(song1)},
   435  		{exactly, twoStars, joinSongs(song2)},
   436  		{exactly, threeStars, joinSongs(song3)},
   437  		{exactly, fourStars, joinSongs(song4)},
   438  		{exactly, fiveStars, joinSongs(song5)},
   439  	} {
   440  		page.setStage(fmt.Sprintf("%s %s", tc.op, tc.stars))
   441  		page.clickOption(ratingOpSelect, tc.op)
   442  		page.clickOption(ratingStarsSelect, tc.stars)
   443  		page.click(searchButton)
   444  		page.checkSearchResults(tc.want)
   445  	}
   446  
   447  	page.setStage("unrated")
   448  	page.click(resetButton)
   449  	page.click(unratedCheckbox)
   450  	page.click(searchButton)
   451  	page.checkSearchResults(joinSongs(song6))
   452  }
   453  
   454  func TestFirstTrackQuery(t *testing.T) {
   455  	page, _, done := initWebTest(t)
   456  	defer done()
   457  	album1 := joinSongs(
   458  		newSong("ar1", "ti1", "al1", withTrack(1), withDisc(1)),
   459  		newSong("ar1", "ti2", "al1", withTrack(2), withDisc(1)),
   460  		newSong("ar1", "ti3", "al1", withTrack(3), withDisc(1)),
   461  	)
   462  	album2 := joinSongs(
   463  		newSong("ar2", "ti1", "al2", withTrack(1), withDisc(1)),
   464  		newSong("ar2", "ti2", "al2", withTrack(2), withDisc(1)),
   465  	)
   466  	importSongs(album1, album2)
   467  
   468  	page.click(firstTrackCheckbox)
   469  	page.click(searchButton)
   470  	page.checkSearchResults(joinSongs(album1[0], album2[0]))
   471  }
   472  
   473  func TestOrderByLastPlayedQuery(t *testing.T) {
   474  	page, _, done := initWebTest(t)
   475  	defer done()
   476  	t1 := test.Date(2020, 4, 1)
   477  	t2 := t1.Add(1 * time.Second)
   478  	t3 := t1.Add(2 * time.Second)
   479  	song1 := newSong("ar1", "ti1", "al1", withPlays(t2, t3))
   480  	song2 := newSong("ar2", "ti2", "al2", withPlays(t1))
   481  	song3 := newSong("ar3", "ti3", "al3", withPlays(t1, t2))
   482  	importSongs(song1, song2, song3)
   483  
   484  	page.click(orderByLastPlayedCheckbox)
   485  	page.click(searchButton)
   486  	page.checkSearchResults(joinSongs(song2, song3, song1))
   487  }
   488  
   489  func TestMaxPlaysQuery(t *testing.T) {
   490  	page, _, done := initWebTest(t)
   491  	defer done()
   492  	t1 := test.Date(2020, 4, 1)
   493  	t2 := t1.Add(1 * time.Second)
   494  	t3 := t1.Add(2 * time.Second)
   495  	song1 := newSong("ar1", "ti1", "al1", withPlays(t1, t2))
   496  	song2 := newSong("ar2", "ti2", "al2", withPlays(t1, t2, t3))
   497  	song3 := newSong("ar3", "ti3", "al3")
   498  	importSongs(song1, song2, song3)
   499  
   500  	for _, tc := range []struct {
   501  		plays string
   502  		want  []db.Song
   503  	}{
   504  		{"2", joinSongs(song1, song3)},
   505  		{"3", joinSongs(song1, song2, song3)},
   506  		{"0", joinSongs(song3)},
   507  	} {
   508  		page.setStage(tc.plays)
   509  		page.setText(maxPlaysInput, tc.plays)
   510  		page.click(searchButton)
   511  		page.checkSearchResults(tc.want)
   512  	}
   513  }
   514  
   515  func TestPlayTimeQuery(t *testing.T) {
   516  	page, _, done := initWebTest(t)
   517  	defer done()
   518  	now := time.Now()
   519  	song1 := newSong("ar1", "ti1", "al1", withPlays(now.Add(-5*24*time.Hour)))
   520  	song2 := newSong("ar2", "ti2", "al2", withPlays(now.Add(-90*24*time.Hour)))
   521  	importSongs(song1, song2)
   522  
   523  	for _, tc := range []struct {
   524  		first, last string
   525  		want        []db.Song
   526  	}{
   527  		{oneDay, unsetTime, nil},
   528  		{oneWeek, unsetTime, joinSongs(song1)},
   529  		{oneYear, unsetTime, joinSongs(song1, song2)},
   530  		{unsetTime, oneYear, nil},
   531  		{unsetTime, oneMonth, joinSongs(song2)},
   532  		{unsetTime, oneDay, joinSongs(song1, song2)},
   533  	} {
   534  		page.setStage(fmt.Sprintf("%s / %s", tc.first, tc.last))
   535  		page.clickOption(firstPlayedSelect, tc.first)
   536  		page.clickOption(lastPlayedSelect, tc.last)
   537  		// TODO: This sometimes fails with the following error:
   538  		//
   539  		//  page.go:341: Failed clicking [{tag name search-view} {id search-button}] at
   540  		//  web_test.go:476 (one year / ): unknown error - 64: element click intercepted: Element
   541  		//  <button id="search-button" type="button">...</button> is not clickable at point (575,
   542  		//  343). Other element would receive the click: <dialog class="dialog" open="">...</dialog>
   543  		//
   544  		// Confusingly, the search button has already been clicked for the day and week cases at
   545  		// this point. I don't see anything fishy in the browser or server logs: the two earlier
   546  		// queries are successful. I also don't know what could be opening a <dialog> at this point:
   547  		// search-view opens them for empty or failed searches, but neither of those should be
   548  		// happening here. I've added the dialog's class to the <dialog> element itself to make this
   549  		// easier to debug the next time it happens.
   550  		page.click(searchButton)
   551  		page.checkSearchResults(tc.want)
   552  	}
   553  }
   554  
   555  func TestSongTableFields(t *testing.T) {
   556  	page, _, done := initWebTest(t)
   557  	defer done()
   558  
   559  	const (
   560  		ar1 = `Artist1 "\ Blah`
   561  		ar2 = `Artist2`
   562  		al1 = "Album1"
   563  		al2 = `Album2 "\ Blah`
   564  		al3 = "Album3"
   565  	)
   566  	song1 := newSong(ar1, "Track 1", al1, withTrack(1))
   567  	song2 := newSong(ar1, "Track 2", al2, withTrack(1))
   568  	song3 := newSong(ar1, "Track 3", al2, withTrack(2))
   569  	song4 := newSong(ar2, "Track 4", al3, withTrack(1))
   570  	importSongs(song1, song2, song3, song4)
   571  
   572  	page.click(searchButton)
   573  	page.checkSearchResults(joinSongs(song1, song2, song3, song4))
   574  
   575  	page.clickSongRowArtist(searchResultsTable, 0) // ar1
   576  	page.click(searchButton)
   577  	page.checkSearchResults(joinSongs(song1, song2, song3))
   578  
   579  	page.clickSongRowAlbum(searchResultsTable, 1) // al2
   580  	page.click(searchButton)
   581  	page.checkSearchResults(joinSongs(song2, song3))
   582  }
   583  
   584  func TestSearchResultCheckboxes(t *testing.T) {
   585  	page, _, done := initWebTest(t)
   586  	defer done()
   587  	songs := joinSongs(
   588  		newSong("a", "t1", "al", withTrack(1)),
   589  		newSong("a", "t2", "al", withTrack(2)),
   590  		newSong("a", "t3", "al", withTrack(3)),
   591  	)
   592  	importSongs(songs)
   593  
   594  	// All songs should be selected by default after a search.
   595  	page.setText(keywordsInput, songs[0].Artist)
   596  	page.click(searchButton)
   597  	page.checkSearchResults(songs, hasChecked(true, true, true))
   598  	page.checkCheckbox(searchResultsCheckbox, checkboxChecked)
   599  
   600  	// Click the top checkbox to deselect all songs.
   601  	page.click(searchResultsCheckbox)
   602  	page.checkSearchResults(songs, hasChecked(false, false, false))
   603  	page.checkCheckbox(searchResultsCheckbox, 0)
   604  
   605  	// Click it again to select all songs.
   606  	page.click(searchResultsCheckbox)
   607  	page.checkSearchResults(songs, hasChecked(true, true, true))
   608  	page.checkCheckbox(searchResultsCheckbox, checkboxChecked)
   609  
   610  	// Click the first song to deselect it.
   611  	page.clickSongRowCheckbox(searchResultsTable, 0, "")
   612  	page.checkSearchResults(songs, hasChecked(false, true, true))
   613  	page.checkCheckbox(searchResultsCheckbox, checkboxChecked|checkboxTransparent)
   614  
   615  	// Click the top checkbox to deselect all songs.
   616  	page.click(searchResultsCheckbox)
   617  	page.checkSearchResults(songs, hasChecked(false, false, false))
   618  	page.checkCheckbox(searchResultsCheckbox, 0)
   619  
   620  	// Click the first and second songs individually to select them.
   621  	page.clickSongRowCheckbox(searchResultsTable, 0, "")
   622  	page.clickSongRowCheckbox(searchResultsTable, 1, "")
   623  	page.checkSearchResults(songs, hasChecked(true, true, false))
   624  	page.checkCheckbox(searchResultsCheckbox, checkboxChecked|checkboxTransparent)
   625  
   626  	// Click the third song to select it as well.
   627  	page.clickSongRowCheckbox(searchResultsTable, 2, "")
   628  	page.checkSearchResults(songs, hasChecked(true, true, true))
   629  	page.checkCheckbox(searchResultsCheckbox, checkboxChecked)
   630  
   631  	// Shift-click from the first to third song to select all songs.
   632  	page.click(searchResultsCheckbox)
   633  	page.checkSearchResults(songs, hasChecked(false, false, false))
   634  	page.clickSongRowCheckbox(searchResultsTable, 0, selenium.ShiftKey)
   635  	page.clickSongRowCheckbox(searchResultsTable, 2, selenium.ShiftKey)
   636  	page.checkSearchResults(songs, hasChecked(true, true, true))
   637  	page.checkCheckbox(searchResultsCheckbox, checkboxChecked)
   638  }
   639  
   640  func TestAddToPlaylist(t *testing.T) {
   641  	page, _, done := initWebTest(t)
   642  	defer done()
   643  	song1 := newSong("a", "t1", "al1", withTrack(1))
   644  	song2 := newSong("a", "t2", "al1", withTrack(2))
   645  	song3 := newSong("a", "t3", "al2", withTrack(1))
   646  	song4 := newSong("a", "t4", "al2", withTrack(2))
   647  	song5 := newSong("a", "t5", "al3", withTrack(1))
   648  	song6 := newSong("a", "t6", "al3", withTrack(2))
   649  	importSongs(song1, song2, song3, song4, song5, song6)
   650  
   651  	page.setText(keywordsInput, "al1")
   652  	page.click(searchButton)
   653  	page.checkSearchResults(joinSongs(song1, song2))
   654  	page.click(appendButton)
   655  	page.checkPlaylist(joinSongs(song1, song2), hasActive(0))
   656  
   657  	// Pause so we don't advance through the playlist mid-test.
   658  	page.checkSong(song1, isPaused(false))
   659  	page.click(playPauseButton)
   660  	page.checkSong(song1, isPaused(true))
   661  
   662  	// Inserting should leave the current track paused.
   663  	page.setText(keywordsInput, "al2")
   664  	page.click(searchButton)
   665  	page.checkSearchResults(joinSongs(song3, song4))
   666  	page.click(insertButton)
   667  	page.checkPlaylist(joinSongs(song1, song3, song4, song2), hasActive(0))
   668  	page.checkSong(song1, isPaused(true))
   669  
   670  	// Replacing should result in the new first track being played.
   671  	page.setText(keywordsInput, "al3")
   672  	page.click(searchButton)
   673  	page.checkSearchResults(joinSongs(song5, song6))
   674  	page.click(replaceButton)
   675  	page.checkPlaylist(joinSongs(song5, song6), hasActive(0))
   676  	page.checkSong(song5, isPaused(false))
   677  
   678  	// Appending should leave the first track playing.
   679  	page.setText(keywordsInput, "al1")
   680  	page.click(searchButton)
   681  	page.checkSearchResults(joinSongs(song1, song2))
   682  	page.click(appendButton)
   683  	page.checkPlaylist(joinSongs(song5, song6, song1, song2), hasActive(0))
   684  	page.checkSong(song5, isPaused(false))
   685  
   686  	// The "I'm feeling lucky" button should replace the current playlist and
   687  	// start playing the new first song.
   688  	page.setText(keywordsInput, "al2")
   689  	page.click(luckyButton)
   690  	page.checkPlaylist(joinSongs(song3, song4), hasActive(0))
   691  	page.checkSong(song3, isPaused(false))
   692  }
   693  
   694  func TestPlaybackButtons(t *testing.T) {
   695  	page, _, done := initWebTest(t)
   696  	defer done()
   697  	// Using a 10-second song here makes this test slow, but I've seen flakiness when using the
   698  	// 5-second song.
   699  	song1 := newSong("artist", "track1", "album", withTrack(1), withFilename(file10s))
   700  	song2 := newSong("artist", "track2", "album", withTrack(2), withFilename(file1s))
   701  	importSongs(song1, song2)
   702  
   703  	// We should start playing automatically when the 'lucky' button is clicked.
   704  	page.setText(keywordsInput, song1.Artist)
   705  	page.click(luckyButton)
   706  	page.checkSong(song1, isPaused(false), hasFilename(song1.Filename))
   707  	page.checkPlaylist(joinSongs(song1, song2), hasActive(0))
   708  
   709  	// Pausing and playing should work.
   710  	page.click(playPauseButton)
   711  	page.checkSong(song1, isPaused(true))
   712  	page.checkPlaylist(joinSongs(song1, song2), hasActive(0))
   713  	page.click(playPauseButton)
   714  	page.checkSong(song1, isPaused(false))
   715  
   716  	// Clicking the 'next' button should go to the second song.
   717  	page.click(nextButton)
   718  	page.checkSong(song2, isPaused(false), hasFilename(song2.Filename))
   719  	page.checkPlaylist(joinSongs(song1, song2), hasActive(1))
   720  
   721  	// Clicking it again shouldn't do anything.
   722  	page.click(nextButton)
   723  	page.checkSong(song2)
   724  	page.checkPlaylist(joinSongs(song1, song2), hasActive(1))
   725  
   726  	// Clicking the 'prev' button should go back to the first song.
   727  	page.click(prevButton)
   728  	page.checkSong(song1, isPaused(false))
   729  	page.checkPlaylist(joinSongs(song1, song2), hasActive(0))
   730  
   731  	// Clicking it again shouldn't do anything.
   732  	page.click(prevButton)
   733  	page.checkSong(song1, isPaused(false))
   734  	page.checkPlaylist(joinSongs(song1, song2), hasActive(0))
   735  
   736  	// We should eventually play through to the second song.
   737  	// Use a long timeout since I saw a failure on GCP where we were somehow still only at position
   738  	// 0:09 in the 10-second song after waiting for 15 seconds.
   739  	page.checkSong(song2, isPaused(false), useTimeout(20*time.Second))
   740  	page.checkPlaylist(joinSongs(song1, song2), hasActive(1))
   741  }
   742  
   743  func TestContextMenu(t *testing.T) {
   744  	page, srv, done := initWebTest(t)
   745  	defer done()
   746  	song1 := newSong("a", "t1", "al", withTrack(1))
   747  	song2 := newSong("a", "t2", "al", withTrack(2))
   748  	song3 := newSong("a", "t3", "al", withTrack(3))
   749  	song4 := newSong("a", "t4", "al", withTrack(4))
   750  	song5 := newSong("a", "t5", "al", withTrack(5))
   751  	songs := joinSongs(song1, song2, song3, song4, song5)
   752  	importSongs(songs)
   753  
   754  	page.setText(keywordsInput, song1.Album)
   755  	page.click(luckyButton)
   756  	page.checkSong(song1, isPaused(false))
   757  	page.checkPlaylist(songs, hasActive(0))
   758  
   759  	page.rightClickSongRow(playlistTable, 3)
   760  	page.checkPlaylist(songs, hasMenu(3))
   761  	page.click(menuPlay)
   762  	page.checkSong(song4, isPaused(false))
   763  	page.checkPlaylist(songs, hasActive(3))
   764  
   765  	page.rightClickSongRow(playlistTable, 2)
   766  	page.checkPlaylist(songs, hasMenu(2))
   767  	page.click(menuPlay)
   768  	page.checkSong(song3, isPaused(false))
   769  	page.click(playPauseButton) // make sure we don't advance mid-test
   770  	page.checkSong(song3, isPaused(true))
   771  	page.checkPlaylist(songs, hasActive(2))
   772  
   773  	page.rightClickSongRow(playlistTable, 1)
   774  	page.checkPlaylist(songs, hasMenu(1))
   775  	page.click(menuUpdate)
   776  	page.checkText(updateArtist, song2.Artist)
   777  	page.checkText(updateTitle, song2.Title)
   778  	page.click(updateFourStars)
   779  	page.click(updateCloseImage)
   780  	srv.checkSong(song2, hasSrvRating(4)) // check that the correct song was updated
   781  
   782  	page.rightClickSongRow(playlistTable, 0)
   783  	page.checkPlaylist(songs, hasMenu(0))
   784  	page.click(menuRemove)
   785  	page.checkSong(song3, isPaused(true))
   786  	page.checkPlaylist(joinSongs(song2, song3, song4, song5), hasActive(1))
   787  
   788  	page.rightClickSongRow(playlistTable, 1)
   789  	page.checkPlaylist(joinSongs(song2, song3, song4, song5), hasMenu(1))
   790  	page.click(menuTruncate)
   791  	page.checkSong(song2, isPaused(true))
   792  	page.checkPlaylist(joinSongs(song2), hasActive(0))
   793  }
   794  
   795  func TestDisplayTimeWhilePlaying(t *testing.T) {
   796  	page, _, done := initWebTest(t)
   797  	defer done()
   798  	song := newSong("ar", "t", "al", withFilename(file5s))
   799  	importSongs(song)
   800  
   801  	page.setText(keywordsInput, song.Artist)
   802  	page.click(luckyButton)
   803  
   804  	// TODO: This can be flaky when the checks happen to run slowly.
   805  	page.checkSong(song, isPaused(false), hasTimeStr("0:00 / 0:05"))
   806  	page.checkSong(song, isPaused(false), hasTimeStr("0:01 / 0:05"))
   807  	page.checkSong(song, isPaused(false), hasTimeStr("0:02 / 0:05"))
   808  	page.checkSong(song, isPaused(false), hasTimeStr("0:03 / 0:05"))
   809  	page.checkSong(song, isPaused(false), hasTimeStr("0:04 / 0:05"))
   810  	page.checkSong(song, isEnded(true), isPaused(true), hasTimeStr("0:05 / 0:05"))
   811  }
   812  
   813  func TestReportPlayed(t *testing.T) {
   814  	page, srv, done := initWebTest(t)
   815  	defer done()
   816  	song1 := newSong("a", "t1", "al", withTrack(1), withFilename(file5s))
   817  	song2 := newSong("a", "t2", "al", withTrack(2), withFilename(file1s))
   818  	importSongs(song1, song2)
   819  
   820  	// Skip the first song early on, but listen to all of the second song.
   821  	page.setText(keywordsInput, song1.Artist)
   822  	page.click(luckyButton)
   823  	page.checkSong(song1, isPaused(false))
   824  	page.click(playPauseButton)
   825  	song2Lower := time.Now()
   826  	page.click(nextButton)
   827  	page.checkSong(song2, isEnded(true))
   828  	song2Upper := time.Now()
   829  
   830  	// Only the second song should've been reported.
   831  	srv.checkSong(song2, hasSrvPlay(song2Lower, song2Upper))
   832  	srv.checkSong(song1, hasNoSrvPlays())
   833  
   834  	// Go back to the first song but pause it immediately.
   835  	song1Lower := time.Now()
   836  	page.click(prevButton)
   837  	page.checkSong(song1, isPaused(false))
   838  	song1Upper := time.Now()
   839  	page.click(playPauseButton)
   840  	page.checkSong(song1, isPaused(true))
   841  
   842  	// After more than half of the first song has played, it should be reported.
   843  	page.click(playPauseButton)
   844  	page.checkSong(song1, isPaused(false))
   845  	srv.checkSong(song1, hasSrvPlay(song1Lower, song1Upper))
   846  	srv.checkSong(song2, hasSrvPlay(song2Lower, song2Upper))
   847  }
   848  
   849  func TestReportReplay(t *testing.T) {
   850  	page, srv, done := initWebTest(t)
   851  	defer done()
   852  	song := newSong("a", "t1", "al", withFilename(file1s))
   853  	importSongs(song)
   854  
   855  	// Play the song to completion.
   856  	page.setText(keywordsInput, song.Artist)
   857  	firstLower := time.Now()
   858  	page.click(luckyButton)
   859  	page.checkSong(song, isEnded(true))
   860  
   861  	// Replay the song.
   862  	secondLower := time.Now()
   863  	page.click(playPauseButton)
   864  
   865  	// Both playbacks should be reported.
   866  	srv.checkSong(song, hasSrvPlay(firstLower, secondLower),
   867  		hasSrvPlay(secondLower, secondLower.Add(2*time.Second)))
   868  }
   869  
   870  func TestRateAndTag(t *testing.T) {
   871  	page, srv, done := initWebTest(t)
   872  	defer done()
   873  	song := newSong("ar", "t1", "al", withRating(3), withTags("rock", "guitar"))
   874  	importSongs(song)
   875  
   876  	page.setText(keywordsInput, song.Artist)
   877  	page.click(luckyButton)
   878  	page.checkSong(song, isPaused(false))
   879  	page.click(playPauseButton)
   880  	page.checkSong(song, isPaused(true), hasRating(3), hasImgTitle("Rating: ★★★☆☆\nTags: guitar rock"))
   881  
   882  	page.click(coverImage)
   883  	page.checkText(updateArtist, song.Artist)
   884  	page.checkText(updateTitle, song.Title)
   885  	page.click(updateFourStars)
   886  	page.click(updateCloseImage)
   887  	page.checkSong(song, hasRating(4), hasImgTitle("Rating: ★★★★☆\nTags: guitar rock"))
   888  	srv.checkSong(song, hasSrvRating(4), hasSrvTags("guitar", "rock"))
   889  
   890  	page.click(coverImage)
   891  	page.sendKeys(updateTagsTextarea, " +metal", false)
   892  	page.click(updateCloseImage)
   893  	page.checkSong(song, hasRating(4), hasImgTitle("Rating: ★★★★☆\nTags: guitar metal rock"))
   894  	srv.checkSong(song, hasSrvRating(4), hasSrvTags("guitar", "metal", "rock"))
   895  }
   896  
   897  func TestRetryUpdates(t *testing.T) {
   898  	page, srv, done := initWebTest(t)
   899  	defer done()
   900  	song := newSong("ar", "t1", "al", withFilename(file1s),
   901  		withRating(3), withTags("rock", "guitar"))
   902  	importSongs(song)
   903  
   904  	// Configure the server to reject updates and play the song.
   905  	tester.ForceUpdateFailures(true)
   906  	page.setText(keywordsInput, song.Artist)
   907  	firstLower := time.Now()
   908  	page.click(luckyButton)
   909  	page.checkSong(song, isEnded(true))
   910  	firstUpper := time.Now()
   911  
   912  	// Change the song's rating and tags.
   913  	page.click(coverImage)
   914  	page.click(updateFourStars)
   915  	page.setText(updateTagsTextarea, "+jazz +mellow")
   916  	page.click(updateCloseImage)
   917  
   918  	// Wait a bit to let the updates fail and then let them succeed.
   919  	time.Sleep(time.Second)
   920  	tester.ForceUpdateFailures(false)
   921  	srv.checkSong(song, hasSrvRating(4), hasSrvTags("jazz", "mellow"),
   922  		hasSrvPlay(firstLower, firstUpper))
   923  
   924  	// Queue some more failed updates.
   925  	tester.ForceUpdateFailures(true)
   926  	secondLower := time.Now()
   927  	page.click(playPauseButton)
   928  	page.checkSong(song, isEnded(false))
   929  	page.checkSong(song, isEnded(true))
   930  	secondUpper := time.Now()
   931  	page.click(coverImage)
   932  	page.click(updateTwoStars)
   933  	page.setText(updateTagsTextarea, "+lively +soul")
   934  	page.click(updateCloseImage)
   935  	time.Sleep(time.Second)
   936  
   937  	// The queued updates should be sent if the page is reloaded.
   938  	page.reload()
   939  	tester.ForceUpdateFailures(false)
   940  	srv.checkSong(song, hasSrvRating(2), hasSrvTags("lively", "soul"),
   941  		hasSrvPlay(firstLower, firstUpper), hasSrvPlay(secondLower, secondUpper))
   942  
   943  	// In the case of multiple queued updates, the last one should take precedence.
   944  	tester.ForceUpdateFailures(true)
   945  	page.setText(keywordsInput, song.Artist)
   946  	page.click(luckyButton)
   947  	page.checkSong(song)
   948  	for _, r := range [][]loc{updateThreeStars, updateFourStars, updateFiveStars} {
   949  		page.click(coverImage)
   950  		page.checkDisplayed(updateCloseImage, true)
   951  		page.click(r)
   952  		page.click(updateCloseImage)
   953  		page.checkGone(updateCloseImage)
   954  	}
   955  	tester.ForceUpdateFailures(false)
   956  	srv.checkSong(song, hasSrvRating(5))
   957  }
   958  
   959  func TestUpdateTagsAutocomplete(t *testing.T) {
   960  	page, _, done := initWebTest(t)
   961  	defer done()
   962  	song1 := newSong("ar", "t1", "al", withTags("a0", "a1", "b"))
   963  	song2 := newSong("ar", "t2", "al", withTags("c0", "c1", "d", "long"))
   964  	importSongs(song1, song2)
   965  
   966  	page.refreshTags()
   967  	page.setText(keywordsInput, song1.Title)
   968  	page.click(luckyButton)
   969  	page.checkSong(song1)
   970  
   971  	page.click(coverImage)
   972  	page.checkAttr(updateTagsTextarea, "value", "a0 a1 b ")
   973  
   974  	page.sendKeys(updateTagsTextarea, "d"+selenium.TabKey, false)
   975  	page.checkAttr(updateTagsTextarea, "value", "a0 a1 b d ")
   976  
   977  	page.sendKeys(updateTagsTextarea, "c"+selenium.TabKey, false)
   978  	page.checkAttr(updateTagsTextarea, "value", "a0 a1 b d c")
   979  	page.checkTextRegexp(updateTagSuggester, `^\s*c0\s*c1\s*$`)
   980  
   981  	page.sendKeys(updateTagsTextarea, "1"+selenium.TabKey, false)
   982  	page.checkAttr(updateTagsTextarea, "value", "a0 a1 b d c1 ")
   983  
   984  	// Position the caret at the beginning of the "c1" tag and complete "long".
   985  	// The caret strangely seems to get moved to the end of the textarea for each
   986  	// sendKeys call, so do this all in one go.
   987  	page.sendKeys(updateTagsTextarea, strings.Repeat(selenium.LeftArrowKey, 3)+"l"+selenium.TabKey, false)
   988  	page.checkAttr(updateTagsTextarea, "value", "a0 a1 b d long c1 ")
   989  }
   990  
   991  func TestDragSongs(t *testing.T) {
   992  	page, _, done := initWebTest(t)
   993  	defer done()
   994  
   995  	s1 := newSong("a", "t1", "al", withTrack(1))
   996  	s2 := newSong("a", "t2", "al", withTrack(2))
   997  	s3 := newSong("a", "t3", "al", withTrack(3))
   998  	s4 := newSong("a", "t4", "al", withTrack(4))
   999  	s5 := newSong("a", "t5", "al", withTrack(5))
  1000  	importSongs(joinSongs(s1, s2, s3, s4, s5))
  1001  
  1002  	page.setText(keywordsInput, s1.Artist)
  1003  	page.click(searchButton)
  1004  	page.checkSearchResults(joinSongs(s1, s2, s3, s4, s5))
  1005  	page.clickSongRowCheckbox(searchResultsTable, 2, "")
  1006  	page.checkSearchResults(
  1007  		joinSongs(s1, s2, s3, s4, s5),
  1008  		hasChecked(true, true, false, true, true))
  1009  
  1010  	// Drag the middle song up to the second song's position.
  1011  	page.dragSongRow(searchResultsTable, 2, 1, -10)
  1012  	page.checkSearchResults(
  1013  		joinSongs(s1, s3, s2, s4, s5),
  1014  		hasChecked(true, false, true, true, true))
  1015  
  1016  	// Now drag it to the end of the list.
  1017  	page.dragSongRow(searchResultsTable, 1, 4, 10)
  1018  	page.checkSearchResults(
  1019  		joinSongs(s1, s2, s4, s5, s3),
  1020  		hasChecked(true, true, true, true, false))
  1021  
  1022  	// Enqueue the songs.
  1023  	page.click(appendButton)
  1024  	page.checkSong(s1, isPaused(false))
  1025  	page.checkPlaylist(joinSongs(s1, s2, s4, s5), hasActive(0))
  1026  	page.click(playPauseButton)
  1027  
  1028  	// Drag the second song in the playlist above the first song.
  1029  	page.dragSongRow(playlistTable, 1, 0, -10)
  1030  	page.checkSong(s1, isPaused(true))
  1031  	page.checkPlaylist(joinSongs(s2, s1, s4, s5), hasActive(1))
  1032  
  1033  	// Switch songs to check that the underlying playlist was updated.
  1034  	page.click(prevButton)
  1035  	page.checkSong(s2, isPaused(false))
  1036  	page.click(playPauseButton)
  1037  	page.checkPlaylist(joinSongs(s2, s1, s4, s5), hasActive(0))
  1038  
  1039  	// Now drag the active song to the end of the playlist.
  1040  	page.dragSongRow(playlistTable, 0, 3, 10)
  1041  	page.checkSong(s2, isPaused(true))
  1042  	page.checkPlaylist(joinSongs(s1, s4, s5, s2), hasActive(3))
  1043  }
  1044  
  1045  func TestOptions(t *testing.T) {
  1046  	page, _, done := initWebTest(t)
  1047  	defer done()
  1048  	show := func() { page.emitKeyDown("o", 79, true /* alt */) }
  1049  
  1050  	show()
  1051  	page.checkAttr(gainTypeSelect, "value", gainAutoValue)
  1052  	// TODO: This somehow fails sometimes due to the option not being found (despite clickOption()
  1053  	// finding the correct number of options). Hopefully additional logging in clickOption() will
  1054  	// shed light on why it isn't found.
  1055  	page.clickOption(gainTypeSelect, gainTrack)
  1056  	page.checkAttr(gainTypeSelect, "value", gainTrackValue)
  1057  
  1058  	// The dark theme should be used as soon as it's selected.
  1059  	page.checkAttr(themeSelect, "value", themeAutoValue)
  1060  	page.clickOption(themeSelect, themeDark)
  1061  	page.checkAttr(themeSelect, "value", themeDarkValue)
  1062  	page.checkAttr(document, "data-theme", "dark")
  1063  
  1064  	// I *think* that this clicks the middle of the range. This might be a
  1065  	// no-op since it should be 0, which is the default. :-/
  1066  	page.click(preAmpRange)
  1067  	origPreAmp := page.getAttrOrFail(page.getOrFail(preAmpRange), "value", false)
  1068  
  1069  	page.click(optionsOKButton)
  1070  	page.checkGone(optionsOKButton)
  1071  
  1072  	// The dialog should also be available via the menu, and Escape should dismiss it.
  1073  	page.click(menuButton)
  1074  	page.click(menuOptions)
  1075  	page.getOrFail(optionsOKButton)
  1076  	page.sendKeys(body, selenium.EscapeKey, false)
  1077  	page.checkGone(optionsOKButton)
  1078  
  1079  	page.reload()
  1080  	show()
  1081  	page.checkAttr(themeSelect, "value", themeDarkValue)
  1082  	page.checkAttr(gainTypeSelect, "value", gainTrackValue)
  1083  	page.checkAttr(preAmpRange, "value", origPreAmp)
  1084  	// TODO: For reasons that are unclear to me, clicking the OK button ocasionally fails at this
  1085  	// point with "element not visible: element not interactable", so I'm speculatively dismissing
  1086  	// the dialog with the escape key instead.
  1087  	page.sendKeys(body, selenium.EscapeKey, false)
  1088  	page.checkGone(optionsOKButton)
  1089  
  1090  	// The dark theme should still be used.
  1091  	page.checkAttr(document, "data-theme", "dark")
  1092  }
  1093  
  1094  func TestSongInfo(t *testing.T) {
  1095  	page, _, done := initWebTest(t)
  1096  	defer done()
  1097  
  1098  	song1 := newSong("a", "t1", "al1", withTrack(1), withLength(123),
  1099  		withDate(test.Date(2015, 4, 3, 12, 13, 14)),
  1100  		withRating(5), withTags("guitar", "instrumental"))
  1101  	song2 := newSong("a", "t2", "al2", withTrack(5), withDisc(2),
  1102  		withDiscSubtitle("Second Disc"), withLength(52))
  1103  	importSongs(song1, song2)
  1104  
  1105  	page.setText(keywordsInput, "a")
  1106  	page.click(luckyButton)
  1107  	page.checkPlaylist(joinSongs(song1, song2), hasActive(0))
  1108  	page.click(playPauseButton)
  1109  	page.checkSong(song1, isPaused(true))
  1110  
  1111  	page.emitKeyDown("i", 73, true /* alt */)
  1112  	page.checkText(infoArtist, song1.Artist)
  1113  	page.checkText(infoTitle, song1.Title)
  1114  	page.checkText(infoAlbum, song1.Album)
  1115  	page.checkText(infoDisc, "")
  1116  	page.checkText(infoTrack, strconv.Itoa(song1.Track))
  1117  	page.checkText(infoDate, "2015-04-03")
  1118  	page.checkText(infoLength, "2:03")
  1119  	page.checkText(infoRating, "★★★★★")
  1120  	page.checkText(infoTags, strings.Join(song1.Tags, " "))
  1121  	page.click(infoDismissButton)
  1122  	page.checkGone(infoDismissButton)
  1123  
  1124  	page.rightClickSongRow(playlistTable, 1)
  1125  	page.click(menuInfo)
  1126  	page.checkText(infoArtist, song2.Artist)
  1127  	page.checkText(infoTitle, song2.Title)
  1128  	page.checkText(infoAlbum, song2.Album)
  1129  	page.checkText(infoDisc, fmt.Sprintf("%d (%s)", song2.Disc, song2.DiscSubtitle))
  1130  	page.checkText(infoTrack, strconv.Itoa(song2.Track))
  1131  	page.checkText(infoDate, "")
  1132  	page.checkText(infoLength, "0:52")
  1133  	page.checkText(infoRating, "Unrated")
  1134  	page.checkText(infoTags, "")
  1135  	page.click(infoDismissButton)
  1136  	page.checkGone(infoDismissButton)
  1137  }
  1138  
  1139  func TestPresets(t *testing.T) {
  1140  	page, _, done := initWebTest(t)
  1141  	defer done()
  1142  	now := time.Now()
  1143  	now2 := now.Add(-5 * time.Minute)
  1144  	old := now.Add(-2 * 365 * 24 * time.Hour)
  1145  	old2 := old.Add(-5 * time.Minute)
  1146  	song1 := newSong("a", "t1", "unrated")
  1147  	song2 := newSong("a", "t1", "new", withRating(2), withTrack(1), withDisc(1), withPlays(now, now2))
  1148  	song3 := newSong("a", "t2", "new", withRating(5), withTrack(2), withDisc(1), withPlays(now, now2))
  1149  	song4 := newSong("a", "t1", "old", withRating(4), withPlays(old, old2))
  1150  	song5 := newSong("a", "t2", "old", withRating(4), withTags("instrumental"), withPlays(old, old2))
  1151  	song6 := newSong("a", "t1", "mellow", withRating(4), withTags("mellow"))
  1152  	importSongs(song1, song2, song3, song4, song5, song6)
  1153  
  1154  	page.clickOption(presetSelect, presetInstrumentalOld)
  1155  	page.checkSong(song5)
  1156  	page.clickOption(presetSelect, presetMellow)
  1157  	page.checkSong(song6)
  1158  	page.clickOption(presetSelect, presetNewAlbums)
  1159  	page.checkSearchResults(joinSongs(song2))
  1160  	page.clickOption(presetSelect, presetUnrated)
  1161  	page.checkSong(song1)
  1162  	page.clickOption(presetSelect, presetPlayedOnce)
  1163  	page.checkSong(song6)
  1164  
  1165  	if active, err := page.wd.ActiveElement(); err != nil {
  1166  		t.Error("Failed getting active element: ", err)
  1167  	} else if reflect.DeepEqual(active, page.getOrFail(presetSelect)) {
  1168  		t.Error("Preset select still focused after click")
  1169  	}
  1170  }
  1171  
  1172  func TestFullscreenOverlay(t *testing.T) {
  1173  	page, _, done := initWebTest(t)
  1174  	defer done()
  1175  	show := func() { page.emitKeyDown("v", 86, true /* alt */) }
  1176  	next := func() { page.emitKeyDown("n", 78, true /* alt */) }
  1177  	rate := func() { page.emitKeyDown("r", 82, true /* alt */) }
  1178  
  1179  	song1 := newSong("artist", "track1", "album1", withTrack(1))
  1180  	song2 := newSong("artist", "track2", "album1", withTrack(2))
  1181  	song3 := newSong("artist", "track1", "album2", withTrack(1))
  1182  	importSongs(song1, song2, song3)
  1183  
  1184  	// Enqueue song1 and song2 and check that they're displayed.
  1185  	page.setText(keywordsInput, "album:"+song1.Album)
  1186  	page.click(luckyButton)
  1187  	page.checkPlaylist(joinSongs(song1, song2), hasActive(0))
  1188  	show()
  1189  	page.checkFullscreenOverlay(&song1, &song2)
  1190  	page.sendKeys(body, selenium.EscapeKey, false)
  1191  	page.checkFullscreenOverlay(nil, nil)
  1192  
  1193  	// Insert song3 after song1 and check that it's displayed as the next song.
  1194  	page.setText(keywordsInput, "album:"+song3.Album)
  1195  	page.click(searchButton)
  1196  	page.checkSearchResults(joinSongs(song3))
  1197  	page.click(insertButton)
  1198  	page.checkPlaylist(joinSongs(song1, song3, song2), hasActive(0))
  1199  	show()
  1200  	page.checkFullscreenOverlay(&song1, &song3)
  1201  
  1202  	// Skip to the next song.
  1203  	next()
  1204  	page.checkFullscreenOverlay(&song3, &song2)
  1205  
  1206  	// Skip to the last song. Displaying the update window should hide the overlay.
  1207  	next()
  1208  	page.checkFullscreenOverlay(&song2, nil)
  1209  	rate()
  1210  	page.checkFullscreenOverlay(nil, nil)
  1211  	page.click(updateCloseImage)
  1212  
  1213  	// The overlay should be displayable via the menu too, and clicking on the overlay
  1214  	// should hide it.
  1215  	page.click(menuButton)
  1216  	page.click(menuFullscreen)
  1217  	page.checkFullscreenOverlay(&song2, nil)
  1218  	page.click(fullscreenOverlay)
  1219  	page.checkFullscreenOverlay(nil, nil)
  1220  }
  1221  
  1222  func TestStats(t *testing.T) {
  1223  	page, _, done := initWebTest(t)
  1224  	defer done()
  1225  
  1226  	t2001 := test.Date(2001, 4, 1)
  1227  	t2014 := test.Date(2014, 5, 3)
  1228  	t2015 := test.Date(2015, 10, 31)
  1229  
  1230  	song1 := newSong("artist", "track1", "album1", withRating(3), withLength(7200),
  1231  		withDate(t2001), withPlays(t2001, t2014, t2015))
  1232  	song2 := newSong("artist", "track2", "album1", withRating(5), withLength(201),
  1233  		withDate(t2014), withPlays(t2015))
  1234  	song3 := newSong("artist", "track3", "album2", withRating(0), withLength(45),
  1235  		withDate(t2001), withPlays(t2014))
  1236  	importSongs(song1, song2, song3)
  1237  	tester.UpdateStats()
  1238  
  1239  	page.click(menuButton)
  1240  	page.click(menuStats)
  1241  
  1242  	for _, fields := range [][]string{
  1243  		{"Songs:", "3"},
  1244  		{"Albums:", "2"},
  1245  		{"Duration:", "0.1 days"},
  1246  		// Table columns are year, first plays, last plays, plays, playtime.
  1247  		{"2001", "1", "0", "1", "0.1 days"},
  1248  		{"2014", "1", "1", "2", "0.1 days"},
  1249  		{"2015", "1", "2", "2", "0.1 days"},
  1250  	} {
  1251  		quoted := make([]string, len(fields))
  1252  		for i, s := range fields {
  1253  			quoted[i] = regexp.QuoteMeta(s)
  1254  		}
  1255  		page.checkTextRegexp(statsDialog, `(^|\s+)`+strings.Join(quoted, `\s+`)+`($|\s+)`)
  1256  	}
  1257  
  1258  	page.checkStatsChart(statsDecadesChart, []statsChartBar{
  1259  		{67, "2000s - 2 songs"},
  1260  		{33, "2010s - 1 song"},
  1261  	})
  1262  	page.checkStatsChart(statsRatingsChart, []statsChartBar{
  1263  		{33, "Unrated - 1 song"},
  1264  		{0, "★ - 0 songs"},
  1265  		{0, "★★ - 0 songs"},
  1266  		{33, "★★★ - 1 song"},
  1267  		{0, "★★★★ - 0 songs"},
  1268  		{33, "★★★★★ - 1 song"},
  1269  	})
  1270  }
  1271  
  1272  func TestUnit(t *testing.T) {
  1273  	// We don't care about initializing the page object, but we want to write a header
  1274  	// to the browser log.
  1275  	_, _, done := initWebTest(t)
  1276  	defer done()
  1277  
  1278  	// Transform web/*.ts into JS and write it to a temp dir.
  1279  	tsDir := filepath.Join(t.TempDir(), "ts")
  1280  	if err := os.Mkdir(tsDir, 0755); err != nil {
  1281  		t.Fatal(err)
  1282  	}
  1283  	paths, err := filepath.Glob("../../web/*.ts")
  1284  	if err != nil {
  1285  		t.Fatal(err)
  1286  	}
  1287  	for _, p := range paths {
  1288  		ts, err := ioutil.ReadFile(p)
  1289  		if err != nil {
  1290  			t.Fatal(err)
  1291  		}
  1292  		js, err := esbuild.Transform(ts, api.LoaderTS, false /* minify */, filepath.Base(p))
  1293  		if err != nil {
  1294  			t.Fatalf("Failed transforming %v: %v", p, err)
  1295  		}
  1296  		fn := strings.TrimSuffix(filepath.Base(p), ".ts") + ".js"
  1297  		if err := ioutil.WriteFile(filepath.Join(tsDir, fn), js, 0644); err != nil {
  1298  			t.Fatal(err)
  1299  		}
  1300  	}
  1301  
  1302  	// Start an HTTP server that serves both the web interface and the unit test files.
  1303  	fs := unionFS{[]http.Dir{http.Dir("unit"), http.Dir("../../web"), http.Dir(tsDir)}}
  1304  	srv := httptest.NewServer(http.FileServer(fs))
  1305  	defer srv.Close()
  1306  	if err := webDrv.Get(srv.URL); err != nil {
  1307  		t.Fatalf("Failed navigating to %v: %v", srv.URL, err)
  1308  	}
  1309  
  1310  	// WebDriver apparently blocks internally while executing scripts, so it seems like we
  1311  	// unfortunately can't just start a goroutine to stream logs via copyBrowserLogs.
  1312  	out, err := webDrv.ExecuteScriptAsyncRaw(
  1313  		// ExecuteScriptAsync injects a 'done' callback as the final argument to the called code.
  1314  		fmt.Sprintf(`const done = arguments[0];
  1315  		const results = await window.runTests(%q);
  1316  		done(results);`, unitTestRegexp), nil)
  1317  	if err != nil {
  1318  		t.Fatalf("Failed running tests: %v", err)
  1319  	}
  1320  
  1321  	// The outer object with a 'value' property gets added by Selenium.
  1322  	var results struct {
  1323  		Value []struct {
  1324  			Name   string `json:"name"` // "suite.test"
  1325  			Errors []struct {
  1326  				Src string `json:"src"`
  1327  				Msg string `json:"msg"`
  1328  			} `json:"errors"`
  1329  		} `json:"value"`
  1330  	}
  1331  	if err := json.Unmarshal(out, &results); err != nil {
  1332  		t.Fatalf("Failed unmarshaling test results %q: %v", string(out), err)
  1333  	}
  1334  
  1335  	// Some tests intentionally fail in order to exercise test.js.
  1336  	wantErrors := map[string][]string{
  1337  		"example.syncErrors": {
  1338  			"Got true (boolean); want false (boolean)",
  1339  			"Got true (boolean); want 1 (number)",
  1340  			"Got 1 (number); want 2 (number)",
  1341  			"Got null (object); want false (boolean)",
  1342  			"Got null (object); want undefined (undefined)",
  1343  			`Value is "foo" (string); want "bar" (string)`,
  1344  			`Got [4,"foo"] (object); want [4,"bar"] (object)`,
  1345  			`Got {"a":2} (object); want {"b":2} (object)`,
  1346  		},
  1347  		"example.syncFatal":             {"Fatal: Intentional (exception)"},
  1348  		"example.syncException":         {"Error: Intentional (exception)"},
  1349  		"example.asyncEarlyFatal":       {"Fatal: Intentional (exception)"},
  1350  		"example.asyncEarlyException":   {"Error: Intentional (exception)"},
  1351  		"example.asyncEarlyReject":      {"Unhandled rejection: Intentional"},
  1352  		"example.asyncTimeoutFatal":     {"Fatal: Intentional (exception)"},
  1353  		"example.asyncTimeoutException": {"Error: Intentional (exception)"},
  1354  		"example.asyncTimeoutReject":    {"Unhandled rejection: Intentional"},
  1355  		"example.doneEarlyFatal":        {"Fatal: Intentional (exception)"},
  1356  		"example.doneEarlyException":    {"Error: Intentional (exception)"},
  1357  		"example.doneTimeoutFatal":      {"Fatal: Intentional (exception)"},
  1358  		"example.doneTimeoutException":  {"Error: Intentional (exception)"},
  1359  		"example.doneTimeoutReject":     {"Unhandled rejection: Intentional"},
  1360  	}
  1361  	gotErrors := make(map[string][]string)
  1362  
  1363  	for _, res := range results.Value {
  1364  		if _, ok := wantErrors[res.Name]; ok {
  1365  			msgs := make([]string, 0, len(res.Errors))
  1366  			for _, err := range res.Errors {
  1367  				// TODO: Check err.Src.
  1368  				msgs = append(msgs, err.Msg)
  1369  			}
  1370  			gotErrors[res.Name] = msgs
  1371  			continue
  1372  		}
  1373  
  1374  		for _, err := range res.Errors {
  1375  			pre := res.Name
  1376  			if err.Src != "" {
  1377  				pre += ": " + err.Src
  1378  			}
  1379  			t.Errorf("%v: %v", pre, err.Msg)
  1380  		}
  1381  	}
  1382  
  1383  	// Check that we got expected errors.
  1384  	re := regexp.MustCompile(unitTestRegexp)
  1385  	for test, want := range wantErrors {
  1386  		if !re.MatchString(test) {
  1387  			continue // won't see error if test was skipped
  1388  		}
  1389  		if got := gotErrors[test]; !reflect.DeepEqual(got, want) {
  1390  			t.Errorf("Got %q errors %q; want %q", test, got, want)
  1391  		}
  1392  	}
  1393  }
  1394  
  1395  // unionFS implements http.FileSystem using layered http.Dirs.
  1396  type unionFS struct {
  1397  	dirs []http.Dir
  1398  }
  1399  
  1400  func (fs unionFS) Open(name string) (http.File, error) {
  1401  	var err error
  1402  	for _, dir := range fs.dirs {
  1403  		var f http.File
  1404  		if f, err = dir.Open(name); err == nil {
  1405  			return f, nil
  1406  		}
  1407  	}
  1408  	return nil, err
  1409  }