blob: fed2fd159393a31a6dd442c6b4e4c14a952adc85 [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 ui
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/golang/protobuf/ptypes/empty"
"google.golang.org/grpc"
"chromiumos/tast/ctxutil"
"chromiumos/tast/errors"
"chromiumos/tast/local/bundles/cros/ui/conference"
"chromiumos/tast/local/bundles/cros/ui/cuj"
"chromiumos/tast/local/chrome"
"chromiumos/tast/local/chrome/ash"
"chromiumos/tast/local/chrome/display"
"chromiumos/tast/local/chrome/lacros"
"chromiumos/tast/local/chrome/lacros/lacrosfixt"
pb "chromiumos/tast/services/cros/ui"
"chromiumos/tast/testing"
)
func init() {
testing.AddService(&testing.Service{
Register: func(srv *grpc.Server, s *testing.ServiceState) {
pb.RegisterConferenceServiceServer(srv, &ConferenceService{s: s})
},
Vars: []string{
// mode is optional. Expecting "tablet" or "clamshell".
"ui.cuj_mode",
// CrOS login credentials.
"ui.cujAccountPool",
// Credentials used to join Google Meet. It might be different with CrOS login credentials.
"ui.meet_account",
"ui.meet_password",
// Static Google meet rooms with different participant number have been created.
// They have different URLs. ui.meet_url can be used to run a specific subtest but
// assigning urls to different vars will be easier when running with ui.GoogleMeetCUJ.*.
// Each of the folliwng vars can be assigned with mutiple URLs, seperated by comma.
// Test can retry another url if one fails.
// - Primary URLs: use these URLs first.
"ui.meet_url",
"ui.meet_url_two",
"ui.meet_url_small",
"ui.meet_url_large",
"ui.meet_url_class",
// - Secondary URLs: only used when primary ones fail.
"ui.meet_url_secondary",
"ui.meet_url_two_secondary",
"ui.meet_url_small_secondary",
"ui.meet_url_large_secondary",
"ui.meet_url_class_secondary",
// The total timeout and inteval when trying different URLs if one fails.
"ui.meet_url_retry_timeout",
"ui.meet_url_retry_interval",
// Zoom meet bot server address.
"ui.zoom_bot_server",
"ui.zoom_bot_token",
},
})
}
type ConferenceService struct {
s *testing.ServiceState
}
func confereceChromeOpts(accountPool, cameraVideoPath string) []chrome.Option {
chromeArgs := chromeArgsWithFileCameraInput(cameraVideoPath)
return []chrome.Option{
// Make sure we are running new chrome UI when tablet mode is enabled by CUJ test.
// Remove this when new UI becomes default.
chrome.EnableFeatures("WebUITabStrip"),
chrome.KeepState(),
chrome.ARCSupported(),
chrome.GAIALoginPool(accountPool),
chrome.ExtraArgs(chromeArgs...)}
}
// chromeArgsWithFileCameraInput returns Chrome extra args as string slice
// for video test with a Y4M/MJPEG fileName streamed as live camera input.
func chromeArgsWithFileCameraInput(fileName string) []string {
if fileName == "" {
return []string{}
}
return []string{
// See https://webrtc.github.io/webrtc-org/testing/.
// Feed a test pattern to getUserMedia() instead of live camera input.
"--use-fake-device-for-media-stream",
// Feed a Y4M/MJPEG test file to getUserMedia() instead of live camera input.
"--use-file-for-fake-video-capture=" + fileName,
}
}
// newConferenceChrome returns a new Chrome instance with custom options for confernce cuj,
// including setting whether to use fake camera and lacros browser.
func newConferenceChrome(ctx context.Context, accountPool, cameraVideoPath string, isLacros bool) (cr *chrome.Chrome, err error) {
opts := confereceChromeOpts(accountPool, cameraVideoPath)
if isLacros {
opts = append(opts, chrome.LacrosExtraArgs("--enable-features=WebUITabStrip")) // Enable TabStrip UI for lacros.
cfg := &lacrosfixt.Config{}
lacrosfixt.Selection(lacros.Rootfs)(cfg)
lacrosfixt.Mode(lacros.LacrosPrimary)(cfg)
lacrosfixt.KeepAlive(false)(cfg)
lacrosOpts, err := cfg.Opts()
if err != nil {
return cr, err
}
opts = append(opts, lacrosOpts...)
}
cr, err = chrome.New(ctx, opts...)
if err != nil {
return cr, errors.Wrap(err, "failed to restart Chrome")
}
return cr, nil
}
func (s *ConferenceService) RunGoogleMeetScenario(ctx context.Context, req *pb.MeetScenarioRequest) (*empty.Empty, error) {
roomSize := int(req.RoomSize)
meet, err := conference.GetGoogleMeetConfig(ctx, s.s, roomSize)
if err != nil {
return nil, errors.Wrap(err, "failed to get meet config")
}
outDir, ok := testing.ContextOutDir(ctx)
if !ok {
return nil, errors.New("failed to get outdir from context")
}
run := func(ctx context.Context, roomURL string) error {
accountPool, ok := s.s.Var("ui.cujAccountPool")
if !ok {
return errors.New("failed to get variable ui.cujAccountPool")
}
isLacros := req.IsLacros
cr, err := newConferenceChrome(ctx, accountPool, req.CameraVideoPath, isLacros)
if err != nil {
return errors.Wrap(err, "failed to new Chrome")
}
tconn, err := cr.TestAPIConn(ctx)
if err != nil {
return errors.Wrap(err, "failed to connect to test API")
}
var tabletMode bool
cleanupCtx := ctx
ctx, cancelTablet := ctxutil.Shorten(ctx, 5*time.Second)
defer cancelTablet()
if mode, ok := s.s.Var("ui.cuj_mode"); ok {
tabletMode = mode == "tablet"
cleanup, err := ash.EnsureTabletModeEnabled(ctx, tconn, tabletMode)
if err != nil {
return errors.Wrapf(err, "failed to enable tablet mode to %v", tabletMode)
}
defer cleanup(cleanupCtx)
} else {
// Use default screen mode of the DUT.
tabletMode, err = ash.TabletModeEnabled(ctx, tconn)
if err != nil {
return errors.Wrap(err, "failed to get DUT default screen mode")
}
}
testing.ContextLog(ctx, "Running test with tablet mode: ", tabletMode)
var uiHandler cuj.UIActionHandler
if tabletMode {
cleanup, err := display.RotateToLandscape(ctx, tconn)
if err != nil {
return errors.Wrap(err, "failed to rotate display to landscape")
}
defer cleanup(cleanupCtx)
if uiHandler, err = cuj.NewTabletActionHandler(ctx, tconn); err != nil {
return errors.Wrap(err, "failed to create tablet action handler")
}
} else {
if uiHandler, err = cuj.NewClamshellActionHandler(ctx, tconn); err != nil {
return errors.Wrap(err, "failed to create clamshell action handler")
}
}
if req.ExtendedDisplay {
// Unset mirrored display so two displays can show different information.
if err := cuj.UnsetMirrorDisplay(ctx, tconn); err != nil {
return errors.Wrap(err, "failed to unset mirror display")
}
// Make sure there are two displays on DUT.
// This procedure must be performed after display mirror is unset. Otherwise we can only
// get one display info.
infos, err := display.GetInfo(ctx, tconn)
if err != nil {
return errors.Wrap(err, "failed to get display info")
}
if len(infos) != 2 {
return errors.Errorf("expect 2 displays but got %d", len(infos))
}
}
prepare := func(ctx context.Context) (string, conference.Cleanup, error) {
cleanup := func(ctx context.Context) (err error) {
// Nothing to clean up at the end of Google Meet conference.
return nil
}
if roomSize != conference.NoRoom && roomURL == "" {
return "", nil, errors.New("the conference invite link is empty")
}
return roomURL, cleanup, nil
}
// Creates a Google Meet conference instance which implements conference.Conference methods
// which provides conference operations.
gmcli := conference.NewGoogleMeetConference(cr, tconn, uiHandler, tabletMode, req.ExtendedDisplay, isLacros, int(req.RoomSize), meet.Account, meet.Password, outDir)
defer gmcli.End(ctx)
// Shorten context a bit to allow for cleanup if Run fails.
ctx, cancel := ctxutil.Shorten(ctx, 3*time.Second)
defer cancel()
if err := conference.Run(ctx, cr, gmcli, prepare, req.Tier, outDir, tabletMode, isLacros, roomSize); err != nil {
return errors.Wrap(err, "failed to run Google Meet conference")
}
return nil
}
if roomSize == conference.NoRoom {
// Without Google Meet, there is no need to assign a meet url.
if err := run(ctx, ""); err != nil {
testing.ContextLogf(ctx, "Failed to run conference: %+v", err)
return nil, err
}
return &empty.Empty{}, nil
}
runWithMeetUrls := func(ctx context.Context) error {
var err error
for _, url := range meet.URLs {
testing.ContextLog(ctx, "URL to be tested in the meet url list: ", url)
err = run(ctx, url)
if err == nil {
return nil
}
if !conference.IsParticipantError(err) {
return err
}
}
return err
}
// If meet.RetryTimeout equal to 0, don't do any retry.
if meet.RetryTimeout == 0 {
testing.ContextLog(ctx, "Start running meet scenario")
if err := runWithMeetUrls(ctx); err != nil {
testing.ContextLogf(ctx, "Failed to run conference: %+v", err) // Print error with stack trace.
return nil, err
}
return &empty.Empty{}, nil
}
var lastError error
startTime := time.Now()
if err := testing.Poll(ctx, func(ctx context.Context) error {
if err := runWithMeetUrls(ctx); err != nil {
elapsedTime := time.Now().Sub(startTime)
if elapsedTime < meet.RetryTimeout {
// Record the complete run result if the failure is not because of timeout.
lastError = err
}
if conference.IsParticipantError(err) {
testing.ContextLogf(ctx, "Wait %v and try to run meet scenario again", meet.RetryInterval)
return err
}
return testing.PollBreak(err) // Break if error is not participant number related.
}
return nil
}, &testing.PollOptions{Timeout: meet.RetryTimeout, Interval: meet.RetryInterval}); err != nil {
// Return test failure reason of last complete run.
if lastError != nil {
err = lastError
}
testing.ContextLogf(ctx, "Failed to run conference: %+v", err) // Print error with stack trace.
return nil, err
}
return &empty.Empty{}, nil
}
func (s *ConferenceService) RunZoomScenario(ctx context.Context, req *pb.MeetScenarioRequest) (*empty.Empty, error) {
type responseData struct {
URL string `json:"url"`
RoomID string `json:"room_id"`
Err string `json:"err"`
}
runConferenceAPI := func(ctx context.Context, sessionToken, host, api, parameterString string) (*responseData, error) {
reqURL := fmt.Sprintf("%s/api/room/zoom/%s%s&iszoomcase=true", host, api, parameterString)
testing.ContextLog(ctx, "Requesting a zoom room from the zoom bot server with request URL: ", reqURL)
httpReq, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "Bearer "+sessionToken)
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, errors.Errorf("failed to get zoom conference invite link with status %d and body %s", resp.StatusCode, body)
}
var data *responseData
if err := json.Unmarshal([]byte(body), &data); err != nil {
return nil, err
}
if data.Err != "" {
return data, errors.New(data.Err)
}
return data, nil
}
outDir, ok := testing.ContextOutDir(ctx)
if !ok {
return nil, errors.New("failed to get outdir from context")
}
accountPool, ok := s.s.Var("ui.cujAccountPool")
if !ok {
return nil, errors.New("failed to get variable ui.cujAccountPool")
}
host, ok := s.s.Var("ui.zoom_bot_server")
if !ok {
return nil, errors.New("failed to get variable ui.zoom_bot_server")
}
sessionToken, ok := s.s.Var("ui.zoom_bot_token")
if !ok {
return nil, errors.New("failed to get variable ui.zoom_bot_token")
}
testing.ContextLog(ctx, "Start zoom meet scenario")
isLacros := req.IsLacros
cr, err := newConferenceChrome(ctx, accountPool, req.CameraVideoPath, isLacros)
if err != nil {
return nil, errors.Wrap(err, "failed to new Chrome")
}
account := cr.Creds().User
tconn, err := cr.TestAPIConn(ctx)
if err != nil {
return nil, errors.Wrap(err, "failed to connect to test API")
}
var tabletMode bool
cleanupCtx := ctx
ctx, cancelTablet := ctxutil.Shorten(ctx, 5*time.Second)
defer cancelTablet()
if mode, ok := s.s.Var("ui.cuj_mode"); ok {
tabletMode = mode == "tablet"
cleanup, err := ash.EnsureTabletModeEnabled(ctx, tconn, tabletMode)
if err != nil {
return nil, errors.Wrapf(err, "failed to enable tablet mode to %v", tabletMode)
}
defer cleanup(cleanupCtx)
} else {
// Use default screen mode of the DUT.
tabletMode, err = ash.TabletModeEnabled(ctx, tconn)
if err != nil {
return nil, errors.Wrap(err, "failed to get DUT default screen mode")
}
}
var uiHandler cuj.UIActionHandler
if tabletMode {
cleanup, err := display.RotateToLandscape(ctx, tconn)
if err != nil {
return nil, errors.Wrap(err, "failed to rotate display to landscape")
}
defer cleanup(cleanupCtx)
if uiHandler, err = cuj.NewTabletActionHandler(ctx, tconn); err != nil {
return nil, errors.Wrap(err, "failed to create tablet action handler")
}
} else {
if uiHandler, err = cuj.NewClamshellActionHandler(ctx, tconn); err != nil {
return nil, errors.Wrap(err, "failed to create clamshell action handler")
}
}
// Creates a Zoom conference instance which implements conference.Conference methods.
// which provides conference operations.
zmcli := conference.NewZoomConference(cr, tconn, uiHandler, tabletMode, int(req.RoomSize), account, outDir)
defer zmcli.End(ctx)
// Sends a http request that ask for creating a Zoom conferece with
// specified participants and also return clean up method for closing
// opened conference.
//
// Assume there's a Zoom proxy which can receive http request for
// creating/closing Zoom conference. When Zoom proxy receives "createaio"
// request, it would create a Zoom conference on specified remote server
// with participants via Chrome Devtools Protocols. And "endaio" means close
// the conference which opened by "createaio".
prepare := func(ctx context.Context) (string, conference.Cleanup, error) {
var data *responseData
roomSize := strconv.FormatInt(req.RoomSize-1, 10)
// Create a Zoom conference on remote server dynamically and get conference room
// link. Retry three times until it successfully gets a conference room link.
const retryCount = 3
for i := 0; i < retryCount; i++ {
testing.ContextLogf(ctx, "Attempt #%d to get conference room API", i+1)
// Use the remaining time of the case to set the existence time of the room.
deadline, _ := ctx.Deadline()
maxDuration := math.Ceil(deadline.Sub(time.Now()).Minutes())
parameterString := fmt.Sprintf("?count=%s&max_duration=%v", roomSize, maxDuration)
testing.ContextLogf(ctx, "Create a %s-person zoom room that can exist for %v minutes", roomSize, maxDuration)
if data, err = runConferenceAPI(ctx, sessionToken, host, "createaio", parameterString); err == nil {
break
}
testing.ContextLog(ctx, "Failed to get conference room: ", err)
}
if err != nil {
return "", nil, errors.Wrap(err, "failed to create multiple participants room")
}
// We expect the returned body is a valid url that can be used to issue chatroom request.
// Check the format.
room := strings.TrimSpace(string(data.URL))
if _, err := url.ParseRequestURI(room); err != nil {
return "", nil, errors.Errorf("returned zoom conference invite link %s is not a valid url", room)
}
cleanup := func(ctx context.Context) (err error) {
_, err = runConferenceAPI(ctx, sessionToken, host, "endaio", "?room_id="+data.RoomID)
return
}
return room, cleanup, nil
}
// Shorten context a bit to allow for cleanup if Run fails.
ctx, cancel := ctxutil.Shorten(ctx, 3*time.Second)
defer cancel()
if err := conference.Run(ctx, cr, zmcli, prepare, req.Tier, outDir, tabletMode, isLacros, int(req.RoomSize)); err != nil {
return nil, errors.Wrap(err, "failed to run Zoom conference")
}
return &empty.Empty{}, nil
}