blob: ed4e39aaf4fd8d40245af8e8f39e43cecabd7fc2 [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 uiauto
import (
"bytes"
"context"
"encoding/base64"
"io/ioutil"
"os"
"path/filepath"
"strings"
"time"
"chromiumos/tast/errors"
"chromiumos/tast/local/chrome"
"chromiumos/tast/local/chrome/uiauto/nodewith"
"chromiumos/tast/local/chrome/uiauto/role"
"chromiumos/tast/local/crash"
"chromiumos/tast/local/input"
"chromiumos/tast/testing"
)
const (
timeout = 10 * time.Second
)
// ScreenRecorder is a utility to record the screen during a test scenario.
type ScreenRecorder struct {
isRecording bool
videoRecorder *chrome.JSObject
result string
}
type testingState interface {
OutDir() string
HasError() bool
}
// NewScreenRecorder creates a ScreenRecorder.
// It only needs to create one ScreenRecorder during one test.
// It chooses the entire desktop as the media stream.
// Example:
//
// screenRecorder, err := uiauto.NewScreenRecorder(ctx, tconn)
// if err != nil {
// s.Log("Failed to create ScreenRecorder: ", err)
// }
//
// To stop, save, and release the recorder:
// defer uiauto.ScreenRecorderStopSaveRelease(...)
//
func NewScreenRecorder(ctx context.Context, tconn *chrome.TestConn) (*ScreenRecorder, error) {
expr := `({
chunks: [],
recorder: null,
streamPromise: null,
videoTrack: null,
request: function() {
this.streamPromise = navigator.mediaDevices.getDisplayMedia({
audio: false,
video: {
cursor: "always"
}
});
},
start: function() {
this.chunks = [];
return this.streamPromise.then(
stream => {
this.videoTrack = stream.getVideoTracks()[0];
this.recorder = new MediaRecorder(stream, {mimeType: 'video/webm;codecs=vp9'});
this.recorder.ondataavailable = (e) => {
this.chunks.push(e.data);
};
this.recorder.start();
}
);
},
stop: function() {
return new Promise((resolve, reject) => {
this.recorder.onstop = function() {
let blob = new Blob(this.chunks, {'type': 'video/webm'});
var reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
}
reader.readAsDataURL(blob);
}.bind(this);
this.recorder.stop();
})
},
frameStatus: function() {
return new Promise((resolve, reject) => {
const imageCapture = new ImageCapture(this.videoTrack)
imageCapture.grabFrame()
.then(function(imageBitmap) {
if (imageBitmap.width) {
resolve('Success');
}
})
.catch(function(error) {
resolve('Fail');
});
});
}
})
`
videoRecorder := &chrome.JSObject{}
if err := tconn.Eval(ctx, expr, videoRecorder); err != nil {
return nil, errors.Wrap(err, "failed to initialize video recorder")
}
sr := &ScreenRecorder{isRecording: false, videoRecorder: videoRecorder}
// Request to share the screen.
if err := sr.videoRecorder.Call(ctx, nil, `function() {this.request();}`); err != nil {
return nil, errors.Wrap(err, "failed to request display media")
}
// Choose to record the entire desktop/screen with no audio.
ui := New(tconn)
shareScreenDialog := nodewith.Name("Choose what to share").ClassName("DesktopMediaPickerDialogView")
entireDesktopButton := nodewith.ClassName("DesktopMediaSourceView").Role(role.Button).Ancestor(shareScreenDialog)
// The share button becomes focusable after the entire desktop button is clicked.
shareButton := nodewith.Name("Share").Role(role.Button).Ancestor(shareScreenDialog).Focusable()
if err := Combine("start screen recorder through ui",
ui.WithInterval(500*time.Millisecond).LeftClickUntil(entireDesktopButton, ui.Exists(shareButton)),
ui.LeftClick(shareButton),
)(ctx); err != nil {
return nil, err
}
return sr, nil
}
// Start creates a new media recorder and starts to record the screen. As long as ScreenRecorder
// is not recording, it can start to record again.
func (r *ScreenRecorder) Start(ctx context.Context, tconn *chrome.TestConn) error {
if r.isRecording == true {
return errors.New("recorder already started")
}
if err := r.videoRecorder.Call(ctx, nil, `function() {this.start();}`); err != nil {
return errors.Wrap(err, "failed to start to record screen")
}
testing.ContextLog(ctx, "Started screen recording")
r.isRecording = true
ui := New(tconn)
closeNotificationButton := nodewith.Name("Notification close").Role(role.Button)
messagePopupAlert := nodewith.ClassName("MessagePopupView").Role(role.AlertDialog)
if err := Combine("close notification and wait for it to disappear",
ui.WithInterval(1*time.Second).LeftClick(closeNotificationButton),
ui.WaitUntilGone(messagePopupAlert))(ctx); err != nil {
return err
}
return nil
}
// Stop ends the screen recording and stores the encoded base64 string.
func (r *ScreenRecorder) Stop(ctx context.Context) error {
if r.isRecording == false {
return errors.New("recorder hasn't started yet")
}
var result string
if err := r.videoRecorder.Call(ctx, &result, `function() {return this.stop();}`); err != nil {
return errors.Wrap(err, "failed to stop recording screen")
}
r.result = result
r.isRecording = false
return nil
}
// SaveInBytes saves the latest encoded string into a decoded bytes file.
func (r *ScreenRecorder) SaveInBytes(ctx context.Context, filepath string) error {
parts := strings.Split(r.result, ",")
if len(parts) < 2 {
return errors.New("no content has been recorded. The recorder might have been stopped too soon")
}
// Decode base64 string.
reader := base64.NewDecoder(base64.StdEncoding, strings.NewReader(parts[1]))
buf := bytes.Buffer{}
if _, err := buf.ReadFrom(reader); err != nil {
return errors.Wrap(err, "failed to read from decoder")
}
if err := ioutil.WriteFile(filepath, buf.Bytes(), 0644); err != nil {
return errors.Wrapf(err, "failed to dump bytes to %s", filepath)
}
return nil
}
// SaveInString saves the latest encoded string into a string file.
func (r *ScreenRecorder) SaveInString(ctx context.Context, filepath string) error {
result := strings.Split(r.result, ",")[1]
if err := ioutil.WriteFile(filepath, []byte(result), 0644); err != nil {
return errors.Wrapf(err, "failed to dump string to %s", filepath)
}
return nil
}
// FrameStatus returns the status of the frame being recorded.
func (r *ScreenRecorder) FrameStatus(ctx context.Context) (string, error) {
var result string
if err := r.videoRecorder.Call(ctx, &result, `function() {return this.frameStatus();}`); err != nil {
return "", errors.Wrap(err, "failed to get frame status")
}
return result, nil
}
// Release frees the reference to Javascript for this video recorder.
func (r *ScreenRecorder) Release(ctx context.Context) {
if r.isRecording {
r.Stop(ctx)
}
r.videoRecorder.Release(ctx)
}
// ScreenRecorderStopSaveRelease stops, saves and releases the screen recorder.
func ScreenRecorderStopSaveRelease(ctx context.Context, r *ScreenRecorder, fileName string) {
if r != nil {
if err := r.Stop(ctx); err != nil {
testing.ContextLogf(ctx, "Failed to stop recording: %s", err)
} else {
testing.ContextLogf(ctx, "Saving screen record to %s", fileName)
if err := r.SaveInBytes(ctx, fileName); err != nil {
testing.ContextLogf(ctx, "Failed to save screen record in bytes: %s", err)
}
}
r.Release(ctx)
}
}
// RecordScreen records the screen for the duration of the function into recording.webm.
// For example, if you wanted to record your whole test, you would do the following:
// func MyTest(ctx context.Context, s *testing.State) {
// cr := s.PreValue().(pre.PreData).Chrome
// tconn := s.PreValue().(pre.PreData).TestAPIConn
// uiauto.RecordScreen(ctx, s, tconn, func() {
// <all your existing test code here>
// })
// }
func RecordScreen(ctx context.Context, s testingState, tconn *chrome.TestConn, f func()) {
recorder, err := NewScreenRecorder(ctx, tconn)
if err != nil {
testing.ContextLog(ctx, "Failed to start screen recording: ", err)
}
if recorder != nil {
recorder.Start(ctx, tconn)
defer func() {
// If there's an error, we want to wait long enough to see what happens
// after the error. This allows you to see subtitles when the error has
// occurred, and also happens to help in case something happens after
// timing out.
if s.HasError() {
testing.Sleep(ctx, time.Second*2)
}
ScreenRecorderStopSaveRelease(ctx, recorder, filepath.Join(s.OutDir(), "recording.webm"))
}()
}
f()
}
// StartRecordFromKB starts screen record from keyboard.
// It clicks Ctrl+Shift+F5 then select to record the whole desktop.
// The caller should also call StopRecordFromKB to stop the screen recorder,
// and save the record file.
// Here is an example to call this method:
// if err := uiauto.StartRecordFromKB(ctx, tconn, keyboard); err != nil {
// s.Log("Failed to start recording: ", err)
// }
//
// defer uiauto.StopRecordFromKBAndSaveOnError(ctx, tconn, s.HasError, s.OutDir())
func StartRecordFromKB(ctx context.Context, tconn *chrome.TestConn, kb *input.KeyboardEventWriter) error {
screenRecordBtn := nodewith.Name("Screen record").Role(role.ToggleButton)
fullScreenBtn := nodewith.Name("Record full screen").Role(role.ToggleButton)
desktop := nodewith.Role(role.Window).First()
ui := New(tconn)
const downloads = "/home/chronos/user/Downloads/"
files, err := ioutil.ReadDir(downloads)
if err != nil {
return errors.Wrap(err, "failed to read files from Downloads")
}
expectNumber := len(files) + 1
checkRecordFile := func(ctx context.Context) error {
return testing.Poll(ctx, func(ctx context.Context) error {
files, err = ioutil.ReadDir(downloads)
if err != nil {
return errors.Wrap(err, "failed to read files from Downloads")
}
if len(files) == expectNumber {
return nil
}
return errors.Wrapf(err, "failed to check number of files, got %d, want %d", len(files), expectNumber)
}, &testing.PollOptions{Timeout: 5 * time.Second})
}
return Combine("start screen record",
kb.AccelAction("Ctrl+Shift+F5"),
ui.LeftClick(screenRecordBtn),
ui.LeftClick(fullScreenBtn),
ui.LeftClick(desktop), // It needs to click any button to start, so clicking on the middle of the desktop.
checkRecordFile, // Check a new record file is created in Downloads.
)(ctx)
}
// StopRecordFromKBAndSaveOnError stops the record started by StartRecordFromKB.
// If there is error, it copies the record file to the target dir .
// It also removes the record file from Downloads for cleanup.
func StopRecordFromKBAndSaveOnError(ctx context.Context, tconn *chrome.TestConn, hasError func() bool, dir string) error {
recordResult := nodewith.Name("Screen recording completed").Role(role.Alert)
ui := New(tconn)
if err := Combine("stop record",
ui.LeftClick(ScreenRecordStopButton),
ui.WaitUntilExists(recordResult))(ctx); err != nil {
testing.ContextLog(ctx, "Failed to stop recording: ", err)
return err
}
return SaveRecordFromKBOnError(ctx, tconn, hasError, dir)
}
// ScreenRecordStopButton is the button to stop recording the screen.
var ScreenRecordStopButton = nodewith.Name("Stop screen recording").Role(role.Button)
// SaveRecordFromKBOnError saves the recording from StartRecordFromKB.
// This can be used without StopRecordFromKBAndSaveOnError if the screen recording was stopped automatically (i.e. if the screen was locked).
func SaveRecordFromKBOnError(ctx context.Context, tconn *chrome.TestConn, hasError func() bool, dir string) error {
const downloads = "/home/chronos/user/Downloads/"
files, err := ioutil.ReadDir(downloads)
if err != nil {
return errors.Wrap(err, "failed to read files from Downloads")
}
for _, f := range files {
path := filepath.Join(downloads, f.Name())
if strings.HasSuffix(f.Name(), ".webm") {
defer os.RemoveAll(path)
if hasError() {
if err := crash.MoveFilesToOut(ctx, dir, path); err != nil {
return errors.Wrapf(err, "failed to copy records to %s", dir)
}
testing.ContextLogf(ctx, "Successfully copied the record file %s to %s", f.Name(), dir)
}
}
}
return nil
}