Files
httprunner/runner_uixt.go
2025-08-15 11:14:19 +08:00

328 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package hrp
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"io"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/internal/version"
"github.com/httprunner/httprunner/v5/uixt"
"github.com/httprunner/httprunner/v5/uixt/option"
)
type UIXTRunner struct {
Ctx context.Context
Configs *UIXTConfig
Session *SessionRunner
DriverExt *uixt.XTDriver
RestartCount int // app restart count
RetryCount int // retry count
}
type UIXTConfig struct {
uixt.DriverCacheConfig // includes Platform, Serial, AIOptions
// Runtime context
Ctx context.Context
Cancel context.CancelFunc `json:"-"`
// Test case configuration
JSONCase ITestCase
// Device specific options
UIA2 bool // UIAutomator2Android
LogOn bool // 开启打点日志
WDAPort int // iOS WebDriverAgent port
WDAMjpegPort int // iOS WebDriverAgent MJPEG port
// Agent behavior configuration
Timeout int // seconds
AbortErrors []error // abort errors
MaxRestartAppCount int // max app restart count
MaxRetryCount int // max retry count
// Backward compatibility fields - legacy API support
OSType string // deprecated: use Platform from DriverCacheConfig
Serial string // deprecated: use Serial from DriverCacheConfig
LLMService option.LLMServiceType // deprecated: use AIOptions from DriverCacheConfig
}
const (
DEFAULT_TIMEOUT = 1200 // 20 minutes
DEFAULT_MAX_RESTART_APP_COUNT = 3 // max app restart count
DEFAULT_MAX_RETRY_COUNT = 3 // max retry count
)
func NewUIXTRunner(configs *UIXTConfig) (runner *UIXTRunner, err error) {
configs.addDefault()
log.Info().Str("version", version.GetVersionInfo()).
Interface("configs", configs).Msg("init UIXT runner")
// init testcase config
var config *TConfig
var testSteps []IStep
if configs.JSONCase != nil {
// load testcase
testCases, err := LoadTestCases(configs.JSONCase)
if err != nil || len(testCases) == 0 {
return nil, errors.Wrap(err, "load testcase failed")
}
testCase := testCases[0]
config = testCase.Config.Get()
testSteps = testCase.TestSteps
} else {
config = NewConfig("config agent")
}
config.SetAIOptions(configs.AIOptions...)
switch configs.Platform {
case "ios":
port, err := configs.getWDALocalPort(configs.Serial)
if err != nil {
log.Error().Err(err).Msg("get ios agent WDA local port failed")
} else {
log.Info().Str("port", port).Msg("set WDA_LOCAL_PORT env")
os.Setenv("WDA_LOCAL_PORT", port)
}
config.SetIOS(
option.WithUDID(configs.Serial),
option.WithWDAPort(configs.WDAPort),
option.WithWDAMjpegPort(configs.WDAMjpegPort),
option.WithWDALogOn(configs.LogOn),
)
case "harmony":
config.SetHarmony(
option.WithConnectKey(configs.Serial),
)
case "darwin":
width, height := 1920, 1080
osWidth := os.Getenv("OSWidth")
osHeight := os.Getenv("OSHeight")
if osHeight != "" && osWidth != "" {
width, err = strconv.Atoi(osWidth)
if err != nil {
log.Warn().Msg("get OSWidth failed, use default value")
}
height, err = strconv.Atoi(osHeight)
if err != nil {
log.Warn().Msg("get OSHeight failed, use default value")
}
}
log.Info().Int("width", width).Int("height", height).Msg("get darwin screen size")
config.SetBrowser(
option.WithBrowserLogOn(false),
option.WithBrowserPageSize(width, height),
)
default:
// default to android
configs.Platform = "android"
config.SetAndroid(
option.WithSerialNumber(configs.Serial),
option.WithUIA2(configs.UIA2),
option.WithAdbLogOn(configs.LogOn),
)
}
testcase := TestCase{
Config: config,
TestSteps: testSteps,
}
// create runner with HTML report enabled for UIXT
hrpRunner := NewRunner(nil).SetSaveTests(true).GenHTMLReport()
caseRunner, err := NewCaseRunner(testcase, hrpRunner)
if err != nil {
return nil, errors.Wrap(err, "init case runner failed")
}
sessionRunner := caseRunner.NewSession()
// Use configs directly as it inherits DriverCacheConfig
driverCacheConfig := configs.DriverCacheConfig
driverCacheConfig.AIOptions = config.AIOptions.Options()
dExt, err := uixt.GetOrCreateXTDriver(driverCacheConfig)
if err != nil {
return nil, errors.Wrap(err, "get driver failed")
}
// check environment
if err := CheckEnv(dExt); err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(configs.Ctx)
// create a channel to receive signals
interruptSignal := make(chan os.Signal, 1)
signal.Notify(interruptSignal, syscall.SIGINT, syscall.SIGTERM)
// cancel when interrupted
go func() {
<-interruptSignal
log.Warn().Msg("interrupted in uixt runner")
cancel()
}()
runner = &UIXTRunner{
Ctx: ctx,
Configs: configs,
Session: sessionRunner,
DriverExt: dExt,
}
return runner, nil
}
func (configs *UIXTConfig) addDefault() {
// Handle backward compatibility - sync legacy fields to embedded DriverCacheConfig
if configs.OSType != "" && configs.Platform == "" {
configs.Platform = configs.OSType
}
if configs.Serial != "" && configs.DriverCacheConfig.Serial == "" {
configs.DriverCacheConfig.Serial = configs.Serial
}
if configs.LLMService != "" && len(configs.AIOptions) == 0 {
configs.AIOptions = []option.AIServiceOption{
option.WithLLMService(configs.LLMService),
}
}
if configs.Ctx == nil {
configs.Ctx = context.Background()
}
if configs.Timeout == 0 {
configs.Timeout = DEFAULT_TIMEOUT
}
if configs.MaxRestartAppCount == 0 {
configs.MaxRestartAppCount = DEFAULT_MAX_RESTART_APP_COUNT
}
if configs.MaxRetryCount == 0 {
configs.MaxRetryCount = DEFAULT_MAX_RETRY_COUNT
}
if len(configs.AbortErrors) == 0 {
configs.AbortErrors = []error{
// risk control error, abort
code.RiskControlAccountActivation,
code.RiskControlSlideVerification,
code.RiskControlLogout,
// network error, abort
code.NetworkError,
}
}
if configs.WDAPort == 0 {
configs.WDAPort = 8700
}
if configs.WDAMjpegPort == 0 {
configs.WDAMjpegPort = 8800
}
}
var client = &http.Client{
Timeout: 10 * time.Minute,
}
func (configs *UIXTConfig) getWDALocalPort(udid string) (string, error) {
payloadBytes, _ := json.Marshal(map[string]string{
"device_id": udid,
})
req, err := http.NewRequest("POST",
fmt.Sprintf("http://localhost:%d/get_device_port", configs.WDAMjpegPort),
bytes.NewBuffer(payloadBytes))
if err != nil {
return "", errors.Wrap(err, "create request failed")
}
req.Header.Add("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
return "", errors.Wrap(err, "request ios agent failed")
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "read ios agent response body failed")
}
var resp iosAgentResponse
if err := json.Unmarshal(body, &resp); err != nil {
return "", errors.Wrap(err, "unmarshal ios agent response failed")
}
log.Info().Interface("resp", resp).Msg("get ios agent WDA local port")
if resp.Code != 0 {
return "", errors.New("ios agent response code != 0")
}
return resp.Port, nil
}
type iosAgentResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Port string `json:"port"`
}
func CheckEnv(driverExt *uixt.XTDriver) (err error) {
log.Info().Msg("check runner environment")
// 检查设备是否正常
if err := CheckDevice(driverExt); err != nil {
log.Error().Err(err).Str("screenshot", "").Msg("check device failed")
return err
}
return nil
}
func CheckDevice(driverExt *uixt.XTDriver) error {
// 检测截图功能是否正常
bufSource, err := driverExt.ScreenShot()
if err != nil {
return errors.Wrap(err, "screenshot abnormal")
}
// 检测设备是否锁屏(截图是否全黑)
img, _, err := image.Decode(bufSource)
if err != nil {
return errors.Wrap(err, "decode screenshot image failed")
}
if isImageBlack(img) {
return errors.Wrap(code.DeviceConfigureError,
"device screen is locked")
}
return nil
}
func isBlack(c color.Color) bool {
r, g, b, _ := c.RGBA()
return r == 0 && g == 0 && b == 0
}
// 判断图片是否全黑
func isImageBlack(img image.Image) bool {
bounds := img.Bounds()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if !isBlack(img.At(x, y)) {
return false
}
}
}
return true
}