| // Copyright 2022 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" |
| "fmt" |
| "path/filepath" |
| "time" |
| |
| "chromiumos/tast/common/action" |
| "chromiumos/tast/common/bond" |
| "chromiumos/tast/common/perf" |
| "chromiumos/tast/ctxutil" |
| "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/display" |
| "chromiumos/tast/local/chrome/lacros" |
| "chromiumos/tast/local/chrome/uiauto" |
| "chromiumos/tast/local/chrome/uiauto/faillog" |
| "chromiumos/tast/local/chrome/uiauto/mouse" |
| "chromiumos/tast/local/chrome/uiauto/nodewith" |
| "chromiumos/tast/local/chrome/uiauto/pointer" |
| "chromiumos/tast/local/chrome/uiauto/role" |
| "chromiumos/tast/local/coords" |
| "chromiumos/tast/local/input" |
| "chromiumos/tast/testing" |
| "chromiumos/tast/testing/hwdep" |
| ) |
| |
| func init() { |
| testing.AddTest(&testing.Test{ |
| Func: MeetMultiTaskingCUJ, |
| LacrosStatus: testing.LacrosVariantExists, |
| Desc: "Measures the total performance of multi-tasking with video conferencing CUJ", |
| Contacts: []string{"yichenz@chromium.org", "chromeos-perfmetrics-eng@google.com"}, |
| Attr: []string{"group:cuj"}, |
| SoftwareDeps: []string{"chrome"}, |
| HardwareDeps: hwdep.D(hwdep.InternalDisplay()), |
| Timeout: 10 * time.Minute, |
| Vars: []string{ |
| "record", |
| }, |
| VarDeps: []string{ |
| "ui.bond_credentials", |
| }, |
| Params: []testing.Param{{ |
| Val: browser.TypeAsh, |
| Fixture: "loggedInToCUJUser", |
| }, { |
| Name: "lacros", |
| Val: browser.TypeLacros, |
| Fixture: "loggedInToCUJUserLacros", |
| ExtraSoftwareDeps: []string{"lacros"}, |
| }}, |
| }) |
| } |
| |
| // MeetMultiTaskingCUJ measures the performance of critical user journeys for multi-tasking with video conference. |
| // |
| // Pre-preparation: |
| // - Open a Meet window and grant permissions. |
| // During recording: |
| // - Join the meeting. |
| // - Add a participant (bot) to the meeting. |
| // - Open a large Google Docs file and scroll down. |
| // - Open a large Google Slides file and go down. |
| // - Open the Gmail inbox and scroll down. |
| // After recording: |
| // - Record and save metrics. |
| func MeetMultiTaskingCUJ(ctx context.Context, s *testing.State) { |
| const ( |
| timeout = 10 * time.Second |
| docsURL = "https://docs.google.com/document/d/1NvbdoWF6OrZxenReot5HptK0xvmzK1WKY5TgifoQtko/edit?usp=sharing" |
| docsScrollTimeout = 30 * time.Second |
| slidesURL = "https://docs.google.com/presentation/d/1lItrhkgBqXF_bsP-tOqbjcbBFa86--m3DT5cLxegR2k/edit?usp=sharing&resourcekey=0-FmuN4N-UehRS2q4CdQzRXA" |
| slidesScrollTimeout = 30 * time.Second |
| gmailURL = "https://gmail.com" |
| gmailScrollTimeout = 10 * time.Second |
| newTabTitle = "New Tab" |
| meetTimeout = 2 * time.Minute |
| meetLayout = "Auto" |
| ) |
| |
| // Shorten context a bit to allow for cleanup. |
| closeCtx := ctx |
| ctx, cancel := ctxutil.Shorten(ctx, 10*time.Second) |
| defer cancel() |
| |
| bt := s.Param().(browser.Type) |
| |
| cr := s.FixtValue().(chrome.HasChrome).Chrome() |
| |
| tconn, err := cr.TestAPIConn(ctx) |
| if err != nil { |
| s.Fatal("Failed to connect to the test API connection: ", err) |
| } |
| |
| var l *lacros.Lacros |
| var cs ash.ConnSource |
| var bTconn *chrome.TestConn |
| switch bt { |
| case browser.TypeLacros: |
| var err error |
| if cr, l, cs, err = lacros.Setup(ctx, s.FixtValue(), browser.TypeLacros); err != nil { |
| s.Fatal("Failed to initialize test: ", err) |
| } |
| if bTconn, err = l.TestAPIConn(ctx); err != nil { |
| s.Fatal("Failed to get lacros TestAPIConn: ", err) |
| } |
| defer lacros.CloseLacros(closeCtx, l) |
| case browser.TypeAsh: |
| cs = cr |
| bTconn = tconn |
| default: |
| s.Fatal("Unrecognized browser type: ", bt) |
| } |
| |
| if _, ok := s.Var("record"); ok { |
| screenRecorder, err := uiauto.NewScreenRecorder(ctx, tconn) |
| if err != nil { |
| s.Fatal("Failed to create ScreenRecorder: ", err) |
| } |
| if err := screenRecorder.Start(ctx, tconn); err != nil { |
| screenRecorder.Release(closeCtx) |
| s.Fatal("Failed to start ScreenRecorder: ", err) |
| } |
| defer uiauto.ScreenRecorderStopSaveRelease(closeCtx, screenRecorder, filepath.Join(s.OutDir(), "screen_record.webm")) |
| } |
| |
| inTabletMode, err := ash.TabletModeEnabled(ctx, tconn) |
| if err != nil { |
| s.Fatal("Failed to detect it is in tablet-mode or not: ", err) |
| } |
| var pc pointer.Context |
| if inTabletMode { |
| // If it is in tablet mode, ensure it it in landscape orientation. |
| orientation, err := display.GetOrientation(ctx, tconn) |
| if err != nil { |
| s.Fatal("Failed to get display orientation: ", err) |
| } |
| if orientation.Type == display.OrientationPortraitPrimary { |
| info, err := display.GetPrimaryInfo(ctx, tconn) |
| if err != nil { |
| s.Fatal("Failed to get the primary display info: ", err) |
| } |
| s.Log("Rotating display 90 degrees") |
| if err := display.SetDisplayRotationSync(ctx, tconn, info.ID, display.Rotate90); err != nil { |
| s.Fatal("Failed to rotate display: ", err) |
| } |
| defer display.SetDisplayRotationSync(closeCtx, tconn, info.ID, display.Rotate0) |
| } |
| pc, err = pointer.NewTouch(ctx, tconn) |
| if err != nil { |
| s.Fatal("Failed to create a touch controller: ", err) |
| } |
| } else { |
| // Make it into a maximized window if it is in clamshell-mode. |
| if err := ash.ForEachWindow(ctx, tconn, func(w *ash.Window) error { |
| return ash.SetWindowStateAndWait(ctx, tconn, w.ID, ash.WindowStateMaximized) |
| }); err != nil { |
| s.Fatal("Failed to turn all windows into maximized state: ", err) |
| } |
| pc = pointer.NewMouse(tconn) |
| } |
| defer pc.Close() |
| |
| creds := s.RequiredVar("ui.bond_credentials") |
| bc, err := bond.NewClient(ctx, bond.WithCredsJSON([]byte(creds))) |
| if err != nil { |
| s.Fatal("Failed to create a bond client: ", err) |
| } |
| defer bc.Close() |
| |
| var meetingCode string |
| { |
| sctx, cancel := context.WithTimeout(ctx, timeout) |
| defer cancel() |
| meetingCode, err = bc.CreateConference(sctx) |
| if err != nil { |
| s.Fatal("Failed to create a conference room: ", err) |
| } |
| } |
| s.Log("Created a room with the code ", meetingCode) |
| |
| sctx, cancel := context.WithTimeout(ctx, 30*time.Second) |
| defer cancel() |
| // Add 30 seconds to the bot duration to make sure that bots do not leave |
| // slightly earlier than the test scenario. |
| if _, _, err := bc.AddBots(sctx, meetingCode, 1, meetTimeout+30*time.Second); err != nil { |
| s.Fatal("Failed to create 1 bot: ", err) |
| } |
| defer func(ctx context.Context) { |
| s.Log("Removing all bots from the call") |
| if _, _, err := bc.RemoveAllBots(ctx, meetingCode); err != nil { |
| s.Fatal("Failed to remove all bots: ", err) |
| } |
| }(closeCtx) |
| |
| meetConn, err := cs.NewConn(ctx, "https://meet.google.com/"+meetingCode, browser.WithNewWindow()) |
| if err != nil { |
| s.Fatal("Failed to open the hangout meet website: ", err) |
| } |
| defer meetConn.Close() |
| defer meetConn.CloseTarget(closeCtx) |
| defer faillog.DumpUITreeOnError(closeCtx, s.OutDir(), s.HasError, tconn) |
| |
| // Lacros specific setup. |
| if bt == browser.TypeLacros { |
| if err := l.Browser().CloseWithURL(ctx, chrome.NewTabURL); err != nil { |
| s.Fatal("Failed to close blank tab: ", err) |
| } |
| } |
| |
| // Create a virtual trackpad. |
| tpw, err := input.Trackpad(ctx) |
| if err != nil { |
| s.Fatal("Failed to create a trackpad device: ", err) |
| } |
| defer tpw.Close() |
| tw, err := tpw.NewMultiTouchWriter(2) |
| if err != nil { |
| s.Fatal("Failed to create a multi touch writer: ", err) |
| } |
| defer tw.Close() |
| |
| // Create a virtual keyboard. |
| kw, err := input.Keyboard(ctx) |
| if err != nil { |
| s.Fatal("Failed to create a keyboard: ", err) |
| } |
| defer kw.Close() |
| |
| ui := uiauto.New(tconn) |
| |
| // Find the web view of Meet window. |
| webview := nodewith.ClassName("ContentsWebView").Role(role.WebView) |
| |
| uiLongWait := ui.WithTimeout(time.Minute) |
| bubble := nodewith.ClassName("PermissionPromptBubbleView").First() |
| allow := nodewith.Name("Allow").Role(role.Button).Ancestor(bubble) |
| // Check and grant permissions. |
| if err := testing.Poll(ctx, func(ctx context.Context) error { |
| // Long wait for permission bubble and break poll loop when it times out. |
| if uiLongWait.WaitUntilExists(bubble)(ctx) != nil { |
| return nil |
| } |
| if err := pc.Click(allow)(ctx); err != nil { |
| return errors.Wrap(err, "failed to click the allow button") |
| } |
| return errors.New("granting permissions") |
| }, &testing.PollOptions{Interval: time.Second, Timeout: 2 * time.Minute}); err != nil { |
| s.Fatal("Failed to grant permissions: ", err) |
| } |
| |
| configs := []cuj.MetricConfig{ |
| // Ash metrics config, always collected from ash-chrome. |
| cuj.NewCustomMetricConfig( |
| "Ash.Smoothness.PercentDroppedFrames_1sWindow", "percent", |
| perf.SmallerIsBetter, []int64{50, 80}), |
| cuj.NewCustomMetricConfig( |
| "Browser.Responsiveness.JankyIntervalsPerThirtySeconds3", "janks", |
| perf.SmallerIsBetter, []int64{0, 3}), |
| // Browser metrics config, collected from ash-chrome or lacros-chrome |
| // depending on the browser being used. |
| cuj.NewCustomMetricConfigWithTestConn( |
| "Graphics.Smoothness.PercentDroppedFrames.CompositorThread.Video", "percent", |
| perf.SmallerIsBetter, []int64{5, 10}, bTconn), |
| } |
| for _, suffix := range []string{"Capturer", "Encoder", "EncoderQueue", "RateLimiter"} { |
| configs = append(configs, cuj.NewCustomMetricConfigWithTestConn( |
| "WebRTC.Video.DroppedFrames."+suffix, "percent", perf.SmallerIsBetter, |
| []int64{50, 80}, bTconn)) |
| } |
| configs = append(configs, cuj.NewCustomMetricConfigWithTestConn( |
| "Event.Latency.EndToEnd.KeyPress", "microsecond", perf.SmallerIsBetter, |
| []int64{80000, 400000}, bTconn)) |
| configs = append(configs, cuj.NewCustomMetricConfigWithTestConn( |
| "Event.Latency.EndToEnd.Mouse", "microsecond", perf.SmallerIsBetter, |
| []int64{80000, 400000}, bTconn)) |
| configs = append(configs, cuj.NewCustomMetricConfigWithTestConn( |
| "PageLoad.PaintTiming.NavigationToFirstContentfulPaint", "ms", |
| perf.SmallerIsBetter, []int64{4000, 5000}, bTconn)) |
| configs = append(configs, cuj.NewCustomMetricConfigWithTestConn( |
| "PageLoad.PaintTiming.NavigationToLargestContentfulPaint2", "ms", |
| perf.SmallerIsBetter, []int64{4000, 5000}, bTconn)) |
| |
| rightSnapAllWindows := func() error { |
| if err := ash.ForEachWindow(ctx, tconn, func(w *ash.Window) error { |
| return ash.SetWindowStateAndWait(ctx, tconn, w.ID, ash.WindowStateRightSnapped) |
| }); err != nil { |
| return errors.Wrap(err, "failed to turn all windows into right snapped state") |
| } |
| return nil |
| } |
| |
| leftSnapNonRightSnappedWindows := func() error { |
| if err := ash.ForEachWindow(ctx, tconn, func(w *ash.Window) error { |
| if w.State != ash.WindowStateRightSnapped { |
| return ash.SetWindowStateAndWait(ctx, tconn, w.ID, ash.WindowStateLeftSnapped) |
| } |
| return nil |
| }); err != nil { |
| return errors.Wrap(err, "failed to turn non right snapped windows into left snapped state") |
| } |
| return nil |
| } |
| |
| twoFingerScrollDown := func(duration time.Duration) error { |
| fingerSpacing := tpw.Width() / 4 |
| // Swipe and scroll down the spreadsheet. |
| testing.ContextLogf(ctx, "Scrolling down the Google Sheets file for %s", duration) |
| for end := time.Now().Add(duration); time.Now().Before(end); { |
| // Double swipe from the middle bottom to the middle top of the touchpad. |
| var startX, startY, endX, endY input.TouchCoord |
| startX, startY, endX, endY = tpw.Width()/2, 1, tpw.Width()/2, tpw.Height()-1 |
| fingerNum := 2 |
| if err := tw.Swipe(ctx, startX, startY, endX, endY, fingerSpacing, |
| fingerNum, 500*time.Millisecond); err != nil { |
| return errors.Wrap(err, "failed to swipe") |
| } |
| } |
| return nil |
| } |
| |
| ensureElementGetsScrolled := func(conn *chrome.Conn, element string) error { |
| var scrollTop int |
| if err := conn.Eval(ctx, fmt.Sprintf("parseInt(%s.scrollTop)", element), &scrollTop); err != nil { |
| return errors.Wrap(err, "failed to get the number of pixels that the scrollbar is scrolled vertically") |
| } |
| if scrollTop == 0 { |
| return errors.Errorf("%s is not getting scrolled", element) |
| } |
| return nil |
| } |
| |
| pv := perf.NewValues() |
| recorder, err := cuj.NewRecorder(ctx, cr, nil, cuj.RecorderOptions{}, configs...) |
| if err != nil { |
| s.Fatal("Failed to create a new CUJ recorder: ", err) |
| } |
| defer func() { |
| if err := recorder.Close(closeCtx); err != nil { |
| s.Error("Failed to stop recorder: ", err) |
| } |
| }() |
| if err := recorder.Run(ctx, func(ctx context.Context) error { |
| // Hide notifications so that they won't overlap with other UI components. |
| if err := ash.CloseNotifications(ctx, tconn); err != nil { |
| return errors.Wrap(err, "failed to close all notifications") |
| } |
| shareMessage := "Share this info with people you want in the meeting" |
| if ui.WaitUntilExists(nodewith.Name(shareMessage).Ancestor(webview))(ctx) == nil { |
| // "Share this code" popup appears, dismissing by close button. |
| if err := uiauto.Combine( |
| "click the close button and wait for the popup to disappear", |
| pc.Click(nodewith.Name("Close").Role(role.Button).Ancestor(webview)), |
| ui.WaitUntilGone(nodewith.Name(shareMessage).Ancestor(webview)), |
| )(ctx); err != nil { |
| return err |
| } |
| } |
| |
| if err := meetConn.WaitForExpr(ctx, "hrTelemetryApi.isInMeeting()"); err != nil { |
| return errors.Wrap(err, "failed to wait for entering meeting") |
| } |
| if err := meetConn.Eval(ctx, "hrTelemetryApi.setMicMuted(false)", nil); err != nil { |
| return errors.Wrap(err, "failed to turn on mic") |
| } |
| if err := meetConn.Eval(ctx, "hrTelemetryApi.setCameraMuted(false)", nil); err != nil { |
| return errors.Wrap(err, "failed to turn on camera") |
| } |
| |
| var participantCount int |
| if err := meetConn.Eval(ctx, "hrTelemetryApi.getParticipantCount()", &participantCount); err != nil { |
| return errors.Wrap(err, "failed to get participant count") |
| } |
| if participantCount != 2 { |
| return errors.Errorf("got %d participants, expected 2", participantCount) |
| } |
| |
| // Hide notifications so that they won't overlap with other UI components. |
| if err := ash.CloseNotifications(ctx, tconn); err != nil { |
| return errors.Wrap(err, "failed to close all notifications") |
| } |
| if err := meetConn.Eval(ctx, fmt.Sprintf("hrTelemetryApi.set%sLayout()", meetLayout), nil); err != nil { |
| return errors.Wrapf(err, "failed to set %s layout", meetLayout) |
| } |
| |
| if err := rightSnapAllWindows(); err != nil { |
| return err |
| } |
| |
| // 1. Multi-tasking with Google Docs by opening a large Docs file and scrolling through the file. |
| // ================================================================================ |
| |
| docsConn, err := cs.NewConn(ctx, docsURL, browser.WithNewWindow()) |
| if err != nil { |
| return errors.Wrap(err, "failed to open the google docs website") |
| } |
| defer docsConn.Close() |
| defer docsConn.CloseTarget(closeCtx) |
| |
| // Left snap the Docs window. |
| if err := leftSnapNonRightSnappedWindows(); err != nil { |
| return err |
| } |
| |
| // Pop-up content regarding paperless mode might show up. |
| gotItButton := nodewith.Name("Got it!").Role(role.Button) |
| if err := uiauto.IfSuccessThen(ui.WithTimeout(10*time.Second).WaitUntilExists(gotItButton), ui.LeftClick(gotItButton))(ctx); err != nil { |
| return errors.Wrap(err, "failed to click the Got it button") |
| } |
| |
| // Move mouse to the left side of screen so that mouse will be on top of the left snapped window. |
| info, err := display.GetPrimaryInfo(ctx, tconn) |
| if err != nil { |
| return errors.Wrap(err, "failed to get the primary display info") |
| } |
| leftSidePoint := coords.NewPoint(info.WorkArea.Left+info.WorkArea.Width/4, info.WorkArea.CenterY()) |
| if err := mouse.Move(tconn, leftSidePoint, 0)(ctx); err != nil { |
| return errors.Wrap(err, "failed to move mouse to the left side of the screen") |
| } |
| |
| // Scroll down the Docs file. |
| s.Logf("Scrolling down the Google Docs file for %s", docsScrollTimeout) |
| if err := twoFingerScrollDown(docsScrollTimeout); err != nil { |
| return err |
| } |
| |
| // Ensure the file gets scrolled. |
| if err := ensureElementGetsScrolled(docsConn, "document.getElementsByClassName('navigation-widget-content')[0]"); err != nil { |
| return err |
| } |
| |
| // Navigate away to record PageLoad.PaintTiming.NavigationToLargestContentfulPaint2. |
| if err := docsConn.Navigate(ctx, "chrome://version"); err != nil { |
| return errors.Wrap(err, "failed to navigate to chrome://version") |
| } |
| |
| // 2. Multi-tasking with Google Slides by opening a large Slides file and going through the deck. |
| // ================================================================================ |
| |
| slidesConn, err := cs.NewConn(ctx, slidesURL, browser.WithNewWindow()) |
| if err != nil { |
| return errors.Wrap(err, "failed to open the google slides website") |
| } |
| defer slidesConn.Close() |
| defer slidesConn.CloseTarget(closeCtx) |
| |
| // Left snap the Slides window. |
| if err := leftSnapNonRightSnappedWindows(); err != nil { |
| return err |
| } |
| |
| // Go through the Slides deck. |
| s.Logf("Going through the Google Slides file for %s", slidesScrollTimeout) |
| for end := time.Now().Add(slidesScrollTimeout); time.Now().Before(end); { |
| if err := uiauto.Combine( |
| "sleep and press down", |
| action.Sleep(time.Second), |
| kw.AccelAction("Down"), |
| )(ctx); err != nil { |
| return err |
| } |
| } |
| |
| // Ensure the slides deck gets scrolled. |
| if err := ensureElementGetsScrolled(slidesConn, "document.getElementsByClassName('punch-filmstrip-scroll')[0]"); err != nil { |
| return err |
| } |
| |
| // Navigate away to record PageLoad.PaintTiming.NavigationToLargestContentfulPaint2. |
| if err := slidesConn.Navigate(ctx, "chrome://version"); err != nil { |
| return errors.Wrap(err, "failed to navigate to chrome://version") |
| } |
| |
| // 3. Multi-tasking with Gmail by opening the inbox and scrolling down. |
| // ================================================================================ |
| |
| gmailConn, err := cs.NewConn(ctx, gmailURL, browser.WithNewWindow()) |
| if err != nil { |
| return errors.Wrap(err, "failed to open the Gmail inbox") |
| } |
| defer gmailConn.Close() |
| defer gmailConn.CloseTarget(closeCtx) |
| |
| // Left snap the Gmail window. |
| if err := leftSnapNonRightSnappedWindows(); err != nil { |
| return err |
| } |
| |
| // Scroll down the Gmail inbox. |
| s.Logf("Scrolling down the Gmail inbox for %s", gmailScrollTimeout) |
| if err := twoFingerScrollDown(gmailScrollTimeout); err != nil { |
| return err |
| } |
| |
| // Navigate away to record PageLoad.PaintTiming.NavigationToLargestContentfulPaint2. |
| if err := gmailConn.Navigate(ctx, "chrome://version"); err != nil { |
| return errors.Wrap(err, "failed to navigate to chrome://version") |
| } |
| |
| return nil |
| }); err != nil { |
| s.Fatal("Failed to conduct the recorder task: ", err) |
| } |
| |
| if err := recorder.Record(ctx, pv); err != nil { |
| s.Fatal("Failed to record the data: ", err) |
| } |
| if err := pv.Save(s.OutDir()); err != nil { |
| s.Error("Failed to save the perf data: ", err) |
| } |
| } |