blob: a4a5630d8856a898bdcd8635a8eb75f0c8ef1a4b [file] [log] [blame]
// Copyright 2021 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package videocuj
import (
"context"
"regexp"
"time"
"chromiumos/tast/errors"
"chromiumos/tast/local/bundles/cros/ui/cuj"
"chromiumos/tast/local/chrome"
"chromiumos/tast/local/chrome/ash"
"chromiumos/tast/local/chrome/browser"
"chromiumos/tast/local/chrome/uiauto"
"chromiumos/tast/local/chrome/uiauto/nodewith"
"chromiumos/tast/local/chrome/uiauto/role"
"chromiumos/tast/local/chrome/webutil"
"chromiumos/tast/local/input"
"chromiumos/tast/testing"
)
const (
mouseMoveDuration = 500 * time.Millisecond
shortUITimeout = 5 * time.Second
)
var (
videoPlayer = nodewith.NameStartingWith("YouTube Video Player").Role(role.GenericContainer)
video = nodewith.Role(role.Video).Ancestor(videoPlayer)
)
// YtWeb defines the struct related to youtube web.
type YtWeb struct {
br *browser.Browser
tconn *chrome.TestConn
kb *input.KeyboardEventWriter
video VideoSrc
ui *uiauto.Context
ytConn *chrome.Conn
ytWinID int
uiHdl cuj.UIActionHandler
extendedDisplay bool
}
// NewYtWeb creates an instance of YtWeb.
func NewYtWeb(br *browser.Browser, tconn *chrome.TestConn, kb *input.KeyboardEventWriter, video VideoSrc,
extendedDisplay bool, ui *uiauto.Context, uiHdl cuj.UIActionHandler) *YtWeb {
return &YtWeb{
br: br,
tconn: tconn,
kb: kb,
video: video,
ui: ui,
uiHdl: uiHdl,
extendedDisplay: extendedDisplay,
}
}
// OpenAndPlayVideo opens a youtube video on chrome.
func (y *YtWeb) OpenAndPlayVideo(ctx context.Context) (err error) {
testing.ContextLog(ctx, "Open Youtube web")
y.ytConn, err = y.uiHdl.NewChromeTab(ctx, y.br, y.video.URL, true)
if err != nil {
return errors.Wrap(err, "failed to open youtube tab")
}
if err := webutil.WaitForYoutubeVideo(ctx, y.ytConn, 0); err != nil {
return errors.Wrap(err, "failed to wait for video element")
}
// If prompted to open in YouTube app, instruct device to stay in Chrome.
stayInChrome := nodewith.Name("Stay in Chrome").Role(role.Button)
if err := uiauto.IfSuccessThen(
y.ui.WithTimeout(shortUITimeout).WaitUntilExists(stayInChrome),
func(ctx context.Context) error {
testing.ContextLog(ctx, "dialog popped up and asked whether to switch to YouTube app")
rememberMyChoice := nodewith.Name("Remember my choice").Role(role.CheckBox)
if err := y.uiHdl.Click(rememberMyChoice)(ctx); err != nil {
return err
}
if err := y.uiHdl.Click(stayInChrome)(ctx); err != nil {
return err
}
testing.ContextLog(ctx, "instructed device to stay on YouTube web")
return nil
},
)(ctx); err != nil {
return errors.Wrap(err, "failed to instruct device to stay on YouTube web")
}
// Clear notification prompts if exists. If the notification alert popup isn't clear,
// operations that require finding the current active window (i.e., SwitchWindowToDisplay)
// will not succeed.
prompts := []string{"Allow", "Never", "NO THANKS"}
if err := clearNotificationPrompts(ctx, y.ui, y.uiHdl, prompts...); err != nil {
return errors.Wrap(err, "failed to clear notification prompts")
}
// Default expected display is main display.
if err := cuj.SwitchWindowToDisplay(ctx, y.tconn, y.kb, y.extendedDisplay)(ctx); err != nil {
if y.extendedDisplay {
return errors.Wrap(err, "failed to switch Youtube to the extended display")
}
return errors.Wrap(err, "failed to switch Youtube to the main display")
}
if err := y.SkipAd()(ctx); err != nil {
return errors.Wrap(err, "failed to click 'Skip Ad' button")
}
switchQuality := func(resolution string) error {
testing.ContextLog(ctx, "Switch audio quality to ", resolution)
settings := nodewith.Name("Settings").Role(role.PopUpButton).Ancestor(videoPlayer)
quality := nodewith.NameStartingWith("Quality").Role(role.MenuItem).Ancestor(videoPlayer)
if err := y.ui.WaitUntilExists(videoPlayer)(ctx); err != nil {
return errors.Wrap(err, "failed to find 'YouTube Video Player'")
}
startTime := time.Now()
// The setting panel will automatically disappear if it does not receive any event after a few seconds.
// Dut to the different response time of different DUTs, we need to combine these actions in Poll() to
// make quality switch works reliably.
if err := testing.Poll(ctx, func(ctx context.Context) error {
if err := y.uiHdl.Click(videoPlayer)(ctx); err != nil {
return errors.Wrap(err, "failed to click YouTube Video Player to bring up settings panel")
}
// If an ad is playing, skip it before proceeding.
if err := y.SkipAd()(ctx); err != nil {
return errors.Wrap(err, "failed to click 'Skip Ad' button")
}
// Use DoDefault to avoid fauilure on lacros (see bug b/229003599).
if err := y.ui.DoDefault(settings)(ctx); err != nil {
return errors.Wrap(err, "failed to call DoDefault on settings button")
}
if err := y.ui.WithTimeout(10 * time.Second).WaitUntilExists(quality)(ctx); err != nil {
if y.extendedDisplay {
return errors.Wrap(err, "failed to show the setting panel and click it on extended display")
}
return errors.Wrap(err, "failed to show the setting panel and click it on internal display")
}
testing.ContextLogf(ctx, "Elapsed time to click setting panel: %.3f s", time.Since(startTime).Seconds())
return nil
}, &testing.PollOptions{Interval: 3 * time.Second, Timeout: 30 * time.Second}); err != nil {
return errors.Wrap(err, "failed to click setting panel")
}
// Use DoDefault to avoid fauilure on lacros (see bug b/229003599).
if err := y.ui.DoDefault(quality)(ctx); err != nil {
return errors.Wrap(err, "failed to click 'Quality'")
}
resolutionFinder := nodewith.NameStartingWith(resolution).Role(role.MenuItemRadio).Ancestor(videoPlayer)
if err := y.ui.DoDefault(resolutionFinder)(ctx); err != nil {
return errors.Wrapf(err, "failed to click %q", resolution)
}
if err := waitForYoutubeReadyState(ctx, y.ytConn); err != nil {
return errors.Wrap(err, "failed to wait for Youtube ready state")
}
// We've clicked the center of video player to show setting panel,
// that might pause the video (mouse-click will, but touch-tap won't),
// here let the video keep playing anyway when switch the quality is finished.
if err := uiauto.IfSuccessThen(
y.ui.WithTimeout(3*time.Second).WaitUntilExists(video),
y.uiHdl.Click(video),
)(ctx); err != nil {
return errors.Wrap(err, "failed to ensure video is playing after show setting panel")
}
return nil
}
if err := switchQuality(y.video.Quality); err != nil {
return errors.Wrapf(err, "failed to switch resolution to %s", y.video.Quality)
}
y.ytWinID, err = getWindowID(ctx, y.tconn)
if err != nil {
return errors.Wrap(err, "failed to get window ID")
}
return nil
}
// EnterFullscreen switches youtube video to fullscreen.
func (y *YtWeb) EnterFullscreen(ctx context.Context) error {
testing.ContextLog(ctx, "Make Youtube video fullscreen")
if ytWin, err := ash.GetWindow(ctx, y.tconn, y.ytWinID); err != nil {
return errors.Wrap(err, "failed to get youtube window")
} else if ytWin.State == ash.WindowStateFullscreen {
return nil
}
// Notification prompts are sometimes shown in fullscreen.
prompts := []string{"Allow", "Never", "NO THANKS"}
clearNotificationPrompts(ctx, y.ui, y.uiHdl, prompts...)
fullscreenBtn := nodewith.Name("Full screen (f)").Role(role.Button)
if err := y.ui.DoDefault(fullscreenBtn)(ctx); err != nil {
return errors.Wrap(err, "failed to click fullscreen button")
}
if err := waitWindowStateFullscreen(ctx, y.tconn, y.ytWinID); err != nil {
return errors.Wrap(err, "failed to tap fullscreen button")
}
if err := waitForYoutubeReadyState(ctx, y.ytConn); err != nil {
return errors.Wrap(err, "failed to wait for Youtube ready state")
}
return nil
}
// SkipAd skips the ad.
func (y *YtWeb) SkipAd() uiauto.Action {
return func(ctx context.Context) error {
testing.ContextLog(ctx, "Checking for YouTube ads")
adText := nodewith.NameContaining("Ad").Role(role.StaticText).Ancestor(videoPlayer).First()
skipAdButton := nodewith.NameStartingWith("Skip Ad").Role(role.Button)
return testing.Poll(ctx, func(ctx context.Context) error {
if err := y.ui.WithTimeout(shortUITimeout).WaitUntilExists(adText)(ctx); err != nil {
testing.ContextLog(ctx, "No ads found")
return nil
}
if err := y.ui.Exists(skipAdButton)(ctx); err != nil {
return errors.Wrap(err, "'Skip Ads' button not available yet")
}
if err := y.uiHdl.Click(skipAdButton)(ctx); err != nil {
return errors.Wrap(err, "failed to click 'Skip Ads'")
}
return errors.New("have not determined whether the ad has been skipped successfully")
}, &testing.PollOptions{Timeout: time.Minute})
}
}
// MaximizeWindow maximizes the youtube video.
func (y *YtWeb) MaximizeWindow(ctx context.Context) error {
testing.ContextLog(ctx, "Maximize Youtube video window")
if ytWin, err := ash.GetWindow(ctx, y.tconn, y.ytWinID); err != nil {
return errors.Wrap(err, "failed to get youtube window")
} else if ytWin.State == ash.WindowStateMaximized {
return nil
}
maximizeButton := nodewith.Name("Maximize").HasClass("FrameCaptionButton").Role(role.Button)
if err := y.uiHdl.Click(maximizeButton)(ctx); err != nil {
return errors.Wrap(err, "failed to maximize the window")
}
if err := ash.WaitForCondition(ctx, y.tconn, func(w *ash.Window) bool {
return w.ID == y.ytWinID && w.State == ash.WindowStateMaximized && !w.IsAnimating
}, &testing.PollOptions{Timeout: 5 * time.Second}); err != nil {
return errors.Wrap(err, "failed to wait for window to become maximized")
}
return nil
}
// MinimizeWindow minimizes the youtube video.
func (y *YtWeb) MinimizeWindow(ctx context.Context) error {
testing.ContextLog(ctx, "Minimize Youtube video window")
if ytWin, err := ash.GetWindow(ctx, y.tconn, y.ytWinID); err != nil {
return errors.Wrap(err, "failed to get youtube window")
} else if ytWin.State == ash.WindowStateMinimized {
return nil
}
minimizeButton := nodewith.Name("Minimize").HasClass("FrameCaptionButton").Role(role.Button)
if err := y.uiHdl.Click(minimizeButton)(ctx); err != nil {
return errors.Wrap(err, "failed to minimize the window")
}
if err := ash.WaitForCondition(ctx, y.tconn, func(w *ash.Window) bool {
return w.ID == y.ytWinID && w.State == ash.WindowStateMinimized && !w.IsAnimating
}, &testing.PollOptions{Timeout: 5 * time.Second}); err != nil {
return errors.Wrap(err, "failed to wait for window to become minimized")
}
return nil
}
// RestoreWindow restores the youtube video to normal state.
func (y *YtWeb) RestoreWindow(ctx context.Context) error {
testing.ContextLog(ctx, "Restore Youtube video window")
if _, err := ash.SetWindowState(ctx, y.tconn, y.ytWinID, ash.WMEventNormal, true /* waitForStateChange */); err != nil {
return errors.Wrap(err, "failed to set the window state to normal")
}
if err := ash.WaitForCondition(ctx, y.tconn, func(w *ash.Window) bool {
return w.ID == y.ytWinID && w.State == ash.WindowStateNormal && !w.IsAnimating
}, &testing.PollOptions{Timeout: 5 * time.Second}); err != nil {
return errors.Wrap(err, "failed to wait for window to become normal")
}
return nil
}
// PauseAndPlayVideo verifies video playback on youtube web.
func (y *YtWeb) PauseAndPlayVideo(ctx context.Context) error {
testing.ContextLog(ctx, "Pause and play video")
// The video should be playing at this point. However, we'll double check to make sure
// as we have seen a few cases where the video became paused automatically.
if err := y.Play()(ctx); err != nil {
return errors.Wrap(err, "failed to play the video")
}
return uiauto.Combine("check the playing status of youtube video",
y.SkipAd(),
y.Pause(),
y.Play(),
)(ctx)
}
// Play plays the video by clicking the video itself. If the video has already started playing, the function does nothing.
func (y *YtWeb) Play() uiauto.Action {
return func(ctx context.Context) error {
if err := y.IsPlaying()(ctx); err != nil {
actionName := "play video"
return uiauto.NamedAction(actionName, uiauto.Combine(actionName,
y.ui.MouseMoveTo(video, mouseMoveDuration),
y.ui.LeftClick(video),
))(ctx)
}
return nil
}
}
// Pause pauses the video by clicking the video itself.
func (y *YtWeb) Pause() uiauto.Action {
return func(ctx context.Context) error {
if err := y.IsPaused()(ctx); err != nil {
actionName := "pause video"
return uiauto.NamedAction(actionName, uiauto.Combine(actionName,
y.ui.MouseMoveTo(video, mouseMoveDuration),
y.ui.LeftClick(video),
))(ctx)
}
return nil
}
}
// IsPlaying checks if the video is playing now.
func (y *YtWeb) IsPlaying() uiauto.Action {
return func(ctx context.Context) error {
previousTime, err := y.getCurrentTime(ctx)
if err != nil {
return err
}
return testing.Poll(ctx, func(ctx context.Context) error {
currentTime, err := y.getCurrentTime(ctx)
if err != nil {
return err
}
if currentTime > previousTime {
return nil
}
return errors.New("youtube is not playing")
}, &testing.PollOptions{Timeout: 10 * time.Second})
}
}
// IsPaused checks if the video is paused now.
func (y *YtWeb) IsPaused() uiauto.Action {
return func(ctx context.Context) error {
previousTime, err := y.getCurrentTime(ctx)
if err != nil {
return err
}
// Considering that the paused action might not react immediately to the low-end DUTs.
// If the "paused" reaches the threshold, it means the video is actually paused.
threshold := 5
paused := 0
return testing.Poll(ctx, func(ctx context.Context) error {
currentTime, err := y.getCurrentTime(ctx)
if err != nil {
return err
}
if currentTime == previousTime {
paused++
}
if paused >= threshold {
return nil
}
return errors.New("youtube is not paused")
}, &testing.PollOptions{Timeout: 10 * time.Second})
}
}
// waitForYoutubeReadyState does wait youtube video ready state then return.
func waitForYoutubeReadyState(ctx context.Context, conn *chrome.Conn) error {
startTime := time.Now()
// Wait for element to appear.
return testing.Poll(ctx, func(ctx context.Context) error {
// Querying the main <video> node in youtube page.
var state bool
if err := conn.Call(ctx, &state, `() => {
let video = document.querySelector("#movie_player > div.html5-video-container > video");
return video.readyState === 4 && video.buffered.length > 0;
}`); err != nil {
return err
}
if !state {
return errors.New("failed to wait for youtube on ready state")
}
testing.ContextLogf(ctx, "Elapsed time when waiting for youtube ready state: %.3f s", time.Since(startTime).Seconds())
return nil
}, &testing.PollOptions{Interval: time.Second, Timeout: time.Minute})
}
// Close closes the resources related to video.
func (y *YtWeb) Close(ctx context.Context) {
if y.ytConn != nil {
y.ytConn.CloseTarget(ctx)
y.ytConn.Close()
y.ytConn = nil
}
}
// getCurrentTime gets the current video time in seconds.
func (y *YtWeb) getCurrentTime(ctx context.Context) (int, error) {
settings := nodewith.Name("Settings").Role(role.PopUpButton).Ancestor(videoPlayer)
timeNode := nodewith.NameRegex(regexp.MustCompile("((\\d+):)?(\\d+):(\\d+)$")).Role(role.InlineTextBox).First()
if err := uiauto.Combine("make youtube video play time pop up",
y.ui.MouseMoveTo(settings, mouseMoveDuration),
y.ui.MouseMoveTo(videoPlayer, mouseMoveDuration),
y.ui.WaitUntilExists(timeNode),
)(ctx); err != nil {
return 0, err
}
node, err := y.ui.Info(ctx, timeNode)
if err != nil {
return 0, err
}
timeFormat := "4:05"
if len(node.Name) > 5 { // If the playback time exceeds an hour.
timeFormat = "15:04:05"
}
videoTime, err := time.Parse(timeFormat, node.Name)
if err != nil {
return 0, err
}
return videoTime.Hour()*60*60 + videoTime.Minute()*60 + videoTime.Second(), nil
}
// clearNotificationPrompts finds and clears some youtube web prompts.
func clearNotificationPrompts(ctx context.Context, ui *uiauto.Context, uiHdl cuj.UIActionHandler, prompts ...string) error {
for _, name := range prompts {
tartgetPrompt := nodewith.Name(name).Role(role.Button)
if err := uiauto.IfSuccessThen(
ui.WaitUntilExists(tartgetPrompt),
uiHdl.ClickUntil(tartgetPrompt, ui.WithTimeout(shortUITimeout).WaitUntilGone(tartgetPrompt)),
)(ctx); err != nil {
testing.ContextLogf(ctx, "Failed to clear prompt %q", name)
return err
}
}
return nil
}
// PerformFrameDropsTest checks for dropped frames percent and checks if it is below the threshold.
func (y *YtWeb) PerformFrameDropsTest(ctx context.Context) error {
// If we see more than 10% video frame drops it will be visible to the user and will impact the viewing experience.
const frameDropThreshold float64 = 10.0
var decodedFrameCount, droppedFrameCount int
videoElement := "document.querySelector('#movie_player video')"
if err := y.ytConn.Eval(ctx, videoElement+".getVideoPlaybackQuality().totalVideoFrames", &decodedFrameCount); err != nil {
return errors.Wrap(err, "failed to get decoded framecount")
}
if err := y.ytConn.Eval(ctx, videoElement+".getVideoPlaybackQuality().droppedVideoFrames", &droppedFrameCount); err != nil {
return errors.Wrap(err, "failed to get dropped framecount")
}
droppedFramePercent := 100.0
if decodedFrameCount != 0 {
droppedFramePercent = 100.0 * float64(droppedFrameCount/decodedFrameCount)
}
if droppedFramePercent > frameDropThreshold {
return errors.Errorf("frame drops rate %.2f (dropped %d, decoded %d) higher than allowed threshold %.2f", droppedFramePercent, droppedFrameCount, decodedFrameCount, frameDropThreshold)
}
return nil
}
// YtWebConn returns connection of youtube web.
func (y *YtWeb) YtWebConn() *chrome.Conn {
return y.ytConn
}