diff --git a/internal/version/VERSION b/internal/version/VERSION index 4c2cfbf6..a74704b2 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2503052233 +v5.0.0-beta-2503062216 diff --git a/pkg/gadb/device.go b/pkg/gadb/device.go index 9a9abd4d..75bb376a 100644 --- a/pkg/gadb/device.go +++ b/pkg/gadb/device.go @@ -2,6 +2,7 @@ package gadb import ( "bytes" + "context" "encoding/binary" "fmt" "io" @@ -147,15 +148,28 @@ func (d *Device) Usb() (string, error) { return "", errors.New("does not have attribute: usb") } +func (d *Device) SystemVersion() (string, error) { + if d.HasAttribute("systemVersion") { + return d.attrs["systemVersion"], nil + } + systemVersion, err := d.RunShellCommand("getprop", "ro.build.version.release") + systemVersion = strings.TrimSpace(systemVersion) + if err != nil { + return "", errors.New("get android system version failed") + } + d.attrs["systemVersion"] = systemVersion + return systemVersion, nil +} + func (d *Device) SdkVersion() (string, error) { if d.HasAttribute("sdkVersion") { return d.attrs["sdkVersion"], nil } sdkVersion, err := d.RunShellCommand("getprop", "ro.build.version.sdk") - sdkVersion = strings.TrimSpace(sdkVersion) if err != nil { - return "", errors.New("does not have attribute: sdkVersion") + return "", errors.New("get android sdk version failed") } + sdkVersion = strings.TrimSpace(sdkVersion) d.attrs["sdkVersion"] = sdkVersion return sdkVersion, nil } @@ -732,7 +746,7 @@ func (d *Device) ScreenCap() ([]byte, error) { time.Now().Unix()) _, err := d.RunShellCommandWithBytes("screencap", "-p", tempPath) if err != nil { - return nil, err + return nil, errors.Wrap(err, "screencap failed") } // remove temp file @@ -742,5 +756,46 @@ func (d *Device) ScreenCap() ([]byte, error) { buffer := bytes.NewBuffer(nil) err = d.Pull(tempPath, buffer) - return buffer.Bytes(), err + if err != nil { + return nil, errors.Wrap(err, "pull video failed") + } + return buffer.Bytes(), nil +} + +func (d *Device) ScreenRecord(ctx context.Context) ([]byte, error) { + videoPath := fmt.Sprintf("/sdcard/screenrecord_%d.mp4", time.Now().Unix()) + + done := make(chan error, 1) + go func() { + _, err := d.RunShellCommandWithBytes("screenrecord", videoPath) + done <- err + }() + + select { + case <-ctx.Done(): + // timeout or cancelled + pid, err := d.RunShellCommand("pidof", "screenrecord") + if err == nil && pid != "" { + // 发送 SIGINT 信号终止录屏 + _, _ = d.RunShellCommand("kill", "-2", strings.TrimSpace(pid)) + } + <-done // 等待进程完全退出 + case err := <-done: + // adb screenrecord will exit on reached 180s + if err != nil { + return nil, errors.Wrap(err, "screenrecord failed") + } + } + + // remove temp file + defer func() { + go d.RunShellCommand("rm", videoPath) + }() + + buffer := bytes.NewBuffer(nil) + err := d.Pull(videoPath, buffer) + if err != nil { + return nil, errors.Wrap(err, "pull video failed") + } + return buffer.Bytes(), nil } diff --git a/pkg/gadb/device_test.go b/pkg/gadb/device_test.go index 99df395d..0a55e56b 100644 --- a/pkg/gadb/device_test.go +++ b/pkg/gadb/device_test.go @@ -4,11 +4,15 @@ package gadb import ( "bytes" + "context" "os" "reflect" "strings" "testing" "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var devices []*Device @@ -17,9 +21,7 @@ func setupDevices(t *testing.T) { var err error setupClient(t) devices, err = adbClient.DeviceList() - if err != nil { - t.Fatal(err) - } + require.Nil(t, err) } func TestDevice_State(t *testing.T) { @@ -119,6 +121,24 @@ func TestDevice_DeviceInfo(t *testing.T) { } } +func TestDevice_SdkVersion(t *testing.T) { + setupDevices(t) + for _, device := range devices { + sdkVersion, err := device.SdkVersion() + assert.Nil(t, err) + t.Log(device.Serial(), sdkVersion) + } +} + +func TestDevice_SystemVersion(t *testing.T) { + setupDevices(t) + for _, device := range devices { + systemVersion, err := device.SystemVersion() + assert.Nil(t, err) + t.Log(device.Serial(), systemVersion) + } +} + func TestDevice_Forward(t *testing.T) { setupDevices(t) @@ -276,6 +296,36 @@ func TestDevice_Pull(t *testing.T) { } } +func TestDevice_ScreenRecord(t *testing.T) { + setupDevices(t) + + for _, dev := range devices { + // screen record with time limit 5 seconds + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + if _, err := dev.ScreenRecord(ctx); err != nil { + assert.Nil(t, err) + } + cancel() + } + + for _, dev := range devices { + // screen record with cancel signal + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error) + go func() { + _, err := dev.ScreenRecord(ctx) + done <- err + }() + + // record for 3 seconds + time.Sleep(time.Second * 3) + cancel() + + err := <-done + assert.Nil(t, err) + } +} + func TestDevice_RunShellCommandBackgroundWithBytes(t *testing.T) { type fields struct { adbClient Client diff --git a/server/source.go b/server/source.go index 87cbec7a..aac7e204 100644 --- a/server/source.go +++ b/server/source.go @@ -37,7 +37,7 @@ func (r *Router) screenResultHandler(c *gin.Context) { var actionOptions []option.ActionOption if screenReq.Options != nil { - actionOptions = screenReq.Options.Options() + actionOptions = screenReq.Options.GetScreenShotOptions() } screenResult, err := driver.GetScreenResult(actionOptions...) diff --git a/uixt/android_driver_adb.go b/uixt/android_driver_adb.go index 1026ce7c..22f63b54 100644 --- a/uixt/android_driver_adb.go +++ b/uixt/android_driver_adb.go @@ -3,6 +3,7 @@ package uixt import ( "bufio" "bytes" + "context" "encoding/json" "encoding/xml" "fmt" @@ -779,54 +780,127 @@ func (ad *ADBDriver) GetIme() (ime string, err error) { return currentIme, nil } -func (ad *ADBDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) { - timestamp := time.Now().Format("20060102_150405") + fmt.Sprintf("_%03d", time.Now().UnixNano()/1e6%1000) - fileName := filepath.Join(config.GetConfig().ScreenShotsPath, fmt.Sprintf("%s.mp4", timestamp)) +func (ad *ADBDriver) ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) { + options := option.NewActionOptions(opts...) - file, err := os.Create(fileName) - if err != nil { - log.Error().Err(err) - return "", err + var filePath string + if options.ScreenRecordPath != "" { + filePath = options.ScreenRecordPath + } else { + timestamp := time.Now().Format("20060102_150405") + fmt.Sprintf("_%03d", time.Now().UnixNano()/1e6%1000) + filePath = filepath.Join(config.GetConfig().ScreenShotsPath, fmt.Sprintf("%s.mp4", timestamp)) } + + var ctx context.Context + if options.Context != nil { + ctx = options.Context + } else { + ctx = context.Background() + } + + var cancel context.CancelFunc + duration := options.ScreenRecordDuration + if duration == 0 { + duration = options.Duration + } + if duration != 0 { + ctx, cancel = context.WithTimeout(ctx, + time.Duration(duration*float64(time.Second))) + } else { + ctx, cancel = context.WithCancel(ctx) + } + defer cancel() + + // get android system version + var sysVersion int + if systemVersion, err := ad.Device.SystemVersion(); err == nil { + if version, err := strconv.Atoi(systemVersion); err == nil { + sysVersion = version + } + } + if sysVersion == 0 { + log.Warn().Err(err).Msg("get android system version failed") + } + + var useAdbScreenRecord bool + audioOn := options.ScreenRecordWithAudio + if options.ScreenRecordWithScrcpy { + useAdbScreenRecord = false + } else if !audioOn { + log.Info().Bool("audioOn", audioOn).Msg("screen record with adb screenrecord by default") + useAdbScreenRecord = true + } else if sysVersion != 0 && sysVersion < 11 { + // scrcpy audio forwarding is supported for devices with Android 11 or higher + // https://github.com/Genymobile/scrcpy/blob/master/doc/audio.md + log.Warn().Bool("audioOn", audioOn).Int("version", sysVersion). + Msg("Audio disabled, it is only supported for Android >= 11, use adb screenrecord") + useAdbScreenRecord = true + } + defer func() { - _ = file.Close() + if err == nil { + filePath, err = filepath.Abs(filePath) + if err != nil { + err = errors.Wrap(err, "get absolute path failed") + } else { + log.Info().Str("path", filePath).Msg("screen record success") + } + } }() - // scrcpy -s 7d21bb91 --record=file.mp4 -N + if useAdbScreenRecord { + // screen record with adb screenrecord + // adb screenrecord duration is limited in range [1,180] seconds + res, err := ad.Device.ScreenRecord(ctx) + if err != nil { + return "", errors.Wrap(err, "screen record failed") + } + if err := os.WriteFile(filePath, res, 0o644); err != nil { + return "", errors.Wrap(err, "write screen record file failed") + } + return filePath, nil + } + + // screen record with scrcpy + log.Info().Float64("duration(s)", duration).Msg("screen record with scrcpy") + + // start scrcpy cmd := exec.Command( "scrcpy", "-s", ad.Device.Serial(), - fmt.Sprintf("--record=%s", fileName), - "-N", + fmt.Sprintf("--record=%s", filePath), + "--record-format=mp4", + "--max-fps=30", + "--no-playback", // Disable video and audio playback on the computer ) cmd.Stdout = io.Discard cmd.Stderr = io.Discard - // 启动命令 if err := cmd.Start(); err != nil { - log.Error().Err(err) - return "", err + return "", errors.Wrap(err, "start screen record failed") } - timer := time.After(duration) - done := make(chan error) + done := make(chan error, 1) go func() { - // 等待 ffmpeg 命令执行完毕 done <- cmd.Wait() }() + select { - case <-timer: - // 超时,停止 scrcpy 进程 + case <-ctx.Done(): + // timeout or cancelled + log.Info().Msg("screen recording stopped") if err := cmd.Process.Signal(syscall.SIGINT); err != nil { - log.Error().Err(err) + log.Error().Err(err).Msg("failed to stop scrcpy process") + _ = cmd.Process.Kill() // 强制结束进程 } + <-done // 等待进程完全退出 case err := <-done: - // ffmpeg 正常结束 + log.Info().Msg("scrcpy exited") if err != nil { - log.Error().Err(err) - return "", err + return "", errors.Wrap(err, "screen record with scrcpy failed") } } - return filepath.Abs(fileName) + + return filePath, nil } func (ad *ADBDriver) Setup() error { diff --git a/uixt/android_test.go b/uixt/android_test.go index f492740c..c18c67e0 100644 --- a/uixt/android_test.go +++ b/uixt/android_test.go @@ -3,6 +3,7 @@ package uixt import ( + "context" "os" "testing" "time" @@ -195,10 +196,52 @@ func TestDriver_ADB_ForegroundInfo(t *testing.T) { func TestDriver_ADB_ScreenRecord(t *testing.T) { driver := setupADBDriverExt(t) - path, err := driver.ScreenRecord(5 * time.Second) + + // adb screenrecord --time-limit 5 + path1, err := driver.ScreenRecord( + option.WithScreenRecordDuation(5)) + assert.Nil(t, err) + defer os.Remove(path1) + t.Log(path1) + + // scrcpy with time limit + path2, err := driver.ScreenRecord( + option.WithScreenRecordDuation(5), + option.WithScreenRecordAudio(true), + ) + assert.Nil(t, err) + defer os.Remove(path2) + t.Log(path2) + + // scrcpy with time limit + path3, err := driver.ScreenRecord( + option.WithScreenRecordDuation(5), + option.WithScreenRecordScrcpy(true), + ) + assert.Nil(t, err) + defer os.Remove(path3) + t.Log(path3) + + // scrcpy with cancel signal + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error) + go func() { + path4, err := driver.ScreenRecord( + option.WithContext(ctx), + option.WithScreenRecordScrcpy(true), + ) + assert.Nil(t, err) + defer os.Remove(path4) + t.Log(path4) + done <- err + }() + + // record for 3 seconds + time.Sleep(time.Second * 3) + cancel() + + err = <-done assert.Nil(t, err) - defer os.Remove(path) - t.Log(path) } func TestDriver_ADB_Backspace(t *testing.T) { diff --git a/uixt/browser_driver.go b/uixt/browser_driver.go index 95d08123..7a455529 100644 --- a/uixt/browser_driver.go +++ b/uixt/browser_driver.go @@ -614,7 +614,7 @@ func (wd *BrowserDriver) GetSession() *DriverSession { return nil } -func (wd *BrowserDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) { +func (wd *BrowserDriver) ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) { return } diff --git a/uixt/driver.go b/uixt/driver.go index a27a9a48..70accc51 100644 --- a/uixt/driver.go +++ b/uixt/driver.go @@ -4,7 +4,6 @@ import ( "bytes" _ "image/gif" _ "image/png" - "time" "github.com/httprunner/httprunner/v5/uixt/ai" "github.com/httprunner/httprunner/v5/uixt/option" @@ -37,7 +36,7 @@ type IDriver interface { ForegroundInfo() (app types.AppInfo, err error) WindowSize() (types.Size, error) ScreenShot(opts ...option.ActionOption) (*bytes.Buffer, error) - ScreenRecord(duration time.Duration) (videoPath string, err error) + ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) Source(srcOpt ...option.SourceOption) (string, error) Orientation() (orientation types.Orientation, err error) Rotation() (rotation types.Rotation, err error) diff --git a/uixt/driver_action.go b/uixt/driver_action.go index e20e261c..42a8ff66 100644 --- a/uixt/driver_action.go +++ b/uixt/driver_action.go @@ -273,7 +273,7 @@ func (dExt *XTDriver) DoAction(action MobileAction) (err error) { case ACTION_ScreenShot: // take screenshot log.Info().Msg("take screenshot for current screen") - _, err := dExt.GetScreenResult(action.GetScreenOptions()...) + _, err := dExt.GetScreenResult(action.GetScreenShotOptions()...) return err case ACTION_ClosePopups: return dExt.ClosePopupsHandler() diff --git a/uixt/driver_ext/ios_stub_driver.go b/uixt/driver_ext/ios_stub_driver.go index 948ff9be..6dd7a980 100644 --- a/uixt/driver_ext/ios_stub_driver.go +++ b/uixt/driver_ext/ios_stub_driver.go @@ -364,11 +364,11 @@ func (s *StubIOSDriver) WindowSize() (types.Size, error) { return s.WDADriver.WindowSize() } -func (s *StubIOSDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) { +func (s *StubIOSDriver) ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) { if err := s.SetupWda(); err != nil { return "", errors.Wrap(code.DeviceHTTPDriverError, err.Error()) } - return s.WDADriver.ScreenRecord(duration) + return s.WDADriver.ScreenRecord(opts...) } func (s *StubIOSDriver) Orientation() (types.Orientation, error) { diff --git a/uixt/harmony_driver_hdc.go b/uixt/harmony_driver_hdc.go index 2c0f0698..fb248791 100644 --- a/uixt/harmony_driver_hdc.go +++ b/uixt/harmony_driver_hdc.go @@ -253,7 +253,7 @@ func (hd *HDCDriver) StopCaptureLog() (result interface{}, err error) { return hd.points, nil } -func (hd *HDCDriver) ScreenRecord(duration time.Duration) (videoPath string, err error) { +func (hd *HDCDriver) ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) { return "", nil } diff --git a/uixt/ios_driver_wda.go b/uixt/ios_driver_wda.go index 5d6f6381..8a97a19b 100644 --- a/uixt/ios_driver_wda.go +++ b/uixt/ios_driver_wda.go @@ -875,10 +875,13 @@ func (wd *WDADriver) triggerWDALog(data map[string]interface{}) (rawResp []byte, return wd.Session.POST(data, "/gtf/automation/log") } -func (wd *WDADriver) ScreenRecord(duration time.Duration) (videoPath string, err error) { +func (wd *WDADriver) ScreenRecord(opts ...option.ActionOption) (videoPath string, err error) { timestamp := time.Now().Format("20060102_150405") + fmt.Sprintf("_%03d", time.Now().UnixNano()/1e6%1000) fileName := filepath.Join(config.GetConfig().ScreenShotsPath, fmt.Sprintf("%s.mp4", timestamp)) + options := option.NewActionOptions(opts...) + duration := time.Duration(options.Duration * float64(time.Second)) + file, err := os.Create(fileName) if err != nil { fmt.Println("Error creating file:", err) diff --git a/uixt/ios_test.go b/uixt/ios_test.go index 98a60a57..6fe37bfd 100644 --- a/uixt/ios_test.go +++ b/uixt/ios_test.go @@ -302,7 +302,7 @@ func TestDriver_WDA_AccessibleSource(t *testing.T) { func TestDriver_WDA_ScreenRecord(t *testing.T) { driver := setupWDADriverExt(t) - path, err := driver.ScreenRecord(5 * time.Second) + path, err := driver.ScreenRecord(option.WithScreenRecordDuation(5)) assert.Nil(t, err) t.Log(path) } diff --git a/uixt/option/action.go b/uixt/option/action.go index 4447638a..594a6cd4 100644 --- a/uixt/option/action.go +++ b/uixt/option/action.go @@ -1,12 +1,14 @@ package option import ( + "context" "math/rand/v2" "github.com/httprunner/httprunner/v5/internal/builtin" ) type ActionOptions struct { + Context context.Context // log Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` // used to identify the action in log @@ -33,6 +35,9 @@ func (o *ActionOptions) Options() []ActionOption { return options } + if o.Context != nil { + options = append(options, WithContext(o.Context)) + } if o.Identifier != "" { options = append(options, WithIdentifier(o.Identifier)) } @@ -120,11 +125,10 @@ func (o *ActionOptions) Options() []ActionOption { } } - return options -} + options = append(options, o.GetScreenShotOptions()...) + options = append(options, o.GetScreenRecordOptions()...) -func (o *ActionOptions) GetScreenOptions() []ActionOption { - return o.ScreenOptions.Options() + return options } func (o *ActionOptions) ApplyOffset(absX, absY float64) (float64, float64) { @@ -207,6 +211,12 @@ func NewActionOptions(opts ...ActionOption) *ActionOptions { type ActionOption func(o *ActionOptions) +func WithContext(ctx context.Context) ActionOption { + return func(o *ActionOptions) { + o.Context = ctx + } +} + func WithCustomOption(key string, value interface{}) ActionOption { return func(o *ActionOptions) { if o.Custom == nil { diff --git a/uixt/option/screen.go b/uixt/option/screen.go index 60af4b31..160dd4c9 100644 --- a/uixt/option/screen.go +++ b/uixt/option/screen.go @@ -1,9 +1,12 @@ package option -import "github.com/httprunner/httprunner/v5/uixt/types" +import ( + "github.com/httprunner/httprunner/v5/uixt/types" +) type ScreenOptions struct { ScreenShotOptions + ScreenRecordOptions ScreenFilterOptions } @@ -18,7 +21,7 @@ type ScreenShotOptions struct { ScreenShotFileName string `json:"screenshot_file_name,omitempty" yaml:"screenshot_file_name,omitempty"` } -func (o *ScreenShotOptions) Options() []ActionOption { +func (o *ScreenShotOptions) GetScreenShotOptions() []ActionOption { options := make([]ActionOption, 0) if o == nil { return options @@ -125,6 +128,59 @@ func WithScreenShotFileName(fileName string) ActionOption { } } +type ScreenRecordOptions struct { + ScreenRecordDuration float64 `json:"screenrecord_duration,omitempty" yaml:"screenrecord_duration,omitempty"` + ScreenRecordWithAudio bool `json:"screenrecord_with_audio,omitempty" yaml:"screenrecord_with_audio,omitempty"` + ScreenRecordWithScrcpy bool `json:"screenrecord_with_scrcpy,omitempty" yaml:"screenrecord_with_scrcpy,omitempty"` + ScreenRecordPath string `json:"screenrecord_path,omitempty" yaml:"screenrecord_path,omitempty"` +} + +func (o *ScreenRecordOptions) GetScreenRecordOptions() []ActionOption { + options := make([]ActionOption, 0) + if o == nil { + return options + } + + // screen record options + if o.ScreenRecordDuration > 0 { + options = append(options, WithDuration(o.ScreenRecordDuration)) + } + if o.ScreenRecordWithAudio { + options = append(options, WithScreenRecordAudio(true)) + } + if o.ScreenRecordWithScrcpy { + options = append(options, WithScreenRecordScrcpy(true)) + } + if o.ScreenRecordPath != "" { + options = append(options, WithScreenRecordPath(o.ScreenRecordPath)) + } + return options +} + +func WithScreenRecordDuation(duration float64) ActionOption { + return func(o *ActionOptions) { + o.ScreenRecordDuration = duration + } +} + +func WithScreenRecordAudio(audioOn bool) ActionOption { + return func(o *ActionOptions) { + o.ScreenRecordWithAudio = audioOn + } +} + +func WithScreenRecordScrcpy(scrcpyOn bool) ActionOption { + return func(o *ActionOptions) { + o.ScreenRecordWithScrcpy = scrcpyOn + } +} + +func WithScreenRecordPath(path string) ActionOption { + return func(o *ActionOptions) { + o.ScreenRecordPath = path + } +} + // (x1, y1) is the top left corner, (x2, y2) is the bottom right corner // [x1, y1, x2, y2] in percentage of the screen type Scope []float64