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 }