Merge branch 'mcp-plugin' into 'master'

CaseRunner 支持注册自定义 driver

See merge request iesqa/httprunner!86
This commit is contained in:
李隆
2025-05-15 09:14:31 +00:00
29 changed files with 523 additions and 159 deletions
-3
View File
@@ -151,9 +151,6 @@ func convertCompatMobileStep(mobileUI *MobileUI) {
if ma.Method == uixt.ACTION_SwipeToTapText && actionOptions.MaxRetryTimes == 0 { if ma.Method == uixt.ACTION_SwipeToTapText && actionOptions.MaxRetryTimes == 0 {
ma.ActionOptions.MaxRetryTimes = 10 ma.ActionOptions.MaxRetryTimes = 10
} }
if ma.Method == uixt.ACTION_Swipe {
ma.ActionOptions.Direction = ma.Params
}
mobileUI.Actions[i] = ma mobileUI.Actions[i] = ma
} }
} }
+1 -1
View File
@@ -27,7 +27,7 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/json-iterator/go v1.1.12 github.com/json-iterator/go v1.1.12
github.com/maja42/goval v1.2.1 github.com/maja42/goval v1.2.1
github.com/mark3labs/mcp-go v0.22.0 github.com/mark3labs/mcp-go v0.27.0
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
+2 -2
View File
@@ -177,8 +177,8 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/maja42/goval v1.2.1 h1:fyEgzddqPgCZsKcFLk4C6SdCHyEaAHYvtZG4mGzQOHU= github.com/maja42/goval v1.2.1 h1:fyEgzddqPgCZsKcFLk4C6SdCHyEaAHYvtZG4mGzQOHU=
github.com/maja42/goval v1.2.1/go.mod h1:42LU+BQXL/veE9jnTTUOSj38GRmOTSThYSXRVodI5J4= github.com/maja42/goval v1.2.1/go.mod h1:42LU+BQXL/veE9jnTTUOSj38GRmOTSThYSXRVodI5J4=
github.com/mark3labs/mcp-go v0.22.0 h1:cCEBWi4Yy9Kio+OW1hWIyi4WLsSr+RBBK6FI5tj+b7I= github.com/mark3labs/mcp-go v0.27.0 h1:iok9kU4DUIU2/XVLgFS2Q9biIDqstC0jY4EQTK2Erzc=
github.com/mark3labs/mcp-go v0.22.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/mark3labs/mcp-go v0.27.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+2 -2
View File
@@ -195,12 +195,12 @@ func Interface2Float64(i interface{}) (float64, error) {
return float64(v), nil return float64(v), nil
case float64: case float64:
return v, nil return v, nil
case string: case string: // e.g. "1", "0.5"
floatVar, err := strconv.ParseFloat(v, 64) floatVar, err := strconv.ParseFloat(v, 64)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return floatVar, err return floatVar, nil
} }
// json.Number // json.Number
value, ok := i.(builtinJSON.Number) value, ok := i.(builtinJSON.Number)
+96
View File
@@ -0,0 +1,96 @@
package builtin
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestInterface2Float64(t *testing.T) {
tests := []struct {
name string
input interface{}
want float64
wantErr bool
}{
{
name: "convert int",
input: 42,
want: 42.0,
wantErr: false,
},
{
name: "convert int32",
input: int32(42),
want: 42.0,
wantErr: false,
},
{
name: "convert int64",
input: int64(42),
want: 42.0,
wantErr: false,
},
{
name: "convert float32",
input: float32(42.5),
want: 42.5,
wantErr: false,
},
{
name: "convert float64",
input: 42.5,
want: 42.5,
wantErr: false,
},
{
name: "convert string valid number",
input: "42.5",
want: 42.5,
wantErr: false,
},
{
name: "convert string valid number",
input: "425",
want: 425.0,
wantErr: false,
},
{
name: "convert string invalid number",
input: "invalid",
want: 0,
wantErr: true,
},
{
name: "convert json.Number valid",
input: json.Number("42.5"),
want: 42.5,
wantErr: false,
},
{
name: "convert json.Number invalid",
input: json.Number("invalid"),
want: 0,
wantErr: true,
},
{
name: "convert unsupported type",
input: []int{1, 2, 3},
want: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Interface2Float64(tt.input)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.want, got)
}
})
}
}
+12 -10
View File
@@ -101,6 +101,7 @@ func (h *MCPHub) connectToServer(ctx context.Context, serverName string, config
switch config.TransportType { switch config.TransportType {
case "sse": case "sse":
mcpClient, err = client.NewSSEMCPClient(config.URL) mcpClient, err = client.NewSSEMCPClient(config.URL)
case "stdio", "": // default to stdio case "stdio", "": // default to stdio
var env []string var env []string
for k, v := range config.Env { for k, v := range config.Env {
@@ -108,6 +109,17 @@ func (h *MCPHub) connectToServer(ctx context.Context, serverName string, config
} }
mcpClient, err = client.NewStdioMCPClient(config.Command, mcpClient, err = client.NewStdioMCPClient(config.Command,
env, config.Args...) env, config.Args...)
// print MCP Server logs for stdio transport
stderr, _ := client.GetStderr(mcpClient)
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
fmt.Fprintf(os.Stderr, "MCP Server %s: %s\n",
serverName, scanner.Text())
}
}()
default: default:
return fmt.Errorf("unsupported transport type: %s", config.TransportType) return fmt.Errorf("unsupported transport type: %s", config.TransportType)
} }
@@ -115,16 +127,6 @@ func (h *MCPHub) connectToServer(ctx context.Context, serverName string, config
return fmt.Errorf("failed to create client: %w", err) return fmt.Errorf("failed to create client: %w", err)
} }
// print MCP Server logs
stderr := client.GetStderr(mcpClient)
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
fmt.Fprintf(os.Stderr, "MCP Server %s: %s\n",
serverName, scanner.Text())
}
}()
// prepare client init request // prepare client init request
initRequest := mcp.InitializeRequest{} initRequest := mcp.InitializeRequest{}
initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
+1 -1
View File
@@ -1 +1 @@
v5.0.0-beta-2505091122 v5.0.0-beta-2505141501
+61 -27
View File
@@ -11,6 +11,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"reflect" "reflect"
"strconv"
"strings" "strings"
"syscall" "syscall"
"testing" "testing"
@@ -228,7 +229,7 @@ func (r *HRPRunner) Run(testcases ...ITestCase) (err error) {
// run testcase one by one // run testcase one by one
for _, testcase := range testCases { for _, testcase := range testCases {
// each testcase has its own case runner // each testcase has its own case runner
caseRunner, err := r.NewCaseRunner(*testcase) caseRunner, err := NewCaseRunner(*testcase, r)
if err != nil { if err != nil {
log.Error().Err(err).Msg("[Run] init case runner failed") log.Error().Err(err).Msg("[Run] init case runner failed")
return err return err
@@ -278,10 +279,14 @@ func (r *HRPRunner) Run(testcases ...ITestCase) (err error) {
// NewCaseRunner creates a new case runner for testcase. // NewCaseRunner creates a new case runner for testcase.
// each testcase has its own case runner // each testcase has its own case runner
func (r *HRPRunner) NewCaseRunner(testcase TestCase) (*CaseRunner, error) { // If the provided hrpRunner is nil, a default HRPRunner will be created and used.
func NewCaseRunner(testcase TestCase, hrpRunner *HRPRunner) (*CaseRunner, error) {
if hrpRunner == nil {
hrpRunner = NewRunner(nil)
}
caseRunner := &CaseRunner{ caseRunner := &CaseRunner{
TestCase: testcase, TestCase: testcase,
hrpRunner: r, hrpRunner: hrpRunner,
parser: NewParser(), parser: NewParser(),
uixtDrivers: make(map[string]*uixt.XTDriver), uixtDrivers: make(map[string]*uixt.XTDriver),
} }
@@ -289,7 +294,7 @@ func (r *HRPRunner) NewCaseRunner(testcase TestCase) (*CaseRunner, error) {
// init parser plugin // init parser plugin
if config.PluginSetting != nil { if config.PluginSetting != nil {
plugin, err := initPlugin(config.Path, r.venv, r.pluginLogOn) plugin, err := initPlugin(config.Path, hrpRunner.venv, hrpRunner.pluginLogOn)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "init plugin failed") return nil, errors.Wrap(err, "init plugin failed")
} }
@@ -317,12 +322,12 @@ func (r *HRPRunner) NewCaseRunner(testcase TestCase) (*CaseRunner, error) {
} }
// set request timeout in seconds // set request timeout in seconds
if config.RequestTimeout != 0 { if parsedConfig.RequestTimeout != 0 {
r.SetRequestTimeout(config.RequestTimeout) hrpRunner.SetRequestTimeout(parsedConfig.RequestTimeout)
} }
// set testcase timeout in seconds // set testcase timeout in seconds
if config.CaseTimeout != 0 { if parsedConfig.CaseTimeout != 0 {
r.SetCaseTimeout(config.CaseTimeout) hrpRunner.SetCaseTimeout(parsedConfig.CaseTimeout)
} }
caseRunner.TestCase.Config = parsedConfig caseRunner.TestCase.Config = parsedConfig
@@ -450,7 +455,7 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
if err != nil { if err != nil {
return nil, errors.Wrap(err, "init android XTDriver failed") return nil, errors.Wrap(err, "init android XTDriver failed")
} }
r.uixtDrivers[androidDeviceOptions.SerialNumber] = driverExt r.RegisterUIXTDriver(androidDeviceOptions.SerialNumber, driverExt)
} }
// parse iOS devices config // parse iOS devices config
for _, iosDeviceOptions := range parsedConfig.IOS { for _, iosDeviceOptions := range parsedConfig.IOS {
@@ -473,7 +478,7 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
if err != nil { if err != nil {
return nil, errors.Wrap(err, "init ios XTDriver failed") return nil, errors.Wrap(err, "init ios XTDriver failed")
} }
r.uixtDrivers[iosDeviceOptions.UDID] = driverExt r.RegisterUIXTDriver(iosDeviceOptions.UDID, driverExt)
} }
// parse harmony devices config // parse harmony devices config
for _, harmonyDeviceOptions := range parsedConfig.Harmony { for _, harmonyDeviceOptions := range parsedConfig.Harmony {
@@ -496,7 +501,7 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
if err != nil { if err != nil {
return nil, errors.Wrap(err, "init harmony XTDriver failed") return nil, errors.Wrap(err, "init harmony XTDriver failed")
} }
r.uixtDrivers[harmonyDeviceOptions.ConnectKey] = driverExt r.RegisterUIXTDriver(harmonyDeviceOptions.ConnectKey, driverExt)
} }
// parse browser devices config // parse browser devices config
for _, browserDeviceOptions := range parsedConfig.Browser { for _, browserDeviceOptions := range parsedConfig.Browser {
@@ -509,26 +514,24 @@ func (r *CaseRunner) parseConfig() (parsedConfig *TConfig, err error) {
if err != nil { if err != nil {
return nil, errors.Wrap(err, "init browser device failed") return nil, errors.Wrap(err, "init browser device failed")
} }
if err := device.Setup(); err != nil {
return nil, err
}
driver, err := device.NewDriver() driver, err := device.NewDriver()
if err != nil { if err != nil {
return nil, err return nil, errors.Wrap(err, "init browser driver failed")
}
if err := driver.Setup(); err != nil {
return nil, err
} }
driverExt, err := uixt.NewXTDriver(driver, aiOpts...) driverExt, err := uixt.NewXTDriver(driver, aiOpts...)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "init browser XTDriver failed") return nil, errors.Wrap(err, "init browser XTDriver failed")
} }
r.uixtDrivers[browserDeviceOptions.BrowserID] = driverExt r.RegisterUIXTDriver(browserDeviceOptions.BrowserID, driverExt)
} }
return parsedConfig, nil return parsedConfig, nil
} }
func (r *CaseRunner) RegisterUIXTDriver(serial string, driver *uixt.XTDriver) {
r.uixtDrivers[serial] = driver
}
func (r *CaseRunner) parseDeviceConfig(device interface{}, configVariables map[string]interface{}) error { func (r *CaseRunner) parseDeviceConfig(device interface{}, configVariables map[string]interface{}) error {
deviceValue := reflect.ValueOf(device).Elem() deviceValue := reflect.ValueOf(device).Elem()
deviceType := deviceValue.Type() deviceType := deviceValue.Type()
@@ -705,14 +708,9 @@ func (r *SessionRunner) RunStep(step IStep) (stepResult *StepResult, err error)
log.Info().Str("step", stepName).Str("type", stepType).Msg("run step start") log.Info().Str("step", stepName).Str("type", stepType).Msg("run step start")
// run times of step // run times of step
loopTimes := step.Config().Loops loopTimes, err := r.getLoopTimes(step)
if loopTimes < 0 { if err != nil {
log.Warn().Int("loops", loopTimes).Msg("loop times should be positive, set to 1") return nil, errors.Wrap(err, "failed to get loop times")
loopTimes = 1
} else if loopTimes == 0 {
loopTimes = 1
} else if loopTimes > 1 {
log.Info().Int("loops", loopTimes).Msg("run step with specified loop times")
} }
// run step with specified loop times // run step with specified loop times
@@ -838,3 +836,39 @@ func (r *SessionRunner) GetSessionVariables() map[string]interface{} {
func (r *SessionRunner) GetTransactions() map[string]map[TransactionType]time.Time { func (r *SessionRunner) GetTransactions() map[string]map[TransactionType]time.Time {
return r.transactions return r.transactions
} }
func (r *SessionRunner) getLoopTimes(step IStep) (int, error) {
loops := step.Config().Loops
if loops == nil {
// default run once
return 1, nil
}
loopTimes, err := loops.Value()
if err != nil {
parsed, err := r.caseRunner.parser.ParseString(
*loops.StringValue, step.Config().Variables)
if err != nil {
return 0, errors.Wrap(err, "failed to parse loop times")
}
switch v := parsed.(type) {
case int:
loopTimes = v
case string:
n, err := strconv.Atoi(v)
if err != nil {
return 0, errors.Wrap(err, "failed to parse loop times")
}
loopTimes = n
}
}
if loopTimes < 0 {
return 0, fmt.Errorf("loop times should be positive, got %d", loopTimes)
} else if loopTimes == 0 {
loopTimes = 1
} else if loopTimes > 1 {
log.Info().Int("loops", loopTimes).Msg("set multiple loop times")
}
return loopTimes, nil
}
+5 -2
View File
@@ -1,6 +1,9 @@
package hrp package hrp
import "github.com/httprunner/httprunner/v5/uixt" import (
"github.com/httprunner/httprunner/v5/uixt"
"github.com/httprunner/httprunner/v5/uixt/types"
)
type StepType string type StepType string
@@ -31,7 +34,7 @@ type StepConfig struct {
Extract map[string]string `json:"extract,omitempty" yaml:"extract,omitempty"` Extract map[string]string `json:"extract,omitempty" yaml:"extract,omitempty"`
Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"` Validators []interface{} `json:"validate,omitempty" yaml:"validate,omitempty"`
StepExport []string `json:"export,omitempty" yaml:"export,omitempty"` StepExport []string `json:"export,omitempty" yaml:"export,omitempty"`
Loops int `json:"loops,omitempty" yaml:"loops,omitempty"` Loops *types.IntOrString `json:"loops,omitempty" yaml:"loops,omitempty"`
IgnorePopup bool `json:"ignore_popup,omitempty" yaml:"ignore_popup,omitempty"` IgnorePopup bool `json:"ignore_popup,omitempty" yaml:"ignore_popup,omitempty"`
} }
+4 -1
View File
@@ -25,6 +25,7 @@ import (
"github.com/httprunner/httprunner/v5/internal/httpstat" "github.com/httprunner/httprunner/v5/internal/httpstat"
"github.com/httprunner/httprunner/v5/internal/json" "github.com/httprunner/httprunner/v5/internal/json"
"github.com/httprunner/httprunner/v5/uixt/option" "github.com/httprunner/httprunner/v5/uixt/option"
"github.com/httprunner/httprunner/v5/uixt/types"
) )
type HTTPMethod string type HTTPMethod string
@@ -559,7 +560,9 @@ func (s *StepRequest) HTTP2() *StepRequest {
// Loop specify running times for the current step // Loop specify running times for the current step
func (s *StepRequest) Loop(times int) *StepRequest { func (s *StepRequest) Loop(times int) *StepRequest {
s.Loops = times s.Loops = &types.IntOrString{
IntValue: &times,
}
return s return s
} }
+1 -1
View File
@@ -80,7 +80,7 @@ func (s *StepTestCaseWithOptionalArgs) Run(r *SessionRunner) (stepResult *StepRe
// merge & override extractors // merge & override extractors
config.Export = mergeSlices(s.StepExport, config.Export) config.Export = mergeSlices(s.StepExport, config.Export)
caseRunner, err := r.caseRunner.hrpRunner.NewCaseRunner(*copiedTestCase) caseRunner, err := NewCaseRunner(*copiedTestCase, r.caseRunner.hrpRunner)
if err != nil { if err != nil {
log.Error().Err(err).Msg("create case runner failed") log.Error().Err(err).Msg("create case runner failed")
return stepResult, err return stepResult, err
+2 -2
View File
@@ -447,12 +447,12 @@ func (s *StepMobile) ClosePopups(opts ...option.ActionOption) *StepMobile {
return s return s
} }
func (s *StepMobile) Call(name string, fn func()) *StepMobile { func (s *StepMobile) Call(name string, fn func(), opts ...option.ActionOption) *StepMobile {
s.obj().Actions = append(s.obj().Actions, uixt.MobileAction{ s.obj().Actions = append(s.obj().Actions, uixt.MobileAction{
Method: uixt.ACTION_CallFunction, Method: uixt.ACTION_CallFunction,
Params: name, // function description Params: name, // function description
Fn: fn, Fn: fn,
Options: nil, Options: option.NewActionOptions(opts...),
}) })
return s return s
} }
+1 -1
View File
@@ -287,7 +287,7 @@ func TestSessionRunner(t *testing.T) {
}, },
} }
caseRunner, _ := hrp.NewRunner(t).NewCaseRunner(testcase) caseRunner, _ := hrp.NewCaseRunner(testcase, hrp.NewRunner(t))
sessionRunner := caseRunner.NewSession() sessionRunner := caseRunner.NewSession()
step := testcase.TestSteps[0] step := testcase.TestSteps[0]
if !assert.Equal(t, step.Config().Variables["varFoo"], "${max($a, $b)}") { if !assert.Equal(t, step.Config().Variables["varFoo"], "${max($a, $b)}") {
+1 -1
View File
@@ -69,7 +69,7 @@ func TestRunRequestStatOn(t *testing.T) {
Config: hrp.NewConfig("test").SetBaseURL("https://postman-echo.com"), Config: hrp.NewConfig("test").SetBaseURL("https://postman-echo.com"),
TestSteps: []hrp.IStep{stepGET, stepPOSTData}, TestSteps: []hrp.IStep{stepGET, stepPOSTData},
} }
caseRunner, _ := hrp.NewRunner(t).SetHTTPStatOn().NewCaseRunner(testcase) caseRunner, _ := hrp.NewCaseRunner(testcase, hrp.NewRunner(t).SetHTTPStatOn())
sessionRunner := caseRunner.NewSession() sessionRunner := caseRunner.NewSession()
summary, err := sessionRunner.Start(nil) summary, err := sessionRunner.Start(nil)
assert.Nil(t, err) assert.Nil(t, err)
+13 -8
View File
@@ -307,11 +307,12 @@ func (ad *ADBDriver) TapXY(x, y float64, opts ...option.ActionOption) error {
func (ad *ADBDriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error { func (ad *ADBDriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
log.Info().Float64("x", x).Float64("y", y).Msg("ADBDriver.TapAbsXY") log.Info().Float64("x", x).Float64("y", y).Msg("ADBDriver.TapAbsXY")
var err error actionOptions := option.NewActionOptions(opts...)
x, y, err = handlerTapAbsXY(ad, x, y, opts...) x, y, err := preHandler_TapAbsXY(ad, actionOptions, x, y)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(ad, ACTION_TapAbsXY, actionOptions)
// adb shell input tap x y // adb shell input tap x y
xStr := fmt.Sprintf("%.1f", x) xStr := fmt.Sprintf("%.1f", x)
@@ -325,11 +326,12 @@ func (ad *ADBDriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
func (ad *ADBDriver) DoubleTap(x, y float64, opts ...option.ActionOption) error { func (ad *ADBDriver) DoubleTap(x, y float64, opts ...option.ActionOption) error {
log.Info().Float64("x", x).Float64("y", y).Msg("ADBDriver.DoubleTap") log.Info().Float64("x", x).Float64("y", y).Msg("ADBDriver.DoubleTap")
var err error actionOptions := option.NewActionOptions(opts...)
x, y, err = handlerDoubleTap(ad, x, y, opts...) x, y, err := preHandler_DoubleTap(ad, actionOptions, x, y)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(ad, ACTION_DoubleTapXY, actionOptions)
// adb shell input tap x y // adb shell input tap x y
xStr := fmt.Sprintf("%.1f", x) xStr := fmt.Sprintf("%.1f", x)
@@ -373,12 +375,13 @@ func (ad *ADBDriver) Drag(fromX, fromY, toX, toY float64, opts ...option.ActionO
log.Info().Float64("fromX", fromX).Float64("fromY", fromY). log.Info().Float64("fromX", fromX).Float64("fromY", fromY).
Float64("toX", toX).Float64("toY", toY).Msg("ADBDriver.Drag") Float64("toX", toX).Float64("toY", toY).Msg("ADBDriver.Drag")
fromX, fromY, toX, toY, err = handlerDrag(ad, fromX, fromY, toX, toY, opts...) actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err = preHandler_Drag(ad, actionOptions, fromX, fromY, toX, toY)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(ad, ACTION_Drag, actionOptions)
actionOptions := option.NewActionOptions(opts...)
duration := 200.0 duration := 200.0
if actionOptions.Duration > 0 { if actionOptions.Duration > 0 {
duration = actionOptions.Duration * 1000 duration = actionOptions.Duration * 1000
@@ -403,11 +406,13 @@ func (ad *ADBDriver) Drag(fromX, fromY, toX, toY float64, opts ...option.ActionO
func (ad *ADBDriver) Swipe(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error { func (ad *ADBDriver) Swipe(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error {
log.Info().Float64("fromX", fromX).Float64("fromY", fromY). log.Info().Float64("fromX", fromX).Float64("fromY", fromY).
Float64("toX", toX).Float64("toY", toY).Msg("ADBDriver.Swipe") Float64("toX", toX).Float64("toY", toY).Msg("ADBDriver.Swipe")
var err error
fromX, fromY, toX, toY, err = handlerSwipe(ad, fromX, fromY, toX, toY) actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err := preHandler_Swipe(ad, actionOptions, fromX, fromY, toX, toY)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(ad, ACTION_Swipe, actionOptions)
// adb shell input swipe fromX fromY toX toY // adb shell input swipe fromX fromY toX toY
_, err = ad.runShellCommand( _, err = ad.runShellCommand(
+14 -11
View File
@@ -257,11 +257,12 @@ func (ud *UIA2Driver) Orientation() (orientation types.Orientation, err error) {
func (ud *UIA2Driver) DoubleTap(x, y float64, opts ...option.ActionOption) error { func (ud *UIA2Driver) DoubleTap(x, y float64, opts ...option.ActionOption) error {
log.Info().Float64("x", x).Float64("y", y).Msg("UIA2Driver.DoubleTap") log.Info().Float64("x", x).Float64("y", y).Msg("UIA2Driver.DoubleTap")
var err error actionOptions := option.NewActionOptions(opts...)
x, y, err = handlerDoubleTap(ud, x, y, opts...) x, y, err := preHandler_DoubleTap(ud, actionOptions, x, y)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(ud, ACTION_DoubleTapXY, actionOptions)
data := map[string]interface{}{ data := map[string]interface{}{
"actions": []interface{}{ "actions": []interface{}{
@@ -298,14 +299,13 @@ func (ud *UIA2Driver) TapXY(x, y float64, opts ...option.ActionOption) error {
func (ud *UIA2Driver) TapAbsXY(x, y float64, opts ...option.ActionOption) error { func (ud *UIA2Driver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
log.Info().Float64("x", x).Float64("y", y).Msg("UIA2Driver.TapAbsXY") log.Info().Float64("x", x).Float64("y", y).Msg("UIA2Driver.TapAbsXY")
// register(postHandler, new Tap("/wd/hub/session/:sessionId/appium/tap")) // register(postHandler, new Tap("/wd/hub/session/:sessionId/appium/tap"))
actionOptions := option.NewActionOptions(opts...)
var err error x, y, err := preHandler_TapAbsXY(ud, actionOptions, x, y)
x, y, err = handlerTapAbsXY(ud, x, y, opts...)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(ud, ACTION_TapAbsXY, actionOptions)
actionOptions := option.NewActionOptions(opts...)
duration := 100.0 duration := 100.0
if actionOptions.PressDuration > 0 { if actionOptions.PressDuration > 0 {
duration = actionOptions.PressDuration * 1000 // convert to ms duration = actionOptions.PressDuration * 1000 // convert to ms
@@ -362,11 +362,12 @@ func (ud *UIA2Driver) Drag(fromX, fromY, toX, toY float64, opts ...option.Action
log.Info().Float64("fromX", fromX).Float64("fromY", fromY). log.Info().Float64("fromX", fromX).Float64("fromY", fromY).
Float64("toX", toX).Float64("toY", toY).Msg("UIA2Driver.Drag") Float64("toX", toX).Float64("toY", toY).Msg("UIA2Driver.Drag")
var err error actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err = handlerDrag(ud, fromX, fromY, toX, toY, opts...) fromX, fromY, toX, toY, err := preHandler_Drag(ud, actionOptions, fromX, fromY, toX, toY)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(ud, ACTION_Drag, actionOptions)
data := map[string]interface{}{ data := map[string]interface{}{
"startX": fromX, "startX": fromX,
@@ -391,12 +392,14 @@ func (ud *UIA2Driver) Swipe(fromX, fromY, toX, toY float64, opts ...option.Actio
// register(postHandler, new Swipe("/wd/hub/session/:sessionId/touch/perform")) // register(postHandler, new Swipe("/wd/hub/session/:sessionId/touch/perform"))
log.Info().Float64("fromX", fromX).Float64("fromY", fromY). log.Info().Float64("fromX", fromX).Float64("fromY", fromY).
Float64("toX", toX).Float64("toY", toY).Msg("UIA2Driver.Swipe") Float64("toX", toX).Float64("toY", toY).Msg("UIA2Driver.Swipe")
var err error
fromX, fromY, toX, toY, err = handlerSwipe(ud, fromX, fromY, toX, toY) actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err := preHandler_Swipe(ud, actionOptions, fromX, fromY, toX, toY)
if err != nil { if err != nil {
return err return err
} }
actionOptions := option.NewActionOptions(opts...) defer postHandler(ud, ACTION_Swipe, actionOptions)
duration := 200.0 duration := 200.0
if actionOptions.PressDuration > 0 { if actionOptions.PressDuration > 0 {
duration = actionOptions.PressDuration * 1000 // ms duration = actionOptions.PressDuration * 1000 // ms
+18
View File
@@ -8,6 +8,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -133,6 +134,23 @@ func TestDriver_ADB_TapXY(t *testing.T) {
assert.Nil(t, err) assert.Nil(t, err)
} }
func TestDriver_ADB_TapXY_WithHook(t *testing.T) {
driver := setupADBDriverExt(t)
err := driver.Call("pre hook", func() {
log.Info().Msg("pre hook")
}, option.WithTimeout(1))
assert.Nil(t, err)
err = driver.TapXY(0.4, 0.5)
assert.Nil(t, err)
err = driver.Call("post hook", func() {
log.Info().Msg("post hook")
}, option.WithTimeout(1))
assert.Nil(t, err)
}
func TestDriver_ADB_TapAbsXY(t *testing.T) { func TestDriver_ADB_TapAbsXY(t *testing.T) {
driver := setupADBDriverExt(t) driver := setupADBDriverExt(t)
err := driver.TapAbsXY(100, 300) err := driver.TapAbsXY(100, 300)
+11 -7
View File
@@ -114,17 +114,19 @@ func (wd *BrowserDriver) Setup() error {
} }
func (wd *BrowserDriver) Drag(fromX, fromY, toX, toY float64, options ...option.ActionOption) (err error) { func (wd *BrowserDriver) Drag(fromX, fromY, toX, toY float64, options ...option.ActionOption) (err error) {
fromX, fromY, toX, toY, err = handlerDrag(wd, fromX, fromY, toX, toY, options...) actionOptions := option.NewActionOptions(options...)
fromX, fromY, toX, toY, err = preHandler_Drag(wd, actionOptions, fromX, fromY, toX, toY)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(wd, ACTION_Drag, actionOptions)
data := map[string]interface{}{ data := map[string]interface{}{
"from_x": fromX, "from_x": fromX,
"from_y": fromY, "from_y": fromY,
"to_x": toX, "to_x": toX,
"to_y": toY, "to_y": toY,
} }
actionOptions := option.NewActionOptions(options...)
if actionOptions.Duration > 0 { if actionOptions.Duration > 0 {
data["duration"] = actionOptions.Duration data["duration"] = actionOptions.Duration
@@ -511,13 +513,13 @@ func (wd *BrowserDriver) Tap(x, y float64, options ...option.ActionOption) error
} }
func (wd *BrowserDriver) TapFloat(x, y float64, opts ...option.ActionOption) error { func (wd *BrowserDriver) TapFloat(x, y float64, opts ...option.ActionOption) error {
var err error actionOptions := option.NewActionOptions(opts...)
x, y, err = handlerTapAbsXY(wd, x, y, opts...) x, y, err := preHandler_TapAbsXY(wd, actionOptions, x, y)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(wd, ACTION_TapAbsXY, actionOptions)
actionOptions := option.NewActionOptions(opts...)
duration := 0.1 duration := 0.1
if actionOptions.Duration > 0 { if actionOptions.Duration > 0 {
duration = actionOptions.Duration duration = actionOptions.Duration
@@ -535,11 +537,13 @@ func (wd *BrowserDriver) TapFloat(x, y float64, opts ...option.ActionOption) err
// DoubleTap Sends a double tap event at the coordinate. // DoubleTap Sends a double tap event at the coordinate.
func (wd *BrowserDriver) DoubleTap(x, y float64, options ...option.ActionOption) error { func (wd *BrowserDriver) DoubleTap(x, y float64, options ...option.ActionOption) error {
var err error actionOptions := option.NewActionOptions(options...)
x, y, err = handlerDoubleTap(wd, x, y, options...) x, y, err := preHandler_DoubleTap(wd, actionOptions, x, y)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(wd, ACTION_DoubleTapXY, actionOptions)
data := map[string]interface{}{ data := map[string]interface{}{
"x": x, "x": x,
"y": y, "y": y,
+12 -4
View File
@@ -87,7 +87,7 @@ const (
type MobileAction struct { type MobileAction struct {
Method ActionMethod `json:"method,omitempty" yaml:"method,omitempty"` Method ActionMethod `json:"method,omitempty" yaml:"method,omitempty"`
Params interface{} `json:"params,omitempty" yaml:"params,omitempty"` Params interface{} `json:"params,omitempty" yaml:"params,omitempty"`
Fn func() `json:"-" yaml:"-"` // only used for function action, not serialized Fn func() `json:"-" yaml:"-"` // used for function action, not serialized
Options *option.ActionOptions `json:"options,omitempty" yaml:"options,omitempty"` Options *option.ActionOptions `json:"options,omitempty" yaml:"options,omitempty"`
option.ActionOptions option.ActionOptions
} }
@@ -306,6 +306,13 @@ func (dExt *XTDriver) DoAction(action MobileAction) (err error) {
} else if sd, ok := action.Params.(SleepConfig); ok { } else if sd, ok := action.Params.(SleepConfig); ok {
sleepStrict(sd.StartTime, int64(sd.Seconds*1000)) sleepStrict(sd.StartTime, int64(sd.Seconds*1000))
return nil return nil
} else if param, ok := action.Params.(string); ok {
seconds, err := builtin.ConvertToFloat64(param)
if err != nil {
return errors.Wrapf(err, "invalid sleep params: %v(%T)", action.Params, action.Params)
}
time.Sleep(time.Duration(seconds*1000) * time.Millisecond)
return nil
} }
return fmt.Errorf("invalid sleep params: %v(%T)", action.Params, action.Params) return fmt.Errorf("invalid sleep params: %v(%T)", action.Params, action.Params)
case ACTION_SleepMS: case ACTION_SleepMS:
@@ -335,9 +342,10 @@ func (dExt *XTDriver) DoAction(action MobileAction) (err error) {
case ACTION_ClosePopups: case ACTION_ClosePopups:
return dExt.ClosePopupsHandler() return dExt.ClosePopupsHandler()
case ACTION_CallFunction: case ACTION_CallFunction:
fn := action.Fn if funcDesc, ok := action.Params.(string); ok {
fn() return dExt.Call(funcDesc, action.Fn, action.GetOptions()...)
return nil }
return fmt.Errorf("invalid function description: %v", action.Params)
case ACTION_AIAction: case ACTION_AIAction:
if prompt, ok := action.Params.(string); ok { if prompt, ok := action.Params.(string); ok {
return dExt.AIAction(prompt, action.GetOptions()...) return dExt.AIAction(prompt, action.GetOptions()...)
+2 -2
View File
@@ -60,7 +60,7 @@ func (dExt *XTDriver) PlanNextAction(text string, opts ...option.ActionOption) (
return nil, errors.New("LLM service is not initialized") return nil, errors.New("LLM service is not initialized")
} }
compressedBufSource, err := dExt.GetScreenShotBuffer() compressedBufSource, err := getScreenShotBuffer(dExt.IDriver)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -118,7 +118,7 @@ func (dExt *XTDriver) AIAssert(assertion string, opts ...option.ActionOption) er
return errors.New("LLM service is not initialized") return errors.New("LLM service is not initialized")
} }
compressedBufSource, err := dExt.GetScreenShotBuffer() compressedBufSource, err := getScreenShotBuffer(dExt.IDriver)
if err != nil { if err != nil {
return err return err
} }
+25 -29
View File
@@ -48,28 +48,10 @@ func (s *ScreenResult) FilterTextsByScope(x1, y1, x2, y2 float64) ai.OCRTexts {
}) })
} }
func (dExt *XTDriver) GetScreenShotBuffer() (compressedBufSource *bytes.Buffer, err error) {
// take screenshot
bufSource, err := dExt.ScreenShot()
if err != nil {
return nil, errors.Wrapf(code.DeviceScreenShotError,
"take screenshot failed %v", err)
}
// compress screenshot
compressBufSource, err := compressImageBuffer(bufSource)
if err != nil {
return nil, errors.Wrapf(code.DeviceScreenShotError,
"compress screenshot failed %v", err)
}
return compressBufSource, nil
}
// GetScreenResult takes a screenshot, returns the image recognition result // GetScreenResult takes a screenshot, returns the image recognition result
func (dExt *XTDriver) GetScreenResult(opts ...option.ActionOption) (screenResult *ScreenResult, err error) { func (dExt *XTDriver) GetScreenResult(opts ...option.ActionOption) (screenResult *ScreenResult, err error) {
// get compressed screenshot buffer // get compressed screenshot buffer
compressBufSource, err := dExt.GetScreenShotBuffer() compressBufSource, err := getScreenShotBuffer(dExt.IDriver)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -220,6 +202,25 @@ func (dExt *XTDriver) FindUIResult(opts ...option.ActionOption) (uiResult ai.UIR
return return
} }
// getScreenShotBuffer takes a screenshot, returns the compressed image buffer
func getScreenShotBuffer(driver IDriver) (compressedBufSource *bytes.Buffer, err error) {
// take screenshot
bufSource, err := driver.ScreenShot()
if err != nil {
return nil, errors.Wrapf(code.DeviceScreenShotError,
"take screenshot failed %v", err)
}
// compress screenshot
compressBufSource, err := compressImageBuffer(bufSource)
if err != nil {
return nil, errors.Wrapf(code.DeviceScreenShotError,
"compress screenshot failed %v", err)
}
return compressBufSource, nil
}
// saveScreenShot saves compressed image file with file name // saveScreenShot saves compressed image file with file name
func saveScreenShot(raw *bytes.Buffer, screenshotPath string) error { func saveScreenShot(raw *bytes.Buffer, screenshotPath string) error {
// notice: screenshot data is a stream, so we need to copy it to a new buffer // notice: screenshot data is a stream, so we need to copy it to a new buffer
@@ -314,17 +315,16 @@ func MarkUIOperation(driver IDriver, actionType ActionMethod, actionCoordinates
} }
// create screenshot save path // create screenshot save path
timestamp := builtin.GenNameWithTimestamp("action_%d") timestamp := builtin.GenNameWithTimestamp("%d")
var imagePath string imagePath := filepath.Join(
config.GetConfig().ScreenShotsPath,
fmt.Sprintf("action_%s_pre_%s.png", timestamp, actionType),
)
if actionType == ACTION_TapAbsXY || actionType == ACTION_DoubleTapXY { if actionType == ACTION_TapAbsXY || actionType == ACTION_DoubleTapXY {
if len(actionCoordinates) != 2 { if len(actionCoordinates) != 2 {
return fmt.Errorf("invalid tap action coordinates: %v", actionCoordinates) return fmt.Errorf("invalid tap action coordinates: %v", actionCoordinates)
} }
imagePath = filepath.Join(
config.GetConfig().ScreenShotsPath,
fmt.Sprintf("%s_%s.png", timestamp, actionType),
)
x, y := actionCoordinates[0], actionCoordinates[1] x, y := actionCoordinates[0], actionCoordinates[1]
point := image.Point{X: int(x), Y: int(y)} point := image.Point{X: int(x), Y: int(y)}
err = SaveImageWithCircleMarker(compressedBufSource, point, imagePath) err = SaveImageWithCircleMarker(compressedBufSource, point, imagePath)
@@ -332,10 +332,6 @@ func MarkUIOperation(driver IDriver, actionType ActionMethod, actionCoordinates
if len(actionCoordinates) != 4 { if len(actionCoordinates) != 4 {
return fmt.Errorf("invalid swipe action coordinates: %v", actionCoordinates) return fmt.Errorf("invalid swipe action coordinates: %v", actionCoordinates)
} }
imagePath = filepath.Join(
config.GetConfig().ScreenShotsPath,
fmt.Sprintf("%s_%s.png", timestamp, actionType),
)
fromX, fromY := actionCoordinates[0], actionCoordinates[1] fromX, fromY := actionCoordinates[0], actionCoordinates[1]
toX, toY := actionCoordinates[2], actionCoordinates[3] toX, toY := actionCoordinates[2], actionCoordinates[3]
from := image.Point{X: int(fromX), Y: int(fromY)} from := image.Point{X: int(fromX), Y: int(fromY)}
+2 -2
View File
@@ -29,7 +29,7 @@ func (dExt *XTDriver) TapByOCR(text string, opts ...option.ActionOption) error {
point = textRect.Center() point = textRect.Center()
} }
log.Info().Str("text", text).Interface("rawTextRect", textRect). log.Info().Str("text", text).Interface("rawTextRect", textRect).
Interface("tapPoint", point).Msg("TapByOCR success") Interface("tapPoint", point).Msg("TapByOCR")
return dExt.TapAbsXY(point.X, point.Y, opts...) return dExt.TapAbsXY(point.X, point.Y, opts...)
} }
@@ -52,7 +52,7 @@ func (dExt *XTDriver) TapByCV(opts ...option.ActionOption) error {
point = uiResult.Center() point = uiResult.Center()
} }
log.Info().Interface("rawUIResult", uiResult). log.Info().Interface("rawUIResult", uiResult).
Interface("tapPoint", point).Msg("TapByCV success") Interface("tapPoint", point).Msg("TapByCV")
return dExt.TapAbsXY(point.X, point.Y, opts...) return dExt.TapAbsXY(point.X, point.Y, opts...)
} }
+1 -1
View File
@@ -287,7 +287,7 @@ func TestSaveImageWithArrow(t *testing.T) {
func TestMarkOperation(t *testing.T) { func TestMarkOperation(t *testing.T) {
driver := setupDriverExt(t) driver := setupDriverExt(t)
opts := []option.ActionOption{option.WithMarkOperationEnabled(true)} opts := []option.ActionOption{option.WithPreMarkOperation(true)}
// tap point // tap point
err := driver.TapXY(0.5, 0.5, opts...) err := driver.TapXY(0.5, 0.5, opts...)
+78 -17
View File
@@ -1,18 +1,56 @@
package uixt package uixt
import ( import (
"fmt"
"path/filepath"
"time"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/internal/config"
"github.com/httprunner/httprunner/v5/uixt/option" "github.com/httprunner/httprunner/v5/uixt/option"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func handlerTapAbsXY(driver IDriver, rawX, rawY float64, opts ...option.ActionOption) ( // Call custom function, used for pre/post action hook
func (dExt *XTDriver) Call(desc string, fn func(), opts ...option.ActionOption) error {
actionOptions := option.NewActionOptions(opts...)
startTime := time.Now()
defer func() {
log.Info().Str("desc", desc).
Int64("duration(ms)", time.Since(startTime).Milliseconds()).
Msg("function called")
}()
if actionOptions.Timeout == 0 {
// wait for function to finish
fn()
return nil
}
// set timeout for function execution
done := make(chan struct{})
go func() {
defer close(done)
fn()
}()
select {
case <-done:
// function completed within timeout
return nil
case <-time.After(time.Duration(actionOptions.Timeout) * time.Second):
return fmt.Errorf("function execution exceeded timeout of %d seconds", actionOptions.Timeout)
}
}
func preHandler_TapAbsXY(driver IDriver, options *option.ActionOptions, rawX, rawY float64) (
x, y float64, err error) { x, y float64, err error) {
actionOptions := option.NewActionOptions(opts...) x, y = options.ApplyTapOffset(rawX, rawY)
x, y = actionOptions.ApplyTapOffset(rawX, rawY)
// mark UI operation // mark UI operation
if actionOptions.MarkOperationEnabled { if options.PreMarkOperation {
if markErr := MarkUIOperation(driver, ACTION_TapAbsXY, []float64{x, y}); markErr != nil { if markErr := MarkUIOperation(driver, ACTION_TapAbsXY, []float64{x, y}); markErr != nil {
log.Warn().Err(markErr).Msg("Failed to mark tap operation") log.Warn().Err(markErr).Msg("Failed to mark tap operation")
} }
@@ -21,7 +59,7 @@ func handlerTapAbsXY(driver IDriver, rawX, rawY float64, opts ...option.ActionOp
return x, y, nil return x, y, nil
} }
func handlerDoubleTap(driver IDriver, rawX, rawY float64, opts ...option.ActionOption) ( func preHandler_DoubleTap(driver IDriver, options *option.ActionOptions, rawX, rawY float64) (
x, y float64, err error) { x, y float64, err error) {
x, y, err = convertToAbsolutePoint(driver, rawX, rawY) x, y, err = convertToAbsolutePoint(driver, rawX, rawY)
@@ -29,11 +67,10 @@ func handlerDoubleTap(driver IDriver, rawX, rawY float64, opts ...option.ActionO
return 0, 0, err return 0, 0, err
} }
actionOptions := option.NewActionOptions(opts...) x, y = options.ApplyTapOffset(x, y)
x, y = actionOptions.ApplyTapOffset(x, y)
// mark UI operation // mark UI operation
if actionOptions.MarkOperationEnabled { if options.PreMarkOperation {
if markErr := MarkUIOperation(driver, ACTION_DoubleTapXY, []float64{x, y}); markErr != nil { if markErr := MarkUIOperation(driver, ACTION_DoubleTapXY, []float64{x, y}); markErr != nil {
log.Warn().Err(markErr).Msg("Failed to mark double tap operation") log.Warn().Err(markErr).Msg("Failed to mark double tap operation")
} }
@@ -42,18 +79,17 @@ func handlerDoubleTap(driver IDriver, rawX, rawY float64, opts ...option.ActionO
return x, y, nil return x, y, nil
} }
func handlerDrag(driver IDriver, rawFomX, rawFromY, rawToX, rawToY float64, opts ...option.ActionOption) ( func preHandler_Drag(driver IDriver, options *option.ActionOptions, rawFomX, rawFromY, rawToX, rawToY float64) (
fromX, fromY, toX, toY float64, err error) { fromX, fromY, toX, toY float64, err error) {
actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err = convertToAbsoluteCoordinates(driver, rawFomX, rawFromY, rawToX, rawToY) fromX, fromY, toX, toY, err = convertToAbsoluteCoordinates(driver, rawFomX, rawFromY, rawToX, rawToY)
if err != nil { if err != nil {
return 0, 0, 0, 0, err return 0, 0, 0, 0, err
} }
fromX, fromY, toX, toY = actionOptions.ApplySwipeOffset(fromX, fromY, toX, toY) fromX, fromY, toX, toY = options.ApplySwipeOffset(fromX, fromY, toX, toY)
// mark UI operation // mark UI operation
if actionOptions.MarkOperationEnabled { if options.PreMarkOperation {
if markErr := MarkUIOperation(driver, ACTION_Drag, []float64{fromX, fromY, toX, toY}); markErr != nil { if markErr := MarkUIOperation(driver, ACTION_Drag, []float64{fromX, fromY, toX, toY}); markErr != nil {
log.Warn().Err(markErr).Msg("Failed to mark drag operation") log.Warn().Err(markErr).Msg("Failed to mark drag operation")
} }
@@ -62,18 +98,17 @@ func handlerDrag(driver IDriver, rawFomX, rawFromY, rawToX, rawToY float64, opts
return fromX, fromY, toX, toY, nil return fromX, fromY, toX, toY, nil
} }
func handlerSwipe(driver IDriver, rawFomX, rawFromY, rawToX, rawToY float64, opts ...option.ActionOption) ( func preHandler_Swipe(driver IDriver, options *option.ActionOptions, rawFomX, rawFromY, rawToX, rawToY float64) (
fromX, fromY, toX, toY float64, err error) { fromX, fromY, toX, toY float64, err error) {
actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err = convertToAbsoluteCoordinates(driver, rawFomX, rawFromY, rawToX, rawToY) fromX, fromY, toX, toY, err = convertToAbsoluteCoordinates(driver, rawFomX, rawFromY, rawToX, rawToY)
if err != nil { if err != nil {
return 0, 0, 0, 0, err return 0, 0, 0, 0, err
} }
fromX, fromY, toX, toY = actionOptions.ApplySwipeOffset(fromX, fromY, toX, toY) fromX, fromY, toX, toY = options.ApplySwipeOffset(fromX, fromY, toX, toY)
// mark UI operation // save screenshot before action and mark UI operation
if actionOptions.MarkOperationEnabled { if options.PreMarkOperation {
if markErr := MarkUIOperation(driver, ACTION_Swipe, []float64{fromX, fromY, toX, toY}); markErr != nil { if markErr := MarkUIOperation(driver, ACTION_Swipe, []float64{fromX, fromY, toX, toY}); markErr != nil {
log.Warn().Err(markErr).Msg("Failed to mark swipe operation") log.Warn().Err(markErr).Msg("Failed to mark swipe operation")
} }
@@ -81,3 +116,29 @@ func handlerSwipe(driver IDriver, rawFomX, rawFromY, rawToX, rawToY float64, opt
return fromX, fromY, toX, toY, nil return fromX, fromY, toX, toY, nil
} }
func postHandler(driver IDriver, actionType ActionMethod, options *option.ActionOptions) error {
// save screenshot after action
if options.PostMarkOperation {
// get compressed screenshot buffer
compressBufSource, err := getScreenShotBuffer(driver)
if err != nil {
return err
}
// save compressed screenshot to file
timestamp := builtin.GenNameWithTimestamp("%d")
imagePath := filepath.Join(
config.GetConfig().ScreenShotsPath,
fmt.Sprintf("action_%s_post_%s.png", timestamp, actionType),
)
go func() {
err := saveScreenShot(compressBufSource, imagePath)
if err != nil {
log.Error().Err(err).Msg("save screenshot file failed")
}
}()
}
return nil
}
+8 -7
View File
@@ -154,14 +154,13 @@ func (hd *HDCDriver) TapXY(x, y float64, opts ...option.ActionOption) error {
func (hd *HDCDriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error { func (hd *HDCDriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
log.Info().Float64("x", x).Float64("y", y).Msg("HDCDriver.TapAbsXY") log.Info().Float64("x", x).Float64("y", y).Msg("HDCDriver.TapAbsXY")
actionOptions := option.NewActionOptions(opts...)
var err error x, y, err := preHandler_TapAbsXY(hd, actionOptions, x, y)
x, y, err = handlerTapAbsXY(hd, x, y, opts...)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(hd, ACTION_TapAbsXY, actionOptions)
actionOptions := option.NewActionOptions(opts...)
if actionOptions.Identifier != "" { if actionOptions.Identifier != "" {
startTime := int(time.Now().UnixMilli()) startTime := int(time.Now().UnixMilli())
hd.points = append(hd.points, ExportPoint{Start: startTime, End: startTime + 100, Ext: actionOptions.Identifier, RunTime: 100}) hd.points = append(hd.points, ExportPoint{Start: startTime, End: startTime + 100, Ext: actionOptions.Identifier, RunTime: 100})
@@ -186,12 +185,14 @@ func (hd *HDCDriver) Drag(fromX, fromY, toX, toY float64, opts ...option.ActionO
func (hd *HDCDriver) Swipe(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error { func (hd *HDCDriver) Swipe(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error {
log.Info().Float64("fromX", fromX).Float64("fromY", fromY). log.Info().Float64("fromX", fromX).Float64("fromY", fromY).
Float64("toX", toX).Float64("toY", toY).Msg("HDCDriver.Swipe") Float64("toX", toX).Float64("toY", toY).Msg("HDCDriver.Swipe")
var err error
fromX, fromY, toX, toY, err = handlerSwipe(hd, fromX, fromY, toX, toY) actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err := preHandler_Swipe(hd, actionOptions, fromX, fromY, toX, toY)
if err != nil { if err != nil {
return err return err
} }
actionOptions := option.NewActionOptions(opts...) defer postHandler(hd, ACTION_Swipe, actionOptions)
duration := 200 duration := 200
if actionOptions.PressDuration > 0 { if actionOptions.PressDuration > 0 {
duration = int(actionOptions.PressDuration * 1000) duration = int(actionOptions.PressDuration * 1000)
+9 -6
View File
@@ -597,11 +597,12 @@ func (wd *WDADriver) TapAbsXY(x, y float64, opts ...option.ActionOption) error {
x = wd.toScale(x) x = wd.toScale(x)
y = wd.toScale(y) y = wd.toScale(y)
var err error actionOptions := option.NewActionOptions(opts...)
x, y, err = handlerTapAbsXY(wd, x, y, opts...) x, y, err := preHandler_TapAbsXY(wd, actionOptions, x, y)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(wd, ACTION_TapAbsXY, actionOptions)
data := map[string]interface{}{ data := map[string]interface{}{
"x": x, "x": x,
@@ -621,11 +622,12 @@ func (wd *WDADriver) DoubleTap(x, y float64, opts ...option.ActionOption) error
x = wd.toScale(x) x = wd.toScale(x)
y = wd.toScale(y) y = wd.toScale(y)
var err error actionOptions := option.NewActionOptions(opts...)
x, y, err = handlerDoubleTap(wd, x, y, opts...) x, y, err := preHandler_DoubleTap(wd, actionOptions, x, y)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(wd, ACTION_DoubleTapXY, actionOptions)
data := map[string]interface{}{ data := map[string]interface{}{
"x": x, "x": x,
@@ -657,11 +659,12 @@ func (wd *WDADriver) Drag(fromX, fromY, toX, toY float64, opts ...option.ActionO
toX = wd.toScale(toX) toX = wd.toScale(toX)
toY = wd.toScale(toY) toY = wd.toScale(toY)
var err error actionOptions := option.NewActionOptions(opts...)
fromX, fromY, toX, toY, err = handlerDrag(wd, fromX, fromY, toX, toY, opts...) fromX, fromY, toX, toY, err := preHandler_Drag(wd, actionOptions, fromX, fromY, toX, toY)
if err != nil { if err != nil {
return err return err
} }
defer postHandler(wd, ACTION_Drag, actionOptions)
data := map[string]interface{}{ data := map[string]interface{}{
"fromX": math.Round(fromX*10) / 10, "fromX": math.Round(fromX*10) / 10,
+17 -4
View File
@@ -5,6 +5,7 @@ import (
"math/rand/v2" "math/rand/v2"
"github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/rs/zerolog/log"
) )
type ActionOptions struct { type ActionOptions struct {
@@ -72,10 +73,22 @@ func (o *ActionOptions) Options() []ActionOption {
case []interface{}: case []interface{}:
// loaded from json case // loaded from json case
// custom direction: [fromX, fromY, toX, toY] // custom direction: [fromX, fromY, toX, toY]
sx, _ := builtin.Interface2Float64(v[0]) sx, err := builtin.Interface2Float64(v[0])
sy, _ := builtin.Interface2Float64(v[1]) if err != nil {
ex, _ := builtin.Interface2Float64(v[2]) log.Error().Err(err).Interface("fromX", v[0]).Msg("convert float64 failed")
ey, _ := builtin.Interface2Float64(v[3]) }
sy, err := builtin.Interface2Float64(v[1])
if err != nil {
log.Error().Err(err).Interface("fromY", v[1]).Msg("convert float64 failed")
}
ex, err := builtin.Interface2Float64(v[2])
if err != nil {
log.Error().Err(err).Interface("toX", v[2]).Msg("convert float64 failed")
}
ey, err := builtin.Interface2Float64(v[3])
if err != nil {
log.Error().Err(err).Interface("toY", v[3]).Msg("convert float64 failed")
}
options = append(options, WithCustomDirection( options = append(options, WithCustomDirection(
sx, sy, sx, sy,
ex, ey, ex, ey,
+17 -7
View File
@@ -277,8 +277,8 @@ func WithIndex(index int) ActionOption {
// MarkOperationOptions contains options for marking UI operations // MarkOperationOptions contains options for marking UI operations
type MarkOperationOptions struct { type MarkOperationOptions struct {
// mark UI operation, enable/disable UI operation marking PreMarkOperation bool `json:"pre_mark_operation,omitempty" yaml:"pre_mark_operation,omitempty"`
MarkOperationEnabled bool `json:"mark_operation_enabled,omitempty" yaml:"mark_operation_enabled,omitempty"` PostMarkOperation bool `json:"post_mark_operation,omitempty" yaml:"post_mark_operation,omitempty"`
} }
func (o *MarkOperationOptions) GetMarkOperationOptions() []ActionOption { func (o *MarkOperationOptions) GetMarkOperationOptions() []ActionOption {
@@ -287,16 +287,26 @@ func (o *MarkOperationOptions) GetMarkOperationOptions() []ActionOption {
return options return options
} }
if o.MarkOperationEnabled { if o.PreMarkOperation {
options = append(options, WithMarkOperationEnabled(true)) options = append(options, WithPreMarkOperation(true))
}
if o.PostMarkOperation {
options = append(options, WithPostMarkOperation(true))
} }
return options return options
} }
// WithMarkOperationEnabled enables or disables UI operation marking // WithPreMarkOperation enables UI operation marking before action
func WithMarkOperationEnabled(enabled bool) ActionOption { func WithPreMarkOperation(enabled bool) ActionOption {
return func(o *ActionOptions) { return func(o *ActionOptions) {
o.MarkOperationEnabled = enabled o.PreMarkOperation = enabled
}
}
// WithPostMarkOperation enables UI operation marking after action
func WithPostMarkOperation(enabled bool) ActionOption {
return func(o *ActionOptions) {
o.PostMarkOperation = enabled
} }
} }
+107
View File
@@ -0,0 +1,107 @@
package types
import (
"encoding/json"
"fmt"
"strconv"
)
// IntOrString supports int or string
type IntOrString struct {
IntValue *int // e.g 513
StringValue *string // e.g "513", "$var"
}
// Value returns the int value, converting from string if necessary
func (ios *IntOrString) Value() (int, error) {
if ios == nil {
return 0, nil
}
if ios.IntValue != nil {
return *ios.IntValue, nil
}
if ios.StringValue != nil {
if *ios.StringValue == "" {
return 0, nil
}
n, err := strconv.Atoi(*ios.StringValue)
if err != nil {
// variable expression, e.g. "$var"
return 0, err
}
return n, nil
}
// IntValue and StringValue are both nil
return 0, nil
}
// UnmarshalJSON implements custom JSON unmarshalling for IntOrString
func (ios *IntOrString) UnmarshalJSON(data []byte) error {
// Try to unmarshal as int
var i int
if err := json.Unmarshal(data, &i); err == nil {
ios.IntValue = &i
ios.StringValue = nil
return nil
}
// Try to unmarshal as string
var s string
if err := json.Unmarshal(data, &s); err == nil {
ios.StringValue = &s
ios.IntValue = nil
return nil
}
return fmt.Errorf("invalid IntOrString data: %s", string(data))
}
// FloatOrString supports float64 or string
type FloatOrString struct {
FloatValue *float64 // e.g 5.13
StringValue *string // e.g "5.13", "$var"
}
// Value returns the float value, converting from string if necessary
func (ios *FloatOrString) Value() (float64, error) {
if ios == nil {
return 0, nil
}
if ios.FloatValue != nil {
return *ios.FloatValue, nil
}
if ios.StringValue != nil {
if *ios.StringValue == "" {
return 0, nil
}
n, err := strconv.ParseFloat(*ios.StringValue, 64)
if err != nil {
// variable expression, e.g. "$var"
return 0, err
}
return n, nil
}
// IntValue and StringValue are both nil
return 0, nil
}
// UnmarshalJSON implements custom JSON unmarshalling for IntOrString
func (ios *FloatOrString) UnmarshalJSON(data []byte) error {
// Try to unmarshal as float
var f float64
if err := json.Unmarshal(data, &f); err == nil {
ios.FloatValue = &f
ios.StringValue = nil
return nil
}
// Try to unmarshal as string
var s string
if err := json.Unmarshal(data, &s); err == nil {
ios.StringValue = &s
ios.FloatValue = nil
return nil
}
return fmt.Errorf("invalid FloatOrString data: %s", string(data))
}